From f1dbe1b51d6144bb889b8e588930db2e80e5ebf4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 31 Oct 2015 17:01:20 +0100 Subject: [PATCH 001/976] deprecate api v1 --- website/src/routes/api-v1.js | 168 +----------------------------- website/src/routes/api-v2/auth.js | 3 - 2 files changed, 4 insertions(+), 167 deletions(-) diff --git a/website/src/routes/api-v1.js b/website/src/routes/api-v1.js index 1002391cae..0546571cef 100644 --- a/website/src/routes/api-v1.js +++ b/website/src/routes/api-v1.js @@ -1,173 +1,13 @@ var express = require('express'); var router = new express.Router(); -var _ = require('lodash'); -var async = require('async'); -var icalendar = require('icalendar'); -var api = require('./../controllers/api-v2/user'); -var auth = require('./../controllers/api-v2/auth'); -var logging = require('./../libs/logging'); -var i18n = require('./../libs/i18n'); -var forceRefresh = require('../middlewares/forceRefresh').middleware; +var nconf = require('nconf'); /* ---------- Deprecated API ------------*/ -var initDeprecated = function(req, res, next) { - req.headers['x-api-user'] = req.params.uid; - req.headers['x-api-key'] = req.body.apiToken; - return next(); -}; - -router.post('/v1/users/:uid/tasks/:taskId/:direction', initDeprecated, auth.auth, i18n.getUserLanguage, api.score); - -// FIXME add this back in -router.get('/v1/users/:uid/calendar.ics', i18n.getUserLanguage, function(req, res, next) { - return next() //disable for now - - var apiToken, model, query, uid; - uid = req.params.uid; - apiToken = req.query.apiToken; - model = req.getModel(); - query = model.query('users').withIdAndToken(uid, apiToken); - return query.fetch(function(err, result) { - var formattedIcal, ical, tasks, tasksWithDates; - if (err) { - return res.send(500, err); - } - tasks = result.get('tasks'); - /* tasks = result[0].tasks*/ - - tasksWithDates = _.filter(tasks, function(task) { - return !!task.date; - }); - if (_.isEmpty(tasksWithDates)) { - return res.send(500, "No events found"); - } - ical = new icalendar.iCalendar(); - ical.addProperty('NAME', 'HabitRPG'); - _.each(tasksWithDates, function(task) { - var d, event; - 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); - return true; - }); - res.type('text/calendar'); - formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:'); - return res.send(200, formattedIcal); +router.all('*', function deprecated(req, res, next) { + res.json(404, { + err: 'API v1 is no longer supported, please use API v2 instead ' + nconf.get('BASE_URL') + '/static/api' }); }); -/* - ------------------------------------------------------------------------ - Batch Update - This is super-deprecated, and will be removed once apiv2 is running against mobile for a while - ------------------------------------------------------------------------ - */ -var batchUpdate = function(req, res, next) { - var user = res.locals.user; - var oldSend = res.send; - var oldJson = res.json; - var performAction = function(action, cb) { - - // req.body=action.data; delete action.data; _.defaults(req.params, action) - // Would require changing action.dir on mobile app - req.params.id = action.data && action.data.id; - req.params.direction = action.dir; - req.params.type = action.type; - req.body = action.data; - res.send = res.json = function(code, data) { - if (_.isNumber(code) && code >= 400) { - logging.error({ - code: code, - data: data - }); - } - //FIXME send error messages down - return cb(); - }; - switch (action.op) { - case "score": - api.score(req, res); - break; - case "addTask": - api.addTask(req, res); - break; - case "delTask": - api.deleteTask(req, res); - break; - case "revive": - api.revive(req, res); - break; - default: - cb(); - break; - } - }; - - // Setup the array of functions we're going to call in parallel with async - var actions = _.transform(req.body || [], function(result, action) { - if (!_.isEmpty(action)) { - result.push(function(cb) { - performAction(action, cb); - }); - } - }); - - // call all the operations, then return the user object to the requester - async.series(actions, function(err) { - res.json = oldJson; - res.send = oldSend; - if (err) return res.json(500, {err: err}); - var response = user.toJSON(); - response.wasModified = res.locals.wasModified; - if (response._tmp && response._tmp.drop){ - res.json(200, {_tmp: {drop: response._tmp.drop}, _v: response._v}); - }else if(response.wasModified){ - res.json(200, response); - }else{ - res.json(200, {_v: response._v}); - } - }); -}; - -/* - ------------------------------------------------------------------------ - API v1 Routes - ------------------------------------------------------------------------ - */ - - -var cron = api.cron; - -router.get('/status', i18n.getUserLanguage, function(req, res) { - return res.json({ - status: 'up' - }); -}); - -// Scoring -router.post('/user/task/:id/:direction', auth.auth, i18n.getUserLanguage, cron, api.score); -router.post('/user/tasks/:id/:direction', auth.auth, i18n.getUserLanguage, cron, api.score); - -// Tasks -router.get('/user/tasks', auth.auth, i18n.getUserLanguage, cron, api.getTasks); -router.get('/user/task/:id', auth.auth, i18n.getUserLanguage, cron, api.getTask); -router.delete('/user/task/:id', auth.auth, i18n.getUserLanguage, cron, api.deleteTask); -router.post('/user/task', auth.auth, i18n.getUserLanguage, cron, api.addTask); - -// User -router.get('/user', auth.auth, i18n.getUserLanguage, cron, api.getUser); -router.post('/user/revive', auth.auth, i18n.getUserLanguage, cron, api.revive); -router.post('/user/batch-update', forceRefresh, auth.auth, i18n.getUserLanguage, cron, batchUpdate); - -function deprecated(req, res) { - res.json(404, {err:'API v1 is no longer supported, please use API v2 instead (https://github.com/HabitRPG/habitrpg/blob/develop/API.md)'}); -} -router.get('*', i18n.getUserLanguage, deprecated); -router.post('*', i18n.getUserLanguage, deprecated); -router.put('*', i18n.getUserLanguage, deprecated); - module.exports = router; \ No newline at end of file diff --git a/website/src/routes/api-v2/auth.js b/website/src/routes/api-v2/auth.js index 39f2f6e069..c60f44547b 100644 --- a/website/src/routes/api-v2/auth.js +++ b/website/src/routes/api-v2/auth.js @@ -15,7 +15,4 @@ router.post('/api/v2/user/change-username', i18n.getUserLanguage, auth.auth, aut router.post('/api/v2/user/change-email', i18n.getUserLanguage, auth.auth, auth.changeEmail); router.post('/api/v2/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken); -router.post('/api/v1/register', i18n.getUserLanguage, auth.registerUser); -router.post('/api/v1/user/auth/local', i18n.getUserLanguage, auth.loginLocal); -router.post('/api/v1/user/auth/social', i18n.getUserLanguage, auth.loginSocial); module.exports = router; \ No newline at end of file From b21df9edb3c3065b9a33cbf8f0e97069e3f63d81 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 1 Nov 2015 12:39:11 +0100 Subject: [PATCH 002/976] use node 4, npm 3, mongoose 4 and express 4 --- package.json | 18 ++++-- .../src/controllers/api-v2/unsubscription.js | 2 +- website/src/libs/i18n.js | 9 ++- website/src/server.js | 62 +++++++++++-------- 4 files changed, 57 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index a3fc0957cc..a9117cd89b 100644 --- a/package.json +++ b/package.json @@ -4,20 +4,24 @@ "version": "0.0.0-152", "main": "./website/src/server.js", "dependencies": { + "accepts": "^1.3.0", "amazon-payments": "0.0.4", "amplitude": "^2.0.1", "async": "~0.9.0", "aws-sdk": "^2.0.25", "babel": "^5.5.4", - "gulp-babel": "^5.2.1", + "body-parser": "^1.14.1", "bower": "~1.3.12", "browserify": "~3.30.2", "coffee-script": "1.6.x", "coffeeify": "0.6.0", + "compression": "^1.6.0", "connect-ratelimit": "0.0.7", + "cookie-parser": "^1.4.0", + "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", "domain-middleware": "~0.1.0", - "express": "~3.17.5", + "express": "~4.13.3", "express-csv": "~0.6.0", "firebase": "^2.2.9", "firebase-token-generator": "^2.0.0", @@ -34,6 +38,7 @@ "grunt-hashres": "~0.4.1", "grunt-karma": "~0.6.2", "gulp": "^3.9.0", + "gulp-babel": "^5.2.1", "gulp-clean": "^0.3.1", "gulp-eslint": "^1.0.0", "gulp-grunt": "^0.5.2", @@ -53,8 +58,9 @@ "merge-stream": "^1.0.0", "method-override": "~2.2.0", "moment": "~2.8.3", - "mongoose": "~3.8.23", + "mongoose": "~4.2.3", "mongoose-id-autoinc": "~2013.7.14-4", + "morgan": "^1.6.1", "nconf": "~0.6.9", "newrelic": "~1.23.0", "nib": "~1.0.1", @@ -71,6 +77,7 @@ "qs": "^2.3.2", "request": "~2.44.0", "s3-upload-stream": "^1.0.6", + "serve-favicon": "^2.3.0", "stripe": "*", "superagent": "~1.4.0", "swagger-node-express": "lefnire/swagger-node-express#habitrpg", @@ -82,8 +89,8 @@ }, "private": true, "engines": { - "node": "^0.10.40", - "npm": "^2.14.9" + "node": "^4.2.1", + "npm": "^3.3.10" }, "scripts": { "test": "gulp test", @@ -115,6 +122,7 @@ "karma-ng-html2js-preprocessor": "~0.1.0", "karma-phantomjs-launcher": "~0.1.0", "karma-requirejs": "~0.2.0", + "requirejs": "~2.1", "karma-script-launcher": "~0.1.0", "lcov-result-merger": "^1.0.2", "mocha": "^2.3.3", diff --git a/website/src/controllers/api-v2/unsubscription.js b/website/src/controllers/api-v2/unsubscription.js index f768236ee5..772d0580e9 100644 --- a/website/src/controllers/api-v2/unsubscription.js +++ b/website/src/controllers/api-v2/unsubscription.js @@ -15,7 +15,7 @@ api.unsubscribe = function(req, res, next){ $set: {'preferences.emailNotifications.unsubscribeFromAll': true} }, {multi: false}, function(err, updateRes){ if(err) return next(err); - if(updateRes !== 1) return res.json(404, {err: 'User not found'}); + if(updateRes.n !== 1) return res.json(404, {err: 'User not found'}); res.send('

' + i18n.t('unsubscribedSuccessfully', null, req.language) + '

' + i18n.t('unsubscribedTextUsers', null, req.language)); }); diff --git a/website/src/libs/i18n.js b/website/src/libs/i18n.js index f2170b453c..b769afe697 100644 --- a/website/src/libs/i18n.js +++ b/website/src/libs/i18n.js @@ -2,6 +2,7 @@ var fs = require('fs'), path = require('path'), _ = require('lodash'), User = require('../models/user').model, + accepts = require('accepts'), shared = require('../../../common'), translations = {}; @@ -54,7 +55,7 @@ _.each(langCodes, function(code){ lang.momentLangCode = (momentLangsMapping[code] || code); try{ // MomentJS lang files are JS files that has to be executed in the browser so we load them as plain text files - var f = fs.readFileSync(path.join(__dirname, '/../../../node_modules/moment/locale/' + lang.momentLangCode + '.js'), 'utf8'); + var f = fs.readFileSync(path.join(__dirname, '/../../node_modules/moment/locale/' + lang.momentLangCode + '.js'), 'utf8'); momentLangs[code] = f; }catch (e){} }); @@ -74,7 +75,9 @@ var chineseVersions = ['zh-tw']; var getUserLanguage = function(req, res, next){ var getFromBrowser = function(){ - var acceptable = _(req.acceptedLanguages).map(function(lang){ + var acceptedLanguages = accepts(req).languages(); + + var acceptable = _(acceptedLanguages).map(function(lang){ return lang.slice(0, 2); }).uniq().value(); @@ -83,7 +86,7 @@ var getUserLanguage = function(req, res, next){ var iAcceptedCompleteLang = (matches.length > 0) ? multipleVersionsLanguages.indexOf(matches[0].toLowerCase()) : -1; if(iAcceptedCompleteLang !== -1){ - var acceptedCompleteLang = _.find(req.acceptedLanguages, function(accepted){ + var acceptedCompleteLang = _.find(acceptedLanguages, function(accepted){ return accepted.slice(0, 2) == multipleVersionsLanguages[iAcceptedCompleteLang]; }); diff --git a/website/src/server.js b/website/src/server.js index 94bffe592b..8a25354e93 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -12,13 +12,14 @@ var cores = +nconf.get("WEB_CONCURRENCY") || 0; if (cores!==0 && cluster.isMaster && (isDev || isProd)) { // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) - _.times(cores, cluster.fork); + for (var i = 0; i < cores; i += 1) { + cluster.fork(); + } cluster.on('disconnect', function(worker, code, signal) { var w = cluster.fork(); // replace the dead worker logging.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid); }); - } else { require('coffee-script'); // remove this once we've fully converted over var express = require("express"); @@ -98,38 +99,60 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { var newApp = express(); // api v3 // Route requests to the right app - app.use(app.router); // Matches all request except the ones going to /api/v3/** app.all(/^(?!\/api\/v3).+/i, oldApp); // Matches all requests going to /api/v3 app.all('/api/v3', newApp); - require('./middlewares/apiThrottle')(oldApp); + //require('./middlewares/apiThrottle')(oldApp); oldApp.use(require('./middlewares/domain')(server,mongoose)); - if (!isProd && !DISABLE_LOGGING) oldApp.use(express.logger("dev")); - oldApp.use(express.compress()); + if (!isProd && !DISABLE_LOGGING) oldApp.use(require('morgan')("dev")); + oldApp.use(require('compression')()); oldApp.set("views", __dirname + "/../views"); oldApp.set("view engine", "jade"); - oldApp.use(express.favicon(publicDir + '/favicon.ico')); + oldApp.use(require('serve-favicon')(publicDir + '/favicon.ico')); oldApp.use(require('./middlewares/cors')); var redirects = require('./middlewares/redirects'); oldApp.use(redirects.forceHabitica); oldApp.use(redirects.forceSSL); - oldApp.use(express.urlencoded()); - oldApp.use(express.json()); + var bodyParser = require('body-parser'); + // Default limit is 100kb, need that because we actually send whole groups to the server + // FIXME as soon as possible (need to move on the client from $resource -> $http) + oldApp.use(bodyParser.urlencoded({ + limit: '1mb', + parameterLimit: 10000, // Upped for safety from 1k, FIXME as above + extended: true // Uses 'qs' library as old connect middleware + })); + oldApp.use(bodyParser.json({ + limit: '1mb' + })); oldApp.use(require('method-override')()); - //oldApp.use(express.cookieParser(nconf.get('SESSION_SECRET'))); - oldApp.use(express.cookieParser()); - oldApp.use(express.cookieSession({ secret: nconf.get('SESSION_SECRET'), httpOnly: false, cookie: { maxAge: TWO_WEEKS }})); - //oldApp.use(express.session()); + + oldApp.use(require('cookie-parser')()); + oldApp.use(require('cookie-session')({ + name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies + secret: nconf.get('SESSION_SECRET'), + httpOnly: false, + maxAge: TWO_WEEKS + })); // Initialize Passport! Also use passport.session() middleware, to support // persistent login sessions (recommended). oldApp.use(passport.initialize()); oldApp.use(passport.session()); - oldApp.use(oldApp.router); + // Custom Directives + oldApp.use(require('./routes/pages')); + oldApp.use(require('./routes/payments')); + oldApp.use(require('./routes/api-v2/auth')); + oldApp.use(require('./routes/api-v2/coupon')); + oldApp.use(require('./routes/api-v2/unsubscription')); + var v2 = express(); + oldApp.use('/api/v2', v2); + oldApp.use('/api/v1', require('./routes/api-v1')); + oldApp.use('/export', require('./routes/dataexport')); + require('./routes/api-v2/swagger')(swagger, v2); var maxAge = isProd ? 31536000000 : 0; // Cache emojis without copying them to build, they are too many @@ -140,17 +163,6 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { oldApp.use('/common/img', express['static'](publicDir + "/../../common/img", { maxAge: maxAge })); oldApp.use(express['static'](publicDir)); - // Custom Directives - oldApp.use(require('./routes/pages').middleware); - oldApp.use(require('./routes/payments').middleware); - oldApp.use(require('./routes/api-v2/auth').middleware); - oldApp.use(require('./routes/api-v2/coupon').middleware); - oldApp.use(require('./routes/api-v2/unsubscription').middleware); - var v2 = express(); - oldApp.use('/api/v2', v2); - oldApp.use('/api/v1', require('./routes/api-v1').middleware); - oldApp.use('/export', require('./routes/dataexport').middleware); - require('./routes/api-v2/swagger')(swagger, v2); oldApp.use(require('./middlewares/errorHandler')); server.on('request', app); From 4322b8c3dc011b1dc09394fee40640a0b00629c1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 Nov 2015 13:35:32 +0100 Subject: [PATCH 003/976] wip api-v3: add error handler middleware and custom error classes --- website/src/libs/api-v3/errors.js | 51 +++++++++++++++++++ .../src/middlewares/api-v3/errorHandler.js | 33 ++++++++++++ website/src/middlewares/api-v3/index.js | 11 ++++ website/src/server.js | 3 ++ 4 files changed, 98 insertions(+) create mode 100644 website/src/libs/api-v3/errors.js create mode 100644 website/src/middlewares/api-v3/errorHandler.js create mode 100644 website/src/middlewares/api-v3/index.js diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js new file mode 100644 index 0000000000..e8f9056484 --- /dev/null +++ b/website/src/libs/api-v3/errors.js @@ -0,0 +1,51 @@ +'use strict'; + +// Base class for custom application errors +// It extends Error and capture the stack trace +class CustomError extends Error { + constructor() { + super(); + Error.captureStackTrace(this, this.constructor); + } +}; + +// NotAuthorized error with a 401 http error code +// used when a request is not authorized +class NotAuthorized extends CustomError { + constructor(customMessage) { + super(); + this.name = this.constructor.name; + this.httpCode = 401; + this.message = customMessage || 'Not authorized.' + } +}; + +// BadRequest error with a 400 http error code +// used for requests not formatted correctly +// TODO use for validation errors too? +class BadRequest extends CustomError { + constructor(customMessage) { + super(); + this.name = this.constructor.name; + this.httpCode = 400; + this.message = customMessage || 'Bad request.' + } +}; + +// InternalError error with a 500 http error code +// used when an unexpected, internal server error is thrown +class InternalServerError extends CustomError { + constructor(customMessage) { + super(); + this.name = this.constructor.name; + this.httpCode = 500; + this.message = customMessage || 'Internal server error.' + } +}; + +module.exports = { + CustomError, + NotAuthorized, + BadRequest, + InternalServerError +}; \ No newline at end of file diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js new file mode 100644 index 0000000000..b964bcd6c9 --- /dev/null +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -0,0 +1,33 @@ +'use strict'; + +// The error handler middleware that handles all errors +// and respond to the client + +let errors = require('../../libs/api-v3/errors'); +let CustomError = errors.CustomError; +let InternalServerError = errors.InternalServerError; + +module.exports = function (err, req, res, next) { + // TODO add logging + + // In case of a CustomError class, use it's data + // Otherwise try to identify the type of error (mongoose validation, mongodb unique, ...) + // If we can't identify it, respond with a generic 500 error + + let responseErr = err instanceof CustomError ? err : null; + + if (!responseErr) { + // Try to identify the error... + // ... + // Otherwise create an InternalServerError and use it + // we don't want to leak anything, just a generic error message + responseErr = new InternalServerError(); + } + + return res + .status(responseErr.httpCode) + .json({ + error: responseErr.name, + message: responseErr.message + }); +}; \ No newline at end of file diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js new file mode 100644 index 0000000000..0ab9de4fe4 --- /dev/null +++ b/website/src/middlewares/api-v3/index.js @@ -0,0 +1,11 @@ +'use strict'; + +// This module is only used to attach middlewares to the express app + +let errorHandler = require('./errorHandler'); + +module.exports = function (app) { + + // Error handler middleware, define as the last one + app.use(errorHandler); +}; \ No newline at end of file diff --git a/website/src/server.js b/website/src/server.js index 8a25354e93..fdf0e9654f 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -104,6 +104,9 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { // Matches all requests going to /api/v3 app.all('/api/v3', newApp); + // Mount middlewares for the new app + require('./middlewares/api-v3/index')(newApp); + //require('./middlewares/apiThrottle')(oldApp); oldApp.use(require('./middlewares/domain')(server,mongoose)); if (!isProd && !DISABLE_LOGGING) oldApp.use(require('morgan')("dev")); From 7b4d082ab8f7374f0ed4be24d76c34d8b2057fce Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 Nov 2015 13:48:07 +0100 Subject: [PATCH 004/976] use node v4 in travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index bdbbc2c781..833c5834aa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: node_js node_js: - - '0.10' + - '4.2' before_install: - "sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 7F0CEB10" - "echo 'deb http://downloads-distro.mongodb.org/repo/ubuntu-upstart dist 10gen' | sudo tee /etc/apt/sources.list.d/mongodb.list" From 5f3e7980168bb0bebc5850748a3286ba442f7c00 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 Nov 2015 13:55:54 +0100 Subject: [PATCH 005/976] add basic .eslintrc and correct some style issues --- .eslintrc | 15 +++++++++++++++ website/src/libs/api-v3/errors.js | 14 +++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 .eslintrc diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000000..b10e9e46ac --- /dev/null +++ b/.eslintrc @@ -0,0 +1,15 @@ +{ + "rules": { + "indent": [2, 2], + "quotes": [2, "single"], + "linebreak-style": [2, "unix"], + "semi": [2, "always"], + "no-extra-parens": [2], + "no-unexpected-multiline": [2] + }, + "env": { + "es6": true, + "node": true + }, + "extends": "eslint:recommended" +} \ No newline at end of file diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index e8f9056484..c4afdd956c 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -7,7 +7,7 @@ class CustomError extends Error { super(); Error.captureStackTrace(this, this.constructor); } -}; +} // NotAuthorized error with a 401 http error code // used when a request is not authorized @@ -16,9 +16,9 @@ class NotAuthorized extends CustomError { super(); this.name = this.constructor.name; this.httpCode = 401; - this.message = customMessage || 'Not authorized.' + this.message = customMessage || 'Not authorized.'; } -}; +} // BadRequest error with a 400 http error code // used for requests not formatted correctly @@ -28,9 +28,9 @@ class BadRequest extends CustomError { super(); this.name = this.constructor.name; this.httpCode = 400; - this.message = customMessage || 'Bad request.' + this.message = customMessage || 'Bad request.'; } -}; +} // InternalError error with a 500 http error code // used when an unexpected, internal server error is thrown @@ -39,9 +39,9 @@ class InternalServerError extends CustomError { super(); this.name = this.constructor.name; this.httpCode = 500; - this.message = customMessage || 'Internal server error.' + this.message = customMessage || 'Internal server error.'; } -}; +} module.exports = { CustomError, From 5e73bc9f1c48669961cf39c9cfb1f4eec31f8554 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 Nov 2015 17:47:16 +0100 Subject: [PATCH 006/976] adds logger and starts logging errors --- package.json | 2 +- website/src/libs/api-v3/logger.js | 21 +++++++++++++++++++ .../src/middlewares/api-v3/errorHandler.js | 10 +++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 website/src/libs/api-v3/logger.js diff --git a/package.json b/package.json index a9117cd89b..7b08df56df 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "swagger-node-express": "lefnire/swagger-node-express#habitrpg", "universal-analytics": "~0.3.2", "validator": "~3.19.0", - "winston": "~0.8.0", + "winston": "~2.0.1", "winston-mail": "~0.2.9", "winston-newrelic": "~0.1.4" }, diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js new file mode 100644 index 0000000000..e40aed7b99 --- /dev/null +++ b/website/src/libs/api-v3/logger.js @@ -0,0 +1,21 @@ +'use strict'; + +// Logger utility +// TODO remove winston-mail and winston-newrelic if not used +let winston = require('winston'); +let nconf = require('nconf'); + +// TODO use const? +// TODO move isProd to a single location +let isProd = nconf.get('NODE_ENV') === 'production'; + +let logger = new winston.Logger(); + +if (isProd) { + // TODO production logging +} else { + logger + .add(winston.transports.Console); +} + +module.exports = logger; \ No newline at end of file diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index b964bcd6c9..bd4435b1cc 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -2,13 +2,19 @@ // The error handler middleware that handles all errors // and respond to the client - +let logger = require('../../libs/api-v3/logger'); let errors = require('../../libs/api-v3/errors'); let CustomError = errors.CustomError; let InternalServerError = errors.InternalServerError; module.exports = function (err, req, res, next) { - // TODO add logging + // Log the original error with some metadata + let stack = err.stack || err.message || err; + logging.error(stack, { + originalUrl: req.originalUrl, + headers: req.headers, + body: req.body + }); // In case of a CustomError class, use it's data // Otherwise try to identify the type of error (mongoose validation, mongodb unique, ...) From 182e4bff6f9e4a0f3b99d827bac478c91bc25e4a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 Nov 2015 18:08:14 +0100 Subject: [PATCH 007/976] wip api-v3: adds some comments and more eslint rules --- .eslintrc | 16 ++++++++++++++-- website/src/libs/api-v3/logger.js | 3 ++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.eslintrc b/.eslintrc index b10e9e46ac..d765d72851 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,8 +4,20 @@ "quotes": [2, "single"], "linebreak-style": [2, "unix"], "semi": [2, "always"], - "no-extra-parens": [2], - "no-unexpected-multiline": [2] + "no-extra-parens": 2, + "no-unexpected-multiline": 2, + "block-scoped-var": 2, + "dot-location": [2, "property"], + "dot-notation": 2, + "eqeqeq": 2, + "no-caller": 2, + "no-eval": 2, + "no-extend-native": 2, + "no-extra-bind": 2, + "no-fallthrough": 2, + "no-floating-decimal": 2, + "no-empty-pattern": 2, + "no-empty-label": 2 }, "env": { "es6": true, diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index e40aed7b99..34f0bf002b 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -12,7 +12,8 @@ let isProd = nconf.get('NODE_ENV') === 'production'; let logger = new winston.Logger(); if (isProd) { - // TODO production logging + // TODO production logging, use loggly + // log errors to console too } else { logger .add(winston.transports.Console); From cbca250b997d35973ecd01724d88e4b1aae63697 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 Nov 2015 18:10:42 +0100 Subject: [PATCH 008/976] remove winston-mail and winston-newrelic from deps as they may not be compatible with v2 --- package.json | 4 +--- website/src/libs/logging.js | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 7b08df56df..4bdb699e78 100644 --- a/package.json +++ b/package.json @@ -83,9 +83,7 @@ "swagger-node-express": "lefnire/swagger-node-express#habitrpg", "universal-analytics": "~0.3.2", "validator": "~3.19.0", - "winston": "~2.0.1", - "winston-mail": "~0.2.9", - "winston-newrelic": "~0.1.4" + "winston": "~2.0.1" }, "private": true, "engines": { diff --git a/website/src/libs/logging.js b/website/src/libs/logging.js index 25870b4ecd..c381873395 100644 --- a/website/src/libs/logging.js +++ b/website/src/libs/logging.js @@ -1,6 +1,6 @@ var nconf = require('nconf'); var winston = require('winston'); -require('winston-mail').Mail; +//require('winston-mail').Mail; //require('winston-newrelic'); var logger, loggly; @@ -26,7 +26,7 @@ if (logger == null) { logger = new (winston.Logger)({}); if (nconf.get('NODE_ENV') == 'production') { //logger.add(winston.transports.newrelic, {}); - if (!nconf.get('DISABLE_ERROR_EMAILS')) { + if (!nconf.get('DISABLE_ERROR_EMAILS') && false) { logger.add(winston.transports.Mail, { to: nconf.get('ADMIN_EMAIL') || nconf.get('SMTP_USER'), from: "HabitRPG <" + nconf.get('SMTP_USER') + ">", From 54d89a59e3d87ec49347a280c85f9f8d39646154 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 Nov 2015 22:22:33 +0100 Subject: [PATCH 009/976] add some eslint rules --- .eslintrc | 39 ++++++++++++++++++- .../src/middlewares/api-v3/errorHandler.js | 2 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.eslintrc b/.eslintrc index d765d72851..3a54b75b94 100644 --- a/.eslintrc +++ b/.eslintrc @@ -17,7 +17,44 @@ "no-fallthrough": 2, "no-floating-decimal": 2, "no-empty-pattern": 2, - "no-empty-label": 2 + "no-empty-label": 2, + "no-lone-blocks": 2, + "no-loop-func": 2, + "no-implicit-coercion": 2, + "no-implied-eval": 2, + "no-invalid-this": 2, + "no-magic-numbers": 2, + "no-native-reassign": 2, + "no-new-func": 2, + "no-new-wrappers": 2, + "no-new": 2, + "no-octal-escape": 2, + "no-octal": 2, + "no-param-reassign": 2, + "no-process-env": 2, + "no-proto": 2, + "no-implied-eval": 2, + "yoda": 2, + "wrap-iife": 2, + "radix": 2, + "no-with": 2, + "no-void": 2, + "no-useless-concat": 2, + "no-unused-expressions": 2, + "no-throw-literal": 2, + "no-sequences": 2, + "no-self-compare": 2, + "no-return-assign": 2, + "no-redeclare": 2, + "strict": [2, "global"], + "no-delete-var": 2, + "no-label-var": 2, + "no-shadow-restricted-names": 2, + "no-shadow": [2, { "builtinGlobals": true }], + "no-undef-init": 2, + "no-undef": [2, { typeof: true }], + "no-unused-vars": 2, + "no-use-before-define": 2 }, "env": { "es6": true, diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index bd4435b1cc..bdbaef9043 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -10,7 +10,7 @@ let InternalServerError = errors.InternalServerError; module.exports = function (err, req, res, next) { // Log the original error with some metadata let stack = err.stack || err.message || err; - logging.error(stack, { + logger.error(stack, { originalUrl: req.originalUrl, headers: req.headers, body: req.body From f02ef9b6be14cb710f2452723f0131b78d702b43 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 Nov 2015 22:46:05 +0100 Subject: [PATCH 010/976] add some eslint rules --- .eslintrc | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.eslintrc b/.eslintrc index 3a54b75b94..4906005f54 100644 --- a/.eslintrc +++ b/.eslintrc @@ -54,7 +54,23 @@ "no-undef-init": 2, "no-undef": [2, { typeof: true }], "no-unused-vars": 2, - "no-use-before-define": 2 + "no-use-before-define": 2, + "global-require": 2, + "handle-callback-err": [2, "^.*(e|E)rr"] + "no-path-concat": 2, + "arrow-spacing": 2, + "constructor-super": 2, + "generator-star-spacing": 2, + "no-arrow-condition": 2, + "no-class-assign": 2, + "no-const-assign": 2, + "no-dupe-class-members": 2, + "no-this-before-super": 2, + "no-var": 2, + "object-shorthand": 2, + "prefer-const": 2, + "prefer-spread": 2, + "prefer-template": 2 }, "env": { "es6": true, From 235f1977baf50a4329425759bfb696dc4ad48c71 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 4 Nov 2015 18:25:29 +0100 Subject: [PATCH 011/976] finish .eslintrc file and update gulp task --- .eslintrc | 46 ++++++++++++++++++++++++++++++++++++++++++-- tasks/gulp-eslint.js | 42 ++++++---------------------------------- 2 files changed, 50 insertions(+), 38 deletions(-) diff --git a/.eslintrc b/.eslintrc index 4906005f54..09b633ccd8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -56,7 +56,7 @@ "no-unused-vars": 2, "no-use-before-define": 2, "global-require": 2, - "handle-callback-err": [2, "^.*(e|E)rr"] + "handle-callback-err": [2, "^.*(e|E)rr"], "no-path-concat": 2, "arrow-spacing": 2, "constructor-super": 2, @@ -70,11 +70,53 @@ "object-shorthand": 2, "prefer-const": 2, "prefer-spread": 2, - "prefer-template": 2 + "prefer-template": 2, + "array-bracket-spacing": [2, "never"], + "brace-style": [2, "1tbs", { "allowSingleLine": false }], + "camel-case": 2, + "comma-spacing": 2, + "comma-style": [2, "last"], + "computed-property-spacing": [2, "never"], + "consistent-this": [2, "self"], + "func-names": 2, + "func-style": [2, "expression"], + "block-spacing": [2, "always"], + "key-spacing": [2, {"beforeColon": false, "afterColon": true}], + "max-nested-callbacks": [2, 3], + "new-cap": 2, + "new-parens": 2, + "newline-after-var": 2, + "no-array-constructor": 2, + "no-continue": 2, + "no-lonely-if": 2, + "no-mixed-spaces-and-tabs": 2, + "no-trailing-spaces": 2, + "no-spaced-func": 2, + "no-new-object": 2, + "no-nested-ternary": 2, + "one-var": [2, "never"], + "operator-linebreak": [2, "after"], + "quote-props": [2, "as-needed", { "keywords": true }], + "semi-spacing": [2, {"before": false, "after": true}], + "space-after-keyword": 2, + "space-before-blocks": 2, + "space-before-function-paren": 2, + "space-before-keywords": 2, + "space-in-parens": [2, "never"], + "space-infix-ops": 2, + "space-return-throw-case": 2, + "space-unary-ops": 2, + "spaced-comment": [2, "always", { exceptions: ["-"]}], + "padded-blocks": [2, "never"], + "no-multiple-empty-lines": [2, {max: 2}], + "lines-around-comment": [2, { "beforeBlockComment": true, "beforeLineComment": true }] }, "env": { "es6": true, "node": true }, + ecmaFeatures : { + modules: true + }, "extends": "eslint:recommended" } \ No newline at end of file diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index ec8866c4dc..cbb6c97928 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -1,45 +1,15 @@ import gulp from 'gulp'; import eslint from 'gulp-eslint'; -import _ from 'lodash'; - -// TODO remove once we upgrade to lodash 3 -const defaultsDeep = _.partialRight(_.merge, _.defaults); - -const shared = { - rules: { - indent: [2, 2], - quotes: [2, 'single'], - 'linebreak-style': [2, 'unix'], - semi: [2, 'always'] - }, - extends: 'eslint:recommended', - env: { - es6: true - } -}; - -gulp.task('lint:client', () => { - // Ignore .coffee files - return gulp.src(['./website/public/js/**/*.js']) - .pipe(eslint(defaultsDeep({ - env: { - node: true - } - }, shared))) - .pipe(eslint.format()) - .pipe(eslint.failAfterError()); -}); +// TODO lint client +// TDOO separate linting cong between gulp.task('lint:server', () => { // Ignore .coffee files - return gulp.src(['./website/src/**/*.js']) - .pipe(eslint(defaultsDeep({ - env: { - browser: true - } - }, shared))) + return gulp + .src(['./website/src/**/api-v3/**/*.js']) + .pipe(eslint()) .pipe(eslint.format()) .pipe(eslint.failAfterError()); }); -gulp.task('lint', ['lint:server', 'lint:client']); \ No newline at end of file +gulp.task('lint', ['lint:server']); \ No newline at end of file From 3135613be3ea7222b0aaa78dbdc42f07e7f1695f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 4 Nov 2015 18:50:08 +0100 Subject: [PATCH 012/976] tweaks eslint rules, fixes gulp-eslint, adds some comments --- .eslintrc | 8 ++++---- package.json | 1 + tasks/gulp-eslint.js | 5 ++++- website/src/libs/api-v3/errors.js | 8 ++++---- website/src/libs/api-v3/logger.js | 2 +- website/src/middlewares/api-v3/errorHandler.js | 7 ++++--- website/src/middlewares/api-v3/index.js | 3 +-- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.eslintrc b/.eslintrc index 09b633ccd8..a11da84ae9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -68,12 +68,12 @@ "no-this-before-super": 2, "no-var": 2, "object-shorthand": 2, - "prefer-const": 2, + "prefer-const": 0, "prefer-spread": 2, "prefer-template": 2, "array-bracket-spacing": [2, "never"], "brace-style": [2, "1tbs", { "allowSingleLine": false }], - "camel-case": 2, + "camelcase": 2, "comma-spacing": 2, "comma-style": [2, "last"], "computed-property-spacing": [2, "never"], @@ -98,7 +98,7 @@ "operator-linebreak": [2, "after"], "quote-props": [2, "as-needed", { "keywords": true }], "semi-spacing": [2, {"before": false, "after": true}], - "space-after-keyword": 2, + "space-after-keywords": 2, "space-before-blocks": 2, "space-before-function-paren": 2, "space-before-keywords": 2, @@ -109,7 +109,7 @@ "spaced-comment": [2, "always", { exceptions: ["-"]}], "padded-blocks": [2, "never"], "no-multiple-empty-lines": [2, {max: 2}], - "lines-around-comment": [2, { "beforeBlockComment": true, "beforeLineComment": true }] + "lines-around-comment": [2, { "beforeBlockComment": true, "beforeLineComment": true, "allowBlockStart": true }] }, "env": { "es6": true, diff --git a/package.json b/package.json index 4bdb699e78..480959e118 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", "domain-middleware": "~0.1.0", + "estraverse": "^4.1.1", "express": "~4.13.3", "express-csv": "~0.6.0", "firebase": "^2.2.9", diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index cbb6c97928..7a30f35ca1 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -2,7 +2,10 @@ import gulp from 'gulp'; import eslint from 'gulp-eslint'; // TODO lint client -// TDOO separate linting cong between +// TDOO separate linting cong between +// TODO lint gulp tasks, tests, ...? +// TODO what about prefer-const rule? +// TODO remove estraverse dependency once https://github.com/adametry/gulp-eslint/issues/117 sorted out gulp.task('lint:server', () => { // Ignore .coffee files return gulp diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index c4afdd956c..c1ebec5d57 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -3,7 +3,7 @@ // Base class for custom application errors // It extends Error and capture the stack trace class CustomError extends Error { - constructor() { + constructor () { super(); Error.captureStackTrace(this, this.constructor); } @@ -12,7 +12,7 @@ class CustomError extends Error { // NotAuthorized error with a 401 http error code // used when a request is not authorized class NotAuthorized extends CustomError { - constructor(customMessage) { + constructor (customMessage) { super(); this.name = this.constructor.name; this.httpCode = 401; @@ -24,7 +24,7 @@ class NotAuthorized extends CustomError { // used for requests not formatted correctly // TODO use for validation errors too? class BadRequest extends CustomError { - constructor(customMessage) { + constructor (customMessage) { super(); this.name = this.constructor.name; this.httpCode = 400; @@ -35,7 +35,7 @@ class BadRequest extends CustomError { // InternalError error with a 500 http error code // used when an unexpected, internal server error is thrown class InternalServerError extends CustomError { - constructor(customMessage) { + constructor (customMessage) { super(); this.name = this.constructor.name; this.httpCode = 500; diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 34f0bf002b..8a256a3461 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -5,7 +5,7 @@ let winston = require('winston'); let nconf = require('nconf'); -// TODO use const? +// TODO use const? // TODO move isProd to a single location let isProd = nconf.get('NODE_ENV') === 'production'; diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index bdbaef9043..833c03b508 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -1,21 +1,22 @@ 'use strict'; -// The error handler middleware that handles all errors +// The error handler middleware that handles all errors // and respond to the client let logger = require('../../libs/api-v3/logger'); let errors = require('../../libs/api-v3/errors'); let CustomError = errors.CustomError; let InternalServerError = errors.InternalServerError; -module.exports = function (err, req, res, next) { +module.exports = function errorHandlerMiddleware (err, req, res, next) { // Log the original error with some metadata let stack = err.stack || err.message || err; + logger.error(stack, { originalUrl: req.originalUrl, headers: req.headers, body: req.body }); - + // In case of a CustomError class, use it's data // Otherwise try to identify the type of error (mongoose validation, mongodb unique, ...) // If we can't identify it, respond with a generic 500 error diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 0ab9de4fe4..61ac54c4dc 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -4,8 +4,7 @@ let errorHandler = require('./errorHandler'); -module.exports = function (app) { - +module.exports = function attachMiddlewares (app) { // Error handler middleware, define as the last one app.use(errorHandler); }; \ No newline at end of file From bea5fd6e59a6cb0f3fc07853731985935af74bc7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 4 Nov 2015 18:58:50 +0100 Subject: [PATCH 013/976] add eslint task to tests --- tasks/gulp-tests.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 698651f790..6b3c757dce 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -333,6 +333,7 @@ gulp.task('test', [ 'test:karma:safe', 'test:api-legacy:safe', 'test:api-v2:safe', + 'lint' ], () => { let totals = [0,0,0]; From 0e8ad62607d9c8bf3c6fbd07c0af7d40e37b0b69 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 4 Nov 2015 20:44:21 -0600 Subject: [PATCH 014/976] Update nvmrc --- .nvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index 313d2e48ee..bf77d54968 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -0.10.40 \ No newline at end of file +4.2 From aaa4a7da12f984dd8b9979167279f0e881b97820 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 4 Nov 2015 20:45:51 -0600 Subject: [PATCH 015/976] Add sinon to helpers --- test/helpers/globals.helper.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index a731f1e8c2..74a9ae6045 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -4,6 +4,7 @@ global._ = require("lodash") global.chai = require("chai") +global.sinon = require("sinon"); chai.use(require("sinon-chai")) chai.use(require("chai-as-promised")); global.expect = chai.expect From a4f12f06f4c5fa0e1bbed02c7a8c75e05a5844a6 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 4 Nov 2015 20:46:23 -0600 Subject: [PATCH 016/976] Remove extraneous white space --- website/src/server.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/src/server.js b/website/src/server.js index fdf0e9654f..7daa1f2dcb 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -100,7 +100,7 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { // Route requests to the right app // Matches all request except the ones going to /api/v3/** - app.all(/^(?!\/api\/v3).+/i, oldApp); + app.all(/^(?!\/api\/v3).+/i, oldApp); // Matches all requests going to /api/v3 app.all('/api/v3', newApp); @@ -121,7 +121,7 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { oldApp.use(redirects.forceSSL); var bodyParser = require('body-parser'); // Default limit is 100kb, need that because we actually send whole groups to the server - // FIXME as soon as possible (need to move on the client from $resource -> $http) + // FIXME as soon as possible (need to move on the client from $resource -> $http) oldApp.use(bodyParser.urlencoded({ limit: '1mb', parameterLimit: 10000, // Upped for safety from 1k, FIXME as above @@ -129,7 +129,7 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { })); oldApp.use(bodyParser.json({ limit: '1mb' - })); + })); oldApp.use(require('method-override')()); oldApp.use(require('cookie-parser')()); @@ -138,7 +138,7 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { secret: nconf.get('SESSION_SECRET'), httpOnly: false, maxAge: TWO_WEEKS - })); + })); // Initialize Passport! Also use passport.session() middleware, to support // persistent login sessions (recommended). @@ -174,4 +174,4 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { }); module.exports = server; -} \ No newline at end of file +} From 2f6f0eb9337ccdace68c5566f957032b27bee3cb Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 4 Nov 2015 20:46:37 -0600 Subject: [PATCH 017/976] Add Babel require hook --- website/src/server.js | 1 + 1 file changed, 1 insertion(+) diff --git a/website/src/server.js b/website/src/server.js index 7daa1f2dcb..5d44dcd1b0 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -1,3 +1,4 @@ +require('babel/register'); // Only do the minimal amount of work before forking just in case of a dyno restart var cluster = require("cluster"); var _ = require('lodash'); From d5736e1178a201beb821220aefeca86bc7cd347f Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 4 Nov 2015 20:47:32 -0600 Subject: [PATCH 018/976] Convert classes to use Babel syntax --- website/src/libs/api-v3/errors.js | 17 ++++------------- website/src/libs/api-v3/logger.js | 11 ++++------- website/src/middlewares/api-v3/errorHandler.js | 18 ++++++++---------- website/src/middlewares/api-v3/index.js | 9 ++++----- 4 files changed, 20 insertions(+), 35 deletions(-) diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index c1ebec5d57..1439669d20 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -1,8 +1,6 @@ -'use strict'; - // Base class for custom application errors // It extends Error and capture the stack trace -class CustomError extends Error { +export class CustomError extends Error { constructor () { super(); Error.captureStackTrace(this, this.constructor); @@ -11,7 +9,7 @@ class CustomError extends Error { // NotAuthorized error with a 401 http error code // used when a request is not authorized -class NotAuthorized extends CustomError { +export class NotAuthorized extends CustomError { constructor (customMessage) { super(); this.name = this.constructor.name; @@ -23,7 +21,7 @@ class NotAuthorized extends CustomError { // BadRequest error with a 400 http error code // used for requests not formatted correctly // TODO use for validation errors too? -class BadRequest extends CustomError { +export class BadRequest extends CustomError { constructor (customMessage) { super(); this.name = this.constructor.name; @@ -34,7 +32,7 @@ class BadRequest extends CustomError { // InternalError error with a 500 http error code // used when an unexpected, internal server error is thrown -class InternalServerError extends CustomError { +export class InternalServerError extends CustomError { constructor (customMessage) { super(); this.name = this.constructor.name; @@ -42,10 +40,3 @@ class InternalServerError extends CustomError { this.message = customMessage || 'Internal server error.'; } } - -module.exports = { - CustomError, - NotAuthorized, - BadRequest, - InternalServerError -}; \ No newline at end of file diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 8a256a3461..b94d492b68 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -1,13 +1,10 @@ -'use strict'; - // Logger utility // TODO remove winston-mail and winston-newrelic if not used -let winston = require('winston'); -let nconf = require('nconf'); +import winston from 'winston'; +import nconf from 'nconf'; -// TODO use const? // TODO move isProd to a single location -let isProd = nconf.get('NODE_ENV') === 'production'; +const isProd = nconf.get('NODE_ENV') === 'production'; let logger = new winston.Logger(); @@ -19,4 +16,4 @@ if (isProd) { .add(winston.transports.Console); } -module.exports = logger; \ No newline at end of file +export default logger; diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 833c03b508..d673e7af1c 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -1,20 +1,18 @@ -'use strict'; - // The error handler middleware that handles all errors // and respond to the client -let logger = require('../../libs/api-v3/logger'); -let errors = require('../../libs/api-v3/errors'); -let CustomError = errors.CustomError; -let InternalServerError = errors.InternalServerError; +import logger from '../../libs/api-v3/logger'; +import { + CustomError, + InternalServerError, +} from '../../libs/api-v3/errors'; -module.exports = function errorHandlerMiddleware (err, req, res, next) { +export default function errorHandler (err, req, res, next) { // Log the original error with some metadata let stack = err.stack || err.message || err; - logger.error(stack, { originalUrl: req.originalUrl, headers: req.headers, - body: req.body + body: req.body, }); // In case of a CustomError class, use it's data @@ -37,4 +35,4 @@ module.exports = function errorHandlerMiddleware (err, req, res, next) { error: responseErr.name, message: responseErr.message }); -}; \ No newline at end of file +}; diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 61ac54c4dc..54410c09a2 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -1,10 +1,9 @@ -'use strict'; - // This module is only used to attach middlewares to the express app -let errorHandler = require('./errorHandler'); +import errorHandler from './errorHandler'; + +export default function middleware (app) { -module.exports = function attachMiddlewares (app) { // Error handler middleware, define as the last one app.use(errorHandler); -}; \ No newline at end of file +}; From 87eb63ba9bedda9afe9ba7cecc620bce77409e7a Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 4 Nov 2015 20:48:39 -0600 Subject: [PATCH 019/976] Add case where errorhandlerr does not receive an error --- website/src/middlewares/api-v3/errorHandler.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index d673e7af1c..d15d33fb00 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -7,6 +7,8 @@ import { } from '../../libs/api-v3/errors'; export default function errorHandler (err, req, res, next) { + if (!err) return next(); + // Log the original error with some metadata let stack = err.stack || err.message || err; logger.error(stack, { From 77d60cd6165589bb526bb01c3d69f71363dc453f Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 4 Nov 2015 20:49:08 -0600 Subject: [PATCH 020/976] Add tests for error handling --- test/api/v3/unit/libs/errors.test.js | 94 +++++++++++++++++++ .../v3/unit/middlewares/errorHandler.test.js | 77 +++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 test/api/v3/unit/libs/errors.test.js create mode 100644 test/api/v3/unit/middlewares/errorHandler.test.js diff --git a/test/api/v3/unit/libs/errors.test.js b/test/api/v3/unit/libs/errors.test.js new file mode 100644 index 0000000000..e859d2bb02 --- /dev/null +++ b/test/api/v3/unit/libs/errors.test.js @@ -0,0 +1,94 @@ +import { + CustomError, + NotAuthorized, + BadRequest, + InternalServerError, +} from '../../../../../website/src/libs/api-v3/errors'; + +describe('Custom Errors', () => { + describe('CustomError', () => { + it('is an instance of Error', () => { + let customError = new CustomError(); + + expect(customError).to.be.an.instanceOf(Error); + }); + }); + + describe('NotAuthorized', () => { + it('is an instance of CustomError', () => { + let notAuthorizedError = new NotAuthorized(); + + expect(notAuthorizedError).to.be.an.instanceOf(CustomError); + }); + + it('it returns an http code of 400', () => { + let notAuthorizedError = new NotAuthorized(); + + expect(notAuthorizedError.httpCode).to.eql(401); + }); + + it('returns a default message', () => { + let notAuthorizedError = new NotAuthorized(); + + expect(notAuthorizedError.message).to.eql('Not authorized.'); + }); + + it('allows a custom message', () => { + let notAuthorizedError = new NotAuthorized('Custom Error Message'); + + expect(notAuthorizedError.message).to.eql('Custom Error Message'); + }); + }); + + describe('BadRequest', () => { + it('is an instance of CustomError', () => { + let badRequestError = new BadRequest(); + + expect(badRequestError).to.be.an.instanceOf(CustomError); + }); + + it('it returns an http code of 401', () => { + let badRequestError = new BadRequest(); + + expect(badRequestError.httpCode).to.eql(400); + }); + + it('returns a default message', () => { + let badRequestError = new BadRequest(); + + expect(badRequestError.message).to.eql('Bad request.'); + }); + + it('allows a custom message', () => { + let badRequestError = new BadRequest('Custom Error Message'); + + expect(badRequestError.message).to.eql('Custom Error Message'); + }); + }); + + describe('InternalServerError', () => { + it('is an instance of CustomError', () => { + let internalServerError = new InternalServerError(); + + expect(internalServerError).to.be.an.instanceOf(CustomError); + }); + + it('it returns an http code of 500', () => { + let internalServerError = new InternalServerError(); + + expect(internalServerError.httpCode).to.eql(500); + }); + + it('returns a default message', () => { + let internalServerError = new InternalServerError(); + + expect(internalServerError.message).to.eql('Internal server error.'); + }); + + it('allows a custom message', () => { + let internalServerError = new InternalServerError('Custom Error Message'); + + expect(internalServerError.message).to.eql('Custom Error Message'); + }); + }); +}); diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js new file mode 100644 index 0000000000..39e72b2e45 --- /dev/null +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -0,0 +1,77 @@ +import errorHandler from '../../../../../website/src/middlewares/api-v3/errorHandler'; + +import { BadRequest } from '../../../../../website/src/libs/api-v3/errors'; +import logger from '../../../../../website/src/libs/api-v3/logger'; + +describe('errorHandler', () => { + let res, req; + + beforeEach(() => { + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + req = { + originalUrl: 'foo', + headers: {}, + body: {}, + }; + + sinon.stub(logger, 'error'); + }); + + afterEach(() => { + logger.error.restore(); + }); + + it('sends internal server error if error is not a CustomError', () => { + let error = new Error(); + + errorHandler(error, req, res); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(500); + expect(res.json).to.be.calledWith({ + error: 'InternalServerError', + message: 'Internal server error.', + }); + }); + + it('sends CustomError', () => { + let error = new BadRequest(); + + errorHandler(error, req, res); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(400); + expect(res.json).to.be.calledWith({ + error: 'BadRequest', + message: 'Bad request.', + }); + }); + + it('logs error', () => { + let error = new BadRequest(); + + errorHandler(error, req, res); + + expect(logger.error).to.be.calledOnce; + expect(logger.error).to.be.calledWith(error.stack, { + originalUrl: req.originalUrl, + headers: req.headers, + body: req.body, + }); + }); + + it('does not send error if error is not defined', () => { + let next = sinon.stub(); + errorHandler(null, req, res, next); + + expect(next).to.be.calledOnce; + expect(res.status).to.not.be.called; + }); +}); From 292177e51fb87daf2d5132e3e14ae5269286dbd3 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 4 Nov 2015 21:19:29 -0600 Subject: [PATCH 021/976] Update eslintrc --- .eslintrc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.eslintrc b/.eslintrc index a11da84ae9..2b2ebd92ab 100644 --- a/.eslintrc +++ b/.eslintrc @@ -76,10 +76,11 @@ "camelcase": 2, "comma-spacing": 2, "comma-style": [2, "last"], + "comma-dangle": [2, "always-multiline"], "computed-property-spacing": [2, "never"], "consistent-this": [2, "self"], "func-names": 2, - "func-style": [2, "expression"], + "func-style": [2, "declaration", { "allowArrowFunctions": true }], "block-spacing": [2, "always"], "key-spacing": [2, {"beforeColon": false, "afterColon": true}], "max-nested-callbacks": [2, 3], @@ -108,8 +109,7 @@ "space-unary-ops": 2, "spaced-comment": [2, "always", { exceptions: ["-"]}], "padded-blocks": [2, "never"], - "no-multiple-empty-lines": [2, {max: 2}], - "lines-around-comment": [2, { "beforeBlockComment": true, "beforeLineComment": true, "allowBlockStart": true }] + "no-multiple-empty-lines": [2, {max: 2}] }, "env": { "es6": true, @@ -119,4 +119,4 @@ modules: true }, "extends": "eslint:recommended" -} \ No newline at end of file +} From 5cd3f0dd6ef8259d1880a00c2ded69c1f1a9b494 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 4 Nov 2015 21:19:58 -0600 Subject: [PATCH 022/976] Fix eslint styling errors. --- website/src/middlewares/api-v3/errorHandler.js | 5 +++-- website/src/middlewares/api-v3/index.js | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index d15d33fb00..4950d09e09 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -11,6 +11,7 @@ export default function errorHandler (err, req, res, next) { // Log the original error with some metadata let stack = err.stack || err.message || err; + logger.error(stack, { originalUrl: req.originalUrl, headers: req.headers, @@ -35,6 +36,6 @@ export default function errorHandler (err, req, res, next) { .status(responseErr.httpCode) .json({ error: responseErr.name, - message: responseErr.message + message: responseErr.message, }); -}; +} diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 54410c09a2..264f102dec 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -3,7 +3,6 @@ import errorHandler from './errorHandler'; export default function middleware (app) { - // Error handler middleware, define as the last one app.use(errorHandler); -}; +} From 07df642f15f7b39d1cb71ceeda3fba0301906b69 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 4 Nov 2015 21:27:19 -0600 Subject: [PATCH 023/976] Add gulp tasks for v3 testing --- tasks/gulp-tests.js | 48 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 6b3c757dce..8264f91321 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -18,6 +18,7 @@ let server; const TEST_DB_URI = `mongodb://localhost/${TEST_DB}` const API_V2_TEST_COMMAND = 'mocha test/api/v2 --recursive --compilers js:babel/register'; +const API_V3_TEST_COMMAND = 'mocha test/api/v3 --recursive --compilers js:babel/register'; const LEGACY_API_TEST_COMMAND = 'mocha test/api-legacy'; const COMMON_TEST_COMMAND = 'mocha test/common --compilers coffee:coffee-script'; const CONTENT_TEST_COMMAND = 'mocha test/content --compilers js:babel/register'; @@ -296,7 +297,6 @@ gulp.task('test:e2e:safe', ['test:prepare', 'test:prepare:server'], (cb) => { }); gulp.task('test:api-v2', ['test:prepare:server'], (done) => { - awaitPort(TEST_SERVER_PORT).then(() => { runMochaTests('./test/api/v2/**/*.js', server, done) }); @@ -313,7 +313,48 @@ gulp.task('test:api-v2:safe', ['test:prepare:server'], (done) => { testBin(API_V2_TEST_COMMAND), (err, stdout, stderr) => { testResults.push({ - suite: 'API Specs\t', + suite: 'API V2 Specs\t', + pass: testCount(stdout, /(\d+) passing/), + fail: testCount(stderr, /(\d+) failing/), + pend: testCount(stdout, /(\d+) pending/) + }); + done(); + } + ); + pipe(runner); + }); +}); + +gulp.task('test:api-v3', ['test:api-v3:unit', 'test:api-v3:integration']); + +gulp.task('test:api-v3:watch', ['test:api-v3:unit:watch', 'test:api-v3:integration:watch']); + +gulp.task('test:api-v3:unit', ['test:prepare:server'], (done) => { + runMochaTests('./test/api/v3/unit/**/*.js', null, done) +}); + +gulp.task('test:api-v3:unit:watch', ['test:prepare:server'], () => { + gulp.watch(['website/src/**', 'test/api/v3/unit/**'], ['test:api-v3:unit']); +}); + +gulp.task('test:api-v3:integration', ['test:prepare:server'], (done) => { + awaitPort(TEST_SERVER_PORT).then(() => { + runMochaTests('./test/api/v3/unit/**/*.js', server, done) + }); +}); + +gulp.task('test:api-v3:integration:watch', ['test:prepare:server'], () => { + process.env.RUN_INTEGRATION_TEST_FOREVER = true; + gulp.watch(['website/src/**', 'test/api/v3/integration/**'], ['test:api-v3:integration']); +}); + +gulp.task('test:api-v3:safe', ['test:prepare:server'], (done) => { + awaitPort(TEST_SERVER_PORT).then(() => { + let runner = exec( + testBin(API_V3_TEST_COMMAND), + (err, stdout, stderr) => { + testResults.push({ + suite: 'API V3 Specs\t', pass: testCount(stdout, /(\d+) passing/), fail: testCount(stderr, /(\d+) failing/), pend: testCount(stdout, /(\d+) pending/) @@ -326,6 +367,7 @@ gulp.task('test:api-v2:safe', ['test:prepare:server'], (done) => { }); gulp.task('test', [ + 'lint', 'test:e2e:safe', 'test:common:safe', // 'test:content:safe', @@ -333,7 +375,7 @@ gulp.task('test', [ 'test:karma:safe', 'test:api-legacy:safe', 'test:api-v2:safe', - 'lint' + 'test:api-v3:safe', ], () => { let totals = [0,0,0]; From 8967e6ee9093f67e76634228669497653b5ba416 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 5 Nov 2015 08:46:20 -0600 Subject: [PATCH 024/976] Give function more meaningful name. --- website/src/middlewares/api-v3/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 264f102dec..460e4c53e4 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -2,7 +2,7 @@ import errorHandler from './errorHandler'; -export default function middleware (app) { +export default function attachMiddlewares (app) { // Error handler middleware, define as the last one app.use(errorHandler); } From 18328c0c4265ca21fc2d920a55cf743bf1ac5053 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 6 Nov 2015 21:27:38 -0600 Subject: [PATCH 025/976] Add lodash.deepDefaults as a dependency --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 480959e118..ba6d1d5edf 100644 --- a/package.json +++ b/package.json @@ -121,13 +121,14 @@ "karma-ng-html2js-preprocessor": "~0.1.0", "karma-phantomjs-launcher": "~0.1.0", "karma-requirejs": "~0.2.0", - "requirejs": "~2.1", "karma-script-launcher": "~0.1.0", "lcov-result-merger": "^1.0.2", + "lodash.defaultsdeep": "^3.10.0", "mocha": "^2.3.3", "mongodb": "^2.0.46", "mongoskin": "~0.6.1", "protractor": "~2.0.0", + "requirejs": "~2.1", "rewire": "^2.3.3", "shelljs": "^0.4.0", "sinon": "^1.17.2", From 303b88b6fae88efb095152dcd165e68466832fb4 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 6 Nov 2015 21:28:19 -0600 Subject: [PATCH 026/976] Adjust api tests --- tasks/gulp-tests.js | 6 ++++-- test/helpers/api-integration.helper.js | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 8264f91321..1fb2242e5f 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -297,6 +297,7 @@ gulp.task('test:e2e:safe', ['test:prepare', 'test:prepare:server'], (cb) => { }); gulp.task('test:api-v2', ['test:prepare:server'], (done) => { + process.env.API_VERSION = 'v2'; awaitPort(TEST_SERVER_PORT).then(() => { runMochaTests('./test/api/v2/**/*.js', server, done) }); @@ -329,15 +330,16 @@ gulp.task('test:api-v3', ['test:api-v3:unit', 'test:api-v3:integration']); gulp.task('test:api-v3:watch', ['test:api-v3:unit:watch', 'test:api-v3:integration:watch']); -gulp.task('test:api-v3:unit', ['test:prepare:server'], (done) => { +gulp.task('test:api-v3:unit', (done) => { runMochaTests('./test/api/v3/unit/**/*.js', null, done) }); -gulp.task('test:api-v3:unit:watch', ['test:prepare:server'], () => { +gulp.task('test:api-v3:unit:watch', () => { gulp.watch(['website/src/**', 'test/api/v3/unit/**'], ['test:api-v3:unit']); }); gulp.task('test:api-v3:integration', ['test:prepare:server'], (done) => { + process.env.API_VERSION = 'v3'; awaitPort(TEST_SERVER_PORT).then(() => { runMochaTests('./test/api/v3/unit/**/*.js', server, done) }); diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index b2b0ac99c6..1ddaa75b0d 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -208,9 +208,10 @@ export function resetHabiticaDB() { } function _requestMaker(user, method, additionalSets) { + const API_V = process.env.API_VERSION || 'v2' return (route, send, query) => { return new Promise((resolve, reject) => { - let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/v2${route}`) + let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/${API_V}${route}`) .accept('application/json'); if (user && user._id && user.apiToken) { From 6e344ce04b1eb8c7a386e608deb76c59462a7f72 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 6 Nov 2015 21:28:31 -0600 Subject: [PATCH 027/976] Add sandbox as a global --- test/helpers/globals.helper.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 74a9ae6045..32ec18f79e 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -8,3 +8,5 @@ global.sinon = require("sinon"); chai.use(require("sinon-chai")) chai.use(require("chai-as-promised")); global.expect = chai.expect + +global.sandbox = sinon.sandbox.create(); From 94dbb25fa61b94ad04196b9c0841e534b430ee31 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 6 Nov 2015 21:28:47 -0600 Subject: [PATCH 028/976] Add unit helper --- test/helpers/api-unit.helper.js | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/helpers/api-unit.helper.js diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js new file mode 100644 index 0000000000..3bcf4be4b9 --- /dev/null +++ b/test/helpers/api-unit.helper.js @@ -0,0 +1,41 @@ +// @TODO: remove when lodash can be upgraded +import defaults from 'lodash.defaultsdeep'; +import { model as User } from '../../website/src/models/user' +import { model as Group } from '../../website/src/models/group' +import i18n from '../../common/script/src/i18n'; +require('coffee-script'); +i18n.translations = require('../../website/src/libs/i18n.js').translations; + +afterEach(() => { + sandbox.restore(); +}); + +export function generateUser(options={}) { + return new User(options)._doc; +} + +export function generateGroup(options={}) { + return new Group(options)._doc; +} + +export function generateRes(options={}) { + let defaultRes = { + send: sandbox.stub(), + json: sandbox.stub(), + locals: { + user: generateUser(options.localsUser), + group: generateGroup(options.localsGroup), + }, + }; + + return defaults(options, defaultRes); +} + +export function generateReq(options={}) { + let defaultReq = { + body: {}, + query: {}, + }; + + return defaults(options, defaultReq); +} From e657a30320f8101c7e4a3cef3c4f71d95441abcb Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 7 Nov 2015 08:32:02 -0600 Subject: [PATCH 029/976] Add next generator for easier controller testing. --- test/helpers/api-unit.helper.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 3bcf4be4b9..5aa2816677 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -39,3 +39,7 @@ export function generateReq(options={}) { return defaults(options, defaultReq); } + +export function generateNext(func) { + return func || sandbox.stub(); +} From ac7d3e642f019f2bbfe9dc8bd96582807f07e76f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 7 Nov 2015 15:37:38 +0100 Subject: [PATCH 030/976] port middlewares to authenticate users with headers and sessions --- website/src/controllers/api-v2/auth.js | 1 + website/src/middlewares/api-v3/auth.js | 66 +++++++++++++++++++ .../src/middlewares/api-v3/errorHandler.js | 1 - 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 website/src/middlewares/api-v3/auth.js diff --git a/website/src/controllers/api-v2/auth.js b/website/src/controllers/api-v2/auth.js index b3a552721a..b01e1aba3d 100644 --- a/website/src/controllers/api-v2/auth.js +++ b/website/src/controllers/api-v2/auth.js @@ -53,6 +53,7 @@ api.authWithSession = function(req, res, next) { //[todo] there is probably a mo }); }; +// TODO passing auth params as query params is not safe as they are logged by browser history, ... api.authWithUrl = function(req, res, next) { User.findOne({_id:req.query._id, apiToken:req.query.apiToken}, function(err,user){ if (err) return next(err); diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js new file mode 100644 index 0000000000..583b087353 --- /dev/null +++ b/website/src/middlewares/api-v3/auth.js @@ -0,0 +1,66 @@ +// Middlewares used to authenticate requests +import { + NotAuthorized, +} from '../../libs/api-v3/errors'; + +import { + UserModel as User, +} from '../../models/user'; + +// TODO use i18n +const missingAuthHeaders = 'Missing authentication headers.'; +const userNotFound = 'User not found.'; +const accountSuspended = (user) => { + return `Account has been suspended, please contact leslie@habitica.com + with your UUID ${user._id} for assistance.`; +}; + +// TODO adopt JSDoc syntax? +// Authenticate a request through the x-api-user and x-api key header +export function authWithHeaders (req, res, next) { + let userId = req.header['x-api-user']; + let apiToken = req.header['x-api-key']; + + if (!userId || !apiToken) { + // TODO use i18n? + // TODO use badrequest error? + return next(new NotAuthorized(missingAuthHeaders)); + } + + // TODO use promises? + User.findOne({ + _id: userId, + apiToken, + }, (err, user) => { + if (err) return next(err); + if (!user) return next(new NotAuthorized(userNotFound)); + + // TODO better handling for this case + if (user.blocked) return next(new NotAuthorized(accountSuspended(user))); + + res.locals.user = user; + // TODO use either session/cookie or headers, not both + req.session.userId = user._id; + return next(); + }); +} + +// Authenticate a request through a valid session +// TODO should use json web token +export function authWithSession (req, res, next) { + let session = req.session; + + if (!session || !session.userId) { + return next(new NotAuthorized(userNotFound)); + } + + User.findOne({ + _id: session.userId, + }, (err, user) => { + if (err) return next(err); + if (!user) return next(new NotAuthorized(userNotFound)); + + res.locals.user = user; + return next(); + }); +} \ No newline at end of file diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 4950d09e09..97e324aee3 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -21,7 +21,6 @@ export default function errorHandler (err, req, res, next) { // In case of a CustomError class, use it's data // Otherwise try to identify the type of error (mongoose validation, mongodb unique, ...) // If we can't identify it, respond with a generic 500 error - let responseErr = err instanceof CustomError ? err : null; if (!responseErr) { From 3a12490ad7d9f60c7aabb0b9d8863d4c5d29585f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 7 Nov 2015 15:40:57 +0100 Subject: [PATCH 031/976] simplify code --- website/src/middlewares/api-v3/auth.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 583b087353..65838a9a84 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -48,14 +48,12 @@ export function authWithHeaders (req, res, next) { // Authenticate a request through a valid session // TODO should use json web token export function authWithSession (req, res, next) { - let session = req.session; + let userId = req.session.userId; - if (!session || !session.userId) { - return next(new NotAuthorized(userNotFound)); - } + if (!userId) return next(new NotAuthorized(userNotFound)); User.findOne({ - _id: session.userId, + _id: userId, }, (err, user) => { if (err) return next(err); if (!user) return next(new NotAuthorized(userNotFound)); From 0834f8eeeaddd00352ee14eb22fb58d230b1b22d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 7 Nov 2015 15:51:26 +0100 Subject: [PATCH 032/976] disable api v2 and related tests to enable changes to models --- common/dist/sprites/habitrpg-shared.css | 2 +- tasks/gulp-tests.js | 8 ++++---- website/src/server.js | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/common/dist/sprites/habitrpg-shared.css b/common/dist/sprites/habitrpg-shared.css index 3e62e01174..add5354098 100644 --- a/common/dist/sprites/habitrpg-shared.css +++ b/common/dist/sprites/habitrpg-shared.css @@ -1 +1 @@ -.2014_Fall_HealerPROMO2{background-image:url(spritesmith-largeSprites-0.png);background-position:-475px -950px;width:90px;height:90px}.2014_Fall_Mage_PROMO9{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -392px;width:120px;height:90px}.2014_Fall_RoguePROMO3{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -589px;width:105px;height:90px}.2014_Fall_Warrior_PROMO{background-image:url(spritesmith-largeSprites-0.png);background-position:-566px -950px;width:90px;height:90px}.promo_backtoschool{background-image:url(spritesmith-largeSprites-0.png);background-position:-220px -220px;width:150px;height:150px}.promo_burnout{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -220px;width:219px;height:240px}.promo_classes_fall_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-326px -664px;width:321px;height:100px}.promo_classes_fall_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -564px;width:377px;height:99px}.promo_dilatoryDistress{background-image:url(spritesmith-largeSprites-0.png);background-position:-839px -950px;width:90px;height:90px}.promo_enchanted_armoire{background-image:url(spritesmith-largeSprites-0.png);background-position:-378px -564px;width:374px;height:76px}.promo_enchanted_armoire_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-507px -859px;width:217px;height:90px}.promo_enchanted_armoire_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-725px -859px;width:180px;height:90px}.promo_enchanted_armoire_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-293px -950px;width:90px;height:90px}.promo_habitica{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -166px;width:175px;height:175px}.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -148px;width:100px;height:137px}.customize-option.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-995px -163px;width:60px;height:60px}.promo_item_notif{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -347px;width:249px;height:102px}.promo_mystery_201405{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -91px;width:90px;height:90px}.promo_mystery_201406{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -680px;width:90px;height:96px}.promo_mystery_201407{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -613px;width:42px;height:62px}.promo_mystery_201408{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -337px;width:60px;height:71px}.promo_mystery_201409{background-image:url(spritesmith-largeSprites-0.png);background-position:-748px -950px;width:90px;height:90px}.promo_mystery_201410{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -273px;width:72px;height:63px}.promo_mystery_201411{background-image:url(spritesmith-largeSprites-0.png);background-position:-930px -950px;width:90px;height:90px}.promo_mystery_201412{background-image:url(spritesmith-largeSprites-0.png);background-position:-1154px -543px;width:42px;height:66px}.promo_mystery_201501{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -479px;width:48px;height:63px}.promo_mystery_201502{background-image:url(spritesmith-largeSprites-0.png);background-position:-384px -950px;width:90px;height:90px}.promo_mystery_201503{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -182px;width:90px;height:90px}.promo_mystery_201504{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -409px;width:60px;height:69px}.promo_mystery_201505{background-image:url(spritesmith-largeSprites-0.png);background-position:-657px -950px;width:90px;height:90px}.promo_mystery_201506{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -543px;width:42px;height:69px}.promo_mystery_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -483px;width:90px;height:105px}.promo_mystery_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-199px -950px;width:93px;height:90px}.promo_mystery_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px 0;width:90px;height:90px}.promo_mystery_201510{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -777px;width:93px;height:90px}.promo_mystery_3014{background-image:url(spritesmith-largeSprites-0.png);background-position:-289px -859px;width:217px;height:90px}.promo_orca{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -286px;width:105px;height:105px}.promo_partyhats{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -868px;width:115px;height:47px}.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-331px -775px;width:330px;height:83px}.customize-option.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-356px -790px;width:60px;height:60px}.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px 0;width:140px;height:147px}.customize-option.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-995px -15px;width:60px;height:60px}.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -775px;width:330px;height:83px}.customize-option.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -790px;width:60px;height:60px}.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -950px;width:198px;height:91px}.customize-option.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -965px;width:60px;height:60px}.promo_springclasses2014{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -859px;width:288px;height:90px}.promo_springclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-430px -461px;width:288px;height:90px}.promo_summer_classes_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -461px;width:429px;height:102px}.promo_summer_classes_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-648px -664px;width:300px;height:88px}.promo_updos{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -342px;width:156px;height:147px}.promo_veteran_pets{background-image:url(spritesmith-largeSprites-0.png);background-position:-753px -564px;width:146px;height:75px}.promo_winterclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -664px;width:325px;height:110px}.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-220px -371px;width:152px;height:75px}.customize-option.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-245px -386px;width:60px;height:60px}.party_preview{background-image:url(spritesmith-largeSprites-0.png);background-position:0 0;width:451px;height:219px}.welcome_basic_avatars{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -181px;width:246px;height:165px}.welcome_promo_party{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px 0;width:270px;height:180px}.welcome_sample_tasks{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px 0;width:246px;height:165px}.achievement-alien{background-image:url(spritesmith-main-0.png);background-position:-1703px -533px;width:24px;height:26px}.achievement-alpha{background-image:url(spritesmith-main-0.png);background-position:-1703px -182px;width:24px;height:26px}.achievement-armor{background-image:url(spritesmith-main-0.png);background-position:-1678px -533px;width:24px;height:26px}.achievement-boot{background-image:url(spritesmith-main-0.png);background-position:-1728px -506px;width:24px;height:26px}.achievement-bow{background-image:url(spritesmith-main-0.png);background-position:-1703px -506px;width:24px;height:26px}.achievement-burnout{background-image:url(spritesmith-main-0.png);background-position:-1678px -506px;width:24px;height:26px}.achievement-cactus{background-image:url(spritesmith-main-0.png);background-position:-1728px -479px;width:24px;height:26px}.achievement-cake{background-image:url(spritesmith-main-0.png);background-position:-1703px -479px;width:24px;height:26px}.achievement-cave{background-image:url(spritesmith-main-0.png);background-position:-1678px -479px;width:24px;height:26px}.achievement-coffin{background-image:url(spritesmith-main-0.png);background-position:-1728px -452px;width:24px;height:26px}.achievement-comment{background-image:url(spritesmith-main-0.png);background-position:-1703px -452px;width:24px;height:26px}.achievement-costumeContest{background-image:url(spritesmith-main-0.png);background-position:-1678px -452px;width:24px;height:26px}.achievement-dilatory{background-image:url(spritesmith-main-0.png);background-position:-1728px -425px;width:24px;height:26px}.achievement-firefox{background-image:url(spritesmith-main-0.png);background-position:-1703px -425px;width:24px;height:26px}.achievement-greeting{background-image:url(spritesmith-main-0.png);background-position:-1678px -425px;width:24px;height:26px}.achievement-habitBirthday{background-image:url(spritesmith-main-0.png);background-position:-1728px -398px;width:24px;height:26px}.achievement-habiticaDay{background-image:url(spritesmith-main-0.png);background-position:-1703px -398px;width:24px;height:26px}.achievement-heart{background-image:url(spritesmith-main-0.png);background-position:-1678px -398px;width:24px;height:26px}.achievement-karaoke{background-image:url(spritesmith-main-0.png);background-position:-1728px -371px;width:24px;height:26px}.achievement-ninja{background-image:url(spritesmith-main-0.png);background-position:-1703px -371px;width:24px;height:26px}.achievement-nye{background-image:url(spritesmith-main-0.png);background-position:-1678px -371px;width:24px;height:26px}.achievement-perfect{background-image:url(spritesmith-main-0.png);background-position:-1678px -182px;width:24px;height:26px}.achievement-rat{background-image:url(spritesmith-main-0.png);background-position:-1703px -344px;width:24px;height:26px}.achievement-seafoam{background-image:url(spritesmith-main-0.png);background-position:-1678px -344px;width:24px;height:26px}.achievement-shield{background-image:url(spritesmith-main-0.png);background-position:-1728px -317px;width:24px;height:26px}.achievement-shinySeed{background-image:url(spritesmith-main-0.png);background-position:-1703px -317px;width:24px;height:26px}.achievement-snowball{background-image:url(spritesmith-main-0.png);background-position:-1678px -317px;width:24px;height:26px}.achievement-spookDust{background-image:url(spritesmith-main-0.png);background-position:-1728px -290px;width:24px;height:26px}.achievement-stoikalm{background-image:url(spritesmith-main-0.png);background-position:-1703px -290px;width:24px;height:26px}.achievement-sun{background-image:url(spritesmith-main-0.png);background-position:-1678px -290px;width:24px;height:26px}.achievement-sword{background-image:url(spritesmith-main-0.png);background-position:-1728px -263px;width:24px;height:26px}.achievement-thankyou{background-image:url(spritesmith-main-0.png);background-position:-1703px -263px;width:24px;height:26px}.achievement-thermometer{background-image:url(spritesmith-main-0.png);background-position:-1678px -263px;width:24px;height:26px}.achievement-tree{background-image:url(spritesmith-main-0.png);background-position:-1728px -236px;width:24px;height:26px}.achievement-triadbingo{background-image:url(spritesmith-main-0.png);background-position:-1703px -236px;width:24px;height:26px}.achievement-ultimate-healer{background-image:url(spritesmith-main-0.png);background-position:-1678px -236px;width:24px;height:26px}.achievement-ultimate-mage{background-image:url(spritesmith-main-0.png);background-position:-1728px -209px;width:24px;height:26px}.achievement-ultimate-rogue{background-image:url(spritesmith-main-0.png);background-position:-1703px -209px;width:24px;height:26px}.achievement-ultimate-warrior{background-image:url(spritesmith-main-0.png);background-position:-1678px -209px;width:24px;height:26px}.achievement-valentine{background-image:url(spritesmith-main-0.png);background-position:-1728px -182px;width:24px;height:26px}.achievement-wolf{background-image:url(spritesmith-main-0.png);background-position:-1728px -344px;width:24px;height:26px}.background_autumn_forest{background-image:url(spritesmith-main-0.png);background-position:-282px -592px;width:140px;height:147px}.background_beach{background-image:url(spritesmith-main-0.png);background-position:-426px 0;width:141px;height:147px}.background_blacksmithy{background-image:url(spritesmith-main-0.png);background-position:-709px 0;width:140px;height:147px}.background_cherry_trees{background-image:url(spritesmith-main-0.png);background-position:-709px -148px;width:140px;height:147px}.background_clouds{background-image:url(spritesmith-main-0.png);background-position:-709px -444px;width:140px;height:147px}.background_coral_reef{background-image:url(spritesmith-main-0.png);background-position:-705px -592px;width:140px;height:147px}.background_crystal_cave{background-image:url(spritesmith-main-0.png);background-position:-850px -296px;width:140px;height:147px}.background_dilatory_ruins{background-image:url(spritesmith-main-0.png);background-position:-850px -444px;width:140px;height:147px}.background_distant_castle{background-image:url(spritesmith-main-0.png);background-position:0 -888px;width:140px;height:147px}.background_drifting_raft{background-image:url(spritesmith-main-0.png);background-position:-141px -888px;width:140px;height:147px}.background_dusty_canyons{background-image:url(spritesmith-main-0.png);background-position:-282px -888px;width:140px;height:147px}.background_fairy_ring{background-image:url(spritesmith-main-0.png);background-position:-568px 0;width:140px;height:147px}.background_floral_meadow{background-image:url(spritesmith-main-0.png);background-position:-568px -148px;width:140px;height:147px}.background_forest{background-image:url(spritesmith-main-0.png);background-position:-568px -296px;width:140px;height:147px}.background_frigid_peak{background-image:url(spritesmith-main-0.png);background-position:0 -444px;width:140px;height:147px}.background_giant_wave{background-image:url(spritesmith-main-0.png);background-position:-284px 0;width:141px;height:147px}.background_graveyard{background-image:url(spritesmith-main-0.png);background-position:-282px -444px;width:140px;height:147px}.background_gumdrop_land{background-image:url(spritesmith-main-0.png);background-position:-423px -444px;width:140px;height:147px}.background_harvest_feast{background-image:url(spritesmith-main-0.png);background-position:-564px -444px;width:140px;height:147px}.background_harvest_fields{background-image:url(spritesmith-main-0.png);background-position:0 -148px;width:141px;height:147px}.background_harvest_moon{background-image:url(spritesmith-main-0.png);background-position:-142px -148px;width:141px;height:147px}.background_haunted_house{background-image:url(spritesmith-main-0.png);background-position:-709px -296px;width:140px;height:147px}.background_ice_cave{background-image:url(spritesmith-main-0.png);background-position:-284px -148px;width:141px;height:147px}.background_iceberg{background-image:url(spritesmith-main-0.png);background-position:0 -592px;width:140px;height:147px}.background_island_waterfalls{background-image:url(spritesmith-main-0.png);background-position:-141px -592px;width:140px;height:147px}.background_marble_temple{background-image:url(spritesmith-main-0.png);background-position:-142px 0;width:141px;height:147px}.background_market{background-image:url(spritesmith-main-0.png);background-position:-423px -592px;width:140px;height:147px}.background_mountain_lake{background-image:url(spritesmith-main-0.png);background-position:-564px -592px;width:140px;height:147px}.background_open_waters{background-image:url(spritesmith-main-0.png);background-position:0 0;width:141px;height:147px}.background_pagodas{background-image:url(spritesmith-main-0.png);background-position:-850px 0;width:140px;height:147px}.background_pumpkin_patch{background-image:url(spritesmith-main-0.png);background-position:-850px -148px;width:140px;height:147px}.background_pyramids{background-image:url(spritesmith-main-0.png);background-position:-426px -148px;width:141px;height:147px}.background_rolling_hills{background-image:url(spritesmith-main-0.png);background-position:0 -296px;width:141px;height:147px}.background_seafarer_ship{background-image:url(spritesmith-main-0.png);background-position:-850px -592px;width:140px;height:147px}.background_shimmery_bubbles{background-image:url(spritesmith-main-0.png);background-position:0 -740px;width:140px;height:147px}.background_slimy_swamp{background-image:url(spritesmith-main-0.png);background-position:-141px -740px;width:140px;height:147px}.background_snowy_pines{background-image:url(spritesmith-main-0.png);background-position:-282px -740px;width:140px;height:147px}.background_south_pole{background-image:url(spritesmith-main-0.png);background-position:-423px -740px;width:140px;height:147px}.background_spring_rain{background-image:url(spritesmith-main-0.png);background-position:-564px -740px;width:140px;height:147px}.background_stable{background-image:url(spritesmith-main-0.png);background-position:-705px -740px;width:140px;height:147px}.background_stained_glass{background-image:url(spritesmith-main-0.png);background-position:-846px -740px;width:140px;height:147px}.background_starry_skies{background-image:url(spritesmith-main-0.png);background-position:-991px 0;width:140px;height:147px}.background_sunken_ship{background-image:url(spritesmith-main-0.png);background-position:-991px -148px;width:140px;height:147px}.background_sunset_meadow{background-image:url(spritesmith-main-0.png);background-position:-991px -296px;width:140px;height:147px}.background_sunset_savannah{background-image:url(spritesmith-main-0.png);background-position:-991px -444px;width:140px;height:147px}.background_swarming_darkness{background-image:url(spritesmith-main-0.png);background-position:-991px -592px;width:140px;height:147px}.background_tavern{background-image:url(spritesmith-main-0.png);background-position:-991px -740px;width:140px;height:147px}.background_thunderstorm{background-image:url(spritesmith-main-0.png);background-position:-142px -296px;width:141px;height:147px}.background_twinkly_lights{background-image:url(spritesmith-main-0.png);background-position:-284px -296px;width:141px;height:147px}.background_twinkly_party_lights{background-image:url(spritesmith-main-0.png);background-position:-426px -296px;width:141px;height:147px}.background_volcano{background-image:url(spritesmith-main-0.png);background-position:-141px -444px;width:140px;height:147px}.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-910px -1127px;width:90px;height:90px}.customize-option.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-935px -1142px;width:60px;height:60px}.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1001px -1127px;width:90px;height:90px}.customize-option.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1026px -1142px;width:60px;height:60px}.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1092px -1127px;width:90px;height:90px}.customize-option.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1117px -1142px;width:60px;height:60px}.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1223px 0;width:90px;height:90px}.customize-option.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1248px -15px;width:60px;height:60px}.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-1223px -91px;width:90px;height:90px}.customize-option.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-1248px -106px;width:60px;height:60px}.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-1223px -182px;width:90px;height:90px}.customize-option.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-1248px -197px;width:60px;height:60px}.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1223px -273px;width:90px;height:90px}.customize-option.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1248px -288px;width:60px;height:60px}.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1223px -364px;width:90px;height:90px}.customize-option.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1248px -379px;width:60px;height:60px}.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1223px -455px;width:90px;height:90px}.customize-option.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1248px -470px;width:60px;height:60px}.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1223px -546px;width:90px;height:90px}.customize-option.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1248px -561px;width:60px;height:60px}.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1223px -637px;width:90px;height:90px}.customize-option.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1248px -652px;width:60px;height:60px}.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-1223px -728px;width:90px;height:90px}.customize-option.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-1248px -743px;width:60px;height:60px}.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1223px -819px;width:90px;height:90px}.customize-option.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1248px -834px;width:60px;height:60px}.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1223px -910px;width:90px;height:90px}.customize-option.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1248px -925px;width:60px;height:60px}.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1223px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1248px -1016px;width:60px;height:60px}.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1223px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1248px -1107px;width:60px;height:60px}.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:0 -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-25px -1233px;width:60px;height:60px}.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-91px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-116px -1233px;width:60px;height:60px}.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-182px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-207px -1233px;width:60px;height:60px}.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-273px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-298px -1233px;width:60px;height:60px}.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-364px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-389px -1233px;width:60px;height:60px}.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-455px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-480px -1233px;width:60px;height:60px}.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-546px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-571px -1233px;width:60px;height:60px}.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-637px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-662px -1233px;width:60px;height:60px}.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-728px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-753px -1233px;width:60px;height:60px}.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-819px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-844px -1233px;width:60px;height:60px}.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-910px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-935px -1233px;width:60px;height:60px}.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1001px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1026px -1233px;width:60px;height:60px}.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1092px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1117px -1233px;width:60px;height:60px}.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1183px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1208px -1233px;width:60px;height:60px}.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1314px 0;width:90px;height:90px}.customize-option.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1339px -15px;width:60px;height:60px}.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1314px -91px;width:90px;height:90px}.customize-option.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1339px -106px;width:60px;height:60px}.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1314px -182px;width:90px;height:90px}.customize-option.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1339px -197px;width:60px;height:60px}.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -273px;width:90px;height:90px}.customize-option.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -288px;width:60px;height:60px}.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -364px;width:90px;height:90px}.customize-option.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -379px;width:60px;height:60px}.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -455px;width:90px;height:90px}.customize-option.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -470px;width:60px;height:60px}.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -546px;width:90px;height:90px}.customize-option.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -561px;width:60px;height:60px}.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1314px -637px;width:90px;height:90px}.customize-option.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1339px -652px;width:60px;height:60px}.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1314px -728px;width:90px;height:90px}.customize-option.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1339px -743px;width:60px;height:60px}.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1314px -819px;width:90px;height:90px}.customize-option.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1339px -834px;width:60px;height:60px}.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1314px -910px;width:90px;height:90px}.customize-option.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1339px -925px;width:60px;height:60px}.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1314px -1001px;width:90px;height:90px}.customize-option.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1339px -1016px;width:60px;height:60px}.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-1314px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-1339px -1107px;width:60px;height:60px}.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1314px -1183px;width:90px;height:90px}.customize-option.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1339px -1198px;width:60px;height:60px}.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:0 -1309px;width:90px;height:90px}.customize-option.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-25px -1324px;width:60px;height:60px}.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-91px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-116px -1324px;width:60px;height:60px}.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-182px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-207px -1324px;width:60px;height:60px}.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-273px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-298px -1324px;width:60px;height:60px}.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-364px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-389px -1324px;width:60px;height:60px}.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-455px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-480px -1324px;width:60px;height:60px}.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-546px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-571px -1324px;width:60px;height:60px}.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-637px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-662px -1324px;width:60px;height:60px}.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-728px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-753px -1324px;width:60px;height:60px}.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-819px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-844px -1324px;width:60px;height:60px}.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-423px -888px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-448px -903px;width:60px;height:60px}.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1001px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1026px -1324px;width:60px;height:60px}.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1092px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1117px -1324px;width:60px;height:60px}.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1183px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1208px -1324px;width:60px;height:60px}.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1274px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1299px -1324px;width:60px;height:60px}.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1405px 0;width:90px;height:90px}.customize-option.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1430px -15px;width:60px;height:60px}.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1405px -91px;width:90px;height:90px}.customize-option.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1430px -106px;width:60px;height:60px}.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1405px -182px;width:90px;height:90px}.customize-option.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1430px -197px;width:60px;height:60px}.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1405px -273px;width:90px;height:90px}.customize-option.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1430px -288px;width:60px;height:60px}.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1405px -364px;width:90px;height:90px}.customize-option.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1430px -379px;width:60px;height:60px}.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -455px;width:90px;height:90px}.customize-option.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -470px;width:60px;height:60px}.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1405px -546px;width:90px;height:90px}.customize-option.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1430px -561px;width:60px;height:60px}.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1405px -637px;width:90px;height:90px}.customize-option.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1430px -652px;width:60px;height:60px}.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-1405px -728px;width:90px;height:90px}.customize-option.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-1430px -743px;width:60px;height:60px}.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-1405px -819px;width:90px;height:90px}.customize-option.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-1430px -834px;width:60px;height:60px}.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-1405px -910px;width:90px;height:90px}.customize-option.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-1430px -925px;width:60px;height:60px}.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-1405px -1001px;width:90px;height:90px}.customize-option.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-1430px -1016px;width:60px;height:60px}.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-1405px -1092px;width:90px;height:90px}.customize-option.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-1430px -1107px;width:60px;height:60px}.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-1405px -1183px;width:90px;height:90px}.customize-option.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-1430px -1198px;width:60px;height:60px}.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1405px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1430px -1289px;width:60px;height:60px}.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:0 -1400px;width:90px;height:90px}.customize-option.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-25px -1415px;width:60px;height:60px}.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-91px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-116px -1415px;width:60px;height:60px}.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-182px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-207px -1415px;width:60px;height:60px}.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-273px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-298px -1415px;width:60px;height:60px}.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-364px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-389px -1415px;width:60px;height:60px}.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-455px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-480px -1415px;width:60px;height:60px}.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-546px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-571px -1415px;width:60px;height:60px}.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-637px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-662px -1415px;width:60px;height:60px}.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-728px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-753px -1415px;width:60px;height:60px}.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-819px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-844px -1415px;width:60px;height:60px}.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1415px;width:60px;height:60px}.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1001px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1026px -1415px;width:60px;height:60px}.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1092px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1117px -1415px;width:60px;height:60px}.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1183px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1208px -1415px;width:60px;height:60px}.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1274px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1299px -1415px;width:60px;height:60px}.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1365px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1390px -1415px;width:60px;height:60px}.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1496px 0;width:90px;height:90px}.customize-option.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -15px;width:60px;height:60px}.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1496px -91px;width:90px;height:90px}.customize-option.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1521px -106px;width:60px;height:60px}.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1496px -182px;width:90px;height:90px}.customize-option.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1521px -197px;width:60px;height:60px}.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1496px -273px;width:90px;height:90px}.customize-option.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1521px -288px;width:60px;height:60px}.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1496px -364px;width:90px;height:90px}.customize-option.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1521px -379px;width:60px;height:60px}.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1496px -455px;width:90px;height:90px}.customize-option.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1521px -470px;width:60px;height:60px}.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1496px -546px;width:90px;height:90px}.customize-option.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1521px -561px;width:60px;height:60px}.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -637px;width:90px;height:90px}.customize-option.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -652px;width:60px;height:60px}.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1496px -728px;width:90px;height:90px}.customize-option.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1521px -743px;width:60px;height:60px}.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1496px -819px;width:90px;height:90px}.customize-option.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1521px -834px;width:60px;height:60px}.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1496px -910px;width:90px;height:90px}.customize-option.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1521px -925px;width:60px;height:60px}.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-1496px -1001px;width:90px;height:90px}.customize-option.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-1521px -1016px;width:60px;height:60px}.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1496px -1092px;width:90px;height:90px}.customize-option.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1521px -1107px;width:60px;height:60px}.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-1496px -1183px;width:90px;height:90px}.customize-option.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-1521px -1198px;width:60px;height:60px}.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-1496px -1274px;width:90px;height:90px}.customize-option.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-1521px -1289px;width:60px;height:60px}.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1496px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1521px -1380px;width:60px;height:60px}.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:0 -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-25px -1506px;width:60px;height:60px}.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-91px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-116px -1506px;width:60px;height:60px}.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-182px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-207px -1506px;width:60px;height:60px}.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-273px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-298px -1506px;width:60px;height:60px}.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-364px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-389px -1506px;width:60px;height:60px}.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-455px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-480px -1506px;width:60px;height:60px}.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-546px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-571px -1506px;width:60px;height:60px}.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-637px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-662px -1506px;width:60px;height:60px}.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-728px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-753px -1506px;width:60px;height:60px}.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-819px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-844px -1506px;width:60px;height:60px}.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-910px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-935px -1506px;width:60px;height:60px}.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1001px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1026px -1506px;width:60px;height:60px}.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1092px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1117px -1506px;width:60px;height:60px}.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1183px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1208px -1506px;width:60px;height:60px}.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1274px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1299px -1506px;width:60px;height:60px}.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1365px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1390px -1506px;width:60px;height:60px}.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1456px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1481px -1506px;width:60px;height:60px}.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1587px 0;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -15px;width:60px;height:60px}.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1587px -91px;width:90px;height:90px}.customize-option.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1612px -106px;width:60px;height:60px}.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-1587px -182px;width:90px;height:90px}.customize-option.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-1612px -197px;width:60px;height:60px}.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1587px -273px;width:90px;height:90px}.customize-option.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1612px -288px;width:60px;height:60px}.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-1587px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-1612px -379px;width:60px;height:60px}.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1587px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1612px -470px;width:60px;height:60px}.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1587px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1612px -561px;width:60px;height:60px}.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -652px;width:60px;height:60px}.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1587px -728px;width:90px;height:90px}.customize-option.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1612px -743px;width:60px;height:60px}.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1587px -819px;width:90px;height:90px}.customize-option.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1612px -834px;width:60px;height:60px}.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1587px -910px;width:90px;height:90px}.customize-option.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1612px -925px;width:60px;height:60px}.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1587px -1001px;width:90px;height:90px}.customize-option.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1612px -1016px;width:60px;height:60px}.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1587px -1092px;width:90px;height:90px}.customize-option.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1612px -1107px;width:60px;height:60px}.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1587px -1183px;width:90px;height:90px}.customize-option.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1612px -1198px;width:60px;height:60px}.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1587px -1274px;width:90px;height:90px}.customize-option.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1612px -1289px;width:60px;height:60px}.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1587px -1365px;width:90px;height:90px}.customize-option.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1612px -1380px;width:60px;height:60px}.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1587px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1612px -1471px;width:60px;height:60px}.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:0 -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-25px -1597px;width:60px;height:60px}.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-91px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-116px -1597px;width:60px;height:60px}.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-182px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-207px -1597px;width:60px;height:60px}.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-273px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-298px -1597px;width:60px;height:60px}.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-364px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-389px -1597px;width:60px;height:60px}.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-455px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-480px -1597px;width:60px;height:60px}.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-546px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-571px -1597px;width:60px;height:60px}.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-637px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-662px -1597px;width:60px;height:60px}.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-728px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-753px -1597px;width:60px;height:60px}.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-819px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-844px -1597px;width:60px;height:60px}.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1597px;width:60px;height:60px}.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1001px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1026px -1597px;width:60px;height:60px}.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1092px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1117px -1597px;width:60px;height:60px}.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1183px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1208px -1597px;width:60px;height:60px}.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1274px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1299px -1597px;width:60px;height:60px}.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1365px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1390px -1597px;width:60px;height:60px}.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1456px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1481px -1597px;width:60px;height:60px}.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1547px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1572px -1597px;width:60px;height:60px}.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1678px 0;width:90px;height:90px}.customize-option.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1703px -15px;width:60px;height:60px}.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1678px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1703px -106px;width:60px;height:60px}.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-910px -1309px;width:90px;height:90px}.customize-option.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-935px -1324px;width:60px;height:60px}.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1132px -910px;width:90px;height:90px}.customize-option.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1157px -925px;width:60px;height:60px}.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1132px -819px;width:90px;height:90px}.customize-option.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1157px -834px;width:60px;height:60px}.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1132px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1157px -743px;width:60px;height:60px}.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1132px -637px;width:90px;height:90px}.customize-option.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1157px -652px;width:60px;height:60px}.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1132px -546px;width:90px;height:90px}.customize-option.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1157px -561px;width:60px;height:60px}.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1132px -455px;width:90px;height:90px}.customize-option.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1157px -470px;width:60px;height:60px}.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-1132px -364px;width:90px;height:90px}.customize-option.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-1157px -379px;width:60px;height:60px}.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-1132px -273px;width:90px;height:90px}.customize-option.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-1157px -288px;width:60px;height:60px}.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-1132px -182px;width:90px;height:90px}.customize-option.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-1157px -197px;width:60px;height:60px}.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-1132px -91px;width:90px;height:90px}.customize-option.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-1157px -106px;width:60px;height:60px}.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1132px 0;width:90px;height:90px}.customize-option.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1157px -15px;width:60px;height:60px}.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1001px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1026px -1051px;width:60px;height:60px}.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-910px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-935px -1051px;width:60px;height:60px}.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-819px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-844px -1051px;width:60px;height:60px}.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-728px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-753px -1051px;width:60px;height:60px}.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-637px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-662px -1051px;width:60px;height:60px}.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-546px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-571px -1051px;width:60px;height:60px}.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-455px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-480px -1051px;width:60px;height:60px}.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-364px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-389px -1051px;width:60px;height:60px}.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-273px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-298px -1051px;width:60px;height:60px}.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-182px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-207px -1051px;width:60px;height:60px}.hair_bangs_1_green{background-image:url(spritesmith-main-0.png);background-position:-91px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_green{background-image:url(spritesmith-main-0.png);background-position:-116px -1051px;width:60px;height:60px}.hair_bangs_1_halloween{background-image:url(spritesmith-main-0.png);background-position:0 -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-25px -1051px;width:60px;height:60px}.hair_bangs_1_holly{background-image:url(spritesmith-main-0.png);background-position:-969px -888px;width:90px;height:90px}.customize-option.hair_bangs_1_holly{background-image:url(spritesmith-main-0.png);background-position:-994px -903px;width:60px;height:60px}.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-878px -888px;width:90px;height:90px}.customize-option.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-903px -903px;width:60px;height:60px}.hair_bangs_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-787px -888px;width:90px;height:90px}.customize-option.hair_bangs_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-812px -903px;width:60px;height:60px}.hair_bangs_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-696px -888px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-721px -903px;width:60px;height:60px}.hair_bangs_1_pblue2{background-image:url(spritesmith-main-0.png);background-position:-605px -888px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue2{background-image:url(spritesmith-main-0.png);background-position:-630px -903px;width:60px;height:60px}.hair_bangs_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-514px -888px;width:90px;height:90px}.customize-option.hair_bangs_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-539px -903px;width:60px;height:60px}.hair_bangs_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-819px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-844px -1142px;width:60px;height:60px}.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-0.png);background-position:-728px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-0.png);background-position:-753px -1142px;width:60px;height:60px}.hair_bangs_1_porange{background-image:url(spritesmith-main-0.png);background-position:-637px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_porange{background-image:url(spritesmith-main-0.png);background-position:-662px -1142px;width:60px;height:60px}.hair_bangs_1_porange2{background-image:url(spritesmith-main-0.png);background-position:-546px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_porange2{background-image:url(spritesmith-main-0.png);background-position:-571px -1142px;width:60px;height:60px}.hair_bangs_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-455px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-480px -1142px;width:60px;height:60px}.hair_bangs_1_ppink2{background-image:url(spritesmith-main-0.png);background-position:-364px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink2{background-image:url(spritesmith-main-0.png);background-position:-389px -1142px;width:60px;height:60px}.hair_bangs_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-273px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-298px -1142px;width:60px;height:60px}.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-0.png);background-position:-182px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-0.png);background-position:-207px -1142px;width:60px;height:60px}.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-91px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-116px -1142px;width:60px;height:60px}.hair_bangs_1_purple{background-image:url(spritesmith-main-0.png);background-position:0 -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_purple{background-image:url(spritesmith-main-0.png);background-position:-25px -1142px;width:60px;height:60px}.hair_bangs_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1132px -1001px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1157px -1016px;width:60px;height:60px}.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-116px -15px;width:60px;height:60px}.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-753px -1107px;width:60px;height:60px}.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-25px -106px;width:60px;height:60px}.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-116px -106px;width:60px;height:60px}.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-207px -15px;width:60px;height:60px}.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-207px -106px;width:60px;height:60px}.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-25px -197px;width:60px;height:60px}.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-116px -197px;width:60px;height:60px}.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-207px -197px;width:60px;height:60px}.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-298px -15px;width:60px;height:60px}.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-298px -106px;width:60px;height:60px}.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-298px -197px;width:60px;height:60px}.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-25px -288px;width:60px;height:60px}.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-116px -288px;width:60px;height:60px}.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-207px -288px;width:60px;height:60px}.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-298px -288px;width:60px;height:60px}.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-389px -15px;width:60px;height:60px}.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-389px -106px;width:60px;height:60px}.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -197px;width:60px;height:60px}.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-389px -288px;width:60px;height:60px}.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-25px -379px;width:60px;height:60px}.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-116px -379px;width:60px;height:60px}.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-207px -379px;width:60px;height:60px}.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-298px -379px;width:60px;height:60px}.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-389px -379px;width:60px;height:60px}.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-480px -15px;width:60px;height:60px}.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-480px -106px;width:60px;height:60px}.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-480px -197px;width:60px;height:60px}.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-480px -288px;width:60px;height:60px}.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-480px -379px;width:60px;height:60px}.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-25px -470px;width:60px;height:60px}.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-116px -470px;width:60px;height:60px}.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-207px -470px;width:60px;height:60px}.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-298px -470px;width:60px;height:60px}.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-389px -470px;width:60px;height:60px}.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-480px -470px;width:60px;height:60px}.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-571px -15px;width:60px;height:60px}.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-571px -106px;width:60px;height:60px}.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-571px -197px;width:60px;height:60px}.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-571px -288px;width:60px;height:60px}.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-571px -379px;width:60px;height:60px}.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-571px -470px;width:60px;height:60px}.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-25px -561px;width:60px;height:60px}.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-116px -561px;width:60px;height:60px}.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-207px -561px;width:60px;height:60px}.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-298px -561px;width:60px;height:60px}.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-389px -561px;width:60px;height:60px}.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-480px -561px;width:60px;height:60px}.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-571px -561px;width:60px;height:60px}.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-662px -15px;width:60px;height:60px}.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-662px -106px;width:60px;height:60px}.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-662px -197px;width:60px;height:60px}.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-662px -288px;width:60px;height:60px}.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-662px -379px;width:60px;height:60px}.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-662px -470px;width:60px;height:60px}.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-662px -561px;width:60px;height:60px}.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-25px -652px;width:60px;height:60px}.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-116px -652px;width:60px;height:60px}.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-207px -652px;width:60px;height:60px}.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-298px -652px;width:60px;height:60px}.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-389px -652px;width:60px;height:60px}.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-480px -652px;width:60px;height:60px}.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-571px -652px;width:60px;height:60px}.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-662px -652px;width:60px;height:60px}.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-753px -15px;width:60px;height:60px}.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-753px -106px;width:60px;height:60px}.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-753px -197px;width:60px;height:60px}.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-753px -288px;width:60px;height:60px}.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-753px -379px;width:60px;height:60px}.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-753px -470px;width:60px;height:60px}.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-753px -561px;width:60px;height:60px}.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-753px -652px;width:60px;height:60px}.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-25px -743px;width:60px;height:60px}.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-116px -743px;width:60px;height:60px}.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-207px -743px;width:60px;height:60px}.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-298px -743px;width:60px;height:60px}.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-389px -743px;width:60px;height:60px}.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-480px -743px;width:60px;height:60px}.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-571px -743px;width:60px;height:60px}.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-662px -743px;width:60px;height:60px}.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-753px -743px;width:60px;height:60px}.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-844px -15px;width:60px;height:60px}.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-844px -106px;width:60px;height:60px}.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -197px;width:60px;height:60px}.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-844px -288px;width:60px;height:60px}.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-844px -379px;width:60px;height:60px}.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-844px -470px;width:60px;height:60px}.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-844px -561px;width:60px;height:60px}.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-844px -652px;width:60px;height:60px}.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-844px -743px;width:60px;height:60px}.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-25px -834px;width:60px;height:60px}.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-116px -834px;width:60px;height:60px}.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-207px -834px;width:60px;height:60px}.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_1_purple{background-image:url(spritesmith-main-1.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_1_purple{background-image:url(spritesmith-main-1.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_1_red{background-image:url(spritesmith-main-1.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_1_red{background-image:url(spritesmith-main-1.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_1_white{background-image:url(spritesmith-main-1.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_1_white{background-image:url(spritesmith-main-1.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_base_2_black{background-image:url(spritesmith-main-1.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_base_2_black{background-image:url(spritesmith-main-1.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_base_2_blond{background-image:url(spritesmith-main-1.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_base_2_blond{background-image:url(spritesmith-main-1.png);background-position:-1663px -15px;width:60px;height:60px}.hair_base_2_blue{background-image:url(spritesmith-main-1.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_base_2_blue{background-image:url(spritesmith-main-1.png);background-position:-1663px -106px;width:60px;height:60px}.hair_base_2_brown{background-image:url(spritesmith-main-1.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_base_2_brown{background-image:url(spritesmith-main-1.png);background-position:-1663px -197px;width:60px;height:60px}.hair_base_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_base_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-1663px -288px;width:60px;height:60px}.hair_base_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_base_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1663px -379px;width:60px;height:60px}.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-116px -15px;width:60px;height:60px}.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-753px -1107px;width:60px;height:60px}.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-25px -106px;width:60px;height:60px}.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-116px -106px;width:60px;height:60px}.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-207px -15px;width:60px;height:60px}.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-207px -106px;width:60px;height:60px}.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-25px -197px;width:60px;height:60px}.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-116px -197px;width:60px;height:60px}.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-207px -197px;width:60px;height:60px}.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-298px -15px;width:60px;height:60px}.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-298px -106px;width:60px;height:60px}.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-298px -197px;width:60px;height:60px}.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-25px -288px;width:60px;height:60px}.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-116px -288px;width:60px;height:60px}.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-207px -288px;width:60px;height:60px}.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-298px -288px;width:60px;height:60px}.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-389px -15px;width:60px;height:60px}.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-389px -106px;width:60px;height:60px}.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -197px;width:60px;height:60px}.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-389px -288px;width:60px;height:60px}.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-25px -379px;width:60px;height:60px}.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-116px -379px;width:60px;height:60px}.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-207px -379px;width:60px;height:60px}.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:-298px -379px;width:60px;height:60px}.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-389px -379px;width:60px;height:60px}.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-480px -15px;width:60px;height:60px}.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-480px -106px;width:60px;height:60px}.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-480px -197px;width:60px;height:60px}.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-480px -288px;width:60px;height:60px}.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-480px -379px;width:60px;height:60px}.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-25px -470px;width:60px;height:60px}.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-116px -470px;width:60px;height:60px}.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-207px -470px;width:60px;height:60px}.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-298px -470px;width:60px;height:60px}.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-389px -470px;width:60px;height:60px}.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-480px -470px;width:60px;height:60px}.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-571px -15px;width:60px;height:60px}.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-571px -106px;width:60px;height:60px}.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-571px -197px;width:60px;height:60px}.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-571px -288px;width:60px;height:60px}.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-571px -379px;width:60px;height:60px}.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -470px;width:60px;height:60px}.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-25px -561px;width:60px;height:60px}.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-116px -561px;width:60px;height:60px}.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-207px -561px;width:60px;height:60px}.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-298px -561px;width:60px;height:60px}.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-389px -561px;width:60px;height:60px}.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-480px -561px;width:60px;height:60px}.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-571px -561px;width:60px;height:60px}.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-662px -15px;width:60px;height:60px}.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-662px -106px;width:60px;height:60px}.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-662px -197px;width:60px;height:60px}.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-662px -288px;width:60px;height:60px}.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-662px -379px;width:60px;height:60px}.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-662px -470px;width:60px;height:60px}.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-662px -561px;width:60px;height:60px}.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-25px -652px;width:60px;height:60px}.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-116px -652px;width:60px;height:60px}.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-207px -652px;width:60px;height:60px}.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-298px -652px;width:60px;height:60px}.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-389px -652px;width:60px;height:60px}.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-480px -652px;width:60px;height:60px}.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-571px -652px;width:60px;height:60px}.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-662px -652px;width:60px;height:60px}.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-753px -15px;width:60px;height:60px}.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-753px -106px;width:60px;height:60px}.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-753px -197px;width:60px;height:60px}.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-753px -288px;width:60px;height:60px}.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-753px -379px;width:60px;height:60px}.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-753px -470px;width:60px;height:60px}.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-753px -561px;width:60px;height:60px}.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-753px -652px;width:60px;height:60px}.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-25px -743px;width:60px;height:60px}.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-116px -743px;width:60px;height:60px}.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-207px -743px;width:60px;height:60px}.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-298px -743px;width:60px;height:60px}.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-389px -743px;width:60px;height:60px}.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-480px -743px;width:60px;height:60px}.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-571px -743px;width:60px;height:60px}.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-662px -743px;width:60px;height:60px}.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-753px -743px;width:60px;height:60px}.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-844px -15px;width:60px;height:60px}.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-844px -106px;width:60px;height:60px}.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -197px;width:60px;height:60px}.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-844px -288px;width:60px;height:60px}.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-844px -379px;width:60px;height:60px}.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-844px -470px;width:60px;height:60px}.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-844px -561px;width:60px;height:60px}.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-844px -652px;width:60px;height:60px}.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-844px -743px;width:60px;height:60px}.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-25px -834px;width:60px;height:60px}.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-116px -834px;width:60px;height:60px}.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-207px -834px;width:60px;height:60px}.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -1562px;width:60px;height:60px}.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-116px -1562px;width:60px;height:60px}.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-207px -1562px;width:60px;height:60px}.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-298px -1562px;width:60px;height:60px}.hair_beard_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-389px -1562px;width:60px;height:60px}.hair_beard_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-480px -1562px;width:60px;height:60px}.hair_beard_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-571px -1562px;width:60px;height:60px}.hair_beard_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-662px -1562px;width:60px;height:60px}.hair_beard_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-753px -1562px;width:60px;height:60px}.hair_beard_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-844px -1562px;width:60px;height:60px}.hair_beard_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_beard_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-935px -1562px;width:60px;height:60px}.hair_beard_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_beard_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_beard_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_beard_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_beard_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_beard_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_beard_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_mustache_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_mustache_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_mustache_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1663px -15px;width:60px;height:60px}.hair_mustache_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1663px -106px;width:60px;height:60px}.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1663px -197px;width:60px;height:60px}.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1663px -288px;width:60px;height:60px}.hair_mustache_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1663px -379px;width:60px;height:60px}.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1185px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1210px -743px;width:60px;height:60px}.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -1289px;width:60px;height:60px}.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1185px -819px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1210px -834px;width:60px;height:60px}.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1185px -910px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1210px -925px;width:60px;height:60px}.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-662px -1198px;width:60px;height:60px}.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-753px -1213px;width:60px;height:60px}.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-844px -1213px;width:60px;height:60px}.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-298px -1395px;width:60px;height:60px}.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-389px -1395px;width:60px;height:60px}.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-753px -1395px;width:60px;height:60px}.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-844px -1395px;width:60px;height:60px}.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-1026px -1395px;width:60px;height:60px}.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-1117px -1395px;width:60px;height:60px}.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-1458px 0;width:90px;height:90px}.customize-option.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-1483px -30px;width:60px;height:60px}.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-1458px -91px;width:90px;height:90px}.customize-option.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-1483px -121px;width:60px;height:60px}.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1458px -273px;width:90px;height:90px}.customize-option.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1483px -303px;width:60px;height:60px}.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-1458px -546px;width:90px;height:90px}.customize-option.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-1483px -576px;width:60px;height:60px}.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-1458px -637px;width:90px;height:90px}.customize-option.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-1483px -667px;width:60px;height:60px}.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-1549px 0;width:90px;height:90px}.customize-option.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-1574px -30px;width:60px;height:60px}.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-1549px -91px;width:90px;height:90px}.customize-option.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-1574px -121px;width:60px;height:60px}.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-1549px -364px;width:90px;height:90px}.customize-option.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-1574px -394px;width:60px;height:60px}.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-1549px -455px;width:90px;height:90px}.customize-option.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-1574px -485px;width:60px;height:60px}.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-1549px -819px;width:90px;height:90px}.customize-option.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-1574px -849px;width:60px;height:60px}.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-1549px -910px;width:90px;height:90px}.customize-option.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-1574px -940px;width:60px;height:60px}.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-1549px -1092px;width:90px;height:90px}.customize-option.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-1574px -1122px;width:60px;height:60px}.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-1549px -1183px;width:90px;height:90px}.customize-option.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-1574px -1213px;width:60px;height:60px}.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-545px -182px;width:90px;height:90px}.customize-option.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-570px -212px;width:60px;height:60px}.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-545px -273px;width:90px;height:90px}.customize-option.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-570px -303px;width:60px;height:60px}.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-545px -364px;width:90px;height:90px}.customize-option.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-570px -394px;width:60px;height:60px}.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:0 -455px;width:90px;height:90px}.customize-option.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-25px -485px;width:60px;height:60px}.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-116px -485px;width:60px;height:60px}.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-207px -485px;width:60px;height:60px}.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-298px -485px;width:60px;height:60px}.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-389px -485px;width:60px;height:60px}.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-480px -485px;width:60px;height:60px}.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-571px -485px;width:60px;height:60px}.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:0 -546px;width:90px;height:90px}.customize-option.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-25px -576px;width:60px;height:60px}.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-116px -576px;width:60px;height:60px}.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-207px -576px;width:60px;height:60px}.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-298px -561px;width:60px;height:60px}.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-389px -561px;width:60px;height:60px}.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-480px -561px;width:60px;height:60px}.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-571px -561px;width:60px;height:60px}.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-639px 0;width:90px;height:90px}.customize-option.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-664px -15px;width:60px;height:60px}.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-639px -91px;width:90px;height:90px}.customize-option.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-664px -106px;width:60px;height:60px}.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-639px -182px;width:90px;height:90px}.customize-option.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-664px -197px;width:60px;height:60px}.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-639px -273px;width:90px;height:90px}.customize-option.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-664px -288px;width:60px;height:60px}.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-639px -364px;width:90px;height:90px}.customize-option.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-664px -379px;width:60px;height:60px}.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-639px -455px;width:90px;height:90px}.customize-option.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-664px -470px;width:60px;height:60px}.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:-639px -546px;width:90px;height:90px}.customize-option.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:-664px -561px;width:60px;height:60px}.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:0 -637px;width:90px;height:90px}.customize-option.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-25px -652px;width:60px;height:60px}.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-116px -652px;width:60px;height:60px}.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-207px -652px;width:60px;height:60px}.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-298px -652px;width:60px;height:60px}.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-389px -652px;width:60px;height:60px}.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-480px -652px;width:60px;height:60px}.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-571px -652px;width:60px;height:60px}.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-662px -652px;width:60px;height:60px}.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px 0;width:90px;height:90px}.customize-option.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -15px;width:60px;height:60px}.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-730px -91px;width:90px;height:90px}.customize-option.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-755px -106px;width:60px;height:60px}.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -182px;width:90px;height:90px}.customize-option.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -197px;width:60px;height:60px}.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-730px -273px;width:90px;height:90px}.customize-option.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-755px -288px;width:60px;height:60px}.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -364px;width:90px;height:90px}.customize-option.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -379px;width:60px;height:60px}.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-730px -455px;width:90px;height:90px}.customize-option.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-755px -470px;width:60px;height:60px}.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -546px;width:90px;height:90px}.customize-option.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -561px;width:60px;height:60px}.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-730px -637px;width:90px;height:90px}.customize-option.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-755px -652px;width:60px;height:60px}.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:0 -728px;width:90px;height:90px}.customize-option.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-25px -743px;width:60px;height:60px}.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-116px -743px;width:60px;height:60px}.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-207px -743px;width:60px;height:60px}.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-298px -743px;width:60px;height:60px}.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-389px -743px;width:60px;height:60px}.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-480px -743px;width:60px;height:60px}.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-571px -743px;width:60px;height:60px}.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-662px -743px;width:60px;height:60px}.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-753px -743px;width:60px;height:60px}.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-821px 0;width:90px;height:90px}.customize-option.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-846px -15px;width:60px;height:60px}.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -91px;width:90px;height:90px}.customize-option.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -106px;width:60px;height:60px}.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-821px -182px;width:90px;height:90px}.customize-option.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-846px -197px;width:60px;height:60px}.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -273px;width:90px;height:90px}.customize-option.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -288px;width:60px;height:60px}.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-821px -364px;width:90px;height:90px}.customize-option.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-846px -379px;width:60px;height:60px}.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -455px;width:90px;height:90px}.customize-option.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -470px;width:60px;height:60px}.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-821px -546px;width:90px;height:90px}.customize-option.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-846px -561px;width:60px;height:60px}.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -637px;width:90px;height:90px}.customize-option.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -652px;width:60px;height:60px}.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-821px -728px;width:90px;height:90px}.customize-option.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-846px -743px;width:60px;height:60px}.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:0 -819px;width:90px;height:90px}.customize-option.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-25px -834px;width:60px;height:60px}.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-116px -834px;width:60px;height:60px}.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-207px -834px;width:60px;height:60px}.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-298px -834px;width:60px;height:60px}.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-389px -834px;width:60px;height:60px}.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-480px -834px;width:60px;height:60px}.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-571px -834px;width:60px;height:60px}.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-662px -834px;width:60px;height:60px}.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-753px -834px;width:60px;height:60px}.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-844px -834px;width:60px;height:60px}.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px 0;width:90px;height:90px}.customize-option.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -15px;width:60px;height:60px}.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-912px -91px;width:90px;height:90px}.customize-option.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-937px -106px;width:60px;height:60px}.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -182px;width:90px;height:90px}.customize-option.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -197px;width:60px;height:60px}.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-912px -273px;width:90px;height:90px}.customize-option.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-937px -288px;width:60px;height:60px}.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -364px;width:90px;height:90px}.customize-option.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -379px;width:60px;height:60px}.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-912px -455px;width:90px;height:90px}.customize-option.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-937px -470px;width:60px;height:60px}.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -546px;width:90px;height:90px}.customize-option.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -561px;width:60px;height:60px}.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-912px -637px;width:90px;height:90px}.customize-option.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-937px -652px;width:60px;height:60px}.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -728px;width:90px;height:90px}.customize-option.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -743px;width:60px;height:60px}.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-912px -819px;width:90px;height:90px}.customize-option.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-937px -834px;width:60px;height:60px}.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:0 -910px;width:90px;height:90px}.customize-option.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-25px -925px;width:60px;height:60px}.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-116px -925px;width:60px;height:60px}.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-207px -925px;width:60px;height:60px}.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-298px -925px;width:60px;height:60px}.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-389px -925px;width:60px;height:60px}.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-480px -925px;width:60px;height:60px}.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-571px -925px;width:60px;height:60px}.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-662px -925px;width:60px;height:60px}.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-753px -925px;width:60px;height:60px}.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-844px -925px;width:60px;height:60px}.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-935px -925px;width:60px;height:60px}.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-1003px 0;width:90px;height:90px}.customize-option.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-1028px -15px;width:60px;height:60px}.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -91px;width:90px;height:90px}.customize-option.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -106px;width:60px;height:60px}.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-1003px -182px;width:90px;height:90px}.customize-option.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-1028px -197px;width:60px;height:60px}.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-1003px -273px;width:90px;height:90px}.customize-option.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-1028px -288px;width:60px;height:60px}.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -364px;width:90px;height:90px}.customize-option.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -379px;width:60px;height:60px}.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -455px;width:90px;height:90px}.customize-option.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -470px;width:60px;height:60px}.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1003px -546px;width:90px;height:90px}.customize-option.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1028px -561px;width:60px;height:60px}.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -637px;width:90px;height:90px}.customize-option.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -652px;width:60px;height:60px}.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1003px -728px;width:90px;height:90px}.customize-option.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1028px -743px;width:60px;height:60px}.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -819px;width:90px;height:90px}.customize-option.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -834px;width:60px;height:60px}.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1003px -910px;width:90px;height:90px}.customize-option.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1028px -925px;width:60px;height:60px}.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-25px -1016px;width:60px;height:60px}.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1016px;width:60px;height:60px}.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-207px -1016px;width:60px;height:60px}.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-298px -1016px;width:60px;height:60px}.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-389px -1016px;width:60px;height:60px}.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-480px -1016px;width:60px;height:60px}.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-571px -1016px;width:60px;height:60px}.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1016px;width:60px;height:60px}.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-753px -1016px;width:60px;height:60px}.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-844px -1016px;width:60px;height:60px}.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-935px -1016px;width:60px;height:60px}.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-1026px -1016px;width:60px;height:60px}.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px 0;width:90px;height:90px}.customize-option.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -15px;width:60px;height:60px}.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-1094px -91px;width:90px;height:90px}.customize-option.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-1119px -106px;width:60px;height:60px}.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -182px;width:90px;height:90px}.customize-option.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -197px;width:60px;height:60px}.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-1094px -273px;width:90px;height:90px}.customize-option.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-1119px -288px;width:60px;height:60px}.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -364px;width:90px;height:90px}.customize-option.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -379px;width:60px;height:60px}.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-1094px -455px;width:90px;height:90px}.customize-option.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-1119px -470px;width:60px;height:60px}.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1094px -546px;width:90px;height:90px}.customize-option.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1119px -561px;width:60px;height:60px}.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -637px;width:90px;height:90px}.customize-option.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -652px;width:60px;height:60px}.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -728px;width:90px;height:90px}.customize-option.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -743px;width:60px;height:60px}.broad_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1094px -819px;width:90px;height:90px}.broad_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1094px -910px;width:90px;height:90px}.broad_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1094px -1001px;width:90px;height:90px}.broad_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:0 -1092px;width:90px;height:90px}.broad_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-91px -1092px;width:90px;height:90px}.broad_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-182px -1092px;width:90px;height:90px}.eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-273px -1092px;width:90px;height:90px}.head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-364px -1092px;width:90px;height:90px}.head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-455px -1092px;width:90px;height:90px}.head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-546px -1092px;width:90px;height:90px}.head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-637px -1092px;width:90px;height:90px}.head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-728px -1092px;width:90px;height:90px}.head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-819px -1092px;width:90px;height:90px}.head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-910px -1092px;width:90px;height:90px}.head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-1001px -1092px;width:90px;height:90px}.head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1092px -1092px;width:90px;height:90px}.head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1185px 0;width:90px;height:90px}.head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1185px -91px;width:90px;height:90px}.head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1185px -182px;width:90px;height:90px}.head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1185px -273px;width:90px;height:90px}.head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1185px -364px;width:90px;height:90px}.shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1185px -455px;width:90px;height:90px}.shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1185px -546px;width:90px;height:90px}.shop_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -820px;width:40px;height:40px}.shop_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1640px -779px;width:40px;height:40px}.shop_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -656px;width:40px;height:40px}.shop_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -615px;width:40px;height:40px}.shop_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1640px -574px;width:40px;height:40px}.shop_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -451px;width:40px;height:40px}.shop_eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1640px -410px;width:40px;height:40px}.shop_head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -369px;width:40px;height:40px}.shop_head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1640px -246px;width:40px;height:40px}.shop_head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -205px;width:40px;height:40px}.shop_head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-1640px -164px;width:40px;height:40px}.shop_head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -41px;width:40px;height:40px}.shop_head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-1640px 0;width:40px;height:40px}.shop_head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-1599px -1588px;width:40px;height:40px}.shop_head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-1476px -1588px;width:40px;height:40px}.shop_head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1435px -1588px;width:40px;height:40px}.shop_head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1394px -1588px;width:40px;height:40px}.shop_head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1271px -1588px;width:40px;height:40px}.shop_head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1230px -1588px;width:40px;height:40px}.shop_head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1107px -1588px;width:40px;height:40px}.shop_head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1066px -1588px;width:40px;height:40px}.shop_shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1025px -1588px;width:40px;height:40px}.shop_shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-902px -1588px;width:40px;height:40px}.shop_weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-861px -1588px;width:40px;height:40px}.shop_weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-820px -1588px;width:40px;height:40px}.shop_weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-656px -1588px;width:40px;height:40px}.shop_weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-615px -1588px;width:40px;height:40px}.shop_weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-574px -1588px;width:40px;height:40px}.shop_weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-533px -1588px;width:40px;height:40px}.shop_weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-410px -1588px;width:40px;height:40px}.slim_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1276px -910px;width:90px;height:90px}.slim_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1276px -1001px;width:90px;height:90px}.slim_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1276px -1092px;width:90px;height:90px}.slim_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1276px -1183px;width:90px;height:90px}.slim_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-545px -91px;width:90px;height:90px}.slim_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-91px -1274px;width:90px;height:90px}.weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1274px;width:90px;height:90px}.weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-273px -1274px;width:90px;height:90px}.weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-364px -1274px;width:90px;height:90px}.weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-455px -1274px;width:90px;height:90px}.weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-546px -1274px;width:90px;height:90px}.weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-637px -1274px;width:90px;height:90px}.weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-728px -1274px;width:90px;height:90px}.broad_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-819px -1274px;width:90px;height:90px}.broad_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-910px -1274px;width:90px;height:90px}.broad_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1001px -1274px;width:90px;height:90px}.broad_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1092px -1274px;width:90px;height:90px}.broad_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1183px -1274px;width:90px;height:90px}.broad_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1274px -1274px;width:90px;height:90px}.broad_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1367px 0;width:90px;height:90px}.broad_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1367px -91px;width:90px;height:90px}.broad_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1367px -182px;width:90px;height:90px}.broad_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1367px -273px;width:90px;height:90px}.broad_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1367px -364px;width:90px;height:90px}.broad_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -455px;width:90px;height:90px}.broad_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1367px -546px;width:90px;height:90px}.broad_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1367px -637px;width:90px;height:90px}.broad_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1367px -728px;width:90px;height:90px}.broad_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1367px -819px;width:90px;height:90px}.broad_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1367px -910px;width:90px;height:90px}.broad_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1367px -1001px;width:90px;height:90px}.broad_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1367px -1092px;width:90px;height:90px}.broad_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1367px -1183px;width:90px;height:90px}.broad_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1367px -1274px;width:90px;height:90px}.broad_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:0 -1365px;width:90px;height:90px}.shop_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-369px -1588px;width:40px;height:40px}.shop_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-454px -291px;width:40px;height:40px}.shop_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-287px -1588px;width:40px;height:40px}.shop_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-246px -1588px;width:40px;height:40px}.shop_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-205px -1588px;width:40px;height:40px}.shop_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-164px -1588px;width:40px;height:40px}.shop_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-123px -1588px;width:40px;height:40px}.shop_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-82px -1588px;width:40px;height:40px}.shop_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-41px -1588px;width:40px;height:40px}.shop_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:0 -1588px;width:40px;height:40px}.shop_armor_special_0{background-image:url(spritesmith-main-3.png);background-position:-1599px -1547px;width:40px;height:40px}.shop_armor_special_1{background-image:url(spritesmith-main-3.png);background-position:-1558px -1547px;width:40px;height:40px}.shop_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1517px -1547px;width:40px;height:40px}.shop_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1476px -1547px;width:40px;height:40px}.shop_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1435px -1547px;width:40px;height:40px}.shop_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1394px -1547px;width:40px;height:40px}.shop_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1353px -1547px;width:40px;height:40px}.shop_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-82px -1547px;width:40px;height:40px}.shop_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-41px -1547px;width:40px;height:40px}.shop_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:0 -1547px;width:40px;height:40px}.shop_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-470px -405px;width:40px;height:40px}.shop_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-470px -364px;width:40px;height:40px}.shop_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-400px -314px;width:40px;height:40px}.shop_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-400px -273px;width:40px;height:40px}.slim_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -819px;width:90px;height:90px}.slim_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -910px;width:90px;height:90px}.slim_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -1001px;width:90px;height:90px}.slim_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -1092px;width:90px;height:90px}.slim_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -1183px;width:90px;height:90px}.slim_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -1274px;width:90px;height:90px}.slim_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -1365px;width:90px;height:90px}.slim_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:0 -1456px;width:90px;height:90px}.slim_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-91px -1456px;width:90px;height:90px}.slim_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-182px -1456px;width:90px;height:90px}.slim_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-273px -1456px;width:90px;height:90px}.slim_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-364px -1456px;width:90px;height:90px}.slim_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-455px -1456px;width:90px;height:90px}.slim_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-546px -1456px;width:90px;height:90px}.slim_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-637px -1456px;width:90px;height:90px}.slim_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-728px -1456px;width:90px;height:90px}.slim_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-819px -1456px;width:90px;height:90px}.slim_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-910px -1456px;width:90px;height:90px}.slim_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1456px;width:90px;height:90px}.slim_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1092px -1456px;width:90px;height:90px}.slim_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1183px -1456px;width:90px;height:90px}.slim_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1456px;width:90px;height:90px}.broad_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1365px -1456px;width:90px;height:90px}.broad_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-1456px -1456px;width:90px;height:90px}.shop_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-328px -1588px;width:40px;height:40px}.shop_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-495px -291px;width:40px;height:40px}.slim_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1549px -182px;width:90px;height:90px}.slim_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-1549px -273px;width:90px;height:90px}.broad_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-212px -273px;width:93px;height:90px}.broad_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:0 -273px;width:105px;height:90px}.broad_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1549px -546px;width:90px;height:90px}.broad_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1549px -637px;width:90px;height:90px}.broad_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1549px -728px;width:90px;height:90px}.broad_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 0;width:120px;height:90px}.broad_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-242px -91px;width:105px;height:90px}.broad_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1549px -1001px;width:90px;height:90px}.head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-545px 0;width:93px;height:90px}.head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-212px -182px;width:105px;height:90px}.head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1549px -1274px;width:90px;height:90px}.head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1549px -1365px;width:90px;height:90px}.head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1549px -1456px;width:90px;height:90px}.head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px 0;width:120px;height:90px}.head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:0 -182px;width:105px;height:90px}.head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1458px -728px;width:90px;height:90px}.shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-188px -364px;width:93px;height:90px}.shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-348px -91px;width:105px;height:90px}.shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1458px -455px;width:90px;height:90px}.shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1458px -364px;width:90px;height:90px}.shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-106px -273px;width:105px;height:90px}.shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1458px -182px;width:90px;height:90px}.shop_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-123px -1547px;width:40px;height:40px}.shop_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-164px -1547px;width:40px;height:40px}.shop_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-205px -1547px;width:40px;height:40px}.shop_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-246px -1547px;width:40px;height:40px}.shop_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-287px -1547px;width:40px;height:40px}.shop_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-328px -1547px;width:40px;height:40px}.shop_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-369px -1547px;width:40px;height:40px}.shop_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-410px -1547px;width:40px;height:40px}.shop_head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-451px -1547px;width:40px;height:40px}.shop_head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-492px -1547px;width:40px;height:40px}.shop_head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-533px -1547px;width:40px;height:40px}.shop_head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-574px -1547px;width:40px;height:40px}.shop_head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-615px -1547px;width:40px;height:40px}.shop_head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-656px -1547px;width:40px;height:40px}.shop_head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-697px -1547px;width:40px;height:40px}.shop_head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-738px -1547px;width:40px;height:40px}.shop_shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-779px -1547px;width:40px;height:40px}.shop_shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-820px -1547px;width:40px;height:40px}.shop_shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-861px -1547px;width:40px;height:40px}.shop_shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-902px -1547px;width:40px;height:40px}.shop_shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-943px -1547px;width:40px;height:40px}.shop_shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-984px -1547px;width:40px;height:40px}.shop_weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-1025px -1547px;width:40px;height:40px}.shop_weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-1066px -1547px;width:40px;height:40px}.shop_weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1107px -1547px;width:40px;height:40px}.shop_weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1148px -1547px;width:40px;height:40px}.shop_weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1189px -1547px;width:40px;height:40px}.shop_weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-1230px -1547px;width:40px;height:40px}.shop_weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1271px -1547px;width:40px;height:40px}.shop_weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1312px -1547px;width:40px;height:40px}.slim_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-306px -273px;width:93px;height:90px}.slim_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-242px 0;width:105px;height:90px}.slim_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1365px -1365px;width:90px;height:90px}.slim_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1274px -1365px;width:90px;height:90px}.slim_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1183px -1365px;width:90px;height:90px}.slim_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px -91px;width:120px;height:90px}.slim_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px -182px;width:105px;height:90px}.slim_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-910px -1365px;width:90px;height:90px}.weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:0 -364px;width:93px;height:90px}.weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-348px 0;width:105px;height:90px}.weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-637px -1365px;width:90px;height:90px}.weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-546px -1365px;width:90px;height:90px}.weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-455px -1365px;width:90px;height:90px}.weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 -91px;width:120px;height:90px}.weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-106px -182px;width:105px;height:90px}.weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-182px -1365px;width:90px;height:90px}.broad_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-91px -1365px;width:90px;height:90px}.head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -819px;width:90px;height:90px}.shop_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-451px -1588px;width:40px;height:40px}.shop_head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-492px -1588px;width:40px;height:40px}.slim_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -728px;width:90px;height:90px}.back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -637px;width:90px;height:90px}.broad_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -546px;width:90px;height:90px}.head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -455px;width:90px;height:90px}.shop_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-697px -1588px;width:40px;height:40px}.shop_back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-738px -1588px;width:40px;height:40px}.shop_head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-779px -1588px;width:40px;height:40px}.slim_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -364px;width:90px;height:90px}.broad_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1276px -273px;width:90px;height:90px}.headAccessory_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1276px -182px;width:90px;height:90px}.shop_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-943px -1588px;width:40px;height:40px}.shop_headAccessory_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-984px -1588px;width:40px;height:40px}.slim_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1276px -91px;width:90px;height:90px}.back_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-1276px 0;width:90px;height:90px}.headAccessory_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-1183px -1183px;width:90px;height:90px}.shop_back_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-1148px -1588px;width:40px;height:40px}.shop_headAccessory_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-1189px -1588px;width:40px;height:40px}.broad_armor_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1092px -1183px;width:90px;height:90px}.head_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1001px -1183px;width:90px;height:90px}.shop_armor_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1312px -1588px;width:40px;height:40px}.shop_head_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1353px -1588px;width:40px;height:40px}.slim_armor_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-910px -1183px;width:90px;height:90px}.broad_armor_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-454px 0;width:90px;height:96px}.head_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-454px -97px;width:90px;height:96px}.shop_armor_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-1517px -1588px;width:40px;height:40px}.shop_head_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-1558px -1588px;width:40px;height:40px}.slim_armor_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-454px -194px;width:90px;height:96px}.broad_armor_mystery_201407{background-image:url(spritesmith-main-3.png);background-position:-546px -1183px;width:90px;height:90px}.head_mystery_201407{background-image:url(spritesmith-main-3.png);background-position:-455px -1183px;width:90px;height:90px}.shop_armor_mystery_201407{background-image:url(spritesmith-main-3.png);background-position:-1640px -82px;width:40px;height:40px}.shop_head_mystery_201407{background-image:url(spritesmith-main-3.png);background-position:-1640px -123px;width:40px;height:40px}.slim_armor_mystery_201407{background-image:url(spritesmith-main-3.png);background-position:-364px -1183px;width:90px;height:90px}.broad_armor_mystery_201408{background-image:url(spritesmith-main-3.png);background-position:-273px -1183px;width:90px;height:90px}.head_mystery_201408{background-image:url(spritesmith-main-3.png);background-position:-182px -1183px;width:90px;height:90px}.shop_armor_mystery_201408{background-image:url(spritesmith-main-3.png);background-position:-1640px -287px;width:40px;height:40px}.shop_head_mystery_201408{background-image:url(spritesmith-main-3.png);background-position:-1640px -328px;width:40px;height:40px}.slim_armor_mystery_201408{background-image:url(spritesmith-main-3.png);background-position:-91px -1183px;width:90px;height:90px}.broad_armor_mystery_201409{background-image:url(spritesmith-main-3.png);background-position:0 -1183px;width:90px;height:90px}.headAccessory_mystery_201409{background-image:url(spritesmith-main-3.png);background-position:-1185px -1092px;width:90px;height:90px}.shop_armor_mystery_201409{background-image:url(spritesmith-main-3.png);background-position:-1640px -492px;width:40px;height:40px}.shop_headAccessory_mystery_201409{background-image:url(spritesmith-main-3.png);background-position:-1640px -533px;width:40px;height:40px}.slim_armor_mystery_201409{background-image:url(spritesmith-main-3.png);background-position:-1185px -1001px;width:90px;height:90px}.back_mystery_201410{background-image:url(spritesmith-main-3.png);background-position:-282px -364px;width:93px;height:90px}.broad_armor_mystery_201410{background-image:url(spritesmith-main-3.png);background-position:-376px -364px;width:93px;height:90px}.shop_armor_mystery_201410{background-image:url(spritesmith-main-3.png);background-position:-1640px -697px;width:40px;height:40px}.shop_back_mystery_201410{background-image:url(spritesmith-main-3.png);background-position:-1640px -738px;width:40px;height:40px}.slim_armor_mystery_201410{background-image:url(spritesmith-main-3.png);background-position:-94px -364px;width:93px;height:90px}.head_mystery_201411{background-image:url(spritesmith-main-3.png);background-position:-1185px -637px;width:90px;height:90px}.shop_head_mystery_201411{background-image:url(spritesmith-main-3.png);background-position:-1640px -861px;width:40px;height:40px}.shop_weapon_mystery_201411{background-image:url(spritesmith-main-3.png);background-position:-1640px -902px;width:40px;height:40px}.weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-819px -1152px;width:90px;height:90px}.broad_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-273px -1425px;width:90px;height:90px}.head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-910px -1152px;width:90px;height:90px}.shop_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1655px -902px;width:40px;height:40px}.shop_head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1655px -861px;width:40px;height:40px}.slim_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1291px -273px;width:90px;height:90px}.broad_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1291px -546px;width:90px;height:90px}.head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1291px -637px;width:90px;height:90px}.shop_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1655px -820px;width:40px;height:40px}.shop_head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1655px -779px;width:40px;height:40px}.slim_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1183px -1334px;width:90px;height:90px}.headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1274px -1334px;width:90px;height:90px}.shop_headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1655px -738px;width:40px;height:40px}.shop_weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1655px -697px;width:40px;height:40px}.weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1018px -819px;width:90px;height:90px}.broad_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:0 -970px;width:90px;height:90px}.eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-91px -970px;width:90px;height:90px}.shop_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1655px -656px;width:40px;height:40px}.shop_eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1655px -615px;width:40px;height:40px}.slim_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1200px -819px;width:90px;height:90px}.back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1200px -910px;width:90px;height:90px}.broad_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1200px -1001px;width:90px;height:90px}.shop_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1655px -574px;width:40px;height:40px}.shop_back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1655px -533px;width:40px;height:40px}.slim_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-364px -1152px;width:90px;height:90px}.head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-455px -1152px;width:90px;height:90px}.shop_head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1655px -492px;width:40px;height:40px}.shop_weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1655px -451px;width:40px;height:40px}.weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-728px -1152px;width:90px;height:90px}.broad_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-182px -485px;width:90px;height:105px}.eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:0 -485px;width:90px;height:105px}.shop_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1655px -410px;width:40px;height:40px}.shop_eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1655px -369px;width:40px;height:40px}.slim_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-273px -591px;width:90px;height:105px}.back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-739px 0;width:90px;height:105px}.eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-637px -591px;width:90px;height:105px}.shop_back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-1655px -328px;width:40px;height:40px}.shop_eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-1655px -287px;width:40px;height:40px}.broad_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -455px;width:93px;height:90px}.head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -364px;width:93px;height:90px}.shop_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-1655px -246px;width:40px;height:40px}.shop_head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-1655px -205px;width:40px;height:40px}.slim_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -273px;width:93px;height:90px}.broad_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1291px -1092px;width:90px;height:90px}.head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:0 -1243px;width:90px;height:90px}.shop_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1655px -164px;width:40px;height:40px}.shop_head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1655px -123px;width:40px;height:40px}.slim_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-364px -1334px;width:90px;height:90px}.back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-536px -394px;width:105px;height:90px}.headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-830px -182px;width:93px;height:90px}.shop_back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-1655px -82px;width:40px;height:40px}.shop_headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-1655px -41px;width:40px;height:40px}.broad_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1473px -91px;width:90px;height:90px}.eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1473px -182px;width:90px;height:90px}.head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1473px -455px;width:90px;height:90px}.shop_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1655px 0;width:40px;height:40px}.shop_eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1599px -1598px;width:40px;height:40px}.shop_head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1558px -1598px;width:40px;height:40px}.shop_weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1517px -1598px;width:40px;height:40px}.slim_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1473px -910px;width:90px;height:90px}.weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-91px -1425px;width:90px;height:90px}.eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1001px -1425px;width:90px;height:90px}.headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1274px -1425px;width:90px;height:90px}.head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1564px 0;width:90px;height:90px}.shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-830px -637px;width:90px;height:90px}.shop_eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1476px -1598px;width:40px;height:40px}.shop_headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1435px -1598px;width:40px;height:40px}.shop_head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1394px -1598px;width:40px;height:40px}.shop_shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1353px -1598px;width:40px;height:40px}.broad_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-739px -576px;width:90px;height:90px}.broad_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -788px;width:90px;height:90px}.broad_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-91px -788px;width:90px;height:90px}.broad_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-182px -788px;width:90px;height:90px}.broad_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-273px -788px;width:90px;height:90px}.broad_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-364px -788px;width:90px;height:90px}.broad_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-455px -788px;width:90px;height:90px}.broad_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-546px -788px;width:90px;height:90px}.headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-637px -788px;width:90px;height:90px}.headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-728px -788px;width:90px;height:90px}.headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-819px -788px;width:90px;height:90px}.headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-927px 0;width:90px;height:90px}.headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-927px -91px;width:90px;height:90px}.headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-927px -182px;width:90px;height:90px}.headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-927px -273px;width:90px;height:90px}.headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-927px -364px;width:90px;height:90px}.head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-927px -455px;width:90px;height:90px}.head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-927px -546px;width:90px;height:90px}.head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-927px -637px;width:90px;height:90px}.head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-927px -728px;width:90px;height:90px}.head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:0 -879px;width:90px;height:90px}.head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-91px -879px;width:90px;height:90px}.head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-182px -879px;width:90px;height:90px}.head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-273px -879px;width:90px;height:90px}.shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-364px -879px;width:90px;height:90px}.shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-455px -879px;width:90px;height:90px}.shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -879px;width:90px;height:90px}.shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-637px -879px;width:90px;height:90px}.shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-728px -879px;width:90px;height:90px}.shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-819px -879px;width:90px;height:90px}.shop_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1312px -1598px;width:40px;height:40px}.shop_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-656px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-615px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-574px -1557px;width:40px;height:40px}.shop_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-533px -1557px;width:40px;height:40px}.shop_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-492px -1557px;width:40px;height:40px}.shop_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-451px -1557px;width:40px;height:40px}.shop_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-410px -1557px;width:40px;height:40px}.shop_headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-369px -1557px;width:40px;height:40px}.shop_headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-328px -1557px;width:40px;height:40px}.shop_headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-287px -1557px;width:40px;height:40px}.shop_headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-246px -1557px;width:40px;height:40px}.shop_headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-205px -1557px;width:40px;height:40px}.shop_headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-164px -1557px;width:40px;height:40px}.shop_headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-123px -1557px;width:40px;height:40px}.shop_headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-82px -1557px;width:40px;height:40px}.shop_head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-41px -1557px;width:40px;height:40px}.shop_head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -1557px;width:40px;height:40px}.shop_head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1599px -1516px;width:40px;height:40px}.shop_head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1558px -1516px;width:40px;height:40px}.shop_head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1517px -1516px;width:40px;height:40px}.shop_head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1148px -1516px;width:40px;height:40px}.shop_head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1655px -943px;width:40px;height:40px}.shop_head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1066px -1516px;width:40px;height:40px}.shop_shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1025px -1516px;width:40px;height:40px}.shop_shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-984px -1516px;width:40px;height:40px}.shop_shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-943px -1516px;width:40px;height:40px}.shop_shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-902px -1516px;width:40px;height:40px}.shop_shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-861px -1516px;width:40px;height:40px}.shop_shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-448px -394px;width:40px;height:40px}.shop_weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-377px -344px;width:40px;height:40px}.shop_weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-336px -344px;width:40px;height:40px}.shop_weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-377px -303px;width:40px;height:40px}.shop_weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-336px -303px;width:40px;height:40px}.shop_weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-230px -253px;width:40px;height:40px}.shop_weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-230px -212px;width:40px;height:40px}.shop_weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-689px -530px;width:40px;height:40px}.shop_weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-648px -530px;width:40px;height:40px}.slim_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-364px -1061px;width:90px;height:90px}.slim_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-455px -1061px;width:90px;height:90px}.slim_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-546px -1061px;width:90px;height:90px}.slim_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-637px -1061px;width:90px;height:90px}.slim_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-728px -1061px;width:90px;height:90px}.slim_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-819px -1061px;width:90px;height:90px}.slim_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-910px -1061px;width:90px;height:90px}.slim_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1001px -1061px;width:90px;height:90px}.weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1092px -1061px;width:90px;height:90px}.weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px 0;width:90px;height:90px}.weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1200px -91px;width:90px;height:90px}.weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1200px -182px;width:90px;height:90px}.weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1200px -273px;width:90px;height:90px}.weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1200px -364px;width:90px;height:90px}.weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1200px -455px;width:90px;height:90px}.weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1200px -546px;width:90px;height:90px}.body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1200px -637px;width:90px;height:90px}.body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -728px;width:90px;height:90px}.body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 -106px;width:102px;height:105px}.body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-648px -212px;width:90px;height:105px}.body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-546px -485px;width:90px;height:105px}.body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-455px -485px;width:90px;height:105px}.broad_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1152px;width:90px;height:90px}.broad_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-182px -1152px;width:90px;height:90px}.broad_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px -106px;width:102px;height:105px}.broad_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-91px -485px;width:90px;height:105px}.broad_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:0 -591px;width:90px;height:105px}.broad_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-536px -288px;width:90px;height:105px}.broad_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-536px -91px;width:111px;height:90px}.broad_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-536px 0;width:111px;height:90px}.eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-336px -394px;width:111px;height:90px}.eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -91px;width:111px;height:90px}.head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1001px -1152px;width:90px;height:90px}.head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1092px -1152px;width:90px;height:90px}.head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px -106px;width:102px;height:105px}.head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-739px -106px;width:90px;height:105px}.head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-273px -485px;width:90px;height:105px}.head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-364px -485px;width:90px;height:105px}.head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -394px;width:111px;height:90px}.head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -273px;width:111px;height:90px}.Healer_Summer{background-image:url(spritesmith-main-4.png);background-position:-648px 0;width:90px;height:105px}.Mage_Summer{background-image:url(spritesmith-main-4.png);background-position:-648px -106px;width:90px;height:105px}.SummerRogue14{background-image:url(spritesmith-main-4.png);background-position:-424px -182px;width:111px;height:90px}.SummerWarrior14{background-image:url(spritesmith-main-4.png);background-position:-424px 0;width:111px;height:90px}.shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -819px;width:90px;height:90px}.shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px 0;width:102px;height:105px}.shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-536px -182px;width:90px;height:105px}.shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-91px -591px;width:90px;height:105px}.shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-424px -91px;width:111px;height:90px}.shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-224px -303px;width:111px;height:90px}.shop_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-871px -728px;width:40px;height:40px}.shop_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-830px -728px;width:40px;height:40px}.shop_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-968px -819px;width:40px;height:40px}.shop_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-927px -819px;width:40px;height:40px}.shop_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1059px -910px;width:40px;height:40px}.shop_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1018px -910px;width:40px;height:40px}.shop_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1150px -1001px;width:40px;height:40px}.shop_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -1001px;width:40px;height:40px}.shop_body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1241px -1092px;width:40px;height:40px}.shop_body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1092px;width:40px;height:40px}.shop_body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1332px -1183px;width:40px;height:40px}.shop_body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -1183px;width:40px;height:40px}.shop_body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1423px -1274px;width:40px;height:40px}.shop_body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1382px -1274px;width:40px;height:40px}.shop_eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1380px;width:40px;height:40px}.shop_eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -1339px;width:40px;height:40px}.shop_head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1339px;width:40px;height:40px}.shop_head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1605px -1298px;width:40px;height:40px}.shop_head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1298px;width:40px;height:40px}.shop_head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -1257px;width:40px;height:40px}.shop_head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1257px;width:40px;height:40px}.shop_head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1605px -1216px;width:40px;height:40px}.shop_head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1216px;width:40px;height:40px}.shop_head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -1175px;width:40px;height:40px}.shop_shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1175px;width:40px;height:40px}.shop_shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -1134px;width:40px;height:40px}.shop_shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1134px;width:40px;height:40px}.shop_shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1605px -1093px;width:40px;height:40px}.shop_shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1093px;width:40px;height:40px}.shop_shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -1052px;width:40px;height:40px}.shop_weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1052px;width:40px;height:40px}.shop_weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1605px -1011px;width:40px;height:40px}.shop_weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1011px;width:40px;height:40px}.shop_weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -970px;width:40px;height:40px}.shop_weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -970px;width:40px;height:40px}.shop_weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1605px -929px;width:40px;height:40px}.shop_weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -929px;width:40px;height:40px}.shop_weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -273px;width:40px;height:40px}.slim_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1001px -1334px;width:90px;height:90px}.slim_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1092px -1334px;width:90px;height:90px}.slim_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px 0;width:102px;height:105px}.slim_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-364px -591px;width:90px;height:105px}.slim_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -591px;width:90px;height:105px}.slim_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-546px -591px;width:90px;height:105px}.slim_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -303px;width:111px;height:90px}.slim_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -182px;width:111px;height:90px}.weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px -273px;width:90px;height:90px}.weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1473px -364px;width:90px;height:90px}.weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 0;width:102px;height:105px}.weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-182px -591px;width:90px;height:105px}.weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-648px -424px;width:90px;height:105px}.weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -318px;width:90px;height:105px}.weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-112px -303px;width:111px;height:90px}.weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-112px -394px;width:111px;height:90px}.broad_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -1001px;width:90px;height:90px}.broad_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -1092px;width:90px;height:90px}.broad_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -1183px;width:90px;height:90px}.broad_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px -1274px;width:90px;height:90px}.broad_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -1425px;width:90px;height:90px}.broad_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-707px -697px;width:96px;height:90px}.broad_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-182px -1425px;width:90px;height:90px}.broad_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-830px -546px;width:90px;height:90px}.head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-364px -1425px;width:90px;height:90px}.head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-455px -1425px;width:90px;height:90px}.head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-546px -1425px;width:90px;height:90px}.head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-637px -1425px;width:90px;height:90px}.head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-728px -1425px;width:90px;height:90px}.head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-819px -1425px;width:90px;height:90px}.head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-910px -1425px;width:90px;height:90px}.head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-830px 0;width:96px;height:90px}.head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1092px -1425px;width:90px;height:90px}.head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1183px -1425px;width:90px;height:90px}.shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -697px;width:104px;height:90px}.shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1365px -1425px;width:90px;height:90px}.shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1456px -1425px;width:90px;height:90px}.shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-513px -697px;width:96px;height:90px}.shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -91px;width:90px;height:90px}.shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-910px -1334px;width:90px;height:90px}.shop_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1107px -1516px;width:40px;height:40px}.shop_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1605px -273px;width:40px;height:40px}.shop_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1564px -314px;width:40px;height:40px}.shop_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -314px;width:40px;height:40px}.shop_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -355px;width:40px;height:40px}.shop_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -355px;width:40px;height:40px}.shop_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -396px;width:40px;height:40px}.shop_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1605px -396px;width:40px;height:40px}.shop_head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1564px -437px;width:40px;height:40px}.shop_head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-1605px -437px;width:40px;height:40px}.shop_head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-1564px -478px;width:40px;height:40px}.shop_head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1605px -478px;width:40px;height:40px}.shop_head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1564px -519px;width:40px;height:40px}.shop_head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -519px;width:40px;height:40px}.shop_head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -560px;width:40px;height:40px}.shop_head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -560px;width:40px;height:40px}.shop_head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -601px;width:40px;height:40px}.shop_head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1605px -601px;width:40px;height:40px}.shop_shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1564px -642px;width:40px;height:40px}.shop_shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1605px -642px;width:40px;height:40px}.shop_shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -683px;width:40px;height:40px}.shop_shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -683px;width:40px;height:40px}.shop_shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -724px;width:40px;height:40px}.shop_shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1605px -724px;width:40px;height:40px}.shop_weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1564px -765px;width:40px;height:40px}.shop_weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1605px -765px;width:40px;height:40px}.shop_weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1564px -806px;width:40px;height:40px}.shop_weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -806px;width:40px;height:40px}.shop_weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -847px;width:40px;height:40px}.shop_weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -847px;width:40px;height:40px}.shop_weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -888px;width:40px;height:40px}.shop_weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1605px -888px;width:40px;height:40px}.slim_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-819px -1334px;width:90px;height:90px}.slim_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-728px -1334px;width:90px;height:90px}.slim_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-637px -1334px;width:90px;height:90px}.slim_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-546px -1334px;width:90px;height:90px}.slim_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-455px -1334px;width:90px;height:90px}.slim_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-416px -697px;width:96px;height:90px}.slim_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -1334px;width:90px;height:90px}.slim_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-182px -1334px;width:90px;height:90px}.weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-91px -1334px;width:90px;height:90px}.weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -1334px;width:90px;height:90px}.weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1382px -1183px;width:90px;height:90px}.weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px -1092px;width:90px;height:90px}.weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1382px -1001px;width:90px;height:90px}.weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-830px -91px;width:96px;height:90px}.weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1382px -819px;width:90px;height:90px}.weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1382px -728px;width:90px;height:90px}.back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1382px -637px;width:90px;height:90px}.back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1382px -546px;width:90px;height:90px}.body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1382px -455px;width:90px;height:90px}.body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1382px -364px;width:90px;height:90px}.body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1382px -273px;width:90px;height:90px}.eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1382px -182px;width:90px;height:90px}.eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1382px -91px;width:90px;height:90px}.shop_back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1605px -1380px;width:40px;height:40px}.shop_back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1421px;width:40px;height:40px}.shop_body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1605px -1421px;width:40px;height:40px}.shop_body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1564px -1462px;width:40px;height:40px}.shop_body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1605px -1462px;width:40px;height:40px}.shop_eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1473px -1365px;width:40px;height:40px}.shop_eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1514px -1365px;width:40px;height:40px}.head_0{background-image:url(spritesmith-main-4.png);background-position:-1382px 0;width:90px;height:90px}.customize-option.head_0{background-image:url(spritesmith-main-4.png);background-position:-1407px -15px;width:60px;height:60px}.head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1274px -1243px;width:90px;height:90px}.head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1183px -1243px;width:90px;height:90px}.head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1092px -1243px;width:90px;height:90px}.head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1001px -1243px;width:90px;height:90px}.head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-910px -1243px;width:90px;height:90px}.head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-819px -1243px;width:90px;height:90px}.head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-728px -1243px;width:90px;height:90px}.head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-637px -1243px;width:90px;height:90px}.head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-546px -1243px;width:90px;height:90px}.head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-455px -1243px;width:90px;height:90px}.head_special_2{background-image:url(spritesmith-main-4.png);background-position:-364px -1243px;width:90px;height:90px}.head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-273px -1243px;width:90px;height:90px}.head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-182px -1243px;width:90px;height:90px}.head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-273px -1061px;width:90px;height:90px}.head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-182px -1061px;width:90px;height:90px}.head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-91px -1061px;width:90px;height:90px}.head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:0 -1061px;width:90px;height:90px}.head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1109px -910px;width:90px;height:90px}.head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1109px -819px;width:90px;height:90px}.head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1109px -728px;width:90px;height:90px}.head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1109px -637px;width:90px;height:90px}.head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1109px -546px;width:90px;height:90px}.shop_head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-489px -394px;width:40px;height:40px}.shop_head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-448px -435px;width:40px;height:40px}.shop_head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-489px -435px;width:40px;height:40px}.shop_head_healer_4{background-image:url(spritesmith-main-4.png);background-position:0 -1516px;width:40px;height:40px}.shop_head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-41px -1516px;width:40px;height:40px}.shop_head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-82px -1516px;width:40px;height:40px}.shop_head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-123px -1516px;width:40px;height:40px}.shop_head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-164px -1516px;width:40px;height:40px}.shop_head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-205px -1516px;width:40px;height:40px}.shop_head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-246px -1516px;width:40px;height:40px}.shop_head_special_0{background-image:url(spritesmith-main-4.png);background-position:-287px -1516px;width:40px;height:40px}.shop_head_special_1{background-image:url(spritesmith-main-4.png);background-position:-328px -1516px;width:40px;height:40px}.shop_head_special_2{background-image:url(spritesmith-main-4.png);background-position:-369px -1516px;width:40px;height:40px}.shop_head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-410px -1516px;width:40px;height:40px}.shop_head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-451px -1516px;width:40px;height:40px}.shop_head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-492px -1516px;width:40px;height:40px}.shop_head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-533px -1516px;width:40px;height:40px}.shop_head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-574px -1516px;width:40px;height:40px}.shop_head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-615px -1516px;width:40px;height:40px}.shop_head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-656px -1516px;width:40px;height:40px}.shop_head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-697px -1516px;width:40px;height:40px}.shop_head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-738px -1516px;width:40px;height:40px}.shop_head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-779px -1516px;width:40px;height:40px}.shop_head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-820px -1516px;width:40px;height:40px}.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1109px -455px;width:90px;height:90px}.customize-option.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1134px -470px;width:60px;height:60px}.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1109px -364px;width:90px;height:90px}.customize-option.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1134px -379px;width:60px;height:60px}.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1109px -273px;width:90px;height:90px}.customize-option.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1134px -288px;width:60px;height:60px}.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1109px -182px;width:90px;height:90px}.customize-option.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1134px -197px;width:60px;height:60px}.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1109px -91px;width:90px;height:90px}.customize-option.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1134px -106px;width:60px;height:60px}.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1109px 0;width:90px;height:90px}.customize-option.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1134px -15px;width:60px;height:60px}.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1001px -970px;width:90px;height:90px}.customize-option.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1026px -985px;width:60px;height:60px}.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-910px -970px;width:90px;height:90px}.customize-option.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-935px -985px;width:60px;height:60px}.shop_headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1189px -1516px;width:40px;height:40px}.shop_headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1230px -1516px;width:40px;height:40px}.shop_headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1271px -1516px;width:40px;height:40px}.shop_headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1312px -1516px;width:40px;height:40px}.shop_headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1353px -1516px;width:40px;height:40px}.shop_headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1394px -1516px;width:40px;height:40px}.shop_headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1435px -1516px;width:40px;height:40px}.shop_headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-1476px -1516px;width:40px;height:40px}.shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-819px -970px;width:90px;height:90px}.shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-728px -970px;width:90px;height:90px}.shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-637px -970px;width:90px;height:90px}.shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-546px -970px;width:90px;height:90px}.shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-455px -970px;width:90px;height:90px}.shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-364px -970px;width:90px;height:90px}.shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-209px -697px;width:103px;height:90px}.shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-105px -697px;width:103px;height:90px}.shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-309px 0;width:114px;height:90px}.shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-610px -697px;width:96px;height:90px}.shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-115px -212px;width:114px;height:90px}.shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:0 -212px;width:114px;height:90px}.shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-1018px -637px;width:90px;height:90px}.shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-224px -394px;width:111px;height:90px}.shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-1018px -455px;width:90px;height:90px}.shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1018px -364px;width:90px;height:90px}.shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1018px -273px;width:90px;height:90px}.shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1018px -182px;width:90px;height:90px}.shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1018px -91px;width:90px;height:90px}.shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1018px 0;width:90px;height:90px}.shop_shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-697px -1557px;width:40px;height:40px}.shop_shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-738px -1557px;width:40px;height:40px}.shop_shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-779px -1557px;width:40px;height:40px}.shop_shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-820px -1557px;width:40px;height:40px}.shop_shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-861px -1557px;width:40px;height:40px}.shop_shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-902px -1557px;width:40px;height:40px}.shop_shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-943px -1557px;width:40px;height:40px}.shop_shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-984px -1557px;width:40px;height:40px}.shop_shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1025px -1557px;width:40px;height:40px}.shop_shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1066px -1557px;width:40px;height:40px}.shop_shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1107px -1557px;width:40px;height:40px}.shop_shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1148px -1557px;width:40px;height:40px}.shop_shield_special_0{background-image:url(spritesmith-main-4.png);background-position:-1189px -1557px;width:40px;height:40px}.shop_shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-1230px -1557px;width:40px;height:40px}.shop_shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-1271px -1557px;width:40px;height:40px}.shop_shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-1312px -1557px;width:40px;height:40px}.shop_shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1353px -1557px;width:40px;height:40px}.shop_shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1394px -1557px;width:40px;height:40px}.shop_shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1435px -1557px;width:40px;height:40px}.shop_shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1476px -1557px;width:40px;height:40px}.shop_shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1517px -1557px;width:40px;height:40px}.shop_weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-1558px -1557px;width:40px;height:40px}.shop_weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1599px -1557px;width:40px;height:40px}.shop_weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:0 -1598px;width:40px;height:40px}.shop_weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-41px -1598px;width:40px;height:40px}.shop_weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-82px -1598px;width:40px;height:40px}.shop_weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-123px -1598px;width:40px;height:40px}.shop_weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-164px -1598px;width:40px;height:40px}.shop_weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-205px -1598px;width:40px;height:40px}.shop_weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-246px -1598px;width:40px;height:40px}.shop_weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-287px -1598px;width:40px;height:40px}.shop_weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-328px -1598px;width:40px;height:40px}.shop_weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-369px -1598px;width:40px;height:40px}.shop_weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-410px -1598px;width:40px;height:40px}.shop_weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-451px -1598px;width:40px;height:40px}.shop_weapon_special_0{background-image:url(spritesmith-main-4.png);background-position:-492px -1598px;width:40px;height:40px}.shop_weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-533px -1598px;width:40px;height:40px}.shop_weapon_special_2{background-image:url(spritesmith-main-4.png);background-position:-574px -1598px;width:40px;height:40px}.shop_weapon_special_3{background-image:url(spritesmith-main-4.png);background-position:-615px -1598px;width:40px;height:40px}.shop_weapon_special_critical{background-image:url(spritesmith-main-4.png);background-position:-656px -1598px;width:40px;height:40px}.shop_weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-4.png);background-position:-697px -1598px;width:40px;height:40px}.shop_weapon_warrior_0{background-image:url(spritesmith-main-4.png);background-position:-738px -1598px;width:40px;height:40px}.shop_weapon_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-779px -1598px;width:40px;height:40px}.shop_weapon_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-820px -1598px;width:40px;height:40px}.shop_weapon_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-861px -1598px;width:40px;height:40px}.shop_weapon_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-902px -1598px;width:40px;height:40px}.shop_weapon_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-943px -1598px;width:40px;height:40px}.shop_weapon_warrior_6{background-image:url(spritesmith-main-4.png);background-position:-984px -1598px;width:40px;height:40px}.shop_weapon_wizard_0{background-image:url(spritesmith-main-4.png);background-position:-1025px -1598px;width:40px;height:40px}.shop_weapon_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1066px -1598px;width:40px;height:40px}.shop_weapon_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1107px -1598px;width:40px;height:40px}.shop_weapon_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1148px -1598px;width:40px;height:40px}.shop_weapon_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1189px -1598px;width:40px;height:40px}.shop_weapon_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1230px -1598px;width:40px;height:40px}.shop_weapon_wizard_6{background-image:url(spritesmith-main-4.png);background-position:-1271px -1598px;width:40px;height:40px}.weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-910px -879px;width:90px;height:90px}.weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-739px -485px;width:90px;height:90px}.weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-739px -394px;width:90px;height:90px}.weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-739px -303px;width:90px;height:90px}.weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-739px -212px;width:90px;height:90px}.weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-1473px -819px;width:90px;height:90px}.weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-1473px -728px;width:90px;height:90px}.weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-1473px -637px;width:90px;height:90px}.weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1473px -546px;width:90px;height:90px}.weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1473px 0;width:90px;height:90px}.weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1365px -1334px;width:90px;height:90px}.weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1382px -910px;width:90px;height:90px}.weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-91px -1243px;width:90px;height:90px}.weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1291px -910px;width:90px;height:90px}.weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-313px -697px;width:102px;height:90px}.weapon_special_2{background-image:url(spritesmith-main-4.png);background-position:-1291px -455px;width:90px;height:90px}.weapon_special_3{background-image:url(spritesmith-main-4.png);background-position:-1291px -364px;width:90px;height:90px}.weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-4.png);background-position:-1291px 0;width:90px;height:90px}.weapon_warrior_0{background-image:url(spritesmith-main-4.png);background-position:-1183px -1152px;width:90px;height:90px}.weapon_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-637px -1152px;width:90px;height:90px}.weapon_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-546px -1152px;width:90px;height:90px}.weapon_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-273px -1152px;width:90px;height:90px}.weapon_warrior_4{background-image:url(spritesmith-main-4.png);background-position:0 -1152px;width:90px;height:90px}.weapon_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-273px -970px;width:90px;height:90px}.weapon_warrior_6{background-image:url(spritesmith-main-4.png);background-position:-182px -970px;width:90px;height:90px}.weapon_wizard_0{background-image:url(spritesmith-main-4.png);background-position:-1018px -728px;width:90px;height:90px}.weapon_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1018px -546px;width:90px;height:90px}.weapon_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1291px -1001px;width:90px;height:90px}.weapon_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1291px -728px;width:90px;height:90px}.weapon_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1291px -182px;width:90px;height:90px}.weapon_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1291px -91px;width:90px;height:90px}.weapon_wizard_6{background-image:url(spritesmith-main-4.png);background-position:-1564px -182px;width:90px;height:90px}.GrimReaper{background-image:url(spritesmith-main-5.png);background-position:-1642px -255px;width:57px;height:66px}.Pet_Currency_Gem{background-image:url(spritesmith-main-5.png);background-position:-1782px -867px;width:45px;height:39px}.Pet_Currency_Gem1x{background-image:url(spritesmith-main-5.png);background-position:-1815px -1316px;width:15px;height:13px}.Pet_Currency_Gem2x{background-image:url(spritesmith-main-5.png);background-position:-1782px -1479px;width:30px;height:26px}.PixelPaw-Gold{background-image:url(spritesmith-main-5.png);background-position:-1642px -979px;width:51px;height:51px}.PixelPaw{background-image:url(spritesmith-main-5.png);background-position:-1642px -927px;width:51px;height:51px}.PixelPaw002{background-image:url(spritesmith-main-5.png);background-position:-1642px -1031px;width:51px;height:51px}.avatar_floral_healer{background-image:url(spritesmith-main-5.png);background-position:-1536px -1223px;width:99px;height:99px}.avatar_floral_rogue{background-image:url(spritesmith-main-5.png);background-position:-1536px -923px;width:99px;height:99px}.avatar_floral_warrior{background-image:url(spritesmith-main-5.png);background-position:-1536px -1123px;width:99px;height:99px}.avatar_floral_wizard{background-image:url(spritesmith-main-5.png);background-position:-1536px -1023px;width:99px;height:99px}.inventory_present{background-image:url(spritesmith-main-5.png);background-position:-1733px -260px;width:48px;height:51px}.inventory_present_01{background-image:url(spritesmith-main-5.png);background-position:-1733px -416px;width:48px;height:51px}.inventory_present_02{background-image:url(spritesmith-main-5.png);background-position:-1733px -468px;width:48px;height:51px}.inventory_present_03{background-image:url(spritesmith-main-5.png);background-position:-1733px -572px;width:48px;height:51px}.inventory_present_04{background-image:url(spritesmith-main-5.png);background-position:-1733px -624px;width:48px;height:51px}.inventory_present_05{background-image:url(spritesmith-main-5.png);background-position:-1733px -676px;width:48px;height:51px}.inventory_present_06{background-image:url(spritesmith-main-5.png);background-position:-1733px -728px;width:48px;height:51px}.inventory_present_07{background-image:url(spritesmith-main-5.png);background-position:-1733px -780px;width:48px;height:51px}.inventory_present_08{background-image:url(spritesmith-main-5.png);background-position:-1733px -832px;width:48px;height:51px}.inventory_present_09{background-image:url(spritesmith-main-5.png);background-position:-1733px -884px;width:48px;height:51px}.inventory_present_10{background-image:url(spritesmith-main-5.png);background-position:-1733px -936px;width:48px;height:51px}.inventory_present_11{background-image:url(spritesmith-main-5.png);background-position:-931px -1714px;width:48px;height:51px}.inventory_present_12{background-image:url(spritesmith-main-5.png);background-position:-980px -1714px;width:48px;height:51px}.inventory_quest_scroll{background-image:url(spritesmith-main-5.png);background-position:-1029px -1714px;width:48px;height:51px}.inventory_quest_scroll_locked{background-image:url(spritesmith-main-5.png);background-position:-1078px -1714px;width:48px;height:51px}.inventory_special_fortify{background-image:url(spritesmith-main-5.png);background-position:-1642px -872px;width:57px;height:54px}.inventory_special_greeting{background-image:url(spritesmith-main-5.png);background-position:-1642px -432px;width:57px;height:54px}.inventory_special_nye{background-image:url(spritesmith-main-5.png);background-position:-1642px -817px;width:57px;height:54px}.inventory_special_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1782px -1030px;width:40px;height:40px}.inventory_special_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1642px -762px;width:57px;height:54px}.inventory_special_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1642px -322px;width:57px;height:54px}.inventory_special_snowball{background-image:url(spritesmith-main-5.png);background-position:-1642px -652px;width:57px;height:54px}.inventory_special_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1642px -597px;width:57px;height:54px}.inventory_special_thankyou{background-image:url(spritesmith-main-5.png);background-position:-1642px -542px;width:57px;height:54px}.inventory_special_trinket{background-image:url(spritesmith-main-5.png);background-position:-1519px -1662px;width:48px;height:51px}.inventory_special_valentine{background-image:url(spritesmith-main-5.png);background-position:-1642px -487px;width:57px;height:54px}.knockout{background-image:url(spritesmith-main-5.png);background-position:0 -1562px;width:120px;height:47px}.pet_key{background-image:url(spritesmith-main-5.png);background-position:-1642px -707px;width:57px;height:54px}.rebirth_orb{background-image:url(spritesmith-main-5.png);background-position:-1642px -377px;width:57px;height:54px}.seafoam_star{background-image:url(spritesmith-main-5.png);background-position:-1642px -91px;width:90px;height:90px}.shop_armoire{background-image:url(spritesmith-main-5.png);background-position:-1782px -989px;width:40px;height:40px}.snowman{background-image:url(spritesmith-main-5.png);background-position:-1536px -1414px;width:90px;height:90px}.spookman{background-image:url(spritesmith-main-5.png);background-position:-1642px 0;width:90px;height:90px}.zzz{background-image:url(spritesmith-main-5.png);background-position:-1782px -948px;width:40px;height:40px}.zzz_light{background-image:url(spritesmith-main-5.png);background-position:-1782px -907px;width:40px;height:40px}.npc_alex{background-image:url(spritesmith-main-5.png);background-position:0 -1251px;width:162px;height:138px}.npc_bailey{background-image:url(spritesmith-main-5.png);background-position:-1642px -182px;width:60px;height:72px}.npc_daniel{background-image:url(spritesmith-main-5.png);background-position:-625px -1251px;width:135px;height:123px}.npc_daniel_broken{background-image:url(spritesmith-main-5.png);background-position:-489px -1251px;width:135px;height:123px}.npc_ian{background-image:url(spritesmith-main-5.png);background-position:-1536px -666px;width:75px;height:135px}.npc_ian_broken{background-image:url(spritesmith-main-5.png);background-position:-1536px -530px;width:75px;height:135px}.npc_justin{background-image:url(spritesmith-main-5.png);background-position:-1536px -802px;width:84px;height:120px}.npc_justin_head{background-image:url(spritesmith-main-5.png);background-position:-1782px -1276px;width:36px;height:39px}.npc_matt{background-image:url(spritesmith-main-5.png);background-position:-1322px -815px;width:195px;height:138px}.npc_timetravelers{background-image:url(spritesmith-main-5.png);background-position:-1322px -676px;width:195px;height:138px}.npc_timetravelers_active{background-image:url(spritesmith-main-5.png);background-position:-1322px -954px;width:195px;height:138px}.npc_tyler{background-image:url(spritesmith-main-5.png);background-position:-1536px -1323px;width:90px;height:90px}.seasonalshop_broken{background-image:url(spritesmith-main-5.png);background-position:-163px -1251px;width:162px;height:138px}.seasonalshop_closed{background-image:url(spritesmith-main-5.png);background-position:-1121px -1070px;width:162px;height:138px}.seasonalshop_open{background-image:url(spritesmith-main-5.png);background-position:-326px -1251px;width:162px;height:138px}.inventory_quest_scroll_atom1{background-image:url(spritesmith-main-5.png);background-position:-588px -1662px;width:48px;height:51px}.inventory_quest_scroll_atom1_locked{background-image:url(spritesmith-main-5.png);background-position:-441px -1662px;width:48px;height:51px}.inventory_quest_scroll_atom2{background-image:url(spritesmith-main-5.png);background-position:-343px -1662px;width:48px;height:51px}.inventory_quest_scroll_atom2_locked{background-image:url(spritesmith-main-5.png);background-position:-294px -1662px;width:48px;height:51px}.inventory_quest_scroll_atom3{background-image:url(spritesmith-main-5.png);background-position:-147px -1662px;width:48px;height:51px}.inventory_quest_scroll_atom3_locked{background-image:url(spritesmith-main-5.png);background-position:-98px -1662px;width:48px;height:51px}.inventory_quest_scroll_basilist{background-image:url(spritesmith-main-5.png);background-position:-49px -1662px;width:48px;height:51px}.inventory_quest_scroll_bunny{background-image:url(spritesmith-main-5.png);background-position:-245px -1610px;width:48px;height:51px}.inventory_quest_scroll_cheetah{background-image:url(spritesmith-main-5.png);background-position:-196px -1610px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-147px -1610px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-98px -1610px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2_locked{background-image:url(spritesmith-main-5.png);background-position:-49px -1610px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:0 -1610px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3_locked{background-image:url(spritesmith-main-5.png);background-position:-1642px -1499px;width:48px;height:51px}.inventory_quest_scroll_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-1642px -1447px;width:48px;height:51px}.inventory_quest_scroll_egg{background-image:url(spritesmith-main-5.png);background-position:-1733px -520px;width:48px;height:51px}.inventory_quest_scroll_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-294px -1610px;width:48px;height:51px}.inventory_quest_scroll_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:-196px -1662px;width:48px;height:51px}.inventory_quest_scroll_frog{background-image:url(spritesmith-main-5.png);background-position:-392px -1662px;width:48px;height:51px}.inventory_quest_scroll_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-490px -1662px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-637px -1662px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1_locked{background-image:url(spritesmith-main-5.png);background-position:-686px -1662px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-735px -1662px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2_locked{background-image:url(spritesmith-main-5.png);background-position:-784px -1662px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:-833px -1662px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3_locked{background-image:url(spritesmith-main-5.png);background-position:-882px -1662px;width:48px;height:51px}.inventory_quest_scroll_gryphon{background-image:url(spritesmith-main-5.png);background-position:-931px -1662px;width:48px;height:51px}.inventory_quest_scroll_harpy{background-image:url(spritesmith-main-5.png);background-position:-980px -1662px;width:48px;height:51px}.inventory_quest_scroll_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1029px -1662px;width:48px;height:51px}.inventory_quest_scroll_horse{background-image:url(spritesmith-main-5.png);background-position:-1078px -1662px;width:48px;height:51px}.inventory_quest_scroll_kraken{background-image:url(spritesmith-main-5.png);background-position:-1127px -1662px;width:48px;height:51px}.inventory_quest_scroll_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-1176px -1662px;width:48px;height:51px}.inventory_quest_scroll_moonstone1_locked{background-image:url(spritesmith-main-5.png);background-position:-1225px -1662px;width:48px;height:51px}.inventory_quest_scroll_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-1274px -1662px;width:48px;height:51px}.inventory_quest_scroll_moonstone2_locked{background-image:url(spritesmith-main-5.png);background-position:-1323px -1662px;width:48px;height:51px}.inventory_quest_scroll_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-1372px -1662px;width:48px;height:51px}.inventory_quest_scroll_moonstone3_locked{background-image:url(spritesmith-main-5.png);background-position:-1421px -1662px;width:48px;height:51px}.inventory_quest_scroll_octopus{background-image:url(spritesmith-main-5.png);background-position:-1470px -1662px;width:48px;height:51px}.inventory_quest_scroll_owl{background-image:url(spritesmith-main-5.png);background-position:-1568px -1662px;width:48px;height:51px}.inventory_quest_scroll_penguin{background-image:url(spritesmith-main-5.png);background-position:-1617px -1662px;width:48px;height:51px}.inventory_quest_scroll_rat{background-image:url(spritesmith-main-5.png);background-position:-1666px -1662px;width:48px;height:51px}.inventory_quest_scroll_rock{background-image:url(spritesmith-main-5.png);background-position:-1733px 0;width:48px;height:51px}.inventory_quest_scroll_rooster{background-image:url(spritesmith-main-5.png);background-position:-1733px -52px;width:48px;height:51px}.inventory_quest_scroll_sheep{background-image:url(spritesmith-main-5.png);background-position:-1733px -104px;width:48px;height:51px}.inventory_quest_scroll_slime{background-image:url(spritesmith-main-5.png);background-position:-1733px -156px;width:48px;height:51px}.inventory_quest_scroll_spider{background-image:url(spritesmith-main-5.png);background-position:-1733px -208px;width:48px;height:51px}.inventory_quest_scroll_trex{background-image:url(spritesmith-main-5.png);background-position:-1733px -312px;width:48px;height:51px}.inventory_quest_scroll_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-1733px -364px;width:48px;height:51px}.inventory_quest_scroll_vice1{background-image:url(spritesmith-main-5.png);background-position:-1642px -1083px;width:48px;height:51px}.inventory_quest_scroll_vice1_locked{background-image:url(spritesmith-main-5.png);background-position:-1642px -1135px;width:48px;height:51px}.inventory_quest_scroll_vice2{background-image:url(spritesmith-main-5.png);background-position:-1642px -1187px;width:48px;height:51px}.inventory_quest_scroll_vice2_locked{background-image:url(spritesmith-main-5.png);background-position:-1642px -1239px;width:48px;height:51px}.inventory_quest_scroll_vice3{background-image:url(spritesmith-main-5.png);background-position:-1642px -1291px;width:48px;height:51px}.inventory_quest_scroll_vice3_locked{background-image:url(spritesmith-main-5.png);background-position:-1642px -1343px;width:48px;height:51px}.inventory_quest_scroll_whale{background-image:url(spritesmith-main-5.png);background-position:-1642px -1395px;width:48px;height:51px}.quest_TEMPLATE_FOR_MISSING_IMAGE{background-image:url(spritesmith-main-5.png);background-position:-222px -1522px;width:221px;height:39px}.quest_atom1{background-image:url(spritesmith-main-5.png);background-position:-217px -1070px;width:250px;height:150px}.quest_atom2{background-image:url(spritesmith-main-5.png);background-position:-1322px -537px;width:207px;height:138px}.quest_atom3{background-image:url(spritesmith-main-5.png);background-position:0 -1070px;width:216px;height:180px}.quest_basilist{background-image:url(spritesmith-main-5.png);background-position:-1322px -1093px;width:189px;height:141px}.quest_bunny{background-image:url(spritesmith-main-5.png);background-position:-1100px -618px;width:210px;height:186px}.quest_cheetah{background-image:url(spritesmith-main-5.png);background-position:-440px -452px;width:219px;height:219px}.quest_dilatory{background-image:url(spritesmith-main-5.png);background-position:-440px 0;width:219px;height:219px}.quest_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-666px -1522px;width:221px;height:39px}.quest_dilatoryDistress1_blueFins{background-image:url(spritesmith-main-5.png);background-position:-343px -1610px;width:51px;height:48px}.quest_dilatoryDistress1_fireCoral{background-image:url(spritesmith-main-5.png);background-position:0 -1662px;width:48px;height:51px}.quest_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-970px -1070px;width:150px;height:150px}.quest_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:0 -232px;width:219px;height:219px}.quest_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-220px -232px;width:219px;height:219px}.quest_egg{background-image:url(spritesmith-main-5.png);background-position:-444px -1522px;width:221px;height:39px}.quest_egg_plainEgg{background-image:url(spritesmith-main-5.png);background-position:-245px -1662px;width:48px;height:51px}.quest_evilsanta{background-image:url(spritesmith-main-5.png);background-position:0 -1390px;width:118px;height:131px}.quest_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:-220px -452px;width:219px;height:219px}.quest_frog{background-image:url(spritesmith-main-5.png);background-position:-1100px 0;width:221px;height:213px}.quest_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-880px 0;width:219px;height:219px}.quest_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-1100px -805px;width:221px;height:39px}.quest_goldenknight1_testimony{background-image:url(spritesmith-main-5.png);background-position:-539px -1662px;width:48px;height:51px}.quest_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-468px -1070px;width:250px;height:150px}.quest_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:0 0;width:219px;height:231px}.quest_gryphon{background-image:url(spritesmith-main-5.png);background-position:-440px -892px;width:216px;height:177px}.quest_harpy{background-image:url(spritesmith-main-5.png);background-position:0 -672px;width:219px;height:219px}.quest_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1100px -431px;width:219px;height:186px}.quest_horse{background-image:url(spritesmith-main-5.png);background-position:-880px -220px;width:219px;height:219px}.quest_kraken{background-image:url(spritesmith-main-5.png);background-position:-1091px -892px;width:216px;height:177px}.quest_moonstone1{background-image:url(spritesmith-main-5.png);background-position:0 -1522px;width:221px;height:39px}.quest_moonstone1_moonstone{background-image:url(spritesmith-main-5.png);background-position:-1782px -1448px;width:30px;height:30px}.quest_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-880px -672px;width:219px;height:219px}.quest_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-660px -672px;width:219px;height:219px}.quest_octopus{background-image:url(spritesmith-main-5.png);background-position:0 -892px;width:222px;height:177px}.quest_owl{background-image:url(spritesmith-main-5.png);background-position:-220px -672px;width:219px;height:219px}.quest_penguin{background-image:url(spritesmith-main-5.png);background-position:-1322px -353px;width:190px;height:183px}.quest_rat{background-image:url(spritesmith-main-5.png);background-position:-880px -440px;width:219px;height:219px}.quest_rock{background-image:url(spritesmith-main-5.png);background-position:-1100px -214px;width:216px;height:216px}.quest_rooster{background-image:url(spritesmith-main-5.png);background-position:-1322px 0;width:213px;height:174px}.quest_sheep{background-image:url(spritesmith-main-5.png);background-position:-660px -452px;width:219px;height:219px}.quest_slime{background-image:url(spritesmith-main-5.png);background-position:-220px 0;width:219px;height:219px}.quest_spider{background-image:url(spritesmith-main-5.png);background-position:-719px -1070px;width:250px;height:150px}.quest_stressbeast{background-image:url(spritesmith-main-5.png);background-position:0 -452px;width:219px;height:219px}.quest_stressbeast_bailey{background-image:url(spritesmith-main-5.png);background-position:-660px -220px;width:219px;height:219px}.quest_stressbeast_guide{background-image:url(spritesmith-main-5.png);background-position:-660px 0;width:219px;height:219px}.quest_stressbeast_stables{background-image:url(spritesmith-main-5.png);background-position:-440px -232px;width:219px;height:219px}.quest_trex{background-image:url(spritesmith-main-5.png);background-position:-1322px -175px;width:204px;height:177px}.quest_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-874px -892px;width:216px;height:177px}.quest_vice1{background-image:url(spritesmith-main-5.png);background-position:-657px -892px;width:216px;height:177px}.quest_vice2{background-image:url(spritesmith-main-5.png);background-position:-1100px -845px;width:221px;height:39px}.quest_vice2_lightCrystal{background-image:url(spritesmith-main-5.png);background-position:-1782px -1235px;width:40px;height:40px}.quest_vice3{background-image:url(spritesmith-main-5.png);background-position:-223px -892px;width:216px;height:177px}.quest_whale{background-image:url(spritesmith-main-5.png);background-position:-440px -672px;width:219px;height:219px}.shop_copper{background-image:url(spritesmith-main-5.png);background-position:-1782px -1529px;width:32px;height:22px}.shop_eyes{background-image:url(spritesmith-main-5.png);background-position:-1782px -1194px;width:40px;height:40px}.shop_gold{background-image:url(spritesmith-main-5.png);background-position:-1782px -1552px;width:32px;height:22px}.shop_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1782px -1153px;width:40px;height:40px}.shop_potion{background-image:url(spritesmith-main-5.png);background-position:-1782px -1112px;width:40px;height:40px}.shop_reroll{background-image:url(spritesmith-main-5.png);background-position:-1782px -1071px;width:40px;height:40px}.shop_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1782px -1382px;width:32px;height:32px}.shop_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1782px -1316px;width:32px;height:32px}.shop_silver{background-image:url(spritesmith-main-5.png);background-position:-1782px -1506px;width:32px;height:22px}.shop_snowball{background-image:url(spritesmith-main-5.png);background-position:-1782px -1349px;width:32px;height:32px}.shop_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1782px -1415px;width:32px;height:32px}.Pet_Egg_BearCub{background-image:url(spritesmith-main-5.png);background-position:-1733px -988px;width:48px;height:51px}.Pet_Egg_Bunny{background-image:url(spritesmith-main-5.png);background-position:-1733px -1040px;width:48px;height:51px}.Pet_Egg_Cactus{background-image:url(spritesmith-main-5.png);background-position:-1733px -1092px;width:48px;height:51px}.Pet_Egg_Cheetah{background-image:url(spritesmith-main-5.png);background-position:-1733px -1144px;width:48px;height:51px}.Pet_Egg_Cuttlefish{background-image:url(spritesmith-main-5.png);background-position:-1733px -1196px;width:48px;height:51px}.Pet_Egg_Deer{background-image:url(spritesmith-main-5.png);background-position:-1733px -1248px;width:48px;height:51px}.Pet_Egg_Dragon{background-image:url(spritesmith-main-5.png);background-position:-1733px -1300px;width:48px;height:51px}.Pet_Egg_Egg{background-image:url(spritesmith-main-5.png);background-position:-1733px -1352px;width:48px;height:51px}.Pet_Egg_FlyingPig{background-image:url(spritesmith-main-5.png);background-position:-1733px -1404px;width:48px;height:51px}.Pet_Egg_Fox{background-image:url(spritesmith-main-5.png);background-position:-1733px -1456px;width:48px;height:51px}.Pet_Egg_Frog{background-image:url(spritesmith-main-5.png);background-position:-1733px -1508px;width:48px;height:51px}.Pet_Egg_Gryphon{background-image:url(spritesmith-main-5.png);background-position:-1733px -1560px;width:48px;height:51px}.Pet_Egg_Hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1733px -1612px;width:48px;height:51px}.Pet_Egg_Horse{background-image:url(spritesmith-main-5.png);background-position:0 -1714px;width:48px;height:51px}.Pet_Egg_LionCub{background-image:url(spritesmith-main-5.png);background-position:-49px -1714px;width:48px;height:51px}.Pet_Egg_Octopus{background-image:url(spritesmith-main-5.png);background-position:-98px -1714px;width:48px;height:51px}.Pet_Egg_Owl{background-image:url(spritesmith-main-5.png);background-position:-147px -1714px;width:48px;height:51px}.Pet_Egg_PandaCub{background-image:url(spritesmith-main-5.png);background-position:-196px -1714px;width:48px;height:51px}.Pet_Egg_Parrot{background-image:url(spritesmith-main-5.png);background-position:-245px -1714px;width:48px;height:51px}.Pet_Egg_Penguin{background-image:url(spritesmith-main-5.png);background-position:-294px -1714px;width:48px;height:51px}.Pet_Egg_PolarBear{background-image:url(spritesmith-main-5.png);background-position:-343px -1714px;width:48px;height:51px}.Pet_Egg_Rat{background-image:url(spritesmith-main-5.png);background-position:-392px -1714px;width:48px;height:51px}.Pet_Egg_Rock{background-image:url(spritesmith-main-5.png);background-position:-441px -1714px;width:48px;height:51px}.Pet_Egg_Rooster{background-image:url(spritesmith-main-5.png);background-position:-490px -1714px;width:48px;height:51px}.Pet_Egg_Seahorse{background-image:url(spritesmith-main-5.png);background-position:-539px -1714px;width:48px;height:51px}.Pet_Egg_Sheep{background-image:url(spritesmith-main-5.png);background-position:-588px -1714px;width:48px;height:51px}.Pet_Egg_Slime{background-image:url(spritesmith-main-5.png);background-position:-637px -1714px;width:48px;height:51px}.Pet_Egg_Spider{background-image:url(spritesmith-main-5.png);background-position:-686px -1714px;width:48px;height:51px}.Pet_Egg_TRex{background-image:url(spritesmith-main-5.png);background-position:-735px -1714px;width:48px;height:51px}.Pet_Egg_TigerCub{background-image:url(spritesmith-main-5.png);background-position:-784px -1714px;width:48px;height:51px}.Pet_Egg_Whale{background-image:url(spritesmith-main-5.png);background-position:-833px -1714px;width:48px;height:51px}.Pet_Egg_Wolf{background-image:url(spritesmith-main-5.png);background-position:-882px -1714px;width:48px;height:51px}.Pet_Food_Cake_Base{background-image:url(spritesmith-main-5.png);background-position:-1782px -735px;width:43px;height:43px}.Pet_Food_Cake_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1782px -779px;width:42px;height:44px}.Pet_Food_Cake_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1782px -554px;width:43px;height:45px}.Pet_Food_Cake_Desert{background-image:url(spritesmith-main-5.png);background-position:-1782px -690px;width:43px;height:44px}.Pet_Food_Cake_Golden{background-image:url(spritesmith-main-5.png);background-position:-1782px -824px;width:43px;height:42px}.Pet_Food_Cake_Red{background-image:url(spritesmith-main-5.png);background-position:-1782px -600px;width:43px;height:44px}.Pet_Food_Cake_Shade{background-image:url(spritesmith-main-5.png);background-position:-1782px -645px;width:43px;height:44px}.Pet_Food_Cake_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1782px -461px;width:42px;height:47px}.Pet_Food_Cake_White{background-image:url(spritesmith-main-5.png);background-position:-1782px -509px;width:44px;height:44px}.Pet_Food_Cake_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1782px -416px;width:45px;height:44px}.Pet_Food_Candy_Base{background-image:url(spritesmith-main-5.png);background-position:-1421px -1714px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1470px -1714px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1519px -1714px;width:48px;height:51px}.Pet_Food_Candy_Desert{background-image:url(spritesmith-main-5.png);background-position:-1568px -1714px;width:48px;height:51px}.Pet_Food_Candy_Golden{background-image:url(spritesmith-main-5.png);background-position:-1617px -1714px;width:48px;height:51px}.Pet_Food_Candy_Red{background-image:url(spritesmith-main-5.png);background-position:-1666px -1714px;width:48px;height:51px}.Pet_Food_Candy_Shade{background-image:url(spritesmith-main-5.png);background-position:-1715px -1714px;width:48px;height:51px}.Pet_Food_Candy_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1782px 0;width:48px;height:51px}.Pet_Food_Candy_White{background-image:url(spritesmith-main-5.png);background-position:-1782px -52px;width:48px;height:51px}.Pet_Food_Candy_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1782px -104px;width:48px;height:51px}.Pet_Food_Chocolate{background-image:url(spritesmith-main-5.png);background-position:-1782px -156px;width:48px;height:51px}.Pet_Food_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1782px -208px;width:48px;height:51px}.Pet_Food_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1782px -260px;width:48px;height:51px}.Pet_Food_Fish{background-image:url(spritesmith-main-5.png);background-position:-1782px -312px;width:48px;height:51px}.Pet_Food_Honey{background-image:url(spritesmith-main-5.png);background-position:-1782px -364px;width:48px;height:51px}.Pet_Food_Meat{background-image:url(spritesmith-main-5.png);background-position:-1372px -1714px;width:48px;height:51px}.Pet_Food_Milk{background-image:url(spritesmith-main-5.png);background-position:-1323px -1714px;width:48px;height:51px}.Pet_Food_Potatoe{background-image:url(spritesmith-main-5.png);background-position:-1274px -1714px;width:48px;height:51px}.Pet_Food_RottenMeat{background-image:url(spritesmith-main-5.png);background-position:-1225px -1714px;width:48px;height:51px}.Pet_Food_Saddle{background-image:url(spritesmith-main-5.png);background-position:-1176px -1714px;width:48px;height:51px}.Pet_Food_Strawberry{background-image:url(spritesmith-main-5.png);background-position:-1127px -1714px;width:48px;height:51px}.Mount_Body_BearCub-Base{background-image:url(spritesmith-main-5.png);background-position:-761px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1536px -424px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1536px -318px;width:105px;height:105px}.Mount_Body_BearCub-Desert{background-image:url(spritesmith-main-5.png);background-position:-1536px -212px;width:105px;height:105px}.Mount_Body_BearCub-Golden{background-image:url(spritesmith-main-5.png);background-position:-1536px -106px;width:105px;height:105px}.Mount_Body_BearCub-Polar{background-image:url(spritesmith-main-5.png);background-position:-1536px 0;width:105px;height:105px}.Mount_Body_BearCub-Red{background-image:url(spritesmith-main-5.png);background-position:-1391px -1390px;width:105px;height:105px}.Mount_Body_BearCub-Shade{background-image:url(spritesmith-main-5.png);background-position:-1285px -1390px;width:105px;height:105px}.Mount_Body_BearCub-Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1179px -1390px;width:105px;height:105px}.Mount_Body_BearCub-Spooky{background-image:url(spritesmith-main-5.png);background-position:-1073px -1390px;width:105px;height:105px}.Mount_Body_BearCub-White{background-image:url(spritesmith-main-5.png);background-position:-967px -1390px;width:105px;height:105px}.Mount_Body_BearCub-Zombie{background-image:url(spritesmith-main-5.png);background-position:-861px -1390px;width:105px;height:105px}.Mount_Body_Bunny-Base{background-image:url(spritesmith-main-5.png);background-position:-755px -1390px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-649px -1390px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-543px -1390px;width:105px;height:105px}.Mount_Body_Bunny-Desert{background-image:url(spritesmith-main-5.png);background-position:-437px -1390px;width:105px;height:105px}.Mount_Body_Bunny-Golden{background-image:url(spritesmith-main-5.png);background-position:-331px -1390px;width:105px;height:105px}.Mount_Body_Bunny-Red{background-image:url(spritesmith-main-5.png);background-position:-225px -1390px;width:105px;height:105px}.Mount_Body_Bunny-Shade{background-image:url(spritesmith-main-5.png);background-position:-119px -1390px;width:105px;height:105px}.Mount_Body_Bunny-Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1397px -1251px;width:105px;height:105px}.Mount_Body_Bunny-White{background-image:url(spritesmith-main-5.png);background-position:-1291px -1251px;width:105px;height:105px}.Mount_Body_Bunny-Zombie{background-image:url(spritesmith-main-5.png);background-position:-1185px -1251px;width:105px;height:105px}.Mount_Body_Cactus-Base{background-image:url(spritesmith-main-5.png);background-position:-1079px -1251px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-973px -1251px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-867px -1251px;width:105px;height:105px}.Mount_Body_Cactus-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -115px;width:105px;height:105px}.Mount_Body_Cactus-Golden{background-image:url(spritesmith-main-6.png);background-position:-318px -1105px;width:105px;height:105px}.Mount_Body_Cactus-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px 0;width:105px;height:105px}.Mount_Body_Cactus-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -106px;width:105px;height:105px}.Mount_Body_Cactus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1272px 0;width:105px;height:105px}.Mount_Body_Cactus-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1272px -424px;width:105px;height:105px}.Mount_Body_Cactus-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -530px;width:105px;height:105px}.Mount_Body_Cactus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-530px -221px;width:105px;height:105px}.Mount_Body_Cheetah-Base{background-image:url(spritesmith-main-6.png);background-position:-530px -327px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-221px -469px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-327px -469px;width:105px;height:105px}.Mount_Body_Cheetah-Desert{background-image:url(spritesmith-main-6.png);background-position:-212px -893px;width:105px;height:105px}.Mount_Body_Cheetah-Golden{background-image:url(spritesmith-main-6.png);background-position:-318px -893px;width:105px;height:105px}.Mount_Body_Cheetah-Red{background-image:url(spritesmith-main-6.png);background-position:-424px -893px;width:105px;height:105px}.Mount_Body_Cheetah-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -893px;width:105px;height:105px}.Mount_Body_Cheetah-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -893px;width:105px;height:105px}.Mount_Body_Cheetah-White{background-image:url(spritesmith-main-6.png);background-position:-742px -893px;width:105px;height:105px}.Mount_Body_Cheetah-Zombie{background-image:url(spritesmith-main-6.png);background-position:-848px -893px;width:105px;height:105px}.Mount_Body_Cuttlefish-Base{background-image:url(spritesmith-main-6.png);background-position:-530px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -115px;width:105px;height:114px}.Mount_Body_Cuttlefish-White{background-image:url(spritesmith-main-6.png);background-position:0 -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -239px;width:105px;height:114px}.Mount_Body_Deer-Base{background-image:url(spritesmith-main-6.png);background-position:-433px -469px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-636px 0;width:105px;height:105px}.Mount_Body_Deer-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -106px;width:105px;height:105px}.Mount_Body_Deer-Desert{background-image:url(spritesmith-main-6.png);background-position:-636px -212px;width:105px;height:105px}.Mount_Body_Deer-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -318px;width:105px;height:105px}.Mount_Body_Deer-Red{background-image:url(spritesmith-main-6.png);background-position:-636px -424px;width:105px;height:105px}.Mount_Body_Deer-Shade{background-image:url(spritesmith-main-6.png);background-position:0 -575px;width:105px;height:105px}.Mount_Body_Deer-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -575px;width:105px;height:105px}.Mount_Body_Deer-White{background-image:url(spritesmith-main-6.png);background-position:-212px -575px;width:105px;height:105px}.Mount_Body_Deer-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -575px;width:105px;height:105px}.Mount_Body_Dragon-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -575px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -575px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -575px;width:105px;height:105px}.Mount_Body_Dragon-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px 0;width:105px;height:105px}.Mount_Body_Dragon-Golden{background-image:url(spritesmith-main-6.png);background-position:-742px -106px;width:105px;height:105px}.Mount_Body_Dragon-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -212px;width:105px;height:105px}.Mount_Body_Dragon-Shade{background-image:url(spritesmith-main-6.png);background-position:-742px -318px;width:105px;height:105px}.Mount_Body_Dragon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-742px -424px;width:105px;height:105px}.Mount_Body_Dragon-Spooky{background-image:url(spritesmith-main-6.png);background-position:-742px -530px;width:105px;height:105px}.Mount_Body_Dragon-White{background-image:url(spritesmith-main-6.png);background-position:0 -681px;width:105px;height:105px}.Mount_Body_Dragon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -681px;width:105px;height:105px}.Mount_Body_Egg-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -681px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -681px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px -681px;width:105px;height:105px}.Mount_Body_Egg-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -681px;width:105px;height:105px}.Mount_Body_Egg-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -681px;width:105px;height:105px}.Mount_Body_Egg-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -681px;width:105px;height:105px}.Mount_Body_Egg-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px 0;width:105px;height:105px}.Mount_Body_Egg-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-848px -106px;width:105px;height:105px}.Mount_Body_Egg-White{background-image:url(spritesmith-main-6.png);background-position:-848px -212px;width:105px;height:105px}.Mount_Body_Egg-Zombie{background-image:url(spritesmith-main-6.png);background-position:-848px -318px;width:105px;height:105px}.Mount_Body_FlyingPig-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -424px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -530px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-848px -636px;width:105px;height:105px}.Mount_Body_FlyingPig-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-424px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Spooky{background-image:url(spritesmith-main-6.png);background-position:-530px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-White{background-image:url(spritesmith-main-6.png);background-position:-636px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -787px;width:105px;height:105px}.Mount_Body_Fox-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -787px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px 0;width:105px;height:105px}.Mount_Body_Fox-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -106px;width:105px;height:105px}.Mount_Body_Fox-Desert{background-image:url(spritesmith-main-6.png);background-position:-954px -212px;width:105px;height:105px}.Mount_Body_Fox-Golden{background-image:url(spritesmith-main-6.png);background-position:-954px -318px;width:105px;height:105px}.Mount_Body_Fox-Red{background-image:url(spritesmith-main-6.png);background-position:-954px -424px;width:105px;height:105px}.Mount_Body_Fox-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -530px;width:105px;height:105px}.Mount_Body_Fox-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -636px;width:105px;height:105px}.Mount_Body_Fox-Spooky{background-image:url(spritesmith-main-6.png);background-position:-954px -742px;width:105px;height:105px}.Mount_Body_Fox-White{background-image:url(spritesmith-main-6.png);background-position:0 -893px;width:105px;height:105px}.Mount_Body_Fox-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -893px;width:105px;height:105px}.Mount_Body_Frog-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -239px;width:105px;height:114px}.Mount_Body_Frog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px 0;width:105px;height:114px}.Mount_Body_Frog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px 0;width:105px;height:114px}.Mount_Body_Frog-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -115px;width:105px;height:114px}.Mount_Body_Frog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -230px;width:105px;height:114px}.Mount_Body_Frog-Red{background-image:url(spritesmith-main-6.png);background-position:0 -354px;width:105px;height:114px}.Mount_Body_Frog-Shade{background-image:url(spritesmith-main-6.png);background-position:-106px -354px;width:105px;height:114px}.Mount_Body_Frog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-212px -354px;width:105px;height:114px}.Mount_Body_Frog-White{background-image:url(spritesmith-main-6.png);background-position:-318px -354px;width:105px;height:114px}.Mount_Body_Frog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-424px -354px;width:105px;height:114px}.Mount_Body_Gryphon-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -212px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px -318px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px -424px;width:105px;height:105px}.Mount_Body_Gryphon-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -530px;width:105px;height:105px}.Mount_Body_Gryphon-Golden{background-image:url(spritesmith-main-6.png);background-position:-1060px -636px;width:105px;height:105px}.Mount_Body_Gryphon-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px -742px;width:105px;height:105px}.Mount_Body_Gryphon-RoyalPurple{background-image:url(spritesmith-main-6.png);background-position:-1060px -848px;width:105px;height:105px}.Mount_Body_Gryphon-Shade{background-image:url(spritesmith-main-6.png);background-position:0 -999px;width:105px;height:105px}.Mount_Body_Gryphon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -999px;width:105px;height:105px}.Mount_Body_Gryphon-White{background-image:url(spritesmith-main-6.png);background-position:-212px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-Red{background-image:url(spritesmith-main-6.png);background-position:-954px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px 0;width:105px;height:105px}.Mount_Body_Hedgehog-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -106px;width:105px;height:105px}.Mount_Body_Hedgehog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -212px;width:105px;height:105px}.Mount_Body_Horse-Base{background-image:url(spritesmith-main-6.png);background-position:-1166px -318px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px -424px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px -530px;width:105px;height:105px}.Mount_Body_Horse-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -636px;width:105px;height:105px}.Mount_Body_Horse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -742px;width:105px;height:105px}.Mount_Body_Horse-Red{background-image:url(spritesmith-main-6.png);background-position:-1166px -848px;width:105px;height:105px}.Mount_Body_Horse-Shade{background-image:url(spritesmith-main-6.png);background-position:-1166px -954px;width:105px;height:105px}.Mount_Body_Horse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -1105px;width:105px;height:105px}.Mount_Body_Horse-White{background-image:url(spritesmith-main-6.png);background-position:-106px -1105px;width:105px;height:105px}.Mount_Body_Horse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-212px -1105px;width:105px;height:105px}.Mount_Body_JackOLantern-Base{background-image:url(spritesmith-main-6.png);background-position:-1696px -530px;width:90px;height:105px}.Mount_Body_LionCub-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -1105px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -1105px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -1105px;width:105px;height:105px}.Mount_Body_LionCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -1105px;width:105px;height:105px}.Mount_Body_LionCub-Ethereal{background-image:url(spritesmith-main-6.png);background-position:-848px -1105px;width:105px;height:105px}.Mount_Body_LionCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-954px -1105px;width:105px;height:105px}.Mount_Body_LionCub-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px -1105px;width:105px;height:105px}.Mount_Body_LionCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-1166px -1105px;width:105px;height:105px}.Mount_Body_LionCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -469px;width:111px;height:105px}.Mount_Body_LionCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1272px -106px;width:105px;height:105px}.Mount_Body_LionCub-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -212px;width:105px;height:105px}.Mount_Body_LionCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -318px;width:105px;height:105px}.Mount_Body_Mammoth-Base{background-image:url(spritesmith-main-6.png);background-position:0 0;width:105px;height:123px}.Mount_Body_MantisShrimp-Base{background-image:url(spritesmith-main-6.png);background-position:-112px -469px;width:108px;height:105px}.Mount_Body_Octopus-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -636px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -742px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1272px -848px;width:105px;height:105px}.Mount_Body_Octopus-Desert{background-image:url(spritesmith-main-6.png);background-position:-1272px -954px;width:105px;height:105px}.Mount_Body_Octopus-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -1060px;width:105px;height:105px}.Mount_Body_Octopus-Red{background-image:url(spritesmith-main-6.png);background-position:0 -1211px;width:105px;height:105px}.Mount_Body_Octopus-Shade{background-image:url(spritesmith-main-6.png);background-position:-106px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-212px -1211px;width:105px;height:105px}.Mount_Body_Octopus-White{background-image:url(spritesmith-main-6.png);background-position:-318px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-424px -1211px;width:105px;height:105px}.Mount_Body_Orca-Base{background-image:url(spritesmith-main-6.png);background-position:-530px -1211px;width:105px;height:105px}.Mount_Body_Owl-Base{background-image:url(spritesmith-main-6.png);background-position:-636px -1211px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-742px -1211px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-848px -1211px;width:105px;height:105px}.Mount_Body_Owl-Desert{background-image:url(spritesmith-main-6.png);background-position:-954px -1211px;width:105px;height:105px}.Mount_Body_Owl-Golden{background-image:url(spritesmith-main-6.png);background-position:-1060px -1211px;width:105px;height:105px}.Mount_Body_Owl-Red{background-image:url(spritesmith-main-6.png);background-position:-1166px -1211px;width:105px;height:105px}.Mount_Body_Owl-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -1211px;width:105px;height:105px}.Mount_Body_Owl-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1378px 0;width:105px;height:105px}.Mount_Body_Owl-White{background-image:url(spritesmith-main-6.png);background-position:-1378px -106px;width:105px;height:105px}.Mount_Body_Owl-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -212px;width:105px;height:105px}.Mount_Body_PandaCub-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -318px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -424px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -530px;width:105px;height:105px}.Mount_Body_PandaCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-1378px -636px;width:105px;height:105px}.Mount_Body_PandaCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-1378px -742px;width:105px;height:105px}.Mount_Body_PandaCub-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -848px;width:105px;height:105px}.Mount_Body_PandaCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -954px;width:105px;height:105px}.Mount_Body_PandaCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1378px -1060px;width:105px;height:105px}.Mount_Body_PandaCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1378px -1166px;width:105px;height:105px}.Mount_Body_PandaCub-White{background-image:url(spritesmith-main-6.png);background-position:0 -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -1317px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -1317px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -1317px;width:105px;height:105px}.Mount_Body_Parrot-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -1317px;width:105px;height:105px}.Mount_Body_Penguin-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1317px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -1317px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px 0;width:105px;height:105px}.Mount_Body_Penguin-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -106px;width:105px;height:105px}.Mount_Body_Penguin-Golden{background-image:url(spritesmith-main-6.png);background-position:-1484px -212px;width:105px;height:105px}.Mount_Body_Penguin-Red{background-image:url(spritesmith-main-6.png);background-position:-1484px -318px;width:105px;height:105px}.Mount_Body_Penguin-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px -424px;width:105px;height:105px}.Mount_Body_Penguin-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -530px;width:105px;height:105px}.Mount_Body_Penguin-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -636px;width:105px;height:105px}.Mount_Body_Penguin-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -742px;width:105px;height:105px}.Mount_Body_Phoenix-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -848px;width:105px;height:105px}.Mount_Body_Rat-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -954px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -1060px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px -1166px;width:105px;height:105px}.Mount_Body_Rat-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -1272px;width:105px;height:105px}.Mount_Body_Rat-Golden{background-image:url(spritesmith-main-6.png);background-position:0 -1423px;width:105px;height:105px}.Mount_Body_Rat-Red{background-image:url(spritesmith-main-6.png);background-position:-106px -1423px;width:105px;height:105px}.Mount_Body_Rat-Shade{background-image:url(spritesmith-main-6.png);background-position:-212px -1423px;width:105px;height:105px}.Mount_Body_Rat-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -1423px;width:105px;height:105px}.Mount_Body_Rat-White{background-image:url(spritesmith-main-6.png);background-position:-424px -1423px;width:105px;height:105px}.Mount_Body_Rat-Zombie{background-image:url(spritesmith-main-6.png);background-position:-530px -1423px;width:105px;height:105px}.Mount_Body_Rock-Base{background-image:url(spritesmith-main-6.png);background-position:-636px -1423px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-742px -1423px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-848px -1423px;width:105px;height:105px}.Mount_Body_Rock-Desert{background-image:url(spritesmith-main-6.png);background-position:-954px -1423px;width:105px;height:105px}.Mount_Body_Rock-Golden{background-image:url(spritesmith-main-6.png);background-position:-1060px -1423px;width:105px;height:105px}.Mount_Body_Rock-Red{background-image:url(spritesmith-main-6.png);background-position:-1166px -1423px;width:105px;height:105px}.Mount_Body_Rock-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -1423px;width:105px;height:105px}.Mount_Body_Rock-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1378px -1423px;width:105px;height:105px}.Mount_Body_Rock-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -1423px;width:105px;height:105px}.Mount_Body_Rock-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1590px 0;width:105px;height:105px}.Mount_Body_Rooster-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px -106px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -212px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px -318px;width:105px;height:105px}.Mount_Body_Rooster-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -424px;width:105px;height:105px}.Mount_Body_Rooster-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -530px;width:105px;height:105px}.Mount_Body_Rooster-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -636px;width:105px;height:105px}.Mount_Body_Rooster-Shade{background-image:url(spritesmith-main-6.png);background-position:-1590px -742px;width:105px;height:105px}.Mount_Body_Rooster-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1590px -848px;width:105px;height:105px}.Mount_Body_Rooster-White{background-image:url(spritesmith-main-6.png);background-position:-1590px -954px;width:105px;height:105px}.Mount_Body_Rooster-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1590px -1060px;width:105px;height:105px}.Mount_Body_Seahorse-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px -1166px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -1272px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px -1378px;width:105px;height:105px}.Mount_Body_Seahorse-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-424px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-White{background-image:url(spritesmith-main-6.png);background-position:-530px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-636px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -1529px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -1529px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -1529px;width:105px;height:105px}.Mount_Body_Sheep-White{background-image:url(spritesmith-main-6.png);background-position:-1590px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1696px 0;width:105px;height:105px}.Mount_Body_Slime-Base{background-image:url(spritesmith-main-6.png);background-position:-1696px -106px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1696px -212px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1696px -318px;width:105px;height:105px}.Mount_Body_Slime-Desert{background-image:url(spritesmith-main-6.png);background-position:-1696px -424px;width:105px;height:105px}.Mount_Body_Slime-Golden{background-image:url(spritesmith-main-6.png);background-position:-954px -893px;width:105px;height:105px}.Mount_Body_Slime-Red{background-image:url(spritesmith-main-7.png);background-position:0 -795px;width:105px;height:105px}.Mount_Body_Slime-Shade{background-image:url(spritesmith-main-7.png);background-position:-848px -1113px;width:105px;height:105px}.Mount_Body_Slime-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-106px -795px;width:105px;height:105px}.Mount_Body_Slime-White{background-image:url(spritesmith-main-7.png);background-position:-212px -795px;width:105px;height:105px}.Mount_Body_Slime-Zombie{background-image:url(spritesmith-main-7.png);background-position:-318px -795px;width:105px;height:105px}.Mount_Body_Spider-Base{background-image:url(spritesmith-main-7.png);background-position:-424px -795px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-530px -795px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-636px -795px;width:105px;height:105px}.Mount_Body_Spider-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Body_Spider-Golden{background-image:url(spritesmith-main-7.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Body_Spider-Red{background-image:url(spritesmith-main-7.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Body_Spider-Shade{background-image:url(spritesmith-main-7.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Body_Spider-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Body_Spider-White{background-image:url(spritesmith-main-7.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Body_Spider-Zombie{background-image:url(spritesmith-main-7.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Body_TRex-Base{background-image:url(spritesmith-main-7.png);background-position:-136px 0;width:135px;height:135px}.Mount_Body_TRex-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 -544px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Body_TRex-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -136px;width:135px;height:135px}.Mount_Body_TRex-Golden{background-image:url(spritesmith-main-7.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Body_TRex-Red{background-image:url(spritesmith-main-7.png);background-position:-272px 0;width:135px;height:135px}.Mount_Body_TRex-Shade{background-image:url(spritesmith-main-7.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Body_TRex-Skeleton{background-image:url(spritesmith-main-7.png);background-position:0 -272px;width:135px;height:135px}.Mount_Body_TRex-White{background-image:url(spritesmith-main-7.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Body_TRex-Zombie{background-image:url(spritesmith-main-7.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Body_TigerCub-Base{background-image:url(spritesmith-main-7.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Body_TigerCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Body_TigerCub-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -1113px;width:105px;height:105px}.Mount_Body_TigerCub-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -1113px;width:105px;height:105px}.Mount_Body_TigerCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -1113px;width:105px;height:105px}.Mount_Body_TigerCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-848px -1325px;width:105px;height:105px}.Mount_Body_TigerCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-954px -1325px;width:105px;height:105px}.Mount_Body_TigerCub-White{background-image:url(spritesmith-main-7.png);background-position:-1060px -1325px;width:105px;height:105px}.Mount_Body_TigerCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1166px -1325px;width:105px;height:105px}.Mount_Body_Turkey-Base{background-image:url(spritesmith-main-7.png);background-position:-1272px -1325px;width:105px;height:105px}.Mount_Body_Whale-Base{background-image:url(spritesmith-main-7.png);background-position:-1378px -1325px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Body_Whale-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Body_Whale-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Body_Whale-Golden{background-image:url(spritesmith-main-7.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Body_Whale-Red{background-image:url(spritesmith-main-7.png);background-position:-212px -1537px;width:105px;height:105px}.Mount_Body_Whale-Shade{background-image:url(spritesmith-main-7.png);background-position:-636px -1537px;width:105px;height:105px}.Mount_Body_Whale-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-742px -1537px;width:105px;height:105px}.Mount_Body_Whale-White{background-image:url(spritesmith-main-7.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Body_Whale-Zombie{background-image:url(spritesmith-main-7.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Body_Wolf-Base{background-image:url(spritesmith-main-7.png);background-position:-408px 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Body_Wolf-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -408px;width:135px;height:135px}.Mount_Body_Wolf-Golden{background-image:url(spritesmith-main-7.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Body_Wolf-Red{background-image:url(spritesmith-main-7.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Body_Wolf-Shade{background-image:url(spritesmith-main-7.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Body_Wolf-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-544px 0;width:135px;height:135px}.Mount_Body_Wolf-Spooky{background-image:url(spritesmith-main-7.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Body_Wolf-White{background-image:url(spritesmith-main-7.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Body_Wolf-Zombie{background-image:url(spritesmith-main-7.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_BearCub-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -795px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -795px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-998px 0;width:105px;height:105px}.Mount_Head_BearCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Head_BearCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Head_BearCub-Polar{background-image:url(spritesmith-main-7.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_BearCub-Red{background-image:url(spritesmith-main-7.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_BearCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_BearCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_BearCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_BearCub-White{background-image:url(spritesmith-main-7.png);background-position:0 -901px;width:105px;height:105px}.Mount_Head_BearCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-106px -901px;width:105px;height:105px}.Mount_Head_Bunny-Base{background-image:url(spritesmith-main-7.png);background-position:-212px -901px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-318px -901px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-424px -901px;width:105px;height:105px}.Mount_Head_Bunny-Desert{background-image:url(spritesmith-main-7.png);background-position:-530px -901px;width:105px;height:105px}.Mount_Head_Bunny-Golden{background-image:url(spritesmith-main-7.png);background-position:-636px -901px;width:105px;height:105px}.Mount_Head_Bunny-Red{background-image:url(spritesmith-main-7.png);background-position:-742px -901px;width:105px;height:105px}.Mount_Head_Bunny-Shade{background-image:url(spritesmith-main-7.png);background-position:-848px -901px;width:105px;height:105px}.Mount_Head_Bunny-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-954px -901px;width:105px;height:105px}.Mount_Head_Bunny-White{background-image:url(spritesmith-main-7.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Bunny-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Cactus-Base{background-image:url(spritesmith-main-7.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Cactus-Desert{background-image:url(spritesmith-main-7.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Cactus-Golden{background-image:url(spritesmith-main-7.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Cactus-Red{background-image:url(spritesmith-main-7.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Cactus-Shade{background-image:url(spritesmith-main-7.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Cactus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:0 -1007px;width:105px;height:105px}.Mount_Head_Cactus-Spooky{background-image:url(spritesmith-main-7.png);background-position:-106px -1007px;width:105px;height:105px}.Mount_Head_Cactus-White{background-image:url(spritesmith-main-7.png);background-position:-212px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Zombie{background-image:url(spritesmith-main-7.png);background-position:-318px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-Base{background-image:url(spritesmith-main-7.png);background-position:-424px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-530px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-636px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-Desert{background-image:url(spritesmith-main-7.png);background-position:-742px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-Golden{background-image:url(spritesmith-main-7.png);background-position:-848px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-Red{background-image:url(spritesmith-main-7.png);background-position:-954px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-Shade{background-image:url(spritesmith-main-7.png);background-position:-1060px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Cheetah-White{background-image:url(spritesmith-main-7.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Cheetah-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Cuttlefish-Base{background-image:url(spritesmith-main-7.png);background-position:-530px -680px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-348px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Desert{background-image:url(spritesmith-main-7.png);background-position:-454px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Golden{background-image:url(spritesmith-main-7.png);background-position:-560px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Red{background-image:url(spritesmith-main-7.png);background-position:-680px 0;width:105px;height:114px}.Mount_Head_Cuttlefish-Shade{background-image:url(spritesmith-main-7.png);background-position:-680px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-680px -230px;width:105px;height:114px}.Mount_Head_Cuttlefish-White{background-image:url(spritesmith-main-7.png);background-position:-680px -345px;width:105px;height:114px}.Mount_Head_Cuttlefish-Zombie{background-image:url(spritesmith-main-7.png);background-position:-680px -460px;width:105px;height:114px}.Mount_Head_Deer-Base{background-image:url(spritesmith-main-7.png);background-position:-318px -1113px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-424px -1113px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-530px -1113px;width:105px;height:105px}.Mount_Head_Deer-Desert{background-image:url(spritesmith-main-7.png);background-position:-636px -1113px;width:105px;height:105px}.Mount_Head_Deer-Golden{background-image:url(spritesmith-main-7.png);background-position:-742px -1113px;width:105px;height:105px}.Mount_Head_Deer-Red{background-image:url(spritesmith-main-7.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Deer-Shade{background-image:url(spritesmith-main-7.png);background-position:-954px -1113px;width:105px;height:105px}.Mount_Head_Deer-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1060px -1113px;width:105px;height:105px}.Mount_Head_Deer-White{background-image:url(spritesmith-main-7.png);background-position:-1166px -1113px;width:105px;height:105px}.Mount_Head_Deer-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Dragon-Base{background-image:url(spritesmith-main-7.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Dragon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Dragon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Dragon-Red{background-image:url(spritesmith-main-7.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Dragon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Dragon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Dragon-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Dragon-White{background-image:url(spritesmith-main-7.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Dragon-Zombie{background-image:url(spritesmith-main-7.png);background-position:0 -1219px;width:105px;height:105px}.Mount_Head_Egg-Base{background-image:url(spritesmith-main-7.png);background-position:-106px -1219px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-212px -1219px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-318px -1219px;width:105px;height:105px}.Mount_Head_Egg-Desert{background-image:url(spritesmith-main-7.png);background-position:-424px -1219px;width:105px;height:105px}.Mount_Head_Egg-Golden{background-image:url(spritesmith-main-7.png);background-position:-530px -1219px;width:105px;height:105px}.Mount_Head_Egg-Red{background-image:url(spritesmith-main-7.png);background-position:-636px -1219px;width:105px;height:105px}.Mount_Head_Egg-Shade{background-image:url(spritesmith-main-7.png);background-position:-742px -1219px;width:105px;height:105px}.Mount_Head_Egg-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-848px -1219px;width:105px;height:105px}.Mount_Head_Egg-White{background-image:url(spritesmith-main-7.png);background-position:-954px -1219px;width:105px;height:105px}.Mount_Head_Egg-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1060px -1219px;width:105px;height:105px}.Mount_Head_FlyingPig-Base{background-image:url(spritesmith-main-7.png);background-position:-1166px -1219px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1272px -1219px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_FlyingPig-Desert{background-image:url(spritesmith-main-7.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_FlyingPig-Golden{background-image:url(spritesmith-main-7.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_FlyingPig-Red{background-image:url(spritesmith-main-7.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_FlyingPig-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_FlyingPig-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_FlyingPig-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_FlyingPig-White{background-image:url(spritesmith-main-7.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_FlyingPig-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_Fox-Base{background-image:url(spritesmith-main-7.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Fox-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -1325px;width:105px;height:105px}.Mount_Head_Fox-Golden{background-image:url(spritesmith-main-7.png);background-position:-106px -1325px;width:105px;height:105px}.Mount_Head_Fox-Red{background-image:url(spritesmith-main-7.png);background-position:-212px -1325px;width:105px;height:105px}.Mount_Head_Fox-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1325px;width:105px;height:105px}.Mount_Head_Fox-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1325px;width:105px;height:105px}.Mount_Head_Fox-Spooky{background-image:url(spritesmith-main-7.png);background-position:-530px -1325px;width:105px;height:105px}.Mount_Head_Fox-White{background-image:url(spritesmith-main-7.png);background-position:-636px -1325px;width:105px;height:105px}.Mount_Head_Fox-Zombie{background-image:url(spritesmith-main-7.png);background-position:-742px -1325px;width:105px;height:105px}.Mount_Head_Frog-Base{background-image:url(spritesmith-main-7.png);background-position:-786px 0;width:105px;height:114px}.Mount_Head_Frog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-242px -544px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -230px;width:105px;height:114px}.Mount_Head_Frog-Desert{background-image:url(spritesmith-main-7.png);background-position:-786px -345px;width:105px;height:114px}.Mount_Head_Frog-Golden{background-image:url(spritesmith-main-7.png);background-position:-786px -460px;width:105px;height:114px}.Mount_Head_Frog-Red{background-image:url(spritesmith-main-7.png);background-position:0 -680px;width:105px;height:114px}.Mount_Head_Frog-Shade{background-image:url(spritesmith-main-7.png);background-position:-106px -680px;width:105px;height:114px}.Mount_Head_Frog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-212px -680px;width:105px;height:114px}.Mount_Head_Frog-White{background-image:url(spritesmith-main-7.png);background-position:-318px -680px;width:105px;height:114px}.Mount_Head_Frog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-424px -680px;width:105px;height:114px}.Mount_Head_Gryphon-Base{background-image:url(spritesmith-main-7.png);background-position:-1528px -424px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px -530px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -636px;width:105px;height:105px}.Mount_Head_Gryphon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px -742px;width:105px;height:105px}.Mount_Head_Gryphon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1528px -848px;width:105px;height:105px}.Mount_Head_Gryphon-Red{background-image:url(spritesmith-main-7.png);background-position:-1528px -954px;width:105px;height:105px}.Mount_Head_Gryphon-RoyalPurple{background-image:url(spritesmith-main-7.png);background-position:-1528px -1060px;width:105px;height:105px}.Mount_Head_Gryphon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1528px -1166px;width:105px;height:105px}.Mount_Head_Gryphon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1528px -1272px;width:105px;height:105px}.Mount_Head_Gryphon-White{background-image:url(spritesmith-main-7.png);background-position:0 -1431px;width:105px;height:105px}.Mount_Head_Gryphon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-106px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Base{background-image:url(spritesmith-main-7.png);background-position:-212px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-318px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-424px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Desert{background-image:url(spritesmith-main-7.png);background-position:-530px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Golden{background-image:url(spritesmith-main-7.png);background-position:-636px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Red{background-image:url(spritesmith-main-7.png);background-position:-742px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Shade{background-image:url(spritesmith-main-7.png);background-position:-848px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-954px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-White{background-image:url(spritesmith-main-7.png);background-position:-1060px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1166px -1431px;width:105px;height:105px}.Mount_Head_Horse-Base{background-image:url(spritesmith-main-7.png);background-position:-1272px -1431px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1378px -1431px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1484px -1431px;width:105px;height:105px}.Mount_Head_Horse-Desert{background-image:url(spritesmith-main-7.png);background-position:-1634px 0;width:105px;height:105px}.Mount_Head_Horse-Golden{background-image:url(spritesmith-main-7.png);background-position:-1634px -106px;width:105px;height:105px}.Mount_Head_Horse-Red{background-image:url(spritesmith-main-7.png);background-position:-1634px -212px;width:105px;height:105px}.Mount_Head_Horse-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -318px;width:105px;height:105px}.Mount_Head_Horse-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px -424px;width:105px;height:105px}.Mount_Head_Horse-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -530px;width:105px;height:105px}.Mount_Head_Horse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -636px;width:105px;height:105px}.Mount_Head_JackOLantern-Base{background-image:url(spritesmith-main-7.png);background-position:-1740px -424px;width:90px;height:105px}.Mount_Head_LionCub-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -848px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1634px -954px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1634px -1060px;width:105px;height:105px}.Mount_Head_LionCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-1634px -1166px;width:105px;height:105px}.Mount_Head_LionCub-Ethereal{background-image:url(spritesmith-main-7.png);background-position:-1634px -1272px;width:105px;height:105px}.Mount_Head_LionCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-1634px -1378px;width:105px;height:105px}.Mount_Head_LionCub-Red{background-image:url(spritesmith-main-7.png);background-position:0 -1537px;width:105px;height:105px}.Mount_Head_LionCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-106px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -680px;width:105px;height:110px}.Mount_Head_LionCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-318px -1537px;width:105px;height:105px}.Mount_Head_LionCub-White{background-image:url(spritesmith-main-7.png);background-position:-424px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-530px -1537px;width:105px;height:105px}.Mount_Head_Mammoth-Base{background-image:url(spritesmith-main-7.png);background-position:-136px -544px;width:105px;height:123px}.Mount_Head_MantisShrimp-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -680px;width:108px;height:105px}.Mount_Head_Octopus-Base{background-image:url(spritesmith-main-7.png);background-position:-848px -1537px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-954px -1537px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1060px -1537px;width:105px;height:105px}.Mount_Head_Octopus-Desert{background-image:url(spritesmith-main-7.png);background-position:-1166px -1537px;width:105px;height:105px}.Mount_Head_Octopus-Golden{background-image:url(spritesmith-main-7.png);background-position:-1272px -1537px;width:105px;height:105px}.Mount_Head_Octopus-Red{background-image:url(spritesmith-main-7.png);background-position:-1378px -1537px;width:105px;height:105px}.Mount_Head_Octopus-Shade{background-image:url(spritesmith-main-7.png);background-position:-1484px -1537px;width:105px;height:105px}.Mount_Head_Octopus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1590px -1537px;width:105px;height:105px}.Mount_Head_Octopus-White{background-image:url(spritesmith-main-7.png);background-position:-1740px 0;width:105px;height:105px}.Mount_Head_Octopus-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1740px -106px;width:105px;height:105px}.Mount_Head_Orca-Base{background-image:url(spritesmith-main-7.png);background-position:-1740px -212px;width:105px;height:105px}.Mount_Head_Owl-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -742px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1740px -318px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1060px -1104px;width:105px;height:105px}.Mount_Head_Owl-Desert{background-image:url(spritesmith-main-8.png);background-position:-954px -1210px;width:105px;height:105px}.Mount_Head_Owl-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Owl-Red{background-image:url(spritesmith-main-8.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Owl-Shade{background-image:url(spritesmith-main-8.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Owl-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Owl-White{background-image:url(spritesmith-main-8.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Owl-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_PandaCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_PandaCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_PandaCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_PandaCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_PandaCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1316px -1166px;width:105px;height:105px}.Mount_Head_PandaCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-106px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-White{background-image:url(spritesmith-main-8.png);background-position:-212px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-318px -1316px;width:105px;height:105px}.Mount_Head_Parrot-Base{background-image:url(spritesmith-main-8.png);background-position:-424px -1316px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-530px -1316px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-636px -1316px;width:105px;height:105px}.Mount_Head_Parrot-Desert{background-image:url(spritesmith-main-8.png);background-position:-242px -544px;width:105px;height:105px}.Mount_Head_Parrot-Golden{background-image:url(spritesmith-main-8.png);background-position:-348px -544px;width:105px;height:105px}.Mount_Head_Parrot-Red{background-image:url(spritesmith-main-8.png);background-position:-454px -544px;width:105px;height:105px}.Mount_Head_Parrot-Shade{background-image:url(spritesmith-main-8.png);background-position:-560px -544px;width:105px;height:105px}.Mount_Head_Parrot-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-680px 0;width:105px;height:105px}.Mount_Head_Parrot-White{background-image:url(spritesmith-main-8.png);background-position:-680px -106px;width:105px;height:105px}.Mount_Head_Parrot-Zombie{background-image:url(spritesmith-main-8.png);background-position:-680px -212px;width:105px;height:105px}.Mount_Head_Penguin-Base{background-image:url(spritesmith-main-8.png);background-position:-680px -318px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-680px -424px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-680px -530px;width:105px;height:105px}.Mount_Head_Penguin-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -680px;width:105px;height:105px}.Mount_Head_Penguin-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -680px;width:105px;height:105px}.Mount_Head_Penguin-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -680px;width:105px;height:105px}.Mount_Head_Penguin-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -680px;width:105px;height:105px}.Mount_Head_Penguin-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -680px;width:105px;height:105px}.Mount_Head_Penguin-White{background-image:url(spritesmith-main-8.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_Penguin-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Head_Phoenix-Base{background-image:url(spritesmith-main-8.png);background-position:-786px 0;width:105px;height:105px}.Mount_Head_Rat-Base{background-image:url(spritesmith-main-8.png);background-position:-786px -106px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-786px -212px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-786px -318px;width:105px;height:105px}.Mount_Head_Rat-Desert{background-image:url(spritesmith-main-8.png);background-position:-786px -424px;width:105px;height:105px}.Mount_Head_Rat-Golden{background-image:url(spritesmith-main-8.png);background-position:-786px -530px;width:105px;height:105px}.Mount_Head_Rat-Red{background-image:url(spritesmith-main-8.png);background-position:-786px -636px;width:105px;height:105px}.Mount_Head_Rat-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -786px;width:105px;height:105px}.Mount_Head_Rat-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-106px -786px;width:105px;height:105px}.Mount_Head_Rat-White{background-image:url(spritesmith-main-8.png);background-position:-212px -786px;width:105px;height:105px}.Mount_Head_Rat-Zombie{background-image:url(spritesmith-main-8.png);background-position:-318px -786px;width:105px;height:105px}.Mount_Head_Rock-Base{background-image:url(spritesmith-main-8.png);background-position:-424px -786px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-530px -786px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-636px -786px;width:105px;height:105px}.Mount_Head_Rock-Desert{background-image:url(spritesmith-main-8.png);background-position:-742px -786px;width:105px;height:105px}.Mount_Head_Rock-Golden{background-image:url(spritesmith-main-8.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Rock-Red{background-image:url(spritesmith-main-8.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Head_Rock-Shade{background-image:url(spritesmith-main-8.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Head_Rock-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Head_Rock-White{background-image:url(spritesmith-main-8.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Head_Rock-Zombie{background-image:url(spritesmith-main-8.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Head_Rooster-Base{background-image:url(spritesmith-main-8.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-892px -742px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:0 -892px;width:105px;height:105px}.Mount_Head_Rooster-Desert{background-image:url(spritesmith-main-8.png);background-position:-106px -892px;width:105px;height:105px}.Mount_Head_Rooster-Golden{background-image:url(spritesmith-main-8.png);background-position:-212px -892px;width:105px;height:105px}.Mount_Head_Rooster-Red{background-image:url(spritesmith-main-8.png);background-position:-318px -892px;width:105px;height:105px}.Mount_Head_Rooster-Shade{background-image:url(spritesmith-main-8.png);background-position:-424px -892px;width:105px;height:105px}.Mount_Head_Rooster-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-530px -892px;width:105px;height:105px}.Mount_Head_Rooster-White{background-image:url(spritesmith-main-8.png);background-position:-636px -892px;width:105px;height:105px}.Mount_Head_Rooster-Zombie{background-image:url(spritesmith-main-8.png);background-position:-742px -892px;width:105px;height:105px}.Mount_Head_Seahorse-Base{background-image:url(spritesmith-main-8.png);background-position:-848px -892px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-998px 0;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Head_Seahorse-Desert{background-image:url(spritesmith-main-8.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Head_Seahorse-Golden{background-image:url(spritesmith-main-8.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_Seahorse-Red{background-image:url(spritesmith-main-8.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_Seahorse-Shade{background-image:url(spritesmith-main-8.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_Seahorse-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_Seahorse-White{background-image:url(spritesmith-main-8.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_Seahorse-Zombie{background-image:url(spritesmith-main-8.png);background-position:-998px -848px;width:105px;height:105px}.Mount_Head_Sheep-Base{background-image:url(spritesmith-main-8.png);background-position:0 -998px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-106px -998px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-212px -998px;width:105px;height:105px}.Mount_Head_Sheep-Desert{background-image:url(spritesmith-main-8.png);background-position:-318px -998px;width:105px;height:105px}.Mount_Head_Sheep-Golden{background-image:url(spritesmith-main-8.png);background-position:-424px -998px;width:105px;height:105px}.Mount_Head_Sheep-Red{background-image:url(spritesmith-main-8.png);background-position:-530px -998px;width:105px;height:105px}.Mount_Head_Sheep-Shade{background-image:url(spritesmith-main-8.png);background-position:-636px -998px;width:105px;height:105px}.Mount_Head_Sheep-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-742px -998px;width:105px;height:105px}.Mount_Head_Sheep-White{background-image:url(spritesmith-main-8.png);background-position:-848px -998px;width:105px;height:105px}.Mount_Head_Sheep-Zombie{background-image:url(spritesmith-main-8.png);background-position:-954px -998px;width:105px;height:105px}.Mount_Head_Slime-Base{background-image:url(spritesmith-main-8.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Slime-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Slime-Desert{background-image:url(spritesmith-main-8.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Slime-Golden{background-image:url(spritesmith-main-8.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Slime-Red{background-image:url(spritesmith-main-8.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Slime-Shade{background-image:url(spritesmith-main-8.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Slime-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Slime-White{background-image:url(spritesmith-main-8.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Slime-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1104px -954px;width:105px;height:105px}.Mount_Head_Spider-Base{background-image:url(spritesmith-main-8.png);background-position:0 -1104px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-106px -1104px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-212px -1104px;width:105px;height:105px}.Mount_Head_Spider-Desert{background-image:url(spritesmith-main-8.png);background-position:-318px -1104px;width:105px;height:105px}.Mount_Head_Spider-Golden{background-image:url(spritesmith-main-8.png);background-position:-424px -1104px;width:105px;height:105px}.Mount_Head_Spider-Red{background-image:url(spritesmith-main-8.png);background-position:-530px -1104px;width:105px;height:105px}.Mount_Head_Spider-Shade{background-image:url(spritesmith-main-8.png);background-position:-636px -1104px;width:105px;height:105px}.Mount_Head_Spider-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-742px -1104px;width:105px;height:105px}.Mount_Head_Spider-White{background-image:url(spritesmith-main-8.png);background-position:-848px -1104px;width:105px;height:105px}.Mount_Head_Spider-Zombie{background-image:url(spritesmith-main-8.png);background-position:-954px -1104px;width:105px;height:105px}.Mount_Head_TRex-Base{background-image:url(spritesmith-main-8.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:0 -136px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Head_TRex-Desert{background-image:url(spritesmith-main-8.png);background-position:-272px 0;width:135px;height:135px}.Mount_Head_TRex-Golden{background-image:url(spritesmith-main-8.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Head_TRex-Red{background-image:url(spritesmith-main-8.png);background-position:0 -272px;width:135px;height:135px}.Mount_Head_TRex-Shade{background-image:url(spritesmith-main-8.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Head_TRex-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Head_TRex-White{background-image:url(spritesmith-main-8.png);background-position:-408px 0;width:135px;height:135px}.Mount_Head_TRex-Zombie{background-image:url(spritesmith-main-8.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Head_TigerCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1210px -1060px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:0 -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-106px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-212px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Red{background-image:url(spritesmith-main-8.png);background-position:-318px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-424px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-530px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-636px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-White{background-image:url(spritesmith-main-8.png);background-position:-742px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-848px -1210px;width:105px;height:105px}.Mount_Head_Turkey-Base{background-image:url(spritesmith-main-8.png);background-position:-136px -544px;width:105px;height:105px}.Mount_Head_Whale-Base{background-image:url(spritesmith-main-8.png);background-position:-1060px -1210px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1166px -1210px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Whale-Desert{background-image:url(spritesmith-main-8.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Whale-Golden{background-image:url(spritesmith-main-8.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Whale-Red{background-image:url(spritesmith-main-8.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Whale-Shade{background-image:url(spritesmith-main-8.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Whale-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Whale-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Whale-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Wolf-Base{background-image:url(spritesmith-main-8.png);background-position:0 0;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:0 -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Head_Wolf-Desert{background-image:url(spritesmith-main-8.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Head_Wolf-Golden{background-image:url(spritesmith-main-8.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Head_Wolf-Red{background-image:url(spritesmith-main-8.png);background-position:-544px 0;width:135px;height:135px}.Mount_Head_Wolf-Shade{background-image:url(spritesmith-main-8.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Head_Wolf-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Head_Wolf-Spooky{background-image:url(spritesmith-main-8.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_Wolf-White{background-image:url(spritesmith-main-8.png);background-position:0 -544px;width:135px;height:135px}.Mount_Head_Wolf-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px 0;width:135px;height:135px}.Pet-BearCub-Base{background-image:url(spritesmith-main-8.png);background-position:-824px -1316px;width:81px;height:99px}.Pet-BearCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1586px 0;width:81px;height:99px}.Pet-BearCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-906px -1316px;width:81px;height:99px}.Pet-BearCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-988px -1316px;width:81px;height:99px}.Pet-BearCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1070px -1316px;width:81px;height:99px}.Pet-BearCub-Polar{background-image:url(spritesmith-main-8.png);background-position:-1152px -1316px;width:81px;height:99px}.Pet-BearCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1234px -1316px;width:81px;height:99px}.Pet-BearCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1316px -1316px;width:81px;height:99px}.Pet-BearCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1422px 0;width:81px;height:99px}.Pet-BearCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1422px -100px;width:81px;height:99px}.Pet-BearCub-White{background-image:url(spritesmith-main-8.png);background-position:-1422px -200px;width:81px;height:99px}.Pet-BearCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1422px -300px;width:81px;height:99px}.Pet-Bunny-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -400px;width:81px;height:99px}.Pet-Bunny-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1422px -500px;width:81px;height:99px}.Pet-Bunny-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px -600px;width:81px;height:99px}.Pet-Bunny-Desert{background-image:url(spritesmith-main-8.png);background-position:-1422px -700px;width:81px;height:99px}.Pet-Bunny-Golden{background-image:url(spritesmith-main-8.png);background-position:-1422px -800px;width:81px;height:99px}.Pet-Bunny-Red{background-image:url(spritesmith-main-8.png);background-position:-1422px -900px;width:81px;height:99px}.Pet-Bunny-Shade{background-image:url(spritesmith-main-8.png);background-position:-1422px -1000px;width:81px;height:99px}.Pet-Bunny-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1422px -1100px;width:81px;height:99px}.Pet-Bunny-White{background-image:url(spritesmith-main-8.png);background-position:-1422px -1200px;width:81px;height:99px}.Pet-Bunny-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1422px -1300px;width:81px;height:99px}.Pet-Cactus-Base{background-image:url(spritesmith-main-8.png);background-position:-1504px 0;width:81px;height:99px}.Pet-Cactus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1504px -100px;width:81px;height:99px}.Pet-Cactus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1504px -200px;width:81px;height:99px}.Pet-Cactus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1504px -300px;width:81px;height:99px}.Pet-Cactus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1504px -400px;width:81px;height:99px}.Pet-Cactus-Red{background-image:url(spritesmith-main-8.png);background-position:-1504px -500px;width:81px;height:99px}.Pet-Cactus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1504px -600px;width:81px;height:99px}.Pet-Cactus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1504px -700px;width:81px;height:99px}.Pet-Cactus-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1504px -800px;width:81px;height:99px}.Pet-Cactus-White{background-image:url(spritesmith-main-8.png);background-position:-1504px -900px;width:81px;height:99px}.Pet-Cactus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1504px -1000px;width:81px;height:99px}.Pet-Cheetah-Base{background-image:url(spritesmith-main-8.png);background-position:-1504px -1100px;width:81px;height:99px}.Pet-Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1504px -1200px;width:81px;height:99px}.Pet-Cheetah-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1504px -1300px;width:81px;height:99px}.Pet-Cheetah-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1422px;width:81px;height:99px}.Pet-Cheetah-Golden{background-image:url(spritesmith-main-8.png);background-position:-82px -1422px;width:81px;height:99px}.Pet-Cheetah-Red{background-image:url(spritesmith-main-8.png);background-position:-164px -1422px;width:81px;height:99px}.Pet-Cheetah-Shade{background-image:url(spritesmith-main-8.png);background-position:-246px -1422px;width:81px;height:99px}.Pet-Cheetah-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-328px -1422px;width:81px;height:99px}.Pet-Cheetah-White{background-image:url(spritesmith-main-8.png);background-position:-410px -1422px;width:81px;height:99px}.Pet-Cheetah-Zombie{background-image:url(spritesmith-main-8.png);background-position:-492px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Base{background-image:url(spritesmith-main-8.png);background-position:-574px -1422px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-656px -1422px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-738px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Desert{background-image:url(spritesmith-main-8.png);background-position:-820px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Golden{background-image:url(spritesmith-main-8.png);background-position:-902px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Red{background-image:url(spritesmith-main-8.png);background-position:-984px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Shade{background-image:url(spritesmith-main-8.png);background-position:-1066px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1148px -1422px;width:81px;height:99px}.Pet-Cuttlefish-White{background-image:url(spritesmith-main-8.png);background-position:-1230px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1312px -1422px;width:81px;height:99px}.Pet-Deer-Base{background-image:url(spritesmith-main-8.png);background-position:-1394px -1422px;width:81px;height:99px}.Pet-Deer-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1476px -1422px;width:81px;height:99px}.Pet-Deer-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -1316px;width:81px;height:99px}.Pet-Deer-Desert{background-image:url(spritesmith-main-8.png);background-position:-1586px -100px;width:81px;height:99px}.Pet-Deer-Golden{background-image:url(spritesmith-main-8.png);background-position:-1586px -200px;width:81px;height:99px}.Pet-Deer-Red{background-image:url(spritesmith-main-8.png);background-position:-1586px -300px;width:81px;height:99px}.Pet-Deer-Shade{background-image:url(spritesmith-main-8.png);background-position:-1586px -400px;width:81px;height:99px}.Pet-Deer-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1586px -500px;width:81px;height:99px}.Pet-Deer-White{background-image:url(spritesmith-main-8.png);background-position:-1586px -600px;width:81px;height:99px}.Pet-Deer-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1586px -700px;width:81px;height:99px}.Pet-Dragon-Base{background-image:url(spritesmith-main-8.png);background-position:-1586px -800px;width:81px;height:99px}.Pet-Dragon-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1586px -900px;width:81px;height:99px}.Pet-Dragon-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1586px -1000px;width:81px;height:99px}.Pet-Dragon-Desert{background-image:url(spritesmith-main-8.png);background-position:-1586px -1100px;width:81px;height:99px}.Pet-Dragon-Golden{background-image:url(spritesmith-main-8.png);background-position:-1586px -1200px;width:81px;height:99px}.Pet-Dragon-Hydra{background-image:url(spritesmith-main-8.png);background-position:-1586px -1300px;width:81px;height:99px}.Pet-Dragon-Red{background-image:url(spritesmith-main-8.png);background-position:-1586px -1400px;width:81px;height:99px}.Pet-Dragon-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -1522px;width:81px;height:99px}.Pet-Dragon-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-82px -1522px;width:81px;height:99px}.Pet-Dragon-Spooky{background-image:url(spritesmith-main-8.png);background-position:-164px -1522px;width:81px;height:99px}.Pet-Dragon-White{background-image:url(spritesmith-main-8.png);background-position:-246px -1522px;width:81px;height:99px}.Pet-Dragon-Zombie{background-image:url(spritesmith-main-8.png);background-position:-328px -1522px;width:81px;height:99px}.Pet-Egg-Base{background-image:url(spritesmith-main-8.png);background-position:-410px -1522px;width:81px;height:99px}.Pet-Egg-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-492px -1522px;width:81px;height:99px}.Pet-Egg-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-574px -1522px;width:81px;height:99px}.Pet-Egg-Desert{background-image:url(spritesmith-main-8.png);background-position:-656px -1522px;width:81px;height:99px}.Pet-Egg-Golden{background-image:url(spritesmith-main-8.png);background-position:-738px -1522px;width:81px;height:99px}.Pet-Egg-Red{background-image:url(spritesmith-main-8.png);background-position:-820px -1522px;width:81px;height:99px}.Pet-Egg-Shade{background-image:url(spritesmith-main-8.png);background-position:-902px -1522px;width:81px;height:99px}.Pet-Egg-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-984px -1522px;width:81px;height:99px}.Pet-Egg-White{background-image:url(spritesmith-main-8.png);background-position:-1066px -1522px;width:81px;height:99px}.Pet-Egg-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1148px -1522px;width:81px;height:99px}.Pet-FlyingPig-Base{background-image:url(spritesmith-main-8.png);background-position:-1230px -1522px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1312px -1522px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1394px -1522px;width:81px;height:99px}.Pet-FlyingPig-Desert{background-image:url(spritesmith-main-8.png);background-position:-1476px -1522px;width:81px;height:99px}.Pet-FlyingPig-Golden{background-image:url(spritesmith-main-8.png);background-position:-1558px -1522px;width:81px;height:99px}.Pet-FlyingPig-Red{background-image:url(spritesmith-main-8.png);background-position:-1668px 0;width:81px;height:99px}.Pet-FlyingPig-Shade{background-image:url(spritesmith-main-8.png);background-position:-1668px -100px;width:81px;height:99px}.Pet-FlyingPig-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1668px -200px;width:81px;height:99px}.Pet-FlyingPig-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1668px -300px;width:81px;height:99px}.Pet-FlyingPig-White{background-image:url(spritesmith-main-8.png);background-position:-1668px -400px;width:81px;height:99px}.Pet-FlyingPig-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1668px -500px;width:81px;height:99px}.Pet-Fox-Base{background-image:url(spritesmith-main-8.png);background-position:-1668px -600px;width:81px;height:99px}.Pet-Fox-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1668px -700px;width:81px;height:99px}.Pet-Fox-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1668px -800px;width:81px;height:99px}.Pet-Fox-Desert{background-image:url(spritesmith-main-8.png);background-position:-1668px -900px;width:81px;height:99px}.Pet-Fox-Golden{background-image:url(spritesmith-main-8.png);background-position:-1668px -1000px;width:81px;height:99px}.Pet-Fox-Red{background-image:url(spritesmith-main-8.png);background-position:-1668px -1100px;width:81px;height:99px}.Pet-Fox-Shade{background-image:url(spritesmith-main-8.png);background-position:-1668px -1200px;width:81px;height:99px}.Pet-Fox-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1668px -1300px;width:81px;height:99px}.Pet-Fox-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1668px -1400px;width:81px;height:99px}.Pet-Fox-White{background-image:url(spritesmith-main-8.png);background-position:-1668px -1500px;width:81px;height:99px}.Pet-Fox-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1622px;width:81px;height:99px}.Pet-Frog-Base{background-image:url(spritesmith-main-8.png);background-position:-82px -1622px;width:81px;height:99px}.Pet-Frog-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-164px -1622px;width:81px;height:99px}.Pet-Frog-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-246px -1622px;width:81px;height:99px}.Pet-Frog-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px 0;width:81px;height:99px}.Pet-Frog-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -600px;width:81px;height:99px}.Pet-Frog-Red{background-image:url(spritesmith-main-9.png);background-position:-164px 0;width:81px;height:99px}.Pet-Frog-Shade{background-image:url(spritesmith-main-9.png);background-position:0 -100px;width:81px;height:99px}.Pet-Frog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px -100px;width:81px;height:99px}.Pet-Frog-White{background-image:url(spritesmith-main-9.png);background-position:-164px -100px;width:81px;height:99px}.Pet-Frog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-246px 0;width:81px;height:99px}.Pet-Gryphon-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -100px;width:81px;height:99px}.Pet-Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -200px;width:81px;height:99px}.Pet-Gryphon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -200px;width:81px;height:99px}.Pet-Gryphon-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -200px;width:81px;height:99px}.Pet-Gryphon-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -200px;width:81px;height:99px}.Pet-Gryphon-Red{background-image:url(spritesmith-main-9.png);background-position:-328px 0;width:81px;height:99px}.Pet-Gryphon-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -100px;width:81px;height:99px}.Pet-Gryphon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -200px;width:81px;height:99px}.Pet-Gryphon-White{background-image:url(spritesmith-main-9.png);background-position:0 -300px;width:81px;height:99px}.Pet-Gryphon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-82px -300px;width:81px;height:99px}.Pet-Hedgehog-Base{background-image:url(spritesmith-main-9.png);background-position:-164px -300px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-246px -300px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-328px -300px;width:81px;height:99px}.Pet-Hedgehog-Desert{background-image:url(spritesmith-main-9.png);background-position:-410px 0;width:81px;height:99px}.Pet-Hedgehog-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -100px;width:81px;height:99px}.Pet-Hedgehog-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -200px;width:81px;height:99px}.Pet-Hedgehog-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -300px;width:81px;height:99px}.Pet-Hedgehog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-492px 0;width:81px;height:99px}.Pet-Hedgehog-White{background-image:url(spritesmith-main-9.png);background-position:-492px -100px;width:81px;height:99px}.Pet-Hedgehog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -200px;width:81px;height:99px}.Pet-Horse-Base{background-image:url(spritesmith-main-9.png);background-position:-492px -300px;width:81px;height:99px}.Pet-Horse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -400px;width:81px;height:99px}.Pet-Horse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -400px;width:81px;height:99px}.Pet-Horse-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -400px;width:81px;height:99px}.Pet-Horse-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -400px;width:81px;height:99px}.Pet-Horse-Red{background-image:url(spritesmith-main-9.png);background-position:-328px -400px;width:81px;height:99px}.Pet-Horse-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -400px;width:81px;height:99px}.Pet-Horse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-492px -400px;width:81px;height:99px}.Pet-Horse-White{background-image:url(spritesmith-main-9.png);background-position:-574px 0;width:81px;height:99px}.Pet-Horse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px -100px;width:81px;height:99px}.Pet-JackOLantern-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -200px;width:81px;height:99px}.Pet-LionCub-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -300px;width:81px;height:99px}.Pet-LionCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-574px -400px;width:81px;height:99px}.Pet-LionCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 -500px;width:81px;height:99px}.Pet-LionCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px -500px;width:81px;height:99px}.Pet-LionCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-164px -500px;width:81px;height:99px}.Pet-LionCub-Red{background-image:url(spritesmith-main-9.png);background-position:-246px -500px;width:81px;height:99px}.Pet-LionCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -500px;width:81px;height:99px}.Pet-LionCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -500px;width:81px;height:99px}.Pet-LionCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-492px -500px;width:81px;height:99px}.Pet-LionCub-White{background-image:url(spritesmith-main-9.png);background-position:-574px -500px;width:81px;height:99px}.Pet-LionCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-656px 0;width:81px;height:99px}.Pet-Mammoth-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -100px;width:81px;height:99px}.Pet-MantisShrimp-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -200px;width:81px;height:99px}.Pet-Octopus-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -300px;width:81px;height:99px}.Pet-Octopus-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px -400px;width:81px;height:99px}.Pet-Octopus-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-656px -500px;width:81px;height:99px}.Pet-Octopus-Desert{background-image:url(spritesmith-main-9.png);background-position:0 -600px;width:81px;height:99px}.Pet-Octopus-Golden{background-image:url(spritesmith-main-9.png);background-position:-82px -600px;width:81px;height:99px}.Pet-Octopus-Red{background-image:url(spritesmith-main-9.png);background-position:-164px -600px;width:81px;height:99px}.Pet-Octopus-Shade{background-image:url(spritesmith-main-9.png);background-position:-246px -600px;width:81px;height:99px}.Pet-Octopus-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -600px;width:81px;height:99px}.Pet-Octopus-White{background-image:url(spritesmith-main-9.png);background-position:-410px -600px;width:81px;height:99px}.Pet-Octopus-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -600px;width:81px;height:99px}.Pet-Owl-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -600px;width:81px;height:99px}.Pet-Owl-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px -600px;width:81px;height:99px}.Pet-Owl-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-738px 0;width:81px;height:99px}.Pet-Owl-Desert{background-image:url(spritesmith-main-9.png);background-position:-738px -100px;width:81px;height:99px}.Pet-Owl-Golden{background-image:url(spritesmith-main-9.png);background-position:-738px -200px;width:81px;height:99px}.Pet-Owl-Red{background-image:url(spritesmith-main-9.png);background-position:-738px -300px;width:81px;height:99px}.Pet-Owl-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px -400px;width:81px;height:99px}.Pet-Owl-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -500px;width:81px;height:99px}.Pet-Owl-White{background-image:url(spritesmith-main-9.png);background-position:-738px -600px;width:81px;height:99px}.Pet-Owl-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -700px;width:81px;height:99px}.Pet-PandaCub-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -700px;width:81px;height:99px}.Pet-PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -700px;width:81px;height:99px}.Pet-PandaCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px -700px;width:81px;height:99px}.Pet-PandaCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-328px -700px;width:81px;height:99px}.Pet-PandaCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -700px;width:81px;height:99px}.Pet-PandaCub-Red{background-image:url(spritesmith-main-9.png);background-position:-492px -700px;width:81px;height:99px}.Pet-PandaCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-574px -700px;width:81px;height:99px}.Pet-PandaCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-656px -700px;width:81px;height:99px}.Pet-PandaCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-738px -700px;width:81px;height:99px}.Pet-PandaCub-White{background-image:url(spritesmith-main-9.png);background-position:-820px 0;width:81px;height:99px}.Pet-PandaCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -100px;width:81px;height:99px}.Pet-Parrot-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -200px;width:81px;height:99px}.Pet-Parrot-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-820px -300px;width:81px;height:99px}.Pet-Parrot-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-820px -400px;width:81px;height:99px}.Pet-Parrot-Desert{background-image:url(spritesmith-main-9.png);background-position:-820px -500px;width:81px;height:99px}.Pet-Parrot-Golden{background-image:url(spritesmith-main-9.png);background-position:-820px -600px;width:81px;height:99px}.Pet-Parrot-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -700px;width:81px;height:99px}.Pet-Parrot-Shade{background-image:url(spritesmith-main-9.png);background-position:0 -800px;width:81px;height:99px}.Pet-Parrot-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px -800px;width:81px;height:99px}.Pet-Parrot-White{background-image:url(spritesmith-main-9.png);background-position:-164px -800px;width:81px;height:99px}.Pet-Parrot-Zombie{background-image:url(spritesmith-main-9.png);background-position:-246px -800px;width:81px;height:99px}.Pet-Penguin-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -800px;width:81px;height:99px}.Pet-Penguin-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-410px -800px;width:81px;height:99px}.Pet-Penguin-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -800px;width:81px;height:99px}.Pet-Penguin-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -800px;width:81px;height:99px}.Pet-Penguin-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -800px;width:81px;height:99px}.Pet-Penguin-Red{background-image:url(spritesmith-main-9.png);background-position:-738px -800px;width:81px;height:99px}.Pet-Penguin-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -800px;width:81px;height:99px}.Pet-Penguin-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-902px 0;width:81px;height:99px}.Pet-Penguin-White{background-image:url(spritesmith-main-9.png);background-position:-902px -100px;width:81px;height:99px}.Pet-Penguin-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -200px;width:81px;height:99px}.Pet-Phoenix-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -300px;width:81px;height:99px}.Pet-Rat-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -400px;width:81px;height:99px}.Pet-Rat-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -500px;width:81px;height:99px}.Pet-Rat-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-902px -600px;width:81px;height:99px}.Pet-Rat-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -700px;width:81px;height:99px}.Pet-Rat-Golden{background-image:url(spritesmith-main-9.png);background-position:-902px -800px;width:81px;height:99px}.Pet-Rat-Red{background-image:url(spritesmith-main-9.png);background-position:-984px 0;width:81px;height:99px}.Pet-Rat-Shade{background-image:url(spritesmith-main-9.png);background-position:-984px -100px;width:81px;height:99px}.Pet-Rat-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-984px -200px;width:81px;height:99px}.Pet-Rat-White{background-image:url(spritesmith-main-9.png);background-position:-984px -300px;width:81px;height:99px}.Pet-Rat-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px -400px;width:81px;height:99px}.Pet-Rock-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -500px;width:81px;height:99px}.Pet-Rock-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 0;width:81px;height:99px}.Pet-Rock-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -700px;width:81px;height:99px}.Pet-Rock-Desert{background-image:url(spritesmith-main-9.png);background-position:-984px -800px;width:81px;height:99px}.Pet-Rock-Golden{background-image:url(spritesmith-main-9.png);background-position:0 -900px;width:81px;height:99px}.Pet-Rock-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -900px;width:81px;height:99px}.Pet-Rock-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -900px;width:81px;height:99px}.Pet-Rock-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -900px;width:81px;height:99px}.Pet-Rock-White{background-image:url(spritesmith-main-9.png);background-position:-328px -900px;width:81px;height:99px}.Pet-Rock-Zombie{background-image:url(spritesmith-main-9.png);background-position:-410px -900px;width:81px;height:99px}.Pet-Rooster-Base{background-image:url(spritesmith-main-9.png);background-position:-492px -900px;width:81px;height:99px}.Pet-Rooster-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-574px -900px;width:81px;height:99px}.Pet-Rooster-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-656px -900px;width:81px;height:99px}.Pet-Rooster-Desert{background-image:url(spritesmith-main-9.png);background-position:-738px -900px;width:81px;height:99px}.Pet-Rooster-Golden{background-image:url(spritesmith-main-9.png);background-position:-820px -900px;width:81px;height:99px}.Pet-Rooster-Red{background-image:url(spritesmith-main-9.png);background-position:-902px -900px;width:81px;height:99px}.Pet-Rooster-Shade{background-image:url(spritesmith-main-9.png);background-position:-984px -900px;width:81px;height:99px}.Pet-Rooster-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1066px 0;width:81px;height:99px}.Pet-Rooster-White{background-image:url(spritesmith-main-9.png);background-position:-1066px -100px;width:81px;height:99px}.Pet-Rooster-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1066px -200px;width:81px;height:99px}.Pet-Seahorse-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -300px;width:81px;height:99px}.Pet-Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1066px -400px;width:81px;height:99px}.Pet-Seahorse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -500px;width:81px;height:99px}.Pet-Seahorse-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -600px;width:81px;height:99px}.Pet-Seahorse-Golden{background-image:url(spritesmith-main-9.png);background-position:-1066px -700px;width:81px;height:99px}.Pet-Seahorse-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -800px;width:81px;height:99px}.Pet-Seahorse-Shade{background-image:url(spritesmith-main-9.png);background-position:-1066px -900px;width:81px;height:99px}.Pet-Seahorse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:0 -1000px;width:81px;height:99px}.Pet-Seahorse-White{background-image:url(spritesmith-main-9.png);background-position:-82px -1000px;width:81px;height:99px}.Pet-Seahorse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px -1000px;width:81px;height:99px}.Pet-Sheep-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -1000px;width:81px;height:99px}.Pet-Sheep-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-328px -1000px;width:81px;height:99px}.Pet-Sheep-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-410px -1000px;width:81px;height:99px}.Pet-Sheep-Desert{background-image:url(spritesmith-main-9.png);background-position:-492px -1000px;width:81px;height:99px}.Pet-Sheep-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -1000px;width:81px;height:99px}.Pet-Sheep-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -1000px;width:81px;height:99px}.Pet-Sheep-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px -1000px;width:81px;height:99px}.Pet-Sheep-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-820px -1000px;width:81px;height:99px}.Pet-Sheep-White{background-image:url(spritesmith-main-9.png);background-position:-902px -1000px;width:81px;height:99px}.Pet-Sheep-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px -1000px;width:81px;height:99px}.Pet-Slime-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -1000px;width:81px;height:99px}.Pet-Slime-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px 0;width:81px;height:99px}.Pet-Slime-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1148px -100px;width:81px;height:99px}.Pet-Slime-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -200px;width:81px;height:99px}.Pet-Slime-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -300px;width:81px;height:99px}.Pet-Slime-Red{background-image:url(spritesmith-main-9.png);background-position:-1148px -400px;width:81px;height:99px}.Pet-Slime-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -500px;width:81px;height:99px}.Pet-Slime-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1148px -600px;width:81px;height:99px}.Pet-Slime-White{background-image:url(spritesmith-main-9.png);background-position:-1148px -700px;width:81px;height:99px}.Pet-Slime-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -800px;width:81px;height:99px}.Pet-Spider-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -900px;width:81px;height:99px}.Pet-Spider-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px -1000px;width:81px;height:99px}.Pet-Spider-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 -1100px;width:81px;height:99px}.Pet-Spider-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px -1100px;width:81px;height:99px}.Pet-Spider-Golden{background-image:url(spritesmith-main-9.png);background-position:-164px -1100px;width:81px;height:99px}.Pet-Spider-Red{background-image:url(spritesmith-main-9.png);background-position:-246px -1100px;width:81px;height:99px}.Pet-Spider-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -1100px;width:81px;height:99px}.Pet-Spider-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -1100px;width:81px;height:99px}.Pet-Spider-White{background-image:url(spritesmith-main-9.png);background-position:-492px -1100px;width:81px;height:99px}.Pet-Spider-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px -1100px;width:81px;height:99px}.Pet-TRex-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -1100px;width:81px;height:99px}.Pet-TRex-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -1100px;width:81px;height:99px}.Pet-TRex-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-820px -1100px;width:81px;height:99px}.Pet-TRex-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -1100px;width:81px;height:99px}.Pet-TRex-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -1100px;width:81px;height:99px}.Pet-TRex-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -1100px;width:81px;height:99px}.Pet-TRex-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -1100px;width:81px;height:99px}.Pet-TRex-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px 0;width:81px;height:99px}.Pet-TRex-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -100px;width:81px;height:99px}.Pet-TRex-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1230px -200px;width:81px;height:99px}.Pet-Tiger-Veteran{background-image:url(spritesmith-main-9.png);background-position:-1230px -300px;width:81px;height:99px}.Pet-TigerCub-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px -400px;width:81px;height:99px}.Pet-TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1230px -500px;width:81px;height:99px}.Pet-TigerCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1230px -600px;width:81px;height:99px}.Pet-TigerCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-1230px -700px;width:81px;height:99px}.Pet-TigerCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -800px;width:81px;height:99px}.Pet-TigerCub-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -900px;width:81px;height:99px}.Pet-TigerCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-1230px -1000px;width:81px;height:99px}.Pet-TigerCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px -1100px;width:81px;height:99px}.Pet-TigerCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:0 -1200px;width:81px;height:99px}.Pet-TigerCub-White{background-image:url(spritesmith-main-9.png);background-position:-82px -1200px;width:81px;height:99px}.Pet-TigerCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px -1200px;width:81px;height:99px}.Pet-Turkey-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -1200px;width:81px;height:99px}.Pet-Whale-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -1200px;width:81px;height:99px}.Pet-Whale-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-410px -1200px;width:81px;height:99px}.Pet-Whale-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -1200px;width:81px;height:99px}.Pet-Whale-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -1200px;width:81px;height:99px}.Pet-Whale-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -1200px;width:81px;height:99px}.Pet-Whale-Red{background-image:url(spritesmith-main-9.png);background-position:-738px -1200px;width:81px;height:99px}.Pet-Whale-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -1200px;width:81px;height:99px}.Pet-Whale-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-902px -1200px;width:81px;height:99px}.Pet-Whale-White{background-image:url(spritesmith-main-9.png);background-position:-984px -1200px;width:81px;height:99px}.Pet-Whale-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1066px -1200px;width:81px;height:99px}.Pet-Wolf-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -1200px;width:81px;height:99px}.Pet-Wolf-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1230px -1200px;width:81px;height:99px}.Pet-Wolf-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px 0;width:81px;height:99px}.Pet-Wolf-Desert{background-image:url(spritesmith-main-9.png);background-position:-1312px -100px;width:81px;height:99px}.Pet-Wolf-Golden{background-image:url(spritesmith-main-9.png);background-position:-1312px -200px;width:81px;height:99px}.Pet-Wolf-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px -300px;width:81px;height:99px}.Pet-Wolf-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -400px;width:81px;height:99px}.Pet-Wolf-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -500px;width:81px;height:99px}.Pet-Wolf-Spooky{background-image:url(spritesmith-main-9.png);background-position:-1312px -600px;width:81px;height:99px}.Pet-Wolf-Veteran{background-image:url(spritesmith-main-9.png);background-position:-1312px -700px;width:81px;height:99px}.Pet-Wolf-White{background-image:url(spritesmith-main-9.png);background-position:-1312px -800px;width:81px;height:99px}.Pet-Wolf-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1312px -900px;width:81px;height:99px}.Pet_HatchingPotion_Base{background-image:url(spritesmith-main-9.png);background-position:-1312px -1052px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -1300px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px -1104px;width:48px;height:51px}.Pet_HatchingPotion_Desert{background-image:url(spritesmith-main-9.png);background-position:-1312px -1156px;width:48px;height:51px}.Pet_HatchingPotion_Golden{background-image:url(spritesmith-main-9.png);background-position:-1312px -1208px;width:48px;height:51px}.Pet_HatchingPotion_Red{background-image:url(spritesmith-main-9.png);background-position:-1312px -1000px;width:48px;height:51px}.Pet_HatchingPotion_Shade{background-image:url(spritesmith-main-9.png);background-position:-49px -1300px;width:48px;height:51px}.Pet_HatchingPotion_Skeleton{background-image:url(spritesmith-main-9.png);background-position:-98px -1300px;width:48px;height:51px}.Pet_HatchingPotion_Spooky{background-image:url(spritesmith-main-9.png);background-position:-147px -1300px;width:48px;height:51px}.Pet_HatchingPotion_White{background-image:url(spritesmith-main-9.png);background-position:-196px -1300px;width:48px;height:51px}.Pet_HatchingPotion_Zombie{background-image:url(spritesmith-main-9.png);background-position:-245px -1300px;width:48px;height:51px}.head_special_0,.weapon_special_0{width:105px;height:105px;margin-left:-3px;margin-top:-18px}.broad_armor_special_0,.shield_special_0,.slim_armor_special_0{width:90px;height:90px}.weapon_special_critical{background:url(/common/img/sprites/backer-only/weapon_special_critical.gif) no-repeat;width:90px;height:90px;margin-left:-12px;margin-top:12px}.weapon_special_1{margin-left:-12px}.broad_armor_special_1,.head_special_1,.slim_armor_special_1{width:90px;height:90px}.head_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeHelmet.gif) no-repeat}.head_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalHelmet.gif) no-repeat;margin-top:3px}.broad_armor_special_0,.slim_armor_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeArmor.gif) no-repeat}.broad_armor_special_1,.slim_armor_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalArmor.gif) no-repeat}.shield_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Shield-TormentedSkull.gif) no-repeat}.weapon_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Weapon-DarkSoulsBlade.gif) no-repeat}.Pet-Wolf-Cerberus{width:105px;height:72px;background:url(/common/img/sprites/backer-only/BackerOnly-Pet-CerberusPup.gif) no-repeat}.quest_burnout{background:url(/common/img/sprites/quest_burnout.gif) no-repeat;width:219px;height:249px}.Gems{display:inline-block;margin-right:5px;border-style:none;margin-left:0;margin-top:2px}.inline-gems{vertical-align:middle;margin-left:0;display:inline-block}.customize-menu .locked{background-color:#727272}.achievement{float:left;clear:right;margin-right:10px}.multi-achievement{margin:auto;padding-left:.5em;padding-right:.5em}[class*=Mount_Body_],[class*=Mount_Head_]{margin-top:18px}.Pet_Currency_Gem{margin-top:5px;margin-bottom:5px} \ No newline at end of file +.2014_Fall_HealerPROMO2{background-image:url(spritesmith-largeSprites-0.png);background-position:-387px -950px;width:90px;height:90px}.2014_Fall_Mage_PROMO9{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -483px;width:120px;height:90px}.2014_Fall_RoguePROMO3{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -680px;width:105px;height:90px}.2014_Fall_Warrior_PROMO{background-image:url(spritesmith-largeSprites-0.png);background-position:-569px -950px;width:90px;height:90px}.promo_backtoschool{background-image:url(spritesmith-largeSprites-0.png);background-position:-220px -220px;width:150px;height:150px}.promo_burnout{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -220px;width:219px;height:240px}.promo_classes_fall_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-326px -664px;width:321px;height:100px}.promo_classes_fall_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -564px;width:377px;height:99px}.promo_dilatoryDistress{background-image:url(spritesmith-largeSprites-0.png);background-position:-660px -950px;width:90px;height:90px}.promo_enchanted_armoire{background-image:url(spritesmith-largeSprites-0.png);background-position:-378px -564px;width:374px;height:76px}.promo_enchanted_armoire_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-289px -859px;width:217px;height:90px}.promo_enchanted_armoire_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-725px -859px;width:180px;height:90px}.promo_enchanted_armoire_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-933px -950px;width:90px;height:90px}.promo_enchanted_armoire_201511{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -392px;width:122px;height:90px}.promo_habitica{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -166px;width:175px;height:175px}.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -148px;width:100px;height:137px}.customize-option.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-995px -163px;width:60px;height:60px}.promo_item_notif{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -347px;width:249px;height:102px}.promo_mystery_201405{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -182px;width:90px;height:90px}.promo_mystery_201406{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -771px;width:90px;height:96px}.promo_mystery_201407{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -704px;width:42px;height:62px}.promo_mystery_201408{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -428px;width:60px;height:71px}.promo_mystery_201409{background-image:url(spritesmith-largeSprites-0.png);background-position:-842px -950px;width:90px;height:90px}.promo_mystery_201410{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -364px;width:72px;height:63px}.promo_mystery_201411{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px 0;width:90px;height:90px}.promo_mystery_201412{background-image:url(spritesmith-largeSprites-0.png);background-position:-1154px -634px;width:42px;height:66px}.promo_mystery_201501{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -570px;width:48px;height:63px}.promo_mystery_201502{background-image:url(spritesmith-largeSprites-0.png);background-position:-478px -950px;width:90px;height:90px}.promo_mystery_201503{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -273px;width:90px;height:90px}.promo_mystery_201504{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -500px;width:60px;height:69px}.promo_mystery_201505{background-image:url(spritesmith-largeSprites-0.png);background-position:-751px -950px;width:90px;height:90px}.promo_mystery_201506{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -634px;width:42px;height:69px}.promo_mystery_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -574px;width:90px;height:105px}.promo_mystery_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-293px -950px;width:93px;height:90px}.promo_mystery_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -91px;width:90px;height:90px}.promo_mystery_201510{background-image:url(spritesmith-largeSprites-0.png);background-position:-199px -950px;width:93px;height:90px}.promo_mystery_3014{background-image:url(spritesmith-largeSprites-0.png);background-position:-507px -859px;width:217px;height:90px}.promo_orca{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -286px;width:105px;height:105px}.promo_partyhats{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -868px;width:115px;height:47px}.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -775px;width:330px;height:83px}.customize-option.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -790px;width:60px;height:60px}.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px 0;width:140px;height:147px}.customize-option.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-995px -15px;width:60px;height:60px}.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-331px -775px;width:330px;height:83px}.customize-option.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-356px -790px;width:60px;height:60px}.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -950px;width:198px;height:91px}.customize-option.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -965px;width:60px;height:60px}.promo_springclasses2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-430px -461px;width:288px;height:90px}.promo_springclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -859px;width:288px;height:90px}.promo_summer_classes_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -461px;width:429px;height:102px}.promo_summer_classes_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-648px -664px;width:300px;height:88px}.promo_updos{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -342px;width:156px;height:147px}.promo_veteran_pets{background-image:url(spritesmith-largeSprites-0.png);background-position:-753px -564px;width:146px;height:75px}.promo_winterclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -664px;width:325px;height:110px}.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-220px -371px;width:152px;height:75px}.customize-option.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-245px -386px;width:60px;height:60px}.party_preview{background-image:url(spritesmith-largeSprites-0.png);background-position:0 0;width:451px;height:219px}.welcome_basic_avatars{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -181px;width:246px;height:165px}.welcome_promo_party{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px 0;width:270px;height:180px}.welcome_sample_tasks{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px 0;width:246px;height:165px}.achievement-alien{background-image:url(spritesmith-main-0.png);background-position:-1092px -1036px;width:24px;height:26px}.achievement-alpha{background-image:url(spritesmith-main-0.png);background-position:-1612px -1547px;width:24px;height:26px}.achievement-armor{background-image:url(spritesmith-main-0.png);background-position:-1096px -1006px;width:24px;height:26px}.achievement-boot{background-image:url(spritesmith-main-0.png);background-position:-1071px -1006px;width:24px;height:26px}.achievement-bow{background-image:url(spritesmith-main-0.png);background-position:-1046px -1006px;width:24px;height:26px}.achievement-burnout{background-image:url(spritesmith-main-0.png);background-position:-1021px -1006px;width:24px;height:26px}.achievement-cactus{background-image:url(spritesmith-main-0.png);background-position:-996px -1006px;width:24px;height:26px}.achievement-cake{background-image:url(spritesmith-main-0.png);background-position:-971px -1006px;width:24px;height:26px}.achievement-cave{background-image:url(spritesmith-main-0.png);background-position:-946px -1006px;width:24px;height:26px}.achievement-coffin{background-image:url(spritesmith-main-0.png);background-position:-921px -1006px;width:24px;height:26px}.achievement-comment{background-image:url(spritesmith-main-0.png);background-position:-896px -1006px;width:24px;height:26px}.achievement-costumeContest{background-image:url(spritesmith-main-0.png);background-position:-871px -1006px;width:24px;height:26px}.achievement-dilatory{background-image:url(spritesmith-main-0.png);background-position:-846px -1006px;width:24px;height:26px}.achievement-firefox{background-image:url(spritesmith-main-0.png);background-position:-1096px -979px;width:24px;height:26px}.achievement-greeting{background-image:url(spritesmith-main-0.png);background-position:-1071px -979px;width:24px;height:26px}.achievement-habitBirthday{background-image:url(spritesmith-main-0.png);background-position:-1046px -979px;width:24px;height:26px}.achievement-habiticaDay{background-image:url(spritesmith-main-0.png);background-position:-1021px -979px;width:24px;height:26px}.achievement-heart{background-image:url(spritesmith-main-0.png);background-position:-996px -979px;width:24px;height:26px}.achievement-karaoke{background-image:url(spritesmith-main-0.png);background-position:-971px -979px;width:24px;height:26px}.achievement-ninja{background-image:url(spritesmith-main-0.png);background-position:-946px -979px;width:24px;height:26px}.achievement-nye{background-image:url(spritesmith-main-0.png);background-position:-921px -979px;width:24px;height:26px}.achievement-perfect{background-image:url(spritesmith-main-0.png);background-position:-1587px -1547px;width:24px;height:26px}.achievement-rat{background-image:url(spritesmith-main-0.png);background-position:-871px -979px;width:24px;height:26px}.achievement-seafoam{background-image:url(spritesmith-main-0.png);background-position:-846px -979px;width:24px;height:26px}.achievement-shield{background-image:url(spritesmith-main-0.png);background-position:-1182px -1092px;width:24px;height:26px}.achievement-shinySeed{background-image:url(spritesmith-main-0.png);background-position:-1157px -1092px;width:24px;height:26px}.achievement-snowball{background-image:url(spritesmith-main-0.png);background-position:-1132px -1092px;width:24px;height:26px}.achievement-spookDust{background-image:url(spritesmith-main-0.png);background-position:-1273px -1183px;width:24px;height:26px}.achievement-stoikalm{background-image:url(spritesmith-main-0.png);background-position:-1248px -1183px;width:24px;height:26px}.achievement-sun{background-image:url(spritesmith-main-0.png);background-position:-1223px -1183px;width:24px;height:26px}.achievement-sword{background-image:url(spritesmith-main-0.png);background-position:-1364px -1274px;width:24px;height:26px}.achievement-thankyou{background-image:url(spritesmith-main-0.png);background-position:-1339px -1274px;width:24px;height:26px}.achievement-thermometer{background-image:url(spritesmith-main-0.png);background-position:-1314px -1274px;width:24px;height:26px}.achievement-tree{background-image:url(spritesmith-main-0.png);background-position:-1455px -1365px;width:24px;height:26px}.achievement-triadbingo{background-image:url(spritesmith-main-0.png);background-position:-1430px -1365px;width:24px;height:26px}.achievement-ultimate-healer{background-image:url(spritesmith-main-0.png);background-position:-1405px -1365px;width:24px;height:26px}.achievement-ultimate-mage{background-image:url(spritesmith-main-0.png);background-position:-1546px -1456px;width:24px;height:26px}.achievement-ultimate-rogue{background-image:url(spritesmith-main-0.png);background-position:-1521px -1456px;width:24px;height:26px}.achievement-ultimate-warrior{background-image:url(spritesmith-main-0.png);background-position:-1496px -1456px;width:24px;height:26px}.achievement-valentine{background-image:url(spritesmith-main-0.png);background-position:-1637px -1547px;width:24px;height:26px}.achievement-wolf{background-image:url(spritesmith-main-0.png);background-position:-896px -979px;width:24px;height:26px}.background_autumn_forest{background-image:url(spritesmith-main-0.png);background-position:-423px -592px;width:140px;height:147px}.background_beach{background-image:url(spritesmith-main-0.png);background-position:-426px 0;width:141px;height:147px}.background_blacksmithy{background-image:url(spritesmith-main-0.png);background-position:-709px -148px;width:140px;height:147px}.background_cherry_trees{background-image:url(spritesmith-main-0.png);background-position:-709px -296px;width:140px;height:147px}.background_clouds{background-image:url(spritesmith-main-0.png);background-position:0 -592px;width:140px;height:147px}.background_coral_reef{background-image:url(spritesmith-main-0.png);background-position:-850px -148px;width:140px;height:147px}.background_crystal_cave{background-image:url(spritesmith-main-0.png);background-position:-850px -592px;width:140px;height:147px}.background_dilatory_ruins{background-image:url(spritesmith-main-0.png);background-position:0 -740px;width:140px;height:147px}.background_distant_castle{background-image:url(spritesmith-main-0.png);background-position:-423px -888px;width:140px;height:147px}.background_drifting_raft{background-image:url(spritesmith-main-0.png);background-position:-564px -888px;width:140px;height:147px}.background_dusty_canyons{background-image:url(spritesmith-main-0.png);background-position:-705px -888px;width:140px;height:147px}.background_fairy_ring{background-image:url(spritesmith-main-0.png);background-position:-568px 0;width:140px;height:147px}.background_floating_islands{background-image:url(spritesmith-main-0.png);background-position:-568px -148px;width:140px;height:147px}.background_floral_meadow{background-image:url(spritesmith-main-0.png);background-position:-568px -296px;width:140px;height:147px}.background_forest{background-image:url(spritesmith-main-0.png);background-position:0 -444px;width:140px;height:147px}.background_frigid_peak{background-image:url(spritesmith-main-0.png);background-position:-141px -444px;width:140px;height:147px}.background_giant_wave{background-image:url(spritesmith-main-0.png);background-position:-284px 0;width:141px;height:147px}.background_graveyard{background-image:url(spritesmith-main-0.png);background-position:-423px -444px;width:140px;height:147px}.background_gumdrop_land{background-image:url(spritesmith-main-0.png);background-position:-564px -444px;width:140px;height:147px}.background_harvest_feast{background-image:url(spritesmith-main-0.png);background-position:-709px 0;width:140px;height:147px}.background_harvest_fields{background-image:url(spritesmith-main-0.png);background-position:0 -148px;width:141px;height:147px}.background_harvest_moon{background-image:url(spritesmith-main-0.png);background-position:-142px -148px;width:141px;height:147px}.background_haunted_house{background-image:url(spritesmith-main-0.png);background-position:-709px -444px;width:140px;height:147px}.background_ice_cave{background-image:url(spritesmith-main-0.png);background-position:-284px -148px;width:141px;height:147px}.background_iceberg{background-image:url(spritesmith-main-0.png);background-position:-141px -592px;width:140px;height:147px}.background_island_waterfalls{background-image:url(spritesmith-main-0.png);background-position:-282px -592px;width:140px;height:147px}.background_marble_temple{background-image:url(spritesmith-main-0.png);background-position:-142px 0;width:141px;height:147px}.background_market{background-image:url(spritesmith-main-0.png);background-position:-564px -592px;width:140px;height:147px}.background_mountain_lake{background-image:url(spritesmith-main-0.png);background-position:-705px -592px;width:140px;height:147px}.background_night_dunes{background-image:url(spritesmith-main-0.png);background-position:-850px 0;width:140px;height:147px}.background_open_waters{background-image:url(spritesmith-main-0.png);background-position:0 0;width:141px;height:147px}.background_pagodas{background-image:url(spritesmith-main-0.png);background-position:-850px -296px;width:140px;height:147px}.background_pumpkin_patch{background-image:url(spritesmith-main-0.png);background-position:-850px -444px;width:140px;height:147px}.background_pyramids{background-image:url(spritesmith-main-0.png);background-position:-426px -148px;width:141px;height:147px}.background_rolling_hills{background-image:url(spritesmith-main-0.png);background-position:0 -296px;width:141px;height:147px}.background_seafarer_ship{background-image:url(spritesmith-main-0.png);background-position:-141px -740px;width:140px;height:147px}.background_shimmery_bubbles{background-image:url(spritesmith-main-0.png);background-position:-282px -740px;width:140px;height:147px}.background_slimy_swamp{background-image:url(spritesmith-main-0.png);background-position:-423px -740px;width:140px;height:147px}.background_snowy_pines{background-image:url(spritesmith-main-0.png);background-position:-564px -740px;width:140px;height:147px}.background_south_pole{background-image:url(spritesmith-main-0.png);background-position:-705px -740px;width:140px;height:147px}.background_spring_rain{background-image:url(spritesmith-main-0.png);background-position:-846px -740px;width:140px;height:147px}.background_stable{background-image:url(spritesmith-main-0.png);background-position:-991px 0;width:140px;height:147px}.background_stained_glass{background-image:url(spritesmith-main-0.png);background-position:-991px -148px;width:140px;height:147px}.background_starry_skies{background-image:url(spritesmith-main-0.png);background-position:-991px -296px;width:140px;height:147px}.background_sunken_ship{background-image:url(spritesmith-main-0.png);background-position:-991px -444px;width:140px;height:147px}.background_sunset_meadow{background-image:url(spritesmith-main-0.png);background-position:-991px -592px;width:140px;height:147px}.background_sunset_oasis{background-image:url(spritesmith-main-0.png);background-position:-991px -740px;width:140px;height:147px}.background_sunset_savannah{background-image:url(spritesmith-main-0.png);background-position:0 -888px;width:140px;height:147px}.background_swarming_darkness{background-image:url(spritesmith-main-0.png);background-position:-141px -888px;width:140px;height:147px}.background_tavern{background-image:url(spritesmith-main-0.png);background-position:-282px -888px;width:140px;height:147px}.background_thunderstorm{background-image:url(spritesmith-main-0.png);background-position:-142px -296px;width:141px;height:147px}.background_twinkly_lights{background-image:url(spritesmith-main-0.png);background-position:-284px -296px;width:141px;height:147px}.background_twinkly_party_lights{background-image:url(spritesmith-main-0.png);background-position:-426px -296px;width:141px;height:147px}.background_volcano{background-image:url(spritesmith-main-0.png);background-position:-282px -444px;width:140px;height:147px}.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1223px -91px;width:90px;height:90px}.customize-option.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1248px -106px;width:60px;height:60px}.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1223px -182px;width:90px;height:90px}.customize-option.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1248px -197px;width:60px;height:60px}.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1223px -273px;width:90px;height:90px}.customize-option.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1248px -288px;width:60px;height:60px}.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1223px -364px;width:90px;height:90px}.customize-option.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1248px -379px;width:60px;height:60px}.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-1223px -455px;width:90px;height:90px}.customize-option.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-1248px -470px;width:60px;height:60px}.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-1223px -546px;width:90px;height:90px}.customize-option.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-1248px -561px;width:60px;height:60px}.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1223px -637px;width:90px;height:90px}.customize-option.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1248px -652px;width:60px;height:60px}.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1223px -728px;width:90px;height:90px}.customize-option.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1248px -743px;width:60px;height:60px}.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1223px -819px;width:90px;height:90px}.customize-option.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1248px -834px;width:60px;height:60px}.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1223px -910px;width:90px;height:90px}.customize-option.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1248px -925px;width:60px;height:60px}.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1223px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1248px -1016px;width:60px;height:60px}.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-1223px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-1248px -1107px;width:60px;height:60px}.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:0 -1218px;width:90px;height:90px}.customize-option.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-25px -1233px;width:60px;height:60px}.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-91px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-116px -1233px;width:60px;height:60px}.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-182px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-207px -1233px;width:60px;height:60px}.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-273px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-298px -1233px;width:60px;height:60px}.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-364px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-389px -1233px;width:60px;height:60px}.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-455px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-480px -1233px;width:60px;height:60px}.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-546px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-571px -1233px;width:60px;height:60px}.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-637px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-662px -1233px;width:60px;height:60px}.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-728px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-753px -1233px;width:60px;height:60px}.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-819px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-844px -1233px;width:60px;height:60px}.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-910px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-935px -1233px;width:60px;height:60px}.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1001px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1026px -1233px;width:60px;height:60px}.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1092px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1117px -1233px;width:60px;height:60px}.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1183px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1208px -1233px;width:60px;height:60px}.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1314px 0;width:90px;height:90px}.customize-option.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1339px -15px;width:60px;height:60px}.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1314px -91px;width:90px;height:90px}.customize-option.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1339px -106px;width:60px;height:60px}.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1314px -182px;width:90px;height:90px}.customize-option.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1339px -197px;width:60px;height:60px}.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1314px -273px;width:90px;height:90px}.customize-option.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1339px -288px;width:60px;height:60px}.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1314px -364px;width:90px;height:90px}.customize-option.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1339px -379px;width:60px;height:60px}.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1314px -455px;width:90px;height:90px}.customize-option.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1339px -470px;width:60px;height:60px}.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1314px -546px;width:90px;height:90px}.customize-option.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1339px -561px;width:60px;height:60px}.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -637px;width:90px;height:90px}.customize-option.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -652px;width:60px;height:60px}.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -728px;width:90px;height:90px}.customize-option.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -743px;width:60px;height:60px}.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -819px;width:90px;height:90px}.customize-option.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -834px;width:60px;height:60px}.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -910px;width:90px;height:90px}.customize-option.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -925px;width:60px;height:60px}.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1314px -1001px;width:90px;height:90px}.customize-option.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1339px -1016px;width:60px;height:60px}.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1314px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1339px -1107px;width:60px;height:60px}.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1314px -1183px;width:90px;height:90px}.customize-option.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1339px -1198px;width:60px;height:60px}.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:0 -1309px;width:90px;height:90px}.customize-option.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-25px -1324px;width:60px;height:60px}.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-91px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-116px -1324px;width:60px;height:60px}.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-182px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-207px -1324px;width:60px;height:60px}.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-273px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-298px -1324px;width:60px;height:60px}.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-364px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-389px -1324px;width:60px;height:60px}.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-455px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-480px -1324px;width:60px;height:60px}.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-546px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-571px -1324px;width:60px;height:60px}.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-637px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-662px -1324px;width:60px;height:60px}.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-728px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-753px -1324px;width:60px;height:60px}.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-846px -888px;width:90px;height:90px}.customize-option.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-871px -903px;width:60px;height:60px}.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-910px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-935px -1324px;width:60px;height:60px}.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1001px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1026px -1324px;width:60px;height:60px}.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1092px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1117px -1324px;width:60px;height:60px}.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1183px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1208px -1324px;width:60px;height:60px}.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1274px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1299px -1324px;width:60px;height:60px}.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1405px 0;width:90px;height:90px}.customize-option.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1430px -15px;width:60px;height:60px}.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1405px -91px;width:90px;height:90px}.customize-option.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1430px -106px;width:60px;height:60px}.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -182px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -197px;width:60px;height:60px}.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1405px -273px;width:90px;height:90px}.customize-option.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1430px -288px;width:60px;height:60px}.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1405px -364px;width:90px;height:90px}.customize-option.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1430px -379px;width:60px;height:60px}.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1405px -455px;width:90px;height:90px}.customize-option.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1430px -470px;width:60px;height:60px}.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1405px -546px;width:90px;height:90px}.customize-option.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1430px -561px;width:60px;height:60px}.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1405px -637px;width:90px;height:90px}.customize-option.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1430px -652px;width:60px;height:60px}.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1405px -728px;width:90px;height:90px}.customize-option.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1430px -743px;width:60px;height:60px}.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -819px;width:90px;height:90px}.customize-option.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -834px;width:60px;height:60px}.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1405px -910px;width:90px;height:90px}.customize-option.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1430px -925px;width:60px;height:60px}.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1405px -1001px;width:90px;height:90px}.customize-option.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1430px -1016px;width:60px;height:60px}.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-1405px -1092px;width:90px;height:90px}.customize-option.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-1430px -1107px;width:60px;height:60px}.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-1405px -1183px;width:90px;height:90px}.customize-option.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-1430px -1198px;width:60px;height:60px}.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-1405px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-1430px -1289px;width:60px;height:60px}.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:0 -1400px;width:90px;height:90px}.customize-option.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-25px -1415px;width:60px;height:60px}.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1415px;width:60px;height:60px}.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-182px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-207px -1415px;width:60px;height:60px}.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-273px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-298px -1415px;width:60px;height:60px}.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-364px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-389px -1415px;width:60px;height:60px}.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-455px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-480px -1415px;width:60px;height:60px}.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-546px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-571px -1415px;width:60px;height:60px}.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-637px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-662px -1415px;width:60px;height:60px}.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-728px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-753px -1415px;width:60px;height:60px}.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1415px;width:60px;height:60px}.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1415px;width:60px;height:60px}.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1415px;width:60px;height:60px}.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1092px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1117px -1415px;width:60px;height:60px}.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1183px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1208px -1415px;width:60px;height:60px}.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1415px;width:60px;height:60px}.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1365px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1390px -1415px;width:60px;height:60px}.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1496px 0;width:90px;height:90px}.customize-option.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1521px -15px;width:60px;height:60px}.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1496px -91px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1521px -106px;width:60px;height:60px}.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1496px -182px;width:90px;height:90px}.customize-option.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1521px -197px;width:60px;height:60px}.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1496px -273px;width:90px;height:90px}.customize-option.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1521px -288px;width:60px;height:60px}.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -364px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -379px;width:60px;height:60px}.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1496px -455px;width:90px;height:90px}.customize-option.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1521px -470px;width:60px;height:60px}.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1496px -546px;width:90px;height:90px}.customize-option.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1521px -561px;width:60px;height:60px}.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1496px -637px;width:90px;height:90px}.customize-option.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1521px -652px;width:60px;height:60px}.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1496px -728px;width:90px;height:90px}.customize-option.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1521px -743px;width:60px;height:60px}.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1496px -819px;width:90px;height:90px}.customize-option.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1521px -834px;width:60px;height:60px}.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1496px -910px;width:90px;height:90px}.customize-option.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1521px -925px;width:60px;height:60px}.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -1001px;width:90px;height:90px}.customize-option.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -1016px;width:60px;height:60px}.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1496px -1092px;width:90px;height:90px}.customize-option.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1521px -1107px;width:60px;height:60px}.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1496px -1183px;width:90px;height:90px}.customize-option.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1521px -1198px;width:60px;height:60px}.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1496px -1274px;width:90px;height:90px}.customize-option.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1521px -1289px;width:60px;height:60px}.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-1496px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-1521px -1380px;width:60px;height:60px}.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:0 -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-25px -1506px;width:60px;height:60px}.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-91px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-116px -1506px;width:60px;height:60px}.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-182px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-207px -1506px;width:60px;height:60px}.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-273px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-298px -1506px;width:60px;height:60px}.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-364px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-389px -1506px;width:60px;height:60px}.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-455px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-480px -1506px;width:60px;height:60px}.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-546px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-571px -1506px;width:60px;height:60px}.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-637px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-662px -1506px;width:60px;height:60px}.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-728px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-753px -1506px;width:60px;height:60px}.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-819px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-844px -1506px;width:60px;height:60px}.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-910px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-935px -1506px;width:60px;height:60px}.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1001px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1026px -1506px;width:60px;height:60px}.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1092px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1117px -1506px;width:60px;height:60px}.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1183px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1208px -1506px;width:60px;height:60px}.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1274px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1299px -1506px;width:60px;height:60px}.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1365px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1390px -1506px;width:60px;height:60px}.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1456px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1481px -1506px;width:60px;height:60px}.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1587px 0;width:90px;height:90px}.customize-option.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1612px -15px;width:60px;height:60px}.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1587px -91px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1612px -106px;width:60px;height:60px}.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1587px -182px;width:90px;height:90px}.customize-option.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1612px -197px;width:60px;height:60px}.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1587px -273px;width:90px;height:90px}.customize-option.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1612px -288px;width:60px;height:60px}.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -379px;width:60px;height:60px}.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1587px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1612px -470px;width:60px;height:60px}.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-1587px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-1612px -561px;width:60px;height:60px}.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1587px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1612px -652px;width:60px;height:60px}.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-1587px -728px;width:90px;height:90px}.customize-option.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-1612px -743px;width:60px;height:60px}.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1587px -819px;width:90px;height:90px}.customize-option.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1612px -834px;width:60px;height:60px}.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1587px -910px;width:90px;height:90px}.customize-option.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1612px -925px;width:60px;height:60px}.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -1001px;width:90px;height:90px}.customize-option.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -1016px;width:60px;height:60px}.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1587px -1092px;width:90px;height:90px}.customize-option.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1612px -1107px;width:60px;height:60px}.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1587px -1183px;width:90px;height:90px}.customize-option.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1612px -1198px;width:60px;height:60px}.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1587px -1274px;width:90px;height:90px}.customize-option.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1612px -1289px;width:60px;height:60px}.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1587px -1365px;width:90px;height:90px}.customize-option.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1612px -1380px;width:60px;height:60px}.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1587px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1612px -1471px;width:60px;height:60px}.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:0 -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-25px -1597px;width:60px;height:60px}.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1597px;width:60px;height:60px}.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-182px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-207px -1597px;width:60px;height:60px}.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-273px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-298px -1597px;width:60px;height:60px}.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-364px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-389px -1597px;width:60px;height:60px}.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-455px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-480px -1597px;width:60px;height:60px}.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-546px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-571px -1597px;width:60px;height:60px}.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-637px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-662px -1597px;width:60px;height:60px}.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-728px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-753px -1597px;width:60px;height:60px}.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1597px;width:60px;height:60px}.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1597px;width:60px;height:60px}.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1597px;width:60px;height:60px}.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1092px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1117px -1597px;width:60px;height:60px}.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1183px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1208px -1597px;width:60px;height:60px}.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1597px;width:60px;height:60px}.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1365px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1390px -1597px;width:60px;height:60px}.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1456px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1481px -1597px;width:60px;height:60px}.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-819px -1309px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-844px -1324px;width:60px;height:60px}.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1132px -1001px;width:90px;height:90px}.customize-option.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1157px -1016px;width:60px;height:60px}.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1132px -910px;width:90px;height:90px}.customize-option.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1157px -925px;width:60px;height:60px}.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1132px -819px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1157px -834px;width:60px;height:60px}.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1132px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1157px -743px;width:60px;height:60px}.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1132px -637px;width:90px;height:90px}.customize-option.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1157px -652px;width:60px;height:60px}.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1132px -546px;width:90px;height:90px}.customize-option.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1157px -561px;width:60px;height:60px}.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1132px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1157px -470px;width:60px;height:60px}.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1132px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1157px -379px;width:60px;height:60px}.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1132px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1157px -288px;width:60px;height:60px}.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1132px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1157px -197px;width:60px;height:60px}.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1132px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1157px -106px;width:60px;height:60px}.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1132px 0;width:90px;height:90px}.customize-option.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1157px -15px;width:60px;height:60px}.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1001px -1036px;width:90px;height:90px}.customize-option.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1026px -1051px;width:60px;height:60px}.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-910px -1036px;width:90px;height:90px}.customize-option.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-935px -1051px;width:60px;height:60px}.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-819px -1036px;width:90px;height:90px}.customize-option.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-844px -1051px;width:60px;height:60px}.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-728px -1036px;width:90px;height:90px}.customize-option.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-753px -1051px;width:60px;height:60px}.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-637px -1036px;width:90px;height:90px}.customize-option.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-662px -1051px;width:60px;height:60px}.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-546px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-571px -1051px;width:60px;height:60px}.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-455px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-480px -1051px;width:60px;height:60px}.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-364px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-389px -1051px;width:60px;height:60px}.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1051px;width:60px;height:60px}.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-182px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-207px -1051px;width:60px;height:60px}.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1051px;width:60px;height:60px}.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:0 -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-25px -1051px;width:60px;height:60px}.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1028px -888px;width:90px;height:90px}.customize-option.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1053px -903px;width:60px;height:60px}.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-937px -888px;width:90px;height:90px}.customize-option.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-962px -903px;width:60px;height:60px}.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1223px 0;width:90px;height:90px}.customize-option.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1248px -15px;width:60px;height:60px}.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1092px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1117px -1142px;width:60px;height:60px}.hair_bangs_1_green{background-image:url(spritesmith-main-0.png);background-position:-1001px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_green{background-image:url(spritesmith-main-0.png);background-position:-1026px -1142px;width:60px;height:60px}.hair_bangs_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-910px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-935px -1142px;width:60px;height:60px}.hair_bangs_1_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1142px;width:60px;height:60px}.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-728px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-753px -1142px;width:60px;height:60px}.hair_bangs_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-637px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-662px -1142px;width:60px;height:60px}.hair_bangs_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-546px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-571px -1142px;width:60px;height:60px}.hair_bangs_1_pblue2{background-image:url(spritesmith-main-0.png);background-position:-455px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue2{background-image:url(spritesmith-main-0.png);background-position:-480px -1142px;width:60px;height:60px}.hair_bangs_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-364px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-389px -1142px;width:60px;height:60px}.hair_bangs_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-273px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-298px -1142px;width:60px;height:60px}.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-0.png);background-position:-182px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-0.png);background-position:-207px -1142px;width:60px;height:60px}.hair_bangs_1_porange{background-image:url(spritesmith-main-0.png);background-position:-91px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_porange{background-image:url(spritesmith-main-0.png);background-position:-116px -1142px;width:60px;height:60px}.hair_bangs_1_porange2{background-image:url(spritesmith-main-0.png);background-position:0 -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_porange2{background-image:url(spritesmith-main-0.png);background-position:-25px -1142px;width:60px;height:60px}.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-116px -15px;width:60px;height:60px}.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-753px -1107px;width:60px;height:60px}.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-25px -106px;width:60px;height:60px}.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-116px -106px;width:60px;height:60px}.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-207px -15px;width:60px;height:60px}.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -106px;width:60px;height:60px}.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-25px -197px;width:60px;height:60px}.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-116px -197px;width:60px;height:60px}.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-207px -197px;width:60px;height:60px}.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-298px -15px;width:60px;height:60px}.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-298px -106px;width:60px;height:60px}.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-298px -197px;width:60px;height:60px}.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-25px -288px;width:60px;height:60px}.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-116px -288px;width:60px;height:60px}.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-207px -288px;width:60px;height:60px}.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-298px -288px;width:60px;height:60px}.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-389px -15px;width:60px;height:60px}.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-389px -106px;width:60px;height:60px}.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-389px -197px;width:60px;height:60px}.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-389px -288px;width:60px;height:60px}.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-25px -379px;width:60px;height:60px}.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-116px -379px;width:60px;height:60px}.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-207px -379px;width:60px;height:60px}.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-298px -379px;width:60px;height:60px}.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-389px -379px;width:60px;height:60px}.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-480px -15px;width:60px;height:60px}.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -106px;width:60px;height:60px}.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-480px -197px;width:60px;height:60px}.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-480px -288px;width:60px;height:60px}.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-480px -379px;width:60px;height:60px}.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-25px -470px;width:60px;height:60px}.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-116px -470px;width:60px;height:60px}.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -470px;width:60px;height:60px}.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-298px -470px;width:60px;height:60px}.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-389px -470px;width:60px;height:60px}.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-480px -470px;width:60px;height:60px}.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-571px -15px;width:60px;height:60px}.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-571px -106px;width:60px;height:60px}.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-571px -197px;width:60px;height:60px}.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -288px;width:60px;height:60px}.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-571px -379px;width:60px;height:60px}.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-571px -470px;width:60px;height:60px}.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -561px;width:60px;height:60px}.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -561px;width:60px;height:60px}.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -561px;width:60px;height:60px}.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -561px;width:60px;height:60px}.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -561px;width:60px;height:60px}.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -561px;width:60px;height:60px}.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-571px -561px;width:60px;height:60px}.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -15px;width:60px;height:60px}.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-662px -106px;width:60px;height:60px}.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-662px -197px;width:60px;height:60px}.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-662px -288px;width:60px;height:60px}.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-662px -379px;width:60px;height:60px}.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-662px -470px;width:60px;height:60px}.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-662px -561px;width:60px;height:60px}.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-25px -652px;width:60px;height:60px}.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-116px -652px;width:60px;height:60px}.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-207px -652px;width:60px;height:60px}.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-298px -652px;width:60px;height:60px}.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-389px -652px;width:60px;height:60px}.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-480px -652px;width:60px;height:60px}.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-571px -652px;width:60px;height:60px}.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-662px -652px;width:60px;height:60px}.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-753px -15px;width:60px;height:60px}.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-753px -106px;width:60px;height:60px}.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-753px -197px;width:60px;height:60px}.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -288px;width:60px;height:60px}.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -379px;width:60px;height:60px}.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-753px -470px;width:60px;height:60px}.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-753px -561px;width:60px;height:60px}.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-753px -652px;width:60px;height:60px}.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -743px;width:60px;height:60px}.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -743px;width:60px;height:60px}.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -743px;width:60px;height:60px}.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -743px;width:60px;height:60px}.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-389px -743px;width:60px;height:60px}.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-480px -743px;width:60px;height:60px}.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -743px;width:60px;height:60px}.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -743px;width:60px;height:60px}.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-753px -743px;width:60px;height:60px}.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-844px -15px;width:60px;height:60px}.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-844px -106px;width:60px;height:60px}.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-844px -197px;width:60px;height:60px}.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-844px -288px;width:60px;height:60px}.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-844px -379px;width:60px;height:60px}.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-844px -470px;width:60px;height:60px}.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-844px -561px;width:60px;height:60px}.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-844px -652px;width:60px;height:60px}.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-844px -743px;width:60px;height:60px}.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-25px -834px;width:60px;height:60px}.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-116px -834px;width:60px;height:60px}.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-207px -834px;width:60px;height:60px}.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_1_purple{background-image:url(spritesmith-main-1.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_1_purple{background-image:url(spritesmith-main-1.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_1_red{background-image:url(spritesmith-main-1.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_1_red{background-image:url(spritesmith-main-1.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_base_1_white{background-image:url(spritesmith-main-1.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_base_1_white{background-image:url(spritesmith-main-1.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_base_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_base_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-1663px -15px;width:60px;height:60px}.hair_base_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_base_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1663px -106px;width:60px;height:60px}.hair_base_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_base_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-1663px -197px;width:60px;height:60px}.hair_base_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_base_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-1663px -288px;width:60px;height:60px}.hair_base_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_base_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1663px -379px;width:60px;height:60px}.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:-116px -15px;width:60px;height:60px}.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-753px -1107px;width:60px;height:60px}.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-25px -106px;width:60px;height:60px}.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-116px -106px;width:60px;height:60px}.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-207px -15px;width:60px;height:60px}.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -106px;width:60px;height:60px}.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-25px -197px;width:60px;height:60px}.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-116px -197px;width:60px;height:60px}.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-207px -197px;width:60px;height:60px}.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-298px -15px;width:60px;height:60px}.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-298px -106px;width:60px;height:60px}.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-298px -197px;width:60px;height:60px}.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-25px -288px;width:60px;height:60px}.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-116px -288px;width:60px;height:60px}.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-207px -288px;width:60px;height:60px}.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-298px -288px;width:60px;height:60px}.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-389px -15px;width:60px;height:60px}.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-389px -106px;width:60px;height:60px}.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-389px -197px;width:60px;height:60px}.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-389px -288px;width:60px;height:60px}.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-25px -379px;width:60px;height:60px}.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-116px -379px;width:60px;height:60px}.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-207px -379px;width:60px;height:60px}.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-298px -379px;width:60px;height:60px}.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-389px -379px;width:60px;height:60px}.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-480px -15px;width:60px;height:60px}.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -106px;width:60px;height:60px}.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-480px -197px;width:60px;height:60px}.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-480px -288px;width:60px;height:60px}.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-480px -379px;width:60px;height:60px}.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:-25px -470px;width:60px;height:60px}.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-116px -470px;width:60px;height:60px}.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -470px;width:60px;height:60px}.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-298px -470px;width:60px;height:60px}.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-389px -470px;width:60px;height:60px}.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-480px -470px;width:60px;height:60px}.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-571px -15px;width:60px;height:60px}.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-571px -106px;width:60px;height:60px}.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-571px -197px;width:60px;height:60px}.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-571px -288px;width:60px;height:60px}.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-571px -379px;width:60px;height:60px}.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-571px -470px;width:60px;height:60px}.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -561px;width:60px;height:60px}.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -561px;width:60px;height:60px}.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -561px;width:60px;height:60px}.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -561px;width:60px;height:60px}.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -561px;width:60px;height:60px}.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -561px;width:60px;height:60px}.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -561px;width:60px;height:60px}.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-662px -15px;width:60px;height:60px}.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-662px -106px;width:60px;height:60px}.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-662px -197px;width:60px;height:60px}.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-662px -288px;width:60px;height:60px}.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-662px -379px;width:60px;height:60px}.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-662px -470px;width:60px;height:60px}.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-662px -561px;width:60px;height:60px}.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-25px -652px;width:60px;height:60px}.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-116px -652px;width:60px;height:60px}.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-207px -652px;width:60px;height:60px}.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-298px -652px;width:60px;height:60px}.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-389px -652px;width:60px;height:60px}.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-480px -652px;width:60px;height:60px}.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-571px -652px;width:60px;height:60px}.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-662px -652px;width:60px;height:60px}.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-753px -15px;width:60px;height:60px}.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-753px -106px;width:60px;height:60px}.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-753px -197px;width:60px;height:60px}.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -288px;width:60px;height:60px}.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -379px;width:60px;height:60px}.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-753px -470px;width:60px;height:60px}.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-753px -561px;width:60px;height:60px}.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-753px -652px;width:60px;height:60px}.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-25px -743px;width:60px;height:60px}.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -743px;width:60px;height:60px}.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -743px;width:60px;height:60px}.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -743px;width:60px;height:60px}.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-389px -743px;width:60px;height:60px}.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-480px -743px;width:60px;height:60px}.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-571px -743px;width:60px;height:60px}.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-662px -743px;width:60px;height:60px}.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-753px -743px;width:60px;height:60px}.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-844px -15px;width:60px;height:60px}.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-844px -106px;width:60px;height:60px}.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-844px -197px;width:60px;height:60px}.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-844px -288px;width:60px;height:60px}.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-844px -379px;width:60px;height:60px}.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-844px -470px;width:60px;height:60px}.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-844px -561px;width:60px;height:60px}.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-844px -652px;width:60px;height:60px}.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-844px -743px;width:60px;height:60px}.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-25px -834px;width:60px;height:60px}.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-116px -834px;width:60px;height:60px}.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-207px -834px;width:60px;height:60px}.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-389px -1562px;width:60px;height:60px}.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-480px -1562px;width:60px;height:60px}.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-571px -1562px;width:60px;height:60px}.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-662px -1562px;width:60px;height:60px}.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-753px -1562px;width:60px;height:60px}.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-844px -1562px;width:60px;height:60px}.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-935px -1562px;width:60px;height:60px}.hair_beard_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_beard_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_beard_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_beard_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_beard_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_beard_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_beard_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_beard_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_beard_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_beard_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1663px -15px;width:60px;height:60px}.hair_beard_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_beard_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-1663px -106px;width:60px;height:60px}.hair_beard_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_beard_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1663px -197px;width:60px;height:60px}.hair_beard_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1663px -288px;width:60px;height:60px}.hair_beard_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1663px -379px;width:60px;height:60px}.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-662px -1380px;width:60px;height:60px}.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-25px -1198px;width:60px;height:60px}.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:-1182px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:-1207px -652px;width:60px;height:60px}.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-753px -1380px;width:60px;height:60px}.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1546px 0;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1571px -15px;width:60px;height:60px}.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1546px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1571px -379px;width:60px;height:60px}.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1546px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1571px -470px;width:60px;height:60px}.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1546px -637px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1571px -652px;width:60px;height:60px}.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1546px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1571px -743px;width:60px;height:60px}.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-1546px -1001px;width:90px;height:90px}.customize-option.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-1571px -1031px;width:60px;height:60px}.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-1546px -1183px;width:90px;height:90px}.customize-option.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-1571px -1213px;width:60px;height:60px}.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-1546px -1274px;width:90px;height:90px}.customize-option.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-1571px -1304px;width:60px;height:60px}.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-207px -1486px;width:60px;height:60px}.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-298px -1486px;width:60px;height:60px}.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-480px -1486px;width:60px;height:60px}.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-571px -1486px;width:60px;height:60px}.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-935px -1486px;width:60px;height:60px}.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-1026px -1486px;width:60px;height:60px}.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-1637px -1001px;width:90px;height:90px}.customize-option.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-1662px -1031px;width:60px;height:60px}.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-454px -194px;width:90px;height:90px}.customize-option.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-479px -224px;width:60px;height:60px}.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-282px -364px;width:90px;height:90px}.customize-option.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-307px -394px;width:60px;height:60px}.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-373px -364px;width:90px;height:90px}.customize-option.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-398px -394px;width:60px;height:60px}.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-545px 0;width:90px;height:90px}.customize-option.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-570px -30px;width:60px;height:60px}.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-545px -91px;width:90px;height:90px}.customize-option.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-570px -121px;width:60px;height:60px}.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-545px -182px;width:90px;height:90px}.customize-option.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-570px -212px;width:60px;height:60px}.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-545px -273px;width:90px;height:90px}.customize-option.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-570px -303px;width:60px;height:60px}.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-545px -364px;width:90px;height:90px}.customize-option.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-570px -394px;width:60px;height:60px}.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:0 -455px;width:90px;height:90px}.customize-option.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-25px -485px;width:60px;height:60px}.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-116px -485px;width:60px;height:60px}.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-207px -485px;width:60px;height:60px}.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-298px -485px;width:60px;height:60px}.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-389px -485px;width:60px;height:60px}.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-480px -485px;width:60px;height:60px}.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-636px 0;width:90px;height:90px}.customize-option.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-661px -30px;width:60px;height:60px}.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-636px -91px;width:90px;height:90px}.customize-option.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-661px -121px;width:60px;height:60px}.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-636px -182px;width:90px;height:90px}.customize-option.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-661px -212px;width:60px;height:60px}.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-636px -273px;width:90px;height:90px}.customize-option.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-661px -303px;width:60px;height:60px}.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-636px -364px;width:90px;height:90px}.customize-option.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-661px -394px;width:60px;height:60px}.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-636px -455px;width:90px;height:90px}.customize-option.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-661px -485px;width:60px;height:60px}.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:0 -546px;width:90px;height:90px}.customize-option.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-25px -576px;width:60px;height:60px}.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-116px -576px;width:60px;height:60px}.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-207px -576px;width:60px;height:60px}.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-298px -576px;width:60px;height:60px}.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-389px -561px;width:60px;height:60px}.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -561px;width:60px;height:60px}.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-571px -561px;width:60px;height:60px}.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-727px 0;width:90px;height:90px}.customize-option.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-752px -15px;width:60px;height:60px}.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-727px -91px;width:90px;height:90px}.customize-option.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-752px -106px;width:60px;height:60px}.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-727px -182px;width:90px;height:90px}.customize-option.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-752px -197px;width:60px;height:60px}.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-727px -273px;width:90px;height:90px}.customize-option.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-752px -288px;width:60px;height:60px}.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-727px -364px;width:90px;height:90px}.customize-option.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-752px -379px;width:60px;height:60px}.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-727px -455px;width:90px;height:90px}.customize-option.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-752px -470px;width:60px;height:60px}.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-727px -546px;width:90px;height:90px}.customize-option.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-752px -561px;width:60px;height:60px}.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:0 -637px;width:90px;height:90px}.customize-option.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:-25px -652px;width:60px;height:60px}.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -652px;width:60px;height:60px}.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-207px -652px;width:60px;height:60px}.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -652px;width:60px;height:60px}.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-389px -652px;width:60px;height:60px}.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -652px;width:60px;height:60px}.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-571px -652px;width:60px;height:60px}.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -652px;width:60px;height:60px}.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-818px 0;width:90px;height:90px}.customize-option.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-843px -15px;width:60px;height:60px}.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-818px -91px;width:90px;height:90px}.customize-option.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-843px -106px;width:60px;height:60px}.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-818px -182px;width:90px;height:90px}.customize-option.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-843px -197px;width:60px;height:60px}.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-818px -273px;width:90px;height:90px}.customize-option.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-843px -288px;width:60px;height:60px}.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-818px -364px;width:90px;height:90px}.customize-option.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-843px -379px;width:60px;height:60px}.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-818px -455px;width:90px;height:90px}.customize-option.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-843px -470px;width:60px;height:60px}.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-818px -546px;width:90px;height:90px}.customize-option.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-843px -561px;width:60px;height:60px}.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-818px -637px;width:90px;height:90px}.customize-option.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-843px -652px;width:60px;height:60px}.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:0 -728px;width:90px;height:90px}.customize-option.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-25px -743px;width:60px;height:60px}.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -743px;width:60px;height:60px}.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-207px -743px;width:60px;height:60px}.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -743px;width:60px;height:60px}.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-389px -743px;width:60px;height:60px}.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -743px;width:60px;height:60px}.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-571px -743px;width:60px;height:60px}.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -743px;width:60px;height:60px}.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-753px -743px;width:60px;height:60px}.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-909px 0;width:90px;height:90px}.customize-option.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-934px -15px;width:60px;height:60px}.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-909px -91px;width:90px;height:90px}.customize-option.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-934px -106px;width:60px;height:60px}.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-909px -182px;width:90px;height:90px}.customize-option.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-934px -197px;width:60px;height:60px}.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-909px -273px;width:90px;height:90px}.customize-option.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-934px -288px;width:60px;height:60px}.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-909px -364px;width:90px;height:90px}.customize-option.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-934px -379px;width:60px;height:60px}.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-909px -455px;width:90px;height:90px}.customize-option.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-934px -470px;width:60px;height:60px}.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-909px -546px;width:90px;height:90px}.customize-option.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-934px -561px;width:60px;height:60px}.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-909px -637px;width:90px;height:90px}.customize-option.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-934px -652px;width:60px;height:60px}.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-909px -728px;width:90px;height:90px}.customize-option.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-934px -743px;width:60px;height:60px}.skin_lion{background-image:url(spritesmith-main-3.png);background-position:0 -819px;width:90px;height:90px}.customize-option.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-25px -834px;width:60px;height:60px}.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -834px;width:60px;height:60px}.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-207px -834px;width:60px;height:60px}.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -834px;width:60px;height:60px}.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-389px -834px;width:60px;height:60px}.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -834px;width:60px;height:60px}.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-571px -834px;width:60px;height:60px}.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -834px;width:60px;height:60px}.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-753px -834px;width:60px;height:60px}.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -834px;width:60px;height:60px}.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-1000px 0;width:90px;height:90px}.customize-option.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-1025px -15px;width:60px;height:60px}.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-1000px -91px;width:90px;height:90px}.customize-option.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-1025px -106px;width:60px;height:60px}.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-1000px -182px;width:90px;height:90px}.customize-option.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-1025px -197px;width:60px;height:60px}.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-1000px -273px;width:90px;height:90px}.customize-option.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-1025px -288px;width:60px;height:60px}.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-1000px -364px;width:90px;height:90px}.customize-option.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-1025px -379px;width:60px;height:60px}.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1000px -455px;width:90px;height:90px}.customize-option.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1025px -470px;width:60px;height:60px}.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1000px -546px;width:90px;height:90px}.customize-option.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1025px -561px;width:60px;height:60px}.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1000px -637px;width:90px;height:90px}.customize-option.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1025px -652px;width:60px;height:60px}.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1000px -728px;width:90px;height:90px}.customize-option.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1025px -743px;width:60px;height:60px}.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1000px -819px;width:90px;height:90px}.customize-option.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1025px -834px;width:60px;height:60px}.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:0 -910px;width:90px;height:90px}.customize-option.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-25px -925px;width:60px;height:60px}.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -925px;width:60px;height:60px}.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-207px -925px;width:60px;height:60px}.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -925px;width:60px;height:60px}.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-389px -925px;width:60px;height:60px}.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -925px;width:60px;height:60px}.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-571px -925px;width:60px;height:60px}.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -925px;width:60px;height:60px}.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-753px -925px;width:60px;height:60px}.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -925px;width:60px;height:60px}.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-935px -925px;width:60px;height:60px}.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px 0;width:90px;height:90px}.customize-option.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -15px;width:60px;height:60px}.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-1091px -91px;width:90px;height:90px}.customize-option.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-1116px -106px;width:60px;height:60px}.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px -182px;width:90px;height:90px}.customize-option.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -197px;width:60px;height:60px}.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-1091px -273px;width:90px;height:90px}.customize-option.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-1116px -288px;width:60px;height:60px}.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-1091px -364px;width:90px;height:90px}.customize-option.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-1116px -379px;width:60px;height:60px}.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px -455px;width:90px;height:90px}.customize-option.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -470px;width:60px;height:60px}.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px -546px;width:90px;height:90px}.customize-option.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -561px;width:60px;height:60px}.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1091px -637px;width:90px;height:90px}.customize-option.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1116px -652px;width:60px;height:60px}.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px -728px;width:90px;height:90px}.customize-option.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -743px;width:60px;height:60px}.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1091px -819px;width:90px;height:90px}.customize-option.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1116px -834px;width:60px;height:60px}.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px -910px;width:90px;height:90px}.customize-option.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -925px;width:60px;height:60px}.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-25px -1016px;width:60px;height:60px}.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-116px -1016px;width:60px;height:60px}.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-207px -1016px;width:60px;height:60px}.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1016px;width:60px;height:60px}.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-389px -1016px;width:60px;height:60px}.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1016px;width:60px;height:60px}.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-571px -1016px;width:60px;height:60px}.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-662px -1016px;width:60px;height:60px}.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-753px -1016px;width:60px;height:60px}.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -1016px;width:60px;height:60px}.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-935px -1016px;width:60px;height:60px}.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-1026px -1016px;width:60px;height:60px}.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-1182px 0;width:90px;height:90px}.customize-option.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-1207px -15px;width:60px;height:60px}.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-1182px -91px;width:90px;height:90px}.customize-option.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-1207px -106px;width:60px;height:60px}.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-1182px -182px;width:90px;height:90px}.customize-option.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-1207px -197px;width:60px;height:60px}.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-1182px -273px;width:90px;height:90px}.customize-option.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-1207px -288px;width:60px;height:60px}.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-1182px -364px;width:90px;height:90px}.customize-option.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-1207px -379px;width:60px;height:60px}.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-1182px -455px;width:90px;height:90px}.customize-option.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-1207px -470px;width:60px;height:60px}.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-1182px -546px;width:90px;height:90px}.customize-option.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-1207px -561px;width:60px;height:60px}.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1637px -1092px;width:90px;height:90px}.customize-option.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1662px -1107px;width:60px;height:60px}.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1182px -728px;width:90px;height:90px}.customize-option.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1207px -743px;width:60px;height:60px}.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1182px -819px;width:90px;height:90px}.customize-option.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1207px -834px;width:60px;height:60px}.broad_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1182px -910px;width:90px;height:90px}.broad_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1182px -1001px;width:90px;height:90px}.broad_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:0 -1092px;width:90px;height:90px}.broad_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-91px -1092px;width:90px;height:90px}.broad_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-182px -1092px;width:90px;height:90px}.broad_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-273px -1092px;width:90px;height:90px}.broad_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-364px -1092px;width:90px;height:90px}.broad_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-455px -1092px;width:90px;height:90px}.eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-546px -1092px;width:90px;height:90px}.head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-637px -1092px;width:90px;height:90px}.head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-728px -1092px;width:90px;height:90px}.head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-819px -1092px;width:90px;height:90px}.head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-910px -1092px;width:90px;height:90px}.head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-1001px -1092px;width:90px;height:90px}.head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-1092px -1092px;width:90px;height:90px}.head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-1273px 0;width:90px;height:90px}.head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-1273px -91px;width:90px;height:90px}.head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-1273px -182px;width:90px;height:90px}.head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1273px -273px;width:90px;height:90px}.head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1273px -364px;width:90px;height:90px}.head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1273px -455px;width:90px;height:90px}.head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1273px -546px;width:90px;height:90px}.head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-1273px -637px;width:90px;height:90px}.head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1273px -728px;width:90px;height:90px}.head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1273px -819px;width:90px;height:90px}.shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1273px -910px;width:90px;height:90px}.shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1273px -1001px;width:90px;height:90px}.shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1273px -1092px;width:90px;height:90px}.shop_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1678px -1470px;width:40px;height:40px}.shop_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-454px -285px;width:40px;height:40px}.shop_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-41px -1547px;width:40px;height:40px}.shop_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-82px -1547px;width:40px;height:40px}.shop_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-123px -1547px;width:40px;height:40px}.shop_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-164px -1547px;width:40px;height:40px}.shop_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-205px -1547px;width:40px;height:40px}.shop_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-246px -1547px;width:40px;height:40px}.shop_eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-369px -1547px;width:40px;height:40px}.shop_head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-410px -1547px;width:40px;height:40px}.shop_head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-451px -1547px;width:40px;height:40px}.shop_head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-492px -1547px;width:40px;height:40px}.shop_head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-656px -1547px;width:40px;height:40px}.shop_head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-697px -1547px;width:40px;height:40px}.shop_head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-738px -1547px;width:40px;height:40px}.shop_head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-861px -1547px;width:40px;height:40px}.shop_head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-902px -1547px;width:40px;height:40px}.shop_head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-943px -1547px;width:40px;height:40px}.shop_head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1066px -1547px;width:40px;height:40px}.shop_head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1107px -1547px;width:40px;height:40px}.shop_head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1230px -1547px;width:40px;height:40px}.shop_head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1394px -1547px;width:40px;height:40px}.shop_head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-1312px -1547px;width:40px;height:40px}.shop_head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1637px -1183px;width:40px;height:40px}.shop_head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1678px -1183px;width:40px;height:40px}.shop_shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1637px -1224px;width:40px;height:40px}.shop_shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1678px -1265px;width:40px;height:40px}.shop_shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1637px -1306px;width:40px;height:40px}.shop_weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-1678px -1306px;width:40px;height:40px}.shop_weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-1637px -1347px;width:40px;height:40px}.shop_weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-1678px -1347px;width:40px;height:40px}.shop_weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-1637px -1388px;width:40px;height:40px}.shop_weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-1678px -1388px;width:40px;height:40px}.shop_weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-1637px -1429px;width:40px;height:40px}.shop_weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-1678px -1429px;width:40px;height:40px}.shop_weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-1637px -1470px;width:40px;height:40px}.slim_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-91px -1183px;width:90px;height:90px}.slim_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-182px -1183px;width:90px;height:90px}.slim_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-273px -1183px;width:90px;height:90px}.slim_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-364px -1183px;width:90px;height:90px}.slim_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-455px -1183px;width:90px;height:90px}.slim_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-546px -1183px;width:90px;height:90px}.slim_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-637px -1183px;width:90px;height:90px}.slim_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-728px -1183px;width:90px;height:90px}.weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-819px -1183px;width:90px;height:90px}.weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-910px -1183px;width:90px;height:90px}.weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-1001px -1183px;width:90px;height:90px}.weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-1092px -1183px;width:90px;height:90px}.weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-1183px -1183px;width:90px;height:90px}.weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-1364px 0;width:90px;height:90px}.weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-1364px -91px;width:90px;height:90px}.weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-1364px -182px;width:90px;height:90px}.broad_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1364px -273px;width:90px;height:90px}.broad_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1364px -364px;width:90px;height:90px}.broad_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1364px -455px;width:90px;height:90px}.broad_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1364px -546px;width:90px;height:90px}.broad_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1364px -637px;width:90px;height:90px}.broad_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1364px -728px;width:90px;height:90px}.broad_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1364px -819px;width:90px;height:90px}.broad_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1364px -910px;width:90px;height:90px}.broad_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1364px -1001px;width:90px;height:90px}.broad_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1364px -1092px;width:90px;height:90px}.broad_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1364px -1183px;width:90px;height:90px}.broad_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:0 -1274px;width:90px;height:90px}.broad_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-91px -1274px;width:90px;height:90px}.broad_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-182px -1274px;width:90px;height:90px}.broad_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-273px -1274px;width:90px;height:90px}.broad_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-364px -1274px;width:90px;height:90px}.broad_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-455px -1274px;width:90px;height:90px}.broad_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-546px -1274px;width:90px;height:90px}.broad_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-637px -1274px;width:90px;height:90px}.broad_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-728px -1274px;width:90px;height:90px}.broad_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-819px -1274px;width:90px;height:90px}.broad_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-910px -1274px;width:90px;height:90px}.shop_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-495px -285px;width:40px;height:40px}.shop_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-400px -273px;width:40px;height:40px}.shop_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-400px -314px;width:40px;height:40px}.shop_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-464px -364px;width:40px;height:40px}.shop_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-464px -405px;width:40px;height:40px}.shop_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-546px -455px;width:40px;height:40px}.shop_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-587px -455px;width:40px;height:40px}.shop_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-546px -496px;width:40px;height:40px}.shop_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-587px -496px;width:40px;height:40px}.shop_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-637px -546px;width:40px;height:40px}.shop_armor_special_0{background-image:url(spritesmith-main-3.png);background-position:-678px -546px;width:40px;height:40px}.shop_armor_special_1{background-image:url(spritesmith-main-3.png);background-position:-637px -587px;width:40px;height:40px}.shop_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1406px -1274px;width:40px;height:40px}.shop_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1365px -1315px;width:40px;height:40px}.shop_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1406px -1315px;width:40px;height:40px}.shop_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1456px -1365px;width:40px;height:40px}.shop_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1497px -1365px;width:40px;height:40px}.shop_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1456px -1406px;width:40px;height:40px}.shop_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1497px -1406px;width:40px;height:40px}.shop_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1547px -1456px;width:40px;height:40px}.shop_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1588px -1456px;width:40px;height:40px}.shop_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1547px -1497px;width:40px;height:40px}.shop_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1588px -1497px;width:40px;height:40px}.shop_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:0 -1547px;width:40px;height:40px}.slim_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1001px -1274px;width:90px;height:90px}.slim_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1092px -1274px;width:90px;height:90px}.slim_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1183px -1274px;width:90px;height:90px}.slim_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1274px -1274px;width:90px;height:90px}.slim_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1455px 0;width:90px;height:90px}.slim_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1455px -91px;width:90px;height:90px}.slim_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1455px -182px;width:90px;height:90px}.slim_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1455px -273px;width:90px;height:90px}.slim_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1455px -364px;width:90px;height:90px}.slim_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1455px -455px;width:90px;height:90px}.slim_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1455px -546px;width:90px;height:90px}.slim_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1455px -637px;width:90px;height:90px}.slim_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1455px -728px;width:90px;height:90px}.slim_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1455px -819px;width:90px;height:90px}.slim_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1455px -910px;width:90px;height:90px}.slim_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1455px -1001px;width:90px;height:90px}.slim_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1455px -1092px;width:90px;height:90px}.slim_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1455px -1183px;width:90px;height:90px}.slim_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1455px -1274px;width:90px;height:90px}.slim_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:0 -1365px;width:90px;height:90px}.slim_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-91px -1365px;width:90px;height:90px}.slim_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-182px -1365px;width:90px;height:90px}.broad_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-273px -1365px;width:90px;height:90px}.broad_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-364px -1365px;width:90px;height:90px}.shop_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1678px -1224px;width:40px;height:40px}.shop_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-1637px -1265px;width:40px;height:40px}.slim_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-455px -1365px;width:90px;height:90px}.slim_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-546px -1365px;width:90px;height:90px}.broad_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:0 -364px;width:93px;height:90px}.broad_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -182px;width:105px;height:90px}.broad_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-819px -1365px;width:90px;height:90px}.broad_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-910px -1365px;width:90px;height:90px}.broad_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1001px -1365px;width:90px;height:90px}.broad_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px 0;width:120px;height:90px}.broad_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-212px -182px;width:105px;height:90px}.broad_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1274px -1365px;width:90px;height:90px}.head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-188px -364px;width:93px;height:90px}.head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-242px -91px;width:105px;height:90px}.head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1546px -91px;width:90px;height:90px}.head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1546px -182px;width:90px;height:90px}.head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1546px -273px;width:90px;height:90px}.head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 -91px;width:120px;height:90px}.head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-106px -273px;width:105px;height:90px}.head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1546px -546px;width:90px;height:90px}.shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-94px -364px;width:93px;height:90px}.shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:0 -182px;width:105px;height:90px}.shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1546px -819px;width:90px;height:90px}.shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1546px -910px;width:90px;height:90px}.shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px -91px;width:105px;height:90px}.shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1546px -1092px;width:90px;height:90px}.shop_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-678px -587px;width:40px;height:40px}.shop_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-728px -637px;width:40px;height:40px}.shop_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-769px -637px;width:40px;height:40px}.shop_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-728px -678px;width:40px;height:40px}.shop_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-769px -678px;width:40px;height:40px}.shop_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-819px -728px;width:40px;height:40px}.shop_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-860px -728px;width:40px;height:40px}.shop_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-819px -769px;width:40px;height:40px}.shop_head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-860px -769px;width:40px;height:40px}.shop_head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-910px -819px;width:40px;height:40px}.shop_head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-951px -819px;width:40px;height:40px}.shop_head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-910px -860px;width:40px;height:40px}.shop_head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-951px -860px;width:40px;height:40px}.shop_head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-1001px -910px;width:40px;height:40px}.shop_head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1042px -910px;width:40px;height:40px}.shop_head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1001px -951px;width:40px;height:40px}.shop_shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-1042px -951px;width:40px;height:40px}.shop_shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1092px -1001px;width:40px;height:40px}.shop_shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1133px -1001px;width:40px;height:40px}.shop_shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1092px -1042px;width:40px;height:40px}.shop_shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1133px -1042px;width:40px;height:40px}.shop_shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1183px -1092px;width:40px;height:40px}.shop_weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-1224px -1092px;width:40px;height:40px}.shop_weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-1183px -1133px;width:40px;height:40px}.shop_weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1224px -1133px;width:40px;height:40px}.shop_weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1274px -1183px;width:40px;height:40px}.shop_weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1315px -1183px;width:40px;height:40px}.shop_weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-1274px -1224px;width:40px;height:40px}.shop_weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1315px -1224px;width:40px;height:40px}.shop_weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1365px -1274px;width:40px;height:40px}.slim_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-212px -273px;width:93px;height:90px}.slim_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:0 -273px;width:105px;height:90px}.slim_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1546px -1365px;width:90px;height:90px}.slim_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:0 -1456px;width:90px;height:90px}.slim_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-91px -1456px;width:90px;height:90px}.slim_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 0;width:120px;height:90px}.slim_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px -182px;width:105px;height:90px}.slim_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-364px -1456px;width:90px;height:90px}.weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-306px -273px;width:93px;height:90px}.weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-348px 0;width:105px;height:90px}.weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-637px -1456px;width:90px;height:90px}.weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-728px -1456px;width:90px;height:90px}.weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-819px -1456px;width:90px;height:90px}.weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px -91px;width:120px;height:90px}.weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-242px 0;width:105px;height:90px}.weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1092px -1456px;width:90px;height:90px}.broad_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1183px -1456px;width:90px;height:90px}.head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1274px -1456px;width:90px;height:90px}.shop_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-287px -1547px;width:40px;height:40px}.shop_head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-328px -1547px;width:40px;height:40px}.slim_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1365px -1456px;width:90px;height:90px}.back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1456px -1456px;width:90px;height:90px}.broad_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1637px 0;width:90px;height:90px}.head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1637px -91px;width:90px;height:90px}.shop_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-533px -1547px;width:40px;height:40px}.shop_back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-574px -1547px;width:40px;height:40px}.shop_head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-615px -1547px;width:40px;height:40px}.slim_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1637px -182px;width:90px;height:90px}.broad_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1637px -273px;width:90px;height:90px}.headAccessory_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1637px -364px;width:90px;height:90px}.shop_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-779px -1547px;width:40px;height:40px}.shop_headAccessory_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-820px -1547px;width:40px;height:40px}.slim_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1637px -455px;width:90px;height:90px}.back_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-1637px -546px;width:90px;height:90px}.headAccessory_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-1637px -637px;width:90px;height:90px}.shop_back_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-984px -1547px;width:40px;height:40px}.shop_headAccessory_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-1025px -1547px;width:40px;height:40px}.broad_armor_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1637px -728px;width:90px;height:90px}.head_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1637px -819px;width:90px;height:90px}.shop_armor_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1148px -1547px;width:40px;height:40px}.shop_head_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1189px -1547px;width:40px;height:40px}.slim_armor_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1637px -910px;width:90px;height:90px}.broad_armor_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-454px -97px;width:90px;height:96px}.head_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-454px 0;width:90px;height:96px}.shop_armor_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-1353px -1547px;width:40px;height:40px}.shop_head_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-1271px -1547px;width:40px;height:40px}.slim_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-739px -212px;width:90px;height:96px}.broad_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-637px -1425px;width:90px;height:90px}.head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1109px -91px;width:90px;height:90px}.shop_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -861px;width:40px;height:40px}.shop_head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -820px;width:40px;height:40px}.slim_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1109px -364px;width:90px;height:90px}.broad_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1109px -455px;width:90px;height:90px}.head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1109px -546px;width:90px;height:90px}.shop_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-377px -303px;width:40px;height:40px}.shop_head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1382px -1274px;width:40px;height:40px}.slim_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-728px -1425px;width:90px;height:90px}.broad_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1274px -1425px;width:90px;height:90px}.headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1365px -1425px;width:90px;height:90px}.shop_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1473px -1365px;width:40px;height:40px}.shop_headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-697px -1557px;width:40px;height:40px}.slim_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1001px -1425px;width:90px;height:90px}.back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:0 -788px;width:93px;height:90px}.broad_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -637px;width:93px;height:90px}.shop_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1514px -1365px;width:40px;height:40px}.shop_back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1423px -1274px;width:40px;height:40px}.slim_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -273px;width:93px;height:90px}.head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -819px;width:90px;height:90px}.shop_head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1564px -410px;width:40px;height:40px}.shop_weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1564px -451px;width:40px;height:40px}.weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -546px;width:90px;height:90px}.broad_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1382px -364px;width:90px;height:90px}.head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1382px -273px;width:90px;height:90px}.shop_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1564px -492px;width:40px;height:40px}.shop_head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1564px -533px;width:40px;height:40px}.slim_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1382px 0;width:90px;height:90px}.broad_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1274px -1243px;width:90px;height:90px}.head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1183px -1243px;width:90px;height:90px}.shop_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1564px -574px;width:40px;height:40px}.shop_head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1564px -615px;width:40px;height:40px}.slim_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-910px -1243px;width:90px;height:90px}.headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-637px -1243px;width:90px;height:90px}.shop_headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1564px -943px;width:40px;height:40px}.shop_weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1564px -984px;width:40px;height:40px}.weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-364px -1243px;width:90px;height:90px}.broad_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-273px -1243px;width:90px;height:90px}.eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-182px -1243px;width:90px;height:90px}.shop_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1564px -1025px;width:40px;height:40px}.shop_eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1564px -1066px;width:40px;height:40px}.slim_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1291px -910px;width:90px;height:90px}.back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1291px -819px;width:90px;height:90px}.broad_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1291px -728px;width:90px;height:90px}.shop_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1564px -1107px;width:40px;height:40px}.shop_back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1646px -451px;width:40px;height:40px}.slim_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1382px -910px;width:90px;height:90px}.head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1109px -273px;width:90px;height:90px}.shop_head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1646px -738px;width:40px;height:40px}.shop_weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-648px -530px;width:40px;height:40px}.weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1473px 0;width:90px;height:90px}.broad_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-739px -106px;width:90px;height:105px}.eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-648px 0;width:90px;height:105px}.shop_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1646px -820px;width:40px;height:40px}.shop_eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1646px -779px;width:40px;height:40px}.slim_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-637px -591px;width:90px;height:105px}.back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-536px -288px;width:90px;height:105px}.eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-739px 0;width:90px;height:105px}.shop_back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-1646px -697px;width:40px;height:40px}.shop_eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-1646px -656px;width:40px;height:40px}.broad_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -182px;width:93px;height:90px}.head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -546px;width:93px;height:90px}.shop_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-1646px -615px;width:40px;height:40px}.shop_head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-1646px -574px;width:40px;height:40px}.slim_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -455px;width:93px;height:90px}.broad_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-739px -309px;width:90px;height:90px}.head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-739px -400px;width:90px;height:90px}.shop_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1646px -533px;width:40px;height:40px}.shop_head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1646px -492px;width:40px;height:40px}.slim_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-94px -788px;width:90px;height:90px}.back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-536px -394px;width:105px;height:90px}.headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-830px -364px;width:93px;height:90px}.shop_back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-1646px -410px;width:40px;height:40px}.shop_headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-1646px -369px;width:40px;height:40px}.broad_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-549px -788px;width:90px;height:90px}.eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-640px -788px;width:90px;height:90px}.head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-731px -788px;width:90px;height:90px}.shop_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1646px -328px;width:40px;height:40px}.shop_eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1646px -287px;width:40px;height:40px}.shop_head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1605px -1189px;width:40px;height:40px}.shop_weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1605px -1148px;width:40px;height:40px}.slim_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-927px -273px;width:90px;height:90px}.weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-927px -364px;width:90px;height:90px}.eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-927px -455px;width:90px;height:90px}.headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-927px -546px;width:90px;height:90px}.head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-927px -637px;width:90px;height:90px}.shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-927px -728px;width:90px;height:90px}.shop_eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1605px -1107px;width:40px;height:40px}.shop_headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1605px -1066px;width:40px;height:40px}.shop_head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1605px -1025px;width:40px;height:40px}.shop_shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1605px -984px;width:40px;height:40px}.broad_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-364px -879px;width:90px;height:90px}.broad_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-455px -879px;width:90px;height:90px}.broad_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-546px -879px;width:90px;height:90px}.broad_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-637px -879px;width:90px;height:90px}.broad_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-728px -879px;width:90px;height:90px}.broad_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-819px -879px;width:90px;height:90px}.broad_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-910px -879px;width:90px;height:90px}.broad_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1018px 0;width:90px;height:90px}.headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1018px -91px;width:90px;height:90px}.headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1018px -182px;width:90px;height:90px}.headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1018px -273px;width:90px;height:90px}.headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1018px -364px;width:90px;height:90px}.headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1018px -455px;width:90px;height:90px}.headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1018px -546px;width:90px;height:90px}.headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1018px -637px;width:90px;height:90px}.headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1018px -728px;width:90px;height:90px}.head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1018px -819px;width:90px;height:90px}.head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -970px;width:90px;height:90px}.head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-91px -970px;width:90px;height:90px}.head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-182px -970px;width:90px;height:90px}.head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-273px -970px;width:90px;height:90px}.head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-364px -970px;width:90px;height:90px}.head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-455px -970px;width:90px;height:90px}.head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-546px -970px;width:90px;height:90px}.shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-637px -970px;width:90px;height:90px}.shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-728px -970px;width:90px;height:90px}.shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-819px -970px;width:90px;height:90px}.shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-910px -970px;width:90px;height:90px}.shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1001px -970px;width:90px;height:90px}.shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px 0;width:90px;height:90px}.shop_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -943px;width:40px;height:40px}.shop_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1605px -902px;width:40px;height:40px}.shop_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -779px;width:40px;height:40px}.shop_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -738px;width:40px;height:40px}.shop_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1605px -697px;width:40px;height:40px}.shop_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1605px -656px;width:40px;height:40px}.shop_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -615px;width:40px;height:40px}.shop_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -574px;width:40px;height:40px}.shop_headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -533px;width:40px;height:40px}.shop_headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1605px -492px;width:40px;height:40px}.shop_headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -451px;width:40px;height:40px}.shop_headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -410px;width:40px;height:40px}.shop_headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1605px -41px;width:40px;height:40px}.shop_headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1605px 0;width:40px;height:40px}.shop_headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1558px -1557px;width:40px;height:40px}.shop_headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1517px -1557px;width:40px;height:40px}.shop_head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1476px -1557px;width:40px;height:40px}.shop_head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1435px -1557px;width:40px;height:40px}.shop_head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1394px -1557px;width:40px;height:40px}.shop_head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1353px -1557px;width:40px;height:40px}.shop_head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-328px -1557px;width:40px;height:40px}.shop_head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-287px -1557px;width:40px;height:40px}.shop_head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-246px -1557px;width:40px;height:40px}.shop_head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-205px -1557px;width:40px;height:40px}.shop_shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-164px -1557px;width:40px;height:40px}.shop_shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-123px -1557px;width:40px;height:40px}.shop_shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-82px -1557px;width:40px;height:40px}.shop_shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-41px -1557px;width:40px;height:40px}.shop_shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:0 -1557px;width:40px;height:40px}.shop_shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1476px;width:40px;height:40px}.shop_weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1435px;width:40px;height:40px}.shop_weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -1394px;width:40px;height:40px}.shop_weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1353px;width:40px;height:40px}.shop_weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1312px;width:40px;height:40px}.shop_weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1271px;width:40px;height:40px}.shop_weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -1230px;width:40px;height:40px}.shop_weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1189px;width:40px;height:40px}.shop_weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1148px;width:40px;height:40px}.slim_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-273px -1152px;width:90px;height:90px}.slim_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-364px -1152px;width:90px;height:90px}.slim_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-455px -1152px;width:90px;height:90px}.slim_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -1152px;width:90px;height:90px}.slim_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-637px -1152px;width:90px;height:90px}.slim_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-728px -1152px;width:90px;height:90px}.slim_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-819px -1152px;width:90px;height:90px}.slim_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-910px -1152px;width:90px;height:90px}.weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1001px -1152px;width:90px;height:90px}.weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1092px -1152px;width:90px;height:90px}.weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1183px -1152px;width:90px;height:90px}.weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px 0;width:90px;height:90px}.weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1291px -91px;width:90px;height:90px}.weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1291px -182px;width:90px;height:90px}.weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -273px;width:90px;height:90px}.weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -364px;width:90px;height:90px}.body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -455px;width:90px;height:90px}.body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1291px -546px;width:90px;height:90px}.body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px 0;width:102px;height:105px}.body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -591px;width:90px;height:105px}.body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -591px;width:90px;height:105px}.body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-364px -591px;width:90px;height:105px}.broad_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -1001px;width:90px;height:90px}.broad_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1291px -1092px;width:90px;height:90px}.broad_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px 0;width:102px;height:105px}.broad_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-182px -591px;width:90px;height:105px}.broad_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-91px -591px;width:90px;height:105px}.broad_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:0 -591px;width:90px;height:105px}.broad_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-536px 0;width:111px;height:90px}.broad_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-336px -394px;width:111px;height:90px}.eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -394px;width:111px;height:90px}.eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-112px -394px;width:111px;height:90px}.head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1243px;width:90px;height:90px}.head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -1243px;width:90px;height:90px}.head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 -106px;width:102px;height:105px}.head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -485px;width:90px;height:105px}.head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -485px;width:90px;height:105px}.head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-364px -485px;width:90px;height:105px}.head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-424px -273px;width:111px;height:90px}.head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -182px;width:111px;height:90px}.Healer_Summer{background-image:url(spritesmith-main-4.png);background-position:-91px -485px;width:90px;height:105px}.Mage_Summer{background-image:url(spritesmith-main-4.png);background-position:-536px -182px;width:90px;height:105px}.SummerRogue14{background-image:url(spritesmith-main-4.png);background-position:-424px 0;width:111px;height:90px}.SummerWarrior14{background-image:url(spritesmith-main-4.png);background-position:-224px -303px;width:111px;height:90px}.shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px -455px;width:90px;height:90px}.shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 0;width:102px;height:105px}.shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-648px -424px;width:90px;height:105px}.shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-648px -318px;width:90px;height:105px}.shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -303px;width:111px;height:90px}.shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -182px;width:111px;height:90px}.shop_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -369px;width:40px;height:40px}.shop_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -328px;width:40px;height:40px}.shop_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -287px;width:40px;height:40px}.shop_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -246px;width:40px;height:40px}.shop_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -205px;width:40px;height:40px}.shop_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -164px;width:40px;height:40px}.shop_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -123px;width:40px;height:40px}.shop_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -82px;width:40px;height:40px}.shop_body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -41px;width:40px;height:40px}.shop_body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px 0;width:40px;height:40px}.shop_body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1517px -1516px;width:40px;height:40px}.shop_body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1476px -1516px;width:40px;height:40px}.shop_body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1435px -1516px;width:40px;height:40px}.shop_body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1394px -1516px;width:40px;height:40px}.shop_eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1353px -1516px;width:40px;height:40px}.shop_eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1312px -1516px;width:40px;height:40px}.shop_head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1271px -1516px;width:40px;height:40px}.shop_head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-448px -435px;width:40px;height:40px}.shop_head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-489px -394px;width:40px;height:40px}.shop_head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-448px -394px;width:40px;height:40px}.shop_head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-377px -344px;width:40px;height:40px}.shop_head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-336px -344px;width:40px;height:40px}.shop_head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-336px -303px;width:40px;height:40px}.shop_head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-230px -253px;width:40px;height:40px}.shop_shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-230px -212px;width:40px;height:40px}.shop_shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-689px -530px;width:40px;height:40px}.shop_shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-871px -728px;width:40px;height:40px}.shop_shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-830px -728px;width:40px;height:40px}.shop_shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-968px -819px;width:40px;height:40px}.shop_shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-927px -819px;width:40px;height:40px}.shop_weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1059px -910px;width:40px;height:40px}.shop_weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1018px -910px;width:40px;height:40px}.shop_weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1150px -1001px;width:40px;height:40px}.shop_weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -1001px;width:40px;height:40px}.shop_weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1241px -1092px;width:40px;height:40px}.shop_weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1092px;width:40px;height:40px}.shop_weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1332px -1183px;width:40px;height:40px}.shop_weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -1183px;width:40px;height:40px}.slim_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-364px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-455px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px -106px;width:102px;height:105px}.slim_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -485px;width:90px;height:105px}.slim_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-273px -485px;width:90px;height:105px}.slim_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-182px -485px;width:90px;height:105px}.slim_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -394px;width:111px;height:90px}.slim_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -91px;width:111px;height:90px}.weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1092px -1425px;width:90px;height:90px}.weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1183px -1425px;width:90px;height:90px}.weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px -106px;width:102px;height:105px}.weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -591px;width:90px;height:105px}.weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-648px -212px;width:90px;height:105px}.weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -106px;width:90px;height:105px}.weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-112px -303px;width:111px;height:90px}.weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-536px -91px;width:111px;height:90px}.broad_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-546px -1425px;width:90px;height:90px}.broad_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-273px -1425px;width:90px;height:90px}.broad_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-182px -1425px;width:90px;height:90px}.broad_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1425px;width:90px;height:90px}.broad_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -1425px;width:90px;height:90px}.broad_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-513px -697px;width:96px;height:90px}.broad_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1473px -1183px;width:90px;height:90px}.broad_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1473px -1092px;width:90px;height:90px}.head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -1001px;width:90px;height:90px}.head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-1473px -910px;width:90px;height:90px}.head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-1473px -819px;width:90px;height:90px}.head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -728px;width:90px;height:90px}.head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -637px;width:90px;height:90px}.head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1456px -1425px;width:90px;height:90px}.head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1473px -546px;width:90px;height:90px}.head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-610px -697px;width:96px;height:90px}.head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1473px -364px;width:90px;height:90px}.head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1473px -273px;width:90px;height:90px}.shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -697px;width:104px;height:90px}.shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -182px;width:90px;height:90px}.shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px -91px;width:90px;height:90px}.shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-707px -697px;width:96px;height:90px}.shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1365px -1334px;width:90px;height:90px}.shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1274px -1334px;width:90px;height:90px}.shop_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-489px -435px;width:40px;height:40px}.shop_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -1516px;width:40px;height:40px}.shop_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-41px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-82px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-123px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-164px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-205px -1516px;width:40px;height:40px}.shop_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-246px -1516px;width:40px;height:40px}.shop_head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-287px -1516px;width:40px;height:40px}.shop_head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-328px -1516px;width:40px;height:40px}.shop_head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-369px -1516px;width:40px;height:40px}.shop_head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-410px -1516px;width:40px;height:40px}.shop_head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-451px -1516px;width:40px;height:40px}.shop_head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-492px -1516px;width:40px;height:40px}.shop_head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-533px -1516px;width:40px;height:40px}.shop_head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-574px -1516px;width:40px;height:40px}.shop_head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-615px -1516px;width:40px;height:40px}.shop_head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-656px -1516px;width:40px;height:40px}.shop_shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:-697px -1516px;width:40px;height:40px}.shop_shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-738px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-779px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-820px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-861px -1516px;width:40px;height:40px}.shop_shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-902px -1516px;width:40px;height:40px}.shop_weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-943px -1516px;width:40px;height:40px}.shop_weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-984px -1516px;width:40px;height:40px}.shop_weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1025px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1066px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1107px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1148px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1189px -1516px;width:40px;height:40px}.shop_weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1230px -1516px;width:40px;height:40px}.slim_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1183px -1334px;width:90px;height:90px}.slim_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1092px -1334px;width:90px;height:90px}.slim_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1001px -1334px;width:90px;height:90px}.slim_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-910px -1334px;width:90px;height:90px}.slim_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -1334px;width:90px;height:90px}.slim_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-830px -91px;width:96px;height:90px}.slim_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-637px -1334px;width:90px;height:90px}.slim_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-546px -1334px;width:90px;height:90px}.weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-455px -1334px;width:90px;height:90px}.weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-364px -1334px;width:90px;height:90px}.weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-273px -1334px;width:90px;height:90px}.weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-182px -1334px;width:90px;height:90px}.weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-91px -1334px;width:90px;height:90px}.weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-416px -697px;width:96px;height:90px}.weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1382px -1183px;width:90px;height:90px}.weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1382px -1092px;width:90px;height:90px}.back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1382px -1001px;width:90px;height:90px}.back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1382px -728px;width:90px;height:90px}.body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1382px -637px;width:90px;height:90px}.body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1382px -182px;width:90px;height:90px}.body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1382px -91px;width:90px;height:90px}.eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1092px -1243px;width:90px;height:90px}.eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1001px -1243px;width:90px;height:90px}.shop_back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -656px;width:40px;height:40px}.shop_back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -697px;width:40px;height:40px}.shop_body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -738px;width:40px;height:40px}.shop_body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1564px -779px;width:40px;height:40px}.shop_body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -820px;width:40px;height:40px}.shop_eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -861px;width:40px;height:40px}.shop_eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -902px;width:40px;height:40px}.head_0{background-image:url(spritesmith-main-4.png);background-position:-546px -1243px;width:90px;height:90px}.customize-option.head_0{background-image:url(spritesmith-main-4.png);background-position:-571px -1258px;width:60px;height:60px}.head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-455px -1243px;width:90px;height:90px}.head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-91px -1243px;width:90px;height:90px}.head_healer_3{background-image:url(spritesmith-main-4.png);background-position:0 -1243px;width:90px;height:90px}.head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1291px -637px;width:90px;height:90px}.head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-182px -1152px;width:90px;height:90px}.head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-91px -1152px;width:90px;height:90px}.head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:0 -1152px;width:90px;height:90px}.head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1200px -1001px;width:90px;height:90px}.head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1200px -910px;width:90px;height:90px}.head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1200px -819px;width:90px;height:90px}.head_special_2{background-image:url(spritesmith-main-4.png);background-position:-1200px -728px;width:90px;height:90px}.head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-1200px -637px;width:90px;height:90px}.head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1200px -546px;width:90px;height:90px}.head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1200px -455px;width:90px;height:90px}.head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1200px -364px;width:90px;height:90px}.head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1200px -273px;width:90px;height:90px}.head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1200px -182px;width:90px;height:90px}.head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1200px -91px;width:90px;height:90px}.head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1200px 0;width:90px;height:90px}.head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1092px -1061px;width:90px;height:90px}.head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1001px -1061px;width:90px;height:90px}.head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-910px -1061px;width:90px;height:90px}.shop_head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-369px -1557px;width:40px;height:40px}.shop_head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-410px -1557px;width:40px;height:40px}.shop_head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-451px -1557px;width:40px;height:40px}.shop_head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-492px -1557px;width:40px;height:40px}.shop_head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-533px -1557px;width:40px;height:40px}.shop_head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-574px -1557px;width:40px;height:40px}.shop_head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-615px -1557px;width:40px;height:40px}.shop_head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-656px -1557px;width:40px;height:40px}.shop_head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -861px;width:40px;height:40px}.shop_head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-738px -1557px;width:40px;height:40px}.shop_head_special_0{background-image:url(spritesmith-main-4.png);background-position:-779px -1557px;width:40px;height:40px}.shop_head_special_1{background-image:url(spritesmith-main-4.png);background-position:-820px -1557px;width:40px;height:40px}.shop_head_special_2{background-image:url(spritesmith-main-4.png);background-position:-861px -1557px;width:40px;height:40px}.shop_head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-902px -1557px;width:40px;height:40px}.shop_head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-943px -1557px;width:40px;height:40px}.shop_head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-984px -1557px;width:40px;height:40px}.shop_head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1025px -1557px;width:40px;height:40px}.shop_head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1066px -1557px;width:40px;height:40px}.shop_head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1107px -1557px;width:40px;height:40px}.shop_head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1148px -1557px;width:40px;height:40px}.shop_head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1189px -1557px;width:40px;height:40px}.shop_head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1230px -1557px;width:40px;height:40px}.shop_head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1271px -1557px;width:40px;height:40px}.shop_head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1312px -1557px;width:40px;height:40px}.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-819px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-844px -1076px;width:60px;height:60px}.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-728px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-753px -1076px;width:60px;height:60px}.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-637px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-662px -1076px;width:60px;height:60px}.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-546px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-571px -1076px;width:60px;height:60px}.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-455px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-480px -1076px;width:60px;height:60px}.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-364px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-389px -1076px;width:60px;height:60px}.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-273px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-298px -1076px;width:60px;height:60px}.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-182px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-207px -1076px;width:60px;height:60px}.shop_headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -82px;width:40px;height:40px}.shop_headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -123px;width:40px;height:40px}.shop_headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -164px;width:40px;height:40px}.shop_headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -205px;width:40px;height:40px}.shop_headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -246px;width:40px;height:40px}.shop_headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -287px;width:40px;height:40px}.shop_headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -328px;width:40px;height:40px}.shop_headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -369px;width:40px;height:40px}.shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-91px -1061px;width:90px;height:90px}.shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:0 -1061px;width:90px;height:90px}.shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1109px -910px;width:90px;height:90px}.shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1109px -819px;width:90px;height:90px}.shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-1109px -728px;width:90px;height:90px}.shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-1109px -637px;width:90px;height:90px}.shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-105px -697px;width:103px;height:90px}.shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-209px -697px;width:103px;height:90px}.shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-115px -212px;width:114px;height:90px}.shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-830px 0;width:96px;height:90px}.shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-309px 0;width:114px;height:90px}.shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:0 -212px;width:114px;height:90px}.shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-1109px -182px;width:90px;height:90px}.shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-309px -91px;width:111px;height:90px}.shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-273px -879px;width:90px;height:90px}.shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-182px -879px;width:90px;height:90px}.shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-91px -879px;width:90px;height:90px}.shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:0 -879px;width:90px;height:90px}.shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-927px -182px;width:90px;height:90px}.shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-927px -91px;width:90px;height:90px}.shop_shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1605px -1230px;width:40px;height:40px}.shop_shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1605px -1271px;width:40px;height:40px}.shop_shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1605px -1312px;width:40px;height:40px}.shop_shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1605px -1353px;width:40px;height:40px}.shop_shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-1605px -1394px;width:40px;height:40px}.shop_shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-1605px -1435px;width:40px;height:40px}.shop_shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1605px -1476px;width:40px;height:40px}.shop_shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1605px -1517px;width:40px;height:40px}.shop_shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:0 -1598px;width:40px;height:40px}.shop_shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-41px -1598px;width:40px;height:40px}.shop_shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-82px -1598px;width:40px;height:40px}.shop_shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-123px -1598px;width:40px;height:40px}.shop_shield_special_0{background-image:url(spritesmith-main-4.png);background-position:-164px -1598px;width:40px;height:40px}.shop_shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-205px -1598px;width:40px;height:40px}.shop_shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-246px -1598px;width:40px;height:40px}.shop_shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-287px -1598px;width:40px;height:40px}.shop_shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-328px -1598px;width:40px;height:40px}.shop_shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-369px -1598px;width:40px;height:40px}.shop_shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-410px -1598px;width:40px;height:40px}.shop_shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-451px -1598px;width:40px;height:40px}.shop_shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-492px -1598px;width:40px;height:40px}.shop_weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-533px -1598px;width:40px;height:40px}.shop_weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-574px -1598px;width:40px;height:40px}.shop_weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-615px -1598px;width:40px;height:40px}.shop_weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-656px -1598px;width:40px;height:40px}.shop_weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-697px -1598px;width:40px;height:40px}.shop_weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-738px -1598px;width:40px;height:40px}.shop_weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-779px -1598px;width:40px;height:40px}.shop_weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-820px -1598px;width:40px;height:40px}.shop_weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-861px -1598px;width:40px;height:40px}.shop_weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-902px -1598px;width:40px;height:40px}.shop_weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-943px -1598px;width:40px;height:40px}.shop_weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-984px -1598px;width:40px;height:40px}.shop_weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1025px -1598px;width:40px;height:40px}.shop_weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1066px -1598px;width:40px;height:40px}.shop_weapon_special_0{background-image:url(spritesmith-main-4.png);background-position:-1107px -1598px;width:40px;height:40px}.shop_weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-1148px -1598px;width:40px;height:40px}.shop_weapon_special_2{background-image:url(spritesmith-main-4.png);background-position:-1189px -1598px;width:40px;height:40px}.shop_weapon_special_3{background-image:url(spritesmith-main-4.png);background-position:-1230px -1598px;width:40px;height:40px}.shop_weapon_special_critical{background-image:url(spritesmith-main-4.png);background-position:-1271px -1598px;width:40px;height:40px}.shop_weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-4.png);background-position:-1312px -1598px;width:40px;height:40px}.shop_weapon_warrior_0{background-image:url(spritesmith-main-4.png);background-position:-1353px -1598px;width:40px;height:40px}.shop_weapon_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1394px -1598px;width:40px;height:40px}.shop_weapon_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1435px -1598px;width:40px;height:40px}.shop_weapon_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1476px -1598px;width:40px;height:40px}.shop_weapon_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1517px -1598px;width:40px;height:40px}.shop_weapon_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1558px -1598px;width:40px;height:40px}.shop_weapon_warrior_6{background-image:url(spritesmith-main-4.png);background-position:-1599px -1598px;width:40px;height:40px}.shop_weapon_wizard_0{background-image:url(spritesmith-main-4.png);background-position:-1646px 0;width:40px;height:40px}.shop_weapon_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -41px;width:40px;height:40px}.shop_weapon_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -82px;width:40px;height:40px}.shop_weapon_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -123px;width:40px;height:40px}.shop_weapon_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -164px;width:40px;height:40px}.shop_weapon_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -205px;width:40px;height:40px}.shop_weapon_wizard_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -246px;width:40px;height:40px}.weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-927px 0;width:90px;height:90px}.weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-822px -788px;width:90px;height:90px}.weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-458px -788px;width:90px;height:90px}.weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-367px -788px;width:90px;height:90px}.weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-185px -788px;width:90px;height:90px}.weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-739px -582px;width:90px;height:90px}.weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-739px -491px;width:90px;height:90px}.weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-819px -1425px;width:90px;height:90px}.weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1473px -1274px;width:90px;height:90px}.weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-728px -1334px;width:90px;height:90px}.weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:0 -1334px;width:90px;height:90px}.weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-276px -788px;width:90px;height:90px}.weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-910px -1425px;width:90px;height:90px}.weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1473px -455px;width:90px;height:90px}.weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-313px -697px;width:102px;height:90px}.weapon_special_2{background-image:url(spritesmith-main-5.png);background-position:-1621px -910px;width:90px;height:90px}.weapon_special_3{background-image:url(spritesmith-main-5.png);background-position:-1621px -1001px;width:90px;height:90px}.weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-5.png);background-position:-1621px -1092px;width:90px;height:90px}.weapon_warrior_0{background-image:url(spritesmith-main-5.png);background-position:-1621px -1183px;width:90px;height:90px}.weapon_warrior_1{background-image:url(spritesmith-main-5.png);background-position:-1621px -1365px;width:90px;height:90px}.weapon_warrior_2{background-image:url(spritesmith-main-5.png);background-position:-1621px -1456px;width:90px;height:90px}.weapon_warrior_3{background-image:url(spritesmith-main-5.png);background-position:0 -1562px;width:90px;height:90px}.weapon_warrior_4{background-image:url(spritesmith-main-5.png);background-position:-91px -1562px;width:90px;height:90px}.weapon_warrior_5{background-image:url(spritesmith-main-5.png);background-position:-182px -1562px;width:90px;height:90px}.weapon_warrior_6{background-image:url(spritesmith-main-5.png);background-position:-1621px 0;width:90px;height:90px}.weapon_wizard_0{background-image:url(spritesmith-main-5.png);background-position:-1621px -273px;width:90px;height:90px}.weapon_wizard_1{background-image:url(spritesmith-main-5.png);background-position:-1621px -364px;width:90px;height:90px}.weapon_wizard_2{background-image:url(spritesmith-main-5.png);background-position:-1621px -455px;width:90px;height:90px}.weapon_wizard_3{background-image:url(spritesmith-main-5.png);background-position:-1621px -546px;width:90px;height:90px}.weapon_wizard_4{background-image:url(spritesmith-main-5.png);background-position:-1621px -637px;width:90px;height:90px}.weapon_wizard_5{background-image:url(spritesmith-main-5.png);background-position:-1621px -728px;width:90px;height:90px}.weapon_wizard_6{background-image:url(spritesmith-main-5.png);background-position:-1621px -819px;width:90px;height:90px}.GrimReaper{background-image:url(spritesmith-main-5.png);background-position:-1536px -194px;width:57px;height:66px}.Pet_Currency_Gem{background-image:url(spritesmith-main-5.png);background-position:-1761px -1439px;width:45px;height:39px}.Pet_Currency_Gem1x{background-image:url(spritesmith-main-5.png);background-position:-1792px -1725px;width:15px;height:13px}.Pet_Currency_Gem2x{background-image:url(spritesmith-main-5.png);background-position:-1585px -1074px;width:30px;height:26px}.PixelPaw-Gold{background-image:url(spritesmith-main-5.png);background-position:-1536px -970px;width:51px;height:51px}.PixelPaw{background-image:url(spritesmith-main-5.png);background-position:-1536px -918px;width:51px;height:51px}.PixelPaw002{background-image:url(spritesmith-main-5.png);background-position:-1536px -866px;width:51px;height:51px}.avatar_floral_healer{background-image:url(spritesmith-main-5.png);background-position:-1373px -1390px;width:99px;height:99px}.avatar_floral_rogue{background-image:url(spritesmith-main-5.png);background-position:-1273px -1390px;width:99px;height:99px}.avatar_floral_warrior{background-image:url(spritesmith-main-5.png);background-position:-1173px -1390px;width:99px;height:99px}.avatar_floral_wizard{background-image:url(spritesmith-main-5.png);background-position:-1073px -1390px;width:99px;height:99px}.inventory_present{background-image:url(spritesmith-main-5.png);background-position:-1712px -208px;width:48px;height:51px}.inventory_present_01{background-image:url(spritesmith-main-5.png);background-position:-1712px -780px;width:48px;height:51px}.inventory_present_02{background-image:url(spritesmith-main-5.png);background-position:-1712px -1040px;width:48px;height:51px}.inventory_present_03{background-image:url(spritesmith-main-5.png);background-position:-1712px -1196px;width:48px;height:51px}.inventory_present_04{background-image:url(spritesmith-main-5.png);background-position:-1712px -1248px;width:48px;height:51px}.inventory_present_05{background-image:url(spritesmith-main-5.png);background-position:-1712px -1300px;width:48px;height:51px}.inventory_present_06{background-image:url(spritesmith-main-5.png);background-position:-1712px -1352px;width:48px;height:51px}.inventory_present_07{background-image:url(spritesmith-main-5.png);background-position:-1712px -1404px;width:48px;height:51px}.inventory_present_08{background-image:url(spritesmith-main-5.png);background-position:-1712px -1456px;width:48px;height:51px}.inventory_present_09{background-image:url(spritesmith-main-5.png);background-position:-1712px -1560px;width:48px;height:51px}.inventory_present_10{background-image:url(spritesmith-main-5.png);background-position:-1712px -1612px;width:48px;height:51px}.inventory_present_11{background-image:url(spritesmith-main-5.png);background-position:0 -1705px;width:48px;height:51px}.inventory_present_12{background-image:url(spritesmith-main-5.png);background-position:-1712px -1144px;width:48px;height:51px}.inventory_quest_scroll{background-image:url(spritesmith-main-5.png);background-position:-1712px -1092px;width:48px;height:51px}.inventory_quest_scroll_locked{background-image:url(spritesmith-main-5.png);background-position:-1712px -988px;width:48px;height:51px}.inventory_special_fortify{background-image:url(spritesmith-main-5.png);background-position:-1536px -591px;width:57px;height:54px}.inventory_special_greeting{background-image:url(spritesmith-main-5.png);background-position:-1536px -536px;width:57px;height:54px}.inventory_special_nye{background-image:url(spritesmith-main-5.png);background-position:-1536px -316px;width:57px;height:54px}.inventory_special_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1712px -1664px;width:40px;height:40px}.inventory_special_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1536px -371px;width:57px;height:54px}.inventory_special_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1536px -811px;width:57px;height:54px}.inventory_special_snowball{background-image:url(spritesmith-main-5.png);background-position:-1536px -756px;width:57px;height:54px}.inventory_special_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1536px -701px;width:57px;height:54px}.inventory_special_thankyou{background-image:url(spritesmith-main-5.png);background-position:-1536px -646px;width:57px;height:54px}.inventory_special_trinket{background-image:url(spritesmith-main-5.png);background-position:-1712px -468px;width:48px;height:51px}.inventory_special_valentine{background-image:url(spritesmith-main-5.png);background-position:-1536px -261px;width:57px;height:54px}.knockout{background-image:url(spritesmith-main-5.png);background-position:-364px -1562px;width:120px;height:47px}.pet_key{background-image:url(spritesmith-main-5.png);background-position:-1536px -481px;width:57px;height:54px}.rebirth_orb{background-image:url(spritesmith-main-5.png);background-position:-1536px -426px;width:57px;height:54px}.seafoam_star{background-image:url(spritesmith-main-5.png);background-position:-273px -1562px;width:90px;height:90px}.shop_armoire{background-image:url(spritesmith-main-5.png);background-position:-1761px -1561px;width:40px;height:40px}.snowman{background-image:url(spritesmith-main-5.png);background-position:-1621px -91px;width:90px;height:90px}.spookman{background-image:url(spritesmith-main-5.png);background-position:-1621px -182px;width:90px;height:90px}.zzz{background-image:url(spritesmith-main-5.png);background-position:-1761px -1520px;width:40px;height:40px}.zzz_light{background-image:url(spritesmith-main-5.png);background-position:-1761px -1479px;width:40px;height:40px}.npc_alex{background-image:url(spritesmith-main-5.png);background-position:0 -1251px;width:162px;height:138px}.npc_bailey{background-image:url(spritesmith-main-5.png);background-position:-1536px -121px;width:60px;height:72px}.npc_daniel{background-image:url(spritesmith-main-5.png);background-position:-163px -1251px;width:135px;height:123px}.npc_justin{background-image:url(spritesmith-main-5.png);background-position:-1536px 0;width:84px;height:120px}.npc_justin_head{background-image:url(spritesmith-main-5.png);background-position:-1284px -1070px;width:36px;height:39px}.npc_matt{background-image:url(spritesmith-main-5.png);background-position:-1322px -954px;width:195px;height:138px}.npc_timetravelers{background-image:url(spritesmith-main-5.png);background-position:-1322px -815px;width:195px;height:138px}.npc_timetravelers_active{background-image:url(spritesmith-main-5.png);background-position:-1322px -676px;width:195px;height:138px}.npc_tyler{background-image:url(spritesmith-main-5.png);background-position:-1621px -1274px;width:90px;height:90px}.seasonalshop_closed{background-image:url(spritesmith-main-5.png);background-position:-1121px -1070px;width:162px;height:138px}.inventory_quest_scroll_atom1{background-image:url(spritesmith-main-5.png);background-position:-1372px -1653px;width:48px;height:51px}.inventory_quest_scroll_atom1_locked{background-image:url(spritesmith-main-5.png);background-position:-1323px -1653px;width:48px;height:51px}.inventory_quest_scroll_atom2{background-image:url(spritesmith-main-5.png);background-position:-1274px -1653px;width:48px;height:51px}.inventory_quest_scroll_atom2_locked{background-image:url(spritesmith-main-5.png);background-position:-1225px -1653px;width:48px;height:51px}.inventory_quest_scroll_atom3{background-image:url(spritesmith-main-5.png);background-position:-1176px -1653px;width:48px;height:51px}.inventory_quest_scroll_atom3_locked{background-image:url(spritesmith-main-5.png);background-position:-1127px -1653px;width:48px;height:51px}.inventory_quest_scroll_basilist{background-image:url(spritesmith-main-5.png);background-position:-1078px -1653px;width:48px;height:51px}.inventory_quest_scroll_bunny{background-image:url(spritesmith-main-5.png);background-position:-980px -1653px;width:48px;height:51px}.inventory_quest_scroll_cheetah{background-image:url(spritesmith-main-5.png);background-position:-931px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-882px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-833px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2_locked{background-image:url(spritesmith-main-5.png);background-position:-784px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-686px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3_locked{background-image:url(spritesmith-main-5.png);background-position:-637px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-588px -1653px;width:48px;height:51px}.inventory_quest_scroll_egg{background-image:url(spritesmith-main-5.png);background-position:-539px -1653px;width:48px;height:51px}.inventory_quest_scroll_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-441px -1653px;width:48px;height:51px}.inventory_quest_scroll_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:-392px -1653px;width:48px;height:51px}.inventory_quest_scroll_frog{background-image:url(spritesmith-main-5.png);background-position:-343px -1653px;width:48px;height:51px}.inventory_quest_scroll_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-294px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-245px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1_locked{background-image:url(spritesmith-main-5.png);background-position:-196px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-147px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2_locked{background-image:url(spritesmith-main-5.png);background-position:-98px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:-49px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3_locked{background-image:url(spritesmith-main-5.png);background-position:0 -1653px;width:48px;height:51px}.inventory_quest_scroll_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1536px -1022px;width:48px;height:51px}.inventory_quest_scroll_harpy{background-image:url(spritesmith-main-5.png);background-position:-1712px -1508px;width:48px;height:51px}.inventory_quest_scroll_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1712px 0;width:48px;height:51px}.inventory_quest_scroll_horse{background-image:url(spritesmith-main-5.png);background-position:-1712px -52px;width:48px;height:51px}.inventory_quest_scroll_kraken{background-image:url(spritesmith-main-5.png);background-position:-1712px -104px;width:48px;height:51px}.inventory_quest_scroll_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-1712px -156px;width:48px;height:51px}.inventory_quest_scroll_moonstone1_locked{background-image:url(spritesmith-main-5.png);background-position:-1712px -260px;width:48px;height:51px}.inventory_quest_scroll_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-1712px -312px;width:48px;height:51px}.inventory_quest_scroll_moonstone2_locked{background-image:url(spritesmith-main-5.png);background-position:-1712px -364px;width:48px;height:51px}.inventory_quest_scroll_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-1712px -416px;width:48px;height:51px}.inventory_quest_scroll_moonstone3_locked{background-image:url(spritesmith-main-5.png);background-position:-1712px -520px;width:48px;height:51px}.inventory_quest_scroll_octopus{background-image:url(spritesmith-main-5.png);background-position:-1712px -572px;width:48px;height:51px}.inventory_quest_scroll_owl{background-image:url(spritesmith-main-5.png);background-position:-1712px -624px;width:48px;height:51px}.inventory_quest_scroll_penguin{background-image:url(spritesmith-main-5.png);background-position:-1712px -676px;width:48px;height:51px}.inventory_quest_scroll_rat{background-image:url(spritesmith-main-5.png);background-position:-1712px -728px;width:48px;height:51px}.inventory_quest_scroll_rock{background-image:url(spritesmith-main-5.png);background-position:-1712px -832px;width:48px;height:51px}.inventory_quest_scroll_rooster{background-image:url(spritesmith-main-5.png);background-position:-1712px -884px;width:48px;height:51px}.inventory_quest_scroll_sheep{background-image:url(spritesmith-main-5.png);background-position:-1712px -936px;width:48px;height:51px}.inventory_quest_scroll_slime{background-image:url(spritesmith-main-5.png);background-position:-1536px -1074px;width:48px;height:51px}.inventory_quest_scroll_spider{background-image:url(spritesmith-main-5.png);background-position:-1536px -1126px;width:48px;height:51px}.inventory_quest_scroll_trex{background-image:url(spritesmith-main-5.png);background-position:-1536px -1178px;width:48px;height:51px}.inventory_quest_scroll_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-1536px -1230px;width:48px;height:51px}.inventory_quest_scroll_vice1{background-image:url(spritesmith-main-5.png);background-position:-1536px -1282px;width:48px;height:51px}.inventory_quest_scroll_vice1_locked{background-image:url(spritesmith-main-5.png);background-position:-1536px -1334px;width:48px;height:51px}.inventory_quest_scroll_vice2{background-image:url(spritesmith-main-5.png);background-position:-1536px -1386px;width:48px;height:51px}.inventory_quest_scroll_vice2_locked{background-image:url(spritesmith-main-5.png);background-position:-1536px -1438px;width:48px;height:51px}.inventory_quest_scroll_vice3{background-image:url(spritesmith-main-5.png);background-position:-1465px -1251px;width:48px;height:51px}.inventory_quest_scroll_vice3_locked{background-image:url(spritesmith-main-5.png);background-position:-1465px -1303px;width:48px;height:51px}.inventory_quest_scroll_whale{background-image:url(spritesmith-main-5.png);background-position:-1473px -1390px;width:48px;height:51px}.quest_TEMPLATE_FOR_MISSING_IMAGE{background-image:url(spritesmith-main-5.png);background-position:-666px -1522px;width:221px;height:39px}.quest_atom1{background-image:url(spritesmith-main-5.png);background-position:-719px -1070px;width:250px;height:150px}.quest_atom2{background-image:url(spritesmith-main-5.png);background-position:-1322px -537px;width:207px;height:138px}.quest_atom3{background-image:url(spritesmith-main-5.png);background-position:0 -1070px;width:216px;height:180px}.quest_basilist{background-image:url(spritesmith-main-5.png);background-position:-1322px -1093px;width:189px;height:141px}.quest_bunny{background-image:url(spritesmith-main-5.png);background-position:-1100px -618px;width:210px;height:186px}.quest_cheetah{background-image:url(spritesmith-main-5.png);background-position:-440px -452px;width:219px;height:219px}.quest_dilatory{background-image:url(spritesmith-main-5.png);background-position:-220px -452px;width:219px;height:219px}.quest_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-1100px -845px;width:221px;height:39px}.quest_dilatoryDistress1_blueFins{background-image:url(spritesmith-main-5.png);background-position:-1421px -1653px;width:51px;height:48px}.quest_dilatoryDistress1_fireCoral{background-image:url(spritesmith-main-5.png);background-position:-490px -1653px;width:48px;height:51px}.quest_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-970px -1070px;width:150px;height:150px}.quest_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-440px 0;width:219px;height:219px}.quest_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-880px -672px;width:219px;height:219px}.quest_egg{background-image:url(spritesmith-main-5.png);background-position:-222px -1522px;width:221px;height:39px}.quest_egg_plainEgg{background-image:url(spritesmith-main-5.png);background-position:-735px -1653px;width:48px;height:51px}.quest_evilsanta{background-image:url(spritesmith-main-5.png);background-position:0 -1390px;width:118px;height:131px}.quest_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:0 -232px;width:219px;height:219px}.quest_frog{background-image:url(spritesmith-main-5.png);background-position:-1100px 0;width:221px;height:213px}.quest_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-220px -232px;width:219px;height:219px}.quest_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-444px -1522px;width:221px;height:39px}.quest_goldenknight1_testimony{background-image:url(spritesmith-main-5.png);background-position:-1029px -1653px;width:48px;height:51px}.quest_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-468px -1070px;width:250px;height:150px}.quest_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:0 0;width:219px;height:231px}.quest_gryphon{background-image:url(spritesmith-main-5.png);background-position:-223px -892px;width:216px;height:177px}.quest_harpy{background-image:url(spritesmith-main-5.png);background-position:-660px -220px;width:219px;height:219px}.quest_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1100px -431px;width:219px;height:186px}.quest_horse{background-image:url(spritesmith-main-5.png);background-position:0 -452px;width:219px;height:219px}.quest_kraken{background-image:url(spritesmith-main-5.png);background-position:-440px -892px;width:216px;height:177px}.quest_moonstone1{background-image:url(spritesmith-main-5.png);background-position:0 -1522px;width:221px;height:39px}.quest_moonstone1_moonstone{background-image:url(spritesmith-main-5.png);background-position:-1761px -1725px;width:30px;height:30px}.quest_moonstone2{background-image:url(spritesmith-main-5.png);background-position:0 -672px;width:219px;height:219px}.quest_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-220px 0;width:219px;height:219px}.quest_octopus{background-image:url(spritesmith-main-5.png);background-position:0 -892px;width:222px;height:177px}.quest_owl{background-image:url(spritesmith-main-5.png);background-position:-660px -672px;width:219px;height:219px}.quest_penguin{background-image:url(spritesmith-main-5.png);background-position:-1322px -353px;width:190px;height:183px}.quest_rat{background-image:url(spritesmith-main-5.png);background-position:-660px 0;width:219px;height:219px}.quest_rock{background-image:url(spritesmith-main-5.png);background-position:-1100px -214px;width:216px;height:216px}.quest_rooster{background-image:url(spritesmith-main-5.png);background-position:-1322px 0;width:213px;height:174px}.quest_sheep{background-image:url(spritesmith-main-5.png);background-position:-440px -672px;width:219px;height:219px}.quest_slime{background-image:url(spritesmith-main-5.png);background-position:-220px -672px;width:219px;height:219px}.quest_spider{background-image:url(spritesmith-main-5.png);background-position:-217px -1070px;width:250px;height:150px}.quest_stressbeast{background-image:url(spritesmith-main-5.png);background-position:-880px -440px;width:219px;height:219px}.quest_stressbeast_bailey{background-image:url(spritesmith-main-5.png);background-position:-880px -220px;width:219px;height:219px}.quest_stressbeast_guide{background-image:url(spritesmith-main-5.png);background-position:-880px 0;width:219px;height:219px}.quest_stressbeast_stables{background-image:url(spritesmith-main-5.png);background-position:-660px -452px;width:219px;height:219px}.quest_trex{background-image:url(spritesmith-main-5.png);background-position:-1322px -175px;width:204px;height:177px}.quest_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-657px -892px;width:216px;height:177px}.quest_vice1{background-image:url(spritesmith-main-5.png);background-position:-1091px -892px;width:216px;height:177px}.quest_vice2{background-image:url(spritesmith-main-5.png);background-position:-1100px -805px;width:221px;height:39px}.quest_vice2_lightCrystal{background-image:url(spritesmith-main-5.png);background-position:-485px -1562px;width:40px;height:40px}.quest_vice3{background-image:url(spritesmith-main-5.png);background-position:-874px -892px;width:216px;height:177px}.quest_whale{background-image:url(spritesmith-main-5.png);background-position:-440px -232px;width:219px;height:219px}.shop_copper{background-image:url(spritesmith-main-5.png);background-position:-1585px -1149px;width:32px;height:22px}.shop_eyes{background-image:url(spritesmith-main-5.png);background-position:-1473px -1442px;width:40px;height:40px}.shop_gold{background-image:url(spritesmith-main-5.png);background-position:-1585px -1101px;width:32px;height:22px}.shop_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1761px -1684px;width:40px;height:40px}.shop_potion{background-image:url(spritesmith-main-5.png);background-position:-1761px -1643px;width:40px;height:40px}.shop_reroll{background-image:url(spritesmith-main-5.png);background-position:-1761px -1602px;width:40px;height:40px}.shop_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1588px -866px;width:32px;height:32px}.shop_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1588px -918px;width:32px;height:32px}.shop_silver{background-image:url(spritesmith-main-5.png);background-position:-1585px -1126px;width:32px;height:22px}.shop_snowball{background-image:url(spritesmith-main-5.png);background-position:-1588px -970px;width:32px;height:32px}.shop_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1585px -1022px;width:32px;height:32px}.Pet_Egg_BearCub{background-image:url(spritesmith-main-5.png);background-position:-98px -1705px;width:48px;height:51px}.Pet_Egg_Bunny{background-image:url(spritesmith-main-5.png);background-position:-147px -1705px;width:48px;height:51px}.Pet_Egg_Cactus{background-image:url(spritesmith-main-5.png);background-position:-196px -1705px;width:48px;height:51px}.Pet_Egg_Cheetah{background-image:url(spritesmith-main-5.png);background-position:-245px -1705px;width:48px;height:51px}.Pet_Egg_Cuttlefish{background-image:url(spritesmith-main-5.png);background-position:-294px -1705px;width:48px;height:51px}.Pet_Egg_Deer{background-image:url(spritesmith-main-5.png);background-position:-343px -1705px;width:48px;height:51px}.Pet_Egg_Dragon{background-image:url(spritesmith-main-5.png);background-position:-392px -1705px;width:48px;height:51px}.Pet_Egg_Egg{background-image:url(spritesmith-main-5.png);background-position:-441px -1705px;width:48px;height:51px}.Pet_Egg_FlyingPig{background-image:url(spritesmith-main-5.png);background-position:-490px -1705px;width:48px;height:51px}.Pet_Egg_Fox{background-image:url(spritesmith-main-5.png);background-position:-539px -1705px;width:48px;height:51px}.Pet_Egg_Frog{background-image:url(spritesmith-main-5.png);background-position:-588px -1705px;width:48px;height:51px}.Pet_Egg_Gryphon{background-image:url(spritesmith-main-5.png);background-position:-637px -1705px;width:48px;height:51px}.Pet_Egg_Hedgehog{background-image:url(spritesmith-main-5.png);background-position:-686px -1705px;width:48px;height:51px}.Pet_Egg_Horse{background-image:url(spritesmith-main-5.png);background-position:-735px -1705px;width:48px;height:51px}.Pet_Egg_LionCub{background-image:url(spritesmith-main-5.png);background-position:-784px -1705px;width:48px;height:51px}.Pet_Egg_Octopus{background-image:url(spritesmith-main-5.png);background-position:-833px -1705px;width:48px;height:51px}.Pet_Egg_Owl{background-image:url(spritesmith-main-5.png);background-position:-882px -1705px;width:48px;height:51px}.Pet_Egg_PandaCub{background-image:url(spritesmith-main-5.png);background-position:-931px -1705px;width:48px;height:51px}.Pet_Egg_Parrot{background-image:url(spritesmith-main-5.png);background-position:-980px -1705px;width:48px;height:51px}.Pet_Egg_Penguin{background-image:url(spritesmith-main-5.png);background-position:-1029px -1705px;width:48px;height:51px}.Pet_Egg_PolarBear{background-image:url(spritesmith-main-5.png);background-position:-1078px -1705px;width:48px;height:51px}.Pet_Egg_Rat{background-image:url(spritesmith-main-5.png);background-position:-1127px -1705px;width:48px;height:51px}.Pet_Egg_Rock{background-image:url(spritesmith-main-5.png);background-position:-1176px -1705px;width:48px;height:51px}.Pet_Egg_Rooster{background-image:url(spritesmith-main-5.png);background-position:-1225px -1705px;width:48px;height:51px}.Pet_Egg_Seahorse{background-image:url(spritesmith-main-5.png);background-position:-1274px -1705px;width:48px;height:51px}.Pet_Egg_Sheep{background-image:url(spritesmith-main-5.png);background-position:-1323px -1705px;width:48px;height:51px}.Pet_Egg_Slime{background-image:url(spritesmith-main-5.png);background-position:-1372px -1705px;width:48px;height:51px}.Pet_Egg_Spider{background-image:url(spritesmith-main-5.png);background-position:-1421px -1705px;width:48px;height:51px}.Pet_Egg_TRex{background-image:url(spritesmith-main-5.png);background-position:-1470px -1705px;width:48px;height:51px}.Pet_Egg_TigerCub{background-image:url(spritesmith-main-5.png);background-position:-1519px -1705px;width:48px;height:51px}.Pet_Egg_Whale{background-image:url(spritesmith-main-5.png);background-position:-1568px -1705px;width:48px;height:51px}.Pet_Egg_Wolf{background-image:url(spritesmith-main-5.png);background-position:-1617px -1705px;width:48px;height:51px}.Pet_Food_Cake_Base{background-image:url(spritesmith-main-5.png);background-position:-1761px -1307px;width:43px;height:43px}.Pet_Food_Cake_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1761px -1351px;width:42px;height:44px}.Pet_Food_Cake_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1761px -1126px;width:43px;height:45px}.Pet_Food_Cake_Desert{background-image:url(spritesmith-main-5.png);background-position:-1761px -1262px;width:43px;height:44px}.Pet_Food_Cake_Golden{background-image:url(spritesmith-main-5.png);background-position:-1761px -1396px;width:43px;height:42px}.Pet_Food_Cake_Red{background-image:url(spritesmith-main-5.png);background-position:-1761px -1172px;width:43px;height:44px}.Pet_Food_Cake_Shade{background-image:url(spritesmith-main-5.png);background-position:-1761px -1217px;width:43px;height:44px}.Pet_Food_Cake_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1761px -1033px;width:42px;height:47px}.Pet_Food_Cake_White{background-image:url(spritesmith-main-5.png);background-position:-1761px -1081px;width:44px;height:44px}.Pet_Food_Cake_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1761px -988px;width:45px;height:44px}.Pet_Food_Candy_Base{background-image:url(spritesmith-main-5.png);background-position:-1761px -468px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1761px -520px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1761px -572px;width:48px;height:51px}.Pet_Food_Candy_Desert{background-image:url(spritesmith-main-5.png);background-position:-1761px -624px;width:48px;height:51px}.Pet_Food_Candy_Golden{background-image:url(spritesmith-main-5.png);background-position:-1761px -676px;width:48px;height:51px}.Pet_Food_Candy_Red{background-image:url(spritesmith-main-5.png);background-position:-1761px -728px;width:48px;height:51px}.Pet_Food_Candy_Shade{background-image:url(spritesmith-main-5.png);background-position:-1761px -780px;width:48px;height:51px}.Pet_Food_Candy_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1761px -832px;width:48px;height:51px}.Pet_Food_Candy_White{background-image:url(spritesmith-main-5.png);background-position:-1761px -884px;width:48px;height:51px}.Pet_Food_Candy_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1761px -936px;width:48px;height:51px}.Pet_Food_Chocolate{background-image:url(spritesmith-main-5.png);background-position:-1761px -416px;width:48px;height:51px}.Pet_Food_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1761px -364px;width:48px;height:51px}.Pet_Food_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1761px -312px;width:48px;height:51px}.Pet_Food_Fish{background-image:url(spritesmith-main-5.png);background-position:-1761px -260px;width:48px;height:51px}.Pet_Food_Honey{background-image:url(spritesmith-main-5.png);background-position:-1761px -208px;width:48px;height:51px}.Pet_Food_Meat{background-image:url(spritesmith-main-5.png);background-position:-1761px -156px;width:48px;height:51px}.Pet_Food_Milk{background-image:url(spritesmith-main-5.png);background-position:-1761px -104px;width:48px;height:51px}.Pet_Food_Potatoe{background-image:url(spritesmith-main-5.png);background-position:-1761px -52px;width:48px;height:51px}.Pet_Food_RottenMeat{background-image:url(spritesmith-main-5.png);background-position:-1761px 0;width:48px;height:51px}.Pet_Food_Saddle{background-image:url(spritesmith-main-5.png);background-position:-1666px -1705px;width:48px;height:51px}.Pet_Food_Strawberry{background-image:url(spritesmith-main-5.png);background-position:-49px -1705px;width:48px;height:51px}.Mount_Body_BearCub-Base{background-image:url(spritesmith-main-5.png);background-position:-649px -1390px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-543px -1390px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-437px -1390px;width:105px;height:105px}.Mount_Body_BearCub-Desert{background-image:url(spritesmith-main-5.png);background-position:-331px -1390px;width:105px;height:105px}.Mount_Body_BearCub-Golden{background-image:url(spritesmith-main-5.png);background-position:-119px -1390px;width:105px;height:105px}.Mount_Body_BearCub-Polar{background-image:url(spritesmith-main-5.png);background-position:-1359px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Red{background-image:url(spritesmith-main-5.png);background-position:-1253px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Shade{background-image:url(spritesmith-main-5.png);background-position:-1147px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1041px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Spooky{background-image:url(spritesmith-main-5.png);background-position:-935px -1251px;width:105px;height:105px}.Mount_Body_BearCub-White{background-image:url(spritesmith-main-5.png);background-position:-829px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Zombie{background-image:url(spritesmith-main-5.png);background-position:-723px -1251px;width:105px;height:105px}.Mount_Body_Bunny-Base{background-image:url(spritesmith-main-5.png);background-position:-967px -1390px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-861px -1390px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-755px -1390px;width:105px;height:105px}.Mount_Body_Bunny-Desert{background-image:url(spritesmith-main-5.png);background-position:-617px -1251px;width:105px;height:105px}.Mount_Body_Bunny-Golden{background-image:url(spritesmith-main-5.png);background-position:-511px -1251px;width:105px;height:105px}.Mount_Body_Bunny-Red{background-image:url(spritesmith-main-5.png);background-position:-405px -1251px;width:105px;height:105px}.Mount_Body_Bunny-Shade{background-image:url(spritesmith-main-5.png);background-position:-225px -1390px;width:105px;height:105px}.Mount_Body_Bunny-Skeleton{background-image:url(spritesmith-main-5.png);background-position:-299px -1251px;width:105px;height:105px}.Mount_Body_Bunny-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -1060px;width:105px;height:105px}.Mount_Body_Bunny-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -1105px;width:105px;height:105px}.Mount_Body_Cactus-Base{background-image:url(spritesmith-main-6.png);background-position:-530px -221px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -327px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-221px -469px;width:105px;height:105px}.Mount_Body_Cactus-Desert{background-image:url(spritesmith-main-6.png);background-position:-327px -469px;width:105px;height:105px}.Mount_Body_Cactus-Golden{background-image:url(spritesmith-main-6.png);background-position:-433px -469px;width:105px;height:105px}.Mount_Body_Cactus-Red{background-image:url(spritesmith-main-6.png);background-position:-636px 0;width:105px;height:105px}.Mount_Body_Cactus-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -106px;width:105px;height:105px}.Mount_Body_Cactus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -212px;width:105px;height:105px}.Mount_Body_Cactus-Spooky{background-image:url(spritesmith-main-6.png);background-position:-636px -318px;width:105px;height:105px}.Mount_Body_Cactus-White{background-image:url(spritesmith-main-6.png);background-position:-742px -893px;width:105px;height:105px}.Mount_Body_Cactus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-848px -893px;width:105px;height:105px}.Mount_Body_Cheetah-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -893px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px 0;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px -106px;width:105px;height:105px}.Mount_Body_Cheetah-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -212px;width:105px;height:105px}.Mount_Body_Cheetah-Golden{background-image:url(spritesmith-main-6.png);background-position:-1060px -318px;width:105px;height:105px}.Mount_Body_Cheetah-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px -424px;width:105px;height:105px}.Mount_Body_Cheetah-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -530px;width:105px;height:105px}.Mount_Body_Cheetah-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1060px -636px;width:105px;height:105px}.Mount_Body_Cheetah-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -530px;width:105px;height:105px}.Mount_Body_Cheetah-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -954px;width:105px;height:105px}.Mount_Body_Cuttlefish-Base{background-image:url(spritesmith-main-6.png);background-position:-530px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -115px;width:105px;height:114px}.Mount_Body_Cuttlefish-White{background-image:url(spritesmith-main-6.png);background-position:0 -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -239px;width:105px;height:114px}.Mount_Body_Deer-Base{background-image:url(spritesmith-main-6.png);background-position:-636px -424px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:0 -575px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-106px -575px;width:105px;height:105px}.Mount_Body_Deer-Desert{background-image:url(spritesmith-main-6.png);background-position:-212px -575px;width:105px;height:105px}.Mount_Body_Deer-Golden{background-image:url(spritesmith-main-6.png);background-position:-318px -575px;width:105px;height:105px}.Mount_Body_Deer-Red{background-image:url(spritesmith-main-6.png);background-position:-424px -575px;width:105px;height:105px}.Mount_Body_Deer-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -575px;width:105px;height:105px}.Mount_Body_Deer-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -575px;width:105px;height:105px}.Mount_Body_Deer-White{background-image:url(spritesmith-main-6.png);background-position:-742px 0;width:105px;height:105px}.Mount_Body_Deer-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -106px;width:105px;height:105px}.Mount_Body_Dragon-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -212px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-742px -318px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-742px -424px;width:105px;height:105px}.Mount_Body_Dragon-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -530px;width:105px;height:105px}.Mount_Body_Dragon-Golden{background-image:url(spritesmith-main-6.png);background-position:0 -681px;width:105px;height:105px}.Mount_Body_Dragon-Red{background-image:url(spritesmith-main-6.png);background-position:-106px -681px;width:105px;height:105px}.Mount_Body_Dragon-Shade{background-image:url(spritesmith-main-6.png);background-position:-212px -681px;width:105px;height:105px}.Mount_Body_Dragon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -681px;width:105px;height:105px}.Mount_Body_Dragon-Spooky{background-image:url(spritesmith-main-6.png);background-position:-424px -681px;width:105px;height:105px}.Mount_Body_Dragon-White{background-image:url(spritesmith-main-6.png);background-position:-530px -681px;width:105px;height:105px}.Mount_Body_Dragon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-636px -681px;width:105px;height:105px}.Mount_Body_Egg-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -681px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px 0;width:105px;height:105px}.Mount_Body_Egg-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-848px -106px;width:105px;height:105px}.Mount_Body_Egg-Desert{background-image:url(spritesmith-main-6.png);background-position:-848px -212px;width:105px;height:105px}.Mount_Body_Egg-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -318px;width:105px;height:105px}.Mount_Body_Egg-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -424px;width:105px;height:105px}.Mount_Body_Egg-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -530px;width:105px;height:105px}.Mount_Body_Egg-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-848px -636px;width:105px;height:105px}.Mount_Body_Egg-White{background-image:url(spritesmith-main-6.png);background-position:0 -787px;width:105px;height:105px}.Mount_Body_Egg-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px 0;width:105px;height:105px}.Mount_Body_FlyingPig-Spooky{background-image:url(spritesmith-main-6.png);background-position:-954px -106px;width:105px;height:105px}.Mount_Body_FlyingPig-White{background-image:url(spritesmith-main-6.png);background-position:-954px -212px;width:105px;height:105px}.Mount_Body_FlyingPig-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -318px;width:105px;height:105px}.Mount_Body_Fox-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -424px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -530px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -636px;width:105px;height:105px}.Mount_Body_Fox-Desert{background-image:url(spritesmith-main-6.png);background-position:-954px -742px;width:105px;height:105px}.Mount_Body_Fox-Golden{background-image:url(spritesmith-main-6.png);background-position:0 -893px;width:105px;height:105px}.Mount_Body_Fox-Red{background-image:url(spritesmith-main-6.png);background-position:-106px -893px;width:105px;height:105px}.Mount_Body_Fox-Shade{background-image:url(spritesmith-main-6.png);background-position:-212px -893px;width:105px;height:105px}.Mount_Body_Fox-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -893px;width:105px;height:105px}.Mount_Body_Fox-Spooky{background-image:url(spritesmith-main-6.png);background-position:-424px -893px;width:105px;height:105px}.Mount_Body_Fox-White{background-image:url(spritesmith-main-6.png);background-position:-530px -893px;width:105px;height:105px}.Mount_Body_Fox-Zombie{background-image:url(spritesmith-main-6.png);background-position:-636px -893px;width:105px;height:105px}.Mount_Body_Frog-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -239px;width:105px;height:114px}.Mount_Body_Frog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px 0;width:105px;height:114px}.Mount_Body_Frog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px 0;width:105px;height:114px}.Mount_Body_Frog-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -115px;width:105px;height:114px}.Mount_Body_Frog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -230px;width:105px;height:114px}.Mount_Body_Frog-Red{background-image:url(spritesmith-main-6.png);background-position:0 -354px;width:105px;height:114px}.Mount_Body_Frog-Shade{background-image:url(spritesmith-main-6.png);background-position:-106px -354px;width:105px;height:114px}.Mount_Body_Frog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-212px -354px;width:105px;height:114px}.Mount_Body_Frog-White{background-image:url(spritesmith-main-6.png);background-position:-318px -354px;width:105px;height:114px}.Mount_Body_Frog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-424px -354px;width:105px;height:114px}.Mount_Body_Gryphon-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -742px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px -848px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -999px;width:105px;height:105px}.Mount_Body_Gryphon-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -999px;width:105px;height:105px}.Mount_Body_Gryphon-RoyalPurple{background-image:url(spritesmith-main-6.png);background-position:-424px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -999px;width:105px;height:105px}.Mount_Body_Gryphon-White{background-image:url(spritesmith-main-6.png);background-position:-742px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-848px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px 0;width:105px;height:105px}.Mount_Body_Hedgehog-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -106px;width:105px;height:105px}.Mount_Body_Hedgehog-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -212px;width:105px;height:105px}.Mount_Body_Hedgehog-Red{background-image:url(spritesmith-main-6.png);background-position:-1166px -318px;width:105px;height:105px}.Mount_Body_Hedgehog-Shade{background-image:url(spritesmith-main-6.png);background-position:-1166px -424px;width:105px;height:105px}.Mount_Body_Hedgehog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px -530px;width:105px;height:105px}.Mount_Body_Hedgehog-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -636px;width:105px;height:105px}.Mount_Body_Hedgehog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -742px;width:105px;height:105px}.Mount_Body_Horse-Base{background-image:url(spritesmith-main-6.png);background-position:-1166px -848px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px -954px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -1105px;width:105px;height:105px}.Mount_Body_Horse-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -1105px;width:105px;height:105px}.Mount_Body_Horse-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -1105px;width:105px;height:105px}.Mount_Body_Horse-Red{background-image:url(spritesmith-main-6.png);background-position:-530px -115px;width:105px;height:105px}.Mount_Body_Horse-Shade{background-image:url(spritesmith-main-6.png);background-position:-424px -1105px;width:105px;height:105px}.Mount_Body_Horse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -1105px;width:105px;height:105px}.Mount_Body_Horse-White{background-image:url(spritesmith-main-6.png);background-position:-636px -1105px;width:105px;height:105px}.Mount_Body_Horse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -1105px;width:105px;height:105px}.Mount_Body_JackOLantern-Base{background-image:url(spritesmith-main-6.png);background-position:-1696px -530px;width:90px;height:105px}.Mount_Body_LionCub-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -1105px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px -1105px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px -1105px;width:105px;height:105px}.Mount_Body_LionCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-1272px 0;width:105px;height:105px}.Mount_Body_LionCub-Ethereal{background-image:url(spritesmith-main-6.png);background-position:-1272px -106px;width:105px;height:105px}.Mount_Body_LionCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -212px;width:105px;height:105px}.Mount_Body_LionCub-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -318px;width:105px;height:105px}.Mount_Body_LionCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -424px;width:105px;height:105px}.Mount_Body_LionCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -469px;width:111px;height:105px}.Mount_Body_LionCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1272px -636px;width:105px;height:105px}.Mount_Body_LionCub-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -742px;width:105px;height:105px}.Mount_Body_LionCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -848px;width:105px;height:105px}.Mount_Body_Mammoth-Base{background-image:url(spritesmith-main-6.png);background-position:0 0;width:105px;height:123px}.Mount_Body_MantisShrimp-Base{background-image:url(spritesmith-main-6.png);background-position:-112px -469px;width:108px;height:105px}.Mount_Body_Octopus-Base{background-image:url(spritesmith-main-6.png);background-position:0 -1211px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px -1211px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Desert{background-image:url(spritesmith-main-6.png);background-position:-318px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Red{background-image:url(spritesmith-main-6.png);background-position:-530px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-742px -1211px;width:105px;height:105px}.Mount_Body_Octopus-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -1211px;width:105px;height:105px}.Mount_Body_Orca-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -1211px;width:105px;height:105px}.Mount_Body_Owl-Base{background-image:url(spritesmith-main-6.png);background-position:-1166px -1211px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -1211px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px 0;width:105px;height:105px}.Mount_Body_Owl-Desert{background-image:url(spritesmith-main-6.png);background-position:-1378px -106px;width:105px;height:105px}.Mount_Body_Owl-Golden{background-image:url(spritesmith-main-6.png);background-position:-1378px -212px;width:105px;height:105px}.Mount_Body_Owl-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -318px;width:105px;height:105px}.Mount_Body_Owl-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -424px;width:105px;height:105px}.Mount_Body_Owl-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1378px -530px;width:105px;height:105px}.Mount_Body_Owl-White{background-image:url(spritesmith-main-6.png);background-position:-1378px -636px;width:105px;height:105px}.Mount_Body_Owl-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -742px;width:105px;height:105px}.Mount_Body_PandaCub-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -848px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -954px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -1060px;width:105px;height:105px}.Mount_Body_PandaCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-1378px -1166px;width:105px;height:105px}.Mount_Body_PandaCub-Golden{background-image:url(spritesmith-main-6.png);background-position:0 -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Red{background-image:url(spritesmith-main-6.png);background-position:-106px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-212px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-424px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-White{background-image:url(spritesmith-main-6.png);background-position:-530px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-636px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -1317px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -1317px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px 0;width:105px;height:105px}.Mount_Body_Parrot-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -106px;width:105px;height:105px}.Mount_Body_Parrot-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -212px;width:105px;height:105px}.Mount_Body_Penguin-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -318px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -424px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px -530px;width:105px;height:105px}.Mount_Body_Penguin-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -636px;width:105px;height:105px}.Mount_Body_Penguin-Golden{background-image:url(spritesmith-main-6.png);background-position:-1484px -742px;width:105px;height:105px}.Mount_Body_Penguin-Red{background-image:url(spritesmith-main-6.png);background-position:-1484px -848px;width:105px;height:105px}.Mount_Body_Penguin-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px -954px;width:105px;height:105px}.Mount_Body_Penguin-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -1060px;width:105px;height:105px}.Mount_Body_Penguin-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -1166px;width:105px;height:105px}.Mount_Body_Penguin-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -1272px;width:105px;height:105px}.Mount_Body_Phoenix-Base{background-image:url(spritesmith-main-6.png);background-position:0 -1423px;width:105px;height:105px}.Mount_Body_Rat-Base{background-image:url(spritesmith-main-6.png);background-position:-106px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-212px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-318px -1423px;width:105px;height:105px}.Mount_Body_Rat-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -1423px;width:105px;height:105px}.Mount_Body_Rat-Golden{background-image:url(spritesmith-main-6.png);background-position:-530px -1423px;width:105px;height:105px}.Mount_Body_Rat-Red{background-image:url(spritesmith-main-6.png);background-position:-636px -1423px;width:105px;height:105px}.Mount_Body_Rat-Shade{background-image:url(spritesmith-main-6.png);background-position:-742px -1423px;width:105px;height:105px}.Mount_Body_Rat-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-848px -1423px;width:105px;height:105px}.Mount_Body_Rat-White{background-image:url(spritesmith-main-6.png);background-position:-954px -1423px;width:105px;height:105px}.Mount_Body_Rat-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1060px -1423px;width:105px;height:105px}.Mount_Body_Rock-Base{background-image:url(spritesmith-main-6.png);background-position:-1166px -1423px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -1423px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -1423px;width:105px;height:105px}.Mount_Body_Rock-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -1423px;width:105px;height:105px}.Mount_Body_Rock-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px 0;width:105px;height:105px}.Mount_Body_Rock-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -106px;width:105px;height:105px}.Mount_Body_Rock-Shade{background-image:url(spritesmith-main-6.png);background-position:-1590px -212px;width:105px;height:105px}.Mount_Body_Rock-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1590px -318px;width:105px;height:105px}.Mount_Body_Rock-White{background-image:url(spritesmith-main-6.png);background-position:-1590px -424px;width:105px;height:105px}.Mount_Body_Rock-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1590px -530px;width:105px;height:105px}.Mount_Body_Rooster-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px -636px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -742px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px -848px;width:105px;height:105px}.Mount_Body_Rooster-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -954px;width:105px;height:105px}.Mount_Body_Rooster-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -1060px;width:105px;height:105px}.Mount_Body_Rooster-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -1166px;width:105px;height:105px}.Mount_Body_Rooster-Shade{background-image:url(spritesmith-main-6.png);background-position:-1590px -1272px;width:105px;height:105px}.Mount_Body_Rooster-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1590px -1378px;width:105px;height:105px}.Mount_Body_Rooster-White{background-image:url(spritesmith-main-6.png);background-position:0 -1529px;width:105px;height:105px}.Mount_Body_Rooster-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1529px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -1529px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Golden{background-image:url(spritesmith-main-6.png);background-position:-1696px 0;width:105px;height:105px}.Mount_Body_Sheep-Red{background-image:url(spritesmith-main-6.png);background-position:-1696px -106px;width:105px;height:105px}.Mount_Body_Sheep-Shade{background-image:url(spritesmith-main-6.png);background-position:-1696px -212px;width:105px;height:105px}.Mount_Body_Sheep-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1696px -318px;width:105px;height:105px}.Mount_Body_Sheep-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1105px;width:105px;height:105px}.Mount_Body_Sheep-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1696px -424px;width:105px;height:105px}.Mount_Body_Slime-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -1113px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Body_Slime-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Body_Slime-Golden{background-image:url(spritesmith-main-7.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Body_Slime-Red{background-image:url(spritesmith-main-7.png);background-position:0 -1113px;width:105px;height:105px}.Mount_Body_Slime-Shade{background-image:url(spritesmith-main-7.png);background-position:-106px -1113px;width:105px;height:105px}.Mount_Body_Slime-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-212px -1113px;width:105px;height:105px}.Mount_Body_Slime-White{background-image:url(spritesmith-main-7.png);background-position:-318px -1113px;width:105px;height:105px}.Mount_Body_Slime-Zombie{background-image:url(spritesmith-main-7.png);background-position:-424px -1113px;width:105px;height:105px}.Mount_Body_Spider-Base{background-image:url(spritesmith-main-7.png);background-position:-530px -1113px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-106px -795px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-212px -795px;width:105px;height:105px}.Mount_Body_Spider-Desert{background-image:url(spritesmith-main-7.png);background-position:-318px -795px;width:105px;height:105px}.Mount_Body_Spider-Golden{background-image:url(spritesmith-main-7.png);background-position:-424px -795px;width:105px;height:105px}.Mount_Body_Spider-Red{background-image:url(spritesmith-main-7.png);background-position:-530px -795px;width:105px;height:105px}.Mount_Body_Spider-Shade{background-image:url(spritesmith-main-7.png);background-position:-636px -795px;width:105px;height:105px}.Mount_Body_Spider-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-742px -795px;width:105px;height:105px}.Mount_Body_Spider-White{background-image:url(spritesmith-main-7.png);background-position:-848px -795px;width:105px;height:105px}.Mount_Body_Spider-Zombie{background-image:url(spritesmith-main-7.png);background-position:-998px 0;width:105px;height:105px}.Mount_Body_TRex-Base{background-image:url(spritesmith-main-7.png);background-position:-136px 0;width:135px;height:135px}.Mount_Body_TRex-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 -544px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Body_TRex-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -136px;width:135px;height:135px}.Mount_Body_TRex-Golden{background-image:url(spritesmith-main-7.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Body_TRex-Red{background-image:url(spritesmith-main-7.png);background-position:-272px 0;width:135px;height:135px}.Mount_Body_TRex-Shade{background-image:url(spritesmith-main-7.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Body_TRex-Skeleton{background-image:url(spritesmith-main-7.png);background-position:0 -272px;width:135px;height:135px}.Mount_Body_TRex-White{background-image:url(spritesmith-main-7.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Body_TRex-Zombie{background-image:url(spritesmith-main-7.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Body_TigerCub-Base{background-image:url(spritesmith-main-7.png);background-position:-636px -1113px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-742px -1113px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1378px -1325px;width:105px;height:105px}.Mount_Body_TigerCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Body_TigerCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Body_TigerCub-Red{background-image:url(spritesmith-main-7.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Body_TigerCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Body_TigerCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1528px -424px;width:105px;height:105px}.Mount_Body_TigerCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1528px -530px;width:105px;height:105px}.Mount_Body_TigerCub-White{background-image:url(spritesmith-main-7.png);background-position:-1528px -636px;width:105px;height:105px}.Mount_Body_TigerCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1528px -742px;width:105px;height:105px}.Mount_Body_Turkey-Base{background-image:url(spritesmith-main-7.png);background-position:-1528px -848px;width:105px;height:105px}.Mount_Body_Whale-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -1537px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1537px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1272px -1537px;width:105px;height:105px}.Mount_Body_Whale-Desert{background-image:url(spritesmith-main-7.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Body_Whale-Golden{background-image:url(spritesmith-main-7.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Body_Whale-Red{background-image:url(spritesmith-main-7.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Body_Whale-Shade{background-image:url(spritesmith-main-7.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Body_Whale-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Body_Whale-White{background-image:url(spritesmith-main-7.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Body_Whale-Zombie{background-image:url(spritesmith-main-7.png);background-position:0 -795px;width:105px;height:105px}.Mount_Body_Wolf-Base{background-image:url(spritesmith-main-7.png);background-position:-408px 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Body_Wolf-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -408px;width:135px;height:135px}.Mount_Body_Wolf-Golden{background-image:url(spritesmith-main-7.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Body_Wolf-Red{background-image:url(spritesmith-main-7.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Body_Wolf-Shade{background-image:url(spritesmith-main-7.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Body_Wolf-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-544px 0;width:135px;height:135px}.Mount_Body_Wolf-Spooky{background-image:url(spritesmith-main-7.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Body_Wolf-White{background-image:url(spritesmith-main-7.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Body_Wolf-Zombie{background-image:url(spritesmith-main-7.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_BearCub-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_BearCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_BearCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_BearCub-Polar{background-image:url(spritesmith-main-7.png);background-position:0 -901px;width:105px;height:105px}.Mount_Head_BearCub-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -901px;width:105px;height:105px}.Mount_Head_BearCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -901px;width:105px;height:105px}.Mount_Head_BearCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -901px;width:105px;height:105px}.Mount_Head_BearCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-424px -901px;width:105px;height:105px}.Mount_Head_BearCub-White{background-image:url(spritesmith-main-7.png);background-position:-530px -901px;width:105px;height:105px}.Mount_Head_BearCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -901px;width:105px;height:105px}.Mount_Head_Bunny-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -901px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -901px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -901px;width:105px;height:105px}.Mount_Head_Bunny-Desert{background-image:url(spritesmith-main-7.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Bunny-Golden{background-image:url(spritesmith-main-7.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Bunny-Red{background-image:url(spritesmith-main-7.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Bunny-Shade{background-image:url(spritesmith-main-7.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Bunny-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Bunny-White{background-image:url(spritesmith-main-7.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Bunny-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Cactus-Base{background-image:url(spritesmith-main-7.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -1007px;width:105px;height:105px}.Mount_Head_Cactus-Desert{background-image:url(spritesmith-main-7.png);background-position:-106px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Golden{background-image:url(spritesmith-main-7.png);background-position:-212px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Red{background-image:url(spritesmith-main-7.png);background-position:-318px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Shade{background-image:url(spritesmith-main-7.png);background-position:-424px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-530px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Spooky{background-image:url(spritesmith-main-7.png);background-position:-636px -1007px;width:105px;height:105px}.Mount_Head_Cactus-White{background-image:url(spritesmith-main-7.png);background-position:-742px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Zombie{background-image:url(spritesmith-main-7.png);background-position:-848px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-Base{background-image:url(spritesmith-main-7.png);background-position:-954px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1060px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Cheetah-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Cheetah-Golden{background-image:url(spritesmith-main-7.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Cheetah-Red{background-image:url(spritesmith-main-7.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Cheetah-Shade{background-image:url(spritesmith-main-7.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Cheetah-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Cheetah-White{background-image:url(spritesmith-main-7.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Cheetah-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Cuttlefish-Base{background-image:url(spritesmith-main-7.png);background-position:-530px -680px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-348px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Desert{background-image:url(spritesmith-main-7.png);background-position:-454px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Golden{background-image:url(spritesmith-main-7.png);background-position:-560px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Red{background-image:url(spritesmith-main-7.png);background-position:-680px 0;width:105px;height:114px}.Mount_Head_Cuttlefish-Shade{background-image:url(spritesmith-main-7.png);background-position:-680px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-680px -230px;width:105px;height:114px}.Mount_Head_Cuttlefish-White{background-image:url(spritesmith-main-7.png);background-position:-680px -345px;width:105px;height:114px}.Mount_Head_Cuttlefish-Zombie{background-image:url(spritesmith-main-7.png);background-position:-680px -460px;width:105px;height:114px}.Mount_Head_Deer-Base{background-image:url(spritesmith-main-7.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Deer-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-954px -1113px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1060px -1113px;width:105px;height:105px}.Mount_Head_Deer-Desert{background-image:url(spritesmith-main-7.png);background-position:-1166px -1113px;width:105px;height:105px}.Mount_Head_Deer-Golden{background-image:url(spritesmith-main-7.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Deer-Red{background-image:url(spritesmith-main-7.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Deer-Shade{background-image:url(spritesmith-main-7.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Deer-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Deer-White{background-image:url(spritesmith-main-7.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Deer-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Dragon-Base{background-image:url(spritesmith-main-7.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Dragon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Dragon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Dragon-Red{background-image:url(spritesmith-main-7.png);background-position:0 -1219px;width:105px;height:105px}.Mount_Head_Dragon-Shade{background-image:url(spritesmith-main-7.png);background-position:-106px -1219px;width:105px;height:105px}.Mount_Head_Dragon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-212px -1219px;width:105px;height:105px}.Mount_Head_Dragon-Spooky{background-image:url(spritesmith-main-7.png);background-position:-318px -1219px;width:105px;height:105px}.Mount_Head_Dragon-White{background-image:url(spritesmith-main-7.png);background-position:-424px -1219px;width:105px;height:105px}.Mount_Head_Dragon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-530px -1219px;width:105px;height:105px}.Mount_Head_Egg-Base{background-image:url(spritesmith-main-7.png);background-position:-636px -1219px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-742px -1219px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-848px -1219px;width:105px;height:105px}.Mount_Head_Egg-Desert{background-image:url(spritesmith-main-7.png);background-position:-954px -1219px;width:105px;height:105px}.Mount_Head_Egg-Golden{background-image:url(spritesmith-main-7.png);background-position:-1060px -1219px;width:105px;height:105px}.Mount_Head_Egg-Red{background-image:url(spritesmith-main-7.png);background-position:-1166px -1219px;width:105px;height:105px}.Mount_Head_Egg-Shade{background-image:url(spritesmith-main-7.png);background-position:-1272px -1219px;width:105px;height:105px}.Mount_Head_Egg-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_Egg-White{background-image:url(spritesmith-main-7.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_Egg-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_FlyingPig-Base{background-image:url(spritesmith-main-7.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_FlyingPig-Desert{background-image:url(spritesmith-main-7.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_FlyingPig-Golden{background-image:url(spritesmith-main-7.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_FlyingPig-Red{background-image:url(spritesmith-main-7.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_FlyingPig-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_FlyingPig-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_FlyingPig-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_FlyingPig-White{background-image:url(spritesmith-main-7.png);background-position:0 -1325px;width:105px;height:105px}.Mount_Head_FlyingPig-Zombie{background-image:url(spritesmith-main-7.png);background-position:-106px -1325px;width:105px;height:105px}.Mount_Head_Fox-Base{background-image:url(spritesmith-main-7.png);background-position:-212px -1325px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-318px -1325px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-424px -1325px;width:105px;height:105px}.Mount_Head_Fox-Desert{background-image:url(spritesmith-main-7.png);background-position:-530px -1325px;width:105px;height:105px}.Mount_Head_Fox-Golden{background-image:url(spritesmith-main-7.png);background-position:-636px -1325px;width:105px;height:105px}.Mount_Head_Fox-Red{background-image:url(spritesmith-main-7.png);background-position:-742px -1325px;width:105px;height:105px}.Mount_Head_Fox-Shade{background-image:url(spritesmith-main-7.png);background-position:-848px -1325px;width:105px;height:105px}.Mount_Head_Fox-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-954px -1325px;width:105px;height:105px}.Mount_Head_Fox-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1060px -1325px;width:105px;height:105px}.Mount_Head_Fox-White{background-image:url(spritesmith-main-7.png);background-position:-1166px -1325px;width:105px;height:105px}.Mount_Head_Fox-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1272px -1325px;width:105px;height:105px}.Mount_Head_Frog-Base{background-image:url(spritesmith-main-7.png);background-position:-786px 0;width:105px;height:114px}.Mount_Head_Frog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-242px -544px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -230px;width:105px;height:114px}.Mount_Head_Frog-Desert{background-image:url(spritesmith-main-7.png);background-position:-786px -345px;width:105px;height:114px}.Mount_Head_Frog-Golden{background-image:url(spritesmith-main-7.png);background-position:-786px -460px;width:105px;height:114px}.Mount_Head_Frog-Red{background-image:url(spritesmith-main-7.png);background-position:0 -680px;width:105px;height:114px}.Mount_Head_Frog-Shade{background-image:url(spritesmith-main-7.png);background-position:-106px -680px;width:105px;height:114px}.Mount_Head_Frog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-212px -680px;width:105px;height:114px}.Mount_Head_Frog-White{background-image:url(spritesmith-main-7.png);background-position:-318px -680px;width:105px;height:114px}.Mount_Head_Frog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-424px -680px;width:105px;height:114px}.Mount_Head_Gryphon-Base{background-image:url(spritesmith-main-7.png);background-position:-1528px -954px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px -1060px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -1166px;width:105px;height:105px}.Mount_Head_Gryphon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px -1272px;width:105px;height:105px}.Mount_Head_Gryphon-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -1431px;width:105px;height:105px}.Mount_Head_Gryphon-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -1431px;width:105px;height:105px}.Mount_Head_Gryphon-RoyalPurple{background-image:url(spritesmith-main-7.png);background-position:-212px -1431px;width:105px;height:105px}.Mount_Head_Gryphon-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1431px;width:105px;height:105px}.Mount_Head_Gryphon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1431px;width:105px;height:105px}.Mount_Head_Gryphon-White{background-image:url(spritesmith-main-7.png);background-position:-530px -1431px;width:105px;height:105px}.Mount_Head_Gryphon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Desert{background-image:url(spritesmith-main-7.png);background-position:-1060px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Golden{background-image:url(spritesmith-main-7.png);background-position:-1166px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Red{background-image:url(spritesmith-main-7.png);background-position:-1272px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Shade{background-image:url(spritesmith-main-7.png);background-position:-1378px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1484px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-White{background-image:url(spritesmith-main-7.png);background-position:-1634px 0;width:105px;height:105px}.Mount_Head_Hedgehog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -106px;width:105px;height:105px}.Mount_Head_Horse-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -212px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1634px -318px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1634px -424px;width:105px;height:105px}.Mount_Head_Horse-Desert{background-image:url(spritesmith-main-7.png);background-position:-1634px -530px;width:105px;height:105px}.Mount_Head_Horse-Golden{background-image:url(spritesmith-main-7.png);background-position:-1634px -636px;width:105px;height:105px}.Mount_Head_Horse-Red{background-image:url(spritesmith-main-7.png);background-position:-1634px -742px;width:105px;height:105px}.Mount_Head_Horse-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -848px;width:105px;height:105px}.Mount_Head_Horse-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px -954px;width:105px;height:105px}.Mount_Head_Horse-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -1060px;width:105px;height:105px}.Mount_Head_Horse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -1166px;width:105px;height:105px}.Mount_Head_JackOLantern-Base{background-image:url(spritesmith-main-7.png);background-position:-1740px -424px;width:90px;height:105px}.Mount_Head_LionCub-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -1378px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 -1537px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-106px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-212px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Ethereal{background-image:url(spritesmith-main-7.png);background-position:-318px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-424px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Red{background-image:url(spritesmith-main-7.png);background-position:-530px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-636px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -680px;width:105px;height:110px}.Mount_Head_LionCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-848px -1537px;width:105px;height:105px}.Mount_Head_LionCub-White{background-image:url(spritesmith-main-7.png);background-position:-954px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1060px -1537px;width:105px;height:105px}.Mount_Head_Mammoth-Base{background-image:url(spritesmith-main-7.png);background-position:-136px -544px;width:105px;height:123px}.Mount_Head_MantisShrimp-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -680px;width:108px;height:105px}.Mount_Head_Octopus-Base{background-image:url(spritesmith-main-7.png);background-position:-1378px -1537px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1484px -1537px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1590px -1537px;width:105px;height:105px}.Mount_Head_Octopus-Desert{background-image:url(spritesmith-main-7.png);background-position:-1740px 0;width:105px;height:105px}.Mount_Head_Octopus-Golden{background-image:url(spritesmith-main-7.png);background-position:-1740px -106px;width:105px;height:105px}.Mount_Head_Octopus-Red{background-image:url(spritesmith-main-7.png);background-position:-1740px -212px;width:105px;height:105px}.Mount_Head_Octopus-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -1272px;width:105px;height:105px}.Mount_Head_Octopus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1740px -318px;width:105px;height:105px}.Mount_Head_Octopus-White{background-image:url(spritesmith-main-8.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Octopus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-848px -1210px;width:105px;height:105px}.Mount_Head_Orca-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Owl-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Owl-Desert{background-image:url(spritesmith-main-8.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Owl-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px -1060px;width:105px;height:105px}.Mount_Head_Owl-Red{background-image:url(spritesmith-main-8.png);background-position:0 -1210px;width:105px;height:105px}.Mount_Head_Owl-Shade{background-image:url(spritesmith-main-8.png);background-position:-106px -1210px;width:105px;height:105px}.Mount_Head_Owl-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-212px -1210px;width:105px;height:105px}.Mount_Head_Owl-White{background-image:url(spritesmith-main-8.png);background-position:-106px -1316px;width:105px;height:105px}.Mount_Head_Owl-Zombie{background-image:url(spritesmith-main-8.png);background-position:-212px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-742px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Red{background-image:url(spritesmith-main-8.png);background-position:-848px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-954px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1060px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1166px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-White{background-image:url(spritesmith-main-8.png);background-position:-242px -544px;width:105px;height:105px}.Mount_Head_PandaCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-348px -544px;width:105px;height:105px}.Mount_Head_Parrot-Base{background-image:url(spritesmith-main-8.png);background-position:-454px -544px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-560px -544px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-680px 0;width:105px;height:105px}.Mount_Head_Parrot-Desert{background-image:url(spritesmith-main-8.png);background-position:-680px -106px;width:105px;height:105px}.Mount_Head_Parrot-Golden{background-image:url(spritesmith-main-8.png);background-position:-680px -212px;width:105px;height:105px}.Mount_Head_Parrot-Red{background-image:url(spritesmith-main-8.png);background-position:-680px -318px;width:105px;height:105px}.Mount_Head_Parrot-Shade{background-image:url(spritesmith-main-8.png);background-position:-680px -424px;width:105px;height:105px}.Mount_Head_Parrot-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-680px -530px;width:105px;height:105px}.Mount_Head_Parrot-White{background-image:url(spritesmith-main-8.png);background-position:0 -680px;width:105px;height:105px}.Mount_Head_Parrot-Zombie{background-image:url(spritesmith-main-8.png);background-position:-106px -680px;width:105px;height:105px}.Mount_Head_Penguin-Base{background-image:url(spritesmith-main-8.png);background-position:-212px -680px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-318px -680px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-424px -680px;width:105px;height:105px}.Mount_Head_Penguin-Desert{background-image:url(spritesmith-main-8.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_Penguin-Golden{background-image:url(spritesmith-main-8.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Head_Penguin-Red{background-image:url(spritesmith-main-8.png);background-position:-786px 0;width:105px;height:105px}.Mount_Head_Penguin-Shade{background-image:url(spritesmith-main-8.png);background-position:-786px -106px;width:105px;height:105px}.Mount_Head_Penguin-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-786px -212px;width:105px;height:105px}.Mount_Head_Penguin-White{background-image:url(spritesmith-main-8.png);background-position:-786px -318px;width:105px;height:105px}.Mount_Head_Penguin-Zombie{background-image:url(spritesmith-main-8.png);background-position:-786px -424px;width:105px;height:105px}.Mount_Head_Phoenix-Base{background-image:url(spritesmith-main-8.png);background-position:-786px -530px;width:105px;height:105px}.Mount_Head_Rat-Base{background-image:url(spritesmith-main-8.png);background-position:-786px -636px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:0 -786px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-106px -786px;width:105px;height:105px}.Mount_Head_Rat-Desert{background-image:url(spritesmith-main-8.png);background-position:-212px -786px;width:105px;height:105px}.Mount_Head_Rat-Golden{background-image:url(spritesmith-main-8.png);background-position:-318px -786px;width:105px;height:105px}.Mount_Head_Rat-Red{background-image:url(spritesmith-main-8.png);background-position:-424px -786px;width:105px;height:105px}.Mount_Head_Rat-Shade{background-image:url(spritesmith-main-8.png);background-position:-530px -786px;width:105px;height:105px}.Mount_Head_Rat-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-636px -786px;width:105px;height:105px}.Mount_Head_Rat-White{background-image:url(spritesmith-main-8.png);background-position:-742px -786px;width:105px;height:105px}.Mount_Head_Rat-Zombie{background-image:url(spritesmith-main-8.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Rock-Base{background-image:url(spritesmith-main-8.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Head_Rock-Desert{background-image:url(spritesmith-main-8.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Head_Rock-Golden{background-image:url(spritesmith-main-8.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Head_Rock-Red{background-image:url(spritesmith-main-8.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Head_Rock-Shade{background-image:url(spritesmith-main-8.png);background-position:-892px -742px;width:105px;height:105px}.Mount_Head_Rock-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -892px;width:105px;height:105px}.Mount_Head_Rock-White{background-image:url(spritesmith-main-8.png);background-position:-106px -892px;width:105px;height:105px}.Mount_Head_Rock-Zombie{background-image:url(spritesmith-main-8.png);background-position:-212px -892px;width:105px;height:105px}.Mount_Head_Rooster-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -892px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -892px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -892px;width:105px;height:105px}.Mount_Head_Rooster-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -892px;width:105px;height:105px}.Mount_Head_Rooster-Golden{background-image:url(spritesmith-main-8.png);background-position:-742px -892px;width:105px;height:105px}.Mount_Head_Rooster-Red{background-image:url(spritesmith-main-8.png);background-position:-848px -892px;width:105px;height:105px}.Mount_Head_Rooster-Shade{background-image:url(spritesmith-main-8.png);background-position:-998px 0;width:105px;height:105px}.Mount_Head_Rooster-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Head_Rooster-White{background-image:url(spritesmith-main-8.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Head_Rooster-Zombie{background-image:url(spritesmith-main-8.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_Seahorse-Base{background-image:url(spritesmith-main-8.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_Seahorse-Desert{background-image:url(spritesmith-main-8.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_Seahorse-Golden{background-image:url(spritesmith-main-8.png);background-position:-998px -848px;width:105px;height:105px}.Mount_Head_Seahorse-Red{background-image:url(spritesmith-main-8.png);background-position:0 -998px;width:105px;height:105px}.Mount_Head_Seahorse-Shade{background-image:url(spritesmith-main-8.png);background-position:-106px -998px;width:105px;height:105px}.Mount_Head_Seahorse-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-212px -998px;width:105px;height:105px}.Mount_Head_Seahorse-White{background-image:url(spritesmith-main-8.png);background-position:-318px -998px;width:105px;height:105px}.Mount_Head_Seahorse-Zombie{background-image:url(spritesmith-main-8.png);background-position:-424px -998px;width:105px;height:105px}.Mount_Head_Sheep-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -998px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -998px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -998px;width:105px;height:105px}.Mount_Head_Sheep-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -998px;width:105px;height:105px}.Mount_Head_Sheep-Golden{background-image:url(spritesmith-main-8.png);background-position:-954px -998px;width:105px;height:105px}.Mount_Head_Sheep-Red{background-image:url(spritesmith-main-8.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Sheep-Shade{background-image:url(spritesmith-main-8.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Sheep-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Sheep-White{background-image:url(spritesmith-main-8.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Sheep-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Slime-Base{background-image:url(spritesmith-main-8.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Slime-Desert{background-image:url(spritesmith-main-8.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Slime-Golden{background-image:url(spritesmith-main-8.png);background-position:-1104px -954px;width:105px;height:105px}.Mount_Head_Slime-Red{background-image:url(spritesmith-main-8.png);background-position:0 -1104px;width:105px;height:105px}.Mount_Head_Slime-Shade{background-image:url(spritesmith-main-8.png);background-position:-106px -1104px;width:105px;height:105px}.Mount_Head_Slime-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-212px -1104px;width:105px;height:105px}.Mount_Head_Slime-White{background-image:url(spritesmith-main-8.png);background-position:-318px -1104px;width:105px;height:105px}.Mount_Head_Slime-Zombie{background-image:url(spritesmith-main-8.png);background-position:-424px -1104px;width:105px;height:105px}.Mount_Head_Spider-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -1104px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -1104px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -1104px;width:105px;height:105px}.Mount_Head_Spider-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -1104px;width:105px;height:105px}.Mount_Head_Spider-Golden{background-image:url(spritesmith-main-8.png);background-position:-954px -1104px;width:105px;height:105px}.Mount_Head_Spider-Red{background-image:url(spritesmith-main-8.png);background-position:-1060px -1104px;width:105px;height:105px}.Mount_Head_Spider-Shade{background-image:url(spritesmith-main-8.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Spider-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Spider-White{background-image:url(spritesmith-main-8.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Spider-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_TRex-Base{background-image:url(spritesmith-main-8.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:0 -136px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Head_TRex-Desert{background-image:url(spritesmith-main-8.png);background-position:-272px 0;width:135px;height:135px}.Mount_Head_TRex-Golden{background-image:url(spritesmith-main-8.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Head_TRex-Red{background-image:url(spritesmith-main-8.png);background-position:0 -272px;width:135px;height:135px}.Mount_Head_TRex-Shade{background-image:url(spritesmith-main-8.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Head_TRex-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Head_TRex-White{background-image:url(spritesmith-main-8.png);background-position:-408px 0;width:135px;height:135px}.Mount_Head_TRex-Zombie{background-image:url(spritesmith-main-8.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Head_TigerCub-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-742px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Red{background-image:url(spritesmith-main-8.png);background-position:-136px -544px;width:105px;height:105px}.Mount_Head_TigerCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-954px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1060px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1166px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-White{background-image:url(spritesmith-main-8.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_TigerCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Turkey-Base{background-image:url(spritesmith-main-8.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Whale-Base{background-image:url(spritesmith-main-8.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Whale-Desert{background-image:url(spritesmith-main-8.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Whale-Golden{background-image:url(spritesmith-main-8.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Whale-Red{background-image:url(spritesmith-main-8.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Whale-Shade{background-image:url(spritesmith-main-8.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Whale-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Whale-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -1166px;width:105px;height:105px}.Mount_Head_Whale-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1316px;width:105px;height:105px}.Mount_Head_Wolf-Base{background-image:url(spritesmith-main-8.png);background-position:0 0;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:0 -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Head_Wolf-Desert{background-image:url(spritesmith-main-8.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Head_Wolf-Golden{background-image:url(spritesmith-main-8.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Head_Wolf-Red{background-image:url(spritesmith-main-8.png);background-position:-544px 0;width:135px;height:135px}.Mount_Head_Wolf-Shade{background-image:url(spritesmith-main-8.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Head_Wolf-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Head_Wolf-Spooky{background-image:url(spritesmith-main-8.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_Wolf-White{background-image:url(spritesmith-main-8.png);background-position:0 -544px;width:135px;height:135px}.Mount_Head_Wolf-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px 0;width:135px;height:135px}.Pet-BearCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px 0;width:81px;height:99px}.Pet-BearCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1586px -300px;width:81px;height:99px}.Pet-BearCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px -100px;width:81px;height:99px}.Pet-BearCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1422px -200px;width:81px;height:99px}.Pet-BearCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1422px -300px;width:81px;height:99px}.Pet-BearCub-Polar{background-image:url(spritesmith-main-8.png);background-position:-1422px -400px;width:81px;height:99px}.Pet-BearCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1422px -500px;width:81px;height:99px}.Pet-BearCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1422px -600px;width:81px;height:99px}.Pet-BearCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1422px -700px;width:81px;height:99px}.Pet-BearCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1422px -800px;width:81px;height:99px}.Pet-BearCub-White{background-image:url(spritesmith-main-8.png);background-position:-1422px -900px;width:81px;height:99px}.Pet-BearCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1422px -1000px;width:81px;height:99px}.Pet-Bunny-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -1100px;width:81px;height:99px}.Pet-Bunny-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1422px -1200px;width:81px;height:99px}.Pet-Bunny-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px -1300px;width:81px;height:99px}.Pet-Bunny-Desert{background-image:url(spritesmith-main-8.png);background-position:-1504px 0;width:81px;height:99px}.Pet-Bunny-Golden{background-image:url(spritesmith-main-8.png);background-position:-1504px -100px;width:81px;height:99px}.Pet-Bunny-Red{background-image:url(spritesmith-main-8.png);background-position:-1504px -200px;width:81px;height:99px}.Pet-Bunny-Shade{background-image:url(spritesmith-main-8.png);background-position:-1504px -300px;width:81px;height:99px}.Pet-Bunny-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1504px -400px;width:81px;height:99px}.Pet-Bunny-White{background-image:url(spritesmith-main-8.png);background-position:-1504px -500px;width:81px;height:99px}.Pet-Bunny-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1504px -600px;width:81px;height:99px}.Pet-Cactus-Base{background-image:url(spritesmith-main-8.png);background-position:-1504px -700px;width:81px;height:99px}.Pet-Cactus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1504px -800px;width:81px;height:99px}.Pet-Cactus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1504px -900px;width:81px;height:99px}.Pet-Cactus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1504px -1000px;width:81px;height:99px}.Pet-Cactus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1504px -1100px;width:81px;height:99px}.Pet-Cactus-Red{background-image:url(spritesmith-main-8.png);background-position:-1504px -1200px;width:81px;height:99px}.Pet-Cactus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1504px -1300px;width:81px;height:99px}.Pet-Cactus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -1422px;width:81px;height:99px}.Pet-Cactus-Spooky{background-image:url(spritesmith-main-8.png);background-position:-82px -1422px;width:81px;height:99px}.Pet-Cactus-White{background-image:url(spritesmith-main-8.png);background-position:-164px -1422px;width:81px;height:99px}.Pet-Cactus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-246px -1422px;width:81px;height:99px}.Pet-Cheetah-Base{background-image:url(spritesmith-main-8.png);background-position:-328px -1422px;width:81px;height:99px}.Pet-Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-410px -1422px;width:81px;height:99px}.Pet-Cheetah-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-492px -1422px;width:81px;height:99px}.Pet-Cheetah-Desert{background-image:url(spritesmith-main-8.png);background-position:-574px -1422px;width:81px;height:99px}.Pet-Cheetah-Golden{background-image:url(spritesmith-main-8.png);background-position:-656px -1422px;width:81px;height:99px}.Pet-Cheetah-Red{background-image:url(spritesmith-main-8.png);background-position:-738px -1422px;width:81px;height:99px}.Pet-Cheetah-Shade{background-image:url(spritesmith-main-8.png);background-position:-820px -1422px;width:81px;height:99px}.Pet-Cheetah-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-902px -1422px;width:81px;height:99px}.Pet-Cheetah-White{background-image:url(spritesmith-main-8.png);background-position:-984px -1422px;width:81px;height:99px}.Pet-Cheetah-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1066px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Base{background-image:url(spritesmith-main-8.png);background-position:-1148px -1422px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1230px -1422px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1312px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Desert{background-image:url(spritesmith-main-8.png);background-position:-1394px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Golden{background-image:url(spritesmith-main-8.png);background-position:-1476px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Red{background-image:url(spritesmith-main-8.png);background-position:-1586px 0;width:81px;height:99px}.Pet-Cuttlefish-Shade{background-image:url(spritesmith-main-8.png);background-position:-1586px -100px;width:81px;height:99px}.Pet-Cuttlefish-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1586px -200px;width:81px;height:99px}.Pet-Cuttlefish-White{background-image:url(spritesmith-main-8.png);background-position:-1272px -1316px;width:81px;height:99px}.Pet-Cuttlefish-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1586px -400px;width:81px;height:99px}.Pet-Deer-Base{background-image:url(spritesmith-main-8.png);background-position:-1586px -500px;width:81px;height:99px}.Pet-Deer-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1586px -600px;width:81px;height:99px}.Pet-Deer-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1586px -700px;width:81px;height:99px}.Pet-Deer-Desert{background-image:url(spritesmith-main-8.png);background-position:-1586px -800px;width:81px;height:99px}.Pet-Deer-Golden{background-image:url(spritesmith-main-8.png);background-position:-1586px -900px;width:81px;height:99px}.Pet-Deer-Red{background-image:url(spritesmith-main-8.png);background-position:-1586px -1000px;width:81px;height:99px}.Pet-Deer-Shade{background-image:url(spritesmith-main-8.png);background-position:-1586px -1100px;width:81px;height:99px}.Pet-Deer-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1586px -1200px;width:81px;height:99px}.Pet-Deer-White{background-image:url(spritesmith-main-8.png);background-position:-1586px -1300px;width:81px;height:99px}.Pet-Deer-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1586px -1400px;width:81px;height:99px}.Pet-Dragon-Base{background-image:url(spritesmith-main-8.png);background-position:0 -1522px;width:81px;height:99px}.Pet-Dragon-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-82px -1522px;width:81px;height:99px}.Pet-Dragon-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-164px -1522px;width:81px;height:99px}.Pet-Dragon-Desert{background-image:url(spritesmith-main-8.png);background-position:-246px -1522px;width:81px;height:99px}.Pet-Dragon-Golden{background-image:url(spritesmith-main-8.png);background-position:-328px -1522px;width:81px;height:99px}.Pet-Dragon-Hydra{background-image:url(spritesmith-main-8.png);background-position:-410px -1522px;width:81px;height:99px}.Pet-Dragon-Red{background-image:url(spritesmith-main-8.png);background-position:-492px -1522px;width:81px;height:99px}.Pet-Dragon-Shade{background-image:url(spritesmith-main-8.png);background-position:-574px -1522px;width:81px;height:99px}.Pet-Dragon-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-656px -1522px;width:81px;height:99px}.Pet-Dragon-Spooky{background-image:url(spritesmith-main-8.png);background-position:-738px -1522px;width:81px;height:99px}.Pet-Dragon-White{background-image:url(spritesmith-main-8.png);background-position:-820px -1522px;width:81px;height:99px}.Pet-Dragon-Zombie{background-image:url(spritesmith-main-8.png);background-position:-902px -1522px;width:81px;height:99px}.Pet-Egg-Base{background-image:url(spritesmith-main-8.png);background-position:-984px -1522px;width:81px;height:99px}.Pet-Egg-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1066px -1522px;width:81px;height:99px}.Pet-Egg-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1148px -1522px;width:81px;height:99px}.Pet-Egg-Desert{background-image:url(spritesmith-main-8.png);background-position:-1230px -1522px;width:81px;height:99px}.Pet-Egg-Golden{background-image:url(spritesmith-main-8.png);background-position:-1312px -1522px;width:81px;height:99px}.Pet-Egg-Red{background-image:url(spritesmith-main-8.png);background-position:-1394px -1522px;width:81px;height:99px}.Pet-Egg-Shade{background-image:url(spritesmith-main-8.png);background-position:-1476px -1522px;width:81px;height:99px}.Pet-Egg-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1558px -1522px;width:81px;height:99px}.Pet-Egg-White{background-image:url(spritesmith-main-8.png);background-position:-1668px 0;width:81px;height:99px}.Pet-Egg-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1668px -100px;width:81px;height:99px}.Pet-FlyingPig-Base{background-image:url(spritesmith-main-8.png);background-position:-1668px -200px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1668px -300px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1668px -400px;width:81px;height:99px}.Pet-FlyingPig-Desert{background-image:url(spritesmith-main-8.png);background-position:-1668px -500px;width:81px;height:99px}.Pet-FlyingPig-Golden{background-image:url(spritesmith-main-8.png);background-position:-1668px -600px;width:81px;height:99px}.Pet-FlyingPig-Red{background-image:url(spritesmith-main-8.png);background-position:-1668px -700px;width:81px;height:99px}.Pet-FlyingPig-Shade{background-image:url(spritesmith-main-8.png);background-position:-1668px -800px;width:81px;height:99px}.Pet-FlyingPig-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1668px -900px;width:81px;height:99px}.Pet-FlyingPig-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1668px -1000px;width:81px;height:99px}.Pet-FlyingPig-White{background-image:url(spritesmith-main-8.png);background-position:-1668px -1100px;width:81px;height:99px}.Pet-FlyingPig-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1668px -1200px;width:81px;height:99px}.Pet-Fox-Base{background-image:url(spritesmith-main-8.png);background-position:-1668px -1300px;width:81px;height:99px}.Pet-Fox-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1668px -1400px;width:81px;height:99px}.Pet-Fox-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1668px -1500px;width:81px;height:99px}.Pet-Fox-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1622px;width:81px;height:99px}.Pet-Fox-Golden{background-image:url(spritesmith-main-8.png);background-position:-82px -1622px;width:81px;height:99px}.Pet-Fox-Red{background-image:url(spritesmith-main-8.png);background-position:-164px -1622px;width:81px;height:99px}.Pet-Fox-Shade{background-image:url(spritesmith-main-8.png);background-position:-246px -1622px;width:81px;height:99px}.Pet-Fox-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px 0;width:81px;height:99px}.Pet-Fox-Spooky{background-image:url(spritesmith-main-9.png);background-position:-82px -900px;width:81px;height:99px}.Pet-Fox-White{background-image:url(spritesmith-main-9.png);background-position:-164px 0;width:81px;height:99px}.Pet-Fox-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -100px;width:81px;height:99px}.Pet-Frog-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -100px;width:81px;height:99px}.Pet-Frog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -100px;width:81px;height:99px}.Pet-Frog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px 0;width:81px;height:99px}.Pet-Frog-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -100px;width:81px;height:99px}.Pet-Frog-Golden{background-image:url(spritesmith-main-9.png);background-position:0 -200px;width:81px;height:99px}.Pet-Frog-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -200px;width:81px;height:99px}.Pet-Frog-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -200px;width:81px;height:99px}.Pet-Frog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -200px;width:81px;height:99px}.Pet-Frog-White{background-image:url(spritesmith-main-9.png);background-position:-328px 0;width:81px;height:99px}.Pet-Frog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-328px -100px;width:81px;height:99px}.Pet-Gryphon-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -200px;width:81px;height:99px}.Pet-Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -300px;width:81px;height:99px}.Pet-Gryphon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -300px;width:81px;height:99px}.Pet-Gryphon-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -300px;width:81px;height:99px}.Pet-Gryphon-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -300px;width:81px;height:99px}.Pet-Gryphon-Red{background-image:url(spritesmith-main-9.png);background-position:-328px -300px;width:81px;height:99px}.Pet-Gryphon-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px 0;width:81px;height:99px}.Pet-Gryphon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -100px;width:81px;height:99px}.Pet-Gryphon-White{background-image:url(spritesmith-main-9.png);background-position:-410px -200px;width:81px;height:99px}.Pet-Gryphon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-410px -300px;width:81px;height:99px}.Pet-Hedgehog-Base{background-image:url(spritesmith-main-9.png);background-position:-492px 0;width:81px;height:99px}.Pet-Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -100px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -200px;width:81px;height:99px}.Pet-Hedgehog-Desert{background-image:url(spritesmith-main-9.png);background-position:-492px -300px;width:81px;height:99px}.Pet-Hedgehog-Golden{background-image:url(spritesmith-main-9.png);background-position:0 -400px;width:81px;height:99px}.Pet-Hedgehog-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -400px;width:81px;height:99px}.Pet-Hedgehog-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -400px;width:81px;height:99px}.Pet-Hedgehog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -400px;width:81px;height:99px}.Pet-Hedgehog-White{background-image:url(spritesmith-main-9.png);background-position:-328px -400px;width:81px;height:99px}.Pet-Hedgehog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-410px -400px;width:81px;height:99px}.Pet-Horse-Base{background-image:url(spritesmith-main-9.png);background-position:-492px -400px;width:81px;height:99px}.Pet-Horse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-574px 0;width:81px;height:99px}.Pet-Horse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -100px;width:81px;height:99px}.Pet-Horse-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -200px;width:81px;height:99px}.Pet-Horse-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -300px;width:81px;height:99px}.Pet-Horse-Red{background-image:url(spritesmith-main-9.png);background-position:-574px -400px;width:81px;height:99px}.Pet-Horse-Shade{background-image:url(spritesmith-main-9.png);background-position:0 -500px;width:81px;height:99px}.Pet-Horse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px -500px;width:81px;height:99px}.Pet-Horse-White{background-image:url(spritesmith-main-9.png);background-position:-164px -500px;width:81px;height:99px}.Pet-Horse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-246px -500px;width:81px;height:99px}.Pet-JackOLantern-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -500px;width:81px;height:99px}.Pet-LionCub-Base{background-image:url(spritesmith-main-9.png);background-position:-410px -500px;width:81px;height:99px}.Pet-LionCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -500px;width:81px;height:99px}.Pet-LionCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -500px;width:81px;height:99px}.Pet-LionCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-656px 0;width:81px;height:99px}.Pet-LionCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -100px;width:81px;height:99px}.Pet-LionCub-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -200px;width:81px;height:99px}.Pet-LionCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -300px;width:81px;height:99px}.Pet-LionCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-656px -400px;width:81px;height:99px}.Pet-LionCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px -500px;width:81px;height:99px}.Pet-LionCub-White{background-image:url(spritesmith-main-9.png);background-position:0 -600px;width:81px;height:99px}.Pet-LionCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-82px -600px;width:81px;height:99px}.Pet-Mammoth-Base{background-image:url(spritesmith-main-9.png);background-position:-164px -600px;width:81px;height:99px}.Pet-MantisShrimp-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -600px;width:81px;height:99px}.Pet-Octopus-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -600px;width:81px;height:99px}.Pet-Octopus-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-410px -600px;width:81px;height:99px}.Pet-Octopus-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -600px;width:81px;height:99px}.Pet-Octopus-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -600px;width:81px;height:99px}.Pet-Octopus-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -600px;width:81px;height:99px}.Pet-Octopus-Red{background-image:url(spritesmith-main-9.png);background-position:-738px 0;width:81px;height:99px}.Pet-Octopus-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px -100px;width:81px;height:99px}.Pet-Octopus-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -200px;width:81px;height:99px}.Pet-Octopus-White{background-image:url(spritesmith-main-9.png);background-position:-738px -300px;width:81px;height:99px}.Pet-Octopus-Zombie{background-image:url(spritesmith-main-9.png);background-position:-738px -400px;width:81px;height:99px}.Pet-Owl-Base{background-image:url(spritesmith-main-9.png);background-position:-738px -500px;width:81px;height:99px}.Pet-Owl-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -600px;width:81px;height:99px}.Pet-Owl-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 -700px;width:81px;height:99px}.Pet-Owl-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px -700px;width:81px;height:99px}.Pet-Owl-Golden{background-image:url(spritesmith-main-9.png);background-position:-164px -700px;width:81px;height:99px}.Pet-Owl-Red{background-image:url(spritesmith-main-9.png);background-position:-246px -700px;width:81px;height:99px}.Pet-Owl-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -700px;width:81px;height:99px}.Pet-Owl-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -700px;width:81px;height:99px}.Pet-Owl-White{background-image:url(spritesmith-main-9.png);background-position:-492px -700px;width:81px;height:99px}.Pet-Owl-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px -700px;width:81px;height:99px}.Pet-PandaCub-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -700px;width:81px;height:99px}.Pet-PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -700px;width:81px;height:99px}.Pet-PandaCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-820px 0;width:81px;height:99px}.Pet-PandaCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-820px -100px;width:81px;height:99px}.Pet-PandaCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-820px -200px;width:81px;height:99px}.Pet-PandaCub-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -300px;width:81px;height:99px}.Pet-PandaCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -400px;width:81px;height:99px}.Pet-PandaCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-820px -500px;width:81px;height:99px}.Pet-PandaCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-820px -600px;width:81px;height:99px}.Pet-PandaCub-White{background-image:url(spritesmith-main-9.png);background-position:-820px -700px;width:81px;height:99px}.Pet-PandaCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -800px;width:81px;height:99px}.Pet-Parrot-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -800px;width:81px;height:99px}.Pet-Parrot-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -800px;width:81px;height:99px}.Pet-Parrot-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px -800px;width:81px;height:99px}.Pet-Parrot-Desert{background-image:url(spritesmith-main-9.png);background-position:-328px -800px;width:81px;height:99px}.Pet-Parrot-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -800px;width:81px;height:99px}.Pet-Parrot-Red{background-image:url(spritesmith-main-9.png);background-position:-492px -800px;width:81px;height:99px}.Pet-Parrot-Shade{background-image:url(spritesmith-main-9.png);background-position:-574px -800px;width:81px;height:99px}.Pet-Parrot-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-656px -800px;width:81px;height:99px}.Pet-Parrot-White{background-image:url(spritesmith-main-9.png);background-position:-738px -800px;width:81px;height:99px}.Pet-Parrot-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -800px;width:81px;height:99px}.Pet-Penguin-Base{background-image:url(spritesmith-main-9.png);background-position:-902px 0;width:81px;height:99px}.Pet-Penguin-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -100px;width:81px;height:99px}.Pet-Penguin-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-902px -200px;width:81px;height:99px}.Pet-Penguin-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -300px;width:81px;height:99px}.Pet-Penguin-Golden{background-image:url(spritesmith-main-9.png);background-position:-902px -400px;width:81px;height:99px}.Pet-Penguin-Red{background-image:url(spritesmith-main-9.png);background-position:-902px -500px;width:81px;height:99px}.Pet-Penguin-Shade{background-image:url(spritesmith-main-9.png);background-position:-902px -600px;width:81px;height:99px}.Pet-Penguin-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-902px -700px;width:81px;height:99px}.Pet-Penguin-White{background-image:url(spritesmith-main-9.png);background-position:-902px -800px;width:81px;height:99px}.Pet-Penguin-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px 0;width:81px;height:99px}.Pet-Phoenix-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -100px;width:81px;height:99px}.Pet-Rat-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -200px;width:81px;height:99px}.Pet-Rat-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -300px;width:81px;height:99px}.Pet-Rat-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -400px;width:81px;height:99px}.Pet-Rat-Desert{background-image:url(spritesmith-main-9.png);background-position:-984px -500px;width:81px;height:99px}.Pet-Rat-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -600px;width:81px;height:99px}.Pet-Rat-Red{background-image:url(spritesmith-main-9.png);background-position:-984px -700px;width:81px;height:99px}.Pet-Rat-Shade{background-image:url(spritesmith-main-9.png);background-position:-984px -800px;width:81px;height:99px}.Pet-Rat-Skeleton{background-image:url(spritesmith-main-9.png);background-position:0 -900px;width:81px;height:99px}.Pet-Rat-White{background-image:url(spritesmith-main-9.png);background-position:0 0;width:81px;height:99px}.Pet-Rat-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px -900px;width:81px;height:99px}.Pet-Rock-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -900px;width:81px;height:99px}.Pet-Rock-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-328px -900px;width:81px;height:99px}.Pet-Rock-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-410px -900px;width:81px;height:99px}.Pet-Rock-Desert{background-image:url(spritesmith-main-9.png);background-position:-492px -900px;width:81px;height:99px}.Pet-Rock-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -900px;width:81px;height:99px}.Pet-Rock-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -900px;width:81px;height:99px}.Pet-Rock-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px -900px;width:81px;height:99px}.Pet-Rock-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-820px -900px;width:81px;height:99px}.Pet-Rock-White{background-image:url(spritesmith-main-9.png);background-position:-902px -900px;width:81px;height:99px}.Pet-Rock-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px -900px;width:81px;height:99px}.Pet-Rooster-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px 0;width:81px;height:99px}.Pet-Rooster-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1066px -100px;width:81px;height:99px}.Pet-Rooster-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -200px;width:81px;height:99px}.Pet-Rooster-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -300px;width:81px;height:99px}.Pet-Rooster-Golden{background-image:url(spritesmith-main-9.png);background-position:-1066px -400px;width:81px;height:99px}.Pet-Rooster-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -500px;width:81px;height:99px}.Pet-Rooster-Shade{background-image:url(spritesmith-main-9.png);background-position:-1066px -600px;width:81px;height:99px}.Pet-Rooster-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1066px -700px;width:81px;height:99px}.Pet-Rooster-White{background-image:url(spritesmith-main-9.png);background-position:-1066px -800px;width:81px;height:99px}.Pet-Rooster-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1066px -900px;width:81px;height:99px}.Pet-Seahorse-Base{background-image:url(spritesmith-main-9.png);background-position:0 -1000px;width:81px;height:99px}.Pet-Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -1000px;width:81px;height:99px}.Pet-Seahorse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -1000px;width:81px;height:99px}.Pet-Seahorse-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -1000px;width:81px;height:99px}.Pet-Seahorse-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -1000px;width:81px;height:99px}.Pet-Seahorse-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -1000px;width:81px;height:99px}.Pet-Seahorse-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -1000px;width:81px;height:99px}.Pet-Seahorse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -1000px;width:81px;height:99px}.Pet-Seahorse-White{background-image:url(spritesmith-main-9.png);background-position:-656px -1000px;width:81px;height:99px}.Pet-Seahorse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-738px -1000px;width:81px;height:99px}.Pet-Sheep-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -1000px;width:81px;height:99px}.Pet-Sheep-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -1000px;width:81px;height:99px}.Pet-Sheep-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -1000px;width:81px;height:99px}.Pet-Sheep-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -1000px;width:81px;height:99px}.Pet-Sheep-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px 0;width:81px;height:99px}.Pet-Sheep-Red{background-image:url(spritesmith-main-9.png);background-position:-1148px -100px;width:81px;height:99px}.Pet-Sheep-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -200px;width:81px;height:99px}.Pet-Sheep-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1148px -300px;width:81px;height:99px}.Pet-Sheep-White{background-image:url(spritesmith-main-9.png);background-position:-1148px -400px;width:81px;height:99px}.Pet-Sheep-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -500px;width:81px;height:99px}.Pet-Slime-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -600px;width:81px;height:99px}.Pet-Slime-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px -700px;width:81px;height:99px}.Pet-Slime-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1148px -800px;width:81px;height:99px}.Pet-Slime-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -900px;width:81px;height:99px}.Pet-Slime-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -1000px;width:81px;height:99px}.Pet-Slime-Red{background-image:url(spritesmith-main-9.png);background-position:0 -1100px;width:81px;height:99px}.Pet-Slime-Shade{background-image:url(spritesmith-main-9.png);background-position:-82px -1100px;width:81px;height:99px}.Pet-Slime-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-164px -1100px;width:81px;height:99px}.Pet-Slime-White{background-image:url(spritesmith-main-9.png);background-position:-246px -1100px;width:81px;height:99px}.Pet-Slime-Zombie{background-image:url(spritesmith-main-9.png);background-position:-328px -1100px;width:81px;height:99px}.Pet-Spider-Base{background-image:url(spritesmith-main-9.png);background-position:-410px -1100px;width:81px;height:99px}.Pet-Spider-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -1100px;width:81px;height:99px}.Pet-Spider-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -1100px;width:81px;height:99px}.Pet-Spider-Desert{background-image:url(spritesmith-main-9.png);background-position:-656px -1100px;width:81px;height:99px}.Pet-Spider-Golden{background-image:url(spritesmith-main-9.png);background-position:-738px -1100px;width:81px;height:99px}.Pet-Spider-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -1100px;width:81px;height:99px}.Pet-Spider-Shade{background-image:url(spritesmith-main-9.png);background-position:-902px -1100px;width:81px;height:99px}.Pet-Spider-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-984px -1100px;width:81px;height:99px}.Pet-Spider-White{background-image:url(spritesmith-main-9.png);background-position:-1066px -1100px;width:81px;height:99px}.Pet-Spider-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -1100px;width:81px;height:99px}.Pet-TRex-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px 0;width:81px;height:99px}.Pet-TRex-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1230px -100px;width:81px;height:99px}.Pet-TRex-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1230px -200px;width:81px;height:99px}.Pet-TRex-Desert{background-image:url(spritesmith-main-9.png);background-position:-1230px -300px;width:81px;height:99px}.Pet-TRex-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -400px;width:81px;height:99px}.Pet-TRex-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -500px;width:81px;height:99px}.Pet-TRex-Shade{background-image:url(spritesmith-main-9.png);background-position:-1230px -600px;width:81px;height:99px}.Pet-TRex-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px -700px;width:81px;height:99px}.Pet-TRex-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -800px;width:81px;height:99px}.Pet-TRex-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1230px -900px;width:81px;height:99px}.Pet-Tiger-Veteran{background-image:url(spritesmith-main-9.png);background-position:-1230px -1000px;width:81px;height:99px}.Pet-TigerCub-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px -1100px;width:81px;height:99px}.Pet-TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -1200px;width:81px;height:99px}.Pet-TigerCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -1200px;width:81px;height:99px}.Pet-TigerCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -1200px;width:81px;height:99px}.Pet-TigerCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -1200px;width:81px;height:99px}.Pet-TigerCub-Red{background-image:url(spritesmith-main-9.png);background-position:-328px -1200px;width:81px;height:99px}.Pet-TigerCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -1200px;width:81px;height:99px}.Pet-TigerCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-492px -1200px;width:81px;height:99px}.Pet-TigerCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-574px -1200px;width:81px;height:99px}.Pet-TigerCub-White{background-image:url(spritesmith-main-9.png);background-position:-656px -1200px;width:81px;height:99px}.Pet-TigerCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-738px -1200px;width:81px;height:99px}.Pet-Turkey-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -1200px;width:81px;height:99px}.Pet-Whale-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -1200px;width:81px;height:99px}.Pet-Whale-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -1200px;width:81px;height:99px}.Pet-Whale-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -1200px;width:81px;height:99px}.Pet-Whale-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -1200px;width:81px;height:99px}.Pet-Whale-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -1200px;width:81px;height:99px}.Pet-Whale-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px 0;width:81px;height:99px}.Pet-Whale-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -100px;width:81px;height:99px}.Pet-Whale-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -200px;width:81px;height:99px}.Pet-Whale-White{background-image:url(spritesmith-main-9.png);background-position:-1312px -300px;width:81px;height:99px}.Pet-Whale-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1312px -400px;width:81px;height:99px}.Pet-Wolf-Base{background-image:url(spritesmith-main-9.png);background-position:-1312px -500px;width:81px;height:99px}.Pet-Wolf-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1312px -600px;width:81px;height:99px}.Pet-Wolf-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px -700px;width:81px;height:99px}.Pet-Wolf-Desert{background-image:url(spritesmith-main-9.png);background-position:-1312px -800px;width:81px;height:99px}.Pet-Wolf-Golden{background-image:url(spritesmith-main-9.png);background-position:-1312px -900px;width:81px;height:99px}.Pet-Wolf-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px -1000px;width:81px;height:99px}.Pet-Wolf-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -1100px;width:81px;height:99px}.Pet-Wolf-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -1200px;width:81px;height:99px}.Pet-Wolf-Spooky{background-image:url(spritesmith-main-9.png);background-position:-1394px 0;width:81px;height:99px}.Pet-Wolf-Veteran{background-image:url(spritesmith-main-9.png);background-position:-1394px -100px;width:81px;height:99px}.Pet-Wolf-White{background-image:url(spritesmith-main-9.png);background-position:-1394px -200px;width:81px;height:99px}.Pet-Wolf-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -300px;width:81px;height:99px}.Pet_HatchingPotion_Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -452px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1394px -660px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1394px -504px;width:48px;height:51px}.Pet_HatchingPotion_Desert{background-image:url(spritesmith-main-9.png);background-position:-1394px -556px;width:48px;height:51px}.Pet_HatchingPotion_Golden{background-image:url(spritesmith-main-9.png);background-position:-1394px -608px;width:48px;height:51px}.Pet_HatchingPotion_Red{background-image:url(spritesmith-main-9.png);background-position:-1394px -400px;width:48px;height:51px}.Pet_HatchingPotion_Shade{background-image:url(spritesmith-main-9.png);background-position:-1394px -712px;width:48px;height:51px}.Pet_HatchingPotion_Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -764px;width:48px;height:51px}.Pet_HatchingPotion_Spooky{background-image:url(spritesmith-main-9.png);background-position:-1394px -816px;width:48px;height:51px}.Pet_HatchingPotion_White{background-image:url(spritesmith-main-9.png);background-position:-1394px -868px;width:48px;height:51px}.Pet_HatchingPotion_Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -920px;width:48px;height:51px}.head_special_0,.weapon_special_0{width:105px;height:105px;margin-left:-3px;margin-top:-18px}.broad_armor_special_0,.shield_special_0,.slim_armor_special_0{width:90px;height:90px}.weapon_special_critical{background:url(/common/img/sprites/backer-only/weapon_special_critical.gif) no-repeat;width:90px;height:90px;margin-left:-12px;margin-top:12px}.weapon_special_1{margin-left:-12px}.broad_armor_special_1,.head_special_1,.slim_armor_special_1{width:90px;height:90px}.head_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeHelmet.gif) no-repeat}.head_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalHelmet.gif) no-repeat;margin-top:3px}.broad_armor_special_0,.slim_armor_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeArmor.gif) no-repeat}.broad_armor_special_1,.slim_armor_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalArmor.gif) no-repeat}.shield_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Shield-TormentedSkull.gif) no-repeat}.weapon_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Weapon-DarkSoulsBlade.gif) no-repeat}.Pet-Wolf-Cerberus{width:105px;height:72px;background:url(/common/img/sprites/backer-only/BackerOnly-Pet-CerberusPup.gif) no-repeat}.npc_ian{background:url(/common/img/sprites/npc_ian.gif) no-repeat;width:78px;height:135px}.quest_burnout{background:url(/common/img/sprites/quest_burnout.gif) no-repeat;width:219px;height:249px}.Gems{display:inline-block;margin-right:5px;border-style:none;margin-left:0;margin-top:2px}.inline-gems{vertical-align:middle;margin-left:0;display:inline-block}.customize-menu .locked{background-color:#727272}.achievement{float:left;clear:right;margin-right:10px}.multi-achievement{margin:auto;padding-left:.5em;padding-right:.5em}[class*=Mount_Body_],[class*=Mount_Head_]{margin-top:18px}.Pet_Currency_Gem{margin-top:5px;margin-bottom:5px} \ No newline at end of file diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 8264f91321..3f2f469f29 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -368,13 +368,13 @@ gulp.task('test:api-v3:safe', ['test:prepare:server'], (done) => { gulp.task('test', [ 'lint', - 'test:e2e:safe', + //'test:e2e:safe', 'test:common:safe', // 'test:content:safe', 'test:server_side:safe', - 'test:karma:safe', - 'test:api-legacy:safe', - 'test:api-v2:safe', + //'test:karma:safe', + //'test:api-legacy:safe', + //'test:api-v2:safe', 'test:api-v3:safe', ], () => { let totals = [0,0,0]; diff --git a/website/src/server.js b/website/src/server.js index 5d44dcd1b0..6c626ce4b0 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -108,6 +108,7 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { // Mount middlewares for the new app require('./middlewares/api-v3/index')(newApp); + /* OLD APP IS DISABLED UNTIL COMPATIBLE WITH NEW MODELS //require('./middlewares/apiThrottle')(oldApp); oldApp.use(require('./middlewares/domain')(server,mongoose)); if (!isProd && !DISABLE_LOGGING) oldApp.use(require('morgan')("dev")); @@ -168,7 +169,8 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { oldApp.use(express['static'](publicDir)); oldApp.use(require('./middlewares/errorHandler')); - + */ + server.on('request', app); server.listen(app.get("port"), function() { return logging.info("Express server listening on port " + app.get("port")); From 1eadceea3d5ce88e01c05e1199b28a119732c09e Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 7 Nov 2015 09:25:43 -0600 Subject: [PATCH 033/976] Add status to default res --- test/helpers/api-unit.helper.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 5aa2816677..30bdab8fc3 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -21,6 +21,7 @@ export function generateGroup(options={}) { export function generateRes(options={}) { let defaultRes = { send: sandbox.stub(), + status: sandbox.stub().returnsThis(), json: sandbox.stub(), locals: { user: generateUser(options.localsUser), From b323c3b5e97d5388d81a50ca88a072cc68dcb660 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 7 Nov 2015 09:26:13 -0600 Subject: [PATCH 034/976] Use res and req generators in error handler test --- .../v3/unit/middlewares/errorHandler.test.js | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 39e72b2e45..7ce86127e5 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -1,33 +1,29 @@ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; + import errorHandler from '../../../../../website/src/middlewares/api-v3/errorHandler'; import { BadRequest } from '../../../../../website/src/libs/api-v3/errors'; import logger from '../../../../../website/src/libs/api-v3/logger'; describe('errorHandler', () => { - let res, req; + let res, req, next; beforeEach(() => { - res = { - status: sinon.stub().returnsThis(), - json: sinon.stub(), - }; - req = { - originalUrl: 'foo', - headers: {}, - body: {}, - }; + res = generateRes(); + req = generateReq(); + next = generateNext(); - sinon.stub(logger, 'error'); - }); - - afterEach(() => { - logger.error.restore(); + sandbox.stub(logger, 'error'); }); it('sends internal server error if error is not a CustomError', () => { let error = new Error(); - errorHandler(error, req, res); + errorHandler(error, req, res, next); expect(res.status).to.be.calledOnce; expect(res.json).to.be.calledOnce; @@ -42,7 +38,7 @@ describe('errorHandler', () => { it('sends CustomError', () => { let error = new BadRequest(); - errorHandler(error, req, res); + errorHandler(error, req, res, next); expect(res.status).to.be.calledOnce; expect(res.json).to.be.calledOnce; @@ -57,7 +53,7 @@ describe('errorHandler', () => { it('logs error', () => { let error = new BadRequest(); - errorHandler(error, req, res); + errorHandler(error, req, res, next); expect(logger.error).to.be.calledOnce; expect(logger.error).to.be.calledWith(error.stack, { @@ -68,7 +64,6 @@ describe('errorHandler', () => { }); it('does not send error if error is not defined', () => { - let next = sinon.stub(); errorHandler(null, req, res, next); expect(next).to.be.calledOnce; From f26737ab793542d0f017c683447fa9fc16cf0e4f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 7 Nov 2015 16:28:52 +0100 Subject: [PATCH 035/976] use q for mongoose promises and switch to promises from callbacks in queries --- website/src/middlewares/api-v3/auth.js | 19 +++++++++++-------- website/src/server.js | 2 ++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 65838a9a84..0de7474b6c 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -4,7 +4,7 @@ import { } from '../../libs/api-v3/errors'; import { - UserModel as User, + model as User, } from '../../models/user'; // TODO use i18n @@ -27,12 +27,12 @@ export function authWithHeaders (req, res, next) { return next(new NotAuthorized(missingAuthHeaders)); } - // TODO use promises? User.findOne({ _id: userId, apiToken, - }, (err, user) => { - if (err) return next(err); + }) + .exec() + .then((user) => { if (!user) return next(new NotAuthorized(userNotFound)); // TODO better handling for this case @@ -42,7 +42,8 @@ export function authWithHeaders (req, res, next) { // TODO use either session/cookie or headers, not both req.session.userId = user._id; return next(); - }); + }) + .catch(next); } // Authenticate a request through a valid session @@ -54,11 +55,13 @@ export function authWithSession (req, res, next) { User.findOne({ _id: userId, - }, (err, user) => { - if (err) return next(err); + }) + .exec() + .then((user) => { if (!user) return next(new NotAuthorized(userNotFound)); res.locals.user = user; return next(); - }); + }) + .catch(next); } \ No newline at end of file diff --git a/website/src/server.js b/website/src/server.js index 6c626ce4b0..fad04b5ff1 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -39,6 +39,8 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { // ------------ MongoDB Configuration ------------ var mongoose = require('mongoose'); + // Use Q promises instead of mpromise in mongoose + mongoose.Promise = require('q'); var mongooseOptions = !isProd ? {} : { replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } } From 3559be0f83d2be36012393998fbd396ed4672f35 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 7 Nov 2015 09:42:57 -0600 Subject: [PATCH 036/976] Use toObject instead of _doc --- test/helpers/api-unit.helper.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 30bdab8fc3..6e561df076 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -11,11 +11,11 @@ afterEach(() => { }); export function generateUser(options={}) { - return new User(options)._doc; + return new User(options).toObject(); } export function generateGroup(options={}) { - return new Group(options)._doc; + return new Group(options).toObject(); } export function generateRes(options={}) { From 22f76e94793ed87e23d7f9655c0ff93919a41de5 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 7 Nov 2015 21:06:59 -0600 Subject: [PATCH 037/976] Use npm 3 for travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8419a792f3..5d18669e31 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: node_js node_js: - '4.2' before_install: - - "npm install -g npm@2" + - "npm install -g npm@3" - "npm install -g gulp" - "sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 7F0CEB10" - "echo 'deb http://downloads-distro.mongodb.org/repo/ubuntu-upstart dist 10gen' | sudo tee /etc/apt/sources.list.d/mongodb.list" From 0d7f984fcefe93383c1c62e9d717bdc3c43952ab Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 08:47:46 -0600 Subject: [PATCH 038/976] Add setupNconf function --- test/api/v3/unit/libs/setupNconf.test.js | 41 ++++++++++++++++++++++++ website/src/libs/api-v3/setupNconf.js | 14 ++++++++ 2 files changed, 55 insertions(+) create mode 100644 test/api/v3/unit/libs/setupNconf.test.js create mode 100644 website/src/libs/api-v3/setupNconf.js diff --git a/test/api/v3/unit/libs/setupNconf.test.js b/test/api/v3/unit/libs/setupNconf.test.js new file mode 100644 index 0000000000..0dc0c3dbb5 --- /dev/null +++ b/test/api/v3/unit/libs/setupNconf.test.js @@ -0,0 +1,41 @@ +import setupNconf from '../../../../../website/src/libs/api-v3/setupNconf'; + +import nconf from 'nconf'; + +describe('setupNconf', () => { + afterEach(() => { + sandbox.restore(); + }); + + it('sets up nconf to load command line arguments', () => { + sandbox.spy(nconf, 'argv'); + + setupNconf(); + + expect(nconf.argv).to.be.calledOnce; + }); + + it('sets up nconf to load environmental variables', () => { + sandbox.spy(nconf, 'env'); + + setupNconf(); + + expect(nconf.env).to.be.calledOnce; + }); + + it('sets up nconf to load variables from config file', () => { + sandbox.spy(nconf, 'file'); + + setupNconf(); + + expect(nconf.file).to.be.calledOnce; + }); + + it('sets IS_PROD variable', () => { + expect(nconf.get('IS_PROD')).to.exist; + }); + + it('sets IS_DEV variable', () => { + expect(nconf.get('IS_DEV')).to.exist; + }); +}); diff --git a/website/src/libs/api-v3/setupNconf.js b/website/src/libs/api-v3/setupNconf.js new file mode 100644 index 0000000000..ff0da651df --- /dev/null +++ b/website/src/libs/api-v3/setupNconf.js @@ -0,0 +1,14 @@ +import nconf from 'nconf'; +import { join, resolve } from 'path'; + +const PATH_TO_CONFIG = join(resolve(__dirname, '../../../../config.json')); + +export default function setupNconf () { + nconf + .argv() + .env() + .file('user', PATH_TO_CONFIG); + + nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production'); + nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development'); +} From 1ef6839eea0cf46d577410152eabaae3f09570f5 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 08:49:16 -0600 Subject: [PATCH 039/976] Add IS_PROD to logger utility --- website/src/libs/api-v3/logger.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index b94d492b68..4583610d25 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -3,12 +3,11 @@ import winston from 'winston'; import nconf from 'nconf'; -// TODO move isProd to a single location -const isProd = nconf.get('NODE_ENV') === 'production'; +const IS_PROD = nconf.get('IS_PROD'); let logger = new winston.Logger(); -if (isProd) { +if (IS_PROD) { // TODO production logging, use loggly // log errors to console too } else { From 447d4c332d8bf608f33616e835476b40c14f55f0 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 08:56:27 -0600 Subject: [PATCH 040/976] Remove extraneous nconf setup. --- website/src/libs/utils.js | 22 +++++++--------------- website/src/server.js | 15 ++++++++------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/website/src/libs/utils.js b/website/src/libs/utils.js index 1b1231e102..c8944c9ccc 100644 --- a/website/src/libs/utils.js +++ b/website/src/libs/utils.js @@ -4,8 +4,8 @@ var crypto = require('crypto'); var path = require("path"); var request = require('request'); -// Set when utils.setupConfig is run -var isProd, baseUrl; +const IS_PROD = nconf.get('IS_PROD'); +const BASE_URL = nconf.get('BASE_URL'); module.exports.sendEmail = function(mailData) { var smtpTransport = nodemailer.createTransport("SMTP",{ @@ -59,7 +59,7 @@ module.exports.txnEmail = function(mailingInfoArray, emailType, variables, perso var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray]; var variables = [ - {name: 'BASE_URL', content: baseUrl} + {name: 'BASE_URL', content: BASE_URL} ].concat(variables || []); // It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed @@ -120,7 +120,7 @@ module.exports.txnEmail = function(mailingInfoArray, emailType, variables, perso }); } - if(isProd && mailingInfoArray.length > 0){ + if(IS_PROD && mailingInfoArray.length > 0){ request({ url: nconf.get('EMAIL_SERVER:url') + '/job', method: 'POST', @@ -167,20 +167,12 @@ module.exports.analytics = { track: function() { }, trackPurchase: function() { * Load nconf and define default configuration values if config.json or ENV vars are not found */ module.exports.setupConfig = function(){ - nconf.argv() - .env() - //.file('defaults', path.join(path.resolve(__dirname, '../config.json.example'))) - .file('user', path.join(path.resolve(__dirname, './../../../config.json'))); - - if (nconf.get('NODE_ENV') === "development") + if (nconf.get('IS_DEV')) Error.stackTraceLimit = Infinity; - //if (nconf.get('NODE_ENV') === 'production') + //if (nconf.get('IS_PROD')) //require('newrelic'); - isProd = nconf.get('NODE_ENV') === 'production'; - baseUrl = nconf.get('BASE_URL'); - - var analytics = isProd && require('./analytics'); + var analytics = IS_PROD && require('./analytics'); var analyticsTokens = { amplitudeToken: nconf.get('AMPLITUDE_KEY'), googleAnalytics: nconf.get('GA_ID') diff --git a/website/src/server.js b/website/src/server.js index fad04b5ff1..bea0da1428 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -1,4 +1,5 @@ require('babel/register'); +require('./libs/api-v3/setupNconf')(); // Only do the minimal amount of work before forking just in case of a dyno restart var cluster = require("cluster"); var _ = require('lodash'); @@ -6,12 +7,12 @@ var nconf = require('nconf'); var utils = require('./libs/utils'); utils.setupConfig(); var logging = require('./libs/logging'); -var isProd = nconf.get('NODE_ENV') === 'production'; -var isDev = nconf.get('NODE_ENV') === 'development'; +var IS_PROD = nconf.get('IS_PROD'); +var IS_DEV = nconf.get('IS_DEV'); var DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); var cores = +nconf.get("WEB_CONCURRENCY") || 0; -if (cores!==0 && cluster.isMaster && (isDev || isProd)) { +if (cores!==0 && cluster.isMaster && (IS_DEV || IS_PROD)) { // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) for (var i = 0; i < cores; i += 1) { cluster.fork(); @@ -41,7 +42,7 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { var mongoose = require('mongoose'); // Use Q promises instead of mpromise in mongoose mongoose.Promise = require('q'); - var mongooseOptions = !isProd ? {} : { + var mongooseOptions = !IS_PROD ? {} : { replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } } }; @@ -113,7 +114,7 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { /* OLD APP IS DISABLED UNTIL COMPATIBLE WITH NEW MODELS //require('./middlewares/apiThrottle')(oldApp); oldApp.use(require('./middlewares/domain')(server,mongoose)); - if (!isProd && !DISABLE_LOGGING) oldApp.use(require('morgan')("dev")); + if (!IS_PROD && !DISABLE_LOGGING) oldApp.use(require('morgan')("dev")); oldApp.use(require('compression')()); oldApp.set("views", __dirname + "/../views"); oldApp.set("view engine", "jade"); @@ -161,7 +162,7 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { oldApp.use('/export', require('./routes/dataexport')); require('./routes/api-v2/swagger')(swagger, v2); - var maxAge = isProd ? 31536000000 : 0; + var maxAge = IS_PROD ? 31536000000 : 0; // Cache emojis without copying them to build, they are too many oldApp.use(express['static'](path.join(__dirname, "/../build"), { maxAge: maxAge })); oldApp.use('/common/dist', express['static'](publicDir + "/../../common/dist", { maxAge: maxAge })); @@ -172,7 +173,7 @@ if (cores!==0 && cluster.isMaster && (isDev || isProd)) { oldApp.use(require('./middlewares/errorHandler')); */ - + server.on('request', app); server.listen(app.get("port"), function() { return logging.info("Express server listening on port " + app.get("port")); From 9451e7239b72e8b6348bac04c9d648debad8cf23 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 09:00:07 -0600 Subject: [PATCH 041/976] Add nconf setup to unit test helper --- test/helpers/globals.helper.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 32ec18f79e..4a166ea184 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -8,5 +8,9 @@ global.sinon = require("sinon"); chai.use(require("sinon-chai")) chai.use(require("chai-as-promised")); global.expect = chai.expect - global.sandbox = sinon.sandbox.create(); + +//------------------------------ +// Load nconf for unit tests +//------------------------------ +require('../../website/src/libs/api-v3/setupNconf')(); From 832e837f6a26c1f14bed0003cc89fcb893a2e28b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 09:07:13 -0600 Subject: [PATCH 042/976] Simplify nconf test --- test/api/v3/unit/libs/setupNconf.test.js | 28 ++++++++---------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/test/api/v3/unit/libs/setupNconf.test.js b/test/api/v3/unit/libs/setupNconf.test.js index 0dc0c3dbb5..e34d87c16b 100644 --- a/test/api/v3/unit/libs/setupNconf.test.js +++ b/test/api/v3/unit/libs/setupNconf.test.js @@ -3,31 +3,21 @@ import setupNconf from '../../../../../website/src/libs/api-v3/setupNconf'; import nconf from 'nconf'; describe('setupNconf', () => { - afterEach(() => { - sandbox.restore(); - }); - - it('sets up nconf to load command line arguments', () => { + before(() => { sandbox.spy(nconf, 'argv'); - - setupNconf(); - - expect(nconf.argv).to.be.calledOnce; - }); - - it('sets up nconf to load environmental variables', () => { sandbox.spy(nconf, 'env'); - - setupNconf(); - - expect(nconf.env).to.be.calledOnce; - }); - - it('sets up nconf to load variables from config file', () => { sandbox.spy(nconf, 'file'); setupNconf(); + }); + after(() => { + sandbox.restore(); + }); + + it('sets up nconf', () => { + expect(nconf.argv).to.be.calledOnce; + expect(nconf.env).to.be.calledOnce; expect(nconf.file).to.be.calledOnce; }); From 4b60698527416ba099288249baa2f0f76b249f83 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 8 Nov 2015 16:11:55 +0100 Subject: [PATCH 043/976] migrate user model to new code style and es6 --- .eslintrc | 4 +- tasks/gulp-eslint.js | 5 +- website/src/models/user.js | 797 +++++++++++++++++++------------------ 3 files changed, 414 insertions(+), 392 deletions(-) diff --git a/.eslintrc b/.eslintrc index 2b2ebd92ab..69c945431e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -22,8 +22,6 @@ "no-loop-func": 2, "no-implicit-coercion": 2, "no-implied-eval": 2, - "no-invalid-this": 2, - "no-magic-numbers": 2, "no-native-reassign": 2, "no-new-func": 2, "no-new-wrappers": 2, @@ -97,7 +95,7 @@ "no-nested-ternary": 2, "one-var": [2, "never"], "operator-linebreak": [2, "after"], - "quote-props": [2, "as-needed", { "keywords": true }], + "quote-props": [2, "as-needed"], "semi-spacing": [2, {"before": false, "after": true}], "space-after-keywords": 2, "space-before-blocks": 2, diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 7a30f35ca1..eaee185f5c 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -9,7 +9,10 @@ import eslint from 'gulp-eslint'; gulp.task('lint:server', () => { // Ignore .coffee files return gulp - .src(['./website/src/**/api-v3/**/*.js']) + .src([ + './website/src/**/api-v3/**/*.js', + './website/src/models/user.js' + ]) .pipe(eslint()) .pipe(eslint.format()) .pipe(eslint.failAfterError()); diff --git a/website/src/models/user.js b/website/src/models/user.js index e36bedef11..47b790785f 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -1,39 +1,52 @@ -// User.js -// ======= -// Defines the user data model (schema) for use via the API. +// User schema and model +import mongoose from 'mongoose'; +import shared from '../../../common'; +import _ from 'lodash'; +import moment from 'moment'; +import TaskSchemas from './task'; +// import {model as Challenge} from './challenge'; -// Dependencies -// ------------ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); -var TaskSchemas = require('./task'); -var Challenge = require('./challenge').model; -var moment = require('moment'); +let Schema = mongoose.Schema; -// User Schema -// ----------- - -var UserSchema = new Schema({ - // ### UUID and API Token +// User schema definition +export let schema = new Schema({ + // The user _id, stored as a string + // TODO validation _id: { type: String, - 'default': shared.uuid + default: shared.uuid, }, + // TODO validation apiToken: { type: String, - 'default': shared.uuid + default: shared.uuid, + }, + + auth: { + blocked: Boolean, + facebook: Schema.Types.Mixed, // TODO validate + local: { + email: String, + hashed_password: String, + salt: String, + username: String, + // Store a lowercase version of username to check for duplicates + lowerCaseUsername: String, + }, + timestamps: { + created: {type: Date, default: Date.now}, + loggedin: {type: Date, default: Date.now}, + }, }, - // ### Mongoose Update Object // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which // have been updated (http://goo.gl/gQLz41), but we want *every* update - _v: { type: Number, 'default': 0 }, + _v: { type: Number, default: 0 }, + achievements: { originalUser: Boolean, habitSurveys: Number, - ultimateGearSets: Schema.Types.Mixed, + ultimateGearSets: Schema.Types.Mixed, // TODO remove, use dictionary? beastMaster: Boolean, beastMasterCount: Number, mountMaster: Boolean, @@ -47,7 +60,7 @@ var UserSchema = new Schema({ seafoam: Number, streak: Number, challenges: Array, - quests: Schema.Types.Mixed, + quests: Schema.Types.Mixed, // TODO remove, use dictionary? rebirths: Number, rebirthLevel: Number, perfect: Number, @@ -57,183 +70,178 @@ var UserSchema = new Schema({ nye: Number, habiticaDays: Number, greeting: Number, - thankyou: Number - }, - auth: { - blocked: Boolean, - facebook: Schema.Types.Mixed, - local: { - email: String, - hashed_password: String, - salt: String, - username: String, - lowerCaseUsername: String // Store a lowercase version of username to check for duplicates - }, - timestamps: { - created: {type: Date,'default': Date.now}, - loggedin: {type: Date,'default': Date.now} - } + thankyou: Number, }, backer: { tier: Number, npc: String, - tokensApplied: Boolean + tokensApplied: Boolean, }, contributor: { - level: Number, // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801 + // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801 + // TODO validate + level: Number, admin: Boolean, sudo: Boolean, - text: String, // Artisan, Friend, Blacksmith, etc - contributions: String, // a markdown textarea to list their contributions + links - critical: String + // Artisan, Friend, Blacksmith, etc + text: String, + // a markdown textarea to list their contributions + links + contributions: String, + critical: String, }, - balance: {type: Number, 'default':0}, - filters: {type: Schema.Types.Mixed, 'default': {}}, + balance: {type: Number, default: 0}, + filters: {type: Schema.Types.Mixed, default: {}}, // TODO dictionary purchased: { - ads: {type: Boolean, 'default': false}, - skin: {type: Schema.Types.Mixed, 'default': {}}, // eg, {skeleton: true, pumpkin: true, eb052b: true} - hair: {type: Schema.Types.Mixed, 'default': {}}, - shirt: {type: Schema.Types.Mixed, 'default': {}}, - background: {type: Schema.Types.Mixed, 'default': {}}, - txnCount: {type: Number, 'default':0}, + ads: {type: Boolean, default: false}, + // eg, {skeleton: true, pumpkin: true, eb052b: true} + // TODO dictionary + skin: {type: Schema.Types.Mixed, default: {}}, + hair: {type: Schema.Types.Mixed, default: {}}, + shirt: {type: Schema.Types.Mixed, default: {}}, + background: {type: Schema.Types.Mixed, default: {}}, + txnCount: {type: Number, default: 0}, mobileChat: Boolean, plan: { planId: String, - paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} + paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} customerId: String, // Billing Agreement Id in case of Amazon Payments dateCreated: Date, dateTerminated: Date, dateUpdated: Date, - extraMonths: {type:Number, 'default':0}, - gemsBought: {type: Number, 'default': 0}, - mysteryItems: {type: Array, 'default': []}, + extraMonths: {type: Number, default: 0}, + gemsBought: {type: Number, default: 0}, + mysteryItems: {type: Array, default: []}, lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date consecutive: { - count: {type:Number, 'default':0}, - offset: {type:Number, 'default':0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 - gemCapExtra: {type:Number, 'default':0}, - trinkets: {type:Number, 'default':0} - } - } + count: {type: Number, default: 0}, + offset: {type: Number, default: 0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 + gemCapExtra: {type: Number, default: 0}, + trinkets: {type: Number, default: 0}, + }, + }, }, flags: { - customizationsNotification: {type: Boolean, 'default': false}, - showTour: {type: Boolean, 'default': true}, + customizationsNotification: {type: Boolean, default: false}, + showTour: {type: Boolean, default: true}, tour: { // -1 indicates "uninitiated", -2 means "complete", any other number is the current tour step (0-index) - intro: {type: Number, 'default': -1}, - classes: {type: Number, 'default': -1}, - stats: {type: Number, 'default': -1}, - tavern: {type: Number, 'default': -1}, - party: {type: Number, 'default': -1}, - guilds: {type: Number, 'default': -1}, - challenges: {type: Number, 'default': -1}, - market: {type: Number, 'default': -1}, - pets: {type: Number, 'default': -1}, - mounts: {type: Number, 'default': -1}, - hall: {type: Number, 'default': -1}, - equipment: {type: Number, 'default': -1} + intro: {type: Number, default: -1}, + classes: {type: Number, default: -1}, + stats: {type: Number, default: -1}, + tavern: {type: Number, default: -1}, + party: {type: Number, default: -1}, + guilds: {type: Number, default: -1}, + challenges: {type: Number, default: -1}, + market: {type: Number, default: -1}, + pets: {type: Number, default: -1}, + mounts: {type: Number, default: -1}, + hall: {type: Number, default: -1}, + equipment: {type: Number, default: -1}, }, tutorial: { common: { - habits: {type: Boolean, 'default': false}, - dailies: {type: Boolean, 'default': false}, - todos: {type: Boolean, 'default': false}, - rewards: {type: Boolean, 'default': false}, - party: {type: Boolean, 'default': false}, - pets: {type: Boolean, 'default': false}, - gems: {type: Boolean, 'default': false}, - skills: {type: Boolean, 'default': false}, - classes: {type: Boolean, 'default': false}, - tavern: {type: Boolean, 'default': false}, - equipment: {type: Boolean, 'default': false}, - items: {type: Boolean, 'default': false}, + habits: {type: Boolean, default: false}, + dailies: {type: Boolean, default: false}, + todos: {type: Boolean, default: false}, + rewards: {type: Boolean, default: false}, + party: {type: Boolean, default: false}, + pets: {type: Boolean, default: false}, + gems: {type: Boolean, default: false}, + skills: {type: Boolean, default: false}, + classes: {type: Boolean, default: false}, + tavern: {type: Boolean, default: false}, + equipment: {type: Boolean, default: false}, + items: {type: Boolean, default: false}, }, ios: { - addTask: {type: Boolean, 'default': false}, - editTask: {type: Boolean, 'default': false}, - deleteTask: {type: Boolean, 'default': false}, - filterTask: {type: Boolean, 'default': false}, - groupPets: {type: Boolean, 'default': false}, - } + addTask: {type: Boolean, default: false}, + editTask: {type: Boolean, default: false}, + deleteTask: {type: Boolean, default: false}, + filterTask: {type: Boolean, default: false}, + groupPets: {type: Boolean, default: false}, + }, }, - dropsEnabled: {type: Boolean, 'default': false}, - itemsEnabled: {type: Boolean, 'default': false}, - newStuff: {type: Boolean, 'default': false}, - rewrite: {type: Boolean, 'default': true}, + dropsEnabled: {type: Boolean, default: false}, + itemsEnabled: {type: Boolean, default: false}, + newStuff: {type: Boolean, default: false}, + rewrite: {type: Boolean, default: true}, contributor: Boolean, - classSelected: {type: Boolean, 'default': false}, + classSelected: {type: Boolean, default: false}, mathUpdates: Boolean, - rebirthEnabled: {type: Boolean, 'default': false}, - levelDrops: {type:Schema.Types.Mixed, 'default':{}}, + rebirthEnabled: {type: Boolean, default: false}, + levelDrops: {type: Schema.Types.Mixed, default: {}}, chatRevoked: Boolean, // Used to track the status of recapture emails sent to each user, // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user - recaptureEmailsPhase: {type: Number, 'default': 0}, + recaptureEmailsPhase: {type: Number, default: 0}, // Needed to track the tip to send inside the email - weeklyRecapEmailsPhase: {type: Number, 'default': 0}, + weeklyRecapEmailsPhase: {type: Number, default: 0}, // Used to track when the next weekly recap should be sent - lastWeeklyRecap: {type: Date, 'default': Date.now}, + lastWeeklyRecap: {type: Date, default: Date.now}, // Used to enable weekly recap emails as users login lastWeeklyRecapDiscriminator: Boolean, - communityGuidelinesAccepted: {type: Boolean, 'default': false}, - cronCount: {type:Number, 'default':0}, - welcomed: {type: Boolean, 'default': false}, - armoireEnabled: {type: Boolean, 'default': false}, - armoireOpened: {type: Boolean, 'default': false}, - armoireEmpty: {type: Boolean, 'default': false}, - cardReceived: {type: Boolean, 'default': false}, - warnedLowHealth: {type: Boolean, 'default': false} + communityGuidelinesAccepted: {type: Boolean, default: false}, + cronCount: {type: Number, default: 0}, + welcomed: {type: Boolean, default: false}, + armoireEnabled: {type: Boolean, default: false}, + armoireOpened: {type: Boolean, default: false}, + armoireEmpty: {type: Boolean, default: false}, + cardReceived: {type: Boolean, default: false}, + warnedLowHealth: {type: Boolean, default: false}, }, + history: { + // TODO absolutely preen these for everyone exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined - todos: Array //[{data: Date, value: Number}] // big peformance issues if these are defined + todos: Array, // [{data: Date, value: Number}] // big peformance issues if these are defined }, invitations: { - guilds: {type: Array, 'default': []}, - party: Schema.Types.Mixed + guilds: {type: Array, default: []}, + party: Schema.Types.Mixed, // TODO dictionary }, + items: { gear: { - owned: _.transform(shared.content.gear.flat, function(m,v,k){ + owned: _.transform(shared.content.gear.flat, (m, v, k) => { m[v.key] = {type: Boolean}; - if (v.key.match(/[armor|head|shield]_warrior_0/)) - m[v.key]['default'] = true; + if (v.key.match(/[armor|head|shield]_warrior_0/)) { + m[v.key].default = true; + } }), equipped: { weapon: String, - armor: {type: String, 'default': 'armor_base_0'}, - head: {type: String, 'default': 'head_base_0'}, - shield: {type: String, 'default': 'shield_base_0'}, + armor: {type: String, default: 'armor_base_0'}, + head: {type: String, default: 'head_base_0'}, + shield: {type: String, default: 'shield_base_0'}, back: String, headAccessory: String, eyewear: String, - body: String + body: String, }, costume: { weapon: String, - armor: {type: String, 'default': 'armor_base_0'}, - head: {type: String, 'default': 'head_base_0'}, - shield: {type: String, 'default': 'shield_base_0'}, + armor: {type: String, default: 'armor_base_0'}, + head: {type: String, default: 'head_base_0'}, + shield: {type: String, default: 'shield_base_0'}, back: String, headAccessory: String, eyewear: String, - body: String - } + body: String, + }, }, - special:{ - snowball: {type: Number, 'default': 0}, - spookDust: {type: Number, 'default': 0}, - shinySeed: {type: Number, 'default': 0}, - seafoam: {type: Number, 'default': 0}, + special: { + snowball: {type: Number, default: 0}, + spookDust: {type: Number, default: 0}, + shinySeed: {type: Number, default: 0}, + seafoam: {type: Number, default: 0}, valentine: Number, valentineReceived: Array, // array of strings, by sender name nye: Number, @@ -241,7 +249,7 @@ var UserSchema = new Schema({ greeting: Number, greetingReceived: Array, thankyou: Number, - thankyouReceived: Array + thankyouReceived: Array, }, // -------------- Animals ------------------- @@ -251,14 +259,13 @@ var UserSchema = new Schema({ // 'PandaCub-Red': 10, // Number represents "Growth Points" // etc... // } - pets: - _.defaults( + pets: _.defaults( // First transform to a 1D eggs/potions mapping - _.transform(shared.content.pets, function(m,v,k){ m[k] = Number; }), + _.transform(shared.content.pets, (m, v, k) => m[k] = Number), // Then add additional pets (quest, backer, contributor, premium) - _.transform(shared.content.questPets, function(m,v,k){ m[k] = Number; }), - _.transform(shared.content.specialPets, function(m,v,k){ m[k] = Number; }), - _.transform(shared.content.premiumPets, function(m,v,k){ m[k] = Number; }) + _.transform(shared.content.questPets, (m, v, k) => m[k] = Number), + _.transform(shared.content.specialPets, (m, v, k) => m[k] = Number), + _.transform(shared.content.premiumPets, (m, v, k) => m[k] = Number) ), currentPet: String, // Cactus-Desert @@ -266,19 +273,19 @@ var UserSchema = new Schema({ // 'PandaCub': 0, // 0 indicates "doesn't own" // 'Wolf': 5 // Number indicates "stacking" // } - eggs: _.transform(shared.content.eggs, function(m,v,k){ m[k] = Number; }), + eggs: _.transform(shared.content.eggs, (m, v, k) => m[k] = Number), // hatchingPotions: { // 'Desert': 0, // 0 indicates "doesn't own" // 'CottonCandyBlue': 5 // Number indicates "stacking" // } - hatchingPotions: _.transform(shared.content.hatchingPotions, function(m,v,k){ m[k] = Number; }), + hatchingPotions: _.transform(shared.content.hatchingPotions, (m, v, k) => m[k] = Number), // Food: { // 'Watermelon': 0, // 0 indicates "doesn't own" // 'RottenMeat': 5 // Number indicates "stacking" // } - food: _.transform(shared.content.food, function(m,v,k){ m[k] = Number; }), + food: _.transform(shared.content.food, (m, v, k) => m[k] = Number), // mounts: { // 'Wolf-Desert': true, @@ -287,12 +294,12 @@ var UserSchema = new Schema({ // } mounts: _.defaults( // First transform to a 1D eggs/potions mapping - _.transform(shared.content.pets, function(m,v,k){ m[k] = Boolean; }), + _.transform(shared.content.pets, (m, v, k) => m[k] = Boolean), // Then add quest and premium pets - _.transform(shared.content.questPets, function(m,v,k){ m[k] = Boolean; }), - _.transform(shared.content.premiumPets, function(m,v,k){ m[k] = Boolean; }), + _.transform(shared.content.questPets, (m, v, k) => m[k] = Boolean), + _.transform(shared.content.premiumPets, (m, v, k) => m[k] = Boolean), // Then add additional mounts (backer, contributor) - _.transform(shared.content.specialMounts, function(m,v,k){ m[k] = Boolean; }) + _.transform(shared.content.specialMounts, (m, v, k) => m[k] = Boolean) ), currentMount: String, @@ -300,164 +307,167 @@ var UserSchema = new Schema({ // 'boss_0': 0, // 0 indicates "doesn't own" // 'collection_honey': 5 // Number indicates "stacking" // } - quests: _.transform(shared.content.quests, function(m,v,k){ m[k] = Number; }), + quests: _.transform(shared.content.quests, (m, v, k) => m[k] = Number), lastDrop: { - date: {type: Date, 'default': Date.now}, - count: {type: Number, 'default': 0} - } + date: {type: Date, default: Date.now}, + count: {type: Number, default: 0}, + }, }, - lastCron: {type: Date, 'default': Date.now}, + lastCron: {type: Date, default: Date.now}, // {GROUP_ID: Boolean}, represents whether they have unseen chat messages - newMessages: {type: Schema.Types.Mixed, 'default': {}}, + newMessages: {type: Schema.Types.Mixed, default: {}}, party: { // id // FIXME can we use a populated doc instead of fetching party separate from user? - order: {type:String, 'default':'level'}, - orderAscending: {type:String, 'default':'ascending'}, + order: {type: String, default: 'level'}, + orderAscending: {type: String, default: 'ascending'}, quest: { key: String, progress: { - up: {type: Number, 'default': 0}, - down: {type: Number, 'default': 0}, - collect: {type: Schema.Types.Mixed, 'default': {}} // {feather:1, ingot:2} + up: {type: Number, default: 0}, + down: {type: Number, default: 0}, + collect: {type: Schema.Types.Mixed, default: {}}, // {feather:1, ingot:2} }, completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser - RSVPNeeded: {type: Boolean, 'default': false} // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled - } + RSVPNeeded: {type: Boolean, default: false}, // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled + }, }, preferences: { - dayStart: {type:Number, 'default': 0, min: 0, max: 23}, - size: {type:String, enum: ['broad','slim'], 'default': 'slim'}, + dayStart: {type: Number, default: 0, min: 0, max: 23}, + size: {type: String, enum: ['broad', 'slim'], default: 'slim'}, hair: { - color: {type: String, 'default': 'red'}, - base: {type: Number, 'default': 3}, - bangs: {type: Number, 'default': 1}, - beard: {type: Number, 'default': 0}, - mustache: {type: Number, 'default': 0}, - flower: {type: Number, 'default': 1} + color: {type: String, default: 'red'}, + base: {type: Number, default: 3}, + bangs: {type: Number, default: 1}, + beard: {type: Number, default: 0}, + mustache: {type: Number, default: 0}, + flower: {type: Number, default: 1}, }, - hideHeader: {type:Boolean, 'default':false}, - skin: {type:String, 'default':'915533'}, - shirt: {type: String, 'default': 'blue'}, + hideHeader: {type: Boolean, default: false}, + skin: {type: String, default: '915533'}, + shirt: {type: String, default: 'blue'}, timezoneOffset: Number, - sound: {type:String, 'default':'off', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme']}, + sound: {type: String, default: 'off', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme']}, language: String, automaticAllocation: Boolean, - allocationMode: {type:String, enum: ['flat','classbased','taskbased'], 'default': 'flat'}, - autoEquip: {type: Boolean, 'default': true}, + allocationMode: {type: String, enum: ['flat', 'classbased', 'taskbased'], default: 'flat'}, + autoEquip: {type: Boolean, default: true}, costume: Boolean, - dateFormat: {type: String, enum:['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], 'default': 'MM/dd/yyyy'}, - sleep: {type: Boolean, 'default': false}, - stickyHeader: {type: Boolean, 'default': true}, - disableClasses: {type: Boolean, 'default': false}, - newTaskEdit: {type: Boolean, 'default': false}, - dailyDueDefaultView: {type: Boolean, 'default': false}, - tagsCollapsed: {type: Boolean, 'default': false}, - advancedCollapsed: {type: Boolean, 'default': false}, - toolbarCollapsed: {type:Boolean, 'default':false}, + dateFormat: {type: String, enum: ['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], default: 'MM/dd/yyyy'}, + sleep: {type: Boolean, default: false}, + stickyHeader: {type: Boolean, default: true}, + disableClasses: {type: Boolean, default: false}, + newTaskEdit: {type: Boolean, default: false}, + dailyDueDefaultView: {type: Boolean, default: false}, + tagsCollapsed: {type: Boolean, default: false}, + advancedCollapsed: {type: Boolean, default: false}, + toolbarCollapsed: {type: Boolean, default: false}, background: String, - displayInviteToPartyWhenPartyIs1: { type:Boolean, 'default':true}, - webhooks: {type: Schema.Types.Mixed, 'default': {}}, - // For this fields make sure to use strict comparison when searching for falsey values (=== false) + displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true}, + webhooks: {type: Schema.Types.Mixed, default: {}}, + // For the following fields make sure to use strict comparison when searching for falsey values (=== false) // As users who didn't login after these were introduced may have them undefined/null emailNotifications: { - unsubscribeFromAll: {type: Boolean, 'default': false}, - newPM: {type: Boolean, 'default': true}, - kickedGroup: {type: Boolean, 'default': true}, - wonChallenge: {type: Boolean, 'default': true}, - giftedGems: {type: Boolean, 'default': true}, - giftedSubscription: {type: Boolean, 'default': true}, - invitedParty: {type: Boolean, 'default': true}, - invitedGuild: {type: Boolean, 'default': true}, - questStarted: {type: Boolean, 'default': true}, - invitedQuest: {type: Boolean, 'default': true}, - //remindersToLogin: {type: Boolean, 'default': true}, - // Those importantAnnouncements are in fact the recapture emails - importantAnnouncements: {type: Boolean, 'default': true}, - weeklyRecaps: {type: Boolean, 'default': true} - } + unsubscribeFromAll: {type: Boolean, default: false}, + newPM: {type: Boolean, default: true}, + kickedGroup: {type: Boolean, default: true}, + wonChallenge: {type: Boolean, default: true}, + giftedGems: {type: Boolean, default: true}, + giftedSubscription: {type: Boolean, default: true}, + invitedParty: {type: Boolean, default: true}, + invitedGuild: {type: Boolean, default: true}, + questStarted: {type: Boolean, default: true}, + invitedQuest: {type: Boolean, default: true}, + // remindersToLogin: {type: Boolean, default: true}, + // importantAnnouncements are in fact the recapture emails + importantAnnouncements: {type: Boolean, default: true}, + weeklyRecaps: {type: Boolean, default: true}, + }, }, profile: { blurb: String, imageUrl: String, - name: String + name: String, }, stats: { - hp: {type: Number, 'default': shared.maxHealth}, - mp: {type: Number, 'default': 10}, - exp: {type: Number, 'default': 0}, - gp: {type: Number, 'default': 0}, - lvl: {type: Number, 'default': 1}, + hp: {type: Number, default: shared.maxHealth}, + mp: {type: Number, default: 10}, + exp: {type: Number, default: 0}, + gp: {type: Number, default: 0}, + lvl: {type: Number, default: 1}, // Class System - 'class': {type: String, enum: ['warrior','rogue','wizard','healer'], 'default': 'warrior'}, - points: {type: Number, 'default': 0}, - str: {type: Number, 'default': 0}, - con: {type: Number, 'default': 0}, - int: {type: Number, 'default': 0}, - per: {type: Number, 'default': 0}, + class: {type: String, enum: ['warrior', 'rogue', 'wizard', 'healer'], default: 'warrior'}, + points: {type: Number, default: 0}, + str: {type: Number, default: 0}, + con: {type: Number, default: 0}, + int: {type: Number, default: 0}, + per: {type: Number, default: 0}, buffs: { - str: {type: Number, 'default': 0}, - int: {type: Number, 'default': 0}, - per: {type: Number, 'default': 0}, - con: {type: Number, 'default': 0}, - stealth: {type: Number, 'default': 0}, - streaks: {type: Boolean, 'default': false}, - snowball: {type: Boolean, 'default': false}, - spookDust: {type: Boolean, 'default': false}, - shinySeed: {type: Boolean, 'default': false}, - seafoam: {type: Boolean, 'default': false} + str: {type: Number, default: 0}, + int: {type: Number, default: 0}, + per: {type: Number, default: 0}, + con: {type: Number, default: 0}, + stealth: {type: Number, default: 0}, + streaks: {type: Boolean, default: false}, + snowball: {type: Boolean, default: false}, + spookDust: {type: Boolean, default: false}, + shinySeed: {type: Boolean, default: false}, + seafoam: {type: Boolean, default: false}, }, training: { - int: {type: Number, 'default': 0}, - per: {type: Number, 'default': 0}, - str: {type: Number, 'default': 0}, - con: {type: Number, 'default': 0} - } + int: {type: Number, default: 0}, + per: {type: Number, default: 0}, + str: {type: Number, default: 0}, + con: {type: Number, default: 0}, + }, }, tags: {type: [{ _id: false, - id: { type: String, 'default': shared.uuid }, + id: {type: String, default: shared.uuid}, name: String, - challenge: String + challenge: String, }]}, - challenges: [{type: 'String', ref:'Challenge'}], + challenges: [{type: String, ref: 'Challenge'}], inbox: { - newMessages: {type:Number, 'default':0}, - blocks: {type:Array, 'default':[]}, - messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist - optOut: {type:Boolean, 'default':false} + newMessages: {type: Number, default: 0}, + blocks: {type: Array, default: []}, + messages: {type: Schema.Types.Mixed, default: {}}, + optOut: {type: Boolean, default: false}, }, - habits: {type:[TaskSchemas.HabitSchema]}, - dailys: {type:[TaskSchemas.DailySchema]}, - todos: {type:[TaskSchemas.TodoSchema]}, - rewards: {type:[TaskSchemas.RewardSchema]}, + habits: {type: [TaskSchemas.HabitSchema]}, + dailys: {type: [TaskSchemas.DailySchema]}, + todos: {type: [TaskSchemas.TodoSchema]}, + rewards: {type: [TaskSchemas.RewardSchema]}, extra: Schema.Types.Mixed, - pushDevices: {type: [{ - regId: {type: String}, - type: {type: String} - }],'default': []} - + pushDevices: { + type: [{ + regId: {type: String}, + type: {type: String}, + }], + default: [], + }, }, { strict: true, - minimize: false // So empty objects are returned + minimize: false, // So empty objects are returned }); -UserSchema.methods.deleteTask = function(tid) { - this.ops.deleteTask({params:{id:tid}},function(){}); // TODO remove this whole method, since it just proxies, and change all references to this method -} +schema.methods.deleteTask = function (tid) { + this.ops.deleteTask({params: {id: tid}}, () => {}); // TODO remove this whole method, since it just proxies, and change all references to this method +}; + +schema.methods.toJSON = function () { + let doc = this.toObject(); -UserSchema.methods.toJSON = function() { - var doc = this.toObject(); doc.id = doc._id; // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? @@ -467,120 +477,52 @@ UserSchema.methods.toJSON = function() { return doc; }; -//UserSchema.virtual('tasks').get(function () { -// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); -// var tasks = _.object(_.pluck(tasks,'id'), tasks); -// return tasks; -//}); +// schema.virtual('tasks').get(function () { +// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); +// var tasks = _.object(_.pluck(tasks,'id'), tasks); +// return tasks; +// }); -UserSchema.post('init', function(doc){ +schema.post('init', function postInitUser (doc) { shared.wrap(doc); -}) - -UserSchema.pre('save', function(next) { - - // Populate new users with default content - if (this.isNew){ - _populateDefaultsForNewUser(this); - } - - //this.markModified('tasks'); - if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { - this.preferences.dayStart = 0; - } - - if (!this.profile.name) { - var fb = this.auth.facebook; - this.profile.name = - (this.auth.local && this.auth.local.username) || - (fb && (fb.displayName || fb.name || fb.username || (fb.first_name && fb.first_name + ' ' + fb.last_name))) || - 'Anonymous'; - } - - // Determines if Beast Master should be awarded - var beastMasterProgress = shared.count.beastMasterProgress(this.items.pets); - if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) { - this.achievements.beastMaster = true; - } - - // Determines if Mount Master should be awarded - var mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts); - - if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) { - this.achievements.mountMaster = true - } - - // Determines if Triad Bingo should be awarded - - var dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets); - var qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90; - - if (qualifiesForTriad || this.achievements.triadBingoCount > 0) { - this.achievements.triadBingo = true; - } - - // Enable weekly recap emails for old users who sign in - if(this.flags.lastWeeklyRecapDiscriminator){ - // Enable weekly recap emails in 24 hours - this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate(); - // Unset the field so this is run only once - this.flags.lastWeeklyRecapDiscriminator = undefined; - } - - // EXAMPLE CODE for allowing all existing and new players to be - // automatically granted an item during a certain time period: - // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) - // this.items.pets['JackOLantern-Base'] = 5; - - //our own version incrementer - if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; - this._v++; - - next(); }); -UserSchema.methods.unlink = function(options, cb) { - var cid = options.cid, keep = options.keep, tid = options.tid; - if (!cid) { - return cb("Could not remove challenge tasks. Please delete them manually."); - } - var self = this; - switch (keep) { - case 'keep': - self.tasks[tid].challenge = {}; - break; - case 'remove': - self.deleteTask(tid); - break; - case 'keep-all': - _.each(self.tasks, function(t){ - if (t.challenge && t.challenge.id == cid) { - t.challenge = {}; +function _populateDefaultTasks (user, taskTypes) { + _.each(taskTypes, (taskType) => { + user[taskType] = _.map(shared.content.userDefaults[taskType], (task) => { + let newTask = _.cloneDeep(task); + + // Render task's text and notes in user's language + if (taskType === 'tags') { + // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here + newTask.id = shared.uuid(); + newTask.name = newTask.name(user.preferences.language); + } else { + newTask.text = newTask.text(user.preferences.language); + if (newTask.notes) { + newTask.notes = newTask.notes(user.preferences.language); } - }); - break; - case 'remove-all': - _.each(self.tasks, function(t){ - if (t.challenge && t.challenge.id == cid) { - self.deleteTask(t.id); + + if (newTask.checklist) { + newTask.checklist = _.map(newTask.checklist, (checklistItem) => { + checklistItem.text = checklistItem.text(user.preferences.language); + return checklistItem; + }); } - }) - break; - } - self.markModified('habits'); - self.markModified('dailys'); - self.markModified('todos'); - self.markModified('rewards'); - self.save(cb); + } + + return newTask; + }); + }); } -function _populateDefaultsForNewUser(user) { - var taskTypes; +function _populateDefaultsForNewUser (user) { + let taskTypes; - if (user.registeredThrough === "habitica-web") { + if (user.registeredThrough === 'habitica-web') { taskTypes = ['habits', 'dailys', 'todos', 'rewards', 'tags']; - var tutorialCommonSections = [ + let tutorialCommonSections = [ 'habits', 'dailies', 'todos', @@ -595,15 +537,15 @@ function _populateDefaultsForNewUser(user) { 'items', ]; - _.each(tutorialCommonSections, function(section) { + _.each(tutorialCommonSections, (section) => { user.flags.tutorial.common[section] = true; }); } else { - taskTypes = ['todos', 'tags'] + taskTypes = ['todos', 'tags']; user.flags.showTour = false; - var tourSections = [ + let tourSections = [ 'showTour', 'intro', 'classes', @@ -619,7 +561,7 @@ function _populateDefaultsForNewUser(user) { 'equipment', ]; - _.each(tourSections, function(section) { + _.each(tourSections, (section) => { user.flags.tour[section] = -2; }); } @@ -627,47 +569,126 @@ function _populateDefaultsForNewUser(user) { _populateDefaultTasks(user, taskTypes); } -function _populateDefaultTasks (user, taskTypes) { - _.each(taskTypes, function(taskType){ - user[taskType] = _.map(shared.content.userDefaults[taskType], function(task){ - var newTask = _.cloneDeep(task); +schema.pre('save', function postSaveUser (next) { + // Populate new users with default content + if (this.isNew) { + _populateDefaultsForNewUser(this); + } - // Render task's text and notes in user's language - if(taskType === 'tags'){ - // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here - newTask.id = shared.uuid(); - newTask.name = newTask.name(user.preferences.language); - }else{ - newTask.text = newTask.text(user.preferences.language); - if(newTask.notes) { - newTask.notes = newTask.notes(user.preferences.language); + // this.markModified('tasks'); + if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { + this.preferences.dayStart = 0; + } + + if (!this.profile.name) { + let fb = this.auth.facebook; + + this.profile.name = + (this.auth.local && this.auth.local.username) || + (fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`)) || + 'Anonymous'; + } + + // Determines if Beast Master should be awarded + let beastMasterProgress = shared.count.beastMasterProgress(this.items.pets); + + if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) { + this.achievements.beastMaster = true; + } + + // Determines if Mount Master should be awarded + let mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts); + + if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) { + this.achievements.mountMaster = true; + } + + // Determines if Triad Bingo should be awarded + + let dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets); + let qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90; + + if (qualifiesForTriad || this.achievements.triadBingoCount > 0) { + this.achievements.triadBingo = true; + } + + // Enable weekly recap emails for old users who sign in + if (this.flags.lastWeeklyRecapDiscriminator) { + // Enable weekly recap emails in 24 hours + this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate(); + // Unset the field so this is run only once + this.flags.lastWeeklyRecapDiscriminator = undefined; + } + + // EXAMPLE CODE for allowing all existing and new players to be + // automatically granted an item during a certain time period: + // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) + // this.items.pets['JackOLantern-Base'] = 5; + + // our own version incrementer + if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; + this._v++; + + next(); +}); + +schema.methods.unlink = function (options, cb) { + let cid = options.cid; + let keep = options.keep; + let tid = options.tid; + + if (!cid) { + return cb('Could not remove challenge tasks. Please delete them manually.'); + } + + let self = this; + + switch (keep) { + case 'keep': + self.tasks[tid].challenge = {}; + break; + case 'remove': + self.deleteTask(tid); + break; + case 'keep-all': + _.each(self.tasks, (t) => { + if (t.challenge && t.challenge.id === cid) { + t.challenge = {}; } - - if(newTask.checklist){ - newTask.checklist = _.map(newTask.checklist, function(checklistItem){ - checklistItem.text = checklistItem.text(user.preferences.language); - return checklistItem; - }); + }); + break; + case 'remove-all': + _.each(self.tasks, (t) => { + if (t.challenge && t.challenge.id === cid) { + self.deleteTask(t.id); } - } + }); + break; + } - return newTask; - }); - }); -} + self.markModified('habits'); + self.markModified('dailys'); + self.markModified('todos'); + self.markModified('rewards'); + self.save(cb); +}; + +export let model = mongoose.model('User', schema); -module.exports.schema = UserSchema; -module.exports.model = mongoose.model("User", UserSchema); // Initially export an empty object so external requires will get // the right object by reference when it's defined later // Otherwise it would remain undefined if requested before the query executes -module.exports.mods = []; +export let mods = []; -mongoose.model("User") - .find({'contributor.admin':true}) +mongoose.model('User') + .find({'contributor.admin': true}) .sort('-contributor.level -backer.npc profile.name') .select('profile contributor backer') - .exec(function(err,mods){ + .exec() + .then((foundMods) => { // Using push to maintain the reference to mods - module.exports.mods.push.apply(module.exports.mods, mods); -}); + mods.push(...foundMods); + }) + .catch((err) => { + throw err; // TODO ? + }); From 7b5887386691e54cde534cdc3b1c63bbe82e827e Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 09:25:45 -0600 Subject: [PATCH 044/976] Add names to functions --- website/src/models/user.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 47b790785f..96f208ee44 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -461,11 +461,11 @@ export let schema = new Schema({ minimize: false, // So empty objects are returned }); -schema.methods.deleteTask = function (tid) { +schema.methods.deleteTask = function deleteTask (tid) { this.ops.deleteTask({params: {id: tid}}, () => {}); // TODO remove this whole method, since it just proxies, and change all references to this method }; -schema.methods.toJSON = function () { +schema.methods.toJSON = function toJSON () { let doc = this.toObject(); doc.id = doc._id; @@ -632,7 +632,7 @@ schema.pre('save', function postSaveUser (next) { next(); }); -schema.methods.unlink = function (options, cb) { +schema.methods.unlink = function unlink (options, cb) { let cid = options.cid; let keep = options.keep; let tid = options.tid; From 3d86cefe78e306be1bbe76cd0ce088e122a86518 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 09:28:11 -0600 Subject: [PATCH 045/976] Convert switch statement to if-else --- website/src/models/user.js | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 96f208ee44..5adf4e3f5b 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -643,27 +643,22 @@ schema.methods.unlink = function unlink (options, cb) { let self = this; - switch (keep) { - case 'keep': - self.tasks[tid].challenge = {}; - break; - case 'remove': - self.deleteTask(tid); - break; - case 'keep-all': - _.each(self.tasks, (t) => { - if (t.challenge && t.challenge.id === cid) { - t.challenge = {}; - } - }); - break; - case 'remove-all': - _.each(self.tasks, (t) => { - if (t.challenge && t.challenge.id === cid) { - self.deleteTask(t.id); - } - }); - break; + if (keep === 'keep') { + self.tasks[tid].challenge = {}; + } else if (keep === 'remove') { + self.deleteTask(tid); + } else if (keep === 'keep-all') { + _.each(self.tasks, (t) => { + if (t.challenge && t.challenge.id === cid) { + t.challenge = {}; + } + }); + } else if (keep === 'remove-all') { + _.each(self.tasks, (t) => { + if (t.challenge && t.challenge.id === cid) { + self.deleteTask(t.id); + } + }); } self.markModified('habits'); From 366cbd9bff908bdb9dc24a4f3f67a6b6764ac08c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 09:30:31 -0600 Subject: [PATCH 046/976] Remove unneeded variable --- website/src/models/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 5adf4e3f5b..fee48bf452 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -208,7 +208,7 @@ export let schema = new Schema({ items: { gear: { - owned: _.transform(shared.content.gear.flat, (m, v, k) => { + owned: _.transform(shared.content.gear.flat, (m, v) => { m[v.key] = {type: Boolean}; if (v.key.match(/[armor|head|shield]_warrior_0/)) { m[v.key].default = true; From 7251a3a104349cab927a6746762ca13adce565d6 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 09:36:10 -0600 Subject: [PATCH 047/976] Extract profile name logic to private function --- website/src/models/user.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index fee48bf452..b364fe7c9f 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -569,6 +569,16 @@ function _populateDefaultsForNewUser (user) { _populateDefaultTasks(user, taskTypes); } +function _setProfileName (user) { + let fb = user.auth.facebook; + + let localUsername = user.auth.local && user.auth.local.username; + let facebookUsername = fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`); + let anonymous = 'Anonymous'; + + return localUsername || facebookUsername || anonymous; +} + schema.pre('save', function postSaveUser (next) { // Populate new users with default content if (this.isNew) { @@ -581,12 +591,7 @@ schema.pre('save', function postSaveUser (next) { } if (!this.profile.name) { - let fb = this.auth.facebook; - - this.profile.name = - (this.auth.local && this.auth.local.username) || - (fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`)) || - 'Anonymous'; + this.profile.name = _setProfileName(this); } // Determines if Beast Master should be awarded From a0094f4f48e5068afb63165063a4d0b39cb80ff1 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 09:49:54 -0600 Subject: [PATCH 048/976] Add exception for camelcase rule --- website/src/models/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index b364fe7c9f..15c8b047de 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -27,7 +27,7 @@ export let schema = new Schema({ facebook: Schema.Types.Mixed, // TODO validate local: { email: String, - hashed_password: String, + hashed_password: String, // eslint-disable-line camelcase salt: String, username: String, // Store a lowercase version of username to check for duplicates From 6aaef1430020bc2c01b155fd107e6aab7c1f3325 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 8 Nov 2015 10:06:40 -0600 Subject: [PATCH 049/976] Convert catch to second callback --- website/src/models/user.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 15c8b047de..126c1073be 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -688,7 +688,6 @@ mongoose.model('User') .then((foundMods) => { // Using push to maintain the reference to mods mods.push(...foundMods); - }) - .catch((err) => { + }, (err) => { throw err; // TODO ? }); From 87f50ff2b9b6e7be21a366c3c6027307b1ed7bd5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 8 Nov 2015 19:01:48 +0100 Subject: [PATCH 050/976] use callback in place of catch --- website/src/models/user.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 47b790785f..92f1f16085 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -688,7 +688,6 @@ mongoose.model('User') .then((foundMods) => { // Using push to maintain the reference to mods mods.push(...foundMods); - }) - .catch((err) => { + }, (err) => { // TODO replace with .catch which for some reason was throwing an error throw err; // TODO ? }); From 7dd6eb76c96750c258cc38ae26fbd2e10e628014 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 8 Nov 2015 22:48:31 +0100 Subject: [PATCH 051/976] add express-validator, add body parser middleware, support some more errors in error handler middleware --- package.json | 3 +- .../v3/unit/middlewares/errorHandler.test.js | 34 ++++++++++++++++++- .../src/middlewares/api-v3/errorHandler.js | 11 +++++- website/src/middlewares/api-v3/index.js | 9 +++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 53b5620478..92d3e6bc39 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "estraverse": "^4.1.1", "express": "~4.13.3", "express-csv": "~0.6.0", + "express-validator": "^2.18.0", "firebase": "^2.2.9", "firebase-token-generator": "^2.0.0", "glob": "^4.3.5", @@ -82,7 +83,7 @@ "superagent": "~1.4.0", "swagger-node-express": "lefnire/swagger-node-express#habitrpg", "universal-analytics": "~0.3.2", - "validator": "~3.19.0", + "validator": "~4.2.1", "winston": "~2.0.1" }, "private": true, diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 7ce86127e5..7df6ed8713 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -20,7 +20,7 @@ describe('errorHandler', () => { sandbox.stub(logger, 'error'); }); - it('sends internal server error if error is not a CustomError', () => { + it('sends internal server error if error is not a CustomError and is not identified', () => { let error = new Error(); errorHandler(error, req, res, next); @@ -35,6 +35,38 @@ describe('errorHandler', () => { }); }); + it('identifies errors with statusCode property and format them correctly', () => { + let error = new Error('Error message'); + error.statusCode = 400; + + errorHandler(error, req, res, next); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(400); + expect(res.json).to.be.calledWith({ + error: 'Error', + message: 'Error message', + }); + }); + + it('doesn\'t leak info about 500 errors', () => { + let error = new Error('Some secret error message'); + error.statusCode = 500; + + errorHandler(error, req, res, next); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(500); + expect(res.json).to.be.calledWith({ + error: 'InternalServerError', + message: 'Internal server error.', + }); + }); + it('sends CustomError', () => { let error = new BadRequest(); diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 97e324aee3..20c7dae30b 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -23,11 +23,20 @@ export default function errorHandler (err, req, res, next) { // If we can't identify it, respond with a generic 500 error let responseErr = err instanceof CustomError ? err : null; - if (!responseErr) { + // Handle errors created with 'http-errors' or similar that have a status/statusCode property + if (err.statusCode && typeof err.statusCode === 'number') { + responseErr = new CustomError(); + responseErr.httpCode = err.statusCode; + responseErr.error = err.name; + responseErr.message = err.message; + } + + if (!responseErr || responseErr.httpCode >= 500) { // Try to identify the error... // ... // Otherwise create an InternalServerError and use it // we don't want to leak anything, just a generic error message + // Use it also in case of identified errors but with httpCode === 500 responseErr = new InternalServerError(); } diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 460e4c53e4..6ce30edc58 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -1,8 +1,17 @@ // This module is only used to attach middlewares to the express app import errorHandler from './errorHandler'; +import bodyParser from 'body-parser'; export default function attachMiddlewares (app) { + + // Parse query parameters and json bodies + // TODO handle errors + app.use(bodyParser.urlencoded( + extended: true, // Uses 'qs' library as old connect middleware + })); + app.use(bodyParser.json()); + // Error handler middleware, define as the last one app.use(errorHandler); } From 1489b41fcc5ad2833159a33c17c043170b10356d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 9 Nov 2015 10:53:49 +0100 Subject: [PATCH 052/976] fix typo --- website/src/middlewares/api-v3/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 6ce30edc58..d97e6c0e0e 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -7,7 +7,7 @@ export default function attachMiddlewares (app) { // Parse query parameters and json bodies // TODO handle errors - app.use(bodyParser.urlencoded( + app.use(bodyParser.urlencoded({ extended: true, // Uses 'qs' library as old connect middleware })); app.use(bodyParser.json()); From 78bdbfc96fcdc3f7ab62da502ad3508191b098f5 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 9 Nov 2015 07:43:48 -0600 Subject: [PATCH 053/976] Add todo to use catch syntax --- website/src/models/user.js | 1 + 1 file changed, 1 insertion(+) diff --git a/website/src/models/user.js b/website/src/models/user.js index 126c1073be..3841cbd8f7 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -689,5 +689,6 @@ mongoose.model('User') // Using push to maintain the reference to mods mods.push(...foundMods); }, (err) => { + // @TODO convert this to a catch throw err; // TODO ? }); From 84158c92f59f3e09f48ddf2c3fb50f49b3839385 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 9 Nov 2015 07:58:36 -0600 Subject: [PATCH 054/976] Remove extra space --- website/src/middlewares/api-v3/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index d97e6c0e0e..4d2053fe2b 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -4,7 +4,6 @@ import errorHandler from './errorHandler'; import bodyParser from 'body-parser'; export default function attachMiddlewares (app) { - // Parse query parameters and json bodies // TODO handle errors app.use(bodyParser.urlencoded({ From bab41be646099f920527e0fc9755f2b9bef96386 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 9 Nov 2015 08:05:51 -0600 Subject: [PATCH 055/976] Correct gruntfile to use babel require hook --- Gruntfile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gruntfile.js b/Gruntfile.js index be40b40816..81eb26c0c3 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -139,7 +139,7 @@ module.exports = function(grunt) { grunt.registerTask('build:test', ['test:prepare:translations', 'build:dev']); grunt.registerTask('test:prepare:translations', function() { - require('coffee-script'); + require('babel/register'); var i18n = require('./website/src/libs/i18n'), fs = require('fs'); fs.writeFileSync('test/spec/mocks/translations.js', From 851631e465b20b821efab7b451f4f2c7987bf134 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 9 Nov 2015 08:19:24 -0600 Subject: [PATCH 056/976] Fix build errors --- test/api-legacy/api-helper.js | 1 + test/common/user.fns.buy.test.js | 2 +- test/helpers/globals.helper.js | 1 + test/mocha.opts | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/test/api-legacy/api-helper.js b/test/api-legacy/api-helper.js index 3dcaf5241c..b74e004847 100644 --- a/test/api-legacy/api-helper.js +++ b/test/api-legacy/api-helper.js @@ -1,4 +1,5 @@ require('coffee-script'); +require('babel/register'); var path, superagentDefaults; superagentDefaults = require("superagent-defaults"); diff --git a/test/common/user.fns.buy.test.js b/test/common/user.fns.buy.test.js index 05f7abb78d..677941a3d7 100644 --- a/test/common/user.fns.buy.test.js +++ b/test/common/user.fns.buy.test.js @@ -132,7 +132,7 @@ describe('user.fns.buy', function() { _(shared.content.gearTypes).each(function(type) { _(shared.content.gear.tree[type].armoire).each(function(gearObject, gearName) { - armoireKey = gearObject.key; + var armoireKey = gearObject.key; fullArmoire[armoireKey] = true; }); }); diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 4a166ea184..b825a527dc 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,3 +1,4 @@ +require('babel/register'); //------------------------------ // Global modules //------------------------------ diff --git a/test/mocha.opts b/test/mocha.opts index 30b4268b07..a2e826f179 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -4,6 +4,7 @@ --check-leaks --growl --compilers coffee:coffee-script +--compilers js:babel/register --globals io --require test/api-legacy/api-helper --require ./test/helpers/globals.helper From c8ee1eaaec06aab209b64eff43f8985507716753 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 9 Nov 2015 18:41:04 -0600 Subject: [PATCH 057/976] Add support for passing in custom env file to nconf. --- test/api/v3/unit/libs/setupNconf.test.js | 24 +++++++++++++++++------- test/helpers/globals.helper.js | 2 +- website/src/libs/api-v3/setupNconf.js | 6 ++++-- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/test/api/v3/unit/libs/setupNconf.test.js b/test/api/v3/unit/libs/setupNconf.test.js index e34d87c16b..1fbb046a85 100644 --- a/test/api/v3/unit/libs/setupNconf.test.js +++ b/test/api/v3/unit/libs/setupNconf.test.js @@ -3,29 +3,39 @@ import setupNconf from '../../../../../website/src/libs/api-v3/setupNconf'; import nconf from 'nconf'; describe('setupNconf', () => { - before(() => { - sandbox.spy(nconf, 'argv'); - sandbox.spy(nconf, 'env'); - sandbox.spy(nconf, 'file'); - - setupNconf(); + beforeEach(() => { + sandbox.stub(nconf, 'argv').returnsThis(); + sandbox.stub(nconf, 'env').returnsThis(); + sandbox.stub(nconf, 'file').returnsThis(); }); - after(() => { + afterEach(() => { sandbox.restore(); }); it('sets up nconf', () => { + setupNconf(); + expect(nconf.argv).to.be.calledOnce; expect(nconf.env).to.be.calledOnce; expect(nconf.file).to.be.calledOnce; + expect(nconf.file).to.be.calledWithMatch('user', /\/config.json$/); }); it('sets IS_PROD variable', () => { + setupNconf(); expect(nconf.get('IS_PROD')).to.exist; }); it('sets IS_DEV variable', () => { + setupNconf(); expect(nconf.get('IS_DEV')).to.exist; }); + + it('allows a custom config.json file to be passed in', () => { + setupNconf('customfile.json'); + + expect(nconf.file).to.be.calledOnce; + expect(nconf.file).to.be.calledWithMatch('user', 'customfile.json'); + }); }); diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index b825a527dc..52f9096315 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -14,4 +14,4 @@ global.sandbox = sinon.sandbox.create(); //------------------------------ // Load nconf for unit tests //------------------------------ -require('../../website/src/libs/api-v3/setupNconf')(); +require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); diff --git a/website/src/libs/api-v3/setupNconf.js b/website/src/libs/api-v3/setupNconf.js index ff0da651df..a3ba29f779 100644 --- a/website/src/libs/api-v3/setupNconf.js +++ b/website/src/libs/api-v3/setupNconf.js @@ -3,11 +3,13 @@ import { join, resolve } from 'path'; const PATH_TO_CONFIG = join(resolve(__dirname, '../../../../config.json')); -export default function setupNconf () { +export default function setupNconf (file) { + file = file || PATH_TO_CONFIG; + nconf .argv() .env() - .file('user', PATH_TO_CONFIG); + .file('user', file); nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production'); nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development'); From e518e2795f389f960ece8ee82b27731be6b69420 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 9 Nov 2015 19:11:25 -0600 Subject: [PATCH 058/976] Correct setupConfig to use proper linting. --- website/src/libs/api-v3/setupNconf.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/libs/api-v3/setupNconf.js b/website/src/libs/api-v3/setupNconf.js index a3ba29f779..e5a49bc3e9 100644 --- a/website/src/libs/api-v3/setupNconf.js +++ b/website/src/libs/api-v3/setupNconf.js @@ -4,12 +4,12 @@ import { join, resolve } from 'path'; const PATH_TO_CONFIG = join(resolve(__dirname, '../../../../config.json')); export default function setupNconf (file) { - file = file || PATH_TO_CONFIG; + let configFile = file || PATH_TO_CONFIG; nconf .argv() .env() - .file('user', file); + .file('user', configFile); nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production'); nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development'); From 8f9d2a5f9a45200dc684dcfbc3fc4222add5f9a7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 Nov 2015 10:12:13 +0100 Subject: [PATCH 059/976] change to erro handler middleware to log full error object and support express-validator --- test/api/v3/unit/middlewares/errorHandler.test.js | 1 + website/src/middlewares/api-v3/errorHandler.js | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 7df6ed8713..912dd75d1c 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -92,6 +92,7 @@ describe('errorHandler', () => { originalUrl: req.originalUrl, headers: req.headers, body: req.body, + fullError: error, }); }); diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 20c7dae30b..5bf90f6969 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -3,6 +3,7 @@ import logger from '../../libs/api-v3/logger'; import { CustomError, + BadRequest, InternalServerError, } from '../../libs/api-v3/errors'; @@ -16,6 +17,7 @@ export default function errorHandler (err, req, res, next) { originalUrl: req.originalUrl, headers: req.headers, body: req.body, + fullError: err, }); // In case of a CustomError class, use it's data @@ -31,6 +33,12 @@ export default function errorHandler (err, req, res, next) { responseErr.message = err.message; } + // Handle errors by express-validator + if (Array.isArray(err) && err[0].param && err[0].msg) { + responseErr = new BadRequest('Invalid request parameters.'); + responseErr.errors = err; + } + if (!responseErr || responseErr.httpCode >= 500) { // Try to identify the error... // ... @@ -40,6 +48,7 @@ export default function errorHandler (err, req, res, next) { responseErr = new InternalServerError(); } + // TODO unless status >= 500 return data attached to errors return res .status(responseErr.httpCode) .json({ From 3795b1d1516071dac535f391f5bb797c74c2bbcc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 Nov 2015 10:32:49 +0100 Subject: [PATCH 060/976] wip: starts upgrading lodash to v3 --- common/script/count.js | 6 ++--- common/script/src/content/helpers.js | 4 +--- package.json | 4 +--- tasks/gulp-transifex-test.js | 6 ++--- test/api-legacy/coupons.coffee | 22 ++++++++++++++----- test/common/user.fns.buy.test.js | 4 ++-- .../public/js/controllers/challengesCtrl.js | 2 +- website/public/js/services/guideServices.js | 2 +- website/public/js/services/statServices.js | 2 +- website/public/js/services/taskServices.js | 2 +- website/src/controllers/payments/index.js | 2 +- website/src/libs/analytics.js | 4 ++-- website/views/options/profile.jade | 2 +- 13 files changed, 35 insertions(+), 27 deletions(-) diff --git a/common/script/count.js b/common/script/count.js index de2bbf1227..bc32f4623f 100644 --- a/common/script/count.js +++ b/common/script/count.js @@ -11,7 +11,7 @@ function beastMasterProgress(pets) { _(DROP_ANIMALS).each(function(animal) { if(pets[animal] > 0 || pets[animal] == -1) count++ - }); + }).value(); return count; } @@ -22,7 +22,7 @@ function dropPetsCurrentlyOwned(pets) { _(DROP_ANIMALS).each(function(animal) { if(pets[animal] > 0) count++ - }); + }).value(); return count; } @@ -32,7 +32,7 @@ function mountMasterProgress(mounts) { _(DROP_ANIMALS).each(function(animal) { if (mounts[animal]) count++ - }); + }).value(); return count; } diff --git a/common/script/src/content/helpers.js b/common/script/src/content/helpers.js index 7413ac84d6..793884e42c 100644 --- a/common/script/src/content/helpers.js +++ b/common/script/src/content/helpers.js @@ -1,6 +1,4 @@ -import {each, defaults, assign} from 'lodash'; -import capitalize from 'lodash.capitalize'; -import camelCase from 'lodash.camelcase'; +import {each, defaults, assign, capitalize, camelCase} from 'lodash'; import i18n from '../i18n'; diff --git a/package.json b/package.json index 283ba5852d..3db0384304 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,7 @@ "in-app-purchase": "^0.2.0", "jade": "~1.11.0", "js2xmlparser": "~0.1.2", - "lodash": "~2.4.1", - "lodash.camelcase": "^3.0.1", - "lodash.capitalize": "^3.0.0", + "lodash": "^3.10.1", "loggly": "~1.0.8", "marked": "^0.3.5", "merge-stream": "^1.0.0", diff --git a/tasks/gulp-transifex-test.js b/tasks/gulp-transifex-test.js index d4e6cdc3c3..6326069815 100644 --- a/tasks/gulp-transifex-test.js +++ b/tasks/gulp-transifex-test.js @@ -89,7 +89,7 @@ gulp.task('transifex:malformedStrings', () => { } }); }); - }); + }).value(); if (!_.isEmpty(stringsWithMalformedInterpolations)) { let message = 'The following strings have malformed or missing interpolations'; @@ -128,7 +128,7 @@ function eachTranslationFile(languages, cb) { cb(null, lang, filename, parsedEnglishFile, parsedTranslationFile) }); - }); + }).value(); } function eachTranslationString(languages, cb) { @@ -162,7 +162,7 @@ function getStringsWith(json, interpolationRegex) { var match = value.match(interpolationRegex); if(match) strings[file_name][key] = match; }); - }); + }).value(); return strings; } diff --git a/test/api-legacy/coupons.coffee b/test/api-legacy/coupons.coffee index e0fd380bf5..87c2c94a96 100644 --- a/test/api-legacy/coupons.coffee +++ b/test/api-legacy/coupons.coffee @@ -41,6 +41,7 @@ describe "Coupons", -> expect(coupons.length).to.equal 10 _(coupons).each (c)-> expect(c.event).to.equal 'wondercon' + .value() done() context "while regular user", -> @@ -73,7 +74,9 @@ describe "Coupons", -> codes = res.text expect(codes).to.contain('code') # Expect each coupon code _id to exist in response - _(coupons).each (c) -> expect(codes).to.contain(c._id) + _(coupons).each (c) -> + expect(codes).to.contain(c._id) + .value() done() @@ -89,9 +92,13 @@ describe "Coupons", -> secondHalf = sortedCoupons[5..9] # First five coupons should be present in codes - _(firstHalf).each (c) -> expect(codes).to.contain(c._id) + _(firstHalf).each (c) -> + expect(codes).to.contain(c._id) + .value() # Second five coupons should not be present in codes - _(secondHalf).each (c) -> expect(codes).to.not.contain(c._id) + _(secondHalf).each (c) -> + expect(codes).to.not.contain(c._id) + .value() done() it "gets last 5 coupons out of 10 when a limit of 5 is set", (done) -> @@ -106,9 +113,13 @@ describe "Coupons", -> secondHalf = sortedCoupons[5..9] # First five coupons should not be present in codes - _(firstHalf).each (c) -> expect(codes).to.not.contain(c._id) + _(firstHalf).each (c) -> + expect(codes).to.not.contain(c._id) + .value() # Second five coupons should be present in codes - _(secondHalf).each (c) -> expect(codes).to.contain(c._id) + _(secondHalf).each (c) -> + expect(codes).to.contain(c._id) + .value() done() context "while regular user", -> @@ -142,6 +153,7 @@ describe "Coupons", -> expect(gear[i]).to.exist else expect(gear[i]).to.not.exist + .value() beforeEach (done) -> registerNewUser -> diff --git a/test/common/user.fns.buy.test.js b/test/common/user.fns.buy.test.js index 677941a3d7..3eb91dcf0b 100644 --- a/test/common/user.fns.buy.test.js +++ b/test/common/user.fns.buy.test.js @@ -134,8 +134,8 @@ describe('user.fns.buy', function() { _(shared.content.gear.tree[type].armoire).each(function(gearObject, gearName) { var armoireKey = gearObject.key; fullArmoire[armoireKey] = true; - }); - }); + }).value(); + }).value(); beforeEach(function() { user.achievements.ultimateGearSets = { rogue: true }; diff --git a/website/public/js/controllers/challengesCtrl.js b/website/public/js/controllers/challengesCtrl.js index d60c15bd00..f6e09731fa 100644 --- a/website/public/js/controllers/challengesCtrl.js +++ b/website/public/js/controllers/challengesCtrl.js @@ -81,7 +81,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', _(clonedTasks).each(function(val, type) { challenge[type + 's'].forEach(_cloneTaskAndPush); - }); + }).value(); $scope.obj = $scope.newChallenge = new Challenges.Challenge({ name: challenge.name, diff --git a/website/public/js/services/guideServices.js b/website/public/js/services/guideServices.js index 86c2c36cc7..e442be6199 100644 --- a/website/public/js/services/guideServices.js +++ b/website/public/js/services/guideServices.js @@ -209,7 +209,7 @@ function($rootScope, User, $timeout, $state, Analytics) { } User.set(ups); } - }) + }).value(); }); var tour = {}; diff --git a/website/public/js/services/statServices.js b/website/public/js/services/statServices.js index e836b39fb2..f2db805fb6 100644 --- a/website/public/js/services/statServices.js +++ b/website/public/js/services/statServices.js @@ -48,7 +48,7 @@ total += equipmentStat; } - }); + }).value(); return total; } diff --git a/website/public/js/services/taskServices.js b/website/public/js/services/taskServices.js index 3f968dfa6e..bbe7f6f689 100644 --- a/website/public/js/services/taskServices.js +++ b/website/public/js/services/taskServices.js @@ -36,7 +36,7 @@ _(cleansedTask.checklist).forEach(function(item) { item.completed = false; item.id = Shared.uuid(); - }); + }).value(); if (cleansedTask.type !== 'reward') { delete cleansedTask.value; diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js index fd7f98a9c2..cf6d0dae90 100644 --- a/website/src/controllers/payments/index.js +++ b/website/src/controllers/payments/index.js @@ -60,7 +60,7 @@ exports.createSubscription = function(data, cb) { }).defaults({ // allow non-override if a plan was previously used dateCreated: new Date(), mysteryItems: [] - }); + }).value(); } // Block sub perks diff --git a/website/src/libs/analytics.js b/website/src/libs/analytics.js index 769a5cf8e1..315dc4b618 100644 --- a/website/src/libs/analytics.js +++ b/website/src/libs/analytics.js @@ -60,7 +60,7 @@ function _generateLabelForGoogleAnalytics(data) { label = data[key]; return false; // exit _.each early } - }); + }).value(); return label; } @@ -74,7 +74,7 @@ function _generateValueForGoogleAnalytics(data) { value = data[key]; return false; // exit _.each early } - }); + }).value(); return value; } diff --git a/website/views/options/profile.jade b/website/views/options/profile.jade index 8c4b437df8..68b78c5a12 100644 --- a/website/views/options/profile.jade +++ b/website/views/options/profile.jade @@ -4,7 +4,7 @@ mixin gemCost(cost) = ' ' + env.t('locked') block --var gearGroup = function(grouping) { return env._(env.Content.gear.flat).where({gearSet:grouping}).pluck('key') } +-var gearGroup = function(grouping) { return env._(env.Content.gear.flat).where({gearSet:grouping}).pluck('key').value() } -var showPath = function(path, items, joiner) { return path+'["'+items.join('"] '+joiner+' '+path+'["')+'"]'; } -var unlockPath = function(path, items) { return 'unlock("'+path+'.'+items.join(','+path+'.')+'")'; } From 4f21ee290b0a66340206cc53cf81fbc897c557f0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 Nov 2015 11:08:16 +0100 Subject: [PATCH 061/976] finish upgrading to lodash 3 --- common/script/i18n.coffee | 4 ++-- common/script/src/i18n.js | 4 ++-- website/public/js/services/guideServices.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/common/script/i18n.coffee b/common/script/i18n.coffee index edc3ae02c8..3e9b08f01d 100644 --- a/common/script/i18n.coffee +++ b/common/script/i18n.coffee @@ -27,7 +27,7 @@ module.exports = if string try - _.template(string, (clonedVars)) + _.template(string)((clonedVars)) catch e 'Error processing the string. Please see Help > Report a Bug.' else @@ -39,6 +39,6 @@ module.exports = module.exports.translations[locale].stringNotFound try - _.template(stringNotFound, {string: stringName}) + _.template(stringNotFound)({string: stringName}) catch e 'Error processing the string. Please see Help > Report a Bug.' diff --git a/common/script/src/i18n.js b/common/script/src/i18n.js index 315624e76a..3b91ede35b 100644 --- a/common/script/src/i18n.js +++ b/common/script/src/i18n.js @@ -25,7 +25,7 @@ module.exports = { clonedVars.locale = locale; if (string) { try { - return _.template(string, clonedVars); + return _.template(string)(clonedVars); } catch (_error) { e = _error; return 'Error processing the string. Please see Help > Report a Bug.'; @@ -37,7 +37,7 @@ module.exports = { stringNotFound = module.exports.translations[locale] && module.exports.translations[locale].stringNotFound; } try { - return _.template(stringNotFound, { + return _.template(stringNotFound)({ string: stringName }); } catch (_error) { diff --git a/website/public/js/services/guideServices.js b/website/public/js/services/guideServices.js index e442be6199..a8bd144e52 100644 --- a/website/public/js/services/guideServices.js +++ b/website/public/js/services/guideServices.js @@ -184,7 +184,7 @@ function($rootScope, User, $timeout, $state, Analytics) { } _.each(chapters, function(chapter, k){ - _(chapter).flatten().each(function(step, i) { + _(chapter).flattenDeep().each(function(step, i) { step.content = "
" + step.content + "
"; $(step.element).popover('destroy'); // destroy existing hover popovers so we can add our own step.onShow = function(){ @@ -226,7 +226,7 @@ function($rootScope, User, $timeout, $state, Analytics) { '

' + '
' + '
' + - (showCounter ? ''+ (i+1 +' of '+ _.flatten(chapters[k]).length) +'' : '')+ // counter + (showCounter ? ''+ (i+1 +' of '+ _.flattenDeep(chapters[k]).length) +'' : '')+ // counter '
' + (step.hideNavigation ? '' : '') + (showFinish ? ('') : From 8864508482ad1b20f8fd3bf86978866cf59945fa Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 Nov 2015 11:58:15 +0100 Subject: [PATCH 062/976] rewrite server.js in es6 --- Procfile | 2 +- package.json | 2 +- tasks/gulp-eslint.js | 3 +- tasks/gulp-tests.js | 2 +- website/src/index.js | 36 ++++++ website/src/server.js | 295 ++++++++++++++++++++---------------------- 6 files changed, 178 insertions(+), 162 deletions(-) create mode 100644 website/src/index.js diff --git a/Procfile b/Procfile index c23771318e..df67da0332 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: node ./website/src/server.js +web: node ./website/src/index.js diff --git a/package.json b/package.json index 3db0384304..c8b8af6d54 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "habitrpg", "description": "A habit tracker app which treats your goals like a Role Playing Game.", "version": "0.0.0-152", - "main": "./website/src/server.js", + "main": "./website/src/index.js", "dependencies": { "accepts": "^1.3.0", "amazon-payments": "0.0.4", diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index eaee185f5c..6b1306049c 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -11,7 +11,8 @@ gulp.task('lint:server', () => { return gulp .src([ './website/src/**/api-v3/**/*.js', - './website/src/models/user.js' + './website/src/models/user.js', + './website/src/server.js' ]) .pipe(eslint()) .pipe(eslint.format()) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 0187773824..b3e406741e 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -51,7 +51,7 @@ gulp.task('test:prepare:mongo', (cb) => { gulp.task('test:prepare:server', ['test:prepare:mongo'], () => { if (!server) { - server = exec(`NODE_ENV="TESTING" NODE_DB_URI="${TEST_DB_URI}" PORT="${TEST_SERVER_PORT}" node ./website/src/server.js`, (error, stdout, stderr) => { + server = exec(`NODE_ENV="TESTING" NODE_DB_URI="${TEST_DB_URI}" PORT="${TEST_SERVER_PORT}" node ./website/src/index.js`, (error, stdout, stderr) => { if (error) { throw `Problem with the server: ${error}`; } if (stderr) { console.error(stderr); } }); diff --git a/website/src/index.js b/website/src/index.js new file mode 100644 index 0000000000..3f21cedbfb --- /dev/null +++ b/website/src/index.js @@ -0,0 +1,36 @@ +// Register babel hook so we can write the real entry file (server.js) in ES6 +require('babel/register'); + +// TODO remove this once we've fully converted over +require('coffee-script'); + +// Only do the minimal amount of work before forking just in case of a dyno restart +var cluster = require('cluster'); +var nconf = require('nconf'); +var logging = require('./libs/logging'); + +// Initialize configuration +var setupNconf = require('./libs/api-v3/setupNconf'); +setupNconf(); +var utils = require('./libs/utils'); +utils.setupConfig(); + +var IS_PROD = nconf.get('IS_PROD'); +var IS_DEV = nconf.get('IS_DEV'); +var cores = Number(nconf.get('WEB_CONCURRENCY')) || 0; + +// Setup the cluster module +if (cores !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) { + // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) + for (var i = 0; i < cores; i += 1) { + cluster.fork(); + } + + cluster.on('disconnect', (worker) => { + var w = cluster.fork(); // replace the dead worker + + logging.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid); + }); +} else { + module.exports = require('./server.js'); +} \ No newline at end of file diff --git a/website/src/server.js b/website/src/server.js index bea0da1428..41600e8550 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -1,183 +1,162 @@ -require('babel/register'); -require('./libs/api-v3/setupNconf')(); -// Only do the minimal amount of work before forking just in case of a dyno restart -var cluster = require("cluster"); -var _ = require('lodash'); -var nconf = require('nconf'); -var utils = require('./libs/utils'); +import nconf from 'nconf'; +import logging from './libs/logging'; +import utils from './libs/utils'; +import express from 'express'; +import http from 'http'; +// import path from 'path'; +// let swagger = require('swagger-node-express'); +import autoinc from 'mongoose-id-autoinc'; +import passport from 'passport'; +// let shared = require('../../common'); +import passportFacebook from 'passport-facebook'; +import mongoose from 'mongoose'; +import Q from 'q'; +import attachMiddlewares from './middlewares/api-v3/index'; utils.setupConfig(); -var logging = require('./libs/logging'); -var IS_PROD = nconf.get('IS_PROD'); -var IS_DEV = nconf.get('IS_DEV'); -var DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); -var cores = +nconf.get("WEB_CONCURRENCY") || 0; -if (cores!==0 && cluster.isMaster && (IS_DEV || IS_PROD)) { - // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) - for (var i = 0; i < cores; i += 1) { - cluster.fork(); - } +// Setup translations +// let i18n = require('./libs/i18n'); - cluster.on('disconnect', function(worker, code, signal) { - var w = cluster.fork(); // replace the dead worker - logging.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid); - }); -} else { - require('coffee-script'); // remove this once we've fully converted over - var express = require("express"); - var http = require("http"); - var path = require("path"); - var swagger = require("swagger-node-express"); - var autoinc = require('mongoose-id-autoinc'); - var shared = require('../../common'); +const IS_PROD = nconf.get('IS_PROD'); +// const IS_DEV = nconf.get('IS_DEV'); +// const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); +// const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; - // Setup translations - var i18n = require('./libs/i18n'); +let server = http.createServer(); +let app = express(); - var TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; - var app = express(); - var server = http.createServer(); +// Mongoose configuration - // ------------ MongoDB Configuration ------------ - var mongoose = require('mongoose'); - // Use Q promises instead of mpromise in mongoose - mongoose.Promise = require('q'); - var mongooseOptions = !IS_PROD ? {} : { - replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, - server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } } - }; - var db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, function(err) { - if (err) throw err; - logging.info('Connected with Mongoose'); - }); - autoinc.init(db); +// Use Q promises instead of mpromise in mongoose +mongoose.Promise = Q; +let mongooseOptions = !IS_PROD ? {} : { + replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, + server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, +}; +let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { + if (err) throw err; + logging.info('Connected with Mongoose'); +}); - require('./libs/firebase'); +autoinc.init(db); - // load schemas & models - require('./models/challenge'); - require('./models/group'); - require('./models/user'); +import './libs/firebase'; - // ------------ Passport Configuration ------------ - var passport = require('passport') - var util = require('util') - var FacebookStrategy = require('passport-facebook').Strategy; - // Passport session setup. - // To support persistent login sessions, Passport needs to be able to - // serialize users into and deserialize users out of the session. Typically, - // this will be as simple as storing the user ID when serializing, and finding - // the user by ID when deserializing. However, since this example does not - // have a database of user records, the complete Facebook profile is serialized - // and deserialized. - passport.serializeUser(function(user, done) { - done(null, user); - }); +// load schemas & models +import './models/challenge'; +import './models/group'; +import './models/user'; - passport.deserializeUser(function(obj, done) { - done(null, obj); - }); +// ------------ Passport Configuration ------------ +// let util = require('util') +let FacebookStrategy = passportFacebook.Strategy; - // FIXME - // This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) - // The proper fix would be to move to a general OAuth module simply to verify accessTokens - passport.use(new FacebookStrategy({ - clientID: nconf.get("FACEBOOK_KEY"), - clientSecret: nconf.get("FACEBOOK_SECRET"), - //callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" - }, - function(accessToken, refreshToken, profile, done) { - done(null, profile); - } - )); +// Passport session setup. +// To support persistent login sessions, Passport needs to be able to +// serialize users into and deserialize users out of the session. Typically, +// this will be as simple as storing the user ID when serializing, and finding +// the user by ID when deserializing. However, since this example does not +// have a database of user records, the complete Facebook profile is serialized +// and deserialized. +passport.serializeUser((user, done) => done(null, user)); - // ------------ Server Configuration ------------ - var publicDir = path.join(__dirname, "/../public"); +passport.deserializeUser((obj, done) => done(null, obj)); - app.set("port", nconf.get('PORT')); +// FIXME +// This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) +// The proper fix would be to move to a general OAuth module simply to verify accessTokens +passport.use(new FacebookStrategy({ + clientID: nconf.get('FACEBOOK_KEY'), + clientSecret: nconf.get('FACEBOOK_SECRET'), + // callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" +}, (accessToken, refreshToken, profile, done) => done(null, profile))); - // Setup two different Express apps, one that matches everything except '/api/v3' - // and the other for /api/v3 routes, so we can keep the old an new api versions completely separate - // not sharing a single middleware if we don't want to - var oldApp = express(); // api v1 and v2, and not scoped routes - var newApp = express(); // api v3 +// ------------ Server Configuration ------------ +// let publicDir = path.join(__dirname, '/../public'); - // Route requests to the right app - // Matches all request except the ones going to /api/v3/** - app.all(/^(?!\/api\/v3).+/i, oldApp); - // Matches all requests going to /api/v3 - app.all('/api/v3', newApp); +app.set('port', nconf.get('PORT')); - // Mount middlewares for the new app - require('./middlewares/api-v3/index')(newApp); +// Setup two different Express apps, one that matches everything except '/api/v3' +// and the other for /api/v3 routes, so we can keep the old an new api versions completely separate +// not sharing a single middleware if we don't want to +let oldApp = express(); // api v1 and v2, and not scoped routes +let newApp = express(); // api v3 - /* OLD APP IS DISABLED UNTIL COMPATIBLE WITH NEW MODELS - //require('./middlewares/apiThrottle')(oldApp); - oldApp.use(require('./middlewares/domain')(server,mongoose)); - if (!IS_PROD && !DISABLE_LOGGING) oldApp.use(require('morgan')("dev")); - oldApp.use(require('compression')()); - oldApp.set("views", __dirname + "/../views"); - oldApp.set("view engine", "jade"); - oldApp.use(require('serve-favicon')(publicDir + '/favicon.ico')); - oldApp.use(require('./middlewares/cors')); +// Route requests to the right app +// Matches all request except the ones going to /api/v3/** +app.all(/^(?!\/api\/v3).+/i, oldApp); +// Matches all requests going to /api/v3 +app.all('/api/v3', newApp); - var redirects = require('./middlewares/redirects'); - oldApp.use(redirects.forceHabitica); - oldApp.use(redirects.forceSSL); - var bodyParser = require('body-parser'); - // Default limit is 100kb, need that because we actually send whole groups to the server - // FIXME as soon as possible (need to move on the client from $resource -> $http) - oldApp.use(bodyParser.urlencoded({ - limit: '1mb', - parameterLimit: 10000, // Upped for safety from 1k, FIXME as above - extended: true // Uses 'qs' library as old connect middleware - })); - oldApp.use(bodyParser.json({ - limit: '1mb' - })); - oldApp.use(require('method-override')()); +// Mount middlewares for the new app +attachMiddlewares(newApp); - oldApp.use(require('cookie-parser')()); - oldApp.use(require('cookie-session')({ - name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies - secret: nconf.get('SESSION_SECRET'), - httpOnly: false, - maxAge: TWO_WEEKS - })); +/* OLD APP IS DISABLED UNTIL COMPATIBLE WITH NEW MODELS +//require('./middlewares/apiThrottle')(oldApp); +oldApp.use(require('./middlewares/domain')(server,mongoose)); +if (!IS_PROD && !DISABLE_LOGGING) oldApp.use(require('morgan')("dev")); +oldApp.use(require('compression')()); +oldApp.set("views", __dirname + "/../views"); +oldApp.set("view engine", "jade"); +oldApp.use(require('serve-favicon')(publicDir + '/favicon.ico')); +oldApp.use(require('./middlewares/cors')); - // Initialize Passport! Also use passport.session() middleware, to support - // persistent login sessions (recommended). - oldApp.use(passport.initialize()); - oldApp.use(passport.session()); +var redirects = require('./middlewares/redirects'); +oldApp.use(redirects.forceHabitica); +oldApp.use(redirects.forceSSL); +var bodyParser = require('body-parser'); +// Default limit is 100kb, need that because we actually send whole groups to the server +// FIXME as soon as possible (need to move on the client from $resource -> $http) +oldApp.use(bodyParser.urlencoded({ + limit: '1mb', + parameterLimit: 10000, // Upped for safety from 1k, FIXME as above + extended: true // Uses 'qs' library as old connect middleware +})); +oldApp.use(bodyParser.json({ + limit: '1mb' +})); +oldApp.use(require('method-override')()); - // Custom Directives - oldApp.use(require('./routes/pages')); - oldApp.use(require('./routes/payments')); - oldApp.use(require('./routes/api-v2/auth')); - oldApp.use(require('./routes/api-v2/coupon')); - oldApp.use(require('./routes/api-v2/unsubscription')); - var v2 = express(); - oldApp.use('/api/v2', v2); - oldApp.use('/api/v1', require('./routes/api-v1')); - oldApp.use('/export', require('./routes/dataexport')); - require('./routes/api-v2/swagger')(swagger, v2); +oldApp.use(require('cookie-parser')()); +oldApp.use(require('cookie-session')({ + name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies + secret: nconf.get('SESSION_SECRET'), + httpOnly: false, + maxAge: TWO_WEEKS +})); - var maxAge = IS_PROD ? 31536000000 : 0; - // Cache emojis without copying them to build, they are too many - oldApp.use(express['static'](path.join(__dirname, "/../build"), { maxAge: maxAge })); - oldApp.use('/common/dist', express['static'](publicDir + "/../../common/dist", { maxAge: maxAge })); - oldApp.use('/common/audio', express['static'](publicDir + "/../../common/audio", { maxAge: maxAge })); - oldApp.use('/common/script/public', express['static'](publicDir + "/../../common/script/public", { maxAge: maxAge })); - oldApp.use('/common/img', express['static'](publicDir + "/../../common/img", { maxAge: maxAge })); - oldApp.use(express['static'](publicDir)); +// Initialize Passport! Also use passport.session() middleware, to support +// persistent login sessions (recommended). +oldApp.use(passport.initialize()); +oldApp.use(passport.session()); - oldApp.use(require('./middlewares/errorHandler')); - */ +// Custom Directives +oldApp.use(require('./routes/pages')); +oldApp.use(require('./routes/payments')); +oldApp.use(require('./routes/api-v2/auth')); +oldApp.use(require('./routes/api-v2/coupon')); +oldApp.use(require('./routes/api-v2/unsubscription')); +var v2 = express(); +oldApp.use('/api/v2', v2); +oldApp.use('/api/v1', require('./routes/api-v1')); +oldApp.use('/export', require('./routes/dataexport')); +require('./routes/api-v2/swagger')(swagger, v2); - server.on('request', app); - server.listen(app.get("port"), function() { - return logging.info("Express server listening on port " + app.get("port")); - }); +var maxAge = IS_PROD ? 31536000000 : 0; +// Cache emojis without copying them to build, they are too many +oldApp.use(express['static'](path.join(__dirname, "/../build"), { maxAge: maxAge })); +oldApp.use('/common/dist', express['static'](publicDir + "/../../common/dist", { maxAge: maxAge })); +oldApp.use('/common/audio', express['static'](publicDir + "/../../common/audio", { maxAge: maxAge })); +oldApp.use('/common/script/public', express['static'](publicDir + "/../../common/script/public", { maxAge: maxAge })); +oldApp.use('/common/img', express['static'](publicDir + "/../../common/img", { maxAge: maxAge })); +oldApp.use(express['static'](publicDir)); - module.exports = server; -} +oldApp.use(require('./middlewares/errorHandler')); +*/ + +server.on('request', app); +server.listen(app.get('port'), () => { + return logging.info(`Express server listening on port ${app.get('port')}`); +}); + +export default server; \ No newline at end of file From 249037b80f4d2192a15d32f8f15767df499cf818 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 Nov 2015 12:12:44 +0100 Subject: [PATCH 063/976] upgrade some deps, and remove unused qs module --- package.json | 13 ++++++------- website/src/controllers/api-v2/user.js | 1 - website/src/server.js | 2 ++ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index c8b8af6d54..2f94beb25d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "accepts": "^1.3.0", "amazon-payments": "0.0.4", "amplitude": "^2.0.1", - "async": "~0.9.0", + "async": "^1.5.0", "aws-sdk": "^2.0.25", "babel": "^5.5.4", "body-parser": "^1.14.1", @@ -55,12 +55,12 @@ "loggly": "~1.0.8", "marked": "^0.3.5", "merge-stream": "^1.0.0", - "method-override": "~2.2.0", - "moment": "~2.8.3", + "method-override": "^2.3.5", + "moment": "~2.10.6", "mongoose": "~4.2.3", "mongoose-id-autoinc": "~2013.7.14-4", "morgan": "^1.6.1", - "nconf": "~0.6.9", + "nconf": "~0.8.2", "newrelic": "~1.23.0", "nib": "~1.0.1", "nodemailer": "~0.5.2", @@ -73,7 +73,6 @@ "ps-tree": "^1.0.0", "push-notify": "^1.1.1", "q": "^1.4.1", - "qs": "^2.3.2", "request": "~2.44.0", "s3-upload-stream": "^1.0.6", "serve-favicon": "^2.3.0", @@ -82,7 +81,7 @@ "swagger-node-express": "lefnire/swagger-node-express#habitrpg", "universal-analytics": "~0.3.2", "validator": "~4.2.1", - "winston": "~2.0.1" + "winston": "^2.1.0" }, "private": true, "engines": { @@ -121,7 +120,7 @@ "rewire": "^2.3.3", "rimraf": "^2.4.3", "run-sequence": "^1.1.4", - "shelljs": "^0.4.0", + "shelljs": "^0.5.3", "sinon": "^1.17.2", "sinon-chai": "^2.8.0", "superagent-defaults": "^0.1.13", diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index caf3c092a4..b3a83dd80c 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -15,7 +15,6 @@ var moment = require('moment'); var logging = require('./../../libs/logging'); var acceptablePUTPaths; var api = module.exports; -var qs = require('qs'); var firebase = require('../../libs/firebase'); var webhook = require('../../libs/webhook'); diff --git a/website/src/server.js b/website/src/server.js index 41600e8550..74c8e87e5e 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -1,3 +1,5 @@ +// TODO cleanup all comments when finished API v3 + import nconf from 'nconf'; import logging from './libs/logging'; import utils from './libs/utils'; From 367223e15cbd063c3c081e2f07d1911664d3db18 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 Nov 2015 13:05:46 +0100 Subject: [PATCH 064/976] port password utilities to api v3 --- website/src/libs/api-v3/password.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 website/src/libs/api-v3/password.js diff --git a/website/src/libs/api-v3/password.js b/website/src/libs/api-v3/password.js new file mode 100644 index 0000000000..b047aa2f0d --- /dev/null +++ b/website/src/libs/api-v3/password.js @@ -0,0 +1,15 @@ +import crypto from 'crypto'; + +export function encrypt (password, salt) { + return crypto + .createHmac('sha1', salt) + .update(password) + .digest('hex'); +} + +export function makeSalt (len = 10) { + return crypto + .randomBytes(Math.ceil(len / 2)) + .toString('hex') + .substring(0, len); +} \ No newline at end of file From c91c3f78ed17c4b94bb27aa48884c7d2c8aab692 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 11 Nov 2015 06:44:45 -0600 Subject: [PATCH 065/976] Remove lodash.defaultsDeep dependency --- package.json | 1 - test/helpers/api-unit.helper.js | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/package.json b/package.json index 2f94beb25d..6174bf5f52 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,6 @@ "karma-mocha-reporter": "^1.1.1", "karma-phantomjs-launcher": "~0.2.1", "lcov-result-merger": "^1.0.2", - "lodash.defaultsdeep": "^3.10.0", "mocha": "^2.3.3", "mongodb": "^2.0.46", "mongoskin": "~0.6.1", diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 6e561df076..95a34316e1 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -1,5 +1,4 @@ -// @TODO: remove when lodash can be upgraded -import defaults from 'lodash.defaultsdeep'; +import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user' import { model as Group } from '../../website/src/models/group' import i18n from '../../common/script/src/i18n'; From ac4f26a94f06d837f0a6b68f7c3dd26c68275522 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 Nov 2015 16:05:35 +0100 Subject: [PATCH 066/976] add tests for password utilities --- test/api/v3/unit/libs/password.test.js | 41 ++++++++++++++++++++++++++ website/src/libs/api-v3/password.js | 3 ++ 2 files changed, 44 insertions(+) create mode 100644 test/api/v3/unit/libs/password.test.js diff --git a/test/api/v3/unit/libs/password.test.js b/test/api/v3/unit/libs/password.test.js new file mode 100644 index 0000000000..6bc652963e --- /dev/null +++ b/test/api/v3/unit/libs/password.test.js @@ -0,0 +1,41 @@ +import { + encrypt as encryptPassword, + makeSalt, +} from '../../../../../website/src/libs/api-v3/password'; + +describe('Password Utilities', () => { + describe('Encrypt', () => { + it('always encrypt the same password to the same value when using the same salt', () => { + let textPassword = 'mySecretPassword'; + let salt = makeSalt(); + let encryptedPassword = encryptPassword(textPassword, salt); + + expect(encryptPassword(textPassword, salt)).to.eql(encryptedPassword); + }); + + it('never encrypt the same password to the same value when using a different salt', () => { + let textPassword = 'mySecretPassword'; + let aSalt = makeSalt(); + let anotherSalt = makeSalt(); + let anEncryptedPassword = encryptPassword(textPassword, aSalt); + let anotherEncryptedPassword = encryptPassword(textPassword, anotherSalt); + + expect(anEncryptedPassword).not.to.eql(anotherEncryptedPassword); + }); + }); + + describe('Make Salt', () => { + it('creates a salt with length 10 by default', () => { + let salt = makeSalt(); + + expect(salt.length).to.eql(10); + }); + + it('can create a salt of any length', () => { + let length = 24; + let salt = makeSalt(length); + + expect(salt.length).to.eql(length); + }); + }); +}); diff --git a/website/src/libs/api-v3/password.js b/website/src/libs/api-v3/password.js index b047aa2f0d..c825083936 100644 --- a/website/src/libs/api-v3/password.js +++ b/website/src/libs/api-v3/password.js @@ -1,5 +1,7 @@ +// Utilities for working with passwords import crypto from 'crypto'; +// Return the encrypted version of a password (using sha1) given a salt export function encrypt (password, salt) { return crypto .createHmac('sha1', salt) @@ -7,6 +9,7 @@ export function encrypt (password, salt) { .digest('hex'); } +// Create a salt, default length is 10 export function makeSalt (len = 10) { return crypto .randomBytes(Math.ceil(len / 2)) From cdb05e1b424f74be10c67a448e151a4944b6882d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 11 Nov 2015 06:36:28 -0600 Subject: [PATCH 067/976] Add analytics service to v3 --- package.json | 1 + .../api/v3/unit/libs/analyticsService.test.js | 309 ++++++++++++++++++ .../api/v3/unit/middlewares/analytics.test.js | 32 ++ website/src/libs/api-v3/analyticsService.js | 240 ++++++++++++++ website/src/middlewares/api-v3/analytics.js | 23 ++ website/src/middlewares/api-v3/index.js | 3 + 6 files changed, 608 insertions(+) create mode 100644 test/api/v3/unit/libs/analyticsService.test.js create mode 100644 test/api/v3/unit/middlewares/analytics.test.js create mode 100644 website/src/libs/api-v3/analyticsService.js create mode 100644 website/src/middlewares/api-v3/analytics.js diff --git a/package.json b/package.json index 6174bf5f52..3117ca87b9 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "mocha": "^2.3.3", "mongodb": "^2.0.46", "mongoskin": "~0.6.1", + "nock": "^2.17.0", "protractor": "~2.5.1", "rewire": "^2.3.3", "rimraf": "^2.4.3", diff --git a/test/api/v3/unit/libs/analyticsService.test.js b/test/api/v3/unit/libs/analyticsService.test.js new file mode 100644 index 0000000000..99657b270a --- /dev/null +++ b/test/api/v3/unit/libs/analyticsService.test.js @@ -0,0 +1,309 @@ +import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; + +import nock from 'nock'; + +describe('analyticsService', () => { + let amplitudeNock, gaNock; + + beforeEach(() => { + amplitudeNock = nock( 'https://api.amplitude.com') + .filteringPath(/httpapi.*/g, '') + .post('/') + .reply(200, {status: 'OK'}); + + gaNock = nock( 'http://www.google-analytics.com'); + }); + + describe('#track', () => { + let eventType, data; + + beforeEach(() => { + eventType = 'Cron'; + data = { + category: 'behavior', + uuid: 'unique-user-id', + resting: true, + cronCount: 5 + }; + }); + + context('Amplitude', () => { + it('calls out to amplitude', () => { + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('uses a dummy user id if none is provided', () => { + delete data.uuid; + + amplitudeNock + .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sets platform as server', () => { + amplitudeNock + .filteringPath(/httpapi.*platform.*server.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends details about event', () => { + amplitudeNock + .filteringPath(/httpapi.*event_properties%22%3A%7B%22category%22%3A%22behavior%22%2C%22resting%22%3Atrue%2C%22cronCount%22%3A5%7D%2C%22.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for gear if itemKey is provided', () => { + data.itemKey = 'headAccessory_special_foxEars' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Fox%20Ears.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for egg if itemKey is provided', () => { + data.itemKey = 'Wolf' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Wolf%20Egg.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for food if itemKey is provided', () => { + data.itemKey = 'Cake_Skeleton' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Bare%20Bones%20Cake.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for hatching potion if itemKey is provided', () => { + data.itemKey = 'Golden' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Golden%20Hatching%20Potion.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for quest if itemKey is provided', () => { + data.itemKey = 'atom1' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Attack%20of%20the%20Mundane%2C%20Part%201%3A%20Dish%20Disaster!.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends english item name for purchased spell if itemKey is provided', () => { + data.itemKey = 'seafoam' + + amplitudeNock + .filteringPath(/httpapi.*itemName.*Seafoam.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends user data if provided', () => { + let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; + let user = { + stats: stats, + contributor: { level: 1 }, + purchased: { plan: { planId: 'foo-plan' } }, + flags: {tour: {intro: -2}}, + habits: [{_id: 'habit'}], + dailys: [{_id: 'daily'}], + todos: [{_id: 'todo'}], + rewards: [{_id: 'reward'}] + }; + + data.user = user; + + amplitudeNock + .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); + + return analyticsService.track(eventType, data) + .then((res) => { + amplitudeNock.done(); + }); + }); + }); + + context('GA', () => { + it('calls out to GA', () => { + gaNock + .post('/collect') + .reply(200, {status: 'OK'}); + + return analyticsService.track(eventType, data) + .then((res) => { + gaNock.done(); + }); + }); + + it('sends details about event', () => { + gaNock + .post('/collect', /ec=behavior&ea=Cron&v=1&tid=GA_ID&cid=.*&t=event/) + .reply(200, {status: 'OK'}); + + return analyticsService.track(eventType, data) + .then((res) => { + gaNock.done(); + }); + }); + }); + }); + + describe('#trackPurchase', () => { + let data; + + beforeEach(() => { + data = { + uuid: 'user-id', + sku: 'paypal-checkout', + paymentMethod: 'PayPal', + itemPurchased: 'Gems', + purchaseValue: 8, + purchaseType: 'checkout', + gift: false, + quantity: 1 + }; + }); + + context('Amplitude', () => { + it('calls out to amplitude', () => { + return analyticsService.trackPurchase(data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('uses a dummy user id if none is provided', () => { + delete data.uuid; + + amplitudeNock + .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); + + return analyticsService.trackPurchase(data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sets platform as server', () => { + amplitudeNock + .filteringPath(/httpapi.*platform.*server.*/g, ''); + + return analyticsService.trackPurchase(data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends details about purchase', () => { + amplitudeNock + .filteringPath(/httpapi.*aypal-checkout%22%2C%22paymentMethod%22%3A%22PayPal%22%2C%22itemPurchased%22%3A%22Gems%22%2C%22purchaseType%22%3A%22checkout%22%2C%22gift%22%3Afalse%2C%22quantity%22%3A1%7D%2C%22event_type%22%3A%22purchase%22%2C%22revenue.*/g, ''); + + return analyticsService.trackPurchase(data) + .then((res) => { + amplitudeNock.done(); + }); + }); + + it('sends user data if provided', () => { + let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; + let user = { + stats: stats, + contributor: { level: 1 }, + purchased: { plan: { planId: 'foo-plan' } }, + flags: {tour: {intro: -2}}, + habits: [{_id: 'habit'}], + dailys: [{_id: 'daily'}], + todos: [{_id: 'todo'}], + rewards: [{_id: 'reward'}] + }; + + data.user = user; + + amplitudeNock + .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); + + return analyticsService.trackPurchase(data) + .then((res) => { + amplitudeNock.done(); + }); + }); + }); + + context('GA', () => { + it('calls out to GA', () => { + gaNock + .post('/collect') + .reply(200, {status: 'OK'}); + + return analyticsService.trackPurchase(data) + .then((res) => { + gaNock.done(); + }); + }); + + it('sends details about purchase', () => { + gaNock + .post('/collect', /ti=user-id&tr=8&v=1&tid=GA_ID&cid=.*&t=transaction/) + .reply(200, {status: 'OK'}) + .post('/collect', /ec=commerce&ea=checkout&el=PayPal&ev=8&v=1&tid=GA_ID&cid=.*&t=event/) + .reply(200, {status: 'OK'}); + + return analyticsService.trackPurchase(data) + .then((res) => { + gaNock.done(); + }); + }); + }); + }); + + describe('mockAnalyticsService', () => { + it('has stubbed track method', () => { + expect(analyticsService.mockAnalyticsService).to.respondTo('track'); + }); + + it('has stubbed trackPurchase method', () => { + expect(analyticsService.mockAnalyticsService).to.respondTo('trackPurchase'); + }); + }); +}); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js new file mode 100644 index 0000000000..75df6c72dc --- /dev/null +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -0,0 +1,32 @@ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; +import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService' +import nconf from 'nconf'; +import attachAnalytics from '../../../../../website/src/middlewares/api-v3/analytics'; + +describe('analytics middleware', function() { + let res, req, next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + }); + + it('attaches analytics object res.locals', function() { + attachAnalytics(req, res, next); + + expect(res.analytics).to.exist; + }); + + it('attaches stubbed methods for non-prod environments', () => { + attachAnalytics(req, res, next); + + expect(res.analytics.track).to.eql(analyticsService.mockAnalyticsService.track); + expect(res.analytics.trackPurchase).to.eql(analyticsService.mockAnalyticsService.trackPurchase); + }); +}); + diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js new file mode 100644 index 0000000000..6e460097eb --- /dev/null +++ b/website/src/libs/api-v3/analyticsService.js @@ -0,0 +1,240 @@ +/* eslint-disable camelcase */ +import nconf from 'nconf'; +import Amplitude from 'amplitude'; +import Q from 'q'; +import googleAnalytics from 'universal-analytics'; +import { + each, + omit, +} from 'lodash'; +import { content as Content } from '../../../../common'; + +require('coffee-script'); +require('../../libs/i18n'); + +const AMPLIUDE_TOKEN = nconf.get('AMPLITUDE_KEY'); +const GA_TOKEN = nconf.get('GA_ID'); +const GA_POSSIBLE_LABELS = ['gaLabel', 'itemKey']; +const GA_POSSIBLE_VALUES = ['gaValue', 'gemCost', 'goldCost']; +const AMPLITUDE_PROPERTIES_TO_SCRUB = ['uuid', 'user', 'purchaseValue', 'gaLabel', 'gaValue']; + +let amplitude = new Amplitude(AMPLIUDE_TOKEN); +let ga = googleAnalytics(GA_TOKEN); + +let _lookUpItemName = (itemKey) => { + if (!itemKey) return; + + let gear = Content.gear.flat[itemKey]; + let egg = Content.eggs[itemKey]; + let food = Content.food[itemKey]; + let hatchingPotion = Content.hatchingPotions[itemKey]; + let quest = Content.quests[itemKey]; + let spell = Content.special[itemKey]; + + let itemName; + + if (gear) { + itemName = gear.text(); + } else if (egg) { + itemName = `${egg.text()} Egg`; + } else if (food) { + itemName = food.text(); + } else if (hatchingPotion) { + itemName = `${hatchingPotion.text()} Hatching Potion`; + } else if (quest) { + itemName = quest.text(); + } else if (spell) { + itemName = spell.text(); + } + + return itemName; +}; + +let _formatUserData = (user) => { + let properties = {}; + + if (user.stats) { + properties.Class = user.stats.class; + properties.Experience = Math.floor(user.stats.exp); + properties.Gold = Math.floor(user.stats.gp); + properties.Health = Math.ceil(user.stats.hp); + properties.Level = user.stats.lvl; + properties.Mana = Math.floor(user.stats.mp); + } + + properties.tutorialComplete = user.flags && user.flags.tour && user.flags.tour.intro === -2; + + if (user.habits && user.dailys && user.todos && user.rewards) { + properties['Number Of Tasks'] = { + habits: user.habits.length, + dailys: user.dailys.length, + todos: user.todos.length, + rewards: user.rewards.length, + }; + } + + if (user.contributor && user.contributor.level) { + properties.contributorLevel = user.contributor.level; + } + + if (user.purchased && user.purchased.plan.planId) { + properties.subscription = user.purchased.plan.planId; + } + + return properties; +}; + + +let _formatDataForAmplitude = (data) => { + let event_properties = omit(data, AMPLITUDE_PROPERTIES_TO_SCRUB); + + let ampData = { + user_id: data.uuid || 'no-user-id-was-provided', + platform: 'server', + event_properties, + }; + + if (data.user) { + ampData.user_properties = _formatUserData(data.user); + } + + let itemName = _lookUpItemName(data.itemKey); + + if (itemName) { + event_properties.itemName = itemName; + } + + return ampData; +}; + +let _sendDataToAmplitude = (eventType, data) => { + let amplitudeData = _formatDataForAmplitude(data); + + amplitudeData.event_type = eventType; + + return Q.promise((resolve, reject) => { + amplitude.track(amplitudeData) + .then(resolve) + .catch(reject); + }); +}; + +let _generateLabelForGoogleAnalytics = (data) => { + let label; + + each(GA_POSSIBLE_LABELS, (key) => { + if (data[key]) { + label = data[key]; + return false; // exit each early + } + }); + + return label; +}; + +let _generateValueForGoogleAnalytics = (data) => { + let value; + + each(GA_POSSIBLE_VALUES, (key) => { + if (data[key]) { + value = data[key]; + return false; // exit each early + } + }); + + return value; +}; + +let _sendDataToGoogle = (eventType, data) => { + let eventData = { + ec: data.category, + ea: eventType, + }; + + let label = _generateLabelForGoogleAnalytics(data); + + if (label) { + eventData.el = label; + } + + let value = _generateValueForGoogleAnalytics(data); + + if (value) { + eventData.ev = value; + } + + return Q.promise((resolve, reject) => { + ga.event(eventData, (err) => { + if (err) return reject(err); + resolve(); + }); + }); +}; + +let _sendPurchaseDataToAmplitude = (data) => { + let amplitudeData = _formatDataForAmplitude(data); + + amplitudeData.event_type = 'purchase'; + amplitudeData.revenue = data.purchaseValue; + + return Q.promise((resolve, reject) => { + amplitude.track(amplitudeData) + .then(resolve) + .catch(reject); + }); +}; + +let _sendPurchaseDataToGoogle = (data) => { + let label = data.paymentMethod; + let type = data.purchaseType; + let price = data.purchaseValue; + let qty = data.quantity; + let sku = data.sku; + let itemKey = data.itemPurchased; + let variation = type; + + if (data.gift) variation += ' - Gift'; + + let eventData = { + ec: 'commerce', + ea: type, + el: label, + ev: price, + }; + + return Q.promise((resolve) => { + ga.event(eventData).send(); + + ga.transaction(data.uuid, price) + .item(price, qty, sku, itemKey, variation) + .send(); + + resolve(); + }); +}; + +function track (eventType, data) { + return Q.all([ + _sendDataToAmplitude(eventType, data), + _sendDataToGoogle(eventType, data), + ]); +} + +function trackPurchase (data) { + return Q.all([ + _sendPurchaseDataToAmplitude(data), + _sendPurchaseDataToGoogle(data), + ]); +} + +// Stub for non-prod environments +let mockAnalyticsService = { + track: () => { }, + trackPurchase: () => { }, +}; + +export default { + track, + trackPurchase, + mockAnalyticsService, +}; diff --git a/website/src/middlewares/api-v3/analytics.js b/website/src/middlewares/api-v3/analytics.js new file mode 100644 index 0000000000..e4872fad56 --- /dev/null +++ b/website/src/middlewares/api-v3/analytics.js @@ -0,0 +1,23 @@ +import nconf from 'nconf'; +import { + track, + trackPurchase, + mockAnalyticsService, +} from '../../libs/api-v3/analyticsService'; + +let service; + +if (nconf.get('IS_PROD')) { + service = { + track, + trackPurchase, + }; +} else { + service = mockAnalyticsService; +} + +export default function attachAnalytics (req, res, next) { + res.analytics = service; + + next(); +} diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 4d2053fe2b..82b85eee11 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -1,5 +1,6 @@ // This module is only used to attach middlewares to the express app +import analytics from './analytics'; import errorHandler from './errorHandler'; import bodyParser from 'body-parser'; @@ -11,6 +12,8 @@ export default function attachMiddlewares (app) { })); app.use(bodyParser.json()); + app.use(analytics); + // Error handler middleware, define as the last one app.use(errorHandler); } From 40fab075e2d7977a4832704afd7fd579e9450b7f Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 11 Nov 2015 19:52:48 -0600 Subject: [PATCH 068/976] Clean up linting for user model. --- website/src/models/user.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 457e08a77d..1cb1684360 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -387,11 +387,11 @@ export let schema = new Schema({ weeklyRecaps: {type: Boolean, default: true}, }, suppressModals: { - levelUp: {type: Boolean, 'default': false}, - hatchPet: {type: Boolean, 'default': false}, - raisePet: {type: Boolean, 'default': false}, - streak: {type: Boolean, 'default': false} - } + levelUp: {type: Boolean, default: false}, + hatchPet: {type: Boolean, default: false}, + raisePet: {type: Boolean, default: false}, + streak: {type: Boolean, default: false}, + }, }, profile: { blurb: String, From 9a3e4c8f8bb1a531c09e4f3f7ba9f9910a848e71 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 12 Nov 2015 07:51:51 -0600 Subject: [PATCH 069/976] Move old analytics lib to v2 directory. --- test/server_side/analytics.test.js | 6 +++--- website/src/libs/{ => api-v2}/analytics.js | 0 website/src/libs/utils.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename website/src/libs/{ => api-v2}/analytics.js (100%) diff --git a/test/server_side/analytics.test.js b/test/server_side/analytics.test.js index 7cde7aede8..56dd8f1787 100644 --- a/test/server_side/analytics.test.js +++ b/test/server_side/analytics.test.js @@ -30,7 +30,7 @@ describe('analytics', function() { }); describe('init', function() { - var analytics = rewire('../../website/src/libs/analytics'); + var analytics = rewire('../../website/src/libs/api-v2/analytics'); it('throws an error if no options are passed in', function() { expect(analytics).to.throw('No options provided'); @@ -62,7 +62,7 @@ describe('analytics', function() { describe('track', function() { var analyticsData, event_type; - var analytics = rewire('../../website/src/libs/analytics'); + var analytics = rewire('../../website/src/libs/api-v2/analytics'); var initializedAnalytics; beforeEach(function() { @@ -370,7 +370,7 @@ describe('analytics', function() { var purchaseData; - var analytics = rewire('../../website/src/libs/analytics'); + var analytics = rewire('../../website/src/libs/api-v2/analytics'); var initializedAnalytics; beforeEach(function() { diff --git a/website/src/libs/analytics.js b/website/src/libs/api-v2/analytics.js similarity index 100% rename from website/src/libs/analytics.js rename to website/src/libs/api-v2/analytics.js diff --git a/website/src/libs/utils.js b/website/src/libs/utils.js index c8944c9ccc..a39fa639ff 100644 --- a/website/src/libs/utils.js +++ b/website/src/libs/utils.js @@ -172,7 +172,7 @@ module.exports.setupConfig = function(){ //if (nconf.get('IS_PROD')) //require('newrelic'); - var analytics = IS_PROD && require('./analytics'); + var analytics = IS_PROD && require('./api-v2/analytics'); var analyticsTokens = { amplitudeToken: nconf.get('AMPLITUDE_KEY'), googleAnalytics: nconf.get('GA_ID') From 858bf999303b363450d58bdde2a2a2b67490c4ae Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 12 Nov 2015 08:07:32 -0600 Subject: [PATCH 070/976] Move old logging lib v2 directory --- tasks/gulp-console.js | 2 +- website/src/controllers/api-v2/challenges.js | 2 +- website/src/controllers/api-v2/user.js | 2 +- website/src/controllers/payments/paypal.js | 2 +- website/src/index.js | 6 +++--- website/src/libs/{ => api-v2}/logging.js | 0 website/src/libs/utils.js | 2 +- website/src/middlewares/errorHandler.js | 2 +- website/src/models/group.js | 2 +- website/src/server.js | 4 ++-- 10 files changed, 12 insertions(+), 12 deletions(-) rename website/src/libs/{ => api-v2}/logging.js (100%) diff --git a/tasks/gulp-console.js b/tasks/gulp-console.js index 95a1962fd4..768df81e3b 100644 --- a/tasks/gulp-console.js +++ b/tasks/gulp-console.js @@ -2,7 +2,7 @@ import 'coffee-script'; import mongoose from 'mongoose'; import autoinc from 'mongoose-id-autoinc'; -import logging from '../website/src/libs/logging'; +import logging from '../website/src/libs/api-v2/logging'; import nconf from 'nconf'; import utils from '../website/src/libs/utils'; import repl from 'repl'; diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 72f28d9f3d..21104f2885 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -7,7 +7,7 @@ var shared = require('../../../../common'); var User = require('./../../models/user').model; var Group = require('./../../models/group').model; var Challenge = require('./../../models/challenge').model; -var logging = require('./../../libs/logging'); +var logging = require('./../../libs/api-v2/logging'); var csv = require('express-csv'); var utils = require('../../libs/utils'); var api = module.exports; diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index b3a83dd80c..b8d3dd7bf1 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -12,7 +12,7 @@ var analytics = utils.analytics; var Group = require('./../../models/group').model; var Challenge = require('./../../models/challenge').model; var moment = require('moment'); -var logging = require('./../../libs/logging'); +var logging = require('./../../libs/api-v2/logging'); var acceptablePUTPaths; var api = module.exports; var firebase = require('../../libs/firebase'); diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/payments/paypal.js index 30970f72cf..19f9cc4136 100644 --- a/website/src/controllers/payments/paypal.js +++ b/website/src/controllers/payments/paypal.js @@ -5,7 +5,7 @@ var _ = require('lodash'); var url = require('url'); var User = require('mongoose').model('User'); var payments = require('./index'); -var logger = require('../../libs/logging'); +var logger = require('../../libs/api-v2/logging'); var ipn = require('paypal-ipn'); var paypal = require('paypal-rest-sdk'); var shared = require('../../../../common'); diff --git a/website/src/index.js b/website/src/index.js index 3f21cedbfb..9454b0c46b 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -2,12 +2,12 @@ require('babel/register'); // TODO remove this once we've fully converted over -require('coffee-script'); +require('coffee-script'); // Only do the minimal amount of work before forking just in case of a dyno restart var cluster = require('cluster'); var nconf = require('nconf'); -var logging = require('./libs/logging'); +var logging = require('./libs/api-v2/logging'); // Initialize configuration var setupNconf = require('./libs/api-v3/setupNconf'); @@ -33,4 +33,4 @@ if (cores !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) { }); } else { module.exports = require('./server.js'); -} \ No newline at end of file +} diff --git a/website/src/libs/logging.js b/website/src/libs/api-v2/logging.js similarity index 100% rename from website/src/libs/logging.js rename to website/src/libs/api-v2/logging.js diff --git a/website/src/libs/utils.js b/website/src/libs/utils.js index a39fa639ff..1016a35daf 100644 --- a/website/src/libs/utils.js +++ b/website/src/libs/utils.js @@ -16,7 +16,7 @@ module.exports.sendEmail = function(mailData) { } }); smtpTransport.sendMail(mailData, function(error, response){ - var logging = require('./logging'); + var logging = require('./api-v2/logging'); if(error) logging.error(error); else logging.info("Message sent: " + response.message); smtpTransport.close(); // shut down the connection pool, no more messages diff --git a/website/src/middlewares/errorHandler.js b/website/src/middlewares/errorHandler.js index 9a82316a75..36daacdf2b 100644 --- a/website/src/middlewares/errorHandler.js +++ b/website/src/middlewares/errorHandler.js @@ -1,4 +1,4 @@ -var logging = require('../libs/logging'); +var logging = require('../libs/api-v2/logging'); module.exports = function(err, req, res, next) { //res.locals.domain.emit('error', err); diff --git a/website/src/models/group.js b/website/src/models/group.js index 7ba8dac173..b2da0011d7 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -4,7 +4,7 @@ var User = require('./user').model; var shared = require('../../../common'); var _ = require('lodash'); var async = require('async'); -var logging = require('../libs/logging'); +var logging = require('../libs/api-v2/logging'); var Challenge = require('./../models/challenge').model; var firebase = require('../libs/firebase'); diff --git a/website/src/server.js b/website/src/server.js index 74c8e87e5e..224022b192 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -1,7 +1,7 @@ // TODO cleanup all comments when finished API v3 import nconf from 'nconf'; -import logging from './libs/logging'; +import logging from './libs/api-v2/logging'; import utils from './libs/utils'; import express from 'express'; import http from 'http'; @@ -161,4 +161,4 @@ server.listen(app.get('port'), () => { return logging.info(`Express server listening on port ${app.get('port')}`); }); -export default server; \ No newline at end of file +export default server; From a07d4dad128f71e4c47fa92c1e25a4f0ba6467f4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 Nov 2015 15:28:43 +0100 Subject: [PATCH 071/976] port i18n lib to es6 and extract middleware --- website/src/libs/api-v3/i18n.js | 98 +++++++++++++++++++ .../src/middlewares/api-v3/getUserLanguage.js | 85 ++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 website/src/libs/api-v3/i18n.js create mode 100644 website/src/middlewares/api-v3/getUserLanguage.js diff --git a/website/src/libs/api-v3/i18n.js b/website/src/libs/api-v3/i18n.js new file mode 100644 index 0000000000..96ad15a2e7 --- /dev/null +++ b/website/src/libs/api-v3/i18n.js @@ -0,0 +1,98 @@ +import fs from 'fs'; +import path from 'path'; +import _ from 'lodash'; +import shared from '../../../../common'; + +const localePath = path.join(__dirname, '/../../../../common/locales/'); + +// Store translations +export let translations = {}; +// Store MomentJS localization files +export let momentLangs = {}; + +// Handle differencies in language codes between MomentJS and /locales +let momentLangsMapping = { + en: 'en-gb', + en_GB: 'en-gb', // eslint-disable-line camelcase + no: 'nn', + zh: 'zh-cn', + es_419: 'es', // eslint-disable-line camelcase +}; + +function _loadTranslations (locale) { + let files = fs.readdirSync(path.join(localePath, locale)); + + translations[locale] = {}; + + files.forEach((file) => { + if (path.extname(file) !== '.json') return; + + // We use require to load and parse a JSON file + _.merge(translations[locale], require(path.join(localePath, locale, file))); // eslint-disable-line global-require + }); +} + +// First fetch English strings so we can merge them with missing strings in other languages +_loadTranslations('en'); + +// Then load all other languages +fs.readdirSync(localePath).forEach((file) => { + if (file === 'en' || fs.statSync(path.join(localePath, file)).isDirectory() === false) return; + _loadTranslations(file); + + // Merge missing strings from english + _.defaults(translations[file], translations.en); +}); + +// Add translations to shared +shared.i18n.translations = translations; + +export let langCodes = Object.keys(translations); + +export let avalaibleLanguages = langCodes.map((langCode) => { + return { + code: langCode, + name: translations[langCode].languageName, + }; +}); + +langCodes.forEach((code) => { + let lang = _.find(avalaibleLanguages, {code}); + + lang.momentLangCode = momentLangsMapping[code] || code; + + try { + // MomentJS lang files are JS files that has to be executed in the browser so we load them as plain text files + // We wrap everything in a try catch because the file might not exist + let f = fs.readFileSync(path.join(__dirname, `/../../../node_modules/moment/locale/${lang.momentLangCode}.js`), 'utf8'); + + momentLangs[code] = f; + } catch (e) { // eslint-disable-lint no-empty + // TODO implement some type of error loggin? + // The catch block is mandatory so can't be removed + } +}); + +// Remove en_GB from langCodes checked by browser to avoid it being +// used in place of plain original 'en' (it's an optional language that can be enabled only in setting) +export let defaultLangCodes = _.without(langCodes, 'en_GB'); + +// A map of languages that have different versions and the relative versions +export let multipleVersionsLanguages = { + es: ['es-419', 'es-mx', 'es-gt', 'es-cr', 'es-pa', 'es-do', 'es-ve', 'es-co', 'es-pe', + 'es-ar', 'es-ec', 'es-cl', 'es-uy', 'es-py', 'es-bo', 'es-sv', 'es-hn', + 'es-ni', 'es-pr'], + zh: ['zh-tw'], +}; + +// Export en strings only, temporary solution for mobile +// This is copied from middlewares/locals#t() +// TODO review if this can be removed since the old mobile app is no longer active +// stringName and vars are the allowed parameters +export function enTranslations (...args) { + let language = _.find(avalaibleLanguages, {code: 'en'}); + + // language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined); + args.push(language.code); + return shared.i18n.t(...args); +} \ No newline at end of file diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js new file mode 100644 index 0000000000..9a273b79bf --- /dev/null +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -0,0 +1,85 @@ +import { model as User } from '../../models/user'; +import accepts from 'accepts'; +import _ from 'lodash'; +import { + translations, + defaultLangCodes, + multipleVersionsLanguages, +} from '../../libs/api-v3/i18n'; + +function _getFromBrowser (req) { + let acceptedLanguages = accepts(req).languages(); + + let acceptable = _(acceptedLanguages).map((lang) => { + return lang.slice(0, 2); + }).uniq().value(); + + let matches = _.intersection(acceptable, defaultLangCodes); + + let iAcceptedCompleteLang = matches.length > 0 ? multipleVersionsLanguages.indexOf(matches[0].toLowerCase()) : -1; + + if (iAcceptedCompleteLang !== -1) { + let acceptedCompleteLang = _.find(acceptedLanguages, (accepted) => { + return accepted.slice(0, 2) === multipleVersionsLanguages[iAcceptedCompleteLang]; + }); + + if (acceptedCompleteLang) { + acceptedCompleteLang = acceptedCompleteLang.toLowerCase(); + } else { + return 'en'; + } + + if (matches[0] === 'es') { + // In case of a Latin American version of Spanish use 'es_419' + return multipleVersionsLanguages.es.indexOf(acceptedCompleteLang !== -1) ? 'es_419' : 'es'; + } else if (matches[0] === 'zh') { + let iChinese = multipleVersionsLanguages.zh.indexOf(acceptedCompleteLang.toLowerCase()); + + return iChinese !== -1 ? multipleVersionsLanguages.zh[iChinese] : 'zh'; + } else { + return 'en'; + } + } else if (matches.length > 0) { + return matches[0].toLowerCase(); + } else { + return 'en'; + } +} + +function _getFromUser (user, req) { + let lang; + + if (user && user.preferences.language && translations[user.preferences.language]) { + lang = user.preferences.language; + } else { + let preferred = _getFromBrowser(req); + + lang = translations[preferred] ? preferred : 'en'; + } + + return lang; +} + +export default function getUserLanguage (req, res, next) { + if (req.query.lang) { // In case the language is specified in the request url, use it + req.language = translations[req.query.lang] ? req.query.lang : 'en'; + return next(); + } else if (req.locals && req.locals.user) { // If the request is authenticated, use the user's preferred language + req.language = _getFromUser(req.locals.user, req); + return next(); + } else if (req.session && req.session.userId) { // Same thing if the user has a valid session + User + .findOne({ + _id: req.session.userId, + }, 'preferences.language') + .exec() + .then((user) => { + req.language = _getFromUser(user, req); + return next(); + }) + .catch(next); + } else { // Otherwise get from browser + req.language = _getFromUser(null, req); + return next(); + } +} \ No newline at end of file From a18c2f7444cccb0a5c900b617890800d721c1d7f Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 12 Nov 2015 09:03:28 -0600 Subject: [PATCH 072/976] Port v2 webhook lib to v3 --- test/api/v3/unit/libs/webhooks.test.js | 136 +++++++++++++++++++++++++ website/src/libs/api-v3/webhook.js | 30 ++++++ 2 files changed, 166 insertions(+) create mode 100644 test/api/v3/unit/libs/webhooks.test.js create mode 100644 website/src/libs/api-v3/webhook.js diff --git a/test/api/v3/unit/libs/webhooks.test.js b/test/api/v3/unit/libs/webhooks.test.js new file mode 100644 index 0000000000..62aeee3922 --- /dev/null +++ b/test/api/v3/unit/libs/webhooks.test.js @@ -0,0 +1,136 @@ +import request from 'request'; +import { sendTaskWebhook } from '../../../../../website/src/libs/api-v3/webhook'; + +describe('webhooks', () => { + + beforeEach(() => { + sandbox.stub(request, 'post'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('sendTaskWebhook', () => { + let task = { + details: { _id: 'task-id' }, + delta: 1.4, + direction: 'up' + }; + + let data = { + task: task, + user: { _id: 'user-id' } + }; + + it('does not send if no webhook endpoints exist', () => { + let webhooks = { }; + + sendTaskWebhook(webhooks, data); + + expect(request.post).to.not.be.called; + }); + + it('does not send if no webhooks are enabled', () => { + let webhooks = { + 'some-id': { + sort: 0, + id: 'some-id', + enabled: false, + url: 'http://example.org/endpoint' + } + }; + + sendTaskWebhook(webhooks, data); + + expect(request.post).to.not.be.called; + }); + + it('does not send if webhook url is not valid', () => { + let webhooks = { + 'some-id': { + sort: 0, + id: 'some-id', + enabled: true, + url: 'http://malformedurl/endpoint' + } + }; + + sendTaskWebhook(webhooks, data); + + expect(request.post).to.not.be.called; + }); + + it('sends task direction, task, task delta, and abridged user data', () => { + let webhooks = { + 'some-id': { + sort: 0, + id: 'some-id', + enabled: true, + url: 'http://example.org/endpoint' + } + }; + + sendTaskWebhook(webhooks, data); + + expect(request.post).to.be.calledOnce; + expect(request.post).to.be.calledWith({ + url: 'http://example.org/endpoint', + body: { + direction: 'up', + task: { _id: 'task-id' }, + delta: 1.4, + user: { + _id: 'user-id' + } + }, + json: true + }); + }); + + it('sends a post request for each webhook endpoint', () => { + let webhooks = { + 'some-id': { + sort: 0, + id: 'some-id', + enabled: true, + url: 'http://example.org/endpoint' + }, + 'second-webhook': { + sort: 1, + id: 'second-webhook', + enabled: true, + url: 'http://example.com/2/endpoint' + } + }; + + sendTaskWebhook(webhooks, data); + + expect(request.post).to.be.calledTwice; + expect(request.post).to.be.calledWith({ + url: 'http://example.org/endpoint', + body: { + direction: 'up', + task: { _id: 'task-id' }, + delta: 1.4, + user: { + _id: 'user-id' + } + }, + json: true + }); + expect(request.post).to.be.calledWith({ + url: 'http://example.com/2/endpoint', + body: { + direction: 'up', + task: { _id: 'task-id' }, + delta: 1.4, + user: { + _id: 'user-id' + } + }, + json: true + }); + }); + }); +}); diff --git a/website/src/libs/api-v3/webhook.js b/website/src/libs/api-v3/webhook.js new file mode 100644 index 0000000000..f1c6f13df2 --- /dev/null +++ b/website/src/libs/api-v3/webhook.js @@ -0,0 +1,30 @@ +import { each } from 'lodash'; +import { post } from 'request'; +import { isURL } from 'validator'; + +let _sendWebhook = (url, body) => { + post({ + url, + body, + json: true, + }); +}; + +let _isInvalidWebhook = (hook) => { + return !hook.enabled || !isURL(hook.url); +}; + +export function sendTaskWebhook (webhooks, data) { + each(webhooks, (hook) => { + if (_isInvalidWebhook(hook)) return; + + let body = { + direction: data.task.direction, + task: data.task.details, + delta: data.task.delta, + user: data.user, + }; + + _sendWebhook(hook.url, body); + }); +} From 1e106b3c5378e91af10b2931713f3b0505c796fc Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 12 Nov 2015 09:08:36 -0600 Subject: [PATCH 073/976] Move old webhooks to v2 lib --- website/src/controllers/api-v2/user.js | 2 +- website/src/libs/{ => api-v2}/webhook.js | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename website/src/libs/{ => api-v2}/webhook.js (100%) diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index b8d3dd7bf1..ed8aa05f25 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -16,7 +16,7 @@ var logging = require('./../../libs/api-v2/logging'); var acceptablePUTPaths; var api = module.exports; var firebase = require('../../libs/firebase'); -var webhook = require('../../libs/webhook'); +var webhook = require('../../libs/api-v2/webhook'); // api.purchase // Shared.ops diff --git a/website/src/libs/webhook.js b/website/src/libs/api-v2/webhook.js similarity index 100% rename from website/src/libs/webhook.js rename to website/src/libs/api-v2/webhook.js From 4af8a8f7aae815987b881d6b53961f629878e317 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 Nov 2015 16:24:08 +0100 Subject: [PATCH 074/976] add tests for i18n and getUserLanguage --- test/api/v3/unit/libs/i18n.test.js | 38 ++++++++ .../unit/middlewares/getUserLanguage.test.js | 89 +++++++++++++++++++ website/src/libs/api-v3/i18n.js | 2 +- .../src/middlewares/api-v3/getUserLanguage.js | 1 + 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/unit/libs/i18n.test.js create mode 100644 test/api/v3/unit/middlewares/getUserLanguage.test.js diff --git a/test/api/v3/unit/libs/i18n.test.js b/test/api/v3/unit/libs/i18n.test.js new file mode 100644 index 0000000000..8acef06486 --- /dev/null +++ b/test/api/v3/unit/libs/i18n.test.js @@ -0,0 +1,38 @@ +import { + translations, + localePath, + langCodes, +} from '../../../../../website/src/libs/api-v3/i18n'; +import fs from 'fs'; +import path from 'path'; + +describe('i18n', () => { + describe('translations', () => { + it('loads all locales', (done) => { + fs.readdir(localePath, (err, files) => { + if (err) return done(err); + let locales = []; + + files.forEach((file) => { + if (fs.statSync(path.join(localePath, file)).isDirectory() === false) return; + locales.push(file); + }); + + locales = locales.sort(); + let loaded = Object.keys(translations).sort(); + + expect(locales).to.eql(loaded); + done(); + }); + }); + + it('keeps a list all locales', () => { + expect(Object.keys(translations).sort()).to.eql(langCodes.sort()); + }); + + it('has an english translations', () => { + expect(langCodes).to.contain('en'); + expect(translations.en).to.be.an('object'); + }); + }); +}); diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js new file mode 100644 index 0000000000..4a9bb0ec04 --- /dev/null +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -0,0 +1,89 @@ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; +import getUserLanguage from '../../../../../website/src/middlewares/api-v3/getUserLanguage'; +import Q from 'q'; +import { model as User } from '../../../../../website/src/models/user'; +import { translations } from '../../../../../website/src/libs/api-v3/i18n'; +import accepts from 'accepts'; + +describe('getUserLanguage', () => { + let res, req, next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + + sandbox.stub(User, 'findOne').returns({ + exec() { + return Q.resolve({ + preferences: { + language: 'it', + } + }); + } + }); + }); + + describe('query parameter', () => { + it('uses the language in the query parameter if avalaible', () => { + req.query = { + lang: 'es', + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('es'); + }); + + it('falls back to english if the query parameter language does not exists', () => { + req.query = { + lang: 'bla', + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('en'); + }); + }); + + describe('authorized request', () => { + it('uses the user preferred language if avalaible', () => { + req.locals = { + user: { + preferences: { + language: 'it', + }, + }, + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('it'); + }); + + xit('falls back to english if the user preferred language is not avalaible', () => { + req.locals = { + user: { + preferences: { + language: 'bla', + }, + }, + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('en'); + }); + }); + + describe('request with session', () => { + it('uses the user preferred language if avalaible', () => { + req.session = { + userId: 123 + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('it'); + }); + }); +}); diff --git a/website/src/libs/api-v3/i18n.js b/website/src/libs/api-v3/i18n.js index 96ad15a2e7..e8a1b630cc 100644 --- a/website/src/libs/api-v3/i18n.js +++ b/website/src/libs/api-v3/i18n.js @@ -3,7 +3,7 @@ import path from 'path'; import _ from 'lodash'; import shared from '../../../../common'; -const localePath = path.join(__dirname, '/../../../../common/locales/'); +export const localePath = path.join(__dirname, '/../../../../common/locales/'); // Store translations export let translations = {}; diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index 9a273b79bf..813fe848fc 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -75,6 +75,7 @@ export default function getUserLanguage (req, res, next) { .exec() .then((user) => { req.language = _getFromUser(user, req); + console.log(req.language); return next(); }) .catch(next); From de21b72027403dc70e6a75de952d9f1164673fe7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 Nov 2015 16:43:02 +0100 Subject: [PATCH 075/976] move old i18n version to api-v2 folder and use new i18n where possible --- test/common/algos.mocha.coffee | 2 +- test/common/dailies.coffee | 2 +- test/common/user.fns.ultimateGear.test.js | 2 +- test/helpers/api-integration.helper.js | 2 +- test/helpers/api-unit.helper.js | 2 +- test/helpers/content.helper.js | 2 +- website/src/controllers/api-v2/auth.js | 2 +- website/src/libs/api-v2/analytics.js | 2 +- website/src/libs/{ => api-v2}/i18n.js | 0 website/src/libs/api-v3/analyticsService.js | 1 - website/src/middlewares/locals.js | 2 +- website/src/routes/api-v2/auth.js | 2 +- website/src/routes/api-v2/coupon.js | 2 +- website/src/routes/api-v2/swagger.js | 2 +- website/src/routes/api-v2/unsubscription.js | 2 +- website/src/routes/dataexport.js | 2 +- website/src/routes/pages.js | 2 +- website/src/routes/payments.js | 2 +- website/src/server.js | 2 +- 19 files changed, 17 insertions(+), 18 deletions(-) rename website/src/libs/{ => api-v2}/i18n.js (100%) diff --git a/test/common/algos.mocha.coffee b/test/common/algos.mocha.coffee index b413c3e000..cec964a8bd 100644 --- a/test/common/algos.mocha.coffee +++ b/test/common/algos.mocha.coffee @@ -3,7 +3,7 @@ expect = require 'expect.js' sinon = require 'sinon' moment = require 'moment' shared = require '../../common/script/index.coffee' -shared.i18n.translations = require('../../website/src/libs/i18n.js').translations +shared.i18n.translations = require('../../website/src/libs/api-v2/i18n.js').translations test_helper = require './test_helper' test_helper.addCustomMatchers() $w = (s)->s.split(' ') diff --git a/test/common/dailies.coffee b/test/common/dailies.coffee index e5e3fad81a..869583d37b 100644 --- a/test/common/dailies.coffee +++ b/test/common/dailies.coffee @@ -3,7 +3,7 @@ expect = require 'expect.js' sinon = require 'sinon' moment = require 'moment' shared = require '../../common/script/index.coffee' -shared.i18n.translations = require('../../website/src/libs/i18n.js').translations +shared.i18n.translations = require('../../website/src/libs/api-v2/i18n.js').translations repeatWithoutLastWeekday = ()-> repeat = {su:true,m:true,t:true,w:true,th:true,f:true,s:true} diff --git a/test/common/user.fns.ultimateGear.test.js b/test/common/user.fns.ultimateGear.test.js index d2b82f43e8..33d3c4f52c 100644 --- a/test/common/user.fns.ultimateGear.test.js +++ b/test/common/user.fns.ultimateGear.test.js @@ -1,7 +1,7 @@ 'use strict'; var shared = require('../../common/script/index.coffee'); -shared.i18n.translations = require('../../website/src/libs/i18n.js').translations +shared.i18n.translations = require('../../website/src/libs/api-v2/i18n.js').translations require('./test_helper'); diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 1ddaa75b0d..6e78046f8e 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -9,7 +9,7 @@ import {v4 as generateUUID} from 'uuid'; import superagent from 'superagent'; import i18n from '../../common/script/src/i18n'; require('coffee-script'); -i18n.translations = require('../../website/src/libs/i18n.js').translations; +i18n.translations = require('../../website/src/libs/api-v3/i18n').translations; const API_TEST_SERVER_PORT = 3003; diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 95a34316e1..2e789d53a9 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -3,7 +3,7 @@ import { model as User } from '../../website/src/models/user' import { model as Group } from '../../website/src/models/group' import i18n from '../../common/script/src/i18n'; require('coffee-script'); -i18n.translations = require('../../website/src/libs/i18n.js').translations; +i18n.translations = require('../../website/src/libs/api-v3/i18n.js').translations; afterEach(() => { sandbox.restore(); diff --git a/test/helpers/content.helper.js b/test/helpers/content.helper.js index c54a62d508..2f885f0830 100644 --- a/test/helpers/content.helper.js +++ b/test/helpers/content.helper.js @@ -3,7 +3,7 @@ import {each} from 'lodash'; import i18n from '../../common/script/src/i18n'; require('coffee-script'); -i18n.translations = require('../../website/src/libs/i18n.js').translations; +i18n.translations = require('../../website/src/libs/api-v3/i18n').translations; export const STRING_ERROR_MSG = 'Error processing the string. Please see Help > Report a Bug.'; export const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/; diff --git a/website/src/controllers/api-v2/auth.js b/website/src/controllers/api-v2/auth.js index b01e1aba3d..524615760e 100644 --- a/website/src/controllers/api-v2/auth.js +++ b/website/src/controllers/api-v2/auth.js @@ -10,7 +10,7 @@ var FirebaseTokenGenerator = require('firebase-token-generator'); var User = require('../../models/user').model; var EmailUnsubscription = require('../../models/emailUnsubscription').model; var analytics = utils.analytics; -var i18n = require('./../../libs/i18n'); +var i18n = require('./../../libs/api-v2/i18n'); var isProd = nconf.get('NODE_ENV') === 'production'; diff --git a/website/src/libs/api-v2/analytics.js b/website/src/libs/api-v2/analytics.js index 315dc4b618..f7c1391a11 100644 --- a/website/src/libs/api-v2/analytics.js +++ b/website/src/libs/api-v2/analytics.js @@ -1,5 +1,5 @@ require('coffee-script'); -require('./i18n'); +require('./api-v2/i18n'); var _ = require('lodash'); var Content = require('../../../common').content; diff --git a/website/src/libs/i18n.js b/website/src/libs/api-v2/i18n.js similarity index 100% rename from website/src/libs/i18n.js rename to website/src/libs/api-v2/i18n.js diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js index 6e460097eb..81be37322e 100644 --- a/website/src/libs/api-v3/analyticsService.js +++ b/website/src/libs/api-v3/analyticsService.js @@ -10,7 +10,6 @@ import { import { content as Content } from '../../../../common'; require('coffee-script'); -require('../../libs/i18n'); const AMPLIUDE_TOKEN = nconf.get('AMPLITUDE_KEY'); const GA_TOKEN = nconf.get('GA_ID'); diff --git a/website/src/middlewares/locals.js b/website/src/middlewares/locals.js index 2f238a6a99..e6b2fa8818 100644 --- a/website/src/middlewares/locals.js +++ b/website/src/middlewares/locals.js @@ -2,7 +2,7 @@ var nconf = require('nconf'); var _ = require('lodash'); var utils = require('../libs/utils'); var shared = require('../../../common'); -var i18n = require('../libs/i18n'); +var i18n = require('../libs/api-v2/i18n'); var buildManifest = require('../libs/buildManifest'); var shared = require('../../../common'); var forceRefresh = require('./forceRefresh'); diff --git a/website/src/routes/api-v2/auth.js b/website/src/routes/api-v2/auth.js index c60f44547b..d76891e40d 100644 --- a/website/src/routes/api-v2/auth.js +++ b/website/src/routes/api-v2/auth.js @@ -1,6 +1,6 @@ var auth = require('../../controllers/api-v2/auth'); var express = require('express'); -var i18n = require('../../libs/i18n'); +var i18n = require('../../libs/api-v2/i18n'); var router = new express.Router(); /* auth.auth*/ diff --git a/website/src/routes/api-v2/coupon.js b/website/src/routes/api-v2/coupon.js index 811d81a6f2..132184a585 100644 --- a/website/src/routes/api-v2/coupon.js +++ b/website/src/routes/api-v2/coupon.js @@ -3,7 +3,7 @@ var express = require('express'); var router = new express.Router(); var auth = require('../../controllers/api-v2/auth'); var coupon = require('../../controllers/api-v2/coupon'); -var i18n = require('../../libs/i18n'); +var i18n = require('../../libs/api-v2/i18n'); router.get('/api/v2/coupons', auth.authWithUrl, i18n.getUserLanguage, coupon.ensureAdmin, coupon.getCoupons); router.post('/api/v2/coupons/generate/:event', auth.auth, i18n.getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons); diff --git a/website/src/routes/api-v2/swagger.js b/website/src/routes/api-v2/swagger.js index d93cfe6271..94bac8ad7b 100644 --- a/website/src/routes/api-v2/swagger.js +++ b/website/src/routes/api-v2/swagger.js @@ -20,7 +20,7 @@ var nconf = require("nconf"); var cron = user.cron; var _ = require('lodash'); var content = require('../../../../common').content; -var i18n = require('../../libs/i18n'); +var i18n = require('../../libs/api-v2/i18n'); var forceRefresh = require('../../middlewares/forceRefresh').middleware; module.exports = function(swagger, v2) { diff --git a/website/src/routes/api-v2/unsubscription.js b/website/src/routes/api-v2/unsubscription.js index 942a396eef..3b31305b5a 100644 --- a/website/src/routes/api-v2/unsubscription.js +++ b/website/src/routes/api-v2/unsubscription.js @@ -1,6 +1,6 @@ var express = require('express'); var router = new express.Router(); -var i18n = require('../../libs/i18n'); +var i18n = require('../../libs/api-v2/i18n'); var unsubscription = require('../../controllers/api-v2/unsubscription'); router.get('/unsubscribe', i18n.getUserLanguage, unsubscription.unsubscribe); diff --git a/website/src/routes/dataexport.js b/website/src/routes/dataexport.js index 5bf02a228c..d7328434a0 100644 --- a/website/src/routes/dataexport.js +++ b/website/src/routes/dataexport.js @@ -3,7 +3,7 @@ var router = new express.Router(); var dataexport = require('../controllers/dataexport'); var auth = require('../controllers/api-v2/auth'); var nconf = require('nconf'); -var i18n = require('../libs/i18n'); +var i18n = require('../libs/api-v2/i18n'); var locals = require('../middlewares/locals'); /* Data export */ diff --git a/website/src/routes/pages.js b/website/src/routes/pages.js index 27bc9a3619..7c847722e7 100644 --- a/website/src/routes/pages.js +++ b/website/src/routes/pages.js @@ -3,7 +3,7 @@ var express = require('express'); var router = new express.Router(); var _ = require('lodash'); var locals = require('../middlewares/locals'); -var i18n = require('../libs/i18n'); +var i18n = require('../libs/api-v2/i18n'); // -------- App -------- router.get('/', i18n.getUserLanguage, locals, function(req, res) { diff --git a/website/src/routes/payments.js b/website/src/routes/payments.js index 41c03210be..4989b113a1 100644 --- a/website/src/routes/payments.js +++ b/website/src/routes/payments.js @@ -3,7 +3,7 @@ var express = require('express'); var router = new express.Router(); var auth = require('../controllers/api-v2/auth'); var payments = require('../controllers/payments'); -var i18n = require('../libs/i18n'); +var i18n = require('../libs/api-v2/i18n'); router.get('/paypal/checkout', auth.authWithUrl, i18n.getUserLanguage, payments.paypalCheckout); router.get('/paypal/checkout/success', i18n.getUserLanguage, payments.paypalCheckoutSuccess); diff --git a/website/src/server.js b/website/src/server.js index 224022b192..5dd297638d 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -17,7 +17,7 @@ import attachMiddlewares from './middlewares/api-v3/index'; utils.setupConfig(); // Setup translations -// let i18n = require('./libs/i18n'); +// let i18n = require('./libs/api-v2/i18n'); const IS_PROD = nconf.get('IS_PROD'); // const IS_DEV = nconf.get('IS_DEV'); From 79c20105a193a6be79849987d6cdec8eedb7b681 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 Nov 2015 15:16:00 +0100 Subject: [PATCH 076/976] use new logger where possible --- tasks/gulp-console.js | 4 ++-- website/src/index.js | 4 ++-- website/src/server.js | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tasks/gulp-console.js b/tasks/gulp-console.js index 84c389578b..46f4ea44ea 100644 --- a/tasks/gulp-console.js +++ b/tasks/gulp-console.js @@ -1,6 +1,6 @@ import mongoose from 'mongoose'; import autoinc from 'mongoose-id-autoinc'; -import logging from '../website/src/libs/api-v2/logging'; +import logger from '../website/src/libs/api-v3/logger'; import nconf from 'nconf'; import utils from '../website/src/libs/utils'; import repl from 'repl'; @@ -36,7 +36,7 @@ let improveRepl = (context) => { mongooseOptions, function(err) { if (err) throw err; - logging.info('Connected with Mongoose'); + logger.info('Connected with Mongoose'); } ) ); diff --git a/website/src/index.js b/website/src/index.js index aa72ec1a27..2097a7b834 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -4,7 +4,7 @@ require('babel/register'); // Only do the minimal amount of work before forking just in case of a dyno restart var cluster = require('cluster'); var nconf = require('nconf'); -var logging = require('./libs/api-v2/logging'); +var logger = require('./libs/api-v2/logger'); // Initialize configuration var setupNconf = require('./libs/api-v3/setupNconf'); @@ -26,7 +26,7 @@ if (cores !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) { cluster.on('disconnect', (worker) => { var w = cluster.fork(); // replace the dead worker - logging.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid); + logger.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid); }); } else { module.exports = require('./server.js'); diff --git a/website/src/server.js b/website/src/server.js index 224022b192..2562c9bb87 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -1,7 +1,7 @@ // TODO cleanup all comments when finished API v3 import nconf from 'nconf'; -import logging from './libs/api-v2/logging'; +import logger from './libs/api-v3/logger'; import utils from './libs/utils'; import express from 'express'; import http from 'http'; @@ -37,7 +37,7 @@ let mongooseOptions = !IS_PROD ? {} : { }; let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { if (err) throw err; - logging.info('Connected with Mongoose'); + logger.info('Connected with Mongoose'); }); autoinc.init(db); @@ -158,7 +158,7 @@ oldApp.use(require('./middlewares/errorHandler')); server.on('request', app); server.listen(app.get('port'), () => { - return logging.info(`Express server listening on port ${app.get('port')}`); + return logger.info(`Express server listening on port ${app.get('port')}`); }); export default server; From eab9d7b3bab3740eb45c4fb2418381ac6ab3f0c7 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 09:15:11 -0600 Subject: [PATCH 077/976] Reorganize i18n tests. --- test/api/v3/unit/libs/i18n.test.js | 48 +++++++++++++++++------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/test/api/v3/unit/libs/i18n.test.js b/test/api/v3/unit/libs/i18n.test.js index 8acef06486..5bafc201dc 100644 --- a/test/api/v3/unit/libs/i18n.test.js +++ b/test/api/v3/unit/libs/i18n.test.js @@ -7,32 +7,40 @@ import fs from 'fs'; import path from 'path'; describe('i18n', () => { + let listOfLocales = []; + + before((done) => { + fs.readdir(localePath, (err, files) => { + if (err) return done(err); + + files.forEach((file) => { + if (fs.statSync(path.join(localePath, file)).isDirectory() === false) return; + listOfLocales.push(file); + }); + + listOfLocales = listOfLocales.sort(); + done(); + }); + }); + describe('translations', () => { - it('loads all locales', (done) => { - fs.readdir(localePath, (err, files) => { - if (err) return done(err); - let locales = []; - - files.forEach((file) => { - if (fs.statSync(path.join(localePath, file)).isDirectory() === false) return; - locales.push(file); - }); - - locales = locales.sort(); - let loaded = Object.keys(translations).sort(); - - expect(locales).to.eql(loaded); - done(); + it('includes a translation object for each locale', () => { + listOfLocales.forEach((locale) => { + expect(translations[locale]).to.be.an('object'); }); }); + }); - it('keeps a list all locales', () => { - expect(Object.keys(translations).sort()).to.eql(langCodes.sort()); + describe('localePath', () => { + it('is an absolute path to common/locales/', () => { + expect(localePath).to.match(/.*\/common\/locales\//); + expect(localePath) }); + }); - it('has an english translations', () => { - expect(langCodes).to.contain('en'); - expect(translations.en).to.be.an('object'); + describe('langCodes', () => { + it('is a list of all the language codes', () => { + expect(langCodes.sort()).to.eql(listOfLocales); }); }); }); From 939bc893c66a025ebeaf00657f8fa5444d35751b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 09:21:22 -0600 Subject: [PATCH 078/976] Correct test to check req status after function is finished. --- test/api/v3/unit/middlewares/getUserLanguage.test.js | 5 +++-- website/src/middlewares/api-v3/getUserLanguage.js | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 4a9bb0ec04..3346ef40d6 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -82,8 +82,9 @@ describe('getUserLanguage', () => { userId: 123 }; - getUserLanguage(req, res, next); - expect(req.language).to.equal('it'); + getUserLanguage(req, res, () => { + expect(req.language).to.equal('it'); + }); }); }); }); diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index 813fe848fc..fd959cab94 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -75,7 +75,6 @@ export default function getUserLanguage (req, res, next) { .exec() .then((user) => { req.language = _getFromUser(user, req); - console.log(req.language); return next(); }) .catch(next); @@ -83,4 +82,4 @@ export default function getUserLanguage (req, res, next) { req.language = _getFromUser(null, req); return next(); } -} \ No newline at end of file +} From db67451a38401fd2de2c2fa5c3058a1537325d68 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 09:24:03 -0600 Subject: [PATCH 079/976] Add done callback to test. --- test/api/v3/unit/middlewares/getUserLanguage.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 3346ef40d6..232a036cd3 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -77,13 +77,14 @@ describe('getUserLanguage', () => { }); describe('request with session', () => { - it('uses the user preferred language if avalaible', () => { + it('uses the user preferred language if avalaible', (done) => { req.session = { userId: 123 }; getUserLanguage(req, res, () => { expect(req.language).to.equal('it'); + done(); }); }); }); From a6a9c3c74f87d95569fa0c650272f7e362f9125e Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 09:27:17 -0600 Subject: [PATCH 080/976] Use context blocks instead of describe blocks. --- test/api/v3/unit/middlewares/getUserLanguage.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 232a036cd3..7f5f9da7b3 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -28,7 +28,7 @@ describe('getUserLanguage', () => { }); }); - describe('query parameter', () => { + context('query parameter', () => { it('uses the language in the query parameter if avalaible', () => { req.query = { lang: 'es', @@ -48,7 +48,7 @@ describe('getUserLanguage', () => { }); }); - describe('authorized request', () => { + context('authorized request', () => { it('uses the user preferred language if avalaible', () => { req.locals = { user: { From 810a818334c42e3e5f96e05a7b268a19eef2d9e6 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 09:36:18 -0600 Subject: [PATCH 081/976] Add headers to req generator and unpend failing test. --- test/api/v3/unit/middlewares/getUserLanguage.test.js | 8 +++++--- test/helpers/api-unit.helper.js | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 7f5f9da7b3..a8fbb88de7 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -62,7 +62,7 @@ describe('getUserLanguage', () => { expect(req.language).to.equal('it'); }); - xit('falls back to english if the user preferred language is not avalaible', () => { + it('falls back to english if the user preferred language is not avalaible', (done) => { req.locals = { user: { preferences: { @@ -71,8 +71,10 @@ describe('getUserLanguage', () => { }, }; - getUserLanguage(req, res, next); - expect(req.language).to.equal('en'); + getUserLanguage(req, res, () => { + expect(req.language).to.equal('en'); + done(); + }); }); }); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 2e789d53a9..62516f2751 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -35,6 +35,7 @@ export function generateReq(options={}) { let defaultReq = { body: {}, query: {}, + headers: {}, }; return defaults(options, defaultReq); From 38749eae1b3413f7eccfc9127990db6cd6e08f14 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 Nov 2015 17:30:12 +0100 Subject: [PATCH 082/976] fix i18n.js so that it supports lodash 3 --- common/script/i18n.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/common/script/i18n.js b/common/script/i18n.js index 59e5e358da..f35daa232a 100644 --- a/common/script/i18n.js +++ b/common/script/i18n.js @@ -6,7 +6,7 @@ module.exports = { strings: null, translations: {}, t: function(stringName) { - var clonedVars, e, locale, string, stringNotFound, vars; + var clonedVars, e, error, error1, locale, string, stringNotFound, vars; vars = arguments[1]; if (_.isString(arguments[1])) { vars = null; @@ -27,9 +27,9 @@ module.exports = { clonedVars.locale = locale; if (string) { try { - return _.template(string, clonedVars); - } catch (_error) { - e = _error; + return _.template(string)(clonedVars); + } catch (error) { + e = error; return 'Error processing the string. Please see Help > Report a Bug.'; } } else { @@ -39,13 +39,13 @@ module.exports = { stringNotFound = module.exports.translations[locale] && module.exports.translations[locale].stringNotFound; } try { - return _.template(stringNotFound, { + return _.template(stringNotFound)({ string: stringName }); - } catch (_error) { - e = _error; + } catch (error1) { + e = error1; return 'Error processing the string. Please see Help > Report a Bug.'; } } } -}; +}; \ No newline at end of file From 6bb107f9e021f19e1c1bd368ae9bd74b329b01ec Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 10:35:27 -0600 Subject: [PATCH 083/976] Set up analytics middleware to test prod and non prod behavior. --- .../api/v3/unit/middlewares/analytics.test.js | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index 75df6c72dc..668196c966 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -5,28 +5,49 @@ import { } from '../../../../helpers/api-unit.helper'; import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService' import nconf from 'nconf'; -import attachAnalytics from '../../../../../website/src/middlewares/api-v3/analytics'; describe('analytics middleware', function() { let res, req, next; + let pathToAnalyticsMiddleware = '../../../../../website/src/middlewares/api-v3/analytics'; beforeEach(() => { + // The nconf.get('IS_PROD') occurs when the file is required + // Since node caches IS_PROD, we have to delete it from the cache + // to test prod vs non-prod behaviors + delete require.cache[require.resolve(pathToAnalyticsMiddleware)]; + res = generateRes(); req = generateReq(); next = generateNext(); }); it('attaches analytics object res.locals', function() { + let attachAnalytics = require(pathToAnalyticsMiddleware); + attachAnalytics(req, res, next); expect(res.analytics).to.exist; }); it('attaches stubbed methods for non-prod environments', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false); + let attachAnalytics = require(pathToAnalyticsMiddleware); + attachAnalytics(req, res, next); expect(res.analytics.track).to.eql(analyticsService.mockAnalyticsService.track); expect(res.analytics.trackPurchase).to.eql(analyticsService.mockAnalyticsService.trackPurchase); }); + + it('attaches real methods for prod environments', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); + + let attachAnalytics = require(pathToAnalyticsMiddleware); + + attachAnalytics(req, res, next); + + expect(res.analytics.track).to.eql(analyticsService.track); + expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase); + }); }); From c6f059c1df8ab630c97b5c31078d98087f843afe Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 10:40:31 -0600 Subject: [PATCH 084/976] Move delete cacth to afterEach --- test/api/v3/unit/middlewares/analytics.test.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index 668196c966..e2808bc881 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -11,14 +11,16 @@ describe('analytics middleware', function() { let pathToAnalyticsMiddleware = '../../../../../website/src/middlewares/api-v3/analytics'; beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + }); + + afterEach(() => { // The nconf.get('IS_PROD') occurs when the file is required // Since node caches IS_PROD, we have to delete it from the cache // to test prod vs non-prod behaviors delete require.cache[require.resolve(pathToAnalyticsMiddleware)]; - - res = generateRes(); - req = generateReq(); - next = generateNext(); }); it('attaches analytics object res.locals', function() { From 879ec9740846386e6fd05798b93edd1b2f9071a1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 Nov 2015 17:41:18 +0100 Subject: [PATCH 085/976] fix some requires --- common/dist/sprites/habitrpg-shared.css | 2 +- website/src/index.js | 2 +- website/src/libs/api-v2/analytics.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/dist/sprites/habitrpg-shared.css b/common/dist/sprites/habitrpg-shared.css index add5354098..c204501040 100644 --- a/common/dist/sprites/habitrpg-shared.css +++ b/common/dist/sprites/habitrpg-shared.css @@ -1 +1 @@ -.2014_Fall_HealerPROMO2{background-image:url(spritesmith-largeSprites-0.png);background-position:-387px -950px;width:90px;height:90px}.2014_Fall_Mage_PROMO9{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -483px;width:120px;height:90px}.2014_Fall_RoguePROMO3{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -680px;width:105px;height:90px}.2014_Fall_Warrior_PROMO{background-image:url(spritesmith-largeSprites-0.png);background-position:-569px -950px;width:90px;height:90px}.promo_backtoschool{background-image:url(spritesmith-largeSprites-0.png);background-position:-220px -220px;width:150px;height:150px}.promo_burnout{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -220px;width:219px;height:240px}.promo_classes_fall_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-326px -664px;width:321px;height:100px}.promo_classes_fall_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -564px;width:377px;height:99px}.promo_dilatoryDistress{background-image:url(spritesmith-largeSprites-0.png);background-position:-660px -950px;width:90px;height:90px}.promo_enchanted_armoire{background-image:url(spritesmith-largeSprites-0.png);background-position:-378px -564px;width:374px;height:76px}.promo_enchanted_armoire_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-289px -859px;width:217px;height:90px}.promo_enchanted_armoire_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-725px -859px;width:180px;height:90px}.promo_enchanted_armoire_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-933px -950px;width:90px;height:90px}.promo_enchanted_armoire_201511{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -392px;width:122px;height:90px}.promo_habitica{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -166px;width:175px;height:175px}.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -148px;width:100px;height:137px}.customize-option.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-995px -163px;width:60px;height:60px}.promo_item_notif{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -347px;width:249px;height:102px}.promo_mystery_201405{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -182px;width:90px;height:90px}.promo_mystery_201406{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -771px;width:90px;height:96px}.promo_mystery_201407{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -704px;width:42px;height:62px}.promo_mystery_201408{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -428px;width:60px;height:71px}.promo_mystery_201409{background-image:url(spritesmith-largeSprites-0.png);background-position:-842px -950px;width:90px;height:90px}.promo_mystery_201410{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -364px;width:72px;height:63px}.promo_mystery_201411{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px 0;width:90px;height:90px}.promo_mystery_201412{background-image:url(spritesmith-largeSprites-0.png);background-position:-1154px -634px;width:42px;height:66px}.promo_mystery_201501{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -570px;width:48px;height:63px}.promo_mystery_201502{background-image:url(spritesmith-largeSprites-0.png);background-position:-478px -950px;width:90px;height:90px}.promo_mystery_201503{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -273px;width:90px;height:90px}.promo_mystery_201504{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -500px;width:60px;height:69px}.promo_mystery_201505{background-image:url(spritesmith-largeSprites-0.png);background-position:-751px -950px;width:90px;height:90px}.promo_mystery_201506{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -634px;width:42px;height:69px}.promo_mystery_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -574px;width:90px;height:105px}.promo_mystery_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-293px -950px;width:93px;height:90px}.promo_mystery_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -91px;width:90px;height:90px}.promo_mystery_201510{background-image:url(spritesmith-largeSprites-0.png);background-position:-199px -950px;width:93px;height:90px}.promo_mystery_3014{background-image:url(spritesmith-largeSprites-0.png);background-position:-507px -859px;width:217px;height:90px}.promo_orca{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -286px;width:105px;height:105px}.promo_partyhats{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -868px;width:115px;height:47px}.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -775px;width:330px;height:83px}.customize-option.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -790px;width:60px;height:60px}.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px 0;width:140px;height:147px}.customize-option.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-995px -15px;width:60px;height:60px}.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-331px -775px;width:330px;height:83px}.customize-option.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-356px -790px;width:60px;height:60px}.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -950px;width:198px;height:91px}.customize-option.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -965px;width:60px;height:60px}.promo_springclasses2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-430px -461px;width:288px;height:90px}.promo_springclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -859px;width:288px;height:90px}.promo_summer_classes_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -461px;width:429px;height:102px}.promo_summer_classes_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-648px -664px;width:300px;height:88px}.promo_updos{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -342px;width:156px;height:147px}.promo_veteran_pets{background-image:url(spritesmith-largeSprites-0.png);background-position:-753px -564px;width:146px;height:75px}.promo_winterclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -664px;width:325px;height:110px}.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-220px -371px;width:152px;height:75px}.customize-option.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-245px -386px;width:60px;height:60px}.party_preview{background-image:url(spritesmith-largeSprites-0.png);background-position:0 0;width:451px;height:219px}.welcome_basic_avatars{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -181px;width:246px;height:165px}.welcome_promo_party{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px 0;width:270px;height:180px}.welcome_sample_tasks{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px 0;width:246px;height:165px}.achievement-alien{background-image:url(spritesmith-main-0.png);background-position:-1092px -1036px;width:24px;height:26px}.achievement-alpha{background-image:url(spritesmith-main-0.png);background-position:-1612px -1547px;width:24px;height:26px}.achievement-armor{background-image:url(spritesmith-main-0.png);background-position:-1096px -1006px;width:24px;height:26px}.achievement-boot{background-image:url(spritesmith-main-0.png);background-position:-1071px -1006px;width:24px;height:26px}.achievement-bow{background-image:url(spritesmith-main-0.png);background-position:-1046px -1006px;width:24px;height:26px}.achievement-burnout{background-image:url(spritesmith-main-0.png);background-position:-1021px -1006px;width:24px;height:26px}.achievement-cactus{background-image:url(spritesmith-main-0.png);background-position:-996px -1006px;width:24px;height:26px}.achievement-cake{background-image:url(spritesmith-main-0.png);background-position:-971px -1006px;width:24px;height:26px}.achievement-cave{background-image:url(spritesmith-main-0.png);background-position:-946px -1006px;width:24px;height:26px}.achievement-coffin{background-image:url(spritesmith-main-0.png);background-position:-921px -1006px;width:24px;height:26px}.achievement-comment{background-image:url(spritesmith-main-0.png);background-position:-896px -1006px;width:24px;height:26px}.achievement-costumeContest{background-image:url(spritesmith-main-0.png);background-position:-871px -1006px;width:24px;height:26px}.achievement-dilatory{background-image:url(spritesmith-main-0.png);background-position:-846px -1006px;width:24px;height:26px}.achievement-firefox{background-image:url(spritesmith-main-0.png);background-position:-1096px -979px;width:24px;height:26px}.achievement-greeting{background-image:url(spritesmith-main-0.png);background-position:-1071px -979px;width:24px;height:26px}.achievement-habitBirthday{background-image:url(spritesmith-main-0.png);background-position:-1046px -979px;width:24px;height:26px}.achievement-habiticaDay{background-image:url(spritesmith-main-0.png);background-position:-1021px -979px;width:24px;height:26px}.achievement-heart{background-image:url(spritesmith-main-0.png);background-position:-996px -979px;width:24px;height:26px}.achievement-karaoke{background-image:url(spritesmith-main-0.png);background-position:-971px -979px;width:24px;height:26px}.achievement-ninja{background-image:url(spritesmith-main-0.png);background-position:-946px -979px;width:24px;height:26px}.achievement-nye{background-image:url(spritesmith-main-0.png);background-position:-921px -979px;width:24px;height:26px}.achievement-perfect{background-image:url(spritesmith-main-0.png);background-position:-1587px -1547px;width:24px;height:26px}.achievement-rat{background-image:url(spritesmith-main-0.png);background-position:-871px -979px;width:24px;height:26px}.achievement-seafoam{background-image:url(spritesmith-main-0.png);background-position:-846px -979px;width:24px;height:26px}.achievement-shield{background-image:url(spritesmith-main-0.png);background-position:-1182px -1092px;width:24px;height:26px}.achievement-shinySeed{background-image:url(spritesmith-main-0.png);background-position:-1157px -1092px;width:24px;height:26px}.achievement-snowball{background-image:url(spritesmith-main-0.png);background-position:-1132px -1092px;width:24px;height:26px}.achievement-spookDust{background-image:url(spritesmith-main-0.png);background-position:-1273px -1183px;width:24px;height:26px}.achievement-stoikalm{background-image:url(spritesmith-main-0.png);background-position:-1248px -1183px;width:24px;height:26px}.achievement-sun{background-image:url(spritesmith-main-0.png);background-position:-1223px -1183px;width:24px;height:26px}.achievement-sword{background-image:url(spritesmith-main-0.png);background-position:-1364px -1274px;width:24px;height:26px}.achievement-thankyou{background-image:url(spritesmith-main-0.png);background-position:-1339px -1274px;width:24px;height:26px}.achievement-thermometer{background-image:url(spritesmith-main-0.png);background-position:-1314px -1274px;width:24px;height:26px}.achievement-tree{background-image:url(spritesmith-main-0.png);background-position:-1455px -1365px;width:24px;height:26px}.achievement-triadbingo{background-image:url(spritesmith-main-0.png);background-position:-1430px -1365px;width:24px;height:26px}.achievement-ultimate-healer{background-image:url(spritesmith-main-0.png);background-position:-1405px -1365px;width:24px;height:26px}.achievement-ultimate-mage{background-image:url(spritesmith-main-0.png);background-position:-1546px -1456px;width:24px;height:26px}.achievement-ultimate-rogue{background-image:url(spritesmith-main-0.png);background-position:-1521px -1456px;width:24px;height:26px}.achievement-ultimate-warrior{background-image:url(spritesmith-main-0.png);background-position:-1496px -1456px;width:24px;height:26px}.achievement-valentine{background-image:url(spritesmith-main-0.png);background-position:-1637px -1547px;width:24px;height:26px}.achievement-wolf{background-image:url(spritesmith-main-0.png);background-position:-896px -979px;width:24px;height:26px}.background_autumn_forest{background-image:url(spritesmith-main-0.png);background-position:-423px -592px;width:140px;height:147px}.background_beach{background-image:url(spritesmith-main-0.png);background-position:-426px 0;width:141px;height:147px}.background_blacksmithy{background-image:url(spritesmith-main-0.png);background-position:-709px -148px;width:140px;height:147px}.background_cherry_trees{background-image:url(spritesmith-main-0.png);background-position:-709px -296px;width:140px;height:147px}.background_clouds{background-image:url(spritesmith-main-0.png);background-position:0 -592px;width:140px;height:147px}.background_coral_reef{background-image:url(spritesmith-main-0.png);background-position:-850px -148px;width:140px;height:147px}.background_crystal_cave{background-image:url(spritesmith-main-0.png);background-position:-850px -592px;width:140px;height:147px}.background_dilatory_ruins{background-image:url(spritesmith-main-0.png);background-position:0 -740px;width:140px;height:147px}.background_distant_castle{background-image:url(spritesmith-main-0.png);background-position:-423px -888px;width:140px;height:147px}.background_drifting_raft{background-image:url(spritesmith-main-0.png);background-position:-564px -888px;width:140px;height:147px}.background_dusty_canyons{background-image:url(spritesmith-main-0.png);background-position:-705px -888px;width:140px;height:147px}.background_fairy_ring{background-image:url(spritesmith-main-0.png);background-position:-568px 0;width:140px;height:147px}.background_floating_islands{background-image:url(spritesmith-main-0.png);background-position:-568px -148px;width:140px;height:147px}.background_floral_meadow{background-image:url(spritesmith-main-0.png);background-position:-568px -296px;width:140px;height:147px}.background_forest{background-image:url(spritesmith-main-0.png);background-position:0 -444px;width:140px;height:147px}.background_frigid_peak{background-image:url(spritesmith-main-0.png);background-position:-141px -444px;width:140px;height:147px}.background_giant_wave{background-image:url(spritesmith-main-0.png);background-position:-284px 0;width:141px;height:147px}.background_graveyard{background-image:url(spritesmith-main-0.png);background-position:-423px -444px;width:140px;height:147px}.background_gumdrop_land{background-image:url(spritesmith-main-0.png);background-position:-564px -444px;width:140px;height:147px}.background_harvest_feast{background-image:url(spritesmith-main-0.png);background-position:-709px 0;width:140px;height:147px}.background_harvest_fields{background-image:url(spritesmith-main-0.png);background-position:0 -148px;width:141px;height:147px}.background_harvest_moon{background-image:url(spritesmith-main-0.png);background-position:-142px -148px;width:141px;height:147px}.background_haunted_house{background-image:url(spritesmith-main-0.png);background-position:-709px -444px;width:140px;height:147px}.background_ice_cave{background-image:url(spritesmith-main-0.png);background-position:-284px -148px;width:141px;height:147px}.background_iceberg{background-image:url(spritesmith-main-0.png);background-position:-141px -592px;width:140px;height:147px}.background_island_waterfalls{background-image:url(spritesmith-main-0.png);background-position:-282px -592px;width:140px;height:147px}.background_marble_temple{background-image:url(spritesmith-main-0.png);background-position:-142px 0;width:141px;height:147px}.background_market{background-image:url(spritesmith-main-0.png);background-position:-564px -592px;width:140px;height:147px}.background_mountain_lake{background-image:url(spritesmith-main-0.png);background-position:-705px -592px;width:140px;height:147px}.background_night_dunes{background-image:url(spritesmith-main-0.png);background-position:-850px 0;width:140px;height:147px}.background_open_waters{background-image:url(spritesmith-main-0.png);background-position:0 0;width:141px;height:147px}.background_pagodas{background-image:url(spritesmith-main-0.png);background-position:-850px -296px;width:140px;height:147px}.background_pumpkin_patch{background-image:url(spritesmith-main-0.png);background-position:-850px -444px;width:140px;height:147px}.background_pyramids{background-image:url(spritesmith-main-0.png);background-position:-426px -148px;width:141px;height:147px}.background_rolling_hills{background-image:url(spritesmith-main-0.png);background-position:0 -296px;width:141px;height:147px}.background_seafarer_ship{background-image:url(spritesmith-main-0.png);background-position:-141px -740px;width:140px;height:147px}.background_shimmery_bubbles{background-image:url(spritesmith-main-0.png);background-position:-282px -740px;width:140px;height:147px}.background_slimy_swamp{background-image:url(spritesmith-main-0.png);background-position:-423px -740px;width:140px;height:147px}.background_snowy_pines{background-image:url(spritesmith-main-0.png);background-position:-564px -740px;width:140px;height:147px}.background_south_pole{background-image:url(spritesmith-main-0.png);background-position:-705px -740px;width:140px;height:147px}.background_spring_rain{background-image:url(spritesmith-main-0.png);background-position:-846px -740px;width:140px;height:147px}.background_stable{background-image:url(spritesmith-main-0.png);background-position:-991px 0;width:140px;height:147px}.background_stained_glass{background-image:url(spritesmith-main-0.png);background-position:-991px -148px;width:140px;height:147px}.background_starry_skies{background-image:url(spritesmith-main-0.png);background-position:-991px -296px;width:140px;height:147px}.background_sunken_ship{background-image:url(spritesmith-main-0.png);background-position:-991px -444px;width:140px;height:147px}.background_sunset_meadow{background-image:url(spritesmith-main-0.png);background-position:-991px -592px;width:140px;height:147px}.background_sunset_oasis{background-image:url(spritesmith-main-0.png);background-position:-991px -740px;width:140px;height:147px}.background_sunset_savannah{background-image:url(spritesmith-main-0.png);background-position:0 -888px;width:140px;height:147px}.background_swarming_darkness{background-image:url(spritesmith-main-0.png);background-position:-141px -888px;width:140px;height:147px}.background_tavern{background-image:url(spritesmith-main-0.png);background-position:-282px -888px;width:140px;height:147px}.background_thunderstorm{background-image:url(spritesmith-main-0.png);background-position:-142px -296px;width:141px;height:147px}.background_twinkly_lights{background-image:url(spritesmith-main-0.png);background-position:-284px -296px;width:141px;height:147px}.background_twinkly_party_lights{background-image:url(spritesmith-main-0.png);background-position:-426px -296px;width:141px;height:147px}.background_volcano{background-image:url(spritesmith-main-0.png);background-position:-282px -444px;width:140px;height:147px}.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1223px -91px;width:90px;height:90px}.customize-option.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1248px -106px;width:60px;height:60px}.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1223px -182px;width:90px;height:90px}.customize-option.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1248px -197px;width:60px;height:60px}.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1223px -273px;width:90px;height:90px}.customize-option.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1248px -288px;width:60px;height:60px}.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1223px -364px;width:90px;height:90px}.customize-option.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1248px -379px;width:60px;height:60px}.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-1223px -455px;width:90px;height:90px}.customize-option.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-1248px -470px;width:60px;height:60px}.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-1223px -546px;width:90px;height:90px}.customize-option.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-1248px -561px;width:60px;height:60px}.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1223px -637px;width:90px;height:90px}.customize-option.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1248px -652px;width:60px;height:60px}.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1223px -728px;width:90px;height:90px}.customize-option.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1248px -743px;width:60px;height:60px}.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1223px -819px;width:90px;height:90px}.customize-option.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1248px -834px;width:60px;height:60px}.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1223px -910px;width:90px;height:90px}.customize-option.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1248px -925px;width:60px;height:60px}.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1223px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1248px -1016px;width:60px;height:60px}.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-1223px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-1248px -1107px;width:60px;height:60px}.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:0 -1218px;width:90px;height:90px}.customize-option.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-25px -1233px;width:60px;height:60px}.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-91px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-116px -1233px;width:60px;height:60px}.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-182px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-207px -1233px;width:60px;height:60px}.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-273px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-298px -1233px;width:60px;height:60px}.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-364px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-389px -1233px;width:60px;height:60px}.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-455px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-480px -1233px;width:60px;height:60px}.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-546px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-571px -1233px;width:60px;height:60px}.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-637px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-662px -1233px;width:60px;height:60px}.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-728px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-753px -1233px;width:60px;height:60px}.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-819px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-844px -1233px;width:60px;height:60px}.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-910px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-935px -1233px;width:60px;height:60px}.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1001px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1026px -1233px;width:60px;height:60px}.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1092px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1117px -1233px;width:60px;height:60px}.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1183px -1218px;width:90px;height:90px}.customize-option.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1208px -1233px;width:60px;height:60px}.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1314px 0;width:90px;height:90px}.customize-option.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1339px -15px;width:60px;height:60px}.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1314px -91px;width:90px;height:90px}.customize-option.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1339px -106px;width:60px;height:60px}.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1314px -182px;width:90px;height:90px}.customize-option.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1339px -197px;width:60px;height:60px}.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1314px -273px;width:90px;height:90px}.customize-option.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1339px -288px;width:60px;height:60px}.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1314px -364px;width:90px;height:90px}.customize-option.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1339px -379px;width:60px;height:60px}.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1314px -455px;width:90px;height:90px}.customize-option.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1339px -470px;width:60px;height:60px}.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1314px -546px;width:90px;height:90px}.customize-option.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1339px -561px;width:60px;height:60px}.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -637px;width:90px;height:90px}.customize-option.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -652px;width:60px;height:60px}.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -728px;width:90px;height:90px}.customize-option.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -743px;width:60px;height:60px}.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -819px;width:90px;height:90px}.customize-option.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -834px;width:60px;height:60px}.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -910px;width:90px;height:90px}.customize-option.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -925px;width:60px;height:60px}.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1314px -1001px;width:90px;height:90px}.customize-option.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1339px -1016px;width:60px;height:60px}.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1314px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1339px -1107px;width:60px;height:60px}.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1314px -1183px;width:90px;height:90px}.customize-option.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1339px -1198px;width:60px;height:60px}.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:0 -1309px;width:90px;height:90px}.customize-option.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-25px -1324px;width:60px;height:60px}.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-91px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-116px -1324px;width:60px;height:60px}.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-182px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-207px -1324px;width:60px;height:60px}.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-273px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-298px -1324px;width:60px;height:60px}.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-364px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-389px -1324px;width:60px;height:60px}.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-455px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-480px -1324px;width:60px;height:60px}.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-546px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-571px -1324px;width:60px;height:60px}.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-637px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-662px -1324px;width:60px;height:60px}.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-728px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-753px -1324px;width:60px;height:60px}.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-846px -888px;width:90px;height:90px}.customize-option.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-871px -903px;width:60px;height:60px}.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-910px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-935px -1324px;width:60px;height:60px}.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1001px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1026px -1324px;width:60px;height:60px}.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1092px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1117px -1324px;width:60px;height:60px}.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1183px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1208px -1324px;width:60px;height:60px}.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1274px -1309px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1299px -1324px;width:60px;height:60px}.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1405px 0;width:90px;height:90px}.customize-option.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1430px -15px;width:60px;height:60px}.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1405px -91px;width:90px;height:90px}.customize-option.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1430px -106px;width:60px;height:60px}.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -182px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -197px;width:60px;height:60px}.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1405px -273px;width:90px;height:90px}.customize-option.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1430px -288px;width:60px;height:60px}.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1405px -364px;width:90px;height:90px}.customize-option.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1430px -379px;width:60px;height:60px}.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1405px -455px;width:90px;height:90px}.customize-option.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1430px -470px;width:60px;height:60px}.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1405px -546px;width:90px;height:90px}.customize-option.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1430px -561px;width:60px;height:60px}.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1405px -637px;width:90px;height:90px}.customize-option.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1430px -652px;width:60px;height:60px}.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1405px -728px;width:90px;height:90px}.customize-option.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1430px -743px;width:60px;height:60px}.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -819px;width:90px;height:90px}.customize-option.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -834px;width:60px;height:60px}.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1405px -910px;width:90px;height:90px}.customize-option.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1430px -925px;width:60px;height:60px}.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1405px -1001px;width:90px;height:90px}.customize-option.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1430px -1016px;width:60px;height:60px}.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-1405px -1092px;width:90px;height:90px}.customize-option.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-1430px -1107px;width:60px;height:60px}.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-1405px -1183px;width:90px;height:90px}.customize-option.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-1430px -1198px;width:60px;height:60px}.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-1405px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-1430px -1289px;width:60px;height:60px}.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:0 -1400px;width:90px;height:90px}.customize-option.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-25px -1415px;width:60px;height:60px}.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1415px;width:60px;height:60px}.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-182px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-207px -1415px;width:60px;height:60px}.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-273px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-298px -1415px;width:60px;height:60px}.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-364px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-389px -1415px;width:60px;height:60px}.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-455px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-480px -1415px;width:60px;height:60px}.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-546px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-571px -1415px;width:60px;height:60px}.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-637px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-662px -1415px;width:60px;height:60px}.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-728px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-753px -1415px;width:60px;height:60px}.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1415px;width:60px;height:60px}.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1415px;width:60px;height:60px}.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1415px;width:60px;height:60px}.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1092px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1117px -1415px;width:60px;height:60px}.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1183px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1208px -1415px;width:60px;height:60px}.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1415px;width:60px;height:60px}.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1365px -1400px;width:90px;height:90px}.customize-option.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1390px -1415px;width:60px;height:60px}.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1496px 0;width:90px;height:90px}.customize-option.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1521px -15px;width:60px;height:60px}.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1496px -91px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1521px -106px;width:60px;height:60px}.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1496px -182px;width:90px;height:90px}.customize-option.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1521px -197px;width:60px;height:60px}.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1496px -273px;width:90px;height:90px}.customize-option.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1521px -288px;width:60px;height:60px}.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -364px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -379px;width:60px;height:60px}.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1496px -455px;width:90px;height:90px}.customize-option.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1521px -470px;width:60px;height:60px}.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1496px -546px;width:90px;height:90px}.customize-option.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1521px -561px;width:60px;height:60px}.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1496px -637px;width:90px;height:90px}.customize-option.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1521px -652px;width:60px;height:60px}.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1496px -728px;width:90px;height:90px}.customize-option.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1521px -743px;width:60px;height:60px}.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1496px -819px;width:90px;height:90px}.customize-option.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1521px -834px;width:60px;height:60px}.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1496px -910px;width:90px;height:90px}.customize-option.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1521px -925px;width:60px;height:60px}.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -1001px;width:90px;height:90px}.customize-option.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -1016px;width:60px;height:60px}.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1496px -1092px;width:90px;height:90px}.customize-option.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1521px -1107px;width:60px;height:60px}.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1496px -1183px;width:90px;height:90px}.customize-option.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1521px -1198px;width:60px;height:60px}.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1496px -1274px;width:90px;height:90px}.customize-option.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1521px -1289px;width:60px;height:60px}.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-1496px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-1521px -1380px;width:60px;height:60px}.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:0 -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-25px -1506px;width:60px;height:60px}.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-91px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-116px -1506px;width:60px;height:60px}.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-182px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-207px -1506px;width:60px;height:60px}.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-273px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-298px -1506px;width:60px;height:60px}.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-364px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-389px -1506px;width:60px;height:60px}.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-455px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-480px -1506px;width:60px;height:60px}.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-546px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-571px -1506px;width:60px;height:60px}.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-637px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-662px -1506px;width:60px;height:60px}.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-728px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-753px -1506px;width:60px;height:60px}.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-819px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-844px -1506px;width:60px;height:60px}.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-910px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-935px -1506px;width:60px;height:60px}.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1001px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1026px -1506px;width:60px;height:60px}.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1092px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1117px -1506px;width:60px;height:60px}.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1183px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1208px -1506px;width:60px;height:60px}.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1274px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1299px -1506px;width:60px;height:60px}.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1365px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1390px -1506px;width:60px;height:60px}.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1456px -1491px;width:90px;height:90px}.customize-option.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1481px -1506px;width:60px;height:60px}.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1587px 0;width:90px;height:90px}.customize-option.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1612px -15px;width:60px;height:60px}.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1587px -91px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1612px -106px;width:60px;height:60px}.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1587px -182px;width:90px;height:90px}.customize-option.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1612px -197px;width:60px;height:60px}.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1587px -273px;width:90px;height:90px}.customize-option.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1612px -288px;width:60px;height:60px}.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -379px;width:60px;height:60px}.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1587px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1612px -470px;width:60px;height:60px}.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-1587px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-1612px -561px;width:60px;height:60px}.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1587px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1612px -652px;width:60px;height:60px}.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-1587px -728px;width:90px;height:90px}.customize-option.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-1612px -743px;width:60px;height:60px}.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1587px -819px;width:90px;height:90px}.customize-option.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1612px -834px;width:60px;height:60px}.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1587px -910px;width:90px;height:90px}.customize-option.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1612px -925px;width:60px;height:60px}.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -1001px;width:90px;height:90px}.customize-option.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -1016px;width:60px;height:60px}.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1587px -1092px;width:90px;height:90px}.customize-option.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1612px -1107px;width:60px;height:60px}.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1587px -1183px;width:90px;height:90px}.customize-option.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1612px -1198px;width:60px;height:60px}.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1587px -1274px;width:90px;height:90px}.customize-option.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1612px -1289px;width:60px;height:60px}.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1587px -1365px;width:90px;height:90px}.customize-option.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1612px -1380px;width:60px;height:60px}.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1587px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1612px -1471px;width:60px;height:60px}.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:0 -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-25px -1597px;width:60px;height:60px}.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1597px;width:60px;height:60px}.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-182px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-207px -1597px;width:60px;height:60px}.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-273px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-298px -1597px;width:60px;height:60px}.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-364px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-389px -1597px;width:60px;height:60px}.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-455px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-480px -1597px;width:60px;height:60px}.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-546px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-571px -1597px;width:60px;height:60px}.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-637px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-662px -1597px;width:60px;height:60px}.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-728px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-753px -1597px;width:60px;height:60px}.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1597px;width:60px;height:60px}.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1597px;width:60px;height:60px}.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1597px;width:60px;height:60px}.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1092px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1117px -1597px;width:60px;height:60px}.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1183px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1208px -1597px;width:60px;height:60px}.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1597px;width:60px;height:60px}.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1365px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1390px -1597px;width:60px;height:60px}.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1456px -1582px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1481px -1597px;width:60px;height:60px}.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-819px -1309px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-844px -1324px;width:60px;height:60px}.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1132px -1001px;width:90px;height:90px}.customize-option.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1157px -1016px;width:60px;height:60px}.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1132px -910px;width:90px;height:90px}.customize-option.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1157px -925px;width:60px;height:60px}.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1132px -819px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1157px -834px;width:60px;height:60px}.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1132px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1157px -743px;width:60px;height:60px}.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1132px -637px;width:90px;height:90px}.customize-option.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1157px -652px;width:60px;height:60px}.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1132px -546px;width:90px;height:90px}.customize-option.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1157px -561px;width:60px;height:60px}.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1132px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1157px -470px;width:60px;height:60px}.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1132px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1157px -379px;width:60px;height:60px}.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1132px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1157px -288px;width:60px;height:60px}.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1132px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1157px -197px;width:60px;height:60px}.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1132px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1157px -106px;width:60px;height:60px}.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1132px 0;width:90px;height:90px}.customize-option.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1157px -15px;width:60px;height:60px}.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1001px -1036px;width:90px;height:90px}.customize-option.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1026px -1051px;width:60px;height:60px}.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-910px -1036px;width:90px;height:90px}.customize-option.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-935px -1051px;width:60px;height:60px}.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-819px -1036px;width:90px;height:90px}.customize-option.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-844px -1051px;width:60px;height:60px}.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-728px -1036px;width:90px;height:90px}.customize-option.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-753px -1051px;width:60px;height:60px}.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-637px -1036px;width:90px;height:90px}.customize-option.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-662px -1051px;width:60px;height:60px}.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-546px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-571px -1051px;width:60px;height:60px}.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-455px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-480px -1051px;width:60px;height:60px}.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-364px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-389px -1051px;width:60px;height:60px}.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1051px;width:60px;height:60px}.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-182px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-207px -1051px;width:60px;height:60px}.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1051px;width:60px;height:60px}.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:0 -1036px;width:90px;height:90px}.customize-option.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-25px -1051px;width:60px;height:60px}.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1028px -888px;width:90px;height:90px}.customize-option.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1053px -903px;width:60px;height:60px}.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-937px -888px;width:90px;height:90px}.customize-option.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-962px -903px;width:60px;height:60px}.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1223px 0;width:90px;height:90px}.customize-option.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1248px -15px;width:60px;height:60px}.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1092px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1117px -1142px;width:60px;height:60px}.hair_bangs_1_green{background-image:url(spritesmith-main-0.png);background-position:-1001px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_green{background-image:url(spritesmith-main-0.png);background-position:-1026px -1142px;width:60px;height:60px}.hair_bangs_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-910px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-935px -1142px;width:60px;height:60px}.hair_bangs_1_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1142px;width:60px;height:60px}.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-728px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-753px -1142px;width:60px;height:60px}.hair_bangs_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-637px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-662px -1142px;width:60px;height:60px}.hair_bangs_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-546px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-571px -1142px;width:60px;height:60px}.hair_bangs_1_pblue2{background-image:url(spritesmith-main-0.png);background-position:-455px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue2{background-image:url(spritesmith-main-0.png);background-position:-480px -1142px;width:60px;height:60px}.hair_bangs_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-364px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-389px -1142px;width:60px;height:60px}.hair_bangs_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-273px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-298px -1142px;width:60px;height:60px}.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-0.png);background-position:-182px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-0.png);background-position:-207px -1142px;width:60px;height:60px}.hair_bangs_1_porange{background-image:url(spritesmith-main-0.png);background-position:-91px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_porange{background-image:url(spritesmith-main-0.png);background-position:-116px -1142px;width:60px;height:60px}.hair_bangs_1_porange2{background-image:url(spritesmith-main-0.png);background-position:0 -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_porange2{background-image:url(spritesmith-main-0.png);background-position:-25px -1142px;width:60px;height:60px}.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-116px -15px;width:60px;height:60px}.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-753px -1107px;width:60px;height:60px}.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-25px -106px;width:60px;height:60px}.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-116px -106px;width:60px;height:60px}.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-207px -15px;width:60px;height:60px}.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -106px;width:60px;height:60px}.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-25px -197px;width:60px;height:60px}.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-116px -197px;width:60px;height:60px}.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-207px -197px;width:60px;height:60px}.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-298px -15px;width:60px;height:60px}.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-298px -106px;width:60px;height:60px}.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-298px -197px;width:60px;height:60px}.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-25px -288px;width:60px;height:60px}.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-116px -288px;width:60px;height:60px}.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-207px -288px;width:60px;height:60px}.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-298px -288px;width:60px;height:60px}.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-389px -15px;width:60px;height:60px}.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-389px -106px;width:60px;height:60px}.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-389px -197px;width:60px;height:60px}.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-389px -288px;width:60px;height:60px}.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-25px -379px;width:60px;height:60px}.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-116px -379px;width:60px;height:60px}.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-207px -379px;width:60px;height:60px}.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-298px -379px;width:60px;height:60px}.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-389px -379px;width:60px;height:60px}.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-480px -15px;width:60px;height:60px}.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -106px;width:60px;height:60px}.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-480px -197px;width:60px;height:60px}.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-480px -288px;width:60px;height:60px}.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-480px -379px;width:60px;height:60px}.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-25px -470px;width:60px;height:60px}.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-116px -470px;width:60px;height:60px}.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -470px;width:60px;height:60px}.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-298px -470px;width:60px;height:60px}.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-389px -470px;width:60px;height:60px}.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-480px -470px;width:60px;height:60px}.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-571px -15px;width:60px;height:60px}.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-571px -106px;width:60px;height:60px}.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-571px -197px;width:60px;height:60px}.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -288px;width:60px;height:60px}.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-571px -379px;width:60px;height:60px}.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-571px -470px;width:60px;height:60px}.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -561px;width:60px;height:60px}.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -561px;width:60px;height:60px}.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -561px;width:60px;height:60px}.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -561px;width:60px;height:60px}.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -561px;width:60px;height:60px}.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -561px;width:60px;height:60px}.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-571px -561px;width:60px;height:60px}.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -15px;width:60px;height:60px}.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-662px -106px;width:60px;height:60px}.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-662px -197px;width:60px;height:60px}.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-662px -288px;width:60px;height:60px}.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-662px -379px;width:60px;height:60px}.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-662px -470px;width:60px;height:60px}.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-662px -561px;width:60px;height:60px}.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-25px -652px;width:60px;height:60px}.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-116px -652px;width:60px;height:60px}.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-207px -652px;width:60px;height:60px}.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-298px -652px;width:60px;height:60px}.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-389px -652px;width:60px;height:60px}.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-480px -652px;width:60px;height:60px}.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-571px -652px;width:60px;height:60px}.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-662px -652px;width:60px;height:60px}.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-753px -15px;width:60px;height:60px}.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-753px -106px;width:60px;height:60px}.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-753px -197px;width:60px;height:60px}.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -288px;width:60px;height:60px}.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -379px;width:60px;height:60px}.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-753px -470px;width:60px;height:60px}.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-753px -561px;width:60px;height:60px}.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-753px -652px;width:60px;height:60px}.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -743px;width:60px;height:60px}.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -743px;width:60px;height:60px}.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -743px;width:60px;height:60px}.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -743px;width:60px;height:60px}.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-389px -743px;width:60px;height:60px}.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-480px -743px;width:60px;height:60px}.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -743px;width:60px;height:60px}.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -743px;width:60px;height:60px}.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-753px -743px;width:60px;height:60px}.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-844px -15px;width:60px;height:60px}.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-844px -106px;width:60px;height:60px}.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-844px -197px;width:60px;height:60px}.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-844px -288px;width:60px;height:60px}.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-844px -379px;width:60px;height:60px}.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-844px -470px;width:60px;height:60px}.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-844px -561px;width:60px;height:60px}.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-844px -652px;width:60px;height:60px}.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-844px -743px;width:60px;height:60px}.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-25px -834px;width:60px;height:60px}.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-116px -834px;width:60px;height:60px}.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-207px -834px;width:60px;height:60px}.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_1_purple{background-image:url(spritesmith-main-1.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_1_purple{background-image:url(spritesmith-main-1.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_1_red{background-image:url(spritesmith-main-1.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_1_red{background-image:url(spritesmith-main-1.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_base_1_white{background-image:url(spritesmith-main-1.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_base_1_white{background-image:url(spritesmith-main-1.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_base_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_base_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-1663px -15px;width:60px;height:60px}.hair_base_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_base_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1663px -106px;width:60px;height:60px}.hair_base_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_base_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-1663px -197px;width:60px;height:60px}.hair_base_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_base_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-1663px -288px;width:60px;height:60px}.hair_base_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_base_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1663px -379px;width:60px;height:60px}.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:-116px -15px;width:60px;height:60px}.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-753px -1107px;width:60px;height:60px}.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-25px -106px;width:60px;height:60px}.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-116px -106px;width:60px;height:60px}.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-207px -15px;width:60px;height:60px}.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -106px;width:60px;height:60px}.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-25px -197px;width:60px;height:60px}.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-116px -197px;width:60px;height:60px}.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-207px -197px;width:60px;height:60px}.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-298px -15px;width:60px;height:60px}.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-298px -106px;width:60px;height:60px}.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-298px -197px;width:60px;height:60px}.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-25px -288px;width:60px;height:60px}.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-116px -288px;width:60px;height:60px}.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-207px -288px;width:60px;height:60px}.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-298px -288px;width:60px;height:60px}.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-389px -15px;width:60px;height:60px}.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-389px -106px;width:60px;height:60px}.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-389px -197px;width:60px;height:60px}.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-389px -288px;width:60px;height:60px}.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-25px -379px;width:60px;height:60px}.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-116px -379px;width:60px;height:60px}.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-207px -379px;width:60px;height:60px}.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-298px -379px;width:60px;height:60px}.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-389px -379px;width:60px;height:60px}.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-480px -15px;width:60px;height:60px}.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -106px;width:60px;height:60px}.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-480px -197px;width:60px;height:60px}.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-480px -288px;width:60px;height:60px}.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-480px -379px;width:60px;height:60px}.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:-25px -470px;width:60px;height:60px}.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-116px -470px;width:60px;height:60px}.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -470px;width:60px;height:60px}.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-298px -470px;width:60px;height:60px}.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-389px -470px;width:60px;height:60px}.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-480px -470px;width:60px;height:60px}.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-571px -15px;width:60px;height:60px}.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-571px -106px;width:60px;height:60px}.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-571px -197px;width:60px;height:60px}.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-571px -288px;width:60px;height:60px}.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-571px -379px;width:60px;height:60px}.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-571px -470px;width:60px;height:60px}.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -561px;width:60px;height:60px}.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -561px;width:60px;height:60px}.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -561px;width:60px;height:60px}.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -561px;width:60px;height:60px}.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -561px;width:60px;height:60px}.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -561px;width:60px;height:60px}.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -561px;width:60px;height:60px}.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-662px -15px;width:60px;height:60px}.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-662px -106px;width:60px;height:60px}.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-662px -197px;width:60px;height:60px}.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-662px -288px;width:60px;height:60px}.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-662px -379px;width:60px;height:60px}.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-662px -470px;width:60px;height:60px}.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-662px -561px;width:60px;height:60px}.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-25px -652px;width:60px;height:60px}.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-116px -652px;width:60px;height:60px}.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-207px -652px;width:60px;height:60px}.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-298px -652px;width:60px;height:60px}.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-389px -652px;width:60px;height:60px}.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-480px -652px;width:60px;height:60px}.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-571px -652px;width:60px;height:60px}.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-662px -652px;width:60px;height:60px}.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-753px -15px;width:60px;height:60px}.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-753px -106px;width:60px;height:60px}.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-753px -197px;width:60px;height:60px}.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -288px;width:60px;height:60px}.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -379px;width:60px;height:60px}.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-753px -470px;width:60px;height:60px}.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-753px -561px;width:60px;height:60px}.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-753px -652px;width:60px;height:60px}.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-25px -743px;width:60px;height:60px}.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -743px;width:60px;height:60px}.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -743px;width:60px;height:60px}.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -743px;width:60px;height:60px}.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-389px -743px;width:60px;height:60px}.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-480px -743px;width:60px;height:60px}.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-571px -743px;width:60px;height:60px}.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-662px -743px;width:60px;height:60px}.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-753px -743px;width:60px;height:60px}.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-844px -15px;width:60px;height:60px}.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-844px -106px;width:60px;height:60px}.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-844px -197px;width:60px;height:60px}.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-844px -288px;width:60px;height:60px}.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-844px -379px;width:60px;height:60px}.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-844px -470px;width:60px;height:60px}.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-844px -561px;width:60px;height:60px}.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-844px -652px;width:60px;height:60px}.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-844px -743px;width:60px;height:60px}.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-25px -834px;width:60px;height:60px}.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-116px -834px;width:60px;height:60px}.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-207px -834px;width:60px;height:60px}.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-389px -1562px;width:60px;height:60px}.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-480px -1562px;width:60px;height:60px}.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-571px -1562px;width:60px;height:60px}.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-662px -1562px;width:60px;height:60px}.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-753px -1562px;width:60px;height:60px}.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-844px -1562px;width:60px;height:60px}.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-935px -1562px;width:60px;height:60px}.hair_beard_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_beard_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_beard_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_beard_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_beard_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_beard_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_beard_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_beard_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_beard_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_beard_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1663px -15px;width:60px;height:60px}.hair_beard_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_beard_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-1663px -106px;width:60px;height:60px}.hair_beard_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_beard_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1663px -197px;width:60px;height:60px}.hair_beard_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1663px -288px;width:60px;height:60px}.hair_beard_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1663px -379px;width:60px;height:60px}.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-662px -1380px;width:60px;height:60px}.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-25px -1198px;width:60px;height:60px}.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:-1182px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:-1207px -652px;width:60px;height:60px}.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-753px -1380px;width:60px;height:60px}.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1546px 0;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1571px -15px;width:60px;height:60px}.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1546px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1571px -379px;width:60px;height:60px}.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1546px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1571px -470px;width:60px;height:60px}.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1546px -637px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1571px -652px;width:60px;height:60px}.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1546px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1571px -743px;width:60px;height:60px}.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-1546px -1001px;width:90px;height:90px}.customize-option.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-1571px -1031px;width:60px;height:60px}.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-1546px -1183px;width:90px;height:90px}.customize-option.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-1571px -1213px;width:60px;height:60px}.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-1546px -1274px;width:90px;height:90px}.customize-option.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-1571px -1304px;width:60px;height:60px}.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-207px -1486px;width:60px;height:60px}.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-298px -1486px;width:60px;height:60px}.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-480px -1486px;width:60px;height:60px}.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-571px -1486px;width:60px;height:60px}.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-935px -1486px;width:60px;height:60px}.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-1026px -1486px;width:60px;height:60px}.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-1637px -1001px;width:90px;height:90px}.customize-option.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-1662px -1031px;width:60px;height:60px}.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-454px -194px;width:90px;height:90px}.customize-option.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-479px -224px;width:60px;height:60px}.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-282px -364px;width:90px;height:90px}.customize-option.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-307px -394px;width:60px;height:60px}.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-373px -364px;width:90px;height:90px}.customize-option.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-398px -394px;width:60px;height:60px}.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-545px 0;width:90px;height:90px}.customize-option.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-570px -30px;width:60px;height:60px}.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-545px -91px;width:90px;height:90px}.customize-option.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-570px -121px;width:60px;height:60px}.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-545px -182px;width:90px;height:90px}.customize-option.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-570px -212px;width:60px;height:60px}.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-545px -273px;width:90px;height:90px}.customize-option.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-570px -303px;width:60px;height:60px}.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-545px -364px;width:90px;height:90px}.customize-option.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-570px -394px;width:60px;height:60px}.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:0 -455px;width:90px;height:90px}.customize-option.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-25px -485px;width:60px;height:60px}.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-116px -485px;width:60px;height:60px}.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-207px -485px;width:60px;height:60px}.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-298px -485px;width:60px;height:60px}.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-389px -485px;width:60px;height:60px}.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-480px -485px;width:60px;height:60px}.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-636px 0;width:90px;height:90px}.customize-option.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-661px -30px;width:60px;height:60px}.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-636px -91px;width:90px;height:90px}.customize-option.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-661px -121px;width:60px;height:60px}.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-636px -182px;width:90px;height:90px}.customize-option.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-661px -212px;width:60px;height:60px}.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-636px -273px;width:90px;height:90px}.customize-option.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-661px -303px;width:60px;height:60px}.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-636px -364px;width:90px;height:90px}.customize-option.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-661px -394px;width:60px;height:60px}.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-636px -455px;width:90px;height:90px}.customize-option.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-661px -485px;width:60px;height:60px}.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:0 -546px;width:90px;height:90px}.customize-option.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-25px -576px;width:60px;height:60px}.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-116px -576px;width:60px;height:60px}.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-207px -576px;width:60px;height:60px}.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-298px -576px;width:60px;height:60px}.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-389px -561px;width:60px;height:60px}.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -561px;width:60px;height:60px}.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-571px -561px;width:60px;height:60px}.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-727px 0;width:90px;height:90px}.customize-option.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-752px -15px;width:60px;height:60px}.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-727px -91px;width:90px;height:90px}.customize-option.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-752px -106px;width:60px;height:60px}.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-727px -182px;width:90px;height:90px}.customize-option.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-752px -197px;width:60px;height:60px}.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-727px -273px;width:90px;height:90px}.customize-option.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-752px -288px;width:60px;height:60px}.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-727px -364px;width:90px;height:90px}.customize-option.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-752px -379px;width:60px;height:60px}.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-727px -455px;width:90px;height:90px}.customize-option.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-752px -470px;width:60px;height:60px}.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-727px -546px;width:90px;height:90px}.customize-option.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-752px -561px;width:60px;height:60px}.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:0 -637px;width:90px;height:90px}.customize-option.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:-25px -652px;width:60px;height:60px}.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -652px;width:60px;height:60px}.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-207px -652px;width:60px;height:60px}.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -652px;width:60px;height:60px}.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-389px -652px;width:60px;height:60px}.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -652px;width:60px;height:60px}.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-571px -652px;width:60px;height:60px}.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -652px;width:60px;height:60px}.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-818px 0;width:90px;height:90px}.customize-option.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-843px -15px;width:60px;height:60px}.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-818px -91px;width:90px;height:90px}.customize-option.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-843px -106px;width:60px;height:60px}.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-818px -182px;width:90px;height:90px}.customize-option.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-843px -197px;width:60px;height:60px}.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-818px -273px;width:90px;height:90px}.customize-option.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-843px -288px;width:60px;height:60px}.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-818px -364px;width:90px;height:90px}.customize-option.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-843px -379px;width:60px;height:60px}.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-818px -455px;width:90px;height:90px}.customize-option.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-843px -470px;width:60px;height:60px}.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-818px -546px;width:90px;height:90px}.customize-option.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-843px -561px;width:60px;height:60px}.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-818px -637px;width:90px;height:90px}.customize-option.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-843px -652px;width:60px;height:60px}.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:0 -728px;width:90px;height:90px}.customize-option.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-25px -743px;width:60px;height:60px}.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -743px;width:60px;height:60px}.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-207px -743px;width:60px;height:60px}.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -743px;width:60px;height:60px}.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-389px -743px;width:60px;height:60px}.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -743px;width:60px;height:60px}.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-571px -743px;width:60px;height:60px}.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -743px;width:60px;height:60px}.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-753px -743px;width:60px;height:60px}.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-909px 0;width:90px;height:90px}.customize-option.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-934px -15px;width:60px;height:60px}.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-909px -91px;width:90px;height:90px}.customize-option.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-934px -106px;width:60px;height:60px}.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-909px -182px;width:90px;height:90px}.customize-option.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-934px -197px;width:60px;height:60px}.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-909px -273px;width:90px;height:90px}.customize-option.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-934px -288px;width:60px;height:60px}.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-909px -364px;width:90px;height:90px}.customize-option.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-934px -379px;width:60px;height:60px}.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-909px -455px;width:90px;height:90px}.customize-option.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-934px -470px;width:60px;height:60px}.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-909px -546px;width:90px;height:90px}.customize-option.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-934px -561px;width:60px;height:60px}.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-909px -637px;width:90px;height:90px}.customize-option.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-934px -652px;width:60px;height:60px}.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-909px -728px;width:90px;height:90px}.customize-option.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-934px -743px;width:60px;height:60px}.skin_lion{background-image:url(spritesmith-main-3.png);background-position:0 -819px;width:90px;height:90px}.customize-option.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-25px -834px;width:60px;height:60px}.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -834px;width:60px;height:60px}.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-207px -834px;width:60px;height:60px}.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -834px;width:60px;height:60px}.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-389px -834px;width:60px;height:60px}.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -834px;width:60px;height:60px}.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-571px -834px;width:60px;height:60px}.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -834px;width:60px;height:60px}.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-753px -834px;width:60px;height:60px}.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -834px;width:60px;height:60px}.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-1000px 0;width:90px;height:90px}.customize-option.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-1025px -15px;width:60px;height:60px}.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-1000px -91px;width:90px;height:90px}.customize-option.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-1025px -106px;width:60px;height:60px}.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-1000px -182px;width:90px;height:90px}.customize-option.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-1025px -197px;width:60px;height:60px}.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-1000px -273px;width:90px;height:90px}.customize-option.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-1025px -288px;width:60px;height:60px}.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-1000px -364px;width:90px;height:90px}.customize-option.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-1025px -379px;width:60px;height:60px}.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1000px -455px;width:90px;height:90px}.customize-option.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1025px -470px;width:60px;height:60px}.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1000px -546px;width:90px;height:90px}.customize-option.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1025px -561px;width:60px;height:60px}.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1000px -637px;width:90px;height:90px}.customize-option.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1025px -652px;width:60px;height:60px}.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1000px -728px;width:90px;height:90px}.customize-option.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1025px -743px;width:60px;height:60px}.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1000px -819px;width:90px;height:90px}.customize-option.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1025px -834px;width:60px;height:60px}.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:0 -910px;width:90px;height:90px}.customize-option.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-25px -925px;width:60px;height:60px}.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -925px;width:60px;height:60px}.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-207px -925px;width:60px;height:60px}.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -925px;width:60px;height:60px}.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-389px -925px;width:60px;height:60px}.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -925px;width:60px;height:60px}.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-571px -925px;width:60px;height:60px}.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -925px;width:60px;height:60px}.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-753px -925px;width:60px;height:60px}.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -925px;width:60px;height:60px}.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-935px -925px;width:60px;height:60px}.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px 0;width:90px;height:90px}.customize-option.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -15px;width:60px;height:60px}.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-1091px -91px;width:90px;height:90px}.customize-option.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-1116px -106px;width:60px;height:60px}.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px -182px;width:90px;height:90px}.customize-option.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -197px;width:60px;height:60px}.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-1091px -273px;width:90px;height:90px}.customize-option.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-1116px -288px;width:60px;height:60px}.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-1091px -364px;width:90px;height:90px}.customize-option.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-1116px -379px;width:60px;height:60px}.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px -455px;width:90px;height:90px}.customize-option.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -470px;width:60px;height:60px}.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px -546px;width:90px;height:90px}.customize-option.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -561px;width:60px;height:60px}.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1091px -637px;width:90px;height:90px}.customize-option.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1116px -652px;width:60px;height:60px}.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px -728px;width:90px;height:90px}.customize-option.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -743px;width:60px;height:60px}.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1091px -819px;width:90px;height:90px}.customize-option.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1116px -834px;width:60px;height:60px}.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1091px -910px;width:90px;height:90px}.customize-option.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1116px -925px;width:60px;height:60px}.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-25px -1016px;width:60px;height:60px}.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-116px -1016px;width:60px;height:60px}.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-207px -1016px;width:60px;height:60px}.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1016px;width:60px;height:60px}.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-389px -1016px;width:60px;height:60px}.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1016px;width:60px;height:60px}.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-571px -1016px;width:60px;height:60px}.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-662px -1016px;width:60px;height:60px}.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-753px -1016px;width:60px;height:60px}.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -1016px;width:60px;height:60px}.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-935px -1016px;width:60px;height:60px}.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-1026px -1016px;width:60px;height:60px}.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-1182px 0;width:90px;height:90px}.customize-option.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-1207px -15px;width:60px;height:60px}.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-1182px -91px;width:90px;height:90px}.customize-option.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-1207px -106px;width:60px;height:60px}.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-1182px -182px;width:90px;height:90px}.customize-option.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-1207px -197px;width:60px;height:60px}.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-1182px -273px;width:90px;height:90px}.customize-option.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-1207px -288px;width:60px;height:60px}.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-1182px -364px;width:90px;height:90px}.customize-option.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-1207px -379px;width:60px;height:60px}.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-1182px -455px;width:90px;height:90px}.customize-option.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-1207px -470px;width:60px;height:60px}.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-1182px -546px;width:90px;height:90px}.customize-option.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-1207px -561px;width:60px;height:60px}.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1637px -1092px;width:90px;height:90px}.customize-option.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1662px -1107px;width:60px;height:60px}.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1182px -728px;width:90px;height:90px}.customize-option.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1207px -743px;width:60px;height:60px}.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1182px -819px;width:90px;height:90px}.customize-option.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1207px -834px;width:60px;height:60px}.broad_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1182px -910px;width:90px;height:90px}.broad_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1182px -1001px;width:90px;height:90px}.broad_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:0 -1092px;width:90px;height:90px}.broad_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-91px -1092px;width:90px;height:90px}.broad_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-182px -1092px;width:90px;height:90px}.broad_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-273px -1092px;width:90px;height:90px}.broad_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-364px -1092px;width:90px;height:90px}.broad_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-455px -1092px;width:90px;height:90px}.eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-546px -1092px;width:90px;height:90px}.head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-637px -1092px;width:90px;height:90px}.head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-728px -1092px;width:90px;height:90px}.head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-819px -1092px;width:90px;height:90px}.head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-910px -1092px;width:90px;height:90px}.head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-1001px -1092px;width:90px;height:90px}.head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-1092px -1092px;width:90px;height:90px}.head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-1273px 0;width:90px;height:90px}.head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-1273px -91px;width:90px;height:90px}.head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-1273px -182px;width:90px;height:90px}.head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1273px -273px;width:90px;height:90px}.head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1273px -364px;width:90px;height:90px}.head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1273px -455px;width:90px;height:90px}.head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1273px -546px;width:90px;height:90px}.head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-1273px -637px;width:90px;height:90px}.head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1273px -728px;width:90px;height:90px}.head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1273px -819px;width:90px;height:90px}.shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1273px -910px;width:90px;height:90px}.shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1273px -1001px;width:90px;height:90px}.shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1273px -1092px;width:90px;height:90px}.shop_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1678px -1470px;width:40px;height:40px}.shop_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-454px -285px;width:40px;height:40px}.shop_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-41px -1547px;width:40px;height:40px}.shop_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-82px -1547px;width:40px;height:40px}.shop_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-123px -1547px;width:40px;height:40px}.shop_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-164px -1547px;width:40px;height:40px}.shop_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-205px -1547px;width:40px;height:40px}.shop_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-246px -1547px;width:40px;height:40px}.shop_eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-369px -1547px;width:40px;height:40px}.shop_head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-410px -1547px;width:40px;height:40px}.shop_head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-451px -1547px;width:40px;height:40px}.shop_head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-492px -1547px;width:40px;height:40px}.shop_head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-656px -1547px;width:40px;height:40px}.shop_head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-697px -1547px;width:40px;height:40px}.shop_head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-738px -1547px;width:40px;height:40px}.shop_head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-861px -1547px;width:40px;height:40px}.shop_head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-902px -1547px;width:40px;height:40px}.shop_head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-943px -1547px;width:40px;height:40px}.shop_head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1066px -1547px;width:40px;height:40px}.shop_head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1107px -1547px;width:40px;height:40px}.shop_head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1230px -1547px;width:40px;height:40px}.shop_head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1394px -1547px;width:40px;height:40px}.shop_head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-1312px -1547px;width:40px;height:40px}.shop_head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1637px -1183px;width:40px;height:40px}.shop_head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1678px -1183px;width:40px;height:40px}.shop_shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1637px -1224px;width:40px;height:40px}.shop_shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1678px -1265px;width:40px;height:40px}.shop_shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1637px -1306px;width:40px;height:40px}.shop_weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-1678px -1306px;width:40px;height:40px}.shop_weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-1637px -1347px;width:40px;height:40px}.shop_weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-1678px -1347px;width:40px;height:40px}.shop_weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-1637px -1388px;width:40px;height:40px}.shop_weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-1678px -1388px;width:40px;height:40px}.shop_weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-1637px -1429px;width:40px;height:40px}.shop_weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-1678px -1429px;width:40px;height:40px}.shop_weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-1637px -1470px;width:40px;height:40px}.slim_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-91px -1183px;width:90px;height:90px}.slim_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-182px -1183px;width:90px;height:90px}.slim_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-273px -1183px;width:90px;height:90px}.slim_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-364px -1183px;width:90px;height:90px}.slim_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-455px -1183px;width:90px;height:90px}.slim_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-546px -1183px;width:90px;height:90px}.slim_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-637px -1183px;width:90px;height:90px}.slim_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-728px -1183px;width:90px;height:90px}.weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-819px -1183px;width:90px;height:90px}.weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-910px -1183px;width:90px;height:90px}.weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-1001px -1183px;width:90px;height:90px}.weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-1092px -1183px;width:90px;height:90px}.weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-1183px -1183px;width:90px;height:90px}.weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-1364px 0;width:90px;height:90px}.weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-1364px -91px;width:90px;height:90px}.weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-1364px -182px;width:90px;height:90px}.broad_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1364px -273px;width:90px;height:90px}.broad_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1364px -364px;width:90px;height:90px}.broad_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1364px -455px;width:90px;height:90px}.broad_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1364px -546px;width:90px;height:90px}.broad_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1364px -637px;width:90px;height:90px}.broad_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1364px -728px;width:90px;height:90px}.broad_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1364px -819px;width:90px;height:90px}.broad_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1364px -910px;width:90px;height:90px}.broad_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1364px -1001px;width:90px;height:90px}.broad_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1364px -1092px;width:90px;height:90px}.broad_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1364px -1183px;width:90px;height:90px}.broad_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:0 -1274px;width:90px;height:90px}.broad_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-91px -1274px;width:90px;height:90px}.broad_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-182px -1274px;width:90px;height:90px}.broad_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-273px -1274px;width:90px;height:90px}.broad_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-364px -1274px;width:90px;height:90px}.broad_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-455px -1274px;width:90px;height:90px}.broad_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-546px -1274px;width:90px;height:90px}.broad_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-637px -1274px;width:90px;height:90px}.broad_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-728px -1274px;width:90px;height:90px}.broad_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-819px -1274px;width:90px;height:90px}.broad_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-910px -1274px;width:90px;height:90px}.shop_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-495px -285px;width:40px;height:40px}.shop_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-400px -273px;width:40px;height:40px}.shop_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-400px -314px;width:40px;height:40px}.shop_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-464px -364px;width:40px;height:40px}.shop_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-464px -405px;width:40px;height:40px}.shop_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-546px -455px;width:40px;height:40px}.shop_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-587px -455px;width:40px;height:40px}.shop_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-546px -496px;width:40px;height:40px}.shop_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-587px -496px;width:40px;height:40px}.shop_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-637px -546px;width:40px;height:40px}.shop_armor_special_0{background-image:url(spritesmith-main-3.png);background-position:-678px -546px;width:40px;height:40px}.shop_armor_special_1{background-image:url(spritesmith-main-3.png);background-position:-637px -587px;width:40px;height:40px}.shop_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1406px -1274px;width:40px;height:40px}.shop_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1365px -1315px;width:40px;height:40px}.shop_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1406px -1315px;width:40px;height:40px}.shop_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1456px -1365px;width:40px;height:40px}.shop_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1497px -1365px;width:40px;height:40px}.shop_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1456px -1406px;width:40px;height:40px}.shop_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1497px -1406px;width:40px;height:40px}.shop_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1547px -1456px;width:40px;height:40px}.shop_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1588px -1456px;width:40px;height:40px}.shop_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1547px -1497px;width:40px;height:40px}.shop_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1588px -1497px;width:40px;height:40px}.shop_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:0 -1547px;width:40px;height:40px}.slim_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1001px -1274px;width:90px;height:90px}.slim_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1092px -1274px;width:90px;height:90px}.slim_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1183px -1274px;width:90px;height:90px}.slim_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1274px -1274px;width:90px;height:90px}.slim_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1455px 0;width:90px;height:90px}.slim_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1455px -91px;width:90px;height:90px}.slim_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1455px -182px;width:90px;height:90px}.slim_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1455px -273px;width:90px;height:90px}.slim_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1455px -364px;width:90px;height:90px}.slim_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1455px -455px;width:90px;height:90px}.slim_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1455px -546px;width:90px;height:90px}.slim_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1455px -637px;width:90px;height:90px}.slim_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1455px -728px;width:90px;height:90px}.slim_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1455px -819px;width:90px;height:90px}.slim_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1455px -910px;width:90px;height:90px}.slim_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1455px -1001px;width:90px;height:90px}.slim_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1455px -1092px;width:90px;height:90px}.slim_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1455px -1183px;width:90px;height:90px}.slim_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1455px -1274px;width:90px;height:90px}.slim_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:0 -1365px;width:90px;height:90px}.slim_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-91px -1365px;width:90px;height:90px}.slim_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-182px -1365px;width:90px;height:90px}.broad_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-273px -1365px;width:90px;height:90px}.broad_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-364px -1365px;width:90px;height:90px}.shop_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1678px -1224px;width:40px;height:40px}.shop_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-1637px -1265px;width:40px;height:40px}.slim_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-455px -1365px;width:90px;height:90px}.slim_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-546px -1365px;width:90px;height:90px}.broad_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:0 -364px;width:93px;height:90px}.broad_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -182px;width:105px;height:90px}.broad_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-819px -1365px;width:90px;height:90px}.broad_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-910px -1365px;width:90px;height:90px}.broad_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1001px -1365px;width:90px;height:90px}.broad_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px 0;width:120px;height:90px}.broad_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-212px -182px;width:105px;height:90px}.broad_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1274px -1365px;width:90px;height:90px}.head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-188px -364px;width:93px;height:90px}.head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-242px -91px;width:105px;height:90px}.head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1546px -91px;width:90px;height:90px}.head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1546px -182px;width:90px;height:90px}.head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1546px -273px;width:90px;height:90px}.head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 -91px;width:120px;height:90px}.head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-106px -273px;width:105px;height:90px}.head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1546px -546px;width:90px;height:90px}.shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-94px -364px;width:93px;height:90px}.shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:0 -182px;width:105px;height:90px}.shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1546px -819px;width:90px;height:90px}.shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1546px -910px;width:90px;height:90px}.shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px -91px;width:105px;height:90px}.shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1546px -1092px;width:90px;height:90px}.shop_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-678px -587px;width:40px;height:40px}.shop_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-728px -637px;width:40px;height:40px}.shop_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-769px -637px;width:40px;height:40px}.shop_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-728px -678px;width:40px;height:40px}.shop_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-769px -678px;width:40px;height:40px}.shop_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-819px -728px;width:40px;height:40px}.shop_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-860px -728px;width:40px;height:40px}.shop_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-819px -769px;width:40px;height:40px}.shop_head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-860px -769px;width:40px;height:40px}.shop_head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-910px -819px;width:40px;height:40px}.shop_head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-951px -819px;width:40px;height:40px}.shop_head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-910px -860px;width:40px;height:40px}.shop_head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-951px -860px;width:40px;height:40px}.shop_head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-1001px -910px;width:40px;height:40px}.shop_head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1042px -910px;width:40px;height:40px}.shop_head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1001px -951px;width:40px;height:40px}.shop_shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-1042px -951px;width:40px;height:40px}.shop_shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1092px -1001px;width:40px;height:40px}.shop_shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1133px -1001px;width:40px;height:40px}.shop_shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1092px -1042px;width:40px;height:40px}.shop_shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1133px -1042px;width:40px;height:40px}.shop_shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1183px -1092px;width:40px;height:40px}.shop_weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-1224px -1092px;width:40px;height:40px}.shop_weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-1183px -1133px;width:40px;height:40px}.shop_weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1224px -1133px;width:40px;height:40px}.shop_weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1274px -1183px;width:40px;height:40px}.shop_weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1315px -1183px;width:40px;height:40px}.shop_weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-1274px -1224px;width:40px;height:40px}.shop_weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1315px -1224px;width:40px;height:40px}.shop_weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1365px -1274px;width:40px;height:40px}.slim_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-212px -273px;width:93px;height:90px}.slim_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:0 -273px;width:105px;height:90px}.slim_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1546px -1365px;width:90px;height:90px}.slim_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:0 -1456px;width:90px;height:90px}.slim_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-91px -1456px;width:90px;height:90px}.slim_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 0;width:120px;height:90px}.slim_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px -182px;width:105px;height:90px}.slim_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-364px -1456px;width:90px;height:90px}.weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-306px -273px;width:93px;height:90px}.weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-348px 0;width:105px;height:90px}.weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-637px -1456px;width:90px;height:90px}.weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-728px -1456px;width:90px;height:90px}.weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-819px -1456px;width:90px;height:90px}.weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px -91px;width:120px;height:90px}.weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-242px 0;width:105px;height:90px}.weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1092px -1456px;width:90px;height:90px}.broad_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1183px -1456px;width:90px;height:90px}.head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1274px -1456px;width:90px;height:90px}.shop_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-287px -1547px;width:40px;height:40px}.shop_head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-328px -1547px;width:40px;height:40px}.slim_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1365px -1456px;width:90px;height:90px}.back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1456px -1456px;width:90px;height:90px}.broad_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1637px 0;width:90px;height:90px}.head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1637px -91px;width:90px;height:90px}.shop_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-533px -1547px;width:40px;height:40px}.shop_back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-574px -1547px;width:40px;height:40px}.shop_head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-615px -1547px;width:40px;height:40px}.slim_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1637px -182px;width:90px;height:90px}.broad_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1637px -273px;width:90px;height:90px}.headAccessory_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1637px -364px;width:90px;height:90px}.shop_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-779px -1547px;width:40px;height:40px}.shop_headAccessory_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-820px -1547px;width:40px;height:40px}.slim_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1637px -455px;width:90px;height:90px}.back_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-1637px -546px;width:90px;height:90px}.headAccessory_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-1637px -637px;width:90px;height:90px}.shop_back_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-984px -1547px;width:40px;height:40px}.shop_headAccessory_mystery_201404{background-image:url(spritesmith-main-3.png);background-position:-1025px -1547px;width:40px;height:40px}.broad_armor_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1637px -728px;width:90px;height:90px}.head_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1637px -819px;width:90px;height:90px}.shop_armor_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1148px -1547px;width:40px;height:40px}.shop_head_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1189px -1547px;width:40px;height:40px}.slim_armor_mystery_201405{background-image:url(spritesmith-main-3.png);background-position:-1637px -910px;width:90px;height:90px}.broad_armor_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-454px -97px;width:90px;height:96px}.head_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-454px 0;width:90px;height:96px}.shop_armor_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-1353px -1547px;width:40px;height:40px}.shop_head_mystery_201406{background-image:url(spritesmith-main-3.png);background-position:-1271px -1547px;width:40px;height:40px}.slim_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-739px -212px;width:90px;height:96px}.broad_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-637px -1425px;width:90px;height:90px}.head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1109px -91px;width:90px;height:90px}.shop_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -861px;width:40px;height:40px}.shop_head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -820px;width:40px;height:40px}.slim_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1109px -364px;width:90px;height:90px}.broad_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1109px -455px;width:90px;height:90px}.head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1109px -546px;width:90px;height:90px}.shop_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-377px -303px;width:40px;height:40px}.shop_head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1382px -1274px;width:40px;height:40px}.slim_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-728px -1425px;width:90px;height:90px}.broad_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1274px -1425px;width:90px;height:90px}.headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1365px -1425px;width:90px;height:90px}.shop_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1473px -1365px;width:40px;height:40px}.shop_headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-697px -1557px;width:40px;height:40px}.slim_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1001px -1425px;width:90px;height:90px}.back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:0 -788px;width:93px;height:90px}.broad_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -637px;width:93px;height:90px}.shop_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1514px -1365px;width:40px;height:40px}.shop_back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1423px -1274px;width:40px;height:40px}.slim_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -273px;width:93px;height:90px}.head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -819px;width:90px;height:90px}.shop_head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1564px -410px;width:40px;height:40px}.shop_weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1564px -451px;width:40px;height:40px}.weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -546px;width:90px;height:90px}.broad_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1382px -364px;width:90px;height:90px}.head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1382px -273px;width:90px;height:90px}.shop_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1564px -492px;width:40px;height:40px}.shop_head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1564px -533px;width:40px;height:40px}.slim_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1382px 0;width:90px;height:90px}.broad_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1274px -1243px;width:90px;height:90px}.head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1183px -1243px;width:90px;height:90px}.shop_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1564px -574px;width:40px;height:40px}.shop_head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1564px -615px;width:40px;height:40px}.slim_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-910px -1243px;width:90px;height:90px}.headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-637px -1243px;width:90px;height:90px}.shop_headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1564px -943px;width:40px;height:40px}.shop_weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1564px -984px;width:40px;height:40px}.weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-364px -1243px;width:90px;height:90px}.broad_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-273px -1243px;width:90px;height:90px}.eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-182px -1243px;width:90px;height:90px}.shop_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1564px -1025px;width:40px;height:40px}.shop_eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1564px -1066px;width:40px;height:40px}.slim_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1291px -910px;width:90px;height:90px}.back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1291px -819px;width:90px;height:90px}.broad_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1291px -728px;width:90px;height:90px}.shop_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1564px -1107px;width:40px;height:40px}.shop_back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1646px -451px;width:40px;height:40px}.slim_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1382px -910px;width:90px;height:90px}.head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1109px -273px;width:90px;height:90px}.shop_head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1646px -738px;width:40px;height:40px}.shop_weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-648px -530px;width:40px;height:40px}.weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1473px 0;width:90px;height:90px}.broad_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-739px -106px;width:90px;height:105px}.eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-648px 0;width:90px;height:105px}.shop_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1646px -820px;width:40px;height:40px}.shop_eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1646px -779px;width:40px;height:40px}.slim_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-637px -591px;width:90px;height:105px}.back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-536px -288px;width:90px;height:105px}.eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-739px 0;width:90px;height:105px}.shop_back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-1646px -697px;width:40px;height:40px}.shop_eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-1646px -656px;width:40px;height:40px}.broad_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -182px;width:93px;height:90px}.head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -546px;width:93px;height:90px}.shop_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-1646px -615px;width:40px;height:40px}.shop_head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-1646px -574px;width:40px;height:40px}.slim_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -455px;width:93px;height:90px}.broad_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-739px -309px;width:90px;height:90px}.head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-739px -400px;width:90px;height:90px}.shop_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1646px -533px;width:40px;height:40px}.shop_head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1646px -492px;width:40px;height:40px}.slim_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-94px -788px;width:90px;height:90px}.back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-536px -394px;width:105px;height:90px}.headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-830px -364px;width:93px;height:90px}.shop_back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-1646px -410px;width:40px;height:40px}.shop_headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-1646px -369px;width:40px;height:40px}.broad_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-549px -788px;width:90px;height:90px}.eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-640px -788px;width:90px;height:90px}.head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-731px -788px;width:90px;height:90px}.shop_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1646px -328px;width:40px;height:40px}.shop_eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1646px -287px;width:40px;height:40px}.shop_head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1605px -1189px;width:40px;height:40px}.shop_weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1605px -1148px;width:40px;height:40px}.slim_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-927px -273px;width:90px;height:90px}.weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-927px -364px;width:90px;height:90px}.eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-927px -455px;width:90px;height:90px}.headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-927px -546px;width:90px;height:90px}.head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-927px -637px;width:90px;height:90px}.shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-927px -728px;width:90px;height:90px}.shop_eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1605px -1107px;width:40px;height:40px}.shop_headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1605px -1066px;width:40px;height:40px}.shop_head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1605px -1025px;width:40px;height:40px}.shop_shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1605px -984px;width:40px;height:40px}.broad_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-364px -879px;width:90px;height:90px}.broad_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-455px -879px;width:90px;height:90px}.broad_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-546px -879px;width:90px;height:90px}.broad_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-637px -879px;width:90px;height:90px}.broad_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-728px -879px;width:90px;height:90px}.broad_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-819px -879px;width:90px;height:90px}.broad_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-910px -879px;width:90px;height:90px}.broad_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1018px 0;width:90px;height:90px}.headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1018px -91px;width:90px;height:90px}.headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1018px -182px;width:90px;height:90px}.headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1018px -273px;width:90px;height:90px}.headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1018px -364px;width:90px;height:90px}.headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1018px -455px;width:90px;height:90px}.headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1018px -546px;width:90px;height:90px}.headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1018px -637px;width:90px;height:90px}.headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1018px -728px;width:90px;height:90px}.head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1018px -819px;width:90px;height:90px}.head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -970px;width:90px;height:90px}.head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-91px -970px;width:90px;height:90px}.head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-182px -970px;width:90px;height:90px}.head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-273px -970px;width:90px;height:90px}.head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-364px -970px;width:90px;height:90px}.head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-455px -970px;width:90px;height:90px}.head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-546px -970px;width:90px;height:90px}.shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-637px -970px;width:90px;height:90px}.shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-728px -970px;width:90px;height:90px}.shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-819px -970px;width:90px;height:90px}.shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-910px -970px;width:90px;height:90px}.shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1001px -970px;width:90px;height:90px}.shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px 0;width:90px;height:90px}.shop_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -943px;width:40px;height:40px}.shop_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1605px -902px;width:40px;height:40px}.shop_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -779px;width:40px;height:40px}.shop_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -738px;width:40px;height:40px}.shop_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1605px -697px;width:40px;height:40px}.shop_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1605px -656px;width:40px;height:40px}.shop_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -615px;width:40px;height:40px}.shop_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -574px;width:40px;height:40px}.shop_headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -533px;width:40px;height:40px}.shop_headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1605px -492px;width:40px;height:40px}.shop_headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -451px;width:40px;height:40px}.shop_headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -410px;width:40px;height:40px}.shop_headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1605px -41px;width:40px;height:40px}.shop_headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1605px 0;width:40px;height:40px}.shop_headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1558px -1557px;width:40px;height:40px}.shop_headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1517px -1557px;width:40px;height:40px}.shop_head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1476px -1557px;width:40px;height:40px}.shop_head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1435px -1557px;width:40px;height:40px}.shop_head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1394px -1557px;width:40px;height:40px}.shop_head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1353px -1557px;width:40px;height:40px}.shop_head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-328px -1557px;width:40px;height:40px}.shop_head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-287px -1557px;width:40px;height:40px}.shop_head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-246px -1557px;width:40px;height:40px}.shop_head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-205px -1557px;width:40px;height:40px}.shop_shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-164px -1557px;width:40px;height:40px}.shop_shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-123px -1557px;width:40px;height:40px}.shop_shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-82px -1557px;width:40px;height:40px}.shop_shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-41px -1557px;width:40px;height:40px}.shop_shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:0 -1557px;width:40px;height:40px}.shop_shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1476px;width:40px;height:40px}.shop_weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1435px;width:40px;height:40px}.shop_weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -1394px;width:40px;height:40px}.shop_weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1353px;width:40px;height:40px}.shop_weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1312px;width:40px;height:40px}.shop_weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1271px;width:40px;height:40px}.shop_weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -1230px;width:40px;height:40px}.shop_weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1189px;width:40px;height:40px}.shop_weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1148px;width:40px;height:40px}.slim_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-273px -1152px;width:90px;height:90px}.slim_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-364px -1152px;width:90px;height:90px}.slim_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-455px -1152px;width:90px;height:90px}.slim_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -1152px;width:90px;height:90px}.slim_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-637px -1152px;width:90px;height:90px}.slim_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-728px -1152px;width:90px;height:90px}.slim_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-819px -1152px;width:90px;height:90px}.slim_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-910px -1152px;width:90px;height:90px}.weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1001px -1152px;width:90px;height:90px}.weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1092px -1152px;width:90px;height:90px}.weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1183px -1152px;width:90px;height:90px}.weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px 0;width:90px;height:90px}.weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1291px -91px;width:90px;height:90px}.weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1291px -182px;width:90px;height:90px}.weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -273px;width:90px;height:90px}.weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -364px;width:90px;height:90px}.body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -455px;width:90px;height:90px}.body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1291px -546px;width:90px;height:90px}.body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px 0;width:102px;height:105px}.body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -591px;width:90px;height:105px}.body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -591px;width:90px;height:105px}.body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-364px -591px;width:90px;height:105px}.broad_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -1001px;width:90px;height:90px}.broad_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1291px -1092px;width:90px;height:90px}.broad_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px 0;width:102px;height:105px}.broad_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-182px -591px;width:90px;height:105px}.broad_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-91px -591px;width:90px;height:105px}.broad_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:0 -591px;width:90px;height:105px}.broad_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-536px 0;width:111px;height:90px}.broad_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-336px -394px;width:111px;height:90px}.eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -394px;width:111px;height:90px}.eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-112px -394px;width:111px;height:90px}.head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1243px;width:90px;height:90px}.head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -1243px;width:90px;height:90px}.head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 -106px;width:102px;height:105px}.head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -485px;width:90px;height:105px}.head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -485px;width:90px;height:105px}.head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-364px -485px;width:90px;height:105px}.head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-424px -273px;width:111px;height:90px}.head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -182px;width:111px;height:90px}.Healer_Summer{background-image:url(spritesmith-main-4.png);background-position:-91px -485px;width:90px;height:105px}.Mage_Summer{background-image:url(spritesmith-main-4.png);background-position:-536px -182px;width:90px;height:105px}.SummerRogue14{background-image:url(spritesmith-main-4.png);background-position:-424px 0;width:111px;height:90px}.SummerWarrior14{background-image:url(spritesmith-main-4.png);background-position:-224px -303px;width:111px;height:90px}.shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px -455px;width:90px;height:90px}.shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 0;width:102px;height:105px}.shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-648px -424px;width:90px;height:105px}.shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-648px -318px;width:90px;height:105px}.shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -303px;width:111px;height:90px}.shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -182px;width:111px;height:90px}.shop_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -369px;width:40px;height:40px}.shop_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -328px;width:40px;height:40px}.shop_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -287px;width:40px;height:40px}.shop_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -246px;width:40px;height:40px}.shop_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -205px;width:40px;height:40px}.shop_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -164px;width:40px;height:40px}.shop_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -123px;width:40px;height:40px}.shop_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -82px;width:40px;height:40px}.shop_body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -41px;width:40px;height:40px}.shop_body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px 0;width:40px;height:40px}.shop_body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1517px -1516px;width:40px;height:40px}.shop_body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1476px -1516px;width:40px;height:40px}.shop_body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1435px -1516px;width:40px;height:40px}.shop_body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1394px -1516px;width:40px;height:40px}.shop_eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1353px -1516px;width:40px;height:40px}.shop_eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1312px -1516px;width:40px;height:40px}.shop_head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1271px -1516px;width:40px;height:40px}.shop_head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-448px -435px;width:40px;height:40px}.shop_head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-489px -394px;width:40px;height:40px}.shop_head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-448px -394px;width:40px;height:40px}.shop_head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-377px -344px;width:40px;height:40px}.shop_head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-336px -344px;width:40px;height:40px}.shop_head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-336px -303px;width:40px;height:40px}.shop_head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-230px -253px;width:40px;height:40px}.shop_shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-230px -212px;width:40px;height:40px}.shop_shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-689px -530px;width:40px;height:40px}.shop_shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-871px -728px;width:40px;height:40px}.shop_shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-830px -728px;width:40px;height:40px}.shop_shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-968px -819px;width:40px;height:40px}.shop_shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-927px -819px;width:40px;height:40px}.shop_weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1059px -910px;width:40px;height:40px}.shop_weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1018px -910px;width:40px;height:40px}.shop_weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1150px -1001px;width:40px;height:40px}.shop_weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -1001px;width:40px;height:40px}.shop_weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1241px -1092px;width:40px;height:40px}.shop_weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1092px;width:40px;height:40px}.shop_weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1332px -1183px;width:40px;height:40px}.shop_weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -1183px;width:40px;height:40px}.slim_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-364px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-455px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px -106px;width:102px;height:105px}.slim_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -485px;width:90px;height:105px}.slim_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-273px -485px;width:90px;height:105px}.slim_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-182px -485px;width:90px;height:105px}.slim_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -394px;width:111px;height:90px}.slim_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -91px;width:111px;height:90px}.weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1092px -1425px;width:90px;height:90px}.weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1183px -1425px;width:90px;height:90px}.weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px -106px;width:102px;height:105px}.weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -591px;width:90px;height:105px}.weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-648px -212px;width:90px;height:105px}.weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -106px;width:90px;height:105px}.weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-112px -303px;width:111px;height:90px}.weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-536px -91px;width:111px;height:90px}.broad_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-546px -1425px;width:90px;height:90px}.broad_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-273px -1425px;width:90px;height:90px}.broad_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-182px -1425px;width:90px;height:90px}.broad_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1425px;width:90px;height:90px}.broad_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -1425px;width:90px;height:90px}.broad_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-513px -697px;width:96px;height:90px}.broad_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1473px -1183px;width:90px;height:90px}.broad_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1473px -1092px;width:90px;height:90px}.head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -1001px;width:90px;height:90px}.head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-1473px -910px;width:90px;height:90px}.head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-1473px -819px;width:90px;height:90px}.head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -728px;width:90px;height:90px}.head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -637px;width:90px;height:90px}.head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1456px -1425px;width:90px;height:90px}.head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1473px -546px;width:90px;height:90px}.head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-610px -697px;width:96px;height:90px}.head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1473px -364px;width:90px;height:90px}.head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1473px -273px;width:90px;height:90px}.shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -697px;width:104px;height:90px}.shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -182px;width:90px;height:90px}.shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px -91px;width:90px;height:90px}.shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-707px -697px;width:96px;height:90px}.shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1365px -1334px;width:90px;height:90px}.shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1274px -1334px;width:90px;height:90px}.shop_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-489px -435px;width:40px;height:40px}.shop_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -1516px;width:40px;height:40px}.shop_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-41px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-82px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-123px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-164px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-205px -1516px;width:40px;height:40px}.shop_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-246px -1516px;width:40px;height:40px}.shop_head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-287px -1516px;width:40px;height:40px}.shop_head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-328px -1516px;width:40px;height:40px}.shop_head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-369px -1516px;width:40px;height:40px}.shop_head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-410px -1516px;width:40px;height:40px}.shop_head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-451px -1516px;width:40px;height:40px}.shop_head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-492px -1516px;width:40px;height:40px}.shop_head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-533px -1516px;width:40px;height:40px}.shop_head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-574px -1516px;width:40px;height:40px}.shop_head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-615px -1516px;width:40px;height:40px}.shop_head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-656px -1516px;width:40px;height:40px}.shop_shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:-697px -1516px;width:40px;height:40px}.shop_shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-738px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-779px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-820px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-861px -1516px;width:40px;height:40px}.shop_shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-902px -1516px;width:40px;height:40px}.shop_weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-943px -1516px;width:40px;height:40px}.shop_weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-984px -1516px;width:40px;height:40px}.shop_weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1025px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1066px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1107px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1148px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1189px -1516px;width:40px;height:40px}.shop_weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1230px -1516px;width:40px;height:40px}.slim_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1183px -1334px;width:90px;height:90px}.slim_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1092px -1334px;width:90px;height:90px}.slim_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1001px -1334px;width:90px;height:90px}.slim_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-910px -1334px;width:90px;height:90px}.slim_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -1334px;width:90px;height:90px}.slim_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-830px -91px;width:96px;height:90px}.slim_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-637px -1334px;width:90px;height:90px}.slim_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-546px -1334px;width:90px;height:90px}.weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-455px -1334px;width:90px;height:90px}.weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-364px -1334px;width:90px;height:90px}.weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-273px -1334px;width:90px;height:90px}.weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-182px -1334px;width:90px;height:90px}.weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-91px -1334px;width:90px;height:90px}.weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-416px -697px;width:96px;height:90px}.weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1382px -1183px;width:90px;height:90px}.weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1382px -1092px;width:90px;height:90px}.back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1382px -1001px;width:90px;height:90px}.back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1382px -728px;width:90px;height:90px}.body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1382px -637px;width:90px;height:90px}.body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1382px -182px;width:90px;height:90px}.body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1382px -91px;width:90px;height:90px}.eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1092px -1243px;width:90px;height:90px}.eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1001px -1243px;width:90px;height:90px}.shop_back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -656px;width:40px;height:40px}.shop_back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -697px;width:40px;height:40px}.shop_body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -738px;width:40px;height:40px}.shop_body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1564px -779px;width:40px;height:40px}.shop_body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -820px;width:40px;height:40px}.shop_eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -861px;width:40px;height:40px}.shop_eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -902px;width:40px;height:40px}.head_0{background-image:url(spritesmith-main-4.png);background-position:-546px -1243px;width:90px;height:90px}.customize-option.head_0{background-image:url(spritesmith-main-4.png);background-position:-571px -1258px;width:60px;height:60px}.head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-455px -1243px;width:90px;height:90px}.head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-91px -1243px;width:90px;height:90px}.head_healer_3{background-image:url(spritesmith-main-4.png);background-position:0 -1243px;width:90px;height:90px}.head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1291px -637px;width:90px;height:90px}.head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-182px -1152px;width:90px;height:90px}.head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-91px -1152px;width:90px;height:90px}.head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:0 -1152px;width:90px;height:90px}.head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1200px -1001px;width:90px;height:90px}.head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1200px -910px;width:90px;height:90px}.head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1200px -819px;width:90px;height:90px}.head_special_2{background-image:url(spritesmith-main-4.png);background-position:-1200px -728px;width:90px;height:90px}.head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-1200px -637px;width:90px;height:90px}.head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1200px -546px;width:90px;height:90px}.head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1200px -455px;width:90px;height:90px}.head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1200px -364px;width:90px;height:90px}.head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1200px -273px;width:90px;height:90px}.head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1200px -182px;width:90px;height:90px}.head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1200px -91px;width:90px;height:90px}.head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1200px 0;width:90px;height:90px}.head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1092px -1061px;width:90px;height:90px}.head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1001px -1061px;width:90px;height:90px}.head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-910px -1061px;width:90px;height:90px}.shop_head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-369px -1557px;width:40px;height:40px}.shop_head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-410px -1557px;width:40px;height:40px}.shop_head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-451px -1557px;width:40px;height:40px}.shop_head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-492px -1557px;width:40px;height:40px}.shop_head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-533px -1557px;width:40px;height:40px}.shop_head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-574px -1557px;width:40px;height:40px}.shop_head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-615px -1557px;width:40px;height:40px}.shop_head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-656px -1557px;width:40px;height:40px}.shop_head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -861px;width:40px;height:40px}.shop_head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-738px -1557px;width:40px;height:40px}.shop_head_special_0{background-image:url(spritesmith-main-4.png);background-position:-779px -1557px;width:40px;height:40px}.shop_head_special_1{background-image:url(spritesmith-main-4.png);background-position:-820px -1557px;width:40px;height:40px}.shop_head_special_2{background-image:url(spritesmith-main-4.png);background-position:-861px -1557px;width:40px;height:40px}.shop_head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-902px -1557px;width:40px;height:40px}.shop_head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-943px -1557px;width:40px;height:40px}.shop_head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-984px -1557px;width:40px;height:40px}.shop_head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1025px -1557px;width:40px;height:40px}.shop_head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1066px -1557px;width:40px;height:40px}.shop_head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1107px -1557px;width:40px;height:40px}.shop_head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1148px -1557px;width:40px;height:40px}.shop_head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1189px -1557px;width:40px;height:40px}.shop_head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1230px -1557px;width:40px;height:40px}.shop_head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1271px -1557px;width:40px;height:40px}.shop_head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1312px -1557px;width:40px;height:40px}.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-819px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-844px -1076px;width:60px;height:60px}.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-728px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-753px -1076px;width:60px;height:60px}.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-637px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-662px -1076px;width:60px;height:60px}.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-546px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-571px -1076px;width:60px;height:60px}.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-455px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-480px -1076px;width:60px;height:60px}.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-364px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-389px -1076px;width:60px;height:60px}.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-273px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-298px -1076px;width:60px;height:60px}.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-182px -1061px;width:90px;height:90px}.customize-option.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-207px -1076px;width:60px;height:60px}.shop_headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -82px;width:40px;height:40px}.shop_headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -123px;width:40px;height:40px}.shop_headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -164px;width:40px;height:40px}.shop_headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -205px;width:40px;height:40px}.shop_headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -246px;width:40px;height:40px}.shop_headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -287px;width:40px;height:40px}.shop_headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -328px;width:40px;height:40px}.shop_headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -369px;width:40px;height:40px}.shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-91px -1061px;width:90px;height:90px}.shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:0 -1061px;width:90px;height:90px}.shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1109px -910px;width:90px;height:90px}.shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1109px -819px;width:90px;height:90px}.shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-1109px -728px;width:90px;height:90px}.shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-1109px -637px;width:90px;height:90px}.shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-105px -697px;width:103px;height:90px}.shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-209px -697px;width:103px;height:90px}.shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-115px -212px;width:114px;height:90px}.shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-830px 0;width:96px;height:90px}.shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-309px 0;width:114px;height:90px}.shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:0 -212px;width:114px;height:90px}.shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-1109px -182px;width:90px;height:90px}.shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-309px -91px;width:111px;height:90px}.shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-273px -879px;width:90px;height:90px}.shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-182px -879px;width:90px;height:90px}.shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-91px -879px;width:90px;height:90px}.shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:0 -879px;width:90px;height:90px}.shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-927px -182px;width:90px;height:90px}.shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-927px -91px;width:90px;height:90px}.shop_shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1605px -1230px;width:40px;height:40px}.shop_shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1605px -1271px;width:40px;height:40px}.shop_shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1605px -1312px;width:40px;height:40px}.shop_shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1605px -1353px;width:40px;height:40px}.shop_shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-1605px -1394px;width:40px;height:40px}.shop_shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-1605px -1435px;width:40px;height:40px}.shop_shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1605px -1476px;width:40px;height:40px}.shop_shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1605px -1517px;width:40px;height:40px}.shop_shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:0 -1598px;width:40px;height:40px}.shop_shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-41px -1598px;width:40px;height:40px}.shop_shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-82px -1598px;width:40px;height:40px}.shop_shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-123px -1598px;width:40px;height:40px}.shop_shield_special_0{background-image:url(spritesmith-main-4.png);background-position:-164px -1598px;width:40px;height:40px}.shop_shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-205px -1598px;width:40px;height:40px}.shop_shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-246px -1598px;width:40px;height:40px}.shop_shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-287px -1598px;width:40px;height:40px}.shop_shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-328px -1598px;width:40px;height:40px}.shop_shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-369px -1598px;width:40px;height:40px}.shop_shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-410px -1598px;width:40px;height:40px}.shop_shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-451px -1598px;width:40px;height:40px}.shop_shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-492px -1598px;width:40px;height:40px}.shop_weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-533px -1598px;width:40px;height:40px}.shop_weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-574px -1598px;width:40px;height:40px}.shop_weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-615px -1598px;width:40px;height:40px}.shop_weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-656px -1598px;width:40px;height:40px}.shop_weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-697px -1598px;width:40px;height:40px}.shop_weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-738px -1598px;width:40px;height:40px}.shop_weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-779px -1598px;width:40px;height:40px}.shop_weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-820px -1598px;width:40px;height:40px}.shop_weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-861px -1598px;width:40px;height:40px}.shop_weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-902px -1598px;width:40px;height:40px}.shop_weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-943px -1598px;width:40px;height:40px}.shop_weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-984px -1598px;width:40px;height:40px}.shop_weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1025px -1598px;width:40px;height:40px}.shop_weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1066px -1598px;width:40px;height:40px}.shop_weapon_special_0{background-image:url(spritesmith-main-4.png);background-position:-1107px -1598px;width:40px;height:40px}.shop_weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-1148px -1598px;width:40px;height:40px}.shop_weapon_special_2{background-image:url(spritesmith-main-4.png);background-position:-1189px -1598px;width:40px;height:40px}.shop_weapon_special_3{background-image:url(spritesmith-main-4.png);background-position:-1230px -1598px;width:40px;height:40px}.shop_weapon_special_critical{background-image:url(spritesmith-main-4.png);background-position:-1271px -1598px;width:40px;height:40px}.shop_weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-4.png);background-position:-1312px -1598px;width:40px;height:40px}.shop_weapon_warrior_0{background-image:url(spritesmith-main-4.png);background-position:-1353px -1598px;width:40px;height:40px}.shop_weapon_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1394px -1598px;width:40px;height:40px}.shop_weapon_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1435px -1598px;width:40px;height:40px}.shop_weapon_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1476px -1598px;width:40px;height:40px}.shop_weapon_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1517px -1598px;width:40px;height:40px}.shop_weapon_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1558px -1598px;width:40px;height:40px}.shop_weapon_warrior_6{background-image:url(spritesmith-main-4.png);background-position:-1599px -1598px;width:40px;height:40px}.shop_weapon_wizard_0{background-image:url(spritesmith-main-4.png);background-position:-1646px 0;width:40px;height:40px}.shop_weapon_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -41px;width:40px;height:40px}.shop_weapon_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -82px;width:40px;height:40px}.shop_weapon_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -123px;width:40px;height:40px}.shop_weapon_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -164px;width:40px;height:40px}.shop_weapon_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -205px;width:40px;height:40px}.shop_weapon_wizard_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -246px;width:40px;height:40px}.weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-927px 0;width:90px;height:90px}.weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-822px -788px;width:90px;height:90px}.weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-458px -788px;width:90px;height:90px}.weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-367px -788px;width:90px;height:90px}.weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-185px -788px;width:90px;height:90px}.weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-739px -582px;width:90px;height:90px}.weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-739px -491px;width:90px;height:90px}.weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-819px -1425px;width:90px;height:90px}.weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1473px -1274px;width:90px;height:90px}.weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-728px -1334px;width:90px;height:90px}.weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:0 -1334px;width:90px;height:90px}.weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-276px -788px;width:90px;height:90px}.weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-910px -1425px;width:90px;height:90px}.weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1473px -455px;width:90px;height:90px}.weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-313px -697px;width:102px;height:90px}.weapon_special_2{background-image:url(spritesmith-main-5.png);background-position:-1621px -910px;width:90px;height:90px}.weapon_special_3{background-image:url(spritesmith-main-5.png);background-position:-1621px -1001px;width:90px;height:90px}.weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-5.png);background-position:-1621px -1092px;width:90px;height:90px}.weapon_warrior_0{background-image:url(spritesmith-main-5.png);background-position:-1621px -1183px;width:90px;height:90px}.weapon_warrior_1{background-image:url(spritesmith-main-5.png);background-position:-1621px -1365px;width:90px;height:90px}.weapon_warrior_2{background-image:url(spritesmith-main-5.png);background-position:-1621px -1456px;width:90px;height:90px}.weapon_warrior_3{background-image:url(spritesmith-main-5.png);background-position:0 -1562px;width:90px;height:90px}.weapon_warrior_4{background-image:url(spritesmith-main-5.png);background-position:-91px -1562px;width:90px;height:90px}.weapon_warrior_5{background-image:url(spritesmith-main-5.png);background-position:-182px -1562px;width:90px;height:90px}.weapon_warrior_6{background-image:url(spritesmith-main-5.png);background-position:-1621px 0;width:90px;height:90px}.weapon_wizard_0{background-image:url(spritesmith-main-5.png);background-position:-1621px -273px;width:90px;height:90px}.weapon_wizard_1{background-image:url(spritesmith-main-5.png);background-position:-1621px -364px;width:90px;height:90px}.weapon_wizard_2{background-image:url(spritesmith-main-5.png);background-position:-1621px -455px;width:90px;height:90px}.weapon_wizard_3{background-image:url(spritesmith-main-5.png);background-position:-1621px -546px;width:90px;height:90px}.weapon_wizard_4{background-image:url(spritesmith-main-5.png);background-position:-1621px -637px;width:90px;height:90px}.weapon_wizard_5{background-image:url(spritesmith-main-5.png);background-position:-1621px -728px;width:90px;height:90px}.weapon_wizard_6{background-image:url(spritesmith-main-5.png);background-position:-1621px -819px;width:90px;height:90px}.GrimReaper{background-image:url(spritesmith-main-5.png);background-position:-1536px -194px;width:57px;height:66px}.Pet_Currency_Gem{background-image:url(spritesmith-main-5.png);background-position:-1761px -1439px;width:45px;height:39px}.Pet_Currency_Gem1x{background-image:url(spritesmith-main-5.png);background-position:-1792px -1725px;width:15px;height:13px}.Pet_Currency_Gem2x{background-image:url(spritesmith-main-5.png);background-position:-1585px -1074px;width:30px;height:26px}.PixelPaw-Gold{background-image:url(spritesmith-main-5.png);background-position:-1536px -970px;width:51px;height:51px}.PixelPaw{background-image:url(spritesmith-main-5.png);background-position:-1536px -918px;width:51px;height:51px}.PixelPaw002{background-image:url(spritesmith-main-5.png);background-position:-1536px -866px;width:51px;height:51px}.avatar_floral_healer{background-image:url(spritesmith-main-5.png);background-position:-1373px -1390px;width:99px;height:99px}.avatar_floral_rogue{background-image:url(spritesmith-main-5.png);background-position:-1273px -1390px;width:99px;height:99px}.avatar_floral_warrior{background-image:url(spritesmith-main-5.png);background-position:-1173px -1390px;width:99px;height:99px}.avatar_floral_wizard{background-image:url(spritesmith-main-5.png);background-position:-1073px -1390px;width:99px;height:99px}.inventory_present{background-image:url(spritesmith-main-5.png);background-position:-1712px -208px;width:48px;height:51px}.inventory_present_01{background-image:url(spritesmith-main-5.png);background-position:-1712px -780px;width:48px;height:51px}.inventory_present_02{background-image:url(spritesmith-main-5.png);background-position:-1712px -1040px;width:48px;height:51px}.inventory_present_03{background-image:url(spritesmith-main-5.png);background-position:-1712px -1196px;width:48px;height:51px}.inventory_present_04{background-image:url(spritesmith-main-5.png);background-position:-1712px -1248px;width:48px;height:51px}.inventory_present_05{background-image:url(spritesmith-main-5.png);background-position:-1712px -1300px;width:48px;height:51px}.inventory_present_06{background-image:url(spritesmith-main-5.png);background-position:-1712px -1352px;width:48px;height:51px}.inventory_present_07{background-image:url(spritesmith-main-5.png);background-position:-1712px -1404px;width:48px;height:51px}.inventory_present_08{background-image:url(spritesmith-main-5.png);background-position:-1712px -1456px;width:48px;height:51px}.inventory_present_09{background-image:url(spritesmith-main-5.png);background-position:-1712px -1560px;width:48px;height:51px}.inventory_present_10{background-image:url(spritesmith-main-5.png);background-position:-1712px -1612px;width:48px;height:51px}.inventory_present_11{background-image:url(spritesmith-main-5.png);background-position:0 -1705px;width:48px;height:51px}.inventory_present_12{background-image:url(spritesmith-main-5.png);background-position:-1712px -1144px;width:48px;height:51px}.inventory_quest_scroll{background-image:url(spritesmith-main-5.png);background-position:-1712px -1092px;width:48px;height:51px}.inventory_quest_scroll_locked{background-image:url(spritesmith-main-5.png);background-position:-1712px -988px;width:48px;height:51px}.inventory_special_fortify{background-image:url(spritesmith-main-5.png);background-position:-1536px -591px;width:57px;height:54px}.inventory_special_greeting{background-image:url(spritesmith-main-5.png);background-position:-1536px -536px;width:57px;height:54px}.inventory_special_nye{background-image:url(spritesmith-main-5.png);background-position:-1536px -316px;width:57px;height:54px}.inventory_special_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1712px -1664px;width:40px;height:40px}.inventory_special_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1536px -371px;width:57px;height:54px}.inventory_special_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1536px -811px;width:57px;height:54px}.inventory_special_snowball{background-image:url(spritesmith-main-5.png);background-position:-1536px -756px;width:57px;height:54px}.inventory_special_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1536px -701px;width:57px;height:54px}.inventory_special_thankyou{background-image:url(spritesmith-main-5.png);background-position:-1536px -646px;width:57px;height:54px}.inventory_special_trinket{background-image:url(spritesmith-main-5.png);background-position:-1712px -468px;width:48px;height:51px}.inventory_special_valentine{background-image:url(spritesmith-main-5.png);background-position:-1536px -261px;width:57px;height:54px}.knockout{background-image:url(spritesmith-main-5.png);background-position:-364px -1562px;width:120px;height:47px}.pet_key{background-image:url(spritesmith-main-5.png);background-position:-1536px -481px;width:57px;height:54px}.rebirth_orb{background-image:url(spritesmith-main-5.png);background-position:-1536px -426px;width:57px;height:54px}.seafoam_star{background-image:url(spritesmith-main-5.png);background-position:-273px -1562px;width:90px;height:90px}.shop_armoire{background-image:url(spritesmith-main-5.png);background-position:-1761px -1561px;width:40px;height:40px}.snowman{background-image:url(spritesmith-main-5.png);background-position:-1621px -91px;width:90px;height:90px}.spookman{background-image:url(spritesmith-main-5.png);background-position:-1621px -182px;width:90px;height:90px}.zzz{background-image:url(spritesmith-main-5.png);background-position:-1761px -1520px;width:40px;height:40px}.zzz_light{background-image:url(spritesmith-main-5.png);background-position:-1761px -1479px;width:40px;height:40px}.npc_alex{background-image:url(spritesmith-main-5.png);background-position:0 -1251px;width:162px;height:138px}.npc_bailey{background-image:url(spritesmith-main-5.png);background-position:-1536px -121px;width:60px;height:72px}.npc_daniel{background-image:url(spritesmith-main-5.png);background-position:-163px -1251px;width:135px;height:123px}.npc_justin{background-image:url(spritesmith-main-5.png);background-position:-1536px 0;width:84px;height:120px}.npc_justin_head{background-image:url(spritesmith-main-5.png);background-position:-1284px -1070px;width:36px;height:39px}.npc_matt{background-image:url(spritesmith-main-5.png);background-position:-1322px -954px;width:195px;height:138px}.npc_timetravelers{background-image:url(spritesmith-main-5.png);background-position:-1322px -815px;width:195px;height:138px}.npc_timetravelers_active{background-image:url(spritesmith-main-5.png);background-position:-1322px -676px;width:195px;height:138px}.npc_tyler{background-image:url(spritesmith-main-5.png);background-position:-1621px -1274px;width:90px;height:90px}.seasonalshop_closed{background-image:url(spritesmith-main-5.png);background-position:-1121px -1070px;width:162px;height:138px}.inventory_quest_scroll_atom1{background-image:url(spritesmith-main-5.png);background-position:-1372px -1653px;width:48px;height:51px}.inventory_quest_scroll_atom1_locked{background-image:url(spritesmith-main-5.png);background-position:-1323px -1653px;width:48px;height:51px}.inventory_quest_scroll_atom2{background-image:url(spritesmith-main-5.png);background-position:-1274px -1653px;width:48px;height:51px}.inventory_quest_scroll_atom2_locked{background-image:url(spritesmith-main-5.png);background-position:-1225px -1653px;width:48px;height:51px}.inventory_quest_scroll_atom3{background-image:url(spritesmith-main-5.png);background-position:-1176px -1653px;width:48px;height:51px}.inventory_quest_scroll_atom3_locked{background-image:url(spritesmith-main-5.png);background-position:-1127px -1653px;width:48px;height:51px}.inventory_quest_scroll_basilist{background-image:url(spritesmith-main-5.png);background-position:-1078px -1653px;width:48px;height:51px}.inventory_quest_scroll_bunny{background-image:url(spritesmith-main-5.png);background-position:-980px -1653px;width:48px;height:51px}.inventory_quest_scroll_cheetah{background-image:url(spritesmith-main-5.png);background-position:-931px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-882px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-833px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2_locked{background-image:url(spritesmith-main-5.png);background-position:-784px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-686px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3_locked{background-image:url(spritesmith-main-5.png);background-position:-637px -1653px;width:48px;height:51px}.inventory_quest_scroll_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-588px -1653px;width:48px;height:51px}.inventory_quest_scroll_egg{background-image:url(spritesmith-main-5.png);background-position:-539px -1653px;width:48px;height:51px}.inventory_quest_scroll_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-441px -1653px;width:48px;height:51px}.inventory_quest_scroll_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:-392px -1653px;width:48px;height:51px}.inventory_quest_scroll_frog{background-image:url(spritesmith-main-5.png);background-position:-343px -1653px;width:48px;height:51px}.inventory_quest_scroll_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-294px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-245px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1_locked{background-image:url(spritesmith-main-5.png);background-position:-196px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-147px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2_locked{background-image:url(spritesmith-main-5.png);background-position:-98px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:-49px -1653px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3_locked{background-image:url(spritesmith-main-5.png);background-position:0 -1653px;width:48px;height:51px}.inventory_quest_scroll_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1536px -1022px;width:48px;height:51px}.inventory_quest_scroll_harpy{background-image:url(spritesmith-main-5.png);background-position:-1712px -1508px;width:48px;height:51px}.inventory_quest_scroll_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1712px 0;width:48px;height:51px}.inventory_quest_scroll_horse{background-image:url(spritesmith-main-5.png);background-position:-1712px -52px;width:48px;height:51px}.inventory_quest_scroll_kraken{background-image:url(spritesmith-main-5.png);background-position:-1712px -104px;width:48px;height:51px}.inventory_quest_scroll_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-1712px -156px;width:48px;height:51px}.inventory_quest_scroll_moonstone1_locked{background-image:url(spritesmith-main-5.png);background-position:-1712px -260px;width:48px;height:51px}.inventory_quest_scroll_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-1712px -312px;width:48px;height:51px}.inventory_quest_scroll_moonstone2_locked{background-image:url(spritesmith-main-5.png);background-position:-1712px -364px;width:48px;height:51px}.inventory_quest_scroll_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-1712px -416px;width:48px;height:51px}.inventory_quest_scroll_moonstone3_locked{background-image:url(spritesmith-main-5.png);background-position:-1712px -520px;width:48px;height:51px}.inventory_quest_scroll_octopus{background-image:url(spritesmith-main-5.png);background-position:-1712px -572px;width:48px;height:51px}.inventory_quest_scroll_owl{background-image:url(spritesmith-main-5.png);background-position:-1712px -624px;width:48px;height:51px}.inventory_quest_scroll_penguin{background-image:url(spritesmith-main-5.png);background-position:-1712px -676px;width:48px;height:51px}.inventory_quest_scroll_rat{background-image:url(spritesmith-main-5.png);background-position:-1712px -728px;width:48px;height:51px}.inventory_quest_scroll_rock{background-image:url(spritesmith-main-5.png);background-position:-1712px -832px;width:48px;height:51px}.inventory_quest_scroll_rooster{background-image:url(spritesmith-main-5.png);background-position:-1712px -884px;width:48px;height:51px}.inventory_quest_scroll_sheep{background-image:url(spritesmith-main-5.png);background-position:-1712px -936px;width:48px;height:51px}.inventory_quest_scroll_slime{background-image:url(spritesmith-main-5.png);background-position:-1536px -1074px;width:48px;height:51px}.inventory_quest_scroll_spider{background-image:url(spritesmith-main-5.png);background-position:-1536px -1126px;width:48px;height:51px}.inventory_quest_scroll_trex{background-image:url(spritesmith-main-5.png);background-position:-1536px -1178px;width:48px;height:51px}.inventory_quest_scroll_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-1536px -1230px;width:48px;height:51px}.inventory_quest_scroll_vice1{background-image:url(spritesmith-main-5.png);background-position:-1536px -1282px;width:48px;height:51px}.inventory_quest_scroll_vice1_locked{background-image:url(spritesmith-main-5.png);background-position:-1536px -1334px;width:48px;height:51px}.inventory_quest_scroll_vice2{background-image:url(spritesmith-main-5.png);background-position:-1536px -1386px;width:48px;height:51px}.inventory_quest_scroll_vice2_locked{background-image:url(spritesmith-main-5.png);background-position:-1536px -1438px;width:48px;height:51px}.inventory_quest_scroll_vice3{background-image:url(spritesmith-main-5.png);background-position:-1465px -1251px;width:48px;height:51px}.inventory_quest_scroll_vice3_locked{background-image:url(spritesmith-main-5.png);background-position:-1465px -1303px;width:48px;height:51px}.inventory_quest_scroll_whale{background-image:url(spritesmith-main-5.png);background-position:-1473px -1390px;width:48px;height:51px}.quest_TEMPLATE_FOR_MISSING_IMAGE{background-image:url(spritesmith-main-5.png);background-position:-666px -1522px;width:221px;height:39px}.quest_atom1{background-image:url(spritesmith-main-5.png);background-position:-719px -1070px;width:250px;height:150px}.quest_atom2{background-image:url(spritesmith-main-5.png);background-position:-1322px -537px;width:207px;height:138px}.quest_atom3{background-image:url(spritesmith-main-5.png);background-position:0 -1070px;width:216px;height:180px}.quest_basilist{background-image:url(spritesmith-main-5.png);background-position:-1322px -1093px;width:189px;height:141px}.quest_bunny{background-image:url(spritesmith-main-5.png);background-position:-1100px -618px;width:210px;height:186px}.quest_cheetah{background-image:url(spritesmith-main-5.png);background-position:-440px -452px;width:219px;height:219px}.quest_dilatory{background-image:url(spritesmith-main-5.png);background-position:-220px -452px;width:219px;height:219px}.quest_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-1100px -845px;width:221px;height:39px}.quest_dilatoryDistress1_blueFins{background-image:url(spritesmith-main-5.png);background-position:-1421px -1653px;width:51px;height:48px}.quest_dilatoryDistress1_fireCoral{background-image:url(spritesmith-main-5.png);background-position:-490px -1653px;width:48px;height:51px}.quest_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-970px -1070px;width:150px;height:150px}.quest_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-440px 0;width:219px;height:219px}.quest_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-880px -672px;width:219px;height:219px}.quest_egg{background-image:url(spritesmith-main-5.png);background-position:-222px -1522px;width:221px;height:39px}.quest_egg_plainEgg{background-image:url(spritesmith-main-5.png);background-position:-735px -1653px;width:48px;height:51px}.quest_evilsanta{background-image:url(spritesmith-main-5.png);background-position:0 -1390px;width:118px;height:131px}.quest_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:0 -232px;width:219px;height:219px}.quest_frog{background-image:url(spritesmith-main-5.png);background-position:-1100px 0;width:221px;height:213px}.quest_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-220px -232px;width:219px;height:219px}.quest_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-444px -1522px;width:221px;height:39px}.quest_goldenknight1_testimony{background-image:url(spritesmith-main-5.png);background-position:-1029px -1653px;width:48px;height:51px}.quest_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-468px -1070px;width:250px;height:150px}.quest_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:0 0;width:219px;height:231px}.quest_gryphon{background-image:url(spritesmith-main-5.png);background-position:-223px -892px;width:216px;height:177px}.quest_harpy{background-image:url(spritesmith-main-5.png);background-position:-660px -220px;width:219px;height:219px}.quest_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1100px -431px;width:219px;height:186px}.quest_horse{background-image:url(spritesmith-main-5.png);background-position:0 -452px;width:219px;height:219px}.quest_kraken{background-image:url(spritesmith-main-5.png);background-position:-440px -892px;width:216px;height:177px}.quest_moonstone1{background-image:url(spritesmith-main-5.png);background-position:0 -1522px;width:221px;height:39px}.quest_moonstone1_moonstone{background-image:url(spritesmith-main-5.png);background-position:-1761px -1725px;width:30px;height:30px}.quest_moonstone2{background-image:url(spritesmith-main-5.png);background-position:0 -672px;width:219px;height:219px}.quest_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-220px 0;width:219px;height:219px}.quest_octopus{background-image:url(spritesmith-main-5.png);background-position:0 -892px;width:222px;height:177px}.quest_owl{background-image:url(spritesmith-main-5.png);background-position:-660px -672px;width:219px;height:219px}.quest_penguin{background-image:url(spritesmith-main-5.png);background-position:-1322px -353px;width:190px;height:183px}.quest_rat{background-image:url(spritesmith-main-5.png);background-position:-660px 0;width:219px;height:219px}.quest_rock{background-image:url(spritesmith-main-5.png);background-position:-1100px -214px;width:216px;height:216px}.quest_rooster{background-image:url(spritesmith-main-5.png);background-position:-1322px 0;width:213px;height:174px}.quest_sheep{background-image:url(spritesmith-main-5.png);background-position:-440px -672px;width:219px;height:219px}.quest_slime{background-image:url(spritesmith-main-5.png);background-position:-220px -672px;width:219px;height:219px}.quest_spider{background-image:url(spritesmith-main-5.png);background-position:-217px -1070px;width:250px;height:150px}.quest_stressbeast{background-image:url(spritesmith-main-5.png);background-position:-880px -440px;width:219px;height:219px}.quest_stressbeast_bailey{background-image:url(spritesmith-main-5.png);background-position:-880px -220px;width:219px;height:219px}.quest_stressbeast_guide{background-image:url(spritesmith-main-5.png);background-position:-880px 0;width:219px;height:219px}.quest_stressbeast_stables{background-image:url(spritesmith-main-5.png);background-position:-660px -452px;width:219px;height:219px}.quest_trex{background-image:url(spritesmith-main-5.png);background-position:-1322px -175px;width:204px;height:177px}.quest_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-657px -892px;width:216px;height:177px}.quest_vice1{background-image:url(spritesmith-main-5.png);background-position:-1091px -892px;width:216px;height:177px}.quest_vice2{background-image:url(spritesmith-main-5.png);background-position:-1100px -805px;width:221px;height:39px}.quest_vice2_lightCrystal{background-image:url(spritesmith-main-5.png);background-position:-485px -1562px;width:40px;height:40px}.quest_vice3{background-image:url(spritesmith-main-5.png);background-position:-874px -892px;width:216px;height:177px}.quest_whale{background-image:url(spritesmith-main-5.png);background-position:-440px -232px;width:219px;height:219px}.shop_copper{background-image:url(spritesmith-main-5.png);background-position:-1585px -1149px;width:32px;height:22px}.shop_eyes{background-image:url(spritesmith-main-5.png);background-position:-1473px -1442px;width:40px;height:40px}.shop_gold{background-image:url(spritesmith-main-5.png);background-position:-1585px -1101px;width:32px;height:22px}.shop_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1761px -1684px;width:40px;height:40px}.shop_potion{background-image:url(spritesmith-main-5.png);background-position:-1761px -1643px;width:40px;height:40px}.shop_reroll{background-image:url(spritesmith-main-5.png);background-position:-1761px -1602px;width:40px;height:40px}.shop_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1588px -866px;width:32px;height:32px}.shop_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1588px -918px;width:32px;height:32px}.shop_silver{background-image:url(spritesmith-main-5.png);background-position:-1585px -1126px;width:32px;height:22px}.shop_snowball{background-image:url(spritesmith-main-5.png);background-position:-1588px -970px;width:32px;height:32px}.shop_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1585px -1022px;width:32px;height:32px}.Pet_Egg_BearCub{background-image:url(spritesmith-main-5.png);background-position:-98px -1705px;width:48px;height:51px}.Pet_Egg_Bunny{background-image:url(spritesmith-main-5.png);background-position:-147px -1705px;width:48px;height:51px}.Pet_Egg_Cactus{background-image:url(spritesmith-main-5.png);background-position:-196px -1705px;width:48px;height:51px}.Pet_Egg_Cheetah{background-image:url(spritesmith-main-5.png);background-position:-245px -1705px;width:48px;height:51px}.Pet_Egg_Cuttlefish{background-image:url(spritesmith-main-5.png);background-position:-294px -1705px;width:48px;height:51px}.Pet_Egg_Deer{background-image:url(spritesmith-main-5.png);background-position:-343px -1705px;width:48px;height:51px}.Pet_Egg_Dragon{background-image:url(spritesmith-main-5.png);background-position:-392px -1705px;width:48px;height:51px}.Pet_Egg_Egg{background-image:url(spritesmith-main-5.png);background-position:-441px -1705px;width:48px;height:51px}.Pet_Egg_FlyingPig{background-image:url(spritesmith-main-5.png);background-position:-490px -1705px;width:48px;height:51px}.Pet_Egg_Fox{background-image:url(spritesmith-main-5.png);background-position:-539px -1705px;width:48px;height:51px}.Pet_Egg_Frog{background-image:url(spritesmith-main-5.png);background-position:-588px -1705px;width:48px;height:51px}.Pet_Egg_Gryphon{background-image:url(spritesmith-main-5.png);background-position:-637px -1705px;width:48px;height:51px}.Pet_Egg_Hedgehog{background-image:url(spritesmith-main-5.png);background-position:-686px -1705px;width:48px;height:51px}.Pet_Egg_Horse{background-image:url(spritesmith-main-5.png);background-position:-735px -1705px;width:48px;height:51px}.Pet_Egg_LionCub{background-image:url(spritesmith-main-5.png);background-position:-784px -1705px;width:48px;height:51px}.Pet_Egg_Octopus{background-image:url(spritesmith-main-5.png);background-position:-833px -1705px;width:48px;height:51px}.Pet_Egg_Owl{background-image:url(spritesmith-main-5.png);background-position:-882px -1705px;width:48px;height:51px}.Pet_Egg_PandaCub{background-image:url(spritesmith-main-5.png);background-position:-931px -1705px;width:48px;height:51px}.Pet_Egg_Parrot{background-image:url(spritesmith-main-5.png);background-position:-980px -1705px;width:48px;height:51px}.Pet_Egg_Penguin{background-image:url(spritesmith-main-5.png);background-position:-1029px -1705px;width:48px;height:51px}.Pet_Egg_PolarBear{background-image:url(spritesmith-main-5.png);background-position:-1078px -1705px;width:48px;height:51px}.Pet_Egg_Rat{background-image:url(spritesmith-main-5.png);background-position:-1127px -1705px;width:48px;height:51px}.Pet_Egg_Rock{background-image:url(spritesmith-main-5.png);background-position:-1176px -1705px;width:48px;height:51px}.Pet_Egg_Rooster{background-image:url(spritesmith-main-5.png);background-position:-1225px -1705px;width:48px;height:51px}.Pet_Egg_Seahorse{background-image:url(spritesmith-main-5.png);background-position:-1274px -1705px;width:48px;height:51px}.Pet_Egg_Sheep{background-image:url(spritesmith-main-5.png);background-position:-1323px -1705px;width:48px;height:51px}.Pet_Egg_Slime{background-image:url(spritesmith-main-5.png);background-position:-1372px -1705px;width:48px;height:51px}.Pet_Egg_Spider{background-image:url(spritesmith-main-5.png);background-position:-1421px -1705px;width:48px;height:51px}.Pet_Egg_TRex{background-image:url(spritesmith-main-5.png);background-position:-1470px -1705px;width:48px;height:51px}.Pet_Egg_TigerCub{background-image:url(spritesmith-main-5.png);background-position:-1519px -1705px;width:48px;height:51px}.Pet_Egg_Whale{background-image:url(spritesmith-main-5.png);background-position:-1568px -1705px;width:48px;height:51px}.Pet_Egg_Wolf{background-image:url(spritesmith-main-5.png);background-position:-1617px -1705px;width:48px;height:51px}.Pet_Food_Cake_Base{background-image:url(spritesmith-main-5.png);background-position:-1761px -1307px;width:43px;height:43px}.Pet_Food_Cake_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1761px -1351px;width:42px;height:44px}.Pet_Food_Cake_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1761px -1126px;width:43px;height:45px}.Pet_Food_Cake_Desert{background-image:url(spritesmith-main-5.png);background-position:-1761px -1262px;width:43px;height:44px}.Pet_Food_Cake_Golden{background-image:url(spritesmith-main-5.png);background-position:-1761px -1396px;width:43px;height:42px}.Pet_Food_Cake_Red{background-image:url(spritesmith-main-5.png);background-position:-1761px -1172px;width:43px;height:44px}.Pet_Food_Cake_Shade{background-image:url(spritesmith-main-5.png);background-position:-1761px -1217px;width:43px;height:44px}.Pet_Food_Cake_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1761px -1033px;width:42px;height:47px}.Pet_Food_Cake_White{background-image:url(spritesmith-main-5.png);background-position:-1761px -1081px;width:44px;height:44px}.Pet_Food_Cake_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1761px -988px;width:45px;height:44px}.Pet_Food_Candy_Base{background-image:url(spritesmith-main-5.png);background-position:-1761px -468px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1761px -520px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1761px -572px;width:48px;height:51px}.Pet_Food_Candy_Desert{background-image:url(spritesmith-main-5.png);background-position:-1761px -624px;width:48px;height:51px}.Pet_Food_Candy_Golden{background-image:url(spritesmith-main-5.png);background-position:-1761px -676px;width:48px;height:51px}.Pet_Food_Candy_Red{background-image:url(spritesmith-main-5.png);background-position:-1761px -728px;width:48px;height:51px}.Pet_Food_Candy_Shade{background-image:url(spritesmith-main-5.png);background-position:-1761px -780px;width:48px;height:51px}.Pet_Food_Candy_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1761px -832px;width:48px;height:51px}.Pet_Food_Candy_White{background-image:url(spritesmith-main-5.png);background-position:-1761px -884px;width:48px;height:51px}.Pet_Food_Candy_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1761px -936px;width:48px;height:51px}.Pet_Food_Chocolate{background-image:url(spritesmith-main-5.png);background-position:-1761px -416px;width:48px;height:51px}.Pet_Food_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1761px -364px;width:48px;height:51px}.Pet_Food_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1761px -312px;width:48px;height:51px}.Pet_Food_Fish{background-image:url(spritesmith-main-5.png);background-position:-1761px -260px;width:48px;height:51px}.Pet_Food_Honey{background-image:url(spritesmith-main-5.png);background-position:-1761px -208px;width:48px;height:51px}.Pet_Food_Meat{background-image:url(spritesmith-main-5.png);background-position:-1761px -156px;width:48px;height:51px}.Pet_Food_Milk{background-image:url(spritesmith-main-5.png);background-position:-1761px -104px;width:48px;height:51px}.Pet_Food_Potatoe{background-image:url(spritesmith-main-5.png);background-position:-1761px -52px;width:48px;height:51px}.Pet_Food_RottenMeat{background-image:url(spritesmith-main-5.png);background-position:-1761px 0;width:48px;height:51px}.Pet_Food_Saddle{background-image:url(spritesmith-main-5.png);background-position:-1666px -1705px;width:48px;height:51px}.Pet_Food_Strawberry{background-image:url(spritesmith-main-5.png);background-position:-49px -1705px;width:48px;height:51px}.Mount_Body_BearCub-Base{background-image:url(spritesmith-main-5.png);background-position:-649px -1390px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-543px -1390px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-437px -1390px;width:105px;height:105px}.Mount_Body_BearCub-Desert{background-image:url(spritesmith-main-5.png);background-position:-331px -1390px;width:105px;height:105px}.Mount_Body_BearCub-Golden{background-image:url(spritesmith-main-5.png);background-position:-119px -1390px;width:105px;height:105px}.Mount_Body_BearCub-Polar{background-image:url(spritesmith-main-5.png);background-position:-1359px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Red{background-image:url(spritesmith-main-5.png);background-position:-1253px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Shade{background-image:url(spritesmith-main-5.png);background-position:-1147px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1041px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Spooky{background-image:url(spritesmith-main-5.png);background-position:-935px -1251px;width:105px;height:105px}.Mount_Body_BearCub-White{background-image:url(spritesmith-main-5.png);background-position:-829px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Zombie{background-image:url(spritesmith-main-5.png);background-position:-723px -1251px;width:105px;height:105px}.Mount_Body_Bunny-Base{background-image:url(spritesmith-main-5.png);background-position:-967px -1390px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-861px -1390px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-755px -1390px;width:105px;height:105px}.Mount_Body_Bunny-Desert{background-image:url(spritesmith-main-5.png);background-position:-617px -1251px;width:105px;height:105px}.Mount_Body_Bunny-Golden{background-image:url(spritesmith-main-5.png);background-position:-511px -1251px;width:105px;height:105px}.Mount_Body_Bunny-Red{background-image:url(spritesmith-main-5.png);background-position:-405px -1251px;width:105px;height:105px}.Mount_Body_Bunny-Shade{background-image:url(spritesmith-main-5.png);background-position:-225px -1390px;width:105px;height:105px}.Mount_Body_Bunny-Skeleton{background-image:url(spritesmith-main-5.png);background-position:-299px -1251px;width:105px;height:105px}.Mount_Body_Bunny-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -1060px;width:105px;height:105px}.Mount_Body_Bunny-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -1105px;width:105px;height:105px}.Mount_Body_Cactus-Base{background-image:url(spritesmith-main-6.png);background-position:-530px -221px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -327px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-221px -469px;width:105px;height:105px}.Mount_Body_Cactus-Desert{background-image:url(spritesmith-main-6.png);background-position:-327px -469px;width:105px;height:105px}.Mount_Body_Cactus-Golden{background-image:url(spritesmith-main-6.png);background-position:-433px -469px;width:105px;height:105px}.Mount_Body_Cactus-Red{background-image:url(spritesmith-main-6.png);background-position:-636px 0;width:105px;height:105px}.Mount_Body_Cactus-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -106px;width:105px;height:105px}.Mount_Body_Cactus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -212px;width:105px;height:105px}.Mount_Body_Cactus-Spooky{background-image:url(spritesmith-main-6.png);background-position:-636px -318px;width:105px;height:105px}.Mount_Body_Cactus-White{background-image:url(spritesmith-main-6.png);background-position:-742px -893px;width:105px;height:105px}.Mount_Body_Cactus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-848px -893px;width:105px;height:105px}.Mount_Body_Cheetah-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -893px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px 0;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px -106px;width:105px;height:105px}.Mount_Body_Cheetah-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -212px;width:105px;height:105px}.Mount_Body_Cheetah-Golden{background-image:url(spritesmith-main-6.png);background-position:-1060px -318px;width:105px;height:105px}.Mount_Body_Cheetah-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px -424px;width:105px;height:105px}.Mount_Body_Cheetah-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -530px;width:105px;height:105px}.Mount_Body_Cheetah-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1060px -636px;width:105px;height:105px}.Mount_Body_Cheetah-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -530px;width:105px;height:105px}.Mount_Body_Cheetah-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -954px;width:105px;height:105px}.Mount_Body_Cuttlefish-Base{background-image:url(spritesmith-main-6.png);background-position:-530px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -115px;width:105px;height:114px}.Mount_Body_Cuttlefish-White{background-image:url(spritesmith-main-6.png);background-position:0 -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -239px;width:105px;height:114px}.Mount_Body_Deer-Base{background-image:url(spritesmith-main-6.png);background-position:-636px -424px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:0 -575px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-106px -575px;width:105px;height:105px}.Mount_Body_Deer-Desert{background-image:url(spritesmith-main-6.png);background-position:-212px -575px;width:105px;height:105px}.Mount_Body_Deer-Golden{background-image:url(spritesmith-main-6.png);background-position:-318px -575px;width:105px;height:105px}.Mount_Body_Deer-Red{background-image:url(spritesmith-main-6.png);background-position:-424px -575px;width:105px;height:105px}.Mount_Body_Deer-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -575px;width:105px;height:105px}.Mount_Body_Deer-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -575px;width:105px;height:105px}.Mount_Body_Deer-White{background-image:url(spritesmith-main-6.png);background-position:-742px 0;width:105px;height:105px}.Mount_Body_Deer-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -106px;width:105px;height:105px}.Mount_Body_Dragon-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -212px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-742px -318px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-742px -424px;width:105px;height:105px}.Mount_Body_Dragon-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -530px;width:105px;height:105px}.Mount_Body_Dragon-Golden{background-image:url(spritesmith-main-6.png);background-position:0 -681px;width:105px;height:105px}.Mount_Body_Dragon-Red{background-image:url(spritesmith-main-6.png);background-position:-106px -681px;width:105px;height:105px}.Mount_Body_Dragon-Shade{background-image:url(spritesmith-main-6.png);background-position:-212px -681px;width:105px;height:105px}.Mount_Body_Dragon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -681px;width:105px;height:105px}.Mount_Body_Dragon-Spooky{background-image:url(spritesmith-main-6.png);background-position:-424px -681px;width:105px;height:105px}.Mount_Body_Dragon-White{background-image:url(spritesmith-main-6.png);background-position:-530px -681px;width:105px;height:105px}.Mount_Body_Dragon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-636px -681px;width:105px;height:105px}.Mount_Body_Egg-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -681px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px 0;width:105px;height:105px}.Mount_Body_Egg-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-848px -106px;width:105px;height:105px}.Mount_Body_Egg-Desert{background-image:url(spritesmith-main-6.png);background-position:-848px -212px;width:105px;height:105px}.Mount_Body_Egg-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -318px;width:105px;height:105px}.Mount_Body_Egg-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -424px;width:105px;height:105px}.Mount_Body_Egg-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -530px;width:105px;height:105px}.Mount_Body_Egg-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-848px -636px;width:105px;height:105px}.Mount_Body_Egg-White{background-image:url(spritesmith-main-6.png);background-position:0 -787px;width:105px;height:105px}.Mount_Body_Egg-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -787px;width:105px;height:105px}.Mount_Body_FlyingPig-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px 0;width:105px;height:105px}.Mount_Body_FlyingPig-Spooky{background-image:url(spritesmith-main-6.png);background-position:-954px -106px;width:105px;height:105px}.Mount_Body_FlyingPig-White{background-image:url(spritesmith-main-6.png);background-position:-954px -212px;width:105px;height:105px}.Mount_Body_FlyingPig-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -318px;width:105px;height:105px}.Mount_Body_Fox-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -424px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -530px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -636px;width:105px;height:105px}.Mount_Body_Fox-Desert{background-image:url(spritesmith-main-6.png);background-position:-954px -742px;width:105px;height:105px}.Mount_Body_Fox-Golden{background-image:url(spritesmith-main-6.png);background-position:0 -893px;width:105px;height:105px}.Mount_Body_Fox-Red{background-image:url(spritesmith-main-6.png);background-position:-106px -893px;width:105px;height:105px}.Mount_Body_Fox-Shade{background-image:url(spritesmith-main-6.png);background-position:-212px -893px;width:105px;height:105px}.Mount_Body_Fox-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -893px;width:105px;height:105px}.Mount_Body_Fox-Spooky{background-image:url(spritesmith-main-6.png);background-position:-424px -893px;width:105px;height:105px}.Mount_Body_Fox-White{background-image:url(spritesmith-main-6.png);background-position:-530px -893px;width:105px;height:105px}.Mount_Body_Fox-Zombie{background-image:url(spritesmith-main-6.png);background-position:-636px -893px;width:105px;height:105px}.Mount_Body_Frog-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -239px;width:105px;height:114px}.Mount_Body_Frog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px 0;width:105px;height:114px}.Mount_Body_Frog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px 0;width:105px;height:114px}.Mount_Body_Frog-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -115px;width:105px;height:114px}.Mount_Body_Frog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -230px;width:105px;height:114px}.Mount_Body_Frog-Red{background-image:url(spritesmith-main-6.png);background-position:0 -354px;width:105px;height:114px}.Mount_Body_Frog-Shade{background-image:url(spritesmith-main-6.png);background-position:-106px -354px;width:105px;height:114px}.Mount_Body_Frog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-212px -354px;width:105px;height:114px}.Mount_Body_Frog-White{background-image:url(spritesmith-main-6.png);background-position:-318px -354px;width:105px;height:114px}.Mount_Body_Frog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-424px -354px;width:105px;height:114px}.Mount_Body_Gryphon-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -742px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px -848px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -999px;width:105px;height:105px}.Mount_Body_Gryphon-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -999px;width:105px;height:105px}.Mount_Body_Gryphon-RoyalPurple{background-image:url(spritesmith-main-6.png);background-position:-424px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -999px;width:105px;height:105px}.Mount_Body_Gryphon-White{background-image:url(spritesmith-main-6.png);background-position:-742px -999px;width:105px;height:105px}.Mount_Body_Gryphon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-848px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px -999px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px 0;width:105px;height:105px}.Mount_Body_Hedgehog-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -106px;width:105px;height:105px}.Mount_Body_Hedgehog-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -212px;width:105px;height:105px}.Mount_Body_Hedgehog-Red{background-image:url(spritesmith-main-6.png);background-position:-1166px -318px;width:105px;height:105px}.Mount_Body_Hedgehog-Shade{background-image:url(spritesmith-main-6.png);background-position:-1166px -424px;width:105px;height:105px}.Mount_Body_Hedgehog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px -530px;width:105px;height:105px}.Mount_Body_Hedgehog-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -636px;width:105px;height:105px}.Mount_Body_Hedgehog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -742px;width:105px;height:105px}.Mount_Body_Horse-Base{background-image:url(spritesmith-main-6.png);background-position:-1166px -848px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px -954px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -1105px;width:105px;height:105px}.Mount_Body_Horse-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -1105px;width:105px;height:105px}.Mount_Body_Horse-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -1105px;width:105px;height:105px}.Mount_Body_Horse-Red{background-image:url(spritesmith-main-6.png);background-position:-530px -115px;width:105px;height:105px}.Mount_Body_Horse-Shade{background-image:url(spritesmith-main-6.png);background-position:-424px -1105px;width:105px;height:105px}.Mount_Body_Horse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -1105px;width:105px;height:105px}.Mount_Body_Horse-White{background-image:url(spritesmith-main-6.png);background-position:-636px -1105px;width:105px;height:105px}.Mount_Body_Horse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -1105px;width:105px;height:105px}.Mount_Body_JackOLantern-Base{background-image:url(spritesmith-main-6.png);background-position:-1696px -530px;width:90px;height:105px}.Mount_Body_LionCub-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -1105px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px -1105px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px -1105px;width:105px;height:105px}.Mount_Body_LionCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-1272px 0;width:105px;height:105px}.Mount_Body_LionCub-Ethereal{background-image:url(spritesmith-main-6.png);background-position:-1272px -106px;width:105px;height:105px}.Mount_Body_LionCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -212px;width:105px;height:105px}.Mount_Body_LionCub-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -318px;width:105px;height:105px}.Mount_Body_LionCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -424px;width:105px;height:105px}.Mount_Body_LionCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -469px;width:111px;height:105px}.Mount_Body_LionCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1272px -636px;width:105px;height:105px}.Mount_Body_LionCub-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -742px;width:105px;height:105px}.Mount_Body_LionCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -848px;width:105px;height:105px}.Mount_Body_Mammoth-Base{background-image:url(spritesmith-main-6.png);background-position:0 0;width:105px;height:123px}.Mount_Body_MantisShrimp-Base{background-image:url(spritesmith-main-6.png);background-position:-112px -469px;width:108px;height:105px}.Mount_Body_Octopus-Base{background-image:url(spritesmith-main-6.png);background-position:0 -1211px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px -1211px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Desert{background-image:url(spritesmith-main-6.png);background-position:-318px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Red{background-image:url(spritesmith-main-6.png);background-position:-530px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-742px -1211px;width:105px;height:105px}.Mount_Body_Octopus-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1211px;width:105px;height:105px}.Mount_Body_Octopus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -1211px;width:105px;height:105px}.Mount_Body_Orca-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -1211px;width:105px;height:105px}.Mount_Body_Owl-Base{background-image:url(spritesmith-main-6.png);background-position:-1166px -1211px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -1211px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px 0;width:105px;height:105px}.Mount_Body_Owl-Desert{background-image:url(spritesmith-main-6.png);background-position:-1378px -106px;width:105px;height:105px}.Mount_Body_Owl-Golden{background-image:url(spritesmith-main-6.png);background-position:-1378px -212px;width:105px;height:105px}.Mount_Body_Owl-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -318px;width:105px;height:105px}.Mount_Body_Owl-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -424px;width:105px;height:105px}.Mount_Body_Owl-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1378px -530px;width:105px;height:105px}.Mount_Body_Owl-White{background-image:url(spritesmith-main-6.png);background-position:-1378px -636px;width:105px;height:105px}.Mount_Body_Owl-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -742px;width:105px;height:105px}.Mount_Body_PandaCub-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -848px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -954px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -1060px;width:105px;height:105px}.Mount_Body_PandaCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-1378px -1166px;width:105px;height:105px}.Mount_Body_PandaCub-Golden{background-image:url(spritesmith-main-6.png);background-position:0 -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Red{background-image:url(spritesmith-main-6.png);background-position:-106px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-212px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-424px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-White{background-image:url(spritesmith-main-6.png);background-position:-530px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-636px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -1317px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -1317px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -1317px;width:105px;height:105px}.Mount_Body_Parrot-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px 0;width:105px;height:105px}.Mount_Body_Parrot-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -106px;width:105px;height:105px}.Mount_Body_Parrot-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -212px;width:105px;height:105px}.Mount_Body_Penguin-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -318px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -424px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px -530px;width:105px;height:105px}.Mount_Body_Penguin-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -636px;width:105px;height:105px}.Mount_Body_Penguin-Golden{background-image:url(spritesmith-main-6.png);background-position:-1484px -742px;width:105px;height:105px}.Mount_Body_Penguin-Red{background-image:url(spritesmith-main-6.png);background-position:-1484px -848px;width:105px;height:105px}.Mount_Body_Penguin-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px -954px;width:105px;height:105px}.Mount_Body_Penguin-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -1060px;width:105px;height:105px}.Mount_Body_Penguin-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -1166px;width:105px;height:105px}.Mount_Body_Penguin-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -1272px;width:105px;height:105px}.Mount_Body_Phoenix-Base{background-image:url(spritesmith-main-6.png);background-position:0 -1423px;width:105px;height:105px}.Mount_Body_Rat-Base{background-image:url(spritesmith-main-6.png);background-position:-106px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-212px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-318px -1423px;width:105px;height:105px}.Mount_Body_Rat-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -1423px;width:105px;height:105px}.Mount_Body_Rat-Golden{background-image:url(spritesmith-main-6.png);background-position:-530px -1423px;width:105px;height:105px}.Mount_Body_Rat-Red{background-image:url(spritesmith-main-6.png);background-position:-636px -1423px;width:105px;height:105px}.Mount_Body_Rat-Shade{background-image:url(spritesmith-main-6.png);background-position:-742px -1423px;width:105px;height:105px}.Mount_Body_Rat-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-848px -1423px;width:105px;height:105px}.Mount_Body_Rat-White{background-image:url(spritesmith-main-6.png);background-position:-954px -1423px;width:105px;height:105px}.Mount_Body_Rat-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1060px -1423px;width:105px;height:105px}.Mount_Body_Rock-Base{background-image:url(spritesmith-main-6.png);background-position:-1166px -1423px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -1423px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -1423px;width:105px;height:105px}.Mount_Body_Rock-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -1423px;width:105px;height:105px}.Mount_Body_Rock-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px 0;width:105px;height:105px}.Mount_Body_Rock-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -106px;width:105px;height:105px}.Mount_Body_Rock-Shade{background-image:url(spritesmith-main-6.png);background-position:-1590px -212px;width:105px;height:105px}.Mount_Body_Rock-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1590px -318px;width:105px;height:105px}.Mount_Body_Rock-White{background-image:url(spritesmith-main-6.png);background-position:-1590px -424px;width:105px;height:105px}.Mount_Body_Rock-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1590px -530px;width:105px;height:105px}.Mount_Body_Rooster-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px -636px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -742px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px -848px;width:105px;height:105px}.Mount_Body_Rooster-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -954px;width:105px;height:105px}.Mount_Body_Rooster-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -1060px;width:105px;height:105px}.Mount_Body_Rooster-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -1166px;width:105px;height:105px}.Mount_Body_Rooster-Shade{background-image:url(spritesmith-main-6.png);background-position:-1590px -1272px;width:105px;height:105px}.Mount_Body_Rooster-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1590px -1378px;width:105px;height:105px}.Mount_Body_Rooster-White{background-image:url(spritesmith-main-6.png);background-position:0 -1529px;width:105px;height:105px}.Mount_Body_Rooster-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1529px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -1529px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -1529px;width:105px;height:105px}.Mount_Body_Sheep-Golden{background-image:url(spritesmith-main-6.png);background-position:-1696px 0;width:105px;height:105px}.Mount_Body_Sheep-Red{background-image:url(spritesmith-main-6.png);background-position:-1696px -106px;width:105px;height:105px}.Mount_Body_Sheep-Shade{background-image:url(spritesmith-main-6.png);background-position:-1696px -212px;width:105px;height:105px}.Mount_Body_Sheep-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1696px -318px;width:105px;height:105px}.Mount_Body_Sheep-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1105px;width:105px;height:105px}.Mount_Body_Sheep-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1696px -424px;width:105px;height:105px}.Mount_Body_Slime-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -1113px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Body_Slime-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Body_Slime-Golden{background-image:url(spritesmith-main-7.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Body_Slime-Red{background-image:url(spritesmith-main-7.png);background-position:0 -1113px;width:105px;height:105px}.Mount_Body_Slime-Shade{background-image:url(spritesmith-main-7.png);background-position:-106px -1113px;width:105px;height:105px}.Mount_Body_Slime-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-212px -1113px;width:105px;height:105px}.Mount_Body_Slime-White{background-image:url(spritesmith-main-7.png);background-position:-318px -1113px;width:105px;height:105px}.Mount_Body_Slime-Zombie{background-image:url(spritesmith-main-7.png);background-position:-424px -1113px;width:105px;height:105px}.Mount_Body_Spider-Base{background-image:url(spritesmith-main-7.png);background-position:-530px -1113px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-106px -795px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-212px -795px;width:105px;height:105px}.Mount_Body_Spider-Desert{background-image:url(spritesmith-main-7.png);background-position:-318px -795px;width:105px;height:105px}.Mount_Body_Spider-Golden{background-image:url(spritesmith-main-7.png);background-position:-424px -795px;width:105px;height:105px}.Mount_Body_Spider-Red{background-image:url(spritesmith-main-7.png);background-position:-530px -795px;width:105px;height:105px}.Mount_Body_Spider-Shade{background-image:url(spritesmith-main-7.png);background-position:-636px -795px;width:105px;height:105px}.Mount_Body_Spider-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-742px -795px;width:105px;height:105px}.Mount_Body_Spider-White{background-image:url(spritesmith-main-7.png);background-position:-848px -795px;width:105px;height:105px}.Mount_Body_Spider-Zombie{background-image:url(spritesmith-main-7.png);background-position:-998px 0;width:105px;height:105px}.Mount_Body_TRex-Base{background-image:url(spritesmith-main-7.png);background-position:-136px 0;width:135px;height:135px}.Mount_Body_TRex-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 -544px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Body_TRex-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -136px;width:135px;height:135px}.Mount_Body_TRex-Golden{background-image:url(spritesmith-main-7.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Body_TRex-Red{background-image:url(spritesmith-main-7.png);background-position:-272px 0;width:135px;height:135px}.Mount_Body_TRex-Shade{background-image:url(spritesmith-main-7.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Body_TRex-Skeleton{background-image:url(spritesmith-main-7.png);background-position:0 -272px;width:135px;height:135px}.Mount_Body_TRex-White{background-image:url(spritesmith-main-7.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Body_TRex-Zombie{background-image:url(spritesmith-main-7.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Body_TigerCub-Base{background-image:url(spritesmith-main-7.png);background-position:-636px -1113px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-742px -1113px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1378px -1325px;width:105px;height:105px}.Mount_Body_TigerCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Body_TigerCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Body_TigerCub-Red{background-image:url(spritesmith-main-7.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Body_TigerCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Body_TigerCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1528px -424px;width:105px;height:105px}.Mount_Body_TigerCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1528px -530px;width:105px;height:105px}.Mount_Body_TigerCub-White{background-image:url(spritesmith-main-7.png);background-position:-1528px -636px;width:105px;height:105px}.Mount_Body_TigerCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1528px -742px;width:105px;height:105px}.Mount_Body_Turkey-Base{background-image:url(spritesmith-main-7.png);background-position:-1528px -848px;width:105px;height:105px}.Mount_Body_Whale-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -1537px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1537px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1272px -1537px;width:105px;height:105px}.Mount_Body_Whale-Desert{background-image:url(spritesmith-main-7.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Body_Whale-Golden{background-image:url(spritesmith-main-7.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Body_Whale-Red{background-image:url(spritesmith-main-7.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Body_Whale-Shade{background-image:url(spritesmith-main-7.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Body_Whale-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Body_Whale-White{background-image:url(spritesmith-main-7.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Body_Whale-Zombie{background-image:url(spritesmith-main-7.png);background-position:0 -795px;width:105px;height:105px}.Mount_Body_Wolf-Base{background-image:url(spritesmith-main-7.png);background-position:-408px 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Body_Wolf-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -408px;width:135px;height:135px}.Mount_Body_Wolf-Golden{background-image:url(spritesmith-main-7.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Body_Wolf-Red{background-image:url(spritesmith-main-7.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Body_Wolf-Shade{background-image:url(spritesmith-main-7.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Body_Wolf-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-544px 0;width:135px;height:135px}.Mount_Body_Wolf-Spooky{background-image:url(spritesmith-main-7.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Body_Wolf-White{background-image:url(spritesmith-main-7.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Body_Wolf-Zombie{background-image:url(spritesmith-main-7.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_BearCub-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_BearCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_BearCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_BearCub-Polar{background-image:url(spritesmith-main-7.png);background-position:0 -901px;width:105px;height:105px}.Mount_Head_BearCub-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -901px;width:105px;height:105px}.Mount_Head_BearCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -901px;width:105px;height:105px}.Mount_Head_BearCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -901px;width:105px;height:105px}.Mount_Head_BearCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-424px -901px;width:105px;height:105px}.Mount_Head_BearCub-White{background-image:url(spritesmith-main-7.png);background-position:-530px -901px;width:105px;height:105px}.Mount_Head_BearCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -901px;width:105px;height:105px}.Mount_Head_Bunny-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -901px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -901px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -901px;width:105px;height:105px}.Mount_Head_Bunny-Desert{background-image:url(spritesmith-main-7.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Bunny-Golden{background-image:url(spritesmith-main-7.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Bunny-Red{background-image:url(spritesmith-main-7.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Bunny-Shade{background-image:url(spritesmith-main-7.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Bunny-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Bunny-White{background-image:url(spritesmith-main-7.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Bunny-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Cactus-Base{background-image:url(spritesmith-main-7.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -1007px;width:105px;height:105px}.Mount_Head_Cactus-Desert{background-image:url(spritesmith-main-7.png);background-position:-106px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Golden{background-image:url(spritesmith-main-7.png);background-position:-212px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Red{background-image:url(spritesmith-main-7.png);background-position:-318px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Shade{background-image:url(spritesmith-main-7.png);background-position:-424px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-530px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Spooky{background-image:url(spritesmith-main-7.png);background-position:-636px -1007px;width:105px;height:105px}.Mount_Head_Cactus-White{background-image:url(spritesmith-main-7.png);background-position:-742px -1007px;width:105px;height:105px}.Mount_Head_Cactus-Zombie{background-image:url(spritesmith-main-7.png);background-position:-848px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-Base{background-image:url(spritesmith-main-7.png);background-position:-954px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1060px -1007px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Cheetah-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Cheetah-Golden{background-image:url(spritesmith-main-7.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Cheetah-Red{background-image:url(spritesmith-main-7.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Cheetah-Shade{background-image:url(spritesmith-main-7.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Cheetah-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Cheetah-White{background-image:url(spritesmith-main-7.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Cheetah-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Cuttlefish-Base{background-image:url(spritesmith-main-7.png);background-position:-530px -680px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-348px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Desert{background-image:url(spritesmith-main-7.png);background-position:-454px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Golden{background-image:url(spritesmith-main-7.png);background-position:-560px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Red{background-image:url(spritesmith-main-7.png);background-position:-680px 0;width:105px;height:114px}.Mount_Head_Cuttlefish-Shade{background-image:url(spritesmith-main-7.png);background-position:-680px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-680px -230px;width:105px;height:114px}.Mount_Head_Cuttlefish-White{background-image:url(spritesmith-main-7.png);background-position:-680px -345px;width:105px;height:114px}.Mount_Head_Cuttlefish-Zombie{background-image:url(spritesmith-main-7.png);background-position:-680px -460px;width:105px;height:114px}.Mount_Head_Deer-Base{background-image:url(spritesmith-main-7.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Deer-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-954px -1113px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1060px -1113px;width:105px;height:105px}.Mount_Head_Deer-Desert{background-image:url(spritesmith-main-7.png);background-position:-1166px -1113px;width:105px;height:105px}.Mount_Head_Deer-Golden{background-image:url(spritesmith-main-7.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Deer-Red{background-image:url(spritesmith-main-7.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Deer-Shade{background-image:url(spritesmith-main-7.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Deer-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Deer-White{background-image:url(spritesmith-main-7.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Deer-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Dragon-Base{background-image:url(spritesmith-main-7.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Dragon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Dragon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Dragon-Red{background-image:url(spritesmith-main-7.png);background-position:0 -1219px;width:105px;height:105px}.Mount_Head_Dragon-Shade{background-image:url(spritesmith-main-7.png);background-position:-106px -1219px;width:105px;height:105px}.Mount_Head_Dragon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-212px -1219px;width:105px;height:105px}.Mount_Head_Dragon-Spooky{background-image:url(spritesmith-main-7.png);background-position:-318px -1219px;width:105px;height:105px}.Mount_Head_Dragon-White{background-image:url(spritesmith-main-7.png);background-position:-424px -1219px;width:105px;height:105px}.Mount_Head_Dragon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-530px -1219px;width:105px;height:105px}.Mount_Head_Egg-Base{background-image:url(spritesmith-main-7.png);background-position:-636px -1219px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-742px -1219px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-848px -1219px;width:105px;height:105px}.Mount_Head_Egg-Desert{background-image:url(spritesmith-main-7.png);background-position:-954px -1219px;width:105px;height:105px}.Mount_Head_Egg-Golden{background-image:url(spritesmith-main-7.png);background-position:-1060px -1219px;width:105px;height:105px}.Mount_Head_Egg-Red{background-image:url(spritesmith-main-7.png);background-position:-1166px -1219px;width:105px;height:105px}.Mount_Head_Egg-Shade{background-image:url(spritesmith-main-7.png);background-position:-1272px -1219px;width:105px;height:105px}.Mount_Head_Egg-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_Egg-White{background-image:url(spritesmith-main-7.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_Egg-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_FlyingPig-Base{background-image:url(spritesmith-main-7.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_FlyingPig-Desert{background-image:url(spritesmith-main-7.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_FlyingPig-Golden{background-image:url(spritesmith-main-7.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_FlyingPig-Red{background-image:url(spritesmith-main-7.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_FlyingPig-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_FlyingPig-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_FlyingPig-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_FlyingPig-White{background-image:url(spritesmith-main-7.png);background-position:0 -1325px;width:105px;height:105px}.Mount_Head_FlyingPig-Zombie{background-image:url(spritesmith-main-7.png);background-position:-106px -1325px;width:105px;height:105px}.Mount_Head_Fox-Base{background-image:url(spritesmith-main-7.png);background-position:-212px -1325px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-318px -1325px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-424px -1325px;width:105px;height:105px}.Mount_Head_Fox-Desert{background-image:url(spritesmith-main-7.png);background-position:-530px -1325px;width:105px;height:105px}.Mount_Head_Fox-Golden{background-image:url(spritesmith-main-7.png);background-position:-636px -1325px;width:105px;height:105px}.Mount_Head_Fox-Red{background-image:url(spritesmith-main-7.png);background-position:-742px -1325px;width:105px;height:105px}.Mount_Head_Fox-Shade{background-image:url(spritesmith-main-7.png);background-position:-848px -1325px;width:105px;height:105px}.Mount_Head_Fox-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-954px -1325px;width:105px;height:105px}.Mount_Head_Fox-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1060px -1325px;width:105px;height:105px}.Mount_Head_Fox-White{background-image:url(spritesmith-main-7.png);background-position:-1166px -1325px;width:105px;height:105px}.Mount_Head_Fox-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1272px -1325px;width:105px;height:105px}.Mount_Head_Frog-Base{background-image:url(spritesmith-main-7.png);background-position:-786px 0;width:105px;height:114px}.Mount_Head_Frog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-242px -544px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -230px;width:105px;height:114px}.Mount_Head_Frog-Desert{background-image:url(spritesmith-main-7.png);background-position:-786px -345px;width:105px;height:114px}.Mount_Head_Frog-Golden{background-image:url(spritesmith-main-7.png);background-position:-786px -460px;width:105px;height:114px}.Mount_Head_Frog-Red{background-image:url(spritesmith-main-7.png);background-position:0 -680px;width:105px;height:114px}.Mount_Head_Frog-Shade{background-image:url(spritesmith-main-7.png);background-position:-106px -680px;width:105px;height:114px}.Mount_Head_Frog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-212px -680px;width:105px;height:114px}.Mount_Head_Frog-White{background-image:url(spritesmith-main-7.png);background-position:-318px -680px;width:105px;height:114px}.Mount_Head_Frog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-424px -680px;width:105px;height:114px}.Mount_Head_Gryphon-Base{background-image:url(spritesmith-main-7.png);background-position:-1528px -954px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px -1060px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -1166px;width:105px;height:105px}.Mount_Head_Gryphon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px -1272px;width:105px;height:105px}.Mount_Head_Gryphon-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -1431px;width:105px;height:105px}.Mount_Head_Gryphon-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -1431px;width:105px;height:105px}.Mount_Head_Gryphon-RoyalPurple{background-image:url(spritesmith-main-7.png);background-position:-212px -1431px;width:105px;height:105px}.Mount_Head_Gryphon-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1431px;width:105px;height:105px}.Mount_Head_Gryphon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1431px;width:105px;height:105px}.Mount_Head_Gryphon-White{background-image:url(spritesmith-main-7.png);background-position:-530px -1431px;width:105px;height:105px}.Mount_Head_Gryphon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Desert{background-image:url(spritesmith-main-7.png);background-position:-1060px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Golden{background-image:url(spritesmith-main-7.png);background-position:-1166px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Red{background-image:url(spritesmith-main-7.png);background-position:-1272px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Shade{background-image:url(spritesmith-main-7.png);background-position:-1378px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1484px -1431px;width:105px;height:105px}.Mount_Head_Hedgehog-White{background-image:url(spritesmith-main-7.png);background-position:-1634px 0;width:105px;height:105px}.Mount_Head_Hedgehog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -106px;width:105px;height:105px}.Mount_Head_Horse-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -212px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1634px -318px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1634px -424px;width:105px;height:105px}.Mount_Head_Horse-Desert{background-image:url(spritesmith-main-7.png);background-position:-1634px -530px;width:105px;height:105px}.Mount_Head_Horse-Golden{background-image:url(spritesmith-main-7.png);background-position:-1634px -636px;width:105px;height:105px}.Mount_Head_Horse-Red{background-image:url(spritesmith-main-7.png);background-position:-1634px -742px;width:105px;height:105px}.Mount_Head_Horse-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -848px;width:105px;height:105px}.Mount_Head_Horse-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px -954px;width:105px;height:105px}.Mount_Head_Horse-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -1060px;width:105px;height:105px}.Mount_Head_Horse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -1166px;width:105px;height:105px}.Mount_Head_JackOLantern-Base{background-image:url(spritesmith-main-7.png);background-position:-1740px -424px;width:90px;height:105px}.Mount_Head_LionCub-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -1378px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 -1537px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-106px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-212px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Ethereal{background-image:url(spritesmith-main-7.png);background-position:-318px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-424px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Red{background-image:url(spritesmith-main-7.png);background-position:-530px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-636px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -680px;width:105px;height:110px}.Mount_Head_LionCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-848px -1537px;width:105px;height:105px}.Mount_Head_LionCub-White{background-image:url(spritesmith-main-7.png);background-position:-954px -1537px;width:105px;height:105px}.Mount_Head_LionCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1060px -1537px;width:105px;height:105px}.Mount_Head_Mammoth-Base{background-image:url(spritesmith-main-7.png);background-position:-136px -544px;width:105px;height:123px}.Mount_Head_MantisShrimp-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -680px;width:108px;height:105px}.Mount_Head_Octopus-Base{background-image:url(spritesmith-main-7.png);background-position:-1378px -1537px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1484px -1537px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1590px -1537px;width:105px;height:105px}.Mount_Head_Octopus-Desert{background-image:url(spritesmith-main-7.png);background-position:-1740px 0;width:105px;height:105px}.Mount_Head_Octopus-Golden{background-image:url(spritesmith-main-7.png);background-position:-1740px -106px;width:105px;height:105px}.Mount_Head_Octopus-Red{background-image:url(spritesmith-main-7.png);background-position:-1740px -212px;width:105px;height:105px}.Mount_Head_Octopus-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -1272px;width:105px;height:105px}.Mount_Head_Octopus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1740px -318px;width:105px;height:105px}.Mount_Head_Octopus-White{background-image:url(spritesmith-main-8.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Octopus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-848px -1210px;width:105px;height:105px}.Mount_Head_Orca-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Owl-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Owl-Desert{background-image:url(spritesmith-main-8.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Owl-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px -1060px;width:105px;height:105px}.Mount_Head_Owl-Red{background-image:url(spritesmith-main-8.png);background-position:0 -1210px;width:105px;height:105px}.Mount_Head_Owl-Shade{background-image:url(spritesmith-main-8.png);background-position:-106px -1210px;width:105px;height:105px}.Mount_Head_Owl-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-212px -1210px;width:105px;height:105px}.Mount_Head_Owl-White{background-image:url(spritesmith-main-8.png);background-position:-106px -1316px;width:105px;height:105px}.Mount_Head_Owl-Zombie{background-image:url(spritesmith-main-8.png);background-position:-212px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-742px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Red{background-image:url(spritesmith-main-8.png);background-position:-848px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-954px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1060px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1166px -1316px;width:105px;height:105px}.Mount_Head_PandaCub-White{background-image:url(spritesmith-main-8.png);background-position:-242px -544px;width:105px;height:105px}.Mount_Head_PandaCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-348px -544px;width:105px;height:105px}.Mount_Head_Parrot-Base{background-image:url(spritesmith-main-8.png);background-position:-454px -544px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-560px -544px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-680px 0;width:105px;height:105px}.Mount_Head_Parrot-Desert{background-image:url(spritesmith-main-8.png);background-position:-680px -106px;width:105px;height:105px}.Mount_Head_Parrot-Golden{background-image:url(spritesmith-main-8.png);background-position:-680px -212px;width:105px;height:105px}.Mount_Head_Parrot-Red{background-image:url(spritesmith-main-8.png);background-position:-680px -318px;width:105px;height:105px}.Mount_Head_Parrot-Shade{background-image:url(spritesmith-main-8.png);background-position:-680px -424px;width:105px;height:105px}.Mount_Head_Parrot-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-680px -530px;width:105px;height:105px}.Mount_Head_Parrot-White{background-image:url(spritesmith-main-8.png);background-position:0 -680px;width:105px;height:105px}.Mount_Head_Parrot-Zombie{background-image:url(spritesmith-main-8.png);background-position:-106px -680px;width:105px;height:105px}.Mount_Head_Penguin-Base{background-image:url(spritesmith-main-8.png);background-position:-212px -680px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-318px -680px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-424px -680px;width:105px;height:105px}.Mount_Head_Penguin-Desert{background-image:url(spritesmith-main-8.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_Penguin-Golden{background-image:url(spritesmith-main-8.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Head_Penguin-Red{background-image:url(spritesmith-main-8.png);background-position:-786px 0;width:105px;height:105px}.Mount_Head_Penguin-Shade{background-image:url(spritesmith-main-8.png);background-position:-786px -106px;width:105px;height:105px}.Mount_Head_Penguin-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-786px -212px;width:105px;height:105px}.Mount_Head_Penguin-White{background-image:url(spritesmith-main-8.png);background-position:-786px -318px;width:105px;height:105px}.Mount_Head_Penguin-Zombie{background-image:url(spritesmith-main-8.png);background-position:-786px -424px;width:105px;height:105px}.Mount_Head_Phoenix-Base{background-image:url(spritesmith-main-8.png);background-position:-786px -530px;width:105px;height:105px}.Mount_Head_Rat-Base{background-image:url(spritesmith-main-8.png);background-position:-786px -636px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:0 -786px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-106px -786px;width:105px;height:105px}.Mount_Head_Rat-Desert{background-image:url(spritesmith-main-8.png);background-position:-212px -786px;width:105px;height:105px}.Mount_Head_Rat-Golden{background-image:url(spritesmith-main-8.png);background-position:-318px -786px;width:105px;height:105px}.Mount_Head_Rat-Red{background-image:url(spritesmith-main-8.png);background-position:-424px -786px;width:105px;height:105px}.Mount_Head_Rat-Shade{background-image:url(spritesmith-main-8.png);background-position:-530px -786px;width:105px;height:105px}.Mount_Head_Rat-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-636px -786px;width:105px;height:105px}.Mount_Head_Rat-White{background-image:url(spritesmith-main-8.png);background-position:-742px -786px;width:105px;height:105px}.Mount_Head_Rat-Zombie{background-image:url(spritesmith-main-8.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Rock-Base{background-image:url(spritesmith-main-8.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Head_Rock-Desert{background-image:url(spritesmith-main-8.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Head_Rock-Golden{background-image:url(spritesmith-main-8.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Head_Rock-Red{background-image:url(spritesmith-main-8.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Head_Rock-Shade{background-image:url(spritesmith-main-8.png);background-position:-892px -742px;width:105px;height:105px}.Mount_Head_Rock-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -892px;width:105px;height:105px}.Mount_Head_Rock-White{background-image:url(spritesmith-main-8.png);background-position:-106px -892px;width:105px;height:105px}.Mount_Head_Rock-Zombie{background-image:url(spritesmith-main-8.png);background-position:-212px -892px;width:105px;height:105px}.Mount_Head_Rooster-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -892px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -892px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -892px;width:105px;height:105px}.Mount_Head_Rooster-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -892px;width:105px;height:105px}.Mount_Head_Rooster-Golden{background-image:url(spritesmith-main-8.png);background-position:-742px -892px;width:105px;height:105px}.Mount_Head_Rooster-Red{background-image:url(spritesmith-main-8.png);background-position:-848px -892px;width:105px;height:105px}.Mount_Head_Rooster-Shade{background-image:url(spritesmith-main-8.png);background-position:-998px 0;width:105px;height:105px}.Mount_Head_Rooster-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Head_Rooster-White{background-image:url(spritesmith-main-8.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Head_Rooster-Zombie{background-image:url(spritesmith-main-8.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_Seahorse-Base{background-image:url(spritesmith-main-8.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_Seahorse-Desert{background-image:url(spritesmith-main-8.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_Seahorse-Golden{background-image:url(spritesmith-main-8.png);background-position:-998px -848px;width:105px;height:105px}.Mount_Head_Seahorse-Red{background-image:url(spritesmith-main-8.png);background-position:0 -998px;width:105px;height:105px}.Mount_Head_Seahorse-Shade{background-image:url(spritesmith-main-8.png);background-position:-106px -998px;width:105px;height:105px}.Mount_Head_Seahorse-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-212px -998px;width:105px;height:105px}.Mount_Head_Seahorse-White{background-image:url(spritesmith-main-8.png);background-position:-318px -998px;width:105px;height:105px}.Mount_Head_Seahorse-Zombie{background-image:url(spritesmith-main-8.png);background-position:-424px -998px;width:105px;height:105px}.Mount_Head_Sheep-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -998px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -998px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -998px;width:105px;height:105px}.Mount_Head_Sheep-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -998px;width:105px;height:105px}.Mount_Head_Sheep-Golden{background-image:url(spritesmith-main-8.png);background-position:-954px -998px;width:105px;height:105px}.Mount_Head_Sheep-Red{background-image:url(spritesmith-main-8.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Sheep-Shade{background-image:url(spritesmith-main-8.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Sheep-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Sheep-White{background-image:url(spritesmith-main-8.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Sheep-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Slime-Base{background-image:url(spritesmith-main-8.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Slime-Desert{background-image:url(spritesmith-main-8.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Slime-Golden{background-image:url(spritesmith-main-8.png);background-position:-1104px -954px;width:105px;height:105px}.Mount_Head_Slime-Red{background-image:url(spritesmith-main-8.png);background-position:0 -1104px;width:105px;height:105px}.Mount_Head_Slime-Shade{background-image:url(spritesmith-main-8.png);background-position:-106px -1104px;width:105px;height:105px}.Mount_Head_Slime-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-212px -1104px;width:105px;height:105px}.Mount_Head_Slime-White{background-image:url(spritesmith-main-8.png);background-position:-318px -1104px;width:105px;height:105px}.Mount_Head_Slime-Zombie{background-image:url(spritesmith-main-8.png);background-position:-424px -1104px;width:105px;height:105px}.Mount_Head_Spider-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -1104px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -1104px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -1104px;width:105px;height:105px}.Mount_Head_Spider-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -1104px;width:105px;height:105px}.Mount_Head_Spider-Golden{background-image:url(spritesmith-main-8.png);background-position:-954px -1104px;width:105px;height:105px}.Mount_Head_Spider-Red{background-image:url(spritesmith-main-8.png);background-position:-1060px -1104px;width:105px;height:105px}.Mount_Head_Spider-Shade{background-image:url(spritesmith-main-8.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Spider-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Spider-White{background-image:url(spritesmith-main-8.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Spider-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_TRex-Base{background-image:url(spritesmith-main-8.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:0 -136px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Head_TRex-Desert{background-image:url(spritesmith-main-8.png);background-position:-272px 0;width:135px;height:135px}.Mount_Head_TRex-Golden{background-image:url(spritesmith-main-8.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Head_TRex-Red{background-image:url(spritesmith-main-8.png);background-position:0 -272px;width:135px;height:135px}.Mount_Head_TRex-Shade{background-image:url(spritesmith-main-8.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Head_TRex-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Head_TRex-White{background-image:url(spritesmith-main-8.png);background-position:-408px 0;width:135px;height:135px}.Mount_Head_TRex-Zombie{background-image:url(spritesmith-main-8.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Head_TigerCub-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-742px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Red{background-image:url(spritesmith-main-8.png);background-position:-136px -544px;width:105px;height:105px}.Mount_Head_TigerCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-954px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1060px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1166px -1210px;width:105px;height:105px}.Mount_Head_TigerCub-White{background-image:url(spritesmith-main-8.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_TigerCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Turkey-Base{background-image:url(spritesmith-main-8.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Whale-Base{background-image:url(spritesmith-main-8.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Whale-Desert{background-image:url(spritesmith-main-8.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Whale-Golden{background-image:url(spritesmith-main-8.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Whale-Red{background-image:url(spritesmith-main-8.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Whale-Shade{background-image:url(spritesmith-main-8.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Whale-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Whale-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -1166px;width:105px;height:105px}.Mount_Head_Whale-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1316px;width:105px;height:105px}.Mount_Head_Wolf-Base{background-image:url(spritesmith-main-8.png);background-position:0 0;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:0 -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Head_Wolf-Desert{background-image:url(spritesmith-main-8.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Head_Wolf-Golden{background-image:url(spritesmith-main-8.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Head_Wolf-Red{background-image:url(spritesmith-main-8.png);background-position:-544px 0;width:135px;height:135px}.Mount_Head_Wolf-Shade{background-image:url(spritesmith-main-8.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Head_Wolf-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Head_Wolf-Spooky{background-image:url(spritesmith-main-8.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_Wolf-White{background-image:url(spritesmith-main-8.png);background-position:0 -544px;width:135px;height:135px}.Mount_Head_Wolf-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px 0;width:135px;height:135px}.Pet-BearCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px 0;width:81px;height:99px}.Pet-BearCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1586px -300px;width:81px;height:99px}.Pet-BearCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px -100px;width:81px;height:99px}.Pet-BearCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1422px -200px;width:81px;height:99px}.Pet-BearCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1422px -300px;width:81px;height:99px}.Pet-BearCub-Polar{background-image:url(spritesmith-main-8.png);background-position:-1422px -400px;width:81px;height:99px}.Pet-BearCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1422px -500px;width:81px;height:99px}.Pet-BearCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1422px -600px;width:81px;height:99px}.Pet-BearCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1422px -700px;width:81px;height:99px}.Pet-BearCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1422px -800px;width:81px;height:99px}.Pet-BearCub-White{background-image:url(spritesmith-main-8.png);background-position:-1422px -900px;width:81px;height:99px}.Pet-BearCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1422px -1000px;width:81px;height:99px}.Pet-Bunny-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -1100px;width:81px;height:99px}.Pet-Bunny-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1422px -1200px;width:81px;height:99px}.Pet-Bunny-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px -1300px;width:81px;height:99px}.Pet-Bunny-Desert{background-image:url(spritesmith-main-8.png);background-position:-1504px 0;width:81px;height:99px}.Pet-Bunny-Golden{background-image:url(spritesmith-main-8.png);background-position:-1504px -100px;width:81px;height:99px}.Pet-Bunny-Red{background-image:url(spritesmith-main-8.png);background-position:-1504px -200px;width:81px;height:99px}.Pet-Bunny-Shade{background-image:url(spritesmith-main-8.png);background-position:-1504px -300px;width:81px;height:99px}.Pet-Bunny-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1504px -400px;width:81px;height:99px}.Pet-Bunny-White{background-image:url(spritesmith-main-8.png);background-position:-1504px -500px;width:81px;height:99px}.Pet-Bunny-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1504px -600px;width:81px;height:99px}.Pet-Cactus-Base{background-image:url(spritesmith-main-8.png);background-position:-1504px -700px;width:81px;height:99px}.Pet-Cactus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1504px -800px;width:81px;height:99px}.Pet-Cactus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1504px -900px;width:81px;height:99px}.Pet-Cactus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1504px -1000px;width:81px;height:99px}.Pet-Cactus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1504px -1100px;width:81px;height:99px}.Pet-Cactus-Red{background-image:url(spritesmith-main-8.png);background-position:-1504px -1200px;width:81px;height:99px}.Pet-Cactus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1504px -1300px;width:81px;height:99px}.Pet-Cactus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -1422px;width:81px;height:99px}.Pet-Cactus-Spooky{background-image:url(spritesmith-main-8.png);background-position:-82px -1422px;width:81px;height:99px}.Pet-Cactus-White{background-image:url(spritesmith-main-8.png);background-position:-164px -1422px;width:81px;height:99px}.Pet-Cactus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-246px -1422px;width:81px;height:99px}.Pet-Cheetah-Base{background-image:url(spritesmith-main-8.png);background-position:-328px -1422px;width:81px;height:99px}.Pet-Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-410px -1422px;width:81px;height:99px}.Pet-Cheetah-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-492px -1422px;width:81px;height:99px}.Pet-Cheetah-Desert{background-image:url(spritesmith-main-8.png);background-position:-574px -1422px;width:81px;height:99px}.Pet-Cheetah-Golden{background-image:url(spritesmith-main-8.png);background-position:-656px -1422px;width:81px;height:99px}.Pet-Cheetah-Red{background-image:url(spritesmith-main-8.png);background-position:-738px -1422px;width:81px;height:99px}.Pet-Cheetah-Shade{background-image:url(spritesmith-main-8.png);background-position:-820px -1422px;width:81px;height:99px}.Pet-Cheetah-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-902px -1422px;width:81px;height:99px}.Pet-Cheetah-White{background-image:url(spritesmith-main-8.png);background-position:-984px -1422px;width:81px;height:99px}.Pet-Cheetah-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1066px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Base{background-image:url(spritesmith-main-8.png);background-position:-1148px -1422px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1230px -1422px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1312px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Desert{background-image:url(spritesmith-main-8.png);background-position:-1394px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Golden{background-image:url(spritesmith-main-8.png);background-position:-1476px -1422px;width:81px;height:99px}.Pet-Cuttlefish-Red{background-image:url(spritesmith-main-8.png);background-position:-1586px 0;width:81px;height:99px}.Pet-Cuttlefish-Shade{background-image:url(spritesmith-main-8.png);background-position:-1586px -100px;width:81px;height:99px}.Pet-Cuttlefish-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1586px -200px;width:81px;height:99px}.Pet-Cuttlefish-White{background-image:url(spritesmith-main-8.png);background-position:-1272px -1316px;width:81px;height:99px}.Pet-Cuttlefish-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1586px -400px;width:81px;height:99px}.Pet-Deer-Base{background-image:url(spritesmith-main-8.png);background-position:-1586px -500px;width:81px;height:99px}.Pet-Deer-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1586px -600px;width:81px;height:99px}.Pet-Deer-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1586px -700px;width:81px;height:99px}.Pet-Deer-Desert{background-image:url(spritesmith-main-8.png);background-position:-1586px -800px;width:81px;height:99px}.Pet-Deer-Golden{background-image:url(spritesmith-main-8.png);background-position:-1586px -900px;width:81px;height:99px}.Pet-Deer-Red{background-image:url(spritesmith-main-8.png);background-position:-1586px -1000px;width:81px;height:99px}.Pet-Deer-Shade{background-image:url(spritesmith-main-8.png);background-position:-1586px -1100px;width:81px;height:99px}.Pet-Deer-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1586px -1200px;width:81px;height:99px}.Pet-Deer-White{background-image:url(spritesmith-main-8.png);background-position:-1586px -1300px;width:81px;height:99px}.Pet-Deer-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1586px -1400px;width:81px;height:99px}.Pet-Dragon-Base{background-image:url(spritesmith-main-8.png);background-position:0 -1522px;width:81px;height:99px}.Pet-Dragon-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-82px -1522px;width:81px;height:99px}.Pet-Dragon-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-164px -1522px;width:81px;height:99px}.Pet-Dragon-Desert{background-image:url(spritesmith-main-8.png);background-position:-246px -1522px;width:81px;height:99px}.Pet-Dragon-Golden{background-image:url(spritesmith-main-8.png);background-position:-328px -1522px;width:81px;height:99px}.Pet-Dragon-Hydra{background-image:url(spritesmith-main-8.png);background-position:-410px -1522px;width:81px;height:99px}.Pet-Dragon-Red{background-image:url(spritesmith-main-8.png);background-position:-492px -1522px;width:81px;height:99px}.Pet-Dragon-Shade{background-image:url(spritesmith-main-8.png);background-position:-574px -1522px;width:81px;height:99px}.Pet-Dragon-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-656px -1522px;width:81px;height:99px}.Pet-Dragon-Spooky{background-image:url(spritesmith-main-8.png);background-position:-738px -1522px;width:81px;height:99px}.Pet-Dragon-White{background-image:url(spritesmith-main-8.png);background-position:-820px -1522px;width:81px;height:99px}.Pet-Dragon-Zombie{background-image:url(spritesmith-main-8.png);background-position:-902px -1522px;width:81px;height:99px}.Pet-Egg-Base{background-image:url(spritesmith-main-8.png);background-position:-984px -1522px;width:81px;height:99px}.Pet-Egg-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1066px -1522px;width:81px;height:99px}.Pet-Egg-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1148px -1522px;width:81px;height:99px}.Pet-Egg-Desert{background-image:url(spritesmith-main-8.png);background-position:-1230px -1522px;width:81px;height:99px}.Pet-Egg-Golden{background-image:url(spritesmith-main-8.png);background-position:-1312px -1522px;width:81px;height:99px}.Pet-Egg-Red{background-image:url(spritesmith-main-8.png);background-position:-1394px -1522px;width:81px;height:99px}.Pet-Egg-Shade{background-image:url(spritesmith-main-8.png);background-position:-1476px -1522px;width:81px;height:99px}.Pet-Egg-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1558px -1522px;width:81px;height:99px}.Pet-Egg-White{background-image:url(spritesmith-main-8.png);background-position:-1668px 0;width:81px;height:99px}.Pet-Egg-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1668px -100px;width:81px;height:99px}.Pet-FlyingPig-Base{background-image:url(spritesmith-main-8.png);background-position:-1668px -200px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1668px -300px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1668px -400px;width:81px;height:99px}.Pet-FlyingPig-Desert{background-image:url(spritesmith-main-8.png);background-position:-1668px -500px;width:81px;height:99px}.Pet-FlyingPig-Golden{background-image:url(spritesmith-main-8.png);background-position:-1668px -600px;width:81px;height:99px}.Pet-FlyingPig-Red{background-image:url(spritesmith-main-8.png);background-position:-1668px -700px;width:81px;height:99px}.Pet-FlyingPig-Shade{background-image:url(spritesmith-main-8.png);background-position:-1668px -800px;width:81px;height:99px}.Pet-FlyingPig-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1668px -900px;width:81px;height:99px}.Pet-FlyingPig-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1668px -1000px;width:81px;height:99px}.Pet-FlyingPig-White{background-image:url(spritesmith-main-8.png);background-position:-1668px -1100px;width:81px;height:99px}.Pet-FlyingPig-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1668px -1200px;width:81px;height:99px}.Pet-Fox-Base{background-image:url(spritesmith-main-8.png);background-position:-1668px -1300px;width:81px;height:99px}.Pet-Fox-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1668px -1400px;width:81px;height:99px}.Pet-Fox-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1668px -1500px;width:81px;height:99px}.Pet-Fox-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1622px;width:81px;height:99px}.Pet-Fox-Golden{background-image:url(spritesmith-main-8.png);background-position:-82px -1622px;width:81px;height:99px}.Pet-Fox-Red{background-image:url(spritesmith-main-8.png);background-position:-164px -1622px;width:81px;height:99px}.Pet-Fox-Shade{background-image:url(spritesmith-main-8.png);background-position:-246px -1622px;width:81px;height:99px}.Pet-Fox-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px 0;width:81px;height:99px}.Pet-Fox-Spooky{background-image:url(spritesmith-main-9.png);background-position:-82px -900px;width:81px;height:99px}.Pet-Fox-White{background-image:url(spritesmith-main-9.png);background-position:-164px 0;width:81px;height:99px}.Pet-Fox-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -100px;width:81px;height:99px}.Pet-Frog-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -100px;width:81px;height:99px}.Pet-Frog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -100px;width:81px;height:99px}.Pet-Frog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px 0;width:81px;height:99px}.Pet-Frog-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -100px;width:81px;height:99px}.Pet-Frog-Golden{background-image:url(spritesmith-main-9.png);background-position:0 -200px;width:81px;height:99px}.Pet-Frog-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -200px;width:81px;height:99px}.Pet-Frog-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -200px;width:81px;height:99px}.Pet-Frog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -200px;width:81px;height:99px}.Pet-Frog-White{background-image:url(spritesmith-main-9.png);background-position:-328px 0;width:81px;height:99px}.Pet-Frog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-328px -100px;width:81px;height:99px}.Pet-Gryphon-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -200px;width:81px;height:99px}.Pet-Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -300px;width:81px;height:99px}.Pet-Gryphon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -300px;width:81px;height:99px}.Pet-Gryphon-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -300px;width:81px;height:99px}.Pet-Gryphon-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -300px;width:81px;height:99px}.Pet-Gryphon-Red{background-image:url(spritesmith-main-9.png);background-position:-328px -300px;width:81px;height:99px}.Pet-Gryphon-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px 0;width:81px;height:99px}.Pet-Gryphon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -100px;width:81px;height:99px}.Pet-Gryphon-White{background-image:url(spritesmith-main-9.png);background-position:-410px -200px;width:81px;height:99px}.Pet-Gryphon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-410px -300px;width:81px;height:99px}.Pet-Hedgehog-Base{background-image:url(spritesmith-main-9.png);background-position:-492px 0;width:81px;height:99px}.Pet-Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -100px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -200px;width:81px;height:99px}.Pet-Hedgehog-Desert{background-image:url(spritesmith-main-9.png);background-position:-492px -300px;width:81px;height:99px}.Pet-Hedgehog-Golden{background-image:url(spritesmith-main-9.png);background-position:0 -400px;width:81px;height:99px}.Pet-Hedgehog-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -400px;width:81px;height:99px}.Pet-Hedgehog-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -400px;width:81px;height:99px}.Pet-Hedgehog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -400px;width:81px;height:99px}.Pet-Hedgehog-White{background-image:url(spritesmith-main-9.png);background-position:-328px -400px;width:81px;height:99px}.Pet-Hedgehog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-410px -400px;width:81px;height:99px}.Pet-Horse-Base{background-image:url(spritesmith-main-9.png);background-position:-492px -400px;width:81px;height:99px}.Pet-Horse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-574px 0;width:81px;height:99px}.Pet-Horse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -100px;width:81px;height:99px}.Pet-Horse-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -200px;width:81px;height:99px}.Pet-Horse-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -300px;width:81px;height:99px}.Pet-Horse-Red{background-image:url(spritesmith-main-9.png);background-position:-574px -400px;width:81px;height:99px}.Pet-Horse-Shade{background-image:url(spritesmith-main-9.png);background-position:0 -500px;width:81px;height:99px}.Pet-Horse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px -500px;width:81px;height:99px}.Pet-Horse-White{background-image:url(spritesmith-main-9.png);background-position:-164px -500px;width:81px;height:99px}.Pet-Horse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-246px -500px;width:81px;height:99px}.Pet-JackOLantern-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -500px;width:81px;height:99px}.Pet-LionCub-Base{background-image:url(spritesmith-main-9.png);background-position:-410px -500px;width:81px;height:99px}.Pet-LionCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -500px;width:81px;height:99px}.Pet-LionCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -500px;width:81px;height:99px}.Pet-LionCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-656px 0;width:81px;height:99px}.Pet-LionCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -100px;width:81px;height:99px}.Pet-LionCub-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -200px;width:81px;height:99px}.Pet-LionCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -300px;width:81px;height:99px}.Pet-LionCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-656px -400px;width:81px;height:99px}.Pet-LionCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px -500px;width:81px;height:99px}.Pet-LionCub-White{background-image:url(spritesmith-main-9.png);background-position:0 -600px;width:81px;height:99px}.Pet-LionCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-82px -600px;width:81px;height:99px}.Pet-Mammoth-Base{background-image:url(spritesmith-main-9.png);background-position:-164px -600px;width:81px;height:99px}.Pet-MantisShrimp-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -600px;width:81px;height:99px}.Pet-Octopus-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -600px;width:81px;height:99px}.Pet-Octopus-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-410px -600px;width:81px;height:99px}.Pet-Octopus-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -600px;width:81px;height:99px}.Pet-Octopus-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -600px;width:81px;height:99px}.Pet-Octopus-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -600px;width:81px;height:99px}.Pet-Octopus-Red{background-image:url(spritesmith-main-9.png);background-position:-738px 0;width:81px;height:99px}.Pet-Octopus-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px -100px;width:81px;height:99px}.Pet-Octopus-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -200px;width:81px;height:99px}.Pet-Octopus-White{background-image:url(spritesmith-main-9.png);background-position:-738px -300px;width:81px;height:99px}.Pet-Octopus-Zombie{background-image:url(spritesmith-main-9.png);background-position:-738px -400px;width:81px;height:99px}.Pet-Owl-Base{background-image:url(spritesmith-main-9.png);background-position:-738px -500px;width:81px;height:99px}.Pet-Owl-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -600px;width:81px;height:99px}.Pet-Owl-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 -700px;width:81px;height:99px}.Pet-Owl-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px -700px;width:81px;height:99px}.Pet-Owl-Golden{background-image:url(spritesmith-main-9.png);background-position:-164px -700px;width:81px;height:99px}.Pet-Owl-Red{background-image:url(spritesmith-main-9.png);background-position:-246px -700px;width:81px;height:99px}.Pet-Owl-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -700px;width:81px;height:99px}.Pet-Owl-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -700px;width:81px;height:99px}.Pet-Owl-White{background-image:url(spritesmith-main-9.png);background-position:-492px -700px;width:81px;height:99px}.Pet-Owl-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px -700px;width:81px;height:99px}.Pet-PandaCub-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -700px;width:81px;height:99px}.Pet-PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -700px;width:81px;height:99px}.Pet-PandaCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-820px 0;width:81px;height:99px}.Pet-PandaCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-820px -100px;width:81px;height:99px}.Pet-PandaCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-820px -200px;width:81px;height:99px}.Pet-PandaCub-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -300px;width:81px;height:99px}.Pet-PandaCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -400px;width:81px;height:99px}.Pet-PandaCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-820px -500px;width:81px;height:99px}.Pet-PandaCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-820px -600px;width:81px;height:99px}.Pet-PandaCub-White{background-image:url(spritesmith-main-9.png);background-position:-820px -700px;width:81px;height:99px}.Pet-PandaCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -800px;width:81px;height:99px}.Pet-Parrot-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -800px;width:81px;height:99px}.Pet-Parrot-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -800px;width:81px;height:99px}.Pet-Parrot-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px -800px;width:81px;height:99px}.Pet-Parrot-Desert{background-image:url(spritesmith-main-9.png);background-position:-328px -800px;width:81px;height:99px}.Pet-Parrot-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -800px;width:81px;height:99px}.Pet-Parrot-Red{background-image:url(spritesmith-main-9.png);background-position:-492px -800px;width:81px;height:99px}.Pet-Parrot-Shade{background-image:url(spritesmith-main-9.png);background-position:-574px -800px;width:81px;height:99px}.Pet-Parrot-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-656px -800px;width:81px;height:99px}.Pet-Parrot-White{background-image:url(spritesmith-main-9.png);background-position:-738px -800px;width:81px;height:99px}.Pet-Parrot-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -800px;width:81px;height:99px}.Pet-Penguin-Base{background-image:url(spritesmith-main-9.png);background-position:-902px 0;width:81px;height:99px}.Pet-Penguin-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -100px;width:81px;height:99px}.Pet-Penguin-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-902px -200px;width:81px;height:99px}.Pet-Penguin-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -300px;width:81px;height:99px}.Pet-Penguin-Golden{background-image:url(spritesmith-main-9.png);background-position:-902px -400px;width:81px;height:99px}.Pet-Penguin-Red{background-image:url(spritesmith-main-9.png);background-position:-902px -500px;width:81px;height:99px}.Pet-Penguin-Shade{background-image:url(spritesmith-main-9.png);background-position:-902px -600px;width:81px;height:99px}.Pet-Penguin-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-902px -700px;width:81px;height:99px}.Pet-Penguin-White{background-image:url(spritesmith-main-9.png);background-position:-902px -800px;width:81px;height:99px}.Pet-Penguin-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px 0;width:81px;height:99px}.Pet-Phoenix-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -100px;width:81px;height:99px}.Pet-Rat-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -200px;width:81px;height:99px}.Pet-Rat-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -300px;width:81px;height:99px}.Pet-Rat-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -400px;width:81px;height:99px}.Pet-Rat-Desert{background-image:url(spritesmith-main-9.png);background-position:-984px -500px;width:81px;height:99px}.Pet-Rat-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -600px;width:81px;height:99px}.Pet-Rat-Red{background-image:url(spritesmith-main-9.png);background-position:-984px -700px;width:81px;height:99px}.Pet-Rat-Shade{background-image:url(spritesmith-main-9.png);background-position:-984px -800px;width:81px;height:99px}.Pet-Rat-Skeleton{background-image:url(spritesmith-main-9.png);background-position:0 -900px;width:81px;height:99px}.Pet-Rat-White{background-image:url(spritesmith-main-9.png);background-position:0 0;width:81px;height:99px}.Pet-Rat-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px -900px;width:81px;height:99px}.Pet-Rock-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -900px;width:81px;height:99px}.Pet-Rock-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-328px -900px;width:81px;height:99px}.Pet-Rock-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-410px -900px;width:81px;height:99px}.Pet-Rock-Desert{background-image:url(spritesmith-main-9.png);background-position:-492px -900px;width:81px;height:99px}.Pet-Rock-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -900px;width:81px;height:99px}.Pet-Rock-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -900px;width:81px;height:99px}.Pet-Rock-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px -900px;width:81px;height:99px}.Pet-Rock-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-820px -900px;width:81px;height:99px}.Pet-Rock-White{background-image:url(spritesmith-main-9.png);background-position:-902px -900px;width:81px;height:99px}.Pet-Rock-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px -900px;width:81px;height:99px}.Pet-Rooster-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px 0;width:81px;height:99px}.Pet-Rooster-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1066px -100px;width:81px;height:99px}.Pet-Rooster-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -200px;width:81px;height:99px}.Pet-Rooster-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -300px;width:81px;height:99px}.Pet-Rooster-Golden{background-image:url(spritesmith-main-9.png);background-position:-1066px -400px;width:81px;height:99px}.Pet-Rooster-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -500px;width:81px;height:99px}.Pet-Rooster-Shade{background-image:url(spritesmith-main-9.png);background-position:-1066px -600px;width:81px;height:99px}.Pet-Rooster-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1066px -700px;width:81px;height:99px}.Pet-Rooster-White{background-image:url(spritesmith-main-9.png);background-position:-1066px -800px;width:81px;height:99px}.Pet-Rooster-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1066px -900px;width:81px;height:99px}.Pet-Seahorse-Base{background-image:url(spritesmith-main-9.png);background-position:0 -1000px;width:81px;height:99px}.Pet-Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -1000px;width:81px;height:99px}.Pet-Seahorse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -1000px;width:81px;height:99px}.Pet-Seahorse-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -1000px;width:81px;height:99px}.Pet-Seahorse-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -1000px;width:81px;height:99px}.Pet-Seahorse-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -1000px;width:81px;height:99px}.Pet-Seahorse-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -1000px;width:81px;height:99px}.Pet-Seahorse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -1000px;width:81px;height:99px}.Pet-Seahorse-White{background-image:url(spritesmith-main-9.png);background-position:-656px -1000px;width:81px;height:99px}.Pet-Seahorse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-738px -1000px;width:81px;height:99px}.Pet-Sheep-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -1000px;width:81px;height:99px}.Pet-Sheep-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -1000px;width:81px;height:99px}.Pet-Sheep-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -1000px;width:81px;height:99px}.Pet-Sheep-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -1000px;width:81px;height:99px}.Pet-Sheep-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px 0;width:81px;height:99px}.Pet-Sheep-Red{background-image:url(spritesmith-main-9.png);background-position:-1148px -100px;width:81px;height:99px}.Pet-Sheep-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -200px;width:81px;height:99px}.Pet-Sheep-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1148px -300px;width:81px;height:99px}.Pet-Sheep-White{background-image:url(spritesmith-main-9.png);background-position:-1148px -400px;width:81px;height:99px}.Pet-Sheep-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -500px;width:81px;height:99px}.Pet-Slime-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -600px;width:81px;height:99px}.Pet-Slime-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px -700px;width:81px;height:99px}.Pet-Slime-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1148px -800px;width:81px;height:99px}.Pet-Slime-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -900px;width:81px;height:99px}.Pet-Slime-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -1000px;width:81px;height:99px}.Pet-Slime-Red{background-image:url(spritesmith-main-9.png);background-position:0 -1100px;width:81px;height:99px}.Pet-Slime-Shade{background-image:url(spritesmith-main-9.png);background-position:-82px -1100px;width:81px;height:99px}.Pet-Slime-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-164px -1100px;width:81px;height:99px}.Pet-Slime-White{background-image:url(spritesmith-main-9.png);background-position:-246px -1100px;width:81px;height:99px}.Pet-Slime-Zombie{background-image:url(spritesmith-main-9.png);background-position:-328px -1100px;width:81px;height:99px}.Pet-Spider-Base{background-image:url(spritesmith-main-9.png);background-position:-410px -1100px;width:81px;height:99px}.Pet-Spider-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -1100px;width:81px;height:99px}.Pet-Spider-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -1100px;width:81px;height:99px}.Pet-Spider-Desert{background-image:url(spritesmith-main-9.png);background-position:-656px -1100px;width:81px;height:99px}.Pet-Spider-Golden{background-image:url(spritesmith-main-9.png);background-position:-738px -1100px;width:81px;height:99px}.Pet-Spider-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -1100px;width:81px;height:99px}.Pet-Spider-Shade{background-image:url(spritesmith-main-9.png);background-position:-902px -1100px;width:81px;height:99px}.Pet-Spider-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-984px -1100px;width:81px;height:99px}.Pet-Spider-White{background-image:url(spritesmith-main-9.png);background-position:-1066px -1100px;width:81px;height:99px}.Pet-Spider-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -1100px;width:81px;height:99px}.Pet-TRex-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px 0;width:81px;height:99px}.Pet-TRex-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1230px -100px;width:81px;height:99px}.Pet-TRex-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1230px -200px;width:81px;height:99px}.Pet-TRex-Desert{background-image:url(spritesmith-main-9.png);background-position:-1230px -300px;width:81px;height:99px}.Pet-TRex-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -400px;width:81px;height:99px}.Pet-TRex-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -500px;width:81px;height:99px}.Pet-TRex-Shade{background-image:url(spritesmith-main-9.png);background-position:-1230px -600px;width:81px;height:99px}.Pet-TRex-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px -700px;width:81px;height:99px}.Pet-TRex-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -800px;width:81px;height:99px}.Pet-TRex-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1230px -900px;width:81px;height:99px}.Pet-Tiger-Veteran{background-image:url(spritesmith-main-9.png);background-position:-1230px -1000px;width:81px;height:99px}.Pet-TigerCub-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px -1100px;width:81px;height:99px}.Pet-TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -1200px;width:81px;height:99px}.Pet-TigerCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -1200px;width:81px;height:99px}.Pet-TigerCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -1200px;width:81px;height:99px}.Pet-TigerCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -1200px;width:81px;height:99px}.Pet-TigerCub-Red{background-image:url(spritesmith-main-9.png);background-position:-328px -1200px;width:81px;height:99px}.Pet-TigerCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -1200px;width:81px;height:99px}.Pet-TigerCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-492px -1200px;width:81px;height:99px}.Pet-TigerCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-574px -1200px;width:81px;height:99px}.Pet-TigerCub-White{background-image:url(spritesmith-main-9.png);background-position:-656px -1200px;width:81px;height:99px}.Pet-TigerCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-738px -1200px;width:81px;height:99px}.Pet-Turkey-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -1200px;width:81px;height:99px}.Pet-Whale-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -1200px;width:81px;height:99px}.Pet-Whale-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -1200px;width:81px;height:99px}.Pet-Whale-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -1200px;width:81px;height:99px}.Pet-Whale-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -1200px;width:81px;height:99px}.Pet-Whale-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -1200px;width:81px;height:99px}.Pet-Whale-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px 0;width:81px;height:99px}.Pet-Whale-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -100px;width:81px;height:99px}.Pet-Whale-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -200px;width:81px;height:99px}.Pet-Whale-White{background-image:url(spritesmith-main-9.png);background-position:-1312px -300px;width:81px;height:99px}.Pet-Whale-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1312px -400px;width:81px;height:99px}.Pet-Wolf-Base{background-image:url(spritesmith-main-9.png);background-position:-1312px -500px;width:81px;height:99px}.Pet-Wolf-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1312px -600px;width:81px;height:99px}.Pet-Wolf-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px -700px;width:81px;height:99px}.Pet-Wolf-Desert{background-image:url(spritesmith-main-9.png);background-position:-1312px -800px;width:81px;height:99px}.Pet-Wolf-Golden{background-image:url(spritesmith-main-9.png);background-position:-1312px -900px;width:81px;height:99px}.Pet-Wolf-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px -1000px;width:81px;height:99px}.Pet-Wolf-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -1100px;width:81px;height:99px}.Pet-Wolf-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -1200px;width:81px;height:99px}.Pet-Wolf-Spooky{background-image:url(spritesmith-main-9.png);background-position:-1394px 0;width:81px;height:99px}.Pet-Wolf-Veteran{background-image:url(spritesmith-main-9.png);background-position:-1394px -100px;width:81px;height:99px}.Pet-Wolf-White{background-image:url(spritesmith-main-9.png);background-position:-1394px -200px;width:81px;height:99px}.Pet-Wolf-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -300px;width:81px;height:99px}.Pet_HatchingPotion_Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -452px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1394px -660px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1394px -504px;width:48px;height:51px}.Pet_HatchingPotion_Desert{background-image:url(spritesmith-main-9.png);background-position:-1394px -556px;width:48px;height:51px}.Pet_HatchingPotion_Golden{background-image:url(spritesmith-main-9.png);background-position:-1394px -608px;width:48px;height:51px}.Pet_HatchingPotion_Red{background-image:url(spritesmith-main-9.png);background-position:-1394px -400px;width:48px;height:51px}.Pet_HatchingPotion_Shade{background-image:url(spritesmith-main-9.png);background-position:-1394px -712px;width:48px;height:51px}.Pet_HatchingPotion_Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -764px;width:48px;height:51px}.Pet_HatchingPotion_Spooky{background-image:url(spritesmith-main-9.png);background-position:-1394px -816px;width:48px;height:51px}.Pet_HatchingPotion_White{background-image:url(spritesmith-main-9.png);background-position:-1394px -868px;width:48px;height:51px}.Pet_HatchingPotion_Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -920px;width:48px;height:51px}.head_special_0,.weapon_special_0{width:105px;height:105px;margin-left:-3px;margin-top:-18px}.broad_armor_special_0,.shield_special_0,.slim_armor_special_0{width:90px;height:90px}.weapon_special_critical{background:url(/common/img/sprites/backer-only/weapon_special_critical.gif) no-repeat;width:90px;height:90px;margin-left:-12px;margin-top:12px}.weapon_special_1{margin-left:-12px}.broad_armor_special_1,.head_special_1,.slim_armor_special_1{width:90px;height:90px}.head_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeHelmet.gif) no-repeat}.head_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalHelmet.gif) no-repeat;margin-top:3px}.broad_armor_special_0,.slim_armor_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeArmor.gif) no-repeat}.broad_armor_special_1,.slim_armor_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalArmor.gif) no-repeat}.shield_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Shield-TormentedSkull.gif) no-repeat}.weapon_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Weapon-DarkSoulsBlade.gif) no-repeat}.Pet-Wolf-Cerberus{width:105px;height:72px;background:url(/common/img/sprites/backer-only/BackerOnly-Pet-CerberusPup.gif) no-repeat}.npc_ian{background:url(/common/img/sprites/npc_ian.gif) no-repeat;width:78px;height:135px}.quest_burnout{background:url(/common/img/sprites/quest_burnout.gif) no-repeat;width:219px;height:249px}.Gems{display:inline-block;margin-right:5px;border-style:none;margin-left:0;margin-top:2px}.inline-gems{vertical-align:middle;margin-left:0;display:inline-block}.customize-menu .locked{background-color:#727272}.achievement{float:left;clear:right;margin-right:10px}.multi-achievement{margin:auto;padding-left:.5em;padding-right:.5em}[class*=Mount_Body_],[class*=Mount_Head_]{margin-top:18px}.Pet_Currency_Gem{margin-top:5px;margin-bottom:5px} \ No newline at end of file +.2014_Fall_HealerPROMO2{background-image:url(spritesmith-largeSprites-0.png);background-position:-637px -955px;width:90px;height:90px}.2014_Fall_Mage_PROMO9{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -347px;width:120px;height:90px}.2014_Fall_RoguePROMO3{background-image:url(spritesmith-largeSprites-0.png);background-position:-573px -347px;width:105px;height:90px}.2014_Fall_Warrior_PROMO{background-image:url(spritesmith-largeSprites-0.png);background-position:-590px -461px;width:90px;height:90px}.promo_backtoschool{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -342px;width:150px;height:150px}.promo_burnout{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -220px;width:219px;height:240px}.promo_classes_fall_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-326px -760px;width:321px;height:100px}.promo_classes_fall_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -660px;width:377px;height:99px}.promo_dilatoryDistress{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -955px;width:90px;height:90px}.promo_enchanted_armoire{background-image:url(spritesmith-largeSprites-0.png);background-position:-378px -660px;width:374px;height:76px}.promo_enchanted_armoire_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -641px;width:217px;height:90px}.promo_enchanted_armoire_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -342px;width:180px;height:90px}.promo_enchanted_armoire_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-273px -955px;width:90px;height:90px}.promo_enchanted_armoire_201511{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -493px;width:122px;height:90px}.promo_habitica{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -166px;width:175px;height:175px}.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-1127px -194px;width:100px;height:137px}.customize-option.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-1152px -209px;width:60px;height:60px}.promo_item_notif{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -91px;width:249px;height:102px}.promo_mystery_201405{background-image:url(spritesmith-largeSprites-0.png);background-position:-546px -955px;width:90px;height:90px}.promo_mystery_201406{background-image:url(spritesmith-largeSprites-0.png);background-position:-311px -220px;width:90px;height:96px}.promo_mystery_201407{background-image:url(spritesmith-largeSprites-0.png);background-position:-876px -433px;width:42px;height:62px}.promo_mystery_201408{background-image:url(spritesmith-largeSprites-0.png);background-position:-1188px -641px;width:60px;height:71px}.promo_mystery_201409{background-image:url(spritesmith-largeSprites-0.png);background-position:-182px -955px;width:90px;height:90px}.promo_mystery_201410{background-image:url(spritesmith-largeSprites-0.png);background-position:-1169px -823px;width:72px;height:63px}.promo_mystery_201411{background-image:url(spritesmith-largeSprites-0.png);background-position:-364px -955px;width:90px;height:90px}.promo_mystery_201412{background-image:url(spritesmith-largeSprites-0.png);background-position:-904px -342px;width:42px;height:66px}.promo_mystery_201501{background-image:url(spritesmith-largeSprites-0.png);background-position:-899px -166px;width:48px;height:63px}.promo_mystery_201502{background-image:url(spritesmith-largeSprites-0.png);background-position:-499px -461px;width:90px;height:90px}.promo_mystery_201503{background-image:url(spritesmith-largeSprites-0.png);background-position:-728px -955px;width:90px;height:90px}.promo_mystery_201504{background-image:url(spritesmith-largeSprites-0.png);background-position:-1188px -732px;width:60px;height:69px}.promo_mystery_201505{background-image:url(spritesmith-largeSprites-0.png);background-position:-91px -955px;width:90px;height:90px}.promo_mystery_201506{background-image:url(spritesmith-largeSprites-0.png);background-position:-899px -230px;width:42px;height:69px}.promo_mystery_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-220px -220px;width:90px;height:105px}.promo_mystery_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-314px -326px;width:93px;height:90px}.promo_mystery_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-455px -955px;width:90px;height:90px}.promo_mystery_201510{background-image:url(spritesmith-largeSprites-0.png);background-position:-220px -326px;width:93px;height:90px}.promo_mystery_3014{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -732px;width:217px;height:90px}.promo_orca{background-image:url(spritesmith-largeSprites-0.png);background-position:-1121px -342px;width:105px;height:105px}.promo_partyhats{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -584px;width:115px;height:47px}.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-331px -871px;width:330px;height:83px}.customize-option.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-356px -886px;width:60px;height:60px}.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -493px;width:140px;height:147px}.customize-option.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-995px -508px;width:60px;height:60px}.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -871px;width:330px;height:83px}.customize-option.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -886px;width:60px;height:60px}.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -823px;width:198px;height:91px}.customize-option.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-995px -838px;width:60px;height:60px}.promo_springclasses2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px 0;width:288px;height:90px}.promo_springclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-430px -557px;width:288px;height:90px}.promo_summer_classes_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -557px;width:429px;height:102px}.promo_summer_classes_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-648px -760px;width:300px;height:88px}.promo_updos{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -194px;width:156px;height:147px}.promo_veteran_pets{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -509px;width:146px;height:75px}.promo_winterclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -760px;width:325px;height:110px}.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -433px;width:152px;height:75px}.customize-option.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-748px -448px;width:60px;height:60px}.avatar_variety{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -461px;width:498px;height:95px}.party_preview{background-image:url(spritesmith-largeSprites-0.png);background-position:0 0;width:451px;height:219px}.welcome_basic_avatars{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -181px;width:246px;height:165px}.welcome_promo_party{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px 0;width:270px;height:180px}.welcome_sample_tasks{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px 0;width:246px;height:165px}.achievement-alien{background-image:url(spritesmith-main-0.png);background-position:-1678px -1451px;width:24px;height:26px}.achievement-alien2x{background-image:url(spritesmith-main-0.png);background-position:-895px -979px;width:48px;height:52px}.achievement-alpha{background-image:url(spritesmith-main-0.png);background-position:-1678px -1424px;width:24px;height:26px}.achievement-armor{background-image:url(spritesmith-main-0.png);background-position:-1678px -1397px;width:24px;height:26px}.achievement-armor2x{background-image:url(spritesmith-main-0.png);background-position:-944px -979px;width:48px;height:52px}.achievement-boot{background-image:url(spritesmith-main-0.png);background-position:-1678px -1343px;width:24px;height:26px}.achievement-boot2x{background-image:url(spritesmith-main-0.png);background-position:-1042px -979px;width:48px;height:52px}.achievement-bow{background-image:url(spritesmith-main-0.png);background-position:-1678px -1289px;width:24px;height:26px}.achievement-bow2x{background-image:url(spritesmith-main-0.png);background-position:-504px -1582px;width:48px;height:52px}.achievement-burnout{background-image:url(spritesmith-main-0.png);background-position:-1678px -1235px;width:24px;height:26px}.achievement-burnout2x{background-image:url(spritesmith-main-0.png);background-position:-602px -1582px;width:48px;height:52px}.achievement-cactus{background-image:url(spritesmith-main-0.png);background-position:-1678px -1181px;width:24px;height:26px}.achievement-cactus2x{background-image:url(spritesmith-main-0.png);background-position:-700px -1582px;width:48px;height:52px}.achievement-cake{background-image:url(spritesmith-main-0.png);background-position:-1678px -1127px;width:24px;height:26px}.achievement-cake2x{background-image:url(spritesmith-main-0.png);background-position:-798px -1582px;width:48px;height:52px}.achievement-cave{background-image:url(spritesmith-main-0.png);background-position:-1678px -1073px;width:24px;height:26px}.achievement-cave2x{background-image:url(spritesmith-main-0.png);background-position:-896px -1582px;width:48px;height:52px}.achievement-coffin{background-image:url(spritesmith-main-0.png);background-position:-1678px -1019px;width:24px;height:26px}.achievement-comment{background-image:url(spritesmith-main-0.png);background-position:-1678px -992px;width:24px;height:26px}.achievement-comment2x{background-image:url(spritesmith-main-0.png);background-position:-994px -1582px;width:48px;height:52px}.achievement-costumeContest{background-image:url(spritesmith-main-0.png);background-position:-1678px -938px;width:24px;height:26px}.achievement-costumeContest2x{background-image:url(spritesmith-main-0.png);background-position:-1092px -1582px;width:48px;height:52px}.achievement-dilatory{background-image:url(spritesmith-main-0.png);background-position:-1678px -884px;width:24px;height:26px}.achievement-firefox{background-image:url(spritesmith-main-0.png);background-position:-1678px -857px;width:24px;height:26px}.achievement-greeting{background-image:url(spritesmith-main-0.png);background-position:-1678px -830px;width:24px;height:26px}.achievement-greeting2x{background-image:url(spritesmith-main-0.png);background-position:-1190px -1582px;width:48px;height:52px}.achievement-habitBirthday{background-image:url(spritesmith-main-0.png);background-position:-1678px -776px;width:24px;height:26px}.achievement-habitBirthday2x{background-image:url(spritesmith-main-0.png);background-position:-1288px -1582px;width:48px;height:52px}.achievement-habiticaDay{background-image:url(spritesmith-main-0.png);background-position:-1678px -722px;width:24px;height:26px}.achievement-habiticaDay2x{background-image:url(spritesmith-main-0.png);background-position:-1386px -1582px;width:48px;height:52px}.achievement-heart{background-image:url(spritesmith-main-0.png);background-position:-1678px -668px;width:24px;height:26px}.achievement-heart2x{background-image:url(spritesmith-main-0.png);background-position:-1435px -1582px;width:48px;height:52px}.achievement-karaoke-2x{background-image:url(spritesmith-main-0.png);background-position:-1533px -1582px;width:48px;height:52px}.achievement-karaoke{background-image:url(spritesmith-main-0.png);background-position:-1678px -587px;width:24px;height:26px}.achievement-ninja{background-image:url(spritesmith-main-0.png);background-position:-1678px -560px;width:24px;height:26px}.achievement-ninja2x{background-image:url(spritesmith-main-0.png);background-position:-1678px 0;width:48px;height:52px}.achievement-nye{background-image:url(spritesmith-main-0.png);background-position:-1678px -506px;width:24px;height:26px}.achievement-nye2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -106px;width:48px;height:52px}.achievement-perfect{background-image:url(spritesmith-main-0.png);background-position:-1678px -452px;width:24px;height:26px}.achievement-perfect2x{background-image:url(spritesmith-main-0.png);background-position:-846px -979px;width:48px;height:52px}.achievement-rat{background-image:url(spritesmith-main-0.png);background-position:-1678px -911px;width:24px;height:26px}.achievement-rat2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -318px;width:48px;height:52px}.achievement-seafoam{background-image:url(spritesmith-main-0.png);background-position:-1678px -398px;width:24px;height:26px}.achievement-seafoam2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -265px;width:48px;height:52px}.achievement-shield{background-image:url(spritesmith-main-0.png);background-position:-1678px -425px;width:24px;height:26px}.achievement-shield2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -159px;width:48px;height:52px}.achievement-shinySeed{background-image:url(spritesmith-main-0.png);background-position:-1678px -479px;width:24px;height:26px}.achievement-shinySeed2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -53px;width:48px;height:52px}.achievement-snowball{background-image:url(spritesmith-main-0.png);background-position:-1678px -533px;width:24px;height:26px}.achievement-snowball2x{background-image:url(spritesmith-main-0.png);background-position:-1582px -1582px;width:48px;height:52px}.achievement-spookDust{background-image:url(spritesmith-main-0.png);background-position:-1678px -614px;width:24px;height:26px}.achievement-spookDust2x{background-image:url(spritesmith-main-0.png);background-position:-1484px -1582px;width:48px;height:52px}.achievement-stoikalm{background-image:url(spritesmith-main-0.png);background-position:-1678px -641px;width:24px;height:26px}.achievement-sun{background-image:url(spritesmith-main-0.png);background-position:-1678px -695px;width:24px;height:26px}.achievement-sun2x{background-image:url(spritesmith-main-0.png);background-position:-1337px -1582px;width:48px;height:52px}.achievement-sword{background-image:url(spritesmith-main-0.png);background-position:-1678px -749px;width:24px;height:26px}.achievement-sword2x{background-image:url(spritesmith-main-0.png);background-position:-1239px -1582px;width:48px;height:52px}.achievement-thankyou{background-image:url(spritesmith-main-0.png);background-position:-1678px -803px;width:24px;height:26px}.achievement-thankyou2x{background-image:url(spritesmith-main-0.png);background-position:-1141px -1582px;width:48px;height:52px}.achievement-thermometer{background-image:url(spritesmith-main-0.png);background-position:-1678px -371px;width:24px;height:26px}.achievement-thermometer2x{background-image:url(spritesmith-main-0.png);background-position:-1043px -1582px;width:48px;height:52px}.achievement-tree{background-image:url(spritesmith-main-0.png);background-position:-1678px -965px;width:24px;height:26px}.achievement-tree2x{background-image:url(spritesmith-main-0.png);background-position:-945px -1582px;width:48px;height:52px}.achievement-triadbingo{background-image:url(spritesmith-main-0.png);background-position:-1678px -1046px;width:24px;height:26px}.achievement-triadbingo2x{background-image:url(spritesmith-main-0.png);background-position:-847px -1582px;width:48px;height:52px}.achievement-ultimate-healer{background-image:url(spritesmith-main-0.png);background-position:-1678px -1100px;width:24px;height:26px}.achievement-ultimate-healer2x{background-image:url(spritesmith-main-0.png);background-position:-749px -1582px;width:48px;height:52px}.achievement-ultimate-mage{background-image:url(spritesmith-main-0.png);background-position:-1678px -1154px;width:24px;height:26px}.achievement-ultimate-mage2x{background-image:url(spritesmith-main-0.png);background-position:-651px -1582px;width:48px;height:52px}.achievement-ultimate-rogue{background-image:url(spritesmith-main-0.png);background-position:-1678px -1208px;width:24px;height:26px}.achievement-ultimate-rogue2x{background-image:url(spritesmith-main-0.png);background-position:-553px -1582px;width:48px;height:52px}.achievement-ultimate-warrior{background-image:url(spritesmith-main-0.png);background-position:-1678px -1262px;width:24px;height:26px}.achievement-ultimate-warrior2x{background-image:url(spritesmith-main-0.png);background-position:-455px -1582px;width:48px;height:52px}.achievement-valentine{background-image:url(spritesmith-main-0.png);background-position:-1678px -1316px;width:24px;height:26px}.achievement-valentine2x{background-image:url(spritesmith-main-0.png);background-position:-993px -979px;width:48px;height:52px}.achievement-wolf{background-image:url(spritesmith-main-0.png);background-position:-1678px -1370px;width:24px;height:26px}.achievement-wolf2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -212px;width:48px;height:52px}.background_autumn_forest{background-image:url(spritesmith-main-0.png);background-position:-423px -592px;width:140px;height:147px}.background_beach{background-image:url(spritesmith-main-0.png);background-position:-426px 0;width:141px;height:147px}.background_blacksmithy{background-image:url(spritesmith-main-0.png);background-position:-709px -148px;width:140px;height:147px}.background_cherry_trees{background-image:url(spritesmith-main-0.png);background-position:-709px -296px;width:140px;height:147px}.background_clouds{background-image:url(spritesmith-main-0.png);background-position:0 -592px;width:140px;height:147px}.background_coral_reef{background-image:url(spritesmith-main-0.png);background-position:-850px -148px;width:140px;height:147px}.background_crystal_cave{background-image:url(spritesmith-main-0.png);background-position:-850px -592px;width:140px;height:147px}.background_dilatory_ruins{background-image:url(spritesmith-main-0.png);background-position:0 -740px;width:140px;height:147px}.background_distant_castle{background-image:url(spritesmith-main-0.png);background-position:-423px -888px;width:140px;height:147px}.background_drifting_raft{background-image:url(spritesmith-main-0.png);background-position:-564px -888px;width:140px;height:147px}.background_dusty_canyons{background-image:url(spritesmith-main-0.png);background-position:-705px -888px;width:140px;height:147px}.background_fairy_ring{background-image:url(spritesmith-main-0.png);background-position:-568px 0;width:140px;height:147px}.background_floating_islands{background-image:url(spritesmith-main-0.png);background-position:-568px -148px;width:140px;height:147px}.background_floral_meadow{background-image:url(spritesmith-main-0.png);background-position:-568px -296px;width:140px;height:147px}.background_forest{background-image:url(spritesmith-main-0.png);background-position:0 -444px;width:140px;height:147px}.background_frigid_peak{background-image:url(spritesmith-main-0.png);background-position:-141px -444px;width:140px;height:147px}.background_giant_wave{background-image:url(spritesmith-main-0.png);background-position:-284px 0;width:141px;height:147px}.background_graveyard{background-image:url(spritesmith-main-0.png);background-position:-423px -444px;width:140px;height:147px}.background_gumdrop_land{background-image:url(spritesmith-main-0.png);background-position:-564px -444px;width:140px;height:147px}.background_harvest_feast{background-image:url(spritesmith-main-0.png);background-position:-709px 0;width:140px;height:147px}.background_harvest_fields{background-image:url(spritesmith-main-0.png);background-position:0 -148px;width:141px;height:147px}.background_harvest_moon{background-image:url(spritesmith-main-0.png);background-position:-142px -148px;width:141px;height:147px}.background_haunted_house{background-image:url(spritesmith-main-0.png);background-position:-709px -444px;width:140px;height:147px}.background_ice_cave{background-image:url(spritesmith-main-0.png);background-position:-284px -148px;width:141px;height:147px}.background_iceberg{background-image:url(spritesmith-main-0.png);background-position:-141px -592px;width:140px;height:147px}.background_island_waterfalls{background-image:url(spritesmith-main-0.png);background-position:-282px -592px;width:140px;height:147px}.background_marble_temple{background-image:url(spritesmith-main-0.png);background-position:-142px 0;width:141px;height:147px}.background_market{background-image:url(spritesmith-main-0.png);background-position:-564px -592px;width:140px;height:147px}.background_mountain_lake{background-image:url(spritesmith-main-0.png);background-position:-705px -592px;width:140px;height:147px}.background_night_dunes{background-image:url(spritesmith-main-0.png);background-position:-850px 0;width:140px;height:147px}.background_open_waters{background-image:url(spritesmith-main-0.png);background-position:0 0;width:141px;height:147px}.background_pagodas{background-image:url(spritesmith-main-0.png);background-position:-850px -296px;width:140px;height:147px}.background_pumpkin_patch{background-image:url(spritesmith-main-0.png);background-position:-850px -444px;width:140px;height:147px}.background_pyramids{background-image:url(spritesmith-main-0.png);background-position:-426px -148px;width:141px;height:147px}.background_rolling_hills{background-image:url(spritesmith-main-0.png);background-position:0 -296px;width:141px;height:147px}.background_seafarer_ship{background-image:url(spritesmith-main-0.png);background-position:-141px -740px;width:140px;height:147px}.background_shimmery_bubbles{background-image:url(spritesmith-main-0.png);background-position:-282px -740px;width:140px;height:147px}.background_slimy_swamp{background-image:url(spritesmith-main-0.png);background-position:-423px -740px;width:140px;height:147px}.background_snowy_pines{background-image:url(spritesmith-main-0.png);background-position:-564px -740px;width:140px;height:147px}.background_south_pole{background-image:url(spritesmith-main-0.png);background-position:-705px -740px;width:140px;height:147px}.background_spring_rain{background-image:url(spritesmith-main-0.png);background-position:-846px -740px;width:140px;height:147px}.background_stable{background-image:url(spritesmith-main-0.png);background-position:-991px 0;width:140px;height:147px}.background_stained_glass{background-image:url(spritesmith-main-0.png);background-position:-991px -148px;width:140px;height:147px}.background_starry_skies{background-image:url(spritesmith-main-0.png);background-position:-991px -296px;width:140px;height:147px}.background_sunken_ship{background-image:url(spritesmith-main-0.png);background-position:-991px -444px;width:140px;height:147px}.background_sunset_meadow{background-image:url(spritesmith-main-0.png);background-position:-991px -592px;width:140px;height:147px}.background_sunset_oasis{background-image:url(spritesmith-main-0.png);background-position:-991px -740px;width:140px;height:147px}.background_sunset_savannah{background-image:url(spritesmith-main-0.png);background-position:0 -888px;width:140px;height:147px}.background_swarming_darkness{background-image:url(spritesmith-main-0.png);background-position:-141px -888px;width:140px;height:147px}.background_tavern{background-image:url(spritesmith-main-0.png);background-position:-282px -888px;width:140px;height:147px}.background_thunderstorm{background-image:url(spritesmith-main-0.png);background-position:-142px -296px;width:141px;height:147px}.background_twinkly_lights{background-image:url(spritesmith-main-0.png);background-position:-284px -296px;width:141px;height:147px}.background_twinkly_party_lights{background-image:url(spritesmith-main-0.png);background-position:-426px -296px;width:141px;height:147px}.background_volcano{background-image:url(spritesmith-main-0.png);background-position:-282px -444px;width:140px;height:147px}.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -910px;width:90px;height:90px}.customize-option.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -925px;width:60px;height:60px}.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -1016px;width:60px;height:60px}.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -1107px;width:60px;height:60px}.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -1183px;width:90px;height:90px}.customize-option.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -1198px;width:60px;height:60px}.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:0 -1309px;width:90px;height:90px}.customize-option.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-25px -1324px;width:60px;height:60px}.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1324px;width:60px;height:60px}.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-182px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-207px -1324px;width:60px;height:60px}.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-273px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-298px -1324px;width:60px;height:60px}.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-364px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-389px -1324px;width:60px;height:60px}.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-455px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-480px -1324px;width:60px;height:60px}.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-546px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-571px -1324px;width:60px;height:60px}.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-637px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-662px -1324px;width:60px;height:60px}.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-728px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-753px -1324px;width:60px;height:60px}.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1324px;width:60px;height:60px}.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1324px;width:60px;height:60px}.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1324px;width:60px;height:60px}.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1092px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1117px -1324px;width:60px;height:60px}.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1183px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1208px -1324px;width:60px;height:60px}.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1324px;width:60px;height:60px}.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1405px 0;width:90px;height:90px}.customize-option.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1430px -15px;width:60px;height:60px}.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1405px -91px;width:90px;height:90px}.customize-option.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1430px -106px;width:60px;height:60px}.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1405px -182px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1430px -197px;width:60px;height:60px}.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1405px -273px;width:90px;height:90px}.customize-option.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1430px -288px;width:60px;height:60px}.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1405px -364px;width:90px;height:90px}.customize-option.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1430px -379px;width:60px;height:60px}.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -455px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -470px;width:60px;height:60px}.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-846px -888px;width:90px;height:90px}.customize-option.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-871px -903px;width:60px;height:60px}.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1405px -637px;width:90px;height:90px}.customize-option.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1430px -652px;width:60px;height:60px}.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1405px -728px;width:90px;height:90px}.customize-option.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1430px -743px;width:60px;height:60px}.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1405px -819px;width:90px;height:90px}.customize-option.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1430px -834px;width:60px;height:60px}.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1405px -910px;width:90px;height:90px}.customize-option.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1430px -925px;width:60px;height:60px}.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1405px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1430px -1016px;width:60px;height:60px}.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -1107px;width:60px;height:60px}.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1405px -1183px;width:90px;height:90px}.customize-option.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1430px -1198px;width:60px;height:60px}.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1405px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1430px -1289px;width:60px;height:60px}.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:0 -1400px;width:90px;height:90px}.customize-option.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-25px -1415px;width:60px;height:60px}.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-91px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-116px -1415px;width:60px;height:60px}.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-182px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-207px -1415px;width:60px;height:60px}.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-273px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-298px -1415px;width:60px;height:60px}.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-364px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-389px -1415px;width:60px;height:60px}.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-455px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-480px -1415px;width:60px;height:60px}.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-546px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-571px -1415px;width:60px;height:60px}.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-637px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-662px -1415px;width:60px;height:60px}.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-728px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-753px -1415px;width:60px;height:60px}.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-819px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-844px -1415px;width:60px;height:60px}.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-910px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-935px -1415px;width:60px;height:60px}.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-1001px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-1026px -1415px;width:60px;height:60px}.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-1092px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-1117px -1415px;width:60px;height:60px}.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1183px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1208px -1415px;width:60px;height:60px}.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1274px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1299px -1415px;width:60px;height:60px}.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1365px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1390px -1415px;width:60px;height:60px}.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1496px 0;width:90px;height:90px}.customize-option.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1521px -15px;width:60px;height:60px}.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1496px -91px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1521px -106px;width:60px;height:60px}.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1496px -182px;width:90px;height:90px}.customize-option.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1521px -197px;width:60px;height:60px}.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1496px -273px;width:90px;height:90px}.customize-option.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1521px -288px;width:60px;height:60px}.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1496px -364px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1521px -379px;width:60px;height:60px}.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1496px -455px;width:90px;height:90px}.customize-option.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1521px -470px;width:60px;height:60px}.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1496px -546px;width:90px;height:90px}.customize-option.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1521px -561px;width:60px;height:60px}.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -637px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -652px;width:60px;height:60px}.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1496px -728px;width:90px;height:90px}.customize-option.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1521px -743px;width:60px;height:60px}.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1496px -819px;width:90px;height:90px}.customize-option.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1521px -834px;width:60px;height:60px}.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1496px -910px;width:90px;height:90px}.customize-option.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1521px -925px;width:60px;height:60px}.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1496px -1001px;width:90px;height:90px}.customize-option.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1521px -1016px;width:60px;height:60px}.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1496px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1521px -1107px;width:60px;height:60px}.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1496px -1183px;width:90px;height:90px}.customize-option.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1521px -1198px;width:60px;height:60px}.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -1289px;width:60px;height:60px}.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1496px -1365px;width:90px;height:90px}.customize-option.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1521px -1380px;width:60px;height:60px}.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:0 -1491px;width:90px;height:90px}.customize-option.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-25px -1506px;width:60px;height:60px}.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-91px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-116px -1506px;width:60px;height:60px}.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-182px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-207px -1506px;width:60px;height:60px}.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1506px;width:60px;height:60px}.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-364px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-389px -1506px;width:60px;height:60px}.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-455px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-480px -1506px;width:60px;height:60px}.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-546px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-571px -1506px;width:60px;height:60px}.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-637px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-662px -1506px;width:60px;height:60px}.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-728px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-753px -1506px;width:60px;height:60px}.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-819px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-844px -1506px;width:60px;height:60px}.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-910px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-935px -1506px;width:60px;height:60px}.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-1001px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-1026px -1506px;width:60px;height:60px}.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-1092px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-1117px -1506px;width:60px;height:60px}.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-1183px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-1208px -1506px;width:60px;height:60px}.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1506px;width:60px;height:60px}.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1365px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1390px -1506px;width:60px;height:60px}.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1456px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1481px -1506px;width:60px;height:60px}.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1587px 0;width:90px;height:90px}.customize-option.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1612px -15px;width:60px;height:60px}.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1587px -91px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1612px -106px;width:60px;height:60px}.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1587px -182px;width:90px;height:90px}.customize-option.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1612px -197px;width:60px;height:60px}.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1587px -273px;width:90px;height:90px}.customize-option.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1612px -288px;width:60px;height:60px}.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1587px -364px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1612px -379px;width:60px;height:60px}.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1587px -455px;width:90px;height:90px}.customize-option.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1612px -470px;width:60px;height:60px}.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1587px -546px;width:90px;height:90px}.customize-option.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1612px -561px;width:60px;height:60px}.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -637px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -652px;width:60px;height:60px}.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1587px -728px;width:90px;height:90px}.customize-option.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1612px -743px;width:60px;height:60px}.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1587px -819px;width:90px;height:90px}.customize-option.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1612px -834px;width:60px;height:60px}.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1587px -910px;width:90px;height:90px}.customize-option.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1612px -925px;width:60px;height:60px}.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1587px -1001px;width:90px;height:90px}.customize-option.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1612px -1016px;width:60px;height:60px}.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1587px -1092px;width:90px;height:90px}.customize-option.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1612px -1107px;width:60px;height:60px}.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1587px -1183px;width:90px;height:90px}.customize-option.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1612px -1198px;width:60px;height:60px}.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -1289px;width:60px;height:60px}.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1587px -1365px;width:90px;height:90px}.customize-option.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1612px -1380px;width:60px;height:60px}.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1587px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1612px -1471px;width:60px;height:60px}.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:0 -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-25px -1597px;width:60px;height:60px}.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-91px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-116px -1597px;width:60px;height:60px}.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-182px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-207px -1597px;width:60px;height:60px}.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-273px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-298px -1597px;width:60px;height:60px}.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-364px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-389px -1597px;width:60px;height:60px}.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1405px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1430px -561px;width:60px;height:60px}.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1132px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1157px -652px;width:60px;height:60px}.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1132px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1157px -561px;width:60px;height:60px}.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1132px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1157px -470px;width:60px;height:60px}.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1132px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1157px -379px;width:60px;height:60px}.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-1132px -273px;width:90px;height:90px}.customize-option.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-1157px -288px;width:60px;height:60px}.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1132px -182px;width:90px;height:90px}.customize-option.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1157px -197px;width:60px;height:60px}.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1132px -91px;width:90px;height:90px}.customize-option.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1157px -106px;width:60px;height:60px}.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1132px 0;width:90px;height:90px}.customize-option.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1157px -15px;width:60px;height:60px}.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1051px;width:60px;height:60px}.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-910px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-935px -1051px;width:60px;height:60px}.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-819px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-844px -1051px;width:60px;height:60px}.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-728px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-753px -1051px;width:60px;height:60px}.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-637px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-662px -1051px;width:60px;height:60px}.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-546px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-571px -1051px;width:60px;height:60px}.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-455px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-480px -1051px;width:60px;height:60px}.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-364px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-389px -1051px;width:60px;height:60px}.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-273px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-298px -1051px;width:60px;height:60px}.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-182px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-207px -1051px;width:60px;height:60px}.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-91px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-116px -1051px;width:60px;height:60px}.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:0 -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-25px -1051px;width:60px;height:60px}.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1028px -888px;width:90px;height:90px}.customize-option.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1053px -903px;width:60px;height:60px}.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-937px -888px;width:90px;height:90px}.customize-option.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-962px -903px;width:60px;height:60px}.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1314px -819px;width:90px;height:90px}.customize-option.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1339px -834px;width:60px;height:60px}.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1314px -728px;width:90px;height:90px}.customize-option.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1339px -743px;width:60px;height:60px}.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1314px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1339px -652px;width:60px;height:60px}.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1314px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1339px -561px;width:60px;height:60px}.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -470px;width:60px;height:60px}.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -379px;width:60px;height:60px}.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -288px;width:60px;height:60px}.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -197px;width:60px;height:60px}.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1314px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1339px -106px;width:60px;height:60px}.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1314px 0;width:90px;height:90px}.customize-option.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1339px -15px;width:60px;height:60px}.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1183px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1208px -1233px;width:60px;height:60px}.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1092px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1117px -1233px;width:60px;height:60px}.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1001px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1026px -1233px;width:60px;height:60px}.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-910px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-935px -1233px;width:60px;height:60px}.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-819px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-844px -1233px;width:60px;height:60px}.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-728px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-753px -1233px;width:60px;height:60px}.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-637px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-662px -1233px;width:60px;height:60px}.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-546px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-571px -1233px;width:60px;height:60px}.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-455px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-480px -1233px;width:60px;height:60px}.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-364px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-389px -1233px;width:60px;height:60px}.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-273px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-298px -1233px;width:60px;height:60px}.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-182px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-207px -1233px;width:60px;height:60px}.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-91px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-116px -1233px;width:60px;height:60px}.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:0 -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-25px -1233px;width:60px;height:60px}.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1223px -1092px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1248px -1107px;width:60px;height:60px}.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1223px -1001px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1248px -1016px;width:60px;height:60px}.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1223px -910px;width:90px;height:90px}.customize-option.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1248px -925px;width:60px;height:60px}.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1223px -819px;width:90px;height:90px}.customize-option.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1248px -834px;width:60px;height:60px}.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1223px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1248px -743px;width:60px;height:60px}.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1223px -637px;width:90px;height:90px}.customize-option.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1248px -652px;width:60px;height:60px}.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1223px -546px;width:90px;height:90px}.customize-option.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1248px -561px;width:60px;height:60px}.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1223px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1248px -470px;width:60px;height:60px}.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1223px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1248px -379px;width:60px;height:60px}.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1223px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1248px -288px;width:60px;height:60px}.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1223px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1248px -197px;width:60px;height:60px}.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1223px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1248px -106px;width:60px;height:60px}.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1223px 0;width:90px;height:90px}.customize-option.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1248px -15px;width:60px;height:60px}.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1092px -1127px;width:90px;height:90px}.customize-option.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1117px -1142px;width:60px;height:60px}.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1001px -1127px;width:90px;height:90px}.customize-option.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1026px -1142px;width:60px;height:60px}.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-910px -1127px;width:90px;height:90px}.customize-option.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-935px -1142px;width:60px;height:60px}.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-819px -1127px;width:90px;height:90px}.customize-option.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-844px -1142px;width:60px;height:60px}.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-728px -1127px;width:90px;height:90px}.customize-option.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-753px -1142px;width:60px;height:60px}.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-637px -1127px;width:90px;height:90px}.customize-option.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-662px -1142px;width:60px;height:60px}.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-546px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-571px -1142px;width:60px;height:60px}.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-455px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-480px -1142px;width:60px;height:60px}.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-364px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-389px -1142px;width:60px;height:60px}.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1142px;width:60px;height:60px}.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-182px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-207px -1142px;width:60px;height:60px}.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1142px;width:60px;height:60px}.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:0 -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-25px -1142px;width:60px;height:60px}.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1132px -1001px;width:90px;height:90px}.customize-option.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1157px -1016px;width:60px;height:60px}.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1132px -910px;width:90px;height:90px}.customize-option.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1157px -925px;width:60px;height:60px}.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1132px -819px;width:90px;height:90px}.customize-option.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1157px -834px;width:60px;height:60px}.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1132px -728px;width:90px;height:90px}.customize-option.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1157px -743px;width:60px;height:60px}.hair_bangs_1_green{background-image:url(spritesmith-main-1.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_bangs_1_green{background-image:url(spritesmith-main-1.png);background-position:-116px -15px;width:60px;height:60px}.hair_bangs_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_bangs_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -1107px;width:60px;height:60px}.hair_bangs_1_holly{background-image:url(spritesmith-main-1.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_bangs_1_holly{background-image:url(spritesmith-main-1.png);background-position:-25px -106px;width:60px;height:60px}.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-116px -106px;width:60px;height:60px}.hair_bangs_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_bangs_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-207px -15px;width:60px;height:60px}.hair_bangs_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -106px;width:60px;height:60px}.hair_bangs_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -197px;width:60px;height:60px}.hair_bangs_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -197px;width:60px;height:60px}.hair_bangs_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -197px;width:60px;height:60px}.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -15px;width:60px;height:60px}.hair_bangs_1_porange{background-image:url(spritesmith-main-1.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_porange{background-image:url(spritesmith-main-1.png);background-position:-298px -106px;width:60px;height:60px}.hair_bangs_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-298px -197px;width:60px;height:60px}.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -288px;width:60px;height:60px}.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -288px;width:60px;height:60px}.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -288px;width:60px;height:60px}.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -288px;width:60px;height:60px}.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -15px;width:60px;height:60px}.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-389px -106px;width:60px;height:60px}.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-389px -197px;width:60px;height:60px}.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -288px;width:60px;height:60px}.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-25px -379px;width:60px;height:60px}.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-116px -379px;width:60px;height:60px}.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-207px -379px;width:60px;height:60px}.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-298px -379px;width:60px;height:60px}.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-389px -379px;width:60px;height:60px}.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-480px -15px;width:60px;height:60px}.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-480px -106px;width:60px;height:60px}.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-480px -197px;width:60px;height:60px}.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-480px -288px;width:60px;height:60px}.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-480px -379px;width:60px;height:60px}.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-25px -470px;width:60px;height:60px}.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-116px -470px;width:60px;height:60px}.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-207px -470px;width:60px;height:60px}.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-298px -470px;width:60px;height:60px}.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-389px -470px;width:60px;height:60px}.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-480px -470px;width:60px;height:60px}.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-571px -15px;width:60px;height:60px}.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-571px -106px;width:60px;height:60px}.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-571px -197px;width:60px;height:60px}.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-571px -288px;width:60px;height:60px}.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-571px -379px;width:60px;height:60px}.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-571px -470px;width:60px;height:60px}.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-25px -561px;width:60px;height:60px}.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-116px -561px;width:60px;height:60px}.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -561px;width:60px;height:60px}.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-298px -561px;width:60px;height:60px}.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-389px -561px;width:60px;height:60px}.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-480px -561px;width:60px;height:60px}.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-571px -561px;width:60px;height:60px}.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-662px -15px;width:60px;height:60px}.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-662px -106px;width:60px;height:60px}.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-662px -197px;width:60px;height:60px}.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -288px;width:60px;height:60px}.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-662px -379px;width:60px;height:60px}.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-662px -470px;width:60px;height:60px}.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-662px -561px;width:60px;height:60px}.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-25px -652px;width:60px;height:60px}.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-116px -652px;width:60px;height:60px}.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-207px -652px;width:60px;height:60px}.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-298px -652px;width:60px;height:60px}.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-389px -652px;width:60px;height:60px}.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-480px -652px;width:60px;height:60px}.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-571px -652px;width:60px;height:60px}.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-662px -652px;width:60px;height:60px}.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-753px -15px;width:60px;height:60px}.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-753px -106px;width:60px;height:60px}.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-753px -197px;width:60px;height:60px}.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-753px -288px;width:60px;height:60px}.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-753px -379px;width:60px;height:60px}.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-753px -470px;width:60px;height:60px}.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-753px -561px;width:60px;height:60px}.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-753px -652px;width:60px;height:60px}.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-25px -743px;width:60px;height:60px}.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-116px -743px;width:60px;height:60px}.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-207px -743px;width:60px;height:60px}.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-298px -743px;width:60px;height:60px}.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -743px;width:60px;height:60px}.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -743px;width:60px;height:60px}.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-571px -743px;width:60px;height:60px}.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-662px -743px;width:60px;height:60px}.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -743px;width:60px;height:60px}.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-844px -15px;width:60px;height:60px}.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-844px -106px;width:60px;height:60px}.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-844px -197px;width:60px;height:60px}.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-844px -288px;width:60px;height:60px}.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-844px -379px;width:60px;height:60px}.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -470px;width:60px;height:60px}.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-844px -561px;width:60px;height:60px}.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-844px -652px;width:60px;height:60px}.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-844px -743px;width:60px;height:60px}.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -834px;width:60px;height:60px}.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -834px;width:60px;height:60px}.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -834px;width:60px;height:60px}.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -834px;width:60px;height:60px}.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -834px;width:60px;height:60px}.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-480px -834px;width:60px;height:60px}.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-571px -834px;width:60px;height:60px}.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-662px -834px;width:60px;height:60px}.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-753px -834px;width:60px;height:60px}.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-844px -834px;width:60px;height:60px}.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-935px -15px;width:60px;height:60px}.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-935px -106px;width:60px;height:60px}.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-935px -197px;width:60px;height:60px}.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -288px;width:60px;height:60px}.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-935px -379px;width:60px;height:60px}.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1663px -15px;width:60px;height:60px}.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1663px -106px;width:60px;height:60px}.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1663px -197px;width:60px;height:60px}.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1663px -288px;width:60px;height:60px}.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1663px -379px;width:60px;height:60px}.hair_base_1_purple{background-image:url(spritesmith-main-2.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_base_1_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -15px;width:60px;height:60px}.hair_base_1_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_base_1_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -1107px;width:60px;height:60px}.hair_base_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_base_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-25px -106px;width:60px;height:60px}.hair_base_1_rainbow{background-image:url(spritesmith-main-2.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_base_1_rainbow{background-image:url(spritesmith-main-2.png);background-position:-116px -106px;width:60px;height:60px}.hair_base_1_red{background-image:url(spritesmith-main-2.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_base_1_red{background-image:url(spritesmith-main-2.png);background-position:-207px -15px;width:60px;height:60px}.hair_base_1_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_base_1_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -106px;width:60px;height:60px}.hair_base_1_white{background-image:url(spritesmith-main-2.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_base_1_white{background-image:url(spritesmith-main-2.png);background-position:-25px -197px;width:60px;height:60px}.hair_base_1_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_base_1_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -197px;width:60px;height:60px}.hair_base_1_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_base_1_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -197px;width:60px;height:60px}.hair_base_1_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_base_1_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -15px;width:60px;height:60px}.hair_base_1_zombie{background-image:url(spritesmith-main-2.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_base_1_zombie{background-image:url(spritesmith-main-2.png);background-position:-298px -106px;width:60px;height:60px}.hair_base_2_TRUred{background-image:url(spritesmith-main-2.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_base_2_TRUred{background-image:url(spritesmith-main-2.png);background-position:-298px -197px;width:60px;height:60px}.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -288px;width:60px;height:60px}.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-116px -288px;width:60px;height:60px}.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -288px;width:60px;height:60px}.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -288px;width:60px;height:60px}.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -15px;width:60px;height:60px}.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-389px -106px;width:60px;height:60px}.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-389px -197px;width:60px;height:60px}.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -288px;width:60px;height:60px}.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-25px -379px;width:60px;height:60px}.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-116px -379px;width:60px;height:60px}.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-207px -379px;width:60px;height:60px}.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-298px -379px;width:60px;height:60px}.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-389px -379px;width:60px;height:60px}.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-480px -15px;width:60px;height:60px}.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-480px -106px;width:60px;height:60px}.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-480px -197px;width:60px;height:60px}.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-480px -288px;width:60px;height:60px}.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-480px -379px;width:60px;height:60px}.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-25px -470px;width:60px;height:60px}.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-116px -470px;width:60px;height:60px}.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-207px -470px;width:60px;height:60px}.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-298px -470px;width:60px;height:60px}.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-389px -470px;width:60px;height:60px}.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-480px -470px;width:60px;height:60px}.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-571px -15px;width:60px;height:60px}.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-571px -106px;width:60px;height:60px}.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-571px -197px;width:60px;height:60px}.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -288px;width:60px;height:60px}.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-571px -379px;width:60px;height:60px}.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-571px -470px;width:60px;height:60px}.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:-25px -561px;width:60px;height:60px}.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-116px -561px;width:60px;height:60px}.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -561px;width:60px;height:60px}.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-298px -561px;width:60px;height:60px}.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-389px -561px;width:60px;height:60px}.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-480px -561px;width:60px;height:60px}.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-571px -561px;width:60px;height:60px}.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-662px -15px;width:60px;height:60px}.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-662px -106px;width:60px;height:60px}.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-662px -197px;width:60px;height:60px}.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-662px -288px;width:60px;height:60px}.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-662px -379px;width:60px;height:60px}.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-662px -470px;width:60px;height:60px}.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-662px -561px;width:60px;height:60px}.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-25px -652px;width:60px;height:60px}.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-116px -652px;width:60px;height:60px}.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-207px -652px;width:60px;height:60px}.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-298px -652px;width:60px;height:60px}.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-389px -652px;width:60px;height:60px}.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-480px -652px;width:60px;height:60px}.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-571px -652px;width:60px;height:60px}.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-662px -652px;width:60px;height:60px}.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-753px -15px;width:60px;height:60px}.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-753px -106px;width:60px;height:60px}.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-753px -197px;width:60px;height:60px}.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-753px -288px;width:60px;height:60px}.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-753px -379px;width:60px;height:60px}.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-753px -470px;width:60px;height:60px}.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-753px -561px;width:60px;height:60px}.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-753px -652px;width:60px;height:60px}.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -743px;width:60px;height:60px}.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-116px -743px;width:60px;height:60px}.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-207px -743px;width:60px;height:60px}.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-298px -743px;width:60px;height:60px}.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -743px;width:60px;height:60px}.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -743px;width:60px;height:60px}.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -743px;width:60px;height:60px}.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-662px -743px;width:60px;height:60px}.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -743px;width:60px;height:60px}.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-844px -15px;width:60px;height:60px}.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-844px -106px;width:60px;height:60px}.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-844px -197px;width:60px;height:60px}.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-844px -288px;width:60px;height:60px}.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-844px -379px;width:60px;height:60px}.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -470px;width:60px;height:60px}.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-844px -561px;width:60px;height:60px}.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-844px -652px;width:60px;height:60px}.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-844px -743px;width:60px;height:60px}.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -834px;width:60px;height:60px}.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-116px -834px;width:60px;height:60px}.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -834px;width:60px;height:60px}.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1663px -15px;width:60px;height:60px}.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1663px -106px;width:60px;height:60px}.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1663px -197px;width:60px;height:60px}.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1663px -288px;width:60px;height:60px}.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1663px -379px;width:60px;height:60px}.hair_beard_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-454px -273px;width:90px;height:90px}.customize-option.hair_beard_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-479px -288px;width:60px;height:60px}.hair_beard_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1276px -728px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1301px -743px;width:60px;height:60px}.hair_beard_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1276px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1301px -1107px;width:60px;height:60px}.hair_beard_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-207px -1289px;width:60px;height:60px}.hair_beard_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-298px -1289px;width:60px;height:60px}.hair_beard_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-480px -1289px;width:60px;height:60px}.hair_beard_3_pblue2{background-image:url(spritesmith-main-3.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_pblue2{background-image:url(spritesmith-main-3.png);background-position:-571px -1289px;width:60px;height:60px}.hair_beard_3_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-935px -1289px;width:60px;height:60px}.hair_beard_3_porange2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_porange2{background-image:url(spritesmith-main-3.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_beard_3_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_beard_3_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1367px -91px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1392px -106px;width:60px;height:60px}.hair_beard_3_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1367px -182px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1392px -197px;width:60px;height:60px}.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1367px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1392px -379px;width:60px;height:60px}.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1367px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1392px -470px;width:60px;height:60px}.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -1471px;width:60px;height:60px}.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-116px -1471px;width:60px;height:60px}.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-298px -1471px;width:60px;height:60px}.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-389px -1471px;width:60px;height:60px}.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-753px -1471px;width:60px;height:60px}.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-844px -1471px;width:60px;height:60px}.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -379px;width:60px;height:60px}.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-116px -379px;width:60px;height:60px}.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-207px -379px;width:60px;height:60px}.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-298px -379px;width:60px;height:60px}.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-389px -394px;width:60px;height:60px}.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-480px -394px;width:60px;height:60px}.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:0 -455px;width:90px;height:90px}.customize-option.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-25px -485px;width:60px;height:60px}.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-116px -485px;width:60px;height:60px}.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-207px -485px;width:60px;height:60px}.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-298px -485px;width:60px;height:60px}.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-389px -485px;width:60px;height:60px}.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-480px -485px;width:60px;height:60px}.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-548px 0;width:90px;height:90px}.customize-option.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-573px -30px;width:60px;height:60px}.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-548px -91px;width:90px;height:90px}.customize-option.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-573px -121px;width:60px;height:60px}.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-548px -182px;width:90px;height:90px}.customize-option.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-573px -212px;width:60px;height:60px}.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-548px -273px;width:90px;height:90px}.customize-option.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-573px -303px;width:60px;height:60px}.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-548px -364px;width:90px;height:90px}.customize-option.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-573px -394px;width:60px;height:60px}.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-548px -455px;width:90px;height:90px}.customize-option.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-573px -485px;width:60px;height:60px}.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:0 -546px;width:90px;height:90px}.customize-option.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-25px -576px;width:60px;height:60px}.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-116px -576px;width:60px;height:60px}.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-207px -576px;width:60px;height:60px}.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-298px -576px;width:60px;height:60px}.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-389px -576px;width:60px;height:60px}.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-480px -576px;width:60px;height:60px}.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-571px -576px;width:60px;height:60px}.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-639px 0;width:90px;height:90px}.customize-option.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-664px -30px;width:60px;height:60px}.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-639px -91px;width:90px;height:90px}.customize-option.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-664px -121px;width:60px;height:60px}.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-639px -182px;width:90px;height:90px}.customize-option.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-664px -212px;width:60px;height:60px}.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-639px -273px;width:90px;height:90px}.customize-option.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-664px -303px;width:60px;height:60px}.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-639px -364px;width:90px;height:90px}.customize-option.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-664px -394px;width:60px;height:60px}.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-639px -455px;width:90px;height:90px}.customize-option.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-664px -485px;width:60px;height:60px}.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-639px -546px;width:90px;height:90px}.customize-option.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-664px -576px;width:60px;height:60px}.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:0 -637px;width:90px;height:90px}.customize-option.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-25px -667px;width:60px;height:60px}.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-116px -667px;width:60px;height:60px}.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-207px -667px;width:60px;height:60px}.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-298px -667px;width:60px;height:60px}.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-389px -667px;width:60px;height:60px}.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-480px -667px;width:60px;height:60px}.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-571px -652px;width:60px;height:60px}.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -652px;width:60px;height:60px}.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-730px 0;width:90px;height:90px}.customize-option.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-755px -15px;width:60px;height:60px}.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -91px;width:90px;height:90px}.customize-option.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -106px;width:60px;height:60px}.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-730px -182px;width:90px;height:90px}.customize-option.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-755px -197px;width:60px;height:60px}.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -273px;width:90px;height:90px}.customize-option.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -288px;width:60px;height:60px}.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-730px -364px;width:90px;height:90px}.customize-option.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-755px -379px;width:60px;height:60px}.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -455px;width:90px;height:90px}.customize-option.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -470px;width:60px;height:60px}.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-730px -546px;width:90px;height:90px}.customize-option.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-755px -561px;width:60px;height:60px}.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -637px;width:90px;height:90px}.customize-option.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -652px;width:60px;height:60px}.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:0 -728px;width:90px;height:90px}.customize-option.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:-25px -743px;width:60px;height:60px}.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -743px;width:60px;height:60px}.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-207px -743px;width:60px;height:60px}.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -743px;width:60px;height:60px}.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-389px -743px;width:60px;height:60px}.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -743px;width:60px;height:60px}.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-571px -743px;width:60px;height:60px}.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -743px;width:60px;height:60px}.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-753px -743px;width:60px;height:60px}.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px 0;width:90px;height:90px}.customize-option.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -15px;width:60px;height:60px}.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-821px -91px;width:90px;height:90px}.customize-option.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-846px -106px;width:60px;height:60px}.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -182px;width:90px;height:90px}.customize-option.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -197px;width:60px;height:60px}.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-821px -273px;width:90px;height:90px}.customize-option.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-846px -288px;width:60px;height:60px}.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -364px;width:90px;height:90px}.customize-option.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -379px;width:60px;height:60px}.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-821px -455px;width:90px;height:90px}.customize-option.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-846px -470px;width:60px;height:60px}.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -546px;width:90px;height:90px}.customize-option.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -561px;width:60px;height:60px}.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-821px -637px;width:90px;height:90px}.customize-option.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-846px -652px;width:60px;height:60px}.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -728px;width:90px;height:90px}.customize-option.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -743px;width:60px;height:60px}.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:0 -819px;width:90px;height:90px}.customize-option.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-25px -834px;width:60px;height:60px}.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -834px;width:60px;height:60px}.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-207px -834px;width:60px;height:60px}.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -834px;width:60px;height:60px}.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-389px -834px;width:60px;height:60px}.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -834px;width:60px;height:60px}.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-571px -834px;width:60px;height:60px}.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -834px;width:60px;height:60px}.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-753px -834px;width:60px;height:60px}.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -834px;width:60px;height:60px}.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-912px 0;width:90px;height:90px}.customize-option.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-937px -15px;width:60px;height:60px}.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -91px;width:90px;height:90px}.customize-option.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -106px;width:60px;height:60px}.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-912px -182px;width:90px;height:90px}.customize-option.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-937px -197px;width:60px;height:60px}.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -273px;width:90px;height:90px}.customize-option.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -288px;width:60px;height:60px}.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-912px -364px;width:90px;height:90px}.customize-option.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-937px -379px;width:60px;height:60px}.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -455px;width:90px;height:90px}.customize-option.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -470px;width:60px;height:60px}.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-912px -546px;width:90px;height:90px}.customize-option.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-937px -561px;width:60px;height:60px}.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -637px;width:90px;height:90px}.customize-option.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -652px;width:60px;height:60px}.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-912px -728px;width:90px;height:90px}.customize-option.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-937px -743px;width:60px;height:60px}.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -819px;width:90px;height:90px}.customize-option.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -834px;width:60px;height:60px}.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:0 -910px;width:90px;height:90px}.customize-option.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-25px -925px;width:60px;height:60px}.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -925px;width:60px;height:60px}.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-207px -925px;width:60px;height:60px}.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -925px;width:60px;height:60px}.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-389px -925px;width:60px;height:60px}.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -925px;width:60px;height:60px}.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-571px -925px;width:60px;height:60px}.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -925px;width:60px;height:60px}.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-753px -925px;width:60px;height:60px}.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -925px;width:60px;height:60px}.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-935px -925px;width:60px;height:60px}.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px 0;width:90px;height:90px}.customize-option.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -15px;width:60px;height:60px}.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1003px -91px;width:90px;height:90px}.customize-option.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1028px -106px;width:60px;height:60px}.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -182px;width:90px;height:90px}.customize-option.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -197px;width:60px;height:60px}.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1003px -273px;width:90px;height:90px}.customize-option.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1028px -288px;width:60px;height:60px}.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -364px;width:90px;height:90px}.customize-option.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -379px;width:60px;height:60px}.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-1003px -455px;width:90px;height:90px}.customize-option.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-1028px -470px;width:60px;height:60px}.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -546px;width:90px;height:90px}.customize-option.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -561px;width:60px;height:60px}.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-1003px -637px;width:90px;height:90px}.customize-option.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-1028px -652px;width:60px;height:60px}.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -728px;width:90px;height:90px}.customize-option.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -743px;width:60px;height:60px}.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-1003px -819px;width:90px;height:90px}.customize-option.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-1028px -834px;width:60px;height:60px}.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -925px;width:60px;height:60px}.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-25px -1016px;width:60px;height:60px}.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1016px;width:60px;height:60px}.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-207px -1016px;width:60px;height:60px}.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1016px;width:60px;height:60px}.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-389px -1016px;width:60px;height:60px}.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1016px;width:60px;height:60px}.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-571px -1016px;width:60px;height:60px}.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1016px;width:60px;height:60px}.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-753px -1016px;width:60px;height:60px}.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-844px -1016px;width:60px;height:60px}.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-935px -1016px;width:60px;height:60px}.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1026px -1016px;width:60px;height:60px}.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1094px 0;width:90px;height:90px}.customize-option.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1119px -15px;width:60px;height:60px}.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -91px;width:90px;height:90px}.customize-option.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -106px;width:60px;height:60px}.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1094px -182px;width:90px;height:90px}.customize-option.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1119px -197px;width:60px;height:60px}.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -273px;width:90px;height:90px}.customize-option.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -288px;width:60px;height:60px}.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1094px -364px;width:90px;height:90px}.customize-option.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1119px -379px;width:60px;height:60px}.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-1094px -455px;width:90px;height:90px}.customize-option.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-1119px -470px;width:60px;height:60px}.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -546px;width:90px;height:90px}.customize-option.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -561px;width:60px;height:60px}.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -637px;width:90px;height:90px}.customize-option.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -652px;width:60px;height:60px}.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-1094px -728px;width:90px;height:90px}.customize-option.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-1119px -743px;width:60px;height:60px}.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -819px;width:90px;height:90px}.customize-option.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -834px;width:60px;height:60px}.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-1094px -910px;width:90px;height:90px}.customize-option.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-1119px -925px;width:60px;height:60px}.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-1094px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-1119px -1016px;width:60px;height:60px}.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-25px -1107px;width:60px;height:60px}.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1107px;width:60px;height:60px}.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-207px -1107px;width:60px;height:60px}.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1107px;width:60px;height:60px}.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-389px -1107px;width:60px;height:60px}.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1107px;width:60px;height:60px}.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-571px -1107px;width:60px;height:60px}.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1107px;width:60px;height:60px}.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-753px -1107px;width:60px;height:60px}.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -1107px;width:60px;height:60px}.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-935px -1107px;width:60px;height:60px}.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1026px -1107px;width:60px;height:60px}.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1117px -1107px;width:60px;height:60px}.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1185px 0;width:90px;height:90px}.customize-option.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1210px -15px;width:60px;height:60px}.broad_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -91px;width:90px;height:90px}.broad_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1185px -182px;width:90px;height:90px}.broad_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -273px;width:90px;height:90px}.broad_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -364px;width:90px;height:90px}.broad_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1185px -455px;width:90px;height:90px}.broad_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -546px;width:90px;height:90px}.broad_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -637px;width:90px;height:90px}.broad_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -728px;width:90px;height:90px}.eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1185px -819px;width:90px;height:90px}.head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1185px -910px;width:90px;height:90px}.head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1185px -1001px;width:90px;height:90px}.head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1185px -1092px;width:90px;height:90px}.head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:0 -1183px;width:90px;height:90px}.head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-91px -1183px;width:90px;height:90px}.head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-182px -1183px;width:90px;height:90px}.head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-273px -1183px;width:90px;height:90px}.head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-364px -1183px;width:90px;height:90px}.head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-455px -1183px;width:90px;height:90px}.head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-546px -1183px;width:90px;height:90px}.head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-637px -1183px;width:90px;height:90px}.head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-728px -1183px;width:90px;height:90px}.head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-819px -1183px;width:90px;height:90px}.head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-910px -1183px;width:90px;height:90px}.head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1001px -1183px;width:90px;height:90px}.head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1092px -1183px;width:90px;height:90px}.shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1183px -1183px;width:90px;height:90px}.shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1276px 0;width:90px;height:90px}.shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1276px -91px;width:90px;height:90px}.shop_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -943px;width:40px;height:40px}.shop_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1640px -779px;width:40px;height:40px}.shop_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -738px;width:40px;height:40px}.shop_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -697px;width:40px;height:40px}.shop_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1640px -656px;width:40px;height:40px}.shop_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -533px;width:40px;height:40px}.shop_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -984px;width:40px;height:40px}.shop_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -492px;width:40px;height:40px}.shop_eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1640px -451px;width:40px;height:40px}.shop_head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -410px;width:40px;height:40px}.shop_head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -369px;width:40px;height:40px}.shop_head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1640px -328px;width:40px;height:40px}.shop_head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -287px;width:40px;height:40px}.shop_head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-1640px -246px;width:40px;height:40px}.shop_head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -205px;width:40px;height:40px}.shop_head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-1640px -164px;width:40px;height:40px}.shop_head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -123px;width:40px;height:40px}.shop_head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -82px;width:40px;height:40px}.shop_head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -41px;width:40px;height:40px}.shop_head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1640px 0;width:40px;height:40px}.shop_head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1576px -1588px;width:40px;height:40px}.shop_head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1535px -1588px;width:40px;height:40px}.shop_head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-1494px -1588px;width:40px;height:40px}.shop_head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1453px -1588px;width:40px;height:40px}.shop_head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1588px;width:40px;height:40px}.shop_shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1576px -1547px;width:40px;height:40px}.shop_shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1535px -1547px;width:40px;height:40px}.shop_shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1494px -1547px;width:40px;height:40px}.shop_weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-1453px -1547px;width:40px;height:40px}.shop_weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-1412px -1547px;width:40px;height:40px}.shop_weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-1371px -1547px;width:40px;height:40px}.shop_weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-1330px -1547px;width:40px;height:40px}.shop_weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-1289px -1547px;width:40px;height:40px}.shop_weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-1248px -1547px;width:40px;height:40px}.shop_weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-1207px -1547px;width:40px;height:40px}.shop_weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-1166px -1547px;width:40px;height:40px}.slim_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -819px;width:90px;height:90px}.slim_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1367px -910px;width:90px;height:90px}.slim_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -1001px;width:90px;height:90px}.slim_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -1092px;width:90px;height:90px}.slim_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1367px -1183px;width:90px;height:90px}.slim_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1367px -1274px;width:90px;height:90px}.slim_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:0 -1365px;width:90px;height:90px}.slim_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-91px -1365px;width:90px;height:90px}.weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1365px;width:90px;height:90px}.weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-273px -1365px;width:90px;height:90px}.weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-364px -1365px;width:90px;height:90px}.weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-455px -1365px;width:90px;height:90px}.weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-546px -1365px;width:90px;height:90px}.weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-637px -1365px;width:90px;height:90px}.weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-728px -1365px;width:90px;height:90px}.weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-819px -1365px;width:90px;height:90px}.broad_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-910px -1365px;width:90px;height:90px}.broad_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1365px;width:90px;height:90px}.broad_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1092px -1365px;width:90px;height:90px}.broad_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1183px -1365px;width:90px;height:90px}.broad_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1365px;width:90px;height:90px}.broad_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1365px -1365px;width:90px;height:90px}.broad_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1458px 0;width:90px;height:90px}.broad_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -91px;width:90px;height:90px}.broad_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -182px;width:90px;height:90px}.broad_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -273px;width:90px;height:90px}.broad_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -364px;width:90px;height:90px}.broad_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1458px -455px;width:90px;height:90px}.broad_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -546px;width:90px;height:90px}.broad_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -637px;width:90px;height:90px}.broad_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -728px;width:90px;height:90px}.broad_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -819px;width:90px;height:90px}.broad_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -910px;width:90px;height:90px}.broad_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -1001px;width:90px;height:90px}.broad_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -1092px;width:90px;height:90px}.broad_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -1183px;width:90px;height:90px}.broad_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -1274px;width:90px;height:90px}.broad_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -1365px;width:90px;height:90px}.shop_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1125px -1547px;width:40px;height:40px}.shop_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1084px -1547px;width:40px;height:40px}.shop_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1043px -1547px;width:40px;height:40px}.shop_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1002px -1547px;width:40px;height:40px}.shop_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-961px -1547px;width:40px;height:40px}.shop_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-920px -1547px;width:40px;height:40px}.shop_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-879px -1547px;width:40px;height:40px}.shop_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-838px -1547px;width:40px;height:40px}.shop_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-797px -1547px;width:40px;height:40px}.shop_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-756px -1547px;width:40px;height:40px}.shop_armor_special_0{background-image:url(spritesmith-main-3.png);background-position:-715px -1547px;width:40px;height:40px}.shop_armor_special_1{background-image:url(spritesmith-main-3.png);background-position:-674px -1547px;width:40px;height:40px}.shop_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-551px -1547px;width:40px;height:40px}.shop_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-510px -1547px;width:40px;height:40px}.shop_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-469px -1547px;width:40px;height:40px}.shop_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-428px -1547px;width:40px;height:40px}.shop_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-387px -1547px;width:40px;height:40px}.shop_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-346px -1547px;width:40px;height:40px}.shop_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-305px -1547px;width:40px;height:40px}.shop_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-264px -1547px;width:40px;height:40px}.shop_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-223px -1547px;width:40px;height:40px}.shop_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-182px -1547px;width:40px;height:40px}.shop_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-633px -1588px;width:40px;height:40px}.shop_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-400px -314px;width:40px;height:40px}.slim_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -637px;width:90px;height:90px}.slim_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -728px;width:90px;height:90px}.slim_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -819px;width:90px;height:90px}.slim_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -910px;width:90px;height:90px}.slim_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -1001px;width:90px;height:90px}.slim_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -1092px;width:90px;height:90px}.slim_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -1183px;width:90px;height:90px}.slim_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -1274px;width:90px;height:90px}.slim_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -1365px;width:90px;height:90px}.slim_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -1456px;width:90px;height:90px}.slim_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:0 -1547px;width:90px;height:90px}.slim_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-91px -1547px;width:90px;height:90px}.slim_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -546px;width:90px;height:90px}.slim_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -455px;width:90px;height:90px}.slim_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -364px;width:90px;height:90px}.slim_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -273px;width:90px;height:90px}.slim_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -182px;width:90px;height:90px}.slim_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -91px;width:90px;height:90px}.slim_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1549px 0;width:90px;height:90px}.slim_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1456px -1456px;width:90px;height:90px}.slim_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1365px -1456px;width:90px;height:90px}.slim_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1456px;width:90px;height:90px}.broad_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1183px -1456px;width:90px;height:90px}.broad_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-1092px -1456px;width:90px;height:90px}.shop_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-592px -1547px;width:40px;height:40px}.shop_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-633px -1547px;width:40px;height:40px}.slim_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1001px -1456px;width:90px;height:90px}.slim_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-910px -1456px;width:90px;height:90px}.broad_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-212px -273px;width:93px;height:90px}.broad_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -273px;width:105px;height:90px}.broad_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-637px -1456px;width:90px;height:90px}.broad_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-546px -1456px;width:90px;height:90px}.broad_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-455px -1456px;width:90px;height:90px}.broad_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px -91px;width:120px;height:90px}.broad_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px -182px;width:105px;height:90px}.broad_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-182px -1456px;width:90px;height:90px}.head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-306px -273px;width:93px;height:90px}.head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-348px -91px;width:105px;height:90px}.head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1367px -728px;width:90px;height:90px}.head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1367px -637px;width:90px;height:90px}.head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1367px -546px;width:90px;height:90px}.head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 -91px;width:120px;height:90px}.head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-212px -182px;width:105px;height:90px}.head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1367px -273px;width:90px;height:90px}.shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px 0;width:93px;height:90px}.shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:0 -273px;width:105px;height:90px}.shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1367px 0;width:90px;height:90px}.shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1274px -1274px;width:90px;height:90px}.shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:0 -182px;width:105px;height:90px}.shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1092px -1274px;width:90px;height:90px}.shop_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-223px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-264px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-305px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-346px -1588px;width:40px;height:40px}.shop_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-387px -1588px;width:40px;height:40px}.shop_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-428px -1588px;width:40px;height:40px}.shop_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-469px -1588px;width:40px;height:40px}.shop_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-510px -1588px;width:40px;height:40px}.shop_head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-551px -1588px;width:40px;height:40px}.shop_head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-592px -1588px;width:40px;height:40px}.shop_head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-400px -273px;width:40px;height:40px}.shop_head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-674px -1588px;width:40px;height:40px}.shop_head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-715px -1588px;width:40px;height:40px}.shop_head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-756px -1588px;width:40px;height:40px}.shop_head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-797px -1588px;width:40px;height:40px}.shop_head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-838px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-879px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-920px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-961px -1588px;width:40px;height:40px}.shop_shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1002px -1588px;width:40px;height:40px}.shop_shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1043px -1588px;width:40px;height:40px}.shop_shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1084px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-1125px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-1166px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1207px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1248px -1588px;width:40px;height:40px}.shop_weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1289px -1588px;width:40px;height:40px}.shop_weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-1330px -1588px;width:40px;height:40px}.shop_weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1371px -1588px;width:40px;height:40px}.shop_weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1412px -1588px;width:40px;height:40px}.slim_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px -91px;width:93px;height:90px}.slim_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-242px -91px;width:105px;height:90px}.slim_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-819px -1274px;width:90px;height:90px}.slim_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-728px -1274px;width:90px;height:90px}.slim_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-637px -1274px;width:90px;height:90px}.slim_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 0;width:120px;height:90px}.slim_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px 0;width:105px;height:90px}.slim_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-364px -1274px;width:90px;height:90px}.weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px -182px;width:93px;height:90px}.weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -182px;width:105px;height:90px}.weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-91px -1274px;width:90px;height:90px}.weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:0 -1274px;width:90px;height:90px}.weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1276px -1183px;width:90px;height:90px}.weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px 0;width:120px;height:90px}.weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-242px 0;width:105px;height:90px}.weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1276px -910px;width:90px;height:90px}.broad_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -819px;width:90px;height:90px}.head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -637px;width:90px;height:90px}.shop_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1640px -574px;width:40px;height:40px}.shop_head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1640px -615px;width:40px;height:40px}.slim_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -546px;width:90px;height:90px}.back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -455px;width:90px;height:90px}.broad_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -364px;width:90px;height:90px}.head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -273px;width:90px;height:90px}.shop_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -820px;width:40px;height:40px}.shop_back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -861px;width:40px;height:40px}.shop_head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -902px;width:40px;height:40px}.slim_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -182px;width:90px;height:90px}.broad_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1276px -1001px;width:90px;height:90px}.headAccessory_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-739px -309px;width:90px;height:90px}.shop_armor_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1018px -910px;width:40px;height:40px}.shop_headAccessory_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1646px -820px;width:40px;height:40px}.slim_armor_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-461px -788px;width:90px;height:90px}.back_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-546px -1152px;width:90px;height:90px}.headAccessory_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1183px -1243px;width:90px;height:90px}.shop_back_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1646px -779px;width:40px;height:40px}.shop_headAccessory_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1646px -738px;width:40px;height:40px}.broad_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -364px;width:90px;height:90px}.head_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -455px;width:90px;height:90px}.shop_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-82px -1598px;width:40px;height:40px}.shop_head_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-41px -1598px;width:40px;height:40px}.slim_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -819px;width:90px;height:90px}.broad_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-739px -212px;width:90px;height:96px}.head_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-830px -188px;width:90px;height:96px}.shop_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:0 -1598px;width:40px;height:40px}.shop_head_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-1605px -1517px;width:40px;height:40px}.slim_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-830px -91px;width:90px;height:96px}.broad_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-552px -788px;width:90px;height:90px}.head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-825px -788px;width:90px;height:90px}.shop_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -1476px;width:40px;height:40px}.shop_head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -1435px;width:40px;height:40px}.slim_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:0 -879px;width:90px;height:90px}.broad_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-91px -879px;width:90px;height:90px}.head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1200px -910px;width:90px;height:90px}.shop_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1605px -1394px;width:40px;height:40px}.shop_head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1605px -1353px;width:40px;height:40px}.slim_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1291px -273px;width:90px;height:90px}.broad_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1001px -1243px;width:90px;height:90px}.headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1092px -1243px;width:90px;height:90px}.shop_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1605px -1312px;width:40px;height:40px}.shop_headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1605px -1271px;width:40px;height:40px}.slim_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1382px -182px;width:90px;height:90px}.back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -649px;width:93px;height:90px}.broad_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -558px;width:93px;height:90px}.shop_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1605px -1230px;width:40px;height:40px}.shop_back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1605px -1189px;width:40px;height:40px}.slim_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -376px;width:93px;height:90px}.head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -728px;width:90px;height:90px}.shop_head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1605px -1148px;width:40px;height:40px}.shop_weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1605px -1107px;width:40px;height:40px}.weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -1183px;width:90px;height:90px}.broad_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:0 -1334px;width:90px;height:90px}.head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-91px -1334px;width:90px;height:90px}.shop_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1605px -1066px;width:40px;height:40px}.shop_head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1605px -1025px;width:40px;height:40px}.slim_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-364px -1334px;width:90px;height:90px}.broad_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-455px -1334px;width:90px;height:90px}.head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-546px -1334px;width:90px;height:90px}.shop_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1605px -984px;width:40px;height:40px}.shop_head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1605px -943px;width:40px;height:40px}.slim_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-910px -1334px;width:90px;height:90px}.headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1001px -1334px;width:90px;height:90px}.shop_headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1605px -902px;width:40px;height:40px}.shop_weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1605px -861px;width:40px;height:40px}.weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1274px -1334px;width:90px;height:90px}.broad_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1473px -728px;width:90px;height:90px}.eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1473px -1274px;width:90px;height:90px}.shop_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1605px -492px;width:40px;height:40px}.shop_eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1605px -451px;width:40px;height:40px}.slim_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-182px -1425px;width:90px;height:90px}.back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-273px -1425px;width:90px;height:90px}.broad_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-364px -1425px;width:90px;height:90px}.shop_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1605px -410px;width:40px;height:40px}.shop_back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1605px -369px;width:40px;height:40px}.slim_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-819px -1425px;width:90px;height:90px}.head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-910px -1425px;width:90px;height:90px}.shop_head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1605px -328px;width:40px;height:40px}.shop_weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1605px -287px;width:40px;height:40px}.weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-739px -400px;width:90px;height:90px}.broad_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-546px -485px;width:90px;height:105px}.eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-648px -106px;width:90px;height:105px}.shop_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1605px -246px;width:40px;height:40px}.shop_eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1605px -205px;width:40px;height:40px}.slim_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-648px -318px;width:90px;height:105px}.back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-648px -424px;width:90px;height:105px}.eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:0 -591px;width:90px;height:105px}.shop_back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-779px -1557px;width:40px;height:40px}.shop_eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-738px -1557px;width:40px;height:40px}.broad_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:0 -788px;width:93px;height:90px}.head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -467px;width:93px;height:90px}.shop_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-697px -1557px;width:40px;height:40px}.shop_head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-656px -1557px;width:40px;height:40px}.slim_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -285px;width:93px;height:90px}.broad_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -364px;width:90px;height:90px}.head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -455px;width:90px;height:90px}.shop_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-615px -1557px;width:40px;height:40px}.shop_head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1473px -1365px;width:40px;height:40px}.slim_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -728px;width:90px;height:90px}.back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-536px -394px;width:105px;height:90px}.headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-94px -788px;width:93px;height:90px}.shop_back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-533px -1557px;width:40px;height:40px}.shop_headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-492px -1557px;width:40px;height:40px}.broad_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-364px -879px;width:90px;height:90px}.eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-455px -879px;width:90px;height:90px}.head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-546px -879px;width:90px;height:90px}.shop_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-451px -1557px;width:40px;height:40px}.shop_eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-410px -1557px;width:40px;height:40px}.shop_head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-369px -1557px;width:40px;height:40px}.shop_weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-328px -1557px;width:40px;height:40px}.slim_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1018px 0;width:90px;height:90px}.weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1018px -91px;width:90px;height:90px}.eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -182px;width:90px;height:90px}.headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -273px;width:90px;height:90px}.head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -364px;width:90px;height:90px}.shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -455px;width:90px;height:90px}.shop_eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-287px -1557px;width:40px;height:40px}.shop_headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-246px -1557px;width:40px;height:40px}.shop_head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-205px -1557px;width:40px;height:40px}.shop_shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-164px -1557px;width:40px;height:40px}.broad_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:0 -970px;width:90px;height:90px}.broad_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-91px -970px;width:90px;height:90px}.broad_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-182px -970px;width:90px;height:90px}.broad_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -970px;width:90px;height:90px}.broad_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -970px;width:90px;height:90px}.broad_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-455px -970px;width:90px;height:90px}.broad_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-546px -970px;width:90px;height:90px}.broad_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-637px -970px;width:90px;height:90px}.headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -970px;width:90px;height:90px}.headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -970px;width:90px;height:90px}.headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-910px -970px;width:90px;height:90px}.headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1001px -970px;width:90px;height:90px}.headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1109px 0;width:90px;height:90px}.headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1109px -91px;width:90px;height:90px}.headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -182px;width:90px;height:90px}.headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -273px;width:90px;height:90px}.head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1109px -364px;width:90px;height:90px}.head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1109px -455px;width:90px;height:90px}.head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -546px;width:90px;height:90px}.head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -637px;width:90px;height:90px}.head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1109px -728px;width:90px;height:90px}.head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1109px -819px;width:90px;height:90px}.head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -910px;width:90px;height:90px}.head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:0 -1061px;width:90px;height:90px}.shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1061px;width:90px;height:90px}.shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-182px -1061px;width:90px;height:90px}.shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -1061px;width:90px;height:90px}.shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -1061px;width:90px;height:90px}.shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-455px -1061px;width:90px;height:90px}.shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-546px -1061px;width:90px;height:90px}.shop_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-123px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-82px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-41px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -1557px;width:40px;height:40px}.shop_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1476px;width:40px;height:40px}.shop_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -1435px;width:40px;height:40px}.shop_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1394px;width:40px;height:40px}.shop_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1066px;width:40px;height:40px}.shop_headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1025px;width:40px;height:40px}.shop_headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -984px;width:40px;height:40px}.shop_headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -943px;width:40px;height:40px}.shop_headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -902px;width:40px;height:40px}.shop_headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -861px;width:40px;height:40px}.shop_headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -820px;width:40px;height:40px}.shop_headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -779px;width:40px;height:40px}.shop_headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -738px;width:40px;height:40px}.shop_head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -697px;width:40px;height:40px}.shop_head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -656px;width:40px;height:40px}.shop_head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -615px;width:40px;height:40px}.shop_head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -574px;width:40px;height:40px}.shop_head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -533px;width:40px;height:40px}.shop_head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -492px;width:40px;height:40px}.shop_head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -451px;width:40px;height:40px}.shop_head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -410px;width:40px;height:40px}.shop_shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -369px;width:40px;height:40px}.shop_shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -328px;width:40px;height:40px}.shop_shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -287px;width:40px;height:40px}.shop_shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -246px;width:40px;height:40px}.shop_shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -205px;width:40px;height:40px}.shop_shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -164px;width:40px;height:40px}.shop_weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-369px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-328px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-287px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-246px -1516px;width:40px;height:40px}.shop_weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-205px -1516px;width:40px;height:40px}.shop_weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-164px -1516px;width:40px;height:40px}.shop_weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-123px -1516px;width:40px;height:40px}.shop_weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-82px -1516px;width:40px;height:40px}.slim_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -546px;width:90px;height:90px}.slim_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1291px -637px;width:90px;height:90px}.slim_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -728px;width:90px;height:90px}.slim_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -819px;width:90px;height:90px}.slim_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1291px -910px;width:90px;height:90px}.slim_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1291px -1001px;width:90px;height:90px}.slim_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -1092px;width:90px;height:90px}.slim_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:0 -1243px;width:90px;height:90px}.weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1243px;width:90px;height:90px}.weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-182px -1243px;width:90px;height:90px}.weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-273px -1243px;width:90px;height:90px}.weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-364px -1243px;width:90px;height:90px}.weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -1243px;width:90px;height:90px}.weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-546px -1243px;width:90px;height:90px}.weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-637px -1243px;width:90px;height:90px}.weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-728px -1243px;width:90px;height:90px}.body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-819px -1243px;width:90px;height:90px}.body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-910px -1243px;width:90px;height:90px}.body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px -106px;width:102px;height:105px}.body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -485px;width:90px;height:105px}.body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -485px;width:90px;height:105px}.body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-455px -485px;width:90px;height:105px}.broad_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px 0;width:90px;height:90px}.broad_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1382px -91px;width:90px;height:90px}.broad_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 -106px;width:102px;height:105px}.broad_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-648px 0;width:90px;height:105px}.broad_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-182px -591px;width:90px;height:105px}.broad_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -212px;width:90px;height:105px}.broad_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-424px -182px;width:111px;height:90px}.broad_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -273px;width:111px;height:90px}.eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -394px;width:111px;height:90px}.eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-112px -394px;width:111px;height:90px}.head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px -910px;width:90px;height:90px}.head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1382px -1001px;width:90px;height:90px}.head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px 0;width:102px;height:105px}.head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -591px;width:90px;height:105px}.head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -591px;width:90px;height:105px}.head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-455px -591px;width:90px;height:105px}.head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-336px -394px;width:111px;height:90px}.head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-536px 0;width:111px;height:90px}.Healer_Summer{background-image:url(spritesmith-main-4.png);background-position:-637px -591px;width:90px;height:105px}.Mage_Summer{background-image:url(spritesmith-main-4.png);background-position:-739px 0;width:90px;height:105px}.SummerRogue14{background-image:url(spritesmith-main-4.png);background-position:-424px 0;width:111px;height:90px}.SummerWarrior14{background-image:url(spritesmith-main-4.png);background-position:-536px -91px;width:111px;height:90px}.shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1334px;width:90px;height:90px}.shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 0;width:102px;height:105px}.shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -485px;width:90px;height:105px}.shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-536px -288px;width:90px;height:105px}.shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -303px;width:111px;height:90px}.shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -182px;width:111px;height:90px}.shop_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-41px -1516px;width:40px;height:40px}.shop_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -1516px;width:40px;height:40px}.shop_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1488px -1466px;width:40px;height:40px}.shop_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1447px -1466px;width:40px;height:40px}.shop_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1406px -1466px;width:40px;height:40px}.shop_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1466px;width:40px;height:40px}.shop_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1488px -1425px;width:40px;height:40px}.shop_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1447px -1425px;width:40px;height:40px}.shop_body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1406px -1425px;width:40px;height:40px}.shop_body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1425px;width:40px;height:40px}.shop_body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-489px -435px;width:40px;height:40px}.shop_body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-448px -435px;width:40px;height:40px}.shop_body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-489px -394px;width:40px;height:40px}.shop_body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-448px -394px;width:40px;height:40px}.shop_eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-377px -344px;width:40px;height:40px}.shop_eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-336px -344px;width:40px;height:40px}.shop_head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-377px -303px;width:40px;height:40px}.shop_head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-336px -303px;width:40px;height:40px}.shop_head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-230px -253px;width:40px;height:40px}.shop_head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-230px -212px;width:40px;height:40px}.shop_head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-689px -530px;width:40px;height:40px}.shop_head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -530px;width:40px;height:40px}.shop_head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-871px -740px;width:40px;height:40px}.shop_head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-830px -740px;width:40px;height:40px}.shop_shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-968px -819px;width:40px;height:40px}.shop_shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-927px -819px;width:40px;height:40px}.shop_shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1059px -910px;width:40px;height:40px}.shop_shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1646px -861px;width:40px;height:40px}.shop_shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1150px -1001px;width:40px;height:40px}.shop_shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -1001px;width:40px;height:40px}.shop_weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1241px -1092px;width:40px;height:40px}.shop_weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1092px;width:40px;height:40px}.shop_weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1514px -1365px;width:40px;height:40px}.shop_weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-574px -1557px;width:40px;height:40px}.shop_weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1382px -1274px;width:40px;height:40px}.shop_weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1423px -1274px;width:40px;height:40px}.shop_weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -1183px;width:40px;height:40px}.shop_weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1332px -1183px;width:40px;height:40px}.slim_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1274px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1183px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px 0;width:102px;height:105px}.slim_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -591px;width:90px;height:105px}.slim_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-739px -106px;width:90px;height:105px}.slim_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-536px -182px;width:90px;height:105px}.slim_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -303px;width:111px;height:90px}.slim_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -91px;width:111px;height:90px}.weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-546px -1425px;width:90px;height:90px}.weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-455px -1425px;width:90px;height:90px}.weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px -106px;width:102px;height:105px}.weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-91px -485px;width:90px;height:105px}.weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-182px -485px;width:90px;height:105px}.weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-91px -591px;width:90px;height:105px}.weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -394px;width:111px;height:90px}.weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -91px;width:111px;height:90px}.broad_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -1183px;width:90px;height:90px}.broad_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -1092px;width:90px;height:90px}.broad_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -1001px;width:90px;height:90px}.broad_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px -910px;width:90px;height:90px}.broad_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1473px -819px;width:90px;height:90px}.broad_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-507px -697px;width:96px;height:90px}.broad_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1473px -637px;width:90px;height:90px}.broad_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1473px -546px;width:90px;height:90px}.head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -455px;width:90px;height:90px}.head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-1473px -364px;width:90px;height:90px}.head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-1473px -273px;width:90px;height:90px}.head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -182px;width:90px;height:90px}.head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -91px;width:90px;height:90px}.head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px 0;width:90px;height:90px}.head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1334px;width:90px;height:90px}.head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-410px -697px;width:96px;height:90px}.head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -455px;width:90px;height:90px}.head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1291px -364px;width:90px;height:90px}.shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -697px;width:104px;height:90px}.shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1291px -182px;width:90px;height:90px}.shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -91px;width:90px;height:90px}.shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-604px -697px;width:96px;height:90px}.shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1183px -1152px;width:90px;height:90px}.shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1092px -1152px;width:90px;height:90px}.shop_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-410px -1516px;width:40px;height:40px}.shop_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-451px -1516px;width:40px;height:40px}.shop_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-492px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-533px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-574px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-615px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-656px -1516px;width:40px;height:40px}.shop_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-697px -1516px;width:40px;height:40px}.shop_head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-738px -1516px;width:40px;height:40px}.shop_head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-779px -1516px;width:40px;height:40px}.shop_head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-820px -1516px;width:40px;height:40px}.shop_head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-861px -1516px;width:40px;height:40px}.shop_head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-902px -1516px;width:40px;height:40px}.shop_head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-943px -1516px;width:40px;height:40px}.shop_head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-984px -1516px;width:40px;height:40px}.shop_head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1025px -1516px;width:40px;height:40px}.shop_head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1066px -1516px;width:40px;height:40px}.shop_head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1107px -1516px;width:40px;height:40px}.shop_shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1148px -1516px;width:40px;height:40px}.shop_shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1189px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1230px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1271px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1312px -1516px;width:40px;height:40px}.shop_shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1353px -1516px;width:40px;height:40px}.shop_weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1394px -1516px;width:40px;height:40px}.shop_weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1435px -1516px;width:40px;height:40px}.shop_weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1476px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1517px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px 0;width:40px;height:40px}.shop_weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -41px;width:40px;height:40px}.shop_weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -82px;width:40px;height:40px}.shop_weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1564px -123px;width:40px;height:40px}.slim_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1001px -1152px;width:90px;height:90px}.slim_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-910px -1152px;width:90px;height:90px}.slim_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-819px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-637px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-701px -697px;width:96px;height:90px}.slim_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-455px -1152px;width:90px;height:90px}.slim_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-364px -1152px;width:90px;height:90px}.weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-273px -1152px;width:90px;height:90px}.weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-182px -1152px;width:90px;height:90px}.weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-91px -1152px;width:90px;height:90px}.weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:0 -1152px;width:90px;height:90px}.weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1001px;width:90px;height:90px}.weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-313px -697px;width:96px;height:90px}.weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1200px -819px;width:90px;height:90px}.weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1200px -728px;width:90px;height:90px}.back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -637px;width:90px;height:90px}.back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -546px;width:90px;height:90px}.body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -455px;width:90px;height:90px}.body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1200px -364px;width:90px;height:90px}.body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -273px;width:90px;height:90px}.eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -182px;width:90px;height:90px}.eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -91px;width:90px;height:90px}.shop_back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1107px;width:40px;height:40px}.shop_back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1148px;width:40px;height:40px}.shop_body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1189px;width:40px;height:40px}.shop_body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1564px -1230px;width:40px;height:40px}.shop_body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1271px;width:40px;height:40px}.shop_eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1312px;width:40px;height:40px}.shop_eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1353px;width:40px;height:40px}.head_0{background-image:url(spritesmith-main-4.png);background-position:-1200px 0;width:90px;height:90px}.customize-option.head_0{background-image:url(spritesmith-main-4.png);background-position:-1225px -15px;width:60px;height:60px}.head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1092px -1061px;width:90px;height:90px}.head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1001px -1061px;width:90px;height:90px}.head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-910px -1061px;width:90px;height:90px}.head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-819px -1061px;width:90px;height:90px}.head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-728px -1061px;width:90px;height:90px}.head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-637px -1061px;width:90px;height:90px}.head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1018px -819px;width:90px;height:90px}.head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1018px -728px;width:90px;height:90px}.head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1018px -637px;width:90px;height:90px}.head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1018px -546px;width:90px;height:90px}.head_special_2{background-image:url(spritesmith-main-4.png);background-position:-910px -879px;width:90px;height:90px}.head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-819px -879px;width:90px;height:90px}.head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-728px -879px;width:90px;height:90px}.head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-637px -879px;width:90px;height:90px}.head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-273px -879px;width:90px;height:90px}.head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-182px -879px;width:90px;height:90px}.head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-927px -637px;width:90px;height:90px}.head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-927px -546px;width:90px;height:90px}.head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-927px -182px;width:90px;height:90px}.head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-927px -91px;width:90px;height:90px}.head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-734px -788px;width:90px;height:90px}.head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-643px -788px;width:90px;height:90px}.shop_head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-820px -1557px;width:40px;height:40px}.shop_head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-861px -1557px;width:40px;height:40px}.shop_head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-902px -1557px;width:40px;height:40px}.shop_head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-943px -1557px;width:40px;height:40px}.shop_head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-984px -1557px;width:40px;height:40px}.shop_head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1025px -1557px;width:40px;height:40px}.shop_head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1066px -1557px;width:40px;height:40px}.shop_head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1107px -1557px;width:40px;height:40px}.shop_head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1148px -1557px;width:40px;height:40px}.shop_head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1189px -1557px;width:40px;height:40px}.shop_head_special_0{background-image:url(spritesmith-main-4.png);background-position:-1230px -1557px;width:40px;height:40px}.shop_head_special_1{background-image:url(spritesmith-main-4.png);background-position:-1271px -1557px;width:40px;height:40px}.shop_head_special_2{background-image:url(spritesmith-main-4.png);background-position:-1312px -1557px;width:40px;height:40px}.shop_head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-1353px -1557px;width:40px;height:40px}.shop_head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1394px -1557px;width:40px;height:40px}.shop_head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1435px -1557px;width:40px;height:40px}.shop_head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1476px -1557px;width:40px;height:40px}.shop_head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1517px -1557px;width:40px;height:40px}.shop_head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1558px -1557px;width:40px;height:40px}.shop_head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1605px 0;width:40px;height:40px}.shop_head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1605px -41px;width:40px;height:40px}.shop_head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1605px -82px;width:40px;height:40px}.shop_head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1605px -123px;width:40px;height:40px}.shop_head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1605px -164px;width:40px;height:40px}.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-279px -788px;width:90px;height:90px}.customize-option.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-304px -803px;width:60px;height:60px}.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-188px -788px;width:90px;height:90px}.customize-option.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-213px -803px;width:60px;height:60px}.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1092px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1117px -1440px;width:60px;height:60px}.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1001px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1026px -1440px;width:60px;height:60px}.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-728px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-753px -1440px;width:60px;height:60px}.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-637px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-662px -1440px;width:60px;height:60px}.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-91px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-116px -1440px;width:60px;height:60px}.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:0 -1425px;width:90px;height:90px}.customize-option.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-25px -1440px;width:60px;height:60px}.shop_headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -533px;width:40px;height:40px}.shop_headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -574px;width:40px;height:40px}.shop_headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -615px;width:40px;height:40px}.shop_headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -656px;width:40px;height:40px}.shop_headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -697px;width:40px;height:40px}.shop_headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -738px;width:40px;height:40px}.shop_headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -779px;width:40px;height:40px}.shop_headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -820px;width:40px;height:40px}.shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1183px -1334px;width:90px;height:90px}.shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1092px -1334px;width:90px;height:90px}.shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-819px -1334px;width:90px;height:90px}.shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-637px -1334px;width:90px;height:90px}.shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-273px -1334px;width:90px;height:90px}.shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-182px -1334px;width:90px;height:90px}.shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-105px -697px;width:103px;height:90px}.shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-209px -697px;width:103px;height:90px}.shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:0 -212px;width:114px;height:90px}.shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-830px 0;width:96px;height:90px}.shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-115px -212px;width:114px;height:90px}.shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-309px 0;width:114px;height:90px}.shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-1291px 0;width:90px;height:90px}.shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-112px -303px;width:111px;height:90px}.shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-927px -273px;width:90px;height:90px}.shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-927px 0;width:90px;height:90px}.shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-370px -788px;width:90px;height:90px}.shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-739px -582px;width:90px;height:90px}.shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1382px -637px;width:90px;height:90px}.shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1382px -546px;width:90px;height:90px}.shop_shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-123px -1598px;width:40px;height:40px}.shop_shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-164px -1598px;width:40px;height:40px}.shop_shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-205px -1598px;width:40px;height:40px}.shop_shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-246px -1598px;width:40px;height:40px}.shop_shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-287px -1598px;width:40px;height:40px}.shop_shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-328px -1598px;width:40px;height:40px}.shop_shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-369px -1598px;width:40px;height:40px}.shop_shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-410px -1598px;width:40px;height:40px}.shop_shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-451px -1598px;width:40px;height:40px}.shop_shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-492px -1598px;width:40px;height:40px}.shop_shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-533px -1598px;width:40px;height:40px}.shop_shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-574px -1598px;width:40px;height:40px}.shop_shield_special_0{background-image:url(spritesmith-main-4.png);background-position:-615px -1598px;width:40px;height:40px}.shop_shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-656px -1598px;width:40px;height:40px}.shop_shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-697px -1598px;width:40px;height:40px}.shop_shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-738px -1598px;width:40px;height:40px}.shop_shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-779px -1598px;width:40px;height:40px}.shop_shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-820px -1598px;width:40px;height:40px}.shop_shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-861px -1598px;width:40px;height:40px}.shop_shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-902px -1598px;width:40px;height:40px}.shop_shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-943px -1598px;width:40px;height:40px}.shop_weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-984px -1598px;width:40px;height:40px}.shop_weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1025px -1598px;width:40px;height:40px}.shop_weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1066px -1598px;width:40px;height:40px}.shop_weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1107px -1598px;width:40px;height:40px}.shop_weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1148px -1598px;width:40px;height:40px}.shop_weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-1189px -1598px;width:40px;height:40px}.shop_weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-1230px -1598px;width:40px;height:40px}.shop_weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-1271px -1598px;width:40px;height:40px}.shop_weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1312px -1598px;width:40px;height:40px}.shop_weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1353px -1598px;width:40px;height:40px}.shop_weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1394px -1598px;width:40px;height:40px}.shop_weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1435px -1598px;width:40px;height:40px}.shop_weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1476px -1598px;width:40px;height:40px}.shop_weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1517px -1598px;width:40px;height:40px}.shop_weapon_special_0{background-image:url(spritesmith-main-4.png);background-position:-1558px -1598px;width:40px;height:40px}.shop_weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-1599px -1598px;width:40px;height:40px}.shop_weapon_special_2{background-image:url(spritesmith-main-4.png);background-position:-1646px 0;width:40px;height:40px}.shop_weapon_special_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -41px;width:40px;height:40px}.shop_weapon_special_critical{background-image:url(spritesmith-main-4.png);background-position:-1646px -82px;width:40px;height:40px}.shop_weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-4.png);background-position:-1646px -123px;width:40px;height:40px}.shop_weapon_warrior_0{background-image:url(spritesmith-main-4.png);background-position:-1646px -164px;width:40px;height:40px}.shop_weapon_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -205px;width:40px;height:40px}.shop_weapon_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -246px;width:40px;height:40px}.shop_weapon_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -287px;width:40px;height:40px}.shop_weapon_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -328px;width:40px;height:40px}.shop_weapon_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -369px;width:40px;height:40px}.shop_weapon_warrior_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -410px;width:40px;height:40px}.shop_weapon_wizard_0{background-image:url(spritesmith-main-4.png);background-position:-1646px -451px;width:40px;height:40px}.shop_weapon_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -492px;width:40px;height:40px}.shop_weapon_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -533px;width:40px;height:40px}.shop_weapon_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -574px;width:40px;height:40px}.shop_weapon_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -615px;width:40px;height:40px}.shop_weapon_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -656px;width:40px;height:40px}.shop_weapon_wizard_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -697px;width:40px;height:40px}.weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-1382px -273px;width:90px;height:90px}.weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1274px -1243px;width:90px;height:90px}.weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-739px -491px;width:90px;height:90px}.weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1382px -1092px;width:90px;height:90px}.weapon_healer_4{background-image:url(spritesmith-main-5.png);background-position:-728px -1523px;width:90px;height:90px}.weapon_healer_5{background-image:url(spritesmith-main-5.png);background-position:-1274px -1523px;width:90px;height:90px}.weapon_healer_6{background-image:url(spritesmith-main-5.png);background-position:0 -1523px;width:90px;height:90px}.weapon_rogue_0{background-image:url(spritesmith-main-5.png);background-position:-91px -1523px;width:90px;height:90px}.weapon_rogue_1{background-image:url(spritesmith-main-5.png);background-position:-182px -1523px;width:90px;height:90px}.weapon_rogue_2{background-image:url(spritesmith-main-5.png);background-position:-273px -1523px;width:90px;height:90px}.weapon_rogue_3{background-image:url(spritesmith-main-5.png);background-position:-364px -1523px;width:90px;height:90px}.weapon_rogue_4{background-image:url(spritesmith-main-5.png);background-position:-455px -1523px;width:90px;height:90px}.weapon_rogue_5{background-image:url(spritesmith-main-5.png);background-position:-546px -1523px;width:90px;height:90px}.weapon_rogue_6{background-image:url(spritesmith-main-5.png);background-position:-637px -1523px;width:90px;height:90px}.weapon_special_1{background-image:url(spritesmith-main-5.png);background-position:-485px -1402px;width:102px;height:90px}.weapon_special_2{background-image:url(spritesmith-main-5.png);background-position:-819px -1523px;width:90px;height:90px}.weapon_special_3{background-image:url(spritesmith-main-5.png);background-position:-910px -1523px;width:90px;height:90px}.weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-5.png);background-position:-1001px -1523px;width:90px;height:90px}.weapon_warrior_0{background-image:url(spritesmith-main-5.png);background-position:-1092px -1523px;width:90px;height:90px}.weapon_warrior_1{background-image:url(spritesmith-main-5.png);background-position:-1183px -1523px;width:90px;height:90px}.weapon_warrior_2{background-image:url(spritesmith-main-5.png);background-position:-1536px 0;width:90px;height:90px}.weapon_warrior_3{background-image:url(spritesmith-main-5.png);background-position:-1536px -182px;width:90px;height:90px}.weapon_warrior_4{background-image:url(spritesmith-main-5.png);background-position:-1536px -455px;width:90px;height:90px}.weapon_warrior_5{background-image:url(spritesmith-main-5.png);background-position:-1536px -546px;width:90px;height:90px}.weapon_warrior_6{background-image:url(spritesmith-main-5.png);background-position:-1536px -637px;width:90px;height:90px}.weapon_wizard_0{background-image:url(spritesmith-main-5.png);background-position:-1536px -728px;width:90px;height:90px}.weapon_wizard_1{background-image:url(spritesmith-main-5.png);background-position:-1536px -819px;width:90px;height:90px}.weapon_wizard_2{background-image:url(spritesmith-main-5.png);background-position:-1536px -910px;width:90px;height:90px}.weapon_wizard_3{background-image:url(spritesmith-main-5.png);background-position:-1536px -1001px;width:90px;height:90px}.weapon_wizard_4{background-image:url(spritesmith-main-5.png);background-position:-1536px -1092px;width:90px;height:90px}.weapon_wizard_5{background-image:url(spritesmith-main-5.png);background-position:-1536px -1183px;width:90px;height:90px}.weapon_wizard_6{background-image:url(spritesmith-main-5.png);background-position:-1536px -1274px;width:90px;height:90px}.GrimReaper{background-image:url(spritesmith-main-5.png);background-position:-1536px -1456px;width:57px;height:66px}.Pet_Currency_Gem{background-image:url(spritesmith-main-5.png);background-position:-1476px -1402px;width:45px;height:39px}.Pet_Currency_Gem1x{background-image:url(spritesmith-main-5.png);background-position:-1767px -1692px;width:15px;height:13px}.Pet_Currency_Gem2x{background-image:url(spritesmith-main-5.png);background-position:-1627px -1584px;width:30px;height:26px}.PixelPaw-Gold{background-image:url(spritesmith-main-5.png);background-position:-1627px -544px;width:51px;height:51px}.PixelPaw{background-image:url(spritesmith-main-5.png);background-position:-1627px -492px;width:51px;height:51px}.PixelPaw002{background-image:url(spritesmith-main-5.png);background-position:-1627px -440px;width:51px;height:51px}.avatar_floral_healer{background-image:url(spritesmith-main-5.png);background-position:-385px -1402px;width:99px;height:99px}.avatar_floral_rogue{background-image:url(spritesmith-main-5.png);background-position:-285px -1402px;width:99px;height:99px}.avatar_floral_warrior{background-image:url(spritesmith-main-5.png);background-position:-185px -1402px;width:99px;height:99px}.avatar_floral_wizard{background-image:url(spritesmith-main-5.png);background-position:-85px -1402px;width:99px;height:99px}.empty_bottles{background-image:url(spritesmith-main-5.png);background-position:-1365px -1523px;width:64px;height:54px}.inventory_present{background-image:url(spritesmith-main-5.png);background-position:-490px -1666px;width:48px;height:51px}.inventory_present_01{background-image:url(spritesmith-main-5.png);background-position:-392px -1666px;width:48px;height:51px}.inventory_present_02{background-image:url(spritesmith-main-5.png);background-position:-343px -1666px;width:48px;height:51px}.inventory_present_03{background-image:url(spritesmith-main-5.png);background-position:-294px -1666px;width:48px;height:51px}.inventory_present_04{background-image:url(spritesmith-main-5.png);background-position:-1627px -596px;width:48px;height:51px}.inventory_present_05{background-image:url(spritesmith-main-5.png);background-position:-196px -1666px;width:48px;height:51px}.inventory_present_06{background-image:url(spritesmith-main-5.png);background-position:-147px -1666px;width:48px;height:51px}.inventory_present_07{background-image:url(spritesmith-main-5.png);background-position:-98px -1666px;width:48px;height:51px}.inventory_present_08{background-image:url(spritesmith-main-5.png);background-position:-49px -1666px;width:48px;height:51px}.inventory_present_09{background-image:url(spritesmith-main-5.png);background-position:0 -1666px;width:48px;height:51px}.inventory_present_10{background-image:url(spritesmith-main-5.png);background-position:-1685px -1612px;width:48px;height:51px}.inventory_present_11{background-image:url(spritesmith-main-5.png);background-position:-1685px -1560px;width:48px;height:51px}.inventory_present_12{background-image:url(spritesmith-main-5.png);background-position:-1685px -1508px;width:48px;height:51px}.inventory_quest_scroll{background-image:url(spritesmith-main-5.png);background-position:-1685px -1456px;width:48px;height:51px}.inventory_quest_scroll_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -1404px;width:48px;height:51px}.inventory_special_fortify{background-image:url(spritesmith-main-5.png);background-position:-1627px -110px;width:57px;height:54px}.inventory_special_greeting{background-image:url(spritesmith-main-5.png);background-position:-1430px -1523px;width:57px;height:54px}.inventory_special_nye{background-image:url(spritesmith-main-5.png);background-position:-1488px -1523px;width:57px;height:54px}.inventory_special_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1011px -1442px;width:40px;height:40px}.inventory_special_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1627px -385px;width:57px;height:54px}.inventory_special_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1627px -330px;width:57px;height:54px}.inventory_special_snowball{background-image:url(spritesmith-main-5.png);background-position:-1627px -275px;width:57px;height:54px}.inventory_special_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1627px -220px;width:57px;height:54px}.inventory_special_thankyou{background-image:url(spritesmith-main-5.png);background-position:-1627px -165px;width:57px;height:54px}.inventory_special_trinket{background-image:url(spritesmith-main-5.png);background-position:-1685px -832px;width:48px;height:51px}.inventory_special_valentine{background-image:url(spritesmith-main-5.png);background-position:-1627px -55px;width:57px;height:54px}.knockout{background-image:url(spritesmith-main-5.png);background-position:-588px -1442px;width:120px;height:47px}.pet_key{background-image:url(spritesmith-main-5.png);background-position:-1627px 0;width:57px;height:54px}.rebirth_orb{background-image:url(spritesmith-main-5.png);background-position:-1546px -1523px;width:57px;height:54px}.seafoam_star{background-image:url(spritesmith-main-5.png);background-position:-1536px -91px;width:90px;height:90px}.shop_armoire{background-image:url(spritesmith-main-5.png);background-position:-970px -1442px;width:40px;height:40px}.snowman{background-image:url(spritesmith-main-5.png);background-position:-1536px -273px;width:90px;height:90px}.spookman{background-image:url(spritesmith-main-5.png);background-position:-1536px -364px;width:90px;height:90px}.zzz{background-image:url(spritesmith-main-5.png);background-position:-929px -1442px;width:40px;height:40px}.zzz_light{background-image:url(spritesmith-main-5.png);background-position:-1134px -1442px;width:40px;height:40px}.npc_alex{background-image:url(spritesmith-main-5.png);background-position:-151px -1251px;width:162px;height:138px}.npc_bailey{background-image:url(spritesmith-main-5.png);background-position:-1461px -1251px;width:60px;height:72px}.npc_daniel{background-image:url(spritesmith-main-5.png);background-position:-477px -1251px;width:135px;height:123px}.npc_justin{background-image:url(spritesmith-main-5.png);background-position:0 -1402px;width:84px;height:120px}.npc_justin_head{background-image:url(spritesmith-main-5.png);background-position:-1298px -1442px;width:36px;height:39px}.npc_matt{background-image:url(spritesmith-main-5.png);background-position:-1322px -954px;width:195px;height:138px}.npc_timetravelers{background-image:url(spritesmith-main-5.png);background-position:-1322px -815px;width:195px;height:138px}.npc_timetravelers_active{background-image:url(spritesmith-main-5.png);background-position:-1322px -676px;width:195px;height:138px}.npc_tyler{background-image:url(spritesmith-main-5.png);background-position:-1536px -1365px;width:90px;height:90px}.seasonalshop_closed{background-image:url(spritesmith-main-5.png);background-position:-314px -1251px;width:162px;height:138px}.inventory_quest_scroll_atom1{background-image:url(spritesmith-main-5.png);background-position:-1685px -208px;width:48px;height:51px}.inventory_quest_scroll_atom1_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -156px;width:48px;height:51px}.inventory_quest_scroll_atom2{background-image:url(spritesmith-main-5.png);background-position:-1685px -104px;width:48px;height:51px}.inventory_quest_scroll_atom2_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -52px;width:48px;height:51px}.inventory_quest_scroll_atom3{background-image:url(spritesmith-main-5.png);background-position:-637px -1614px;width:48px;height:51px}.inventory_quest_scroll_atom3_locked{background-image:url(spritesmith-main-5.png);background-position:-588px -1614px;width:48px;height:51px}.inventory_quest_scroll_basilist{background-image:url(spritesmith-main-5.png);background-position:-539px -1614px;width:48px;height:51px}.inventory_quest_scroll_bunny{background-image:url(spritesmith-main-5.png);background-position:-490px -1614px;width:48px;height:51px}.inventory_quest_scroll_cheetah{background-image:url(spritesmith-main-5.png);background-position:-441px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-392px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-343px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2_locked{background-image:url(spritesmith-main-5.png);background-position:-294px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-245px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3_locked{background-image:url(spritesmith-main-5.png);background-position:-147px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-1685px -312px;width:48px;height:51px}.inventory_quest_scroll_egg{background-image:url(spritesmith-main-5.png);background-position:-245px -1666px;width:48px;height:51px}.inventory_quest_scroll_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-1685px -364px;width:48px;height:51px}.inventory_quest_scroll_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:-1685px -416px;width:48px;height:51px}.inventory_quest_scroll_frog{background-image:url(spritesmith-main-5.png);background-position:-1685px -468px;width:48px;height:51px}.inventory_quest_scroll_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-1685px -520px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-1685px -624px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -676px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-1685px -728px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -780px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:-1685px -884px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -936px;width:48px;height:51px}.inventory_quest_scroll_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1685px -988px;width:48px;height:51px}.inventory_quest_scroll_harpy{background-image:url(spritesmith-main-5.png);background-position:-1685px -1092px;width:48px;height:51px}.inventory_quest_scroll_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1685px -1144px;width:48px;height:51px}.inventory_quest_scroll_horse{background-image:url(spritesmith-main-5.png);background-position:-1685px -1248px;width:48px;height:51px}.inventory_quest_scroll_kraken{background-image:url(spritesmith-main-5.png);background-position:-1685px -1300px;width:48px;height:51px}.inventory_quest_scroll_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-1685px -1352px;width:48px;height:51px}.inventory_quest_scroll_moonstone1_locked{background-image:url(spritesmith-main-5.png);background-position:-539px -1666px;width:48px;height:51px}.inventory_quest_scroll_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-1627px -648px;width:48px;height:51px}.inventory_quest_scroll_moonstone2_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -700px;width:48px;height:51px}.inventory_quest_scroll_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-1627px -752px;width:48px;height:51px}.inventory_quest_scroll_moonstone3_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -804px;width:48px;height:51px}.inventory_quest_scroll_octopus{background-image:url(spritesmith-main-5.png);background-position:-1627px -856px;width:48px;height:51px}.inventory_quest_scroll_owl{background-image:url(spritesmith-main-5.png);background-position:-1627px -908px;width:48px;height:51px}.inventory_quest_scroll_penguin{background-image:url(spritesmith-main-5.png);background-position:-1627px -960px;width:48px;height:51px}.inventory_quest_scroll_rat{background-image:url(spritesmith-main-5.png);background-position:-1627px -1012px;width:48px;height:51px}.inventory_quest_scroll_rock{background-image:url(spritesmith-main-5.png);background-position:-1627px -1064px;width:48px;height:51px}.inventory_quest_scroll_rooster{background-image:url(spritesmith-main-5.png);background-position:-1627px -1116px;width:48px;height:51px}.inventory_quest_scroll_sheep{background-image:url(spritesmith-main-5.png);background-position:-1627px -1168px;width:48px;height:51px}.inventory_quest_scroll_slime{background-image:url(spritesmith-main-5.png);background-position:-1627px -1220px;width:48px;height:51px}.inventory_quest_scroll_snake{background-image:url(spritesmith-main-5.png);background-position:-1627px -1272px;width:48px;height:51px}.inventory_quest_scroll_spider{background-image:url(spritesmith-main-5.png);background-position:-1627px -1324px;width:48px;height:51px}.inventory_quest_scroll_trex{background-image:url(spritesmith-main-5.png);background-position:-1627px -1376px;width:48px;height:51px}.inventory_quest_scroll_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-1627px -1428px;width:48px;height:51px}.inventory_quest_scroll_vice1{background-image:url(spritesmith-main-5.png);background-position:-1627px -1480px;width:48px;height:51px}.inventory_quest_scroll_vice1_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -1532px;width:48px;height:51px}.inventory_quest_scroll_vice2{background-image:url(spritesmith-main-5.png);background-position:0 -1614px;width:48px;height:51px}.inventory_quest_scroll_vice2_locked{background-image:url(spritesmith-main-5.png);background-position:-49px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice3{background-image:url(spritesmith-main-5.png);background-position:-98px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice3_locked{background-image:url(spritesmith-main-5.png);background-position:-1734px -1456px;width:48px;height:51px}.inventory_quest_scroll_whale{background-image:url(spritesmith-main-5.png);background-position:-196px -1614px;width:48px;height:51px}.quest_TEMPLATE_FOR_MISSING_IMAGE{background-image:url(spritesmith-main-5.png);background-position:-1254px -1402px;width:221px;height:39px}.quest_atom1{background-image:url(spritesmith-main-5.png);background-position:-434px -1070px;width:250px;height:150px}.quest_atom2{background-image:url(spritesmith-main-5.png);background-position:-1322px -537px;width:207px;height:138px}.quest_atom3{background-image:url(spritesmith-main-5.png);background-position:0 -1070px;width:216px;height:180px}.quest_basilist{background-image:url(spritesmith-main-5.png);background-position:-1322px -1093px;width:189px;height:141px}.quest_bunny{background-image:url(spritesmith-main-5.png);background-position:-1100px -618px;width:210px;height:186px}.quest_cheetah{background-image:url(spritesmith-main-5.png);background-position:-440px -452px;width:219px;height:219px}.quest_dilatory{background-image:url(spritesmith-main-5.png);background-position:-220px -452px;width:219px;height:219px}.quest_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-1100px -845px;width:221px;height:39px}.quest_dilatoryDistress1_blueFins{background-image:url(spritesmith-main-5.png);background-position:-686px -1614px;width:51px;height:48px}.quest_dilatoryDistress1_fireCoral{background-image:url(spritesmith-main-5.png);background-position:-1685px 0;width:48px;height:51px}.quest_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:0 -1251px;width:150px;height:150px}.quest_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-440px 0;width:219px;height:219px}.quest_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-660px 0;width:219px;height:219px}.quest_egg{background-image:url(spritesmith-main-5.png);background-position:-810px -1402px;width:221px;height:39px}.quest_egg_plainEgg{background-image:url(spritesmith-main-5.png);background-position:-1685px -260px;width:48px;height:51px}.quest_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-1187px -1070px;width:118px;height:131px}.quest_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:0 -232px;width:219px;height:219px}.quest_frog{background-image:url(spritesmith-main-5.png);background-position:-1100px 0;width:221px;height:213px}.quest_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-220px -232px;width:219px;height:219px}.quest_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-1032px -1402px;width:221px;height:39px}.quest_goldenknight1_testimony{background-image:url(spritesmith-main-5.png);background-position:-1685px -572px;width:48px;height:51px}.quest_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-685px -1070px;width:250px;height:150px}.quest_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:0 0;width:219px;height:231px}.quest_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1091px -892px;width:216px;height:177px}.quest_harpy{background-image:url(spritesmith-main-5.png);background-position:-660px -220px;width:219px;height:219px}.quest_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1100px -431px;width:219px;height:186px}.quest_horse{background-image:url(spritesmith-main-5.png);background-position:0 -452px;width:219px;height:219px}.quest_kraken{background-image:url(spritesmith-main-5.png);background-position:-440px -892px;width:216px;height:177px}.quest_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-588px -1402px;width:221px;height:39px}.quest_moonstone1_moonstone{background-image:url(spritesmith-main-5.png);background-position:-1335px -1442px;width:30px;height:30px}.quest_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-220px 0;width:219px;height:219px}.quest_moonstone3{background-image:url(spritesmith-main-5.png);background-position:0 -672px;width:219px;height:219px}.quest_octopus{background-image:url(spritesmith-main-5.png);background-position:0 -892px;width:222px;height:177px}.quest_owl{background-image:url(spritesmith-main-5.png);background-position:-220px -672px;width:219px;height:219px}.quest_penguin{background-image:url(spritesmith-main-5.png);background-position:-1322px -353px;width:190px;height:183px}.quest_rat{background-image:url(spritesmith-main-5.png);background-position:-880px -672px;width:219px;height:219px}.quest_rock{background-image:url(spritesmith-main-5.png);background-position:-1100px -214px;width:216px;height:216px}.quest_rooster{background-image:url(spritesmith-main-5.png);background-position:-1322px 0;width:213px;height:174px}.quest_sheep{background-image:url(spritesmith-main-5.png);background-position:-660px -672px;width:219px;height:219px}.quest_slime{background-image:url(spritesmith-main-5.png);background-position:-440px -672px;width:219px;height:219px}.quest_snake{background-image:url(spritesmith-main-5.png);background-position:-874px -892px;width:216px;height:177px}.quest_spider{background-image:url(spritesmith-main-5.png);background-position:-936px -1070px;width:250px;height:150px}.quest_stressbeast{background-image:url(spritesmith-main-5.png);background-position:-880px -440px;width:219px;height:219px}.quest_stressbeast_bailey{background-image:url(spritesmith-main-5.png);background-position:-880px -220px;width:219px;height:219px}.quest_stressbeast_guide{background-image:url(spritesmith-main-5.png);background-position:-880px 0;width:219px;height:219px}.quest_stressbeast_stables{background-image:url(spritesmith-main-5.png);background-position:-660px -452px;width:219px;height:219px}.quest_trex{background-image:url(spritesmith-main-5.png);background-position:-1322px -175px;width:204px;height:177px}.quest_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-223px -892px;width:216px;height:177px}.quest_vice1{background-image:url(spritesmith-main-5.png);background-position:-657px -892px;width:216px;height:177px}.quest_vice2{background-image:url(spritesmith-main-5.png);background-position:-1100px -805px;width:221px;height:39px}.quest_vice2_lightCrystal{background-image:url(spritesmith-main-5.png);background-position:-1175px -1442px;width:40px;height:40px}.quest_vice3{background-image:url(spritesmith-main-5.png);background-position:-217px -1070px;width:216px;height:177px}.quest_whale{background-image:url(spritesmith-main-5.png);background-position:-440px -232px;width:219px;height:219px}.shop_copper{background-image:url(spritesmith-main-5.png);background-position:-467px -1221px;width:32px;height:22px}.shop_eyes{background-image:url(spritesmith-main-5.png);background-position:-1216px -1442px;width:40px;height:40px}.shop_gold{background-image:url(spritesmith-main-5.png);background-position:-1734px -1692px;width:32px;height:22px}.shop_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1257px -1442px;width:40px;height:40px}.shop_potion{background-image:url(spritesmith-main-5.png);background-position:-1093px -1442px;width:40px;height:40px}.shop_reroll{background-image:url(spritesmith-main-5.png);background-position:-1052px -1442px;width:40px;height:40px}.shop_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1594px -1456px;width:32px;height:32px}.shop_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1461px -1324px;width:32px;height:32px}.shop_silver{background-image:url(spritesmith-main-5.png);background-position:-434px -1221px;width:32px;height:22px}.shop_snowball{background-image:url(spritesmith-main-5.png);background-position:-1594px -1489px;width:32px;height:32px}.shop_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1494px -1324px;width:32px;height:32px}.Pet_Egg_BearCub{background-image:url(spritesmith-main-5.png);background-position:-1127px -1666px;width:48px;height:51px}.Pet_Egg_Bunny{background-image:url(spritesmith-main-5.png);background-position:-1176px -1666px;width:48px;height:51px}.Pet_Egg_Cactus{background-image:url(spritesmith-main-5.png);background-position:-1225px -1666px;width:48px;height:51px}.Pet_Egg_Cheetah{background-image:url(spritesmith-main-5.png);background-position:-1274px -1666px;width:48px;height:51px}.Pet_Egg_Cuttlefish{background-image:url(spritesmith-main-5.png);background-position:-1323px -1666px;width:48px;height:51px}.Pet_Egg_Deer{background-image:url(spritesmith-main-5.png);background-position:-1372px -1666px;width:48px;height:51px}.Pet_Egg_Dragon{background-image:url(spritesmith-main-5.png);background-position:-1421px -1666px;width:48px;height:51px}.Pet_Egg_Egg{background-image:url(spritesmith-main-5.png);background-position:-1470px -1666px;width:48px;height:51px}.Pet_Egg_FlyingPig{background-image:url(spritesmith-main-5.png);background-position:-1519px -1666px;width:48px;height:51px}.Pet_Egg_Fox{background-image:url(spritesmith-main-5.png);background-position:-1568px -1666px;width:48px;height:51px}.Pet_Egg_Frog{background-image:url(spritesmith-main-5.png);background-position:-1617px -1666px;width:48px;height:51px}.Pet_Egg_Gryphon{background-image:url(spritesmith-main-5.png);background-position:-1666px -1666px;width:48px;height:51px}.Pet_Egg_Hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1734px 0;width:48px;height:51px}.Pet_Egg_Horse{background-image:url(spritesmith-main-5.png);background-position:-1734px -52px;width:48px;height:51px}.Pet_Egg_LionCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -104px;width:48px;height:51px}.Pet_Egg_Octopus{background-image:url(spritesmith-main-5.png);background-position:-1734px -156px;width:48px;height:51px}.Pet_Egg_Owl{background-image:url(spritesmith-main-5.png);background-position:-1734px -208px;width:48px;height:51px}.Pet_Egg_PandaCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -260px;width:48px;height:51px}.Pet_Egg_Parrot{background-image:url(spritesmith-main-5.png);background-position:-1734px -312px;width:48px;height:51px}.Pet_Egg_Penguin{background-image:url(spritesmith-main-5.png);background-position:-1734px -364px;width:48px;height:51px}.Pet_Egg_PolarBear{background-image:url(spritesmith-main-5.png);background-position:-1734px -416px;width:48px;height:51px}.Pet_Egg_Rat{background-image:url(spritesmith-main-5.png);background-position:-1734px -468px;width:48px;height:51px}.Pet_Egg_Rock{background-image:url(spritesmith-main-5.png);background-position:-1734px -520px;width:48px;height:51px}.Pet_Egg_Rooster{background-image:url(spritesmith-main-5.png);background-position:-1734px -572px;width:48px;height:51px}.Pet_Egg_Seahorse{background-image:url(spritesmith-main-5.png);background-position:-1734px -624px;width:48px;height:51px}.Pet_Egg_Sheep{background-image:url(spritesmith-main-5.png);background-position:-1734px -676px;width:48px;height:51px}.Pet_Egg_Slime{background-image:url(spritesmith-main-5.png);background-position:-1734px -728px;width:48px;height:51px}.Pet_Egg_Snake{background-image:url(spritesmith-main-5.png);background-position:-1734px -780px;width:48px;height:51px}.Pet_Egg_Spider{background-image:url(spritesmith-main-5.png);background-position:-1734px -832px;width:48px;height:51px}.Pet_Egg_TRex{background-image:url(spritesmith-main-5.png);background-position:-1734px -884px;width:48px;height:51px}.Pet_Egg_TigerCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -936px;width:48px;height:51px}.Pet_Egg_Whale{background-image:url(spritesmith-main-5.png);background-position:-1734px -988px;width:48px;height:51px}.Pet_Egg_Wolf{background-image:url(spritesmith-main-5.png);background-position:-1734px -1040px;width:48px;height:51px}.Pet_Food_Cake_Base{background-image:url(spritesmith-main-5.png);background-position:-841px -1442px;width:43px;height:43px}.Pet_Food_Cake_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-738px -1614px;width:42px;height:44px}.Pet_Food_Cake_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1734px -1646px;width:43px;height:45px}.Pet_Food_Cake_Desert{background-image:url(spritesmith-main-5.png);background-position:-753px -1442px;width:43px;height:44px}.Pet_Food_Cake_Golden{background-image:url(spritesmith-main-5.png);background-position:-885px -1442px;width:43px;height:42px}.Pet_Food_Cake_Red{background-image:url(spritesmith-main-5.png);background-position:-797px -1442px;width:43px;height:44px}.Pet_Food_Cake_Shade{background-image:url(spritesmith-main-5.png);background-position:-709px -1442px;width:43px;height:44px}.Pet_Food_Cake_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1734px -1553px;width:42px;height:47px}.Pet_Food_Cake_White{background-image:url(spritesmith-main-5.png);background-position:-1734px -1601px;width:44px;height:44px}.Pet_Food_Cake_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1734px -1508px;width:45px;height:44px}.Pet_Food_Candy_Base{background-image:url(spritesmith-main-5.png);background-position:-1734px -1404px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1734px -1352px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1734px -1300px;width:48px;height:51px}.Pet_Food_Candy_Desert{background-image:url(spritesmith-main-5.png);background-position:-1734px -1248px;width:48px;height:51px}.Pet_Food_Candy_Golden{background-image:url(spritesmith-main-5.png);background-position:-1734px -1196px;width:48px;height:51px}.Pet_Food_Candy_Red{background-image:url(spritesmith-main-5.png);background-position:-1734px -1144px;width:48px;height:51px}.Pet_Food_Candy_Shade{background-image:url(spritesmith-main-5.png);background-position:-1734px -1092px;width:48px;height:51px}.Pet_Food_Candy_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1078px -1666px;width:48px;height:51px}.Pet_Food_Candy_White{background-image:url(spritesmith-main-5.png);background-position:-1029px -1666px;width:48px;height:51px}.Pet_Food_Candy_Zombie{background-image:url(spritesmith-main-5.png);background-position:-980px -1666px;width:48px;height:51px}.Pet_Food_Chocolate{background-image:url(spritesmith-main-5.png);background-position:-931px -1666px;width:48px;height:51px}.Pet_Food_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-882px -1666px;width:48px;height:51px}.Pet_Food_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-833px -1666px;width:48px;height:51px}.Pet_Food_Fish{background-image:url(spritesmith-main-5.png);background-position:-784px -1666px;width:48px;height:51px}.Pet_Food_Honey{background-image:url(spritesmith-main-5.png);background-position:-735px -1666px;width:48px;height:51px}.Pet_Food_Meat{background-image:url(spritesmith-main-5.png);background-position:-686px -1666px;width:48px;height:51px}.Pet_Food_Milk{background-image:url(spritesmith-main-5.png);background-position:-637px -1666px;width:48px;height:51px}.Pet_Food_Potatoe{background-image:url(spritesmith-main-5.png);background-position:-588px -1666px;width:48px;height:51px}.Pet_Food_RottenMeat{background-image:url(spritesmith-main-5.png);background-position:-441px -1666px;width:48px;height:51px}.Pet_Food_Saddle{background-image:url(spritesmith-main-5.png);background-position:-1685px -1196px;width:48px;height:51px}.Pet_Food_Strawberry{background-image:url(spritesmith-main-5.png);background-position:-1685px -1040px;width:48px;height:51px}.Mount_Body_BearCub-Base{background-image:url(spritesmith-main-5.png);background-position:-613px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1355px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1249px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Desert{background-image:url(spritesmith-main-5.png);background-position:-1143px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Golden{background-image:url(spritesmith-main-5.png);background-position:-1037px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Polar{background-image:url(spritesmith-main-5.png);background-position:-931px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Red{background-image:url(spritesmith-main-5.png);background-position:-825px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Shade{background-image:url(spritesmith-main-5.png);background-position:-719px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -575px;width:105px;height:105px}.Mount_Body_BearCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-318px -1105px;width:105px;height:105px}.Mount_Body_BearCub-White{background-image:url(spritesmith-main-6.png);background-position:-212px -575px;width:105px;height:105px}.Mount_Body_BearCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -575px;width:105px;height:105px}.Mount_Body_Bunny-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -575px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -575px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -575px;width:105px;height:105px}.Mount_Body_Bunny-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px 0;width:105px;height:105px}.Mount_Body_Bunny-Golden{background-image:url(spritesmith-main-6.png);background-position:-742px -106px;width:105px;height:105px}.Mount_Body_Bunny-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -212px;width:105px;height:105px}.Mount_Body_Bunny-Shade{background-image:url(spritesmith-main-6.png);background-position:-742px -318px;width:105px;height:105px}.Mount_Body_Bunny-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -999px;width:105px;height:105px}.Mount_Body_Bunny-White{background-image:url(spritesmith-main-6.png);background-position:-106px -999px;width:105px;height:105px}.Mount_Body_Bunny-Zombie{background-image:url(spritesmith-main-6.png);background-position:-212px -999px;width:105px;height:105px}.Mount_Body_Cactus-Base{background-image:url(spritesmith-main-6.png);background-position:-318px -999px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-424px -999px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-530px -999px;width:105px;height:105px}.Mount_Body_Cactus-Desert{background-image:url(spritesmith-main-6.png);background-position:-636px -999px;width:105px;height:105px}.Mount_Body_Cactus-Golden{background-image:url(spritesmith-main-6.png);background-position:-742px -999px;width:105px;height:105px}.Mount_Body_Cactus-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -999px;width:105px;height:105px}.Mount_Body_Cactus-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -999px;width:105px;height:105px}.Mount_Body_Cactus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -1211px;width:105px;height:105px}.Mount_Body_Cactus-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1060px -1211px;width:105px;height:105px}.Mount_Body_Cactus-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -1211px;width:105px;height:105px}.Mount_Body_Cactus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-530px -221px;width:105px;height:105px}.Mount_Body_Cheetah-Base{background-image:url(spritesmith-main-6.png);background-position:-530px -327px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-221px -469px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-327px -469px;width:105px;height:105px}.Mount_Body_Cheetah-Desert{background-image:url(spritesmith-main-6.png);background-position:-433px -469px;width:105px;height:105px}.Mount_Body_Cheetah-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px 0;width:105px;height:105px}.Mount_Body_Cheetah-Red{background-image:url(spritesmith-main-6.png);background-position:-636px -106px;width:105px;height:105px}.Mount_Body_Cheetah-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -212px;width:105px;height:105px}.Mount_Body_Cheetah-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -318px;width:105px;height:105px}.Mount_Body_Cheetah-White{background-image:url(spritesmith-main-6.png);background-position:-636px -424px;width:105px;height:105px}.Mount_Body_Cheetah-Zombie{background-image:url(spritesmith-main-6.png);background-position:0 -575px;width:105px;height:105px}.Mount_Body_Cuttlefish-Base{background-image:url(spritesmith-main-6.png);background-position:-530px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -115px;width:105px;height:114px}.Mount_Body_Cuttlefish-White{background-image:url(spritesmith-main-6.png);background-position:0 -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -239px;width:105px;height:114px}.Mount_Body_Deer-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -424px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-742px -530px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -681px;width:105px;height:105px}.Mount_Body_Deer-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -681px;width:105px;height:105px}.Mount_Body_Deer-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -681px;width:105px;height:105px}.Mount_Body_Deer-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -681px;width:105px;height:105px}.Mount_Body_Deer-Shade{background-image:url(spritesmith-main-6.png);background-position:-424px -681px;width:105px;height:105px}.Mount_Body_Deer-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -681px;width:105px;height:105px}.Mount_Body_Deer-White{background-image:url(spritesmith-main-6.png);background-position:-636px -681px;width:105px;height:105px}.Mount_Body_Deer-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -681px;width:105px;height:105px}.Mount_Body_Dragon-Base{background-image:url(spritesmith-main-6.png);background-position:-848px 0;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -106px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-848px -212px;width:105px;height:105px}.Mount_Body_Dragon-Desert{background-image:url(spritesmith-main-6.png);background-position:-848px -318px;width:105px;height:105px}.Mount_Body_Dragon-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -424px;width:105px;height:105px}.Mount_Body_Dragon-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -530px;width:105px;height:105px}.Mount_Body_Dragon-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -636px;width:105px;height:105px}.Mount_Body_Dragon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -787px;width:105px;height:105px}.Mount_Body_Dragon-Spooky{background-image:url(spritesmith-main-6.png);background-position:-106px -787px;width:105px;height:105px}.Mount_Body_Dragon-White{background-image:url(spritesmith-main-6.png);background-position:-212px -787px;width:105px;height:105px}.Mount_Body_Dragon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -787px;width:105px;height:105px}.Mount_Body_Egg-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -787px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -787px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -787px;width:105px;height:105px}.Mount_Body_Egg-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -787px;width:105px;height:105px}.Mount_Body_Egg-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -787px;width:105px;height:105px}.Mount_Body_Egg-Red{background-image:url(spritesmith-main-6.png);background-position:-954px 0;width:105px;height:105px}.Mount_Body_Egg-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -106px;width:105px;height:105px}.Mount_Body_Egg-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -212px;width:105px;height:105px}.Mount_Body_Egg-White{background-image:url(spritesmith-main-6.png);background-position:-954px -318px;width:105px;height:105px}.Mount_Body_Egg-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -424px;width:105px;height:105px}.Mount_Body_FlyingPig-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -530px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -636px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -742px;width:105px;height:105px}.Mount_Body_FlyingPig-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-424px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Spooky{background-image:url(spritesmith-main-6.png);background-position:-530px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-White{background-image:url(spritesmith-main-6.png);background-position:-636px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -893px;width:105px;height:105px}.Mount_Body_Fox-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -893px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -893px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px 0;width:105px;height:105px}.Mount_Body_Fox-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -106px;width:105px;height:105px}.Mount_Body_Fox-Golden{background-image:url(spritesmith-main-6.png);background-position:-1060px -212px;width:105px;height:105px}.Mount_Body_Fox-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px -318px;width:105px;height:105px}.Mount_Body_Fox-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -424px;width:105px;height:105px}.Mount_Body_Fox-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1060px -530px;width:105px;height:105px}.Mount_Body_Fox-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1060px -636px;width:105px;height:105px}.Mount_Body_Fox-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -742px;width:105px;height:105px}.Mount_Body_Fox-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1060px -848px;width:105px;height:105px}.Mount_Body_Frog-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -239px;width:105px;height:114px}.Mount_Body_Frog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px 0;width:105px;height:114px}.Mount_Body_Frog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px 0;width:105px;height:114px}.Mount_Body_Frog-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -115px;width:105px;height:114px}.Mount_Body_Frog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -230px;width:105px;height:114px}.Mount_Body_Frog-Red{background-image:url(spritesmith-main-6.png);background-position:0 -354px;width:105px;height:114px}.Mount_Body_Frog-Shade{background-image:url(spritesmith-main-6.png);background-position:-106px -354px;width:105px;height:114px}.Mount_Body_Frog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-212px -354px;width:105px;height:114px}.Mount_Body_Frog-White{background-image:url(spritesmith-main-6.png);background-position:-318px -354px;width:105px;height:114px}.Mount_Body_Frog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-424px -354px;width:105px;height:114px}.Mount_Body_Gryphon-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -999px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px 0;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px -106px;width:105px;height:105px}.Mount_Body_Gryphon-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -212px;width:105px;height:105px}.Mount_Body_Gryphon-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -318px;width:105px;height:105px}.Mount_Body_Gryphon-Red{background-image:url(spritesmith-main-6.png);background-position:-1166px -424px;width:105px;height:105px}.Mount_Body_Gryphon-RoyalPurple{background-image:url(spritesmith-main-6.png);background-position:-1166px -530px;width:105px;height:105px}.Mount_Body_Gryphon-Shade{background-image:url(spritesmith-main-6.png);background-position:-1166px -636px;width:105px;height:105px}.Mount_Body_Gryphon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px -742px;width:105px;height:105px}.Mount_Body_Gryphon-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -848px;width:105px;height:105px}.Mount_Body_Gryphon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -954px;width:105px;height:105px}.Mount_Body_Hedgehog-Base{background-image:url(spritesmith-main-6.png);background-position:0 -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -115px;width:105px;height:105px}.Mount_Body_Hedgehog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Red{background-image:url(spritesmith-main-6.png);background-position:-530px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-742px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -1105px;width:105px;height:105px}.Mount_Body_Horse-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -1105px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px -1105px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1272px 0;width:105px;height:105px}.Mount_Body_Horse-Desert{background-image:url(spritesmith-main-6.png);background-position:-1272px -106px;width:105px;height:105px}.Mount_Body_Horse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -212px;width:105px;height:105px}.Mount_Body_Horse-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -318px;width:105px;height:105px}.Mount_Body_Horse-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -424px;width:105px;height:105px}.Mount_Body_Horse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1272px -530px;width:105px;height:105px}.Mount_Body_Horse-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -636px;width:105px;height:105px}.Mount_Body_Horse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -742px;width:105px;height:105px}.Mount_Body_JackOLantern-Base{background-image:url(spritesmith-main-6.png);background-position:-1696px -530px;width:90px;height:105px}.Mount_Body_LionCub-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -954px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -1060px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -1211px;width:105px;height:105px}.Mount_Body_LionCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Ethereal{background-image:url(spritesmith-main-6.png);background-position:-212px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-318px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Red{background-image:url(spritesmith-main-6.png);background-position:-424px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -469px;width:111px;height:105px}.Mount_Body_LionCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-742px -1211px;width:105px;height:105px}.Mount_Body_LionCub-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -1211px;width:105px;height:105px}.Mount_Body_Mammoth-Base{background-image:url(spritesmith-main-6.png);background-position:0 0;width:105px;height:123px}.Mount_Body_MantisShrimp-Base{background-image:url(spritesmith-main-6.png);background-position:-112px -469px;width:108px;height:105px}.Mount_Body_Octopus-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1211px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px 0;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -106px;width:105px;height:105px}.Mount_Body_Octopus-Desert{background-image:url(spritesmith-main-6.png);background-position:-1378px -212px;width:105px;height:105px}.Mount_Body_Octopus-Golden{background-image:url(spritesmith-main-6.png);background-position:-1378px -318px;width:105px;height:105px}.Mount_Body_Octopus-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -424px;width:105px;height:105px}.Mount_Body_Octopus-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -530px;width:105px;height:105px}.Mount_Body_Octopus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1378px -636px;width:105px;height:105px}.Mount_Body_Octopus-White{background-image:url(spritesmith-main-6.png);background-position:-1378px -742px;width:105px;height:105px}.Mount_Body_Octopus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -848px;width:105px;height:105px}.Mount_Body_Orca-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -954px;width:105px;height:105px}.Mount_Body_Owl-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -1060px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -1166px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -1317px;width:105px;height:105px}.Mount_Body_Owl-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -1317px;width:105px;height:105px}.Mount_Body_Owl-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -1317px;width:105px;height:105px}.Mount_Body_Owl-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -1317px;width:105px;height:105px}.Mount_Body_Owl-Shade{background-image:url(spritesmith-main-6.png);background-position:-424px -1317px;width:105px;height:105px}.Mount_Body_Owl-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -1317px;width:105px;height:105px}.Mount_Body_Owl-White{background-image:url(spritesmith-main-6.png);background-position:-636px -1317px;width:105px;height:105px}.Mount_Body_Owl-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px 0;width:105px;height:105px}.Mount_Body_PandaCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -106px;width:105px;height:105px}.Mount_Body_PandaCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1484px -212px;width:105px;height:105px}.Mount_Body_PandaCub-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -318px;width:105px;height:105px}.Mount_Body_PandaCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -424px;width:105px;height:105px}.Mount_Body_Parrot-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -530px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -636px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px -742px;width:105px;height:105px}.Mount_Body_Parrot-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -848px;width:105px;height:105px}.Mount_Body_Parrot-Golden{background-image:url(spritesmith-main-6.png);background-position:-1484px -954px;width:105px;height:105px}.Mount_Body_Parrot-Red{background-image:url(spritesmith-main-6.png);background-position:-1484px -1060px;width:105px;height:105px}.Mount_Body_Parrot-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px -1166px;width:105px;height:105px}.Mount_Body_Parrot-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -1272px;width:105px;height:105px}.Mount_Body_Parrot-White{background-image:url(spritesmith-main-6.png);background-position:0 -1423px;width:105px;height:105px}.Mount_Body_Parrot-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -1423px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -1423px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -1423px;width:105px;height:105px}.Mount_Body_Penguin-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -1423px;width:105px;height:105px}.Mount_Body_Phoenix-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1423px;width:105px;height:105px}.Mount_Body_Rat-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px 0;width:105px;height:105px}.Mount_Body_Rat-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -106px;width:105px;height:105px}.Mount_Body_Rat-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -212px;width:105px;height:105px}.Mount_Body_Rat-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -318px;width:105px;height:105px}.Mount_Body_Rat-Shade{background-image:url(spritesmith-main-6.png);background-position:-1590px -424px;width:105px;height:105px}.Mount_Body_Rat-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1590px -530px;width:105px;height:105px}.Mount_Body_Rat-White{background-image:url(spritesmith-main-6.png);background-position:-1590px -636px;width:105px;height:105px}.Mount_Body_Rat-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1590px -742px;width:105px;height:105px}.Mount_Body_Rock-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px -848px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -954px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px -1060px;width:105px;height:105px}.Mount_Body_Rock-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -1166px;width:105px;height:105px}.Mount_Body_Rock-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -1272px;width:105px;height:105px}.Mount_Body_Rock-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -1378px;width:105px;height:105px}.Mount_Body_Rock-Shade{background-image:url(spritesmith-main-6.png);background-position:0 -1529px;width:105px;height:105px}.Mount_Body_Rock-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -1529px;width:105px;height:105px}.Mount_Body_Rock-White{background-image:url(spritesmith-main-6.png);background-position:-212px -1529px;width:105px;height:105px}.Mount_Body_Rock-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -1529px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -1529px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Red{background-image:url(spritesmith-main-6.png);background-position:-954px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px -1529px;width:105px;height:105px}.Mount_Body_Rooster-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1696px 0;width:105px;height:105px}.Mount_Body_Seahorse-Desert{background-image:url(spritesmith-main-6.png);background-position:-1696px -106px;width:105px;height:105px}.Mount_Body_Seahorse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1696px -212px;width:105px;height:105px}.Mount_Body_Seahorse-Red{background-image:url(spritesmith-main-6.png);background-position:-1696px -318px;width:105px;height:105px}.Mount_Body_Seahorse-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -848px;width:105px;height:105px}.Mount_Body_Seahorse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1696px -424px;width:105px;height:105px}.Mount_Body_Seahorse-White{background-image:url(spritesmith-main-7.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Body_Seahorse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-848px -1113px;width:105px;height:105px}.Mount_Body_Sheep-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -680px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-892px 0;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Body_Sheep-Desert{background-image:url(spritesmith-main-7.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Body_Sheep-Golden{background-image:url(spritesmith-main-7.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Body_Sheep-Red{background-image:url(spritesmith-main-7.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Body_Sheep-Shade{background-image:url(spritesmith-main-7.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Body_Sheep-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Body_Sheep-White{background-image:url(spritesmith-main-7.png);background-position:0 -795px;width:105px;height:105px}.Mount_Body_Sheep-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -901px;width:105px;height:105px}.Mount_Body_Slime-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -901px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -901px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -901px;width:105px;height:105px}.Mount_Body_Slime-Desert{background-image:url(spritesmith-main-7.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Body_Slime-Golden{background-image:url(spritesmith-main-7.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Body_Slime-Red{background-image:url(spritesmith-main-7.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Body_Slime-Shade{background-image:url(spritesmith-main-7.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Body_Slime-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Body_Slime-White{background-image:url(spritesmith-main-7.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Body_Slime-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Body_Snake-Base{background-image:url(spritesmith-main-7.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Body_Snake-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Body_Snake-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Body_Snake-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -1219px;width:105px;height:105px}.Mount_Body_Snake-Golden{background-image:url(spritesmith-main-7.png);background-position:-106px -1219px;width:105px;height:105px}.Mount_Body_Snake-Red{background-image:url(spritesmith-main-7.png);background-position:-212px -1219px;width:105px;height:105px}.Mount_Body_Snake-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1219px;width:105px;height:105px}.Mount_Body_Snake-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1219px;width:105px;height:105px}.Mount_Body_Snake-White{background-image:url(spritesmith-main-7.png);background-position:-530px -1219px;width:105px;height:105px}.Mount_Body_Snake-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -1219px;width:105px;height:105px}.Mount_Body_Spider-Base{background-image:url(spritesmith-main-7.png);background-position:-848px -1431px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-954px -1431px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1060px -1431px;width:105px;height:105px}.Mount_Body_Spider-Desert{background-image:url(spritesmith-main-7.png);background-position:-1166px -1431px;width:105px;height:105px}.Mount_Body_Spider-Golden{background-image:url(spritesmith-main-7.png);background-position:-1272px -1431px;width:105px;height:105px}.Mount_Body_Spider-Red{background-image:url(spritesmith-main-7.png);background-position:-1378px -1431px;width:105px;height:105px}.Mount_Body_Spider-Shade{background-image:url(spritesmith-main-7.png);background-position:-1484px -1431px;width:105px;height:105px}.Mount_Body_Spider-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px 0;width:105px;height:105px}.Mount_Body_Spider-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -106px;width:105px;height:105px}.Mount_Body_Spider-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -212px;width:105px;height:105px}.Mount_Body_TRex-Base{background-image:url(spritesmith-main-7.png);background-position:0 -544px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -136px;width:135px;height:135px}.Mount_Body_TRex-Desert{background-image:url(spritesmith-main-7.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Body_TRex-Golden{background-image:url(spritesmith-main-7.png);background-position:-272px 0;width:135px;height:135px}.Mount_Body_TRex-Red{background-image:url(spritesmith-main-7.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Body_TRex-Shade{background-image:url(spritesmith-main-7.png);background-position:0 -272px;width:135px;height:135px}.Mount_Body_TRex-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Body_TRex-White{background-image:url(spritesmith-main-7.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Body_TRex-Zombie{background-image:url(spritesmith-main-7.png);background-position:-408px 0;width:135px;height:135px}.Mount_Body_TigerCub-Base{background-image:url(spritesmith-main-7.png);background-position:-106px -795px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-212px -795px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-318px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-424px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-530px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Red{background-image:url(spritesmith-main-7.png);background-position:-636px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-742px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-848px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-998px 0;width:105px;height:105px}.Mount_Body_TigerCub-White{background-image:url(spritesmith-main-7.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Body_TigerCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Body_Turkey-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Body_Whale-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Body_Whale-Desert{background-image:url(spritesmith-main-7.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Body_Whale-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -901px;width:105px;height:105px}.Mount_Body_Whale-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -901px;width:105px;height:105px}.Mount_Body_Whale-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -901px;width:105px;height:105px}.Mount_Body_Whale-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -901px;width:105px;height:105px}.Mount_Body_Whale-White{background-image:url(spritesmith-main-7.png);background-position:-424px -901px;width:105px;height:105px}.Mount_Body_Whale-Zombie{background-image:url(spritesmith-main-7.png);background-position:-530px -901px;width:105px;height:105px}.Mount_Body_Wolf-Base{background-image:url(spritesmith-main-7.png);background-position:0 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -408px;width:135px;height:135px}.Mount_Body_Wolf-Desert{background-image:url(spritesmith-main-7.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Body_Wolf-Golden{background-image:url(spritesmith-main-7.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Body_Wolf-Red{background-image:url(spritesmith-main-7.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Body_Wolf-Shade{background-image:url(spritesmith-main-7.png);background-position:-544px 0;width:135px;height:135px}.Mount_Body_Wolf-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Body_Wolf-Spooky{background-image:url(spritesmith-main-7.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Body_Wolf-White{background-image:url(spritesmith-main-7.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Body_Wolf-Zombie{background-image:url(spritesmith-main-7.png);background-position:-136px 0;width:135px;height:135px}.Mount_Head_BearCub-Base{background-image:url(spritesmith-main-7.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -1007px;width:105px;height:105px}.Mount_Head_BearCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-106px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-212px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Polar{background-image:url(spritesmith-main-7.png);background-position:-318px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Red{background-image:url(spritesmith-main-7.png);background-position:-424px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-530px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-742px -1007px;width:105px;height:105px}.Mount_Head_BearCub-White{background-image:url(spritesmith-main-7.png);background-position:-848px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1007px;width:105px;height:105px}.Mount_Head_Bunny-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1007px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Bunny-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Bunny-Golden{background-image:url(spritesmith-main-7.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Bunny-Red{background-image:url(spritesmith-main-7.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Bunny-Shade{background-image:url(spritesmith-main-7.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Bunny-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Bunny-White{background-image:url(spritesmith-main-7.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Bunny-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Cactus-Base{background-image:url(spritesmith-main-7.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 -1113px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-106px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Desert{background-image:url(spritesmith-main-7.png);background-position:-212px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Golden{background-image:url(spritesmith-main-7.png);background-position:-318px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Red{background-image:url(spritesmith-main-7.png);background-position:-424px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Shade{background-image:url(spritesmith-main-7.png);background-position:-530px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Spooky{background-image:url(spritesmith-main-7.png);background-position:-742px -1113px;width:105px;height:105px}.Mount_Head_Cactus-White{background-image:url(spritesmith-main-7.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_Cactus-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Cheetah-Desert{background-image:url(spritesmith-main-7.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Cheetah-Golden{background-image:url(spritesmith-main-7.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Cheetah-Red{background-image:url(spritesmith-main-7.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Cheetah-Shade{background-image:url(spritesmith-main-7.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Cheetah-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Cheetah-White{background-image:url(spritesmith-main-7.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Cheetah-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Cuttlefish-Base{background-image:url(spritesmith-main-7.png);background-position:-242px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-348px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-454px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Desert{background-image:url(spritesmith-main-7.png);background-position:-560px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Golden{background-image:url(spritesmith-main-7.png);background-position:-680px 0;width:105px;height:114px}.Mount_Head_Cuttlefish-Red{background-image:url(spritesmith-main-7.png);background-position:-680px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Shade{background-image:url(spritesmith-main-7.png);background-position:-680px -230px;width:105px;height:114px}.Mount_Head_Cuttlefish-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-680px -345px;width:105px;height:114px}.Mount_Head_Cuttlefish-White{background-image:url(spritesmith-main-7.png);background-position:-680px -460px;width:105px;height:114px}.Mount_Head_Cuttlefish-Zombie{background-image:url(spritesmith-main-7.png);background-position:-786px 0;width:105px;height:114px}.Mount_Head_Deer-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -1219px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -1219px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -1219px;width:105px;height:105px}.Mount_Head_Deer-Desert{background-image:url(spritesmith-main-7.png);background-position:-1060px -1219px;width:105px;height:105px}.Mount_Head_Deer-Golden{background-image:url(spritesmith-main-7.png);background-position:-1166px -1219px;width:105px;height:105px}.Mount_Head_Deer-Red{background-image:url(spritesmith-main-7.png);background-position:-1272px -1219px;width:105px;height:105px}.Mount_Head_Deer-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_Deer-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_Deer-White{background-image:url(spritesmith-main-7.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_Deer-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_Dragon-Base{background-image:url(spritesmith-main-7.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_Dragon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_Dragon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_Dragon-Red{background-image:url(spritesmith-main-7.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_Dragon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_Dragon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Dragon-Spooky{background-image:url(spritesmith-main-7.png);background-position:0 -1325px;width:105px;height:105px}.Mount_Head_Dragon-White{background-image:url(spritesmith-main-7.png);background-position:-106px -1325px;width:105px;height:105px}.Mount_Head_Dragon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-212px -1325px;width:105px;height:105px}.Mount_Head_Egg-Base{background-image:url(spritesmith-main-7.png);background-position:-318px -1325px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-424px -1325px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-530px -1325px;width:105px;height:105px}.Mount_Head_Egg-Desert{background-image:url(spritesmith-main-7.png);background-position:-636px -1325px;width:105px;height:105px}.Mount_Head_Egg-Golden{background-image:url(spritesmith-main-7.png);background-position:-742px -1325px;width:105px;height:105px}.Mount_Head_Egg-Red{background-image:url(spritesmith-main-7.png);background-position:-848px -1325px;width:105px;height:105px}.Mount_Head_Egg-Shade{background-image:url(spritesmith-main-7.png);background-position:-954px -1325px;width:105px;height:105px}.Mount_Head_Egg-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1060px -1325px;width:105px;height:105px}.Mount_Head_Egg-White{background-image:url(spritesmith-main-7.png);background-position:-1166px -1325px;width:105px;height:105px}.Mount_Head_Egg-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1272px -1325px;width:105px;height:105px}.Mount_Head_FlyingPig-Base{background-image:url(spritesmith-main-7.png);background-position:-1378px -1325px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Head_FlyingPig-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Head_FlyingPig-Golden{background-image:url(spritesmith-main-7.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Head_FlyingPig-Red{background-image:url(spritesmith-main-7.png);background-position:-1528px -424px;width:105px;height:105px}.Mount_Head_FlyingPig-Shade{background-image:url(spritesmith-main-7.png);background-position:-1528px -530px;width:105px;height:105px}.Mount_Head_FlyingPig-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1528px -636px;width:105px;height:105px}.Mount_Head_FlyingPig-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1528px -742px;width:105px;height:105px}.Mount_Head_FlyingPig-White{background-image:url(spritesmith-main-7.png);background-position:-1528px -848px;width:105px;height:105px}.Mount_Head_FlyingPig-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1528px -954px;width:105px;height:105px}.Mount_Head_Fox-Base{background-image:url(spritesmith-main-7.png);background-position:-1528px -1060px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px -1166px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -1272px;width:105px;height:105px}.Mount_Head_Fox-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -1431px;width:105px;height:105px}.Mount_Head_Fox-Golden{background-image:url(spritesmith-main-7.png);background-position:-106px -1431px;width:105px;height:105px}.Mount_Head_Fox-Red{background-image:url(spritesmith-main-7.png);background-position:-212px -1431px;width:105px;height:105px}.Mount_Head_Fox-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1431px;width:105px;height:105px}.Mount_Head_Fox-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1431px;width:105px;height:105px}.Mount_Head_Fox-Spooky{background-image:url(spritesmith-main-7.png);background-position:-530px -1431px;width:105px;height:105px}.Mount_Head_Fox-White{background-image:url(spritesmith-main-7.png);background-position:-636px -1431px;width:105px;height:105px}.Mount_Head_Fox-Zombie{background-image:url(spritesmith-main-7.png);background-position:-742px -1431px;width:105px;height:105px}.Mount_Head_Frog-Base{background-image:url(spritesmith-main-7.png);background-position:-786px -115px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-786px -230px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -345px;width:105px;height:114px}.Mount_Head_Frog-Desert{background-image:url(spritesmith-main-7.png);background-position:-786px -460px;width:105px;height:114px}.Mount_Head_Frog-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -680px;width:105px;height:114px}.Mount_Head_Frog-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -680px;width:105px;height:114px}.Mount_Head_Frog-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -680px;width:105px;height:114px}.Mount_Head_Frog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -680px;width:105px;height:114px}.Mount_Head_Frog-White{background-image:url(spritesmith-main-7.png);background-position:-424px -680px;width:105px;height:114px}.Mount_Head_Frog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-136px -544px;width:105px;height:114px}.Mount_Head_Gryphon-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -318px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1634px -424px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1634px -530px;width:105px;height:105px}.Mount_Head_Gryphon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1634px -636px;width:105px;height:105px}.Mount_Head_Gryphon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1634px -742px;width:105px;height:105px}.Mount_Head_Gryphon-Red{background-image:url(spritesmith-main-7.png);background-position:-1634px -848px;width:105px;height:105px}.Mount_Head_Gryphon-RoyalPurple{background-image:url(spritesmith-main-7.png);background-position:-1634px -954px;width:105px;height:105px}.Mount_Head_Gryphon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -1060px;width:105px;height:105px}.Mount_Head_Gryphon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px -1166px;width:105px;height:105px}.Mount_Head_Gryphon-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -1272px;width:105px;height:105px}.Mount_Head_Gryphon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -1378px;width:105px;height:105px}.Mount_Head_Hedgehog-Base{background-image:url(spritesmith-main-7.png);background-position:0 -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-106px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-212px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Desert{background-image:url(spritesmith-main-7.png);background-position:-318px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Golden{background-image:url(spritesmith-main-7.png);background-position:-424px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Red{background-image:url(spritesmith-main-7.png);background-position:-530px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Shade{background-image:url(spritesmith-main-7.png);background-position:-636px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-742px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-White{background-image:url(spritesmith-main-7.png);background-position:-848px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1537px;width:105px;height:105px}.Mount_Head_Horse-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1537px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1537px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1272px -1537px;width:105px;height:105px}.Mount_Head_Horse-Desert{background-image:url(spritesmith-main-7.png);background-position:-1378px -1537px;width:105px;height:105px}.Mount_Head_Horse-Golden{background-image:url(spritesmith-main-7.png);background-position:-1484px -1537px;width:105px;height:105px}.Mount_Head_Horse-Red{background-image:url(spritesmith-main-7.png);background-position:-1590px -1537px;width:105px;height:105px}.Mount_Head_Horse-Shade{background-image:url(spritesmith-main-7.png);background-position:-1740px 0;width:105px;height:105px}.Mount_Head_Horse-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1740px -106px;width:105px;height:105px}.Mount_Head_Horse-White{background-image:url(spritesmith-main-7.png);background-position:-1740px -212px;width:105px;height:105px}.Mount_Head_Horse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1740px -318px;width:105px;height:105px}.Mount_Head_JackOLantern-Base{background-image:url(spritesmith-main-7.png);background-position:-1740px -424px;width:90px;height:105px}.Mount_Head_LionCub-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -1316px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-318px -1210px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Ethereal{background-image:url(spritesmith-main-8.png);background-position:-106px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-212px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Red{background-image:url(spritesmith-main-8.png);background-position:-318px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-424px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-242px -544px;width:105px;height:110px}.Mount_Head_LionCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-636px -1316px;width:105px;height:105px}.Mount_Head_LionCub-White{background-image:url(spritesmith-main-8.png);background-position:-742px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-848px -1316px;width:105px;height:105px}.Mount_Head_Mammoth-Base{background-image:url(spritesmith-main-8.png);background-position:-136px -544px;width:105px;height:123px}.Mount_Head_MantisShrimp-Base{background-image:url(spritesmith-main-8.png);background-position:-348px -544px;width:108px;height:105px}.Mount_Head_Octopus-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -1422px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -1422px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1166px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Red{background-image:url(spritesmith-main-8.png);background-position:-1272px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1378px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Head_Octopus-White{background-image:url(spritesmith-main-8.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Head_Octopus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Head_Orca-Base{background-image:url(spritesmith-main-8.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Head_Owl-Base{background-image:url(spritesmith-main-8.png);background-position:-563px -544px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-680px 0;width:105px;height:105px}.Mount_Head_Owl-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-680px -106px;width:105px;height:105px}.Mount_Head_Owl-Desert{background-image:url(spritesmith-main-8.png);background-position:-680px -212px;width:105px;height:105px}.Mount_Head_Owl-Golden{background-image:url(spritesmith-main-8.png);background-position:-680px -318px;width:105px;height:105px}.Mount_Head_Owl-Red{background-image:url(spritesmith-main-8.png);background-position:-680px -424px;width:105px;height:105px}.Mount_Head_Owl-Shade{background-image:url(spritesmith-main-8.png);background-position:-680px -530px;width:105px;height:105px}.Mount_Head_Owl-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -680px;width:105px;height:105px}.Mount_Head_Owl-White{background-image:url(spritesmith-main-8.png);background-position:-106px -680px;width:105px;height:105px}.Mount_Head_Owl-Zombie{background-image:url(spritesmith-main-8.png);background-position:-212px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -680px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -680px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-786px 0;width:105px;height:105px}.Mount_Head_PandaCub-Red{background-image:url(spritesmith-main-8.png);background-position:-786px -106px;width:105px;height:105px}.Mount_Head_PandaCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-786px -212px;width:105px;height:105px}.Mount_Head_PandaCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-786px -318px;width:105px;height:105px}.Mount_Head_PandaCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-786px -424px;width:105px;height:105px}.Mount_Head_PandaCub-White{background-image:url(spritesmith-main-8.png);background-position:-786px -530px;width:105px;height:105px}.Mount_Head_PandaCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-786px -636px;width:105px;height:105px}.Mount_Head_Parrot-Base{background-image:url(spritesmith-main-8.png);background-position:0 -786px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-106px -786px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-212px -786px;width:105px;height:105px}.Mount_Head_Parrot-Desert{background-image:url(spritesmith-main-8.png);background-position:-318px -786px;width:105px;height:105px}.Mount_Head_Parrot-Golden{background-image:url(spritesmith-main-8.png);background-position:-424px -786px;width:105px;height:105px}.Mount_Head_Parrot-Red{background-image:url(spritesmith-main-8.png);background-position:-530px -786px;width:105px;height:105px}.Mount_Head_Parrot-Shade{background-image:url(spritesmith-main-8.png);background-position:-636px -786px;width:105px;height:105px}.Mount_Head_Parrot-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-742px -786px;width:105px;height:105px}.Mount_Head_Parrot-White{background-image:url(spritesmith-main-8.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Parrot-Zombie{background-image:url(spritesmith-main-8.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Head_Penguin-Base{background-image:url(spritesmith-main-8.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Head_Penguin-Desert{background-image:url(spritesmith-main-8.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Head_Penguin-Golden{background-image:url(spritesmith-main-8.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Head_Penguin-Red{background-image:url(spritesmith-main-8.png);background-position:-892px -742px;width:105px;height:105px}.Mount_Head_Penguin-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -892px;width:105px;height:105px}.Mount_Head_Penguin-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-106px -892px;width:105px;height:105px}.Mount_Head_Penguin-White{background-image:url(spritesmith-main-8.png);background-position:-212px -892px;width:105px;height:105px}.Mount_Head_Penguin-Zombie{background-image:url(spritesmith-main-8.png);background-position:-318px -892px;width:105px;height:105px}.Mount_Head_Phoenix-Base{background-image:url(spritesmith-main-8.png);background-position:-424px -892px;width:105px;height:105px}.Mount_Head_Rat-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -892px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -892px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -892px;width:105px;height:105px}.Mount_Head_Rat-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -892px;width:105px;height:105px}.Mount_Head_Rat-Golden{background-image:url(spritesmith-main-8.png);background-position:-998px 0;width:105px;height:105px}.Mount_Head_Rat-Red{background-image:url(spritesmith-main-8.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Head_Rat-Shade{background-image:url(spritesmith-main-8.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Head_Rat-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_Rat-White{background-image:url(spritesmith-main-8.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_Rat-Zombie{background-image:url(spritesmith-main-8.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_Rock-Base{background-image:url(spritesmith-main-8.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-998px -848px;width:105px;height:105px}.Mount_Head_Rock-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -998px;width:105px;height:105px}.Mount_Head_Rock-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -998px;width:105px;height:105px}.Mount_Head_Rock-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -998px;width:105px;height:105px}.Mount_Head_Rock-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -998px;width:105px;height:105px}.Mount_Head_Rock-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -998px;width:105px;height:105px}.Mount_Head_Rock-White{background-image:url(spritesmith-main-8.png);background-position:-530px -998px;width:105px;height:105px}.Mount_Head_Rock-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -998px;width:105px;height:105px}.Mount_Head_Rooster-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -998px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -998px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -998px;width:105px;height:105px}.Mount_Head_Rooster-Desert{background-image:url(spritesmith-main-8.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Rooster-Golden{background-image:url(spritesmith-main-8.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Rooster-Red{background-image:url(spritesmith-main-8.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Rooster-Shade{background-image:url(spritesmith-main-8.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Rooster-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Rooster-White{background-image:url(spritesmith-main-8.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Rooster-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Seahorse-Base{background-image:url(spritesmith-main-8.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1104px -954px;width:105px;height:105px}.Mount_Head_Seahorse-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-White{background-image:url(spritesmith-main-8.png);background-position:-530px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -1104px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -1104px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Sheep-Red{background-image:url(spritesmith-main-8.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Sheep-Shade{background-image:url(spritesmith-main-8.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Sheep-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Sheep-White{background-image:url(spritesmith-main-8.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Sheep-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Slime-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Slime-Desert{background-image:url(spritesmith-main-8.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Slime-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px -1060px;width:105px;height:105px}.Mount_Head_Slime-Red{background-image:url(spritesmith-main-8.png);background-position:0 -1210px;width:105px;height:105px}.Mount_Head_Slime-Shade{background-image:url(spritesmith-main-8.png);background-position:-106px -1210px;width:105px;height:105px}.Mount_Head_Slime-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-212px -1210px;width:105px;height:105px}.Mount_Head_Slime-White{background-image:url(spritesmith-main-8.png);background-position:-457px -544px;width:105px;height:105px}.Mount_Head_Slime-Zombie{background-image:url(spritesmith-main-8.png);background-position:-424px -1210px;width:105px;height:105px}.Mount_Head_Snake-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -1210px;width:105px;height:105px}.Mount_Head_Snake-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -1210px;width:105px;height:105px}.Mount_Head_Snake-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -1210px;width:105px;height:105px}.Mount_Head_Snake-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -1210px;width:105px;height:105px}.Mount_Head_Snake-Golden{background-image:url(spritesmith-main-8.png);background-position:-954px -1210px;width:105px;height:105px}.Mount_Head_Snake-Red{background-image:url(spritesmith-main-8.png);background-position:-1060px -1210px;width:105px;height:105px}.Mount_Head_Snake-Shade{background-image:url(spritesmith-main-8.png);background-position:-1166px -1210px;width:105px;height:105px}.Mount_Head_Snake-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Snake-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Snake-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Spider-Base{background-image:url(spritesmith-main-8.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Spider-Desert{background-image:url(spritesmith-main-8.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Spider-Golden{background-image:url(spritesmith-main-8.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Spider-Red{background-image:url(spritesmith-main-8.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Spider-Shade{background-image:url(spritesmith-main-8.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Spider-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Spider-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -1166px;width:105px;height:105px}.Mount_Head_Spider-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1316px;width:105px;height:105px}.Mount_Head_TRex-Base{background-image:url(spritesmith-main-8.png);background-position:-272px 0;width:135px;height:135px}.Mount_Head_TRex-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:0 -272px;width:135px;height:135px}.Mount_Head_TRex-Desert{background-image:url(spritesmith-main-8.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Head_TRex-Golden{background-image:url(spritesmith-main-8.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Head_TRex-Red{background-image:url(spritesmith-main-8.png);background-position:-408px 0;width:135px;height:135px}.Mount_Head_TRex-Shade{background-image:url(spritesmith-main-8.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Head_TRex-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Head_TRex-White{background-image:url(spritesmith-main-8.png);background-position:0 0;width:135px;height:135px}.Mount_Head_TRex-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Head_TigerCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1166px -1316px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1272px -1316px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_TigerCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_TigerCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_TigerCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_TigerCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_TigerCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_TigerCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_TigerCub-White{background-image:url(spritesmith-main-8.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_TigerCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_Turkey-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_Whale-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px -1272px;width:105px;height:105px}.Mount_Head_Whale-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1422px;width:105px;height:105px}.Mount_Head_Whale-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -1422px;width:105px;height:105px}.Mount_Head_Whale-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -1422px;width:105px;height:105px}.Mount_Head_Whale-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -1422px;width:105px;height:105px}.Mount_Head_Whale-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -1422px;width:105px;height:105px}.Mount_Head_Whale-White{background-image:url(spritesmith-main-8.png);background-position:-530px -1422px;width:105px;height:105px}.Mount_Head_Whale-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -1422px;width:105px;height:105px}.Mount_Head_Wolf-Base{background-image:url(spritesmith-main-8.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-544px 0;width:135px;height:135px}.Mount_Head_Wolf-Desert{background-image:url(spritesmith-main-8.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Head_Wolf-Golden{background-image:url(spritesmith-main-8.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Head_Wolf-Red{background-image:url(spritesmith-main-8.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_Wolf-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -544px;width:135px;height:135px}.Mount_Head_Wolf-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -408px;width:135px;height:135px}.Mount_Head_Wolf-Spooky{background-image:url(spritesmith-main-8.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Head_Wolf-White{background-image:url(spritesmith-main-8.png);background-position:0 -136px;width:135px;height:135px}.Mount_Head_Wolf-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px 0;width:135px;height:135px}.Pet-BearCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1528px -524px;width:81px;height:99px}.Pet-BearCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px 0;width:81px;height:99px}.Pet-BearCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1528px -624px;width:81px;height:99px}.Pet-BearCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1528px -724px;width:81px;height:99px}.Pet-BearCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1528px -824px;width:81px;height:99px}.Pet-BearCub-Polar{background-image:url(spritesmith-main-8.png);background-position:-1528px -924px;width:81px;height:99px}.Pet-BearCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1528px -1024px;width:81px;height:99px}.Pet-BearCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1528px -1124px;width:81px;height:99px}.Pet-BearCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1528px -1224px;width:81px;height:99px}.Pet-BearCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1528px -1324px;width:81px;height:99px}.Pet-BearCub-White{background-image:url(spritesmith-main-8.png);background-position:-1528px -1424px;width:81px;height:99px}.Pet-BearCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1528px;width:81px;height:99px}.Pet-Bunny-Base{background-image:url(spritesmith-main-8.png);background-position:-82px -1528px;width:81px;height:99px}.Pet-Bunny-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-164px -1528px;width:81px;height:99px}.Pet-Bunny-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-246px -1528px;width:81px;height:99px}.Pet-Bunny-Desert{background-image:url(spritesmith-main-8.png);background-position:-328px -1528px;width:81px;height:99px}.Pet-Bunny-Golden{background-image:url(spritesmith-main-8.png);background-position:-410px -1528px;width:81px;height:99px}.Pet-Bunny-Red{background-image:url(spritesmith-main-8.png);background-position:-492px -1528px;width:81px;height:99px}.Pet-Bunny-Shade{background-image:url(spritesmith-main-8.png);background-position:-574px -1528px;width:81px;height:99px}.Pet-Bunny-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-656px -1528px;width:81px;height:99px}.Pet-Bunny-White{background-image:url(spritesmith-main-8.png);background-position:-738px -1528px;width:81px;height:99px}.Pet-Bunny-Zombie{background-image:url(spritesmith-main-8.png);background-position:-820px -1528px;width:81px;height:99px}.Pet-Cactus-Base{background-image:url(spritesmith-main-8.png);background-position:-902px -1528px;width:81px;height:99px}.Pet-Cactus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-984px -1528px;width:81px;height:99px}.Pet-Cactus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1066px -1528px;width:81px;height:99px}.Pet-Cactus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1148px -1528px;width:81px;height:99px}.Pet-Cactus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1230px -1528px;width:81px;height:99px}.Pet-Cactus-Red{background-image:url(spritesmith-main-8.png);background-position:-1312px -1528px;width:81px;height:99px}.Pet-Cactus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1394px -1528px;width:81px;height:99px}.Pet-Cactus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1476px -1528px;width:81px;height:99px}.Pet-Cactus-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1528px -424px;width:81px;height:99px}.Pet-Cactus-White{background-image:url(spritesmith-main-8.png);background-position:-1634px -100px;width:81px;height:99px}.Pet-Cactus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1634px -200px;width:81px;height:99px}.Pet-Cheetah-Base{background-image:url(spritesmith-main-8.png);background-position:-1634px -300px;width:81px;height:99px}.Pet-Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px -400px;width:81px;height:99px}.Pet-Cheetah-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1634px -500px;width:81px;height:99px}.Pet-Cheetah-Desert{background-image:url(spritesmith-main-8.png);background-position:-1634px -600px;width:81px;height:99px}.Pet-Cheetah-Golden{background-image:url(spritesmith-main-8.png);background-position:-1634px -700px;width:81px;height:99px}.Pet-Cheetah-Red{background-image:url(spritesmith-main-8.png);background-position:-1634px -800px;width:81px;height:99px}.Pet-Cheetah-Shade{background-image:url(spritesmith-main-8.png);background-position:-1634px -900px;width:81px;height:99px}.Pet-Cheetah-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1634px -1000px;width:81px;height:99px}.Pet-Cheetah-White{background-image:url(spritesmith-main-8.png);background-position:-1634px -1100px;width:81px;height:99px}.Pet-Cheetah-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1634px -1200px;width:81px;height:99px}.Pet-Cuttlefish-Base{background-image:url(spritesmith-main-8.png);background-position:-1634px -1300px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px -1400px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1634px -1500px;width:81px;height:99px}.Pet-Cuttlefish-Desert{background-image:url(spritesmith-main-8.png);background-position:-1716px 0;width:81px;height:99px}.Pet-Cuttlefish-Golden{background-image:url(spritesmith-main-8.png);background-position:-1716px -100px;width:81px;height:99px}.Pet-Cuttlefish-Red{background-image:url(spritesmith-main-8.png);background-position:-1716px -200px;width:81px;height:99px}.Pet-Cuttlefish-Shade{background-image:url(spritesmith-main-8.png);background-position:-1716px -300px;width:81px;height:99px}.Pet-Cuttlefish-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1716px -400px;width:81px;height:99px}.Pet-Cuttlefish-White{background-image:url(spritesmith-main-8.png);background-position:-1716px -500px;width:81px;height:99px}.Pet-Cuttlefish-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1716px -600px;width:81px;height:99px}.Pet-Deer-Base{background-image:url(spritesmith-main-8.png);background-position:-1716px -700px;width:81px;height:99px}.Pet-Deer-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1716px -800px;width:81px;height:99px}.Pet-Deer-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1716px -900px;width:81px;height:99px}.Pet-Deer-Desert{background-image:url(spritesmith-main-8.png);background-position:-1716px -1000px;width:81px;height:99px}.Pet-Deer-Golden{background-image:url(spritesmith-main-8.png);background-position:-1716px -1100px;width:81px;height:99px}.Pet-Deer-Red{background-image:url(spritesmith-main-8.png);background-position:-1716px -1200px;width:81px;height:99px}.Pet-Deer-Shade{background-image:url(spritesmith-main-8.png);background-position:-1716px -1300px;width:81px;height:99px}.Pet-Deer-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px 0;width:81px;height:99px}.Pet-Deer-White{background-image:url(spritesmith-main-9.png);background-position:-328px -1000px;width:81px;height:99px}.Pet-Deer-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px 0;width:81px;height:99px}.Pet-Dragon-Base{background-image:url(spritesmith-main-9.png);background-position:0 -100px;width:81px;height:99px}.Pet-Dragon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -100px;width:81px;height:99px}.Pet-Dragon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -100px;width:81px;height:99px}.Pet-Dragon-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px 0;width:81px;height:99px}.Pet-Dragon-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -100px;width:81px;height:99px}.Pet-Dragon-Hydra{background-image:url(spritesmith-main-9.png);background-position:0 -200px;width:81px;height:99px}.Pet-Dragon-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -200px;width:81px;height:99px}.Pet-Dragon-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -200px;width:81px;height:99px}.Pet-Dragon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -200px;width:81px;height:99px}.Pet-Dragon-Spooky{background-image:url(spritesmith-main-9.png);background-position:-328px 0;width:81px;height:99px}.Pet-Dragon-White{background-image:url(spritesmith-main-9.png);background-position:-328px -100px;width:81px;height:99px}.Pet-Dragon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-328px -200px;width:81px;height:99px}.Pet-Egg-Base{background-image:url(spritesmith-main-9.png);background-position:0 -300px;width:81px;height:99px}.Pet-Egg-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -300px;width:81px;height:99px}.Pet-Egg-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -300px;width:81px;height:99px}.Pet-Egg-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -300px;width:81px;height:99px}.Pet-Egg-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -300px;width:81px;height:99px}.Pet-Egg-Red{background-image:url(spritesmith-main-9.png);background-position:-410px 0;width:81px;height:99px}.Pet-Egg-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -100px;width:81px;height:99px}.Pet-Egg-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -200px;width:81px;height:99px}.Pet-Egg-White{background-image:url(spritesmith-main-9.png);background-position:-410px -300px;width:81px;height:99px}.Pet-Egg-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px 0;width:81px;height:99px}.Pet-FlyingPig-Base{background-image:url(spritesmith-main-9.png);background-position:-492px -100px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -200px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -300px;width:81px;height:99px}.Pet-FlyingPig-Desert{background-image:url(spritesmith-main-9.png);background-position:0 -400px;width:81px;height:99px}.Pet-FlyingPig-Golden{background-image:url(spritesmith-main-9.png);background-position:-82px -400px;width:81px;height:99px}.Pet-FlyingPig-Red{background-image:url(spritesmith-main-9.png);background-position:-164px -400px;width:81px;height:99px}.Pet-FlyingPig-Shade{background-image:url(spritesmith-main-9.png);background-position:-246px -400px;width:81px;height:99px}.Pet-FlyingPig-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -400px;width:81px;height:99px}.Pet-FlyingPig-Spooky{background-image:url(spritesmith-main-9.png);background-position:-410px -400px;width:81px;height:99px}.Pet-FlyingPig-White{background-image:url(spritesmith-main-9.png);background-position:-492px -400px;width:81px;height:99px}.Pet-FlyingPig-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px 0;width:81px;height:99px}.Pet-Fox-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -100px;width:81px;height:99px}.Pet-Fox-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-574px -200px;width:81px;height:99px}.Pet-Fox-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -300px;width:81px;height:99px}.Pet-Fox-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -400px;width:81px;height:99px}.Pet-Fox-Golden{background-image:url(spritesmith-main-9.png);background-position:0 -500px;width:81px;height:99px}.Pet-Fox-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -500px;width:81px;height:99px}.Pet-Fox-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -500px;width:81px;height:99px}.Pet-Fox-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -500px;width:81px;height:99px}.Pet-Fox-Spooky{background-image:url(spritesmith-main-9.png);background-position:-328px -500px;width:81px;height:99px}.Pet-Fox-White{background-image:url(spritesmith-main-9.png);background-position:-410px -500px;width:81px;height:99px}.Pet-Fox-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -500px;width:81px;height:99px}.Pet-Frog-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -500px;width:81px;height:99px}.Pet-Frog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px 0;width:81px;height:99px}.Pet-Frog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-656px -100px;width:81px;height:99px}.Pet-Frog-Desert{background-image:url(spritesmith-main-9.png);background-position:-656px -200px;width:81px;height:99px}.Pet-Frog-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -300px;width:81px;height:99px}.Pet-Frog-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -400px;width:81px;height:99px}.Pet-Frog-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -500px;width:81px;height:99px}.Pet-Frog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:0 -600px;width:81px;height:99px}.Pet-Frog-White{background-image:url(spritesmith-main-9.png);background-position:-82px -600px;width:81px;height:99px}.Pet-Frog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px -600px;width:81px;height:99px}.Pet-Gryphon-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -600px;width:81px;height:99px}.Pet-Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-328px -600px;width:81px;height:99px}.Pet-Gryphon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-410px -600px;width:81px;height:99px}.Pet-Gryphon-Desert{background-image:url(spritesmith-main-9.png);background-position:-492px -600px;width:81px;height:99px}.Pet-Gryphon-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -600px;width:81px;height:99px}.Pet-Gryphon-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -600px;width:81px;height:99px}.Pet-Gryphon-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px 0;width:81px;height:99px}.Pet-Gryphon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -100px;width:81px;height:99px}.Pet-Gryphon-White{background-image:url(spritesmith-main-9.png);background-position:-738px -200px;width:81px;height:99px}.Pet-Gryphon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-738px -300px;width:81px;height:99px}.Pet-Hedgehog-Base{background-image:url(spritesmith-main-9.png);background-position:-738px -400px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -500px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-738px -600px;width:81px;height:99px}.Pet-Hedgehog-Desert{background-image:url(spritesmith-main-9.png);background-position:0 -700px;width:81px;height:99px}.Pet-Hedgehog-Golden{background-image:url(spritesmith-main-9.png);background-position:-82px -700px;width:81px;height:99px}.Pet-Hedgehog-Red{background-image:url(spritesmith-main-9.png);background-position:-164px -700px;width:81px;height:99px}.Pet-Hedgehog-Shade{background-image:url(spritesmith-main-9.png);background-position:-246px -700px;width:81px;height:99px}.Pet-Hedgehog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -700px;width:81px;height:99px}.Pet-Hedgehog-White{background-image:url(spritesmith-main-9.png);background-position:-410px -700px;width:81px;height:99px}.Pet-Hedgehog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -700px;width:81px;height:99px}.Pet-Horse-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -700px;width:81px;height:99px}.Pet-Horse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px -700px;width:81px;height:99px}.Pet-Horse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-738px -700px;width:81px;height:99px}.Pet-Horse-Desert{background-image:url(spritesmith-main-9.png);background-position:-820px 0;width:81px;height:99px}.Pet-Horse-Golden{background-image:url(spritesmith-main-9.png);background-position:-820px -100px;width:81px;height:99px}.Pet-Horse-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -200px;width:81px;height:99px}.Pet-Horse-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -300px;width:81px;height:99px}.Pet-Horse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-820px -400px;width:81px;height:99px}.Pet-Horse-White{background-image:url(spritesmith-main-9.png);background-position:-820px -500px;width:81px;height:99px}.Pet-Horse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -600px;width:81px;height:99px}.Pet-JackOLantern-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -700px;width:81px;height:99px}.Pet-LionCub-Base{background-image:url(spritesmith-main-9.png);background-position:0 -800px;width:81px;height:99px}.Pet-LionCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -800px;width:81px;height:99px}.Pet-LionCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -800px;width:81px;height:99px}.Pet-LionCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -800px;width:81px;height:99px}.Pet-LionCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -800px;width:81px;height:99px}.Pet-LionCub-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -800px;width:81px;height:99px}.Pet-LionCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -800px;width:81px;height:99px}.Pet-LionCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -800px;width:81px;height:99px}.Pet-LionCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px -800px;width:81px;height:99px}.Pet-LionCub-White{background-image:url(spritesmith-main-9.png);background-position:-738px -800px;width:81px;height:99px}.Pet-LionCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -800px;width:81px;height:99px}.Pet-Mammoth-Base{background-image:url(spritesmith-main-9.png);background-position:-902px 0;width:81px;height:99px}.Pet-MantisShrimp-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -100px;width:81px;height:99px}.Pet-Octopus-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -200px;width:81px;height:99px}.Pet-Octopus-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -300px;width:81px;height:99px}.Pet-Octopus-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-902px -400px;width:81px;height:99px}.Pet-Octopus-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -500px;width:81px;height:99px}.Pet-Octopus-Golden{background-image:url(spritesmith-main-9.png);background-position:-902px -600px;width:81px;height:99px}.Pet-Octopus-Red{background-image:url(spritesmith-main-9.png);background-position:-902px -700px;width:81px;height:99px}.Pet-Octopus-Shade{background-image:url(spritesmith-main-9.png);background-position:-902px -800px;width:81px;height:99px}.Pet-Octopus-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-984px 0;width:81px;height:99px}.Pet-Octopus-White{background-image:url(spritesmith-main-9.png);background-position:-984px -100px;width:81px;height:99px}.Pet-Octopus-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px -200px;width:81px;height:99px}.Pet-Owl-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -300px;width:81px;height:99px}.Pet-Owl-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -400px;width:81px;height:99px}.Pet-Owl-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -500px;width:81px;height:99px}.Pet-Owl-Desert{background-image:url(spritesmith-main-9.png);background-position:-984px -600px;width:81px;height:99px}.Pet-Owl-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -700px;width:81px;height:99px}.Pet-Owl-Red{background-image:url(spritesmith-main-9.png);background-position:-984px -800px;width:81px;height:99px}.Pet-Owl-Shade{background-image:url(spritesmith-main-9.png);background-position:0 -900px;width:81px;height:99px}.Pet-Owl-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px -900px;width:81px;height:99px}.Pet-Owl-White{background-image:url(spritesmith-main-9.png);background-position:-164px -900px;width:81px;height:99px}.Pet-Owl-Zombie{background-image:url(spritesmith-main-9.png);background-position:-246px -900px;width:81px;height:99px}.Pet-PandaCub-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -900px;width:81px;height:99px}.Pet-PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-410px -900px;width:81px;height:99px}.Pet-PandaCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -900px;width:81px;height:99px}.Pet-PandaCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -900px;width:81px;height:99px}.Pet-PandaCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -900px;width:81px;height:99px}.Pet-PandaCub-Red{background-image:url(spritesmith-main-9.png);background-position:-738px -900px;width:81px;height:99px}.Pet-PandaCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -900px;width:81px;height:99px}.Pet-PandaCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-902px -900px;width:81px;height:99px}.Pet-PandaCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-984px -900px;width:81px;height:99px}.Pet-PandaCub-White{background-image:url(spritesmith-main-9.png);background-position:-1066px 0;width:81px;height:99px}.Pet-PandaCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1066px -100px;width:81px;height:99px}.Pet-Parrot-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -200px;width:81px;height:99px}.Pet-Parrot-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1066px -300px;width:81px;height:99px}.Pet-Parrot-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -400px;width:81px;height:99px}.Pet-Parrot-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -500px;width:81px;height:99px}.Pet-Parrot-Golden{background-image:url(spritesmith-main-9.png);background-position:-1066px -600px;width:81px;height:99px}.Pet-Parrot-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -700px;width:81px;height:99px}.Pet-Parrot-Shade{background-image:url(spritesmith-main-9.png);background-position:-1066px -800px;width:81px;height:99px}.Pet-Parrot-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1066px -900px;width:81px;height:99px}.Pet-Parrot-White{background-image:url(spritesmith-main-9.png);background-position:0 -1000px;width:81px;height:99px}.Pet-Parrot-Zombie{background-image:url(spritesmith-main-9.png);background-position:-82px -1000px;width:81px;height:99px}.Pet-Penguin-Base{background-image:url(spritesmith-main-9.png);background-position:-164px -1000px;width:81px;height:99px}.Pet-Penguin-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-246px -1000px;width:81px;height:99px}.Pet-Penguin-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 0;width:81px;height:99px}.Pet-Penguin-Desert{background-image:url(spritesmith-main-9.png);background-position:-410px -1000px;width:81px;height:99px}.Pet-Penguin-Golden{background-image:url(spritesmith-main-9.png);background-position:-492px -1000px;width:81px;height:99px}.Pet-Penguin-Red{background-image:url(spritesmith-main-9.png);background-position:-574px -1000px;width:81px;height:99px}.Pet-Penguin-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -1000px;width:81px;height:99px}.Pet-Penguin-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -1000px;width:81px;height:99px}.Pet-Penguin-White{background-image:url(spritesmith-main-9.png);background-position:-820px -1000px;width:81px;height:99px}.Pet-Penguin-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -1000px;width:81px;height:99px}.Pet-Phoenix-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -1000px;width:81px;height:99px}.Pet-Rat-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -1000px;width:81px;height:99px}.Pet-Rat-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px 0;width:81px;height:99px}.Pet-Rat-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1148px -100px;width:81px;height:99px}.Pet-Rat-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -200px;width:81px;height:99px}.Pet-Rat-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -300px;width:81px;height:99px}.Pet-Rat-Red{background-image:url(spritesmith-main-9.png);background-position:-1148px -400px;width:81px;height:99px}.Pet-Rat-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -500px;width:81px;height:99px}.Pet-Rat-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1148px -600px;width:81px;height:99px}.Pet-Rat-White{background-image:url(spritesmith-main-9.png);background-position:-1148px -700px;width:81px;height:99px}.Pet-Rat-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -800px;width:81px;height:99px}.Pet-Rock-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -900px;width:81px;height:99px}.Pet-Rock-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px -1000px;width:81px;height:99px}.Pet-Rock-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 -1100px;width:81px;height:99px}.Pet-Rock-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px -1100px;width:81px;height:99px}.Pet-Rock-Golden{background-image:url(spritesmith-main-9.png);background-position:-164px -1100px;width:81px;height:99px}.Pet-Rock-Red{background-image:url(spritesmith-main-9.png);background-position:-246px -1100px;width:81px;height:99px}.Pet-Rock-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -1100px;width:81px;height:99px}.Pet-Rock-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -1100px;width:81px;height:99px}.Pet-Rock-White{background-image:url(spritesmith-main-9.png);background-position:-492px -1100px;width:81px;height:99px}.Pet-Rock-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px -1100px;width:81px;height:99px}.Pet-Rooster-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -1100px;width:81px;height:99px}.Pet-Rooster-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -1100px;width:81px;height:99px}.Pet-Rooster-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-820px -1100px;width:81px;height:99px}.Pet-Rooster-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -1100px;width:81px;height:99px}.Pet-Rooster-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -1100px;width:81px;height:99px}.Pet-Rooster-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -1100px;width:81px;height:99px}.Pet-Rooster-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -1100px;width:81px;height:99px}.Pet-Rooster-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px 0;width:81px;height:99px}.Pet-Rooster-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -100px;width:81px;height:99px}.Pet-Rooster-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1230px -200px;width:81px;height:99px}.Pet-Seahorse-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px -300px;width:81px;height:99px}.Pet-Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1230px -400px;width:81px;height:99px}.Pet-Seahorse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1230px -500px;width:81px;height:99px}.Pet-Seahorse-Desert{background-image:url(spritesmith-main-9.png);background-position:-1230px -600px;width:81px;height:99px}.Pet-Seahorse-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -700px;width:81px;height:99px}.Pet-Seahorse-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -800px;width:81px;height:99px}.Pet-Seahorse-Shade{background-image:url(spritesmith-main-9.png);background-position:-1230px -900px;width:81px;height:99px}.Pet-Seahorse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px -1000px;width:81px;height:99px}.Pet-Seahorse-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -1100px;width:81px;height:99px}.Pet-Seahorse-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -1200px;width:81px;height:99px}.Pet-Sheep-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -1200px;width:81px;height:99px}.Pet-Sheep-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -1200px;width:81px;height:99px}.Pet-Sheep-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px -1200px;width:81px;height:99px}.Pet-Sheep-Desert{background-image:url(spritesmith-main-9.png);background-position:-328px -1200px;width:81px;height:99px}.Pet-Sheep-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -1200px;width:81px;height:99px}.Pet-Sheep-Red{background-image:url(spritesmith-main-9.png);background-position:-492px -1200px;width:81px;height:99px}.Pet-Sheep-Shade{background-image:url(spritesmith-main-9.png);background-position:-574px -1200px;width:81px;height:99px}.Pet-Sheep-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-656px -1200px;width:81px;height:99px}.Pet-Sheep-White{background-image:url(spritesmith-main-9.png);background-position:-738px -1200px;width:81px;height:99px}.Pet-Sheep-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -1200px;width:81px;height:99px}.Pet-Slime-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -1200px;width:81px;height:99px}.Pet-Slime-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -1200px;width:81px;height:99px}.Pet-Slime-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -1200px;width:81px;height:99px}.Pet-Slime-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -1200px;width:81px;height:99px}.Pet-Slime-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -1200px;width:81px;height:99px}.Pet-Slime-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px 0;width:81px;height:99px}.Pet-Slime-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -100px;width:81px;height:99px}.Pet-Slime-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -200px;width:81px;height:99px}.Pet-Slime-White{background-image:url(spritesmith-main-9.png);background-position:-1312px -300px;width:81px;height:99px}.Pet-Slime-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1312px -400px;width:81px;height:99px}.Pet-Snake-Base{background-image:url(spritesmith-main-9.png);background-position:-1312px -500px;width:81px;height:99px}.Pet-Snake-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1312px -600px;width:81px;height:99px}.Pet-Snake-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px -700px;width:81px;height:99px}.Pet-Snake-Desert{background-image:url(spritesmith-main-9.png);background-position:-1312px -800px;width:81px;height:99px}.Pet-Snake-Golden{background-image:url(spritesmith-main-9.png);background-position:-1312px -900px;width:81px;height:99px}.Pet-Snake-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px -1000px;width:81px;height:99px}.Pet-Snake-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -1100px;width:81px;height:99px}.Pet-Snake-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -1200px;width:81px;height:99px}.Pet-Snake-White{background-image:url(spritesmith-main-9.png);background-position:-1394px 0;width:81px;height:99px}.Pet-Snake-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -100px;width:81px;height:99px}.Pet-Spider-Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -200px;width:81px;height:99px}.Pet-Spider-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1394px -300px;width:81px;height:99px}.Pet-Spider-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1394px -400px;width:81px;height:99px}.Pet-Spider-Desert{background-image:url(spritesmith-main-9.png);background-position:-1394px -500px;width:81px;height:99px}.Pet-Spider-Golden{background-image:url(spritesmith-main-9.png);background-position:-1394px -600px;width:81px;height:99px}.Pet-Spider-Red{background-image:url(spritesmith-main-9.png);background-position:-1394px -700px;width:81px;height:99px}.Pet-Spider-Shade{background-image:url(spritesmith-main-9.png);background-position:-1394px -800px;width:81px;height:99px}.Pet-Spider-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -900px;width:81px;height:99px}.Pet-Spider-White{background-image:url(spritesmith-main-9.png);background-position:-1394px -1000px;width:81px;height:99px}.Pet-Spider-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -1100px;width:81px;height:99px}.Pet-TRex-Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -1200px;width:81px;height:99px}.Pet-TRex-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -1300px;width:81px;height:99px}.Pet-TRex-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -1300px;width:81px;height:99px}.Pet-TRex-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -1300px;width:81px;height:99px}.Pet-TRex-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -1300px;width:81px;height:99px}.Pet-TRex-Red{background-image:url(spritesmith-main-9.png);background-position:-328px -1300px;width:81px;height:99px}.Pet-TRex-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -1300px;width:81px;height:99px}.Pet-TRex-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-492px -1300px;width:81px;height:99px}.Pet-TRex-White{background-image:url(spritesmith-main-9.png);background-position:-574px -1300px;width:81px;height:99px}.Pet-TRex-Zombie{background-image:url(spritesmith-main-9.png);background-position:-656px -1300px;width:81px;height:99px}.Pet-Tiger-Veteran{background-image:url(spritesmith-main-9.png);background-position:-738px -1300px;width:81px;height:99px}.Pet-TigerCub-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -1300px;width:81px;height:99px}.Pet-TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -1300px;width:81px;height:99px}.Pet-TigerCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -1300px;width:81px;height:99px}.Pet-TigerCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -1300px;width:81px;height:99px}.Pet-TigerCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -1300px;width:81px;height:99px}.Pet-TigerCub-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -1300px;width:81px;height:99px}.Pet-TigerCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -1300px;width:81px;height:99px}.Pet-TigerCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -1300px;width:81px;height:99px}.Pet-TigerCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-1476px 0;width:81px;height:99px}.Pet-TigerCub-White{background-image:url(spritesmith-main-9.png);background-position:-1476px -100px;width:81px;height:99px}.Pet-TigerCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1476px -200px;width:81px;height:99px}.Pet-Turkey-Base{background-image:url(spritesmith-main-9.png);background-position:-1476px -300px;width:81px;height:99px}.Pet-Whale-Base{background-image:url(spritesmith-main-9.png);background-position:-1476px -400px;width:81px;height:99px}.Pet-Whale-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1476px -500px;width:81px;height:99px}.Pet-Whale-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1476px -600px;width:81px;height:99px}.Pet-Whale-Desert{background-image:url(spritesmith-main-9.png);background-position:-1476px -700px;width:81px;height:99px}.Pet-Whale-Golden{background-image:url(spritesmith-main-9.png);background-position:-1476px -800px;width:81px;height:99px}.Pet-Whale-Red{background-image:url(spritesmith-main-9.png);background-position:-1476px -900px;width:81px;height:99px}.Pet-Whale-Shade{background-image:url(spritesmith-main-9.png);background-position:-1476px -1000px;width:81px;height:99px}.Pet-Whale-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1476px -1100px;width:81px;height:99px}.Pet-Whale-White{background-image:url(spritesmith-main-9.png);background-position:-1476px -1200px;width:81px;height:99px}.Pet-Whale-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1476px -1300px;width:81px;height:99px}.Pet-Wolf-Base{background-image:url(spritesmith-main-9.png);background-position:0 -1400px;width:81px;height:99px}.Pet-Wolf-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -1400px;width:81px;height:99px}.Pet-Wolf-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -1400px;width:81px;height:99px}.Pet-Wolf-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -1400px;width:81px;height:99px}.Pet-Wolf-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -1400px;width:81px;height:99px}.Pet-Wolf-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -1400px;width:81px;height:99px}.Pet-Wolf-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -1400px;width:81px;height:99px}.Pet-Wolf-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -1400px;width:81px;height:99px}.Pet-Wolf-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px -1400px;width:81px;height:99px}.Pet-Wolf-Veteran{background-image:url(spritesmith-main-9.png);background-position:-738px -1400px;width:81px;height:99px}.Pet-Wolf-White{background-image:url(spritesmith-main-9.png);background-position:-820px -1400px;width:81px;height:99px}.Pet-Wolf-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -1400px;width:81px;height:99px}.Pet_HatchingPotion_Base{background-image:url(spritesmith-main-9.png);background-position:-1033px -1400px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1229px -1400px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1082px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Desert{background-image:url(spritesmith-main-9.png);background-position:-1131px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Golden{background-image:url(spritesmith-main-9.png);background-position:-1180px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Red{background-image:url(spritesmith-main-9.png);background-position:-984px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Shade{background-image:url(spritesmith-main-9.png);background-position:-1278px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1327px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Spooky{background-image:url(spritesmith-main-9.png);background-position:-1376px -1400px;width:48px;height:51px}.Pet_HatchingPotion_White{background-image:url(spritesmith-main-9.png);background-position:-1425px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Zombie{background-image:url(spritesmith-main-9.png);background-position:-1474px -1400px;width:48px;height:51px}.head_special_0,.weapon_special_0{width:105px;height:105px;margin-left:-3px;margin-top:-18px}.broad_armor_special_0,.shield_special_0,.slim_armor_special_0{width:90px;height:90px}.weapon_special_critical{background:url(/common/img/sprites/backer-only/weapon_special_critical.gif) no-repeat;width:90px;height:90px;margin-left:-12px;margin-top:12px}.weapon_special_1{margin-left:-12px}.broad_armor_special_1,.head_special_1,.slim_armor_special_1{width:90px;height:90px}.head_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeHelmet.gif) no-repeat}.head_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalHelmet.gif) no-repeat;margin-top:3px}.broad_armor_special_0,.slim_armor_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeArmor.gif) no-repeat}.broad_armor_special_1,.slim_armor_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalArmor.gif) no-repeat}.shield_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Shield-TormentedSkull.gif) no-repeat}.weapon_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Weapon-DarkSoulsBlade.gif) no-repeat}.Pet-Wolf-Cerberus{width:105px;height:72px;background:url(/common/img/sprites/backer-only/BackerOnly-Pet-CerberusPup.gif) no-repeat}.npc_ian{background:url(/common/img/sprites/npc_ian.gif) no-repeat;width:78px;height:135px}.quest_burnout{background:url(/common/img/sprites/quest_burnout.gif) no-repeat;width:219px;height:249px}.Gems{display:inline-block;margin-right:5px;border-style:none;margin-left:0;margin-top:2px}.inline-gems{vertical-align:middle;margin-left:0;display:inline-block}.customize-menu .locked{background-color:#727272}.achievement{float:left;clear:right;margin-right:10px}.multi-achievement{margin:auto;padding-left:.5em;padding-right:.5em}[class*=Mount_Body_],[class*=Mount_Head_]{margin-top:18px}.Pet_Currency_Gem{margin-top:5px;margin-bottom:5px} \ No newline at end of file diff --git a/website/src/index.js b/website/src/index.js index 2097a7b834..134060bf45 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -4,7 +4,7 @@ require('babel/register'); // Only do the minimal amount of work before forking just in case of a dyno restart var cluster = require('cluster'); var nconf = require('nconf'); -var logger = require('./libs/api-v2/logger'); +var logger = require('./libs/api-v3/logger'); // Initialize configuration var setupNconf = require('./libs/api-v3/setupNconf'); diff --git a/website/src/libs/api-v2/analytics.js b/website/src/libs/api-v2/analytics.js index dffa7c2a9b..dff60a358a 100644 --- a/website/src/libs/api-v2/analytics.js +++ b/website/src/libs/api-v2/analytics.js @@ -1,4 +1,4 @@ -require('./i18n'); +require('../i18n'); var _ = require('lodash'); var Content = require('../../../common').content; From 8fd82c6808d3afad9b0e62eb589ff330d851a9ba Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 Nov 2015 17:50:34 +0100 Subject: [PATCH 086/976] fix some requires --- website/src/libs/api-v2/analytics.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/libs/api-v2/analytics.js b/website/src/libs/api-v2/analytics.js index dff60a358a..3ea45256d9 100644 --- a/website/src/libs/api-v2/analytics.js +++ b/website/src/libs/api-v2/analytics.js @@ -1,7 +1,7 @@ require('../i18n'); var _ = require('lodash'); -var Content = require('../../../common').content; +var Content = require('../../../../common').content; var Amplitude = require('amplitude'); var googleAnalytics = require('universal-analytics'); From e84e3e135202ee6edda5413943933533bc2f5da0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 Nov 2015 18:06:50 +0100 Subject: [PATCH 087/976] fix another require path --- test/server_side/webhooks.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/server_side/webhooks.test.js b/test/server_side/webhooks.test.js index ef6636bf93..45e042db8a 100644 --- a/test/server_side/webhooks.test.js +++ b/test/server_side/webhooks.test.js @@ -4,7 +4,7 @@ chai.use(require("sinon-chai")) var expect = chai.expect var rewire = require('rewire'); -var webhook = rewire('../../website/src/libs/webhook'); +var webhook = rewire('../../website/src/libs/api-v2/webhook'); describe('webhooks', function() { var postSpy; From 500c520c5983e05aa7bbdac88517dd2810f04a36 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 11:08:28 -0600 Subject: [PATCH 088/976] Move sandbox stub to test that uses it --- .../unit/middlewares/getUserLanguage.test.js | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index a8fbb88de7..a222e2e9c3 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -16,16 +16,6 @@ describe('getUserLanguage', () => { res = generateRes(); req = generateReq(); next = generateNext(); - - sandbox.stub(User, 'findOne').returns({ - exec() { - return Q.resolve({ - preferences: { - language: 'it', - } - }); - } - }); }); context('query parameter', () => { @@ -80,6 +70,16 @@ describe('getUserLanguage', () => { describe('request with session', () => { it('uses the user preferred language if avalaible', (done) => { + sandbox.stub(User, 'findOne').returns({ + exec() { + return Q.resolve({ + preferences: { + language: 'it', + } + }); + } + }); + req.session = { userId: 123 }; From 43058f1642838451654e67b82c841b984408ab8b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 11:10:22 -0600 Subject: [PATCH 089/976] Add queries for which req pieces take precedence. --- .../unit/middlewares/getUserLanguage.test.js | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index a222e2e9c3..f587dafe04 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -36,6 +36,27 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('en'); }); + + it('uses query even if the request includes a user and session', () => { + req.query = { + lang: 'es', + }; + + req.locals = { + user: { + preferences: { + language: 'it', + }, + }, + }; + + req.session = { + userId: 123 + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('es'); + }); }); context('authorized request', () => { @@ -66,9 +87,26 @@ describe('getUserLanguage', () => { done(); }); }); + + it('uses the user preferred language even if a session is included in request', () => { + req.locals = { + user: { + preferences: { + language: 'it', + }, + }, + }; + + req.session = { + userId: 123 + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('it'); + }); }); - describe('request with session', () => { + context('request with session', () => { it('uses the user preferred language if avalaible', (done) => { sandbox.stub(User, 'findOne').returns({ exec() { From 8372a56d888df56867af5126fbddd6d7d08e68ea Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 11:10:56 -0600 Subject: [PATCH 090/976] Adjust style of User.findOne call. --- website/src/middlewares/api-v3/getUserLanguage.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index fd959cab94..475ff98742 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -68,8 +68,7 @@ export default function getUserLanguage (req, res, next) { req.language = _getFromUser(req.locals.user, req); return next(); } else if (req.session && req.session.userId) { // Same thing if the user has a valid session - User - .findOne({ + User.findOne({ _id: req.session.userId, }, 'preferences.language') .exec() From 06dd343d473bbe4fe4f338cc762cb35c9ebc1575 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 15:46:22 -0600 Subject: [PATCH 091/976] Adjust i18n script to actually display zh_TW language. --- website/src/libs/api-v2/i18n.js | 37 +++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/website/src/libs/api-v2/i18n.js b/website/src/libs/api-v2/i18n.js index b769afe697..335b4d5789 100644 --- a/website/src/libs/api-v2/i18n.js +++ b/website/src/libs/api-v2/i18n.js @@ -60,18 +60,38 @@ _.each(langCodes, function(code){ }catch (e){} }); -// Remove en_GB from langCodes checked by browser to avaoi it being +// Remove en_GB from langCodes checked by browser to avaoi it being // used in place of plain original 'en' var defaultLangCodes = _.without(langCodes, 'en_GB'); // A list of languages that have different versions var multipleVersionsLanguages = ['es', 'zh']; -var latinAmericanSpanishes = ['es-419', 'es-mx', 'es-gt', 'es-cr', 'es-pa', 'es-do', 'es-ve', 'es-co', 'es-pe', - 'es-ar', 'es-ec', 'es-cl', 'es-uy', 'es-py', 'es-bo', 'es-sv', 'es-hn', - 'es-ni', 'es-pr']; +var latinAmericanSpanishes = { + 'es-419': 'es_419', + 'es-mx': 'es_419', + 'es-gt': 'es_419', + 'es-cr': 'es_419', + 'es-pa': 'es_419', + 'es-do': 'es_419', + 'es-ve': 'es_419', + 'es-co': 'es_419', + 'es-pe': 'es_419', + 'es-ar': 'es_419', + 'es-ec': 'es_419', + 'es-cl': 'es_419', + 'es-uy': 'es_419', + 'es-py': 'es_419', + 'es-bo': 'es_419', + 'es-sv': 'es_419', + 'es-hn': 'es_419', + 'es-ni': 'es_419', + 'es-pr': 'es_419', +}; -var chineseVersions = ['zh-tw']; +var chineseVersions = { + 'zh-tw': 'zh_TW', +}; var getUserLanguage = function(req, res, next){ var getFromBrowser = function(){ @@ -97,10 +117,9 @@ var getUserLanguage = function(req, res, next){ } if(matches[0] === 'es'){ - return (latinAmericanSpanishes.indexOf(acceptedCompleteLang) !== -1) ? 'es_419' : 'es'; + return latinAmericanSpanishes[acceptedCompleteLang] || 'es'; }else if(matches[0] === 'zh'){ - var iChinese = chineseVersions.indexOf(acceptedCompleteLang.toLowerCase()); - return (iChinese !== -1) ? chineseVersions[iChinese] : 'zh'; + return chineseVersions[acceptedCompleteLang] || 'zh'; }else{ return en; } @@ -158,4 +177,4 @@ module.exports.enTranslations = function(){ // stringName and vars are the allow var args = Array.prototype.slice.call(arguments, 0); args.push(language.code); return shared.i18n.t.apply(null, args); -}; \ No newline at end of file +}; From dc8d52e00a7aca9e419f5a100de410dbdf19fe32 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 16:58:48 -0600 Subject: [PATCH 092/976] Correct paths in v2 lib --- website/src/libs/api-v2/i18n.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/libs/api-v2/i18n.js b/website/src/libs/api-v2/i18n.js index 335b4d5789..e8295deb03 100644 --- a/website/src/libs/api-v2/i18n.js +++ b/website/src/libs/api-v2/i18n.js @@ -1,12 +1,12 @@ var fs = require('fs'), path = require('path'), _ = require('lodash'), - User = require('../models/user').model, + User = require('../../models/user').model, accepts = require('accepts'), - shared = require('../../../common'), + shared = require('../../../../common'), translations = {}; -var localePath = path.join(__dirname, "/../../../common/locales/") +var localePath = path.join(__dirname, "/../../../../common/locales/") var loadTranslations = function(locale){ var files = fs.readdirSync(path.join(localePath, locale)); From f672ac8c59ff2d037a46a68d0c4f21265493cfa1 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 18:05:28 -0600 Subject: [PATCH 093/976] Port over change to v2 lib --- website/src/libs/api-v3/i18n.js | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/website/src/libs/api-v3/i18n.js b/website/src/libs/api-v3/i18n.js index e8a1b630cc..8b8c30e498 100644 --- a/website/src/libs/api-v3/i18n.js +++ b/website/src/libs/api-v3/i18n.js @@ -79,10 +79,30 @@ export let defaultLangCodes = _.without(langCodes, 'en_GB'); // A map of languages that have different versions and the relative versions export let multipleVersionsLanguages = { - es: ['es-419', 'es-mx', 'es-gt', 'es-cr', 'es-pa', 'es-do', 'es-ve', 'es-co', 'es-pe', - 'es-ar', 'es-ec', 'es-cl', 'es-uy', 'es-py', 'es-bo', 'es-sv', 'es-hn', - 'es-ni', 'es-pr'], - zh: ['zh-tw'], + es: { + 'es-419': 'es_419', + 'es-mx': 'es_419', + 'es-gt': 'es_419', + 'es-cr': 'es_419', + 'es-pa': 'es_419', + 'es-do': 'es_419', + 'es-ve': 'es_419', + 'es-co': 'es_419', + 'es-pe': 'es_419', + 'es-ar': 'es_419', + 'es-ec': 'es_419', + 'es-cl': 'es_419', + 'es-uy': 'es_419', + 'es-py': 'es_419', + 'es-bo': 'es_419', + 'es-sv': 'es_419', + 'es-hn': 'es_419', + 'es-ni': 'es_419', + 'es-pr': 'es_419', + }, + zh: { + 'zh-tw': 'zh_TW', + } }; // Export en strings only, temporary solution for mobile @@ -95,4 +115,4 @@ export function enTranslations (...args) { // language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined); args.push(language.code); return shared.i18n.t(...args); -} \ No newline at end of file +} From 4cd4c588a8db01ae12597d04648bf16c8b6339ec Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 18:05:45 -0600 Subject: [PATCH 094/976] Add tests for browser selection of language and refactor --- .../unit/middlewares/getUserLanguage.test.js | 110 ++++++++++++++++++ .../src/middlewares/api-v3/getUserLanguage.js | 51 ++++---- 2 files changed, 136 insertions(+), 25 deletions(-) diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index f587dafe04..2cbf1d2c87 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -128,4 +128,114 @@ describe('getUserLanguage', () => { }); }); }); + + context('browser fallback', () => { + it('uses browser specificed language', (done) => { + req.headers['accept-language'] = 'pt'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('pt'); + done(); + }); + }); + + it('uses first language in series if browser specifies multiple', (done) => { + req.headers['accept-language'] = 'he, pt, it'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('he'); + done(); + }); + }); + + it('skips invalid lanaguages and uses first language in series if browser specifies multiple', (done) => { + req.headers['accept-language'] = 'blah, he, pt, it'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('he'); + done(); + }); + }); + + it('uses normal version of language if specialized locale is passed in', (done) => { + req.headers['accept-language'] = 'fr-CA'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('fr'); + done(); + }); + }); + + it('uses normal version of language if specialized locale is passed in', (done) => { + req.headers['accept-language'] = 'fr-CA'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('fr'); + done(); + }); + }); + + it('uses es if es is passed in', (done) => { + req.headers['accept-language'] = 'es'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('es'); + done(); + }); + }); + + it('uses es_419 if applicable es-languages are passed in', (done) => { + req.headers['accept-language'] = 'es-mx'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('es_419'); + done(); + }); + }); + + it('uses es_419 if multiple es languages are passed in', (done) => { + req.headers['accept-language'] = 'es-GT, es-MX, es-CR'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('es_419'); + done(); + }); + }); + + it('zh', (done) => { + req.headers['accept-language'] = 'zh-TW'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('zh_TW'); + done(); + }); + }); + + it('uses english if browser specified language is not compatible', (done) => { + req.headers['accept-language'] = 'blah'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('en'); + done(); + }); + }); + + it('uses english if browser does not specify', (done) => { + req.headers['accept-language'] = ''; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('en'); + done(); + }); + }); + + it('uses english if browser does not supply an accept-language header', (done) => { + delete req.headers['accept-language']; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('en'); + done(); + }); + }); + }); }); diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index 475ff98742..4174d833ea 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -7,42 +7,43 @@ import { multipleVersionsLanguages, } from '../../libs/api-v3/i18n'; -function _getFromBrowser (req) { - let acceptedLanguages = accepts(req).languages(); - - let acceptable = _(acceptedLanguages).map((lang) => { +function _getUniqueListOfLanguages (languages) { + let acceptableLanguages = _(languages).map((lang) => { return lang.slice(0, 2); }).uniq().value(); - let matches = _.intersection(acceptable, defaultLangCodes); + let uniqueListOfLanguages = _.intersection(acceptableLanguages, defaultLangCodes); - let iAcceptedCompleteLang = matches.length > 0 ? multipleVersionsLanguages.indexOf(matches[0].toLowerCase()) : -1; + return uniqueListOfLanguages; +} - if (iAcceptedCompleteLang !== -1) { - let acceptedCompleteLang = _.find(acceptedLanguages, (accepted) => { - return accepted.slice(0, 2) === multipleVersionsLanguages[iAcceptedCompleteLang]; - }); +function _checkForApplicableLanguageVariant (originalLanguageOptions) { + let languageVariant = _.find(originalLanguageOptions, (accepted) => { + let trimmedAccepted = accepted.slice(0, 2); + return multipleVersionsLanguages[trimmedAccepted]; + }); - if (acceptedCompleteLang) { - acceptedCompleteLang = acceptedCompleteLang.toLowerCase(); + return languageVariant; +} + +function _getFromBrowser (req) { + let originalLanguageOptions = accepts(req).languages(); + let uniqueListOfLanguages = _getUniqueListOfLanguages(originalLanguageOptions); + let baseLanguage = (uniqueListOfLanguages[0] || '').toLowerCase(); + let languageMapping = multipleVersionsLanguages[baseLanguage]; + + if (languageMapping) { + let languageVariant = _checkForApplicableLanguageVariant(originalLanguageOptions); + + if (languageVariant) { + languageVariant = languageVariant.toLowerCase(); } else { return 'en'; } - if (matches[0] === 'es') { - // In case of a Latin American version of Spanish use 'es_419' - return multipleVersionsLanguages.es.indexOf(acceptedCompleteLang !== -1) ? 'es_419' : 'es'; - } else if (matches[0] === 'zh') { - let iChinese = multipleVersionsLanguages.zh.indexOf(acceptedCompleteLang.toLowerCase()); - - return iChinese !== -1 ? multipleVersionsLanguages.zh[iChinese] : 'zh'; - } else { - return 'en'; - } - } else if (matches.length > 0) { - return matches[0].toLowerCase(); + return languageMapping[languageVariant] || baseLanguage; } else { - return 'en'; + return baseLanguage || 'en'; } } From 805d4bba241dcf71ca1817f8de62d59204a86e90 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 18:29:19 -0600 Subject: [PATCH 095/976] Adjust for linter. --- website/src/libs/api-v3/i18n.js | 2 +- website/src/middlewares/api-v3/getUserLanguage.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/website/src/libs/api-v3/i18n.js b/website/src/libs/api-v3/i18n.js index 8b8c30e498..ed2134f7bf 100644 --- a/website/src/libs/api-v3/i18n.js +++ b/website/src/libs/api-v3/i18n.js @@ -102,7 +102,7 @@ export let multipleVersionsLanguages = { }, zh: { 'zh-tw': 'zh_TW', - } + }, }; // Export en strings only, temporary solution for mobile diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index 4174d833ea..64d98475d5 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -20,6 +20,7 @@ function _getUniqueListOfLanguages (languages) { function _checkForApplicableLanguageVariant (originalLanguageOptions) { let languageVariant = _.find(originalLanguageOptions, (accepted) => { let trimmedAccepted = accepted.slice(0, 2); + return multipleVersionsLanguages[trimmedAccepted]; }); From 1ae9b7aff03ed0ce340a5d027b5598fdb03f8abb Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 18:40:46 -0600 Subject: [PATCH 096/976] Correct path to i18n --- Gruntfile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gruntfile.js b/Gruntfile.js index e2fc2b1083..18ce157de2 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -129,7 +129,7 @@ module.exports = function(grunt) { grunt.registerTask('test:prepare:translations', function() { require('babel/register'); - var i18n = require('./website/src/libs/i18n'), + var i18n = require('./website/src/libs/api-v3/i18n'), fs = require('fs'); fs.writeFileSync('test/spec/mocks/translations.js', "if(!window.env) window.env = {};\n" + From 4a52f227741d5bcdb3a5dd4c55e1eff638581548 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 19:30:36 -0600 Subject: [PATCH 097/976] Correct path to old i18n file --- website/src/libs/api-v2/analytics.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/libs/api-v2/analytics.js b/website/src/libs/api-v2/analytics.js index 6f585eb2fe..6c1ccd3020 100644 --- a/website/src/libs/api-v2/analytics.js +++ b/website/src/libs/api-v2/analytics.js @@ -1,4 +1,4 @@ -require('./api-v2/i18n'); +require('./i18n'); var _ = require('lodash'); var Content = require('../../../../common').content; From 2e21d227e0906937beea93666c6bbdb5a2420631 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 Nov 2015 19:36:49 -0600 Subject: [PATCH 098/976] Simplify get language from user function. --- website/src/middlewares/api-v3/getUserLanguage.js | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index 64d98475d5..234a78a29c 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -49,15 +49,8 @@ function _getFromBrowser (req) { } function _getFromUser (user, req) { - let lang; - - if (user && user.preferences.language && translations[user.preferences.language]) { - lang = user.preferences.language; - } else { - let preferred = _getFromBrowser(req); - - lang = translations[preferred] ? preferred : 'en'; - } + let preferredLang = user && user.preferences && user.preferences.language; + let lang = translations[preferredLang] ? preferredLang : _getFromBrowser(req); return lang; } From d89b1cb60dbf96a4a72c86328a8fffc6f71a65c0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 14 Nov 2015 02:50:58 +0100 Subject: [PATCH 099/976] port firebase lib to api v3 --- website/src/controllers/api-v2/groups.js | 2 +- website/src/controllers/api-v2/user.js | 2 +- website/src/libs/{ => api-v2}/firebase.js | 0 website/src/libs/api-v3/firebase.js | 68 +++++++++++++++++++++++ website/src/models/group.js | 2 +- website/src/server.js | 2 +- 6 files changed, 72 insertions(+), 4 deletions(-) rename website/src/libs/{ => api-v2}/firebase.js (100%) create mode 100644 website/src/libs/api-v3/firebase.js diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index 0fc969c3af..62ac3804b5 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -19,7 +19,7 @@ var isProd = nconf.get('NODE_ENV') === 'production'; var api = module.exports; var pushNotify = require('./../pushNotifications'); var analytics = utils.analytics; -var firebase = require('../../libs/firebase'); +var firebase = require('../../libs/api-v2/firebase'); /* ------------------------------------------------------------------------ diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index cd747e7d5c..5c6d256044 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -13,7 +13,7 @@ var moment = require('moment'); var logging = require('./../../libs/api-v2/logging'); var acceptablePUTPaths; var api = module.exports; -var firebase = require('../../libs/firebase'); +var firebase = require('../../libs/api-v2/firebase'); var webhook = require('../../libs/api-v2/webhook'); // api.purchase // Shared.ops diff --git a/website/src/libs/firebase.js b/website/src/libs/api-v2/firebase.js similarity index 100% rename from website/src/libs/firebase.js rename to website/src/libs/api-v2/firebase.js diff --git a/website/src/libs/api-v3/firebase.js b/website/src/libs/api-v3/firebase.js new file mode 100644 index 0000000000..ce20f3c7fc --- /dev/null +++ b/website/src/libs/api-v3/firebase.js @@ -0,0 +1,68 @@ +import Firebase from 'firebase'; +import nconf from 'nconf'; +const IS_PROD = nconf.get('IS_PROD'); +const FIREBASE_CONFIG = nconf.get('FIREBASE'); +const FIREBASE_ENABLED = IS_PROD && FIREBASE_CONFIG.ENABLED === 'true'; + +let firebaseRef; + +if (FIREBASE_ENABLED) { + firebaseRef = new Firebase(`https://${FIREBASE_CONFIG.APP}.firebaseio.com`); + + // TODO what happens if an op is sent before client is authenticated? + firebaseRef.authWithCustomToken(FIREBASE_CONFIG.SECRET, (err) => { + // TODO it's ok to kill the server here? what if FB is offline? + if (err) throw new Error('Impossible to authenticate Firebase'); + }); +} + +export function updateGroupData (group) { + if (!FIREBASE_ENABLED) return; + // TODO is throw ok? we don't have callbacks + if (!group) throw new Error('group obj is required.'); + // Return in case of tavern (comparison working because we use string for _id) + if (group._id === 'habitrpg') return; + + firebaseRef.child(`rooms/${group._id}`) + .set({ + name: group.name, + }); +} + +export function addUserToGroup (groupId, userId) { + if (!FIREBASE_ENABLED) return; + if (!userId || !groupId) throw new Error('groupId, userId are required.'); + if (groupId === 'habitrpg') return; + + firebaseRef.child(`members/${groupId}/${userId}`).set(true); + firebaseRef.child(`users/${userId}/rooms/${groupId}`).set(true); +} + +export function removeUserFromGroup (groupId, userId) { + if (!FIREBASE_ENABLED) return; + if (!userId || !groupId) throw new Error('groupId, userId are required.'); + if (groupId === 'habitrpg') return; + + firebaseRef.child(`members/${groupId}/${userId}`).remove(); + firebaseRef.child(`users/${userId}/rooms/${groupId}`).remove(); +} + +export function deleteGroup (groupId) { + if (!FIREBASE_ENABLED) return; + if (!groupId) throw new Error('groupId is required.'); + if (groupId === 'habitrpg') return; + + firebaseRef.child(`members/${groupId}`).remove(); + // FIXME not really necessary as long as we only store room data, + // as empty objects are automatically deleted (/members/... in future...) + firebaseRef.child(`rooms/${groupId}`).remove(); +} + +// FIXME not really necessary as long as we only store room data, +// as empty objects are automatically deleted +export function deleteUser (userId) { + if (!FIREBASE_ENABLED) return; + if (!userId) throw new Error('userId is required.'); + + firebaseRef.child(`users/${userId}`).remove(); +} \ No newline at end of file diff --git a/website/src/models/group.js b/website/src/models/group.js index b2da0011d7..0d3011cc38 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -6,7 +6,7 @@ var _ = require('lodash'); var async = require('async'); var logging = require('../libs/api-v2/logging'); var Challenge = require('./../models/challenge').model; -var firebase = require('../libs/firebase'); +var firebase = require('../libs/api-v2/firebase'); // NOTE any change to groups' members in MongoDB will have to be run through the API // changes made directly to the db will cause Firebase to get out of sync diff --git a/website/src/server.js b/website/src/server.js index 2562c9bb87..d8b98b10db 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -42,7 +42,7 @@ let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { autoinc.init(db); -import './libs/firebase'; +import './libs/api-v3/firebase'; // load schemas & models import './models/challenge'; From ae656b044c58d816367d4e1729dc8cef26031936 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 14 Nov 2015 14:52:10 +0100 Subject: [PATCH 100/976] firebase can be enabled when not in production --- website/src/libs/api-v3/firebase.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/libs/api-v3/firebase.js b/website/src/libs/api-v3/firebase.js index ce20f3c7fc..44b431ed46 100644 --- a/website/src/libs/api-v3/firebase.js +++ b/website/src/libs/api-v3/firebase.js @@ -2,7 +2,7 @@ import Firebase from 'firebase'; import nconf from 'nconf'; const IS_PROD = nconf.get('IS_PROD'); const FIREBASE_CONFIG = nconf.get('FIREBASE'); -const FIREBASE_ENABLED = IS_PROD && FIREBASE_CONFIG.ENABLED === 'true'; +const FIREBASE_ENABLED = FIREBASE_CONFIG.ENABLED === 'true'; let firebaseRef; From 9c8d4c383f391621308e0890293b03a3ca77f0b4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 14 Nov 2015 15:37:02 +0100 Subject: [PATCH 101/976] fix eslint, remove unused constant --- website/src/libs/api-v3/firebase.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/libs/api-v3/firebase.js b/website/src/libs/api-v3/firebase.js index 44b431ed46..92d6c28fc1 100644 --- a/website/src/libs/api-v3/firebase.js +++ b/website/src/libs/api-v3/firebase.js @@ -1,6 +1,5 @@ import Firebase from 'firebase'; import nconf from 'nconf'; -const IS_PROD = nconf.get('IS_PROD'); const FIREBASE_CONFIG = nconf.get('FIREBASE'); const FIREBASE_ENABLED = FIREBASE_CONFIG.ENABLED === 'true'; From 6343bc9f590617a31a2ef59d739e725c9fa311f9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 14 Nov 2015 15:36:08 +0100 Subject: [PATCH 102/976] port buildManifest lib --- .eslintrc | 1 - .../src/libs/{ => api-v2}/buildManifest.js | 6 +- website/src/libs/api-v3/buildManifest.js | 62 +++++++++++++++++++ website/src/middlewares/locals.js | 2 +- 4 files changed, 66 insertions(+), 5 deletions(-) rename website/src/libs/{ => api-v2}/buildManifest.js (91%) create mode 100644 website/src/libs/api-v3/buildManifest.js diff --git a/.eslintrc b/.eslintrc index 69c945431e..a3bc6debda 100644 --- a/.eslintrc +++ b/.eslintrc @@ -28,7 +28,6 @@ "no-new": 2, "no-octal-escape": 2, "no-octal": 2, - "no-param-reassign": 2, "no-process-env": 2, "no-proto": 2, "no-implied-eval": 2, diff --git a/website/src/libs/buildManifest.js b/website/src/libs/api-v2/buildManifest.js similarity index 91% rename from website/src/libs/buildManifest.js rename to website/src/libs/api-v2/buildManifest.js index e2b337860f..458bd8ac1e 100644 --- a/website/src/libs/buildManifest.js +++ b/website/src/libs/api-v2/buildManifest.js @@ -2,7 +2,7 @@ var fs = require('fs'); var path = require('path'); var nconf = require('nconf'); var _ = require('lodash'); -var manifestFiles = require("../../public/manifest.json"); +var manifestFiles = require("../../../public/manifest.json"); var IS_PROD = nconf.get('NODE_ENV') === 'production'; var buildFiles = []; @@ -15,7 +15,7 @@ var walk = function(folder){ if(fs.statSync(file).isDirectory()){ walk(file); }else{ - var relFolder = path.relative(path.join(__dirname, "/../../build"), folder); + var relFolder = path.relative(path.join(__dirname, "/../../../build"), folder); var old = fileName.replace(/-.{8}(\.[\d\w]+)$/, '$1'); if(relFolder){ @@ -28,7 +28,7 @@ var walk = function(folder){ }); }; -walk(path.join(__dirname, "/../../build")); +walk(path.join(__dirname, "/../../../build")); var getBuildUrl = module.exports.getBuildUrl = function(url){ if(buildFiles[url]) return '/' + buildFiles[url]; diff --git a/website/src/libs/api-v3/buildManifest.js b/website/src/libs/api-v3/buildManifest.js new file mode 100644 index 0000000000..94d6d49a2d --- /dev/null +++ b/website/src/libs/api-v3/buildManifest.js @@ -0,0 +1,62 @@ +import fs from 'fs'; +import path from 'path'; +import nconf from 'nconf'; + +const MANIFEST_FILE_PATH = path.join(__dirname, '/../../../public/manifest.json'); +const BUILD_FOLDER_PATH = path.join(__dirname, '/../../../build'); +let manifestFiles = require(MANIFEST_FILE_PATH); + +const IS_PROD = nconf.get('IS_PROD'); +let buildFiles = []; + +function _walk (folder) { + let files = fs.readdirSync(folder); + + files.forEach((fileName) => { + let file = `${folder}/${fileName}`; + + if (fs.statSync(file).isDirectory()) { + _walk(file); + } else { + let relFolder = path.relative(BUILD_FOLDER_PATH, folder); + let original = fileName.replace(/-.{8}(\.[\d\w]+)$/, '$1'); // Match the hash part of the filename + + if (relFolder) { + original = `${relFolder}/${original}`; + fileName = `${relFolder}/${fileName}`; + } + + buildFiles[original] = fileName; + } + }); +} + +// Walks through all the files in the build directory +// and creates a map of original files names and hashed files names +_walk(BUILD_FOLDER_PATH); + +export function getBuildUrl (url) { + return `/${buildFiles[url] || url}`; +} + +export function getManifestFiles (page) { + let files = manifestFiles[page]; + + if (!files) throw new Error(`Page "${page}" not found!`); + + let htmlCode = ''; + + if (IS_PROD) { + htmlCode += ``; // eslint-disable-line prefer-template + htmlCode += ``; // eslint-disable-line prefer-template + } else { + files.css.forEach((file) => { + htmlCode += ``; + }); + files.js.forEach((file) => { + htmlCode += ``; + }); + } + + return htmlCode; +} \ No newline at end of file diff --git a/website/src/middlewares/locals.js b/website/src/middlewares/locals.js index e6b2fa8818..7bec7cc399 100644 --- a/website/src/middlewares/locals.js +++ b/website/src/middlewares/locals.js @@ -3,7 +3,7 @@ var _ = require('lodash'); var utils = require('../libs/utils'); var shared = require('../../../common'); var i18n = require('../libs/api-v2/i18n'); -var buildManifest = require('../libs/buildManifest'); +var buildManifest = require('../libs/api-v2/buildManifest'); var shared = require('../../../common'); var forceRefresh = require('./forceRefresh'); var tavernQuest = require('../models/group').tavernQuest; From 466797cc6cf616849d582726f6dfdc917f4565fc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 14 Nov 2015 15:47:45 +0100 Subject: [PATCH 103/976] add some basic tests --- test/api/v3/unit/libs/buildManifest.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 test/api/v3/unit/libs/buildManifest.test.js diff --git a/test/api/v3/unit/libs/buildManifest.test.js b/test/api/v3/unit/libs/buildManifest.test.js new file mode 100644 index 0000000000..5128427747 --- /dev/null +++ b/test/api/v3/unit/libs/buildManifest.test.js @@ -0,0 +1,18 @@ +import { + getManifestFiles, +} from '../../../../../website/src/libs/api-v3/buildManifest'; + +describe('Build Manifest', () => { + describe('getManifestFiles', () => { + it('returns an html string', () => { + let htmlCode = getManifestFiles('app'); + + expect(htmlCode).to.be.a.String; + }); + + it('throws an error in case the page does not exist', () => { + let getManifestFilesFn = () => { getManifestFiles('strange name here') }; + expect(getManifestFilesFn).to.throw(Error); + }); + }); +}); From b315d10c79d5389347210738dbf0d4da7835b84d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 Nov 2015 16:09:51 +0100 Subject: [PATCH 104/976] migrate utils to v3, uprade nodemailer --- .eslintrc | 1 - package.json | 4 +- tasks/gulp-console.js | 2 +- test/server_side/controllers/groups.test.js | 2 +- website/src/controllers/api-v2/auth.js | 2 +- website/src/controllers/api-v2/challenges.js | 2 +- website/src/controllers/api-v2/groups.js | 2 +- website/src/controllers/api-v2/members.js | 2 +- .../src/controllers/api-v2/unsubscription.js | 2 +- website/src/controllers/api-v2/user.js | 2 +- website/src/controllers/payments/index.js | 2 +- website/src/index.js | 4 +- website/src/libs/{ => api-v2}/utils.js | 0 website/src/libs/api-v3/email.js | 153 ++++++++++++++++++ website/src/libs/api-v3/encryption.js | 24 +++ website/src/middlewares/locals.js | 2 +- website/src/server.js | 2 +- 17 files changed, 192 insertions(+), 16 deletions(-) rename website/src/libs/{ => api-v2}/utils.js (100%) create mode 100644 website/src/libs/api-v3/email.js create mode 100644 website/src/libs/api-v3/encryption.js diff --git a/.eslintrc b/.eslintrc index 69c945431e..a3bc6debda 100644 --- a/.eslintrc +++ b/.eslintrc @@ -28,7 +28,6 @@ "no-new": 2, "no-octal-escape": 2, "no-octal": 2, - "no-param-reassign": 2, "no-process-env": 2, "no-proto": 2, "no-implied-eval": 2, diff --git a/package.json b/package.json index 40c04bdfbb..8e0603e0db 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "babel": "^5.5.4", "babelify": "^7.2.0", "body-parser": "^1.14.1", - "compression": "^1.6.0", "bower": "~1.3.12", "browserify": "~12.0.1", "coffee-script": "1.6.x", + "compression": "^1.6.0", "connect-ratelimit": "0.0.7", "cookie-parser": "^1.4.0", "cookie-session": "^1.2.0", @@ -64,7 +64,7 @@ "nconf": "~0.8.2", "newrelic": "~1.23.0", "nib": "~1.0.1", - "nodemailer": "~0.5.2", + "nodemailer": "^1.9.0", "pageres": "^1.0.1", "passport": "~0.2.1", "passport-facebook": "2.0.0", diff --git a/tasks/gulp-console.js b/tasks/gulp-console.js index 46f4ea44ea..d5d318c28e 100644 --- a/tasks/gulp-console.js +++ b/tasks/gulp-console.js @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import autoinc from 'mongoose-id-autoinc'; import logger from '../website/src/libs/api-v3/logger'; import nconf from 'nconf'; -import utils from '../website/src/libs/utils'; +import utils from '../website/src/libs/api-v2/utils'; import repl from 'repl'; import gulp from 'gulp'; diff --git a/test/server_side/controllers/groups.test.js b/test/server_side/controllers/groups.test.js index 9e970c4afc..9574ed7640 100644 --- a/test/server_side/controllers/groups.test.js +++ b/test/server_side/controllers/groups.test.js @@ -8,7 +8,7 @@ var Group = require('../../../website/src/models/group').model; var groupsController = require('../../../website/src/controllers/api-v2/groups'); describe('Groups Controller', function() { - var utils = require('../../../website/src/libs/utils'); + var utils = require('../../../website/src/libs/api-v2/utils'); describe('#invite', function() { var res, req, user, group; diff --git a/website/src/controllers/api-v2/auth.js b/website/src/controllers/api-v2/auth.js index 11cab90624..dd379cc095 100644 --- a/website/src/controllers/api-v2/auth.js +++ b/website/src/controllers/api-v2/auth.js @@ -3,7 +3,7 @@ var validator = require('validator'); var passport = require('passport'); var shared = require('../../../../common'); var async = require('async'); -var utils = require('../../libs/utils'); +var utils = require('../../libs/api-v2/utils'); var nconf = require('nconf'); var request = require('request'); var FirebaseTokenGenerator = require('firebase-token-generator'); diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 21104f2885..89d68495d6 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -9,7 +9,7 @@ var Group = require('./../../models/group').model; var Challenge = require('./../../models/challenge').model; var logging = require('./../../libs/api-v2/logging'); var csv = require('express-csv'); -var utils = require('../../libs/utils'); +var utils = require('../../libs/api-v2/utils'); var api = module.exports; var pushNotify = require('./../pushNotifications'); diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index 62ac3804b5..0355148856 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -9,7 +9,7 @@ var _ = require('lodash'); var nconf = require('nconf'); var async = require('async'); var Q = require('q'); -var utils = require('./../../libs/utils'); +var utils = require('./../../libs/api-v2/utils'); var shared = require('../../../../common'); var User = require('./../../models/user').model; var Group = require('./../../models/group').model; diff --git a/website/src/controllers/api-v2/members.js b/website/src/controllers/api-v2/members.js index c528b92cd7..1c3bb448ac 100644 --- a/website/src/controllers/api-v2/members.js +++ b/website/src/controllers/api-v2/members.js @@ -5,7 +5,7 @@ var api = module.exports; var async = require('async'); var _ = require('lodash'); var shared = require('../../../../common'); -var utils = require('../../libs/utils'); +var utils = require('../../libs/api-v2/utils'); var nconf = require('nconf'); var pushNotify = require('./../pushNotifications'); diff --git a/website/src/controllers/api-v2/unsubscription.js b/website/src/controllers/api-v2/unsubscription.js index 772d0580e9..65f37f6298 100644 --- a/website/src/controllers/api-v2/unsubscription.js +++ b/website/src/controllers/api-v2/unsubscription.js @@ -1,6 +1,6 @@ var User = require('../../models/user').model; var EmailUnsubscription = require('../../models/emailUnsubscription').model; -var utils = require('../../libs/utils'); +var utils = require('../../libs/api-v2/utils'); var i18n = require('../../../../common').i18n; var api = module.exports = {}; diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 5c6d256044..519ae0c557 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -5,7 +5,7 @@ var nconf = require('nconf'); var async = require('async'); var shared = require('../../../../common'); var User = require('./../../models/user').model; -var utils = require('./../../libs/utils'); +var utils = require('./../../libs/api-v2/utils'); var analytics = utils.analytics; var Group = require('./../../models/group').model; var Challenge = require('./../../models/challenge').model; diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js index 866c33381b..074fab2369 100644 --- a/website/src/controllers/payments/index.js +++ b/website/src/controllers/payments/index.js @@ -1,7 +1,7 @@ var _ = require('lodash'); var shared = require('../../../../common'); var nconf = require('nconf'); -var utils = require('./../../libs/utils'); +var utils = require('./../../libs/api-v2/utils'); var moment = require('moment'); var isProduction = nconf.get("NODE_ENV") === "production"; var stripe = require('./stripe'); diff --git a/website/src/index.js b/website/src/index.js index 134060bf45..1de0170cd1 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -9,13 +9,13 @@ var logger = require('./libs/api-v3/logger'); // Initialize configuration var setupNconf = require('./libs/api-v3/setupNconf'); setupNconf(); -var utils = require('./libs/utils'); -utils.setupConfig(); var IS_PROD = nconf.get('IS_PROD'); var IS_DEV = nconf.get('IS_DEV'); var cores = Number(nconf.get('WEB_CONCURRENCY')) || 0; +if (IS_DEV) Error.stackTraceLimit = Infinity; + // Setup the cluster module if (cores !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) { // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) diff --git a/website/src/libs/utils.js b/website/src/libs/api-v2/utils.js similarity index 100% rename from website/src/libs/utils.js rename to website/src/libs/api-v2/utils.js diff --git a/website/src/libs/api-v3/email.js b/website/src/libs/api-v3/email.js new file mode 100644 index 0000000000..8d0cff0a8c --- /dev/null +++ b/website/src/libs/api-v3/email.js @@ -0,0 +1,153 @@ +import { createTransport } from 'nodemailer'; +import nconf from 'nconf'; +import logger from './logger'; +import { encrypt } from './encryption'; +import request from 'request'; + +const IS_PROD = nconf.get('IS_PROD'); +const EMAIL_SERVER = { + url: nconf.get('EMAIL_SERVER:url'), + auth: { + user: nconf.get('EMAIL_SERVER:authUser'), + password: nconf.get('EMAIL_SERVER:authPassword'), + }, +}; +const BASE_URL = nconf.get('BASE_URL'); + +let smtpTransporter = createTransport({ + service: nconf.get('SMTP_SERVICE'), + auth: { + user: nconf.get('SMTP_USER'), + pass: nconf.get('SMTP_PASS'), + }, +}); + +// Send email directly from the server using the smtpTransporter, +// used only to send password reset emails because users unsubscribed on Mandrill wouldn't get them +export function send (mailData) { + return smtpTransporter + .sendMail(mailData) + .catch((error) => logger.error(error)); +} + +export function getUserInfo (user, fields) { + let info = {}; + + if (fields.indexOf('name') !== -1) { + if (user.auth.local) { + info.name = user.profile.name || user.auth.local.username; + } else if (user.auth.facebook) { + info.name = user.profile.name || user.auth.facebook.displayName || user.auth.facebook.username; + } + } + + if (fields.indexOf('email') !== -1) { + if (user.auth.local && user.auth.local.email) { + info.email = user.auth.local.email; + } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value) { + info.email = user.auth.facebook.emails[0].value; + } + } + + if (fields.indexOf('_id') !== -1) { + info._id = user._id; + } + + if (fields.indexOf('canSend') !== -1) { + info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true; + } + + return info; +} + +// Send a transactional email using Mandrill through the external email server +export function txnEmail (mailingInfoArray, emailType, variables, personalVariables) { + mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray]; + + variables = [ + {name: 'BASE_URL', content: BASE_URL}, + ].concat(variables || []); + + // It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed + mailingInfoArray = mailingInfoArray.map((mailingInfo) => { + return mailingInfo._id ? getUserInfo(mailingInfo, ['_id', 'email', 'name', 'canSend']) : mailingInfo; + }).filter((mailingInfo) => { + // Always send reset-password emails + // Don't check canSend for non registered users as already checked before + return mailingInfo.email && (!mailingInfo._id || mailingInfo.canSend || emailType === 'reset-password'); + }); + + // Personal variables are personal to each email recipient, if they are missing + // we manually create a structure for them with RECIPIENT_NAME and RECIPIENT_UNSUB_URL + // otherwise we just add RECIPIENT_NAME and RECIPIENT_UNSUB_URL to the existing personal variables + if (!personalVariables || personalVariables.length === 0) { + personalVariables = mailingInfoArray.map((mailingInfo) => { + return { + rcpt: mailingInfo.email, + vars: [ + { + name: 'RECIPIENT_NAME', + content: mailingInfo.name, + }, + { + name: 'RECIPIENT_UNSUB_URL', + content: `/unsubscribe?code=${encrypt(JSON.stringify({ + _id: mailingInfo._id, + email: mailingInfo.email, + }))}`, + }, + ], + }; + }); + } else { + let temporaryPersonalVariables = {}; + + mailingInfoArray.forEach((mailingInfo) => { + temporaryPersonalVariables[mailingInfo.email] = { + name: mailingInfo.name, + _id: mailingInfo._id, + }; + }); + + personalVariables.forEach((singlePersonalVariables) => { + singlePersonalVariables.vars.push( + { + name: 'RECIPIENT_NAME', + content: temporaryPersonalVariables[singlePersonalVariables.rcpt].name, + }, + { + name: 'RECIPIENT_UNSUB_URL', + content: `/unsubscribe?code=${encrypt(JSON.stringify({ + _id: temporaryPersonalVariables[singlePersonalVariables.rcpt]._id, + email: singlePersonalVariables.rcpt, + }))}`, + } + ); + }); + } + + if (IS_PROD && mailingInfoArray.length > 0) { + request({ + url: `${EMAIL_SERVER.url}/job`, + method: 'POST', + auth: { + user: EMAIL_SERVER.auth.user, + pass: EMAIL_SERVER.auth.password, + }, + json: { + type: 'email', + data: { + emailType, + to: mailingInfoArray, + variables, + personalVariables, + }, + options: { + priority: 'high', + attempts: 5, + backoff: {delay: 10 * 60 * 1000, type: 'fixed'}, + }, + }, + }, (err) => logger.error(err)); + } +} diff --git a/website/src/libs/api-v3/encryption.js b/website/src/libs/api-v3/encryption.js new file mode 100644 index 0000000000..4e370bb9a8 --- /dev/null +++ b/website/src/libs/api-v3/encryption.js @@ -0,0 +1,24 @@ +import { + createCipher, + createDecipher, +} from 'crypto'; +import nconf from 'nconf'; + +const algorithm = 'aes-256-ctr'; +const SESSION_SECRET = nconf.get('SESSION_SECRET'); + +export function encrypt (text) { + let cipher = createCipher(algorithm, SESSION_SECRET); + let crypted = cipher.update(text, 'utf8', 'hex'); + + crypted += cipher.final('hex'); + return crypted; +} + +export function decrypt (text) { + let decipher = createDecipher(algorithm, SESSION_SECRET); + let dec = decipher.update(text, 'hex', 'utf8'); + + dec += decipher.final('utf8'); + return dec; +} \ No newline at end of file diff --git a/website/src/middlewares/locals.js b/website/src/middlewares/locals.js index e6b2fa8818..e5caea707b 100644 --- a/website/src/middlewares/locals.js +++ b/website/src/middlewares/locals.js @@ -1,6 +1,6 @@ var nconf = require('nconf'); var _ = require('lodash'); -var utils = require('../libs/utils'); +var utils = require('../libs/api-v2/utils'); var shared = require('../../../common'); var i18n = require('../libs/api-v2/i18n'); var buildManifest = require('../libs/buildManifest'); diff --git a/website/src/server.js b/website/src/server.js index e9f7ddbbaa..e6a45aa743 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -2,7 +2,7 @@ import nconf from 'nconf'; import logger from './libs/api-v3/logger'; -import utils from './libs/utils'; +import utils from './libs/api-v2/utils'; import express from 'express'; import http from 'http'; // import path from 'path'; From 170a25c7122168ee10ba757373c25cc396bf1c78 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 Nov 2015 16:12:32 +0100 Subject: [PATCH 105/976] remove v2 utils where possible --- tasks/gulp-console.js | 3 --- website/src/server.js | 2 -- 2 files changed, 5 deletions(-) diff --git a/tasks/gulp-console.js b/tasks/gulp-console.js index d5d318c28e..96af26a27a 100644 --- a/tasks/gulp-console.js +++ b/tasks/gulp-console.js @@ -2,7 +2,6 @@ import mongoose from 'mongoose'; import autoinc from 'mongoose-id-autoinc'; import logger from '../website/src/libs/api-v3/logger'; import nconf from 'nconf'; -import utils from '../website/src/libs/api-v2/utils'; import repl from 'repl'; import gulp from 'gulp'; @@ -19,8 +18,6 @@ let improveRepl = (context) => { process.stdout.write('\u001B[2J\u001B[0;0f'); }}); - utils.setupConfig(); - context.Challenge = require('../website/src/models/challenge').model; context.Group = require('../website/src/models/group').model; context.User = require('../website/src/models/user').model; diff --git a/website/src/server.js b/website/src/server.js index e6a45aa743..657600b39c 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -2,7 +2,6 @@ import nconf from 'nconf'; import logger from './libs/api-v3/logger'; -import utils from './libs/api-v2/utils'; import express from 'express'; import http from 'http'; // import path from 'path'; @@ -14,7 +13,6 @@ import passportFacebook from 'passport-facebook'; import mongoose from 'mongoose'; import Q from 'q'; import attachMiddlewares from './middlewares/api-v3/index'; -utils.setupConfig(); // Setup translations // let i18n = require('./libs/api-v2/i18n'); From 19ce7c9b53b5cb813487a210c5b6a9021fb04ca3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 Nov 2015 17:04:47 +0100 Subject: [PATCH 106/976] add tests for emails (wip) and encryption, misc fixes --- website/src/libs/api-v3/email.js | 23 ++++++++++++++--------- website/src/libs/api-v3/encryption.js | 1 + website/src/libs/api-v3/logger.js | 4 +++- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/website/src/libs/api-v3/email.js b/website/src/libs/api-v3/email.js index 8d0cff0a8c..65cbcce02a 100644 --- a/website/src/libs/api-v3/email.js +++ b/website/src/libs/api-v3/email.js @@ -30,14 +30,18 @@ export function send (mailData) { .catch((error) => logger.error(error)); } -export function getUserInfo (user, fields) { +export function getUserInfo (user, fields = []) { let info = {}; if (fields.indexOf('name') !== -1) { - if (user.auth.local) { - info.name = user.profile.name || user.auth.local.username; - } else if (user.auth.facebook) { - info.name = user.profile.name || user.auth.facebook.displayName || user.auth.facebook.username; + info.name = user.profile && user.profile.name; + + if (!info.name) { + if (user.auth.local && user.auth.local.username) { + info.name = user.auth.local.username; + } else if (user.auth.facebook) { + info.name = user.auth.facebook.displayName || user.auth.facebook.username; + } } } @@ -54,14 +58,16 @@ export function getUserInfo (user, fields) { } if (fields.indexOf('canSend') !== -1) { - info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true; + if (user.preferences && user.preferences.emailNotifications) { + info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true; + } } return info; } // Send a transactional email using Mandrill through the external email server -export function txnEmail (mailingInfoArray, emailType, variables, personalVariables) { +export function sendTxn (mailingInfoArray, emailType, variables, personalVariables) { mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray]; variables = [ @@ -127,9 +133,8 @@ export function txnEmail (mailingInfoArray, emailType, variables, personalVariab } if (IS_PROD && mailingInfoArray.length > 0) { - request({ + request.post({ url: `${EMAIL_SERVER.url}/job`, - method: 'POST', auth: { user: EMAIL_SERVER.auth.user, pass: EMAIL_SERVER.auth.password, diff --git a/website/src/libs/api-v3/encryption.js b/website/src/libs/api-v3/encryption.js index 4e370bb9a8..0f5f9d83dd 100644 --- a/website/src/libs/api-v3/encryption.js +++ b/website/src/libs/api-v3/encryption.js @@ -4,6 +4,7 @@ import { } from 'crypto'; import nconf from 'nconf'; +// TODO check this is secure const algorithm = 'aes-256-ctr'; const SESSION_SECRET = nconf.get('SESSION_SECRET'); diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 4583610d25..3ab20ad685 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -12,7 +12,9 @@ if (IS_PROD) { // log errors to console too } else { logger - .add(winston.transports.Console); + .add(winston.transports.Console, { + colorize: true, + }); } export default logger; From 3b75fe6adeec45c5c34298e541aff4cfeb6d4f76 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 Nov 2015 17:27:13 +0100 Subject: [PATCH 107/976] add tests for emails (wip) and encryption, misc fixes --- test/api/v3/unit/libs/email.test.js | 94 ++++++++++++++++++++++++ test/api/v3/unit/libs/encryption.test.js | 15 ++++ 2 files changed, 109 insertions(+) create mode 100644 test/api/v3/unit/libs/email.test.js create mode 100644 test/api/v3/unit/libs/encryption.test.js diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js new file mode 100644 index 0000000000..731f24e347 --- /dev/null +++ b/test/api/v3/unit/libs/email.test.js @@ -0,0 +1,94 @@ +import request from 'request'; +import { + send as sendEmail, + sendTxn as sendTxnEmail, + getUserInfo, +} from '../../../../../website/src/libs/api-v3/email'; + +function getUser () { + return { + _id: 'random _id', + auth: { + local: { + username: 'username', + email: 'email@email', + }, + facebook: { + emails: [{ + value: 'email@facebook' + }], + displayName: 'fb display name', + } + }, + profile: { + name: 'profile name', + }, + preferences: { + emailNotifications: { + unsubscribeFromAll: false + }, + }, + }; +}; + +describe('emails', () => { + + beforeEach(() => { + sandbox.stub(request, 'post'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('sendEmail', () => { + + }); + + describe('getUserInfo', () => { + it('returns an empty object if no field request', () => { + expect(getUserInfo({}, [])).to.be.empty; + }); + + it('returns correct user data', () => { + let user = getUser(); + let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); + + expect(data).to.have.property('name', user.profile.name); + expect(data).to.have.property('email', user.auth.local.email); + expect(data).to.have.property('_id', user._id); + expect(data).to.have.property('canSend', true); + }); + + it('returns correct user data [facebook users]', () => { + let user = getUser(); + delete user.profile['name']; + delete user.auth['local']; + + let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); + + expect(data).to.have.property('name', user.auth.facebook.displayName); + expect(data).to.have.property('email', user.auth.facebook.emails[0].value); + expect(data).to.have.property('_id', user._id); + expect(data).to.have.property('canSend', true); + }); + + it('has fallbacks for missing data', () => { + let user = getUser(); + delete user.profile['name']; + delete user.auth.local['email'] + delete user.auth['facebook']; + + let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); + + expect(data).to.have.property('name', user.auth.local.username); + expect(data).not.to.have.property('email'); + expect(data).to.have.property('_id', user._id); + expect(data).to.have.property('canSend', true); + }); + }); + + describe('sendTxnEmail', () => { + + }); +}); diff --git a/test/api/v3/unit/libs/encryption.test.js b/test/api/v3/unit/libs/encryption.test.js new file mode 100644 index 0000000000..dcab9bffd3 --- /dev/null +++ b/test/api/v3/unit/libs/encryption.test.js @@ -0,0 +1,15 @@ +import { + encrypt, + decrypt, +} from '../../../../../website/src/libs/api-v3/encryption'; + +describe('encryption', () => { + it('can encrypt and decrypt', () => { + let data = 'some secret text'; + let encrypted = encrypt(data); + let decrypted = decrypt(encrypted); + + expect(encrypted).not.to.equal(data); + expect(data).to.equal(decrypted); + }); +}); From a8b3780cc0bf768bd2f80a3b84e48dde4f761a34 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 14 Nov 2015 18:13:17 +0100 Subject: [PATCH 108/976] add send email tests --- test/api/v3/unit/libs/email.test.js | 50 +++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index 731f24e347..5760fd2a99 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -1,9 +1,8 @@ import request from 'request'; -import { - send as sendEmail, - sendTxn as sendTxnEmail, - getUserInfo, -} from '../../../../../website/src/libs/api-v3/email'; +import nconf from 'nconf'; +import nodemailer from 'nodemailer'; +import Q from 'q'; +import logger from '../../../../../website/src/libs/api-v3/logger'; function getUser () { return { @@ -32,8 +31,10 @@ function getUser () { }; describe('emails', () => { + let pathToEmailLib = '../../../../../website/src/libs/api-v3/email'; beforeEach(() => { + delete require.cache[require.resolve(pathToEmailLib)]; sandbox.stub(request, 'post'); }); @@ -42,15 +43,48 @@ describe('emails', () => { }); describe('sendEmail', () => { + it('can send an email using the default transport', () => { + let sendMailSpy = sandbox.stub().returns(Q.defer().promise); + sandbox.stub(nodemailer, 'createTransport').returns({ + sendMail: sendMailSpy, + }); + + let attachEmail = require(pathToEmailLib); + attachEmail.send(); + expect(sendMailSpy).to.be.calledOnce; + }); + + it('logs errors', (done) => { + let deferred = Q.defer(); + let sendMailSpy = sandbox.stub().returns(deferred.promise); + + sandbox.stub(nodemailer, 'createTransport').returns({ + sendMail: sendMailSpy, + }); + sandbox.stub(logger, 'error'); + + let attachEmail = require(pathToEmailLib); + attachEmail.send(); + expect(sendMailSpy).to.be.calledOnce; + deferred.reject(); + deferred.promise.catch((err) => { + expect(logger.error).to.be.calledOnce; + done(); + }); + }); }); describe('getUserInfo', () => { it('returns an empty object if no field request', () => { + let attachEmail = require(pathToEmailLib); + let getUserInfo = attachEmail.getUserInfo; expect(getUserInfo({}, [])).to.be.empty; }); it('returns correct user data', () => { + let attachEmail = require(pathToEmailLib); + let getUserInfo = attachEmail.getUserInfo; let user = getUser(); let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); @@ -61,6 +95,8 @@ describe('emails', () => { }); it('returns correct user data [facebook users]', () => { + let attachEmail = require(pathToEmailLib); + let getUserInfo = attachEmail.getUserInfo; let user = getUser(); delete user.profile['name']; delete user.auth['local']; @@ -74,6 +110,8 @@ describe('emails', () => { }); it('has fallbacks for missing data', () => { + let attachEmail = require(pathToEmailLib); + let getUserInfo = attachEmail.getUserInfo; let user = getUser(); delete user.profile['name']; delete user.auth.local['email'] @@ -89,6 +127,6 @@ describe('emails', () => { }); describe('sendTxnEmail', () => { - + it }); }); From a80be380f9018a06f690ee8a95aa5957898976ff Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 14 Nov 2015 18:58:33 +0100 Subject: [PATCH 109/976] add txt emails tests --- test/api/v3/unit/libs/email.test.js | 97 +++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 6 deletions(-) diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index 5760fd2a99..353ac0ad09 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -35,11 +35,6 @@ describe('emails', () => { beforeEach(() => { delete require.cache[require.resolve(pathToEmailLib)]; - sandbox.stub(request, 'post'); - }); - - afterEach(() => { - sandbox.restore(); }); describe('sendEmail', () => { @@ -127,6 +122,96 @@ describe('emails', () => { }); describe('sendTxnEmail', () => { - it + beforeEach(() => { + sandbox.stub(request, 'post'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('can send a txn email to one recipient', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); + let attachEmail = require(pathToEmailLib); + let sendTxnEmail = attachEmail.sendTxn; + let emailType = 'an email type'; + let mailingInfo = { + name: 'my name', + email: 'my@email', + }; + + sendTxnEmail(mailingInfo, emailType); + expect(request.post).to.be.calledWith(sinon.match({ + json: { + data: { + emailType: sinon.match.same(emailType), + to: sinon.match((value) => { + return Array.isArray(value) && value[0].name === mailingInfo.name; + }, 'matches mailing info array'), + } + } + })); + }); + + it('does not send email if address is missing', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); + let attachEmail = require(pathToEmailLib); + let sendTxnEmail = attachEmail.sendTxn; + let emailType = 'an email type'; + let mailingInfo = { + name: 'my name', + //email: 'my@email', + }; + + sendTxnEmail(mailingInfo, emailType); + expect(request.post).not.to.be.called; + }); + + it('uses getUserInfo in case of user data', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); + let attachEmail = require(pathToEmailLib); + let sendTxnEmail = attachEmail.sendTxn; + let emailType = 'an email type'; + let mailingInfo = getUser(); + + sendTxnEmail(mailingInfo, emailType); + expect(request.post).to.be.calledWith(sinon.match({ + json: { + data: { + emailType: sinon.match.same(emailType), + to: sinon.match(val => val[0]._id === mailingInfo._id), + } + } + })); + }); + + it('sends email with some default variables', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); + let attachEmail = require(pathToEmailLib); + let sendTxnEmail = attachEmail.sendTxn; + let emailType = 'an email type'; + let mailingInfo = { + name: 'my name', + email: 'my@email', + }; + let variables = [1,2,3]; + + sendTxnEmail(mailingInfo, emailType, variables); + expect(request.post).to.be.calledWith(sinon.match({ + json: { + data: { + variables: sinon.match((value) => { + return value[0].name === 'BASE_URL'; + }, 'matches variables'), + personalVariables: sinon.match((value) => { + return (value[0].rcpt === mailingInfo.email + && value[0].vars[0].name === 'RECIPIENT_NAME' + && value[0].vars[1].name === 'RECIPIENT_UNSUB_URL' + ); + }, 'matches personal variables'), + } + } + })); + }); }); }); From 78e5d913f5063676e41eaea79f7f494bc4e6cfe6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 Nov 2015 17:27:22 +0100 Subject: [PATCH 110/976] update test to match possible html string --- test/api/v3/unit/libs/buildManifest.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/unit/libs/buildManifest.test.js b/test/api/v3/unit/libs/buildManifest.test.js index 5128427747..d03e19c9eb 100644 --- a/test/api/v3/unit/libs/buildManifest.test.js +++ b/test/api/v3/unit/libs/buildManifest.test.js @@ -7,7 +7,7 @@ describe('Build Manifest', () => { it('returns an html string', () => { let htmlCode = getManifestFiles('app'); - expect(htmlCode).to.be.a.String; + expect(htmlCode.startsWith(' { From 5dc2fb0b6f06b76a91ffd5519bda5dcdce9006fd Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 Nov 2015 18:12:55 +0100 Subject: [PATCH 111/976] port domainMiddleware --- website/src/middlewares/api-v3/domain.js | 16 ++++++++++++++++ website/src/server.js | 2 ++ 2 files changed, 18 insertions(+) create mode 100644 website/src/middlewares/api-v3/domain.js diff --git a/website/src/middlewares/api-v3/domain.js b/website/src/middlewares/api-v3/domain.js new file mode 100644 index 0000000000..63272381da --- /dev/null +++ b/website/src/middlewares/api-v3/domain.js @@ -0,0 +1,16 @@ +// TODO in api-v2 this module also checked memory usage every x minutes and +// threw an error in case of low memory avalible (possible memory leak) +// it's yet to be decided whether to keep it or not +import domainMiddleware from 'domain-middleware'; + +export default function implementDomainMiddleware (server, mongoose) { + return domainMiddleware({ + server: { + close () { + server.close(); + mongoose.connection.close(); + }, + }, + killTimeout: 10000, + }); +} \ No newline at end of file diff --git a/website/src/server.js b/website/src/server.js index e9f7ddbbaa..2e39f50d05 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -13,6 +13,7 @@ import passport from 'passport'; import passportFacebook from 'passport-facebook'; import mongoose from 'mongoose'; import Q from 'q'; +import domainMiddleware from './middlewares/api-v3/domain'; import attachMiddlewares from './middlewares/api-v3/index'; utils.setupConfig(); @@ -84,6 +85,7 @@ app.set('port', nconf.get('PORT')); let oldApp = express(); // api v1 and v2, and not scoped routes let newApp = express(); // api v3 +app.use(domainMiddleware(server, mongoose)); // Route requests to the right app // Matches all request except the ones going to /api/v3/** app.all(/^(?!\/api\/v3).+/i, oldApp); From 1ccb9a5aaa03a173e8b8d608583b29bbdf1b5c79 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 Nov 2015 18:15:06 +0100 Subject: [PATCH 112/976] move ported middlewares to /api-v2 --- website/src/middlewares/{ => api-v2}/domain.js | 0 website/src/middlewares/{ => api-v2}/errorHandler.js | 0 website/src/server.js | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) rename website/src/middlewares/{ => api-v2}/domain.js (100%) rename website/src/middlewares/{ => api-v2}/errorHandler.js (100%) diff --git a/website/src/middlewares/domain.js b/website/src/middlewares/api-v2/domain.js similarity index 100% rename from website/src/middlewares/domain.js rename to website/src/middlewares/api-v2/domain.js diff --git a/website/src/middlewares/errorHandler.js b/website/src/middlewares/api-v2/errorHandler.js similarity index 100% rename from website/src/middlewares/errorHandler.js rename to website/src/middlewares/api-v2/errorHandler.js diff --git a/website/src/server.js b/website/src/server.js index 2e39f50d05..6573bb3c6e 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -97,7 +97,7 @@ attachMiddlewares(newApp); /* OLD APP IS DISABLED UNTIL COMPATIBLE WITH NEW MODELS //require('./middlewares/apiThrottle')(oldApp); -oldApp.use(require('./middlewares/domain')(server,mongoose)); +oldApp.use(require('./middlewares/api-v2/domain')(server,mongoose)); if (!IS_PROD && !DISABLE_LOGGING) oldApp.use(require('morgan')("dev")); oldApp.use(require('compression')()); oldApp.set("views", __dirname + "/../views"); @@ -155,7 +155,7 @@ oldApp.use('/common/script/public', express['static'](publicDir + "/../../common oldApp.use('/common/img', express['static'](publicDir + "/../../common/img", { maxAge: maxAge })); oldApp.use(express['static'](publicDir)); -oldApp.use(require('./middlewares/errorHandler')); +oldApp.use(require('./middlewares/api-v2/errorHandler')); */ server.on('request', app); From 24cd36e71623c97afcf11e2b2c3a4cd05d920bc9 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 15 Nov 2015 15:08:31 -0600 Subject: [PATCH 113/976] Remove second babel require --- Gruntfile.js | 1 - 1 file changed, 1 deletion(-) diff --git a/Gruntfile.js b/Gruntfile.js index 40752c9d90..c199069fe6 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -129,7 +129,6 @@ module.exports = function(grunt) { grunt.registerTask('build:test', ['test:prepare:translations', 'build:dev']); grunt.registerTask('test:prepare:translations', function() { - require('babel/register'); var i18n = require('./website/src/libs/api-v3/i18n'), fs = require('fs'); fs.writeFileSync('test/spec/mocks/translations.js', From 6ba90272521a8cfb84af8f7e8569fd3fe31b7baa Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 15 Nov 2015 15:15:52 -0600 Subject: [PATCH 114/976] Switch out babel for babel-core --- test/api-legacy/api-helper.js | 2 +- test/helpers/globals.helper.js | 2 +- test/mocha.opts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/test/api-legacy/api-helper.js b/test/api-legacy/api-helper.js index e972a0cc34..57817173c2 100644 --- a/test/api-legacy/api-helper.js +++ b/test/api-legacy/api-helper.js @@ -1,4 +1,4 @@ -require('babel/register'); +require('babel-core/register'); var path, superagentDefaults; superagentDefaults = require("superagent-defaults"); diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 52f9096315..4da36c7977 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,4 +1,4 @@ -require('babel/register'); +require('babel-core/register'); //------------------------------ // Global modules //------------------------------ diff --git a/test/mocha.opts b/test/mocha.opts index 4f6781fd4d..bcdefd3e55 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -3,7 +3,6 @@ --timeout 8000 --check-leaks --growl ---compilers js:babel/register --globals io --compilers js:babel-core/register --require test/api-legacy/api-helper From 448ed70ab9073cf2f6db56656a540d14f0704ef5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 Nov 2015 22:40:00 +0100 Subject: [PATCH 115/976] add example controller and lib to setup routes --- website/src/controllers/api-v3/example.js | 15 +++++++++++++++ website/src/libs/api-v3/setupRoutes.js | 23 +++++++++++++++++++++++ website/src/middlewares/api-v3/index.js | 2 ++ 3 files changed, 40 insertions(+) create mode 100644 website/src/controllers/api-v3/example.js create mode 100644 website/src/libs/api-v3/setupRoutes.js diff --git a/website/src/controllers/api-v3/example.js b/website/src/controllers/api-v3/example.js new file mode 100644 index 0000000000..7604503436 --- /dev/null +++ b/website/src/controllers/api-v3/example.js @@ -0,0 +1,15 @@ +// An example file to show how a controller should be structured +let api = {}; + +api.exampleRoute = { + method: 'GET', + url: '/example/:param', + middlewares: [], + handler (req, res) { + res.status(200).send({ + status: 'ok' + }); + }, +}; + +export default api; \ No newline at end of file diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js new file mode 100644 index 0000000000..e1c41ffe83 --- /dev/null +++ b/website/src/libs/api-v3/setupRoutes.js @@ -0,0 +1,23 @@ +import fs from 'fs'; +import path from 'path'; +import express from 'express'; +import _ from 'lodash'; +const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); +let router = express.Router(); + +fs + .readdirSync(CONTROLLERS_PATH) + .filter(fileName => fileName.match(/\.js$/)) + .filter(fileName => fs.statSync(CONTROLLERS_PATH + fileName).isFile()) + .forEach((fileName) => { + let controller = require(CONTROLLERS_PATH + fileName); + + _.each(controller, (action) => { + let {method, url, middlewares, handler} = action; + + method = method.toLowerCase(); + router[method](url, ...middlewares, handler); + }); + }); + +export default router; \ No newline at end of file diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 82b85eee11..e3c042931b 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -3,6 +3,7 @@ import analytics from './analytics'; import errorHandler from './errorHandler'; import bodyParser from 'body-parser'; +import routes from '../../libs/api-v3/setupRoutes'; export default function attachMiddlewares (app) { // Parse query parameters and json bodies @@ -14,6 +15,7 @@ export default function attachMiddlewares (app) { app.use(analytics); + app.use(routes); // Error handler middleware, define as the last one app.use(errorHandler); } From 3fe6f87a9614f1aee42de1e4319cb16f13fcf3c7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 Nov 2015 22:44:17 +0100 Subject: [PATCH 116/976] fix linting --- website/src/controllers/api-v3/example.js | 2 +- website/src/libs/api-v3/setupRoutes.js | 6 +++--- website/src/middlewares/api-v3/index.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/website/src/controllers/api-v3/example.js b/website/src/controllers/api-v3/example.js index 7604503436..f5e9f24b71 100644 --- a/website/src/controllers/api-v3/example.js +++ b/website/src/controllers/api-v3/example.js @@ -7,7 +7,7 @@ api.exampleRoute = { middlewares: [], handler (req, res) { res.status(200).send({ - status: 'ok' + status: 'ok', }); }, }; diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index e1c41ffe83..4bce7b1e26 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -3,15 +3,15 @@ import path from 'path'; import express from 'express'; import _ from 'lodash'; const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); -let router = express.Router(); +let router = express.Router(); // eslint-disable-line new-cap fs .readdirSync(CONTROLLERS_PATH) .filter(fileName => fileName.match(/\.js$/)) .filter(fileName => fs.statSync(CONTROLLERS_PATH + fileName).isFile()) .forEach((fileName) => { - let controller = require(CONTROLLERS_PATH + fileName); - + let controller = require(CONTROLLERS_PATH + fileName); // eslint-disable-line global-require + _.each(controller, (action) => { let {method, url, middlewares, handler} = action; diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index e3c042931b..39847e67bf 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -12,10 +12,10 @@ export default function attachMiddlewares (app) { extended: true, // Uses 'qs' library as old connect middleware })); app.use(bodyParser.json()); - app.use(analytics); app.use(routes); + // Error handler middleware, define as the last one app.use(errorHandler); } From cc0eefd97d5aff789c621b20d2790e69f32247a2 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 15 Nov 2015 15:45:30 -0600 Subject: [PATCH 117/976] Remove coffeescript dependency --- website/src/libs/api-v3/analyticsService.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js index 81be37322e..ae9ad6f60f 100644 --- a/website/src/libs/api-v3/analyticsService.js +++ b/website/src/libs/api-v3/analyticsService.js @@ -9,8 +9,6 @@ import { } from 'lodash'; import { content as Content } from '../../../../common'; -require('coffee-script'); - const AMPLIUDE_TOKEN = nconf.get('AMPLITUDE_KEY'); const GA_TOKEN = nconf.get('GA_ID'); const GA_POSSIBLE_LABELS = ['gaLabel', 'itemKey']; From 4ab2fafc5ebdad814031cf607203ff32e5a0158d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 15 Nov 2015 15:45:56 -0600 Subject: [PATCH 118/976] Remove coffee files --- test/api-legacy/coupons.coffee | 203 -------- test/common/algos.mocha.coffee | 925 --------------------------------- test/common/dailies.coffee | 418 --------------- 3 files changed, 1546 deletions(-) delete mode 100644 test/api-legacy/coupons.coffee delete mode 100644 test/common/algos.mocha.coffee delete mode 100644 test/common/dailies.coffee diff --git a/test/api-legacy/coupons.coffee b/test/api-legacy/coupons.coffee deleted file mode 100644 index 87c2c94a96..0000000000 --- a/test/api-legacy/coupons.coffee +++ /dev/null @@ -1,203 +0,0 @@ -'use strict' - -app = require("../../website/src/server") -Coupon = require("../../website/src/models/coupon").model - -makeSudoUser = (usr, cb) -> - registerNewUser -> - sudoUpdate = { "$set" : { "contributor.sudo" : true } } - User.findByIdAndUpdate user._id, sudoUpdate, {new: true}, (err, _user) -> - usr = _user - cb() - , true - -describe "Coupons", -> - before (done) -> - async.parallel [ - (cb) -> - mongoose.connection.collections['coupons'].drop (err) -> - cb() - (cb) -> - mongoose.connection.collections['users'].drop (err) -> - cb() - ], done - - coupons = null - - describe "POST /api/v2/coupons/generate/:event", -> - - context "while sudo user", -> - before (done) -> - makeSudoUser(user, done) - - it "generates coupons", (done) -> - queries = '?count=10' - request - .post(baseURL + '/coupons/generate/wondercon' + queries) - .end (err, res) -> - expectCode res, 200 - Coupon.find { event: 'wondercon' }, (err, _coupons) -> - coupons = _coupons - expect(coupons.length).to.equal 10 - _(coupons).each (c)-> - expect(c.event).to.equal 'wondercon' - .value() - done() - - context "while regular user", -> - - before (done) -> - registerNewUser(done, true) - - it "does not generate coupons", (done) -> - queries = '?count=10' - request - .post(baseURL + '/coupons/generate/wondercon' + queries) - .end (err, res) -> - expectCode res, 401 - expect(res.body.err).to.equal 'You don\'t have admin access' - done() - - describe "GET /api/v2/coupons", -> - - context "while sudo user", -> - - before (done) -> - makeSudoUser(user, done) - - it "gets coupons", (done) -> - queries = '?_id=' + user._id + '&apiToken=' + user.apiToken - request - .get(baseURL + '/coupons' + queries) - .end (err, res) -> - expectCode res, 200 - codes = res.text - expect(codes).to.contain('code') - # Expect each coupon code _id to exist in response - _(coupons).each (c) -> - expect(codes).to.contain(c._id) - .value() - - done() - - it "gets first 5 coupons out of 10 when a limit of 5 is set", (done) -> - queries = '?_id=' + user._id + '&apiToken=' + user.apiToken + '&limit=5' - request - .get(baseURL + '/coupons' + queries) - .end (err, res) -> - expectCode res, 200 - codes = res.text - sortedCoupons = _.sortBy(coupons, 'seq') - firstHalf = sortedCoupons[0..4] - secondHalf = sortedCoupons[5..9] - - # First five coupons should be present in codes - _(firstHalf).each (c) -> - expect(codes).to.contain(c._id) - .value() - # Second five coupons should not be present in codes - _(secondHalf).each (c) -> - expect(codes).to.not.contain(c._id) - .value() - done() - - it "gets last 5 coupons out of 10 when a limit of 5 is set", (done) -> - queries = '?_id=' + user._id + '&apiToken=' + user.apiToken + '&skip=5' - request - .get(baseURL + '/coupons' + queries) - .end (err, res) -> - expectCode res, 200 - codes = res.text - sortedCoupons = _.sortBy(coupons, 'seq') - firstHalf = sortedCoupons[0..4] - secondHalf = sortedCoupons[5..9] - - # First five coupons should not be present in codes - _(firstHalf).each (c) -> - expect(codes).to.not.contain(c._id) - .value() - # Second five coupons should be present in codes - _(secondHalf).each (c) -> - expect(codes).to.contain(c._id) - .value() - done() - - context "while regular user", -> - - before (done) -> - registerNewUser(done, true) - - it "does not get coupons", (done) -> - - queries = '?_id=' + user._id + '&apiToken=' + user.apiToken - request - .get(baseURL + '/coupons' + queries) - .end (err, res) -> - expectCode res, 401 - expect(res.body.err).to.equal 'You don\'t have admin access' - done() - - describe "POST /api/v2/user/coupon/:code", -> - specialGear = (gear, has) -> - items = ['body_special_wondercon_gold' - 'body_special_wondercon_black' - 'body_special_wondercon_red' - 'back_special_wondercon_red' - 'back_special_wondercon_black' - 'back_special_wondercon_red' - 'eyewear_special_wondercon_black' - 'eyewear_special_wondercon_red'] - - _(items).each (i) -> - if(has) - expect(gear[i]).to.exist - else - expect(gear[i]).to.not.exist - .value() - - beforeEach (done) -> - registerNewUser -> - gear = user.items.gear.owned - specialGear(gear, false) - done() - , true - - context "unused coupon", -> - it "applies coupon and awards equipment", (done) -> - - code = coupons[0]._id - request - .post(baseURL + '/user/coupon/' + code) - .end (err, res) -> - expectCode res, 200 - gear = res.body.items.gear.owned - specialGear(gear, true) - done() - - context "already used coupon", -> - it "does not apply coupon and does not award equipment", (done) -> - - code = coupons[0]._id - request - .post(baseURL + '/user/coupon/' + code) - .end (err, res) -> - expectCode res, 400 - expect(res.body.err).to.equal "Coupon already used" - User.findById user._id, (err, _user) -> - gear = _user.items.gear.owned - specialGear(gear, false) - done() - - context "invalid coupon", -> - it "does not apply coupon and does not award equipment", (done) -> - - code = "not-a-real-coupon" - request - .post(baseURL + '/user/coupon/' + code) - .end (err, res) -> - expectCode res, 400 - expect(res.body.err).to.equal "Invalid coupon code" - User.findById user._id, (err, _user) -> - gear = _user.items.gear.owned - specialGear(gear, false) - done() diff --git a/test/common/algos.mocha.coffee b/test/common/algos.mocha.coffee deleted file mode 100644 index e914ab9194..0000000000 --- a/test/common/algos.mocha.coffee +++ /dev/null @@ -1,925 +0,0 @@ -_ = require 'lodash' -expect = require 'expect.js' -sinon = require 'sinon' -moment = require 'moment' -shared = require '../../common/script/index.js' -shared.i18n.translations = require('../../website/src/libs/api-v2/i18n.js').translations -test_helper = require './test_helper' -test_helper.addCustomMatchers() -$w = (s)->s.split(' ') - -### Helper Functions #### -newUser = (addTasks=true)-> - buffs = {per:0, int:0, con:0, str:0, stealth: 0, streaks: false} - user = - auth: - timestamps: {} - stats: {str:1, con:1, per:1, int:1, mp: 32, class: 'warrior', buffs: buffs} - items: - lastDrop: - count: 0 - hatchingPotions: {} - eggs: {} - food: {} - gear: - equipped: {} - costume: {} - owned: {} - quests: {} - party: - quest: - progress: - down: 0 - preferences: { - autoEquip: true - } - dailys: [] - todos: [] - rewards: [] - flags: {} - achievements: - ultimateGearSets: {} - contributor: - level: 2 - _tmp: {} - - shared.wrap(user) - user.ops.reset(null, ->) - if addTasks - _.each ['habit', 'todo', 'daily'], (task)-> - user.ops.addTask {body: {type: task, id: shared.uuid()}} - user - -rewrapUser = (user)-> - user._wrapped = false - shared.wrap(user) - user - -expectStrings = (obj, paths) -> - _.each paths, (path) -> expect(obj[path]).to.be.ok() - -# options.daysAgo: days ago when the last cron was executed -# cronAfterStart: moves the lastCron to be after the dayStart. -# This way the daysAgo works as expected if the test case -# makes the assumption that the lastCron was after dayStart. -beforeAfter = (options={}) -> - user = newUser() - [before, after] = [user, _.cloneDeep(user)] - # avoid closure on the original user - rewrapUser(after) - before.preferences.dayStart = after.preferences.dayStart = options.dayStart if options.dayStart - before.preferences.timezoneOffset = after.preferences.timezoneOffset = (options.timezoneOffset or moment().zone()) - if options.limitOne - before["#{options.limitOne}s"] = [before["#{options.limitOne}s"][0]] - after["#{options.limitOne}s"] = [after["#{options.limitOne}s"][0]] - lastCron = moment(options.now || +new Date).subtract( {days:options.daysAgo} ) if options.daysAgo - lastCron.add( {hours:options.dayStart, minutes:1} ) if options.daysAgo and options.cronAfterStart - lastCron = +lastCron if options.daysAgo - _.each [before,after], (obj) -> - obj.lastCron = lastCron if options.daysAgo - {before:before, after:after} -#TODO calculate actual points - -expectLostPoints = (before, after, taskType) -> - if taskType in ['daily','habit'] - expect(after.stats.hp).to.be.lessThan before.stats.hp - expect(after["#{taskType}s"][0].history).to.have.length(1) - else expect(after.history.todos).to.have.length(1) - expect(after).toHaveExp 0 - expect(after).toHaveGP 0 - expect(after["#{taskType}s"][0].value).to.be.lessThan before["#{taskType}s"][0].value - -expectGainedPoints = (before, after, taskType) -> - expect(after.stats.hp).to.be 50 - expect(after.stats.exp).to.be.greaterThan before.stats.exp - expect(after.stats.gp).to.be.greaterThan before.stats.gp - expect(after["#{taskType}s"][0].value).to.be.greaterThan before["#{taskType}s"][0].value - expect(after["#{taskType}s"][0].history).to.have.length(1) if taskType is 'habit' - # daily & todo histories handled on cron - -expectNoChange = (before,after) -> - _.each $w('stats items gear dailys todos rewards preferences'), (attr)-> - expect(after[attr]).to.eql before[attr] - -expectClosePoints = (before, after, taskType) -> - expect( Math.abs(after.stats.exp - before.stats.exp) ).to.be.lessThan 0.0001 - expect( Math.abs(after.stats.gp - before.stats.gp) ).to.be.lessThan 0.0001 - expect( Math.abs(after["#{taskType}s"][0].value - before["#{taskType}s"][0].value) ).to.be.lessThan 0.0001 - -expectDayResetNoDamage = (b,a) -> - [before,after] = [_.cloneDeep(b), _.cloneDeep(a)] - _.each after.dailys, (task,i) -> - expect(task.completed).to.be false - expect(before.dailys[i].value).to.be task.value - expect(before.dailys[i].streak).to.be task.streak - expect(task.history).to.have.length(1) - _.each after.todos, (task,i) -> - expect(task.completed).to.be false - expect(before.todos[i].value).to.be.greaterThan task.value - expect(after.history.todos).to.have.length(1) - # hack so we can compare user before/after obj equality sans effected paths - _.each [before,after], (obj) -> - delete obj.stats.buffs - _.each $w('dailys todos history lastCron'), (path) -> delete obj[path] - delete after._tmp - expectNoChange(before, after) - -cycle = (array)-> - n = -1 - (seed=0)-> - n++ - return array[n % array.length] - -repeatWithoutLastWeekday = ()-> - repeat = {su:true,m:true,t:true,w:true,th:true,f:true,s:true} - if shared.startOfWeek(moment().zone(0)).isoWeekday() == 1 # Monday - repeat.su = false - else - repeat.s = false - {repeat: repeat} - -###### Specs ###### - -describe 'User', -> - it 'sets correct user defaults', -> - user = newUser() - base_gear = { armor: 'armor_base_0', weapon: 'weapon_base_0', head: 'head_base_0', shield: 'shield_base_0' } - buffs = {per:0, int:0, con:0, str:0, stealth: 0, streaks: false} - expect(user.stats).to.eql { str: 1, con: 1, per: 1, int: 1, hp: 50, mp: 32, lvl: 1, exp: 0, gp: 0, class: 'warrior', buffs: buffs } - expect(user.items.gear).to.eql { equipped: base_gear, costume: base_gear, owned: {weapon_warrior_0: true} } - expect(user.preferences).to.eql { autoEquip: true, costume: false } - - it 'calculates max MP', -> - user = newUser() - expect(user).toHaveMaxMP 32 - user.stats.int = 10 - expect(user).toHaveMaxMP 50 - user.stats.lvl = 5 - expect(user).toHaveMaxMP 54 - user.stats.class = 'wizard' - user.items.gear.equipped.weapon = 'weapon_wizard_1' - expect(user).toHaveMaxMP 63 - - it 'handles perfect days', -> - user = newUser() - user.dailys = [] - _.times 3, ->user.dailys.push shared.taskDefaults({type:'daily', startDate: moment().subtract(7, 'days')}) - cron = -> user.lastCron = moment().subtract(1,'days');user.fns.cron() - - cron() - expect(user.stats.buffs.str).to.be 0 - expect(user.achievements.perfect).to.not.be.ok() - - user.dailys[0].completed = true - cron() - expect(user.stats.buffs.str).to.be 0 - expect(user.achievements.perfect).to.not.be.ok() - - _.each user.dailys, (d)->d.completed = true - cron() - expect(user.stats.buffs.str).to.be 1 - expect(user.achievements.perfect).to.be 1 - - # Handle greyed-out dailys - yesterday = moment().subtract(1,'days') - user.dailys[0].repeat[shared.dayMapping[yesterday.day()]] = false - _.each user.dailys[1..], (d)->d.completed = true - cron() - expect(user.stats.buffs.str).to.be 1 - expect(user.achievements.perfect).to.be 2 - - describe 'Resting in the Inn', -> - user = null - cron = null - - beforeEach -> - user = newUser() - user.preferences.sleep = true - cron = -> user.lastCron = moment().subtract(1, 'days');user.fns.cron() - user.dailys = [] - _.times 2, -> user.dailys.push shared.taskDefaults({type:'daily', startDate: moment().subtract(7, 'days')}) - - it 'remains in the inn on cron', -> - cron() - expect(user.preferences.sleep).to.be true - - it 'resets dailies', -> - user.dailys[0].completed = true - cron() - expect(user.dailys[0].completed).to.be false - - it 'resets checklist on incomplete dailies', -> - user.dailys[0].checklist = [ - { - "text" : "1", - "id" : "checklist-one", - "completed" : true - }, - { - "text" : "2", - "id" : "checklist-two", - "completed" : true - }, - { - "text" : "3", - "id" : "checklist-three", - "completed" : false - } - ] - cron() - _.each user.dailys[0].checklist, (box)-> - expect(box.completed).to.be false - - it 'resets checklist on complete dailies', -> - user.dailys[0].checklist = [ - { - "text" : "1", - "id" : "checklist-one", - "completed" : true - }, - { - "text" : "2", - "id" : "checklist-two", - "completed" : true - }, - { - "text" : "3", - "id" : "checklist-three", - "completed" : false - } - ] - user.dailys[0].completed = true - cron() - _.each user.dailys[0].checklist, (box)-> - expect(box.completed).to.be false - - it 'does not reset checklist on grey incomplete dailies', -> - yesterday = moment().subtract(1,'days') - user.dailys[0].repeat[shared.dayMapping[yesterday.day()]] = false - user.dailys[0].checklist = [ - { - "text" : "1", - "id" : "checklist-one", - "completed" : true - }, - { - "text" : "2", - "id" : "checklist-two", - "completed" : true - }, - { - "text" : "3", - "id" : "checklist-three", - "completed" : true - } - ] - - cron() - _.each user.dailys[0].checklist, (box)-> - expect(box.completed).to.be true - - it 'resets checklist on complete grey complete dailies', -> - yesterday = moment().subtract(1,'days') - user.dailys[0].repeat[shared.dayMapping[yesterday.day()]] = false - user.dailys[0].checklist = [ - { - "text" : "1", - "id" : "checklist-one", - "completed" : true - }, - { - "text" : "2", - "id" : "checklist-two", - "completed" : true - }, - { - "text" : "3", - "id" : "checklist-three", - "completed" : true - } - ] - user.dailys[0].completed = true - - cron() - _.each user.dailys[0].checklist, (box)-> - expect(box.completed).to.be false - - it 'does not damage user for incomplete dailies', -> - expect(user).toHaveHP 50 - user.dailys[0].completed = true - user.dailys[1].completed = false - cron() - expect(user).toHaveHP 50 - - it 'gives credit for complete dailies', -> - user.dailys[0].completed = true - expect(user.dailys[0].history).to.be.empty - cron() - expect(user.dailys[0].history).to.not.be.empty - - it 'damages user for incomplete dailies after checkout', -> - expect(user).toHaveHP 50 - user.dailys[0].completed = true - user.dailys[1].completed = false - user.preferences.sleep = false - cron() - expect(user.stats.hp).to.be.lessThan 50 - - describe 'Death', -> - user = undefined - it 'revives correctly', -> - user = newUser() - user.stats = { gp: 10, exp: 100, lvl: 2, hp: 0, class: 'warrior' } - user.ops.revive() - expect(user).toHaveGP 0 - expect(user).toHaveExp 0 - expect(user).toHaveLevel 1 - expect(user).toHaveHP 50 - expect(user.items.gear.owned).to.eql { weapon_warrior_0: false } - - it "doesn't break unbreakables", -> - ce = shared.countExists - user = newUser() - # breakables (includes default weapon_warrior_0): - user.items.gear.owned['shield_warrior_1'] = true - # unbreakables because off-class or 0 value: - user.items.gear.owned['shield_rogue_1'] = true - user.items.gear.owned['head_special_nye'] = true - expect(ce user.items.gear.owned).to.be 4 - user.stats.hp = 0 - user.ops.revive() - expect(ce(user.items.gear.owned)).to.be 3 - user.stats.hp = 0 - user.ops.revive() - expect(ce(user.items.gear.owned)).to.be 2 - user.stats.hp = 0 - user.ops.revive() - expect(ce(user.items.gear.owned)).to.be 2 - expect(user.items.gear.owned).to.eql { weapon_warrior_0: false, shield_warrior_1: false, shield_rogue_1: true, head_special_nye: true } - - it "handles event items", -> - shared.content.gear.flat.head_special_nye.event.start = '2012-01-01' - shared.content.gear.flat.head_special_nye.event.end = '2012-02-01' - expect(shared.content.gear.flat.head_special_nye.canOwn(user)).to.be true - delete user.items.gear.owned['head_special_nye'] - expect(shared.content.gear.flat.head_special_nye.canOwn(user)).to.be false - - shared.content.gear.flat.head_special_nye.event.start = moment().subtract(5,'days') - shared.content.gear.flat.head_special_nye.event.end = moment().add(5,'days') - expect(shared.content.gear.flat.head_special_nye.canOwn(user)).to.be true - - describe 'Rebirth', -> - user = undefined - it 'removes correct gear', -> - user = newUser() - user.stats.lvl = 100 - user.items.gear.owned = { - "weapon_warrior_0": true, - "weapon_warrior_1": true, - "armor_warrior_1": false, - "armor_mystery_201402": true, - "back_mystery_201402": false, - "head_mystery_201402": true, - "weapon_armoire_basicCrossbow": true, - } - user.ops.rebirth() - expect(user.items.gear.owned).to.eql { - "weapon_warrior_0": true, - "weapon_warrior_1": false, - "armor_warrior_1": false, - "armor_mystery_201402": true, - "back_mystery_201402": false, - "head_mystery_201402": true, - "weapon_armoire_basicCrossbow": false, - } - - describe 'store', -> - it 'buys a Quest scroll', -> - user = newUser() - user.stats.gp = 205 - user.ops.buyQuest {params: {key: 'dilatoryDistress1'}} - expect(user.items.quests).to.eql {dilatoryDistress1: 1} - expect(user).toHaveGP 5 - - it 'does not buy Quests without enough Gold', -> - user = newUser() - user.stats.gp = 1 - user.ops.buyQuest {params: {key: 'dilatoryDistress1'}} - expect(user.items.quests).to.eql {} - expect(user).toHaveGP 1 - - it 'does not buy nonexistent Quests', -> - user = newUser() - user.stats.gp = 9999 - user.ops.buyQuest {params: {key: 'snarfblatter'}} - expect(user.items.quests).to.eql {} - expect(user).toHaveGP 9999 - - it 'does not buy Gem-premium Quests', -> - user = newUser() - user.stats.gp = 9999 - user.ops.buyQuest {params: {key: 'kraken'}} - expect(user.items.quests).to.eql {} - expect(user).toHaveGP 9999 - - describe 'Gem purchases', -> - it 'does not purchase items without enough Gems', -> - user = newUser() - user.ops.purchase {params: {type: 'eggs', key: 'Cactus'}} - user.ops.purchase {params: {type: 'gear', key: 'headAccessory_special_foxEars'}} - user.ops.unlock {query: {path: 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars'}} - expect(user.items.eggs).to.eql {} - expect(user.items.gear.owned).to.eql { weapon_warrior_0: true } - - it 'purchases an egg', -> - user = newUser() - user.balance = 1 - user.ops.purchase {params: {type: 'eggs', key: 'Cactus'}} - expect(user.items.eggs).to.eql { Cactus: 1} - expect(user.balance).to.eql 0.25 - - it 'purchases fox ears', -> - user = newUser() - user.balance = 1 - user.ops.purchase {params: {type: 'gear', key: 'headAccessory_special_foxEars'}} - expect(user.items.gear.owned).to.eql { weapon_warrior_0: true, headAccessory_special_foxEars: true } - expect(user.balance).to.eql 0.5 - - it 'unlocks all the animal ears at once', -> - user = newUser() - user.balance = 2 - user.ops.unlock {query: {path: 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars'}} - expect(user.items.gear.owned).to.eql { weapon_warrior_0: true, headAccessory_special_bearEars: true, headAccessory_special_cactusEars: true, headAccessory_special_foxEars: true, headAccessory_special_lionEars: true, headAccessory_special_pandaEars: true, headAccessory_special_pigEars: true, headAccessory_special_tigerEars: true, headAccessory_special_wolfEars: true} - expect(user.balance).to.eql 0.75 - - describe 'spells', -> - _.each shared.content.spells, (spellClass)-> - _.each spellClass, (spell)-> - it "#{spell.text} has valid values", -> - expect(spell.target).to.match(/^(task|self|party|user)$/) - expect(spell.mana).to.be.an('number') - if spell.lvl - expect(spell.lvl).to.be.an('number') - expect(spell.lvl).to.be.above(0) - expect(spell.cast).to.be.a('function') - - describe 'drop system', -> - user = null - MIN_RANGE_FOR_POTION = 0 - MAX_RANGE_FOR_POTION = .3 - MIN_RANGE_FOR_EGG = .4 - MAX_RANGE_FOR_EGG = .6 - MIN_RANGE_FOR_FOOD = .7 - MAX_RANGE_FOR_FOOD = 1 - - beforeEach -> - user = newUser() - user.flags.dropsEnabled = true - @task_id = shared.uuid() - user.ops.addTask({body: {type: 'daily', id: @task_id}}) - - it 'drops a hatching potion', -> - for random in [MIN_RANGE_FOR_POTION..MAX_RANGE_FOR_POTION] by .1 - sinon.stub(user.fns, 'predictableRandom').returns random - user.ops.score {params: { id: @task_id, direction: 'up'}} - expect(user.items.eggs).to.be.empty - expect(user.items.hatchingPotions).to.not.be.empty - expect(user.items.food).to.be.empty - user.fns.predictableRandom.restore() - - it 'drops a pet egg', -> - for random in [MIN_RANGE_FOR_EGG..MAX_RANGE_FOR_EGG] by .1 - sinon.stub(user.fns, 'predictableRandom').returns random - user.ops.score {params: { id: @task_id, direction: 'up'}} - expect(user.items.eggs).to.not.be.empty - expect(user.items.hatchingPotions).to.be.empty - expect(user.items.food).to.be.empty - user.fns.predictableRandom.restore() - - it 'drops food', -> - for random in [MIN_RANGE_FOR_FOOD..MAX_RANGE_FOR_FOOD] by .1 - sinon.stub(user.fns, 'predictableRandom').returns random - user.ops.score {params: { id: @task_id, direction: 'up'}} - expect(user.items.eggs).to.be.empty - expect(user.items.hatchingPotions).to.be.empty - expect(user.items.food).to.not.be.empty - user.fns.predictableRandom.restore() - - it 'does not get a drop', -> - sinon.stub(user.fns, 'predictableRandom').returns 0.5 - user.ops.score {params: { id: @task_id, direction: 'up'}} - expect(user.items.eggs).to.eql {} - expect(user.items.hatchingPotions).to.eql {} - expect(user.items.food).to.eql {} - user.fns.predictableRandom.restore() - - describe 'Quests', -> - _.each shared.content.quests, (quest)-> - it "#{quest.text()} has valid values", -> - expect(quest.notes()).to.be.an('string') - expect(quest.completion()).to.be.an('string') if quest.completion - expect(quest.previous).to.be.an('string') if quest.previous - expect(quest.value).to.be.greaterThan 0 if quest.canBuy() - expect(quest.drop.gp).to.not.be.lessThan 0 - expect(quest.drop.exp).to.not.be.lessThan 0 - expect(quest.category).to.match(/pet|unlockable|gold|world/) - if quest.drop.items - expect(quest.drop.items).to.be.an(Array) - if quest.boss - expect(quest.boss.name()).to.be.an('string') - expect(quest.boss.hp).to.be.greaterThan 0 - expect(quest.boss.str).to.be.greaterThan 0 - else if quest.collect - _.each quest.collect, (collect)-> - expect(collect.text()).to.be.an('string') - expect(collect.count).to.be.greaterThan 0 - - describe 'Achievements', -> - _.each shared.content.classes, (klass) -> - user = newUser() - user.stats.gp = 10000 - _.each shared.content.gearTypes, (type) -> - _.each [1..5], (i) -> - user.ops.buy {params:'#{type}_#{klass}_#{i}'} - it 'does not get ultimateGear ' + klass, -> - expect(user.achievements.ultimateGearSets[klass]).to.not.be.ok() - _.each shared.content.gearTypes, (type) -> - user.ops.buy {params:'#{type}_#{klass}_6'} - xit 'gets ultimateGear ' + klass, -> - expect(user.achievements.ultimateGearSets[klass]).to.be.ok() - - it 'does not remove existing Ultimate Gear achievements', -> - user = newUser() - user.achievements.ultimateGearSets = {'healer':true,'wizard':true,'rogue':true,'warrior':true} - user.items.gear.owned.shield_warrior_5 = false - user.items.gear.owned.weapon_rogue_6 = false - user.ops.buy {params:'shield_warrior_5'} - expect(user.achievements.ultimateGearSets).to.eql {'healer':true,'wizard':true,'rogue':true,'warrior':true} - - describe 'unlocking features', -> - it 'unlocks drops at level 3', -> - user = newUser() - user.stats.lvl = 3 - user.fns.updateStats(user.stats) - expect(user.flags.dropsEnabled).to.be.ok() - - it 'unlocks Rebirth at level 50', -> - user = newUser() - user.stats.lvl = 50 - user.fns.updateStats(user.stats) - expect(user.flags.rebirthEnabled).to.be.ok() - - describe 'level-awarded Quests', -> - it 'gets Attack of the Mundane at level 15', -> - user = newUser() - user.stats.lvl = 15 - user.fns.updateStats(user.stats) - expect(user.flags.levelDrops.atom1).to.be.ok() - expect(user.items.quests.atom1).to.eql 1 - - it 'gets Vice at level 30', -> - user = newUser() - user.stats.lvl = 30 - user.fns.updateStats(user.stats) - expect(user.flags.levelDrops.vice1).to.be.ok() - expect(user.items.quests.vice1).to.eql 1 - - it 'gets Golden Knight at level 40', -> - user = newUser() - user.stats.lvl = 40 - user.fns.updateStats(user.stats) - expect(user.flags.levelDrops.goldenknight1).to.be.ok() - expect(user.items.quests.goldenknight1).to.eql 1 - - it 'gets Moonstone Chain at level 60', -> - user = newUser() - user.stats.lvl = 60 - user.fns.updateStats(user.stats) - expect(user.flags.levelDrops.moonstone1).to.be.ok() - expect(user.items.quests.moonstone1).to.eql 1 - -describe 'Simple Scoring', -> - beforeEach -> - {@before, @after} = beforeAfter() - - it 'Habits : Up', -> - @after.ops.score {params: {id: @after.habits[0].id, direction: 'down'}, query: {times: 5}} - expectLostPoints(@before, @after,'habit') - - it 'Habits : Down', -> - @after.ops.score {params: {id: @after.habits[0].id, direction: 'up'}, query: {times: 5}} - expectGainedPoints(@before, @after,'habit') - - it 'Dailys : Up', -> - @after.ops.score {params: {id: @after.dailys[0].id, direction: 'up'}} - expectGainedPoints(@before, @after,'daily') - - it 'Dailys : Up, Down', -> - @after.ops.score {params: {id: @after.dailys[0].id, direction: 'up'}} - @after.ops.score {params: {id: @after.dailys[0].id, direction: 'down'}} - expectClosePoints(@before, @after, 'daily') - - it 'Todos : Up', -> - @after.ops.score {params: {id: @after.todos[0].id, direction: 'up'}} - expectGainedPoints(@before, @after,'todo') - - it 'Todos : Up, Down', -> - @after.ops.score {params: {id: @after.todos[0].id, direction: 'up'}} - @after.ops.score {params: {id: @after.todos[0].id, direction: 'down'}} - expectClosePoints(@before, @after, 'todo') - -describe 'Cron', -> - - it 'computes shouldCron', -> - user = newUser() - - paths = {};user.fns.cron {paths} - expect(user.lastCron).to.not.be.ok # it setup the cron property now - - user.lastCron = +moment().subtract(1,'days') - - paths = {};user.fns.cron {paths} - expect(user.lastCron).to.be.greaterThan 0 - -# user.lastCron = +moment().add(1,'days') -# paths = {};algos.cron user, {paths} -# expect(paths.lastCron).to.be true # busted cron (was set to after today's date) - - it 'only dailies & todos are affected', -> - {before,after} = beforeAfter({daysAgo:1}) - before.dailys = before.todos = after.dailys = after.todos = [] - after.fns.cron() - before.stats.mp=after.stats.mp #FIXME - expect(after.lastCron).to.not.be before.lastCron # make sure cron was run - delete after.stats.buffs;delete before.stats.buffs - expect(before.stats).to.eql after.stats - beforeTasks = before.habits.concat(before.dailys).concat(before.todos).concat(before.rewards) - afterTasks = after.habits.concat(after.dailys).concat(after.todos).concat(after.rewards) - expect(beforeTasks).to.eql afterTasks - - describe 'preening', -> - beforeEach -> - @clock = sinon.useFakeTimers(Date.parse("2013-11-20"), "Date") - - afterEach -> - @clock.restore() - - it 'should preen user history', -> - {before,after} = beforeAfter({daysAgo:1}) - history = [ - # Last year should be condensed to one entry, avg: 1 - {date:'09/01/2012', value: 0} - {date:'10/01/2012', value: 0} - {date:'11/01/2012', value: 2} - {date:'12/01/2012', value: 2} - - # Each month of this year should be condensed to 1/mo, averages follow - {date:'01/01/2013', value: 1} #2 - {date:'01/15/2013', value: 3} - - {date:'02/01/2013', value: 2} #3 - {date:'02/15/2013', value: 4} - - {date:'03/01/2013', value: 3} #4 - {date:'03/15/2013', value: 5} - - {date:'04/01/2013', value: 4} #5 - {date:'04/15/2013', value: 6} - - {date:'05/01/2013', value: 5} #6 - {date:'05/15/2013', value: 7} - - {date:'06/01/2013', value: 6} #7 - {date:'06/15/2013', value: 8} - - {date:'07/01/2013', value: 7} #8 - {date:'07/15/2013', value: 9} - - {date:'08/01/2013', value: 8} #9 - {date:'08/15/2013', value: 10} - - {date:'09/01/2013', value: 9} #10 - {date:'09/15/2013', value: 11} - - {date:'010/01/2013', value: 10} #11 - {date:'010/15/2013', value: 12} - - # This month should condense each week - {date:'011/01/2013', value: 12} - {date:'011/02/2013', value: 13} - {date:'011/03/2013', value: 14} - {date:'011/04/2013', value: 15} - ] - after.history = {exp: _.cloneDeep(history), todos: _.cloneDeep(history)} - after.habits[0].history = _.cloneDeep(history) - after.fns.cron() - - # remove history entries created by cron - after.history.exp.pop() - after.history.todos.pop() - - _.each [after.history.exp, after.history.todos, after.habits[0].history], (arr) -> - expect(_.map(arr, (x)->x.value)).to.eql [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] - - describe 'Todos', -> - it '1 day missed', -> - {before,after} = beforeAfter({daysAgo:1}) - before.dailys = after.dailys = [] - after.fns.cron() - - # todos don't effect stats - expect(after).toHaveHP 50 - expect(after).toHaveExp 0 - expect(after).toHaveGP 0 - - # but they devalue - expect(before.todos[0].value).to.be 0 # sanity check for task setup - expect(after.todos[0].value).to.be -1 # the actual test - expect(after.history.todos).to.have.length 1 - - it '2 days missed', -> - {before,after} = beforeAfter({daysAgo:2}) - before.dailys = after.dailys = [] - after.fns.cron() - - # todos devalue by only one day's worth of devaluation - expect(before.todos[0].value).to.be 0 # sanity check for task setup - expect(after.todos[0].value).to.be -1 # the actual test - - # I used hard-coded dates here instead of 'now' so the tests don't fail - # when you run them between midnight and dayStart. Nothing worse than - # intermittent failures. - describe 'cron day calculations', -> - dayStart = 4 - fstr = "YYYY-MM-DD HH:mm:ss" - - it 'startOfDay before dayStart', -> - # If the time is before dayStart, then we expect the start of the day to be yesterday at dayStart - start = shared.startOfDay {now: moment('2014-10-09 02:30:00'), dayStart} - expect(start.format(fstr)).to.eql '2014-10-08 04:00:00' - - it 'startOfDay after dayStart', -> - # If the time is after dayStart, then we expect the start of the day to be today at dayStart - start = shared.startOfDay {now: moment('2014-10-09 05:30:00'), dayStart} - expect(start.format(fstr)).to.eql '2014-10-09 04:00:00' - - it 'daysSince cron before, now after', -> - # If the lastCron was before dayStart, then a time on the same day after dayStart - # should be 1 day later than lastCron - lastCron = moment('2014-10-09 02:30:00') - days = shared.daysSince(lastCron, {now: moment('2014-10-09 11:30:00'), dayStart}) - expect(days).to.eql 1 - - it 'daysSince cron before, now before', -> - # If the lastCron was before dayStart, then a time on the same day also before dayStart - # should be 0 days later than lastCron - lastCron = moment('2014-10-09 02:30:00') - days = shared.daysSince(lastCron, {now: moment('2014-10-09 03:30:00'), dayStart}) - expect(days).to.eql 0 - - it 'daysSince cron after, now after', -> - # If the lastCron was after dayStart, then a time on the same day also after dayStart - # should be 0 days later than lastCron - lastCron = moment('2014-10-09 05:30:00') - days = shared.daysSince(lastCron, {now: moment('2014-10-09 06:30:00'), dayStart}) - expect(days).to.eql 0 - - it 'daysSince cron after, now tomorrow before', -> - # If the lastCron was after dayStart, then a time on the following day but before dayStart - # should be 0 days later than lastCron - lastCron = moment('2014-10-09 12:30:00') - days = shared.daysSince(lastCron, {now: moment('2014-10-10 01:30:00'), dayStart}) - expect(days).to.eql 0 - - it 'daysSince cron after, now tomorrow after', -> - # If the lastCron was after dayStart, then a time on the following day and after dayStart - # should be 1 day later than lastCron - lastCron = moment('2014-10-09 12:30:00') - days = shared.daysSince(lastCron, {now: moment('2014-10-10 10:30:00'), dayStart}) - expect(days).to.eql 1 - - xit 'daysSince, last cron before new dayStart', -> - # If lastCron was after dayStart (at 1am) with dayStart set at 0, changing dayStart to 4am - # should not trigger another cron the same day - - # dayStart is 0 - lastCron = moment('2014-10-09 01:00:00') - # dayStart is 4 - days = shared.daysSince(lastCron, {now: moment('2014-10-09 05:00:00'), dayStart}) - expect(days).to.eql 0 - - describe 'dailies', -> - - describe 'new day', -> - - ### - This section runs through a "cron matrix" of all permutations (that I can easily account for). It sets - task due days, user custom day start, timezoneOffset, etc - then runs cron, jumps to tomorrow and runs cron, - and so on - testing each possible outcome along the way - ### - - runCron = (options) -> - _.each [480, 240, 0, -120], (timezoneOffset) -> # test different timezones - now = shared.startOfWeek({timezoneOffset}).add(options.currentHour||0, 'hours') - {before,after} = beforeAfter({now, timezoneOffset, daysAgo:1, cronAfterStart:options.cronAfterStart||true, dayStart:options.dayStart||0, limitOne:'daily'}) - before.dailys[0].repeat = after.dailys[0].repeat = options.repeat if options.repeat - before.dailys[0].streak = after.dailys[0].streak = 10 - before.dailys[0].completed = after.dailys[0].completed = true if options.checked - before.dailys[0].startDate = after.dailys[0].startDate = moment().subtract(30, 'days') - if options.shouldDo - expect(shared.shouldDo(now.toDate(), after.dailys[0], {timezoneOffset, dayStart:options.dayStart, now})).to.be.ok() - after.fns.cron {now} - before.stats.mp=after.stats.mp #FIXME - switch options.expect - when 'losePoints' then expectLostPoints(before,after,'daily') - when 'noChange' then expectNoChange(before,after) - when 'noDamage' then expectDayResetNoDamage(before,after) - {before,after} - - # These test cases were written assuming that lastCron was run after dayStart - # even if currentHour < dayStart and lastCron = yesterday at currentHour. - # cronAfterStart makes sure that lastCron is moved to be after dayStart. - cronMatrix = - steps: - - 'due yesterday': - defaults: {daysAgo:1, cronAfterStart:true, limitOne: 'daily'} - steps: - - '(simple)': {expect:'losePoints'} - - 'due today': - # NOTE: a strange thing here, moment().startOf('week') is Sunday, but moment.zone(myTimeZone).startOf('week') is Monday. - defaults: {repeat:{su:true,m:true,t:true,w:true,th:true,f:true,s:true}} - steps: - 'pre-dayStart': - defaults: {currentHour:3, dayStart:4, shouldDo:true} - steps: - 'checked': {checked: true, expect:'noChange'} - 'un-checked': {checked: false, expect:'noChange'} - 'post-dayStart': - defaults: {currentHour:5, dayStart:4, shouldDo:true} - steps: - 'checked': {checked:true, expect:'noDamage'} - 'unchecked': {checked:false, expect: 'losePoints'} - - 'NOT due today': - defaults: {repeat:{su:true,m:false,t:true,w:true,th:true,f:true,s:true}} - steps: - 'pre-dayStart': - defaults: {currentHour:3, dayStart:4, shouldDo:true} - steps: - 'checked': {checked: true, expect:'noChange'} - 'un-checked': {checked: false, expect:'noChange'} - 'post-dayStart': - defaults: {currentHour:5, dayStart:4, shouldDo:false} - steps: - 'checked': {checked:true, expect:'noDamage'} - 'unchecked': {checked:false, expect: 'losePoints'} - - 'not due yesterday': - defaults: repeatWithoutLastWeekday() - steps: - '(simple)': {expect:'noDamage'} - 'post-dayStart': {currentHour:5,dayStart:4, expect:'noDamage'} - 'pre-dayStart': {currentHour:3, dayStart:4, expect:'noChange'} - - recurseCronMatrix = (obj, options={}) -> - if obj.steps - _.each obj.steps, (step, text) -> - o = _.cloneDeep options - o.text ?= ''; o.text += " #{text} " - recurseCronMatrix step, _.defaults(o,obj.defaults) - else - it "#{options.text}", -> runCron(_.defaults(obj,options)) - recurseCronMatrix(cronMatrix) - -describe 'Helper', -> - - it 'calculates gold coins', -> - expect(shared.gold(10)).to.eql 10 - expect(shared.gold(1.957)).to.eql 1 - expect(shared.gold()).to.eql 0 - - it 'calculates silver coins', -> - expect(shared.silver(10)).to.eql 0 - expect(shared.silver(1.957)).to.eql 95 - expect(shared.silver(0.01)).to.eql "01" - expect(shared.silver()).to.eql "00" - - it 'calculates experience to next level', -> - expect(shared.tnl 1).to.eql 150 - expect(shared.tnl 2).to.eql 160 - expect(shared.tnl 10).to.eql 260 - expect(shared.tnl 99).to.eql 3580 - - it 'calculates the start of the day', -> - fstr = 'YYYY-MM-DD HH:mm:ss' - today = '2013-01-01 00:00:00' - # get the timezone for the day, so the test case doesn't fail - # if you run it during daylight savings time because by default - # it uses moment().zone() which is the current minute offset - zone = moment(today).zone() - expect(shared.startOfDay({now: new Date(2013, 0, 1, 0)}, timezoneOffset:zone).format(fstr)).to.eql today - expect(shared.startOfDay({now: new Date(2013, 0, 1, 5)}, timezoneOffset:zone).format(fstr)).to.eql today - expect(shared.startOfDay({now: new Date(2013, 0, 1, 23, 59, 59), timezoneOffset:zone}).format(fstr)).to.eql today diff --git a/test/common/dailies.coffee b/test/common/dailies.coffee deleted file mode 100644 index da00c88871..0000000000 --- a/test/common/dailies.coffee +++ /dev/null @@ -1,418 +0,0 @@ -_ = require 'lodash' -expect = require 'expect.js' -sinon = require 'sinon' -moment = require 'moment' -shared = require '../../common/script/index.js' -shared.i18n.translations = require('../../website/src/libs/api-v2/i18n.js').translations - -repeatWithoutLastWeekday = ()-> - repeat = {su:true,m:true,t:true,w:true,th:true,f:true,s:true} - if shared.startOfWeek(moment().zone(0)).isoWeekday() == 1 # Monday - repeat.su = false - else - repeat.s = false - {repeat: repeat} - -### Helper Functions #### -# @TODO: Refactor into helper file -newUser = (addTasks=true)-> - buffs = {per:0, int:0, con:0, str:0, stealth: 0, streaks: false} - user = - auth: - timestamps: {} - stats: {str:1, con:1, per:1, int:1, mp: 32, class: 'warrior', buffs: buffs} - items: - lastDrop: - count: 0 - hatchingPotions: {} - eggs: {} - food: {} - gear: - equipped: {} - costume: {} - party: - quest: - progress: - down: 0 - preferences: {} - dailys: [] - todos: [] - rewards: [] - flags: {} - achievements: {} - contributor: - level: 2 - shared.wrap(user) - user.ops.reset(null, ->) - if addTasks - _.each ['habit', 'todo', 'daily'], (task)-> - user.ops.addTask {body: {type: task, id: shared.uuid()}} - user - -cron = (usr, missedDays=1) -> - usr.lastCron = moment().subtract(missedDays,'days') - usr.fns.cron() - -describe 'daily/weekly that repeats everyday (default)', -> - user = null - daily = null - weekly = null - - describe 'when startDate is in the future', -> - beforeEach -> - user = newUser() - user.dailys = [ - shared.taskDefaults({type:'daily', startDate: moment().add(7, 'days'), frequency: 'daily'}) - shared.taskDefaults({type:'daily', startDate: moment().add(7, 'days'), frequency: 'weekly', repeat: {su:true,m:true,t:true,w:true,th:true,f:true,s:true}}) - ] - daily = user.dailys[0] - weekly = user.dailys[1] - - it 'does not damage user for not completing it', -> - cron(user) - expect(user.stats.hp).to.be 50 - - it 'does not change value on cron if daily is incomplete', -> - cron(user) - expect(daily.value).to.be 0 - expect(weekly.value).to.be 0 - - it 'does not reset checklists if daily is not marked as complete', -> - checklist = [ - { - 'text' : '1', - 'id' : 'checklist-one', - 'completed' : true - }, - { - 'text' : '2', - 'id' : 'checklist-two', - 'completed' : true - }, - { - 'text' : '3', - 'id' : 'checklist-three', - 'completed' : false - } - ] - daily.checklist = checklist - weekly.checklist = checklist - cron(user) - - expect(daily.checklist[0].completed).to.be true - expect(daily.checklist[1].completed).to.be true - expect(daily.checklist[2].completed).to.be false - - expect(weekly.checklist[0].completed).to.be true - expect(weekly.checklist[1].completed).to.be true - expect(weekly.checklist[2].completed).to.be false - - it 'resets checklists if daily is marked as complete', -> - checklist = [ - { - 'text' : '1', - 'id' : 'checklist-one', - 'completed' : true - }, - { - 'text' : '2', - 'id' : 'checklist-two', - 'completed' : true - }, - { - 'text' : '3', - 'id' : 'checklist-three', - 'completed' : false - } - ] - daily.checklist = checklist - weekly.checklist = checklist - daily.completed = true - weekly.completed = true - cron(user) - - _.each daily.checklist, (box)-> - expect(box.completed).to.be false - - _.each weekly.checklist, (box)-> - expect(box.completed).to.be false - - it 'is due on startDate', -> - daily_due_today = shared.shouldDo moment(), daily - daily_due_on_start_date = shared.shouldDo moment().add(7, 'days'), daily - - expect(daily_due_today).to.be false - expect(daily_due_on_start_date).to.be true - - weekly_due_today = shared.shouldDo moment(), weekly - weekly_due_on_start_date = shared.shouldDo moment().add(7, 'days'), weekly - - expect(weekly_due_today).to.be false - expect(weekly_due_on_start_date).to.be true - - describe 'when startDate is in the past', -> - beforeEach -> - user = newUser() - user.dailys = [ - shared.taskDefaults({type:'daily', startDate: moment().subtract(7, 'days'), frequency: 'daily'}) - shared.taskDefaults({type:'daily', startDate: moment().subtract(7, 'days'), frequency: 'weekly'}) - ] - daily = user.dailys[0] - weekly = user.dailys[1] - - it 'does damage user for not completing it', -> - cron(user) - expect(user.stats.hp).to.be.lessThan 50 - - it 'decreases value on cron if daily is incomplete', -> - cron(user, 1) - expect(daily.value).to.be -1 - expect(weekly.value).to.be -1 - - it 'decreases value on cron once only if daily is incomplete and multiple days are missed', -> - cron(user, 7) - expect(daily.value).to.be -1 - expect(weekly.value).to.be -1 - - it 'resets checklists if daily is not marked as complete', -> - checklist = [ - { - 'text' : '1', - 'id' : 'checklist-one', - 'completed' : true - }, - { - 'text' : '2', - 'id' : 'checklist-two', - 'completed' : true - }, - { - 'text' : '3', - 'id' : 'checklist-three', - 'completed' : false - } - ] - daily.checklist = checklist - weekly.checklist = checklist - cron(user) - - _.each daily.checklist, (box)-> - expect(box.completed).to.be false - - _.each weekly.checklist, (box)-> - expect(box.completed).to.be false - - it 'resets checklists if daily is marked as complete', -> - checklist = [ - { - 'text' : '1', - 'id' : 'checklist-one', - 'completed' : true - }, - { - 'text' : '2', - 'id' : 'checklist-two', - 'completed' : true - }, - { - 'text' : '3', - 'id' : 'checklist-three', - 'completed' : false - } - ] - daily.checklist = checklist - daily.completed = true - weekly.checklist = checklist - weekly.completed = true - cron(user) - - _.each daily.checklist, (box)-> - expect(box.completed).to.be false - - _.each weekly.checklist, (box)-> - expect(box.completed).to.be false - - describe 'when startDate is today', -> - beforeEach -> - user = newUser() - user.dailys = [ - # Must set start date to yesterday, because cron mock sets last cron to yesterday - shared.taskDefaults({type:'daily', startDate: moment().subtract(1, 'days'), frequency: 'daily'}) - shared.taskDefaults({type:'daily', startDate: moment().subtract(1, 'days'), frequency: 'weekly'}) - ] - daily = user.dailys[0] - weekly = user.dailys[1] - - it 'does damage user for not completing it', -> - cron(user) - expect(user.stats.hp).to.be.lessThan 50 - - it 'decreases value on cron if daily is incomplete', -> - cron(user) - expect(daily.value).to.be.lessThan 0 - expect(weekly.value).to.be.lessThan 0 - - it 'resets checklists if daily is not marked as complete', -> - checklist = [ - { - 'text' : '1', - 'id' : 'checklist-one', - 'completed' : true - }, - { - 'text' : '2', - 'id' : 'checklist-two', - 'completed' : true - }, - { - 'text' : '3', - 'id' : 'checklist-three', - 'completed' : false - } - ] - daily.checklist = checklist - weekly.checklist = checklist - cron(user) - - _.each daily.checklist, (box)-> - expect(box.completed).to.be false - - _.each weekly.checklist, (box)-> - expect(box.completed).to.be false - - it 'resets checklists if daily is marked as complete', -> - checklist = [ - { - 'text' : '1', - 'id' : 'checklist-one', - 'completed' : true - }, - { - 'text' : '2', - 'id' : 'checklist-two', - 'completed' : true - }, - { - 'text' : '3', - 'id' : 'checklist-three', - 'completed' : false - } - ] - daily.checklist = checklist - daily.completed = true - weekly.checklist = checklist - weekly.completed = true - cron(user) - - _.each daily.checklist, (box)-> - expect(box.completed).to.be false - - _.each weekly.checklist, (box)-> - expect(box.completed).to.be false - -describe 'daily that repeats every x days', -> - user = null - daily = null - - beforeEach -> - user = newUser() - user.dailys = [ shared.taskDefaults({type:'daily', startDate: moment(), frequency: 'daily'}) ] - daily = user.dailys[0] - - _.times 11, (due) -> - - it 'where x equals ' + due, -> - daily.everyX = due - - _.times 30, (day) -> - isDue = shared.shouldDo moment().add(day, 'days'), daily - expect(isDue).to.be true if day % due == 0 - expect(isDue).to.be false if day % due != 0 - -describe 'daily that repeats every X days when multiple days are missed', -> - everyX = 3 - startDateDaysAgo = everyX * 3 - user = null - daily = null - - describe 'including missing a due date', -> - missedDays = everyX * 2 + 1 - - beforeEach -> - user = newUser() - user.dailys = [ - shared.taskDefaults({type:'daily', startDate: moment().subtract(startDateDaysAgo, 'days'), frequency: 'daily', everyX: everyX}) - ] - daily = user.dailys[0] - - it 'decreases value on cron once only if daily is incomplete', -> - cron(user, missedDays) - expect(daily.value).to.be -1 - - it 'resets checklists if daily is incomplete', -> - checklist = [ - { - 'text' : '1', - 'id' : 'checklist-one', - 'completed' : true - } - ] - daily.checklist = checklist - cron(user, missedDays) - _.each daily.checklist, (box)-> - expect(box.completed).to.be false - - it 'resets checklists if daily is marked as complete', -> - checklist = [ - { - 'text' : '1', - 'id' : 'checklist-one', - 'completed' : true - } - ] - daily.checklist = checklist - daily.completed = true - cron(user, missedDays) - _.each daily.checklist, (box)-> - expect(box.completed).to.be false - - describe 'but not missing a due date', -> - missedDays = everyX - 1 - - beforeEach -> - user = newUser() - user.dailys = [ - shared.taskDefaults({type:'daily', startDate: moment().subtract(startDateDaysAgo, 'days'), frequency: 'daily', everyX: everyX}) - ] - daily = user.dailys[0] - - it 'does not decrease value on cron', -> - cron(user, missedDays) - expect(daily.value).to.be 0 - - it 'does not reset checklists if daily is incomplete', -> - checklist = [ - { - 'text' : '1', - 'id' : 'checklist-one', - 'completed' : true - } - ] - daily.checklist = checklist - cron(user, missedDays) - _.each daily.checklist, (box)-> - expect(box.completed).to.be true - - it 'resets checklists if daily is marked as complete', -> - checklist = [ - { - 'text' : '1', - 'id' : 'checklist-one', - 'completed' : true - } - ] - daily.checklist = checklist - daily.completed = true - cron(user, missedDays) - _.each daily.checklist, (box)-> - expect(box.completed).to.be false From f20336861a8c3070251b7ca3328b87b559c0af89 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 16 Nov 2015 08:07:20 -0600 Subject: [PATCH 119/976] Correct lodash syntax --- common/script/i18n.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/script/i18n.js b/common/script/i18n.js index 908925e9e5..41a0e63bc2 100644 --- a/common/script/i18n.js +++ b/common/script/i18n.js @@ -37,7 +37,7 @@ function t (stringName) { if (string) { try { - return _.template(string, clonedVars); + return _.template(string)(clonedVars); } catch (_error) { return 'Error processing the string. Please see Help > Report a Bug.'; } @@ -51,7 +51,7 @@ function t (stringName) { } try { - return _.template(stringNotFound, { + return _.template(stringNotFound)({ string: stringName, }); } catch (_error) { From 9ec39a597ec983b020fee2850e7d508eab9f34f4 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 16 Nov 2015 08:07:28 -0600 Subject: [PATCH 120/976] Set up i18n in test helpers --- test/helpers/globals.helper.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 4da36c7977..9b9ff5b8bc 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,4 +1,5 @@ require('babel-core/register'); +require('../../website/src/libs/api-v3/i18n'); //------------------------------ // Global modules //------------------------------ From dcca17fa4e6e0467ec8cfa4bd545dfe03281f172 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 16 Nov 2015 08:12:45 -0600 Subject: [PATCH 121/976] Set up i18n in unit test helper. --- test/helpers/api-unit.helper.js | 4 +--- test/helpers/globals.helper.js | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 62516f2751..554578ab6e 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -1,9 +1,7 @@ +import '../../website/src/libs/api-v3/i18n'; import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user' import { model as Group } from '../../website/src/models/group' -import i18n from '../../common/script/src/i18n'; -require('coffee-script'); -i18n.translations = require('../../website/src/libs/api-v3/i18n.js').translations; afterEach(() => { sandbox.restore(); diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 9b9ff5b8bc..4da36c7977 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,5 +1,4 @@ require('babel-core/register'); -require('../../website/src/libs/api-v3/i18n'); //------------------------------ // Global modules //------------------------------ From 9bf1ebdb0faee3f46ef8ed3f7ed2eca72bf06be1 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 16 Nov 2015 08:14:42 -0600 Subject: [PATCH 122/976] Remove last of coffee files --- common/script/i18n.coffee | 44 --------------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 common/script/i18n.coffee diff --git a/common/script/i18n.coffee b/common/script/i18n.coffee deleted file mode 100644 index 3e9b08f01d..0000000000 --- a/common/script/i18n.coffee +++ /dev/null @@ -1,44 +0,0 @@ -_ = require 'lodash' - -module.exports = - strings: null, # Strings for one single language - translations: {} # Strings for multiple languages {en: strings, de: strings, ...} - t: (stringName) -> # Other parameters allowed are vars (Object) and locale (String) - vars = arguments[1] - - if _.isString(arguments[1]) - vars = null - locale = arguments[1] - else if arguments[2]? - vars = arguments[1] - locale = arguments[2] - - locale = 'en' if (!locale? or (!module.exports.strings and !module.exports.translations[locale])) - - if module.exports.strings - string = module.exports.strings[stringName] - else - string = - module.exports.translations[locale] and - module.exports.translations[locale][stringName] - - clonedVars = _.clone(vars) or {} - clonedVars.locale = locale - - if string - try - _.template(string)((clonedVars)) - catch e - 'Error processing the string. Please see Help > Report a Bug.' - else - if module.exports.strings - stringNotFound = module.exports.strings.stringNotFound - else if module.exports.translations[locale] - stringNotFound = - module.exports.translations[locale] and - module.exports.translations[locale].stringNotFound - - try - _.template(stringNotFound)({string: stringName}) - catch e - 'Error processing the string. Please see Help > Report a Bug.' From c3f8bb07261e4c98bde0a00b8fea4208f7cc5f82 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 16 Nov 2015 12:10:58 -0600 Subject: [PATCH 123/976] Correct count module to use correct lodash 3 syntax --- common/script/count.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/common/script/count.js b/common/script/count.js index 6305646836..cd53a591e0 100644 --- a/common/script/count.js +++ b/common/script/count.js @@ -1,12 +1,16 @@ -import _ from 'lodash'; +import { + each, + keys, + size, +} from 'lodash'; import content from './content/index'; -const DROP_ANIMALS = _.keys(content.pets); +const DROP_ANIMALS = keys(content.pets); function beastMasterProgress (pets) { let count = 0; - _(DROP_ANIMALS).each((animal) => { + each(DROP_ANIMALS, (animal) => { if (pets[animal] > 0 || pets[animal] === -1) count++; }); @@ -17,7 +21,7 @@ function beastMasterProgress (pets) { function dropPetsCurrentlyOwned (pets) { let count = 0; - _(DROP_ANIMALS).each((animal) => { + each(DROP_ANIMALS, (animal) => { if (pets[animal] > 0) count++; }); @@ -28,7 +32,7 @@ function dropPetsCurrentlyOwned (pets) { function mountMasterProgress (mounts) { let count = 0; - _(DROP_ANIMALS).each((animal) => { + each(DROP_ANIMALS, (animal) => { if (mounts[animal]) count++; }); @@ -44,7 +48,7 @@ function remainingGearInSet (userGear, set) { return setMatches && !hasItem; }); - let count = _.size(gear); + let count = size(gear); return count; } @@ -57,7 +61,7 @@ function questsOfCategory (userQuests, category) { return categoryMatches && hasQuest; }); - let count = _.size(quests); + let count = size(quests); return count; } From 61948e1ca579c428a2ecf95207ea5be749bc9e1c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 16 Nov 2015 12:22:00 -0600 Subject: [PATCH 124/976] Add filter to lodash import. --- common/script/count.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/script/count.js b/common/script/count.js index cd53a591e0..f61da83a09 100644 --- a/common/script/count.js +++ b/common/script/count.js @@ -1,5 +1,6 @@ import { each, + filter, keys, size, } from 'lodash'; @@ -41,7 +42,7 @@ function mountMasterProgress (mounts) { } function remainingGearInSet (userGear, set) { - let gear = _.filter(content.gear.flat, (item) => { + let gear = filter(content.gear.flat, (item) => { let setMatches = item.klass === set; let hasItem = userGear[item.key]; @@ -54,7 +55,7 @@ function remainingGearInSet (userGear, set) { } function questsOfCategory (userQuests, category) { - let quests = _.filter(content.quests, (quest) => { + let quests = filter(content.quests, (quest) => { let categoryMatches = quest.category === category; let hasQuest = userQuests[quest.key]; From aa1b046cf2c9def411cf72d2195dbe9684886ef8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 Nov 2015 16:48:50 +0100 Subject: [PATCH 125/976] add example apidoc comments, add notFound middleware --- .gitignore | 1 + .../v3/unit/middlewares/errorHandler.test.js | 2 +- test/api/v3/unit/middlewares/notFound.test.js | 32 +++++++++++++++++++ website/src/controllers/api-v3/example.js | 21 +++++++++++- website/src/libs/api-v3/errors.js | 19 +++++++++++ .../src/middlewares/api-v3/errorHandler.js | 20 ++++++------ website/src/middlewares/api-v3/index.js | 2 ++ website/src/middlewares/api-v3/notFound.js | 7 ++++ website/src/server.js | 19 +++++------ 9 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 test/api/v3/unit/middlewares/notFound.test.js create mode 100644 website/src/middlewares/api-v3/notFound.js diff --git a/.gitignore b/.gitignore index 911f7f45e9..78a2bf4294 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store website/public/gen website/public/common +website/public/apidoc node_modules *.swp .idea* diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 912dd75d1c..783e0aff21 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -88,7 +88,7 @@ describe('errorHandler', () => { errorHandler(error, req, res, next); expect(logger.error).to.be.calledOnce; - expect(logger.error).to.be.calledWith(error.stack, { + expect(logger.error).to.be.calledWithExactly(error.stack, { originalUrl: req.originalUrl, headers: req.headers, body: req.body, diff --git a/test/api/v3/unit/middlewares/notFound.test.js b/test/api/v3/unit/middlewares/notFound.test.js new file mode 100644 index 0000000000..55064dbe0c --- /dev/null +++ b/test/api/v3/unit/middlewares/notFound.test.js @@ -0,0 +1,32 @@ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; + +import notFoundHandler from '../../../../../website/src/middlewares/api-v3/notFound'; + +import { NotFound } from '../../../../../website/src/libs/api-v3/errors'; + +describe('notFoundHandler', () => { + let res, req, next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + + sandbox.stub(logger, 'error'); + }); + + it('sends NotFound error if the resource isn\'t found', () => { + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(404); + expect(res.json).to.be.calledWith({ + error: 'NotFound', + message: 'Not found.', + }); + }); +}); diff --git a/website/src/controllers/api-v3/example.js b/website/src/controllers/api-v3/example.js index f5e9f24b71..7ead22d794 100644 --- a/website/src/controllers/api-v3/example.js +++ b/website/src/controllers/api-v3/example.js @@ -1,6 +1,25 @@ // An example file to show how a controller should be structured let api = {}; +/** + * @api {get} /example/:id Request Example information + * @apiName GetExample + * @apiGroup Example + * + * @apiParam {Number} id Examples unique ID. + * + * @apiSuccess {String} firstname Firstname of the Example. + * @apiSuccess {String} lastname Lastname of the Example. + * + * @apiSuccessExample Success-Response: + * HTTP/1.1 200 OK + * { + * "firstname": "John", + * "lastname": "Doe" + * } + * + * @apiUse NotFound + */ api.exampleRoute = { method: 'GET', url: '/example/:param', @@ -12,4 +31,4 @@ api.exampleRoute = { }, }; -export default api; \ No newline at end of file +export default api; diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index 1439669d20..49e3a12c45 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -30,6 +30,25 @@ export class BadRequest extends CustomError { } } +/** + * @apiDefine NotFound + * @apiError NotFound The requested resource was not found. + * + * @apiErrorExample Error-Response: + * HTTP/1.1 404 Not Found + * { + * "error": "NotFound" + * } + */ +export class NotFound extends CustomError { + constructor (customMessage) { + super(); + this.name = this.constructor.name; + this.httpCode = 401; + this.message = customMessage || 'Not found.'; + } +} + // InternalError error with a 500 http error code // used when an unexpected, internal server error is thrown export class InternalServerError extends CustomError { diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 5bf90f6969..5982284895 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -10,16 +10,6 @@ import { export default function errorHandler (err, req, res, next) { if (!err) return next(); - // Log the original error with some metadata - let stack = err.stack || err.message || err; - - logger.error(stack, { - originalUrl: req.originalUrl, - headers: req.headers, - body: req.body, - fullError: err, - }); - // In case of a CustomError class, use it's data // Otherwise try to identify the type of error (mongoose validation, mongodb unique, ...) // If we can't identify it, respond with a generic 500 error @@ -48,6 +38,16 @@ export default function errorHandler (err, req, res, next) { responseErr = new InternalServerError(); } + // Log the original error with some metadata + let stack = err.stack || err.message || err; + + logger.error(stack, { + originalUrl: req.originalUrl, + headers: req.headers, + body: req.body, + fullError: err, + }); + // TODO unless status >= 500 return data attached to errors return res .status(responseErr.httpCode) diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 39847e67bf..0041e2dd54 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -4,6 +4,7 @@ import analytics from './analytics'; import errorHandler from './errorHandler'; import bodyParser from 'body-parser'; import routes from '../../libs/api-v3/setupRoutes'; +import notFoundHandler from './notFound'; export default function attachMiddlewares (app) { // Parse query parameters and json bodies @@ -15,6 +16,7 @@ export default function attachMiddlewares (app) { app.use(analytics); app.use(routes); + app.use(notFoundHandler); // Error handler middleware, define as the last one app.use(errorHandler); diff --git a/website/src/middlewares/api-v3/notFound.js b/website/src/middlewares/api-v3/notFound.js new file mode 100644 index 0000000000..733a247d1d --- /dev/null +++ b/website/src/middlewares/api-v3/notFound.js @@ -0,0 +1,7 @@ +import { + NotFound, +} from '../../libs/api-v3/errors'; + +export default function (req, res, next) { + next(new NotFound()); +} diff --git a/website/src/server.js b/website/src/server.js index 1142f27539..de899ab62d 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -4,7 +4,7 @@ import nconf from 'nconf'; import logger from './libs/api-v3/logger'; import express from 'express'; import http from 'http'; -// import path from 'path'; +import path from 'path'; // let swagger = require('swagger-node-express'); import autoinc from 'mongoose-id-autoinc'; import passport from 'passport'; @@ -73,7 +73,7 @@ passport.use(new FacebookStrategy({ }, (accessToken, refreshToken, profile, done) => done(null, profile))); // ------------ Server Configuration ------------ -// let publicDir = path.join(__dirname, '/../public'); +let publicDir = path.join(__dirname, '/../public'); app.set('port', nconf.get('PORT')); @@ -144,17 +144,18 @@ oldApp.use('/api/v1', require('./routes/api-v1')); oldApp.use('/export', require('./routes/dataexport')); require('./routes/api-v2/swagger')(swagger, v2); -var maxAge = IS_PROD ? 31536000000 : 0; // Cache emojis without copying them to build, they are too many -oldApp.use(express['static'](path.join(__dirname, "/../build"), { maxAge: maxAge })); -oldApp.use('/common/dist', express['static'](publicDir + "/../../common/dist", { maxAge: maxAge })); -oldApp.use('/common/audio', express['static'](publicDir + "/../../common/audio", { maxAge: maxAge })); -oldApp.use('/common/script/public', express['static'](publicDir + "/../../common/script/public", { maxAge: maxAge })); -oldApp.use('/common/img', express['static'](publicDir + "/../../common/img", { maxAge: maxAge })); -oldApp.use(express['static'](publicDir)); oldApp.use(require('./middlewares/api-v2/errorHandler')); */ +let maxAge = IS_PROD ? 31536000000 : 0; + +oldApp.use(express.static(path.join(__dirname, '/../build'), { maxAge })); +oldApp.use('/common/dist', express.static(`${publicDir}/../../common/dist`, { maxAge })); +oldApp.use('/common/audio', express.static(`${publicDir}/../../common/audio`, { maxAge })); +oldApp.use('/common/script/public', express.static(`${publicDir}/../../common/script/public`, { maxAge })); +oldApp.use('/common/img', express.static(`${publicDir}/../../common/img`, { maxAge })); +oldApp.use(express.static(publicDir)); server.on('request', app); server.listen(app.get('port'), () => { From 9905deec060631bc7d1d797a8ab5b59f756d3b28 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 Nov 2015 17:02:53 +0100 Subject: [PATCH 126/976] move static handler to separate middleware --- website/src/middlewares/api-v3/static.js | 18 ++++++++++++++++++ website/src/server.js | 10 +++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 website/src/middlewares/api-v3/static.js diff --git a/website/src/middlewares/api-v3/static.js b/website/src/middlewares/api-v3/static.js new file mode 100644 index 0000000000..2fc141ac1b --- /dev/null +++ b/website/src/middlewares/api-v3/static.js @@ -0,0 +1,18 @@ +import express from 'express'; +import nconf from 'nconf'; +import path from 'path'; + +const IS_PROD = nconf.get('IS_PROD'); +const MAX_AGE = IS_PROD ? 31536000000 : 0; +const PUBLIC_DIR = path.join(__dirname, '/../../../public'); +const BUILD_DIR = path.join(__dirname, '/../../../build'); + +export default function staticMiddleware (expressApp) { + // TODO move all static files to a single location (one for public and one for build) + expressApp.use(express.static(BUILD_DIR, { maxAge: MAX_AGE })); + expressApp.use('/common/dist', express.static(`${PUBLIC_DIR}/../../common/dist`, { maxAge: MAX_AGE })); + expressApp.use('/common/audio', express.static(`${PUBLIC_DIR}/../../common/audio`, { maxAge: MAX_AGE })); + expressApp.use('/common/script/public', express.static(`${PUBLIC_DIR}/../../common/script/public`, { maxAge: MAX_AGE })); + expressApp.use('/common/img', express.static(`${PUBLIC_DIR}/../../common/img`, { maxAge: MAX_AGE })); + expressApp.use(express.static(PUBLIC_DIR)); +}; diff --git a/website/src/server.js b/website/src/server.js index de899ab62d..21744c0813 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -4,7 +4,7 @@ import nconf from 'nconf'; import logger from './libs/api-v3/logger'; import express from 'express'; import http from 'http'; -import path from 'path'; +// import path from 'path'; // let swagger = require('swagger-node-express'); import autoinc from 'mongoose-id-autoinc'; import passport from 'passport'; @@ -14,6 +14,7 @@ import mongoose from 'mongoose'; import Q from 'q'; import domainMiddleware from './middlewares/api-v3/domain'; import attachMiddlewares from './middlewares/api-v3/index'; +import staticMiddleware from './middlewares/api-v3/static'; // Setup translations // let i18n = require('./libs/api-v2/i18n'); @@ -73,7 +74,7 @@ passport.use(new FacebookStrategy({ }, (accessToken, refreshToken, profile, done) => done(null, profile))); // ------------ Server Configuration ------------ -let publicDir = path.join(__dirname, '/../public'); +// let publicDir = path.join(__dirname, '/../public'); app.set('port', nconf.get('PORT')); @@ -147,7 +148,7 @@ require('./routes/api-v2/swagger')(swagger, v2); // Cache emojis without copying them to build, they are too many oldApp.use(require('./middlewares/api-v2/errorHandler')); -*/ +* let maxAge = IS_PROD ? 31536000000 : 0; oldApp.use(express.static(path.join(__dirname, '/../build'), { maxAge })); @@ -156,6 +157,9 @@ oldApp.use('/common/audio', express.static(`${publicDir}/../../common/audio`, { oldApp.use('/common/script/public', express.static(`${publicDir}/../../common/script/public`, { maxAge })); oldApp.use('/common/img', express.static(`${publicDir}/../../common/img`, { maxAge })); oldApp.use(express.static(publicDir)); +*/ + +staticMiddleware(app); server.on('request', app); server.listen(app.get('port'), () => { From 846800ccc9dd052fc7c9bf3f2f3ce3ed26163db7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 Nov 2015 17:49:59 +0100 Subject: [PATCH 127/976] fix linting --- website/src/middlewares/api-v3/static.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/middlewares/api-v3/static.js b/website/src/middlewares/api-v3/static.js index 2fc141ac1b..f944e1e301 100644 --- a/website/src/middlewares/api-v3/static.js +++ b/website/src/middlewares/api-v3/static.js @@ -15,4 +15,4 @@ export default function staticMiddleware (expressApp) { expressApp.use('/common/script/public', express.static(`${PUBLIC_DIR}/../../common/script/public`, { maxAge: MAX_AGE })); expressApp.use('/common/img', express.static(`${PUBLIC_DIR}/../../common/img`, { maxAge: MAX_AGE })); expressApp.use(express.static(PUBLIC_DIR)); -}; +} From 2a51117d216b4796c5e65a470a1fe168a6bfdb9f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 Nov 2015 19:22:47 +0100 Subject: [PATCH 128/976] comment errors according to apidoc, add new tests and fix existing ones --- test/api/v3/unit/libs/errors.test.js | 31 +++++++++++- .../v3/unit/middlewares/errorHandler.test.js | 4 +- test/api/v3/unit/middlewares/notFound.test.js | 4 +- website/src/libs/api-v3/errors.js | 49 ++++++++++++++----- .../src/middlewares/api-v3/errorHandler.js | 20 ++++---- 5 files changed, 80 insertions(+), 28 deletions(-) diff --git a/test/api/v3/unit/libs/errors.test.js b/test/api/v3/unit/libs/errors.test.js index e859d2bb02..15110a06ee 100644 --- a/test/api/v3/unit/libs/errors.test.js +++ b/test/api/v3/unit/libs/errors.test.js @@ -3,6 +3,7 @@ import { NotAuthorized, BadRequest, InternalServerError, + NotFound, } from '../../../../../website/src/libs/api-v3/errors'; describe('Custom Errors', () => { @@ -21,7 +22,7 @@ describe('Custom Errors', () => { expect(notAuthorizedError).to.be.an.instanceOf(CustomError); }); - it('it returns an http code of 400', () => { + it('it returns an http code of 401', () => { let notAuthorizedError = new NotAuthorized(); expect(notAuthorizedError.httpCode).to.eql(401); @@ -40,6 +41,32 @@ describe('Custom Errors', () => { }); }); + describe('NotFound', () => { + it('is an instance of CustomError', () => { + let notAuthorizedError = new NotFound(); + + expect(notAuthorizedError).to.be.an.instanceOf(CustomError); + }); + + it('it returns an http code of 404', () => { + let notAuthorizedError = new NotFound(); + + expect(notAuthorizedError.httpCode).to.eql(404); + }); + + it('returns a default message', () => { + let notAuthorizedError = new NotFound(); + + expect(notAuthorizedError.message).to.eql('Not found.'); + }); + + it('allows a custom message', () => { + let notAuthorizedError = new NotFound('Custom Error Message'); + + expect(notAuthorizedError.message).to.eql('Custom Error Message'); + }); + }); + describe('BadRequest', () => { it('is an instance of CustomError', () => { let badRequestError = new BadRequest(); @@ -82,7 +109,7 @@ describe('Custom Errors', () => { it('returns a default message', () => { let internalServerError = new InternalServerError(); - expect(internalServerError.message).to.eql('Internal server error.'); + expect(internalServerError.message).to.eql('An unexpected error occurred.'); }); it('allows a custom message', () => { diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 783e0aff21..afc506e976 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -31,7 +31,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(500); expect(res.json).to.be.calledWith({ error: 'InternalServerError', - message: 'Internal server error.', + message: 'An unexpected error occurred.', }); }); @@ -63,7 +63,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(500); expect(res.json).to.be.calledWith({ error: 'InternalServerError', - message: 'Internal server error.', + message: 'An unexpected error occurred.', }); }); diff --git a/test/api/v3/unit/middlewares/notFound.test.js b/test/api/v3/unit/middlewares/notFound.test.js index 55064dbe0c..5f9922d38c 100644 --- a/test/api/v3/unit/middlewares/notFound.test.js +++ b/test/api/v3/unit/middlewares/notFound.test.js @@ -15,11 +15,9 @@ describe('notFoundHandler', () => { res = generateRes(); req = generateReq(); next = generateNext(); - - sandbox.stub(logger, 'error'); }); - it('sends NotFound error if the resource isn\'t found', () => { + xit('sends NotFound error if the resource isn\'t found', () => { expect(res.status).to.be.calledOnce; expect(res.json).to.be.calledOnce; diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index 49e3a12c45..1e90c0158e 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -7,8 +7,17 @@ export class CustomError extends Error { } } -// NotAuthorized error with a 401 http error code -// used when a request is not authorized +/** + * @apiDefine NotFound + * @apiError NotFound The client is not authorized to make this request. + * + * @apiErrorExample Error-Response: + * HTTP/1.1 401 Unauthorized + * { + * "error": "NotAuthorized", + * "message": "Not authorized." + * } + */ export class NotAuthorized extends CustomError { constructor (customMessage) { super(); @@ -18,10 +27,18 @@ export class NotAuthorized extends CustomError { } } -// BadRequest error with a 400 http error code -// used for requests not formatted correctly -// TODO use for validation errors too? -export class BadRequest extends CustomError { +/** + * @apiDefine BadRequest + * @apiError BadRequest The request wasn't formatted correctly. + * + * @apiErrorExample Error-Response: + * HTTP/1.1 400 Bad Request + * { + * "error": "BadRequest", + * "message": "Bad request." + * } + */ + export class BadRequest extends CustomError { constructor (customMessage) { super(); this.name = this.constructor.name; @@ -37,25 +54,35 @@ export class BadRequest extends CustomError { * @apiErrorExample Error-Response: * HTTP/1.1 404 Not Found * { - * "error": "NotFound" + * "error": "NotFound", + * "message": "Not found." * } */ export class NotFound extends CustomError { constructor (customMessage) { super(); this.name = this.constructor.name; - this.httpCode = 401; + this.httpCode = 404; this.message = customMessage || 'Not found.'; } } -// InternalError error with a 500 http error code -// used when an unexpected, internal server error is thrown +/** + * @apiDefine InternalServerError + * @apiError InternalServerError An unexpected error occurred. + * + * @apiErrorExample Error-Response: + * HTTP/1.1 500 Internal Server Error + * { + * "error": "InternalServerError", + * "message": "An unexpected error occurred." + * } + */ export class InternalServerError extends CustomError { constructor (customMessage) { super(); this.name = this.constructor.name; this.httpCode = 500; - this.message = customMessage || 'Internal server error.'; + this.message = customMessage || 'An unexpected error occurred.'; } } diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 5982284895..5bf90f6969 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -10,6 +10,16 @@ import { export default function errorHandler (err, req, res, next) { if (!err) return next(); + // Log the original error with some metadata + let stack = err.stack || err.message || err; + + logger.error(stack, { + originalUrl: req.originalUrl, + headers: req.headers, + body: req.body, + fullError: err, + }); + // In case of a CustomError class, use it's data // Otherwise try to identify the type of error (mongoose validation, mongodb unique, ...) // If we can't identify it, respond with a generic 500 error @@ -38,16 +48,6 @@ export default function errorHandler (err, req, res, next) { responseErr = new InternalServerError(); } - // Log the original error with some metadata - let stack = err.stack || err.message || err; - - logger.error(stack, { - originalUrl: req.originalUrl, - headers: req.headers, - body: req.body, - fullError: err, - }); - // TODO unless status >= 500 return data attached to errors return res .status(responseErr.httpCode) From 18503e31c3fe7f78f4fb88ccf12b51731e9ab8ef Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 Nov 2015 19:31:51 +0100 Subject: [PATCH 129/976] fix linting --- website/src/libs/api-v3/errors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index 1e90c0158e..89e2bd4fd1 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -38,7 +38,7 @@ export class NotAuthorized extends CustomError { * "message": "Bad request." * } */ - export class BadRequest extends CustomError { +export class BadRequest extends CustomError { constructor (customMessage) { super(); this.name = this.constructor.name; From 05eb0ec78280007ca846780a20cfbc2d807d388d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 17 Nov 2015 17:26:18 -0600 Subject: [PATCH 130/976] Add missing comma. --- website/src/models/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 4b0e269e9a..5c294388d4 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -71,7 +71,7 @@ export let schema = new Schema({ habiticaDays: Number, greeting: Number, thankyou: Number, - costumeContests: Number + costumeContests: Number, }, backer: { From 6fb4fdfd8acfbc630fb02f9bb4f439d283c2f154 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 Nov 2015 11:50:55 +0100 Subject: [PATCH 131/976] create apidoc task in grunt, update package.json version --- package.json | 11 ++++++++++- tasks/gulp-apidoc.js | 22 ++++++++++++++++++++++ website/src/controllers/api-v3/example.js | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tasks/gulp-apidoc.js diff --git a/package.json b/package.json index f13295e9e6..d973a03c5c 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "habitrpg", "description": "A habit tracker app which treats your goals like a Role Playing Game.", - "version": "0.0.0-152", + "version": "3.0.0-alpha", "main": "./website/src/index.js", "dependencies": { "accepts": "^1.3.0", "amazon-payments": "0.0.4", "amplitude": "^2.0.3", + "apidoc": "^0.13.1", "async": "^1.5.0", "aws-sdk": "^2.0.25", "babel-core": "^5.8.34", @@ -117,6 +118,7 @@ "mongodb": "^2.0.46", "mongoskin": "~0.6.1", "nock": "^2.17.0", + "phantomjs": "^1.9.18", "protractor": "~2.5.1", "rewire": "^2.3.3", "rimraf": "^2.4.3", @@ -128,5 +130,12 @@ "uuid": "^2.0.1", "vinyl-source-stream": "^1.0.0", "vinyl-transform": "^1.0.0" + }, + "apidoc": { + "name": "habitica", + "title": "Habitica", + "version": "3.0.0", + "url": "https://habitica.com/api/v3", + "sampleUrl": "https://habitica.com/api/v3" } } diff --git a/tasks/gulp-apidoc.js b/tasks/gulp-apidoc.js new file mode 100644 index 0000000000..cb2777c254 --- /dev/null +++ b/tasks/gulp-apidoc.js @@ -0,0 +1,22 @@ +import gulp from 'gulp'; +import clean from 'rimraf'; +import apidoc from 'apidoc'; + +const APIDOC_DEST_PATH = './website/public/apidoc'; +const APIDOC_SRC_PATH = './website/src'; +gulp.task('apidoc:clean', (done) => { + clean(APIDOC_DEST_PATH, done); +}); + +gulp.task('apidoc', ['apidoc:clean'], (done) => { + let result = apidoc.createDoc({ + src: APIDOC_SRC_PATH, + dest: APIDOC_DEST_PATH, + }); + + if (result === false) { + done(new Error('There was a problem generating apiDoc documentation.')) + } else { + done(); + } +}); diff --git a/website/src/controllers/api-v3/example.js b/website/src/controllers/api-v3/example.js index 7ead22d794..578ce21655 100644 --- a/website/src/controllers/api-v3/example.js +++ b/website/src/controllers/api-v3/example.js @@ -1,8 +1,8 @@ -// An example file to show how a controller should be structured let api = {}; /** * @api {get} /example/:id Request Example information + * @apiVersion 3.0.0 * @apiName GetExample * @apiGroup Example * From b5adf9f19bc3e76f7d0bfca7b3e27b6bca417d1f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 Nov 2015 11:52:13 +0100 Subject: [PATCH 132/976] add apidoc to production build step --- tasks/gulp-build.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/gulp-build.js b/tasks/gulp-build.js index 599d577b10..e7e683ebb9 100644 --- a/tasks/gulp-build.js +++ b/tasks/gulp-build.js @@ -17,6 +17,6 @@ gulp.task('build:dev:watch', ['build:dev'], () => { gulp.watch(['website/public/**/*.styl', 'common/script/*']); }); -gulp.task('build:prod', ['browserify', 'prepare:staticNewStuff'], (done) => { +gulp.task('build:prod', ['browserify', 'prepare:staticNewStuff', 'apidoc'], (done) => { gulp.start('grunt-build:prod', done); }); From 65a8d2e255eadcd92e7d711706483e761e2dd6c1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 Nov 2015 12:36:13 +0100 Subject: [PATCH 133/976] misc fixes and improvements, starts adding route tests --- test/api/v3/unit/middlewares/notFound.test.js | 37 +++++-------------- website/src/controllers/api-v3/example.js | 4 +- website/src/index.js | 2 - website/src/libs/api-v3/logger.js | 1 + website/src/libs/api-v3/setupRoutes.js | 3 +- website/src/middlewares/api-v3/index.js | 10 ++++- website/src/server.js | 2 +- 7 files changed, 24 insertions(+), 35 deletions(-) diff --git a/test/api/v3/unit/middlewares/notFound.test.js b/test/api/v3/unit/middlewares/notFound.test.js index 5f9922d38c..f2f6082acf 100644 --- a/test/api/v3/unit/middlewares/notFound.test.js +++ b/test/api/v3/unit/middlewares/notFound.test.js @@ -1,30 +1,13 @@ -import { - generateRes, - generateReq, - generateNext, -} from '../../../../helpers/api-unit.helper'; +import { requester } from '../../../../helpers/api-integration.helper'; -import notFoundHandler from '../../../../../website/src/middlewares/api-v3/notFound'; +describe('notFound Middleware', () => { + it('returns a 404 error when the resource is not found', () => { + let request = requester().get('/api/v3/dummy-url'); -import { NotFound } from '../../../../../website/src/libs/api-v3/errors'; - -describe('notFoundHandler', () => { - let res, req, next; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - }); - - xit('sends NotFound error if the resource isn\'t found', () => { - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(404); - expect(res.json).to.be.calledWith({ - error: 'NotFound', - message: 'Not found.', - }); - }); + return expect(request) + .to.eventually.be.rejected.and.eql({ + error: "NotFound", + message: "Not found.", + }); + }); }); diff --git a/website/src/controllers/api-v3/example.js b/website/src/controllers/api-v3/example.js index 578ce21655..6ad474a5f2 100644 --- a/website/src/controllers/api-v3/example.js +++ b/website/src/controllers/api-v3/example.js @@ -22,11 +22,11 @@ let api = {}; */ api.exampleRoute = { method: 'GET', - url: '/example/:param', + url: '/example/:id', middlewares: [], handler (req, res) { res.status(200).send({ - status: 'ok', + status: req.params.id, }); }, }; diff --git a/website/src/index.js b/website/src/index.js index 2440dde36f..a562dfa9c2 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -14,8 +14,6 @@ var IS_PROD = nconf.get('IS_PROD'); var IS_DEV = nconf.get('IS_DEV'); var cores = Number(nconf.get('WEB_CONCURRENCY')) || 0; -if (IS_DEV) Error.stackTraceLimit = Infinity; - // Setup the cluster module if (cores !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) { // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 3ab20ad685..0d00ca7f4b 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -14,6 +14,7 @@ if (IS_PROD) { logger .add(winston.transports.Console, { colorize: true, + prettyPrint: true, }); } diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index 4bce7b1e26..8c346577bc 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import express from 'express'; import _ from 'lodash'; + const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); let router = express.Router(); // eslint-disable-line new-cap @@ -20,4 +21,4 @@ fs }); }); -export default router; \ No newline at end of file +export default router; diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 0041e2dd54..dfd19e5493 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -5,9 +5,15 @@ import errorHandler from './errorHandler'; import bodyParser from 'body-parser'; import routes from '../../libs/api-v3/setupRoutes'; import notFoundHandler from './notFound'; +import nconf from 'nconf'; +import morgan from 'morgan'; + +const IS_PROD = nconf.get('IS_PROD'); +const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); export default function attachMiddlewares (app) { - // Parse query parameters and json bodies + if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev')); + // TODO handle errors app.use(bodyParser.urlencoded({ extended: true, // Uses 'qs' library as old connect middleware @@ -15,7 +21,7 @@ export default function attachMiddlewares (app) { app.use(bodyParser.json()); app.use(analytics); - app.use(routes); + app.use('/api/v3', routes); app.use(notFoundHandler); // Error handler middleware, define as the last one diff --git a/website/src/server.js b/website/src/server.js index 21744c0813..aa0013d89e 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -89,7 +89,7 @@ app.use(domainMiddleware(server, mongoose)); // Matches all request except the ones going to /api/v3/** app.all(/^(?!\/api\/v3).+/i, oldApp); // Matches all requests going to /api/v3 -app.all('/api/v3', newApp); +app.all('/api/*', newApp); // Mount middlewares for the new app attachMiddlewares(newApp); From 3b633a87b94904cfbe196089fb2ced76f9a8a94f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 Nov 2015 12:44:52 +0100 Subject: [PATCH 134/976] try fixing the notFound test --- test/api/v3/unit/middlewares/notFound.test.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/api/v3/unit/middlewares/notFound.test.js b/test/api/v3/unit/middlewares/notFound.test.js index f2f6082acf..44ca295e95 100644 --- a/test/api/v3/unit/middlewares/notFound.test.js +++ b/test/api/v3/unit/middlewares/notFound.test.js @@ -4,10 +4,11 @@ describe('notFound Middleware', () => { it('returns a 404 error when the resource is not found', () => { let request = requester().get('/api/v3/dummy-url'); - return expect(request) - .to.eventually.be.rejected.and.eql({ - error: "NotFound", - message: "Not found.", - }); - }); + return request.then((errBody) => { + expect(errBody.error).to.equal('NotFound'); + expect(errBody.message).to.equal('Not found.'); + }).to.eventually.be.rejected.and.eql({ + code: 404, + }); + }); }); From d5d13477d725e79b5584cbee08b2c8293a936e98 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 Nov 2015 18:02:35 +0100 Subject: [PATCH 135/976] wip user controllers: local login method --- common/dist/sprites/habitrpg-shared.css | 2 +- common/locales/en/api-v3.json | 8 +++ website/src/controllers/api-v3/example.js | 34 --------- website/src/controllers/api-v3/user.js | 84 +++++++++++++++++++++++ website/src/middlewares/api-v3/auth.js | 29 +++----- website/src/middlewares/api-v3/index.js | 3 +- 6 files changed, 103 insertions(+), 57 deletions(-) create mode 100644 common/locales/en/api-v3.json delete mode 100644 website/src/controllers/api-v3/example.js create mode 100644 website/src/controllers/api-v3/user.js diff --git a/common/dist/sprites/habitrpg-shared.css b/common/dist/sprites/habitrpg-shared.css index c204501040..038ffda277 100644 --- a/common/dist/sprites/habitrpg-shared.css +++ b/common/dist/sprites/habitrpg-shared.css @@ -1 +1 @@ -.2014_Fall_HealerPROMO2{background-image:url(spritesmith-largeSprites-0.png);background-position:-637px -955px;width:90px;height:90px}.2014_Fall_Mage_PROMO9{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -347px;width:120px;height:90px}.2014_Fall_RoguePROMO3{background-image:url(spritesmith-largeSprites-0.png);background-position:-573px -347px;width:105px;height:90px}.2014_Fall_Warrior_PROMO{background-image:url(spritesmith-largeSprites-0.png);background-position:-590px -461px;width:90px;height:90px}.promo_backtoschool{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -342px;width:150px;height:150px}.promo_burnout{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -220px;width:219px;height:240px}.promo_classes_fall_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-326px -760px;width:321px;height:100px}.promo_classes_fall_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -660px;width:377px;height:99px}.promo_dilatoryDistress{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -955px;width:90px;height:90px}.promo_enchanted_armoire{background-image:url(spritesmith-largeSprites-0.png);background-position:-378px -660px;width:374px;height:76px}.promo_enchanted_armoire_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -641px;width:217px;height:90px}.promo_enchanted_armoire_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -342px;width:180px;height:90px}.promo_enchanted_armoire_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-273px -955px;width:90px;height:90px}.promo_enchanted_armoire_201511{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -493px;width:122px;height:90px}.promo_habitica{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -166px;width:175px;height:175px}.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-1127px -194px;width:100px;height:137px}.customize-option.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-1152px -209px;width:60px;height:60px}.promo_item_notif{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -91px;width:249px;height:102px}.promo_mystery_201405{background-image:url(spritesmith-largeSprites-0.png);background-position:-546px -955px;width:90px;height:90px}.promo_mystery_201406{background-image:url(spritesmith-largeSprites-0.png);background-position:-311px -220px;width:90px;height:96px}.promo_mystery_201407{background-image:url(spritesmith-largeSprites-0.png);background-position:-876px -433px;width:42px;height:62px}.promo_mystery_201408{background-image:url(spritesmith-largeSprites-0.png);background-position:-1188px -641px;width:60px;height:71px}.promo_mystery_201409{background-image:url(spritesmith-largeSprites-0.png);background-position:-182px -955px;width:90px;height:90px}.promo_mystery_201410{background-image:url(spritesmith-largeSprites-0.png);background-position:-1169px -823px;width:72px;height:63px}.promo_mystery_201411{background-image:url(spritesmith-largeSprites-0.png);background-position:-364px -955px;width:90px;height:90px}.promo_mystery_201412{background-image:url(spritesmith-largeSprites-0.png);background-position:-904px -342px;width:42px;height:66px}.promo_mystery_201501{background-image:url(spritesmith-largeSprites-0.png);background-position:-899px -166px;width:48px;height:63px}.promo_mystery_201502{background-image:url(spritesmith-largeSprites-0.png);background-position:-499px -461px;width:90px;height:90px}.promo_mystery_201503{background-image:url(spritesmith-largeSprites-0.png);background-position:-728px -955px;width:90px;height:90px}.promo_mystery_201504{background-image:url(spritesmith-largeSprites-0.png);background-position:-1188px -732px;width:60px;height:69px}.promo_mystery_201505{background-image:url(spritesmith-largeSprites-0.png);background-position:-91px -955px;width:90px;height:90px}.promo_mystery_201506{background-image:url(spritesmith-largeSprites-0.png);background-position:-899px -230px;width:42px;height:69px}.promo_mystery_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-220px -220px;width:90px;height:105px}.promo_mystery_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-314px -326px;width:93px;height:90px}.promo_mystery_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-455px -955px;width:90px;height:90px}.promo_mystery_201510{background-image:url(spritesmith-largeSprites-0.png);background-position:-220px -326px;width:93px;height:90px}.promo_mystery_3014{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -732px;width:217px;height:90px}.promo_orca{background-image:url(spritesmith-largeSprites-0.png);background-position:-1121px -342px;width:105px;height:105px}.promo_partyhats{background-image:url(spritesmith-largeSprites-0.png);background-position:-1111px -584px;width:115px;height:47px}.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-331px -871px;width:330px;height:83px}.customize-option.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-356px -886px;width:60px;height:60px}.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -493px;width:140px;height:147px}.customize-option.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-995px -508px;width:60px;height:60px}.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -871px;width:330px;height:83px}.customize-option.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -886px;width:60px;height:60px}.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -823px;width:198px;height:91px}.customize-option.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-995px -838px;width:60px;height:60px}.promo_springclasses2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px 0;width:288px;height:90px}.promo_springclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-430px -557px;width:288px;height:90px}.promo_summer_classes_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -557px;width:429px;height:102px}.promo_summer_classes_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-648px -760px;width:300px;height:88px}.promo_updos{background-image:url(spritesmith-largeSprites-0.png);background-position:-970px -194px;width:156px;height:147px}.promo_veteran_pets{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -509px;width:146px;height:75px}.promo_winterclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -760px;width:325px;height:110px}.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px -433px;width:152px;height:75px}.customize-option.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-748px -448px;width:60px;height:60px}.avatar_variety{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -461px;width:498px;height:95px}.party_preview{background-image:url(spritesmith-largeSprites-0.png);background-position:0 0;width:451px;height:219px}.welcome_basic_avatars{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -181px;width:246px;height:165px}.welcome_promo_party{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px 0;width:270px;height:180px}.welcome_sample_tasks{background-image:url(spritesmith-largeSprites-0.png);background-position:-723px 0;width:246px;height:165px}.achievement-alien{background-image:url(spritesmith-main-0.png);background-position:-1678px -1451px;width:24px;height:26px}.achievement-alien2x{background-image:url(spritesmith-main-0.png);background-position:-895px -979px;width:48px;height:52px}.achievement-alpha{background-image:url(spritesmith-main-0.png);background-position:-1678px -1424px;width:24px;height:26px}.achievement-armor{background-image:url(spritesmith-main-0.png);background-position:-1678px -1397px;width:24px;height:26px}.achievement-armor2x{background-image:url(spritesmith-main-0.png);background-position:-944px -979px;width:48px;height:52px}.achievement-boot{background-image:url(spritesmith-main-0.png);background-position:-1678px -1343px;width:24px;height:26px}.achievement-boot2x{background-image:url(spritesmith-main-0.png);background-position:-1042px -979px;width:48px;height:52px}.achievement-bow{background-image:url(spritesmith-main-0.png);background-position:-1678px -1289px;width:24px;height:26px}.achievement-bow2x{background-image:url(spritesmith-main-0.png);background-position:-504px -1582px;width:48px;height:52px}.achievement-burnout{background-image:url(spritesmith-main-0.png);background-position:-1678px -1235px;width:24px;height:26px}.achievement-burnout2x{background-image:url(spritesmith-main-0.png);background-position:-602px -1582px;width:48px;height:52px}.achievement-cactus{background-image:url(spritesmith-main-0.png);background-position:-1678px -1181px;width:24px;height:26px}.achievement-cactus2x{background-image:url(spritesmith-main-0.png);background-position:-700px -1582px;width:48px;height:52px}.achievement-cake{background-image:url(spritesmith-main-0.png);background-position:-1678px -1127px;width:24px;height:26px}.achievement-cake2x{background-image:url(spritesmith-main-0.png);background-position:-798px -1582px;width:48px;height:52px}.achievement-cave{background-image:url(spritesmith-main-0.png);background-position:-1678px -1073px;width:24px;height:26px}.achievement-cave2x{background-image:url(spritesmith-main-0.png);background-position:-896px -1582px;width:48px;height:52px}.achievement-coffin{background-image:url(spritesmith-main-0.png);background-position:-1678px -1019px;width:24px;height:26px}.achievement-comment{background-image:url(spritesmith-main-0.png);background-position:-1678px -992px;width:24px;height:26px}.achievement-comment2x{background-image:url(spritesmith-main-0.png);background-position:-994px -1582px;width:48px;height:52px}.achievement-costumeContest{background-image:url(spritesmith-main-0.png);background-position:-1678px -938px;width:24px;height:26px}.achievement-costumeContest2x{background-image:url(spritesmith-main-0.png);background-position:-1092px -1582px;width:48px;height:52px}.achievement-dilatory{background-image:url(spritesmith-main-0.png);background-position:-1678px -884px;width:24px;height:26px}.achievement-firefox{background-image:url(spritesmith-main-0.png);background-position:-1678px -857px;width:24px;height:26px}.achievement-greeting{background-image:url(spritesmith-main-0.png);background-position:-1678px -830px;width:24px;height:26px}.achievement-greeting2x{background-image:url(spritesmith-main-0.png);background-position:-1190px -1582px;width:48px;height:52px}.achievement-habitBirthday{background-image:url(spritesmith-main-0.png);background-position:-1678px -776px;width:24px;height:26px}.achievement-habitBirthday2x{background-image:url(spritesmith-main-0.png);background-position:-1288px -1582px;width:48px;height:52px}.achievement-habiticaDay{background-image:url(spritesmith-main-0.png);background-position:-1678px -722px;width:24px;height:26px}.achievement-habiticaDay2x{background-image:url(spritesmith-main-0.png);background-position:-1386px -1582px;width:48px;height:52px}.achievement-heart{background-image:url(spritesmith-main-0.png);background-position:-1678px -668px;width:24px;height:26px}.achievement-heart2x{background-image:url(spritesmith-main-0.png);background-position:-1435px -1582px;width:48px;height:52px}.achievement-karaoke-2x{background-image:url(spritesmith-main-0.png);background-position:-1533px -1582px;width:48px;height:52px}.achievement-karaoke{background-image:url(spritesmith-main-0.png);background-position:-1678px -587px;width:24px;height:26px}.achievement-ninja{background-image:url(spritesmith-main-0.png);background-position:-1678px -560px;width:24px;height:26px}.achievement-ninja2x{background-image:url(spritesmith-main-0.png);background-position:-1678px 0;width:48px;height:52px}.achievement-nye{background-image:url(spritesmith-main-0.png);background-position:-1678px -506px;width:24px;height:26px}.achievement-nye2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -106px;width:48px;height:52px}.achievement-perfect{background-image:url(spritesmith-main-0.png);background-position:-1678px -452px;width:24px;height:26px}.achievement-perfect2x{background-image:url(spritesmith-main-0.png);background-position:-846px -979px;width:48px;height:52px}.achievement-rat{background-image:url(spritesmith-main-0.png);background-position:-1678px -911px;width:24px;height:26px}.achievement-rat2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -318px;width:48px;height:52px}.achievement-seafoam{background-image:url(spritesmith-main-0.png);background-position:-1678px -398px;width:24px;height:26px}.achievement-seafoam2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -265px;width:48px;height:52px}.achievement-shield{background-image:url(spritesmith-main-0.png);background-position:-1678px -425px;width:24px;height:26px}.achievement-shield2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -159px;width:48px;height:52px}.achievement-shinySeed{background-image:url(spritesmith-main-0.png);background-position:-1678px -479px;width:24px;height:26px}.achievement-shinySeed2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -53px;width:48px;height:52px}.achievement-snowball{background-image:url(spritesmith-main-0.png);background-position:-1678px -533px;width:24px;height:26px}.achievement-snowball2x{background-image:url(spritesmith-main-0.png);background-position:-1582px -1582px;width:48px;height:52px}.achievement-spookDust{background-image:url(spritesmith-main-0.png);background-position:-1678px -614px;width:24px;height:26px}.achievement-spookDust2x{background-image:url(spritesmith-main-0.png);background-position:-1484px -1582px;width:48px;height:52px}.achievement-stoikalm{background-image:url(spritesmith-main-0.png);background-position:-1678px -641px;width:24px;height:26px}.achievement-sun{background-image:url(spritesmith-main-0.png);background-position:-1678px -695px;width:24px;height:26px}.achievement-sun2x{background-image:url(spritesmith-main-0.png);background-position:-1337px -1582px;width:48px;height:52px}.achievement-sword{background-image:url(spritesmith-main-0.png);background-position:-1678px -749px;width:24px;height:26px}.achievement-sword2x{background-image:url(spritesmith-main-0.png);background-position:-1239px -1582px;width:48px;height:52px}.achievement-thankyou{background-image:url(spritesmith-main-0.png);background-position:-1678px -803px;width:24px;height:26px}.achievement-thankyou2x{background-image:url(spritesmith-main-0.png);background-position:-1141px -1582px;width:48px;height:52px}.achievement-thermometer{background-image:url(spritesmith-main-0.png);background-position:-1678px -371px;width:24px;height:26px}.achievement-thermometer2x{background-image:url(spritesmith-main-0.png);background-position:-1043px -1582px;width:48px;height:52px}.achievement-tree{background-image:url(spritesmith-main-0.png);background-position:-1678px -965px;width:24px;height:26px}.achievement-tree2x{background-image:url(spritesmith-main-0.png);background-position:-945px -1582px;width:48px;height:52px}.achievement-triadbingo{background-image:url(spritesmith-main-0.png);background-position:-1678px -1046px;width:24px;height:26px}.achievement-triadbingo2x{background-image:url(spritesmith-main-0.png);background-position:-847px -1582px;width:48px;height:52px}.achievement-ultimate-healer{background-image:url(spritesmith-main-0.png);background-position:-1678px -1100px;width:24px;height:26px}.achievement-ultimate-healer2x{background-image:url(spritesmith-main-0.png);background-position:-749px -1582px;width:48px;height:52px}.achievement-ultimate-mage{background-image:url(spritesmith-main-0.png);background-position:-1678px -1154px;width:24px;height:26px}.achievement-ultimate-mage2x{background-image:url(spritesmith-main-0.png);background-position:-651px -1582px;width:48px;height:52px}.achievement-ultimate-rogue{background-image:url(spritesmith-main-0.png);background-position:-1678px -1208px;width:24px;height:26px}.achievement-ultimate-rogue2x{background-image:url(spritesmith-main-0.png);background-position:-553px -1582px;width:48px;height:52px}.achievement-ultimate-warrior{background-image:url(spritesmith-main-0.png);background-position:-1678px -1262px;width:24px;height:26px}.achievement-ultimate-warrior2x{background-image:url(spritesmith-main-0.png);background-position:-455px -1582px;width:48px;height:52px}.achievement-valentine{background-image:url(spritesmith-main-0.png);background-position:-1678px -1316px;width:24px;height:26px}.achievement-valentine2x{background-image:url(spritesmith-main-0.png);background-position:-993px -979px;width:48px;height:52px}.achievement-wolf{background-image:url(spritesmith-main-0.png);background-position:-1678px -1370px;width:24px;height:26px}.achievement-wolf2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -212px;width:48px;height:52px}.background_autumn_forest{background-image:url(spritesmith-main-0.png);background-position:-423px -592px;width:140px;height:147px}.background_beach{background-image:url(spritesmith-main-0.png);background-position:-426px 0;width:141px;height:147px}.background_blacksmithy{background-image:url(spritesmith-main-0.png);background-position:-709px -148px;width:140px;height:147px}.background_cherry_trees{background-image:url(spritesmith-main-0.png);background-position:-709px -296px;width:140px;height:147px}.background_clouds{background-image:url(spritesmith-main-0.png);background-position:0 -592px;width:140px;height:147px}.background_coral_reef{background-image:url(spritesmith-main-0.png);background-position:-850px -148px;width:140px;height:147px}.background_crystal_cave{background-image:url(spritesmith-main-0.png);background-position:-850px -592px;width:140px;height:147px}.background_dilatory_ruins{background-image:url(spritesmith-main-0.png);background-position:0 -740px;width:140px;height:147px}.background_distant_castle{background-image:url(spritesmith-main-0.png);background-position:-423px -888px;width:140px;height:147px}.background_drifting_raft{background-image:url(spritesmith-main-0.png);background-position:-564px -888px;width:140px;height:147px}.background_dusty_canyons{background-image:url(spritesmith-main-0.png);background-position:-705px -888px;width:140px;height:147px}.background_fairy_ring{background-image:url(spritesmith-main-0.png);background-position:-568px 0;width:140px;height:147px}.background_floating_islands{background-image:url(spritesmith-main-0.png);background-position:-568px -148px;width:140px;height:147px}.background_floral_meadow{background-image:url(spritesmith-main-0.png);background-position:-568px -296px;width:140px;height:147px}.background_forest{background-image:url(spritesmith-main-0.png);background-position:0 -444px;width:140px;height:147px}.background_frigid_peak{background-image:url(spritesmith-main-0.png);background-position:-141px -444px;width:140px;height:147px}.background_giant_wave{background-image:url(spritesmith-main-0.png);background-position:-284px 0;width:141px;height:147px}.background_graveyard{background-image:url(spritesmith-main-0.png);background-position:-423px -444px;width:140px;height:147px}.background_gumdrop_land{background-image:url(spritesmith-main-0.png);background-position:-564px -444px;width:140px;height:147px}.background_harvest_feast{background-image:url(spritesmith-main-0.png);background-position:-709px 0;width:140px;height:147px}.background_harvest_fields{background-image:url(spritesmith-main-0.png);background-position:0 -148px;width:141px;height:147px}.background_harvest_moon{background-image:url(spritesmith-main-0.png);background-position:-142px -148px;width:141px;height:147px}.background_haunted_house{background-image:url(spritesmith-main-0.png);background-position:-709px -444px;width:140px;height:147px}.background_ice_cave{background-image:url(spritesmith-main-0.png);background-position:-284px -148px;width:141px;height:147px}.background_iceberg{background-image:url(spritesmith-main-0.png);background-position:-141px -592px;width:140px;height:147px}.background_island_waterfalls{background-image:url(spritesmith-main-0.png);background-position:-282px -592px;width:140px;height:147px}.background_marble_temple{background-image:url(spritesmith-main-0.png);background-position:-142px 0;width:141px;height:147px}.background_market{background-image:url(spritesmith-main-0.png);background-position:-564px -592px;width:140px;height:147px}.background_mountain_lake{background-image:url(spritesmith-main-0.png);background-position:-705px -592px;width:140px;height:147px}.background_night_dunes{background-image:url(spritesmith-main-0.png);background-position:-850px 0;width:140px;height:147px}.background_open_waters{background-image:url(spritesmith-main-0.png);background-position:0 0;width:141px;height:147px}.background_pagodas{background-image:url(spritesmith-main-0.png);background-position:-850px -296px;width:140px;height:147px}.background_pumpkin_patch{background-image:url(spritesmith-main-0.png);background-position:-850px -444px;width:140px;height:147px}.background_pyramids{background-image:url(spritesmith-main-0.png);background-position:-426px -148px;width:141px;height:147px}.background_rolling_hills{background-image:url(spritesmith-main-0.png);background-position:0 -296px;width:141px;height:147px}.background_seafarer_ship{background-image:url(spritesmith-main-0.png);background-position:-141px -740px;width:140px;height:147px}.background_shimmery_bubbles{background-image:url(spritesmith-main-0.png);background-position:-282px -740px;width:140px;height:147px}.background_slimy_swamp{background-image:url(spritesmith-main-0.png);background-position:-423px -740px;width:140px;height:147px}.background_snowy_pines{background-image:url(spritesmith-main-0.png);background-position:-564px -740px;width:140px;height:147px}.background_south_pole{background-image:url(spritesmith-main-0.png);background-position:-705px -740px;width:140px;height:147px}.background_spring_rain{background-image:url(spritesmith-main-0.png);background-position:-846px -740px;width:140px;height:147px}.background_stable{background-image:url(spritesmith-main-0.png);background-position:-991px 0;width:140px;height:147px}.background_stained_glass{background-image:url(spritesmith-main-0.png);background-position:-991px -148px;width:140px;height:147px}.background_starry_skies{background-image:url(spritesmith-main-0.png);background-position:-991px -296px;width:140px;height:147px}.background_sunken_ship{background-image:url(spritesmith-main-0.png);background-position:-991px -444px;width:140px;height:147px}.background_sunset_meadow{background-image:url(spritesmith-main-0.png);background-position:-991px -592px;width:140px;height:147px}.background_sunset_oasis{background-image:url(spritesmith-main-0.png);background-position:-991px -740px;width:140px;height:147px}.background_sunset_savannah{background-image:url(spritesmith-main-0.png);background-position:0 -888px;width:140px;height:147px}.background_swarming_darkness{background-image:url(spritesmith-main-0.png);background-position:-141px -888px;width:140px;height:147px}.background_tavern{background-image:url(spritesmith-main-0.png);background-position:-282px -888px;width:140px;height:147px}.background_thunderstorm{background-image:url(spritesmith-main-0.png);background-position:-142px -296px;width:141px;height:147px}.background_twinkly_lights{background-image:url(spritesmith-main-0.png);background-position:-284px -296px;width:141px;height:147px}.background_twinkly_party_lights{background-image:url(spritesmith-main-0.png);background-position:-426px -296px;width:141px;height:147px}.background_volcano{background-image:url(spritesmith-main-0.png);background-position:-282px -444px;width:140px;height:147px}.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -910px;width:90px;height:90px}.customize-option.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -925px;width:60px;height:60px}.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -1016px;width:60px;height:60px}.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -1107px;width:60px;height:60px}.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -1183px;width:90px;height:90px}.customize-option.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -1198px;width:60px;height:60px}.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:0 -1309px;width:90px;height:90px}.customize-option.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-25px -1324px;width:60px;height:60px}.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1324px;width:60px;height:60px}.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-182px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-207px -1324px;width:60px;height:60px}.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-273px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-298px -1324px;width:60px;height:60px}.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-364px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-389px -1324px;width:60px;height:60px}.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-455px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-480px -1324px;width:60px;height:60px}.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-546px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-571px -1324px;width:60px;height:60px}.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-637px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-662px -1324px;width:60px;height:60px}.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-728px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-753px -1324px;width:60px;height:60px}.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1324px;width:60px;height:60px}.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1324px;width:60px;height:60px}.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1324px;width:60px;height:60px}.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1092px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1117px -1324px;width:60px;height:60px}.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1183px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1208px -1324px;width:60px;height:60px}.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1324px;width:60px;height:60px}.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1405px 0;width:90px;height:90px}.customize-option.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1430px -15px;width:60px;height:60px}.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1405px -91px;width:90px;height:90px}.customize-option.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1430px -106px;width:60px;height:60px}.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1405px -182px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1430px -197px;width:60px;height:60px}.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1405px -273px;width:90px;height:90px}.customize-option.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1430px -288px;width:60px;height:60px}.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1405px -364px;width:90px;height:90px}.customize-option.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1430px -379px;width:60px;height:60px}.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -455px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -470px;width:60px;height:60px}.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-846px -888px;width:90px;height:90px}.customize-option.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-871px -903px;width:60px;height:60px}.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1405px -637px;width:90px;height:90px}.customize-option.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1430px -652px;width:60px;height:60px}.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1405px -728px;width:90px;height:90px}.customize-option.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1430px -743px;width:60px;height:60px}.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1405px -819px;width:90px;height:90px}.customize-option.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1430px -834px;width:60px;height:60px}.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1405px -910px;width:90px;height:90px}.customize-option.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1430px -925px;width:60px;height:60px}.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1405px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1430px -1016px;width:60px;height:60px}.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -1107px;width:60px;height:60px}.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1405px -1183px;width:90px;height:90px}.customize-option.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1430px -1198px;width:60px;height:60px}.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1405px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1430px -1289px;width:60px;height:60px}.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:0 -1400px;width:90px;height:90px}.customize-option.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-25px -1415px;width:60px;height:60px}.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-91px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-116px -1415px;width:60px;height:60px}.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-182px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-207px -1415px;width:60px;height:60px}.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-273px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-298px -1415px;width:60px;height:60px}.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-364px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-389px -1415px;width:60px;height:60px}.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-455px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-480px -1415px;width:60px;height:60px}.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-546px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-571px -1415px;width:60px;height:60px}.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-637px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-662px -1415px;width:60px;height:60px}.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-728px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-753px -1415px;width:60px;height:60px}.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-819px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-844px -1415px;width:60px;height:60px}.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-910px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-935px -1415px;width:60px;height:60px}.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-1001px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-1026px -1415px;width:60px;height:60px}.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-1092px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-1117px -1415px;width:60px;height:60px}.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1183px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1208px -1415px;width:60px;height:60px}.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1274px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1299px -1415px;width:60px;height:60px}.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1365px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1390px -1415px;width:60px;height:60px}.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1496px 0;width:90px;height:90px}.customize-option.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1521px -15px;width:60px;height:60px}.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1496px -91px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1521px -106px;width:60px;height:60px}.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1496px -182px;width:90px;height:90px}.customize-option.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1521px -197px;width:60px;height:60px}.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1496px -273px;width:90px;height:90px}.customize-option.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1521px -288px;width:60px;height:60px}.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1496px -364px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1521px -379px;width:60px;height:60px}.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1496px -455px;width:90px;height:90px}.customize-option.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1521px -470px;width:60px;height:60px}.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1496px -546px;width:90px;height:90px}.customize-option.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1521px -561px;width:60px;height:60px}.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -637px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -652px;width:60px;height:60px}.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1496px -728px;width:90px;height:90px}.customize-option.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1521px -743px;width:60px;height:60px}.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1496px -819px;width:90px;height:90px}.customize-option.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1521px -834px;width:60px;height:60px}.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1496px -910px;width:90px;height:90px}.customize-option.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1521px -925px;width:60px;height:60px}.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1496px -1001px;width:90px;height:90px}.customize-option.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1521px -1016px;width:60px;height:60px}.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1496px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1521px -1107px;width:60px;height:60px}.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1496px -1183px;width:90px;height:90px}.customize-option.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1521px -1198px;width:60px;height:60px}.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -1289px;width:60px;height:60px}.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1496px -1365px;width:90px;height:90px}.customize-option.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1521px -1380px;width:60px;height:60px}.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:0 -1491px;width:90px;height:90px}.customize-option.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-25px -1506px;width:60px;height:60px}.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-91px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-116px -1506px;width:60px;height:60px}.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-182px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-207px -1506px;width:60px;height:60px}.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1506px;width:60px;height:60px}.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-364px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-389px -1506px;width:60px;height:60px}.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-455px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-480px -1506px;width:60px;height:60px}.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-546px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-571px -1506px;width:60px;height:60px}.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-637px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-662px -1506px;width:60px;height:60px}.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-728px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-753px -1506px;width:60px;height:60px}.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-819px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-844px -1506px;width:60px;height:60px}.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-910px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-935px -1506px;width:60px;height:60px}.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-1001px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-1026px -1506px;width:60px;height:60px}.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-1092px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-1117px -1506px;width:60px;height:60px}.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-1183px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-1208px -1506px;width:60px;height:60px}.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1506px;width:60px;height:60px}.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1365px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1390px -1506px;width:60px;height:60px}.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1456px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1481px -1506px;width:60px;height:60px}.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1587px 0;width:90px;height:90px}.customize-option.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1612px -15px;width:60px;height:60px}.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1587px -91px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1612px -106px;width:60px;height:60px}.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1587px -182px;width:90px;height:90px}.customize-option.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1612px -197px;width:60px;height:60px}.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1587px -273px;width:90px;height:90px}.customize-option.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1612px -288px;width:60px;height:60px}.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1587px -364px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1612px -379px;width:60px;height:60px}.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1587px -455px;width:90px;height:90px}.customize-option.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1612px -470px;width:60px;height:60px}.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1587px -546px;width:90px;height:90px}.customize-option.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1612px -561px;width:60px;height:60px}.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -637px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -652px;width:60px;height:60px}.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1587px -728px;width:90px;height:90px}.customize-option.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1612px -743px;width:60px;height:60px}.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1587px -819px;width:90px;height:90px}.customize-option.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1612px -834px;width:60px;height:60px}.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1587px -910px;width:90px;height:90px}.customize-option.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1612px -925px;width:60px;height:60px}.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1587px -1001px;width:90px;height:90px}.customize-option.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1612px -1016px;width:60px;height:60px}.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1587px -1092px;width:90px;height:90px}.customize-option.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1612px -1107px;width:60px;height:60px}.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1587px -1183px;width:90px;height:90px}.customize-option.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1612px -1198px;width:60px;height:60px}.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -1289px;width:60px;height:60px}.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1587px -1365px;width:90px;height:90px}.customize-option.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1612px -1380px;width:60px;height:60px}.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1587px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1612px -1471px;width:60px;height:60px}.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:0 -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-25px -1597px;width:60px;height:60px}.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-91px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-116px -1597px;width:60px;height:60px}.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-182px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-207px -1597px;width:60px;height:60px}.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-273px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-298px -1597px;width:60px;height:60px}.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-364px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-389px -1597px;width:60px;height:60px}.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1405px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1430px -561px;width:60px;height:60px}.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1132px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1157px -652px;width:60px;height:60px}.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1132px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1157px -561px;width:60px;height:60px}.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1132px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1157px -470px;width:60px;height:60px}.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1132px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1157px -379px;width:60px;height:60px}.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-1132px -273px;width:90px;height:90px}.customize-option.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-1157px -288px;width:60px;height:60px}.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1132px -182px;width:90px;height:90px}.customize-option.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1157px -197px;width:60px;height:60px}.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1132px -91px;width:90px;height:90px}.customize-option.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1157px -106px;width:60px;height:60px}.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1132px 0;width:90px;height:90px}.customize-option.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1157px -15px;width:60px;height:60px}.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1051px;width:60px;height:60px}.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-910px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-935px -1051px;width:60px;height:60px}.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-819px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-844px -1051px;width:60px;height:60px}.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-728px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-753px -1051px;width:60px;height:60px}.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-637px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-662px -1051px;width:60px;height:60px}.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-546px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-571px -1051px;width:60px;height:60px}.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-455px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-480px -1051px;width:60px;height:60px}.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-364px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-389px -1051px;width:60px;height:60px}.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-273px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-298px -1051px;width:60px;height:60px}.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-182px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-207px -1051px;width:60px;height:60px}.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-91px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-116px -1051px;width:60px;height:60px}.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:0 -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-25px -1051px;width:60px;height:60px}.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1028px -888px;width:90px;height:90px}.customize-option.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1053px -903px;width:60px;height:60px}.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-937px -888px;width:90px;height:90px}.customize-option.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-962px -903px;width:60px;height:60px}.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1314px -819px;width:90px;height:90px}.customize-option.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1339px -834px;width:60px;height:60px}.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1314px -728px;width:90px;height:90px}.customize-option.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1339px -743px;width:60px;height:60px}.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1314px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1339px -652px;width:60px;height:60px}.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1314px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1339px -561px;width:60px;height:60px}.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -470px;width:60px;height:60px}.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -379px;width:60px;height:60px}.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -288px;width:60px;height:60px}.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -197px;width:60px;height:60px}.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1314px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1339px -106px;width:60px;height:60px}.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1314px 0;width:90px;height:90px}.customize-option.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1339px -15px;width:60px;height:60px}.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1183px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1208px -1233px;width:60px;height:60px}.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1092px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1117px -1233px;width:60px;height:60px}.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1001px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1026px -1233px;width:60px;height:60px}.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-910px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-935px -1233px;width:60px;height:60px}.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-819px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-844px -1233px;width:60px;height:60px}.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-728px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-753px -1233px;width:60px;height:60px}.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-637px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-662px -1233px;width:60px;height:60px}.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-546px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-571px -1233px;width:60px;height:60px}.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-455px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-480px -1233px;width:60px;height:60px}.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-364px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-389px -1233px;width:60px;height:60px}.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-273px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-298px -1233px;width:60px;height:60px}.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-182px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-207px -1233px;width:60px;height:60px}.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-91px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-116px -1233px;width:60px;height:60px}.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:0 -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-25px -1233px;width:60px;height:60px}.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1223px -1092px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1248px -1107px;width:60px;height:60px}.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1223px -1001px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1248px -1016px;width:60px;height:60px}.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1223px -910px;width:90px;height:90px}.customize-option.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1248px -925px;width:60px;height:60px}.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1223px -819px;width:90px;height:90px}.customize-option.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1248px -834px;width:60px;height:60px}.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1223px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1248px -743px;width:60px;height:60px}.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1223px -637px;width:90px;height:90px}.customize-option.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1248px -652px;width:60px;height:60px}.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1223px -546px;width:90px;height:90px}.customize-option.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1248px -561px;width:60px;height:60px}.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1223px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1248px -470px;width:60px;height:60px}.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1223px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1248px -379px;width:60px;height:60px}.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1223px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1248px -288px;width:60px;height:60px}.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1223px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1248px -197px;width:60px;height:60px}.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1223px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1248px -106px;width:60px;height:60px}.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1223px 0;width:90px;height:90px}.customize-option.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1248px -15px;width:60px;height:60px}.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1092px -1127px;width:90px;height:90px}.customize-option.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1117px -1142px;width:60px;height:60px}.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1001px -1127px;width:90px;height:90px}.customize-option.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1026px -1142px;width:60px;height:60px}.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-910px -1127px;width:90px;height:90px}.customize-option.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-935px -1142px;width:60px;height:60px}.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-819px -1127px;width:90px;height:90px}.customize-option.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-844px -1142px;width:60px;height:60px}.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-728px -1127px;width:90px;height:90px}.customize-option.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-753px -1142px;width:60px;height:60px}.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-637px -1127px;width:90px;height:90px}.customize-option.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-662px -1142px;width:60px;height:60px}.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-546px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-571px -1142px;width:60px;height:60px}.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-455px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-480px -1142px;width:60px;height:60px}.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-364px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-389px -1142px;width:60px;height:60px}.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1142px;width:60px;height:60px}.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-182px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-207px -1142px;width:60px;height:60px}.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1142px;width:60px;height:60px}.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:0 -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-25px -1142px;width:60px;height:60px}.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1132px -1001px;width:90px;height:90px}.customize-option.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1157px -1016px;width:60px;height:60px}.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1132px -910px;width:90px;height:90px}.customize-option.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1157px -925px;width:60px;height:60px}.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1132px -819px;width:90px;height:90px}.customize-option.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1157px -834px;width:60px;height:60px}.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1132px -728px;width:90px;height:90px}.customize-option.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1157px -743px;width:60px;height:60px}.hair_bangs_1_green{background-image:url(spritesmith-main-1.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_bangs_1_green{background-image:url(spritesmith-main-1.png);background-position:-116px -15px;width:60px;height:60px}.hair_bangs_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_bangs_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -1107px;width:60px;height:60px}.hair_bangs_1_holly{background-image:url(spritesmith-main-1.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_bangs_1_holly{background-image:url(spritesmith-main-1.png);background-position:-25px -106px;width:60px;height:60px}.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-116px -106px;width:60px;height:60px}.hair_bangs_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_bangs_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-207px -15px;width:60px;height:60px}.hair_bangs_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -106px;width:60px;height:60px}.hair_bangs_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -197px;width:60px;height:60px}.hair_bangs_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -197px;width:60px;height:60px}.hair_bangs_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -197px;width:60px;height:60px}.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -15px;width:60px;height:60px}.hair_bangs_1_porange{background-image:url(spritesmith-main-1.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_porange{background-image:url(spritesmith-main-1.png);background-position:-298px -106px;width:60px;height:60px}.hair_bangs_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-298px -197px;width:60px;height:60px}.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -288px;width:60px;height:60px}.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -288px;width:60px;height:60px}.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -288px;width:60px;height:60px}.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -288px;width:60px;height:60px}.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -15px;width:60px;height:60px}.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-389px -106px;width:60px;height:60px}.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-389px -197px;width:60px;height:60px}.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -288px;width:60px;height:60px}.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-25px -379px;width:60px;height:60px}.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-116px -379px;width:60px;height:60px}.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-207px -379px;width:60px;height:60px}.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-298px -379px;width:60px;height:60px}.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-389px -379px;width:60px;height:60px}.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-480px -15px;width:60px;height:60px}.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-480px -106px;width:60px;height:60px}.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-480px -197px;width:60px;height:60px}.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-480px -288px;width:60px;height:60px}.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-480px -379px;width:60px;height:60px}.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-25px -470px;width:60px;height:60px}.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-116px -470px;width:60px;height:60px}.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-207px -470px;width:60px;height:60px}.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-298px -470px;width:60px;height:60px}.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-389px -470px;width:60px;height:60px}.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-480px -470px;width:60px;height:60px}.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-571px -15px;width:60px;height:60px}.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-571px -106px;width:60px;height:60px}.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-571px -197px;width:60px;height:60px}.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-571px -288px;width:60px;height:60px}.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-571px -379px;width:60px;height:60px}.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-571px -470px;width:60px;height:60px}.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-25px -561px;width:60px;height:60px}.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-116px -561px;width:60px;height:60px}.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -561px;width:60px;height:60px}.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-298px -561px;width:60px;height:60px}.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-389px -561px;width:60px;height:60px}.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-480px -561px;width:60px;height:60px}.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-571px -561px;width:60px;height:60px}.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-662px -15px;width:60px;height:60px}.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-662px -106px;width:60px;height:60px}.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-662px -197px;width:60px;height:60px}.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -288px;width:60px;height:60px}.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-662px -379px;width:60px;height:60px}.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-662px -470px;width:60px;height:60px}.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-662px -561px;width:60px;height:60px}.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-25px -652px;width:60px;height:60px}.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-116px -652px;width:60px;height:60px}.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-207px -652px;width:60px;height:60px}.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-298px -652px;width:60px;height:60px}.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-389px -652px;width:60px;height:60px}.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-480px -652px;width:60px;height:60px}.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-571px -652px;width:60px;height:60px}.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-662px -652px;width:60px;height:60px}.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-753px -15px;width:60px;height:60px}.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-753px -106px;width:60px;height:60px}.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-753px -197px;width:60px;height:60px}.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-753px -288px;width:60px;height:60px}.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-753px -379px;width:60px;height:60px}.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-753px -470px;width:60px;height:60px}.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-753px -561px;width:60px;height:60px}.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-753px -652px;width:60px;height:60px}.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-25px -743px;width:60px;height:60px}.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-116px -743px;width:60px;height:60px}.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-207px -743px;width:60px;height:60px}.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-298px -743px;width:60px;height:60px}.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -743px;width:60px;height:60px}.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -743px;width:60px;height:60px}.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-571px -743px;width:60px;height:60px}.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-662px -743px;width:60px;height:60px}.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -743px;width:60px;height:60px}.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-844px -15px;width:60px;height:60px}.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-844px -106px;width:60px;height:60px}.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-844px -197px;width:60px;height:60px}.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-844px -288px;width:60px;height:60px}.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-844px -379px;width:60px;height:60px}.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -470px;width:60px;height:60px}.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-844px -561px;width:60px;height:60px}.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-844px -652px;width:60px;height:60px}.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-844px -743px;width:60px;height:60px}.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -834px;width:60px;height:60px}.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -834px;width:60px;height:60px}.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -834px;width:60px;height:60px}.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -834px;width:60px;height:60px}.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -834px;width:60px;height:60px}.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-480px -834px;width:60px;height:60px}.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-571px -834px;width:60px;height:60px}.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-662px -834px;width:60px;height:60px}.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-753px -834px;width:60px;height:60px}.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-844px -834px;width:60px;height:60px}.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-935px -15px;width:60px;height:60px}.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-935px -106px;width:60px;height:60px}.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-935px -197px;width:60px;height:60px}.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -288px;width:60px;height:60px}.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-935px -379px;width:60px;height:60px}.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1663px -15px;width:60px;height:60px}.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1663px -106px;width:60px;height:60px}.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1663px -197px;width:60px;height:60px}.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1663px -288px;width:60px;height:60px}.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1663px -379px;width:60px;height:60px}.hair_base_1_purple{background-image:url(spritesmith-main-2.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_base_1_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -15px;width:60px;height:60px}.hair_base_1_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_base_1_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -1107px;width:60px;height:60px}.hair_base_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_base_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-25px -106px;width:60px;height:60px}.hair_base_1_rainbow{background-image:url(spritesmith-main-2.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_base_1_rainbow{background-image:url(spritesmith-main-2.png);background-position:-116px -106px;width:60px;height:60px}.hair_base_1_red{background-image:url(spritesmith-main-2.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_base_1_red{background-image:url(spritesmith-main-2.png);background-position:-207px -15px;width:60px;height:60px}.hair_base_1_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_base_1_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -106px;width:60px;height:60px}.hair_base_1_white{background-image:url(spritesmith-main-2.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_base_1_white{background-image:url(spritesmith-main-2.png);background-position:-25px -197px;width:60px;height:60px}.hair_base_1_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_base_1_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -197px;width:60px;height:60px}.hair_base_1_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_base_1_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -197px;width:60px;height:60px}.hair_base_1_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_base_1_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -15px;width:60px;height:60px}.hair_base_1_zombie{background-image:url(spritesmith-main-2.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_base_1_zombie{background-image:url(spritesmith-main-2.png);background-position:-298px -106px;width:60px;height:60px}.hair_base_2_TRUred{background-image:url(spritesmith-main-2.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_base_2_TRUred{background-image:url(spritesmith-main-2.png);background-position:-298px -197px;width:60px;height:60px}.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -288px;width:60px;height:60px}.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-116px -288px;width:60px;height:60px}.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -288px;width:60px;height:60px}.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -288px;width:60px;height:60px}.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -15px;width:60px;height:60px}.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-389px -106px;width:60px;height:60px}.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-389px -197px;width:60px;height:60px}.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -288px;width:60px;height:60px}.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-25px -379px;width:60px;height:60px}.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-116px -379px;width:60px;height:60px}.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-207px -379px;width:60px;height:60px}.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-298px -379px;width:60px;height:60px}.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-389px -379px;width:60px;height:60px}.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-480px -15px;width:60px;height:60px}.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-480px -106px;width:60px;height:60px}.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-480px -197px;width:60px;height:60px}.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-480px -288px;width:60px;height:60px}.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-480px -379px;width:60px;height:60px}.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-25px -470px;width:60px;height:60px}.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-116px -470px;width:60px;height:60px}.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-207px -470px;width:60px;height:60px}.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-298px -470px;width:60px;height:60px}.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-389px -470px;width:60px;height:60px}.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-480px -470px;width:60px;height:60px}.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-571px -15px;width:60px;height:60px}.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-571px -106px;width:60px;height:60px}.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-571px -197px;width:60px;height:60px}.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -288px;width:60px;height:60px}.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-571px -379px;width:60px;height:60px}.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-571px -470px;width:60px;height:60px}.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:-25px -561px;width:60px;height:60px}.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-116px -561px;width:60px;height:60px}.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -561px;width:60px;height:60px}.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-298px -561px;width:60px;height:60px}.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-389px -561px;width:60px;height:60px}.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-480px -561px;width:60px;height:60px}.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-571px -561px;width:60px;height:60px}.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-662px -15px;width:60px;height:60px}.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-662px -106px;width:60px;height:60px}.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-662px -197px;width:60px;height:60px}.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-662px -288px;width:60px;height:60px}.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-662px -379px;width:60px;height:60px}.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-662px -470px;width:60px;height:60px}.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-662px -561px;width:60px;height:60px}.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-25px -652px;width:60px;height:60px}.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-116px -652px;width:60px;height:60px}.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-207px -652px;width:60px;height:60px}.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-298px -652px;width:60px;height:60px}.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-389px -652px;width:60px;height:60px}.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-480px -652px;width:60px;height:60px}.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-571px -652px;width:60px;height:60px}.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-662px -652px;width:60px;height:60px}.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-753px -15px;width:60px;height:60px}.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-753px -106px;width:60px;height:60px}.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-753px -197px;width:60px;height:60px}.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-753px -288px;width:60px;height:60px}.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-753px -379px;width:60px;height:60px}.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-753px -470px;width:60px;height:60px}.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-753px -561px;width:60px;height:60px}.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-753px -652px;width:60px;height:60px}.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -743px;width:60px;height:60px}.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-116px -743px;width:60px;height:60px}.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-207px -743px;width:60px;height:60px}.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-298px -743px;width:60px;height:60px}.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -743px;width:60px;height:60px}.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -743px;width:60px;height:60px}.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -743px;width:60px;height:60px}.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-662px -743px;width:60px;height:60px}.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -743px;width:60px;height:60px}.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-844px -15px;width:60px;height:60px}.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-844px -106px;width:60px;height:60px}.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-844px -197px;width:60px;height:60px}.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-844px -288px;width:60px;height:60px}.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-844px -379px;width:60px;height:60px}.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -470px;width:60px;height:60px}.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-844px -561px;width:60px;height:60px}.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-844px -652px;width:60px;height:60px}.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-844px -743px;width:60px;height:60px}.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -834px;width:60px;height:60px}.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-116px -834px;width:60px;height:60px}.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -834px;width:60px;height:60px}.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1663px -15px;width:60px;height:60px}.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1663px -106px;width:60px;height:60px}.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1663px -197px;width:60px;height:60px}.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1663px -288px;width:60px;height:60px}.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1663px -379px;width:60px;height:60px}.hair_beard_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-454px -273px;width:90px;height:90px}.customize-option.hair_beard_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-479px -288px;width:60px;height:60px}.hair_beard_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1276px -728px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1301px -743px;width:60px;height:60px}.hair_beard_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1276px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1301px -1107px;width:60px;height:60px}.hair_beard_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-207px -1289px;width:60px;height:60px}.hair_beard_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-298px -1289px;width:60px;height:60px}.hair_beard_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-480px -1289px;width:60px;height:60px}.hair_beard_3_pblue2{background-image:url(spritesmith-main-3.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_pblue2{background-image:url(spritesmith-main-3.png);background-position:-571px -1289px;width:60px;height:60px}.hair_beard_3_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-935px -1289px;width:60px;height:60px}.hair_beard_3_porange2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_porange2{background-image:url(spritesmith-main-3.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_beard_3_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_beard_3_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1367px -91px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1392px -106px;width:60px;height:60px}.hair_beard_3_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1367px -182px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1392px -197px;width:60px;height:60px}.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1367px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1392px -379px;width:60px;height:60px}.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1367px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1392px -470px;width:60px;height:60px}.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -1471px;width:60px;height:60px}.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-116px -1471px;width:60px;height:60px}.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-298px -1471px;width:60px;height:60px}.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-389px -1471px;width:60px;height:60px}.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-753px -1471px;width:60px;height:60px}.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-844px -1471px;width:60px;height:60px}.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -379px;width:60px;height:60px}.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-116px -379px;width:60px;height:60px}.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-207px -379px;width:60px;height:60px}.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-298px -379px;width:60px;height:60px}.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-389px -394px;width:60px;height:60px}.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-480px -394px;width:60px;height:60px}.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:0 -455px;width:90px;height:90px}.customize-option.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-25px -485px;width:60px;height:60px}.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-116px -485px;width:60px;height:60px}.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-207px -485px;width:60px;height:60px}.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-298px -485px;width:60px;height:60px}.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-389px -485px;width:60px;height:60px}.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-480px -485px;width:60px;height:60px}.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-548px 0;width:90px;height:90px}.customize-option.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-573px -30px;width:60px;height:60px}.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-548px -91px;width:90px;height:90px}.customize-option.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-573px -121px;width:60px;height:60px}.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-548px -182px;width:90px;height:90px}.customize-option.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-573px -212px;width:60px;height:60px}.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-548px -273px;width:90px;height:90px}.customize-option.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-573px -303px;width:60px;height:60px}.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-548px -364px;width:90px;height:90px}.customize-option.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-573px -394px;width:60px;height:60px}.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-548px -455px;width:90px;height:90px}.customize-option.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-573px -485px;width:60px;height:60px}.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:0 -546px;width:90px;height:90px}.customize-option.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-25px -576px;width:60px;height:60px}.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-116px -576px;width:60px;height:60px}.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-207px -576px;width:60px;height:60px}.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-298px -576px;width:60px;height:60px}.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-389px -576px;width:60px;height:60px}.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-480px -576px;width:60px;height:60px}.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-571px -576px;width:60px;height:60px}.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-639px 0;width:90px;height:90px}.customize-option.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-664px -30px;width:60px;height:60px}.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-639px -91px;width:90px;height:90px}.customize-option.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-664px -121px;width:60px;height:60px}.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-639px -182px;width:90px;height:90px}.customize-option.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-664px -212px;width:60px;height:60px}.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-639px -273px;width:90px;height:90px}.customize-option.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-664px -303px;width:60px;height:60px}.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-639px -364px;width:90px;height:90px}.customize-option.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-664px -394px;width:60px;height:60px}.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-639px -455px;width:90px;height:90px}.customize-option.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-664px -485px;width:60px;height:60px}.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-639px -546px;width:90px;height:90px}.customize-option.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-664px -576px;width:60px;height:60px}.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:0 -637px;width:90px;height:90px}.customize-option.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-25px -667px;width:60px;height:60px}.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-116px -667px;width:60px;height:60px}.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-207px -667px;width:60px;height:60px}.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-298px -667px;width:60px;height:60px}.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-389px -667px;width:60px;height:60px}.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-480px -667px;width:60px;height:60px}.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-571px -652px;width:60px;height:60px}.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -652px;width:60px;height:60px}.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-730px 0;width:90px;height:90px}.customize-option.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-755px -15px;width:60px;height:60px}.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -91px;width:90px;height:90px}.customize-option.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -106px;width:60px;height:60px}.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-730px -182px;width:90px;height:90px}.customize-option.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-755px -197px;width:60px;height:60px}.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -273px;width:90px;height:90px}.customize-option.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -288px;width:60px;height:60px}.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-730px -364px;width:90px;height:90px}.customize-option.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-755px -379px;width:60px;height:60px}.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -455px;width:90px;height:90px}.customize-option.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -470px;width:60px;height:60px}.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-730px -546px;width:90px;height:90px}.customize-option.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-755px -561px;width:60px;height:60px}.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -637px;width:90px;height:90px}.customize-option.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -652px;width:60px;height:60px}.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:0 -728px;width:90px;height:90px}.customize-option.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:-25px -743px;width:60px;height:60px}.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -743px;width:60px;height:60px}.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-207px -743px;width:60px;height:60px}.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -743px;width:60px;height:60px}.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-389px -743px;width:60px;height:60px}.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -743px;width:60px;height:60px}.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-571px -743px;width:60px;height:60px}.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -743px;width:60px;height:60px}.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-753px -743px;width:60px;height:60px}.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px 0;width:90px;height:90px}.customize-option.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -15px;width:60px;height:60px}.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-821px -91px;width:90px;height:90px}.customize-option.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-846px -106px;width:60px;height:60px}.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -182px;width:90px;height:90px}.customize-option.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -197px;width:60px;height:60px}.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-821px -273px;width:90px;height:90px}.customize-option.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-846px -288px;width:60px;height:60px}.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -364px;width:90px;height:90px}.customize-option.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -379px;width:60px;height:60px}.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-821px -455px;width:90px;height:90px}.customize-option.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-846px -470px;width:60px;height:60px}.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -546px;width:90px;height:90px}.customize-option.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -561px;width:60px;height:60px}.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-821px -637px;width:90px;height:90px}.customize-option.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-846px -652px;width:60px;height:60px}.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -728px;width:90px;height:90px}.customize-option.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -743px;width:60px;height:60px}.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:0 -819px;width:90px;height:90px}.customize-option.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-25px -834px;width:60px;height:60px}.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -834px;width:60px;height:60px}.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-207px -834px;width:60px;height:60px}.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -834px;width:60px;height:60px}.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-389px -834px;width:60px;height:60px}.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -834px;width:60px;height:60px}.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-571px -834px;width:60px;height:60px}.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -834px;width:60px;height:60px}.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-753px -834px;width:60px;height:60px}.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -834px;width:60px;height:60px}.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-912px 0;width:90px;height:90px}.customize-option.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-937px -15px;width:60px;height:60px}.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -91px;width:90px;height:90px}.customize-option.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -106px;width:60px;height:60px}.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-912px -182px;width:90px;height:90px}.customize-option.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-937px -197px;width:60px;height:60px}.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -273px;width:90px;height:90px}.customize-option.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -288px;width:60px;height:60px}.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-912px -364px;width:90px;height:90px}.customize-option.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-937px -379px;width:60px;height:60px}.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -455px;width:90px;height:90px}.customize-option.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -470px;width:60px;height:60px}.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-912px -546px;width:90px;height:90px}.customize-option.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-937px -561px;width:60px;height:60px}.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -637px;width:90px;height:90px}.customize-option.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -652px;width:60px;height:60px}.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-912px -728px;width:90px;height:90px}.customize-option.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-937px -743px;width:60px;height:60px}.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -819px;width:90px;height:90px}.customize-option.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -834px;width:60px;height:60px}.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:0 -910px;width:90px;height:90px}.customize-option.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-25px -925px;width:60px;height:60px}.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -925px;width:60px;height:60px}.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-207px -925px;width:60px;height:60px}.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -925px;width:60px;height:60px}.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-389px -925px;width:60px;height:60px}.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -925px;width:60px;height:60px}.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-571px -925px;width:60px;height:60px}.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -925px;width:60px;height:60px}.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-753px -925px;width:60px;height:60px}.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -925px;width:60px;height:60px}.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-935px -925px;width:60px;height:60px}.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px 0;width:90px;height:90px}.customize-option.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -15px;width:60px;height:60px}.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1003px -91px;width:90px;height:90px}.customize-option.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1028px -106px;width:60px;height:60px}.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -182px;width:90px;height:90px}.customize-option.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -197px;width:60px;height:60px}.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1003px -273px;width:90px;height:90px}.customize-option.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1028px -288px;width:60px;height:60px}.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -364px;width:90px;height:90px}.customize-option.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -379px;width:60px;height:60px}.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-1003px -455px;width:90px;height:90px}.customize-option.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-1028px -470px;width:60px;height:60px}.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -546px;width:90px;height:90px}.customize-option.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -561px;width:60px;height:60px}.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-1003px -637px;width:90px;height:90px}.customize-option.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-1028px -652px;width:60px;height:60px}.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -728px;width:90px;height:90px}.customize-option.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -743px;width:60px;height:60px}.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-1003px -819px;width:90px;height:90px}.customize-option.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-1028px -834px;width:60px;height:60px}.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -925px;width:60px;height:60px}.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-25px -1016px;width:60px;height:60px}.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1016px;width:60px;height:60px}.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-207px -1016px;width:60px;height:60px}.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1016px;width:60px;height:60px}.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-389px -1016px;width:60px;height:60px}.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1016px;width:60px;height:60px}.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-571px -1016px;width:60px;height:60px}.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1016px;width:60px;height:60px}.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-753px -1016px;width:60px;height:60px}.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-844px -1016px;width:60px;height:60px}.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-935px -1016px;width:60px;height:60px}.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1026px -1016px;width:60px;height:60px}.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1094px 0;width:90px;height:90px}.customize-option.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1119px -15px;width:60px;height:60px}.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -91px;width:90px;height:90px}.customize-option.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -106px;width:60px;height:60px}.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1094px -182px;width:90px;height:90px}.customize-option.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1119px -197px;width:60px;height:60px}.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -273px;width:90px;height:90px}.customize-option.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -288px;width:60px;height:60px}.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1094px -364px;width:90px;height:90px}.customize-option.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1119px -379px;width:60px;height:60px}.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-1094px -455px;width:90px;height:90px}.customize-option.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-1119px -470px;width:60px;height:60px}.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -546px;width:90px;height:90px}.customize-option.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -561px;width:60px;height:60px}.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -637px;width:90px;height:90px}.customize-option.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -652px;width:60px;height:60px}.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-1094px -728px;width:90px;height:90px}.customize-option.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-1119px -743px;width:60px;height:60px}.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -819px;width:90px;height:90px}.customize-option.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -834px;width:60px;height:60px}.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-1094px -910px;width:90px;height:90px}.customize-option.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-1119px -925px;width:60px;height:60px}.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-1094px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-1119px -1016px;width:60px;height:60px}.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-25px -1107px;width:60px;height:60px}.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1107px;width:60px;height:60px}.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-207px -1107px;width:60px;height:60px}.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1107px;width:60px;height:60px}.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-389px -1107px;width:60px;height:60px}.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1107px;width:60px;height:60px}.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-571px -1107px;width:60px;height:60px}.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1107px;width:60px;height:60px}.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-753px -1107px;width:60px;height:60px}.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -1107px;width:60px;height:60px}.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-935px -1107px;width:60px;height:60px}.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1026px -1107px;width:60px;height:60px}.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1117px -1107px;width:60px;height:60px}.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1185px 0;width:90px;height:90px}.customize-option.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1210px -15px;width:60px;height:60px}.broad_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -91px;width:90px;height:90px}.broad_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1185px -182px;width:90px;height:90px}.broad_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -273px;width:90px;height:90px}.broad_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -364px;width:90px;height:90px}.broad_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1185px -455px;width:90px;height:90px}.broad_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -546px;width:90px;height:90px}.broad_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -637px;width:90px;height:90px}.broad_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -728px;width:90px;height:90px}.eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1185px -819px;width:90px;height:90px}.head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1185px -910px;width:90px;height:90px}.head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1185px -1001px;width:90px;height:90px}.head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1185px -1092px;width:90px;height:90px}.head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:0 -1183px;width:90px;height:90px}.head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-91px -1183px;width:90px;height:90px}.head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-182px -1183px;width:90px;height:90px}.head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-273px -1183px;width:90px;height:90px}.head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-364px -1183px;width:90px;height:90px}.head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-455px -1183px;width:90px;height:90px}.head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-546px -1183px;width:90px;height:90px}.head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-637px -1183px;width:90px;height:90px}.head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-728px -1183px;width:90px;height:90px}.head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-819px -1183px;width:90px;height:90px}.head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-910px -1183px;width:90px;height:90px}.head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1001px -1183px;width:90px;height:90px}.head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1092px -1183px;width:90px;height:90px}.shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1183px -1183px;width:90px;height:90px}.shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1276px 0;width:90px;height:90px}.shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1276px -91px;width:90px;height:90px}.shop_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -943px;width:40px;height:40px}.shop_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1640px -779px;width:40px;height:40px}.shop_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -738px;width:40px;height:40px}.shop_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -697px;width:40px;height:40px}.shop_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1640px -656px;width:40px;height:40px}.shop_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -533px;width:40px;height:40px}.shop_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -984px;width:40px;height:40px}.shop_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -492px;width:40px;height:40px}.shop_eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1640px -451px;width:40px;height:40px}.shop_head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -410px;width:40px;height:40px}.shop_head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -369px;width:40px;height:40px}.shop_head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1640px -328px;width:40px;height:40px}.shop_head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -287px;width:40px;height:40px}.shop_head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-1640px -246px;width:40px;height:40px}.shop_head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -205px;width:40px;height:40px}.shop_head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-1640px -164px;width:40px;height:40px}.shop_head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -123px;width:40px;height:40px}.shop_head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -82px;width:40px;height:40px}.shop_head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -41px;width:40px;height:40px}.shop_head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1640px 0;width:40px;height:40px}.shop_head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1576px -1588px;width:40px;height:40px}.shop_head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1535px -1588px;width:40px;height:40px}.shop_head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-1494px -1588px;width:40px;height:40px}.shop_head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1453px -1588px;width:40px;height:40px}.shop_head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1588px;width:40px;height:40px}.shop_shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1576px -1547px;width:40px;height:40px}.shop_shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1535px -1547px;width:40px;height:40px}.shop_shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1494px -1547px;width:40px;height:40px}.shop_weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-1453px -1547px;width:40px;height:40px}.shop_weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-1412px -1547px;width:40px;height:40px}.shop_weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-1371px -1547px;width:40px;height:40px}.shop_weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-1330px -1547px;width:40px;height:40px}.shop_weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-1289px -1547px;width:40px;height:40px}.shop_weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-1248px -1547px;width:40px;height:40px}.shop_weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-1207px -1547px;width:40px;height:40px}.shop_weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-1166px -1547px;width:40px;height:40px}.slim_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -819px;width:90px;height:90px}.slim_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1367px -910px;width:90px;height:90px}.slim_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -1001px;width:90px;height:90px}.slim_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -1092px;width:90px;height:90px}.slim_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1367px -1183px;width:90px;height:90px}.slim_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1367px -1274px;width:90px;height:90px}.slim_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:0 -1365px;width:90px;height:90px}.slim_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-91px -1365px;width:90px;height:90px}.weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1365px;width:90px;height:90px}.weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-273px -1365px;width:90px;height:90px}.weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-364px -1365px;width:90px;height:90px}.weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-455px -1365px;width:90px;height:90px}.weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-546px -1365px;width:90px;height:90px}.weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-637px -1365px;width:90px;height:90px}.weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-728px -1365px;width:90px;height:90px}.weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-819px -1365px;width:90px;height:90px}.broad_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-910px -1365px;width:90px;height:90px}.broad_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1365px;width:90px;height:90px}.broad_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1092px -1365px;width:90px;height:90px}.broad_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1183px -1365px;width:90px;height:90px}.broad_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1365px;width:90px;height:90px}.broad_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1365px -1365px;width:90px;height:90px}.broad_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1458px 0;width:90px;height:90px}.broad_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -91px;width:90px;height:90px}.broad_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -182px;width:90px;height:90px}.broad_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -273px;width:90px;height:90px}.broad_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -364px;width:90px;height:90px}.broad_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1458px -455px;width:90px;height:90px}.broad_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -546px;width:90px;height:90px}.broad_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -637px;width:90px;height:90px}.broad_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -728px;width:90px;height:90px}.broad_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -819px;width:90px;height:90px}.broad_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -910px;width:90px;height:90px}.broad_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -1001px;width:90px;height:90px}.broad_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -1092px;width:90px;height:90px}.broad_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -1183px;width:90px;height:90px}.broad_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -1274px;width:90px;height:90px}.broad_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -1365px;width:90px;height:90px}.shop_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1125px -1547px;width:40px;height:40px}.shop_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1084px -1547px;width:40px;height:40px}.shop_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1043px -1547px;width:40px;height:40px}.shop_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1002px -1547px;width:40px;height:40px}.shop_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-961px -1547px;width:40px;height:40px}.shop_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-920px -1547px;width:40px;height:40px}.shop_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-879px -1547px;width:40px;height:40px}.shop_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-838px -1547px;width:40px;height:40px}.shop_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-797px -1547px;width:40px;height:40px}.shop_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-756px -1547px;width:40px;height:40px}.shop_armor_special_0{background-image:url(spritesmith-main-3.png);background-position:-715px -1547px;width:40px;height:40px}.shop_armor_special_1{background-image:url(spritesmith-main-3.png);background-position:-674px -1547px;width:40px;height:40px}.shop_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-551px -1547px;width:40px;height:40px}.shop_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-510px -1547px;width:40px;height:40px}.shop_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-469px -1547px;width:40px;height:40px}.shop_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-428px -1547px;width:40px;height:40px}.shop_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-387px -1547px;width:40px;height:40px}.shop_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-346px -1547px;width:40px;height:40px}.shop_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-305px -1547px;width:40px;height:40px}.shop_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-264px -1547px;width:40px;height:40px}.shop_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-223px -1547px;width:40px;height:40px}.shop_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-182px -1547px;width:40px;height:40px}.shop_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-633px -1588px;width:40px;height:40px}.shop_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-400px -314px;width:40px;height:40px}.slim_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -637px;width:90px;height:90px}.slim_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -728px;width:90px;height:90px}.slim_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -819px;width:90px;height:90px}.slim_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -910px;width:90px;height:90px}.slim_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -1001px;width:90px;height:90px}.slim_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -1092px;width:90px;height:90px}.slim_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -1183px;width:90px;height:90px}.slim_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -1274px;width:90px;height:90px}.slim_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -1365px;width:90px;height:90px}.slim_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -1456px;width:90px;height:90px}.slim_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:0 -1547px;width:90px;height:90px}.slim_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-91px -1547px;width:90px;height:90px}.slim_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -546px;width:90px;height:90px}.slim_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -455px;width:90px;height:90px}.slim_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -364px;width:90px;height:90px}.slim_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -273px;width:90px;height:90px}.slim_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -182px;width:90px;height:90px}.slim_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -91px;width:90px;height:90px}.slim_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1549px 0;width:90px;height:90px}.slim_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1456px -1456px;width:90px;height:90px}.slim_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1365px -1456px;width:90px;height:90px}.slim_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1456px;width:90px;height:90px}.broad_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1183px -1456px;width:90px;height:90px}.broad_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-1092px -1456px;width:90px;height:90px}.shop_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-592px -1547px;width:40px;height:40px}.shop_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-633px -1547px;width:40px;height:40px}.slim_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1001px -1456px;width:90px;height:90px}.slim_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-910px -1456px;width:90px;height:90px}.broad_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-212px -273px;width:93px;height:90px}.broad_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -273px;width:105px;height:90px}.broad_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-637px -1456px;width:90px;height:90px}.broad_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-546px -1456px;width:90px;height:90px}.broad_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-455px -1456px;width:90px;height:90px}.broad_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px -91px;width:120px;height:90px}.broad_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px -182px;width:105px;height:90px}.broad_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-182px -1456px;width:90px;height:90px}.head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-306px -273px;width:93px;height:90px}.head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-348px -91px;width:105px;height:90px}.head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1367px -728px;width:90px;height:90px}.head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1367px -637px;width:90px;height:90px}.head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1367px -546px;width:90px;height:90px}.head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 -91px;width:120px;height:90px}.head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-212px -182px;width:105px;height:90px}.head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1367px -273px;width:90px;height:90px}.shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px 0;width:93px;height:90px}.shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:0 -273px;width:105px;height:90px}.shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1367px 0;width:90px;height:90px}.shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1274px -1274px;width:90px;height:90px}.shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:0 -182px;width:105px;height:90px}.shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1092px -1274px;width:90px;height:90px}.shop_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-223px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-264px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-305px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-346px -1588px;width:40px;height:40px}.shop_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-387px -1588px;width:40px;height:40px}.shop_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-428px -1588px;width:40px;height:40px}.shop_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-469px -1588px;width:40px;height:40px}.shop_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-510px -1588px;width:40px;height:40px}.shop_head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-551px -1588px;width:40px;height:40px}.shop_head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-592px -1588px;width:40px;height:40px}.shop_head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-400px -273px;width:40px;height:40px}.shop_head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-674px -1588px;width:40px;height:40px}.shop_head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-715px -1588px;width:40px;height:40px}.shop_head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-756px -1588px;width:40px;height:40px}.shop_head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-797px -1588px;width:40px;height:40px}.shop_head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-838px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-879px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-920px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-961px -1588px;width:40px;height:40px}.shop_shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1002px -1588px;width:40px;height:40px}.shop_shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1043px -1588px;width:40px;height:40px}.shop_shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1084px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-1125px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-1166px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1207px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1248px -1588px;width:40px;height:40px}.shop_weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1289px -1588px;width:40px;height:40px}.shop_weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-1330px -1588px;width:40px;height:40px}.shop_weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1371px -1588px;width:40px;height:40px}.shop_weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1412px -1588px;width:40px;height:40px}.slim_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px -91px;width:93px;height:90px}.slim_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-242px -91px;width:105px;height:90px}.slim_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-819px -1274px;width:90px;height:90px}.slim_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-728px -1274px;width:90px;height:90px}.slim_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-637px -1274px;width:90px;height:90px}.slim_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 0;width:120px;height:90px}.slim_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px 0;width:105px;height:90px}.slim_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-364px -1274px;width:90px;height:90px}.weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px -182px;width:93px;height:90px}.weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -182px;width:105px;height:90px}.weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-91px -1274px;width:90px;height:90px}.weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:0 -1274px;width:90px;height:90px}.weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1276px -1183px;width:90px;height:90px}.weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px 0;width:120px;height:90px}.weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-242px 0;width:105px;height:90px}.weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1276px -910px;width:90px;height:90px}.broad_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -819px;width:90px;height:90px}.head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -637px;width:90px;height:90px}.shop_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1640px -574px;width:40px;height:40px}.shop_head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1640px -615px;width:40px;height:40px}.slim_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -546px;width:90px;height:90px}.back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -455px;width:90px;height:90px}.broad_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -364px;width:90px;height:90px}.head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -273px;width:90px;height:90px}.shop_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -820px;width:40px;height:40px}.shop_back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -861px;width:40px;height:40px}.shop_head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -902px;width:40px;height:40px}.slim_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -182px;width:90px;height:90px}.broad_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1276px -1001px;width:90px;height:90px}.headAccessory_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-739px -309px;width:90px;height:90px}.shop_armor_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1018px -910px;width:40px;height:40px}.shop_headAccessory_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1646px -820px;width:40px;height:40px}.slim_armor_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-461px -788px;width:90px;height:90px}.back_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-546px -1152px;width:90px;height:90px}.headAccessory_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1183px -1243px;width:90px;height:90px}.shop_back_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1646px -779px;width:40px;height:40px}.shop_headAccessory_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1646px -738px;width:40px;height:40px}.broad_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -364px;width:90px;height:90px}.head_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -455px;width:90px;height:90px}.shop_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-82px -1598px;width:40px;height:40px}.shop_head_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-41px -1598px;width:40px;height:40px}.slim_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -819px;width:90px;height:90px}.broad_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-739px -212px;width:90px;height:96px}.head_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-830px -188px;width:90px;height:96px}.shop_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:0 -1598px;width:40px;height:40px}.shop_head_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-1605px -1517px;width:40px;height:40px}.slim_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-830px -91px;width:90px;height:96px}.broad_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-552px -788px;width:90px;height:90px}.head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-825px -788px;width:90px;height:90px}.shop_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -1476px;width:40px;height:40px}.shop_head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -1435px;width:40px;height:40px}.slim_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:0 -879px;width:90px;height:90px}.broad_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-91px -879px;width:90px;height:90px}.head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1200px -910px;width:90px;height:90px}.shop_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1605px -1394px;width:40px;height:40px}.shop_head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1605px -1353px;width:40px;height:40px}.slim_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1291px -273px;width:90px;height:90px}.broad_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1001px -1243px;width:90px;height:90px}.headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1092px -1243px;width:90px;height:90px}.shop_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1605px -1312px;width:40px;height:40px}.shop_headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1605px -1271px;width:40px;height:40px}.slim_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1382px -182px;width:90px;height:90px}.back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -649px;width:93px;height:90px}.broad_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -558px;width:93px;height:90px}.shop_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1605px -1230px;width:40px;height:40px}.shop_back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1605px -1189px;width:40px;height:40px}.slim_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -376px;width:93px;height:90px}.head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -728px;width:90px;height:90px}.shop_head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1605px -1148px;width:40px;height:40px}.shop_weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1605px -1107px;width:40px;height:40px}.weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -1183px;width:90px;height:90px}.broad_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:0 -1334px;width:90px;height:90px}.head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-91px -1334px;width:90px;height:90px}.shop_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1605px -1066px;width:40px;height:40px}.shop_head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1605px -1025px;width:40px;height:40px}.slim_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-364px -1334px;width:90px;height:90px}.broad_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-455px -1334px;width:90px;height:90px}.head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-546px -1334px;width:90px;height:90px}.shop_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1605px -984px;width:40px;height:40px}.shop_head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1605px -943px;width:40px;height:40px}.slim_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-910px -1334px;width:90px;height:90px}.headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1001px -1334px;width:90px;height:90px}.shop_headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1605px -902px;width:40px;height:40px}.shop_weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1605px -861px;width:40px;height:40px}.weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1274px -1334px;width:90px;height:90px}.broad_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1473px -728px;width:90px;height:90px}.eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1473px -1274px;width:90px;height:90px}.shop_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1605px -492px;width:40px;height:40px}.shop_eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1605px -451px;width:40px;height:40px}.slim_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-182px -1425px;width:90px;height:90px}.back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-273px -1425px;width:90px;height:90px}.broad_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-364px -1425px;width:90px;height:90px}.shop_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1605px -410px;width:40px;height:40px}.shop_back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1605px -369px;width:40px;height:40px}.slim_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-819px -1425px;width:90px;height:90px}.head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-910px -1425px;width:90px;height:90px}.shop_head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1605px -328px;width:40px;height:40px}.shop_weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1605px -287px;width:40px;height:40px}.weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-739px -400px;width:90px;height:90px}.broad_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-546px -485px;width:90px;height:105px}.eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-648px -106px;width:90px;height:105px}.shop_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1605px -246px;width:40px;height:40px}.shop_eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1605px -205px;width:40px;height:40px}.slim_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-648px -318px;width:90px;height:105px}.back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-648px -424px;width:90px;height:105px}.eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:0 -591px;width:90px;height:105px}.shop_back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-779px -1557px;width:40px;height:40px}.shop_eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-738px -1557px;width:40px;height:40px}.broad_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:0 -788px;width:93px;height:90px}.head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -467px;width:93px;height:90px}.shop_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-697px -1557px;width:40px;height:40px}.shop_head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-656px -1557px;width:40px;height:40px}.slim_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -285px;width:93px;height:90px}.broad_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -364px;width:90px;height:90px}.head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -455px;width:90px;height:90px}.shop_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-615px -1557px;width:40px;height:40px}.shop_head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1473px -1365px;width:40px;height:40px}.slim_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -728px;width:90px;height:90px}.back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-536px -394px;width:105px;height:90px}.headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-94px -788px;width:93px;height:90px}.shop_back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-533px -1557px;width:40px;height:40px}.shop_headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-492px -1557px;width:40px;height:40px}.broad_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-364px -879px;width:90px;height:90px}.eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-455px -879px;width:90px;height:90px}.head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-546px -879px;width:90px;height:90px}.shop_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-451px -1557px;width:40px;height:40px}.shop_eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-410px -1557px;width:40px;height:40px}.shop_head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-369px -1557px;width:40px;height:40px}.shop_weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-328px -1557px;width:40px;height:40px}.slim_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1018px 0;width:90px;height:90px}.weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1018px -91px;width:90px;height:90px}.eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -182px;width:90px;height:90px}.headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -273px;width:90px;height:90px}.head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -364px;width:90px;height:90px}.shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -455px;width:90px;height:90px}.shop_eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-287px -1557px;width:40px;height:40px}.shop_headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-246px -1557px;width:40px;height:40px}.shop_head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-205px -1557px;width:40px;height:40px}.shop_shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-164px -1557px;width:40px;height:40px}.broad_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:0 -970px;width:90px;height:90px}.broad_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-91px -970px;width:90px;height:90px}.broad_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-182px -970px;width:90px;height:90px}.broad_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -970px;width:90px;height:90px}.broad_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -970px;width:90px;height:90px}.broad_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-455px -970px;width:90px;height:90px}.broad_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-546px -970px;width:90px;height:90px}.broad_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-637px -970px;width:90px;height:90px}.headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -970px;width:90px;height:90px}.headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -970px;width:90px;height:90px}.headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-910px -970px;width:90px;height:90px}.headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1001px -970px;width:90px;height:90px}.headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1109px 0;width:90px;height:90px}.headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1109px -91px;width:90px;height:90px}.headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -182px;width:90px;height:90px}.headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -273px;width:90px;height:90px}.head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1109px -364px;width:90px;height:90px}.head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1109px -455px;width:90px;height:90px}.head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -546px;width:90px;height:90px}.head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -637px;width:90px;height:90px}.head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1109px -728px;width:90px;height:90px}.head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1109px -819px;width:90px;height:90px}.head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -910px;width:90px;height:90px}.head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:0 -1061px;width:90px;height:90px}.shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1061px;width:90px;height:90px}.shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-182px -1061px;width:90px;height:90px}.shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -1061px;width:90px;height:90px}.shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -1061px;width:90px;height:90px}.shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-455px -1061px;width:90px;height:90px}.shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-546px -1061px;width:90px;height:90px}.shop_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-123px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-82px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-41px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -1557px;width:40px;height:40px}.shop_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1476px;width:40px;height:40px}.shop_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -1435px;width:40px;height:40px}.shop_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1394px;width:40px;height:40px}.shop_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1066px;width:40px;height:40px}.shop_headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1025px;width:40px;height:40px}.shop_headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -984px;width:40px;height:40px}.shop_headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -943px;width:40px;height:40px}.shop_headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -902px;width:40px;height:40px}.shop_headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -861px;width:40px;height:40px}.shop_headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -820px;width:40px;height:40px}.shop_headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -779px;width:40px;height:40px}.shop_headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -738px;width:40px;height:40px}.shop_head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -697px;width:40px;height:40px}.shop_head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -656px;width:40px;height:40px}.shop_head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -615px;width:40px;height:40px}.shop_head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -574px;width:40px;height:40px}.shop_head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -533px;width:40px;height:40px}.shop_head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -492px;width:40px;height:40px}.shop_head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -451px;width:40px;height:40px}.shop_head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -410px;width:40px;height:40px}.shop_shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -369px;width:40px;height:40px}.shop_shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -328px;width:40px;height:40px}.shop_shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -287px;width:40px;height:40px}.shop_shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -246px;width:40px;height:40px}.shop_shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -205px;width:40px;height:40px}.shop_shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -164px;width:40px;height:40px}.shop_weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-369px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-328px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-287px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-246px -1516px;width:40px;height:40px}.shop_weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-205px -1516px;width:40px;height:40px}.shop_weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-164px -1516px;width:40px;height:40px}.shop_weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-123px -1516px;width:40px;height:40px}.shop_weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-82px -1516px;width:40px;height:40px}.slim_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -546px;width:90px;height:90px}.slim_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1291px -637px;width:90px;height:90px}.slim_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -728px;width:90px;height:90px}.slim_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -819px;width:90px;height:90px}.slim_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1291px -910px;width:90px;height:90px}.slim_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1291px -1001px;width:90px;height:90px}.slim_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -1092px;width:90px;height:90px}.slim_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:0 -1243px;width:90px;height:90px}.weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1243px;width:90px;height:90px}.weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-182px -1243px;width:90px;height:90px}.weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-273px -1243px;width:90px;height:90px}.weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-364px -1243px;width:90px;height:90px}.weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -1243px;width:90px;height:90px}.weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-546px -1243px;width:90px;height:90px}.weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-637px -1243px;width:90px;height:90px}.weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-728px -1243px;width:90px;height:90px}.body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-819px -1243px;width:90px;height:90px}.body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-910px -1243px;width:90px;height:90px}.body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px -106px;width:102px;height:105px}.body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -485px;width:90px;height:105px}.body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -485px;width:90px;height:105px}.body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-455px -485px;width:90px;height:105px}.broad_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px 0;width:90px;height:90px}.broad_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1382px -91px;width:90px;height:90px}.broad_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 -106px;width:102px;height:105px}.broad_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-648px 0;width:90px;height:105px}.broad_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-182px -591px;width:90px;height:105px}.broad_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -212px;width:90px;height:105px}.broad_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-424px -182px;width:111px;height:90px}.broad_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -273px;width:111px;height:90px}.eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -394px;width:111px;height:90px}.eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-112px -394px;width:111px;height:90px}.head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px -910px;width:90px;height:90px}.head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1382px -1001px;width:90px;height:90px}.head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px 0;width:102px;height:105px}.head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -591px;width:90px;height:105px}.head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -591px;width:90px;height:105px}.head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-455px -591px;width:90px;height:105px}.head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-336px -394px;width:111px;height:90px}.head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-536px 0;width:111px;height:90px}.Healer_Summer{background-image:url(spritesmith-main-4.png);background-position:-637px -591px;width:90px;height:105px}.Mage_Summer{background-image:url(spritesmith-main-4.png);background-position:-739px 0;width:90px;height:105px}.SummerRogue14{background-image:url(spritesmith-main-4.png);background-position:-424px 0;width:111px;height:90px}.SummerWarrior14{background-image:url(spritesmith-main-4.png);background-position:-536px -91px;width:111px;height:90px}.shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1334px;width:90px;height:90px}.shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 0;width:102px;height:105px}.shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -485px;width:90px;height:105px}.shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-536px -288px;width:90px;height:105px}.shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -303px;width:111px;height:90px}.shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -182px;width:111px;height:90px}.shop_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-41px -1516px;width:40px;height:40px}.shop_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -1516px;width:40px;height:40px}.shop_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1488px -1466px;width:40px;height:40px}.shop_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1447px -1466px;width:40px;height:40px}.shop_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1406px -1466px;width:40px;height:40px}.shop_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1466px;width:40px;height:40px}.shop_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1488px -1425px;width:40px;height:40px}.shop_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1447px -1425px;width:40px;height:40px}.shop_body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1406px -1425px;width:40px;height:40px}.shop_body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1425px;width:40px;height:40px}.shop_body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-489px -435px;width:40px;height:40px}.shop_body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-448px -435px;width:40px;height:40px}.shop_body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-489px -394px;width:40px;height:40px}.shop_body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-448px -394px;width:40px;height:40px}.shop_eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-377px -344px;width:40px;height:40px}.shop_eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-336px -344px;width:40px;height:40px}.shop_head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-377px -303px;width:40px;height:40px}.shop_head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-336px -303px;width:40px;height:40px}.shop_head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-230px -253px;width:40px;height:40px}.shop_head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-230px -212px;width:40px;height:40px}.shop_head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-689px -530px;width:40px;height:40px}.shop_head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -530px;width:40px;height:40px}.shop_head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-871px -740px;width:40px;height:40px}.shop_head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-830px -740px;width:40px;height:40px}.shop_shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-968px -819px;width:40px;height:40px}.shop_shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-927px -819px;width:40px;height:40px}.shop_shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1059px -910px;width:40px;height:40px}.shop_shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1646px -861px;width:40px;height:40px}.shop_shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1150px -1001px;width:40px;height:40px}.shop_shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -1001px;width:40px;height:40px}.shop_weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1241px -1092px;width:40px;height:40px}.shop_weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1092px;width:40px;height:40px}.shop_weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1514px -1365px;width:40px;height:40px}.shop_weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-574px -1557px;width:40px;height:40px}.shop_weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1382px -1274px;width:40px;height:40px}.shop_weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1423px -1274px;width:40px;height:40px}.shop_weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -1183px;width:40px;height:40px}.shop_weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1332px -1183px;width:40px;height:40px}.slim_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1274px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1183px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px 0;width:102px;height:105px}.slim_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -591px;width:90px;height:105px}.slim_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-739px -106px;width:90px;height:105px}.slim_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-536px -182px;width:90px;height:105px}.slim_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -303px;width:111px;height:90px}.slim_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -91px;width:111px;height:90px}.weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-546px -1425px;width:90px;height:90px}.weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-455px -1425px;width:90px;height:90px}.weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px -106px;width:102px;height:105px}.weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-91px -485px;width:90px;height:105px}.weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-182px -485px;width:90px;height:105px}.weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-91px -591px;width:90px;height:105px}.weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -394px;width:111px;height:90px}.weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -91px;width:111px;height:90px}.broad_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -1183px;width:90px;height:90px}.broad_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -1092px;width:90px;height:90px}.broad_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -1001px;width:90px;height:90px}.broad_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px -910px;width:90px;height:90px}.broad_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1473px -819px;width:90px;height:90px}.broad_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-507px -697px;width:96px;height:90px}.broad_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1473px -637px;width:90px;height:90px}.broad_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1473px -546px;width:90px;height:90px}.head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -455px;width:90px;height:90px}.head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-1473px -364px;width:90px;height:90px}.head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-1473px -273px;width:90px;height:90px}.head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -182px;width:90px;height:90px}.head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -91px;width:90px;height:90px}.head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px 0;width:90px;height:90px}.head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1334px;width:90px;height:90px}.head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-410px -697px;width:96px;height:90px}.head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -455px;width:90px;height:90px}.head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1291px -364px;width:90px;height:90px}.shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -697px;width:104px;height:90px}.shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1291px -182px;width:90px;height:90px}.shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -91px;width:90px;height:90px}.shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-604px -697px;width:96px;height:90px}.shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1183px -1152px;width:90px;height:90px}.shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1092px -1152px;width:90px;height:90px}.shop_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-410px -1516px;width:40px;height:40px}.shop_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-451px -1516px;width:40px;height:40px}.shop_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-492px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-533px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-574px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-615px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-656px -1516px;width:40px;height:40px}.shop_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-697px -1516px;width:40px;height:40px}.shop_head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-738px -1516px;width:40px;height:40px}.shop_head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-779px -1516px;width:40px;height:40px}.shop_head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-820px -1516px;width:40px;height:40px}.shop_head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-861px -1516px;width:40px;height:40px}.shop_head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-902px -1516px;width:40px;height:40px}.shop_head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-943px -1516px;width:40px;height:40px}.shop_head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-984px -1516px;width:40px;height:40px}.shop_head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1025px -1516px;width:40px;height:40px}.shop_head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1066px -1516px;width:40px;height:40px}.shop_head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1107px -1516px;width:40px;height:40px}.shop_shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1148px -1516px;width:40px;height:40px}.shop_shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1189px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1230px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1271px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1312px -1516px;width:40px;height:40px}.shop_shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1353px -1516px;width:40px;height:40px}.shop_weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1394px -1516px;width:40px;height:40px}.shop_weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1435px -1516px;width:40px;height:40px}.shop_weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1476px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1517px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px 0;width:40px;height:40px}.shop_weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -41px;width:40px;height:40px}.shop_weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -82px;width:40px;height:40px}.shop_weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1564px -123px;width:40px;height:40px}.slim_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1001px -1152px;width:90px;height:90px}.slim_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-910px -1152px;width:90px;height:90px}.slim_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-819px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-637px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-701px -697px;width:96px;height:90px}.slim_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-455px -1152px;width:90px;height:90px}.slim_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-364px -1152px;width:90px;height:90px}.weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-273px -1152px;width:90px;height:90px}.weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-182px -1152px;width:90px;height:90px}.weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-91px -1152px;width:90px;height:90px}.weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:0 -1152px;width:90px;height:90px}.weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1001px;width:90px;height:90px}.weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-313px -697px;width:96px;height:90px}.weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1200px -819px;width:90px;height:90px}.weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1200px -728px;width:90px;height:90px}.back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -637px;width:90px;height:90px}.back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -546px;width:90px;height:90px}.body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -455px;width:90px;height:90px}.body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1200px -364px;width:90px;height:90px}.body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -273px;width:90px;height:90px}.eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -182px;width:90px;height:90px}.eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -91px;width:90px;height:90px}.shop_back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1107px;width:40px;height:40px}.shop_back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1148px;width:40px;height:40px}.shop_body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1189px;width:40px;height:40px}.shop_body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1564px -1230px;width:40px;height:40px}.shop_body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1271px;width:40px;height:40px}.shop_eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1312px;width:40px;height:40px}.shop_eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1353px;width:40px;height:40px}.head_0{background-image:url(spritesmith-main-4.png);background-position:-1200px 0;width:90px;height:90px}.customize-option.head_0{background-image:url(spritesmith-main-4.png);background-position:-1225px -15px;width:60px;height:60px}.head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1092px -1061px;width:90px;height:90px}.head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1001px -1061px;width:90px;height:90px}.head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-910px -1061px;width:90px;height:90px}.head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-819px -1061px;width:90px;height:90px}.head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-728px -1061px;width:90px;height:90px}.head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-637px -1061px;width:90px;height:90px}.head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1018px -819px;width:90px;height:90px}.head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1018px -728px;width:90px;height:90px}.head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1018px -637px;width:90px;height:90px}.head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1018px -546px;width:90px;height:90px}.head_special_2{background-image:url(spritesmith-main-4.png);background-position:-910px -879px;width:90px;height:90px}.head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-819px -879px;width:90px;height:90px}.head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-728px -879px;width:90px;height:90px}.head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-637px -879px;width:90px;height:90px}.head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-273px -879px;width:90px;height:90px}.head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-182px -879px;width:90px;height:90px}.head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-927px -637px;width:90px;height:90px}.head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-927px -546px;width:90px;height:90px}.head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-927px -182px;width:90px;height:90px}.head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-927px -91px;width:90px;height:90px}.head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-734px -788px;width:90px;height:90px}.head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-643px -788px;width:90px;height:90px}.shop_head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-820px -1557px;width:40px;height:40px}.shop_head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-861px -1557px;width:40px;height:40px}.shop_head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-902px -1557px;width:40px;height:40px}.shop_head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-943px -1557px;width:40px;height:40px}.shop_head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-984px -1557px;width:40px;height:40px}.shop_head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1025px -1557px;width:40px;height:40px}.shop_head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1066px -1557px;width:40px;height:40px}.shop_head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1107px -1557px;width:40px;height:40px}.shop_head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1148px -1557px;width:40px;height:40px}.shop_head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1189px -1557px;width:40px;height:40px}.shop_head_special_0{background-image:url(spritesmith-main-4.png);background-position:-1230px -1557px;width:40px;height:40px}.shop_head_special_1{background-image:url(spritesmith-main-4.png);background-position:-1271px -1557px;width:40px;height:40px}.shop_head_special_2{background-image:url(spritesmith-main-4.png);background-position:-1312px -1557px;width:40px;height:40px}.shop_head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-1353px -1557px;width:40px;height:40px}.shop_head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1394px -1557px;width:40px;height:40px}.shop_head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1435px -1557px;width:40px;height:40px}.shop_head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1476px -1557px;width:40px;height:40px}.shop_head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1517px -1557px;width:40px;height:40px}.shop_head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1558px -1557px;width:40px;height:40px}.shop_head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1605px 0;width:40px;height:40px}.shop_head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1605px -41px;width:40px;height:40px}.shop_head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1605px -82px;width:40px;height:40px}.shop_head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1605px -123px;width:40px;height:40px}.shop_head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1605px -164px;width:40px;height:40px}.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-279px -788px;width:90px;height:90px}.customize-option.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-304px -803px;width:60px;height:60px}.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-188px -788px;width:90px;height:90px}.customize-option.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-213px -803px;width:60px;height:60px}.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1092px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1117px -1440px;width:60px;height:60px}.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1001px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1026px -1440px;width:60px;height:60px}.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-728px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-753px -1440px;width:60px;height:60px}.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-637px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-662px -1440px;width:60px;height:60px}.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-91px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-116px -1440px;width:60px;height:60px}.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:0 -1425px;width:90px;height:90px}.customize-option.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-25px -1440px;width:60px;height:60px}.shop_headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -533px;width:40px;height:40px}.shop_headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -574px;width:40px;height:40px}.shop_headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -615px;width:40px;height:40px}.shop_headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -656px;width:40px;height:40px}.shop_headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -697px;width:40px;height:40px}.shop_headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -738px;width:40px;height:40px}.shop_headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -779px;width:40px;height:40px}.shop_headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -820px;width:40px;height:40px}.shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1183px -1334px;width:90px;height:90px}.shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1092px -1334px;width:90px;height:90px}.shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-819px -1334px;width:90px;height:90px}.shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-637px -1334px;width:90px;height:90px}.shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-273px -1334px;width:90px;height:90px}.shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-182px -1334px;width:90px;height:90px}.shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-105px -697px;width:103px;height:90px}.shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-209px -697px;width:103px;height:90px}.shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:0 -212px;width:114px;height:90px}.shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-830px 0;width:96px;height:90px}.shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-115px -212px;width:114px;height:90px}.shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-309px 0;width:114px;height:90px}.shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-1291px 0;width:90px;height:90px}.shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-112px -303px;width:111px;height:90px}.shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-927px -273px;width:90px;height:90px}.shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-927px 0;width:90px;height:90px}.shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-370px -788px;width:90px;height:90px}.shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-739px -582px;width:90px;height:90px}.shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1382px -637px;width:90px;height:90px}.shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1382px -546px;width:90px;height:90px}.shop_shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-123px -1598px;width:40px;height:40px}.shop_shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-164px -1598px;width:40px;height:40px}.shop_shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-205px -1598px;width:40px;height:40px}.shop_shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-246px -1598px;width:40px;height:40px}.shop_shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-287px -1598px;width:40px;height:40px}.shop_shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-328px -1598px;width:40px;height:40px}.shop_shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-369px -1598px;width:40px;height:40px}.shop_shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-410px -1598px;width:40px;height:40px}.shop_shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-451px -1598px;width:40px;height:40px}.shop_shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-492px -1598px;width:40px;height:40px}.shop_shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-533px -1598px;width:40px;height:40px}.shop_shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-574px -1598px;width:40px;height:40px}.shop_shield_special_0{background-image:url(spritesmith-main-4.png);background-position:-615px -1598px;width:40px;height:40px}.shop_shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-656px -1598px;width:40px;height:40px}.shop_shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-697px -1598px;width:40px;height:40px}.shop_shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-738px -1598px;width:40px;height:40px}.shop_shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-779px -1598px;width:40px;height:40px}.shop_shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-820px -1598px;width:40px;height:40px}.shop_shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-861px -1598px;width:40px;height:40px}.shop_shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-902px -1598px;width:40px;height:40px}.shop_shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-943px -1598px;width:40px;height:40px}.shop_weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-984px -1598px;width:40px;height:40px}.shop_weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1025px -1598px;width:40px;height:40px}.shop_weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1066px -1598px;width:40px;height:40px}.shop_weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1107px -1598px;width:40px;height:40px}.shop_weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1148px -1598px;width:40px;height:40px}.shop_weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-1189px -1598px;width:40px;height:40px}.shop_weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-1230px -1598px;width:40px;height:40px}.shop_weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-1271px -1598px;width:40px;height:40px}.shop_weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1312px -1598px;width:40px;height:40px}.shop_weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1353px -1598px;width:40px;height:40px}.shop_weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1394px -1598px;width:40px;height:40px}.shop_weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1435px -1598px;width:40px;height:40px}.shop_weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1476px -1598px;width:40px;height:40px}.shop_weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1517px -1598px;width:40px;height:40px}.shop_weapon_special_0{background-image:url(spritesmith-main-4.png);background-position:-1558px -1598px;width:40px;height:40px}.shop_weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-1599px -1598px;width:40px;height:40px}.shop_weapon_special_2{background-image:url(spritesmith-main-4.png);background-position:-1646px 0;width:40px;height:40px}.shop_weapon_special_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -41px;width:40px;height:40px}.shop_weapon_special_critical{background-image:url(spritesmith-main-4.png);background-position:-1646px -82px;width:40px;height:40px}.shop_weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-4.png);background-position:-1646px -123px;width:40px;height:40px}.shop_weapon_warrior_0{background-image:url(spritesmith-main-4.png);background-position:-1646px -164px;width:40px;height:40px}.shop_weapon_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -205px;width:40px;height:40px}.shop_weapon_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -246px;width:40px;height:40px}.shop_weapon_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -287px;width:40px;height:40px}.shop_weapon_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -328px;width:40px;height:40px}.shop_weapon_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -369px;width:40px;height:40px}.shop_weapon_warrior_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -410px;width:40px;height:40px}.shop_weapon_wizard_0{background-image:url(spritesmith-main-4.png);background-position:-1646px -451px;width:40px;height:40px}.shop_weapon_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -492px;width:40px;height:40px}.shop_weapon_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -533px;width:40px;height:40px}.shop_weapon_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -574px;width:40px;height:40px}.shop_weapon_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -615px;width:40px;height:40px}.shop_weapon_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -656px;width:40px;height:40px}.shop_weapon_wizard_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -697px;width:40px;height:40px}.weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-1382px -273px;width:90px;height:90px}.weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1274px -1243px;width:90px;height:90px}.weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-739px -491px;width:90px;height:90px}.weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1382px -1092px;width:90px;height:90px}.weapon_healer_4{background-image:url(spritesmith-main-5.png);background-position:-728px -1523px;width:90px;height:90px}.weapon_healer_5{background-image:url(spritesmith-main-5.png);background-position:-1274px -1523px;width:90px;height:90px}.weapon_healer_6{background-image:url(spritesmith-main-5.png);background-position:0 -1523px;width:90px;height:90px}.weapon_rogue_0{background-image:url(spritesmith-main-5.png);background-position:-91px -1523px;width:90px;height:90px}.weapon_rogue_1{background-image:url(spritesmith-main-5.png);background-position:-182px -1523px;width:90px;height:90px}.weapon_rogue_2{background-image:url(spritesmith-main-5.png);background-position:-273px -1523px;width:90px;height:90px}.weapon_rogue_3{background-image:url(spritesmith-main-5.png);background-position:-364px -1523px;width:90px;height:90px}.weapon_rogue_4{background-image:url(spritesmith-main-5.png);background-position:-455px -1523px;width:90px;height:90px}.weapon_rogue_5{background-image:url(spritesmith-main-5.png);background-position:-546px -1523px;width:90px;height:90px}.weapon_rogue_6{background-image:url(spritesmith-main-5.png);background-position:-637px -1523px;width:90px;height:90px}.weapon_special_1{background-image:url(spritesmith-main-5.png);background-position:-485px -1402px;width:102px;height:90px}.weapon_special_2{background-image:url(spritesmith-main-5.png);background-position:-819px -1523px;width:90px;height:90px}.weapon_special_3{background-image:url(spritesmith-main-5.png);background-position:-910px -1523px;width:90px;height:90px}.weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-5.png);background-position:-1001px -1523px;width:90px;height:90px}.weapon_warrior_0{background-image:url(spritesmith-main-5.png);background-position:-1092px -1523px;width:90px;height:90px}.weapon_warrior_1{background-image:url(spritesmith-main-5.png);background-position:-1183px -1523px;width:90px;height:90px}.weapon_warrior_2{background-image:url(spritesmith-main-5.png);background-position:-1536px 0;width:90px;height:90px}.weapon_warrior_3{background-image:url(spritesmith-main-5.png);background-position:-1536px -182px;width:90px;height:90px}.weapon_warrior_4{background-image:url(spritesmith-main-5.png);background-position:-1536px -455px;width:90px;height:90px}.weapon_warrior_5{background-image:url(spritesmith-main-5.png);background-position:-1536px -546px;width:90px;height:90px}.weapon_warrior_6{background-image:url(spritesmith-main-5.png);background-position:-1536px -637px;width:90px;height:90px}.weapon_wizard_0{background-image:url(spritesmith-main-5.png);background-position:-1536px -728px;width:90px;height:90px}.weapon_wizard_1{background-image:url(spritesmith-main-5.png);background-position:-1536px -819px;width:90px;height:90px}.weapon_wizard_2{background-image:url(spritesmith-main-5.png);background-position:-1536px -910px;width:90px;height:90px}.weapon_wizard_3{background-image:url(spritesmith-main-5.png);background-position:-1536px -1001px;width:90px;height:90px}.weapon_wizard_4{background-image:url(spritesmith-main-5.png);background-position:-1536px -1092px;width:90px;height:90px}.weapon_wizard_5{background-image:url(spritesmith-main-5.png);background-position:-1536px -1183px;width:90px;height:90px}.weapon_wizard_6{background-image:url(spritesmith-main-5.png);background-position:-1536px -1274px;width:90px;height:90px}.GrimReaper{background-image:url(spritesmith-main-5.png);background-position:-1536px -1456px;width:57px;height:66px}.Pet_Currency_Gem{background-image:url(spritesmith-main-5.png);background-position:-1476px -1402px;width:45px;height:39px}.Pet_Currency_Gem1x{background-image:url(spritesmith-main-5.png);background-position:-1767px -1692px;width:15px;height:13px}.Pet_Currency_Gem2x{background-image:url(spritesmith-main-5.png);background-position:-1627px -1584px;width:30px;height:26px}.PixelPaw-Gold{background-image:url(spritesmith-main-5.png);background-position:-1627px -544px;width:51px;height:51px}.PixelPaw{background-image:url(spritesmith-main-5.png);background-position:-1627px -492px;width:51px;height:51px}.PixelPaw002{background-image:url(spritesmith-main-5.png);background-position:-1627px -440px;width:51px;height:51px}.avatar_floral_healer{background-image:url(spritesmith-main-5.png);background-position:-385px -1402px;width:99px;height:99px}.avatar_floral_rogue{background-image:url(spritesmith-main-5.png);background-position:-285px -1402px;width:99px;height:99px}.avatar_floral_warrior{background-image:url(spritesmith-main-5.png);background-position:-185px -1402px;width:99px;height:99px}.avatar_floral_wizard{background-image:url(spritesmith-main-5.png);background-position:-85px -1402px;width:99px;height:99px}.empty_bottles{background-image:url(spritesmith-main-5.png);background-position:-1365px -1523px;width:64px;height:54px}.inventory_present{background-image:url(spritesmith-main-5.png);background-position:-490px -1666px;width:48px;height:51px}.inventory_present_01{background-image:url(spritesmith-main-5.png);background-position:-392px -1666px;width:48px;height:51px}.inventory_present_02{background-image:url(spritesmith-main-5.png);background-position:-343px -1666px;width:48px;height:51px}.inventory_present_03{background-image:url(spritesmith-main-5.png);background-position:-294px -1666px;width:48px;height:51px}.inventory_present_04{background-image:url(spritesmith-main-5.png);background-position:-1627px -596px;width:48px;height:51px}.inventory_present_05{background-image:url(spritesmith-main-5.png);background-position:-196px -1666px;width:48px;height:51px}.inventory_present_06{background-image:url(spritesmith-main-5.png);background-position:-147px -1666px;width:48px;height:51px}.inventory_present_07{background-image:url(spritesmith-main-5.png);background-position:-98px -1666px;width:48px;height:51px}.inventory_present_08{background-image:url(spritesmith-main-5.png);background-position:-49px -1666px;width:48px;height:51px}.inventory_present_09{background-image:url(spritesmith-main-5.png);background-position:0 -1666px;width:48px;height:51px}.inventory_present_10{background-image:url(spritesmith-main-5.png);background-position:-1685px -1612px;width:48px;height:51px}.inventory_present_11{background-image:url(spritesmith-main-5.png);background-position:-1685px -1560px;width:48px;height:51px}.inventory_present_12{background-image:url(spritesmith-main-5.png);background-position:-1685px -1508px;width:48px;height:51px}.inventory_quest_scroll{background-image:url(spritesmith-main-5.png);background-position:-1685px -1456px;width:48px;height:51px}.inventory_quest_scroll_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -1404px;width:48px;height:51px}.inventory_special_fortify{background-image:url(spritesmith-main-5.png);background-position:-1627px -110px;width:57px;height:54px}.inventory_special_greeting{background-image:url(spritesmith-main-5.png);background-position:-1430px -1523px;width:57px;height:54px}.inventory_special_nye{background-image:url(spritesmith-main-5.png);background-position:-1488px -1523px;width:57px;height:54px}.inventory_special_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1011px -1442px;width:40px;height:40px}.inventory_special_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1627px -385px;width:57px;height:54px}.inventory_special_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1627px -330px;width:57px;height:54px}.inventory_special_snowball{background-image:url(spritesmith-main-5.png);background-position:-1627px -275px;width:57px;height:54px}.inventory_special_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1627px -220px;width:57px;height:54px}.inventory_special_thankyou{background-image:url(spritesmith-main-5.png);background-position:-1627px -165px;width:57px;height:54px}.inventory_special_trinket{background-image:url(spritesmith-main-5.png);background-position:-1685px -832px;width:48px;height:51px}.inventory_special_valentine{background-image:url(spritesmith-main-5.png);background-position:-1627px -55px;width:57px;height:54px}.knockout{background-image:url(spritesmith-main-5.png);background-position:-588px -1442px;width:120px;height:47px}.pet_key{background-image:url(spritesmith-main-5.png);background-position:-1627px 0;width:57px;height:54px}.rebirth_orb{background-image:url(spritesmith-main-5.png);background-position:-1546px -1523px;width:57px;height:54px}.seafoam_star{background-image:url(spritesmith-main-5.png);background-position:-1536px -91px;width:90px;height:90px}.shop_armoire{background-image:url(spritesmith-main-5.png);background-position:-970px -1442px;width:40px;height:40px}.snowman{background-image:url(spritesmith-main-5.png);background-position:-1536px -273px;width:90px;height:90px}.spookman{background-image:url(spritesmith-main-5.png);background-position:-1536px -364px;width:90px;height:90px}.zzz{background-image:url(spritesmith-main-5.png);background-position:-929px -1442px;width:40px;height:40px}.zzz_light{background-image:url(spritesmith-main-5.png);background-position:-1134px -1442px;width:40px;height:40px}.npc_alex{background-image:url(spritesmith-main-5.png);background-position:-151px -1251px;width:162px;height:138px}.npc_bailey{background-image:url(spritesmith-main-5.png);background-position:-1461px -1251px;width:60px;height:72px}.npc_daniel{background-image:url(spritesmith-main-5.png);background-position:-477px -1251px;width:135px;height:123px}.npc_justin{background-image:url(spritesmith-main-5.png);background-position:0 -1402px;width:84px;height:120px}.npc_justin_head{background-image:url(spritesmith-main-5.png);background-position:-1298px -1442px;width:36px;height:39px}.npc_matt{background-image:url(spritesmith-main-5.png);background-position:-1322px -954px;width:195px;height:138px}.npc_timetravelers{background-image:url(spritesmith-main-5.png);background-position:-1322px -815px;width:195px;height:138px}.npc_timetravelers_active{background-image:url(spritesmith-main-5.png);background-position:-1322px -676px;width:195px;height:138px}.npc_tyler{background-image:url(spritesmith-main-5.png);background-position:-1536px -1365px;width:90px;height:90px}.seasonalshop_closed{background-image:url(spritesmith-main-5.png);background-position:-314px -1251px;width:162px;height:138px}.inventory_quest_scroll_atom1{background-image:url(spritesmith-main-5.png);background-position:-1685px -208px;width:48px;height:51px}.inventory_quest_scroll_atom1_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -156px;width:48px;height:51px}.inventory_quest_scroll_atom2{background-image:url(spritesmith-main-5.png);background-position:-1685px -104px;width:48px;height:51px}.inventory_quest_scroll_atom2_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -52px;width:48px;height:51px}.inventory_quest_scroll_atom3{background-image:url(spritesmith-main-5.png);background-position:-637px -1614px;width:48px;height:51px}.inventory_quest_scroll_atom3_locked{background-image:url(spritesmith-main-5.png);background-position:-588px -1614px;width:48px;height:51px}.inventory_quest_scroll_basilist{background-image:url(spritesmith-main-5.png);background-position:-539px -1614px;width:48px;height:51px}.inventory_quest_scroll_bunny{background-image:url(spritesmith-main-5.png);background-position:-490px -1614px;width:48px;height:51px}.inventory_quest_scroll_cheetah{background-image:url(spritesmith-main-5.png);background-position:-441px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-392px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-343px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2_locked{background-image:url(spritesmith-main-5.png);background-position:-294px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-245px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3_locked{background-image:url(spritesmith-main-5.png);background-position:-147px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-1685px -312px;width:48px;height:51px}.inventory_quest_scroll_egg{background-image:url(spritesmith-main-5.png);background-position:-245px -1666px;width:48px;height:51px}.inventory_quest_scroll_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-1685px -364px;width:48px;height:51px}.inventory_quest_scroll_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:-1685px -416px;width:48px;height:51px}.inventory_quest_scroll_frog{background-image:url(spritesmith-main-5.png);background-position:-1685px -468px;width:48px;height:51px}.inventory_quest_scroll_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-1685px -520px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-1685px -624px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -676px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-1685px -728px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -780px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:-1685px -884px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -936px;width:48px;height:51px}.inventory_quest_scroll_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1685px -988px;width:48px;height:51px}.inventory_quest_scroll_harpy{background-image:url(spritesmith-main-5.png);background-position:-1685px -1092px;width:48px;height:51px}.inventory_quest_scroll_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1685px -1144px;width:48px;height:51px}.inventory_quest_scroll_horse{background-image:url(spritesmith-main-5.png);background-position:-1685px -1248px;width:48px;height:51px}.inventory_quest_scroll_kraken{background-image:url(spritesmith-main-5.png);background-position:-1685px -1300px;width:48px;height:51px}.inventory_quest_scroll_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-1685px -1352px;width:48px;height:51px}.inventory_quest_scroll_moonstone1_locked{background-image:url(spritesmith-main-5.png);background-position:-539px -1666px;width:48px;height:51px}.inventory_quest_scroll_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-1627px -648px;width:48px;height:51px}.inventory_quest_scroll_moonstone2_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -700px;width:48px;height:51px}.inventory_quest_scroll_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-1627px -752px;width:48px;height:51px}.inventory_quest_scroll_moonstone3_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -804px;width:48px;height:51px}.inventory_quest_scroll_octopus{background-image:url(spritesmith-main-5.png);background-position:-1627px -856px;width:48px;height:51px}.inventory_quest_scroll_owl{background-image:url(spritesmith-main-5.png);background-position:-1627px -908px;width:48px;height:51px}.inventory_quest_scroll_penguin{background-image:url(spritesmith-main-5.png);background-position:-1627px -960px;width:48px;height:51px}.inventory_quest_scroll_rat{background-image:url(spritesmith-main-5.png);background-position:-1627px -1012px;width:48px;height:51px}.inventory_quest_scroll_rock{background-image:url(spritesmith-main-5.png);background-position:-1627px -1064px;width:48px;height:51px}.inventory_quest_scroll_rooster{background-image:url(spritesmith-main-5.png);background-position:-1627px -1116px;width:48px;height:51px}.inventory_quest_scroll_sheep{background-image:url(spritesmith-main-5.png);background-position:-1627px -1168px;width:48px;height:51px}.inventory_quest_scroll_slime{background-image:url(spritesmith-main-5.png);background-position:-1627px -1220px;width:48px;height:51px}.inventory_quest_scroll_snake{background-image:url(spritesmith-main-5.png);background-position:-1627px -1272px;width:48px;height:51px}.inventory_quest_scroll_spider{background-image:url(spritesmith-main-5.png);background-position:-1627px -1324px;width:48px;height:51px}.inventory_quest_scroll_trex{background-image:url(spritesmith-main-5.png);background-position:-1627px -1376px;width:48px;height:51px}.inventory_quest_scroll_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-1627px -1428px;width:48px;height:51px}.inventory_quest_scroll_vice1{background-image:url(spritesmith-main-5.png);background-position:-1627px -1480px;width:48px;height:51px}.inventory_quest_scroll_vice1_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -1532px;width:48px;height:51px}.inventory_quest_scroll_vice2{background-image:url(spritesmith-main-5.png);background-position:0 -1614px;width:48px;height:51px}.inventory_quest_scroll_vice2_locked{background-image:url(spritesmith-main-5.png);background-position:-49px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice3{background-image:url(spritesmith-main-5.png);background-position:-98px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice3_locked{background-image:url(spritesmith-main-5.png);background-position:-1734px -1456px;width:48px;height:51px}.inventory_quest_scroll_whale{background-image:url(spritesmith-main-5.png);background-position:-196px -1614px;width:48px;height:51px}.quest_TEMPLATE_FOR_MISSING_IMAGE{background-image:url(spritesmith-main-5.png);background-position:-1254px -1402px;width:221px;height:39px}.quest_atom1{background-image:url(spritesmith-main-5.png);background-position:-434px -1070px;width:250px;height:150px}.quest_atom2{background-image:url(spritesmith-main-5.png);background-position:-1322px -537px;width:207px;height:138px}.quest_atom3{background-image:url(spritesmith-main-5.png);background-position:0 -1070px;width:216px;height:180px}.quest_basilist{background-image:url(spritesmith-main-5.png);background-position:-1322px -1093px;width:189px;height:141px}.quest_bunny{background-image:url(spritesmith-main-5.png);background-position:-1100px -618px;width:210px;height:186px}.quest_cheetah{background-image:url(spritesmith-main-5.png);background-position:-440px -452px;width:219px;height:219px}.quest_dilatory{background-image:url(spritesmith-main-5.png);background-position:-220px -452px;width:219px;height:219px}.quest_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-1100px -845px;width:221px;height:39px}.quest_dilatoryDistress1_blueFins{background-image:url(spritesmith-main-5.png);background-position:-686px -1614px;width:51px;height:48px}.quest_dilatoryDistress1_fireCoral{background-image:url(spritesmith-main-5.png);background-position:-1685px 0;width:48px;height:51px}.quest_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:0 -1251px;width:150px;height:150px}.quest_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-440px 0;width:219px;height:219px}.quest_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-660px 0;width:219px;height:219px}.quest_egg{background-image:url(spritesmith-main-5.png);background-position:-810px -1402px;width:221px;height:39px}.quest_egg_plainEgg{background-image:url(spritesmith-main-5.png);background-position:-1685px -260px;width:48px;height:51px}.quest_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-1187px -1070px;width:118px;height:131px}.quest_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:0 -232px;width:219px;height:219px}.quest_frog{background-image:url(spritesmith-main-5.png);background-position:-1100px 0;width:221px;height:213px}.quest_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-220px -232px;width:219px;height:219px}.quest_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-1032px -1402px;width:221px;height:39px}.quest_goldenknight1_testimony{background-image:url(spritesmith-main-5.png);background-position:-1685px -572px;width:48px;height:51px}.quest_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-685px -1070px;width:250px;height:150px}.quest_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:0 0;width:219px;height:231px}.quest_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1091px -892px;width:216px;height:177px}.quest_harpy{background-image:url(spritesmith-main-5.png);background-position:-660px -220px;width:219px;height:219px}.quest_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1100px -431px;width:219px;height:186px}.quest_horse{background-image:url(spritesmith-main-5.png);background-position:0 -452px;width:219px;height:219px}.quest_kraken{background-image:url(spritesmith-main-5.png);background-position:-440px -892px;width:216px;height:177px}.quest_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-588px -1402px;width:221px;height:39px}.quest_moonstone1_moonstone{background-image:url(spritesmith-main-5.png);background-position:-1335px -1442px;width:30px;height:30px}.quest_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-220px 0;width:219px;height:219px}.quest_moonstone3{background-image:url(spritesmith-main-5.png);background-position:0 -672px;width:219px;height:219px}.quest_octopus{background-image:url(spritesmith-main-5.png);background-position:0 -892px;width:222px;height:177px}.quest_owl{background-image:url(spritesmith-main-5.png);background-position:-220px -672px;width:219px;height:219px}.quest_penguin{background-image:url(spritesmith-main-5.png);background-position:-1322px -353px;width:190px;height:183px}.quest_rat{background-image:url(spritesmith-main-5.png);background-position:-880px -672px;width:219px;height:219px}.quest_rock{background-image:url(spritesmith-main-5.png);background-position:-1100px -214px;width:216px;height:216px}.quest_rooster{background-image:url(spritesmith-main-5.png);background-position:-1322px 0;width:213px;height:174px}.quest_sheep{background-image:url(spritesmith-main-5.png);background-position:-660px -672px;width:219px;height:219px}.quest_slime{background-image:url(spritesmith-main-5.png);background-position:-440px -672px;width:219px;height:219px}.quest_snake{background-image:url(spritesmith-main-5.png);background-position:-874px -892px;width:216px;height:177px}.quest_spider{background-image:url(spritesmith-main-5.png);background-position:-936px -1070px;width:250px;height:150px}.quest_stressbeast{background-image:url(spritesmith-main-5.png);background-position:-880px -440px;width:219px;height:219px}.quest_stressbeast_bailey{background-image:url(spritesmith-main-5.png);background-position:-880px -220px;width:219px;height:219px}.quest_stressbeast_guide{background-image:url(spritesmith-main-5.png);background-position:-880px 0;width:219px;height:219px}.quest_stressbeast_stables{background-image:url(spritesmith-main-5.png);background-position:-660px -452px;width:219px;height:219px}.quest_trex{background-image:url(spritesmith-main-5.png);background-position:-1322px -175px;width:204px;height:177px}.quest_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-223px -892px;width:216px;height:177px}.quest_vice1{background-image:url(spritesmith-main-5.png);background-position:-657px -892px;width:216px;height:177px}.quest_vice2{background-image:url(spritesmith-main-5.png);background-position:-1100px -805px;width:221px;height:39px}.quest_vice2_lightCrystal{background-image:url(spritesmith-main-5.png);background-position:-1175px -1442px;width:40px;height:40px}.quest_vice3{background-image:url(spritesmith-main-5.png);background-position:-217px -1070px;width:216px;height:177px}.quest_whale{background-image:url(spritesmith-main-5.png);background-position:-440px -232px;width:219px;height:219px}.shop_copper{background-image:url(spritesmith-main-5.png);background-position:-467px -1221px;width:32px;height:22px}.shop_eyes{background-image:url(spritesmith-main-5.png);background-position:-1216px -1442px;width:40px;height:40px}.shop_gold{background-image:url(spritesmith-main-5.png);background-position:-1734px -1692px;width:32px;height:22px}.shop_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1257px -1442px;width:40px;height:40px}.shop_potion{background-image:url(spritesmith-main-5.png);background-position:-1093px -1442px;width:40px;height:40px}.shop_reroll{background-image:url(spritesmith-main-5.png);background-position:-1052px -1442px;width:40px;height:40px}.shop_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1594px -1456px;width:32px;height:32px}.shop_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1461px -1324px;width:32px;height:32px}.shop_silver{background-image:url(spritesmith-main-5.png);background-position:-434px -1221px;width:32px;height:22px}.shop_snowball{background-image:url(spritesmith-main-5.png);background-position:-1594px -1489px;width:32px;height:32px}.shop_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1494px -1324px;width:32px;height:32px}.Pet_Egg_BearCub{background-image:url(spritesmith-main-5.png);background-position:-1127px -1666px;width:48px;height:51px}.Pet_Egg_Bunny{background-image:url(spritesmith-main-5.png);background-position:-1176px -1666px;width:48px;height:51px}.Pet_Egg_Cactus{background-image:url(spritesmith-main-5.png);background-position:-1225px -1666px;width:48px;height:51px}.Pet_Egg_Cheetah{background-image:url(spritesmith-main-5.png);background-position:-1274px -1666px;width:48px;height:51px}.Pet_Egg_Cuttlefish{background-image:url(spritesmith-main-5.png);background-position:-1323px -1666px;width:48px;height:51px}.Pet_Egg_Deer{background-image:url(spritesmith-main-5.png);background-position:-1372px -1666px;width:48px;height:51px}.Pet_Egg_Dragon{background-image:url(spritesmith-main-5.png);background-position:-1421px -1666px;width:48px;height:51px}.Pet_Egg_Egg{background-image:url(spritesmith-main-5.png);background-position:-1470px -1666px;width:48px;height:51px}.Pet_Egg_FlyingPig{background-image:url(spritesmith-main-5.png);background-position:-1519px -1666px;width:48px;height:51px}.Pet_Egg_Fox{background-image:url(spritesmith-main-5.png);background-position:-1568px -1666px;width:48px;height:51px}.Pet_Egg_Frog{background-image:url(spritesmith-main-5.png);background-position:-1617px -1666px;width:48px;height:51px}.Pet_Egg_Gryphon{background-image:url(spritesmith-main-5.png);background-position:-1666px -1666px;width:48px;height:51px}.Pet_Egg_Hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1734px 0;width:48px;height:51px}.Pet_Egg_Horse{background-image:url(spritesmith-main-5.png);background-position:-1734px -52px;width:48px;height:51px}.Pet_Egg_LionCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -104px;width:48px;height:51px}.Pet_Egg_Octopus{background-image:url(spritesmith-main-5.png);background-position:-1734px -156px;width:48px;height:51px}.Pet_Egg_Owl{background-image:url(spritesmith-main-5.png);background-position:-1734px -208px;width:48px;height:51px}.Pet_Egg_PandaCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -260px;width:48px;height:51px}.Pet_Egg_Parrot{background-image:url(spritesmith-main-5.png);background-position:-1734px -312px;width:48px;height:51px}.Pet_Egg_Penguin{background-image:url(spritesmith-main-5.png);background-position:-1734px -364px;width:48px;height:51px}.Pet_Egg_PolarBear{background-image:url(spritesmith-main-5.png);background-position:-1734px -416px;width:48px;height:51px}.Pet_Egg_Rat{background-image:url(spritesmith-main-5.png);background-position:-1734px -468px;width:48px;height:51px}.Pet_Egg_Rock{background-image:url(spritesmith-main-5.png);background-position:-1734px -520px;width:48px;height:51px}.Pet_Egg_Rooster{background-image:url(spritesmith-main-5.png);background-position:-1734px -572px;width:48px;height:51px}.Pet_Egg_Seahorse{background-image:url(spritesmith-main-5.png);background-position:-1734px -624px;width:48px;height:51px}.Pet_Egg_Sheep{background-image:url(spritesmith-main-5.png);background-position:-1734px -676px;width:48px;height:51px}.Pet_Egg_Slime{background-image:url(spritesmith-main-5.png);background-position:-1734px -728px;width:48px;height:51px}.Pet_Egg_Snake{background-image:url(spritesmith-main-5.png);background-position:-1734px -780px;width:48px;height:51px}.Pet_Egg_Spider{background-image:url(spritesmith-main-5.png);background-position:-1734px -832px;width:48px;height:51px}.Pet_Egg_TRex{background-image:url(spritesmith-main-5.png);background-position:-1734px -884px;width:48px;height:51px}.Pet_Egg_TigerCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -936px;width:48px;height:51px}.Pet_Egg_Whale{background-image:url(spritesmith-main-5.png);background-position:-1734px -988px;width:48px;height:51px}.Pet_Egg_Wolf{background-image:url(spritesmith-main-5.png);background-position:-1734px -1040px;width:48px;height:51px}.Pet_Food_Cake_Base{background-image:url(spritesmith-main-5.png);background-position:-841px -1442px;width:43px;height:43px}.Pet_Food_Cake_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-738px -1614px;width:42px;height:44px}.Pet_Food_Cake_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1734px -1646px;width:43px;height:45px}.Pet_Food_Cake_Desert{background-image:url(spritesmith-main-5.png);background-position:-753px -1442px;width:43px;height:44px}.Pet_Food_Cake_Golden{background-image:url(spritesmith-main-5.png);background-position:-885px -1442px;width:43px;height:42px}.Pet_Food_Cake_Red{background-image:url(spritesmith-main-5.png);background-position:-797px -1442px;width:43px;height:44px}.Pet_Food_Cake_Shade{background-image:url(spritesmith-main-5.png);background-position:-709px -1442px;width:43px;height:44px}.Pet_Food_Cake_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1734px -1553px;width:42px;height:47px}.Pet_Food_Cake_White{background-image:url(spritesmith-main-5.png);background-position:-1734px -1601px;width:44px;height:44px}.Pet_Food_Cake_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1734px -1508px;width:45px;height:44px}.Pet_Food_Candy_Base{background-image:url(spritesmith-main-5.png);background-position:-1734px -1404px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1734px -1352px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1734px -1300px;width:48px;height:51px}.Pet_Food_Candy_Desert{background-image:url(spritesmith-main-5.png);background-position:-1734px -1248px;width:48px;height:51px}.Pet_Food_Candy_Golden{background-image:url(spritesmith-main-5.png);background-position:-1734px -1196px;width:48px;height:51px}.Pet_Food_Candy_Red{background-image:url(spritesmith-main-5.png);background-position:-1734px -1144px;width:48px;height:51px}.Pet_Food_Candy_Shade{background-image:url(spritesmith-main-5.png);background-position:-1734px -1092px;width:48px;height:51px}.Pet_Food_Candy_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1078px -1666px;width:48px;height:51px}.Pet_Food_Candy_White{background-image:url(spritesmith-main-5.png);background-position:-1029px -1666px;width:48px;height:51px}.Pet_Food_Candy_Zombie{background-image:url(spritesmith-main-5.png);background-position:-980px -1666px;width:48px;height:51px}.Pet_Food_Chocolate{background-image:url(spritesmith-main-5.png);background-position:-931px -1666px;width:48px;height:51px}.Pet_Food_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-882px -1666px;width:48px;height:51px}.Pet_Food_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-833px -1666px;width:48px;height:51px}.Pet_Food_Fish{background-image:url(spritesmith-main-5.png);background-position:-784px -1666px;width:48px;height:51px}.Pet_Food_Honey{background-image:url(spritesmith-main-5.png);background-position:-735px -1666px;width:48px;height:51px}.Pet_Food_Meat{background-image:url(spritesmith-main-5.png);background-position:-686px -1666px;width:48px;height:51px}.Pet_Food_Milk{background-image:url(spritesmith-main-5.png);background-position:-637px -1666px;width:48px;height:51px}.Pet_Food_Potatoe{background-image:url(spritesmith-main-5.png);background-position:-588px -1666px;width:48px;height:51px}.Pet_Food_RottenMeat{background-image:url(spritesmith-main-5.png);background-position:-441px -1666px;width:48px;height:51px}.Pet_Food_Saddle{background-image:url(spritesmith-main-5.png);background-position:-1685px -1196px;width:48px;height:51px}.Pet_Food_Strawberry{background-image:url(spritesmith-main-5.png);background-position:-1685px -1040px;width:48px;height:51px}.Mount_Body_BearCub-Base{background-image:url(spritesmith-main-5.png);background-position:-613px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1355px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1249px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Desert{background-image:url(spritesmith-main-5.png);background-position:-1143px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Golden{background-image:url(spritesmith-main-5.png);background-position:-1037px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Polar{background-image:url(spritesmith-main-5.png);background-position:-931px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Red{background-image:url(spritesmith-main-5.png);background-position:-825px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Shade{background-image:url(spritesmith-main-5.png);background-position:-719px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -575px;width:105px;height:105px}.Mount_Body_BearCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-318px -1105px;width:105px;height:105px}.Mount_Body_BearCub-White{background-image:url(spritesmith-main-6.png);background-position:-212px -575px;width:105px;height:105px}.Mount_Body_BearCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -575px;width:105px;height:105px}.Mount_Body_Bunny-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -575px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -575px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -575px;width:105px;height:105px}.Mount_Body_Bunny-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px 0;width:105px;height:105px}.Mount_Body_Bunny-Golden{background-image:url(spritesmith-main-6.png);background-position:-742px -106px;width:105px;height:105px}.Mount_Body_Bunny-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -212px;width:105px;height:105px}.Mount_Body_Bunny-Shade{background-image:url(spritesmith-main-6.png);background-position:-742px -318px;width:105px;height:105px}.Mount_Body_Bunny-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -999px;width:105px;height:105px}.Mount_Body_Bunny-White{background-image:url(spritesmith-main-6.png);background-position:-106px -999px;width:105px;height:105px}.Mount_Body_Bunny-Zombie{background-image:url(spritesmith-main-6.png);background-position:-212px -999px;width:105px;height:105px}.Mount_Body_Cactus-Base{background-image:url(spritesmith-main-6.png);background-position:-318px -999px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-424px -999px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-530px -999px;width:105px;height:105px}.Mount_Body_Cactus-Desert{background-image:url(spritesmith-main-6.png);background-position:-636px -999px;width:105px;height:105px}.Mount_Body_Cactus-Golden{background-image:url(spritesmith-main-6.png);background-position:-742px -999px;width:105px;height:105px}.Mount_Body_Cactus-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -999px;width:105px;height:105px}.Mount_Body_Cactus-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -999px;width:105px;height:105px}.Mount_Body_Cactus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -1211px;width:105px;height:105px}.Mount_Body_Cactus-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1060px -1211px;width:105px;height:105px}.Mount_Body_Cactus-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -1211px;width:105px;height:105px}.Mount_Body_Cactus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-530px -221px;width:105px;height:105px}.Mount_Body_Cheetah-Base{background-image:url(spritesmith-main-6.png);background-position:-530px -327px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-221px -469px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-327px -469px;width:105px;height:105px}.Mount_Body_Cheetah-Desert{background-image:url(spritesmith-main-6.png);background-position:-433px -469px;width:105px;height:105px}.Mount_Body_Cheetah-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px 0;width:105px;height:105px}.Mount_Body_Cheetah-Red{background-image:url(spritesmith-main-6.png);background-position:-636px -106px;width:105px;height:105px}.Mount_Body_Cheetah-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -212px;width:105px;height:105px}.Mount_Body_Cheetah-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -318px;width:105px;height:105px}.Mount_Body_Cheetah-White{background-image:url(spritesmith-main-6.png);background-position:-636px -424px;width:105px;height:105px}.Mount_Body_Cheetah-Zombie{background-image:url(spritesmith-main-6.png);background-position:0 -575px;width:105px;height:105px}.Mount_Body_Cuttlefish-Base{background-image:url(spritesmith-main-6.png);background-position:-530px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -115px;width:105px;height:114px}.Mount_Body_Cuttlefish-White{background-image:url(spritesmith-main-6.png);background-position:0 -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -239px;width:105px;height:114px}.Mount_Body_Deer-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -424px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-742px -530px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -681px;width:105px;height:105px}.Mount_Body_Deer-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -681px;width:105px;height:105px}.Mount_Body_Deer-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -681px;width:105px;height:105px}.Mount_Body_Deer-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -681px;width:105px;height:105px}.Mount_Body_Deer-Shade{background-image:url(spritesmith-main-6.png);background-position:-424px -681px;width:105px;height:105px}.Mount_Body_Deer-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -681px;width:105px;height:105px}.Mount_Body_Deer-White{background-image:url(spritesmith-main-6.png);background-position:-636px -681px;width:105px;height:105px}.Mount_Body_Deer-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -681px;width:105px;height:105px}.Mount_Body_Dragon-Base{background-image:url(spritesmith-main-6.png);background-position:-848px 0;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -106px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-848px -212px;width:105px;height:105px}.Mount_Body_Dragon-Desert{background-image:url(spritesmith-main-6.png);background-position:-848px -318px;width:105px;height:105px}.Mount_Body_Dragon-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -424px;width:105px;height:105px}.Mount_Body_Dragon-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -530px;width:105px;height:105px}.Mount_Body_Dragon-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -636px;width:105px;height:105px}.Mount_Body_Dragon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -787px;width:105px;height:105px}.Mount_Body_Dragon-Spooky{background-image:url(spritesmith-main-6.png);background-position:-106px -787px;width:105px;height:105px}.Mount_Body_Dragon-White{background-image:url(spritesmith-main-6.png);background-position:-212px -787px;width:105px;height:105px}.Mount_Body_Dragon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -787px;width:105px;height:105px}.Mount_Body_Egg-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -787px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -787px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -787px;width:105px;height:105px}.Mount_Body_Egg-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -787px;width:105px;height:105px}.Mount_Body_Egg-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -787px;width:105px;height:105px}.Mount_Body_Egg-Red{background-image:url(spritesmith-main-6.png);background-position:-954px 0;width:105px;height:105px}.Mount_Body_Egg-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -106px;width:105px;height:105px}.Mount_Body_Egg-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -212px;width:105px;height:105px}.Mount_Body_Egg-White{background-image:url(spritesmith-main-6.png);background-position:-954px -318px;width:105px;height:105px}.Mount_Body_Egg-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -424px;width:105px;height:105px}.Mount_Body_FlyingPig-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -530px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -636px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -742px;width:105px;height:105px}.Mount_Body_FlyingPig-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-424px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Spooky{background-image:url(spritesmith-main-6.png);background-position:-530px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-White{background-image:url(spritesmith-main-6.png);background-position:-636px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -893px;width:105px;height:105px}.Mount_Body_Fox-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -893px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -893px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px 0;width:105px;height:105px}.Mount_Body_Fox-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -106px;width:105px;height:105px}.Mount_Body_Fox-Golden{background-image:url(spritesmith-main-6.png);background-position:-1060px -212px;width:105px;height:105px}.Mount_Body_Fox-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px -318px;width:105px;height:105px}.Mount_Body_Fox-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -424px;width:105px;height:105px}.Mount_Body_Fox-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1060px -530px;width:105px;height:105px}.Mount_Body_Fox-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1060px -636px;width:105px;height:105px}.Mount_Body_Fox-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -742px;width:105px;height:105px}.Mount_Body_Fox-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1060px -848px;width:105px;height:105px}.Mount_Body_Frog-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -239px;width:105px;height:114px}.Mount_Body_Frog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px 0;width:105px;height:114px}.Mount_Body_Frog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px 0;width:105px;height:114px}.Mount_Body_Frog-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -115px;width:105px;height:114px}.Mount_Body_Frog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -230px;width:105px;height:114px}.Mount_Body_Frog-Red{background-image:url(spritesmith-main-6.png);background-position:0 -354px;width:105px;height:114px}.Mount_Body_Frog-Shade{background-image:url(spritesmith-main-6.png);background-position:-106px -354px;width:105px;height:114px}.Mount_Body_Frog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-212px -354px;width:105px;height:114px}.Mount_Body_Frog-White{background-image:url(spritesmith-main-6.png);background-position:-318px -354px;width:105px;height:114px}.Mount_Body_Frog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-424px -354px;width:105px;height:114px}.Mount_Body_Gryphon-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -999px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px 0;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px -106px;width:105px;height:105px}.Mount_Body_Gryphon-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -212px;width:105px;height:105px}.Mount_Body_Gryphon-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -318px;width:105px;height:105px}.Mount_Body_Gryphon-Red{background-image:url(spritesmith-main-6.png);background-position:-1166px -424px;width:105px;height:105px}.Mount_Body_Gryphon-RoyalPurple{background-image:url(spritesmith-main-6.png);background-position:-1166px -530px;width:105px;height:105px}.Mount_Body_Gryphon-Shade{background-image:url(spritesmith-main-6.png);background-position:-1166px -636px;width:105px;height:105px}.Mount_Body_Gryphon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px -742px;width:105px;height:105px}.Mount_Body_Gryphon-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -848px;width:105px;height:105px}.Mount_Body_Gryphon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -954px;width:105px;height:105px}.Mount_Body_Hedgehog-Base{background-image:url(spritesmith-main-6.png);background-position:0 -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -115px;width:105px;height:105px}.Mount_Body_Hedgehog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Red{background-image:url(spritesmith-main-6.png);background-position:-530px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-742px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -1105px;width:105px;height:105px}.Mount_Body_Horse-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -1105px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px -1105px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1272px 0;width:105px;height:105px}.Mount_Body_Horse-Desert{background-image:url(spritesmith-main-6.png);background-position:-1272px -106px;width:105px;height:105px}.Mount_Body_Horse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -212px;width:105px;height:105px}.Mount_Body_Horse-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -318px;width:105px;height:105px}.Mount_Body_Horse-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -424px;width:105px;height:105px}.Mount_Body_Horse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1272px -530px;width:105px;height:105px}.Mount_Body_Horse-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -636px;width:105px;height:105px}.Mount_Body_Horse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -742px;width:105px;height:105px}.Mount_Body_JackOLantern-Base{background-image:url(spritesmith-main-6.png);background-position:-1696px -530px;width:90px;height:105px}.Mount_Body_LionCub-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -954px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -1060px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -1211px;width:105px;height:105px}.Mount_Body_LionCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Ethereal{background-image:url(spritesmith-main-6.png);background-position:-212px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-318px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Red{background-image:url(spritesmith-main-6.png);background-position:-424px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -469px;width:111px;height:105px}.Mount_Body_LionCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-742px -1211px;width:105px;height:105px}.Mount_Body_LionCub-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -1211px;width:105px;height:105px}.Mount_Body_Mammoth-Base{background-image:url(spritesmith-main-6.png);background-position:0 0;width:105px;height:123px}.Mount_Body_MantisShrimp-Base{background-image:url(spritesmith-main-6.png);background-position:-112px -469px;width:108px;height:105px}.Mount_Body_Octopus-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1211px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px 0;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -106px;width:105px;height:105px}.Mount_Body_Octopus-Desert{background-image:url(spritesmith-main-6.png);background-position:-1378px -212px;width:105px;height:105px}.Mount_Body_Octopus-Golden{background-image:url(spritesmith-main-6.png);background-position:-1378px -318px;width:105px;height:105px}.Mount_Body_Octopus-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -424px;width:105px;height:105px}.Mount_Body_Octopus-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -530px;width:105px;height:105px}.Mount_Body_Octopus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1378px -636px;width:105px;height:105px}.Mount_Body_Octopus-White{background-image:url(spritesmith-main-6.png);background-position:-1378px -742px;width:105px;height:105px}.Mount_Body_Octopus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -848px;width:105px;height:105px}.Mount_Body_Orca-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -954px;width:105px;height:105px}.Mount_Body_Owl-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -1060px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -1166px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -1317px;width:105px;height:105px}.Mount_Body_Owl-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -1317px;width:105px;height:105px}.Mount_Body_Owl-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -1317px;width:105px;height:105px}.Mount_Body_Owl-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -1317px;width:105px;height:105px}.Mount_Body_Owl-Shade{background-image:url(spritesmith-main-6.png);background-position:-424px -1317px;width:105px;height:105px}.Mount_Body_Owl-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -1317px;width:105px;height:105px}.Mount_Body_Owl-White{background-image:url(spritesmith-main-6.png);background-position:-636px -1317px;width:105px;height:105px}.Mount_Body_Owl-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px 0;width:105px;height:105px}.Mount_Body_PandaCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -106px;width:105px;height:105px}.Mount_Body_PandaCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1484px -212px;width:105px;height:105px}.Mount_Body_PandaCub-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -318px;width:105px;height:105px}.Mount_Body_PandaCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -424px;width:105px;height:105px}.Mount_Body_Parrot-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -530px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -636px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px -742px;width:105px;height:105px}.Mount_Body_Parrot-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -848px;width:105px;height:105px}.Mount_Body_Parrot-Golden{background-image:url(spritesmith-main-6.png);background-position:-1484px -954px;width:105px;height:105px}.Mount_Body_Parrot-Red{background-image:url(spritesmith-main-6.png);background-position:-1484px -1060px;width:105px;height:105px}.Mount_Body_Parrot-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px -1166px;width:105px;height:105px}.Mount_Body_Parrot-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -1272px;width:105px;height:105px}.Mount_Body_Parrot-White{background-image:url(spritesmith-main-6.png);background-position:0 -1423px;width:105px;height:105px}.Mount_Body_Parrot-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -1423px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -1423px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -1423px;width:105px;height:105px}.Mount_Body_Penguin-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -1423px;width:105px;height:105px}.Mount_Body_Phoenix-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1423px;width:105px;height:105px}.Mount_Body_Rat-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px 0;width:105px;height:105px}.Mount_Body_Rat-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -106px;width:105px;height:105px}.Mount_Body_Rat-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -212px;width:105px;height:105px}.Mount_Body_Rat-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -318px;width:105px;height:105px}.Mount_Body_Rat-Shade{background-image:url(spritesmith-main-6.png);background-position:-1590px -424px;width:105px;height:105px}.Mount_Body_Rat-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1590px -530px;width:105px;height:105px}.Mount_Body_Rat-White{background-image:url(spritesmith-main-6.png);background-position:-1590px -636px;width:105px;height:105px}.Mount_Body_Rat-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1590px -742px;width:105px;height:105px}.Mount_Body_Rock-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px -848px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -954px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px -1060px;width:105px;height:105px}.Mount_Body_Rock-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -1166px;width:105px;height:105px}.Mount_Body_Rock-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -1272px;width:105px;height:105px}.Mount_Body_Rock-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -1378px;width:105px;height:105px}.Mount_Body_Rock-Shade{background-image:url(spritesmith-main-6.png);background-position:0 -1529px;width:105px;height:105px}.Mount_Body_Rock-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -1529px;width:105px;height:105px}.Mount_Body_Rock-White{background-image:url(spritesmith-main-6.png);background-position:-212px -1529px;width:105px;height:105px}.Mount_Body_Rock-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -1529px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -1529px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Red{background-image:url(spritesmith-main-6.png);background-position:-954px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px -1529px;width:105px;height:105px}.Mount_Body_Rooster-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1696px 0;width:105px;height:105px}.Mount_Body_Seahorse-Desert{background-image:url(spritesmith-main-6.png);background-position:-1696px -106px;width:105px;height:105px}.Mount_Body_Seahorse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1696px -212px;width:105px;height:105px}.Mount_Body_Seahorse-Red{background-image:url(spritesmith-main-6.png);background-position:-1696px -318px;width:105px;height:105px}.Mount_Body_Seahorse-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -848px;width:105px;height:105px}.Mount_Body_Seahorse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1696px -424px;width:105px;height:105px}.Mount_Body_Seahorse-White{background-image:url(spritesmith-main-7.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Body_Seahorse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-848px -1113px;width:105px;height:105px}.Mount_Body_Sheep-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -680px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-892px 0;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Body_Sheep-Desert{background-image:url(spritesmith-main-7.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Body_Sheep-Golden{background-image:url(spritesmith-main-7.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Body_Sheep-Red{background-image:url(spritesmith-main-7.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Body_Sheep-Shade{background-image:url(spritesmith-main-7.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Body_Sheep-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Body_Sheep-White{background-image:url(spritesmith-main-7.png);background-position:0 -795px;width:105px;height:105px}.Mount_Body_Sheep-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -901px;width:105px;height:105px}.Mount_Body_Slime-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -901px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -901px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -901px;width:105px;height:105px}.Mount_Body_Slime-Desert{background-image:url(spritesmith-main-7.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Body_Slime-Golden{background-image:url(spritesmith-main-7.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Body_Slime-Red{background-image:url(spritesmith-main-7.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Body_Slime-Shade{background-image:url(spritesmith-main-7.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Body_Slime-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Body_Slime-White{background-image:url(spritesmith-main-7.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Body_Slime-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Body_Snake-Base{background-image:url(spritesmith-main-7.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Body_Snake-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Body_Snake-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Body_Snake-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -1219px;width:105px;height:105px}.Mount_Body_Snake-Golden{background-image:url(spritesmith-main-7.png);background-position:-106px -1219px;width:105px;height:105px}.Mount_Body_Snake-Red{background-image:url(spritesmith-main-7.png);background-position:-212px -1219px;width:105px;height:105px}.Mount_Body_Snake-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1219px;width:105px;height:105px}.Mount_Body_Snake-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1219px;width:105px;height:105px}.Mount_Body_Snake-White{background-image:url(spritesmith-main-7.png);background-position:-530px -1219px;width:105px;height:105px}.Mount_Body_Snake-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -1219px;width:105px;height:105px}.Mount_Body_Spider-Base{background-image:url(spritesmith-main-7.png);background-position:-848px -1431px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-954px -1431px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1060px -1431px;width:105px;height:105px}.Mount_Body_Spider-Desert{background-image:url(spritesmith-main-7.png);background-position:-1166px -1431px;width:105px;height:105px}.Mount_Body_Spider-Golden{background-image:url(spritesmith-main-7.png);background-position:-1272px -1431px;width:105px;height:105px}.Mount_Body_Spider-Red{background-image:url(spritesmith-main-7.png);background-position:-1378px -1431px;width:105px;height:105px}.Mount_Body_Spider-Shade{background-image:url(spritesmith-main-7.png);background-position:-1484px -1431px;width:105px;height:105px}.Mount_Body_Spider-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px 0;width:105px;height:105px}.Mount_Body_Spider-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -106px;width:105px;height:105px}.Mount_Body_Spider-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -212px;width:105px;height:105px}.Mount_Body_TRex-Base{background-image:url(spritesmith-main-7.png);background-position:0 -544px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -136px;width:135px;height:135px}.Mount_Body_TRex-Desert{background-image:url(spritesmith-main-7.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Body_TRex-Golden{background-image:url(spritesmith-main-7.png);background-position:-272px 0;width:135px;height:135px}.Mount_Body_TRex-Red{background-image:url(spritesmith-main-7.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Body_TRex-Shade{background-image:url(spritesmith-main-7.png);background-position:0 -272px;width:135px;height:135px}.Mount_Body_TRex-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Body_TRex-White{background-image:url(spritesmith-main-7.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Body_TRex-Zombie{background-image:url(spritesmith-main-7.png);background-position:-408px 0;width:135px;height:135px}.Mount_Body_TigerCub-Base{background-image:url(spritesmith-main-7.png);background-position:-106px -795px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-212px -795px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-318px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-424px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-530px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Red{background-image:url(spritesmith-main-7.png);background-position:-636px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-742px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-848px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-998px 0;width:105px;height:105px}.Mount_Body_TigerCub-White{background-image:url(spritesmith-main-7.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Body_TigerCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Body_Turkey-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Body_Whale-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Body_Whale-Desert{background-image:url(spritesmith-main-7.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Body_Whale-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -901px;width:105px;height:105px}.Mount_Body_Whale-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -901px;width:105px;height:105px}.Mount_Body_Whale-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -901px;width:105px;height:105px}.Mount_Body_Whale-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -901px;width:105px;height:105px}.Mount_Body_Whale-White{background-image:url(spritesmith-main-7.png);background-position:-424px -901px;width:105px;height:105px}.Mount_Body_Whale-Zombie{background-image:url(spritesmith-main-7.png);background-position:-530px -901px;width:105px;height:105px}.Mount_Body_Wolf-Base{background-image:url(spritesmith-main-7.png);background-position:0 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -408px;width:135px;height:135px}.Mount_Body_Wolf-Desert{background-image:url(spritesmith-main-7.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Body_Wolf-Golden{background-image:url(spritesmith-main-7.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Body_Wolf-Red{background-image:url(spritesmith-main-7.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Body_Wolf-Shade{background-image:url(spritesmith-main-7.png);background-position:-544px 0;width:135px;height:135px}.Mount_Body_Wolf-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Body_Wolf-Spooky{background-image:url(spritesmith-main-7.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Body_Wolf-White{background-image:url(spritesmith-main-7.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Body_Wolf-Zombie{background-image:url(spritesmith-main-7.png);background-position:-136px 0;width:135px;height:135px}.Mount_Head_BearCub-Base{background-image:url(spritesmith-main-7.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -1007px;width:105px;height:105px}.Mount_Head_BearCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-106px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-212px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Polar{background-image:url(spritesmith-main-7.png);background-position:-318px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Red{background-image:url(spritesmith-main-7.png);background-position:-424px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-530px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-742px -1007px;width:105px;height:105px}.Mount_Head_BearCub-White{background-image:url(spritesmith-main-7.png);background-position:-848px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1007px;width:105px;height:105px}.Mount_Head_Bunny-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1007px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Bunny-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Bunny-Golden{background-image:url(spritesmith-main-7.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Bunny-Red{background-image:url(spritesmith-main-7.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Bunny-Shade{background-image:url(spritesmith-main-7.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Bunny-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Bunny-White{background-image:url(spritesmith-main-7.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Bunny-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Cactus-Base{background-image:url(spritesmith-main-7.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 -1113px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-106px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Desert{background-image:url(spritesmith-main-7.png);background-position:-212px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Golden{background-image:url(spritesmith-main-7.png);background-position:-318px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Red{background-image:url(spritesmith-main-7.png);background-position:-424px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Shade{background-image:url(spritesmith-main-7.png);background-position:-530px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Spooky{background-image:url(spritesmith-main-7.png);background-position:-742px -1113px;width:105px;height:105px}.Mount_Head_Cactus-White{background-image:url(spritesmith-main-7.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_Cactus-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Cheetah-Desert{background-image:url(spritesmith-main-7.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Cheetah-Golden{background-image:url(spritesmith-main-7.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Cheetah-Red{background-image:url(spritesmith-main-7.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Cheetah-Shade{background-image:url(spritesmith-main-7.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Cheetah-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Cheetah-White{background-image:url(spritesmith-main-7.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Cheetah-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Cuttlefish-Base{background-image:url(spritesmith-main-7.png);background-position:-242px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-348px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-454px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Desert{background-image:url(spritesmith-main-7.png);background-position:-560px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Golden{background-image:url(spritesmith-main-7.png);background-position:-680px 0;width:105px;height:114px}.Mount_Head_Cuttlefish-Red{background-image:url(spritesmith-main-7.png);background-position:-680px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Shade{background-image:url(spritesmith-main-7.png);background-position:-680px -230px;width:105px;height:114px}.Mount_Head_Cuttlefish-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-680px -345px;width:105px;height:114px}.Mount_Head_Cuttlefish-White{background-image:url(spritesmith-main-7.png);background-position:-680px -460px;width:105px;height:114px}.Mount_Head_Cuttlefish-Zombie{background-image:url(spritesmith-main-7.png);background-position:-786px 0;width:105px;height:114px}.Mount_Head_Deer-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -1219px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -1219px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -1219px;width:105px;height:105px}.Mount_Head_Deer-Desert{background-image:url(spritesmith-main-7.png);background-position:-1060px -1219px;width:105px;height:105px}.Mount_Head_Deer-Golden{background-image:url(spritesmith-main-7.png);background-position:-1166px -1219px;width:105px;height:105px}.Mount_Head_Deer-Red{background-image:url(spritesmith-main-7.png);background-position:-1272px -1219px;width:105px;height:105px}.Mount_Head_Deer-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_Deer-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_Deer-White{background-image:url(spritesmith-main-7.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_Deer-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_Dragon-Base{background-image:url(spritesmith-main-7.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_Dragon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_Dragon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_Dragon-Red{background-image:url(spritesmith-main-7.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_Dragon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_Dragon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Dragon-Spooky{background-image:url(spritesmith-main-7.png);background-position:0 -1325px;width:105px;height:105px}.Mount_Head_Dragon-White{background-image:url(spritesmith-main-7.png);background-position:-106px -1325px;width:105px;height:105px}.Mount_Head_Dragon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-212px -1325px;width:105px;height:105px}.Mount_Head_Egg-Base{background-image:url(spritesmith-main-7.png);background-position:-318px -1325px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-424px -1325px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-530px -1325px;width:105px;height:105px}.Mount_Head_Egg-Desert{background-image:url(spritesmith-main-7.png);background-position:-636px -1325px;width:105px;height:105px}.Mount_Head_Egg-Golden{background-image:url(spritesmith-main-7.png);background-position:-742px -1325px;width:105px;height:105px}.Mount_Head_Egg-Red{background-image:url(spritesmith-main-7.png);background-position:-848px -1325px;width:105px;height:105px}.Mount_Head_Egg-Shade{background-image:url(spritesmith-main-7.png);background-position:-954px -1325px;width:105px;height:105px}.Mount_Head_Egg-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1060px -1325px;width:105px;height:105px}.Mount_Head_Egg-White{background-image:url(spritesmith-main-7.png);background-position:-1166px -1325px;width:105px;height:105px}.Mount_Head_Egg-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1272px -1325px;width:105px;height:105px}.Mount_Head_FlyingPig-Base{background-image:url(spritesmith-main-7.png);background-position:-1378px -1325px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Head_FlyingPig-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Head_FlyingPig-Golden{background-image:url(spritesmith-main-7.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Head_FlyingPig-Red{background-image:url(spritesmith-main-7.png);background-position:-1528px -424px;width:105px;height:105px}.Mount_Head_FlyingPig-Shade{background-image:url(spritesmith-main-7.png);background-position:-1528px -530px;width:105px;height:105px}.Mount_Head_FlyingPig-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1528px -636px;width:105px;height:105px}.Mount_Head_FlyingPig-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1528px -742px;width:105px;height:105px}.Mount_Head_FlyingPig-White{background-image:url(spritesmith-main-7.png);background-position:-1528px -848px;width:105px;height:105px}.Mount_Head_FlyingPig-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1528px -954px;width:105px;height:105px}.Mount_Head_Fox-Base{background-image:url(spritesmith-main-7.png);background-position:-1528px -1060px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px -1166px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -1272px;width:105px;height:105px}.Mount_Head_Fox-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -1431px;width:105px;height:105px}.Mount_Head_Fox-Golden{background-image:url(spritesmith-main-7.png);background-position:-106px -1431px;width:105px;height:105px}.Mount_Head_Fox-Red{background-image:url(spritesmith-main-7.png);background-position:-212px -1431px;width:105px;height:105px}.Mount_Head_Fox-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1431px;width:105px;height:105px}.Mount_Head_Fox-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1431px;width:105px;height:105px}.Mount_Head_Fox-Spooky{background-image:url(spritesmith-main-7.png);background-position:-530px -1431px;width:105px;height:105px}.Mount_Head_Fox-White{background-image:url(spritesmith-main-7.png);background-position:-636px -1431px;width:105px;height:105px}.Mount_Head_Fox-Zombie{background-image:url(spritesmith-main-7.png);background-position:-742px -1431px;width:105px;height:105px}.Mount_Head_Frog-Base{background-image:url(spritesmith-main-7.png);background-position:-786px -115px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-786px -230px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -345px;width:105px;height:114px}.Mount_Head_Frog-Desert{background-image:url(spritesmith-main-7.png);background-position:-786px -460px;width:105px;height:114px}.Mount_Head_Frog-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -680px;width:105px;height:114px}.Mount_Head_Frog-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -680px;width:105px;height:114px}.Mount_Head_Frog-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -680px;width:105px;height:114px}.Mount_Head_Frog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -680px;width:105px;height:114px}.Mount_Head_Frog-White{background-image:url(spritesmith-main-7.png);background-position:-424px -680px;width:105px;height:114px}.Mount_Head_Frog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-136px -544px;width:105px;height:114px}.Mount_Head_Gryphon-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -318px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1634px -424px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1634px -530px;width:105px;height:105px}.Mount_Head_Gryphon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1634px -636px;width:105px;height:105px}.Mount_Head_Gryphon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1634px -742px;width:105px;height:105px}.Mount_Head_Gryphon-Red{background-image:url(spritesmith-main-7.png);background-position:-1634px -848px;width:105px;height:105px}.Mount_Head_Gryphon-RoyalPurple{background-image:url(spritesmith-main-7.png);background-position:-1634px -954px;width:105px;height:105px}.Mount_Head_Gryphon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -1060px;width:105px;height:105px}.Mount_Head_Gryphon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px -1166px;width:105px;height:105px}.Mount_Head_Gryphon-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -1272px;width:105px;height:105px}.Mount_Head_Gryphon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -1378px;width:105px;height:105px}.Mount_Head_Hedgehog-Base{background-image:url(spritesmith-main-7.png);background-position:0 -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-106px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-212px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Desert{background-image:url(spritesmith-main-7.png);background-position:-318px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Golden{background-image:url(spritesmith-main-7.png);background-position:-424px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Red{background-image:url(spritesmith-main-7.png);background-position:-530px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Shade{background-image:url(spritesmith-main-7.png);background-position:-636px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-742px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-White{background-image:url(spritesmith-main-7.png);background-position:-848px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1537px;width:105px;height:105px}.Mount_Head_Horse-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1537px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1537px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1272px -1537px;width:105px;height:105px}.Mount_Head_Horse-Desert{background-image:url(spritesmith-main-7.png);background-position:-1378px -1537px;width:105px;height:105px}.Mount_Head_Horse-Golden{background-image:url(spritesmith-main-7.png);background-position:-1484px -1537px;width:105px;height:105px}.Mount_Head_Horse-Red{background-image:url(spritesmith-main-7.png);background-position:-1590px -1537px;width:105px;height:105px}.Mount_Head_Horse-Shade{background-image:url(spritesmith-main-7.png);background-position:-1740px 0;width:105px;height:105px}.Mount_Head_Horse-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1740px -106px;width:105px;height:105px}.Mount_Head_Horse-White{background-image:url(spritesmith-main-7.png);background-position:-1740px -212px;width:105px;height:105px}.Mount_Head_Horse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1740px -318px;width:105px;height:105px}.Mount_Head_JackOLantern-Base{background-image:url(spritesmith-main-7.png);background-position:-1740px -424px;width:90px;height:105px}.Mount_Head_LionCub-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -1316px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-318px -1210px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Ethereal{background-image:url(spritesmith-main-8.png);background-position:-106px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-212px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Red{background-image:url(spritesmith-main-8.png);background-position:-318px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-424px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-242px -544px;width:105px;height:110px}.Mount_Head_LionCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-636px -1316px;width:105px;height:105px}.Mount_Head_LionCub-White{background-image:url(spritesmith-main-8.png);background-position:-742px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-848px -1316px;width:105px;height:105px}.Mount_Head_Mammoth-Base{background-image:url(spritesmith-main-8.png);background-position:-136px -544px;width:105px;height:123px}.Mount_Head_MantisShrimp-Base{background-image:url(spritesmith-main-8.png);background-position:-348px -544px;width:108px;height:105px}.Mount_Head_Octopus-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -1422px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -1422px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1166px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Red{background-image:url(spritesmith-main-8.png);background-position:-1272px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1378px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Head_Octopus-White{background-image:url(spritesmith-main-8.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Head_Octopus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Head_Orca-Base{background-image:url(spritesmith-main-8.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Head_Owl-Base{background-image:url(spritesmith-main-8.png);background-position:-563px -544px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-680px 0;width:105px;height:105px}.Mount_Head_Owl-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-680px -106px;width:105px;height:105px}.Mount_Head_Owl-Desert{background-image:url(spritesmith-main-8.png);background-position:-680px -212px;width:105px;height:105px}.Mount_Head_Owl-Golden{background-image:url(spritesmith-main-8.png);background-position:-680px -318px;width:105px;height:105px}.Mount_Head_Owl-Red{background-image:url(spritesmith-main-8.png);background-position:-680px -424px;width:105px;height:105px}.Mount_Head_Owl-Shade{background-image:url(spritesmith-main-8.png);background-position:-680px -530px;width:105px;height:105px}.Mount_Head_Owl-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -680px;width:105px;height:105px}.Mount_Head_Owl-White{background-image:url(spritesmith-main-8.png);background-position:-106px -680px;width:105px;height:105px}.Mount_Head_Owl-Zombie{background-image:url(spritesmith-main-8.png);background-position:-212px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -680px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -680px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-786px 0;width:105px;height:105px}.Mount_Head_PandaCub-Red{background-image:url(spritesmith-main-8.png);background-position:-786px -106px;width:105px;height:105px}.Mount_Head_PandaCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-786px -212px;width:105px;height:105px}.Mount_Head_PandaCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-786px -318px;width:105px;height:105px}.Mount_Head_PandaCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-786px -424px;width:105px;height:105px}.Mount_Head_PandaCub-White{background-image:url(spritesmith-main-8.png);background-position:-786px -530px;width:105px;height:105px}.Mount_Head_PandaCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-786px -636px;width:105px;height:105px}.Mount_Head_Parrot-Base{background-image:url(spritesmith-main-8.png);background-position:0 -786px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-106px -786px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-212px -786px;width:105px;height:105px}.Mount_Head_Parrot-Desert{background-image:url(spritesmith-main-8.png);background-position:-318px -786px;width:105px;height:105px}.Mount_Head_Parrot-Golden{background-image:url(spritesmith-main-8.png);background-position:-424px -786px;width:105px;height:105px}.Mount_Head_Parrot-Red{background-image:url(spritesmith-main-8.png);background-position:-530px -786px;width:105px;height:105px}.Mount_Head_Parrot-Shade{background-image:url(spritesmith-main-8.png);background-position:-636px -786px;width:105px;height:105px}.Mount_Head_Parrot-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-742px -786px;width:105px;height:105px}.Mount_Head_Parrot-White{background-image:url(spritesmith-main-8.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Parrot-Zombie{background-image:url(spritesmith-main-8.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Head_Penguin-Base{background-image:url(spritesmith-main-8.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Head_Penguin-Desert{background-image:url(spritesmith-main-8.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Head_Penguin-Golden{background-image:url(spritesmith-main-8.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Head_Penguin-Red{background-image:url(spritesmith-main-8.png);background-position:-892px -742px;width:105px;height:105px}.Mount_Head_Penguin-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -892px;width:105px;height:105px}.Mount_Head_Penguin-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-106px -892px;width:105px;height:105px}.Mount_Head_Penguin-White{background-image:url(spritesmith-main-8.png);background-position:-212px -892px;width:105px;height:105px}.Mount_Head_Penguin-Zombie{background-image:url(spritesmith-main-8.png);background-position:-318px -892px;width:105px;height:105px}.Mount_Head_Phoenix-Base{background-image:url(spritesmith-main-8.png);background-position:-424px -892px;width:105px;height:105px}.Mount_Head_Rat-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -892px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -892px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -892px;width:105px;height:105px}.Mount_Head_Rat-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -892px;width:105px;height:105px}.Mount_Head_Rat-Golden{background-image:url(spritesmith-main-8.png);background-position:-998px 0;width:105px;height:105px}.Mount_Head_Rat-Red{background-image:url(spritesmith-main-8.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Head_Rat-Shade{background-image:url(spritesmith-main-8.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Head_Rat-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_Rat-White{background-image:url(spritesmith-main-8.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_Rat-Zombie{background-image:url(spritesmith-main-8.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_Rock-Base{background-image:url(spritesmith-main-8.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-998px -848px;width:105px;height:105px}.Mount_Head_Rock-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -998px;width:105px;height:105px}.Mount_Head_Rock-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -998px;width:105px;height:105px}.Mount_Head_Rock-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -998px;width:105px;height:105px}.Mount_Head_Rock-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -998px;width:105px;height:105px}.Mount_Head_Rock-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -998px;width:105px;height:105px}.Mount_Head_Rock-White{background-image:url(spritesmith-main-8.png);background-position:-530px -998px;width:105px;height:105px}.Mount_Head_Rock-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -998px;width:105px;height:105px}.Mount_Head_Rooster-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -998px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -998px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -998px;width:105px;height:105px}.Mount_Head_Rooster-Desert{background-image:url(spritesmith-main-8.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Rooster-Golden{background-image:url(spritesmith-main-8.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Rooster-Red{background-image:url(spritesmith-main-8.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Rooster-Shade{background-image:url(spritesmith-main-8.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Rooster-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Rooster-White{background-image:url(spritesmith-main-8.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Rooster-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Seahorse-Base{background-image:url(spritesmith-main-8.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1104px -954px;width:105px;height:105px}.Mount_Head_Seahorse-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-White{background-image:url(spritesmith-main-8.png);background-position:-530px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -1104px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -1104px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Sheep-Red{background-image:url(spritesmith-main-8.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Sheep-Shade{background-image:url(spritesmith-main-8.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Sheep-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Sheep-White{background-image:url(spritesmith-main-8.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Sheep-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Slime-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Slime-Desert{background-image:url(spritesmith-main-8.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Slime-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px -1060px;width:105px;height:105px}.Mount_Head_Slime-Red{background-image:url(spritesmith-main-8.png);background-position:0 -1210px;width:105px;height:105px}.Mount_Head_Slime-Shade{background-image:url(spritesmith-main-8.png);background-position:-106px -1210px;width:105px;height:105px}.Mount_Head_Slime-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-212px -1210px;width:105px;height:105px}.Mount_Head_Slime-White{background-image:url(spritesmith-main-8.png);background-position:-457px -544px;width:105px;height:105px}.Mount_Head_Slime-Zombie{background-image:url(spritesmith-main-8.png);background-position:-424px -1210px;width:105px;height:105px}.Mount_Head_Snake-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -1210px;width:105px;height:105px}.Mount_Head_Snake-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -1210px;width:105px;height:105px}.Mount_Head_Snake-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -1210px;width:105px;height:105px}.Mount_Head_Snake-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -1210px;width:105px;height:105px}.Mount_Head_Snake-Golden{background-image:url(spritesmith-main-8.png);background-position:-954px -1210px;width:105px;height:105px}.Mount_Head_Snake-Red{background-image:url(spritesmith-main-8.png);background-position:-1060px -1210px;width:105px;height:105px}.Mount_Head_Snake-Shade{background-image:url(spritesmith-main-8.png);background-position:-1166px -1210px;width:105px;height:105px}.Mount_Head_Snake-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Snake-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Snake-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Spider-Base{background-image:url(spritesmith-main-8.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Spider-Desert{background-image:url(spritesmith-main-8.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Spider-Golden{background-image:url(spritesmith-main-8.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Spider-Red{background-image:url(spritesmith-main-8.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Spider-Shade{background-image:url(spritesmith-main-8.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Spider-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Spider-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -1166px;width:105px;height:105px}.Mount_Head_Spider-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1316px;width:105px;height:105px}.Mount_Head_TRex-Base{background-image:url(spritesmith-main-8.png);background-position:-272px 0;width:135px;height:135px}.Mount_Head_TRex-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:0 -272px;width:135px;height:135px}.Mount_Head_TRex-Desert{background-image:url(spritesmith-main-8.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Head_TRex-Golden{background-image:url(spritesmith-main-8.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Head_TRex-Red{background-image:url(spritesmith-main-8.png);background-position:-408px 0;width:135px;height:135px}.Mount_Head_TRex-Shade{background-image:url(spritesmith-main-8.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Head_TRex-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Head_TRex-White{background-image:url(spritesmith-main-8.png);background-position:0 0;width:135px;height:135px}.Mount_Head_TRex-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Head_TigerCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1166px -1316px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1272px -1316px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_TigerCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_TigerCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_TigerCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_TigerCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_TigerCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_TigerCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_TigerCub-White{background-image:url(spritesmith-main-8.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_TigerCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_Turkey-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_Whale-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px -1272px;width:105px;height:105px}.Mount_Head_Whale-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1422px;width:105px;height:105px}.Mount_Head_Whale-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -1422px;width:105px;height:105px}.Mount_Head_Whale-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -1422px;width:105px;height:105px}.Mount_Head_Whale-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -1422px;width:105px;height:105px}.Mount_Head_Whale-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -1422px;width:105px;height:105px}.Mount_Head_Whale-White{background-image:url(spritesmith-main-8.png);background-position:-530px -1422px;width:105px;height:105px}.Mount_Head_Whale-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -1422px;width:105px;height:105px}.Mount_Head_Wolf-Base{background-image:url(spritesmith-main-8.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-544px 0;width:135px;height:135px}.Mount_Head_Wolf-Desert{background-image:url(spritesmith-main-8.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Head_Wolf-Golden{background-image:url(spritesmith-main-8.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Head_Wolf-Red{background-image:url(spritesmith-main-8.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_Wolf-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -544px;width:135px;height:135px}.Mount_Head_Wolf-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -408px;width:135px;height:135px}.Mount_Head_Wolf-Spooky{background-image:url(spritesmith-main-8.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Head_Wolf-White{background-image:url(spritesmith-main-8.png);background-position:0 -136px;width:135px;height:135px}.Mount_Head_Wolf-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px 0;width:135px;height:135px}.Pet-BearCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1528px -524px;width:81px;height:99px}.Pet-BearCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px 0;width:81px;height:99px}.Pet-BearCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1528px -624px;width:81px;height:99px}.Pet-BearCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1528px -724px;width:81px;height:99px}.Pet-BearCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1528px -824px;width:81px;height:99px}.Pet-BearCub-Polar{background-image:url(spritesmith-main-8.png);background-position:-1528px -924px;width:81px;height:99px}.Pet-BearCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1528px -1024px;width:81px;height:99px}.Pet-BearCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1528px -1124px;width:81px;height:99px}.Pet-BearCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1528px -1224px;width:81px;height:99px}.Pet-BearCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1528px -1324px;width:81px;height:99px}.Pet-BearCub-White{background-image:url(spritesmith-main-8.png);background-position:-1528px -1424px;width:81px;height:99px}.Pet-BearCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1528px;width:81px;height:99px}.Pet-Bunny-Base{background-image:url(spritesmith-main-8.png);background-position:-82px -1528px;width:81px;height:99px}.Pet-Bunny-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-164px -1528px;width:81px;height:99px}.Pet-Bunny-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-246px -1528px;width:81px;height:99px}.Pet-Bunny-Desert{background-image:url(spritesmith-main-8.png);background-position:-328px -1528px;width:81px;height:99px}.Pet-Bunny-Golden{background-image:url(spritesmith-main-8.png);background-position:-410px -1528px;width:81px;height:99px}.Pet-Bunny-Red{background-image:url(spritesmith-main-8.png);background-position:-492px -1528px;width:81px;height:99px}.Pet-Bunny-Shade{background-image:url(spritesmith-main-8.png);background-position:-574px -1528px;width:81px;height:99px}.Pet-Bunny-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-656px -1528px;width:81px;height:99px}.Pet-Bunny-White{background-image:url(spritesmith-main-8.png);background-position:-738px -1528px;width:81px;height:99px}.Pet-Bunny-Zombie{background-image:url(spritesmith-main-8.png);background-position:-820px -1528px;width:81px;height:99px}.Pet-Cactus-Base{background-image:url(spritesmith-main-8.png);background-position:-902px -1528px;width:81px;height:99px}.Pet-Cactus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-984px -1528px;width:81px;height:99px}.Pet-Cactus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1066px -1528px;width:81px;height:99px}.Pet-Cactus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1148px -1528px;width:81px;height:99px}.Pet-Cactus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1230px -1528px;width:81px;height:99px}.Pet-Cactus-Red{background-image:url(spritesmith-main-8.png);background-position:-1312px -1528px;width:81px;height:99px}.Pet-Cactus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1394px -1528px;width:81px;height:99px}.Pet-Cactus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1476px -1528px;width:81px;height:99px}.Pet-Cactus-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1528px -424px;width:81px;height:99px}.Pet-Cactus-White{background-image:url(spritesmith-main-8.png);background-position:-1634px -100px;width:81px;height:99px}.Pet-Cactus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1634px -200px;width:81px;height:99px}.Pet-Cheetah-Base{background-image:url(spritesmith-main-8.png);background-position:-1634px -300px;width:81px;height:99px}.Pet-Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px -400px;width:81px;height:99px}.Pet-Cheetah-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1634px -500px;width:81px;height:99px}.Pet-Cheetah-Desert{background-image:url(spritesmith-main-8.png);background-position:-1634px -600px;width:81px;height:99px}.Pet-Cheetah-Golden{background-image:url(spritesmith-main-8.png);background-position:-1634px -700px;width:81px;height:99px}.Pet-Cheetah-Red{background-image:url(spritesmith-main-8.png);background-position:-1634px -800px;width:81px;height:99px}.Pet-Cheetah-Shade{background-image:url(spritesmith-main-8.png);background-position:-1634px -900px;width:81px;height:99px}.Pet-Cheetah-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1634px -1000px;width:81px;height:99px}.Pet-Cheetah-White{background-image:url(spritesmith-main-8.png);background-position:-1634px -1100px;width:81px;height:99px}.Pet-Cheetah-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1634px -1200px;width:81px;height:99px}.Pet-Cuttlefish-Base{background-image:url(spritesmith-main-8.png);background-position:-1634px -1300px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px -1400px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1634px -1500px;width:81px;height:99px}.Pet-Cuttlefish-Desert{background-image:url(spritesmith-main-8.png);background-position:-1716px 0;width:81px;height:99px}.Pet-Cuttlefish-Golden{background-image:url(spritesmith-main-8.png);background-position:-1716px -100px;width:81px;height:99px}.Pet-Cuttlefish-Red{background-image:url(spritesmith-main-8.png);background-position:-1716px -200px;width:81px;height:99px}.Pet-Cuttlefish-Shade{background-image:url(spritesmith-main-8.png);background-position:-1716px -300px;width:81px;height:99px}.Pet-Cuttlefish-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1716px -400px;width:81px;height:99px}.Pet-Cuttlefish-White{background-image:url(spritesmith-main-8.png);background-position:-1716px -500px;width:81px;height:99px}.Pet-Cuttlefish-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1716px -600px;width:81px;height:99px}.Pet-Deer-Base{background-image:url(spritesmith-main-8.png);background-position:-1716px -700px;width:81px;height:99px}.Pet-Deer-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1716px -800px;width:81px;height:99px}.Pet-Deer-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1716px -900px;width:81px;height:99px}.Pet-Deer-Desert{background-image:url(spritesmith-main-8.png);background-position:-1716px -1000px;width:81px;height:99px}.Pet-Deer-Golden{background-image:url(spritesmith-main-8.png);background-position:-1716px -1100px;width:81px;height:99px}.Pet-Deer-Red{background-image:url(spritesmith-main-8.png);background-position:-1716px -1200px;width:81px;height:99px}.Pet-Deer-Shade{background-image:url(spritesmith-main-8.png);background-position:-1716px -1300px;width:81px;height:99px}.Pet-Deer-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px 0;width:81px;height:99px}.Pet-Deer-White{background-image:url(spritesmith-main-9.png);background-position:-328px -1000px;width:81px;height:99px}.Pet-Deer-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px 0;width:81px;height:99px}.Pet-Dragon-Base{background-image:url(spritesmith-main-9.png);background-position:0 -100px;width:81px;height:99px}.Pet-Dragon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -100px;width:81px;height:99px}.Pet-Dragon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -100px;width:81px;height:99px}.Pet-Dragon-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px 0;width:81px;height:99px}.Pet-Dragon-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -100px;width:81px;height:99px}.Pet-Dragon-Hydra{background-image:url(spritesmith-main-9.png);background-position:0 -200px;width:81px;height:99px}.Pet-Dragon-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -200px;width:81px;height:99px}.Pet-Dragon-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -200px;width:81px;height:99px}.Pet-Dragon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -200px;width:81px;height:99px}.Pet-Dragon-Spooky{background-image:url(spritesmith-main-9.png);background-position:-328px 0;width:81px;height:99px}.Pet-Dragon-White{background-image:url(spritesmith-main-9.png);background-position:-328px -100px;width:81px;height:99px}.Pet-Dragon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-328px -200px;width:81px;height:99px}.Pet-Egg-Base{background-image:url(spritesmith-main-9.png);background-position:0 -300px;width:81px;height:99px}.Pet-Egg-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -300px;width:81px;height:99px}.Pet-Egg-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -300px;width:81px;height:99px}.Pet-Egg-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -300px;width:81px;height:99px}.Pet-Egg-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -300px;width:81px;height:99px}.Pet-Egg-Red{background-image:url(spritesmith-main-9.png);background-position:-410px 0;width:81px;height:99px}.Pet-Egg-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -100px;width:81px;height:99px}.Pet-Egg-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -200px;width:81px;height:99px}.Pet-Egg-White{background-image:url(spritesmith-main-9.png);background-position:-410px -300px;width:81px;height:99px}.Pet-Egg-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px 0;width:81px;height:99px}.Pet-FlyingPig-Base{background-image:url(spritesmith-main-9.png);background-position:-492px -100px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -200px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -300px;width:81px;height:99px}.Pet-FlyingPig-Desert{background-image:url(spritesmith-main-9.png);background-position:0 -400px;width:81px;height:99px}.Pet-FlyingPig-Golden{background-image:url(spritesmith-main-9.png);background-position:-82px -400px;width:81px;height:99px}.Pet-FlyingPig-Red{background-image:url(spritesmith-main-9.png);background-position:-164px -400px;width:81px;height:99px}.Pet-FlyingPig-Shade{background-image:url(spritesmith-main-9.png);background-position:-246px -400px;width:81px;height:99px}.Pet-FlyingPig-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -400px;width:81px;height:99px}.Pet-FlyingPig-Spooky{background-image:url(spritesmith-main-9.png);background-position:-410px -400px;width:81px;height:99px}.Pet-FlyingPig-White{background-image:url(spritesmith-main-9.png);background-position:-492px -400px;width:81px;height:99px}.Pet-FlyingPig-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px 0;width:81px;height:99px}.Pet-Fox-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -100px;width:81px;height:99px}.Pet-Fox-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-574px -200px;width:81px;height:99px}.Pet-Fox-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -300px;width:81px;height:99px}.Pet-Fox-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -400px;width:81px;height:99px}.Pet-Fox-Golden{background-image:url(spritesmith-main-9.png);background-position:0 -500px;width:81px;height:99px}.Pet-Fox-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -500px;width:81px;height:99px}.Pet-Fox-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -500px;width:81px;height:99px}.Pet-Fox-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -500px;width:81px;height:99px}.Pet-Fox-Spooky{background-image:url(spritesmith-main-9.png);background-position:-328px -500px;width:81px;height:99px}.Pet-Fox-White{background-image:url(spritesmith-main-9.png);background-position:-410px -500px;width:81px;height:99px}.Pet-Fox-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -500px;width:81px;height:99px}.Pet-Frog-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -500px;width:81px;height:99px}.Pet-Frog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px 0;width:81px;height:99px}.Pet-Frog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-656px -100px;width:81px;height:99px}.Pet-Frog-Desert{background-image:url(spritesmith-main-9.png);background-position:-656px -200px;width:81px;height:99px}.Pet-Frog-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -300px;width:81px;height:99px}.Pet-Frog-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -400px;width:81px;height:99px}.Pet-Frog-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -500px;width:81px;height:99px}.Pet-Frog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:0 -600px;width:81px;height:99px}.Pet-Frog-White{background-image:url(spritesmith-main-9.png);background-position:-82px -600px;width:81px;height:99px}.Pet-Frog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px -600px;width:81px;height:99px}.Pet-Gryphon-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -600px;width:81px;height:99px}.Pet-Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-328px -600px;width:81px;height:99px}.Pet-Gryphon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-410px -600px;width:81px;height:99px}.Pet-Gryphon-Desert{background-image:url(spritesmith-main-9.png);background-position:-492px -600px;width:81px;height:99px}.Pet-Gryphon-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -600px;width:81px;height:99px}.Pet-Gryphon-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -600px;width:81px;height:99px}.Pet-Gryphon-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px 0;width:81px;height:99px}.Pet-Gryphon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -100px;width:81px;height:99px}.Pet-Gryphon-White{background-image:url(spritesmith-main-9.png);background-position:-738px -200px;width:81px;height:99px}.Pet-Gryphon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-738px -300px;width:81px;height:99px}.Pet-Hedgehog-Base{background-image:url(spritesmith-main-9.png);background-position:-738px -400px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -500px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-738px -600px;width:81px;height:99px}.Pet-Hedgehog-Desert{background-image:url(spritesmith-main-9.png);background-position:0 -700px;width:81px;height:99px}.Pet-Hedgehog-Golden{background-image:url(spritesmith-main-9.png);background-position:-82px -700px;width:81px;height:99px}.Pet-Hedgehog-Red{background-image:url(spritesmith-main-9.png);background-position:-164px -700px;width:81px;height:99px}.Pet-Hedgehog-Shade{background-image:url(spritesmith-main-9.png);background-position:-246px -700px;width:81px;height:99px}.Pet-Hedgehog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -700px;width:81px;height:99px}.Pet-Hedgehog-White{background-image:url(spritesmith-main-9.png);background-position:-410px -700px;width:81px;height:99px}.Pet-Hedgehog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -700px;width:81px;height:99px}.Pet-Horse-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -700px;width:81px;height:99px}.Pet-Horse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px -700px;width:81px;height:99px}.Pet-Horse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-738px -700px;width:81px;height:99px}.Pet-Horse-Desert{background-image:url(spritesmith-main-9.png);background-position:-820px 0;width:81px;height:99px}.Pet-Horse-Golden{background-image:url(spritesmith-main-9.png);background-position:-820px -100px;width:81px;height:99px}.Pet-Horse-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -200px;width:81px;height:99px}.Pet-Horse-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -300px;width:81px;height:99px}.Pet-Horse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-820px -400px;width:81px;height:99px}.Pet-Horse-White{background-image:url(spritesmith-main-9.png);background-position:-820px -500px;width:81px;height:99px}.Pet-Horse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -600px;width:81px;height:99px}.Pet-JackOLantern-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -700px;width:81px;height:99px}.Pet-LionCub-Base{background-image:url(spritesmith-main-9.png);background-position:0 -800px;width:81px;height:99px}.Pet-LionCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -800px;width:81px;height:99px}.Pet-LionCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -800px;width:81px;height:99px}.Pet-LionCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -800px;width:81px;height:99px}.Pet-LionCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -800px;width:81px;height:99px}.Pet-LionCub-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -800px;width:81px;height:99px}.Pet-LionCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -800px;width:81px;height:99px}.Pet-LionCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -800px;width:81px;height:99px}.Pet-LionCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px -800px;width:81px;height:99px}.Pet-LionCub-White{background-image:url(spritesmith-main-9.png);background-position:-738px -800px;width:81px;height:99px}.Pet-LionCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -800px;width:81px;height:99px}.Pet-Mammoth-Base{background-image:url(spritesmith-main-9.png);background-position:-902px 0;width:81px;height:99px}.Pet-MantisShrimp-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -100px;width:81px;height:99px}.Pet-Octopus-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -200px;width:81px;height:99px}.Pet-Octopus-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -300px;width:81px;height:99px}.Pet-Octopus-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-902px -400px;width:81px;height:99px}.Pet-Octopus-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -500px;width:81px;height:99px}.Pet-Octopus-Golden{background-image:url(spritesmith-main-9.png);background-position:-902px -600px;width:81px;height:99px}.Pet-Octopus-Red{background-image:url(spritesmith-main-9.png);background-position:-902px -700px;width:81px;height:99px}.Pet-Octopus-Shade{background-image:url(spritesmith-main-9.png);background-position:-902px -800px;width:81px;height:99px}.Pet-Octopus-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-984px 0;width:81px;height:99px}.Pet-Octopus-White{background-image:url(spritesmith-main-9.png);background-position:-984px -100px;width:81px;height:99px}.Pet-Octopus-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px -200px;width:81px;height:99px}.Pet-Owl-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -300px;width:81px;height:99px}.Pet-Owl-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -400px;width:81px;height:99px}.Pet-Owl-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -500px;width:81px;height:99px}.Pet-Owl-Desert{background-image:url(spritesmith-main-9.png);background-position:-984px -600px;width:81px;height:99px}.Pet-Owl-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -700px;width:81px;height:99px}.Pet-Owl-Red{background-image:url(spritesmith-main-9.png);background-position:-984px -800px;width:81px;height:99px}.Pet-Owl-Shade{background-image:url(spritesmith-main-9.png);background-position:0 -900px;width:81px;height:99px}.Pet-Owl-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px -900px;width:81px;height:99px}.Pet-Owl-White{background-image:url(spritesmith-main-9.png);background-position:-164px -900px;width:81px;height:99px}.Pet-Owl-Zombie{background-image:url(spritesmith-main-9.png);background-position:-246px -900px;width:81px;height:99px}.Pet-PandaCub-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -900px;width:81px;height:99px}.Pet-PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-410px -900px;width:81px;height:99px}.Pet-PandaCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -900px;width:81px;height:99px}.Pet-PandaCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -900px;width:81px;height:99px}.Pet-PandaCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -900px;width:81px;height:99px}.Pet-PandaCub-Red{background-image:url(spritesmith-main-9.png);background-position:-738px -900px;width:81px;height:99px}.Pet-PandaCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -900px;width:81px;height:99px}.Pet-PandaCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-902px -900px;width:81px;height:99px}.Pet-PandaCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-984px -900px;width:81px;height:99px}.Pet-PandaCub-White{background-image:url(spritesmith-main-9.png);background-position:-1066px 0;width:81px;height:99px}.Pet-PandaCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1066px -100px;width:81px;height:99px}.Pet-Parrot-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -200px;width:81px;height:99px}.Pet-Parrot-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1066px -300px;width:81px;height:99px}.Pet-Parrot-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -400px;width:81px;height:99px}.Pet-Parrot-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -500px;width:81px;height:99px}.Pet-Parrot-Golden{background-image:url(spritesmith-main-9.png);background-position:-1066px -600px;width:81px;height:99px}.Pet-Parrot-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -700px;width:81px;height:99px}.Pet-Parrot-Shade{background-image:url(spritesmith-main-9.png);background-position:-1066px -800px;width:81px;height:99px}.Pet-Parrot-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1066px -900px;width:81px;height:99px}.Pet-Parrot-White{background-image:url(spritesmith-main-9.png);background-position:0 -1000px;width:81px;height:99px}.Pet-Parrot-Zombie{background-image:url(spritesmith-main-9.png);background-position:-82px -1000px;width:81px;height:99px}.Pet-Penguin-Base{background-image:url(spritesmith-main-9.png);background-position:-164px -1000px;width:81px;height:99px}.Pet-Penguin-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-246px -1000px;width:81px;height:99px}.Pet-Penguin-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 0;width:81px;height:99px}.Pet-Penguin-Desert{background-image:url(spritesmith-main-9.png);background-position:-410px -1000px;width:81px;height:99px}.Pet-Penguin-Golden{background-image:url(spritesmith-main-9.png);background-position:-492px -1000px;width:81px;height:99px}.Pet-Penguin-Red{background-image:url(spritesmith-main-9.png);background-position:-574px -1000px;width:81px;height:99px}.Pet-Penguin-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -1000px;width:81px;height:99px}.Pet-Penguin-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -1000px;width:81px;height:99px}.Pet-Penguin-White{background-image:url(spritesmith-main-9.png);background-position:-820px -1000px;width:81px;height:99px}.Pet-Penguin-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -1000px;width:81px;height:99px}.Pet-Phoenix-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -1000px;width:81px;height:99px}.Pet-Rat-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -1000px;width:81px;height:99px}.Pet-Rat-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px 0;width:81px;height:99px}.Pet-Rat-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1148px -100px;width:81px;height:99px}.Pet-Rat-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -200px;width:81px;height:99px}.Pet-Rat-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -300px;width:81px;height:99px}.Pet-Rat-Red{background-image:url(spritesmith-main-9.png);background-position:-1148px -400px;width:81px;height:99px}.Pet-Rat-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -500px;width:81px;height:99px}.Pet-Rat-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1148px -600px;width:81px;height:99px}.Pet-Rat-White{background-image:url(spritesmith-main-9.png);background-position:-1148px -700px;width:81px;height:99px}.Pet-Rat-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -800px;width:81px;height:99px}.Pet-Rock-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -900px;width:81px;height:99px}.Pet-Rock-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px -1000px;width:81px;height:99px}.Pet-Rock-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 -1100px;width:81px;height:99px}.Pet-Rock-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px -1100px;width:81px;height:99px}.Pet-Rock-Golden{background-image:url(spritesmith-main-9.png);background-position:-164px -1100px;width:81px;height:99px}.Pet-Rock-Red{background-image:url(spritesmith-main-9.png);background-position:-246px -1100px;width:81px;height:99px}.Pet-Rock-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -1100px;width:81px;height:99px}.Pet-Rock-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -1100px;width:81px;height:99px}.Pet-Rock-White{background-image:url(spritesmith-main-9.png);background-position:-492px -1100px;width:81px;height:99px}.Pet-Rock-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px -1100px;width:81px;height:99px}.Pet-Rooster-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -1100px;width:81px;height:99px}.Pet-Rooster-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -1100px;width:81px;height:99px}.Pet-Rooster-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-820px -1100px;width:81px;height:99px}.Pet-Rooster-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -1100px;width:81px;height:99px}.Pet-Rooster-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -1100px;width:81px;height:99px}.Pet-Rooster-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -1100px;width:81px;height:99px}.Pet-Rooster-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -1100px;width:81px;height:99px}.Pet-Rooster-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px 0;width:81px;height:99px}.Pet-Rooster-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -100px;width:81px;height:99px}.Pet-Rooster-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1230px -200px;width:81px;height:99px}.Pet-Seahorse-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px -300px;width:81px;height:99px}.Pet-Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1230px -400px;width:81px;height:99px}.Pet-Seahorse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1230px -500px;width:81px;height:99px}.Pet-Seahorse-Desert{background-image:url(spritesmith-main-9.png);background-position:-1230px -600px;width:81px;height:99px}.Pet-Seahorse-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -700px;width:81px;height:99px}.Pet-Seahorse-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -800px;width:81px;height:99px}.Pet-Seahorse-Shade{background-image:url(spritesmith-main-9.png);background-position:-1230px -900px;width:81px;height:99px}.Pet-Seahorse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px -1000px;width:81px;height:99px}.Pet-Seahorse-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -1100px;width:81px;height:99px}.Pet-Seahorse-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -1200px;width:81px;height:99px}.Pet-Sheep-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -1200px;width:81px;height:99px}.Pet-Sheep-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -1200px;width:81px;height:99px}.Pet-Sheep-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px -1200px;width:81px;height:99px}.Pet-Sheep-Desert{background-image:url(spritesmith-main-9.png);background-position:-328px -1200px;width:81px;height:99px}.Pet-Sheep-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -1200px;width:81px;height:99px}.Pet-Sheep-Red{background-image:url(spritesmith-main-9.png);background-position:-492px -1200px;width:81px;height:99px}.Pet-Sheep-Shade{background-image:url(spritesmith-main-9.png);background-position:-574px -1200px;width:81px;height:99px}.Pet-Sheep-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-656px -1200px;width:81px;height:99px}.Pet-Sheep-White{background-image:url(spritesmith-main-9.png);background-position:-738px -1200px;width:81px;height:99px}.Pet-Sheep-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -1200px;width:81px;height:99px}.Pet-Slime-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -1200px;width:81px;height:99px}.Pet-Slime-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -1200px;width:81px;height:99px}.Pet-Slime-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -1200px;width:81px;height:99px}.Pet-Slime-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -1200px;width:81px;height:99px}.Pet-Slime-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -1200px;width:81px;height:99px}.Pet-Slime-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px 0;width:81px;height:99px}.Pet-Slime-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -100px;width:81px;height:99px}.Pet-Slime-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -200px;width:81px;height:99px}.Pet-Slime-White{background-image:url(spritesmith-main-9.png);background-position:-1312px -300px;width:81px;height:99px}.Pet-Slime-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1312px -400px;width:81px;height:99px}.Pet-Snake-Base{background-image:url(spritesmith-main-9.png);background-position:-1312px -500px;width:81px;height:99px}.Pet-Snake-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1312px -600px;width:81px;height:99px}.Pet-Snake-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px -700px;width:81px;height:99px}.Pet-Snake-Desert{background-image:url(spritesmith-main-9.png);background-position:-1312px -800px;width:81px;height:99px}.Pet-Snake-Golden{background-image:url(spritesmith-main-9.png);background-position:-1312px -900px;width:81px;height:99px}.Pet-Snake-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px -1000px;width:81px;height:99px}.Pet-Snake-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -1100px;width:81px;height:99px}.Pet-Snake-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -1200px;width:81px;height:99px}.Pet-Snake-White{background-image:url(spritesmith-main-9.png);background-position:-1394px 0;width:81px;height:99px}.Pet-Snake-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -100px;width:81px;height:99px}.Pet-Spider-Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -200px;width:81px;height:99px}.Pet-Spider-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1394px -300px;width:81px;height:99px}.Pet-Spider-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1394px -400px;width:81px;height:99px}.Pet-Spider-Desert{background-image:url(spritesmith-main-9.png);background-position:-1394px -500px;width:81px;height:99px}.Pet-Spider-Golden{background-image:url(spritesmith-main-9.png);background-position:-1394px -600px;width:81px;height:99px}.Pet-Spider-Red{background-image:url(spritesmith-main-9.png);background-position:-1394px -700px;width:81px;height:99px}.Pet-Spider-Shade{background-image:url(spritesmith-main-9.png);background-position:-1394px -800px;width:81px;height:99px}.Pet-Spider-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -900px;width:81px;height:99px}.Pet-Spider-White{background-image:url(spritesmith-main-9.png);background-position:-1394px -1000px;width:81px;height:99px}.Pet-Spider-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -1100px;width:81px;height:99px}.Pet-TRex-Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -1200px;width:81px;height:99px}.Pet-TRex-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -1300px;width:81px;height:99px}.Pet-TRex-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -1300px;width:81px;height:99px}.Pet-TRex-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -1300px;width:81px;height:99px}.Pet-TRex-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -1300px;width:81px;height:99px}.Pet-TRex-Red{background-image:url(spritesmith-main-9.png);background-position:-328px -1300px;width:81px;height:99px}.Pet-TRex-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -1300px;width:81px;height:99px}.Pet-TRex-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-492px -1300px;width:81px;height:99px}.Pet-TRex-White{background-image:url(spritesmith-main-9.png);background-position:-574px -1300px;width:81px;height:99px}.Pet-TRex-Zombie{background-image:url(spritesmith-main-9.png);background-position:-656px -1300px;width:81px;height:99px}.Pet-Tiger-Veteran{background-image:url(spritesmith-main-9.png);background-position:-738px -1300px;width:81px;height:99px}.Pet-TigerCub-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -1300px;width:81px;height:99px}.Pet-TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -1300px;width:81px;height:99px}.Pet-TigerCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -1300px;width:81px;height:99px}.Pet-TigerCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -1300px;width:81px;height:99px}.Pet-TigerCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -1300px;width:81px;height:99px}.Pet-TigerCub-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -1300px;width:81px;height:99px}.Pet-TigerCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -1300px;width:81px;height:99px}.Pet-TigerCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -1300px;width:81px;height:99px}.Pet-TigerCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-1476px 0;width:81px;height:99px}.Pet-TigerCub-White{background-image:url(spritesmith-main-9.png);background-position:-1476px -100px;width:81px;height:99px}.Pet-TigerCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1476px -200px;width:81px;height:99px}.Pet-Turkey-Base{background-image:url(spritesmith-main-9.png);background-position:-1476px -300px;width:81px;height:99px}.Pet-Whale-Base{background-image:url(spritesmith-main-9.png);background-position:-1476px -400px;width:81px;height:99px}.Pet-Whale-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1476px -500px;width:81px;height:99px}.Pet-Whale-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1476px -600px;width:81px;height:99px}.Pet-Whale-Desert{background-image:url(spritesmith-main-9.png);background-position:-1476px -700px;width:81px;height:99px}.Pet-Whale-Golden{background-image:url(spritesmith-main-9.png);background-position:-1476px -800px;width:81px;height:99px}.Pet-Whale-Red{background-image:url(spritesmith-main-9.png);background-position:-1476px -900px;width:81px;height:99px}.Pet-Whale-Shade{background-image:url(spritesmith-main-9.png);background-position:-1476px -1000px;width:81px;height:99px}.Pet-Whale-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1476px -1100px;width:81px;height:99px}.Pet-Whale-White{background-image:url(spritesmith-main-9.png);background-position:-1476px -1200px;width:81px;height:99px}.Pet-Whale-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1476px -1300px;width:81px;height:99px}.Pet-Wolf-Base{background-image:url(spritesmith-main-9.png);background-position:0 -1400px;width:81px;height:99px}.Pet-Wolf-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -1400px;width:81px;height:99px}.Pet-Wolf-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -1400px;width:81px;height:99px}.Pet-Wolf-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -1400px;width:81px;height:99px}.Pet-Wolf-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -1400px;width:81px;height:99px}.Pet-Wolf-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -1400px;width:81px;height:99px}.Pet-Wolf-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -1400px;width:81px;height:99px}.Pet-Wolf-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -1400px;width:81px;height:99px}.Pet-Wolf-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px -1400px;width:81px;height:99px}.Pet-Wolf-Veteran{background-image:url(spritesmith-main-9.png);background-position:-738px -1400px;width:81px;height:99px}.Pet-Wolf-White{background-image:url(spritesmith-main-9.png);background-position:-820px -1400px;width:81px;height:99px}.Pet-Wolf-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -1400px;width:81px;height:99px}.Pet_HatchingPotion_Base{background-image:url(spritesmith-main-9.png);background-position:-1033px -1400px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1229px -1400px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1082px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Desert{background-image:url(spritesmith-main-9.png);background-position:-1131px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Golden{background-image:url(spritesmith-main-9.png);background-position:-1180px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Red{background-image:url(spritesmith-main-9.png);background-position:-984px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Shade{background-image:url(spritesmith-main-9.png);background-position:-1278px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1327px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Spooky{background-image:url(spritesmith-main-9.png);background-position:-1376px -1400px;width:48px;height:51px}.Pet_HatchingPotion_White{background-image:url(spritesmith-main-9.png);background-position:-1425px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Zombie{background-image:url(spritesmith-main-9.png);background-position:-1474px -1400px;width:48px;height:51px}.head_special_0,.weapon_special_0{width:105px;height:105px;margin-left:-3px;margin-top:-18px}.broad_armor_special_0,.shield_special_0,.slim_armor_special_0{width:90px;height:90px}.weapon_special_critical{background:url(/common/img/sprites/backer-only/weapon_special_critical.gif) no-repeat;width:90px;height:90px;margin-left:-12px;margin-top:12px}.weapon_special_1{margin-left:-12px}.broad_armor_special_1,.head_special_1,.slim_armor_special_1{width:90px;height:90px}.head_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeHelmet.gif) no-repeat}.head_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalHelmet.gif) no-repeat;margin-top:3px}.broad_armor_special_0,.slim_armor_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeArmor.gif) no-repeat}.broad_armor_special_1,.slim_armor_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalArmor.gif) no-repeat}.shield_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Shield-TormentedSkull.gif) no-repeat}.weapon_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Weapon-DarkSoulsBlade.gif) no-repeat}.Pet-Wolf-Cerberus{width:105px;height:72px;background:url(/common/img/sprites/backer-only/BackerOnly-Pet-CerberusPup.gif) no-repeat}.npc_ian{background:url(/common/img/sprites/npc_ian.gif) no-repeat;width:78px;height:135px}.quest_burnout{background:url(/common/img/sprites/quest_burnout.gif) no-repeat;width:219px;height:249px}.Gems{display:inline-block;margin-right:5px;border-style:none;margin-left:0;margin-top:2px}.inline-gems{vertical-align:middle;margin-left:0;display:inline-block}.customize-menu .locked{background-color:#727272}.achievement{float:left;clear:right;margin-right:10px}.multi-achievement{margin:auto;padding-left:.5em;padding-right:.5em}[class*=Mount_Body_],[class*=Mount_Head_]{margin-top:18px}.Pet_Currency_Gem{margin-top:5px;margin-bottom:5px} \ No newline at end of file +.2014_Fall_HealerPROMO2{background-image:url(spritesmith-largeSprites-0.png);background-position:-731px -995px;width:90px;height:90px}.2014_Fall_Mage_PROMO9{background-image:url(spritesmith-largeSprites-0.png);background-position:-306px -417px;width:120px;height:90px}.2014_Fall_RoguePROMO3{background-image:url(spritesmith-largeSprites-0.png);background-position:-808px -621px;width:105px;height:90px}.2014_Fall_Warrior_PROMO{background-image:url(spritesmith-largeSprites-0.png);background-position:-1095px -995px;width:90px;height:90px}.promo_backtoschool{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -522px;width:150px;height:150px}.promo_burnout{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px 0;width:219px;height:240px}.promo_classes_fall_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-326px -724px;width:321px;height:100px}.promo_classes_fall_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-430px -621px;width:377px;height:99px}.promo_dilatoryDistress{background-image:url(spritesmith-largeSprites-0.png);background-position:-367px -995px;width:90px;height:90px}.promo_enchanted_armoire{background-image:url(spritesmith-largeSprites-0.png);background-position:-499px -525px;width:374px;height:76px}.promo_enchanted_armoire_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -673px;width:217px;height:90px}.promo_enchanted_armoire_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-648px -724px;width:180px;height:90px}.promo_enchanted_armoire_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-458px -995px;width:90px;height:90px}.promo_enchanted_armoire_201511{background-image:url(spritesmith-largeSprites-0.png);background-position:-306px -326px;width:122px;height:90px}.promo_habitica{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -241px;width:175px;height:175px}.promo_habitica_sticker{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -220px;width:305px;height:304px}.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-1094px -522px;width:100px;height:137px}.customize-option.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-1119px -537px;width:60px;height:60px}.promo_item_notif{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -271px;width:249px;height:102px}.promo_mystery_201405{background-image:url(spritesmith-largeSprites-0.png);background-position:-1004px -995px;width:90px;height:90px}.promo_mystery_201406{background-image:url(spritesmith-largeSprites-0.png);background-position:-91px -995px;width:90px;height:96px}.promo_mystery_201407{background-image:url(spritesmith-largeSprites-0.png);background-position:-628px -241px;width:42px;height:62px}.promo_mystery_201408{background-image:url(spritesmith-largeSprites-0.png);background-position:-1161px -764px;width:60px;height:71px}.promo_mystery_201409{background-image:url(spritesmith-largeSprites-0.png);background-position:-640px -995px;width:90px;height:90px}.promo_mystery_201410{background-image:url(spritesmith-largeSprites-0.png);background-position:-1161px -673px;width:72px;height:63px}.promo_mystery_201411{background-image:url(spritesmith-largeSprites-0.png);background-position:-822px -995px;width:90px;height:90px}.promo_mystery_201412{background-image:url(spritesmith-largeSprites-0.png);background-position:-1195px -592px;width:42px;height:66px}.promo_mystery_201501{background-image:url(spritesmith-largeSprites-0.png);background-position:-1193px -271px;width:48px;height:63px}.promo_mystery_201502{background-image:url(spritesmith-largeSprites-0.png);background-position:-276px -995px;width:90px;height:90px}.promo_mystery_201503{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -1101px;width:90px;height:90px}.promo_mystery_201504{background-image:url(spritesmith-largeSprites-0.png);background-position:-874px -525px;width:60px;height:69px}.promo_mystery_201505{background-image:url(spritesmith-largeSprites-0.png);background-position:-549px -995px;width:90px;height:90px}.promo_mystery_201506{background-image:url(spritesmith-largeSprites-0.png);background-position:-1195px -522px;width:42px;height:69px}.promo_mystery_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -995px;width:90px;height:105px}.promo_mystery_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-829px -724px;width:93px;height:90px}.promo_mystery_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-913px -995px;width:90px;height:90px}.promo_mystery_201510{background-image:url(spritesmith-largeSprites-0.png);background-position:-182px -995px;width:93px;height:90px}.promo_mystery_3014{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -764px;width:217px;height:90px}.promo_orca{background-image:url(spritesmith-largeSprites-0.png);background-position:-306px -220px;width:105px;height:105px}.promo_partyhats{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -855px;width:115px;height:47px}.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -835px;width:330px;height:83px}.customize-option.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -850px;width:60px;height:60px}.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-1100px -374px;width:140px;height:147px}.customize-option.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-1125px -389px;width:60px;height:60px}.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-331px -835px;width:330px;height:83px}.customize-option.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-356px -850px;width:60px;height:60px}.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -417px;width:198px;height:91px}.customize-option.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-477px -432px;width:60px;height:60px}.promo_springclasses2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -180px;width:288px;height:90px}.promo_springclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -89px;width:288px;height:90px}.promo_summer_classes_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -621px;width:429px;height:102px}.promo_summer_classes_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px 0;width:300px;height:88px}.promo_updos{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -374px;width:156px;height:147px}.promo_veteran_pets{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -919px;width:146px;height:75px}.promo_winterclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -724px;width:325px;height:110px}.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-662px -835px;width:152px;height:75px}.customize-option.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-687px -850px;width:60px;height:60px}.avatar_variety{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -525px;width:498px;height:95px}.party_preview{background-image:url(spritesmith-largeSprites-0.png);background-position:0 0;width:451px;height:219px}.welcome_basic_avatars{background-image:url(spritesmith-largeSprites-0.png);background-position:-672px -347px;width:246px;height:165px}.welcome_promo_party{background-image:url(spritesmith-largeSprites-0.png);background-position:-672px 0;width:270px;height:180px}.welcome_sample_tasks{background-image:url(spritesmith-largeSprites-0.png);background-position:-672px -181px;width:246px;height:165px}.achievement-alien{background-image:url(spritesmith-main-0.png);background-position:-1678px -1451px;width:24px;height:26px}.achievement-alien2x{background-image:url(spritesmith-main-0.png);background-position:-895px -979px;width:48px;height:52px}.achievement-alpha{background-image:url(spritesmith-main-0.png);background-position:-1678px -1424px;width:24px;height:26px}.achievement-armor{background-image:url(spritesmith-main-0.png);background-position:-1678px -1397px;width:24px;height:26px}.achievement-armor2x{background-image:url(spritesmith-main-0.png);background-position:-944px -979px;width:48px;height:52px}.achievement-boot{background-image:url(spritesmith-main-0.png);background-position:-1678px -1343px;width:24px;height:26px}.achievement-boot2x{background-image:url(spritesmith-main-0.png);background-position:-1042px -979px;width:48px;height:52px}.achievement-bow{background-image:url(spritesmith-main-0.png);background-position:-1678px -1289px;width:24px;height:26px}.achievement-bow2x{background-image:url(spritesmith-main-0.png);background-position:-504px -1582px;width:48px;height:52px}.achievement-burnout{background-image:url(spritesmith-main-0.png);background-position:-1678px -1235px;width:24px;height:26px}.achievement-burnout2x{background-image:url(spritesmith-main-0.png);background-position:-602px -1582px;width:48px;height:52px}.achievement-cactus{background-image:url(spritesmith-main-0.png);background-position:-1678px -1181px;width:24px;height:26px}.achievement-cactus2x{background-image:url(spritesmith-main-0.png);background-position:-700px -1582px;width:48px;height:52px}.achievement-cake{background-image:url(spritesmith-main-0.png);background-position:-1678px -1127px;width:24px;height:26px}.achievement-cake2x{background-image:url(spritesmith-main-0.png);background-position:-798px -1582px;width:48px;height:52px}.achievement-cave{background-image:url(spritesmith-main-0.png);background-position:-1678px -1073px;width:24px;height:26px}.achievement-cave2x{background-image:url(spritesmith-main-0.png);background-position:-896px -1582px;width:48px;height:52px}.achievement-coffin{background-image:url(spritesmith-main-0.png);background-position:-1678px -1019px;width:24px;height:26px}.achievement-comment{background-image:url(spritesmith-main-0.png);background-position:-1678px -992px;width:24px;height:26px}.achievement-comment2x{background-image:url(spritesmith-main-0.png);background-position:-994px -1582px;width:48px;height:52px}.achievement-costumeContest{background-image:url(spritesmith-main-0.png);background-position:-1678px -938px;width:24px;height:26px}.achievement-costumeContest2x{background-image:url(spritesmith-main-0.png);background-position:-1092px -1582px;width:48px;height:52px}.achievement-dilatory{background-image:url(spritesmith-main-0.png);background-position:-1678px -884px;width:24px;height:26px}.achievement-firefox{background-image:url(spritesmith-main-0.png);background-position:-1678px -857px;width:24px;height:26px}.achievement-greeting{background-image:url(spritesmith-main-0.png);background-position:-1678px -830px;width:24px;height:26px}.achievement-greeting2x{background-image:url(spritesmith-main-0.png);background-position:-1190px -1582px;width:48px;height:52px}.achievement-habitBirthday{background-image:url(spritesmith-main-0.png);background-position:-1678px -776px;width:24px;height:26px}.achievement-habitBirthday2x{background-image:url(spritesmith-main-0.png);background-position:-1288px -1582px;width:48px;height:52px}.achievement-habiticaDay{background-image:url(spritesmith-main-0.png);background-position:-1678px -722px;width:24px;height:26px}.achievement-habiticaDay2x{background-image:url(spritesmith-main-0.png);background-position:-1386px -1582px;width:48px;height:52px}.achievement-heart{background-image:url(spritesmith-main-0.png);background-position:-1678px -668px;width:24px;height:26px}.achievement-heart2x{background-image:url(spritesmith-main-0.png);background-position:-1435px -1582px;width:48px;height:52px}.achievement-karaoke-2x{background-image:url(spritesmith-main-0.png);background-position:-1533px -1582px;width:48px;height:52px}.achievement-karaoke{background-image:url(spritesmith-main-0.png);background-position:-1678px -587px;width:24px;height:26px}.achievement-ninja{background-image:url(spritesmith-main-0.png);background-position:-1678px -560px;width:24px;height:26px}.achievement-ninja2x{background-image:url(spritesmith-main-0.png);background-position:-1678px 0;width:48px;height:52px}.achievement-nye{background-image:url(spritesmith-main-0.png);background-position:-1678px -506px;width:24px;height:26px}.achievement-nye2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -106px;width:48px;height:52px}.achievement-perfect{background-image:url(spritesmith-main-0.png);background-position:-1678px -452px;width:24px;height:26px}.achievement-perfect2x{background-image:url(spritesmith-main-0.png);background-position:-846px -979px;width:48px;height:52px}.achievement-rat{background-image:url(spritesmith-main-0.png);background-position:-1678px -911px;width:24px;height:26px}.achievement-rat2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -318px;width:48px;height:52px}.achievement-seafoam{background-image:url(spritesmith-main-0.png);background-position:-1678px -398px;width:24px;height:26px}.achievement-seafoam2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -265px;width:48px;height:52px}.achievement-shield{background-image:url(spritesmith-main-0.png);background-position:-1678px -425px;width:24px;height:26px}.achievement-shield2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -159px;width:48px;height:52px}.achievement-shinySeed{background-image:url(spritesmith-main-0.png);background-position:-1678px -479px;width:24px;height:26px}.achievement-shinySeed2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -53px;width:48px;height:52px}.achievement-snowball{background-image:url(spritesmith-main-0.png);background-position:-1678px -533px;width:24px;height:26px}.achievement-snowball2x{background-image:url(spritesmith-main-0.png);background-position:-1582px -1582px;width:48px;height:52px}.achievement-spookDust{background-image:url(spritesmith-main-0.png);background-position:-1678px -614px;width:24px;height:26px}.achievement-spookDust2x{background-image:url(spritesmith-main-0.png);background-position:-1484px -1582px;width:48px;height:52px}.achievement-stoikalm{background-image:url(spritesmith-main-0.png);background-position:-1678px -641px;width:24px;height:26px}.achievement-sun{background-image:url(spritesmith-main-0.png);background-position:-1678px -695px;width:24px;height:26px}.achievement-sun2x{background-image:url(spritesmith-main-0.png);background-position:-1337px -1582px;width:48px;height:52px}.achievement-sword{background-image:url(spritesmith-main-0.png);background-position:-1678px -749px;width:24px;height:26px}.achievement-sword2x{background-image:url(spritesmith-main-0.png);background-position:-1239px -1582px;width:48px;height:52px}.achievement-thankyou{background-image:url(spritesmith-main-0.png);background-position:-1678px -803px;width:24px;height:26px}.achievement-thankyou2x{background-image:url(spritesmith-main-0.png);background-position:-1141px -1582px;width:48px;height:52px}.achievement-thermometer{background-image:url(spritesmith-main-0.png);background-position:-1678px -371px;width:24px;height:26px}.achievement-thermometer2x{background-image:url(spritesmith-main-0.png);background-position:-1043px -1582px;width:48px;height:52px}.achievement-tree{background-image:url(spritesmith-main-0.png);background-position:-1678px -965px;width:24px;height:26px}.achievement-tree2x{background-image:url(spritesmith-main-0.png);background-position:-945px -1582px;width:48px;height:52px}.achievement-triadbingo{background-image:url(spritesmith-main-0.png);background-position:-1678px -1046px;width:24px;height:26px}.achievement-triadbingo2x{background-image:url(spritesmith-main-0.png);background-position:-847px -1582px;width:48px;height:52px}.achievement-ultimate-healer{background-image:url(spritesmith-main-0.png);background-position:-1678px -1100px;width:24px;height:26px}.achievement-ultimate-healer2x{background-image:url(spritesmith-main-0.png);background-position:-749px -1582px;width:48px;height:52px}.achievement-ultimate-mage{background-image:url(spritesmith-main-0.png);background-position:-1678px -1154px;width:24px;height:26px}.achievement-ultimate-mage2x{background-image:url(spritesmith-main-0.png);background-position:-651px -1582px;width:48px;height:52px}.achievement-ultimate-rogue{background-image:url(spritesmith-main-0.png);background-position:-1678px -1208px;width:24px;height:26px}.achievement-ultimate-rogue2x{background-image:url(spritesmith-main-0.png);background-position:-553px -1582px;width:48px;height:52px}.achievement-ultimate-warrior{background-image:url(spritesmith-main-0.png);background-position:-1678px -1262px;width:24px;height:26px}.achievement-ultimate-warrior2x{background-image:url(spritesmith-main-0.png);background-position:-455px -1582px;width:48px;height:52px}.achievement-valentine{background-image:url(spritesmith-main-0.png);background-position:-1678px -1316px;width:24px;height:26px}.achievement-valentine2x{background-image:url(spritesmith-main-0.png);background-position:-993px -979px;width:48px;height:52px}.achievement-wolf{background-image:url(spritesmith-main-0.png);background-position:-1678px -1370px;width:24px;height:26px}.achievement-wolf2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -212px;width:48px;height:52px}.background_autumn_forest{background-image:url(spritesmith-main-0.png);background-position:-423px -592px;width:140px;height:147px}.background_beach{background-image:url(spritesmith-main-0.png);background-position:-426px 0;width:141px;height:147px}.background_blacksmithy{background-image:url(spritesmith-main-0.png);background-position:-709px -148px;width:140px;height:147px}.background_cherry_trees{background-image:url(spritesmith-main-0.png);background-position:-709px -296px;width:140px;height:147px}.background_clouds{background-image:url(spritesmith-main-0.png);background-position:0 -592px;width:140px;height:147px}.background_coral_reef{background-image:url(spritesmith-main-0.png);background-position:-850px -148px;width:140px;height:147px}.background_crystal_cave{background-image:url(spritesmith-main-0.png);background-position:-850px -592px;width:140px;height:147px}.background_dilatory_ruins{background-image:url(spritesmith-main-0.png);background-position:0 -740px;width:140px;height:147px}.background_distant_castle{background-image:url(spritesmith-main-0.png);background-position:-423px -888px;width:140px;height:147px}.background_drifting_raft{background-image:url(spritesmith-main-0.png);background-position:-564px -888px;width:140px;height:147px}.background_dusty_canyons{background-image:url(spritesmith-main-0.png);background-position:-705px -888px;width:140px;height:147px}.background_fairy_ring{background-image:url(spritesmith-main-0.png);background-position:-568px 0;width:140px;height:147px}.background_floating_islands{background-image:url(spritesmith-main-0.png);background-position:-568px -148px;width:140px;height:147px}.background_floral_meadow{background-image:url(spritesmith-main-0.png);background-position:-568px -296px;width:140px;height:147px}.background_forest{background-image:url(spritesmith-main-0.png);background-position:0 -444px;width:140px;height:147px}.background_frigid_peak{background-image:url(spritesmith-main-0.png);background-position:-141px -444px;width:140px;height:147px}.background_giant_wave{background-image:url(spritesmith-main-0.png);background-position:-284px 0;width:141px;height:147px}.background_graveyard{background-image:url(spritesmith-main-0.png);background-position:-423px -444px;width:140px;height:147px}.background_gumdrop_land{background-image:url(spritesmith-main-0.png);background-position:-564px -444px;width:140px;height:147px}.background_harvest_feast{background-image:url(spritesmith-main-0.png);background-position:-709px 0;width:140px;height:147px}.background_harvest_fields{background-image:url(spritesmith-main-0.png);background-position:0 -148px;width:141px;height:147px}.background_harvest_moon{background-image:url(spritesmith-main-0.png);background-position:-142px -148px;width:141px;height:147px}.background_haunted_house{background-image:url(spritesmith-main-0.png);background-position:-709px -444px;width:140px;height:147px}.background_ice_cave{background-image:url(spritesmith-main-0.png);background-position:-284px -148px;width:141px;height:147px}.background_iceberg{background-image:url(spritesmith-main-0.png);background-position:-141px -592px;width:140px;height:147px}.background_island_waterfalls{background-image:url(spritesmith-main-0.png);background-position:-282px -592px;width:140px;height:147px}.background_marble_temple{background-image:url(spritesmith-main-0.png);background-position:-142px 0;width:141px;height:147px}.background_market{background-image:url(spritesmith-main-0.png);background-position:-564px -592px;width:140px;height:147px}.background_mountain_lake{background-image:url(spritesmith-main-0.png);background-position:-705px -592px;width:140px;height:147px}.background_night_dunes{background-image:url(spritesmith-main-0.png);background-position:-850px 0;width:140px;height:147px}.background_open_waters{background-image:url(spritesmith-main-0.png);background-position:0 0;width:141px;height:147px}.background_pagodas{background-image:url(spritesmith-main-0.png);background-position:-850px -296px;width:140px;height:147px}.background_pumpkin_patch{background-image:url(spritesmith-main-0.png);background-position:-850px -444px;width:140px;height:147px}.background_pyramids{background-image:url(spritesmith-main-0.png);background-position:-426px -148px;width:141px;height:147px}.background_rolling_hills{background-image:url(spritesmith-main-0.png);background-position:0 -296px;width:141px;height:147px}.background_seafarer_ship{background-image:url(spritesmith-main-0.png);background-position:-141px -740px;width:140px;height:147px}.background_shimmery_bubbles{background-image:url(spritesmith-main-0.png);background-position:-282px -740px;width:140px;height:147px}.background_slimy_swamp{background-image:url(spritesmith-main-0.png);background-position:-423px -740px;width:140px;height:147px}.background_snowy_pines{background-image:url(spritesmith-main-0.png);background-position:-564px -740px;width:140px;height:147px}.background_south_pole{background-image:url(spritesmith-main-0.png);background-position:-705px -740px;width:140px;height:147px}.background_spring_rain{background-image:url(spritesmith-main-0.png);background-position:-846px -740px;width:140px;height:147px}.background_stable{background-image:url(spritesmith-main-0.png);background-position:-991px 0;width:140px;height:147px}.background_stained_glass{background-image:url(spritesmith-main-0.png);background-position:-991px -148px;width:140px;height:147px}.background_starry_skies{background-image:url(spritesmith-main-0.png);background-position:-991px -296px;width:140px;height:147px}.background_sunken_ship{background-image:url(spritesmith-main-0.png);background-position:-991px -444px;width:140px;height:147px}.background_sunset_meadow{background-image:url(spritesmith-main-0.png);background-position:-991px -592px;width:140px;height:147px}.background_sunset_oasis{background-image:url(spritesmith-main-0.png);background-position:-991px -740px;width:140px;height:147px}.background_sunset_savannah{background-image:url(spritesmith-main-0.png);background-position:0 -888px;width:140px;height:147px}.background_swarming_darkness{background-image:url(spritesmith-main-0.png);background-position:-141px -888px;width:140px;height:147px}.background_tavern{background-image:url(spritesmith-main-0.png);background-position:-282px -888px;width:140px;height:147px}.background_thunderstorm{background-image:url(spritesmith-main-0.png);background-position:-142px -296px;width:141px;height:147px}.background_twinkly_lights{background-image:url(spritesmith-main-0.png);background-position:-284px -296px;width:141px;height:147px}.background_twinkly_party_lights{background-image:url(spritesmith-main-0.png);background-position:-426px -296px;width:141px;height:147px}.background_volcano{background-image:url(spritesmith-main-0.png);background-position:-282px -444px;width:140px;height:147px}.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -910px;width:90px;height:90px}.customize-option.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -925px;width:60px;height:60px}.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -1016px;width:60px;height:60px}.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -1107px;width:60px;height:60px}.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -1183px;width:90px;height:90px}.customize-option.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -1198px;width:60px;height:60px}.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:0 -1309px;width:90px;height:90px}.customize-option.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-25px -1324px;width:60px;height:60px}.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1324px;width:60px;height:60px}.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-182px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-207px -1324px;width:60px;height:60px}.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-273px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-298px -1324px;width:60px;height:60px}.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-364px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-389px -1324px;width:60px;height:60px}.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-455px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-480px -1324px;width:60px;height:60px}.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-546px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-571px -1324px;width:60px;height:60px}.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-637px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-662px -1324px;width:60px;height:60px}.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-728px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-753px -1324px;width:60px;height:60px}.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1324px;width:60px;height:60px}.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1324px;width:60px;height:60px}.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1324px;width:60px;height:60px}.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1092px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1117px -1324px;width:60px;height:60px}.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1183px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1208px -1324px;width:60px;height:60px}.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1324px;width:60px;height:60px}.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1405px 0;width:90px;height:90px}.customize-option.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1430px -15px;width:60px;height:60px}.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1405px -91px;width:90px;height:90px}.customize-option.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1430px -106px;width:60px;height:60px}.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1405px -182px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1430px -197px;width:60px;height:60px}.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1405px -273px;width:90px;height:90px}.customize-option.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1430px -288px;width:60px;height:60px}.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1405px -364px;width:90px;height:90px}.customize-option.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1430px -379px;width:60px;height:60px}.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -455px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -470px;width:60px;height:60px}.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-846px -888px;width:90px;height:90px}.customize-option.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-871px -903px;width:60px;height:60px}.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1405px -637px;width:90px;height:90px}.customize-option.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1430px -652px;width:60px;height:60px}.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1405px -728px;width:90px;height:90px}.customize-option.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1430px -743px;width:60px;height:60px}.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1405px -819px;width:90px;height:90px}.customize-option.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1430px -834px;width:60px;height:60px}.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1405px -910px;width:90px;height:90px}.customize-option.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1430px -925px;width:60px;height:60px}.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1405px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1430px -1016px;width:60px;height:60px}.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -1107px;width:60px;height:60px}.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1405px -1183px;width:90px;height:90px}.customize-option.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1430px -1198px;width:60px;height:60px}.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1405px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1430px -1289px;width:60px;height:60px}.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:0 -1400px;width:90px;height:90px}.customize-option.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-25px -1415px;width:60px;height:60px}.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-91px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-116px -1415px;width:60px;height:60px}.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-182px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-207px -1415px;width:60px;height:60px}.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-273px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-298px -1415px;width:60px;height:60px}.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-364px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-389px -1415px;width:60px;height:60px}.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-455px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-480px -1415px;width:60px;height:60px}.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-546px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-571px -1415px;width:60px;height:60px}.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-637px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-662px -1415px;width:60px;height:60px}.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-728px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-753px -1415px;width:60px;height:60px}.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-819px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-844px -1415px;width:60px;height:60px}.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-910px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-935px -1415px;width:60px;height:60px}.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-1001px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-1026px -1415px;width:60px;height:60px}.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-1092px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-1117px -1415px;width:60px;height:60px}.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1183px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1208px -1415px;width:60px;height:60px}.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1274px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1299px -1415px;width:60px;height:60px}.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1365px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1390px -1415px;width:60px;height:60px}.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1496px 0;width:90px;height:90px}.customize-option.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1521px -15px;width:60px;height:60px}.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1496px -91px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1521px -106px;width:60px;height:60px}.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1496px -182px;width:90px;height:90px}.customize-option.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1521px -197px;width:60px;height:60px}.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1496px -273px;width:90px;height:90px}.customize-option.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1521px -288px;width:60px;height:60px}.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1496px -364px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1521px -379px;width:60px;height:60px}.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1496px -455px;width:90px;height:90px}.customize-option.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1521px -470px;width:60px;height:60px}.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1496px -546px;width:90px;height:90px}.customize-option.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1521px -561px;width:60px;height:60px}.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -637px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -652px;width:60px;height:60px}.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1496px -728px;width:90px;height:90px}.customize-option.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1521px -743px;width:60px;height:60px}.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1496px -819px;width:90px;height:90px}.customize-option.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1521px -834px;width:60px;height:60px}.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1496px -910px;width:90px;height:90px}.customize-option.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1521px -925px;width:60px;height:60px}.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1496px -1001px;width:90px;height:90px}.customize-option.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1521px -1016px;width:60px;height:60px}.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1496px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1521px -1107px;width:60px;height:60px}.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1496px -1183px;width:90px;height:90px}.customize-option.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1521px -1198px;width:60px;height:60px}.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -1289px;width:60px;height:60px}.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1496px -1365px;width:90px;height:90px}.customize-option.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1521px -1380px;width:60px;height:60px}.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:0 -1491px;width:90px;height:90px}.customize-option.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-25px -1506px;width:60px;height:60px}.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-91px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-116px -1506px;width:60px;height:60px}.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-182px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-207px -1506px;width:60px;height:60px}.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1506px;width:60px;height:60px}.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-364px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-389px -1506px;width:60px;height:60px}.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-455px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-480px -1506px;width:60px;height:60px}.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-546px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-571px -1506px;width:60px;height:60px}.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-637px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-662px -1506px;width:60px;height:60px}.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-728px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-753px -1506px;width:60px;height:60px}.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-819px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-844px -1506px;width:60px;height:60px}.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-910px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-935px -1506px;width:60px;height:60px}.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-1001px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-1026px -1506px;width:60px;height:60px}.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-1092px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-1117px -1506px;width:60px;height:60px}.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-1183px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-1208px -1506px;width:60px;height:60px}.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1506px;width:60px;height:60px}.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1365px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1390px -1506px;width:60px;height:60px}.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1456px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1481px -1506px;width:60px;height:60px}.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1587px 0;width:90px;height:90px}.customize-option.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1612px -15px;width:60px;height:60px}.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1587px -91px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1612px -106px;width:60px;height:60px}.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1587px -182px;width:90px;height:90px}.customize-option.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1612px -197px;width:60px;height:60px}.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1587px -273px;width:90px;height:90px}.customize-option.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1612px -288px;width:60px;height:60px}.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1587px -364px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1612px -379px;width:60px;height:60px}.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1587px -455px;width:90px;height:90px}.customize-option.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1612px -470px;width:60px;height:60px}.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1587px -546px;width:90px;height:90px}.customize-option.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1612px -561px;width:60px;height:60px}.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -637px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -652px;width:60px;height:60px}.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1587px -728px;width:90px;height:90px}.customize-option.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1612px -743px;width:60px;height:60px}.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1587px -819px;width:90px;height:90px}.customize-option.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1612px -834px;width:60px;height:60px}.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1587px -910px;width:90px;height:90px}.customize-option.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1612px -925px;width:60px;height:60px}.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1587px -1001px;width:90px;height:90px}.customize-option.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1612px -1016px;width:60px;height:60px}.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1587px -1092px;width:90px;height:90px}.customize-option.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1612px -1107px;width:60px;height:60px}.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1587px -1183px;width:90px;height:90px}.customize-option.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1612px -1198px;width:60px;height:60px}.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -1289px;width:60px;height:60px}.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1587px -1365px;width:90px;height:90px}.customize-option.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1612px -1380px;width:60px;height:60px}.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1587px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1612px -1471px;width:60px;height:60px}.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:0 -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-25px -1597px;width:60px;height:60px}.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-91px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-116px -1597px;width:60px;height:60px}.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-182px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-207px -1597px;width:60px;height:60px}.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-273px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-298px -1597px;width:60px;height:60px}.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-364px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-389px -1597px;width:60px;height:60px}.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1405px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1430px -561px;width:60px;height:60px}.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1132px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1157px -652px;width:60px;height:60px}.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1132px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1157px -561px;width:60px;height:60px}.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1132px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1157px -470px;width:60px;height:60px}.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1132px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1157px -379px;width:60px;height:60px}.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-1132px -273px;width:90px;height:90px}.customize-option.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-1157px -288px;width:60px;height:60px}.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1132px -182px;width:90px;height:90px}.customize-option.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1157px -197px;width:60px;height:60px}.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1132px -91px;width:90px;height:90px}.customize-option.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1157px -106px;width:60px;height:60px}.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1132px 0;width:90px;height:90px}.customize-option.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1157px -15px;width:60px;height:60px}.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1051px;width:60px;height:60px}.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-910px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-935px -1051px;width:60px;height:60px}.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-819px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-844px -1051px;width:60px;height:60px}.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-728px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-753px -1051px;width:60px;height:60px}.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-637px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-662px -1051px;width:60px;height:60px}.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-546px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-571px -1051px;width:60px;height:60px}.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-455px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-480px -1051px;width:60px;height:60px}.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-364px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-389px -1051px;width:60px;height:60px}.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-273px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-298px -1051px;width:60px;height:60px}.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-182px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-207px -1051px;width:60px;height:60px}.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-91px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-116px -1051px;width:60px;height:60px}.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:0 -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-25px -1051px;width:60px;height:60px}.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1028px -888px;width:90px;height:90px}.customize-option.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1053px -903px;width:60px;height:60px}.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-937px -888px;width:90px;height:90px}.customize-option.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-962px -903px;width:60px;height:60px}.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1314px -819px;width:90px;height:90px}.customize-option.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1339px -834px;width:60px;height:60px}.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1314px -728px;width:90px;height:90px}.customize-option.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1339px -743px;width:60px;height:60px}.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1314px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1339px -652px;width:60px;height:60px}.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1314px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1339px -561px;width:60px;height:60px}.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -470px;width:60px;height:60px}.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -379px;width:60px;height:60px}.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -288px;width:60px;height:60px}.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -197px;width:60px;height:60px}.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1314px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1339px -106px;width:60px;height:60px}.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1314px 0;width:90px;height:90px}.customize-option.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1339px -15px;width:60px;height:60px}.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1183px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1208px -1233px;width:60px;height:60px}.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1092px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1117px -1233px;width:60px;height:60px}.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1001px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1026px -1233px;width:60px;height:60px}.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-910px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-935px -1233px;width:60px;height:60px}.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-819px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-844px -1233px;width:60px;height:60px}.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-728px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-753px -1233px;width:60px;height:60px}.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-637px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-662px -1233px;width:60px;height:60px}.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-546px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-571px -1233px;width:60px;height:60px}.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-455px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-480px -1233px;width:60px;height:60px}.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-364px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-389px -1233px;width:60px;height:60px}.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-273px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-298px -1233px;width:60px;height:60px}.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-182px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-207px -1233px;width:60px;height:60px}.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-91px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-116px -1233px;width:60px;height:60px}.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:0 -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-25px -1233px;width:60px;height:60px}.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1223px -1092px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1248px -1107px;width:60px;height:60px}.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1223px -1001px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1248px -1016px;width:60px;height:60px}.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1223px -910px;width:90px;height:90px}.customize-option.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1248px -925px;width:60px;height:60px}.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1223px -819px;width:90px;height:90px}.customize-option.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1248px -834px;width:60px;height:60px}.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1223px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1248px -743px;width:60px;height:60px}.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1223px -637px;width:90px;height:90px}.customize-option.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1248px -652px;width:60px;height:60px}.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1223px -546px;width:90px;height:90px}.customize-option.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1248px -561px;width:60px;height:60px}.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1223px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1248px -470px;width:60px;height:60px}.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1223px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1248px -379px;width:60px;height:60px}.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1223px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1248px -288px;width:60px;height:60px}.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1223px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1248px -197px;width:60px;height:60px}.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1223px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1248px -106px;width:60px;height:60px}.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1223px 0;width:90px;height:90px}.customize-option.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1248px -15px;width:60px;height:60px}.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1092px -1127px;width:90px;height:90px}.customize-option.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1117px -1142px;width:60px;height:60px}.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1001px -1127px;width:90px;height:90px}.customize-option.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1026px -1142px;width:60px;height:60px}.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-910px -1127px;width:90px;height:90px}.customize-option.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-935px -1142px;width:60px;height:60px}.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-819px -1127px;width:90px;height:90px}.customize-option.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-844px -1142px;width:60px;height:60px}.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-728px -1127px;width:90px;height:90px}.customize-option.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-753px -1142px;width:60px;height:60px}.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-637px -1127px;width:90px;height:90px}.customize-option.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-662px -1142px;width:60px;height:60px}.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-546px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-571px -1142px;width:60px;height:60px}.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-455px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-480px -1142px;width:60px;height:60px}.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-364px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-389px -1142px;width:60px;height:60px}.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1142px;width:60px;height:60px}.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-182px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-207px -1142px;width:60px;height:60px}.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1142px;width:60px;height:60px}.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:0 -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-25px -1142px;width:60px;height:60px}.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1132px -1001px;width:90px;height:90px}.customize-option.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1157px -1016px;width:60px;height:60px}.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1132px -910px;width:90px;height:90px}.customize-option.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1157px -925px;width:60px;height:60px}.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1132px -819px;width:90px;height:90px}.customize-option.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1157px -834px;width:60px;height:60px}.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1132px -728px;width:90px;height:90px}.customize-option.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1157px -743px;width:60px;height:60px}.hair_bangs_1_green{background-image:url(spritesmith-main-1.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_bangs_1_green{background-image:url(spritesmith-main-1.png);background-position:-116px -15px;width:60px;height:60px}.hair_bangs_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_bangs_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -1107px;width:60px;height:60px}.hair_bangs_1_holly{background-image:url(spritesmith-main-1.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_bangs_1_holly{background-image:url(spritesmith-main-1.png);background-position:-25px -106px;width:60px;height:60px}.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-116px -106px;width:60px;height:60px}.hair_bangs_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_bangs_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-207px -15px;width:60px;height:60px}.hair_bangs_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -106px;width:60px;height:60px}.hair_bangs_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -197px;width:60px;height:60px}.hair_bangs_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -197px;width:60px;height:60px}.hair_bangs_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -197px;width:60px;height:60px}.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -15px;width:60px;height:60px}.hair_bangs_1_porange{background-image:url(spritesmith-main-1.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_porange{background-image:url(spritesmith-main-1.png);background-position:-298px -106px;width:60px;height:60px}.hair_bangs_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-298px -197px;width:60px;height:60px}.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -288px;width:60px;height:60px}.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -288px;width:60px;height:60px}.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -288px;width:60px;height:60px}.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -288px;width:60px;height:60px}.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -15px;width:60px;height:60px}.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-389px -106px;width:60px;height:60px}.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-389px -197px;width:60px;height:60px}.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -288px;width:60px;height:60px}.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-25px -379px;width:60px;height:60px}.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-116px -379px;width:60px;height:60px}.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-207px -379px;width:60px;height:60px}.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-298px -379px;width:60px;height:60px}.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-389px -379px;width:60px;height:60px}.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-480px -15px;width:60px;height:60px}.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-480px -106px;width:60px;height:60px}.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-480px -197px;width:60px;height:60px}.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-480px -288px;width:60px;height:60px}.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-480px -379px;width:60px;height:60px}.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-25px -470px;width:60px;height:60px}.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-116px -470px;width:60px;height:60px}.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-207px -470px;width:60px;height:60px}.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-298px -470px;width:60px;height:60px}.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-389px -470px;width:60px;height:60px}.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-480px -470px;width:60px;height:60px}.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-571px -15px;width:60px;height:60px}.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-571px -106px;width:60px;height:60px}.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-571px -197px;width:60px;height:60px}.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-571px -288px;width:60px;height:60px}.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-571px -379px;width:60px;height:60px}.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-571px -470px;width:60px;height:60px}.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-25px -561px;width:60px;height:60px}.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-116px -561px;width:60px;height:60px}.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -561px;width:60px;height:60px}.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-298px -561px;width:60px;height:60px}.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-389px -561px;width:60px;height:60px}.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-480px -561px;width:60px;height:60px}.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-571px -561px;width:60px;height:60px}.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-662px -15px;width:60px;height:60px}.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-662px -106px;width:60px;height:60px}.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-662px -197px;width:60px;height:60px}.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -288px;width:60px;height:60px}.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-662px -379px;width:60px;height:60px}.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-662px -470px;width:60px;height:60px}.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-662px -561px;width:60px;height:60px}.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-25px -652px;width:60px;height:60px}.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-116px -652px;width:60px;height:60px}.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-207px -652px;width:60px;height:60px}.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-298px -652px;width:60px;height:60px}.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-389px -652px;width:60px;height:60px}.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-480px -652px;width:60px;height:60px}.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-571px -652px;width:60px;height:60px}.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-662px -652px;width:60px;height:60px}.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-753px -15px;width:60px;height:60px}.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-753px -106px;width:60px;height:60px}.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-753px -197px;width:60px;height:60px}.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-753px -288px;width:60px;height:60px}.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-753px -379px;width:60px;height:60px}.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-753px -470px;width:60px;height:60px}.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-753px -561px;width:60px;height:60px}.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-753px -652px;width:60px;height:60px}.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-25px -743px;width:60px;height:60px}.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-116px -743px;width:60px;height:60px}.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-207px -743px;width:60px;height:60px}.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-298px -743px;width:60px;height:60px}.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -743px;width:60px;height:60px}.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -743px;width:60px;height:60px}.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-571px -743px;width:60px;height:60px}.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-662px -743px;width:60px;height:60px}.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -743px;width:60px;height:60px}.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-844px -15px;width:60px;height:60px}.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-844px -106px;width:60px;height:60px}.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-844px -197px;width:60px;height:60px}.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-844px -288px;width:60px;height:60px}.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-844px -379px;width:60px;height:60px}.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -470px;width:60px;height:60px}.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-844px -561px;width:60px;height:60px}.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-844px -652px;width:60px;height:60px}.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-844px -743px;width:60px;height:60px}.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -834px;width:60px;height:60px}.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -834px;width:60px;height:60px}.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -834px;width:60px;height:60px}.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -834px;width:60px;height:60px}.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -834px;width:60px;height:60px}.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-480px -834px;width:60px;height:60px}.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-571px -834px;width:60px;height:60px}.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-662px -834px;width:60px;height:60px}.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-753px -834px;width:60px;height:60px}.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-844px -834px;width:60px;height:60px}.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-935px -15px;width:60px;height:60px}.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-935px -106px;width:60px;height:60px}.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-935px -197px;width:60px;height:60px}.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -288px;width:60px;height:60px}.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-935px -379px;width:60px;height:60px}.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1663px -15px;width:60px;height:60px}.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1663px -106px;width:60px;height:60px}.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1663px -197px;width:60px;height:60px}.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1663px -288px;width:60px;height:60px}.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1663px -379px;width:60px;height:60px}.hair_base_1_purple{background-image:url(spritesmith-main-2.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_base_1_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -15px;width:60px;height:60px}.hair_base_1_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_base_1_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -1107px;width:60px;height:60px}.hair_base_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_base_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-25px -106px;width:60px;height:60px}.hair_base_1_rainbow{background-image:url(spritesmith-main-2.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_base_1_rainbow{background-image:url(spritesmith-main-2.png);background-position:-116px -106px;width:60px;height:60px}.hair_base_1_red{background-image:url(spritesmith-main-2.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_base_1_red{background-image:url(spritesmith-main-2.png);background-position:-207px -15px;width:60px;height:60px}.hair_base_1_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_base_1_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -106px;width:60px;height:60px}.hair_base_1_white{background-image:url(spritesmith-main-2.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_base_1_white{background-image:url(spritesmith-main-2.png);background-position:-25px -197px;width:60px;height:60px}.hair_base_1_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_base_1_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -197px;width:60px;height:60px}.hair_base_1_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_base_1_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -197px;width:60px;height:60px}.hair_base_1_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_base_1_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -15px;width:60px;height:60px}.hair_base_1_zombie{background-image:url(spritesmith-main-2.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_base_1_zombie{background-image:url(spritesmith-main-2.png);background-position:-298px -106px;width:60px;height:60px}.hair_base_2_TRUred{background-image:url(spritesmith-main-2.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_base_2_TRUred{background-image:url(spritesmith-main-2.png);background-position:-298px -197px;width:60px;height:60px}.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -288px;width:60px;height:60px}.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-116px -288px;width:60px;height:60px}.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -288px;width:60px;height:60px}.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -288px;width:60px;height:60px}.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -15px;width:60px;height:60px}.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-389px -106px;width:60px;height:60px}.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-389px -197px;width:60px;height:60px}.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -288px;width:60px;height:60px}.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-25px -379px;width:60px;height:60px}.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-116px -379px;width:60px;height:60px}.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-207px -379px;width:60px;height:60px}.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-298px -379px;width:60px;height:60px}.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-389px -379px;width:60px;height:60px}.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-480px -15px;width:60px;height:60px}.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-480px -106px;width:60px;height:60px}.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-480px -197px;width:60px;height:60px}.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-480px -288px;width:60px;height:60px}.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-480px -379px;width:60px;height:60px}.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-25px -470px;width:60px;height:60px}.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-116px -470px;width:60px;height:60px}.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-207px -470px;width:60px;height:60px}.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-298px -470px;width:60px;height:60px}.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-389px -470px;width:60px;height:60px}.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-480px -470px;width:60px;height:60px}.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-571px -15px;width:60px;height:60px}.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-571px -106px;width:60px;height:60px}.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-571px -197px;width:60px;height:60px}.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -288px;width:60px;height:60px}.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-571px -379px;width:60px;height:60px}.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-571px -470px;width:60px;height:60px}.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:-25px -561px;width:60px;height:60px}.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-116px -561px;width:60px;height:60px}.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -561px;width:60px;height:60px}.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-298px -561px;width:60px;height:60px}.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-389px -561px;width:60px;height:60px}.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-480px -561px;width:60px;height:60px}.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-571px -561px;width:60px;height:60px}.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-662px -15px;width:60px;height:60px}.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-662px -106px;width:60px;height:60px}.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-662px -197px;width:60px;height:60px}.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-662px -288px;width:60px;height:60px}.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-662px -379px;width:60px;height:60px}.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-662px -470px;width:60px;height:60px}.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-662px -561px;width:60px;height:60px}.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-25px -652px;width:60px;height:60px}.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-116px -652px;width:60px;height:60px}.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-207px -652px;width:60px;height:60px}.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-298px -652px;width:60px;height:60px}.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-389px -652px;width:60px;height:60px}.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-480px -652px;width:60px;height:60px}.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-571px -652px;width:60px;height:60px}.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-662px -652px;width:60px;height:60px}.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-753px -15px;width:60px;height:60px}.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-753px -106px;width:60px;height:60px}.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-753px -197px;width:60px;height:60px}.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-753px -288px;width:60px;height:60px}.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-753px -379px;width:60px;height:60px}.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-753px -470px;width:60px;height:60px}.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-753px -561px;width:60px;height:60px}.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-753px -652px;width:60px;height:60px}.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -743px;width:60px;height:60px}.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-116px -743px;width:60px;height:60px}.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-207px -743px;width:60px;height:60px}.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-298px -743px;width:60px;height:60px}.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -743px;width:60px;height:60px}.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -743px;width:60px;height:60px}.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -743px;width:60px;height:60px}.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-662px -743px;width:60px;height:60px}.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -743px;width:60px;height:60px}.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-844px -15px;width:60px;height:60px}.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-844px -106px;width:60px;height:60px}.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-844px -197px;width:60px;height:60px}.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-844px -288px;width:60px;height:60px}.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-844px -379px;width:60px;height:60px}.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -470px;width:60px;height:60px}.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-844px -561px;width:60px;height:60px}.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-844px -652px;width:60px;height:60px}.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-844px -743px;width:60px;height:60px}.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -834px;width:60px;height:60px}.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-116px -834px;width:60px;height:60px}.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -834px;width:60px;height:60px}.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1663px -15px;width:60px;height:60px}.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1663px -106px;width:60px;height:60px}.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1663px -197px;width:60px;height:60px}.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1663px -288px;width:60px;height:60px}.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1663px -379px;width:60px;height:60px}.hair_beard_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-454px -273px;width:90px;height:90px}.customize-option.hair_beard_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-479px -288px;width:60px;height:60px}.hair_beard_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1276px -728px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1301px -743px;width:60px;height:60px}.hair_beard_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1276px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1301px -1107px;width:60px;height:60px}.hair_beard_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-207px -1289px;width:60px;height:60px}.hair_beard_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-298px -1289px;width:60px;height:60px}.hair_beard_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-480px -1289px;width:60px;height:60px}.hair_beard_3_pblue2{background-image:url(spritesmith-main-3.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_pblue2{background-image:url(spritesmith-main-3.png);background-position:-571px -1289px;width:60px;height:60px}.hair_beard_3_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-935px -1289px;width:60px;height:60px}.hair_beard_3_porange2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_porange2{background-image:url(spritesmith-main-3.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_beard_3_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_beard_3_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1367px -91px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1392px -106px;width:60px;height:60px}.hair_beard_3_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1367px -182px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1392px -197px;width:60px;height:60px}.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1367px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1392px -379px;width:60px;height:60px}.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1367px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1392px -470px;width:60px;height:60px}.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -1471px;width:60px;height:60px}.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-116px -1471px;width:60px;height:60px}.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-298px -1471px;width:60px;height:60px}.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-389px -1471px;width:60px;height:60px}.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-753px -1471px;width:60px;height:60px}.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-844px -1471px;width:60px;height:60px}.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -379px;width:60px;height:60px}.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-116px -379px;width:60px;height:60px}.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-207px -379px;width:60px;height:60px}.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-298px -379px;width:60px;height:60px}.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-389px -394px;width:60px;height:60px}.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-480px -394px;width:60px;height:60px}.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:0 -455px;width:90px;height:90px}.customize-option.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-25px -485px;width:60px;height:60px}.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-116px -485px;width:60px;height:60px}.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-207px -485px;width:60px;height:60px}.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-298px -485px;width:60px;height:60px}.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-389px -485px;width:60px;height:60px}.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-480px -485px;width:60px;height:60px}.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-548px 0;width:90px;height:90px}.customize-option.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-573px -30px;width:60px;height:60px}.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-548px -91px;width:90px;height:90px}.customize-option.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-573px -121px;width:60px;height:60px}.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-548px -182px;width:90px;height:90px}.customize-option.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-573px -212px;width:60px;height:60px}.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-548px -273px;width:90px;height:90px}.customize-option.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-573px -303px;width:60px;height:60px}.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-548px -364px;width:90px;height:90px}.customize-option.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-573px -394px;width:60px;height:60px}.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-548px -455px;width:90px;height:90px}.customize-option.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-573px -485px;width:60px;height:60px}.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:0 -546px;width:90px;height:90px}.customize-option.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-25px -576px;width:60px;height:60px}.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-116px -576px;width:60px;height:60px}.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-207px -576px;width:60px;height:60px}.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-298px -576px;width:60px;height:60px}.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-389px -576px;width:60px;height:60px}.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-480px -576px;width:60px;height:60px}.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-571px -576px;width:60px;height:60px}.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-639px 0;width:90px;height:90px}.customize-option.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-664px -30px;width:60px;height:60px}.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-639px -91px;width:90px;height:90px}.customize-option.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-664px -121px;width:60px;height:60px}.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-639px -182px;width:90px;height:90px}.customize-option.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-664px -212px;width:60px;height:60px}.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-639px -273px;width:90px;height:90px}.customize-option.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-664px -303px;width:60px;height:60px}.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-639px -364px;width:90px;height:90px}.customize-option.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-664px -394px;width:60px;height:60px}.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-639px -455px;width:90px;height:90px}.customize-option.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-664px -485px;width:60px;height:60px}.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-639px -546px;width:90px;height:90px}.customize-option.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-664px -576px;width:60px;height:60px}.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:0 -637px;width:90px;height:90px}.customize-option.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-25px -667px;width:60px;height:60px}.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-116px -667px;width:60px;height:60px}.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-207px -667px;width:60px;height:60px}.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-298px -667px;width:60px;height:60px}.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-389px -667px;width:60px;height:60px}.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-480px -667px;width:60px;height:60px}.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-571px -652px;width:60px;height:60px}.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -652px;width:60px;height:60px}.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-730px 0;width:90px;height:90px}.customize-option.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-755px -15px;width:60px;height:60px}.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -91px;width:90px;height:90px}.customize-option.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -106px;width:60px;height:60px}.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-730px -182px;width:90px;height:90px}.customize-option.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-755px -197px;width:60px;height:60px}.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -273px;width:90px;height:90px}.customize-option.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -288px;width:60px;height:60px}.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-730px -364px;width:90px;height:90px}.customize-option.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-755px -379px;width:60px;height:60px}.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -455px;width:90px;height:90px}.customize-option.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -470px;width:60px;height:60px}.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-730px -546px;width:90px;height:90px}.customize-option.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-755px -561px;width:60px;height:60px}.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -637px;width:90px;height:90px}.customize-option.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -652px;width:60px;height:60px}.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:0 -728px;width:90px;height:90px}.customize-option.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:-25px -743px;width:60px;height:60px}.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -743px;width:60px;height:60px}.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-207px -743px;width:60px;height:60px}.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -743px;width:60px;height:60px}.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-389px -743px;width:60px;height:60px}.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -743px;width:60px;height:60px}.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-571px -743px;width:60px;height:60px}.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -743px;width:60px;height:60px}.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-753px -743px;width:60px;height:60px}.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px 0;width:90px;height:90px}.customize-option.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -15px;width:60px;height:60px}.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-821px -91px;width:90px;height:90px}.customize-option.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-846px -106px;width:60px;height:60px}.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -182px;width:90px;height:90px}.customize-option.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -197px;width:60px;height:60px}.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-821px -273px;width:90px;height:90px}.customize-option.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-846px -288px;width:60px;height:60px}.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -364px;width:90px;height:90px}.customize-option.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -379px;width:60px;height:60px}.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-821px -455px;width:90px;height:90px}.customize-option.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-846px -470px;width:60px;height:60px}.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -546px;width:90px;height:90px}.customize-option.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -561px;width:60px;height:60px}.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-821px -637px;width:90px;height:90px}.customize-option.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-846px -652px;width:60px;height:60px}.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -728px;width:90px;height:90px}.customize-option.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -743px;width:60px;height:60px}.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:0 -819px;width:90px;height:90px}.customize-option.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-25px -834px;width:60px;height:60px}.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -834px;width:60px;height:60px}.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-207px -834px;width:60px;height:60px}.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -834px;width:60px;height:60px}.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-389px -834px;width:60px;height:60px}.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -834px;width:60px;height:60px}.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-571px -834px;width:60px;height:60px}.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -834px;width:60px;height:60px}.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-753px -834px;width:60px;height:60px}.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -834px;width:60px;height:60px}.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-912px 0;width:90px;height:90px}.customize-option.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-937px -15px;width:60px;height:60px}.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -91px;width:90px;height:90px}.customize-option.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -106px;width:60px;height:60px}.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-912px -182px;width:90px;height:90px}.customize-option.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-937px -197px;width:60px;height:60px}.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -273px;width:90px;height:90px}.customize-option.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -288px;width:60px;height:60px}.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-912px -364px;width:90px;height:90px}.customize-option.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-937px -379px;width:60px;height:60px}.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -455px;width:90px;height:90px}.customize-option.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -470px;width:60px;height:60px}.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-912px -546px;width:90px;height:90px}.customize-option.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-937px -561px;width:60px;height:60px}.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -637px;width:90px;height:90px}.customize-option.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -652px;width:60px;height:60px}.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-912px -728px;width:90px;height:90px}.customize-option.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-937px -743px;width:60px;height:60px}.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -819px;width:90px;height:90px}.customize-option.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -834px;width:60px;height:60px}.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:0 -910px;width:90px;height:90px}.customize-option.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-25px -925px;width:60px;height:60px}.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -925px;width:60px;height:60px}.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-207px -925px;width:60px;height:60px}.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -925px;width:60px;height:60px}.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-389px -925px;width:60px;height:60px}.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -925px;width:60px;height:60px}.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-571px -925px;width:60px;height:60px}.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -925px;width:60px;height:60px}.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-753px -925px;width:60px;height:60px}.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -925px;width:60px;height:60px}.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-935px -925px;width:60px;height:60px}.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px 0;width:90px;height:90px}.customize-option.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -15px;width:60px;height:60px}.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1003px -91px;width:90px;height:90px}.customize-option.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1028px -106px;width:60px;height:60px}.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -182px;width:90px;height:90px}.customize-option.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -197px;width:60px;height:60px}.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1003px -273px;width:90px;height:90px}.customize-option.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1028px -288px;width:60px;height:60px}.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -364px;width:90px;height:90px}.customize-option.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -379px;width:60px;height:60px}.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-1003px -455px;width:90px;height:90px}.customize-option.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-1028px -470px;width:60px;height:60px}.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -546px;width:90px;height:90px}.customize-option.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -561px;width:60px;height:60px}.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-1003px -637px;width:90px;height:90px}.customize-option.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-1028px -652px;width:60px;height:60px}.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -728px;width:90px;height:90px}.customize-option.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -743px;width:60px;height:60px}.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-1003px -819px;width:90px;height:90px}.customize-option.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-1028px -834px;width:60px;height:60px}.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -925px;width:60px;height:60px}.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-25px -1016px;width:60px;height:60px}.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1016px;width:60px;height:60px}.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-207px -1016px;width:60px;height:60px}.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1016px;width:60px;height:60px}.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-389px -1016px;width:60px;height:60px}.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1016px;width:60px;height:60px}.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-571px -1016px;width:60px;height:60px}.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1016px;width:60px;height:60px}.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-753px -1016px;width:60px;height:60px}.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-844px -1016px;width:60px;height:60px}.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-935px -1016px;width:60px;height:60px}.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1026px -1016px;width:60px;height:60px}.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1094px 0;width:90px;height:90px}.customize-option.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1119px -15px;width:60px;height:60px}.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -91px;width:90px;height:90px}.customize-option.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -106px;width:60px;height:60px}.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1094px -182px;width:90px;height:90px}.customize-option.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1119px -197px;width:60px;height:60px}.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -273px;width:90px;height:90px}.customize-option.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -288px;width:60px;height:60px}.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1094px -364px;width:90px;height:90px}.customize-option.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1119px -379px;width:60px;height:60px}.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-1094px -455px;width:90px;height:90px}.customize-option.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-1119px -470px;width:60px;height:60px}.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -546px;width:90px;height:90px}.customize-option.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -561px;width:60px;height:60px}.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -637px;width:90px;height:90px}.customize-option.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -652px;width:60px;height:60px}.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-1094px -728px;width:90px;height:90px}.customize-option.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-1119px -743px;width:60px;height:60px}.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -819px;width:90px;height:90px}.customize-option.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -834px;width:60px;height:60px}.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-1094px -910px;width:90px;height:90px}.customize-option.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-1119px -925px;width:60px;height:60px}.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-1094px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-1119px -1016px;width:60px;height:60px}.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-25px -1107px;width:60px;height:60px}.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1107px;width:60px;height:60px}.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-207px -1107px;width:60px;height:60px}.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1107px;width:60px;height:60px}.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-389px -1107px;width:60px;height:60px}.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1107px;width:60px;height:60px}.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-571px -1107px;width:60px;height:60px}.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1107px;width:60px;height:60px}.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-753px -1107px;width:60px;height:60px}.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -1107px;width:60px;height:60px}.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-935px -1107px;width:60px;height:60px}.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1026px -1107px;width:60px;height:60px}.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1117px -1107px;width:60px;height:60px}.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1185px 0;width:90px;height:90px}.customize-option.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1210px -15px;width:60px;height:60px}.broad_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -91px;width:90px;height:90px}.broad_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1185px -182px;width:90px;height:90px}.broad_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -273px;width:90px;height:90px}.broad_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -364px;width:90px;height:90px}.broad_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1185px -455px;width:90px;height:90px}.broad_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -546px;width:90px;height:90px}.broad_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -637px;width:90px;height:90px}.broad_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -728px;width:90px;height:90px}.eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1185px -819px;width:90px;height:90px}.head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1185px -910px;width:90px;height:90px}.head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1185px -1001px;width:90px;height:90px}.head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1185px -1092px;width:90px;height:90px}.head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:0 -1183px;width:90px;height:90px}.head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-91px -1183px;width:90px;height:90px}.head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-182px -1183px;width:90px;height:90px}.head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-273px -1183px;width:90px;height:90px}.head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-364px -1183px;width:90px;height:90px}.head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-455px -1183px;width:90px;height:90px}.head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-546px -1183px;width:90px;height:90px}.head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-637px -1183px;width:90px;height:90px}.head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-728px -1183px;width:90px;height:90px}.head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-819px -1183px;width:90px;height:90px}.head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-910px -1183px;width:90px;height:90px}.head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1001px -1183px;width:90px;height:90px}.head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1092px -1183px;width:90px;height:90px}.shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1183px -1183px;width:90px;height:90px}.shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1276px 0;width:90px;height:90px}.shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1276px -91px;width:90px;height:90px}.shop_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -943px;width:40px;height:40px}.shop_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1640px -779px;width:40px;height:40px}.shop_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -738px;width:40px;height:40px}.shop_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -697px;width:40px;height:40px}.shop_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1640px -656px;width:40px;height:40px}.shop_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -533px;width:40px;height:40px}.shop_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -984px;width:40px;height:40px}.shop_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -492px;width:40px;height:40px}.shop_eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1640px -451px;width:40px;height:40px}.shop_head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -410px;width:40px;height:40px}.shop_head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -369px;width:40px;height:40px}.shop_head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1640px -328px;width:40px;height:40px}.shop_head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -287px;width:40px;height:40px}.shop_head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-1640px -246px;width:40px;height:40px}.shop_head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -205px;width:40px;height:40px}.shop_head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-1640px -164px;width:40px;height:40px}.shop_head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -123px;width:40px;height:40px}.shop_head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -82px;width:40px;height:40px}.shop_head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -41px;width:40px;height:40px}.shop_head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1640px 0;width:40px;height:40px}.shop_head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1576px -1588px;width:40px;height:40px}.shop_head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1535px -1588px;width:40px;height:40px}.shop_head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-1494px -1588px;width:40px;height:40px}.shop_head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1453px -1588px;width:40px;height:40px}.shop_head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1588px;width:40px;height:40px}.shop_shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1576px -1547px;width:40px;height:40px}.shop_shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1535px -1547px;width:40px;height:40px}.shop_shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1494px -1547px;width:40px;height:40px}.shop_weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-1453px -1547px;width:40px;height:40px}.shop_weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-1412px -1547px;width:40px;height:40px}.shop_weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-1371px -1547px;width:40px;height:40px}.shop_weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-1330px -1547px;width:40px;height:40px}.shop_weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-1289px -1547px;width:40px;height:40px}.shop_weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-1248px -1547px;width:40px;height:40px}.shop_weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-1207px -1547px;width:40px;height:40px}.shop_weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-1166px -1547px;width:40px;height:40px}.slim_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -819px;width:90px;height:90px}.slim_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1367px -910px;width:90px;height:90px}.slim_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -1001px;width:90px;height:90px}.slim_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -1092px;width:90px;height:90px}.slim_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1367px -1183px;width:90px;height:90px}.slim_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1367px -1274px;width:90px;height:90px}.slim_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:0 -1365px;width:90px;height:90px}.slim_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-91px -1365px;width:90px;height:90px}.weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1365px;width:90px;height:90px}.weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-273px -1365px;width:90px;height:90px}.weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-364px -1365px;width:90px;height:90px}.weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-455px -1365px;width:90px;height:90px}.weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-546px -1365px;width:90px;height:90px}.weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-637px -1365px;width:90px;height:90px}.weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-728px -1365px;width:90px;height:90px}.weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-819px -1365px;width:90px;height:90px}.broad_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-910px -1365px;width:90px;height:90px}.broad_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1365px;width:90px;height:90px}.broad_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1092px -1365px;width:90px;height:90px}.broad_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1183px -1365px;width:90px;height:90px}.broad_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1365px;width:90px;height:90px}.broad_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1365px -1365px;width:90px;height:90px}.broad_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1458px 0;width:90px;height:90px}.broad_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -91px;width:90px;height:90px}.broad_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -182px;width:90px;height:90px}.broad_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -273px;width:90px;height:90px}.broad_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -364px;width:90px;height:90px}.broad_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1458px -455px;width:90px;height:90px}.broad_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -546px;width:90px;height:90px}.broad_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -637px;width:90px;height:90px}.broad_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -728px;width:90px;height:90px}.broad_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -819px;width:90px;height:90px}.broad_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -910px;width:90px;height:90px}.broad_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -1001px;width:90px;height:90px}.broad_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -1092px;width:90px;height:90px}.broad_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -1183px;width:90px;height:90px}.broad_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -1274px;width:90px;height:90px}.broad_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -1365px;width:90px;height:90px}.shop_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1125px -1547px;width:40px;height:40px}.shop_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1084px -1547px;width:40px;height:40px}.shop_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1043px -1547px;width:40px;height:40px}.shop_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1002px -1547px;width:40px;height:40px}.shop_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-961px -1547px;width:40px;height:40px}.shop_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-920px -1547px;width:40px;height:40px}.shop_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-879px -1547px;width:40px;height:40px}.shop_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-838px -1547px;width:40px;height:40px}.shop_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-797px -1547px;width:40px;height:40px}.shop_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-756px -1547px;width:40px;height:40px}.shop_armor_special_0{background-image:url(spritesmith-main-3.png);background-position:-715px -1547px;width:40px;height:40px}.shop_armor_special_1{background-image:url(spritesmith-main-3.png);background-position:-674px -1547px;width:40px;height:40px}.shop_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-551px -1547px;width:40px;height:40px}.shop_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-510px -1547px;width:40px;height:40px}.shop_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-469px -1547px;width:40px;height:40px}.shop_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-428px -1547px;width:40px;height:40px}.shop_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-387px -1547px;width:40px;height:40px}.shop_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-346px -1547px;width:40px;height:40px}.shop_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-305px -1547px;width:40px;height:40px}.shop_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-264px -1547px;width:40px;height:40px}.shop_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-223px -1547px;width:40px;height:40px}.shop_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-182px -1547px;width:40px;height:40px}.shop_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-633px -1588px;width:40px;height:40px}.shop_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-400px -314px;width:40px;height:40px}.slim_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -637px;width:90px;height:90px}.slim_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -728px;width:90px;height:90px}.slim_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -819px;width:90px;height:90px}.slim_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -910px;width:90px;height:90px}.slim_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -1001px;width:90px;height:90px}.slim_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -1092px;width:90px;height:90px}.slim_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -1183px;width:90px;height:90px}.slim_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -1274px;width:90px;height:90px}.slim_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -1365px;width:90px;height:90px}.slim_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -1456px;width:90px;height:90px}.slim_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:0 -1547px;width:90px;height:90px}.slim_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-91px -1547px;width:90px;height:90px}.slim_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -546px;width:90px;height:90px}.slim_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -455px;width:90px;height:90px}.slim_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -364px;width:90px;height:90px}.slim_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -273px;width:90px;height:90px}.slim_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -182px;width:90px;height:90px}.slim_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -91px;width:90px;height:90px}.slim_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1549px 0;width:90px;height:90px}.slim_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1456px -1456px;width:90px;height:90px}.slim_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1365px -1456px;width:90px;height:90px}.slim_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1456px;width:90px;height:90px}.broad_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1183px -1456px;width:90px;height:90px}.broad_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-1092px -1456px;width:90px;height:90px}.shop_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-592px -1547px;width:40px;height:40px}.shop_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-633px -1547px;width:40px;height:40px}.slim_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1001px -1456px;width:90px;height:90px}.slim_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-910px -1456px;width:90px;height:90px}.broad_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-212px -273px;width:93px;height:90px}.broad_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -273px;width:105px;height:90px}.broad_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-637px -1456px;width:90px;height:90px}.broad_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-546px -1456px;width:90px;height:90px}.broad_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-455px -1456px;width:90px;height:90px}.broad_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px -91px;width:120px;height:90px}.broad_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px -182px;width:105px;height:90px}.broad_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-182px -1456px;width:90px;height:90px}.head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-306px -273px;width:93px;height:90px}.head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-348px -91px;width:105px;height:90px}.head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1367px -728px;width:90px;height:90px}.head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1367px -637px;width:90px;height:90px}.head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1367px -546px;width:90px;height:90px}.head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 -91px;width:120px;height:90px}.head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-212px -182px;width:105px;height:90px}.head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1367px -273px;width:90px;height:90px}.shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px 0;width:93px;height:90px}.shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:0 -273px;width:105px;height:90px}.shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1367px 0;width:90px;height:90px}.shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1274px -1274px;width:90px;height:90px}.shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:0 -182px;width:105px;height:90px}.shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1092px -1274px;width:90px;height:90px}.shop_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-223px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-264px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-305px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-346px -1588px;width:40px;height:40px}.shop_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-387px -1588px;width:40px;height:40px}.shop_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-428px -1588px;width:40px;height:40px}.shop_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-469px -1588px;width:40px;height:40px}.shop_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-510px -1588px;width:40px;height:40px}.shop_head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-551px -1588px;width:40px;height:40px}.shop_head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-592px -1588px;width:40px;height:40px}.shop_head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-400px -273px;width:40px;height:40px}.shop_head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-674px -1588px;width:40px;height:40px}.shop_head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-715px -1588px;width:40px;height:40px}.shop_head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-756px -1588px;width:40px;height:40px}.shop_head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-797px -1588px;width:40px;height:40px}.shop_head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-838px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-879px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-920px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-961px -1588px;width:40px;height:40px}.shop_shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1002px -1588px;width:40px;height:40px}.shop_shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1043px -1588px;width:40px;height:40px}.shop_shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1084px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-1125px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-1166px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1207px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1248px -1588px;width:40px;height:40px}.shop_weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1289px -1588px;width:40px;height:40px}.shop_weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-1330px -1588px;width:40px;height:40px}.shop_weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1371px -1588px;width:40px;height:40px}.shop_weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1412px -1588px;width:40px;height:40px}.slim_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px -91px;width:93px;height:90px}.slim_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-242px -91px;width:105px;height:90px}.slim_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-819px -1274px;width:90px;height:90px}.slim_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-728px -1274px;width:90px;height:90px}.slim_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-637px -1274px;width:90px;height:90px}.slim_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 0;width:120px;height:90px}.slim_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px 0;width:105px;height:90px}.slim_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-364px -1274px;width:90px;height:90px}.weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px -182px;width:93px;height:90px}.weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -182px;width:105px;height:90px}.weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-91px -1274px;width:90px;height:90px}.weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:0 -1274px;width:90px;height:90px}.weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1276px -1183px;width:90px;height:90px}.weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px 0;width:120px;height:90px}.weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-242px 0;width:105px;height:90px}.weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1276px -910px;width:90px;height:90px}.broad_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -819px;width:90px;height:90px}.head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -637px;width:90px;height:90px}.shop_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1640px -574px;width:40px;height:40px}.shop_head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1640px -615px;width:40px;height:40px}.slim_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -546px;width:90px;height:90px}.back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -455px;width:90px;height:90px}.broad_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -364px;width:90px;height:90px}.head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -273px;width:90px;height:90px}.shop_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -820px;width:40px;height:40px}.shop_back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -861px;width:40px;height:40px}.shop_head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -902px;width:40px;height:40px}.slim_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -182px;width:90px;height:90px}.broad_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1276px -1001px;width:90px;height:90px}.headAccessory_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-739px -309px;width:90px;height:90px}.shop_armor_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1018px -910px;width:40px;height:40px}.shop_headAccessory_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1646px -820px;width:40px;height:40px}.slim_armor_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-461px -788px;width:90px;height:90px}.back_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-546px -1152px;width:90px;height:90px}.headAccessory_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1183px -1243px;width:90px;height:90px}.shop_back_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1646px -779px;width:40px;height:40px}.shop_headAccessory_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1646px -738px;width:40px;height:40px}.broad_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -364px;width:90px;height:90px}.head_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -455px;width:90px;height:90px}.shop_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-82px -1598px;width:40px;height:40px}.shop_head_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-41px -1598px;width:40px;height:40px}.slim_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -819px;width:90px;height:90px}.broad_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-739px -212px;width:90px;height:96px}.head_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-830px -188px;width:90px;height:96px}.shop_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:0 -1598px;width:40px;height:40px}.shop_head_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-1605px -1517px;width:40px;height:40px}.slim_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-830px -91px;width:90px;height:96px}.broad_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-552px -788px;width:90px;height:90px}.head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-825px -788px;width:90px;height:90px}.shop_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -1476px;width:40px;height:40px}.shop_head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -1435px;width:40px;height:40px}.slim_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:0 -879px;width:90px;height:90px}.broad_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-91px -879px;width:90px;height:90px}.head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1200px -910px;width:90px;height:90px}.shop_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1605px -1394px;width:40px;height:40px}.shop_head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1605px -1353px;width:40px;height:40px}.slim_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1291px -273px;width:90px;height:90px}.broad_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1001px -1243px;width:90px;height:90px}.headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1092px -1243px;width:90px;height:90px}.shop_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1605px -1312px;width:40px;height:40px}.shop_headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1605px -1271px;width:40px;height:40px}.slim_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1382px -182px;width:90px;height:90px}.back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -649px;width:93px;height:90px}.broad_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -558px;width:93px;height:90px}.shop_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1605px -1230px;width:40px;height:40px}.shop_back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1605px -1189px;width:40px;height:40px}.slim_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -376px;width:93px;height:90px}.head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -728px;width:90px;height:90px}.shop_head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1605px -1148px;width:40px;height:40px}.shop_weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1605px -1107px;width:40px;height:40px}.weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -1183px;width:90px;height:90px}.broad_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:0 -1334px;width:90px;height:90px}.head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-91px -1334px;width:90px;height:90px}.shop_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1605px -1066px;width:40px;height:40px}.shop_head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1605px -1025px;width:40px;height:40px}.slim_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-364px -1334px;width:90px;height:90px}.broad_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-455px -1334px;width:90px;height:90px}.head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-546px -1334px;width:90px;height:90px}.shop_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1605px -984px;width:40px;height:40px}.shop_head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1605px -943px;width:40px;height:40px}.slim_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-910px -1334px;width:90px;height:90px}.headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1001px -1334px;width:90px;height:90px}.shop_headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1605px -902px;width:40px;height:40px}.shop_weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1605px -861px;width:40px;height:40px}.weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1274px -1334px;width:90px;height:90px}.broad_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1473px -728px;width:90px;height:90px}.eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1473px -1274px;width:90px;height:90px}.shop_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1605px -492px;width:40px;height:40px}.shop_eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1605px -451px;width:40px;height:40px}.slim_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-182px -1425px;width:90px;height:90px}.back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-273px -1425px;width:90px;height:90px}.broad_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-364px -1425px;width:90px;height:90px}.shop_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1605px -410px;width:40px;height:40px}.shop_back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1605px -369px;width:40px;height:40px}.slim_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-819px -1425px;width:90px;height:90px}.head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-910px -1425px;width:90px;height:90px}.shop_head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1605px -328px;width:40px;height:40px}.shop_weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1605px -287px;width:40px;height:40px}.weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-739px -400px;width:90px;height:90px}.broad_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-546px -485px;width:90px;height:105px}.eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-648px -106px;width:90px;height:105px}.shop_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1605px -246px;width:40px;height:40px}.shop_eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1605px -205px;width:40px;height:40px}.slim_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-648px -318px;width:90px;height:105px}.back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-648px -424px;width:90px;height:105px}.eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:0 -591px;width:90px;height:105px}.shop_back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-779px -1557px;width:40px;height:40px}.shop_eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-738px -1557px;width:40px;height:40px}.broad_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:0 -788px;width:93px;height:90px}.head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -467px;width:93px;height:90px}.shop_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-697px -1557px;width:40px;height:40px}.shop_head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-656px -1557px;width:40px;height:40px}.slim_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -285px;width:93px;height:90px}.broad_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -364px;width:90px;height:90px}.head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -455px;width:90px;height:90px}.shop_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-615px -1557px;width:40px;height:40px}.shop_head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1473px -1365px;width:40px;height:40px}.slim_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -728px;width:90px;height:90px}.back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-536px -394px;width:105px;height:90px}.headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-94px -788px;width:93px;height:90px}.shop_back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-533px -1557px;width:40px;height:40px}.shop_headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-492px -1557px;width:40px;height:40px}.broad_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-364px -879px;width:90px;height:90px}.eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-455px -879px;width:90px;height:90px}.head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-546px -879px;width:90px;height:90px}.shop_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-451px -1557px;width:40px;height:40px}.shop_eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-410px -1557px;width:40px;height:40px}.shop_head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-369px -1557px;width:40px;height:40px}.shop_weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-328px -1557px;width:40px;height:40px}.slim_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1018px 0;width:90px;height:90px}.weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1018px -91px;width:90px;height:90px}.eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -182px;width:90px;height:90px}.headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -273px;width:90px;height:90px}.head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -364px;width:90px;height:90px}.shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -455px;width:90px;height:90px}.shop_eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-287px -1557px;width:40px;height:40px}.shop_headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-246px -1557px;width:40px;height:40px}.shop_head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-205px -1557px;width:40px;height:40px}.shop_shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-164px -1557px;width:40px;height:40px}.broad_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:0 -970px;width:90px;height:90px}.broad_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-91px -970px;width:90px;height:90px}.broad_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-182px -970px;width:90px;height:90px}.broad_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -970px;width:90px;height:90px}.broad_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -970px;width:90px;height:90px}.broad_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-455px -970px;width:90px;height:90px}.broad_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-546px -970px;width:90px;height:90px}.broad_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-637px -970px;width:90px;height:90px}.headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -970px;width:90px;height:90px}.headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -970px;width:90px;height:90px}.headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-910px -970px;width:90px;height:90px}.headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1001px -970px;width:90px;height:90px}.headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1109px 0;width:90px;height:90px}.headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1109px -91px;width:90px;height:90px}.headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -182px;width:90px;height:90px}.headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -273px;width:90px;height:90px}.head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1109px -364px;width:90px;height:90px}.head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1109px -455px;width:90px;height:90px}.head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -546px;width:90px;height:90px}.head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -637px;width:90px;height:90px}.head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1109px -728px;width:90px;height:90px}.head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1109px -819px;width:90px;height:90px}.head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -910px;width:90px;height:90px}.head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:0 -1061px;width:90px;height:90px}.shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1061px;width:90px;height:90px}.shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-182px -1061px;width:90px;height:90px}.shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -1061px;width:90px;height:90px}.shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -1061px;width:90px;height:90px}.shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-455px -1061px;width:90px;height:90px}.shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-546px -1061px;width:90px;height:90px}.shop_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-123px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-82px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-41px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -1557px;width:40px;height:40px}.shop_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1476px;width:40px;height:40px}.shop_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -1435px;width:40px;height:40px}.shop_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1394px;width:40px;height:40px}.shop_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1066px;width:40px;height:40px}.shop_headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1025px;width:40px;height:40px}.shop_headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -984px;width:40px;height:40px}.shop_headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -943px;width:40px;height:40px}.shop_headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -902px;width:40px;height:40px}.shop_headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -861px;width:40px;height:40px}.shop_headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -820px;width:40px;height:40px}.shop_headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -779px;width:40px;height:40px}.shop_headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -738px;width:40px;height:40px}.shop_head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -697px;width:40px;height:40px}.shop_head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -656px;width:40px;height:40px}.shop_head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -615px;width:40px;height:40px}.shop_head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -574px;width:40px;height:40px}.shop_head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -533px;width:40px;height:40px}.shop_head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -492px;width:40px;height:40px}.shop_head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -451px;width:40px;height:40px}.shop_head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -410px;width:40px;height:40px}.shop_shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -369px;width:40px;height:40px}.shop_shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -328px;width:40px;height:40px}.shop_shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -287px;width:40px;height:40px}.shop_shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -246px;width:40px;height:40px}.shop_shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -205px;width:40px;height:40px}.shop_shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -164px;width:40px;height:40px}.shop_weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-369px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-328px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-287px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-246px -1516px;width:40px;height:40px}.shop_weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-205px -1516px;width:40px;height:40px}.shop_weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-164px -1516px;width:40px;height:40px}.shop_weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-123px -1516px;width:40px;height:40px}.shop_weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-82px -1516px;width:40px;height:40px}.slim_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -546px;width:90px;height:90px}.slim_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1291px -637px;width:90px;height:90px}.slim_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -728px;width:90px;height:90px}.slim_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -819px;width:90px;height:90px}.slim_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1291px -910px;width:90px;height:90px}.slim_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1291px -1001px;width:90px;height:90px}.slim_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -1092px;width:90px;height:90px}.slim_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:0 -1243px;width:90px;height:90px}.weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1243px;width:90px;height:90px}.weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-182px -1243px;width:90px;height:90px}.weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-273px -1243px;width:90px;height:90px}.weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-364px -1243px;width:90px;height:90px}.weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -1243px;width:90px;height:90px}.weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-546px -1243px;width:90px;height:90px}.weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-637px -1243px;width:90px;height:90px}.weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-728px -1243px;width:90px;height:90px}.body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-819px -1243px;width:90px;height:90px}.body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-910px -1243px;width:90px;height:90px}.body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px -106px;width:102px;height:105px}.body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -485px;width:90px;height:105px}.body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -485px;width:90px;height:105px}.body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-455px -485px;width:90px;height:105px}.broad_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px 0;width:90px;height:90px}.broad_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1382px -91px;width:90px;height:90px}.broad_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 -106px;width:102px;height:105px}.broad_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-648px 0;width:90px;height:105px}.broad_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-182px -591px;width:90px;height:105px}.broad_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -212px;width:90px;height:105px}.broad_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-424px -182px;width:111px;height:90px}.broad_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -273px;width:111px;height:90px}.eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -394px;width:111px;height:90px}.eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-112px -394px;width:111px;height:90px}.head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px -910px;width:90px;height:90px}.head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1382px -1001px;width:90px;height:90px}.head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px 0;width:102px;height:105px}.head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -591px;width:90px;height:105px}.head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -591px;width:90px;height:105px}.head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-455px -591px;width:90px;height:105px}.head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-336px -394px;width:111px;height:90px}.head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-536px 0;width:111px;height:90px}.Healer_Summer{background-image:url(spritesmith-main-4.png);background-position:-637px -591px;width:90px;height:105px}.Mage_Summer{background-image:url(spritesmith-main-4.png);background-position:-739px 0;width:90px;height:105px}.SummerRogue14{background-image:url(spritesmith-main-4.png);background-position:-424px 0;width:111px;height:90px}.SummerWarrior14{background-image:url(spritesmith-main-4.png);background-position:-536px -91px;width:111px;height:90px}.shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1334px;width:90px;height:90px}.shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 0;width:102px;height:105px}.shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -485px;width:90px;height:105px}.shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-536px -288px;width:90px;height:105px}.shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -303px;width:111px;height:90px}.shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -182px;width:111px;height:90px}.shop_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-41px -1516px;width:40px;height:40px}.shop_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -1516px;width:40px;height:40px}.shop_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1488px -1466px;width:40px;height:40px}.shop_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1447px -1466px;width:40px;height:40px}.shop_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1406px -1466px;width:40px;height:40px}.shop_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1466px;width:40px;height:40px}.shop_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1488px -1425px;width:40px;height:40px}.shop_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1447px -1425px;width:40px;height:40px}.shop_body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1406px -1425px;width:40px;height:40px}.shop_body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1425px;width:40px;height:40px}.shop_body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-489px -435px;width:40px;height:40px}.shop_body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-448px -435px;width:40px;height:40px}.shop_body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-489px -394px;width:40px;height:40px}.shop_body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-448px -394px;width:40px;height:40px}.shop_eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-377px -344px;width:40px;height:40px}.shop_eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-336px -344px;width:40px;height:40px}.shop_head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-377px -303px;width:40px;height:40px}.shop_head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-336px -303px;width:40px;height:40px}.shop_head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-230px -253px;width:40px;height:40px}.shop_head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-230px -212px;width:40px;height:40px}.shop_head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-689px -530px;width:40px;height:40px}.shop_head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -530px;width:40px;height:40px}.shop_head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-871px -740px;width:40px;height:40px}.shop_head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-830px -740px;width:40px;height:40px}.shop_shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-968px -819px;width:40px;height:40px}.shop_shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-927px -819px;width:40px;height:40px}.shop_shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1059px -910px;width:40px;height:40px}.shop_shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1646px -861px;width:40px;height:40px}.shop_shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1150px -1001px;width:40px;height:40px}.shop_shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -1001px;width:40px;height:40px}.shop_weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1241px -1092px;width:40px;height:40px}.shop_weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1092px;width:40px;height:40px}.shop_weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1514px -1365px;width:40px;height:40px}.shop_weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-574px -1557px;width:40px;height:40px}.shop_weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1382px -1274px;width:40px;height:40px}.shop_weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1423px -1274px;width:40px;height:40px}.shop_weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -1183px;width:40px;height:40px}.shop_weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1332px -1183px;width:40px;height:40px}.slim_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1274px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1183px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px 0;width:102px;height:105px}.slim_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -591px;width:90px;height:105px}.slim_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-739px -106px;width:90px;height:105px}.slim_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-536px -182px;width:90px;height:105px}.slim_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -303px;width:111px;height:90px}.slim_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -91px;width:111px;height:90px}.weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-546px -1425px;width:90px;height:90px}.weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-455px -1425px;width:90px;height:90px}.weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px -106px;width:102px;height:105px}.weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-91px -485px;width:90px;height:105px}.weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-182px -485px;width:90px;height:105px}.weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-91px -591px;width:90px;height:105px}.weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -394px;width:111px;height:90px}.weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -91px;width:111px;height:90px}.broad_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -1183px;width:90px;height:90px}.broad_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -1092px;width:90px;height:90px}.broad_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -1001px;width:90px;height:90px}.broad_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px -910px;width:90px;height:90px}.broad_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1473px -819px;width:90px;height:90px}.broad_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-507px -697px;width:96px;height:90px}.broad_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1473px -637px;width:90px;height:90px}.broad_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1473px -546px;width:90px;height:90px}.head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -455px;width:90px;height:90px}.head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-1473px -364px;width:90px;height:90px}.head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-1473px -273px;width:90px;height:90px}.head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -182px;width:90px;height:90px}.head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -91px;width:90px;height:90px}.head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px 0;width:90px;height:90px}.head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1334px;width:90px;height:90px}.head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-410px -697px;width:96px;height:90px}.head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -455px;width:90px;height:90px}.head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1291px -364px;width:90px;height:90px}.shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -697px;width:104px;height:90px}.shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1291px -182px;width:90px;height:90px}.shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -91px;width:90px;height:90px}.shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-604px -697px;width:96px;height:90px}.shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1183px -1152px;width:90px;height:90px}.shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1092px -1152px;width:90px;height:90px}.shop_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-410px -1516px;width:40px;height:40px}.shop_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-451px -1516px;width:40px;height:40px}.shop_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-492px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-533px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-574px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-615px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-656px -1516px;width:40px;height:40px}.shop_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-697px -1516px;width:40px;height:40px}.shop_head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-738px -1516px;width:40px;height:40px}.shop_head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-779px -1516px;width:40px;height:40px}.shop_head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-820px -1516px;width:40px;height:40px}.shop_head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-861px -1516px;width:40px;height:40px}.shop_head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-902px -1516px;width:40px;height:40px}.shop_head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-943px -1516px;width:40px;height:40px}.shop_head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-984px -1516px;width:40px;height:40px}.shop_head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1025px -1516px;width:40px;height:40px}.shop_head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1066px -1516px;width:40px;height:40px}.shop_head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1107px -1516px;width:40px;height:40px}.shop_shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1148px -1516px;width:40px;height:40px}.shop_shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1189px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1230px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1271px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1312px -1516px;width:40px;height:40px}.shop_shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1353px -1516px;width:40px;height:40px}.shop_weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1394px -1516px;width:40px;height:40px}.shop_weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1435px -1516px;width:40px;height:40px}.shop_weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1476px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1517px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px 0;width:40px;height:40px}.shop_weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -41px;width:40px;height:40px}.shop_weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -82px;width:40px;height:40px}.shop_weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1564px -123px;width:40px;height:40px}.slim_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1001px -1152px;width:90px;height:90px}.slim_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-910px -1152px;width:90px;height:90px}.slim_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-819px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-637px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-701px -697px;width:96px;height:90px}.slim_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-455px -1152px;width:90px;height:90px}.slim_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-364px -1152px;width:90px;height:90px}.weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-273px -1152px;width:90px;height:90px}.weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-182px -1152px;width:90px;height:90px}.weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-91px -1152px;width:90px;height:90px}.weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:0 -1152px;width:90px;height:90px}.weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1001px;width:90px;height:90px}.weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-313px -697px;width:96px;height:90px}.weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1200px -819px;width:90px;height:90px}.weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1200px -728px;width:90px;height:90px}.back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -637px;width:90px;height:90px}.back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -546px;width:90px;height:90px}.body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -455px;width:90px;height:90px}.body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1200px -364px;width:90px;height:90px}.body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -273px;width:90px;height:90px}.eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -182px;width:90px;height:90px}.eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -91px;width:90px;height:90px}.shop_back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1107px;width:40px;height:40px}.shop_back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1148px;width:40px;height:40px}.shop_body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1189px;width:40px;height:40px}.shop_body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1564px -1230px;width:40px;height:40px}.shop_body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1271px;width:40px;height:40px}.shop_eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1312px;width:40px;height:40px}.shop_eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1353px;width:40px;height:40px}.head_0{background-image:url(spritesmith-main-4.png);background-position:-1200px 0;width:90px;height:90px}.customize-option.head_0{background-image:url(spritesmith-main-4.png);background-position:-1225px -15px;width:60px;height:60px}.head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1092px -1061px;width:90px;height:90px}.head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1001px -1061px;width:90px;height:90px}.head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-910px -1061px;width:90px;height:90px}.head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-819px -1061px;width:90px;height:90px}.head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-728px -1061px;width:90px;height:90px}.head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-637px -1061px;width:90px;height:90px}.head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1018px -819px;width:90px;height:90px}.head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1018px -728px;width:90px;height:90px}.head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1018px -637px;width:90px;height:90px}.head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1018px -546px;width:90px;height:90px}.head_special_2{background-image:url(spritesmith-main-4.png);background-position:-910px -879px;width:90px;height:90px}.head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-819px -879px;width:90px;height:90px}.head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-728px -879px;width:90px;height:90px}.head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-637px -879px;width:90px;height:90px}.head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-273px -879px;width:90px;height:90px}.head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-182px -879px;width:90px;height:90px}.head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-927px -637px;width:90px;height:90px}.head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-927px -546px;width:90px;height:90px}.head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-927px -182px;width:90px;height:90px}.head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-927px -91px;width:90px;height:90px}.head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-734px -788px;width:90px;height:90px}.head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-643px -788px;width:90px;height:90px}.shop_head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-820px -1557px;width:40px;height:40px}.shop_head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-861px -1557px;width:40px;height:40px}.shop_head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-902px -1557px;width:40px;height:40px}.shop_head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-943px -1557px;width:40px;height:40px}.shop_head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-984px -1557px;width:40px;height:40px}.shop_head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1025px -1557px;width:40px;height:40px}.shop_head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1066px -1557px;width:40px;height:40px}.shop_head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1107px -1557px;width:40px;height:40px}.shop_head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1148px -1557px;width:40px;height:40px}.shop_head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1189px -1557px;width:40px;height:40px}.shop_head_special_0{background-image:url(spritesmith-main-4.png);background-position:-1230px -1557px;width:40px;height:40px}.shop_head_special_1{background-image:url(spritesmith-main-4.png);background-position:-1271px -1557px;width:40px;height:40px}.shop_head_special_2{background-image:url(spritesmith-main-4.png);background-position:-1312px -1557px;width:40px;height:40px}.shop_head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-1353px -1557px;width:40px;height:40px}.shop_head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1394px -1557px;width:40px;height:40px}.shop_head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1435px -1557px;width:40px;height:40px}.shop_head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1476px -1557px;width:40px;height:40px}.shop_head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1517px -1557px;width:40px;height:40px}.shop_head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1558px -1557px;width:40px;height:40px}.shop_head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1605px 0;width:40px;height:40px}.shop_head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1605px -41px;width:40px;height:40px}.shop_head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1605px -82px;width:40px;height:40px}.shop_head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1605px -123px;width:40px;height:40px}.shop_head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1605px -164px;width:40px;height:40px}.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-279px -788px;width:90px;height:90px}.customize-option.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-304px -803px;width:60px;height:60px}.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-188px -788px;width:90px;height:90px}.customize-option.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-213px -803px;width:60px;height:60px}.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1092px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1117px -1440px;width:60px;height:60px}.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1001px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1026px -1440px;width:60px;height:60px}.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-728px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-753px -1440px;width:60px;height:60px}.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-637px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-662px -1440px;width:60px;height:60px}.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-91px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-116px -1440px;width:60px;height:60px}.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:0 -1425px;width:90px;height:90px}.customize-option.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-25px -1440px;width:60px;height:60px}.shop_headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -533px;width:40px;height:40px}.shop_headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -574px;width:40px;height:40px}.shop_headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -615px;width:40px;height:40px}.shop_headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -656px;width:40px;height:40px}.shop_headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -697px;width:40px;height:40px}.shop_headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -738px;width:40px;height:40px}.shop_headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -779px;width:40px;height:40px}.shop_headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -820px;width:40px;height:40px}.shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1183px -1334px;width:90px;height:90px}.shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1092px -1334px;width:90px;height:90px}.shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-819px -1334px;width:90px;height:90px}.shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-637px -1334px;width:90px;height:90px}.shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-273px -1334px;width:90px;height:90px}.shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-182px -1334px;width:90px;height:90px}.shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-105px -697px;width:103px;height:90px}.shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-209px -697px;width:103px;height:90px}.shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:0 -212px;width:114px;height:90px}.shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-830px 0;width:96px;height:90px}.shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-115px -212px;width:114px;height:90px}.shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-309px 0;width:114px;height:90px}.shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-1291px 0;width:90px;height:90px}.shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-112px -303px;width:111px;height:90px}.shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-927px -273px;width:90px;height:90px}.shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-927px 0;width:90px;height:90px}.shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-370px -788px;width:90px;height:90px}.shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-739px -582px;width:90px;height:90px}.shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1382px -637px;width:90px;height:90px}.shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1382px -546px;width:90px;height:90px}.shop_shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-123px -1598px;width:40px;height:40px}.shop_shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-164px -1598px;width:40px;height:40px}.shop_shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-205px -1598px;width:40px;height:40px}.shop_shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-246px -1598px;width:40px;height:40px}.shop_shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-287px -1598px;width:40px;height:40px}.shop_shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-328px -1598px;width:40px;height:40px}.shop_shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-369px -1598px;width:40px;height:40px}.shop_shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-410px -1598px;width:40px;height:40px}.shop_shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-451px -1598px;width:40px;height:40px}.shop_shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-492px -1598px;width:40px;height:40px}.shop_shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-533px -1598px;width:40px;height:40px}.shop_shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-574px -1598px;width:40px;height:40px}.shop_shield_special_0{background-image:url(spritesmith-main-4.png);background-position:-615px -1598px;width:40px;height:40px}.shop_shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-656px -1598px;width:40px;height:40px}.shop_shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-697px -1598px;width:40px;height:40px}.shop_shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-738px -1598px;width:40px;height:40px}.shop_shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-779px -1598px;width:40px;height:40px}.shop_shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-820px -1598px;width:40px;height:40px}.shop_shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-861px -1598px;width:40px;height:40px}.shop_shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-902px -1598px;width:40px;height:40px}.shop_shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-943px -1598px;width:40px;height:40px}.shop_weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-984px -1598px;width:40px;height:40px}.shop_weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1025px -1598px;width:40px;height:40px}.shop_weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1066px -1598px;width:40px;height:40px}.shop_weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1107px -1598px;width:40px;height:40px}.shop_weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1148px -1598px;width:40px;height:40px}.shop_weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-1189px -1598px;width:40px;height:40px}.shop_weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-1230px -1598px;width:40px;height:40px}.shop_weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-1271px -1598px;width:40px;height:40px}.shop_weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1312px -1598px;width:40px;height:40px}.shop_weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1353px -1598px;width:40px;height:40px}.shop_weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1394px -1598px;width:40px;height:40px}.shop_weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1435px -1598px;width:40px;height:40px}.shop_weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1476px -1598px;width:40px;height:40px}.shop_weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1517px -1598px;width:40px;height:40px}.shop_weapon_special_0{background-image:url(spritesmith-main-4.png);background-position:-1558px -1598px;width:40px;height:40px}.shop_weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-1599px -1598px;width:40px;height:40px}.shop_weapon_special_2{background-image:url(spritesmith-main-4.png);background-position:-1646px 0;width:40px;height:40px}.shop_weapon_special_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -41px;width:40px;height:40px}.shop_weapon_special_critical{background-image:url(spritesmith-main-4.png);background-position:-1646px -82px;width:40px;height:40px}.shop_weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-4.png);background-position:-1646px -123px;width:40px;height:40px}.shop_weapon_warrior_0{background-image:url(spritesmith-main-4.png);background-position:-1646px -164px;width:40px;height:40px}.shop_weapon_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -205px;width:40px;height:40px}.shop_weapon_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -246px;width:40px;height:40px}.shop_weapon_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -287px;width:40px;height:40px}.shop_weapon_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -328px;width:40px;height:40px}.shop_weapon_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -369px;width:40px;height:40px}.shop_weapon_warrior_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -410px;width:40px;height:40px}.shop_weapon_wizard_0{background-image:url(spritesmith-main-4.png);background-position:-1646px -451px;width:40px;height:40px}.shop_weapon_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -492px;width:40px;height:40px}.shop_weapon_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -533px;width:40px;height:40px}.shop_weapon_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -574px;width:40px;height:40px}.shop_weapon_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -615px;width:40px;height:40px}.shop_weapon_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -656px;width:40px;height:40px}.shop_weapon_wizard_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -697px;width:40px;height:40px}.weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-1382px -273px;width:90px;height:90px}.weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1274px -1243px;width:90px;height:90px}.weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-739px -491px;width:90px;height:90px}.weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1382px -1092px;width:90px;height:90px}.weapon_healer_4{background-image:url(spritesmith-main-5.png);background-position:-728px -1523px;width:90px;height:90px}.weapon_healer_5{background-image:url(spritesmith-main-5.png);background-position:-1274px -1523px;width:90px;height:90px}.weapon_healer_6{background-image:url(spritesmith-main-5.png);background-position:0 -1523px;width:90px;height:90px}.weapon_rogue_0{background-image:url(spritesmith-main-5.png);background-position:-91px -1523px;width:90px;height:90px}.weapon_rogue_1{background-image:url(spritesmith-main-5.png);background-position:-182px -1523px;width:90px;height:90px}.weapon_rogue_2{background-image:url(spritesmith-main-5.png);background-position:-273px -1523px;width:90px;height:90px}.weapon_rogue_3{background-image:url(spritesmith-main-5.png);background-position:-364px -1523px;width:90px;height:90px}.weapon_rogue_4{background-image:url(spritesmith-main-5.png);background-position:-455px -1523px;width:90px;height:90px}.weapon_rogue_5{background-image:url(spritesmith-main-5.png);background-position:-546px -1523px;width:90px;height:90px}.weapon_rogue_6{background-image:url(spritesmith-main-5.png);background-position:-637px -1523px;width:90px;height:90px}.weapon_special_1{background-image:url(spritesmith-main-5.png);background-position:-485px -1402px;width:102px;height:90px}.weapon_special_2{background-image:url(spritesmith-main-5.png);background-position:-819px -1523px;width:90px;height:90px}.weapon_special_3{background-image:url(spritesmith-main-5.png);background-position:-910px -1523px;width:90px;height:90px}.weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-5.png);background-position:-1001px -1523px;width:90px;height:90px}.weapon_warrior_0{background-image:url(spritesmith-main-5.png);background-position:-1092px -1523px;width:90px;height:90px}.weapon_warrior_1{background-image:url(spritesmith-main-5.png);background-position:-1183px -1523px;width:90px;height:90px}.weapon_warrior_2{background-image:url(spritesmith-main-5.png);background-position:-1536px 0;width:90px;height:90px}.weapon_warrior_3{background-image:url(spritesmith-main-5.png);background-position:-1536px -182px;width:90px;height:90px}.weapon_warrior_4{background-image:url(spritesmith-main-5.png);background-position:-1536px -455px;width:90px;height:90px}.weapon_warrior_5{background-image:url(spritesmith-main-5.png);background-position:-1536px -546px;width:90px;height:90px}.weapon_warrior_6{background-image:url(spritesmith-main-5.png);background-position:-1536px -637px;width:90px;height:90px}.weapon_wizard_0{background-image:url(spritesmith-main-5.png);background-position:-1536px -728px;width:90px;height:90px}.weapon_wizard_1{background-image:url(spritesmith-main-5.png);background-position:-1536px -819px;width:90px;height:90px}.weapon_wizard_2{background-image:url(spritesmith-main-5.png);background-position:-1536px -910px;width:90px;height:90px}.weapon_wizard_3{background-image:url(spritesmith-main-5.png);background-position:-1536px -1001px;width:90px;height:90px}.weapon_wizard_4{background-image:url(spritesmith-main-5.png);background-position:-1536px -1092px;width:90px;height:90px}.weapon_wizard_5{background-image:url(spritesmith-main-5.png);background-position:-1536px -1183px;width:90px;height:90px}.weapon_wizard_6{background-image:url(spritesmith-main-5.png);background-position:-1536px -1274px;width:90px;height:90px}.GrimReaper{background-image:url(spritesmith-main-5.png);background-position:-1536px -1456px;width:57px;height:66px}.Pet_Currency_Gem{background-image:url(spritesmith-main-5.png);background-position:-1476px -1402px;width:45px;height:39px}.Pet_Currency_Gem1x{background-image:url(spritesmith-main-5.png);background-position:-1767px -1692px;width:15px;height:13px}.Pet_Currency_Gem2x{background-image:url(spritesmith-main-5.png);background-position:-1627px -1584px;width:30px;height:26px}.PixelPaw-Gold{background-image:url(spritesmith-main-5.png);background-position:-1627px -544px;width:51px;height:51px}.PixelPaw{background-image:url(spritesmith-main-5.png);background-position:-1627px -492px;width:51px;height:51px}.PixelPaw002{background-image:url(spritesmith-main-5.png);background-position:-1627px -440px;width:51px;height:51px}.avatar_floral_healer{background-image:url(spritesmith-main-5.png);background-position:-385px -1402px;width:99px;height:99px}.avatar_floral_rogue{background-image:url(spritesmith-main-5.png);background-position:-285px -1402px;width:99px;height:99px}.avatar_floral_warrior{background-image:url(spritesmith-main-5.png);background-position:-185px -1402px;width:99px;height:99px}.avatar_floral_wizard{background-image:url(spritesmith-main-5.png);background-position:-85px -1402px;width:99px;height:99px}.empty_bottles{background-image:url(spritesmith-main-5.png);background-position:-1365px -1523px;width:64px;height:54px}.inventory_present{background-image:url(spritesmith-main-5.png);background-position:-490px -1666px;width:48px;height:51px}.inventory_present_01{background-image:url(spritesmith-main-5.png);background-position:-392px -1666px;width:48px;height:51px}.inventory_present_02{background-image:url(spritesmith-main-5.png);background-position:-343px -1666px;width:48px;height:51px}.inventory_present_03{background-image:url(spritesmith-main-5.png);background-position:-294px -1666px;width:48px;height:51px}.inventory_present_04{background-image:url(spritesmith-main-5.png);background-position:-1627px -596px;width:48px;height:51px}.inventory_present_05{background-image:url(spritesmith-main-5.png);background-position:-196px -1666px;width:48px;height:51px}.inventory_present_06{background-image:url(spritesmith-main-5.png);background-position:-147px -1666px;width:48px;height:51px}.inventory_present_07{background-image:url(spritesmith-main-5.png);background-position:-98px -1666px;width:48px;height:51px}.inventory_present_08{background-image:url(spritesmith-main-5.png);background-position:-49px -1666px;width:48px;height:51px}.inventory_present_09{background-image:url(spritesmith-main-5.png);background-position:0 -1666px;width:48px;height:51px}.inventory_present_10{background-image:url(spritesmith-main-5.png);background-position:-1685px -1612px;width:48px;height:51px}.inventory_present_11{background-image:url(spritesmith-main-5.png);background-position:-1685px -1560px;width:48px;height:51px}.inventory_present_12{background-image:url(spritesmith-main-5.png);background-position:-1685px -1508px;width:48px;height:51px}.inventory_quest_scroll{background-image:url(spritesmith-main-5.png);background-position:-1685px -1456px;width:48px;height:51px}.inventory_quest_scroll_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -1404px;width:48px;height:51px}.inventory_special_fortify{background-image:url(spritesmith-main-5.png);background-position:-1627px -110px;width:57px;height:54px}.inventory_special_greeting{background-image:url(spritesmith-main-5.png);background-position:-1430px -1523px;width:57px;height:54px}.inventory_special_nye{background-image:url(spritesmith-main-5.png);background-position:-1488px -1523px;width:57px;height:54px}.inventory_special_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1011px -1442px;width:40px;height:40px}.inventory_special_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1627px -385px;width:57px;height:54px}.inventory_special_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1627px -330px;width:57px;height:54px}.inventory_special_snowball{background-image:url(spritesmith-main-5.png);background-position:-1627px -275px;width:57px;height:54px}.inventory_special_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1627px -220px;width:57px;height:54px}.inventory_special_thankyou{background-image:url(spritesmith-main-5.png);background-position:-1627px -165px;width:57px;height:54px}.inventory_special_trinket{background-image:url(spritesmith-main-5.png);background-position:-1685px -832px;width:48px;height:51px}.inventory_special_valentine{background-image:url(spritesmith-main-5.png);background-position:-1627px -55px;width:57px;height:54px}.knockout{background-image:url(spritesmith-main-5.png);background-position:-588px -1442px;width:120px;height:47px}.pet_key{background-image:url(spritesmith-main-5.png);background-position:-1627px 0;width:57px;height:54px}.rebirth_orb{background-image:url(spritesmith-main-5.png);background-position:-1546px -1523px;width:57px;height:54px}.seafoam_star{background-image:url(spritesmith-main-5.png);background-position:-1536px -91px;width:90px;height:90px}.shop_armoire{background-image:url(spritesmith-main-5.png);background-position:-970px -1442px;width:40px;height:40px}.snowman{background-image:url(spritesmith-main-5.png);background-position:-1536px -273px;width:90px;height:90px}.spookman{background-image:url(spritesmith-main-5.png);background-position:-1536px -364px;width:90px;height:90px}.zzz{background-image:url(spritesmith-main-5.png);background-position:-929px -1442px;width:40px;height:40px}.zzz_light{background-image:url(spritesmith-main-5.png);background-position:-1134px -1442px;width:40px;height:40px}.npc_alex{background-image:url(spritesmith-main-5.png);background-position:-151px -1251px;width:162px;height:138px}.npc_bailey{background-image:url(spritesmith-main-5.png);background-position:-1461px -1251px;width:60px;height:72px}.npc_daniel{background-image:url(spritesmith-main-5.png);background-position:-477px -1251px;width:135px;height:123px}.npc_justin{background-image:url(spritesmith-main-5.png);background-position:0 -1402px;width:84px;height:120px}.npc_justin_head{background-image:url(spritesmith-main-5.png);background-position:-1298px -1442px;width:36px;height:39px}.npc_matt{background-image:url(spritesmith-main-5.png);background-position:-1322px -954px;width:195px;height:138px}.npc_timetravelers{background-image:url(spritesmith-main-5.png);background-position:-1322px -815px;width:195px;height:138px}.npc_timetravelers_active{background-image:url(spritesmith-main-5.png);background-position:-1322px -676px;width:195px;height:138px}.npc_tyler{background-image:url(spritesmith-main-5.png);background-position:-1536px -1365px;width:90px;height:90px}.seasonalshop_closed{background-image:url(spritesmith-main-5.png);background-position:-314px -1251px;width:162px;height:138px}.inventory_quest_scroll_atom1{background-image:url(spritesmith-main-5.png);background-position:-1685px -208px;width:48px;height:51px}.inventory_quest_scroll_atom1_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -156px;width:48px;height:51px}.inventory_quest_scroll_atom2{background-image:url(spritesmith-main-5.png);background-position:-1685px -104px;width:48px;height:51px}.inventory_quest_scroll_atom2_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -52px;width:48px;height:51px}.inventory_quest_scroll_atom3{background-image:url(spritesmith-main-5.png);background-position:-637px -1614px;width:48px;height:51px}.inventory_quest_scroll_atom3_locked{background-image:url(spritesmith-main-5.png);background-position:-588px -1614px;width:48px;height:51px}.inventory_quest_scroll_basilist{background-image:url(spritesmith-main-5.png);background-position:-539px -1614px;width:48px;height:51px}.inventory_quest_scroll_bunny{background-image:url(spritesmith-main-5.png);background-position:-490px -1614px;width:48px;height:51px}.inventory_quest_scroll_cheetah{background-image:url(spritesmith-main-5.png);background-position:-441px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-392px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-343px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2_locked{background-image:url(spritesmith-main-5.png);background-position:-294px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-245px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3_locked{background-image:url(spritesmith-main-5.png);background-position:-147px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-1685px -312px;width:48px;height:51px}.inventory_quest_scroll_egg{background-image:url(spritesmith-main-5.png);background-position:-245px -1666px;width:48px;height:51px}.inventory_quest_scroll_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-1685px -364px;width:48px;height:51px}.inventory_quest_scroll_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:-1685px -416px;width:48px;height:51px}.inventory_quest_scroll_frog{background-image:url(spritesmith-main-5.png);background-position:-1685px -468px;width:48px;height:51px}.inventory_quest_scroll_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-1685px -520px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-1685px -624px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -676px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-1685px -728px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -780px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:-1685px -884px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -936px;width:48px;height:51px}.inventory_quest_scroll_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1685px -988px;width:48px;height:51px}.inventory_quest_scroll_harpy{background-image:url(spritesmith-main-5.png);background-position:-1685px -1092px;width:48px;height:51px}.inventory_quest_scroll_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1685px -1144px;width:48px;height:51px}.inventory_quest_scroll_horse{background-image:url(spritesmith-main-5.png);background-position:-1685px -1248px;width:48px;height:51px}.inventory_quest_scroll_kraken{background-image:url(spritesmith-main-5.png);background-position:-1685px -1300px;width:48px;height:51px}.inventory_quest_scroll_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-1685px -1352px;width:48px;height:51px}.inventory_quest_scroll_moonstone1_locked{background-image:url(spritesmith-main-5.png);background-position:-539px -1666px;width:48px;height:51px}.inventory_quest_scroll_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-1627px -648px;width:48px;height:51px}.inventory_quest_scroll_moonstone2_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -700px;width:48px;height:51px}.inventory_quest_scroll_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-1627px -752px;width:48px;height:51px}.inventory_quest_scroll_moonstone3_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -804px;width:48px;height:51px}.inventory_quest_scroll_octopus{background-image:url(spritesmith-main-5.png);background-position:-1627px -856px;width:48px;height:51px}.inventory_quest_scroll_owl{background-image:url(spritesmith-main-5.png);background-position:-1627px -908px;width:48px;height:51px}.inventory_quest_scroll_penguin{background-image:url(spritesmith-main-5.png);background-position:-1627px -960px;width:48px;height:51px}.inventory_quest_scroll_rat{background-image:url(spritesmith-main-5.png);background-position:-1627px -1012px;width:48px;height:51px}.inventory_quest_scroll_rock{background-image:url(spritesmith-main-5.png);background-position:-1627px -1064px;width:48px;height:51px}.inventory_quest_scroll_rooster{background-image:url(spritesmith-main-5.png);background-position:-1627px -1116px;width:48px;height:51px}.inventory_quest_scroll_sheep{background-image:url(spritesmith-main-5.png);background-position:-1627px -1168px;width:48px;height:51px}.inventory_quest_scroll_slime{background-image:url(spritesmith-main-5.png);background-position:-1627px -1220px;width:48px;height:51px}.inventory_quest_scroll_snake{background-image:url(spritesmith-main-5.png);background-position:-1627px -1272px;width:48px;height:51px}.inventory_quest_scroll_spider{background-image:url(spritesmith-main-5.png);background-position:-1627px -1324px;width:48px;height:51px}.inventory_quest_scroll_trex{background-image:url(spritesmith-main-5.png);background-position:-1627px -1376px;width:48px;height:51px}.inventory_quest_scroll_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-1627px -1428px;width:48px;height:51px}.inventory_quest_scroll_vice1{background-image:url(spritesmith-main-5.png);background-position:-1627px -1480px;width:48px;height:51px}.inventory_quest_scroll_vice1_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -1532px;width:48px;height:51px}.inventory_quest_scroll_vice2{background-image:url(spritesmith-main-5.png);background-position:0 -1614px;width:48px;height:51px}.inventory_quest_scroll_vice2_locked{background-image:url(spritesmith-main-5.png);background-position:-49px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice3{background-image:url(spritesmith-main-5.png);background-position:-98px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice3_locked{background-image:url(spritesmith-main-5.png);background-position:-1734px -1456px;width:48px;height:51px}.inventory_quest_scroll_whale{background-image:url(spritesmith-main-5.png);background-position:-196px -1614px;width:48px;height:51px}.quest_TEMPLATE_FOR_MISSING_IMAGE{background-image:url(spritesmith-main-5.png);background-position:-1254px -1402px;width:221px;height:39px}.quest_atom1{background-image:url(spritesmith-main-5.png);background-position:-434px -1070px;width:250px;height:150px}.quest_atom2{background-image:url(spritesmith-main-5.png);background-position:-1322px -537px;width:207px;height:138px}.quest_atom3{background-image:url(spritesmith-main-5.png);background-position:0 -1070px;width:216px;height:180px}.quest_basilist{background-image:url(spritesmith-main-5.png);background-position:-1322px -1093px;width:189px;height:141px}.quest_bunny{background-image:url(spritesmith-main-5.png);background-position:-1100px -618px;width:210px;height:186px}.quest_cheetah{background-image:url(spritesmith-main-5.png);background-position:-440px -452px;width:219px;height:219px}.quest_dilatory{background-image:url(spritesmith-main-5.png);background-position:-220px -452px;width:219px;height:219px}.quest_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-1100px -845px;width:221px;height:39px}.quest_dilatoryDistress1_blueFins{background-image:url(spritesmith-main-5.png);background-position:-686px -1614px;width:51px;height:48px}.quest_dilatoryDistress1_fireCoral{background-image:url(spritesmith-main-5.png);background-position:-1685px 0;width:48px;height:51px}.quest_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:0 -1251px;width:150px;height:150px}.quest_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-440px 0;width:219px;height:219px}.quest_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-660px 0;width:219px;height:219px}.quest_egg{background-image:url(spritesmith-main-5.png);background-position:-810px -1402px;width:221px;height:39px}.quest_egg_plainEgg{background-image:url(spritesmith-main-5.png);background-position:-1685px -260px;width:48px;height:51px}.quest_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-1187px -1070px;width:118px;height:131px}.quest_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:0 -232px;width:219px;height:219px}.quest_frog{background-image:url(spritesmith-main-5.png);background-position:-1100px 0;width:221px;height:213px}.quest_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-220px -232px;width:219px;height:219px}.quest_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-1032px -1402px;width:221px;height:39px}.quest_goldenknight1_testimony{background-image:url(spritesmith-main-5.png);background-position:-1685px -572px;width:48px;height:51px}.quest_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-685px -1070px;width:250px;height:150px}.quest_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:0 0;width:219px;height:231px}.quest_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1091px -892px;width:216px;height:177px}.quest_harpy{background-image:url(spritesmith-main-5.png);background-position:-660px -220px;width:219px;height:219px}.quest_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1100px -431px;width:219px;height:186px}.quest_horse{background-image:url(spritesmith-main-5.png);background-position:0 -452px;width:219px;height:219px}.quest_kraken{background-image:url(spritesmith-main-5.png);background-position:-440px -892px;width:216px;height:177px}.quest_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-588px -1402px;width:221px;height:39px}.quest_moonstone1_moonstone{background-image:url(spritesmith-main-5.png);background-position:-1335px -1442px;width:30px;height:30px}.quest_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-220px 0;width:219px;height:219px}.quest_moonstone3{background-image:url(spritesmith-main-5.png);background-position:0 -672px;width:219px;height:219px}.quest_octopus{background-image:url(spritesmith-main-5.png);background-position:0 -892px;width:222px;height:177px}.quest_owl{background-image:url(spritesmith-main-5.png);background-position:-220px -672px;width:219px;height:219px}.quest_penguin{background-image:url(spritesmith-main-5.png);background-position:-1322px -353px;width:190px;height:183px}.quest_rat{background-image:url(spritesmith-main-5.png);background-position:-880px -672px;width:219px;height:219px}.quest_rock{background-image:url(spritesmith-main-5.png);background-position:-1100px -214px;width:216px;height:216px}.quest_rooster{background-image:url(spritesmith-main-5.png);background-position:-1322px 0;width:213px;height:174px}.quest_sheep{background-image:url(spritesmith-main-5.png);background-position:-660px -672px;width:219px;height:219px}.quest_slime{background-image:url(spritesmith-main-5.png);background-position:-440px -672px;width:219px;height:219px}.quest_snake{background-image:url(spritesmith-main-5.png);background-position:-874px -892px;width:216px;height:177px}.quest_spider{background-image:url(spritesmith-main-5.png);background-position:-936px -1070px;width:250px;height:150px}.quest_stressbeast{background-image:url(spritesmith-main-5.png);background-position:-880px -440px;width:219px;height:219px}.quest_stressbeast_bailey{background-image:url(spritesmith-main-5.png);background-position:-880px -220px;width:219px;height:219px}.quest_stressbeast_guide{background-image:url(spritesmith-main-5.png);background-position:-880px 0;width:219px;height:219px}.quest_stressbeast_stables{background-image:url(spritesmith-main-5.png);background-position:-660px -452px;width:219px;height:219px}.quest_trex{background-image:url(spritesmith-main-5.png);background-position:-1322px -175px;width:204px;height:177px}.quest_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-223px -892px;width:216px;height:177px}.quest_vice1{background-image:url(spritesmith-main-5.png);background-position:-657px -892px;width:216px;height:177px}.quest_vice2{background-image:url(spritesmith-main-5.png);background-position:-1100px -805px;width:221px;height:39px}.quest_vice2_lightCrystal{background-image:url(spritesmith-main-5.png);background-position:-1175px -1442px;width:40px;height:40px}.quest_vice3{background-image:url(spritesmith-main-5.png);background-position:-217px -1070px;width:216px;height:177px}.quest_whale{background-image:url(spritesmith-main-5.png);background-position:-440px -232px;width:219px;height:219px}.shop_copper{background-image:url(spritesmith-main-5.png);background-position:-467px -1221px;width:32px;height:22px}.shop_eyes{background-image:url(spritesmith-main-5.png);background-position:-1216px -1442px;width:40px;height:40px}.shop_gold{background-image:url(spritesmith-main-5.png);background-position:-1734px -1692px;width:32px;height:22px}.shop_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1257px -1442px;width:40px;height:40px}.shop_potion{background-image:url(spritesmith-main-5.png);background-position:-1093px -1442px;width:40px;height:40px}.shop_reroll{background-image:url(spritesmith-main-5.png);background-position:-1052px -1442px;width:40px;height:40px}.shop_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1594px -1456px;width:32px;height:32px}.shop_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1461px -1324px;width:32px;height:32px}.shop_silver{background-image:url(spritesmith-main-5.png);background-position:-434px -1221px;width:32px;height:22px}.shop_snowball{background-image:url(spritesmith-main-5.png);background-position:-1594px -1489px;width:32px;height:32px}.shop_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1494px -1324px;width:32px;height:32px}.Pet_Egg_BearCub{background-image:url(spritesmith-main-5.png);background-position:-1127px -1666px;width:48px;height:51px}.Pet_Egg_Bunny{background-image:url(spritesmith-main-5.png);background-position:-1176px -1666px;width:48px;height:51px}.Pet_Egg_Cactus{background-image:url(spritesmith-main-5.png);background-position:-1225px -1666px;width:48px;height:51px}.Pet_Egg_Cheetah{background-image:url(spritesmith-main-5.png);background-position:-1274px -1666px;width:48px;height:51px}.Pet_Egg_Cuttlefish{background-image:url(spritesmith-main-5.png);background-position:-1323px -1666px;width:48px;height:51px}.Pet_Egg_Deer{background-image:url(spritesmith-main-5.png);background-position:-1372px -1666px;width:48px;height:51px}.Pet_Egg_Dragon{background-image:url(spritesmith-main-5.png);background-position:-1421px -1666px;width:48px;height:51px}.Pet_Egg_Egg{background-image:url(spritesmith-main-5.png);background-position:-1470px -1666px;width:48px;height:51px}.Pet_Egg_FlyingPig{background-image:url(spritesmith-main-5.png);background-position:-1519px -1666px;width:48px;height:51px}.Pet_Egg_Fox{background-image:url(spritesmith-main-5.png);background-position:-1568px -1666px;width:48px;height:51px}.Pet_Egg_Frog{background-image:url(spritesmith-main-5.png);background-position:-1617px -1666px;width:48px;height:51px}.Pet_Egg_Gryphon{background-image:url(spritesmith-main-5.png);background-position:-1666px -1666px;width:48px;height:51px}.Pet_Egg_Hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1734px 0;width:48px;height:51px}.Pet_Egg_Horse{background-image:url(spritesmith-main-5.png);background-position:-1734px -52px;width:48px;height:51px}.Pet_Egg_LionCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -104px;width:48px;height:51px}.Pet_Egg_Octopus{background-image:url(spritesmith-main-5.png);background-position:-1734px -156px;width:48px;height:51px}.Pet_Egg_Owl{background-image:url(spritesmith-main-5.png);background-position:-1734px -208px;width:48px;height:51px}.Pet_Egg_PandaCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -260px;width:48px;height:51px}.Pet_Egg_Parrot{background-image:url(spritesmith-main-5.png);background-position:-1734px -312px;width:48px;height:51px}.Pet_Egg_Penguin{background-image:url(spritesmith-main-5.png);background-position:-1734px -364px;width:48px;height:51px}.Pet_Egg_PolarBear{background-image:url(spritesmith-main-5.png);background-position:-1734px -416px;width:48px;height:51px}.Pet_Egg_Rat{background-image:url(spritesmith-main-5.png);background-position:-1734px -468px;width:48px;height:51px}.Pet_Egg_Rock{background-image:url(spritesmith-main-5.png);background-position:-1734px -520px;width:48px;height:51px}.Pet_Egg_Rooster{background-image:url(spritesmith-main-5.png);background-position:-1734px -572px;width:48px;height:51px}.Pet_Egg_Seahorse{background-image:url(spritesmith-main-5.png);background-position:-1734px -624px;width:48px;height:51px}.Pet_Egg_Sheep{background-image:url(spritesmith-main-5.png);background-position:-1734px -676px;width:48px;height:51px}.Pet_Egg_Slime{background-image:url(spritesmith-main-5.png);background-position:-1734px -728px;width:48px;height:51px}.Pet_Egg_Snake{background-image:url(spritesmith-main-5.png);background-position:-1734px -780px;width:48px;height:51px}.Pet_Egg_Spider{background-image:url(spritesmith-main-5.png);background-position:-1734px -832px;width:48px;height:51px}.Pet_Egg_TRex{background-image:url(spritesmith-main-5.png);background-position:-1734px -884px;width:48px;height:51px}.Pet_Egg_TigerCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -936px;width:48px;height:51px}.Pet_Egg_Whale{background-image:url(spritesmith-main-5.png);background-position:-1734px -988px;width:48px;height:51px}.Pet_Egg_Wolf{background-image:url(spritesmith-main-5.png);background-position:-1734px -1040px;width:48px;height:51px}.Pet_Food_Cake_Base{background-image:url(spritesmith-main-5.png);background-position:-841px -1442px;width:43px;height:43px}.Pet_Food_Cake_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-738px -1614px;width:42px;height:44px}.Pet_Food_Cake_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1734px -1646px;width:43px;height:45px}.Pet_Food_Cake_Desert{background-image:url(spritesmith-main-5.png);background-position:-753px -1442px;width:43px;height:44px}.Pet_Food_Cake_Golden{background-image:url(spritesmith-main-5.png);background-position:-885px -1442px;width:43px;height:42px}.Pet_Food_Cake_Red{background-image:url(spritesmith-main-5.png);background-position:-797px -1442px;width:43px;height:44px}.Pet_Food_Cake_Shade{background-image:url(spritesmith-main-5.png);background-position:-709px -1442px;width:43px;height:44px}.Pet_Food_Cake_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1734px -1553px;width:42px;height:47px}.Pet_Food_Cake_White{background-image:url(spritesmith-main-5.png);background-position:-1734px -1601px;width:44px;height:44px}.Pet_Food_Cake_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1734px -1508px;width:45px;height:44px}.Pet_Food_Candy_Base{background-image:url(spritesmith-main-5.png);background-position:-1734px -1404px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1734px -1352px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1734px -1300px;width:48px;height:51px}.Pet_Food_Candy_Desert{background-image:url(spritesmith-main-5.png);background-position:-1734px -1248px;width:48px;height:51px}.Pet_Food_Candy_Golden{background-image:url(spritesmith-main-5.png);background-position:-1734px -1196px;width:48px;height:51px}.Pet_Food_Candy_Red{background-image:url(spritesmith-main-5.png);background-position:-1734px -1144px;width:48px;height:51px}.Pet_Food_Candy_Shade{background-image:url(spritesmith-main-5.png);background-position:-1734px -1092px;width:48px;height:51px}.Pet_Food_Candy_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1078px -1666px;width:48px;height:51px}.Pet_Food_Candy_White{background-image:url(spritesmith-main-5.png);background-position:-1029px -1666px;width:48px;height:51px}.Pet_Food_Candy_Zombie{background-image:url(spritesmith-main-5.png);background-position:-980px -1666px;width:48px;height:51px}.Pet_Food_Chocolate{background-image:url(spritesmith-main-5.png);background-position:-931px -1666px;width:48px;height:51px}.Pet_Food_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-882px -1666px;width:48px;height:51px}.Pet_Food_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-833px -1666px;width:48px;height:51px}.Pet_Food_Fish{background-image:url(spritesmith-main-5.png);background-position:-784px -1666px;width:48px;height:51px}.Pet_Food_Honey{background-image:url(spritesmith-main-5.png);background-position:-735px -1666px;width:48px;height:51px}.Pet_Food_Meat{background-image:url(spritesmith-main-5.png);background-position:-686px -1666px;width:48px;height:51px}.Pet_Food_Milk{background-image:url(spritesmith-main-5.png);background-position:-637px -1666px;width:48px;height:51px}.Pet_Food_Potatoe{background-image:url(spritesmith-main-5.png);background-position:-588px -1666px;width:48px;height:51px}.Pet_Food_RottenMeat{background-image:url(spritesmith-main-5.png);background-position:-441px -1666px;width:48px;height:51px}.Pet_Food_Saddle{background-image:url(spritesmith-main-5.png);background-position:-1685px -1196px;width:48px;height:51px}.Pet_Food_Strawberry{background-image:url(spritesmith-main-5.png);background-position:-1685px -1040px;width:48px;height:51px}.Mount_Body_BearCub-Base{background-image:url(spritesmith-main-5.png);background-position:-613px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1355px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1249px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Desert{background-image:url(spritesmith-main-5.png);background-position:-1143px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Golden{background-image:url(spritesmith-main-5.png);background-position:-1037px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Polar{background-image:url(spritesmith-main-5.png);background-position:-931px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Red{background-image:url(spritesmith-main-5.png);background-position:-825px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Shade{background-image:url(spritesmith-main-5.png);background-position:-719px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -575px;width:105px;height:105px}.Mount_Body_BearCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-318px -1105px;width:105px;height:105px}.Mount_Body_BearCub-White{background-image:url(spritesmith-main-6.png);background-position:-212px -575px;width:105px;height:105px}.Mount_Body_BearCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -575px;width:105px;height:105px}.Mount_Body_Bunny-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -575px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -575px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -575px;width:105px;height:105px}.Mount_Body_Bunny-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px 0;width:105px;height:105px}.Mount_Body_Bunny-Golden{background-image:url(spritesmith-main-6.png);background-position:-742px -106px;width:105px;height:105px}.Mount_Body_Bunny-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -212px;width:105px;height:105px}.Mount_Body_Bunny-Shade{background-image:url(spritesmith-main-6.png);background-position:-742px -318px;width:105px;height:105px}.Mount_Body_Bunny-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -999px;width:105px;height:105px}.Mount_Body_Bunny-White{background-image:url(spritesmith-main-6.png);background-position:-106px -999px;width:105px;height:105px}.Mount_Body_Bunny-Zombie{background-image:url(spritesmith-main-6.png);background-position:-212px -999px;width:105px;height:105px}.Mount_Body_Cactus-Base{background-image:url(spritesmith-main-6.png);background-position:-318px -999px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-424px -999px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-530px -999px;width:105px;height:105px}.Mount_Body_Cactus-Desert{background-image:url(spritesmith-main-6.png);background-position:-636px -999px;width:105px;height:105px}.Mount_Body_Cactus-Golden{background-image:url(spritesmith-main-6.png);background-position:-742px -999px;width:105px;height:105px}.Mount_Body_Cactus-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -999px;width:105px;height:105px}.Mount_Body_Cactus-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -999px;width:105px;height:105px}.Mount_Body_Cactus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -1211px;width:105px;height:105px}.Mount_Body_Cactus-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1060px -1211px;width:105px;height:105px}.Mount_Body_Cactus-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -1211px;width:105px;height:105px}.Mount_Body_Cactus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-530px -221px;width:105px;height:105px}.Mount_Body_Cheetah-Base{background-image:url(spritesmith-main-6.png);background-position:-530px -327px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-221px -469px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-327px -469px;width:105px;height:105px}.Mount_Body_Cheetah-Desert{background-image:url(spritesmith-main-6.png);background-position:-433px -469px;width:105px;height:105px}.Mount_Body_Cheetah-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px 0;width:105px;height:105px}.Mount_Body_Cheetah-Red{background-image:url(spritesmith-main-6.png);background-position:-636px -106px;width:105px;height:105px}.Mount_Body_Cheetah-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -212px;width:105px;height:105px}.Mount_Body_Cheetah-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -318px;width:105px;height:105px}.Mount_Body_Cheetah-White{background-image:url(spritesmith-main-6.png);background-position:-636px -424px;width:105px;height:105px}.Mount_Body_Cheetah-Zombie{background-image:url(spritesmith-main-6.png);background-position:0 -575px;width:105px;height:105px}.Mount_Body_Cuttlefish-Base{background-image:url(spritesmith-main-6.png);background-position:-530px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -115px;width:105px;height:114px}.Mount_Body_Cuttlefish-White{background-image:url(spritesmith-main-6.png);background-position:0 -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -239px;width:105px;height:114px}.Mount_Body_Deer-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -424px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-742px -530px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -681px;width:105px;height:105px}.Mount_Body_Deer-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -681px;width:105px;height:105px}.Mount_Body_Deer-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -681px;width:105px;height:105px}.Mount_Body_Deer-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -681px;width:105px;height:105px}.Mount_Body_Deer-Shade{background-image:url(spritesmith-main-6.png);background-position:-424px -681px;width:105px;height:105px}.Mount_Body_Deer-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -681px;width:105px;height:105px}.Mount_Body_Deer-White{background-image:url(spritesmith-main-6.png);background-position:-636px -681px;width:105px;height:105px}.Mount_Body_Deer-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -681px;width:105px;height:105px}.Mount_Body_Dragon-Base{background-image:url(spritesmith-main-6.png);background-position:-848px 0;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -106px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-848px -212px;width:105px;height:105px}.Mount_Body_Dragon-Desert{background-image:url(spritesmith-main-6.png);background-position:-848px -318px;width:105px;height:105px}.Mount_Body_Dragon-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -424px;width:105px;height:105px}.Mount_Body_Dragon-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -530px;width:105px;height:105px}.Mount_Body_Dragon-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -636px;width:105px;height:105px}.Mount_Body_Dragon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -787px;width:105px;height:105px}.Mount_Body_Dragon-Spooky{background-image:url(spritesmith-main-6.png);background-position:-106px -787px;width:105px;height:105px}.Mount_Body_Dragon-White{background-image:url(spritesmith-main-6.png);background-position:-212px -787px;width:105px;height:105px}.Mount_Body_Dragon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -787px;width:105px;height:105px}.Mount_Body_Egg-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -787px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -787px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -787px;width:105px;height:105px}.Mount_Body_Egg-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -787px;width:105px;height:105px}.Mount_Body_Egg-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -787px;width:105px;height:105px}.Mount_Body_Egg-Red{background-image:url(spritesmith-main-6.png);background-position:-954px 0;width:105px;height:105px}.Mount_Body_Egg-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -106px;width:105px;height:105px}.Mount_Body_Egg-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -212px;width:105px;height:105px}.Mount_Body_Egg-White{background-image:url(spritesmith-main-6.png);background-position:-954px -318px;width:105px;height:105px}.Mount_Body_Egg-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -424px;width:105px;height:105px}.Mount_Body_FlyingPig-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -530px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -636px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -742px;width:105px;height:105px}.Mount_Body_FlyingPig-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-424px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Spooky{background-image:url(spritesmith-main-6.png);background-position:-530px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-White{background-image:url(spritesmith-main-6.png);background-position:-636px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -893px;width:105px;height:105px}.Mount_Body_Fox-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -893px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -893px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px 0;width:105px;height:105px}.Mount_Body_Fox-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -106px;width:105px;height:105px}.Mount_Body_Fox-Golden{background-image:url(spritesmith-main-6.png);background-position:-1060px -212px;width:105px;height:105px}.Mount_Body_Fox-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px -318px;width:105px;height:105px}.Mount_Body_Fox-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -424px;width:105px;height:105px}.Mount_Body_Fox-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1060px -530px;width:105px;height:105px}.Mount_Body_Fox-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1060px -636px;width:105px;height:105px}.Mount_Body_Fox-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -742px;width:105px;height:105px}.Mount_Body_Fox-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1060px -848px;width:105px;height:105px}.Mount_Body_Frog-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -239px;width:105px;height:114px}.Mount_Body_Frog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px 0;width:105px;height:114px}.Mount_Body_Frog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px 0;width:105px;height:114px}.Mount_Body_Frog-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -115px;width:105px;height:114px}.Mount_Body_Frog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -230px;width:105px;height:114px}.Mount_Body_Frog-Red{background-image:url(spritesmith-main-6.png);background-position:0 -354px;width:105px;height:114px}.Mount_Body_Frog-Shade{background-image:url(spritesmith-main-6.png);background-position:-106px -354px;width:105px;height:114px}.Mount_Body_Frog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-212px -354px;width:105px;height:114px}.Mount_Body_Frog-White{background-image:url(spritesmith-main-6.png);background-position:-318px -354px;width:105px;height:114px}.Mount_Body_Frog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-424px -354px;width:105px;height:114px}.Mount_Body_Gryphon-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -999px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px 0;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px -106px;width:105px;height:105px}.Mount_Body_Gryphon-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -212px;width:105px;height:105px}.Mount_Body_Gryphon-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -318px;width:105px;height:105px}.Mount_Body_Gryphon-Red{background-image:url(spritesmith-main-6.png);background-position:-1166px -424px;width:105px;height:105px}.Mount_Body_Gryphon-RoyalPurple{background-image:url(spritesmith-main-6.png);background-position:-1166px -530px;width:105px;height:105px}.Mount_Body_Gryphon-Shade{background-image:url(spritesmith-main-6.png);background-position:-1166px -636px;width:105px;height:105px}.Mount_Body_Gryphon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px -742px;width:105px;height:105px}.Mount_Body_Gryphon-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -848px;width:105px;height:105px}.Mount_Body_Gryphon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -954px;width:105px;height:105px}.Mount_Body_Hedgehog-Base{background-image:url(spritesmith-main-6.png);background-position:0 -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -115px;width:105px;height:105px}.Mount_Body_Hedgehog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Red{background-image:url(spritesmith-main-6.png);background-position:-530px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-742px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -1105px;width:105px;height:105px}.Mount_Body_Horse-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -1105px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px -1105px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1272px 0;width:105px;height:105px}.Mount_Body_Horse-Desert{background-image:url(spritesmith-main-6.png);background-position:-1272px -106px;width:105px;height:105px}.Mount_Body_Horse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -212px;width:105px;height:105px}.Mount_Body_Horse-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -318px;width:105px;height:105px}.Mount_Body_Horse-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -424px;width:105px;height:105px}.Mount_Body_Horse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1272px -530px;width:105px;height:105px}.Mount_Body_Horse-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -636px;width:105px;height:105px}.Mount_Body_Horse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -742px;width:105px;height:105px}.Mount_Body_JackOLantern-Base{background-image:url(spritesmith-main-6.png);background-position:-1696px -530px;width:90px;height:105px}.Mount_Body_LionCub-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -954px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -1060px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -1211px;width:105px;height:105px}.Mount_Body_LionCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Ethereal{background-image:url(spritesmith-main-6.png);background-position:-212px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-318px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Red{background-image:url(spritesmith-main-6.png);background-position:-424px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -469px;width:111px;height:105px}.Mount_Body_LionCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-742px -1211px;width:105px;height:105px}.Mount_Body_LionCub-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -1211px;width:105px;height:105px}.Mount_Body_Mammoth-Base{background-image:url(spritesmith-main-6.png);background-position:0 0;width:105px;height:123px}.Mount_Body_MantisShrimp-Base{background-image:url(spritesmith-main-6.png);background-position:-112px -469px;width:108px;height:105px}.Mount_Body_Octopus-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1211px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px 0;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -106px;width:105px;height:105px}.Mount_Body_Octopus-Desert{background-image:url(spritesmith-main-6.png);background-position:-1378px -212px;width:105px;height:105px}.Mount_Body_Octopus-Golden{background-image:url(spritesmith-main-6.png);background-position:-1378px -318px;width:105px;height:105px}.Mount_Body_Octopus-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -424px;width:105px;height:105px}.Mount_Body_Octopus-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -530px;width:105px;height:105px}.Mount_Body_Octopus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1378px -636px;width:105px;height:105px}.Mount_Body_Octopus-White{background-image:url(spritesmith-main-6.png);background-position:-1378px -742px;width:105px;height:105px}.Mount_Body_Octopus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -848px;width:105px;height:105px}.Mount_Body_Orca-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -954px;width:105px;height:105px}.Mount_Body_Owl-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -1060px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -1166px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -1317px;width:105px;height:105px}.Mount_Body_Owl-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -1317px;width:105px;height:105px}.Mount_Body_Owl-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -1317px;width:105px;height:105px}.Mount_Body_Owl-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -1317px;width:105px;height:105px}.Mount_Body_Owl-Shade{background-image:url(spritesmith-main-6.png);background-position:-424px -1317px;width:105px;height:105px}.Mount_Body_Owl-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -1317px;width:105px;height:105px}.Mount_Body_Owl-White{background-image:url(spritesmith-main-6.png);background-position:-636px -1317px;width:105px;height:105px}.Mount_Body_Owl-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px 0;width:105px;height:105px}.Mount_Body_PandaCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -106px;width:105px;height:105px}.Mount_Body_PandaCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1484px -212px;width:105px;height:105px}.Mount_Body_PandaCub-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -318px;width:105px;height:105px}.Mount_Body_PandaCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -424px;width:105px;height:105px}.Mount_Body_Parrot-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -530px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -636px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px -742px;width:105px;height:105px}.Mount_Body_Parrot-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -848px;width:105px;height:105px}.Mount_Body_Parrot-Golden{background-image:url(spritesmith-main-6.png);background-position:-1484px -954px;width:105px;height:105px}.Mount_Body_Parrot-Red{background-image:url(spritesmith-main-6.png);background-position:-1484px -1060px;width:105px;height:105px}.Mount_Body_Parrot-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px -1166px;width:105px;height:105px}.Mount_Body_Parrot-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -1272px;width:105px;height:105px}.Mount_Body_Parrot-White{background-image:url(spritesmith-main-6.png);background-position:0 -1423px;width:105px;height:105px}.Mount_Body_Parrot-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -1423px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -1423px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -1423px;width:105px;height:105px}.Mount_Body_Penguin-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -1423px;width:105px;height:105px}.Mount_Body_Phoenix-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1423px;width:105px;height:105px}.Mount_Body_Rat-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px 0;width:105px;height:105px}.Mount_Body_Rat-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -106px;width:105px;height:105px}.Mount_Body_Rat-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -212px;width:105px;height:105px}.Mount_Body_Rat-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -318px;width:105px;height:105px}.Mount_Body_Rat-Shade{background-image:url(spritesmith-main-6.png);background-position:-1590px -424px;width:105px;height:105px}.Mount_Body_Rat-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1590px -530px;width:105px;height:105px}.Mount_Body_Rat-White{background-image:url(spritesmith-main-6.png);background-position:-1590px -636px;width:105px;height:105px}.Mount_Body_Rat-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1590px -742px;width:105px;height:105px}.Mount_Body_Rock-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px -848px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -954px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px -1060px;width:105px;height:105px}.Mount_Body_Rock-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -1166px;width:105px;height:105px}.Mount_Body_Rock-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -1272px;width:105px;height:105px}.Mount_Body_Rock-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -1378px;width:105px;height:105px}.Mount_Body_Rock-Shade{background-image:url(spritesmith-main-6.png);background-position:0 -1529px;width:105px;height:105px}.Mount_Body_Rock-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -1529px;width:105px;height:105px}.Mount_Body_Rock-White{background-image:url(spritesmith-main-6.png);background-position:-212px -1529px;width:105px;height:105px}.Mount_Body_Rock-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -1529px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -1529px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Red{background-image:url(spritesmith-main-6.png);background-position:-954px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px -1529px;width:105px;height:105px}.Mount_Body_Rooster-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1696px 0;width:105px;height:105px}.Mount_Body_Seahorse-Desert{background-image:url(spritesmith-main-6.png);background-position:-1696px -106px;width:105px;height:105px}.Mount_Body_Seahorse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1696px -212px;width:105px;height:105px}.Mount_Body_Seahorse-Red{background-image:url(spritesmith-main-6.png);background-position:-1696px -318px;width:105px;height:105px}.Mount_Body_Seahorse-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -848px;width:105px;height:105px}.Mount_Body_Seahorse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1696px -424px;width:105px;height:105px}.Mount_Body_Seahorse-White{background-image:url(spritesmith-main-7.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Body_Seahorse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-848px -1113px;width:105px;height:105px}.Mount_Body_Sheep-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -680px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-892px 0;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Body_Sheep-Desert{background-image:url(spritesmith-main-7.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Body_Sheep-Golden{background-image:url(spritesmith-main-7.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Body_Sheep-Red{background-image:url(spritesmith-main-7.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Body_Sheep-Shade{background-image:url(spritesmith-main-7.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Body_Sheep-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Body_Sheep-White{background-image:url(spritesmith-main-7.png);background-position:0 -795px;width:105px;height:105px}.Mount_Body_Sheep-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -901px;width:105px;height:105px}.Mount_Body_Slime-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -901px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -901px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -901px;width:105px;height:105px}.Mount_Body_Slime-Desert{background-image:url(spritesmith-main-7.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Body_Slime-Golden{background-image:url(spritesmith-main-7.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Body_Slime-Red{background-image:url(spritesmith-main-7.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Body_Slime-Shade{background-image:url(spritesmith-main-7.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Body_Slime-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Body_Slime-White{background-image:url(spritesmith-main-7.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Body_Slime-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Body_Snake-Base{background-image:url(spritesmith-main-7.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Body_Snake-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Body_Snake-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Body_Snake-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -1219px;width:105px;height:105px}.Mount_Body_Snake-Golden{background-image:url(spritesmith-main-7.png);background-position:-106px -1219px;width:105px;height:105px}.Mount_Body_Snake-Red{background-image:url(spritesmith-main-7.png);background-position:-212px -1219px;width:105px;height:105px}.Mount_Body_Snake-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1219px;width:105px;height:105px}.Mount_Body_Snake-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1219px;width:105px;height:105px}.Mount_Body_Snake-White{background-image:url(spritesmith-main-7.png);background-position:-530px -1219px;width:105px;height:105px}.Mount_Body_Snake-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -1219px;width:105px;height:105px}.Mount_Body_Spider-Base{background-image:url(spritesmith-main-7.png);background-position:-848px -1431px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-954px -1431px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1060px -1431px;width:105px;height:105px}.Mount_Body_Spider-Desert{background-image:url(spritesmith-main-7.png);background-position:-1166px -1431px;width:105px;height:105px}.Mount_Body_Spider-Golden{background-image:url(spritesmith-main-7.png);background-position:-1272px -1431px;width:105px;height:105px}.Mount_Body_Spider-Red{background-image:url(spritesmith-main-7.png);background-position:-1378px -1431px;width:105px;height:105px}.Mount_Body_Spider-Shade{background-image:url(spritesmith-main-7.png);background-position:-1484px -1431px;width:105px;height:105px}.Mount_Body_Spider-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px 0;width:105px;height:105px}.Mount_Body_Spider-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -106px;width:105px;height:105px}.Mount_Body_Spider-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -212px;width:105px;height:105px}.Mount_Body_TRex-Base{background-image:url(spritesmith-main-7.png);background-position:0 -544px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -136px;width:135px;height:135px}.Mount_Body_TRex-Desert{background-image:url(spritesmith-main-7.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Body_TRex-Golden{background-image:url(spritesmith-main-7.png);background-position:-272px 0;width:135px;height:135px}.Mount_Body_TRex-Red{background-image:url(spritesmith-main-7.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Body_TRex-Shade{background-image:url(spritesmith-main-7.png);background-position:0 -272px;width:135px;height:135px}.Mount_Body_TRex-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Body_TRex-White{background-image:url(spritesmith-main-7.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Body_TRex-Zombie{background-image:url(spritesmith-main-7.png);background-position:-408px 0;width:135px;height:135px}.Mount_Body_TigerCub-Base{background-image:url(spritesmith-main-7.png);background-position:-106px -795px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-212px -795px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-318px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-424px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-530px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Red{background-image:url(spritesmith-main-7.png);background-position:-636px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-742px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-848px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-998px 0;width:105px;height:105px}.Mount_Body_TigerCub-White{background-image:url(spritesmith-main-7.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Body_TigerCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Body_Turkey-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Body_Whale-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Body_Whale-Desert{background-image:url(spritesmith-main-7.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Body_Whale-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -901px;width:105px;height:105px}.Mount_Body_Whale-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -901px;width:105px;height:105px}.Mount_Body_Whale-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -901px;width:105px;height:105px}.Mount_Body_Whale-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -901px;width:105px;height:105px}.Mount_Body_Whale-White{background-image:url(spritesmith-main-7.png);background-position:-424px -901px;width:105px;height:105px}.Mount_Body_Whale-Zombie{background-image:url(spritesmith-main-7.png);background-position:-530px -901px;width:105px;height:105px}.Mount_Body_Wolf-Base{background-image:url(spritesmith-main-7.png);background-position:0 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -408px;width:135px;height:135px}.Mount_Body_Wolf-Desert{background-image:url(spritesmith-main-7.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Body_Wolf-Golden{background-image:url(spritesmith-main-7.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Body_Wolf-Red{background-image:url(spritesmith-main-7.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Body_Wolf-Shade{background-image:url(spritesmith-main-7.png);background-position:-544px 0;width:135px;height:135px}.Mount_Body_Wolf-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Body_Wolf-Spooky{background-image:url(spritesmith-main-7.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Body_Wolf-White{background-image:url(spritesmith-main-7.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Body_Wolf-Zombie{background-image:url(spritesmith-main-7.png);background-position:-136px 0;width:135px;height:135px}.Mount_Head_BearCub-Base{background-image:url(spritesmith-main-7.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -1007px;width:105px;height:105px}.Mount_Head_BearCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-106px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-212px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Polar{background-image:url(spritesmith-main-7.png);background-position:-318px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Red{background-image:url(spritesmith-main-7.png);background-position:-424px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-530px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-742px -1007px;width:105px;height:105px}.Mount_Head_BearCub-White{background-image:url(spritesmith-main-7.png);background-position:-848px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1007px;width:105px;height:105px}.Mount_Head_Bunny-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1007px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Bunny-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Bunny-Golden{background-image:url(spritesmith-main-7.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Bunny-Red{background-image:url(spritesmith-main-7.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Bunny-Shade{background-image:url(spritesmith-main-7.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Bunny-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Bunny-White{background-image:url(spritesmith-main-7.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Bunny-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Cactus-Base{background-image:url(spritesmith-main-7.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 -1113px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-106px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Desert{background-image:url(spritesmith-main-7.png);background-position:-212px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Golden{background-image:url(spritesmith-main-7.png);background-position:-318px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Red{background-image:url(spritesmith-main-7.png);background-position:-424px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Shade{background-image:url(spritesmith-main-7.png);background-position:-530px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Spooky{background-image:url(spritesmith-main-7.png);background-position:-742px -1113px;width:105px;height:105px}.Mount_Head_Cactus-White{background-image:url(spritesmith-main-7.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_Cactus-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Cheetah-Desert{background-image:url(spritesmith-main-7.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Cheetah-Golden{background-image:url(spritesmith-main-7.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Cheetah-Red{background-image:url(spritesmith-main-7.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Cheetah-Shade{background-image:url(spritesmith-main-7.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Cheetah-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Cheetah-White{background-image:url(spritesmith-main-7.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Cheetah-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Cuttlefish-Base{background-image:url(spritesmith-main-7.png);background-position:-242px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-348px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-454px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Desert{background-image:url(spritesmith-main-7.png);background-position:-560px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Golden{background-image:url(spritesmith-main-7.png);background-position:-680px 0;width:105px;height:114px}.Mount_Head_Cuttlefish-Red{background-image:url(spritesmith-main-7.png);background-position:-680px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Shade{background-image:url(spritesmith-main-7.png);background-position:-680px -230px;width:105px;height:114px}.Mount_Head_Cuttlefish-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-680px -345px;width:105px;height:114px}.Mount_Head_Cuttlefish-White{background-image:url(spritesmith-main-7.png);background-position:-680px -460px;width:105px;height:114px}.Mount_Head_Cuttlefish-Zombie{background-image:url(spritesmith-main-7.png);background-position:-786px 0;width:105px;height:114px}.Mount_Head_Deer-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -1219px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -1219px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -1219px;width:105px;height:105px}.Mount_Head_Deer-Desert{background-image:url(spritesmith-main-7.png);background-position:-1060px -1219px;width:105px;height:105px}.Mount_Head_Deer-Golden{background-image:url(spritesmith-main-7.png);background-position:-1166px -1219px;width:105px;height:105px}.Mount_Head_Deer-Red{background-image:url(spritesmith-main-7.png);background-position:-1272px -1219px;width:105px;height:105px}.Mount_Head_Deer-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_Deer-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_Deer-White{background-image:url(spritesmith-main-7.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_Deer-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_Dragon-Base{background-image:url(spritesmith-main-7.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_Dragon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_Dragon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_Dragon-Red{background-image:url(spritesmith-main-7.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_Dragon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_Dragon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Dragon-Spooky{background-image:url(spritesmith-main-7.png);background-position:0 -1325px;width:105px;height:105px}.Mount_Head_Dragon-White{background-image:url(spritesmith-main-7.png);background-position:-106px -1325px;width:105px;height:105px}.Mount_Head_Dragon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-212px -1325px;width:105px;height:105px}.Mount_Head_Egg-Base{background-image:url(spritesmith-main-7.png);background-position:-318px -1325px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-424px -1325px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-530px -1325px;width:105px;height:105px}.Mount_Head_Egg-Desert{background-image:url(spritesmith-main-7.png);background-position:-636px -1325px;width:105px;height:105px}.Mount_Head_Egg-Golden{background-image:url(spritesmith-main-7.png);background-position:-742px -1325px;width:105px;height:105px}.Mount_Head_Egg-Red{background-image:url(spritesmith-main-7.png);background-position:-848px -1325px;width:105px;height:105px}.Mount_Head_Egg-Shade{background-image:url(spritesmith-main-7.png);background-position:-954px -1325px;width:105px;height:105px}.Mount_Head_Egg-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1060px -1325px;width:105px;height:105px}.Mount_Head_Egg-White{background-image:url(spritesmith-main-7.png);background-position:-1166px -1325px;width:105px;height:105px}.Mount_Head_Egg-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1272px -1325px;width:105px;height:105px}.Mount_Head_FlyingPig-Base{background-image:url(spritesmith-main-7.png);background-position:-1378px -1325px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Head_FlyingPig-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Head_FlyingPig-Golden{background-image:url(spritesmith-main-7.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Head_FlyingPig-Red{background-image:url(spritesmith-main-7.png);background-position:-1528px -424px;width:105px;height:105px}.Mount_Head_FlyingPig-Shade{background-image:url(spritesmith-main-7.png);background-position:-1528px -530px;width:105px;height:105px}.Mount_Head_FlyingPig-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1528px -636px;width:105px;height:105px}.Mount_Head_FlyingPig-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1528px -742px;width:105px;height:105px}.Mount_Head_FlyingPig-White{background-image:url(spritesmith-main-7.png);background-position:-1528px -848px;width:105px;height:105px}.Mount_Head_FlyingPig-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1528px -954px;width:105px;height:105px}.Mount_Head_Fox-Base{background-image:url(spritesmith-main-7.png);background-position:-1528px -1060px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px -1166px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -1272px;width:105px;height:105px}.Mount_Head_Fox-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -1431px;width:105px;height:105px}.Mount_Head_Fox-Golden{background-image:url(spritesmith-main-7.png);background-position:-106px -1431px;width:105px;height:105px}.Mount_Head_Fox-Red{background-image:url(spritesmith-main-7.png);background-position:-212px -1431px;width:105px;height:105px}.Mount_Head_Fox-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1431px;width:105px;height:105px}.Mount_Head_Fox-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1431px;width:105px;height:105px}.Mount_Head_Fox-Spooky{background-image:url(spritesmith-main-7.png);background-position:-530px -1431px;width:105px;height:105px}.Mount_Head_Fox-White{background-image:url(spritesmith-main-7.png);background-position:-636px -1431px;width:105px;height:105px}.Mount_Head_Fox-Zombie{background-image:url(spritesmith-main-7.png);background-position:-742px -1431px;width:105px;height:105px}.Mount_Head_Frog-Base{background-image:url(spritesmith-main-7.png);background-position:-786px -115px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-786px -230px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -345px;width:105px;height:114px}.Mount_Head_Frog-Desert{background-image:url(spritesmith-main-7.png);background-position:-786px -460px;width:105px;height:114px}.Mount_Head_Frog-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -680px;width:105px;height:114px}.Mount_Head_Frog-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -680px;width:105px;height:114px}.Mount_Head_Frog-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -680px;width:105px;height:114px}.Mount_Head_Frog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -680px;width:105px;height:114px}.Mount_Head_Frog-White{background-image:url(spritesmith-main-7.png);background-position:-424px -680px;width:105px;height:114px}.Mount_Head_Frog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-136px -544px;width:105px;height:114px}.Mount_Head_Gryphon-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -318px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1634px -424px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1634px -530px;width:105px;height:105px}.Mount_Head_Gryphon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1634px -636px;width:105px;height:105px}.Mount_Head_Gryphon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1634px -742px;width:105px;height:105px}.Mount_Head_Gryphon-Red{background-image:url(spritesmith-main-7.png);background-position:-1634px -848px;width:105px;height:105px}.Mount_Head_Gryphon-RoyalPurple{background-image:url(spritesmith-main-7.png);background-position:-1634px -954px;width:105px;height:105px}.Mount_Head_Gryphon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -1060px;width:105px;height:105px}.Mount_Head_Gryphon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px -1166px;width:105px;height:105px}.Mount_Head_Gryphon-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -1272px;width:105px;height:105px}.Mount_Head_Gryphon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -1378px;width:105px;height:105px}.Mount_Head_Hedgehog-Base{background-image:url(spritesmith-main-7.png);background-position:0 -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-106px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-212px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Desert{background-image:url(spritesmith-main-7.png);background-position:-318px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Golden{background-image:url(spritesmith-main-7.png);background-position:-424px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Red{background-image:url(spritesmith-main-7.png);background-position:-530px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Shade{background-image:url(spritesmith-main-7.png);background-position:-636px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-742px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-White{background-image:url(spritesmith-main-7.png);background-position:-848px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1537px;width:105px;height:105px}.Mount_Head_Horse-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1537px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1537px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1272px -1537px;width:105px;height:105px}.Mount_Head_Horse-Desert{background-image:url(spritesmith-main-7.png);background-position:-1378px -1537px;width:105px;height:105px}.Mount_Head_Horse-Golden{background-image:url(spritesmith-main-7.png);background-position:-1484px -1537px;width:105px;height:105px}.Mount_Head_Horse-Red{background-image:url(spritesmith-main-7.png);background-position:-1590px -1537px;width:105px;height:105px}.Mount_Head_Horse-Shade{background-image:url(spritesmith-main-7.png);background-position:-1740px 0;width:105px;height:105px}.Mount_Head_Horse-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1740px -106px;width:105px;height:105px}.Mount_Head_Horse-White{background-image:url(spritesmith-main-7.png);background-position:-1740px -212px;width:105px;height:105px}.Mount_Head_Horse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1740px -318px;width:105px;height:105px}.Mount_Head_JackOLantern-Base{background-image:url(spritesmith-main-7.png);background-position:-1740px -424px;width:90px;height:105px}.Mount_Head_LionCub-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -1316px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-318px -1210px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Ethereal{background-image:url(spritesmith-main-8.png);background-position:-106px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-212px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Red{background-image:url(spritesmith-main-8.png);background-position:-318px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-424px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-242px -544px;width:105px;height:110px}.Mount_Head_LionCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-636px -1316px;width:105px;height:105px}.Mount_Head_LionCub-White{background-image:url(spritesmith-main-8.png);background-position:-742px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-848px -1316px;width:105px;height:105px}.Mount_Head_Mammoth-Base{background-image:url(spritesmith-main-8.png);background-position:-136px -544px;width:105px;height:123px}.Mount_Head_MantisShrimp-Base{background-image:url(spritesmith-main-8.png);background-position:-348px -544px;width:108px;height:105px}.Mount_Head_Octopus-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -1422px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -1422px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1166px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Red{background-image:url(spritesmith-main-8.png);background-position:-1272px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1378px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Head_Octopus-White{background-image:url(spritesmith-main-8.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Head_Octopus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Head_Orca-Base{background-image:url(spritesmith-main-8.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Head_Owl-Base{background-image:url(spritesmith-main-8.png);background-position:-563px -544px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-680px 0;width:105px;height:105px}.Mount_Head_Owl-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-680px -106px;width:105px;height:105px}.Mount_Head_Owl-Desert{background-image:url(spritesmith-main-8.png);background-position:-680px -212px;width:105px;height:105px}.Mount_Head_Owl-Golden{background-image:url(spritesmith-main-8.png);background-position:-680px -318px;width:105px;height:105px}.Mount_Head_Owl-Red{background-image:url(spritesmith-main-8.png);background-position:-680px -424px;width:105px;height:105px}.Mount_Head_Owl-Shade{background-image:url(spritesmith-main-8.png);background-position:-680px -530px;width:105px;height:105px}.Mount_Head_Owl-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -680px;width:105px;height:105px}.Mount_Head_Owl-White{background-image:url(spritesmith-main-8.png);background-position:-106px -680px;width:105px;height:105px}.Mount_Head_Owl-Zombie{background-image:url(spritesmith-main-8.png);background-position:-212px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -680px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -680px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-786px 0;width:105px;height:105px}.Mount_Head_PandaCub-Red{background-image:url(spritesmith-main-8.png);background-position:-786px -106px;width:105px;height:105px}.Mount_Head_PandaCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-786px -212px;width:105px;height:105px}.Mount_Head_PandaCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-786px -318px;width:105px;height:105px}.Mount_Head_PandaCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-786px -424px;width:105px;height:105px}.Mount_Head_PandaCub-White{background-image:url(spritesmith-main-8.png);background-position:-786px -530px;width:105px;height:105px}.Mount_Head_PandaCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-786px -636px;width:105px;height:105px}.Mount_Head_Parrot-Base{background-image:url(spritesmith-main-8.png);background-position:0 -786px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-106px -786px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-212px -786px;width:105px;height:105px}.Mount_Head_Parrot-Desert{background-image:url(spritesmith-main-8.png);background-position:-318px -786px;width:105px;height:105px}.Mount_Head_Parrot-Golden{background-image:url(spritesmith-main-8.png);background-position:-424px -786px;width:105px;height:105px}.Mount_Head_Parrot-Red{background-image:url(spritesmith-main-8.png);background-position:-530px -786px;width:105px;height:105px}.Mount_Head_Parrot-Shade{background-image:url(spritesmith-main-8.png);background-position:-636px -786px;width:105px;height:105px}.Mount_Head_Parrot-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-742px -786px;width:105px;height:105px}.Mount_Head_Parrot-White{background-image:url(spritesmith-main-8.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Parrot-Zombie{background-image:url(spritesmith-main-8.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Head_Penguin-Base{background-image:url(spritesmith-main-8.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Head_Penguin-Desert{background-image:url(spritesmith-main-8.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Head_Penguin-Golden{background-image:url(spritesmith-main-8.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Head_Penguin-Red{background-image:url(spritesmith-main-8.png);background-position:-892px -742px;width:105px;height:105px}.Mount_Head_Penguin-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -892px;width:105px;height:105px}.Mount_Head_Penguin-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-106px -892px;width:105px;height:105px}.Mount_Head_Penguin-White{background-image:url(spritesmith-main-8.png);background-position:-212px -892px;width:105px;height:105px}.Mount_Head_Penguin-Zombie{background-image:url(spritesmith-main-8.png);background-position:-318px -892px;width:105px;height:105px}.Mount_Head_Phoenix-Base{background-image:url(spritesmith-main-8.png);background-position:-424px -892px;width:105px;height:105px}.Mount_Head_Rat-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -892px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -892px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -892px;width:105px;height:105px}.Mount_Head_Rat-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -892px;width:105px;height:105px}.Mount_Head_Rat-Golden{background-image:url(spritesmith-main-8.png);background-position:-998px 0;width:105px;height:105px}.Mount_Head_Rat-Red{background-image:url(spritesmith-main-8.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Head_Rat-Shade{background-image:url(spritesmith-main-8.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Head_Rat-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_Rat-White{background-image:url(spritesmith-main-8.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_Rat-Zombie{background-image:url(spritesmith-main-8.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_Rock-Base{background-image:url(spritesmith-main-8.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-998px -848px;width:105px;height:105px}.Mount_Head_Rock-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -998px;width:105px;height:105px}.Mount_Head_Rock-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -998px;width:105px;height:105px}.Mount_Head_Rock-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -998px;width:105px;height:105px}.Mount_Head_Rock-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -998px;width:105px;height:105px}.Mount_Head_Rock-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -998px;width:105px;height:105px}.Mount_Head_Rock-White{background-image:url(spritesmith-main-8.png);background-position:-530px -998px;width:105px;height:105px}.Mount_Head_Rock-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -998px;width:105px;height:105px}.Mount_Head_Rooster-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -998px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -998px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -998px;width:105px;height:105px}.Mount_Head_Rooster-Desert{background-image:url(spritesmith-main-8.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Rooster-Golden{background-image:url(spritesmith-main-8.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Rooster-Red{background-image:url(spritesmith-main-8.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Rooster-Shade{background-image:url(spritesmith-main-8.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Rooster-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Rooster-White{background-image:url(spritesmith-main-8.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Rooster-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Seahorse-Base{background-image:url(spritesmith-main-8.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1104px -954px;width:105px;height:105px}.Mount_Head_Seahorse-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-White{background-image:url(spritesmith-main-8.png);background-position:-530px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -1104px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -1104px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Sheep-Red{background-image:url(spritesmith-main-8.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Sheep-Shade{background-image:url(spritesmith-main-8.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Sheep-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Sheep-White{background-image:url(spritesmith-main-8.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Sheep-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Slime-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Slime-Desert{background-image:url(spritesmith-main-8.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Slime-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px -1060px;width:105px;height:105px}.Mount_Head_Slime-Red{background-image:url(spritesmith-main-8.png);background-position:0 -1210px;width:105px;height:105px}.Mount_Head_Slime-Shade{background-image:url(spritesmith-main-8.png);background-position:-106px -1210px;width:105px;height:105px}.Mount_Head_Slime-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-212px -1210px;width:105px;height:105px}.Mount_Head_Slime-White{background-image:url(spritesmith-main-8.png);background-position:-457px -544px;width:105px;height:105px}.Mount_Head_Slime-Zombie{background-image:url(spritesmith-main-8.png);background-position:-424px -1210px;width:105px;height:105px}.Mount_Head_Snake-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -1210px;width:105px;height:105px}.Mount_Head_Snake-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -1210px;width:105px;height:105px}.Mount_Head_Snake-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -1210px;width:105px;height:105px}.Mount_Head_Snake-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -1210px;width:105px;height:105px}.Mount_Head_Snake-Golden{background-image:url(spritesmith-main-8.png);background-position:-954px -1210px;width:105px;height:105px}.Mount_Head_Snake-Red{background-image:url(spritesmith-main-8.png);background-position:-1060px -1210px;width:105px;height:105px}.Mount_Head_Snake-Shade{background-image:url(spritesmith-main-8.png);background-position:-1166px -1210px;width:105px;height:105px}.Mount_Head_Snake-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Snake-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Snake-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Spider-Base{background-image:url(spritesmith-main-8.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Spider-Desert{background-image:url(spritesmith-main-8.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Spider-Golden{background-image:url(spritesmith-main-8.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Spider-Red{background-image:url(spritesmith-main-8.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Spider-Shade{background-image:url(spritesmith-main-8.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Spider-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Spider-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -1166px;width:105px;height:105px}.Mount_Head_Spider-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1316px;width:105px;height:105px}.Mount_Head_TRex-Base{background-image:url(spritesmith-main-8.png);background-position:-272px 0;width:135px;height:135px}.Mount_Head_TRex-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:0 -272px;width:135px;height:135px}.Mount_Head_TRex-Desert{background-image:url(spritesmith-main-8.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Head_TRex-Golden{background-image:url(spritesmith-main-8.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Head_TRex-Red{background-image:url(spritesmith-main-8.png);background-position:-408px 0;width:135px;height:135px}.Mount_Head_TRex-Shade{background-image:url(spritesmith-main-8.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Head_TRex-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Head_TRex-White{background-image:url(spritesmith-main-8.png);background-position:0 0;width:135px;height:135px}.Mount_Head_TRex-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Head_TigerCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1166px -1316px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1272px -1316px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_TigerCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_TigerCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_TigerCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_TigerCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_TigerCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_TigerCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_TigerCub-White{background-image:url(spritesmith-main-8.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_TigerCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_Turkey-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_Whale-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px -1272px;width:105px;height:105px}.Mount_Head_Whale-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1422px;width:105px;height:105px}.Mount_Head_Whale-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -1422px;width:105px;height:105px}.Mount_Head_Whale-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -1422px;width:105px;height:105px}.Mount_Head_Whale-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -1422px;width:105px;height:105px}.Mount_Head_Whale-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -1422px;width:105px;height:105px}.Mount_Head_Whale-White{background-image:url(spritesmith-main-8.png);background-position:-530px -1422px;width:105px;height:105px}.Mount_Head_Whale-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -1422px;width:105px;height:105px}.Mount_Head_Wolf-Base{background-image:url(spritesmith-main-8.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-544px 0;width:135px;height:135px}.Mount_Head_Wolf-Desert{background-image:url(spritesmith-main-8.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Head_Wolf-Golden{background-image:url(spritesmith-main-8.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Head_Wolf-Red{background-image:url(spritesmith-main-8.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_Wolf-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -544px;width:135px;height:135px}.Mount_Head_Wolf-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -408px;width:135px;height:135px}.Mount_Head_Wolf-Spooky{background-image:url(spritesmith-main-8.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Head_Wolf-White{background-image:url(spritesmith-main-8.png);background-position:0 -136px;width:135px;height:135px}.Mount_Head_Wolf-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px 0;width:135px;height:135px}.Pet-BearCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1528px -524px;width:81px;height:99px}.Pet-BearCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px 0;width:81px;height:99px}.Pet-BearCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1528px -624px;width:81px;height:99px}.Pet-BearCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1528px -724px;width:81px;height:99px}.Pet-BearCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1528px -824px;width:81px;height:99px}.Pet-BearCub-Polar{background-image:url(spritesmith-main-8.png);background-position:-1528px -924px;width:81px;height:99px}.Pet-BearCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1528px -1024px;width:81px;height:99px}.Pet-BearCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1528px -1124px;width:81px;height:99px}.Pet-BearCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1528px -1224px;width:81px;height:99px}.Pet-BearCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1528px -1324px;width:81px;height:99px}.Pet-BearCub-White{background-image:url(spritesmith-main-8.png);background-position:-1528px -1424px;width:81px;height:99px}.Pet-BearCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1528px;width:81px;height:99px}.Pet-Bunny-Base{background-image:url(spritesmith-main-8.png);background-position:-82px -1528px;width:81px;height:99px}.Pet-Bunny-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-164px -1528px;width:81px;height:99px}.Pet-Bunny-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-246px -1528px;width:81px;height:99px}.Pet-Bunny-Desert{background-image:url(spritesmith-main-8.png);background-position:-328px -1528px;width:81px;height:99px}.Pet-Bunny-Golden{background-image:url(spritesmith-main-8.png);background-position:-410px -1528px;width:81px;height:99px}.Pet-Bunny-Red{background-image:url(spritesmith-main-8.png);background-position:-492px -1528px;width:81px;height:99px}.Pet-Bunny-Shade{background-image:url(spritesmith-main-8.png);background-position:-574px -1528px;width:81px;height:99px}.Pet-Bunny-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-656px -1528px;width:81px;height:99px}.Pet-Bunny-White{background-image:url(spritesmith-main-8.png);background-position:-738px -1528px;width:81px;height:99px}.Pet-Bunny-Zombie{background-image:url(spritesmith-main-8.png);background-position:-820px -1528px;width:81px;height:99px}.Pet-Cactus-Base{background-image:url(spritesmith-main-8.png);background-position:-902px -1528px;width:81px;height:99px}.Pet-Cactus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-984px -1528px;width:81px;height:99px}.Pet-Cactus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1066px -1528px;width:81px;height:99px}.Pet-Cactus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1148px -1528px;width:81px;height:99px}.Pet-Cactus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1230px -1528px;width:81px;height:99px}.Pet-Cactus-Red{background-image:url(spritesmith-main-8.png);background-position:-1312px -1528px;width:81px;height:99px}.Pet-Cactus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1394px -1528px;width:81px;height:99px}.Pet-Cactus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1476px -1528px;width:81px;height:99px}.Pet-Cactus-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1528px -424px;width:81px;height:99px}.Pet-Cactus-White{background-image:url(spritesmith-main-8.png);background-position:-1634px -100px;width:81px;height:99px}.Pet-Cactus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1634px -200px;width:81px;height:99px}.Pet-Cheetah-Base{background-image:url(spritesmith-main-8.png);background-position:-1634px -300px;width:81px;height:99px}.Pet-Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px -400px;width:81px;height:99px}.Pet-Cheetah-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1634px -500px;width:81px;height:99px}.Pet-Cheetah-Desert{background-image:url(spritesmith-main-8.png);background-position:-1634px -600px;width:81px;height:99px}.Pet-Cheetah-Golden{background-image:url(spritesmith-main-8.png);background-position:-1634px -700px;width:81px;height:99px}.Pet-Cheetah-Red{background-image:url(spritesmith-main-8.png);background-position:-1634px -800px;width:81px;height:99px}.Pet-Cheetah-Shade{background-image:url(spritesmith-main-8.png);background-position:-1634px -900px;width:81px;height:99px}.Pet-Cheetah-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1634px -1000px;width:81px;height:99px}.Pet-Cheetah-White{background-image:url(spritesmith-main-8.png);background-position:-1634px -1100px;width:81px;height:99px}.Pet-Cheetah-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1634px -1200px;width:81px;height:99px}.Pet-Cuttlefish-Base{background-image:url(spritesmith-main-8.png);background-position:-1634px -1300px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px -1400px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1634px -1500px;width:81px;height:99px}.Pet-Cuttlefish-Desert{background-image:url(spritesmith-main-8.png);background-position:-1716px 0;width:81px;height:99px}.Pet-Cuttlefish-Golden{background-image:url(spritesmith-main-8.png);background-position:-1716px -100px;width:81px;height:99px}.Pet-Cuttlefish-Red{background-image:url(spritesmith-main-8.png);background-position:-1716px -200px;width:81px;height:99px}.Pet-Cuttlefish-Shade{background-image:url(spritesmith-main-8.png);background-position:-1716px -300px;width:81px;height:99px}.Pet-Cuttlefish-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1716px -400px;width:81px;height:99px}.Pet-Cuttlefish-White{background-image:url(spritesmith-main-8.png);background-position:-1716px -500px;width:81px;height:99px}.Pet-Cuttlefish-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1716px -600px;width:81px;height:99px}.Pet-Deer-Base{background-image:url(spritesmith-main-8.png);background-position:-1716px -700px;width:81px;height:99px}.Pet-Deer-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1716px -800px;width:81px;height:99px}.Pet-Deer-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1716px -900px;width:81px;height:99px}.Pet-Deer-Desert{background-image:url(spritesmith-main-8.png);background-position:-1716px -1000px;width:81px;height:99px}.Pet-Deer-Golden{background-image:url(spritesmith-main-8.png);background-position:-1716px -1100px;width:81px;height:99px}.Pet-Deer-Red{background-image:url(spritesmith-main-8.png);background-position:-1716px -1200px;width:81px;height:99px}.Pet-Deer-Shade{background-image:url(spritesmith-main-8.png);background-position:-1716px -1300px;width:81px;height:99px}.Pet-Deer-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px 0;width:81px;height:99px}.Pet-Deer-White{background-image:url(spritesmith-main-9.png);background-position:-328px -1000px;width:81px;height:99px}.Pet-Deer-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px 0;width:81px;height:99px}.Pet-Dragon-Base{background-image:url(spritesmith-main-9.png);background-position:0 -100px;width:81px;height:99px}.Pet-Dragon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -100px;width:81px;height:99px}.Pet-Dragon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -100px;width:81px;height:99px}.Pet-Dragon-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px 0;width:81px;height:99px}.Pet-Dragon-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -100px;width:81px;height:99px}.Pet-Dragon-Hydra{background-image:url(spritesmith-main-9.png);background-position:0 -200px;width:81px;height:99px}.Pet-Dragon-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -200px;width:81px;height:99px}.Pet-Dragon-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -200px;width:81px;height:99px}.Pet-Dragon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -200px;width:81px;height:99px}.Pet-Dragon-Spooky{background-image:url(spritesmith-main-9.png);background-position:-328px 0;width:81px;height:99px}.Pet-Dragon-White{background-image:url(spritesmith-main-9.png);background-position:-328px -100px;width:81px;height:99px}.Pet-Dragon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-328px -200px;width:81px;height:99px}.Pet-Egg-Base{background-image:url(spritesmith-main-9.png);background-position:0 -300px;width:81px;height:99px}.Pet-Egg-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -300px;width:81px;height:99px}.Pet-Egg-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -300px;width:81px;height:99px}.Pet-Egg-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -300px;width:81px;height:99px}.Pet-Egg-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -300px;width:81px;height:99px}.Pet-Egg-Red{background-image:url(spritesmith-main-9.png);background-position:-410px 0;width:81px;height:99px}.Pet-Egg-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -100px;width:81px;height:99px}.Pet-Egg-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -200px;width:81px;height:99px}.Pet-Egg-White{background-image:url(spritesmith-main-9.png);background-position:-410px -300px;width:81px;height:99px}.Pet-Egg-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px 0;width:81px;height:99px}.Pet-FlyingPig-Base{background-image:url(spritesmith-main-9.png);background-position:-492px -100px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -200px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -300px;width:81px;height:99px}.Pet-FlyingPig-Desert{background-image:url(spritesmith-main-9.png);background-position:0 -400px;width:81px;height:99px}.Pet-FlyingPig-Golden{background-image:url(spritesmith-main-9.png);background-position:-82px -400px;width:81px;height:99px}.Pet-FlyingPig-Red{background-image:url(spritesmith-main-9.png);background-position:-164px -400px;width:81px;height:99px}.Pet-FlyingPig-Shade{background-image:url(spritesmith-main-9.png);background-position:-246px -400px;width:81px;height:99px}.Pet-FlyingPig-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -400px;width:81px;height:99px}.Pet-FlyingPig-Spooky{background-image:url(spritesmith-main-9.png);background-position:-410px -400px;width:81px;height:99px}.Pet-FlyingPig-White{background-image:url(spritesmith-main-9.png);background-position:-492px -400px;width:81px;height:99px}.Pet-FlyingPig-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px 0;width:81px;height:99px}.Pet-Fox-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -100px;width:81px;height:99px}.Pet-Fox-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-574px -200px;width:81px;height:99px}.Pet-Fox-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -300px;width:81px;height:99px}.Pet-Fox-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -400px;width:81px;height:99px}.Pet-Fox-Golden{background-image:url(spritesmith-main-9.png);background-position:0 -500px;width:81px;height:99px}.Pet-Fox-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -500px;width:81px;height:99px}.Pet-Fox-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -500px;width:81px;height:99px}.Pet-Fox-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -500px;width:81px;height:99px}.Pet-Fox-Spooky{background-image:url(spritesmith-main-9.png);background-position:-328px -500px;width:81px;height:99px}.Pet-Fox-White{background-image:url(spritesmith-main-9.png);background-position:-410px -500px;width:81px;height:99px}.Pet-Fox-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -500px;width:81px;height:99px}.Pet-Frog-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -500px;width:81px;height:99px}.Pet-Frog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px 0;width:81px;height:99px}.Pet-Frog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-656px -100px;width:81px;height:99px}.Pet-Frog-Desert{background-image:url(spritesmith-main-9.png);background-position:-656px -200px;width:81px;height:99px}.Pet-Frog-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -300px;width:81px;height:99px}.Pet-Frog-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -400px;width:81px;height:99px}.Pet-Frog-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -500px;width:81px;height:99px}.Pet-Frog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:0 -600px;width:81px;height:99px}.Pet-Frog-White{background-image:url(spritesmith-main-9.png);background-position:-82px -600px;width:81px;height:99px}.Pet-Frog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px -600px;width:81px;height:99px}.Pet-Gryphon-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -600px;width:81px;height:99px}.Pet-Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-328px -600px;width:81px;height:99px}.Pet-Gryphon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-410px -600px;width:81px;height:99px}.Pet-Gryphon-Desert{background-image:url(spritesmith-main-9.png);background-position:-492px -600px;width:81px;height:99px}.Pet-Gryphon-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -600px;width:81px;height:99px}.Pet-Gryphon-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -600px;width:81px;height:99px}.Pet-Gryphon-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px 0;width:81px;height:99px}.Pet-Gryphon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -100px;width:81px;height:99px}.Pet-Gryphon-White{background-image:url(spritesmith-main-9.png);background-position:-738px -200px;width:81px;height:99px}.Pet-Gryphon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-738px -300px;width:81px;height:99px}.Pet-Hedgehog-Base{background-image:url(spritesmith-main-9.png);background-position:-738px -400px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -500px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-738px -600px;width:81px;height:99px}.Pet-Hedgehog-Desert{background-image:url(spritesmith-main-9.png);background-position:0 -700px;width:81px;height:99px}.Pet-Hedgehog-Golden{background-image:url(spritesmith-main-9.png);background-position:-82px -700px;width:81px;height:99px}.Pet-Hedgehog-Red{background-image:url(spritesmith-main-9.png);background-position:-164px -700px;width:81px;height:99px}.Pet-Hedgehog-Shade{background-image:url(spritesmith-main-9.png);background-position:-246px -700px;width:81px;height:99px}.Pet-Hedgehog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -700px;width:81px;height:99px}.Pet-Hedgehog-White{background-image:url(spritesmith-main-9.png);background-position:-410px -700px;width:81px;height:99px}.Pet-Hedgehog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -700px;width:81px;height:99px}.Pet-Horse-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -700px;width:81px;height:99px}.Pet-Horse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px -700px;width:81px;height:99px}.Pet-Horse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-738px -700px;width:81px;height:99px}.Pet-Horse-Desert{background-image:url(spritesmith-main-9.png);background-position:-820px 0;width:81px;height:99px}.Pet-Horse-Golden{background-image:url(spritesmith-main-9.png);background-position:-820px -100px;width:81px;height:99px}.Pet-Horse-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -200px;width:81px;height:99px}.Pet-Horse-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -300px;width:81px;height:99px}.Pet-Horse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-820px -400px;width:81px;height:99px}.Pet-Horse-White{background-image:url(spritesmith-main-9.png);background-position:-820px -500px;width:81px;height:99px}.Pet-Horse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -600px;width:81px;height:99px}.Pet-JackOLantern-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -700px;width:81px;height:99px}.Pet-LionCub-Base{background-image:url(spritesmith-main-9.png);background-position:0 -800px;width:81px;height:99px}.Pet-LionCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -800px;width:81px;height:99px}.Pet-LionCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -800px;width:81px;height:99px}.Pet-LionCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -800px;width:81px;height:99px}.Pet-LionCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -800px;width:81px;height:99px}.Pet-LionCub-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -800px;width:81px;height:99px}.Pet-LionCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -800px;width:81px;height:99px}.Pet-LionCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -800px;width:81px;height:99px}.Pet-LionCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px -800px;width:81px;height:99px}.Pet-LionCub-White{background-image:url(spritesmith-main-9.png);background-position:-738px -800px;width:81px;height:99px}.Pet-LionCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -800px;width:81px;height:99px}.Pet-Mammoth-Base{background-image:url(spritesmith-main-9.png);background-position:-902px 0;width:81px;height:99px}.Pet-MantisShrimp-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -100px;width:81px;height:99px}.Pet-Octopus-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -200px;width:81px;height:99px}.Pet-Octopus-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -300px;width:81px;height:99px}.Pet-Octopus-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-902px -400px;width:81px;height:99px}.Pet-Octopus-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -500px;width:81px;height:99px}.Pet-Octopus-Golden{background-image:url(spritesmith-main-9.png);background-position:-902px -600px;width:81px;height:99px}.Pet-Octopus-Red{background-image:url(spritesmith-main-9.png);background-position:-902px -700px;width:81px;height:99px}.Pet-Octopus-Shade{background-image:url(spritesmith-main-9.png);background-position:-902px -800px;width:81px;height:99px}.Pet-Octopus-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-984px 0;width:81px;height:99px}.Pet-Octopus-White{background-image:url(spritesmith-main-9.png);background-position:-984px -100px;width:81px;height:99px}.Pet-Octopus-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px -200px;width:81px;height:99px}.Pet-Owl-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -300px;width:81px;height:99px}.Pet-Owl-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -400px;width:81px;height:99px}.Pet-Owl-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -500px;width:81px;height:99px}.Pet-Owl-Desert{background-image:url(spritesmith-main-9.png);background-position:-984px -600px;width:81px;height:99px}.Pet-Owl-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -700px;width:81px;height:99px}.Pet-Owl-Red{background-image:url(spritesmith-main-9.png);background-position:-984px -800px;width:81px;height:99px}.Pet-Owl-Shade{background-image:url(spritesmith-main-9.png);background-position:0 -900px;width:81px;height:99px}.Pet-Owl-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px -900px;width:81px;height:99px}.Pet-Owl-White{background-image:url(spritesmith-main-9.png);background-position:-164px -900px;width:81px;height:99px}.Pet-Owl-Zombie{background-image:url(spritesmith-main-9.png);background-position:-246px -900px;width:81px;height:99px}.Pet-PandaCub-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -900px;width:81px;height:99px}.Pet-PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-410px -900px;width:81px;height:99px}.Pet-PandaCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -900px;width:81px;height:99px}.Pet-PandaCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -900px;width:81px;height:99px}.Pet-PandaCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -900px;width:81px;height:99px}.Pet-PandaCub-Red{background-image:url(spritesmith-main-9.png);background-position:-738px -900px;width:81px;height:99px}.Pet-PandaCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -900px;width:81px;height:99px}.Pet-PandaCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-902px -900px;width:81px;height:99px}.Pet-PandaCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-984px -900px;width:81px;height:99px}.Pet-PandaCub-White{background-image:url(spritesmith-main-9.png);background-position:-1066px 0;width:81px;height:99px}.Pet-PandaCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1066px -100px;width:81px;height:99px}.Pet-Parrot-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -200px;width:81px;height:99px}.Pet-Parrot-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1066px -300px;width:81px;height:99px}.Pet-Parrot-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -400px;width:81px;height:99px}.Pet-Parrot-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -500px;width:81px;height:99px}.Pet-Parrot-Golden{background-image:url(spritesmith-main-9.png);background-position:-1066px -600px;width:81px;height:99px}.Pet-Parrot-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -700px;width:81px;height:99px}.Pet-Parrot-Shade{background-image:url(spritesmith-main-9.png);background-position:-1066px -800px;width:81px;height:99px}.Pet-Parrot-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1066px -900px;width:81px;height:99px}.Pet-Parrot-White{background-image:url(spritesmith-main-9.png);background-position:0 -1000px;width:81px;height:99px}.Pet-Parrot-Zombie{background-image:url(spritesmith-main-9.png);background-position:-82px -1000px;width:81px;height:99px}.Pet-Penguin-Base{background-image:url(spritesmith-main-9.png);background-position:-164px -1000px;width:81px;height:99px}.Pet-Penguin-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-246px -1000px;width:81px;height:99px}.Pet-Penguin-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 0;width:81px;height:99px}.Pet-Penguin-Desert{background-image:url(spritesmith-main-9.png);background-position:-410px -1000px;width:81px;height:99px}.Pet-Penguin-Golden{background-image:url(spritesmith-main-9.png);background-position:-492px -1000px;width:81px;height:99px}.Pet-Penguin-Red{background-image:url(spritesmith-main-9.png);background-position:-574px -1000px;width:81px;height:99px}.Pet-Penguin-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -1000px;width:81px;height:99px}.Pet-Penguin-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -1000px;width:81px;height:99px}.Pet-Penguin-White{background-image:url(spritesmith-main-9.png);background-position:-820px -1000px;width:81px;height:99px}.Pet-Penguin-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -1000px;width:81px;height:99px}.Pet-Phoenix-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -1000px;width:81px;height:99px}.Pet-Rat-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -1000px;width:81px;height:99px}.Pet-Rat-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px 0;width:81px;height:99px}.Pet-Rat-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1148px -100px;width:81px;height:99px}.Pet-Rat-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -200px;width:81px;height:99px}.Pet-Rat-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -300px;width:81px;height:99px}.Pet-Rat-Red{background-image:url(spritesmith-main-9.png);background-position:-1148px -400px;width:81px;height:99px}.Pet-Rat-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -500px;width:81px;height:99px}.Pet-Rat-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1148px -600px;width:81px;height:99px}.Pet-Rat-White{background-image:url(spritesmith-main-9.png);background-position:-1148px -700px;width:81px;height:99px}.Pet-Rat-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -800px;width:81px;height:99px}.Pet-Rock-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -900px;width:81px;height:99px}.Pet-Rock-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px -1000px;width:81px;height:99px}.Pet-Rock-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 -1100px;width:81px;height:99px}.Pet-Rock-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px -1100px;width:81px;height:99px}.Pet-Rock-Golden{background-image:url(spritesmith-main-9.png);background-position:-164px -1100px;width:81px;height:99px}.Pet-Rock-Red{background-image:url(spritesmith-main-9.png);background-position:-246px -1100px;width:81px;height:99px}.Pet-Rock-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -1100px;width:81px;height:99px}.Pet-Rock-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -1100px;width:81px;height:99px}.Pet-Rock-White{background-image:url(spritesmith-main-9.png);background-position:-492px -1100px;width:81px;height:99px}.Pet-Rock-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px -1100px;width:81px;height:99px}.Pet-Rooster-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -1100px;width:81px;height:99px}.Pet-Rooster-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -1100px;width:81px;height:99px}.Pet-Rooster-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-820px -1100px;width:81px;height:99px}.Pet-Rooster-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -1100px;width:81px;height:99px}.Pet-Rooster-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -1100px;width:81px;height:99px}.Pet-Rooster-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -1100px;width:81px;height:99px}.Pet-Rooster-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -1100px;width:81px;height:99px}.Pet-Rooster-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px 0;width:81px;height:99px}.Pet-Rooster-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -100px;width:81px;height:99px}.Pet-Rooster-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1230px -200px;width:81px;height:99px}.Pet-Seahorse-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px -300px;width:81px;height:99px}.Pet-Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1230px -400px;width:81px;height:99px}.Pet-Seahorse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1230px -500px;width:81px;height:99px}.Pet-Seahorse-Desert{background-image:url(spritesmith-main-9.png);background-position:-1230px -600px;width:81px;height:99px}.Pet-Seahorse-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -700px;width:81px;height:99px}.Pet-Seahorse-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -800px;width:81px;height:99px}.Pet-Seahorse-Shade{background-image:url(spritesmith-main-9.png);background-position:-1230px -900px;width:81px;height:99px}.Pet-Seahorse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px -1000px;width:81px;height:99px}.Pet-Seahorse-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -1100px;width:81px;height:99px}.Pet-Seahorse-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -1200px;width:81px;height:99px}.Pet-Sheep-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -1200px;width:81px;height:99px}.Pet-Sheep-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -1200px;width:81px;height:99px}.Pet-Sheep-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px -1200px;width:81px;height:99px}.Pet-Sheep-Desert{background-image:url(spritesmith-main-9.png);background-position:-328px -1200px;width:81px;height:99px}.Pet-Sheep-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -1200px;width:81px;height:99px}.Pet-Sheep-Red{background-image:url(spritesmith-main-9.png);background-position:-492px -1200px;width:81px;height:99px}.Pet-Sheep-Shade{background-image:url(spritesmith-main-9.png);background-position:-574px -1200px;width:81px;height:99px}.Pet-Sheep-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-656px -1200px;width:81px;height:99px}.Pet-Sheep-White{background-image:url(spritesmith-main-9.png);background-position:-738px -1200px;width:81px;height:99px}.Pet-Sheep-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -1200px;width:81px;height:99px}.Pet-Slime-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -1200px;width:81px;height:99px}.Pet-Slime-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -1200px;width:81px;height:99px}.Pet-Slime-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -1200px;width:81px;height:99px}.Pet-Slime-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -1200px;width:81px;height:99px}.Pet-Slime-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -1200px;width:81px;height:99px}.Pet-Slime-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px 0;width:81px;height:99px}.Pet-Slime-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -100px;width:81px;height:99px}.Pet-Slime-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -200px;width:81px;height:99px}.Pet-Slime-White{background-image:url(spritesmith-main-9.png);background-position:-1312px -300px;width:81px;height:99px}.Pet-Slime-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1312px -400px;width:81px;height:99px}.Pet-Snake-Base{background-image:url(spritesmith-main-9.png);background-position:-1312px -500px;width:81px;height:99px}.Pet-Snake-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1312px -600px;width:81px;height:99px}.Pet-Snake-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px -700px;width:81px;height:99px}.Pet-Snake-Desert{background-image:url(spritesmith-main-9.png);background-position:-1312px -800px;width:81px;height:99px}.Pet-Snake-Golden{background-image:url(spritesmith-main-9.png);background-position:-1312px -900px;width:81px;height:99px}.Pet-Snake-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px -1000px;width:81px;height:99px}.Pet-Snake-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -1100px;width:81px;height:99px}.Pet-Snake-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -1200px;width:81px;height:99px}.Pet-Snake-White{background-image:url(spritesmith-main-9.png);background-position:-1394px 0;width:81px;height:99px}.Pet-Snake-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -100px;width:81px;height:99px}.Pet-Spider-Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -200px;width:81px;height:99px}.Pet-Spider-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1394px -300px;width:81px;height:99px}.Pet-Spider-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1394px -400px;width:81px;height:99px}.Pet-Spider-Desert{background-image:url(spritesmith-main-9.png);background-position:-1394px -500px;width:81px;height:99px}.Pet-Spider-Golden{background-image:url(spritesmith-main-9.png);background-position:-1394px -600px;width:81px;height:99px}.Pet-Spider-Red{background-image:url(spritesmith-main-9.png);background-position:-1394px -700px;width:81px;height:99px}.Pet-Spider-Shade{background-image:url(spritesmith-main-9.png);background-position:-1394px -800px;width:81px;height:99px}.Pet-Spider-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -900px;width:81px;height:99px}.Pet-Spider-White{background-image:url(spritesmith-main-9.png);background-position:-1394px -1000px;width:81px;height:99px}.Pet-Spider-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -1100px;width:81px;height:99px}.Pet-TRex-Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -1200px;width:81px;height:99px}.Pet-TRex-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -1300px;width:81px;height:99px}.Pet-TRex-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -1300px;width:81px;height:99px}.Pet-TRex-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -1300px;width:81px;height:99px}.Pet-TRex-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -1300px;width:81px;height:99px}.Pet-TRex-Red{background-image:url(spritesmith-main-9.png);background-position:-328px -1300px;width:81px;height:99px}.Pet-TRex-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -1300px;width:81px;height:99px}.Pet-TRex-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-492px -1300px;width:81px;height:99px}.Pet-TRex-White{background-image:url(spritesmith-main-9.png);background-position:-574px -1300px;width:81px;height:99px}.Pet-TRex-Zombie{background-image:url(spritesmith-main-9.png);background-position:-656px -1300px;width:81px;height:99px}.Pet-Tiger-Veteran{background-image:url(spritesmith-main-9.png);background-position:-738px -1300px;width:81px;height:99px}.Pet-TigerCub-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -1300px;width:81px;height:99px}.Pet-TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -1300px;width:81px;height:99px}.Pet-TigerCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -1300px;width:81px;height:99px}.Pet-TigerCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -1300px;width:81px;height:99px}.Pet-TigerCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -1300px;width:81px;height:99px}.Pet-TigerCub-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -1300px;width:81px;height:99px}.Pet-TigerCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -1300px;width:81px;height:99px}.Pet-TigerCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -1300px;width:81px;height:99px}.Pet-TigerCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-1476px 0;width:81px;height:99px}.Pet-TigerCub-White{background-image:url(spritesmith-main-9.png);background-position:-1476px -100px;width:81px;height:99px}.Pet-TigerCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1476px -200px;width:81px;height:99px}.Pet-Turkey-Base{background-image:url(spritesmith-main-9.png);background-position:-1476px -300px;width:81px;height:99px}.Pet-Whale-Base{background-image:url(spritesmith-main-9.png);background-position:-1476px -400px;width:81px;height:99px}.Pet-Whale-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1476px -500px;width:81px;height:99px}.Pet-Whale-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1476px -600px;width:81px;height:99px}.Pet-Whale-Desert{background-image:url(spritesmith-main-9.png);background-position:-1476px -700px;width:81px;height:99px}.Pet-Whale-Golden{background-image:url(spritesmith-main-9.png);background-position:-1476px -800px;width:81px;height:99px}.Pet-Whale-Red{background-image:url(spritesmith-main-9.png);background-position:-1476px -900px;width:81px;height:99px}.Pet-Whale-Shade{background-image:url(spritesmith-main-9.png);background-position:-1476px -1000px;width:81px;height:99px}.Pet-Whale-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1476px -1100px;width:81px;height:99px}.Pet-Whale-White{background-image:url(spritesmith-main-9.png);background-position:-1476px -1200px;width:81px;height:99px}.Pet-Whale-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1476px -1300px;width:81px;height:99px}.Pet-Wolf-Base{background-image:url(spritesmith-main-9.png);background-position:0 -1400px;width:81px;height:99px}.Pet-Wolf-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -1400px;width:81px;height:99px}.Pet-Wolf-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -1400px;width:81px;height:99px}.Pet-Wolf-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -1400px;width:81px;height:99px}.Pet-Wolf-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -1400px;width:81px;height:99px}.Pet-Wolf-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -1400px;width:81px;height:99px}.Pet-Wolf-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -1400px;width:81px;height:99px}.Pet-Wolf-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -1400px;width:81px;height:99px}.Pet-Wolf-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px -1400px;width:81px;height:99px}.Pet-Wolf-Veteran{background-image:url(spritesmith-main-9.png);background-position:-738px -1400px;width:81px;height:99px}.Pet-Wolf-White{background-image:url(spritesmith-main-9.png);background-position:-820px -1400px;width:81px;height:99px}.Pet-Wolf-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -1400px;width:81px;height:99px}.Pet_HatchingPotion_Base{background-image:url(spritesmith-main-9.png);background-position:-1033px -1400px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1229px -1400px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1082px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Desert{background-image:url(spritesmith-main-9.png);background-position:-1131px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Golden{background-image:url(spritesmith-main-9.png);background-position:-1180px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Red{background-image:url(spritesmith-main-9.png);background-position:-984px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Shade{background-image:url(spritesmith-main-9.png);background-position:-1278px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1327px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Spooky{background-image:url(spritesmith-main-9.png);background-position:-1376px -1400px;width:48px;height:51px}.Pet_HatchingPotion_White{background-image:url(spritesmith-main-9.png);background-position:-1425px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Zombie{background-image:url(spritesmith-main-9.png);background-position:-1474px -1400px;width:48px;height:51px}.head_special_0,.weapon_special_0{width:105px;height:105px;margin-left:-3px;margin-top:-18px}.broad_armor_special_0,.shield_special_0,.slim_armor_special_0{width:90px;height:90px}.weapon_special_critical{background:url(/common/img/sprites/backer-only/weapon_special_critical.gif) no-repeat;width:90px;height:90px;margin-left:-12px;margin-top:12px}.weapon_special_1{margin-left:-12px}.broad_armor_special_1,.head_special_1,.slim_armor_special_1{width:90px;height:90px}.head_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeHelmet.gif) no-repeat}.head_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalHelmet.gif) no-repeat;margin-top:3px}.broad_armor_special_0,.slim_armor_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeArmor.gif) no-repeat}.broad_armor_special_1,.slim_armor_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalArmor.gif) no-repeat}.shield_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Shield-TormentedSkull.gif) no-repeat}.weapon_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Weapon-DarkSoulsBlade.gif) no-repeat}.Pet-Wolf-Cerberus{width:105px;height:72px;background:url(/common/img/sprites/backer-only/BackerOnly-Pet-CerberusPup.gif) no-repeat}.npc_ian{background:url(/common/img/sprites/npc_ian.gif) no-repeat;width:78px;height:135px}.quest_burnout{background:url(/common/img/sprites/quest_burnout.gif) no-repeat;width:219px;height:249px}.Gems{display:inline-block;margin-right:5px;border-style:none;margin-left:0;margin-top:2px}.inline-gems{vertical-align:middle;margin-left:0;display:inline-block}.customize-menu .locked{background-color:#727272}.achievement{float:left;clear:right;margin-right:10px}.multi-achievement{margin:auto;padding-left:.5em;padding-right:.5em}[class*=Mount_Body_],[class*=Mount_Head_]{margin-top:18px}.Pet_Currency_Gem{margin-top:5px;margin-bottom:5px} \ No newline at end of file diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json new file mode 100644 index 0000000000..14b4a53da1 --- /dev/null +++ b/common/locales/en/api-v3.json @@ -0,0 +1,8 @@ +{ + "missingAuthHeaders": "Missing authentication headers.", + "missingUsernameEmail": "Missing username or email.", + "missingPassword": "Missing password.", + "invalidLoginCredentials": "Incorrect username / email and / or password.", + "invalidCredentials": "User not found with given auth credentials.", + "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your UUID \"<%= userId %>\" for assistance." +} diff --git a/website/src/controllers/api-v3/example.js b/website/src/controllers/api-v3/example.js deleted file mode 100644 index 6ad474a5f2..0000000000 --- a/website/src/controllers/api-v3/example.js +++ /dev/null @@ -1,34 +0,0 @@ -let api = {}; - -/** - * @api {get} /example/:id Request Example information - * @apiVersion 3.0.0 - * @apiName GetExample - * @apiGroup Example - * - * @apiParam {Number} id Examples unique ID. - * - * @apiSuccess {String} firstname Firstname of the Example. - * @apiSuccess {String} lastname Lastname of the Example. - * - * @apiSuccessExample Success-Response: - * HTTP/1.1 200 OK - * { - * "firstname": "John", - * "lastname": "Doe" - * } - * - * @apiUse NotFound - */ -api.exampleRoute = { - method: 'GET', - url: '/example/:id', - middlewares: [], - handler (req, res) { - res.status(200).send({ - status: req.params.id, - }); - }, -}; - -export default api; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js new file mode 100644 index 0000000000..784f602ffd --- /dev/null +++ b/website/src/controllers/api-v3/user.js @@ -0,0 +1,84 @@ +import i18n from '../../../../common/script/i18n'; +// TODO add getUserLanguage as a global middleware? +import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; +import validator from 'validator'; +import { + NotAuthorized, +} from '../../libs/api-v3/errors'; +import passwordUtils from '../../libs/api-v3/password'; +import User from '../../models/user'; + +let api = {}; + +/** + * @api {get} /user/login/local Login a user with email / username and password + * @apiVersion 3.0.0 + * @apiName UserLoginLocal + * @apiGroup User + * + * @apiParam {String} username Username or email of the User. + * @apiParam {String} password The user's password + * + * @apiSuccess {String} _id The user's unique identifier + * @apiSuccess {String} apiToken The user's api token that must be used to authenticate requests. + * + * @apiSuccessExample Success-Response: + * HTTP/1.1 200 OK + * { + * "_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + * "apiToken": "1234567890" + * } + * + * @apiUse NotAuthorized + */ +api.loginLocal = { + method: 'GET', + url: '/user/login/local', + middlewares: [getUserLanguage], + handler (req, res, next) { + req.checkBody({ + username: { + notEmpty: true, + errorMessage: i18n.t('missingUsernameEmail'), + }, + password: { + notEmpty: true, + errorMessage: i18n.t('missingPassword'), + }, + }); + + let validationErrors = req.validationErrors(); + + if (validationErrors) return next(validationErrors); + + req.sanitizeBody('username').trim(); + req.sanitizeBody('password').trim(); + + let login; + let username = req.body.username; + + if (validator.isEmail(username)) { + login = {'auth.local.email': username.toLowerCase()}; // Emails are stored lowercase + } else { + login = {'auth.local.username': username}; + } + + User + .findOne(login, {auth: 1, apiToken: 1}) + .exec() + .then((user) => { + // TODO abstract isnce it's also used in auth middlewares if (user.auth.blocked) return res.json(401, accountSuspended(user._id)); + // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); + let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); + + if (!isValidPassword) return next(new NotAuthorized(i18n.t('invalidLoginCredentials'))); + + res + .status(200) + .json({id: user._id, apiToken: user.apiToken}); + }) + .catch(next); + }, +}; + +export default api; diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 0de7474b6c..7790cf1c58 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -1,30 +1,19 @@ -// Middlewares used to authenticate requests import { NotAuthorized, + BadRequest, } from '../../libs/api-v3/errors'; - +import i18n from '../../../../common/script/i18n'; import { model as User, } from '../../models/user'; -// TODO use i18n -const missingAuthHeaders = 'Missing authentication headers.'; -const userNotFound = 'User not found.'; -const accountSuspended = (user) => { - return `Account has been suspended, please contact leslie@habitica.com - with your UUID ${user._id} for assistance.`; -}; - -// TODO adopt JSDoc syntax? // Authenticate a request through the x-api-user and x-api key header export function authWithHeaders (req, res, next) { let userId = req.header['x-api-user']; let apiToken = req.header['x-api-key']; if (!userId || !apiToken) { - // TODO use i18n? - // TODO use badrequest error? - return next(new NotAuthorized(missingAuthHeaders)); + return next(new BadRequest(i18n.t('missingAuthHeaders'))); } User.findOne({ @@ -33,10 +22,8 @@ export function authWithHeaders (req, res, next) { }) .exec() .then((user) => { - if (!user) return next(new NotAuthorized(userNotFound)); - - // TODO better handling for this case - if (user.blocked) return next(new NotAuthorized(accountSuspended(user))); + if (!user) return next(new NotAuthorized(i18n.t('invalidCredentials'))); + if (user.blocked) return next(new NotAuthorized(i18n.t('accountSuspended', {userId: user._id}))); res.locals.user = user; // TODO use either session/cookie or headers, not both @@ -51,17 +38,17 @@ export function authWithHeaders (req, res, next) { export function authWithSession (req, res, next) { let userId = req.session.userId; - if (!userId) return next(new NotAuthorized(userNotFound)); + if (!userId) return next(new NotAuthorized(i18n.t('invalidCredentials'))); User.findOne({ _id: userId, }) .exec() .then((user) => { - if (!user) return next(new NotAuthorized(userNotFound)); + if (!user) return next(new NotAuthorized(i18n.t('invalidCredentials'))); res.locals.user = user; return next(); }) .catch(next); -} \ No newline at end of file +} diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index dfd19e5493..f8e3e98f09 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -1,5 +1,5 @@ // This module is only used to attach middlewares to the express app - +import expressValidator from 'express-validator'; import analytics from './analytics'; import errorHandler from './errorHandler'; import bodyParser from 'body-parser'; @@ -19,6 +19,7 @@ export default function attachMiddlewares (app) { extended: true, // Uses 'qs' library as old connect middleware })); app.use(bodyParser.json()); + app.use(expressValidator()); // TODO config app.use(analytics); app.use('/api/v3', routes); From de75849c7a810ba23e44cf9cc606c841535d4b72 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 Nov 2015 18:06:37 +0100 Subject: [PATCH 136/976] fix apidoc comment --- website/src/libs/api-v3/errors.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index 89e2bd4fd1..cb93e7b4a2 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -8,8 +8,8 @@ export class CustomError extends Error { } /** - * @apiDefine NotFound - * @apiError NotFound The client is not authorized to make this request. + * @apiDefine NotAuthorized + * @apiError NotAuthorized The client is not authorized to make this request. * * @apiErrorExample Error-Response: * HTTP/1.1 401 Unauthorized From bd980e166b942013329d4d51a1e6addba3cba78c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 Nov 2015 18:16:14 +0100 Subject: [PATCH 137/976] fix action method --- website/src/controllers/api-v3/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 784f602ffd..db5f23a57d 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -32,7 +32,7 @@ let api = {}; * @apiUse NotAuthorized */ api.loginLocal = { - method: 'GET', + method: 'POST', url: '/user/login/local', middlewares: [getUserLanguage], handler (req, res, next) { From a64085828ba2b49b3c84b7f466fdc23919b639f3 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 18 Nov 2015 13:00:58 -0600 Subject: [PATCH 138/976] Correct path to api-v3 integration files in gulp test task. --- tasks/gulp-tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 66f99b2169..c0d18c99b0 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -342,7 +342,7 @@ gulp.task('test:api-v3:unit:watch', () => { gulp.task('test:api-v3:integration', ['test:prepare:server'], (done) => { process.env.API_VERSION = 'v3'; awaitPort(TEST_SERVER_PORT).then(() => { - runMochaTests('./test/api/v3/unit/**/*.js', server, done) + runMochaTests('./test/api/v3/integration/**/*.js', server, done) }); }); From f95c6ef927fca3f89113521d2afbda9a3266b26d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 18 Nov 2015 13:01:35 -0600 Subject: [PATCH 139/976] Move notFound api v3 test to integration folder. --- test/api/v3/integration/notFound.test.js | 13 +++++++++++++ test/api/v3/unit/middlewares/notFound.test.js | 14 -------------- 2 files changed, 13 insertions(+), 14 deletions(-) create mode 100644 test/api/v3/integration/notFound.test.js delete mode 100644 test/api/v3/unit/middlewares/notFound.test.js diff --git a/test/api/v3/integration/notFound.test.js b/test/api/v3/integration/notFound.test.js new file mode 100644 index 0000000000..e86b6b35e8 --- /dev/null +++ b/test/api/v3/integration/notFound.test.js @@ -0,0 +1,13 @@ +import { requester } from '../../../helpers/api-integration.helper'; + +describe('notFound Middleware', () => { + it('returns a 404 error when the resource is not found', () => { + let request = requester().get('/api/v3/dummy-url'); + + return expect(request).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); +}); diff --git a/test/api/v3/unit/middlewares/notFound.test.js b/test/api/v3/unit/middlewares/notFound.test.js deleted file mode 100644 index 44ca295e95..0000000000 --- a/test/api/v3/unit/middlewares/notFound.test.js +++ /dev/null @@ -1,14 +0,0 @@ -import { requester } from '../../../../helpers/api-integration.helper'; - -describe('notFound Middleware', () => { - it('returns a 404 error when the resource is not found', () => { - let request = requester().get('/api/v3/dummy-url'); - - return request.then((errBody) => { - expect(errBody.error).to.equal('NotFound'); - expect(errBody.message).to.equal('Not found.'); - }).to.eventually.be.rejected.and.eql({ - code: 404, - }); - }); -}); From 6991d5ab6771e41b5575d79dbb6d0675b46140a7 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 18 Nov 2015 13:01:56 -0600 Subject: [PATCH 140/976] Correct reject block to include details about error --- test/helpers/api-integration.helper.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index d10a87096c..ed3ffd3125 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -244,8 +244,9 @@ function _requestMaker(user, method, additionalSets) { if (!err.response) return reject(err); return reject({ - code: err.response.status, - text: err.response.body.err, + code: err.status, + error: err.response.body.error, + message: err.response.body.message, }); } From 5c859ca52e00ce64070c3508f045e45d6d95d40b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 Nov 2015 22:04:36 +0100 Subject: [PATCH 141/976] add req.t in place of i18n.t passing req.language, begins implementing user signup --- .../unit/middlewares/getUserLanguage.test.js | 25 +++++++++++ website/src/controllers/api-v3/user.js | 42 ++++++++++++------- .../src/middlewares/api-v3/getUserLanguage.js | 17 ++++++-- website/src/middlewares/api-v3/index.js | 2 + 4 files changed, 66 insertions(+), 20 deletions(-) diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 2cbf1d2c87..e1d28290e6 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -4,6 +4,7 @@ import { generateNext, } from '../../../../helpers/api-unit.helper'; import getUserLanguage from '../../../../../website/src/middlewares/api-v3/getUserLanguage'; +import { i18n } from '../../../../../common'; import Q from 'q'; import { model as User } from '../../../../../website/src/models/user'; import { translations } from '../../../../../website/src/libs/api-v3/i18n'; @@ -12,6 +13,11 @@ import accepts from 'accepts'; describe('getUserLanguage', () => { let res, req, next; + let checkReqT = (req) => { + expect(req.t).to.be.a('function'); + expect(req.t('help')).to.equal(i18n.t('help', req.language)); + }; + beforeEach(() => { res = generateRes(); req = generateReq(); @@ -26,6 +32,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('es'); + checkReqT(req); }); it('falls back to english if the query parameter language does not exists', () => { @@ -35,6 +42,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('en'); + checkReqT(req); }); it('uses query even if the request includes a user and session', () => { @@ -56,6 +64,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('es'); + checkReqT(req); }); }); @@ -71,6 +80,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('it'); + checkReqT(req); }); it('falls back to english if the user preferred language is not avalaible', (done) => { @@ -84,6 +94,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); + checkReqT(req); done(); }); }); @@ -103,6 +114,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('it'); + checkReqT(req); }); }); @@ -124,6 +136,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('it'); + checkReqT(req); done(); }); }); @@ -135,6 +148,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('pt'); + checkReqT(req); done(); }); }); @@ -144,6 +158,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('he'); + checkReqT(req); done(); }); }); @@ -153,6 +168,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('he'); + checkReqT(req); done(); }); }); @@ -162,6 +178,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('fr'); + checkReqT(req); done(); }); }); @@ -171,6 +188,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('fr'); + checkReqT(req); done(); }); }); @@ -180,6 +198,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es'); + checkReqT(req); done(); }); }); @@ -189,6 +208,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es_419'); + checkReqT(req); done(); }); }); @@ -198,6 +218,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es_419'); + checkReqT(req); done(); }); }); @@ -207,6 +228,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('zh_TW'); + checkReqT(req); done(); }); }); @@ -216,6 +238,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); + checkReqT(req); done(); }); }); @@ -225,6 +248,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); + checkReqT(req); done(); }); }); @@ -234,6 +258,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); + checkReqT(req); done(); }); }); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index db5f23a57d..b6b1f1356d 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1,6 +1,3 @@ -import i18n from '../../../../common/script/i18n'; -// TODO add getUserLanguage as a global middleware? -import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; import validator from 'validator'; import { NotAuthorized, @@ -11,39 +8,52 @@ import User from '../../models/user'; let api = {}; /** - * @api {get} /user/login/local Login a user with email / username and password + * @api {post} /user/register/local Register a new user with email, username and password + * @apiVersion 3.0.0 + * @apiName UserRegisterLocal + * @apiGroup User + * + * @apiParam {String} username Username of the new user + * @apiParam {String} email Email address of the new user + * @apiParam {String} password Password for the new user account + * @apiParam {String} passwordConfirmation Password confirmation + * + * @apiSuccess {Object} user The user public fields + * + * + * @apiUse NotAuthorized + */ +api.registerLocal = { + method: 'POST', + url: '/user/register/local', +}; + +/** + * @api {post} /user/login/local Login an user with email / username and password * @apiVersion 3.0.0 * @apiName UserLoginLocal * @apiGroup User * - * @apiParam {String} username Username or email of the User. + * @apiParam {String} username Username or email of the user * @apiParam {String} password The user's password * * @apiSuccess {String} _id The user's unique identifier * @apiSuccess {String} apiToken The user's api token that must be used to authenticate requests. * - * @apiSuccessExample Success-Response: - * HTTP/1.1 200 OK - * { - * "_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - * "apiToken": "1234567890" - * } - * * @apiUse NotAuthorized */ api.loginLocal = { method: 'POST', url: '/user/login/local', - middlewares: [getUserLanguage], handler (req, res, next) { req.checkBody({ username: { notEmpty: true, - errorMessage: i18n.t('missingUsernameEmail'), + errorMessage: req.t('missingUsernameEmail'), }, password: { notEmpty: true, - errorMessage: i18n.t('missingPassword'), + errorMessage: req.t('missingPassword'), }, }); @@ -71,7 +81,7 @@ api.loginLocal = { // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (!isValidPassword) return next(new NotAuthorized(i18n.t('invalidLoginCredentials'))); + if (!isValidPassword) return next(new NotAuthorized(req.t('invalidLoginCredentials'))); res .status(200) diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index 234a78a29c..8e0be1e669 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -1,5 +1,6 @@ import { model as User } from '../../models/user'; import accepts from 'accepts'; +import { i18n } from '../../../../common'; import _ from 'lodash'; import { translations, @@ -55,13 +56,21 @@ function _getFromUser (user, req) { return lang; } +function _attachTranslateFunction (req, next) { + req.t = function reqTranslation () { + return i18n.t(...arguments, req.language); + }; + + next(); +} + export default function getUserLanguage (req, res, next) { if (req.query.lang) { // In case the language is specified in the request url, use it req.language = translations[req.query.lang] ? req.query.lang : 'en'; - return next(); + return _attachTranslateFunction(req, next); } else if (req.locals && req.locals.user) { // If the request is authenticated, use the user's preferred language req.language = _getFromUser(req.locals.user, req); - return next(); + return _attachTranslateFunction(req, next); } else if (req.session && req.session.userId) { // Same thing if the user has a valid session User.findOne({ _id: req.session.userId, @@ -69,11 +78,11 @@ export default function getUserLanguage (req, res, next) { .exec() .then((user) => { req.language = _getFromUser(user, req); - return next(); + return _attachTranslateFunction(req, next); }) .catch(next); } else { // Otherwise get from browser req.language = _getFromUser(null, req); - return next(); + return _attachTranslateFunction(req, next); } } diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index f8e3e98f09..46a546b549 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -1,5 +1,6 @@ // This module is only used to attach middlewares to the express app import expressValidator from 'express-validator'; +import getUserLanguage from './getUserLanguage'; import analytics from './analytics'; import errorHandler from './errorHandler'; import bodyParser from 'body-parser'; @@ -21,6 +22,7 @@ export default function attachMiddlewares (app) { app.use(bodyParser.json()); app.use(expressValidator()); // TODO config app.use(analytics); + app.use(getUserLanguage); app.use('/api/v3', routes); app.use(notFoundHandler); From 6451264572266ff311b30f04322af685c1958a6c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 Nov 2015 12:13:54 +0100 Subject: [PATCH 142/976] start porting register local user handler, several bug fixes --- common/locales/en/api-v3.json | 4 + tasks/gulp-eslint.js | 1 + website/src/controllers/api-v3/user.js | 109 +++++++++++++++++- website/src/libs/api-v3/setupRoutes.js | 2 +- .../src/middlewares/api-v3/getUserLanguage.js | 1 + website/src/models/emailUnsubscription.js | 20 ++-- 6 files changed, 126 insertions(+), 11 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 14b4a53da1..a7f355d2b9 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -2,6 +2,10 @@ "missingAuthHeaders": "Missing authentication headers.", "missingUsernameEmail": "Missing username or email.", "missingPassword": "Missing password.", + "invalidEmail": "Invalid email address.", + "emailTaken": "Email already taken.", + "usernameTaken": "Username already taken.", + "passwordConfirmationMatch": "Password confirmation doesn't match password.", "invalidLoginCredentials": "Incorrect username / email and / or password.", "invalidCredentials": "User not found with given auth credentials.", "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your UUID \"<%= userId %>\" for assistance." diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index f85cb9455a..afd8d24fe4 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -11,6 +11,7 @@ gulp.task('lint:server', () => { .src([ './website/src/**/api-v3/**/*.js', './website/src/models/user.js', + './website/src/models/emailUnsubscription.js', './website/src/server.js' ]) .pipe(eslint()) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index b6b1f1356d..15d7d84740 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -4,7 +4,9 @@ import { } from '../../libs/api-v3/errors'; import passwordUtils from '../../libs/api-v3/password'; import User from '../../models/user'; - +import EmailUnsubscription from '../../models/emailUnsubscription'; +import Q from 'q'; +import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; let api = {}; /** @@ -18,14 +20,115 @@ let api = {}; * @apiParam {String} password Password for the new user account * @apiParam {String} passwordConfirmation Password confirmation * - * @apiSuccess {Object} user The user public fields - * + * @apiSuccess {Object} user The user profile * * @apiUse NotAuthorized */ api.registerLocal = { method: 'POST', url: '/user/register/local', + handler (req, res, next) { + req.checkBody({ + username: { + notEmpty: true, + errorMessage: req.t('missingEmail'), + }, + email: { + notEmpty: true, + isEmail: true, + errorMessage: req.t('invalidEmail'), + }, + password: { + notEmpty: true, + errorMessage: req.t('missingPassword'), + }, + passwordConfirmation: { + notEmpty: true, + equals: { + options: [req.body.password], + }, + errorMessage: req.t('passwordConfirmationMatch'), + }, + }); + + let validationErrors = req.validationErrors(); + + if (validationErrors) return next(validationErrors); + + req.sanitizeBody('username').trim(); + req.sanitizeBody('email').trim(); + req.sanitizeBody('password').trim(); + req.sanitizeBody('passwordConfirmation').trim(); + + let email = req.body.email.toLowerCase(); + let username = req.body.username; + // Get the lowercase version of username to check that we do not have duplicates + // So we can search for it in the database and then reject the choosen username if 1 or more results are found + let lowerCaseUsername = username.toLowerCase(); + + Q.all([ + // Search for duplicates using lowercase version of username + User.findOne({$or: [ + {'auth.local.email': email}, + {'auth.local.lowerCaseUsername': lowerCaseUsername}, + ]}, {'auth.local': 1}) + .exec(), + + // If the request is made by an authenticated Facebook user, find it + // TODO move to a separate route + // TODO automatically merge? + /* User.findOne({ + _id: req.headers['x-api-user'], + apiToken: req.headers['x-api-key'] + }, {auth:1}) + .exec(); */ + ]) + .then((results) => { + if (results[0]) { + if (email === results[0].auth.local.email) return next(new NotAuthorized(req.t('emailTaken'))); + // Check that the lowercase username isn't already used + if (lowerCaseUsername === results[0].auth.local.lowerCaseUsername) return next(new NotAuthorized(req.t('usernameTaken'))); + } + + let salt = passwordUtils.makeSalt(); + let newUser = new User({ + auth: { + local: { + username, + lowerCaseUsername, // Store the lowercase version of the username + email, // Store email as lowercase + salt, + hashed_password: passwordUtils.encrypt(req.body.password, salt), // eslint-disable-line camelcase + }, + }, + preferences: { + language: req.language, + }, + }); + + newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? + + res.analytics.track('register', { + category: 'acquisition', + type: 'local', + gaLabel: 'local', + uuid: newUser._id, + }); + + return newUser.save(); + }) + .then((savedUser) => { + res.status(201).json(savedUser); + + // Clean previous email preferences + EmailUnsubscription + .remove({email: savedUser.auth.local.email}) + .then(() => { + sendTxnEmail(savedUser, 'welcome'); + }); + }) + .catch(next); + }, }; /** diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index 8c346577bc..bdb587d50c 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -14,7 +14,7 @@ fs let controller = require(CONTROLLERS_PATH + fileName); // eslint-disable-line global-require _.each(controller, (action) => { - let {method, url, middlewares, handler} = action; + let {method, url, middlewares = [], handler} = action; method = method.toLowerCase(); router[method](url, ...middlewares, handler); diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index 8e0be1e669..0f9838c1e6 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -57,6 +57,7 @@ function _getFromUser (user, req) { } function _attachTranslateFunction (req, next) { + // TODO attach to res? req.t = function reqTranslation () { return i18n.t(...arguments, req.language); }; diff --git a/website/src/models/emailUnsubscription.js b/website/src/models/emailUnsubscription.js index 144417f3fc..4d0594c0c5 100644 --- a/website/src/models/emailUnsubscription.js +++ b/website/src/models/emailUnsubscription.js @@ -1,14 +1,20 @@ -var mongoose = require("mongoose"); -var shared = require('../../../common'); +import mongoose from 'mongoose'; +import common from '../../../common'; +import validator from 'validator'; // A collection used to store mailing list unsubscription for non registered email addresses -var EmailUnsubscriptionSchema = new mongoose.Schema({ +export let schema = new mongoose.Schema({ _id: { type: String, - 'default': shared.uuid + default: common.uuid, + }, + email: { + type: String, + required: true, + trim: true, + lowercase: true, // TODO migrate existing to lowerCase + validator: [validator.isEmail, 'Invalid email.'], }, - email: String }); -module.exports.schema = EmailUnsubscriptionSchema; -module.exports.model = mongoose.model('EmailUnsubscription', EmailUnsubscriptionSchema); \ No newline at end of file +export let model = mongoose.model('EmailUnsubscription', schema); From dd8c22584db1e6fa78b4edac8c2580e5b199d896 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 Nov 2015 12:17:44 +0100 Subject: [PATCH 143/976] req.t --> res.t --- .../unit/middlewares/getUserLanguage.test.js | 4 ++-- website/src/controllers/api-v3/user.js | 18 +++++++++--------- .../src/middlewares/api-v3/getUserLanguage.js | 13 ++++++------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index e1d28290e6..3f7165c39a 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -14,8 +14,8 @@ describe('getUserLanguage', () => { let res, req, next; let checkReqT = (req) => { - expect(req.t).to.be.a('function'); - expect(req.t('help')).to.equal(i18n.t('help', req.language)); + expect(res.t).to.be.a('function'); + expect(res.t('help')).to.equal(i18n.t('help', req.language)); }; beforeEach(() => { diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 15d7d84740..f87f09209a 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -31,23 +31,23 @@ api.registerLocal = { req.checkBody({ username: { notEmpty: true, - errorMessage: req.t('missingEmail'), + errorMessage: res.t('missingEmail'), }, email: { notEmpty: true, isEmail: true, - errorMessage: req.t('invalidEmail'), + errorMessage: res.t('invalidEmail'), }, password: { notEmpty: true, - errorMessage: req.t('missingPassword'), + errorMessage: res.t('missingPassword'), }, passwordConfirmation: { notEmpty: true, equals: { options: [req.body.password], }, - errorMessage: req.t('passwordConfirmationMatch'), + errorMessage: res.t('passwordConfirmationMatch'), }, }); @@ -85,9 +85,9 @@ api.registerLocal = { ]) .then((results) => { if (results[0]) { - if (email === results[0].auth.local.email) return next(new NotAuthorized(req.t('emailTaken'))); + if (email === results[0].auth.local.email) return next(new NotAuthorized(res.t('emailTaken'))); // Check that the lowercase username isn't already used - if (lowerCaseUsername === results[0].auth.local.lowerCaseUsername) return next(new NotAuthorized(req.t('usernameTaken'))); + if (lowerCaseUsername === results[0].auth.local.lowerCaseUsername) return next(new NotAuthorized(res.t('usernameTaken'))); } let salt = passwordUtils.makeSalt(); @@ -152,11 +152,11 @@ api.loginLocal = { req.checkBody({ username: { notEmpty: true, - errorMessage: req.t('missingUsernameEmail'), + errorMessage: res.t('missingUsernameEmail'), }, password: { notEmpty: true, - errorMessage: req.t('missingPassword'), + errorMessage: res.t('missingPassword'), }, }); @@ -184,7 +184,7 @@ api.loginLocal = { // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (!isValidPassword) return next(new NotAuthorized(req.t('invalidLoginCredentials'))); + if (!isValidPassword) return next(new NotAuthorized(res.t('invalidLoginCredentials'))); res .status(200) diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index 0f9838c1e6..ef957ee03c 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -56,9 +56,8 @@ function _getFromUser (user, req) { return lang; } -function _attachTranslateFunction (req, next) { - // TODO attach to res? - req.t = function reqTranslation () { +function _attachTranslateFunction (req, res, next) { + res.t = function reqTranslation () { return i18n.t(...arguments, req.language); }; @@ -68,10 +67,10 @@ function _attachTranslateFunction (req, next) { export default function getUserLanguage (req, res, next) { if (req.query.lang) { // In case the language is specified in the request url, use it req.language = translations[req.query.lang] ? req.query.lang : 'en'; - return _attachTranslateFunction(req, next); + return _attachTranslateFunction(...arguments); } else if (req.locals && req.locals.user) { // If the request is authenticated, use the user's preferred language req.language = _getFromUser(req.locals.user, req); - return _attachTranslateFunction(req, next); + return _attachTranslateFunction(...arguments); } else if (req.session && req.session.userId) { // Same thing if the user has a valid session User.findOne({ _id: req.session.userId, @@ -79,11 +78,11 @@ export default function getUserLanguage (req, res, next) { .exec() .then((user) => { req.language = _getFromUser(user, req); - return _attachTranslateFunction(req, next); + return _attachTranslateFunction(...arguments); }) .catch(next); } else { // Otherwise get from browser req.language = _getFromUser(null, req); - return _attachTranslateFunction(req, next); + return _attachTranslateFunction(...arguments); } } From 5874e89e2ab5bb958251309043fcd738f27af08c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 Nov 2015 16:22:16 +0100 Subject: [PATCH 144/976] simplify code --- website/src/controllers/api-v3/user.js | 29 +++++++------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index f87f09209a..be70e00f48 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -20,7 +20,7 @@ let api = {}; * @apiParam {String} password Password for the new user account * @apiParam {String} passwordConfirmation Password confirmation * - * @apiSuccess {Object} user The user profile + * @apiSuccess {Object} user The user object * * @apiUse NotAuthorized */ @@ -66,23 +66,12 @@ api.registerLocal = { // So we can search for it in the database and then reject the choosen username if 1 or more results are found let lowerCaseUsername = username.toLowerCase(); - Q.all([ - // Search for duplicates using lowercase version of username - User.findOne({$or: [ - {'auth.local.email': email}, - {'auth.local.lowerCaseUsername': lowerCaseUsername}, - ]}, {'auth.local': 1}) - .exec(), - - // If the request is made by an authenticated Facebook user, find it - // TODO move to a separate route - // TODO automatically merge? - /* User.findOne({ - _id: req.headers['x-api-user'], - apiToken: req.headers['x-api-key'] - }, {auth:1}) - .exec(); */ - ]) + // Search for duplicates using lowercase version of username + User.findOne({$or: [ + {'auth.local.email': email}, + {'auth.local.lowerCaseUsername': lowerCaseUsername}, + ]}, {'auth.local': 1}) + .exec() .then((results) => { if (results[0]) { if (email === results[0].auth.local.email) return next(new NotAuthorized(res.t('emailTaken'))); @@ -186,9 +175,7 @@ api.loginLocal = { if (!isValidPassword) return next(new NotAuthorized(res.t('invalidLoginCredentials'))); - res - .status(200) - .json({id: user._id, apiToken: user.apiToken}); + res.status(200).json({id: user._id, apiToken: user.apiToken}); }) .catch(next); }, From 55d743ebe5df3ec4510740ded67e63623e27a3eb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 Nov 2015 16:29:22 +0100 Subject: [PATCH 145/976] remove q import --- website/src/controllers/api-v3/user.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index be70e00f48..43216c2b23 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -5,7 +5,6 @@ import { import passwordUtils from '../../libs/api-v3/password'; import User from '../../models/user'; import EmailUnsubscription from '../../models/emailUnsubscription'; -import Q from 'q'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; let api = {}; From 972cbbdaa6d79c657296c171c6d3126166426c73 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 Nov 2015 21:09:39 +0100 Subject: [PATCH 146/976] try moving all the logic to the model --- common/locales/en/api-v3.json | 2 + website/src/controllers/api-v3/user.js | 49 +++------------------ website/src/models/user.js | 60 ++++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 46 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a7f355d2b9..adc988617d 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -1,6 +1,8 @@ { "missingAuthHeaders": "Missing authentication headers.", "missingUsernameEmail": "Missing username or email.", + "missingEmail": "Missing email.", + "missingUsername": "Missing username.", "missingPassword": "Missing password.", "invalidEmail": "Invalid email address.", "emailTaken": "Email already taken.", diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 43216c2b23..77359963fa 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -20,45 +20,11 @@ let api = {}; * @apiParam {String} passwordConfirmation Password confirmation * * @apiSuccess {Object} user The user object - * - * @apiUse NotAuthorized */ api.registerLocal = { method: 'POST', url: '/user/register/local', handler (req, res, next) { - req.checkBody({ - username: { - notEmpty: true, - errorMessage: res.t('missingEmail'), - }, - email: { - notEmpty: true, - isEmail: true, - errorMessage: res.t('invalidEmail'), - }, - password: { - notEmpty: true, - errorMessage: res.t('missingPassword'), - }, - passwordConfirmation: { - notEmpty: true, - equals: { - options: [req.body.password], - }, - errorMessage: res.t('passwordConfirmationMatch'), - }, - }); - - let validationErrors = req.validationErrors(); - - if (validationErrors) return next(validationErrors); - - req.sanitizeBody('username').trim(); - req.sanitizeBody('email').trim(); - req.sanitizeBody('password').trim(); - req.sanitizeBody('passwordConfirmation').trim(); - let email = req.body.email.toLowerCase(); let username = req.body.username; // Get the lowercase version of username to check that we do not have duplicates @@ -71,22 +37,22 @@ api.registerLocal = { {'auth.local.lowerCaseUsername': lowerCaseUsername}, ]}, {'auth.local': 1}) .exec() - .then((results) => { - if (results[0]) { - if (email === results[0].auth.local.email) return next(new NotAuthorized(res.t('emailTaken'))); + .then((user) => { + if (user) { + if (email === user.auth.local.email) return next(new NotAuthorized(res.t('emailTaken'))); // Check that the lowercase username isn't already used - if (lowerCaseUsername === results[0].auth.local.lowerCaseUsername) return next(new NotAuthorized(res.t('usernameTaken'))); + if (lowerCaseUsername === user.auth.local.lowerCaseUsername) return next(new NotAuthorized(res.t('usernameTaken'))); } - let salt = passwordUtils.makeSalt(); let newUser = new User({ auth: { local: { username, lowerCaseUsername, // Store the lowercase version of the username email, // Store email as lowercase - salt, - hashed_password: passwordUtils.encrypt(req.body.password, salt), // eslint-disable-line camelcase + salt: passwordUtils.makeSalt(), + password: req.body.password, + passwordConfirmation: req.body.passwordConfirmation, }, }, preferences: { @@ -131,7 +97,6 @@ api.registerLocal = { * @apiSuccess {String} _id The user's unique identifier * @apiSuccess {String} apiToken The user's api token that must be used to authenticate requests. * - * @apiUse NotAuthorized */ api.loginLocal = { method: 'POST', diff --git a/website/src/models/user.js b/website/src/models/user.js index 5c294388d4..aff9fb0483 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -1,7 +1,9 @@ // User schema and model import mongoose from 'mongoose'; import shared from '../../../common'; +import passwordUtils from '../libs/api-v3/password'; import _ from 'lodash'; +import validator from 'validator'; import moment from 'moment'; import TaskSchemas from './task'; // import {model as Challenge} from './challenge'; @@ -26,12 +28,29 @@ export let schema = new Schema({ blocked: Boolean, facebook: Schema.Types.Mixed, // TODO validate local: { - email: String, - hashed_password: String, // eslint-disable-line camelcase - salt: String, - username: String, + email: { + type: String, + trim: true, + lowercase: true, + validate: [validator.isEmail, shared.i18n.t('invalidEmail')], // TODO translate error messages here, use preferences.language? + }, + username: { + type: String, + trim: true, + }, // Store a lowercase version of username to check for duplicates lowerCaseUsername: String, + hashed_password: String, // eslint-disable-line camelcase + salt: String, + // password and passwordConfirmation are not stored in the database, used only for validation + password: { + type: String, + trim: true, + }, + passwordConfirmation: { + type: String, + trim: true, + }, }, timestamps: { created: {type: Date, default: Date.now}, @@ -208,6 +227,7 @@ export let schema = new Schema({ party: Schema.Types.Mixed, // TODO dictionary }, + // TODO we're storing too many fields here, find a way to reduce them items: { gear: { owned: _.transform(shared.content.gear.flat, (m, v) => { @@ -328,6 +348,7 @@ export let schema = new Schema({ orderAscending: {type: String, default: 'ascending'}, quest: { key: String, + // TODO why are we storing quest progress here too and not only on party object? progress: { up: {type: Number, default: 0}, down: {type: Number, default: 0}, @@ -589,6 +610,37 @@ function _setProfileName (user) { } schema.pre('save', function postSaveUser (next) { + // Validate the auth path (doesn't work with schema.path('auth').validate) + if (!this.auth.facebook.id) { + if (!this.auth.local.email) { + this.invalidate('auth.local.email', shared.i18n.t('missingEmail')); + return next(); + } + + if (!this.auth.local.email) { + this.invalidate('auth.local.username', shared.i18n.t('missingUsername')); + return next(); + } + } + + // Validate password and password confirmation and create hashed version + if (this.isModified('auth.local.password') || this.isNew() && !this.auth.facebook.id) { + if (!this.auth.local.password) { + this.invalidate('auth.local.password', shared.i18n.t('missingPassword')); + return next(); + } + + if (this.auth.local.password !== this.auth.local.passwordConfirmation) { + this.invalidate('auth.local.passwordConfirmation', shared.i18n.t('passwordConfirmationMatch')); + return next(); + } + + this.hashed_password = passwordUtils.encrypt(this.auth.local.password, this.auth.local.salt); // eslint-disable-line camelcase + } + + // Do not store password and passwordConfirmation + this.auth.local.password = this.local.auth.passwordConfirmation = undefined; + // Populate new users with default content if (this.isNew) { _populateDefaultsForNewUser(this); From e028af9f3fa1b25bb526235f5b233b94d94ff4e1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 Nov 2015 21:50:23 +0100 Subject: [PATCH 147/976] add social login / signup and misc fixes --- website/src/controllers/api-v3/user.js | 96 ++++++++++++++++++++++---- website/src/middlewares/api-v3/auth.js | 4 +- 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 77359963fa..31fe7b1061 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1,4 +1,5 @@ import validator from 'validator'; +import passport from 'passport'; import { NotAuthorized, } from '../../libs/api-v3/errors'; @@ -6,6 +7,7 @@ import passwordUtils from '../../libs/api-v3/password'; import User from '../../models/user'; import EmailUnsubscription from '../../models/emailUnsubscription'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; + let api = {}; /** @@ -62,13 +64,6 @@ api.registerLocal = { newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? - res.analytics.track('register', { - category: 'acquisition', - type: 'local', - gaLabel: 'local', - uuid: newUser._id, - }); - return newUser.save(); }) .then((savedUser) => { @@ -77,8 +72,13 @@ api.registerLocal = { // Clean previous email preferences EmailUnsubscription .remove({email: savedUser.auth.local.email}) - .then(() => { - sendTxnEmail(savedUser, 'welcome'); + .then(() => sendTxnEmail(savedUser, 'welcome')); + + res.analytics.track('register', { + category: 'acquisition', + type: 'local', + gaLabel: 'local', + uuid: savedUser._id, }); }) .catch(next); @@ -96,7 +96,6 @@ api.registerLocal = { * * @apiSuccess {String} _id The user's unique identifier * @apiSuccess {String} apiToken The user's api token that must be used to authenticate requests. - * */ api.loginLocal = { method: 'POST', @@ -130,19 +129,88 @@ api.loginLocal = { } User - .findOne(login, {auth: 1, apiToken: 1}) - .exec() + .findOne(login, {auth: 1, apiToken: 1}).exec() .then((user) => { - // TODO abstract isnce it's also used in auth middlewares if (user.auth.blocked) return res.json(401, accountSuspended(user._id)); // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (user.auth.blocked) return next(new NotAuthorized(res.t('accountSuspended', {userId: user._id}))); if (!isValidPassword) return next(new NotAuthorized(res.t('invalidLoginCredentials'))); - res.status(200).json({id: user._id, apiToken: user.apiToken}); }) .catch(next); }, }; +// Called as a callback by Facebook (or other social providers) +api.loginSocial = { + method: 'POST', + url: '/user/aurh/social', + handler (req, res, next) { + let accessToken = req.body.authResponse.access_token; + let network = req.body.network; + + if (network !== 'facebook') return next(new NotAuthorized('Only Facebook supported currently.')); + + passport._strategies[network].userProfile(accessToken, (err, profile) => { + if (err) return next(err); + + function _respond (user) { + if (user.auth.blocked) return next(new NotAuthorized(res.t('accountSuspended', {userId: user._id}))); + return res.status(200).json({_id: user._id, apiToken: user.apiToken}); + } + + User.findOne({ + [`auth.${network}.id`]: profile.id, + }, {_id: 1, apiToken: 1, auth: 1}).exec() + .then((user) => { + // User already signed up + if (user) { + return _respond(user); + } else { // Create new user + user = new User({ + auth: { + [network]: profile, + }, + preferences: { + language: req.language, + }, + }); + user.registeredThrough = req.headers['x-client']; + + user.save() + .then((savedUser) => { + _respond(savedUser); + + // Clean previous email preferences + if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) { + EmailUnsubscription + .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()}) + .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks + } + + res.analytics.track('register', { + category: 'acquisition', + type: network, + gaLabel: network, + uuid: savedUser._id, + }); + }) + .catch(next); + } + }) + .catch(next); + }); + }, +}; + +api.attachSocial = { + +}; + +api.deleteSocial = { + +}; + + export default api; diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 7790cf1c58..4061a60f92 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -13,7 +13,7 @@ export function authWithHeaders (req, res, next) { let apiToken = req.header['x-api-key']; if (!userId || !apiToken) { - return next(new BadRequest(i18n.t('missingAuthHeaders'))); + return next(new BadRequest(res.t('missingAuthHeaders'))); } User.findOne({ @@ -23,7 +23,7 @@ export function authWithHeaders (req, res, next) { .exec() .then((user) => { if (!user) return next(new NotAuthorized(i18n.t('invalidCredentials'))); - if (user.blocked) return next(new NotAuthorized(i18n.t('accountSuspended', {userId: user._id}))); + if (user.auth.blocked) return next(new NotAuthorized(i18n.t('accountSuspended', {userId: user._id}))); res.locals.user = user; // TODO use either session/cookie or headers, not both From c87200f582caca097a35aa769bcf4422bd3181f7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 Nov 2015 22:04:18 +0100 Subject: [PATCH 148/976] comment out incomplete code --- website/src/controllers/api-v3/user.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 31fe7b1061..b3c7d22e98 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -145,7 +145,7 @@ api.loginLocal = { // Called as a callback by Facebook (or other social providers) api.loginSocial = { method: 'POST', - url: '/user/aurh/social', + url: '/user/auth/social', handler (req, res, next) { let accessToken = req.body.authResponse.access_token; let network = req.body.network; @@ -204,13 +204,13 @@ api.loginSocial = { }, }; -api.attachSocial = { +/* api.attachSocial = { }; api.deleteSocial = { -}; +};*/ export default api; From 2fa2e0f483374229277fa2902bc6cb7a9126539d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 20 Nov 2015 19:02:08 -0600 Subject: [PATCH 149/976] fix(api tests): Let api test script pass the correct api version for helper. --- tasks/gulp-tests.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index c0d18c99b0..9fb029366a 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -36,8 +36,8 @@ let testCount = (stdout, regexp) => { return parseInt(match && match[1] || 0); } -let testBin = (string) => { - return `NODE_ENV=testing ./node_modules/.bin/${string}`; +let testBin = (string, additionalEnvVariables = '') => { + return `NODE_ENV=testing ${additionalEnvVariables} ./node_modules/.bin/${string}`; }; gulp.task('test:prepare:mongo', (cb) => { @@ -354,7 +354,7 @@ gulp.task('test:api-v3:integration:watch', ['test:prepare:server'], () => { gulp.task('test:api-v3:safe', ['test:prepare:server'], (done) => { awaitPort(TEST_SERVER_PORT).then(() => { let runner = exec( - testBin(API_V3_TEST_COMMAND), + testBin(API_V3_TEST_COMMAND, 'API_VERSION=v3'), (err, stdout, stderr) => { testResults.push({ suite: 'API V3 Specs\t', From e4827f1b78fe34aa1ba101ed9e8963b1773d637c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 20 Nov 2015 19:08:25 -0600 Subject: [PATCH 150/976] fix(user ctrl): Correct path to use model import --- website/src/controllers/api-v3/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index b3c7d22e98..bde5aafe2d 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -4,7 +4,7 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import passwordUtils from '../../libs/api-v3/password'; -import User from '../../models/user'; +import { model as User } from '../../models/user'; import EmailUnsubscription from '../../models/emailUnsubscription'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; From 07247033b27c5da7a93196eb5fe266f3a05f6cbe Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 20 Nov 2015 19:14:07 -0600 Subject: [PATCH 151/976] test: Add integration test for POST user/register/local --- .../user/auth/POST-register_local.test.js | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 test/api/v3/integration/user/auth/POST-register_local.test.js diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js new file mode 100644 index 0000000000..a8fa55f095 --- /dev/null +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -0,0 +1,249 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateRandomUserName } from 'uuid'; +import { each } from 'lodash'; + +describe.skip('POST /user/register/local', () => { + context('username and email are free', () => { + it('registers a new user', () => { + let api = requester(); + let username = generateRandomUserName(); + let email = `${username}@example.com`; + let password = 'password'; + + return api.post('/user/register/local', { + username: username, + email: email, + password: password, + confirmPassword: password, + }).then((user) => { + expect(user._id).to.exist; + expect(user.apiToken).to.exist; + expect(user.auth.local.username).to.eql(username); + }); + }); + + it('requires password and confirmPassword to match', () => { + let api = requester(); + let username = generateRandomUserName(); + let email = `${username}@example.com`; + let password = 'password'; + let confirmPassword = 'not password'; + + return expect(api.post('/user/register/local', { + username: username, + email: email, + password: password, + confirmPassword: confirmPassword, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('passwordConfirmationMatch'), + }); + }); + + it('requires a username', () => { + let api = requester(); + let email = `${generateRandomUserName()}@example.com`; + let password = 'password'; + let confirmPassword = 'password'; + + return expect(api.post('/user/register/local', { + email: email, + password: password, + confirmPassword: confirmPassword, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingUsernameEmail'), + }); + }); + + it('requires an email', () => { + let api = requester(); + let username = generateRandomUserName(); + let password = 'password'; + let confirmPassword = 'password'; + + return expect(api.post('/user/register/local', { + username: username, + password: password, + confirmPassword: confirmPassword, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingUsernameEmail'), + }); + }); + + it('requires a password', () => { + let api = requester(); + let username = generateRandomUserName(); + let email = `${username}@example.com`; + let confirmPassword = 'password'; + + return expect(api.post('/user/register/local', { + username: username, + email: email, + confirmPassword: confirmPassword, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingPassword'), + }); + }); + }); + + context('login is already taken', () => { + let username, email; + + beforeEach(() => { + username = generateRandomUserName(); + email = `${username}@example.com`; + return generateUser({ + 'auth.local.username': username, + 'auth.local.lowerCaseUsername': username, + 'auth.local.email': email + }); + }); + + it('rejects if username is already taken', () => { + let api = requester(); + let uniqueEmail = `${generateRandomUserName()}@exampe.com`; + let password = 'password'; + + return expect(api.post('/user/register/local', { + username: username, + email: uniqueEmail, + password: password, + confirmPassword: password, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('usernameTake'), + }); + }); + + it('rejects if email is already taken', () => { + let api = requester(); + let uniqueUsername = generateRandomUserName(); + let password = 'password'; + + return expect(api.post('/user/register/local', { + username: uniqueUsername, + email: email, + password: password, + confirmPassword: password, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('emailTaken'), + }); + }); + }); + + context('successful login via api', () => { + let api, username, email, password; + + beforeEach(() => { + api = requester(); + username = generateRandomUserName(); + email = `${username}@example.com`; + password = 'password'; + }); + + it('sets all site tour values to -2 (already seen)', () => { + return api.post('/user/register/local', { + username: username, + email: email, + password: password, + confirmPassword: password, + }).then((user) => { + expect(user.flags.tour).to.not.be.empty; + + each(user.flags.tour, (value, attribute) => { + expect(value).to.eql(-2); + }); + }); + }); + + it('populates user with default todos, not no other task types', () => { + return api.post('/user/register/local', { + username: username, + email: email, + password: password, + confirmPassword: password, + }).then((user) => { + expect(user.todos).to.not.be.empty; + expect(user.dailys).to.be.empty; + expect(user.habits).to.be.empty; + expect(user.rewards).to.be.empty; + }); + }); + + it('populates user with default tags', () => { + return api.post('/user/register/local', { + username: username, + email: email, + password: password, + confirmPassword: password, + }).then((user) => { + expect(user.tags).to.not.be.empty; + }); + }); + }); + + context('successful login with habitica-web header', () => { + let api, username, email, password; + + beforeEach(() => { + api = requester({}, {'x-client': 'habitica-web'}); + username = generateRandomUserName(); + email = `${username}@example.com`; + password = 'password'; + }); + + it('sets all common tutorial flags to true', () => { + return api.post('/user/register/local', { + username: username, + email: email, + password: password, + confirmPassword: password, + }).then((user) => { + expect(user.flags.tour).to.not.be.empty; + + each(user.flags.tutorial.common, (value, attribute) => { + expect(value).to.eql(true); + }); + }); + }); + + it('populates user with default todos, habits, and rewards', () => { + return api.post('/user/register/local', { + username: username, + email: email, + password: password, + confirmPassword: password, + }).then((user) => { + expect(user.todos).to.not.be.empty; + expect(user.dailys).to.be.empty; + expect(user.habits).to.not.be.empty; + expect(user.rewards).to.not.be.empty; + }); + }); + + it('populates user with default tags', () => { + return api.post('/user/register/local', { + username: username, + email: email, + password: password, + confirmPassword: password, + }).then((user) => { + expect(user.tags).to.not.be.empty; + }); + }); + }); +}); From 7866f7393bf3821b8a3acfd5db9059045d014f64 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 Nov 2015 12:03:08 +0100 Subject: [PATCH 152/976] clean up code, add deleteSocial method --- common/locales/en/api-v3.json | 4 +- .../unit/middlewares/getUserLanguage.test.js | 40 ++++++------- website/src/controllers/api-v3/user.js | 57 ++++++++++++------- 3 files changed, 60 insertions(+), 41 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index adc988617d..edd383f9b9 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -10,5 +10,7 @@ "passwordConfirmationMatch": "Password confirmation doesn't match password.", "invalidLoginCredentials": "Incorrect username / email and / or password.", "invalidCredentials": "User not found with given auth credentials.", - "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your UUID \"<%= userId %>\" for assistance." + "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your UUID \"<%= userId %>\" for assistance.", + "onlyFbSupported": "Only Facebook supported currently.", + "cantDetachFb": "Account lacks another authentication method, can't detach Facebook." } diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 3f7165c39a..1ee185915b 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -13,7 +13,7 @@ import accepts from 'accepts'; describe('getUserLanguage', () => { let res, req, next; - let checkReqT = (req) => { + let checkResT = (req) => { expect(res.t).to.be.a('function'); expect(res.t('help')).to.equal(i18n.t('help', req.language)); }; @@ -32,7 +32,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('es'); - checkReqT(req); + checkResT(req); }); it('falls back to english if the query parameter language does not exists', () => { @@ -42,7 +42,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('en'); - checkReqT(req); + checkResT(req); }); it('uses query even if the request includes a user and session', () => { @@ -64,7 +64,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('es'); - checkReqT(req); + checkResT(req); }); }); @@ -80,7 +80,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('it'); - checkReqT(req); + checkResT(req); }); it('falls back to english if the user preferred language is not avalaible', (done) => { @@ -94,7 +94,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -114,7 +114,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('it'); - checkReqT(req); + checkResT(req); }); }); @@ -136,7 +136,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('it'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -148,7 +148,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('pt'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -158,7 +158,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('he'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -168,7 +168,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('he'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -178,7 +178,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('fr'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -188,7 +188,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('fr'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -198,7 +198,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -208,7 +208,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es_419'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -218,7 +218,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es_419'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -228,7 +228,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('zh_TW'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -238,7 +238,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -248,7 +248,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkReqT(req); + checkResT(req); done(); }); }); @@ -258,7 +258,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkReqT(req); + checkResT(req); done(); }); }); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index b3c7d22e98..3cda3fd66a 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1,5 +1,6 @@ import validator from 'validator'; import passport from 'passport'; +import { authWithHeaders } from '../../middlewares/api-v3/auth'; import { NotAuthorized, } from '../../libs/api-v3/errors'; @@ -11,7 +12,7 @@ import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; let api = {}; /** - * @api {post} /user/register/local Register a new user with email, username and password + * @api {post} /user/auth/local/register Register a new user with email, username and password or add local authentication to a social user * @apiVersion 3.0.0 * @apiName UserRegisterLocal * @apiGroup User @@ -25,7 +26,7 @@ let api = {}; */ api.registerLocal = { method: 'POST', - url: '/user/register/local', + url: '/user/auth/local/register', handler (req, res, next) { let email = req.body.email.toLowerCase(); let username = req.body.username; @@ -85,8 +86,13 @@ api.registerLocal = { }, }; +function _loginRes (user, req, res, next) { + if (user.auth.blocked) return next(new NotAuthorized(res.t('accountSuspended', {userId: user._id}))); + res.status(200).json({id: user._id, apiToken: user.apiToken}); +} + /** - * @api {post} /user/login/local Login an user with email / username and password + * @api {post} /user/auth/local/login Login an user with email / username and password * @apiVersion 3.0.0 * @apiName UserLoginLocal * @apiGroup User @@ -99,7 +105,7 @@ api.registerLocal = { */ api.loginLocal = { method: 'POST', - url: '/user/login/local', + url: '/user/auth/local/login', handler (req, res, next) { req.checkBody({ username: { @@ -134,9 +140,8 @@ api.loginLocal = { // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (user.auth.blocked) return next(new NotAuthorized(res.t('accountSuspended', {userId: user._id}))); if (!isValidPassword) return next(new NotAuthorized(res.t('invalidLoginCredentials'))); - res.status(200).json({id: user._id, apiToken: user.apiToken}); + _loginRes(user, ...arguments); }) .catch(next); }, @@ -145,28 +150,23 @@ api.loginLocal = { // Called as a callback by Facebook (or other social providers) api.loginSocial = { method: 'POST', - url: '/user/auth/social', + url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 handler (req, res, next) { let accessToken = req.body.authResponse.access_token; let network = req.body.network; - if (network !== 'facebook') return next(new NotAuthorized('Only Facebook supported currently.')); + if (network !== 'facebook') return next(new NotAuthorized(res.t('onlyFbSupported'))); passport._strategies[network].userProfile(accessToken, (err, profile) => { if (err) return next(err); - function _respond (user) { - if (user.auth.blocked) return next(new NotAuthorized(res.t('accountSuspended', {userId: user._id}))); - return res.status(200).json({_id: user._id, apiToken: user.apiToken}); - } - User.findOne({ [`auth.${network}.id`]: profile.id, }, {_id: 1, apiToken: 1, auth: 1}).exec() .then((user) => { // User already signed up if (user) { - return _respond(user); + return _loginRes(user, ...arguments); } else { // Create new user user = new User({ auth: { @@ -180,7 +180,7 @@ api.loginSocial = { user.save() .then((savedUser) => { - _respond(savedUser); + _loginRes(user, ...arguments); // Clean previous email preferences if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) { @@ -204,13 +204,30 @@ api.loginSocial = { }, }; -/* api.attachSocial = { - -}; - +/** + * @api {delete} /user/auth/social/:network Delete a social authentication method (only facebook supported) + * @apiVersion 3.0.0 + * @apiName UserDeleteSocial + * @apiGroup User + * + * @apiSuccess {Boolean=true} success Always true + */ api.deleteSocial = { + method: 'DELETE', + url: '/user/auth/social/:network', + middlewares: [authWithHeaders], + handler (req, res, next) { + let user = res.locals.user; + let network = req.params.network; -};*/ + if (network !== 'facebook') return next(new NotAuthorized(res.t('onlyFbSupported'))); + if (!user.auth.local.username) return next(new NotAuthorized(res.t('cantDetachFb'))); // TODO move to model validation? + + User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}) + .then(() => res.status(200).json({ok: true})) // TODO standardize this type of response + .catch(next); + }, +}; export default api; From f87d6d250a85b626c3ed2efa6653df3b2cfeb810 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 Nov 2015 12:11:53 +0100 Subject: [PATCH 153/976] remove unlinted file --- tasks/gulp-eslint.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 29d1f36ab8..c13f27789c 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -4,7 +4,6 @@ import eslint from 'gulp-eslint'; const SERVER_FILES = [ './website/src/**/api-v3/**/*.js', './website/src/models/user.js', - './website/src/models/emailUnsubscription.js', './website/src/server.js', ]; const COMMON_FILES = [ From 22464f53e99b96b8b94bc8161122ade78899048e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 Nov 2015 12:12:48 +0100 Subject: [PATCH 154/976] put back linting on emailUnsubscription model --- tasks/gulp-eslint.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index c13f27789c..29d1f36ab8 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -4,6 +4,7 @@ import eslint from 'gulp-eslint'; const SERVER_FILES = [ './website/src/**/api-v3/**/*.js', './website/src/models/user.js', + './website/src/models/emailUnsubscription.js', './website/src/server.js', ]; const COMMON_FILES = [ From 3608742e20c8c75de65910e3b3e3db1adeddfd12 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 Nov 2015 12:26:48 +0100 Subject: [PATCH 155/976] fix bugs on user controller --- .../user/auth/POST-register_local.test.js | 2 +- website/src/controllers/api-v3/user.js | 43 ++++++++++--------- website/src/models/user.js | 8 +++- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index a8fa55f095..4be22ed0ec 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -6,7 +6,7 @@ import { import { v4 as generateRandomUserName } from 'uuid'; import { each } from 'lodash'; -describe.skip('POST /user/register/local', () => { +describe('POST /user/register/local', () => { context('username and email are free', () => { it('registers a new user', () => { let api = requester(); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 0d347b17fb..8e7cbb4c62 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -28,11 +28,32 @@ api.registerLocal = { method: 'POST', url: '/user/auth/local/register', handler (req, res, next) { - let email = req.body.email.toLowerCase(); + let email = req.body.email && req.body.email.toLowerCase(); let username = req.body.username; // Get the lowercase version of username to check that we do not have duplicates // So we can search for it in the database and then reject the choosen username if 1 or more results are found - let lowerCaseUsername = username.toLowerCase(); + let lowerCaseUsername = username && username.toLowerCase(); + + let newUser = new User({ + auth: { + local: { + username, + lowerCaseUsername, // Store the lowercase version of the username + email, // Store email as lowercase + salt: passwordUtils.makeSalt(), + password: req.body.password, + passwordConfirmation: req.body.passwordConfirmation, + }, + }, + preferences: { + language: req.language, + }, + }); + + newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? + let validationErrors = newUser.validateSync(); // Validate synchronously for speed, remove if we add any async validator + + if (validationErrors) return next(validationErrors); // Search for duplicates using lowercase version of username User.findOne({$or: [ @@ -47,24 +68,6 @@ api.registerLocal = { if (lowerCaseUsername === user.auth.local.lowerCaseUsername) return next(new NotAuthorized(res.t('usernameTaken'))); } - let newUser = new User({ - auth: { - local: { - username, - lowerCaseUsername, // Store the lowercase version of the username - email, // Store email as lowercase - salt: passwordUtils.makeSalt(), - password: req.body.password, - passwordConfirmation: req.body.passwordConfirmation, - }, - }, - preferences: { - language: req.language, - }, - }); - - newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? - return newUser.save(); }) .then((savedUser) => { diff --git a/website/src/models/user.js b/website/src/models/user.js index aff9fb0483..f4810bae01 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -609,7 +609,7 @@ function _setProfileName (user) { return localUsername || facebookUsername || anonymous; } -schema.pre('save', function postSaveUser (next) { +schema.pre('validate', function beforeValidateUser (next) { // Validate the auth path (doesn't work with schema.path('auth').validate) if (!this.auth.facebook.id) { if (!this.auth.local.email) { @@ -617,7 +617,7 @@ schema.pre('save', function postSaveUser (next) { return next(); } - if (!this.auth.local.email) { + if (!this.auth.local.username) { this.invalidate('auth.local.username', shared.i18n.t('missingUsername')); return next(); } @@ -638,6 +638,10 @@ schema.pre('save', function postSaveUser (next) { this.hashed_password = passwordUtils.encrypt(this.auth.local.password, this.auth.local.salt); // eslint-disable-line camelcase } + next(); +}); + +schema.pre('save', function postSaveUser (next) { // Do not store password and passwordConfirmation this.auth.local.password = this.local.auth.passwordConfirmation = undefined; From afbfbdd01c53e66b9552ce7c66b3cb044407d42b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 Nov 2015 14:27:24 +0100 Subject: [PATCH 156/976] fix mongoose promise, some user validation, tests urls --- .../user/auth/POST-register_local.test.js | 28 +++++++++---------- website/src/models/user.js | 7 ++--- website/src/server.js | 2 +- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 4be22ed0ec..d5bd25a970 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -6,7 +6,7 @@ import { import { v4 as generateRandomUserName } from 'uuid'; import { each } from 'lodash'; -describe('POST /user/register/local', () => { +describe('POST /user/auth/local/register', () => { context('username and email are free', () => { it('registers a new user', () => { let api = requester(); @@ -14,7 +14,7 @@ describe('POST /user/register/local', () => { let email = `${username}@example.com`; let password = 'password'; - return api.post('/user/register/local', { + return api.post('/user/auth/local/register', { username: username, email: email, password: password, @@ -33,7 +33,7 @@ describe('POST /user/register/local', () => { let password = 'password'; let confirmPassword = 'not password'; - return expect(api.post('/user/register/local', { + return expect(api.post('/user/auth/local/register', { username: username, email: email, password: password, @@ -51,7 +51,7 @@ describe('POST /user/register/local', () => { let password = 'password'; let confirmPassword = 'password'; - return expect(api.post('/user/register/local', { + return expect(api.post('/user/auth/local/register', { email: email, password: password, confirmPassword: confirmPassword, @@ -68,7 +68,7 @@ describe('POST /user/register/local', () => { let password = 'password'; let confirmPassword = 'password'; - return expect(api.post('/user/register/local', { + return expect(api.post('/user/auth/local/register', { username: username, password: password, confirmPassword: confirmPassword, @@ -85,7 +85,7 @@ describe('POST /user/register/local', () => { let email = `${username}@example.com`; let confirmPassword = 'password'; - return expect(api.post('/user/register/local', { + return expect(api.post('/user/auth/local/register', { username: username, email: email, confirmPassword: confirmPassword, @@ -115,7 +115,7 @@ describe('POST /user/register/local', () => { let uniqueEmail = `${generateRandomUserName()}@exampe.com`; let password = 'password'; - return expect(api.post('/user/register/local', { + return expect(api.post('/user/auth/local/register', { username: username, email: uniqueEmail, password: password, @@ -132,7 +132,7 @@ describe('POST /user/register/local', () => { let uniqueUsername = generateRandomUserName(); let password = 'password'; - return expect(api.post('/user/register/local', { + return expect(api.post('/user/auth/local/register', { username: uniqueUsername, email: email, password: password, @@ -156,7 +156,7 @@ describe('POST /user/register/local', () => { }); it('sets all site tour values to -2 (already seen)', () => { - return api.post('/user/register/local', { + return api.post('/user/auth/local/register', { username: username, email: email, password: password, @@ -171,7 +171,7 @@ describe('POST /user/register/local', () => { }); it('populates user with default todos, not no other task types', () => { - return api.post('/user/register/local', { + return api.post('/user/auth/local/register', { username: username, email: email, password: password, @@ -185,7 +185,7 @@ describe('POST /user/register/local', () => { }); it('populates user with default tags', () => { - return api.post('/user/register/local', { + return api.post('/user/auth/local/register', { username: username, email: email, password: password, @@ -207,7 +207,7 @@ describe('POST /user/register/local', () => { }); it('sets all common tutorial flags to true', () => { - return api.post('/user/register/local', { + return api.post('/user/auth/local/register', { username: username, email: email, password: password, @@ -222,7 +222,7 @@ describe('POST /user/register/local', () => { }); it('populates user with default todos, habits, and rewards', () => { - return api.post('/user/register/local', { + return api.post('/user/auth/local/register', { username: username, email: email, password: password, @@ -236,7 +236,7 @@ describe('POST /user/register/local', () => { }); it('populates user with default tags', () => { - return api.post('/user/register/local', { + return api.post('/user/auth/local/register', { username: username, email: email, password: password, diff --git a/website/src/models/user.js b/website/src/models/user.js index f4810bae01..2d001a3ca6 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -26,7 +26,7 @@ export let schema = new Schema({ auth: { blocked: Boolean, - facebook: Schema.Types.Mixed, // TODO validate + facebook: {type: Schema.Types.Mixed, default: {}}, // TODO validate, IMPORTANT make sure the {} default isn't shared across all user objects local: { email: { type: String, @@ -610,8 +610,7 @@ function _setProfileName (user) { } schema.pre('validate', function beforeValidateUser (next) { - // Validate the auth path (doesn't work with schema.path('auth').validate) - if (!this.auth.facebook.id) { + if (!this.auth.facebook.id || this.auth.local.email || this.auth.local.username) { if (!this.auth.local.email) { this.invalidate('auth.local.email', shared.i18n.t('missingEmail')); return next(); @@ -624,7 +623,7 @@ schema.pre('validate', function beforeValidateUser (next) { } // Validate password and password confirmation and create hashed version - if (this.isModified('auth.local.password') || this.isNew() && !this.auth.facebook.id) { + if (this.isModified('auth.local.password') || this.isNew() && !this.auth.facebook.id) { // TODO this does not catch when you already have social auth and password isn't passedß if (!this.auth.local.password) { this.invalidate('auth.local.password', shared.i18n.t('missingPassword')); return next(); diff --git a/website/src/server.js b/website/src/server.js index aa0013d89e..d5b9176472 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -30,7 +30,7 @@ let app = express(); // Mongoose configuration // Use Q promises instead of mpromise in mongoose -mongoose.Promise = Q; +mongoose.Promise = Q.Promise; let mongooseOptions = !IS_PROD ? {} : { replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, From 5c33a404fba1cb9ac5b69c08a2ffe8e5d38a99be Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 Nov 2015 15:00:31 +0100 Subject: [PATCH 157/976] handle mongoose validation errors, fix bug in import and add more tests for errors --- .../v3/unit/middlewares/errorHandler.test.js | 59 +++++++++++++++++++ website/src/controllers/api-v3/user.js | 2 +- .../src/middlewares/api-v3/errorHandler.js | 28 +++++++-- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index afc506e976..4987e1e8ac 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -82,6 +82,65 @@ describe('errorHandler', () => { }); }); + it('handle http-errors errors', () => { + let error = new Error('custom message'); + error.statusCode = 422; + + errorHandler(error, req, res, next); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(error.statusCode); + expect(res.json).to.be.calledWith({ + error: error.name, + message: error.message, + }); + }); + + it('handle express-validator errors', () => { + let error = [{param: 'param', msg: 'invalid param'}]; + + errorHandler(error, req, res, next); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(400); + expect(res.json).to.be.calledWith({ + error: 'BadRequest', + message: 'Invalid request parameters.', + errors: error, + }); + }); + + it('handle Mongoose Validation errors', () => { + let error = new Error('User validation failed.'); + error.name = 'ValidationError'; + + error.errors = { + 'auth.local.email': { + path: 'auth.local.email', + message: 'Invalid email.', + value: 'not an email', + }, + }; + + errorHandler(error, req, res, next); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(400); + expect(res.json).to.be.calledWith({ + error: 'BadRequest', + message: 'User validation failed.', + errors: [ + {path: 'auth.local.email', message: 'Invalid email.', value: 'not an email'} + ] + }); + }); + it('logs error', () => { let error = new BadRequest(); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 8e7cbb4c62..8af250674b 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -4,7 +4,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import { NotAuthorized, } from '../../libs/api-v3/errors'; -import passwordUtils from '../../libs/api-v3/password'; +import * as passwordUtils from '../../libs/api-v3/password'; import { model as User } from '../../models/user'; import EmailUnsubscription from '../../models/emailUnsubscription'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 5bf90f6969..616af12ffb 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -6,6 +6,7 @@ import { BadRequest, InternalServerError, } from '../../libs/api-v3/errors'; +import { map } from 'lodash'; export default function errorHandler (err, req, res, next) { if (!err) return next(); @@ -36,7 +37,19 @@ export default function errorHandler (err, req, res, next) { // Handle errors by express-validator if (Array.isArray(err) && err[0].param && err[0].msg) { responseErr = new BadRequest('Invalid request parameters.'); - responseErr.errors = err; + responseErr.errors = err; // TODO format + } + + // Handle mongoose validation errors + if (err.name === 'ValidationError') { + responseErr = new BadRequest(err.message); + responseErr.errors = map(err.errors, (mongooseErr) => { + return { + path: mongooseErr.path, + message: mongooseErr.message, + value: mongooseErr.value, + }; + }); } if (!responseErr || responseErr.httpCode >= 500) { @@ -48,11 +61,14 @@ export default function errorHandler (err, req, res, next) { responseErr = new InternalServerError(); } - // TODO unless status >= 500 return data attached to errors + let jsonRes = { + error: responseErr.name, + message: responseErr.message, + }; + + if (responseErr.errors) jsonRes.errors = responseErr.errors; + return res .status(responseErr.httpCode) - .json({ - error: responseErr.name, - message: responseErr.message, - }); + .json(jsonRes); } From 85a08f881bc208ca627e3f38967125b576244895 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 09:19:48 -0600 Subject: [PATCH 158/976] chore(lint): Correct linting errors in test files. --- tasks/gulp-eslint.js | 1 + test/common/user.fns.ultimateGear.test.js | 7 +++++-- test/helpers/api-integration.helper.js | 4 ++-- test/helpers/api-unit.helper.js | 15 ++++++++------- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index c13f27789c..3b5c517409 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -60,6 +60,7 @@ gulp.task('lint:tests', () => { 'expect': true, '_': true, 'sinon': true, + 'sandbox': true, }, plugins: [ 'mocha' ], }; diff --git a/test/common/user.fns.ultimateGear.test.js b/test/common/user.fns.ultimateGear.test.js index da598b486b..a95cb403d9 100644 --- a/test/common/user.fns.ultimateGear.test.js +++ b/test/common/user.fns.ultimateGear.test.js @@ -1,5 +1,8 @@ -var shared = require('../../common/script/index.js'); -shared.i18n.translations = require('../../website/src/libs/api-v2/i18n.js').translations +/* eslint-disable camelcase */ + +let shared = require('../../common/script/index.js'); + +shared.i18n.translations = require('../../website/src/libs/i18n.js').translations; require('./test_helper'); diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index ed3ffd3125..0fd23d6de3 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -218,8 +218,8 @@ export function resetHabiticaDB () { }); } -function _requestMaker(user, method, additionalSets) { - const API_V = process.env.API_VERSION || 'v2' +function _requestMaker (user, method, additionalSets) { + const API_V = process.env.API_VERSION || 'v2'; // eslint-disable-line no-process-env return (route, send, query) => { return new Promise((resolve, reject) => { diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 554578ab6e..cefd1eeeeb 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -1,24 +1,25 @@ import '../../website/src/libs/api-v3/i18n'; import { defaultsDeep as defaults } from 'lodash'; -import { model as User } from '../../website/src/models/user' -import { model as Group } from '../../website/src/models/group' +import { model as User } from '../../website/src/models/user'; +import { model as Group } from '../../website/src/models/group'; afterEach(() => { sandbox.restore(); }); -export function generateUser(options={}) { +export function generateUser (options = {}) { return new User(options).toObject(); } -export function generateGroup(options={}) { +export function generateGroup (options = {}) { return new Group(options).toObject(); } -export function generateRes(options={}) { +export function generateRes (options = {}) { let defaultRes = { send: sandbox.stub(), status: sandbox.stub().returnsThis(), + sendStatus: sandbox.stub().returnsThis(), json: sandbox.stub(), locals: { user: generateUser(options.localsUser), @@ -29,7 +30,7 @@ export function generateRes(options={}) { return defaults(options, defaultRes); } -export function generateReq(options={}) { +export function generateReq (options = {}) { let defaultReq = { body: {}, query: {}, @@ -39,6 +40,6 @@ export function generateReq(options={}) { return defaults(options, defaultReq); } -export function generateNext(func) { +export function generateNext (func) { return func || sandbox.stub(); } From b3e9872f594aadb1ecde24b0aa02ff4fa1056e43 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 09:20:08 -0600 Subject: [PATCH 159/976] tests: Adjust integration helper to display server errors for each API. --- test/helpers/api-integration.helper.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 0fd23d6de3..501d5ab6b8 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -243,11 +243,20 @@ function _requestMaker (user, method, additionalSets) { if (err) { if (!err.response) return reject(err); - return reject({ - code: err.status, - error: err.response.body.error, - message: err.response.body.message, - }); + if (API_V === 'v3') { + return reject({ + code: err.status, + error: err.response.body.error, + message: err.response.body.message, + }); + } else if (API_V === 'v2') { + return reject({ + code: err.status, + text: err.response.body.err, + }); + } + + return reject(err); } resolve(response.body); From 4dee29a20bd9a9a1914a4ccc427ac8d7dd198573 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 09:42:40 -0600 Subject: [PATCH 160/976] tests(fix): Correct path to i18n.js module --- test/common/user.fns.ultimateGear.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/common/user.fns.ultimateGear.test.js b/test/common/user.fns.ultimateGear.test.js index a95cb403d9..d1fba030bc 100644 --- a/test/common/user.fns.ultimateGear.test.js +++ b/test/common/user.fns.ultimateGear.test.js @@ -2,7 +2,7 @@ let shared = require('../../common/script/index.js'); -shared.i18n.translations = require('../../website/src/libs/i18n.js').translations; +shared.i18n.translations = require('../../website/src/libs/api-v2/i18n.js').translations; require('./test_helper'); From ade764acbdd9bc59b54caa282b347fa3b8f7ccbd Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 13:45:46 -0600 Subject: [PATCH 161/976] tests(helper): Allow route to register user to be variable based on api version. --- test/helpers/api-integration.helper.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 501d5ab6b8..48ee56739d 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -13,6 +13,15 @@ import i18n from '../../common/script/src/i18n'; i18n.translations = require('../../website/src/libs/api-v3/i18n').translations; const API_TEST_SERVER_PORT = 3003; +const API_V = process.env.API_VERSION || 'v2'; // eslint-disable-line no-process-env +const ROUTES = { + v2: { + register: '/register', + }, + v3: { + register: '/user/auth/local/register', + }, +}; // Sets up an abject that can make all REST requests // If a user is passed in, the uuid and api token of @@ -78,7 +87,7 @@ export function generateUser (update = {}) { let request = _requestMaker({}, 'post'); return new Promise((resolve, reject) => { - request('/register', { + request(ROUTES[API_V].register, { username, email, password, @@ -219,8 +228,6 @@ export function resetHabiticaDB () { } function _requestMaker (user, method, additionalSets) { - const API_V = process.env.API_VERSION || 'v2'; // eslint-disable-line no-process-env - return (route, send, query) => { return new Promise((resolve, reject) => { let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/${API_V}${route}`) From 49e7799baa5d4cb4e21335d6235f6d733d628f38 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 13:51:31 -0600 Subject: [PATCH 162/976] tests(api): clean up code style of test file --- .../user/auth/POST-register_local.test.js | 75 +++++++++---------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index d5bd25a970..5d63eebcc0 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -15,9 +15,9 @@ describe('POST /user/auth/local/register', () => { let password = 'password'; return api.post('/user/auth/local/register', { - username: username, - email: email, - password: password, + username, + email, + password, confirmPassword: password, }).then((user) => { expect(user._id).to.exist; @@ -34,9 +34,9 @@ describe('POST /user/auth/local/register', () => { let confirmPassword = 'not password'; return expect(api.post('/user/auth/local/register', { - username: username, - email: email, - password: password, + username, + email, + password, confirmPassword: confirmPassword, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -52,13 +52,13 @@ describe('POST /user/auth/local/register', () => { let confirmPassword = 'password'; return expect(api.post('/user/auth/local/register', { - email: email, - password: password, - confirmPassword: confirmPassword, + email, + password, + confirmPassword, })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('missingUsernameEmail'), + message: t('missingUsername'), }); }); @@ -66,16 +66,15 @@ describe('POST /user/auth/local/register', () => { let api = requester(); let username = generateRandomUserName(); let password = 'password'; - let confirmPassword = 'password'; return expect(api.post('/user/auth/local/register', { - username: username, - password: password, - confirmPassword: confirmPassword, + username, + password, + confirmPassword: password, })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('missingUsernameEmail'), + message: t('missingEmail'), }); }); @@ -86,7 +85,7 @@ describe('POST /user/auth/local/register', () => { let confirmPassword = 'password'; return expect(api.post('/user/auth/local/register', { - username: username, + username, email: email, confirmPassword: confirmPassword, })).to.eventually.be.rejected.and.eql({ @@ -123,7 +122,7 @@ describe('POST /user/auth/local/register', () => { })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('usernameTake'), + message: t('usernameTaken'), }); }); @@ -133,9 +132,9 @@ describe('POST /user/auth/local/register', () => { let password = 'password'; return expect(api.post('/user/auth/local/register', { - username: uniqueUsername, - email: email, - password: password, + username: uniqueUsername, + email, + password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -157,9 +156,9 @@ describe('POST /user/auth/local/register', () => { it('sets all site tour values to -2 (already seen)', () => { return api.post('/user/auth/local/register', { - username: username, - email: email, - password: password, + username, + email, + password, confirmPassword: password, }).then((user) => { expect(user.flags.tour).to.not.be.empty; @@ -172,9 +171,9 @@ describe('POST /user/auth/local/register', () => { it('populates user with default todos, not no other task types', () => { return api.post('/user/auth/local/register', { - username: username, - email: email, - password: password, + username, + email, + password, confirmPassword: password, }).then((user) => { expect(user.todos).to.not.be.empty; @@ -186,9 +185,9 @@ describe('POST /user/auth/local/register', () => { it('populates user with default tags', () => { return api.post('/user/auth/local/register', { - username: username, - email: email, - password: password, + username, + email, + password, confirmPassword: password, }).then((user) => { expect(user.tags).to.not.be.empty; @@ -208,9 +207,9 @@ describe('POST /user/auth/local/register', () => { it('sets all common tutorial flags to true', () => { return api.post('/user/auth/local/register', { - username: username, - email: email, - password: password, + username, + email, + password, confirmPassword: password, }).then((user) => { expect(user.flags.tour).to.not.be.empty; @@ -223,9 +222,9 @@ describe('POST /user/auth/local/register', () => { it('populates user with default todos, habits, and rewards', () => { return api.post('/user/auth/local/register', { - username: username, - email: email, - password: password, + username, + email, + password, confirmPassword: password, }).then((user) => { expect(user.todos).to.not.be.empty; @@ -237,9 +236,9 @@ describe('POST /user/auth/local/register', () => { it('populates user with default tags', () => { return api.post('/user/auth/local/register', { - username: username, - email: email, - password: password, + username, + email, + password, confirmPassword: password, }).then((user) => { expect(user.tags).to.not.be.empty; From 78cdc1753fbab4bcdcb2d3ba87a970dc561d6058 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 13:52:09 -0600 Subject: [PATCH 163/976] tests(api): Add test for registering with an invalid email --- .../user/auth/POST-register_local.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 5d63eebcc0..8eca0ed07d 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -78,6 +78,24 @@ describe('POST /user/auth/local/register', () => { }); }); + it('requires a valid email', () => { + let api = requester(); + let username = generateRandomUserName(); + let email = 'notanemail@sdf'; + let password = 'password'; + + return expect(api.post('/user/auth/local/register', { + username, + email, + password, + confirmPassword: password, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('invalidEmail'), + }); + }); + it('requires a password', () => { let api = requester(); let username = generateRandomUserName(); From 7ce554c57855c2ca5d8f6b524002e4b257eb4525 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 14:41:07 -0600 Subject: [PATCH 164/976] fix(controller): Correct path to EmailUnsubscription model --- website/src/controllers/api-v3/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 8af250674b..6354f0d142 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -6,7 +6,7 @@ import { } from '../../libs/api-v3/errors'; import * as passwordUtils from '../../libs/api-v3/password'; import { model as User } from '../../models/user'; -import EmailUnsubscription from '../../models/emailUnsubscription'; +import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; let api = {}; From 43ef4e51b527551ab176f5fef5a9caa71d42da43 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 14:56:20 -0600 Subject: [PATCH 165/976] fix(model): Remove pre validation from user model --- website/src/models/user.js | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 2d001a3ca6..0deca34bac 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -609,37 +609,6 @@ function _setProfileName (user) { return localUsername || facebookUsername || anonymous; } -schema.pre('validate', function beforeValidateUser (next) { - if (!this.auth.facebook.id || this.auth.local.email || this.auth.local.username) { - if (!this.auth.local.email) { - this.invalidate('auth.local.email', shared.i18n.t('missingEmail')); - return next(); - } - - if (!this.auth.local.username) { - this.invalidate('auth.local.username', shared.i18n.t('missingUsername')); - return next(); - } - } - - // Validate password and password confirmation and create hashed version - if (this.isModified('auth.local.password') || this.isNew() && !this.auth.facebook.id) { // TODO this does not catch when you already have social auth and password isn't passedß - if (!this.auth.local.password) { - this.invalidate('auth.local.password', shared.i18n.t('missingPassword')); - return next(); - } - - if (this.auth.local.password !== this.auth.local.passwordConfirmation) { - this.invalidate('auth.local.passwordConfirmation', shared.i18n.t('passwordConfirmationMatch')); - return next(); - } - - this.hashed_password = passwordUtils.encrypt(this.auth.local.password, this.auth.local.salt); // eslint-disable-line camelcase - } - - next(); -}); - schema.pre('save', function postSaveUser (next) { // Do not store password and passwordConfirmation this.auth.local.password = this.local.auth.passwordConfirmation = undefined; From 75cea0c810e3b26918c7e32d00c12712b75a9839 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 14:56:44 -0600 Subject: [PATCH 166/976] refactor(model): Remove password and passwordConfirmation from user model --- website/src/models/user.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 0deca34bac..a70d76e7d0 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -42,15 +42,6 @@ export let schema = new Schema({ lowerCaseUsername: String, hashed_password: String, // eslint-disable-line camelcase salt: String, - // password and passwordConfirmation are not stored in the database, used only for validation - password: { - type: String, - trim: true, - }, - passwordConfirmation: { - type: String, - trim: true, - }, }, timestamps: { created: {type: Date, default: Date.now}, @@ -610,9 +601,6 @@ function _setProfileName (user) { } schema.pre('save', function postSaveUser (next) { - // Do not store password and passwordConfirmation - this.auth.local.password = this.local.auth.passwordConfirmation = undefined; - // Populate new users with default content if (this.isNew) { _populateDefaultsForNewUser(this); From 074b3f5079be55102933dfb86f0f1fea28eeb846 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 14:57:59 -0600 Subject: [PATCH 167/976] fix(controller): Validate params for register route in user controller --- website/src/controllers/api-v3/user.js | 84 ++++++++++++++------------ 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 6354f0d142..acff305ae9 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -20,7 +20,7 @@ let api = {}; * @apiParam {String} username Username of the new user * @apiParam {String} email Email address of the new user * @apiParam {String} password Password for the new user account - * @apiParam {String} passwordConfirmation Password confirmation + * @apiParam {String} confirmPassword Password confirmation * * @apiSuccess {Object} user The user object */ @@ -28,32 +28,19 @@ api.registerLocal = { method: 'POST', url: '/user/auth/local/register', handler (req, res, next) { - let email = req.body.email && req.body.email.toLowerCase(); - let username = req.body.username; + let { email, username, password, confirmPassword } = req.body; + + // Validate required params + if (!username) return next(new NotAuthorized(res.t('missingUsername'))); + if (!email) return next(new NotAuthorized(res.t('missingEmail'))); + if (!validator.isEmail(email)) return next(new NotAuthorized(res.t('invalidEmail'))); + if (!password) return next(new NotAuthorized(res.t('missingPassword'))); + if (password !== confirmPassword) return next(new NotAuthorized(res.t('passwordConfirmationMatch'))); + // Get the lowercase version of username to check that we do not have duplicates // So we can search for it in the database and then reject the choosen username if 1 or more results are found - let lowerCaseUsername = username && username.toLowerCase(); - - let newUser = new User({ - auth: { - local: { - username, - lowerCaseUsername, // Store the lowercase version of the username - email, // Store email as lowercase - salt: passwordUtils.makeSalt(), - password: req.body.password, - passwordConfirmation: req.body.passwordConfirmation, - }, - }, - preferences: { - language: req.language, - }, - }); - - newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? - let validationErrors = newUser.validateSync(); // Validate synchronously for speed, remove if we add any async validator - - if (validationErrors) return next(validationErrors); + let lowerCaseUsername = username.toLowerCase(); + email = email.toLowerCase(); // Search for duplicates using lowercase version of username User.findOne({$or: [ @@ -65,25 +52,48 @@ api.registerLocal = { if (user) { if (email === user.auth.local.email) return next(new NotAuthorized(res.t('emailTaken'))); // Check that the lowercase username isn't already used - if (lowerCaseUsername === user.auth.local.lowerCaseUsername) return next(new NotAuthorized(res.t('usernameTaken'))); + if (lowerCaseUsername === user.auth.local.lowerCaseUsername) { + return next(new NotAuthorized(res.t('usernameTaken'))); + } } + let salt = passwordUtils.makeSalt(); + let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase + let newUser = new User({ + auth: { + local: { + username, + lowerCaseUsername, + email, + salt, + hashed_password, // eslint-disable-line camelcase + }, + }, + preferences: { + language: req.language, + }, + }); + + newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? + return newUser.save(); }) .then((savedUser) => { - res.status(201).json(savedUser); + if (savedUser) { + res.status(201).json(savedUser); - // Clean previous email preferences - EmailUnsubscription - .remove({email: savedUser.auth.local.email}) - .then(() => sendTxnEmail(savedUser, 'welcome')); + // Clean previous email preferences + EmailUnsubscription + .remove({email: savedUser.auth.local.email}) + .then(() => sendTxnEmail(savedUser, 'welcome')); - res.analytics.track('register', { - category: 'acquisition', - type: 'local', - gaLabel: 'local', - uuid: savedUser._id, - }); + res.analytics.track('register', { + category: 'acquisition', + type: 'local', + gaLabel: 'local', + uuid: savedUser._id, + }); + } }) .catch(next); }, From d1839b816e2db2c39b66c23202728c3c5117a2cf Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 16:17:42 -0600 Subject: [PATCH 168/976] chore(lint): Clean up user model and controller --- website/src/controllers/api-v3/user.js | 2 +- website/src/models/user.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index acff305ae9..3394e8b15a 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -39,8 +39,8 @@ api.registerLocal = { // Get the lowercase version of username to check that we do not have duplicates // So we can search for it in the database and then reject the choosen username if 1 or more results are found - let lowerCaseUsername = username.toLowerCase(); email = email.toLowerCase(); + let lowerCaseUsername = username.toLowerCase(); // Search for duplicates using lowercase version of username User.findOne({$or: [ diff --git a/website/src/models/user.js b/website/src/models/user.js index a70d76e7d0..0462dd5ab1 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -1,7 +1,6 @@ // User schema and model import mongoose from 'mongoose'; import shared from '../../../common'; -import passwordUtils from '../libs/api-v3/password'; import _ from 'lodash'; import validator from 'validator'; import moment from 'moment'; From 867efd707812806f90bc4c578655d55e7a835b45 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 Nov 2015 17:01:56 -0600 Subject: [PATCH 169/976] fix(controller): Adjust next calls to throw errors instead inside promise --- website/src/controllers/api-v3/user.js | 30 +++++++++++--------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 3394e8b15a..b3414ca921 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -50,11 +50,9 @@ api.registerLocal = { .exec() .then((user) => { if (user) { - if (email === user.auth.local.email) return next(new NotAuthorized(res.t('emailTaken'))); + if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken')); // Check that the lowercase username isn't already used - if (lowerCaseUsername === user.auth.local.lowerCaseUsername) { - return next(new NotAuthorized(res.t('usernameTaken'))); - } + if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken')); } let salt = passwordUtils.makeSalt(); @@ -79,21 +77,19 @@ api.registerLocal = { return newUser.save(); }) .then((savedUser) => { - if (savedUser) { - res.status(201).json(savedUser); + res.status(201).json(savedUser); - // Clean previous email preferences - EmailUnsubscription - .remove({email: savedUser.auth.local.email}) - .then(() => sendTxnEmail(savedUser, 'welcome')); + // Clean previous email preferences + EmailUnsubscription + .remove({email: savedUser.auth.local.email}) + .then(() => sendTxnEmail(savedUser, 'welcome')); - res.analytics.track('register', { - category: 'acquisition', - type: 'local', - gaLabel: 'local', - uuid: savedUser._id, - }); - } + res.analytics.track('register', { + category: 'acquisition', + type: 'local', + gaLabel: 'local', + uuid: savedUser._id, + }); }) .catch(next); }, From 6d238a077041ecf71156e36a11ba7a9f7dd73cac Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 23 Nov 2015 10:09:17 +0100 Subject: [PATCH 170/976] fix .eslintrc --- .eslintrc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.eslintrc b/.eslintrc index 585f456870..0aafa5a5e4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -49,7 +49,7 @@ "no-shadow-restricted-names": 2, "no-shadow": [2, { "builtinGlobals": true }], "no-undef-init": 2, - "no-undef": [2, { typeof: true }], + "no-undef": [2, { "typeof": true }], "no-unused-vars": 2, "no-use-before-define": 2, "global-require": 2, @@ -104,17 +104,17 @@ "space-infix-ops": 2, "space-return-throw-case": 2, "space-unary-ops": 2, - "spaced-comment": [2, "always", { exceptions: ["-"]}], + "spaced-comment": [2, "always", { "exceptions": ["-"]}], "padded-blocks": [2, "never"], - "no-multiple-empty-lines": [2, {max: 2}] + "no-multiple-empty-lines": [2, {"max": 2}] }, "env": { "es6": true, "mocha": true, "node": true }, - ecmaFeatures : { - modules: true + "ecmaFeatures" : { + "modules": true }, "extends": "eslint:recommended" } From 3459b51cef114f9ee588bc4fe97e421f85167cc1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 23 Nov 2015 10:20:07 +0100 Subject: [PATCH 171/976] use throw instead of returning next inside of promises --- website/src/controllers/api-v3/user.js | 3 ++- website/src/middlewares/api-v3/auth.js | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index b3414ca921..643108886f 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -149,7 +149,7 @@ api.loginLocal = { // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (!isValidPassword) return next(new NotAuthorized(res.t('invalidLoginCredentials'))); + if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials')); _loginRes(user, ...arguments); }) .catch(next); @@ -195,6 +195,7 @@ api.loginSocial = { if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) { EmailUnsubscription .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()}) + .exec() .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks } diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 4061a60f92..562af57130 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -22,13 +22,13 @@ export function authWithHeaders (req, res, next) { }) .exec() .then((user) => { - if (!user) return next(new NotAuthorized(i18n.t('invalidCredentials'))); - if (user.auth.blocked) return next(new NotAuthorized(i18n.t('accountSuspended', {userId: user._id}))); + if (!user) throw new NotAuthorized(i18n.t('invalidCredentials')); + if (user.auth.blocked) throw new NotAuthorized(i18n.t('accountSuspended', {userId: user._id})); res.locals.user = user; // TODO use either session/cookie or headers, not both req.session.userId = user._id; - return next(); + next(); }) .catch(next); } @@ -45,10 +45,10 @@ export function authWithSession (req, res, next) { }) .exec() .then((user) => { - if (!user) return next(new NotAuthorized(i18n.t('invalidCredentials'))); + if (!user) throw new NotAuthorized(i18n.t('invalidCredentials')); res.locals.user = user; - return next(); + next(); }) .catch(next); } From a26f713e18c26c4f7416223be71cf55f8b9f9671 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 23 Nov 2015 12:47:15 +0100 Subject: [PATCH 172/976] misc fixes, add ability to attach local auth to social user --- common/locales/en/api-v3.json | 6 +- .../user/auth/POST-register_local.test.js | 30 ++++----- website/src/controllers/api-v3/user.js | 61 ++++++++++++------- website/src/middlewares/api-v3/auth.js | 46 +++++++------- .../src/middlewares/api-v3/errorHandler.js | 2 +- 5 files changed, 85 insertions(+), 60 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index edd383f9b9..526cf273bf 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -4,7 +4,7 @@ "missingEmail": "Missing email.", "missingUsername": "Missing username.", "missingPassword": "Missing password.", - "invalidEmail": "Invalid email address.", + "notAnEmail": "Invalid email address.", "emailTaken": "Email already taken.", "usernameTaken": "Username already taken.", "passwordConfirmationMatch": "Password confirmation doesn't match password.", @@ -12,5 +12,7 @@ "invalidCredentials": "User not found with given auth credentials.", "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your UUID \"<%= userId %>\" for assistance.", "onlyFbSupported": "Only Facebook supported currently.", - "cantDetachFb": "Account lacks another authentication method, can't detach Facebook." + "cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", + "onlySocialAttachLocal": "Local auth can only be added to a social account.", + "invalidReqParams": "Invalid request parameters." } diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 8eca0ed07d..4ad2127cd4 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -39,9 +39,9 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: confirmPassword, })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('passwordConfirmationMatch'), + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), }); }); @@ -56,9 +56,9 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword, })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingUsername'), + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), }); }); @@ -72,9 +72,9 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingEmail'), + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), }); }); @@ -90,9 +90,9 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('invalidEmail'), + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), }); }); @@ -107,9 +107,9 @@ describe('POST /user/auth/local/register', () => { email: email, confirmPassword: confirmPassword, })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingPassword'), + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), }); }); }); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 643108886f..9dd471868f 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -12,7 +12,7 @@ import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; let api = {}; /** - * @api {post} /user/auth/local/register Register a new user with email, username and password or add local authentication to a social user + * @api {post} /user/auth/local/register Register a new user with email, username and password or attach local auth to a social user * @apiVersion 3.0.0 * @apiName UserRegisterLocal * @apiGroup User @@ -22,21 +22,32 @@ let api = {}; * @apiParam {String} password Password for the new user account * @apiParam {String} confirmPassword Password confirmation * - * @apiSuccess {Object} user The user object + * @apiSuccess {Object} user The user object, if we just attached local auth to a social user then only user.auth.local */ api.registerLocal = { method: 'POST', + middlewares: [authWithHeaders(true)], url: '/user/auth/local/register', handler (req, res, next) { - let { email, username, password, confirmPassword } = req.body; + let fbUser = res.locals.user; // If adding local auth to social user - // Validate required params - if (!username) return next(new NotAuthorized(res.t('missingUsername'))); - if (!email) return next(new NotAuthorized(res.t('missingEmail'))); - if (!validator.isEmail(email)) return next(new NotAuthorized(res.t('invalidEmail'))); - if (!password) return next(new NotAuthorized(res.t('missingPassword'))); - if (password !== confirmPassword) return next(new NotAuthorized(res.t('passwordConfirmationMatch'))); + req.checkBody({ + email: { + notEmpty: {errorMessage: res.t('missingEmail')}, + isEmail: {errorMessage: res.t('notAnEmail')}, + }, + username: {notEmpty: {errorMessage: res.t('missingUsername')}}, + password: { + notEmpty: {errorMessage: res.t('missingPassword')}, + equals: {options: [req.body.confirmPassword], errorMessage: res.t('passwordConfirmationMatch')}, + }, + }); + let validationErrors = req.validationErrors(); + + if (validationErrors) return next(validationErrors); + + let { email, username, password } = req.body; // Get the lowercase version of username to check that we do not have duplicates // So we can search for it in the database and then reject the choosen username if 1 or more results are found email = email.toLowerCase(); @@ -57,7 +68,7 @@ api.registerLocal = { let salt = passwordUtils.makeSalt(); let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase - let newUser = new User({ + let newUser = { auth: { local: { username, @@ -70,11 +81,17 @@ api.registerLocal = { preferences: { language: req.language, }, - }); + }; - newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? - - return newUser.save(); + if (fbUser) { + if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal')); + fbUser.auth.local = newUser; + return fbUser.save(); + } else { + newUser = new User(newUser); + newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? + return newUser.save(); + } }) .then((savedUser) => { res.status(201).json(savedUser); @@ -84,12 +101,14 @@ api.registerLocal = { .remove({email: savedUser.auth.local.email}) .then(() => sendTxnEmail(savedUser, 'welcome')); - res.analytics.track('register', { - category: 'acquisition', - type: 'local', - gaLabel: 'local', - uuid: savedUser._id, - }); + if (!savedUser.auth.facebook.id) { + res.analytics.track('register', { + category: 'acquisition', + type: 'local', + gaLabel: 'local', + uuid: savedUser._id, + }); + } }) .catch(next); }, @@ -225,7 +244,7 @@ api.loginSocial = { api.deleteSocial = { method: 'DELETE', url: '/user/auth/social/:network', - middlewares: [authWithHeaders], + middlewares: [authWithHeaders()], handler (req, res, next) { let user = res.locals.user; let network = req.params.network; diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 562af57130..27604bc445 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -8,29 +8,33 @@ import { } from '../../models/user'; // Authenticate a request through the x-api-user and x-api key header -export function authWithHeaders (req, res, next) { - let userId = req.header['x-api-user']; - let apiToken = req.header['x-api-key']; +// If optional is true, don't error on missing authentication +export function authWithHeaders (optional = false) { + return function authWithHeadersHandler (req, res, next) { + let userId = req.header['x-api-user']; + let apiToken = req.header['x-api-key']; - if (!userId || !apiToken) { - return next(new BadRequest(res.t('missingAuthHeaders'))); + if (!userId || !apiToken) { + if (optional) return next(); + return next(new BadRequest(res.t('missingAuthHeaders'))); + } + + User.findOne({ + _id: userId, + apiToken, + }) + .exec() + .then((user) => { + if (!user) throw new NotAuthorized(i18n.t('invalidCredentials')); + if (user.auth.blocked) throw new NotAuthorized(i18n.t('accountSuspended', {userId: user._id})); + + res.locals.user = user; + // TODO use either session/cookie or headers, not both + req.session.userId = user._id; + next(); + }) + .catch(next); } - - User.findOne({ - _id: userId, - apiToken, - }) - .exec() - .then((user) => { - if (!user) throw new NotAuthorized(i18n.t('invalidCredentials')); - if (user.auth.blocked) throw new NotAuthorized(i18n.t('accountSuspended', {userId: user._id})); - - res.locals.user = user; - // TODO use either session/cookie or headers, not both - req.session.userId = user._id; - next(); - }) - .catch(next); } // Authenticate a request through a valid session diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 616af12ffb..57280db12a 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -36,7 +36,7 @@ export default function errorHandler (err, req, res, next) { // Handle errors by express-validator if (Array.isArray(err) && err[0].param && err[0].msg) { - responseErr = new BadRequest('Invalid request parameters.'); + responseErr = new BadRequest(res.t('invalidReqParams')); responseErr.errors = err; // TODO format } From 349122c9a1a26f2ab4122d54d60c9d5835d66057 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 23 Nov 2015 13:01:47 +0100 Subject: [PATCH 173/976] add res.respond, fix linting --- test/api/v3/integration/notFound.test.js | 1 + .../v3/integration/user/auth/POST-register_local.test.js | 7 +++++++ test/helpers/api-integration.helper.js | 1 + website/src/controllers/api-v3/user.js | 1 + website/src/middlewares/api-v3/auth.js | 2 +- website/src/middlewares/api-v3/errorHandler.js | 4 +--- website/src/middlewares/api-v3/index.js | 2 ++ 7 files changed, 14 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/notFound.test.js b/test/api/v3/integration/notFound.test.js index e86b6b35e8..4c17b13dda 100644 --- a/test/api/v3/integration/notFound.test.js +++ b/test/api/v3/integration/notFound.test.js @@ -5,6 +5,7 @@ describe('notFound Middleware', () => { let request = requester().get('/api/v3/dummy-url'); return expect(request).to.eventually.be.rejected.and.eql({ + success: false, code: 404, error: 'NotFound', message: 'Not found.', diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 4ad2127cd4..8b21447a57 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -39,6 +39,7 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: confirmPassword, })).to.eventually.be.rejected.and.eql({ + success: false, code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -56,6 +57,7 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword, })).to.eventually.be.rejected.and.eql({ + success: false, code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -72,6 +74,7 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ + success: false, code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -90,6 +93,7 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ + success: false, code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -107,6 +111,7 @@ describe('POST /user/auth/local/register', () => { email: email, confirmPassword: confirmPassword, })).to.eventually.be.rejected.and.eql({ + success: false, code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -138,6 +143,7 @@ describe('POST /user/auth/local/register', () => { password: password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ + success: false, code: 401, error: 'NotAuthorized', message: t('usernameTaken'), @@ -155,6 +161,7 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ + success: false, code: 401, error: 'NotAuthorized', message: t('emailTaken'), diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 48ee56739d..be3e9e66b7 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -252,6 +252,7 @@ function _requestMaker (user, method, additionalSets) { if (API_V === 'v3') { return reject({ + success: err.response.body.success, code: err.status, error: err.response.body.error, message: err.response.body.message, diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 9dd471868f..cd154faca7 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -48,6 +48,7 @@ api.registerLocal = { if (validationErrors) return next(validationErrors); let { email, username, password } = req.body; + // Get the lowercase version of username to check that we do not have duplicates // So we can search for it in the database and then reject the choosen username if 1 or more results are found email = email.toLowerCase(); diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 27604bc445..f2158278bf 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -34,7 +34,7 @@ export function authWithHeaders (optional = false) { next(); }) .catch(next); - } + }; } // Authenticate a request through a valid session diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 57280db12a..e017910297 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -68,7 +68,5 @@ export default function errorHandler (err, req, res, next) { if (responseErr.errors) jsonRes.errors = responseErr.errors; - return res - .status(responseErr.httpCode) - .json(jsonRes); + return res.respond(responseErr.httpCode, jsonRes); } diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 46a546b549..56d78add8e 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -8,6 +8,7 @@ import routes from '../../libs/api-v3/setupRoutes'; import notFoundHandler from './notFound'; import nconf from 'nconf'; import morgan from 'morgan'; +import responseHandler from './response'; const IS_PROD = nconf.get('IS_PROD'); const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); @@ -22,6 +23,7 @@ export default function attachMiddlewares (app) { app.use(bodyParser.json()); app.use(expressValidator()); // TODO config app.use(analytics); + app.use(responseHandler); app.use(getUserLanguage); app.use('/api/v3', routes); From 7086fbfbd613a5e0f8f3b088b5a85f02eb482bde Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 23 Nov 2015 13:07:21 +0100 Subject: [PATCH 174/976] add res.respond middleware... and use it in user controller --- website/src/controllers/api-v3/user.js | 10 +++++++--- website/src/middlewares/api-v3/response.js | 9 +++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 website/src/middlewares/api-v3/response.js diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index cd154faca7..5afd792d90 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -95,7 +95,11 @@ api.registerLocal = { } }) .then((savedUser) => { - res.status(201).json(savedUser); + if (savedUser.auth.facebook.id) { + res.respond(200, savedUser.auth.local); // TODO make sure this used .toJSON and removes private fields + } else { + res.respond(201, savedUser); + } // Clean previous email preferences EmailUnsubscription @@ -117,7 +121,7 @@ api.registerLocal = { function _loginRes (user, req, res, next) { if (user.auth.blocked) return next(new NotAuthorized(res.t('accountSuspended', {userId: user._id}))); - res.status(200).json({id: user._id, apiToken: user.apiToken}); + res.respond(200, {id: user._id, apiToken: user.apiToken}); } /** @@ -254,7 +258,7 @@ api.deleteSocial = { if (!user.auth.local.username) return next(new NotAuthorized(res.t('cantDetachFb'))); // TODO move to model validation? User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}) - .then(() => res.status(200).json({ok: true})) // TODO standardize this type of response + .then(() => res.respond(200)) .catch(next); }, }; diff --git a/website/src/middlewares/api-v3/response.js b/website/src/middlewares/api-v3/response.js new file mode 100644 index 0000000000..7d60df3dba --- /dev/null +++ b/website/src/middlewares/api-v3/response.js @@ -0,0 +1,9 @@ +export default function responseHandler (req, res, next) { + res.respond = function respond (status = 200, data = {}) { + res.status(status); + data.success = status >= 400 ? false : true; // TODO the data object should be cloned to avoid pollution? + res.json(data); + }; + + next(); +} From 645095e58f975a5714c22ac72bdbcee228af2ddc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 23 Nov 2015 13:28:25 +0100 Subject: [PATCH 175/976] fix errorHandler tests, add tests for res.respond --- .../v3/unit/middlewares/errorHandler.test.js | 18 ++++--- test/api/v3/unit/middlewares/response.js | 51 +++++++++++++++++++ .../src/middlewares/api-v3/errorHandler.js | 4 +- 3 files changed, 63 insertions(+), 10 deletions(-) create mode 100644 test/api/v3/unit/middlewares/response.js diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 4987e1e8ac..096d25b850 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -5,6 +5,8 @@ import { } from '../../../../helpers/api-unit.helper'; import errorHandler from '../../../../../website/src/middlewares/api-v3/errorHandler'; +import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response'; +import getUserLanguage from '../../../../../website/src/middlewares/api-v3/getUserLanguage'; import { BadRequest } from '../../../../../website/src/libs/api-v3/errors'; import logger from '../../../../../website/src/libs/api-v3/logger'; @@ -16,6 +18,8 @@ describe('errorHandler', () => { res = generateRes(); req = generateReq(); next = generateNext(); + responseMiddleware(req, res, next); + getUserLanguage(req, res, next); sandbox.stub(logger, 'error'); }); @@ -30,6 +34,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(500); expect(res.json).to.be.calledWith({ + success: false, error: 'InternalServerError', message: 'An unexpected error occurred.', }); @@ -46,6 +51,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ + success: false, error: 'Error', message: 'Error message', }); @@ -62,6 +68,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(500); expect(res.json).to.be.calledWith({ + success: false, error: 'InternalServerError', message: 'An unexpected error occurred.', }); @@ -77,6 +84,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ + success: false, error: 'BadRequest', message: 'Bad request.', }); @@ -93,6 +101,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(error.statusCode); expect(res.json).to.be.calledWith({ + success: false, error: error.name, message: error.message, }); @@ -108,6 +117,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ + success: false, error: 'BadRequest', message: 'Invalid request parameters.', errors: error, @@ -133,6 +143,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ + success: false, error: 'BadRequest', message: 'User validation failed.', errors: [ @@ -154,11 +165,4 @@ describe('errorHandler', () => { fullError: error, }); }); - - it('does not send error if error is not defined', () => { - errorHandler(null, req, res, next); - - expect(next).to.be.calledOnce; - expect(res.status).to.not.be.called; - }); }); diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js new file mode 100644 index 0000000000..5850ef35fd --- /dev/null +++ b/test/api/v3/unit/middlewares/response.js @@ -0,0 +1,51 @@ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; +import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response' + +describe('response middleware', function() { + let res, req, next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + }); + + + it('attaches respond method to res', function() { + responseMiddleware(req, res, next); + + expect(res.respond).to.exist; + }); + + it('can be used to respond to requests', function() { + responseMiddleware(req, res, next); + res.respond(200, {field: 1}); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(200); + expect(res.json).to.be.calledWith({ + field: 1, + success: true, + }); + }); + + it('treats status >= 400 as failures', function() { + responseMiddleware(req, res, next); + res.respond(403, {field: 1}); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(403); + expect(res.json).to.be.calledWith({ + field: 1, + success: false, + }); + }); +}); diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index e017910297..59bdca98d5 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -8,9 +8,7 @@ import { } from '../../libs/api-v3/errors'; import { map } from 'lodash'; -export default function errorHandler (err, req, res, next) { - if (!err) return next(); - +export default function errorHandler (err, req, res, next) { // eslint-disable-line no-unused-vars // Log the original error with some metadata let stack = err.stack || err.message || err; From 8196c65627dbf16d48662094929669825b3a1d32 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 20 Nov 2015 19:02:08 -0600 Subject: [PATCH 176/976] fix(api tests): Let api test script pass the correct api version for helper. (cherry picked from commit 2fa2e0f483374229277fa2902bc6cb7a9126539d) --- tasks/gulp-tests.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 132e75ed93..3698fd1f64 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -36,8 +36,8 @@ let testCount = (stdout, regexp) => { return parseInt(match && match[1] || 0); } -let testBin = (string) => { - return `NODE_ENV=testing ./node_modules/.bin/${string}`; +let testBin = (string, additionalEnvVariables = '') => { + return `NODE_ENV=testing ${additionalEnvVariables} ./node_modules/.bin/${string}`; }; gulp.task('test:nodemon', (done) => { @@ -361,7 +361,7 @@ gulp.task('test:api-v3:integration:watch', ['test:prepare:server'], () => { gulp.task('test:api-v3:safe', ['test:prepare:server'], (done) => { awaitPort(TEST_SERVER_PORT).then(() => { let runner = exec( - testBin(API_V3_TEST_COMMAND), + testBin(API_V3_TEST_COMMAND, 'API_VERSION=v3'), (err, stdout, stderr) => { testResults.push({ suite: 'API V3 Specs\t', From 4a941deece48c7253cbe5a010c2b2156c0288ed5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 23 Nov 2015 21:04:21 +0100 Subject: [PATCH 177/976] remove success field from json responses --- test/api/v3/integration/notFound.test.js | 1 - .../v3/integration/user/auth/POST-register_local.test.js | 7 ------- test/api/v3/unit/middlewares/errorHandler.test.js | 7 ------- test/api/v3/unit/middlewares/response.js | 2 -- test/helpers/api-integration.helper.js | 1 - website/src/middlewares/api-v3/response.js | 4 +--- 6 files changed, 1 insertion(+), 21 deletions(-) diff --git a/test/api/v3/integration/notFound.test.js b/test/api/v3/integration/notFound.test.js index 4c17b13dda..e86b6b35e8 100644 --- a/test/api/v3/integration/notFound.test.js +++ b/test/api/v3/integration/notFound.test.js @@ -5,7 +5,6 @@ describe('notFound Middleware', () => { let request = requester().get('/api/v3/dummy-url'); return expect(request).to.eventually.be.rejected.and.eql({ - success: false, code: 404, error: 'NotFound', message: 'Not found.', diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 8b21447a57..4ad2127cd4 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -39,7 +39,6 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: confirmPassword, })).to.eventually.be.rejected.and.eql({ - success: false, code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -57,7 +56,6 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword, })).to.eventually.be.rejected.and.eql({ - success: false, code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -74,7 +72,6 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ - success: false, code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -93,7 +90,6 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ - success: false, code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -111,7 +107,6 @@ describe('POST /user/auth/local/register', () => { email: email, confirmPassword: confirmPassword, })).to.eventually.be.rejected.and.eql({ - success: false, code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -143,7 +138,6 @@ describe('POST /user/auth/local/register', () => { password: password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ - success: false, code: 401, error: 'NotAuthorized', message: t('usernameTaken'), @@ -161,7 +155,6 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ - success: false, code: 401, error: 'NotAuthorized', message: t('emailTaken'), diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 096d25b850..790eaf448d 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -34,7 +34,6 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(500); expect(res.json).to.be.calledWith({ - success: false, error: 'InternalServerError', message: 'An unexpected error occurred.', }); @@ -51,7 +50,6 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ - success: false, error: 'Error', message: 'Error message', }); @@ -68,7 +66,6 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(500); expect(res.json).to.be.calledWith({ - success: false, error: 'InternalServerError', message: 'An unexpected error occurred.', }); @@ -84,7 +81,6 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ - success: false, error: 'BadRequest', message: 'Bad request.', }); @@ -101,7 +97,6 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(error.statusCode); expect(res.json).to.be.calledWith({ - success: false, error: error.name, message: error.message, }); @@ -117,7 +112,6 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ - success: false, error: 'BadRequest', message: 'Invalid request parameters.', errors: error, @@ -143,7 +137,6 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ - success: false, error: 'BadRequest', message: 'User validation failed.', errors: [ diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js index 5850ef35fd..ca3d160908 100644 --- a/test/api/v3/unit/middlewares/response.js +++ b/test/api/v3/unit/middlewares/response.js @@ -31,7 +31,6 @@ describe('response middleware', function() { expect(res.status).to.be.calledWith(200); expect(res.json).to.be.calledWith({ field: 1, - success: true, }); }); @@ -45,7 +44,6 @@ describe('response middleware', function() { expect(res.status).to.be.calledWith(403); expect(res.json).to.be.calledWith({ field: 1, - success: false, }); }); }); diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index be3e9e66b7..48ee56739d 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -252,7 +252,6 @@ function _requestMaker (user, method, additionalSets) { if (API_V === 'v3') { return reject({ - success: err.response.body.success, code: err.status, error: err.response.body.error, message: err.response.body.message, diff --git a/website/src/middlewares/api-v3/response.js b/website/src/middlewares/api-v3/response.js index 7d60df3dba..707d7ad0bf 100644 --- a/website/src/middlewares/api-v3/response.js +++ b/website/src/middlewares/api-v3/response.js @@ -1,8 +1,6 @@ export default function responseHandler (req, res, next) { res.respond = function respond (status = 200, data = {}) { - res.status(status); - data.success = status >= 400 ? false : true; // TODO the data object should be cloned to avoid pollution? - res.json(data); + res.status(status).json(data); }; next(); From 1bf91030c6fc036d3e609532b15474818f1cfbf7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 23 Nov 2015 21:34:21 +0100 Subject: [PATCH 178/976] format express-validator errors --- test/api/v3/unit/middlewares/errorHandler.test.js | 6 ++++-- website/src/middlewares/api-v3/errorHandler.js | 10 ++++++++-- website/src/middlewares/api-v3/index.js | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 790eaf448d..80e1ab08d1 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -103,7 +103,7 @@ describe('errorHandler', () => { }); it('handle express-validator errors', () => { - let error = [{param: 'param', msg: 'invalid param'}]; + let error = [{param: 'param', msg: 'invalid param', value: 123}]; errorHandler(error, req, res, next); @@ -114,7 +114,9 @@ describe('errorHandler', () => { expect(res.json).to.be.calledWith({ error: 'BadRequest', message: 'Invalid request parameters.', - errors: error, + errors: [ + {param: error[0].param, value: error[0].value, message: error[0].msg} + ], }); }); diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 59bdca98d5..3844caaa88 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -35,7 +35,13 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l // Handle errors by express-validator if (Array.isArray(err) && err[0].param && err[0].msg) { responseErr = new BadRequest(res.t('invalidReqParams')); - responseErr.errors = err; // TODO format + responseErr.errors = err.map((paramErr) => { + return { + message: paramErr.msg, + param: paramErr.param, + value: paramErr.value, + } + }); } // Handle mongoose validation errors @@ -43,8 +49,8 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l responseErr = new BadRequest(err.message); responseErr.errors = map(err.errors, (mongooseErr) => { return { - path: mongooseErr.path, message: mongooseErr.message, + path: mongooseErr.path, value: mongooseErr.value, }; }); diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 56d78add8e..fdb2db7bd8 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -21,7 +21,7 @@ export default function attachMiddlewares (app) { extended: true, // Uses 'qs' library as old connect middleware })); app.use(bodyParser.json()); - app.use(expressValidator()); // TODO config + app.use(expressValidator()); app.use(analytics); app.use(responseHandler); app.use(getUserLanguage); From 6a4ab17c5600dc2f1d3f3f9d415fc4800c7915ad Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 23 Nov 2015 21:43:39 +0100 Subject: [PATCH 179/976] fix linting and add comment --- website/src/middlewares/api-v3/errorHandler.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 3844caaa88..e0ad096d1c 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -32,6 +32,8 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l responseErr.message = err.message; } + // TODO make mongoose and express-validator errors more recognizable + // Handle errors by express-validator if (Array.isArray(err) && err[0].param && err[0].msg) { responseErr = new BadRequest(res.t('invalidReqParams')); @@ -40,7 +42,7 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l message: paramErr.msg, param: paramErr.param, value: paramErr.value, - } + }; }); } From 3f1faf113e28af4cf0c0c9e23aaeabddbeebcaf8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 24 Nov 2015 18:51:48 +0100 Subject: [PATCH 180/976] add baseModel plugin with some tests --- test/api/v3/unit/libs/baseModel.test.js | 59 +++++++++++++++++++++++++ website/src/libs/api-v3/baseModel.js | 53 ++++++++++++++++++++++ website/src/models/user.js | 14 +++--- 3 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 test/api/v3/unit/libs/baseModel.test.js create mode 100644 website/src/libs/api-v3/baseModel.js diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js new file mode 100644 index 0000000000..9cab299d82 --- /dev/null +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -0,0 +1,59 @@ +import baseModel from '../../../../../website/src/libs/api-v3/baseModel'; + +describe('Base model plugin', () => { + let schema = { + add () { + return true; + }, + statics: {}, + options: {}, + pre () { + return true; + }, + }; + + beforeEach(() => { + sandbox.stub(schema, 'add'); + }); + + it('adds a _id field to the schema', () => { + baseModel(schema); + + expect(schema.add).to.be.calledWith(sinon.match({ + _id: sinon.match.object, + })); + }); + + it('can add timestamps fields', () => { + baseModel(schema, {timestamps: true}); + + expect(schema.add).to.be.calledTwice; + }); + + it('can sanitize input objects', () => { + baseModel(schema, { + noSet: ['noUpdateForMe'] + }); + + expect(schema.statics.sanitize).to.exist; + let sanitized = schema.statics.sanitize({ok: true, noUpdateForMe: true}); + + expect(sanitized).to.have.property('ok'); + expect(sanitized).to.have.property('noUpdateForMe'); + expect(sanitized.noUpdateForMe).to.equal(undefined); + }); + + it('can make fields private', () => { + baseModel(schema, { + private: ['amPrivate'] + }); + + expect(schema.options.toObject.transform).to.exist; + let objToTransform = {ok: true, amPrivate: true}; + let privatized = schema.options.toObject.transform({}, objToTransform); + + expect(objToTransform).to.have.property('ok'); + expect(objToTransform).to.have.property('amPrivate'); + expect(objToTransform.amPrivate).to.equal(undefined); + }); +}); diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js new file mode 100644 index 0000000000..c5bc964af8 --- /dev/null +++ b/website/src/libs/api-v3/baseModel.js @@ -0,0 +1,53 @@ +import _ from 'lodash'; +import { uuid } from '../../../../common'; +import validator from 'validator'; + +export default function baseModel (schema, options = {}) { + schema.add({ + _id: { + type: String, + default: uuid.v4, + validate: [validator.isUUID, 'Invalid uuid.'], // TODO check for UUID version + }, + }); + + if (options.timestamps) { + schema.add({ + createdAt: { + type: Date, + default: Date.now, + }, + updatedAt: { + type: Date, + default: Date.now, + }, + }); + } + + if (options.timestamps) { + schema.pre('save', function updateUpdatedAt (next) { + if (!this.isNew) this.updatedAt = Date.now(); + next(); + }); + } + + let noSetFields = ['createdAt', 'updatedAt']; + let privateFields = ['__v']; + + if (Array.isArray(options.noSet)) noSetFields.push(...options.noSet); + schema.statics.sanitize = function sanitize (objToSanitize = {}) { + noSetFields.forEach((fieldPath) => { + _.set(objToSanitize, fieldPath, undefined); // TODO decide wheter to use delete here + }); + + return objToSanitize; + }; + + if (Array.isArray(options.private)) privateFields.push(...options.private); + if (!schema.options.toObject) schema.options.toObject = {}; + schema.options.toObject.transform = function transformToObject (doc, plainObj) { + privateFields.forEach((fieldPath) => { + _.set(plainObj, fieldPath, undefined); // TODO decide wheter to use delete here + }); + }; +} diff --git a/website/src/models/user.js b/website/src/models/user.js index 0462dd5ab1..c67e5f69e1 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -5,19 +5,13 @@ import _ from 'lodash'; import validator from 'validator'; import moment from 'moment'; import TaskSchemas from './task'; +import baseModel from '../libs/api-v3/baseModel'; // import {model as Challenge} from './challenge'; let Schema = mongoose.Schema; // User schema definition export let schema = new Schema({ - // The user _id, stored as a string - // TODO validation - _id: { - type: String, - default: shared.uuid, - }, - // TODO validation apiToken: { type: String, default: shared.uuid, @@ -480,6 +474,12 @@ export let schema = new Schema({ minimize: false, // So empty objects are returned }); +schema.plugin(baseModel, { + noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt'], + private: ['auth.local.hashed_password', 'auth.local.salt'], +}); + + schema.methods.deleteTask = function deleteTask (tid) { this.ops.deleteTask({params: {id: tid}}, () => {}); // TODO remove this whole method, since it just proxies, and change all references to this method }; From 77d25ddc322f118f331fe01b778601c8d5717a2d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 25 Nov 2015 13:43:34 +0100 Subject: [PATCH 181/976] correctly add _id default --- website/src/libs/api-v3/baseModel.js | 2 +- website/src/models/user.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index c5bc964af8..32fa63bf86 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -6,7 +6,7 @@ export default function baseModel (schema, options = {}) { schema.add({ _id: { type: String, - default: uuid.v4, + default: uuid, validate: [validator.isUUID, 'Invalid uuid.'], // TODO check for UUID version }, }); diff --git a/website/src/models/user.js b/website/src/models/user.js index c67e5f69e1..63eea0c504 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -479,7 +479,6 @@ schema.plugin(baseModel, { private: ['auth.local.hashed_password', 'auth.local.salt'], }); - schema.methods.deleteTask = function deleteTask (tid) { this.ops.deleteTask({params: {id: tid}}, () => {}); // TODO remove this whole method, since it just proxies, and change all references to this method }; From 94fc1c9bef63143e42b41224dcb1003309001755 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 25 Nov 2015 15:19:21 +0100 Subject: [PATCH 182/976] baseModel: delete properties instead of setting them to undefined, transform toJSON only not toObject --- package.json | 1 + test/api/v3/unit/libs/baseModel.test.js | 8 ++++---- website/src/libs/api-v3/baseModel.js | 10 +++++----- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 8a3f1c23dd..438423344d 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "newrelic": "~1.23.0", "nib": "~1.0.1", "nodemailer": "^1.9.0", + "object-path": "^0.9.2", "pageres": "^1.0.1", "passport": "~0.2.1", "passport-facebook": "2.0.0", diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 9cab299d82..5c9f2a95dc 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -39,7 +39,7 @@ describe('Base model plugin', () => { let sanitized = schema.statics.sanitize({ok: true, noUpdateForMe: true}); expect(sanitized).to.have.property('ok'); - expect(sanitized).to.have.property('noUpdateForMe'); + expect(sanitized).not.to.have.property('noUpdateForMe'); expect(sanitized.noUpdateForMe).to.equal(undefined); }); @@ -48,12 +48,12 @@ describe('Base model plugin', () => { private: ['amPrivate'] }); - expect(schema.options.toObject.transform).to.exist; + expect(schema.options.toJSON.transform).to.exist; let objToTransform = {ok: true, amPrivate: true}; - let privatized = schema.options.toObject.transform({}, objToTransform); + let privatized = schema.options.toJSON.transform({}, objToTransform); expect(objToTransform).to.have.property('ok'); - expect(objToTransform).to.have.property('amPrivate'); + expect(objToTransform).not.to.have.property('amPrivate'); expect(objToTransform.amPrivate).to.equal(undefined); }); }); diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index 32fa63bf86..ebff377ff2 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -1,6 +1,6 @@ -import _ from 'lodash'; import { uuid } from '../../../../common'; import validator from 'validator'; +import objectPath from 'object-path'; // TODO use lodash's unset once v4 is out export default function baseModel (schema, options = {}) { schema.add({ @@ -37,17 +37,17 @@ export default function baseModel (schema, options = {}) { if (Array.isArray(options.noSet)) noSetFields.push(...options.noSet); schema.statics.sanitize = function sanitize (objToSanitize = {}) { noSetFields.forEach((fieldPath) => { - _.set(objToSanitize, fieldPath, undefined); // TODO decide wheter to use delete here + objectPath.del(objToSanitize, fieldPath); }); return objToSanitize; }; + if (!schema.options.toJSON) schema.options.toJSON = {}; if (Array.isArray(options.private)) privateFields.push(...options.private); - if (!schema.options.toObject) schema.options.toObject = {}; - schema.options.toObject.transform = function transformToObject (doc, plainObj) { + schema.options.toJSON.transform = function transformToObject (doc, plainObj) { privateFields.forEach((fieldPath) => { - _.set(plainObj, fieldPath, undefined); // TODO decide wheter to use delete here + objectPath.del(plainObj, fieldPath); }); }; } From d67836ee1fa77ce6c76f3ba9e0b44aa9558a441e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 25 Nov 2015 15:42:29 +0100 Subject: [PATCH 183/976] allow for additional transform functions for toJSON and sanitize --- test/api/v3/unit/libs/baseModel.test.js | 36 ++++++++++++++++++++++--- website/src/libs/api-v3/baseModel.js | 6 ++++- website/src/models/user.js | 21 +++++++-------- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 5c9f2a95dc..3b50a7f4f5 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -52,8 +52,38 @@ describe('Base model plugin', () => { let objToTransform = {ok: true, amPrivate: true}; let privatized = schema.options.toJSON.transform({}, objToTransform); - expect(objToTransform).to.have.property('ok'); - expect(objToTransform).not.to.have.property('amPrivate'); - expect(objToTransform.amPrivate).to.equal(undefined); + expect(privatized).to.have.property('ok'); + expect(privatized).not.to.have.property('amPrivate'); + }); + + it('accepts a further transform function for toJSON', () => { + let options = { + private: ['amPrivate'], + toJSONTransform: sandbox.stub().returns(true) + }; + + baseModel(schema, options); + + let objToTransform = {ok: true, amPrivate: true}; + let privatized = schema.options.toJSON.transform({}, objToTransform); + + expect(privatized).to.equals(true); + expect(options.toJSONTransform).to.be.calledWith(objToTransform); + }); + + it('accepts a transform function for sanitize', () => { + let options = { + private: ['amPrivate'], + sanitizeTransform: sandbox.stub().returns(true) + }; + + baseModel(schema, options); + + expect(schema.options.toJSON.transform).to.exist; + let objToSanitize = {ok: true, noUpdateForMe: true}; + let sanitized = schema.statics.sanitize(objToSanitize); + + expect(sanitized).to.equals(true); + expect(options.sanitizeTransform).to.be.calledWith(objToSanitize); }); }); diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index ebff377ff2..2de770226b 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -40,7 +40,8 @@ export default function baseModel (schema, options = {}) { objectPath.del(objToSanitize, fieldPath); }); - return objToSanitize; + // Allow a sanitize transform function to be used + return options.sanitizeTransform ? options.sanitizeTransform(objToSanitize) : objToSanitize; }; if (!schema.options.toJSON) schema.options.toJSON = {}; @@ -49,5 +50,8 @@ export default function baseModel (schema, options = {}) { privateFields.forEach((fieldPath) => { objectPath.del(plainObj, fieldPath); }); + + // Allow an additional toJSON transform function to be used + return options.toJSONTransform ? options.toJSONTransform(plainObj) : plainObj; }; } diff --git a/website/src/models/user.js b/website/src/models/user.js index 63eea0c504..e5d9e63c84 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -477,24 +477,21 @@ export let schema = new Schema({ schema.plugin(baseModel, { noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt'], private: ['auth.local.hashed_password', 'auth.local.salt'], + toJSONTransform: function toJSON (doc) { + doc.id = doc._id; + + // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? + doc.filters = {}; + doc._tmp = this._tmp; // be sure to send down drop notifs + + return doc; + }, }); schema.methods.deleteTask = function deleteTask (tid) { this.ops.deleteTask({params: {id: tid}}, () => {}); // TODO remove this whole method, since it just proxies, and change all references to this method }; -schema.methods.toJSON = function toJSON () { - let doc = this.toObject(); - - doc.id = doc._id; - - // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? - doc.filters = {}; - doc._tmp = this._tmp; // be sure to send down drop notifs - - return doc; -}; - // schema.virtual('tasks').get(function () { // var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); // var tasks = _.object(_.pluck(tasks,'id'), tasks); From 9adfd6311fb7e5b6a72d8a4c227fc723cfeccd93 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 27 Nov 2015 17:09:01 +0100 Subject: [PATCH 184/976] fix merge conflict --- website/src/models/user.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 45fb751506..be2274adbd 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -164,22 +164,13 @@ export let schema = new Schema({ inviteParty: {type: Boolean, default: false}, }, ios: { -<<<<<<< HEAD addTask: {type: Boolean, default: false}, editTask: {type: Boolean, default: false}, deleteTask: {type: Boolean, default: false}, filterTask: {type: Boolean, default: false}, groupPets: {type: Boolean, default: false}, + inviteParty: {type: Boolean, default: false}, }, -======= - addTask: {type: Boolean, 'default': false}, - editTask: {type: Boolean, 'default': false}, - deleteTask: {type: Boolean, 'default': false}, - filterTask: {type: Boolean, 'default': false}, - groupPets: {type: Boolean, 'default': false}, - inviteParty: {type: Boolean, 'default': false}, - } ->>>>>>> develop }, dropsEnabled: {type: Boolean, default: false}, itemsEnabled: {type: Boolean, default: false}, From 7d53a4fd5450cc90885e16a492a5a4922a06ac2a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 27 Nov 2015 19:06:26 +0100 Subject: [PATCH 185/976] port task model to es6 and implement discriminators --- tasks/gulp-eslint.js | 1 + website/src/models/task.js | 189 ++++++++++++++++++------------------- website/src/models/user.js | 82 +++++----------- 3 files changed, 112 insertions(+), 160 deletions(-) diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 7cff1a18b2..695bf03c36 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -4,6 +4,7 @@ import eslint from 'gulp-eslint'; const SERVER_FILES = [ './website/src/**/api-v3/**/*.js', './website/src/models/user.js', + './website/src/models/task.js', './website/src/models/emailUnsubscription.js', './website/src/server.js', ]; diff --git a/website/src/models/task.js b/website/src/models/task.js index 8ae24e353a..98fd6bf3c7 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -1,108 +1,99 @@ -// User.js -// ======= -// Defines the user data model (schema) for use via the API. +import mongoose from 'mongoose'; +import shared from '../../../common'; +import moment from 'moment'; +import baseModel from '../libs/api-v3/baseModel'; +import _ from 'lodash'; -// Dependencies -// ------------ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); -var moment = require('moment'); - -// Task Schema -// ----------- - -var TaskSchema = { - //_id:{type: String,'default': helpers.uuid}, - id: {type: String,'default': shared.uuid}, - dateCreated: {type:Date, 'default':Date.now}, - text: String, - notes: {type: String, 'default': ''}, - tags: {type: Schema.Types.Mixed, 'default': {}}, //{ "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, - value: {type: Number, 'default': 0}, // redness - priority: {type: Number, 'default': '1'}, - attribute: {type: String, 'default': "str", enum: ['str','con','int','per']}, - challenge: { - id: {type: 'String', ref:'Challenge'}, - broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED - winner: String // user.profile.name - // group: {type: 'Strign', ref: 'Group'} // if we restore this, rename `id` above to `challenge` - } +let Schema = mongoose.Schema; +let discriminatorOptions = () => { + return {discriminatorKey: 'type'}; // the key that distinguishes task types }; -var HabitSchema = new Schema( - _.defaults({ - type: {type:String, 'default': 'habit'}, - history: Array, // [{date:Date, value:Number}], // this causes major performance problems - up: {type: Boolean, 'default': true}, - down: {type: Boolean, 'default': true} - }, TaskSchema) - , { _id: false, minimize:false } -); +// TODO make sure a task can only update the fields belonging to its type +// We could use discriminators but it looks like when loading from the parent +// Task model the subclasses are not applied - check twice -var collapseChecklist = {type:Boolean, 'default':false}; -var checklist = [{ - completed:{type:Boolean,'default':false}, +export let TaskSchema = new Schema({ + type: {type: String, enum: ['habit', 'todo', 'daily', 'reward'], required: true, default: 'habit'}, text: String, - _id:false, - id: {type:String,'default':shared.uuid} -}]; + notes: {type: String, default: ''}, + tags: {type: Schema.Types.Mixed, default: {}}, // TODO dictionary? { "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, + value: {type: Number, default: 0}, // redness + priority: {type: Number, default: 1}, + attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, + userId: {type: String, ref: 'User'}, // When null it belongs to a challenge -var DailySchema = new Schema( - _.defaults({ - type: {type: String, 'default': 'daily'}, - frequency: {type: String, 'default': 'weekly', enum: ['daily', 'weekly']}, - everyX: {type: Number, 'default': 1}, // e.g. once every X weeks - startDate: {type: Date, 'default': moment().startOf('day').toDate()}, - history: Array, - completed: {type: Boolean, 'default': false}, - repeat: { // used only for 'weekly' frequency, - m: {type: Boolean, 'default': true}, - t: {type: Boolean, 'default': true}, - w: {type: Boolean, 'default': true}, - th: {type: Boolean, 'default': true}, - f: {type: Boolean, 'default': true}, - s: {type: Boolean, 'default': true}, - su: {type: Boolean, 'default': true} + challenge: { + id: {type: String, ref: 'Challenge'}, + taskId: {type: String, ref: 'Task'}, // When null but challenge.id defined it's the original task + broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED TODO enum + winner: String, // user.profile.name TODO necessary? + }, +}, _.default({ + minimize: true, // So empty objects are returned + strict: true, +}, discriminatorOptions())); + +TaskSchema.plugin(baseModel, { + noSet: [], + private: [], + timestamps: true, +}); + +export let Task = mongoose.model('Task', TaskSchema); + +// TODO discriminators: it's very important to check that the options and plugins of the parent schema are used in the sub-schemas too + +// habits and dailies shared fields +let habitDailySchema = () => { + return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems TODO revisit +}; + +// dailys and todos shared fields +let dailyTodoSchema = () => { + return { + completed: {type: Boolean, default: false}, + // Checklist fields (dailies and todos) + collapseChecklist: {type: Boolean, default: false}, + checklist: [{ + completed: {type: Boolean, default: false}, + text: String, + _id: {type: String, default: shared.uuid}, + }], + }; +}; + +export let Habit = Task.discriminator('Habit', new Schema(_.defaults({ + up: {type: Boolean, default: true}, + down: {type: Boolean, default: true}, +}, habitDailySchema())), discriminatorOptions()); + +export let Daily = Task.discriminator('Daily', new Schema(_.defaults({ + frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']}, + everyX: {type: Number, default: 1}, // e.g. once every X weeks + startDate: { + type: Date, + default () { + return moment().startOf('day').toDate(); }, - collapseChecklist:collapseChecklist, - checklist:checklist, - streak: {type: Number, 'default': 0} - }, TaskSchema) - , { _id: false, minimize:false } -) + }, + repeat: { // used only for 'weekly' frequency, + m: {type: Boolean, default: true}, + t: {type: Boolean, default: true}, + w: {type: Boolean, default: true}, + th: {type: Boolean, default: true}, + f: {type: Boolean, default: true}, + s: {type: Boolean, default: true}, + su: {type: Boolean, default: true}, + }, + streak: {type: Number, default: 0}, +}, habitDailySchema(), dailyTodoSchema())), discriminatorOptions()); -var TodoSchema = new Schema( - _.defaults({ - type: {type:String, 'default': 'todo'}, - completed: {type: Boolean, 'default': false}, - dateCompleted: Date, - date: String, // due date for todos // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date - collapseChecklist:collapseChecklist, - checklist:checklist - }, TaskSchema) - , { _id: false, minimize:false } -); +export let Todo = Task.discriminator('Todo', new Schema(_.defaults({ + dateCompleted: Date, + // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date + // TODO change field name + date: String, // due date for todos +}, dailyTodoSchema())), discriminatorOptions()); -var RewardSchema = new Schema( - _.defaults({ - type: {type:String, 'default': 'reward'} - }, TaskSchema) - , { _id: false, minimize:false } -); - -/** - * Workaround for bug when _id & id were out of sync, we can remove this after challenges has been running for a while - */ -//_.each([HabitSchema, DailySchema, TodoSchema, RewardSchema], function(schema){ -// schema.post('init', function(doc){ -// if (!doc.id && doc._id) doc.id = doc._id; -// }) -//}) - -module.exports.TaskSchema = TaskSchema; -module.exports.HabitSchema = HabitSchema; -module.exports.DailySchema = DailySchema; -module.exports.TodoSchema = TodoSchema; -module.exports.RewardSchema = RewardSchema; +export let Reward = Task.discriminator('Reward', new Schema({}), discriminatorOptions()); diff --git a/website/src/models/user.js b/website/src/models/user.js index be2274adbd..5756419195 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -1,10 +1,9 @@ -// User schema and model import mongoose from 'mongoose'; import shared from '../../../common'; import _ from 'lodash'; import validator from 'validator'; import moment from 'moment'; -import TaskSchemas from './task'; +import { model as Task } from './task'; import baseModel from '../libs/api-v3/baseModel'; // import {model as Challenge} from './challenge'; @@ -41,11 +40,9 @@ export let schema = new Schema({ loggedin: {type: Date, default: Date.now}, }, }, - // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which // have been updated (http://goo.gl/gQLz41), but we want *every* update _v: { type: Number, default: 0 }, - achievements: { originalUser: Boolean, habitSurveys: Number, @@ -455,14 +452,14 @@ export let schema = new Schema({ messages: {type: Schema.Types.Mixed, default: {}}, optOut: {type: Boolean, default: false}, }, - - habits: {type: [TaskSchemas.HabitSchema]}, - dailys: {type: [TaskSchemas.DailySchema]}, - todos: {type: [TaskSchemas.TodoSchema]}, - rewards: {type: [TaskSchemas.RewardSchema]}, - + tasksOrder: { + habits: [{type: String, ref: 'Task'}], + dailys: [{type: String, ref: 'Task'}], + todos: [{type: String, ref: 'Task'}], + completedTodos: [{type: String, ref: 'Task'}], + rewards: [{type: String, ref: 'Task'}], + }, extra: Schema.Types.Mixed, - pushDevices: { type: [{ regId: {type: String}, @@ -476,10 +473,10 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt'], + noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function toJSON (doc) { - doc.id = doc._id; + doc.id = doc._id; // TODO remove? // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? doc.filters = {}; @@ -489,31 +486,25 @@ schema.plugin(baseModel, { }, }); -schema.methods.deleteTask = function deleteTask (tid) { - this.ops.deleteTask({params: {id: tid}}, () => {}); // TODO remove this whole method, since it just proxies, and change all references to this method -}; - -// schema.virtual('tasks').get(function () { -// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); -// var tasks = _.object(_.pluck(tasks,'id'), tasks); -// return tasks; -// }); - schema.post('init', function postInitUser (doc) { shared.wrap(doc); }); function _populateDefaultTasks (user, taskTypes) { _.each(taskTypes, (taskType) => { - user[taskType] = _.map(shared.content.userDefaults[taskType], (task) => { - let newTask = _.cloneDeep(task); + // TODO save in own documents + user[taskType] = _.map(shared.content.userDefaults[taskType], (taskDefaults) => { + let newTask; // Render task's text and notes in user's language if (taskType === 'tags') { + newTask = _.cloneDeep(taskDefaults); // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here newTask.id = shared.uuid(); newTask.name = newTask.name(user.preferences.language); } else { + newTask = new Task(taskDefaults); + newTask.userId = user._id; newTask.text = newTask.text(user.preferences.language); if (newTask.notes) { newTask.notes = newTask.notes(user.preferences.language); @@ -534,27 +525,12 @@ function _populateDefaultTasks (user, taskTypes) { function _populateDefaultsForNewUser (user) { let taskTypes; + let iterableFlags = user.toObject().flags; if (user.registeredThrough === 'habitica-web') { taskTypes = ['habits', 'dailys', 'todos', 'rewards', 'tags']; - let tutorialCommonSections = [ - 'habits', - 'dailies', - 'todos', - 'rewards', - 'party', - 'pets', - 'gems', - 'skills', - 'classes', - 'tavern', - 'equipment', - 'items', - 'inviteParty', - ]; - - _.each(tutorialCommonSections, (section) => { + _.each(iterableFlags.tutorial.common, (section) => { user.flags.tutorial.common[section] = true; }); } else { @@ -562,23 +538,7 @@ function _populateDefaultsForNewUser (user) { user.flags.showTour = false; - let tourSections = [ - 'showTour', - 'intro', - 'classes', - 'stats', - 'tavern', - 'party', - 'guilds', - 'challenges', - 'market', - 'pets', - 'mounts', - 'hall', - 'equipment', - ]; - - _.each(tourSections, (section) => { + _.each(iterableFlags.tour, (section) => { user.flags.tour[section] = -2; }); } @@ -668,7 +628,7 @@ schema.methods.unlink = function unlink (options, cb) { if (keep === 'keep') { self.tasks[tid].challenge = {}; } else if (keep === 'remove') { - self.deleteTask(tid); + self.ops.deleteTask({params: {id: tid}}, () => {}); } else if (keep === 'keep-all') { _.each(self.tasks, (t) => { if (t.challenge && t.challenge.id === cid) { @@ -678,7 +638,7 @@ schema.methods.unlink = function unlink (options, cb) { } else if (keep === 'remove-all') { _.each(self.tasks, (t) => { if (t.challenge && t.challenge.id === cid) { - self.deleteTask(t.id); + this.ops.deleteTask({params: {id: tid}}, () => {}); } }); } From cfa776fff3488c888da33cc937361264938e2b9a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 27 Nov 2015 21:18:37 +0100 Subject: [PATCH 186/976] starts fixing default tasks and use discriminator --- website/src/models/challenge.js | 8 ++-- website/src/models/task.js | 17 ++++---- website/src/models/user.js | 74 +++++++++++++++++---------------- 3 files changed, 52 insertions(+), 47 deletions(-) diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index d44b798bc0..a59f0d0939 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -10,10 +10,10 @@ var ChallengeSchema = new Schema({ shortName: String, description: String, official: {type: Boolean,'default':false}, - habits: [TaskSchemas.HabitSchema], - dailys: [TaskSchemas.DailySchema], - todos: [TaskSchemas.TodoSchema], - rewards: [TaskSchemas.RewardSchema], + //habits: [TaskSchemas.HabitSchema], + //dailys: [TaskSchemas.DailySchema], + //todos: [TaskSchemas.TodoSchema], + //rewards: [TaskSchemas.RewardSchema], leader: {type: String, ref: 'User'}, group: {type: String, ref: 'Group'}, timestamp: {type: Date, 'default': Date.now}, diff --git a/website/src/models/task.js b/website/src/models/task.js index 98fd6bf3c7..de488a3d86 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -5,9 +5,10 @@ import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; let Schema = mongoose.Schema; -let discriminatorOptions = () => { - return {discriminatorKey: 'type'}; // the key that distinguishes task types +let discriminatorOptions = { + discriminatorKey: 'type', // the key that distinguishes task types }; +let subDiscriminatorOptions = _.defaults(_.cloneDeep(discriminatorOptions), {_id: false}); // TODO make sure a task can only update the fields belonging to its type // We could use discriminators but it looks like when loading from the parent @@ -29,10 +30,10 @@ export let TaskSchema = new Schema({ broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED TODO enum winner: String, // user.profile.name TODO necessary? }, -}, _.default({ +}, _.defaults({ minimize: true, // So empty objects are returned strict: true, -}, discriminatorOptions())); +}, discriminatorOptions)); TaskSchema.plugin(baseModel, { noSet: [], @@ -66,7 +67,7 @@ let dailyTodoSchema = () => { export let Habit = Task.discriminator('Habit', new Schema(_.defaults({ up: {type: Boolean, default: true}, down: {type: Boolean, default: true}, -}, habitDailySchema())), discriminatorOptions()); +}, habitDailySchema()), subDiscriminatorOptions)); export let Daily = Task.discriminator('Daily', new Schema(_.defaults({ frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']}, @@ -87,13 +88,13 @@ export let Daily = Task.discriminator('Daily', new Schema(_.defaults({ su: {type: Boolean, default: true}, }, streak: {type: Number, default: 0}, -}, habitDailySchema(), dailyTodoSchema())), discriminatorOptions()); +}, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions)); export let Todo = Task.discriminator('Todo', new Schema(_.defaults({ dateCompleted: Date, // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date // TODO change field name date: String, // due date for todos -}, dailyTodoSchema())), discriminatorOptions()); +}, dailyTodoSchema()), subDiscriminatorOptions)); -export let Reward = Task.discriminator('Reward', new Schema({}), discriminatorOptions()); +export let Reward = Task.discriminator('Reward', new Schema({}, subDiscriminatorOptions)); diff --git a/website/src/models/user.js b/website/src/models/user.js index 5756419195..d650dc29ba 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -3,6 +3,7 @@ import shared from '../../../common'; import _ from 'lodash'; import validator from 'validator'; import moment from 'moment'; +import Q from 'q'; import { model as Task } from './task'; import baseModel from '../libs/api-v3/baseModel'; // import {model as Challenge} from './challenge'; @@ -491,36 +492,37 @@ schema.post('init', function postInitUser (doc) { }); function _populateDefaultTasks (user, taskTypes) { - _.each(taskTypes, (taskType) => { - // TODO save in own documents - user[taskType] = _.map(shared.content.userDefaults[taskType], (taskDefaults) => { - let newTask; + if ('tags' in taskTypes) { + user.tags = _.map(shared.content.userDefaults.tags, function(tag){ + let newTag = _.cloneDeep(tag); - // Render task's text and notes in user's language - if (taskType === 'tags') { - newTask = _.cloneDeep(taskDefaults); - // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here - newTask.id = shared.uuid(); - newTask.name = newTask.name(user.preferences.language); - } else { - newTask = new Task(taskDefaults); - newTask.userId = user._id; - newTask.text = newTask.text(user.preferences.language); - if (newTask.notes) { - newTask.notes = newTask.notes(user.preferences.language); - } - - if (newTask.checklist) { - newTask.checklist = _.map(newTask.checklist, (checklistItem) => { - checklistItem.text = checklistItem.text(user.preferences.language); - return checklistItem; - }); - } - } - - return newTask; + // tasks automatically get _id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here + newTag.id = shared.uuid(); + // Render tag's name in user's language + newTag.name = newTag.name(user.preferences.language); + return newTag; }); + } + + let tasksToCreate = []; + _.each(taskTypes, (taskType) => { + let tasksOfType = _.map(shared.content.userDefaults[taskType], (taskDefaults) => { + let newTask = new Task(taskDefaults); + newTask.userId = user._id; + newTask.text = newTask.text(user.preferences.language); + if (newTask.notes) newTask.notes = newTask.notes(user.preferences.language); + if (newTask.checklist) { + newTask.checklist = _.map(newTask.checklist, (checklistItem) => { + checklistItem.text = checklistItem.text(user.preferences.language); + return checklistItem; + }); + } + }); + + tasksToCreate.push(...tasksOfType); }); + + return Task.create(tasksToCreate); } function _populateDefaultsForNewUser (user) { @@ -543,7 +545,7 @@ function _populateDefaultsForNewUser (user) { }); } - _populateDefaultTasks(user, taskTypes); + return _populateDefaultTasks(user, taskTypes); } function _setProfileName (user) { @@ -556,13 +558,10 @@ function _setProfileName (user) { return localUsername || facebookUsername || anonymous; } -schema.pre('save', function postSaveUser (next) { - // Populate new users with default content - if (this.isNew) { - _populateDefaultsForNewUser(this); - } +schema.pre('save', function postSaveUser (next, done) { + next(); - // this.markModified('tasks'); + // TODO remove all unnecessary checks if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { this.preferences.dayStart = 0; } @@ -611,7 +610,12 @@ schema.pre('save', function postSaveUser (next) { if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; this._v++; - next(); + // Populate new users with default content + if (this.isNew) { + _populateDefaultsForNewUser(this) + .then((tasks) => done()) + .catch(done); + } }); schema.methods.unlink = function unlink (options, cb) { From 58d87887e673ea91c1271622f586d3a8dad032c2 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 27 Nov 2015 21:28:49 +0100 Subject: [PATCH 187/976] fix linting --- website/src/models/user.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index d650dc29ba..04c49d036f 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -3,7 +3,6 @@ import shared from '../../../common'; import _ from 'lodash'; import validator from 'validator'; import moment from 'moment'; -import Q from 'q'; import { model as Task } from './task'; import baseModel from '../libs/api-v3/baseModel'; // import {model as Challenge} from './challenge'; @@ -493,7 +492,7 @@ schema.post('init', function postInitUser (doc) { function _populateDefaultTasks (user, taskTypes) { if ('tags' in taskTypes) { - user.tags = _.map(shared.content.userDefaults.tags, function(tag){ + user.tags = _.map(shared.content.userDefaults.tags, (tag) => { let newTag = _.cloneDeep(tag); // tasks automatically get _id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here @@ -505,9 +504,11 @@ function _populateDefaultTasks (user, taskTypes) { } let tasksToCreate = []; + _.each(taskTypes, (taskType) => { let tasksOfType = _.map(shared.content.userDefaults[taskType], (taskDefaults) => { let newTask = new Task(taskDefaults); + newTask.userId = user._id; newTask.text = newTask.text(user.preferences.language); if (newTask.notes) newTask.notes = newTask.notes(user.preferences.language); @@ -613,7 +614,7 @@ schema.pre('save', function postSaveUser (next, done) { // Populate new users with default content if (this.isNew) { _populateDefaultsForNewUser(this) - .then((tasks) => done()) + .then(() => done()) .catch(done); } }); From 90d7f1f6a80cd782761924cb6b44e4fb820ea423 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 28 Nov 2015 13:46:53 +0100 Subject: [PATCH 188/976] improve tasks models, fix a part of user pre save, rename auth controller --- .../controllers/api-v3/{user.js => auth.js} | 0 website/src/controllers/api-v3/tasks.js | 0 website/src/models/task.js | 33 +++++++++++-------- website/src/models/user.js | 23 +++++++++---- 4 files changed, 36 insertions(+), 20 deletions(-) rename website/src/controllers/api-v3/{user.js => auth.js} (100%) create mode 100644 website/src/controllers/api-v3/tasks.js diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/auth.js similarity index 100% rename from website/src/controllers/api-v3/user.js rename to website/src/controllers/api-v3/auth.js diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/website/src/models/task.js b/website/src/models/task.js index de488a3d86..e7760642bf 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -1,5 +1,6 @@ import mongoose from 'mongoose'; import shared from '../../../common'; +import validator from 'validator'; import moment from 'moment'; import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; @@ -15,10 +16,10 @@ let subDiscriminatorOptions = _.defaults(_.cloneDeep(discriminatorOptions), {_id // Task model the subclasses are not applied - check twice export let TaskSchema = new Schema({ - type: {type: String, enum: ['habit', 'todo', 'daily', 'reward'], required: true, default: 'habit'}, - text: String, + type: {type: String, enum: ['Habit', 'Todo', 'Daily', 'Reward'], required: true, default: 'Habit'}, + text: {type: String, required: true}, notes: {type: String, default: ''}, - tags: {type: Schema.Types.Mixed, default: {}}, // TODO dictionary? { "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, + tags: {type: Schema.Types.Mixed, default: {}}, // TODO dictionary? { "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, validate value: {type: Number, default: 0}, // redness priority: {type: Number, default: 1}, attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, @@ -36,12 +37,12 @@ export let TaskSchema = new Schema({ }, discriminatorOptions)); TaskSchema.plugin(baseModel, { - noSet: [], + noSet: ['challenge', 'userId', 'value', 'completed', 'history', 'streak', 'dateCompleted'], // TODO checklist fields editable? private: [], timestamps: true, }); -export let Task = mongoose.model('Task', TaskSchema); +export let TaskModel = mongoose.model('Task', TaskSchema); // TODO discriminators: it's very important to check that the options and plugins of the parent schema are used in the sub-schemas too @@ -58,18 +59,19 @@ let dailyTodoSchema = () => { collapseChecklist: {type: Boolean, default: false}, checklist: [{ completed: {type: Boolean, default: false}, - text: String, - _id: {type: String, default: shared.uuid}, + text: {type: String, required: true}, + _id: {type: String, default: shared.uuid, validate: [validator.isUUID, 'Invalid uuid.']}, }], }; }; -export let Habit = Task.discriminator('Habit', new Schema(_.defaults({ +export let HabitSchema = new Schema(_.defaults({ up: {type: Boolean, default: true}, down: {type: Boolean, default: true}, -}, habitDailySchema()), subDiscriminatorOptions)); +}, habitDailySchema()), subDiscriminatorOptions); +export let HabitModel = TaskModel.discriminator('Habit', HabitSchema); -export let Daily = Task.discriminator('Daily', new Schema(_.defaults({ +export let DailySchema = new Schema(_.defaults({ frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']}, everyX: {type: Number, default: 1}, // e.g. once every X weeks startDate: { @@ -88,13 +90,16 @@ export let Daily = Task.discriminator('Daily', new Schema(_.defaults({ su: {type: Boolean, default: true}, }, streak: {type: Number, default: 0}, -}, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions)); +}, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions); +export let DailyModel = TaskModel.discriminator('Daily', DailySchema); -export let Todo = Task.discriminator('Todo', new Schema(_.defaults({ +export let TodoSchema = new Schema(_.defaults({ dateCompleted: Date, // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date // TODO change field name date: String, // due date for todos -}, dailyTodoSchema()), subDiscriminatorOptions)); +}, dailyTodoSchema()), subDiscriminatorOptions); +export let TodoModel = TaskModel.discriminator('Todo', TodoSchema); -export let Reward = Task.discriminator('Reward', new Schema({}, subDiscriminatorOptions)); +export let RewardSchema = new Schema({}, subDiscriminatorOptions); +export let RewardModel = TaskModel.discriminator('Reward', RewardSchema); diff --git a/website/src/models/user.js b/website/src/models/user.js index 04c49d036f..8c45fb1b2b 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -3,7 +3,8 @@ import shared from '../../../common'; import _ from 'lodash'; import validator from 'validator'; import moment from 'moment'; -import { model as Task } from './task'; +import * as Tasks from './task'; +import Q from 'q'; import baseModel from '../libs/api-v3/baseModel'; // import {model as Challenge} from './challenge'; @@ -491,7 +492,9 @@ schema.post('init', function postInitUser (doc) { }); function _populateDefaultTasks (user, taskTypes) { - if ('tags' in taskTypes) { + let tagsI = taskTypes.indexOf('tags'); + + if (tagsI !== -1) { user.tags = _.map(shared.content.userDefaults.tags, (tag) => { let newTag = _.cloneDeep(tag); @@ -505,9 +508,10 @@ function _populateDefaultTasks (user, taskTypes) { let tasksToCreate = []; + taskTypes = tagsI !== -1 ? _.clone(taskTypes).slice(tagsI, 1) : taskTypes; _.each(taskTypes, (taskType) => { let tasksOfType = _.map(shared.content.userDefaults[taskType], (taskDefaults) => { - let newTask = new Task(taskDefaults); + let newTask = new Tasks[taskType.charAt(0).toUpperCase() + taskType.slice(1)](taskDefaults); newTask.userId = user._id; newTask.text = newTask.text(user.preferences.language); @@ -518,12 +522,19 @@ function _populateDefaultTasks (user, taskTypes) { return checklistItem; }); } + + return newTask.save(); }); - tasksToCreate.push(...tasksOfType); + tasksToCreate.push(...tasksOfType); // TODO find better way since this creates each task individually }); - return Task.create(tasksToCreate); + return Q.all(tasksToCreate) + .then((tasksCreated) => { + _.each(tasksCreated, (task) => { + user.tasksOrder[`${task.type.toLowerCase()}s`].push(task._id); + }); + }); } function _populateDefaultsForNewUser (user) { @@ -559,7 +570,7 @@ function _setProfileName (user) { return localUsername || facebookUsername || anonymous; } -schema.pre('save', function postSaveUser (next, done) { +schema.pre('save', true, function preSaveUser (next, done) { next(); // TODO remove all unnecessary checks From 786845effd3c8516b9cfd19be73d4fe6a258d7ac Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 28 Nov 2015 14:07:33 +0100 Subject: [PATCH 189/976] fix tour/tutorial on signup --- website/src/models/user.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 8c45fb1b2b..69125a86b2 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -544,15 +544,14 @@ function _populateDefaultsForNewUser (user) { if (user.registeredThrough === 'habitica-web') { taskTypes = ['habits', 'dailys', 'todos', 'rewards', 'tags']; - _.each(iterableFlags.tutorial.common, (section) => { + _.each(iterableFlags.tutorial.common, (val, section) => { user.flags.tutorial.common[section] = true; }); } else { taskTypes = ['todos', 'tags']; - user.flags.showTour = false; - _.each(iterableFlags.tour, (section) => { + _.each(iterableFlags.tour, (val, section) => { user.flags.tour[section] = -2; }); } From 6c904330e76829b879833ae31e56686b01daabe5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 28 Nov 2015 17:47:32 +0100 Subject: [PATCH 190/976] misc fixes, add createTask route and ability to get tasks by type to getTasks route --- .eslintrc | 2 +- common/locales/en/api-v3.json | 5 +- website/src/controllers/api-v3/auth.js | 2 +- website/src/controllers/api-v3/tasks.js | 97 +++++++++++++++++++++++++ website/src/models/user.js | 2 +- 5 files changed, 104 insertions(+), 4 deletions(-) diff --git a/.eslintrc b/.eslintrc index 0aafa5a5e4..b6bfb0f4fa 100644 --- a/.eslintrc +++ b/.eslintrc @@ -83,7 +83,7 @@ "max-nested-callbacks": [2, 3], "new-cap": 2, "new-parens": 2, - "newline-after-var": 2, + "newline-after-var": 0, "no-array-constructor": 2, "no-continue": 2, "no-lonely-if": 2, diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 526cf273bf..5227ffde67 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -14,5 +14,8 @@ "onlyFbSupported": "Only Facebook supported currently.", "cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", "onlySocialAttachLocal": "Local auth can only be added to a social account.", - "invalidReqParams": "Invalid request parameters." + "invalidReqParams": "Invalid request parameters.", + "taskIdRequired": "\"taskId\" must be a valid UUID", + "taskNotFound": "Task not found.", + "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\"." } diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 5afd792d90..7e7668ff9a 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -244,7 +244,7 @@ api.loginSocial = { * @apiName UserDeleteSocial * @apiGroup User * - * @apiSuccess {Boolean=true} success Always true + * @apiSuccess {Object} response Empty object */ api.deleteSocial = { method: 'DELETE', diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e69de29bb2..a6bfd83c78 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -0,0 +1,97 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import * as Tasks from '../../models/tasks'; +import { NotFound } from '../../libs/errors'; + +let api = {}; + +/** + * @api {post} /tasks Create a new task + * @apiVersion 3.0.0 + * @apiName CreateTask + * @apiGroup Task + * + * @apiSuccess {Object} task The newly created task + */ +api.createTask = { + method: 'POST', + url: '/tasks', + middlewares: [authWithHeaders()], + handler (req, res, next) { + req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(['habit', 'daily', 'todo', 'reward']); + + let user = res.locals.user; + let taskType = req.body.type; + + let newTask = new Tasks[`${taskType.charAt(0).toUpperCase() + taskType.slice(1)}Model`](Tasks.Task.sanitize(req.body)); + newTask.userId = user._id; + + newTask.save() + .then((task) => res.respond(201, task)) + .catch(next); + }, +}; + +/** + * @api {get} /tasks Get an user's tasks + * @apiVersion 3.0.0 + * @apiName GetTasks + * @apiGroup Task + * + * @apiParam {string="habit","daily","todo","reward"} type Optional queyr parameter to return just a type of tasks + * + * @apiSuccess {Array} tasks An array of task objects + */ +api.getTasks = { + method: 'GET', + url: '/tasks', + middlewares: [authWithHeaders()], + handler (req, res, next) { + req.checkQuery('type', res.t('invalidTaskType')).isIn(['habit', 'daily', 'todo', 'reward']); + + let user = res.locals.user; + let query = {userId: user._id}; + let type = req.query.type; + if (type) query.type = type.charAt(0).toUpperCase() + type.slice(1); // task.ype is stored with firt uppercase letter + + Tasks.TaskModel.find(query).exec() + .then((tasks) => res.respond(200, tasks)) + .catch(next); + }, +}; + +/** + * @api {get} /task/:taskId Get a task given its id + * @apiVersion 3.0.0 + * @apiName GetTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * + * @apiSuccess {object} task The task object + */ +api.getTask = { + method: 'GET', + url: '/tasks/:taskId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + + Tasks.TaskModel.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + res.respond(200, task); + }) + .catch(next); + }, +}; +// api.updateTask +// api.deleteTask +// api.score +// api.scoreChecklist + +export default api; diff --git a/website/src/models/user.js b/website/src/models/user.js index 69125a86b2..45f21b6e4d 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -539,7 +539,7 @@ function _populateDefaultTasks (user, taskTypes) { function _populateDefaultsForNewUser (user) { let taskTypes; - let iterableFlags = user.toObject().flags; + let iterableFlags = user.flags.toObject(); if (user.registeredThrough === 'habitica-web') { taskTypes = ['habits', 'dailys', 'todos', 'rewards', 'tags']; From c2dac2c494b0597d24f9c42ed4af39f24f807412 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 28 Nov 2015 18:05:07 +0100 Subject: [PATCH 191/976] fix import paths --- website/src/controllers/api-v3/tasks.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index a6bfd83c78..531be2a40f 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,6 +1,6 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import * as Tasks from '../../models/tasks'; -import { NotFound } from '../../libs/errors'; +import * as Tasks from '../../models/task'; +import { NotFound } from '../../libs/api-v3/errors'; let api = {}; From 454aa3731cc612c0e580546f043be734d4a4950f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 28 Nov 2015 18:18:28 +0100 Subject: [PATCH 192/976] add tasks to user.tasksOrder or creation --- website/src/controllers/api-v3/tasks.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 531be2a40f..8604ef54de 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,6 +1,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import * as Tasks from '../../models/task'; import { NotFound } from '../../libs/api-v3/errors'; +import Q from 'q'; let api = {}; @@ -25,8 +26,13 @@ api.createTask = { let newTask = new Tasks[`${taskType.charAt(0).toUpperCase() + taskType.slice(1)}Model`](Tasks.Task.sanitize(req.body)); newTask.userId = user._id; - newTask.save() - .then((task) => res.respond(201, task)) + user.tasksOrder[taskType].unshift(newTask._id); + + Q.all([ + newTask.save(), + user.save(), + ]) + .then(([task]) => res.respond(201, task)) .catch(next); }, }; From 6849fd49be1c3034e6715c4624170779162452ac Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 29 Nov 2015 17:47:10 +0100 Subject: [PATCH 193/976] add ability to remove a task --- website/src/controllers/api-v3/tasks.js | 56 +++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 8604ef54de..6bad74c4d4 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -95,9 +95,57 @@ api.getTask = { .catch(next); }, }; -// api.updateTask -// api.deleteTask -// api.score -// api.scoreChecklist + +// Remove a task from user.tasksOrder +function _removeTaskTasksOrder (user, taskId) { + let types = ['habits', 'dailys', 'todos', 'rewards']; + + // Loop through all lists and when the task is found, remove it and return + for (let i = 0; i < types.length; i++) { + let list = user.tasksOrder[types[i]]; + let index = list.indexOf(taskId); + + if (index !== -1) { + list.splice(index, 1); + break; + } + } + + return; +} + +/** + * @api {delete} /task/:taskId Delete a task given its id + * @apiVersion 3.0.0 + * @apiName DeleteTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * + * @apiSuccess {object} empty An empty object + */ +api.deleteTask = { + method: 'GET', + url: '/tasks/:taskId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + _removeTaskTasksOrder(user, req.params.taskId); + + // TODO should we block deleting challenges tasks? both when userId is defined and when not + // Remove the task and save the user + Q.all([ + Tasks.TaskModel.remove({ + _id: req.params.taskId, + userId: user._id, + }), + user.save(), + ]) + .then(() => res.respond(200, {})) + .catch(next); + }, +}; export default api; From 5291753841d0849c546d4dd810a576bc82924429 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 29 Nov 2015 18:29:35 +0100 Subject: [PATCH 194/976] prevent the deletion of challenge tasks --- website/src/controllers/api-v3/tasks.js | 31 +++++++++++++++---------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 6bad74c4d4..1b9eddeb7c 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,6 +1,9 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import * as Tasks from '../../models/task'; -import { NotFound } from '../../libs/api-v3/errors'; +import { + NotFound, + NotAuthorized, +} from '../../libs/api-v3/errors'; import Q from 'q'; let api = {}; @@ -115,7 +118,7 @@ function _removeTaskTasksOrder (user, taskId) { } /** - * @api {delete} /task/:taskId Delete a task given its id + * @api {delete} /task/:taskId Delete a user task given its id * @apiVersion 3.0.0 * @apiName DeleteTask * @apiGroup Task @@ -132,17 +135,21 @@ api.deleteTask = { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - _removeTaskTasksOrder(user, req.params.taskId); - // TODO should we block deleting challenges tasks? both when userId is defined and when not - // Remove the task and save the user - Q.all([ - Tasks.TaskModel.remove({ - _id: req.params.taskId, - userId: user._id, - }), - user.save(), - ]) + Tasks.TaskModel.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.challenge.id) throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + + _removeTaskTasksOrder(user, req.params.taskId); + return Q.all([ + user.save(), + task.remove(), + ]); + }) .then(() => res.respond(200, {})) .catch(next); }, From 3002db3d75690a727a5752e86da934dbde24c779 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 29 Nov 2015 19:05:24 +0100 Subject: [PATCH 195/976] add ability to update tasks, can pass additional fields to Model.sanitize at runtime --- common/locales/en/api-v3.json | 3 +- test/api/v3/unit/libs/baseModel.test.js | 14 +++++++++ website/src/controllers/api-v3/tasks.js | 41 +++++++++++++++++++++++-- website/src/libs/api-v3/baseModel.js | 5 +-- website/src/models/task.js | 8 +++-- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 5227ffde67..fd28563c87 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -17,5 +17,6 @@ "invalidReqParams": "Invalid request parameters.", "taskIdRequired": "\"taskId\" must be a valid UUID", "taskNotFound": "Task not found.", - "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\"." + "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", + "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted." } diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 3b50a7f4f5..98835b1c29 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -43,6 +43,20 @@ describe('Base model plugin', () => { expect(sanitized.noUpdateForMe).to.equal(undefined); }); + it('accepts an array of additional fields to sanitize at runtime', () => { + baseModel(schema, { + noSet: ['noUpdateForMe'] + }); + + expect(schema.statics.sanitize).to.exist; + let sanitized = schema.statics.sanitize({ok: true, noUpdateForMe: true, usuallySettable: true}, ['usuallySettable']); + + expect(sanitized).to.have.property('ok'); + expect(sanitized).not.to.have.property('noUpdateForMe'); + expect(sanitized).not.to.have.property('usuallySettable'); + }); + + it('can make fields private', () => { baseModel(schema, { private: ['amPrivate'] diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 1b9eddeb7c..e6f9eb41e4 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -5,6 +5,7 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import Q from 'q'; +import _ from 'lodash'; let api = {}; @@ -26,7 +27,7 @@ api.createTask = { let user = res.locals.user; let taskType = req.body.type; - let newTask = new Tasks[`${taskType.charAt(0).toUpperCase() + taskType.slice(1)}Model`](Tasks.Task.sanitize(req.body)); + let newTask = new Tasks[`${taskType.charAt(0).toUpperCase() + taskType.slice(1)}Model`](Tasks.TaskModel.sanitize(req.body)); newTask.userId = user._id; user.tasksOrder[taskType].unshift(newTask._id); @@ -35,8 +36,8 @@ api.createTask = { newTask.save(), user.save(), ]) - .then(([task]) => res.respond(201, task)) - .catch(next); + .then(([task]) => res.respond(201, task)) + .catch(next); }, }; @@ -99,6 +100,40 @@ api.getTask = { }, }; +/** + * @api {put} /task/:taskId Update a task + * @apiVersion 3.0.0 + * @apiName GetTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * + * @apiSuccess {object} task The updated task + */ +api.updateTask = { + method: 'GET', + url: '/tasks/:taskId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + + Tasks.TaskModel.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + // TODO merge goes deep into objects, it's ok? + // TODO also check that array fields are updated correctly without marking modified + _.merge(task, Tasks.TaskModel.sanitizeUpdate(req.body)); + return task.save(); + }) + .then((savedTask) => res.respond(200, savedTask)) + .catch(next); + }, +}; + // Remove a task from user.tasksOrder function _removeTaskTasksOrder (user, taskId) { let types = ['habits', 'dailys', 'todos', 'rewards']; diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index 2de770226b..c0331bf924 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -35,8 +35,9 @@ export default function baseModel (schema, options = {}) { let privateFields = ['__v']; if (Array.isArray(options.noSet)) noSetFields.push(...options.noSet); - schema.statics.sanitize = function sanitize (objToSanitize = {}) { - noSetFields.forEach((fieldPath) => { + // This method accepts an additional array of fields to be sanitized that can be passed at runtime + schema.statics.sanitize = function sanitize (objToSanitize = {}, additionalFields = []) { + noSetFields.concat(additionalFields).forEach((fieldPath) => { objectPath.del(objToSanitize, fieldPath); }); diff --git a/website/src/models/task.js b/website/src/models/task.js index e7760642bf..0fecf5f0ed 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -42,9 +42,13 @@ TaskSchema.plugin(baseModel, { timestamps: true, }); -export let TaskModel = mongoose.model('Task', TaskSchema); +// A list of additional fields that cannot be updated (but can be set on creation) +let noUpdate = ['_id', 'type']; +TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { + return TaskModel.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define +}; -// TODO discriminators: it's very important to check that the options and plugins of the parent schema are used in the sub-schemas too +export let TaskModel = mongoose.model('Task', TaskSchema); // habits and dailies shared fields let habitDailySchema = () => { From ebdfe4c49bc35fbc645034a6820370e28f8b5ea5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 30 Nov 2015 17:25:40 +0100 Subject: [PATCH 196/976] add checklists routes --- common/locales/en/api-v3.json | 5 +- website/src/controllers/api-v3/tasks.js | 170 +++++++++++++++++++++++- 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index fd28563c87..8a0c5d8930 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -18,5 +18,8 @@ "taskIdRequired": "\"taskId\" must be a valid UUID", "taskNotFound": "Task not found.", "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", - "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted." + "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.", + "checklistOnlyDailyTodo": "Checklists are supported only on dailies and todos", + "checklistItemNotFound": "No checklist item was wound with given id.", + "itemIdRequired": "\"itemId\" must be a valid UUID" } diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e6f9eb41e4..04b431db81 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -3,6 +3,7 @@ import * as Tasks from '../../models/task'; import { NotFound, NotAuthorized, + BadRequest, } from '../../libs/api-v3/errors'; import Q from 'q'; import _ from 'lodash'; @@ -103,7 +104,7 @@ api.getTask = { /** * @api {put} /task/:taskId Update a task * @apiVersion 3.0.0 - * @apiName GetTask + * @apiName UpdateTask * @apiGroup Task * * @apiParam {UUID} taskId The task _id @@ -111,12 +112,14 @@ api.getTask = { * @apiSuccess {object} task The updated task */ api.updateTask = { - method: 'GET', + method: 'PUT', url: '/tasks/:taskId', middlewares: [authWithHeaders()], handler (req, res, next) { let user = res.locals.user; + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + // TODO check that req.body isn't empty Tasks.TaskModel.findOne({ _id: req.params.taskId, @@ -124,6 +127,12 @@ api.updateTask = { }).exec() .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); + + // If checklist is updated -> replace the original one + if (req.body.checklist) { + delete req.body.checklist; + task.checklist = req.body.checklist; + } // TODO merge goes deep into objects, it's ok? // TODO also check that array fields are updated correctly without marking modified _.merge(task, Tasks.TaskModel.sanitizeUpdate(req.body)); @@ -134,6 +143,163 @@ api.updateTask = { }, }; +/** + * @api {post} /tasks/:taskId/checklist/addItem Add an item to a checklist, creating the checklist if it doesn't exist + * @apiVersion 3.0.0 + * @apiName AddChecklistItem + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * + * @apiSuccess {object} task The updated task + */ +api.addChecklistItem = { + method: 'POST', + url: '/tasks/:taskId/checklist/addItem', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + // TODO check that req.body isn't empty and is an array + + Tasks.TaskModel.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'Daily' && task.type !== 'Todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + + task.checklist.push(req.body); + return task.save(); + }) + .then((savedTask) => res.respond(200, savedTask)) // TODO what to return + .catch(next); + }, +}; + +/** + * @api {post} /tasks/:taskId/checklist/:itemId/score Score a checklist item + * @apiVersion 3.0.0 + * @apiName ScoreChecklistItem + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * @apiParam {UUID} itemId The checklist item _id + * + * @apiSuccess {object} task The updated task + */ +api.scoreCheckListItem = { + method: 'POST', + url: '/tasks/:taskId/checklist/:itemId/score', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); + + Tasks.TaskModel.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'Daily' && task.type !== 'Todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + + let item = _.find(task.checklist, {_id: req.params.itemId}); + + if (!item) throw new NotFound(res.t('checklistItemNotFound')); + item.completed = !item.completed; + return task.save(); + }) + .then((savedTask) => res.respond(200, savedTask)) // TODO what to return + .catch(next); + }, +}; + +/** + * @api {put} /tasks/:taskId/checklist/:itemId Update a checklist item + * @apiVersion 3.0.0 + * @apiName UpdateChecklistItem + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * @apiParam {UUID} itemId The checklist item _id + * + * @apiSuccess {object} task The updated task + */ +api.updateChecklistItem = { + method: 'PUT', + url: '/tasks/:taskId/checklist/:itemId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); + + Tasks.TaskModel.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'Daily' && task.type !== 'Todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + + let item = _.find(task.checklist, {_id: req.params.itemId}); + if (!item) throw new NotFound(res.t('checklistItemNotFound')); + + delete req.body.id; // Simple sanitization to prevent the ID to be changed + _.merge(item, req.body); + return task.save(); + }) + .then((savedTask) => res.respond(200, savedTask)) // TODO what to return + .catch(next); + }, +}; + +/** + * @api {delete} /tasks/:taskId/checklist/:itemId Remove a checklist item + * @apiVersion 3.0.0 + * @apiName RemoveChecklistItem + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * @apiParam {UUID} itemId The checklist item _id + * + * @apiSuccess {object} empty An empty object + */ +api.removeChecklistItem = { + method: 'DELETE', + url: '/tasks/:taskId/checklist/:itemId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); + + Tasks.TaskModel.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'Daily' && task.type !== 'Todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + + let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); + if (itemI === -1) throw new NotFound(res.t('checklistItemNotFound')); + + task.checklist.splice(itemI, 1); + return task.save(); + }) + .then(() => res.respond(200, {})) // TODO what to return + .catch(next); + }, +}; + // Remove a task from user.tasksOrder function _removeTaskTasksOrder (user, taskId) { let types = ['habits', 'dailys', 'todos', 'rewards']; From 1bd794b5e3c69477ae941ee1955d9a73b64c2da4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 30 Nov 2015 19:38:53 +0100 Subject: [PATCH 197/976] simplify tasks naming --- website/src/controllers/api-v3/tasks.js | 40 ++++++++++++------------- website/src/models/task.js | 18 +++++------ website/src/models/user.js | 4 +-- 3 files changed, 29 insertions(+), 33 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 04b431db81..e1a31c2079 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -23,12 +23,12 @@ api.createTask = { url: '/tasks', middlewares: [authWithHeaders()], handler (req, res, next) { - req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(['habit', 'daily', 'todo', 'reward']); + req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes); let user = res.locals.user; let taskType = req.body.type; - let newTask = new Tasks[`${taskType.charAt(0).toUpperCase() + taskType.slice(1)}Model`](Tasks.TaskModel.sanitize(req.body)); + let newTask = new Tasks[taskType](Tasks.Task.sanitize(req.body)); newTask.userId = user._id; user.tasksOrder[taskType].unshift(newTask._id); @@ -57,14 +57,14 @@ api.getTasks = { url: '/tasks', middlewares: [authWithHeaders()], handler (req, res, next) { - req.checkQuery('type', res.t('invalidTaskType')).isIn(['habit', 'daily', 'todo', 'reward']); + req.checkQuery('type', res.t('invalidTaskType')).isIn(Tasks.tasksTypes); let user = res.locals.user; let query = {userId: user._id}; let type = req.query.type; - if (type) query.type = type.charAt(0).toUpperCase() + type.slice(1); // task.ype is stored with firt uppercase letter + if (type) query.type = type; - Tasks.TaskModel.find(query).exec() + Tasks.Task.find(query).exec() .then((tasks) => res.respond(200, tasks)) .catch(next); }, @@ -89,7 +89,7 @@ api.getTask = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - Tasks.TaskModel.findOne({ + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, }).exec() @@ -121,7 +121,7 @@ api.updateTask = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty - Tasks.TaskModel.findOne({ + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, }).exec() @@ -135,7 +135,7 @@ api.updateTask = { } // TODO merge goes deep into objects, it's ok? // TODO also check that array fields are updated correctly without marking modified - _.merge(task, Tasks.TaskModel.sanitizeUpdate(req.body)); + _.merge(task, Tasks.Task.sanitizeUpdate(req.body)); return task.save(); }) .then((savedTask) => res.respond(200, savedTask)) @@ -163,13 +163,13 @@ api.addChecklistItem = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty and is an array - Tasks.TaskModel.findOne({ + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, }).exec() .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'Daily' && task.type !== 'Todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); task.checklist.push(req.body); return task.save(); @@ -200,13 +200,13 @@ api.scoreCheckListItem = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); - Tasks.TaskModel.findOne({ + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, }).exec() .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'Daily' && task.type !== 'Todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); let item = _.find(task.checklist, {_id: req.params.itemId}); @@ -240,13 +240,13 @@ api.updateChecklistItem = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); - Tasks.TaskModel.findOne({ + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, }).exec() .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'Daily' && task.type !== 'Todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); let item = _.find(task.checklist, {_id: req.params.itemId}); if (!item) throw new NotFound(res.t('checklistItemNotFound')); @@ -281,13 +281,13 @@ api.removeChecklistItem = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); - Tasks.TaskModel.findOne({ + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, }).exec() .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'Daily' && task.type !== 'Todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); if (itemI === -1) throw new NotFound(res.t('checklistItemNotFound')); @@ -302,11 +302,9 @@ api.removeChecklistItem = { // Remove a task from user.tasksOrder function _removeTaskTasksOrder (user, taskId) { - let types = ['habits', 'dailys', 'todos', 'rewards']; - // Loop through all lists and when the task is found, remove it and return - for (let i = 0; i < types.length; i++) { - let list = user.tasksOrder[types[i]]; + for (let i = 0; i < Tasks.tasksTypes.length; i++) { + let list = user.tasksOrder[Tasks.tasksTypes[i]]; let index = list.indexOf(taskId); if (index !== -1) { @@ -337,7 +335,7 @@ api.deleteTask = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - Tasks.TaskModel.findOne({ + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, }).exec() diff --git a/website/src/models/task.js b/website/src/models/task.js index 0fecf5f0ed..9228ad4788 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -11,12 +11,10 @@ let discriminatorOptions = { }; let subDiscriminatorOptions = _.defaults(_.cloneDeep(discriminatorOptions), {_id: false}); -// TODO make sure a task can only update the fields belonging to its type -// We could use discriminators but it looks like when loading from the parent -// Task model the subclasses are not applied - check twice +export let tasksTypes = ['habit', 'daily', 'todo', 'reward']; export let TaskSchema = new Schema({ - type: {type: String, enum: ['Habit', 'Todo', 'Daily', 'Reward'], required: true, default: 'Habit'}, + type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]}, text: {type: String, required: true}, notes: {type: String, default: ''}, tags: {type: Schema.Types.Mixed, default: {}}, // TODO dictionary? { "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, validate @@ -45,10 +43,10 @@ TaskSchema.plugin(baseModel, { // A list of additional fields that cannot be updated (but can be set on creation) let noUpdate = ['_id', 'type']; TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { - return TaskModel.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define + return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define }; -export let TaskModel = mongoose.model('Task', TaskSchema); +export let Task = mongoose.model('Task', TaskSchema); // habits and dailies shared fields let habitDailySchema = () => { @@ -73,7 +71,7 @@ export let HabitSchema = new Schema(_.defaults({ up: {type: Boolean, default: true}, down: {type: Boolean, default: true}, }, habitDailySchema()), subDiscriminatorOptions); -export let HabitModel = TaskModel.discriminator('Habit', HabitSchema); +export let Habit = Task.discriminator('habit', HabitSchema); export let DailySchema = new Schema(_.defaults({ frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']}, @@ -95,7 +93,7 @@ export let DailySchema = new Schema(_.defaults({ }, streak: {type: Number, default: 0}, }, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions); -export let DailyModel = TaskModel.discriminator('Daily', DailySchema); +export let Daily = Task.discriminator('daily', DailySchema); export let TodoSchema = new Schema(_.defaults({ dateCompleted: Date, @@ -103,7 +101,7 @@ export let TodoSchema = new Schema(_.defaults({ // TODO change field name date: String, // due date for todos }, dailyTodoSchema()), subDiscriminatorOptions); -export let TodoModel = TaskModel.discriminator('Todo', TodoSchema); +export let Todo = Task.discriminator('todo', TodoSchema); export let RewardSchema = new Schema({}, subDiscriminatorOptions); -export let RewardModel = TaskModel.discriminator('Reward', RewardSchema); +export let Reward = Task.discriminator('reward', RewardSchema); diff --git a/website/src/models/user.js b/website/src/models/user.js index 45f21b6e4d..7e14b29c86 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -511,7 +511,7 @@ function _populateDefaultTasks (user, taskTypes) { taskTypes = tagsI !== -1 ? _.clone(taskTypes).slice(tagsI, 1) : taskTypes; _.each(taskTypes, (taskType) => { let tasksOfType = _.map(shared.content.userDefaults[taskType], (taskDefaults) => { - let newTask = new Tasks[taskType.charAt(0).toUpperCase() + taskType.slice(1)](taskDefaults); + let newTask = new Tasks[taskType](taskDefaults); newTask.userId = user._id; newTask.text = newTask.text(user.preferences.language); @@ -532,7 +532,7 @@ function _populateDefaultTasks (user, taskTypes) { return Q.all(tasksToCreate) .then((tasksCreated) => { _.each(tasksCreated, (task) => { - user.tasksOrder[`${task.type.toLowerCase()}s`].push(task._id); + user.tasksOrder[`${task.type}s`].push(task._id); }); }); } From 67bcfde6dc73b32f24b6b1a54e80a80f268c317c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 30 Nov 2015 20:14:53 +0100 Subject: [PATCH 198/976] implement move task --- common/locales/en/api-v3.json | 3 +- website/src/controllers/api-v3/tasks.js | 50 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 8a0c5d8930..d9fb392743 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -21,5 +21,6 @@ "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.", "checklistOnlyDailyTodo": "Checklists are supported only on dailies and todos", "checklistItemNotFound": "No checklist item was wound with given id.", - "itemIdRequired": "\"itemId\" must be a valid UUID" + "itemIdRequired": "\"itemId\" must be a valid UUID", + "positionRequired": "\"position\" is required and must be a number." } diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e1a31c2079..c6209de2a4 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -143,6 +143,56 @@ api.updateTask = { }, }; +// completed todos cannot be moved, they'll be returned ordered by date of completion +/** + * @api {put} /tasks/move/:taskId/to/:position Move a task to a new position + * @apiVersion 3.0.0 + * @apiName MoveTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * @apiParam {Number} position Where to move the task (-1 means push to bottom) + * + * @apiSuccess {object} emoty An empty object + */ +api.moveTask = { + method: 'POST', + url: '/tasks/move/:taskId/to/:position', + middlewares: [authWithHeaders()], + handler (req, res, next) { + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); + + let user = res.locals.user; + let to = Number(req.params.position); + + Tasks.Task.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + let order = user.tasksOrder[`${task.type}s`]; + let currentIndex = order.indexOf(task._id); + + // If for some reason the task isn't ordered (should never happen) + // or if the task is moved to a non existing position + // or if the task is moved to postion -1 (push to bottom) + // -> push task at end of list + if (currentIndex === -1 || !order[to] || to === -1) { + order.push(task._id); + } else { + let taskToMove = order.splice(currentIndex, 1)[0]; + order.splice(to, 0, taskToMove); + } + + return user.save(); + }) + .then(() => res.respond(200, {})) // TODO what to return + .catch(next); + }, +}; + /** * @api {post} /tasks/:taskId/checklist/addItem Add an item to a checklist, creating the checklist if it doesn't exist * @apiVersion 3.0.0 From 93fcc7957e9792308c7f818f189345d3fef6bc6f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 30 Nov 2015 20:22:22 +0100 Subject: [PATCH 199/976] fix trying to move completed todos --- common/locales/en/api-v3.json | 3 ++- website/src/controllers/api-v3/tasks.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index d9fb392743..9f4346c414 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -22,5 +22,6 @@ "checklistOnlyDailyTodo": "Checklists are supported only on dailies and todos", "checklistItemNotFound": "No checklist item was wound with given id.", "itemIdRequired": "\"itemId\" must be a valid UUID", - "positionRequired": "\"position\" is required and must be a number." + "positionRequired": "\"position\" is required and must be a number.", + "cantMoveCompletedTodo": "Can't move a completed todo." } diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index c6209de2a4..e4ba525115 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -172,6 +172,7 @@ api.moveTask = { }).exec() .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type === 'todo' && task.completed) throw new NotFound(res.t('cantMoveCompletedTodo')); let order = user.tasksOrder[`${task.type}s`]; let currentIndex = order.indexOf(task._id); From fde47bdc90f84db4c04b147a7ed8eb94daa23a19 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 30 Nov 2015 21:00:09 +0100 Subject: [PATCH 200/976] add ability to get completed todos (only 30 for now) --- website/src/controllers/api-v3/tasks.js | 36 +++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e4ba525115..f1aeb639e2 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -48,7 +48,8 @@ api.createTask = { * @apiName GetTasks * @apiGroup Task * - * @apiParam {string="habit","daily","todo","reward"} type Optional queyr parameter to return just a type of tasks + * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks + * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo" * * @apiSuccess {Array} tasks An array of task objects */ @@ -62,11 +63,36 @@ api.getTasks = { let user = res.locals.user; let query = {userId: user._id}; let type = req.query.type; - if (type) query.type = type; - Tasks.Task.find(query).exec() - .then((tasks) => res.respond(200, tasks)) - .catch(next); + if (type) { + query.type = type; + if (type === 'todo') query.completed = false; // Exclude completed todos + } else { + query.$and = [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ]; + } + + if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { + let queryCompleted = Tasks.Task.find({ + type: 'todo', + completed: true, + }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos + dateCompleted: 1, + }); + + Q.all([ + queryCompleted.exec(), + Tasks.Task.find(query).exec(), + ]) + .then((results) => res.respond(200, results[1].concat(results[0]))) + .catch(next); + } else { + Tasks.Task.find(query).exec() + .then((tasks) => res.respond(200, tasks)) + .catch(next); + } }, }; From 427c805ea506e685c307b36364847d3dceb0b38f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 2 Dec 2015 11:22:53 +0100 Subject: [PATCH 201/976] fix user tests and misc changes --- common/locales/en/api-v3.json | 3 +- .../user/auth/POST-register_local.test.js | 16 +++---- website/src/controllers/api-v3/tasks.js | 42 +++++++++++++++++-- website/src/models/task.js | 10 ++--- website/src/models/user.js | 26 ++++++------ 5 files changed, 68 insertions(+), 29 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 9f4346c414..b01d0eed99 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -23,5 +23,6 @@ "checklistItemNotFound": "No checklist item was wound with given id.", "itemIdRequired": "\"itemId\" must be a valid UUID", "positionRequired": "\"position\" is required and must be a number.", - "cantMoveCompletedTodo": "Can't move a completed todo." + "cantMoveCompletedTodo": "Can't move a completed todo.", + "directionUpDown": "\"direction\" is required and must be 'up' or 'down'" } diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 4ad2127cd4..0e11e7b924 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -194,10 +194,10 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: password, }).then((user) => { - expect(user.todos).to.not.be.empty; - expect(user.dailys).to.be.empty; - expect(user.habits).to.be.empty; - expect(user.rewards).to.be.empty; + expect(user.tasksOrder.todos).to.not.be.empty; + expect(user.tasksOrder.dailys).to.be.empty; + expect(user.tasksOrder.habits).to.be.empty; + expect(user.tasksOrder.rewards).to.be.empty; }); }); @@ -245,10 +245,10 @@ describe('POST /user/auth/local/register', () => { password, confirmPassword: password, }).then((user) => { - expect(user.todos).to.not.be.empty; - expect(user.dailys).to.be.empty; - expect(user.habits).to.not.be.empty; - expect(user.rewards).to.not.be.empty; + expect(user.tasksOrder.todos).to.not.be.empty; + expect(user.tasksOrder.dailys).to.be.empty; + expect(user.tasksOrder.habits).to.not.be.empty; + expect(user.tasksOrder.rewards).to.not.be.empty; }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index f1aeb639e2..8ba7fc21a0 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -169,9 +169,45 @@ api.updateTask = { }, }; -// completed todos cannot be moved, they'll be returned ordered by date of completion /** - * @api {put} /tasks/move/:taskId/to/:position Move a task to a new position + * @api {put} /tasks/score/:taskId/:direction Score a task + * @apiVersion 3.0.0 + * @apiName ScoreTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * @apiParam {string="up","down"} direction The direction for scoring the task + * + * @apiSuccess {object} empty An empty object + */ +api.scoreTask = { + method: 'POST', + url: 'tasks/score/:taskId/:direction', + middlewares: [authWithHeaders()], + handler (req, res, next) { + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); + + let user = res.locals.user; + + Tasks.Task.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + + let delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language}); + }) + .then(() => res.respond(200, {})) // TODO what to return + .catch(next); + }, +}; + +// completed todos cannot be moved, they'll be returned ordered by date of completion +// TODO check that it works when a tag is selected or todos are split between dated and due +/** + * @api {post} /tasks/move/:taskId/to/:position Move a task to a new position * @apiVersion 3.0.0 * @apiName MoveTask * @apiGroup Task @@ -179,7 +215,7 @@ api.updateTask = { * @apiParam {UUID} taskId The task _id * @apiParam {Number} position Where to move the task (-1 means push to bottom) * - * @apiSuccess {object} emoty An empty object + * @apiSuccess {object} empty An empty object */ api.moveTask = { method: 'POST', diff --git a/website/src/models/task.js b/website/src/models/task.js index 9228ad4788..d697bac622 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -19,7 +19,7 @@ export let TaskSchema = new Schema({ notes: {type: String, default: ''}, tags: {type: Schema.Types.Mixed, default: {}}, // TODO dictionary? { "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, validate value: {type: Number, default: 0}, // redness - priority: {type: Number, default: 1}, + priority: {type: Number, default: 1, required: true}, attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, userId: {type: String, ref: 'User'}, // When null it belongs to a challenge @@ -71,7 +71,7 @@ export let HabitSchema = new Schema(_.defaults({ up: {type: Boolean, default: true}, down: {type: Boolean, default: true}, }, habitDailySchema()), subDiscriminatorOptions); -export let Habit = Task.discriminator('habit', HabitSchema); +export let habit = Task.discriminator('habit', HabitSchema); export let DailySchema = new Schema(_.defaults({ frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']}, @@ -93,7 +93,7 @@ export let DailySchema = new Schema(_.defaults({ }, streak: {type: Number, default: 0}, }, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions); -export let Daily = Task.discriminator('daily', DailySchema); +export let daily = Task.discriminator('daily', DailySchema); export let TodoSchema = new Schema(_.defaults({ dateCompleted: Date, @@ -101,7 +101,7 @@ export let TodoSchema = new Schema(_.defaults({ // TODO change field name date: String, // due date for todos }, dailyTodoSchema()), subDiscriminatorOptions); -export let Todo = Task.discriminator('todo', TodoSchema); +export let todo = Task.discriminator('todo', TodoSchema); export let RewardSchema = new Schema({}, subDiscriminatorOptions); -export let Reward = Task.discriminator('reward', RewardSchema); +export let reward = Task.discriminator('reward', RewardSchema); diff --git a/website/src/models/user.js b/website/src/models/user.js index 7e14b29c86..bdcc479ea9 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -477,8 +477,6 @@ schema.plugin(baseModel, { noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function toJSON (doc) { - doc.id = doc._id; // TODO remove? - // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? doc.filters = {}; doc._tmp = this._tmp; // be sure to send down drop notifs @@ -492,7 +490,7 @@ schema.post('init', function postInitUser (doc) { }); function _populateDefaultTasks (user, taskTypes) { - let tagsI = taskTypes.indexOf('tags'); + let tagsI = taskTypes.indexOf('tag'); if (tagsI !== -1) { user.tags = _.map(shared.content.userDefaults.tags, (tag) => { @@ -508,16 +506,20 @@ function _populateDefaultTasks (user, taskTypes) { let tasksToCreate = []; - taskTypes = tagsI !== -1 ? _.clone(taskTypes).slice(tagsI, 1) : taskTypes; + if (tagsI !== -1) { + taskTypes = _.clone(taskTypes); + taskTypes.splice(tagsI, 1); + }; + _.each(taskTypes, (taskType) => { - let tasksOfType = _.map(shared.content.userDefaults[taskType], (taskDefaults) => { - let newTask = new Tasks[taskType](taskDefaults); + let tasksOfType = _.map(shared.content.userDefaults[`${taskType}s`], (taskDefaults) => { + let newTask = new (Tasks[taskType])(taskDefaults); newTask.userId = user._id; - newTask.text = newTask.text(user.preferences.language); - if (newTask.notes) newTask.notes = newTask.notes(user.preferences.language); - if (newTask.checklist) { - newTask.checklist = _.map(newTask.checklist, (checklistItem) => { + newTask.text = taskDefaults.text(user.preferences.language); + if (newTask.notes) newTask.notes = taskDefaults.notes(user.preferences.language); + if (taskDefaults.checklist) { + newTask.checklist = _.map(taskDefaults.checklist, (checklistItem) => { checklistItem.text = checklistItem.text(user.preferences.language); return checklistItem; }); @@ -542,13 +544,13 @@ function _populateDefaultsForNewUser (user) { let iterableFlags = user.flags.toObject(); if (user.registeredThrough === 'habitica-web') { - taskTypes = ['habits', 'dailys', 'todos', 'rewards', 'tags']; + taskTypes = ['habit', 'daily', 'todo', 'reward', 'tag']; _.each(iterableFlags.tutorial.common, (val, section) => { user.flags.tutorial.common[section] = true; }); } else { - taskTypes = ['todos', 'tags']; + taskTypes = ['todo', 'tag']; user.flags.showTour = false; _.each(iterableFlags.tour, (val, section) => { From 62708d43654b8556fde4c6486cc2c74f789a23b6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 2 Dec 2015 11:30:58 +0100 Subject: [PATCH 202/976] cleanup --- common/dist/sprites/habitrpg-shared.css | 2 +- website/src/controllers/api-v3/tasks.js | 2 -- website/src/models/user.js | 4 ++-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/common/dist/sprites/habitrpg-shared.css b/common/dist/sprites/habitrpg-shared.css index 038ffda277..bfd8135e18 100644 --- a/common/dist/sprites/habitrpg-shared.css +++ b/common/dist/sprites/habitrpg-shared.css @@ -1 +1 @@ -.2014_Fall_HealerPROMO2{background-image:url(spritesmith-largeSprites-0.png);background-position:-731px -995px;width:90px;height:90px}.2014_Fall_Mage_PROMO9{background-image:url(spritesmith-largeSprites-0.png);background-position:-306px -417px;width:120px;height:90px}.2014_Fall_RoguePROMO3{background-image:url(spritesmith-largeSprites-0.png);background-position:-808px -621px;width:105px;height:90px}.2014_Fall_Warrior_PROMO{background-image:url(spritesmith-largeSprites-0.png);background-position:-1095px -995px;width:90px;height:90px}.promo_backtoschool{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -522px;width:150px;height:150px}.promo_burnout{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px 0;width:219px;height:240px}.promo_classes_fall_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-326px -724px;width:321px;height:100px}.promo_classes_fall_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-430px -621px;width:377px;height:99px}.promo_dilatoryDistress{background-image:url(spritesmith-largeSprites-0.png);background-position:-367px -995px;width:90px;height:90px}.promo_enchanted_armoire{background-image:url(spritesmith-largeSprites-0.png);background-position:-499px -525px;width:374px;height:76px}.promo_enchanted_armoire_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -673px;width:217px;height:90px}.promo_enchanted_armoire_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-648px -724px;width:180px;height:90px}.promo_enchanted_armoire_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-458px -995px;width:90px;height:90px}.promo_enchanted_armoire_201511{background-image:url(spritesmith-largeSprites-0.png);background-position:-306px -326px;width:122px;height:90px}.promo_habitica{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -241px;width:175px;height:175px}.promo_habitica_sticker{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -220px;width:305px;height:304px}.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-1094px -522px;width:100px;height:137px}.customize-option.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-1119px -537px;width:60px;height:60px}.promo_item_notif{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -271px;width:249px;height:102px}.promo_mystery_201405{background-image:url(spritesmith-largeSprites-0.png);background-position:-1004px -995px;width:90px;height:90px}.promo_mystery_201406{background-image:url(spritesmith-largeSprites-0.png);background-position:-91px -995px;width:90px;height:96px}.promo_mystery_201407{background-image:url(spritesmith-largeSprites-0.png);background-position:-628px -241px;width:42px;height:62px}.promo_mystery_201408{background-image:url(spritesmith-largeSprites-0.png);background-position:-1161px -764px;width:60px;height:71px}.promo_mystery_201409{background-image:url(spritesmith-largeSprites-0.png);background-position:-640px -995px;width:90px;height:90px}.promo_mystery_201410{background-image:url(spritesmith-largeSprites-0.png);background-position:-1161px -673px;width:72px;height:63px}.promo_mystery_201411{background-image:url(spritesmith-largeSprites-0.png);background-position:-822px -995px;width:90px;height:90px}.promo_mystery_201412{background-image:url(spritesmith-largeSprites-0.png);background-position:-1195px -592px;width:42px;height:66px}.promo_mystery_201501{background-image:url(spritesmith-largeSprites-0.png);background-position:-1193px -271px;width:48px;height:63px}.promo_mystery_201502{background-image:url(spritesmith-largeSprites-0.png);background-position:-276px -995px;width:90px;height:90px}.promo_mystery_201503{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -1101px;width:90px;height:90px}.promo_mystery_201504{background-image:url(spritesmith-largeSprites-0.png);background-position:-874px -525px;width:60px;height:69px}.promo_mystery_201505{background-image:url(spritesmith-largeSprites-0.png);background-position:-549px -995px;width:90px;height:90px}.promo_mystery_201506{background-image:url(spritesmith-largeSprites-0.png);background-position:-1195px -522px;width:42px;height:69px}.promo_mystery_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -995px;width:90px;height:105px}.promo_mystery_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-829px -724px;width:93px;height:90px}.promo_mystery_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-913px -995px;width:90px;height:90px}.promo_mystery_201510{background-image:url(spritesmith-largeSprites-0.png);background-position:-182px -995px;width:93px;height:90px}.promo_mystery_3014{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -764px;width:217px;height:90px}.promo_orca{background-image:url(spritesmith-largeSprites-0.png);background-position:-306px -220px;width:105px;height:105px}.promo_partyhats{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -855px;width:115px;height:47px}.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -835px;width:330px;height:83px}.customize-option.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -850px;width:60px;height:60px}.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-1100px -374px;width:140px;height:147px}.customize-option.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-1125px -389px;width:60px;height:60px}.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-331px -835px;width:330px;height:83px}.customize-option.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-356px -850px;width:60px;height:60px}.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -417px;width:198px;height:91px}.customize-option.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-477px -432px;width:60px;height:60px}.promo_springclasses2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -180px;width:288px;height:90px}.promo_springclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -89px;width:288px;height:90px}.promo_summer_classes_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -621px;width:429px;height:102px}.promo_summer_classes_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px 0;width:300px;height:88px}.promo_updos{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -374px;width:156px;height:147px}.promo_veteran_pets{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -919px;width:146px;height:75px}.promo_winterclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -724px;width:325px;height:110px}.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-662px -835px;width:152px;height:75px}.customize-option.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-687px -850px;width:60px;height:60px}.avatar_variety{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -525px;width:498px;height:95px}.party_preview{background-image:url(spritesmith-largeSprites-0.png);background-position:0 0;width:451px;height:219px}.welcome_basic_avatars{background-image:url(spritesmith-largeSprites-0.png);background-position:-672px -347px;width:246px;height:165px}.welcome_promo_party{background-image:url(spritesmith-largeSprites-0.png);background-position:-672px 0;width:270px;height:180px}.welcome_sample_tasks{background-image:url(spritesmith-largeSprites-0.png);background-position:-672px -181px;width:246px;height:165px}.achievement-alien{background-image:url(spritesmith-main-0.png);background-position:-1678px -1451px;width:24px;height:26px}.achievement-alien2x{background-image:url(spritesmith-main-0.png);background-position:-895px -979px;width:48px;height:52px}.achievement-alpha{background-image:url(spritesmith-main-0.png);background-position:-1678px -1424px;width:24px;height:26px}.achievement-armor{background-image:url(spritesmith-main-0.png);background-position:-1678px -1397px;width:24px;height:26px}.achievement-armor2x{background-image:url(spritesmith-main-0.png);background-position:-944px -979px;width:48px;height:52px}.achievement-boot{background-image:url(spritesmith-main-0.png);background-position:-1678px -1343px;width:24px;height:26px}.achievement-boot2x{background-image:url(spritesmith-main-0.png);background-position:-1042px -979px;width:48px;height:52px}.achievement-bow{background-image:url(spritesmith-main-0.png);background-position:-1678px -1289px;width:24px;height:26px}.achievement-bow2x{background-image:url(spritesmith-main-0.png);background-position:-504px -1582px;width:48px;height:52px}.achievement-burnout{background-image:url(spritesmith-main-0.png);background-position:-1678px -1235px;width:24px;height:26px}.achievement-burnout2x{background-image:url(spritesmith-main-0.png);background-position:-602px -1582px;width:48px;height:52px}.achievement-cactus{background-image:url(spritesmith-main-0.png);background-position:-1678px -1181px;width:24px;height:26px}.achievement-cactus2x{background-image:url(spritesmith-main-0.png);background-position:-700px -1582px;width:48px;height:52px}.achievement-cake{background-image:url(spritesmith-main-0.png);background-position:-1678px -1127px;width:24px;height:26px}.achievement-cake2x{background-image:url(spritesmith-main-0.png);background-position:-798px -1582px;width:48px;height:52px}.achievement-cave{background-image:url(spritesmith-main-0.png);background-position:-1678px -1073px;width:24px;height:26px}.achievement-cave2x{background-image:url(spritesmith-main-0.png);background-position:-896px -1582px;width:48px;height:52px}.achievement-coffin{background-image:url(spritesmith-main-0.png);background-position:-1678px -1019px;width:24px;height:26px}.achievement-comment{background-image:url(spritesmith-main-0.png);background-position:-1678px -992px;width:24px;height:26px}.achievement-comment2x{background-image:url(spritesmith-main-0.png);background-position:-994px -1582px;width:48px;height:52px}.achievement-costumeContest{background-image:url(spritesmith-main-0.png);background-position:-1678px -938px;width:24px;height:26px}.achievement-costumeContest2x{background-image:url(spritesmith-main-0.png);background-position:-1092px -1582px;width:48px;height:52px}.achievement-dilatory{background-image:url(spritesmith-main-0.png);background-position:-1678px -884px;width:24px;height:26px}.achievement-firefox{background-image:url(spritesmith-main-0.png);background-position:-1678px -857px;width:24px;height:26px}.achievement-greeting{background-image:url(spritesmith-main-0.png);background-position:-1678px -830px;width:24px;height:26px}.achievement-greeting2x{background-image:url(spritesmith-main-0.png);background-position:-1190px -1582px;width:48px;height:52px}.achievement-habitBirthday{background-image:url(spritesmith-main-0.png);background-position:-1678px -776px;width:24px;height:26px}.achievement-habitBirthday2x{background-image:url(spritesmith-main-0.png);background-position:-1288px -1582px;width:48px;height:52px}.achievement-habiticaDay{background-image:url(spritesmith-main-0.png);background-position:-1678px -722px;width:24px;height:26px}.achievement-habiticaDay2x{background-image:url(spritesmith-main-0.png);background-position:-1386px -1582px;width:48px;height:52px}.achievement-heart{background-image:url(spritesmith-main-0.png);background-position:-1678px -668px;width:24px;height:26px}.achievement-heart2x{background-image:url(spritesmith-main-0.png);background-position:-1435px -1582px;width:48px;height:52px}.achievement-karaoke-2x{background-image:url(spritesmith-main-0.png);background-position:-1533px -1582px;width:48px;height:52px}.achievement-karaoke{background-image:url(spritesmith-main-0.png);background-position:-1678px -587px;width:24px;height:26px}.achievement-ninja{background-image:url(spritesmith-main-0.png);background-position:-1678px -560px;width:24px;height:26px}.achievement-ninja2x{background-image:url(spritesmith-main-0.png);background-position:-1678px 0;width:48px;height:52px}.achievement-nye{background-image:url(spritesmith-main-0.png);background-position:-1678px -506px;width:24px;height:26px}.achievement-nye2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -106px;width:48px;height:52px}.achievement-perfect{background-image:url(spritesmith-main-0.png);background-position:-1678px -452px;width:24px;height:26px}.achievement-perfect2x{background-image:url(spritesmith-main-0.png);background-position:-846px -979px;width:48px;height:52px}.achievement-rat{background-image:url(spritesmith-main-0.png);background-position:-1678px -911px;width:24px;height:26px}.achievement-rat2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -318px;width:48px;height:52px}.achievement-seafoam{background-image:url(spritesmith-main-0.png);background-position:-1678px -398px;width:24px;height:26px}.achievement-seafoam2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -265px;width:48px;height:52px}.achievement-shield{background-image:url(spritesmith-main-0.png);background-position:-1678px -425px;width:24px;height:26px}.achievement-shield2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -159px;width:48px;height:52px}.achievement-shinySeed{background-image:url(spritesmith-main-0.png);background-position:-1678px -479px;width:24px;height:26px}.achievement-shinySeed2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -53px;width:48px;height:52px}.achievement-snowball{background-image:url(spritesmith-main-0.png);background-position:-1678px -533px;width:24px;height:26px}.achievement-snowball2x{background-image:url(spritesmith-main-0.png);background-position:-1582px -1582px;width:48px;height:52px}.achievement-spookDust{background-image:url(spritesmith-main-0.png);background-position:-1678px -614px;width:24px;height:26px}.achievement-spookDust2x{background-image:url(spritesmith-main-0.png);background-position:-1484px -1582px;width:48px;height:52px}.achievement-stoikalm{background-image:url(spritesmith-main-0.png);background-position:-1678px -641px;width:24px;height:26px}.achievement-sun{background-image:url(spritesmith-main-0.png);background-position:-1678px -695px;width:24px;height:26px}.achievement-sun2x{background-image:url(spritesmith-main-0.png);background-position:-1337px -1582px;width:48px;height:52px}.achievement-sword{background-image:url(spritesmith-main-0.png);background-position:-1678px -749px;width:24px;height:26px}.achievement-sword2x{background-image:url(spritesmith-main-0.png);background-position:-1239px -1582px;width:48px;height:52px}.achievement-thankyou{background-image:url(spritesmith-main-0.png);background-position:-1678px -803px;width:24px;height:26px}.achievement-thankyou2x{background-image:url(spritesmith-main-0.png);background-position:-1141px -1582px;width:48px;height:52px}.achievement-thermometer{background-image:url(spritesmith-main-0.png);background-position:-1678px -371px;width:24px;height:26px}.achievement-thermometer2x{background-image:url(spritesmith-main-0.png);background-position:-1043px -1582px;width:48px;height:52px}.achievement-tree{background-image:url(spritesmith-main-0.png);background-position:-1678px -965px;width:24px;height:26px}.achievement-tree2x{background-image:url(spritesmith-main-0.png);background-position:-945px -1582px;width:48px;height:52px}.achievement-triadbingo{background-image:url(spritesmith-main-0.png);background-position:-1678px -1046px;width:24px;height:26px}.achievement-triadbingo2x{background-image:url(spritesmith-main-0.png);background-position:-847px -1582px;width:48px;height:52px}.achievement-ultimate-healer{background-image:url(spritesmith-main-0.png);background-position:-1678px -1100px;width:24px;height:26px}.achievement-ultimate-healer2x{background-image:url(spritesmith-main-0.png);background-position:-749px -1582px;width:48px;height:52px}.achievement-ultimate-mage{background-image:url(spritesmith-main-0.png);background-position:-1678px -1154px;width:24px;height:26px}.achievement-ultimate-mage2x{background-image:url(spritesmith-main-0.png);background-position:-651px -1582px;width:48px;height:52px}.achievement-ultimate-rogue{background-image:url(spritesmith-main-0.png);background-position:-1678px -1208px;width:24px;height:26px}.achievement-ultimate-rogue2x{background-image:url(spritesmith-main-0.png);background-position:-553px -1582px;width:48px;height:52px}.achievement-ultimate-warrior{background-image:url(spritesmith-main-0.png);background-position:-1678px -1262px;width:24px;height:26px}.achievement-ultimate-warrior2x{background-image:url(spritesmith-main-0.png);background-position:-455px -1582px;width:48px;height:52px}.achievement-valentine{background-image:url(spritesmith-main-0.png);background-position:-1678px -1316px;width:24px;height:26px}.achievement-valentine2x{background-image:url(spritesmith-main-0.png);background-position:-993px -979px;width:48px;height:52px}.achievement-wolf{background-image:url(spritesmith-main-0.png);background-position:-1678px -1370px;width:24px;height:26px}.achievement-wolf2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -212px;width:48px;height:52px}.background_autumn_forest{background-image:url(spritesmith-main-0.png);background-position:-423px -592px;width:140px;height:147px}.background_beach{background-image:url(spritesmith-main-0.png);background-position:-426px 0;width:141px;height:147px}.background_blacksmithy{background-image:url(spritesmith-main-0.png);background-position:-709px -148px;width:140px;height:147px}.background_cherry_trees{background-image:url(spritesmith-main-0.png);background-position:-709px -296px;width:140px;height:147px}.background_clouds{background-image:url(spritesmith-main-0.png);background-position:0 -592px;width:140px;height:147px}.background_coral_reef{background-image:url(spritesmith-main-0.png);background-position:-850px -148px;width:140px;height:147px}.background_crystal_cave{background-image:url(spritesmith-main-0.png);background-position:-850px -592px;width:140px;height:147px}.background_dilatory_ruins{background-image:url(spritesmith-main-0.png);background-position:0 -740px;width:140px;height:147px}.background_distant_castle{background-image:url(spritesmith-main-0.png);background-position:-423px -888px;width:140px;height:147px}.background_drifting_raft{background-image:url(spritesmith-main-0.png);background-position:-564px -888px;width:140px;height:147px}.background_dusty_canyons{background-image:url(spritesmith-main-0.png);background-position:-705px -888px;width:140px;height:147px}.background_fairy_ring{background-image:url(spritesmith-main-0.png);background-position:-568px 0;width:140px;height:147px}.background_floating_islands{background-image:url(spritesmith-main-0.png);background-position:-568px -148px;width:140px;height:147px}.background_floral_meadow{background-image:url(spritesmith-main-0.png);background-position:-568px -296px;width:140px;height:147px}.background_forest{background-image:url(spritesmith-main-0.png);background-position:0 -444px;width:140px;height:147px}.background_frigid_peak{background-image:url(spritesmith-main-0.png);background-position:-141px -444px;width:140px;height:147px}.background_giant_wave{background-image:url(spritesmith-main-0.png);background-position:-284px 0;width:141px;height:147px}.background_graveyard{background-image:url(spritesmith-main-0.png);background-position:-423px -444px;width:140px;height:147px}.background_gumdrop_land{background-image:url(spritesmith-main-0.png);background-position:-564px -444px;width:140px;height:147px}.background_harvest_feast{background-image:url(spritesmith-main-0.png);background-position:-709px 0;width:140px;height:147px}.background_harvest_fields{background-image:url(spritesmith-main-0.png);background-position:0 -148px;width:141px;height:147px}.background_harvest_moon{background-image:url(spritesmith-main-0.png);background-position:-142px -148px;width:141px;height:147px}.background_haunted_house{background-image:url(spritesmith-main-0.png);background-position:-709px -444px;width:140px;height:147px}.background_ice_cave{background-image:url(spritesmith-main-0.png);background-position:-284px -148px;width:141px;height:147px}.background_iceberg{background-image:url(spritesmith-main-0.png);background-position:-141px -592px;width:140px;height:147px}.background_island_waterfalls{background-image:url(spritesmith-main-0.png);background-position:-282px -592px;width:140px;height:147px}.background_marble_temple{background-image:url(spritesmith-main-0.png);background-position:-142px 0;width:141px;height:147px}.background_market{background-image:url(spritesmith-main-0.png);background-position:-564px -592px;width:140px;height:147px}.background_mountain_lake{background-image:url(spritesmith-main-0.png);background-position:-705px -592px;width:140px;height:147px}.background_night_dunes{background-image:url(spritesmith-main-0.png);background-position:-850px 0;width:140px;height:147px}.background_open_waters{background-image:url(spritesmith-main-0.png);background-position:0 0;width:141px;height:147px}.background_pagodas{background-image:url(spritesmith-main-0.png);background-position:-850px -296px;width:140px;height:147px}.background_pumpkin_patch{background-image:url(spritesmith-main-0.png);background-position:-850px -444px;width:140px;height:147px}.background_pyramids{background-image:url(spritesmith-main-0.png);background-position:-426px -148px;width:141px;height:147px}.background_rolling_hills{background-image:url(spritesmith-main-0.png);background-position:0 -296px;width:141px;height:147px}.background_seafarer_ship{background-image:url(spritesmith-main-0.png);background-position:-141px -740px;width:140px;height:147px}.background_shimmery_bubbles{background-image:url(spritesmith-main-0.png);background-position:-282px -740px;width:140px;height:147px}.background_slimy_swamp{background-image:url(spritesmith-main-0.png);background-position:-423px -740px;width:140px;height:147px}.background_snowy_pines{background-image:url(spritesmith-main-0.png);background-position:-564px -740px;width:140px;height:147px}.background_south_pole{background-image:url(spritesmith-main-0.png);background-position:-705px -740px;width:140px;height:147px}.background_spring_rain{background-image:url(spritesmith-main-0.png);background-position:-846px -740px;width:140px;height:147px}.background_stable{background-image:url(spritesmith-main-0.png);background-position:-991px 0;width:140px;height:147px}.background_stained_glass{background-image:url(spritesmith-main-0.png);background-position:-991px -148px;width:140px;height:147px}.background_starry_skies{background-image:url(spritesmith-main-0.png);background-position:-991px -296px;width:140px;height:147px}.background_sunken_ship{background-image:url(spritesmith-main-0.png);background-position:-991px -444px;width:140px;height:147px}.background_sunset_meadow{background-image:url(spritesmith-main-0.png);background-position:-991px -592px;width:140px;height:147px}.background_sunset_oasis{background-image:url(spritesmith-main-0.png);background-position:-991px -740px;width:140px;height:147px}.background_sunset_savannah{background-image:url(spritesmith-main-0.png);background-position:0 -888px;width:140px;height:147px}.background_swarming_darkness{background-image:url(spritesmith-main-0.png);background-position:-141px -888px;width:140px;height:147px}.background_tavern{background-image:url(spritesmith-main-0.png);background-position:-282px -888px;width:140px;height:147px}.background_thunderstorm{background-image:url(spritesmith-main-0.png);background-position:-142px -296px;width:141px;height:147px}.background_twinkly_lights{background-image:url(spritesmith-main-0.png);background-position:-284px -296px;width:141px;height:147px}.background_twinkly_party_lights{background-image:url(spritesmith-main-0.png);background-position:-426px -296px;width:141px;height:147px}.background_volcano{background-image:url(spritesmith-main-0.png);background-position:-282px -444px;width:140px;height:147px}.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -910px;width:90px;height:90px}.customize-option.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -925px;width:60px;height:60px}.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -1016px;width:60px;height:60px}.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -1107px;width:60px;height:60px}.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -1183px;width:90px;height:90px}.customize-option.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -1198px;width:60px;height:60px}.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:0 -1309px;width:90px;height:90px}.customize-option.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-25px -1324px;width:60px;height:60px}.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1324px;width:60px;height:60px}.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-182px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-207px -1324px;width:60px;height:60px}.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-273px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-298px -1324px;width:60px;height:60px}.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-364px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-389px -1324px;width:60px;height:60px}.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-455px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-480px -1324px;width:60px;height:60px}.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-546px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-571px -1324px;width:60px;height:60px}.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-637px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-662px -1324px;width:60px;height:60px}.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-728px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-753px -1324px;width:60px;height:60px}.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1324px;width:60px;height:60px}.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1324px;width:60px;height:60px}.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1324px;width:60px;height:60px}.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1092px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1117px -1324px;width:60px;height:60px}.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1183px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1208px -1324px;width:60px;height:60px}.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1324px;width:60px;height:60px}.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1405px 0;width:90px;height:90px}.customize-option.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1430px -15px;width:60px;height:60px}.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1405px -91px;width:90px;height:90px}.customize-option.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1430px -106px;width:60px;height:60px}.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1405px -182px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1430px -197px;width:60px;height:60px}.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1405px -273px;width:90px;height:90px}.customize-option.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1430px -288px;width:60px;height:60px}.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1405px -364px;width:90px;height:90px}.customize-option.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1430px -379px;width:60px;height:60px}.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -455px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -470px;width:60px;height:60px}.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-846px -888px;width:90px;height:90px}.customize-option.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-871px -903px;width:60px;height:60px}.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1405px -637px;width:90px;height:90px}.customize-option.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1430px -652px;width:60px;height:60px}.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1405px -728px;width:90px;height:90px}.customize-option.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1430px -743px;width:60px;height:60px}.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1405px -819px;width:90px;height:90px}.customize-option.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1430px -834px;width:60px;height:60px}.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1405px -910px;width:90px;height:90px}.customize-option.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1430px -925px;width:60px;height:60px}.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1405px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1430px -1016px;width:60px;height:60px}.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -1107px;width:60px;height:60px}.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1405px -1183px;width:90px;height:90px}.customize-option.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1430px -1198px;width:60px;height:60px}.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1405px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1430px -1289px;width:60px;height:60px}.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:0 -1400px;width:90px;height:90px}.customize-option.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-25px -1415px;width:60px;height:60px}.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-91px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-116px -1415px;width:60px;height:60px}.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-182px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-207px -1415px;width:60px;height:60px}.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-273px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-298px -1415px;width:60px;height:60px}.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-364px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-389px -1415px;width:60px;height:60px}.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-455px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-480px -1415px;width:60px;height:60px}.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-546px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-571px -1415px;width:60px;height:60px}.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-637px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-662px -1415px;width:60px;height:60px}.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-728px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-753px -1415px;width:60px;height:60px}.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-819px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-844px -1415px;width:60px;height:60px}.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-910px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-935px -1415px;width:60px;height:60px}.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-1001px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-1026px -1415px;width:60px;height:60px}.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-1092px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-1117px -1415px;width:60px;height:60px}.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1183px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1208px -1415px;width:60px;height:60px}.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1274px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1299px -1415px;width:60px;height:60px}.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1365px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1390px -1415px;width:60px;height:60px}.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1496px 0;width:90px;height:90px}.customize-option.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1521px -15px;width:60px;height:60px}.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1496px -91px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1521px -106px;width:60px;height:60px}.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1496px -182px;width:90px;height:90px}.customize-option.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1521px -197px;width:60px;height:60px}.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1496px -273px;width:90px;height:90px}.customize-option.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1521px -288px;width:60px;height:60px}.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1496px -364px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1521px -379px;width:60px;height:60px}.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1496px -455px;width:90px;height:90px}.customize-option.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1521px -470px;width:60px;height:60px}.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1496px -546px;width:90px;height:90px}.customize-option.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1521px -561px;width:60px;height:60px}.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -637px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -652px;width:60px;height:60px}.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1496px -728px;width:90px;height:90px}.customize-option.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1521px -743px;width:60px;height:60px}.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1496px -819px;width:90px;height:90px}.customize-option.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1521px -834px;width:60px;height:60px}.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1496px -910px;width:90px;height:90px}.customize-option.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1521px -925px;width:60px;height:60px}.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1496px -1001px;width:90px;height:90px}.customize-option.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1521px -1016px;width:60px;height:60px}.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1496px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1521px -1107px;width:60px;height:60px}.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1496px -1183px;width:90px;height:90px}.customize-option.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1521px -1198px;width:60px;height:60px}.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -1289px;width:60px;height:60px}.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1496px -1365px;width:90px;height:90px}.customize-option.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1521px -1380px;width:60px;height:60px}.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:0 -1491px;width:90px;height:90px}.customize-option.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-25px -1506px;width:60px;height:60px}.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-91px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-116px -1506px;width:60px;height:60px}.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-182px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-207px -1506px;width:60px;height:60px}.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1506px;width:60px;height:60px}.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-364px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-389px -1506px;width:60px;height:60px}.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-455px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-480px -1506px;width:60px;height:60px}.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-546px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-571px -1506px;width:60px;height:60px}.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-637px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-662px -1506px;width:60px;height:60px}.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-728px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-753px -1506px;width:60px;height:60px}.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-819px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-844px -1506px;width:60px;height:60px}.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-910px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-935px -1506px;width:60px;height:60px}.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-1001px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-1026px -1506px;width:60px;height:60px}.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-1092px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-1117px -1506px;width:60px;height:60px}.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-1183px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-1208px -1506px;width:60px;height:60px}.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1506px;width:60px;height:60px}.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1365px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1390px -1506px;width:60px;height:60px}.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1456px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1481px -1506px;width:60px;height:60px}.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1587px 0;width:90px;height:90px}.customize-option.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1612px -15px;width:60px;height:60px}.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1587px -91px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1612px -106px;width:60px;height:60px}.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1587px -182px;width:90px;height:90px}.customize-option.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1612px -197px;width:60px;height:60px}.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1587px -273px;width:90px;height:90px}.customize-option.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1612px -288px;width:60px;height:60px}.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1587px -364px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1612px -379px;width:60px;height:60px}.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1587px -455px;width:90px;height:90px}.customize-option.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1612px -470px;width:60px;height:60px}.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1587px -546px;width:90px;height:90px}.customize-option.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1612px -561px;width:60px;height:60px}.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -637px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -652px;width:60px;height:60px}.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1587px -728px;width:90px;height:90px}.customize-option.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1612px -743px;width:60px;height:60px}.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1587px -819px;width:90px;height:90px}.customize-option.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1612px -834px;width:60px;height:60px}.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1587px -910px;width:90px;height:90px}.customize-option.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1612px -925px;width:60px;height:60px}.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1587px -1001px;width:90px;height:90px}.customize-option.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1612px -1016px;width:60px;height:60px}.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1587px -1092px;width:90px;height:90px}.customize-option.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1612px -1107px;width:60px;height:60px}.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1587px -1183px;width:90px;height:90px}.customize-option.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1612px -1198px;width:60px;height:60px}.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -1289px;width:60px;height:60px}.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1587px -1365px;width:90px;height:90px}.customize-option.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1612px -1380px;width:60px;height:60px}.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1587px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1612px -1471px;width:60px;height:60px}.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:0 -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-25px -1597px;width:60px;height:60px}.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-91px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-116px -1597px;width:60px;height:60px}.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-182px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-207px -1597px;width:60px;height:60px}.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-273px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-298px -1597px;width:60px;height:60px}.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-364px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-389px -1597px;width:60px;height:60px}.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1405px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1430px -561px;width:60px;height:60px}.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1132px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1157px -652px;width:60px;height:60px}.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1132px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1157px -561px;width:60px;height:60px}.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1132px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1157px -470px;width:60px;height:60px}.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1132px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1157px -379px;width:60px;height:60px}.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-1132px -273px;width:90px;height:90px}.customize-option.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-1157px -288px;width:60px;height:60px}.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1132px -182px;width:90px;height:90px}.customize-option.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1157px -197px;width:60px;height:60px}.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1132px -91px;width:90px;height:90px}.customize-option.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1157px -106px;width:60px;height:60px}.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1132px 0;width:90px;height:90px}.customize-option.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1157px -15px;width:60px;height:60px}.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1051px;width:60px;height:60px}.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-910px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-935px -1051px;width:60px;height:60px}.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-819px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-844px -1051px;width:60px;height:60px}.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-728px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-753px -1051px;width:60px;height:60px}.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-637px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-662px -1051px;width:60px;height:60px}.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-546px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-571px -1051px;width:60px;height:60px}.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-455px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-480px -1051px;width:60px;height:60px}.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-364px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-389px -1051px;width:60px;height:60px}.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-273px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-298px -1051px;width:60px;height:60px}.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-182px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-207px -1051px;width:60px;height:60px}.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-91px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-116px -1051px;width:60px;height:60px}.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:0 -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-25px -1051px;width:60px;height:60px}.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1028px -888px;width:90px;height:90px}.customize-option.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1053px -903px;width:60px;height:60px}.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-937px -888px;width:90px;height:90px}.customize-option.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-962px -903px;width:60px;height:60px}.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1314px -819px;width:90px;height:90px}.customize-option.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1339px -834px;width:60px;height:60px}.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1314px -728px;width:90px;height:90px}.customize-option.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1339px -743px;width:60px;height:60px}.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1314px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1339px -652px;width:60px;height:60px}.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1314px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1339px -561px;width:60px;height:60px}.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -470px;width:60px;height:60px}.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -379px;width:60px;height:60px}.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -288px;width:60px;height:60px}.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -197px;width:60px;height:60px}.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1314px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1339px -106px;width:60px;height:60px}.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1314px 0;width:90px;height:90px}.customize-option.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1339px -15px;width:60px;height:60px}.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1183px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1208px -1233px;width:60px;height:60px}.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1092px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1117px -1233px;width:60px;height:60px}.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1001px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1026px -1233px;width:60px;height:60px}.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-910px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-935px -1233px;width:60px;height:60px}.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-819px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-844px -1233px;width:60px;height:60px}.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-728px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-753px -1233px;width:60px;height:60px}.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-637px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-662px -1233px;width:60px;height:60px}.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-546px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-571px -1233px;width:60px;height:60px}.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-455px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-480px -1233px;width:60px;height:60px}.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-364px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-389px -1233px;width:60px;height:60px}.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-273px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-298px -1233px;width:60px;height:60px}.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-182px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-207px -1233px;width:60px;height:60px}.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-91px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-116px -1233px;width:60px;height:60px}.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:0 -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-25px -1233px;width:60px;height:60px}.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1223px -1092px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1248px -1107px;width:60px;height:60px}.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1223px -1001px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1248px -1016px;width:60px;height:60px}.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1223px -910px;width:90px;height:90px}.customize-option.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1248px -925px;width:60px;height:60px}.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1223px -819px;width:90px;height:90px}.customize-option.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1248px -834px;width:60px;height:60px}.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1223px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1248px -743px;width:60px;height:60px}.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1223px -637px;width:90px;height:90px}.customize-option.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1248px -652px;width:60px;height:60px}.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1223px -546px;width:90px;height:90px}.customize-option.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1248px -561px;width:60px;height:60px}.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1223px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1248px -470px;width:60px;height:60px}.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1223px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1248px -379px;width:60px;height:60px}.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1223px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1248px -288px;width:60px;height:60px}.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1223px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1248px -197px;width:60px;height:60px}.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1223px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1248px -106px;width:60px;height:60px}.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1223px 0;width:90px;height:90px}.customize-option.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1248px -15px;width:60px;height:60px}.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1092px -1127px;width:90px;height:90px}.customize-option.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1117px -1142px;width:60px;height:60px}.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1001px -1127px;width:90px;height:90px}.customize-option.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1026px -1142px;width:60px;height:60px}.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-910px -1127px;width:90px;height:90px}.customize-option.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-935px -1142px;width:60px;height:60px}.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-819px -1127px;width:90px;height:90px}.customize-option.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-844px -1142px;width:60px;height:60px}.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-728px -1127px;width:90px;height:90px}.customize-option.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-753px -1142px;width:60px;height:60px}.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-637px -1127px;width:90px;height:90px}.customize-option.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-662px -1142px;width:60px;height:60px}.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-546px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-571px -1142px;width:60px;height:60px}.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-455px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-480px -1142px;width:60px;height:60px}.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-364px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-389px -1142px;width:60px;height:60px}.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1142px;width:60px;height:60px}.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-182px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-207px -1142px;width:60px;height:60px}.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1142px;width:60px;height:60px}.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:0 -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-25px -1142px;width:60px;height:60px}.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1132px -1001px;width:90px;height:90px}.customize-option.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1157px -1016px;width:60px;height:60px}.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1132px -910px;width:90px;height:90px}.customize-option.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1157px -925px;width:60px;height:60px}.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1132px -819px;width:90px;height:90px}.customize-option.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1157px -834px;width:60px;height:60px}.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1132px -728px;width:90px;height:90px}.customize-option.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1157px -743px;width:60px;height:60px}.hair_bangs_1_green{background-image:url(spritesmith-main-1.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_bangs_1_green{background-image:url(spritesmith-main-1.png);background-position:-116px -15px;width:60px;height:60px}.hair_bangs_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_bangs_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -1107px;width:60px;height:60px}.hair_bangs_1_holly{background-image:url(spritesmith-main-1.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_bangs_1_holly{background-image:url(spritesmith-main-1.png);background-position:-25px -106px;width:60px;height:60px}.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-116px -106px;width:60px;height:60px}.hair_bangs_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_bangs_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-207px -15px;width:60px;height:60px}.hair_bangs_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -106px;width:60px;height:60px}.hair_bangs_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -197px;width:60px;height:60px}.hair_bangs_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -197px;width:60px;height:60px}.hair_bangs_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -197px;width:60px;height:60px}.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -15px;width:60px;height:60px}.hair_bangs_1_porange{background-image:url(spritesmith-main-1.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_porange{background-image:url(spritesmith-main-1.png);background-position:-298px -106px;width:60px;height:60px}.hair_bangs_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-298px -197px;width:60px;height:60px}.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -288px;width:60px;height:60px}.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -288px;width:60px;height:60px}.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -288px;width:60px;height:60px}.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -288px;width:60px;height:60px}.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -15px;width:60px;height:60px}.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-389px -106px;width:60px;height:60px}.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-389px -197px;width:60px;height:60px}.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -288px;width:60px;height:60px}.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-25px -379px;width:60px;height:60px}.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-116px -379px;width:60px;height:60px}.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-207px -379px;width:60px;height:60px}.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-298px -379px;width:60px;height:60px}.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-389px -379px;width:60px;height:60px}.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-480px -15px;width:60px;height:60px}.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-480px -106px;width:60px;height:60px}.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-480px -197px;width:60px;height:60px}.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-480px -288px;width:60px;height:60px}.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-480px -379px;width:60px;height:60px}.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-25px -470px;width:60px;height:60px}.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-116px -470px;width:60px;height:60px}.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-207px -470px;width:60px;height:60px}.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-298px -470px;width:60px;height:60px}.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-389px -470px;width:60px;height:60px}.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-480px -470px;width:60px;height:60px}.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-571px -15px;width:60px;height:60px}.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-571px -106px;width:60px;height:60px}.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-571px -197px;width:60px;height:60px}.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-571px -288px;width:60px;height:60px}.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-571px -379px;width:60px;height:60px}.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-571px -470px;width:60px;height:60px}.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-25px -561px;width:60px;height:60px}.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-116px -561px;width:60px;height:60px}.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -561px;width:60px;height:60px}.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-298px -561px;width:60px;height:60px}.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-389px -561px;width:60px;height:60px}.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-480px -561px;width:60px;height:60px}.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-571px -561px;width:60px;height:60px}.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-662px -15px;width:60px;height:60px}.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-662px -106px;width:60px;height:60px}.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-662px -197px;width:60px;height:60px}.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -288px;width:60px;height:60px}.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-662px -379px;width:60px;height:60px}.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-662px -470px;width:60px;height:60px}.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-662px -561px;width:60px;height:60px}.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-25px -652px;width:60px;height:60px}.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-116px -652px;width:60px;height:60px}.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-207px -652px;width:60px;height:60px}.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-298px -652px;width:60px;height:60px}.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-389px -652px;width:60px;height:60px}.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-480px -652px;width:60px;height:60px}.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-571px -652px;width:60px;height:60px}.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-662px -652px;width:60px;height:60px}.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-753px -15px;width:60px;height:60px}.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-753px -106px;width:60px;height:60px}.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-753px -197px;width:60px;height:60px}.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-753px -288px;width:60px;height:60px}.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-753px -379px;width:60px;height:60px}.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-753px -470px;width:60px;height:60px}.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-753px -561px;width:60px;height:60px}.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-753px -652px;width:60px;height:60px}.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-25px -743px;width:60px;height:60px}.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-116px -743px;width:60px;height:60px}.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-207px -743px;width:60px;height:60px}.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-298px -743px;width:60px;height:60px}.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -743px;width:60px;height:60px}.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -743px;width:60px;height:60px}.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-571px -743px;width:60px;height:60px}.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-662px -743px;width:60px;height:60px}.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -743px;width:60px;height:60px}.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-844px -15px;width:60px;height:60px}.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-844px -106px;width:60px;height:60px}.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-844px -197px;width:60px;height:60px}.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-844px -288px;width:60px;height:60px}.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-844px -379px;width:60px;height:60px}.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -470px;width:60px;height:60px}.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-844px -561px;width:60px;height:60px}.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-844px -652px;width:60px;height:60px}.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-844px -743px;width:60px;height:60px}.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -834px;width:60px;height:60px}.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -834px;width:60px;height:60px}.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -834px;width:60px;height:60px}.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -834px;width:60px;height:60px}.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -834px;width:60px;height:60px}.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-480px -834px;width:60px;height:60px}.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-571px -834px;width:60px;height:60px}.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-662px -834px;width:60px;height:60px}.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-753px -834px;width:60px;height:60px}.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-844px -834px;width:60px;height:60px}.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-935px -15px;width:60px;height:60px}.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-935px -106px;width:60px;height:60px}.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-935px -197px;width:60px;height:60px}.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -288px;width:60px;height:60px}.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-935px -379px;width:60px;height:60px}.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1663px -15px;width:60px;height:60px}.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1663px -106px;width:60px;height:60px}.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1663px -197px;width:60px;height:60px}.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1663px -288px;width:60px;height:60px}.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1663px -379px;width:60px;height:60px}.hair_base_1_purple{background-image:url(spritesmith-main-2.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_base_1_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -15px;width:60px;height:60px}.hair_base_1_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_base_1_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -1107px;width:60px;height:60px}.hair_base_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_base_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-25px -106px;width:60px;height:60px}.hair_base_1_rainbow{background-image:url(spritesmith-main-2.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_base_1_rainbow{background-image:url(spritesmith-main-2.png);background-position:-116px -106px;width:60px;height:60px}.hair_base_1_red{background-image:url(spritesmith-main-2.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_base_1_red{background-image:url(spritesmith-main-2.png);background-position:-207px -15px;width:60px;height:60px}.hair_base_1_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_base_1_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -106px;width:60px;height:60px}.hair_base_1_white{background-image:url(spritesmith-main-2.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_base_1_white{background-image:url(spritesmith-main-2.png);background-position:-25px -197px;width:60px;height:60px}.hair_base_1_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_base_1_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -197px;width:60px;height:60px}.hair_base_1_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_base_1_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -197px;width:60px;height:60px}.hair_base_1_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_base_1_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -15px;width:60px;height:60px}.hair_base_1_zombie{background-image:url(spritesmith-main-2.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_base_1_zombie{background-image:url(spritesmith-main-2.png);background-position:-298px -106px;width:60px;height:60px}.hair_base_2_TRUred{background-image:url(spritesmith-main-2.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_base_2_TRUred{background-image:url(spritesmith-main-2.png);background-position:-298px -197px;width:60px;height:60px}.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -288px;width:60px;height:60px}.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-116px -288px;width:60px;height:60px}.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -288px;width:60px;height:60px}.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -288px;width:60px;height:60px}.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -15px;width:60px;height:60px}.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-389px -106px;width:60px;height:60px}.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-389px -197px;width:60px;height:60px}.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -288px;width:60px;height:60px}.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-25px -379px;width:60px;height:60px}.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-116px -379px;width:60px;height:60px}.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-207px -379px;width:60px;height:60px}.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-298px -379px;width:60px;height:60px}.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-389px -379px;width:60px;height:60px}.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-480px -15px;width:60px;height:60px}.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-480px -106px;width:60px;height:60px}.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-480px -197px;width:60px;height:60px}.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-480px -288px;width:60px;height:60px}.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-480px -379px;width:60px;height:60px}.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-25px -470px;width:60px;height:60px}.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-116px -470px;width:60px;height:60px}.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-207px -470px;width:60px;height:60px}.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-298px -470px;width:60px;height:60px}.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-389px -470px;width:60px;height:60px}.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-480px -470px;width:60px;height:60px}.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-571px -15px;width:60px;height:60px}.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-571px -106px;width:60px;height:60px}.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-571px -197px;width:60px;height:60px}.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -288px;width:60px;height:60px}.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-571px -379px;width:60px;height:60px}.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-571px -470px;width:60px;height:60px}.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:-25px -561px;width:60px;height:60px}.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-116px -561px;width:60px;height:60px}.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -561px;width:60px;height:60px}.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-298px -561px;width:60px;height:60px}.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-389px -561px;width:60px;height:60px}.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-480px -561px;width:60px;height:60px}.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-571px -561px;width:60px;height:60px}.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-662px -15px;width:60px;height:60px}.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-662px -106px;width:60px;height:60px}.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-662px -197px;width:60px;height:60px}.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-662px -288px;width:60px;height:60px}.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-662px -379px;width:60px;height:60px}.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-662px -470px;width:60px;height:60px}.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-662px -561px;width:60px;height:60px}.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-25px -652px;width:60px;height:60px}.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-116px -652px;width:60px;height:60px}.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-207px -652px;width:60px;height:60px}.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-298px -652px;width:60px;height:60px}.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-389px -652px;width:60px;height:60px}.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-480px -652px;width:60px;height:60px}.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-571px -652px;width:60px;height:60px}.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-662px -652px;width:60px;height:60px}.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-753px -15px;width:60px;height:60px}.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-753px -106px;width:60px;height:60px}.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-753px -197px;width:60px;height:60px}.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-753px -288px;width:60px;height:60px}.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-753px -379px;width:60px;height:60px}.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-753px -470px;width:60px;height:60px}.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-753px -561px;width:60px;height:60px}.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-753px -652px;width:60px;height:60px}.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -743px;width:60px;height:60px}.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-116px -743px;width:60px;height:60px}.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-207px -743px;width:60px;height:60px}.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-298px -743px;width:60px;height:60px}.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -743px;width:60px;height:60px}.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -743px;width:60px;height:60px}.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -743px;width:60px;height:60px}.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-662px -743px;width:60px;height:60px}.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -743px;width:60px;height:60px}.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-844px -15px;width:60px;height:60px}.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-844px -106px;width:60px;height:60px}.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-844px -197px;width:60px;height:60px}.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-844px -288px;width:60px;height:60px}.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-844px -379px;width:60px;height:60px}.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -470px;width:60px;height:60px}.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-844px -561px;width:60px;height:60px}.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-844px -652px;width:60px;height:60px}.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-844px -743px;width:60px;height:60px}.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -834px;width:60px;height:60px}.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-116px -834px;width:60px;height:60px}.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -834px;width:60px;height:60px}.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1663px -15px;width:60px;height:60px}.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1663px -106px;width:60px;height:60px}.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1663px -197px;width:60px;height:60px}.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1663px -288px;width:60px;height:60px}.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1663px -379px;width:60px;height:60px}.hair_beard_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-454px -273px;width:90px;height:90px}.customize-option.hair_beard_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-479px -288px;width:60px;height:60px}.hair_beard_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1276px -728px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1301px -743px;width:60px;height:60px}.hair_beard_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1276px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1301px -1107px;width:60px;height:60px}.hair_beard_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-207px -1289px;width:60px;height:60px}.hair_beard_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-298px -1289px;width:60px;height:60px}.hair_beard_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-480px -1289px;width:60px;height:60px}.hair_beard_3_pblue2{background-image:url(spritesmith-main-3.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_pblue2{background-image:url(spritesmith-main-3.png);background-position:-571px -1289px;width:60px;height:60px}.hair_beard_3_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-935px -1289px;width:60px;height:60px}.hair_beard_3_porange2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_porange2{background-image:url(spritesmith-main-3.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_beard_3_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_beard_3_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1367px -91px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1392px -106px;width:60px;height:60px}.hair_beard_3_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1367px -182px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1392px -197px;width:60px;height:60px}.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1367px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1392px -379px;width:60px;height:60px}.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1367px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1392px -470px;width:60px;height:60px}.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -1471px;width:60px;height:60px}.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-116px -1471px;width:60px;height:60px}.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-298px -1471px;width:60px;height:60px}.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-389px -1471px;width:60px;height:60px}.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-753px -1471px;width:60px;height:60px}.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-844px -1471px;width:60px;height:60px}.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -379px;width:60px;height:60px}.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-116px -379px;width:60px;height:60px}.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-207px -379px;width:60px;height:60px}.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-298px -379px;width:60px;height:60px}.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-389px -394px;width:60px;height:60px}.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-480px -394px;width:60px;height:60px}.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:0 -455px;width:90px;height:90px}.customize-option.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-25px -485px;width:60px;height:60px}.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-116px -485px;width:60px;height:60px}.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-207px -485px;width:60px;height:60px}.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-298px -485px;width:60px;height:60px}.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-389px -485px;width:60px;height:60px}.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-480px -485px;width:60px;height:60px}.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-548px 0;width:90px;height:90px}.customize-option.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-573px -30px;width:60px;height:60px}.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-548px -91px;width:90px;height:90px}.customize-option.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-573px -121px;width:60px;height:60px}.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-548px -182px;width:90px;height:90px}.customize-option.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-573px -212px;width:60px;height:60px}.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-548px -273px;width:90px;height:90px}.customize-option.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-573px -303px;width:60px;height:60px}.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-548px -364px;width:90px;height:90px}.customize-option.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-573px -394px;width:60px;height:60px}.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-548px -455px;width:90px;height:90px}.customize-option.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-573px -485px;width:60px;height:60px}.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:0 -546px;width:90px;height:90px}.customize-option.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-25px -576px;width:60px;height:60px}.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-116px -576px;width:60px;height:60px}.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-207px -576px;width:60px;height:60px}.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-298px -576px;width:60px;height:60px}.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-389px -576px;width:60px;height:60px}.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-480px -576px;width:60px;height:60px}.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-571px -576px;width:60px;height:60px}.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-639px 0;width:90px;height:90px}.customize-option.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-664px -30px;width:60px;height:60px}.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-639px -91px;width:90px;height:90px}.customize-option.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-664px -121px;width:60px;height:60px}.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-639px -182px;width:90px;height:90px}.customize-option.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-664px -212px;width:60px;height:60px}.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-639px -273px;width:90px;height:90px}.customize-option.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-664px -303px;width:60px;height:60px}.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-639px -364px;width:90px;height:90px}.customize-option.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-664px -394px;width:60px;height:60px}.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-639px -455px;width:90px;height:90px}.customize-option.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-664px -485px;width:60px;height:60px}.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-639px -546px;width:90px;height:90px}.customize-option.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-664px -576px;width:60px;height:60px}.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:0 -637px;width:90px;height:90px}.customize-option.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-25px -667px;width:60px;height:60px}.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-116px -667px;width:60px;height:60px}.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-207px -667px;width:60px;height:60px}.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-298px -667px;width:60px;height:60px}.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-389px -667px;width:60px;height:60px}.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-480px -667px;width:60px;height:60px}.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-571px -652px;width:60px;height:60px}.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -652px;width:60px;height:60px}.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-730px 0;width:90px;height:90px}.customize-option.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-755px -15px;width:60px;height:60px}.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -91px;width:90px;height:90px}.customize-option.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -106px;width:60px;height:60px}.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-730px -182px;width:90px;height:90px}.customize-option.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-755px -197px;width:60px;height:60px}.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -273px;width:90px;height:90px}.customize-option.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -288px;width:60px;height:60px}.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-730px -364px;width:90px;height:90px}.customize-option.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-755px -379px;width:60px;height:60px}.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -455px;width:90px;height:90px}.customize-option.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -470px;width:60px;height:60px}.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-730px -546px;width:90px;height:90px}.customize-option.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-755px -561px;width:60px;height:60px}.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -637px;width:90px;height:90px}.customize-option.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -652px;width:60px;height:60px}.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:0 -728px;width:90px;height:90px}.customize-option.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:-25px -743px;width:60px;height:60px}.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -743px;width:60px;height:60px}.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-207px -743px;width:60px;height:60px}.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -743px;width:60px;height:60px}.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-389px -743px;width:60px;height:60px}.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -743px;width:60px;height:60px}.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-571px -743px;width:60px;height:60px}.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -743px;width:60px;height:60px}.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-753px -743px;width:60px;height:60px}.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px 0;width:90px;height:90px}.customize-option.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -15px;width:60px;height:60px}.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-821px -91px;width:90px;height:90px}.customize-option.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-846px -106px;width:60px;height:60px}.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -182px;width:90px;height:90px}.customize-option.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -197px;width:60px;height:60px}.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-821px -273px;width:90px;height:90px}.customize-option.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-846px -288px;width:60px;height:60px}.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -364px;width:90px;height:90px}.customize-option.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -379px;width:60px;height:60px}.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-821px -455px;width:90px;height:90px}.customize-option.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-846px -470px;width:60px;height:60px}.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -546px;width:90px;height:90px}.customize-option.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -561px;width:60px;height:60px}.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-821px -637px;width:90px;height:90px}.customize-option.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-846px -652px;width:60px;height:60px}.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -728px;width:90px;height:90px}.customize-option.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -743px;width:60px;height:60px}.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:0 -819px;width:90px;height:90px}.customize-option.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-25px -834px;width:60px;height:60px}.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -834px;width:60px;height:60px}.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-207px -834px;width:60px;height:60px}.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -834px;width:60px;height:60px}.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-389px -834px;width:60px;height:60px}.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -834px;width:60px;height:60px}.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-571px -834px;width:60px;height:60px}.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -834px;width:60px;height:60px}.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-753px -834px;width:60px;height:60px}.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -834px;width:60px;height:60px}.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-912px 0;width:90px;height:90px}.customize-option.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-937px -15px;width:60px;height:60px}.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -91px;width:90px;height:90px}.customize-option.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -106px;width:60px;height:60px}.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-912px -182px;width:90px;height:90px}.customize-option.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-937px -197px;width:60px;height:60px}.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -273px;width:90px;height:90px}.customize-option.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -288px;width:60px;height:60px}.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-912px -364px;width:90px;height:90px}.customize-option.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-937px -379px;width:60px;height:60px}.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -455px;width:90px;height:90px}.customize-option.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -470px;width:60px;height:60px}.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-912px -546px;width:90px;height:90px}.customize-option.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-937px -561px;width:60px;height:60px}.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -637px;width:90px;height:90px}.customize-option.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -652px;width:60px;height:60px}.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-912px -728px;width:90px;height:90px}.customize-option.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-937px -743px;width:60px;height:60px}.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -819px;width:90px;height:90px}.customize-option.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -834px;width:60px;height:60px}.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:0 -910px;width:90px;height:90px}.customize-option.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-25px -925px;width:60px;height:60px}.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -925px;width:60px;height:60px}.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-207px -925px;width:60px;height:60px}.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -925px;width:60px;height:60px}.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-389px -925px;width:60px;height:60px}.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -925px;width:60px;height:60px}.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-571px -925px;width:60px;height:60px}.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -925px;width:60px;height:60px}.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-753px -925px;width:60px;height:60px}.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -925px;width:60px;height:60px}.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-935px -925px;width:60px;height:60px}.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px 0;width:90px;height:90px}.customize-option.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -15px;width:60px;height:60px}.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1003px -91px;width:90px;height:90px}.customize-option.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1028px -106px;width:60px;height:60px}.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -182px;width:90px;height:90px}.customize-option.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -197px;width:60px;height:60px}.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1003px -273px;width:90px;height:90px}.customize-option.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1028px -288px;width:60px;height:60px}.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -364px;width:90px;height:90px}.customize-option.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -379px;width:60px;height:60px}.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-1003px -455px;width:90px;height:90px}.customize-option.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-1028px -470px;width:60px;height:60px}.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -546px;width:90px;height:90px}.customize-option.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -561px;width:60px;height:60px}.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-1003px -637px;width:90px;height:90px}.customize-option.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-1028px -652px;width:60px;height:60px}.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -728px;width:90px;height:90px}.customize-option.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -743px;width:60px;height:60px}.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-1003px -819px;width:90px;height:90px}.customize-option.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-1028px -834px;width:60px;height:60px}.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -925px;width:60px;height:60px}.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-25px -1016px;width:60px;height:60px}.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1016px;width:60px;height:60px}.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-207px -1016px;width:60px;height:60px}.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1016px;width:60px;height:60px}.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-389px -1016px;width:60px;height:60px}.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1016px;width:60px;height:60px}.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-571px -1016px;width:60px;height:60px}.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1016px;width:60px;height:60px}.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-753px -1016px;width:60px;height:60px}.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-844px -1016px;width:60px;height:60px}.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-935px -1016px;width:60px;height:60px}.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1026px -1016px;width:60px;height:60px}.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1094px 0;width:90px;height:90px}.customize-option.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1119px -15px;width:60px;height:60px}.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -91px;width:90px;height:90px}.customize-option.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -106px;width:60px;height:60px}.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1094px -182px;width:90px;height:90px}.customize-option.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1119px -197px;width:60px;height:60px}.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -273px;width:90px;height:90px}.customize-option.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -288px;width:60px;height:60px}.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1094px -364px;width:90px;height:90px}.customize-option.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1119px -379px;width:60px;height:60px}.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-1094px -455px;width:90px;height:90px}.customize-option.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-1119px -470px;width:60px;height:60px}.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -546px;width:90px;height:90px}.customize-option.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -561px;width:60px;height:60px}.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -637px;width:90px;height:90px}.customize-option.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -652px;width:60px;height:60px}.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-1094px -728px;width:90px;height:90px}.customize-option.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-1119px -743px;width:60px;height:60px}.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -819px;width:90px;height:90px}.customize-option.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -834px;width:60px;height:60px}.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-1094px -910px;width:90px;height:90px}.customize-option.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-1119px -925px;width:60px;height:60px}.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-1094px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-1119px -1016px;width:60px;height:60px}.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-25px -1107px;width:60px;height:60px}.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1107px;width:60px;height:60px}.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-207px -1107px;width:60px;height:60px}.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1107px;width:60px;height:60px}.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-389px -1107px;width:60px;height:60px}.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1107px;width:60px;height:60px}.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-571px -1107px;width:60px;height:60px}.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1107px;width:60px;height:60px}.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-753px -1107px;width:60px;height:60px}.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -1107px;width:60px;height:60px}.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-935px -1107px;width:60px;height:60px}.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1026px -1107px;width:60px;height:60px}.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1117px -1107px;width:60px;height:60px}.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1185px 0;width:90px;height:90px}.customize-option.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1210px -15px;width:60px;height:60px}.broad_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -91px;width:90px;height:90px}.broad_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1185px -182px;width:90px;height:90px}.broad_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -273px;width:90px;height:90px}.broad_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -364px;width:90px;height:90px}.broad_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1185px -455px;width:90px;height:90px}.broad_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -546px;width:90px;height:90px}.broad_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -637px;width:90px;height:90px}.broad_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -728px;width:90px;height:90px}.eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1185px -819px;width:90px;height:90px}.head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1185px -910px;width:90px;height:90px}.head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1185px -1001px;width:90px;height:90px}.head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1185px -1092px;width:90px;height:90px}.head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:0 -1183px;width:90px;height:90px}.head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-91px -1183px;width:90px;height:90px}.head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-182px -1183px;width:90px;height:90px}.head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-273px -1183px;width:90px;height:90px}.head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-364px -1183px;width:90px;height:90px}.head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-455px -1183px;width:90px;height:90px}.head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-546px -1183px;width:90px;height:90px}.head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-637px -1183px;width:90px;height:90px}.head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-728px -1183px;width:90px;height:90px}.head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-819px -1183px;width:90px;height:90px}.head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-910px -1183px;width:90px;height:90px}.head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1001px -1183px;width:90px;height:90px}.head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1092px -1183px;width:90px;height:90px}.shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1183px -1183px;width:90px;height:90px}.shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1276px 0;width:90px;height:90px}.shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1276px -91px;width:90px;height:90px}.shop_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -943px;width:40px;height:40px}.shop_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1640px -779px;width:40px;height:40px}.shop_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -738px;width:40px;height:40px}.shop_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -697px;width:40px;height:40px}.shop_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1640px -656px;width:40px;height:40px}.shop_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -533px;width:40px;height:40px}.shop_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -984px;width:40px;height:40px}.shop_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -492px;width:40px;height:40px}.shop_eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1640px -451px;width:40px;height:40px}.shop_head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -410px;width:40px;height:40px}.shop_head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -369px;width:40px;height:40px}.shop_head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1640px -328px;width:40px;height:40px}.shop_head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -287px;width:40px;height:40px}.shop_head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-1640px -246px;width:40px;height:40px}.shop_head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -205px;width:40px;height:40px}.shop_head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-1640px -164px;width:40px;height:40px}.shop_head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -123px;width:40px;height:40px}.shop_head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -82px;width:40px;height:40px}.shop_head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -41px;width:40px;height:40px}.shop_head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1640px 0;width:40px;height:40px}.shop_head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1576px -1588px;width:40px;height:40px}.shop_head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1535px -1588px;width:40px;height:40px}.shop_head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-1494px -1588px;width:40px;height:40px}.shop_head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1453px -1588px;width:40px;height:40px}.shop_head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1588px;width:40px;height:40px}.shop_shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1576px -1547px;width:40px;height:40px}.shop_shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1535px -1547px;width:40px;height:40px}.shop_shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1494px -1547px;width:40px;height:40px}.shop_weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-1453px -1547px;width:40px;height:40px}.shop_weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-1412px -1547px;width:40px;height:40px}.shop_weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-1371px -1547px;width:40px;height:40px}.shop_weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-1330px -1547px;width:40px;height:40px}.shop_weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-1289px -1547px;width:40px;height:40px}.shop_weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-1248px -1547px;width:40px;height:40px}.shop_weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-1207px -1547px;width:40px;height:40px}.shop_weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-1166px -1547px;width:40px;height:40px}.slim_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -819px;width:90px;height:90px}.slim_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1367px -910px;width:90px;height:90px}.slim_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -1001px;width:90px;height:90px}.slim_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -1092px;width:90px;height:90px}.slim_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1367px -1183px;width:90px;height:90px}.slim_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1367px -1274px;width:90px;height:90px}.slim_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:0 -1365px;width:90px;height:90px}.slim_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-91px -1365px;width:90px;height:90px}.weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1365px;width:90px;height:90px}.weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-273px -1365px;width:90px;height:90px}.weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-364px -1365px;width:90px;height:90px}.weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-455px -1365px;width:90px;height:90px}.weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-546px -1365px;width:90px;height:90px}.weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-637px -1365px;width:90px;height:90px}.weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-728px -1365px;width:90px;height:90px}.weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-819px -1365px;width:90px;height:90px}.broad_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-910px -1365px;width:90px;height:90px}.broad_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1365px;width:90px;height:90px}.broad_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1092px -1365px;width:90px;height:90px}.broad_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1183px -1365px;width:90px;height:90px}.broad_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1365px;width:90px;height:90px}.broad_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1365px -1365px;width:90px;height:90px}.broad_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1458px 0;width:90px;height:90px}.broad_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -91px;width:90px;height:90px}.broad_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -182px;width:90px;height:90px}.broad_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -273px;width:90px;height:90px}.broad_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -364px;width:90px;height:90px}.broad_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1458px -455px;width:90px;height:90px}.broad_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -546px;width:90px;height:90px}.broad_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -637px;width:90px;height:90px}.broad_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -728px;width:90px;height:90px}.broad_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -819px;width:90px;height:90px}.broad_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -910px;width:90px;height:90px}.broad_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -1001px;width:90px;height:90px}.broad_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -1092px;width:90px;height:90px}.broad_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -1183px;width:90px;height:90px}.broad_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -1274px;width:90px;height:90px}.broad_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -1365px;width:90px;height:90px}.shop_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1125px -1547px;width:40px;height:40px}.shop_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1084px -1547px;width:40px;height:40px}.shop_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1043px -1547px;width:40px;height:40px}.shop_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1002px -1547px;width:40px;height:40px}.shop_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-961px -1547px;width:40px;height:40px}.shop_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-920px -1547px;width:40px;height:40px}.shop_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-879px -1547px;width:40px;height:40px}.shop_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-838px -1547px;width:40px;height:40px}.shop_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-797px -1547px;width:40px;height:40px}.shop_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-756px -1547px;width:40px;height:40px}.shop_armor_special_0{background-image:url(spritesmith-main-3.png);background-position:-715px -1547px;width:40px;height:40px}.shop_armor_special_1{background-image:url(spritesmith-main-3.png);background-position:-674px -1547px;width:40px;height:40px}.shop_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-551px -1547px;width:40px;height:40px}.shop_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-510px -1547px;width:40px;height:40px}.shop_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-469px -1547px;width:40px;height:40px}.shop_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-428px -1547px;width:40px;height:40px}.shop_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-387px -1547px;width:40px;height:40px}.shop_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-346px -1547px;width:40px;height:40px}.shop_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-305px -1547px;width:40px;height:40px}.shop_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-264px -1547px;width:40px;height:40px}.shop_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-223px -1547px;width:40px;height:40px}.shop_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-182px -1547px;width:40px;height:40px}.shop_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-633px -1588px;width:40px;height:40px}.shop_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-400px -314px;width:40px;height:40px}.slim_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -637px;width:90px;height:90px}.slim_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -728px;width:90px;height:90px}.slim_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -819px;width:90px;height:90px}.slim_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -910px;width:90px;height:90px}.slim_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -1001px;width:90px;height:90px}.slim_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -1092px;width:90px;height:90px}.slim_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -1183px;width:90px;height:90px}.slim_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -1274px;width:90px;height:90px}.slim_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -1365px;width:90px;height:90px}.slim_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -1456px;width:90px;height:90px}.slim_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:0 -1547px;width:90px;height:90px}.slim_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-91px -1547px;width:90px;height:90px}.slim_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -546px;width:90px;height:90px}.slim_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -455px;width:90px;height:90px}.slim_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -364px;width:90px;height:90px}.slim_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -273px;width:90px;height:90px}.slim_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -182px;width:90px;height:90px}.slim_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -91px;width:90px;height:90px}.slim_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1549px 0;width:90px;height:90px}.slim_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1456px -1456px;width:90px;height:90px}.slim_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1365px -1456px;width:90px;height:90px}.slim_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1456px;width:90px;height:90px}.broad_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1183px -1456px;width:90px;height:90px}.broad_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-1092px -1456px;width:90px;height:90px}.shop_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-592px -1547px;width:40px;height:40px}.shop_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-633px -1547px;width:40px;height:40px}.slim_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1001px -1456px;width:90px;height:90px}.slim_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-910px -1456px;width:90px;height:90px}.broad_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-212px -273px;width:93px;height:90px}.broad_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -273px;width:105px;height:90px}.broad_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-637px -1456px;width:90px;height:90px}.broad_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-546px -1456px;width:90px;height:90px}.broad_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-455px -1456px;width:90px;height:90px}.broad_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px -91px;width:120px;height:90px}.broad_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px -182px;width:105px;height:90px}.broad_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-182px -1456px;width:90px;height:90px}.head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-306px -273px;width:93px;height:90px}.head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-348px -91px;width:105px;height:90px}.head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1367px -728px;width:90px;height:90px}.head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1367px -637px;width:90px;height:90px}.head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1367px -546px;width:90px;height:90px}.head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 -91px;width:120px;height:90px}.head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-212px -182px;width:105px;height:90px}.head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1367px -273px;width:90px;height:90px}.shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px 0;width:93px;height:90px}.shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:0 -273px;width:105px;height:90px}.shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1367px 0;width:90px;height:90px}.shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1274px -1274px;width:90px;height:90px}.shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:0 -182px;width:105px;height:90px}.shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1092px -1274px;width:90px;height:90px}.shop_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-223px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-264px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-305px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-346px -1588px;width:40px;height:40px}.shop_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-387px -1588px;width:40px;height:40px}.shop_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-428px -1588px;width:40px;height:40px}.shop_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-469px -1588px;width:40px;height:40px}.shop_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-510px -1588px;width:40px;height:40px}.shop_head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-551px -1588px;width:40px;height:40px}.shop_head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-592px -1588px;width:40px;height:40px}.shop_head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-400px -273px;width:40px;height:40px}.shop_head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-674px -1588px;width:40px;height:40px}.shop_head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-715px -1588px;width:40px;height:40px}.shop_head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-756px -1588px;width:40px;height:40px}.shop_head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-797px -1588px;width:40px;height:40px}.shop_head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-838px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-879px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-920px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-961px -1588px;width:40px;height:40px}.shop_shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1002px -1588px;width:40px;height:40px}.shop_shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1043px -1588px;width:40px;height:40px}.shop_shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1084px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-1125px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-1166px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1207px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1248px -1588px;width:40px;height:40px}.shop_weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1289px -1588px;width:40px;height:40px}.shop_weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-1330px -1588px;width:40px;height:40px}.shop_weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1371px -1588px;width:40px;height:40px}.shop_weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1412px -1588px;width:40px;height:40px}.slim_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px -91px;width:93px;height:90px}.slim_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-242px -91px;width:105px;height:90px}.slim_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-819px -1274px;width:90px;height:90px}.slim_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-728px -1274px;width:90px;height:90px}.slim_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-637px -1274px;width:90px;height:90px}.slim_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 0;width:120px;height:90px}.slim_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px 0;width:105px;height:90px}.slim_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-364px -1274px;width:90px;height:90px}.weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px -182px;width:93px;height:90px}.weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -182px;width:105px;height:90px}.weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-91px -1274px;width:90px;height:90px}.weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:0 -1274px;width:90px;height:90px}.weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1276px -1183px;width:90px;height:90px}.weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px 0;width:120px;height:90px}.weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-242px 0;width:105px;height:90px}.weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1276px -910px;width:90px;height:90px}.broad_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -819px;width:90px;height:90px}.head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -637px;width:90px;height:90px}.shop_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1640px -574px;width:40px;height:40px}.shop_head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1640px -615px;width:40px;height:40px}.slim_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -546px;width:90px;height:90px}.back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -455px;width:90px;height:90px}.broad_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -364px;width:90px;height:90px}.head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -273px;width:90px;height:90px}.shop_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -820px;width:40px;height:40px}.shop_back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -861px;width:40px;height:40px}.shop_head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -902px;width:40px;height:40px}.slim_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -182px;width:90px;height:90px}.broad_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1276px -1001px;width:90px;height:90px}.headAccessory_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-739px -309px;width:90px;height:90px}.shop_armor_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1018px -910px;width:40px;height:40px}.shop_headAccessory_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1646px -820px;width:40px;height:40px}.slim_armor_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-461px -788px;width:90px;height:90px}.back_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-546px -1152px;width:90px;height:90px}.headAccessory_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1183px -1243px;width:90px;height:90px}.shop_back_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1646px -779px;width:40px;height:40px}.shop_headAccessory_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1646px -738px;width:40px;height:40px}.broad_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -364px;width:90px;height:90px}.head_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -455px;width:90px;height:90px}.shop_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-82px -1598px;width:40px;height:40px}.shop_head_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-41px -1598px;width:40px;height:40px}.slim_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1382px -819px;width:90px;height:90px}.broad_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-739px -212px;width:90px;height:96px}.head_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-830px -188px;width:90px;height:96px}.shop_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:0 -1598px;width:40px;height:40px}.shop_head_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-1605px -1517px;width:40px;height:40px}.slim_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-830px -91px;width:90px;height:96px}.broad_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-552px -788px;width:90px;height:90px}.head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-825px -788px;width:90px;height:90px}.shop_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -1476px;width:40px;height:40px}.shop_head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1605px -1435px;width:40px;height:40px}.slim_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:0 -879px;width:90px;height:90px}.broad_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-91px -879px;width:90px;height:90px}.head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1200px -910px;width:90px;height:90px}.shop_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1605px -1394px;width:40px;height:40px}.shop_head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1605px -1353px;width:40px;height:40px}.slim_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1291px -273px;width:90px;height:90px}.broad_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1001px -1243px;width:90px;height:90px}.headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1092px -1243px;width:90px;height:90px}.shop_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1605px -1312px;width:40px;height:40px}.shop_headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1605px -1271px;width:40px;height:40px}.slim_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-1382px -182px;width:90px;height:90px}.back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -649px;width:93px;height:90px}.broad_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -558px;width:93px;height:90px}.shop_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1605px -1230px;width:40px;height:40px}.shop_back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1605px -1189px;width:40px;height:40px}.slim_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -376px;width:93px;height:90px}.head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -728px;width:90px;height:90px}.shop_head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1605px -1148px;width:40px;height:40px}.shop_weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1605px -1107px;width:40px;height:40px}.weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1382px -1183px;width:90px;height:90px}.broad_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:0 -1334px;width:90px;height:90px}.head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-91px -1334px;width:90px;height:90px}.shop_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1605px -1066px;width:40px;height:40px}.shop_head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1605px -1025px;width:40px;height:40px}.slim_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-364px -1334px;width:90px;height:90px}.broad_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-455px -1334px;width:90px;height:90px}.head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-546px -1334px;width:90px;height:90px}.shop_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1605px -984px;width:40px;height:40px}.shop_head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1605px -943px;width:40px;height:40px}.slim_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-910px -1334px;width:90px;height:90px}.headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1001px -1334px;width:90px;height:90px}.shop_headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1605px -902px;width:40px;height:40px}.shop_weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1605px -861px;width:40px;height:40px}.weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1274px -1334px;width:90px;height:90px}.broad_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1473px -728px;width:90px;height:90px}.eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1473px -1274px;width:90px;height:90px}.shop_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1605px -492px;width:40px;height:40px}.shop_eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-1605px -451px;width:40px;height:40px}.slim_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-182px -1425px;width:90px;height:90px}.back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-273px -1425px;width:90px;height:90px}.broad_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-364px -1425px;width:90px;height:90px}.shop_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1605px -410px;width:40px;height:40px}.shop_back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1605px -369px;width:40px;height:40px}.slim_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-819px -1425px;width:90px;height:90px}.head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-910px -1425px;width:90px;height:90px}.shop_head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1605px -328px;width:40px;height:40px}.shop_weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1605px -287px;width:40px;height:40px}.weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-739px -400px;width:90px;height:90px}.broad_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-546px -485px;width:90px;height:105px}.eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-648px -106px;width:90px;height:105px}.shop_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1605px -246px;width:40px;height:40px}.shop_eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1605px -205px;width:40px;height:40px}.slim_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-648px -318px;width:90px;height:105px}.back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-648px -424px;width:90px;height:105px}.eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:0 -591px;width:90px;height:105px}.shop_back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-779px -1557px;width:40px;height:40px}.shop_eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-738px -1557px;width:40px;height:40px}.broad_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:0 -788px;width:93px;height:90px}.head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -467px;width:93px;height:90px}.shop_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-697px -1557px;width:40px;height:40px}.shop_head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-656px -1557px;width:40px;height:40px}.slim_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -285px;width:93px;height:90px}.broad_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -364px;width:90px;height:90px}.head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -455px;width:90px;height:90px}.shop_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-615px -1557px;width:40px;height:40px}.shop_head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1473px -1365px;width:40px;height:40px}.slim_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-927px -728px;width:90px;height:90px}.back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-536px -394px;width:105px;height:90px}.headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-94px -788px;width:93px;height:90px}.shop_back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-533px -1557px;width:40px;height:40px}.shop_headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-492px -1557px;width:40px;height:40px}.broad_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-364px -879px;width:90px;height:90px}.eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-455px -879px;width:90px;height:90px}.head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-546px -879px;width:90px;height:90px}.shop_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-451px -1557px;width:40px;height:40px}.shop_eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-410px -1557px;width:40px;height:40px}.shop_head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-369px -1557px;width:40px;height:40px}.shop_weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-328px -1557px;width:40px;height:40px}.slim_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1018px 0;width:90px;height:90px}.weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1018px -91px;width:90px;height:90px}.eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -182px;width:90px;height:90px}.headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -273px;width:90px;height:90px}.head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -364px;width:90px;height:90px}.shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1018px -455px;width:90px;height:90px}.shop_eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-287px -1557px;width:40px;height:40px}.shop_headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-246px -1557px;width:40px;height:40px}.shop_head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-205px -1557px;width:40px;height:40px}.shop_shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-164px -1557px;width:40px;height:40px}.broad_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:0 -970px;width:90px;height:90px}.broad_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-91px -970px;width:90px;height:90px}.broad_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-182px -970px;width:90px;height:90px}.broad_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -970px;width:90px;height:90px}.broad_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -970px;width:90px;height:90px}.broad_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-455px -970px;width:90px;height:90px}.broad_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-546px -970px;width:90px;height:90px}.broad_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-637px -970px;width:90px;height:90px}.headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -970px;width:90px;height:90px}.headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -970px;width:90px;height:90px}.headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-910px -970px;width:90px;height:90px}.headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1001px -970px;width:90px;height:90px}.headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1109px 0;width:90px;height:90px}.headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1109px -91px;width:90px;height:90px}.headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -182px;width:90px;height:90px}.headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -273px;width:90px;height:90px}.head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1109px -364px;width:90px;height:90px}.head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1109px -455px;width:90px;height:90px}.head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -546px;width:90px;height:90px}.head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -637px;width:90px;height:90px}.head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1109px -728px;width:90px;height:90px}.head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1109px -819px;width:90px;height:90px}.head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1109px -910px;width:90px;height:90px}.head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:0 -1061px;width:90px;height:90px}.shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1061px;width:90px;height:90px}.shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-182px -1061px;width:90px;height:90px}.shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -1061px;width:90px;height:90px}.shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -1061px;width:90px;height:90px}.shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-455px -1061px;width:90px;height:90px}.shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-546px -1061px;width:90px;height:90px}.shop_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-123px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-82px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-41px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -1557px;width:40px;height:40px}.shop_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1476px;width:40px;height:40px}.shop_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -1435px;width:40px;height:40px}.shop_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -1394px;width:40px;height:40px}.shop_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -1066px;width:40px;height:40px}.shop_headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -1025px;width:40px;height:40px}.shop_headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -984px;width:40px;height:40px}.shop_headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -943px;width:40px;height:40px}.shop_headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -902px;width:40px;height:40px}.shop_headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -861px;width:40px;height:40px}.shop_headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -820px;width:40px;height:40px}.shop_headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -779px;width:40px;height:40px}.shop_headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -738px;width:40px;height:40px}.shop_head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -697px;width:40px;height:40px}.shop_head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px -656px;width:40px;height:40px}.shop_head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -615px;width:40px;height:40px}.shop_head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -574px;width:40px;height:40px}.shop_head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -533px;width:40px;height:40px}.shop_head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1564px -492px;width:40px;height:40px}.shop_head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -451px;width:40px;height:40px}.shop_head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -410px;width:40px;height:40px}.shop_shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1564px -369px;width:40px;height:40px}.shop_shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -328px;width:40px;height:40px}.shop_shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -287px;width:40px;height:40px}.shop_shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1564px -246px;width:40px;height:40px}.shop_shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -205px;width:40px;height:40px}.shop_shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -164px;width:40px;height:40px}.shop_weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-369px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-328px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-287px -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-246px -1516px;width:40px;height:40px}.shop_weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-205px -1516px;width:40px;height:40px}.shop_weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-164px -1516px;width:40px;height:40px}.shop_weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-123px -1516px;width:40px;height:40px}.shop_weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-82px -1516px;width:40px;height:40px}.slim_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -546px;width:90px;height:90px}.slim_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1291px -637px;width:90px;height:90px}.slim_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -728px;width:90px;height:90px}.slim_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -819px;width:90px;height:90px}.slim_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1291px -910px;width:90px;height:90px}.slim_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1291px -1001px;width:90px;height:90px}.slim_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -1092px;width:90px;height:90px}.slim_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:0 -1243px;width:90px;height:90px}.weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-91px -1243px;width:90px;height:90px}.weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-182px -1243px;width:90px;height:90px}.weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-273px -1243px;width:90px;height:90px}.weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-364px -1243px;width:90px;height:90px}.weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -1243px;width:90px;height:90px}.weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-546px -1243px;width:90px;height:90px}.weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-637px -1243px;width:90px;height:90px}.weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-728px -1243px;width:90px;height:90px}.body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-819px -1243px;width:90px;height:90px}.body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-910px -1243px;width:90px;height:90px}.body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px -106px;width:102px;height:105px}.body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -485px;width:90px;height:105px}.body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -485px;width:90px;height:105px}.body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-455px -485px;width:90px;height:105px}.broad_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px 0;width:90px;height:90px}.broad_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1382px -91px;width:90px;height:90px}.broad_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 -106px;width:102px;height:105px}.broad_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-648px 0;width:90px;height:105px}.broad_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-182px -591px;width:90px;height:105px}.broad_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -212px;width:90px;height:105px}.broad_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-424px -182px;width:111px;height:90px}.broad_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -273px;width:111px;height:90px}.eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -394px;width:111px;height:90px}.eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-112px -394px;width:111px;height:90px}.head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1382px -910px;width:90px;height:90px}.head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1382px -1001px;width:90px;height:90px}.head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px 0;width:102px;height:105px}.head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -591px;width:90px;height:105px}.head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-364px -591px;width:90px;height:105px}.head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-455px -591px;width:90px;height:105px}.head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-336px -394px;width:111px;height:90px}.head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-536px 0;width:111px;height:90px}.Healer_Summer{background-image:url(spritesmith-main-4.png);background-position:-637px -591px;width:90px;height:105px}.Mage_Summer{background-image:url(spritesmith-main-4.png);background-position:-739px 0;width:90px;height:105px}.SummerRogue14{background-image:url(spritesmith-main-4.png);background-position:-424px 0;width:111px;height:90px}.SummerWarrior14{background-image:url(spritesmith-main-4.png);background-position:-536px -91px;width:111px;height:90px}.shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1334px;width:90px;height:90px}.shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 0;width:102px;height:105px}.shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -485px;width:90px;height:105px}.shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-536px -288px;width:90px;height:105px}.shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:0 -303px;width:111px;height:90px}.shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -182px;width:111px;height:90px}.shop_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-41px -1516px;width:40px;height:40px}.shop_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:0 -1516px;width:40px;height:40px}.shop_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1488px -1466px;width:40px;height:40px}.shop_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1447px -1466px;width:40px;height:40px}.shop_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1406px -1466px;width:40px;height:40px}.shop_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1466px;width:40px;height:40px}.shop_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1488px -1425px;width:40px;height:40px}.shop_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1447px -1425px;width:40px;height:40px}.shop_body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1406px -1425px;width:40px;height:40px}.shop_body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1425px;width:40px;height:40px}.shop_body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-489px -435px;width:40px;height:40px}.shop_body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-448px -435px;width:40px;height:40px}.shop_body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-489px -394px;width:40px;height:40px}.shop_body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-448px -394px;width:40px;height:40px}.shop_eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-377px -344px;width:40px;height:40px}.shop_eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-336px -344px;width:40px;height:40px}.shop_head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-377px -303px;width:40px;height:40px}.shop_head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-336px -303px;width:40px;height:40px}.shop_head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-230px -253px;width:40px;height:40px}.shop_head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-230px -212px;width:40px;height:40px}.shop_head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-689px -530px;width:40px;height:40px}.shop_head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px -530px;width:40px;height:40px}.shop_head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-871px -740px;width:40px;height:40px}.shop_head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-830px -740px;width:40px;height:40px}.shop_shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-968px -819px;width:40px;height:40px}.shop_shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-927px -819px;width:40px;height:40px}.shop_shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1059px -910px;width:40px;height:40px}.shop_shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1646px -861px;width:40px;height:40px}.shop_shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1150px -1001px;width:40px;height:40px}.shop_shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1109px -1001px;width:40px;height:40px}.shop_weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1241px -1092px;width:40px;height:40px}.shop_weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1092px;width:40px;height:40px}.shop_weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1514px -1365px;width:40px;height:40px}.shop_weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-574px -1557px;width:40px;height:40px}.shop_weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1382px -1274px;width:40px;height:40px}.shop_weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1423px -1274px;width:40px;height:40px}.shop_weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1291px -1183px;width:40px;height:40px}.shop_weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1332px -1183px;width:40px;height:40px}.slim_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1274px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1183px -1425px;width:90px;height:90px}.slim_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px 0;width:102px;height:105px}.slim_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -591px;width:90px;height:105px}.slim_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-739px -106px;width:90px;height:105px}.slim_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-536px -182px;width:90px;height:105px}.slim_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -303px;width:111px;height:90px}.slim_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-424px -91px;width:111px;height:90px}.weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-546px -1425px;width:90px;height:90px}.weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-455px -1425px;width:90px;height:90px}.weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px -106px;width:102px;height:105px}.weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-91px -485px;width:90px;height:105px}.weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-182px -485px;width:90px;height:105px}.weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-91px -591px;width:90px;height:105px}.weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -394px;width:111px;height:90px}.weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -91px;width:111px;height:90px}.broad_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -1183px;width:90px;height:90px}.broad_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -1092px;width:90px;height:90px}.broad_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -1001px;width:90px;height:90px}.broad_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px -910px;width:90px;height:90px}.broad_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1473px -819px;width:90px;height:90px}.broad_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-507px -697px;width:96px;height:90px}.broad_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1473px -637px;width:90px;height:90px}.broad_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1473px -546px;width:90px;height:90px}.head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1473px -455px;width:90px;height:90px}.head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-1473px -364px;width:90px;height:90px}.head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-1473px -273px;width:90px;height:90px}.head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1473px -182px;width:90px;height:90px}.head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1473px -91px;width:90px;height:90px}.head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1473px 0;width:90px;height:90px}.head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1365px -1334px;width:90px;height:90px}.head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-410px -697px;width:96px;height:90px}.head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -455px;width:90px;height:90px}.head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1291px -364px;width:90px;height:90px}.shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -697px;width:104px;height:90px}.shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1291px -182px;width:90px;height:90px}.shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -91px;width:90px;height:90px}.shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-604px -697px;width:96px;height:90px}.shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1183px -1152px;width:90px;height:90px}.shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1092px -1152px;width:90px;height:90px}.shop_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-410px -1516px;width:40px;height:40px}.shop_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-451px -1516px;width:40px;height:40px}.shop_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-492px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-533px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-574px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-615px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-656px -1516px;width:40px;height:40px}.shop_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-697px -1516px;width:40px;height:40px}.shop_head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-738px -1516px;width:40px;height:40px}.shop_head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-779px -1516px;width:40px;height:40px}.shop_head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-820px -1516px;width:40px;height:40px}.shop_head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-861px -1516px;width:40px;height:40px}.shop_head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-902px -1516px;width:40px;height:40px}.shop_head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-943px -1516px;width:40px;height:40px}.shop_head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-984px -1516px;width:40px;height:40px}.shop_head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1025px -1516px;width:40px;height:40px}.shop_head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1066px -1516px;width:40px;height:40px}.shop_head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1107px -1516px;width:40px;height:40px}.shop_shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1148px -1516px;width:40px;height:40px}.shop_shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1189px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1230px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1271px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1312px -1516px;width:40px;height:40px}.shop_shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1353px -1516px;width:40px;height:40px}.shop_weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1394px -1516px;width:40px;height:40px}.shop_weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1435px -1516px;width:40px;height:40px}.shop_weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1476px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1517px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px 0;width:40px;height:40px}.shop_weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -41px;width:40px;height:40px}.shop_weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -82px;width:40px;height:40px}.shop_weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1564px -123px;width:40px;height:40px}.slim_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1001px -1152px;width:90px;height:90px}.slim_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-910px -1152px;width:90px;height:90px}.slim_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-819px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-637px -1152px;width:90px;height:90px}.slim_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-701px -697px;width:96px;height:90px}.slim_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-455px -1152px;width:90px;height:90px}.slim_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-364px -1152px;width:90px;height:90px}.weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-273px -1152px;width:90px;height:90px}.weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-182px -1152px;width:90px;height:90px}.weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-91px -1152px;width:90px;height:90px}.weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:0 -1152px;width:90px;height:90px}.weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -1001px;width:90px;height:90px}.weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-313px -697px;width:96px;height:90px}.weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1200px -819px;width:90px;height:90px}.weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1200px -728px;width:90px;height:90px}.back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -637px;width:90px;height:90px}.back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -546px;width:90px;height:90px}.body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -455px;width:90px;height:90px}.body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1200px -364px;width:90px;height:90px}.body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -273px;width:90px;height:90px}.eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1200px -182px;width:90px;height:90px}.eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1200px -91px;width:90px;height:90px}.shop_back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1107px;width:40px;height:40px}.shop_back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1148px;width:40px;height:40px}.shop_body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1189px;width:40px;height:40px}.shop_body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1564px -1230px;width:40px;height:40px}.shop_body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1271px;width:40px;height:40px}.shop_eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1312px;width:40px;height:40px}.shop_eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1353px;width:40px;height:40px}.head_0{background-image:url(spritesmith-main-4.png);background-position:-1200px 0;width:90px;height:90px}.customize-option.head_0{background-image:url(spritesmith-main-4.png);background-position:-1225px -15px;width:60px;height:60px}.head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1092px -1061px;width:90px;height:90px}.head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1001px -1061px;width:90px;height:90px}.head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-910px -1061px;width:90px;height:90px}.head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-819px -1061px;width:90px;height:90px}.head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-728px -1061px;width:90px;height:90px}.head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-637px -1061px;width:90px;height:90px}.head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1018px -819px;width:90px;height:90px}.head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1018px -728px;width:90px;height:90px}.head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1018px -637px;width:90px;height:90px}.head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1018px -546px;width:90px;height:90px}.head_special_2{background-image:url(spritesmith-main-4.png);background-position:-910px -879px;width:90px;height:90px}.head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-819px -879px;width:90px;height:90px}.head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-728px -879px;width:90px;height:90px}.head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-637px -879px;width:90px;height:90px}.head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-273px -879px;width:90px;height:90px}.head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-182px -879px;width:90px;height:90px}.head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-927px -637px;width:90px;height:90px}.head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-927px -546px;width:90px;height:90px}.head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-927px -182px;width:90px;height:90px}.head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-927px -91px;width:90px;height:90px}.head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-734px -788px;width:90px;height:90px}.head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-643px -788px;width:90px;height:90px}.shop_head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-820px -1557px;width:40px;height:40px}.shop_head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-861px -1557px;width:40px;height:40px}.shop_head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-902px -1557px;width:40px;height:40px}.shop_head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-943px -1557px;width:40px;height:40px}.shop_head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-984px -1557px;width:40px;height:40px}.shop_head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1025px -1557px;width:40px;height:40px}.shop_head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1066px -1557px;width:40px;height:40px}.shop_head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1107px -1557px;width:40px;height:40px}.shop_head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1148px -1557px;width:40px;height:40px}.shop_head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1189px -1557px;width:40px;height:40px}.shop_head_special_0{background-image:url(spritesmith-main-4.png);background-position:-1230px -1557px;width:40px;height:40px}.shop_head_special_1{background-image:url(spritesmith-main-4.png);background-position:-1271px -1557px;width:40px;height:40px}.shop_head_special_2{background-image:url(spritesmith-main-4.png);background-position:-1312px -1557px;width:40px;height:40px}.shop_head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-1353px -1557px;width:40px;height:40px}.shop_head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1394px -1557px;width:40px;height:40px}.shop_head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1435px -1557px;width:40px;height:40px}.shop_head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1476px -1557px;width:40px;height:40px}.shop_head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1517px -1557px;width:40px;height:40px}.shop_head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1558px -1557px;width:40px;height:40px}.shop_head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1605px 0;width:40px;height:40px}.shop_head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1605px -41px;width:40px;height:40px}.shop_head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1605px -82px;width:40px;height:40px}.shop_head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1605px -123px;width:40px;height:40px}.shop_head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1605px -164px;width:40px;height:40px}.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-279px -788px;width:90px;height:90px}.customize-option.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-304px -803px;width:60px;height:60px}.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-188px -788px;width:90px;height:90px}.customize-option.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-213px -803px;width:60px;height:60px}.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1092px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1117px -1440px;width:60px;height:60px}.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1001px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1026px -1440px;width:60px;height:60px}.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-728px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-753px -1440px;width:60px;height:60px}.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-637px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-662px -1440px;width:60px;height:60px}.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-91px -1425px;width:90px;height:90px}.customize-option.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-116px -1440px;width:60px;height:60px}.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:0 -1425px;width:90px;height:90px}.customize-option.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-25px -1440px;width:60px;height:60px}.shop_headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -533px;width:40px;height:40px}.shop_headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -574px;width:40px;height:40px}.shop_headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -615px;width:40px;height:40px}.shop_headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -656px;width:40px;height:40px}.shop_headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -697px;width:40px;height:40px}.shop_headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -738px;width:40px;height:40px}.shop_headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -779px;width:40px;height:40px}.shop_headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -820px;width:40px;height:40px}.shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1183px -1334px;width:90px;height:90px}.shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1092px -1334px;width:90px;height:90px}.shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-819px -1334px;width:90px;height:90px}.shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-637px -1334px;width:90px;height:90px}.shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-273px -1334px;width:90px;height:90px}.shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-182px -1334px;width:90px;height:90px}.shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-105px -697px;width:103px;height:90px}.shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-209px -697px;width:103px;height:90px}.shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:0 -212px;width:114px;height:90px}.shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-830px 0;width:96px;height:90px}.shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-115px -212px;width:114px;height:90px}.shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-309px 0;width:114px;height:90px}.shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-1291px 0;width:90px;height:90px}.shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-112px -303px;width:111px;height:90px}.shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-927px -273px;width:90px;height:90px}.shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-927px 0;width:90px;height:90px}.shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-370px -788px;width:90px;height:90px}.shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-739px -582px;width:90px;height:90px}.shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1382px -637px;width:90px;height:90px}.shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1382px -546px;width:90px;height:90px}.shop_shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-123px -1598px;width:40px;height:40px}.shop_shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-164px -1598px;width:40px;height:40px}.shop_shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-205px -1598px;width:40px;height:40px}.shop_shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-246px -1598px;width:40px;height:40px}.shop_shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-287px -1598px;width:40px;height:40px}.shop_shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-328px -1598px;width:40px;height:40px}.shop_shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-369px -1598px;width:40px;height:40px}.shop_shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-410px -1598px;width:40px;height:40px}.shop_shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-451px -1598px;width:40px;height:40px}.shop_shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-492px -1598px;width:40px;height:40px}.shop_shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-533px -1598px;width:40px;height:40px}.shop_shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-574px -1598px;width:40px;height:40px}.shop_shield_special_0{background-image:url(spritesmith-main-4.png);background-position:-615px -1598px;width:40px;height:40px}.shop_shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-656px -1598px;width:40px;height:40px}.shop_shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-697px -1598px;width:40px;height:40px}.shop_shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-738px -1598px;width:40px;height:40px}.shop_shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-779px -1598px;width:40px;height:40px}.shop_shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-820px -1598px;width:40px;height:40px}.shop_shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-861px -1598px;width:40px;height:40px}.shop_shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-902px -1598px;width:40px;height:40px}.shop_shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-943px -1598px;width:40px;height:40px}.shop_weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-984px -1598px;width:40px;height:40px}.shop_weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1025px -1598px;width:40px;height:40px}.shop_weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1066px -1598px;width:40px;height:40px}.shop_weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1107px -1598px;width:40px;height:40px}.shop_weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1148px -1598px;width:40px;height:40px}.shop_weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-1189px -1598px;width:40px;height:40px}.shop_weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-1230px -1598px;width:40px;height:40px}.shop_weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-1271px -1598px;width:40px;height:40px}.shop_weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1312px -1598px;width:40px;height:40px}.shop_weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1353px -1598px;width:40px;height:40px}.shop_weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1394px -1598px;width:40px;height:40px}.shop_weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1435px -1598px;width:40px;height:40px}.shop_weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1476px -1598px;width:40px;height:40px}.shop_weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1517px -1598px;width:40px;height:40px}.shop_weapon_special_0{background-image:url(spritesmith-main-4.png);background-position:-1558px -1598px;width:40px;height:40px}.shop_weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-1599px -1598px;width:40px;height:40px}.shop_weapon_special_2{background-image:url(spritesmith-main-4.png);background-position:-1646px 0;width:40px;height:40px}.shop_weapon_special_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -41px;width:40px;height:40px}.shop_weapon_special_critical{background-image:url(spritesmith-main-4.png);background-position:-1646px -82px;width:40px;height:40px}.shop_weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-4.png);background-position:-1646px -123px;width:40px;height:40px}.shop_weapon_warrior_0{background-image:url(spritesmith-main-4.png);background-position:-1646px -164px;width:40px;height:40px}.shop_weapon_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -205px;width:40px;height:40px}.shop_weapon_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -246px;width:40px;height:40px}.shop_weapon_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -287px;width:40px;height:40px}.shop_weapon_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -328px;width:40px;height:40px}.shop_weapon_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -369px;width:40px;height:40px}.shop_weapon_warrior_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -410px;width:40px;height:40px}.shop_weapon_wizard_0{background-image:url(spritesmith-main-4.png);background-position:-1646px -451px;width:40px;height:40px}.shop_weapon_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -492px;width:40px;height:40px}.shop_weapon_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -533px;width:40px;height:40px}.shop_weapon_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -574px;width:40px;height:40px}.shop_weapon_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -615px;width:40px;height:40px}.shop_weapon_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -656px;width:40px;height:40px}.shop_weapon_wizard_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -697px;width:40px;height:40px}.weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-1382px -273px;width:90px;height:90px}.weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1274px -1243px;width:90px;height:90px}.weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-739px -491px;width:90px;height:90px}.weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1382px -1092px;width:90px;height:90px}.weapon_healer_4{background-image:url(spritesmith-main-5.png);background-position:-728px -1523px;width:90px;height:90px}.weapon_healer_5{background-image:url(spritesmith-main-5.png);background-position:-1274px -1523px;width:90px;height:90px}.weapon_healer_6{background-image:url(spritesmith-main-5.png);background-position:0 -1523px;width:90px;height:90px}.weapon_rogue_0{background-image:url(spritesmith-main-5.png);background-position:-91px -1523px;width:90px;height:90px}.weapon_rogue_1{background-image:url(spritesmith-main-5.png);background-position:-182px -1523px;width:90px;height:90px}.weapon_rogue_2{background-image:url(spritesmith-main-5.png);background-position:-273px -1523px;width:90px;height:90px}.weapon_rogue_3{background-image:url(spritesmith-main-5.png);background-position:-364px -1523px;width:90px;height:90px}.weapon_rogue_4{background-image:url(spritesmith-main-5.png);background-position:-455px -1523px;width:90px;height:90px}.weapon_rogue_5{background-image:url(spritesmith-main-5.png);background-position:-546px -1523px;width:90px;height:90px}.weapon_rogue_6{background-image:url(spritesmith-main-5.png);background-position:-637px -1523px;width:90px;height:90px}.weapon_special_1{background-image:url(spritesmith-main-5.png);background-position:-485px -1402px;width:102px;height:90px}.weapon_special_2{background-image:url(spritesmith-main-5.png);background-position:-819px -1523px;width:90px;height:90px}.weapon_special_3{background-image:url(spritesmith-main-5.png);background-position:-910px -1523px;width:90px;height:90px}.weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-5.png);background-position:-1001px -1523px;width:90px;height:90px}.weapon_warrior_0{background-image:url(spritesmith-main-5.png);background-position:-1092px -1523px;width:90px;height:90px}.weapon_warrior_1{background-image:url(spritesmith-main-5.png);background-position:-1183px -1523px;width:90px;height:90px}.weapon_warrior_2{background-image:url(spritesmith-main-5.png);background-position:-1536px 0;width:90px;height:90px}.weapon_warrior_3{background-image:url(spritesmith-main-5.png);background-position:-1536px -182px;width:90px;height:90px}.weapon_warrior_4{background-image:url(spritesmith-main-5.png);background-position:-1536px -455px;width:90px;height:90px}.weapon_warrior_5{background-image:url(spritesmith-main-5.png);background-position:-1536px -546px;width:90px;height:90px}.weapon_warrior_6{background-image:url(spritesmith-main-5.png);background-position:-1536px -637px;width:90px;height:90px}.weapon_wizard_0{background-image:url(spritesmith-main-5.png);background-position:-1536px -728px;width:90px;height:90px}.weapon_wizard_1{background-image:url(spritesmith-main-5.png);background-position:-1536px -819px;width:90px;height:90px}.weapon_wizard_2{background-image:url(spritesmith-main-5.png);background-position:-1536px -910px;width:90px;height:90px}.weapon_wizard_3{background-image:url(spritesmith-main-5.png);background-position:-1536px -1001px;width:90px;height:90px}.weapon_wizard_4{background-image:url(spritesmith-main-5.png);background-position:-1536px -1092px;width:90px;height:90px}.weapon_wizard_5{background-image:url(spritesmith-main-5.png);background-position:-1536px -1183px;width:90px;height:90px}.weapon_wizard_6{background-image:url(spritesmith-main-5.png);background-position:-1536px -1274px;width:90px;height:90px}.GrimReaper{background-image:url(spritesmith-main-5.png);background-position:-1536px -1456px;width:57px;height:66px}.Pet_Currency_Gem{background-image:url(spritesmith-main-5.png);background-position:-1476px -1402px;width:45px;height:39px}.Pet_Currency_Gem1x{background-image:url(spritesmith-main-5.png);background-position:-1767px -1692px;width:15px;height:13px}.Pet_Currency_Gem2x{background-image:url(spritesmith-main-5.png);background-position:-1627px -1584px;width:30px;height:26px}.PixelPaw-Gold{background-image:url(spritesmith-main-5.png);background-position:-1627px -544px;width:51px;height:51px}.PixelPaw{background-image:url(spritesmith-main-5.png);background-position:-1627px -492px;width:51px;height:51px}.PixelPaw002{background-image:url(spritesmith-main-5.png);background-position:-1627px -440px;width:51px;height:51px}.avatar_floral_healer{background-image:url(spritesmith-main-5.png);background-position:-385px -1402px;width:99px;height:99px}.avatar_floral_rogue{background-image:url(spritesmith-main-5.png);background-position:-285px -1402px;width:99px;height:99px}.avatar_floral_warrior{background-image:url(spritesmith-main-5.png);background-position:-185px -1402px;width:99px;height:99px}.avatar_floral_wizard{background-image:url(spritesmith-main-5.png);background-position:-85px -1402px;width:99px;height:99px}.empty_bottles{background-image:url(spritesmith-main-5.png);background-position:-1365px -1523px;width:64px;height:54px}.inventory_present{background-image:url(spritesmith-main-5.png);background-position:-490px -1666px;width:48px;height:51px}.inventory_present_01{background-image:url(spritesmith-main-5.png);background-position:-392px -1666px;width:48px;height:51px}.inventory_present_02{background-image:url(spritesmith-main-5.png);background-position:-343px -1666px;width:48px;height:51px}.inventory_present_03{background-image:url(spritesmith-main-5.png);background-position:-294px -1666px;width:48px;height:51px}.inventory_present_04{background-image:url(spritesmith-main-5.png);background-position:-1627px -596px;width:48px;height:51px}.inventory_present_05{background-image:url(spritesmith-main-5.png);background-position:-196px -1666px;width:48px;height:51px}.inventory_present_06{background-image:url(spritesmith-main-5.png);background-position:-147px -1666px;width:48px;height:51px}.inventory_present_07{background-image:url(spritesmith-main-5.png);background-position:-98px -1666px;width:48px;height:51px}.inventory_present_08{background-image:url(spritesmith-main-5.png);background-position:-49px -1666px;width:48px;height:51px}.inventory_present_09{background-image:url(spritesmith-main-5.png);background-position:0 -1666px;width:48px;height:51px}.inventory_present_10{background-image:url(spritesmith-main-5.png);background-position:-1685px -1612px;width:48px;height:51px}.inventory_present_11{background-image:url(spritesmith-main-5.png);background-position:-1685px -1560px;width:48px;height:51px}.inventory_present_12{background-image:url(spritesmith-main-5.png);background-position:-1685px -1508px;width:48px;height:51px}.inventory_quest_scroll{background-image:url(spritesmith-main-5.png);background-position:-1685px -1456px;width:48px;height:51px}.inventory_quest_scroll_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -1404px;width:48px;height:51px}.inventory_special_fortify{background-image:url(spritesmith-main-5.png);background-position:-1627px -110px;width:57px;height:54px}.inventory_special_greeting{background-image:url(spritesmith-main-5.png);background-position:-1430px -1523px;width:57px;height:54px}.inventory_special_nye{background-image:url(spritesmith-main-5.png);background-position:-1488px -1523px;width:57px;height:54px}.inventory_special_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1011px -1442px;width:40px;height:40px}.inventory_special_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1627px -385px;width:57px;height:54px}.inventory_special_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1627px -330px;width:57px;height:54px}.inventory_special_snowball{background-image:url(spritesmith-main-5.png);background-position:-1627px -275px;width:57px;height:54px}.inventory_special_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1627px -220px;width:57px;height:54px}.inventory_special_thankyou{background-image:url(spritesmith-main-5.png);background-position:-1627px -165px;width:57px;height:54px}.inventory_special_trinket{background-image:url(spritesmith-main-5.png);background-position:-1685px -832px;width:48px;height:51px}.inventory_special_valentine{background-image:url(spritesmith-main-5.png);background-position:-1627px -55px;width:57px;height:54px}.knockout{background-image:url(spritesmith-main-5.png);background-position:-588px -1442px;width:120px;height:47px}.pet_key{background-image:url(spritesmith-main-5.png);background-position:-1627px 0;width:57px;height:54px}.rebirth_orb{background-image:url(spritesmith-main-5.png);background-position:-1546px -1523px;width:57px;height:54px}.seafoam_star{background-image:url(spritesmith-main-5.png);background-position:-1536px -91px;width:90px;height:90px}.shop_armoire{background-image:url(spritesmith-main-5.png);background-position:-970px -1442px;width:40px;height:40px}.snowman{background-image:url(spritesmith-main-5.png);background-position:-1536px -273px;width:90px;height:90px}.spookman{background-image:url(spritesmith-main-5.png);background-position:-1536px -364px;width:90px;height:90px}.zzz{background-image:url(spritesmith-main-5.png);background-position:-929px -1442px;width:40px;height:40px}.zzz_light{background-image:url(spritesmith-main-5.png);background-position:-1134px -1442px;width:40px;height:40px}.npc_alex{background-image:url(spritesmith-main-5.png);background-position:-151px -1251px;width:162px;height:138px}.npc_bailey{background-image:url(spritesmith-main-5.png);background-position:-1461px -1251px;width:60px;height:72px}.npc_daniel{background-image:url(spritesmith-main-5.png);background-position:-477px -1251px;width:135px;height:123px}.npc_justin{background-image:url(spritesmith-main-5.png);background-position:0 -1402px;width:84px;height:120px}.npc_justin_head{background-image:url(spritesmith-main-5.png);background-position:-1298px -1442px;width:36px;height:39px}.npc_matt{background-image:url(spritesmith-main-5.png);background-position:-1322px -954px;width:195px;height:138px}.npc_timetravelers{background-image:url(spritesmith-main-5.png);background-position:-1322px -815px;width:195px;height:138px}.npc_timetravelers_active{background-image:url(spritesmith-main-5.png);background-position:-1322px -676px;width:195px;height:138px}.npc_tyler{background-image:url(spritesmith-main-5.png);background-position:-1536px -1365px;width:90px;height:90px}.seasonalshop_closed{background-image:url(spritesmith-main-5.png);background-position:-314px -1251px;width:162px;height:138px}.inventory_quest_scroll_atom1{background-image:url(spritesmith-main-5.png);background-position:-1685px -208px;width:48px;height:51px}.inventory_quest_scroll_atom1_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -156px;width:48px;height:51px}.inventory_quest_scroll_atom2{background-image:url(spritesmith-main-5.png);background-position:-1685px -104px;width:48px;height:51px}.inventory_quest_scroll_atom2_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -52px;width:48px;height:51px}.inventory_quest_scroll_atom3{background-image:url(spritesmith-main-5.png);background-position:-637px -1614px;width:48px;height:51px}.inventory_quest_scroll_atom3_locked{background-image:url(spritesmith-main-5.png);background-position:-588px -1614px;width:48px;height:51px}.inventory_quest_scroll_basilist{background-image:url(spritesmith-main-5.png);background-position:-539px -1614px;width:48px;height:51px}.inventory_quest_scroll_bunny{background-image:url(spritesmith-main-5.png);background-position:-490px -1614px;width:48px;height:51px}.inventory_quest_scroll_cheetah{background-image:url(spritesmith-main-5.png);background-position:-441px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-392px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-343px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2_locked{background-image:url(spritesmith-main-5.png);background-position:-294px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-245px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3_locked{background-image:url(spritesmith-main-5.png);background-position:-147px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-1685px -312px;width:48px;height:51px}.inventory_quest_scroll_egg{background-image:url(spritesmith-main-5.png);background-position:-245px -1666px;width:48px;height:51px}.inventory_quest_scroll_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-1685px -364px;width:48px;height:51px}.inventory_quest_scroll_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:-1685px -416px;width:48px;height:51px}.inventory_quest_scroll_frog{background-image:url(spritesmith-main-5.png);background-position:-1685px -468px;width:48px;height:51px}.inventory_quest_scroll_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-1685px -520px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-1685px -624px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -676px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-1685px -728px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -780px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:-1685px -884px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3_locked{background-image:url(spritesmith-main-5.png);background-position:-1685px -936px;width:48px;height:51px}.inventory_quest_scroll_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1685px -988px;width:48px;height:51px}.inventory_quest_scroll_harpy{background-image:url(spritesmith-main-5.png);background-position:-1685px -1092px;width:48px;height:51px}.inventory_quest_scroll_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1685px -1144px;width:48px;height:51px}.inventory_quest_scroll_horse{background-image:url(spritesmith-main-5.png);background-position:-1685px -1248px;width:48px;height:51px}.inventory_quest_scroll_kraken{background-image:url(spritesmith-main-5.png);background-position:-1685px -1300px;width:48px;height:51px}.inventory_quest_scroll_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-1685px -1352px;width:48px;height:51px}.inventory_quest_scroll_moonstone1_locked{background-image:url(spritesmith-main-5.png);background-position:-539px -1666px;width:48px;height:51px}.inventory_quest_scroll_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-1627px -648px;width:48px;height:51px}.inventory_quest_scroll_moonstone2_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -700px;width:48px;height:51px}.inventory_quest_scroll_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-1627px -752px;width:48px;height:51px}.inventory_quest_scroll_moonstone3_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -804px;width:48px;height:51px}.inventory_quest_scroll_octopus{background-image:url(spritesmith-main-5.png);background-position:-1627px -856px;width:48px;height:51px}.inventory_quest_scroll_owl{background-image:url(spritesmith-main-5.png);background-position:-1627px -908px;width:48px;height:51px}.inventory_quest_scroll_penguin{background-image:url(spritesmith-main-5.png);background-position:-1627px -960px;width:48px;height:51px}.inventory_quest_scroll_rat{background-image:url(spritesmith-main-5.png);background-position:-1627px -1012px;width:48px;height:51px}.inventory_quest_scroll_rock{background-image:url(spritesmith-main-5.png);background-position:-1627px -1064px;width:48px;height:51px}.inventory_quest_scroll_rooster{background-image:url(spritesmith-main-5.png);background-position:-1627px -1116px;width:48px;height:51px}.inventory_quest_scroll_sheep{background-image:url(spritesmith-main-5.png);background-position:-1627px -1168px;width:48px;height:51px}.inventory_quest_scroll_slime{background-image:url(spritesmith-main-5.png);background-position:-1627px -1220px;width:48px;height:51px}.inventory_quest_scroll_snake{background-image:url(spritesmith-main-5.png);background-position:-1627px -1272px;width:48px;height:51px}.inventory_quest_scroll_spider{background-image:url(spritesmith-main-5.png);background-position:-1627px -1324px;width:48px;height:51px}.inventory_quest_scroll_trex{background-image:url(spritesmith-main-5.png);background-position:-1627px -1376px;width:48px;height:51px}.inventory_quest_scroll_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-1627px -1428px;width:48px;height:51px}.inventory_quest_scroll_vice1{background-image:url(spritesmith-main-5.png);background-position:-1627px -1480px;width:48px;height:51px}.inventory_quest_scroll_vice1_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -1532px;width:48px;height:51px}.inventory_quest_scroll_vice2{background-image:url(spritesmith-main-5.png);background-position:0 -1614px;width:48px;height:51px}.inventory_quest_scroll_vice2_locked{background-image:url(spritesmith-main-5.png);background-position:-49px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice3{background-image:url(spritesmith-main-5.png);background-position:-98px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice3_locked{background-image:url(spritesmith-main-5.png);background-position:-1734px -1456px;width:48px;height:51px}.inventory_quest_scroll_whale{background-image:url(spritesmith-main-5.png);background-position:-196px -1614px;width:48px;height:51px}.quest_TEMPLATE_FOR_MISSING_IMAGE{background-image:url(spritesmith-main-5.png);background-position:-1254px -1402px;width:221px;height:39px}.quest_atom1{background-image:url(spritesmith-main-5.png);background-position:-434px -1070px;width:250px;height:150px}.quest_atom2{background-image:url(spritesmith-main-5.png);background-position:-1322px -537px;width:207px;height:138px}.quest_atom3{background-image:url(spritesmith-main-5.png);background-position:0 -1070px;width:216px;height:180px}.quest_basilist{background-image:url(spritesmith-main-5.png);background-position:-1322px -1093px;width:189px;height:141px}.quest_bunny{background-image:url(spritesmith-main-5.png);background-position:-1100px -618px;width:210px;height:186px}.quest_cheetah{background-image:url(spritesmith-main-5.png);background-position:-440px -452px;width:219px;height:219px}.quest_dilatory{background-image:url(spritesmith-main-5.png);background-position:-220px -452px;width:219px;height:219px}.quest_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-1100px -845px;width:221px;height:39px}.quest_dilatoryDistress1_blueFins{background-image:url(spritesmith-main-5.png);background-position:-686px -1614px;width:51px;height:48px}.quest_dilatoryDistress1_fireCoral{background-image:url(spritesmith-main-5.png);background-position:-1685px 0;width:48px;height:51px}.quest_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:0 -1251px;width:150px;height:150px}.quest_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-440px 0;width:219px;height:219px}.quest_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-660px 0;width:219px;height:219px}.quest_egg{background-image:url(spritesmith-main-5.png);background-position:-810px -1402px;width:221px;height:39px}.quest_egg_plainEgg{background-image:url(spritesmith-main-5.png);background-position:-1685px -260px;width:48px;height:51px}.quest_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-1187px -1070px;width:118px;height:131px}.quest_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:0 -232px;width:219px;height:219px}.quest_frog{background-image:url(spritesmith-main-5.png);background-position:-1100px 0;width:221px;height:213px}.quest_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-220px -232px;width:219px;height:219px}.quest_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-1032px -1402px;width:221px;height:39px}.quest_goldenknight1_testimony{background-image:url(spritesmith-main-5.png);background-position:-1685px -572px;width:48px;height:51px}.quest_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-685px -1070px;width:250px;height:150px}.quest_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:0 0;width:219px;height:231px}.quest_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1091px -892px;width:216px;height:177px}.quest_harpy{background-image:url(spritesmith-main-5.png);background-position:-660px -220px;width:219px;height:219px}.quest_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1100px -431px;width:219px;height:186px}.quest_horse{background-image:url(spritesmith-main-5.png);background-position:0 -452px;width:219px;height:219px}.quest_kraken{background-image:url(spritesmith-main-5.png);background-position:-440px -892px;width:216px;height:177px}.quest_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-588px -1402px;width:221px;height:39px}.quest_moonstone1_moonstone{background-image:url(spritesmith-main-5.png);background-position:-1335px -1442px;width:30px;height:30px}.quest_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-220px 0;width:219px;height:219px}.quest_moonstone3{background-image:url(spritesmith-main-5.png);background-position:0 -672px;width:219px;height:219px}.quest_octopus{background-image:url(spritesmith-main-5.png);background-position:0 -892px;width:222px;height:177px}.quest_owl{background-image:url(spritesmith-main-5.png);background-position:-220px -672px;width:219px;height:219px}.quest_penguin{background-image:url(spritesmith-main-5.png);background-position:-1322px -353px;width:190px;height:183px}.quest_rat{background-image:url(spritesmith-main-5.png);background-position:-880px -672px;width:219px;height:219px}.quest_rock{background-image:url(spritesmith-main-5.png);background-position:-1100px -214px;width:216px;height:216px}.quest_rooster{background-image:url(spritesmith-main-5.png);background-position:-1322px 0;width:213px;height:174px}.quest_sheep{background-image:url(spritesmith-main-5.png);background-position:-660px -672px;width:219px;height:219px}.quest_slime{background-image:url(spritesmith-main-5.png);background-position:-440px -672px;width:219px;height:219px}.quest_snake{background-image:url(spritesmith-main-5.png);background-position:-874px -892px;width:216px;height:177px}.quest_spider{background-image:url(spritesmith-main-5.png);background-position:-936px -1070px;width:250px;height:150px}.quest_stressbeast{background-image:url(spritesmith-main-5.png);background-position:-880px -440px;width:219px;height:219px}.quest_stressbeast_bailey{background-image:url(spritesmith-main-5.png);background-position:-880px -220px;width:219px;height:219px}.quest_stressbeast_guide{background-image:url(spritesmith-main-5.png);background-position:-880px 0;width:219px;height:219px}.quest_stressbeast_stables{background-image:url(spritesmith-main-5.png);background-position:-660px -452px;width:219px;height:219px}.quest_trex{background-image:url(spritesmith-main-5.png);background-position:-1322px -175px;width:204px;height:177px}.quest_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-223px -892px;width:216px;height:177px}.quest_vice1{background-image:url(spritesmith-main-5.png);background-position:-657px -892px;width:216px;height:177px}.quest_vice2{background-image:url(spritesmith-main-5.png);background-position:-1100px -805px;width:221px;height:39px}.quest_vice2_lightCrystal{background-image:url(spritesmith-main-5.png);background-position:-1175px -1442px;width:40px;height:40px}.quest_vice3{background-image:url(spritesmith-main-5.png);background-position:-217px -1070px;width:216px;height:177px}.quest_whale{background-image:url(spritesmith-main-5.png);background-position:-440px -232px;width:219px;height:219px}.shop_copper{background-image:url(spritesmith-main-5.png);background-position:-467px -1221px;width:32px;height:22px}.shop_eyes{background-image:url(spritesmith-main-5.png);background-position:-1216px -1442px;width:40px;height:40px}.shop_gold{background-image:url(spritesmith-main-5.png);background-position:-1734px -1692px;width:32px;height:22px}.shop_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1257px -1442px;width:40px;height:40px}.shop_potion{background-image:url(spritesmith-main-5.png);background-position:-1093px -1442px;width:40px;height:40px}.shop_reroll{background-image:url(spritesmith-main-5.png);background-position:-1052px -1442px;width:40px;height:40px}.shop_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1594px -1456px;width:32px;height:32px}.shop_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1461px -1324px;width:32px;height:32px}.shop_silver{background-image:url(spritesmith-main-5.png);background-position:-434px -1221px;width:32px;height:22px}.shop_snowball{background-image:url(spritesmith-main-5.png);background-position:-1594px -1489px;width:32px;height:32px}.shop_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1494px -1324px;width:32px;height:32px}.Pet_Egg_BearCub{background-image:url(spritesmith-main-5.png);background-position:-1127px -1666px;width:48px;height:51px}.Pet_Egg_Bunny{background-image:url(spritesmith-main-5.png);background-position:-1176px -1666px;width:48px;height:51px}.Pet_Egg_Cactus{background-image:url(spritesmith-main-5.png);background-position:-1225px -1666px;width:48px;height:51px}.Pet_Egg_Cheetah{background-image:url(spritesmith-main-5.png);background-position:-1274px -1666px;width:48px;height:51px}.Pet_Egg_Cuttlefish{background-image:url(spritesmith-main-5.png);background-position:-1323px -1666px;width:48px;height:51px}.Pet_Egg_Deer{background-image:url(spritesmith-main-5.png);background-position:-1372px -1666px;width:48px;height:51px}.Pet_Egg_Dragon{background-image:url(spritesmith-main-5.png);background-position:-1421px -1666px;width:48px;height:51px}.Pet_Egg_Egg{background-image:url(spritesmith-main-5.png);background-position:-1470px -1666px;width:48px;height:51px}.Pet_Egg_FlyingPig{background-image:url(spritesmith-main-5.png);background-position:-1519px -1666px;width:48px;height:51px}.Pet_Egg_Fox{background-image:url(spritesmith-main-5.png);background-position:-1568px -1666px;width:48px;height:51px}.Pet_Egg_Frog{background-image:url(spritesmith-main-5.png);background-position:-1617px -1666px;width:48px;height:51px}.Pet_Egg_Gryphon{background-image:url(spritesmith-main-5.png);background-position:-1666px -1666px;width:48px;height:51px}.Pet_Egg_Hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1734px 0;width:48px;height:51px}.Pet_Egg_Horse{background-image:url(spritesmith-main-5.png);background-position:-1734px -52px;width:48px;height:51px}.Pet_Egg_LionCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -104px;width:48px;height:51px}.Pet_Egg_Octopus{background-image:url(spritesmith-main-5.png);background-position:-1734px -156px;width:48px;height:51px}.Pet_Egg_Owl{background-image:url(spritesmith-main-5.png);background-position:-1734px -208px;width:48px;height:51px}.Pet_Egg_PandaCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -260px;width:48px;height:51px}.Pet_Egg_Parrot{background-image:url(spritesmith-main-5.png);background-position:-1734px -312px;width:48px;height:51px}.Pet_Egg_Penguin{background-image:url(spritesmith-main-5.png);background-position:-1734px -364px;width:48px;height:51px}.Pet_Egg_PolarBear{background-image:url(spritesmith-main-5.png);background-position:-1734px -416px;width:48px;height:51px}.Pet_Egg_Rat{background-image:url(spritesmith-main-5.png);background-position:-1734px -468px;width:48px;height:51px}.Pet_Egg_Rock{background-image:url(spritesmith-main-5.png);background-position:-1734px -520px;width:48px;height:51px}.Pet_Egg_Rooster{background-image:url(spritesmith-main-5.png);background-position:-1734px -572px;width:48px;height:51px}.Pet_Egg_Seahorse{background-image:url(spritesmith-main-5.png);background-position:-1734px -624px;width:48px;height:51px}.Pet_Egg_Sheep{background-image:url(spritesmith-main-5.png);background-position:-1734px -676px;width:48px;height:51px}.Pet_Egg_Slime{background-image:url(spritesmith-main-5.png);background-position:-1734px -728px;width:48px;height:51px}.Pet_Egg_Snake{background-image:url(spritesmith-main-5.png);background-position:-1734px -780px;width:48px;height:51px}.Pet_Egg_Spider{background-image:url(spritesmith-main-5.png);background-position:-1734px -832px;width:48px;height:51px}.Pet_Egg_TRex{background-image:url(spritesmith-main-5.png);background-position:-1734px -884px;width:48px;height:51px}.Pet_Egg_TigerCub{background-image:url(spritesmith-main-5.png);background-position:-1734px -936px;width:48px;height:51px}.Pet_Egg_Whale{background-image:url(spritesmith-main-5.png);background-position:-1734px -988px;width:48px;height:51px}.Pet_Egg_Wolf{background-image:url(spritesmith-main-5.png);background-position:-1734px -1040px;width:48px;height:51px}.Pet_Food_Cake_Base{background-image:url(spritesmith-main-5.png);background-position:-841px -1442px;width:43px;height:43px}.Pet_Food_Cake_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-738px -1614px;width:42px;height:44px}.Pet_Food_Cake_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1734px -1646px;width:43px;height:45px}.Pet_Food_Cake_Desert{background-image:url(spritesmith-main-5.png);background-position:-753px -1442px;width:43px;height:44px}.Pet_Food_Cake_Golden{background-image:url(spritesmith-main-5.png);background-position:-885px -1442px;width:43px;height:42px}.Pet_Food_Cake_Red{background-image:url(spritesmith-main-5.png);background-position:-797px -1442px;width:43px;height:44px}.Pet_Food_Cake_Shade{background-image:url(spritesmith-main-5.png);background-position:-709px -1442px;width:43px;height:44px}.Pet_Food_Cake_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1734px -1553px;width:42px;height:47px}.Pet_Food_Cake_White{background-image:url(spritesmith-main-5.png);background-position:-1734px -1601px;width:44px;height:44px}.Pet_Food_Cake_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1734px -1508px;width:45px;height:44px}.Pet_Food_Candy_Base{background-image:url(spritesmith-main-5.png);background-position:-1734px -1404px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1734px -1352px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1734px -1300px;width:48px;height:51px}.Pet_Food_Candy_Desert{background-image:url(spritesmith-main-5.png);background-position:-1734px -1248px;width:48px;height:51px}.Pet_Food_Candy_Golden{background-image:url(spritesmith-main-5.png);background-position:-1734px -1196px;width:48px;height:51px}.Pet_Food_Candy_Red{background-image:url(spritesmith-main-5.png);background-position:-1734px -1144px;width:48px;height:51px}.Pet_Food_Candy_Shade{background-image:url(spritesmith-main-5.png);background-position:-1734px -1092px;width:48px;height:51px}.Pet_Food_Candy_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1078px -1666px;width:48px;height:51px}.Pet_Food_Candy_White{background-image:url(spritesmith-main-5.png);background-position:-1029px -1666px;width:48px;height:51px}.Pet_Food_Candy_Zombie{background-image:url(spritesmith-main-5.png);background-position:-980px -1666px;width:48px;height:51px}.Pet_Food_Chocolate{background-image:url(spritesmith-main-5.png);background-position:-931px -1666px;width:48px;height:51px}.Pet_Food_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-882px -1666px;width:48px;height:51px}.Pet_Food_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-833px -1666px;width:48px;height:51px}.Pet_Food_Fish{background-image:url(spritesmith-main-5.png);background-position:-784px -1666px;width:48px;height:51px}.Pet_Food_Honey{background-image:url(spritesmith-main-5.png);background-position:-735px -1666px;width:48px;height:51px}.Pet_Food_Meat{background-image:url(spritesmith-main-5.png);background-position:-686px -1666px;width:48px;height:51px}.Pet_Food_Milk{background-image:url(spritesmith-main-5.png);background-position:-637px -1666px;width:48px;height:51px}.Pet_Food_Potatoe{background-image:url(spritesmith-main-5.png);background-position:-588px -1666px;width:48px;height:51px}.Pet_Food_RottenMeat{background-image:url(spritesmith-main-5.png);background-position:-441px -1666px;width:48px;height:51px}.Pet_Food_Saddle{background-image:url(spritesmith-main-5.png);background-position:-1685px -1196px;width:48px;height:51px}.Pet_Food_Strawberry{background-image:url(spritesmith-main-5.png);background-position:-1685px -1040px;width:48px;height:51px}.Mount_Body_BearCub-Base{background-image:url(spritesmith-main-5.png);background-position:-613px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1355px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1249px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Desert{background-image:url(spritesmith-main-5.png);background-position:-1143px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Golden{background-image:url(spritesmith-main-5.png);background-position:-1037px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Polar{background-image:url(spritesmith-main-5.png);background-position:-931px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Red{background-image:url(spritesmith-main-5.png);background-position:-825px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Shade{background-image:url(spritesmith-main-5.png);background-position:-719px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -575px;width:105px;height:105px}.Mount_Body_BearCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-318px -1105px;width:105px;height:105px}.Mount_Body_BearCub-White{background-image:url(spritesmith-main-6.png);background-position:-212px -575px;width:105px;height:105px}.Mount_Body_BearCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -575px;width:105px;height:105px}.Mount_Body_Bunny-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -575px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -575px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -575px;width:105px;height:105px}.Mount_Body_Bunny-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px 0;width:105px;height:105px}.Mount_Body_Bunny-Golden{background-image:url(spritesmith-main-6.png);background-position:-742px -106px;width:105px;height:105px}.Mount_Body_Bunny-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -212px;width:105px;height:105px}.Mount_Body_Bunny-Shade{background-image:url(spritesmith-main-6.png);background-position:-742px -318px;width:105px;height:105px}.Mount_Body_Bunny-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -999px;width:105px;height:105px}.Mount_Body_Bunny-White{background-image:url(spritesmith-main-6.png);background-position:-106px -999px;width:105px;height:105px}.Mount_Body_Bunny-Zombie{background-image:url(spritesmith-main-6.png);background-position:-212px -999px;width:105px;height:105px}.Mount_Body_Cactus-Base{background-image:url(spritesmith-main-6.png);background-position:-318px -999px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-424px -999px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-530px -999px;width:105px;height:105px}.Mount_Body_Cactus-Desert{background-image:url(spritesmith-main-6.png);background-position:-636px -999px;width:105px;height:105px}.Mount_Body_Cactus-Golden{background-image:url(spritesmith-main-6.png);background-position:-742px -999px;width:105px;height:105px}.Mount_Body_Cactus-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -999px;width:105px;height:105px}.Mount_Body_Cactus-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -999px;width:105px;height:105px}.Mount_Body_Cactus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -1211px;width:105px;height:105px}.Mount_Body_Cactus-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1060px -1211px;width:105px;height:105px}.Mount_Body_Cactus-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -1211px;width:105px;height:105px}.Mount_Body_Cactus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-530px -221px;width:105px;height:105px}.Mount_Body_Cheetah-Base{background-image:url(spritesmith-main-6.png);background-position:-530px -327px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-221px -469px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-327px -469px;width:105px;height:105px}.Mount_Body_Cheetah-Desert{background-image:url(spritesmith-main-6.png);background-position:-433px -469px;width:105px;height:105px}.Mount_Body_Cheetah-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px 0;width:105px;height:105px}.Mount_Body_Cheetah-Red{background-image:url(spritesmith-main-6.png);background-position:-636px -106px;width:105px;height:105px}.Mount_Body_Cheetah-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -212px;width:105px;height:105px}.Mount_Body_Cheetah-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -318px;width:105px;height:105px}.Mount_Body_Cheetah-White{background-image:url(spritesmith-main-6.png);background-position:-636px -424px;width:105px;height:105px}.Mount_Body_Cheetah-Zombie{background-image:url(spritesmith-main-6.png);background-position:0 -575px;width:105px;height:105px}.Mount_Body_Cuttlefish-Base{background-image:url(spritesmith-main-6.png);background-position:-530px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -115px;width:105px;height:114px}.Mount_Body_Cuttlefish-White{background-image:url(spritesmith-main-6.png);background-position:0 -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -239px;width:105px;height:114px}.Mount_Body_Deer-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -424px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-742px -530px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -681px;width:105px;height:105px}.Mount_Body_Deer-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -681px;width:105px;height:105px}.Mount_Body_Deer-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -681px;width:105px;height:105px}.Mount_Body_Deer-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -681px;width:105px;height:105px}.Mount_Body_Deer-Shade{background-image:url(spritesmith-main-6.png);background-position:-424px -681px;width:105px;height:105px}.Mount_Body_Deer-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -681px;width:105px;height:105px}.Mount_Body_Deer-White{background-image:url(spritesmith-main-6.png);background-position:-636px -681px;width:105px;height:105px}.Mount_Body_Deer-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -681px;width:105px;height:105px}.Mount_Body_Dragon-Base{background-image:url(spritesmith-main-6.png);background-position:-848px 0;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -106px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-848px -212px;width:105px;height:105px}.Mount_Body_Dragon-Desert{background-image:url(spritesmith-main-6.png);background-position:-848px -318px;width:105px;height:105px}.Mount_Body_Dragon-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -424px;width:105px;height:105px}.Mount_Body_Dragon-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -530px;width:105px;height:105px}.Mount_Body_Dragon-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -636px;width:105px;height:105px}.Mount_Body_Dragon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -787px;width:105px;height:105px}.Mount_Body_Dragon-Spooky{background-image:url(spritesmith-main-6.png);background-position:-106px -787px;width:105px;height:105px}.Mount_Body_Dragon-White{background-image:url(spritesmith-main-6.png);background-position:-212px -787px;width:105px;height:105px}.Mount_Body_Dragon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -787px;width:105px;height:105px}.Mount_Body_Egg-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -787px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -787px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -787px;width:105px;height:105px}.Mount_Body_Egg-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -787px;width:105px;height:105px}.Mount_Body_Egg-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -787px;width:105px;height:105px}.Mount_Body_Egg-Red{background-image:url(spritesmith-main-6.png);background-position:-954px 0;width:105px;height:105px}.Mount_Body_Egg-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -106px;width:105px;height:105px}.Mount_Body_Egg-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -212px;width:105px;height:105px}.Mount_Body_Egg-White{background-image:url(spritesmith-main-6.png);background-position:-954px -318px;width:105px;height:105px}.Mount_Body_Egg-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -424px;width:105px;height:105px}.Mount_Body_FlyingPig-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -530px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -636px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -742px;width:105px;height:105px}.Mount_Body_FlyingPig-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-424px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Spooky{background-image:url(spritesmith-main-6.png);background-position:-530px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-White{background-image:url(spritesmith-main-6.png);background-position:-636px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -893px;width:105px;height:105px}.Mount_Body_Fox-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -893px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -893px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px 0;width:105px;height:105px}.Mount_Body_Fox-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -106px;width:105px;height:105px}.Mount_Body_Fox-Golden{background-image:url(spritesmith-main-6.png);background-position:-1060px -212px;width:105px;height:105px}.Mount_Body_Fox-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px -318px;width:105px;height:105px}.Mount_Body_Fox-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -424px;width:105px;height:105px}.Mount_Body_Fox-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1060px -530px;width:105px;height:105px}.Mount_Body_Fox-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1060px -636px;width:105px;height:105px}.Mount_Body_Fox-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -742px;width:105px;height:105px}.Mount_Body_Fox-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1060px -848px;width:105px;height:105px}.Mount_Body_Frog-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -239px;width:105px;height:114px}.Mount_Body_Frog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px 0;width:105px;height:114px}.Mount_Body_Frog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px 0;width:105px;height:114px}.Mount_Body_Frog-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -115px;width:105px;height:114px}.Mount_Body_Frog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -230px;width:105px;height:114px}.Mount_Body_Frog-Red{background-image:url(spritesmith-main-6.png);background-position:0 -354px;width:105px;height:114px}.Mount_Body_Frog-Shade{background-image:url(spritesmith-main-6.png);background-position:-106px -354px;width:105px;height:114px}.Mount_Body_Frog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-212px -354px;width:105px;height:114px}.Mount_Body_Frog-White{background-image:url(spritesmith-main-6.png);background-position:-318px -354px;width:105px;height:114px}.Mount_Body_Frog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-424px -354px;width:105px;height:114px}.Mount_Body_Gryphon-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -999px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px 0;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px -106px;width:105px;height:105px}.Mount_Body_Gryphon-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -212px;width:105px;height:105px}.Mount_Body_Gryphon-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -318px;width:105px;height:105px}.Mount_Body_Gryphon-Red{background-image:url(spritesmith-main-6.png);background-position:-1166px -424px;width:105px;height:105px}.Mount_Body_Gryphon-RoyalPurple{background-image:url(spritesmith-main-6.png);background-position:-1166px -530px;width:105px;height:105px}.Mount_Body_Gryphon-Shade{background-image:url(spritesmith-main-6.png);background-position:-1166px -636px;width:105px;height:105px}.Mount_Body_Gryphon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px -742px;width:105px;height:105px}.Mount_Body_Gryphon-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -848px;width:105px;height:105px}.Mount_Body_Gryphon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -954px;width:105px;height:105px}.Mount_Body_Hedgehog-Base{background-image:url(spritesmith-main-6.png);background-position:0 -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -115px;width:105px;height:105px}.Mount_Body_Hedgehog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Red{background-image:url(spritesmith-main-6.png);background-position:-530px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-742px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -1105px;width:105px;height:105px}.Mount_Body_Horse-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -1105px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px -1105px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1272px 0;width:105px;height:105px}.Mount_Body_Horse-Desert{background-image:url(spritesmith-main-6.png);background-position:-1272px -106px;width:105px;height:105px}.Mount_Body_Horse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -212px;width:105px;height:105px}.Mount_Body_Horse-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -318px;width:105px;height:105px}.Mount_Body_Horse-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -424px;width:105px;height:105px}.Mount_Body_Horse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1272px -530px;width:105px;height:105px}.Mount_Body_Horse-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -636px;width:105px;height:105px}.Mount_Body_Horse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -742px;width:105px;height:105px}.Mount_Body_JackOLantern-Base{background-image:url(spritesmith-main-6.png);background-position:-1696px -530px;width:90px;height:105px}.Mount_Body_LionCub-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -954px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -1060px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -1211px;width:105px;height:105px}.Mount_Body_LionCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Ethereal{background-image:url(spritesmith-main-6.png);background-position:-212px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-318px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Red{background-image:url(spritesmith-main-6.png);background-position:-424px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -469px;width:111px;height:105px}.Mount_Body_LionCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-742px -1211px;width:105px;height:105px}.Mount_Body_LionCub-White{background-image:url(spritesmith-main-6.png);background-position:-848px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -1211px;width:105px;height:105px}.Mount_Body_Mammoth-Base{background-image:url(spritesmith-main-6.png);background-position:0 0;width:105px;height:123px}.Mount_Body_MantisShrimp-Base{background-image:url(spritesmith-main-6.png);background-position:-112px -469px;width:108px;height:105px}.Mount_Body_Octopus-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1211px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px 0;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -106px;width:105px;height:105px}.Mount_Body_Octopus-Desert{background-image:url(spritesmith-main-6.png);background-position:-1378px -212px;width:105px;height:105px}.Mount_Body_Octopus-Golden{background-image:url(spritesmith-main-6.png);background-position:-1378px -318px;width:105px;height:105px}.Mount_Body_Octopus-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -424px;width:105px;height:105px}.Mount_Body_Octopus-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -530px;width:105px;height:105px}.Mount_Body_Octopus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1378px -636px;width:105px;height:105px}.Mount_Body_Octopus-White{background-image:url(spritesmith-main-6.png);background-position:-1378px -742px;width:105px;height:105px}.Mount_Body_Octopus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -848px;width:105px;height:105px}.Mount_Body_Orca-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -954px;width:105px;height:105px}.Mount_Body_Owl-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -1060px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -1166px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:0 -1317px;width:105px;height:105px}.Mount_Body_Owl-Desert{background-image:url(spritesmith-main-6.png);background-position:-106px -1317px;width:105px;height:105px}.Mount_Body_Owl-Golden{background-image:url(spritesmith-main-6.png);background-position:-212px -1317px;width:105px;height:105px}.Mount_Body_Owl-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -1317px;width:105px;height:105px}.Mount_Body_Owl-Shade{background-image:url(spritesmith-main-6.png);background-position:-424px -1317px;width:105px;height:105px}.Mount_Body_Owl-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -1317px;width:105px;height:105px}.Mount_Body_Owl-White{background-image:url(spritesmith-main-6.png);background-position:-636px -1317px;width:105px;height:105px}.Mount_Body_Owl-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-954px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px 0;width:105px;height:105px}.Mount_Body_PandaCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -106px;width:105px;height:105px}.Mount_Body_PandaCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1484px -212px;width:105px;height:105px}.Mount_Body_PandaCub-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -318px;width:105px;height:105px}.Mount_Body_PandaCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -424px;width:105px;height:105px}.Mount_Body_Parrot-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -530px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -636px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px -742px;width:105px;height:105px}.Mount_Body_Parrot-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -848px;width:105px;height:105px}.Mount_Body_Parrot-Golden{background-image:url(spritesmith-main-6.png);background-position:-1484px -954px;width:105px;height:105px}.Mount_Body_Parrot-Red{background-image:url(spritesmith-main-6.png);background-position:-1484px -1060px;width:105px;height:105px}.Mount_Body_Parrot-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px -1166px;width:105px;height:105px}.Mount_Body_Parrot-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -1272px;width:105px;height:105px}.Mount_Body_Parrot-White{background-image:url(spritesmith-main-6.png);background-position:0 -1423px;width:105px;height:105px}.Mount_Body_Parrot-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -1423px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -1423px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Desert{background-image:url(spritesmith-main-6.png);background-position:-530px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -1423px;width:105px;height:105px}.Mount_Body_Penguin-White{background-image:url(spritesmith-main-6.png);background-position:-1060px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1166px -1423px;width:105px;height:105px}.Mount_Body_Phoenix-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -1423px;width:105px;height:105px}.Mount_Body_Rat-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -1423px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px 0;width:105px;height:105px}.Mount_Body_Rat-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -106px;width:105px;height:105px}.Mount_Body_Rat-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -212px;width:105px;height:105px}.Mount_Body_Rat-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -318px;width:105px;height:105px}.Mount_Body_Rat-Shade{background-image:url(spritesmith-main-6.png);background-position:-1590px -424px;width:105px;height:105px}.Mount_Body_Rat-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1590px -530px;width:105px;height:105px}.Mount_Body_Rat-White{background-image:url(spritesmith-main-6.png);background-position:-1590px -636px;width:105px;height:105px}.Mount_Body_Rat-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1590px -742px;width:105px;height:105px}.Mount_Body_Rock-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px -848px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -954px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px -1060px;width:105px;height:105px}.Mount_Body_Rock-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -1166px;width:105px;height:105px}.Mount_Body_Rock-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -1272px;width:105px;height:105px}.Mount_Body_Rock-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -1378px;width:105px;height:105px}.Mount_Body_Rock-Shade{background-image:url(spritesmith-main-6.png);background-position:0 -1529px;width:105px;height:105px}.Mount_Body_Rock-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -1529px;width:105px;height:105px}.Mount_Body_Rock-White{background-image:url(spritesmith-main-6.png);background-position:-212px -1529px;width:105px;height:105px}.Mount_Body_Rock-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Base{background-image:url(spritesmith-main-6.png);background-position:-424px -1529px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-530px -1529px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Desert{background-image:url(spritesmith-main-6.png);background-position:-742px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Golden{background-image:url(spritesmith-main-6.png);background-position:-848px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Red{background-image:url(spritesmith-main-6.png);background-position:-954px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1166px -1529px;width:105px;height:105px}.Mount_Body_Rooster-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -1529px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1696px 0;width:105px;height:105px}.Mount_Body_Seahorse-Desert{background-image:url(spritesmith-main-6.png);background-position:-1696px -106px;width:105px;height:105px}.Mount_Body_Seahorse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1696px -212px;width:105px;height:105px}.Mount_Body_Seahorse-Red{background-image:url(spritesmith-main-6.png);background-position:-1696px -318px;width:105px;height:105px}.Mount_Body_Seahorse-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -848px;width:105px;height:105px}.Mount_Body_Seahorse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1696px -424px;width:105px;height:105px}.Mount_Body_Seahorse-White{background-image:url(spritesmith-main-7.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Body_Seahorse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-848px -1113px;width:105px;height:105px}.Mount_Body_Sheep-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -680px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-892px 0;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Body_Sheep-Desert{background-image:url(spritesmith-main-7.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Body_Sheep-Golden{background-image:url(spritesmith-main-7.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Body_Sheep-Red{background-image:url(spritesmith-main-7.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Body_Sheep-Shade{background-image:url(spritesmith-main-7.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Body_Sheep-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Body_Sheep-White{background-image:url(spritesmith-main-7.png);background-position:0 -795px;width:105px;height:105px}.Mount_Body_Sheep-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -901px;width:105px;height:105px}.Mount_Body_Slime-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -901px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -901px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -901px;width:105px;height:105px}.Mount_Body_Slime-Desert{background-image:url(spritesmith-main-7.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Body_Slime-Golden{background-image:url(spritesmith-main-7.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Body_Slime-Red{background-image:url(spritesmith-main-7.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Body_Slime-Shade{background-image:url(spritesmith-main-7.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Body_Slime-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Body_Slime-White{background-image:url(spritesmith-main-7.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Body_Slime-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Body_Snake-Base{background-image:url(spritesmith-main-7.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Body_Snake-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Body_Snake-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Body_Snake-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -1219px;width:105px;height:105px}.Mount_Body_Snake-Golden{background-image:url(spritesmith-main-7.png);background-position:-106px -1219px;width:105px;height:105px}.Mount_Body_Snake-Red{background-image:url(spritesmith-main-7.png);background-position:-212px -1219px;width:105px;height:105px}.Mount_Body_Snake-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1219px;width:105px;height:105px}.Mount_Body_Snake-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1219px;width:105px;height:105px}.Mount_Body_Snake-White{background-image:url(spritesmith-main-7.png);background-position:-530px -1219px;width:105px;height:105px}.Mount_Body_Snake-Zombie{background-image:url(spritesmith-main-7.png);background-position:-636px -1219px;width:105px;height:105px}.Mount_Body_Spider-Base{background-image:url(spritesmith-main-7.png);background-position:-848px -1431px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-954px -1431px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1060px -1431px;width:105px;height:105px}.Mount_Body_Spider-Desert{background-image:url(spritesmith-main-7.png);background-position:-1166px -1431px;width:105px;height:105px}.Mount_Body_Spider-Golden{background-image:url(spritesmith-main-7.png);background-position:-1272px -1431px;width:105px;height:105px}.Mount_Body_Spider-Red{background-image:url(spritesmith-main-7.png);background-position:-1378px -1431px;width:105px;height:105px}.Mount_Body_Spider-Shade{background-image:url(spritesmith-main-7.png);background-position:-1484px -1431px;width:105px;height:105px}.Mount_Body_Spider-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px 0;width:105px;height:105px}.Mount_Body_Spider-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -106px;width:105px;height:105px}.Mount_Body_Spider-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -212px;width:105px;height:105px}.Mount_Body_TRex-Base{background-image:url(spritesmith-main-7.png);background-position:0 -544px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -136px;width:135px;height:135px}.Mount_Body_TRex-Desert{background-image:url(spritesmith-main-7.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Body_TRex-Golden{background-image:url(spritesmith-main-7.png);background-position:-272px 0;width:135px;height:135px}.Mount_Body_TRex-Red{background-image:url(spritesmith-main-7.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Body_TRex-Shade{background-image:url(spritesmith-main-7.png);background-position:0 -272px;width:135px;height:135px}.Mount_Body_TRex-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Body_TRex-White{background-image:url(spritesmith-main-7.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Body_TRex-Zombie{background-image:url(spritesmith-main-7.png);background-position:-408px 0;width:135px;height:135px}.Mount_Body_TigerCub-Base{background-image:url(spritesmith-main-7.png);background-position:-106px -795px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-212px -795px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-318px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-424px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-530px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Red{background-image:url(spritesmith-main-7.png);background-position:-636px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-742px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-848px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-998px 0;width:105px;height:105px}.Mount_Body_TigerCub-White{background-image:url(spritesmith-main-7.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Body_TigerCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Body_Turkey-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Body_Whale-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Body_Whale-Desert{background-image:url(spritesmith-main-7.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Body_Whale-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -901px;width:105px;height:105px}.Mount_Body_Whale-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -901px;width:105px;height:105px}.Mount_Body_Whale-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -901px;width:105px;height:105px}.Mount_Body_Whale-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -901px;width:105px;height:105px}.Mount_Body_Whale-White{background-image:url(spritesmith-main-7.png);background-position:-424px -901px;width:105px;height:105px}.Mount_Body_Whale-Zombie{background-image:url(spritesmith-main-7.png);background-position:-530px -901px;width:105px;height:105px}.Mount_Body_Wolf-Base{background-image:url(spritesmith-main-7.png);background-position:0 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -408px;width:135px;height:135px}.Mount_Body_Wolf-Desert{background-image:url(spritesmith-main-7.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Body_Wolf-Golden{background-image:url(spritesmith-main-7.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Body_Wolf-Red{background-image:url(spritesmith-main-7.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Body_Wolf-Shade{background-image:url(spritesmith-main-7.png);background-position:-544px 0;width:135px;height:135px}.Mount_Body_Wolf-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Body_Wolf-Spooky{background-image:url(spritesmith-main-7.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Body_Wolf-White{background-image:url(spritesmith-main-7.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Body_Wolf-Zombie{background-image:url(spritesmith-main-7.png);background-position:-136px 0;width:135px;height:135px}.Mount_Head_BearCub-Base{background-image:url(spritesmith-main-7.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -1007px;width:105px;height:105px}.Mount_Head_BearCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-106px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-212px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Polar{background-image:url(spritesmith-main-7.png);background-position:-318px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Red{background-image:url(spritesmith-main-7.png);background-position:-424px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-530px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-742px -1007px;width:105px;height:105px}.Mount_Head_BearCub-White{background-image:url(spritesmith-main-7.png);background-position:-848px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1007px;width:105px;height:105px}.Mount_Head_Bunny-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1007px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Bunny-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Bunny-Golden{background-image:url(spritesmith-main-7.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Bunny-Red{background-image:url(spritesmith-main-7.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Bunny-Shade{background-image:url(spritesmith-main-7.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Bunny-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Bunny-White{background-image:url(spritesmith-main-7.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Bunny-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Cactus-Base{background-image:url(spritesmith-main-7.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 -1113px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-106px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Desert{background-image:url(spritesmith-main-7.png);background-position:-212px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Golden{background-image:url(spritesmith-main-7.png);background-position:-318px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Red{background-image:url(spritesmith-main-7.png);background-position:-424px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Shade{background-image:url(spritesmith-main-7.png);background-position:-530px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Spooky{background-image:url(spritesmith-main-7.png);background-position:-742px -1113px;width:105px;height:105px}.Mount_Head_Cactus-White{background-image:url(spritesmith-main-7.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_Cactus-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1113px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Cheetah-Desert{background-image:url(spritesmith-main-7.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Cheetah-Golden{background-image:url(spritesmith-main-7.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Cheetah-Red{background-image:url(spritesmith-main-7.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Cheetah-Shade{background-image:url(spritesmith-main-7.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Cheetah-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Cheetah-White{background-image:url(spritesmith-main-7.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Cheetah-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Cuttlefish-Base{background-image:url(spritesmith-main-7.png);background-position:-242px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-348px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-454px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Desert{background-image:url(spritesmith-main-7.png);background-position:-560px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Golden{background-image:url(spritesmith-main-7.png);background-position:-680px 0;width:105px;height:114px}.Mount_Head_Cuttlefish-Red{background-image:url(spritesmith-main-7.png);background-position:-680px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Shade{background-image:url(spritesmith-main-7.png);background-position:-680px -230px;width:105px;height:114px}.Mount_Head_Cuttlefish-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-680px -345px;width:105px;height:114px}.Mount_Head_Cuttlefish-White{background-image:url(spritesmith-main-7.png);background-position:-680px -460px;width:105px;height:114px}.Mount_Head_Cuttlefish-Zombie{background-image:url(spritesmith-main-7.png);background-position:-786px 0;width:105px;height:114px}.Mount_Head_Deer-Base{background-image:url(spritesmith-main-7.png);background-position:-742px -1219px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-848px -1219px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-954px -1219px;width:105px;height:105px}.Mount_Head_Deer-Desert{background-image:url(spritesmith-main-7.png);background-position:-1060px -1219px;width:105px;height:105px}.Mount_Head_Deer-Golden{background-image:url(spritesmith-main-7.png);background-position:-1166px -1219px;width:105px;height:105px}.Mount_Head_Deer-Red{background-image:url(spritesmith-main-7.png);background-position:-1272px -1219px;width:105px;height:105px}.Mount_Head_Deer-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_Deer-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_Deer-White{background-image:url(spritesmith-main-7.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_Deer-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_Dragon-Base{background-image:url(spritesmith-main-7.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_Dragon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_Dragon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_Dragon-Red{background-image:url(spritesmith-main-7.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_Dragon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_Dragon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Dragon-Spooky{background-image:url(spritesmith-main-7.png);background-position:0 -1325px;width:105px;height:105px}.Mount_Head_Dragon-White{background-image:url(spritesmith-main-7.png);background-position:-106px -1325px;width:105px;height:105px}.Mount_Head_Dragon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-212px -1325px;width:105px;height:105px}.Mount_Head_Egg-Base{background-image:url(spritesmith-main-7.png);background-position:-318px -1325px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-424px -1325px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-530px -1325px;width:105px;height:105px}.Mount_Head_Egg-Desert{background-image:url(spritesmith-main-7.png);background-position:-636px -1325px;width:105px;height:105px}.Mount_Head_Egg-Golden{background-image:url(spritesmith-main-7.png);background-position:-742px -1325px;width:105px;height:105px}.Mount_Head_Egg-Red{background-image:url(spritesmith-main-7.png);background-position:-848px -1325px;width:105px;height:105px}.Mount_Head_Egg-Shade{background-image:url(spritesmith-main-7.png);background-position:-954px -1325px;width:105px;height:105px}.Mount_Head_Egg-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1060px -1325px;width:105px;height:105px}.Mount_Head_Egg-White{background-image:url(spritesmith-main-7.png);background-position:-1166px -1325px;width:105px;height:105px}.Mount_Head_Egg-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1272px -1325px;width:105px;height:105px}.Mount_Head_FlyingPig-Base{background-image:url(spritesmith-main-7.png);background-position:-1378px -1325px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Head_FlyingPig-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Head_FlyingPig-Golden{background-image:url(spritesmith-main-7.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Head_FlyingPig-Red{background-image:url(spritesmith-main-7.png);background-position:-1528px -424px;width:105px;height:105px}.Mount_Head_FlyingPig-Shade{background-image:url(spritesmith-main-7.png);background-position:-1528px -530px;width:105px;height:105px}.Mount_Head_FlyingPig-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1528px -636px;width:105px;height:105px}.Mount_Head_FlyingPig-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1528px -742px;width:105px;height:105px}.Mount_Head_FlyingPig-White{background-image:url(spritesmith-main-7.png);background-position:-1528px -848px;width:105px;height:105px}.Mount_Head_FlyingPig-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1528px -954px;width:105px;height:105px}.Mount_Head_Fox-Base{background-image:url(spritesmith-main-7.png);background-position:-1528px -1060px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px -1166px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -1272px;width:105px;height:105px}.Mount_Head_Fox-Desert{background-image:url(spritesmith-main-7.png);background-position:0 -1431px;width:105px;height:105px}.Mount_Head_Fox-Golden{background-image:url(spritesmith-main-7.png);background-position:-106px -1431px;width:105px;height:105px}.Mount_Head_Fox-Red{background-image:url(spritesmith-main-7.png);background-position:-212px -1431px;width:105px;height:105px}.Mount_Head_Fox-Shade{background-image:url(spritesmith-main-7.png);background-position:-318px -1431px;width:105px;height:105px}.Mount_Head_Fox-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-424px -1431px;width:105px;height:105px}.Mount_Head_Fox-Spooky{background-image:url(spritesmith-main-7.png);background-position:-530px -1431px;width:105px;height:105px}.Mount_Head_Fox-White{background-image:url(spritesmith-main-7.png);background-position:-636px -1431px;width:105px;height:105px}.Mount_Head_Fox-Zombie{background-image:url(spritesmith-main-7.png);background-position:-742px -1431px;width:105px;height:105px}.Mount_Head_Frog-Base{background-image:url(spritesmith-main-7.png);background-position:-786px -115px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-786px -230px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -345px;width:105px;height:114px}.Mount_Head_Frog-Desert{background-image:url(spritesmith-main-7.png);background-position:-786px -460px;width:105px;height:114px}.Mount_Head_Frog-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -680px;width:105px;height:114px}.Mount_Head_Frog-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -680px;width:105px;height:114px}.Mount_Head_Frog-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -680px;width:105px;height:114px}.Mount_Head_Frog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -680px;width:105px;height:114px}.Mount_Head_Frog-White{background-image:url(spritesmith-main-7.png);background-position:-424px -680px;width:105px;height:114px}.Mount_Head_Frog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-136px -544px;width:105px;height:114px}.Mount_Head_Gryphon-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -318px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1634px -424px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1634px -530px;width:105px;height:105px}.Mount_Head_Gryphon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1634px -636px;width:105px;height:105px}.Mount_Head_Gryphon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1634px -742px;width:105px;height:105px}.Mount_Head_Gryphon-Red{background-image:url(spritesmith-main-7.png);background-position:-1634px -848px;width:105px;height:105px}.Mount_Head_Gryphon-RoyalPurple{background-image:url(spritesmith-main-7.png);background-position:-1634px -954px;width:105px;height:105px}.Mount_Head_Gryphon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -1060px;width:105px;height:105px}.Mount_Head_Gryphon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1634px -1166px;width:105px;height:105px}.Mount_Head_Gryphon-White{background-image:url(spritesmith-main-7.png);background-position:-1634px -1272px;width:105px;height:105px}.Mount_Head_Gryphon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1634px -1378px;width:105px;height:105px}.Mount_Head_Hedgehog-Base{background-image:url(spritesmith-main-7.png);background-position:0 -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-106px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-212px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Desert{background-image:url(spritesmith-main-7.png);background-position:-318px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Golden{background-image:url(spritesmith-main-7.png);background-position:-424px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Red{background-image:url(spritesmith-main-7.png);background-position:-530px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Shade{background-image:url(spritesmith-main-7.png);background-position:-636px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-742px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-White{background-image:url(spritesmith-main-7.png);background-position:-848px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-954px -1537px;width:105px;height:105px}.Mount_Head_Horse-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1537px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1537px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1272px -1537px;width:105px;height:105px}.Mount_Head_Horse-Desert{background-image:url(spritesmith-main-7.png);background-position:-1378px -1537px;width:105px;height:105px}.Mount_Head_Horse-Golden{background-image:url(spritesmith-main-7.png);background-position:-1484px -1537px;width:105px;height:105px}.Mount_Head_Horse-Red{background-image:url(spritesmith-main-7.png);background-position:-1590px -1537px;width:105px;height:105px}.Mount_Head_Horse-Shade{background-image:url(spritesmith-main-7.png);background-position:-1740px 0;width:105px;height:105px}.Mount_Head_Horse-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1740px -106px;width:105px;height:105px}.Mount_Head_Horse-White{background-image:url(spritesmith-main-7.png);background-position:-1740px -212px;width:105px;height:105px}.Mount_Head_Horse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1740px -318px;width:105px;height:105px}.Mount_Head_JackOLantern-Base{background-image:url(spritesmith-main-7.png);background-position:-1740px -424px;width:90px;height:105px}.Mount_Head_LionCub-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -1316px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-318px -1210px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Ethereal{background-image:url(spritesmith-main-8.png);background-position:-106px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-212px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Red{background-image:url(spritesmith-main-8.png);background-position:-318px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-424px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-242px -544px;width:105px;height:110px}.Mount_Head_LionCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-636px -1316px;width:105px;height:105px}.Mount_Head_LionCub-White{background-image:url(spritesmith-main-8.png);background-position:-742px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-848px -1316px;width:105px;height:105px}.Mount_Head_Mammoth-Base{background-image:url(spritesmith-main-8.png);background-position:-136px -544px;width:105px;height:123px}.Mount_Head_MantisShrimp-Base{background-image:url(spritesmith-main-8.png);background-position:-348px -544px;width:108px;height:105px}.Mount_Head_Octopus-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -1422px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -1422px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1166px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Red{background-image:url(spritesmith-main-8.png);background-position:-1272px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1378px -1422px;width:105px;height:105px}.Mount_Head_Octopus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Head_Octopus-White{background-image:url(spritesmith-main-8.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Head_Octopus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Head_Orca-Base{background-image:url(spritesmith-main-8.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Head_Owl-Base{background-image:url(spritesmith-main-8.png);background-position:-563px -544px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-680px 0;width:105px;height:105px}.Mount_Head_Owl-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-680px -106px;width:105px;height:105px}.Mount_Head_Owl-Desert{background-image:url(spritesmith-main-8.png);background-position:-680px -212px;width:105px;height:105px}.Mount_Head_Owl-Golden{background-image:url(spritesmith-main-8.png);background-position:-680px -318px;width:105px;height:105px}.Mount_Head_Owl-Red{background-image:url(spritesmith-main-8.png);background-position:-680px -424px;width:105px;height:105px}.Mount_Head_Owl-Shade{background-image:url(spritesmith-main-8.png);background-position:-680px -530px;width:105px;height:105px}.Mount_Head_Owl-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -680px;width:105px;height:105px}.Mount_Head_Owl-White{background-image:url(spritesmith-main-8.png);background-position:-106px -680px;width:105px;height:105px}.Mount_Head_Owl-Zombie{background-image:url(spritesmith-main-8.png);background-position:-212px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -680px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -680px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-786px 0;width:105px;height:105px}.Mount_Head_PandaCub-Red{background-image:url(spritesmith-main-8.png);background-position:-786px -106px;width:105px;height:105px}.Mount_Head_PandaCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-786px -212px;width:105px;height:105px}.Mount_Head_PandaCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-786px -318px;width:105px;height:105px}.Mount_Head_PandaCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-786px -424px;width:105px;height:105px}.Mount_Head_PandaCub-White{background-image:url(spritesmith-main-8.png);background-position:-786px -530px;width:105px;height:105px}.Mount_Head_PandaCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-786px -636px;width:105px;height:105px}.Mount_Head_Parrot-Base{background-image:url(spritesmith-main-8.png);background-position:0 -786px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-106px -786px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-212px -786px;width:105px;height:105px}.Mount_Head_Parrot-Desert{background-image:url(spritesmith-main-8.png);background-position:-318px -786px;width:105px;height:105px}.Mount_Head_Parrot-Golden{background-image:url(spritesmith-main-8.png);background-position:-424px -786px;width:105px;height:105px}.Mount_Head_Parrot-Red{background-image:url(spritesmith-main-8.png);background-position:-530px -786px;width:105px;height:105px}.Mount_Head_Parrot-Shade{background-image:url(spritesmith-main-8.png);background-position:-636px -786px;width:105px;height:105px}.Mount_Head_Parrot-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-742px -786px;width:105px;height:105px}.Mount_Head_Parrot-White{background-image:url(spritesmith-main-8.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Parrot-Zombie{background-image:url(spritesmith-main-8.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Head_Penguin-Base{background-image:url(spritesmith-main-8.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Head_Penguin-Desert{background-image:url(spritesmith-main-8.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Head_Penguin-Golden{background-image:url(spritesmith-main-8.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Head_Penguin-Red{background-image:url(spritesmith-main-8.png);background-position:-892px -742px;width:105px;height:105px}.Mount_Head_Penguin-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -892px;width:105px;height:105px}.Mount_Head_Penguin-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-106px -892px;width:105px;height:105px}.Mount_Head_Penguin-White{background-image:url(spritesmith-main-8.png);background-position:-212px -892px;width:105px;height:105px}.Mount_Head_Penguin-Zombie{background-image:url(spritesmith-main-8.png);background-position:-318px -892px;width:105px;height:105px}.Mount_Head_Phoenix-Base{background-image:url(spritesmith-main-8.png);background-position:-424px -892px;width:105px;height:105px}.Mount_Head_Rat-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -892px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -892px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -892px;width:105px;height:105px}.Mount_Head_Rat-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -892px;width:105px;height:105px}.Mount_Head_Rat-Golden{background-image:url(spritesmith-main-8.png);background-position:-998px 0;width:105px;height:105px}.Mount_Head_Rat-Red{background-image:url(spritesmith-main-8.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Head_Rat-Shade{background-image:url(spritesmith-main-8.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Head_Rat-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_Rat-White{background-image:url(spritesmith-main-8.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_Rat-Zombie{background-image:url(spritesmith-main-8.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_Rock-Base{background-image:url(spritesmith-main-8.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-998px -848px;width:105px;height:105px}.Mount_Head_Rock-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -998px;width:105px;height:105px}.Mount_Head_Rock-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -998px;width:105px;height:105px}.Mount_Head_Rock-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -998px;width:105px;height:105px}.Mount_Head_Rock-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -998px;width:105px;height:105px}.Mount_Head_Rock-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -998px;width:105px;height:105px}.Mount_Head_Rock-White{background-image:url(spritesmith-main-8.png);background-position:-530px -998px;width:105px;height:105px}.Mount_Head_Rock-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -998px;width:105px;height:105px}.Mount_Head_Rooster-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -998px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -998px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -998px;width:105px;height:105px}.Mount_Head_Rooster-Desert{background-image:url(spritesmith-main-8.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Rooster-Golden{background-image:url(spritesmith-main-8.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Rooster-Red{background-image:url(spritesmith-main-8.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Rooster-Shade{background-image:url(spritesmith-main-8.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Rooster-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Rooster-White{background-image:url(spritesmith-main-8.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Rooster-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Seahorse-Base{background-image:url(spritesmith-main-8.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1104px -954px;width:105px;height:105px}.Mount_Head_Seahorse-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-White{background-image:url(spritesmith-main-8.png);background-position:-530px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -1104px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-848px -1104px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-954px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Desert{background-image:url(spritesmith-main-8.png);background-position:-1060px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Sheep-Red{background-image:url(spritesmith-main-8.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Sheep-Shade{background-image:url(spritesmith-main-8.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Sheep-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Sheep-White{background-image:url(spritesmith-main-8.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Sheep-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Slime-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Slime-Desert{background-image:url(spritesmith-main-8.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Slime-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px -1060px;width:105px;height:105px}.Mount_Head_Slime-Red{background-image:url(spritesmith-main-8.png);background-position:0 -1210px;width:105px;height:105px}.Mount_Head_Slime-Shade{background-image:url(spritesmith-main-8.png);background-position:-106px -1210px;width:105px;height:105px}.Mount_Head_Slime-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-212px -1210px;width:105px;height:105px}.Mount_Head_Slime-White{background-image:url(spritesmith-main-8.png);background-position:-457px -544px;width:105px;height:105px}.Mount_Head_Slime-Zombie{background-image:url(spritesmith-main-8.png);background-position:-424px -1210px;width:105px;height:105px}.Mount_Head_Snake-Base{background-image:url(spritesmith-main-8.png);background-position:-530px -1210px;width:105px;height:105px}.Mount_Head_Snake-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-636px -1210px;width:105px;height:105px}.Mount_Head_Snake-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-742px -1210px;width:105px;height:105px}.Mount_Head_Snake-Desert{background-image:url(spritesmith-main-8.png);background-position:-848px -1210px;width:105px;height:105px}.Mount_Head_Snake-Golden{background-image:url(spritesmith-main-8.png);background-position:-954px -1210px;width:105px;height:105px}.Mount_Head_Snake-Red{background-image:url(spritesmith-main-8.png);background-position:-1060px -1210px;width:105px;height:105px}.Mount_Head_Snake-Shade{background-image:url(spritesmith-main-8.png);background-position:-1166px -1210px;width:105px;height:105px}.Mount_Head_Snake-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Snake-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Snake-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Spider-Base{background-image:url(spritesmith-main-8.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Spider-Desert{background-image:url(spritesmith-main-8.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Spider-Golden{background-image:url(spritesmith-main-8.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Spider-Red{background-image:url(spritesmith-main-8.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Spider-Shade{background-image:url(spritesmith-main-8.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Spider-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Spider-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -1166px;width:105px;height:105px}.Mount_Head_Spider-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1316px;width:105px;height:105px}.Mount_Head_TRex-Base{background-image:url(spritesmith-main-8.png);background-position:-272px 0;width:135px;height:135px}.Mount_Head_TRex-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:0 -272px;width:135px;height:135px}.Mount_Head_TRex-Desert{background-image:url(spritesmith-main-8.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Head_TRex-Golden{background-image:url(spritesmith-main-8.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Head_TRex-Red{background-image:url(spritesmith-main-8.png);background-position:-408px 0;width:135px;height:135px}.Mount_Head_TRex-Shade{background-image:url(spritesmith-main-8.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Head_TRex-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Head_TRex-White{background-image:url(spritesmith-main-8.png);background-position:0 0;width:135px;height:135px}.Mount_Head_TRex-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Head_TigerCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1166px -1316px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1272px -1316px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_TigerCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_TigerCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_TigerCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_TigerCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_TigerCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_TigerCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_TigerCub-White{background-image:url(spritesmith-main-8.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_TigerCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_Turkey-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_Whale-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px -1272px;width:105px;height:105px}.Mount_Head_Whale-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -1422px;width:105px;height:105px}.Mount_Head_Whale-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -1422px;width:105px;height:105px}.Mount_Head_Whale-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -1422px;width:105px;height:105px}.Mount_Head_Whale-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -1422px;width:105px;height:105px}.Mount_Head_Whale-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -1422px;width:105px;height:105px}.Mount_Head_Whale-White{background-image:url(spritesmith-main-8.png);background-position:-530px -1422px;width:105px;height:105px}.Mount_Head_Whale-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -1422px;width:105px;height:105px}.Mount_Head_Wolf-Base{background-image:url(spritesmith-main-8.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-544px 0;width:135px;height:135px}.Mount_Head_Wolf-Desert{background-image:url(spritesmith-main-8.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Head_Wolf-Golden{background-image:url(spritesmith-main-8.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Head_Wolf-Red{background-image:url(spritesmith-main-8.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_Wolf-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -544px;width:135px;height:135px}.Mount_Head_Wolf-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 -408px;width:135px;height:135px}.Mount_Head_Wolf-Spooky{background-image:url(spritesmith-main-8.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Head_Wolf-White{background-image:url(spritesmith-main-8.png);background-position:0 -136px;width:135px;height:135px}.Mount_Head_Wolf-Zombie{background-image:url(spritesmith-main-8.png);background-position:-136px 0;width:135px;height:135px}.Pet-BearCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1528px -524px;width:81px;height:99px}.Pet-BearCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px 0;width:81px;height:99px}.Pet-BearCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1528px -624px;width:81px;height:99px}.Pet-BearCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1528px -724px;width:81px;height:99px}.Pet-BearCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1528px -824px;width:81px;height:99px}.Pet-BearCub-Polar{background-image:url(spritesmith-main-8.png);background-position:-1528px -924px;width:81px;height:99px}.Pet-BearCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1528px -1024px;width:81px;height:99px}.Pet-BearCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1528px -1124px;width:81px;height:99px}.Pet-BearCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1528px -1224px;width:81px;height:99px}.Pet-BearCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1528px -1324px;width:81px;height:99px}.Pet-BearCub-White{background-image:url(spritesmith-main-8.png);background-position:-1528px -1424px;width:81px;height:99px}.Pet-BearCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -1528px;width:81px;height:99px}.Pet-Bunny-Base{background-image:url(spritesmith-main-8.png);background-position:-82px -1528px;width:81px;height:99px}.Pet-Bunny-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-164px -1528px;width:81px;height:99px}.Pet-Bunny-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-246px -1528px;width:81px;height:99px}.Pet-Bunny-Desert{background-image:url(spritesmith-main-8.png);background-position:-328px -1528px;width:81px;height:99px}.Pet-Bunny-Golden{background-image:url(spritesmith-main-8.png);background-position:-410px -1528px;width:81px;height:99px}.Pet-Bunny-Red{background-image:url(spritesmith-main-8.png);background-position:-492px -1528px;width:81px;height:99px}.Pet-Bunny-Shade{background-image:url(spritesmith-main-8.png);background-position:-574px -1528px;width:81px;height:99px}.Pet-Bunny-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-656px -1528px;width:81px;height:99px}.Pet-Bunny-White{background-image:url(spritesmith-main-8.png);background-position:-738px -1528px;width:81px;height:99px}.Pet-Bunny-Zombie{background-image:url(spritesmith-main-8.png);background-position:-820px -1528px;width:81px;height:99px}.Pet-Cactus-Base{background-image:url(spritesmith-main-8.png);background-position:-902px -1528px;width:81px;height:99px}.Pet-Cactus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-984px -1528px;width:81px;height:99px}.Pet-Cactus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1066px -1528px;width:81px;height:99px}.Pet-Cactus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1148px -1528px;width:81px;height:99px}.Pet-Cactus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1230px -1528px;width:81px;height:99px}.Pet-Cactus-Red{background-image:url(spritesmith-main-8.png);background-position:-1312px -1528px;width:81px;height:99px}.Pet-Cactus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1394px -1528px;width:81px;height:99px}.Pet-Cactus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1476px -1528px;width:81px;height:99px}.Pet-Cactus-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1528px -424px;width:81px;height:99px}.Pet-Cactus-White{background-image:url(spritesmith-main-8.png);background-position:-1634px -100px;width:81px;height:99px}.Pet-Cactus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1634px -200px;width:81px;height:99px}.Pet-Cheetah-Base{background-image:url(spritesmith-main-8.png);background-position:-1634px -300px;width:81px;height:99px}.Pet-Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px -400px;width:81px;height:99px}.Pet-Cheetah-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1634px -500px;width:81px;height:99px}.Pet-Cheetah-Desert{background-image:url(spritesmith-main-8.png);background-position:-1634px -600px;width:81px;height:99px}.Pet-Cheetah-Golden{background-image:url(spritesmith-main-8.png);background-position:-1634px -700px;width:81px;height:99px}.Pet-Cheetah-Red{background-image:url(spritesmith-main-8.png);background-position:-1634px -800px;width:81px;height:99px}.Pet-Cheetah-Shade{background-image:url(spritesmith-main-8.png);background-position:-1634px -900px;width:81px;height:99px}.Pet-Cheetah-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1634px -1000px;width:81px;height:99px}.Pet-Cheetah-White{background-image:url(spritesmith-main-8.png);background-position:-1634px -1100px;width:81px;height:99px}.Pet-Cheetah-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1634px -1200px;width:81px;height:99px}.Pet-Cuttlefish-Base{background-image:url(spritesmith-main-8.png);background-position:-1634px -1300px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px -1400px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1634px -1500px;width:81px;height:99px}.Pet-Cuttlefish-Desert{background-image:url(spritesmith-main-8.png);background-position:-1716px 0;width:81px;height:99px}.Pet-Cuttlefish-Golden{background-image:url(spritesmith-main-8.png);background-position:-1716px -100px;width:81px;height:99px}.Pet-Cuttlefish-Red{background-image:url(spritesmith-main-8.png);background-position:-1716px -200px;width:81px;height:99px}.Pet-Cuttlefish-Shade{background-image:url(spritesmith-main-8.png);background-position:-1716px -300px;width:81px;height:99px}.Pet-Cuttlefish-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1716px -400px;width:81px;height:99px}.Pet-Cuttlefish-White{background-image:url(spritesmith-main-8.png);background-position:-1716px -500px;width:81px;height:99px}.Pet-Cuttlefish-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1716px -600px;width:81px;height:99px}.Pet-Deer-Base{background-image:url(spritesmith-main-8.png);background-position:-1716px -700px;width:81px;height:99px}.Pet-Deer-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1716px -800px;width:81px;height:99px}.Pet-Deer-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1716px -900px;width:81px;height:99px}.Pet-Deer-Desert{background-image:url(spritesmith-main-8.png);background-position:-1716px -1000px;width:81px;height:99px}.Pet-Deer-Golden{background-image:url(spritesmith-main-8.png);background-position:-1716px -1100px;width:81px;height:99px}.Pet-Deer-Red{background-image:url(spritesmith-main-8.png);background-position:-1716px -1200px;width:81px;height:99px}.Pet-Deer-Shade{background-image:url(spritesmith-main-8.png);background-position:-1716px -1300px;width:81px;height:99px}.Pet-Deer-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px 0;width:81px;height:99px}.Pet-Deer-White{background-image:url(spritesmith-main-9.png);background-position:-328px -1000px;width:81px;height:99px}.Pet-Deer-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px 0;width:81px;height:99px}.Pet-Dragon-Base{background-image:url(spritesmith-main-9.png);background-position:0 -100px;width:81px;height:99px}.Pet-Dragon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -100px;width:81px;height:99px}.Pet-Dragon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -100px;width:81px;height:99px}.Pet-Dragon-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px 0;width:81px;height:99px}.Pet-Dragon-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -100px;width:81px;height:99px}.Pet-Dragon-Hydra{background-image:url(spritesmith-main-9.png);background-position:0 -200px;width:81px;height:99px}.Pet-Dragon-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -200px;width:81px;height:99px}.Pet-Dragon-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -200px;width:81px;height:99px}.Pet-Dragon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -200px;width:81px;height:99px}.Pet-Dragon-Spooky{background-image:url(spritesmith-main-9.png);background-position:-328px 0;width:81px;height:99px}.Pet-Dragon-White{background-image:url(spritesmith-main-9.png);background-position:-328px -100px;width:81px;height:99px}.Pet-Dragon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-328px -200px;width:81px;height:99px}.Pet-Egg-Base{background-image:url(spritesmith-main-9.png);background-position:0 -300px;width:81px;height:99px}.Pet-Egg-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -300px;width:81px;height:99px}.Pet-Egg-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -300px;width:81px;height:99px}.Pet-Egg-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -300px;width:81px;height:99px}.Pet-Egg-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -300px;width:81px;height:99px}.Pet-Egg-Red{background-image:url(spritesmith-main-9.png);background-position:-410px 0;width:81px;height:99px}.Pet-Egg-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -100px;width:81px;height:99px}.Pet-Egg-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -200px;width:81px;height:99px}.Pet-Egg-White{background-image:url(spritesmith-main-9.png);background-position:-410px -300px;width:81px;height:99px}.Pet-Egg-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px 0;width:81px;height:99px}.Pet-FlyingPig-Base{background-image:url(spritesmith-main-9.png);background-position:-492px -100px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -200px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -300px;width:81px;height:99px}.Pet-FlyingPig-Desert{background-image:url(spritesmith-main-9.png);background-position:0 -400px;width:81px;height:99px}.Pet-FlyingPig-Golden{background-image:url(spritesmith-main-9.png);background-position:-82px -400px;width:81px;height:99px}.Pet-FlyingPig-Red{background-image:url(spritesmith-main-9.png);background-position:-164px -400px;width:81px;height:99px}.Pet-FlyingPig-Shade{background-image:url(spritesmith-main-9.png);background-position:-246px -400px;width:81px;height:99px}.Pet-FlyingPig-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -400px;width:81px;height:99px}.Pet-FlyingPig-Spooky{background-image:url(spritesmith-main-9.png);background-position:-410px -400px;width:81px;height:99px}.Pet-FlyingPig-White{background-image:url(spritesmith-main-9.png);background-position:-492px -400px;width:81px;height:99px}.Pet-FlyingPig-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px 0;width:81px;height:99px}.Pet-Fox-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -100px;width:81px;height:99px}.Pet-Fox-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-574px -200px;width:81px;height:99px}.Pet-Fox-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -300px;width:81px;height:99px}.Pet-Fox-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -400px;width:81px;height:99px}.Pet-Fox-Golden{background-image:url(spritesmith-main-9.png);background-position:0 -500px;width:81px;height:99px}.Pet-Fox-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -500px;width:81px;height:99px}.Pet-Fox-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -500px;width:81px;height:99px}.Pet-Fox-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -500px;width:81px;height:99px}.Pet-Fox-Spooky{background-image:url(spritesmith-main-9.png);background-position:-328px -500px;width:81px;height:99px}.Pet-Fox-White{background-image:url(spritesmith-main-9.png);background-position:-410px -500px;width:81px;height:99px}.Pet-Fox-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -500px;width:81px;height:99px}.Pet-Frog-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -500px;width:81px;height:99px}.Pet-Frog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px 0;width:81px;height:99px}.Pet-Frog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-656px -100px;width:81px;height:99px}.Pet-Frog-Desert{background-image:url(spritesmith-main-9.png);background-position:-656px -200px;width:81px;height:99px}.Pet-Frog-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -300px;width:81px;height:99px}.Pet-Frog-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -400px;width:81px;height:99px}.Pet-Frog-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -500px;width:81px;height:99px}.Pet-Frog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:0 -600px;width:81px;height:99px}.Pet-Frog-White{background-image:url(spritesmith-main-9.png);background-position:-82px -600px;width:81px;height:99px}.Pet-Frog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px -600px;width:81px;height:99px}.Pet-Gryphon-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -600px;width:81px;height:99px}.Pet-Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-328px -600px;width:81px;height:99px}.Pet-Gryphon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-410px -600px;width:81px;height:99px}.Pet-Gryphon-Desert{background-image:url(spritesmith-main-9.png);background-position:-492px -600px;width:81px;height:99px}.Pet-Gryphon-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -600px;width:81px;height:99px}.Pet-Gryphon-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -600px;width:81px;height:99px}.Pet-Gryphon-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px 0;width:81px;height:99px}.Pet-Gryphon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -100px;width:81px;height:99px}.Pet-Gryphon-White{background-image:url(spritesmith-main-9.png);background-position:-738px -200px;width:81px;height:99px}.Pet-Gryphon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-738px -300px;width:81px;height:99px}.Pet-Hedgehog-Base{background-image:url(spritesmith-main-9.png);background-position:-738px -400px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -500px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-738px -600px;width:81px;height:99px}.Pet-Hedgehog-Desert{background-image:url(spritesmith-main-9.png);background-position:0 -700px;width:81px;height:99px}.Pet-Hedgehog-Golden{background-image:url(spritesmith-main-9.png);background-position:-82px -700px;width:81px;height:99px}.Pet-Hedgehog-Red{background-image:url(spritesmith-main-9.png);background-position:-164px -700px;width:81px;height:99px}.Pet-Hedgehog-Shade{background-image:url(spritesmith-main-9.png);background-position:-246px -700px;width:81px;height:99px}.Pet-Hedgehog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -700px;width:81px;height:99px}.Pet-Hedgehog-White{background-image:url(spritesmith-main-9.png);background-position:-410px -700px;width:81px;height:99px}.Pet-Hedgehog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -700px;width:81px;height:99px}.Pet-Horse-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -700px;width:81px;height:99px}.Pet-Horse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px -700px;width:81px;height:99px}.Pet-Horse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-738px -700px;width:81px;height:99px}.Pet-Horse-Desert{background-image:url(spritesmith-main-9.png);background-position:-820px 0;width:81px;height:99px}.Pet-Horse-Golden{background-image:url(spritesmith-main-9.png);background-position:-820px -100px;width:81px;height:99px}.Pet-Horse-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -200px;width:81px;height:99px}.Pet-Horse-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -300px;width:81px;height:99px}.Pet-Horse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-820px -400px;width:81px;height:99px}.Pet-Horse-White{background-image:url(spritesmith-main-9.png);background-position:-820px -500px;width:81px;height:99px}.Pet-Horse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -600px;width:81px;height:99px}.Pet-JackOLantern-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -700px;width:81px;height:99px}.Pet-LionCub-Base{background-image:url(spritesmith-main-9.png);background-position:0 -800px;width:81px;height:99px}.Pet-LionCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -800px;width:81px;height:99px}.Pet-LionCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -800px;width:81px;height:99px}.Pet-LionCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -800px;width:81px;height:99px}.Pet-LionCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -800px;width:81px;height:99px}.Pet-LionCub-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -800px;width:81px;height:99px}.Pet-LionCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -800px;width:81px;height:99px}.Pet-LionCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -800px;width:81px;height:99px}.Pet-LionCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px -800px;width:81px;height:99px}.Pet-LionCub-White{background-image:url(spritesmith-main-9.png);background-position:-738px -800px;width:81px;height:99px}.Pet-LionCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -800px;width:81px;height:99px}.Pet-Mammoth-Base{background-image:url(spritesmith-main-9.png);background-position:-902px 0;width:81px;height:99px}.Pet-MantisShrimp-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -100px;width:81px;height:99px}.Pet-Octopus-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -200px;width:81px;height:99px}.Pet-Octopus-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -300px;width:81px;height:99px}.Pet-Octopus-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-902px -400px;width:81px;height:99px}.Pet-Octopus-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -500px;width:81px;height:99px}.Pet-Octopus-Golden{background-image:url(spritesmith-main-9.png);background-position:-902px -600px;width:81px;height:99px}.Pet-Octopus-Red{background-image:url(spritesmith-main-9.png);background-position:-902px -700px;width:81px;height:99px}.Pet-Octopus-Shade{background-image:url(spritesmith-main-9.png);background-position:-902px -800px;width:81px;height:99px}.Pet-Octopus-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-984px 0;width:81px;height:99px}.Pet-Octopus-White{background-image:url(spritesmith-main-9.png);background-position:-984px -100px;width:81px;height:99px}.Pet-Octopus-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px -200px;width:81px;height:99px}.Pet-Owl-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -300px;width:81px;height:99px}.Pet-Owl-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -400px;width:81px;height:99px}.Pet-Owl-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -500px;width:81px;height:99px}.Pet-Owl-Desert{background-image:url(spritesmith-main-9.png);background-position:-984px -600px;width:81px;height:99px}.Pet-Owl-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -700px;width:81px;height:99px}.Pet-Owl-Red{background-image:url(spritesmith-main-9.png);background-position:-984px -800px;width:81px;height:99px}.Pet-Owl-Shade{background-image:url(spritesmith-main-9.png);background-position:0 -900px;width:81px;height:99px}.Pet-Owl-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px -900px;width:81px;height:99px}.Pet-Owl-White{background-image:url(spritesmith-main-9.png);background-position:-164px -900px;width:81px;height:99px}.Pet-Owl-Zombie{background-image:url(spritesmith-main-9.png);background-position:-246px -900px;width:81px;height:99px}.Pet-PandaCub-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -900px;width:81px;height:99px}.Pet-PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-410px -900px;width:81px;height:99px}.Pet-PandaCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -900px;width:81px;height:99px}.Pet-PandaCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -900px;width:81px;height:99px}.Pet-PandaCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -900px;width:81px;height:99px}.Pet-PandaCub-Red{background-image:url(spritesmith-main-9.png);background-position:-738px -900px;width:81px;height:99px}.Pet-PandaCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -900px;width:81px;height:99px}.Pet-PandaCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-902px -900px;width:81px;height:99px}.Pet-PandaCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-984px -900px;width:81px;height:99px}.Pet-PandaCub-White{background-image:url(spritesmith-main-9.png);background-position:-1066px 0;width:81px;height:99px}.Pet-PandaCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1066px -100px;width:81px;height:99px}.Pet-Parrot-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -200px;width:81px;height:99px}.Pet-Parrot-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1066px -300px;width:81px;height:99px}.Pet-Parrot-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -400px;width:81px;height:99px}.Pet-Parrot-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -500px;width:81px;height:99px}.Pet-Parrot-Golden{background-image:url(spritesmith-main-9.png);background-position:-1066px -600px;width:81px;height:99px}.Pet-Parrot-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -700px;width:81px;height:99px}.Pet-Parrot-Shade{background-image:url(spritesmith-main-9.png);background-position:-1066px -800px;width:81px;height:99px}.Pet-Parrot-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1066px -900px;width:81px;height:99px}.Pet-Parrot-White{background-image:url(spritesmith-main-9.png);background-position:0 -1000px;width:81px;height:99px}.Pet-Parrot-Zombie{background-image:url(spritesmith-main-9.png);background-position:-82px -1000px;width:81px;height:99px}.Pet-Penguin-Base{background-image:url(spritesmith-main-9.png);background-position:-164px -1000px;width:81px;height:99px}.Pet-Penguin-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-246px -1000px;width:81px;height:99px}.Pet-Penguin-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 0;width:81px;height:99px}.Pet-Penguin-Desert{background-image:url(spritesmith-main-9.png);background-position:-410px -1000px;width:81px;height:99px}.Pet-Penguin-Golden{background-image:url(spritesmith-main-9.png);background-position:-492px -1000px;width:81px;height:99px}.Pet-Penguin-Red{background-image:url(spritesmith-main-9.png);background-position:-574px -1000px;width:81px;height:99px}.Pet-Penguin-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -1000px;width:81px;height:99px}.Pet-Penguin-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -1000px;width:81px;height:99px}.Pet-Penguin-White{background-image:url(spritesmith-main-9.png);background-position:-820px -1000px;width:81px;height:99px}.Pet-Penguin-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -1000px;width:81px;height:99px}.Pet-Phoenix-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -1000px;width:81px;height:99px}.Pet-Rat-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -1000px;width:81px;height:99px}.Pet-Rat-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px 0;width:81px;height:99px}.Pet-Rat-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1148px -100px;width:81px;height:99px}.Pet-Rat-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -200px;width:81px;height:99px}.Pet-Rat-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -300px;width:81px;height:99px}.Pet-Rat-Red{background-image:url(spritesmith-main-9.png);background-position:-1148px -400px;width:81px;height:99px}.Pet-Rat-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -500px;width:81px;height:99px}.Pet-Rat-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1148px -600px;width:81px;height:99px}.Pet-Rat-White{background-image:url(spritesmith-main-9.png);background-position:-1148px -700px;width:81px;height:99px}.Pet-Rat-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -800px;width:81px;height:99px}.Pet-Rock-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -900px;width:81px;height:99px}.Pet-Rock-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px -1000px;width:81px;height:99px}.Pet-Rock-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 -1100px;width:81px;height:99px}.Pet-Rock-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px -1100px;width:81px;height:99px}.Pet-Rock-Golden{background-image:url(spritesmith-main-9.png);background-position:-164px -1100px;width:81px;height:99px}.Pet-Rock-Red{background-image:url(spritesmith-main-9.png);background-position:-246px -1100px;width:81px;height:99px}.Pet-Rock-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -1100px;width:81px;height:99px}.Pet-Rock-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -1100px;width:81px;height:99px}.Pet-Rock-White{background-image:url(spritesmith-main-9.png);background-position:-492px -1100px;width:81px;height:99px}.Pet-Rock-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px -1100px;width:81px;height:99px}.Pet-Rooster-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -1100px;width:81px;height:99px}.Pet-Rooster-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -1100px;width:81px;height:99px}.Pet-Rooster-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-820px -1100px;width:81px;height:99px}.Pet-Rooster-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -1100px;width:81px;height:99px}.Pet-Rooster-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -1100px;width:81px;height:99px}.Pet-Rooster-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px -1100px;width:81px;height:99px}.Pet-Rooster-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -1100px;width:81px;height:99px}.Pet-Rooster-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px 0;width:81px;height:99px}.Pet-Rooster-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -100px;width:81px;height:99px}.Pet-Rooster-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1230px -200px;width:81px;height:99px}.Pet-Seahorse-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px -300px;width:81px;height:99px}.Pet-Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1230px -400px;width:81px;height:99px}.Pet-Seahorse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1230px -500px;width:81px;height:99px}.Pet-Seahorse-Desert{background-image:url(spritesmith-main-9.png);background-position:-1230px -600px;width:81px;height:99px}.Pet-Seahorse-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -700px;width:81px;height:99px}.Pet-Seahorse-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -800px;width:81px;height:99px}.Pet-Seahorse-Shade{background-image:url(spritesmith-main-9.png);background-position:-1230px -900px;width:81px;height:99px}.Pet-Seahorse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px -1000px;width:81px;height:99px}.Pet-Seahorse-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -1100px;width:81px;height:99px}.Pet-Seahorse-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -1200px;width:81px;height:99px}.Pet-Sheep-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -1200px;width:81px;height:99px}.Pet-Sheep-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -1200px;width:81px;height:99px}.Pet-Sheep-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px -1200px;width:81px;height:99px}.Pet-Sheep-Desert{background-image:url(spritesmith-main-9.png);background-position:-328px -1200px;width:81px;height:99px}.Pet-Sheep-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -1200px;width:81px;height:99px}.Pet-Sheep-Red{background-image:url(spritesmith-main-9.png);background-position:-492px -1200px;width:81px;height:99px}.Pet-Sheep-Shade{background-image:url(spritesmith-main-9.png);background-position:-574px -1200px;width:81px;height:99px}.Pet-Sheep-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-656px -1200px;width:81px;height:99px}.Pet-Sheep-White{background-image:url(spritesmith-main-9.png);background-position:-738px -1200px;width:81px;height:99px}.Pet-Sheep-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px -1200px;width:81px;height:99px}.Pet-Slime-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -1200px;width:81px;height:99px}.Pet-Slime-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -1200px;width:81px;height:99px}.Pet-Slime-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -1200px;width:81px;height:99px}.Pet-Slime-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -1200px;width:81px;height:99px}.Pet-Slime-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -1200px;width:81px;height:99px}.Pet-Slime-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px 0;width:81px;height:99px}.Pet-Slime-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -100px;width:81px;height:99px}.Pet-Slime-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -200px;width:81px;height:99px}.Pet-Slime-White{background-image:url(spritesmith-main-9.png);background-position:-1312px -300px;width:81px;height:99px}.Pet-Slime-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1312px -400px;width:81px;height:99px}.Pet-Snake-Base{background-image:url(spritesmith-main-9.png);background-position:-1312px -500px;width:81px;height:99px}.Pet-Snake-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1312px -600px;width:81px;height:99px}.Pet-Snake-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px -700px;width:81px;height:99px}.Pet-Snake-Desert{background-image:url(spritesmith-main-9.png);background-position:-1312px -800px;width:81px;height:99px}.Pet-Snake-Golden{background-image:url(spritesmith-main-9.png);background-position:-1312px -900px;width:81px;height:99px}.Pet-Snake-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px -1000px;width:81px;height:99px}.Pet-Snake-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -1100px;width:81px;height:99px}.Pet-Snake-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -1200px;width:81px;height:99px}.Pet-Snake-White{background-image:url(spritesmith-main-9.png);background-position:-1394px 0;width:81px;height:99px}.Pet-Snake-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -100px;width:81px;height:99px}.Pet-Spider-Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -200px;width:81px;height:99px}.Pet-Spider-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1394px -300px;width:81px;height:99px}.Pet-Spider-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1394px -400px;width:81px;height:99px}.Pet-Spider-Desert{background-image:url(spritesmith-main-9.png);background-position:-1394px -500px;width:81px;height:99px}.Pet-Spider-Golden{background-image:url(spritesmith-main-9.png);background-position:-1394px -600px;width:81px;height:99px}.Pet-Spider-Red{background-image:url(spritesmith-main-9.png);background-position:-1394px -700px;width:81px;height:99px}.Pet-Spider-Shade{background-image:url(spritesmith-main-9.png);background-position:-1394px -800px;width:81px;height:99px}.Pet-Spider-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -900px;width:81px;height:99px}.Pet-Spider-White{background-image:url(spritesmith-main-9.png);background-position:-1394px -1000px;width:81px;height:99px}.Pet-Spider-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -1100px;width:81px;height:99px}.Pet-TRex-Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -1200px;width:81px;height:99px}.Pet-TRex-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -1300px;width:81px;height:99px}.Pet-TRex-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -1300px;width:81px;height:99px}.Pet-TRex-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -1300px;width:81px;height:99px}.Pet-TRex-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -1300px;width:81px;height:99px}.Pet-TRex-Red{background-image:url(spritesmith-main-9.png);background-position:-328px -1300px;width:81px;height:99px}.Pet-TRex-Shade{background-image:url(spritesmith-main-9.png);background-position:-410px -1300px;width:81px;height:99px}.Pet-TRex-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-492px -1300px;width:81px;height:99px}.Pet-TRex-White{background-image:url(spritesmith-main-9.png);background-position:-574px -1300px;width:81px;height:99px}.Pet-TRex-Zombie{background-image:url(spritesmith-main-9.png);background-position:-656px -1300px;width:81px;height:99px}.Pet-Tiger-Veteran{background-image:url(spritesmith-main-9.png);background-position:-738px -1300px;width:81px;height:99px}.Pet-TigerCub-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -1300px;width:81px;height:99px}.Pet-TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -1300px;width:81px;height:99px}.Pet-TigerCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-984px -1300px;width:81px;height:99px}.Pet-TigerCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -1300px;width:81px;height:99px}.Pet-TigerCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -1300px;width:81px;height:99px}.Pet-TigerCub-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -1300px;width:81px;height:99px}.Pet-TigerCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -1300px;width:81px;height:99px}.Pet-TigerCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -1300px;width:81px;height:99px}.Pet-TigerCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-1476px 0;width:81px;height:99px}.Pet-TigerCub-White{background-image:url(spritesmith-main-9.png);background-position:-1476px -100px;width:81px;height:99px}.Pet-TigerCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1476px -200px;width:81px;height:99px}.Pet-Turkey-Base{background-image:url(spritesmith-main-9.png);background-position:-1476px -300px;width:81px;height:99px}.Pet-Whale-Base{background-image:url(spritesmith-main-9.png);background-position:-1476px -400px;width:81px;height:99px}.Pet-Whale-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1476px -500px;width:81px;height:99px}.Pet-Whale-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1476px -600px;width:81px;height:99px}.Pet-Whale-Desert{background-image:url(spritesmith-main-9.png);background-position:-1476px -700px;width:81px;height:99px}.Pet-Whale-Golden{background-image:url(spritesmith-main-9.png);background-position:-1476px -800px;width:81px;height:99px}.Pet-Whale-Red{background-image:url(spritesmith-main-9.png);background-position:-1476px -900px;width:81px;height:99px}.Pet-Whale-Shade{background-image:url(spritesmith-main-9.png);background-position:-1476px -1000px;width:81px;height:99px}.Pet-Whale-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1476px -1100px;width:81px;height:99px}.Pet-Whale-White{background-image:url(spritesmith-main-9.png);background-position:-1476px -1200px;width:81px;height:99px}.Pet-Whale-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1476px -1300px;width:81px;height:99px}.Pet-Wolf-Base{background-image:url(spritesmith-main-9.png);background-position:0 -1400px;width:81px;height:99px}.Pet-Wolf-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -1400px;width:81px;height:99px}.Pet-Wolf-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -1400px;width:81px;height:99px}.Pet-Wolf-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -1400px;width:81px;height:99px}.Pet-Wolf-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -1400px;width:81px;height:99px}.Pet-Wolf-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -1400px;width:81px;height:99px}.Pet-Wolf-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -1400px;width:81px;height:99px}.Pet-Wolf-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -1400px;width:81px;height:99px}.Pet-Wolf-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px -1400px;width:81px;height:99px}.Pet-Wolf-Veteran{background-image:url(spritesmith-main-9.png);background-position:-738px -1400px;width:81px;height:99px}.Pet-Wolf-White{background-image:url(spritesmith-main-9.png);background-position:-820px -1400px;width:81px;height:99px}.Pet-Wolf-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -1400px;width:81px;height:99px}.Pet_HatchingPotion_Base{background-image:url(spritesmith-main-9.png);background-position:-1033px -1400px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1229px -1400px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1082px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Desert{background-image:url(spritesmith-main-9.png);background-position:-1131px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Golden{background-image:url(spritesmith-main-9.png);background-position:-1180px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Red{background-image:url(spritesmith-main-9.png);background-position:-984px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Shade{background-image:url(spritesmith-main-9.png);background-position:-1278px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1327px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Spooky{background-image:url(spritesmith-main-9.png);background-position:-1376px -1400px;width:48px;height:51px}.Pet_HatchingPotion_White{background-image:url(spritesmith-main-9.png);background-position:-1425px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Zombie{background-image:url(spritesmith-main-9.png);background-position:-1474px -1400px;width:48px;height:51px}.head_special_0,.weapon_special_0{width:105px;height:105px;margin-left:-3px;margin-top:-18px}.broad_armor_special_0,.shield_special_0,.slim_armor_special_0{width:90px;height:90px}.weapon_special_critical{background:url(/common/img/sprites/backer-only/weapon_special_critical.gif) no-repeat;width:90px;height:90px;margin-left:-12px;margin-top:12px}.weapon_special_1{margin-left:-12px}.broad_armor_special_1,.head_special_1,.slim_armor_special_1{width:90px;height:90px}.head_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeHelmet.gif) no-repeat}.head_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalHelmet.gif) no-repeat;margin-top:3px}.broad_armor_special_0,.slim_armor_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeArmor.gif) no-repeat}.broad_armor_special_1,.slim_armor_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalArmor.gif) no-repeat}.shield_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Shield-TormentedSkull.gif) no-repeat}.weapon_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Weapon-DarkSoulsBlade.gif) no-repeat}.Pet-Wolf-Cerberus{width:105px;height:72px;background:url(/common/img/sprites/backer-only/BackerOnly-Pet-CerberusPup.gif) no-repeat}.npc_ian{background:url(/common/img/sprites/npc_ian.gif) no-repeat;width:78px;height:135px}.quest_burnout{background:url(/common/img/sprites/quest_burnout.gif) no-repeat;width:219px;height:249px}.Gems{display:inline-block;margin-right:5px;border-style:none;margin-left:0;margin-top:2px}.inline-gems{vertical-align:middle;margin-left:0;display:inline-block}.customize-menu .locked{background-color:#727272}.achievement{float:left;clear:right;margin-right:10px}.multi-achievement{margin:auto;padding-left:.5em;padding-right:.5em}[class*=Mount_Body_],[class*=Mount_Head_]{margin-top:18px}.Pet_Currency_Gem{margin-top:5px;margin-bottom:5px} \ No newline at end of file +.2014_Fall_HealerPROMO2{background-image:url(spritesmith-largeSprites-0.png);background-position:-822px -995px;width:90px;height:90px}.2014_Fall_Mage_PROMO9{background-image:url(spritesmith-largeSprites-0.png);background-position:-306px -417px;width:120px;height:90px}.2014_Fall_RoguePROMO3{background-image:url(spritesmith-largeSprites-0.png);background-position:-808px -621px;width:105px;height:90px}.2014_Fall_Warrior_PROMO{background-image:url(spritesmith-largeSprites-0.png);background-position:-276px -995px;width:90px;height:90px}.promo_backtoschool{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -522px;width:150px;height:150px}.promo_burnout{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px 0;width:219px;height:240px}.promo_classes_fall_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-326px -724px;width:321px;height:100px}.promo_classes_fall_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-430px -621px;width:377px;height:99px}.promo_dilatoryDistress{background-image:url(spritesmith-largeSprites-0.png);background-position:-458px -995px;width:90px;height:90px}.promo_enchanted_armoire{background-image:url(spritesmith-largeSprites-0.png);background-position:-499px -525px;width:374px;height:76px}.promo_enchanted_armoire_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -673px;width:217px;height:90px}.promo_enchanted_armoire_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-648px -724px;width:180px;height:90px}.promo_enchanted_armoire_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-549px -995px;width:90px;height:90px}.promo_enchanted_armoire_201511{background-image:url(spritesmith-largeSprites-0.png);background-position:-306px -326px;width:122px;height:90px}.promo_habitica{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -241px;width:175px;height:175px}.promo_habitica_sticker{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -220px;width:305px;height:304px}.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-1094px -522px;width:100px;height:137px}.customize-option.promo_haunted_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-1119px -537px;width:60px;height:60px}.promo_item_notif{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -271px;width:249px;height:102px}.promo_mystery_201405{background-image:url(spritesmith-largeSprites-0.png);background-position:-1095px -995px;width:90px;height:90px}.promo_mystery_201406{background-image:url(spritesmith-largeSprites-0.png);background-position:-91px -995px;width:90px;height:96px}.promo_mystery_201407{background-image:url(spritesmith-largeSprites-0.png);background-position:-628px -241px;width:42px;height:62px}.promo_mystery_201408{background-image:url(spritesmith-largeSprites-0.png);background-position:-1161px -764px;width:60px;height:71px}.promo_mystery_201409{background-image:url(spritesmith-largeSprites-0.png);background-position:-731px -995px;width:90px;height:90px}.promo_mystery_201410{background-image:url(spritesmith-largeSprites-0.png);background-position:-1161px -673px;width:72px;height:63px}.promo_mystery_201411{background-image:url(spritesmith-largeSprites-0.png);background-position:-913px -995px;width:90px;height:90px}.promo_mystery_201412{background-image:url(spritesmith-largeSprites-0.png);background-position:-1195px -592px;width:42px;height:66px}.promo_mystery_201501{background-image:url(spritesmith-largeSprites-0.png);background-position:-1193px -271px;width:48px;height:63px}.promo_mystery_201502{background-image:url(spritesmith-largeSprites-0.png);background-position:-367px -995px;width:90px;height:90px}.promo_mystery_201503{background-image:url(spritesmith-largeSprites-0.png);background-position:-91px -1101px;width:90px;height:90px}.promo_mystery_201504{background-image:url(spritesmith-largeSprites-0.png);background-position:-874px -525px;width:60px;height:69px}.promo_mystery_201505{background-image:url(spritesmith-largeSprites-0.png);background-position:-640px -995px;width:90px;height:90px}.promo_mystery_201506{background-image:url(spritesmith-largeSprites-0.png);background-position:-1195px -522px;width:42px;height:69px}.promo_mystery_201507{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -995px;width:90px;height:105px}.promo_mystery_201508{background-image:url(spritesmith-largeSprites-0.png);background-position:-829px -724px;width:93px;height:90px}.promo_mystery_201509{background-image:url(spritesmith-largeSprites-0.png);background-position:-1004px -995px;width:90px;height:90px}.promo_mystery_201510{background-image:url(spritesmith-largeSprites-0.png);background-position:-182px -995px;width:93px;height:90px}.promo_mystery_201511{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -1101px;width:90px;height:90px}.promo_mystery_3014{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -764px;width:217px;height:90px}.promo_orca{background-image:url(spritesmith-largeSprites-0.png);background-position:-306px -220px;width:105px;height:105px}.promo_partyhats{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -855px;width:115px;height:47px}.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -835px;width:330px;height:83px}.customize-option.promo_pastel_skin{background-image:url(spritesmith-largeSprites-0.png);background-position:-25px -850px;width:60px;height:60px}.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-1100px -374px;width:140px;height:147px}.customize-option.promo_pet_skins{background-image:url(spritesmith-largeSprites-0.png);background-position:-1125px -389px;width:60px;height:60px}.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-331px -835px;width:330px;height:83px}.customize-option.promo_shimmer_hair{background-image:url(spritesmith-largeSprites-0.png);background-position:-356px -850px;width:60px;height:60px}.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-452px -417px;width:198px;height:91px}.customize-option.promo_splashyskins{background-image:url(spritesmith-largeSprites-0.png);background-position:-477px -432px;width:60px;height:60px}.promo_springclasses2014{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -180px;width:288px;height:90px}.promo_springclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -89px;width:288px;height:90px}.promo_summer_classes_2014{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -621px;width:429px;height:102px}.promo_summer_classes_2015{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px 0;width:300px;height:88px}.promo_updos{background-image:url(spritesmith-largeSprites-0.png);background-position:-943px -374px;width:156px;height:147px}.promo_veteran_pets{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -919px;width:146px;height:75px}.promo_winterclasses2015{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -724px;width:325px;height:110px}.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-662px -835px;width:152px;height:75px}.customize-option.promo_winteryhair{background-image:url(spritesmith-largeSprites-0.png);background-position:-687px -850px;width:60px;height:60px}.avatar_variety{background-image:url(spritesmith-largeSprites-0.png);background-position:0 -525px;width:498px;height:95px}.party_preview{background-image:url(spritesmith-largeSprites-0.png);background-position:0 0;width:451px;height:219px}.welcome_basic_avatars{background-image:url(spritesmith-largeSprites-0.png);background-position:-672px -347px;width:246px;height:165px}.welcome_promo_party{background-image:url(spritesmith-largeSprites-0.png);background-position:-672px 0;width:270px;height:180px}.welcome_sample_tasks{background-image:url(spritesmith-largeSprites-0.png);background-position:-672px -181px;width:246px;height:165px}.achievement-alien{background-image:url(spritesmith-main-0.png);background-position:-1678px -1451px;width:24px;height:26px}.achievement-alien2x{background-image:url(spritesmith-main-0.png);background-position:-895px -979px;width:48px;height:52px}.achievement-alpha{background-image:url(spritesmith-main-0.png);background-position:-1678px -1424px;width:24px;height:26px}.achievement-armor{background-image:url(spritesmith-main-0.png);background-position:-1678px -1397px;width:24px;height:26px}.achievement-armor2x{background-image:url(spritesmith-main-0.png);background-position:-944px -979px;width:48px;height:52px}.achievement-boot{background-image:url(spritesmith-main-0.png);background-position:-1678px -1343px;width:24px;height:26px}.achievement-boot2x{background-image:url(spritesmith-main-0.png);background-position:-1042px -979px;width:48px;height:52px}.achievement-bow{background-image:url(spritesmith-main-0.png);background-position:-1678px -1289px;width:24px;height:26px}.achievement-bow2x{background-image:url(spritesmith-main-0.png);background-position:-504px -1582px;width:48px;height:52px}.achievement-burnout{background-image:url(spritesmith-main-0.png);background-position:-1678px -1235px;width:24px;height:26px}.achievement-burnout2x{background-image:url(spritesmith-main-0.png);background-position:-602px -1582px;width:48px;height:52px}.achievement-cactus{background-image:url(spritesmith-main-0.png);background-position:-1678px -1181px;width:24px;height:26px}.achievement-cactus2x{background-image:url(spritesmith-main-0.png);background-position:-700px -1582px;width:48px;height:52px}.achievement-cake{background-image:url(spritesmith-main-0.png);background-position:-1678px -1127px;width:24px;height:26px}.achievement-cake2x{background-image:url(spritesmith-main-0.png);background-position:-798px -1582px;width:48px;height:52px}.achievement-cave{background-image:url(spritesmith-main-0.png);background-position:-1678px -1073px;width:24px;height:26px}.achievement-cave2x{background-image:url(spritesmith-main-0.png);background-position:-896px -1582px;width:48px;height:52px}.achievement-coffin{background-image:url(spritesmith-main-0.png);background-position:-1678px -1019px;width:24px;height:26px}.achievement-comment{background-image:url(spritesmith-main-0.png);background-position:-1678px -992px;width:24px;height:26px}.achievement-comment2x{background-image:url(spritesmith-main-0.png);background-position:-994px -1582px;width:48px;height:52px}.achievement-costumeContest{background-image:url(spritesmith-main-0.png);background-position:-1678px -938px;width:24px;height:26px}.achievement-costumeContest2x{background-image:url(spritesmith-main-0.png);background-position:-1092px -1582px;width:48px;height:52px}.achievement-dilatory{background-image:url(spritesmith-main-0.png);background-position:-1678px -884px;width:24px;height:26px}.achievement-firefox{background-image:url(spritesmith-main-0.png);background-position:-1678px -857px;width:24px;height:26px}.achievement-greeting{background-image:url(spritesmith-main-0.png);background-position:-1678px -830px;width:24px;height:26px}.achievement-greeting2x{background-image:url(spritesmith-main-0.png);background-position:-1190px -1582px;width:48px;height:52px}.achievement-habitBirthday{background-image:url(spritesmith-main-0.png);background-position:-1678px -776px;width:24px;height:26px}.achievement-habitBirthday2x{background-image:url(spritesmith-main-0.png);background-position:-1288px -1582px;width:48px;height:52px}.achievement-habiticaDay{background-image:url(spritesmith-main-0.png);background-position:-1678px -722px;width:24px;height:26px}.achievement-habiticaDay2x{background-image:url(spritesmith-main-0.png);background-position:-1386px -1582px;width:48px;height:52px}.achievement-heart{background-image:url(spritesmith-main-0.png);background-position:-1678px -668px;width:24px;height:26px}.achievement-heart2x{background-image:url(spritesmith-main-0.png);background-position:-1435px -1582px;width:48px;height:52px}.achievement-karaoke-2x{background-image:url(spritesmith-main-0.png);background-position:-1533px -1582px;width:48px;height:52px}.achievement-karaoke{background-image:url(spritesmith-main-0.png);background-position:-1678px -587px;width:24px;height:26px}.achievement-ninja{background-image:url(spritesmith-main-0.png);background-position:-1678px -560px;width:24px;height:26px}.achievement-ninja2x{background-image:url(spritesmith-main-0.png);background-position:-1678px 0;width:48px;height:52px}.achievement-nye{background-image:url(spritesmith-main-0.png);background-position:-1678px -506px;width:24px;height:26px}.achievement-nye2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -106px;width:48px;height:52px}.achievement-perfect{background-image:url(spritesmith-main-0.png);background-position:-1678px -452px;width:24px;height:26px}.achievement-perfect2x{background-image:url(spritesmith-main-0.png);background-position:-846px -979px;width:48px;height:52px}.achievement-rat{background-image:url(spritesmith-main-0.png);background-position:-1678px -911px;width:24px;height:26px}.achievement-rat2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -318px;width:48px;height:52px}.achievement-seafoam{background-image:url(spritesmith-main-0.png);background-position:-1678px -398px;width:24px;height:26px}.achievement-seafoam2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -265px;width:48px;height:52px}.achievement-shield{background-image:url(spritesmith-main-0.png);background-position:-1678px -425px;width:24px;height:26px}.achievement-shield2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -159px;width:48px;height:52px}.achievement-shinySeed{background-image:url(spritesmith-main-0.png);background-position:-1678px -479px;width:24px;height:26px}.achievement-shinySeed2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -53px;width:48px;height:52px}.achievement-snowball{background-image:url(spritesmith-main-0.png);background-position:-1678px -533px;width:24px;height:26px}.achievement-snowball2x{background-image:url(spritesmith-main-0.png);background-position:-1582px -1582px;width:48px;height:52px}.achievement-spookDust{background-image:url(spritesmith-main-0.png);background-position:-1678px -614px;width:24px;height:26px}.achievement-spookDust2x{background-image:url(spritesmith-main-0.png);background-position:-1484px -1582px;width:48px;height:52px}.achievement-stoikalm{background-image:url(spritesmith-main-0.png);background-position:-1678px -641px;width:24px;height:26px}.achievement-sun{background-image:url(spritesmith-main-0.png);background-position:-1678px -695px;width:24px;height:26px}.achievement-sun2x{background-image:url(spritesmith-main-0.png);background-position:-1337px -1582px;width:48px;height:52px}.achievement-sword{background-image:url(spritesmith-main-0.png);background-position:-1678px -749px;width:24px;height:26px}.achievement-sword2x{background-image:url(spritesmith-main-0.png);background-position:-1239px -1582px;width:48px;height:52px}.achievement-thankyou{background-image:url(spritesmith-main-0.png);background-position:-1678px -803px;width:24px;height:26px}.achievement-thankyou2x{background-image:url(spritesmith-main-0.png);background-position:-1141px -1582px;width:48px;height:52px}.achievement-thermometer{background-image:url(spritesmith-main-0.png);background-position:-1678px -371px;width:24px;height:26px}.achievement-thermometer2x{background-image:url(spritesmith-main-0.png);background-position:-1043px -1582px;width:48px;height:52px}.achievement-tree{background-image:url(spritesmith-main-0.png);background-position:-1678px -965px;width:24px;height:26px}.achievement-tree2x{background-image:url(spritesmith-main-0.png);background-position:-945px -1582px;width:48px;height:52px}.achievement-triadbingo{background-image:url(spritesmith-main-0.png);background-position:-1678px -1046px;width:24px;height:26px}.achievement-triadbingo2x{background-image:url(spritesmith-main-0.png);background-position:-847px -1582px;width:48px;height:52px}.achievement-ultimate-healer{background-image:url(spritesmith-main-0.png);background-position:-1678px -1100px;width:24px;height:26px}.achievement-ultimate-healer2x{background-image:url(spritesmith-main-0.png);background-position:-749px -1582px;width:48px;height:52px}.achievement-ultimate-mage{background-image:url(spritesmith-main-0.png);background-position:-1678px -1154px;width:24px;height:26px}.achievement-ultimate-mage2x{background-image:url(spritesmith-main-0.png);background-position:-651px -1582px;width:48px;height:52px}.achievement-ultimate-rogue{background-image:url(spritesmith-main-0.png);background-position:-1678px -1208px;width:24px;height:26px}.achievement-ultimate-rogue2x{background-image:url(spritesmith-main-0.png);background-position:-553px -1582px;width:48px;height:52px}.achievement-ultimate-warrior{background-image:url(spritesmith-main-0.png);background-position:-1678px -1262px;width:24px;height:26px}.achievement-ultimate-warrior2x{background-image:url(spritesmith-main-0.png);background-position:-455px -1582px;width:48px;height:52px}.achievement-valentine{background-image:url(spritesmith-main-0.png);background-position:-1678px -1316px;width:24px;height:26px}.achievement-valentine2x{background-image:url(spritesmith-main-0.png);background-position:-993px -979px;width:48px;height:52px}.achievement-wolf{background-image:url(spritesmith-main-0.png);background-position:-1678px -1370px;width:24px;height:26px}.achievement-wolf2x{background-image:url(spritesmith-main-0.png);background-position:-1678px -212px;width:48px;height:52px}.background_autumn_forest{background-image:url(spritesmith-main-0.png);background-position:-423px -592px;width:140px;height:147px}.background_beach{background-image:url(spritesmith-main-0.png);background-position:-426px 0;width:141px;height:147px}.background_blacksmithy{background-image:url(spritesmith-main-0.png);background-position:-709px -148px;width:140px;height:147px}.background_cherry_trees{background-image:url(spritesmith-main-0.png);background-position:-709px -296px;width:140px;height:147px}.background_clouds{background-image:url(spritesmith-main-0.png);background-position:0 -592px;width:140px;height:147px}.background_coral_reef{background-image:url(spritesmith-main-0.png);background-position:-850px -148px;width:140px;height:147px}.background_crystal_cave{background-image:url(spritesmith-main-0.png);background-position:-850px -592px;width:140px;height:147px}.background_dilatory_ruins{background-image:url(spritesmith-main-0.png);background-position:0 -740px;width:140px;height:147px}.background_distant_castle{background-image:url(spritesmith-main-0.png);background-position:-423px -888px;width:140px;height:147px}.background_drifting_raft{background-image:url(spritesmith-main-0.png);background-position:-564px -888px;width:140px;height:147px}.background_dusty_canyons{background-image:url(spritesmith-main-0.png);background-position:-705px -888px;width:140px;height:147px}.background_fairy_ring{background-image:url(spritesmith-main-0.png);background-position:-568px 0;width:140px;height:147px}.background_floating_islands{background-image:url(spritesmith-main-0.png);background-position:-568px -148px;width:140px;height:147px}.background_floral_meadow{background-image:url(spritesmith-main-0.png);background-position:-568px -296px;width:140px;height:147px}.background_forest{background-image:url(spritesmith-main-0.png);background-position:0 -444px;width:140px;height:147px}.background_frigid_peak{background-image:url(spritesmith-main-0.png);background-position:-141px -444px;width:140px;height:147px}.background_giant_wave{background-image:url(spritesmith-main-0.png);background-position:-284px 0;width:141px;height:147px}.background_graveyard{background-image:url(spritesmith-main-0.png);background-position:-423px -444px;width:140px;height:147px}.background_gumdrop_land{background-image:url(spritesmith-main-0.png);background-position:-564px -444px;width:140px;height:147px}.background_harvest_feast{background-image:url(spritesmith-main-0.png);background-position:-709px 0;width:140px;height:147px}.background_harvest_fields{background-image:url(spritesmith-main-0.png);background-position:0 -148px;width:141px;height:147px}.background_harvest_moon{background-image:url(spritesmith-main-0.png);background-position:-142px -148px;width:141px;height:147px}.background_haunted_house{background-image:url(spritesmith-main-0.png);background-position:-709px -444px;width:140px;height:147px}.background_ice_cave{background-image:url(spritesmith-main-0.png);background-position:-284px -148px;width:141px;height:147px}.background_iceberg{background-image:url(spritesmith-main-0.png);background-position:-141px -592px;width:140px;height:147px}.background_island_waterfalls{background-image:url(spritesmith-main-0.png);background-position:-282px -592px;width:140px;height:147px}.background_marble_temple{background-image:url(spritesmith-main-0.png);background-position:-142px 0;width:141px;height:147px}.background_market{background-image:url(spritesmith-main-0.png);background-position:-564px -592px;width:140px;height:147px}.background_mountain_lake{background-image:url(spritesmith-main-0.png);background-position:-705px -592px;width:140px;height:147px}.background_night_dunes{background-image:url(spritesmith-main-0.png);background-position:-850px 0;width:140px;height:147px}.background_open_waters{background-image:url(spritesmith-main-0.png);background-position:0 0;width:141px;height:147px}.background_pagodas{background-image:url(spritesmith-main-0.png);background-position:-850px -296px;width:140px;height:147px}.background_pumpkin_patch{background-image:url(spritesmith-main-0.png);background-position:-850px -444px;width:140px;height:147px}.background_pyramids{background-image:url(spritesmith-main-0.png);background-position:-426px -148px;width:141px;height:147px}.background_rolling_hills{background-image:url(spritesmith-main-0.png);background-position:0 -296px;width:141px;height:147px}.background_seafarer_ship{background-image:url(spritesmith-main-0.png);background-position:-141px -740px;width:140px;height:147px}.background_shimmery_bubbles{background-image:url(spritesmith-main-0.png);background-position:-282px -740px;width:140px;height:147px}.background_slimy_swamp{background-image:url(spritesmith-main-0.png);background-position:-423px -740px;width:140px;height:147px}.background_snowy_pines{background-image:url(spritesmith-main-0.png);background-position:-564px -740px;width:140px;height:147px}.background_south_pole{background-image:url(spritesmith-main-0.png);background-position:-705px -740px;width:140px;height:147px}.background_spring_rain{background-image:url(spritesmith-main-0.png);background-position:-846px -740px;width:140px;height:147px}.background_stable{background-image:url(spritesmith-main-0.png);background-position:-991px 0;width:140px;height:147px}.background_stained_glass{background-image:url(spritesmith-main-0.png);background-position:-991px -148px;width:140px;height:147px}.background_starry_skies{background-image:url(spritesmith-main-0.png);background-position:-991px -296px;width:140px;height:147px}.background_sunken_ship{background-image:url(spritesmith-main-0.png);background-position:-991px -444px;width:140px;height:147px}.background_sunset_meadow{background-image:url(spritesmith-main-0.png);background-position:-991px -592px;width:140px;height:147px}.background_sunset_oasis{background-image:url(spritesmith-main-0.png);background-position:-991px -740px;width:140px;height:147px}.background_sunset_savannah{background-image:url(spritesmith-main-0.png);background-position:0 -888px;width:140px;height:147px}.background_swarming_darkness{background-image:url(spritesmith-main-0.png);background-position:-141px -888px;width:140px;height:147px}.background_tavern{background-image:url(spritesmith-main-0.png);background-position:-282px -888px;width:140px;height:147px}.background_thunderstorm{background-image:url(spritesmith-main-0.png);background-position:-142px -296px;width:141px;height:147px}.background_twinkly_lights{background-image:url(spritesmith-main-0.png);background-position:-284px -296px;width:141px;height:147px}.background_twinkly_party_lights{background-image:url(spritesmith-main-0.png);background-position:-426px -296px;width:141px;height:147px}.background_volcano{background-image:url(spritesmith-main-0.png);background-position:-282px -444px;width:140px;height:147px}.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -910px;width:90px;height:90px}.customize-option.hair_beard_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -925px;width:60px;height:60px}.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -1016px;width:60px;height:60px}.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -1107px;width:60px;height:60px}.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -1183px;width:90px;height:90px}.customize-option.hair_beard_1_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -1198px;width:60px;height:60px}.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:0 -1309px;width:90px;height:90px}.customize-option.hair_beard_1_blue{background-image:url(spritesmith-main-0.png);background-position:-25px -1324px;width:60px;height:60px}.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1324px;width:60px;height:60px}.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-182px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-207px -1324px;width:60px;height:60px}.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-273px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-298px -1324px;width:60px;height:60px}.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-364px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_festive{background-image:url(spritesmith-main-0.png);background-position:-389px -1324px;width:60px;height:60px}.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-455px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_frost{background-image:url(spritesmith-main-0.png);background-position:-480px -1324px;width:60px;height:60px}.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-546px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-571px -1324px;width:60px;height:60px}.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-637px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_green{background-image:url(spritesmith-main-0.png);background-position:-662px -1324px;width:60px;height:60px}.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-728px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-753px -1324px;width:60px;height:60px}.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-819px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_holly{background-image:url(spritesmith-main-0.png);background-position:-844px -1324px;width:60px;height:60px}.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-910px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-935px -1324px;width:60px;height:60px}.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1324px;width:60px;height:60px}.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1092px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-1117px -1324px;width:60px;height:60px}.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1183px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1208px -1324px;width:60px;height:60px}.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1309px;width:90px;height:90px}.customize-option.hair_beard_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1324px;width:60px;height:60px}.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1405px 0;width:90px;height:90px}.customize-option.hair_beard_1_porange{background-image:url(spritesmith-main-0.png);background-position:-1430px -15px;width:60px;height:60px}.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1405px -91px;width:90px;height:90px}.customize-option.hair_beard_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-1430px -106px;width:60px;height:60px}.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1405px -182px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1430px -197px;width:60px;height:60px}.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1405px -273px;width:90px;height:90px}.customize-option.hair_beard_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1430px -288px;width:60px;height:60px}.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1405px -364px;width:90px;height:90px}.customize-option.hair_beard_1_purple{background-image:url(spritesmith-main-0.png);background-position:-1430px -379px;width:60px;height:60px}.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -455px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -470px;width:60px;height:60px}.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-846px -888px;width:90px;height:90px}.customize-option.hair_beard_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-871px -903px;width:60px;height:60px}.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1405px -637px;width:90px;height:90px}.customize-option.hair_beard_1_red{background-image:url(spritesmith-main-0.png);background-position:-1430px -652px;width:60px;height:60px}.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1405px -728px;width:90px;height:90px}.customize-option.hair_beard_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1430px -743px;width:60px;height:60px}.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1405px -819px;width:90px;height:90px}.customize-option.hair_beard_1_white{background-image:url(spritesmith-main-0.png);background-position:-1430px -834px;width:60px;height:60px}.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1405px -910px;width:90px;height:90px}.customize-option.hair_beard_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1430px -925px;width:60px;height:60px}.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1405px -1001px;width:90px;height:90px}.customize-option.hair_beard_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1430px -1016px;width:60px;height:60px}.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1405px -1092px;width:90px;height:90px}.customize-option.hair_beard_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1430px -1107px;width:60px;height:60px}.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1405px -1183px;width:90px;height:90px}.customize-option.hair_beard_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1430px -1198px;width:60px;height:60px}.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1405px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1430px -1289px;width:60px;height:60px}.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:0 -1400px;width:90px;height:90px}.customize-option.hair_beard_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-25px -1415px;width:60px;height:60px}.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-91px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_black{background-image:url(spritesmith-main-0.png);background-position:-116px -1415px;width:60px;height:60px}.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-182px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_blond{background-image:url(spritesmith-main-0.png);background-position:-207px -1415px;width:60px;height:60px}.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-273px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_blue{background-image:url(spritesmith-main-0.png);background-position:-298px -1415px;width:60px;height:60px}.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-364px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_brown{background-image:url(spritesmith-main-0.png);background-position:-389px -1415px;width:60px;height:60px}.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-455px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-480px -1415px;width:60px;height:60px}.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-546px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-571px -1415px;width:60px;height:60px}.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-637px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_festive{background-image:url(spritesmith-main-0.png);background-position:-662px -1415px;width:60px;height:60px}.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-728px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_frost{background-image:url(spritesmith-main-0.png);background-position:-753px -1415px;width:60px;height:60px}.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-819px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-844px -1415px;width:60px;height:60px}.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-910px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_green{background-image:url(spritesmith-main-0.png);background-position:-935px -1415px;width:60px;height:60px}.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-1001px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-1026px -1415px;width:60px;height:60px}.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-1092px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_holly{background-image:url(spritesmith-main-0.png);background-position:-1117px -1415px;width:60px;height:60px}.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1183px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1208px -1415px;width:60px;height:60px}.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1274px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-1299px -1415px;width:60px;height:60px}.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1365px -1400px;width:90px;height:90px}.customize-option.hair_beard_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-1390px -1415px;width:60px;height:60px}.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1496px 0;width:90px;height:90px}.customize-option.hair_beard_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1521px -15px;width:60px;height:60px}.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1496px -91px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1521px -106px;width:60px;height:60px}.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1496px -182px;width:90px;height:90px}.customize-option.hair_beard_2_porange{background-image:url(spritesmith-main-0.png);background-position:-1521px -197px;width:60px;height:60px}.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1496px -273px;width:90px;height:90px}.customize-option.hair_beard_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1521px -288px;width:60px;height:60px}.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1496px -364px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1521px -379px;width:60px;height:60px}.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1496px -455px;width:90px;height:90px}.customize-option.hair_beard_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1521px -470px;width:60px;height:60px}.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1496px -546px;width:90px;height:90px}.customize-option.hair_beard_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1521px -561px;width:60px;height:60px}.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -637px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -652px;width:60px;height:60px}.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1496px -728px;width:90px;height:90px}.customize-option.hair_beard_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1521px -743px;width:60px;height:60px}.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1496px -819px;width:90px;height:90px}.customize-option.hair_beard_2_red{background-image:url(spritesmith-main-0.png);background-position:-1521px -834px;width:60px;height:60px}.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1496px -910px;width:90px;height:90px}.customize-option.hair_beard_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1521px -925px;width:60px;height:60px}.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1496px -1001px;width:90px;height:90px}.customize-option.hair_beard_2_white{background-image:url(spritesmith-main-0.png);background-position:-1521px -1016px;width:60px;height:60px}.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1496px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1521px -1107px;width:60px;height:60px}.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1496px -1183px;width:90px;height:90px}.customize-option.hair_beard_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1521px -1198px;width:60px;height:60px}.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1496px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1521px -1289px;width:60px;height:60px}.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1496px -1365px;width:90px;height:90px}.customize-option.hair_beard_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1521px -1380px;width:60px;height:60px}.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:0 -1491px;width:90px;height:90px}.customize-option.hair_beard_3_TRUred{background-image:url(spritesmith-main-0.png);background-position:-25px -1506px;width:60px;height:60px}.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-91px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_aurora{background-image:url(spritesmith-main-0.png);background-position:-116px -1506px;width:60px;height:60px}.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-182px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_black{background-image:url(spritesmith-main-0.png);background-position:-207px -1506px;width:60px;height:60px}.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1506px;width:60px;height:60px}.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-364px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_blue{background-image:url(spritesmith-main-0.png);background-position:-389px -1506px;width:60px;height:60px}.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-455px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_brown{background-image:url(spritesmith-main-0.png);background-position:-480px -1506px;width:60px;height:60px}.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-546px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_candycane{background-image:url(spritesmith-main-0.png);background-position:-571px -1506px;width:60px;height:60px}.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-637px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_candycorn{background-image:url(spritesmith-main-0.png);background-position:-662px -1506px;width:60px;height:60px}.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-728px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_festive{background-image:url(spritesmith-main-0.png);background-position:-753px -1506px;width:60px;height:60px}.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-819px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_frost{background-image:url(spritesmith-main-0.png);background-position:-844px -1506px;width:60px;height:60px}.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-910px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-935px -1506px;width:60px;height:60px}.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-1001px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_green{background-image:url(spritesmith-main-0.png);background-position:-1026px -1506px;width:60px;height:60px}.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-1092px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_halloween{background-image:url(spritesmith-main-0.png);background-position:-1117px -1506px;width:60px;height:60px}.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-1183px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_holly{background-image:url(spritesmith-main-0.png);background-position:-1208px -1506px;width:60px;height:60px}.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1274px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1299px -1506px;width:60px;height:60px}.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1365px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_midnight{background-image:url(spritesmith-main-0.png);background-position:-1390px -1506px;width:60px;height:60px}.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1456px -1491px;width:90px;height:90px}.customize-option.hair_beard_3_pblue{background-image:url(spritesmith-main-0.png);background-position:-1481px -1506px;width:60px;height:60px}.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1587px 0;width:90px;height:90px}.customize-option.hair_beard_3_peppermint{background-image:url(spritesmith-main-0.png);background-position:-1612px -15px;width:60px;height:60px}.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1587px -91px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen{background-image:url(spritesmith-main-0.png);background-position:-1612px -106px;width:60px;height:60px}.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1587px -182px;width:90px;height:90px}.customize-option.hair_beard_3_porange{background-image:url(spritesmith-main-0.png);background-position:-1612px -197px;width:60px;height:60px}.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1587px -273px;width:90px;height:90px}.customize-option.hair_beard_3_ppink{background-image:url(spritesmith-main-0.png);background-position:-1612px -288px;width:60px;height:60px}.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1587px -364px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1612px -379px;width:60px;height:60px}.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1587px -455px;width:90px;height:90px}.customize-option.hair_beard_3_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1612px -470px;width:60px;height:60px}.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1587px -546px;width:90px;height:90px}.customize-option.hair_beard_3_purple{background-image:url(spritesmith-main-0.png);background-position:-1612px -561px;width:60px;height:60px}.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -637px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -652px;width:60px;height:60px}.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1587px -728px;width:90px;height:90px}.customize-option.hair_beard_3_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1612px -743px;width:60px;height:60px}.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1587px -819px;width:90px;height:90px}.customize-option.hair_beard_3_red{background-image:url(spritesmith-main-0.png);background-position:-1612px -834px;width:60px;height:60px}.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1587px -910px;width:90px;height:90px}.customize-option.hair_beard_3_snowy{background-image:url(spritesmith-main-0.png);background-position:-1612px -925px;width:60px;height:60px}.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1587px -1001px;width:90px;height:90px}.customize-option.hair_beard_3_white{background-image:url(spritesmith-main-0.png);background-position:-1612px -1016px;width:60px;height:60px}.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1587px -1092px;width:90px;height:90px}.customize-option.hair_beard_3_winternight{background-image:url(spritesmith-main-0.png);background-position:-1612px -1107px;width:60px;height:60px}.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1587px -1183px;width:90px;height:90px}.customize-option.hair_beard_3_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1612px -1198px;width:60px;height:60px}.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1587px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_yellow{background-image:url(spritesmith-main-0.png);background-position:-1612px -1289px;width:60px;height:60px}.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1587px -1365px;width:90px;height:90px}.customize-option.hair_beard_3_zombie{background-image:url(spritesmith-main-0.png);background-position:-1612px -1380px;width:60px;height:60px}.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1587px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1612px -1471px;width:60px;height:60px}.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:0 -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-25px -1597px;width:60px;height:60px}.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-91px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_black{background-image:url(spritesmith-main-0.png);background-position:-116px -1597px;width:60px;height:60px}.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-182px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_blond{background-image:url(spritesmith-main-0.png);background-position:-207px -1597px;width:60px;height:60px}.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-273px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_blue{background-image:url(spritesmith-main-0.png);background-position:-298px -1597px;width:60px;height:60px}.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-364px -1582px;width:90px;height:90px}.customize-option.hair_mustache_1_brown{background-image:url(spritesmith-main-0.png);background-position:-389px -1597px;width:60px;height:60px}.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1405px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-1430px -561px;width:60px;height:60px}.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1132px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1157px -652px;width:60px;height:60px}.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1132px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1157px -561px;width:60px;height:60px}.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1132px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1157px -470px;width:60px;height:60px}.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1132px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1157px -379px;width:60px;height:60px}.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-1132px -273px;width:90px;height:90px}.customize-option.hair_mustache_1_green{background-image:url(spritesmith-main-0.png);background-position:-1157px -288px;width:60px;height:60px}.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1132px -182px;width:90px;height:90px}.customize-option.hair_mustache_1_halloween{background-image:url(spritesmith-main-0.png);background-position:-1157px -197px;width:60px;height:60px}.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1132px -91px;width:90px;height:90px}.customize-option.hair_mustache_1_holly{background-image:url(spritesmith-main-0.png);background-position:-1157px -106px;width:60px;height:60px}.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1132px 0;width:90px;height:90px}.customize-option.hair_mustache_1_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-1157px -15px;width:60px;height:60px}.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1001px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_midnight{background-image:url(spritesmith-main-0.png);background-position:-1026px -1051px;width:60px;height:60px}.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-910px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue{background-image:url(spritesmith-main-0.png);background-position:-935px -1051px;width:60px;height:60px}.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-819px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_peppermint{background-image:url(spritesmith-main-0.png);background-position:-844px -1051px;width:60px;height:60px}.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-728px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen{background-image:url(spritesmith-main-0.png);background-position:-753px -1051px;width:60px;height:60px}.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-637px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_porange{background-image:url(spritesmith-main-0.png);background-position:-662px -1051px;width:60px;height:60px}.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-546px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink{background-image:url(spritesmith-main-0.png);background-position:-571px -1051px;width:60px;height:60px}.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-455px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple{background-image:url(spritesmith-main-0.png);background-position:-480px -1051px;width:60px;height:60px}.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-364px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-389px -1051px;width:60px;height:60px}.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-273px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_purple{background-image:url(spritesmith-main-0.png);background-position:-298px -1051px;width:60px;height:60px}.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-182px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow{background-image:url(spritesmith-main-0.png);background-position:-207px -1051px;width:60px;height:60px}.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-91px -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_rainbow{background-image:url(spritesmith-main-0.png);background-position:-116px -1051px;width:60px;height:60px}.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:0 -1036px;width:90px;height:90px}.customize-option.hair_mustache_1_red{background-image:url(spritesmith-main-0.png);background-position:-25px -1051px;width:60px;height:60px}.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1028px -888px;width:90px;height:90px}.customize-option.hair_mustache_1_snowy{background-image:url(spritesmith-main-0.png);background-position:-1053px -903px;width:60px;height:60px}.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-937px -888px;width:90px;height:90px}.customize-option.hair_mustache_1_white{background-image:url(spritesmith-main-0.png);background-position:-962px -903px;width:60px;height:60px}.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1314px -819px;width:90px;height:90px}.customize-option.hair_mustache_1_winternight{background-image:url(spritesmith-main-0.png);background-position:-1339px -834px;width:60px;height:60px}.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1314px -728px;width:90px;height:90px}.customize-option.hair_mustache_1_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1339px -743px;width:60px;height:60px}.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1314px -637px;width:90px;height:90px}.customize-option.hair_mustache_1_yellow{background-image:url(spritesmith-main-0.png);background-position:-1339px -652px;width:60px;height:60px}.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1314px -546px;width:90px;height:90px}.customize-option.hair_mustache_1_zombie{background-image:url(spritesmith-main-0.png);background-position:-1339px -561px;width:60px;height:60px}.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1314px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_TRUred{background-image:url(spritesmith-main-0.png);background-position:-1339px -470px;width:60px;height:60px}.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1314px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_aurora{background-image:url(spritesmith-main-0.png);background-position:-1339px -379px;width:60px;height:60px}.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1314px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_black{background-image:url(spritesmith-main-0.png);background-position:-1339px -288px;width:60px;height:60px}.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1314px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_blond{background-image:url(spritesmith-main-0.png);background-position:-1339px -197px;width:60px;height:60px}.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1314px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_blue{background-image:url(spritesmith-main-0.png);background-position:-1339px -106px;width:60px;height:60px}.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1314px 0;width:90px;height:90px}.customize-option.hair_mustache_2_brown{background-image:url(spritesmith-main-0.png);background-position:-1339px -15px;width:60px;height:60px}.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1183px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_candycane{background-image:url(spritesmith-main-0.png);background-position:-1208px -1233px;width:60px;height:60px}.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1092px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1117px -1233px;width:60px;height:60px}.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1001px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_festive{background-image:url(spritesmith-main-0.png);background-position:-1026px -1233px;width:60px;height:60px}.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-910px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_frost{background-image:url(spritesmith-main-0.png);background-position:-935px -1233px;width:60px;height:60px}.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-819px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-844px -1233px;width:60px;height:60px}.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-728px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_green{background-image:url(spritesmith-main-0.png);background-position:-753px -1233px;width:60px;height:60px}.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-637px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_halloween{background-image:url(spritesmith-main-0.png);background-position:-662px -1233px;width:60px;height:60px}.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-546px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_holly{background-image:url(spritesmith-main-0.png);background-position:-571px -1233px;width:60px;height:60px}.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-455px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_hollygreen{background-image:url(spritesmith-main-0.png);background-position:-480px -1233px;width:60px;height:60px}.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-364px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_midnight{background-image:url(spritesmith-main-0.png);background-position:-389px -1233px;width:60px;height:60px}.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-273px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue{background-image:url(spritesmith-main-0.png);background-position:-298px -1233px;width:60px;height:60px}.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-182px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_peppermint{background-image:url(spritesmith-main-0.png);background-position:-207px -1233px;width:60px;height:60px}.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-91px -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen{background-image:url(spritesmith-main-0.png);background-position:-116px -1233px;width:60px;height:60px}.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:0 -1218px;width:90px;height:90px}.customize-option.hair_mustache_2_porange{background-image:url(spritesmith-main-0.png);background-position:-25px -1233px;width:60px;height:60px}.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1223px -1092px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink{background-image:url(spritesmith-main-0.png);background-position:-1248px -1107px;width:60px;height:60px}.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1223px -1001px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple{background-image:url(spritesmith-main-0.png);background-position:-1248px -1016px;width:60px;height:60px}.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1223px -910px;width:90px;height:90px}.customize-option.hair_mustache_2_pumpkin{background-image:url(spritesmith-main-0.png);background-position:-1248px -925px;width:60px;height:60px}.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1223px -819px;width:90px;height:90px}.customize-option.hair_mustache_2_purple{background-image:url(spritesmith-main-0.png);background-position:-1248px -834px;width:60px;height:60px}.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1223px -728px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow{background-image:url(spritesmith-main-0.png);background-position:-1248px -743px;width:60px;height:60px}.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1223px -637px;width:90px;height:90px}.customize-option.hair_mustache_2_rainbow{background-image:url(spritesmith-main-0.png);background-position:-1248px -652px;width:60px;height:60px}.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1223px -546px;width:90px;height:90px}.customize-option.hair_mustache_2_red{background-image:url(spritesmith-main-0.png);background-position:-1248px -561px;width:60px;height:60px}.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1223px -455px;width:90px;height:90px}.customize-option.hair_mustache_2_snowy{background-image:url(spritesmith-main-0.png);background-position:-1248px -470px;width:60px;height:60px}.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1223px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_white{background-image:url(spritesmith-main-0.png);background-position:-1248px -379px;width:60px;height:60px}.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1223px -273px;width:90px;height:90px}.customize-option.hair_mustache_2_winternight{background-image:url(spritesmith-main-0.png);background-position:-1248px -288px;width:60px;height:60px}.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1223px -182px;width:90px;height:90px}.customize-option.hair_mustache_2_winterstar{background-image:url(spritesmith-main-0.png);background-position:-1248px -197px;width:60px;height:60px}.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1223px -91px;width:90px;height:90px}.customize-option.hair_mustache_2_yellow{background-image:url(spritesmith-main-0.png);background-position:-1248px -106px;width:60px;height:60px}.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1223px 0;width:90px;height:90px}.customize-option.hair_mustache_2_zombie{background-image:url(spritesmith-main-0.png);background-position:-1248px -15px;width:60px;height:60px}.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1092px -1127px;width:90px;height:90px}.customize-option.hair_flower_1{background-image:url(spritesmith-main-0.png);background-position:-1117px -1142px;width:60px;height:60px}.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1001px -1127px;width:90px;height:90px}.customize-option.hair_flower_2{background-image:url(spritesmith-main-0.png);background-position:-1026px -1142px;width:60px;height:60px}.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-910px -1127px;width:90px;height:90px}.customize-option.hair_flower_3{background-image:url(spritesmith-main-0.png);background-position:-935px -1142px;width:60px;height:60px}.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-819px -1127px;width:90px;height:90px}.customize-option.hair_flower_4{background-image:url(spritesmith-main-0.png);background-position:-844px -1142px;width:60px;height:60px}.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-728px -1127px;width:90px;height:90px}.customize-option.hair_flower_5{background-image:url(spritesmith-main-0.png);background-position:-753px -1142px;width:60px;height:60px}.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-637px -1127px;width:90px;height:90px}.customize-option.hair_flower_6{background-image:url(spritesmith-main-0.png);background-position:-662px -1142px;width:60px;height:60px}.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-546px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_TRUred{background-image:url(spritesmith-main-0.png);background-position:-571px -1142px;width:60px;height:60px}.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-455px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_aurora{background-image:url(spritesmith-main-0.png);background-position:-480px -1142px;width:60px;height:60px}.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-364px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_black{background-image:url(spritesmith-main-0.png);background-position:-389px -1142px;width:60px;height:60px}.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-273px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_blond{background-image:url(spritesmith-main-0.png);background-position:-298px -1142px;width:60px;height:60px}.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-182px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_blue{background-image:url(spritesmith-main-0.png);background-position:-207px -1142px;width:60px;height:60px}.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-91px -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_brown{background-image:url(spritesmith-main-0.png);background-position:-116px -1142px;width:60px;height:60px}.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:0 -1127px;width:90px;height:90px}.customize-option.hair_bangs_1_candycane{background-image:url(spritesmith-main-0.png);background-position:-25px -1142px;width:60px;height:60px}.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1132px -1001px;width:90px;height:90px}.customize-option.hair_bangs_1_candycorn{background-image:url(spritesmith-main-0.png);background-position:-1157px -1016px;width:60px;height:60px}.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1132px -910px;width:90px;height:90px}.customize-option.hair_bangs_1_festive{background-image:url(spritesmith-main-0.png);background-position:-1157px -925px;width:60px;height:60px}.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1132px -819px;width:90px;height:90px}.customize-option.hair_bangs_1_frost{background-image:url(spritesmith-main-0.png);background-position:-1157px -834px;width:60px;height:60px}.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1132px -728px;width:90px;height:90px}.customize-option.hair_bangs_1_ghostwhite{background-image:url(spritesmith-main-0.png);background-position:-1157px -743px;width:60px;height:60px}.hair_bangs_1_green{background-image:url(spritesmith-main-1.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_bangs_1_green{background-image:url(spritesmith-main-1.png);background-position:-116px -15px;width:60px;height:60px}.hair_bangs_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_bangs_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -1107px;width:60px;height:60px}.hair_bangs_1_holly{background-image:url(spritesmith-main-1.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_bangs_1_holly{background-image:url(spritesmith-main-1.png);background-position:-25px -106px;width:60px;height:60px}.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-116px -106px;width:60px;height:60px}.hair_bangs_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_bangs_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-207px -15px;width:60px;height:60px}.hair_bangs_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -106px;width:60px;height:60px}.hair_bangs_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -197px;width:60px;height:60px}.hair_bangs_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -197px;width:60px;height:60px}.hair_bangs_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -197px;width:60px;height:60px}.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -15px;width:60px;height:60px}.hair_bangs_1_porange{background-image:url(spritesmith-main-1.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_porange{background-image:url(spritesmith-main-1.png);background-position:-298px -106px;width:60px;height:60px}.hair_bangs_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-298px -197px;width:60px;height:60px}.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -288px;width:60px;height:60px}.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -288px;width:60px;height:60px}.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -288px;width:60px;height:60px}.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -288px;width:60px;height:60px}.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_bangs_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -15px;width:60px;height:60px}.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_purple{background-image:url(spritesmith-main-1.png);background-position:-389px -106px;width:60px;height:60px}.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow{background-image:url(spritesmith-main-1.png);background-position:-389px -197px;width:60px;height:60px}.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_bangs_1_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -288px;width:60px;height:60px}.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_bangs_1_rainbow{background-image:url(spritesmith-main-1.png);background-position:-25px -379px;width:60px;height:60px}.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_red{background-image:url(spritesmith-main-1.png);background-position:-116px -379px;width:60px;height:60px}.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_snowy{background-image:url(spritesmith-main-1.png);background-position:-207px -379px;width:60px;height:60px}.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_white{background-image:url(spritesmith-main-1.png);background-position:-298px -379px;width:60px;height:60px}.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_bangs_1_winternight{background-image:url(spritesmith-main-1.png);background-position:-389px -379px;width:60px;height:60px}.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_bangs_1_winterstar{background-image:url(spritesmith-main-1.png);background-position:-480px -15px;width:60px;height:60px}.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_bangs_1_yellow{background-image:url(spritesmith-main-1.png);background-position:-480px -106px;width:60px;height:60px}.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_bangs_1_zombie{background-image:url(spritesmith-main-1.png);background-position:-480px -197px;width:60px;height:60px}.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_TRUred{background-image:url(spritesmith-main-1.png);background-position:-480px -288px;width:60px;height:60px}.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_aurora{background-image:url(spritesmith-main-1.png);background-position:-480px -379px;width:60px;height:60px}.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_bangs_2_black{background-image:url(spritesmith-main-1.png);background-position:-25px -470px;width:60px;height:60px}.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_blond{background-image:url(spritesmith-main-1.png);background-position:-116px -470px;width:60px;height:60px}.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_blue{background-image:url(spritesmith-main-1.png);background-position:-207px -470px;width:60px;height:60px}.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_brown{background-image:url(spritesmith-main-1.png);background-position:-298px -470px;width:60px;height:60px}.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_candycane{background-image:url(spritesmith-main-1.png);background-position:-389px -470px;width:60px;height:60px}.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_candycorn{background-image:url(spritesmith-main-1.png);background-position:-480px -470px;width:60px;height:60px}.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_bangs_2_festive{background-image:url(spritesmith-main-1.png);background-position:-571px -15px;width:60px;height:60px}.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_frost{background-image:url(spritesmith-main-1.png);background-position:-571px -106px;width:60px;height:60px}.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-571px -197px;width:60px;height:60px}.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_green{background-image:url(spritesmith-main-1.png);background-position:-571px -288px;width:60px;height:60px}.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_halloween{background-image:url(spritesmith-main-1.png);background-position:-571px -379px;width:60px;height:60px}.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_holly{background-image:url(spritesmith-main-1.png);background-position:-571px -470px;width:60px;height:60px}.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_bangs_2_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-25px -561px;width:60px;height:60px}.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_midnight{background-image:url(spritesmith-main-1.png);background-position:-116px -561px;width:60px;height:60px}.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue{background-image:url(spritesmith-main-1.png);background-position:-207px -561px;width:60px;height:60px}.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pblue2{background-image:url(spritesmith-main-1.png);background-position:-298px -561px;width:60px;height:60px}.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_peppermint{background-image:url(spritesmith-main-1.png);background-position:-389px -561px;width:60px;height:60px}.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen{background-image:url(spritesmith-main-1.png);background-position:-480px -561px;width:60px;height:60px}.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-571px -561px;width:60px;height:60px}.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_bangs_2_porange{background-image:url(spritesmith-main-1.png);background-position:-662px -15px;width:60px;height:60px}.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_porange2{background-image:url(spritesmith-main-1.png);background-position:-662px -106px;width:60px;height:60px}.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink{background-image:url(spritesmith-main-1.png);background-position:-662px -197px;width:60px;height:60px}.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_bangs_2_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -288px;width:60px;height:60px}.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple{background-image:url(spritesmith-main-1.png);background-position:-662px -379px;width:60px;height:60px}.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_bangs_2_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-662px -470px;width:60px;height:60px}.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_bangs_2_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-662px -561px;width:60px;height:60px}.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_bangs_2_purple{background-image:url(spritesmith-main-1.png);background-position:-25px -652px;width:60px;height:60px}.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow{background-image:url(spritesmith-main-1.png);background-position:-116px -652px;width:60px;height:60px}.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-207px -652px;width:60px;height:60px}.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_rainbow{background-image:url(spritesmith-main-1.png);background-position:-298px -652px;width:60px;height:60px}.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_red{background-image:url(spritesmith-main-1.png);background-position:-389px -652px;width:60px;height:60px}.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_snowy{background-image:url(spritesmith-main-1.png);background-position:-480px -652px;width:60px;height:60px}.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_white{background-image:url(spritesmith-main-1.png);background-position:-571px -652px;width:60px;height:60px}.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_bangs_2_winternight{background-image:url(spritesmith-main-1.png);background-position:-662px -652px;width:60px;height:60px}.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_bangs_2_winterstar{background-image:url(spritesmith-main-1.png);background-position:-753px -15px;width:60px;height:60px}.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_bangs_2_yellow{background-image:url(spritesmith-main-1.png);background-position:-753px -106px;width:60px;height:60px}.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_bangs_2_zombie{background-image:url(spritesmith-main-1.png);background-position:-753px -197px;width:60px;height:60px}.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_TRUred{background-image:url(spritesmith-main-1.png);background-position:-753px -288px;width:60px;height:60px}.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_aurora{background-image:url(spritesmith-main-1.png);background-position:-753px -379px;width:60px;height:60px}.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_black{background-image:url(spritesmith-main-1.png);background-position:-753px -470px;width:60px;height:60px}.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_blond{background-image:url(spritesmith-main-1.png);background-position:-753px -561px;width:60px;height:60px}.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_blue{background-image:url(spritesmith-main-1.png);background-position:-753px -652px;width:60px;height:60px}.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_bangs_3_brown{background-image:url(spritesmith-main-1.png);background-position:-25px -743px;width:60px;height:60px}.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_candycane{background-image:url(spritesmith-main-1.png);background-position:-116px -743px;width:60px;height:60px}.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_candycorn{background-image:url(spritesmith-main-1.png);background-position:-207px -743px;width:60px;height:60px}.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_festive{background-image:url(spritesmith-main-1.png);background-position:-298px -743px;width:60px;height:60px}.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -743px;width:60px;height:60px}.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -743px;width:60px;height:60px}.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_green{background-image:url(spritesmith-main-1.png);background-position:-571px -743px;width:60px;height:60px}.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_halloween{background-image:url(spritesmith-main-1.png);background-position:-662px -743px;width:60px;height:60px}.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -743px;width:60px;height:60px}.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_bangs_3_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-844px -15px;width:60px;height:60px}.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_midnight{background-image:url(spritesmith-main-1.png);background-position:-844px -106px;width:60px;height:60px}.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue{background-image:url(spritesmith-main-1.png);background-position:-844px -197px;width:60px;height:60px}.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_pblue2{background-image:url(spritesmith-main-1.png);background-position:-844px -288px;width:60px;height:60px}.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_peppermint{background-image:url(spritesmith-main-1.png);background-position:-844px -379px;width:60px;height:60px}.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -470px;width:60px;height:60px}.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_bangs_3_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-844px -561px;width:60px;height:60px}.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_bangs_3_porange{background-image:url(spritesmith-main-1.png);background-position:-844px -652px;width:60px;height:60px}.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_bangs_3_porange2{background-image:url(spritesmith-main-1.png);background-position:-844px -743px;width:60px;height:60px}.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink{background-image:url(spritesmith-main-1.png);background-position:-25px -834px;width:60px;height:60px}.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppink2{background-image:url(spritesmith-main-1.png);background-position:-116px -834px;width:60px;height:60px}.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple{background-image:url(spritesmith-main-1.png);background-position:-207px -834px;width:60px;height:60px}.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-298px -834px;width:60px;height:60px}.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-389px -834px;width:60px;height:60px}.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_purple{background-image:url(spritesmith-main-1.png);background-position:-480px -834px;width:60px;height:60px}.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow{background-image:url(spritesmith-main-1.png);background-position:-571px -834px;width:60px;height:60px}.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-662px -834px;width:60px;height:60px}.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_rainbow{background-image:url(spritesmith-main-1.png);background-position:-753px -834px;width:60px;height:60px}.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_bangs_3_red{background-image:url(spritesmith-main-1.png);background-position:-844px -834px;width:60px;height:60px}.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_bangs_3_snowy{background-image:url(spritesmith-main-1.png);background-position:-935px -15px;width:60px;height:60px}.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_bangs_3_white{background-image:url(spritesmith-main-1.png);background-position:-935px -106px;width:60px;height:60px}.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_bangs_3_winternight{background-image:url(spritesmith-main-1.png);background-position:-935px -197px;width:60px;height:60px}.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_bangs_3_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -288px;width:60px;height:60px}.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_bangs_3_yellow{background-image:url(spritesmith-main-1.png);background-position:-935px -379px;width:60px;height:60px}.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_bangs_3_zombie{background-image:url(spritesmith-main-1.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_10_TRUred{background-image:url(spritesmith-main-1.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_10_aurora{background-image:url(spritesmith-main-1.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_10_black{background-image:url(spritesmith-main-1.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_10_blond{background-image:url(spritesmith-main-1.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_10_blue{background-image:url(spritesmith-main-1.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_10_brown{background-image:url(spritesmith-main-1.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_10_candycane{background-image:url(spritesmith-main-1.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_10_candycorn{background-image:url(spritesmith-main-1.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_10_festive{background-image:url(spritesmith-main-1.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_10_frost{background-image:url(spritesmith-main-1.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_10_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_10_green{background-image:url(spritesmith-main-1.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_10_halloween{background-image:url(spritesmith-main-1.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_10_holly{background-image:url(spritesmith-main-1.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_10_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_10_midnight{background-image:url(spritesmith-main-1.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_10_pblue{background-image:url(spritesmith-main-1.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_10_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_10_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_10_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_10_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_10_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_10_porange2{background-image:url(spritesmith-main-1.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_10_ppink{background-image:url(spritesmith-main-1.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_10_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_10_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_10_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_10_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_10_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_10_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_10_red{background-image:url(spritesmith-main-1.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_10_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_10_white{background-image:url(spritesmith-main-1.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_10_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_10_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_10_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_10_zombie{background-image:url(spritesmith-main-1.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_11_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_11_aurora{background-image:url(spritesmith-main-1.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_11_black{background-image:url(spritesmith-main-1.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_11_blond{background-image:url(spritesmith-main-1.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_11_blue{background-image:url(spritesmith-main-1.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_11_brown{background-image:url(spritesmith-main-1.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_11_candycane{background-image:url(spritesmith-main-1.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_11_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_11_festive{background-image:url(spritesmith-main-1.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_11_frost{background-image:url(spritesmith-main-1.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_11_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_11_green{background-image:url(spritesmith-main-1.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_11_halloween{background-image:url(spritesmith-main-1.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_11_holly{background-image:url(spritesmith-main-1.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_11_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_11_midnight{background-image:url(spritesmith-main-1.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pblue{background-image:url(spritesmith-main-1.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pblue2{background-image:url(spritesmith-main-1.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_11_peppermint{background-image:url(spritesmith-main-1.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_11_pgreen{background-image:url(spritesmith-main-1.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_11_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_11_porange{background-image:url(spritesmith-main-1.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_11_porange2{background-image:url(spritesmith-main-1.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_11_ppink{background-image:url(spritesmith-main-1.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_11_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_11_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_11_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_11_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_11_purple{background-image:url(spritesmith-main-1.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_11_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_11_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_11_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_11_red{background-image:url(spritesmith-main-1.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_11_snowy{background-image:url(spritesmith-main-1.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_11_white{background-image:url(spritesmith-main-1.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_11_winternight{background-image:url(spritesmith-main-1.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_11_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_11_yellow{background-image:url(spritesmith-main-1.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_11_zombie{background-image:url(spritesmith-main-1.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_12_TRUred{background-image:url(spritesmith-main-1.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_12_aurora{background-image:url(spritesmith-main-1.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_12_black{background-image:url(spritesmith-main-1.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_12_blond{background-image:url(spritesmith-main-1.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_12_blue{background-image:url(spritesmith-main-1.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_12_brown{background-image:url(spritesmith-main-1.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_12_candycane{background-image:url(spritesmith-main-1.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_12_candycorn{background-image:url(spritesmith-main-1.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_12_festive{background-image:url(spritesmith-main-1.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_12_frost{background-image:url(spritesmith-main-1.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_12_green{background-image:url(spritesmith-main-1.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_12_halloween{background-image:url(spritesmith-main-1.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_12_holly{background-image:url(spritesmith-main-1.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_12_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_12_midnight{background-image:url(spritesmith-main-1.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_12_pblue{background-image:url(spritesmith-main-1.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_12_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_12_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_12_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_12_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_12_porange{background-image:url(spritesmith-main-1.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_12_porange2{background-image:url(spritesmith-main-1.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_12_ppink{background-image:url(spritesmith-main-1.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_12_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_12_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_12_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_12_purple{background-image:url(spritesmith-main-1.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pyellow{background-image:url(spritesmith-main-1.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_12_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_12_rainbow{background-image:url(spritesmith-main-1.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_12_red{background-image:url(spritesmith-main-1.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_12_snowy{background-image:url(spritesmith-main-1.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_12_white{background-image:url(spritesmith-main-1.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_12_winternight{background-image:url(spritesmith-main-1.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_12_winterstar{background-image:url(spritesmith-main-1.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_12_yellow{background-image:url(spritesmith-main-1.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_12_zombie{background-image:url(spritesmith-main-1.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_13_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_13_aurora{background-image:url(spritesmith-main-1.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_13_black{background-image:url(spritesmith-main-1.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_13_blond{background-image:url(spritesmith-main-1.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_13_blue{background-image:url(spritesmith-main-1.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_13_brown{background-image:url(spritesmith-main-1.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_13_candycane{background-image:url(spritesmith-main-1.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_13_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_13_festive{background-image:url(spritesmith-main-1.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_13_frost{background-image:url(spritesmith-main-1.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_13_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_13_green{background-image:url(spritesmith-main-1.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_13_halloween{background-image:url(spritesmith-main-1.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_13_holly{background-image:url(spritesmith-main-1.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_13_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_13_midnight{background-image:url(spritesmith-main-1.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_13_pblue{background-image:url(spritesmith-main-1.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_13_pblue2{background-image:url(spritesmith-main-1.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_13_peppermint{background-image:url(spritesmith-main-1.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pgreen{background-image:url(spritesmith-main-1.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_13_porange{background-image:url(spritesmith-main-1.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_13_porange2{background-image:url(spritesmith-main-1.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppink{background-image:url(spritesmith-main-1.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppink2{background-image:url(spritesmith-main-1.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppurple{background-image:url(spritesmith-main-1.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_13_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_13_purple{background-image:url(spritesmith-main-1.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_13_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_13_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_13_red{background-image:url(spritesmith-main-1.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_13_snowy{background-image:url(spritesmith-main-1.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_13_white{background-image:url(spritesmith-main-1.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_13_winternight{background-image:url(spritesmith-main-1.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_13_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_13_yellow{background-image:url(spritesmith-main-1.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_13_zombie{background-image:url(spritesmith-main-1.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_14_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_14_aurora{background-image:url(spritesmith-main-1.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_14_black{background-image:url(spritesmith-main-1.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_14_blond{background-image:url(spritesmith-main-1.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_14_blue{background-image:url(spritesmith-main-1.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_14_brown{background-image:url(spritesmith-main-1.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_14_candycane{background-image:url(spritesmith-main-1.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_14_candycorn{background-image:url(spritesmith-main-1.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_14_festive{background-image:url(spritesmith-main-1.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_14_frost{background-image:url(spritesmith-main-1.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_14_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_14_green{background-image:url(spritesmith-main-1.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_14_halloween{background-image:url(spritesmith-main-1.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_14_holly{background-image:url(spritesmith-main-1.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_14_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_14_midnight{background-image:url(spritesmith-main-1.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pblue{background-image:url(spritesmith-main-1.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pblue2{background-image:url(spritesmith-main-1.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_14_peppermint{background-image:url(spritesmith-main-1.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pgreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_14_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange{background-image:url(spritesmith-main-1.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_14_porange2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink{background-image:url(spritesmith-main-1.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_14_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_14_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_14_purple{background-image:url(spritesmith-main-1.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_14_pyellow{background-image:url(spritesmith-main-1.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_14_pyellow2{background-image:url(spritesmith-main-1.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_14_rainbow{background-image:url(spritesmith-main-1.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_14_red{background-image:url(spritesmith-main-1.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_14_snowy{background-image:url(spritesmith-main-1.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_14_white{background-image:url(spritesmith-main-1.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_14_winternight{background-image:url(spritesmith-main-1.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_14_winterstar{background-image:url(spritesmith-main-1.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_14_yellow{background-image:url(spritesmith-main-1.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_14_zombie{background-image:url(spritesmith-main-1.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_1_TRUred{background-image:url(spritesmith-main-1.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_1_aurora{background-image:url(spritesmith-main-1.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_1_black{background-image:url(spritesmith-main-1.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_1_blond{background-image:url(spritesmith-main-1.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_1_blue{background-image:url(spritesmith-main-1.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_1_brown{background-image:url(spritesmith-main-1.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_1_candycane{background-image:url(spritesmith-main-1.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_1_candycorn{background-image:url(spritesmith-main-1.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_1_festive{background-image:url(spritesmith-main-1.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_1_frost{background-image:url(spritesmith-main-1.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_1_ghostwhite{background-image:url(spritesmith-main-1.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_1_green{background-image:url(spritesmith-main-1.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_1_halloween{background-image:url(spritesmith-main-1.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_1_holly{background-image:url(spritesmith-main-1.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_1_hollygreen{background-image:url(spritesmith-main-1.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_1_midnight{background-image:url(spritesmith-main-1.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue{background-image:url(spritesmith-main-1.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pblue2{background-image:url(spritesmith-main-1.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_1_peppermint{background-image:url(spritesmith-main-1.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen{background-image:url(spritesmith-main-1.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_1_pgreen2{background-image:url(spritesmith-main-1.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange{background-image:url(spritesmith-main-1.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_base_1_porange2{background-image:url(spritesmith-main-1.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_base_1_ppink{background-image:url(spritesmith-main-1.png);background-position:-1663px -15px;width:60px;height:60px}.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_base_1_ppink2{background-image:url(spritesmith-main-1.png);background-position:-1663px -106px;width:60px;height:60px}.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_base_1_ppurple{background-image:url(spritesmith-main-1.png);background-position:-1663px -197px;width:60px;height:60px}.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_base_1_ppurple2{background-image:url(spritesmith-main-1.png);background-position:-1663px -288px;width:60px;height:60px}.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_base_1_pumpkin{background-image:url(spritesmith-main-1.png);background-position:-1663px -379px;width:60px;height:60px}.hair_base_1_purple{background-image:url(spritesmith-main-2.png);background-position:-91px 0;width:90px;height:90px}.customize-option.hair_base_1_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -15px;width:60px;height:60px}.hair_base_1_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.hair_base_1_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -1107px;width:60px;height:60px}.hair_base_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:0 -91px;width:90px;height:90px}.customize-option.hair_base_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-25px -106px;width:60px;height:60px}.hair_base_1_rainbow{background-image:url(spritesmith-main-2.png);background-position:-91px -91px;width:90px;height:90px}.customize-option.hair_base_1_rainbow{background-image:url(spritesmith-main-2.png);background-position:-116px -106px;width:60px;height:60px}.hair_base_1_red{background-image:url(spritesmith-main-2.png);background-position:-182px 0;width:90px;height:90px}.customize-option.hair_base_1_red{background-image:url(spritesmith-main-2.png);background-position:-207px -15px;width:60px;height:60px}.hair_base_1_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -91px;width:90px;height:90px}.customize-option.hair_base_1_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -106px;width:60px;height:60px}.hair_base_1_white{background-image:url(spritesmith-main-2.png);background-position:0 -182px;width:90px;height:90px}.customize-option.hair_base_1_white{background-image:url(spritesmith-main-2.png);background-position:-25px -197px;width:60px;height:60px}.hair_base_1_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -182px;width:90px;height:90px}.customize-option.hair_base_1_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -197px;width:60px;height:60px}.hair_base_1_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -182px;width:90px;height:90px}.customize-option.hair_base_1_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -197px;width:60px;height:60px}.hair_base_1_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px 0;width:90px;height:90px}.customize-option.hair_base_1_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -15px;width:60px;height:60px}.hair_base_1_zombie{background-image:url(spritesmith-main-2.png);background-position:-273px -91px;width:90px;height:90px}.customize-option.hair_base_1_zombie{background-image:url(spritesmith-main-2.png);background-position:-298px -106px;width:60px;height:60px}.hair_base_2_TRUred{background-image:url(spritesmith-main-2.png);background-position:-273px -182px;width:90px;height:90px}.customize-option.hair_base_2_TRUred{background-image:url(spritesmith-main-2.png);background-position:-298px -197px;width:60px;height:60px}.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -273px;width:90px;height:90px}.customize-option.hair_base_2_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -288px;width:60px;height:60px}.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-91px -273px;width:90px;height:90px}.customize-option.hair_base_2_black{background-image:url(spritesmith-main-2.png);background-position:-116px -288px;width:60px;height:60px}.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -273px;width:90px;height:90px}.customize-option.hair_base_2_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -288px;width:60px;height:60px}.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -273px;width:90px;height:90px}.customize-option.hair_base_2_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -288px;width:60px;height:60px}.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-364px 0;width:90px;height:90px}.customize-option.hair_base_2_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -15px;width:60px;height:60px}.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-364px -91px;width:90px;height:90px}.customize-option.hair_base_2_candycane{background-image:url(spritesmith-main-2.png);background-position:-389px -106px;width:60px;height:60px}.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-364px -182px;width:90px;height:90px}.customize-option.hair_base_2_candycorn{background-image:url(spritesmith-main-2.png);background-position:-389px -197px;width:60px;height:60px}.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -273px;width:90px;height:90px}.customize-option.hair_base_2_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -288px;width:60px;height:60px}.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_base_2_frost{background-image:url(spritesmith-main-2.png);background-position:-25px -379px;width:60px;height:60px}.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_base_2_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-116px -379px;width:60px;height:60px}.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_base_2_green{background-image:url(spritesmith-main-2.png);background-position:-207px -379px;width:60px;height:60px}.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_base_2_halloween{background-image:url(spritesmith-main-2.png);background-position:-298px -379px;width:60px;height:60px}.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.hair_base_2_holly{background-image:url(spritesmith-main-2.png);background-position:-389px -379px;width:60px;height:60px}.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-455px 0;width:90px;height:90px}.customize-option.hair_base_2_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-480px -15px;width:60px;height:60px}.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-455px -91px;width:90px;height:90px}.customize-option.hair_base_2_midnight{background-image:url(spritesmith-main-2.png);background-position:-480px -106px;width:60px;height:60px}.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-455px -182px;width:90px;height:90px}.customize-option.hair_base_2_pblue{background-image:url(spritesmith-main-2.png);background-position:-480px -197px;width:60px;height:60px}.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-455px -273px;width:90px;height:90px}.customize-option.hair_base_2_pblue2{background-image:url(spritesmith-main-2.png);background-position:-480px -288px;width:60px;height:60px}.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.hair_base_2_peppermint{background-image:url(spritesmith-main-2.png);background-position:-480px -379px;width:60px;height:60px}.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:0 -455px;width:90px;height:90px}.customize-option.hair_base_2_pgreen{background-image:url(spritesmith-main-2.png);background-position:-25px -470px;width:60px;height:60px}.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.hair_base_2_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-116px -470px;width:60px;height:60px}.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.hair_base_2_porange{background-image:url(spritesmith-main-2.png);background-position:-207px -470px;width:60px;height:60px}.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.hair_base_2_porange2{background-image:url(spritesmith-main-2.png);background-position:-298px -470px;width:60px;height:60px}.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.hair_base_2_ppink{background-image:url(spritesmith-main-2.png);background-position:-389px -470px;width:60px;height:60px}.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.hair_base_2_ppink2{background-image:url(spritesmith-main-2.png);background-position:-480px -470px;width:60px;height:60px}.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-546px 0;width:90px;height:90px}.customize-option.hair_base_2_ppurple{background-image:url(spritesmith-main-2.png);background-position:-571px -15px;width:60px;height:60px}.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-546px -91px;width:90px;height:90px}.customize-option.hair_base_2_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-571px -106px;width:60px;height:60px}.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-546px -182px;width:90px;height:90px}.customize-option.hair_base_2_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-571px -197px;width:60px;height:60px}.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -273px;width:90px;height:90px}.customize-option.hair_base_2_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -288px;width:60px;height:60px}.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-546px -364px;width:90px;height:90px}.customize-option.hair_base_2_pyellow{background-image:url(spritesmith-main-2.png);background-position:-571px -379px;width:60px;height:60px}.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-546px -455px;width:90px;height:90px}.customize-option.hair_base_2_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-571px -470px;width:60px;height:60px}.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:0 -546px;width:90px;height:90px}.customize-option.hair_base_2_rainbow{background-image:url(spritesmith-main-2.png);background-position:-25px -561px;width:60px;height:60px}.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.hair_base_2_red{background-image:url(spritesmith-main-2.png);background-position:-116px -561px;width:60px;height:60px}.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.hair_base_2_snowy{background-image:url(spritesmith-main-2.png);background-position:-207px -561px;width:60px;height:60px}.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.hair_base_2_white{background-image:url(spritesmith-main-2.png);background-position:-298px -561px;width:60px;height:60px}.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.hair_base_2_winternight{background-image:url(spritesmith-main-2.png);background-position:-389px -561px;width:60px;height:60px}.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.hair_base_2_winterstar{background-image:url(spritesmith-main-2.png);background-position:-480px -561px;width:60px;height:60px}.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.hair_base_2_yellow{background-image:url(spritesmith-main-2.png);background-position:-571px -561px;width:60px;height:60px}.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-637px 0;width:90px;height:90px}.customize-option.hair_base_2_zombie{background-image:url(spritesmith-main-2.png);background-position:-662px -15px;width:60px;height:60px}.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-637px -91px;width:90px;height:90px}.customize-option.hair_base_3_TRUred{background-image:url(spritesmith-main-2.png);background-position:-662px -106px;width:60px;height:60px}.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-637px -182px;width:90px;height:90px}.customize-option.hair_base_3_aurora{background-image:url(spritesmith-main-2.png);background-position:-662px -197px;width:60px;height:60px}.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-637px -273px;width:90px;height:90px}.customize-option.hair_base_3_black{background-image:url(spritesmith-main-2.png);background-position:-662px -288px;width:60px;height:60px}.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-637px -364px;width:90px;height:90px}.customize-option.hair_base_3_blond{background-image:url(spritesmith-main-2.png);background-position:-662px -379px;width:60px;height:60px}.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-637px -455px;width:90px;height:90px}.customize-option.hair_base_3_blue{background-image:url(spritesmith-main-2.png);background-position:-662px -470px;width:60px;height:60px}.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-637px -546px;width:90px;height:90px}.customize-option.hair_base_3_brown{background-image:url(spritesmith-main-2.png);background-position:-662px -561px;width:60px;height:60px}.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:0 -637px;width:90px;height:90px}.customize-option.hair_base_3_candycane{background-image:url(spritesmith-main-2.png);background-position:-25px -652px;width:60px;height:60px}.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.hair_base_3_candycorn{background-image:url(spritesmith-main-2.png);background-position:-116px -652px;width:60px;height:60px}.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.hair_base_3_festive{background-image:url(spritesmith-main-2.png);background-position:-207px -652px;width:60px;height:60px}.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.hair_base_3_frost{background-image:url(spritesmith-main-2.png);background-position:-298px -652px;width:60px;height:60px}.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.hair_base_3_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-389px -652px;width:60px;height:60px}.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.hair_base_3_green{background-image:url(spritesmith-main-2.png);background-position:-480px -652px;width:60px;height:60px}.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.hair_base_3_halloween{background-image:url(spritesmith-main-2.png);background-position:-571px -652px;width:60px;height:60px}.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.hair_base_3_holly{background-image:url(spritesmith-main-2.png);background-position:-662px -652px;width:60px;height:60px}.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-728px 0;width:90px;height:90px}.customize-option.hair_base_3_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-753px -15px;width:60px;height:60px}.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-728px -91px;width:90px;height:90px}.customize-option.hair_base_3_midnight{background-image:url(spritesmith-main-2.png);background-position:-753px -106px;width:60px;height:60px}.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-728px -182px;width:90px;height:90px}.customize-option.hair_base_3_pblue{background-image:url(spritesmith-main-2.png);background-position:-753px -197px;width:60px;height:60px}.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-728px -273px;width:90px;height:90px}.customize-option.hair_base_3_pblue2{background-image:url(spritesmith-main-2.png);background-position:-753px -288px;width:60px;height:60px}.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-728px -364px;width:90px;height:90px}.customize-option.hair_base_3_peppermint{background-image:url(spritesmith-main-2.png);background-position:-753px -379px;width:60px;height:60px}.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-728px -455px;width:90px;height:90px}.customize-option.hair_base_3_pgreen{background-image:url(spritesmith-main-2.png);background-position:-753px -470px;width:60px;height:60px}.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-728px -546px;width:90px;height:90px}.customize-option.hair_base_3_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-753px -561px;width:60px;height:60px}.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-728px -637px;width:90px;height:90px}.customize-option.hair_base_3_porange{background-image:url(spritesmith-main-2.png);background-position:-753px -652px;width:60px;height:60px}.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -728px;width:90px;height:90px}.customize-option.hair_base_3_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -743px;width:60px;height:60px}.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppink{background-image:url(spritesmith-main-2.png);background-position:-116px -743px;width:60px;height:60px}.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppink2{background-image:url(spritesmith-main-2.png);background-position:-207px -743px;width:60px;height:60px}.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppurple{background-image:url(spritesmith-main-2.png);background-position:-298px -743px;width:60px;height:60px}.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.hair_base_3_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -743px;width:60px;height:60px}.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.hair_base_3_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -743px;width:60px;height:60px}.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.hair_base_3_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -743px;width:60px;height:60px}.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.hair_base_3_pyellow{background-image:url(spritesmith-main-2.png);background-position:-662px -743px;width:60px;height:60px}.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.hair_base_3_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -743px;width:60px;height:60px}.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-819px 0;width:90px;height:90px}.customize-option.hair_base_3_rainbow{background-image:url(spritesmith-main-2.png);background-position:-844px -15px;width:60px;height:60px}.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-819px -91px;width:90px;height:90px}.customize-option.hair_base_3_red{background-image:url(spritesmith-main-2.png);background-position:-844px -106px;width:60px;height:60px}.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-819px -182px;width:90px;height:90px}.customize-option.hair_base_3_snowy{background-image:url(spritesmith-main-2.png);background-position:-844px -197px;width:60px;height:60px}.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-819px -273px;width:90px;height:90px}.customize-option.hair_base_3_white{background-image:url(spritesmith-main-2.png);background-position:-844px -288px;width:60px;height:60px}.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-819px -364px;width:90px;height:90px}.customize-option.hair_base_3_winternight{background-image:url(spritesmith-main-2.png);background-position:-844px -379px;width:60px;height:60px}.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -455px;width:90px;height:90px}.customize-option.hair_base_3_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -470px;width:60px;height:60px}.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-819px -546px;width:90px;height:90px}.customize-option.hair_base_3_yellow{background-image:url(spritesmith-main-2.png);background-position:-844px -561px;width:60px;height:60px}.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-819px -637px;width:90px;height:90px}.customize-option.hair_base_3_zombie{background-image:url(spritesmith-main-2.png);background-position:-844px -652px;width:60px;height:60px}.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-819px -728px;width:90px;height:90px}.customize-option.hair_base_4_TRUred{background-image:url(spritesmith-main-2.png);background-position:-844px -743px;width:60px;height:60px}.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:0 -819px;width:90px;height:90px}.customize-option.hair_base_4_aurora{background-image:url(spritesmith-main-2.png);background-position:-25px -834px;width:60px;height:60px}.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.hair_base_4_black{background-image:url(spritesmith-main-2.png);background-position:-116px -834px;width:60px;height:60px}.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.hair_base_4_blond{background-image:url(spritesmith-main-2.png);background-position:-207px -834px;width:60px;height:60px}.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.hair_base_4_blue{background-image:url(spritesmith-main-2.png);background-position:-298px -834px;width:60px;height:60px}.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.hair_base_4_brown{background-image:url(spritesmith-main-2.png);background-position:-389px -834px;width:60px;height:60px}.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.hair_base_4_candycane{background-image:url(spritesmith-main-2.png);background-position:-480px -834px;width:60px;height:60px}.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.hair_base_4_candycorn{background-image:url(spritesmith-main-2.png);background-position:-571px -834px;width:60px;height:60px}.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.hair_base_4_festive{background-image:url(spritesmith-main-2.png);background-position:-662px -834px;width:60px;height:60px}.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.hair_base_4_frost{background-image:url(spritesmith-main-2.png);background-position:-753px -834px;width:60px;height:60px}.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.hair_base_4_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-844px -834px;width:60px;height:60px}.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-910px 0;width:90px;height:90px}.customize-option.hair_base_4_green{background-image:url(spritesmith-main-2.png);background-position:-935px -15px;width:60px;height:60px}.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-910px -91px;width:90px;height:90px}.customize-option.hair_base_4_halloween{background-image:url(spritesmith-main-2.png);background-position:-935px -106px;width:60px;height:60px}.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-910px -182px;width:90px;height:90px}.customize-option.hair_base_4_holly{background-image:url(spritesmith-main-2.png);background-position:-935px -197px;width:60px;height:60px}.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -273px;width:90px;height:90px}.customize-option.hair_base_4_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -288px;width:60px;height:60px}.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-910px -364px;width:90px;height:90px}.customize-option.hair_base_4_midnight{background-image:url(spritesmith-main-2.png);background-position:-935px -379px;width:60px;height:60px}.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-910px -455px;width:90px;height:90px}.customize-option.hair_base_4_pblue{background-image:url(spritesmith-main-2.png);background-position:-935px -470px;width:60px;height:60px}.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-910px -546px;width:90px;height:90px}.customize-option.hair_base_4_pblue2{background-image:url(spritesmith-main-2.png);background-position:-935px -561px;width:60px;height:60px}.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-910px -637px;width:90px;height:90px}.customize-option.hair_base_4_peppermint{background-image:url(spritesmith-main-2.png);background-position:-935px -652px;width:60px;height:60px}.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-910px -728px;width:90px;height:90px}.customize-option.hair_base_4_pgreen{background-image:url(spritesmith-main-2.png);background-position:-935px -743px;width:60px;height:60px}.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-910px -819px;width:90px;height:90px}.customize-option.hair_base_4_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-935px -834px;width:60px;height:60px}.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:0 -910px;width:90px;height:90px}.customize-option.hair_base_4_porange{background-image:url(spritesmith-main-2.png);background-position:-25px -925px;width:60px;height:60px}.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.hair_base_4_porange2{background-image:url(spritesmith-main-2.png);background-position:-116px -925px;width:60px;height:60px}.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppink{background-image:url(spritesmith-main-2.png);background-position:-207px -925px;width:60px;height:60px}.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppink2{background-image:url(spritesmith-main-2.png);background-position:-298px -925px;width:60px;height:60px}.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppurple{background-image:url(spritesmith-main-2.png);background-position:-389px -925px;width:60px;height:60px}.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.hair_base_4_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-480px -925px;width:60px;height:60px}.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.hair_base_4_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-571px -925px;width:60px;height:60px}.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.hair_base_4_purple{background-image:url(spritesmith-main-2.png);background-position:-662px -925px;width:60px;height:60px}.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.hair_base_4_pyellow{background-image:url(spritesmith-main-2.png);background-position:-753px -925px;width:60px;height:60px}.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.hair_base_4_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-844px -925px;width:60px;height:60px}.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.hair_base_4_rainbow{background-image:url(spritesmith-main-2.png);background-position:-935px -925px;width:60px;height:60px}.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-1001px 0;width:90px;height:90px}.customize-option.hair_base_4_red{background-image:url(spritesmith-main-2.png);background-position:-1026px -15px;width:60px;height:60px}.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-1001px -91px;width:90px;height:90px}.customize-option.hair_base_4_snowy{background-image:url(spritesmith-main-2.png);background-position:-1026px -106px;width:60px;height:60px}.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-1001px -182px;width:90px;height:90px}.customize-option.hair_base_4_white{background-image:url(spritesmith-main-2.png);background-position:-1026px -197px;width:60px;height:60px}.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-1001px -273px;width:90px;height:90px}.customize-option.hair_base_4_winternight{background-image:url(spritesmith-main-2.png);background-position:-1026px -288px;width:60px;height:60px}.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1001px -364px;width:90px;height:90px}.customize-option.hair_base_4_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1026px -379px;width:60px;height:60px}.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-1001px -455px;width:90px;height:90px}.customize-option.hair_base_4_yellow{background-image:url(spritesmith-main-2.png);background-position:-1026px -470px;width:60px;height:60px}.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -546px;width:90px;height:90px}.customize-option.hair_base_4_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -561px;width:60px;height:60px}.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1001px -637px;width:90px;height:90px}.customize-option.hair_base_5_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1026px -652px;width:60px;height:60px}.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-1001px -728px;width:90px;height:90px}.customize-option.hair_base_5_aurora{background-image:url(spritesmith-main-2.png);background-position:-1026px -743px;width:60px;height:60px}.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-1001px -819px;width:90px;height:90px}.customize-option.hair_base_5_black{background-image:url(spritesmith-main-2.png);background-position:-1026px -834px;width:60px;height:60px}.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-1001px -910px;width:90px;height:90px}.customize-option.hair_base_5_blond{background-image:url(spritesmith-main-2.png);background-position:-1026px -925px;width:60px;height:60px}.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.hair_base_5_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -1016px;width:60px;height:60px}.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.hair_base_5_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -1016px;width:60px;height:60px}.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.hair_base_5_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -1016px;width:60px;height:60px}.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.hair_base_5_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -1016px;width:60px;height:60px}.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.hair_base_5_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -1016px;width:60px;height:60px}.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.hair_base_5_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -1016px;width:60px;height:60px}.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.hair_base_5_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -1016px;width:60px;height:60px}.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.hair_base_5_green{background-image:url(spritesmith-main-2.png);background-position:-662px -1016px;width:60px;height:60px}.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.hair_base_5_halloween{background-image:url(spritesmith-main-2.png);background-position:-753px -1016px;width:60px;height:60px}.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.hair_base_5_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -1016px;width:60px;height:60px}.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.hair_base_5_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -1016px;width:60px;height:60px}.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.hair_base_5_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -1016px;width:60px;height:60px}.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1092px 0;width:90px;height:90px}.customize-option.hair_base_5_pblue{background-image:url(spritesmith-main-2.png);background-position:-1117px -15px;width:60px;height:60px}.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1092px -91px;width:90px;height:90px}.customize-option.hair_base_5_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1117px -106px;width:60px;height:60px}.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1092px -182px;width:90px;height:90px}.customize-option.hair_base_5_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1117px -197px;width:60px;height:60px}.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1092px -273px;width:90px;height:90px}.customize-option.hair_base_5_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1117px -288px;width:60px;height:60px}.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1092px -364px;width:90px;height:90px}.customize-option.hair_base_5_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1117px -379px;width:60px;height:60px}.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1092px -455px;width:90px;height:90px}.customize-option.hair_base_5_porange{background-image:url(spritesmith-main-2.png);background-position:-1117px -470px;width:60px;height:60px}.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1092px -546px;width:90px;height:90px}.customize-option.hair_base_5_porange2{background-image:url(spritesmith-main-2.png);background-position:-1117px -561px;width:60px;height:60px}.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-1092px -637px;width:90px;height:90px}.customize-option.hair_base_5_ppink{background-image:url(spritesmith-main-2.png);background-position:-1117px -652px;width:60px;height:60px}.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1092px -728px;width:90px;height:90px}.customize-option.hair_base_5_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1117px -743px;width:60px;height:60px}.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1092px -819px;width:90px;height:90px}.customize-option.hair_base_5_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1117px -834px;width:60px;height:60px}.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1092px -910px;width:90px;height:90px}.customize-option.hair_base_5_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1117px -925px;width:60px;height:60px}.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1092px -1001px;width:90px;height:90px}.customize-option.hair_base_5_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1117px -1016px;width:60px;height:60px}.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.hair_base_5_purple{background-image:url(spritesmith-main-2.png);background-position:-25px -1107px;width:60px;height:60px}.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.hair_base_5_pyellow{background-image:url(spritesmith-main-2.png);background-position:-116px -1107px;width:60px;height:60px}.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.hair_base_5_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-207px -1107px;width:60px;height:60px}.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.hair_base_5_rainbow{background-image:url(spritesmith-main-2.png);background-position:-298px -1107px;width:60px;height:60px}.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.hair_base_5_red{background-image:url(spritesmith-main-2.png);background-position:-389px -1107px;width:60px;height:60px}.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.hair_base_5_snowy{background-image:url(spritesmith-main-2.png);background-position:-480px -1107px;width:60px;height:60px}.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.hair_base_5_white{background-image:url(spritesmith-main-2.png);background-position:-571px -1107px;width:60px;height:60px}.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.hair_base_5_winternight{background-image:url(spritesmith-main-2.png);background-position:-662px -1107px;width:60px;height:60px}.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:0 0;width:90px;height:90px}.customize-option.hair_base_5_winterstar{background-image:url(spritesmith-main-2.png);background-position:-25px -15px;width:60px;height:60px}.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.hair_base_5_yellow{background-image:url(spritesmith-main-2.png);background-position:-844px -1107px;width:60px;height:60px}.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.hair_base_5_zombie{background-image:url(spritesmith-main-2.png);background-position:-935px -1107px;width:60px;height:60px}.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.hair_base_6_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1026px -1107px;width:60px;height:60px}.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.hair_base_6_aurora{background-image:url(spritesmith-main-2.png);background-position:-1117px -1107px;width:60px;height:60px}.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1183px 0;width:90px;height:90px}.customize-option.hair_base_6_black{background-image:url(spritesmith-main-2.png);background-position:-1208px -15px;width:60px;height:60px}.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1183px -91px;width:90px;height:90px}.customize-option.hair_base_6_blond{background-image:url(spritesmith-main-2.png);background-position:-1208px -106px;width:60px;height:60px}.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1183px -182px;width:90px;height:90px}.customize-option.hair_base_6_blue{background-image:url(spritesmith-main-2.png);background-position:-1208px -197px;width:60px;height:60px}.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1183px -273px;width:90px;height:90px}.customize-option.hair_base_6_brown{background-image:url(spritesmith-main-2.png);background-position:-1208px -288px;width:60px;height:60px}.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1183px -364px;width:90px;height:90px}.customize-option.hair_base_6_candycane{background-image:url(spritesmith-main-2.png);background-position:-1208px -379px;width:60px;height:60px}.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1183px -455px;width:90px;height:90px}.customize-option.hair_base_6_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1208px -470px;width:60px;height:60px}.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -546px;width:90px;height:90px}.customize-option.hair_base_6_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -561px;width:60px;height:60px}.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-1183px -637px;width:90px;height:90px}.customize-option.hair_base_6_frost{background-image:url(spritesmith-main-2.png);background-position:-1208px -652px;width:60px;height:60px}.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1183px -728px;width:90px;height:90px}.customize-option.hair_base_6_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1208px -743px;width:60px;height:60px}.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-1183px -819px;width:90px;height:90px}.customize-option.hair_base_6_green{background-image:url(spritesmith-main-2.png);background-position:-1208px -834px;width:60px;height:60px}.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1183px -910px;width:90px;height:90px}.customize-option.hair_base_6_halloween{background-image:url(spritesmith-main-2.png);background-position:-1208px -925px;width:60px;height:60px}.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1183px -1001px;width:90px;height:90px}.customize-option.hair_base_6_holly{background-image:url(spritesmith-main-2.png);background-position:-1208px -1016px;width:60px;height:60px}.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1183px -1092px;width:90px;height:90px}.customize-option.hair_base_6_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1208px -1107px;width:60px;height:60px}.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:0 -1183px;width:90px;height:90px}.customize-option.hair_base_6_midnight{background-image:url(spritesmith-main-2.png);background-position:-25px -1198px;width:60px;height:60px}.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-91px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pblue{background-image:url(spritesmith-main-2.png);background-position:-116px -1198px;width:60px;height:60px}.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-182px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pblue2{background-image:url(spritesmith-main-2.png);background-position:-207px -1198px;width:60px;height:60px}.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-273px -1183px;width:90px;height:90px}.customize-option.hair_base_6_peppermint{background-image:url(spritesmith-main-2.png);background-position:-298px -1198px;width:60px;height:60px}.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-364px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pgreen{background-image:url(spritesmith-main-2.png);background-position:-389px -1198px;width:60px;height:60px}.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-455px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-480px -1198px;width:60px;height:60px}.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-546px -1183px;width:90px;height:90px}.customize-option.hair_base_6_porange{background-image:url(spritesmith-main-2.png);background-position:-571px -1198px;width:60px;height:60px}.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-637px -1183px;width:90px;height:90px}.customize-option.hair_base_6_porange2{background-image:url(spritesmith-main-2.png);background-position:-662px -1198px;width:60px;height:60px}.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-728px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppink{background-image:url(spritesmith-main-2.png);background-position:-753px -1198px;width:60px;height:60px}.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-819px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppink2{background-image:url(spritesmith-main-2.png);background-position:-844px -1198px;width:60px;height:60px}.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-910px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppurple{background-image:url(spritesmith-main-2.png);background-position:-935px -1198px;width:60px;height:60px}.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1001px -1183px;width:90px;height:90px}.customize-option.hair_base_6_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1026px -1198px;width:60px;height:60px}.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1092px -1183px;width:90px;height:90px}.customize-option.hair_base_6_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1117px -1198px;width:60px;height:60px}.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1183px -1183px;width:90px;height:90px}.customize-option.hair_base_6_purple{background-image:url(spritesmith-main-2.png);background-position:-1208px -1198px;width:60px;height:60px}.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1274px 0;width:90px;height:90px}.customize-option.hair_base_6_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -15px;width:60px;height:60px}.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1274px -91px;width:90px;height:90px}.customize-option.hair_base_6_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1299px -106px;width:60px;height:60px}.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1274px -182px;width:90px;height:90px}.customize-option.hair_base_6_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1299px -197px;width:60px;height:60px}.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1274px -273px;width:90px;height:90px}.customize-option.hair_base_6_red{background-image:url(spritesmith-main-2.png);background-position:-1299px -288px;width:60px;height:60px}.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1274px -364px;width:90px;height:90px}.customize-option.hair_base_6_snowy{background-image:url(spritesmith-main-2.png);background-position:-1299px -379px;width:60px;height:60px}.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-1274px -455px;width:90px;height:90px}.customize-option.hair_base_6_white{background-image:url(spritesmith-main-2.png);background-position:-1299px -470px;width:60px;height:60px}.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-1274px -546px;width:90px;height:90px}.customize-option.hair_base_6_winternight{background-image:url(spritesmith-main-2.png);background-position:-1299px -561px;width:60px;height:60px}.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1274px -637px;width:90px;height:90px}.customize-option.hair_base_6_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1299px -652px;width:60px;height:60px}.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-1274px -728px;width:90px;height:90px}.customize-option.hair_base_6_yellow{background-image:url(spritesmith-main-2.png);background-position:-1299px -743px;width:60px;height:60px}.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1274px -819px;width:90px;height:90px}.customize-option.hair_base_6_zombie{background-image:url(spritesmith-main-2.png);background-position:-1299px -834px;width:60px;height:60px}.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1274px -910px;width:90px;height:90px}.customize-option.hair_base_7_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1299px -925px;width:60px;height:60px}.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1274px -1001px;width:90px;height:90px}.customize-option.hair_base_7_aurora{background-image:url(spritesmith-main-2.png);background-position:-1299px -1016px;width:60px;height:60px}.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1274px -1092px;width:90px;height:90px}.customize-option.hair_base_7_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -1107px;width:60px;height:60px}.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1274px -1183px;width:90px;height:90px}.customize-option.hair_base_7_blond{background-image:url(spritesmith-main-2.png);background-position:-1299px -1198px;width:60px;height:60px}.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:0 -1274px;width:90px;height:90px}.customize-option.hair_base_7_blue{background-image:url(spritesmith-main-2.png);background-position:-25px -1289px;width:60px;height:60px}.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-91px -1274px;width:90px;height:90px}.customize-option.hair_base_7_brown{background-image:url(spritesmith-main-2.png);background-position:-116px -1289px;width:60px;height:60px}.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_base_7_candycane{background-image:url(spritesmith-main-2.png);background-position:-207px -1289px;width:60px;height:60px}.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_base_7_candycorn{background-image:url(spritesmith-main-2.png);background-position:-298px -1289px;width:60px;height:60px}.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-364px -1274px;width:90px;height:90px}.customize-option.hair_base_7_festive{background-image:url(spritesmith-main-2.png);background-position:-389px -1289px;width:60px;height:60px}.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_base_7_frost{background-image:url(spritesmith-main-2.png);background-position:-480px -1289px;width:60px;height:60px}.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_base_7_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-571px -1289px;width:60px;height:60px}.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-637px -1274px;width:90px;height:90px}.customize-option.hair_base_7_green{background-image:url(spritesmith-main-2.png);background-position:-662px -1289px;width:60px;height:60px}.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-728px -1274px;width:90px;height:90px}.customize-option.hair_base_7_halloween{background-image:url(spritesmith-main-2.png);background-position:-753px -1289px;width:60px;height:60px}.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-819px -1274px;width:90px;height:90px}.customize-option.hair_base_7_holly{background-image:url(spritesmith-main-2.png);background-position:-844px -1289px;width:60px;height:60px}.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_base_7_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-935px -1289px;width:60px;height:60px}.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_base_7_midnight{background-image:url(spritesmith-main-2.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1092px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue{background-image:url(spritesmith-main-2.png);background-position:-1117px -1289px;width:60px;height:60px}.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_base_7_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1274px -1274px;width:90px;height:90px}.customize-option.hair_base_7_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1299px -1289px;width:60px;height:60px}.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1365px 0;width:90px;height:90px}.customize-option.hair_base_7_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1390px -15px;width:60px;height:60px}.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1365px -91px;width:90px;height:90px}.customize-option.hair_base_7_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1390px -106px;width:60px;height:60px}.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1365px -182px;width:90px;height:90px}.customize-option.hair_base_7_porange{background-image:url(spritesmith-main-2.png);background-position:-1390px -197px;width:60px;height:60px}.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1365px -273px;width:90px;height:90px}.customize-option.hair_base_7_porange2{background-image:url(spritesmith-main-2.png);background-position:-1390px -288px;width:60px;height:60px}.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-1365px -364px;width:90px;height:90px}.customize-option.hair_base_7_ppink{background-image:url(spritesmith-main-2.png);background-position:-1390px -379px;width:60px;height:60px}.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1365px -455px;width:90px;height:90px}.customize-option.hair_base_7_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1390px -470px;width:60px;height:60px}.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1365px -546px;width:90px;height:90px}.customize-option.hair_base_7_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1390px -561px;width:60px;height:60px}.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1365px -637px;width:90px;height:90px}.customize-option.hair_base_7_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1390px -652px;width:60px;height:60px}.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1365px -728px;width:90px;height:90px}.customize-option.hair_base_7_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-1390px -743px;width:60px;height:60px}.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1365px -819px;width:90px;height:90px}.customize-option.hair_base_7_purple{background-image:url(spritesmith-main-2.png);background-position:-1390px -834px;width:60px;height:60px}.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -910px;width:90px;height:90px}.customize-option.hair_base_7_pyellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -925px;width:60px;height:60px}.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1365px -1001px;width:90px;height:90px}.customize-option.hair_base_7_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1390px -1016px;width:60px;height:60px}.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1092px;width:90px;height:90px}.customize-option.hair_base_7_rainbow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1107px;width:60px;height:60px}.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1365px -1183px;width:90px;height:90px}.customize-option.hair_base_7_red{background-image:url(spritesmith-main-2.png);background-position:-1390px -1198px;width:60px;height:60px}.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1365px -1274px;width:90px;height:90px}.customize-option.hair_base_7_snowy{background-image:url(spritesmith-main-2.png);background-position:-1390px -1289px;width:60px;height:60px}.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:0 -1365px;width:90px;height:90px}.customize-option.hair_base_7_white{background-image:url(spritesmith-main-2.png);background-position:-25px -1380px;width:60px;height:60px}.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-91px -1365px;width:90px;height:90px}.customize-option.hair_base_7_winternight{background-image:url(spritesmith-main-2.png);background-position:-116px -1380px;width:60px;height:60px}.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-182px -1365px;width:90px;height:90px}.customize-option.hair_base_7_winterstar{background-image:url(spritesmith-main-2.png);background-position:-207px -1380px;width:60px;height:60px}.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-273px -1365px;width:90px;height:90px}.customize-option.hair_base_7_yellow{background-image:url(spritesmith-main-2.png);background-position:-298px -1380px;width:60px;height:60px}.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-364px -1365px;width:90px;height:90px}.customize-option.hair_base_7_zombie{background-image:url(spritesmith-main-2.png);background-position:-389px -1380px;width:60px;height:60px}.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-455px -1365px;width:90px;height:90px}.customize-option.hair_base_8_TRUred{background-image:url(spritesmith-main-2.png);background-position:-480px -1380px;width:60px;height:60px}.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-546px -1365px;width:90px;height:90px}.customize-option.hair_base_8_aurora{background-image:url(spritesmith-main-2.png);background-position:-571px -1380px;width:60px;height:60px}.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-637px -1365px;width:90px;height:90px}.customize-option.hair_base_8_black{background-image:url(spritesmith-main-2.png);background-position:-662px -1380px;width:60px;height:60px}.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-728px -1365px;width:90px;height:90px}.customize-option.hair_base_8_blond{background-image:url(spritesmith-main-2.png);background-position:-753px -1380px;width:60px;height:60px}.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-819px -1365px;width:90px;height:90px}.customize-option.hair_base_8_blue{background-image:url(spritesmith-main-2.png);background-position:-844px -1380px;width:60px;height:60px}.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-910px -1365px;width:90px;height:90px}.customize-option.hair_base_8_brown{background-image:url(spritesmith-main-2.png);background-position:-935px -1380px;width:60px;height:60px}.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1001px -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycane{background-image:url(spritesmith-main-2.png);background-position:-1026px -1380px;width:60px;height:60px}.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1092px -1365px;width:90px;height:90px}.customize-option.hair_base_8_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1117px -1380px;width:60px;height:60px}.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1183px -1365px;width:90px;height:90px}.customize-option.hair_base_8_festive{background-image:url(spritesmith-main-2.png);background-position:-1208px -1380px;width:60px;height:60px}.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1274px -1365px;width:90px;height:90px}.customize-option.hair_base_8_frost{background-image:url(spritesmith-main-2.png);background-position:-1299px -1380px;width:60px;height:60px}.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1365px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1390px -1380px;width:60px;height:60px}.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1456px 0;width:90px;height:90px}.customize-option.hair_base_8_green{background-image:url(spritesmith-main-2.png);background-position:-1481px -15px;width:60px;height:60px}.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1456px -91px;width:90px;height:90px}.customize-option.hair_base_8_halloween{background-image:url(spritesmith-main-2.png);background-position:-1481px -106px;width:60px;height:60px}.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1456px -182px;width:90px;height:90px}.customize-option.hair_base_8_holly{background-image:url(spritesmith-main-2.png);background-position:-1481px -197px;width:60px;height:60px}.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1456px -273px;width:90px;height:90px}.customize-option.hair_base_8_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1481px -288px;width:60px;height:60px}.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-1456px -364px;width:90px;height:90px}.customize-option.hair_base_8_midnight{background-image:url(spritesmith-main-2.png);background-position:-1481px -379px;width:60px;height:60px}.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-1456px -455px;width:90px;height:90px}.customize-option.hair_base_8_pblue{background-image:url(spritesmith-main-2.png);background-position:-1481px -470px;width:60px;height:60px}.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1456px -546px;width:90px;height:90px}.customize-option.hair_base_8_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1481px -561px;width:60px;height:60px}.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1456px -637px;width:90px;height:90px}.customize-option.hair_base_8_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1481px -652px;width:60px;height:60px}.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1456px -728px;width:90px;height:90px}.customize-option.hair_base_8_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1481px -743px;width:60px;height:60px}.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1456px -819px;width:90px;height:90px}.customize-option.hair_base_8_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1481px -834px;width:60px;height:60px}.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1456px -910px;width:90px;height:90px}.customize-option.hair_base_8_porange{background-image:url(spritesmith-main-2.png);background-position:-1481px -925px;width:60px;height:60px}.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1001px;width:90px;height:90px}.customize-option.hair_base_8_porange2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1016px;width:60px;height:60px}.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1456px -1092px;width:90px;height:90px}.customize-option.hair_base_8_ppink{background-image:url(spritesmith-main-2.png);background-position:-1481px -1107px;width:60px;height:60px}.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1183px;width:90px;height:90px}.customize-option.hair_base_8_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1198px;width:60px;height:60px}.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1456px -1274px;width:90px;height:90px}.customize-option.hair_base_8_ppurple{background-image:url(spritesmith-main-2.png);background-position:-1481px -1289px;width:60px;height:60px}.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1456px -1365px;width:90px;height:90px}.customize-option.hair_base_8_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1481px -1380px;width:60px;height:60px}.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_base_8_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-25px -1471px;width:60px;height:60px}.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_base_8_purple{background-image:url(spritesmith-main-2.png);background-position:-116px -1471px;width:60px;height:60px}.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-182px -1456px;width:90px;height:90px}.customize-option.hair_base_8_pyellow{background-image:url(spritesmith-main-2.png);background-position:-207px -1471px;width:60px;height:60px}.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_base_8_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-298px -1471px;width:60px;height:60px}.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_base_8_rainbow{background-image:url(spritesmith-main-2.png);background-position:-389px -1471px;width:60px;height:60px}.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-455px -1456px;width:90px;height:90px}.customize-option.hair_base_8_red{background-image:url(spritesmith-main-2.png);background-position:-480px -1471px;width:60px;height:60px}.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-546px -1456px;width:90px;height:90px}.customize-option.hair_base_8_snowy{background-image:url(spritesmith-main-2.png);background-position:-571px -1471px;width:60px;height:60px}.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-637px -1456px;width:90px;height:90px}.customize-option.hair_base_8_white{background-image:url(spritesmith-main-2.png);background-position:-662px -1471px;width:60px;height:60px}.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_base_8_winternight{background-image:url(spritesmith-main-2.png);background-position:-753px -1471px;width:60px;height:60px}.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_base_8_winterstar{background-image:url(spritesmith-main-2.png);background-position:-844px -1471px;width:60px;height:60px}.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-910px -1456px;width:90px;height:90px}.customize-option.hair_base_8_yellow{background-image:url(spritesmith-main-2.png);background-position:-935px -1471px;width:60px;height:60px}.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1001px -1456px;width:90px;height:90px}.customize-option.hair_base_8_zombie{background-image:url(spritesmith-main-2.png);background-position:-1026px -1471px;width:60px;height:60px}.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1092px -1456px;width:90px;height:90px}.customize-option.hair_base_9_TRUred{background-image:url(spritesmith-main-2.png);background-position:-1117px -1471px;width:60px;height:60px}.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1183px -1456px;width:90px;height:90px}.customize-option.hair_base_9_aurora{background-image:url(spritesmith-main-2.png);background-position:-1208px -1471px;width:60px;height:60px}.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1274px -1456px;width:90px;height:90px}.customize-option.hair_base_9_black{background-image:url(spritesmith-main-2.png);background-position:-1299px -1471px;width:60px;height:60px}.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1365px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blond{background-image:url(spritesmith-main-2.png);background-position:-1390px -1471px;width:60px;height:60px}.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1456px -1456px;width:90px;height:90px}.customize-option.hair_base_9_blue{background-image:url(spritesmith-main-2.png);background-position:-1481px -1471px;width:60px;height:60px}.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1547px 0;width:90px;height:90px}.customize-option.hair_base_9_brown{background-image:url(spritesmith-main-2.png);background-position:-1572px -15px;width:60px;height:60px}.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1547px -91px;width:90px;height:90px}.customize-option.hair_base_9_candycane{background-image:url(spritesmith-main-2.png);background-position:-1572px -106px;width:60px;height:60px}.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1547px -182px;width:90px;height:90px}.customize-option.hair_base_9_candycorn{background-image:url(spritesmith-main-2.png);background-position:-1572px -197px;width:60px;height:60px}.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-1547px -273px;width:90px;height:90px}.customize-option.hair_base_9_festive{background-image:url(spritesmith-main-2.png);background-position:-1572px -288px;width:60px;height:60px}.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-1547px -364px;width:90px;height:90px}.customize-option.hair_base_9_frost{background-image:url(spritesmith-main-2.png);background-position:-1572px -379px;width:60px;height:60px}.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1547px -455px;width:90px;height:90px}.customize-option.hair_base_9_ghostwhite{background-image:url(spritesmith-main-2.png);background-position:-1572px -470px;width:60px;height:60px}.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1547px -546px;width:90px;height:90px}.customize-option.hair_base_9_green{background-image:url(spritesmith-main-2.png);background-position:-1572px -561px;width:60px;height:60px}.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1547px -637px;width:90px;height:90px}.customize-option.hair_base_9_halloween{background-image:url(spritesmith-main-2.png);background-position:-1572px -652px;width:60px;height:60px}.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1547px -728px;width:90px;height:90px}.customize-option.hair_base_9_holly{background-image:url(spritesmith-main-2.png);background-position:-1572px -743px;width:60px;height:60px}.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -819px;width:90px;height:90px}.customize-option.hair_base_9_hollygreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -834px;width:60px;height:60px}.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1547px -910px;width:90px;height:90px}.customize-option.hair_base_9_midnight{background-image:url(spritesmith-main-2.png);background-position:-1572px -925px;width:60px;height:60px}.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1547px -1001px;width:90px;height:90px}.customize-option.hair_base_9_pblue{background-image:url(spritesmith-main-2.png);background-position:-1572px -1016px;width:60px;height:60px}.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1092px;width:90px;height:90px}.customize-option.hair_base_9_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1107px;width:60px;height:60px}.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1547px -1183px;width:90px;height:90px}.customize-option.hair_base_9_peppermint{background-image:url(spritesmith-main-2.png);background-position:-1572px -1198px;width:60px;height:60px}.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1547px -1274px;width:90px;height:90px}.customize-option.hair_base_9_pgreen{background-image:url(spritesmith-main-2.png);background-position:-1572px -1289px;width:60px;height:60px}.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1365px;width:90px;height:90px}.customize-option.hair_base_9_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1380px;width:60px;height:60px}.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1547px -1456px;width:90px;height:90px}.customize-option.hair_base_9_porange{background-image:url(spritesmith-main-2.png);background-position:-1572px -1471px;width:60px;height:60px}.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:0 -1547px;width:90px;height:90px}.customize-option.hair_base_9_porange2{background-image:url(spritesmith-main-2.png);background-position:-25px -1562px;width:60px;height:60px}.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-91px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppink{background-image:url(spritesmith-main-2.png);background-position:-116px -1562px;width:60px;height:60px}.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-182px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppink2{background-image:url(spritesmith-main-2.png);background-position:-207px -1562px;width:60px;height:60px}.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-273px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppurple{background-image:url(spritesmith-main-2.png);background-position:-298px -1562px;width:60px;height:60px}.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-364px -1547px;width:90px;height:90px}.customize-option.hair_base_9_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-389px -1562px;width:60px;height:60px}.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-455px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pumpkin{background-image:url(spritesmith-main-2.png);background-position:-480px -1562px;width:60px;height:60px}.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-546px -1547px;width:90px;height:90px}.customize-option.hair_base_9_purple{background-image:url(spritesmith-main-2.png);background-position:-571px -1562px;width:60px;height:60px}.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-637px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pyellow{background-image:url(spritesmith-main-2.png);background-position:-662px -1562px;width:60px;height:60px}.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-728px -1547px;width:90px;height:90px}.customize-option.hair_base_9_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-753px -1562px;width:60px;height:60px}.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-819px -1547px;width:90px;height:90px}.customize-option.hair_base_9_rainbow{background-image:url(spritesmith-main-2.png);background-position:-844px -1562px;width:60px;height:60px}.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-910px -1547px;width:90px;height:90px}.customize-option.hair_base_9_red{background-image:url(spritesmith-main-2.png);background-position:-935px -1562px;width:60px;height:60px}.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1001px -1547px;width:90px;height:90px}.customize-option.hair_base_9_snowy{background-image:url(spritesmith-main-2.png);background-position:-1026px -1562px;width:60px;height:60px}.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1092px -1547px;width:90px;height:90px}.customize-option.hair_base_9_white{background-image:url(spritesmith-main-2.png);background-position:-1117px -1562px;width:60px;height:60px}.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1183px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winternight{background-image:url(spritesmith-main-2.png);background-position:-1208px -1562px;width:60px;height:60px}.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1274px -1547px;width:90px;height:90px}.customize-option.hair_base_9_winterstar{background-image:url(spritesmith-main-2.png);background-position:-1299px -1562px;width:60px;height:60px}.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1365px -1547px;width:90px;height:90px}.customize-option.hair_base_9_yellow{background-image:url(spritesmith-main-2.png);background-position:-1390px -1562px;width:60px;height:60px}.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1456px -1547px;width:90px;height:90px}.customize-option.hair_base_9_zombie{background-image:url(spritesmith-main-2.png);background-position:-1481px -1562px;width:60px;height:60px}.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1547px -1547px;width:90px;height:90px}.customize-option.hair_beard_1_pblue2{background-image:url(spritesmith-main-2.png);background-position:-1572px -1562px;width:60px;height:60px}.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1638px 0;width:90px;height:90px}.customize-option.hair_beard_1_pgreen2{background-image:url(spritesmith-main-2.png);background-position:-1663px -15px;width:60px;height:60px}.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1638px -91px;width:90px;height:90px}.customize-option.hair_beard_1_porange2{background-image:url(spritesmith-main-2.png);background-position:-1663px -106px;width:60px;height:60px}.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1638px -182px;width:90px;height:90px}.customize-option.hair_beard_1_ppink2{background-image:url(spritesmith-main-2.png);background-position:-1663px -197px;width:60px;height:60px}.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1638px -273px;width:90px;height:90px}.customize-option.hair_beard_1_ppurple2{background-image:url(spritesmith-main-2.png);background-position:-1663px -288px;width:60px;height:60px}.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1638px -364px;width:90px;height:90px}.customize-option.hair_beard_1_pyellow2{background-image:url(spritesmith-main-2.png);background-position:-1663px -379px;width:60px;height:60px}.hair_beard_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-454px -273px;width:90px;height:90px}.customize-option.hair_beard_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-479px -288px;width:60px;height:60px}.hair_beard_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1276px -728px;width:90px;height:90px}.customize-option.hair_beard_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1301px -743px;width:60px;height:60px}.hair_beard_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1276px -1092px;width:90px;height:90px}.customize-option.hair_beard_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-1301px -1107px;width:60px;height:60px}.hair_beard_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-182px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-207px -1289px;width:60px;height:60px}.hair_beard_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-273px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-298px -1289px;width:60px;height:60px}.hair_beard_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-455px -1274px;width:90px;height:90px}.customize-option.hair_beard_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-480px -1289px;width:60px;height:60px}.hair_beard_3_pblue2{background-image:url(spritesmith-main-3.png);background-position:-546px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_pblue2{background-image:url(spritesmith-main-3.png);background-position:-571px -1289px;width:60px;height:60px}.hair_beard_3_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-910px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-935px -1289px;width:60px;height:60px}.hair_beard_3_porange2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_porange2{background-image:url(spritesmith-main-3.png);background-position:-1026px -1289px;width:60px;height:60px}.hair_beard_3_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1183px -1274px;width:90px;height:90px}.customize-option.hair_beard_3_ppink2{background-image:url(spritesmith-main-3.png);background-position:-1208px -1289px;width:60px;height:60px}.hair_beard_3_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1367px -91px;width:90px;height:90px}.customize-option.hair_beard_3_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-1392px -106px;width:60px;height:60px}.hair_beard_3_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1367px -182px;width:90px;height:90px}.customize-option.hair_beard_3_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-1392px -197px;width:60px;height:60px}.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1367px -364px;width:90px;height:90px}.customize-option.hair_mustache_1_pblue2{background-image:url(spritesmith-main-3.png);background-position:-1392px -379px;width:60px;height:60px}.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1367px -455px;width:90px;height:90px}.customize-option.hair_mustache_1_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-1392px -470px;width:60px;height:60px}.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -1471px;width:60px;height:60px}.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-91px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_ppink2{background-image:url(spritesmith-main-3.png);background-position:-116px -1471px;width:60px;height:60px}.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-273px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-298px -1471px;width:60px;height:60px}.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-364px -1456px;width:90px;height:90px}.customize-option.hair_mustache_1_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-389px -1471px;width:60px;height:60px}.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-728px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_pblue2{background-image:url(spritesmith-main-3.png);background-position:-753px -1471px;width:60px;height:60px}.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-819px -1456px;width:90px;height:90px}.customize-option.hair_mustache_2_pgreen2{background-image:url(spritesmith-main-3.png);background-position:-844px -1471px;width:60px;height:60px}.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:0 -364px;width:90px;height:90px}.customize-option.hair_mustache_2_porange2{background-image:url(spritesmith-main-3.png);background-position:-25px -379px;width:60px;height:60px}.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-91px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_ppink2{background-image:url(spritesmith-main-3.png);background-position:-116px -379px;width:60px;height:60px}.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-182px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_ppurple2{background-image:url(spritesmith-main-3.png);background-position:-207px -379px;width:60px;height:60px}.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-273px -364px;width:90px;height:90px}.customize-option.hair_mustache_2_pyellow2{background-image:url(spritesmith-main-3.png);background-position:-298px -379px;width:60px;height:60px}.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-364px -364px;width:90px;height:90px}.customize-option.broad_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-389px -394px;width:60px;height:60px}.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-455px -364px;width:90px;height:90px}.customize-option.broad_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-480px -394px;width:60px;height:60px}.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:0 -455px;width:90px;height:90px}.customize-option.broad_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-25px -485px;width:60px;height:60px}.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-91px -455px;width:90px;height:90px}.customize-option.broad_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-116px -485px;width:60px;height:60px}.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-182px -455px;width:90px;height:90px}.customize-option.broad_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-207px -485px;width:60px;height:60px}.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-273px -455px;width:90px;height:90px}.customize-option.broad_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-298px -485px;width:60px;height:60px}.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-364px -455px;width:90px;height:90px}.customize-option.broad_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-389px -485px;width:60px;height:60px}.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-455px -455px;width:90px;height:90px}.customize-option.broad_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-480px -485px;width:60px;height:60px}.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-548px 0;width:90px;height:90px}.customize-option.broad_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-573px -30px;width:60px;height:60px}.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-548px -91px;width:90px;height:90px}.customize-option.broad_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-573px -121px;width:60px;height:60px}.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-548px -182px;width:90px;height:90px}.customize-option.broad_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-573px -212px;width:60px;height:60px}.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-548px -273px;width:90px;height:90px}.customize-option.broad_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-573px -303px;width:60px;height:60px}.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-548px -364px;width:90px;height:90px}.customize-option.broad_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-573px -394px;width:60px;height:60px}.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-548px -455px;width:90px;height:90px}.customize-option.broad_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-573px -485px;width:60px;height:60px}.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:0 -546px;width:90px;height:90px}.customize-option.broad_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-25px -576px;width:60px;height:60px}.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-91px -546px;width:90px;height:90px}.customize-option.broad_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-116px -576px;width:60px;height:60px}.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-182px -546px;width:90px;height:90px}.customize-option.broad_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-207px -576px;width:60px;height:60px}.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-273px -546px;width:90px;height:90px}.customize-option.slim_shirt_black{background-image:url(spritesmith-main-3.png);background-position:-298px -576px;width:60px;height:60px}.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-364px -546px;width:90px;height:90px}.customize-option.slim_shirt_blue{background-image:url(spritesmith-main-3.png);background-position:-389px -576px;width:60px;height:60px}.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-455px -546px;width:90px;height:90px}.customize-option.slim_shirt_convict{background-image:url(spritesmith-main-3.png);background-position:-480px -576px;width:60px;height:60px}.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-546px -546px;width:90px;height:90px}.customize-option.slim_shirt_cross{background-image:url(spritesmith-main-3.png);background-position:-571px -576px;width:60px;height:60px}.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-639px 0;width:90px;height:90px}.customize-option.slim_shirt_fire{background-image:url(spritesmith-main-3.png);background-position:-664px -30px;width:60px;height:60px}.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-639px -91px;width:90px;height:90px}.customize-option.slim_shirt_green{background-image:url(spritesmith-main-3.png);background-position:-664px -121px;width:60px;height:60px}.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-639px -182px;width:90px;height:90px}.customize-option.slim_shirt_horizon{background-image:url(spritesmith-main-3.png);background-position:-664px -212px;width:60px;height:60px}.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-639px -273px;width:90px;height:90px}.customize-option.slim_shirt_ocean{background-image:url(spritesmith-main-3.png);background-position:-664px -303px;width:60px;height:60px}.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-639px -364px;width:90px;height:90px}.customize-option.slim_shirt_pink{background-image:url(spritesmith-main-3.png);background-position:-664px -394px;width:60px;height:60px}.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-639px -455px;width:90px;height:90px}.customize-option.slim_shirt_purple{background-image:url(spritesmith-main-3.png);background-position:-664px -485px;width:60px;height:60px}.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-639px -546px;width:90px;height:90px}.customize-option.slim_shirt_rainbow{background-image:url(spritesmith-main-3.png);background-position:-664px -576px;width:60px;height:60px}.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:0 -637px;width:90px;height:90px}.customize-option.slim_shirt_redblue{background-image:url(spritesmith-main-3.png);background-position:-25px -667px;width:60px;height:60px}.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-91px -637px;width:90px;height:90px}.customize-option.slim_shirt_thunder{background-image:url(spritesmith-main-3.png);background-position:-116px -667px;width:60px;height:60px}.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-182px -637px;width:90px;height:90px}.customize-option.slim_shirt_tropical{background-image:url(spritesmith-main-3.png);background-position:-207px -667px;width:60px;height:60px}.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-273px -637px;width:90px;height:90px}.customize-option.slim_shirt_white{background-image:url(spritesmith-main-3.png);background-position:-298px -667px;width:60px;height:60px}.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-364px -637px;width:90px;height:90px}.customize-option.slim_shirt_yellow{background-image:url(spritesmith-main-3.png);background-position:-389px -667px;width:60px;height:60px}.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-455px -637px;width:90px;height:90px}.customize-option.slim_shirt_zombie{background-image:url(spritesmith-main-3.png);background-position:-480px -667px;width:60px;height:60px}.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-546px -637px;width:90px;height:90px}.customize-option.skin_0ff591{background-image:url(spritesmith-main-3.png);background-position:-571px -652px;width:60px;height:60px}.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -637px;width:90px;height:90px}.customize-option.skin_0ff591_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -652px;width:60px;height:60px}.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-730px 0;width:90px;height:90px}.customize-option.skin_2b43f6{background-image:url(spritesmith-main-3.png);background-position:-755px -15px;width:60px;height:60px}.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -91px;width:90px;height:90px}.customize-option.skin_2b43f6_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -106px;width:60px;height:60px}.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-730px -182px;width:90px;height:90px}.customize-option.skin_6bd049{background-image:url(spritesmith-main-3.png);background-position:-755px -197px;width:60px;height:60px}.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -273px;width:90px;height:90px}.customize-option.skin_6bd049_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -288px;width:60px;height:60px}.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-730px -364px;width:90px;height:90px}.customize-option.skin_800ed0{background-image:url(spritesmith-main-3.png);background-position:-755px -379px;width:60px;height:60px}.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -455px;width:90px;height:90px}.customize-option.skin_800ed0_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -470px;width:60px;height:60px}.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-730px -546px;width:90px;height:90px}.customize-option.skin_915533{background-image:url(spritesmith-main-3.png);background-position:-755px -561px;width:60px;height:60px}.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-730px -637px;width:90px;height:90px}.customize-option.skin_915533_sleep{background-image:url(spritesmith-main-3.png);background-position:-755px -652px;width:60px;height:60px}.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:0 -728px;width:90px;height:90px}.customize-option.skin_98461a{background-image:url(spritesmith-main-3.png);background-position:-25px -743px;width:60px;height:60px}.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -728px;width:90px;height:90px}.customize-option.skin_98461a_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -743px;width:60px;height:60px}.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-182px -728px;width:90px;height:90px}.customize-option.skin_bear{background-image:url(spritesmith-main-3.png);background-position:-207px -743px;width:60px;height:60px}.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -728px;width:90px;height:90px}.customize-option.skin_bear_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -743px;width:60px;height:60px}.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-364px -728px;width:90px;height:90px}.customize-option.skin_c06534{background-image:url(spritesmith-main-3.png);background-position:-389px -743px;width:60px;height:60px}.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -728px;width:90px;height:90px}.customize-option.skin_c06534_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -743px;width:60px;height:60px}.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-546px -728px;width:90px;height:90px}.customize-option.skin_c3e1dc{background-image:url(spritesmith-main-3.png);background-position:-571px -743px;width:60px;height:60px}.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -728px;width:90px;height:90px}.customize-option.skin_c3e1dc_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -743px;width:60px;height:60px}.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-728px -728px;width:90px;height:90px}.customize-option.skin_cactus{background-image:url(spritesmith-main-3.png);background-position:-753px -743px;width:60px;height:60px}.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px 0;width:90px;height:90px}.customize-option.skin_cactus_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -15px;width:60px;height:60px}.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-821px -91px;width:90px;height:90px}.customize-option.skin_candycorn{background-image:url(spritesmith-main-3.png);background-position:-846px -106px;width:60px;height:60px}.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -182px;width:90px;height:90px}.customize-option.skin_candycorn_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -197px;width:60px;height:60px}.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-821px -273px;width:90px;height:90px}.customize-option.skin_clownfish{background-image:url(spritesmith-main-3.png);background-position:-846px -288px;width:60px;height:60px}.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -364px;width:90px;height:90px}.customize-option.skin_clownfish_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -379px;width:60px;height:60px}.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-821px -455px;width:90px;height:90px}.customize-option.skin_d7a9f7{background-image:url(spritesmith-main-3.png);background-position:-846px -470px;width:60px;height:60px}.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -546px;width:90px;height:90px}.customize-option.skin_d7a9f7_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -561px;width:60px;height:60px}.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-821px -637px;width:90px;height:90px}.customize-option.skin_ddc994{background-image:url(spritesmith-main-3.png);background-position:-846px -652px;width:60px;height:60px}.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-821px -728px;width:90px;height:90px}.customize-option.skin_ddc994_sleep{background-image:url(spritesmith-main-3.png);background-position:-846px -743px;width:60px;height:60px}.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:0 -819px;width:90px;height:90px}.customize-option.skin_deepocean{background-image:url(spritesmith-main-3.png);background-position:-25px -834px;width:60px;height:60px}.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -819px;width:90px;height:90px}.customize-option.skin_deepocean_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -834px;width:60px;height:60px}.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-182px -819px;width:90px;height:90px}.customize-option.skin_ea8349{background-image:url(spritesmith-main-3.png);background-position:-207px -834px;width:60px;height:60px}.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -819px;width:90px;height:90px}.customize-option.skin_ea8349_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -834px;width:60px;height:60px}.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-364px -819px;width:90px;height:90px}.customize-option.skin_eb052b{background-image:url(spritesmith-main-3.png);background-position:-389px -834px;width:60px;height:60px}.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -819px;width:90px;height:90px}.customize-option.skin_eb052b_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -834px;width:60px;height:60px}.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-546px -819px;width:90px;height:90px}.customize-option.skin_f5a76e{background-image:url(spritesmith-main-3.png);background-position:-571px -834px;width:60px;height:60px}.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -819px;width:90px;height:90px}.customize-option.skin_f5a76e_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -834px;width:60px;height:60px}.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-728px -819px;width:90px;height:90px}.customize-option.skin_f5d70f{background-image:url(spritesmith-main-3.png);background-position:-753px -834px;width:60px;height:60px}.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -819px;width:90px;height:90px}.customize-option.skin_f5d70f_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -834px;width:60px;height:60px}.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-912px 0;width:90px;height:90px}.customize-option.skin_f69922{background-image:url(spritesmith-main-3.png);background-position:-937px -15px;width:60px;height:60px}.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -91px;width:90px;height:90px}.customize-option.skin_f69922_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -106px;width:60px;height:60px}.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-912px -182px;width:90px;height:90px}.customize-option.skin_fox{background-image:url(spritesmith-main-3.png);background-position:-937px -197px;width:60px;height:60px}.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -273px;width:90px;height:90px}.customize-option.skin_fox_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -288px;width:60px;height:60px}.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-912px -364px;width:90px;height:90px}.customize-option.skin_ghost{background-image:url(spritesmith-main-3.png);background-position:-937px -379px;width:60px;height:60px}.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -455px;width:90px;height:90px}.customize-option.skin_ghost_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -470px;width:60px;height:60px}.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-912px -546px;width:90px;height:90px}.customize-option.skin_lion{background-image:url(spritesmith-main-3.png);background-position:-937px -561px;width:60px;height:60px}.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -637px;width:90px;height:90px}.customize-option.skin_lion_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -652px;width:60px;height:60px}.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-912px -728px;width:90px;height:90px}.customize-option.skin_merblue{background-image:url(spritesmith-main-3.png);background-position:-937px -743px;width:60px;height:60px}.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-912px -819px;width:90px;height:90px}.customize-option.skin_merblue_sleep{background-image:url(spritesmith-main-3.png);background-position:-937px -834px;width:60px;height:60px}.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:0 -910px;width:90px;height:90px}.customize-option.skin_mergold{background-image:url(spritesmith-main-3.png);background-position:-25px -925px;width:60px;height:60px}.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -910px;width:90px;height:90px}.customize-option.skin_mergold_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -925px;width:60px;height:60px}.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-182px -910px;width:90px;height:90px}.customize-option.skin_mergreen{background-image:url(spritesmith-main-3.png);background-position:-207px -925px;width:60px;height:60px}.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -910px;width:90px;height:90px}.customize-option.skin_mergreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -925px;width:60px;height:60px}.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-364px -910px;width:90px;height:90px}.customize-option.skin_merruby{background-image:url(spritesmith-main-3.png);background-position:-389px -925px;width:60px;height:60px}.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -910px;width:90px;height:90px}.customize-option.skin_merruby_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -925px;width:60px;height:60px}.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-546px -910px;width:90px;height:90px}.customize-option.skin_monster{background-image:url(spritesmith-main-3.png);background-position:-571px -925px;width:60px;height:60px}.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -910px;width:90px;height:90px}.customize-option.skin_monster_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -925px;width:60px;height:60px}.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-728px -910px;width:90px;height:90px}.customize-option.skin_ogre{background-image:url(spritesmith-main-3.png);background-position:-753px -925px;width:60px;height:60px}.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -910px;width:90px;height:90px}.customize-option.skin_ogre_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -925px;width:60px;height:60px}.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-910px -910px;width:90px;height:90px}.customize-option.skin_panda{background-image:url(spritesmith-main-3.png);background-position:-935px -925px;width:60px;height:60px}.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px 0;width:90px;height:90px}.customize-option.skin_panda_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -15px;width:60px;height:60px}.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1003px -91px;width:90px;height:90px}.customize-option.skin_pastelBlue{background-image:url(spritesmith-main-3.png);background-position:-1028px -106px;width:60px;height:60px}.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -182px;width:90px;height:90px}.customize-option.skin_pastelBlue_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -197px;width:60px;height:60px}.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1003px -273px;width:90px;height:90px}.customize-option.skin_pastelGreen{background-image:url(spritesmith-main-3.png);background-position:-1028px -288px;width:60px;height:60px}.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -364px;width:90px;height:90px}.customize-option.skin_pastelGreen_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -379px;width:60px;height:60px}.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-1003px -455px;width:90px;height:90px}.customize-option.skin_pastelOrange{background-image:url(spritesmith-main-3.png);background-position:-1028px -470px;width:60px;height:60px}.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -546px;width:90px;height:90px}.customize-option.skin_pastelOrange_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -561px;width:60px;height:60px}.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-1003px -637px;width:90px;height:90px}.customize-option.skin_pastelPink{background-image:url(spritesmith-main-3.png);background-position:-1028px -652px;width:60px;height:60px}.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -728px;width:90px;height:90px}.customize-option.skin_pastelPink_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -743px;width:60px;height:60px}.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-1003px -819px;width:90px;height:90px}.customize-option.skin_pastelPurple{background-image:url(spritesmith-main-3.png);background-position:-1028px -834px;width:60px;height:60px}.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-1003px -910px;width:90px;height:90px}.customize-option.skin_pastelPurple_sleep{background-image:url(spritesmith-main-3.png);background-position:-1028px -925px;width:60px;height:60px}.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:0 -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron{background-image:url(spritesmith-main-3.png);background-position:-25px -1016px;width:60px;height:60px}.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowChevron_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1016px;width:60px;height:60px}.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-182px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal{background-image:url(spritesmith-main-3.png);background-position:-207px -1016px;width:60px;height:60px}.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1001px;width:90px;height:90px}.customize-option.skin_pastelRainbowDiagonal_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1016px;width:60px;height:60px}.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-364px -1001px;width:90px;height:90px}.customize-option.skin_pastelYellow{background-image:url(spritesmith-main-3.png);background-position:-389px -1016px;width:60px;height:60px}.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1001px;width:90px;height:90px}.customize-option.skin_pastelYellow_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1016px;width:60px;height:60px}.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-546px -1001px;width:90px;height:90px}.customize-option.skin_pig{background-image:url(spritesmith-main-3.png);background-position:-571px -1016px;width:60px;height:60px}.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1001px;width:90px;height:90px}.customize-option.skin_pig_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1016px;width:60px;height:60px}.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-728px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin{background-image:url(spritesmith-main-3.png);background-position:-753px -1016px;width:60px;height:60px}.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-819px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin2{background-image:url(spritesmith-main-3.png);background-position:-844px -1016px;width:60px;height:60px}.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-910px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin2_sleep{background-image:url(spritesmith-main-3.png);background-position:-935px -1016px;width:60px;height:60px}.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1001px -1001px;width:90px;height:90px}.customize-option.skin_pumpkin_sleep{background-image:url(spritesmith-main-3.png);background-position:-1026px -1016px;width:60px;height:60px}.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1094px 0;width:90px;height:90px}.customize-option.skin_rainbow{background-image:url(spritesmith-main-3.png);background-position:-1119px -15px;width:60px;height:60px}.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -91px;width:90px;height:90px}.customize-option.skin_rainbow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -106px;width:60px;height:60px}.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1094px -182px;width:90px;height:90px}.customize-option.skin_reptile{background-image:url(spritesmith-main-3.png);background-position:-1119px -197px;width:60px;height:60px}.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -273px;width:90px;height:90px}.customize-option.skin_reptile_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -288px;width:60px;height:60px}.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1094px -364px;width:90px;height:90px}.customize-option.skin_shadow{background-image:url(spritesmith-main-3.png);background-position:-1119px -379px;width:60px;height:60px}.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-1094px -455px;width:90px;height:90px}.customize-option.skin_shadow2{background-image:url(spritesmith-main-3.png);background-position:-1119px -470px;width:60px;height:60px}.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -546px;width:90px;height:90px}.customize-option.skin_shadow2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -561px;width:60px;height:60px}.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -637px;width:90px;height:90px}.customize-option.skin_shadow_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -652px;width:60px;height:60px}.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-1094px -728px;width:90px;height:90px}.customize-option.skin_shark{background-image:url(spritesmith-main-3.png);background-position:-1119px -743px;width:60px;height:60px}.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-1094px -819px;width:90px;height:90px}.customize-option.skin_shark_sleep{background-image:url(spritesmith-main-3.png);background-position:-1119px -834px;width:60px;height:60px}.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-1094px -910px;width:90px;height:90px}.customize-option.skin_skeleton{background-image:url(spritesmith-main-3.png);background-position:-1119px -925px;width:60px;height:60px}.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-1094px -1001px;width:90px;height:90px}.customize-option.skin_skeleton2{background-image:url(spritesmith-main-3.png);background-position:-1119px -1016px;width:60px;height:60px}.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:0 -1092px;width:90px;height:90px}.customize-option.skin_skeleton2_sleep{background-image:url(spritesmith-main-3.png);background-position:-25px -1107px;width:60px;height:60px}.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-91px -1092px;width:90px;height:90px}.customize-option.skin_skeleton_sleep{background-image:url(spritesmith-main-3.png);background-position:-116px -1107px;width:60px;height:60px}.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-182px -1092px;width:90px;height:90px}.customize-option.skin_tiger{background-image:url(spritesmith-main-3.png);background-position:-207px -1107px;width:60px;height:60px}.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-273px -1092px;width:90px;height:90px}.customize-option.skin_tiger_sleep{background-image:url(spritesmith-main-3.png);background-position:-298px -1107px;width:60px;height:60px}.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-364px -1092px;width:90px;height:90px}.customize-option.skin_transparent{background-image:url(spritesmith-main-3.png);background-position:-389px -1107px;width:60px;height:60px}.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-455px -1092px;width:90px;height:90px}.customize-option.skin_transparent_sleep{background-image:url(spritesmith-main-3.png);background-position:-480px -1107px;width:60px;height:60px}.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-546px -1092px;width:90px;height:90px}.customize-option.skin_tropicalwater{background-image:url(spritesmith-main-3.png);background-position:-571px -1107px;width:60px;height:60px}.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-637px -1092px;width:90px;height:90px}.customize-option.skin_tropicalwater_sleep{background-image:url(spritesmith-main-3.png);background-position:-662px -1107px;width:60px;height:60px}.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-728px -1092px;width:90px;height:90px}.customize-option.skin_wolf{background-image:url(spritesmith-main-3.png);background-position:-753px -1107px;width:60px;height:60px}.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-819px -1092px;width:90px;height:90px}.customize-option.skin_wolf_sleep{background-image:url(spritesmith-main-3.png);background-position:-844px -1107px;width:60px;height:60px}.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-910px -1092px;width:90px;height:90px}.customize-option.skin_zombie{background-image:url(spritesmith-main-3.png);background-position:-935px -1107px;width:60px;height:60px}.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1092px;width:90px;height:90px}.customize-option.skin_zombie2{background-image:url(spritesmith-main-3.png);background-position:-1026px -1107px;width:60px;height:60px}.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1092px -1092px;width:90px;height:90px}.customize-option.skin_zombie2_sleep{background-image:url(spritesmith-main-3.png);background-position:-1117px -1107px;width:60px;height:60px}.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1185px 0;width:90px;height:90px}.customize-option.skin_zombie_sleep{background-image:url(spritesmith-main-3.png);background-position:-1210px -15px;width:60px;height:60px}.broad_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -91px;width:90px;height:90px}.broad_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1185px -182px;width:90px;height:90px}.broad_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -273px;width:90px;height:90px}.broad_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1185px -364px;width:90px;height:90px}.broad_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1185px -455px;width:90px;height:90px}.broad_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -546px;width:90px;height:90px}.broad_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -637px;width:90px;height:90px}.broad_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-1185px -728px;width:90px;height:90px}.eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1185px -819px;width:90px;height:90px}.head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1185px -910px;width:90px;height:90px}.head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1185px -1001px;width:90px;height:90px}.head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1185px -1092px;width:90px;height:90px}.head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:0 -1183px;width:90px;height:90px}.head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-91px -1183px;width:90px;height:90px}.head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-182px -1183px;width:90px;height:90px}.head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-273px -1183px;width:90px;height:90px}.head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-364px -1183px;width:90px;height:90px}.head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-455px -1183px;width:90px;height:90px}.head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-546px -1183px;width:90px;height:90px}.head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-637px -1183px;width:90px;height:90px}.head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-728px -1183px;width:90px;height:90px}.head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-819px -1183px;width:90px;height:90px}.head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-910px -1183px;width:90px;height:90px}.head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1001px -1183px;width:90px;height:90px}.head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-1092px -1183px;width:90px;height:90px}.shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1183px -1183px;width:90px;height:90px}.shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1276px 0;width:90px;height:90px}.shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1276px -91px;width:90px;height:90px}.shop_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -943px;width:40px;height:40px}.shop_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1640px -779px;width:40px;height:40px}.shop_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -738px;width:40px;height:40px}.shop_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1640px -697px;width:40px;height:40px}.shop_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1640px -656px;width:40px;height:40px}.shop_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -533px;width:40px;height:40px}.shop_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -984px;width:40px;height:40px}.shop_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-1640px -492px;width:40px;height:40px}.shop_eyewear_armoire_plagueDoctorMask{background-image:url(spritesmith-main-3.png);background-position:-1640px -451px;width:40px;height:40px}.shop_head_armoire_blackCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -410px;width:40px;height:40px}.shop_head_armoire_blueFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -369px;width:40px;height:40px}.shop_head_armoire_blueHairbow{background-image:url(spritesmith-main-3.png);background-position:-1640px -328px;width:40px;height:40px}.shop_head_armoire_gladiatorHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -287px;width:40px;height:40px}.shop_head_armoire_goldenLaurels{background-image:url(spritesmith-main-3.png);background-position:-1640px -246px;width:40px;height:40px}.shop_head_armoire_hornedIronHelm{background-image:url(spritesmith-main-3.png);background-position:-1640px -205px;width:40px;height:40px}.shop_head_armoire_lunarCrown{background-image:url(spritesmith-main-3.png);background-position:-1640px -164px;width:40px;height:40px}.shop_head_armoire_orangeCat{background-image:url(spritesmith-main-3.png);background-position:-1640px -123px;width:40px;height:40px}.shop_head_armoire_plagueDoctorHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -82px;width:40px;height:40px}.shop_head_armoire_rancherHat{background-image:url(spritesmith-main-3.png);background-position:-1640px -41px;width:40px;height:40px}.shop_head_armoire_redFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1640px 0;width:40px;height:40px}.shop_head_armoire_redHairbow{background-image:url(spritesmith-main-3.png);background-position:-1576px -1588px;width:40px;height:40px}.shop_head_armoire_royalCrown{background-image:url(spritesmith-main-3.png);background-position:-1535px -1588px;width:40px;height:40px}.shop_head_armoire_shepherdHeaddress{background-image:url(spritesmith-main-3.png);background-position:-1494px -1588px;width:40px;height:40px}.shop_head_armoire_violetFloppyHat{background-image:url(spritesmith-main-3.png);background-position:-1453px -1588px;width:40px;height:40px}.shop_head_armoire_yellowHairbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1588px;width:40px;height:40px}.shop_shield_armoire_gladiatorShield{background-image:url(spritesmith-main-3.png);background-position:-1576px -1547px;width:40px;height:40px}.shop_shield_armoire_midnightShield{background-image:url(spritesmith-main-3.png);background-position:-1535px -1547px;width:40px;height:40px}.shop_shield_armoire_royalCane{background-image:url(spritesmith-main-3.png);background-position:-1494px -1547px;width:40px;height:40px}.shop_weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-1453px -1547px;width:40px;height:40px}.shop_weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-1412px -1547px;width:40px;height:40px}.shop_weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-1371px -1547px;width:40px;height:40px}.shop_weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-1330px -1547px;width:40px;height:40px}.shop_weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-1289px -1547px;width:40px;height:40px}.shop_weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-1248px -1547px;width:40px;height:40px}.shop_weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-1207px -1547px;width:40px;height:40px}.shop_weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-1166px -1547px;width:40px;height:40px}.slim_armor_armoire_gladiatorArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -819px;width:90px;height:90px}.slim_armor_armoire_goldenToga{background-image:url(spritesmith-main-3.png);background-position:-1367px -910px;width:90px;height:90px}.slim_armor_armoire_hornedIronArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -1001px;width:90px;height:90px}.slim_armor_armoire_lunarArmor{background-image:url(spritesmith-main-3.png);background-position:-1367px -1092px;width:90px;height:90px}.slim_armor_armoire_plagueDoctorOvercoat{background-image:url(spritesmith-main-3.png);background-position:-1367px -1183px;width:90px;height:90px}.slim_armor_armoire_rancherRobes{background-image:url(spritesmith-main-3.png);background-position:-1367px -1274px;width:90px;height:90px}.slim_armor_armoire_royalRobes{background-image:url(spritesmith-main-3.png);background-position:0 -1365px;width:90px;height:90px}.slim_armor_armoire_shepherdRobes{background-image:url(spritesmith-main-3.png);background-position:-91px -1365px;width:90px;height:90px}.weapon_armoire_basicCrossbow{background-image:url(spritesmith-main-3.png);background-position:-182px -1365px;width:90px;height:90px}.weapon_armoire_batWand{background-image:url(spritesmith-main-3.png);background-position:-273px -1365px;width:90px;height:90px}.weapon_armoire_goldWingStaff{background-image:url(spritesmith-main-3.png);background-position:-364px -1365px;width:90px;height:90px}.weapon_armoire_ironCrook{background-image:url(spritesmith-main-3.png);background-position:-455px -1365px;width:90px;height:90px}.weapon_armoire_lunarSceptre{background-image:url(spritesmith-main-3.png);background-position:-546px -1365px;width:90px;height:90px}.weapon_armoire_mythmakerSword{background-image:url(spritesmith-main-3.png);background-position:-637px -1365px;width:90px;height:90px}.weapon_armoire_rancherLasso{background-image:url(spritesmith-main-3.png);background-position:-728px -1365px;width:90px;height:90px}.weapon_armoire_shepherdsCrook{background-image:url(spritesmith-main-3.png);background-position:-819px -1365px;width:90px;height:90px}.broad_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-910px -1365px;width:90px;height:90px}.broad_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1001px -1365px;width:90px;height:90px}.broad_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1092px -1365px;width:90px;height:90px}.broad_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1183px -1365px;width:90px;height:90px}.broad_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1365px;width:90px;height:90px}.broad_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1365px -1365px;width:90px;height:90px}.broad_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1458px 0;width:90px;height:90px}.broad_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -91px;width:90px;height:90px}.broad_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -182px;width:90px;height:90px}.broad_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -273px;width:90px;height:90px}.broad_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -364px;width:90px;height:90px}.broad_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-1458px -455px;width:90px;height:90px}.broad_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -546px;width:90px;height:90px}.broad_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -637px;width:90px;height:90px}.broad_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -728px;width:90px;height:90px}.broad_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -819px;width:90px;height:90px}.broad_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -910px;width:90px;height:90px}.broad_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1458px -1001px;width:90px;height:90px}.broad_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1458px -1092px;width:90px;height:90px}.broad_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1458px -1183px;width:90px;height:90px}.broad_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1458px -1274px;width:90px;height:90px}.broad_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1458px -1365px;width:90px;height:90px}.shop_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1125px -1547px;width:40px;height:40px}.shop_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1084px -1547px;width:40px;height:40px}.shop_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1043px -1547px;width:40px;height:40px}.shop_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1002px -1547px;width:40px;height:40px}.shop_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-961px -1547px;width:40px;height:40px}.shop_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-920px -1547px;width:40px;height:40px}.shop_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-879px -1547px;width:40px;height:40px}.shop_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-838px -1547px;width:40px;height:40px}.shop_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-797px -1547px;width:40px;height:40px}.shop_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-756px -1547px;width:40px;height:40px}.shop_armor_special_0{background-image:url(spritesmith-main-3.png);background-position:-715px -1547px;width:40px;height:40px}.shop_armor_special_1{background-image:url(spritesmith-main-3.png);background-position:-674px -1547px;width:40px;height:40px}.shop_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:-551px -1547px;width:40px;height:40px}.shop_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-510px -1547px;width:40px;height:40px}.shop_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-469px -1547px;width:40px;height:40px}.shop_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-428px -1547px;width:40px;height:40px}.shop_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-387px -1547px;width:40px;height:40px}.shop_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-346px -1547px;width:40px;height:40px}.shop_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-305px -1547px;width:40px;height:40px}.shop_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-264px -1547px;width:40px;height:40px}.shop_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-223px -1547px;width:40px;height:40px}.shop_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-182px -1547px;width:40px;height:40px}.shop_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-633px -1588px;width:40px;height:40px}.shop_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-400px -314px;width:40px;height:40px}.slim_armor_healer_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -637px;width:90px;height:90px}.slim_armor_healer_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -728px;width:90px;height:90px}.slim_armor_healer_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -819px;width:90px;height:90px}.slim_armor_healer_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -910px;width:90px;height:90px}.slim_armor_healer_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -1001px;width:90px;height:90px}.slim_armor_rogue_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -1092px;width:90px;height:90px}.slim_armor_rogue_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -1183px;width:90px;height:90px}.slim_armor_rogue_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -1274px;width:90px;height:90px}.slim_armor_rogue_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -1365px;width:90px;height:90px}.slim_armor_rogue_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -1456px;width:90px;height:90px}.slim_armor_special_2{background-image:url(spritesmith-main-3.png);background-position:0 -1547px;width:90px;height:90px}.slim_armor_special_finnedOceanicArmor{background-image:url(spritesmith-main-3.png);background-position:-91px -1547px;width:90px;height:90px}.slim_armor_warrior_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -546px;width:90px;height:90px}.slim_armor_warrior_2{background-image:url(spritesmith-main-3.png);background-position:-1549px -455px;width:90px;height:90px}.slim_armor_warrior_3{background-image:url(spritesmith-main-3.png);background-position:-1549px -364px;width:90px;height:90px}.slim_armor_warrior_4{background-image:url(spritesmith-main-3.png);background-position:-1549px -273px;width:90px;height:90px}.slim_armor_warrior_5{background-image:url(spritesmith-main-3.png);background-position:-1549px -182px;width:90px;height:90px}.slim_armor_wizard_1{background-image:url(spritesmith-main-3.png);background-position:-1549px -91px;width:90px;height:90px}.slim_armor_wizard_2{background-image:url(spritesmith-main-3.png);background-position:-1549px 0;width:90px;height:90px}.slim_armor_wizard_3{background-image:url(spritesmith-main-3.png);background-position:-1456px -1456px;width:90px;height:90px}.slim_armor_wizard_4{background-image:url(spritesmith-main-3.png);background-position:-1365px -1456px;width:90px;height:90px}.slim_armor_wizard_5{background-image:url(spritesmith-main-3.png);background-position:-1274px -1456px;width:90px;height:90px}.broad_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1183px -1456px;width:90px;height:90px}.broad_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-1092px -1456px;width:90px;height:90px}.shop_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-592px -1547px;width:40px;height:40px}.shop_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-633px -1547px;width:40px;height:40px}.slim_armor_special_birthday{background-image:url(spritesmith-main-3.png);background-position:-1001px -1456px;width:90px;height:90px}.slim_armor_special_birthday2015{background-image:url(spritesmith-main-3.png);background-position:-910px -1456px;width:90px;height:90px}.broad_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-212px -273px;width:93px;height:90px}.broad_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -273px;width:105px;height:90px}.broad_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-637px -1456px;width:90px;height:90px}.broad_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-546px -1456px;width:90px;height:90px}.broad_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-455px -1456px;width:90px;height:90px}.broad_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px -91px;width:120px;height:90px}.broad_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px -182px;width:105px;height:90px}.broad_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-182px -1456px;width:90px;height:90px}.head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-306px -273px;width:93px;height:90px}.head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-348px -91px;width:105px;height:90px}.head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1367px -728px;width:90px;height:90px}.head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1367px -637px;width:90px;height:90px}.head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1367px -546px;width:90px;height:90px}.head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 -91px;width:120px;height:90px}.head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-212px -182px;width:105px;height:90px}.head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1367px -273px;width:90px;height:90px}.shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px 0;width:93px;height:90px}.shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:0 -273px;width:105px;height:90px}.shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1367px 0;width:90px;height:90px}.shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1274px -1274px;width:90px;height:90px}.shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:0 -182px;width:105px;height:90px}.shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1092px -1274px;width:90px;height:90px}.shop_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-223px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-264px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-305px -1588px;width:40px;height:40px}.shop_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-346px -1588px;width:40px;height:40px}.shop_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-387px -1588px;width:40px;height:40px}.shop_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-428px -1588px;width:40px;height:40px}.shop_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-469px -1588px;width:40px;height:40px}.shop_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-510px -1588px;width:40px;height:40px}.shop_head_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-551px -1588px;width:40px;height:40px}.shop_head_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-592px -1588px;width:40px;height:40px}.shop_head_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-400px -273px;width:40px;height:40px}.shop_head_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-674px -1588px;width:40px;height:40px}.shop_head_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-715px -1588px;width:40px;height:40px}.shop_head_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-756px -1588px;width:40px;height:40px}.shop_head_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-797px -1588px;width:40px;height:40px}.shop_head_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-838px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-879px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-920px -1588px;width:40px;height:40px}.shop_shield_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-961px -1588px;width:40px;height:40px}.shop_shield_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1002px -1588px;width:40px;height:40px}.shop_shield_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1043px -1588px;width:40px;height:40px}.shop_shield_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1084px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-1125px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-1166px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-1207px -1588px;width:40px;height:40px}.shop_weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-1248px -1588px;width:40px;height:40px}.shop_weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1289px -1588px;width:40px;height:40px}.shop_weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-1330px -1588px;width:40px;height:40px}.shop_weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-1371px -1588px;width:40px;height:40px}.shop_weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1412px -1588px;width:40px;height:40px}.slim_armor_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px -91px;width:93px;height:90px}.slim_armor_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-242px -91px;width:105px;height:90px}.slim_armor_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-819px -1274px;width:90px;height:90px}.slim_armor_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:-728px -1274px;width:90px;height:90px}.slim_armor_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-637px -1274px;width:90px;height:90px}.slim_armor_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:0 0;width:120px;height:90px}.slim_armor_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-348px 0;width:105px;height:90px}.slim_armor_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-364px -1274px;width:90px;height:90px}.weapon_special_fall2015Healer{background-image:url(spritesmith-main-3.png);background-position:-454px -182px;width:93px;height:90px}.weapon_special_fall2015Mage{background-image:url(spritesmith-main-3.png);background-position:-106px -182px;width:105px;height:90px}.weapon_special_fall2015Rogue{background-image:url(spritesmith-main-3.png);background-position:-91px -1274px;width:90px;height:90px}.weapon_special_fall2015Warrior{background-image:url(spritesmith-main-3.png);background-position:0 -1274px;width:90px;height:90px}.weapon_special_fallHealer{background-image:url(spritesmith-main-3.png);background-position:-1276px -1183px;width:90px;height:90px}.weapon_special_fallMage{background-image:url(spritesmith-main-3.png);background-position:-121px 0;width:120px;height:90px}.weapon_special_fallRogue{background-image:url(spritesmith-main-3.png);background-position:-242px 0;width:105px;height:90px}.weapon_special_fallWarrior{background-image:url(spritesmith-main-3.png);background-position:-1276px -910px;width:90px;height:90px}.broad_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -819px;width:90px;height:90px}.head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -637px;width:90px;height:90px}.shop_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1640px -574px;width:40px;height:40px}.shop_head_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1640px -615px;width:40px;height:40px}.slim_armor_special_gaymerx{background-image:url(spritesmith-main-3.png);background-position:-1276px -546px;width:90px;height:90px}.back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -455px;width:90px;height:90px}.broad_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -364px;width:90px;height:90px}.head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -273px;width:90px;height:90px}.shop_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -820px;width:40px;height:40px}.shop_back_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -861px;width:40px;height:40px}.shop_head_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1640px -902px;width:40px;height:40px}.slim_armor_mystery_201402{background-image:url(spritesmith-main-3.png);background-position:-1276px -182px;width:90px;height:90px}.broad_armor_mystery_201403{background-image:url(spritesmith-main-3.png);background-position:-1276px -1001px;width:90px;height:90px}.headAccessory_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-910px -1061px;width:90px;height:90px}.shop_armor_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1605px -1435px;width:40px;height:40px}.shop_headAccessory_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1564px -1025px;width:40px;height:40px}.slim_armor_mystery_201403{background-image:url(spritesmith-main-4.png);background-position:-1109px -728px;width:90px;height:90px}.back_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1001px -1061px;width:90px;height:90px}.headAccessory_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1092px -1061px;width:90px;height:90px}.shop_back_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1564px -533px;width:40px;height:40px}.shop_headAccessory_mystery_201404{background-image:url(spritesmith-main-4.png);background-position:-1564px -902px;width:40px;height:40px}.broad_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-637px -970px;width:90px;height:90px}.head_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-728px -970px;width:90px;height:90px}.shop_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-697px -1557px;width:40px;height:40px}.shop_head_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-246px -1516px;width:40px;height:40px}.slim_armor_mystery_201405{background-image:url(spritesmith-main-4.png);background-position:-1001px -970px;width:90px;height:90px}.broad_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-739px -212px;width:90px;height:96px}.head_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-830px -188px;width:90px;height:96px}.shop_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-164px -1557px;width:40px;height:40px}.shop_head_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-205px -1557px;width:40px;height:40px}.slim_armor_mystery_201406{background-image:url(spritesmith-main-4.png);background-position:-830px -91px;width:90px;height:96px}.broad_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1200px 0;width:90px;height:90px}.head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1200px -273px;width:90px;height:90px}.shop_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-369px -1516px;width:40px;height:40px}.shop_head_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1564px -492px;width:40px;height:40px}.slim_armor_mystery_201407{background-image:url(spritesmith-main-4.png);background-position:-1200px -364px;width:90px;height:90px}.broad_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1200px -455px;width:90px;height:90px}.head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1200px -546px;width:90px;height:90px}.shop_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-1564px -1066px;width:40px;height:40px}.shop_head_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:-41px -1557px;width:40px;height:40px}.slim_armor_mystery_201408{background-image:url(spritesmith-main-4.png);background-position:0 -1152px;width:90px;height:90px}.broad_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-91px -1152px;width:90px;height:90px}.headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-455px -970px;width:90px;height:90px}.shop_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-451px -1557px;width:40px;height:40px}.shop_headAccessory_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-656px -1557px;width:40px;height:40px}.slim_armor_mystery_201409{background-image:url(spritesmith-main-4.png);background-position:-546px -970px;width:90px;height:90px}.back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -558px;width:93px;height:90px}.broad_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-830px -285px;width:93px;height:90px}.shop_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1564px -328px;width:40px;height:40px}.shop_back_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-1564px -369px;width:40px;height:40px}.slim_armor_mystery_201410{background-image:url(spritesmith-main-4.png);background-position:-94px -788px;width:93px;height:90px}.head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1109px 0;width:90px;height:90px}.shop_head_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1564px -820px;width:40px;height:40px}.shop_weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1564px -861px;width:40px;height:40px}.weapon_mystery_201411{background-image:url(spritesmith-main-4.png);background-position:-1109px -91px;width:90px;height:90px}.broad_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1109px -182px;width:90px;height:90px}.head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1109px -273px;width:90px;height:90px}.shop_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1564px -1394px;width:40px;height:40px}.shop_head_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:0 -1557px;width:40px;height:40px}.slim_armor_mystery_201412{background-image:url(spritesmith-main-4.png);background-position:-1109px -364px;width:90px;height:90px}.broad_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1109px -455px;width:90px;height:90px}.head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1109px -546px;width:90px;height:90px}.shop_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-246px -1557px;width:40px;height:40px}.shop_head_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-369px -1557px;width:40px;height:40px}.slim_armor_mystery_201501{background-image:url(spritesmith-main-4.png);background-position:-1109px -819px;width:90px;height:90px}.headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-1109px -910px;width:90px;height:90px}.shop_headAccessory_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-574px -1557px;width:40px;height:40px}.shop_weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:-615px -1557px;width:40px;height:40px}.weapon_mystery_201502{background-image:url(spritesmith-main-4.png);background-position:0 -1061px;width:90px;height:90px}.broad_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-91px -1061px;width:90px;height:90px}.eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-182px -1061px;width:90px;height:90px}.shop_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-287px -1516px;width:40px;height:40px}.shop_eyewear_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-328px -1516px;width:40px;height:40px}.slim_armor_mystery_201503{background-image:url(spritesmith-main-4.png);background-position:-273px -1061px;width:90px;height:90px}.back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-364px -1061px;width:90px;height:90px}.broad_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-455px -1061px;width:90px;height:90px}.shop_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1564px -410px;width:40px;height:40px}.shop_back_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-1564px -451px;width:40px;height:40px}.slim_armor_mystery_201504{background-image:url(spritesmith-main-4.png);background-position:-546px -1061px;width:90px;height:90px}.head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-637px -1061px;width:90px;height:90px}.shop_head_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1564px -738px;width:40px;height:40px}.shop_weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-1564px -779px;width:40px;height:40px}.weapon_mystery_201505{background-image:url(spritesmith-main-4.png);background-position:-819px -1061px;width:90px;height:90px}.broad_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:0 -591px;width:90px;height:105px}.eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-273px -591px;width:90px;height:105px}.shop_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1564px -943px;width:40px;height:40px}.shop_eyewear_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-1564px -984px;width:40px;height:40px}.slim_armor_mystery_201506{background-image:url(spritesmith-main-4.png);background-position:-739px -106px;width:90px;height:105px}.back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-455px -591px;width:90px;height:105px}.eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-364px -591px;width:90px;height:105px}.shop_back_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-1564px -1435px;width:40px;height:40px}.shop_eyewear_mystery_201507{background-image:url(spritesmith-main-4.png);background-position:-1564px -1476px;width:40px;height:40px}.broad_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -467px;width:93px;height:90px}.head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:0 -788px;width:93px;height:90px}.shop_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-82px -1557px;width:40px;height:40px}.shop_head_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-123px -1557px;width:40px;height:40px}.slim_armor_mystery_201508{background-image:url(spritesmith-main-4.png);background-position:-830px -376px;width:93px;height:90px}.broad_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1200px -637px;width:90px;height:90px}.head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1200px -728px;width:90px;height:90px}.shop_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-287px -1557px;width:40px;height:40px}.shop_head_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-328px -1557px;width:40px;height:40px}.slim_armor_mystery_201509{background-image:url(spritesmith-main-4.png);background-position:-1200px -1001px;width:90px;height:90px}.back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-536px -394px;width:105px;height:90px}.headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-830px -649px;width:93px;height:90px}.shop_back_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-492px -1557px;width:40px;height:40px}.shop_headAccessory_mystery_201510{background-image:url(spritesmith-main-4.png);background-position:-533px -1557px;width:40px;height:40px}.broad_armor_mystery_201511{background-image:url(spritesmith-main-4.png);background-position:-182px -1152px;width:90px;height:90px}.head_mystery_201511{background-image:url(spritesmith-main-4.png);background-position:-273px -1152px;width:90px;height:90px}.shop_armor_mystery_201511{background-image:url(spritesmith-main-4.png);background-position:-1516px -1365px;width:42px;height:42px}.shop_head_mystery_201511{background-image:url(spritesmith-main-4.png);background-position:-1473px -1365px;width:42px;height:42px}.slim_armor_mystery_201511{background-image:url(spritesmith-main-4.png);background-position:-1291px -546px;width:90px;height:90px}.broad_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1291px -819px;width:90px;height:90px}.eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1291px -1092px;width:90px;height:90px}.head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-637px -1243px;width:90px;height:90px}.shop_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1564px -164px;width:40px;height:40px}.shop_eyewear_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1564px -205px;width:40px;height:40px}.shop_head_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1564px -246px;width:40px;height:40px}.shop_weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1564px -287px;width:40px;height:40px}.slim_armor_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-1382px 0;width:90px;height:90px}.weapon_mystery_301404{background-image:url(spritesmith-main-4.png);background-position:-182px -1425px;width:90px;height:90px}.eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-273px -1425px;width:90px;height:90px}.headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-364px -1425px;width:90px;height:90px}.head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-455px -1425px;width:90px;height:90px}.shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-546px -1425px;width:90px;height:90px}.shop_eyewear_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1564px -574px;width:40px;height:40px}.shop_headAccessory_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1564px -615px;width:40px;height:40px}.shop_head_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1564px -656px;width:40px;height:40px}.shop_shield_mystery_301405{background-image:url(spritesmith-main-4.png);background-position:-1564px -697px;width:40px;height:40px}.broad_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-637px -1425px;width:90px;height:90px}.broad_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -1425px;width:90px;height:90px}.broad_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-739px -400px;width:90px;height:90px}.broad_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-739px -491px;width:90px;height:90px}.broad_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-739px -582px;width:90px;height:90px}.broad_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-188px -788px;width:90px;height:90px}.broad_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-279px -788px;width:90px;height:90px}.broad_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-370px -788px;width:90px;height:90px}.headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-461px -788px;width:90px;height:90px}.headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-552px -788px;width:90px;height:90px}.headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-643px -788px;width:90px;height:90px}.headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-734px -788px;width:90px;height:90px}.headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-825px -788px;width:90px;height:90px}.headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-927px 0;width:90px;height:90px}.headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-927px -91px;width:90px;height:90px}.headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-927px -182px;width:90px;height:90px}.head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-927px -273px;width:90px;height:90px}.head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-927px -364px;width:90px;height:90px}.head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-927px -455px;width:90px;height:90px}.head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-927px -546px;width:90px;height:90px}.head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-927px -637px;width:90px;height:90px}.head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-927px -728px;width:90px;height:90px}.head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:0 -879px;width:90px;height:90px}.head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-91px -879px;width:90px;height:90px}.shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-182px -879px;width:90px;height:90px}.shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-273px -879px;width:90px;height:90px}.shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-364px -879px;width:90px;height:90px}.shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -879px;width:90px;height:90px}.shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-546px -879px;width:90px;height:90px}.shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-637px -879px;width:90px;height:90px}.shop_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-738px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-779px -1557px;width:40px;height:40px}.shop_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-927px -819px;width:40px;height:40px}.shop_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-968px -819px;width:40px;height:40px}.shop_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-830px -740px;width:40px;height:40px}.shop_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-871px -740px;width:40px;height:40px}.shop_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-648px -530px;width:40px;height:40px}.shop_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-689px -530px;width:40px;height:40px}.shop_headAccessory_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-230px -212px;width:40px;height:40px}.shop_headAccessory_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-230px -253px;width:40px;height:40px}.shop_headAccessory_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-336px -303px;width:40px;height:40px}.shop_headAccessory_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-377px -303px;width:40px;height:40px}.shop_headAccessory_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-336px -344px;width:40px;height:40px}.shop_headAccessory_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-377px -344px;width:40px;height:40px}.shop_headAccessory_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-448px -394px;width:40px;height:40px}.shop_headAccessory_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-489px -394px;width:40px;height:40px}.shop_head_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-448px -435px;width:40px;height:40px}.shop_head_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-489px -435px;width:40px;height:40px}.shop_head_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1274px -1425px;width:40px;height:40px}.shop_head_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1315px -1425px;width:40px;height:40px}.shop_head_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1356px -1425px;width:40px;height:40px}.shop_head_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1397px -1425px;width:40px;height:40px}.shop_head_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1438px -1425px;width:40px;height:40px}.shop_head_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1479px -1425px;width:40px;height:40px}.shop_shield_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1520px -1425px;width:40px;height:40px}.shop_shield_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1274px -1466px;width:40px;height:40px}.shop_shield_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1315px -1466px;width:40px;height:40px}.shop_shield_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1356px -1466px;width:40px;height:40px}.shop_shield_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1397px -1466px;width:40px;height:40px}.shop_shield_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1438px -1466px;width:40px;height:40px}.shop_weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1479px -1466px;width:40px;height:40px}.shop_weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1520px -1466px;width:40px;height:40px}.shop_weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 -1516px;width:40px;height:40px}.shop_weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-41px -1516px;width:40px;height:40px}.shop_weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-82px -1516px;width:40px;height:40px}.shop_weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-123px -1516px;width:40px;height:40px}.shop_weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-164px -1516px;width:40px;height:40px}.shop_weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-205px -1516px;width:40px;height:40px}.slim_armor_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -879px;width:90px;height:90px}.slim_armor_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -879px;width:90px;height:90px}.slim_armor_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-910px -879px;width:90px;height:90px}.slim_armor_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1018px 0;width:90px;height:90px}.slim_armor_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1018px -91px;width:90px;height:90px}.slim_armor_special_springMage{background-image:url(spritesmith-main-4.png);background-position:-1018px -182px;width:90px;height:90px}.slim_armor_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-1018px -273px;width:90px;height:90px}.slim_armor_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-1018px -364px;width:90px;height:90px}.weapon_special_spring2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1018px -455px;width:90px;height:90px}.weapon_special_spring2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1018px -546px;width:90px;height:90px}.weapon_special_spring2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1018px -637px;width:90px;height:90px}.weapon_special_spring2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1018px -728px;width:90px;height:90px}.weapon_special_springHealer{background-image:url(spritesmith-main-4.png);background-position:-1018px -819px;width:90px;height:90px}.weapon_special_springMage{background-image:url(spritesmith-main-4.png);background-position:0 -970px;width:90px;height:90px}.weapon_special_springRogue{background-image:url(spritesmith-main-4.png);background-position:-91px -970px;width:90px;height:90px}.weapon_special_springWarrior{background-image:url(spritesmith-main-4.png);background-position:-182px -970px;width:90px;height:90px}.body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-273px -970px;width:90px;height:90px}.body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-364px -970px;width:90px;height:90px}.body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 -106px;width:102px;height:105px}.body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-273px -485px;width:90px;height:105px}.body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-182px -485px;width:90px;height:105px}.body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-91px -485px;width:90px;height:105px}.broad_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-819px -970px;width:90px;height:90px}.broad_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-910px -970px;width:90px;height:90px}.broad_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px -106px;width:102px;height:105px}.broad_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -485px;width:90px;height:105px}.broad_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-637px -591px;width:90px;height:105px}.broad_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-536px -288px;width:90px;height:105px}.broad_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-536px -91px;width:111px;height:90px}.broad_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-536px 0;width:111px;height:90px}.eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -394px;width:111px;height:90px}.eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-112px -394px;width:111px;height:90px}.head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1109px -637px;width:90px;height:90px}.head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-739px -309px;width:90px;height:90px}.head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px -106px;width:102px;height:105px}.head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-364px -485px;width:90px;height:105px}.head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-455px -485px;width:90px;height:105px}.head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-546px -485px;width:90px;height:105px}.head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-424px -182px;width:111px;height:90px}.head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -91px;width:111px;height:90px}.Healer_Summer{background-image:url(spritesmith-main-4.png);background-position:-648px -212px;width:90px;height:105px}.Mage_Summer{background-image:url(spritesmith-main-4.png);background-position:-648px -318px;width:90px;height:105px}.SummerRogue14{background-image:url(spritesmith-main-4.png);background-position:-424px -91px;width:111px;height:90px}.SummerWarrior14{background-image:url(spritesmith-main-4.png);background-position:-424px 0;width:111px;height:90px}.shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1061px;width:90px;height:90px}.shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-206px 0;width:102px;height:105px}.shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-91px -591px;width:90px;height:105px}.shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-182px -591px;width:90px;height:105px}.shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-112px -303px;width:111px;height:90px}.shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:0 -303px;width:111px;height:90px}.shop_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -205px;width:40px;height:40px}.shop_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1605px -246px;width:40px;height:40px}.shop_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -287px;width:40px;height:40px}.shop_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -328px;width:40px;height:40px}.shop_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1605px -369px;width:40px;height:40px}.shop_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1605px -410px;width:40px;height:40px}.shop_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -451px;width:40px;height:40px}.shop_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -492px;width:40px;height:40px}.shop_body_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -861px;width:40px;height:40px}.shop_body_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1605px -902px;width:40px;height:40px}.shop_body_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -943px;width:40px;height:40px}.shop_body_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -984px;width:40px;height:40px}.shop_body_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1605px -1025px;width:40px;height:40px}.shop_body_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1605px -1066px;width:40px;height:40px}.shop_eyewear_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -1107px;width:40px;height:40px}.shop_eyewear_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -1148px;width:40px;height:40px}.shop_head_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -1189px;width:40px;height:40px}.shop_head_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1605px -1230px;width:40px;height:40px}.shop_head_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1605px -1271px;width:40px;height:40px}.shop_head_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -1312px;width:40px;height:40px}.shop_head_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1605px -1353px;width:40px;height:40px}.shop_head_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1605px -1394px;width:40px;height:40px}.shop_head_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1646px -697px;width:40px;height:40px}.shop_head_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1605px -1476px;width:40px;height:40px}.shop_shield_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1605px -1517px;width:40px;height:40px}.shop_shield_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 -1598px;width:40px;height:40px}.shop_shield_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-41px -1598px;width:40px;height:40px}.shop_shield_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-82px -1598px;width:40px;height:40px}.shop_shield_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1382px -1274px;width:40px;height:40px}.shop_shield_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1423px -1274px;width:40px;height:40px}.shop_weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -1183px;width:40px;height:40px}.shop_weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1332px -1183px;width:40px;height:40px}.shop_weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1200px -1092px;width:40px;height:40px}.shop_weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1241px -1092px;width:40px;height:40px}.shop_weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-1109px -1001px;width:40px;height:40px}.shop_weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-1150px -1001px;width:40px;height:40px}.shop_weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-1018px -910px;width:40px;height:40px}.shop_weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-1059px -910px;width:40px;height:40px}.slim_armor_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1200px -91px;width:90px;height:90px}.slim_armor_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -182px;width:90px;height:90px}.slim_armor_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-103px 0;width:102px;height:105px}.slim_armor_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-546px -591px;width:90px;height:105px}.slim_armor_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-536px -182px;width:90px;height:105px}.slim_armor_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-739px 0;width:90px;height:105px}.slim_armor_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-336px -394px;width:111px;height:90px}.slim_armor_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:0 -394px;width:111px;height:90px}.weapon_special_summer2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1200px -819px;width:90px;height:90px}.weapon_special_summer2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1200px -910px;width:90px;height:90px}.weapon_special_summer2015Rogue{background-image:url(spritesmith-main-4.png);background-position:0 0;width:102px;height:105px}.weapon_special_summer2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-648px -424px;width:90px;height:105px}.weapon_special_summerHealer{background-image:url(spritesmith-main-4.png);background-position:-648px -106px;width:90px;height:105px}.weapon_special_summerMage{background-image:url(spritesmith-main-4.png);background-position:-648px 0;width:90px;height:105px}.weapon_special_summerRogue{background-image:url(spritesmith-main-4.png);background-position:-224px -303px;width:111px;height:90px}.weapon_special_summerWarrior{background-image:url(spritesmith-main-4.png);background-position:-309px -182px;width:111px;height:90px}.broad_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-455px -1152px;width:90px;height:90px}.broad_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-546px -1152px;width:90px;height:90px}.broad_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-637px -1152px;width:90px;height:90px}.broad_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-728px -1152px;width:90px;height:90px}.broad_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-819px -1152px;width:90px;height:90px}.broad_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-604px -697px;width:96px;height:90px}.broad_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1001px -1152px;width:90px;height:90px}.broad_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1092px -1152px;width:90px;height:90px}.head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1183px -1152px;width:90px;height:90px}.head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-1291px 0;width:90px;height:90px}.head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-1291px -91px;width:90px;height:90px}.head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1291px -182px;width:90px;height:90px}.head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1291px -273px;width:90px;height:90px}.head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -364px;width:90px;height:90px}.head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1291px -455px;width:90px;height:90px}.head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-701px -697px;width:96px;height:90px}.head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1291px -637px;width:90px;height:90px}.head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1291px -728px;width:90px;height:90px}.shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:0 -697px;width:104px;height:90px}.shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1291px -910px;width:90px;height:90px}.shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1291px -1001px;width:90px;height:90px}.shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-410px -697px;width:96px;height:90px}.shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:0 -1243px;width:90px;height:90px}.shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-91px -1243px;width:90px;height:90px}.shop_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-410px -1516px;width:40px;height:40px}.shop_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-451px -1516px;width:40px;height:40px}.shop_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-492px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-533px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-574px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-615px -1516px;width:40px;height:40px}.shop_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-656px -1516px;width:40px;height:40px}.shop_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-697px -1516px;width:40px;height:40px}.shop_head_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-738px -1516px;width:40px;height:40px}.shop_head_special_nye{background-image:url(spritesmith-main-4.png);background-position:-779px -1516px;width:40px;height:40px}.shop_head_special_nye2014{background-image:url(spritesmith-main-4.png);background-position:-820px -1516px;width:40px;height:40px}.shop_head_special_ski{background-image:url(spritesmith-main-4.png);background-position:-861px -1516px;width:40px;height:40px}.shop_head_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-902px -1516px;width:40px;height:40px}.shop_head_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-943px -1516px;width:40px;height:40px}.shop_head_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-984px -1516px;width:40px;height:40px}.shop_head_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1025px -1516px;width:40px;height:40px}.shop_head_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1066px -1516px;width:40px;height:40px}.shop_head_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1107px -1516px;width:40px;height:40px}.shop_shield_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1148px -1516px;width:40px;height:40px}.shop_shield_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1189px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1230px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1271px -1516px;width:40px;height:40px}.shop_shield_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1312px -1516px;width:40px;height:40px}.shop_shield_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1353px -1516px;width:40px;height:40px}.shop_weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-1394px -1516px;width:40px;height:40px}.shop_weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1435px -1516px;width:40px;height:40px}.shop_weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1476px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1517px -1516px;width:40px;height:40px}.shop_weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1564px 0;width:40px;height:40px}.shop_weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-1564px -41px;width:40px;height:40px}.shop_weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1564px -82px;width:40px;height:40px}.shop_weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1564px -123px;width:40px;height:40px}.slim_armor_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-182px -1243px;width:90px;height:90px}.slim_armor_special_ski{background-image:url(spritesmith-main-4.png);background-position:-273px -1243px;width:90px;height:90px}.slim_armor_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-364px -1243px;width:90px;height:90px}.slim_armor_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-455px -1243px;width:90px;height:90px}.slim_armor_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-546px -1243px;width:90px;height:90px}.slim_armor_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-507px -697px;width:96px;height:90px}.slim_armor_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-728px -1243px;width:90px;height:90px}.slim_armor_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-819px -1243px;width:90px;height:90px}.weapon_special_candycane{background-image:url(spritesmith-main-4.png);background-position:-910px -1243px;width:90px;height:90px}.weapon_special_ski{background-image:url(spritesmith-main-4.png);background-position:-1001px -1243px;width:90px;height:90px}.weapon_special_snowflake{background-image:url(spritesmith-main-4.png);background-position:-1092px -1243px;width:90px;height:90px}.weapon_special_winter2015Healer{background-image:url(spritesmith-main-4.png);background-position:-1183px -1243px;width:90px;height:90px}.weapon_special_winter2015Mage{background-image:url(spritesmith-main-4.png);background-position:-1274px -1243px;width:90px;height:90px}.weapon_special_winter2015Rogue{background-image:url(spritesmith-main-4.png);background-position:-313px -697px;width:96px;height:90px}.weapon_special_winter2015Warrior{background-image:url(spritesmith-main-4.png);background-position:-1382px -91px;width:90px;height:90px}.weapon_special_yeti{background-image:url(spritesmith-main-4.png);background-position:-1382px -182px;width:90px;height:90px}.back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1382px -273px;width:90px;height:90px}.back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1382px -364px;width:90px;height:90px}.body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1382px -455px;width:90px;height:90px}.body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1382px -546px;width:90px;height:90px}.body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1382px -637px;width:90px;height:90px}.eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1382px -728px;width:90px;height:90px}.eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1382px -819px;width:90px;height:90px}.shop_back_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1107px;width:40px;height:40px}.shop_back_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1148px;width:40px;height:40px}.shop_body_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1189px;width:40px;height:40px}.shop_body_special_wondercon_gold{background-image:url(spritesmith-main-4.png);background-position:-1564px -1230px;width:40px;height:40px}.shop_body_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1271px;width:40px;height:40px}.shop_eyewear_special_wondercon_black{background-image:url(spritesmith-main-4.png);background-position:-1564px -1312px;width:40px;height:40px}.shop_eyewear_special_wondercon_red{background-image:url(spritesmith-main-4.png);background-position:-1564px -1353px;width:40px;height:40px}.head_0{background-image:url(spritesmith-main-4.png);background-position:-1382px -910px;width:90px;height:90px}.customize-option.head_0{background-image:url(spritesmith-main-4.png);background-position:-1407px -925px;width:60px;height:60px}.head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1382px -1001px;width:90px;height:90px}.head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1382px -1092px;width:90px;height:90px}.head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1382px -1183px;width:90px;height:90px}.head_healer_4{background-image:url(spritesmith-main-4.png);background-position:0 -1334px;width:90px;height:90px}.head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-91px -1334px;width:90px;height:90px}.head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-182px -1334px;width:90px;height:90px}.head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-273px -1334px;width:90px;height:90px}.head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-364px -1334px;width:90px;height:90px}.head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-455px -1334px;width:90px;height:90px}.head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-546px -1334px;width:90px;height:90px}.head_special_2{background-image:url(spritesmith-main-4.png);background-position:-637px -1334px;width:90px;height:90px}.head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-728px -1334px;width:90px;height:90px}.head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-819px -1334px;width:90px;height:90px}.head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-910px -1334px;width:90px;height:90px}.head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1001px -1334px;width:90px;height:90px}.head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1092px -1334px;width:90px;height:90px}.head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1183px -1334px;width:90px;height:90px}.head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1274px -1334px;width:90px;height:90px}.head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1365px -1334px;width:90px;height:90px}.head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1473px 0;width:90px;height:90px}.head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1473px -91px;width:90px;height:90px}.head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1473px -182px;width:90px;height:90px}.shop_head_healer_1{background-image:url(spritesmith-main-4.png);background-position:-820px -1557px;width:40px;height:40px}.shop_head_healer_2{background-image:url(spritesmith-main-4.png);background-position:-861px -1557px;width:40px;height:40px}.shop_head_healer_3{background-image:url(spritesmith-main-4.png);background-position:-902px -1557px;width:40px;height:40px}.shop_head_healer_4{background-image:url(spritesmith-main-4.png);background-position:-943px -1557px;width:40px;height:40px}.shop_head_healer_5{background-image:url(spritesmith-main-4.png);background-position:-984px -1557px;width:40px;height:40px}.shop_head_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1025px -1557px;width:40px;height:40px}.shop_head_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1066px -1557px;width:40px;height:40px}.shop_head_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1107px -1557px;width:40px;height:40px}.shop_head_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1148px -1557px;width:40px;height:40px}.shop_head_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1189px -1557px;width:40px;height:40px}.shop_head_special_0{background-image:url(spritesmith-main-4.png);background-position:-1230px -1557px;width:40px;height:40px}.shop_head_special_1{background-image:url(spritesmith-main-4.png);background-position:-1271px -1557px;width:40px;height:40px}.shop_head_special_2{background-image:url(spritesmith-main-4.png);background-position:-1312px -1557px;width:40px;height:40px}.shop_head_special_fireCoralCirclet{background-image:url(spritesmith-main-4.png);background-position:-1353px -1557px;width:40px;height:40px}.shop_head_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1394px -1557px;width:40px;height:40px}.shop_head_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1435px -1557px;width:40px;height:40px}.shop_head_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1476px -1557px;width:40px;height:40px}.shop_head_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1517px -1557px;width:40px;height:40px}.shop_head_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1558px -1557px;width:40px;height:40px}.shop_head_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1605px 0;width:40px;height:40px}.shop_head_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1605px -41px;width:40px;height:40px}.shop_head_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1605px -82px;width:40px;height:40px}.shop_head_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1605px -123px;width:40px;height:40px}.shop_head_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1605px -164px;width:40px;height:40px}.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1473px -273px;width:90px;height:90px}.customize-option.headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1498px -288px;width:60px;height:60px}.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1473px -364px;width:90px;height:90px}.customize-option.headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1498px -379px;width:60px;height:60px}.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1473px -455px;width:90px;height:90px}.customize-option.headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1498px -470px;width:60px;height:60px}.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1473px -546px;width:90px;height:90px}.customize-option.headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1498px -561px;width:60px;height:60px}.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1473px -637px;width:90px;height:90px}.customize-option.headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1498px -652px;width:60px;height:60px}.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1473px -728px;width:90px;height:90px}.customize-option.headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1498px -743px;width:60px;height:60px}.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1473px -819px;width:90px;height:90px}.customize-option.headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1498px -834px;width:60px;height:60px}.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-1473px -910px;width:90px;height:90px}.customize-option.headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-1498px -925px;width:60px;height:60px}.shop_headAccessory_special_bearEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -533px;width:40px;height:40px}.shop_headAccessory_special_cactusEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -574px;width:40px;height:40px}.shop_headAccessory_special_foxEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -615px;width:40px;height:40px}.shop_headAccessory_special_lionEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -656px;width:40px;height:40px}.shop_headAccessory_special_pandaEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -697px;width:40px;height:40px}.shop_headAccessory_special_pigEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -738px;width:40px;height:40px}.shop_headAccessory_special_tigerEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -779px;width:40px;height:40px}.shop_headAccessory_special_wolfEars{background-image:url(spritesmith-main-4.png);background-position:-1605px -820px;width:40px;height:40px}.shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1473px -1001px;width:90px;height:90px}.shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1473px -1092px;width:90px;height:90px}.shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1473px -1183px;width:90px;height:90px}.shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1473px -1274px;width:90px;height:90px}.shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:0 -1425px;width:90px;height:90px}.shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-91px -1425px;width:90px;height:90px}.shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-105px -697px;width:103px;height:90px}.shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-209px -697px;width:103px;height:90px}.shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-309px 0;width:114px;height:90px}.shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-830px 0;width:96px;height:90px}.shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-115px -212px;width:114px;height:90px}.shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:0 -212px;width:114px;height:90px}.shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-728px -1425px;width:90px;height:90px}.shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-424px -273px;width:111px;height:90px}.shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-910px -1425px;width:90px;height:90px}.shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1001px -1425px;width:90px;height:90px}.shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1092px -1425px;width:90px;height:90px}.shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-910px -1152px;width:90px;height:90px}.shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-364px -1152px;width:90px;height:90px}.shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1183px -1425px;width:90px;height:90px}.shop_shield_healer_1{background-image:url(spritesmith-main-4.png);background-position:-123px -1598px;width:40px;height:40px}.shop_shield_healer_2{background-image:url(spritesmith-main-4.png);background-position:-164px -1598px;width:40px;height:40px}.shop_shield_healer_3{background-image:url(spritesmith-main-4.png);background-position:-205px -1598px;width:40px;height:40px}.shop_shield_healer_4{background-image:url(spritesmith-main-4.png);background-position:-246px -1598px;width:40px;height:40px}.shop_shield_healer_5{background-image:url(spritesmith-main-4.png);background-position:-287px -1598px;width:40px;height:40px}.shop_shield_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-328px -1598px;width:40px;height:40px}.shop_shield_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-369px -1598px;width:40px;height:40px}.shop_shield_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-410px -1598px;width:40px;height:40px}.shop_shield_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-451px -1598px;width:40px;height:40px}.shop_shield_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-492px -1598px;width:40px;height:40px}.shop_shield_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-533px -1598px;width:40px;height:40px}.shop_shield_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-574px -1598px;width:40px;height:40px}.shop_shield_special_0{background-image:url(spritesmith-main-4.png);background-position:-615px -1598px;width:40px;height:40px}.shop_shield_special_1{background-image:url(spritesmith-main-4.png);background-position:-656px -1598px;width:40px;height:40px}.shop_shield_special_goldenknight{background-image:url(spritesmith-main-4.png);background-position:-697px -1598px;width:40px;height:40px}.shop_shield_special_moonpearlShield{background-image:url(spritesmith-main-4.png);background-position:-738px -1598px;width:40px;height:40px}.shop_shield_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-779px -1598px;width:40px;height:40px}.shop_shield_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-820px -1598px;width:40px;height:40px}.shop_shield_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-861px -1598px;width:40px;height:40px}.shop_shield_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-902px -1598px;width:40px;height:40px}.shop_shield_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-943px -1598px;width:40px;height:40px}.shop_weapon_healer_0{background-image:url(spritesmith-main-4.png);background-position:-984px -1598px;width:40px;height:40px}.shop_weapon_healer_1{background-image:url(spritesmith-main-4.png);background-position:-1025px -1598px;width:40px;height:40px}.shop_weapon_healer_2{background-image:url(spritesmith-main-4.png);background-position:-1066px -1598px;width:40px;height:40px}.shop_weapon_healer_3{background-image:url(spritesmith-main-4.png);background-position:-1107px -1598px;width:40px;height:40px}.shop_weapon_healer_4{background-image:url(spritesmith-main-4.png);background-position:-1148px -1598px;width:40px;height:40px}.shop_weapon_healer_5{background-image:url(spritesmith-main-4.png);background-position:-1189px -1598px;width:40px;height:40px}.shop_weapon_healer_6{background-image:url(spritesmith-main-4.png);background-position:-1230px -1598px;width:40px;height:40px}.shop_weapon_rogue_0{background-image:url(spritesmith-main-4.png);background-position:-1271px -1598px;width:40px;height:40px}.shop_weapon_rogue_1{background-image:url(spritesmith-main-4.png);background-position:-1312px -1598px;width:40px;height:40px}.shop_weapon_rogue_2{background-image:url(spritesmith-main-4.png);background-position:-1353px -1598px;width:40px;height:40px}.shop_weapon_rogue_3{background-image:url(spritesmith-main-4.png);background-position:-1394px -1598px;width:40px;height:40px}.shop_weapon_rogue_4{background-image:url(spritesmith-main-4.png);background-position:-1435px -1598px;width:40px;height:40px}.shop_weapon_rogue_5{background-image:url(spritesmith-main-4.png);background-position:-1476px -1598px;width:40px;height:40px}.shop_weapon_rogue_6{background-image:url(spritesmith-main-4.png);background-position:-1517px -1598px;width:40px;height:40px}.shop_weapon_special_0{background-image:url(spritesmith-main-4.png);background-position:-1558px -1598px;width:40px;height:40px}.shop_weapon_special_1{background-image:url(spritesmith-main-4.png);background-position:-1599px -1598px;width:40px;height:40px}.shop_weapon_special_2{background-image:url(spritesmith-main-4.png);background-position:-1646px 0;width:40px;height:40px}.shop_weapon_special_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -41px;width:40px;height:40px}.shop_weapon_special_critical{background-image:url(spritesmith-main-4.png);background-position:-1646px -82px;width:40px;height:40px}.shop_weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-4.png);background-position:-1646px -123px;width:40px;height:40px}.shop_weapon_warrior_0{background-image:url(spritesmith-main-4.png);background-position:-1646px -164px;width:40px;height:40px}.shop_weapon_warrior_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -205px;width:40px;height:40px}.shop_weapon_warrior_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -246px;width:40px;height:40px}.shop_weapon_warrior_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -287px;width:40px;height:40px}.shop_weapon_warrior_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -328px;width:40px;height:40px}.shop_weapon_warrior_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -369px;width:40px;height:40px}.shop_weapon_warrior_6{background-image:url(spritesmith-main-4.png);background-position:-1646px -410px;width:40px;height:40px}.shop_weapon_wizard_0{background-image:url(spritesmith-main-4.png);background-position:-1646px -451px;width:40px;height:40px}.shop_weapon_wizard_1{background-image:url(spritesmith-main-4.png);background-position:-1646px -492px;width:40px;height:40px}.shop_weapon_wizard_2{background-image:url(spritesmith-main-4.png);background-position:-1646px -533px;width:40px;height:40px}.shop_weapon_wizard_3{background-image:url(spritesmith-main-4.png);background-position:-1646px -574px;width:40px;height:40px}.shop_weapon_wizard_4{background-image:url(spritesmith-main-4.png);background-position:-1646px -615px;width:40px;height:40px}.shop_weapon_wizard_5{background-image:url(spritesmith-main-4.png);background-position:-1646px -656px;width:40px;height:40px}.shop_weapon_wizard_6{background-image:url(spritesmith-main-4.png);background-position:-410px -1557px;width:40px;height:40px}.weapon_healer_0{background-image:url(spritesmith-main-5.png);background-position:-1536px -1001px;width:90px;height:90px}.weapon_healer_1{background-image:url(spritesmith-main-5.png);background-position:-1627px 0;width:90px;height:90px}.weapon_healer_2{background-image:url(spritesmith-main-5.png);background-position:-1274px -1523px;width:90px;height:90px}.weapon_healer_3{background-image:url(spritesmith-main-5.png);background-position:-1365px -1523px;width:90px;height:90px}.weapon_healer_4{background-image:url(spritesmith-main-5.png);background-position:-1456px -1523px;width:90px;height:90px}.weapon_healer_5{background-image:url(spritesmith-main-5.png);background-position:-1443px -1251px;width:90px;height:90px}.weapon_healer_6{background-image:url(spritesmith-main-5.png);background-position:-1536px 0;width:90px;height:90px}.weapon_rogue_0{background-image:url(spritesmith-main-5.png);background-position:-1536px -91px;width:90px;height:90px}.weapon_rogue_1{background-image:url(spritesmith-main-5.png);background-position:-1536px -182px;width:90px;height:90px}.weapon_rogue_2{background-image:url(spritesmith-main-5.png);background-position:-1536px -273px;width:90px;height:90px}.weapon_rogue_3{background-image:url(spritesmith-main-5.png);background-position:-1536px -364px;width:90px;height:90px}.weapon_rogue_4{background-image:url(spritesmith-main-5.png);background-position:-1536px -455px;width:90px;height:90px}.weapon_rogue_5{background-image:url(spritesmith-main-5.png);background-position:-1536px -546px;width:90px;height:90px}.weapon_rogue_6{background-image:url(spritesmith-main-5.png);background-position:-1536px -728px;width:90px;height:90px}.weapon_special_1{background-image:url(spritesmith-main-5.png);background-position:-185px -1402px;width:102px;height:90px}.weapon_special_2{background-image:url(spritesmith-main-5.png);background-position:-1536px -1092px;width:90px;height:90px}.weapon_special_3{background-image:url(spritesmith-main-5.png);background-position:-1536px -1183px;width:90px;height:90px}.weapon_special_tridentOfCrashingTides{background-image:url(spritesmith-main-5.png);background-position:-1536px -1274px;width:90px;height:90px}.weapon_warrior_0{background-image:url(spritesmith-main-5.png);background-position:-1536px -1365px;width:90px;height:90px}.weapon_warrior_1{background-image:url(spritesmith-main-5.png);background-position:0 -1523px;width:90px;height:90px}.weapon_warrior_2{background-image:url(spritesmith-main-5.png);background-position:-91px -1523px;width:90px;height:90px}.weapon_warrior_3{background-image:url(spritesmith-main-5.png);background-position:-182px -1523px;width:90px;height:90px}.weapon_warrior_4{background-image:url(spritesmith-main-5.png);background-position:-273px -1523px;width:90px;height:90px}.weapon_warrior_5{background-image:url(spritesmith-main-5.png);background-position:-364px -1523px;width:90px;height:90px}.weapon_warrior_6{background-image:url(spritesmith-main-5.png);background-position:-546px -1523px;width:90px;height:90px}.weapon_wizard_0{background-image:url(spritesmith-main-5.png);background-position:-637px -1523px;width:90px;height:90px}.weapon_wizard_1{background-image:url(spritesmith-main-5.png);background-position:-728px -1523px;width:90px;height:90px}.weapon_wizard_2{background-image:url(spritesmith-main-5.png);background-position:-819px -1523px;width:90px;height:90px}.weapon_wizard_3{background-image:url(spritesmith-main-5.png);background-position:-910px -1523px;width:90px;height:90px}.weapon_wizard_4{background-image:url(spritesmith-main-5.png);background-position:-1001px -1523px;width:90px;height:90px}.weapon_wizard_5{background-image:url(spritesmith-main-5.png);background-position:-1092px -1523px;width:90px;height:90px}.weapon_wizard_6{background-image:url(spritesmith-main-5.png);background-position:-1183px -1523px;width:90px;height:90px}.GrimReaper{background-image:url(spritesmith-main-5.png);background-position:-1627px -164px;width:57px;height:66px}.Pet_Currency_Gem{background-image:url(spritesmith-main-5.png);background-position:-1671px -1567px;width:45px;height:39px}.Pet_Currency_Gem1x{background-image:url(spritesmith-main-5.png);background-position:-1688px -91px;width:15px;height:13px}.Pet_Currency_Gem2x{background-image:url(spritesmith-main-5.png);background-position:-1685px -451px;width:30px;height:26px}.PixelPaw-Gold{background-image:url(spritesmith-main-5.png);background-position:-1627px -891px;width:51px;height:51px}.PixelPaw{background-image:url(spritesmith-main-5.png);background-position:-1627px -995px;width:51px;height:51px}.PixelPaw002{background-image:url(spritesmith-main-5.png);background-position:-1627px -943px;width:51px;height:51px}.avatar_floral_healer{background-image:url(spritesmith-main-5.png);background-position:-1143px -1251px;width:99px;height:99px}.avatar_floral_rogue{background-image:url(spritesmith-main-5.png);background-position:-1243px -1251px;width:99px;height:99px}.avatar_floral_warrior{background-image:url(spritesmith-main-5.png);background-position:-1343px -1251px;width:99px;height:99px}.avatar_floral_wizard{background-image:url(spritesmith-main-5.png);background-position:-85px -1402px;width:99px;height:99px}.empty_bottles{background-image:url(spritesmith-main-5.png);background-position:-1627px -231px;width:64px;height:54px}.inventory_present{background-image:url(spritesmith-main-5.png);background-position:-1718px -156px;width:48px;height:51px}.inventory_present_01{background-image:url(spritesmith-main-5.png);background-position:-1718px -104px;width:48px;height:51px}.inventory_present_02{background-image:url(spritesmith-main-5.png);background-position:-1718px -52px;width:48px;height:51px}.inventory_present_03{background-image:url(spritesmith-main-5.png);background-position:-1718px 0;width:48px;height:51px}.inventory_present_04{background-image:url(spritesmith-main-5.png);background-position:-1627px -1047px;width:48px;height:51px}.inventory_present_05{background-image:url(spritesmith-main-5.png);background-position:-1617px -1666px;width:48px;height:51px}.inventory_present_06{background-image:url(spritesmith-main-5.png);background-position:-1568px -1666px;width:48px;height:51px}.inventory_present_07{background-image:url(spritesmith-main-5.png);background-position:-1519px -1666px;width:48px;height:51px}.inventory_present_08{background-image:url(spritesmith-main-5.png);background-position:-1470px -1666px;width:48px;height:51px}.inventory_present_09{background-image:url(spritesmith-main-5.png);background-position:-1421px -1666px;width:48px;height:51px}.inventory_present_10{background-image:url(spritesmith-main-5.png);background-position:-1372px -1666px;width:48px;height:51px}.inventory_present_11{background-image:url(spritesmith-main-5.png);background-position:-1323px -1666px;width:48px;height:51px}.inventory_present_12{background-image:url(spritesmith-main-5.png);background-position:-1274px -1666px;width:48px;height:51px}.inventory_quest_scroll{background-image:url(spritesmith-main-5.png);background-position:-1225px -1666px;width:48px;height:51px}.inventory_quest_scroll_locked{background-image:url(spritesmith-main-5.png);background-position:-1176px -1666px;width:48px;height:51px}.inventory_special_fortify{background-image:url(spritesmith-main-5.png);background-position:-1627px -781px;width:57px;height:54px}.inventory_special_greeting{background-image:url(spritesmith-main-5.png);background-position:-1627px -726px;width:57px;height:54px}.inventory_special_nye{background-image:url(spritesmith-main-5.png);background-position:-1627px -671px;width:57px;height:54px}.inventory_special_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1676px -1463px;width:40px;height:40px}.inventory_special_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1627px -616px;width:57px;height:54px}.inventory_special_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1627px -561px;width:57px;height:54px}.inventory_special_snowball{background-image:url(spritesmith-main-5.png);background-position:-1627px -286px;width:57px;height:54px}.inventory_special_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1627px -836px;width:57px;height:54px}.inventory_special_thankyou{background-image:url(spritesmith-main-5.png);background-position:-1627px -396px;width:57px;height:54px}.inventory_special_trinket{background-image:url(spritesmith-main-5.png);background-position:-637px -1666px;width:48px;height:51px}.inventory_special_valentine{background-image:url(spritesmith-main-5.png);background-position:-1627px -341px;width:57px;height:54px}.knockout{background-image:url(spritesmith-main-5.png);background-position:-288px -1442px;width:120px;height:47px}.pet_key{background-image:url(spritesmith-main-5.png);background-position:-1627px -506px;width:57px;height:54px}.rebirth_orb{background-image:url(spritesmith-main-5.png);background-position:-1627px -451px;width:57px;height:54px}.seafoam_star{background-image:url(spritesmith-main-5.png);background-position:-1536px -637px;width:90px;height:90px}.shop_armoire{background-image:url(spritesmith-main-5.png);background-position:-1676px -1359px;width:40px;height:40px}.snowman{background-image:url(spritesmith-main-5.png);background-position:-1536px -819px;width:90px;height:90px}.spookman{background-image:url(spritesmith-main-5.png);background-position:-1536px -910px;width:90px;height:90px}.zzz{background-image:url(spritesmith-main-5.png);background-position:-1676px -1255px;width:40px;height:40px}.zzz_light{background-image:url(spritesmith-main-5.png);background-position:-1676px -1151px;width:40px;height:40px}.npc_alex{background-image:url(spritesmith-main-5.png);background-position:-314px -1251px;width:162px;height:138px}.npc_bailey{background-image:url(spritesmith-main-5.png);background-position:-1627px -91px;width:60px;height:72px}.npc_daniel{background-image:url(spritesmith-main-5.png);background-position:-477px -1251px;width:135px;height:123px}.npc_justin{background-image:url(spritesmith-main-5.png);background-position:0 -1402px;width:84px;height:120px}.npc_justin_head{background-image:url(spritesmith-main-5.png);background-position:-1679px -891px;width:36px;height:39px}.npc_matt{background-image:url(spritesmith-main-5.png);background-position:-1322px -676px;width:195px;height:138px}.npc_timetravelers{background-image:url(spritesmith-main-5.png);background-position:-1322px -954px;width:195px;height:138px}.npc_timetravelers_active{background-image:url(spritesmith-main-5.png);background-position:-1322px -815px;width:195px;height:138px}.npc_tyler{background-image:url(spritesmith-main-5.png);background-position:-455px -1523px;width:90px;height:90px}.seasonalshop_closed{background-image:url(spritesmith-main-5.png);background-position:-151px -1251px;width:162px;height:138px}.inventory_quest_scroll_atom1{background-image:url(spritesmith-main-5.png);background-position:-49px -1666px;width:48px;height:51px}.inventory_quest_scroll_atom1_locked{background-image:url(spritesmith-main-5.png);background-position:-1127px -1614px;width:48px;height:51px}.inventory_quest_scroll_atom2{background-image:url(spritesmith-main-5.png);background-position:-1078px -1614px;width:48px;height:51px}.inventory_quest_scroll_atom2_locked{background-image:url(spritesmith-main-5.png);background-position:-1029px -1614px;width:48px;height:51px}.inventory_quest_scroll_atom3{background-image:url(spritesmith-main-5.png);background-position:-980px -1614px;width:48px;height:51px}.inventory_quest_scroll_atom3_locked{background-image:url(spritesmith-main-5.png);background-position:-931px -1614px;width:48px;height:51px}.inventory_quest_scroll_basilist{background-image:url(spritesmith-main-5.png);background-position:-882px -1614px;width:48px;height:51px}.inventory_quest_scroll_bunny{background-image:url(spritesmith-main-5.png);background-position:-833px -1614px;width:48px;height:51px}.inventory_quest_scroll_cheetah{background-image:url(spritesmith-main-5.png);background-position:-784px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-735px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:-441px -1614px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress2_locked{background-image:url(spritesmith-main-5.png);background-position:-98px -1666px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-1666px -1666px;width:48px;height:51px}.inventory_quest_scroll_dilatoryDistress3_locked{background-image:url(spritesmith-main-5.png);background-position:-147px -1666px;width:48px;height:51px}.inventory_quest_scroll_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:-392px -1666px;width:48px;height:51px}.inventory_quest_scroll_egg{background-image:url(spritesmith-main-5.png);background-position:-441px -1666px;width:48px;height:51px}.inventory_quest_scroll_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-490px -1666px;width:48px;height:51px}.inventory_quest_scroll_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:-588px -1666px;width:48px;height:51px}.inventory_quest_scroll_frog{background-image:url(spritesmith-main-5.png);background-position:-686px -1666px;width:48px;height:51px}.inventory_quest_scroll_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-735px -1666px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-784px -1666px;width:48px;height:51px}.inventory_quest_scroll_goldenknight1_locked{background-image:url(spritesmith-main-5.png);background-position:-833px -1666px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-882px -1666px;width:48px;height:51px}.inventory_quest_scroll_goldenknight2_locked{background-image:url(spritesmith-main-5.png);background-position:-1029px -1666px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:-1078px -1666px;width:48px;height:51px}.inventory_quest_scroll_goldenknight3_locked{background-image:url(spritesmith-main-5.png);background-position:-1127px -1666px;width:48px;height:51px}.inventory_quest_scroll_gryphon{background-image:url(spritesmith-main-5.png);background-position:-1718px -208px;width:48px;height:51px}.inventory_quest_scroll_harpy{background-image:url(spritesmith-main-5.png);background-position:-1718px -260px;width:48px;height:51px}.inventory_quest_scroll_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1718px -364px;width:48px;height:51px}.inventory_quest_scroll_horse{background-image:url(spritesmith-main-5.png);background-position:-1718px -416px;width:48px;height:51px}.inventory_quest_scroll_kraken{background-image:url(spritesmith-main-5.png);background-position:-1627px -1099px;width:48px;height:51px}.inventory_quest_scroll_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-1627px -1151px;width:48px;height:51px}.inventory_quest_scroll_moonstone1_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -1203px;width:48px;height:51px}.inventory_quest_scroll_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-1627px -1255px;width:48px;height:51px}.inventory_quest_scroll_moonstone2_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -1307px;width:48px;height:51px}.inventory_quest_scroll_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-1627px -1359px;width:48px;height:51px}.inventory_quest_scroll_moonstone3_locked{background-image:url(spritesmith-main-5.png);background-position:-1627px -1411px;width:48px;height:51px}.inventory_quest_scroll_octopus{background-image:url(spritesmith-main-5.png);background-position:-1627px -1463px;width:48px;height:51px}.inventory_quest_scroll_owl{background-image:url(spritesmith-main-5.png);background-position:-1627px -1515px;width:48px;height:51px}.inventory_quest_scroll_penguin{background-image:url(spritesmith-main-5.png);background-position:-1536px -1456px;width:48px;height:51px}.inventory_quest_scroll_rat{background-image:url(spritesmith-main-5.png);background-position:-1547px -1523px;width:48px;height:51px}.inventory_quest_scroll_rock{background-image:url(spritesmith-main-5.png);background-position:0 -1614px;width:48px;height:51px}.inventory_quest_scroll_rooster{background-image:url(spritesmith-main-5.png);background-position:-49px -1614px;width:48px;height:51px}.inventory_quest_scroll_sheep{background-image:url(spritesmith-main-5.png);background-position:-98px -1614px;width:48px;height:51px}.inventory_quest_scroll_slime{background-image:url(spritesmith-main-5.png);background-position:-147px -1614px;width:48px;height:51px}.inventory_quest_scroll_snake{background-image:url(spritesmith-main-5.png);background-position:-196px -1614px;width:48px;height:51px}.inventory_quest_scroll_spider{background-image:url(spritesmith-main-5.png);background-position:-245px -1614px;width:48px;height:51px}.inventory_quest_scroll_trex{background-image:url(spritesmith-main-5.png);background-position:-294px -1614px;width:48px;height:51px}.inventory_quest_scroll_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-343px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice1{background-image:url(spritesmith-main-5.png);background-position:-392px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice1_locked{background-image:url(spritesmith-main-5.png);background-position:-1767px -1248px;width:48px;height:51px}.inventory_quest_scroll_vice2{background-image:url(spritesmith-main-5.png);background-position:-490px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice2_locked{background-image:url(spritesmith-main-5.png);background-position:-539px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice3{background-image:url(spritesmith-main-5.png);background-position:-588px -1614px;width:48px;height:51px}.inventory_quest_scroll_vice3_locked{background-image:url(spritesmith-main-5.png);background-position:-637px -1614px;width:48px;height:51px}.inventory_quest_scroll_whale{background-image:url(spritesmith-main-5.png);background-position:-686px -1614px;width:48px;height:51px}.quest_TEMPLATE_FOR_MISSING_IMAGE{background-image:url(spritesmith-main-5.png);background-position:-1100px -805px;width:221px;height:39px}.quest_atom1{background-image:url(spritesmith-main-5.png);background-position:-434px -1070px;width:250px;height:150px}.quest_atom2{background-image:url(spritesmith-main-5.png);background-position:-1322px -537px;width:207px;height:138px}.quest_atom3{background-image:url(spritesmith-main-5.png);background-position:0 -1070px;width:216px;height:180px}.quest_basilist{background-image:url(spritesmith-main-5.png);background-position:-1322px -1093px;width:189px;height:141px}.quest_bunny{background-image:url(spritesmith-main-5.png);background-position:-1100px -618px;width:210px;height:186px}.quest_cheetah{background-image:url(spritesmith-main-5.png);background-position:0 -232px;width:219px;height:219px}.quest_dilatory{background-image:url(spritesmith-main-5.png);background-position:-660px 0;width:219px;height:219px}.quest_dilatoryDistress1{background-image:url(spritesmith-main-5.png);background-position:-288px -1402px;width:221px;height:39px}.quest_dilatoryDistress1_blueFins{background-image:url(spritesmith-main-5.png);background-position:-1176px -1614px;width:51px;height:48px}.quest_dilatoryDistress1_fireCoral{background-image:url(spritesmith-main-5.png);background-position:0 -1666px;width:48px;height:51px}.quest_dilatoryDistress2{background-image:url(spritesmith-main-5.png);background-position:0 -1251px;width:150px;height:150px}.quest_dilatoryDistress3{background-image:url(spritesmith-main-5.png);background-position:-660px -220px;width:219px;height:219px}.quest_dilatory_derby{background-image:url(spritesmith-main-5.png);background-position:0 -452px;width:219px;height:219px}.quest_egg{background-image:url(spritesmith-main-5.png);background-position:-732px -1402px;width:221px;height:39px}.quest_egg_plainEgg{background-image:url(spritesmith-main-5.png);background-position:-245px -1666px;width:48px;height:51px}.quest_evilsanta{background-image:url(spritesmith-main-5.png);background-position:-1187px -1070px;width:118px;height:131px}.quest_evilsanta2{background-image:url(spritesmith-main-5.png);background-position:-220px -452px;width:219px;height:219px}.quest_frog{background-image:url(spritesmith-main-5.png);background-position:-1100px 0;width:221px;height:213px}.quest_ghost_stag{background-image:url(spritesmith-main-5.png);background-position:-220px -672px;width:219px;height:219px}.quest_goldenknight1{background-image:url(spritesmith-main-5.png);background-position:-1100px -845px;width:221px;height:39px}.quest_goldenknight1_testimony{background-image:url(spritesmith-main-5.png);background-position:-539px -1666px;width:48px;height:51px}.quest_goldenknight2{background-image:url(spritesmith-main-5.png);background-position:-936px -1070px;width:250px;height:150px}.quest_goldenknight3{background-image:url(spritesmith-main-5.png);background-position:0 0;width:219px;height:231px}.quest_gryphon{background-image:url(spritesmith-main-5.png);background-position:-440px -892px;width:216px;height:177px}.quest_harpy{background-image:url(spritesmith-main-5.png);background-position:-880px -672px;width:219px;height:219px}.quest_hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1100px -431px;width:219px;height:186px}.quest_horse{background-image:url(spritesmith-main-5.png);background-position:-440px 0;width:219px;height:219px}.quest_kraken{background-image:url(spritesmith-main-5.png);background-position:-657px -892px;width:216px;height:177px}.quest_moonstone1{background-image:url(spritesmith-main-5.png);background-position:-954px -1402px;width:221px;height:39px}.quest_moonstone1_moonstone{background-image:url(spritesmith-main-5.png);background-position:-1685px -396px;width:30px;height:30px}.quest_moonstone2{background-image:url(spritesmith-main-5.png);background-position:-440px -232px;width:219px;height:219px}.quest_moonstone3{background-image:url(spritesmith-main-5.png);background-position:-440px -452px;width:219px;height:219px}.quest_octopus{background-image:url(spritesmith-main-5.png);background-position:0 -892px;width:222px;height:177px}.quest_owl{background-image:url(spritesmith-main-5.png);background-position:-660px -452px;width:219px;height:219px}.quest_penguin{background-image:url(spritesmith-main-5.png);background-position:-1322px -353px;width:190px;height:183px}.quest_rat{background-image:url(spritesmith-main-5.png);background-position:-880px 0;width:219px;height:219px}.quest_rock{background-image:url(spritesmith-main-5.png);background-position:-1100px -214px;width:216px;height:216px}.quest_rooster{background-image:url(spritesmith-main-5.png);background-position:-1322px 0;width:213px;height:174px}.quest_sheep{background-image:url(spritesmith-main-5.png);background-position:-440px -672px;width:219px;height:219px}.quest_slime{background-image:url(spritesmith-main-5.png);background-position:-660px -672px;width:219px;height:219px}.quest_snake{background-image:url(spritesmith-main-5.png);background-position:-874px -892px;width:216px;height:177px}.quest_spider{background-image:url(spritesmith-main-5.png);background-position:-685px -1070px;width:250px;height:150px}.quest_stressbeast{background-image:url(spritesmith-main-5.png);background-position:-220px 0;width:219px;height:219px}.quest_stressbeast_bailey{background-image:url(spritesmith-main-5.png);background-position:0 -672px;width:219px;height:219px}.quest_stressbeast_guide{background-image:url(spritesmith-main-5.png);background-position:-880px -440px;width:219px;height:219px}.quest_stressbeast_stables{background-image:url(spritesmith-main-5.png);background-position:-880px -220px;width:219px;height:219px}.quest_trex{background-image:url(spritesmith-main-5.png);background-position:-1322px -175px;width:204px;height:177px}.quest_trex_undead{background-image:url(spritesmith-main-5.png);background-position:-1091px -892px;width:216px;height:177px}.quest_vice1{background-image:url(spritesmith-main-5.png);background-position:-217px -1070px;width:216px;height:177px}.quest_vice2{background-image:url(spritesmith-main-5.png);background-position:-510px -1402px;width:221px;height:39px}.quest_vice2_lightCrystal{background-image:url(spritesmith-main-5.png);background-position:-1676px -1047px;width:40px;height:40px}.quest_vice3{background-image:url(spritesmith-main-5.png);background-position:-223px -892px;width:216px;height:177px}.quest_whale{background-image:url(spritesmith-main-5.png);background-position:-220px -232px;width:219px;height:219px}.shop_copper{background-image:url(spritesmith-main-5.png);background-position:-1685px -478px;width:32px;height:22px}.shop_eyes{background-image:url(spritesmith-main-5.png);background-position:-1676px -1411px;width:40px;height:40px}.shop_gold{background-image:url(spritesmith-main-5.png);background-position:-1685px -427px;width:32px;height:22px}.shop_opaquePotion{background-image:url(spritesmith-main-5.png);background-position:-1676px -1307px;width:40px;height:40px}.shop_potion{background-image:url(spritesmith-main-5.png);background-position:-1676px -1099px;width:40px;height:40px}.shop_reroll{background-image:url(spritesmith-main-5.png);background-position:-1676px -1203px;width:40px;height:40px}.shop_seafoam{background-image:url(spritesmith-main-5.png);background-position:-1685px -164px;width:32px;height:32px}.shop_shinySeed{background-image:url(spritesmith-main-5.png);background-position:-1685px -286px;width:32px;height:32px}.shop_silver{background-image:url(spritesmith-main-5.png);background-position:-1685px -506px;width:32px;height:22px}.shop_snowball{background-image:url(spritesmith-main-5.png);background-position:-1685px -197px;width:32px;height:32px}.shop_spookDust{background-image:url(spritesmith-main-5.png);background-position:-1685px -341px;width:32px;height:32px}.Pet_Egg_BearCub{background-image:url(spritesmith-main-5.png);background-position:-1718px -1040px;width:48px;height:51px}.Pet_Egg_Bunny{background-image:url(spritesmith-main-5.png);background-position:-1718px -1092px;width:48px;height:51px}.Pet_Egg_Cactus{background-image:url(spritesmith-main-5.png);background-position:-1718px -1144px;width:48px;height:51px}.Pet_Egg_Cheetah{background-image:url(spritesmith-main-5.png);background-position:-1718px -1196px;width:48px;height:51px}.Pet_Egg_Cuttlefish{background-image:url(spritesmith-main-5.png);background-position:-1718px -1248px;width:48px;height:51px}.Pet_Egg_Deer{background-image:url(spritesmith-main-5.png);background-position:-1718px -1300px;width:48px;height:51px}.Pet_Egg_Dragon{background-image:url(spritesmith-main-5.png);background-position:-1718px -1352px;width:48px;height:51px}.Pet_Egg_Egg{background-image:url(spritesmith-main-5.png);background-position:-1718px -1404px;width:48px;height:51px}.Pet_Egg_FlyingPig{background-image:url(spritesmith-main-5.png);background-position:-1718px -1456px;width:48px;height:51px}.Pet_Egg_Fox{background-image:url(spritesmith-main-5.png);background-position:-1718px -1508px;width:48px;height:51px}.Pet_Egg_Frog{background-image:url(spritesmith-main-5.png);background-position:-1718px -1560px;width:48px;height:51px}.Pet_Egg_Gryphon{background-image:url(spritesmith-main-5.png);background-position:-1718px -1612px;width:48px;height:51px}.Pet_Egg_Hedgehog{background-image:url(spritesmith-main-5.png);background-position:-1718px -1664px;width:48px;height:51px}.Pet_Egg_Horse{background-image:url(spritesmith-main-5.png);background-position:-1767px 0;width:48px;height:51px}.Pet_Egg_LionCub{background-image:url(spritesmith-main-5.png);background-position:-1767px -52px;width:48px;height:51px}.Pet_Egg_Octopus{background-image:url(spritesmith-main-5.png);background-position:-1767px -104px;width:48px;height:51px}.Pet_Egg_Owl{background-image:url(spritesmith-main-5.png);background-position:-1767px -156px;width:48px;height:51px}.Pet_Egg_PandaCub{background-image:url(spritesmith-main-5.png);background-position:-1767px -208px;width:48px;height:51px}.Pet_Egg_Parrot{background-image:url(spritesmith-main-5.png);background-position:-1767px -260px;width:48px;height:51px}.Pet_Egg_Penguin{background-image:url(spritesmith-main-5.png);background-position:-1767px -312px;width:48px;height:51px}.Pet_Egg_PolarBear{background-image:url(spritesmith-main-5.png);background-position:-1767px -364px;width:48px;height:51px}.Pet_Egg_Rat{background-image:url(spritesmith-main-5.png);background-position:-1767px -416px;width:48px;height:51px}.Pet_Egg_Rock{background-image:url(spritesmith-main-5.png);background-position:-1767px -468px;width:48px;height:51px}.Pet_Egg_Rooster{background-image:url(spritesmith-main-5.png);background-position:-1767px -520px;width:48px;height:51px}.Pet_Egg_Seahorse{background-image:url(spritesmith-main-5.png);background-position:-1767px -572px;width:48px;height:51px}.Pet_Egg_Sheep{background-image:url(spritesmith-main-5.png);background-position:-1767px -624px;width:48px;height:51px}.Pet_Egg_Slime{background-image:url(spritesmith-main-5.png);background-position:-1767px -676px;width:48px;height:51px}.Pet_Egg_Snake{background-image:url(spritesmith-main-5.png);background-position:-1767px -728px;width:48px;height:51px}.Pet_Egg_Spider{background-image:url(spritesmith-main-5.png);background-position:-1767px -780px;width:48px;height:51px}.Pet_Egg_TRex{background-image:url(spritesmith-main-5.png);background-position:-1767px -832px;width:48px;height:51px}.Pet_Egg_TigerCub{background-image:url(spritesmith-main-5.png);background-position:-1767px -884px;width:48px;height:51px}.Pet_Egg_Whale{background-image:url(spritesmith-main-5.png);background-position:-1767px -936px;width:48px;height:51px}.Pet_Egg_Wolf{background-image:url(spritesmith-main-5.png);background-position:-1767px -988px;width:48px;height:51px}.Pet_Food_Cake_Base{background-image:url(spritesmith-main-5.png);background-position:-1767px -1619px;width:43px;height:43px}.Pet_Food_Cake_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1767px -1663px;width:42px;height:44px}.Pet_Food_Cake_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1767px -1438px;width:43px;height:45px}.Pet_Food_Cake_Desert{background-image:url(spritesmith-main-5.png);background-position:-1767px -1529px;width:43px;height:44px}.Pet_Food_Cake_Golden{background-image:url(spritesmith-main-5.png);background-position:-1627px -1567px;width:43px;height:42px}.Pet_Food_Cake_Red{background-image:url(spritesmith-main-5.png);background-position:-1767px -1574px;width:43px;height:44px}.Pet_Food_Cake_Shade{background-image:url(spritesmith-main-5.png);background-position:-1767px -1484px;width:43px;height:44px}.Pet_Food_Cake_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1767px -1345px;width:42px;height:47px}.Pet_Food_Cake_White{background-image:url(spritesmith-main-5.png);background-position:-1767px -1393px;width:44px;height:44px}.Pet_Food_Cake_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1767px -1300px;width:45px;height:44px}.Pet_Food_Candy_Base{background-image:url(spritesmith-main-5.png);background-position:-1767px -1196px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1767px -1144px;width:48px;height:51px}.Pet_Food_Candy_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1767px -1092px;width:48px;height:51px}.Pet_Food_Candy_Desert{background-image:url(spritesmith-main-5.png);background-position:-1767px -1040px;width:48px;height:51px}.Pet_Food_Candy_Golden{background-image:url(spritesmith-main-5.png);background-position:-1718px -988px;width:48px;height:51px}.Pet_Food_Candy_Red{background-image:url(spritesmith-main-5.png);background-position:-1718px -936px;width:48px;height:51px}.Pet_Food_Candy_Shade{background-image:url(spritesmith-main-5.png);background-position:-1718px -884px;width:48px;height:51px}.Pet_Food_Candy_Skeleton{background-image:url(spritesmith-main-5.png);background-position:-1718px -832px;width:48px;height:51px}.Pet_Food_Candy_White{background-image:url(spritesmith-main-5.png);background-position:-1718px -780px;width:48px;height:51px}.Pet_Food_Candy_Zombie{background-image:url(spritesmith-main-5.png);background-position:-1718px -728px;width:48px;height:51px}.Pet_Food_Chocolate{background-image:url(spritesmith-main-5.png);background-position:-1718px -676px;width:48px;height:51px}.Pet_Food_CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-1718px -624px;width:48px;height:51px}.Pet_Food_CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-1718px -572px;width:48px;height:51px}.Pet_Food_Fish{background-image:url(spritesmith-main-5.png);background-position:-1718px -520px;width:48px;height:51px}.Pet_Food_Honey{background-image:url(spritesmith-main-5.png);background-position:-1718px -468px;width:48px;height:51px}.Pet_Food_Meat{background-image:url(spritesmith-main-5.png);background-position:-1718px -312px;width:48px;height:51px}.Pet_Food_Milk{background-image:url(spritesmith-main-5.png);background-position:-980px -1666px;width:48px;height:51px}.Pet_Food_Potatoe{background-image:url(spritesmith-main-5.png);background-position:-931px -1666px;width:48px;height:51px}.Pet_Food_RottenMeat{background-image:url(spritesmith-main-5.png);background-position:-343px -1666px;width:48px;height:51px}.Pet_Food_Saddle{background-image:url(spritesmith-main-5.png);background-position:-294px -1666px;width:48px;height:51px}.Pet_Food_Strawberry{background-image:url(spritesmith-main-5.png);background-position:-196px -1666px;width:48px;height:51px}.Mount_Body_BearCub-Base{background-image:url(spritesmith-main-5.png);background-position:-1037px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-5.png);background-position:-825px -1251px;width:105px;height:105px}.Mount_Body_BearCub-CottonCandyPink{background-image:url(spritesmith-main-5.png);background-position:-719px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Desert{background-image:url(spritesmith-main-5.png);background-position:-931px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Golden{background-image:url(spritesmith-main-5.png);background-position:-613px -1251px;width:105px;height:105px}.Mount_Body_BearCub-Polar{background-image:url(spritesmith-main-6.png);background-position:-424px -575px;width:105px;height:105px}.Mount_Body_BearCub-Red{background-image:url(spritesmith-main-6.png);background-position:-318px -1105px;width:105px;height:105px}.Mount_Body_BearCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -575px;width:105px;height:105px}.Mount_Body_BearCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -575px;width:105px;height:105px}.Mount_Body_BearCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-742px 0;width:105px;height:105px}.Mount_Body_BearCub-White{background-image:url(spritesmith-main-6.png);background-position:-742px -106px;width:105px;height:105px}.Mount_Body_BearCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-742px -212px;width:105px;height:105px}.Mount_Body_Bunny-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -318px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-742px -424px;width:105px;height:105px}.Mount_Body_Bunny-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-742px -530px;width:105px;height:105px}.Mount_Body_Bunny-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -681px;width:105px;height:105px}.Mount_Body_Bunny-Golden{background-image:url(spritesmith-main-6.png);background-position:-318px -999px;width:105px;height:105px}.Mount_Body_Bunny-Red{background-image:url(spritesmith-main-6.png);background-position:-424px -999px;width:105px;height:105px}.Mount_Body_Bunny-Shade{background-image:url(spritesmith-main-6.png);background-position:-530px -999px;width:105px;height:105px}.Mount_Body_Bunny-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-636px -999px;width:105px;height:105px}.Mount_Body_Bunny-White{background-image:url(spritesmith-main-6.png);background-position:-742px -999px;width:105px;height:105px}.Mount_Body_Bunny-Zombie{background-image:url(spritesmith-main-6.png);background-position:-848px -999px;width:105px;height:105px}.Mount_Body_Cactus-Base{background-image:url(spritesmith-main-6.png);background-position:-954px -999px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px -999px;width:105px;height:105px}.Mount_Body_Cactus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px 0;width:105px;height:105px}.Mount_Body_Cactus-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -106px;width:105px;height:105px}.Mount_Body_Cactus-Golden{background-image:url(spritesmith-main-6.png);background-position:-954px -1211px;width:105px;height:105px}.Mount_Body_Cactus-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px 0;width:105px;height:105px}.Mount_Body_Cactus-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -106px;width:105px;height:105px}.Mount_Body_Cactus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-530px -221px;width:105px;height:105px}.Mount_Body_Cactus-Spooky{background-image:url(spritesmith-main-6.png);background-position:-530px -327px;width:105px;height:105px}.Mount_Body_Cactus-White{background-image:url(spritesmith-main-6.png);background-position:-221px -469px;width:105px;height:105px}.Mount_Body_Cactus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-327px -469px;width:105px;height:105px}.Mount_Body_Cheetah-Base{background-image:url(spritesmith-main-6.png);background-position:-433px -469px;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-636px 0;width:105px;height:105px}.Mount_Body_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-636px -106px;width:105px;height:105px}.Mount_Body_Cheetah-Desert{background-image:url(spritesmith-main-6.png);background-position:-636px -212px;width:105px;height:105px}.Mount_Body_Cheetah-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -318px;width:105px;height:105px}.Mount_Body_Cheetah-Red{background-image:url(spritesmith-main-6.png);background-position:-636px -424px;width:105px;height:105px}.Mount_Body_Cheetah-Shade{background-image:url(spritesmith-main-6.png);background-position:0 -575px;width:105px;height:105px}.Mount_Body_Cheetah-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-106px -575px;width:105px;height:105px}.Mount_Body_Cheetah-White{background-image:url(spritesmith-main-6.png);background-position:-212px -575px;width:105px;height:105px}.Mount_Body_Cheetah-Zombie{background-image:url(spritesmith-main-6.png);background-position:-318px -575px;width:105px;height:105px}.Mount_Body_Cuttlefish-Base{background-image:url(spritesmith-main-6.png);background-position:-530px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-318px -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -124px;width:105px;height:114px}.Mount_Body_Cuttlefish-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px 0;width:105px;height:114px}.Mount_Body_Cuttlefish-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -115px;width:105px;height:114px}.Mount_Body_Cuttlefish-White{background-image:url(spritesmith-main-6.png);background-position:0 -239px;width:105px;height:114px}.Mount_Body_Cuttlefish-Zombie{background-image:url(spritesmith-main-6.png);background-position:-106px -239px;width:105px;height:114px}.Mount_Body_Deer-Base{background-image:url(spritesmith-main-6.png);background-position:-106px -681px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-212px -681px;width:105px;height:105px}.Mount_Body_Deer-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-318px -681px;width:105px;height:105px}.Mount_Body_Deer-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -681px;width:105px;height:105px}.Mount_Body_Deer-Golden{background-image:url(spritesmith-main-6.png);background-position:-530px -681px;width:105px;height:105px}.Mount_Body_Deer-Red{background-image:url(spritesmith-main-6.png);background-position:-636px -681px;width:105px;height:105px}.Mount_Body_Deer-Shade{background-image:url(spritesmith-main-6.png);background-position:-742px -681px;width:105px;height:105px}.Mount_Body_Deer-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-848px 0;width:105px;height:105px}.Mount_Body_Deer-White{background-image:url(spritesmith-main-6.png);background-position:-848px -106px;width:105px;height:105px}.Mount_Body_Deer-Zombie{background-image:url(spritesmith-main-6.png);background-position:-848px -212px;width:105px;height:105px}.Mount_Body_Dragon-Base{background-image:url(spritesmith-main-6.png);background-position:-848px -318px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -424px;width:105px;height:105px}.Mount_Body_Dragon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-848px -530px;width:105px;height:105px}.Mount_Body_Dragon-Desert{background-image:url(spritesmith-main-6.png);background-position:-848px -636px;width:105px;height:105px}.Mount_Body_Dragon-Golden{background-image:url(spritesmith-main-6.png);background-position:0 -787px;width:105px;height:105px}.Mount_Body_Dragon-Red{background-image:url(spritesmith-main-6.png);background-position:-106px -787px;width:105px;height:105px}.Mount_Body_Dragon-Shade{background-image:url(spritesmith-main-6.png);background-position:-212px -787px;width:105px;height:105px}.Mount_Body_Dragon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-318px -787px;width:105px;height:105px}.Mount_Body_Dragon-Spooky{background-image:url(spritesmith-main-6.png);background-position:-424px -787px;width:105px;height:105px}.Mount_Body_Dragon-White{background-image:url(spritesmith-main-6.png);background-position:-530px -787px;width:105px;height:105px}.Mount_Body_Dragon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-636px -787px;width:105px;height:105px}.Mount_Body_Egg-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -787px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -787px;width:105px;height:105px}.Mount_Body_Egg-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px 0;width:105px;height:105px}.Mount_Body_Egg-Desert{background-image:url(spritesmith-main-6.png);background-position:-954px -106px;width:105px;height:105px}.Mount_Body_Egg-Golden{background-image:url(spritesmith-main-6.png);background-position:-954px -212px;width:105px;height:105px}.Mount_Body_Egg-Red{background-image:url(spritesmith-main-6.png);background-position:-954px -318px;width:105px;height:105px}.Mount_Body_Egg-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -424px;width:105px;height:105px}.Mount_Body_Egg-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-954px -530px;width:105px;height:105px}.Mount_Body_Egg-White{background-image:url(spritesmith-main-6.png);background-position:-954px -636px;width:105px;height:105px}.Mount_Body_Egg-Zombie{background-image:url(spritesmith-main-6.png);background-position:-954px -742px;width:105px;height:105px}.Mount_Body_FlyingPig-Base{background-image:url(spritesmith-main-6.png);background-position:0 -893px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-212px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Desert{background-image:url(spritesmith-main-6.png);background-position:-318px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Red{background-image:url(spritesmith-main-6.png);background-position:-530px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Shade{background-image:url(spritesmith-main-6.png);background-position:-636px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-742px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Spooky{background-image:url(spritesmith-main-6.png);background-position:-848px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-White{background-image:url(spritesmith-main-6.png);background-position:-954px -893px;width:105px;height:105px}.Mount_Body_FlyingPig-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1060px 0;width:105px;height:105px}.Mount_Body_Fox-Base{background-image:url(spritesmith-main-6.png);background-position:-1060px -106px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1060px -212px;width:105px;height:105px}.Mount_Body_Fox-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1060px -318px;width:105px;height:105px}.Mount_Body_Fox-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -424px;width:105px;height:105px}.Mount_Body_Fox-Golden{background-image:url(spritesmith-main-6.png);background-position:-1060px -530px;width:105px;height:105px}.Mount_Body_Fox-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px -636px;width:105px;height:105px}.Mount_Body_Fox-Shade{background-image:url(spritesmith-main-6.png);background-position:-1060px -742px;width:105px;height:105px}.Mount_Body_Fox-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1060px -848px;width:105px;height:105px}.Mount_Body_Fox-Spooky{background-image:url(spritesmith-main-6.png);background-position:0 -999px;width:105px;height:105px}.Mount_Body_Fox-White{background-image:url(spritesmith-main-6.png);background-position:-106px -999px;width:105px;height:105px}.Mount_Body_Fox-Zombie{background-image:url(spritesmith-main-6.png);background-position:-212px -999px;width:105px;height:105px}.Mount_Body_Frog-Base{background-image:url(spritesmith-main-6.png);background-position:-212px -239px;width:105px;height:114px}.Mount_Body_Frog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-106px 0;width:105px;height:114px}.Mount_Body_Frog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-424px 0;width:105px;height:114px}.Mount_Body_Frog-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -115px;width:105px;height:114px}.Mount_Body_Frog-Golden{background-image:url(spritesmith-main-6.png);background-position:-424px -230px;width:105px;height:114px}.Mount_Body_Frog-Red{background-image:url(spritesmith-main-6.png);background-position:0 -354px;width:105px;height:114px}.Mount_Body_Frog-Shade{background-image:url(spritesmith-main-6.png);background-position:-106px -354px;width:105px;height:114px}.Mount_Body_Frog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-212px -354px;width:105px;height:114px}.Mount_Body_Frog-White{background-image:url(spritesmith-main-6.png);background-position:-318px -354px;width:105px;height:114px}.Mount_Body_Frog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-424px -354px;width:105px;height:114px}.Mount_Body_Gryphon-Base{background-image:url(spritesmith-main-6.png);background-position:-1166px -212px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1166px -318px;width:105px;height:105px}.Mount_Body_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1166px -424px;width:105px;height:105px}.Mount_Body_Gryphon-Desert{background-image:url(spritesmith-main-6.png);background-position:-1166px -530px;width:105px;height:105px}.Mount_Body_Gryphon-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -636px;width:105px;height:105px}.Mount_Body_Gryphon-Red{background-image:url(spritesmith-main-6.png);background-position:-1166px -742px;width:105px;height:105px}.Mount_Body_Gryphon-RoyalPurple{background-image:url(spritesmith-main-6.png);background-position:-1166px -848px;width:105px;height:105px}.Mount_Body_Gryphon-Shade{background-image:url(spritesmith-main-6.png);background-position:-1166px -954px;width:105px;height:105px}.Mount_Body_Gryphon-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -1105px;width:105px;height:105px}.Mount_Body_Gryphon-White{background-image:url(spritesmith-main-6.png);background-position:-106px -1105px;width:105px;height:105px}.Mount_Body_Gryphon-Zombie{background-image:url(spritesmith-main-6.png);background-position:-212px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Base{background-image:url(spritesmith-main-6.png);background-position:-530px -115px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-424px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-530px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Desert{background-image:url(spritesmith-main-6.png);background-position:-636px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Golden{background-image:url(spritesmith-main-6.png);background-position:-742px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Red{background-image:url(spritesmith-main-6.png);background-position:-848px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Shade{background-image:url(spritesmith-main-6.png);background-position:-954px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1060px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -1105px;width:105px;height:105px}.Mount_Body_Hedgehog-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px 0;width:105px;height:105px}.Mount_Body_Horse-Base{background-image:url(spritesmith-main-6.png);background-position:-1272px -106px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -212px;width:105px;height:105px}.Mount_Body_Horse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1272px -318px;width:105px;height:105px}.Mount_Body_Horse-Desert{background-image:url(spritesmith-main-6.png);background-position:-1272px -424px;width:105px;height:105px}.Mount_Body_Horse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1272px -530px;width:105px;height:105px}.Mount_Body_Horse-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -636px;width:105px;height:105px}.Mount_Body_Horse-Shade{background-image:url(spritesmith-main-6.png);background-position:-1272px -742px;width:105px;height:105px}.Mount_Body_Horse-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1272px -848px;width:105px;height:105px}.Mount_Body_Horse-White{background-image:url(spritesmith-main-6.png);background-position:-1272px -954px;width:105px;height:105px}.Mount_Body_Horse-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -1060px;width:105px;height:105px}.Mount_Body_JackOLantern-Base{background-image:url(spritesmith-main-6.png);background-position:-1696px -530px;width:90px;height:105px}.Mount_Body_LionCub-Base{background-image:url(spritesmith-main-6.png);background-position:-106px -1211px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-212px -1211px;width:105px;height:105px}.Mount_Body_LionCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-318px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Ethereal{background-image:url(spritesmith-main-6.png);background-position:-530px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-636px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Red{background-image:url(spritesmith-main-6.png);background-position:-742px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-848px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:0 -469px;width:111px;height:105px}.Mount_Body_LionCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1060px -1211px;width:105px;height:105px}.Mount_Body_LionCub-White{background-image:url(spritesmith-main-6.png);background-position:-1166px -1211px;width:105px;height:105px}.Mount_Body_LionCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1272px -1211px;width:105px;height:105px}.Mount_Body_Mammoth-Base{background-image:url(spritesmith-main-6.png);background-position:0 0;width:105px;height:123px}.Mount_Body_MantisShrimp-Base{background-image:url(spritesmith-main-6.png);background-position:-112px -469px;width:108px;height:105px}.Mount_Body_Octopus-Base{background-image:url(spritesmith-main-6.png);background-position:-1378px -212px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1378px -318px;width:105px;height:105px}.Mount_Body_Octopus-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -424px;width:105px;height:105px}.Mount_Body_Octopus-Desert{background-image:url(spritesmith-main-6.png);background-position:-1378px -530px;width:105px;height:105px}.Mount_Body_Octopus-Golden{background-image:url(spritesmith-main-6.png);background-position:-1378px -636px;width:105px;height:105px}.Mount_Body_Octopus-Red{background-image:url(spritesmith-main-6.png);background-position:-1378px -742px;width:105px;height:105px}.Mount_Body_Octopus-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -848px;width:105px;height:105px}.Mount_Body_Octopus-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1378px -954px;width:105px;height:105px}.Mount_Body_Octopus-White{background-image:url(spritesmith-main-6.png);background-position:-1378px -1060px;width:105px;height:105px}.Mount_Body_Octopus-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1378px -1166px;width:105px;height:105px}.Mount_Body_Orca-Base{background-image:url(spritesmith-main-6.png);background-position:0 -1317px;width:105px;height:105px}.Mount_Body_Owl-Base{background-image:url(spritesmith-main-6.png);background-position:-106px -1317px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-212px -1317px;width:105px;height:105px}.Mount_Body_Owl-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-318px -1317px;width:105px;height:105px}.Mount_Body_Owl-Desert{background-image:url(spritesmith-main-6.png);background-position:-424px -1317px;width:105px;height:105px}.Mount_Body_Owl-Golden{background-image:url(spritesmith-main-6.png);background-position:-530px -1317px;width:105px;height:105px}.Mount_Body_Owl-Red{background-image:url(spritesmith-main-6.png);background-position:-636px -1317px;width:105px;height:105px}.Mount_Body_Owl-Shade{background-image:url(spritesmith-main-6.png);background-position:-742px -1317px;width:105px;height:105px}.Mount_Body_Owl-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-848px -1317px;width:105px;height:105px}.Mount_Body_Owl-White{background-image:url(spritesmith-main-6.png);background-position:-954px -1317px;width:105px;height:105px}.Mount_Body_Owl-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1060px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Base{background-image:url(spritesmith-main-6.png);background-position:-1166px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1272px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1378px -1317px;width:105px;height:105px}.Mount_Body_PandaCub-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px 0;width:105px;height:105px}.Mount_Body_PandaCub-Golden{background-image:url(spritesmith-main-6.png);background-position:-1484px -106px;width:105px;height:105px}.Mount_Body_PandaCub-Red{background-image:url(spritesmith-main-6.png);background-position:-1484px -212px;width:105px;height:105px}.Mount_Body_PandaCub-Shade{background-image:url(spritesmith-main-6.png);background-position:-1484px -318px;width:105px;height:105px}.Mount_Body_PandaCub-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -424px;width:105px;height:105px}.Mount_Body_PandaCub-Spooky{background-image:url(spritesmith-main-6.png);background-position:-1484px -530px;width:105px;height:105px}.Mount_Body_PandaCub-White{background-image:url(spritesmith-main-6.png);background-position:-1484px -636px;width:105px;height:105px}.Mount_Body_PandaCub-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -742px;width:105px;height:105px}.Mount_Body_Parrot-Base{background-image:url(spritesmith-main-6.png);background-position:-1484px -848px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1484px -954px;width:105px;height:105px}.Mount_Body_Parrot-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1484px -1060px;width:105px;height:105px}.Mount_Body_Parrot-Desert{background-image:url(spritesmith-main-6.png);background-position:-1484px -1166px;width:105px;height:105px}.Mount_Body_Parrot-Golden{background-image:url(spritesmith-main-6.png);background-position:-1484px -1272px;width:105px;height:105px}.Mount_Body_Parrot-Red{background-image:url(spritesmith-main-6.png);background-position:0 -1423px;width:105px;height:105px}.Mount_Body_Parrot-Shade{background-image:url(spritesmith-main-6.png);background-position:-106px -1423px;width:105px;height:105px}.Mount_Body_Parrot-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-212px -1423px;width:105px;height:105px}.Mount_Body_Parrot-White{background-image:url(spritesmith-main-6.png);background-position:-318px -1423px;width:105px;height:105px}.Mount_Body_Parrot-Zombie{background-image:url(spritesmith-main-6.png);background-position:-424px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Base{background-image:url(spritesmith-main-6.png);background-position:-530px -1423px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-636px -1423px;width:105px;height:105px}.Mount_Body_Penguin-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-742px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Desert{background-image:url(spritesmith-main-6.png);background-position:-848px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Golden{background-image:url(spritesmith-main-6.png);background-position:-954px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Red{background-image:url(spritesmith-main-6.png);background-position:-1060px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Shade{background-image:url(spritesmith-main-6.png);background-position:-1166px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1272px -1423px;width:105px;height:105px}.Mount_Body_Penguin-White{background-image:url(spritesmith-main-6.png);background-position:-1378px -1423px;width:105px;height:105px}.Mount_Body_Penguin-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1484px -1423px;width:105px;height:105px}.Mount_Body_Phoenix-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px 0;width:105px;height:105px}.Mount_Body_Rat-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px -106px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -212px;width:105px;height:105px}.Mount_Body_Rat-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px -318px;width:105px;height:105px}.Mount_Body_Rat-Desert{background-image:url(spritesmith-main-6.png);background-position:-1590px -424px;width:105px;height:105px}.Mount_Body_Rat-Golden{background-image:url(spritesmith-main-6.png);background-position:-1590px -530px;width:105px;height:105px}.Mount_Body_Rat-Red{background-image:url(spritesmith-main-6.png);background-position:-1590px -636px;width:105px;height:105px}.Mount_Body_Rat-Shade{background-image:url(spritesmith-main-6.png);background-position:-1590px -742px;width:105px;height:105px}.Mount_Body_Rat-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1590px -848px;width:105px;height:105px}.Mount_Body_Rat-White{background-image:url(spritesmith-main-6.png);background-position:-1590px -954px;width:105px;height:105px}.Mount_Body_Rat-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1590px -1060px;width:105px;height:105px}.Mount_Body_Rock-Base{background-image:url(spritesmith-main-6.png);background-position:-1590px -1166px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1590px -1272px;width:105px;height:105px}.Mount_Body_Rock-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1590px -1378px;width:105px;height:105px}.Mount_Body_Rock-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -1529px;width:105px;height:105px}.Mount_Body_Rock-Golden{background-image:url(spritesmith-main-6.png);background-position:-106px -1529px;width:105px;height:105px}.Mount_Body_Rock-Red{background-image:url(spritesmith-main-6.png);background-position:-212px -1529px;width:105px;height:105px}.Mount_Body_Rock-Shade{background-image:url(spritesmith-main-6.png);background-position:-318px -1529px;width:105px;height:105px}.Mount_Body_Rock-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-424px -1529px;width:105px;height:105px}.Mount_Body_Rock-White{background-image:url(spritesmith-main-6.png);background-position:-530px -1529px;width:105px;height:105px}.Mount_Body_Rock-Zombie{background-image:url(spritesmith-main-6.png);background-position:-636px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Base{background-image:url(spritesmith-main-6.png);background-position:-742px -1529px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-848px -1529px;width:105px;height:105px}.Mount_Body_Rooster-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-954px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Desert{background-image:url(spritesmith-main-6.png);background-position:-1060px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Golden{background-image:url(spritesmith-main-6.png);background-position:-1166px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Red{background-image:url(spritesmith-main-6.png);background-position:-1272px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Shade{background-image:url(spritesmith-main-6.png);background-position:-1378px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Skeleton{background-image:url(spritesmith-main-6.png);background-position:-1484px -1529px;width:105px;height:105px}.Mount_Body_Rooster-White{background-image:url(spritesmith-main-6.png);background-position:-1590px -1529px;width:105px;height:105px}.Mount_Body_Rooster-Zombie{background-image:url(spritesmith-main-6.png);background-position:-1696px 0;width:105px;height:105px}.Mount_Body_Seahorse-Base{background-image:url(spritesmith-main-6.png);background-position:-1696px -106px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-6.png);background-position:-1696px -212px;width:105px;height:105px}.Mount_Body_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-6.png);background-position:-1696px -318px;width:105px;height:105px}.Mount_Body_Seahorse-Desert{background-image:url(spritesmith-main-6.png);background-position:0 -1211px;width:105px;height:105px}.Mount_Body_Seahorse-Golden{background-image:url(spritesmith-main-6.png);background-position:-1696px -424px;width:105px;height:105px}.Mount_Body_Seahorse-Red{background-image:url(spritesmith-main-7.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Body_Seahorse-Shade{background-image:url(spritesmith-main-7.png);background-position:-848px -1113px;width:105px;height:105px}.Mount_Body_Seahorse-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Body_Seahorse-White{background-image:url(spritesmith-main-7.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Body_Seahorse-Zombie{background-image:url(spritesmith-main-7.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Body_Sheep-Base{background-image:url(spritesmith-main-7.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Body_Sheep-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -795px;width:105px;height:105px}.Mount_Body_Sheep-Desert{background-image:url(spritesmith-main-7.png);background-position:-106px -795px;width:105px;height:105px}.Mount_Body_Sheep-Golden{background-image:url(spritesmith-main-7.png);background-position:-212px -795px;width:105px;height:105px}.Mount_Body_Sheep-Red{background-image:url(spritesmith-main-7.png);background-position:-318px -795px;width:105px;height:105px}.Mount_Body_Sheep-Shade{background-image:url(spritesmith-main-7.png);background-position:-954px -901px;width:105px;height:105px}.Mount_Body_Sheep-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Body_Sheep-White{background-image:url(spritesmith-main-7.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Body_Sheep-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Body_Slime-Base{background-image:url(spritesmith-main-7.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Body_Slime-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Body_Slime-Desert{background-image:url(spritesmith-main-7.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Body_Slime-Golden{background-image:url(spritesmith-main-7.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Body_Slime-Red{background-image:url(spritesmith-main-7.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Body_Slime-Shade{background-image:url(spritesmith-main-7.png);background-position:0 -1007px;width:105px;height:105px}.Mount_Body_Slime-Skeleton{background-image:url(spritesmith-main-7.png);background-position:0 -1219px;width:105px;height:105px}.Mount_Body_Slime-White{background-image:url(spritesmith-main-7.png);background-position:-106px -1219px;width:105px;height:105px}.Mount_Body_Slime-Zombie{background-image:url(spritesmith-main-7.png);background-position:-212px -1219px;width:105px;height:105px}.Mount_Body_Snake-Base{background-image:url(spritesmith-main-7.png);background-position:-318px -1219px;width:105px;height:105px}.Mount_Body_Snake-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-424px -1219px;width:105px;height:105px}.Mount_Body_Snake-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-530px -1219px;width:105px;height:105px}.Mount_Body_Snake-Desert{background-image:url(spritesmith-main-7.png);background-position:-636px -1219px;width:105px;height:105px}.Mount_Body_Snake-Golden{background-image:url(spritesmith-main-7.png);background-position:-742px -1219px;width:105px;height:105px}.Mount_Body_Snake-Red{background-image:url(spritesmith-main-7.png);background-position:-848px -1219px;width:105px;height:105px}.Mount_Body_Snake-Shade{background-image:url(spritesmith-main-7.png);background-position:-954px -1219px;width:105px;height:105px}.Mount_Body_Snake-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1166px -1431px;width:105px;height:105px}.Mount_Body_Snake-White{background-image:url(spritesmith-main-7.png);background-position:-1272px -1431px;width:105px;height:105px}.Mount_Body_Snake-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1378px -1431px;width:105px;height:105px}.Mount_Body_Spider-Base{background-image:url(spritesmith-main-7.png);background-position:-1484px -1431px;width:105px;height:105px}.Mount_Body_Spider-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1634px 0;width:105px;height:105px}.Mount_Body_Spider-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1634px -106px;width:105px;height:105px}.Mount_Body_Spider-Desert{background-image:url(spritesmith-main-7.png);background-position:-1634px -212px;width:105px;height:105px}.Mount_Body_Spider-Golden{background-image:url(spritesmith-main-7.png);background-position:-1634px -318px;width:105px;height:105px}.Mount_Body_Spider-Red{background-image:url(spritesmith-main-7.png);background-position:-1634px -424px;width:105px;height:105px}.Mount_Body_Spider-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -530px;width:105px;height:105px}.Mount_Body_Spider-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Body_Spider-White{background-image:url(spritesmith-main-7.png);background-position:-742px -680px;width:105px;height:105px}.Mount_Body_Spider-Zombie{background-image:url(spritesmith-main-7.png);background-position:-892px 0;width:105px;height:105px}.Mount_Body_TRex-Base{background-image:url(spritesmith-main-7.png);background-position:0 -544px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Body_TRex-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -136px;width:135px;height:135px}.Mount_Body_TRex-Desert{background-image:url(spritesmith-main-7.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Body_TRex-Golden{background-image:url(spritesmith-main-7.png);background-position:-272px 0;width:135px;height:135px}.Mount_Body_TRex-Red{background-image:url(spritesmith-main-7.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Body_TRex-Shade{background-image:url(spritesmith-main-7.png);background-position:0 -272px;width:135px;height:135px}.Mount_Body_TRex-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Body_TRex-White{background-image:url(spritesmith-main-7.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Body_TRex-Zombie{background-image:url(spritesmith-main-7.png);background-position:-408px 0;width:135px;height:135px}.Mount_Body_TigerCub-Base{background-image:url(spritesmith-main-7.png);background-position:-424px -795px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-530px -795px;width:105px;height:105px}.Mount_Body_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-636px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-742px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-848px -795px;width:105px;height:105px}.Mount_Body_TigerCub-Red{background-image:url(spritesmith-main-7.png);background-position:-998px 0;width:105px;height:105px}.Mount_Body_TigerCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Body_TigerCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Body_TigerCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Body_TigerCub-White{background-image:url(spritesmith-main-7.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Body_TigerCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Body_Turkey-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Body_Whale-Base{background-image:url(spritesmith-main-7.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:0 -901px;width:105px;height:105px}.Mount_Body_Whale-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-106px -901px;width:105px;height:105px}.Mount_Body_Whale-Desert{background-image:url(spritesmith-main-7.png);background-position:-212px -901px;width:105px;height:105px}.Mount_Body_Whale-Golden{background-image:url(spritesmith-main-7.png);background-position:-318px -901px;width:105px;height:105px}.Mount_Body_Whale-Red{background-image:url(spritesmith-main-7.png);background-position:-424px -901px;width:105px;height:105px}.Mount_Body_Whale-Shade{background-image:url(spritesmith-main-7.png);background-position:-530px -901px;width:105px;height:105px}.Mount_Body_Whale-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-636px -901px;width:105px;height:105px}.Mount_Body_Whale-White{background-image:url(spritesmith-main-7.png);background-position:-742px -901px;width:105px;height:105px}.Mount_Body_Whale-Zombie{background-image:url(spritesmith-main-7.png);background-position:-848px -901px;width:105px;height:105px}.Mount_Body_Wolf-Base{background-image:url(spritesmith-main-7.png);background-position:0 0;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Body_Wolf-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:0 -408px;width:135px;height:135px}.Mount_Body_Wolf-Desert{background-image:url(spritesmith-main-7.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Body_Wolf-Golden{background-image:url(spritesmith-main-7.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Body_Wolf-Red{background-image:url(spritesmith-main-7.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Body_Wolf-Shade{background-image:url(spritesmith-main-7.png);background-position:-544px 0;width:135px;height:135px}.Mount_Body_Wolf-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Body_Wolf-Spooky{background-image:url(spritesmith-main-7.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Body_Wolf-White{background-image:url(spritesmith-main-7.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Body_Wolf-Zombie{background-image:url(spritesmith-main-7.png);background-position:-136px 0;width:135px;height:135px}.Mount_Head_BearCub-Base{background-image:url(spritesmith-main-7.png);background-position:-106px -1007px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-212px -1007px;width:105px;height:105px}.Mount_Head_BearCub-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-318px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Desert{background-image:url(spritesmith-main-7.png);background-position:-424px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Golden{background-image:url(spritesmith-main-7.png);background-position:-530px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Polar{background-image:url(spritesmith-main-7.png);background-position:-636px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Red{background-image:url(spritesmith-main-7.png);background-position:-742px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Shade{background-image:url(spritesmith-main-7.png);background-position:-848px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-954px -1007px;width:105px;height:105px}.Mount_Head_BearCub-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1060px -1007px;width:105px;height:105px}.Mount_Head_BearCub-White{background-image:url(spritesmith-main-7.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_BearCub-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Bunny-Base{background-image:url(spritesmith-main-7.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Bunny-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Bunny-Desert{background-image:url(spritesmith-main-7.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Bunny-Golden{background-image:url(spritesmith-main-7.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Bunny-Red{background-image:url(spritesmith-main-7.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Bunny-Shade{background-image:url(spritesmith-main-7.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Bunny-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Bunny-White{background-image:url(spritesmith-main-7.png);background-position:0 -1113px;width:105px;height:105px}.Mount_Head_Bunny-Zombie{background-image:url(spritesmith-main-7.png);background-position:-106px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Base{background-image:url(spritesmith-main-7.png);background-position:-212px -1113px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-318px -1113px;width:105px;height:105px}.Mount_Head_Cactus-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-424px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Desert{background-image:url(spritesmith-main-7.png);background-position:-530px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Golden{background-image:url(spritesmith-main-7.png);background-position:-636px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Red{background-image:url(spritesmith-main-7.png);background-position:-742px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Shade{background-image:url(spritesmith-main-7.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_Cactus-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-954px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1060px -1113px;width:105px;height:105px}.Mount_Head_Cactus-White{background-image:url(spritesmith-main-7.png);background-position:-1166px -1113px;width:105px;height:105px}.Mount_Head_Cactus-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Cheetah-Base{background-image:url(spritesmith-main-7.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Cheetah-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Cheetah-Desert{background-image:url(spritesmith-main-7.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Cheetah-Golden{background-image:url(spritesmith-main-7.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Cheetah-Red{background-image:url(spritesmith-main-7.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Cheetah-Shade{background-image:url(spritesmith-main-7.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Cheetah-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Cheetah-White{background-image:url(spritesmith-main-7.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Cheetah-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Cuttlefish-Base{background-image:url(spritesmith-main-7.png);background-position:-242px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-348px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-454px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Desert{background-image:url(spritesmith-main-7.png);background-position:-560px -544px;width:105px;height:114px}.Mount_Head_Cuttlefish-Golden{background-image:url(spritesmith-main-7.png);background-position:-680px 0;width:105px;height:114px}.Mount_Head_Cuttlefish-Red{background-image:url(spritesmith-main-7.png);background-position:-680px -115px;width:105px;height:114px}.Mount_Head_Cuttlefish-Shade{background-image:url(spritesmith-main-7.png);background-position:-680px -230px;width:105px;height:114px}.Mount_Head_Cuttlefish-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-680px -345px;width:105px;height:114px}.Mount_Head_Cuttlefish-White{background-image:url(spritesmith-main-7.png);background-position:-680px -460px;width:105px;height:114px}.Mount_Head_Cuttlefish-Zombie{background-image:url(spritesmith-main-7.png);background-position:-786px 0;width:105px;height:114px}.Mount_Head_Deer-Base{background-image:url(spritesmith-main-7.png);background-position:-1060px -1219px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1166px -1219px;width:105px;height:105px}.Mount_Head_Deer-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1272px -1219px;width:105px;height:105px}.Mount_Head_Deer-Desert{background-image:url(spritesmith-main-7.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_Deer-Golden{background-image:url(spritesmith-main-7.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_Deer-Red{background-image:url(spritesmith-main-7.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_Deer-Shade{background-image:url(spritesmith-main-7.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_Deer-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_Deer-White{background-image:url(spritesmith-main-7.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_Deer-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_Dragon-Base{background-image:url(spritesmith-main-7.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_Dragon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_Dragon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_Dragon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Dragon-Red{background-image:url(spritesmith-main-7.png);background-position:0 -1325px;width:105px;height:105px}.Mount_Head_Dragon-Shade{background-image:url(spritesmith-main-7.png);background-position:-106px -1325px;width:105px;height:105px}.Mount_Head_Dragon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-212px -1325px;width:105px;height:105px}.Mount_Head_Dragon-Spooky{background-image:url(spritesmith-main-7.png);background-position:-318px -1325px;width:105px;height:105px}.Mount_Head_Dragon-White{background-image:url(spritesmith-main-7.png);background-position:-424px -1325px;width:105px;height:105px}.Mount_Head_Dragon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-530px -1325px;width:105px;height:105px}.Mount_Head_Egg-Base{background-image:url(spritesmith-main-7.png);background-position:-636px -1325px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-742px -1325px;width:105px;height:105px}.Mount_Head_Egg-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-848px -1325px;width:105px;height:105px}.Mount_Head_Egg-Desert{background-image:url(spritesmith-main-7.png);background-position:-954px -1325px;width:105px;height:105px}.Mount_Head_Egg-Golden{background-image:url(spritesmith-main-7.png);background-position:-1060px -1325px;width:105px;height:105px}.Mount_Head_Egg-Red{background-image:url(spritesmith-main-7.png);background-position:-1166px -1325px;width:105px;height:105px}.Mount_Head_Egg-Shade{background-image:url(spritesmith-main-7.png);background-position:-1272px -1325px;width:105px;height:105px}.Mount_Head_Egg-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1378px -1325px;width:105px;height:105px}.Mount_Head_Egg-White{background-image:url(spritesmith-main-7.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Head_Egg-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Head_FlyingPig-Base{background-image:url(spritesmith-main-7.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Head_FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1528px -424px;width:105px;height:105px}.Mount_Head_FlyingPig-Desert{background-image:url(spritesmith-main-7.png);background-position:-1528px -530px;width:105px;height:105px}.Mount_Head_FlyingPig-Golden{background-image:url(spritesmith-main-7.png);background-position:-1528px -636px;width:105px;height:105px}.Mount_Head_FlyingPig-Red{background-image:url(spritesmith-main-7.png);background-position:-1528px -742px;width:105px;height:105px}.Mount_Head_FlyingPig-Shade{background-image:url(spritesmith-main-7.png);background-position:-1528px -848px;width:105px;height:105px}.Mount_Head_FlyingPig-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1528px -954px;width:105px;height:105px}.Mount_Head_FlyingPig-Spooky{background-image:url(spritesmith-main-7.png);background-position:-1528px -1060px;width:105px;height:105px}.Mount_Head_FlyingPig-White{background-image:url(spritesmith-main-7.png);background-position:-1528px -1166px;width:105px;height:105px}.Mount_Head_FlyingPig-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1528px -1272px;width:105px;height:105px}.Mount_Head_Fox-Base{background-image:url(spritesmith-main-7.png);background-position:0 -1431px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-106px -1431px;width:105px;height:105px}.Mount_Head_Fox-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-212px -1431px;width:105px;height:105px}.Mount_Head_Fox-Desert{background-image:url(spritesmith-main-7.png);background-position:-318px -1431px;width:105px;height:105px}.Mount_Head_Fox-Golden{background-image:url(spritesmith-main-7.png);background-position:-424px -1431px;width:105px;height:105px}.Mount_Head_Fox-Red{background-image:url(spritesmith-main-7.png);background-position:-530px -1431px;width:105px;height:105px}.Mount_Head_Fox-Shade{background-image:url(spritesmith-main-7.png);background-position:-636px -1431px;width:105px;height:105px}.Mount_Head_Fox-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-742px -1431px;width:105px;height:105px}.Mount_Head_Fox-Spooky{background-image:url(spritesmith-main-7.png);background-position:-848px -1431px;width:105px;height:105px}.Mount_Head_Fox-White{background-image:url(spritesmith-main-7.png);background-position:-954px -1431px;width:105px;height:105px}.Mount_Head_Fox-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1060px -1431px;width:105px;height:105px}.Mount_Head_Frog-Base{background-image:url(spritesmith-main-7.png);background-position:-786px -115px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-786px -230px;width:105px;height:114px}.Mount_Head_Frog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-786px -345px;width:105px;height:114px}.Mount_Head_Frog-Desert{background-image:url(spritesmith-main-7.png);background-position:-786px -460px;width:105px;height:114px}.Mount_Head_Frog-Golden{background-image:url(spritesmith-main-7.png);background-position:0 -680px;width:105px;height:114px}.Mount_Head_Frog-Red{background-image:url(spritesmith-main-7.png);background-position:-106px -680px;width:105px;height:114px}.Mount_Head_Frog-Shade{background-image:url(spritesmith-main-7.png);background-position:-212px -680px;width:105px;height:114px}.Mount_Head_Frog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-318px -680px;width:105px;height:114px}.Mount_Head_Frog-White{background-image:url(spritesmith-main-7.png);background-position:-424px -680px;width:105px;height:114px}.Mount_Head_Frog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-136px -544px;width:105px;height:114px}.Mount_Head_Gryphon-Base{background-image:url(spritesmith-main-7.png);background-position:-1634px -636px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1634px -742px;width:105px;height:105px}.Mount_Head_Gryphon-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1634px -848px;width:105px;height:105px}.Mount_Head_Gryphon-Desert{background-image:url(spritesmith-main-7.png);background-position:-1634px -954px;width:105px;height:105px}.Mount_Head_Gryphon-Golden{background-image:url(spritesmith-main-7.png);background-position:-1634px -1060px;width:105px;height:105px}.Mount_Head_Gryphon-Red{background-image:url(spritesmith-main-7.png);background-position:-1634px -1166px;width:105px;height:105px}.Mount_Head_Gryphon-RoyalPurple{background-image:url(spritesmith-main-7.png);background-position:-1634px -1272px;width:105px;height:105px}.Mount_Head_Gryphon-Shade{background-image:url(spritesmith-main-7.png);background-position:-1634px -1378px;width:105px;height:105px}.Mount_Head_Gryphon-Skeleton{background-image:url(spritesmith-main-7.png);background-position:0 -1537px;width:105px;height:105px}.Mount_Head_Gryphon-White{background-image:url(spritesmith-main-7.png);background-position:-106px -1537px;width:105px;height:105px}.Mount_Head_Gryphon-Zombie{background-image:url(spritesmith-main-7.png);background-position:-212px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Base{background-image:url(spritesmith-main-7.png);background-position:-318px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-424px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-530px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Desert{background-image:url(spritesmith-main-7.png);background-position:-636px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Golden{background-image:url(spritesmith-main-7.png);background-position:-742px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Red{background-image:url(spritesmith-main-7.png);background-position:-848px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Shade{background-image:url(spritesmith-main-7.png);background-position:-954px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1060px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-White{background-image:url(spritesmith-main-7.png);background-position:-1166px -1537px;width:105px;height:105px}.Mount_Head_Hedgehog-Zombie{background-image:url(spritesmith-main-7.png);background-position:-1272px -1537px;width:105px;height:105px}.Mount_Head_Horse-Base{background-image:url(spritesmith-main-7.png);background-position:-1378px -1537px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyBlue{background-image:url(spritesmith-main-7.png);background-position:-1484px -1537px;width:105px;height:105px}.Mount_Head_Horse-CottonCandyPink{background-image:url(spritesmith-main-7.png);background-position:-1590px -1537px;width:105px;height:105px}.Mount_Head_Horse-Desert{background-image:url(spritesmith-main-7.png);background-position:-1740px 0;width:105px;height:105px}.Mount_Head_Horse-Golden{background-image:url(spritesmith-main-7.png);background-position:-1740px -106px;width:105px;height:105px}.Mount_Head_Horse-Red{background-image:url(spritesmith-main-7.png);background-position:-1740px -212px;width:105px;height:105px}.Mount_Head_Horse-Shade{background-image:url(spritesmith-main-7.png);background-position:-1740px -318px;width:105px;height:105px}.Mount_Head_Horse-Skeleton{background-image:url(spritesmith-main-7.png);background-position:-1740px -424px;width:105px;height:105px}.Mount_Head_Horse-White{background-image:url(spritesmith-main-8.png);background-position:-1060px -1316px;width:105px;height:105px}.Mount_Head_Horse-Zombie{background-image:url(spritesmith-main-8.png);background-position:-212px -1210px;width:105px;height:105px}.Mount_Head_JackOLantern-Base{background-image:url(spritesmith-main-8.png);background-position:-1528px -636px;width:90px;height:105px}.Mount_Head_LionCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1060px -1422px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1166px -1422px;width:105px;height:105px}.Mount_Head_LionCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-424px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-530px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Ethereal{background-image:url(spritesmith-main-8.png);background-position:-636px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-742px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Red{background-image:url(spritesmith-main-8.png);background-position:-848px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-954px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-242px -544px;width:105px;height:110px}.Mount_Head_LionCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1166px -1316px;width:105px;height:105px}.Mount_Head_LionCub-White{background-image:url(spritesmith-main-8.png);background-position:-1272px -1316px;width:105px;height:105px}.Mount_Head_LionCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1422px 0;width:105px;height:105px}.Mount_Head_Mammoth-Base{background-image:url(spritesmith-main-8.png);background-position:-136px -544px;width:105px;height:123px}.Mount_Head_MantisShrimp-Base{background-image:url(spritesmith-main-8.png);background-position:-348px -544px;width:108px;height:105px}.Mount_Head_Octopus-Base{background-image:url(spritesmith-main-8.png);background-position:-1272px -1422px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1378px -1422px;width:105px;height:105px}.Mount_Head_Octopus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1528px 0;width:105px;height:105px}.Mount_Head_Octopus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1528px -106px;width:105px;height:105px}.Mount_Head_Octopus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1528px -212px;width:105px;height:105px}.Mount_Head_Octopus-Red{background-image:url(spritesmith-main-8.png);background-position:-1528px -318px;width:105px;height:105px}.Mount_Head_Octopus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1528px -424px;width:105px;height:105px}.Mount_Head_Octopus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1528px -530px;width:105px;height:105px}.Mount_Head_Octopus-White{background-image:url(spritesmith-main-8.png);background-position:-563px -544px;width:105px;height:105px}.Mount_Head_Octopus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-680px 0;width:105px;height:105px}.Mount_Head_Orca-Base{background-image:url(spritesmith-main-8.png);background-position:-680px -106px;width:105px;height:105px}.Mount_Head_Owl-Base{background-image:url(spritesmith-main-8.png);background-position:-680px -212px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-680px -318px;width:105px;height:105px}.Mount_Head_Owl-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-680px -424px;width:105px;height:105px}.Mount_Head_Owl-Desert{background-image:url(spritesmith-main-8.png);background-position:-680px -530px;width:105px;height:105px}.Mount_Head_Owl-Golden{background-image:url(spritesmith-main-8.png);background-position:0 -680px;width:105px;height:105px}.Mount_Head_Owl-Red{background-image:url(spritesmith-main-8.png);background-position:-106px -680px;width:105px;height:105px}.Mount_Head_Owl-Shade{background-image:url(spritesmith-main-8.png);background-position:-212px -680px;width:105px;height:105px}.Mount_Head_Owl-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-318px -680px;width:105px;height:105px}.Mount_Head_Owl-White{background-image:url(spritesmith-main-8.png);background-position:-424px -680px;width:105px;height:105px}.Mount_Head_Owl-Zombie{background-image:url(spritesmith-main-8.png);background-position:-530px -680px;width:105px;height:105px}.Mount_Head_PandaCub-Base{background-image:url(spritesmith-main-8.png);background-position:-636px -680px;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-786px 0;width:105px;height:105px}.Mount_Head_PandaCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-786px -106px;width:105px;height:105px}.Mount_Head_PandaCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-786px -212px;width:105px;height:105px}.Mount_Head_PandaCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-786px -318px;width:105px;height:105px}.Mount_Head_PandaCub-Red{background-image:url(spritesmith-main-8.png);background-position:-786px -424px;width:105px;height:105px}.Mount_Head_PandaCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-786px -530px;width:105px;height:105px}.Mount_Head_PandaCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-786px -636px;width:105px;height:105px}.Mount_Head_PandaCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:0 -786px;width:105px;height:105px}.Mount_Head_PandaCub-White{background-image:url(spritesmith-main-8.png);background-position:-106px -786px;width:105px;height:105px}.Mount_Head_PandaCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-212px -786px;width:105px;height:105px}.Mount_Head_Parrot-Base{background-image:url(spritesmith-main-8.png);background-position:-318px -786px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-424px -786px;width:105px;height:105px}.Mount_Head_Parrot-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-530px -786px;width:105px;height:105px}.Mount_Head_Parrot-Desert{background-image:url(spritesmith-main-8.png);background-position:-636px -786px;width:105px;height:105px}.Mount_Head_Parrot-Golden{background-image:url(spritesmith-main-8.png);background-position:-742px -786px;width:105px;height:105px}.Mount_Head_Parrot-Red{background-image:url(spritesmith-main-8.png);background-position:-892px 0;width:105px;height:105px}.Mount_Head_Parrot-Shade{background-image:url(spritesmith-main-8.png);background-position:-892px -106px;width:105px;height:105px}.Mount_Head_Parrot-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-892px -212px;width:105px;height:105px}.Mount_Head_Parrot-White{background-image:url(spritesmith-main-8.png);background-position:-892px -318px;width:105px;height:105px}.Mount_Head_Parrot-Zombie{background-image:url(spritesmith-main-8.png);background-position:-892px -424px;width:105px;height:105px}.Mount_Head_Penguin-Base{background-image:url(spritesmith-main-8.png);background-position:-892px -530px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-892px -636px;width:105px;height:105px}.Mount_Head_Penguin-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-892px -742px;width:105px;height:105px}.Mount_Head_Penguin-Desert{background-image:url(spritesmith-main-8.png);background-position:0 -892px;width:105px;height:105px}.Mount_Head_Penguin-Golden{background-image:url(spritesmith-main-8.png);background-position:-106px -892px;width:105px;height:105px}.Mount_Head_Penguin-Red{background-image:url(spritesmith-main-8.png);background-position:-212px -892px;width:105px;height:105px}.Mount_Head_Penguin-Shade{background-image:url(spritesmith-main-8.png);background-position:-318px -892px;width:105px;height:105px}.Mount_Head_Penguin-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-424px -892px;width:105px;height:105px}.Mount_Head_Penguin-White{background-image:url(spritesmith-main-8.png);background-position:-530px -892px;width:105px;height:105px}.Mount_Head_Penguin-Zombie{background-image:url(spritesmith-main-8.png);background-position:-636px -892px;width:105px;height:105px}.Mount_Head_Phoenix-Base{background-image:url(spritesmith-main-8.png);background-position:-742px -892px;width:105px;height:105px}.Mount_Head_Rat-Base{background-image:url(spritesmith-main-8.png);background-position:-848px -892px;width:105px;height:105px}.Mount_Head_Rat-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-998px 0;width:105px;height:105px}.Mount_Head_Rat-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-998px -106px;width:105px;height:105px}.Mount_Head_Rat-Desert{background-image:url(spritesmith-main-8.png);background-position:-998px -212px;width:105px;height:105px}.Mount_Head_Rat-Golden{background-image:url(spritesmith-main-8.png);background-position:-998px -318px;width:105px;height:105px}.Mount_Head_Rat-Red{background-image:url(spritesmith-main-8.png);background-position:-998px -424px;width:105px;height:105px}.Mount_Head_Rat-Shade{background-image:url(spritesmith-main-8.png);background-position:-998px -530px;width:105px;height:105px}.Mount_Head_Rat-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-998px -636px;width:105px;height:105px}.Mount_Head_Rat-White{background-image:url(spritesmith-main-8.png);background-position:-998px -742px;width:105px;height:105px}.Mount_Head_Rat-Zombie{background-image:url(spritesmith-main-8.png);background-position:-998px -848px;width:105px;height:105px}.Mount_Head_Rock-Base{background-image:url(spritesmith-main-8.png);background-position:0 -998px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-106px -998px;width:105px;height:105px}.Mount_Head_Rock-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-212px -998px;width:105px;height:105px}.Mount_Head_Rock-Desert{background-image:url(spritesmith-main-8.png);background-position:-318px -998px;width:105px;height:105px}.Mount_Head_Rock-Golden{background-image:url(spritesmith-main-8.png);background-position:-424px -998px;width:105px;height:105px}.Mount_Head_Rock-Red{background-image:url(spritesmith-main-8.png);background-position:-530px -998px;width:105px;height:105px}.Mount_Head_Rock-Shade{background-image:url(spritesmith-main-8.png);background-position:-636px -998px;width:105px;height:105px}.Mount_Head_Rock-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-742px -998px;width:105px;height:105px}.Mount_Head_Rock-White{background-image:url(spritesmith-main-8.png);background-position:-848px -998px;width:105px;height:105px}.Mount_Head_Rock-Zombie{background-image:url(spritesmith-main-8.png);background-position:-954px -998px;width:105px;height:105px}.Mount_Head_Rooster-Base{background-image:url(spritesmith-main-8.png);background-position:-1104px 0;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1104px -106px;width:105px;height:105px}.Mount_Head_Rooster-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1104px -212px;width:105px;height:105px}.Mount_Head_Rooster-Desert{background-image:url(spritesmith-main-8.png);background-position:-1104px -318px;width:105px;height:105px}.Mount_Head_Rooster-Golden{background-image:url(spritesmith-main-8.png);background-position:-1104px -424px;width:105px;height:105px}.Mount_Head_Rooster-Red{background-image:url(spritesmith-main-8.png);background-position:-1104px -530px;width:105px;height:105px}.Mount_Head_Rooster-Shade{background-image:url(spritesmith-main-8.png);background-position:-1104px -636px;width:105px;height:105px}.Mount_Head_Rooster-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1104px -742px;width:105px;height:105px}.Mount_Head_Rooster-White{background-image:url(spritesmith-main-8.png);background-position:-1104px -848px;width:105px;height:105px}.Mount_Head_Rooster-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1104px -954px;width:105px;height:105px}.Mount_Head_Seahorse-Base{background-image:url(spritesmith-main-8.png);background-position:0 -1104px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-106px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-212px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Desert{background-image:url(spritesmith-main-8.png);background-position:-318px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Golden{background-image:url(spritesmith-main-8.png);background-position:-424px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Red{background-image:url(spritesmith-main-8.png);background-position:-530px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Shade{background-image:url(spritesmith-main-8.png);background-position:-636px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-742px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-White{background-image:url(spritesmith-main-8.png);background-position:-848px -1104px;width:105px;height:105px}.Mount_Head_Seahorse-Zombie{background-image:url(spritesmith-main-8.png);background-position:-954px -1104px;width:105px;height:105px}.Mount_Head_Sheep-Base{background-image:url(spritesmith-main-8.png);background-position:-1060px -1104px;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1210px 0;width:105px;height:105px}.Mount_Head_Sheep-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1210px -106px;width:105px;height:105px}.Mount_Head_Sheep-Desert{background-image:url(spritesmith-main-8.png);background-position:-1210px -212px;width:105px;height:105px}.Mount_Head_Sheep-Golden{background-image:url(spritesmith-main-8.png);background-position:-1210px -318px;width:105px;height:105px}.Mount_Head_Sheep-Red{background-image:url(spritesmith-main-8.png);background-position:-1210px -424px;width:105px;height:105px}.Mount_Head_Sheep-Shade{background-image:url(spritesmith-main-8.png);background-position:-1210px -530px;width:105px;height:105px}.Mount_Head_Sheep-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1210px -636px;width:105px;height:105px}.Mount_Head_Sheep-White{background-image:url(spritesmith-main-8.png);background-position:-1210px -742px;width:105px;height:105px}.Mount_Head_Sheep-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1210px -848px;width:105px;height:105px}.Mount_Head_Slime-Base{background-image:url(spritesmith-main-8.png);background-position:-1210px -954px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1210px -1060px;width:105px;height:105px}.Mount_Head_Slime-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:0 -1210px;width:105px;height:105px}.Mount_Head_Slime-Desert{background-image:url(spritesmith-main-8.png);background-position:-106px -1210px;width:105px;height:105px}.Mount_Head_Slime-Golden{background-image:url(spritesmith-main-8.png);background-position:-457px -544px;width:105px;height:105px}.Mount_Head_Slime-Red{background-image:url(spritesmith-main-8.png);background-position:-318px -1210px;width:105px;height:105px}.Mount_Head_Slime-Shade{background-image:url(spritesmith-main-8.png);background-position:-424px -1210px;width:105px;height:105px}.Mount_Head_Slime-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-530px -1210px;width:105px;height:105px}.Mount_Head_Slime-White{background-image:url(spritesmith-main-8.png);background-position:-636px -1210px;width:105px;height:105px}.Mount_Head_Slime-Zombie{background-image:url(spritesmith-main-8.png);background-position:-742px -1210px;width:105px;height:105px}.Mount_Head_Snake-Base{background-image:url(spritesmith-main-8.png);background-position:-848px -1210px;width:105px;height:105px}.Mount_Head_Snake-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-954px -1210px;width:105px;height:105px}.Mount_Head_Snake-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1060px -1210px;width:105px;height:105px}.Mount_Head_Snake-Desert{background-image:url(spritesmith-main-8.png);background-position:-1166px -1210px;width:105px;height:105px}.Mount_Head_Snake-Golden{background-image:url(spritesmith-main-8.png);background-position:-1316px 0;width:105px;height:105px}.Mount_Head_Snake-Red{background-image:url(spritesmith-main-8.png);background-position:-1316px -106px;width:105px;height:105px}.Mount_Head_Snake-Shade{background-image:url(spritesmith-main-8.png);background-position:-1316px -212px;width:105px;height:105px}.Mount_Head_Snake-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1316px -318px;width:105px;height:105px}.Mount_Head_Snake-White{background-image:url(spritesmith-main-8.png);background-position:-1316px -424px;width:105px;height:105px}.Mount_Head_Snake-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1316px -530px;width:105px;height:105px}.Mount_Head_Spider-Base{background-image:url(spritesmith-main-8.png);background-position:-1316px -636px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1316px -742px;width:105px;height:105px}.Mount_Head_Spider-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1316px -848px;width:105px;height:105px}.Mount_Head_Spider-Desert{background-image:url(spritesmith-main-8.png);background-position:-1316px -954px;width:105px;height:105px}.Mount_Head_Spider-Golden{background-image:url(spritesmith-main-8.png);background-position:-1316px -1060px;width:105px;height:105px}.Mount_Head_Spider-Red{background-image:url(spritesmith-main-8.png);background-position:-1316px -1166px;width:105px;height:105px}.Mount_Head_Spider-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -1316px;width:105px;height:105px}.Mount_Head_Spider-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-106px -1316px;width:105px;height:105px}.Mount_Head_Spider-White{background-image:url(spritesmith-main-8.png);background-position:-212px -1316px;width:105px;height:105px}.Mount_Head_Spider-Zombie{background-image:url(spritesmith-main-8.png);background-position:-318px -1316px;width:105px;height:105px}.Mount_Head_TRex-Base{background-image:url(spritesmith-main-8.png);background-position:-272px -136px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:0 -272px;width:135px;height:135px}.Mount_Head_TRex-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-136px -272px;width:135px;height:135px}.Mount_Head_TRex-Desert{background-image:url(spritesmith-main-8.png);background-position:-272px -272px;width:135px;height:135px}.Mount_Head_TRex-Golden{background-image:url(spritesmith-main-8.png);background-position:-408px 0;width:135px;height:135px}.Mount_Head_TRex-Red{background-image:url(spritesmith-main-8.png);background-position:-408px -136px;width:135px;height:135px}.Mount_Head_TRex-Shade{background-image:url(spritesmith-main-8.png);background-position:-408px -272px;width:135px;height:135px}.Mount_Head_TRex-Skeleton{background-image:url(spritesmith-main-8.png);background-position:0 0;width:135px;height:135px}.Mount_Head_TRex-White{background-image:url(spritesmith-main-8.png);background-position:-136px -408px;width:135px;height:135px}.Mount_Head_TRex-Zombie{background-image:url(spritesmith-main-8.png);background-position:-272px -408px;width:135px;height:135px}.Mount_Head_TigerCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -106px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1422px -212px;width:105px;height:105px}.Mount_Head_TigerCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1422px -318px;width:105px;height:105px}.Mount_Head_TigerCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1422px -424px;width:105px;height:105px}.Mount_Head_TigerCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1422px -530px;width:105px;height:105px}.Mount_Head_TigerCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1422px -636px;width:105px;height:105px}.Mount_Head_TigerCub-Shade{background-image:url(spritesmith-main-8.png);background-position:-1422px -742px;width:105px;height:105px}.Mount_Head_TigerCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1422px -848px;width:105px;height:105px}.Mount_Head_TigerCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1422px -954px;width:105px;height:105px}.Mount_Head_TigerCub-White{background-image:url(spritesmith-main-8.png);background-position:-1422px -1060px;width:105px;height:105px}.Mount_Head_TigerCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1422px -1166px;width:105px;height:105px}.Mount_Head_Turkey-Base{background-image:url(spritesmith-main-8.png);background-position:-1422px -1272px;width:105px;height:105px}.Mount_Head_Whale-Base{background-image:url(spritesmith-main-8.png);background-position:0 -1422px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-106px -1422px;width:105px;height:105px}.Mount_Head_Whale-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-212px -1422px;width:105px;height:105px}.Mount_Head_Whale-Desert{background-image:url(spritesmith-main-8.png);background-position:-318px -1422px;width:105px;height:105px}.Mount_Head_Whale-Golden{background-image:url(spritesmith-main-8.png);background-position:-424px -1422px;width:105px;height:105px}.Mount_Head_Whale-Red{background-image:url(spritesmith-main-8.png);background-position:-530px -1422px;width:105px;height:105px}.Mount_Head_Whale-Shade{background-image:url(spritesmith-main-8.png);background-position:-636px -1422px;width:105px;height:105px}.Mount_Head_Whale-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-742px -1422px;width:105px;height:105px}.Mount_Head_Whale-White{background-image:url(spritesmith-main-8.png);background-position:-848px -1422px;width:105px;height:105px}.Mount_Head_Whale-Zombie{background-image:url(spritesmith-main-8.png);background-position:-954px -1422px;width:105px;height:105px}.Mount_Head_Wolf-Base{background-image:url(spritesmith-main-8.png);background-position:-408px -408px;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-544px 0;width:135px;height:135px}.Mount_Head_Wolf-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-544px -136px;width:135px;height:135px}.Mount_Head_Wolf-Desert{background-image:url(spritesmith-main-8.png);background-position:-544px -272px;width:135px;height:135px}.Mount_Head_Wolf-Golden{background-image:url(spritesmith-main-8.png);background-position:-544px -408px;width:135px;height:135px}.Mount_Head_Wolf-Red{background-image:url(spritesmith-main-8.png);background-position:0 -544px;width:135px;height:135px}.Mount_Head_Wolf-Shade{background-image:url(spritesmith-main-8.png);background-position:-272px 0;width:135px;height:135px}.Mount_Head_Wolf-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-136px -136px;width:135px;height:135px}.Mount_Head_Wolf-Spooky{background-image:url(spritesmith-main-8.png);background-position:0 -136px;width:135px;height:135px}.Mount_Head_Wolf-White{background-image:url(spritesmith-main-8.png);background-position:-136px 0;width:135px;height:135px}.Mount_Head_Wolf-Zombie{background-image:url(spritesmith-main-8.png);background-position:0 -408px;width:135px;height:135px}.Pet-BearCub-Base{background-image:url(spritesmith-main-8.png);background-position:-1634px -100px;width:81px;height:99px}.Pet-BearCub-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1528px -842px;width:81px;height:99px}.Pet-BearCub-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1528px -942px;width:81px;height:99px}.Pet-BearCub-Desert{background-image:url(spritesmith-main-8.png);background-position:-1528px -1042px;width:81px;height:99px}.Pet-BearCub-Golden{background-image:url(spritesmith-main-8.png);background-position:-1528px -1142px;width:81px;height:99px}.Pet-BearCub-Polar{background-image:url(spritesmith-main-8.png);background-position:-1528px -1242px;width:81px;height:99px}.Pet-BearCub-Red{background-image:url(spritesmith-main-8.png);background-position:-1528px -1342px;width:81px;height:99px}.Pet-BearCub-Shade{background-image:url(spritesmith-main-8.png);background-position:0 -1528px;width:81px;height:99px}.Pet-BearCub-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-82px -1528px;width:81px;height:99px}.Pet-BearCub-Spooky{background-image:url(spritesmith-main-8.png);background-position:-164px -1528px;width:81px;height:99px}.Pet-BearCub-White{background-image:url(spritesmith-main-8.png);background-position:-246px -1528px;width:81px;height:99px}.Pet-BearCub-Zombie{background-image:url(spritesmith-main-8.png);background-position:-328px -1528px;width:81px;height:99px}.Pet-Bunny-Base{background-image:url(spritesmith-main-8.png);background-position:-410px -1528px;width:81px;height:99px}.Pet-Bunny-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-492px -1528px;width:81px;height:99px}.Pet-Bunny-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-574px -1528px;width:81px;height:99px}.Pet-Bunny-Desert{background-image:url(spritesmith-main-8.png);background-position:-656px -1528px;width:81px;height:99px}.Pet-Bunny-Golden{background-image:url(spritesmith-main-8.png);background-position:-738px -1528px;width:81px;height:99px}.Pet-Bunny-Red{background-image:url(spritesmith-main-8.png);background-position:-820px -1528px;width:81px;height:99px}.Pet-Bunny-Shade{background-image:url(spritesmith-main-8.png);background-position:-902px -1528px;width:81px;height:99px}.Pet-Bunny-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-984px -1528px;width:81px;height:99px}.Pet-Bunny-White{background-image:url(spritesmith-main-8.png);background-position:-1066px -1528px;width:81px;height:99px}.Pet-Bunny-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1148px -1528px;width:81px;height:99px}.Pet-Cactus-Base{background-image:url(spritesmith-main-8.png);background-position:-1230px -1528px;width:81px;height:99px}.Pet-Cactus-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1312px -1528px;width:81px;height:99px}.Pet-Cactus-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1394px -1528px;width:81px;height:99px}.Pet-Cactus-Desert{background-image:url(spritesmith-main-8.png);background-position:-1476px -1528px;width:81px;height:99px}.Pet-Cactus-Golden{background-image:url(spritesmith-main-8.png);background-position:-1634px 0;width:81px;height:99px}.Pet-Cactus-Red{background-image:url(spritesmith-main-8.png);background-position:-1716px -1300px;width:81px;height:99px}.Pet-Cactus-Shade{background-image:url(spritesmith-main-8.png);background-position:-1634px -200px;width:81px;height:99px}.Pet-Cactus-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1634px -300px;width:81px;height:99px}.Pet-Cactus-Spooky{background-image:url(spritesmith-main-8.png);background-position:-1634px -400px;width:81px;height:99px}.Pet-Cactus-White{background-image:url(spritesmith-main-8.png);background-position:-1634px -500px;width:81px;height:99px}.Pet-Cactus-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1634px -600px;width:81px;height:99px}.Pet-Cheetah-Base{background-image:url(spritesmith-main-8.png);background-position:-1634px -700px;width:81px;height:99px}.Pet-Cheetah-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1634px -800px;width:81px;height:99px}.Pet-Cheetah-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1634px -900px;width:81px;height:99px}.Pet-Cheetah-Desert{background-image:url(spritesmith-main-8.png);background-position:-1634px -1000px;width:81px;height:99px}.Pet-Cheetah-Golden{background-image:url(spritesmith-main-8.png);background-position:-1634px -1100px;width:81px;height:99px}.Pet-Cheetah-Red{background-image:url(spritesmith-main-8.png);background-position:-1634px -1200px;width:81px;height:99px}.Pet-Cheetah-Shade{background-image:url(spritesmith-main-8.png);background-position:-1634px -1300px;width:81px;height:99px}.Pet-Cheetah-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1634px -1400px;width:81px;height:99px}.Pet-Cheetah-White{background-image:url(spritesmith-main-8.png);background-position:-1634px -1500px;width:81px;height:99px}.Pet-Cheetah-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1716px 0;width:81px;height:99px}.Pet-Cuttlefish-Base{background-image:url(spritesmith-main-8.png);background-position:-1716px -100px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1716px -200px;width:81px;height:99px}.Pet-Cuttlefish-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1716px -300px;width:81px;height:99px}.Pet-Cuttlefish-Desert{background-image:url(spritesmith-main-8.png);background-position:-1716px -400px;width:81px;height:99px}.Pet-Cuttlefish-Golden{background-image:url(spritesmith-main-8.png);background-position:-1716px -500px;width:81px;height:99px}.Pet-Cuttlefish-Red{background-image:url(spritesmith-main-8.png);background-position:-1716px -600px;width:81px;height:99px}.Pet-Cuttlefish-Shade{background-image:url(spritesmith-main-8.png);background-position:-1716px -700px;width:81px;height:99px}.Pet-Cuttlefish-Skeleton{background-image:url(spritesmith-main-8.png);background-position:-1716px -800px;width:81px;height:99px}.Pet-Cuttlefish-White{background-image:url(spritesmith-main-8.png);background-position:-1716px -900px;width:81px;height:99px}.Pet-Cuttlefish-Zombie{background-image:url(spritesmith-main-8.png);background-position:-1716px -1000px;width:81px;height:99px}.Pet-Deer-Base{background-image:url(spritesmith-main-8.png);background-position:-1716px -1100px;width:81px;height:99px}.Pet-Deer-CottonCandyBlue{background-image:url(spritesmith-main-8.png);background-position:-1716px -1200px;width:81px;height:99px}.Pet-Deer-CottonCandyPink{background-image:url(spritesmith-main-8.png);background-position:-1528px -742px;width:81px;height:99px}.Pet-Deer-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px 0;width:81px;height:99px}.Pet-Deer-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -1000px;width:81px;height:99px}.Pet-Deer-Red{background-image:url(spritesmith-main-9.png);background-position:-164px 0;width:81px;height:99px}.Pet-Deer-Shade{background-image:url(spritesmith-main-9.png);background-position:0 -100px;width:81px;height:99px}.Pet-Deer-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-82px -100px;width:81px;height:99px}.Pet-Deer-White{background-image:url(spritesmith-main-9.png);background-position:-164px -100px;width:81px;height:99px}.Pet-Deer-Zombie{background-image:url(spritesmith-main-9.png);background-position:-246px 0;width:81px;height:99px}.Pet-Dragon-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -100px;width:81px;height:99px}.Pet-Dragon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 -200px;width:81px;height:99px}.Pet-Dragon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-82px -200px;width:81px;height:99px}.Pet-Dragon-Desert{background-image:url(spritesmith-main-9.png);background-position:-164px -200px;width:81px;height:99px}.Pet-Dragon-Golden{background-image:url(spritesmith-main-9.png);background-position:-246px -200px;width:81px;height:99px}.Pet-Dragon-Hydra{background-image:url(spritesmith-main-9.png);background-position:-328px 0;width:81px;height:99px}.Pet-Dragon-Red{background-image:url(spritesmith-main-9.png);background-position:-328px -100px;width:81px;height:99px}.Pet-Dragon-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -200px;width:81px;height:99px}.Pet-Dragon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:0 -300px;width:81px;height:99px}.Pet-Dragon-Spooky{background-image:url(spritesmith-main-9.png);background-position:-82px -300px;width:81px;height:99px}.Pet-Dragon-White{background-image:url(spritesmith-main-9.png);background-position:-164px -300px;width:81px;height:99px}.Pet-Dragon-Zombie{background-image:url(spritesmith-main-9.png);background-position:-246px -300px;width:81px;height:99px}.Pet-Egg-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -300px;width:81px;height:99px}.Pet-Egg-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-410px 0;width:81px;height:99px}.Pet-Egg-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-410px -100px;width:81px;height:99px}.Pet-Egg-Desert{background-image:url(spritesmith-main-9.png);background-position:-410px -200px;width:81px;height:99px}.Pet-Egg-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -300px;width:81px;height:99px}.Pet-Egg-Red{background-image:url(spritesmith-main-9.png);background-position:-492px 0;width:81px;height:99px}.Pet-Egg-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -100px;width:81px;height:99px}.Pet-Egg-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-492px -200px;width:81px;height:99px}.Pet-Egg-White{background-image:url(spritesmith-main-9.png);background-position:-492px -300px;width:81px;height:99px}.Pet-Egg-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -400px;width:81px;height:99px}.Pet-FlyingPig-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -400px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -400px;width:81px;height:99px}.Pet-FlyingPig-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px -400px;width:81px;height:99px}.Pet-FlyingPig-Desert{background-image:url(spritesmith-main-9.png);background-position:-328px -400px;width:81px;height:99px}.Pet-FlyingPig-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -400px;width:81px;height:99px}.Pet-FlyingPig-Red{background-image:url(spritesmith-main-9.png);background-position:-492px -400px;width:81px;height:99px}.Pet-FlyingPig-Shade{background-image:url(spritesmith-main-9.png);background-position:-574px 0;width:81px;height:99px}.Pet-FlyingPig-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -100px;width:81px;height:99px}.Pet-FlyingPig-Spooky{background-image:url(spritesmith-main-9.png);background-position:-574px -200px;width:81px;height:99px}.Pet-FlyingPig-White{background-image:url(spritesmith-main-9.png);background-position:-574px -300px;width:81px;height:99px}.Pet-FlyingPig-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px -400px;width:81px;height:99px}.Pet-Fox-Base{background-image:url(spritesmith-main-9.png);background-position:0 -500px;width:81px;height:99px}.Pet-Fox-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-82px -500px;width:81px;height:99px}.Pet-Fox-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-164px -500px;width:81px;height:99px}.Pet-Fox-Desert{background-image:url(spritesmith-main-9.png);background-position:-246px -500px;width:81px;height:99px}.Pet-Fox-Golden{background-image:url(spritesmith-main-9.png);background-position:-328px -500px;width:81px;height:99px}.Pet-Fox-Red{background-image:url(spritesmith-main-9.png);background-position:-410px -500px;width:81px;height:99px}.Pet-Fox-Shade{background-image:url(spritesmith-main-9.png);background-position:-492px -500px;width:81px;height:99px}.Pet-Fox-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-574px -500px;width:81px;height:99px}.Pet-Fox-Spooky{background-image:url(spritesmith-main-9.png);background-position:-656px 0;width:81px;height:99px}.Pet-Fox-White{background-image:url(spritesmith-main-9.png);background-position:-656px -100px;width:81px;height:99px}.Pet-Fox-Zombie{background-image:url(spritesmith-main-9.png);background-position:-656px -200px;width:81px;height:99px}.Pet-Frog-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -300px;width:81px;height:99px}.Pet-Frog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px -400px;width:81px;height:99px}.Pet-Frog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-656px -500px;width:81px;height:99px}.Pet-Frog-Desert{background-image:url(spritesmith-main-9.png);background-position:0 -600px;width:81px;height:99px}.Pet-Frog-Golden{background-image:url(spritesmith-main-9.png);background-position:-82px -600px;width:81px;height:99px}.Pet-Frog-Red{background-image:url(spritesmith-main-9.png);background-position:-164px -600px;width:81px;height:99px}.Pet-Frog-Shade{background-image:url(spritesmith-main-9.png);background-position:-246px -600px;width:81px;height:99px}.Pet-Frog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-328px -600px;width:81px;height:99px}.Pet-Frog-White{background-image:url(spritesmith-main-9.png);background-position:-410px -600px;width:81px;height:99px}.Pet-Frog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-492px -600px;width:81px;height:99px}.Pet-Gryphon-Base{background-image:url(spritesmith-main-9.png);background-position:-574px -600px;width:81px;height:99px}.Pet-Gryphon-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-656px -600px;width:81px;height:99px}.Pet-Gryphon-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-738px 0;width:81px;height:99px}.Pet-Gryphon-Desert{background-image:url(spritesmith-main-9.png);background-position:-738px -100px;width:81px;height:99px}.Pet-Gryphon-Golden{background-image:url(spritesmith-main-9.png);background-position:-738px -200px;width:81px;height:99px}.Pet-Gryphon-Red{background-image:url(spritesmith-main-9.png);background-position:-738px -300px;width:81px;height:99px}.Pet-Gryphon-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px -400px;width:81px;height:99px}.Pet-Gryphon-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -500px;width:81px;height:99px}.Pet-Gryphon-White{background-image:url(spritesmith-main-9.png);background-position:-738px -600px;width:81px;height:99px}.Pet-Gryphon-Zombie{background-image:url(spritesmith-main-9.png);background-position:0 -700px;width:81px;height:99px}.Pet-Hedgehog-Base{background-image:url(spritesmith-main-9.png);background-position:-82px -700px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-164px -700px;width:81px;height:99px}.Pet-Hedgehog-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-246px -700px;width:81px;height:99px}.Pet-Hedgehog-Desert{background-image:url(spritesmith-main-9.png);background-position:-328px -700px;width:81px;height:99px}.Pet-Hedgehog-Golden{background-image:url(spritesmith-main-9.png);background-position:-410px -700px;width:81px;height:99px}.Pet-Hedgehog-Red{background-image:url(spritesmith-main-9.png);background-position:-492px -700px;width:81px;height:99px}.Pet-Hedgehog-Shade{background-image:url(spritesmith-main-9.png);background-position:-574px -700px;width:81px;height:99px}.Pet-Hedgehog-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-656px -700px;width:81px;height:99px}.Pet-Hedgehog-White{background-image:url(spritesmith-main-9.png);background-position:-738px -700px;width:81px;height:99px}.Pet-Hedgehog-Zombie{background-image:url(spritesmith-main-9.png);background-position:-820px 0;width:81px;height:99px}.Pet-Horse-Base{background-image:url(spritesmith-main-9.png);background-position:-820px -100px;width:81px;height:99px}.Pet-Horse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-820px -200px;width:81px;height:99px}.Pet-Horse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-820px -300px;width:81px;height:99px}.Pet-Horse-Desert{background-image:url(spritesmith-main-9.png);background-position:-820px -400px;width:81px;height:99px}.Pet-Horse-Golden{background-image:url(spritesmith-main-9.png);background-position:-820px -500px;width:81px;height:99px}.Pet-Horse-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -600px;width:81px;height:99px}.Pet-Horse-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -700px;width:81px;height:99px}.Pet-Horse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:0 -800px;width:81px;height:99px}.Pet-Horse-White{background-image:url(spritesmith-main-9.png);background-position:-82px -800px;width:81px;height:99px}.Pet-Horse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px -800px;width:81px;height:99px}.Pet-JackOLantern-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -800px;width:81px;height:99px}.Pet-LionCub-Base{background-image:url(spritesmith-main-9.png);background-position:-328px -800px;width:81px;height:99px}.Pet-LionCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-410px -800px;width:81px;height:99px}.Pet-LionCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-492px -800px;width:81px;height:99px}.Pet-LionCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-574px -800px;width:81px;height:99px}.Pet-LionCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-656px -800px;width:81px;height:99px}.Pet-LionCub-Red{background-image:url(spritesmith-main-9.png);background-position:-738px -800px;width:81px;height:99px}.Pet-LionCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-820px -800px;width:81px;height:99px}.Pet-LionCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-902px 0;width:81px;height:99px}.Pet-LionCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-902px -100px;width:81px;height:99px}.Pet-LionCub-White{background-image:url(spritesmith-main-9.png);background-position:-902px -200px;width:81px;height:99px}.Pet-LionCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -300px;width:81px;height:99px}.Pet-Mammoth-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -400px;width:81px;height:99px}.Pet-MantisShrimp-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -500px;width:81px;height:99px}.Pet-Octopus-Base{background-image:url(spritesmith-main-9.png);background-position:-902px -600px;width:81px;height:99px}.Pet-Octopus-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-902px -700px;width:81px;height:99px}.Pet-Octopus-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-902px -800px;width:81px;height:99px}.Pet-Octopus-Desert{background-image:url(spritesmith-main-9.png);background-position:-984px 0;width:81px;height:99px}.Pet-Octopus-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -100px;width:81px;height:99px}.Pet-Octopus-Red{background-image:url(spritesmith-main-9.png);background-position:-984px -200px;width:81px;height:99px}.Pet-Octopus-Shade{background-image:url(spritesmith-main-9.png);background-position:-984px -300px;width:81px;height:99px}.Pet-Octopus-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-984px -400px;width:81px;height:99px}.Pet-Octopus-White{background-image:url(spritesmith-main-9.png);background-position:-984px -500px;width:81px;height:99px}.Pet-Octopus-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px -600px;width:81px;height:99px}.Pet-Owl-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -700px;width:81px;height:99px}.Pet-Owl-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-984px -800px;width:81px;height:99px}.Pet-Owl-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:0 -900px;width:81px;height:99px}.Pet-Owl-Desert{background-image:url(spritesmith-main-9.png);background-position:-82px -900px;width:81px;height:99px}.Pet-Owl-Golden{background-image:url(spritesmith-main-9.png);background-position:-164px -900px;width:81px;height:99px}.Pet-Owl-Red{background-image:url(spritesmith-main-9.png);background-position:-246px -900px;width:81px;height:99px}.Pet-Owl-Shade{background-image:url(spritesmith-main-9.png);background-position:-328px -900px;width:81px;height:99px}.Pet-Owl-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-410px -900px;width:81px;height:99px}.Pet-Owl-White{background-image:url(spritesmith-main-9.png);background-position:-492px -900px;width:81px;height:99px}.Pet-Owl-Zombie{background-image:url(spritesmith-main-9.png);background-position:-574px -900px;width:81px;height:99px}.Pet-PandaCub-Base{background-image:url(spritesmith-main-9.png);background-position:-656px -900px;width:81px;height:99px}.Pet-PandaCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-738px -900px;width:81px;height:99px}.Pet-PandaCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-820px -900px;width:81px;height:99px}.Pet-PandaCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-902px -900px;width:81px;height:99px}.Pet-PandaCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-984px -900px;width:81px;height:99px}.Pet-PandaCub-Red{background-image:url(spritesmith-main-9.png);background-position:-1066px 0;width:81px;height:99px}.Pet-PandaCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-1066px -100px;width:81px;height:99px}.Pet-PandaCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1066px -200px;width:81px;height:99px}.Pet-PandaCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-1066px -300px;width:81px;height:99px}.Pet-PandaCub-White{background-image:url(spritesmith-main-9.png);background-position:-1066px -400px;width:81px;height:99px}.Pet-PandaCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1066px -500px;width:81px;height:99px}.Pet-Parrot-Base{background-image:url(spritesmith-main-9.png);background-position:-1066px -600px;width:81px;height:99px}.Pet-Parrot-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1066px -700px;width:81px;height:99px}.Pet-Parrot-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1066px -800px;width:81px;height:99px}.Pet-Parrot-Desert{background-image:url(spritesmith-main-9.png);background-position:-1066px -900px;width:81px;height:99px}.Pet-Parrot-Golden{background-image:url(spritesmith-main-9.png);background-position:0 -1000px;width:81px;height:99px}.Pet-Parrot-Red{background-image:url(spritesmith-main-9.png);background-position:-82px -1000px;width:81px;height:99px}.Pet-Parrot-Shade{background-image:url(spritesmith-main-9.png);background-position:-164px -1000px;width:81px;height:99px}.Pet-Parrot-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-246px -1000px;width:81px;height:99px}.Pet-Parrot-White{background-image:url(spritesmith-main-9.png);background-position:-328px -1000px;width:81px;height:99px}.Pet-Parrot-Zombie{background-image:url(spritesmith-main-9.png);background-position:-410px -1000px;width:81px;height:99px}.Pet-Penguin-Base{background-image:url(spritesmith-main-9.png);background-position:-492px -1000px;width:81px;height:99px}.Pet-Penguin-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:0 0;width:81px;height:99px}.Pet-Penguin-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-656px -1000px;width:81px;height:99px}.Pet-Penguin-Desert{background-image:url(spritesmith-main-9.png);background-position:-738px -1000px;width:81px;height:99px}.Pet-Penguin-Golden{background-image:url(spritesmith-main-9.png);background-position:-820px -1000px;width:81px;height:99px}.Pet-Penguin-Red{background-image:url(spritesmith-main-9.png);background-position:-902px -1000px;width:81px;height:99px}.Pet-Penguin-Shade{background-image:url(spritesmith-main-9.png);background-position:-984px -1000px;width:81px;height:99px}.Pet-Penguin-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1066px -1000px;width:81px;height:99px}.Pet-Penguin-White{background-image:url(spritesmith-main-9.png);background-position:-1148px 0;width:81px;height:99px}.Pet-Penguin-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -100px;width:81px;height:99px}.Pet-Phoenix-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -200px;width:81px;height:99px}.Pet-Rat-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -300px;width:81px;height:99px}.Pet-Rat-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1148px -400px;width:81px;height:99px}.Pet-Rat-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1148px -500px;width:81px;height:99px}.Pet-Rat-Desert{background-image:url(spritesmith-main-9.png);background-position:-1148px -600px;width:81px;height:99px}.Pet-Rat-Golden{background-image:url(spritesmith-main-9.png);background-position:-1148px -700px;width:81px;height:99px}.Pet-Rat-Red{background-image:url(spritesmith-main-9.png);background-position:-1148px -800px;width:81px;height:99px}.Pet-Rat-Shade{background-image:url(spritesmith-main-9.png);background-position:-1148px -900px;width:81px;height:99px}.Pet-Rat-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1148px -1000px;width:81px;height:99px}.Pet-Rat-White{background-image:url(spritesmith-main-9.png);background-position:0 -1100px;width:81px;height:99px}.Pet-Rat-Zombie{background-image:url(spritesmith-main-9.png);background-position:-82px -1100px;width:81px;height:99px}.Pet-Rock-Base{background-image:url(spritesmith-main-9.png);background-position:-164px -1100px;width:81px;height:99px}.Pet-Rock-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-246px -1100px;width:81px;height:99px}.Pet-Rock-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-328px -1100px;width:81px;height:99px}.Pet-Rock-Desert{background-image:url(spritesmith-main-9.png);background-position:-410px -1100px;width:81px;height:99px}.Pet-Rock-Golden{background-image:url(spritesmith-main-9.png);background-position:-492px -1100px;width:81px;height:99px}.Pet-Rock-Red{background-image:url(spritesmith-main-9.png);background-position:-574px -1100px;width:81px;height:99px}.Pet-Rock-Shade{background-image:url(spritesmith-main-9.png);background-position:-656px -1100px;width:81px;height:99px}.Pet-Rock-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-738px -1100px;width:81px;height:99px}.Pet-Rock-White{background-image:url(spritesmith-main-9.png);background-position:-820px -1100px;width:81px;height:99px}.Pet-Rock-Zombie{background-image:url(spritesmith-main-9.png);background-position:-902px -1100px;width:81px;height:99px}.Pet-Rooster-Base{background-image:url(spritesmith-main-9.png);background-position:-984px -1100px;width:81px;height:99px}.Pet-Rooster-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1066px -1100px;width:81px;height:99px}.Pet-Rooster-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1148px -1100px;width:81px;height:99px}.Pet-Rooster-Desert{background-image:url(spritesmith-main-9.png);background-position:-1230px 0;width:81px;height:99px}.Pet-Rooster-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -100px;width:81px;height:99px}.Pet-Rooster-Red{background-image:url(spritesmith-main-9.png);background-position:-1230px -200px;width:81px;height:99px}.Pet-Rooster-Shade{background-image:url(spritesmith-main-9.png);background-position:-1230px -300px;width:81px;height:99px}.Pet-Rooster-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1230px -400px;width:81px;height:99px}.Pet-Rooster-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -500px;width:81px;height:99px}.Pet-Rooster-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1230px -600px;width:81px;height:99px}.Pet-Seahorse-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px -700px;width:81px;height:99px}.Pet-Seahorse-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1230px -800px;width:81px;height:99px}.Pet-Seahorse-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1230px -900px;width:81px;height:99px}.Pet-Seahorse-Desert{background-image:url(spritesmith-main-9.png);background-position:-1230px -1000px;width:81px;height:99px}.Pet-Seahorse-Golden{background-image:url(spritesmith-main-9.png);background-position:-1230px -1100px;width:81px;height:99px}.Pet-Seahorse-Red{background-image:url(spritesmith-main-9.png);background-position:0 -1200px;width:81px;height:99px}.Pet-Seahorse-Shade{background-image:url(spritesmith-main-9.png);background-position:-82px -1200px;width:81px;height:99px}.Pet-Seahorse-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-164px -1200px;width:81px;height:99px}.Pet-Seahorse-White{background-image:url(spritesmith-main-9.png);background-position:-246px -1200px;width:81px;height:99px}.Pet-Seahorse-Zombie{background-image:url(spritesmith-main-9.png);background-position:-328px -1200px;width:81px;height:99px}.Pet-Sheep-Base{background-image:url(spritesmith-main-9.png);background-position:-410px -1200px;width:81px;height:99px}.Pet-Sheep-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -1200px;width:81px;height:99px}.Pet-Sheep-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -1200px;width:81px;height:99px}.Pet-Sheep-Desert{background-image:url(spritesmith-main-9.png);background-position:-656px -1200px;width:81px;height:99px}.Pet-Sheep-Golden{background-image:url(spritesmith-main-9.png);background-position:-738px -1200px;width:81px;height:99px}.Pet-Sheep-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -1200px;width:81px;height:99px}.Pet-Sheep-Shade{background-image:url(spritesmith-main-9.png);background-position:-902px -1200px;width:81px;height:99px}.Pet-Sheep-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-984px -1200px;width:81px;height:99px}.Pet-Sheep-White{background-image:url(spritesmith-main-9.png);background-position:-1066px -1200px;width:81px;height:99px}.Pet-Sheep-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1148px -1200px;width:81px;height:99px}.Pet-Slime-Base{background-image:url(spritesmith-main-9.png);background-position:-1230px -1200px;width:81px;height:99px}.Pet-Slime-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1312px 0;width:81px;height:99px}.Pet-Slime-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px -100px;width:81px;height:99px}.Pet-Slime-Desert{background-image:url(spritesmith-main-9.png);background-position:-1312px -200px;width:81px;height:99px}.Pet-Slime-Golden{background-image:url(spritesmith-main-9.png);background-position:-1312px -300px;width:81px;height:99px}.Pet-Slime-Red{background-image:url(spritesmith-main-9.png);background-position:-1312px -400px;width:81px;height:99px}.Pet-Slime-Shade{background-image:url(spritesmith-main-9.png);background-position:-1312px -500px;width:81px;height:99px}.Pet-Slime-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1312px -600px;width:81px;height:99px}.Pet-Slime-White{background-image:url(spritesmith-main-9.png);background-position:-1312px -700px;width:81px;height:99px}.Pet-Slime-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1312px -800px;width:81px;height:99px}.Pet-Snake-Base{background-image:url(spritesmith-main-9.png);background-position:-1312px -900px;width:81px;height:99px}.Pet-Snake-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1312px -1000px;width:81px;height:99px}.Pet-Snake-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px -1100px;width:81px;height:99px}.Pet-Snake-Desert{background-image:url(spritesmith-main-9.png);background-position:-1312px -1200px;width:81px;height:99px}.Pet-Snake-Golden{background-image:url(spritesmith-main-9.png);background-position:-1394px 0;width:81px;height:99px}.Pet-Snake-Red{background-image:url(spritesmith-main-9.png);background-position:-1394px -100px;width:81px;height:99px}.Pet-Snake-Shade{background-image:url(spritesmith-main-9.png);background-position:-1394px -200px;width:81px;height:99px}.Pet-Snake-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1394px -300px;width:81px;height:99px}.Pet-Snake-White{background-image:url(spritesmith-main-9.png);background-position:-1394px -400px;width:81px;height:99px}.Pet-Snake-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1394px -500px;width:81px;height:99px}.Pet-Spider-Base{background-image:url(spritesmith-main-9.png);background-position:-1394px -600px;width:81px;height:99px}.Pet-Spider-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1394px -700px;width:81px;height:99px}.Pet-Spider-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1394px -800px;width:81px;height:99px}.Pet-Spider-Desert{background-image:url(spritesmith-main-9.png);background-position:-1394px -900px;width:81px;height:99px}.Pet-Spider-Golden{background-image:url(spritesmith-main-9.png);background-position:-1394px -1000px;width:81px;height:99px}.Pet-Spider-Red{background-image:url(spritesmith-main-9.png);background-position:-1394px -1100px;width:81px;height:99px}.Pet-Spider-Shade{background-image:url(spritesmith-main-9.png);background-position:-1394px -1200px;width:81px;height:99px}.Pet-Spider-Skeleton{background-image:url(spritesmith-main-9.png);background-position:0 -1300px;width:81px;height:99px}.Pet-Spider-White{background-image:url(spritesmith-main-9.png);background-position:-82px -1300px;width:81px;height:99px}.Pet-Spider-Zombie{background-image:url(spritesmith-main-9.png);background-position:-164px -1300px;width:81px;height:99px}.Pet-TRex-Base{background-image:url(spritesmith-main-9.png);background-position:-246px -1300px;width:81px;height:99px}.Pet-TRex-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-328px -1300px;width:81px;height:99px}.Pet-TRex-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-410px -1300px;width:81px;height:99px}.Pet-TRex-Desert{background-image:url(spritesmith-main-9.png);background-position:-492px -1300px;width:81px;height:99px}.Pet-TRex-Golden{background-image:url(spritesmith-main-9.png);background-position:-574px -1300px;width:81px;height:99px}.Pet-TRex-Red{background-image:url(spritesmith-main-9.png);background-position:-656px -1300px;width:81px;height:99px}.Pet-TRex-Shade{background-image:url(spritesmith-main-9.png);background-position:-738px -1300px;width:81px;height:99px}.Pet-TRex-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-820px -1300px;width:81px;height:99px}.Pet-TRex-White{background-image:url(spritesmith-main-9.png);background-position:-902px -1300px;width:81px;height:99px}.Pet-TRex-Zombie{background-image:url(spritesmith-main-9.png);background-position:-984px -1300px;width:81px;height:99px}.Pet-Tiger-Veteran{background-image:url(spritesmith-main-9.png);background-position:-1066px -1300px;width:81px;height:99px}.Pet-TigerCub-Base{background-image:url(spritesmith-main-9.png);background-position:-1148px -1300px;width:81px;height:99px}.Pet-TigerCub-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1230px -1300px;width:81px;height:99px}.Pet-TigerCub-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1312px -1300px;width:81px;height:99px}.Pet-TigerCub-Desert{background-image:url(spritesmith-main-9.png);background-position:-1394px -1300px;width:81px;height:99px}.Pet-TigerCub-Golden{background-image:url(spritesmith-main-9.png);background-position:-1476px 0;width:81px;height:99px}.Pet-TigerCub-Red{background-image:url(spritesmith-main-9.png);background-position:-1476px -100px;width:81px;height:99px}.Pet-TigerCub-Shade{background-image:url(spritesmith-main-9.png);background-position:-1476px -200px;width:81px;height:99px}.Pet-TigerCub-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-1476px -300px;width:81px;height:99px}.Pet-TigerCub-Spooky{background-image:url(spritesmith-main-9.png);background-position:-1476px -400px;width:81px;height:99px}.Pet-TigerCub-White{background-image:url(spritesmith-main-9.png);background-position:-1476px -500px;width:81px;height:99px}.Pet-TigerCub-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1476px -600px;width:81px;height:99px}.Pet-Turkey-Base{background-image:url(spritesmith-main-9.png);background-position:-1476px -700px;width:81px;height:99px}.Pet-Turkey-Gilded{background-image:url(spritesmith-main-9.png);background-position:-1476px -800px;width:81px;height:99px}.Pet-Whale-Base{background-image:url(spritesmith-main-9.png);background-position:-1476px -900px;width:81px;height:99px}.Pet-Whale-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-1476px -1000px;width:81px;height:99px}.Pet-Whale-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1476px -1100px;width:81px;height:99px}.Pet-Whale-Desert{background-image:url(spritesmith-main-9.png);background-position:-1476px -1200px;width:81px;height:99px}.Pet-Whale-Golden{background-image:url(spritesmith-main-9.png);background-position:-1476px -1300px;width:81px;height:99px}.Pet-Whale-Red{background-image:url(spritesmith-main-9.png);background-position:0 -1400px;width:81px;height:99px}.Pet-Whale-Shade{background-image:url(spritesmith-main-9.png);background-position:-82px -1400px;width:81px;height:99px}.Pet-Whale-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-164px -1400px;width:81px;height:99px}.Pet-Whale-White{background-image:url(spritesmith-main-9.png);background-position:-246px -1400px;width:81px;height:99px}.Pet-Whale-Zombie{background-image:url(spritesmith-main-9.png);background-position:-328px -1400px;width:81px;height:99px}.Pet-Wolf-Base{background-image:url(spritesmith-main-9.png);background-position:-410px -1400px;width:81px;height:99px}.Pet-Wolf-CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-492px -1400px;width:81px;height:99px}.Pet-Wolf-CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-574px -1400px;width:81px;height:99px}.Pet-Wolf-Desert{background-image:url(spritesmith-main-9.png);background-position:-656px -1400px;width:81px;height:99px}.Pet-Wolf-Golden{background-image:url(spritesmith-main-9.png);background-position:-738px -1400px;width:81px;height:99px}.Pet-Wolf-Red{background-image:url(spritesmith-main-9.png);background-position:-820px -1400px;width:81px;height:99px}.Pet-Wolf-Shade{background-image:url(spritesmith-main-9.png);background-position:-902px -1400px;width:81px;height:99px}.Pet-Wolf-Skeleton{background-image:url(spritesmith-main-9.png);background-position:-984px -1400px;width:81px;height:99px}.Pet-Wolf-Spooky{background-image:url(spritesmith-main-9.png);background-position:-1066px -1400px;width:81px;height:99px}.Pet-Wolf-Veteran{background-image:url(spritesmith-main-9.png);background-position:-1148px -1400px;width:81px;height:99px}.Pet-Wolf-White{background-image:url(spritesmith-main-9.png);background-position:-1230px -1400px;width:81px;height:99px}.Pet-Wolf-Zombie{background-image:url(spritesmith-main-9.png);background-position:-1312px -1400px;width:81px;height:99px}.Pet_HatchingPotion_Base{background-image:url(spritesmith-main-9.png);background-position:-1443px -1400px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyBlue{background-image:url(spritesmith-main-9.png);background-position:-98px -1500px;width:48px;height:51px}.Pet_HatchingPotion_CottonCandyPink{background-image:url(spritesmith-main-9.png);background-position:-1492px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Desert{background-image:url(spritesmith-main-9.png);background-position:0 -1500px;width:48px;height:51px}.Pet_HatchingPotion_Golden{background-image:url(spritesmith-main-9.png);background-position:-49px -1500px;width:48px;height:51px}.Pet_HatchingPotion_Red{background-image:url(spritesmith-main-9.png);background-position:-1394px -1400px;width:48px;height:51px}.Pet_HatchingPotion_Shade{background-image:url(spritesmith-main-9.png);background-position:-147px -1500px;width:48px;height:51px}.Pet_HatchingPotion_Skeleton{background-image:url(spritesmith-main-9.png);background-position:-196px -1500px;width:48px;height:51px}.Pet_HatchingPotion_Spooky{background-image:url(spritesmith-main-9.png);background-position:-245px -1500px;width:48px;height:51px}.Pet_HatchingPotion_White{background-image:url(spritesmith-main-9.png);background-position:-294px -1500px;width:48px;height:51px}.Pet_HatchingPotion_Zombie{background-image:url(spritesmith-main-9.png);background-position:-343px -1500px;width:48px;height:51px}.head_special_0,.weapon_special_0{width:105px;height:105px;margin-left:-3px;margin-top:-18px}.broad_armor_special_0,.shield_special_0,.slim_armor_special_0{width:90px;height:90px}.weapon_special_critical{background:url(/common/img/sprites/backer-only/weapon_special_critical.gif) no-repeat;width:90px;height:90px;margin-left:-12px;margin-top:12px}.weapon_special_1{margin-left:-12px}.broad_armor_special_1,.head_special_1,.slim_armor_special_1{width:90px;height:90px}.head_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeHelmet.gif) no-repeat}.head_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalHelmet.gif) no-repeat;margin-top:3px}.broad_armor_special_0,.slim_armor_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Equip-ShadeArmor.gif) no-repeat}.broad_armor_special_1,.slim_armor_special_1{background:url(/common/img/sprites/backer-only/ContributorOnly-Equip-CrystalArmor.gif) no-repeat}.shield_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Shield-TormentedSkull.gif) no-repeat}.weapon_special_0{background:url(/common/img/sprites/backer-only/BackerOnly-Weapon-DarkSoulsBlade.gif) no-repeat}.Pet-Wolf-Cerberus{width:105px;height:72px;background:url(/common/img/sprites/backer-only/BackerOnly-Pet-CerberusPup.gif) no-repeat}.npc_ian{background:url(/common/img/sprites/npc_ian.gif) no-repeat;width:78px;height:135px}.quest_burnout{background:url(/common/img/sprites/quest_burnout.gif) no-repeat;width:219px;height:249px}.Gems{display:inline-block;margin-right:5px;border-style:none;margin-left:0;margin-top:2px}.inline-gems{vertical-align:middle;margin-left:0;display:inline-block}.customize-menu .locked{background-color:#727272}.achievement{float:left;clear:right;margin-right:10px}.multi-achievement{margin:auto;padding-left:.5em;padding-right:.5em}[class*=Mount_Body_],[class*=Mount_Head_]{margin-top:18px}.Pet_Currency_Gem{margin-top:5px;margin-bottom:5px} \ No newline at end of file diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 8ba7fc21a0..bbe050f141 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -196,8 +196,6 @@ api.scoreTask = { }).exec() .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); - - let delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language}); }) .then(() => res.respond(200, {})) // TODO what to return .catch(next); diff --git a/website/src/models/user.js b/website/src/models/user.js index bdcc479ea9..3607dcc1b2 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -509,11 +509,11 @@ function _populateDefaultTasks (user, taskTypes) { if (tagsI !== -1) { taskTypes = _.clone(taskTypes); taskTypes.splice(tagsI, 1); - }; + } _.each(taskTypes, (taskType) => { let tasksOfType = _.map(shared.content.userDefaults[`${taskType}s`], (taskDefaults) => { - let newTask = new (Tasks[taskType])(taskDefaults); + let newTask = new Tasks[taskType](taskDefaults); newTask.userId = user._id; newTask.text = taskDefaults.text(user.preferences.language); From 506609cc2983dea41f308838ab11b3ae678d4e8c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 3 Dec 2015 17:48:32 +0100 Subject: [PATCH 203/976] starts writing tests for tasks, fix errors in auth middleware and tasks methods --- .../tasks/POST-create_task.test.js | 31 +++++++++++++++++ website/src/controllers/api-v3/auth.js | 2 -- website/src/controllers/api-v3/tasks.js | 33 +++++++++++++++++++ website/src/middlewares/api-v3/auth.js | 5 +-- 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 test/api/v3/integration/tasks/POST-create_task.test.js diff --git a/test/api/v3/integration/tasks/POST-create_task.test.js b/test/api/v3/integration/tasks/POST-create_task.test.js new file mode 100644 index 0000000000..ebf3936ad3 --- /dev/null +++ b/test/api/v3/integration/tasks/POST-create_task.test.js @@ -0,0 +1,31 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; +import { v4 as generateRandomUserName } from 'uuid'; +import { each } from 'lodash'; + +describe('POST /tasks', () => { + let user; + let api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + context('checks "type" is present and a valid value', () => { + it('returns an error if req.body.type is absent', () => { + expect(api.post('/tasks', { + notType: 'habit', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 7e7668ff9a..7a818d9877 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -44,7 +44,6 @@ api.registerLocal = { }); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); let { email, username, password } = req.body; @@ -152,7 +151,6 @@ api.loginLocal = { }); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); req.sanitizeBody('username').trim(); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index bbe050f141..41177e3ad0 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -25,6 +25,9 @@ api.createTask = { handler (req, res, next) { req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + let user = res.locals.user; let taskType = req.body.type; @@ -60,6 +63,9 @@ api.getTasks = { handler (req, res, next) { req.checkQuery('type', res.t('invalidTaskType')).isIn(Tasks.tasksTypes); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + let user = res.locals.user; let query = {userId: user._id}; let type = req.query.type; @@ -115,6 +121,9 @@ api.getTask = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, @@ -147,6 +156,9 @@ api.updateTask = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, @@ -188,6 +200,9 @@ api.scoreTask = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + let user = res.locals.user; Tasks.Task.findOne({ @@ -223,6 +238,9 @@ api.moveTask = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + let user = res.locals.user; let to = Number(req.params.position); @@ -274,6 +292,9 @@ api.addChecklistItem = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty and is an array + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, @@ -311,6 +332,9 @@ api.scoreCheckListItem = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, @@ -351,6 +375,9 @@ api.updateChecklistItem = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, @@ -392,6 +419,9 @@ api.removeChecklistItem = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, @@ -446,6 +476,9 @@ api.deleteTask = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index f2158278bf..6c1991f460 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -11,8 +11,8 @@ import { // If optional is true, don't error on missing authentication export function authWithHeaders (optional = false) { return function authWithHeadersHandler (req, res, next) { - let userId = req.header['x-api-user']; - let apiToken = req.header['x-api-key']; + let userId = req.header('x-api-user'); + let apiToken = req.header('x-api-key'); if (!userId || !apiToken) { if (optional) return next(); @@ -30,6 +30,7 @@ export function authWithHeaders (optional = false) { res.locals.user = user; // TODO use either session/cookie or headers, not both + req.session = req.session || {}; req.session.userId = user._id; next(); }) From c0a99eec8b039a45dccfbebcad076e62a96a4dff Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 3 Dec 2015 18:15:22 +0100 Subject: [PATCH 204/976] fix user model, sanitize some fields on task creation, add some tests and comments --- .../tasks/POST-create_task.test.js | 23 ++++++++++++++++++- website/src/controllers/api-v3/tasks.js | 8 +++---- website/src/models/task.js | 10 ++++++-- website/src/models/user.js | 2 ++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-create_task.test.js b/test/api/v3/integration/tasks/POST-create_task.test.js index ebf3936ad3..90234d06a3 100644 --- a/test/api/v3/integration/tasks/POST-create_task.test.js +++ b/test/api/v3/integration/tasks/POST-create_task.test.js @@ -17,7 +17,7 @@ describe('POST /tasks', () => { }); }); - context('checks "type" is present and a valid value', () => { + context('checks "req.body.type"', () => { it('returns an error if req.body.type is absent', () => { expect(api.post('/tasks', { notType: 'habit', @@ -27,5 +27,26 @@ describe('POST /tasks', () => { message: t('invalidReqParams'), }); }); + + it('returns an error if req.body.type is not valid', () => { + expect(api.post('/tasks', { + type: 'habitF', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + }); + + context('checks "task.userId"', () => { + it('sets "task.userId" to valid value', () => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((task) => { + expect(task.userId).to.equal(user._id); + }); + }); }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 41177e3ad0..1431e3c0a2 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -31,16 +31,16 @@ api.createTask = { let user = res.locals.user; let taskType = req.body.type; - let newTask = new Tasks[taskType](Tasks.Task.sanitize(req.body)); + let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(req.body)); newTask.userId = user._id; - user.tasksOrder[taskType].unshift(newTask._id); + user.tasksOrder[taskType + 's'].unshift(newTask._id); Q.all([ newTask.save(), user.save(), ]) - .then(([task]) => res.respond(201, task)) + .then((results) => res.respond(201, results[0])) .catch(next); }, }; @@ -478,7 +478,7 @@ api.deleteTask = { let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - + Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, diff --git a/website/src/models/task.js b/website/src/models/task.js index d697bac622..c7c368c059 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -18,7 +18,7 @@ export let TaskSchema = new Schema({ text: {type: String, required: true}, notes: {type: String, default: ''}, tags: {type: Schema.Types.Mixed, default: {}}, // TODO dictionary? { "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, validate - value: {type: Number, default: 0}, // redness + value: {type: Number, default: 0}, // redness or cost for rewards priority: {type: Number, default: 1, required: true}, attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, userId: {type: String, ref: 'User'}, // When null it belongs to a challenge @@ -40,8 +40,14 @@ TaskSchema.plugin(baseModel, { timestamps: true, }); +// A list of additional fields that cannot be set on creation (but can be set on updare) +let noCreate = ['completed']; +TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) { + return Task.sanitize(createObj, noCreate); // eslint-disable-line no-use-before-define +}; + // A list of additional fields that cannot be updated (but can be set on creation) -let noUpdate = ['_id', 'type']; +let noUpdate = ['_id', 'type']; // TODO should prevent changes to checlist.*.id TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define }; diff --git a/website/src/models/user.js b/website/src/models/user.js index 3607dcc1b2..0febc4cab0 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -628,6 +628,8 @@ schema.pre('save', true, function preSaveUser (next, done) { _populateDefaultsForNewUser(this) .then(() => done()) .catch(done); + } else { + done(); } }); From a6648fc63869a87e6978ecf1673b0b4fc591f820 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 3 Dec 2015 19:19:53 +0100 Subject: [PATCH 205/976] add tags routes, misc fixes --- common/locales/en/api-v3.json | 9 +- .../tasks/POST-create_task.test.js | 15 +++ website/src/controllers/api-v3/tasks.js | 95 ++++++++++++++++++- website/src/models/task.js | 7 +- 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index b01d0eed99..0b810c76d1 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -20,9 +20,12 @@ "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.", "checklistOnlyDailyTodo": "Checklists are supported only on dailies and todos", - "checklistItemNotFound": "No checklist item was wound with given id.", - "itemIdRequired": "\"itemId\" must be a valid UUID", + "checklistItemNotFound": "No checklist item was found with given id.", + "itemIdRequired": "\"itemId\" must be a valid UUID.", + "tagNotFound": "No tag item was found with given id.", + "tagIdRequired": "\"tagId\" must be a valid UUID corresponding to a tag belonging to the user.", "positionRequired": "\"position\" is required and must be a number.", "cantMoveCompletedTodo": "Can't move a completed todo.", - "directionUpDown": "\"direction\" is required and must be 'up' or 'down'" + "directionUpDown": "\"direction\" is required and must be 'up' or 'down'", + "alreadyTagged": "The task is already tagged with give tag." } diff --git a/test/api/v3/integration/tasks/POST-create_task.test.js b/test/api/v3/integration/tasks/POST-create_task.test.js index 90234d06a3..b76d6ceb4e 100644 --- a/test/api/v3/integration/tasks/POST-create_task.test.js +++ b/test/api/v3/integration/tasks/POST-create_task.test.js @@ -49,4 +49,19 @@ describe('POST /tasks', () => { }); }); }); + + context('correctly creates new tasks', () => { + it('habit', () => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + up: false, + down: true, + history: 'i cannot be set', + notes: 1976, + }).then((task) => { + expect(task.userId).to.equal(user._id); + }); + }); + }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 1431e3c0a2..065e8a9f7b 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -34,7 +34,7 @@ api.createTask = { let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(req.body)); newTask.userId = user._id; - user.tasksOrder[taskType + 's'].unshift(newTask._id); + user.tasksOrder[`${taskType}s`].unshift(newTask._id); Q.all([ newTask.save(), @@ -155,6 +155,7 @@ api.updateTask = { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty + // TODO make sure tags are updated correctly (they aren't set as modified!) maybe use specific routes let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); @@ -172,7 +173,7 @@ api.updateTask = { task.checklist = req.body.checklist; } // TODO merge goes deep into objects, it's ok? - // TODO also check that array fields are updated correctly without marking modified + // TODO also check that array and mixed fields are updated correctly without marking modified _.merge(task, Tasks.Task.sanitizeUpdate(req.body)); return task.save(); }) @@ -273,7 +274,7 @@ api.moveTask = { }; /** - * @api {post} /tasks/:taskId/checklist/addItem Add an item to a checklist, creating the checklist if it doesn't exist + * @api {post} /tasks/:taskId/checklist Add an item to a checklist, creating the checklist if it doesn't exist * @apiVersion 3.0.0 * @apiName AddChecklistItem * @apiGroup Task @@ -284,7 +285,7 @@ api.moveTask = { */ api.addChecklistItem = { method: 'POST', - url: '/tasks/:taskId/checklist/addItem', + url: '/tasks/:taskId/checklist', middlewares: [authWithHeaders()], handler (req, res, next) { let user = res.locals.user; @@ -441,6 +442,92 @@ api.removeChecklistItem = { }, }; +/** + * @api {post} /tasks/:taskId/tags/:tagId Add a tag to a task + * @apiVersion 3.0.0 + * @apiName AddTagToTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * @apiParam {UUID} tagId The tag id + * + * @apiSuccess {object} task The updated task + */ +api.addTagToTask = { + method: 'POST', + url: '/tasks/:taskId/tags', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + let userTags = user.tags.map(tag => tag._id); + req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Tasks.Task.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + let tagId = req.params.tagId; + + let alreadyTagged = task.tags.indexOf(tagId) === -1; + if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged')); + + task.tags.push(tagId); + return task.save(); + }) + .then((savedTask) => res.respond(200, savedTask)) // TODO what to return + .catch(next); + }, +}; + +/** + * @api {delete} /tasks/:taskId/tags/:tagId Remove a tag + * @apiVersion 3.0.0 + * @apiName RemoveTagFromTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * @apiParam {UUID} tagId The tag id + * + * @apiSuccess {object} empty An empty object + */ +api.removeTagFromTask = { + method: 'DELETE', + url: '/tasks/:taskId/tags/:tagId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Tasks.Task.findOne({ + _id: req.params.taskId, + userId: user._id, + }).exec() + .then((task) => { + if (!task) throw new NotFound(res.t('taskNotFound')); + + let tagI = _.findIndex(task.tags, {_id: req.params.tagId}); + if (tagI === -1) throw new NotFound(res.t('tagNotFound')); + + task.tags.splice(tagI, 1); + return task.save(); + }) + .then(() => res.respond(200, {})) // TODO what to return + .catch(next); + }, +}; + // Remove a task from user.tasksOrder function _removeTaskTasksOrder (user, taskId) { // Loop through all lists and when the task is found, remove it and return diff --git a/website/src/models/task.js b/website/src/models/task.js index c7c368c059..ad9411282f 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -17,9 +17,12 @@ export let TaskSchema = new Schema({ type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]}, text: {type: String, required: true}, notes: {type: String, default: ''}, - tags: {type: Schema.Types.Mixed, default: {}}, // TODO dictionary? { "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, validate + tags: [{ + type: String, + validate: [validator.isUUID, 'Invalid uuid.'], + }], value: {type: Number, default: 0}, // redness or cost for rewards - priority: {type: Number, default: 1, required: true}, + priority: {type: Number, default: 1, required: true}, // TODO enum? attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, userId: {type: String, ref: 'User'}, // When null it belongs to a challenge From 1e9386f7b63b3cc1a9aac41acd0566502242928b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 4 Dec 2015 14:47:02 +0100 Subject: [PATCH 206/976] create model for tags, add routes --- website/src/controllers/api-v3/tags.js | 151 +++++++++++++++++++++++++ website/src/models/tag.js | 18 +++ website/src/models/user.js | 11 +- 3 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 website/src/controllers/api-v3/tags.js create mode 100644 website/src/models/tag.js diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js new file mode 100644 index 0000000000..4917858449 --- /dev/null +++ b/website/src/controllers/api-v3/tags.js @@ -0,0 +1,151 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import { model as Tag } from '../../models/tag'; +import { + NotFound, +} from '../../libs/api-v3/errors'; +import _ from 'lodash'; + +let api = {}; + +/** + * @api {post} /tags Create a new tag + * @apiVersion 3.0.0 + * @apiName CreateTag + * @apiGroup Tag + * + * @apiSuccess {Object} tag The newly created tag + */ +api.createTag = { + method: 'POST', + url: '/tags', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + user.tags.push(Tag.sanitize(req.body)); + + user.save() + .then((savedUser) => { + let l = savedUser.tags.length; + let tag = savedUser.tags[l - 1]; + res.respond(201, tag); + }) + .catch(next); + }, +}; + +/** + * @api {get} /tag Get an user's tags + * @apiVersion 3.0.0 + * @apiName GetTags + * @apiGroup Tag + * + * @apiSuccess {Array} tags An array of tag objects + */ +api.getTags = { + method: 'GET', + url: '/tags', + middlewares: [authWithHeaders()], + handler (req, res) { + let user = res.locals.user; + res.respond(200, user.tags); + }, +}; + +/** + * @api {get} /tags/:tagId Get a tag given its id + * @apiVersion 3.0.0 + * @apiName GetTag + * @apiGroup Tag + * + * @apiParam {UUID} tagId The tag _id + * + * @apiSuccess {object} tag The tag object + */ +api.getTag = { + method: 'GET', + url: '/tags/:tagId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('taskId', res.t('tagIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + let tag = user.tags.id(req.params.tagId); + if (!tag) return next(new NotFound(res.t('tagNotFound'))); + res.respond(200, tag); + }, +}; + +/** + * @api {put} /tag/:tagId Update a tag + * @apiVersion 3.0.0 + * @apiName UpdateTag + * @apiGroup Tag + * + * @apiParam {UUID} tagId The tag _id + * + * @apiSuccess {object} tag The updated tag + */ +api.updateTag = { + method: 'PUT', + url: '/tags/:tagId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); + // TODO check that req.body isn't empty + + let tagId = req.params.id; + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + let tag = user.tags.id(tagId); + if (!tag) return next(new NotFound(res.t('tagNotFound'))); + + _.merge(tag, Tag.sanitize(req.body)); + + user.save() + .then((savedUser) => res.respond(200, savedUser.tags.id(tagId))) + .catch(next); + }, +}; + +/** + * @api {delete} /tag/:tagId Delete a user tag given its id + * @apiVersion 3.0.0 + * @apiName DeleteTag + * @apiGroup Tag + * + * @apiParam {UUID} tagId The tag _id + * + * @apiSuccess {object} empty An empty object + */ +api.deleteTag = { + method: 'GET', + url: '/tags/:tagId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + let tag = user.tags.id(req.params.tagId); + if (!tag) return next(new NotFound(res.t('tagNotFound'))); + tag.remove(); + + user.save() + .then(() => res.respond(200, {})) + .catch(next); + }, +}; + +export default api; diff --git a/website/src/models/tag.js b/website/src/models/tag.js new file mode 100644 index 0000000000..a7cc1ca243 --- /dev/null +++ b/website/src/models/tag.js @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; +import baseModel from '../libs/api-v3/baseModel'; + +let Schema = mongoose.Schema; + +export let schema = new Schema({ + name: {type: String, required: true}, + challenge: {type: String}, // TODO validate +}, { + minimize: true, // So empty objects are returned + strict: true, +}); + +schema.plugin(baseModel, { + noSet: ['_id', 'challenge'], +}); + +export let model = mongoose.model('Tag', TagSchema); diff --git a/website/src/models/user.js b/website/src/models/user.js index 0febc4cab0..e10735c441 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -5,6 +5,7 @@ import validator from 'validator'; import moment from 'moment'; import * as Tasks from './task'; import Q from 'q'; +import { schema as TagSchema } from './tag'; import baseModel from '../libs/api-v3/baseModel'; // import {model as Challenge} from './challenge'; @@ -438,13 +439,7 @@ export let schema = new Schema({ }, }, - tags: {type: [{ - _id: false, - id: {type: String, default: shared.uuid}, - name: String, - challenge: String, - }]}, - + tags: [TagSchema], challenges: [{type: String, ref: 'Challenge'}], inbox: { @@ -474,7 +469,7 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder'], + noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function toJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? From c2ba90afcc3eb9e12fd8790057c41db2ead65bc9 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 4 Dec 2015 09:13:50 -0600 Subject: [PATCH 207/976] tests(fix): Add return to expect promise in create task test --- test/api/v3/integration/tasks/POST-create_task.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-create_task.test.js b/test/api/v3/integration/tasks/POST-create_task.test.js index b76d6ceb4e..b37ef37a62 100644 --- a/test/api/v3/integration/tasks/POST-create_task.test.js +++ b/test/api/v3/integration/tasks/POST-create_task.test.js @@ -19,7 +19,7 @@ describe('POST /tasks', () => { context('checks "req.body.type"', () => { it('returns an error if req.body.type is absent', () => { - expect(api.post('/tasks', { + return expect(api.post('/tasks', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -29,7 +29,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.type is not valid', () => { - expect(api.post('/tasks', { + return expect(api.post('/tasks', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ code: 400, From b0bcfff12d72204fc268c522c7063bbeac08b291 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 4 Dec 2015 10:24:40 -0600 Subject: [PATCH 208/976] tests(tasks): Add pending tests for create task test --- .../tasks/POST-create_task.test.js | 114 ++++++++++++++++-- 1 file changed, 105 insertions(+), 9 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-create_task.test.js b/test/api/v3/integration/tasks/POST-create_task.test.js index b37ef37a62..112e673aa0 100644 --- a/test/api/v3/integration/tasks/POST-create_task.test.js +++ b/test/api/v3/integration/tasks/POST-create_task.test.js @@ -7,8 +7,7 @@ import { v4 as generateRandomUserName } from 'uuid'; import { each } from 'lodash'; describe('POST /tasks', () => { - let user; - let api; + let user, api; before(() => { return generateUser().then((generatedUser) => { @@ -17,7 +16,7 @@ describe('POST /tasks', () => { }); }); - context('checks "req.body.type"', () => { + context('validates params', () => { it('returns an error if req.body.type is absent', () => { return expect(api.post('/tasks', { notType: 'habit', @@ -37,10 +36,12 @@ describe('POST /tasks', () => { message: t('invalidReqParams'), }); }); - }); - context('checks "task.userId"', () => { - it('sets "task.userId" to valid value', () => { + it('returns an error if req.body.text is absent'); + + it('ignores setting userId field'); + + it('automatically sets "task.userId" to user\'s uuid', () => { return api.post('/tasks', { text: 'test habit', type: 'habit', @@ -48,20 +49,115 @@ describe('POST /tasks', () => { expect(task.userId).to.equal(user._id); }); }); + + it('ignores setting history field'); + + it('ignores setting createdAt field'); + + it('ignores setting updatedAt field'); + + it('ignores setting challenge field'); + + it('ignores setting value field'); + + it('ignores setting completed field'); + + it('ignores setting streak field'); + + it('ignores setting dateCompleted field'); + + it('ignores invalid fields'); }); - context('correctly creates new tasks', () => { - it('habit', () => { + context('habits', () => { + it('creates a habit', () => { return api.post('/tasks', { text: 'test habit', type: 'habit', up: false, down: true, - history: 'i cannot be set', notes: 1976, }).then((task) => { expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); }); }); + + it('defaults to setting up and down to true'); + + it('cannot create checklists'); + }); + + context('todos', () => { + it('creates a todo', () => { + return api.post('/tasks', { + text: 'test todo', + type: 'todo', + notes: 1976, + }).then((task) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); + }); + }); + + it('can create checklists'); + }); + + context('dailys', () => { + it('creates a daily', () => { + let now = new Date(); + + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + notes: 1976, + frequency: 'daily', + everyX: 5, + startDate: now, + }).then((task) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + expect(task.frequency).to.eql('daily'); + expect(task.everyX).to.eql(5); + expect(task.startDate).to.eql(now); + }); + }); + + it('defaults to a weekly frequency, with every day set'); + + it('allows repeat field to be configured'); + + it('defaults startDate to today'); + + it('can create checklists'); + }); + + context('rewards', () => { + it('creates a reward', () => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + notes: 1976, + value: 10, + }).then((task) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test reward'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('reward'); + expect(task.value).to.eql(10); + }); + }); + + it('defaults to a 0 value'); + + it('cannot create checklists'); }); }); From 49cc6db05b4560441a225081037de16c01cb1505 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 4 Dec 2015 10:25:27 -0600 Subject: [PATCH 209/976] tests(tasks): Rename test to conform to route style --- .../tasks/{POST-create_task.test.js => POST-tasks.test.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/api/v3/integration/tasks/{POST-create_task.test.js => POST-tasks.test.js} (100%) diff --git a/test/api/v3/integration/tasks/POST-create_task.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js similarity index 100% rename from test/api/v3/integration/tasks/POST-create_task.test.js rename to test/api/v3/integration/tasks/POST-tasks.test.js From 1a2cda0835b960230513c7420b2bb0b7982b22ad Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 4 Dec 2015 10:39:20 -0600 Subject: [PATCH 210/976] tests: Add additional pending tests --- .../v3/integration/tasks/POST-tasks.test.js | 2 + .../v3/integration/tasks/PUT-tasks_id.test.js | 157 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 test/api/v3/integration/tasks/PUT-tasks_id.test.js diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 112e673aa0..d966268b68 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -158,6 +158,8 @@ describe('POST /tasks', () => { it('defaults to a 0 value'); + it('requires value to be coerced into a number'); + it('cannot create checklists'); }); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js new file mode 100644 index 0000000000..643a1f29a8 --- /dev/null +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -0,0 +1,157 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; +import { v4 as generateRandomUserName } from 'uuid'; +import { each } from 'lodash'; + +describe('PUT /tasks/:id', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + context('validates params', () => { + let task; + + beforeEach(() => { + // create sample task + // task = createdTask + }); + + it('returns an error if req.body.type is not valid', () => { + return expect(api.put(`/tasks/${task._id}`, { + type: 'habitF', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('ignores setting userId field'); + + it('ignores setting history field'); + + it('ignores setting createdAt field'); + + it('ignores setting updatedAt field'); + + it('ignores setting challenge field'); + + it('ignores setting value field'); + + it('ignores setting completed field'); + + it('ignores setting streak field'); + + it('ignores setting dateCompleted field'); + + it('ignores invalid fields'); + }); + + context('habits', () => { + let habit; + + beforeEach(() => { + // create existing habit + // habit = createdHabit; + }); + + it('updates a habit', () => { + return api.put(`/tasks/${habit._id}`, { + text: 'some new text', + up: false, + down: false, + notes: 'some new notes', + }).then((task) => { + expect(task.text).to.eql('some new text'); + expect(task.notes).to.eql('some new notes'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(false); + }); + }); + }); + + context('todos', () => { + let todo; + + beforeEach(() => { + // create existing todo + // todo = createdTodo; + }); + + it('updates a todo', () => { + return api.put(`/tasks/${todo._id}`, { + text: 'some new text', + notes: 'some new notes', + }).then((task) => { + expect(task.text).to.eql('some new text'); + expect(task.notes).to.eql('some new notes'); + }); + }); + + it('can update checklists'); // Can it? + }); + + context('dailys', () => { + let daily; + + beforeEach(() => { + // create existing daily + // daily = createdDaily; + }); + + it('updates a daily', () => { + let now = new Date(); + + return api.put(`/tasks/${daily._id}`, { + text: 'some new text', + notes: 'some new notes', + frequency: 'daily', + everyX: 5, + }).then((task) => { + expect(task.text).to.eql('some new text'); + expect(task.notes).to.eql('some new notes'); + expect(task.frequency).to.eql('daily'); + expect(task.everyX).to.eql(5); + }); + }); + + it('can update checklists'); // Can it? + + it('updates repeat, even if frequency is set to daily'); + + it('updates everyX, even if frequency is set to weekly'); + + it('defaults startDate to today if none date object is passed in'); + }); + + context('rewards', () => { + let reward; + + beforeEach(() => { + // create existing reward + // reward = createdReward; + }); + + it('updates a reward', () => { + return api.put(`/tasks/${reward._id}`, { + text: 'some new text', + notes: 'some new notes', + value: 10, + }).then((task) => { + expect(task.text).to.eql('some new text'); + expect(task.notes).to.eql('some new notes'); + expect(task.value).to.eql(10); + }); + }); + + it('requires value to be coerced into a number'); + }); +}); From 5aca1668370a5b9715646726f2117c63fca7602a Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 4 Dec 2015 10:50:41 -0600 Subject: [PATCH 211/976] tests: Add delete task integration test for v3 --- .../integration/tasks/DELETE-tasks_id.test.js | 35 +++++++++++++++++++ .../v3/integration/tasks/PUT-tasks_id.test.js | 2 -- 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 test/api/v3/integration/tasks/DELETE-tasks_id.test.js diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js new file mode 100644 index 0000000000..7cdd2209dd --- /dev/null +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -0,0 +1,35 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('DELETE /tasks/:id', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + context('task can be deleted', () => { + let task; + + beforeEach(() => { + // generate task + // task = generatedTask; + }); + + it('deletes a user\'s task'); + }); + + context('task cannot be deleted', () => { + it('cannot delete a non-existant task'); + + it('cannot delete a task owned by someone else'); + + it('cannot delete active challenge tasks'); + }); +}); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 643a1f29a8..d5e938efc5 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -3,8 +3,6 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; -import { v4 as generateRandomUserName } from 'uuid'; -import { each } from 'lodash'; describe('PUT /tasks/:id', () => { let user, api; From 431fc571faf8eb803bcbb23ba85ebca60901559e Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 4 Dec 2015 10:55:15 -0600 Subject: [PATCH 212/976] tests: Add pending tests for getting a specific task in v3 --- .../v3/integration/tasks/GET-tasks_id.test.js | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 test/api/v3/integration/tasks/GET-tasks_id.test.js diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js new file mode 100644 index 0000000000..6c6c722703 --- /dev/null +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -0,0 +1,35 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /tasks/:id', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + context('task can be accessed', () => { + let task; + + beforeEach(() => { + // generate task + // task = generatedTask; + }); + + it('gets specified task'); + + it('can get active challenge task that user does not own'); // Yes? + }); + + context('task cannot accessed', () => { + it('cannot get a non-existant task'); + + it('cannot get a task owned by someone else'); + }); +}); From 834ae123e8df81e766d949b71db7f8bc882c84a0 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 4 Dec 2015 10:56:54 -0600 Subject: [PATCH 213/976] tests: Add pending test for GET /tasks --- .../api/v3/integration/tasks/GET-tasks.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 test/api/v3/integration/tasks/GET-tasks.test.js diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js new file mode 100644 index 0000000000..3faf65c771 --- /dev/null +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -0,0 +1,18 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /tasks', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('returns all user\'s tasks'); +}); From 8a872bd8c035e14f625d3fb53eba1e20d953a486 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 4 Dec 2015 11:12:25 -0600 Subject: [PATCH 214/976] tests: Add pending tests for scoring tasks --- .../POST-tasks_score_id_direction.test.js | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js diff --git a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js new file mode 100644 index 0000000000..46264aee7c --- /dev/null +++ b/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js @@ -0,0 +1,121 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /tasks/score/:id/:direction', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + context('all', () => { + it('requires a task id'); + + it('requires a task direction'); + }); + + context('todos', () => { + let todo; + + beforeEach(() => { + // todo = createdTodo + }); + + it('completes todo when direction is up'); + + it('uncompletes todo when direction is down'); + + it('scores up todo even if it is already completed'); // Yes? + + it('scores down todo even if it is already uncompleted'); // Yes? + + it('increases user\'s mp when direction is up'); + + it('decreases user\'s mp when direction is down'); + + it('increases user\'s exp when direction is up'); + + it('decreases user\'s exp when direction is down'); + + it('increases user\'s gold when direction is up'); + + it('decreases user\'s gold when direction is down'); + }); + + context('dailys', () => { + let daily; + + beforeEach(() => { + // daily = createdDaily + }); + + it('completes daily when direction is up'); + + it('uncompletes daily when direction is down'); + + it('scores up daily even if it is already completed'); // Yes? + + it('scores down daily even if it is already uncompleted'); // Yes? + + it('increases user\'s mp when direction is up'); + + it('decreases user\'s mp when direction is down'); + + it('increases user\'s exp when direction is up'); + + it('decreases user\'s exp when direction is down'); + + it('increases user\'s gold when direction is up'); + + it('decreases user\'s gold when direction is down'); + }); + + context('habits', () => { + let habit, minusHabit, plusHabit, neitherHabit; + + beforeEach(() => { + // habit = createdHabit + // plusHabit = createdPlusHabit + // minusHabit = createdMinusHabit + // neitherHabit = createdNeitherHabit + }); + + it('prevents plus only habit from scoring down'); // Yes? + + it('prevents minus only habit from scoring up'); // Yes? + + it('increases user\'s mp when direction is up'); + + it('decreases user\'s mp when direction is down'); + + it('increases user\'s exp when direction is up'); + + it('decreases user\'s exp when direction is down'); + + it('increases user\'s gold when direction is up'); + + it('decreases user\'s gold when direction is down'); + }); + + context('reward', () => { + let reward; + + beforeEach(() => { + // reward = createdReward + }); + + it('purchases reward'); + + it('does not change user\'s mp'); + + it('does not change user\'s exp'); + + it('does not allow a down direction'); + }); +}); From cb08c383b982973a15b3bc2a63e129dd14b7c1c0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 6 Dec 2015 16:51:18 +0100 Subject: [PATCH 215/976] misc fixes, add tests for some tasks routes --- .../integration/tasks/DELETE-tasks_id.test.js | 46 ++++++++++++++++--- .../v3/integration/tasks/GET-tasks.test.js | 31 ++++++++++++- website/src/controllers/api-v3/tasks.js | 10 ++-- website/src/models/tag.js | 2 +- 4 files changed, 77 insertions(+), 12 deletions(-) diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 7cdd2209dd..5543345220 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -18,18 +18,52 @@ describe('DELETE /tasks/:id', () => { let task; beforeEach(() => { - // generate task - // task = generatedTask; + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((createdTask) => { + task = createdTask; + }); }); - it('deletes a user\'s task'); + it('deletes a user\'s task', () => { + return api.del('/tasks/' + task._id) + .then(() => { + return expect(api.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + }); }); context('task cannot be deleted', () => { - it('cannot delete a non-existant task'); + it('cannot delete a non-existant task', () => { + return expect(api.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); - it('cannot delete a task owned by someone else'); + it('cannot delete a task owned by someone else', () => { + return generateUser() + .then((user2) => { + return requester(user2).post('/tasks', { + text: 'test habit', + type: 'habit', + }) + }) + .then((task2) => { + return expect(api.del('/tasks/' + task2._id)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + }); - it('cannot delete active challenge tasks'); + it('cannot delete active challenge tasks'); // TODO after challenges are implemented }); }); diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 3faf65c771..2ef56ff1fe 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import Q from 'q'; describe('GET /tasks', () => { let user, api; @@ -14,5 +15,33 @@ describe('GET /tasks', () => { }); }); - it('returns all user\'s tasks'); + it('returns all user\'s tasks', () => { + let length; + return Q.all([ + api.post('/tasks', {text: 'test habit', type: 'habit'}), + ]) + .then((createdTasks) => { + length = createdTasks.length; + return api.get('/tasks'); + }) + .then((tasks) => { + expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task + }); + }); + + it('returns only a type of user\'s tasks if req.query.type is specified', () => { + let habitId; + api.post('/tasks', {text: 'test habit', type: 'habit'}) + .then((task) => { + habitId = task._id; + return api.get('/tasks?type=habit'); + }) + .then((tasks) => { + expect(tasks.length).to.equal(1); + expect(tasks[0]._id).to.equal(habitId); + }); + }); + + // TODO complete after task scoring is done + it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified') }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 065e8a9f7b..509aafbe02 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -18,6 +18,8 @@ let api = {}; * * @apiSuccess {Object} task The newly created task */ +// TODO should allow to create multiple tasks at once +// TODO gives problems when creating tasks concurrently because of how mongoose treats arrays (VersionErrors - treated as 500s) api.createTask = { method: 'POST', url: '/tasks', @@ -61,7 +63,7 @@ api.getTasks = { url: '/tasks', middlewares: [authWithHeaders()], handler (req, res, next) { - req.checkQuery('type', res.t('invalidTaskType')).isIn(Tasks.tasksTypes); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); @@ -74,7 +76,7 @@ api.getTasks = { query.type = type; if (type === 'todo') query.completed = false; // Exclude completed todos } else { - query.$and = [ // Exclude completed todos + query.$or = [ // Exclude completed todos {type: 'todo', completed: false}, {type: {$in: ['habit', 'daily', 'reward']}}, ]; @@ -532,7 +534,7 @@ api.removeTagFromTask = { function _removeTaskTasksOrder (user, taskId) { // Loop through all lists and when the task is found, remove it and return for (let i = 0; i < Tasks.tasksTypes.length; i++) { - let list = user.tasksOrder[Tasks.tasksTypes[i]]; + let list = user.tasksOrder[`${Tasks.tasksTypes[i]}s`]; let index = list.indexOf(taskId); if (index !== -1) { @@ -555,7 +557,7 @@ function _removeTaskTasksOrder (user, taskId) { * @apiSuccess {object} empty An empty object */ api.deleteTask = { - method: 'GET', + method: 'DELETE', url: '/tasks/:taskId', middlewares: [authWithHeaders()], handler (req, res, next) { diff --git a/website/src/models/tag.js b/website/src/models/tag.js index a7cc1ca243..78c1b7884e 100644 --- a/website/src/models/tag.js +++ b/website/src/models/tag.js @@ -15,4 +15,4 @@ schema.plugin(baseModel, { noSet: ['_id', 'challenge'], }); -export let model = mongoose.model('Tag', TagSchema); +export let model = mongoose.model('Tag', schema); From 955b0f042bedb414f757fa3b584ace5a372e92ce Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 6 Dec 2015 17:07:29 +0100 Subject: [PATCH 216/976] fix tests --- .../v3/integration/tasks/POST-tasks.test.js | 4 +- .../v3/integration/tasks/PUT-tasks_id.test.js | 47 ++++++++++++------- website/src/models/task.js | 4 +- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index d966268b68..e7e72e1389 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -58,8 +58,6 @@ describe('POST /tasks', () => { it('ignores setting challenge field'); - it('ignores setting value field'); - it('ignores setting completed field'); it('ignores setting streak field'); @@ -127,7 +125,7 @@ describe('POST /tasks', () => { expect(task.type).to.eql('daily'); expect(task.frequency).to.eql('daily'); expect(task.everyX).to.eql(5); - expect(task.startDate).to.eql(now); + expect(new Date(task.startDate)).to.eql(now); }); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index d5e938efc5..dc7143decf 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -22,15 +22,7 @@ describe('PUT /tasks/:id', () => { // task = createdTask }); - it('returns an error if req.body.type is not valid', () => { - return expect(api.put(`/tasks/${task._id}`, { - type: 'habitF', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); + it('ignores setting type field'); it('ignores setting userId field'); @@ -57,8 +49,13 @@ describe('PUT /tasks/:id', () => { let habit; beforeEach(() => { - // create existing habit - // habit = createdHabit; + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + notes: 1976, + }).then((createdHabit) => { + habit = createdHabit; + }); }); it('updates a habit', () => { @@ -80,8 +77,13 @@ describe('PUT /tasks/:id', () => { let todo; beforeEach(() => { - // create existing todo - // todo = createdTodo; + return api.post('/tasks', { + text: 'test todo', + type: 'todo', + notes: 1976, + }).then((createdTodo) => { + todo = createdTodo; + }); }); it('updates a todo', () => { @@ -101,8 +103,13 @@ describe('PUT /tasks/:id', () => { let daily; beforeEach(() => { - // create existing daily - // daily = createdDaily; + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + notes: 1976, + }).then((createdDaily) => { + daily = createdDaily; + }); }); it('updates a daily', () => { @@ -134,8 +141,14 @@ describe('PUT /tasks/:id', () => { let reward; beforeEach(() => { - // create existing reward - // reward = createdReward; + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + notes: 1976, + value: 10, + }).then((createdReward) => { + reward = createdReward; + }); }); it('updates a reward', () => { diff --git a/website/src/models/task.js b/website/src/models/task.js index ad9411282f..77cd0cfa2f 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -38,7 +38,9 @@ export let TaskSchema = new Schema({ }, discriminatorOptions)); TaskSchema.plugin(baseModel, { - noSet: ['challenge', 'userId', 'value', 'completed', 'history', 'streak', 'dateCompleted'], // TODO checklist fields editable? + // TODO checklist fields editable? + // TODO value should be settable only for rewards + noSet: ['challenge', 'userId', 'completed', 'history', 'streak', 'dateCompleted'], private: [], timestamps: true, }); From 66a675c5b0637db06fe70271f96f4890f9047436 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 6 Dec 2015 17:29:14 +0100 Subject: [PATCH 217/976] simplify some tests, replace tags when updating tags (like for checklist) --- .../v3/integration/tasks/POST-tasks.test.js | 16 +++---------- .../v3/integration/tasks/PUT-tasks_id.test.js | 24 ++++--------------- website/src/controllers/api-v3/tasks.js | 7 ++++++ website/src/models/task.js | 4 ++-- 4 files changed, 17 insertions(+), 34 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index e7e72e1389..b8da4a32d3 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -50,19 +50,9 @@ describe('POST /tasks', () => { }); }); - it('ignores setting history field'); - - it('ignores setting createdAt field'); - - it('ignores setting updatedAt field'); - - it('ignores setting challenge field'); - - it('ignores setting completed field'); - - it('ignores setting streak field'); - - it('ignores setting dateCompleted field'); + it(`ignores setting userId, history, createdAt, + updatedAt, challenge, completed, streak, + dateCompleted fields`); it('ignores invalid fields'); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index dc7143decf..815d8f1368 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -22,25 +22,9 @@ describe('PUT /tasks/:id', () => { // task = createdTask }); - it('ignores setting type field'); - - it('ignores setting userId field'); - - it('ignores setting history field'); - - it('ignores setting createdAt field'); - - it('ignores setting updatedAt field'); - - it('ignores setting challenge field'); - - it('ignores setting value field'); - - it('ignores setting completed field'); - - it('ignores setting streak field'); - - it('ignores setting dateCompleted field'); + it(`ignores setting _id, type, userId, history, createdAt, + updatedAt, challenge, completed, streak, + dateCompleted fields`); it('ignores invalid fields'); }); @@ -97,6 +81,7 @@ describe('PUT /tasks/:id', () => { }); it('can update checklists'); // Can it? + it('can update tags'); // Can it? }); context('dailys', () => { @@ -129,6 +114,7 @@ describe('PUT /tasks/:id', () => { }); it('can update checklists'); // Can it? + it('can update tags'); // Can it? it('updates repeat, even if frequency is set to daily'); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 509aafbe02..8a32f66194 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -174,6 +174,13 @@ api.updateTask = { delete req.body.checklist; task.checklist = req.body.checklist; } + + // If tags are updated -> replace the original ones + if (req.body.tags) { + delete req.body.tags; + task.tags = req.body.tags; + } + // TODO merge goes deep into objects, it's ok? // TODO also check that array and mixed fields are updated correctly without marking modified _.merge(task, Tasks.Task.sanitizeUpdate(req.body)); diff --git a/website/src/models/task.js b/website/src/models/task.js index 77cd0cfa2f..3ba4f21b22 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -46,13 +46,13 @@ TaskSchema.plugin(baseModel, { }); // A list of additional fields that cannot be set on creation (but can be set on updare) -let noCreate = ['completed']; +let noCreate = ['completed']; // TODO completed should be removed for updates too? TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) { return Task.sanitize(createObj, noCreate); // eslint-disable-line no-use-before-define }; // A list of additional fields that cannot be updated (but can be set on creation) -let noUpdate = ['_id', 'type']; // TODO should prevent changes to checlist.*.id +let noUpdate = ['_id', 'type']; TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define }; From af1946dd7c396b8041420efed7d82b8d99eba9a0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 6 Dec 2015 18:03:51 +0100 Subject: [PATCH 218/976] starts implementing scoreTask --- website/src/controllers/api-v3/tasks.js | 50 ++++++++++++++++++++++++- website/src/models/user.js | 2 +- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 8a32f66194..b9d9cc4519 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,10 +1,12 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import webhook from '../../libs/api-v3/webhook'; import * as Tasks from '../../models/task'; import { NotFound, NotAuthorized, BadRequest, } from '../../libs/api-v3/errors'; +import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; @@ -191,6 +193,31 @@ api.updateTask = { }, }; +function _generateWebhookTaskData (task, direction, delta, stats, user) { + let extendedStats = _.extend(stats, { + toNextLevel: shared.tnl(user.stats.lvl), + maxHealth: shared.maxHealth, + maxMP: user._statsComputed.maxMP, // TODO refactor as method not getter + }); + + let userData = { + _id: user._id, + _tmp: user._tmp, + stats: extendedStats, + }; + + let taskData = { + details: task, + direction, + delta, + }; + + return { + task: taskData, + user: userData, + }; +} + /** * @api {put} /tasks/score/:taskId/:direction Score a task * @apiVersion 3.0.0 @@ -214,6 +241,7 @@ api.scoreTask = { if (validationErrors) return next(validationErrors); let user = res.locals.user; + let direction = req.params.direction; Tasks.Task.findOne({ _id: req.params.taskId, @@ -221,8 +249,28 @@ api.scoreTask = { }).exec() .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); + + if (task.type === 'daily' || task.type === 'todo') { + task.completed = direction === 'up'; + } + + let delta = user.ops.score({params: {id: task._id, direction}, language: req.language}); + + return Q.all([ + user.save(), + task.save(), + ]).then((results) => { + let savedUser = results[0]; + + let userStats = savedUser.toJSON().stats; + let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); + res.respond(200, resJsonData); + + webhook.sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); + + // TODO sync challenge + }); }) - .then(() => res.respond(200, {})) // TODO what to return .catch(next); }, }; diff --git a/website/src/models/user.js b/website/src/models/user.js index 03f6a132a6..0810bbfa62 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -373,7 +373,7 @@ export let schema = new Schema({ toolbarCollapsed: {type: Boolean, default: false}, background: String, displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true}, - webhooks: {type: Schema.Types.Mixed, default: {}}, + webhooks: {type: Schema.Types.Mixed, default: {}}, // TODO array? and proper controller... unless VersionError becomes problematic // For the following fields make sure to use strict comparison when searching for falsey values (=== false) // As users who didn't login after these were introduced may have them undefined/null emailNotifications: { From 0272a36bac90d49326ae266b6f680c335c93ac4d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 7 Dec 2015 14:22:54 +0100 Subject: [PATCH 219/976] finish implementing tests for POST-tasks.test.js --- .../v3/integration/tasks/POST-tasks.test.js | 193 ++++++++++++++++-- .../src/middlewares/api-v3/errorHandler.js | 2 +- 2 files changed, 180 insertions(+), 15 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index b8da4a32d3..395721032d 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -37,9 +37,24 @@ describe('POST /tasks', () => { }); }); - it('returns an error if req.body.text is absent'); + it('returns an error if req.body.text is absent', () => { + return expect(api.post('/tasks', { + type: 'habit', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + }); + }); - it('ignores setting userId field'); + it('ignores setting userId field', () => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + userId: 123, + }).then((task) => { + expect(task.userId).to.equal(user._id); + }); + }); it('automatically sets "task.userId" to user\'s uuid', () => { return api.post('/tasks', { @@ -52,9 +67,39 @@ describe('POST /tasks', () => { it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, - dateCompleted fields`); + dateCompleted fields`, () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + userId: 123, + history: [123], + createdAt: 'yesterday', + updatedAt: 'tomorrow', + challenge: 'no', + completed: true, + streak: 25, + dateCompleted: 'never', + }).then((task) => { + expect(task.userId).to.equal(user._id); + expect(task.history).to.eql([]); + expect(task.createdAt).not.to.equal('yesterday'); + expect(task.updatedAt).not.to.equal('tomorrow'); + expect(task.challenge).not.to.equal('no'); + expect(task.completed).to.equal(false); + expect(task.streak).to.equal(0); + expect(task.streak).not.to.equal('never'); + }); + }); - it('ignores invalid fields'); + it('ignores invalid fields', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + notValid: true, + }).then((task) => { + expect(task).not.to.have.property('notValid'); + }); + }); }); context('habits', () => { @@ -75,9 +120,28 @@ describe('POST /tasks', () => { }); }); - it('defaults to setting up and down to true'); + it('defaults to setting up and down to true', () => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + notes: 1976, + }).then((task) => { + expect(task.up).to.eql(true); + expect(task.down).to.eql(true); + }); + }); - it('cannot create checklists'); + it('cannot create checklists', () => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + checklist: [ + {_id: 123, completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task).not.to.have.property('checklist'); + }); + }); }); context('todos', () => { @@ -94,7 +158,22 @@ describe('POST /tasks', () => { }); }); - it('can create checklists'); + it('can create checklists', () => { + return api.post('/tasks', { + text: 'test todo', + type: 'todo', + checklist: [ + {completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); + }); + }); }); context('dailys', () => { @@ -119,13 +198,74 @@ describe('POST /tasks', () => { }); }); - it('defaults to a weekly frequency, with every day set'); + it('defaults to a weekly frequency, with every day set', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + }).then((task) => { + expect(task.frequency).to.eql('weekly'); + expect(task.everyX).to.eql(1); + expect(task.repeat).to.eql({ + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + su: true, + }); + }); + }); - it('allows repeat field to be configured'); + it('allows repeat field to be configured', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + repeat: { + m: false, + w: false, + su: false, + }, + }).then((task) => { + expect(task.repeat).to.eql({ + m: false, + t: true, + w: false, + th: true, + f: true, + s: true, + su: false, + }); + }); + }); - it('defaults startDate to today'); + it('defaults startDate to today', () => { + let today = (new Date()).getDay(); - it('can create checklists'); + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + }).then((task) => { + expect((new Date(task.startDate)).getDay()).to.eql(today); + }); + }); + + it('can create checklists', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + checklist: [ + {completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); + }); + }); }); context('rewards', () => { @@ -144,10 +284,35 @@ describe('POST /tasks', () => { }); }); - it('defaults to a 0 value'); + it('defaults to a 0 value', () => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + }).then((task) => { + expect(task.value).to.eql(0); + }); + }); - it('requires value to be coerced into a number'); + it('requires value to be coerced into a number', () => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + value: "10", + }).then((task) => { + expect(task.value).to.eql(10); + }); + }); - it('cannot create checklists'); + it('cannot create checklists', () => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + checklist: [ + {_id: 123, completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task).not.to.have.property('checklist'); + }); + }); }); }); diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index e0ad096d1c..f3b4f65cdb 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -48,7 +48,7 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l // Handle mongoose validation errors if (err.name === 'ValidationError') { - responseErr = new BadRequest(err.message); + responseErr = new BadRequest(err.message); // TODO standard message? translate? responseErr.errors = map(err.errors, (mongooseErr) => { return { message: mongooseErr.message, From 3c4491606ba5ae14d90083087f18cae229b01630 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 7 Dec 2015 21:00:15 +0100 Subject: [PATCH 220/976] finish PUT-tasks_id tests and fix some edge bugs --- .../v3/integration/tasks/POST-tasks.test.js | 11 +- .../v3/integration/tasks/PUT-tasks_id.test.js | 170 ++++++++++++++++-- website/src/controllers/api-v3/tasks.js | 13 +- 3 files changed, 163 insertions(+), 31 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 395721032d..09ca58be01 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -43,16 +43,7 @@ describe('POST /tasks', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - }); - }); - - it('ignores setting userId field', () => { - return api.post('/tasks', { - text: 'test habit', - type: 'habit', - userId: 123, - }).then((task) => { - expect(task.userId).to.equal(user._id); + message: 'habit validation failed', }); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 815d8f1368..7ea2440beb 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:id', () => { let user, api; @@ -14,22 +15,56 @@ describe('PUT /tasks/:id', () => { }); }); - context('validates params', () => { + xcontext('validates params', () => { let task; beforeEach(() => { - // create sample task - // task = createdTask + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((createdTask) => { + task = createdTask; + }); }); it(`ignores setting _id, type, userId, history, createdAt, updatedAt, challenge, completed, streak, - dateCompleted fields`); + dateCompleted fields`, () => { + api.put('/tasks/' + task._id, { + _id: 123, + type: 'daily', + userId: 123, + history: [123], + createdAt: 'yesterday', + updatedAt: 'tomorrow', + challenge: 'no', + completed: true, + streak: 25, + dateCompleted: 'never', + }).then((savedTask) => { + expect(savedTask._id).to.equal(task._id); + expect(savedTask.type).to.equal(task.type); + expect(savedTask.userId).to.equal(user._id); + expect(savedTask.history).to.eql([]); + expect(savedTask.createdAt).not.to.equal('yesterday'); + expect(savedTask.updatedAt).not.to.equal('tomorrow'); + expect(savedTask.challenge).not.to.equal('no'); + expect(savedTask.completed).to.equal(false); + expect(savedTask.streak).to.equal(0); + expect(savedTask.streak).not.to.equal('never'); + }); + }); - it('ignores invalid fields'); + it('ignores invalid fields', () => { + api.put('/tasks/' + task._id, { + notValid: true, + }).then((savedTask) => { + expect(savedTask.notValid).to.be.a('undefined'); + }); + }); }); - context('habits', () => { + xcontext('habits', () => { let habit; beforeEach(() => { @@ -57,7 +92,7 @@ describe('PUT /tasks/:id', () => { }); }); - context('todos', () => { + xcontext('todos', () => { let todo; beforeEach(() => { @@ -80,8 +115,38 @@ describe('PUT /tasks/:id', () => { }); }); - it('can update checklists'); // Can it? - it('can update tags'); // Can it? + it('can update checklists (replace it)', () => { + return api.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 123, completed: false}, + {text: 456, completed: true}, + ] + }).then((savedTodo) => { + return api.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 789, completed: false}, + ] + }); + }).then((savedTodo2) => { + expect(savedTodo2.checklist.length).to.equal(1); + expect(savedTodo2.checklist[0].text).to.equal("789"); + expect(savedTodo2.checklist[0].completed).to.equal(false); + }); + }); + + it('can update tags (replace them)', () => { + let finalUUID = generateUUID(); + return api.put(`/tasks/${todo._id}`, { + tags: [generateUUID(), generateUUID()], + }).then((savedTodo) => { + return api.put(`/tasks/${todo._id}`, { + tags: [finalUUID] + }); + }).then((savedTodo2) => { + expect(savedTodo2.tags.length).to.equal(1); + expect(savedTodo2.tags[0]).to.equal(finalUUID); + }); + }); }); context('dailys', () => { @@ -113,17 +178,84 @@ describe('PUT /tasks/:id', () => { }); }); - it('can update checklists'); // Can it? - it('can update tags'); // Can it? + it('can update checklists (replace it)', () => { + return api.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 123, completed: false}, + {text: 456, completed: true}, + ] + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 789, completed: false}, + ] + }); + }).then((savedDaily2) => { + expect(savedDaily2.checklist.length).to.equal(1); + expect(savedDaily2.checklist[0].text).to.equal("789"); + expect(savedDaily2.checklist[0].completed).to.equal(false); + }); + }); - it('updates repeat, even if frequency is set to daily'); + it('can update tags (replace them)', () => { + let finalUUID = generateUUID(); + return api.put(`/tasks/${daily._id}`, { + tags: [generateUUID(), generateUUID()], + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + tags: [finalUUID] + }); + }).then((savedDaily2) => { + expect(savedDaily2.tags.length).to.equal(1); + expect(savedDaily2.tags[0]).to.equal(finalUUID); + }); + }); - it('updates everyX, even if frequency is set to weekly'); + it('updates repeat, even if frequency is set to daily', () => { + return api.put(`/tasks/${daily._id}`, { + frequency: 'daily', + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + repeat: { + m: false, + su: false + } + }); + }).then((savedDaily2) => { + expect(savedDaily2.repeat).to.eql({ + m: false, + t: true, + w: true, + th: true, + f: true, + s: true, + su: false, + }); + }); + }); - it('defaults startDate to today if none date object is passed in'); + it('updates everyX, even if frequency is set to weekly', () => { + return api.put(`/tasks/${daily._id}`, { + frequency: 'weekly', + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + everyX: 5, + }); + }).then((savedDaily2) => { + expect(savedDaily2.everyX).to.eql(5); + }); + }); + + it('defaults startDate to today if none date object is passed in', () => { + return api.put(`/tasks/${daily._id}`, { + frequency: 'weekly', + }).then((savedDaily2) => { + expect((new Date(savedDaily2.startDate)).getDay()).to.eql((new Date()).getDay()); + }); + }); }); - context('rewards', () => { + xcontext('rewards', () => { let reward; beforeEach(() => { @@ -149,6 +281,12 @@ describe('PUT /tasks/:id', () => { }); }); - it('requires value to be coerced into a number'); + it('requires value to be coerced into a number', () => { + return api.put(`/tasks/${reward._id}`, { + value: "100", + }).then((task) => { + expect(task.value).to.eql(100); + }); + }); }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index b9d9cc4519..f8ccd207d6 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -173,19 +173,22 @@ api.updateTask = { // If checklist is updated -> replace the original one if (req.body.checklist) { - delete req.body.checklist; task.checklist = req.body.checklist; + delete req.body.checklist; } // If tags are updated -> replace the original ones if (req.body.tags) { - delete req.body.tags; task.tags = req.body.tags; + delete req.body.tags; } - // TODO merge goes deep into objects, it's ok? - // TODO also check that array and mixed fields are updated correctly without marking modified - _.merge(task, Tasks.Task.sanitizeUpdate(req.body)); + // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, very bad for performances + // TODO regarding comment above make sure other models with nested fields are using this trick too + _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body))); + // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) + // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() + // see https://github.com/Automattic/mongoose/issues/2749 return task.save(); }) .then((savedTask) => res.respond(200, savedTask)) From 237be08a619aa655b7f247dbf24e0c643a81e7da Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 7 Dec 2015 21:15:02 +0100 Subject: [PATCH 221/976] GET-tasks_id tests --- .../v3/integration/tasks/GET-tasks_id.test.js | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index 6c6c722703..4e69788c6c 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('GET /tasks/:id', () => { let user, api; @@ -18,18 +19,51 @@ describe('GET /tasks/:id', () => { let task; beforeEach(() => { - // generate task - // task = generatedTask; + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((createdTask) => { + task = createdTask; + }); }); - it('gets specified task'); + it('gets specified task', () => { + return api.get('/tasks/' + task._id) + .then((getTask) => { + expect(getTask).to.eql(task); + }); + }); + // TODO after challenges are implemented it('can get active challenge task that user does not own'); // Yes? }); context('task cannot accessed', () => { - it('cannot get a non-existant task'); + it('cannot get a non-existant task', () => { + return expect(api.get('/tasks/' + generateUUID())).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); - it('cannot get a task owned by someone else'); + it('cannot get a task owned by someone else', () => { + let api2; + + return generateUser() + .then((user2) => { + api2 = requester(user2); + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }) + }).then((task) => { + return expect(api2.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + }); }); }); From 72b2791bc4d16cf74601de9f687f569613bde9c7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 8 Dec 2015 14:41:42 +0100 Subject: [PATCH 222/976] refactor scoreTask --- common/script/api-v3/scoreTask.js | 259 ++++++++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 13 +- 2 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 common/script/api-v3/scoreTask.js diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js new file mode 100644 index 0000000000..2a84f00568 --- /dev/null +++ b/common/script/api-v3/scoreTask.js @@ -0,0 +1,259 @@ +import _ from 'lodash'; +import moment from 'moment'; +import { + NotAuthorized, +} from '../../../website/src/libs/api-v3/errors'; +import i18n from '../i18n'; + +const MAX_TASK_VALUE = 21.27; +const MIN_TASK_VALUE = -47.27; +const CLOSE_ENOUGH = 0.00001; + +function _getTaskValue (taskValue) { + if (taskValue < MIN_TASK_VALUE) { + return MIN_TASK_VALUE; + } else if (taskValue < MAX_TASK_VALUE) { + return MAX_TASK_VALUE; + } else { + return taskValue; + } +} + +// Calculates the next task.value based on direction +// Uses a capped inverse log y=.95^x, y>= -5 +function _calculateDelta (task, direction, cron) { + // Min/max on task redness + let currVal = _getTaskValue(task.value); + let nextDelta = Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); + + // Checklists + if (task.checklist && task.checklist.length > 0) { + // If the Daily, only dock them them a portion based on their checklist completion + if (direction === 'down' && task.type === 'daily' && cron) { + nextDelta *= 1 - _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + } + + // If To-Do, point-match the TD per checklist item completed + if (task.type === 'todo') { + nextDelta *= 1 + _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0); + } + } + + return nextDelta; +} + +// Approximates the reverse delta for the task value +// This is meant to return the task value to its original value when unchecking a task. +// First, calculate the the value using the normal way for our first guess although +// it will be a bit off +function _calculateReverseDelta (task, direction) { + let currVal = _getTaskValue(task.value); + let testVal = currVal + Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); + + // Now keep moving closer to the original value until we get "close enough" + // Check how close we are to the original value by computing the delta off our guess + // and looking at the difference between that and our current value. + while (true) { // eslint-disable-line no-constant-condition + let calc = testVal + Math.pow(0.9747, testVal); + let diff = currVal - calc; + + if (Math.abs(diff) < CLOSE_ENOUGH) break; + + if (diff > 0) { + testVal -= diff; + } else { + testVal += diff; + } + } + + // When we get close enough, return the difference between our approximated value + // and the current value. This will be the delta calculated from the original value + // before the task was checked. + let nextDelta = testVal - currVal; + + // Checklists - If To-Do, point-match the TD per checklist item completed + if (task.checklist && task.checklist.length > 0 && task.type === 'todo') { + nextDelta *= 1 + _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0); + } + + return nextDelta; +} + +function _gainMP (user, val) { + val *= user._tmp.crit || 1; + user.stats.mp += val; + + if (user.stats.mp >= user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + if (user.stats.mp < 0) { + user.stats.mp = 0; + return user.stats.mp; + } +} + +// HP modifier +// ===== CONSTITUTION ===== +// TODO Decreases HP loss from bad habits / missed dailies by 0.5% per point. +function _subtractPoints (user, task, stats, delta) { + let conBonus = 1 - user._statsComputed.con / 250; + if (conBonus < 0.1) conBonus = 0.1; + + let hpMod = delta * conBonus * task.priority * 2; // constant 2 multiplier for better results + stats.hp += Math.round(hpMod * 10) / 10; // round to 1dp + return stats.hp; +} + +function _addPoints (user, task, stats, direction, delta) { + // ===== CRITICAL HITS ===== + // allow critical hit only when checking off a task, not when unchecking it: + let _crit = delta > 0 ? user.fns.crit() : 1; + // if there was a crit, alert the user via notification + if (_crit > 1) user._tmp.crit = _crit; + + // Exp Modifier + // ===== Intelligence ===== + // TODO Increases Experience gain by .2% per point. + let intBonus = 1 + user._statsComputed.int * 0.025; + stats.exp += Math.round(delta * intBonus * task.priority * _crit * 6); + + // GP modifier + // ===== PERCEPTION ===== + // TODO Increases Gold gained from tasks by .3% per point. + let perBonus = 1 + user._statsComputed.per * 0.02; + let gpMod = delta * task.priority * _crit * perBonus; + + if (task.streak) { + let currStreak = direction === 'down' ? task.streak - 1 : task.streak; + let streakBonus = currStreak / 100 + 1; // eg, 1-day streak is 1.01, 2-day is 1.02, etc + let afterStreak = gpMod * streakBonus; + if (currStreak > 0 && gpMod > 0) { + user._tmp.streakBonus = afterStreak - gpMod; // keep this on-hand for later, so we can notify streak-bonus + } + + stats.gp += afterStreak; + } else { + stats.gp += gpMod; + } +} + +function _changeTaskValue (user, task, direction, times, cron) { + let addToDelta = 0; + + // If multiple days have passed, multiply times days missed + _.times(times, () => { + // Each iteration calculate the nextDelta, which is then accumulated in the total delta. + let nextDelta = !cron && direction === 'down' ? _calculateReverseDelta(task, direction) : _calculateDelta(task, direction, cron); + + if (task.type !== 'reward') { + if (user.preferences.automaticAllocation === true && user.preferences.allocationMode === 'taskbased' && !(task.type === 'todo' && direction === 'down')) { + user.stats.training[task.attribute] += nextDelta; + } + + if (direction === 'up') { // Make progress on quest based on STR + user.party.quest.progress.up = user.party.quest.progress.up || 0; + + if (task.type === 'todo' || task.type === 'daily') { + user.party.quest.progress.up += nextDelta * (1 + user._statsComputed.str / 200); + } else if (task.type === 'habit') { + user.party.quest.progress.up += nextDelta * (0.5 + user._statsComputed.str / 400); + } + } + + task.value += nextDelta; + } + + addToDelta += nextDelta; + }); + + return addToDelta; +} + +export default function scoreTask (options = {}, req) { + let {user, task, direction, times = 1, cron = false} = options; + let delta = 0; + let stats = { + gp: user.stats.gp, + hp: user.stats.hp, + exp: user.stats.exp, + }; + + // TODO return or pass to cb, don't add to user object + // This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying + // the API consumer, then cleared afterwards + user._tmp = {}; + + // If they're trying to purhcase a too-expensive reward, don't allow them to do that. + if (task.value > user.stats.gp && task.type === 'reward') throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + + // ===== starting to actually do stuff, most of above was definitions ===== + if (task.type === 'habit') { + delta += _changeTaskValue(user, task, direction, times, cron); + // Add habit value to habit-history (if different) + if (delta > 0) { + _addPoints(user, task, stats, direction, delta); + } else { + _subtractPoints(user, task, stats, delta); + } + _gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + + // history + let th = task.history; + let thl = task.history.length; + + if (th[thl - 1] && moment(th[thl - 1].date).isSame(new Date(), 'day')) { + th[thl - 1].value = task.value; + } else { + th.push({ + date: Number(new Date()), // TODO are we going to cast history entries? + value: task.value, + }); + } + } else if (task.type === 'daily') { + if (cron) { + delta += _changeTaskValue(user, task, direction, times, cron); + _subtractPoints(user, task, stats, delta); + if (!user.stats.buffs.streaks) task.streak = 0; + } else { + delta += _changeTaskValue(user, task, direction, times, cron); + if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly + _addPoints(user, task, stats, direction, delta); // obviously for delta>0, but also a trick to undo accidental checkboxes + _gainMP(user, _.max([1, 0.01 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + + if (direction === 'up') { + task.streak += 1; + // Give a streak achievement when the streak is a multiple of 21 + if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1; + } else { + // Remove a streak achievement if streak was a multiple of 21 and the daily was undone + if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak - 1 : 0; + task.streak -= 1; + } + } + } else if (task.type === 'todo') { + if (cron) { // don't touch stats on cron + delta += _changeTaskValue(user, task, direction, times, cron); + } else { + if (direction === 'up') task.dateCompleted = new Date(); + + delta += _changeTaskValue(user, task, direction, times, cron); + if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly + _addPoints(user, task, stats, direction, delta); + + // MP++ per checklist item in ToDo, bonus per CLI + let multiplier = _.max([_.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 1), 1]); + _gainMP(user, _.max([multiplier, 0.01 * user._statsComputed.maxMP * multiplier]) * (direction === 'down' ? -1 : 1)); + } + } else if (task.type === 'reward') { + // Don't adjust values for rewards + delta += _changeTaskValue(user, task, direction, times, cron); + // purchase item + stats.gp -= Math.abs(task.value); + // hp - gp difference + if (stats.gp < 0) { + stats.hp += stats.gp; + stats.gp = 0; + } + } + + user.fns.updateStats(stats, req); + return delta; +} diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index f8ccd207d6..e61bd79965 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -9,6 +9,7 @@ import { import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; +import scoreTask from '../../../../common/script/api-v3/scoreTask'; let api = {}; @@ -257,7 +258,15 @@ api.scoreTask = { task.completed = direction === 'up'; } - let delta = user.ops.score({params: {id: task._id, direction}, language: req.language}); + let delta; + try { + delta = scoreTask({task, user, direction}, req); + } catch (e) { + throw e; + } + + // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) + if (direction === 'up') user.fns.randomDrop({task, delta}, req); return Q.all([ user.save(), @@ -265,7 +274,7 @@ api.scoreTask = { ]).then((results) => { let savedUser = results[0]; - let userStats = savedUser.toJSON().stats; + let userStats = savedUser.stats.toJSON(); let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); res.respond(200, resJsonData); From 0f3b307f409ad11dbc681652112ca518564a3942 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 9 Dec 2015 10:21:15 +0100 Subject: [PATCH 223/976] port tests for score and randomDrop --- common/script/api-v3/scoreTask.js | 6 +- test/common/algos.mocha.js | 104 ++++++------------------ website/src/controllers/api-v3/tasks.js | 8 +- 3 files changed, 30 insertions(+), 88 deletions(-) diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js index 2a84f00568..91e872ee4b 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/api-v3/scoreTask.js @@ -12,7 +12,7 @@ const CLOSE_ENOUGH = 0.00001; function _getTaskValue (taskValue) { if (taskValue < MIN_TASK_VALUE) { return MIN_TASK_VALUE; - } else if (taskValue < MAX_TASK_VALUE) { + } else if (taskValue > MAX_TASK_VALUE) { return MAX_TASK_VALUE; } else { return taskValue; @@ -167,7 +167,7 @@ function _changeTaskValue (user, task, direction, times, cron) { return addToDelta; } -export default function scoreTask (options = {}, req) { +export default function scoreTask (options = {}, req = {}) { let {user, task, direction, times = 1, cron = false} = options; let delta = 0; let stats = { @@ -200,7 +200,7 @@ export default function scoreTask (options = {}, req) { let thl = task.history.length; if (th[thl - 1] && moment(th[thl - 1].date).isSame(new Date(), 'day')) { - th[thl - 1].value = task.value; + th[thl - 1].value = task.value; // TODO mark modified? } else { th.push({ date: Number(new Date()), // TODO are we going to cast history entries? diff --git a/test/common/algos.mocha.js b/test/common/algos.mocha.js index 07d97be9ea..a8c23bb9ba 100644 --- a/test/common/algos.mocha.js +++ b/test/common/algos.mocha.js @@ -5,6 +5,7 @@ import { startOfDay, daysSince, } from '../../common/script/cron'; +import scoreTask from '../../common/script/api-v3/scoreTask'; let expect = require('expect.js'); let sinon = require('sinon'); @@ -720,12 +721,9 @@ describe('User', () => { for (let random = MIN_RANGE_FOR_POTION; random <= MAX_RANGE_FOR_POTION; random += 0.1) { sinon.stub(user.fns, 'predictableRandom').returns(random); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.be.empty; expect(user.items.hatchingPotions).to.not.be.empty; expect(user.items.food).to.be.empty; @@ -738,12 +736,8 @@ describe('User', () => { for (let random = MIN_RANGE_FOR_EGG; random <= MAX_RANGE_FOR_EGG; random += 0.1) { sinon.stub(user.fns, 'predictableRandom').returns(random); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.not.be.empty; expect(user.items.hatchingPotions).to.be.empty; expect(user.items.food).to.be.empty; @@ -757,12 +751,8 @@ describe('User', () => { for (let random = MIN_RANGE_FOR_FOOD; random <= MAX_RANGE_FOR_FOOD; random += 0.1) { sinon.stub(user.fns, 'predictableRandom').returns(random); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.be.empty; expect(user.items.hatchingPotions).to.be.empty; expect(user.items.food).to.not.be.empty; @@ -773,12 +763,8 @@ describe('User', () => { it('does not get a drop', function () { sinon.stub(user.fns, 'predictableRandom').returns(0.5); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.eql({}); expect(user.items.hatchingPotions).to.eql({}); expect(user.items.food).to.eql({}); @@ -930,76 +916,38 @@ describe('Simple Scoring', () => { }); it('Habits : Up', function () { - this.after.ops.score({ - params: { - id: this.after.habits[0].id, - direction: 'down', - }, - query: { - times: 5, - }, - }); + let delta = scoreTask({task: this.after.habits[0], user: this.after, direction: 'down', times: 5}); + this.after.fns.randomDrop({task: this.after.habits[0], delta}, {}); expectLostPoints(this.before, this.after, 'habit'); }); it('Habits : Down', function () { - this.after.ops.score({ - params: { - id: this.after.habits[0].id, - direction: 'up', - }, - query: { - times: 5, - }, - }); + let delta = scoreTask({task: this.after.habits[0], user: this.after, direction: 'up', times: 5}); + this.after.fns.randomDrop({task: this.after.habits[0], delta}, {}); expectGainedPoints(this.before, this.after, 'habit'); }); it('Dailys : Up', function () { - this.after.ops.score({ - params: { - id: this.after.dailys[0].id, - direction: 'up', - }, - }); + let delta = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.dailys[0], delta}, {}); expectGainedPoints(this.before, this.after, 'daily'); }); it('Dailys : Up, Down', function () { - this.after.ops.score({ - params: { - id: this.after.dailys[0].id, - direction: 'up', - }, - }); - this.after.ops.score({ - params: { - id: this.after.dailys[0].id, - direction: 'down', - }, - }); + let delta = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.dailys[0], delta}, {}); + let delta2 = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'down'}); + this.after.fns.randomDrop({task: this.after.dailys[0], delta2}, {}); expectClosePoints(this.before, this.after, 'daily'); }); it('Todos : Up', function () { - this.after.ops.score({ - params: { - id: this.after.todos[0].id, - direction: 'up', - }, - }); + let delta = scoreTask({task: this.after.todos[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.todos[0], delta}, {}); expectGainedPoints(this.before, this.after, 'todo'); }); it('Todos : Up, Down', function () { - this.after.ops.score({ - params: { - id: this.after.todos[0].id, - direction: 'up', - }, - }); - this.after.ops.score({ - params: { - id: this.after.todos[0].id, - direction: 'down', - }, - }); + let delta = scoreTask({task: this.after.todos[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.todos[0], delta}, {}); + let delta2 = scoreTask({task: this.after.todos[0], user: this.after, direction: 'down'}); + this.after.fns.randomDrop({task: this.after.todos[0], delta2}, {}); expectClosePoints(this.before, this.after, 'todo'); }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e61bd79965..2f36b8881a 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -258,13 +258,7 @@ api.scoreTask = { task.completed = direction === 'up'; } - let delta; - try { - delta = scoreTask({task, user, direction}, req); - } catch (e) { - throw e; - } - + let delta = scoreTask({task, user, direction}, req); // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) if (direction === 'up') user.fns.randomDrop({task, delta}, req); From 6a0f9564e03a70d2ac290c9bf6c2ac9d9f77e984 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 9 Dec 2015 10:40:40 +0100 Subject: [PATCH 224/976] begins testing score task route --- .../tasks/POST-tasks_score_id_direction.test.js | 17 +++++++++++++++-- website/src/controllers/api-v3/tasks.js | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js index 46264aee7c..502a75a1ac 100644 --- a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/score/:id/:direction', () => { let user, api; @@ -15,9 +16,21 @@ describe('POST /tasks/score/:id/:direction', () => { }); context('all', () => { - it('requires a task id'); + it('requires a task id', () => { + return expect(api.post('/tasks/score/123/up')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); - it('requires a task direction'); + it('requires a task direction', () => { + return expect(api.post(`/tasks/score/${generateUUID()}/tt`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); }); context('todos', () => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 2f36b8881a..d5fd1a587f 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -235,7 +235,7 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { */ api.scoreTask = { method: 'POST', - url: 'tasks/score/:taskId/:direction', + url: '/tasks/score/:taskId/:direction', middlewares: [authWithHeaders()], handler (req, res, next) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); From 9ffa0d58938d51fd4abe787a856704c9d5eca718 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 10 Dec 2015 19:07:09 +0100 Subject: [PATCH 225/976] port cron and preening --- common/script/api-v3/preenHistory.js | 64 +++++ common/script/api-v3/scoreTask.js | 18 +- website/src/middlewares/api-v3/cron.js | 309 +++++++++++++++++++++++++ website/src/models/user.js | 8 +- 4 files changed, 385 insertions(+), 14 deletions(-) create mode 100644 common/script/api-v3/preenHistory.js create mode 100644 website/src/middlewares/api-v3/cron.js diff --git a/common/script/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js new file mode 100644 index 0000000000..44ed51862e --- /dev/null +++ b/common/script/api-v3/preenHistory.js @@ -0,0 +1,64 @@ +import moment from 'moment'; +import _ from 'lodash'; + +function _preen (newHistory, history, amount, groupBy) { + let groups = _.chain(history) + .groupBy(h => moment(h.date).format(groupBy)) + .sortBy((h, k) => k) + .value(); + + groups = groups.slice(-amount); + groups.pop(); + + _.each(groups, (group) => { + newHistory.push({ + date: moment(group[0].date).toDate(), + value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length, + }); + }); +} + +// Free users: +// Preen history for users with > 7 history entries +// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array +// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week +// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] +// +// Subscribers: +// TODO implement +export function preenHistory (history) { + // TODO remember to add this to migration + /* history = _.filter(history, function(h) { + return !!h; + }); */ + let newHistory = []; + + _preen(newHistory, history, 50, 'YYYY'); + _preen(newHistory, history, moment().format('MM'), 'YYYYMM'); + + let thisMonth = moment().format('YYYYMM'); + newHistory = newHistory.concat(history.filter(h => { + return moment(h.date).format('YYYYMM') === thisMonth; + })); + + return newHistory; +} + +export function preenUserHistory (user, tasksByType, minHistLen = 7) { + tasksByType.habits.concat(user.dailys).forEach((task) => { + if (task.history.length > minHistLen) { + task.history = preenHistory(user, task.history); + task.markModified('history'); + } + }); + + if (user.history.exp.length > minHistLen) { + user.history.exp = preenHistory(user, user.history.exp); + user.markModified('history.exp'); + } + + if (user.history.todos.length > minHistLen) { + user.history.todos = preenHistory(user, user.history.todos); + user.markModified('history.todos'); + } +} diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js index 91e872ee4b..25c59393b6 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/api-v3/scoreTask.js @@ -1,5 +1,4 @@ import _ from 'lodash'; -import moment from 'moment'; import { NotAuthorized, } from '../../../website/src/libs/api-v3/errors'; @@ -195,18 +194,11 @@ export default function scoreTask (options = {}, req = {}) { } _gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); - // history - let th = task.history; - let thl = task.history.length; - - if (th[thl - 1] && moment(th[thl - 1].date).isSame(new Date(), 'day')) { - th[thl - 1].value = task.value; // TODO mark modified? - } else { - th.push({ - date: Number(new Date()), // TODO are we going to cast history entries? - value: task.value, - }); - } + // Add history entry, even more than 1 per day + task.history.push({ + date: Number(new Date()), // TODO are we going to cast history entries? + value: task.value, + }); } else if (task.type === 'daily') { if (cron) { delta += _changeTaskValue(user, task, direction, times, cron); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js new file mode 100644 index 0000000000..3dc5dbbf5e --- /dev/null +++ b/website/src/middlewares/api-v3/cron.js @@ -0,0 +1,309 @@ +import _ from 'lodash'; +import { + daysSince, + shouldDo, +} from '../../../../common/script/cron'; +import common from '../../../../common'; +import scoreTask from '../../../../common/script/api-v3/scoreTask'; +import moment from 'moment'; +import Task from '../../models/task'; +// import Group from '../../models/group'; + +function _runCron (options = {}) { + let {user, tasks, tasksByType, analytics, now, daysMissed} = options; + + user.auth.timestamps.loggedin = now; + user.lastCron = now; + // Reset the lastDrop count to zero + if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; + + // "Perfect Day" achievement for perfect-days + let perfect = true; + + let clearBuffs = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, + }; + + // end-of-month perks for subscribers + let plan = user.purchased.plan; + if (user.isSubscribed()) { + if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { + plan.gemsBought = 0; // reset gem-cap + plan.dateUpdated = now; + // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks + // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 + // TODO use month diff instead of ++ / --? + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + plan.consecutive.count++; + if (plan.consecutive.offset > 0) { + plan.consecutive.offset--; + } else if (plan.consecutive.count % 3 === 0) { // every 3 months + plan.consecutive.trinkets++; + plan.consecutive.gemCapExtra += 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) + } + } + + // If user cancelled subscription, we give them until 30day's end until it terminates + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null, + }); + + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0, + }); + + user.markModified('purchased.plan'); // TODO necessary? + } + } + + // User is resting at the inn. + // On cron, buffs are cleared and all dailies are reset without performing damage + if (user.preferences.sleep === true) { + user.stats.buffs = _.cloneDeep(clearBuffs); + + tasksByType.dailys.forEach((daily) => { + let completed = daily.completed; + let thatDay = moment(now).subtract({days: 1}); + + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + daily.checklist.forEach(box => box.completed = false); + } + daily.completed = false; + }); + + return; + } + + let multiDaysCountAsOneDay = true; + // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. + // When site-wide difficulty settings are introduced, this can be a user preference option. + + // Tally each task + let todoTally = 0; + + tasksByType.todos.forEach((task) => { // make uncompleted todos redder + let completed = task.completed; + scoreTask({ + task, + user, + direction: 'down', + cron: true, + times: multiDaysCountAsOneDay ? 1 : daysMissed, + // TODO pass req for analytics? + }); + + let absVal = completed ? Math.abs(task.value) : task.value; + todoTally += absVal; + }); + + let dailyChecked = 0; // how many dailies were checked? + let dailyDueUnchecked = 0; // how many dailies were cun-hecked? + if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; + + tasksByType.dailys.forEach((task) => { + let completed = task.completed; + // Deduct points for missed Daily tasks + let EvadeTask = 0; + let scheduleMisses = daysMissed; + + if (completed) { + dailyChecked += 1; + } else { + // dailys repeat, so need to calculate how many they've missed according to their own schedule + scheduleMisses = 0; + + for (let i = 0; i < daysMissed; i++) { + let thatDay = moment(now).subtract({days: i + 1}); + + if (shouldDo(thatDay.toDate(), task, user.preferences)) { + scheduleMisses++; + if (user.stats.buffs.stealth) { + user.stats.buffs.stealth--; + EvadeTask++; + } + if (multiDaysCountAsOneDay) break; + } + } + + if (scheduleMisses > EvadeTask) { + perfect = false; + + if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points + let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + dailyDueUnchecked += 1 - fractionChecked; + dailyChecked += fractionChecked; + } else { + dailyDueUnchecked += 1; + } + + let delta = scoreTask({ + user, + task, + direction: 'down', + times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, + cron: true, + }); + + // Apply damage from a boss, less damage for Trivial priority (difficulty) + user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); + // NB: Medium and Hard priorities do not increase damage from boss. This was by accident + // initially, and when we realised, we could not fix it because users are used to + // their Medium and Hard Dailies doing an Easy amount of damage from boss. + // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future + // setting between Trivial and Easy. + } + } + + task.history.push({ + date: Number(new Date()), + value: task.value, + }); + task.completed = false; + + if (completed || scheduleMisses > 0) { + task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + } + }); + + tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 + if (task.up === false || task.down === false) { + task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; + } + }); + + // Finished tallying + user.history.todos({date: now, value: todoTally}); + // tally experience + let expTally = user.stats.exp; + let lvl = 0; // iterator + while (lvl < user.stats.lvl - 1) { + lvl++; + expTally += common.tnl(lvl); + } + user.history.exp.push({date: now, value: expTally}); + + // preen user history so that it doesn't become a performance problem + // also for subscribed users but differentyly + // premium subscribers can keep their full history. + user.fns.preenUserHistory(tasks); + + if (perfect) { + user.achievements.perfect++; + let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); + user.stats.buffs = { + str: lvlDiv2, + int: lvlDiv2, + per: lvlDiv2, + con: lvlDiv2, + stealth: 0, + streaks: false, + }; + } else { + user.stats.buffs = _.cloneDeep(clearBuffs); + } + + // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit + // Adjust for fraction of dailies completed + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + + if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + + // After all is said and done, progress up user's effect on quest, return those values & reset the user's + let progress = user.party.quest.progress; + let _progress = _.cloneDeep(progress); + _.merge(progress, {down: 0, up: 0}); + progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + + + // Analytics + user.flags.cronCount++; + analytics.track('Cron', { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user, // TODO is it really necessary passing the whole user object? + resting: user.preferences.sleep, + cronCount: user.flags.cronCount, + progressUp: _.min([_progress.up, 900]), + progressDown: _progress.down, + }); + + return _progress; +} + +// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) +// For incomplete Dailys, deduct experience +// Make sure to run this function once in a while as server will not take care of overnight calculations. +// And you have to run it every time client connects. +export default function cron (req, res, next) { + let user = res.locals.user; + let analytics = res.analytics; + + let now = new Date(); + let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences)); + + if (daysMissed <= 0) return next(null, user); // TODO why are we passing user down here? + + // Fetch active tasks (no completed todos) + Task.find({ + userId: user._id, + $or: [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ], + }).exec() + .then((tasks) => { + let tasksByType = {habits: [], dailys: [], todos: [], rewards: []}; + tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); + + // Run cron + _runCron({user, tasks, tasksByType, now, daysMissed, analytics}); + + let ranCron = user.isModified(); + let quest = common.content.quests[user.party.quest.key]; + + // if (ranCron) res.locals.wasModified = true; // TODO remove? + if (!ranCron) return next(null, user); // TODO why are we passing user to next? + // TODO Group.tavernBoss(user, progress); + if (!quest || true /* TODO remove */) return user.save(next); + + // If user is on a quest, roll for boss & player, or handle collections + // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + // TODO do + /* async.waterfall([ + function(cb){ + user.save(cb); // make sure to save the cron effects + }, + function(saved, count, cb){ + var type = quest.boss ? 'boss' : 'collect'; + Group[type+'Quest'](user,progress,cb); + }, + function(){ + var cb = arguments[arguments.length-1]; + // User has been updated in boss-grapple, reload + User.findById(user._id, cb); + } + ], function(err, saved) { + res.locals.user = saved; + next(err,saved); + user = progress = quest = null; + });*/ + }); +} diff --git a/website/src/models/user.js b/website/src/models/user.js index 0810bbfa62..a2e414a296 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -45,6 +45,7 @@ export let schema = new Schema({ // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which // have been updated (http://goo.gl/gQLz41), but we want *every* update _v: { type: Number, default: 0 }, + // TODO give all this a default of 0? achievements: { originalUser: Boolean, habitSurveys: Number, @@ -65,7 +66,7 @@ export let schema = new Schema({ quests: Schema.Types.Mixed, // TODO remove, use dictionary? rebirths: Number, rebirthLevel: Number, - perfect: Number, + perfect: {type: Number, default: 0}, habitBirthdays: Number, valentine: Number, costumeContest: Boolean, // Superseded by costumeContests @@ -627,6 +628,11 @@ schema.pre('save', true, function preSaveUser (next, done) { } }); +// TODO unit test this? +schema.methods.isSubscribed = function isSubscribed () { + return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion +}; + schema.methods.unlink = function unlink (options, cb) { let cid = options.cid; let keep = options.keep; From 501a80889319a1ff6e947850ea588844aaae3bca Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 11 Dec 2015 11:21:36 +0100 Subject: [PATCH 226/976] move cron logic back to common --- common/script/api-v3/cron.js | 250 ++++++++++++++++++++++++ website/src/middlewares/api-v3/cron.js | 251 +------------------------ 2 files changed, 253 insertions(+), 248 deletions(-) create mode 100644 common/script/api-v3/cron.js diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js new file mode 100644 index 0000000000..1bc8f29927 --- /dev/null +++ b/common/script/api-v3/cron.js @@ -0,0 +1,250 @@ +import moment from 'moment'; +import _ from 'lodash'; +import scoreTask from './scoreTask'; +import common from '../../'; +import { + shouldDo, +} from '../cron'; + +let clearBuffs = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, +}; + +// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) +// For incomplete Dailys, deduct experience +// Make sure to run this function once in a while as server will not take care of overnight calculations. +// And you have to run it every time client connects. +export default function cron (options = {}) { + let {user, tasks, tasksByType, analytics, now, daysMissed} = options; + + user.auth.timestamps.loggedin = now; + user.lastCron = now; + // Reset the lastDrop count to zero + if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; + + // "Perfect Day" achievement for perfect-days + let perfect = true; + + // end-of-month perks for subscribers + let plan = user.purchased.plan; + if (user.isSubscribed()) { + if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { + plan.gemsBought = 0; // reset gem-cap + plan.dateUpdated = now; + // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks + // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 + // TODO use month diff instead of ++ / --? + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + plan.consecutive.count++; + if (plan.consecutive.offset > 0) { + plan.consecutive.offset--; + } else if (plan.consecutive.count % 3 === 0) { // every 3 months + plan.consecutive.trinkets++; + plan.consecutive.gemCapExtra += 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) + } + } + + // If user cancelled subscription, we give them until 30day's end until it terminates + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null, + }); + + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0, + }); + + user.markModified('purchased.plan'); // TODO necessary? + } + } + + // User is resting at the inn. + // On cron, buffs are cleared and all dailies are reset without performing damage + if (user.preferences.sleep === true) { + user.stats.buffs = _.cloneDeep(clearBuffs); + + tasksByType.dailys.forEach((daily) => { + let completed = daily.completed; + let thatDay = moment(now).subtract({days: 1}); + + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + daily.checklist.forEach(box => box.completed = false); + } + daily.completed = false; + }); + + return; + } + + let multiDaysCountAsOneDay = true; + // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. + // When site-wide difficulty settings are introduced, this can be a user preference option. + + // Tally each task + let todoTally = 0; + + tasksByType.todos.forEach((task) => { // make uncompleted todos redder + let completed = task.completed; + scoreTask({ + task, + user, + direction: 'down', + cron: true, + times: multiDaysCountAsOneDay ? 1 : daysMissed, + // TODO pass req for analytics? + }); + + let absVal = completed ? Math.abs(task.value) : task.value; + todoTally += absVal; + }); + + let dailyChecked = 0; // how many dailies were checked? + let dailyDueUnchecked = 0; // how many dailies were cun-hecked? + if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; + + tasksByType.dailys.forEach((task) => { + let completed = task.completed; + // Deduct points for missed Daily tasks + let EvadeTask = 0; + let scheduleMisses = daysMissed; + + if (completed) { + dailyChecked += 1; + } else { + // dailys repeat, so need to calculate how many they've missed according to their own schedule + scheduleMisses = 0; + + for (let i = 0; i < daysMissed; i++) { + let thatDay = moment(now).subtract({days: i + 1}); + + if (shouldDo(thatDay.toDate(), task, user.preferences)) { + scheduleMisses++; + if (user.stats.buffs.stealth) { + user.stats.buffs.stealth--; + EvadeTask++; + } + if (multiDaysCountAsOneDay) break; + } + } + + if (scheduleMisses > EvadeTask) { + perfect = false; + + if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points + let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + dailyDueUnchecked += 1 - fractionChecked; + dailyChecked += fractionChecked; + } else { + dailyDueUnchecked += 1; + } + + let delta = scoreTask({ + user, + task, + direction: 'down', + times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, + cron: true, + }); + + // Apply damage from a boss, less damage for Trivial priority (difficulty) + user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); + // NB: Medium and Hard priorities do not increase damage from boss. This was by accident + // initially, and when we realised, we could not fix it because users are used to + // their Medium and Hard Dailies doing an Easy amount of damage from boss. + // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future + // setting between Trivial and Easy. + } + } + + task.history.push({ + date: Number(new Date()), + value: task.value, + }); + task.completed = false; + + if (completed || scheduleMisses > 0) { + task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + } + }); + + tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 + if (task.up === false || task.down === false) { + task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; + } + }); + + // Finished tallying + user.history.todos({date: now, value: todoTally}); + // tally experience + let expTally = user.stats.exp; + let lvl = 0; // iterator + while (lvl < user.stats.lvl - 1) { + lvl++; + expTally += common.tnl(lvl); + } + user.history.exp.push({date: now, value: expTally}); + + // preen user history so that it doesn't become a performance problem + // also for subscribed users but differentyly + // premium subscribers can keep their full history. + user.fns.preenUserHistory(tasks); + + if (perfect) { + user.achievements.perfect++; + let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); + user.stats.buffs = { + str: lvlDiv2, + int: lvlDiv2, + per: lvlDiv2, + con: lvlDiv2, + stealth: 0, + streaks: false, + }; + } else { + user.stats.buffs = _.cloneDeep(clearBuffs); + } + + // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit + // Adjust for fraction of dailies completed + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + + if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + + // After all is said and done, progress up user's effect on quest, return those values & reset the user's + let progress = user.party.quest.progress; + let _progress = _.cloneDeep(progress); + _.merge(progress, {down: 0, up: 0}); + progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + + + // Analytics + user.flags.cronCount++; + analytics.track('Cron', { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user, // TODO is it really necessary passing the whole user object? + resting: user.preferences.sleep, + cronCount: user.flags.cronCount, + progressUp: _.min([_progress.up, 900]), + progressDown: _progress.down, + }); + + return _progress; +} diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 3dc5dbbf5e..d71c123346 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -1,258 +1,13 @@ import _ from 'lodash'; import { daysSince, - shouldDo, } from '../../../../common/script/cron'; +import cron from '../../../../common/script/api-v3/cron'; import common from '../../../../common'; -import scoreTask from '../../../../common/script/api-v3/scoreTask'; -import moment from 'moment'; import Task from '../../models/task'; // import Group from '../../models/group'; -function _runCron (options = {}) { - let {user, tasks, tasksByType, analytics, now, daysMissed} = options; - - user.auth.timestamps.loggedin = now; - user.lastCron = now; - // Reset the lastDrop count to zero - if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; - - // "Perfect Day" achievement for perfect-days - let perfect = true; - - let clearBuffs = { - str: 0, - int: 0, - per: 0, - con: 0, - stealth: 0, - streaks: false, - }; - - // end-of-month perks for subscribers - let plan = user.purchased.plan; - if (user.isSubscribed()) { - if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { - plan.gemsBought = 0; // reset gem-cap - plan.dateUpdated = now; - // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks - // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 - // TODO use month diff instead of ++ / --? - _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 - plan.consecutive.count++; - if (plan.consecutive.offset > 0) { - plan.consecutive.offset--; - } else if (plan.consecutive.count % 3 === 0) { // every 3 months - plan.consecutive.trinkets++; - plan.consecutive.gemCapExtra += 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) - } - } - - // If user cancelled subscription, we give them until 30day's end until it terminates - if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { - _.merge(plan, { - planId: null, - customerId: null, - paymentMethod: null, - }); - - _.merge(plan.consecutive, { - count: 0, - offset: 0, - gemCapExtra: 0, - }); - - user.markModified('purchased.plan'); // TODO necessary? - } - } - - // User is resting at the inn. - // On cron, buffs are cleared and all dailies are reset without performing damage - if (user.preferences.sleep === true) { - user.stats.buffs = _.cloneDeep(clearBuffs); - - tasksByType.dailys.forEach((daily) => { - let completed = daily.completed; - let thatDay = moment(now).subtract({days: 1}); - - if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { - daily.checklist.forEach(box => box.completed = false); - } - daily.completed = false; - }); - - return; - } - - let multiDaysCountAsOneDay = true; - // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. - // When site-wide difficulty settings are introduced, this can be a user preference option. - - // Tally each task - let todoTally = 0; - - tasksByType.todos.forEach((task) => { // make uncompleted todos redder - let completed = task.completed; - scoreTask({ - task, - user, - direction: 'down', - cron: true, - times: multiDaysCountAsOneDay ? 1 : daysMissed, - // TODO pass req for analytics? - }); - - let absVal = completed ? Math.abs(task.value) : task.value; - todoTally += absVal; - }); - - let dailyChecked = 0; // how many dailies were checked? - let dailyDueUnchecked = 0; // how many dailies were cun-hecked? - if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; - - tasksByType.dailys.forEach((task) => { - let completed = task.completed; - // Deduct points for missed Daily tasks - let EvadeTask = 0; - let scheduleMisses = daysMissed; - - if (completed) { - dailyChecked += 1; - } else { - // dailys repeat, so need to calculate how many they've missed according to their own schedule - scheduleMisses = 0; - - for (let i = 0; i < daysMissed; i++) { - let thatDay = moment(now).subtract({days: i + 1}); - - if (shouldDo(thatDay.toDate(), task, user.preferences)) { - scheduleMisses++; - if (user.stats.buffs.stealth) { - user.stats.buffs.stealth--; - EvadeTask++; - } - if (multiDaysCountAsOneDay) break; - } - } - - if (scheduleMisses > EvadeTask) { - perfect = false; - - if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points - let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; - dailyDueUnchecked += 1 - fractionChecked; - dailyChecked += fractionChecked; - } else { - dailyDueUnchecked += 1; - } - - let delta = scoreTask({ - user, - task, - direction: 'down', - times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, - cron: true, - }); - - // Apply damage from a boss, less damage for Trivial priority (difficulty) - user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); - // NB: Medium and Hard priorities do not increase damage from boss. This was by accident - // initially, and when we realised, we could not fix it because users are used to - // their Medium and Hard Dailies doing an Easy amount of damage from boss. - // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future - // setting between Trivial and Easy. - } - } - - task.history.push({ - date: Number(new Date()), - value: task.value, - }); - task.completed = false; - - if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed - } - }); - - tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 - if (task.up === false || task.down === false) { - task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; - } - }); - - // Finished tallying - user.history.todos({date: now, value: todoTally}); - // tally experience - let expTally = user.stats.exp; - let lvl = 0; // iterator - while (lvl < user.stats.lvl - 1) { - lvl++; - expTally += common.tnl(lvl); - } - user.history.exp.push({date: now, value: expTally}); - - // preen user history so that it doesn't become a performance problem - // also for subscribed users but differentyly - // premium subscribers can keep their full history. - user.fns.preenUserHistory(tasks); - - if (perfect) { - user.achievements.perfect++; - let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); - user.stats.buffs = { - str: lvlDiv2, - int: lvlDiv2, - per: lvlDiv2, - con: lvlDiv2, - stealth: 0, - streaks: false, - }; - } else { - user.stats.buffs = _.cloneDeep(clearBuffs); - } - - // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit - // Adjust for fraction of dailies completed - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; - - if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) { - user.stats.mp = user._statsComputed.maxMP; - } - - // After all is said and done, progress up user's effect on quest, return those values & reset the user's - let progress = user.party.quest.progress; - let _progress = _.cloneDeep(progress); - _.merge(progress, {down: 0, up: 0}); - progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); - - - // Analytics - user.flags.cronCount++; - analytics.track('Cron', { - category: 'behavior', - gaLabel: 'Cron Count', - gaValue: user.flags.cronCount, - uuid: user._id, - user, // TODO is it really necessary passing the whole user object? - resting: user.preferences.sleep, - cronCount: user.flags.cronCount, - progressUp: _.min([_progress.up, 900]), - progressDown: _progress.down, - }); - - return _progress; -} - -// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) -// For incomplete Dailys, deduct experience -// Make sure to run this function once in a while as server will not take care of overnight calculations. -// And you have to run it every time client connects. -export default function cron (req, res, next) { +export default function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; @@ -274,7 +29,7 @@ export default function cron (req, res, next) { tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); // Run cron - _runCron({user, tasks, tasksByType, now, daysMissed, analytics}); + cron({user, tasks, tasksByType, now, daysMissed, analytics}); let ranCron = user.isModified(); let quest = common.content.quests[user.party.quest.key]; From e6d9c978f7d9370e63ed7bc1f9b12414e73f25d8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 11 Dec 2015 11:41:46 +0100 Subject: [PATCH 227/976] change score task route --- ... => POST-tasks_id_score_direction.test.js} | 59 +++++++++++++++---- website/src/controllers/api-v3/tasks.js | 8 +-- 2 files changed, 53 insertions(+), 14 deletions(-) rename test/api/v3/integration/tasks/{POST-tasks_score_id_direction.test.js => POST-tasks_id_score_direction.test.js} (68%) diff --git a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js similarity index 68% rename from test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js rename to test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 502a75a1ac..71367fbfeb 100644 --- a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -4,8 +4,9 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; +import Q from 'q'; -describe('POST /tasks/score/:id/:direction', () => { +describe('POST /tasks/:id/score/:direction', () => { let user, api; before(() => { @@ -17,7 +18,7 @@ describe('POST /tasks/score/:id/:direction', () => { context('all', () => { it('requires a task id', () => { - return expect(api.post('/tasks/score/123/up')).to.eventually.be.rejected.and.eql({ + return expect(api.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -25,7 +26,7 @@ describe('POST /tasks/score/:id/:direction', () => { }); it('requires a task direction', () => { - return expect(api.post(`/tasks/score/${generateUUID()}/tt`)).to.eventually.be.rejected.and.eql({ + return expect(api.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -37,7 +38,12 @@ describe('POST /tasks/score/:id/:direction', () => { let todo; beforeEach(() => { - // todo = createdTodo + return api.post('/tasks', { + text: 'test todo', + type: 'todo', + }).then((task) => { + todo = task; + }); }); it('completes todo when direction is up'); @@ -65,7 +71,12 @@ describe('POST /tasks/score/:id/:direction', () => { let daily; beforeEach(() => { - // daily = createdDaily + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + }).then((task) => { + daily = task; + }); }); it('completes daily when direction is up'); @@ -93,10 +104,33 @@ describe('POST /tasks/score/:id/:direction', () => { let habit, minusHabit, plusHabit, neitherHabit; beforeEach(() => { - // habit = createdHabit - // plusHabit = createdPlusHabit - // minusHabit = createdMinusHabit - // neitherHabit = createdNeitherHabit + return Q.all([ + api.post('/tasks', { + text: 'test habit', + type: 'habit', + }), + api.post('/tasks', { + text: 'test min habit', + type: 'habit', + up: false, + }), + api.post('/tasks', { + text: 'test plus habit', + type: 'habit', + down: false, + }), + api.post('/tasks', { + text: 'test neither habit', + type: 'habit', + up: false, + down: false, + }), + ]).then(tasks => { + habit = tasks[0]; + minusHabit = tasks[1]; + plusHabit = tasks[2]; + neitherHabit = tasks[3]; + }); }); it('prevents plus only habit from scoring down'); // Yes? @@ -120,7 +154,12 @@ describe('POST /tasks/score/:id/:direction', () => { let reward; beforeEach(() => { - // reward = createdReward + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + }).then((task) => { + reward = task; + }); }); it('purchases reward'); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index d5fd1a587f..c64090eb13 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -223,7 +223,7 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { } /** - * @api {put} /tasks/score/:taskId/:direction Score a task + * @api {put} /tasks/:taskId/score/:direction Score a task * @apiVersion 3.0.0 * @apiName ScoreTask * @apiGroup Task @@ -235,11 +235,11 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { */ api.scoreTask = { method: 'POST', - url: '/tasks/score/:taskId/:direction', + url: '/tasks/:taskId/score/:direction', middlewares: [authWithHeaders()], handler (req, res, next) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); + req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route? let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); @@ -255,7 +255,7 @@ api.scoreTask = { if (!task) throw new NotFound(res.t('taskNotFound')); if (task.type === 'daily' || task.type === 'todo') { - task.completed = direction === 'up'; + task.completed = direction === 'up'; // TODO move into scoreTask } let delta = scoreTask({task, user, direction}, req); From a34f41f0f7bf18782ee40d5965fa3c7530bcbfa3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 13 Dec 2015 20:08:14 +0100 Subject: [PATCH 228/976] misc fixes, GET user (with tests), more comments for preenHistory --- common/script/api-v3/preenHistory.js | 49 +++++++++++++------ test/api/v3/integration/user/GET-user.test.js | 31 ++++++++++++ website/src/controllers/api-v3/auth.js | 2 +- website/src/controllers/api-v3/user.js | 33 +++++++++++++ website/src/models/user.js | 2 +- 5 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 test/api/v3/integration/user/GET-user.test.js create mode 100644 website/src/controllers/api-v3/user.js diff --git a/common/script/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js index 44ed51862e..50ffb1518f 100644 --- a/common/script/api-v3/preenHistory.js +++ b/common/script/api-v3/preenHistory.js @@ -2,30 +2,47 @@ import moment from 'moment'; import _ from 'lodash'; function _preen (newHistory, history, amount, groupBy) { - let groups = _.chain(history) + _.chain(history) .groupBy(h => moment(h.date).format(groupBy)) .sortBy((h, k) => k) + .slice(-amount) + .pop() + .each((group) => { + newHistory.push({ + date: moment(group[0].date).toDate(), + value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length, + }); + }) .value(); - - groups = groups.slice(-amount); - groups.pop(); - - _.each(groups, (group) => { - newHistory.push({ - date: moment(group[0].date).toDate(), - value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length, - }); - }); } // Free users: -// Preen history for users with > 7 history entries -// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array -// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week -// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] +// Preen history for users with > 7 history entries +// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array +// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week +// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] // // Subscribers: -// TODO implement +// TODO implement + +// TODO Probably the description ^ is not too correct, this method actually takes 1 value each for the last 50 years, +// then the X last months, where X is the month we're in (september = 8 starting from 0) +// and all the days in this month +// Allowing for multiple values in a single day for habits we probably want something different: +// For free users: +// - At max 30 values for today (max 30) +// - 1 value each for the previous 61 days (2 months) +// - 1 value each for the previous 10 months (max 10) +// - 1 value each for the previous 50 years +// - Total: 30+61+10+ a few years ~= 105 +// +// For subscribed users +// - At max 30 values for today (max 30) +// - 1 value each for the previous 364 days (max 364) +// - 1 value each for the previous 12 months (max 12) +// - 1 value each for the previous 50 years +// - Total: 30+364+12+ a few years ~= 410 +// export function preenHistory (history) { // TODO remember to add this to migration /* history = _.filter(history, function(h) { diff --git a/test/api/v3/integration/user/GET-user.test.js b/test/api/v3/integration/user/GET-user.test.js new file mode 100644 index 0000000000..47c8eb4cc9 --- /dev/null +++ b/test/api/v3/integration/user/GET-user.test.js @@ -0,0 +1,31 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /user', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('returns the authenticated user', () => { + return api.get('/user') + .then(returnedUser => { + expect(returnedUser._id).to.equal(user._id); + }); + }); + + it('does not return private paths (and apiToken)', () => { + return api.get('/user') + .then(returnedUser => { + expect(returnedUser.auth.local.hashed_password).to.be.a('undefined'); + expect(returnedUser.auth.local.salt).to.be.a('undefined'); + expect(returnedUser.apiToken).to.be.a('undefined'); + }); + }); +}); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 7a818d9877..6693dba861 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -30,7 +30,7 @@ api.registerLocal = { url: '/user/auth/local/register', handler (req, res, next) { let fbUser = res.locals.user; // If adding local auth to social user - + // TODO check user doesn't have local auth req.checkBody({ email: { notEmpty: {errorMessage: res.t('missingEmail')}, diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js new file mode 100644 index 0000000000..690b18aeb2 --- /dev/null +++ b/website/src/controllers/api-v3/user.js @@ -0,0 +1,33 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import common from '../../../../common'; + +let api = {}; + +/** + * @api {get} /user Get the authenticated user's profile + * @apiVersion 3.0.0 + * @apiName UserGet + * @apiGroup User + * + * @apiSuccess {Object} user The user object + */ +api.getUser = { + method: 'GET', + middlewares: [authWithHeaders()], + url: '/user', + handler (req, res) { + let user = res.locals.user.toJSON(); + + // Remove apiToken from resonse TODO make it priavte at the user level? returned in signup/login + delete user.apiToken; + + // TODO move to model (maybe virtuals, maybe in toJSON) + user.stats.toNextLevel = common.tnl(user.stats.lvl); + user.stats.maxHealth = common.maxHealth; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; + + return res.json(200, user); + }, +}; + +export default api; diff --git a/website/src/models/user.js b/website/src/models/user.js index a2e414a296..42bb83f7a0 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -469,7 +469,7 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags'], + noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function toJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? From 9394fb0d94d5e5b373678ada6a72c54886681914 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 13 Dec 2015 20:12:04 +0100 Subject: [PATCH 229/976] use res.respond --- website/src/controllers/api-v3/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 690b18aeb2..498841474f 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -26,7 +26,7 @@ api.getUser = { user.stats.maxHealth = common.maxHealth; user.stats.maxMP = res.locals.user._statsComputed.maxMP; - return res.json(200, user); + return res.respond(200, user); }, }; From 409102ae19b12aa95d0e9ea66741e2f6067a48c9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 13 Dec 2015 23:51:06 +0100 Subject: [PATCH 230/976] wip score tests --- common/script/index.js | 14 +- .../POST-tasks_id_score_direction.test.js | 256 +++++++++++++++--- website/src/models/user.js | 3 +- 3 files changed, 224 insertions(+), 49 deletions(-) diff --git a/common/script/index.js b/common/script/index.js index b39ff00e89..3ccbc5726b 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -2063,7 +2063,7 @@ api.wrap = function(user, main) { return content.gear.flat[type + "_base_0"]; } return item; - }, + }, handleTwoHanded: function(item, type, req) { var message, currentWeapon, currentShield; if (type == null) { @@ -2071,17 +2071,17 @@ api.wrap = function(user, main) { } currentShield = content.gear.flat[user.items.gear[type].shield]; currentWeapon = content.gear.flat[user.items.gear[type].weapon]; - + if (item.type === "shield" && (currentWeapon ? currentWeapon.twoHanded : false)) { user.items.gear[type].weapon = 'weapon_base_0'; message = i18n.t('messageTwoHandedUnequip', { twoHandedText: currentWeapon.text(req.language), offHandedText: item.text(req.language), }, req.language); - } else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) { - user.items.gear[type].shield = "shield_base_0"; + } else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) { + user.items.gear[type].shield = "shield_base_0"; message = i18n.t('messageTwoHandedEquip', { twoHandedText: item.text(req.language), offHandedText: currentShield.text(req.language), - }, req.language); + }, req.language); } return message; }, @@ -2691,11 +2691,11 @@ api.wrap = function(user, main) { return computed; } }); - return Object.defineProperty(user, 'tasks', { + /*return Object.defineProperty(user, 'tasks', { get: function() { var tasks; tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); return _.object(_.pluck(tasks, "id"), tasks); } - }); + });*/ }; diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 71367fbfeb..b3197a88ff 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -46,25 +46,77 @@ describe('POST /tasks/:id/score/:direction', () => { }); }); - it('completes todo when direction is up'); + it('completes todo when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/tasks/${todo._id}`)) + .then((task) => expect(task.completed).to.equal(true)); + }); - it('uncompletes todo when direction is down'); + it('uncompletes todo when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/tasks/${todo._id}`)) + .then((updatedTask) => { + expect(updatedTask.completed).to.equal(false); + }); + }); it('scores up todo even if it is already completed'); // Yes? it('scores down todo even if it is already uncompleted'); // Yes? - it('increases user\'s mp when direction is up'); + it('increases user\'s mp when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s mp when direction is down'); + it('decreases user\'s mp when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s exp when direction is up'); + it('increases user\'s exp when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s exp when direction is down'); + it('decreases user\'s exp when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s gold when direction is up'); + it('increases user\'s gold when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s gold when direction is down'); + it('decreases user\'s gold when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); }); context('dailys', () => { @@ -79,57 +131,108 @@ describe('POST /tasks/:id/score/:direction', () => { }); }); - it('completes daily when direction is up'); + it('completes daily when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/tasks/${daily._id}`)) + .then((task) => expect(task.completed).to.equal(true)); + }); - it('uncompletes daily when direction is down'); + it('uncompletes daily when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/tasks/${daily._id}`)) + .then((task) => expect(task.completed).to.equal(false)); + }); it('scores up daily even if it is already completed'); // Yes? it('scores down daily even if it is already uncompleted'); // Yes? - it('increases user\'s mp when direction is up'); + it('increases user\'s mp when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s mp when direction is down'); + it('decreases user\'s mp when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s exp when direction is up'); + it('increases user\'s exp when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s exp when direction is down'); + it('decreases user\'s exp when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s gold when direction is up'); + it('increases user\'s gold when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s gold when direction is down'); + it('decreases user\'s gold when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); }); context('habits', () => { let habit, minusHabit, plusHabit, neitherHabit; beforeEach(() => { - return Q.all([ - api.post('/tasks', { - text: 'test habit', - type: 'habit', - }), - api.post('/tasks', { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((task) => { + habit = task; + return api.post('/tasks', { text: 'test min habit', type: 'habit', up: false, - }), - api.post('/tasks', { + }); + }).then((task) => { + minusHabit = task; + return api.post('/tasks', { text: 'test plus habit', type: 'habit', down: false, - }), + }) + }).then((task) => { + plusHabit = task; api.post('/tasks', { text: 'test neither habit', type: 'habit', up: false, down: false, - }), - ]).then(tasks => { - habit = tasks[0]; - minusHabit = tasks[1]; - plusHabit = tasks[2]; - neitherHabit = tasks[3]; + }) + }).then((task) => { + neitherHabit = task; }); }); @@ -137,17 +240,59 @@ describe('POST /tasks/:id/score/:direction', () => { it('prevents minus only habit from scoring up'); // Yes? - it('increases user\'s mp when direction is up'); + it('increases user\'s mp when direction is up', () => { + return api.post(`/tasks/${habit._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s mp when direction is down'); + it('decreases user\'s mp when direction is down', () => { + return api.post(`/tasks/${habit._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s exp when direction is up'); + it('increases user\'s exp when direction is up', () => { + return api.post(`/tasks/${habit._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s exp when direction is down'); + it('decreases user\'s exp when direction is down', () => { + return api.post(`/tasks/${habit._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s gold when direction is up'); + it('increases user\'s gold when direction is up', () => { + return api.post(`/tasks/${habit._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s gold when direction is down'); + it('decreases user\'s gold when direction is down', () => { + return api.post(`/tasks/${habit._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); }); context('reward', () => { @@ -157,17 +302,46 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post('/tasks', { text: 'test reward', type: 'reward', + value: 5, }).then((task) => { reward = task; }); }); - it('purchases reward'); + it('purchases reward', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.hp).to.equal(updatedUser.stats.mp + 5); + user = updatedUser; + }); + }); - it('does not change user\'s mp'); + it('does not change user\'s mp', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp).to.equal(updatedUser.stats.mp); + user = updatedUser; + }); + }); - it('does not change user\'s exp'); + it('does not change user\'s exp', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp).to.equal(updatedUser.stats.exp); + user = updatedUser; + }); + }); - it('does not allow a down direction'); + it('does not allow a down direction', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp).to.equal(updatedUser.stats.mp); + user = updatedUser; + }); + }); }); }); diff --git a/website/src/models/user.js b/website/src/models/user.js index 42bb83f7a0..4a4748ccc5 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -469,7 +469,8 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags'], + // TODO revisit a lot of things are missing + noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function toJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? From f4af7309cbc6b15f8863e38019df74f221e7e287 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 08:59:41 -0600 Subject: [PATCH 231/976] tests(helpers): Fix local doc update --- test/helpers/api-integration.helper.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 48ee56739d..027d719edd 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -1,7 +1,7 @@ /* eslint-disable no-use-before-define */ import { - assign, + set, each, isEmpty, times, @@ -282,11 +282,17 @@ function _updateDocument (collectionName, doc, update, cb) { let collection = db.collection(collectionName); - collection.update({ _id: doc._id }, { $set: update }, (updateErr) => { + collection.updateOne({ _id: doc._id }, { $set: update }, (updateErr) => { if (updateErr) throw new Error(`Error updating ${collectionName}: ${updateErr}`); - assign(doc, update); + _updateLocalDocument(doc, update); db.close(); cb(); }); }); } + +function _updateLocalDocument (doc, update) { + each(update, (value, param) => { + set(doc, param, value); + }); +} From c20617e18598d368e3a75632d01c9895de8fd678 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 09:00:22 -0600 Subject: [PATCH 232/976] tests(apiv3): Remove unused dependency --- .../v3/integration/tasks/POST-tasks_id_score_direction.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index b3197a88ff..2b5826c269 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -4,7 +4,6 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; -import Q from 'q'; describe('POST /tasks/:id/score/:direction', () => { let user, api; From ac61809fc0288a2def12912465e222f8e9d2a4f5 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 12:00:23 -0600 Subject: [PATCH 233/976] tests(apiv3): Remove user dependency --- .../POST-tasks_id_score_direction.test.js | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 2b5826c269..c256e5317f 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -8,8 +8,10 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:id/score/:direction', () => { let user, api; - before(() => { - return generateUser().then((generatedUser) => { + beforeEach(() => { + return generateUser({ + 'stats.gp': 100, + }).then((generatedUser) => { user = generatedUser; api = requester(user); }); @@ -68,7 +70,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -77,7 +78,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -86,7 +86,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -95,7 +94,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -104,7 +102,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); @@ -113,7 +110,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); }); @@ -151,7 +147,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -160,7 +155,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -169,7 +163,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -178,7 +171,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -187,7 +179,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); @@ -196,7 +187,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); }); @@ -244,7 +234,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -253,7 +242,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -262,7 +250,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -271,7 +258,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -280,7 +266,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); @@ -289,7 +274,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); }); @@ -311,8 +295,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${reward._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.hp).to.equal(updatedUser.stats.mp + 5); - user = updatedUser; + expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); }); }); @@ -321,7 +304,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); - user = updatedUser; }); }); @@ -330,7 +312,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp).to.equal(updatedUser.stats.exp); - user = updatedUser; }); }); @@ -339,7 +320,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); - user = updatedUser; }); }); }); From 9205f01e01b74275f9c990d28b3c1ad714fc4bab Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 12:13:02 -0600 Subject: [PATCH 234/976] tests(apiv3): Use lessThan and greaterThan expectation syntax --- .../POST-tasks_id_score_direction.test.js | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index c256e5317f..89ae7090de 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -69,7 +69,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); }); @@ -77,7 +77,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); }); @@ -85,7 +85,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); }); @@ -93,7 +93,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); }); @@ -101,7 +101,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); @@ -109,7 +109,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); }); @@ -146,7 +146,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); }); @@ -154,7 +154,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); }); @@ -162,7 +162,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); }); @@ -170,7 +170,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); }); @@ -178,7 +178,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); @@ -186,7 +186,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); }); @@ -233,7 +233,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); }); @@ -241,7 +241,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); }); @@ -249,7 +249,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); }); @@ -257,7 +257,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); }); @@ -265,7 +265,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); @@ -273,7 +273,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); }); From 3857316a1e399d31080dc666e8cd3c462b368ee8 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 12:23:13 -0600 Subject: [PATCH 235/976] fix(api): Correct sendTaskWebhook call --- website/src/controllers/api-v3/tasks.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index c64090eb13..eec1e28ddb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,5 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import webhook from '../../libs/api-v3/webhook'; +import { sendTaskWebhook } from '../../libs/api-v3/webhook'; import * as Tasks from '../../models/task'; import { NotFound, @@ -272,7 +272,7 @@ api.scoreTask = { let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); res.respond(200, resJsonData); - webhook.sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); + sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); // TODO sync challenge }); From 6709c83804efcfce893705e1756178143111a3f8 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 12:37:59 -0600 Subject: [PATCH 236/976] tests(apiv3): Remove incorrect tests --- .../tasks/POST-tasks_id_score_direction.test.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 89ae7090de..98d34bcbd5 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -253,14 +253,6 @@ describe('POST /tasks/:id/score/:direction', () => { }); }); - it('decreases user\'s exp when direction is down', () => { - return api.post(`/tasks/${habit._id}/score/down`) - .then((res) => api.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); - }); - }); - it('increases user\'s gold when direction is up', () => { return api.post(`/tasks/${habit._id}/score/up`) .then((res) => api.get(`/user`)) @@ -268,14 +260,6 @@ describe('POST /tasks/:id/score/:direction', () => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); - - it('decreases user\'s gold when direction is down', () => { - return api.post(`/tasks/${habit._id}/score/down`) - .then((res) => api.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); - }); - }); }); context('reward', () => { From 155719996d7f0047f3edac1dacf3efc8c1ba0e20 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 14 Dec 2015 20:10:18 +0100 Subject: [PATCH 237/976] remove user.tasks getter on the server --- common/script/index.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/common/script/index.js b/common/script/index.js index 3ccbc5726b..7f12be1bf4 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -2691,11 +2691,14 @@ api.wrap = function(user, main) { return computed; } }); - /*return Object.defineProperty(user, 'tasks', { - get: function() { - var tasks; - tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); - return _.object(_.pluck(tasks, "id"), tasks); - } - });*/ + + if (typeof window !== 'undefined') { + Object.defineProperty(user, 'tasks', { + get: function() { + var tasks; + tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); + return _.object(_.pluck(tasks, "id"), tasks); + } + }); + } }; From 9864b8a1cb7f58b76ad2f4e6cf6c66fc59a2bfd4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 14 Dec 2015 21:56:09 +0100 Subject: [PATCH 238/976] tests for tags --- test/helpers/api-integration.helper.js | 1 + website/src/controllers/api-v3/tags.js | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 027d719edd..973414a3a8 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -277,6 +277,7 @@ function _updateDocument (collectionName, doc, update, cb) { return cb(); } + // TODO use config for db url? mongo.connect('mongodb://localhost/habitrpg_test', (connectErr, db) => { if (connectErr) throw new Error(`Error connecting to database when updating ${collectionName} collection: ${connectErr}`); diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 4917858449..db601e968e 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -69,7 +69,7 @@ api.getTag = { handler (req, res, next) { let user = res.locals.user; - req.checkParams('taskId', res.t('tagIdRequired')).notEmpty().isUUID(); + req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); @@ -100,7 +100,7 @@ api.updateTag = { req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty - let tagId = req.params.id; + let tagId = req.params.tagId; let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); @@ -127,7 +127,7 @@ api.updateTag = { * @apiSuccess {object} empty An empty object */ api.deleteTag = { - method: 'GET', + method: 'DELETE', url: '/tags/:tagId', middlewares: [authWithHeaders()], handler (req, res, next) { From da154d3ea38467b294f6c432125bbc55dc12ab77 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 14 Dec 2015 21:56:29 +0100 Subject: [PATCH 239/976] tests for tags (missed some files before) --- .../integration/tags/DELETE-tags_id.test.js | 35 ++++++++++++++++++ test/api/v3/integration/tags/GET-tags.test.js | 26 ++++++++++++++ .../v3/integration/tags/GET-tags_id.test.js | 28 +++++++++++++++ .../api/v3/integration/tags/POST-tags.test.js | 32 +++++++++++++++++ .../v3/integration/tags/PUT-tags_id.test.js | 36 +++++++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 test/api/v3/integration/tags/DELETE-tags_id.test.js create mode 100644 test/api/v3/integration/tags/GET-tags.test.js create mode 100644 test/api/v3/integration/tags/GET-tags_id.test.js create mode 100644 test/api/v3/integration/tags/POST-tags.test.js create mode 100644 test/api/v3/integration/tags/PUT-tags_id.test.js diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js new file mode 100644 index 0000000000..0cf4764679 --- /dev/null +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -0,0 +1,35 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('DELETE /tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('deletes a tag given it\'s id', () => { + let length; + let tag; + + return api.post('/tags', {name: 'Tag 1'}) + .then((createdTag) => { + tag = createdTag; + return api.get(`/tags`); + }) + .then((tags) => { + length = tags.length; + return api.del(`/tags/${tag._id}`); + }) + .then(() => api.get(`/tags`)) + .then((tags) => { + expect(tags.length).to.equal(length - 1); + expect(tags[tags.length - 1].name).to.not.equal('Tag 1'); + }); + }); +}); diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js new file mode 100644 index 0000000000..bc0439ffb6 --- /dev/null +++ b/test/api/v3/integration/tags/GET-tags.test.js @@ -0,0 +1,26 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /tags', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('returns all user\'s tags', () => { + return api.post('/tags', {name: 'Tag 1'}) + .then(() => api.post('/tags', {name: 'Tag 2'})) + .then(() => api.get('/tags')) + .then((tags) => { + expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task + expect(tags[tags.length - 2].name).to.equal('Tag 1'); + expect(tags[tags.length - 1].name).to.equal('Tag 2'); + }); + }); +}); diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js new file mode 100644 index 0000000000..86c63094f3 --- /dev/null +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -0,0 +1,28 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('returns a tag given it\'s id', () => { + let createdTag; + + return api.post('/tags', {name: 'Tag 1'}) + .then((tag) => { + createdTag = tag; + return api.get(`/tags/${createdTag._id}`) + }) + .then((tag) => { + expect(tag).to.deep.equal(createdTag); + }); + }); +}); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js new file mode 100644 index 0000000000..0bed7e71f5 --- /dev/null +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -0,0 +1,32 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /tags', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('creates a tag correctly', () => { + let createdTag; + + return api.post('/tags', { + name: 'Tag 1', + ignored: false, + }).then((tag) => { + createdTag = tag; + expect(tag.name).to.equal('Tag 1'); + expect(tag.ignored).to.be.a('undefined'); + return api.get(`/tags/${createdTag._id}`) + }) + .then((tag) => { + expect(tag).to.deep.equal(createdTag); + }); + }); +}); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js new file mode 100644 index 0000000000..ace042afa8 --- /dev/null +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -0,0 +1,36 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('PUT /tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('updates a tag given it\'s id', () => { + let length; + + return api.post('/tags', {name: 'Tag 1'}) + .then((createdTag) => { + return api.put(`/tags/${createdTag._id}`, { + name: 'Tag updated', + ignored: true + }); + }) + .then((updatedTag) => { + expect(updatedTag.name).to.equal('Tag updated'); + expect(updatedTag.ignored).to.be.a('undefined'); + return api.get(`/tags/${updatedTag._id}`); + }) + .then((tag) => { + expect(tag.name).to.equal('Tag updated'); + expect(tag.ignored).to.be.a('undefined'); + }); + }); +}); From e547eb2ddedb8ff304e1670d97bca363f6b75eac Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 14 Dec 2015 22:44:50 +0100 Subject: [PATCH 240/976] add tests for tags ops on tasks --- common/locales/en/api-v3.json | 2 +- .../DELETE-tasks_taskId_tags_tagId.test.js | 53 ++++++++++++++ .../tags/POST-tasks_taskId_tags_tagId.test.js | 70 +++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 6 +- 4 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js create mode 100644 test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 0b810c76d1..935fdf2a1d 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -27,5 +27,5 @@ "positionRequired": "\"position\" is required and must be a number.", "cantMoveCompletedTodo": "Can't move a completed todo.", "directionUpDown": "\"direction\" is required and must be 'up' or 'down'", - "alreadyTagged": "The task is already tagged with give tag." + "alreadyTagged": "The task is already tagged with given tag." } diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js new file mode 100644 index 0000000000..6fa2c94d68 --- /dev/null +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -0,0 +1,53 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /tasks/:taskId/tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('removes a tag from a task', () => { + let tag; + let task; + + return api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + task = createdTask; + return api.post('/tags', {name: 'Tag 1'}); + }).then(createdTag => { + tag = createdTag; + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + }).then(savedTask => { + return api.del(`/tasks/${task._id}/tags/${tag._id}`); + }).then(() => api.get(`/tasks/${task._id}`)) + .then(updatedTask => { + expect(updatedTask.tags.length).to.equal(0); + }); + }); + + it('only deletes existing tags', () => { + let task; + + return expect(api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + return api.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('tagNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js new file mode 100644 index 0000000000..b5e8f4e485 --- /dev/null +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -0,0 +1,70 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('adds a tag to a task', () => { + let tag; + let task; + + return api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + task = createdTask; + return api.post('/tags', {name: 'Tag 1'}); + }).then(createdTag => { + tag = createdTag; + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + }).then(savedTask => { + expect(savedTask.tags[0]).to.equal(tag._id); + }); + }); + + it('does not add a tag to a task twice', () => { + let tag; + let task; + + return expect(api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + task = createdTask; + return api.post('/tags', {name: 'Tag 1'}); + }).then(createdTag => { + tag = createdTag; + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + }).then(() => { + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('alreadyTagged'), + }); + }); + + it('does not add a non existing tag to a task', () => { + return expect(api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then((task) => { + return api.post(`/tasks/${task._id}/tags/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index eec1e28ddb..818362efc9 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -518,7 +518,7 @@ api.removeChecklistItem = { */ api.addTagToTask = { method: 'POST', - url: '/tasks/:taskId/tags', + url: '/tasks/:taskId/tags/:tagId', middlewares: [authWithHeaders()], handler (req, res, next) { let user = res.locals.user; @@ -538,7 +538,7 @@ api.addTagToTask = { if (!task) throw new NotFound(res.t('taskNotFound')); let tagId = req.params.tagId; - let alreadyTagged = task.tags.indexOf(tagId) === -1; + let alreadyTagged = task.tags.indexOf(tagId) !== -1; if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged')); task.tags.push(tagId); @@ -580,7 +580,7 @@ api.removeTagFromTask = { .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); - let tagI = _.findIndex(task.tags, {_id: req.params.tagId}); + let tagI = task.tags.indexOf(req.params.tagId); if (tagI === -1) throw new NotFound(res.t('tagNotFound')); task.tags.splice(tagI, 1); From 13cbf03759fa8c95c2dadb0fde6370744d1f1f14 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 15 Dec 2015 12:11:32 +0100 Subject: [PATCH 241/976] use cron middleware --- ...LETE-tasks_taskId_checklist_itemId.test.js | 0 .../POST-tasks_taskId_checklist.test.js | 0 ...asks_taskId_checklist_itemId_score.test.js | 0 .../PUT-tasks_taskId_checklist_itemId.test.js | 0 website/src/controllers/api-v3/auth.js | 5 +++- website/src/controllers/api-v3/tags.js | 11 ++++---- website/src/controllers/api-v3/tasks.js | 27 ++++++++++--------- website/src/controllers/api-v3/user.js | 3 ++- website/src/middlewares/api-v3/cron.js | 1 + 9 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js create mode 100644 test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js create mode 100644 test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js create mode 100644 test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 6693dba861..2c7e00cadc 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -1,6 +1,7 @@ import validator from 'validator'; import passport from 'passport'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; import { NotAuthorized, } from '../../libs/api-v3/errors'; @@ -138,6 +139,7 @@ function _loginRes (user, req, res, next) { api.loginLocal = { method: 'POST', url: '/user/auth/local/login', + middlewares: [cron], handler (req, res, next) { req.checkBody({ username: { @@ -182,6 +184,7 @@ api.loginLocal = { api.loginSocial = { method: 'POST', url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 + middlewares: [cron], handler (req, res, next) { let accessToken = req.body.authResponse.access_token; let network = req.body.network; @@ -247,7 +250,7 @@ api.loginSocial = { api.deleteSocial = { method: 'DELETE', url: '/user/auth/social/:network', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; let network = req.params.network; diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index db601e968e..20d3f2f5ed 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -1,4 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; import { model as Tag } from '../../models/tag'; import { NotFound, @@ -18,7 +19,7 @@ let api = {}; api.createTag = { method: 'POST', url: '/tags', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -45,7 +46,7 @@ api.createTag = { api.getTags = { method: 'GET', url: '/tags', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res) { let user = res.locals.user; res.respond(200, user.tags); @@ -65,7 +66,7 @@ api.getTags = { api.getTag = { method: 'GET', url: '/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -93,7 +94,7 @@ api.getTag = { api.updateTag = { method: 'PUT', url: '/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -129,7 +130,7 @@ api.updateTag = { api.deleteTag = { method: 'DELETE', url: '/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 818362efc9..019b7c058c 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,4 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; import { sendTaskWebhook } from '../../libs/api-v3/webhook'; import * as Tasks from '../../models/task'; import { @@ -26,7 +27,7 @@ let api = {}; api.createTask = { method: 'POST', url: '/tasks', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes); @@ -64,7 +65,7 @@ api.createTask = { api.getTasks = { method: 'GET', url: '/tasks', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); @@ -120,7 +121,7 @@ api.getTasks = { api.getTask = { method: 'GET', url: '/tasks/:taskId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -154,7 +155,7 @@ api.getTask = { api.updateTask = { method: 'PUT', url: '/tasks/:taskId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -236,7 +237,7 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { api.scoreTask = { method: 'POST', url: '/tasks/:taskId/score/:direction', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route? @@ -297,7 +298,7 @@ api.scoreTask = { api.moveTask = { method: 'POST', url: '/tasks/move/:taskId/to/:position', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); @@ -349,7 +350,7 @@ api.moveTask = { api.addChecklistItem = { method: 'POST', url: '/tasks/:taskId/checklist', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -389,7 +390,7 @@ api.addChecklistItem = { api.scoreCheckListItem = { method: 'POST', url: '/tasks/:taskId/checklist/:itemId/score', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -432,7 +433,7 @@ api.scoreCheckListItem = { api.updateChecklistItem = { method: 'PUT', url: '/tasks/:taskId/checklist/:itemId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -476,7 +477,7 @@ api.updateChecklistItem = { api.removeChecklistItem = { method: 'DELETE', url: '/tasks/:taskId/checklist/:itemId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -519,7 +520,7 @@ api.removeChecklistItem = { api.addTagToTask = { method: 'POST', url: '/tasks/:taskId/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -563,7 +564,7 @@ api.addTagToTask = { api.removeTagFromTask = { method: 'DELETE', url: '/tasks/:taskId/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -620,7 +621,7 @@ function _removeTaskTasksOrder (user, taskId) { api.deleteTask = { method: 'DELETE', url: '/tasks/:taskId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 498841474f..ef6749209c 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1,4 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; import common from '../../../../common'; let api = {}; @@ -13,7 +14,7 @@ let api = {}; */ api.getUser = { method: 'GET', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], url: '/user', handler (req, res) { let user = res.locals.user.toJSON(); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index d71c123346..d0c24d700d 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -7,6 +7,7 @@ import common from '../../../../common'; import Task from '../../models/task'; // import Group from '../../models/group'; +// TODO check that it's usef everywhere export default function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; From a46fa873a99202b8fafa23588384f296261435eb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 11:16:30 +0100 Subject: [PATCH 242/976] disable common tests for api v3 --- tasks/gulp-tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 3698fd1f64..b3c1eadf84 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -380,7 +380,7 @@ gulp.task('test:all', (done) => { runSequence( 'lint', // 'test:e2e:safe', - 'test:common:safe', + //'test:common:safe', // 'test:content:safe', 'test:server_side:safe', // 'test:karma:safe', From cc7bd1b5ac62e14f5628bb2804809371fafc23ea Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 11:17:56 +0100 Subject: [PATCH 243/976] disable server_side (api v2) tests for api v3 --- tasks/gulp-tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index b3c1eadf84..0c3cf30eb4 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -382,7 +382,7 @@ gulp.task('test:all', (done) => { // 'test:e2e:safe', //'test:common:safe', // 'test:content:safe', - 'test:server_side:safe', + //'test:server_side:safe', // 'test:karma:safe', // 'test:api-legacy:safe', // 'test:api-v2:safe', From d9e786ebaaf9f5fb50e6046fa22780d3aeb29914 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 12:29:03 +0100 Subject: [PATCH 244/976] checklists tests --- ...LETE-tasks_taskId_checklist_itemId.test.js | 86 ++++++++++++++++++ .../POST-tasks_taskId_checklist.test.js | 76 ++++++++++++++++ ...asks_taskId_checklist_itemId_score.test.js | 85 ++++++++++++++++++ .../PUT-tasks_taskId_checklist_itemId.test.js | 87 +++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 5 +- website/src/models/task.js | 6 ++ 6 files changed, 342 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index e69de29bb2..013ba69725 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -0,0 +1,86 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /tasks/:taskId/checklist/:itemId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('deletes a checklist item', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + }).then((savedTask) => { + return api.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + }).then(() => { + return api.get(`/tasks/${task._id}`); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(0); + }); + }); + + it('does not work with habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.del(`/tasks/${habit._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('does not work with rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.del(`/tasks/${reward._id}/checklist/${generateUUID()}`); + }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', () => { + return expect(api.post('/tasks', { + type: 'daily', + text: 'daily with checklist', + }).then(createdTask => { + return api.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index e69de29bb2..e3ec919308 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -0,0 +1,76 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/checklist/', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('adds a checklist item to a task', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', ignored: false, _id: 123}); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); + expect(savedTask.checklist[0].completed).to.equal(false); + expect(savedTask.checklist[0]._id).to.be.a('string'); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].ignored).to.be.an('undefined'); + }); + }); + + it('does not add a checklist to habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.post(`/tasks/${habit._id}/checklist`, {text: 'Checklist Item 1'}); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('does not add a checklist to rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.post(`/tasks/${reward._id}/checklist`, {text: 'Checklist Item 1'}); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.post(`/tasks/${generateUUID()}/checklist`, { + text: 'Checklist Item 1' + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index e69de29bb2..32c09ba1c8 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -0,0 +1,85 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/checklist/:itemId/score', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('scores a checklist item', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + }).then((savedTask) => { + return api.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].completed).to.equal(true); + }); + }); + + it('fails on habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, {text: 'Checklist Item 1'}); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`); + }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', () => { + return expect(api.post('/tasks', { + type: 'daily', + text: 'daily with checklist', + }).then(createdTask => { + return api.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index e69de29bb2..e272d05da8 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -0,0 +1,87 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('PUT /tasks/:taskId/checklist/:itemId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('updates a checklist item', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + }).then((savedTask) => { + return api.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, {text: 'updated', completed: true, _id: 123}); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('updated'); + expect(savedTask.checklist[0].completed).to.equal(true); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + }); + }); + + it('fails on habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.put(`/tasks/${habit._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.put(`/tasks/${reward._id}/checklist/${generateUUID()}`); + }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', () => { + return expect(api.post('/tasks', { + type: 'daily', + text: 'daily with checklist', + }).then(createdTask => { + return api.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 019b7c058c..9221787a63 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -368,7 +368,7 @@ api.addChecklistItem = { if (!task) throw new NotFound(res.t('taskNotFound')); if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - task.checklist.push(req.body); + task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); return task.save(); }) .then((savedTask) => res.respond(200, savedTask)) // TODO what to return @@ -454,8 +454,7 @@ api.updateChecklistItem = { let item = _.find(task.checklist, {_id: req.params.itemId}); if (!item) throw new NotFound(res.t('checklistItemNotFound')); - delete req.body.id; // Simple sanitization to prevent the ID to be changed - _.merge(item, req.body); + _.merge(item, Tasks.Task.sanitizeChecklist(req.body)); return task.save(); }) .then((savedTask) => res.respond(200, savedTask)) // TODO what to return diff --git a/website/src/models/task.js b/website/src/models/task.js index 3ba4f21b22..a4e376446d 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -57,6 +57,12 @@ TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define }; +// Sanitize checklist objects (disallowing _id) +TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) { + delete checklistObj._id; + return checklistObj; +}; + export let Task = mongoose.model('Task', TaskSchema); // habits and dailies shared fields From 35316ebeb6432f3038f59b3a784de4fe28f42849 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 12:57:19 +0100 Subject: [PATCH 245/976] move completed todos outside of tasksOrder (and back) with tests --- .../POST-tasks_id_score_direction.test.js | 33 +++++++++++++++++++ .../v3/integration/tasks/PUT-tasks_id.test.js | 8 ++--- website/src/controllers/api-v3/tasks.js | 17 ++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 98d34bcbd5..622d63dc4f 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -53,6 +53,39 @@ describe('POST /tasks/:id/score/:direction', () => { .then((task) => expect(task.completed).to.equal(true)); }); + it('moves completed todos out of user.tasksOrder.todos', () => { + return api.get('/user') + .then(user => { + expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + }).then(() => api.post(`/tasks/${todo._id}/score/up`)) + .then(() => api.get(`/tasks/${todo._id}`)) + .then((updatedTask) => { + expect(updatedTask.completed).to.equal(true); + return api.get('/user'); + }) + .then((user) => { + expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1) + }); + }); + + it('moves un-completed todos back into user.tasksOrder.todos', () => { + return api.get('/user') + .then(user => { + expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + }).then(() => api.post(`/tasks/${todo._id}/score/up`)) + .then(() => api.post(`/tasks/${todo._id}/score/down`)) + .then(() => api.get(`/tasks/${todo._id}`)) + .then((updatedTask) => { + expect(updatedTask.completed).to.equal(false); + return api.get('/user'); + }) + .then((user) => { + let l = user.tasksOrder.todos.length; + expect(user.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); + expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom + }); + }); + it('uncompletes todo when direction is down', () => { return api.post(`/tasks/${todo._id}/score/down`) .then((res) => api.get(`/tasks/${todo._id}`)) diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 7ea2440beb..f78f7ca35e 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -15,7 +15,7 @@ describe('PUT /tasks/:id', () => { }); }); - xcontext('validates params', () => { + context('validates params', () => { let task; beforeEach(() => { @@ -64,7 +64,7 @@ describe('PUT /tasks/:id', () => { }); }); - xcontext('habits', () => { + context('habits', () => { let habit; beforeEach(() => { @@ -92,7 +92,7 @@ describe('PUT /tasks/:id', () => { }); }); - xcontext('todos', () => { + context('todos', () => { let todo; beforeEach(() => { @@ -255,7 +255,7 @@ describe('PUT /tasks/:id', () => { }); }); - xcontext('rewards', () => { + context('rewards', () => { let reward; beforeEach(() => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 9221787a63..ef7180088e 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -255,6 +255,7 @@ api.scoreTask = { .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); + let wasCompleted = task.completed; if (task.type === 'daily' || task.type === 'todo') { task.completed = direction === 'up'; // TODO move into scoreTask } @@ -263,6 +264,22 @@ api.scoreTask = { // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) if (direction === 'up') user.fns.randomDrop({task, delta}, req); + // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list + if (task.type === 'todo') { + if (!wasCompleted && task.completed) { + let i = user.tasksOrder.todos.indexOf(task._id); + if (i !== -1) user.tasksOrder.todos.splice(i, 1); + } else if (wasCompleted && !task.completed) { + let i = user.tasksOrder.todos.indexOf(task._id); + if (i === -1) { + user.tasksOrder.todos.push(task._id); // TODO push at the top? + } else { // If for some reason it hadn't been removed TODO ok? + user.tasksOrder.todos.splice(i, 1); + user.tasksOrder.push(task._id); + } + } + } + return Q.all([ user.save(), task.save(), From bf7fc985d0b75fd63651cfbcc7b2bffe6d0fc744 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 20:00:10 +0100 Subject: [PATCH 246/976] begins porting group model to es6 --- .eslintrc | 2 +- tasks/gulp-eslint.js | 2 + website/src/models/group.js | 368 +++++++++++++++++++----------------- 3 files changed, 199 insertions(+), 173 deletions(-) diff --git a/.eslintrc b/.eslintrc index b6bfb0f4fa..62e61b8df8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -75,7 +75,7 @@ "comma-style": [2, "last"], "comma-dangle": [2, "always-multiline"], "computed-property-spacing": [2, "never"], - "consistent-this": [2, "self"], + "consistent-this": [0, "self"], "func-names": 2, "func-style": [2, "declaration", { "allowArrowFunctions": true }], "block-spacing": [2, "always"], diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 695bf03c36..1e39fb7532 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -5,6 +5,8 @@ const SERVER_FILES = [ './website/src/**/api-v3/**/*.js', './website/src/models/user.js', './website/src/models/task.js', + './website/src/models/group.js', + './website/src/models/tag.js', './website/src/models/emailUnsubscription.js', './website/src/server.js', ]; diff --git a/website/src/models/group.js b/website/src/models/group.js index 0d3011cc38..282d2461f8 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -1,26 +1,28 @@ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var User = require('./user').model; -var shared = require('../../../common'); -var _ = require('lodash'); -var async = require('async'); -var logging = require('../libs/api-v2/logging'); -var Challenge = require('./../models/challenge').model; -var firebase = require('../libs/api-v2/firebase'); +import mongoose from 'mongoose'; +import { model as User} from './user'; +import shared from '../../../common'; +import _ from 'lodash'; +// var async = require('async'); +import logger from '../libs/api-v3/logger'; +// var Challenge = require('./../models/challenge').model; +import firebase from '../libs/api-v2/firebase'; +import baseModel from '../libs/api-v3/baseModel'; +import Q from 'q'; -// NOTE any change to groups' members in MongoDB will have to be run through the API +let Schema = mongoose.Schema; + +// NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API // changes made directly to the db will cause Firebase to get out of sync -var GroupSchema = new Schema({ - _id: {type: String, 'default': shared.uuid}, - name: String, +export let schema = new Schema({ + name: {type: String, required: true}, description: String, leader: {type: String, ref: 'User'}, - members: [{type: String, ref: 'User'}], - invites: [{type: String, ref: 'User'}], - type: {type: String, "enum": ['guild', 'party']}, - privacy: {type: String, "enum": ['private', 'public'], 'default':'private'}, - //_v: {type: Number,'default': 0}, - chat: Array, + members: [{type: String, ref: 'User'}], // TODO do we need this? could depend on back-ref instead (User.find({group:GID}) + invites: [{type: String, ref: 'User'}], // TODO do we need this? could depend on back-ref instead (User.find({group:GID}) + type: {type: String, enum: ['guild', 'party'], required: true}, + privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true}, + // _v: {type: Number,'default': 0}, // TODO ? + chat: Array, // TODO ? /* # [{ # timestamp: Date @@ -32,41 +34,52 @@ var GroupSchema = new Schema({ # }] */ leaderOnly: { // restrict group actions to leader (members can't do them) - challenges: {type:Boolean, 'default':false}, - //invites: {type:Boolean, 'default':false} + challenges: {type: Boolean, default: false, required: true}, + // invites: {type:Boolean, 'default':false} // TODO ? }, - memberCount: {type: Number, 'default': 0}, - challengeCount: {type: Number, 'default': 0}, - balance: Number, + memberCount: {type: Number, default: 0}, + challengeCount: {type: Number, default: 0}, + balance: {type: Number, default: 0}, logo: String, leaderMessage: String, - challenges: [{type:'String', ref:'Challenge'}], // do we need this? could depend on back-ref instead (Challenge.find({group:GID})) + challenges: [{type: String, ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) quest: { key: String, - active: {type:Boolean, 'default':false}, - leader: {type:String, ref:'User'}, - progress:{ + active: {type: Boolean, default: false}, + leader: {type: String, ref: 'User'}, + progress: { hp: Number, - collect: {type:Schema.Types.Mixed, 'default':{}}, // {feather: 5, ingot: 3} + collect: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, // {feather: 5, ingot: 3} rage: Number, // limit break / "energy stored in shell", for explosion-attacks }, - //Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click - //'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. - //TODO when booting user, remove from .joined and check again if we can now start the quest - members: Schema.Types.Mixed, - extra: Schema.Types.Mixed - } + // Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click + // 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. + // TODO when booting user, remove from .joined and check again if we can now start the quest + members: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, + extra: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, + }, }, { - strict: 'throw', - minimize: false // So empty objects are returned + strict: true, + minimize: false, // So empty objects are returned }); +schema.plugin(baseModel, { + noSet: ['_id'], +}); + +// TODO migration /** * Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration * to remove duplicates, then take these fucntions out */ -function removeDuplicates(doc){ +/* function removeDuplicates(doc){ // Remove duplicate members if (doc.members) { var uniqMembers = _.uniq(doc.members); @@ -74,219 +87,238 @@ function removeDuplicates(doc){ doc.members = uniqMembers; } } -} +}*/ // FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate() -// @see https://github.com/LearnBoost/mongoose/issues/964 -GroupSchema.pre('save', function(next){ - removeDuplicates(this); +// @see https://github.com/LearnBoost/mongoose/issues/964 -> Add update pre? +// TODO necessary? +schema.pre('save', function preSaveGroup (next) { + // removeDuplicates(this); this.memberCount = _.size(this.members); this.challengeCount = _.size(this.challenges); - next(); -}) - -GroupSchema.pre('remove', function(next) { - var group = this; - async.waterfall([ - function(cb) { - var invitationQuery = {}; - var groupType = group.type; - //Add an 's' to group type guild because the model has the plural version - if (group.type == "guild") groupType += "s"; - invitationQuery['invitations.' + groupType + '.id'] = group._id; - User.find(invitationQuery, cb); - }, - function(users, cb) { - if (users) { - users.forEach(function (user, index, array) { - if ( group.type == "party" ) { - user.invitations.party = {}; - } else { - var i = _.findIndex(user.invitations.guilds, {id: group._id}); - user.invitations.guilds.splice(i, 1); - } - user.save(); - }); - } - cb(); - } - ], next); + return next(); }); -GroupSchema.post('remove', function(group) { +schema.pre('remove', true, function preRemoveGroup (next, done) { + next(); + let group = this; + + // Remove invitations when group is deleted + // TODO verify it works fir everything + User.find({ + // TODO remove need for guilds s in migration? same for id -> _id + [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id, + }).exec() + .then(users => { + return Q.all(users.map(user => { + if (group.type === 'party') { + user.invitations.party = {}; + } else { + let i = _.findIndex(user.invitations.guilds, {id: group._id}); + user.invitations.guilds.splice(i, 1); + } + return user.save(); // TODO update? + })); + }) + .then(done) + .catch(done); +}); + +schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -GroupSchema.methods.toJSON = function(){ - var doc = this.toObject(); - removeDuplicates(doc); - doc._isMember = this._isMember; +schema.methods.toJSON = function groupToJSON () { + let doc = this.toObject(); + // removeDuplicates(doc); + doc._isMember = this._isMember; // TODO ? - //fix(groups): temp fix to remove chat entries stored as strings (not sure why that's happening..). + // TODO migration + // fix(groups): temp fix to remove chat entries stored as strings (not sure why that's happening..). // Required as angular 1.3 is strict on dupes, and no message.id to `track by` - _.remove(doc.chat,function(msg){return !msg.id}); + _.remove(doc.chat, msg => !msg.id); + // TODO should not be needed here // @see pre('save') comment above this.memberCount = _.size(this.members); this.challengeCount = _.size(this.challenges); return doc; -} +}; -var chatDefaults = module.exports.chatDefaults = function(msg,user){ - var message = { +// TODO move to its own model +export function chatDefaults (msg, user) { + let message = { id: shared.uuid(), text: msg, - timestamp: +new Date, + timestamp: Number(new Date()), likes: {}, flags: {}, - flagCount: 0 + flagCount: 0, }; + if (user) { _.defaults(message, { uuid: user._id, contributor: user.contributor && user.contributor.toObject(), backer: user.backer && user.backer.toObject(), - user: user.profile.name + user: user.profile.name, }); } else { message.uuid = 'system'; } + return message; } -GroupSchema.methods.sendChat = function(message, user){ - var group = this; - group.chat.unshift(chatDefaults(message,user)); - group.chat.splice(200); - // Kick off chat notifications in the background. - var lastSeenUpdate = {$set:{}, $inc:{_v:1}}; - lastSeenUpdate['$set']['newMessages.'+group._id] = {name:group.name,value:true}; - if (group._id == 'habitrpg') { + +schema.methods.sendChat = function sendChat (message, user) { + this.chat.unshift(chatDefaults(message, user)); + this.chat.splice(200); + + // Kick off chat notifications in the background. // TODO refactor + let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; // TODO standardize this _v inc at the user level + lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; + + if (this._id === 'habitrpg') { // TODO For Tavern, only notify them if their name was mentioned // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); } else { - mongoose.model('User').update({_id:{$in:group.members, $ne: user ? user._id : ''}},lastSeenUpdate,{multi:true}).exec(); + User.update({ + _id: {$in: this.members, $ne: user ? user._id : ''}, + }, lastSeenUpdate, {multi: true}).exec(); } -} +}; -var cleanQuestProgress = function(merge){ - var clean = { +function _cleanQuestProgress (merge) { + // TODO clone? (also in sendChat message) + let clean = { key: null, progress: { up: 0, down: 0, - collect: {} + collect: {}, }, completed: null, - RSVPNeeded: false + RSVPNeeded: false, // TODO absolutely change this cryptic name }; - merge = merge || {progress:{}}; - _.merge(clean, _.omit(merge,'progress')); - _.merge(clean.progress, merge.progress); - return clean; -} -GroupSchema.statics.cleanQuestProgress = cleanQuestProgress; -// Participants: Grant rewards & achievements, finish quest -GroupSchema.methods.finishQuest = function(quest, cb) { - var group = this; - var questK = quest.key; - var updates = {$inc:{},$set:{}}; - - updates['$inc']['achievements.quests.' + questK] = 1; - updates['$inc']['stats.gp'] = +quest.drop.gp; - updates['$inc']['stats.exp'] = +quest.drop.exp; - updates['$inc']['_v'] = 1; - if (group._id == 'habitrpg') { - updates['$set']['party.quest.completed'] = questK; // Just show the notif - } else { - updates['$set']['party.quest'] = cleanQuestProgress({completed: questK}); // clear quest progress + if (merge) { // TODO why does it do 2 merges? + _.merge(clean, _.omit(merge, 'progress')); + _.merge(clean.progress, merge.progress); } - _.each(quest.drop.items, function(item){ - var dropK = item.key; + return clean; +} + +schema.statics.cleanQuestProgress = _cleanQuestProgress; + +// Participants: Grant rewards & achievements, finish quest +// TODO transform in promise +schema.methods.finishQuest = function finishQuest (quest, cb) { + let questK = quest.key; + let updates = {$inc: {}, $set: {}}; + + updates.$inc[`achievements.quests.${questK}`] = 1; + updates.$inc['stats.gp'] = Number(quest.drop.gp); // TODO are this castings necessary? + updates.$inc['stats.exp'] = Number(quest.drop.exp); + updates.$inc._v = 1; + + if (this._id === 'habitrpg') { + updates.$set['party.quest.completed'] = questK; // Just show the notif + } else { + updates.$set['party.quest'] = _cleanQuestProgress({completed: questK}); // clear quest progress + } + + _.each(quest.drop.items, (item) => { + let dropK = item.key; + switch (item.type) { case 'gear': // TODO This means they can lose their new gear on death, is that what we want? - updates['$set']['items.gear.owned.'+dropK] = true; + updates.$set[`items.gear.owned.${dropK}`] = true; break; case 'eggs': case 'food': case 'hatchingPotions': case 'quests': - updates['$inc']['items.'+item.type+'.'+dropK] = _.where(quest.drop.items,{type:item.type,key:item.key}).length; + updates.$inc[`items.${item.type}.${dropK}`] = _.where(quest.drop.items, {type: item.type, key: item.key}).length; break; case 'pets': - updates['$set']['items.pets.'+dropK] = 5; + updates.$set[`items.pets.${dropK}`] = 5; break; case 'mounts': - updates['$set']['items.mounts.'+dropK] = true; + updates.$set[`items.mounts.${dropK}`] = true; break; } - }) - var q = group._id === 'habitrpg' ? {} : {_id:{$in:_.keys(group.quest.members)}}; - group.quest = {};group.markModified('quest'); - mongoose.model('User').update(q, updates, {multi:true}, cb); -} + }); -function isOnQuest(user,progress,group){ + let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}}; + this.quest = {}; + this.markModified('quest'); + User.update(q, updates, {multi: true}, cb); +}; + +function _isOnQuest (user, progress, group) { return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; } -GroupSchema.statics.collectQuest = function(user, progress, cb) { - this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ - if (!isOnQuest(user,progress,group)) return cb(null); - var quest = shared.content.quests[group.quest.key]; +// TODO use promise +schema.statics.collectQuest = function collectQuest (user, progress, cb) { + this.findOne({ + type: 'party', + members: {$in: [user._id]}, + }).then(group => { + if (!_isOnQuest(user, progress, group)) return cb(); + let quest = shared.content.quests[group.quest.key]; - _.each(progress.collect,function(v,k){ + _.each(progress.collect, (v, k) => { group.quest.progress.collect[k] += v; }); - var foundText = _.reduce(progress.collect, function(m,v,k){ - m.push(v + ' ' + quest.collect[k].text('en')); + let foundText = _.reduce(progress.collect, (m, v, k) => { + m.push(`${v} ${quest.collect[k].text('en')}`); return m; }, []); + foundText = foundText ? foundText.join(', ') : 'nothing'; - group.sendChat("`" + user.profile.name + " found "+foundText+".`"); + group.sendChat(`\`${user.profile.name} found ${foundText}.\``); group.markModified('quest.progress.collect'); // Still needs completing - if (_.find(shared.content.quests[group.quest.key].collect, function(v,k){ + if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { return group.quest.progress.collect[k] < v.count; })) return group.save(cb); - async.series([ - function(cb2){ - group.finishQuest(quest,cb2); - }, - function(cb2){ - group.sendChat('`All items found! Party has received their rewards.`'); - group.save(cb2); - } - ],cb); + // TODO use promise + group.finishQuest(quest, () => { + group.sendChat('`All items found! Party has received their rewards.`'); + group.save(cb); + }); }) -} + .catch(cb); +}; // to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` -module.exports.tavernQuest = {}; -var tavernQ = {_id:'habitrpg','quest.key':{$ne:null}}; +// we export an empty object that is then populated with the query-returned data +export let tavernQuest = {}; + process.nextTick(function(){ - mongoose.model('Group').findOne(tavernQ, function(err,tavern){ + mongoose.model('Group').findOne({_id: 'habitrpg', 'quest.key': {$ne: null}}, (err, tavern) => { + // TODO handle error? if (!tavern) return; // No tavern quest - var quest = tavern.quest.toObject(); // Using _assign so we don't lose the reference to the exported tavernQuest - _.assign(module.exports.tavernQuest, quest); + _.assign(tavernQuest, tavern.quest.toObject()); }); }); -GroupSchema.statics.tavernBoss = function(user,progress) { +schema.statics.tavernBoss = function tavernBoss (user, progress) { if (!progress) return; // hack: prevent crazy damage to world boss - var dmg = Math.min(900, Math.abs(progress.up||0)), - rage = -Math.min(900, Math.abs(progress.down||0)); + let dmg = Math.min(900, Math.abs(progress.up || 0)); + let rage = -Math.min(900, Math.abs(progress.down || 0)); async.waterfall([ function(cb){ @@ -339,12 +371,12 @@ GroupSchema.statics.tavernBoss = function(user,progress) { } ],function(err,res){ if (err === true) return; // no current quest - if (err) return logging.error(err); + if (err) return logger.error(err); dmg = rage = null; }) } -GroupSchema.statics.bossQuest = function(user, progress, cb) { +schema.statics.bossQuest = function bossQuest (user, progress, cb) { this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ if (!isOnQuest(user,progress,group)) return cb(null); var quest = shared.content.quests[group.quest.key]; @@ -386,7 +418,7 @@ GroupSchema.statics.bossQuest = function(user, progress, cb) { } // Remove user from this group -GroupSchema.methods.leave = function(user, keep, mainCb){ +schema.methods.leave = function leaveGroup (user, keep, mainCb){ if(!user) return mainCb(new Error('Missing user.')); if(keep && typeof keep === 'function'){ @@ -472,22 +504,14 @@ GroupSchema.methods.leave = function(user, keep, mainCb){ }); }; - -GroupSchema.methods.toJSON = function() { - var doc = this.toObject(); - - return doc; -}; - - -module.exports.schema = GroupSchema; -var Group = module.exports.model = mongoose.model("Group", GroupSchema); +export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) -Group.count({_id: 'habitrpg'}, function(err, ct){ +// TODO use promise +model.count({_id: 'habitrpg'}, (err, ct) => { if (ct > 0) return; - new Group({ + new model({ _id: 'habitrpg', chat: [], leader: '9', From 455eaf0932bee6cb3c1a344730dfeeb89913e3c6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 20:41:54 +0100 Subject: [PATCH 247/976] add skeleton for groups controller --- website/src/controllers/api-v3/groups.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 website/src/controllers/api-v3/groups.js diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js new file mode 100644 index 0000000000..cf8c3c9ca5 --- /dev/null +++ b/website/src/controllers/api-v3/groups.js @@ -0,0 +1,20 @@ +let api = {}; + +/** + * @api {get} /groups/:groupId Get a group by its id + * @apiVersion 3.0.0 + * @apiName GetGroup + * @apiGroup Group + * + * @apiSuccess {Object} group The group object + */ +api.getGroup = { + method: 'GET', + url: '/groups/:groupId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + // ... + }, +}; + +export default api; From bbc47f5e000daabf61b55833b2fea9bf0f9fd1c6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 17 Dec 2015 16:11:45 +0100 Subject: [PATCH 248/976] finish porting group model --- .eslintrc | 2 +- website/src/controllers/api-v3/groups.js | 20 -- website/src/models/group.js | 311 +++++++++++------------ 3 files changed, 152 insertions(+), 181 deletions(-) delete mode 100644 website/src/controllers/api-v3/groups.js diff --git a/.eslintrc b/.eslintrc index 62e61b8df8..83cd0067d1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,6 +1,6 @@ { "rules": { - "indent": [2, 2], + "indent": [2, 2, {"SwitchCase": 1}], "quotes": [2, "single"], "linebreak-style": [2, "unix"], "semi": [2, "always"], diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js deleted file mode 100644 index cf8c3c9ca5..0000000000 --- a/website/src/controllers/api-v3/groups.js +++ /dev/null @@ -1,20 +0,0 @@ -let api = {}; - -/** - * @api {get} /groups/:groupId Get a group by its id - * @apiVersion 3.0.0 - * @apiName GetGroup - * @apiGroup Group - * - * @apiSuccess {Object} group The group object - */ -api.getGroup = { - method: 'GET', - url: '/groups/:groupId', - middlewares: [authWithHeaders()], - handler (req, res, next) { - // ... - }, -}; - -export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 282d2461f8..fb9ca70ad2 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -3,8 +3,7 @@ import { model as User} from './user'; import shared from '../../../common'; import _ from 'lodash'; // var async = require('async'); -import logger from '../libs/api-v3/logger'; -// var Challenge = require('./../models/challenge').model; +import { model as Challenge} from './challenge'; import firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import Q from 'q'; @@ -214,8 +213,7 @@ function _cleanQuestProgress (merge) { schema.statics.cleanQuestProgress = _cleanQuestProgress; // Participants: Grant rewards & achievements, finish quest -// TODO transform in promise -schema.methods.finishQuest = function finishQuest (quest, cb) { +schema.methods.finishQuest = function finishQuest (quest) { let questK = quest.key; let updates = {$inc: {}, $set: {}}; @@ -256,20 +254,19 @@ schema.methods.finishQuest = function finishQuest (quest, cb) { let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}}; this.quest = {}; this.markModified('quest'); - User.update(q, updates, {multi: true}, cb); + return User.update(q, updates, {multi: true}); }; function _isOnQuest (user, progress, group) { return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; } -// TODO use promise -schema.statics.collectQuest = function collectQuest (user, progress, cb) { - this.findOne({ +schema.statics.collectQuest = function collectQuest (user, progress) { + return this.findOne({ type: 'party', members: {$in: [user._id]}, }).then(group => { - if (!_isOnQuest(user, progress, group)) return cb(); + if (!_isOnQuest(user, progress, group)) return; let quest = shared.content.quests[group.quest.key]; _.each(progress.collect, (v, k) => { @@ -288,31 +285,40 @@ schema.statics.collectQuest = function collectQuest (user, progress, cb) { // Still needs completing if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { return group.quest.progress.collect[k] < v.count; - })) return group.save(cb); + })) return group.save(); // TODO use promise - group.finishQuest(quest, () => { + return group.finishQuest(quest) + .then(() => { group.sendChat('`All items found! Party has received their rewards.`'); - group.save(cb); + return group.save(); }); }) - .catch(cb); + // TODO ok to catch even if we're returning a promise? + .catch(); }; // to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` // we export an empty object that is then populated with the query-returned data export let tavernQuest = {}; +let tavernQ = {_id: 'habitrpg', 'quest.key': {$ne: null}}; -process.nextTick(function(){ - mongoose.model('Group').findOne({_id: 'habitrpg', 'quest.key': {$ne: null}}, (err, tavern) => { - // TODO handle error? +// we use process.nextTick because at this point the model is not yet avalaible +process.nextTick(() => { + mongoose.model('Group') + .findOne(tavernQ).exec() + .then(tavern => { if (!tavern) return; // No tavern quest // Using _assign so we don't lose the reference to the exported tavernQuest _.assign(tavernQuest, tavern.quest.toObject()); + }) + .catch(err => { + throw err; }); }); +// TODO promise? schema.statics.tavernBoss = function tavernBoss (user, progress) { if (!progress) return; @@ -320,71 +326,78 @@ schema.statics.tavernBoss = function tavernBoss (user, progress) { let dmg = Math.min(900, Math.abs(progress.up || 0)); let rage = -Math.min(900, Math.abs(progress.down || 0)); - async.waterfall([ - function(cb){ - mongoose.model('Group').findOne(tavernQ,cb); - }, - function(tavern,cb){ - if (!(tavern && tavern.quest && tavern.quest.key)) return cb(true); + this.findOne(tavernQ).exec() + .then(tavern => { + if (!(tavern && tavern.quest && tavern.quest.key)) return; - var quest = shared.content.quests[tavern.quest.key]; - if (tavern.quest.progress.hp <= 0) { - tavern.sendChat(quest.completionChat('en')); - tavern.finishQuest(quest, function(){}); - tavern.save(cb); - _.assign(module.exports.tavernQuest, {extra: null}); - } else { - // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, - // use those first - which allows us to update the boss on the go if things are too easy/hard. - if (!tavern.quest.extra) tavern.quest.extra = {}; - tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); - tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); - if (tavern.quest.progress.rage >= quest.boss.rage.value) { - if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; - var wd = tavern.quest.extra.worldDmg; - var scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // Burnout attacks Ian, Seasonal Sorceress, tavern - if (!scene) { - tavern.sendChat('`'+quest.boss.name('en')+' tries to unleash '+quest.boss.rage.title('en')+', but is too tired.`'); - tavern.quest.progress.rage = 0 //quest.boss.rage.value; - } else { - tavern.sendChat(quest.boss.rage[scene]('en')); - tavern.quest.extra.worldDmg[scene] = true; - tavern.quest.extra.worldDmg.recent = scene; - tavern.markModified('quest.extra.worldDmg'); - tavern.quest.progress.rage = 0; - if (quest.boss.rage.healing) { - tavern.quest.progress.hp += (quest.boss.rage.healing * tavern.quest.progress.hp); - } + let quest = shared.content.quests[tavern.quest.key]; + + if (tavern.quest.progress.hp <= 0) { + tavern.sendChat(quest.completionChat('en')); + tavern.finishQuest(quest, () => {}); + _.assign(tavernQuest, {extra: null}); + return tavern.save(); + } else { + // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, + // use those first - which allows us to update the boss on the go if things are too easy/hard. + if (!tavern.quest.extra) tavern.quest.extra = {}; + tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); + tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); + + if (tavern.quest.progress.rage >= quest.boss.rage.value) { + if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; + + let wd = tavern.quest.extra.worldDmg; + // Burnout attacks Ian, Seasonal Sorceress, tavern + let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary + + if (!scene) { + tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); + tavern.quest.progress.rage = 0; // quest.boss.rage.value; + } else { + tavern.sendChat(quest.boss.rage[scene]('en')); + tavern.quest.extra.worldDmg[scene] = true; + tavern.quest.extra.worldDmg.recent = scene; + tavern.markModified('quest.extra.worldDmg'); + tavern.quest.progress.rage = 0; + if (quest.boss.rage.healing) { + tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp; } } - if (quest.boss.desperation && (tavern.quest.progress.hp < quest.boss.desperation.threshold) && !tavern.quest.extra.desperate) { - tavern.sendChat(quest.boss.desperation.text('en')); - tavern.quest.extra.desperate = true; - tavern.quest.extra.def = quest.boss.desperation.def; - tavern.quest.extra.str = quest.boss.desperation.str; - tavern.markModified('quest.extra'); - } - - _.assign(module.exports.tavernQuest, tavern.quest.toObject()); - tavern.save(cb); } - } - ],function(err,res){ - if (err === true) return; // no current quest - if (err) return logger.error(err); - dmg = rage = null; - }) -} -schema.statics.bossQuest = function bossQuest (user, progress, cb) { - this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ - if (!isOnQuest(user,progress,group)) return cb(null); - var quest = shared.content.quests[group.quest.key]; - if (!progress || !quest) return cb(null); // FIXME why is this ever happening, progress should be defined at this point - var down = progress.down * quest.boss.str; // multiply by boss strength + if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { + tavern.sendChat(quest.boss.desperation.text('en')); + tavern.quest.extra.desperate = true; + tavern.quest.extra.def = quest.boss.desperation.def; + tavern.quest.extra.str = quest.boss.desperation.str; + tavern.markModified('quest.extra'); + } + + _.assign(module.exports.tavernQuest, tavern.quest.toObject()); + return tavern.save(); + } + }) + .catch(err => { + throw err; + }); +}; + +schema.statics.bossQuest = function bossQuest (user, progress) { + return this.findOne({ + type: 'party', + members: {$in: [user._id]}, + }).exec() + .then(group => { + if (!_isOnQuest(user, progress, group)) return; + + let quest = shared.content.quests[group.quest.key]; + if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point + + let down = progress.down * quest.boss.str; // multiply by boss strength group.quest.progress.hp -= progress.up; - group.sendChat("`" + user.profile.name + " attacks " + quest.boss.name('en') + " for " + (progress.up.toFixed(1)) + " damage, " + quest.boss.name('en') + " attacks party for " + Math.abs(down).toFixed(1) + " damage.`"); //TODO Create a party preferred language option so emits like this can be localized + group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``); // TODO Create a party preferred language option so emits like this can be localized // If boss has Rage, increment Rage as well if (quest.boss.rage) { @@ -392,131 +405,109 @@ schema.statics.bossQuest = function bossQuest (user, progress, cb) { if (group.quest.progress.rage >= quest.boss.rage.value) { group.sendChat(quest.boss.rage.effect('en')); group.quest.progress.rage = 0; - if (quest.boss.rage.healing) group.quest.progress.hp += (group.quest.progress.hp * quest.boss.rage.healing); //TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage + + // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage + if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing; if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; } } + // Everyone takes damage - var series = [ - function(cb2){ - mongoose.models.User.update({_id:{$in: _.keys(group.quest.members)}}, {$inc:{'stats.hp':down, _v:1}}, {multi:true}, cb2); - } - ] + let promise = User.update({ + _id: {$in: _.keys(group.quest.members)}, + }, { + $inc: {'stats.hp': down, _v: 1}, + }, {multi: true}); // Boss slain, finish quest if (group.quest.progress.hp <= 0) { - group.sendChat('`You defeated ' + quest.boss.name('en') + '! Questing party members receive the rewards of victory.`'); + group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); // Participants: Grant rewards & achievements, finish quest - series.push(function(cb2){ - group.finishQuest(quest,cb2); - }); + + return promise + .then(() => group.finishQuest()) + .then(() => group.save()); } - series.push(function(cb2){group.save(cb2)}); - async.series(series,cb); + return promise.then(() => group.save()); }) -} + // TODO necessary to catch if we're returning a promise? + .catch(err => { + throw err; + }); +}; // Remove user from this group -schema.methods.leave = function leaveGroup (user, keep, mainCb){ - if(!user) return mainCb(new Error('Missing user.')); +// TODO this is highly inefficient +schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { + let group = this; - if(keep && typeof keep === 'function'){ - mainCb = keep; - keep = null; - } - if(typeof keep !== 'string') keep = 'keep-all'; // can be also 'remove-all' - - var group = this; - - async.parallel([ + return Q.all([ // Remove user from group challenges - function(cb){ - async.waterfall([ - // Find relevant challenges - function(cb2) { - Challenge.find({ - _id: {$in: user.challenges}, // Challenges I am in - group: group._id // that belong to the group I am leaving - }, cb2); - }, - // Update each challenge - function(challenges, cb2) { - Challenge.update( - {_id: {$in: _.pluck(challenges, '_id')}}, - {$pull: {members: user._id}}, - {multi: true}, - function(err) { - cb2(err, challenges); // pass `challenges` above to cb - } - ); - }, - - // Unlink the challenge tasks from user - function(challenges, cb2) { - async.waterfall(challenges.map(function(chal) { - return function(cb3) { - var i = user.challenges.indexOf(chal._id) - if (~i) user.challenges.splice(i,1); - user.unlink({cid: chal._id, keep: keep}, cb3); - } - }), cb2); - } - ], cb); - }, + // First find relevant Challenges + Challenge.find({ + _id: {$in: user.challenges}, // Challenges I am in + group: group._id, // that belong to the group I am leaving + }).then(challenges => { + // Update each challenge + return Challenge.update( + {_id: {$in: _.pluck(challenges, '_id')}}, + {$pull: {members: user._id}}, + {multi: true} + ).then(() => challenges); // pass `challenges` above to next promise TODO ok to return a non-promise? + }).then(challenges => { + return Q.all(challenges.map(chal => { + let i = user.challenges.indexOf(chal._id); + if (i !== -1) user.challenges.splice(i, 1); + return user.unlink({cid: chal._id, keep}); + })); + }), // Update the group - function(cb){ + (() => { // If user is the last one in group and group is private, delete it - if(group.members.length === 1 && ( + if (group.members.length === 1 && ( group.type === 'party' || - (group.type === 'guild' && group.privacy === 'private') - )){ - group.remove(cb) - }else{ // otherwise just remove a member - var update = {$pull: {members: user._id}}; + group.type === 'guild' && group.privacy === 'private' + )) return group.remove(); - // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) - var leader = group.leader; + // otherwise just remove a member + let update = {$pull: {members: user._id}}; + // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) + let leader = group.leader; - if(leader == user._id || !~group.members.indexOf(leader)){ - var seniorMember = _.find(group.members, function (m) {return m != user._id}); + if (leader === user._id || group.members.indexOf(leader) === -1) { + let seniorMember = _.find(group.members, m => m !== user._id); - // could not exist in case of public guild with 1 member who is leaving - if(seniorMember){ - if (leader == user._id || !~group.members.indexOf(leader)) { - update['$set'] = update['$set'] || {}; - update['$set'].leader = seniorMember; - } - } - } - - update['$inc'] = {memberCount: -1}; - Group.update({_id: group._id}, update, cb); + // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving + if (seniorMember) update.$set = {leader: seniorMember}; } - } - ], function(err){ - if(err) return mainCb(err); + update.$inc = {memberCount: -1}; + return mongoose.model('Group').update({_id: group._id}, update); + })(), + ]).then(() => { firebase.removeUserFromGroup(group._id, user._id); - return mainCb(); + return; // TODO ok not to return promise? + }).catch(err => { // TODO do we have to catch err if we return the promise? + throw err; }); }; export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) -// TODO use promise model.count({_id: 'habitrpg'}, (err, ct) => { + if (err) throw err; if (ct > 0) return; - new model({ + new model({ // eslint-disable-line new-cap _id: 'habitrpg', chat: [], leader: '9', name: 'HabitRPG', type: 'guild', - privacy: 'public' + privacy: 'public', }).save(); }); From 18c49493e02fcb5d7adac764e31c5e2b86b36af7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 17 Dec 2015 16:27:17 +0100 Subject: [PATCH 249/976] fixes tests --- test/api-legacy/api-helper.js | 2 ++ test/helpers/common.helper.js | 2 ++ website/src/models/group.js | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/api-legacy/api-helper.js b/test/api-legacy/api-helper.js index 57817173c2..9ade0a2aa2 100644 --- a/test/api-legacy/api-helper.js +++ b/test/api-legacy/api-helper.js @@ -6,6 +6,8 @@ superagentDefaults = require("superagent-defaults"); global.request = superagentDefaults(); global.mongoose = require("mongoose"); +var Q = require('q'); +mongoose.Promise = Q.Promise; global.moment = require("moment"); diff --git a/test/helpers/common.helper.js b/test/helpers/common.helper.js index 96064b8142..5e526c1424 100644 --- a/test/helpers/common.helper.js +++ b/test/helpers/common.helper.js @@ -1,4 +1,6 @@ import mongoose from 'mongoose'; +import Q from 'q'; +mongoose.Promise = Q.Promise; import { wrap as wrapUser } from '../../common/script/index'; import { model as User } from '../../website/src/models/user'; diff --git a/website/src/models/group.js b/website/src/models/group.js index fb9ca70ad2..fd4c0bbe0d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -305,7 +305,7 @@ let tavernQ = {_id: 'habitrpg', 'quest.key': {$ne: null}}; // we use process.nextTick because at this point the model is not yet avalaible process.nextTick(() => { - mongoose.model('Group') + model // eslint-disable-line no-use-before-define .findOne(tavernQ).exec() .then(tavern => { if (!tavern) return; // No tavern quest @@ -485,7 +485,7 @@ schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { } update.$inc = {memberCount: -1}; - return mongoose.model('Group').update({_id: group._id}, update); + return model.update({_id: group._id}, update); // eslint-disable-line no-use-before-define })(), ]).then(() => { firebase.removeUserFromGroup(group._id, user._id); From 21f93c9399371f525df7d4eae8d639cfb7c1166a Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Wed, 16 Dec 2015 17:29:20 -0500 Subject: [PATCH 250/976] WIP(chat): v3 controller --- common/locales/en/api-v3.json | 4 ++- website/src/controllers/api-v3/chat.js | 49 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 website/src/controllers/api-v3/chat.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 935fdf2a1d..d223c66961 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -27,5 +27,7 @@ "positionRequired": "\"position\" is required and must be a number.", "cantMoveCompletedTodo": "Can't move a completed todo.", "directionUpDown": "\"direction\" is required and must be 'up' or 'down'", - "alreadyTagged": "The task is already tagged with given tag." + "alreadyTagged": "The task is already tagged with given tag.", + "groupIdRequired": "\"groupId\" must be a valid UUID", + "groupNotFound": "Group not found." } diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js new file mode 100644 index 0000000000..aa56b03f73 --- /dev/null +++ b/website/src/controllers/api-v3/chat.js @@ -0,0 +1,49 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { model as Group } from '../../models/group'; +import { + NotFound, +} from '../../libs/api-v3/errors'; + +let api = {}; + +/** + * @api {get} /groups/:groupId/chat Get chat messages from a group + * @apiVersion 3.0.0 + * @apiName GetChat + * @apiGroup Chat + * + * @apiParam {UUID} groupId The group _id + * + * @apiSuccess {Array} chat An array of chat messages + */ +api.getChat = { + method: 'GET', + url: '/groups/:groupId/chat', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + let query = groupId === 'party' ? + Group.findOne({type: 'party', members: {$in: [user._id]}}) : + Group.findOne({$or: [ + {_id: groupId, privacy: 'public'}, + {_id: groupId, privacy: 'private', members: {$in: [user._id]}}, + ]}); + + query.exec() + .then((group) => { + if (!group) throw new NotFound(res.t('groupNotFound')); + res.respond(200, group.chat); + }) + .catch(next); + }, +}; + +export default api; From c3b981dc91e3704fcc75bc5aa6799924aca0dd3f Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Thu, 17 Dec 2015 18:48:10 -0500 Subject: [PATCH 251/976] test(api): chat WIP --- test/api/v3/integration/chat/GET-chat.test.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/api/v3/integration/chat/GET-chat.test.js diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js new file mode 100644 index 0000000000..4b4ebd9fdf --- /dev/null +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -0,0 +1,41 @@ +import { + createAndPopulateGroup, + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe.only('GET /groups/:groupId/chat', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + context('public Guild', () => { + let group; + + before(() => { + return createAndPopulateGroup({groupDetails: { + type: 'guild', + privacy: 'public', + chat: [ + 'Hello', + 'Welcome to the Guild', + ], + }}) + .then((createdGroup) => { + group = createdGroup; + }); + }); + + it('returns Guild chat', () => { + return api.get('/groups/' + group._id + '/chat') + .then((getChat) => { + expect(getChat).to.eql(group.chat); + }); + }); + }); +}); From c7b3e3c3e14b0dea858ba7f7070197f093ee82b4 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Thu, 17 Dec 2015 18:50:26 -0500 Subject: [PATCH 252/976] fix(test): Remove only --- test/api/v3/integration/chat/GET-chat.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 4b4ebd9fdf..75b5a36d15 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -4,7 +4,7 @@ import { requester, } from '../../../../helpers/api-integration.helper'; -describe.only('GET /groups/:groupId/chat', () => { +describe('GET /groups/:groupId/chat', () => { let user, api; before(() => { From c1daada82c31028ff365957ddb80fbca1e3b2653 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 15:10:37 +0100 Subject: [PATCH 253/976] do not store members or invitations on group doc --- website/src/models/group.js | 4 ++-- website/src/models/user.js | 23 ++++++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index fd4c0bbe0d..9bf5313858 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -2,8 +2,8 @@ import mongoose from 'mongoose'; import { model as User} from './user'; import shared from '../../../common'; import _ from 'lodash'; -// var async = require('async'); import { model as Challenge} from './challenge'; +import validator from 'validator'; import firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import Q from 'q'; @@ -41,7 +41,7 @@ export let schema = new Schema({ balance: {type: Number, default: 0}, logo: String, leaderMessage: String, - challenges: [{type: String, ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) + challenges: [{type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) quest: { key: String, active: {type: Boolean, default: false}, diff --git a/website/src/models/user.js b/website/src/models/user.js index 379763aeed..2ddfccc99b 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -206,11 +206,6 @@ export let schema = new Schema({ todos: Array, // [{data: Date, value: Number}] // big peformance issues if these are defined }, - invitations: { - guilds: {type: Array, default: []}, - party: Schema.Types.Mixed, // TODO dictionary - }, - // TODO we're storing too many fields here, find a way to reduce them items: { gear: { @@ -324,10 +319,21 @@ export let schema = new Schema({ lastCron: {type: Date, default: Date.now}, // {GROUP_ID: Boolean}, represents whether they have unseen chat messages - newMessages: {type: Schema.Types.Mixed, default: {}}, + newMessages: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, + + challenges: [{type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}], + + invitations: { + guilds: {type: Array}, // TODO what are we storing here + party: Schema.Types.Mixed, // TODO dictionary TODO what are we storing here? + }, + + guilds: [{type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}], party: { - // id // FIXME can we use a populated doc instead of fetching party separate from user? + _id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Group'}, order: {type: String, default: 'level'}, orderAscending: {type: String, default: 'ascending'}, quest: { @@ -440,7 +446,6 @@ export let schema = new Schema({ }, tags: [TagSchema], - challenges: [{type: String, ref: 'Challenge'}], inbox: { newMessages: {type: Number, default: 0}, @@ -470,7 +475,7 @@ export let schema = new Schema({ schema.plugin(baseModel, { // TODO revisit a lot of things are missing - noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats'], + noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function toJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? From ddf77e0e35e3f9f4a3c903ab73b13b86790cbd8f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 16:00:49 +0100 Subject: [PATCH 254/976] add cliff for completed todos and PMs --- common/script/api-v3/cron.js | 27 +++++++++++++++++++------- website/src/middlewares/api-v3/cron.js | 17 +++++++++++++--- website/src/models/user.js | 4 +++- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js index 1bc8f29927..132adafe32 100644 --- a/common/script/api-v3/cron.js +++ b/common/script/api-v3/cron.js @@ -1,6 +1,7 @@ import moment from 'moment'; import _ from 'lodash'; import scoreTask from './scoreTask'; +import preenUserHistory from './preenHistory'; import common from '../../'; import { shouldDo, @@ -20,7 +21,7 @@ let clearBuffs = { // Make sure to run this function once in a while as server will not take care of overnight calculations. // And you have to run it every time client connects. export default function cron (options = {}) { - let {user, tasks, tasksByType, analytics, now, daysMissed} = options; + let {user, tasksByType, analytics, now, daysMissed} = options; user.auth.timestamps.loggedin = now; user.lastCron = now; @@ -93,8 +94,7 @@ export default function cron (options = {}) { // Tally each task let todoTally = 0; - tasksByType.todos.forEach((task) => { // make uncompleted todos redder - let completed = task.completed; + tasksByType.todos.forEach(task => { // make uncompleted todos redder scoreTask({ task, user, @@ -104,8 +104,7 @@ export default function cron (options = {}) { // TODO pass req for analytics? }); - let absVal = completed ? Math.abs(task.value) : task.value; - todoTally += absVal; + todoTally += task.value; }); let dailyChecked = 0; // how many dailies were checked? @@ -184,7 +183,8 @@ export default function cron (options = {}) { }); // Finished tallying - user.history.todos({date: now, value: todoTally}); + user.history.todos.push({date: now, value: todoTally}); + // tally experience let expTally = user.stats.exp; let lvl = 0; // iterator @@ -197,7 +197,7 @@ export default function cron (options = {}) { // preen user history so that it doesn't become a performance problem // also for subscribed users but differentyly // premium subscribers can keep their full history. - user.fns.preenUserHistory(tasks); + preenUserHistory(user, tasksByType); if (perfect) { user.achievements.perfect++; @@ -231,6 +231,19 @@ export default function cron (options = {}) { _.merge(progress, {down: 0, up: 0}); progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + // Clean PMs - keep 200 for subscribers and 50 for free users + let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too + let numberOfPMs = Object.keys(user.inbox.messages).length; + if (Object.keys(user.inbox.messages).length > maxPMs) { + _(user.inbox.messages) + .sortBy('timestamp') + .takeRight(numberOfPMs - maxPMs) + .each(pm => { + user.inbox.messages[pm.id] = undefined; + }).value(); + + user.markModified('inbox.messages'); + } // Analytics user.flags.cronCount++; diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index d0c24d700d..0d6d46b900 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -1,4 +1,5 @@ import _ from 'lodash'; +import moment from 'moment'; import { daysSince, } from '../../../../common/script/cron'; @@ -15,7 +16,7 @@ export default function cronMiddleware (req, res, next) { let now = new Date(); let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences)); - if (daysMissed <= 0) return next(null, user); // TODO why are we passing user down here? + if (daysMissed <= 0) return next(); // Fetch active tasks (no completed todos) Task.find({ @@ -30,13 +31,23 @@ export default function cronMiddleware (req, res, next) { tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); // Run cron - cron({user, tasks, tasksByType, now, daysMissed, analytics}); + cron({user, tasksByType, now, daysMissed, analytics}); + + // Clean completed todos - 30 days for free users, 90 for subscribers + Task.remove({ + userId: user._id, + type: 'todo', + completed: true, + dateCompleted: { + $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), + }, + }).exec(); // TODO catch error or at least log it let ranCron = user.isModified(); let quest = common.content.quests[user.party.quest.key]; // if (ranCron) res.locals.wasModified = true; // TODO remove? - if (!ranCron) return next(null, user); // TODO why are we passing user to next? + if (!ranCron) return next(); // TODO Group.tavernBoss(user, progress); if (!quest || true /* TODO remove */) return user.save(next); diff --git a/website/src/models/user.js b/website/src/models/user.js index 2ddfccc99b..26b38b31d3 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -450,7 +450,9 @@ export let schema = new Schema({ inbox: { newMessages: {type: Number, default: 0}, blocks: {type: Array, default: []}, - messages: {type: Schema.Types.Mixed, default: {}}, + messages: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, optOut: {type: Boolean, default: false}, }, tasksOrder: { From e53bd5079a06a51a12afccfca0e755aaa3681944 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 16:29:57 +0100 Subject: [PATCH 255/976] adapt chat routes to groups saved on user model --- common/script/api-v3/cron.js | 1 + website/src/controllers/api-v3/chat.js | 21 +++++++++++++-------- website/src/models/group.js | 4 ++-- website/src/models/user.js | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js index 132adafe32..604b6254f4 100644 --- a/common/script/api-v3/cron.js +++ b/common/script/api-v3/cron.js @@ -232,6 +232,7 @@ export default function cron (options = {}) { progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); // Clean PMs - keep 200 for subscribers and 50 for free users + // TODO tests let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too let numberOfPMs = Object.keys(user.inbox.messages).length; if (Object.keys(user.inbox.messages).length > maxPMs) { diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index aa56b03f73..e94546ddac 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -30,16 +30,21 @@ api.getChat = { let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - let query = groupId === 'party' ? - Group.findOne({type: 'party', members: {$in: [user._id]}}) : - Group.findOne({$or: [ - {_id: groupId, privacy: 'public'}, - {_id: groupId, privacy: 'private', members: {$in: [user._id]}}, - ]}); + let query; - query.exec() - .then((group) => { + if (groupId === 'party' || user.party._id === groupId) { + query = {type: 'party', _id: user.party._id}; + } else if (user.guilds.indexOf(groupId)) { + query = {type: 'guild', _id: groupId}; + } else { + query = {type: 'guild', privacy: 'public', _id: groupId}; + } + + Group + .findOne(query, 'chat').exec() + .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); + res.respond(200, group.chat); }) .catch(next); diff --git a/website/src/models/group.js b/website/src/models/group.js index 9bf5313858..07f9ec9b16 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -127,7 +127,7 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -schema.methods.toJSON = function groupToJSON () { +/*schema.methods.toJSON = function groupToJSON () { let doc = this.toObject(); // removeDuplicates(doc); doc._isMember = this._isMember; // TODO ? @@ -143,7 +143,7 @@ schema.methods.toJSON = function groupToJSON () { this.challengeCount = _.size(this.challenges); return doc; -}; +};*/ // TODO move to its own model export function chatDefaults (msg, user) { diff --git a/website/src/models/user.js b/website/src/models/user.js index 26b38b31d3..40a9fbcaa0 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -479,7 +479,7 @@ schema.plugin(baseModel, { // TODO revisit a lot of things are missing noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations'], private: ['auth.local.hashed_password', 'auth.local.salt'], - toJSONTransform: function toJSON (doc) { + toJSONTransform: function userToJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? doc.filters = {}; doc._tmp = this._tmp; // be sure to send down drop notifs From 7adc06031259b237b09be3c95d115cc84c62f0f6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 16:32:37 +0100 Subject: [PATCH 256/976] add GET group --- website/src/controllers/api-v3/chat.js | 2 +- website/src/controllers/api-v3/groups.js | 54 ++++++++++++++++++++++++ website/src/models/group.js | 2 +- 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 website/src/controllers/api-v3/groups.js diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index e94546ddac..1cf68464d1 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -34,7 +34,7 @@ api.getChat = { if (groupId === 'party' || user.party._id === groupId) { query = {type: 'party', _id: user.party._id}; - } else if (user.guilds.indexOf(groupId)) { + } else if (user.guilds.indexOf(groupId) !== -1) { query = {type: 'guild', _id: groupId}; } else { query = {type: 'guild', privacy: 'public', _id: groupId}; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js new file mode 100644 index 0000000000..477ba2a101 --- /dev/null +++ b/website/src/controllers/api-v3/groups.js @@ -0,0 +1,54 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { model as Group } from '../../models/group'; +import { + NotFound, +} from '../../libs/api-v3/errors'; + +let api = {}; + +/** + * @api {get} /groups/:groupId Get group + * @apiVersion 3.0.0 + * @apiName GetGroup + * @apiGroup Group + * + * @apiParam {UUID} groupId The group _id + * + * @apiSuccess {Object} group The group object + */ +api.getGroup = { + method: 'GET', + url: '/groups/:groupId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + let query; + + if (groupId === 'party' || user.party._id === groupId) { + query = {type: 'party', _id: user.party._id}; + } else if (user.guilds.indexOf(groupId) !== -1) { + query = {type: 'guild', _id: groupId}; + } else { + query = {type: 'guild', privacy: 'public', _id: groupId}; + } + + Group + .findOne(query).exec() + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + res.respond(200, group); + }) + .catch(next); + }, +}; + +export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 07f9ec9b16..c28334dd17 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -127,7 +127,7 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -/*schema.methods.toJSON = function groupToJSON () { +/* schema.methods.toJSON = function groupToJSON () { let doc = this.toObject(); // removeDuplicates(doc); doc._isMember = this._isMember; // TODO ? From 6b430e68663076b036dc2993b76b5d3862b2bc79 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 16:38:10 +0100 Subject: [PATCH 257/976] add getGroup static method and refactor controllers to use it --- website/src/controllers/api-v3/chat.js | 14 +------------- website/src/controllers/api-v3/groups.js | 14 +------------- website/src/models/group.js | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 1cf68464d1..7a3346d60f 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -23,25 +23,13 @@ api.getChat = { middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; - let groupId = req.params.groupId; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - let query; - - if (groupId === 'party' || user.party._id === groupId) { - query = {type: 'party', _id: user.party._id}; - } else if (user.guilds.indexOf(groupId) !== -1) { - query = {type: 'guild', _id: groupId}; - } else { - query = {type: 'guild', privacy: 'public', _id: groupId}; - } - - Group - .findOne(query, 'chat').exec() + Group.getGroup(user, req.params.groupId, 'chat') .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 477ba2a101..7617f3ea99 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -23,25 +23,13 @@ api.getGroup = { middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; - let groupId = req.params.groupId; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - let query; - - if (groupId === 'party' || user.party._id === groupId) { - query = {type: 'party', _id: user.party._id}; - } else if (user.guilds.indexOf(groupId) !== -1) { - query = {type: 'guild', _id: groupId}; - } else { - query = {type: 'guild', privacy: 'public', _id: groupId}; - } - - Group - .findOne(query).exec() + Group.getGroup(user, req.params.groupId) .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); diff --git a/website/src/models/group.js b/website/src/models/group.js index c28334dd17..0fe32464d0 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -145,6 +145,20 @@ schema.post('remove', function postRemoveGroup (group) { return doc; };*/ +schema.statics.getGroup = function getGroup (user, groupId, fields) { + let query; + + if (groupId === 'party' || user.party._id === groupId) { + query = {type: 'party', _id: user.party._id}; + } else if (user.guilds.indexOf(groupId) !== -1) { + query = {type: 'guild', _id: groupId}; + } else { + query = {type: 'guild', privacy: 'public', _id: groupId}; + } + + return this.findOne(query, fields).exec(); // TODO catch errors here? +}; + // TODO move to its own model export function chatDefaults (msg, user) { let message = { From 1132e3971dece77a9aa4423bbd30cbfc595fc042 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 17:26:49 +0100 Subject: [PATCH 258/976] initial implementation for GET groups --- common/locales/en/api-v3.json | 3 +- website/src/controllers/api-v3/groups.js | 71 +++++++++++++++++++++++- website/src/models/group.js | 1 + 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index d223c66961..206a0bcd35 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -29,5 +29,6 @@ "directionUpDown": "\"direction\" is required and must be 'up' or 'down'", "alreadyTagged": "The task is already tagged with given tag.", "groupIdRequired": "\"groupId\" must be a valid UUID", - "groupNotFound": "Group not found." + "groupNotFound": "Group not found.", + "groupTypesRequired": "You must supply a valid \"type\" query string." } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 7617f3ea99..7074168198 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -1,12 +1,81 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import Q from 'q'; +import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Group } from '../../models/group'; import { NotFound, + BadRequest, } from '../../libs/api-v3/errors'; let api = {}; +/** + * @api {get} /groups Get groups + * @apiVersion 3.0.0 + * @apiName GetGroups + * @apiGroup Group + * + * @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, privateGuilds, publicGuilds, tavern + * + * @apiSuccess {Array} groups An array of the requested groups + */ +api.getGroups = { + method: 'GET', + url: '/groups', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkQuery('type', res.t('groupTypesRequired')).notEmpty(); // TODO better validation + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + // TODO validate types are acceptable? probably not necessary + let types = req.query.type.split(','); + let groupFields = 'name description memberCount balance leader'; + let sort = '-memberCount'; + let queries = []; + + types.forEach(type => { + switch (type) { + case 'party': + queries.push(Group.getGroup(user, 'party', groupFields)); + break; + case 'privateGuilds': + queries.push(Group.find({ + type: 'guild', + privacy: 'private', + _id: {$in: user.guilds}, + }).select(groupFields).sort(sort).exec()); // TODO isMember + break; + case 'publicGuilds': + queries.push(Group.find({ + type: 'guild', + privacy: 'public', + }).select(groupFields).sort(sort).exec()); // TODO use lean? isMember + break; + case 'tavern': + queries.push(Group.getGroup(user, 'habitrpg', groupFields)); + break; + } + }); + + // If no valid value for type was supplied, return an error + if (queries.length === 0) return next(new BadRequest(res.t('groupTypesRequired'))); + + Q.all(queries) // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 + .then(results => { + res.respond(200, _.reduce(results, (m, v) => { + if (_.isEmpty(v)) return m; + return m.concat(Array.isArray(v) ? v : [v]); + }, [])); + }) + .catch(next); + }, +}; + /** * @api {get} /groups/:groupId Get group * @apiVersion 3.0.0 @@ -29,7 +98,7 @@ api.getGroup = { let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - Group.getGroup(user, req.params.groupId) + Group.getGroup(user, req.params.groupId, true) .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); diff --git a/website/src/models/group.js b/website/src/models/group.js index 0fe32464d0..e6d125fd89 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -145,6 +145,7 @@ schema.post('remove', function postRemoveGroup (group) { return doc; };*/ +// TODO populate, isMember? schema.statics.getGroup = function getGroup (user, groupId, fields) { let query; From c4ea3efb2eb793a6d81e858ac7be3e61cc307aad Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 17:37:26 +0100 Subject: [PATCH 259/976] initial implementation for POST group --- website/src/controllers/api-v3/groups.js | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 7074168198..ad12f9e7cc 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -6,10 +6,56 @@ import { model as Group } from '../../models/group'; import { NotFound, BadRequest, + NotAuthorized, } from '../../libs/api-v3/errors'; +import firebase from '../../libs/api-v3/firebase'; let api = {}; +/** + * @api {post} /groups Create group + * @apiVersion 3.0.0 + * @apiName CreateGroup + * @apiGroup Group + * + * @apiSuccess {Object} group The group object + */ +api.createGroup = { + method: 'POST', + url: '/groups', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let group = new Group(req.body); // TODO validate empty body + + group.leader = user._id; + + if (group.type === 'guild') { + if (user.balance < 1) return next(new NotAuthorized(res.t('messageInsufficientGems'))); + + group.balance = 1; + user.balance--; + + user.guilds.push(group._id); + } else { + if (user.party._id) return next(new NotAuthorized(res.t('messageGroupAlreadyInParty'))); + user.party._id = group._id; + } + + Q.all([ + user.save(), + group.save(), + ]).then(results => { + let savedGroup = results[1]; + + firebase.updateGroupData(savedGroup); + firebase.addUserToGroup(savedGroup._id, user._id); + return res.respond(201, savedGroup); // TODO populate + }) + .catch(next); + }, +}; + /** * @api {get} /groups Get groups * @apiVersion 3.0.0 From d8fae3a067e0a8c50825e5b6f2e5981a5f97d539 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 17:45:56 +0100 Subject: [PATCH 260/976] add sanitization to group creation --- website/src/controllers/api-v3/groups.js | 5 +++-- website/src/models/group.js | 11 +++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index ad12f9e7cc..25826d2d04 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -26,19 +26,20 @@ api.createGroup = { middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; - let group = new Group(req.body); // TODO validate empty body + let group = new Group(Group.sanitize(req.body)); // TODO validate empty req.body group.leader = user._id; if (group.type === 'guild') { if (user.balance < 1) return next(new NotAuthorized(res.t('messageInsufficientGems'))); group.balance = 1; - user.balance--; + user.balance--; user.guilds.push(group._id); } else { if (user.party._id) return next(new NotAuthorized(res.t('messageGroupAlreadyInParty'))); + user.party._id = group._id; } diff --git a/website/src/models/group.js b/website/src/models/group.js index e6d125fd89..6e3dec592d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -15,9 +15,7 @@ let Schema = mongoose.Schema; export let schema = new Schema({ name: {type: String, required: true}, description: String, - leader: {type: String, ref: 'User'}, - members: [{type: String, ref: 'User'}], // TODO do we need this? could depend on back-ref instead (User.find({group:GID}) - invites: [{type: String, ref: 'User'}], // TODO do we need this? could depend on back-ref instead (User.find({group:GID}) + leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, type: {type: String, enum: ['guild', 'party'], required: true}, privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true}, // _v: {type: Number,'default': 0}, // TODO ? @@ -41,7 +39,7 @@ export let schema = new Schema({ balance: {type: Number, default: 0}, logo: String, leaderMessage: String, - challenges: [{type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) + // challenges: [{type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) quest: { key: String, active: {type: Boolean, default: false}, @@ -57,6 +55,7 @@ export let schema = new Schema({ // Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click // 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. // TODO when booting user, remove from .joined and check again if we can now start the quest + // TODO as long as quests are party only we can keep it here members: {type: Schema.Types.Mixed, default: () => { return {}; }}, @@ -70,7 +69,7 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - noSet: ['_id'], + noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount'], }); // TODO migration @@ -145,7 +144,7 @@ schema.post('remove', function postRemoveGroup (group) { return doc; };*/ -// TODO populate, isMember? +// TODO populate (invites too), isMember? schema.statics.getGroup = function getGroup (user, groupId, fields) { let query; From 33525b105f7f55ad6a25dab191a01ad7a9ddcdeb Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 18 Dec 2015 23:43:57 -0600 Subject: [PATCH 261/976] Added initial group POST tests --- .../v3/integration/groups/POST-groups.test.js | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 test/api/v3/integration/groups/POST-groups.test.js diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js new file mode 100644 index 0000000000..6d1aec25d5 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -0,0 +1,130 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /group', () => { + let user, api; + + beforeEach(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + context('Guilds', () => { + it('returns an error when a user with insufficient funds attempts to create a guild', () => { + let groupName = "Test Public Guild"; + let groupType = "guild"; + + return expect( + api.post('/groups', { + name: groupName, + type: groupType + }) + ) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageInsufficientGems'), + }); + }); + + context('public guild', () => { + it('creates a group', () => { + let groupName = "Test Public Guild"; + let groupType = "guild"; + let tmpUser; + + return generateUser({balance: 1}).then((generatedUser) => { + tmpUser = generatedUser; + api = requester(tmpUser); + return api.post('/groups', { + name: groupName, + type: groupType + }); + }) + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); + }) + .then(() => { + api = requester(user); + }); + }); + }); + + context('private guild', () => { + it('creates a group', () => { + let groupName = "Test Private Guild"; + let groupType = "guild"; + let groupPrivacy = "private"; + let tmpUser; + + return generateUser({balance: 1}).then((generatedUser) => { + tmpUser = generatedUser; + api = requester(tmpUser); + return api.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy + }); + }) + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); + expect(result.privacy).to.equal(groupPrivacy); + }) + .then(() => { + api = requester(tmpUser); + }); + }); + }); + }); + + context('Parties', () => { + it('creates a party', () => { + let groupName = "Test Party"; + let groupType = "party"; + + return api.post('/groups', { + name: groupName, + type: groupType + }) + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); + }) + }); + + it('prevents user in a party from creating a party', () => { + let tmpUser; + let groupName = "Test Party"; + let groupType = "party"; + + return generateUser().then((generatedUser) => { + tmpUser = generatedUser; + api = requester(tmpUser); + return api.post('/groups', { + name: groupName, + type: groupType + }); + }) + .then(() => { + return expect(api.post('/groups')).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupAlreadyInParty'), + }); + }) + .then(() => { + api = requester(user); + }); + }); + }); +}); From 3e87c8d3e9cdc7580f80b8b2157352cd3f927605 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 19 Dec 2015 18:07:39 +0100 Subject: [PATCH 262/976] fix some tests --- test/api/v3/integration/chat/GET-chat.test.js | 9 +- .../v3/integration/groups/POST-groups.test.js | 185 +++++++++--------- test/helpers/api-integration.helper.js | 6 +- website/src/controllers/api-v3/groups.js | 3 +- website/src/models/group.js | 2 +- website/src/models/user.js | 2 +- 6 files changed, 101 insertions(+), 106 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 75b5a36d15..f1fe89c5d6 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -1,6 +1,7 @@ import { createAndPopulateGroup, generateUser, + generateGroup, requester, } from '../../../../helpers/api-integration.helper'; @@ -8,8 +9,9 @@ describe('GET /groups/:groupId/chat', () => { let user, api; before(() => { - return generateUser().then((generatedUser) => { + return generateUser({balance: 2}).then((generatedUser) => { user = generatedUser; + console.log(user._id, user.balance) api = requester(user); }); }); @@ -18,14 +20,15 @@ describe('GET /groups/:groupId/chat', () => { let group; before(() => { - return createAndPopulateGroup({groupDetails: { + return generateGroup(user, { + name: 'test group', type: 'guild', privacy: 'public', chat: [ 'Hello', 'Welcome to the Guild', ], - }}) + }) .then((createdGroup) => { group = createdGroup; }); diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 6d1aec25d5..85269fe6b7 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -7,17 +7,17 @@ import { describe('POST /group', () => { let user, api; - beforeEach(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - api = requester(user); - }); - }); + beforeEach(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); - context('Guilds', () => { + context('Guilds', () => { it('returns an error when a user with insufficient funds attempts to create a guild', () => { - let groupName = "Test Public Guild"; - let groupType = "guild"; + let groupName = 'Test Public Guild'; + let groupType = 'guild'; return expect( api.post('/groups', { @@ -32,99 +32,90 @@ describe('POST /group', () => { }); }); - context('public guild', () => { - it('creates a group', () => { - let groupName = "Test Public Guild"; - let groupType = "guild"; - let tmpUser; + context('public guild', () => { + it('creates a group', () => { + let groupName = 'Test Public Guild'; + let groupType = 'guild'; - return generateUser({balance: 1}).then((generatedUser) => { - tmpUser = generatedUser; - api = requester(tmpUser); - return api.post('/groups', { - name: groupName, - type: groupType - }); - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - }) - .then(() => { - api = requester(user); - }); - }); - }); - - context('private guild', () => { - it('creates a group', () => { - let groupName = "Test Private Guild"; - let groupType = "guild"; - let groupPrivacy = "private"; - let tmpUser; - - return generateUser({balance: 1}).then((generatedUser) => { - tmpUser = generatedUser; - api = requester(tmpUser); - return api.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy - }); - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - expect(result.privacy).to.equal(groupPrivacy); - }) - .then(() => { - api = requester(tmpUser); - }); - }); - }); - }); - - context('Parties', () => { - it('creates a party', () => { - let groupName = "Test Party"; - let groupType = "party"; - - return api.post('/groups', { - name: groupName, - type: groupType - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - }) - }); - - it('prevents user in a party from creating a party', () => { - let tmpUser; - let groupName = "Test Party"; - let groupType = "party"; - - return generateUser().then((generatedUser) => { - tmpUser = generatedUser; - api = requester(tmpUser); - return api.post('/groups', { + return generateUser({balance: 1}).then((generatedUser) => { + let api2 = requester(generatedUser); + return api2.post('/groups', { name: groupName, type: groupType }); }) - .then(() => { - return expect(api.post('/groups')).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupAlreadyInParty'), - }); - }) - .then(() => { - api = requester(user); + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); }); }); - }); + }); + + context('private guild', () => { + it('creates a group', () => { + let groupName = 'Test Private Guild'; + let groupType = 'guild'; + let groupPrivacy = 'private'; + let tmpUser; + + return generateUser({balance: 1}).then((generatedUser) => { + let api2 = requester(generatedUser); + return api2.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy + }); + }) + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); + expect(result.privacy).to.equal(groupPrivacy); + }); + }); + }); + }); + + context('Parties', () => { + it('creates a party', () => { + let groupName = "Test Party"; + let groupType = "party"; + + return api.post('/groups', { + name: groupName, + type: groupType + }) + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); + }) + }); + + it('prevents user in a party from creating a party', () => { + let tmpUser; + let groupName = "Test Party"; + let groupType = "party"; + + return generateUser().then((generatedUser) => { + tmpUser = generatedUser; + api = requester(tmpUser); + return api.post('/groups', { + name: groupName, + type: groupType + }); + }) + .then(() => { + return expect(api.post('/groups')).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupAlreadyInParty'), + }); + }) + .then(() => { + api = requester(user); + }); + }); + }); }); diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 973414a3a8..9b865ccb85 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -103,15 +103,15 @@ export function generateUser (update = {}) { // Generates a new group. Requires a user object, which // will will become the groups leader. Takes an update // argument which will update group -export function generateGroup (leader, update = {}) { +export function generateGroup (leader, details = {}, update = {}) { let request = _requestMaker(leader, 'post'); return new Promise((resolve, reject) => { - request('/groups').then((group) => { + request('/groups', details).then((group) => { _updateDocument('groups', group, update, () => { resolve(group); }).catch(reject); - }); + }).catch(reject); }); } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 25826d2d04..c431db872f 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -8,7 +8,7 @@ import { BadRequest, NotAuthorized, } from '../../libs/api-v3/errors'; -import firebase from '../../libs/api-v3/firebase'; +import * as firebase from '../../libs/api-v3/firebase'; let api = {}; @@ -31,6 +31,7 @@ api.createGroup = { group.leader = user._id; if (group.type === 'guild') { + console.log(user._id, user.balance) if (user.balance < 1) return next(new NotAuthorized(res.t('messageInsufficientGems'))); group.balance = 1; diff --git a/website/src/models/group.js b/website/src/models/group.js index 6e3dec592d..54c54b473a 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -4,7 +4,7 @@ import shared from '../../../common'; import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; -import firebase from '../libs/api-v2/firebase'; +import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import Q from 'q'; diff --git a/website/src/models/user.js b/website/src/models/user.js index 40a9fbcaa0..54da99fb5c 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -477,7 +477,7 @@ export let schema = new Schema({ schema.plugin(baseModel, { // TODO revisit a lot of things are missing - noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations'], + noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations', 'balance'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? From 8b79f74e54e32ff7d64740d708d1a6a79ff9c317 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 19 Dec 2015 18:23:13 +0100 Subject: [PATCH 263/976] fix tests --- test/api/v3/integration/chat/GET-chat.test.js | 4 +++- test/helpers/api-integration.helper.js | 2 +- website/src/controllers/api-v3/groups.js | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index f1fe89c5d6..206eac6f75 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -11,7 +11,6 @@ describe('GET /groups/:groupId/chat', () => { before(() => { return generateUser({balance: 2}).then((generatedUser) => { user = generatedUser; - console.log(user._id, user.balance) api = requester(user); }); }); @@ -24,6 +23,7 @@ describe('GET /groups/:groupId/chat', () => { name: 'test group', type: 'guild', privacy: 'public', + }, { chat: [ 'Hello', 'Welcome to the Guild', @@ -40,5 +40,7 @@ describe('GET /groups/:groupId/chat', () => { expect(getChat).to.eql(group.chat); }); }); + + // TODO tests that you can only access your groups' chat }); }); diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 9b865ccb85..81718c36c8 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -110,7 +110,7 @@ export function generateGroup (leader, details = {}, update = {}) { request('/groups', details).then((group) => { _updateDocument('groups', group, update, () => { resolve(group); - }).catch(reject); + }); }).catch(reject); }); } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index c431db872f..8843b7aff7 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -31,7 +31,6 @@ api.createGroup = { group.leader = user._id; if (group.type === 'guild') { - console.log(user._id, user.balance) if (user.balance < 1) return next(new NotAuthorized(res.t('messageInsufficientGems'))); group.balance = 1; From 83364c1d56ccfac61805539be5048a088e0d921b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 21 Dec 2015 11:53:47 +0100 Subject: [PATCH 264/976] add leaveGroup, joinGroup and remove member from group methods --- common/locales/en/api-v3.json | 8 +- website/src/controllers/api-v3/chat.js | 2 +- website/src/controllers/api-v3/groups.js | 209 ++++++++++++++++++++++- website/src/models/group.js | 5 +- 4 files changed, 219 insertions(+), 5 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 206a0bcd35..39c0f96bb3 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -30,5 +30,11 @@ "alreadyTagged": "The task is already tagged with given tag.", "groupIdRequired": "\"groupId\" must be a valid UUID", "groupNotFound": "Group not found.", - "groupTypesRequired": "You must supply a valid \"type\" query string." + "groupTypesRequired": "You must supply a valid \"type\" query string.", + "questLeaderCannotLeaveGroup": "You cannot leave your party when you have started a quest. Abort the quest first.", + "cannotLeaveWhileActiveQuest": "You cannot leave party during an active quest. Please leave the quest first.", + "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", + "memberCannotRemoveYourself": "You cannot remove yourself!", + "groupMemberNotFound": "User not found among group's members", + "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"" } diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 7a3346d60f..f0025d6e53 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -13,7 +13,7 @@ let api = {}; * @apiName GetChat * @apiGroup Chat * - * @apiParam {UUID} groupId The group _id + * @apiParam {string} groupId The group _id (or 'party') * * @apiSuccess {Array} chat An array of chat messages */ diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8843b7aff7..a54e501a36 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -3,12 +3,14 @@ import Q from 'q'; import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Group } from '../../models/group'; +import { model as User } from '../../models/user'; import { NotFound, BadRequest, NotAuthorized, } from '../../libs/api-v3/errors'; import * as firebase from '../../libs/api-v3/firebase'; +import txnEmail from '../../libs/api-v3/email'; let api = {}; @@ -129,7 +131,7 @@ api.getGroups = { * @apiName GetGroup * @apiGroup Group * - * @apiParam {UUID} groupId The group _id + * @apiParam {string} groupId The group _id (or 'party') * * @apiSuccess {Object} group The group object */ @@ -145,7 +147,7 @@ api.getGroup = { let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - Group.getGroup(user, req.params.groupId, true) + Group.getGroup(user, req.params.groupId) .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); @@ -155,4 +157,207 @@ api.getGroup = { }, }; +/** + * @api {post} /groups/:groupId/join Join a group + * @apiVersion 3.0.0 + * @apiName JoinGroup + * @apiGroup Group + * + * @apiParam {UUID} groupId The group _id + * + * @apiSuccess {Object} empty An empty object + */ +api.joinGroup = { + method: 'POST', + url: '/groups/:groupId/join', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + let isUserInvited = false; + + if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { + user.invitations.party = undefined; // Clear invite + + // invite new user to pending quest + if (group.quest.key && !group.quest.active) { + user.party.quest.RSVPNeeded = true; + user.party.quest.key = group.quest.key; + group.quest.members[user._id] = undefined; + group.markModified('quest.members'); + } + + user.party._id = group._id; // Set group as user's party + + isUserInvited = true; + } else if (group.type === 'guild' && user.invitations.guilds) { + let i = _.findIndex(user.invitations.guilds, {id: group._id}); + + if (i !== -1) { + isUserInvited = true; + user.invitations.guilds.splice(i, 1); // Remove invitation + } else { + isUserInvited = group.privacy === 'private' ? false : true; + } + } + + if (isUserInvited && group.type === 'guild') user.guilds.push(group._id); // Add group to user's guilds + if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite')); + + if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader + + Q.all([ + group.save(), + user.save(), + User.update({_id: user.invitations.party.inviter}, {$inc: {'items.quests.basilist': 1}}).exec(), // Reward inviter + ]).then(() => { + firebase.addUserToGroup(group._id, user._id); + res.respond(200, {}); // TODO what to return? + }); + }) + .catch(next); + }, +}; + +/** + * @api {post} /groups/:groupId/leave Leave a group + * @apiVersion 3.0.0 + * @apiName LeaveGroup + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string="remove-all","keep-all"} keep Wheter to keep or not challenges' tasks, as an optional query string + * + * @apiSuccess {Object} empty An empty object + */ +api.leaveGroup = { + method: 'POST', + url: '/groups/:groupId/leave', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + // When removing the user from challenges, should we keep the tasks? + req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']); + + Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + // During quests, checke wheter user can leave + if (group.type === 'party') { + if (group.quest && group.quest.leader === user._id) { + throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup')); + } + + if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) { + throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest')); + } + } + + return group.leave(user, req.query.keep); + }) + .then(() => res.respond(200, {})) + .catch(next); + }, +}; + +// Send an email to the removed user with an optional message from the leader +function _sendMessageToRemoved (group, removedUser, message) { + if (removedUser.preferences.emailNotifications.kickedGroup !== false) { + txnEmail(removedUser, `kicked-from-${group.type}`, [ + {name: 'GROUP_NAME', content: group.name}, + {name: 'MESSAGE', content: message}, + {name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'}, + {name: 'PARTY_WANTED_GUILD', content: '/#/options/groups/guilds/f2db2a7f-13c5-454d-b3ee-ea1f5089e601'}, + ]); + } +} + +/** + * @api {post} /groups/:groupId/removeMember/:memberId Remove a member from a group + * @apiVersion 3.0.0 + * @apiName RemoveGroupMember + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {UUID} memberId The _id of the member to remove + * @apiParam {string} message The message to send to the removed members, as a query string // TODO in req.body? + * + * @apiSuccess {Object} empty An empty object + */ +api.removeGroupMember = { + method: 'POST', + url: '/groups/:groupId/removeMember/:memberId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let group; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('groupId', res.t('userIdRequired')).notEmpty().isUUID(); + + Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat + .then(foundGroup => { + group = foundGroup; + let uuid = req.query.memberId; + + if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); + if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself')); + + return User.findOne({_id: uuid}).select('party guilds invitations newMessages').exec(); + }).then(member => { + // We're removing the user from a guild or a party? is the user invited only? + let isInGroup = member.party._id === group._id ? 'party' : member.guilds.indexOf(group._id) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary + let isInvited = member.invitations.party._id === group._id ? 'party' : member.invitations.guilds.indexOf(group._id) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary + + if (isInGroup) { + group.memberCount -= 1; + + if (group.quest && group.quest.leader === member._id) { + group.quest.key = null; + group.quest.leader = null; // TODO markmodified? + } else if (group.quest && group.quest.members) { + // remove member from quest + group.quest.members[member._id] = undefined; + } + + if (isInGroup === 'guild') _.pull(member.guilds, group._id); + if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? + + member.newMessages.group._id = undefined; + + if (group.quest && group.quest.active && group.quest.leader === member._id) { + user.items.quests[group.quest.key] += 1; // TODO why this? + } + } if (isInvited) { + if (isInvited === 'guild') _.pull(user.invitations.guilds, group._id); + if (isInvited === 'party') user.invitations.party._id = undefined; // TODO remove quest information too? + } else { + throw new NotFound(res.t('groupMemberNotFound')); + } + + let message = req.query.message; + if (message) _sendMessageToRemoved(group, member, message); + + return Q.all([ + member.save(), + group.save(), + ]); + }) + .then(() => res.respond(200, {})) + .catch(next); + }, +}; + export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 54c54b473a..fa59f61aec 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -156,7 +156,10 @@ schema.statics.getGroup = function getGroup (user, groupId, fields) { query = {type: 'guild', privacy: 'public', _id: groupId}; } - return this.findOne(query, fields).exec(); // TODO catch errors here? + return this + .findOne(query) + .select(fields) + .exec(); // TODO catch errors here? }; // TODO move to its own model From 99be74b6f72ebb647f2bc67f82d311c4f216b215 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 21 Dec 2015 12:20:10 +0100 Subject: [PATCH 265/976] misc fixes --- website/src/controllers/api-v3/auth.js | 1 + website/src/controllers/api-v3/groups.js | 15 +++++++++------ website/src/models/group.js | 23 +++++++++++++++++------ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 2c7e00cadc..b0ab6b0674 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -259,6 +259,7 @@ api.deleteSocial = { if (!user.auth.local.username) return next(new NotAuthorized(res.t('cantDetachFb'))); // TODO move to model validation? User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}) + .exec() .then(() => res.respond(200)) .catch(next); }, diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index a54e501a36..1c948a5912 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -186,7 +186,7 @@ api.joinGroup = { let isUserInvited = false; if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { - user.invitations.party = undefined; // Clear invite + user.invitations.party = {}; // Clear invite TODO mark modified? // invite new user to pending quest if (group.quest.key && !group.quest.active) { @@ -319,7 +319,7 @@ api.removeGroupMember = { }).then(member => { // We're removing the user from a guild or a party? is the user invited only? let isInGroup = member.party._id === group._id ? 'party' : member.guilds.indexOf(group._id) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary - let isInvited = member.invitations.party._id === group._id ? 'party' : member.invitations.guilds.indexOf(group._id) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary + let isInvited = member.invitations.party.id === group._id ? 'party' : _.findIndex(member.invitations.guilds, {id: group._id}) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary if (isInGroup) { group.memberCount -= 1; @@ -338,11 +338,14 @@ api.removeGroupMember = { member.newMessages.group._id = undefined; if (group.quest && group.quest.active && group.quest.leader === member._id) { - user.items.quests[group.quest.key] += 1; // TODO why this? + member.items.quests[group.quest.key] += 1; // TODO why this? } - } if (isInvited) { - if (isInvited === 'guild') _.pull(user.invitations.guilds, group._id); - if (isInvited === 'party') user.invitations.party._id = undefined; // TODO remove quest information too? + } else if (isInvited) { + if (isInvited === 'guild') { + let i = _.findIndex(member.invitations.guilds, {id: group._id}); + if (i !== -1) member.invitations.guilds.splice(i, 1); + } + if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified? } else { throw new NotFound(res.t('groupMemberNotFound')); } diff --git a/website/src/models/group.js b/website/src/models/group.js index fa59f61aec..8687df181d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -97,6 +97,7 @@ schema.pre('save', function preSaveGroup (next) { return next(); }); +// TODO test schema.pre('remove', true, function preRemoveGroup (next, done) { next(); let group = this; @@ -104,18 +105,19 @@ schema.pre('remove', true, function preRemoveGroup (next, done) { // Remove invitations when group is deleted // TODO verify it works fir everything User.find({ - // TODO remove need for guilds s in migration? same for id -> _id + // TODO id -> _id ? [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id, }).exec() .then(users => { return Q.all(users.map(user => { if (group.type === 'party') { - user.invitations.party = {}; + user.invitations.party = {}; // TODO mark modified } else { let i = _.findIndex(user.invitations.guilds, {id: group._id}); user.invitations.guilds.splice(i, 1); } - return user.save(); // TODO update? + + return user.save(); })); }) .then(done) @@ -489,8 +491,14 @@ schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { group.type === 'guild' && group.privacy === 'private' )) return group.remove(); - // otherwise just remove a member - let update = {$pull: {members: user._id}}; + let update = {}; + // otherwise just remove a member TODO create User.methods.removeFromGroup? + if (group.type === 'guild') { + _.pull(user.guilds, group._id); + } else { + user.party._id = undefined; + } + // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) let leader = group.leader; @@ -502,7 +510,10 @@ schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { } update.$inc = {memberCount: -1}; - return model.update({_id: group._id}, update); // eslint-disable-line no-use-before-define + return Q.all([ + model.update({_id: group._id}, update).exec(), // eslint-disable-line no-use-before-define + user.save(), + ]); })(), ]).then(() => { firebase.removeUserFromGroup(group._id, user._id); From 67da5a977aed2a1c6996faf58867a11c3aa07ea7 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Mon, 21 Dec 2015 17:33:16 -0500 Subject: [PATCH 266/976] test(chat): user not member of group Currently failing because group is not getting defined correctly? --- test/api/v3/integration/chat/GET-chat.test.js | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 206eac6f75..265b80da54 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -1,8 +1,8 @@ import { - createAndPopulateGroup, generateUser, generateGroup, requester, + translate as t, } from '../../../../helpers/api-integration.helper'; describe('GET /groups/:groupId/chat', () => { @@ -19,15 +19,18 @@ describe('GET /groups/:groupId/chat', () => { let group; before(() => { - return generateGroup(user, { - name: 'test group', - type: 'guild', - privacy: 'public', - }, { - chat: [ - 'Hello', - 'Welcome to the Guild', - ], + return generateUser({balance: 2}) + .then((generatedLeader) => { + generateGroup(generatedLeader, { + name: 'test group', + type: 'guild', + privacy: 'public', + }, { + chat: [ + 'Hello', + 'Welcome to the Guild', + ], + }); }) .then((createdGroup) => { group = createdGroup; @@ -40,7 +43,36 @@ describe('GET /groups/:groupId/chat', () => { expect(getChat).to.eql(group.chat); }); }); + }); - // TODO tests that you can only access your groups' chat + context('private Guild', () => { + let group; + + before(() => { + return generateGroup(user, { + name: 'test group', + type: 'guild', + privacy: 'private', + }, { + chat: [ + 'Hello', + 'Welcome to the Guild', + ], + }) + .then((createdGroup) => { + group = createdGroup; + }); + }); + + it('returns error if user is not member of requested private group', () => { + return expect( + api.get('/groups/' + group._id + '/chat') + ) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); }); }); From a48ece7a343feff687b05980da48fc591aa5f10a Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Mon, 21 Dec 2015 18:45:24 -0500 Subject: [PATCH 267/976] fix(test): return functions --- test/api/v3/integration/chat/GET-chat.test.js | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 265b80da54..e6f2aeeb8a 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -5,11 +5,11 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; -describe('GET /groups/:groupId/chat', () => { +describe.only('GET /groups/:groupId/chat', () => { let user, api; before(() => { - return generateUser({balance: 2}).then((generatedUser) => { + return generateUser().then((generatedUser) => { user = generatedUser; api = requester(user); }); @@ -21,7 +21,7 @@ describe('GET /groups/:groupId/chat', () => { before(() => { return generateUser({balance: 2}) .then((generatedLeader) => { - generateGroup(generatedLeader, { + return generateGroup(generatedLeader, { name: 'test group', type: 'guild', privacy: 'public', @@ -49,15 +49,18 @@ describe('GET /groups/:groupId/chat', () => { let group; before(() => { - return generateGroup(user, { - name: 'test group', - type: 'guild', - privacy: 'private', - }, { - chat: [ - 'Hello', - 'Welcome to the Guild', - ], + return generateUser({balance: 2}) + .then((generatedLeader) => { + return generateGroup(generatedLeader, { + name: 'test group', + type: 'guild', + privacy: 'private', + }, { + chat: [ + 'Hello', + 'Welcome to the Guild', + ], + }); }) .then((createdGroup) => { group = createdGroup; From 77414ca49a416da1731f01135868b32cc636c5f8 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 22 Dec 2015 08:48:47 -0600 Subject: [PATCH 268/976] Added chat post route and initial tests --- .../api/v3/integration/chat/POST-chat.test.js | 98 +++++++++++++++++++ website/src/controllers/api-v3/chat.js | 56 +++++++++++ 2 files changed, 154 insertions(+) create mode 100644 test/api/v3/integration/chat/POST-chat.test.js diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js new file mode 100644 index 0000000000..f427ac460d --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -0,0 +1,98 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /chat', () => { + let user; + let api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('Returns an error when no message is provided', () => { + let groupName = 'Test Guild'; + let groupType = 'guild'; + let groupPrivacy = 'public'; + let testMessage = ''; + let api2; + + return generateUser({balance: 1}).then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + }) + .then((group) => { + return expect(api.post(`/groups/${group._id}/chat`, { message: testMessage})) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + }); + + it('Returns an error when group is not found', () => { + let testMessage = 'Test Message'; + return expect(api.post('/groups/nvalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('Returns an error when chat privileges are revoked', () => { + let groupName = 'Test Guild'; + let groupType = 'guild'; + let groupPrivacy = 'public'; + let testMessage = 'Test Message'; + let api2; + + return generateUser({balance: 1, 'flags.chatRevoked': true}).then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + }) + .then((group) => { + return expect(api2.post(`/groups/${group._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Your chat privileges have been revoked.', + }); + }); + }); + + it('creates a chat', () => { + let groupName = 'Test Guild'; + let groupType = 'guild'; + let groupPrivacy = 'public'; + let testMessage = 'Test Message'; + let api2; + + return generateUser({balance: 1}).then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + }) + .then((group) => { + return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + expect(result.message.id).to.exist; + }); + }); +}); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index f0025d6e53..d3a22a1fc8 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -39,4 +39,60 @@ api.getChat = { }, }; +/** + * @api {post} /groups/:groupId/chat Post chat message to a group + * @apiVersion 3.0.0 + * @apiName PostCat + * @apiGroup Chat + * + * @apiParam {UUID} groupId The group _id + * @apiParam {message} message The chat's message + * @apiParam {previousMsg} previousMsg The previous chat message which will force a return of the full group chat + * + * @apiSuccess {Array} chat An array of chat messages + */ +api.postChat = { + method: 'POST', + url: '/groups/:groupId/chat', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let groupId = req.params.groupId; + let chatUpdated; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkBody('message', res.t('messageGroupChatBlankMessage')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, groupId) + .then((group) => { + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party' && user.flags.chatRevoked) { + throw new NotFound('Your chat privileges have been revoked.'); + } + + let lastClientMsg = req.query.previousMsg; + chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; + + group.sendChat(req.body.message, user); + + if (group.type === 'party') { + user.party.lastMessageSeen = group.chat[0].id; + user.save(); + } + return group.save(); + }) + .then((group) => { + if (chatUpdated) { + res.respond(200, {chat: group.chat}); + } else { + res.respond(200, {message: group.chat[0]}); + } + }) + .catch(next); + }, +}; + export default api; From dff464489a18581b7eb916a317255ecccffe3e27 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 24 Dec 2015 18:52:14 -0600 Subject: [PATCH 269/976] Added chat like route and tests --- .../integration/chat/POST-chat.like.test.js | 68 +++++++++++++++++++ website/src/controllers/api-v3/chat.js | 54 +++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 test/api/v3/integration/chat/POST-chat.like.test.js diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js new file mode 100644 index 0000000000..b5f70d6ba4 --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -0,0 +1,68 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /chat', () => { + let user; + let api; + let group; + let testMessage = 'Test Message'; + + before(() => { + let groupName = 'Test Guild'; + let groupType = 'guild'; + let groupPrivacy = 'public'; + + return generateUser({balance: 1}).then((generatedUser) => { + user = generatedUser; + api = requester(user); + }) + .then(() => { + return api.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + }) + .then((generatedGroup) => { + group = generatedGroup; + }); + }); + + it('Returns an error when chat message is not found', () => { + return expect(api.post(`/groups/${group._id}/chat/incorrectMessage/like`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('Returns an error when user tries to like their own message', () => { + return api.post(`/groups/${group._id}/chat`, { message: testMessage}) + .then((result) => { + return expect(api.post(`/groups/${group._id}/chat/${result.message.id}/like`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatLikeOwnMessage'), + }); + }); + }); + + it('Likes a chat', () => { + let api2; + return generateUser().then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + return api.post(`/groups/${group._id}/chat/${result.message.id}/like`); + }) + .then((result) => { + expect(result[0].likes[user._id]).to.equal(true); + }); + }); +}); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index d3a22a1fc8..f04bd031d9 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -4,6 +4,7 @@ import { model as Group } from '../../models/group'; import { NotFound, } from '../../libs/api-v3/errors'; +import _ from 'lodash'; let api = {}; @@ -95,4 +96,57 @@ api.postChat = { }, }; +/** + * @api {post} /groups/:groupId/chat/:chatId/like Like a group chat message + * @apiVersion 3.0.0 + * @apiName LikeChat + * @apiGroup Chat + * + * @apiParam {groupId} groupId The group _id + * @apiParam {chatId} chatId The chat message _id + * + * @apiSuccess {Array} chat An array of chat messages + */ +api.likeChat = { + method: 'Post', + url: '/groups/:groupId/chat/:chatId/like', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, groupId) + .then((group) => { + if (!group) throw new NotFound(res.t('groupNotFound')); + let message = _.find(group.chat, {id: req.params.chatId}); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + + if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); + + if (!message.likes) message.likes = {}; + if (message.likes[user._id]) { + delete message.likes[user._id]; + } else { + message.likes[user._id] = true; + } + + let messageIndex = group.chat.indexOf(message); + group.chat[messageIndex].likes = message.likes; + + return group.save(); + }) + .then((group) => { + if (!group) throw new NotFound(res.t('groupNotFound')); + res.respond(200, group.chat); + }) + .catch(next); + }, +}; + export default api; From 54109f0e62b8d92ec0c1f707f11450562a195ed7 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 27 Dec 2015 00:27:06 -0600 Subject: [PATCH 270/976] Removed redundant code and changed like route to return only altered message --- test/api/v3/integration/chat/POST-chat.like.test.js | 4 ++-- website/src/controllers/api-v3/chat.js | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index b5f70d6ba4..1096aa11e3 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; -describe('POST /chat', () => { +describe('POST /chat/:chatId/like', () => { let user; let api; let group; @@ -62,7 +62,7 @@ describe('POST /chat', () => { return api.post(`/groups/${group._id}/chat/${result.message.id}/like`); }) .then((result) => { - expect(result[0].likes[user._id]).to.equal(true); + expect(result.likes[user._id]).to.equal(true); }); }); }); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index f04bd031d9..d8b79b3979 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -114,6 +114,7 @@ api.likeChat = { handler (req, res, next) { let user = res.locals.user; let groupId = req.params.groupId; + let message; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); @@ -124,7 +125,7 @@ api.likeChat = { Group.getGroup(user, groupId) .then((group) => { if (!group) throw new NotFound(res.t('groupNotFound')); - let message = _.find(group.chat, {id: req.params.chatId}); + message = _.find(group.chat, {id: req.params.chatId}); if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); @@ -136,14 +137,11 @@ api.likeChat = { message.likes[user._id] = true; } - let messageIndex = group.chat.indexOf(message); - group.chat[messageIndex].likes = message.likes; - return group.save(); }) .then((group) => { if (!group) throw new NotFound(res.t('groupNotFound')); - res.respond(200, group.chat); + res.respond(200, message); }) .catch(next); }, From 6ae2c5fa893781102b2b651ebf4493d3414d6902 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 27 Dec 2015 17:24:55 +0100 Subject: [PATCH 271/976] wip on groups controller --- common/locales/en/api-v3.json | 4 +- website/src/controllers/api-v3/groups.js | 202 +++++++++++++++++++- website/src/middlewares/api-v3/index.js | 2 + website/src/middlewares/api-v3/setupBody.js | 5 + website/src/models/group.js | 2 + 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 website/src/middlewares/api-v3/setupBody.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 39c0f96bb3..50726fccef 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -36,5 +36,7 @@ "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", - "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"" + "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", + "canOnlyInviteEmailUuid": "Can only invite using uuids or emails but not both at the same time.", + "inviteMissingEmail": "Missing email address in invite." } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 1c948a5912..cf42b8b6de 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -10,10 +10,13 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import * as firebase from '../../libs/api-v3/firebase'; -import txnEmail from '../../libs/api-v3/email'; +import { txnEmail } from '../../libs/api-v3/email'; +// import { encrypt } from '../../libs/api-v3/encryption'; let api = {}; +// TODO shall we accept party as groupId in all routes? + /** * @api {post} /groups Create group * @apiVersion 3.0.0 @@ -250,6 +253,9 @@ api.leaveGroup = { // When removing the user from challenges, should we keep the tasks? req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); @@ -305,11 +311,16 @@ api.removeGroupMember = { let group; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - req.checkParams('groupId', res.t('userIdRequired')).notEmpty().isUUID(); + req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat .then(foundGroup => { group = foundGroup; + if (!group) throw new NotFound(res.t('groupNotFound')); + let uuid = req.query.memberId; if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); @@ -363,4 +374,191 @@ api.removeGroupMember = { }, }; +/* function _inviteByUUIDs (uuids, group, inviter, req, res, next) { + async.each(uuids, function(uuid, cb){ + User.findById(uuid, function(err,invite){ + if (err) return cb(err); + if (!invite) + return cb({code:400,err:'User with id "' + uuid + '" not found'}); + if (group.type == 'guild') { + if (_.contains(group.members,uuid)) + return cb({code:400, err: "User already in that group"}); + if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id})) + return cb({code:400, err:"User already invited to that group"}); + sendInvite(); + } else if (group.type == 'party') { + if (invite.invitations && !_.isEmpty(invite.invitations.party)) + return cb({code: 400,err:"User already pending invitation."}); + Group.find({type: 'party', members: {$in: [uuid]}}, function(err, groups){ + if (err) return cb(err); + if (!_.isEmpty(groups) && groups[0].members.length > 1) { + return cb({code: 400, err: "User already in a party."}) + } + sendInvite(); + }); + } + + function sendInvite (){ + if(group.type === 'guild'){ + invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id}); + + pushNotify.sendNotify(invite, shared.i18n.t('invitedGuild'), group.name); + }else{ + //req.body.type in 'guild', 'party' + invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; + + pushNotify.sendNotify(invite, shared.i18n.t('invitedParty'), group.name); + } + + group.invites.push(invite._id); + + async.series([ + function(cb){ + invite.save(cb); + } + ], function(err, results){ + if (err) return cb(err); + + if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){ + var inviterVars = utils.getUserInfo(res.locals.user, ['name', 'email']); + var emailVars = [ + {name: 'INVITER', content: inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} + ]; + + if(group.type == 'guild'){ + emailVars.push( + {name: 'GUILD_NAME', content: group.name}, + {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'} + ); + }else{ + emailVars.push( + {name: 'PARTY_NAME', content: group.name}, + {name: 'PARTY_URL', content: '/#/options/groups/party'} + ) + } + + utils.txnEmail(invite, ('invited-' + (group.type == 'guild' ? 'guild' : 'party')), emailVars); + } + + cb(); + }); + } + }); + }, function(err){ + if(err) return err.code ? res.json(err.code, {err: err.err}) : next(err); + + async.series([ + function(cb) { + group.save(cb); + }, + function(cb) { + // TODO pass group from save above don't find it again, or you have to find it again in order to run populate? + populateQuery(group.type, Group.findById(group._id)).exec(function(err, populatedGroup){ + if(err) return next(err); + + res.json(populatedGroup); + }); + } + ]); + }); +}; + +function _inviteByEmails (emails, group, inviter, req, res, next) { + let usersAlreadyRegistered = []; + let invitesToSend = []; + + return Q.all(emails.forEach(invite => { + if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); + + return User.findOne({$or: [ + {'auth.local.email': invite.email}, + {'auth.facebook.emails.value': invite.email} + ]}) + .select({_id: true, 'preferences.emailNotifications': true}) + .exec() + .then(userToContact => { + if(userToContact){ + usersAlreadyRegistered.push(userToContact._id); // TODO does it work not returning + } else { + // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query + // TODO absolutely refactor this horrible code + let link = `?partyInvite=${utils.encrypt(JSON.stringify({id: group._id, inviter: inviter, name: group.name}))}`; + + let inviterVars = getUserInfo(inviter, ['name', 'email']); + let variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: req.body.inviter || inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} + ]; + + if(group.type == 'guild'){ + variables.push({name: 'GUILD_NAME', content: group.name}); + } + + // TODO implement "users can only be invited once" + // Check for the email address not to be unsubscribed + return EmailUnsubscription.findOne({email: invite.email}).exec() + .then(unsubscribed => { + if (!unsubscribed) utils.txnEmail(invite, ('invite-friend' + (group.type == 'guild' ? '-guild' : '')), variables); + }); + } + }); + })) + .then(() => { + if (usersAlreadyRegistered.length > 0){ + return _inviteByUUIDs(usersAlreadyRegistered, group, inviter, req, res, next); + } + + res.respond(200, {}); // TODO what to return? + }); +}; */ + +/** + * @api {post} /groups/:groupId/invite Invite users to a group using their UUIDs or email addresses + * @apiVersion 3.0.0 + * @apiName InviteToGroup + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiParam {array} emails An array of emails addresses to invite (optional) (inside body) + * @apiParam {array} uuids An array of uuids to invite (optional) (inside body) + * @apiParam {string} inviter The inviters' name (optional) (inside body) + * + * @apiSuccess {Object} empty An empty object + */ +/* api.inviteToGroup = { + method: 'POST', + url: '/groups/:groupId/invite', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat TODO other fields too? + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + let uuids = req.body.uuids; + let emails = req.body.emails; + + if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time + throw new BadRequest(res.t('canOnlyInviteEmailUuid')); + } else if (Array.isArray(uuids)) { + return _inviteByUUIDs(uuids, group, user, req, res, next); + } else if (Array.isArray(emails)) { + return _inviteByEmails(emails, group, user, req, res, next) + } else { + throw new BadRequest(res.t('canOnlyInviteEmailUuid')); + } + }) + .catch(next); + }, +};*/ + export default api; diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index fdb2db7bd8..01da4d4004 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -9,6 +9,7 @@ import notFoundHandler from './notFound'; import nconf from 'nconf'; import morgan from 'morgan'; import responseHandler from './response'; +import setupBody from './setupBody'; const IS_PROD = nconf.get('IS_PROD'); const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); @@ -23,6 +24,7 @@ export default function attachMiddlewares (app) { app.use(bodyParser.json()); app.use(expressValidator()); app.use(analytics); + app.use(setupBody); app.use(responseHandler); app.use(getUserLanguage); diff --git a/website/src/middlewares/api-v3/setupBody.js b/website/src/middlewares/api-v3/setupBody.js new file mode 100644 index 0000000000..82e1b798bb --- /dev/null +++ b/website/src/middlewares/api-v3/setupBody.js @@ -0,0 +1,5 @@ +// TODO tests? +export default function setupBodyMiddleware (req, res, next) { + req.body = req.body || {}; + next(); +} diff --git a/website/src/models/group.js b/website/src/models/group.js index 8687df181d..2c5cdecd35 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -162,6 +162,8 @@ schema.statics.getGroup = function getGroup (user, groupId, fields) { .findOne(query) .select(fields) .exec(); // TODO catch errors here? + + // TODO purge chat flags info? }; // TODO move to its own model From 20092f3ddb8cc2d0b75d9ae95b02bc7fe7fcfc5d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 10:46:34 +0100 Subject: [PATCH 272/976] port challenge model --- tasks/gulp-eslint.js | 1 + website/src/models/challenge.js | 186 ++++++++++++++++++-------------- website/src/models/task.js | 8 +- website/src/models/user.js | 1 + 4 files changed, 111 insertions(+), 85 deletions(-) diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 1e39fb7532..0fe0ce5ef9 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -6,6 +6,7 @@ const SERVER_FILES = [ './website/src/models/user.js', './website/src/models/task.js', './website/src/models/group.js', + './website/src/models/challenge.js', './website/src/models/tag.js', './website/src/models/emailUnsubscription.js', './website/src/server.js', diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index a59f0d0939..795d353fdf 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -1,55 +1,55 @@ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); -var TaskSchemas = require('./task'); +import mongoose from 'mongoose'; +import Q from 'q'; +import validator from 'validator'; +import baseModel from '../libs/api-v3/baseModel'; +import _ from 'lodash'; +import * as Tasks from './task'; -var ChallengeSchema = new Schema({ - _id: {type: String, 'default': shared.uuid}, - name: String, - shortName: String, +let Schema = mongoose.Schema; + +let schema = new Schema({ + name: {type: String, required: true}, + shortName: {type: String, required: true}, // TODO what is it? description: String, - official: {type: Boolean,'default':false}, - //habits: [TaskSchemas.HabitSchema], - //dailys: [TaskSchemas.DailySchema], - //todos: [TaskSchemas.TodoSchema], - //rewards: [TaskSchemas.RewardSchema], - leader: {type: String, ref: 'User'}, - group: {type: String, ref: 'Group'}, - timestamp: {type: Date, 'default': Date.now}, - members: [{type: String, ref: 'User'}], - memberCount: {type: Number, 'default': 0}, - prize: {type: Number, 'default': 0} + official: {type: Boolean, default: false}, + tasksOrder: { + habits: [{type: String, ref: 'Task'}], + dailys: [{type: String, ref: 'Task'}], + todos: [{type: String, ref: 'Task'}], + rewards: [{type: String, ref: 'Task'}], + }, + leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, + group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, + timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? + memberCount: {type: Number, default: 0}, + prize: {type: Number, default: 0, required: true}, }); -ChallengeSchema.virtual('tasks').get(function () { - var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); - var tasks = _.object(_.pluck(tasks,'id'), tasks); - return tasks; +schema.plugin(baseModel, { + noSet: ['_id', 'memberCount', 'tasksOrder'], + toJSONTransform: function userToJSON (doc) { + // TODO fixme + // TODO this works? + doc._isMember = this._isMember; + + return doc; + }, }); -ChallengeSchema.methods.toJSON = function(){ - var doc = this.toObject(); - doc._isMember = this._isMember; - return doc; -} -// -------------- // Syncing logic -// -------------- -function syncableAttrs(task) { - var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument +function _syncableAttrs (task) { + let t = task.toObject(); // lodash doesn't seem to like _.omit on EmbeddedDocument // only sync/compare important attrs - var omitAttrs = 'challenge history tags completed streak notes'.split(' '); - if (t.type != 'reward') omitAttrs.push('value'); + let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO use whitelist instead of blacklist? + if (t.type !== 'reward') omitAttrs.push('value'); return _.omit(t, omitAttrs); } -/** - * Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers - */ -function comparableData(obj) { +// TODO redo +// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers +/* function comparableData(obj) { return JSON.stringify( _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) .sortBy('id') // we don't want to update if they're sort-order is different @@ -59,62 +59,86 @@ function comparableData(obj) { .value()) } -ChallengeSchema.methods.isOutdated = function(newData) { +ChallengeSchema.methods.isOutdated = function isChallengeOutdated (newData) { return comparableData(this) !== comparableData(newData); -} +}*/ -/** - * Syncs all new tasks, deleted tasks, etc to the user object - * @param user - * @return nothing, user is modified directly. REMEMBER to save the user! - */ -ChallengeSchema.methods.syncToUser = function(user, cb) { - if (!user) return; - var self = this; - self.shortName = self.shortName || self.name; +// Syncs all new tasks, deleted tasks, etc to the user object +schema.methods.syncToUser = function syncChallengeToUser (user) { + if (!user) throw new Error('User required.'); + + let challenge = this; + challenge.shortName = challenge.shortName || challenge.name; // Add challenge to user.challenges - if (!_.contains(user.challenges, self._id)) { - user.challenges.push(self._id); - } + if (!_.contains(user.challenges, challenge._id)) user.challenges.push(challenge._id); // Sync tags - var tags = user.tags || []; - var i = _.findIndex(tags, {id: self._id}) - if (~i) { - if (tags[i].name !== self.shortName) { + let userTags = user.tags; + let i = _.findIndex(userTags, {_id: challenge._id}); + + if (i !== -1) { + if (userTags[i].name !== challenge.shortName) { // update the name - it's been changed since - user.tags[i].name = self.shortName; + userTags[i].name = challenge.shortName; } } else { - user.tags.push({ - id: self._id, - name: self.shortName, - challenge: true + userTags.push({ + _id: challenge._id, + name: challenge.shortName, + challenge: true, }); } // Sync new tasks and updated tasks - _.each(self.tasks, function(task){ - var list = user[task.type+'s']; - var userTask = user.tasks[task.id] || (list.push(syncableAttrs(task)), list[list.length-1]); - if (!userTask.notes) userTask.notes = task.notes; // don't override the notes, but provide it if not provided - userTask.challenge = {id:self._id}; - userTask.tags = userTask.tags || {}; - userTask.tags[self._id] = true; - _.merge(userTask, syncableAttrs(task)); - }) + return Q.all([ + // Find original challenge tasks + Tasks.Task.find({ + userId: {$exists: false}, + 'challenge.id': challenge._id, + }).exec(), + // Find user's tasks linked to this challenge + Tasks.Task.find({ + userId: user._id, + 'challenge.id': challenge._id, + }).exec(), + ]) + .then(results => { + let challengeTasks = results[0]; + let userTasks = results[1]; + let toSave = []; // An array of things to save - // Flag deleted tasks as "broken" - _.each(user.tasks, function(task){ - if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) { - task.challenge.broken = 'TASK_DELETED'; - } - }) + challengeTasks.forEach(chalTask => { + let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); - user.save(cb); + if (!matchingTask) { // If the task is new, create it + matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; + matchingTask.userId = user._id; + user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); + } else { + _.merge(matchingTask, _syncableAttrs(chalTask)); + // Make sure the task is in user.tasksOrder TODO necessary? + let orderList = user.tasksOrder[`${chalTask.type}s`]; + if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); + } + + if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided + if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing + toSave.push(matchingTask.save()); + }); + + // Flag deleted tasks as "broken" + userTasks.forEach(userTask => { + if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) { + userTask.challenge.broken = 'TASK_DELETED'; + toSave.push(userTask.save()); + } + }); + + toSave.push(user.save()); + return Q.all(toSave); + }); }; - -module.exports.schema = ChallengeSchema; -module.exports.model = mongoose.model("Challenge", ChallengeSchema); +export let model = mongoose.model('Challenge', schema); diff --git a/website/src/models/task.js b/website/src/models/task.js index a4e376446d..74f8d07c53 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -24,12 +24,12 @@ export let TaskSchema = new Schema({ value: {type: Number, default: 0}, // redness or cost for rewards priority: {type: Number, default: 1, required: true}, // TODO enum? attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, - userId: {type: String, ref: 'User'}, // When null it belongs to a challenge + userId: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set it belongs to a challenge challenge: { - id: {type: String, ref: 'Challenge'}, - taskId: {type: String, ref: 'Task'}, // When null but challenge.id defined it's the original task - broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED TODO enum + id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, + taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task + broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, winner: String, // user.profile.name TODO necessary? }, }, _.defaults({ diff --git a/website/src/models/user.js b/website/src/models/user.js index 54da99fb5c..8dff4bcafc 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -481,6 +481,7 @@ schema.plugin(baseModel, { private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? + // TODO this works? doc.filters = {}; doc._tmp = this._tmp; // be sure to send down drop notifs From 01e2bb56df07ccde8db0666c5a6c5899e4cffbc5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 11:18:58 +0100 Subject: [PATCH 273/976] port getChallenges --- website/src/controllers/api-v3/challenges.js | 46 ++++++++++++++++++++ website/src/models/group.js | 1 + 2 files changed, 47 insertions(+) create mode 100644 website/src/controllers/api-v3/challenges.js diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js new file mode 100644 index 0000000000..72c920eb3d --- /dev/null +++ b/website/src/controllers/api-v3/challenges.js @@ -0,0 +1,46 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { model as Challenge } from '../../models/challenge'; + +let api = {}; + +/** + * @api {get} /challenges Get challenges for a user + * @apiVersion 3.0.0 + * @apiName GetChallenges + * @apiGroup Challenge + * + * @apiSuccess {Array} challenges An array of challenges + */ +api.getChallenges = { + method: 'GET', + url: '/challenges', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + let groups = user.guilds || []; + if (user.party._id) groups.push(user.party._id); + groups.push('habitrpg'); // Public challenges + + Challenge.find({ + $or: [ + {_id: {$in: user.challenges}}, // Challenges where the user is participating + {group: {$in: groups}}, // Challenges in groups where I'm a member + {leader: user._id}, // Challenges where I'm the leader + ], + _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit + }) + .sort('-official -timestamp') + // TODO populate + // .populate('group', '_id name type') + // .populate('leader', 'profile.name') + .exec() + .then(challenges => { + res.respond(200, challenges); + }) + .catch(next); + }, +}; + +export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 2c5cdecd35..5c26330cc7 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -13,6 +13,7 @@ let Schema = mongoose.Schema; // NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API // changes made directly to the db will cause Firebase to get out of sync export let schema = new Schema({ + // TODO don't break validation on _id === 'habitrpg' name: {type: String, required: true}, description: String, leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, From ba0fb884dd7c47c7477e82979d661e54e1f03359 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 12:14:56 +0100 Subject: [PATCH 274/976] port create challenge --- common/locales/en/api-v3.json | 5 +- website/src/controllers/api-v3/challenges.js | 92 ++++++++++++++++++++ website/src/models/challenge.js | 9 +- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 50726fccef..df92fe6a07 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -38,5 +38,8 @@ "groupMemberNotFound": "User not found among group's members", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", "canOnlyInviteEmailUuid": "Can only invite using uuids or emails but not both at the same time.", - "inviteMissingEmail": "Missing email address in invite." + "inviteMissingEmail": "Missing email address in invite.", + "onlyGroupLeaderChal": "Only the group leader can create challenges", + "pubChalsMinPrize": "Prize must be at least 1 Gem for public challenges.", + "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount." } diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 72c920eb3d..267ffd8138 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -1,9 +1,101 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; +import { model as Group } from '../../models/group'; +import { + NotFound, + NotAuthorized, +} from '../../libs/api-v3/errors'; +import * as Tasks from './task'; +import Q from 'q'; let api = {}; +/** + * @api {post} /challenges Create a new challenge + * @apiVersion 3.0.0 + * @apiName CreateChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} challenge The newly created challenge + */ +api.getChallenges = { + method: 'POST', + url: '/challenges', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkBody('group', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + let groupId = req.body.group; + let prize = req.body.prize; + + Group.getGroup(user, groupId, '-chat') + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { + throw new NotAuthorized(res.t('onlyGroupLeaderChal')); + } + + if (groupId === 'habitrpg' && prize < 1) { + throw new NotAuthorized(res.t('pubChalsMinPrize')); + } + + if (prize > 0) { + let groupBalance = group.balance && group.leader === user._id ? group.balance : 0; + let prizeCost = prize / 4; + + if (prizeCost > user.balance + groupBalance) { + throw new NotAuthorized(res.t('cantAfford')); + } + + if (groupBalance >= prizeCost) { + // Group pays for all of prize + group.balance -= prizeCost; + } else if (groupBalance > 0) { + // User pays remainder of prize cost after group + let remainder = prizeCost - group.balance; + group.balance = 0; + user.balance -= remainder; + } else { + // User pays for all of prize + user.balance -= prizeCost; + } + } + + let tasks = req.body.tasks || []; // TODO validate + req.body.leader = user._id; + req.body.official = user.contributor.admin && req.body.official; + let challenge = new Challenge(Challenge.sanitize(req.body)); + + let toSave = tasks.map(tasks, taskToCreate => { + // TODO validate type + let task = new Tasks[taskToCreate.type](Tasks.Task.sanitizeCreate(taskToCreate)); + task.challenge.id = challenge._id; + challenge.tasksOrder[`${task.type}s`].push(task._id); + return task.save(); + }); + + + toSave.unshift(challenge, group); + return Q.all(toSave); + }) + .then(results => { + let savedChal = results[0]; + + user.challenges.push(savedChal._id); // TODO save user only after group created, so that we can account for failed validation. Revisit in other places + return savedChal.syncToUser(user) // (it also saves the user) + .then(() => res.respond(201, savedChal)); + }) + .catch(next); + }, +}; + /** * @api {get} /challenges Get challenges for a user * @apiVersion 3.0.0 diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 795d353fdf..d5b3bf1a1b 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -22,18 +22,11 @@ let schema = new Schema({ group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? memberCount: {type: Number, default: 0}, - prize: {type: Number, default: 0, required: true}, + prize: {type: Number, default: 0, min: 0}, }); schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'tasksOrder'], - toJSONTransform: function userToJSON (doc) { - // TODO fixme - // TODO this works? - doc._isMember = this._isMember; - - return doc; - }, }); From 488685ceff7a4b0a616bd4e49892b6af7876f6f4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 12:15:28 +0100 Subject: [PATCH 275/976] typo --- website/src/controllers/api-v3/challenges.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 267ffd8138..3ca89076f6 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -19,7 +19,7 @@ let api = {}; * * @apiSuccess {object} challenge The newly created challenge */ -api.getChallenges = { +api.createChallenge = { method: 'POST', url: '/challenges', middlewares: [authWithHeaders(), cron], From ceb20742dc43200106ca642d13d4981a17862c4a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 12:21:15 +0100 Subject: [PATCH 276/976] fix an import --- website/src/controllers/api-v3/challenges.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3ca89076f6..6f17b8ce74 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -6,7 +6,7 @@ import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; -import * as Tasks from './task'; +import * as Tasks from '../../models/task'; import Q from 'q'; let api = {}; From b3feb997d358f34e79ef98274b7b9ed0a9c3e453 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 28 Dec 2015 08:09:17 -0600 Subject: [PATCH 277/976] Change group save to Group update, added flags route and tests, and made eslint edits --- .../integration/chat/POST-chat.flag.test.js | 126 ++++++++++++++++ .../integration/chat/POST-chat.like.test.js | 37 ++++- website/src/controllers/api-v3/chat.js | 139 +++++++++++++++++- 3 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 test/api/v3/integration/chat/POST-chat.flag.test.js diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js new file mode 100644 index 0000000000..5f4954d28a --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -0,0 +1,126 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; +import _ from 'lodash'; + +describe('POST /chat/:chatId/flag', () => { + let user; + let api; + let group; + let testMessage = 'Test Message'; + + before(() => { + let groupName = 'Test Guild'; + let groupType = 'guild'; + let groupPrivacy = 'public'; + + return generateUser({balance: 1}).then((generatedUser) => { + user = generatedUser; + api = requester(user); + }) + .then(() => { + return api.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + }) + .then((generatedGroup) => { + group = generatedGroup; + }); + }); + + it('Returns an error when chat message is not found', () => { + return expect(api.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('Returns an error when user tries to flag their own message', () => { + return api.post(`/groups/${group._id}/chat`, { message: testMessage}) + .then((result) => { + return expect(api.post(`/groups/${group._id}/chat/${result.message.id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatFlagOwnMessage'), + }); + }); + }); + + it('Flags a chat', () => { + let api2; + let message; + + return generateUser().then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + message = result.message; + return api.post(`/groups/${group._id}/chat/${message.id}/flag`); + }) + .then((result) => { + expect(result.flags[user._id]).to.equal(true); + expect(result.flagCount).to.equal(1); + return api.get(`/groups/${group._id}`); + }) + .then((updatedGroup) => { + let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + expect(messageToCheck.flags[user._id]).to.equal(true); + }); + }); + + it('Flags a chat with a higher flag acount when an admin flags the message', () => { + let api2; + let secondUser; + let message; + + return generateUser({'contributor.admin': true}).then((generatedUser) => { + secondUser = generatedUser; + api2 = requester(generatedUser); + return api.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + message = result.message; + return api2.post(`/groups/${group._id}/chat/${message.id}/flag`); + }) + .then((result) => { + expect(result.flags[secondUser._id]).to.equal(true); + expect(result.flagCount).to.equal(5); + return api.get(`/groups/${group._id}`); + }) + .then((updatedGroup) => { + let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + expect(messageToCheck.flags[secondUser._id]).to.equal(true); + expect(messageToCheck.flagCount).to.equal(5); + }); + }); + + it('Returns an error when user tries to flag a message that is already flagged', () => { + let api2; + let message; + + return generateUser().then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + message = result.message; + return api.post(`/groups/${group._id}/chat/${message.id}/flag`); + }) + .then(() => { + return expect(api.post(`/groups/${group._id}/chat/${message.id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatFlagAlreadyReported'), + }); + }); + }); +}); diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index 1096aa11e3..66403221bd 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import _ from 'lodash'; describe('POST /chat/:chatId/like', () => { let user; @@ -54,15 +55,49 @@ describe('POST /chat/:chatId/like', () => { it('Likes a chat', () => { let api2; + let message; + return generateUser().then((generatedUser) => { api2 = requester(generatedUser); return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { - return api.post(`/groups/${group._id}/chat/${result.message.id}/like`); + message = result.message; + return api.post(`/groups/${group._id}/chat/${message.id}/like`); }) .then((result) => { expect(result.likes[user._id]).to.equal(true); + return api.get(`/groups/${group._id}`); + }) + .then((updatedGroup) => { + let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + expect(messageToCheck.likes[user._id]).to.equal(true); + }); + }); + + it('Unlikes a chat', () => { + let api2; + let message; + + return generateUser().then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + message = result.message; + return api.post(`/groups/${group._id}/chat/${message.id}/like`); + }) + .then((result) => { + expect(result.likes[user._id]).to.equal(true); + return api.post(`/groups/${group._id}/chat/${message.id}/like`); + }) + .then((result) => { + expect(result.likes[user._id]).to.equal(undefined); + return api.get(`/groups/${group._id}`); + }) + .then((updatedGroup) => { + let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + expect(messageToCheck.likes[user._id]).to.equal(undefined); }); }); }); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index d8b79b3979..7fc6f7deda 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -1,10 +1,13 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Group } from '../../models/group'; +import { model as User } from '../../models/user'; import { NotFound, } from '../../libs/api-v3/errors'; import _ from 'lodash'; +import { sendTxn } from '../../libs/api-v3/email'; +import nconf from 'nconf'; let api = {}; @@ -137,10 +140,140 @@ api.likeChat = { message.likes[user._id] = true; } - return group.save(); + return Group.update( + {_id: group._id, 'chat.id': message.id}, + {$set: {'chat.$.likes': message.likes}} + ); }) - .then((group) => { - if (!group) throw new NotFound(res.t('groupNotFound')); + .then((groupSaved) => { + if (!groupSaved) throw new NotFound(res.t('groupNotFound')); + res.respond(200, message); + }) + .catch(next); + }, +}; + +/** + * @api {post} /groups/:groupId/chat/:chatId/like Like a group chat message + * @apiVersion 3.0.0 + * @apiName LikeChat + * @apiGroup Chat + * + * @apiParam {groupId} groupId The group _id + * @apiParam {chatId} chatId The chat message _id + * + * @apiSuccess {Array} chat An array of chat messages + */ +api.flagChat = { + method: 'Post', + url: '/groups/:groupId/chat/:chatId/flag', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let groupId = req.params.groupId; + let message; + let group; + let author; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, groupId) + .then((groupFound) => { + if (!groupFound) throw new NotFound(res.t('groupNotFound')); + group = groupFound; + message = _.find(group.chat, {id: req.params.chatId}); + + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + + if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage')); + + return User.findOne({_id: message.uuid}, {auth: 1}); + }) + .then((foundAuthor) => { + author = foundAuthor; + + // Log user ids that have flagged the message + if (!message.flags) message.flags = {}; + if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported')); + message.flags[user._id] = true; + + // Log total number of flags (publicly viewable) + if (!message.flagCount) message.flagCount = 0; + if (user.contributor.admin) { + // Arbitraty amount, higher than 2 + message.flagCount = 5; + } else { + message.flagCount++; + } + + // return group.save(); + return Group.update( + {_id: group._id, 'chat.id': message.id}, + {$set: { + 'chat.$.flags': message.flags, + 'chat.$.flagCount': message.flagCount, + }}); + }) + .then((group2) => { + if (!group2) throw new NotFound(res.t('groupNotFound')); + + let addressesToSendTo = nconf.get('FLAG_REPORT_EMAIL'); + addressesToSendTo = typeof addressesToSendTo === 'string' ? JSON.parse(addressesToSendTo) : addressesToSendTo; + + if (Array.isArray(addressesToSendTo)) { + addressesToSendTo = addressesToSendTo.map((email) => { + return {email, canSend: true}; + }); + } else { + addressesToSendTo = {email: addressesToSendTo}; + } + + let reporterEmailContent; + if (user.auth.local) { + reporterEmailContent = user.auth.local.email; + } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) { + reporterEmailContent = user.auth.facebook.emails[0].value; + } + + let authorEmailContent; + if (author.auth.local) { + authorEmailContent = author.auth.local.email; + } else if (author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) { + authorEmailContent = author.auth.facebook.emails[0].value; + } + + let groupUrl; + if (group._id === 'habitrpg') { + groupUrl = '/#/options/groups/tavern'; + } else if (group.type === 'guild') { + groupUrl = `/#/options/groups/guilds/{$group._id}`; + } else { + groupUrl = 'party'; + } + + sendTxn(addressesToSendTo, 'flag-report-to-mods', [ + {name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()}, + {name: 'MESSAGE_TEXT', content: message.text}, + + {name: 'REPORTER_USERNAME', content: user.profile.name}, + {name: 'REPORTER_UUID', content: user._id}, + {name: 'REPORTER_EMAIL', content: reporterEmailContent}, + {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId={$user._id}`}, + + {name: 'AUTHOR_USERNAME', content: message.user}, + {name: 'AUTHOR_UUID', content: message.uuid}, + {name: 'AUTHOR_EMAIL', content: authorEmailContent}, + {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId={$message.uuid}`}, + + {name: 'GROUP_NAME', content: group.name}, + {name: 'GROUP_TYPE', content: group.type}, + {name: 'GROUP_ID', content: group._id}, + {name: 'GROUP_URL', content: groupUrl}, + ]); res.respond(200, message); }) .catch(next); From 9748ffb0a68f321f343be9fb7df1a5ade5632ecc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 19:45:34 +0100 Subject: [PATCH 278/976] add updateGroup route --- website/src/controllers/api-v3/groups.js | 39 ++++++++++++++++++++++++ website/src/models/group.js | 6 ++++ 2 files changed, 45 insertions(+) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index cf42b8b6de..0373d02984 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -160,6 +160,45 @@ api.getGroup = { }, }; +/** + * @api {put} /groups/:groupId Update group + * @apiVersion 3.0.0 + * @apiName UpdateGroup + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} group The updated group object + */ +api.updateGroup = { + method: 'PUT', + url: '/groups/:groupId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, req.params.groupId) + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); + + _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body))); + + return group.save(); + }).then(savedGroup => { + res.respond(200, savedGroup); + firebase.updateGroupData(savedGroup); + }) + .catch(next); + }, +}; + /** * @api {post} /groups/:groupId/join Join a group * @apiVersion 3.0.0 diff --git a/website/src/models/group.js b/website/src/models/group.js index 5c26330cc7..fd79686977 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -73,6 +73,12 @@ schema.plugin(baseModel, { noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount'], }); +// A list of additional fields that cannot be updated (but can be set on creation) +let noUpdate = ['privacy', 'type']; +schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { + return model.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define +}; + // TODO migration /** * Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration From cb388eea4015d206877d1664d6e48336cd12c68d Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 28 Dec 2015 14:08:10 -0600 Subject: [PATCH 279/976] Ensured that only one like or flag is updated during query --- website/src/controllers/api-v3/chat.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 7fc6f7deda..dbd0625188 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -133,16 +133,22 @@ api.likeChat = { if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); + let update; + if (!message.likes) message.likes = {}; if (message.likes[user._id]) { delete message.likes[user._id]; + update = {'$unset': {}}; + update.$unset[`chat.$.likes.${user._id}`] = ''; } else { message.likes[user._id] = true; + update = {'$set': {}}; + update.$set[`chat.$.likes.${user._id}`] = true; } return Group.update( {_id: group._id, 'chat.id': message.id}, - {$set: {'chat.$.likes': message.likes}} + update ); }) .then((groupSaved) => { @@ -196,10 +202,13 @@ api.flagChat = { .then((foundAuthor) => { author = foundAuthor; + let update = {$set: {}}; + // Log user ids that have flagged the message if (!message.flags) message.flags = {}; if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported')); message.flags[user._id] = true; + update.$set[`chat.$.flags.${user._id}`] = true; // Log total number of flags (publicly viewable) if (!message.flagCount) message.flagCount = 0; @@ -209,14 +218,12 @@ api.flagChat = { } else { message.flagCount++; } + update.$set['chat.$.flagCount'] = message.flagCount; - // return group.save(); return Group.update( {_id: group._id, 'chat.id': message.id}, - {$set: { - 'chat.$.flags': message.flags, - 'chat.$.flagCount': message.flagCount, - }}); + update + ); }) .then((group2) => { if (!group2) throw new NotFound(res.t('groupNotFound')); From 2750446e7b11c05c16afdc1beb11857766b99f28 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 28 Dec 2015 18:45:34 -0600 Subject: [PATCH 280/976] Updated POST like route to persist data between updates --- .../api/v3/integration/chat/POST-chat.like.test.js | 4 ++-- website/src/controllers/api-v3/chat.js | 14 ++++---------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index 66403221bd..cda9df4e1c 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -92,12 +92,12 @@ describe('POST /chat/:chatId/like', () => { return api.post(`/groups/${group._id}/chat/${message.id}/like`); }) .then((result) => { - expect(result.likes[user._id]).to.equal(undefined); + expect(result.likes[user._id]).to.equal(false); return api.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.likes[user._id]).to.equal(undefined); + expect(messageToCheck.likes[user._id]).to.equal(false); }); }); }); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index dbd0625188..2bfb4c6847 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -133,18 +133,12 @@ api.likeChat = { if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); - let update; + let update = {$set: {}}; if (!message.likes) message.likes = {}; - if (message.likes[user._id]) { - delete message.likes[user._id]; - update = {'$unset': {}}; - update.$unset[`chat.$.likes.${user._id}`] = ''; - } else { - message.likes[user._id] = true; - update = {'$set': {}}; - update.$set[`chat.$.likes.${user._id}`] = true; - } + + message.likes[user._id] = !message.likes[user._id]; + update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id]; return Group.update( {_id: group._id, 'chat.id': message.id}, From 547e8a04f50c4ff0673c9f5c105d7d85ec56acd3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 14:43:29 +0100 Subject: [PATCH 281/976] cleanup group model --- website/src/controllers/api-v2/groups.js | 1 + website/src/controllers/api-v3/groups.js | 4 ++-- website/src/models/group.js | 30 +----------------------- 3 files changed, 4 insertions(+), 31 deletions(-) diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index de4d21022c..5f840c4330 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -1101,6 +1101,7 @@ api.questLeave = function(req, res, next) { }); } +// TODO port to api v3? in tojson? function _purgeFlagInfoFromChat(group, user) { group.chat = _.filter(group.chat, function(message) { return !message.flagCount || message.flagCount < 2; }); _.each(group.chat, function (message) { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 0373d02984..8f96cc09ae 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -567,7 +567,7 @@ function _inviteByEmails (emails, group, inviter, req, res, next) { * * @apiSuccess {Object} empty An empty object */ -/* api.inviteToGroup = { +api.inviteToGroup = { method: 'POST', url: '/groups/:groupId/invite', middlewares: [authWithHeaders(), cron], @@ -598,6 +598,6 @@ function _inviteByEmails (emails, group, inviter, req, res, next) { }) .catch(next); }, -};*/ +}; export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index fd79686977..95f5a82b94 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -94,16 +94,6 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { } }*/ -// FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate() -// @see https://github.com/LearnBoost/mongoose/issues/964 -> Add update pre? -// TODO necessary? -schema.pre('save', function preSaveGroup (next) { - // removeDuplicates(this); - this.memberCount = _.size(this.members); - this.challengeCount = _.size(this.challenges); - return next(); -}); - // TODO test schema.pre('remove', true, function preRemoveGroup (next, done) { next(); @@ -135,24 +125,6 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -/* schema.methods.toJSON = function groupToJSON () { - let doc = this.toObject(); - // removeDuplicates(doc); - doc._isMember = this._isMember; // TODO ? - - // TODO migration - // fix(groups): temp fix to remove chat entries stored as strings (not sure why that's happening..). - // Required as angular 1.3 is strict on dupes, and no message.id to `track by` - _.remove(doc.chat, msg => !msg.id); - - // TODO should not be needed here - // @see pre('save') comment above - this.memberCount = _.size(this.members); - this.challengeCount = _.size(this.challenges); - - return doc; -};*/ - // TODO populate (invites too), isMember? schema.statics.getGroup = function getGroup (user, groupId, fields) { let query; @@ -170,7 +142,7 @@ schema.statics.getGroup = function getGroup (user, groupId, fields) { .select(fields) .exec(); // TODO catch errors here? - // TODO purge chat flags info? + // TODO purge chat flags info? in tojson? }; // TODO move to its own model From 71a910ccfba8be97056e83082ae099a53364fd58 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 14:45:29 +0100 Subject: [PATCH 282/976] temporary fix for eslint --- website/src/controllers/api-v3/groups.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8f96cc09ae..5866797d40 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -589,9 +589,9 @@ api.inviteToGroup = { if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time throw new BadRequest(res.t('canOnlyInviteEmailUuid')); } else if (Array.isArray(uuids)) { - return _inviteByUUIDs(uuids, group, user, req, res, next); + // return _inviteByUUIDs(uuids, group, user, req, res, next); } else if (Array.isArray(emails)) { - return _inviteByEmails(emails, group, user, req, res, next) + // return _inviteByEmails(emails, group, user, req, res, next); } else { throw new BadRequest(res.t('canOnlyInviteEmailUuid')); } From 61fc490f847a973a065efbccaa1a73d9d7caf223 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 18:35:34 +0100 Subject: [PATCH 283/976] port challenges\' tasks scoring --- common/script/api-v3/preenHistory.js | 2 +- website/src/controllers/api-v3/tasks.js | 21 ++++++++++++++++++++- website/src/models/task.js | 4 ++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/common/script/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js index 50ffb1518f..64ae4e4715 100644 --- a/common/script/api-v3/preenHistory.js +++ b/common/script/api-v3/preenHistory.js @@ -62,7 +62,7 @@ export function preenHistory (history) { } export function preenUserHistory (user, tasksByType, minHistLen = 7) { - tasksByType.habits.concat(user.dailys).forEach((task) => { + tasksByType.habits.concat(tasksByType.dailys).forEach((task) => { if (task.history.length > minHistLen) { task.history = preenHistory(user, task.history); task.markModified('history'); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index ef7180088e..be77c97d70 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -7,10 +7,12 @@ import { NotAuthorized, BadRequest, } from '../../libs/api-v3/errors'; +import { model as Challenge } from '../../models/challenge'; import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; import scoreTask from '../../../../common/script/api-v3/scoreTask'; +import { preenHistory } from '../../../../common/script/api-v3/preenHistory'; let api = {}; @@ -292,7 +294,24 @@ api.scoreTask = { sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); - // TODO sync challenge + // TODO test? + if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { + Tasks.Task.findOne({ + _id: task.challenge.taskId + }).exec() + .then(chalTask => { + chalTask.value += delta; + if (t.type == 'habit' || t.type == 'daily') { + chalTask.history.push({value: chalTask.value, date: Number(new Date())}); + // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? + chalTask.history = preenHistory(user, chalTask.history); + chalTask.markModified('history'); + } + + return chalTask.save(); + }); + //.catch(next) TODO what to do here + } }); }) .catch(next); diff --git a/website/src/models/task.js b/website/src/models/task.js index 74f8d07c53..b31676da10 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -21,13 +21,13 @@ export let TaskSchema = new Schema({ type: String, validate: [validator.isUUID, 'Invalid uuid.'], }], - value: {type: Number, default: 0}, // redness or cost for rewards + value: {type: Number, default: 0, required: true}, // redness or cost for rewards Required because it must be settable (for rewards) priority: {type: Number, default: 1, required: true}, // TODO enum? attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, userId: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set it belongs to a challenge challenge: { - id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, + id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, winner: String, // user.profile.name TODO necessary? From 411bfe1bb6f545b025d6e5c99fe67aeb0ee7f6cb Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Tue, 29 Dec 2015 20:51:16 +0200 Subject: [PATCH 284/976] Fix GET chat tests stopping execution of other tests --- test/api/v3/integration/chat/GET-chat.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index e6f2aeeb8a..5681c8b818 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -5,7 +5,7 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; -describe.only('GET /groups/:groupId/chat', () => { +describe('GET /groups/:groupId/chat', () => { let user, api; before(() => { From 6003dad24ba20cd38f7bf7bf4310d38ca8bafc1b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 20:57:20 +0100 Subject: [PATCH 285/976] misc fixes, port challenge task scoring, delete chal and select winner --- common/locales/en/api-v3.json | 9 +- website/src/controllers/api-v3/challenges.js | 142 ++++++++++++++++++- website/src/controllers/api-v3/tasks.js | 7 +- website/src/controllers/pushNotifications.js | 1 + 4 files changed, 150 insertions(+), 9 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index df92fe6a07..2ace1f7773 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -15,7 +15,7 @@ "cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", "onlySocialAttachLocal": "Local auth can only be added to a social account.", "invalidReqParams": "Invalid request parameters.", - "taskIdRequired": "\"taskId\" must be a valid UUID", + "taskIdRequired": "\"taskId\" must be a valid UUID.", "taskNotFound": "Task not found.", "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.", @@ -41,5 +41,10 @@ "inviteMissingEmail": "Missing email address in invite.", "onlyGroupLeaderChal": "Only the group leader can create challenges", "pubChalsMinPrize": "Prize must be at least 1 Gem for public challenges.", - "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount." + "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount.", + "challengeIdRequired": "\"challengeId\" must be a valid UUID.", + "winnerIdRequired": "\"winnerId\" must be a valid UUID.", + "challengeNotFound": "Challenge not found.", + "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", + "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge." } diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 6f17b8ce74..1d950446fa 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -2,11 +2,15 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; +import { model as User } from '../../models/user'; import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; +import shared from '../../../../common'; import * as Tasks from '../../models/task'; +import { txnEmail } from '../../libs/api-v3/email'; +import pushNotify from '../../libs/api-v3/pushNotifications'; import Q from 'q'; let api = {}; @@ -68,6 +72,8 @@ api.createChallenge = { } } + group.challengeCount += 1; + let tasks = req.body.tasks || []; // TODO validate req.body.leader = user._id; req.body.official = user.contributor.admin && req.body.official; @@ -81,14 +87,11 @@ api.createChallenge = { return task.save(); }); - toSave.unshift(challenge, group); return Q.all(toSave); }) .then(results => { let savedChal = results[0]; - - user.challenges.push(savedChal._id); // TODO save user only after group created, so that we can account for failed validation. Revisit in other places return savedChal.syncToUser(user) // (it also saves the user) .then(() => res.respond(201, savedChal)); }) @@ -135,4 +138,137 @@ api.getChallenges = { }, }; +// TODO everything here should be moved to a worker +// actually even for a worker it's probably just to big and will kill mongo +function _closeChal (challenge, broken = {}) { + let winner = broken.winner; + let brokenReason = broken.broken; + + let tasks = [ + // Delete the challenge + Challenge.remove({_id: challenge._id}).exec(), + // And it's tasks + Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(), + // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges + User.update({ + challenges: {$in: [challenge._id]}, + 'tags._id': challenge._id, + }, { + $set: {'tags.$.challenge': false}, + $pull: {challenges: challenge._id}, + }, {multi: true}).exec(), + // Break users' tasks + Tasks.Task.update({ + 'challenge.id': challenge._id, + }, { + $set: { + 'challenge.broken': brokenReason, + 'challenge.winner': winner && winner.profile.name, + }, + }, {multi: true}).exec(), + // Update the challengeCount on the group + Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(), + ]; + + // Refund the leader if the challenge is closed and the group not the tavern + if (challenge.group !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { + tasks.push(User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec()); + } + + // Award prize to winner and notify + if (winner) { + winner.achievements.challenges.push(challenge.name); + winner.balance += challenge.prize / 4; + tasks.push(winner.save().then(savedWinner => { + if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { + txnEmail(savedWinner, 'won-challenge', [ + {name: 'CHALLENGE_NAME', content: challenge.name}, + ]); + } + + pushNotify.sendNotify(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate + })); + } + + return Q.allSettled(tasks); // TODO look if allSettle could be useful somewhere else + // TODO catch and handle +} + +/** + * @api {delete} /challenges/:challengeId Delete a challenge + * @apiVersion 3.0.0 + * @apiName DeleteChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} empty An empty object + */ +api.deleteChallenge = { + method: 'DELETE', + url: '/challenges/:challengeId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Challenge.findOne({_id: req.params.challengeId}) + .exec() + .then(challenge => { + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + + res.respond(200, {}); + // Close channel in background + _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); + }) + .catch(next); + }, +}; + +/** + * @api {delete} /challenges/:challengeId Delete a challenge + * @apiVersion 3.0.0 + * @apiName DeleteChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} empty An empty object + */ +api.selectChallengeWinner = { + method: 'POST', + url: '/challenges/:challengeId/selectWinner/:winnerId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let challenge; + + req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Challenge.findOne({_id: req.params.challengeId}) + .exec() + .then(challengeFound => { + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + + challenge = challengeFound; + + return User.findOne({_id: req.params.winnerId}).exec(); + }) + .then(winner => { + if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.parama.winnerId})); + + res.respond(200, {}); + // Close channel in background + _closeChal(challenge, {broken: 'CHALLENGE_DELETED', winner}); + }) + .catch(next); + }, +}; + export default api; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index be77c97d70..99e217e401 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -7,7 +7,6 @@ import { NotAuthorized, BadRequest, } from '../../libs/api-v3/errors'; -import { model as Challenge } from '../../models/challenge'; import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; @@ -297,11 +296,11 @@ api.scoreTask = { // TODO test? if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { Tasks.Task.findOne({ - _id: task.challenge.taskId + _id: task.challenge.taskId, }).exec() .then(chalTask => { chalTask.value += delta; - if (t.type == 'habit' || t.type == 'daily') { + if (chalTask.type === 'habit' || chalTask.type === 'daily') { chalTask.history.push({value: chalTask.value, date: Number(new Date())}); // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? chalTask.history = preenHistory(user, chalTask.history); @@ -310,7 +309,7 @@ api.scoreTask = { return chalTask.save(); }); - //.catch(next) TODO what to do here + // .catch(next) TODO what to do here } }); }) diff --git a/website/src/controllers/pushNotifications.js b/website/src/controllers/pushNotifications.js index d1c728f365..860cfbf56a 100644 --- a/website/src/controllers/pushNotifications.js +++ b/website/src/controllers/pushNotifications.js @@ -1,3 +1,4 @@ +// TODO move to /api-v2 var api = module.exports; var _ = require('lodash'); var nconf = require('nconf'); From 08e0c670892f0dba1f589c107e6a9cb3f71b814f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 20:57:37 +0100 Subject: [PATCH 286/976] refactor pushNotifications --- website/src/libs/api-v3/pushNotifications.js | 55 ++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 website/src/libs/api-v3/pushNotifications.js diff --git a/website/src/libs/api-v3/pushNotifications.js b/website/src/libs/api-v3/pushNotifications.js new file mode 100644 index 0000000000..1db4c740bd --- /dev/null +++ b/website/src/libs/api-v3/pushNotifications.js @@ -0,0 +1,55 @@ +import _ from 'lodash'; +import nconf from 'nconf'; +import pushNotify from 'push-notify'; + +const GCM_API_KEY = nconf.get('PUSH_CONFIGS:GCM_SERVER_API_KEY'); + +let gcm = GCM_API_KEY ? pushNotify.gcm({ + apiKey: GCM_API_KEY, + retries: 3, +}) : undefined; + +// TODO log +if (gcm) { + gcm.on('transmitted', (/* result, message, registrationId */) => { + // console.info("transmitted", result, message, registrationId); + }); + + gcm.on('transmissionError', (/* error, message, registrationId */) => { + // console.info("transmissionError", error, message, registrationId); + }); + + gcm.on('updated', (/* result, registrationId */) => { + // console.info("updated", result, registrationId); + }); +} + +export default function sendNotify (user, title, message, timeToLive = 15) { + // TODO need investigation: + // https://github.com/HabitRPG/habitrpg/issues/5252 + + if (!user) throw new Error('User is required'); + + _.each(user.pushDevices, pushDevice => { + switch (pushDevice.type) { + case 'android': + if (gcm) { + gcm.send({ + registrationId: pushDevice.regId, + // collapseKey: 'COLLAPSE_KEY', + delayWhileIdle: true, + timeToLive, + data: { + title, + message, + }, + }); + } + + break; + + case 'ios': + break; + } + }); +} From a5aeb6917e5d0819aa6455053a841b5e09848f43 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 21:36:52 +0100 Subject: [PATCH 287/976] allow getGroup to optionally work for groups where the user is not a member --- website/src/controllers/api-v3/groups.js | 2 +- website/src/libs/api-v3/pushNotifications.js | 1 + website/src/models/group.js | 17 +++++++++-------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 5866797d40..8a3ce9630b 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -221,7 +221,7 @@ api.joinGroup = { let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat + Group.getGroup(user, req.params.groupId, '-chat', true) // Do not fetch chat and work even if the user is not yet a member of the group .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); diff --git a/website/src/libs/api-v3/pushNotifications.js b/website/src/libs/api-v3/pushNotifications.js index 1db4c740bd..fbf5e2666a 100644 --- a/website/src/libs/api-v3/pushNotifications.js +++ b/website/src/libs/api-v3/pushNotifications.js @@ -24,6 +24,7 @@ if (gcm) { }); } +// TODO test export default function sendNotify (user, title, message, timeToLive = 15) { // TODO need investigation: // https://github.com/HabitRPG/habitrpg/issues/5252 diff --git a/website/src/models/group.js b/website/src/models/group.js index 95f5a82b94..f425a6a667 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -126,10 +126,13 @@ schema.post('remove', function postRemoveGroup (group) { }); // TODO populate (invites too), isMember? -schema.statics.getGroup = function getGroup (user, groupId, fields) { +schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMembership) { let query; - if (groupId === 'party' || user.party._id === groupId) { + // When optionalMembership is true it's not required for the user to be a member of the group + if (optionalMembership === true) { + query = {_id: groupId}; + } else if (groupId === 'party' || user.party._id === groupId) { query = {type: 'party', _id: user.party._id}; } else if (user.guilds.indexOf(groupId) !== -1) { query = {type: 'guild', _id: groupId}; @@ -137,12 +140,10 @@ schema.statics.getGroup = function getGroup (user, groupId, fields) { query = {type: 'guild', privacy: 'public', _id: groupId}; } - return this - .findOne(query) - .select(fields) - .exec(); // TODO catch errors here? - - // TODO purge chat flags info? in tojson? + let mQuery = this.findOne(query); + if (fields) mQuery.select(fields); + return mQuery.exec(); // TODO catch errors here? + // TODO purge chat flags info? in tojson? }; // TODO move to its own model From eab15a3dd07f69b89b59dd6a648716f9ea59961b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 30 Dec 2015 07:57:41 -0600 Subject: [PATCH 288/976] tests(api): Port over new integration helper from develop --- test/helpers/api-integration.helper.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 973414a3a8..dd0d5baf0b 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -1,9 +1,10 @@ /* eslint-disable no-use-before-define */ import { - set, + assign, each, isEmpty, + set, times, } from 'lodash'; import { MongoClient as mongo } from 'mongodb'; @@ -23,6 +24,17 @@ const ROUTES = { }, }; +class ApiUser { + constructor (options) { + assign(this, options); + + this.get = _requestMaker(this, 'get'); + this.post = _requestMaker(this, 'post'); + this.put = _requestMaker(this, 'put'); + this.del = _requestMaker(this, 'del'); + } +} + // Sets up an abject that can make all REST requests // If a user is passed in, the uuid and api token of // the user are used to make the requests @@ -94,7 +106,9 @@ export function generateUser (update = {}) { confirmPassword: password, }).then((user) => { _updateDocument('users', user, update, () => { - resolve(user); + let apiUser = new ApiUser(user); + + resolve(apiUser); }); }).catch(reject); }); @@ -277,7 +291,6 @@ function _updateDocument (collectionName, doc, update, cb) { return cb(); } - // TODO use config for db url? mongo.connect('mongodb://localhost/habitrpg_test', (connectErr, db) => { if (connectErr) throw new Error(`Error connecting to database when updating ${collectionName} collection: ${connectErr}`); From ca1513aaa90264e3dc394c42f66f34a28f292509 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 30 Dec 2015 08:25:06 -0600 Subject: [PATCH 289/976] tests(api): Use new user['HTTP_METHOD'] for v3 integration tests --- .../integration/tags/DELETE-tags_id.test.js | 12 +- test/api/v3/integration/tags/GET-tags.test.js | 10 +- .../v3/integration/tags/GET-tags_id.test.js | 8 +- .../api/v3/integration/tags/POST-tags.test.js | 10 +- .../v3/integration/tags/PUT-tags_id.test.js | 11 +- .../integration/tasks/DELETE-tasks_id.test.js | 20 ++- .../v3/integration/tasks/GET-tasks.test.js | 12 +- .../v3/integration/tasks/GET-tasks_id.test.js | 23 ++- .../v3/integration/tasks/POST-tasks.test.js | 46 +++--- .../POST-tasks_id_score_direction.test.js | 136 +++++++++--------- .../v3/integration/tasks/PUT-tasks_id.test.js | 54 ++++--- ...LETE-tasks_taskId_checklist_itemId.test.js | 26 ++-- .../POST-tasks_taskId_checklist.test.js | 20 +-- ...asks_taskId_checklist_itemId_score.test.js | 24 ++-- .../PUT-tasks_taskId_checklist_itemId.test.js | 24 ++-- .../DELETE-tasks_taskId_tags_tagId.test.js | 18 ++- .../tags/POST-tasks_taskId_tags_tagId.test.js | 22 ++- test/api/v3/integration/user/GET-user.test.js | 14 +- 18 files changed, 229 insertions(+), 261 deletions(-) diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js index 0cf4764679..82911ddda7 100644 --- a/test/api/v3/integration/tags/DELETE-tags_id.test.js +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -1,15 +1,13 @@ import { generateUser, - requester, } from '../../../../helpers/api-integration.helper'; describe('DELETE /tags/:tagId', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -17,16 +15,16 @@ describe('DELETE /tags/:tagId', () => { let length; let tag; - return api.post('/tags', {name: 'Tag 1'}) + return user.post('/tags', {name: 'Tag 1'}) .then((createdTag) => { tag = createdTag; - return api.get(`/tags`); + return user.get(`/tags`); }) .then((tags) => { length = tags.length; - return api.del(`/tags/${tag._id}`); + return user.del(`/tags/${tag._id}`); }) - .then(() => api.get(`/tags`)) + .then(() => user.get(`/tags`)) .then((tags) => { expect(tags.length).to.equal(length - 1); expect(tags[tags.length - 1].name).to.not.equal('Tag 1'); diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js index bc0439ffb6..3669fc37e0 100644 --- a/test/api/v3/integration/tags/GET-tags.test.js +++ b/test/api/v3/integration/tags/GET-tags.test.js @@ -1,22 +1,20 @@ import { generateUser, - requester, } from '../../../../helpers/api-integration.helper'; describe('GET /tags', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); it('returns all user\'s tags', () => { - return api.post('/tags', {name: 'Tag 1'}) - .then(() => api.post('/tags', {name: 'Tag 2'})) - .then(() => api.get('/tags')) + return user.post('/tags', {name: 'Tag 1'}) + .then(() => user.post('/tags', {name: 'Tag 2'})) + .then(() => user.get('/tags')) .then((tags) => { expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task expect(tags[tags.length - 2].name).to.equal('Tag 1'); diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index 86c63094f3..f8180be5ca 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -1,25 +1,23 @@ import { generateUser, - requester, } from '../../../../helpers/api-integration.helper'; describe('GET /tags/:tagId', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); it('returns a tag given it\'s id', () => { let createdTag; - return api.post('/tags', {name: 'Tag 1'}) + return user.post('/tags', {name: 'Tag 1'}) .then((tag) => { createdTag = tag; - return api.get(`/tags/${createdTag._id}`) + return user.get(`/tags/${createdTag._id}`) }) .then((tag) => { expect(tag).to.deep.equal(createdTag); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js index 0bed7e71f5..351d14e9fb 100644 --- a/test/api/v3/integration/tags/POST-tags.test.js +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -1,29 +1,29 @@ import { generateUser, - requester, } from '../../../../helpers/api-integration.helper'; describe('POST /tags', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); it('creates a tag correctly', () => { let createdTag; - return api.post('/tags', { + return user.post('/tags', { name: 'Tag 1', ignored: false, }).then((tag) => { createdTag = tag; + expect(tag.name).to.equal('Tag 1'); expect(tag.ignored).to.be.a('undefined'); - return api.get(`/tags/${createdTag._id}`) + + return user.get(`/tags/${createdTag._id}`); }) .then((tag) => { expect(tag).to.deep.equal(createdTag); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index ace042afa8..e8a4335444 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -1,24 +1,22 @@ import { generateUser, - requester, } from '../../../../helpers/api-integration.helper'; describe('PUT /tags/:tagId', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); it('updates a tag given it\'s id', () => { let length; - return api.post('/tags', {name: 'Tag 1'}) + return user.post('/tags', {name: 'Tag 1'}) .then((createdTag) => { - return api.put(`/tags/${createdTag._id}`, { + return user.put(`/tags/${createdTag._id}`, { name: 'Tag updated', ignored: true }); @@ -26,7 +24,8 @@ describe('PUT /tags/:tagId', () => { .then((updatedTag) => { expect(updatedTag.name).to.equal('Tag updated'); expect(updatedTag.ignored).to.be.a('undefined'); - return api.get(`/tags/${updatedTag._id}`); + + return user.get(`/tags/${updatedTag._id}`); }) .then((tag) => { expect(tag.name).to.equal('Tag updated'); diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 5543345220..ad92739c9a 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -1,16 +1,14 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; describe('DELETE /tasks/:id', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -18,7 +16,7 @@ describe('DELETE /tasks/:id', () => { let task; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -27,9 +25,9 @@ describe('DELETE /tasks/:id', () => { }); it('deletes a user\'s task', () => { - return api.del('/tasks/' + task._id) + return user.del('/tasks/' + task._id) .then(() => { - return expect(api.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + return expect(user.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -40,7 +38,7 @@ describe('DELETE /tasks/:id', () => { context('task cannot be deleted', () => { it('cannot delete a non-existant task', () => { - return expect(api.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ + return expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -49,14 +47,14 @@ describe('DELETE /tasks/:id', () => { it('cannot delete a task owned by someone else', () => { return generateUser() - .then((user2) => { - return requester(user2).post('/tasks', { + .then((anotherUser) => { + return anotherUser.post('/tasks', { text: 'test habit', type: 'habit', - }) + }); }) .then((task2) => { - return expect(api.del('/tasks/' + task2._id)).to.eventually.be.rejected.and.eql({ + return expect(user.del('/tasks/' + task2._id)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 2ef56ff1fe..25bf8b1308 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -1,28 +1,26 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; import Q from 'q'; describe('GET /tasks', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); it('returns all user\'s tasks', () => { let length; return Q.all([ - api.post('/tasks', {text: 'test habit', type: 'habit'}), + user.post('/tasks', {text: 'test habit', type: 'habit'}), ]) .then((createdTasks) => { length = createdTasks.length; - return api.get('/tasks'); + return user.get('/tasks'); }) .then((tasks) => { expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task @@ -31,10 +29,10 @@ describe('GET /tasks', () => { it('returns only a type of user\'s tasks if req.query.type is specified', () => { let habitId; - api.post('/tasks', {text: 'test habit', type: 'habit'}) + user.post('/tasks', {text: 'test habit', type: 'habit'}) .then((task) => { habitId = task._id; - return api.get('/tasks?type=habit'); + return user.get('/tasks?type=habit'); }) .then((tasks) => { expect(tasks.length).to.equal(1); diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index 4e69788c6c..993e2c127f 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -1,17 +1,15 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('GET /tasks/:id', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -19,7 +17,7 @@ describe('GET /tasks/:id', () => { let task; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -28,7 +26,7 @@ describe('GET /tasks/:id', () => { }); it('gets specified task', () => { - return api.get('/tasks/' + task._id) + return user.get('/tasks/' + task._id) .then((getTask) => { expect(getTask).to.eql(task); }); @@ -38,9 +36,9 @@ describe('GET /tasks/:id', () => { it('can get active challenge task that user does not own'); // Yes? }); - context('task cannot accessed', () => { + context('task cannot be accessed', () => { it('cannot get a non-existant task', () => { - return expect(api.get('/tasks/' + generateUUID())).to.eventually.be.rejected.and.eql({ + return expect(user.get('/tasks/' + generateUUID())).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -48,17 +46,18 @@ describe('GET /tasks/:id', () => { }); it('cannot get a task owned by someone else', () => { - let api2; + let anotherUser; return generateUser() .then((user2) => { - api2 = requester(user2); - return api.post('/tasks', { + anotherUser = user2; + + return user.post('/tasks', { text: 'test habit', type: 'habit', - }) + }); }).then((task) => { - return expect(api2.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + return expect(anotherUser.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 09ca58be01..30e4d3ce37 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -1,24 +1,20 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; -import { v4 as generateRandomUserName } from 'uuid'; -import { each } from 'lodash'; describe('POST /tasks', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); context('validates params', () => { it('returns an error if req.body.type is absent', () => { - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -28,7 +24,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.type is not valid', () => { - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -38,7 +34,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.text is absent', () => { - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -48,7 +44,7 @@ describe('POST /tasks', () => { }); it('automatically sets "task.userId" to user\'s uuid', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test habit', type: 'habit', }).then((task) => { @@ -59,7 +55,7 @@ describe('POST /tasks', () => { it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, dateCompleted fields`, () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test daily', type: 'daily', userId: 123, @@ -83,7 +79,7 @@ describe('POST /tasks', () => { }); it('ignores invalid fields', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test daily', type: 'daily', notValid: true, @@ -95,7 +91,7 @@ describe('POST /tasks', () => { context('habits', () => { it('creates a habit', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test habit', type: 'habit', up: false, @@ -112,7 +108,7 @@ describe('POST /tasks', () => { }); it('defaults to setting up and down to true', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test habit', type: 'habit', notes: 1976, @@ -123,7 +119,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test habit', type: 'habit', checklist: [ @@ -137,7 +133,7 @@ describe('POST /tasks', () => { context('todos', () => { it('creates a todo', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test todo', type: 'todo', notes: 1976, @@ -150,7 +146,7 @@ describe('POST /tasks', () => { }); it('can create checklists', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test todo', type: 'todo', checklist: [ @@ -171,7 +167,7 @@ describe('POST /tasks', () => { it('creates a daily', () => { let now = new Date(); - return api.post('/tasks', { + return user.post('/tasks', { text: 'test daily', type: 'daily', notes: 1976, @@ -190,7 +186,7 @@ describe('POST /tasks', () => { }); it('defaults to a weekly frequency, with every day set', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test daily', type: 'daily', }).then((task) => { @@ -209,7 +205,7 @@ describe('POST /tasks', () => { }); it('allows repeat field to be configured', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test daily', type: 'daily', repeat: { @@ -233,7 +229,7 @@ describe('POST /tasks', () => { it('defaults startDate to today', () => { let today = (new Date()).getDay(); - return api.post('/tasks', { + return user.post('/tasks', { text: 'test daily', type: 'daily', }).then((task) => { @@ -242,7 +238,7 @@ describe('POST /tasks', () => { }); it('can create checklists', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test daily', type: 'daily', checklist: [ @@ -261,7 +257,7 @@ describe('POST /tasks', () => { context('rewards', () => { it('creates a reward', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test reward', type: 'reward', notes: 1976, @@ -276,7 +272,7 @@ describe('POST /tasks', () => { }); it('defaults to a 0 value', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test reward', type: 'reward', }).then((task) => { @@ -285,7 +281,7 @@ describe('POST /tasks', () => { }); it('requires value to be coerced into a number', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test reward', type: 'reward', value: "10", @@ -295,7 +291,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', () => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test reward', type: 'reward', checklist: [ diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 622d63dc4f..4e21a31b20 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -1,25 +1,23 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:id/score/:direction', () => { - let user, api; + let user; beforeEach(() => { return generateUser({ 'stats.gp': 100, }).then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); context('all', () => { it('requires a task id', () => { - return expect(api.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ + return expect(user.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -27,7 +25,7 @@ describe('POST /tasks/:id/score/:direction', () => { }); it('requires a task direction', () => { - return expect(api.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ + return expect(user.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -39,7 +37,7 @@ describe('POST /tasks/:id/score/:direction', () => { let todo; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test todo', type: 'todo', }).then((task) => { @@ -48,20 +46,20 @@ describe('POST /tasks/:id/score/:direction', () => { }); it('completes todo when direction is up', () => { - return api.post(`/tasks/${todo._id}/score/up`) - .then((res) => api.get(`/tasks/${todo._id}`)) + return user.post(`/tasks/${todo._id}/score/up`) + .then((res) => user.get(`/tasks/${todo._id}`)) .then((task) => expect(task.completed).to.equal(true)); }); it('moves completed todos out of user.tasksOrder.todos', () => { - return api.get('/user') + return user.get('/user') .then(user => { expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) - }).then(() => api.post(`/tasks/${todo._id}/score/up`)) - .then(() => api.get(`/tasks/${todo._id}`)) + }).then(() => user.post(`/tasks/${todo._id}/score/up`)) + .then(() => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { expect(updatedTask.completed).to.equal(true); - return api.get('/user'); + return user.get('/user'); }) .then((user) => { expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1) @@ -69,15 +67,15 @@ describe('POST /tasks/:id/score/:direction', () => { }); it('moves un-completed todos back into user.tasksOrder.todos', () => { - return api.get('/user') + return user.get('/user') .then(user => { expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) - }).then(() => api.post(`/tasks/${todo._id}/score/up`)) - .then(() => api.post(`/tasks/${todo._id}/score/down`)) - .then(() => api.get(`/tasks/${todo._id}`)) + }).then(() => user.post(`/tasks/${todo._id}/score/up`)) + .then(() => user.post(`/tasks/${todo._id}/score/down`)) + .then(() => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { expect(updatedTask.completed).to.equal(false); - return api.get('/user'); + return user.get('/user'); }) .then((user) => { let l = user.tasksOrder.todos.length; @@ -87,8 +85,8 @@ describe('POST /tasks/:id/score/:direction', () => { }); it('uncompletes todo when direction is down', () => { - return api.post(`/tasks/${todo._id}/score/down`) - .then((res) => api.get(`/tasks/${todo._id}`)) + return user.post(`/tasks/${todo._id}/score/down`) + .then((res) => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { expect(updatedTask.completed).to.equal(false); }); @@ -99,48 +97,48 @@ describe('POST /tasks/:id/score/:direction', () => { it('scores down todo even if it is already uncompleted'); // Yes? it('increases user\'s mp when direction is up', () => { - return api.post(`/tasks/${todo._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${todo._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); }); it('decreases user\'s mp when direction is down', () => { - return api.post(`/tasks/${todo._id}/score/down`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${todo._id}/score/down`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); }); it('increases user\'s exp when direction is up', () => { - return api.post(`/tasks/${todo._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${todo._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); }); it('decreases user\'s exp when direction is down', () => { - return api.post(`/tasks/${todo._id}/score/down`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${todo._id}/score/down`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); }); it('increases user\'s gold when direction is up', () => { - return api.post(`/tasks/${todo._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${todo._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); it('decreases user\'s gold when direction is down', () => { - return api.post(`/tasks/${todo._id}/score/down`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${todo._id}/score/down`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); @@ -151,7 +149,7 @@ describe('POST /tasks/:id/score/:direction', () => { let daily; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test daily', type: 'daily', }).then((task) => { @@ -160,14 +158,14 @@ describe('POST /tasks/:id/score/:direction', () => { }); it('completes daily when direction is up', () => { - return api.post(`/tasks/${daily._id}/score/up`) - .then((res) => api.get(`/tasks/${daily._id}`)) + return user.post(`/tasks/${daily._id}/score/up`) + .then((res) => user.get(`/tasks/${daily._id}`)) .then((task) => expect(task.completed).to.equal(true)); }); it('uncompletes daily when direction is down', () => { - return api.post(`/tasks/${daily._id}/score/down`) - .then((res) => api.get(`/tasks/${daily._id}`)) + return user.post(`/tasks/${daily._id}/score/down`) + .then((res) => user.get(`/tasks/${daily._id}`)) .then((task) => expect(task.completed).to.equal(false)); }); @@ -176,48 +174,48 @@ describe('POST /tasks/:id/score/:direction', () => { it('scores down daily even if it is already uncompleted'); // Yes? it('increases user\'s mp when direction is up', () => { - return api.post(`/tasks/${daily._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${daily._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); }); it('decreases user\'s mp when direction is down', () => { - return api.post(`/tasks/${daily._id}/score/down`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${daily._id}/score/down`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); }); it('increases user\'s exp when direction is up', () => { - return api.post(`/tasks/${daily._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${daily._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); }); it('decreases user\'s exp when direction is down', () => { - return api.post(`/tasks/${daily._id}/score/down`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${daily._id}/score/down`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); }); it('increases user\'s gold when direction is up', () => { - return api.post(`/tasks/${daily._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${daily._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); it('decreases user\'s gold when direction is down', () => { - return api.post(`/tasks/${daily._id}/score/down`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${daily._id}/score/down`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); @@ -228,26 +226,26 @@ describe('POST /tasks/:id/score/:direction', () => { let habit, minusHabit, plusHabit, neitherHabit; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test habit', type: 'habit', }).then((task) => { habit = task; - return api.post('/tasks', { + return user.post('/tasks', { text: 'test min habit', type: 'habit', up: false, }); }).then((task) => { minusHabit = task; - return api.post('/tasks', { + return user.post('/tasks', { text: 'test plus habit', type: 'habit', down: false, }) }).then((task) => { plusHabit = task; - api.post('/tasks', { + user.post('/tasks', { text: 'test neither habit', type: 'habit', up: false, @@ -263,32 +261,32 @@ describe('POST /tasks/:id/score/:direction', () => { it('prevents minus only habit from scoring up'); // Yes? it('increases user\'s mp when direction is up', () => { - return api.post(`/tasks/${habit._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${habit._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); }); it('decreases user\'s mp when direction is down', () => { - return api.post(`/tasks/${habit._id}/score/down`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${habit._id}/score/down`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); }); it('increases user\'s exp when direction is up', () => { - return api.post(`/tasks/${habit._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${habit._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); }); it('increases user\'s gold when direction is up', () => { - return api.post(`/tasks/${habit._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${habit._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -299,7 +297,7 @@ describe('POST /tasks/:id/score/:direction', () => { let reward; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test reward', type: 'reward', value: 5, @@ -309,32 +307,32 @@ describe('POST /tasks/:id/score/:direction', () => { }); it('purchases reward', () => { - return api.post(`/tasks/${reward._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${reward._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); }); }); it('does not change user\'s mp', () => { - return api.post(`/tasks/${reward._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${reward._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); }); it('does not change user\'s exp', () => { - return api.post(`/tasks/${reward._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${reward._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp).to.equal(updatedUser.stats.exp); }); }); it('does not allow a down direction', () => { - return api.post(`/tasks/${reward._id}/score/up`) - .then((res) => api.get(`/user`)) + return user.post(`/tasks/${reward._id}/score/up`) + .then((res) => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index f78f7ca35e..4e5ac308c5 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -1,17 +1,15 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:id', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -19,7 +17,7 @@ describe('PUT /tasks/:id', () => { let task; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -30,7 +28,7 @@ describe('PUT /tasks/:id', () => { it(`ignores setting _id, type, userId, history, createdAt, updatedAt, challenge, completed, streak, dateCompleted fields`, () => { - api.put('/tasks/' + task._id, { + user.put('/tasks/' + task._id, { _id: 123, type: 'daily', userId: 123, @@ -56,7 +54,7 @@ describe('PUT /tasks/:id', () => { }); it('ignores invalid fields', () => { - api.put('/tasks/' + task._id, { + user.put('/tasks/' + task._id, { notValid: true, }).then((savedTask) => { expect(savedTask.notValid).to.be.a('undefined'); @@ -68,7 +66,7 @@ describe('PUT /tasks/:id', () => { let habit; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test habit', type: 'habit', notes: 1976, @@ -78,7 +76,7 @@ describe('PUT /tasks/:id', () => { }); it('updates a habit', () => { - return api.put(`/tasks/${habit._id}`, { + return user.put(`/tasks/${habit._id}`, { text: 'some new text', up: false, down: false, @@ -96,7 +94,7 @@ describe('PUT /tasks/:id', () => { let todo; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test todo', type: 'todo', notes: 1976, @@ -106,7 +104,7 @@ describe('PUT /tasks/:id', () => { }); it('updates a todo', () => { - return api.put(`/tasks/${todo._id}`, { + return user.put(`/tasks/${todo._id}`, { text: 'some new text', notes: 'some new notes', }).then((task) => { @@ -116,13 +114,13 @@ describe('PUT /tasks/:id', () => { }); it('can update checklists (replace it)', () => { - return api.put(`/tasks/${todo._id}`, { + return user.put(`/tasks/${todo._id}`, { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, ] }).then((savedTodo) => { - return api.put(`/tasks/${todo._id}`, { + return user.put(`/tasks/${todo._id}`, { checklist: [ {text: 789, completed: false}, ] @@ -136,10 +134,10 @@ describe('PUT /tasks/:id', () => { it('can update tags (replace them)', () => { let finalUUID = generateUUID(); - return api.put(`/tasks/${todo._id}`, { + return user.put(`/tasks/${todo._id}`, { tags: [generateUUID(), generateUUID()], }).then((savedTodo) => { - return api.put(`/tasks/${todo._id}`, { + return user.put(`/tasks/${todo._id}`, { tags: [finalUUID] }); }).then((savedTodo2) => { @@ -153,7 +151,7 @@ describe('PUT /tasks/:id', () => { let daily; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test daily', type: 'daily', notes: 1976, @@ -165,7 +163,7 @@ describe('PUT /tasks/:id', () => { it('updates a daily', () => { let now = new Date(); - return api.put(`/tasks/${daily._id}`, { + return user.put(`/tasks/${daily._id}`, { text: 'some new text', notes: 'some new notes', frequency: 'daily', @@ -179,13 +177,13 @@ describe('PUT /tasks/:id', () => { }); it('can update checklists (replace it)', () => { - return api.put(`/tasks/${daily._id}`, { + return user.put(`/tasks/${daily._id}`, { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, ] }).then((savedDaily) => { - return api.put(`/tasks/${daily._id}`, { + return user.put(`/tasks/${daily._id}`, { checklist: [ {text: 789, completed: false}, ] @@ -199,10 +197,10 @@ describe('PUT /tasks/:id', () => { it('can update tags (replace them)', () => { let finalUUID = generateUUID(); - return api.put(`/tasks/${daily._id}`, { + return user.put(`/tasks/${daily._id}`, { tags: [generateUUID(), generateUUID()], }).then((savedDaily) => { - return api.put(`/tasks/${daily._id}`, { + return user.put(`/tasks/${daily._id}`, { tags: [finalUUID] }); }).then((savedDaily2) => { @@ -212,10 +210,10 @@ describe('PUT /tasks/:id', () => { }); it('updates repeat, even if frequency is set to daily', () => { - return api.put(`/tasks/${daily._id}`, { + return user.put(`/tasks/${daily._id}`, { frequency: 'daily', }).then((savedDaily) => { - return api.put(`/tasks/${daily._id}`, { + return user.put(`/tasks/${daily._id}`, { repeat: { m: false, su: false @@ -235,10 +233,10 @@ describe('PUT /tasks/:id', () => { }); it('updates everyX, even if frequency is set to weekly', () => { - return api.put(`/tasks/${daily._id}`, { + return user.put(`/tasks/${daily._id}`, { frequency: 'weekly', }).then((savedDaily) => { - return api.put(`/tasks/${daily._id}`, { + return user.put(`/tasks/${daily._id}`, { everyX: 5, }); }).then((savedDaily2) => { @@ -247,7 +245,7 @@ describe('PUT /tasks/:id', () => { }); it('defaults startDate to today if none date object is passed in', () => { - return api.put(`/tasks/${daily._id}`, { + return user.put(`/tasks/${daily._id}`, { frequency: 'weekly', }).then((savedDaily2) => { expect((new Date(savedDaily2.startDate)).getDay()).to.eql((new Date()).getDay()); @@ -259,7 +257,7 @@ describe('PUT /tasks/:id', () => { let reward; beforeEach(() => { - return api.post('/tasks', { + return user.post('/tasks', { text: 'test reward', type: 'reward', notes: 1976, @@ -270,7 +268,7 @@ describe('PUT /tasks/:id', () => { }); it('updates a reward', () => { - return api.put(`/tasks/${reward._id}`, { + return user.put(`/tasks/${reward._id}`, { text: 'some new text', notes: 'some new notes', value: 10, @@ -282,7 +280,7 @@ describe('PUT /tasks/:id', () => { }); it('requires value to be coerced into a number', () => { - return api.put(`/tasks/${reward._id}`, { + return user.put(`/tasks/${reward._id}`, { value: "100", }).then((task) => { expect(task.value).to.eql(100); diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index 013ba69725..094619e1fd 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -1,33 +1,31 @@ import { generateUser, - requester, translate as t, } from '../../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:taskId/checklist/:itemId', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); it('deletes a checklist item', () => { let task; - return api.post('/tasks', { + return user.post('/tasks', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { task = createdTask; - return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); }).then((savedTask) => { - return api.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + return user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); }).then(() => { - return api.get(`/tasks/${task._id}`); + return user.get(`/tasks/${task._id}`); }).then((savedTask) => { expect(savedTask.checklist.length).to.equal(0); }); @@ -35,12 +33,12 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { it('does not work with habits', () => { let habit; - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { habit = createdTask; - return api.del(`/tasks/${habit._id}/checklist/${generateUUID()}`); + return user.del(`/tasks/${habit._id}/checklist/${generateUUID()}`); })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -50,12 +48,12 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { it('does not work with rewards', () => { let reward; - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'reward', text: 'reward with checklist', }).then(createdTask => { reward = createdTask; - return api.del(`/tasks/${reward._id}/checklist/${generateUUID()}`); + return user.del(`/tasks/${reward._id}/checklist/${generateUUID()}`); }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -64,7 +62,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('fails on task not found', () => { - return expect(api.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + return expect(user.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -72,11 +70,11 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', () => { - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { - return api.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); + return user.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); })).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index e3ec919308..4654871ac5 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -1,29 +1,28 @@ import { generateUser, - requester, translate as t, } from '../../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/checklist/', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); it('adds a checklist item to a task', () => { let task; - return api.post('/tasks', { + return user.post('/tasks', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { task = createdTask; - return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', ignored: false, _id: 123}); + + return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', ignored: false, _id: 123}); }).then((savedTask) => { expect(savedTask.checklist.length).to.equal(1); expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); @@ -36,12 +35,13 @@ describe('POST /tasks/:taskId/checklist/', () => { it('does not add a checklist to habits', () => { let habit; - return expect(api.post('/tasks', { + + return expect(user.post('/tasks', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { habit = createdTask; - return api.post(`/tasks/${habit._id}/checklist`, {text: 'Checklist Item 1'}); + return user.post(`/tasks/${habit._id}/checklist`, {text: 'Checklist Item 1'}); })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -51,12 +51,12 @@ describe('POST /tasks/:taskId/checklist/', () => { it('does not add a checklist to rewards', () => { let reward; - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'reward', text: 'reward with checklist', }).then(createdTask => { reward = createdTask; - return api.post(`/tasks/${reward._id}/checklist`, {text: 'Checklist Item 1'}); + return user.post(`/tasks/${reward._id}/checklist`, {text: 'Checklist Item 1'}); })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -65,7 +65,7 @@ describe('POST /tasks/:taskId/checklist/', () => { }); it('fails on task not found', () => { - return expect(api.post(`/tasks/${generateUUID()}/checklist`, { + return expect(user.post(`/tasks/${generateUUID()}/checklist`, { text: 'Checklist Item 1' })).to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 32c09ba1c8..727aaea74c 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -1,31 +1,29 @@ import { generateUser, - requester, translate as t, } from '../../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/checklist/:itemId/score', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); it('scores a checklist item', () => { let task; - return api.post('/tasks', { + return user.post('/tasks', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { task = createdTask; - return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); }).then((savedTask) => { - return api.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); + return user.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); }).then((savedTask) => { expect(savedTask.checklist.length).to.equal(1); expect(savedTask.checklist[0].completed).to.equal(true); @@ -34,12 +32,12 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { it('fails on habits', () => { let habit; - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { habit = createdTask; - return api.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, {text: 'Checklist Item 1'}); + return user.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, {text: 'Checklist Item 1'}); })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -49,12 +47,12 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { it('fails on rewards', () => { let reward; - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'reward', text: 'reward with checklist', }).then(createdTask => { reward = createdTask; - return api.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`); + return user.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`); }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -63,7 +61,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on task not found', () => { - return expect(api.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ + return expect(user.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -71,11 +69,11 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on checklist item not found', () => { - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { - return api.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`); + return user.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`); })).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index e272d05da8..149d1bedca 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -1,31 +1,29 @@ import { generateUser, - requester, translate as t, } from '../../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:taskId/checklist/:itemId', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); it('updates a checklist item', () => { let task; - return api.post('/tasks', { + return user.post('/tasks', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { task = createdTask; - return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); }).then((savedTask) => { - return api.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, {text: 'updated', completed: true, _id: 123}); + return user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, {text: 'updated', completed: true, _id: 123}); }).then((savedTask) => { expect(savedTask.checklist.length).to.equal(1); expect(savedTask.checklist[0].text).to.equal('updated'); @@ -36,12 +34,12 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { it('fails on habits', () => { let habit; - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { habit = createdTask; - return api.put(`/tasks/${habit._id}/checklist/${generateUUID()}`); + return user.put(`/tasks/${habit._id}/checklist/${generateUUID()}`); })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -51,12 +49,12 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { it('fails on rewards', () => { let reward; - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'reward', text: 'reward with checklist', }).then(createdTask => { reward = createdTask; - return api.put(`/tasks/${reward._id}/checklist/${generateUUID()}`); + return user.put(`/tasks/${reward._id}/checklist/${generateUUID()}`); }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -65,7 +63,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on task not found', () => { - return expect(api.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + return expect(user.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -73,11 +71,11 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', () => { - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { - return api.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); + return user.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); })).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index 6fa2c94d68..ddb78b7262 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -1,17 +1,15 @@ import { generateUser, - requester, translate as t, } from '../../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:taskId/tags/:tagId', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -19,18 +17,18 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return api.post('/tasks', { + return user.post('/tasks', { type: 'habit', text: 'Task with tag', }).then(createdTask => { task = createdTask; - return api.post('/tags', {name: 'Tag 1'}); + return user.post('/tags', {name: 'Tag 1'}); }).then(createdTag => { tag = createdTag; - return api.post(`/tasks/${task._id}/tags/${tag._id}`); + return user.post(`/tasks/${task._id}/tags/${tag._id}`); }).then(savedTask => { - return api.del(`/tasks/${task._id}/tags/${tag._id}`); - }).then(() => api.get(`/tasks/${task._id}`)) + return user.del(`/tasks/${task._id}/tags/${tag._id}`); + }).then(() => user.get(`/tasks/${task._id}`)) .then(updatedTask => { expect(updatedTask.tags.length).to.equal(0); }); @@ -39,11 +37,11 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { it('only deletes existing tags', () => { let task; - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'habit', text: 'Task with tag', }).then(createdTask => { - return api.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`); + return user.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`); })).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index b5e8f4e485..6e4c2ba510 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -1,17 +1,15 @@ import { generateUser, - requester, translate as t, } from '../../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/tags/:tagId', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -19,15 +17,15 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return api.post('/tasks', { + return user.post('/tasks', { type: 'habit', text: 'Task with tag', }).then(createdTask => { task = createdTask; - return api.post('/tags', {name: 'Tag 1'}); + return user.post('/tags', {name: 'Tag 1'}); }).then(createdTag => { tag = createdTag; - return api.post(`/tasks/${task._id}/tags/${tag._id}`); + return user.post(`/tasks/${task._id}/tags/${tag._id}`); }).then(savedTask => { expect(savedTask.tags[0]).to.equal(tag._id); }); @@ -37,17 +35,17 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'habit', text: 'Task with tag', }).then(createdTask => { task = createdTask; - return api.post('/tags', {name: 'Tag 1'}); + return user.post('/tags', {name: 'Tag 1'}); }).then(createdTag => { tag = createdTag; - return api.post(`/tasks/${task._id}/tags/${tag._id}`); + return user.post(`/tasks/${task._id}/tags/${tag._id}`); }).then(() => { - return api.post(`/tasks/${task._id}/tags/${tag._id}`); + return user.post(`/tasks/${task._id}/tags/${tag._id}`); })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -56,11 +54,11 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('does not add a non existing tag to a task', () => { - return expect(api.post('/tasks', { + return expect(user.post('/tasks', { type: 'habit', text: 'Task with tag', }).then((task) => { - return api.post(`/tasks/${task._id}/tags/${generateUUID()}`); + return user.post(`/tasks/${task._id}/tags/${generateUUID()}`); })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', diff --git a/test/api/v3/integration/user/GET-user.test.js b/test/api/v3/integration/user/GET-user.test.js index 47c8eb4cc9..099526e9e6 100644 --- a/test/api/v3/integration/user/GET-user.test.js +++ b/test/api/v3/integration/user/GET-user.test.js @@ -1,31 +1,29 @@ import { generateUser, - requester, } from '../../../../helpers/api-integration.helper'; describe('GET /user', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); it('returns the authenticated user', () => { - return api.get('/user') + return user.get('/user') .then(returnedUser => { expect(returnedUser._id).to.equal(user._id); }); }); it('does not return private paths (and apiToken)', () => { - return api.get('/user') + return user.get('/user') .then(returnedUser => { - expect(returnedUser.auth.local.hashed_password).to.be.a('undefined'); - expect(returnedUser.auth.local.salt).to.be.a('undefined'); - expect(returnedUser.apiToken).to.be.a('undefined'); + expect(returnedUser.auth.local.hashed_password).to.not.exist; + expect(returnedUser.auth.local.salt).to.not.exist; + expect(returnedUser.apiToken).to.not.exist; }); }); }); From dc3407e1af3157bea6104f94653885f262ec7e31 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 30 Dec 2015 08:44:02 -0600 Subject: [PATCH 290/976] tests(api): Convert groups tests to use user['HTTP_METHOD'] syntax --- test/api/v3/integration/chat/GET-chat.test.js | 8 ++-- .../integration/chat/POST-chat.flag.test.js | 39 +++++++------------ .../integration/chat/POST-chat.like.test.js | 33 +++++++--------- .../api/v3/integration/chat/POST-chat.test.js | 31 +++++++-------- .../v3/integration/groups/POST-groups.test.js | 24 ++++-------- 5 files changed, 53 insertions(+), 82 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 5681c8b818..26ed88f70b 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -1,17 +1,15 @@ import { generateUser, generateGroup, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; describe('GET /groups/:groupId/chat', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -38,7 +36,7 @@ describe('GET /groups/:groupId/chat', () => { }); it('returns Guild chat', () => { - return api.get('/groups/' + group._id + '/chat') + return user.get('/groups/' + group._id + '/chat') .then((getChat) => { expect(getChat).to.eql(group.chat); }); @@ -69,7 +67,7 @@ describe('GET /groups/:groupId/chat', () => { it('returns error if user is not member of requested private group', () => { return expect( - api.get('/groups/' + group._id + '/chat') + user.get('/groups/' + group._id + '/chat') ) .to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 5f4954d28a..c5178b7af8 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -1,13 +1,11 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; import _ from 'lodash'; describe('POST /chat/:chatId/flag', () => { let user; - let api; let group; let testMessage = 'Test Message'; @@ -18,10 +16,9 @@ describe('POST /chat/:chatId/flag', () => { return generateUser({balance: 1}).then((generatedUser) => { user = generatedUser; - api = requester(user); }) .then(() => { - return api.post('/groups', { + return user.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, @@ -33,7 +30,7 @@ describe('POST /chat/:chatId/flag', () => { }); it('Returns an error when chat message is not found', () => { - return expect(api.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) + return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -42,9 +39,9 @@ describe('POST /chat/:chatId/flag', () => { }); it('Returns an error when user tries to flag their own message', () => { - return api.post(`/groups/${group._id}/chat`, { message: testMessage}) + return user.post(`/groups/${group._id}/chat`, { message: testMessage}) .then((result) => { - return expect(api.post(`/groups/${group._id}/chat/${result.message.id}/flag`)) + return expect(user.post(`/groups/${group._id}/chat/${result.message.id}/flag`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -54,21 +51,19 @@ describe('POST /chat/:chatId/flag', () => { }); it('Flags a chat', () => { - let api2; let message; - return generateUser().then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + return generateUser().then((anotherUser) => { + return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { message = result.message; - return api.post(`/groups/${group._id}/chat/${message.id}/flag`); + return user.post(`/groups/${group._id}/chat/${message.id}/flag`); }) .then((result) => { expect(result.flags[user._id]).to.equal(true); expect(result.flagCount).to.equal(1); - return api.get(`/groups/${group._id}`); + return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); @@ -77,23 +72,21 @@ describe('POST /chat/:chatId/flag', () => { }); it('Flags a chat with a higher flag acount when an admin flags the message', () => { - let api2; let secondUser; let message; return generateUser({'contributor.admin': true}).then((generatedUser) => { secondUser = generatedUser; - api2 = requester(generatedUser); - return api.post(`/groups/${group._id}/chat`, { message: testMessage}); + return user.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { message = result.message; - return api2.post(`/groups/${group._id}/chat/${message.id}/flag`); + return secondUser.post(`/groups/${group._id}/chat/${message.id}/flag`); }) .then((result) => { expect(result.flags[secondUser._id]).to.equal(true); expect(result.flagCount).to.equal(5); - return api.get(`/groups/${group._id}`); + return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); @@ -103,19 +96,17 @@ describe('POST /chat/:chatId/flag', () => { }); it('Returns an error when user tries to flag a message that is already flagged', () => { - let api2; let message; - return generateUser().then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + return generateUser().then((anotherUser) => { + return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { message = result.message; - return api.post(`/groups/${group._id}/chat/${message.id}/flag`); + return user.post(`/groups/${group._id}/chat/${message.id}/flag`); }) .then(() => { - return expect(api.post(`/groups/${group._id}/chat/${message.id}/flag`)) + return expect(user.post(`/groups/${group._id}/chat/${message.id}/flag`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index cda9df4e1c..40f530174c 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -1,13 +1,11 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; import _ from 'lodash'; describe('POST /chat/:chatId/like', () => { let user; - let api; let group; let testMessage = 'Test Message'; @@ -18,10 +16,9 @@ describe('POST /chat/:chatId/like', () => { return generateUser({balance: 1}).then((generatedUser) => { user = generatedUser; - api = requester(user); }) .then(() => { - return api.post('/groups', { + return user.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, @@ -33,7 +30,7 @@ describe('POST /chat/:chatId/like', () => { }); it('Returns an error when chat message is not found', () => { - return expect(api.post(`/groups/${group._id}/chat/incorrectMessage/like`)) + return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/like`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -42,9 +39,9 @@ describe('POST /chat/:chatId/like', () => { }); it('Returns an error when user tries to like their own message', () => { - return api.post(`/groups/${group._id}/chat`, { message: testMessage}) + return user.post(`/groups/${group._id}/chat`, { message: testMessage}) .then((result) => { - return expect(api.post(`/groups/${group._id}/chat/${result.message.id}/like`)) + return expect(user.post(`/groups/${group._id}/chat/${result.message.id}/like`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -54,20 +51,18 @@ describe('POST /chat/:chatId/like', () => { }); it('Likes a chat', () => { - let api2; let message; - return generateUser().then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + return generateUser().then((anotherUser) => { + return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { message = result.message; - return api.post(`/groups/${group._id}/chat/${message.id}/like`); + return user.post(`/groups/${group._id}/chat/${message.id}/like`); }) .then((result) => { expect(result.likes[user._id]).to.equal(true); - return api.get(`/groups/${group._id}`); + return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); @@ -76,24 +71,22 @@ describe('POST /chat/:chatId/like', () => { }); it('Unlikes a chat', () => { - let api2; let message; - return generateUser().then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + return generateUser().then((anotherUser) => { + return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { message = result.message; - return api.post(`/groups/${group._id}/chat/${message.id}/like`); + return user.post(`/groups/${group._id}/chat/${message.id}/like`); }) .then((result) => { expect(result.likes[user._id]).to.equal(true); - return api.post(`/groups/${group._id}/chat/${message.id}/like`); + return user.post(`/groups/${group._id}/chat/${message.id}/like`); }) .then((result) => { expect(result.likes[user._id]).to.equal(false); - return api.get(`/groups/${group._id}`); + return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index f427ac460d..37403ed12d 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -1,17 +1,14 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; describe('POST /chat', () => { let user; - let api; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -20,18 +17,16 @@ describe('POST /chat', () => { let groupType = 'guild'; let groupPrivacy = 'public'; let testMessage = ''; - let api2; - return generateUser({balance: 1}).then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post('/groups', { + return generateUser({balance: 1}).then((anotherUser) => { + return anotherUser.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, }); }) .then((group) => { - return expect(api.post(`/groups/${group._id}/chat`, { message: testMessage})) + return expect(user.post(`/groups/${group._id}/chat`, { message: testMessage})) .to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -42,7 +37,7 @@ describe('POST /chat', () => { it('Returns an error when group is not found', () => { let testMessage = 'Test Message'; - return expect(api.post('/groups/nvalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ + return expect(user.post('/groups/nvalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), @@ -54,18 +49,19 @@ describe('POST /chat', () => { let groupType = 'guild'; let groupPrivacy = 'public'; let testMessage = 'Test Message'; - let api2; + let userWithoutChat; return generateUser({balance: 1, 'flags.chatRevoked': true}).then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post('/groups', { + userWithoutChat = generatedUser; + + return userWithoutChat.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, }); }) .then((group) => { - return expect(api2.post(`/groups/${group._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ + return expect(userWithoutChat.post(`/groups/${group._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: 'Your chat privileges have been revoked.', @@ -78,18 +74,19 @@ describe('POST /chat', () => { let groupType = 'guild'; let groupPrivacy = 'public'; let testMessage = 'Test Message'; - let api2; + let anotherUser; return generateUser({balance: 1}).then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post('/groups', { + anotherUser = generatedUser; + + return anotherUser.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, }); }) .then((group) => { - return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { expect(result.message.id).to.exist; diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 85269fe6b7..f77e0fb5ae 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -1,16 +1,14 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; describe('POST /group', () => { - let user, api; + let user; beforeEach(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -20,7 +18,7 @@ describe('POST /group', () => { let groupType = 'guild'; return expect( - api.post('/groups', { + user.post('/groups', { name: groupName, type: groupType }) @@ -38,8 +36,7 @@ describe('POST /group', () => { let groupType = 'guild'; return generateUser({balance: 1}).then((generatedUser) => { - let api2 = requester(generatedUser); - return api2.post('/groups', { + return generatedUser.post('/groups', { name: groupName, type: groupType }); @@ -60,8 +57,7 @@ describe('POST /group', () => { let tmpUser; return generateUser({balance: 1}).then((generatedUser) => { - let api2 = requester(generatedUser); - return api2.post('/groups', { + return generatedUser.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy @@ -82,7 +78,7 @@ describe('POST /group', () => { let groupName = "Test Party"; let groupType = "party"; - return api.post('/groups', { + return user.post('/groups', { name: groupName, type: groupType }) @@ -93,28 +89,24 @@ describe('POST /group', () => { }) }); - it('prevents user in a party from creating a party', () => { + it('prevents user in a party from creating another party', () => { let tmpUser; let groupName = "Test Party"; let groupType = "party"; return generateUser().then((generatedUser) => { tmpUser = generatedUser; - api = requester(tmpUser); - return api.post('/groups', { + return tmpUser.post('/groups', { name: groupName, type: groupType }); }) .then(() => { - return expect(api.post('/groups')).to.eventually.be.rejected.and.eql({ + return expect(tmpUser.post('/groups')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageGroupAlreadyInParty'), }); - }) - .then(() => { - api = requester(user); }); }); }); From 59b8ba0c85a2b6129ee80d658c8c527aad40dc38 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 30 Dec 2015 17:14:20 +0100 Subject: [PATCH 291/976] add TODOs to tavern creation --- website/src/models/group.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index f425a6a667..376c3fbd1b 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -513,9 +513,8 @@ model.count({_id: 'habitrpg'}, (err, ct) => { if (ct > 0) return; new model({ // eslint-disable-line new-cap - _id: 'habitrpg', - chat: [], - leader: '9', + _id: 'habitrpg', // TODO hmm this will probably break everything + leader: '9', // TODO change this user id name: 'HabitRPG', type: 'guild', privacy: 'public', From 25b0c3be2bfc46675c57549b648f5de714d9462a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 30 Dec 2015 22:59:36 +0100 Subject: [PATCH 292/976] porh auth controller to es7 async functions --- .eslintrc | 14 +- package.json | 5 +- website/src/controllers/api-v3/auth.js | 243 +++++++++++++------------ website/src/libs/api-v3/setupRoutes.js | 8 +- website/src/models/group.js | 2 +- 5 files changed, 144 insertions(+), 128 deletions(-) diff --git a/.eslintrc b/.eslintrc index 83cd0067d1..c124bdda89 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,4 +1,6 @@ { + "parser": "babel-eslint", + "plugins": ["babel"], "rules": { "indent": [2, 2, {"SwitchCase": 1}], "quotes": [2, "single"], @@ -43,7 +45,7 @@ "no-self-compare": 2, "no-return-assign": 2, "no-redeclare": 2, - "strict": [2, "global"], + "strict": [0, "global"], "no-delete-var": 2, "no-label-var": 2, "no-shadow-restricted-names": 2, @@ -64,7 +66,7 @@ "no-dupe-class-members": 2, "no-this-before-super": 2, "no-var": 2, - "object-shorthand": 2, + "object-shorthand": 0, "prefer-const": 0, "prefer-spread": 2, "prefer-template": 2, @@ -81,7 +83,7 @@ "block-spacing": [2, "always"], "key-spacing": [2, {"beforeColon": false, "afterColon": true}], "max-nested-callbacks": [2, 3], - "new-cap": 2, + "new-cap": 0, "new-parens": 2, "newline-after-var": 0, "no-array-constructor": 2, @@ -106,7 +108,11 @@ "space-unary-ops": 2, "spaced-comment": [2, "always", { "exceptions": ["-"]}], "padded-blocks": [2, "never"], - "no-multiple-empty-lines": [2, {"max": 2}] + "no-multiple-empty-lines": [2, {"max": 2}], + "generator-star-spacing": 0, + "babel/new-cap": 2, + "babel/object-shorthand": 2, + "babel/no-await-in-loop": 2, }, "env": { "es6": true, diff --git a/package.json b/package.json index 2201a372c5..434c63fde7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "async": "^1.5.0", "aws-sdk": "^2.0.25", "babel-core": "^5.8.34", + "babel-eslint": "^4.1.6", "babelify": "^6.x.x", "body-parser": "^1.14.1", "bower": "~1.3.12", @@ -21,6 +22,7 @@ "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", "domain-middleware": "~0.1.0", + "eslint-plugin-babel": "^3.0.0", "estraverse": "^4.1.1", "express": "~4.13.3", "express-csv": "~0.6.0", @@ -110,7 +112,7 @@ "event-stream": "^3.2.2", "expect.js": "~0.2.0", "istanbul": "^0.3.14", - "phantomjs": "^1.9", + "phantomjs": "^1.9.18", "karma": "~0.13.15", "karma-babel-preprocessor": "^5.0.0", "karma-chai-plugins": "~0.6.0", @@ -123,7 +125,6 @@ "mongodb": "^2.0.46", "mongoskin": "~0.6.1", "nock": "^2.17.0", - "phantomjs": "^1.9.18", "protractor": "~2.5.1", "rewire": "^2.3.3", "rimraf": "^2.4.3", diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index b0ab6b0674..abaf84e7cb 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -5,6 +5,7 @@ import cron from '../../middlewares/api-v3/cron'; import { NotAuthorized, } from '../../libs/api-v3/errors'; +import Q from 'q'; import * as passwordUtils from '../../libs/api-v3/password'; import { model as User } from '../../models/user'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; @@ -29,7 +30,7 @@ api.registerLocal = { method: 'POST', middlewares: [authWithHeaders(true)], url: '/user/auth/local/register', - handler (req, res, next) { + async handler (req, res) { let fbUser = res.locals.user; // If adding local auth to social user // TODO check user doesn't have local auth req.checkBody({ @@ -45,7 +46,7 @@ api.registerLocal = { }); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let { email, username, password } = req.body; @@ -55,72 +56,70 @@ api.registerLocal = { let lowerCaseUsername = username.toLowerCase(); // Search for duplicates using lowercase version of username - User.findOne({$or: [ + let user = User.findOne({$or: [ {'auth.local.email': email}, {'auth.local.lowerCaseUsername': lowerCaseUsername}, - ]}, {'auth.local': 1}) - .exec() - .then((user) => { - if (user) { - if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken')); - // Check that the lowercase username isn't already used - if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken')); - } + ]}, {'auth.local': 1}).exec(); - let salt = passwordUtils.makeSalt(); - let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase - let newUser = { - auth: { - local: { - username, - lowerCaseUsername, - email, - salt, - hashed_password, // eslint-disable-line camelcase - }, + if (user) { + if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken')); + // Check that the lowercase username isn't already used + if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken')); + } + + let salt = passwordUtils.makeSalt(); + let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase + let newUser = { + auth: { + local: { + username, + lowerCaseUsername, + email, + salt, + hashed_password, // eslint-disable-line camelcase }, - preferences: { - language: req.language, - }, - }; + }, + preferences: { + language: req.language, + }, + }; - if (fbUser) { - if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal')); - fbUser.auth.local = newUser; - return fbUser.save(); - } else { - newUser = new User(newUser); - newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? - return newUser.save(); - } - }) - .then((savedUser) => { - if (savedUser.auth.facebook.id) { - res.respond(200, savedUser.auth.local); // TODO make sure this used .toJSON and removes private fields - } else { - res.respond(201, savedUser); - } + let savedUser; - // Clean previous email preferences - EmailUnsubscription - .remove({email: savedUser.auth.local.email}) - .then(() => sendTxnEmail(savedUser, 'welcome')); + if (fbUser) { + if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal')); + fbUser.auth.local = newUser; + savedUser = await fbUser.save(); + } else { + newUser = new User(newUser); + newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? + savedUser = await newUser.save(); + } - if (!savedUser.auth.facebook.id) { - res.analytics.track('register', { - category: 'acquisition', - type: 'local', - gaLabel: 'local', - uuid: savedUser._id, - }); - } - }) - .catch(next); + if (savedUser.auth.facebook.id) { + res.respond(200, savedUser.auth.local); // TODO make sure this used .toJSON and removes private fields + } else { + res.respond(201, savedUser); + } + + // Clean previous email preferences + EmailUnsubscription + .remove({email: savedUser.auth.local.email}) + .then(() => sendTxnEmail(savedUser, 'welcome')); + + if (!savedUser.auth.facebook.id) { + res.analytics.track('register', { + category: 'acquisition', + type: 'local', + gaLabel: 'local', + uuid: savedUser._id, + }); + } }, }; -function _loginRes (user, req, res, next) { - if (user.auth.blocked) return next(new NotAuthorized(res.t('accountSuspended', {userId: user._id}))); +function _loginRes (user, req, res) { + if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id})); res.respond(200, {id: user._id, apiToken: user.apiToken}); } @@ -140,7 +139,7 @@ api.loginLocal = { method: 'POST', url: '/user/auth/local/login', middlewares: [cron], - handler (req, res, next) { + async handler (req, res) { req.checkBody({ username: { notEmpty: true, @@ -153,7 +152,7 @@ api.loginLocal = { }); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; req.sanitizeBody('username').trim(); req.sanitizeBody('password').trim(); @@ -167,75 +166,82 @@ api.loginLocal = { login = {'auth.local.username': username}; } - User - .findOne(login, {auth: 1, apiToken: 1}).exec() - .then((user) => { - // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); - let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); + let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec(); - if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials')); - _loginRes(user, ...arguments); - }) - .catch(next); + // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); + let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); + + if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials')); + _loginRes(user, ...arguments); }, }; +function _passportFbProfile (accessToken) { + let deferred = Q.defer(); + + passport._strategies.facebook.userProfile(accessToken, (err, profile) => { + if (err) { + deferred.rejec(); + } else { + deferred.resolve(profile); + } + }); + + return deferred.promise; +} + // Called as a callback by Facebook (or other social providers) api.loginSocial = { method: 'POST', url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 middlewares: [cron], - handler (req, res, next) { + async handler (req, res) { let accessToken = req.body.authResponse.access_token; let network = req.body.network; - if (network !== 'facebook') return next(new NotAuthorized(res.t('onlyFbSupported'))); + if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); - passport._strategies[network].userProfile(accessToken, (err, profile) => { - if (err) return next(err); + // TODO promise here? + // TODO throwing inside a callback still bubblet up to the async handler? + let profile = await _passportFbProfile(accessToken); - User.findOne({ - [`auth.${network}.id`]: profile.id, - }, {_id: 1, apiToken: 1, auth: 1}).exec() - .then((user) => { - // User already signed up - if (user) { - return _loginRes(user, ...arguments); - } else { // Create new user - user = new User({ - auth: { - [network]: profile, - }, - preferences: { - language: req.language, - }, - }); - user.registeredThrough = req.headers['x-client']; + let user = await User.findOne({ + [`auth.${network}.id`]: profile.id, + }, {_id: 1, apiToken: 1, auth: 1}).exec(); - user.save() - .then((savedUser) => { - _loginRes(user, ...arguments); + // User already signed up + if (user) { + _loginRes(user, ...arguments); + } else { // Create new user + user = new User({ + auth: { + [network]: profile, + }, + preferences: { + language: req.language, + }, + }); + user.registeredThrough = req.headers['x-client']; - // Clean previous email preferences - if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) { - EmailUnsubscription - .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()}) - .exec() - .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks - } + let savedUser = await user.save(); - res.analytics.track('register', { - category: 'acquisition', - type: network, - gaLabel: network, - uuid: savedUser._id, - }); - }) - .catch(next); - } - }) - .catch(next); - }); + _loginRes(user, ...arguments); + + // Clean previous email preferences + if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) { + EmailUnsubscription + .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()}) + .exec() + .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks + } + + res.analytics.track('register', { + category: 'acquisition', + type: network, + gaLabel: network, + uuid: savedUser._id, + }); + } }, }; @@ -251,17 +257,16 @@ api.deleteSocial = { method: 'DELETE', url: '/user/auth/social/:network', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let network = req.params.network; - if (network !== 'facebook') return next(new NotAuthorized(res.t('onlyFbSupported'))); - if (!user.auth.local.username) return next(new NotAuthorized(res.t('cantDetachFb'))); // TODO move to model validation? + if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); + if (!user.auth.local.username) throw new NotAuthorized(res.t('cantDetachFb')); - User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}) - .exec() - .then(() => res.respond(200)) - .catch(next); + await User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}).exec(); + + res.respond(200, {}); }, }; diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index bdb587d50c..bfe2ab93e7 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -4,7 +4,11 @@ import express from 'express'; import _ from 'lodash'; const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); -let router = express.Router(); // eslint-disable-line new-cap +let router = express.Router(); // eslint-disable-line babel/new-cap + +// Wrapper function to handler `async` route handlers that return promises +// It takes the async function, execute it and pass any error to next (args[2]) +let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]); fs .readdirSync(CONTROLLERS_PATH) @@ -17,7 +21,7 @@ fs let {method, url, middlewares = [], handler} = action; method = method.toLowerCase(); - router[method](url, ...middlewares, handler); + router[method](url, ...middlewares, _wrapAsyncFn(handler)); }); }); diff --git a/website/src/models/group.js b/website/src/models/group.js index 376c3fbd1b..eb85e7662f 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -512,7 +512,7 @@ model.count({_id: 'habitrpg'}, (err, ct) => { if (err) throw err; if (ct > 0) return; - new model({ // eslint-disable-line new-cap + new model({ // eslint-disable-line babel/new-cap _id: 'habitrpg', // TODO hmm this will probably break everything leader: '9', // TODO change this user id name: 'HabitRPG', From f76c9d025f9c0b2207e7e7617ce1c27cb8e70221 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 30 Dec 2015 23:00:54 +0100 Subject: [PATCH 293/976] remove comments --- website/src/controllers/api-v3/auth.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index abaf84e7cb..a1d2c1b82a 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -201,8 +201,6 @@ api.loginSocial = { if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); - // TODO promise here? - // TODO throwing inside a callback still bubblet up to the async handler? let profile = await _passportFbProfile(accessToken); let user = await User.findOne({ From aebe2fa400841632a9fc935782dc4caebd76a9bd Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 31 Dec 2015 11:46:21 +0100 Subject: [PATCH 294/976] finish porting to async/away syntax --- website/src/controllers/api-v3/auth.js | 2 +- website/src/controllers/api-v3/challenges.js | 160 +++---- website/src/controllers/api-v3/chat.js | 259 +++++------ website/src/controllers/api-v3/groups.js | 315 ++++++-------- website/src/controllers/api-v3/tags.js | 43 +- website/src/controllers/api-v3/tasks.js | 431 +++++++++---------- website/src/controllers/api-v3/user.js | 2 +- 7 files changed, 560 insertions(+), 652 deletions(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index a1d2c1b82a..c66b254e9b 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -56,7 +56,7 @@ api.registerLocal = { let lowerCaseUsername = username.toLowerCase(); // Search for duplicates using lowercase version of username - let user = User.findOne({$or: [ + let user = await User.findOne({$or: [ {'auth.local.email': email}, {'auth.local.lowerCaseUsername': lowerCaseUsername}, ]}, {'auth.local': 1}).exec(); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 1d950446fa..5a19663f74 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -27,75 +27,72 @@ api.createChallenge = { method: 'POST', url: '/challenges', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkBody('group', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let groupId = req.body.group; let prize = req.body.prize; - Group.getGroup(user, groupId, '-chat') - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, groupId, '-chat'); + if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { - throw new NotAuthorized(res.t('onlyGroupLeaderChal')); + if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { + throw new NotAuthorized(res.t('onlyGroupLeaderChal')); + } + + if (groupId === 'habitrpg' && prize < 1) { + throw new NotAuthorized(res.t('pubChalsMinPrize')); + } + + if (prize > 0) { + let groupBalance = group.balance && group.leader === user._id ? group.balance : 0; + let prizeCost = prize / 4; + + if (prizeCost > user.balance + groupBalance) { + throw new NotAuthorized(res.t('cantAfford')); } - if (groupId === 'habitrpg' && prize < 1) { - throw new NotAuthorized(res.t('pubChalsMinPrize')); + if (groupBalance >= prizeCost) { + // Group pays for all of prize + group.balance -= prizeCost; + } else if (groupBalance > 0) { + // User pays remainder of prize cost after group + let remainder = prizeCost - group.balance; + group.balance = 0; + user.balance -= remainder; + } else { + // User pays for all of prize + user.balance -= prizeCost; } + } - if (prize > 0) { - let groupBalance = group.balance && group.leader === user._id ? group.balance : 0; - let prizeCost = prize / 4; + group.challengeCount += 1; - if (prizeCost > user.balance + groupBalance) { - throw new NotAuthorized(res.t('cantAfford')); - } + let tasks = req.body.tasks || []; // TODO validate + req.body.leader = user._id; + req.body.official = user.contributor.admin && req.body.official; + let challenge = new Challenge(Challenge.sanitize(req.body)); - if (groupBalance >= prizeCost) { - // Group pays for all of prize - group.balance -= prizeCost; - } else if (groupBalance > 0) { - // User pays remainder of prize cost after group - let remainder = prizeCost - group.balance; - group.balance = 0; - user.balance -= remainder; - } else { - // User pays for all of prize - user.balance -= prizeCost; - } - } + let toSave = tasks.map(tasks, taskToCreate => { + // TODO validate type + let task = new Tasks[taskToCreate.type](Tasks.Task.sanitizeCreate(taskToCreate)); + task.challenge.id = challenge._id; + challenge.tasksOrder[`${task.type}s`].push(task._id); + return task.save(); + }); - group.challengeCount += 1; + toSave.unshift(challenge, group); - let tasks = req.body.tasks || []; // TODO validate - req.body.leader = user._id; - req.body.official = user.contributor.admin && req.body.official; - let challenge = new Challenge(Challenge.sanitize(req.body)); + let results = await Q.all(toSave); + let savedChal = results[0]; - let toSave = tasks.map(tasks, taskToCreate => { - // TODO validate type - let task = new Tasks[taskToCreate.type](Tasks.Task.sanitizeCreate(taskToCreate)); - task.challenge.id = challenge._id; - challenge.tasksOrder[`${task.type}s`].push(task._id); - return task.save(); - }); - - toSave.unshift(challenge, group); - return Q.all(toSave); - }) - .then(results => { - let savedChal = results[0]; - return savedChal.syncToUser(user) // (it also saves the user) - .then(() => res.respond(201, savedChal)); - }) - .catch(next); + await savedChal.syncToUser(user); // (it also saves the user) + res.respond(201, savedChal); }, }; @@ -111,14 +108,14 @@ api.getChallenges = { method: 'GET', url: '/challenges', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let groups = user.guilds || []; if (user.party._id) groups.push(user.party._id); groups.push('habitrpg'); // Public challenges - Challenge.find({ + let challenges = await Challenge.find({ $or: [ {_id: {$in: user.challenges}}, // Challenges where the user is participating {group: {$in: groups}}, // Challenges in groups where I'm a member @@ -130,11 +127,9 @@ api.getChallenges = { // TODO populate // .populate('group', '_id name type') // .populate('leader', 'profile.name') - .exec() - .then(challenges => { - res.respond(200, challenges); - }) - .catch(next); + .exec(); + + res.respond(200, challenges); }, }; @@ -190,7 +185,7 @@ function _closeChal (challenge, broken = {}) { })); } - return Q.allSettled(tasks); // TODO look if allSettle could be useful somewhere else + return Q.allSettled(tasks); // TODO look if allSettled could be useful somewhere else // TODO catch and handle } @@ -206,25 +201,21 @@ api.deleteChallenge = { method: 'DELETE', url: '/challenges/:challengeId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Challenge.findOne({_id: req.params.challengeId}) - .exec() - .then(challenge => { - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); - res.respond(200, {}); - // Close channel in background - _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); - }) - .catch(next); + res.respond(200, {}); + // Close channel in background + _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); }, }; @@ -240,34 +231,25 @@ api.selectChallengeWinner = { method: 'POST', url: '/challenges/:challengeId/selectWinner/:winnerId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; - let challenge; req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Challenge.findOne({_id: req.params.challengeId}) - .exec() - .then(challengeFound => { - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); - challenge = challengeFound; + let winner = await User.findOne({_id: req.params.winnerId}).exec(); + if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.parama.winnerId})); - return User.findOne({_id: req.params.winnerId}).exec(); - }) - .then(winner => { - if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.parama.winnerId})); - - res.respond(200, {}); - // Close channel in background - _closeChal(challenge, {broken: 'CHALLENGE_DELETED', winner}); - }) - .catch(next); + res.respond(200, {}); + // Close channel in background + _closeChal(challenge, {broken: 'CHALLENGE_DELETED', winner}); }, }; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 2bfb4c6847..48dc2e440d 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -25,21 +25,18 @@ api.getChat = { method: 'GET', url: '/groups/:groupId/chat', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId, 'chat') - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId, 'chat'); + if (!group) throw new NotFound(res.t('groupNotFound')); - res.respond(200, group.chat); - }) - .catch(next); + res.respond(200, group.chat); }, }; @@ -59,7 +56,7 @@ api.postChat = { method: 'POST', url: '/groups/:groupId/chat', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; let chatUpdated; @@ -68,34 +65,31 @@ api.postChat = { req.checkBody('message', res.t('messageGroupChatBlankMessage')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, groupId) - .then((group) => { - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party' && user.flags.chatRevoked) { - throw new NotFound('Your chat privileges have been revoked.'); - } + let group = await Group.getGroup(user, groupId); - let lastClientMsg = req.query.previousMsg; - chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party' && user.flags.chatRevoked) { + throw new NotFound('Your chat privileges have been revoked.'); + } - group.sendChat(req.body.message, user); + let lastClientMsg = req.query.previousMsg; + chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; - if (group.type === 'party') { - user.party.lastMessageSeen = group.chat[0].id; - user.save(); - } - return group.save(); - }) - .then((group) => { - if (chatUpdated) { - res.respond(200, {chat: group.chat}); - } else { - res.respond(200, {message: group.chat[0]}); - } - }) - .catch(next); + group.sendChat(req.body.message, user); + + if (group.type === 'party') { + user.party.lastMessageSeen = group.chat[0].id; + user.save(); // TODO why this is non-blocking? must catch? + } + + let savedGroup = await group.save(); + if (chatUpdated) { + res.respond(200, {chat: savedGroup.chat}); + } else { + res.respond(200, {message: savedGroup.chat[0]}); + } }, }; @@ -114,42 +108,35 @@ api.likeChat = { method: 'Post', url: '/groups/:groupId/chat/:chatId/like', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; - let message; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, groupId) - .then((group) => { - if (!group) throw new NotFound(res.t('groupNotFound')); - message = _.find(group.chat, {id: req.params.chatId}); - if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + let group = await Group.getGroup(user, groupId); + if (!group) throw new NotFound(res.t('groupNotFound')); - if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); + let message = _.find(group.chat, {id: req.params.chatId}); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); - let update = {$set: {}}; + let update = {$set: {}}; - if (!message.likes) message.likes = {}; + if (!message.likes) message.likes = {}; - message.likes[user._id] = !message.likes[user._id]; - update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id]; + message.likes[user._id] = !message.likes[user._id]; + update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id]; - return Group.update( - {_id: group._id, 'chat.id': message.id}, - update - ); - }) - .then((groupSaved) => { - if (!groupSaved) throw new NotFound(res.t('groupNotFound')); - res.respond(200, message); - }) - .catch(next); + await Group.update( + {_id: group._id, 'chat.id': message.id}, + update + ); + res.respond(200, message); }, }; @@ -168,116 +155,104 @@ api.flagChat = { method: 'Post', url: '/groups/:groupId/chat/:chatId/flag', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; - let message; - let group; - let author; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, groupId) - .then((groupFound) => { - if (!groupFound) throw new NotFound(res.t('groupNotFound')); - group = groupFound; - message = _.find(group.chat, {id: req.params.chatId}); + let group = await Group.getGroup(user, groupId); + if (!group) throw new NotFound(res.t('groupNotFound')); + let message = _.find(group.chat, {id: req.params.chatId}); - if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); - if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage')); + if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage')); - return User.findOne({_id: message.uuid}, {auth: 1}); - }) - .then((foundAuthor) => { - author = foundAuthor; + let author = await User.findOne({_id: message.uuid}, {auth: 1}); - let update = {$set: {}}; + let update = {$set: {}}; - // Log user ids that have flagged the message - if (!message.flags) message.flags = {}; - if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported')); - message.flags[user._id] = true; - update.$set[`chat.$.flags.${user._id}`] = true; + // Log user ids that have flagged the message + if (!message.flags) message.flags = {}; + if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported')); + message.flags[user._id] = true; + update.$set[`chat.$.flags.${user._id}`] = true; - // Log total number of flags (publicly viewable) - if (!message.flagCount) message.flagCount = 0; - if (user.contributor.admin) { - // Arbitraty amount, higher than 2 - message.flagCount = 5; - } else { - message.flagCount++; - } - update.$set['chat.$.flagCount'] = message.flagCount; + // Log total number of flags (publicly viewable) + if (!message.flagCount) message.flagCount = 0; + if (user.contributor.admin) { + // Arbitraty amount, higher than 2 + message.flagCount = 5; + } else { + message.flagCount++; + } + update.$set['chat.$.flagCount'] = message.flagCount; - return Group.update( - {_id: group._id, 'chat.id': message.id}, - update - ); - }) - .then((group2) => { - if (!group2) throw new NotFound(res.t('groupNotFound')); + await Group.update( + {_id: group._id, 'chat.id': message.id}, + update + ); - let addressesToSendTo = nconf.get('FLAG_REPORT_EMAIL'); - addressesToSendTo = typeof addressesToSendTo === 'string' ? JSON.parse(addressesToSendTo) : addressesToSendTo; + let addressesToSendTo = nconf.get('FLAG_REPORT_EMAIL'); + addressesToSendTo = typeof addressesToSendTo === 'string' ? JSON.parse(addressesToSendTo) : addressesToSendTo; - if (Array.isArray(addressesToSendTo)) { - addressesToSendTo = addressesToSendTo.map((email) => { - return {email, canSend: true}; - }); - } else { - addressesToSendTo = {email: addressesToSendTo}; - } + if (Array.isArray(addressesToSendTo)) { + addressesToSendTo = addressesToSendTo.map((email) => { + return {email, canSend: true}; + }); + } else { + addressesToSendTo = {email: addressesToSendTo}; + } - let reporterEmailContent; - if (user.auth.local) { - reporterEmailContent = user.auth.local.email; - } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) { - reporterEmailContent = user.auth.facebook.emails[0].value; - } + let reporterEmailContent; + if (user.auth.local) { + reporterEmailContent = user.auth.local.email; + } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) { + reporterEmailContent = user.auth.facebook.emails[0].value; + } - let authorEmailContent; - if (author.auth.local) { - authorEmailContent = author.auth.local.email; - } else if (author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) { - authorEmailContent = author.auth.facebook.emails[0].value; - } + let authorEmailContent; + if (author.auth.local) { + authorEmailContent = author.auth.local.email; + } else if (author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) { + authorEmailContent = author.auth.facebook.emails[0].value; + } - let groupUrl; - if (group._id === 'habitrpg') { - groupUrl = '/#/options/groups/tavern'; - } else if (group.type === 'guild') { - groupUrl = `/#/options/groups/guilds/{$group._id}`; - } else { - groupUrl = 'party'; - } + let groupUrl; + if (group._id === 'habitrpg') { + groupUrl = '/#/options/groups/tavern'; + } else if (group.type === 'guild') { + groupUrl = `/#/options/groups/guilds/{$group._id}`; + } else { + groupUrl = 'party'; + } - sendTxn(addressesToSendTo, 'flag-report-to-mods', [ - {name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()}, - {name: 'MESSAGE_TEXT', content: message.text}, + sendTxn(addressesToSendTo, 'flag-report-to-mods', [ + {name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()}, + {name: 'MESSAGE_TEXT', content: message.text}, - {name: 'REPORTER_USERNAME', content: user.profile.name}, - {name: 'REPORTER_UUID', content: user._id}, - {name: 'REPORTER_EMAIL', content: reporterEmailContent}, - {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId={$user._id}`}, + {name: 'REPORTER_USERNAME', content: user.profile.name}, + {name: 'REPORTER_UUID', content: user._id}, + {name: 'REPORTER_EMAIL', content: reporterEmailContent}, + {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId={$user._id}`}, - {name: 'AUTHOR_USERNAME', content: message.user}, - {name: 'AUTHOR_UUID', content: message.uuid}, - {name: 'AUTHOR_EMAIL', content: authorEmailContent}, - {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId={$message.uuid}`}, + {name: 'AUTHOR_USERNAME', content: message.user}, + {name: 'AUTHOR_UUID', content: message.uuid}, + {name: 'AUTHOR_EMAIL', content: authorEmailContent}, + {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId={$message.uuid}`}, - {name: 'GROUP_NAME', content: group.name}, - {name: 'GROUP_TYPE', content: group.type}, - {name: 'GROUP_ID', content: group._id}, - {name: 'GROUP_URL', content: groupUrl}, - ]); - res.respond(200, message); - }) - .catch(next); + {name: 'GROUP_NAME', content: group.name}, + {name: 'GROUP_TYPE', content: group.type}, + {name: 'GROUP_ID', content: group._id}, + {name: 'GROUP_URL', content: groupUrl}, + ]); + + res.respond(200, message); }, }; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8a3ce9630b..1edf18b026 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -29,36 +29,31 @@ api.createGroup = { method: 'POST', url: '/groups', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let group = new Group(Group.sanitize(req.body)); // TODO validate empty req.body group.leader = user._id; if (group.type === 'guild') { - if (user.balance < 1) return next(new NotAuthorized(res.t('messageInsufficientGems'))); + if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems')); group.balance = 1; user.balance--; user.guilds.push(group._id); } else { - if (user.party._id) return next(new NotAuthorized(res.t('messageGroupAlreadyInParty'))); + if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty')); user.party._id = group._id; } - Q.all([ - user.save(), - group.save(), - ]).then(results => { - let savedGroup = results[1]; + let results = await Q.all([user.save(), group.save()]); + let savedGroup = results[1]; - firebase.updateGroupData(savedGroup); - firebase.addUserToGroup(savedGroup._id, user._id); - return res.respond(201, savedGroup); // TODO populate - }) - .catch(next); + firebase.updateGroupData(savedGroup); + firebase.addUserToGroup(savedGroup._id, user._id); + return res.respond(201, savedGroup); // TODO populate }, }; @@ -76,13 +71,13 @@ api.getGroups = { method: 'GET', url: '/groups', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkQuery('type', res.t('groupTypesRequired')).notEmpty(); // TODO better validation let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; // TODO validate types are acceptable? probably not necessary let types = req.query.type.split(','); @@ -115,16 +110,14 @@ api.getGroups = { }); // If no valid value for type was supplied, return an error - if (queries.length === 0) return next(new BadRequest(res.t('groupTypesRequired'))); + if (queries.length === 0) throw new BadRequest(res.t('groupTypesRequired')); - Q.all(queries) // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 - .then(results => { - res.respond(200, _.reduce(results, (m, v) => { - if (_.isEmpty(v)) return m; - return m.concat(Array.isArray(v) ? v : [v]); - }, [])); - }) - .catch(next); + let results = await Q.all(queries); // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 + + res.respond(200, _.reduce(results, (m, v) => { + if (_.isEmpty(v)) return m; + return m.concat(Array.isArray(v) ? v : [v]); + }, [])); }, }; @@ -142,21 +135,18 @@ api.getGroup = { method: 'GET', url: '/groups/:groupId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId) - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId); + if (!group) throw new NotFound(res.t('groupNotFound')); - res.respond(200, group); - }) - .catch(next); + res.respond(200, group); }, }; @@ -174,28 +164,24 @@ api.updateGroup = { method: 'PUT', url: '/groups/:groupId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId) - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId); + if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); + if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); - _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body))); + _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body))); - return group.save(); - }).then(savedGroup => { - res.respond(200, savedGroup); - firebase.updateGroupData(savedGroup); - }) - .catch(next); + let savedGroup = await group.save(); + res.respond(200, savedGroup); + firebase.updateGroupData(savedGroup); }, }; @@ -213,60 +199,57 @@ api.joinGroup = { method: 'POST', url: '/groups/:groupId/join', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId, '-chat', true) // Do not fetch chat and work even if the user is not yet a member of the group - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId, '-chat', true); // Do not fetch chat and work even if the user is not yet a member of the group + if (!group) throw new NotFound(res.t('groupNotFound')); - let isUserInvited = false; + let isUserInvited = false; - if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { - user.invitations.party = {}; // Clear invite TODO mark modified? + if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { + user.invitations.party = {}; // Clear invite TODO mark modified? - // invite new user to pending quest - if (group.quest.key && !group.quest.active) { - user.party.quest.RSVPNeeded = true; - user.party.quest.key = group.quest.key; - group.quest.members[user._id] = undefined; - group.markModified('quest.members'); - } - - user.party._id = group._id; // Set group as user's party - - isUserInvited = true; - } else if (group.type === 'guild' && user.invitations.guilds) { - let i = _.findIndex(user.invitations.guilds, {id: group._id}); - - if (i !== -1) { - isUserInvited = true; - user.invitations.guilds.splice(i, 1); // Remove invitation - } else { - isUserInvited = group.privacy === 'private' ? false : true; - } + // invite new user to pending quest + if (group.quest.key && !group.quest.active) { + user.party.quest.RSVPNeeded = true; + user.party.quest.key = group.quest.key; + group.quest.members[user._id] = undefined; + group.markModified('quest.members'); } - if (isUserInvited && group.type === 'guild') user.guilds.push(group._id); // Add group to user's guilds - if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite')); + user.party._id = group._id; // Set group as user's party - if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader + isUserInvited = true; + } else if (group.type === 'guild' && user.invitations.guilds) { + let i = _.findIndex(user.invitations.guilds, {id: group._id}); - Q.all([ - group.save(), - user.save(), - User.update({_id: user.invitations.party.inviter}, {$inc: {'items.quests.basilist': 1}}).exec(), // Reward inviter - ]).then(() => { - firebase.addUserToGroup(group._id, user._id); - res.respond(200, {}); // TODO what to return? - }); - }) - .catch(next); + if (i !== -1) { + isUserInvited = true; + user.invitations.guilds.splice(i, 1); // Remove invitation + } else { + isUserInvited = group.privacy === 'private' ? false : true; + } + } + + if (isUserInvited && group.type === 'guild') user.guilds.push(group._id); // Add group to user's guilds + if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite')); + + if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader + + await Q.all([ + group.save(), + user.save(), + User.update({_id: user.invitations.party.inviter}, {$inc: {'items.quests.basilist': 1}}).exec(), // Reward inviter + ]); + + firebase.addUserToGroup(group._id, user._id); + res.respond(200, {}); // TODO what to return? }, }; @@ -285,7 +268,7 @@ api.leaveGroup = { method: 'POST', url: '/groups/:groupId/leave', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); @@ -293,27 +276,24 @@ api.leaveGroup = { req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat + if (!group) throw new NotFound(res.t('groupNotFound')); - // During quests, checke wheter user can leave - if (group.type === 'party') { - if (group.quest && group.quest.leader === user._id) { - throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup')); - } - - if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) { - throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest')); - } + // During quests, checke wheter user can leave + if (group.type === 'party') { + if (group.quest && group.quest.leader === user._id) { + throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup')); } - return group.leave(user, req.query.keep); - }) - .then(() => res.respond(200, {})) - .catch(next); + if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) { + throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest')); + } + } + + await group.leave(user, req.query.keep); + res.respond(200, {}); }, }; @@ -345,71 +325,65 @@ api.removeGroupMember = { method: 'POST', url: '/groups/:groupId/removeMember/:memberId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; - let group; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat - .then(foundGroup => { - group = foundGroup; - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat + if (!group) throw new NotFound(res.t('groupNotFound')); - let uuid = req.query.memberId; + let uuid = req.query.memberId; - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); - if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself')); + if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); + if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself')); - return User.findOne({_id: uuid}).select('party guilds invitations newMessages').exec(); - }).then(member => { - // We're removing the user from a guild or a party? is the user invited only? - let isInGroup = member.party._id === group._id ? 'party' : member.guilds.indexOf(group._id) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary - let isInvited = member.invitations.party.id === group._id ? 'party' : _.findIndex(member.invitations.guilds, {id: group._id}) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary + let member = await User.findOne({_id: uuid}).select('party guilds invitations newMessages').exec(); + // We're removing the user from a guild or a party? is the user invited only? + let isInGroup = member.party._id === group._id ? 'party' : member.guilds.indexOf(group._id) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary + let isInvited = member.invitations.party.id === group._id ? 'party' : _.findIndex(member.invitations.guilds, {id: group._id}) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary - if (isInGroup) { - group.memberCount -= 1; + if (isInGroup) { + group.memberCount -= 1; - if (group.quest && group.quest.leader === member._id) { - group.quest.key = null; - group.quest.leader = null; // TODO markmodified? - } else if (group.quest && group.quest.members) { - // remove member from quest - group.quest.members[member._id] = undefined; - } - - if (isInGroup === 'guild') _.pull(member.guilds, group._id); - if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? - - member.newMessages.group._id = undefined; - - if (group.quest && group.quest.active && group.quest.leader === member._id) { - member.items.quests[group.quest.key] += 1; // TODO why this? - } - } else if (isInvited) { - if (isInvited === 'guild') { - let i = _.findIndex(member.invitations.guilds, {id: group._id}); - if (i !== -1) member.invitations.guilds.splice(i, 1); - } - if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified? - } else { - throw new NotFound(res.t('groupMemberNotFound')); + if (group.quest && group.quest.leader === member._id) { + group.quest.key = null; + group.quest.leader = null; // TODO markmodified? + } else if (group.quest && group.quest.members) { + // remove member from quest + group.quest.members[member._id] = undefined; } - let message = req.query.message; - if (message) _sendMessageToRemoved(group, member, message); + if (isInGroup === 'guild') _.pull(member.guilds, group._id); + if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? - return Q.all([ - member.save(), - group.save(), - ]); - }) - .then(() => res.respond(200, {})) - .catch(next); + member.newMessages.group._id = undefined; + + if (group.quest && group.quest.active && group.quest.leader === member._id) { + member.items.quests[group.quest.key] += 1; // TODO why this? + } + } else if (isInvited) { + if (isInvited === 'guild') { + let i = _.findIndex(member.invitations.guilds, {id: group._id}); + if (i !== -1) member.invitations.guilds.splice(i, 1); + } + if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified? + } else { + throw new NotFound(res.t('groupMemberNotFound')); + } + + let message = req.query.message; + if (message) _sendMessageToRemoved(group, member, message); + + await Q.all([ + member.save(), + group.save(), + ]); + res.respond(200, {}); }, }; @@ -571,32 +545,29 @@ api.inviteToGroup = { method: 'POST', url: '/groups/:groupId/invite', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat TODO other fields too? - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat TODO other fields too? + if (!group) throw new NotFound(res.t('groupNotFound')); - let uuids = req.body.uuids; - let emails = req.body.emails; + let uuids = req.body.uuids; + let emails = req.body.emails; - if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time - throw new BadRequest(res.t('canOnlyInviteEmailUuid')); - } else if (Array.isArray(uuids)) { - // return _inviteByUUIDs(uuids, group, user, req, res, next); - } else if (Array.isArray(emails)) { - // return _inviteByEmails(emails, group, user, req, res, next); - } else { - throw new BadRequest(res.t('canOnlyInviteEmailUuid')); - } - }) - .catch(next); + if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time + throw new BadRequest(res.t('canOnlyInviteEmailUuid')); + } else if (Array.isArray(uuids)) { + // return _inviteByUUIDs(uuids, group, user, req, res, next); + } else if (Array.isArray(emails)) { + // return _inviteByEmails(emails, group, user, req, res, next); + } else { + throw new BadRequest(res.t('canOnlyInviteEmailUuid')); + } }, }; diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 20d3f2f5ed..27191bf0ee 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -20,18 +20,15 @@ api.createTag = { method: 'POST', url: '/tags', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; user.tags.push(Tag.sanitize(req.body)); + let savedUser = await user.save(); - user.save() - .then((savedUser) => { - let l = savedUser.tags.length; - let tag = savedUser.tags[l - 1]; - res.respond(201, tag); - }) - .catch(next); + let l = savedUser.tags.length; + let tag = savedUser.tags[l - 1]; + res.respond(201, tag); }, }; @@ -47,7 +44,7 @@ api.getTags = { method: 'GET', url: '/tags', middlewares: [authWithHeaders(), cron], - handler (req, res) { + async handler (req, res) { let user = res.locals.user; res.respond(200, user.tags); }, @@ -67,16 +64,16 @@ api.getTag = { method: 'GET', url: '/tags/:tagId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let tag = user.tags.id(req.params.tagId); - if (!tag) return next(new NotFound(res.t('tagNotFound'))); + if (!tag) throw new NotFound(res.t('tagNotFound')); res.respond(200, tag); }, }; @@ -95,7 +92,7 @@ api.updateTag = { method: 'PUT', url: '/tags/:tagId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); @@ -104,16 +101,15 @@ api.updateTag = { let tagId = req.params.tagId; let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let tag = user.tags.id(tagId); - if (!tag) return next(new NotFound(res.t('tagNotFound'))); + if (!tag) throw new NotFound(res.t('tagNotFound')); _.merge(tag, Tag.sanitize(req.body)); - user.save() - .then((savedUser) => res.respond(200, savedUser.tags.id(tagId))) - .catch(next); + let savedUser = await user.save(); + res.respond(200, savedUser.tags.id(tagId)); }, }; @@ -131,21 +127,20 @@ api.deleteTag = { method: 'DELETE', url: '/tags/:tagId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let tag = user.tags.id(req.params.tagId); - if (!tag) return next(new NotFound(res.t('tagNotFound'))); + if (!tag) throw new NotFound(res.t('tagNotFound')); tag.remove(); - user.save() - .then(() => res.respond(200, {})) - .catch(next); + await user.save(); + res.respond(200, {}); }, }; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 99e217e401..f67a25b10f 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -29,11 +29,11 @@ api.createTask = { method: 'POST', url: '/tasks', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let user = res.locals.user; let taskType = req.body.type; @@ -43,12 +43,12 @@ api.createTask = { user.tasksOrder[`${taskType}s`].unshift(newTask._id); - Q.all([ + let results = await Q.all([ newTask.save(), user.save(), - ]) - .then((results) => res.respond(201, results[0])) - .catch(next); + ]); + + res.respond(201, results[0]); }, }; @@ -67,11 +67,11 @@ api.getTasks = { method: 'GET', url: '/tasks', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let user = res.locals.user; let query = {userId: user._id}; @@ -95,16 +95,15 @@ api.getTasks = { dateCompleted: 1, }); - Q.all([ + let results = await Q.all([ queryCompleted.exec(), Tasks.Task.find(query).exec(), - ]) - .then((results) => res.respond(200, results[1].concat(results[0]))) - .catch(next); + ]); + + res.respond(200, results[1].concat(results[0])); } else { - Tasks.Task.find(query).exec() - .then((tasks) => res.respond(200, tasks)) - .catch(next); + let tasks = await Tasks.Task.find(query).exec(); + res.respond(200, tasks); } }, }; @@ -123,23 +122,21 @@ api.getTask = { method: 'GET', url: '/tasks/:taskId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - res.respond(200, task); - }) - .catch(next); + }).exec(); + + if (!task) throw new NotFound(res.t('taskNotFound')); + res.respond(200, task); }, }; @@ -157,7 +154,7 @@ api.updateTask = { method: 'PUT', url: '/tasks/:taskId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); @@ -165,37 +162,36 @@ api.updateTask = { // TODO make sure tags are updated correctly (they aren't set as modified!) maybe use specific routes let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); + }).exec(); - // If checklist is updated -> replace the original one - if (req.body.checklist) { - task.checklist = req.body.checklist; - delete req.body.checklist; - } + if (!task) throw new NotFound(res.t('taskNotFound')); - // If tags are updated -> replace the original ones - if (req.body.tags) { - task.tags = req.body.tags; - delete req.body.tags; - } + // If checklist is updated -> replace the original one + if (req.body.checklist) { + task.checklist = req.body.checklist; + delete req.body.checklist; + } - // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, very bad for performances - // TODO regarding comment above make sure other models with nested fields are using this trick too - _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body))); - // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) - // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() - // see https://github.com/Automattic/mongoose/issues/2749 - return task.save(); - }) - .then((savedTask) => res.respond(200, savedTask)) - .catch(next); + // If tags are updated -> replace the original ones + if (req.body.tags) { + task.tags = req.body.tags; + delete req.body.tags; + } + + // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, very bad for performances + // TODO regarding comment above make sure other models with nested fields are using this trick too + _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body))); + // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) + // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() + // see https://github.com/Automattic/mongoose/issues/2749 + + let savedTask = await task.save(); + res.respond(200, savedTask); }, }; @@ -239,81 +235,82 @@ api.scoreTask = { method: 'POST', url: '/tasks/:taskId/score/:direction', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route? let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let user = res.locals.user; let direction = req.params.direction; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); + }).exec(); - let wasCompleted = task.completed; - if (task.type === 'daily' || task.type === 'todo') { - task.completed = direction === 'up'; // TODO move into scoreTask - } + if (!task) throw new NotFound(res.t('taskNotFound')); - let delta = scoreTask({task, user, direction}, req); - // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) - if (direction === 'up') user.fns.randomDrop({task, delta}, req); + let wasCompleted = task.completed; + if (task.type === 'daily' || task.type === 'todo') { + task.completed = direction === 'up'; // TODO move into scoreTask + } - // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list - if (task.type === 'todo') { - if (!wasCompleted && task.completed) { - let i = user.tasksOrder.todos.indexOf(task._id); - if (i !== -1) user.tasksOrder.todos.splice(i, 1); - } else if (wasCompleted && !task.completed) { - let i = user.tasksOrder.todos.indexOf(task._id); - if (i === -1) { - user.tasksOrder.todos.push(task._id); // TODO push at the top? - } else { // If for some reason it hadn't been removed TODO ok? - user.tasksOrder.todos.splice(i, 1); - user.tasksOrder.push(task._id); - } + let delta = scoreTask({task, user, direction}, req); + // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) + if (direction === 'up') user.fns.randomDrop({task, delta}, req); + + // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list + if (task.type === 'todo') { + if (!wasCompleted && task.completed) { + let i = user.tasksOrder.todos.indexOf(task._id); + if (i !== -1) user.tasksOrder.todos.splice(i, 1); + } else if (wasCompleted && !task.completed) { + let i = user.tasksOrder.todos.indexOf(task._id); + if (i === -1) { + user.tasksOrder.todos.push(task._id); // TODO push at the top? + } else { // If for some reason it hadn't been removed TODO ok? + user.tasksOrder.todos.splice(i, 1); + user.tasksOrder.push(task._id); } } + } - return Q.all([ - user.save(), - task.save(), - ]).then((results) => { - let savedUser = results[0]; + let results = await Q.all([ + user.save(), + task.save(), + ]); - let userStats = savedUser.stats.toJSON(); - let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); - res.respond(200, resJsonData); + let savedUser = results[0]; - sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); + let userStats = savedUser.stats.toJSON(); + let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); + res.respond(200, resJsonData); - // TODO test? - if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { - Tasks.Task.findOne({ - _id: task.challenge.taskId, - }).exec() - .then(chalTask => { - chalTask.value += delta; - if (chalTask.type === 'habit' || chalTask.type === 'daily') { - chalTask.history.push({value: chalTask.value, date: Number(new Date())}); - // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? - chalTask.history = preenHistory(user, chalTask.history); - chalTask.markModified('history'); - } + sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); - return chalTask.save(); - }); - // .catch(next) TODO what to do here + // TODO test? + if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { + // Wrapping everything in a try/catch block because if an error occurs using `await` it MUST NOT bubble up because the request has already been handled + try { + let chalTask = await Tasks.Task.findOne({ + _id: task.challenge.taskId, + }).exec(); + + chalTask.value += delta; + if (chalTask.type === 'habit' || chalTask.type === 'daily') { + chalTask.history.push({value: chalTask.value, date: Number(new Date())}); + // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? + chalTask.history = preenHistory(user, chalTask.history); + chalTask.markModified('history'); } - }); - }) - .catch(next); + + await chalTask.save(); + } catch (e) { + // TODO handle + } + } }, }; @@ -334,41 +331,39 @@ api.moveTask = { method: 'POST', url: '/tasks/move/:taskId/to/:position', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let user = res.locals.user; let to = Number(req.params.position); - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type === 'todo' && task.completed) throw new NotFound(res.t('cantMoveCompletedTodo')); - let order = user.tasksOrder[`${task.type}s`]; - let currentIndex = order.indexOf(task._id); + }).exec(); - // If for some reason the task isn't ordered (should never happen) - // or if the task is moved to a non existing position - // or if the task is moved to postion -1 (push to bottom) - // -> push task at end of list - if (currentIndex === -1 || !order[to] || to === -1) { - order.push(task._id); - } else { - let taskToMove = order.splice(currentIndex, 1)[0]; - order.splice(to, 0, taskToMove); - } + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type === 'todo' && task.completed) throw new NotFound(res.t('cantMoveCompletedTodo')); + let order = user.tasksOrder[`${task.type}s`]; + let currentIndex = order.indexOf(task._id); - return user.save(); - }) - .then(() => res.respond(200, {})) // TODO what to return - .catch(next); + // If for some reason the task isn't ordered (should never happen) + // or if the task is moved to a non existing position + // or if the task is moved to postion -1 (push to bottom) + // -> push task at end of list + if (currentIndex === -1 || !order[to] || to === -1) { + order.push(task._id); + } else { + let taskToMove = order.splice(currentIndex, 1)[0]; + order.splice(to, 0, taskToMove); + } + + await user.save(); + res.respond(200, {}); // TODO what to return }, }; @@ -386,28 +381,27 @@ api.addChecklistItem = { method: 'POST', url: '/tasks/:taskId/checklist', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty and is an array let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + }).exec(); - task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); - return task.save(); - }) - .then((savedTask) => res.respond(200, savedTask)) // TODO what to return - .catch(next); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + + task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); + let savedTask = await task.save(); + + res.respond(200, savedTask); // TODO what to return }, }; @@ -426,31 +420,30 @@ api.scoreCheckListItem = { method: 'POST', url: '/tasks/:taskId/checklist/:itemId/score', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + }).exec(); - let item = _.find(task.checklist, {_id: req.params.itemId}); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - if (!item) throw new NotFound(res.t('checklistItemNotFound')); - item.completed = !item.completed; - return task.save(); - }) - .then((savedTask) => res.respond(200, savedTask)) // TODO what to return - .catch(next); + let item = _.find(task.checklist, {_id: req.params.itemId}); + + if (!item) throw new NotFound(res.t('checklistItemNotFound')); + item.completed = !item.completed; + let savedTask = await task.save(); + + res.respond(200, savedTask); // TODO what to return }, }; @@ -469,31 +462,30 @@ api.updateChecklistItem = { method: 'PUT', url: '/tasks/:taskId/checklist/:itemId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + }).exec(); - let item = _.find(task.checklist, {_id: req.params.itemId}); - if (!item) throw new NotFound(res.t('checklistItemNotFound')); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - _.merge(item, Tasks.Task.sanitizeChecklist(req.body)); - return task.save(); - }) - .then((savedTask) => res.respond(200, savedTask)) // TODO what to return - .catch(next); + let item = _.find(task.checklist, {_id: req.params.itemId}); + if (!item) throw new NotFound(res.t('checklistItemNotFound')); + + _.merge(item, Tasks.Task.sanitizeChecklist(req.body)); + let savedTask = await task.save(); + + res.respond(200, savedTask); // TODO what to return }, }; @@ -512,31 +504,30 @@ api.removeChecklistItem = { method: 'DELETE', url: '/tasks/:taskId/checklist/:itemId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + }).exec(); - let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); - if (itemI === -1) throw new NotFound(res.t('checklistItemNotFound')); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - task.checklist.splice(itemI, 1); - return task.save(); - }) - .then(() => res.respond(200, {})) // TODO what to return - .catch(next); + let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); + if (itemI === -1) throw new NotFound(res.t('checklistItemNotFound')); + + task.checklist.splice(itemI, 1); + + await task.save(); + res.respond(200, {}); // TODO what to return }, }; @@ -555,7 +546,7 @@ api.addTagToTask = { method: 'POST', url: '/tasks/:taskId/tags/:tagId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); @@ -563,24 +554,23 @@ api.addTagToTask = { req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - let tagId = req.params.tagId; + }).exec(); - let alreadyTagged = task.tags.indexOf(tagId) !== -1; - if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged')); + if (!task) throw new NotFound(res.t('taskNotFound')); + let tagId = req.params.tagId; - task.tags.push(tagId); - return task.save(); - }) - .then((savedTask) => res.respond(200, savedTask)) // TODO what to return - .catch(next); + let alreadyTagged = task.tags.indexOf(tagId) !== -1; + if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged')); + + task.tags.push(tagId); + + let savedTask = await task.save(); + res.respond(200, savedTask); // TODO what to return }, }; @@ -599,30 +589,29 @@ api.removeTagFromTask = { method: 'DELETE', url: '/tasks/:taskId/tags/:tagId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); + }).exec(); - let tagI = task.tags.indexOf(req.params.tagId); - if (tagI === -1) throw new NotFound(res.t('tagNotFound')); + if (!task) throw new NotFound(res.t('taskNotFound')); - task.tags.splice(tagI, 1); - return task.save(); - }) - .then(() => res.respond(200, {})) // TODO what to return - .catch(next); + let tagI = task.tags.indexOf(req.params.tagId); + if (tagI === -1) throw new NotFound(res.t('tagNotFound')); + + task.tags.splice(tagI, 1); + + await task.save(); + res.respond(200, {}); // TODO what to return }, }; @@ -656,30 +645,26 @@ api.deleteTask = { method: 'DELETE', url: '/tasks/:taskId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.challenge.id) throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + }).exec(); - _removeTaskTasksOrder(user, req.params.taskId); - return Q.all([ - user.save(), - task.remove(), - ]); - }) - .then(() => res.respond(200, {})) - .catch(next); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.challenge.id) throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + + _removeTaskTasksOrder(user, req.params.taskId); + await Q.all([user.save(), task.remove()]); + + res.respond(200, {}); }, }; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index ef6749209c..39efc002f9 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -16,7 +16,7 @@ api.getUser = { method: 'GET', middlewares: [authWithHeaders(), cron], url: '/user', - handler (req, res) { + async handler (req, res) { let user = res.locals.user.toJSON(); // Remove apiToken from resonse TODO make it priavte at the user level? returned in signup/login From bf0a5cba2b46a7b8e358b832ccf06bc14661e73d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 31 Dec 2015 07:37:25 -0600 Subject: [PATCH 295/976] fix: Remove extranous comma in eslint confi --- .eslintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc b/.eslintrc index c124bdda89..7613632120 100644 --- a/.eslintrc +++ b/.eslintrc @@ -112,7 +112,7 @@ "generator-star-spacing": 0, "babel/new-cap": 2, "babel/object-shorthand": 2, - "babel/no-await-in-loop": 2, + "babel/no-await-in-loop": 2 }, "env": { "es6": true, From 16e8104d351186ccbed06360b640b225a5eebedc Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 31 Dec 2015 07:42:15 -0600 Subject: [PATCH 296/976] chore: Move eslint modules to devDependencies --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 434c63fde7..0090cb354d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "async": "^1.5.0", "aws-sdk": "^2.0.25", "babel-core": "^5.8.34", - "babel-eslint": "^4.1.6", "babelify": "^6.x.x", "body-parser": "^1.14.1", "bower": "~1.3.12", @@ -22,7 +21,6 @@ "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", "domain-middleware": "~0.1.0", - "eslint-plugin-babel": "^3.0.0", "estraverse": "^4.1.1", "express": "~4.13.3", "express-csv": "~0.6.0", @@ -108,11 +106,12 @@ "csv": "~0.3.6", "deep-diff": "~0.1.4", "eslint": "^1.9.0", + "babel-eslint": "^4.1.6", + "eslint-plugin-babel": "^3.0.0", "eslint-plugin-mocha": "^1.1.0", "event-stream": "^3.2.2", "expect.js": "~0.2.0", "istanbul": "^0.3.14", - "phantomjs": "^1.9.18", "karma": "~0.13.15", "karma-babel-preprocessor": "^5.0.0", "karma-chai-plugins": "~0.6.0", @@ -125,6 +124,7 @@ "mongodb": "^2.0.46", "mongoskin": "~0.6.1", "nock": "^2.17.0", + "phantomjs": "^1.9.18", "protractor": "~2.5.1", "rewire": "^2.3.3", "rimraf": "^2.4.3", From 6f220c686954bc0feeadfc58422afa6ac9f836ae Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 30 Dec 2015 21:08:42 -0600 Subject: [PATCH 297/976] lint: Turn on linting for v3 group tests --- tasks/gulp-eslint.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 46aece05a6..5f4f28fd30 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -23,7 +23,7 @@ const TEST_FILES = [ './test/**/*.js', // @TODO remove these negations as the test files are cleaned up. '!./test/api-legacy/**/*', - '!./test/api/**/*', + '!./test/api/v2/**/*', '!./test/common/simulations/**/*', '!./test/content/**/*', '!./test/e2e/**/*', From 70151fd07390e8ca8bf07585375166900f96ef5c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 30 Dec 2015 21:16:26 -0600 Subject: [PATCH 298/976] lint: Fix tests that can be fixed with the eslint --fix flag --- test/api/v3/integration/groups/POST-groups.test.js | 10 +++++----- test/api/v3/integration/tags/GET-tags_id.test.js | 2 +- test/api/v3/integration/tasks/GET-tasks.test.js | 2 +- test/api/v3/integration/tasks/POST-tasks.test.js | 2 +- .../tasks/POST-tasks_id_score_direction.test.js | 10 +++++----- test/api/v3/integration/tasks/PUT-tasks_id.test.js | 6 +++--- test/api/v3/unit/libs/analyticsService.test.js | 12 ++++++------ test/api/v3/unit/libs/buildManifest.test.js | 2 +- test/api/v3/unit/libs/email.test.js | 14 +++++++------- test/api/v3/unit/libs/encryption.test.js | 2 +- test/api/v3/unit/libs/i18n.test.js | 2 +- test/api/v3/unit/middlewares/analytics.test.js | 6 +++--- .../v3/unit/middlewares/getUserLanguage.test.js | 2 +- test/api/v3/unit/middlewares/response.js | 10 +++++----- 14 files changed, 41 insertions(+), 41 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index f77e0fb5ae..f2ec45b06d 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -75,8 +75,8 @@ describe('POST /group', () => { context('Parties', () => { it('creates a party', () => { - let groupName = "Test Party"; - let groupType = "party"; + let groupName = 'Test Party'; + let groupType = 'party'; return user.post('/groups', { name: groupName, @@ -86,13 +86,13 @@ describe('POST /group', () => { expect(result._id).to.exist; expect(result.name).to.equal(groupName); expect(result.type).to.equal(groupType); - }) + }); }); it('prevents user in a party from creating another party', () => { let tmpUser; - let groupName = "Test Party"; - let groupType = "party"; + let groupName = 'Test Party'; + let groupType = 'party'; return generateUser().then((generatedUser) => { tmpUser = generatedUser; diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index f8180be5ca..fbdf96312f 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -17,7 +17,7 @@ describe('GET /tags/:tagId', () => { return user.post('/tags', {name: 'Tag 1'}) .then((tag) => { createdTag = tag; - return user.get(`/tags/${createdTag._id}`) + return user.get(`/tags/${createdTag._id}`); }) .then((tag) => { expect(tag).to.deep.equal(createdTag); diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 25bf8b1308..8a191f17bb 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -41,5 +41,5 @@ describe('GET /tasks', () => { }); // TODO complete after task scoring is done - it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified') + it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified'); }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 30e4d3ce37..776e69cad2 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -284,7 +284,7 @@ describe('POST /tasks', () => { return user.post('/tasks', { text: 'test reward', type: 'reward', - value: "10", + value: '10', }).then((task) => { expect(task.value).to.eql(10); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 4e21a31b20..2d7e10e745 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -54,7 +54,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('moves completed todos out of user.tasksOrder.todos', () => { return user.get('/user') .then(user => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); }).then(() => user.post(`/tasks/${todo._id}/score/up`)) .then(() => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { @@ -62,14 +62,14 @@ describe('POST /tasks/:id/score/:direction', () => { return user.get('/user'); }) .then((user) => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1) + expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); }); }); it('moves un-completed todos back into user.tasksOrder.todos', () => { return user.get('/user') .then(user => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); }).then(() => user.post(`/tasks/${todo._id}/score/up`)) .then(() => user.post(`/tasks/${todo._id}/score/down`)) .then(() => user.get(`/tasks/${todo._id}`)) @@ -242,7 +242,7 @@ describe('POST /tasks/:id/score/:direction', () => { text: 'test plus habit', type: 'habit', down: false, - }) + }); }).then((task) => { plusHabit = task; user.post('/tasks', { @@ -250,7 +250,7 @@ describe('POST /tasks/:id/score/:direction', () => { type: 'habit', up: false, down: false, - }) + }); }).then((task) => { neitherHabit = task; }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 4e5ac308c5..99fdb83615 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -127,7 +127,7 @@ describe('PUT /tasks/:id', () => { }); }).then((savedTodo2) => { expect(savedTodo2.checklist.length).to.equal(1); - expect(savedTodo2.checklist[0].text).to.equal("789"); + expect(savedTodo2.checklist[0].text).to.equal('789'); expect(savedTodo2.checklist[0].completed).to.equal(false); }); }); @@ -190,7 +190,7 @@ describe('PUT /tasks/:id', () => { }); }).then((savedDaily2) => { expect(savedDaily2.checklist.length).to.equal(1); - expect(savedDaily2.checklist[0].text).to.equal("789"); + expect(savedDaily2.checklist[0].text).to.equal('789'); expect(savedDaily2.checklist[0].completed).to.equal(false); }); }); @@ -281,7 +281,7 @@ describe('PUT /tasks/:id', () => { it('requires value to be coerced into a number', () => { return user.put(`/tasks/${reward._id}`, { - value: "100", + value: '100', }).then((task) => { expect(task.value).to.eql(100); }); diff --git a/test/api/v3/unit/libs/analyticsService.test.js b/test/api/v3/unit/libs/analyticsService.test.js index 99657b270a..c8536bc887 100644 --- a/test/api/v3/unit/libs/analyticsService.test.js +++ b/test/api/v3/unit/libs/analyticsService.test.js @@ -68,7 +68,7 @@ describe('analyticsService', () => { }); it('sends english item name for gear if itemKey is provided', () => { - data.itemKey = 'headAccessory_special_foxEars' + data.itemKey = 'headAccessory_special_foxEars'; amplitudeNock .filteringPath(/httpapi.*itemName.*Fox%20Ears.*/g, ''); @@ -80,7 +80,7 @@ describe('analyticsService', () => { }); it('sends english item name for egg if itemKey is provided', () => { - data.itemKey = 'Wolf' + data.itemKey = 'Wolf'; amplitudeNock .filteringPath(/httpapi.*itemName.*Wolf%20Egg.*/g, ''); @@ -92,7 +92,7 @@ describe('analyticsService', () => { }); it('sends english item name for food if itemKey is provided', () => { - data.itemKey = 'Cake_Skeleton' + data.itemKey = 'Cake_Skeleton'; amplitudeNock .filteringPath(/httpapi.*itemName.*Bare%20Bones%20Cake.*/g, ''); @@ -104,7 +104,7 @@ describe('analyticsService', () => { }); it('sends english item name for hatching potion if itemKey is provided', () => { - data.itemKey = 'Golden' + data.itemKey = 'Golden'; amplitudeNock .filteringPath(/httpapi.*itemName.*Golden%20Hatching%20Potion.*/g, ''); @@ -116,7 +116,7 @@ describe('analyticsService', () => { }); it('sends english item name for quest if itemKey is provided', () => { - data.itemKey = 'atom1' + data.itemKey = 'atom1'; amplitudeNock .filteringPath(/httpapi.*itemName.*Attack%20of%20the%20Mundane%2C%20Part%201%3A%20Dish%20Disaster!.*/g, ''); @@ -128,7 +128,7 @@ describe('analyticsService', () => { }); it('sends english item name for purchased spell if itemKey is provided', () => { - data.itemKey = 'seafoam' + data.itemKey = 'seafoam'; amplitudeNock .filteringPath(/httpapi.*itemName.*Seafoam.*/g, ''); diff --git a/test/api/v3/unit/libs/buildManifest.test.js b/test/api/v3/unit/libs/buildManifest.test.js index d03e19c9eb..e719d8c434 100644 --- a/test/api/v3/unit/libs/buildManifest.test.js +++ b/test/api/v3/unit/libs/buildManifest.test.js @@ -11,7 +11,7 @@ describe('Build Manifest', () => { }); it('throws an error in case the page does not exist', () => { - let getManifestFilesFn = () => { getManifestFiles('strange name here') }; + let getManifestFilesFn = () => { getManifestFiles('strange name here'); }; expect(getManifestFilesFn).to.throw(Error); }); }); diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index 353ac0ad09..5cfd1b542f 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -28,7 +28,7 @@ function getUser () { }, }, }; -}; +} describe('emails', () => { let pathToEmailLib = '../../../../../website/src/libs/api-v3/email'; @@ -97,7 +97,7 @@ describe('emails', () => { delete user.auth['local']; let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); - + expect(data).to.have.property('name', user.auth.facebook.displayName); expect(data).to.have.property('email', user.auth.facebook.emails[0].value); expect(data).to.have.property('_id', user._id); @@ -109,11 +109,11 @@ describe('emails', () => { let getUserInfo = attachEmail.getUserInfo; let user = getUser(); delete user.profile['name']; - delete user.auth.local['email'] + delete user.auth.local['email']; delete user.auth['facebook']; let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); - + expect(data).to.have.property('name', user.auth.local.username); expect(data).not.to.have.property('email'); expect(data).to.have.property('_id', user._id); @@ -194,7 +194,7 @@ describe('emails', () => { name: 'my name', email: 'my@email', }; - let variables = [1,2,3]; + let variables = [1, 2, 3]; sendTxnEmail(mailingInfo, emailType, variables); expect(request.post).to.be.calledWith(sinon.match({ @@ -204,8 +204,8 @@ describe('emails', () => { return value[0].name === 'BASE_URL'; }, 'matches variables'), personalVariables: sinon.match((value) => { - return (value[0].rcpt === mailingInfo.email - && value[0].vars[0].name === 'RECIPIENT_NAME' + return (value[0].rcpt === mailingInfo.email + && value[0].vars[0].name === 'RECIPIENT_NAME' && value[0].vars[1].name === 'RECIPIENT_UNSUB_URL' ); }, 'matches personal variables'), diff --git a/test/api/v3/unit/libs/encryption.test.js b/test/api/v3/unit/libs/encryption.test.js index dcab9bffd3..34c159ed02 100644 --- a/test/api/v3/unit/libs/encryption.test.js +++ b/test/api/v3/unit/libs/encryption.test.js @@ -1,4 +1,4 @@ -import { +import { encrypt, decrypt, } from '../../../../../website/src/libs/api-v3/encryption'; diff --git a/test/api/v3/unit/libs/i18n.test.js b/test/api/v3/unit/libs/i18n.test.js index 5bafc201dc..1136f255a6 100644 --- a/test/api/v3/unit/libs/i18n.test.js +++ b/test/api/v3/unit/libs/i18n.test.js @@ -34,7 +34,7 @@ describe('i18n', () => { describe('localePath', () => { it('is an absolute path to common/locales/', () => { expect(localePath).to.match(/.*\/common\/locales\//); - expect(localePath) + expect(localePath); }); }); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index e2808bc881..cc09c8f39a 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -3,10 +3,10 @@ import { generateReq, generateNext, } from '../../../../helpers/api-unit.helper'; -import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService' +import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; import nconf from 'nconf'; -describe('analytics middleware', function() { +describe('analytics middleware', function () { let res, req, next; let pathToAnalyticsMiddleware = '../../../../../website/src/middlewares/api-v3/analytics'; @@ -23,7 +23,7 @@ describe('analytics middleware', function() { delete require.cache[require.resolve(pathToAnalyticsMiddleware)]; }); - it('attaches analytics object res.locals', function() { + it('attaches analytics object res.locals', function () { let attachAnalytics = require(pathToAnalyticsMiddleware); attachAnalytics(req, res, next); diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 1ee185915b..94693e6713 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -121,7 +121,7 @@ describe('getUserLanguage', () => { context('request with session', () => { it('uses the user preferred language if avalaible', (done) => { sandbox.stub(User, 'findOne').returns({ - exec() { + exec () { return Q.resolve({ preferences: { language: 'it', diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js index ca3d160908..15bb9881a5 100644 --- a/test/api/v3/unit/middlewares/response.js +++ b/test/api/v3/unit/middlewares/response.js @@ -3,9 +3,9 @@ import { generateReq, generateNext, } from '../../../../helpers/api-unit.helper'; -import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response' +import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response'; -describe('response middleware', function() { +describe('response middleware', function () { let res, req, next; beforeEach(() => { @@ -15,13 +15,13 @@ describe('response middleware', function() { }); - it('attaches respond method to res', function() { + it('attaches respond method to res', function () { responseMiddleware(req, res, next); expect(res.respond).to.exist; }); - it('can be used to respond to requests', function() { + it('can be used to respond to requests', function () { responseMiddleware(req, res, next); res.respond(200, {field: 1}); @@ -34,7 +34,7 @@ describe('response middleware', function() { }); }); - it('treats status >= 400 as failures', function() { + it('treats status >= 400 as failures', function () { responseMiddleware(req, res, next); res.respond(403, {field: 1}); From 9428c6d997dce487f9a9d84c16e5d17441fc6040 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 31 Dec 2015 08:50:02 -0600 Subject: [PATCH 299/976] lint: Correct linting errors in api v3 tests --- test/api/v3/integration/chat/GET-chat.test.js | 4 +- .../integration/chat/POST-chat.flag.test.js | 6 +- .../integration/chat/POST-chat.like.test.js | 6 +- .../v3/integration/groups/POST-groups.test.js | 11 ++- .../v3/integration/tags/PUT-tags_id.test.js | 4 +- .../integration/tasks/DELETE-tasks_id.test.js | 6 +- .../v3/integration/tasks/GET-tasks.test.js | 1 - .../v3/integration/tasks/GET-tasks_id.test.js | 8 ++- .../POST-tasks_id_score_direction.test.js | 70 +++++++++---------- .../v3/integration/tasks/PUT-tasks_id.test.js | 35 +++++----- ...LETE-tasks_taskId_checklist_itemId.test.js | 12 ++-- .../POST-tasks_taskId_checklist.test.js | 2 +- ...asks_taskId_checklist_itemId_score.test.js | 12 ++-- .../PUT-tasks_taskId_checklist_itemId.test.js | 24 +++---- .../DELETE-tasks_taskId_tags_tagId.test.js | 4 +- .../user/auth/POST-register_local.test.js | 18 ++--- .../api/v3/unit/libs/analyticsService.test.js | 56 +++++++-------- test/api/v3/unit/libs/baseModel.test.js | 10 +-- test/api/v3/unit/libs/buildManifest.test.js | 5 +- test/api/v3/unit/libs/email.test.js | 40 +++++------ test/api/v3/unit/libs/webhooks.test.js | 43 ++++++------ .../api/v3/unit/middlewares/analytics.test.js | 5 +- .../v3/unit/middlewares/errorHandler.test.js | 6 +- .../unit/middlewares/getUserLanguage.test.js | 56 +++++++-------- test/api/v3/unit/middlewares/response.js | 8 +-- 25 files changed, 218 insertions(+), 234 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 26ed88f70b..edaca2ca2d 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -36,7 +36,7 @@ describe('GET /groups/:groupId/chat', () => { }); it('returns Guild chat', () => { - return user.get('/groups/' + group._id + '/chat') + return user.get(`/groups/${group._id}/chat`) .then((getChat) => { expect(getChat).to.eql(group.chat); }); @@ -67,7 +67,7 @@ describe('GET /groups/:groupId/chat', () => { it('returns error if user is not member of requested private group', () => { return expect( - user.get('/groups/' + group._id + '/chat') + user.get(`/groups/${group._id}/chat`) ) .to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index c5178b7af8..3a0d494272 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -2,7 +2,7 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration.helper'; -import _ from 'lodash'; +import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { let user; @@ -66,7 +66,7 @@ describe('POST /chat/:chatId/flag', () => { return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { - let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + let messageToCheck = find(updatedGroup.chat, {id: message.id}); expect(messageToCheck.flags[user._id]).to.equal(true); }); }); @@ -89,7 +89,7 @@ describe('POST /chat/:chatId/flag', () => { return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { - let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + let messageToCheck = find(updatedGroup.chat, {id: message.id}); expect(messageToCheck.flags[secondUser._id]).to.equal(true); expect(messageToCheck.flagCount).to.equal(5); }); diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index 40f530174c..05d40f6e9f 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -2,7 +2,7 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration.helper'; -import _ from 'lodash'; +import { find } from 'lodash'; describe('POST /chat/:chatId/like', () => { let user; @@ -65,7 +65,7 @@ describe('POST /chat/:chatId/like', () => { return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { - let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + let messageToCheck = find(updatedGroup.chat, {id: message.id}); expect(messageToCheck.likes[user._id]).to.equal(true); }); }); @@ -89,7 +89,7 @@ describe('POST /chat/:chatId/like', () => { return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { - let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + let messageToCheck = find(updatedGroup.chat, {id: message.id}); expect(messageToCheck.likes[user._id]).to.equal(false); }); }); diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index f2ec45b06d..da532cc8d3 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -20,7 +20,7 @@ describe('POST /group', () => { return expect( user.post('/groups', { name: groupName, - type: groupType + type: groupType, }) ) .to.eventually.be.rejected.and.eql({ @@ -38,7 +38,7 @@ describe('POST /group', () => { return generateUser({balance: 1}).then((generatedUser) => { return generatedUser.post('/groups', { name: groupName, - type: groupType + type: groupType, }); }) .then((result) => { @@ -54,13 +54,12 @@ describe('POST /group', () => { let groupName = 'Test Private Guild'; let groupType = 'guild'; let groupPrivacy = 'private'; - let tmpUser; return generateUser({balance: 1}).then((generatedUser) => { return generatedUser.post('/groups', { name: groupName, type: groupType, - privacy: groupPrivacy + privacy: groupPrivacy, }); }) .then((result) => { @@ -80,7 +79,7 @@ describe('POST /group', () => { return user.post('/groups', { name: groupName, - type: groupType + type: groupType, }) .then((result) => { expect(result._id).to.exist; @@ -98,7 +97,7 @@ describe('POST /group', () => { tmpUser = generatedUser; return tmpUser.post('/groups', { name: groupName, - type: groupType + type: groupType, }); }) .then(() => { diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index e8a4335444..94a76ec39c 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -12,13 +12,11 @@ describe('PUT /tags/:tagId', () => { }); it('updates a tag given it\'s id', () => { - let length; - return user.post('/tags', {name: 'Tag 1'}) .then((createdTag) => { return user.put(`/tags/${createdTag._id}`, { name: 'Tag updated', - ignored: true + ignored: true, }); }) .then((updatedTag) => { diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index ad92739c9a..91f02b7844 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -25,9 +25,9 @@ describe('DELETE /tasks/:id', () => { }); it('deletes a user\'s task', () => { - return user.del('/tasks/' + task._id) + return user.del(`/tasks/${task._id}`) .then(() => { - return expect(user.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + return expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -54,7 +54,7 @@ describe('DELETE /tasks/:id', () => { }); }) .then((task2) => { - return expect(user.del('/tasks/' + task2._id)).to.eventually.be.rejected.and.eql({ + return expect(user.del(`/tasks/${task2._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 8a191f17bb..0772046f0b 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -1,6 +1,5 @@ import { generateUser, - translate as t, } from '../../../../helpers/api-integration.helper'; import Q from 'q'; diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index 993e2c127f..a4ae455d6a 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -26,7 +26,7 @@ describe('GET /tasks/:id', () => { }); it('gets specified task', () => { - return user.get('/tasks/' + task._id) + return user.get(`/tasks/${task._id}`) .then((getTask) => { expect(getTask).to.eql(task); }); @@ -38,7 +38,9 @@ describe('GET /tasks/:id', () => { context('task cannot be accessed', () => { it('cannot get a non-existant task', () => { - return expect(user.get('/tasks/' + generateUUID())).to.eventually.be.rejected.and.eql({ + let dummyId = generateUUID(); + + return expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -57,7 +59,7 @@ describe('GET /tasks/:id', () => { type: 'habit', }); }).then((task) => { - return expect(anotherUser.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + return expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 2d7e10e745..2978d93657 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -47,29 +47,29 @@ describe('POST /tasks/:id/score/:direction', () => { it('completes todo when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/tasks/${todo._id}`)) + .then(() => user.get(`/tasks/${todo._id}`)) .then((task) => expect(task.completed).to.equal(true)); }); it('moves completed todos out of user.tasksOrder.todos', () => { return user.get('/user') - .then(user => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); + .then(usr => { + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); }).then(() => user.post(`/tasks/${todo._id}/score/up`)) .then(() => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { expect(updatedTask.completed).to.equal(true); return user.get('/user'); }) - .then((user) => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); + .then((usr) => { + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); }); }); it('moves un-completed todos back into user.tasksOrder.todos', () => { return user.get('/user') - .then(user => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); + .then(usr => { + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); }).then(() => user.post(`/tasks/${todo._id}/score/up`)) .then(() => user.post(`/tasks/${todo._id}/score/down`)) .then(() => user.get(`/tasks/${todo._id}`)) @@ -77,16 +77,16 @@ describe('POST /tasks/:id/score/:direction', () => { expect(updatedTask.completed).to.equal(false); return user.get('/user'); }) - .then((user) => { - let l = user.tasksOrder.todos.length; - expect(user.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); - expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom + .then((usr) => { + let l = usr.tasksOrder.todos.length; + expect(usr.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom }); }); it('uncompletes todo when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/tasks/${todo._id}`)) + .then(() => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { expect(updatedTask.completed).to.equal(false); }); @@ -98,7 +98,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s mp when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); @@ -106,7 +106,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s mp when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); @@ -114,7 +114,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s exp when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); @@ -122,7 +122,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s exp when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); @@ -130,7 +130,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s gold when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -138,7 +138,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s gold when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); @@ -159,13 +159,13 @@ describe('POST /tasks/:id/score/:direction', () => { it('completes daily when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/tasks/${daily._id}`)) + .then(() => user.get(`/tasks/${daily._id}`)) .then((task) => expect(task.completed).to.equal(true)); }); it('uncompletes daily when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/tasks/${daily._id}`)) + .then(() => user.get(`/tasks/${daily._id}`)) .then((task) => expect(task.completed).to.equal(false)); }); @@ -175,7 +175,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s mp when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); @@ -183,7 +183,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s mp when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); @@ -191,7 +191,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s exp when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); @@ -199,7 +199,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s exp when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); @@ -207,7 +207,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s gold when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -215,7 +215,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s gold when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); @@ -223,7 +223,7 @@ describe('POST /tasks/:id/score/:direction', () => { }); context('habits', () => { - let habit, minusHabit, plusHabit, neitherHabit; + let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars beforeEach(() => { return user.post('/tasks', { @@ -262,7 +262,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s mp when direction is up', () => { return user.post(`/tasks/${habit._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); @@ -270,7 +270,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s mp when direction is down', () => { return user.post(`/tasks/${habit._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); @@ -278,7 +278,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s exp when direction is up', () => { return user.post(`/tasks/${habit._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); @@ -286,7 +286,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s gold when direction is up', () => { return user.post(`/tasks/${habit._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -308,7 +308,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('purchases reward', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); }); @@ -316,7 +316,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('does not change user\'s mp', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); @@ -324,7 +324,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('does not change user\'s exp', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp).to.equal(updatedUser.stats.exp); }); @@ -332,7 +332,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('does not allow a down direction', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 99fdb83615..3092d3bd84 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -1,6 +1,5 @@ import { generateUser, - translate as t, } from '../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -28,7 +27,7 @@ describe('PUT /tasks/:id', () => { it(`ignores setting _id, type, userId, history, createdAt, updatedAt, challenge, completed, streak, dateCompleted fields`, () => { - user.put('/tasks/' + task._id, { + user.put(`/tasks/${task._id}`, { _id: 123, type: 'daily', userId: 123, @@ -54,7 +53,7 @@ describe('PUT /tasks/:id', () => { }); it('ignores invalid fields', () => { - user.put('/tasks/' + task._id, { + user.put(`/tasks/${task._id}`, { notValid: true, }).then((savedTask) => { expect(savedTask.notValid).to.be.a('undefined'); @@ -118,12 +117,12 @@ describe('PUT /tasks/:id', () => { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, - ] - }).then((savedTodo) => { + ], + }).then(() => { return user.put(`/tasks/${todo._id}`, { checklist: [ {text: 789, completed: false}, - ] + ], }); }).then((savedTodo2) => { expect(savedTodo2.checklist.length).to.equal(1); @@ -136,9 +135,9 @@ describe('PUT /tasks/:id', () => { let finalUUID = generateUUID(); return user.put(`/tasks/${todo._id}`, { tags: [generateUUID(), generateUUID()], - }).then((savedTodo) => { + }).then(() => { return user.put(`/tasks/${todo._id}`, { - tags: [finalUUID] + tags: [finalUUID], }); }).then((savedTodo2) => { expect(savedTodo2.tags.length).to.equal(1); @@ -161,8 +160,6 @@ describe('PUT /tasks/:id', () => { }); it('updates a daily', () => { - let now = new Date(); - return user.put(`/tasks/${daily._id}`, { text: 'some new text', notes: 'some new notes', @@ -181,12 +178,12 @@ describe('PUT /tasks/:id', () => { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, - ] - }).then((savedDaily) => { + ], + }).then(() => { return user.put(`/tasks/${daily._id}`, { checklist: [ {text: 789, completed: false}, - ] + ], }); }).then((savedDaily2) => { expect(savedDaily2.checklist.length).to.equal(1); @@ -199,9 +196,9 @@ describe('PUT /tasks/:id', () => { let finalUUID = generateUUID(); return user.put(`/tasks/${daily._id}`, { tags: [generateUUID(), generateUUID()], - }).then((savedDaily) => { + }).then(() => { return user.put(`/tasks/${daily._id}`, { - tags: [finalUUID] + tags: [finalUUID], }); }).then((savedDaily2) => { expect(savedDaily2.tags.length).to.equal(1); @@ -212,12 +209,12 @@ describe('PUT /tasks/:id', () => { it('updates repeat, even if frequency is set to daily', () => { return user.put(`/tasks/${daily._id}`, { frequency: 'daily', - }).then((savedDaily) => { + }).then(() => { return user.put(`/tasks/${daily._id}`, { repeat: { m: false, - su: false - } + su: false, + }, }); }).then((savedDaily2) => { expect(savedDaily2.repeat).to.eql({ @@ -235,7 +232,7 @@ describe('PUT /tasks/:id', () => { it('updates everyX, even if frequency is set to weekly', () => { return user.put(`/tasks/${daily._id}`, { frequency: 'weekly', - }).then((savedDaily) => { + }).then(() => { return user.put(`/tasks/${daily._id}`, { everyX: 5, }); diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index 094619e1fd..d013878a74 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -46,15 +46,13 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); }); - it('does not work with rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('does not work with rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.del(`/tasks/${reward._id}/checklist/${generateUUID()}`); - }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index 4654871ac5..e9b695effd 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -66,7 +66,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('fails on task not found', () => { return expect(user.post(`/tasks/${generateUUID()}/checklist`, { - text: 'Checklist Item 1' + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 727aaea74c..667ef41446 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -45,15 +45,13 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); }); - it('fails on rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('fails on rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`); - }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index 149d1bedca..988574bbf8 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -32,30 +32,26 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); }); - it('fails on habits', () => { - let habit; - return expect(user.post('/tasks', { + it('fails on habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.put(`/tasks/${habit._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.put(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), }); }); - it('fails on rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('fails on rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.put(`/tasks/${reward._id}/checklist/${generateUUID()}`); - }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.put(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index ddb78b7262..c01b71fa2c 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -26,7 +26,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }).then(createdTag => { tag = createdTag; return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(savedTask => { + }).then(() => { return user.del(`/tasks/${task._id}/tags/${tag._id}`); }).then(() => user.get(`/tasks/${task._id}`)) .then(updatedTask => { @@ -35,8 +35,6 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('only deletes existing tags', () => { - let task; - return expect(user.post('/tasks', { type: 'habit', text: 'Task with tag', diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 0e11e7b924..a0f83717ea 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -37,7 +37,7 @@ describe('POST /user/auth/local/register', () => { username, email, password, - confirmPassword: confirmPassword, + confirmPassword, })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -104,8 +104,8 @@ describe('POST /user/auth/local/register', () => { return expect(api.post('/user/auth/local/register', { username, - email: email, - confirmPassword: confirmPassword, + email, + confirmPassword, })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -123,7 +123,7 @@ describe('POST /user/auth/local/register', () => { return generateUser({ 'auth.local.username': username, 'auth.local.lowerCaseUsername': username, - 'auth.local.email': email + 'auth.local.email': email, }); }); @@ -133,9 +133,9 @@ describe('POST /user/auth/local/register', () => { let password = 'password'; return expect(api.post('/user/auth/local/register', { - username: username, - email: uniqueEmail, - password: password, + username, + email: uniqueEmail, + password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -181,7 +181,7 @@ describe('POST /user/auth/local/register', () => { }).then((user) => { expect(user.flags.tour).to.not.be.empty; - each(user.flags.tour, (value, attribute) => { + each(user.flags.tour, (value) => { expect(value).to.eql(-2); }); }); @@ -232,7 +232,7 @@ describe('POST /user/auth/local/register', () => { }).then((user) => { expect(user.flags.tour).to.not.be.empty; - each(user.flags.tutorial.common, (value, attribute) => { + each(user.flags.tutorial.common, (value) => { expect(value).to.eql(true); }); }); diff --git a/test/api/v3/unit/libs/analyticsService.test.js b/test/api/v3/unit/libs/analyticsService.test.js index c8536bc887..8ff14c2408 100644 --- a/test/api/v3/unit/libs/analyticsService.test.js +++ b/test/api/v3/unit/libs/analyticsService.test.js @@ -6,12 +6,12 @@ describe('analyticsService', () => { let amplitudeNock, gaNock; beforeEach(() => { - amplitudeNock = nock( 'https://api.amplitude.com') + amplitudeNock = nock('https://api.amplitude.com') .filteringPath(/httpapi.*/g, '') .post('/') .reply(200, {status: 'OK'}); - gaNock = nock( 'http://www.google-analytics.com'); + gaNock = nock('http://www.google-analytics.com'); }); describe('#track', () => { @@ -23,14 +23,14 @@ describe('analyticsService', () => { category: 'behavior', uuid: 'unique-user-id', resting: true, - cronCount: 5 + cronCount: 5, }; }); context('Amplitude', () => { it('calls out to amplitude', () => { return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -42,7 +42,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -52,7 +52,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*platform.*server.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -62,7 +62,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*event_properties%22%3A%7B%22category%22%3A%22behavior%22%2C%22resting%22%3Atrue%2C%22cronCount%22%3A5%7D%2C%22.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -74,7 +74,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Fox%20Ears.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -86,7 +86,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Wolf%20Egg.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -98,7 +98,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Bare%20Bones%20Cake.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -110,7 +110,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Golden%20Hatching%20Potion.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -122,7 +122,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Attack%20of%20the%20Mundane%2C%20Part%201%3A%20Dish%20Disaster!.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -134,7 +134,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Seafoam.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -142,14 +142,14 @@ describe('analyticsService', () => { it('sends user data if provided', () => { let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; let user = { - stats: stats, + stats, contributor: { level: 1 }, purchased: { plan: { planId: 'foo-plan' } }, flags: {tour: {intro: -2}}, habits: [{_id: 'habit'}], dailys: [{_id: 'daily'}], todos: [{_id: 'todo'}], - rewards: [{_id: 'reward'}] + rewards: [{_id: 'reward'}], }; data.user = user; @@ -158,7 +158,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -171,7 +171,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { gaNock.done(); }); }); @@ -182,7 +182,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { gaNock.done(); }); }); @@ -201,14 +201,14 @@ describe('analyticsService', () => { purchaseValue: 8, purchaseType: 'checkout', gift: false, - quantity: 1 + quantity: 1, }; }); context('Amplitude', () => { it('calls out to amplitude', () => { return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -220,7 +220,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -230,7 +230,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*platform.*server.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -240,7 +240,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*aypal-checkout%22%2C%22paymentMethod%22%3A%22PayPal%22%2C%22itemPurchased%22%3A%22Gems%22%2C%22purchaseType%22%3A%22checkout%22%2C%22gift%22%3Afalse%2C%22quantity%22%3A1%7D%2C%22event_type%22%3A%22purchase%22%2C%22revenue.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -248,14 +248,14 @@ describe('analyticsService', () => { it('sends user data if provided', () => { let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; let user = { - stats: stats, + stats, contributor: { level: 1 }, purchased: { plan: { planId: 'foo-plan' } }, flags: {tour: {intro: -2}}, habits: [{_id: 'habit'}], dailys: [{_id: 'daily'}], todos: [{_id: 'todo'}], - rewards: [{_id: 'reward'}] + rewards: [{_id: 'reward'}], }; data.user = user; @@ -264,7 +264,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -277,7 +277,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { gaNock.done(); }); }); @@ -290,7 +290,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { gaNock.done(); }); }); diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 98835b1c29..95960c704d 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -32,7 +32,7 @@ describe('Base model plugin', () => { it('can sanitize input objects', () => { baseModel(schema, { - noSet: ['noUpdateForMe'] + noSet: ['noUpdateForMe'], }); expect(schema.statics.sanitize).to.exist; @@ -45,7 +45,7 @@ describe('Base model plugin', () => { it('accepts an array of additional fields to sanitize at runtime', () => { baseModel(schema, { - noSet: ['noUpdateForMe'] + noSet: ['noUpdateForMe'], }); expect(schema.statics.sanitize).to.exist; @@ -59,7 +59,7 @@ describe('Base model plugin', () => { it('can make fields private', () => { baseModel(schema, { - private: ['amPrivate'] + private: ['amPrivate'], }); expect(schema.options.toJSON.transform).to.exist; @@ -73,7 +73,7 @@ describe('Base model plugin', () => { it('accepts a further transform function for toJSON', () => { let options = { private: ['amPrivate'], - toJSONTransform: sandbox.stub().returns(true) + toJSONTransform: sandbox.stub().returns(true), }; baseModel(schema, options); @@ -88,7 +88,7 @@ describe('Base model plugin', () => { it('accepts a transform function for sanitize', () => { let options = { private: ['amPrivate'], - sanitizeTransform: sandbox.stub().returns(true) + sanitizeTransform: sandbox.stub().returns(true), }; baseModel(schema, options); diff --git a/test/api/v3/unit/libs/buildManifest.test.js b/test/api/v3/unit/libs/buildManifest.test.js index e719d8c434..0978d8fb41 100644 --- a/test/api/v3/unit/libs/buildManifest.test.js +++ b/test/api/v3/unit/libs/buildManifest.test.js @@ -11,8 +11,9 @@ describe('Build Manifest', () => { }); it('throws an error in case the page does not exist', () => { - let getManifestFilesFn = () => { getManifestFiles('strange name here'); }; - expect(getManifestFilesFn).to.throw(Error); + expect(() => { + getManifestFiles('strange name here'); + }).to.throw(Error); }); }); }); diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index 5cfd1b542f..bb7741dce7 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -1,3 +1,4 @@ +/* eslint-disable global-require */ import request from 'request'; import nconf from 'nconf'; import nodemailer from 'nodemailer'; @@ -14,17 +15,17 @@ function getUser () { }, facebook: { emails: [{ - value: 'email@facebook' + value: 'email@facebook', }], displayName: 'fb display name', - } + }, }, profile: { name: 'profile name', }, preferences: { emailNotifications: { - unsubscribeFromAll: false + unsubscribeFromAll: false, }, }, }; @@ -63,7 +64,7 @@ describe('emails', () => { attachEmail.send(); expect(sendMailSpy).to.be.calledOnce; deferred.reject(); - deferred.promise.catch((err) => { + deferred.promise.catch(() => { expect(logger.error).to.be.calledOnce; done(); }); @@ -93,8 +94,8 @@ describe('emails', () => { let attachEmail = require(pathToEmailLib); let getUserInfo = attachEmail.getUserInfo; let user = getUser(); - delete user.profile['name']; - delete user.auth['local']; + delete user.profile.name; + delete user.auth.local; let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); @@ -108,9 +109,9 @@ describe('emails', () => { let attachEmail = require(pathToEmailLib); let getUserInfo = attachEmail.getUserInfo; let user = getUser(); - delete user.profile['name']; - delete user.auth.local['email']; - delete user.auth['facebook']; + delete user.profile.name; + delete user.auth.local.email; + delete user.auth.facebook; let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); @@ -148,8 +149,8 @@ describe('emails', () => { to: sinon.match((value) => { return Array.isArray(value) && value[0].name === mailingInfo.name; }, 'matches mailing info array'), - } - } + }, + }, })); }); @@ -160,7 +161,7 @@ describe('emails', () => { let emailType = 'an email type'; let mailingInfo = { name: 'my name', - //email: 'my@email', + // email: 'my@email', }; sendTxnEmail(mailingInfo, emailType); @@ -180,8 +181,8 @@ describe('emails', () => { data: { emailType: sinon.match.same(emailType), to: sinon.match(val => val[0]._id === mailingInfo._id), - } - } + }, + }, })); }); @@ -204,13 +205,12 @@ describe('emails', () => { return value[0].name === 'BASE_URL'; }, 'matches variables'), personalVariables: sinon.match((value) => { - return (value[0].rcpt === mailingInfo.email - && value[0].vars[0].name === 'RECIPIENT_NAME' - && value[0].vars[1].name === 'RECIPIENT_UNSUB_URL' - ); + return value[0].rcpt === mailingInfo.email && + value[0].vars[0].name === 'RECIPIENT_NAME' && + value[0].vars[1].name === 'RECIPIENT_UNSUB_URL'; }, 'matches personal variables'), - } - } + }, + }, })); }); }); diff --git a/test/api/v3/unit/libs/webhooks.test.js b/test/api/v3/unit/libs/webhooks.test.js index 62aeee3922..9bef501257 100644 --- a/test/api/v3/unit/libs/webhooks.test.js +++ b/test/api/v3/unit/libs/webhooks.test.js @@ -2,7 +2,6 @@ import request from 'request'; import { sendTaskWebhook } from '../../../../../website/src/libs/api-v3/webhook'; describe('webhooks', () => { - beforeEach(() => { sandbox.stub(request, 'post'); }); @@ -15,12 +14,12 @@ describe('webhooks', () => { let task = { details: { _id: 'task-id' }, delta: 1.4, - direction: 'up' + direction: 'up', }; let data = { - task: task, - user: { _id: 'user-id' } + task, + user: { _id: 'user-id' }, }; it('does not send if no webhook endpoints exist', () => { @@ -37,8 +36,8 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: false, - url: 'http://example.org/endpoint' - } + url: 'http://example.org/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -52,8 +51,8 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: true, - url: 'http://malformedurl/endpoint' - } + url: 'http://malformedurl/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -67,8 +66,8 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: true, - url: 'http://example.org/endpoint' - } + url: 'http://example.org/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -81,10 +80,10 @@ describe('webhooks', () => { task: { _id: 'task-id' }, delta: 1.4, user: { - _id: 'user-id' - } + _id: 'user-id', + }, }, - json: true + json: true, }); }); @@ -94,14 +93,14 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: true, - url: 'http://example.org/endpoint' + url: 'http://example.org/endpoint', }, 'second-webhook': { sort: 1, id: 'second-webhook', enabled: true, - url: 'http://example.com/2/endpoint' - } + url: 'http://example.com/2/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -114,10 +113,10 @@ describe('webhooks', () => { task: { _id: 'task-id' }, delta: 1.4, user: { - _id: 'user-id' - } + _id: 'user-id', + }, }, - json: true + json: true, }); expect(request.post).to.be.calledWith({ url: 'http://example.com/2/endpoint', @@ -126,10 +125,10 @@ describe('webhooks', () => { task: { _id: 'task-id' }, delta: 1.4, user: { - _id: 'user-id' - } + _id: 'user-id', + }, }, - json: true + json: true, }); }); }); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index cc09c8f39a..bcaa7898e2 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -1,3 +1,4 @@ +/* eslint-disable global-require */ import { generateRes, generateReq, @@ -6,7 +7,7 @@ import { import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; import nconf from 'nconf'; -describe('analytics middleware', function () { +describe('analytics middleware', () => { let res, req, next; let pathToAnalyticsMiddleware = '../../../../../website/src/middlewares/api-v3/analytics'; @@ -23,7 +24,7 @@ describe('analytics middleware', function () { delete require.cache[require.resolve(pathToAnalyticsMiddleware)]; }); - it('attaches analytics object res.locals', function () { + it('attaches analytics object res.locals', () => { let attachAnalytics = require(pathToAnalyticsMiddleware); attachAnalytics(req, res, next); diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 80e1ab08d1..269390b35b 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -115,7 +115,7 @@ describe('errorHandler', () => { error: 'BadRequest', message: 'Invalid request parameters.', errors: [ - {param: error[0].param, value: error[0].value, message: error[0].msg} + { param: error[0].param, value: error[0].value, message: error[0].msg }, ], }); }); @@ -142,8 +142,8 @@ describe('errorHandler', () => { error: 'BadRequest', message: 'User validation failed.', errors: [ - {path: 'auth.local.email', message: 'Invalid email.', value: 'not an email'} - ] + { path: 'auth.local.email', message: 'Invalid email.', value: 'not an email' }, + ], }); }); diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 94693e6713..43b0cb0ab8 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -7,15 +7,13 @@ import getUserLanguage from '../../../../../website/src/middlewares/api-v3/getUs import { i18n } from '../../../../../common'; import Q from 'q'; import { model as User } from '../../../../../website/src/models/user'; -import { translations } from '../../../../../website/src/libs/api-v3/i18n'; -import accepts from 'accepts'; describe('getUserLanguage', () => { let res, req, next; - let checkResT = (req) => { - expect(res.t).to.be.a('function'); - expect(res.t('help')).to.equal(i18n.t('help', req.language)); + let checkResT = (resToCheck) => { + expect(resToCheck.t).to.be.a('function'); + expect(resToCheck.t('help')).to.equal(i18n.t('help', req.language)); }; beforeEach(() => { @@ -32,7 +30,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('es'); - checkResT(req); + checkResT(res); }); it('falls back to english if the query parameter language does not exists', () => { @@ -42,7 +40,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); }); it('uses query even if the request includes a user and session', () => { @@ -59,12 +57,12 @@ describe('getUserLanguage', () => { }; req.session = { - userId: 123 + userId: 123, }; getUserLanguage(req, res, next); expect(req.language).to.equal('es'); - checkResT(req); + checkResT(res); }); }); @@ -80,7 +78,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('it'); - checkResT(req); + checkResT(res); }); it('falls back to english if the user preferred language is not avalaible', (done) => { @@ -94,7 +92,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); @@ -109,12 +107,12 @@ describe('getUserLanguage', () => { }; req.session = { - userId: 123 + userId: 123, }; getUserLanguage(req, res, next); expect(req.language).to.equal('it'); - checkResT(req); + checkResT(res); }); }); @@ -125,18 +123,18 @@ describe('getUserLanguage', () => { return Q.resolve({ preferences: { language: 'it', - } + }, }); - } + }, }); req.session = { - userId: 123 + userId: 123, }; getUserLanguage(req, res, () => { expect(req.language).to.equal('it'); - checkResT(req); + checkResT(res); done(); }); }); @@ -148,7 +146,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('pt'); - checkResT(req); + checkResT(res); done(); }); }); @@ -158,7 +156,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('he'); - checkResT(req); + checkResT(res); done(); }); }); @@ -168,7 +166,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('he'); - checkResT(req); + checkResT(res); done(); }); }); @@ -178,7 +176,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('fr'); - checkResT(req); + checkResT(res); done(); }); }); @@ -188,7 +186,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('fr'); - checkResT(req); + checkResT(res); done(); }); }); @@ -198,7 +196,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es'); - checkResT(req); + checkResT(res); done(); }); }); @@ -208,7 +206,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es_419'); - checkResT(req); + checkResT(res); done(); }); }); @@ -218,7 +216,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es_419'); - checkResT(req); + checkResT(res); done(); }); }); @@ -228,7 +226,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('zh_TW'); - checkResT(req); + checkResT(res); done(); }); }); @@ -238,7 +236,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); @@ -248,7 +246,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); @@ -258,7 +256,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js index 15bb9881a5..25916e1ef3 100644 --- a/test/api/v3/unit/middlewares/response.js +++ b/test/api/v3/unit/middlewares/response.js @@ -5,7 +5,7 @@ import { } from '../../../../helpers/api-unit.helper'; import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response'; -describe('response middleware', function () { +describe('response middleware', () => { let res, req, next; beforeEach(() => { @@ -15,13 +15,13 @@ describe('response middleware', function () { }); - it('attaches respond method to res', function () { + it('attaches respond method to res', () => { responseMiddleware(req, res, next); expect(res.respond).to.exist; }); - it('can be used to respond to requests', function () { + it('can be used to respond to requests', () => { responseMiddleware(req, res, next); res.respond(200, {field: 1}); @@ -34,7 +34,7 @@ describe('response middleware', function () { }); }); - it('treats status >= 400 as failures', function () { + it('treats status >= 400 as failures', () => { responseMiddleware(req, res, next); res.respond(403, {field: 1}); From 7025bbfa3244dc1da9477513b6db68f4e436bf17 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Wed, 30 Dec 2015 01:27:38 +0200 Subject: [PATCH 300/976] Fix basilist scroll giving logic when joining party closes #6416 --- .../v3/integration/groups/POST-groups.test.js | 3 + .../groups/POST-groups_groupId_join.js | 63 +++++++++++++++++++ website/src/controllers/api-v3/groups.js | 14 +++-- website/src/models/group.js | 2 +- 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_join.js diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index da532cc8d3..0d3a622581 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -45,6 +45,7 @@ describe('POST /group', () => { expect(result._id).to.exist; expect(result.name).to.equal(groupName); expect(result.type).to.equal(groupType); + expect(result.memberCount).to.equal(1); }); }); }); @@ -66,6 +67,7 @@ describe('POST /group', () => { expect(result._id).to.exist; expect(result.name).to.equal(groupName); expect(result.type).to.equal(groupType); + expect(result.memberCount).to.equal(1); expect(result.privacy).to.equal(groupPrivacy); }); }); @@ -85,6 +87,7 @@ describe('POST /group', () => { expect(result._id).to.exist; expect(result.name).to.equal(groupName); expect(result.type).to.equal(groupType); + expect(result.memberCount).to.equal(1); }); }); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.js b/test/api/v3/integration/groups/POST-groups_groupId_join.js new file mode 100644 index 0000000000..aa26c10289 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.js @@ -0,0 +1,63 @@ +import { + generateUser, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /group/:groupId/join', () => { + context('Accepting invitation to a guild', () => { + let user, invitedUser, guild; + + beforeEach(async () => { + user = await generateUser({balance: 1}); + guild = await user.post('/groups', { + name: 'Test Guild', + type: 'guild', + }); + invitedUser = await generateUser({ + 'invitations.guilds': [{ id: guild._id}], + }); + }); + + it('does not give basilist quest to inviter when joining a guild', async () => { + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(user.get('/user')).to.eventually.not.have.deep.property('items.quests.basilist'); + }); + + it('does not increment basilist quest count to inviter with basilist when joining a guild', async () => { + user.update({ 'items.quests.basilist': 1 }); + + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); + }); + }); + + context('Accepting invitation to a party', () => { + let user, invitedUser, party; + + beforeEach(async () => { + user = await generateUser(); + party = await user.post('/groups', { + name: 'Test Party', + type: 'party', + }); + invitedUser = await generateUser({ + 'invitations.party': { id: party._id, inviter: user._id }, + }); + }); + + it('gives basilist quest item to the inviter when joining a party', async () => { + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); + }); + + it('increments basilist quest item count to inviter when joining a party', async () => { + user.update({'items.quests.basilist': 1 }); + + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 1edf18b026..a61c56e26c 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -201,6 +201,7 @@ api.joinGroup = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let inviter; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); @@ -213,6 +214,7 @@ api.joinGroup = { let isUserInvited = false; if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { + inviter = user.invitations.party.inviter; user.invitations.party = {}; // Clear invite TODO mark modified? // invite new user to pending quest @@ -242,11 +244,13 @@ api.joinGroup = { if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader - await Q.all([ - group.save(), - user.save(), - User.update({_id: user.invitations.party.inviter}, {$inc: {'items.quests.basilist': 1}}).exec(), // Reward inviter - ]); + let promises = [group.save(), user.save()]; + + if (group.type === 'party' && inviter) { + promises.push(User.update({_id: inviter}, {$inc: {'items.quests.basilist': 1}}).exec()); // Reward inviter + } + + await Q.all(promises); firebase.addUserToGroup(group._id, user._id); res.respond(200, {}); // TODO what to return? diff --git a/website/src/models/group.js b/website/src/models/group.js index eb85e7662f..a45d2eaa60 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -35,7 +35,7 @@ export let schema = new Schema({ challenges: {type: Boolean, default: false, required: true}, // invites: {type:Boolean, 'default':false} // TODO ? }, - memberCount: {type: Number, default: 0}, + memberCount: {type: Number, default: 1}, challengeCount: {type: Number, default: 0}, balance: {type: Number, default: 0}, logo: String, From 91e7b9eb329f8277a471f7c4ebe03b3fc05688d9 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 31 Dec 2015 18:11:03 -0600 Subject: [PATCH 301/976] tests(api): Convert GET and flag chat posts to use async/await --- test/api/v3/integration/chat/GET-chat.test.js | 78 ++++++++----------- .../integration/chat/POST-chat.flag.test.js | 46 ++++------- 2 files changed, 49 insertions(+), 75 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index edaca2ca2d..cb1e0996e6 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -7,69 +7,55 @@ import { describe('GET /groups/:groupId/chat', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); context('public Guild', () => { let group; - before(() => { - return generateUser({balance: 2}) - .then((generatedLeader) => { - return generateGroup(generatedLeader, { - name: 'test group', - type: 'guild', - privacy: 'public', - }, { - chat: [ - 'Hello', - 'Welcome to the Guild', - ], - }); - }) - .then((createdGroup) => { - group = createdGroup; + before(async () => { + let leader = await generateUser({balance: 2}); + + group = await generateGroup(leader, { + name: 'test group', + type: 'guild', + privacy: 'public', + }, { + chat: [ + 'Hello', + 'Welcome to the Guild', + ], }); }); - it('returns Guild chat', () => { - return user.get(`/groups/${group._id}/chat`) - .then((getChat) => { - expect(getChat).to.eql(group.chat); - }); + it('returns Guild chat', async () => { + let chat = await user.get(`/groups/${group._id}/chat`); + + expect(chat).to.eql(group.chat); }); }); context('private Guild', () => { let group; - before(() => { - return generateUser({balance: 2}) - .then((generatedLeader) => { - return generateGroup(generatedLeader, { - name: 'test group', - type: 'guild', - privacy: 'private', - }, { - chat: [ - 'Hello', - 'Welcome to the Guild', - ], - }); - }) - .then((createdGroup) => { - group = createdGroup; + before(async () => { + let leader = await generateUser({balance: 2}); + + group = await generateGroup(leader, { + name: 'test group', + type: 'guild', + privacy: 'private', + }, { + chat: [ + 'Hello', + 'Welcome to the Guild', + ], }); }); - it('returns error if user is not member of requested private group', () => { - return expect( - user.get(`/groups/${group._id}/chat`) - ) - .to.eventually.be.rejected.and.eql({ + it('returns error if user is not member of requested private group', async () => { + await expect(user.get(`/groups/${group._id}/chat`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 3a0d494272..b71d462e5a 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -5,31 +5,19 @@ import { import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { - let user; - let group; - let testMessage = 'Test Message'; + let user, group; + const TEST_MESSAGE = 'Test Message'; - before(() => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - - return generateUser({balance: 1}).then((generatedUser) => { - user = generatedUser; - }) - .then(() => { - return user.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((generatedGroup) => { - group = generatedGroup; + before(async () => { + user = await generateUser({balance: 1}); + group = await user.post('/groups', { + name: 'Test Guild', + type: 'guild', + privacy: 'public', }); }); - it('Returns an error when chat message is not found', () => { + it('Returns an error when chat message is not found', async () => { return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) .to.eventually.be.rejected.and.eql({ code: 404, @@ -38,8 +26,8 @@ describe('POST /chat/:chatId/flag', () => { }); }); - it('Returns an error when user tries to flag their own message', () => { - return user.post(`/groups/${group._id}/chat`, { message: testMessage}) + it('Returns an error when user tries to flag their own message', async () => { + return user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}) .then((result) => { return expect(user.post(`/groups/${group._id}/chat/${result.message.id}/flag`)) .to.eventually.be.rejected.and.eql({ @@ -50,11 +38,11 @@ describe('POST /chat/:chatId/flag', () => { }); }); - it('Flags a chat', () => { + it('Flags a chat', async () => { let message; return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); + return anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); }) .then((result) => { message = result.message; @@ -71,13 +59,13 @@ describe('POST /chat/:chatId/flag', () => { }); }); - it('Flags a chat with a higher flag acount when an admin flags the message', () => { + it('Flags a chat with a higher flag acount when an admin flags the message', async () => { let secondUser; let message; return generateUser({'contributor.admin': true}).then((generatedUser) => { secondUser = generatedUser; - return user.post(`/groups/${group._id}/chat`, { message: testMessage}); + return user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); }) .then((result) => { message = result.message; @@ -95,11 +83,11 @@ describe('POST /chat/:chatId/flag', () => { }); }); - it('Returns an error when user tries to flag a message that is already flagged', () => { + it('Returns an error when user tries to flag a message that is already flagged', async () => { let message; return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); + return anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); }) .then((result) => { message = result.message; From 775766b30fb6c9a2e04bd5f64460890f8b4223e1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Jan 2016 15:43:33 +0100 Subject: [PATCH 302/976] allow multiple tasks to be created at once --- .../v3/integration/tasks/POST-tasks.test.js | 115 +++++++++++++++++- website/src/controllers/api-v3/tasks.js | 38 +++--- 2 files changed, 133 insertions(+), 20 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 776e69cad2..f4a7fed227 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -19,7 +19,7 @@ describe('POST /tasks', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: t('invalidReqParams'), + message: t('invalidTaskType'), }); }); @@ -29,7 +29,18 @@ describe('POST /tasks', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: t('invalidReqParams'), + message: t('invalidTaskType'), + }); + }); + + it('returns an error if one object inside an array is invalid', () => { + return expect(user.post('/tasks', [ + {type: 'habitF'}, + {type: 'habit'}, + ])).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidTaskType'), }); }); @@ -107,6 +118,36 @@ describe('POST /tasks', () => { }); }); + it('creates multiple habits', () => { + return user.post('/tasks', [{ + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }, { + text: 'test habit 2', + type: 'habit', + up: true, + down: false, + notes: 1977, + }]).then(([task, task2]) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test habit 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('habit'); + expect(task2.up).to.eql(true); + expect(task2.down).to.eql(false); + }); + }); + it('defaults to setting up and down to true', () => { return user.post('/tasks', { text: 'test habit', @@ -145,6 +186,28 @@ describe('POST /tasks', () => { }); }); + it('creates multiple todos', () => { + return user.post('/tasks', [{ + text: 'test todo', + type: 'todo', + notes: 1976, + }, { + text: 'test todo 2', + type: 'todo', + notes: 1977, + }]).then(([task, task2]) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test todo 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('todo'); + }); + }); + it('can create checklists', () => { return user.post('/tasks', { text: 'test todo', @@ -185,6 +248,28 @@ describe('POST /tasks', () => { }); }); + it('creates multiple dailys', () => { + return user.post('/tasks', [{ + text: 'test daily', + type: 'daily', + notes: 1976, + }, { + text: 'test daily 2', + type: 'daily', + notes: 1977, + }]).then(([task, task2]) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test daily 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('daily'); + }); + }); + it('defaults to a weekly frequency, with every day set', () => { return user.post('/tasks', { text: 'test daily', @@ -271,6 +356,32 @@ describe('POST /tasks', () => { }); }); + it('creates multiple rewards', () => { + return user.post('/tasks', [{ + text: 'test reward', + type: 'reward', + notes: 1976, + value: 11, + }, { + text: 'test reward 2', + type: 'reward', + notes: 1977, + value: 12, + }]).then(([task, task2]) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test reward'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('reward'); + expect(task.value).to.eql(11); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test reward 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('reward'); + expect(task2.value).to.eql(12); + }); + }); + it('defaults to a 0 value', () => { return user.post('/tasks', { text: 'test reward', diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index f67a25b10f..25b5245a04 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -16,39 +16,41 @@ import { preenHistory } from '../../../../common/script/api-v3/preenHistory'; let api = {}; /** - * @api {post} /tasks Create a new task + * @api {post} /tasks Create a new task. Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 * @apiName CreateTask * @apiGroup Task * - * @apiSuccess {Object} task The newly created task + * @apiSuccess {Object|Array} task The newly created task(s) */ -// TODO should allow to create multiple tasks at once -// TODO gives problems when creating tasks concurrently because of how mongoose treats arrays (VersionErrors - treated as 500s) api.createTask = { method: 'POST', url: '/tasks', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - + let tasksData = Array.isArray(req.body) ? req.body : [req.body]; let user = res.locals.user; - let taskType = req.body.type; - let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(req.body)); - newTask.userId = user._id; + let toSave = tasksData.map(taskData => { + if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); - user.tasksOrder[`${taskType}s`].unshift(newTask._id); + let taskType = taskData.type; + let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); + newTask.userId = user._id; + user.tasksOrder[`${taskType}s`].unshift(newTask._id); - let results = await Q.all([ - newTask.save(), - user.save(), - ]); + return newTask.save(); + }); - res.respond(201, results[0]); + toSave.unshift(user.save()); + let results = await Q.all(toSave); + + if (results.length === 2) { // Just one task created + res.respond(201, results[1]); + } else { + results.splice(0, 1); // remove the user + res.respond(201, results); + } }, }; From 7490bfae87c27fef1c57dc4a49b30c3743fbeeb3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Jan 2016 16:04:39 +0100 Subject: [PATCH 303/976] update POST-tasks test to async/await syntax --- .../v3/integration/tasks/POST-tasks.test.js | 362 +++++++++--------- 1 file changed, 181 insertions(+), 181 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index f4a7fed227..c8b9e9a37a 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -6,14 +6,14 @@ import { describe('POST /tasks', () => { let user; - before(() => { + before(async () => { return generateUser().then((generatedUser) => { user = generatedUser; }); }); context('validates params', () => { - it('returns an error if req.body.type is absent', () => { + it('returns an error if req.body.type is absent', async () => { return expect(user.post('/tasks', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ @@ -23,7 +23,7 @@ describe('POST /tasks', () => { }); }); - it('returns an error if req.body.type is not valid', () => { + it('returns an error if req.body.type is not valid', async () => { return expect(user.post('/tasks', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ @@ -33,7 +33,7 @@ describe('POST /tasks', () => { }); }); - it('returns an error if one object inside an array is invalid', () => { + it('returns an error if one object inside an array is invalid', async () => { return expect(user.post('/tasks', [ {type: 'habitF'}, {type: 'habit'}, @@ -44,7 +44,7 @@ describe('POST /tasks', () => { }); }); - it('returns an error if req.body.text is absent', () => { + it('returns an error if req.body.text is absent', async () => { return expect(user.post('/tasks', { type: 'habit', })).to.eventually.be.rejected.and.eql({ @@ -54,19 +54,19 @@ describe('POST /tasks', () => { }); }); - it('automatically sets "task.userId" to user\'s uuid', () => { - return user.post('/tasks', { + it('automatically sets "task.userId" to user\'s uuid', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((task) => { - expect(task.userId).to.equal(user._id); }); + + expect(task.userId).to.equal(user._id); }); it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, - dateCompleted fields`, () => { - return user.post('/tasks', { + dateCompleted fields`, async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', userId: 123, @@ -77,49 +77,49 @@ describe('POST /tasks', () => { completed: true, streak: 25, dateCompleted: 'never', - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.history).to.eql([]); - expect(task.createdAt).not.to.equal('yesterday'); - expect(task.updatedAt).not.to.equal('tomorrow'); - expect(task.challenge).not.to.equal('no'); - expect(task.completed).to.equal(false); - expect(task.streak).to.equal(0); - expect(task.streak).not.to.equal('never'); }); + + expect(task.userId).to.equal(user._id); + expect(task.history).to.eql([]); + expect(task.createdAt).not.to.equal('yesterday'); + expect(task.updatedAt).not.to.equal('tomorrow'); + expect(task.challenge).not.to.equal('no'); + expect(task.completed).to.equal(false); + expect(task.streak).to.equal(0); + expect(task.streak).not.to.equal('never'); }); - it('ignores invalid fields', () => { - return user.post('/tasks', { + it('ignores invalid fields', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', notValid: true, - }).then((task) => { - expect(task).not.to.have.property('notValid'); }); + + expect(task).not.to.have.property('notValid'); }); }); context('habits', () => { - it('creates a habit', () => { - return user.post('/tasks', { + it('creates a habit', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', up: false, down: true, notes: 1976, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test habit'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('habit'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(true); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); }); - it('creates multiple habits', () => { - return user.post('/tasks', [{ + it('creates multiple habits', async () => { + let [task, task2] = await user.post('/tasks', [{ text: 'test habit', type: 'habit', up: false, @@ -131,63 +131,63 @@ describe('POST /tasks', () => { up: true, down: false, notes: 1977, - }]).then(([task, task2]) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test habit'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('habit'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(true); + }]); - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test habit 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('habit'); - expect(task2.up).to.eql(true); - expect(task2.down).to.eql(false); - }); + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test habit 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('habit'); + expect(task2.up).to.eql(true); + expect(task2.down).to.eql(false); }); - it('defaults to setting up and down to true', () => { - return user.post('/tasks', { + it('defaults to setting up and down to true', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', notes: 1976, - }).then((task) => { - expect(task.up).to.eql(true); - expect(task.down).to.eql(true); }); + + expect(task.up).to.eql(true); + expect(task.down).to.eql(true); }); - it('cannot create checklists', () => { - return user.post('/tasks', { + it('cannot create checklists', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', checklist: [ {_id: 123, completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task).not.to.have.property('checklist'); }); + + expect(task).not.to.have.property('checklist'); }); }); context('todos', () => { - it('creates a todo', () => { - return user.post('/tasks', { + it('creates a todo', async () => { + let task = await user.post('/tasks', { text: 'test todo', type: 'todo', notes: 1976, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test todo'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('todo'); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); }); - it('creates multiple todos', () => { - return user.post('/tasks', [{ + it('creates multiple todos', async () => { + let [task, task2] = await user.post('/tasks', [{ text: 'test todo', type: 'todo', notes: 1976, @@ -195,61 +195,61 @@ describe('POST /tasks', () => { text: 'test todo 2', type: 'todo', notes: 1977, - }]).then(([task, task2]) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test todo'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('todo'); + }]); - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test todo 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('todo'); - }); + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test todo 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('todo'); }); - it('can create checklists', () => { - return user.post('/tasks', { + it('can create checklists', async () => { + let task = await user.post('/tasks', { text: 'test todo', type: 'todo', checklist: [ {completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task.checklist).to.be.an('array'); - expect(task.checklist.length).to.eql(1); - expect(task.checklist[0]).to.be.an('object'); - expect(task.checklist[0].text).to.eql('checklist'); - expect(task.checklist[0].completed).to.eql(false); - expect(task.checklist[0]._id).to.be.a('string'); }); + + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); }); }); context('dailys', () => { - it('creates a daily', () => { + it('creates a daily', async () => { let now = new Date(); - return user.post('/tasks', { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', notes: 1976, frequency: 'daily', everyX: 5, startDate: now, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test daily'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('daily'); - expect(task.frequency).to.eql('daily'); - expect(task.everyX).to.eql(5); - expect(new Date(task.startDate)).to.eql(now); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + expect(task.frequency).to.eql('daily'); + expect(task.everyX).to.eql(5); + expect(new Date(task.startDate)).to.eql(now); }); - it('creates multiple dailys', () => { - return user.post('/tasks', [{ + it('creates multiple dailys', async () => { + let [task, task2] = await user.post('/tasks', [{ text: 'test daily', type: 'daily', notes: 1976, @@ -257,40 +257,40 @@ describe('POST /tasks', () => { text: 'test daily 2', type: 'daily', notes: 1977, - }]).then(([task, task2]) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test daily'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('daily'); + }]); - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test daily 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('daily'); - }); + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test daily 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('daily'); }); - it('defaults to a weekly frequency, with every day set', () => { - return user.post('/tasks', { + it('defaults to a weekly frequency, with every day set', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', - }).then((task) => { - expect(task.frequency).to.eql('weekly'); - expect(task.everyX).to.eql(1); - expect(task.repeat).to.eql({ - m: true, - t: true, - w: true, - th: true, - f: true, - s: true, - su: true, - }); + }); + + expect(task.frequency).to.eql('weekly'); + expect(task.everyX).to.eql(1); + expect(task.repeat).to.eql({ + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + su: true, }); }); - it('allows repeat field to be configured', () => { - return user.post('/tasks', { + it('allows repeat field to be configured', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', repeat: { @@ -298,66 +298,66 @@ describe('POST /tasks', () => { w: false, su: false, }, - }).then((task) => { - expect(task.repeat).to.eql({ - m: false, - t: true, - w: false, - th: true, - f: true, - s: true, - su: false, - }); + }); + + expect(task.repeat).to.eql({ + m: false, + t: true, + w: false, + th: true, + f: true, + s: true, + su: false, }); }); - it('defaults startDate to today', () => { + it('defaults startDate to today', async () => { let today = (new Date()).getDay(); - return user.post('/tasks', { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', - }).then((task) => { - expect((new Date(task.startDate)).getDay()).to.eql(today); }); + + expect((new Date(task.startDate)).getDay()).to.eql(today); }); - it('can create checklists', () => { - return user.post('/tasks', { + it('can create checklists', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', checklist: [ {completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task.checklist).to.be.an('array'); - expect(task.checklist.length).to.eql(1); - expect(task.checklist[0]).to.be.an('object'); - expect(task.checklist[0].text).to.eql('checklist'); - expect(task.checklist[0].completed).to.eql(false); - expect(task.checklist[0]._id).to.be.a('string'); }); + + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); }); }); context('rewards', () => { - it('creates a reward', () => { - return user.post('/tasks', { + it('creates a reward', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', notes: 1976, value: 10, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test reward'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('reward'); - expect(task.value).to.eql(10); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test reward'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('reward'); + expect(task.value).to.eql(10); }); - it('creates multiple rewards', () => { - return user.post('/tasks', [{ + it('creates multiple rewards', async () => { + let [task, task2] = await user.post('/tasks', [{ text: 'test reward', type: 'reward', notes: 1976, @@ -367,50 +367,50 @@ describe('POST /tasks', () => { type: 'reward', notes: 1977, value: 12, - }]).then(([task, task2]) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test reward'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('reward'); - expect(task.value).to.eql(11); + }]); - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test reward 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('reward'); - expect(task2.value).to.eql(12); - }); + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test reward'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('reward'); + expect(task.value).to.eql(11); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test reward 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('reward'); + expect(task2.value).to.eql(12); }); - it('defaults to a 0 value', () => { - return user.post('/tasks', { + it('defaults to a 0 value', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', - }).then((task) => { - expect(task.value).to.eql(0); }); + + expect(task.value).to.eql(0); }); - it('requires value to be coerced into a number', () => { - return user.post('/tasks', { + it('requires value to be coerced into a number', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', value: '10', - }).then((task) => { - expect(task.value).to.eql(10); }); + + expect(task.value).to.eql(10); }); - it('cannot create checklists', () => { - return user.post('/tasks', { + it('cannot create checklists', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', checklist: [ {_id: 123, completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task).not.to.have.property('checklist'); }); + + expect(task).not.to.have.property('checklist'); }); }); }); From 743f98d67d2b31087d8fd5b4ceb0ab5cc3143a78 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Jan 2016 17:46:31 +0100 Subject: [PATCH 304/976] make sure user.tasksOrder is update only when necessary --- test/api/v3/integration/tasks/POST-tasks.test.js | 15 +++++++++++++++ website/src/controllers/api-v3/tasks.js | 14 +++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index c8b9e9a37a..51999bc8a2 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -54,6 +54,21 @@ describe('POST /tasks', () => { }); }); + it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { + let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; + return expect(user.post('/tasks', { + type: 'habit', + })).to.eventually.be.rejected.and.eql({ // this block is necessary + code: 400, + error: 'BadRequest', + message: 'habit validation failed', + }).then(async () => { + let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; + + expect(updatedHabitsOrder).to.eql(originalHabitsOrder); + }); + }); + it('automatically sets "task.userId" to user\'s uuid', async () => { let task = await user.post('/tasks', { text: 'test habit', diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 25b5245a04..84ccf0a221 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -32,16 +32,28 @@ api.createTask = { let user = res.locals.user; let toSave = tasksData.map(taskData => { + // Validate that task.type is valid if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); let taskType = taskData.type; let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); newTask.userId = user._id; + + // Validate that the task is valid and throw if it isn't + // otherwise since we're saving user and task in parallel it could save the user with a tasksOrder that doens't match reality + let validationErrors = newTask.validateSync(); + if (validationErrors) throw validationErrors; + + // Otherwise update the user user.tasksOrder[`${taskType}s`].unshift(newTask._id); - return newTask.save(); + return newTask; }); + // If all tasks are valid, save everything, withough running validation again + toSave = toSave.map(task => task.save({ + validateBeforeSave: false, + })); toSave.unshift(user.save()); let results = await Q.all(toSave); From 27065b3f4dcf94eba758ce1c49c6bff20a12aba3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Jan 2016 18:21:06 +0100 Subject: [PATCH 305/976] more tests for user.tasksOrder --- .../v3/integration/tasks/POST-tasks.test.js | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 51999bc8a2..cf9eeea123 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -69,6 +69,38 @@ describe('POST /tasks', () => { }); }); + it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => { + let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; + return expect(user.post('/tasks', [ + {type: 'habit'}, // Missing text + {type: 'habit', text: 'valid'}, // Valid + ])).to.eventually.be.rejected.and.eql({ // this block is necessary + code: 400, + error: 'BadRequest', + message: 'habit validation failed', + }).then(async () => { + let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; + + expect(updatedHabitsOrder).to.eql(originalHabitsOrder); + }); + }); + + it('does not save any task sent in an array when 1 is invalid', async () => { + let originalTasks = await user.get('/tasks'); + return expect(user.post('/tasks', [ + {type: 'habit'}, // Missing text + {type: 'habit', text: 'valid'}, // Valid + ])).to.eventually.be.rejected.and.eql({ // this block is necessary + code: 400, + error: 'BadRequest', + message: 'habit validation failed', + }).then(async () => { + let updatedTasks = await user.get('/tasks'); + + expect(updatedTasks).to.eql(originalTasks); + }); + }); + it('automatically sets "task.userId" to user\'s uuid', async () => { let task = await user.post('/tasks', { text: 'test habit', @@ -133,6 +165,34 @@ describe('POST /tasks', () => { expect(task.down).to.eql(true); }); + it('updates user.tasksOrder.habits when a new habit is created', async () => { + let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; + let task = await user.post('/tasks', { + type: 'habit', + text: 'an habit', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.habits[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.habits.length).to.eql(originalHabitsOrderLen + 1); + }); + + it('updates user.tasksOrder.habits when multiple habits are created', async () => { + let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; + let [task, task2] = await user.post('/tasks', [{ + type: 'habit', + text: 'an habit', + }, { + type: 'habit', + text: 'another habit' + }]); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.habits[0]).to.eql(task2._id); + expect(updatedUser.tasksOrder.habits[1]).to.eql(task._id); + expect(updatedUser.tasksOrder.habits.length).to.eql(originalHabitsOrderLen + 2); + }); + it('creates multiple habits', async () => { let [task, task2] = await user.post('/tasks', [{ text: 'test habit', @@ -223,6 +283,34 @@ describe('POST /tasks', () => { expect(task2.type).to.eql('todo'); }); + it('updates user.tasksOrder.todos when a new todo is created', async () => { + let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; + let task = await user.post('/tasks', { + type: 'todo', + text: 'a todo', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.todos[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.todos.length).to.eql(originalTodosOrderLen + 1); + }); + + it('updates user.tasksOrder.todos when multiple todos are created', async () => { + let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; + let [task, task2] = await user.post('/tasks', [{ + type: 'todo', + text: 'a todo', + }, { + type: 'todo', + text: 'another todo' + }]); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.todos[0]).to.eql(task2._id); + expect(updatedUser.tasksOrder.todos[1]).to.eql(task._id); + expect(updatedUser.tasksOrder.todos.length).to.eql(originalTodosOrderLen + 2); + }); + it('can create checklists', async () => { let task = await user.post('/tasks', { text: 'test todo', @@ -285,6 +373,34 @@ describe('POST /tasks', () => { expect(task2.type).to.eql('daily'); }); + it('updates user.tasksOrder.dailys when a new daily is created', async () => { + let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; + let task = await user.post('/tasks', { + type: 'daily', + text: 'a daily', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.dailys[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.dailys.length).to.eql(originalDailysOrderLen + 1); + }); + + it('updates user.tasksOrder.dailys when multiple dailys are created', async () => { + let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; + let [task, task2] = await user.post('/tasks', [{ + type: 'daily', + text: 'a daily', + }, { + type: 'daily', + text: 'another daily' + }]); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.dailys[0]).to.eql(task2._id); + expect(updatedUser.tasksOrder.dailys[1]).to.eql(task._id); + expect(updatedUser.tasksOrder.dailys.length).to.eql(originalDailysOrderLen + 2); + }); + it('defaults to a weekly frequency, with every day set', async () => { let task = await user.post('/tasks', { text: 'test daily', @@ -397,6 +513,34 @@ describe('POST /tasks', () => { expect(task2.value).to.eql(12); }); + it('updates user.tasksOrder.rewards when a new reward is created', async () => { + let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; + let task = await user.post('/tasks', { + type: 'reward', + text: 'a reward', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.rewards[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.rewards.length).to.eql(originalRewardsOrderLen + 1); + }); + + it('updates user.tasksOrder.dreward when multiple rewards are created', async () => { + let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; + let [task, task2] = await user.post('/tasks', [{ + type: 'reward', + text: 'a reward', + }, { + type: 'reward', + text: 'another reward' + }]); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.rewards[0]).to.eql(task2._id); + expect(updatedUser.tasksOrder.rewards[1]).to.eql(task._id); + expect(updatedUser.tasksOrder.rewards.length).to.eql(originalRewardsOrderLen + 2); + }); + it('defaults to a 0 value', async () => { let task = await user.post('/tasks', { text: 'test reward', From a775c992ab7f588a9a5b6ddbd9019285fbc657ef Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Jan 2016 18:23:01 +0100 Subject: [PATCH 306/976] fix linting and add pending test --- test/api/v3/integration/tasks/DELETE-tasks_id.test.js | 1 + test/api/v3/integration/tasks/POST-tasks.test.js | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 91f02b7844..1d3e59ef15 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -63,5 +63,6 @@ describe('DELETE /tasks/:id', () => { }); it('cannot delete active challenge tasks'); // TODO after challenges are implemented + it('remove a task from user.tasksOrder'); // TODO }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index cf9eeea123..245cea7d28 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -184,7 +184,7 @@ describe('POST /tasks', () => { text: 'an habit', }, { type: 'habit', - text: 'another habit' + text: 'another habit', }]); let updatedUser = await user.get('/user'); @@ -302,7 +302,7 @@ describe('POST /tasks', () => { text: 'a todo', }, { type: 'todo', - text: 'another todo' + text: 'another todo', }]); let updatedUser = await user.get('/user'); @@ -392,7 +392,7 @@ describe('POST /tasks', () => { text: 'a daily', }, { type: 'daily', - text: 'another daily' + text: 'another daily', }]); let updatedUser = await user.get('/user'); @@ -532,7 +532,7 @@ describe('POST /tasks', () => { text: 'a reward', }, { type: 'reward', - text: 'another reward' + text: 'another reward', }]); let updatedUser = await user.get('/user'); From f80f41f76415ae6e875155f1347fe2bb37ec03fd Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 3 Jan 2016 09:47:27 -0600 Subject: [PATCH 307/976] lint: Correct linting errors in v3 tests --- .../v3/integration/tags/GET-tags_id.test.js | 2 +- .../v3/integration/tags/PUT-tags_id.test.js | 4 +- .../integration/tasks/DELETE-tasks_id.test.js | 7 +- .../v3/integration/tasks/GET-tasks.test.js | 3 +- .../v3/integration/tasks/GET-tasks_id.test.js | 8 +- .../v3/integration/tasks/POST-tasks.test.js | 290 ++++++++++-------- .../POST-tasks_id_score_direction.test.js | 74 ++--- .../v3/integration/tasks/PUT-tasks_id.test.js | 41 ++- ...LETE-tasks_taskId_checklist_itemId.test.js | 12 +- .../POST-tasks_taskId_checklist.test.js | 2 +- ...asks_taskId_checklist_itemId_score.test.js | 12 +- .../PUT-tasks_taskId_checklist_itemId.test.js | 24 +- .../DELETE-tasks_taskId_tags_tagId.test.js | 4 +- .../user/auth/POST-register_local.test.js | 18 +- .../api/v3/unit/libs/analyticsService.test.js | 68 ++-- test/api/v3/unit/libs/baseModel.test.js | 10 +- test/api/v3/unit/libs/buildManifest.test.js | 5 +- test/api/v3/unit/libs/email.test.js | 48 +-- test/api/v3/unit/libs/encryption.test.js | 2 +- test/api/v3/unit/libs/i18n.test.js | 2 +- test/api/v3/unit/libs/webhooks.test.js | 43 ++- .../api/v3/unit/middlewares/analytics.test.js | 7 +- .../v3/unit/middlewares/errorHandler.test.js | 6 +- .../unit/middlewares/getUserLanguage.test.js | 58 ++-- test/api/v3/unit/middlewares/response.js | 10 +- website/src/libs/api-v3/setupRoutes.js | 2 +- 26 files changed, 392 insertions(+), 370 deletions(-) diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index f8180be5ca..fbdf96312f 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -17,7 +17,7 @@ describe('GET /tags/:tagId', () => { return user.post('/tags', {name: 'Tag 1'}) .then((tag) => { createdTag = tag; - return user.get(`/tags/${createdTag._id}`) + return user.get(`/tags/${createdTag._id}`); }) .then((tag) => { expect(tag).to.deep.equal(createdTag); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index e8a4335444..94a76ec39c 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -12,13 +12,11 @@ describe('PUT /tags/:tagId', () => { }); it('updates a tag given it\'s id', () => { - let length; - return user.post('/tags', {name: 'Tag 1'}) .then((createdTag) => { return user.put(`/tags/${createdTag._id}`, { name: 'Tag updated', - ignored: true + ignored: true, }); }) .then((updatedTag) => { diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index ad92739c9a..1d3e59ef15 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -25,9 +25,9 @@ describe('DELETE /tasks/:id', () => { }); it('deletes a user\'s task', () => { - return user.del('/tasks/' + task._id) + return user.del(`/tasks/${task._id}`) .then(() => { - return expect(user.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + return expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -54,7 +54,7 @@ describe('DELETE /tasks/:id', () => { }); }) .then((task2) => { - return expect(user.del('/tasks/' + task2._id)).to.eventually.be.rejected.and.eql({ + return expect(user.del(`/tasks/${task2._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -63,5 +63,6 @@ describe('DELETE /tasks/:id', () => { }); it('cannot delete active challenge tasks'); // TODO after challenges are implemented + it('remove a task from user.tasksOrder'); // TODO }); }); diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 25bf8b1308..0772046f0b 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -1,6 +1,5 @@ import { generateUser, - translate as t, } from '../../../../helpers/api-integration.helper'; import Q from 'q'; @@ -41,5 +40,5 @@ describe('GET /tasks', () => { }); // TODO complete after task scoring is done - it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified') + it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified'); }); diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index 993e2c127f..a4ae455d6a 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -26,7 +26,7 @@ describe('GET /tasks/:id', () => { }); it('gets specified task', () => { - return user.get('/tasks/' + task._id) + return user.get(`/tasks/${task._id}`) .then((getTask) => { expect(getTask).to.eql(task); }); @@ -38,7 +38,9 @@ describe('GET /tasks/:id', () => { context('task cannot be accessed', () => { it('cannot get a non-existant task', () => { - return expect(user.get('/tasks/' + generateUUID())).to.eventually.be.rejected.and.eql({ + let dummyId = generateUUID(); + + return expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -57,7 +59,7 @@ describe('GET /tasks/:id', () => { type: 'habit', }); }).then((task) => { - return expect(anotherUser.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + return expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 30e4d3ce37..672c65b20c 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -6,14 +6,14 @@ import { describe('POST /tasks', () => { let user; - before(() => { + before(async () => { return generateUser().then((generatedUser) => { user = generatedUser; }); }); context('validates params', () => { - it('returns an error if req.body.type is absent', () => { + it('returns an error if req.body.type is absent', async () => { return expect(user.post('/tasks', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ @@ -23,7 +23,7 @@ describe('POST /tasks', () => { }); }); - it('returns an error if req.body.type is not valid', () => { + it('returns an error if req.body.type is not valid', async () => { return expect(user.post('/tasks', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ @@ -33,7 +33,7 @@ describe('POST /tasks', () => { }); }); - it('returns an error if req.body.text is absent', () => { + it('returns an error if req.body.text is absent', async () => { return expect(user.post('/tasks', { type: 'habit', })).to.eventually.be.rejected.and.eql({ @@ -43,19 +43,19 @@ describe('POST /tasks', () => { }); }); - it('automatically sets "task.userId" to user\'s uuid', () => { - return user.post('/tasks', { + it('automatically sets "task.userId" to user\'s uuid', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((task) => { - expect(task.userId).to.equal(user._id); }); + + expect(task.userId).to.equal(user._id); }); it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, - dateCompleted fields`, () => { - return user.post('/tasks', { + dateCompleted fields`, async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', userId: 123, @@ -66,146 +66,170 @@ describe('POST /tasks', () => { completed: true, streak: 25, dateCompleted: 'never', - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.history).to.eql([]); - expect(task.createdAt).not.to.equal('yesterday'); - expect(task.updatedAt).not.to.equal('tomorrow'); - expect(task.challenge).not.to.equal('no'); - expect(task.completed).to.equal(false); - expect(task.streak).to.equal(0); - expect(task.streak).not.to.equal('never'); }); + + expect(task.userId).to.equal(user._id); + expect(task.history).to.eql([]); + expect(task.createdAt).not.to.equal('yesterday'); + expect(task.updatedAt).not.to.equal('tomorrow'); + expect(task.challenge).not.to.equal('no'); + expect(task.completed).to.equal(false); + expect(task.streak).to.equal(0); + expect(task.streak).not.to.equal('never'); }); - it('ignores invalid fields', () => { - return user.post('/tasks', { + it('ignores invalid fields', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', notValid: true, - }).then((task) => { - expect(task).not.to.have.property('notValid'); }); + + expect(task).not.to.have.property('notValid'); }); }); context('habits', () => { - it('creates a habit', () => { - return user.post('/tasks', { + it('creates a habit', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', up: false, down: true, notes: 1976, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test habit'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('habit'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(true); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); }); - it('defaults to setting up and down to true', () => { - return user.post('/tasks', { + it('defaults to setting up and down to true', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', notes: 1976, - }).then((task) => { - expect(task.up).to.eql(true); - expect(task.down).to.eql(true); }); + + expect(task.up).to.eql(true); + expect(task.down).to.eql(true); }); - it('cannot create checklists', () => { - return user.post('/tasks', { + it('cannot create checklists', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', checklist: [ {_id: 123, completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task).not.to.have.property('checklist'); }); + + expect(task).not.to.have.property('checklist'); }); }); context('todos', () => { - it('creates a todo', () => { - return user.post('/tasks', { + it('creates a todo', async () => { + let task = await user.post('/tasks', { text: 'test todo', type: 'todo', notes: 1976, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test todo'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('todo'); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); }); - it('can create checklists', () => { - return user.post('/tasks', { + it('updates user.tasksOrder.todos when a new todo is created', async () => { + let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; + let task = await user.post('/tasks', { + type: 'todo', + text: 'a todo', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.todos[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.todos.length).to.eql(originalTodosOrderLen + 1); + }); + + it('can create checklists', async () => { + let task = await user.post('/tasks', { text: 'test todo', type: 'todo', checklist: [ {completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task.checklist).to.be.an('array'); - expect(task.checklist.length).to.eql(1); - expect(task.checklist[0]).to.be.an('object'); - expect(task.checklist[0].text).to.eql('checklist'); - expect(task.checklist[0].completed).to.eql(false); - expect(task.checklist[0]._id).to.be.a('string'); }); + + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); }); }); context('dailys', () => { - it('creates a daily', () => { + it('creates a daily', async () => { let now = new Date(); - return user.post('/tasks', { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', notes: 1976, frequency: 'daily', everyX: 5, startDate: now, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test daily'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('daily'); - expect(task.frequency).to.eql('daily'); - expect(task.everyX).to.eql(5); - expect(new Date(task.startDate)).to.eql(now); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + expect(task.frequency).to.eql('daily'); + expect(task.everyX).to.eql(5); + expect(new Date(task.startDate)).to.eql(now); }); - it('defaults to a weekly frequency, with every day set', () => { - return user.post('/tasks', { + it('updates user.tasksOrder.dailys when a new daily is created', async () => { + let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; + let task = await user.post('/tasks', { + type: 'daily', + text: 'a daily', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.dailys[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.dailys.length).to.eql(originalDailysOrderLen + 1); + }); + + it('defaults to a weekly frequency, with every day set', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', - }).then((task) => { - expect(task.frequency).to.eql('weekly'); - expect(task.everyX).to.eql(1); - expect(task.repeat).to.eql({ - m: true, - t: true, - w: true, - th: true, - f: true, - s: true, - su: true, - }); + }); + + expect(task.frequency).to.eql('weekly'); + expect(task.everyX).to.eql(1); + expect(task.repeat).to.eql({ + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + su: true, }); }); - it('allows repeat field to be configured', () => { - return user.post('/tasks', { + it('allows repeat field to be configured', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', repeat: { @@ -213,93 +237,105 @@ describe('POST /tasks', () => { w: false, su: false, }, - }).then((task) => { - expect(task.repeat).to.eql({ - m: false, - t: true, - w: false, - th: true, - f: true, - s: true, - su: false, - }); + }); + + expect(task.repeat).to.eql({ + m: false, + t: true, + w: false, + th: true, + f: true, + s: true, + su: false, }); }); - it('defaults startDate to today', () => { + it('defaults startDate to today', async () => { let today = (new Date()).getDay(); - return user.post('/tasks', { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', - }).then((task) => { - expect((new Date(task.startDate)).getDay()).to.eql(today); }); + + expect((new Date(task.startDate)).getDay()).to.eql(today); }); - it('can create checklists', () => { - return user.post('/tasks', { + it('can create checklists', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', checklist: [ {completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task.checklist).to.be.an('array'); - expect(task.checklist.length).to.eql(1); - expect(task.checklist[0]).to.be.an('object'); - expect(task.checklist[0].text).to.eql('checklist'); - expect(task.checklist[0].completed).to.eql(false); - expect(task.checklist[0]._id).to.be.a('string'); }); + + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); }); }); context('rewards', () => { - it('creates a reward', () => { - return user.post('/tasks', { + it('creates a reward', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', notes: 1976, value: 10, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test reward'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('reward'); - expect(task.value).to.eql(10); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test reward'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('reward'); + expect(task.value).to.eql(10); }); - it('defaults to a 0 value', () => { - return user.post('/tasks', { + it('updates user.tasksOrder.rewards when a new reward is created', async () => { + let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; + let task = await user.post('/tasks', { + type: 'reward', + text: 'a reward', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.rewards[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.rewards.length).to.eql(originalRewardsOrderLen + 1); + }); + + it('defaults to a 0 value', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', - }).then((task) => { - expect(task.value).to.eql(0); }); + + expect(task.value).to.eql(0); }); - it('requires value to be coerced into a number', () => { - return user.post('/tasks', { + it('requires value to be coerced into a number', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', - value: "10", - }).then((task) => { - expect(task.value).to.eql(10); + value: '10', }); + + expect(task.value).to.eql(10); }); - it('cannot create checklists', () => { - return user.post('/tasks', { + it('cannot create checklists', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', checklist: [ {_id: 123, completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task).not.to.have.property('checklist'); }); + + expect(task).not.to.have.property('checklist'); }); }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 4e21a31b20..2978d93657 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -47,29 +47,29 @@ describe('POST /tasks/:id/score/:direction', () => { it('completes todo when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/tasks/${todo._id}`)) + .then(() => user.get(`/tasks/${todo._id}`)) .then((task) => expect(task.completed).to.equal(true)); }); it('moves completed todos out of user.tasksOrder.todos', () => { return user.get('/user') - .then(user => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + .then(usr => { + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); }).then(() => user.post(`/tasks/${todo._id}/score/up`)) .then(() => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { expect(updatedTask.completed).to.equal(true); return user.get('/user'); }) - .then((user) => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1) + .then((usr) => { + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); }); }); it('moves un-completed todos back into user.tasksOrder.todos', () => { return user.get('/user') - .then(user => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + .then(usr => { + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); }).then(() => user.post(`/tasks/${todo._id}/score/up`)) .then(() => user.post(`/tasks/${todo._id}/score/down`)) .then(() => user.get(`/tasks/${todo._id}`)) @@ -77,16 +77,16 @@ describe('POST /tasks/:id/score/:direction', () => { expect(updatedTask.completed).to.equal(false); return user.get('/user'); }) - .then((user) => { - let l = user.tasksOrder.todos.length; - expect(user.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); - expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom + .then((usr) => { + let l = usr.tasksOrder.todos.length; + expect(usr.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom }); }); it('uncompletes todo when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/tasks/${todo._id}`)) + .then(() => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { expect(updatedTask.completed).to.equal(false); }); @@ -98,7 +98,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s mp when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); @@ -106,7 +106,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s mp when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); @@ -114,7 +114,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s exp when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); @@ -122,7 +122,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s exp when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); @@ -130,7 +130,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s gold when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -138,7 +138,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s gold when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); @@ -159,13 +159,13 @@ describe('POST /tasks/:id/score/:direction', () => { it('completes daily when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/tasks/${daily._id}`)) + .then(() => user.get(`/tasks/${daily._id}`)) .then((task) => expect(task.completed).to.equal(true)); }); it('uncompletes daily when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/tasks/${daily._id}`)) + .then(() => user.get(`/tasks/${daily._id}`)) .then((task) => expect(task.completed).to.equal(false)); }); @@ -175,7 +175,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s mp when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); @@ -183,7 +183,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s mp when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); @@ -191,7 +191,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s exp when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); @@ -199,7 +199,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s exp when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); @@ -207,7 +207,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s gold when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -215,7 +215,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s gold when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); @@ -223,7 +223,7 @@ describe('POST /tasks/:id/score/:direction', () => { }); context('habits', () => { - let habit, minusHabit, plusHabit, neitherHabit; + let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars beforeEach(() => { return user.post('/tasks', { @@ -242,7 +242,7 @@ describe('POST /tasks/:id/score/:direction', () => { text: 'test plus habit', type: 'habit', down: false, - }) + }); }).then((task) => { plusHabit = task; user.post('/tasks', { @@ -250,7 +250,7 @@ describe('POST /tasks/:id/score/:direction', () => { type: 'habit', up: false, down: false, - }) + }); }).then((task) => { neitherHabit = task; }); @@ -262,7 +262,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s mp when direction is up', () => { return user.post(`/tasks/${habit._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); @@ -270,7 +270,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s mp when direction is down', () => { return user.post(`/tasks/${habit._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); @@ -278,7 +278,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s exp when direction is up', () => { return user.post(`/tasks/${habit._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); @@ -286,7 +286,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s gold when direction is up', () => { return user.post(`/tasks/${habit._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -308,7 +308,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('purchases reward', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); }); @@ -316,7 +316,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('does not change user\'s mp', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); @@ -324,7 +324,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('does not change user\'s exp', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp).to.equal(updatedUser.stats.exp); }); @@ -332,7 +332,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('does not allow a down direction', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 4e5ac308c5..3092d3bd84 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -1,6 +1,5 @@ import { generateUser, - translate as t, } from '../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -28,7 +27,7 @@ describe('PUT /tasks/:id', () => { it(`ignores setting _id, type, userId, history, createdAt, updatedAt, challenge, completed, streak, dateCompleted fields`, () => { - user.put('/tasks/' + task._id, { + user.put(`/tasks/${task._id}`, { _id: 123, type: 'daily', userId: 123, @@ -54,7 +53,7 @@ describe('PUT /tasks/:id', () => { }); it('ignores invalid fields', () => { - user.put('/tasks/' + task._id, { + user.put(`/tasks/${task._id}`, { notValid: true, }).then((savedTask) => { expect(savedTask.notValid).to.be.a('undefined'); @@ -118,16 +117,16 @@ describe('PUT /tasks/:id', () => { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, - ] - }).then((savedTodo) => { + ], + }).then(() => { return user.put(`/tasks/${todo._id}`, { checklist: [ {text: 789, completed: false}, - ] + ], }); }).then((savedTodo2) => { expect(savedTodo2.checklist.length).to.equal(1); - expect(savedTodo2.checklist[0].text).to.equal("789"); + expect(savedTodo2.checklist[0].text).to.equal('789'); expect(savedTodo2.checklist[0].completed).to.equal(false); }); }); @@ -136,9 +135,9 @@ describe('PUT /tasks/:id', () => { let finalUUID = generateUUID(); return user.put(`/tasks/${todo._id}`, { tags: [generateUUID(), generateUUID()], - }).then((savedTodo) => { + }).then(() => { return user.put(`/tasks/${todo._id}`, { - tags: [finalUUID] + tags: [finalUUID], }); }).then((savedTodo2) => { expect(savedTodo2.tags.length).to.equal(1); @@ -161,8 +160,6 @@ describe('PUT /tasks/:id', () => { }); it('updates a daily', () => { - let now = new Date(); - return user.put(`/tasks/${daily._id}`, { text: 'some new text', notes: 'some new notes', @@ -181,16 +178,16 @@ describe('PUT /tasks/:id', () => { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, - ] - }).then((savedDaily) => { + ], + }).then(() => { return user.put(`/tasks/${daily._id}`, { checklist: [ {text: 789, completed: false}, - ] + ], }); }).then((savedDaily2) => { expect(savedDaily2.checklist.length).to.equal(1); - expect(savedDaily2.checklist[0].text).to.equal("789"); + expect(savedDaily2.checklist[0].text).to.equal('789'); expect(savedDaily2.checklist[0].completed).to.equal(false); }); }); @@ -199,9 +196,9 @@ describe('PUT /tasks/:id', () => { let finalUUID = generateUUID(); return user.put(`/tasks/${daily._id}`, { tags: [generateUUID(), generateUUID()], - }).then((savedDaily) => { + }).then(() => { return user.put(`/tasks/${daily._id}`, { - tags: [finalUUID] + tags: [finalUUID], }); }).then((savedDaily2) => { expect(savedDaily2.tags.length).to.equal(1); @@ -212,12 +209,12 @@ describe('PUT /tasks/:id', () => { it('updates repeat, even if frequency is set to daily', () => { return user.put(`/tasks/${daily._id}`, { frequency: 'daily', - }).then((savedDaily) => { + }).then(() => { return user.put(`/tasks/${daily._id}`, { repeat: { m: false, - su: false - } + su: false, + }, }); }).then((savedDaily2) => { expect(savedDaily2.repeat).to.eql({ @@ -235,7 +232,7 @@ describe('PUT /tasks/:id', () => { it('updates everyX, even if frequency is set to weekly', () => { return user.put(`/tasks/${daily._id}`, { frequency: 'weekly', - }).then((savedDaily) => { + }).then(() => { return user.put(`/tasks/${daily._id}`, { everyX: 5, }); @@ -281,7 +278,7 @@ describe('PUT /tasks/:id', () => { it('requires value to be coerced into a number', () => { return user.put(`/tasks/${reward._id}`, { - value: "100", + value: '100', }).then((task) => { expect(task.value).to.eql(100); }); diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index 094619e1fd..d013878a74 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -46,15 +46,13 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); }); - it('does not work with rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('does not work with rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.del(`/tasks/${reward._id}/checklist/${generateUUID()}`); - }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index 4654871ac5..e9b695effd 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -66,7 +66,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('fails on task not found', () => { return expect(user.post(`/tasks/${generateUUID()}/checklist`, { - text: 'Checklist Item 1' + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 727aaea74c..667ef41446 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -45,15 +45,13 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); }); - it('fails on rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('fails on rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`); - }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index 149d1bedca..988574bbf8 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -32,30 +32,26 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); }); - it('fails on habits', () => { - let habit; - return expect(user.post('/tasks', { + it('fails on habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.put(`/tasks/${habit._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.put(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), }); }); - it('fails on rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('fails on rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.put(`/tasks/${reward._id}/checklist/${generateUUID()}`); - }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.put(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index ddb78b7262..c01b71fa2c 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -26,7 +26,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }).then(createdTag => { tag = createdTag; return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(savedTask => { + }).then(() => { return user.del(`/tasks/${task._id}/tags/${tag._id}`); }).then(() => user.get(`/tasks/${task._id}`)) .then(updatedTask => { @@ -35,8 +35,6 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('only deletes existing tags', () => { - let task; - return expect(user.post('/tasks', { type: 'habit', text: 'Task with tag', diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 0e11e7b924..a0f83717ea 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -37,7 +37,7 @@ describe('POST /user/auth/local/register', () => { username, email, password, - confirmPassword: confirmPassword, + confirmPassword, })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -104,8 +104,8 @@ describe('POST /user/auth/local/register', () => { return expect(api.post('/user/auth/local/register', { username, - email: email, - confirmPassword: confirmPassword, + email, + confirmPassword, })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -123,7 +123,7 @@ describe('POST /user/auth/local/register', () => { return generateUser({ 'auth.local.username': username, 'auth.local.lowerCaseUsername': username, - 'auth.local.email': email + 'auth.local.email': email, }); }); @@ -133,9 +133,9 @@ describe('POST /user/auth/local/register', () => { let password = 'password'; return expect(api.post('/user/auth/local/register', { - username: username, - email: uniqueEmail, - password: password, + username, + email: uniqueEmail, + password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -181,7 +181,7 @@ describe('POST /user/auth/local/register', () => { }).then((user) => { expect(user.flags.tour).to.not.be.empty; - each(user.flags.tour, (value, attribute) => { + each(user.flags.tour, (value) => { expect(value).to.eql(-2); }); }); @@ -232,7 +232,7 @@ describe('POST /user/auth/local/register', () => { }).then((user) => { expect(user.flags.tour).to.not.be.empty; - each(user.flags.tutorial.common, (value, attribute) => { + each(user.flags.tutorial.common, (value) => { expect(value).to.eql(true); }); }); diff --git a/test/api/v3/unit/libs/analyticsService.test.js b/test/api/v3/unit/libs/analyticsService.test.js index 99657b270a..8ff14c2408 100644 --- a/test/api/v3/unit/libs/analyticsService.test.js +++ b/test/api/v3/unit/libs/analyticsService.test.js @@ -6,12 +6,12 @@ describe('analyticsService', () => { let amplitudeNock, gaNock; beforeEach(() => { - amplitudeNock = nock( 'https://api.amplitude.com') + amplitudeNock = nock('https://api.amplitude.com') .filteringPath(/httpapi.*/g, '') .post('/') .reply(200, {status: 'OK'}); - gaNock = nock( 'http://www.google-analytics.com'); + gaNock = nock('http://www.google-analytics.com'); }); describe('#track', () => { @@ -23,14 +23,14 @@ describe('analyticsService', () => { category: 'behavior', uuid: 'unique-user-id', resting: true, - cronCount: 5 + cronCount: 5, }; }); context('Amplitude', () => { it('calls out to amplitude', () => { return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -42,7 +42,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -52,7 +52,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*platform.*server.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -62,79 +62,79 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*event_properties%22%3A%7B%22category%22%3A%22behavior%22%2C%22resting%22%3Atrue%2C%22cronCount%22%3A5%7D%2C%22.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); it('sends english item name for gear if itemKey is provided', () => { - data.itemKey = 'headAccessory_special_foxEars' + data.itemKey = 'headAccessory_special_foxEars'; amplitudeNock .filteringPath(/httpapi.*itemName.*Fox%20Ears.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); it('sends english item name for egg if itemKey is provided', () => { - data.itemKey = 'Wolf' + data.itemKey = 'Wolf'; amplitudeNock .filteringPath(/httpapi.*itemName.*Wolf%20Egg.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); it('sends english item name for food if itemKey is provided', () => { - data.itemKey = 'Cake_Skeleton' + data.itemKey = 'Cake_Skeleton'; amplitudeNock .filteringPath(/httpapi.*itemName.*Bare%20Bones%20Cake.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); it('sends english item name for hatching potion if itemKey is provided', () => { - data.itemKey = 'Golden' + data.itemKey = 'Golden'; amplitudeNock .filteringPath(/httpapi.*itemName.*Golden%20Hatching%20Potion.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); it('sends english item name for quest if itemKey is provided', () => { - data.itemKey = 'atom1' + data.itemKey = 'atom1'; amplitudeNock .filteringPath(/httpapi.*itemName.*Attack%20of%20the%20Mundane%2C%20Part%201%3A%20Dish%20Disaster!.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); it('sends english item name for purchased spell if itemKey is provided', () => { - data.itemKey = 'seafoam' + data.itemKey = 'seafoam'; amplitudeNock .filteringPath(/httpapi.*itemName.*Seafoam.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -142,14 +142,14 @@ describe('analyticsService', () => { it('sends user data if provided', () => { let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; let user = { - stats: stats, + stats, contributor: { level: 1 }, purchased: { plan: { planId: 'foo-plan' } }, flags: {tour: {intro: -2}}, habits: [{_id: 'habit'}], dailys: [{_id: 'daily'}], todos: [{_id: 'todo'}], - rewards: [{_id: 'reward'}] + rewards: [{_id: 'reward'}], }; data.user = user; @@ -158,7 +158,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -171,7 +171,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { gaNock.done(); }); }); @@ -182,7 +182,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { gaNock.done(); }); }); @@ -201,14 +201,14 @@ describe('analyticsService', () => { purchaseValue: 8, purchaseType: 'checkout', gift: false, - quantity: 1 + quantity: 1, }; }); context('Amplitude', () => { it('calls out to amplitude', () => { return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -220,7 +220,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -230,7 +230,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*platform.*server.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -240,7 +240,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*aypal-checkout%22%2C%22paymentMethod%22%3A%22PayPal%22%2C%22itemPurchased%22%3A%22Gems%22%2C%22purchaseType%22%3A%22checkout%22%2C%22gift%22%3Afalse%2C%22quantity%22%3A1%7D%2C%22event_type%22%3A%22purchase%22%2C%22revenue.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -248,14 +248,14 @@ describe('analyticsService', () => { it('sends user data if provided', () => { let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; let user = { - stats: stats, + stats, contributor: { level: 1 }, purchased: { plan: { planId: 'foo-plan' } }, flags: {tour: {intro: -2}}, habits: [{_id: 'habit'}], dailys: [{_id: 'daily'}], todos: [{_id: 'todo'}], - rewards: [{_id: 'reward'}] + rewards: [{_id: 'reward'}], }; data.user = user; @@ -264,7 +264,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -277,7 +277,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { gaNock.done(); }); }); @@ -290,7 +290,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { gaNock.done(); }); }); diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 98835b1c29..95960c704d 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -32,7 +32,7 @@ describe('Base model plugin', () => { it('can sanitize input objects', () => { baseModel(schema, { - noSet: ['noUpdateForMe'] + noSet: ['noUpdateForMe'], }); expect(schema.statics.sanitize).to.exist; @@ -45,7 +45,7 @@ describe('Base model plugin', () => { it('accepts an array of additional fields to sanitize at runtime', () => { baseModel(schema, { - noSet: ['noUpdateForMe'] + noSet: ['noUpdateForMe'], }); expect(schema.statics.sanitize).to.exist; @@ -59,7 +59,7 @@ describe('Base model plugin', () => { it('can make fields private', () => { baseModel(schema, { - private: ['amPrivate'] + private: ['amPrivate'], }); expect(schema.options.toJSON.transform).to.exist; @@ -73,7 +73,7 @@ describe('Base model plugin', () => { it('accepts a further transform function for toJSON', () => { let options = { private: ['amPrivate'], - toJSONTransform: sandbox.stub().returns(true) + toJSONTransform: sandbox.stub().returns(true), }; baseModel(schema, options); @@ -88,7 +88,7 @@ describe('Base model plugin', () => { it('accepts a transform function for sanitize', () => { let options = { private: ['amPrivate'], - sanitizeTransform: sandbox.stub().returns(true) + sanitizeTransform: sandbox.stub().returns(true), }; baseModel(schema, options); diff --git a/test/api/v3/unit/libs/buildManifest.test.js b/test/api/v3/unit/libs/buildManifest.test.js index d03e19c9eb..0978d8fb41 100644 --- a/test/api/v3/unit/libs/buildManifest.test.js +++ b/test/api/v3/unit/libs/buildManifest.test.js @@ -11,8 +11,9 @@ describe('Build Manifest', () => { }); it('throws an error in case the page does not exist', () => { - let getManifestFilesFn = () => { getManifestFiles('strange name here') }; - expect(getManifestFilesFn).to.throw(Error); + expect(() => { + getManifestFiles('strange name here'); + }).to.throw(Error); }); }); }); diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index 353ac0ad09..bb7741dce7 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -1,3 +1,4 @@ +/* eslint-disable global-require */ import request from 'request'; import nconf from 'nconf'; import nodemailer from 'nodemailer'; @@ -14,21 +15,21 @@ function getUser () { }, facebook: { emails: [{ - value: 'email@facebook' + value: 'email@facebook', }], displayName: 'fb display name', - } + }, }, profile: { name: 'profile name', }, preferences: { emailNotifications: { - unsubscribeFromAll: false + unsubscribeFromAll: false, }, }, }; -}; +} describe('emails', () => { let pathToEmailLib = '../../../../../website/src/libs/api-v3/email'; @@ -63,7 +64,7 @@ describe('emails', () => { attachEmail.send(); expect(sendMailSpy).to.be.calledOnce; deferred.reject(); - deferred.promise.catch((err) => { + deferred.promise.catch(() => { expect(logger.error).to.be.calledOnce; done(); }); @@ -93,11 +94,11 @@ describe('emails', () => { let attachEmail = require(pathToEmailLib); let getUserInfo = attachEmail.getUserInfo; let user = getUser(); - delete user.profile['name']; - delete user.auth['local']; + delete user.profile.name; + delete user.auth.local; let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); - + expect(data).to.have.property('name', user.auth.facebook.displayName); expect(data).to.have.property('email', user.auth.facebook.emails[0].value); expect(data).to.have.property('_id', user._id); @@ -108,12 +109,12 @@ describe('emails', () => { let attachEmail = require(pathToEmailLib); let getUserInfo = attachEmail.getUserInfo; let user = getUser(); - delete user.profile['name']; - delete user.auth.local['email'] - delete user.auth['facebook']; + delete user.profile.name; + delete user.auth.local.email; + delete user.auth.facebook; let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); - + expect(data).to.have.property('name', user.auth.local.username); expect(data).not.to.have.property('email'); expect(data).to.have.property('_id', user._id); @@ -148,8 +149,8 @@ describe('emails', () => { to: sinon.match((value) => { return Array.isArray(value) && value[0].name === mailingInfo.name; }, 'matches mailing info array'), - } - } + }, + }, })); }); @@ -160,7 +161,7 @@ describe('emails', () => { let emailType = 'an email type'; let mailingInfo = { name: 'my name', - //email: 'my@email', + // email: 'my@email', }; sendTxnEmail(mailingInfo, emailType); @@ -180,8 +181,8 @@ describe('emails', () => { data: { emailType: sinon.match.same(emailType), to: sinon.match(val => val[0]._id === mailingInfo._id), - } - } + }, + }, })); }); @@ -194,7 +195,7 @@ describe('emails', () => { name: 'my name', email: 'my@email', }; - let variables = [1,2,3]; + let variables = [1, 2, 3]; sendTxnEmail(mailingInfo, emailType, variables); expect(request.post).to.be.calledWith(sinon.match({ @@ -204,13 +205,12 @@ describe('emails', () => { return value[0].name === 'BASE_URL'; }, 'matches variables'), personalVariables: sinon.match((value) => { - return (value[0].rcpt === mailingInfo.email - && value[0].vars[0].name === 'RECIPIENT_NAME' - && value[0].vars[1].name === 'RECIPIENT_UNSUB_URL' - ); + return value[0].rcpt === mailingInfo.email && + value[0].vars[0].name === 'RECIPIENT_NAME' && + value[0].vars[1].name === 'RECIPIENT_UNSUB_URL'; }, 'matches personal variables'), - } - } + }, + }, })); }); }); diff --git a/test/api/v3/unit/libs/encryption.test.js b/test/api/v3/unit/libs/encryption.test.js index dcab9bffd3..34c159ed02 100644 --- a/test/api/v3/unit/libs/encryption.test.js +++ b/test/api/v3/unit/libs/encryption.test.js @@ -1,4 +1,4 @@ -import { +import { encrypt, decrypt, } from '../../../../../website/src/libs/api-v3/encryption'; diff --git a/test/api/v3/unit/libs/i18n.test.js b/test/api/v3/unit/libs/i18n.test.js index 5bafc201dc..1136f255a6 100644 --- a/test/api/v3/unit/libs/i18n.test.js +++ b/test/api/v3/unit/libs/i18n.test.js @@ -34,7 +34,7 @@ describe('i18n', () => { describe('localePath', () => { it('is an absolute path to common/locales/', () => { expect(localePath).to.match(/.*\/common\/locales\//); - expect(localePath) + expect(localePath); }); }); diff --git a/test/api/v3/unit/libs/webhooks.test.js b/test/api/v3/unit/libs/webhooks.test.js index 62aeee3922..9bef501257 100644 --- a/test/api/v3/unit/libs/webhooks.test.js +++ b/test/api/v3/unit/libs/webhooks.test.js @@ -2,7 +2,6 @@ import request from 'request'; import { sendTaskWebhook } from '../../../../../website/src/libs/api-v3/webhook'; describe('webhooks', () => { - beforeEach(() => { sandbox.stub(request, 'post'); }); @@ -15,12 +14,12 @@ describe('webhooks', () => { let task = { details: { _id: 'task-id' }, delta: 1.4, - direction: 'up' + direction: 'up', }; let data = { - task: task, - user: { _id: 'user-id' } + task, + user: { _id: 'user-id' }, }; it('does not send if no webhook endpoints exist', () => { @@ -37,8 +36,8 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: false, - url: 'http://example.org/endpoint' - } + url: 'http://example.org/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -52,8 +51,8 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: true, - url: 'http://malformedurl/endpoint' - } + url: 'http://malformedurl/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -67,8 +66,8 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: true, - url: 'http://example.org/endpoint' - } + url: 'http://example.org/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -81,10 +80,10 @@ describe('webhooks', () => { task: { _id: 'task-id' }, delta: 1.4, user: { - _id: 'user-id' - } + _id: 'user-id', + }, }, - json: true + json: true, }); }); @@ -94,14 +93,14 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: true, - url: 'http://example.org/endpoint' + url: 'http://example.org/endpoint', }, 'second-webhook': { sort: 1, id: 'second-webhook', enabled: true, - url: 'http://example.com/2/endpoint' - } + url: 'http://example.com/2/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -114,10 +113,10 @@ describe('webhooks', () => { task: { _id: 'task-id' }, delta: 1.4, user: { - _id: 'user-id' - } + _id: 'user-id', + }, }, - json: true + json: true, }); expect(request.post).to.be.calledWith({ url: 'http://example.com/2/endpoint', @@ -126,10 +125,10 @@ describe('webhooks', () => { task: { _id: 'task-id' }, delta: 1.4, user: { - _id: 'user-id' - } + _id: 'user-id', + }, }, - json: true + json: true, }); }); }); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index e2808bc881..bcaa7898e2 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -1,12 +1,13 @@ +/* eslint-disable global-require */ import { generateRes, generateReq, generateNext, } from '../../../../helpers/api-unit.helper'; -import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService' +import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; import nconf from 'nconf'; -describe('analytics middleware', function() { +describe('analytics middleware', () => { let res, req, next; let pathToAnalyticsMiddleware = '../../../../../website/src/middlewares/api-v3/analytics'; @@ -23,7 +24,7 @@ describe('analytics middleware', function() { delete require.cache[require.resolve(pathToAnalyticsMiddleware)]; }); - it('attaches analytics object res.locals', function() { + it('attaches analytics object res.locals', () => { let attachAnalytics = require(pathToAnalyticsMiddleware); attachAnalytics(req, res, next); diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 80e1ab08d1..269390b35b 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -115,7 +115,7 @@ describe('errorHandler', () => { error: 'BadRequest', message: 'Invalid request parameters.', errors: [ - {param: error[0].param, value: error[0].value, message: error[0].msg} + { param: error[0].param, value: error[0].value, message: error[0].msg }, ], }); }); @@ -142,8 +142,8 @@ describe('errorHandler', () => { error: 'BadRequest', message: 'User validation failed.', errors: [ - {path: 'auth.local.email', message: 'Invalid email.', value: 'not an email'} - ] + { path: 'auth.local.email', message: 'Invalid email.', value: 'not an email' }, + ], }); }); diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 1ee185915b..43b0cb0ab8 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -7,15 +7,13 @@ import getUserLanguage from '../../../../../website/src/middlewares/api-v3/getUs import { i18n } from '../../../../../common'; import Q from 'q'; import { model as User } from '../../../../../website/src/models/user'; -import { translations } from '../../../../../website/src/libs/api-v3/i18n'; -import accepts from 'accepts'; describe('getUserLanguage', () => { let res, req, next; - let checkResT = (req) => { - expect(res.t).to.be.a('function'); - expect(res.t('help')).to.equal(i18n.t('help', req.language)); + let checkResT = (resToCheck) => { + expect(resToCheck.t).to.be.a('function'); + expect(resToCheck.t('help')).to.equal(i18n.t('help', req.language)); }; beforeEach(() => { @@ -32,7 +30,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('es'); - checkResT(req); + checkResT(res); }); it('falls back to english if the query parameter language does not exists', () => { @@ -42,7 +40,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); }); it('uses query even if the request includes a user and session', () => { @@ -59,12 +57,12 @@ describe('getUserLanguage', () => { }; req.session = { - userId: 123 + userId: 123, }; getUserLanguage(req, res, next); expect(req.language).to.equal('es'); - checkResT(req); + checkResT(res); }); }); @@ -80,7 +78,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('it'); - checkResT(req); + checkResT(res); }); it('falls back to english if the user preferred language is not avalaible', (done) => { @@ -94,7 +92,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); @@ -109,34 +107,34 @@ describe('getUserLanguage', () => { }; req.session = { - userId: 123 + userId: 123, }; getUserLanguage(req, res, next); expect(req.language).to.equal('it'); - checkResT(req); + checkResT(res); }); }); context('request with session', () => { it('uses the user preferred language if avalaible', (done) => { sandbox.stub(User, 'findOne').returns({ - exec() { + exec () { return Q.resolve({ preferences: { language: 'it', - } + }, }); - } + }, }); req.session = { - userId: 123 + userId: 123, }; getUserLanguage(req, res, () => { expect(req.language).to.equal('it'); - checkResT(req); + checkResT(res); done(); }); }); @@ -148,7 +146,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('pt'); - checkResT(req); + checkResT(res); done(); }); }); @@ -158,7 +156,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('he'); - checkResT(req); + checkResT(res); done(); }); }); @@ -168,7 +166,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('he'); - checkResT(req); + checkResT(res); done(); }); }); @@ -178,7 +176,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('fr'); - checkResT(req); + checkResT(res); done(); }); }); @@ -188,7 +186,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('fr'); - checkResT(req); + checkResT(res); done(); }); }); @@ -198,7 +196,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es'); - checkResT(req); + checkResT(res); done(); }); }); @@ -208,7 +206,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es_419'); - checkResT(req); + checkResT(res); done(); }); }); @@ -218,7 +216,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es_419'); - checkResT(req); + checkResT(res); done(); }); }); @@ -228,7 +226,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('zh_TW'); - checkResT(req); + checkResT(res); done(); }); }); @@ -238,7 +236,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); @@ -248,7 +246,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); @@ -258,7 +256,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js index ca3d160908..25916e1ef3 100644 --- a/test/api/v3/unit/middlewares/response.js +++ b/test/api/v3/unit/middlewares/response.js @@ -3,9 +3,9 @@ import { generateReq, generateNext, } from '../../../../helpers/api-unit.helper'; -import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response' +import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response'; -describe('response middleware', function() { +describe('response middleware', () => { let res, req, next; beforeEach(() => { @@ -15,13 +15,13 @@ describe('response middleware', function() { }); - it('attaches respond method to res', function() { + it('attaches respond method to res', () => { responseMiddleware(req, res, next); expect(res.respond).to.exist; }); - it('can be used to respond to requests', function() { + it('can be used to respond to requests', () => { responseMiddleware(req, res, next); res.respond(200, {field: 1}); @@ -34,7 +34,7 @@ describe('response middleware', function() { }); }); - it('treats status >= 400 as failures', function() { + it('treats status >= 400 as failures', () => { responseMiddleware(req, res, next); res.respond(403, {field: 1}); diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index bdb587d50c..1228786011 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -4,7 +4,7 @@ import express from 'express'; import _ from 'lodash'; const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); -let router = express.Router(); // eslint-disable-line new-cap +let router = express.Router(); // eslint-disable-line babel/new-cap fs .readdirSync(CONTROLLERS_PATH) From 5bcce0b86bce8d0a3e2e77789c2815d907733159 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Sat, 2 Jan 2016 16:10:37 +0200 Subject: [PATCH 308/976] Add tests for group join route --- .../groups/POST-groups_groupId_join.js | 123 +++++++++++++++--- website/src/controllers/api-v3/groups.js | 2 +- 2 files changed, 104 insertions(+), 21 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.js b/test/api/v3/integration/groups/POST-groups_groupId_join.js index aa26c10289..2c36d12f19 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.js @@ -1,8 +1,20 @@ import { generateUser, + translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('POST /group/:groupId/join', () => { + it('returns error when groupId is not for a valid group', async () => { + let joiningUser = await generateUser(); + + await expect(joiningUser.post(`/groups/${generateUUID()}/join`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + context('Accepting invitation to a guild', () => { let user, invitedUser, guild; @@ -11,24 +23,68 @@ describe('POST /group/:groupId/join', () => { guild = await user.post('/groups', { name: 'Test Guild', type: 'guild', - }); - invitedUser = await generateUser({ - 'invitations.guilds': [{ id: guild._id}], + privacy: 'private', }); }); - it('does not give basilist quest to inviter when joining a guild', async () => { - await invitedUser.post(`/groups/${guild._id}/join`); + it('returns error when user is not invited to private guild', async () => { + let joiningUser = await generateUser(); - await expect(user.get('/user')).to.eventually.not.have.deep.property('items.quests.basilist'); + await expect(joiningUser.post(`/groups/${guild._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupRequiresInvite'), + }); }); - it('does not increment basilist quest count to inviter with basilist when joining a guild', async () => { - user.update({ 'items.quests.basilist': 1 }); + it('allows non-invited users to join public guilds', async () => { + await user.update({balance: 1}); + guild = await user.post('/groups', { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }); - await invitedUser.post(`/groups/${guild._id}/join`); + let joiningUser = await generateUser(); + await joiningUser.post(`/groups/${guild._id}/join`); - await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); + await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').to.include(guild._id); + }); + + context('User is invited', () => { + beforeEach(async () => { + invitedUser = await generateUser({ + 'invitations.guilds': [{ id: guild._id}], + }); + }); + + it('allows invited user to join private guilds', async () => { + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(invitedUser.get('/user')).to.eventually.have.property('guilds').to.include(guild._id); + }); + + it('clears invitation from user when joining guilds', async () => { + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(invitedUser.get('/user')) + .to.eventually.have.deep.property('invitations.guilds') + .to.not.include({id: guild._id}); + }); + + it('does not give basilist quest to inviter when joining a guild', async () => { + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(user.get('/user')).to.eventually.not.have.deep.property('items.quests.basilist'); + }); + + it('does not increment basilist quest count to inviter with basilist when joining a guild', async () => { + await user.update({ 'items.quests.basilist': 1 }); + + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); + }); }); }); @@ -41,23 +97,50 @@ describe('POST /group/:groupId/join', () => { name: 'Test Party', type: 'party', }); - invitedUser = await generateUser({ - 'invitations.party': { id: party._id, inviter: user._id }, + }); + + it('returns error when user is not invited to party', async () => { + let joiningUser = await generateUser(); + + await expect(joiningUser.post(`/groups/${party._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupRequiresInvite'), }); }); - it('gives basilist quest item to the inviter when joining a party', async () => { - await invitedUser.post(`/groups/${party._id}/join`); + context('User is invited', () => { + beforeEach(async () => { + invitedUser = await generateUser({ + 'invitations.party': { id: party._id, inviter: user._id }, + }); + }); - await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); - }); + it('allows invited user to join party', async () => { + await invitedUser.post(`/groups/${party._id}/join`); - it('increments basilist quest item count to inviter when joining a party', async () => { - user.update({'items.quests.basilist': 1 }); + await expect(invitedUser.get('/user')).to.eventually.have.deep.property('party._id', party._id); + }); - await invitedUser.post(`/groups/${party._id}/join`); + it('clears invitation from user when joining party', async () => { + await invitedUser.post(`/groups/${party._id}/join`); - await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); + await expect(invitedUser.get('/user')).to.eventually.not.have.deep.property('invitations.party.id'); + }); + + it('gives basilist quest item to the inviter when joining a party', async () => { + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); + }); + + it('increments basilist quest item count to inviter when joining a party', async () => { + await user.update({'items.quests.basilist': 1 }); + + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); + }); }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index a61c56e26c..80b2972217 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -228,7 +228,7 @@ api.joinGroup = { user.party._id = group._id; // Set group as user's party isUserInvited = true; - } else if (group.type === 'guild' && user.invitations.guilds) { + } else if (group.type === 'guild') { let i = _.findIndex(user.invitations.guilds, {id: group._id}); if (i !== -1) { From d25cb70f66f8bc6874c7e9b37afba158930619d1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Jan 2016 19:44:39 +0100 Subject: [PATCH 309/976] wip get and create tasks plus initial user syncing --- common/locales/en/api-v3.json | 4 +- .../v3/integration/tasks/GET-tasks.test.js | 39 ++++------ website/src/controllers/api-v3/tasks.js | 76 ++++++++++++++----- website/src/models/challenge.js | 73 ++++++++++++------ 4 files changed, 124 insertions(+), 68 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 2ace1f7773..a7c62e3744 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -46,5 +46,7 @@ "winnerIdRequired": "\"winnerId\" must be a valid UUID.", "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", - "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge." + "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", + "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", + "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed." } diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 0772046f0b..3f1c50448d 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -1,42 +1,31 @@ import { generateUser, } from '../../../../helpers/api-integration.helper'; -import Q from 'q'; describe('GET /tasks', () => { let user; - before(() => { + beforeEach(async () => { + user = await generateUser(); + }); + + before(async () => { return generateUser().then((generatedUser) => { user = generatedUser; }); }); - it('returns all user\'s tasks', () => { - let length; - return Q.all([ - user.post('/tasks', {text: 'test habit', type: 'habit'}), - ]) - .then((createdTasks) => { - length = createdTasks.length; - return user.get('/tasks'); - }) - .then((tasks) => { - expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task - }); + it('returns all user\'s tasks', async () => { + let createdTasks = await user.post('/tasks', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks/user'); + expect(tasks.length).to.equal(createdTasks.length + 1); // + 1 because 1 is a default task }); - it('returns only a type of user\'s tasks if req.query.type is specified', () => { - let habitId; - user.post('/tasks', {text: 'test habit', type: 'habit'}) - .then((task) => { - habitId = task._id; - return user.get('/tasks?type=habit'); - }) - .then((tasks) => { - expect(tasks.length).to.equal(1); - expect(tasks[0]._id).to.equal(habitId); - }); + it('returns only a type of user\'s tasks if req.query.type is specified', async () => { + let createdTasks = await user.post('/tasks', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks/user?type=habit'); + expect(tasks.length).to.equal(1); + expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); // TODO complete after task scoring is done diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 84ccf0a221..676be05bf5 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -2,6 +2,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { sendTaskWebhook } from '../../libs/api-v3/webhook'; import * as Tasks from '../../models/task'; +import { model as Challenge } from '../../models/challenge'; import { NotFound, NotAuthorized, @@ -21,15 +22,30 @@ let api = {}; * @apiName CreateTask * @apiGroup Task * + * @apiParam {string="user","challenge"} tasksOwner Define if tasks will belong to the auhenticated user or to a challenge (specifying the "challengeId" parameter). + * @apiParam {UUID} challengeId Optional. If "tasksOwner" is "challenge" then specify the challenge id. + * * @apiSuccess {Object|Array} task The newly created task(s) */ api.createTask = { method: 'POST', - url: '/tasks', + url: '/tasks/:tasksOwner/:challengeId?', middlewares: [authWithHeaders(), cron], async handler (req, res) { let tasksData = Array.isArray(req.body) ? req.body : [req.body]; let user = res.locals.user; + let tasksOwner = req.params.tasksOwner; + let challengeId = req.params.challengeId; + let challenge; + + if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); + if (tasksOwner === 'challenge') { + if (!challengeId) throw new BadRequest(res.t('challengeIdRequired')); + challenge = await Challenge.findOne({_id: challengeId}).exec(); + + // If the challenge does not exist, or if it exists but user is not the leader -> throw error + if (!challenge || challenge.leader !== user._id) throw new NotFound(res.t('challengeNotFound')); + } let toSave = tasksData.map(taskData => { // Validate that task.type is valid @@ -37,15 +53,20 @@ api.createTask = { let taskType = taskData.type; let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); - newTask.userId = user._id; + + if (challenge) { + newTask.challenge.id = challengeId; + } else { + newTask.userId = user._id; + } // Validate that the task is valid and throw if it isn't - // otherwise since we're saving user and task in parallel it could save the user with a tasksOrder that doens't match reality + // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality let validationErrors = newTask.validateSync(); if (validationErrors) throw validationErrors; - // Otherwise update the user - user.tasksOrder[`${taskType}s`].unshift(newTask._id); + // Otherwise update the user/challenge + (challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id); return newTask; }); @@ -54,41 +75,60 @@ api.createTask = { toSave = toSave.map(task => task.save({ validateBeforeSave: false, })); - toSave.unshift(user.save()); - let results = await Q.all(toSave); + toSave.unshift((challenge || user).save()); - if (results.length === 2) { // Just one task created - res.respond(201, results[1]); - } else { - results.splice(0, 1); // remove the user - res.respond(201, results); - } + let tasks = await Q.all(toSave); + tasks.splice(0, 1); // remove the user/challenge + res.respond(201, tasks); + + // If adding tasks to a challenge -> sync users + if (challenge) challenge.addTasksToMembers(tasks); // TODO catch/log }, }; /** - * @api {get} /tasks Get an user's tasks + * @api {get} /tasks/:tasksOwner/:challengeId Get an user's tasks * @apiVersion 3.0.0 * @apiName GetTasks * @apiGroup Task * + * @apiParam {string="user","challenge"} tasksOwner Url parameter to return tasks belonging to a challenge (specifying the "challengeId" parameter) or to the autheticated user. + * @apiParam {UUID} challengeId Optional. If "tasksOwner" is "challenge" then specify the challenge id. * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks - * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo" + * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". * * @apiSuccess {Array} tasks An array of task objects */ api.getTasks = { method: 'GET', - url: '/tasks', + url: '/tasks/:tasksOwner/:challengeId?', middlewares: [authWithHeaders(), cron], async handler (req, res) { + req.checkParams('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); + req.checkParams('challengeId', res.t('challengeIdRequired')).optional().isUUID(); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; let user = res.locals.user; - let query = {userId: user._id}; + let tasksOwner = req.params.tasksOwner; + let challengeId = req.params.challengeId; + let challenge; + + if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); + if (tasksOwner === 'challenge') { + if (!challengeId) throw new BadRequest(res.t('challengeIdRequired')); + challenge = await Challenge.findOne({_id: challengeId}).exec(); + + // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error + if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens + throw new NotFound(res.t('challengeNotFound')); + } + } + + let query = challenge ? {'challenge.id': challengeId, userId: {$exists: false}} : {userId: user._id}; let type = req.query.type; if (type) { @@ -102,6 +142,8 @@ api.getTasks = { } if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { + if (challengeId) throw new BadRequest(res.t('noCompletedTodosChallenge')); + let queryCompleted = Tasks.Task.find({ type: 'todo', completed: true, diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index d5b3bf1a1b..d3bf4550a3 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -4,6 +4,7 @@ import validator from 'validator'; import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; import * as Tasks from './task'; +import { model as User } from './user'; let Schema = mongoose.Schema; @@ -29,37 +30,15 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'tasksOrder'], }); - -// Syncing logic - function _syncableAttrs (task) { - let t = task.toObject(); // lodash doesn't seem to like _.omit on EmbeddedDocument + let t = task.toObject(); // lodash doesn't seem to like _.omit on Document // only sync/compare important attrs let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO use whitelist instead of blacklist? if (t.type !== 'reward') omitAttrs.push('value'); return _.omit(t, omitAttrs); } -// TODO redo -// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers -/* function comparableData(obj) { - return JSON.stringify( - _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) - .sortBy('id') // we don't want to update if they're sort-order is different - .transform(function(result, task){ - result.push(syncableAttrs(task)); - }) - .value()) -} - -ChallengeSchema.methods.isOutdated = function isChallengeOutdated (newData) { - return comparableData(this) !== comparableData(newData); -}*/ - -// Syncs all new tasks, deleted tasks, etc to the user object schema.methods.syncToUser = function syncChallengeToUser (user) { - if (!user) throw new Error('User required.'); - let challenge = this; challenge.shortName = challenge.shortName || challenge.name; @@ -83,8 +62,12 @@ schema.methods.syncToUser = function syncChallengeToUser (user) { }); } + return user.save(); + + // Old logic used to sync tasks + // TODO might keep it around for when normal syncing doesn't succeed? or for first time syncing? // Sync new tasks and updated tasks - return Q.all([ + /* return Q.all([ // Find original challenge tasks Tasks.Task.find({ userId: {$exists: false}, @@ -131,7 +114,47 @@ schema.methods.syncToUser = function syncChallengeToUser (user) { toSave.push(user.save()); return Q.all(toSave); - }); + });*/ }; +schema.methods.addTasksToMembers = async function addTasksToMembers (tasks) { + let challenge = this; + + let membersIds = (await User.find({challenges: {$in: [challenge._id]}}).select('_id').exec()).map(member => member._id); + + // Add tasks to users sequentially so that we don't kill the server (hopefully); + // using a for...of loop allows each op to be run in sequence + for (let memberId of membersIds) { + let update = User.update + await db.post(doc); + } + + tasks.forEach(chalTask => { + matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; + matchingTask.userId = user._id; + + }) + +}; + +// Old Syncing logic, kept for reference and maybe will be needed to adapt v2 +/* + +// TODO redo +// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers +function comparableData(obj) { + return JSON.stringify( + _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) + .sortBy('id') // we don't want to update if they're sort-order is different + .transform(function(result, task){ + result.push(syncableAttrs(task)); + }) + .value()) +} + +ChallengeSchema.methods.isOutdated = function isChallengeOutdated (newData) { + return comparableData(this) !== comparableData(newData); +}*/ + export let model = mongoose.model('Challenge', schema); From b73f5a8f402d8dd70e5afe18b7d2b6b893b10336 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Jan 2016 19:57:20 +0100 Subject: [PATCH 310/976] add support for getting single challenges tasks --- website/src/controllers/api-v3/tasks.js | 14 ++++++++++++-- website/src/models/challenge.js | 5 ++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 676be05bf5..45339dc49a 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -120,7 +120,7 @@ api.getTasks = { if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); if (tasksOwner === 'challenge') { if (!challengeId) throw new BadRequest(res.t('challengeIdRequired')); - challenge = await Challenge.findOne({_id: challengeId}).exec(); + challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens @@ -188,10 +188,20 @@ api.getTask = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); if (!task) throw new NotFound(res.t('taskNotFound')); + + // If the task belongs to a challenge make sure the user has rights + if (!task.userId) { + let challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin) { + throw new NotFound(res.t('taskNotFound')); + } + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } + res.respond(200, task); }, }; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index d3bf4550a3..6e7e179047 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -125,7 +125,10 @@ schema.methods.addTasksToMembers = async function addTasksToMembers (tasks) { // Add tasks to users sequentially so that we don't kill the server (hopefully); // using a for...of loop allows each op to be run in sequence for (let memberId of membersIds) { - let update = User.update + let updateQ = {$push: {}}; + tasks.forEach(chalTask => { + + }) await db.post(doc); } From 6680853078aa8a4811d7a430513c894e6e66f0d8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Jan 2016 20:48:48 +0100 Subject: [PATCH 311/976] finish adding challenges support for tasks (except syncing) --- common/locales/en/api-v3.json | 3 +- website/src/controllers/api-v3/tasks.js | 94 +++++++++++++++++++------ 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a7c62e3744..77930e4583 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -48,5 +48,6 @@ "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", - "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed." + "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", + "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader." } diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 45339dc49a..40425c27a3 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -44,7 +44,8 @@ api.createTask = { challenge = await Challenge.findOne({_id: challengeId}).exec(); // If the challenge does not exist, or if it exists but user is not the leader -> throw error - if (!challenge || challenge.leader !== user._id) throw new NotFound(res.t('challengeNotFound')); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); } let toSave = tasksData.map(taskData => { @@ -82,7 +83,7 @@ api.createTask = { res.respond(201, tasks); // If adding tasks to a challenge -> sync users - if (challenge) challenge.addTasksToMembers(tasks); // TODO catch/log + if (challenge) challenge.addTasks(tasks); // TODO catch/log }, }; @@ -190,12 +191,11 @@ api.getTask = { _id: req.params.taskId, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); - - // If the task belongs to a challenge make sure the user has rights - if (!task.userId) { + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights let challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); - if (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin) { + if (!challenge || (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens throw new NotFound(res.t('taskNotFound')); } } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one @@ -222,6 +222,7 @@ api.updateTask = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty @@ -232,10 +233,17 @@ api.updateTask = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } // If checklist is updated -> replace the original one if (req.body.checklist) { @@ -258,6 +266,7 @@ api.updateTask = { let savedTask = await task.save(); res.respond(200, savedTask); + if (challenge) challenge.updateTask(savedTask); // TODO catch/log }, }; @@ -382,6 +391,7 @@ api.scoreTask = { // completed todos cannot be moved, they'll be returned ordered by date of completion // TODO check that it works when a tag is selected or todos are split between dated and due +// TODO support challenges? /** * @api {post} /tasks/move/:taskId/to/:position Move a task to a new position * @apiVersion 3.0.0 @@ -449,6 +459,7 @@ api.addChecklistItem = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty and is an array @@ -458,16 +469,25 @@ api.addChecklistItem = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); let savedTask = await task.save(); res.respond(200, savedTask); // TODO what to return + if (challenge) challenge.updateTask(savedTask); }, }; @@ -530,6 +550,7 @@ api.updateChecklistItem = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); @@ -539,10 +560,17 @@ api.updateChecklistItem = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); let item = _.find(task.checklist, {_id: req.params.itemId}); @@ -552,6 +580,7 @@ api.updateChecklistItem = { let savedTask = await task.save(); res.respond(200, savedTask); // TODO what to return + if (challenge) challenge.updateTask(savedTask); }, }; @@ -572,6 +601,7 @@ api.removeChecklistItem = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); @@ -581,10 +611,17 @@ api.removeChecklistItem = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); @@ -592,8 +629,9 @@ api.removeChecklistItem = { task.checklist.splice(itemI, 1); - await task.save(); + let savedTask = await task.save(); res.respond(200, {}); // TODO what to return + if (challenge) challenge.updateTask(savedTask); }, }; @@ -681,11 +719,11 @@ api.removeTagFromTask = { }, }; -// Remove a task from user.tasksOrder -function _removeTaskTasksOrder (user, taskId) { +// Remove a task from (user|challenge).tasksOrder +function _removeTaskTasksOrder (userOrChallenge, taskId) { // Loop through all lists and when the task is found, remove it and return for (let i = 0; i < Tasks.tasksTypes.length; i++) { - let list = user.tasksOrder[`${Tasks.tasksTypes[i]}s`]; + let list = userOrChallenge.tasksOrder[`${Tasks.tasksTypes[i]}s`]; let index = list.indexOf(taskId); if (index !== -1) { @@ -713,6 +751,7 @@ api.deleteTask = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); @@ -721,16 +760,25 @@ api.deleteTask = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.challenge.id) throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } else if (task.userId && task.challenge.id) { + throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + } - _removeTaskTasksOrder(user, req.params.taskId); + _removeTaskTasksOrder(challenge || user, req.params.taskId); await Q.all([user.save(), task.remove()]); res.respond(200, {}); + if (challenge) challenge.removeTask(task); }, }; From 3bc8945bcc652a83bc0308dc40c0435ee85f63f9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Jan 2016 22:03:21 +0100 Subject: [PATCH 312/976] adapt current tests and change urls to avoid conflicts --- common/locales/en/api-v3.json | 3 +- .../integration/tasks/DELETE-tasks_id.test.js | 4 +- .../v3/integration/tasks/GET-tasks.test.js | 8 +- .../v3/integration/tasks/GET-tasks_id.test.js | 4 +- .../v3/integration/tasks/POST-tasks.test.js | 76 +++++++++---------- .../POST-tasks_id_score_direction.test.js | 14 ++-- .../v3/integration/tasks/PUT-tasks_id.test.js | 10 +-- ...LETE-tasks_taskId_checklist_itemId.test.js | 8 +- .../POST-tasks_taskId_checklist.test.js | 6 +- ...asks_taskId_checklist_itemId_score.test.js | 8 +- .../PUT-tasks_taskId_checklist_itemId.test.js | 8 +- .../DELETE-tasks_taskId_tags_tagId.test.js | 4 +- .../tags/POST-tasks_taskId_tags_tagId.test.js | 6 +- website/src/controllers/api-v3/tasks.js | 40 ++++++---- 14 files changed, 105 insertions(+), 94 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 77930e4583..a97308e064 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -49,5 +49,6 @@ "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", - "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader." + "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", + "invalidTasksOwner": "\"tasksOwner\" must be \"user\" or \"challenge\"." } diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 1d3e59ef15..503e484e66 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -16,7 +16,7 @@ describe('DELETE /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -48,7 +48,7 @@ describe('DELETE /tasks/:id', () => { it('cannot delete a task owned by someone else', () => { return generateUser() .then((anotherUser) => { - return anotherUser.post('/tasks', { + return anotherUser.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 3f1c50448d..bf77442ea4 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -16,14 +16,14 @@ describe('GET /tasks', () => { }); it('returns all user\'s tasks', async () => { - let createdTasks = await user.post('/tasks', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks/user'); + let createdTasks = await user.post('/tasks?tasksOwner=user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks?tasksOwner=user'); expect(tasks.length).to.equal(createdTasks.length + 1); // + 1 because 1 is a default task }); it('returns only a type of user\'s tasks if req.query.type is specified', async () => { - let createdTasks = await user.post('/tasks', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks/user?type=habit'); + let createdTasks = await user.post('/tasks?tasksOwner=user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks?tasksOwner=user&type=habit'); expect(tasks.length).to.equal(1); expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index a4ae455d6a..b88d8a95a6 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -17,7 +17,7 @@ describe('GET /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -54,7 +54,7 @@ describe('GET /tasks/:id', () => { .then((user2) => { anotherUser = user2; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 245cea7d28..a002119634 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -14,7 +14,7 @@ describe('POST /tasks', () => { context('validates params', () => { it('returns an error if req.body.type is absent', async () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -24,7 +24,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.type is not valid', async () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -34,7 +34,7 @@ describe('POST /tasks', () => { }); it('returns an error if one object inside an array is invalid', async () => { - return expect(user.post('/tasks', [ + return expect(user.post('/tasks?tasksOwner=user', [ {type: 'habitF'}, {type: 'habit'}, ])).to.eventually.be.rejected.and.eql({ @@ -45,7 +45,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.text is absent', async () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -56,7 +56,7 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', })).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, @@ -71,7 +71,7 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks', [ + return expect(user.post('/tasks?tasksOwner=user', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary @@ -86,8 +86,8 @@ describe('POST /tasks', () => { }); it('does not save any task sent in an array when 1 is invalid', async () => { - let originalTasks = await user.get('/tasks'); - return expect(user.post('/tasks', [ + let originalTasks = await user.get('/tasks?tasksOwner=user'); + return expect(user.post('/tasks?tasksOwner=user', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary @@ -95,14 +95,14 @@ describe('POST /tasks', () => { error: 'BadRequest', message: 'habit validation failed', }).then(async () => { - let updatedTasks = await user.get('/tasks'); + let updatedTasks = await user.get('/tasks?tasksOwner=user'); expect(updatedTasks).to.eql(originalTasks); }); }); it('automatically sets "task.userId" to user\'s uuid', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }); @@ -113,7 +113,7 @@ describe('POST /tasks', () => { it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, dateCompleted fields`, async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', userId: 123, @@ -137,7 +137,7 @@ describe('POST /tasks', () => { }); it('ignores invalid fields', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', notValid: true, @@ -149,7 +149,7 @@ describe('POST /tasks', () => { context('habits', () => { it('creates a habit', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', up: false, @@ -167,7 +167,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.habits when a new habit is created', async () => { let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'an habit', }); @@ -179,7 +179,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.habits when multiple habits are created', async () => { let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ type: 'habit', text: 'an habit', }, { @@ -194,7 +194,7 @@ describe('POST /tasks', () => { }); it('creates multiple habits', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ text: 'test habit', type: 'habit', up: false, @@ -224,7 +224,7 @@ describe('POST /tasks', () => { }); it('defaults to setting up and down to true', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', notes: 1976, @@ -235,7 +235,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', checklist: [ @@ -249,7 +249,7 @@ describe('POST /tasks', () => { context('todos', () => { it('creates a todo', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test todo', type: 'todo', notes: 1976, @@ -262,7 +262,7 @@ describe('POST /tasks', () => { }); it('creates multiple todos', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ text: 'test todo', type: 'todo', notes: 1976, @@ -285,7 +285,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.todos when a new todo is created', async () => { let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { type: 'todo', text: 'a todo', }); @@ -297,7 +297,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.todos when multiple todos are created', async () => { let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ type: 'todo', text: 'a todo', }, { @@ -312,7 +312,7 @@ describe('POST /tasks', () => { }); it('can create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test todo', type: 'todo', checklist: [ @@ -333,7 +333,7 @@ describe('POST /tasks', () => { it('creates a daily', async () => { let now = new Date(); - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', notes: 1976, @@ -352,7 +352,7 @@ describe('POST /tasks', () => { }); it('creates multiple dailys', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ text: 'test daily', type: 'daily', notes: 1976, @@ -375,7 +375,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dailys when a new daily is created', async () => { let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'a daily', }); @@ -387,7 +387,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dailys when multiple dailys are created', async () => { let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ type: 'daily', text: 'a daily', }, { @@ -402,7 +402,7 @@ describe('POST /tasks', () => { }); it('defaults to a weekly frequency, with every day set', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', }); @@ -421,7 +421,7 @@ describe('POST /tasks', () => { }); it('allows repeat field to be configured', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', repeat: { @@ -445,7 +445,7 @@ describe('POST /tasks', () => { it('defaults startDate to today', async () => { let today = (new Date()).getDay(); - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', }); @@ -454,7 +454,7 @@ describe('POST /tasks', () => { }); it('can create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', checklist: [ @@ -473,7 +473,7 @@ describe('POST /tasks', () => { context('rewards', () => { it('creates a reward', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', notes: 1976, @@ -488,7 +488,7 @@ describe('POST /tasks', () => { }); it('creates multiple rewards', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ text: 'test reward', type: 'reward', notes: 1976, @@ -515,7 +515,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.rewards when a new reward is created', async () => { let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { type: 'reward', text: 'a reward', }); @@ -527,7 +527,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dreward when multiple rewards are created', async () => { let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ type: 'reward', text: 'a reward', }, { @@ -542,7 +542,7 @@ describe('POST /tasks', () => { }); it('defaults to a 0 value', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', }); @@ -551,7 +551,7 @@ describe('POST /tasks', () => { }); it('requires value to be coerced into a number', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', value: '10', @@ -561,7 +561,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', checklist: [ diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 2978d93657..1c552613b4 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -37,7 +37,7 @@ describe('POST /tasks/:id/score/:direction', () => { let todo; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test todo', type: 'todo', }).then((task) => { @@ -149,7 +149,7 @@ describe('POST /tasks/:id/score/:direction', () => { let daily; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', }).then((task) => { @@ -226,26 +226,26 @@ describe('POST /tasks/:id/score/:direction', () => { let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }).then((task) => { habit = task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test min habit', type: 'habit', up: false, }); }).then((task) => { minusHabit = task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test plus habit', type: 'habit', down: false, }); }).then((task) => { plusHabit = task; - user.post('/tasks', { + user.post('/tasks?tasksOwner=user', { text: 'test neither habit', type: 'habit', up: false, @@ -297,7 +297,7 @@ describe('POST /tasks/:id/score/:direction', () => { let reward; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', value: 5, diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 3092d3bd84..408ef92e99 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -16,7 +16,7 @@ describe('PUT /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -65,7 +65,7 @@ describe('PUT /tasks/:id', () => { let habit; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', notes: 1976, @@ -93,7 +93,7 @@ describe('PUT /tasks/:id', () => { let todo; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test todo', type: 'todo', notes: 1976, @@ -150,7 +150,7 @@ describe('PUT /tasks/:id', () => { let daily; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', notes: 1976, @@ -254,7 +254,7 @@ describe('PUT /tasks/:id', () => { let reward; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', notes: 1976, diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index d013878a74..738255996a 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -16,7 +16,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { it('deletes a checklist item', () => { let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -33,7 +33,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { it('does not work with habits', () => { let habit; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -47,7 +47,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('does not work with rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks?tasksOwner=user', { type: 'reward', text: 'reward with checklist', }); @@ -68,7 +68,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index e9b695effd..1ad4f7f58c 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -16,7 +16,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('adds a checklist item to a task', () => { let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -36,7 +36,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('does not add a checklist to habits', () => { let habit; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -51,7 +51,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('does not add a checklist to rewards', () => { let reward; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'reward', text: 'reward with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 667ef41446..862b67b638 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -16,7 +16,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { it('scores a checklist item', () => { let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -32,7 +32,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { it('fails on habits', () => { let habit; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -46,7 +46,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks?tasksOwner=user', { type: 'reward', text: 'reward with checklist', }); @@ -67,7 +67,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index 988574bbf8..ae6babf778 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -16,7 +16,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { it('updates a checklist item', () => { let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -33,7 +33,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on habits', async () => { - let habit = await user.post('/tasks', { + let habit = await user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'habit with checklist', }); @@ -46,7 +46,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks?tasksOwner=user', { type: 'reward', text: 'reward with checklist', }); @@ -67,7 +67,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index c01b71fa2c..27d57bb2f0 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -17,7 +17,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -35,7 +35,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('only deletes existing tags', () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index 6e4c2ba510..9887a5bedb 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -17,7 +17,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -35,7 +35,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -54,7 +54,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('does not add a non existing tag to a task', () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'Task with tag', }).then((task) => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 40425c27a3..6ea130e843 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -22,20 +22,26 @@ let api = {}; * @apiName CreateTask * @apiGroup Task * - * @apiParam {string="user","challenge"} tasksOwner Define if tasks will belong to the auhenticated user or to a challenge (specifying the "challengeId" parameter). - * @apiParam {UUID} challengeId Optional. If "tasksOwner" is "challenge" then specify the challenge id. + * @apiParam {string="user","challenge"} tasksOwner Query parameter to define if tasks will belong to the auhenticated user or to a challenge (specifying the "challengeId" parameter). + * @apiParam {UUID} challengeId Optional. Query parameter. If "tasksOwner" is "challenge" then specify the challenge id. * * @apiSuccess {Object|Array} task The newly created task(s) */ api.createTask = { method: 'POST', - url: '/tasks/:tasksOwner/:challengeId?', + url: '/tasks', middlewares: [authWithHeaders(), cron], async handler (req, res) { + req.checkQuery('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); + req.checkQuery('challengeId', res.t('challengeIdRequired')).optional().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + let tasksData = Array.isArray(req.body) ? req.body : [req.body]; let user = res.locals.user; - let tasksOwner = req.params.tasksOwner; - let challengeId = req.params.challengeId; + let tasksOwner = req.query.tasksOwner; + let challengeId = req.query.challengeId; let challenge; if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); @@ -79,8 +85,13 @@ api.createTask = { toSave.unshift((challenge || user).save()); let tasks = await Q.all(toSave); - tasks.splice(0, 1); // remove the user/challenge - res.respond(201, tasks); + + if (tasks.length === 2) { + res.respond(201, tasks[1]); + } else { + tasks.splice(0, 1); // remove the user/challenge + res.respond(201, tasks); + } // If adding tasks to a challenge -> sync users if (challenge) challenge.addTasks(tasks); // TODO catch/log @@ -93,8 +104,8 @@ api.createTask = { * @apiName GetTasks * @apiGroup Task * - * @apiParam {string="user","challenge"} tasksOwner Url parameter to return tasks belonging to a challenge (specifying the "challengeId" parameter) or to the autheticated user. - * @apiParam {UUID} challengeId Optional. If "tasksOwner" is "challenge" then specify the challenge id. + * @apiParam {string="user","challenge"} tasksOwner Query parameter to return tasks belonging to a challenge (specifying the "challengeId" parameter) or to the autheticated user. + * @apiParam {UUID} challengeId Optional query parameter. If "tasksOwner" is "challenge" then required to specify the challenge id. * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". * @@ -102,20 +113,19 @@ api.createTask = { */ api.getTasks = { method: 'GET', - url: '/tasks/:tasksOwner/:challengeId?', + url: '/tasks', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkParams('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); - req.checkParams('challengeId', res.t('challengeIdRequired')).optional().isUUID(); - + req.checkQuery('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); + req.checkQuery('challengeId', res.t('challengeIdRequired')).optional().isUUID(); req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; let user = res.locals.user; - let tasksOwner = req.params.tasksOwner; - let challengeId = req.params.challengeId; + let tasksOwner = req.query.tasksOwner; + let challengeId = req.query.challengeId; let challenge; if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); From a5b1dfd32d7b14371082541bc9544bb91b048f7f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 5 Jan 2016 20:19:55 +0100 Subject: [PATCH 313/976] finish implementing tasks syncing for challenges --- website/src/controllers/api-v3/groups.js | 11 +- website/src/controllers/api-v3/tasks.js | 4 +- website/src/models/challenge.js | 194 +++++++++++++---------- website/src/models/task.js | 2 +- 4 files changed, 123 insertions(+), 88 deletions(-) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 80b2972217..169512b0da 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -112,12 +112,13 @@ api.getGroups = { // If no valid value for type was supplied, return an error if (queries.length === 0) throw new BadRequest(res.t('groupTypesRequired')); - let results = await Q.all(queries); // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 + // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 + let results = _.reduce(await Q.all(queries), (previousValue, currentValue) => { + if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array + return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue + }, []); - res.respond(200, _.reduce(results, (m, v) => { - if (_.isEmpty(v)) return m; - return m.concat(Array.isArray(v) ? v : [v]); - }, [])); + res.respond(200, results); }, }; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 6ea130e843..0f5ecc314c 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -35,8 +35,8 @@ api.createTask = { req.checkQuery('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); req.checkQuery('challengeId', res.t('challengeIdRequired')).optional().isUUID(); - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; + let reqValidationErrors = req.validationErrors(); + if (reqValidationErrors) throw reqValidationErrors; let tasksData = Array.isArray(req.body) ? req.body : [req.body]; let user = res.locals.user; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 6e7e179047..bf009314a9 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -30,15 +30,18 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'tasksOrder'], }); +// Takes a Task document and return a plain object of attributes that can be synced to the user function _syncableAttrs (task) { let t = task.toObject(); // lodash doesn't seem to like _.omit on Document // only sync/compare important attrs - let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO use whitelist instead of blacklist? + let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt? if (t.type !== 'reward') omitAttrs.push('value'); return _.omit(t, omitAttrs); } -schema.methods.syncToUser = function syncChallengeToUser (user) { +// Sync challenge to user, including tasks and tags. +// Used when user joins the challenge or to force sync. +schema.methods.syncToUser = async function syncChallengeToUser (user) { let challenge = this; challenge.shortName = challenge.shortName || challenge.name; @@ -62,12 +65,7 @@ schema.methods.syncToUser = function syncChallengeToUser (user) { }); } - return user.save(); - - // Old logic used to sync tasks - // TODO might keep it around for when normal syncing doesn't succeed? or for first time syncing? - // Sync new tasks and updated tasks - /* return Q.all([ + let [challengeTasks, userTasks] = await Q.all([ // Find original challenge tasks Tasks.Task.find({ userId: {$exists: false}, @@ -78,86 +76,122 @@ schema.methods.syncToUser = function syncChallengeToUser (user) { userId: user._id, 'challenge.id': challenge._id, }).exec(), - ]) - .then(results => { - let challengeTasks = results[0]; - let userTasks = results[1]; - let toSave = []; // An array of things to save + ]); - challengeTasks.forEach(chalTask => { - let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); + let toSave = []; // An array of things to save - if (!matchingTask) { // If the task is new, create it - matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); - matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; - matchingTask.userId = user._id; - user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); - } else { - _.merge(matchingTask, _syncableAttrs(chalTask)); - // Make sure the task is in user.tasksOrder TODO necessary? - let orderList = user.tasksOrder[`${chalTask.type}s`]; - if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); - } + challengeTasks.forEach(chalTask => { + let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); - if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided - if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing - toSave.push(matchingTask.save()); - }); + if (!matchingTask) { // If the task is new, create it + matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; + matchingTask.userId = user._id; + user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); + } else { + _.merge(matchingTask, _syncableAttrs(chalTask)); + // Make sure the task is in user.tasksOrder TODO necessary? + let orderList = user.tasksOrder[`${chalTask.type}s`]; + if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); + } - // Flag deleted tasks as "broken" - userTasks.forEach(userTask => { - if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) { - userTask.challenge.broken = 'TASK_DELETED'; - toSave.push(userTask.save()); - } - }); + if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided + if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing + toSave.push(matchingTask.save()); + }); - toSave.push(user.save()); - return Q.all(toSave); - });*/ + // Flag deleted tasks as "broken" + userTasks.forEach(userTask => { + if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) { + userTask.challenge.broken = 'TASK_DELETED'; + toSave.push(userTask.save()); + } + }); + + toSave.push(user.save()); + return Q.all(toSave); }; -schema.methods.addTasksToMembers = async function addTasksToMembers (tasks) { - let challenge = this; - - let membersIds = (await User.find({challenges: {$in: [challenge._id]}}).select('_id').exec()).map(member => member._id); - - // Add tasks to users sequentially so that we don't kill the server (hopefully); - // using a for...of loop allows each op to be run in sequence - for (let memberId of membersIds) { - let updateQ = {$push: {}}; - tasks.forEach(chalTask => { - - }) - await db.post(doc); - } - - tasks.forEach(chalTask => { - matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); - matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; - matchingTask.userId = user._id; - - }) - -}; - -// Old Syncing logic, kept for reference and maybe will be needed to adapt v2 -/* - -// TODO redo -// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers -function comparableData(obj) { - return JSON.stringify( - _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) - .sortBy('id') // we don't want to update if they're sort-order is different - .transform(function(result, task){ - result.push(syncableAttrs(task)); - }) - .value()) +async function _fetchMembersIds (challengeId) { + return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id); } -ChallengeSchema.methods.isOutdated = function isChallengeOutdated (newData) { - return comparableData(this) !== comparableData(newData); -}*/ +// Add a new task to challenge members +schema.methods.addTasks = async function challengeAddTasks (tasks) { + let challenge = this; + let membersIds = await _fetchMembersIds(challenge._id); + + // Sync each user sequentially + for (let memberId of membersIds) { + let updateTasksOrderQ = {$push: {}}; + let toSave = []; + + // TODO eslint complaints about ahving a function inside a loop -> make sure it works + tasks.forEach(chalTask => { // eslint-disable-line no-loop-func + let userTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + userTask.challenge = {taskId: chalTask._id, id: challenge._id}; + userTask.userId = memberId; + + let tasksOrderList = updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`]; + if (!tasksOrderList) { + updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`] = { + $position: 0, // unshift + $each: [userTask._id], + }; + } else { + tasksOrderList.$each.unshift(userTask._id); + } + + toSave.push(userTask); + }); + + // Update the user + toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec()); + await Q.all(toSave); // eslint-disable-line babel/no-await-in-loop + } +}; + +// Sync updated task to challenge members +schema.methods.updateTask = async function challengeUpdateTask (task) { + let challenge = this; + + let updateCmd = {$set: {}}; + + _syncableAttrs(task).forEach((value, key) => { + updateCmd.$set[key] = value; + }); + + // TODO reveiw + // Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks + await Tasks.Task.update({ + userId: {$exists: true}, + 'challenge.id': challenge.id, + 'challenge.taskId': task._id, + }, updateCmd, {multi: true}).exec(); +}; + +// Remove a task from challenge members +schema.methods.removeTask = async function challengeRemoveTask (task) { + let challenge = this; + + // Remove the tasks from users' and map each of them to an update query to remove the task from tasksOrder + let updateQueries = (await Tasks.Task.findOneAndRemove({ + userId: {$exists: true}, + 'challenge.id': challenge.id, + 'challenge.taskId': task._id, + }, { + fields: {userId: 1, type: 1}, // fetch only what's necessary + }).lean().exec()) + .map(removedTask => { + return User.update({_id: removedTask.userId}, { + $pull: {[`tasksOrder${removedTask.type}s`]: removedTask._id}, + }); + }); + + // Execute each update sequentially + for (let query of updateQueries) { + await query.exec(); // eslint-disable-line babel/no-await-in-loop + } +}; export let model = mongoose.model('Challenge', schema); diff --git a/website/src/models/task.js b/website/src/models/task.js index b31676da10..3fd574af93 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -28,7 +28,7 @@ export let TaskSchema = new Schema({ challenge: { id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task - taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task + taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task TODO unique index? broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, winner: String, // user.profile.name TODO necessary? }, From 3a6d7bd466f2f5d8b6cfeae4881d5007aef27fc7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Jan 2016 12:16:41 +0100 Subject: [PATCH 314/976] fix sync of remove challenge task --- website/src/models/challenge.js | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index bf009314a9..430a7e0443 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -174,24 +174,14 @@ schema.methods.updateTask = async function challengeUpdateTask (task) { schema.methods.removeTask = async function challengeRemoveTask (task) { let challenge = this; - // Remove the tasks from users' and map each of them to an update query to remove the task from tasksOrder - let updateQueries = (await Tasks.Task.findOneAndRemove({ + // Set the task as broken + await Tasks.Task.update({ userId: {$exists: true}, 'challenge.id': challenge.id, 'challenge.taskId': task._id, }, { - fields: {userId: 1, type: 1}, // fetch only what's necessary - }).lean().exec()) - .map(removedTask => { - return User.update({_id: removedTask.userId}, { - $pull: {[`tasksOrder${removedTask.type}s`]: removedTask._id}, - }); - }); - - // Execute each update sequentially - for (let query of updateQueries) { - await query.exec(); // eslint-disable-line babel/no-await-in-loop - } + $set: {'challenge.broken': 'TASK_DELETED'}, // TODO what about updatedAt? + }).lean().exec(); }; export let model = mongoose.model('Challenge', schema); From 2b2dcfe7ce66fafb53410e45ba13a6b343223ff6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Jan 2016 12:25:53 +0100 Subject: [PATCH 315/976] do not delete completed todos that belongs to a challenge --- website/src/middlewares/api-v3/cron.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 0d6d46b900..f606a72aea 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -34,6 +34,7 @@ export default function cronMiddleware (req, res, next) { cron({user, tasksByType, now, daysMissed, analytics}); // Clean completed todos - 30 days for free users, 90 for subscribers + // Do not delete challenges completed todos TODO unless the task is broken? Task.remove({ userId: user._id, type: 'todo', @@ -41,6 +42,7 @@ export default function cronMiddleware (req, res, next) { dateCompleted: { $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), }, + 'challenge.id': {$exists: false}, }).exec(); // TODO catch error or at least log it let ranCron = user.isModified(); From 8ba486ec12ee83350c759fdf2c048760189e613a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Jan 2016 18:40:11 +0100 Subject: [PATCH 316/976] change tasks routes to /tasks/(user|challenge) --- .../integration/tasks/DELETE-tasks_id.test.js | 4 +- .../v3/integration/tasks/GET-tasks.test.js | 10 +- .../v3/integration/tasks/GET-tasks_id.test.js | 4 +- .../v3/integration/tasks/POST-tasks.test.js | 76 +++--- .../POST-tasks_id_score_direction.test.js | 14 +- .../v3/integration/tasks/PUT-tasks_id.test.js | 10 +- ...LETE-tasks_taskId_checklist_itemId.test.js | 8 +- .../POST-tasks_taskId_checklist.test.js | 6 +- ...asks_taskId_checklist_itemId_score.test.js | 8 +- .../PUT-tasks_taskId_checklist_itemId.test.js | 8 +- .../DELETE-tasks_taskId_tags_tagId.test.js | 4 +- .../tags/POST-tasks_taskId_tags_tagId.test.js | 6 +- website/src/controllers/api-v3/tasks.js | 248 ++++++++++++------ 13 files changed, 248 insertions(+), 158 deletions(-) diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 503e484e66..053176a9ee 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -16,7 +16,7 @@ describe('DELETE /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -48,7 +48,7 @@ describe('DELETE /tasks/:id', () => { it('cannot delete a task owned by someone else', () => { return generateUser() .then((anotherUser) => { - return anotherUser.post('/tasks?tasksOwner=user', { + return anotherUser.post('/tasks/user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index bf77442ea4..bf652777e4 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../../helpers/api-integration.helper'; -describe('GET /tasks', () => { +describe('GET /tasks/user', () => { let user; beforeEach(async () => { @@ -16,14 +16,14 @@ describe('GET /tasks', () => { }); it('returns all user\'s tasks', async () => { - let createdTasks = await user.post('/tasks?tasksOwner=user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks?tasksOwner=user'); + let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks/user'); expect(tasks.length).to.equal(createdTasks.length + 1); // + 1 because 1 is a default task }); it('returns only a type of user\'s tasks if req.query.type is specified', async () => { - let createdTasks = await user.post('/tasks?tasksOwner=user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks?tasksOwner=user&type=habit'); + let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks/user?type=habit'); expect(tasks.length).to.equal(1); expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index b88d8a95a6..d3fe449b8d 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -17,7 +17,7 @@ describe('GET /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -54,7 +54,7 @@ describe('GET /tasks/:id', () => { .then((user2) => { anotherUser = user2; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index a002119634..3fd81aa7cd 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -14,7 +14,7 @@ describe('POST /tasks', () => { context('validates params', () => { it('returns an error if req.body.type is absent', async () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -24,7 +24,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.type is not valid', async () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -34,7 +34,7 @@ describe('POST /tasks', () => { }); it('returns an error if one object inside an array is invalid', async () => { - return expect(user.post('/tasks?tasksOwner=user', [ + return expect(user.post('/tasks/user', [ {type: 'habitF'}, {type: 'habit'}, ])).to.eventually.be.rejected.and.eql({ @@ -45,7 +45,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.text is absent', async () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -56,7 +56,7 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', })).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, @@ -71,7 +71,7 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks?tasksOwner=user', [ + return expect(user.post('/tasks/user', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary @@ -86,8 +86,8 @@ describe('POST /tasks', () => { }); it('does not save any task sent in an array when 1 is invalid', async () => { - let originalTasks = await user.get('/tasks?tasksOwner=user'); - return expect(user.post('/tasks?tasksOwner=user', [ + let originalTasks = await user.get('/tasks/user'); + return expect(user.post('/tasks/user', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary @@ -95,14 +95,14 @@ describe('POST /tasks', () => { error: 'BadRequest', message: 'habit validation failed', }).then(async () => { - let updatedTasks = await user.get('/tasks?tasksOwner=user'); + let updatedTasks = await user.get('/tasks/user'); expect(updatedTasks).to.eql(originalTasks); }); }); it('automatically sets "task.userId" to user\'s uuid', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); @@ -113,7 +113,7 @@ describe('POST /tasks', () => { it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, dateCompleted fields`, async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', userId: 123, @@ -137,7 +137,7 @@ describe('POST /tasks', () => { }); it('ignores invalid fields', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', notValid: true, @@ -149,7 +149,7 @@ describe('POST /tasks', () => { context('habits', () => { it('creates a habit', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', up: false, @@ -167,7 +167,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.habits when a new habit is created', async () => { let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'an habit', }); @@ -179,7 +179,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.habits when multiple habits are created', async () => { let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'habit', text: 'an habit', }, { @@ -194,7 +194,7 @@ describe('POST /tasks', () => { }); it('creates multiple habits', async () => { - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test habit', type: 'habit', up: false, @@ -224,7 +224,7 @@ describe('POST /tasks', () => { }); it('defaults to setting up and down to true', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', notes: 1976, @@ -235,7 +235,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', checklist: [ @@ -249,7 +249,7 @@ describe('POST /tasks', () => { context('todos', () => { it('creates a todo', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test todo', type: 'todo', notes: 1976, @@ -262,7 +262,7 @@ describe('POST /tasks', () => { }); it('creates multiple todos', async () => { - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test todo', type: 'todo', notes: 1976, @@ -285,7 +285,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.todos when a new todo is created', async () => { let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { type: 'todo', text: 'a todo', }); @@ -297,7 +297,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.todos when multiple todos are created', async () => { let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'todo', text: 'a todo', }, { @@ -312,7 +312,7 @@ describe('POST /tasks', () => { }); it('can create checklists', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test todo', type: 'todo', checklist: [ @@ -333,7 +333,7 @@ describe('POST /tasks', () => { it('creates a daily', async () => { let now = new Date(); - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', notes: 1976, @@ -352,7 +352,7 @@ describe('POST /tasks', () => { }); it('creates multiple dailys', async () => { - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test daily', type: 'daily', notes: 1976, @@ -375,7 +375,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dailys when a new daily is created', async () => { let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'a daily', }); @@ -387,7 +387,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dailys when multiple dailys are created', async () => { let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'daily', text: 'a daily', }, { @@ -402,7 +402,7 @@ describe('POST /tasks', () => { }); it('defaults to a weekly frequency, with every day set', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', }); @@ -421,7 +421,7 @@ describe('POST /tasks', () => { }); it('allows repeat field to be configured', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', repeat: { @@ -445,7 +445,7 @@ describe('POST /tasks', () => { it('defaults startDate to today', async () => { let today = (new Date()).getDay(); - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', }); @@ -454,7 +454,7 @@ describe('POST /tasks', () => { }); it('can create checklists', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', checklist: [ @@ -473,7 +473,7 @@ describe('POST /tasks', () => { context('rewards', () => { it('creates a reward', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', notes: 1976, @@ -488,7 +488,7 @@ describe('POST /tasks', () => { }); it('creates multiple rewards', async () => { - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test reward', type: 'reward', notes: 1976, @@ -515,7 +515,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.rewards when a new reward is created', async () => { let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { type: 'reward', text: 'a reward', }); @@ -527,7 +527,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dreward when multiple rewards are created', async () => { let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'reward', text: 'a reward', }, { @@ -542,7 +542,7 @@ describe('POST /tasks', () => { }); it('defaults to a 0 value', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', }); @@ -551,7 +551,7 @@ describe('POST /tasks', () => { }); it('requires value to be coerced into a number', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', value: '10', @@ -561,7 +561,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', checklist: [ diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 1c552613b4..139db4310f 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -37,7 +37,7 @@ describe('POST /tasks/:id/score/:direction', () => { let todo; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test todo', type: 'todo', }).then((task) => { @@ -149,7 +149,7 @@ describe('POST /tasks/:id/score/:direction', () => { let daily; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test daily', type: 'daily', }).then((task) => { @@ -226,26 +226,26 @@ describe('POST /tasks/:id/score/:direction', () => { let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', }).then((task) => { habit = task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test min habit', type: 'habit', up: false, }); }).then((task) => { minusHabit = task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test plus habit', type: 'habit', down: false, }); }).then((task) => { plusHabit = task; - user.post('/tasks?tasksOwner=user', { + user.post('/tasks/user', { text: 'test neither habit', type: 'habit', up: false, @@ -297,7 +297,7 @@ describe('POST /tasks/:id/score/:direction', () => { let reward; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test reward', type: 'reward', value: 5, diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 408ef92e99..453fb03cea 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -16,7 +16,7 @@ describe('PUT /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -65,7 +65,7 @@ describe('PUT /tasks/:id', () => { let habit; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', notes: 1976, @@ -93,7 +93,7 @@ describe('PUT /tasks/:id', () => { let todo; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test todo', type: 'todo', notes: 1976, @@ -150,7 +150,7 @@ describe('PUT /tasks/:id', () => { let daily; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test daily', type: 'daily', notes: 1976, @@ -254,7 +254,7 @@ describe('PUT /tasks/:id', () => { let reward; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test reward', type: 'reward', notes: 1976, diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index 738255996a..2c6de8e570 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -16,7 +16,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { it('deletes a checklist item', () => { let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -33,7 +33,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { it('does not work with habits', () => { let habit; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -47,7 +47,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('does not work with rewards', async () => { - let reward = await user.post('/tasks?tasksOwner=user', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); @@ -68,7 +68,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index 1ad4f7f58c..fd79b89ae9 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -16,7 +16,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('adds a checklist item to a task', () => { let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -36,7 +36,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('does not add a checklist to habits', () => { let habit; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -51,7 +51,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('does not add a checklist to rewards', () => { let reward; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 862b67b638..cc386341b7 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -16,7 +16,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { it('scores a checklist item', () => { let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -32,7 +32,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { it('fails on habits', () => { let habit; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -46,7 +46,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks?tasksOwner=user', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); @@ -67,7 +67,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index ae6babf778..2df3213f2b 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -16,7 +16,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { it('updates a checklist item', () => { let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -33,7 +33,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on habits', async () => { - let habit = await user.post('/tasks?tasksOwner=user', { + let habit = await user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }); @@ -46,7 +46,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks?tasksOwner=user', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); @@ -67,7 +67,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index 27d57bb2f0..8a9617c803 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -17,7 +17,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -35,7 +35,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('only deletes existing tags', () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index 9887a5bedb..ddc0c0fe6b 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -17,7 +17,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -35,7 +35,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -54,7 +54,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('does not add a non existing tag to a task', () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }).then((task) => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 0f5ecc314c..a061ac28e5 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -16,88 +16,197 @@ import { preenHistory } from '../../../../common/script/api-v3/preenHistory'; let api = {}; +// challenge must be passed only when a challenge task is being created +async function _createTasks (req, res, user, challenge) { + let toSave = Array.isArray(req.body) ? req.body : [req.body]; + + toSave = toSave.map(taskData => { + // Validate that task.type is valid + if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); + + let taskType = taskData.type; + let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); + + if (challenge) { + newTask.challenge.id = challenge.id; + } else { + newTask.userId = user._id; + } + + // Validate that the task is valid and throw if it isn't + // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality + let validationErrors = newTask.validateSync(); + if (validationErrors) throw validationErrors; + + // Otherwise update the user/challenge + (challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id); + + return newTask; + }).map(task => task.save({ // If all tasks are valid (this is why it's not in the previous .map()), save everything, withough running validation again + validateBeforeSave: false, + })); + + toSave.unshift((challenge || user).save()); + + let tasks = await Q.all(toSave); + tasks.splice(0, 1); // Remove user or challenge + return tasks; +} + /** - * @api {post} /tasks Create a new task. Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @api {post} /tasks/user Create a new task belonging to the autheticated user. Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 - * @apiName CreateTask + * @apiName CreateUserTasks * @apiGroup Task * - * @apiParam {string="user","challenge"} tasksOwner Query parameter to define if tasks will belong to the auhenticated user or to a challenge (specifying the "challengeId" parameter). - * @apiParam {UUID} challengeId Optional. Query parameter. If "tasksOwner" is "challenge" then specify the challenge id. - * * @apiSuccess {Object|Array} task The newly created task(s) */ -api.createTask = { +api.createUserTasks = { method: 'POST', - url: '/tasks', + url: '/tasks/user', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); - req.checkQuery('challengeId', res.t('challengeIdRequired')).optional().isUUID(); + let tasks = await _createTasks(req, res, res.locals.user); + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); + }, +}; + +/** + * @api {post} /tasks/challenge/:challengeId Create a new task belonging to the challenge. Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @apiVersion 3.0.0 + * @apiName CreateChallengeTasks + * @apiGroup Task + * + * @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to. + * + * @apiSuccess {Object|Array} task The newly created task(s) + */ +api.createChallengeTasks = { + method: 'POST', + url: '/tasks/challenge/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); let reqValidationErrors = req.validationErrors(); if (reqValidationErrors) throw reqValidationErrors; - let tasksData = Array.isArray(req.body) ? req.body : [req.body]; - let user = res.locals.user; - let tasksOwner = req.query.tasksOwner; - let challengeId = req.query.challengeId; - let challenge; + let user = res.local.user; + let challengeId = req.params.challengeId; - if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); - if (tasksOwner === 'challenge') { - if (!challengeId) throw new BadRequest(res.t('challengeIdRequired')); - challenge = await Challenge.findOne({_id: challengeId}).exec(); + let challenge = await Challenge.findOne({_id: challengeId}).exec(); - // If the challenge does not exist, or if it exists but user is not the leader -> throw error - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } + // If the challenge does not exist, or if it exists but user is not the leader -> throw error + if (!challenge || user.challenges.indexOf(challengeId) === -1) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - let toSave = tasksData.map(taskData => { - // Validate that task.type is valid - if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); - - let taskType = taskData.type; - let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); - - if (challenge) { - newTask.challenge.id = challengeId; - } else { - newTask.userId = user._id; - } - - // Validate that the task is valid and throw if it isn't - // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality - let validationErrors = newTask.validateSync(); - if (validationErrors) throw validationErrors; - - // Otherwise update the user/challenge - (challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id); - - return newTask; - }); - - // If all tasks are valid, save everything, withough running validation again - toSave = toSave.map(task => task.save({ - validateBeforeSave: false, - })); - toSave.unshift((challenge || user).save()); - - let tasks = await Q.all(toSave); - - if (tasks.length === 2) { - res.respond(201, tasks[1]); - } else { - tasks.splice(0, 1); // remove the user/challenge - res.respond(201, tasks); - } + let tasks = await _createTasks(req, res, user, challenge); + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); // If adding tasks to a challenge -> sync users if (challenge) challenge.addTasks(tasks); // TODO catch/log }, }; +// challenge must be passed only when a challenge task is being created +async function _getTasks (req, res, user, challenge) { + let query = challenge ? {'challenge.id': challenge.id, userId: {$exists: false}} : {userId: user._id}; + let type = req.query.type; + + if (type) { + query.type = type; + if (type === 'todo') query.completed = false; // Exclude completed todos + } else { + query.$or = [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ]; + } + + if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { + if (challenge) throw new BadRequest(res.t('noCompletedTodosChallenge')); // no completed todos for challenges + + let queryCompleted = Tasks.Task.find({ + type: 'todo', + completed: true, + }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos + dateCompleted: 1, + }); + + let results = await Q.all([ + queryCompleted.exec(), + Tasks.Task.find(query).exec(), + ]); + + res.respond(200, results[1].concat(results[0])); + } else { + let tasks = await Tasks.Task.find(query).exec(); + res.respond(200, tasks); + } +} + +/** + * @api {get} /tasks/user Get an user's tasks + * @apiVersion 3.0.0 + * @apiName GetUserTasks + * @apiGroup Task + * + * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks + * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". + * + * @apiSuccess {Array} tasks An array of task objects + */ +api.getUserTasks = { + method: 'GET', + url: '/tasks/user', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + await _getTasks(req, res, res.locals.user); + }, +}; + +/** + * @api {get} /tasks/challenge/:challengeId Get a challenge's tasks + * @apiVersion 3.0.0 + * @apiName GetChallengeTasks + * @apiGroup Task + * + * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks. + * + * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks + * + * @apiSuccess {Array} tasks An array of task objects + */ +api.getChallengeTasks = { + method: 'GET', + url: '/tasks/challenge/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.local.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); + + // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error + if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens + throw new NotFound(res.t('challengeNotFound')); + } + + await _getTasks(req, res, res.locals.user, challenge); + }, +}; + /** * @api {get} /tasks/:tasksOwner/:challengeId Get an user's tasks * @apiVersion 3.0.0 @@ -116,29 +225,10 @@ api.getTasks = { url: '/tasks', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); - req.checkQuery('challengeId', res.t('challengeIdRequired')).optional().isUUID(); - req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - let user = res.locals.user; - let tasksOwner = req.query.tasksOwner; let challengeId = req.query.challengeId; let challenge; - if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); - if (tasksOwner === 'challenge') { - if (!challengeId) throw new BadRequest(res.t('challengeIdRequired')); - challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); - - // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error - if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens - throw new NotFound(res.t('challengeNotFound')); - } - } - let query = challenge ? {'challenge.id': challengeId, userId: {$exists: false}} : {userId: user._id}; let type = req.query.type; From 9dfcad238cf7ad0b4d4afa9448bf001a02d39ccc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Jan 2016 18:41:25 +0100 Subject: [PATCH 317/976] rename tasks tests to match new routes --- .../tasks/{GET-tasks.test.js => GET-tasks_user.test.js} | 0 .../tasks/{POST-tasks.test.js => POST-tasks_user.test.js} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename test/api/v3/integration/tasks/{GET-tasks.test.js => GET-tasks_user.test.js} (100%) rename test/api/v3/integration/tasks/{POST-tasks.test.js => POST-tasks_user.test.js} (99%) diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js similarity index 100% rename from test/api/v3/integration/tasks/GET-tasks.test.js rename to test/api/v3/integration/tasks/GET-tasks_user.test.js diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js similarity index 99% rename from test/api/v3/integration/tasks/POST-tasks.test.js rename to test/api/v3/integration/tasks/POST-tasks_user.test.js index 3fd81aa7cd..27da94bb50 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; -describe('POST /tasks', () => { +describe('POST /tasks/user', () => { let user; before(async () => { From f2350105368c25b3819aed021973b4dbd1c32c44 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Jan 2016 22:29:47 +0100 Subject: [PATCH 318/976] add getChallenge route and remove old code --- website/src/controllers/api-v3/challenges.js | 45 ++++++++++++++------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 5a19663f74..fa3a71b818 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -73,22 +73,11 @@ api.createChallenge = { group.challengeCount += 1; - let tasks = req.body.tasks || []; // TODO validate req.body.leader = user._id; req.body.official = user.contributor.admin && req.body.official; let challenge = new Challenge(Challenge.sanitize(req.body)); - let toSave = tasks.map(tasks, taskToCreate => { - // TODO validate type - let task = new Tasks[taskToCreate.type](Tasks.Task.sanitizeCreate(taskToCreate)); - task.challenge.id = challenge._id; - challenge.tasksOrder[`${task.type}s`].push(task._id); - return task.save(); - }); - - toSave.unshift(challenge, group); - - let results = await Q.all(toSave); + let results = await Q.all(challenge.save(), group.save()); let savedChal = results[0]; await savedChal.syncToUser(user); // (it also saves the user) @@ -133,6 +122,38 @@ api.getChallenges = { }, }; +/** + * @api {get} /challenges/:challengeId Get a challenge given its id + * @apiVersion 3.0.0 + * @apiName GetChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} challenge The challenge object + */ +api.getChallenge = { + method: 'GET', + url: '/challenges/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.local.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findOne({_id: challengeId}).exec(); // TODO populate + + // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error + if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens + throw new NotFound(res.t('challengeNotFound')); + } + + res.respond(200, challenge); + }, +}; + // TODO everything here should be moved to a worker // actually even for a worker it's probably just to big and will kill mongo function _closeChal (challenge, broken = {}) { From ffbd4696e32b6ba813e11f54cd7108457462a0a8 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 11 Jan 2016 12:35:46 -0600 Subject: [PATCH 319/976] Converted posts tests to async/await syntax and updated tests --- common/locales/en/api-v3.json | 3 +- .../v3/integration/groups/POST-groups.test.js | 167 ++++++++++-------- website/src/controllers/api-v3/groups.js | 1 + 3 files changed, 101 insertions(+), 70 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 2ace1f7773..1f77e456aa 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -46,5 +46,6 @@ "winnerIdRequired": "\"winnerId\" must be a valid UUID.", "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", - "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge." + "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", + "partyMustbePrivate": "Parties must be private" } diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 0d3a622581..f0ac8b9aba 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -6,21 +6,34 @@ import { describe('POST /group', () => { let user; - beforeEach(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; + beforeEach(async () => { + user = await generateUser(); + }); + + context('All Groups', () => { + it('it returns validation error when type is not provided', async () => { + let userToCreateGroup = await generateUser({balance: 1}); + await expect(userToCreateGroup.post('/groups', { name: 'Test Group Without Type' })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Group validation failed', + }); }); }); context('Guilds', () => { - it('returns an error when a user with insufficient funds attempts to create a guild', () => { - let groupName = 'Test Public Guild'; - let groupType = 'guild'; + let userToCreateGuild; - return expect( + beforeEach(async () => { + userToCreateGuild = await generateUser({balance: 1}); + }); + + it('returns an error when a user with insufficient funds attempts to create a guild', async () => { + await expect( user.post('/groups', { - name: groupName, - type: groupType, + name: 'Test Public Guild', + type: 'guild', }) ) .to.eventually.be.rejected.and.eql({ @@ -31,84 +44,100 @@ describe('POST /group', () => { }); context('public guild', () => { - it('creates a group', () => { + it('creates a group', async () => { let groupName = 'Test Public Guild'; let groupType = 'guild'; - - return generateUser({balance: 1}).then((generatedUser) => { - return generatedUser.post('/groups', { - name: groupName, - type: groupType, - }); - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - expect(result.memberCount).to.equal(1); + let groupPrivacy = 'public'; + let publicGuild = await userToCreateGuild.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, }); + + expect(publicGuild._id).to.exist; + expect(publicGuild.name).to.equal(groupName); + expect(publicGuild.type).to.equal(groupType); + expect(publicGuild.memberCount).to.equal(1); + expect(publicGuild.privacy).to.equal(groupPrivacy); }); }); context('private guild', () => { - it('creates a group', () => { - let groupName = 'Test Private Guild'; - let groupType = 'guild'; - let groupPrivacy = 'private'; + let groupName = 'Test Private Guild'; + let groupType = 'guild'; + let groupPrivacy = 'private'; - return generateUser({balance: 1}).then((generatedUser) => { - return generatedUser.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - expect(result.memberCount).to.equal(1); - expect(result.privacy).to.equal(groupPrivacy); + it('creates a group', async () => { + let privateGuild = await userToCreateGuild.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, }); + + expect(privateGuild._id).to.exist; + expect(privateGuild.name).to.equal(groupName); + expect(privateGuild.type).to.equal(groupType); + expect(privateGuild.memberCount).to.equal(1); + expect(privateGuild.privacy).to.equal(groupPrivacy); + }); + + it('deducts gems from user and adds them to guild bank', async () => { + let privateGuild = await userToCreateGuild.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + + expect(privateGuild.balance).to.eql(1); + + let updatedUser = await userToCreateGuild.get('/user'); + + expect(updatedUser.balance).to.eql(0); }); }); }); context('Parties', () => { - it('creates a party', () => { - let groupName = 'Test Party'; - let groupType = 'party'; + let partyName = 'Test Party'; + let partyType = 'party'; - return user.post('/groups', { - name: groupName, - type: groupType, - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - expect(result.memberCount).to.equal(1); + it('creates a party', async () => { + let party = await user.post('/groups', { + name: partyName, + type: partyType, + }); + + expect(party._id).to.exist; + expect(party.name).to.equal(partyName); + expect(party.type).to.equal(partyType); + expect(party.memberCount).to.equal(1); + }); + + it('prevents user in a party from creating another party', async () => { + let userToCreateTwoParties = await generateUser(); + + await userToCreateTwoParties.post('/groups', { + name: partyName, + type: partyType, + }); + + await expect(userToCreateTwoParties.post('/groups')) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupAlreadyInParty'), }); }); - it('prevents user in a party from creating another party', () => { - let tmpUser; - let groupName = 'Test Party'; - let groupType = 'party'; - - return generateUser().then((generatedUser) => { - tmpUser = generatedUser; - return tmpUser.post('/groups', { - name: groupName, - type: groupType, - }); - }) - .then(() => { - return expect(tmpUser.post('/groups')).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupAlreadyInParty'), - }); + it('prevents creating a public party', async () => { + await expect(user.post('/groups', { + name: partyName, + type: partyType, + privacy: 'public', + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('partyMustbePrivate'), }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 80b2972217..8604337648 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -43,6 +43,7 @@ api.createGroup = { user.balance--; user.guilds.push(group._id); } else { + if (group.privacy === 'public') throw new NotAuthorized(res.t('partyMustbePrivate')); if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty')); user.party._id = group._id; From fffa95ca09748a7b6a8dde86e9a9267e1ee87068 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 08:11:19 -0600 Subject: [PATCH 320/976] refactor: Test against party not being private, instead of public --- website/src/controllers/api-v3/groups.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8604337648..13ddf76d64 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -31,7 +31,6 @@ api.createGroup = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; - let group = new Group(Group.sanitize(req.body)); // TODO validate empty req.body group.leader = user._id; @@ -43,7 +42,7 @@ api.createGroup = { user.balance--; user.guilds.push(group._id); } else { - if (group.privacy === 'public') throw new NotAuthorized(res.t('partyMustbePrivate')); + if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate')); if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty')); user.party._id = group._id; @@ -54,6 +53,7 @@ api.createGroup = { firebase.updateGroupData(savedGroup); firebase.addUserToGroup(savedGroup._id, user._id); + return res.respond(201, savedGroup); // TODO populate }, }; From ddd0709b12a00399ff3a2e1bf1dfd50d23cb9519 Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 12 Jan 2016 15:21:24 +0200 Subject: [PATCH 321/976] Refactor Tags tests to use await syntax --- .../integration/tags/DELETE-tags_id.test.js | 35 ++++++++---------- test/api/v3/integration/tags/GET-tags.test.js | 26 ++++++------- .../v3/integration/tags/GET-tags_id.test.js | 20 +++------- .../api/v3/integration/tags/POST-tags.test.js | 31 ++++++---------- .../v3/integration/tags/PUT-tags_id.test.js | 37 ++++++++----------- 5 files changed, 62 insertions(+), 87 deletions(-) diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js index 82911ddda7..b83521613b 100644 --- a/test/api/v3/integration/tags/DELETE-tags_id.test.js +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -4,30 +4,25 @@ import { describe('DELETE /tags/:tagId', () => { let user; + let tagName = 'Tag 1'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('deletes a tag given it\'s id', () => { - let length; - let tag; + it('deletes a tag given it\'s id', async () => { + let tag = await user.post('/tags', {name: tagName}); + let tags = await user.get('/tags'); + let length = tags.length; - return user.post('/tags', {name: 'Tag 1'}) - .then((createdTag) => { - tag = createdTag; - return user.get(`/tags`); - }) - .then((tags) => { - length = tags.length; - return user.del(`/tags/${tag._id}`); - }) - .then(() => user.get(`/tags`)) - .then((tags) => { - expect(tags.length).to.equal(length - 1); - expect(tags[tags.length - 1].name).to.not.equal('Tag 1'); + await user.del(`/tags/${tag._id}`); + + tags = await user.get('/tags'); + let tagNames = tags.map((t) => { + return t.name; }); + + expect(tags.length).to.equal(length - 1); + expect(tagNames).to.not.include(tagName); }); }); diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js index 3669fc37e0..2cb30be885 100644 --- a/test/api/v3/integration/tags/GET-tags.test.js +++ b/test/api/v3/integration/tags/GET-tags.test.js @@ -4,21 +4,21 @@ import { describe('GET /tags', () => { let user; + let tagName1 = 'Tag 1'; + let tagName2 = 'Tag 2'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('returns all user\'s tags', () => { - return user.post('/tags', {name: 'Tag 1'}) - .then(() => user.post('/tags', {name: 'Tag 2'})) - .then(() => user.get('/tags')) - .then((tags) => { - expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task - expect(tags[tags.length - 2].name).to.equal('Tag 1'); - expect(tags[tags.length - 1].name).to.equal('Tag 2'); - }); + it('returns all user\'s tags', async () => { + await user.post('/tags', {name: tagName1}); + await user.post('/tags', {name: tagName2}); + + let tags = await user.get('/tags'); + + expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task + expect(tags[tags.length - 2].name).to.equal(tagName1); + expect(tags[tags.length - 1].name).to.equal(tagName2); }); }); diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index fbdf96312f..adccff6504 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -5,22 +5,14 @@ import { describe('GET /tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('returns a tag given it\'s id', () => { - let createdTag; + it('returns a tag given it\'s id', async () => { + let createdTag = await user.post('/tags', {name: 'Tag 1'}); + let tag = await user.get(`/tags/${createdTag._id}`); - return user.post('/tags', {name: 'Tag 1'}) - .then((tag) => { - createdTag = tag; - return user.get(`/tags/${createdTag._id}`); - }) - .then((tag) => { - expect(tag).to.deep.equal(createdTag); - }); + expect(tag).to.deep.equal(createdTag); }); }); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js index 351d14e9fb..78a73c578a 100644 --- a/test/api/v3/integration/tags/POST-tags.test.js +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -4,29 +4,22 @@ import { describe('POST /tags', () => { let user; + let tagName = 'Tag 1'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('creates a tag correctly', () => { - let createdTag; - - return user.post('/tags', { - name: 'Tag 1', + it('creates a tag correctly', async () => { + let createdTag = await user.post('/tags', { + name: tagName, ignored: false, - }).then((tag) => { - createdTag = tag; - - expect(tag.name).to.equal('Tag 1'); - expect(tag.ignored).to.be.a('undefined'); - - return user.get(`/tags/${createdTag._id}`); - }) - .then((tag) => { - expect(tag).to.deep.equal(createdTag); }); + + let tag = await user.get(`/tags/${createdTag._id}`); + + expect(tag.name).to.equal(tagName); + expect(tag.ignored).to.be.undefined; + expect(tag).to.deep.equal(createdTag); }); }); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index 94a76ec39c..71fbc2ef58 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -4,30 +4,25 @@ import { describe('PUT /tags/:tagId', () => { let user; + let updatedTagName = 'Tag updated'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('updates a tag given it\'s id', () => { - return user.post('/tags', {name: 'Tag 1'}) - .then((createdTag) => { - return user.put(`/tags/${createdTag._id}`, { - name: 'Tag updated', - ignored: true, - }); - }) - .then((updatedTag) => { - expect(updatedTag.name).to.equal('Tag updated'); - expect(updatedTag.ignored).to.be.a('undefined'); - - return user.get(`/tags/${updatedTag._id}`); - }) - .then((tag) => { - expect(tag.name).to.equal('Tag updated'); - expect(tag.ignored).to.be.a('undefined'); + it('updates a tag given it\'s id', async () => { + let createdTag = await user.post('/tags', {name: 'Tag 1'}); + let updatedTag = await user.put(`/tags/${createdTag._id}`, { + name: updatedTagName, + ignored: true, }); + + createdTag = await user.get(`/tags/${updatedTag._id}`); + + expect(updatedTag.name).to.equal(updatedTagName); + expect(updatedTag.ignored).to.be.undefined; + + expect(createdTag.name).to.equal(updatedTagName); + expect(createdTag.ignored).to.be.undefined; }); }); From 88755e69ae4f85f88b68973bb51231808e70a44b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 08:14:12 -0600 Subject: [PATCH 322/976] tests(api): Simplify tests --- .../v3/integration/groups/POST-groups.test.js | 38 ++++++++----------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index f0ac8b9aba..99b263353f 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -7,36 +7,30 @@ describe('POST /group', () => { let user; beforeEach(async () => { - user = await generateUser(); + user = await generateUser({ balance: 10 }); }); context('All Groups', () => { it('it returns validation error when type is not provided', async () => { - let userToCreateGroup = await generateUser({balance: 1}); - await expect(userToCreateGroup.post('/groups', { name: 'Test Group Without Type' })) - .to.eventually.be.rejected.and.eql({ + await expect( + user.post('/groups', { name: 'Test Group Without Type' }) + ).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: 'Group validation failed', }); }); - }); context('Guilds', () => { - let userToCreateGuild; - - beforeEach(async () => { - userToCreateGuild = await generateUser({balance: 1}); - }); - it('returns an error when a user with insufficient funds attempts to create a guild', async () => { + await user.update({ balance: 0 }); + await expect( user.post('/groups', { name: 'Test Public Guild', type: 'guild', }) - ) - .to.eventually.be.rejected.and.eql({ + ).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageInsufficientGems'), @@ -48,7 +42,8 @@ describe('POST /group', () => { let groupName = 'Test Public Guild'; let groupType = 'guild'; let groupPrivacy = 'public'; - let publicGuild = await userToCreateGuild.post('/groups', { + + let publicGuild = await user.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, @@ -68,7 +63,7 @@ describe('POST /group', () => { let groupPrivacy = 'private'; it('creates a group', async () => { - let privateGuild = await userToCreateGuild.post('/groups', { + let privateGuild = await user.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, @@ -82,7 +77,7 @@ describe('POST /group', () => { }); it('deducts gems from user and adds them to guild bank', async () => { - let privateGuild = await userToCreateGuild.post('/groups', { + let privateGuild = await user.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, @@ -90,9 +85,9 @@ describe('POST /group', () => { expect(privateGuild.balance).to.eql(1); - let updatedUser = await userToCreateGuild.get('/user'); + let updatedUser = await user.get('/user'); - expect(updatedUser.balance).to.eql(0); + expect(updatedUser.balance).to.eql(user.balance - 1); }); }); }); @@ -114,15 +109,12 @@ describe('POST /group', () => { }); it('prevents user in a party from creating another party', async () => { - let userToCreateTwoParties = await generateUser(); - - await userToCreateTwoParties.post('/groups', { + await user.post('/groups', { name: partyName, type: partyType, }); - await expect(userToCreateTwoParties.post('/groups')) - .to.eventually.be.rejected.and.eql({ + await expect(user.post('/groups')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageGroupAlreadyInParty'), From 36cd9e94306e35339c06be07e433e24a77dabf5d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 08:14:50 -0600 Subject: [PATCH 323/976] tests(api): Increase test coverage for group post tests --- .../v3/integration/groups/POST-groups.test.js | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 99b263353f..3515f07cec 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -21,6 +21,26 @@ describe('POST /group', () => { }); }); + it('it returns validation error when type is not supported', async () => { + await expect( + user.post('/groups', { name: 'Group with unsupported type', type: 'foo' }) + ).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Group validation failed', + }); + }); + + it('sets the group leader to the user who created the group', async () => { + await expect( + user.post('/groups', { + name: 'Test Public Guild', + type: 'guild', + }) + ).to.eventually.have.property('leader', user._id); + }); + }); + context('Guilds', () => { it('returns an error when a user with insufficient funds attempts to create a guild', async () => { await user.update({ balance: 0 }); @@ -37,6 +57,18 @@ describe('POST /group', () => { }); }); + it('adds guild to user\'s list of guilds', async () => { + let guild = await user.post('/groups', { + name: 'some guild', + type: 'guild', + privacy: 'public', + }); + + let updatedUser = await user.get('/user'); + + expect(updatedUser.guilds).to.include(guild._id); + }); + context('public guild', () => { it('creates a group', async () => { let groupName = 'Test Public Guild'; @@ -108,6 +140,32 @@ describe('POST /group', () => { expect(party.memberCount).to.equal(1); }); + it('does not require gems to create a party', async () => { + await user.update({ balance: 0 }); + + let party = await user.post('/groups', { + name: partyName, + type: partyType, + }); + + expect(party._id).to.exist; + + let updatedUser = await user.get('/user'); + + expect(updatedUser.balance).to.eql(user.balance); + }); + + it('sets party id on user object', async () => { + let party = await user.post('/groups', { + name: partyName, + type: partyType, + }); + + let updatedUser = await user.get('/user'); + + expect(updatedUser.party._id).to.eql(party._id); + }); + it('prevents user in a party from creating another party', async () => { await user.post('/groups', { name: partyName, From 9141598a3478e2d06613fe566b03db27f2ef1667 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 7 Jan 2016 12:32:59 -0600 Subject: [PATCH 324/976] Updated invite route and function for new standards, and added tests. --- common/locales/en/api-v3.json | 11 +- .../groups/POST-groups_invite.test.js | 236 +++++++++++++++ website/src/controllers/api-v3/groups.js | 268 ++++++++---------- 3 files changed, 370 insertions(+), 145 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_invite.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 2ace1f7773..cd3133ffd9 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -37,7 +37,7 @@ "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", - "canOnlyInviteEmailUuid": "Can only invite using uuids or emails but not both at the same time.", + "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", "inviteMissingEmail": "Missing email address in invite.", "onlyGroupLeaderChal": "Only the group leader can create challenges", "pubChalsMinPrize": "Prize must be at least 1 Gem for public challenges.", @@ -46,5 +46,12 @@ "winnerIdRequired": "\"winnerId\" must be a valid UUID.", "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", - "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge." + "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", + "userAlreadyInGroup": "User already in that group.", + "userAlreadyInvitedToGroup": "User already invited to that group.", + "userAlreadyPendingInvitation": "User already pending invitation.", + "userAlreadyInAParty": "User already in a party.", + "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", + "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", + "emailsMustBeAnArray": "Email invites must be a an Array." } diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js new file mode 100644 index 0000000000..1bb604bd19 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -0,0 +1,236 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('Post /groups/:groupId/invite', () => { + let inviter; + let group; + let groupName = 'Test Public Guild'; + + beforeEach(async () => { + inviter = await generateUser({balance: 1}); + group = await inviter.post('/groups', { + name: groupName, + type: 'guild', + }); + }); + + describe('user id invites', () => { + it('returns an error when invited user is not found', async () => { + let fakeID = '206039c6-24e4-4b9f-8a31-61cbb9aa3f66'; + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [fakeID], + })) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: fakeID}), + }); + }); + + it('returns an error when uuids is not an array', async () => { + let fakeID = '206039c6-24e4-4b9f-8a31-61cbb9aa3f66'; + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: {fakeID}, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('uuidsMustBeAnArray'), + }); + }); + + it('returns empty when uuids is empty', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [], + })) + .to.eventually.be.empty; + }); + + it('invites a user to a group by uuid', async () => { + let userToInvite = await generateUser(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id], + })).to.eventually.deep.equal([{ + id: group._id, + name: groupName, + inviter: inviter._id, + }]); + await expect(userToInvite.get('/user')) + .to.eventually.have.deep.property('invitations.guilds[0].id', group._id); + }); + + it('invites multiple users to a group by uuid', async () => { + let userToInvite = await generateUser(); + let userToInvite2 = await generateUser(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id, userToInvite2._id], + })).to.eventually.deep.equal([ + { + id: group._id, + name: groupName, + inviter: inviter._id, + }, + { + id: group._id, + name: groupName, + inviter: inviter._id, + }, + ]); + await expect(userToInvite.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); + await expect(userToInvite2.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); + }); + }); + + describe('email invites', () => { + let testInvite = {name: 'test', email: 'test@habitca.com'}; + + it('returns an error when invite is missing an email', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [{name: 'test'}], + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('inviteMissingEmail'), + }); + }); + + it('returns an error when emails is not an array', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: {testInvite}, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('emailsMustBeAnArray'), + }); + }); + + it('returns empty when emails is an empty array', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [], + })) + .to.eventually.be.empty; + }); + + it('invites a user to a group by email', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [testInvite], + })).to.exist; + }); + + it('invites multiple users to a group by email', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [testInvite, {name: 'test2', email: 'test2@habitca.com'}], + })).to.exist; + }); + }); + + describe('user and email invites', () => { + it('returns an error when emails and uuids are not provided', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteEmailUuid'), + }); + }); + + it('invites users to a group by uuid and email', async () => { + let newUser = await generateUser(); + let invite = await inviter.post(`/groups/${group._id}/invite`, { + uuids: [newUser._id], + emails: [{name: 'test', email: 'test@habitca.com'}], + }); + let invitedUser = await newUser.get('/user'); + + expect(invite).to.exist; + expect(invitedUser.invitations.guilds[0].id).to.equal(group._id); + }); + }); + + describe('guild invites', () => { + it('returns an error when invited user is already invited to the group', async () => { + let userToInivite = await generateUser(); + await inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInivite._id], + }); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInivite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInvitedToGroup'), + }); + }); + + it('returns an error when invited user is already in the group', async () => { + let userToInvite = await generateUser(); + await inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id], + }); + await userToInvite.post(`/groups/${group._id}/join`); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInGroup'), + }); + }); + }); + + describe('party invites', () => { + let party; + + beforeEach(async () => { + party = await inviter.post('/groups', { + name: 'Test Party', + type: 'party', + }); + }); + + it('returns an error when invited user has a pending invitation to the party', async () => { + let userToInvite = await generateUser(); + await inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + + await expect(inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyPendingInvitation'), + }); + }); + + it('returns an error when invited user is already in the party', async () => { + let userToInvite = await generateUser(); + await inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + await userToInvite.post(`/groups/${party._id}/join`); + + await expect(inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInAParty'), + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 80b2972217..0114240481 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -4,14 +4,15 @@ import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; +import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { NotFound, BadRequest, NotAuthorized, } from '../../libs/api-v3/errors'; import * as firebase from '../../libs/api-v3/firebase'; -import { txnEmail } from '../../libs/api-v3/email'; -// import { encrypt } from '../../libs/api-v3/encryption'; +import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; +import { encrypt } from '../../libs/api-v3/encryption'; let api = {}; @@ -304,7 +305,7 @@ api.leaveGroup = { // Send an email to the removed user with an optional message from the leader function _sendMessageToRemoved (group, removedUser, message) { if (removedUser.preferences.emailNotifications.kickedGroup !== false) { - txnEmail(removedUser, `kicked-from-${group.type}`, [ + sendTxnEmail(removedUser, `kicked-from-${group.type}`, [ {name: 'GROUP_NAME', content: group.name}, {name: 'MESSAGE', content: message}, {name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'}, @@ -391,145 +392,105 @@ api.removeGroupMember = { }, }; -/* function _inviteByUUIDs (uuids, group, inviter, req, res, next) { - async.each(uuids, function(uuid, cb){ - User.findById(uuid, function(err,invite){ - if (err) return cb(err); - if (!invite) - return cb({code:400,err:'User with id "' + uuid + '" not found'}); - if (group.type == 'guild') { - if (_.contains(group.members,uuid)) - return cb({code:400, err: "User already in that group"}); - if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id})) - return cb({code:400, err:"User already invited to that group"}); - sendInvite(); - } else if (group.type == 'party') { - if (invite.invitations && !_.isEmpty(invite.invitations.party)) - return cb({code: 400,err:"User already pending invitation."}); - Group.find({type: 'party', members: {$in: [uuid]}}, function(err, groups){ - if (err) return cb(err); - if (!_.isEmpty(groups) && groups[0].members.length > 1) { - return cb({code: 400, err: "User already in a party."}) - } - sendInvite(); - }); - } +async function _inviteByUUID (uuid, group, inviter, req, res) { + // @TODO: Add Push Notifications + let userToInvite = await User.findById(uuid).exec(); - function sendInvite (){ - if(group.type === 'guild'){ - invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id}); + if (!userToInvite) { + throw new NotFound(res.t('userWithIDNotFound', {userId: uuid})); + } - pushNotify.sendNotify(invite, shared.i18n.t('invitedGuild'), group.name); - }else{ - //req.body.type in 'guild', 'party' - invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; + if (group.type === 'guild') { + if (_.contains(userToInvite.guilds, group._id)) { + throw new NotAuthorized(res.t('userAlreadyInGroup')); + } + if (_.find(userToInvite.invitations.guilds, {id: group._id})) { + throw new NotAuthorized(res.t('userAlreadyInvitedToGroup')); + } + userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: res.locals.user._id}); + } else if (group.type === 'party') { + if (!_.isEmpty(userToInvite.invitations.party)) { + throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); + } + if (userToInvite.party._id) { + throw new NotAuthorized(res.t('userAlreadyInAParty')); + } + // @TODO: Why was this here? + // req.body.type in 'guild', 'party' + userToInvite.invitations.party = {id: group._id, name: group.name, inviter: res.locals.user._id}; + } - pushNotify.sendNotify(invite, shared.i18n.t('invitedParty'), group.name); - } + let groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; + if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) { + let emailVars = [ + {name: 'INVITER', content: inviter.profile.name}, + {name: 'REPLY_TO_ADDRESS', content: inviter.email}, + ]; - group.invites.push(invite._id); - - async.series([ - function(cb){ - invite.save(cb); - } - ], function(err, results){ - if (err) return cb(err); - - if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){ - var inviterVars = utils.getUserInfo(res.locals.user, ['name', 'email']); - var emailVars = [ - {name: 'INVITER', content: inviterVars.name}, - {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} - ]; - - if(group.type == 'guild'){ - emailVars.push( - {name: 'GUILD_NAME', content: group.name}, - {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'} - ); - }else{ - emailVars.push( - {name: 'PARTY_NAME', content: group.name}, - {name: 'PARTY_URL', content: '/#/options/groups/party'} - ) - } - - utils.txnEmail(invite, ('invited-' + (group.type == 'guild' ? 'guild' : 'party')), emailVars); - } - - cb(); - }); - } - }); - }, function(err){ - if(err) return err.code ? res.json(err.code, {err: err.err}) : next(err); - - async.series([ - function(cb) { - group.save(cb); - }, - function(cb) { - // TODO pass group from save above don't find it again, or you have to find it again in order to run populate? - populateQuery(group.type, Group.findById(group._id)).exec(function(err, populatedGroup){ - if(err) return next(err); - - res.json(populatedGroup); - }); - } - ]); - }); -}; - -function _inviteByEmails (emails, group, inviter, req, res, next) { - let usersAlreadyRegistered = []; - let invitesToSend = []; - - return Q.all(emails.forEach(invite => { - if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); - - return User.findOne({$or: [ - {'auth.local.email': invite.email}, - {'auth.facebook.emails.value': invite.email} - ]}) - .select({_id: true, 'preferences.emailNotifications': true}) - .exec() - .then(userToContact => { - if(userToContact){ - usersAlreadyRegistered.push(userToContact._id); // TODO does it work not returning - } else { - // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query - // TODO absolutely refactor this horrible code - let link = `?partyInvite=${utils.encrypt(JSON.stringify({id: group._id, inviter: inviter, name: group.name}))}`; - - let inviterVars = getUserInfo(inviter, ['name', 'email']); - let variables = [ - {name: 'LINK', content: link}, - {name: 'INVITER', content: req.body.inviter || inviterVars.name}, - {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} - ]; - - if(group.type == 'guild'){ - variables.push({name: 'GUILD_NAME', content: group.name}); - } - - // TODO implement "users can only be invited once" - // Check for the email address not to be unsubscribed - return EmailUnsubscription.findOne({email: invite.email}).exec() - .then(unsubscribed => { - if (!unsubscribed) utils.txnEmail(invite, ('invite-friend' + (group.type == 'guild' ? '-guild' : '')), variables); - }); - } - }); - })) - .then(() => { - if (usersAlreadyRegistered.length > 0){ - return _inviteByUUIDs(usersAlreadyRegistered, group, inviter, req, res, next); + if (group.type === 'guild') { + emailVars.push( + {name: 'GUILD_NAME', content: group.name}, + {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'}, + ); + } else { + emailVars.push( + {name: 'PARTY_NAME', content: group.name}, + {name: 'PARTY_URL', content: '/#/options/groups/party'}, + ); } - res.respond(200, {}); // TODO what to return? - }); -}; */ + sendTxnEmail(userToInvite, `invited-${groupLabel}`, emailVars); + } + + let userInvited = await userToInvite.save(); + if (group.type === 'guild') { + return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1]; + } else if (group.type === 'party') { + return userInvited.invitations.party; + } +} + +async function _inviteByEmail (invite, group, inviter, req, res) { + let userReturnInfo; + + if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); + + let userToContact = await User.findOne({$or: [ + {'auth.local.email': invite.email}, + {'auth.facebook.emails.value': invite.email}, + ]}) + .select({_id: true, 'preferences.emailNotifications': true}) + .exec(); + + if (userToContact) { + userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res); + } else { + userReturnInfo = invite.email; + // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query + // TODO absolutely refactor this horrible code + const partyQueryString = JSON.stringify({id: group._id, inviter, name: group.name}); + const encryptedPartyqueryString = encrypt(partyQueryString); + let link = `?partyInvite=${encryptedPartyqueryString}`; + + let variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: inviter || inviter.profile.name}, + {name: 'REPLY_TO_ADDRESS', content: inviter.email}, + ]; + + if (group.type === 'guild') { + variables.push({name: 'GUILD_NAME', content: group.name}); + } + + // TODO implement "users can only be invited once" + // Check for the email address not to be unsubscribed + let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec(); + let groupLabel = group.type === 'guild' ? '-guild' : ''; + if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables); + } + + return userReturnInfo; +} /** * @api {post} /groups/:groupId/invite Invite users to a group using their UUIDs or email addresses @@ -563,15 +524,36 @@ api.inviteToGroup = { let uuids = req.body.uuids; let emails = req.body.emails; - if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time - throw new BadRequest(res.t('canOnlyInviteEmailUuid')); - } else if (Array.isArray(uuids)) { - // return _inviteByUUIDs(uuids, group, user, req, res, next); - } else if (Array.isArray(emails)) { - // return _inviteByEmails(emails, group, user, req, res, next); - } else { + let uuidsIsArray = Array.isArray(uuids); + let emailsIsArray = Array.isArray(emails); + + if (!uuids && !emails) { throw new BadRequest(res.t('canOnlyInviteEmailUuid')); } + + let results = []; + + if (uuids && !uuidsIsArray) { + throw new BadRequest(res.t('uuidsMustBeAnArray')); + } + + if (emails && !emailsIsArray) { + throw new BadRequest(res.t('emailsMustBeAnArray')); + } + + if (uuids) { + let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res)); + let uuidResults = await Q.all(uuidInvites); + results.push(...uuidResults); + } + + if (emails) { + let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res)); + let emailResults = await Q.all(emailInvites); + results.push(...emailResults); + } + + res.respond(200, results); }, }; From 1b395b39f849402c7fbf9332bb0de8b5fec48d15 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 12 Jan 2016 08:33:02 -0600 Subject: [PATCH 325/976] Added invite limit and tests for when multiple users with an error --- common/locales/en/api-v3.json | 3 +- .../groups/POST-groups_invite.test.js | 84 +++++++++++++++++-- website/src/controllers/api-v3/groups.js | 30 +++++-- website/src/models/group.js | 2 + 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cd3133ffd9..e82133c009 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -53,5 +53,6 @@ "userAlreadyInAParty": "User already in a party.", "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", - "emailsMustBeAnArray": "Email invites must be a an Array." + "emailsMustBeAnArray": "Email invites must be a an Array.", + "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time" } diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index 1bb604bd19..2466bdf649 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -2,6 +2,9 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +const INVITES_LIMIT = 100; describe('Post /groups/:groupId/invite', () => { let inviter; @@ -18,7 +21,7 @@ describe('Post /groups/:groupId/invite', () => { describe('user id invites', () => { it('returns an error when invited user is not found', async () => { - let fakeID = '206039c6-24e4-4b9f-8a31-61cbb9aa3f66'; + let fakeID = generateUUID(); await expect(inviter.post(`/groups/${group._id}/invite`, { uuids: [fakeID], @@ -31,7 +34,7 @@ describe('Post /groups/:groupId/invite', () => { }); it('returns an error when uuids is not an array', async () => { - let fakeID = '206039c6-24e4-4b9f-8a31-61cbb9aa3f66'; + let fakeID = generateUUID(); await expect(inviter.post(`/groups/${group._id}/invite`, { uuids: {fakeID}, @@ -50,6 +53,23 @@ describe('Post /groups/:groupId/invite', () => { .to.eventually.be.empty; }); + it('returns an error when there are more than INVITES_LIMIT uuids', async () => { + let uuids = []; + + for (let i = 0; i < 101; i += 1) { + uuids.push(generateUUID()); + } + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), + }); + }); + it('invites a user to a group by uuid', async () => { let userToInvite = await generateUser(); @@ -85,10 +105,24 @@ describe('Post /groups/:groupId/invite', () => { await expect(userToInvite.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); await expect(userToInvite2.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); }); + + it('returns an error when inviting multiple users and a user is not found', async () => { + let userToInvite = await generateUser(); + let fakeID = generateUUID(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id, fakeID], + })) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: fakeID}), + }); + }); }); describe('email invites', () => { - let testInvite = {name: 'test', email: 'test@habitca.com'}; + let testInvite = {name: 'test', email: 'test@habitica.com'}; it('returns an error when invite is missing an email', async () => { await expect(inviter.post(`/groups/${group._id}/invite`, { @@ -119,6 +153,23 @@ describe('Post /groups/:groupId/invite', () => { .to.eventually.be.empty; }); + it('returns an error when there are more than INVITES_LIMIT emails', async () => { + let emails = []; + + for (let i = 0; i < 101; i += 1) { + emails.push(`${generateUUID()}@habitica.com`); + } + + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), + }); + }); + it('invites a user to a group by email', async () => { await expect(inviter.post(`/groups/${group._id}/invite`, { emails: [testInvite], @@ -127,7 +178,7 @@ describe('Post /groups/:groupId/invite', () => { it('invites multiple users to a group by email', async () => { await expect(inviter.post(`/groups/${group._id}/invite`, { - emails: [testInvite, {name: 'test2', email: 'test2@habitca.com'}], + emails: [testInvite, {name: 'test2', email: 'test2@habitica.com'}], })).to.exist; }); }); @@ -142,11 +193,34 @@ describe('Post /groups/:groupId/invite', () => { }); }); + it('returns an error when there are more than INVITES_LIMIT uuids and emails', async () => { + let emails = []; + let uuids = []; + + for (let i = 0; i < 50; i += 1) { + emails.push(`${generateUUID()}@habitica.com`); + } + + for (let i = 0; i < 51; i += 1) { + uuids.push(generateUUID()); + } + + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails, + uuids, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), + }); + }); + it('invites users to a group by uuid and email', async () => { let newUser = await generateUser(); let invite = await inviter.post(`/groups/${group._id}/invite`, { uuids: [newUser._id], - emails: [{name: 'test', email: 'test@habitca.com'}], + emails: [{name: 'test', email: 'test@habitica.com'}], }); let invitedUser = await newUser.get('/user'); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 0114240481..d1ca3f6e13 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -2,7 +2,10 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import Q from 'q'; import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; -import { model as Group } from '../../models/group'; +import { + INVITES_LIMIT, + model as Group, +} from '../../models/group'; import { model as User } from '../../models/user'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { @@ -407,7 +410,7 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { if (_.find(userToInvite.invitations.guilds, {id: group._id})) { throw new NotAuthorized(res.t('userAlreadyInvitedToGroup')); } - userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: res.locals.user._id}); + userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: inviter._id}); } else if (group.type === 'party') { if (!_.isEmpty(userToInvite.invitations.party)) { throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); @@ -417,7 +420,7 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { } // @TODO: Why was this here? // req.body.type in 'guild', 'party' - userToInvite.invitations.party = {id: group._id, name: group.name, inviter: res.locals.user._id}; + userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id}; } let groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; @@ -532,13 +535,26 @@ api.inviteToGroup = { } let results = []; + let totalInvites = 0; - if (uuids && !uuidsIsArray) { - throw new BadRequest(res.t('uuidsMustBeAnArray')); + if (uuids) { + if (!uuidsIsArray) { + throw new BadRequest(res.t('uuidsMustBeAnArray')); + } else { + totalInvites += uuids.length; + } } - if (emails && !emailsIsArray) { - throw new BadRequest(res.t('emailsMustBeAnArray')); + if (emails) { + if (!emailsIsArray) { + throw new BadRequest(res.t('emailsMustBeAnArray')); + } else { + totalInvites += emails.length; + } + } + + if (totalInvites > INVITES_LIMIT) { + throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT})); } if (uuids) { diff --git a/website/src/models/group.js b/website/src/models/group.js index a45d2eaa60..27ca80e73f 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -520,3 +520,5 @@ model.count({_id: 'habitrpg'}, (err, ct) => { privacy: 'public', }).save(); }); + +export const INVITES_LIMIT = 100; From 98b0db749bd9ec97b433158e369c3b60fd0fbb53 Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 12 Jan 2016 18:02:09 +0200 Subject: [PATCH 326/976] Refactor User tests to use await syntax --- test/api/v3/integration/user/GET-user.test.js | 27 ++--- .../user/auth/POST-register_local.test.js | 104 +++++++++--------- 2 files changed, 63 insertions(+), 68 deletions(-) diff --git a/test/api/v3/integration/user/GET-user.test.js b/test/api/v3/integration/user/GET-user.test.js index 099526e9e6..8fbedca8cc 100644 --- a/test/api/v3/integration/user/GET-user.test.js +++ b/test/api/v3/integration/user/GET-user.test.js @@ -5,25 +5,20 @@ import { describe('GET /user', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('returns the authenticated user', () => { - return user.get('/user') - .then(returnedUser => { - expect(returnedUser._id).to.equal(user._id); - }); + it('returns the authenticated user', async () => { + let returnedUser = await user.get('/user'); + expect(returnedUser._id).to.equal(user._id); }); - it('does not return private paths (and apiToken)', () => { - return user.get('/user') - .then(returnedUser => { - expect(returnedUser.auth.local.hashed_password).to.not.exist; - expect(returnedUser.auth.local.salt).to.not.exist; - expect(returnedUser.apiToken).to.not.exist; - }); + it('does not return private paths (and apiToken)', async () => { + let returnedUser = await user.get('/user'); + + expect(returnedUser.auth.local.hashed_password).to.not.exist; + expect(returnedUser.auth.local.salt).to.not.exist; + expect(returnedUser.apiToken).to.not.exist; }); }); diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index a0f83717ea..862ee056c2 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -8,26 +8,30 @@ import { each } from 'lodash'; describe('POST /user/auth/local/register', () => { context('username and email are free', () => { - it('registers a new user', () => { - let api = requester(); + let api; + + beforeEach(async () => { + api = requester(); + }); + + it('registers a new user', async () => { let username = generateRandomUserName(); let email = `${username}@example.com`; let password = 'password'; - return api.post('/user/auth/local/register', { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user._id).to.exist; - expect(user.apiToken).to.exist; - expect(user.auth.local.username).to.eql(username); }); + + expect(user._id).to.exist; + expect(user.apiToken).to.exist; + expect(user.auth.local.username).to.eql(username); }); it('requires password and confirmPassword to match', () => { - let api = requester(); let username = generateRandomUserName(); let email = `${username}@example.com`; let password = 'password'; @@ -46,7 +50,6 @@ describe('POST /user/auth/local/register', () => { }); it('requires a username', () => { - let api = requester(); let email = `${generateRandomUserName()}@example.com`; let password = 'password'; let confirmPassword = 'password'; @@ -63,7 +66,6 @@ describe('POST /user/auth/local/register', () => { }); it('requires an email', () => { - let api = requester(); let username = generateRandomUserName(); let password = 'password'; @@ -79,7 +81,6 @@ describe('POST /user/auth/local/register', () => { }); it('requires a valid email', () => { - let api = requester(); let username = generateRandomUserName(); let email = 'notanemail@sdf'; let password = 'password'; @@ -97,7 +98,6 @@ describe('POST /user/auth/local/register', () => { }); it('requires a password', () => { - let api = requester(); let username = generateRandomUserName(); let email = `${username}@example.com`; let confirmPassword = 'password'; @@ -115,11 +115,13 @@ describe('POST /user/auth/local/register', () => { }); context('login is already taken', () => { - let username, email; + let username, email, api; - beforeEach(() => { + beforeEach(async () => { + api = requester(); username = generateRandomUserName(); email = `${username}@example.com`; + return generateUser({ 'auth.local.username': username, 'auth.local.lowerCaseUsername': username, @@ -128,7 +130,6 @@ describe('POST /user/auth/local/register', () => { }); it('rejects if username is already taken', () => { - let api = requester(); let uniqueEmail = `${generateRandomUserName()}@exampe.com`; let password = 'password'; @@ -145,7 +146,6 @@ describe('POST /user/auth/local/register', () => { }); it('rejects if email is already taken', () => { - let api = requester(); let uniqueUsername = generateRandomUserName(); let password = 'password'; @@ -172,44 +172,44 @@ describe('POST /user/auth/local/register', () => { password = 'password'; }); - it('sets all site tour values to -2 (already seen)', () => { - return api.post('/user/auth/local/register', { + it('sets all site tour values to -2 (already seen)', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.flags.tour).to.not.be.empty; + }); - each(user.flags.tour, (value) => { - expect(value).to.eql(-2); - }); + expect(user.flags.tour).to.not.be.empty; + + each(user.flags.tour, (value) => { + expect(value).to.eql(-2); }); }); - it('populates user with default todos, not no other task types', () => { - return api.post('/user/auth/local/register', { + it('populates user with default todos, not no other task types', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tasksOrder.todos).to.not.be.empty; - expect(user.tasksOrder.dailys).to.be.empty; - expect(user.tasksOrder.habits).to.be.empty; - expect(user.tasksOrder.rewards).to.be.empty; }); + + expect(user.tasksOrder.todos).to.not.be.empty; + expect(user.tasksOrder.dailys).to.be.empty; + expect(user.tasksOrder.habits).to.be.empty; + expect(user.tasksOrder.rewards).to.be.empty; }); - it('populates user with default tags', () => { - return api.post('/user/auth/local/register', { + it('populates user with default tags', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tags).to.not.be.empty; }); + + expect(user.tags).to.not.be.empty; }); }); @@ -223,44 +223,44 @@ describe('POST /user/auth/local/register', () => { password = 'password'; }); - it('sets all common tutorial flags to true', () => { - return api.post('/user/auth/local/register', { + it('sets all common tutorial flags to true', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.flags.tour).to.not.be.empty; + }); - each(user.flags.tutorial.common, (value) => { - expect(value).to.eql(true); - }); + expect(user.flags.tour).to.not.be.empty; + + each(user.flags.tutorial.common, (value) => { + expect(value).to.eql(true); }); }); - it('populates user with default todos, habits, and rewards', () => { - return api.post('/user/auth/local/register', { + it('populates user with default todos, habits, and rewards', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tasksOrder.todos).to.not.be.empty; - expect(user.tasksOrder.dailys).to.be.empty; - expect(user.tasksOrder.habits).to.not.be.empty; - expect(user.tasksOrder.rewards).to.not.be.empty; }); + + expect(user.tasksOrder.todos).to.not.be.empty; + expect(user.tasksOrder.dailys).to.be.empty; + expect(user.tasksOrder.habits).to.not.be.empty; + expect(user.tasksOrder.rewards).to.not.be.empty; }); - it('populates user with default tags', () => { - return api.post('/user/auth/local/register', { + it('populates user with default tags', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tags).to.not.be.empty; }); + + expect(user.tags).to.not.be.empty; }); }); }); From 55db0a4a4b066b5d103f0a8433de9f84e1c1b6db Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Jan 2016 17:27:06 +0100 Subject: [PATCH 327/976] add routes to get a single user, get members for a group, get members invited to a group --- common/locales/en/api-v3.json | 1 + website/src/controllers/api-v3/challenges.js | 2 +- website/src/controllers/api-v3/chat.js | 2 +- website/src/controllers/api-v3/members.js | 157 +++++++++++++++++++ website/src/models/group.js | 1 - website/src/models/user.js | 10 +- 6 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 website/src/controllers/api-v3/members.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index c934ad7446..c87946e717 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -15,6 +15,7 @@ "cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", "onlySocialAttachLocal": "Local auth can only be added to a social account.", "invalidReqParams": "Invalid request parameters.", + "memberIdRequired": "\"member\" must be a valid UUID.", "taskIdRequired": "\"taskId\" must be a valid UUID.", "taskNotFound": "Task not found.", "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index fa3a71b818..7eaeb940d5 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -167,7 +167,7 @@ function _closeChal (challenge, broken = {}) { Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(), // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges User.update({ - challenges: {$in: [challenge._id]}, + challenges: challenge._id, 'tags._id': challenge._id, }, { $set: {'tags.$.challenge': false}, diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 48dc2e440d..7c2d42bf62 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -7,7 +7,7 @@ import { } from '../../libs/api-v3/errors'; import _ from 'lodash'; import { sendTxn } from '../../libs/api-v3/email'; -import nconf from 'nconf'; +import nconf from 'nconf'; let api = {}; diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js new file mode 100644 index 0000000000..7bc719d7d4 --- /dev/null +++ b/website/src/controllers/api-v3/members.js @@ -0,0 +1,157 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { + model as User, + publicFields as memberFields, + nameFields, +} from '../../models/user'; +import { model as Group } from '../../models/group'; +import { + NotFound, +} from '../../libs/api-v3/errors'; + +let api = {}; + +// TODO allow only to select nameFields instead of all publicFields? +/** + * @api {get} /members/:memberId Get a member profile + * @apiVersion 3.0.0 + * @apiName GetMember + * @apiGroup Member + * + * @apiParam {UUID} memberId The member's id + * + * @apiSuccess {object} member The member object + */ +api.getMember = { + method: 'GET', + url: '/members/:memberId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let memberId = req.params.memberId; + + let member = await User + .findById(memberId) + .select(memberFields) + .exec(); + + if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); + + res.respond(200, member); + }, +}; + +// TODO allow to get more members' fields (the same as in api.getMember) for parties? +/** + * @api {get} /groups/:groupId/members Get members for a groups with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @apiVersion 3.0.0 + * @apiName GetMembersForGroup + * @apiGroup Member + * + * @apiParam {UUID} groupId The group id + * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results + * @apiParam {boolean} includeAllPublicFields Query parameter avalaible only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member) + * + * @apiSuccess {array} members An array of members, sorted by _id + */ +api.getMembersForGroup = { + method: 'GET', + url: '/groups/:groupId/members', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkQuery('lastId').optional().notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let groupId = req.params.groupId; + let lastId = req.query.lastId; + let user = res.locals.user; + + let group = await Group.getGroup(user, groupId, '_id type'); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let query = {}; + let fields = nameFields; + + if (group.type === 'guild') { + query.guilds = group._id; + } else { + query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' + + if (req.query.includeAllPublicFields === 'true') { + fields = memberFields; + } + } + + if (lastId) query._id = {$gt: lastId}; + + let members = await User + .find(query) + .sortBy({_id: 1}) + .limit(30) + .select(fields) + .exec(); + + res.respond(200, members); + }, +}; + +// TODO very similar to getInvitesForGroup might be worth abstracting some logic +/** + * @api {get} /groups/:groupId/invites Get invites for a groups with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @apiVersion 3.0.0 + * @apiName GetInvitesForGroup + * @apiGroup Member + * + * @apiParam {UUID} groupId The group id + * @apiParam {UUID} lastId Query parameter to specify the last invite returned in a previous request to this route and get the next batch of results + * + * @apiSuccess {array} invites An array of invites, sorted by _id + */ +api.getInvitesForGroup = { + method: 'GET', + url: '/groups/:groupId/invites', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkQuery('lastId').optional().notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let groupId = req.params.groupId; + let lastId = req.query.lastId; + let user = res.locals.user; + + let group = await Group.getGroup(user, groupId, '_id type'); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let query = {}; + + if (group.type === 'guild') { + query['invitations.guilds.id'] = group._id; + } else { + query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' + } + + if (lastId) query._id = {$gt: lastId}; + + let invites = await User + .find(query) + .sortBy({_id: 1}) + .limit(30) + .select(nameFields) + .exec(); + + res.respond(200, invites); + }, +}; + +export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 27ca80e73f..4770c5803d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -125,7 +125,6 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -// TODO populate (invites too), isMember? schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMembership) { let query; diff --git a/website/src/models/user.js b/website/src/models/user.js index 8dff4bcafc..0738d48e02 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -338,7 +338,6 @@ export let schema = new Schema({ orderAscending: {type: String, default: 'ascending'}, quest: { key: String, - // TODO why are we storing quest progress here too and not only on party object? progress: { up: {type: Number, default: 0}, down: {type: Number, default: 0}, @@ -489,6 +488,15 @@ schema.plugin(baseModel, { }, }); +// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private) +// TODO is all party data meant to be public? +export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt + preferences.costume preferences.sleep preferences.background profile stats achievements party + backer contributor auth.timestamps items`; + +// The minimum amount of data needed when populating multiple users +export let nameFields = `profile.name`; + schema.post('init', function postInitUser (doc) { shared.wrap(doc); }); From eaddf5a393c1a14e415fe2cac4a0d21fcb96ead3 Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 12 Jan 2016 18:59:53 +0200 Subject: [PATCH 328/976] Refactor notFound test to use await expect syntax --- test/api/v3/integration/notFound.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/notFound.test.js b/test/api/v3/integration/notFound.test.js index e86b6b35e8..6539a0037d 100644 --- a/test/api/v3/integration/notFound.test.js +++ b/test/api/v3/integration/notFound.test.js @@ -1,10 +1,10 @@ import { requester } from '../../../helpers/api-integration.helper'; describe('notFound Middleware', () => { - it('returns a 404 error when the resource is not found', () => { + it('returns a 404 error when the resource is not found', async () => { let request = requester().get('/api/v3/dummy-url'); - return expect(request).to.eventually.be.rejected.and.eql({ + await expect(request).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: 'Not found.', From 12705932e3b591a2d4afd1ff1f14fd63399b473a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Jan 2016 18:00:03 +0100 Subject: [PATCH 329/976] abstract common logic in members controller --- website/src/controllers/api-v3/members.js | 107 +++++++++------------- 1 file changed, 45 insertions(+), 62 deletions(-) diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 7bc719d7d4..3d36a63cda 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -12,7 +12,6 @@ import { let api = {}; -// TODO allow only to select nameFields instead of all publicFields? /** * @api {get} /members/:memberId Get a member profile * @apiVersion 3.0.0 @@ -46,24 +45,14 @@ api.getMember = { }, }; -// TODO allow to get more members' fields (the same as in api.getMember) for parties? -/** - * @api {get} /groups/:groupId/members Get members for a groups with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. - * @apiVersion 3.0.0 - * @apiName GetMembersForGroup - * @apiGroup Member - * - * @apiParam {UUID} groupId The group id - * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results - * @apiParam {boolean} includeAllPublicFields Query parameter avalaible only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member) - * - * @apiSuccess {array} members An array of members, sorted by _id - */ -api.getMembersForGroup = { - method: 'GET', - url: '/groups/:groupId/members', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { +// Return a request handler for getMembersForGroup / getInvitesForGroup +// type is `invites` or `members` +function handleGetMembersInvitesForGroup (type) { + if (type !== 'members' && type !== 'invites') { + throw new Error('Type must be "invites" or "members"'); + } + + return async function getMembersOrInvitesForGroup (req, res) { req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); req.checkQuery('lastId').optional().notEmpty().isUUID(); @@ -80,30 +69,56 @@ api.getMembersForGroup = { let query = {}; let fields = nameFields; - if (group.type === 'guild') { - query.guilds = group._id; - } else { - query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' + if (type === 'members') { + if (group.type === 'guild') { + query.guilds = group._id; + } else { + query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' - if (req.query.includeAllPublicFields === 'true') { - fields = memberFields; + if (req.query.includeAllPublicFields === 'true') { + fields = memberFields; + } + } + } else { + if (group.type === 'guild') { // eslint-disable-line no-lonely-if + query['invitations.guilds.id'] = group._id; + } else { + query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' } } if (lastId) query._id = {$gt: lastId}; - let members = await User + let users = await User .find(query) .sortBy({_id: 1}) .limit(30) .select(fields) .exec(); - res.respond(200, members); - }, + res.respond(200, users); + }; +} + +/** + * @api {get} /groups/:groupId/members Get members for a groups with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @apiVersion 3.0.0 + * @apiName GetMembersForGroup + * @apiGroup Member + * + * @apiParam {UUID} groupId The group id + * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results + * @apiParam {boolean} includeAllPublicFields Query parameter avalaible only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member) + * + * @apiSuccess {array} members An array of members, sorted by _id + */ +api.getMembersForGroup = { + method: 'GET', + url: '/groups/:groupId/members', + middlewares: [authWithHeaders(), cron], + handler: handleGetMembersInvitesForGroup('members'), }; -// TODO very similar to getInvitesForGroup might be worth abstracting some logic /** * @api {get} /groups/:groupId/invites Get invites for a groups with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 @@ -119,39 +134,7 @@ api.getInvitesForGroup = { method: 'GET', url: '/groups/:groupId/invites', middlewares: [authWithHeaders(), cron], - async handler (req, res) { - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - req.checkQuery('lastId').optional().notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let groupId = req.params.groupId; - let lastId = req.query.lastId; - let user = res.locals.user; - - let group = await Group.getGroup(user, groupId, '_id type'); - if (!group) throw new NotFound(res.t('groupNotFound')); - - let query = {}; - - if (group.type === 'guild') { - query['invitations.guilds.id'] = group._id; - } else { - query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' - } - - if (lastId) query._id = {$gt: lastId}; - - let invites = await User - .find(query) - .sortBy({_id: 1}) - .limit(30) - .select(nameFields) - .exec(); - - res.respond(200, invites); - }, + handler: handleGetMembersInvitesForGroup('invites'), }; export default api; From 2e3bee08d8cb4867addbb41f733fca41f841201a Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 12 Jan 2016 19:00:16 +0200 Subject: [PATCH 330/976] Refactor User tests to use await expect instead of return expect --- .../user/auth/POST-register_local.test.js | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 862ee056c2..68ff003645 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -31,13 +31,13 @@ describe('POST /user/auth/local/register', () => { expect(user.auth.local.username).to.eql(username); }); - it('requires password and confirmPassword to match', () => { + it('requires password and confirmPassword to match', async () => { let username = generateRandomUserName(); let email = `${username}@example.com`; let password = 'password'; let confirmPassword = 'not password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email, password, @@ -49,12 +49,12 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires a username', () => { + it('requires a username', async () => { let email = `${generateRandomUserName()}@example.com`; let password = 'password'; let confirmPassword = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { email, password, confirmPassword, @@ -65,11 +65,11 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires an email', () => { + it('requires an email', async () => { let username = generateRandomUserName(); let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, password, confirmPassword: password, @@ -80,12 +80,12 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires a valid email', () => { + it('requires a valid email', async () => { let username = generateRandomUserName(); let email = 'notanemail@sdf'; let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email, password, @@ -97,12 +97,12 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires a password', () => { + it('requires a password', async () => { let username = generateRandomUserName(); let email = `${username}@example.com`; let confirmPassword = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email, confirmPassword, @@ -129,11 +129,11 @@ describe('POST /user/auth/local/register', () => { }); }); - it('rejects if username is already taken', () => { + it('rejects if username is already taken', async () => { let uniqueEmail = `${generateRandomUserName()}@exampe.com`; let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email: uniqueEmail, password, @@ -145,11 +145,11 @@ describe('POST /user/auth/local/register', () => { }); }); - it('rejects if email is already taken', () => { + it('rejects if email is already taken', async () => { let uniqueUsername = generateRandomUserName(); let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username: uniqueUsername, email, password, From ac6a0276abaaf46d4587c5431b23ed28698d2adf Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 12 Jan 2016 19:11:59 +0200 Subject: [PATCH 331/976] Refactor Tasks tests to use await syntax --- .../integration/tasks/DELETE-tasks_id.test.js | 60 ++- .../v3/integration/tasks/GET-tasks.test.js | 42 +- .../v3/integration/tasks/GET-tasks_id.test.js | 54 +-- .../v3/integration/tasks/POST-tasks.test.js | 41 +- .../POST-tasks_id_score_direction.test.js | 367 ++++++++---------- .../v3/integration/tasks/PUT-tasks_id.test.js | 244 ++++++------ ...LETE-tasks_taskId_checklist_itemId.test.js | 54 ++- .../POST-tasks_taskId_checklist.test.js | 65 ++-- ...asks_taskId_checklist_itemId_score.test.js | 56 ++- .../PUT-tasks_taskId_checklist_itemId.test.js | 52 +-- .../DELETE-tasks_taskId_tags_tagId.test.js | 43 +- .../tags/POST-tasks_taskId_tags_tagId.test.js | 59 ++- 12 files changed, 507 insertions(+), 630 deletions(-) diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 1d3e59ef15..a8c804b715 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -6,60 +6,52 @@ import { describe('DELETE /tasks/:id', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); context('task can be deleted', () => { let task; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((createdTask) => { - task = createdTask; }); }); - it('deletes a user\'s task', () => { - return user.del(`/tasks/${task._id}`) - .then(() => { - return expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); + it('deletes a user\'s task', async () => { + await user.del(`/tasks/${task._id}`); + + await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); }); }); context('task cannot be deleted', () => { - it('cannot delete a non-existant task', () => { - return expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ + it('cannot delete a non-existant task', async () => { + await expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('cannot delete a task owned by someone else', () => { - return generateUser() - .then((anotherUser) => { - return anotherUser.post('/tasks', { - text: 'test habit', - type: 'habit', - }); - }) - .then((task2) => { - return expect(user.del(`/tasks/${task2._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); + it('cannot delete a task owned by someone else', async () => { + let anotherUser = await generateUser(); + let task2 = await anotherUser.post('/tasks', { + text: 'test habit', + type: 'habit', + }); + + await expect(user.del(`/tasks/${task2._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); }); it('cannot delete active challenge tasks'); // TODO after challenges are implemented diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 0772046f0b..4c169b3f6c 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -6,37 +6,27 @@ import Q from 'q'; describe('GET /tasks', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + beforeEach(async () => { + user = await generateUser(); }); - it('returns all user\'s tasks', () => { - let length; - return Q.all([ + it('returns all user\'s tasks', async () => { + let createdTasks = await Q.all([ user.post('/tasks', {text: 'test habit', type: 'habit'}), - ]) - .then((createdTasks) => { - length = createdTasks.length; - return user.get('/tasks'); - }) - .then((tasks) => { - expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task - }); + ]); + + let length = createdTasks.length; + let tasks = await user.get('/tasks'); + + expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task }); - it('returns only a type of user\'s tasks if req.query.type is specified', () => { - let habitId; - user.post('/tasks', {text: 'test habit', type: 'habit'}) - .then((task) => { - habitId = task._id; - return user.get('/tasks?type=habit'); - }) - .then((tasks) => { - expect(tasks.length).to.equal(1); - expect(tasks[0]._id).to.equal(habitId); - }); + it('returns only a type of user\'s tasks if req.query.type is specified', async () => { + let task = await user.post('/tasks', {text: 'test habit', type: 'habit'}); + let tasks = await user.get('/tasks?type=habit'); + + expect(tasks.length).to.equal(1); + expect(tasks[0]._id).to.equal(task._id); }); // TODO complete after task scoring is done diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index a4ae455d6a..84df18187a 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -7,29 +7,23 @@ import { v4 as generateUUID } from 'uuid'; describe('GET /tasks/:id', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - context('task can be accessed', () => { + context('task can be accessed', async () => { let task; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((createdTask) => { - task = createdTask; }); }); - it('gets specified task', () => { - return user.get(`/tasks/${task._id}`) - .then((getTask) => { - expect(getTask).to.eql(task); - }); + it('gets specified task', async () => { + let getTask = await user.get(`/tasks/${task._id}`); + expect(getTask).to.eql(task); }); // TODO after challenges are implemented @@ -37,34 +31,28 @@ describe('GET /tasks/:id', () => { }); context('task cannot be accessed', () => { - it('cannot get a non-existant task', () => { + it('cannot get a non-existant task', async () => { let dummyId = generateUUID(); - return expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ + await expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('cannot get a task owned by someone else', () => { - let anotherUser; + it('cannot get a task owned by someone else', async () => { + let anotherUser = await generateUser(); + let task = await user.post('/tasks', { + text: 'test habit', + type: 'habit', + }); - return generateUser() - .then((user2) => { - anotherUser = user2; - - return user.post('/tasks', { - text: 'test habit', - type: 'habit', - }); - }).then((task) => { - return expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); + await expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); }); }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 245cea7d28..7ce45a9d71 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -7,14 +7,12 @@ describe('POST /tasks', () => { let user; before(async () => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + user = await generateUser(); }); - context('validates params', () => { + context('validates params', async () => { it('returns an error if req.body.type is absent', async () => { - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -24,7 +22,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.type is not valid', async () => { - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -34,7 +32,7 @@ describe('POST /tasks', () => { }); it('returns an error if one object inside an array is invalid', async () => { - return expect(user.post('/tasks', [ + await expect(user.post('/tasks', [ {type: 'habitF'}, {type: 'habit'}, ])).to.eventually.be.rejected.and.eql({ @@ -45,7 +43,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.text is absent', async () => { - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { type: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -56,49 +54,46 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { type: 'habit', })).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, error: 'BadRequest', message: 'habit validation failed', - }).then(async () => { - let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; - - expect(updatedHabitsOrder).to.eql(originalHabitsOrder); }); + + let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; + expect(updatedHabitsOrder).to.eql(originalHabitsOrder); }); it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks', [ + await expect(user.post('/tasks', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, error: 'BadRequest', message: 'habit validation failed', - }).then(async () => { - let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; - - expect(updatedHabitsOrder).to.eql(originalHabitsOrder); }); + + let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; + expect(updatedHabitsOrder).to.eql(originalHabitsOrder); }); it('does not save any task sent in an array when 1 is invalid', async () => { let originalTasks = await user.get('/tasks'); - return expect(user.post('/tasks', [ + await expect(user.post('/tasks', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, error: 'BadRequest', message: 'habit validation failed', - }).then(async () => { - let updatedTasks = await user.get('/tasks'); - - expect(updatedTasks).to.eql(originalTasks); }); + + let updatedTasks = await user.get('/tasks'); + expect(updatedTasks).to.eql(originalTasks); }); it('automatically sets "task.userId" to user\'s uuid', async () => { diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 2978d93657..b01a54775c 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -7,25 +7,23 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:id/score/:direction', () => { let user; - beforeEach(() => { - return generateUser({ + beforeEach(async () => { + user = await generateUser({ 'stats.gp': 100, - }).then((generatedUser) => { - user = generatedUser; }); }); context('all', () => { - it('requires a task id', () => { - return expect(user.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ + it('requires a task id', async () => { + await expect(user.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), }); }); - it('requires a task direction', () => { - return expect(user.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ + it('requires a task direction', async () => { + await expect(user.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -36,110 +34,97 @@ describe('POST /tasks/:id/score/:direction', () => { context('todos', () => { let todo; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + todo = await user.post('/tasks', { text: 'test todo', type: 'todo', - }).then((task) => { - todo = task; }); }); - it('completes todo when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((task) => expect(task.completed).to.equal(true)); + it('completes todo when direction is up', async () => { + await user.post(`/tasks/${todo._id}/score/up`); + let task = await user.get(`/tasks/${todo._id}`); + + expect(task.completed).to.equal(true); }); - it('moves completed todos out of user.tasksOrder.todos', () => { - return user.get('/user') - .then(usr => { - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); - }).then(() => user.post(`/tasks/${todo._id}/score/up`)) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((updatedTask) => { - expect(updatedTask.completed).to.equal(true); - return user.get('/user'); - }) - .then((usr) => { - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); - }); + it('moves completed todos out of user.tasksOrder.todos', async () => { + let getUser = await user.get('/user'); + expect(getUser.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); + + await user.post(`/tasks/${todo._id}/score/up`); + let updatedTask = await user.get(`/tasks/${todo._id}`); + expect(updatedTask.completed).to.equal(true); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); }); - it('moves un-completed todos back into user.tasksOrder.todos', () => { - return user.get('/user') - .then(usr => { - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); - }).then(() => user.post(`/tasks/${todo._id}/score/up`)) - .then(() => user.post(`/tasks/${todo._id}/score/down`)) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((updatedTask) => { - expect(updatedTask.completed).to.equal(false); - return user.get('/user'); - }) - .then((usr) => { - let l = usr.tasksOrder.todos.length; - expect(usr.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom - }); + it('moves un-completed todos back into user.tasksOrder.todos', async () => { + let getUser = await user.get('/user'); + expect(getUser.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); + + await user.post(`/tasks/${todo._id}/score/up`); + await user.post(`/tasks/${todo._id}/score/down`); + + let updatedTask = await user.get(`/tasks/${todo._id}`); + expect(updatedTask.completed).to.equal(false); + + let updatedUser = await user.get('/user'); + let l = updatedUser.tasksOrder.todos.length; + expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); + expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom }); - it('uncompletes todo when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((updatedTask) => { - expect(updatedTask.completed).to.equal(false); - }); + it('uncompletes todo when direction is down', async () => { + await user.post(`/tasks/${todo._id}/score/down`); + let updatedTask = await user.get(`/tasks/${todo._id}`); + + expect(updatedTask.completed).to.equal(false); }); it('scores up todo even if it is already completed'); // Yes? it('scores down todo even if it is already uncompleted'); // Yes? - it('increases user\'s mp when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is up', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${todo._id}/score/up`); + updatedUser = await user.get(`/user`); + }); + + it('increases user\'s mp', () => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); - }); - it('decreases user\'s mp when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); - }); - - it('increases user\'s exp when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s exp', () => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); - }); - it('decreases user\'s exp when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); - }); - }); - - it('increases user\'s gold when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); - it('decreases user\'s gold when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is down', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${todo._id}/score/down`); + updatedUser = await user.get(`/user`); + }); + + it('decreases user\'s mp', () => { + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + }); + + it('decreases user\'s exp', () => { + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); + }); + + it('decreases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); @@ -148,75 +133,69 @@ describe('POST /tasks/:id/score/:direction', () => { context('dailys', () => { let daily; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + daily = await user.post('/tasks', { text: 'test daily', type: 'daily', - }).then((task) => { - daily = task; }); }); - it('completes daily when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/tasks/${daily._id}`)) - .then((task) => expect(task.completed).to.equal(true)); + it('completes daily when direction is up', async () => { + await user.post(`/tasks/${daily._id}/score/up`); + let task = await user.get(`/tasks/${daily._id}`); + + expect(task.completed).to.equal(true); }); - it('uncompletes daily when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/tasks/${daily._id}`)) - .then((task) => expect(task.completed).to.equal(false)); + it('uncompletes daily when direction is down', async () => { + await user.post(`/tasks/${daily._id}/score/down`); + let task = await user.get(`/tasks/${daily._id}`); + + expect(task.completed).to.equal(false); }); it('scores up daily even if it is already completed'); // Yes? it('scores down daily even if it is already uncompleted'); // Yes? - it('increases user\'s mp when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is up', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${daily._id}/score/up`); + updatedUser = await user.get(`/user`); + }); + + it('increases user\'s mp', () => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); - }); - it('decreases user\'s mp when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); - }); - - it('increases user\'s exp when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s exp', () => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); - }); - it('decreases user\'s exp when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); - }); - }); - - it('increases user\'s gold when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); - it('decreases user\'s gold when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is down', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${daily._id}/score/down`); + updatedUser = await user.get(`/user`); + }); + + it('decreases user\'s mp', () => { + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + }); + + it('decreases user\'s exp', () => { + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); + }); + + it('decreases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); @@ -225,34 +204,29 @@ describe('POST /tasks/:id/score/:direction', () => { context('habits', () => { let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + habit = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((task) => { - habit = task; - return user.post('/tasks', { - text: 'test min habit', - type: 'habit', - up: false, - }); - }).then((task) => { - minusHabit = task; - return user.post('/tasks', { - text: 'test plus habit', - type: 'habit', - down: false, - }); - }).then((task) => { - plusHabit = task; - user.post('/tasks', { - text: 'test neither habit', - type: 'habit', - up: false, - down: false, - }); - }).then((task) => { - neitherHabit = task; + }); + + minusHabit = await user.post('/tasks', { + text: 'test min habit', + type: 'habit', + up: false, + }); + + plusHabit = await user.post('/tasks', { + text: 'test plus habit', + type: 'habit', + down: false, + }); + + neitherHabit = await user.post('/tasks', { + text: 'test neither habit', + type: 'habit', + up: false, + down: false, }); }); @@ -260,82 +234,63 @@ describe('POST /tasks/:id/score/:direction', () => { it('prevents minus only habit from scoring up'); // Yes? - it('increases user\'s mp when direction is up', () => { - return user.post(`/tasks/${habit._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); - }); + it('increases user\'s mp when direction is up', async () => { + await user.post(`/tasks/${habit._id}/score/up`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); - it('decreases user\'s mp when direction is down', () => { - return user.post(`/tasks/${habit._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); + it('decreases user\'s mp when direction is down', async () => { + await user.post(`/tasks/${habit._id}/score/down`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); - it('increases user\'s exp when direction is up', () => { - return user.post(`/tasks/${habit._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); - }); + it('increases user\'s exp when direction is up', async () => { + await user.post(`/tasks/${habit._id}/score/up`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); - it('increases user\'s gold when direction is up', () => { - return user.post(`/tasks/${habit._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); - }); + it('increases user\'s gold when direction is up', async () => { + await user.post(`/tasks/${habit._id}/score/up`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); context('reward', () => { - let reward; + let reward, updatedUser; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + reward = await user.post('/tasks', { text: 'test reward', type: 'reward', value: 5, - }).then((task) => { - reward = task; }); + + await user.post(`/tasks/${reward._id}/score/up`); + updatedUser = await user.get(`/user`); }); it('purchases reward', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); - }); + expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); }); it('does not change user\'s mp', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.mp).to.equal(updatedUser.stats.mp); - }); + expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); it('does not change user\'s exp', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.exp).to.equal(updatedUser.stats.exp); - }); + expect(user.stats.exp).to.equal(updatedUser.stats.exp); }); it('does not allow a down direction', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.mp).to.equal(updatedUser.stats.mp); - }); + expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); }); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 3092d3bd84..5f7132136d 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -6,21 +6,17 @@ import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:id', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); context('validates params', () => { let task; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((createdTask) => { - task = createdTask; }); }); @@ -52,236 +48,228 @@ describe('PUT /tasks/:id', () => { }); }); - it('ignores invalid fields', () => { - user.put(`/tasks/${task._id}`, { + it('ignores invalid fields', async () => { + let savedTask = await user.put(`/tasks/${task._id}`, { notValid: true, - }).then((savedTask) => { - expect(savedTask.notValid).to.be.a('undefined'); }); + + expect(savedTask.notValid).to.be.undefined; }); }); context('habits', () => { let habit; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + habit = await user.post('/tasks', { text: 'test habit', type: 'habit', notes: 1976, - }).then((createdHabit) => { - habit = createdHabit; }); }); - it('updates a habit', () => { - return user.put(`/tasks/${habit._id}`, { + it('updates a habit', async () => { + let savedHabit = await user.put(`/tasks/${habit._id}`, { text: 'some new text', up: false, down: false, notes: 'some new notes', - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(false); }); + + expect(savedHabit.text).to.eql('some new text'); + expect(savedHabit.notes).to.eql('some new notes'); + expect(savedHabit.up).to.eql(false); + expect(savedHabit.down).to.eql(false); }); }); context('todos', () => { let todo; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + todo = await user.post('/tasks', { text: 'test todo', type: 'todo', notes: 1976, - }).then((createdTodo) => { - todo = createdTodo; }); }); - it('updates a todo', () => { - return user.put(`/tasks/${todo._id}`, { + it('updates a todo', async () => { + let savedTodo = await user.put(`/tasks/${todo._id}`, { text: 'some new text', notes: 'some new notes', - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); }); + + expect(savedTodo.text).to.eql('some new text'); + expect(savedTodo.notes).to.eql('some new notes'); }); - it('can update checklists (replace it)', () => { - return user.put(`/tasks/${todo._id}`, { + it('can update checklists (replace it)', async () => { + await user.put(`/tasks/${todo._id}`, { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, ], - }).then(() => { - return user.put(`/tasks/${todo._id}`, { - checklist: [ - {text: 789, completed: false}, - ], - }); - }).then((savedTodo2) => { - expect(savedTodo2.checklist.length).to.equal(1); - expect(savedTodo2.checklist[0].text).to.equal('789'); - expect(savedTodo2.checklist[0].completed).to.equal(false); }); + + let savedTodo = await user.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 789, completed: false}, + ], + }); + + expect(savedTodo.checklist.length).to.equal(1); + expect(savedTodo.checklist[0].text).to.equal('789'); + expect(savedTodo.checklist[0].completed).to.equal(false); }); - it('can update tags (replace them)', () => { + it('can update tags (replace them)', async () => { let finalUUID = generateUUID(); - return user.put(`/tasks/${todo._id}`, { + await user.put(`/tasks/${todo._id}`, { tags: [generateUUID(), generateUUID()], - }).then(() => { - return user.put(`/tasks/${todo._id}`, { - tags: [finalUUID], - }); - }).then((savedTodo2) => { - expect(savedTodo2.tags.length).to.equal(1); - expect(savedTodo2.tags[0]).to.equal(finalUUID); }); + + let savedTodo = await user.put(`/tasks/${todo._id}`, { + tags: [finalUUID], + }); + + expect(savedTodo.tags.length).to.equal(1); + expect(savedTodo.tags[0]).to.equal(finalUUID); }); }); context('dailys', () => { let daily; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + daily = await user.post('/tasks', { text: 'test daily', type: 'daily', notes: 1976, - }).then((createdDaily) => { - daily = createdDaily; }); }); - it('updates a daily', () => { - return user.put(`/tasks/${daily._id}`, { + it('updates a daily', async () => { + let savedDaily = await user.put(`/tasks/${daily._id}`, { text: 'some new text', notes: 'some new notes', frequency: 'daily', everyX: 5, - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); - expect(task.frequency).to.eql('daily'); - expect(task.everyX).to.eql(5); }); + + expect(savedDaily.text).to.eql('some new text'); + expect(savedDaily.notes).to.eql('some new notes'); + expect(savedDaily.frequency).to.eql('daily'); + expect(savedDaily.everyX).to.eql(5); }); - it('can update checklists (replace it)', () => { - return user.put(`/tasks/${daily._id}`, { + it('can update checklists (replace it)', async () => { + await user.put(`/tasks/${daily._id}`, { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, ], - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - checklist: [ - {text: 789, completed: false}, - ], - }); - }).then((savedDaily2) => { - expect(savedDaily2.checklist.length).to.equal(1); - expect(savedDaily2.checklist[0].text).to.equal('789'); - expect(savedDaily2.checklist[0].completed).to.equal(false); }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 789, completed: false}, + ], + }); + + expect(savedDaily.checklist.length).to.equal(1); + expect(savedDaily.checklist[0].text).to.equal('789'); + expect(savedDaily.checklist[0].completed).to.equal(false); }); - it('can update tags (replace them)', () => { + it('can update tags (replace them)', async () => { let finalUUID = generateUUID(); - return user.put(`/tasks/${daily._id}`, { + await user.put(`/tasks/${daily._id}`, { tags: [generateUUID(), generateUUID()], - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - tags: [finalUUID], - }); - }).then((savedDaily2) => { - expect(savedDaily2.tags.length).to.equal(1); - expect(savedDaily2.tags[0]).to.equal(finalUUID); }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + tags: [finalUUID], + }); + + expect(savedDaily.tags.length).to.equal(1); + expect(savedDaily.tags[0]).to.equal(finalUUID); }); - it('updates repeat, even if frequency is set to daily', () => { - return user.put(`/tasks/${daily._id}`, { + it('updates repeat, even if frequency is set to daily', async () => { + await user.put(`/tasks/${daily._id}`, { frequency: 'daily', - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - repeat: { - m: false, - su: false, - }, - }); - }).then((savedDaily2) => { - expect(savedDaily2.repeat).to.eql({ + }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + repeat: { m: false, - t: true, - w: true, - th: true, - f: true, - s: true, su: false, - }); + }, + }); + + expect(savedDaily.repeat).to.eql({ + m: false, + t: true, + w: true, + th: true, + f: true, + s: true, + su: false, }); }); - it('updates everyX, even if frequency is set to weekly', () => { - return user.put(`/tasks/${daily._id}`, { + it('updates everyX, even if frequency is set to weekly', async () => { + await user.put(`/tasks/${daily._id}`, { frequency: 'weekly', - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - everyX: 5, - }); - }).then((savedDaily2) => { - expect(savedDaily2.everyX).to.eql(5); }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + everyX: 5, + }); + + expect(savedDaily.everyX).to.eql(5); }); - it('defaults startDate to today if none date object is passed in', () => { - return user.put(`/tasks/${daily._id}`, { + it('defaults startDate to today if none date object is passed in', async () => { + let savedDaily = await user.put(`/tasks/${daily._id}`, { frequency: 'weekly', - }).then((savedDaily2) => { - expect((new Date(savedDaily2.startDate)).getDay()).to.eql((new Date()).getDay()); }); + + expect((new Date(savedDaily.startDate)).getDay()).to.eql((new Date()).getDay()); }); }); context('rewards', () => { let reward; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + reward = await user.post('/tasks', { text: 'test reward', type: 'reward', notes: 1976, value: 10, - }).then((createdReward) => { - reward = createdReward; }); }); - it('updates a reward', () => { - return user.put(`/tasks/${reward._id}`, { + it('updates a reward', async () => { + let savedReward = await user.put(`/tasks/${reward._id}`, { text: 'some new text', notes: 'some new notes', value: 10, - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); - expect(task.value).to.eql(10); }); + + expect(savedReward.text).to.eql('some new text'); + expect(savedReward.notes).to.eql('some new notes'); + expect(savedReward.value).to.eql(10); }); - it('requires value to be coerced into a number', () => { - return user.put(`/tasks/${reward._id}`, { + it('requires value to be coerced into a number', async () => { + let savedReward = await user.put(`/tasks/${reward._id}`, { value: '100', - }).then((task) => { - expect(task.value).to.eql(100); }); + + expect(savedReward.value).to.eql(100); }); }); }); diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index d013878a74..6c690c6eb9 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -7,39 +7,31 @@ import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:taskId/checklist/:itemId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('deletes a checklist item', () => { - let task; - - return user.post('/tasks', { + it('deletes a checklist item', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - }).then((savedTask) => { - return user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); - }).then(() => { - return user.get(`/tasks/${task._id}`); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(0); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + + await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + savedTask = await user.get(`/tasks/${task._id}`); + + expect(savedTask.checklist.length).to.equal(0); }); - it('does not work with habits', () => { - let habit; - return expect(user.post('/tasks', { + it('does not work with habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.del(`/tasks/${habit._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), @@ -59,21 +51,21 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); }); - it('fails on task not found', () => { - return expect(user.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + it('fails on task not found', async () => { + await expect(user.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + it('fails on checklist item not found', async () => { + let createdTask = await user.post('/tasks', { type: 'daily', text: 'daily with checklist', - }).then(createdTask => { - return user.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('checklistItemNotFound'), diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index e9b695effd..3e1dfb8494 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -7,41 +7,38 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/checklist/', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('adds a checklist item to a task', () => { - let task; - - return user.post('/tasks', { + it('adds a checklist item to a task', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', ignored: false, _id: 123}); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); - expect(savedTask.checklist[0].completed).to.equal(false); - expect(savedTask.checklist[0]._id).to.be.a('string'); - expect(savedTask.checklist[0]._id).to.not.equal('123'); - expect(savedTask.checklist[0].ignored).to.be.an('undefined'); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + ignored: false, + _id: 123, + }); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); + expect(savedTask.checklist[0].completed).to.equal(false); + expect(savedTask.checklist[0]._id).to.be.a('string'); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].ignored).to.be.an('undefined'); }); - it('does not add a checklist to habits', () => { - let habit; - - return expect(user.post('/tasks', { + it('does not add a checklist to habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.post(`/tasks/${habit._id}/checklist`, {text: 'Checklist Item 1'}); + }); + + await expect(user.post(`/tasks/${habit._id}/checklist`, { + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -49,14 +46,14 @@ describe('POST /tasks/:taskId/checklist/', () => { }); }); - it('does not add a checklist to rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('does not add a checklist to rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.post(`/tasks/${reward._id}/checklist`, {text: 'Checklist Item 1'}); + }); + + await expect(user.post(`/tasks/${reward._id}/checklist`, { + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -64,8 +61,8 @@ describe('POST /tasks/:taskId/checklist/', () => { }); }); - it('fails on task not found', () => { - return expect(user.post(`/tasks/${generateUUID()}/checklist`, { + it('fails on task not found', async () => { + await expect(user.post(`/tasks/${generateUUID()}/checklist`, { text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 667ef41446..345e1c400e 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -7,37 +7,35 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/checklist/:itemId/score', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('scores a checklist item', () => { - let task; - - return user.post('/tasks', { + it('scores a checklist item', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - }).then((savedTask) => { - return user.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].completed).to.equal(true); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + completed: false, + }); + + savedTask = await user.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].completed).to.equal(true); }); - it('fails on habits', () => { - let habit; - return expect(user.post('/tasks', { + it('fails on habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, {text: 'Checklist Item 1'}); + }); + + await expect(user.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, { + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -58,21 +56,21 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); }); - it('fails on task not found', () => { - return expect(user.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ + it('fails on task not found', async () => { + await expect(user.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + it('fails on checklist item not found', async () => { + let createdTask = await user.post('/tasks', { type: 'daily', text: 'daily with checklist', - }).then(createdTask => { - return user.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('checklistItemNotFound'), diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index 988574bbf8..0d93f6cc64 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -7,29 +7,31 @@ import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:taskId/checklist/:itemId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('updates a checklist item', () => { - let task; - - return user.post('/tasks', { + it('updates a checklist item', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - }).then((savedTask) => { - return user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, {text: 'updated', completed: true, _id: 123}); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('updated'); - expect(savedTask.checklist[0].completed).to.equal(true); - expect(savedTask.checklist[0]._id).to.not.equal('123'); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + completed: false, + }); + + savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { + text: 'updated', + completed: true, + _id: 123, + }); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('updated'); + expect(savedTask.checklist[0].completed).to.equal(true); + expect(savedTask.checklist[0]._id).to.not.equal('123'); }); it('fails on habits', async () => { @@ -58,21 +60,21 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); }); - it('fails on task not found', () => { - return expect(user.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + it('fails on task not found', async () => { + await expect(user.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + it('fails on checklist item not found', async () => { + let createdTask = await user.post('/tasks', { type: 'daily', text: 'daily with checklist', - }).then(createdTask => { - return user.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('checklistItemNotFound'), diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index c01b71fa2c..7ddeb6fe14 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -7,40 +7,33 @@ import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:taskId/tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('removes a tag from a task', () => { - let tag; - let task; - - return user.post('/tasks', { + it('removes a tag from a task', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - task = createdTask; - return user.post('/tags', {name: 'Tag 1'}); - }).then(createdTag => { - tag = createdTag; - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(() => { - return user.del(`/tasks/${task._id}/tags/${tag._id}`); - }).then(() => user.get(`/tasks/${task._id}`)) - .then(updatedTask => { - expect(updatedTask.tags.length).to.equal(0); }); + + let tag = await user.post('/tags', {name: 'Tag 1'}); + + await user.post(`/tasks/${task._id}/tags/${tag._id}`); + await user.del(`/tasks/${task._id}/tags/${tag._id}`); + + let updatedTask = await user.get(`/tasks/${task._id}`); + + expect(updatedTask.tags.length).to.equal(0); }); - it('only deletes existing tags', () => { - return expect(user.post('/tasks', { + it('only deletes existing tags', async () => { + let createdTask = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - return user.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('tagNotFound'), diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index 6e4c2ba510..8377d5a012 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -7,59 +7,46 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('adds a tag to a task', () => { - let tag; - let task; - - return user.post('/tasks', { + it('adds a tag to a task', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - task = createdTask; - return user.post('/tags', {name: 'Tag 1'}); - }).then(createdTag => { - tag = createdTag; - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(savedTask => { - expect(savedTask.tags[0]).to.equal(tag._id); }); + + let tag = await user.post('/tags', {name: 'Tag 1'}); + let savedTask = await user.post(`/tasks/${task._id}/tags/${tag._id}`); + + expect(savedTask.tags[0]).to.equal(tag._id); }); - it('does not add a tag to a task twice', () => { - let tag; - let task; - - return expect(user.post('/tasks', { + it('does not add a tag to a task twice', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - task = createdTask; - return user.post('/tags', {name: 'Tag 1'}); - }).then(createdTag => { - tag = createdTag; - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(() => { - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - })).to.eventually.be.rejected.and.eql({ + }); + + let tag = await user.post('/tags', {name: 'Tag 1'}); + + await user.post(`/tasks/${task._id}/tags/${tag._id}`); + + await expect(user.post(`/tasks/${task._id}/tags/${tag._id}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('alreadyTagged'), }); }); - it('does not add a non existing tag to a task', () => { - return expect(user.post('/tasks', { + it('does not add a non existing tag to a task', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then((task) => { - return user.post(`/tasks/${task._id}/tags/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.post(`/tasks/${task._id}/tags/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), From 99217e865cb4589b3aac5320d62c53555c21685f Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 11 Jan 2016 12:53:03 -0600 Subject: [PATCH 332/976] Added initial group update tests --- .../v3/integration/groups/PUT-groups.test.js | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 test/api/v3/integration/groups/PUT-groups.test.js diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js new file mode 100644 index 0000000000..5a7abf3467 --- /dev/null +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -0,0 +1,50 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('Put /group', () => { + let groupLeader; + let groupName = 'Test Public Guild'; + let groupType = 'guild'; + + beforeEach(async () => { + groupLeader = await generateUser({balance: 1}); + }); + + xit('returns an error when a non group leader tries to update', async () => { + let groupToUpdate = await groupLeader.post('/groups', { + name: groupName, + type: groupType, + }); + let groupUpdatedName = 'Test Public Guild Updated'; + let memberToAttemptUpdate = await generateUser(); + + await groupLeader.post(`/groups/${groupToUpdate._id}/invite`, { + uuids: [memberToAttemptUpdate._id], + }); + await memberToAttemptUpdate.post(`/groups/${groupToUpdate._id}/join`); + + await expect(memberToAttemptUpdate.put(`/groups/${groupToUpdate._id}`, { + name: groupUpdatedName, + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupOnlyLeaderCanUpdate'), + }); + }); + + it('updates a group', async () => { + let groupUpdatedName = 'Test Public Guild Updated'; + let group = await groupLeader.post('/groups', { + name: groupName, + type: groupType, + }); + let updatedGroup = await groupLeader.put(`/groups/${group._id}`, { + name: groupUpdatedName, + }); + + expect(updatedGroup.name).to.equal(groupUpdatedName); + }); +}); From da02f8ca0f333df518108be475feedbba94ec904 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 12 Jan 2016 11:48:07 -0600 Subject: [PATCH 333/976] Removed pending status on test --- test/api/v3/integration/groups/PUT-groups.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 5a7abf3467..38c17df823 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -12,7 +12,7 @@ describe('Put /group', () => { groupLeader = await generateUser({balance: 1}); }); - xit('returns an error when a non group leader tries to update', async () => { + it('returns an error when a non group leader tries to update', async () => { let groupToUpdate = await groupLeader.post('/groups', { name: groupName, type: groupType, From 45aacc1e6f758c622ad2bbffd90e80108a43d5ad Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Jan 2016 19:43:41 +0100 Subject: [PATCH 334/976] challenge.group -> challenge.groupId --- website/src/controllers/api-v3/challenges.js | 10 +++++----- website/src/models/challenge.js | 11 ++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index fa3a71b818..252ffe6c7e 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -30,12 +30,12 @@ api.createChallenge = { async handler (req, res) { let user = res.locals.user; - req.checkBody('group', res.t('groupIdRequired')).notEmpty(); + req.checkBody('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let groupId = req.body.group; + let groupId = req.body.groupId; let prize = req.body.prize; let group = await Group.getGroup(user, groupId, '-chat'); @@ -107,7 +107,7 @@ api.getChallenges = { let challenges = await Challenge.find({ $or: [ {_id: {$in: user.challenges}}, // Challenges where the user is participating - {group: {$in: groups}}, // Challenges in groups where I'm a member + {groupId: {$in: groups}}, // Challenges in groups where I'm a member {leader: user._id}, // Challenges where I'm the leader ], _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit @@ -183,11 +183,11 @@ function _closeChal (challenge, broken = {}) { }, }, {multi: true}).exec(), // Update the challengeCount on the group - Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(), + Group.update({_id: challenge.groupId}, {$inc: {challengeCount: -1}}).exec(), ]; // Refund the leader if the challenge is closed and the group not the tavern - if (challenge.group !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { + if (challenge.groupId !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { tasks.push(User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec()); } diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index d5b3bf1a1b..00ab26a491 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -11,7 +11,7 @@ let schema = new Schema({ name: {type: String, required: true}, shortName: {type: String, required: true}, // TODO what is it? description: String, - official: {type: Boolean, default: false}, + official: {type: Boolean, default: false}, // TODO only settable by admin tasksOrder: { habits: [{type: String, ref: 'Task'}], dailys: [{type: String, ref: 'Task'}], @@ -19,14 +19,15 @@ let schema = new Schema({ rewards: [{type: String, ref: 'Task'}], }, leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, - group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, - timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? + groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, + timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable? memberCount: {type: Number, default: 0}, - prize: {type: Number, default: 0, min: 0}, + challengeCount: {type: Number, default: 0}, + prize: {type: Number, default: 0, min: 0}, // TODO no update? }); schema.plugin(baseModel, { - noSet: ['_id', 'memberCount', 'tasksOrder'], + noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], }); From e4fd37f3d0f450f11e199b73aa20f2f3c3337527 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 12 Jan 2016 13:08:13 -0600 Subject: [PATCH 335/976] Cleaned up code style and abstracted variables that are reused. --- .../v3/integration/groups/PUT-groups.test.js | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 38c17df823..6700374697 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -3,21 +3,22 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; -describe('Put /group', () => { +describe('PUT /group', () => { let groupLeader; let groupName = 'Test Public Guild'; let groupType = 'guild'; + let groupToUpdate; + let groupUpdatedName = 'Test Public Guild Updated'; beforeEach(async () => { groupLeader = await generateUser({balance: 1}); - }); - - it('returns an error when a non group leader tries to update', async () => { - let groupToUpdate = await groupLeader.post('/groups', { + groupToUpdate = await groupLeader.post('/groups', { name: groupName, type: groupType, }); - let groupUpdatedName = 'Test Public Guild Updated'; + }); + + it('returns an error when a non group leader tries to update', async () => { let memberToAttemptUpdate = await generateUser(); await groupLeader.post(`/groups/${groupToUpdate._id}/invite`, { @@ -27,8 +28,7 @@ describe('Put /group', () => { await expect(memberToAttemptUpdate.put(`/groups/${groupToUpdate._id}`, { name: groupUpdatedName, - })) - .to.eventually.be.rejected.and.eql({ + })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageGroupOnlyLeaderCanUpdate'), @@ -36,12 +36,7 @@ describe('Put /group', () => { }); it('updates a group', async () => { - let groupUpdatedName = 'Test Public Guild Updated'; - let group = await groupLeader.post('/groups', { - name: groupName, - type: groupType, - }); - let updatedGroup = await groupLeader.put(`/groups/${group._id}`, { + let updatedGroup = await groupLeader.put(`/groups/${groupToUpdate._id}`, { name: groupUpdatedName, }); From 336d8c9916ed79d0c800a535003b053c9c452147 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 17:36:14 -0600 Subject: [PATCH 336/976] tests(api): Clean up misc test styles --- test/api/v3/integration/tags/DELETE-tags_id.test.js | 9 ++++----- test/api/v3/integration/tags/GET-tags.test.js | 10 ++++------ test/api/v3/integration/tags/POST-tags.test.js | 6 +++--- test/api/v3/integration/tags/PUT-tags_id.test.js | 6 +++--- test/api/v3/integration/tasks/DELETE-tasks_id.test.js | 4 ++-- 5 files changed, 16 insertions(+), 19 deletions(-) diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js index b83521613b..9d6b53c531 100644 --- a/test/api/v3/integration/tags/DELETE-tags_id.test.js +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -4,25 +4,24 @@ import { describe('DELETE /tags/:tagId', () => { let user; - let tagName = 'Tag 1'; before(async () => { user = await generateUser(); }); it('deletes a tag given it\'s id', async () => { + let tagName = 'Tag 1'; let tag = await user.post('/tags', {name: tagName}); - let tags = await user.get('/tags'); - let length = tags.length; + let numberOfTags = (await user.get('/tags')).length; await user.del(`/tags/${tag._id}`); - tags = await user.get('/tags'); + let tags = await user.get('/tags'); let tagNames = tags.map((t) => { return t.name; }); - expect(tags.length).to.equal(length - 1); + expect(tags.length).to.equal(numberOfTags - 1); expect(tagNames).to.not.include(tagName); }); }); diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js index 2cb30be885..5281bd35af 100644 --- a/test/api/v3/integration/tags/GET-tags.test.js +++ b/test/api/v3/integration/tags/GET-tags.test.js @@ -4,21 +4,19 @@ import { describe('GET /tags', () => { let user; - let tagName1 = 'Tag 1'; - let tagName2 = 'Tag 2'; before(async () => { user = await generateUser(); }); it('returns all user\'s tags', async () => { - await user.post('/tags', {name: tagName1}); - await user.post('/tags', {name: tagName2}); + let tag1 = await user.post('/tags', {name: 'Tag 1'}); + let tag2 = await user.post('/tags', {name: 'Tag 2'}); let tags = await user.get('/tags'); expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task - expect(tags[tags.length - 2].name).to.equal(tagName1); - expect(tags[tags.length - 1].name).to.equal(tagName2); + expect(tags[tags.length - 2].name).to.equal(tag1.name); + expect(tags[tags.length - 1].name).to.equal(tag2.name); }); }); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js index 78a73c578a..7b7b5c4a34 100644 --- a/test/api/v3/integration/tags/POST-tags.test.js +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -4,13 +4,13 @@ import { describe('POST /tags', () => { let user; - let tagName = 'Tag 1'; - before(async () => { + beforeEach(async () => { user = await generateUser(); }); it('creates a tag correctly', async () => { + let tagName = 'Tag 1'; let createdTag = await user.post('/tags', { name: tagName, ignored: false, @@ -19,7 +19,7 @@ describe('POST /tags', () => { let tag = await user.get(`/tags/${createdTag._id}`); expect(tag.name).to.equal(tagName); - expect(tag.ignored).to.be.undefined; + expect(tag.ignored).to.not.exist; expect(tag).to.deep.equal(createdTag); }); }); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index 71fbc2ef58..c2576a3f0c 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -4,13 +4,13 @@ import { describe('PUT /tags/:tagId', () => { let user; - let updatedTagName = 'Tag updated'; before(async () => { user = await generateUser(); }); it('updates a tag given it\'s id', async () => { + let updatedTagName = 'Tag updated'; let createdTag = await user.post('/tags', {name: 'Tag 1'}); let updatedTag = await user.put(`/tags/${createdTag._id}`, { name: updatedTagName, @@ -20,9 +20,9 @@ describe('PUT /tags/:tagId', () => { createdTag = await user.get(`/tags/${updatedTag._id}`); expect(updatedTag.name).to.equal(updatedTagName); - expect(updatedTag.ignored).to.be.undefined; + expect(updatedTag.ignored).to.not.exist; expect(createdTag.name).to.equal(updatedTagName); - expect(createdTag.ignored).to.be.undefined; + expect(createdTag.ignored).to.not.exist; }); }); diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index a8c804b715..59866dab3b 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -42,12 +42,12 @@ describe('DELETE /tasks/:id', () => { it('cannot delete a task owned by someone else', async () => { let anotherUser = await generateUser(); - let task2 = await anotherUser.post('/tasks', { + let anotherUsersTask = await anotherUser.post('/tasks', { text: 'test habit', type: 'habit', }); - await expect(user.del(`/tasks/${task2._id}`)).to.eventually.be.rejected.and.eql({ + await expect(user.del(`/tasks/${anotherUsersTask._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), From a3fd838677271239687c069c1b71cd88982bc941 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 18:01:47 -0600 Subject: [PATCH 337/976] tests(api): Fix PUT /task test to be valid --- .../v3/integration/tasks/PUT-tasks_id.test.js | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 5f7132136d..bbb42fa69b 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -22,8 +22,8 @@ describe('PUT /tasks/:id', () => { it(`ignores setting _id, type, userId, history, createdAt, updatedAt, challenge, completed, streak, - dateCompleted fields`, () => { - user.put(`/tasks/${task._id}`, { + dateCompleted fields`, async () => { + let savedTask = await user.put(`/tasks/${task._id}`, { _id: 123, type: 'daily', userId: 123, @@ -34,18 +34,18 @@ describe('PUT /tasks/:id', () => { completed: true, streak: 25, dateCompleted: 'never', - }).then((savedTask) => { - expect(savedTask._id).to.equal(task._id); - expect(savedTask.type).to.equal(task.type); - expect(savedTask.userId).to.equal(user._id); - expect(savedTask.history).to.eql([]); - expect(savedTask.createdAt).not.to.equal('yesterday'); - expect(savedTask.updatedAt).not.to.equal('tomorrow'); - expect(savedTask.challenge).not.to.equal('no'); - expect(savedTask.completed).to.equal(false); - expect(savedTask.streak).to.equal(0); - expect(savedTask.streak).not.to.equal('never'); }); + + expect(savedTask._id).to.equal(task._id); + expect(savedTask.type).to.equal(task.type); + expect(savedTask.userId).to.equal(task.userId); + expect(savedTask.history).to.eql(task.history); + expect(savedTask.createdAt).to.equal(task.createdAt); + expect(savedTask.updatedAt).to.be.greaterThan(task.updatedAt); + expect(savedTask.challenge).to.equal(task.challenge); + expect(savedTask.completed).to.equal(task.completed); + expect(savedTask.streak).to.equal(task.streak); + expect(savedTask.dateCompleted).to.equal(task.dateCompleted); }); it('ignores invalid fields', async () => { From c9d378ab358f6a3b8101c556dd8de96f6b08e062 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 18:10:10 -0600 Subject: [PATCH 338/976] tests(api): bring in await format tests --- test/api/v3/integration/notFound.test.js | 4 +- .../integration/tags/DELETE-tags_id.test.js | 34 +- test/api/v3/integration/tags/GET-tags.test.js | 24 +- .../v3/integration/tags/GET-tags_id.test.js | 20 +- .../api/v3/integration/tags/POST-tags.test.js | 31 +- .../v3/integration/tags/PUT-tags_id.test.js | 37 +- .../integration/tasks/DELETE-tasks_id.test.js | 60 ++- .../v3/integration/tasks/GET-tasks.test.js | 42 +- .../v3/integration/tasks/GET-tasks_id.test.js | 54 +-- .../v3/integration/tasks/POST-tasks.test.js | 12 +- .../POST-tasks_id_score_direction.test.js | 367 ++++++++---------- .../v3/integration/tasks/PUT-tasks_id.test.js | 270 ++++++------- ...LETE-tasks_taskId_checklist_itemId.test.js | 54 ++- .../POST-tasks_taskId_checklist.test.js | 65 ++-- ...asks_taskId_checklist_itemId_score.test.js | 56 ++- .../PUT-tasks_taskId_checklist_itemId.test.js | 52 +-- .../DELETE-tasks_taskId_tags_tagId.test.js | 43 +- .../tags/POST-tasks_taskId_tags_tagId.test.js | 59 ++- test/api/v3/integration/user/GET-user.test.js | 27 +- .../user/auth/POST-register_local.test.js | 132 +++---- 20 files changed, 645 insertions(+), 798 deletions(-) diff --git a/test/api/v3/integration/notFound.test.js b/test/api/v3/integration/notFound.test.js index e86b6b35e8..6539a0037d 100644 --- a/test/api/v3/integration/notFound.test.js +++ b/test/api/v3/integration/notFound.test.js @@ -1,10 +1,10 @@ import { requester } from '../../../helpers/api-integration.helper'; describe('notFound Middleware', () => { - it('returns a 404 error when the resource is not found', () => { + it('returns a 404 error when the resource is not found', async () => { let request = requester().get('/api/v3/dummy-url'); - return expect(request).to.eventually.be.rejected.and.eql({ + await expect(request).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: 'Not found.', diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js index 82911ddda7..9d6b53c531 100644 --- a/test/api/v3/integration/tags/DELETE-tags_id.test.js +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -5,29 +5,23 @@ import { describe('DELETE /tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('deletes a tag given it\'s id', () => { - let length; - let tag; + it('deletes a tag given it\'s id', async () => { + let tagName = 'Tag 1'; + let tag = await user.post('/tags', {name: tagName}); + let numberOfTags = (await user.get('/tags')).length; - return user.post('/tags', {name: 'Tag 1'}) - .then((createdTag) => { - tag = createdTag; - return user.get(`/tags`); - }) - .then((tags) => { - length = tags.length; - return user.del(`/tags/${tag._id}`); - }) - .then(() => user.get(`/tags`)) - .then((tags) => { - expect(tags.length).to.equal(length - 1); - expect(tags[tags.length - 1].name).to.not.equal('Tag 1'); + await user.del(`/tags/${tag._id}`); + + let tags = await user.get('/tags'); + let tagNames = tags.map((t) => { + return t.name; }); + + expect(tags.length).to.equal(numberOfTags - 1); + expect(tagNames).to.not.include(tagName); }); }); diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js index 3669fc37e0..5281bd35af 100644 --- a/test/api/v3/integration/tags/GET-tags.test.js +++ b/test/api/v3/integration/tags/GET-tags.test.js @@ -5,20 +5,18 @@ import { describe('GET /tags', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('returns all user\'s tags', () => { - return user.post('/tags', {name: 'Tag 1'}) - .then(() => user.post('/tags', {name: 'Tag 2'})) - .then(() => user.get('/tags')) - .then((tags) => { - expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task - expect(tags[tags.length - 2].name).to.equal('Tag 1'); - expect(tags[tags.length - 1].name).to.equal('Tag 2'); - }); + it('returns all user\'s tags', async () => { + let tag1 = await user.post('/tags', {name: 'Tag 1'}); + let tag2 = await user.post('/tags', {name: 'Tag 2'}); + + let tags = await user.get('/tags'); + + expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task + expect(tags[tags.length - 2].name).to.equal(tag1.name); + expect(tags[tags.length - 1].name).to.equal(tag2.name); }); }); diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index fbdf96312f..adccff6504 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -5,22 +5,14 @@ import { describe('GET /tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('returns a tag given it\'s id', () => { - let createdTag; + it('returns a tag given it\'s id', async () => { + let createdTag = await user.post('/tags', {name: 'Tag 1'}); + let tag = await user.get(`/tags/${createdTag._id}`); - return user.post('/tags', {name: 'Tag 1'}) - .then((tag) => { - createdTag = tag; - return user.get(`/tags/${createdTag._id}`); - }) - .then((tag) => { - expect(tag).to.deep.equal(createdTag); - }); + expect(tag).to.deep.equal(createdTag); }); }); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js index 351d14e9fb..7b7b5c4a34 100644 --- a/test/api/v3/integration/tags/POST-tags.test.js +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -5,28 +5,21 @@ import { describe('POST /tags', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + beforeEach(async () => { + user = await generateUser(); }); - it('creates a tag correctly', () => { - let createdTag; - - return user.post('/tags', { - name: 'Tag 1', + it('creates a tag correctly', async () => { + let tagName = 'Tag 1'; + let createdTag = await user.post('/tags', { + name: tagName, ignored: false, - }).then((tag) => { - createdTag = tag; - - expect(tag.name).to.equal('Tag 1'); - expect(tag.ignored).to.be.a('undefined'); - - return user.get(`/tags/${createdTag._id}`); - }) - .then((tag) => { - expect(tag).to.deep.equal(createdTag); }); + + let tag = await user.get(`/tags/${createdTag._id}`); + + expect(tag.name).to.equal(tagName); + expect(tag.ignored).to.not.exist; + expect(tag).to.deep.equal(createdTag); }); }); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index 94a76ec39c..c2576a3f0c 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -5,29 +5,24 @@ import { describe('PUT /tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('updates a tag given it\'s id', () => { - return user.post('/tags', {name: 'Tag 1'}) - .then((createdTag) => { - return user.put(`/tags/${createdTag._id}`, { - name: 'Tag updated', - ignored: true, - }); - }) - .then((updatedTag) => { - expect(updatedTag.name).to.equal('Tag updated'); - expect(updatedTag.ignored).to.be.a('undefined'); - - return user.get(`/tags/${updatedTag._id}`); - }) - .then((tag) => { - expect(tag.name).to.equal('Tag updated'); - expect(tag.ignored).to.be.a('undefined'); + it('updates a tag given it\'s id', async () => { + let updatedTagName = 'Tag updated'; + let createdTag = await user.post('/tags', {name: 'Tag 1'}); + let updatedTag = await user.put(`/tags/${createdTag._id}`, { + name: updatedTagName, + ignored: true, }); + + createdTag = await user.get(`/tags/${updatedTag._id}`); + + expect(updatedTag.name).to.equal(updatedTagName); + expect(updatedTag.ignored).to.not.exist; + + expect(createdTag.name).to.equal(updatedTagName); + expect(createdTag.ignored).to.not.exist; }); }); diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 1d3e59ef15..59866dab3b 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -6,60 +6,52 @@ import { describe('DELETE /tasks/:id', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); context('task can be deleted', () => { let task; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((createdTask) => { - task = createdTask; }); }); - it('deletes a user\'s task', () => { - return user.del(`/tasks/${task._id}`) - .then(() => { - return expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); + it('deletes a user\'s task', async () => { + await user.del(`/tasks/${task._id}`); + + await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); }); }); context('task cannot be deleted', () => { - it('cannot delete a non-existant task', () => { - return expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ + it('cannot delete a non-existant task', async () => { + await expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('cannot delete a task owned by someone else', () => { - return generateUser() - .then((anotherUser) => { - return anotherUser.post('/tasks', { - text: 'test habit', - type: 'habit', - }); - }) - .then((task2) => { - return expect(user.del(`/tasks/${task2._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); + it('cannot delete a task owned by someone else', async () => { + let anotherUser = await generateUser(); + let anotherUsersTask = await anotherUser.post('/tasks', { + text: 'test habit', + type: 'habit', + }); + + await expect(user.del(`/tasks/${anotherUsersTask._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); }); it('cannot delete active challenge tasks'); // TODO after challenges are implemented diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 0772046f0b..4c169b3f6c 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -6,37 +6,27 @@ import Q from 'q'; describe('GET /tasks', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + beforeEach(async () => { + user = await generateUser(); }); - it('returns all user\'s tasks', () => { - let length; - return Q.all([ + it('returns all user\'s tasks', async () => { + let createdTasks = await Q.all([ user.post('/tasks', {text: 'test habit', type: 'habit'}), - ]) - .then((createdTasks) => { - length = createdTasks.length; - return user.get('/tasks'); - }) - .then((tasks) => { - expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task - }); + ]); + + let length = createdTasks.length; + let tasks = await user.get('/tasks'); + + expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task }); - it('returns only a type of user\'s tasks if req.query.type is specified', () => { - let habitId; - user.post('/tasks', {text: 'test habit', type: 'habit'}) - .then((task) => { - habitId = task._id; - return user.get('/tasks?type=habit'); - }) - .then((tasks) => { - expect(tasks.length).to.equal(1); - expect(tasks[0]._id).to.equal(habitId); - }); + it('returns only a type of user\'s tasks if req.query.type is specified', async () => { + let task = await user.post('/tasks', {text: 'test habit', type: 'habit'}); + let tasks = await user.get('/tasks?type=habit'); + + expect(tasks.length).to.equal(1); + expect(tasks[0]._id).to.equal(task._id); }); // TODO complete after task scoring is done diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index a4ae455d6a..84df18187a 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -7,29 +7,23 @@ import { v4 as generateUUID } from 'uuid'; describe('GET /tasks/:id', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - context('task can be accessed', () => { + context('task can be accessed', async () => { let task; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((createdTask) => { - task = createdTask; }); }); - it('gets specified task', () => { - return user.get(`/tasks/${task._id}`) - .then((getTask) => { - expect(getTask).to.eql(task); - }); + it('gets specified task', async () => { + let getTask = await user.get(`/tasks/${task._id}`); + expect(getTask).to.eql(task); }); // TODO after challenges are implemented @@ -37,34 +31,28 @@ describe('GET /tasks/:id', () => { }); context('task cannot be accessed', () => { - it('cannot get a non-existant task', () => { + it('cannot get a non-existant task', async () => { let dummyId = generateUUID(); - return expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ + await expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('cannot get a task owned by someone else', () => { - let anotherUser; + it('cannot get a task owned by someone else', async () => { + let anotherUser = await generateUser(); + let task = await user.post('/tasks', { + text: 'test habit', + type: 'habit', + }); - return generateUser() - .then((user2) => { - anotherUser = user2; - - return user.post('/tasks', { - text: 'test habit', - type: 'habit', - }); - }).then((task) => { - return expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); + await expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); }); }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 672c65b20c..7a8804be6c 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -7,14 +7,12 @@ describe('POST /tasks', () => { let user; before(async () => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + user = await generateUser(); }); - context('validates params', () => { + context('validates params', async () => { it('returns an error if req.body.type is absent', async () => { - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -24,7 +22,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.type is not valid', async () => { - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -34,7 +32,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.text is absent', async () => { - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { type: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 2978d93657..b01a54775c 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -7,25 +7,23 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:id/score/:direction', () => { let user; - beforeEach(() => { - return generateUser({ + beforeEach(async () => { + user = await generateUser({ 'stats.gp': 100, - }).then((generatedUser) => { - user = generatedUser; }); }); context('all', () => { - it('requires a task id', () => { - return expect(user.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ + it('requires a task id', async () => { + await expect(user.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), }); }); - it('requires a task direction', () => { - return expect(user.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ + it('requires a task direction', async () => { + await expect(user.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -36,110 +34,97 @@ describe('POST /tasks/:id/score/:direction', () => { context('todos', () => { let todo; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + todo = await user.post('/tasks', { text: 'test todo', type: 'todo', - }).then((task) => { - todo = task; }); }); - it('completes todo when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((task) => expect(task.completed).to.equal(true)); + it('completes todo when direction is up', async () => { + await user.post(`/tasks/${todo._id}/score/up`); + let task = await user.get(`/tasks/${todo._id}`); + + expect(task.completed).to.equal(true); }); - it('moves completed todos out of user.tasksOrder.todos', () => { - return user.get('/user') - .then(usr => { - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); - }).then(() => user.post(`/tasks/${todo._id}/score/up`)) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((updatedTask) => { - expect(updatedTask.completed).to.equal(true); - return user.get('/user'); - }) - .then((usr) => { - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); - }); + it('moves completed todos out of user.tasksOrder.todos', async () => { + let getUser = await user.get('/user'); + expect(getUser.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); + + await user.post(`/tasks/${todo._id}/score/up`); + let updatedTask = await user.get(`/tasks/${todo._id}`); + expect(updatedTask.completed).to.equal(true); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); }); - it('moves un-completed todos back into user.tasksOrder.todos', () => { - return user.get('/user') - .then(usr => { - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); - }).then(() => user.post(`/tasks/${todo._id}/score/up`)) - .then(() => user.post(`/tasks/${todo._id}/score/down`)) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((updatedTask) => { - expect(updatedTask.completed).to.equal(false); - return user.get('/user'); - }) - .then((usr) => { - let l = usr.tasksOrder.todos.length; - expect(usr.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom - }); + it('moves un-completed todos back into user.tasksOrder.todos', async () => { + let getUser = await user.get('/user'); + expect(getUser.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); + + await user.post(`/tasks/${todo._id}/score/up`); + await user.post(`/tasks/${todo._id}/score/down`); + + let updatedTask = await user.get(`/tasks/${todo._id}`); + expect(updatedTask.completed).to.equal(false); + + let updatedUser = await user.get('/user'); + let l = updatedUser.tasksOrder.todos.length; + expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); + expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom }); - it('uncompletes todo when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((updatedTask) => { - expect(updatedTask.completed).to.equal(false); - }); + it('uncompletes todo when direction is down', async () => { + await user.post(`/tasks/${todo._id}/score/down`); + let updatedTask = await user.get(`/tasks/${todo._id}`); + + expect(updatedTask.completed).to.equal(false); }); it('scores up todo even if it is already completed'); // Yes? it('scores down todo even if it is already uncompleted'); // Yes? - it('increases user\'s mp when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is up', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${todo._id}/score/up`); + updatedUser = await user.get(`/user`); + }); + + it('increases user\'s mp', () => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); - }); - it('decreases user\'s mp when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); - }); - - it('increases user\'s exp when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s exp', () => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); - }); - it('decreases user\'s exp when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); - }); - }); - - it('increases user\'s gold when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); - it('decreases user\'s gold when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is down', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${todo._id}/score/down`); + updatedUser = await user.get(`/user`); + }); + + it('decreases user\'s mp', () => { + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + }); + + it('decreases user\'s exp', () => { + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); + }); + + it('decreases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); @@ -148,75 +133,69 @@ describe('POST /tasks/:id/score/:direction', () => { context('dailys', () => { let daily; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + daily = await user.post('/tasks', { text: 'test daily', type: 'daily', - }).then((task) => { - daily = task; }); }); - it('completes daily when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/tasks/${daily._id}`)) - .then((task) => expect(task.completed).to.equal(true)); + it('completes daily when direction is up', async () => { + await user.post(`/tasks/${daily._id}/score/up`); + let task = await user.get(`/tasks/${daily._id}`); + + expect(task.completed).to.equal(true); }); - it('uncompletes daily when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/tasks/${daily._id}`)) - .then((task) => expect(task.completed).to.equal(false)); + it('uncompletes daily when direction is down', async () => { + await user.post(`/tasks/${daily._id}/score/down`); + let task = await user.get(`/tasks/${daily._id}`); + + expect(task.completed).to.equal(false); }); it('scores up daily even if it is already completed'); // Yes? it('scores down daily even if it is already uncompleted'); // Yes? - it('increases user\'s mp when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is up', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${daily._id}/score/up`); + updatedUser = await user.get(`/user`); + }); + + it('increases user\'s mp', () => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); - }); - it('decreases user\'s mp when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); - }); - - it('increases user\'s exp when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s exp', () => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); - }); - it('decreases user\'s exp when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); - }); - }); - - it('increases user\'s gold when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); - it('decreases user\'s gold when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is down', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${daily._id}/score/down`); + updatedUser = await user.get(`/user`); + }); + + it('decreases user\'s mp', () => { + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + }); + + it('decreases user\'s exp', () => { + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); + }); + + it('decreases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); @@ -225,34 +204,29 @@ describe('POST /tasks/:id/score/:direction', () => { context('habits', () => { let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + habit = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((task) => { - habit = task; - return user.post('/tasks', { - text: 'test min habit', - type: 'habit', - up: false, - }); - }).then((task) => { - minusHabit = task; - return user.post('/tasks', { - text: 'test plus habit', - type: 'habit', - down: false, - }); - }).then((task) => { - plusHabit = task; - user.post('/tasks', { - text: 'test neither habit', - type: 'habit', - up: false, - down: false, - }); - }).then((task) => { - neitherHabit = task; + }); + + minusHabit = await user.post('/tasks', { + text: 'test min habit', + type: 'habit', + up: false, + }); + + plusHabit = await user.post('/tasks', { + text: 'test plus habit', + type: 'habit', + down: false, + }); + + neitherHabit = await user.post('/tasks', { + text: 'test neither habit', + type: 'habit', + up: false, + down: false, }); }); @@ -260,82 +234,63 @@ describe('POST /tasks/:id/score/:direction', () => { it('prevents minus only habit from scoring up'); // Yes? - it('increases user\'s mp when direction is up', () => { - return user.post(`/tasks/${habit._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); - }); + it('increases user\'s mp when direction is up', async () => { + await user.post(`/tasks/${habit._id}/score/up`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); - it('decreases user\'s mp when direction is down', () => { - return user.post(`/tasks/${habit._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); + it('decreases user\'s mp when direction is down', async () => { + await user.post(`/tasks/${habit._id}/score/down`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); - it('increases user\'s exp when direction is up', () => { - return user.post(`/tasks/${habit._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); - }); + it('increases user\'s exp when direction is up', async () => { + await user.post(`/tasks/${habit._id}/score/up`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); - it('increases user\'s gold when direction is up', () => { - return user.post(`/tasks/${habit._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); - }); + it('increases user\'s gold when direction is up', async () => { + await user.post(`/tasks/${habit._id}/score/up`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); context('reward', () => { - let reward; + let reward, updatedUser; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + reward = await user.post('/tasks', { text: 'test reward', type: 'reward', value: 5, - }).then((task) => { - reward = task; }); + + await user.post(`/tasks/${reward._id}/score/up`); + updatedUser = await user.get(`/user`); }); it('purchases reward', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); - }); + expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); }); it('does not change user\'s mp', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.mp).to.equal(updatedUser.stats.mp); - }); + expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); it('does not change user\'s exp', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.exp).to.equal(updatedUser.stats.exp); - }); + expect(user.stats.exp).to.equal(updatedUser.stats.exp); }); it('does not allow a down direction', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.mp).to.equal(updatedUser.stats.mp); - }); + expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); }); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 3092d3bd84..bbb42fa69b 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -6,28 +6,24 @@ import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:id', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); context('validates params', () => { let task; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((createdTask) => { - task = createdTask; }); }); it(`ignores setting _id, type, userId, history, createdAt, updatedAt, challenge, completed, streak, - dateCompleted fields`, () => { - user.put(`/tasks/${task._id}`, { + dateCompleted fields`, async () => { + let savedTask = await user.put(`/tasks/${task._id}`, { _id: 123, type: 'daily', userId: 123, @@ -38,250 +34,242 @@ describe('PUT /tasks/:id', () => { completed: true, streak: 25, dateCompleted: 'never', - }).then((savedTask) => { - expect(savedTask._id).to.equal(task._id); - expect(savedTask.type).to.equal(task.type); - expect(savedTask.userId).to.equal(user._id); - expect(savedTask.history).to.eql([]); - expect(savedTask.createdAt).not.to.equal('yesterday'); - expect(savedTask.updatedAt).not.to.equal('tomorrow'); - expect(savedTask.challenge).not.to.equal('no'); - expect(savedTask.completed).to.equal(false); - expect(savedTask.streak).to.equal(0); - expect(savedTask.streak).not.to.equal('never'); }); + + expect(savedTask._id).to.equal(task._id); + expect(savedTask.type).to.equal(task.type); + expect(savedTask.userId).to.equal(task.userId); + expect(savedTask.history).to.eql(task.history); + expect(savedTask.createdAt).to.equal(task.createdAt); + expect(savedTask.updatedAt).to.be.greaterThan(task.updatedAt); + expect(savedTask.challenge).to.equal(task.challenge); + expect(savedTask.completed).to.equal(task.completed); + expect(savedTask.streak).to.equal(task.streak); + expect(savedTask.dateCompleted).to.equal(task.dateCompleted); }); - it('ignores invalid fields', () => { - user.put(`/tasks/${task._id}`, { + it('ignores invalid fields', async () => { + let savedTask = await user.put(`/tasks/${task._id}`, { notValid: true, - }).then((savedTask) => { - expect(savedTask.notValid).to.be.a('undefined'); }); + + expect(savedTask.notValid).to.be.undefined; }); }); context('habits', () => { let habit; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + habit = await user.post('/tasks', { text: 'test habit', type: 'habit', notes: 1976, - }).then((createdHabit) => { - habit = createdHabit; }); }); - it('updates a habit', () => { - return user.put(`/tasks/${habit._id}`, { + it('updates a habit', async () => { + let savedHabit = await user.put(`/tasks/${habit._id}`, { text: 'some new text', up: false, down: false, notes: 'some new notes', - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(false); }); + + expect(savedHabit.text).to.eql('some new text'); + expect(savedHabit.notes).to.eql('some new notes'); + expect(savedHabit.up).to.eql(false); + expect(savedHabit.down).to.eql(false); }); }); context('todos', () => { let todo; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + todo = await user.post('/tasks', { text: 'test todo', type: 'todo', notes: 1976, - }).then((createdTodo) => { - todo = createdTodo; }); }); - it('updates a todo', () => { - return user.put(`/tasks/${todo._id}`, { + it('updates a todo', async () => { + let savedTodo = await user.put(`/tasks/${todo._id}`, { text: 'some new text', notes: 'some new notes', - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); }); + + expect(savedTodo.text).to.eql('some new text'); + expect(savedTodo.notes).to.eql('some new notes'); }); - it('can update checklists (replace it)', () => { - return user.put(`/tasks/${todo._id}`, { + it('can update checklists (replace it)', async () => { + await user.put(`/tasks/${todo._id}`, { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, ], - }).then(() => { - return user.put(`/tasks/${todo._id}`, { - checklist: [ - {text: 789, completed: false}, - ], - }); - }).then((savedTodo2) => { - expect(savedTodo2.checklist.length).to.equal(1); - expect(savedTodo2.checklist[0].text).to.equal('789'); - expect(savedTodo2.checklist[0].completed).to.equal(false); }); + + let savedTodo = await user.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 789, completed: false}, + ], + }); + + expect(savedTodo.checklist.length).to.equal(1); + expect(savedTodo.checklist[0].text).to.equal('789'); + expect(savedTodo.checklist[0].completed).to.equal(false); }); - it('can update tags (replace them)', () => { + it('can update tags (replace them)', async () => { let finalUUID = generateUUID(); - return user.put(`/tasks/${todo._id}`, { + await user.put(`/tasks/${todo._id}`, { tags: [generateUUID(), generateUUID()], - }).then(() => { - return user.put(`/tasks/${todo._id}`, { - tags: [finalUUID], - }); - }).then((savedTodo2) => { - expect(savedTodo2.tags.length).to.equal(1); - expect(savedTodo2.tags[0]).to.equal(finalUUID); }); + + let savedTodo = await user.put(`/tasks/${todo._id}`, { + tags: [finalUUID], + }); + + expect(savedTodo.tags.length).to.equal(1); + expect(savedTodo.tags[0]).to.equal(finalUUID); }); }); context('dailys', () => { let daily; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + daily = await user.post('/tasks', { text: 'test daily', type: 'daily', notes: 1976, - }).then((createdDaily) => { - daily = createdDaily; }); }); - it('updates a daily', () => { - return user.put(`/tasks/${daily._id}`, { + it('updates a daily', async () => { + let savedDaily = await user.put(`/tasks/${daily._id}`, { text: 'some new text', notes: 'some new notes', frequency: 'daily', everyX: 5, - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); - expect(task.frequency).to.eql('daily'); - expect(task.everyX).to.eql(5); }); + + expect(savedDaily.text).to.eql('some new text'); + expect(savedDaily.notes).to.eql('some new notes'); + expect(savedDaily.frequency).to.eql('daily'); + expect(savedDaily.everyX).to.eql(5); }); - it('can update checklists (replace it)', () => { - return user.put(`/tasks/${daily._id}`, { + it('can update checklists (replace it)', async () => { + await user.put(`/tasks/${daily._id}`, { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, ], - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - checklist: [ - {text: 789, completed: false}, - ], - }); - }).then((savedDaily2) => { - expect(savedDaily2.checklist.length).to.equal(1); - expect(savedDaily2.checklist[0].text).to.equal('789'); - expect(savedDaily2.checklist[0].completed).to.equal(false); }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 789, completed: false}, + ], + }); + + expect(savedDaily.checklist.length).to.equal(1); + expect(savedDaily.checklist[0].text).to.equal('789'); + expect(savedDaily.checklist[0].completed).to.equal(false); }); - it('can update tags (replace them)', () => { + it('can update tags (replace them)', async () => { let finalUUID = generateUUID(); - return user.put(`/tasks/${daily._id}`, { + await user.put(`/tasks/${daily._id}`, { tags: [generateUUID(), generateUUID()], - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - tags: [finalUUID], - }); - }).then((savedDaily2) => { - expect(savedDaily2.tags.length).to.equal(1); - expect(savedDaily2.tags[0]).to.equal(finalUUID); }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + tags: [finalUUID], + }); + + expect(savedDaily.tags.length).to.equal(1); + expect(savedDaily.tags[0]).to.equal(finalUUID); }); - it('updates repeat, even if frequency is set to daily', () => { - return user.put(`/tasks/${daily._id}`, { + it('updates repeat, even if frequency is set to daily', async () => { + await user.put(`/tasks/${daily._id}`, { frequency: 'daily', - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - repeat: { - m: false, - su: false, - }, - }); - }).then((savedDaily2) => { - expect(savedDaily2.repeat).to.eql({ + }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + repeat: { m: false, - t: true, - w: true, - th: true, - f: true, - s: true, su: false, - }); + }, + }); + + expect(savedDaily.repeat).to.eql({ + m: false, + t: true, + w: true, + th: true, + f: true, + s: true, + su: false, }); }); - it('updates everyX, even if frequency is set to weekly', () => { - return user.put(`/tasks/${daily._id}`, { + it('updates everyX, even if frequency is set to weekly', async () => { + await user.put(`/tasks/${daily._id}`, { frequency: 'weekly', - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - everyX: 5, - }); - }).then((savedDaily2) => { - expect(savedDaily2.everyX).to.eql(5); }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + everyX: 5, + }); + + expect(savedDaily.everyX).to.eql(5); }); - it('defaults startDate to today if none date object is passed in', () => { - return user.put(`/tasks/${daily._id}`, { + it('defaults startDate to today if none date object is passed in', async () => { + let savedDaily = await user.put(`/tasks/${daily._id}`, { frequency: 'weekly', - }).then((savedDaily2) => { - expect((new Date(savedDaily2.startDate)).getDay()).to.eql((new Date()).getDay()); }); + + expect((new Date(savedDaily.startDate)).getDay()).to.eql((new Date()).getDay()); }); }); context('rewards', () => { let reward; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + reward = await user.post('/tasks', { text: 'test reward', type: 'reward', notes: 1976, value: 10, - }).then((createdReward) => { - reward = createdReward; }); }); - it('updates a reward', () => { - return user.put(`/tasks/${reward._id}`, { + it('updates a reward', async () => { + let savedReward = await user.put(`/tasks/${reward._id}`, { text: 'some new text', notes: 'some new notes', value: 10, - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); - expect(task.value).to.eql(10); }); + + expect(savedReward.text).to.eql('some new text'); + expect(savedReward.notes).to.eql('some new notes'); + expect(savedReward.value).to.eql(10); }); - it('requires value to be coerced into a number', () => { - return user.put(`/tasks/${reward._id}`, { + it('requires value to be coerced into a number', async () => { + let savedReward = await user.put(`/tasks/${reward._id}`, { value: '100', - }).then((task) => { - expect(task.value).to.eql(100); }); + + expect(savedReward.value).to.eql(100); }); }); }); diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index d013878a74..6c690c6eb9 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -7,39 +7,31 @@ import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:taskId/checklist/:itemId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('deletes a checklist item', () => { - let task; - - return user.post('/tasks', { + it('deletes a checklist item', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - }).then((savedTask) => { - return user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); - }).then(() => { - return user.get(`/tasks/${task._id}`); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(0); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + + await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + savedTask = await user.get(`/tasks/${task._id}`); + + expect(savedTask.checklist.length).to.equal(0); }); - it('does not work with habits', () => { - let habit; - return expect(user.post('/tasks', { + it('does not work with habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.del(`/tasks/${habit._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), @@ -59,21 +51,21 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); }); - it('fails on task not found', () => { - return expect(user.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + it('fails on task not found', async () => { + await expect(user.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + it('fails on checklist item not found', async () => { + let createdTask = await user.post('/tasks', { type: 'daily', text: 'daily with checklist', - }).then(createdTask => { - return user.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('checklistItemNotFound'), diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index e9b695effd..3e1dfb8494 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -7,41 +7,38 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/checklist/', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('adds a checklist item to a task', () => { - let task; - - return user.post('/tasks', { + it('adds a checklist item to a task', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', ignored: false, _id: 123}); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); - expect(savedTask.checklist[0].completed).to.equal(false); - expect(savedTask.checklist[0]._id).to.be.a('string'); - expect(savedTask.checklist[0]._id).to.not.equal('123'); - expect(savedTask.checklist[0].ignored).to.be.an('undefined'); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + ignored: false, + _id: 123, + }); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); + expect(savedTask.checklist[0].completed).to.equal(false); + expect(savedTask.checklist[0]._id).to.be.a('string'); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].ignored).to.be.an('undefined'); }); - it('does not add a checklist to habits', () => { - let habit; - - return expect(user.post('/tasks', { + it('does not add a checklist to habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.post(`/tasks/${habit._id}/checklist`, {text: 'Checklist Item 1'}); + }); + + await expect(user.post(`/tasks/${habit._id}/checklist`, { + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -49,14 +46,14 @@ describe('POST /tasks/:taskId/checklist/', () => { }); }); - it('does not add a checklist to rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('does not add a checklist to rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.post(`/tasks/${reward._id}/checklist`, {text: 'Checklist Item 1'}); + }); + + await expect(user.post(`/tasks/${reward._id}/checklist`, { + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -64,8 +61,8 @@ describe('POST /tasks/:taskId/checklist/', () => { }); }); - it('fails on task not found', () => { - return expect(user.post(`/tasks/${generateUUID()}/checklist`, { + it('fails on task not found', async () => { + await expect(user.post(`/tasks/${generateUUID()}/checklist`, { text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 667ef41446..345e1c400e 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -7,37 +7,35 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/checklist/:itemId/score', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('scores a checklist item', () => { - let task; - - return user.post('/tasks', { + it('scores a checklist item', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - }).then((savedTask) => { - return user.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].completed).to.equal(true); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + completed: false, + }); + + savedTask = await user.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].completed).to.equal(true); }); - it('fails on habits', () => { - let habit; - return expect(user.post('/tasks', { + it('fails on habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, {text: 'Checklist Item 1'}); + }); + + await expect(user.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, { + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -58,21 +56,21 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); }); - it('fails on task not found', () => { - return expect(user.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ + it('fails on task not found', async () => { + await expect(user.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + it('fails on checklist item not found', async () => { + let createdTask = await user.post('/tasks', { type: 'daily', text: 'daily with checklist', - }).then(createdTask => { - return user.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('checklistItemNotFound'), diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index 988574bbf8..0d93f6cc64 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -7,29 +7,31 @@ import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:taskId/checklist/:itemId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('updates a checklist item', () => { - let task; - - return user.post('/tasks', { + it('updates a checklist item', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - }).then((savedTask) => { - return user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, {text: 'updated', completed: true, _id: 123}); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('updated'); - expect(savedTask.checklist[0].completed).to.equal(true); - expect(savedTask.checklist[0]._id).to.not.equal('123'); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + completed: false, + }); + + savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { + text: 'updated', + completed: true, + _id: 123, + }); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('updated'); + expect(savedTask.checklist[0].completed).to.equal(true); + expect(savedTask.checklist[0]._id).to.not.equal('123'); }); it('fails on habits', async () => { @@ -58,21 +60,21 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); }); - it('fails on task not found', () => { - return expect(user.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + it('fails on task not found', async () => { + await expect(user.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + it('fails on checklist item not found', async () => { + let createdTask = await user.post('/tasks', { type: 'daily', text: 'daily with checklist', - }).then(createdTask => { - return user.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('checklistItemNotFound'), diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index c01b71fa2c..7ddeb6fe14 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -7,40 +7,33 @@ import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:taskId/tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('removes a tag from a task', () => { - let tag; - let task; - - return user.post('/tasks', { + it('removes a tag from a task', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - task = createdTask; - return user.post('/tags', {name: 'Tag 1'}); - }).then(createdTag => { - tag = createdTag; - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(() => { - return user.del(`/tasks/${task._id}/tags/${tag._id}`); - }).then(() => user.get(`/tasks/${task._id}`)) - .then(updatedTask => { - expect(updatedTask.tags.length).to.equal(0); }); + + let tag = await user.post('/tags', {name: 'Tag 1'}); + + await user.post(`/tasks/${task._id}/tags/${tag._id}`); + await user.del(`/tasks/${task._id}/tags/${tag._id}`); + + let updatedTask = await user.get(`/tasks/${task._id}`); + + expect(updatedTask.tags.length).to.equal(0); }); - it('only deletes existing tags', () => { - return expect(user.post('/tasks', { + it('only deletes existing tags', async () => { + let createdTask = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - return user.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('tagNotFound'), diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index 6e4c2ba510..8377d5a012 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -7,59 +7,46 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('adds a tag to a task', () => { - let tag; - let task; - - return user.post('/tasks', { + it('adds a tag to a task', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - task = createdTask; - return user.post('/tags', {name: 'Tag 1'}); - }).then(createdTag => { - tag = createdTag; - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(savedTask => { - expect(savedTask.tags[0]).to.equal(tag._id); }); + + let tag = await user.post('/tags', {name: 'Tag 1'}); + let savedTask = await user.post(`/tasks/${task._id}/tags/${tag._id}`); + + expect(savedTask.tags[0]).to.equal(tag._id); }); - it('does not add a tag to a task twice', () => { - let tag; - let task; - - return expect(user.post('/tasks', { + it('does not add a tag to a task twice', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - task = createdTask; - return user.post('/tags', {name: 'Tag 1'}); - }).then(createdTag => { - tag = createdTag; - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(() => { - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - })).to.eventually.be.rejected.and.eql({ + }); + + let tag = await user.post('/tags', {name: 'Tag 1'}); + + await user.post(`/tasks/${task._id}/tags/${tag._id}`); + + await expect(user.post(`/tasks/${task._id}/tags/${tag._id}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('alreadyTagged'), }); }); - it('does not add a non existing tag to a task', () => { - return expect(user.post('/tasks', { + it('does not add a non existing tag to a task', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then((task) => { - return user.post(`/tasks/${task._id}/tags/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.post(`/tasks/${task._id}/tags/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), diff --git a/test/api/v3/integration/user/GET-user.test.js b/test/api/v3/integration/user/GET-user.test.js index 099526e9e6..8fbedca8cc 100644 --- a/test/api/v3/integration/user/GET-user.test.js +++ b/test/api/v3/integration/user/GET-user.test.js @@ -5,25 +5,20 @@ import { describe('GET /user', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('returns the authenticated user', () => { - return user.get('/user') - .then(returnedUser => { - expect(returnedUser._id).to.equal(user._id); - }); + it('returns the authenticated user', async () => { + let returnedUser = await user.get('/user'); + expect(returnedUser._id).to.equal(user._id); }); - it('does not return private paths (and apiToken)', () => { - return user.get('/user') - .then(returnedUser => { - expect(returnedUser.auth.local.hashed_password).to.not.exist; - expect(returnedUser.auth.local.salt).to.not.exist; - expect(returnedUser.apiToken).to.not.exist; - }); + it('does not return private paths (and apiToken)', async () => { + let returnedUser = await user.get('/user'); + + expect(returnedUser.auth.local.hashed_password).to.not.exist; + expect(returnedUser.auth.local.salt).to.not.exist; + expect(returnedUser.apiToken).to.not.exist; }); }); diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index a0f83717ea..68ff003645 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -8,32 +8,36 @@ import { each } from 'lodash'; describe('POST /user/auth/local/register', () => { context('username and email are free', () => { - it('registers a new user', () => { - let api = requester(); + let api; + + beforeEach(async () => { + api = requester(); + }); + + it('registers a new user', async () => { let username = generateRandomUserName(); let email = `${username}@example.com`; let password = 'password'; - return api.post('/user/auth/local/register', { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user._id).to.exist; - expect(user.apiToken).to.exist; - expect(user.auth.local.username).to.eql(username); }); + + expect(user._id).to.exist; + expect(user.apiToken).to.exist; + expect(user.auth.local.username).to.eql(username); }); - it('requires password and confirmPassword to match', () => { - let api = requester(); + it('requires password and confirmPassword to match', async () => { let username = generateRandomUserName(); let email = `${username}@example.com`; let password = 'password'; let confirmPassword = 'not password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email, password, @@ -45,13 +49,12 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires a username', () => { - let api = requester(); + it('requires a username', async () => { let email = `${generateRandomUserName()}@example.com`; let password = 'password'; let confirmPassword = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { email, password, confirmPassword, @@ -62,12 +65,11 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires an email', () => { - let api = requester(); + it('requires an email', async () => { let username = generateRandomUserName(); let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, password, confirmPassword: password, @@ -78,13 +80,12 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires a valid email', () => { - let api = requester(); + it('requires a valid email', async () => { let username = generateRandomUserName(); let email = 'notanemail@sdf'; let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email, password, @@ -96,13 +97,12 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires a password', () => { - let api = requester(); + it('requires a password', async () => { let username = generateRandomUserName(); let email = `${username}@example.com`; let confirmPassword = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email, confirmPassword, @@ -115,11 +115,13 @@ describe('POST /user/auth/local/register', () => { }); context('login is already taken', () => { - let username, email; + let username, email, api; - beforeEach(() => { + beforeEach(async () => { + api = requester(); username = generateRandomUserName(); email = `${username}@example.com`; + return generateUser({ 'auth.local.username': username, 'auth.local.lowerCaseUsername': username, @@ -127,12 +129,11 @@ describe('POST /user/auth/local/register', () => { }); }); - it('rejects if username is already taken', () => { - let api = requester(); + it('rejects if username is already taken', async () => { let uniqueEmail = `${generateRandomUserName()}@exampe.com`; let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email: uniqueEmail, password, @@ -144,12 +145,11 @@ describe('POST /user/auth/local/register', () => { }); }); - it('rejects if email is already taken', () => { - let api = requester(); + it('rejects if email is already taken', async () => { let uniqueUsername = generateRandomUserName(); let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username: uniqueUsername, email, password, @@ -172,44 +172,44 @@ describe('POST /user/auth/local/register', () => { password = 'password'; }); - it('sets all site tour values to -2 (already seen)', () => { - return api.post('/user/auth/local/register', { + it('sets all site tour values to -2 (already seen)', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.flags.tour).to.not.be.empty; + }); - each(user.flags.tour, (value) => { - expect(value).to.eql(-2); - }); + expect(user.flags.tour).to.not.be.empty; + + each(user.flags.tour, (value) => { + expect(value).to.eql(-2); }); }); - it('populates user with default todos, not no other task types', () => { - return api.post('/user/auth/local/register', { + it('populates user with default todos, not no other task types', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tasksOrder.todos).to.not.be.empty; - expect(user.tasksOrder.dailys).to.be.empty; - expect(user.tasksOrder.habits).to.be.empty; - expect(user.tasksOrder.rewards).to.be.empty; }); + + expect(user.tasksOrder.todos).to.not.be.empty; + expect(user.tasksOrder.dailys).to.be.empty; + expect(user.tasksOrder.habits).to.be.empty; + expect(user.tasksOrder.rewards).to.be.empty; }); - it('populates user with default tags', () => { - return api.post('/user/auth/local/register', { + it('populates user with default tags', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tags).to.not.be.empty; }); + + expect(user.tags).to.not.be.empty; }); }); @@ -223,44 +223,44 @@ describe('POST /user/auth/local/register', () => { password = 'password'; }); - it('sets all common tutorial flags to true', () => { - return api.post('/user/auth/local/register', { + it('sets all common tutorial flags to true', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.flags.tour).to.not.be.empty; + }); - each(user.flags.tutorial.common, (value) => { - expect(value).to.eql(true); - }); + expect(user.flags.tour).to.not.be.empty; + + each(user.flags.tutorial.common, (value) => { + expect(value).to.eql(true); }); }); - it('populates user with default todos, habits, and rewards', () => { - return api.post('/user/auth/local/register', { + it('populates user with default todos, habits, and rewards', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tasksOrder.todos).to.not.be.empty; - expect(user.tasksOrder.dailys).to.be.empty; - expect(user.tasksOrder.habits).to.not.be.empty; - expect(user.tasksOrder.rewards).to.not.be.empty; }); + + expect(user.tasksOrder.todos).to.not.be.empty; + expect(user.tasksOrder.dailys).to.be.empty; + expect(user.tasksOrder.habits).to.not.be.empty; + expect(user.tasksOrder.rewards).to.not.be.empty; }); - it('populates user with default tags', () => { - return api.post('/user/auth/local/register', { + it('populates user with default tags', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tags).to.not.be.empty; }); + + expect(user.tags).to.not.be.empty; }); }); }); From 7c15472fab0d83ff10b8514e6ac6d21c895704cc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Jan 2016 18:29:02 +0100 Subject: [PATCH 339/976] wip on members controllers --- website/src/controllers/api-v3/members.js | 67 ++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 3d36a63cda..9ac5d826f3 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -101,7 +101,7 @@ function handleGetMembersInvitesForGroup (type) { } /** - * @api {get} /groups/:groupId/members Get members for a groups with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @api {get} /groups/:groupId/members Get members for a group with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 * @apiName GetMembersForGroup * @apiGroup Member @@ -120,7 +120,7 @@ api.getMembersForGroup = { }; /** - * @api {get} /groups/:groupId/invites Get invites for a groups with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @api {get} /groups/:groupId/invites Get invites for a group with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 * @apiName GetInvitesForGroup * @apiGroup Member @@ -137,4 +137,67 @@ api.getInvitesForGroup = { handler: handleGetMembersInvitesForGroup('invites'), }; +/** + * @api {get} /challenges/:challengeId/members Get members for a challenge with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @apiVersion 3.0.0 + * @apiName GetMembersForChallenge + * @apiGroup Member + * + * @apiParam {UUID} challengeId The challenge id + * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results + * + * @apiSuccess {array} members An array of members, sorted by _id + */ +api.getMembersForChallenge = { + method: 'GET', + url: '/challenges/:challengeId/members', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty(); + req.checkQuery('lastId').optional().notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let challengeId = req.params.challengeId; + let lastId = req.query.lastId; + let user = res.locals.user; + + let challenge = await Challenge.findById(challengeId).exec(); + + let query = {}; + let fields = nameFields; + + if (type === 'members') { + if (group.type === 'guild') { + query.guilds = group._id; + } else { + query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' + + if (req.query.includeAllPublicFields === 'true') { + fields = memberFields; + } + } + } else { + if (group.type === 'guild') { // eslint-disable-line no-lonely-if + query['invitations.guilds.id'] = group._id; + } else { + query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' + } + } + + if (lastId) query._id = {$gt: lastId}; + + let users = await User + .find(query) + .sortBy({_id: 1}) + .limit(30) + .select(fields) + .exec(); + + res.respond(200, users); + } +}; + + export default api; From 6088b6da42ef56fc6589a483b7b03c18fe01bd3d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Jan 2016 18:35:29 +0100 Subject: [PATCH 340/976] fix edge case where res.respond would not work, misc comments --- website/src/controllers/api-v3/challenges.js | 1 + website/src/controllers/api-v3/groups.js | 4 ++-- website/src/middlewares/api-v3/errorHandler.js | 4 +++- website/src/models/group.js | 1 - 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 252ffe6c7e..3b25cd359e 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -146,6 +146,7 @@ api.getChallenge = { let challenge = await Challenge.findOne({_id: challengeId}).exec(); // TODO populate // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error + // TODO support challenges in groups I'm a member of if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens throw new NotFound(res.t('challengeNotFound')); } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 70da1221c0..85ba270935 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -100,13 +100,13 @@ api.getGroups = { type: 'guild', privacy: 'private', _id: {$in: user.guilds}, - }).select(groupFields).sort(sort).exec()); // TODO isMember + }).select(groupFields).sort(sort).exec()); break; case 'publicGuilds': queries.push(Group.find({ type: 'guild', privacy: 'public', - }).select(groupFields).sort(sort).exec()); // TODO use lean? isMember + }).select(groupFields).sort(sort).exec()); // TODO use lean? break; case 'tavern': queries.push(Group.getGroup(user, 'habitrpg', groupFields)); diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index f3b4f65cdb..0590ad9f55 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -74,5 +74,7 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l if (responseErr.errors) jsonRes.errors = responseErr.errors; - return res.respond(responseErr.httpCode, jsonRes); + // In some occasions like when invalid JSON is supplied `res.respond` might be not yet avalaible, + // in this case we use the standard res.status(...).json(...) + return res.respond ? res.respond(responseErr.httpCode, jsonRes) : res.status(responseErr.httpCode).json(jsonRes); } diff --git a/website/src/models/group.js b/website/src/models/group.js index 27ca80e73f..91ae59e714 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -146,7 +146,6 @@ schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMemb // TODO purge chat flags info? in tojson? }; -// TODO move to its own model export function chatDefaults (msg, user) { let message = { id: shared.uuid(), From 771bc0c5b5094e35800409569d62ec4d1fc74e4f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Jan 2016 18:41:09 +0100 Subject: [PATCH 341/976] put back acceptablePUTPaths variable deleted in merge --- website/src/controllers/api-v2/user.js | 1 + 1 file changed, 1 insertion(+) diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index de7d17a262..05eb88bc7a 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -11,6 +11,7 @@ var Group = require('./../../models/group').model; var Challenge = require('./../../models/challenge').model; var moment = require('moment'); var logging = require('./../../libs/api-v2/logging'); +var acceptablePUTPaths; let restrictedPUTSubPaths; var api = module.exports; From b68861681c8c5c03725130be4e6d5a337a0ff28a Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 13 Jan 2016 12:23:24 -0600 Subject: [PATCH 342/976] tests(integration): Create separate helper for api integration tests --- test/api/v3/integration/notFound.test.js | 2 +- .../integration/tags/DELETE-tags_id.test.js | 2 +- test/api/v3/integration/tags/GET-tags.test.js | 2 +- .../v3/integration/tags/GET-tags_id.test.js | 2 +- .../api/v3/integration/tags/POST-tags.test.js | 2 +- .../v3/integration/tags/PUT-tags_id.test.js | 2 +- .../integration/tasks/DELETE-tasks_id.test.js | 2 +- .../v3/integration/tasks/GET-tasks.test.js | 2 +- .../v3/integration/tasks/GET-tasks_id.test.js | 2 +- .../v3/integration/tasks/POST-tasks.test.js | 2 +- .../POST-tasks_id_score_direction.test.js | 2 +- .../v3/integration/tasks/PUT-tasks_id.test.js | 2 +- ...LETE-tasks_taskId_checklist_itemId.test.js | 2 +- .../POST-tasks_taskId_checklist.test.js | 2 +- ...asks_taskId_checklist_itemId_score.test.js | 2 +- .../PUT-tasks_taskId_checklist_itemId.test.js | 2 +- .../DELETE-tasks_taskId_tags_tagId.test.js | 2 +- .../tags/POST-tasks_taskId_tags_tagId.test.js | 2 +- test/api/v3/integration/user/GET-user.test.js | 2 +- .../user/auth/POST-register_local.test.js | 2 +- test/helpers/api-v3-integration.helper.js | 300 ++++++++++++++++++ 21 files changed, 320 insertions(+), 20 deletions(-) create mode 100644 test/helpers/api-v3-integration.helper.js diff --git a/test/api/v3/integration/notFound.test.js b/test/api/v3/integration/notFound.test.js index 6539a0037d..959e0d8b28 100644 --- a/test/api/v3/integration/notFound.test.js +++ b/test/api/v3/integration/notFound.test.js @@ -1,4 +1,4 @@ -import { requester } from '../../../helpers/api-integration.helper'; +import { requester } from '../../../helpers/api-v3-integration.helper'; describe('notFound Middleware', () => { it('returns a 404 error when the resource is not found', async () => { diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js index 9d6b53c531..3aefe11d48 100644 --- a/test/api/v3/integration/tags/DELETE-tags_id.test.js +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -1,6 +1,6 @@ import { generateUser, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('DELETE /tags/:tagId', () => { let user; diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js index 5281bd35af..6c27da12c3 100644 --- a/test/api/v3/integration/tags/GET-tags.test.js +++ b/test/api/v3/integration/tags/GET-tags.test.js @@ -1,6 +1,6 @@ import { generateUser, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('GET /tags', () => { let user; diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index adccff6504..1189c2af24 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -1,6 +1,6 @@ import { generateUser, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('GET /tags/:tagId', () => { let user; diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js index 7b7b5c4a34..85947e5add 100644 --- a/test/api/v3/integration/tags/POST-tags.test.js +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -1,6 +1,6 @@ import { generateUser, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('POST /tags', () => { let user; diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index c2576a3f0c..4653f284d6 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -1,6 +1,6 @@ import { generateUser, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('PUT /tags/:tagId', () => { let user; diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 59866dab3b..671a278d1d 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('DELETE /tasks/:id', () => { let user; diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 4c169b3f6c..8d7bd154d0 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -1,6 +1,6 @@ import { generateUser, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import Q from 'q'; describe('GET /tasks', () => { diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index 84df18187a..4b0d74543d 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('GET /tasks/:id', () => { diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 7a8804be6c..5966aee05a 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('POST /tasks', () => { let user; diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index b01a54775c..cdfb94947e 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:id/score/:direction', () => { diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index bbb42fa69b..ff42c9c3de 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -1,6 +1,6 @@ import { generateUser, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:id', () => { diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index 6c690c6eb9..094e3ae21f 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../../helpers/api-integration.helper'; +} from '../../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:taskId/checklist/:itemId', () => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index 3e1dfb8494..45a6e94150 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../../helpers/api-integration.helper'; +} from '../../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/checklist/', () => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 345e1c400e..84fbbe7562 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../../helpers/api-integration.helper'; +} from '../../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/checklist/:itemId/score', () => { diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index 0d93f6cc64..cbc4ff5b21 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../../helpers/api-integration.helper'; +} from '../../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:taskId/checklist/:itemId', () => { diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index 7ddeb6fe14..e2b5c8b015 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../../helpers/api-integration.helper'; +} from '../../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:taskId/tags/:tagId', () => { diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index 8377d5a012..1466be73f1 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../../helpers/api-integration.helper'; +} from '../../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/tags/:tagId', () => { diff --git a/test/api/v3/integration/user/GET-user.test.js b/test/api/v3/integration/user/GET-user.test.js index 8fbedca8cc..692c4c2ab9 100644 --- a/test/api/v3/integration/user/GET-user.test.js +++ b/test/api/v3/integration/user/GET-user.test.js @@ -1,6 +1,6 @@ import { generateUser, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('GET /user', () => { let user; diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 68ff003645..5255a31780 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -2,7 +2,7 @@ import { generateUser, requester, translate as t, -} from '../../../../../helpers/api-integration.helper'; +} from '../../../../../helpers/api-v3-integration.helper'; import { v4 as generateRandomUserName } from 'uuid'; import { each } from 'lodash'; diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js new file mode 100644 index 0000000000..dba72ca99d --- /dev/null +++ b/test/helpers/api-v3-integration.helper.js @@ -0,0 +1,300 @@ +/* eslint-disable no-use-before-define */ + +import { + assign, + each, + isEmpty, + set, + times, +} from 'lodash'; +import { MongoClient as mongo } from 'mongodb'; +import { v4 as generateUUID } from 'uuid'; +import superagent from 'superagent'; +import i18n from '../../common/script/src/i18n'; +i18n.translations = require('../../website/src/libs/api-v3/i18n').translations; + +const API_TEST_SERVER_PORT = 3003; + +class ApiUser { + constructor (options) { + assign(this, options); + + this.get = _requestMaker(this, 'get'); + this.post = _requestMaker(this, 'post'); + this.put = _requestMaker(this, 'put'); + this.del = _requestMaker(this, 'del'); + } + + update (options) { + return new Promise((resolve) => { + _updateDocument('users', this, options, resolve); + }); + } +} + +// Sets up an abject that can make all REST requests +// If a user is passed in, the uuid and api token of +// the user are used to make the requests +export function requester (user = {}, additionalSets) { + return { + get: _requestMaker(user, 'get', additionalSets), + post: _requestMaker(user, 'post', additionalSets), + put: _requestMaker(user, 'put', additionalSets), + del: _requestMaker(user, 'del', additionalSets), + }; +} + +// Use this to verify error messages returned by the server +// That way, if the translated string changes, the test +// will not break. NOTE: it checks agains errors with string as well. +export function translate (key, variables) { + const STRING_ERROR_MSG = 'Error processing the string. Please see Help > Report a Bug.'; + const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/; + + let translatedString = i18n.t(key, variables); + + expect(translatedString).to.not.be.empty; + expect(translatedString).to.not.eql(STRING_ERROR_MSG); + expect(translatedString).to.not.match(STRING_DOES_NOT_EXIST_MSG); + + return translatedString; +} + +// Useful for checking things that have been deleted, +// but you no longer have access to, +// like private parties or users +export function checkExistence (collectionName, id) { + return new Promise((resolve, reject) => { + mongo.connect('mongodb://localhost/habitrpg_test', (connectionError, db) => { + if (connectionError) return reject(connectionError); + let collection = db.collection(collectionName); + + collection.find({_id: id}, {_id: 1}).limit(1).toArray((findError, docs) => { + if (findError) return reject(findError); + + let exists = docs.length > 0; + + db.close(); + resolve(exists); + }); + }); + }); +} + +// Creates a new user and returns it +// If you need the user to have specific requirements, +// such as a balance > 0, just pass in the adjustment +// to the update object. If you want to adjust a nested +// paramter, such as the number of wolf eggs the user has, +// , you can do so by passing in the full path as a string: +// { 'items.eggs.Wolf': 10 } +export function generateUser (update = {}) { + let username = generateUUID(); + let password = 'password'; + let email = `${username}@example.com`; + + let request = _requestMaker({}, 'post'); + + return new Promise((resolve, reject) => { + request('/user/auth/local/register', { + username, + email, + password, + confirmPassword: password, + }).then((user) => { + _updateDocument('users', user, update, () => { + let apiUser = new ApiUser(user); + + resolve(apiUser); + }); + }).catch(reject); + }); +} + +// Generates a new group. Requires a user object, which +// will will become the groups leader. Takes an update +// argument which will update group +export function generateGroup (leader, update = {}) { + let request = _requestMaker(leader, 'post'); + + return new Promise((resolve, reject) => { + request('/groups').then((group) => { + _updateDocument('groups', group, update, () => { + resolve(group); + }).catch(reject); + }); + }); +} + +// This is generate group + the ability to create +// real users to populate it. The settings object +// takes in: +// members: Number - the number of group members to create. Defaults to 0. +// inivtes: Number - the number of users to create and invite to the group. Defaults to 0. +// groupDetails: Object - how to initialize the group +// leaderDetails: Object - defaults for the leader, defaults with a gem balance so the user +// can create the group +// +// Returns an object with +// members: an array of user objects that correspond to the members of the group +// invitees: an array of user objects that correspond to the invitees of the group +// leader: the leader user object +// group: the group object +export function createAndPopulateGroup (settings = {}) { + let request; + let leader; + let members; + let invitees; + let group; + + let numberOfMembers = settings.members || 0; + let numberOfInvites = settings.invites || 0; + let groupDetails = settings.groupDetails; + let leaderDetails = settings.leaderDetails || { balance: 10 }; + + let leaderPromise = generateUser(leaderDetails); + + let memberPromises = Promise.all( + times(numberOfMembers, () => { + return generateUser(); + }) + ); + + let invitePromises = Promise.all( + times(numberOfInvites, () => { + return generateUser(); + }) + ); + + return new Promise((resolve, reject) => { + return leaderPromise.then((user) => { + leader = user; + request = _requestMaker(leader, 'post'); + return memberPromises; + }).then((users) => { + members = users; + groupDetails.members = groupDetails.members || [leader._id]; + + each(members, (member) => { + groupDetails.members.push(member._id); + }); + + return generateGroup(leader, groupDetails); + }).then((createdGroup) => { + group = createdGroup; + return invitePromises; + }).then((users) => { + invitees = users; + + let invitationPromises = []; + + each(invitees, (invitee) => { + let invitePromise = request(`/groups/${group._id}/invite`, { + uuids: [invitee._id], + }); + + invitationPromises.push(invitePromise); + }); + + return Promise.all(invitationPromises); + }).then(() => { + resolve({ + leader, + group, + members, + invitees, + }); + }).catch(reject); + }); +} + +// Specifically helpful for the GET /groups tests, +// resets the db to an empty state and creates a tavern document +export function resetHabiticaDB () { + return new Promise((resolve, reject) => { + mongo.connect('mongodb://localhost/habitrpg_test', (err, db) => { + if (err) return reject(err); + + db.dropDatabase((dbErr) => { + if (dbErr) return reject(dbErr); + let groups = db.collection('groups'); + + groups.insertOne({ + _id: 'habitrpg', + chat: [], + leader: '9', + name: 'HabitRPG', + type: 'guild', + privacy: 'public', + members: [], + }, (insertErr) => { + if (insertErr) return reject(insertErr); + + db.close(); + resolve(); + }); + }); + }); + }); +} + +function _requestMaker (user, method, additionalSets) { + return (route, send, query) => { + return new Promise((resolve, reject) => { + let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/v3${route}`) + .accept('application/json'); + + if (user && user._id && user.apiToken) { + request + .set('x-api-user', user._id) + .set('x-api-key', user.apiToken); + } + + if (additionalSets) { + request.set(additionalSets); + } + + request + .query(query) + .send(send) + .end((err, response) => { + if (err) { + if (!err.response) return reject(err); + + return reject({ + code: err.status, + error: err.response.body.error, + message: err.response.body.message, + }); + } + + resolve(response.body); + }); + }); + }; +} + +function _updateDocument (collectionName, doc, update, cb) { + if (isEmpty(update)) { + return cb(); + } + + mongo.connect('mongodb://localhost/habitrpg_test', (connectErr, db) => { + if (connectErr) throw new Error(`Error connecting to database when updating ${collectionName} collection: ${connectErr}`); + + let collection = db.collection(collectionName); + + collection.updateOne({ _id: doc._id }, { $set: update }, (updateErr) => { + if (updateErr) throw new Error(`Error updating ${collectionName}: ${updateErr}`); + _updateLocalDocument(doc, update); + db.close(); + cb(); + }); + }); +} + +function _updateLocalDocument (doc, update) { + each(update, (value, param) => { + set(doc, param, value); + }); +} From 1f4e58e5cc44abbd3c866a4faefd8414f5a417f9 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 13 Jan 2016 12:39:23 -0600 Subject: [PATCH 343/976] tests(api): Port integration helper to v3 version for groups tests --- test/api/v3/integration/chat/GET-chat.test.js | 2 +- .../integration/chat/POST-chat.flag.test.js | 2 +- .../integration/chat/POST-chat.like.test.js | 2 +- .../api/v3/integration/chat/POST-chat.test.js | 2 +- .../v3/integration/groups/POST-groups.test.js | 2 +- .../groups/POST-groups_groupId_join.js | 2 +- .../groups/POST-groups_invite.test.js | 2 +- .../v3/integration/groups/PUT-groups.test.js | 2 +- test/helpers/api-v3-integration.helper.js | 40 ++++++++++++++----- 9 files changed, 37 insertions(+), 19 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index cb1e0996e6..dbd631e362 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -2,7 +2,7 @@ import { generateUser, generateGroup, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('GET /groups/:groupId/chat', () => { let user; diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index b71d462e5a..47ba58b046 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index 05d40f6e9f..cf266431ba 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { find } from 'lodash'; describe('POST /chat/:chatId/like', () => { diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index 37403ed12d..fe943bfb1c 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('POST /chat', () => { let user; diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 3515f07cec..8a64ef4bb5 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('POST /group', () => { let user; diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.js b/test/api/v3/integration/groups/POST-groups_groupId_join.js index 2c36d12f19..ca7c8317b5 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /group/:groupId/join', () => { diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index 2466bdf649..42719c0fcd 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; const INVITES_LIMIT = 100; diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 6700374697..5c23668ddf 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('PUT /group', () => { let groupLeader; diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js index dba72ca99d..30898513e2 100644 --- a/test/helpers/api-v3-integration.helper.js +++ b/test/helpers/api-v3-integration.helper.js @@ -14,6 +14,15 @@ import i18n from '../../common/script/src/i18n'; i18n.translations = require('../../website/src/libs/api-v3/i18n').translations; const API_TEST_SERVER_PORT = 3003; +const API_V = process.env.API_VERSION || 'v2'; // eslint-disable-line no-process-env +const ROUTES = { + v2: { + register: '/register', + }, + v3: { + register: '/user/auth/local/register', + }, +}; class ApiUser { constructor (options) { @@ -96,7 +105,7 @@ export function generateUser (update = {}) { let request = _requestMaker({}, 'post'); return new Promise((resolve, reject) => { - request('/user/auth/local/register', { + request(ROUTES[API_V].register, { username, email, password, @@ -114,15 +123,15 @@ export function generateUser (update = {}) { // Generates a new group. Requires a user object, which // will will become the groups leader. Takes an update // argument which will update group -export function generateGroup (leader, update = {}) { +export function generateGroup (leader, details = {}, update = {}) { let request = _requestMaker(leader, 'post'); return new Promise((resolve, reject) => { - request('/groups').then((group) => { + request('/groups', details).then((group) => { _updateDocument('groups', group, update, () => { resolve(group); - }).catch(reject); - }); + }); + }).catch(reject); }); } @@ -241,7 +250,7 @@ export function resetHabiticaDB () { function _requestMaker (user, method, additionalSets) { return (route, send, query) => { return new Promise((resolve, reject) => { - let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/v3${route}`) + let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/${API_V}${route}`) .accept('application/json'); if (user && user._id && user.apiToken) { @@ -261,11 +270,20 @@ function _requestMaker (user, method, additionalSets) { if (err) { if (!err.response) return reject(err); - return reject({ - code: err.status, - error: err.response.body.error, - message: err.response.body.message, - }); + if (API_V === 'v3') { + return reject({ + code: err.status, + error: err.response.body.error, + message: err.response.body.message, + }); + } else if (API_V === 'v2') { + return reject({ + code: err.status, + text: err.response.body.err, + }); + } + + return reject(err); } resolve(response.body); From 9a908785c130c1c32a963b9055176955691f3ca5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Jan 2016 23:07:08 +0100 Subject: [PATCH 344/976] new history preening, save tasks after cron --- common/script/api-v3/cron.js | 6 +- common/script/api-v3/preenHistory.js | 81 ---------------- common/script/api-v3/preening.js | 82 ++++++++++++++++ test/common/algos.mocha.js | 118 ------------------------ test/common/preening.test.js | 70 ++++++++++++++ website/src/controllers/api-v3/tasks.js | 27 +++++- website/src/middlewares/api-v3/cron.js | 15 ++- 7 files changed, 189 insertions(+), 210 deletions(-) delete mode 100644 common/script/api-v3/preenHistory.js create mode 100644 common/script/api-v3/preening.js create mode 100644 test/common/preening.test.js diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js index 604b6254f4..6f8626c7d8 100644 --- a/common/script/api-v3/cron.js +++ b/common/script/api-v3/cron.js @@ -1,7 +1,7 @@ import moment from 'moment'; import _ from 'lodash'; import scoreTask from './scoreTask'; -import preenUserHistory from './preenHistory'; +import { preenUserHistory } from './preening'; import common from '../../'; import { shouldDo, @@ -65,7 +65,7 @@ export default function cron (options = {}) { gemCapExtra: 0, }); - user.markModified('purchased.plan'); // TODO necessary? + user.markModified('purchased.plan'); } } @@ -197,7 +197,7 @@ export default function cron (options = {}) { // preen user history so that it doesn't become a performance problem // also for subscribed users but differentyly // premium subscribers can keep their full history. - preenUserHistory(user, tasksByType); + preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); if (perfect) { user.achievements.perfect++; diff --git a/common/script/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js deleted file mode 100644 index 64ae4e4715..0000000000 --- a/common/script/api-v3/preenHistory.js +++ /dev/null @@ -1,81 +0,0 @@ -import moment from 'moment'; -import _ from 'lodash'; - -function _preen (newHistory, history, amount, groupBy) { - _.chain(history) - .groupBy(h => moment(h.date).format(groupBy)) - .sortBy((h, k) => k) - .slice(-amount) - .pop() - .each((group) => { - newHistory.push({ - date: moment(group[0].date).toDate(), - value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length, - }); - }) - .value(); -} - -// Free users: -// Preen history for users with > 7 history entries -// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array -// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week -// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] -// -// Subscribers: -// TODO implement - -// TODO Probably the description ^ is not too correct, this method actually takes 1 value each for the last 50 years, -// then the X last months, where X is the month we're in (september = 8 starting from 0) -// and all the days in this month -// Allowing for multiple values in a single day for habits we probably want something different: -// For free users: -// - At max 30 values for today (max 30) -// - 1 value each for the previous 61 days (2 months) -// - 1 value each for the previous 10 months (max 10) -// - 1 value each for the previous 50 years -// - Total: 30+61+10+ a few years ~= 105 -// -// For subscribed users -// - At max 30 values for today (max 30) -// - 1 value each for the previous 364 days (max 364) -// - 1 value each for the previous 12 months (max 12) -// - 1 value each for the previous 50 years -// - Total: 30+364+12+ a few years ~= 410 -// -export function preenHistory (history) { - // TODO remember to add this to migration - /* history = _.filter(history, function(h) { - return !!h; - }); */ - let newHistory = []; - - _preen(newHistory, history, 50, 'YYYY'); - _preen(newHistory, history, moment().format('MM'), 'YYYYMM'); - - let thisMonth = moment().format('YYYYMM'); - newHistory = newHistory.concat(history.filter(h => { - return moment(h.date).format('YYYYMM') === thisMonth; - })); - - return newHistory; -} - -export function preenUserHistory (user, tasksByType, minHistLen = 7) { - tasksByType.habits.concat(tasksByType.dailys).forEach((task) => { - if (task.history.length > minHistLen) { - task.history = preenHistory(user, task.history); - task.markModified('history'); - } - }); - - if (user.history.exp.length > minHistLen) { - user.history.exp = preenHistory(user, user.history.exp); - user.markModified('history.exp'); - } - - if (user.history.todos.length > minHistLen) { - user.history.todos = preenHistory(user, user.history.todos); - user.markModified('history.todos'); - } -} diff --git a/common/script/api-v3/preening.js b/common/script/api-v3/preening.js new file mode 100644 index 0000000000..ee6a201b3a --- /dev/null +++ b/common/script/api-v3/preening.js @@ -0,0 +1,82 @@ +import _ from 'lodash'; +import moment from 'moment'; + +// Aggregate entries +function _aggregate (history, aggregateBy) { + return _.chain(history) + .groupBy(entry => { // group entries by aggregateBy + return moment(entry.date).format(aggregateBy); + }) + .sortBy((entry, key) => key) // sort by date + .map(entries => { + return { + date: Number(entries[0].date), + value: _.reduce(entries, (previousValue, entry) => { + return previousValue + entry.value; + }, 0) / entries.length, + }; + }) + .value(); +} + +/* Preen an array of history entries +Free users: +- 1 value for each day of the past 60 days (no compression) +- 1 value each month for the previous 10 months +- 1 value each year for the previous years +Subscribers and challenges: +- 1 value for each day of the past 365 days (no compression) +- 1 value each month for the previous 12 months +- 1 value each year for the previous years + */ +export function preenHistory (history, isSubscribed, timezoneOffset) { + // history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries TODO add to migration + let now = timezoneOffset ? moment().zone(timezoneOffset) : moment(); + // Date after which to begin compressing data + let cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day'); + + // Keep uncompressed entries (modifies history) + let newHistory = _.remove(history, entry => { + let date = moment(entry.date); + return date.isSame(cutOff) || date.isAfter(cutOff); + }); + + // Date after which to begin compressing data by year + let monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day'); + let aggregateByMonth = _.remove(history, entry => { + let date = moment(entry.date); + return date.isSame(monthsCutOff) || date.isAfter(monthsCutOff); + }); + // Aggregate remaining entries by month and year + if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM')); + if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY')); + + return newHistory; +} + +// Preen history for users and tasks. This code runs only on the server. +export function preenUserHistory (user, tasksByType) { + let isSubscribed = user.isSubscribed(); + let timezoneOffset = user.preferences.timezoneOffset; + let minHistoryLength = isSubscribed ? 365 : 60; + + function _processTask (task) { + if (task.history && task.history.length > minHistoryLength) { + task.history = preenHistory(task.history, isSubscribed, timezoneOffset); + task.markModified('history'); + } + } + + tasksByType.habits.forEach(_processTask); + tasksByType.dailys.forEach(_processTask); + + if (user.history.exp.length > minHistoryLength) { + user.history.exp = preenHistory(user.history.exp, isSubscribed, timezoneOffset); + user.markModified('history.exp'); + } + + if (user.history.todos.length > minHistoryLength) { + user.history.todos = preenHistory(user.history.todos, isSubscribed, timezoneOffset); + user.markModified('history.todos'); + } +} diff --git a/test/common/algos.mocha.js b/test/common/algos.mocha.js index 8c18e3c92c..0528d62933 100644 --- a/test/common/algos.mocha.js +++ b/test/common/algos.mocha.js @@ -938,124 +938,6 @@ describe('Cron', () => { expect(beforeTasks).to.eql(afterTasks); }); - describe('preening', () => { - beforeEach(function () { - this.clock = sinon.useFakeTimers(Date.parse('2013-11-20'), 'Date'); - }); - afterEach(function () { - return this.clock.restore(); - }); - - it('should preen user history', function () { - let ref = beforeAfter({ - daysAgo: 1, - }); - let after = ref.after; - - let history = [ - { - date: '09/01/2012', - value: 0, - }, { - date: '10/01/2012', - value: 0, - }, { - date: '11/01/2012', - value: 2, - }, { - date: '12/01/2012', - value: 2, - }, { - date: '01/01/2013', - value: 1, - }, { - date: '01/15/2013', - value: 3, - }, { - date: '02/01/2013', - value: 2, - }, { - date: '02/15/2013', - value: 4, - }, { - date: '03/01/2013', - value: 3, - }, { - date: '03/15/2013', - value: 5, - }, { - date: '04/01/2013', - value: 4, - }, { - date: '04/15/2013', - value: 6, - }, { - date: '05/01/2013', - value: 5, - }, { - date: '05/15/2013', - value: 7, - }, { - date: '06/01/2013', - value: 6, - }, { - date: '06/15/2013', - value: 8, - }, { - date: '07/01/2013', - value: 7, - }, { - date: '07/15/2013', - value: 9, - }, { - date: '08/01/2013', - value: 8, - }, { - date: '08/15/2013', - value: 10, - }, { - date: '09/01/2013', - value: 9, - }, { - date: '09/15/2013', - value: 11, - }, { - date: '010/01/2013', - value: 10, - }, { - date: '010/15/2013', - value: 12, - }, { - date: '011/01/2013', - value: 12, - }, { - date: '011/02/2013', - value: 13, - }, { - date: '011/03/2013', - value: 14, - }, { - date: '011/04/2013', - value: 15, - }, - ]; - - after.history = { - exp: _.cloneDeep(history), - todos: _.cloneDeep(history), - }; - after.habits[0].history = _.cloneDeep(history); - after.fns.cron(); - after.history.exp.pop(); - after.history.todos.pop(); - _.each([after.history.exp, after.history.todos, after.habits[0].history], function (arr) { - expect(_.map(arr, (x) => { - return x.value; - })).to.eql([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); - }); - }); - }); - describe('Todos', () => { it('1 day missed', () => { let ref = beforeAfter({ diff --git a/test/common/preening.test.js b/test/common/preening.test.js new file mode 100644 index 0000000000..f19a3c26b5 --- /dev/null +++ b/test/common/preening.test.js @@ -0,0 +1,70 @@ +import { preenHistory } from '../../common/script/preening'; +import moment from 'moment'; +import sinon from 'sinon'; // eslint-disable-line no-shadow + +function generateHistory (days) { + let history = []; + let now = Number(moment().toDate()); + + while (days > 0) { + history.push({ + value: days, + date: Number(moment(now).subtract(days, 'days').toDate()), + }); + days--; + } + + return history; +} + +describe('preenHistory', () => { + let clock; + + beforeEach(() => { + // Replace system clocks so we can get predictable results + clock = sinon.useFakeTimers(Number(moment('2013-10-20').zone(0).startOf('day').toDate()), 'Date'); + }); + afterEach(() => { + return clock.restore(); + }); + + it('does not modify history if all entries are more recent than cutoff (free users)', () => { + let h = generateHistory(60); + expect(preenHistory(_.cloneDeep(h), false, 0)).to.eql(h); + }); + + it('does not modify history if all entries are more recent than cutoff (subscribers)', () => { + let h = generateHistory(365); + expect(preenHistory(_.cloneDeep(h), true, 0)).to.eql(h); + }); + + it('does aggregate data in monthly entries before cutoff (free users)', () => { + let h = generateHistory(81); // Jumps to July + let preened = preenHistory(_.cloneDeep(h), false, 0); + expect(preened.length).to.eql(62); // Keeps 60 days + 2 entries per august and july + }); + + it('does aggregate data in monthly entries before cutoff (subscribers)', () => { + let h = generateHistory(396); // Jumps to September 2012 + let preened = preenHistory(_.cloneDeep(h), true, 0); + expect(preened.length).to.eql(367); // Keeps 365 days + 2 entries per october and september + }); + + it('does aggregate data in monthly and yearly entries before cutoff (free users)', () => { + let h = generateHistory(731); // Jumps to October 21 2012 + let preened = preenHistory(_.cloneDeep(h), false, 0); + expect(preened.length).to.eql(73); // Keeps 60 days + 11 montly entries and 2 yearly entry for 2011 and 2012 + }); + + it('does aggregate data in monthly and yearly entries before cutoff (subscribers)', () => { + let h = generateHistory(1031); // Jumps to October 21 2012 + let preened = preenHistory(_.cloneDeep(h), true, 0); + expect(preened.length).to.eql(380); // Keeps 365 days + 13 montly entries and 2 yearly entries for 2011 and 2010 + }); + + it('correctly aggregates values', () => { + let h = generateHistory(63); // Compress last 3 days + let preened = preenHistory(_.cloneDeep(h), false, 0); + expect(preened[0].value).to.eql((61 + 62 + 63) / 3); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 84ccf0a221..316a4054aa 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -10,8 +10,9 @@ import { import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; +import moment from 'moment'; import scoreTask from '../../../../common/script/api-v3/scoreTask'; -import { preenHistory } from '../../../../common/script/api-v3/preenHistory'; +import { preenHistory } from '../../../../common/script/api-v3/preening'; let api = {}; @@ -313,11 +314,27 @@ api.scoreTask = { }).exec(); chalTask.value += delta; + if (chalTask.type === 'habit' || chalTask.type === 'daily') { - chalTask.history.push({value: chalTask.value, date: Number(new Date())}); - // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? - chalTask.history = preenHistory(user, chalTask.history); - chalTask.markModified('history'); + // Add only one history entry per day + if (moment(chalTask.history[chalTask.history.length - 1].date).isSame(new Date(), 'day')) { + chalTask.history[chalTask.history.length - 1] = { + date: Number(new Date()), + value: chalTask.value, + }; + chalTask.markModified(`history.${chalTask.history.length - 1}`); + } else { + chalTask.history.push({ + date: Number(new Date()), + value: chalTask.value, + }); + + // Only preen task history once a day when the task is scored first + if (chalTask.history.length > 365) { + chalTask.history = preenHistory(chalTask.history, true); // true means the challenge will retain as much entries as a subscribed user + chalTask.markModified(`history.${chalTask.history.length - 1}`); + } + } } await chalTask.save(); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 0d6d46b900..9457926233 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -6,9 +6,10 @@ import { import cron from '../../../../common/script/api-v3/cron'; import common from '../../../../common'; import Task from '../../models/task'; +import Q from 'q'; // import Group from '../../models/group'; -// TODO check that it's usef everywhere +// TODO check that it's used everywhere export default function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; @@ -26,7 +27,7 @@ export default function cronMiddleware (req, res, next) { {type: {$in: ['habit', 'daily', 'reward']}}, ], }).exec() - .then((tasks) => { + .then(tasks => { let tasksByType = {habits: [], dailys: [], todos: [], rewards: []}; tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); @@ -49,7 +50,15 @@ export default function cronMiddleware (req, res, next) { // if (ranCron) res.locals.wasModified = true; // TODO remove? if (!ranCron) return next(); // TODO Group.tavernBoss(user, progress); - if (!quest || true /* TODO remove */) return user.save(next); + if (!quest || true /* TODO remove */) { + // Save user and tasks + let toSave = [user.save()]; + tasks.forEach(task => { + if (task.isModified) toSave.push(task.save()); + }); + + return Q.all(toSave).then(() => next()).catch(next); + } // If user is on a quest, roll for boss & player, or handle collections // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? From 2cc02ee1f160dfaf7933852c0ca8bd24e0f24de4 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 14 Jan 2016 08:55:44 -0600 Subject: [PATCH 345/976] refactor(test helper): Fix api v3 helper to be specific for v3 --- test/helpers/api-v3-integration.helper.js | 137 ++++++++-------------- 1 file changed, 48 insertions(+), 89 deletions(-) diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js index 30898513e2..b0dbcdece8 100644 --- a/test/helpers/api-v3-integration.helper.js +++ b/test/helpers/api-v3-integration.helper.js @@ -7,6 +7,7 @@ import { set, times, } from 'lodash'; +import Q from 'q'; import { MongoClient as mongo } from 'mongodb'; import { v4 as generateUUID } from 'uuid'; import superagent from 'superagent'; @@ -14,15 +15,6 @@ import i18n from '../../common/script/src/i18n'; i18n.translations = require('../../website/src/libs/api-v3/i18n').translations; const API_TEST_SERVER_PORT = 3003; -const API_V = process.env.API_VERSION || 'v2'; // eslint-disable-line no-process-env -const ROUTES = { - v2: { - register: '/register', - }, - v3: { - register: '/user/auth/local/register', - }, -}; class ApiUser { constructor (options) { @@ -97,26 +89,26 @@ export function checkExistence (collectionName, id) { // paramter, such as the number of wolf eggs the user has, // , you can do so by passing in the full path as a string: // { 'items.eggs.Wolf': 10 } -export function generateUser (update = {}) { +export async function generateUser (update = {}) { let username = generateUUID(); let password = 'password'; let email = `${username}@example.com`; let request = _requestMaker({}, 'post'); - return new Promise((resolve, reject) => { - request(ROUTES[API_V].register, { - username, - email, - password, - confirmPassword: password, - }).then((user) => { - _updateDocument('users', user, update, () => { - let apiUser = new ApiUser(user); + let user = await request('/user/auth/local/register', { + username, + email, + password, + confirmPassword: password, + }); - resolve(apiUser); - }); - }).catch(reject); + return Q.promise((resolve) => { + _updateDocument('users', user, update, () => { + let apiUser = new ApiUser(user); + + resolve(apiUser); + }); }); } @@ -124,10 +116,8 @@ export function generateUser (update = {}) { // will will become the groups leader. Takes an update // argument which will update group export function generateGroup (leader, details = {}, update = {}) { - let request = _requestMaker(leader, 'post'); - return new Promise((resolve, reject) => { - request('/groups', details).then((group) => { + leader.post('/groups', details).then((group) => { _updateDocument('groups', group, update, () => { resolve(group); }); @@ -149,72 +139,50 @@ export function generateGroup (leader, details = {}, update = {}) { // invitees: an array of user objects that correspond to the invitees of the group // leader: the leader user object // group: the group object -export function createAndPopulateGroup (settings = {}) { - let request; - let leader; - let members; - let invitees; - let group; - +export async function createAndPopulateGroup (settings = {}) { let numberOfMembers = settings.members || 0; let numberOfInvites = settings.invites || 0; let groupDetails = settings.groupDetails; let leaderDetails = settings.leaderDetails || { balance: 10 }; - let leaderPromise = generateUser(leaderDetails); + let groupLeader = await generateUser(leaderDetails); + let group = await generateGroup(groupLeader, groupDetails); - let memberPromises = Promise.all( + let members = await Q.all( times(numberOfMembers, () => { return generateUser(); }) ); - let invitePromises = Promise.all( + let groupTypes = { + guild: { guilds: [group._id] }, + party: { 'party._id': group._id }, + }; + + each(members, (member) => { + member.update(groupTypes[group.type]); + }); + + let invitees = await Q.all( times(numberOfInvites, () => { return generateUser(); }) ); - return new Promise((resolve, reject) => { - return leaderPromise.then((user) => { - leader = user; - request = _requestMaker(leader, 'post'); - return memberPromises; - }).then((users) => { - members = users; - groupDetails.members = groupDetails.members || [leader._id]; - - each(members, (member) => { - groupDetails.members.push(member._id); - }); - - return generateGroup(leader, groupDetails); - }).then((createdGroup) => { - group = createdGroup; - return invitePromises; - }).then((users) => { - invitees = users; - - let invitationPromises = []; - - each(invitees, (invitee) => { - let invitePromise = request(`/groups/${group._id}/invite`, { - uuids: [invitee._id], - }); - - invitationPromises.push(invitePromise); - }); - - return Promise.all(invitationPromises); - }).then(() => { - resolve({ - leader, - group, - members, - invitees, - }); - }).catch(reject); + let invitationPromises = invitees.map((invitee) => { + return groupLeader.post(`/groups/${group._id}/invite`, { + uuids: [invitee._id], + }); }); + + await Q.all(invitationPromises); + + return { + groupLeader, + group, + members, + invitees, + }; } // Specifically helpful for the GET /groups tests, @@ -250,7 +218,7 @@ export function resetHabiticaDB () { function _requestMaker (user, method, additionalSets) { return (route, send, query) => { return new Promise((resolve, reject) => { - let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/${API_V}${route}`) + let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/v3${route}`) .accept('application/json'); if (user && user._id && user.apiToken) { @@ -270,20 +238,11 @@ function _requestMaker (user, method, additionalSets) { if (err) { if (!err.response) return reject(err); - if (API_V === 'v3') { - return reject({ - code: err.status, - error: err.response.body.error, - message: err.response.body.message, - }); - } else if (API_V === 'v2') { - return reject({ - code: err.status, - text: err.response.body.err, - }); - } - - return reject(err); + return reject({ + code: err.status, + error: err.response.body.error, + message: err.response.body.message, + }); } resolve(response.body); From 42530b9a5fa7f42cd2b866b4005f4c80c6f0cc2a Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 14 Jan 2016 08:56:16 -0600 Subject: [PATCH 346/976] refactor(tests): Adjust tests to use createAndPopulateGroup --- .../groups/POST-groups_groupId_join.js | 70 ++++++++++--------- .../v3/integration/groups/PUT-groups.test.js | 30 ++++---- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.js b/test/api/v3/integration/groups/POST-groups_groupId_join.js index ca7c8317b5..b35409bb6c 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.js @@ -1,5 +1,6 @@ import { generateUser, + createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -15,22 +16,28 @@ describe('POST /group/:groupId/join', () => { }); }); - context('Accepting invitation to a guild', () => { + context('Accepting invitation to a private guild', () => { let user, invitedUser, guild; beforeEach(async () => { - user = await generateUser({balance: 1}); - guild = await user.post('/groups', { - name: 'Test Guild', - type: 'guild', - privacy: 'private', + let { group, groupLeader, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'private', + }, + invites: 1, }); + + guild = group; + user = groupLeader; + invitedUser = invitees[0]; }); it('returns error when user is not invited to private guild', async () => { - let joiningUser = await generateUser(); + let userWithoutInvite = await generateUser(); - await expect(joiningUser.post(`/groups/${guild._id}/join`)).to.eventually.be.rejected.and.eql({ + await expect(userWithoutInvite.post(`/groups/${guild._id}/join`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageGroupRequiresInvite'), @@ -38,26 +45,21 @@ describe('POST /group/:groupId/join', () => { }); it('allows non-invited users to join public guilds', async () => { - await user.update({balance: 1}); - guild = await user.post('/groups', { - name: 'Test Guild', - type: 'guild', - privacy: 'public', - }); + let publicGuild = (await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + })).group; let joiningUser = await generateUser(); - await joiningUser.post(`/groups/${guild._id}/join`); + await joiningUser.post(`/groups/${publicGuild._id}/join`); - await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').to.include(guild._id); + await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').and.to.include(publicGuild._id); }); context('User is invited', () => { - beforeEach(async () => { - invitedUser = await generateUser({ - 'invitations.guilds': [{ id: guild._id}], - }); - }); - it('allows invited user to join private guilds', async () => { await invitedUser.post(`/groups/${guild._id}/join`); @@ -92,17 +94,23 @@ describe('POST /group/:groupId/join', () => { let user, invitedUser, party; beforeEach(async () => { - user = await generateUser(); - party = await user.post('/groups', { - name: 'Test Party', - type: 'party', + let { group, groupLeader, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + }, + invites: 1, }); + + party = group; + user = groupLeader; + invitedUser = invitees[0]; }); it('returns error when user is not invited to party', async () => { - let joiningUser = await generateUser(); + let userWithoutInvite = await generateUser(); - await expect(joiningUser.post(`/groups/${party._id}/join`)).to.eventually.be.rejected.and.eql({ + await expect(userWithoutInvite.post(`/groups/${party._id}/join`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageGroupRequiresInvite'), @@ -110,12 +118,6 @@ describe('POST /group/:groupId/join', () => { }); context('User is invited', () => { - beforeEach(async () => { - invitedUser = await generateUser({ - 'invitations.party': { id: party._id, inviter: user._id }, - }); - }); - it('allows invited user to join party', async () => { await invitedUser.post(`/groups/${party._id}/join`); diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 5c23668ddf..2b4a417090 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -1,32 +1,30 @@ import { - generateUser, + createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; describe('PUT /group', () => { - let groupLeader; + let leader, nonLeader, groupToUpdate; let groupName = 'Test Public Guild'; let groupType = 'guild'; - let groupToUpdate; let groupUpdatedName = 'Test Public Guild Updated'; beforeEach(async () => { - groupLeader = await generateUser({balance: 1}); - groupToUpdate = await groupLeader.post('/groups', { - name: groupName, - type: groupType, + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: groupName, + type: groupType, + }, + members: 1, }); + + groupToUpdate = group; + leader = groupLeader; + nonLeader = members[0]; }); it('returns an error when a non group leader tries to update', async () => { - let memberToAttemptUpdate = await generateUser(); - - await groupLeader.post(`/groups/${groupToUpdate._id}/invite`, { - uuids: [memberToAttemptUpdate._id], - }); - await memberToAttemptUpdate.post(`/groups/${groupToUpdate._id}/join`); - - await expect(memberToAttemptUpdate.put(`/groups/${groupToUpdate._id}`, { + await expect(nonLeader.put(`/groups/${groupToUpdate._id}`, { name: groupUpdatedName, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -36,7 +34,7 @@ describe('PUT /group', () => { }); it('updates a group', async () => { - let updatedGroup = await groupLeader.put(`/groups/${groupToUpdate._id}`, { + let updatedGroup = await leader.put(`/groups/${groupToUpdate._id}`, { name: groupUpdatedName, }); From 0f5cf318d92ba01a93a8af6c78e1bdeffad03c18 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 14 Jan 2016 12:22:56 -0600 Subject: [PATCH 347/976] fix: Update member objects with guild info in test helper --- test/api/v3/integration/groups/PUT-groups.test.js | 1 + test/helpers/api-v3-integration.helper.js | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 2b4a417090..414f495ff7 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -14,6 +14,7 @@ describe('PUT /group', () => { groupDetails: { name: groupName, type: groupType, + privacy: 'public', }, members: 1, }); diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js index b0dbcdece8..530b275fb6 100644 --- a/test/helpers/api-v3-integration.helper.js +++ b/test/helpers/api-v3-integration.helper.js @@ -159,10 +159,12 @@ export async function createAndPopulateGroup (settings = {}) { party: { 'party._id': group._id }, }; - each(members, (member) => { - member.update(groupTypes[group.type]); + let memberPromises = members.map((member) => { + return member.update(groupTypes[group.type]); }); + await Q.all(memberPromises); + let invitees = await Q.all( times(numberOfInvites, () => { return generateUser(); From 5206469e909f656e9869c274eba83cccd45f9330 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Jan 2016 19:46:43 +0100 Subject: [PATCH 348/976] add unlinkTask route and refactor user.unlink (now user.unlinkChallengesTasks) --- common/locales/en/api-v3.json | 4 +- website/src/controllers/api-v3/tasks.js | 81 +++++++++++++++++++------ website/src/models/challenge.js | 2 +- website/src/models/group.js | 6 +- website/src/models/user.js | 56 ++++++++--------- 5 files changed, 95 insertions(+), 54 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 91711f15eb..dfdea49d6c 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -37,6 +37,7 @@ "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", + "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"", "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", "inviteMissingEmail": "Missing email address in invite.", "onlyGroupLeaderChal": "Only the group leader can create challenges", @@ -59,5 +60,6 @@ "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", "emailsMustBeAnArray": "Email invites must be a an Array.", - "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time" + "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", + "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked." } diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index ffa2d20bf5..a3610f7913 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -540,7 +540,7 @@ api.moveTask = { }).exec(); if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type === 'todo' && task.completed) throw new NotFound(res.t('cantMoveCompletedTodo')); + if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo')); let order = user.tasksOrder[`${task.type}s`]; let currentIndex = order.indexOf(task._id); @@ -837,21 +837,63 @@ api.removeTagFromTask = { }; // Remove a task from (user|challenge).tasksOrder -function _removeTaskTasksOrder (userOrChallenge, taskId) { - // Loop through all lists and when the task is found, remove it and return - for (let i = 0; i < Tasks.tasksTypes.length; i++) { - let list = userOrChallenge.tasksOrder[`${Tasks.tasksTypes[i]}s`]; - let index = list.indexOf(taskId); +function _removeTaskTasksOrder (userOrChallenge, taskId, taskType) { + let list = userOrChallenge.tasksOrder[taskType]; + let index = list.indexOf(taskId); - if (index !== -1) { - list.splice(index, 1); - break; - } - } - - return; + if (index !== -1) list.splice(index, 1); } +// TODO this method needs some limitation, like to check if the challenge is really broken? +/** + * @api {post} /tasks/unlink/:taskId Unlink a challenge task + * @apiVersion 3.0.0 + * @apiName UnlinkTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * + * @apiSuccess {object} empty An empty object + */ +api.unlinkTask = { + method: 'POST', + url: '/tasks/unlink/:taskId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkQuery('keep', res.t('keepOrRemove')).notEmpty().isIn(['keep', 'remove']); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let keep = req.query.keep; + let taskId = req.params.taskId; + + let task = await Tasks.Task.findOne({ + _id: taskId, + userId: user._id, + }).exec(); + + if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task.challenge.id) throw new BadRequest(res.t('cantOnlyUnlinkChalTask')); + + if (keep === 'keep') { + task.challenge = {}; + await task.save(); + } else { // remove + if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if + _removeTaskTasksOrder(user, taskId, task.type); + await Q.all([user.save(), task.remove()]); + } else { + await task.remove(); + } + } + + res.respond(200, {}); // TODO what to return + }, +}; + /** * @api {delete} /task/:taskId Delete a user task given its id * @apiVersion 3.0.0 @@ -875,9 +917,8 @@ api.deleteTask = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - }).exec(); + let taskId = req.params.taskId; + let task = await Tasks.Task.findById(taskId).exec(); if (!task) { throw new NotFound(res.t('taskNotFound')); @@ -891,8 +932,12 @@ api.deleteTask = { throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); } - _removeTaskTasksOrder(challenge || user, req.params.taskId); - await Q.all([user.save(), task.remove()]); + if (task.type !== 'todo' || !task.completed) { + _removeTaskTasksOrder(challenge || user, taskId, task.type); + await Q.all([(challenge || user).save(), task.remove()]); + } else { + await task.remove(); + } res.respond(200, {}); if (challenge) challenge.removeTask(task); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 198ea2e476..b675a7833e 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -182,7 +182,7 @@ schema.methods.removeTask = async function challengeRemoveTask (task) { 'challenge.taskId': task._id, }, { $set: {'challenge.broken': 'TASK_DELETED'}, // TODO what about updatedAt? - }).lean().exec(); + }, {multi: true}).exec(); }; export let model = mongoose.model('Challenge', schema); diff --git a/website/src/models/group.js b/website/src/models/group.js index 91ae59e714..61c324e6c1 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -439,7 +439,7 @@ schema.statics.bossQuest = function bossQuest (user, progress) { // Remove user from this group // TODO this is highly inefficient -schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { +schema.methods.leave = function leaveGroup (user, keep) { let group = this; return Q.all([ @@ -455,12 +455,12 @@ schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { {_id: {$in: _.pluck(challenges, '_id')}}, {$pull: {members: user._id}}, {multi: true} - ).then(() => challenges); // pass `challenges` above to next promise TODO ok to return a non-promise? + ).then(() => challenges); // pass `challenges` above to next promise }).then(challenges => { return Q.all(challenges.map(chal => { let i = user.challenges.indexOf(chal._id); if (i !== -1) user.challenges.splice(i, 1); - return user.unlink({cid: chal._id, keep}); + return user.unlinkChallengeTasks(chal._id, keep); })); }), diff --git a/website/src/models/user.js b/website/src/models/user.js index a688453cfe..55fdd49bdb 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -643,40 +643,34 @@ schema.methods.isSubscribed = function isSubscribed () { return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion }; -schema.methods.unlink = function unlink (options, cb) { - let cid = options.cid; - let keep = options.keep; - let tid = options.tid; +// Unlink challenges tasks from user +schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (challengeId, keep) { + let user = this; + let findQuery = { + userId: user._id, + 'challenge.id': challengeId, + }; - if (!cid) { - return cb('Could not remove challenge tasks. Please delete them manually.'); - } - - let self = this; - - if (keep === 'keep') { - self.tasks[tid].challenge = {}; - } else if (keep === 'remove') { - self.ops.deleteTask({params: {id: tid}}, () => {}); - } else if (keep === 'keep-all') { - _.each(self.tasks, (t) => { - if (t.challenge && t.challenge.id === cid) { - t.challenge = {}; + if (keep === 'keep-all') { + await Tasks.Task.update(findQuery, { + $set: {challenge: {}}, // TODO what about updatedAt? + }, {multi: true}).exec(); + } else { // keep = 'remove-all' + let tasks = Tasks.Task.find(findQuery).select('_id type completed').exec(); + tasks = tasks.map(task => { + // Remove task from user.tasksOrder and delete them + if (task.type !== 'todo' || !task.completed) { + let list = user.tasksOrder[task.type]; + let index = list.indexOf(task._id); + if (index !== -1) list.splice(index, 1); } - }); - } else if (keep === 'remove-all') { - _.each(self.tasks, (t) => { - if (t.challenge && t.challenge.id === cid) { - this.ops.deleteTask({params: {id: tid}}, () => {}); - } - }); - } - self.markModified('habits'); - self.markModified('dailys'); - self.markModified('todos'); - self.markModified('rewards'); - self.save(cb); + return task.remove(); + }); + + tasks.push(user.save()); + await Q.all(tasks); + } }; export let model = mongoose.model('User', schema); From 7d5a7503cba2e49ed3afb073a1b349ea9571dcfc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Jan 2016 20:55:13 +0100 Subject: [PATCH 349/976] fix access to user.tasksOrder --- website/src/controllers/api-v3/tasks.js | 2 +- website/src/models/user.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index a3610f7913..cee1ba4527 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -838,7 +838,7 @@ api.removeTagFromTask = { // Remove a task from (user|challenge).tasksOrder function _removeTaskTasksOrder (userOrChallenge, taskId, taskType) { - let list = userOrChallenge.tasksOrder[taskType]; + let list = userOrChallenge.tasksOrder[`${taskType}s`]; let index = list.indexOf(taskId); if (index !== -1) list.splice(index, 1); diff --git a/website/src/models/user.js b/website/src/models/user.js index 55fdd49bdb..40181108d0 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -660,7 +660,7 @@ schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (chall tasks = tasks.map(task => { // Remove task from user.tasksOrder and delete them if (task.type !== 'todo' || !task.completed) { - let list = user.tasksOrder[task.type]; + let list = user.tasksOrder[`${task.type}s`]; let index = list.indexOf(task._id); if (index !== -1) list.splice(index, 1); } From 50a85337a7fb9d6407e1efdad2ebc349834abc0a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 15:29:50 +0100 Subject: [PATCH 350/976] better access control for challenges --- website/src/controllers/api-v3/challenges.js | 14 ++++++-------- website/src/models/challenge.js | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 8bec5eae6e..321f3c66c5 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -100,9 +100,9 @@ api.getChallenges = { async handler (req, res) { let user = res.locals.user; - let groups = user.guilds || []; + let groups = user.guilds.slice(0); // slice is used to clone the array so we don't modify it directly if (user.party._id) groups.push(user.party._id); - groups.push('habitrpg'); // Public challenges + groups.push('habitrpg'); // tavern challenges let challenges = await Challenge.find({ $or: [ @@ -143,11 +143,9 @@ api.getChallenge = { let user = res.local.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findOne({_id: challengeId}).exec(); // TODO populate + let challenge = await Challenge.findById(challengeId).exec(); - // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error - // TODO support challenges in groups I'm a member of - if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens + if (!challenge || !challenge.hasAccess(user)) { throw new NotFound(res.t('challengeNotFound')); } @@ -233,7 +231,7 @@ api.deleteChallenge = { let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); res.respond(200, {}); // Close channel in background @@ -264,7 +262,7 @@ api.selectChallengeWinner = { let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); let winner = await User.findOne({_id: req.params.winnerId}).exec(); if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.parama.winnerId})); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index b675a7833e..fcfb5cc1e3 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -31,6 +31,24 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], }); +// Return true if user has access to the challenge +schema.methods.hasAccess = function hasAccessToChallenge (user) { + let userGroups = user.guilds.slice(0); + if (user.party._id) userGroups.push(user.party._id); + userGroups.push('habitrpg'); // tavern challenges + return this.leader === user._id || user.contributor.admin || userGroups.indexOf(this.groupId) !== -1; +}; + +// Return true if user is a member of the challenge +schema.methods.isMember = function isChallengeMember (user) { + return user.challenges.indexOf(this._id) !== -1; +}; + +// Return true if the user can modify (close, selectWinner, ...) the challenge +schema.methods.canModify = function canModifyChallenge (user) { + return user.contributor.admin || this.leader === user._id; +}; + // Takes a Task document and return a plain object of attributes that can be synced to the user function _syncableAttrs (task) { let t = task.toObject(); // lodash doesn't seem to like _.omit on Document From 3eb9d4b0989f9d18fedc9e6fafb252cef623c37a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 15:30:13 +0100 Subject: [PATCH 351/976] members controller: support challenges --- website/src/controllers/api-v3/members.js | 86 ++++++++--------------- 1 file changed, 28 insertions(+), 58 deletions(-) diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 9ac5d826f3..f464fe830c 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -6,6 +6,7 @@ import { nameFields, } from '../../models/user'; import { model as Group } from '../../models/group'; +import { model as Challenge } from '../../models/challenge'; import { NotFound, } from '../../libs/api-v3/errors'; @@ -45,31 +46,45 @@ api.getMember = { }, }; -// Return a request handler for getMembersForGroup / getInvitesForGroup +// Return a request handler for getMembersForGroup / getInvitesForGroup / getMembersForChallenge // type is `invites` or `members` -function handleGetMembersInvitesForGroup (type) { - if (type !== 'members' && type !== 'invites') { - throw new Error('Type must be "invites" or "members"'); +function _getMembersForItem (type) { + if (['group-members', 'group-invites', 'challenge-members'].indexOf(type) === -1) { + throw new Error('Type must be one of "group-members", "group-invites", "challenge-members"'); } - return async function getMembersOrInvitesForGroup (req, res) { - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + return async function handleGetMembersForItem (req, res) { + if (type === 'challenge-members') { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + } else { + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + } req.checkQuery('lastId').optional().notEmpty().isUUID(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; let groupId = req.params.groupId; + let challengeId = req.params.challengeId; let lastId = req.query.lastId; let user = res.locals.user; + let challenge; + let group; - let group = await Group.getGroup(user, groupId, '_id type'); - if (!group) throw new NotFound(res.t('groupNotFound')); + if (type === 'challenge-members') { + challenge = await Challenge.findById(challengeId).select('_id type leader').exec(); + if (!challenge || !challenge.hasAccess(user)) throw new NotFound(res.t('groupNotFound')); + } else { + group = await Group.getGroup(user, groupId, '_id type'); + if (!group) throw new NotFound(res.t('groupNotFound')); + } let query = {}; let fields = nameFields; - if (type === 'members') { + if (type === 'challenge-members') { + query.challenges = challenge._id; + } else if (type === 'group-members') { if (group.type === 'guild') { query.guilds = group._id; } else { @@ -79,7 +94,7 @@ function handleGetMembersInvitesForGroup (type) { fields = memberFields; } } - } else { + } else if (type === 'group-invites') { if (group.type === 'guild') { // eslint-disable-line no-lonely-if query['invitations.guilds.id'] = group._id; } else { @@ -116,7 +131,7 @@ api.getMembersForGroup = { method: 'GET', url: '/groups/:groupId/members', middlewares: [authWithHeaders(), cron], - handler: handleGetMembersInvitesForGroup('members'), + handler: _getMembersForItem('group-members'), }; /** @@ -134,7 +149,7 @@ api.getInvitesForGroup = { method: 'GET', url: '/groups/:groupId/invites', middlewares: [authWithHeaders(), cron], - handler: handleGetMembersInvitesForGroup('invites'), + handler: _getMembersForItem('group-invites'), }; /** @@ -152,52 +167,7 @@ api.getMembersForChallenge = { method: 'GET', url: '/challenges/:challengeId/members', middlewares: [authWithHeaders(), cron], - async handler (req, res) { - req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty(); - req.checkQuery('lastId').optional().notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let challengeId = req.params.challengeId; - let lastId = req.query.lastId; - let user = res.locals.user; - - let challenge = await Challenge.findById(challengeId).exec(); - - let query = {}; - let fields = nameFields; - - if (type === 'members') { - if (group.type === 'guild') { - query.guilds = group._id; - } else { - query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' - - if (req.query.includeAllPublicFields === 'true') { - fields = memberFields; - } - } - } else { - if (group.type === 'guild') { // eslint-disable-line no-lonely-if - query['invitations.guilds.id'] = group._id; - } else { - query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' - } - } - - if (lastId) query._id = {$gt: lastId}; - - let users = await User - .find(query) - .sortBy({_id: 1}) - .limit(30) - .select(fields) - .exec(); - - res.respond(200, users); - } + handler: _getMembersForItem('challenge-members'), }; - export default api; From b0caf71641384ab9f771d69b501e2562d7387561 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 18:54:28 +0100 Subject: [PATCH 352/976] fix several busg with tasks and challenges --- website/src/controllers/api-v3/challenges.js | 12 +++++++++--- website/src/controllers/api-v3/tasks.js | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 321f3c66c5..f98cac0302 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -77,9 +77,15 @@ api.createChallenge = { req.body.official = user.contributor.admin && req.body.official; let challenge = new Challenge(Challenge.sanitize(req.body)); - let results = await Q.all(challenge.save(), group.save()); - let savedChal = results[0]; + // First validate challenge so we don't save group if it's invalid (only runs sync validators) + let challengeValidationErrors = challenge.validateSync(); + if (challengeValidationErrors) throw challengeValidationErrors; + let results = await Q.all([challenge.save({ + validateBeforeSave: false, // already validate + }), group.save()]); + + let savedChal = results[0]; await savedChal.syncToUser(user); // (it also saves the user) res.respond(201, savedChal); }, @@ -140,7 +146,7 @@ api.getChallenge = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let user = res.local.user; + let user = res.locals.user; let challengeId = req.params.challengeId; let challenge = await Challenge.findById(challengeId).exec(); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index cee1ba4527..c2498b2a94 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -92,7 +92,7 @@ api.createChallengeTasks = { let reqValidationErrors = req.validationErrors(); if (reqValidationErrors) throw reqValidationErrors; - let user = res.local.user; + let user = res.locals.user; let challengeId = req.params.challengeId; let challenge = await Challenge.findOne({_id: challengeId}).exec(); @@ -194,7 +194,7 @@ api.getChallengeTasks = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let user = res.local.user; + let user = res.locals.user; let challengeId = req.params.challengeId; let challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); From 2ee75c1ad34f652e41dfd4ad1b7e180354c72bec Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 19:44:45 +0100 Subject: [PATCH 353/976] add tests for getMember --- .../members/GET-members_id.test.js | 41 +++++++++++++++++++ .../v3/integration/tags/GET-tags_id.test.js | 2 + website/src/controllers/api-v3/members.js | 3 +- website/src/models/user.js | 12 +++--- 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 test/api/v3/integration/members/GET-members_id.test.js diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js new file mode 100644 index 0000000000..25c7600c80 --- /dev/null +++ b/test/api/v3/integration/members/GET-members_id.test.js @@ -0,0 +1,41 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /members/:memberId', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + it('returns a member public data only', async () => { + let member = await generateUser({ // make sure user has all the fields that can be returned by the getMember call + contributor: {level: 1}, + backer: {tier: 3}, + preferences: { + costume: false, + background: 'volcano', + }, + }); + let memberRes = await user.get(`/members/${member._id}`); + expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'preferences', 'profile', 'stats', 'achievements', 'party', + 'backer', 'contributor', 'auth', 'items', + ]); + expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); + expect(Object.keys(memberRes.preferences).sort()).to.eql(['size', 'hair', 'skin', 'shirt', + 'costume', 'sleep', 'background'].sort()); + }); + + it('handles non-existing members', async () => { + let dummyId = generateUUID(); + await expect(user.get(`/members/${dummyId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: dummyId}), + }); + }); +}); diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index 1189c2af24..c46b5b8801 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -15,4 +15,6 @@ describe('GET /tags/:tagId', () => { expect(tag).to.deep.equal(createdTag); }); + + it('handles non-existing tags'); }); diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index f464fe830c..6eef598050 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -42,7 +42,8 @@ api.getMember = { if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); - res.respond(200, member); + // manually call toJSON with minimize: true so empty paths aren't returned + res.respond(200, member.toJSON({minimize: true})); }, }; diff --git a/website/src/models/user.js b/website/src/models/user.js index fcad88c5a6..8942a8afd2 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -472,18 +472,20 @@ export let schema = new Schema({ }, }, { strict: true, - minimize: false, // So empty objects are returned + minimize: false, // So empty objects are returned TODO make sure it's in every model }); schema.plugin(baseModel, { - // TODO revisit a lot of things are missing - noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations', 'balance'], + // TODO revisit a lot of things are missing. Given how many attributes we do have here we should white-list the ones that can be updated + noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', + 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', + 'invitations', 'balance', 'backer', 'contributor'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? // TODO this works? - doc.filters = {}; - doc._tmp = this._tmp; // be sure to send down drop notifs + // doc.filters = {}; + // doc._tmp = this._tmp; // be sure to send down drop notifs return doc; }, From ad41037f597c296930ce8cce11e418f4b5f32799 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 21:47:20 +0100 Subject: [PATCH 354/976] finish tests for members controller --- .../GET-challenges_challengeId_members.test | 0 .../groups/GET-groups_groupId_invites.test.js | 0 .../groups/GET-groups_groupId_members.test.js | 95 +++++++++++++++++++ ...in.js => POST-groups_groupId_join.test.js} | 0 .../members/GET-members_id.test.js | 8 ++ website/src/controllers/api-v3/members.js | 7 +- 6 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_members.test create mode 100644 test/api/v3/integration/groups/GET-groups_groupId_invites.test.js create mode 100644 test/api/v3/integration/groups/GET-groups_groupId_members.test.js rename test/api/v3/integration/groups/{POST-groups_groupId_join.js => POST-groups_groupId_join.test.js} (100%) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js new file mode 100644 index 0000000000..d7ad8ac769 --- /dev/null +++ b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js @@ -0,0 +1,95 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /groups/:groupId/members', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('validates optional req.query.lastId to be an UUID', async () => { + await expect(user.get(`/groups/groupId/members?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('fails if group doesn\'t exists', async () => { + await expect(user.get(`/groups/${generateUUID()}/members`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('fails if user doesn\'t have access to the group', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let anotherUser = await generateUser(); + await expect(anotherUser.get(`/groups/${group._id}/members`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('works when passing party as req.params.groupId', async () => { + await generateGroup(user, {type: 'party', name: generateUUID()}); + let res = await user.get(`/groups/party/members`); + expect(res).to.be.an('array'); + expect(res.length).to.equal(1); + expect(res[0]).to.eql({ + _id: user._id, + profile: {name: user.profile.name}, + }); + }); + + it('populates only some fields', async () => { + await generateGroup(user, {type: 'party', name: generateUUID()}); + let res = await user.get(`/groups/party/members`); + expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0].profile).to.have.all.keys(['name']); + }); + + it('returns only first 30 members', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + + let usersToGenerate = []; + for (let i = 0; i < 31; i++) { + usersToGenerate.push(generateUser({party: {_id: group._id}})); + } + await Promise.all(usersToGenerate); + + let res = await user.get(`/groups/party/members`); + expect(res.length).to.equal(30); + res.forEach(member => { + expect(member).to.have.all.keys(['_id', 'profile']); + expect(member.profile).to.have.all.keys(['name']); + }); + }); + + it('supports using req.query.lastId to get more members', async () => { + let leader = await generateUser({balance: 4}); + let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); + + let usersToGenerate = []; + for (let i = 0; i < 57; i++) { + usersToGenerate.push(generateUser({guilds: [group._id]})); + } + let generatedUsers = await Promise.all(usersToGenerate); // Group has 59 members (1 is the leader) + let expectedIds = [leader._id].concat(generatedUsers.map(generatedUser => generatedUser._id)); + + let res = await user.get(`/groups/${group._id}/members`); + expect(res.length).to.equal(30); + let res2 = await user.get(`/groups/${group._id}/members?lastId=${res[res.length - 1]._id}`); + expect(res2.length).to.equal(28); + + let resIds = res.concat(res2).map(member => member._id); + expect(resIds).to.eql(expectedIds.sort()) + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js similarity index 100% rename from test/api/v3/integration/groups/POST-groups_groupId_join.js rename to test/api/v3/integration/groups/POST-groups_groupId_join.test.js diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js index 25c7600c80..9b4c734ea9 100644 --- a/test/api/v3/integration/members/GET-members_id.test.js +++ b/test/api/v3/integration/members/GET-members_id.test.js @@ -11,6 +11,14 @@ describe('GET /members/:memberId', () => { user = await generateUser(); }); + it('validates req.params.memberId', async () => { + await expect(user.get(`/members/invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + it('returns a member public data only', async () => { let member = await generateUser({ // make sure user has all the fields that can be returned by the getMember call contributor: {level: 1}, diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 6eef598050..3f5ca5ac9d 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -105,14 +105,15 @@ function _getMembersForItem (type) { if (lastId) query._id = {$gt: lastId}; - let users = await User + let members = await User .find(query) - .sortBy({_id: 1}) + .sort({_id: 1}) .limit(30) .select(fields) .exec(); - res.respond(200, users); + // manually call toJSON with minimize: true so empty paths aren't returned + res.respond(200, members.map(member => member.toJSON({minimize: true}))); }; } From 96c523493a4e9b03c1c684410bf347d691290b1b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 22:08:51 +0100 Subject: [PATCH 355/976] fix missing semicolon --- .../v3/integration/groups/GET-groups_groupId_members.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js index d7ad8ac769..15193db012 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js @@ -90,6 +90,6 @@ describe('GET /groups/:groupId/members', () => { expect(res2.length).to.equal(28); let resIds = res.concat(res2).map(member => member._id); - expect(resIds).to.eql(expectedIds.sort()) + expect(resIds).to.eql(expectedIds.sort()); }); }); From d7d63ad229c01f04c38617125901ca102485770d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Jan 2016 15:10:18 +0100 Subject: [PATCH 356/976] populate group.leader --- test/helpers/api-v3-integration.helper.js | 3 +-- website/src/controllers/api-v3/challenges.js | 2 +- website/src/controllers/api-v3/chat.js | 8 +++---- website/src/controllers/api-v3/groups.js | 17 ++++++++------- website/src/controllers/api-v3/members.js | 2 +- website/src/models/group.js | 22 +++++++++++++------- 6 files changed, 30 insertions(+), 24 deletions(-) diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js index 530b275fb6..a5d48478cd 100644 --- a/test/helpers/api-v3-integration.helper.js +++ b/test/helpers/api-v3-integration.helper.js @@ -201,11 +201,10 @@ export function resetHabiticaDB () { groups.insertOne({ _id: 'habitrpg', chat: [], - leader: '9', + leader: '9', // TODO change this name: 'HabitRPG', type: 'guild', privacy: 'public', - members: [], }, (insertErr) => { if (insertErr) return reject(insertErr); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index f98cac0302..ed8440ff1f 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -38,7 +38,7 @@ api.createChallenge = { let groupId = req.body.groupId; let prize = req.body.prize; - let group = await Group.getGroup(user, groupId, '-chat'); + let group = await Group.getGroup({user, groupId, fields: '-chat'}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 7c2d42bf62..212e064441 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -33,7 +33,7 @@ api.getChat = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId, 'chat'); + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'chat'}); if (!group) throw new NotFound(res.t('groupNotFound')); res.respond(200, group.chat); @@ -67,7 +67,7 @@ api.postChat = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, groupId); + let group = await Group.getGroup({user, groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.type !== 'party' && user.flags.chatRevoked) { @@ -118,7 +118,7 @@ api.likeChat = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, groupId); + let group = await Group.getGroup({user, groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); let message = _.find(group.chat, {id: req.params.chatId}); @@ -165,7 +165,7 @@ api.flagChat = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, groupId); + let group = await Group.getGroup({user, groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); let message = _.find(group.chat, {id: req.params.chatId}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8203e00b1b..d7d3eedbd3 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -93,7 +93,7 @@ api.getGroups = { types.forEach(type => { switch (type) { case 'party': - queries.push(Group.getGroup(user, 'party', groupFields)); + queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields, populateLeader: true})); break; case 'privateGuilds': queries.push(Group.find({ @@ -109,7 +109,7 @@ api.getGroups = { }).select(groupFields).sort(sort).exec()); // TODO use lean? break; case 'tavern': - queries.push(Group.getGroup(user, 'habitrpg', groupFields)); + queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields, populateLeader: true})); break; } }); @@ -149,7 +149,7 @@ api.getGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId); + let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: true}); if (!group) throw new NotFound(res.t('groupNotFound')); res.respond(200, group); @@ -178,7 +178,7 @@ api.updateGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId); + let group = await Group.getGroup({user, groupId: req.params.groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); @@ -214,7 +214,8 @@ api.joinGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId, '-chat', true); // Do not fetch chat and work even if the user is not yet a member of the group + // Do not fetch chat and work even if the user is not yet a member of the group + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat', optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group if (!group) throw new NotFound(res.t('groupNotFound')); let isUserInvited = false; @@ -288,7 +289,7 @@ api.leaveGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat if (!group) throw new NotFound(res.t('groupNotFound')); // During quests, checke wheter user can leave @@ -344,7 +345,7 @@ api.removeGroupMember = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat if (!group) throw new NotFound(res.t('groupNotFound')); let uuid = req.query.memberId; @@ -523,7 +524,7 @@ api.inviteToGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat TODO other fields too? + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat TODO other fields too? if (!group) throw new NotFound(res.t('groupNotFound')); let uuids = req.body.uuids; diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 3f5ca5ac9d..0b5638a95a 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -76,7 +76,7 @@ function _getMembersForItem (type) { challenge = await Challenge.findById(challengeId).select('_id type leader').exec(); if (!challenge || !challenge.hasAccess(user)) throw new NotFound(res.t('groupNotFound')); } else { - group = await Group.getGroup(user, groupId, '_id type'); + group = await Group.getGroup({user, groupId, fields: '_id type'}); if (!group) throw new NotFound(res.t('groupNotFound')); } diff --git a/website/src/models/group.js b/website/src/models/group.js index 94072bfb79..169d977d08 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -1,5 +1,8 @@ import mongoose from 'mongoose'; -import { model as User} from './user'; +import { + model as User, + nameFields, +} from './user'; import shared from '../../../common'; import _ from 'lodash'; import { model as Challenge} from './challenge'; @@ -40,7 +43,6 @@ export let schema = new Schema({ balance: {type: Number, default: 0}, logo: String, leaderMessage: String, - // challenges: [{type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) quest: { key: String, active: {type: Boolean, default: false}, @@ -57,6 +59,7 @@ export let schema = new Schema({ // 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. // TODO when booting user, remove from .joined and check again if we can now start the quest // TODO as long as quests are party only we can keep it here + // TODO are we sure we need this type of default for this to work? members: {type: Schema.Types.Mixed, default: () => { return {}; }}, @@ -125,7 +128,8 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMembership) { +schema.statics.getGroup = function getGroup (options = {}) { + let {user, groupId, fields, optionalMembership = false, populateLeader = false} = options; let query; // When optionalMembership is true it's not required for the user to be a member of the group @@ -141,7 +145,8 @@ schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMemb let mQuery = this.findOne(query); if (fields) mQuery.select(fields); - return mQuery.exec(); // TODO catch errors here? + if (populateLeader === true) mQuery.populate('leader', nameFields); + return mQuery.exec(); // TODO purge chat flags info? in tojson? }; @@ -503,6 +508,7 @@ schema.methods.leave = function leaveGroup (user, keep) { }); }; +export const INVITES_LIMIT = 100; export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) @@ -511,12 +517,12 @@ model.count({_id: 'habitrpg'}, (err, ct) => { if (ct > 0) return; new model({ // eslint-disable-line babel/new-cap - _id: 'habitrpg', // TODO hmm this will probably break everything + _id: 'habitrpg', leader: '9', // TODO change this user id name: 'HabitRPG', type: 'guild', privacy: 'public', - }).save(); + }).save({ + validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise + }); // TODO catch/log? }); - -export const INVITES_LIMIT = 100; From f447af19aeef7915adb141e6a5d5a88b1411e3de Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Jan 2016 16:18:06 +0100 Subject: [PATCH 357/976] add tests for getting challenge members and fix a lot of bugs --- common/locales/en/api-v3.json | 1 + .../GET-challenges_challengeId_members.test | 0 website/src/controllers/api-v3/challenges.js | 9 +++++---- website/src/controllers/api-v3/members.js | 6 ++++-- website/src/models/challenge.js | 15 +++++++++++---- website/src/models/group.js | 11 +++++++++++ 6 files changed, 32 insertions(+), 10 deletions(-) delete mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_members.test diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index d7239af5c4..2d00b19192 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -37,6 +37,7 @@ "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", + "mustBeGroupMember": "Must be member of the group.", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"", "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index ed8440ff1f..da7a7e8afe 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -38,8 +38,9 @@ api.createChallenge = { let groupId = req.body.groupId; let prize = req.body.prize; - let group = await Group.getGroup({user, groupId, fields: '-chat'}); + let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true}); if (!group) throw new NotFound(res.t('groupNotFound')); + if (!group.isMember(user)) throw new NotAuthorized(res.t('mustBeGroupMember')); if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { throw new NotAuthorized(res.t('onlyGroupLeaderChal')); @@ -150,10 +151,10 @@ api.getChallenge = { let challengeId = req.params.challengeId; let challenge = await Challenge.findById(challengeId).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge || !challenge.hasAccess(user)) { - throw new NotFound(res.t('challengeNotFound')); - } + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); res.respond(200, challenge); }, diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 0b5638a95a..5e2cb33d35 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -73,8 +73,10 @@ function _getMembersForItem (type) { let group; if (type === 'challenge-members') { - challenge = await Challenge.findById(challengeId).select('_id type leader').exec(); - if (!challenge || !challenge.hasAccess(user)) throw new NotFound(res.t('groupNotFound')); + challenge = await Challenge.findById(challengeId).select('_id type leader groupId').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); } else { group = await Group.getGroup({user, groupId, fields: '_id type'}); if (!group) throw new NotFound(res.t('groupNotFound')); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index fcfb5cc1e3..a19ecd3b60 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -20,7 +20,7 @@ let schema = new Schema({ rewards: [{type: String, ref: 'Task'}], }, leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, - groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, + groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, // TODO no update, no set? timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable? memberCount: {type: Number, default: 0}, challengeCount: {type: Number, default: 0}, @@ -31,7 +31,7 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], }); -// Return true if user has access to the challenge +// Returns true if user has access to the challenge (can join) schema.methods.hasAccess = function hasAccessToChallenge (user) { let userGroups = user.guilds.slice(0); if (user.party._id) userGroups.push(user.party._id); @@ -39,12 +39,19 @@ schema.methods.hasAccess = function hasAccessToChallenge (user) { return this.leader === user._id || user.contributor.admin || userGroups.indexOf(this.groupId) !== -1; }; -// Return true if user is a member of the challenge +// Returns true if user can view the challenge +// Different from hasAccess because challenges of public guilds can be viewed by everyone +schema.methods.canView = function canViewChallenge (user, group) { + if (group.type === 'guild' && group.privacy === 'public') return true; + return this.hasAccess(user); +}; + +// Returns true if user is a member of the challenge schema.methods.isMember = function isChallengeMember (user) { return user.challenges.indexOf(this._id) !== -1; }; -// Return true if the user can modify (close, selectWinner, ...) the challenge +// Returns true if the user can modify (close, selectWinner, ...) the challenge schema.methods.canModify = function canModifyChallenge (user) { return user.contributor.admin || this.leader === user._id; }; diff --git a/website/src/models/group.js b/website/src/models/group.js index 169d977d08..85396fc693 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -150,6 +150,17 @@ schema.statics.getGroup = function getGroup (options = {}) { // TODO purge chat flags info? in tojson? }; +// Return true if user is a member of the group +schema.methods.isMember = function isGroupMember (user) { + if (this._id === 'habitrpg') { + return true; // everyone is considered part of the tavern + } else if (this.type === 'party') { + return user.party._id === this._id ? true : false; + } else { // guilds + return user.guilds.indexOf(this._id) !== -1; + } +}; + export function chatDefaults (msg, user) { let message = { id: shared.uuid(), From 4e5c4e99531eb102d00ff26f0aec2f968d144c57 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Jan 2016 16:18:29 +0100 Subject: [PATCH 358/976] add missing test file --- ...GET-challenges_challengeId_members.test.js | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js new file mode 100644 index 0000000000..318181b66e --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js @@ -0,0 +1,125 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /challenges/:challengeId/members', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('validates optional req.query.lastId to be an UUID', async () => { + await expect(user.get(`/challenges/${generateUUID()}/members?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('fails if challenge doesn\'t exists', async () => { + await expect(user.get(`/challenges/${generateUUID()}/members`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('fails if user doesn\'t have access to the challenge', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let anotherUser = await generateUser(); + await expect(anotherUser.get(`/challenges/${challenge._id}/members`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('works with challenges belonging to public guild', async () => { + let leader = await generateUser({balance: 4}); + let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); + let challenge = await leader.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let res = await user.get(`/challenges/${challenge._id}/members`); + expect(res[0]).to.eql({ + _id: leader._id, + profile: {name: leader.profile.name}, + }); + expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0].profile).to.have.all.keys(['name']); + }); + + it('populates only some fields', async () => { + let anotherUser = await generateUser({balance: 3}); + let group = await generateGroup(anotherUser, {type: 'guild', privacy: 'public', name: generateUUID()}); + let challenge = await anotherUser.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let res = await user.get(`/challenges/${challenge._id}/members`); + expect(res[0]).to.eql({ + _id: anotherUser._id, + profile: {name: anotherUser.profile.name}, + }); + expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0].profile).to.have.all.keys(['name']); + }); + + it('returns only first 30 members', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + + let usersToGenerate = []; + for (let i = 0; i < 31; i++) { + usersToGenerate.push(generateUser({challenges: [challenge._id]})); + } + await Promise.all(usersToGenerate); + + let res = await user.get(`/challenges/${challenge._id}/members`); + expect(res.length).to.equal(30); + res.forEach(member => { + expect(member).to.have.all.keys(['_id', 'profile']); + expect(member.profile).to.have.all.keys(['name']); + }); + }); + + it('supports using req.query.lastId to get more members', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + + let usersToGenerate = []; + for (let i = 0; i < 57; i++) { + usersToGenerate.push(generateUser({challenges: [challenge._id]})); + } + let generatedUsers = await Promise.all(usersToGenerate); // Group has 59 members (1 is the leader) + let expectedIds = [user._id].concat(generatedUsers.map(generatedUser => generatedUser._id)); + + let res = await user.get(`/challenges/${challenge._id}/members`); + expect(res.length).to.equal(30); + let res2 = await user.get(`/challenges/${challenge._id}/members?lastId=${res[res.length - 1]._id}`); + expect(res2.length).to.equal(28); + + let resIds = res.concat(res2).map(member => member._id); + expect(resIds).to.eql(expectedIds.sort()); + }); +}); From a59da8607baa095d5659c0df7972d24818142617 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Jan 2016 16:32:46 +0100 Subject: [PATCH 359/976] add tests for getting invites to a group --- .../groups/GET-groups_groupId_invites.test.js | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js index e69de29bb2..480808521e 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js @@ -0,0 +1,101 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /groups/:groupId/invites', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('validates optional req.query.lastId to be an UUID', async () => { + await expect(user.get(`/groups/groupId/invites?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('fails if group doesn\'t exists', async () => { + await expect(user.get(`/groups/${generateUUID()}/invites`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('fails if user doesn\'t have access to the group', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let anotherUser = await generateUser(); + await expect(anotherUser.get(`/groups/${group._id}/invites`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('works when passing party as req.params.groupId', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let invited = await generateUser(); + await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); + let res = await user.get(`/groups/party/invites`); + + expect(res).to.be.an('array'); + expect(res.length).to.equal(1); + expect(res[0]).to.eql({ + _id: invited._id, + profile: {name: invited.profile.name}, + }); + }); + + it('populates only some fields', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let invited = await generateUser(); + await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); + let res = await user.get(`/groups/party/invites`); + expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0].profile).to.have.all.keys(['name']); + }); + + it('returns only first 30 invites', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let invitesToGenerate = []; + for (let i = 0; i < 31; i++) { + let invited = await generateUser(); + invitesToGenerate.push(invited); + await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); + } + await Promise.all(invitesToGenerate); + let res = await user.get(`/groups/party/invites`); + expect(res.length).to.equal(30); + res.forEach(member => { + expect(member).to.have.all.keys(['_id', 'profile']); + expect(member.profile).to.have.all.keys(['name']); + }); + }); + + it('supports using req.query.lastId to get more invites', async () => { + let leader = await generateUser({balance: 4}); + let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); + + let invitesToGenerate = []; + for (let i = 0; i < 32; i++) { + invitesToGenerate.push(await generateUser()); + } + let generatedInvites = await Promise.all(invitesToGenerate); // Group has 32 invites + let expectedIds = generatedInvites.map(generatedInvite => generatedInvite._id); + await user.post(`/groups/${group._id}/invite`, {uuids: expectedIds}); + + let res = await user.get(`/groups/${group._id}/invites`); + expect(res.length).to.equal(30); + let res2 = await user.get(`/groups/${group._id}/invites?lastId=${res[res.length - 1]._id}`); + expect(res2.length).to.equal(2); + + let resIds = res.concat(res2).map(invite => invite._id); + expect(resIds).to.eql(expectedIds.sort()); + }); +}); From f8f591e521260751ace4dd3a327f2483d5b4f569 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Jan 2016 16:37:21 +0100 Subject: [PATCH 360/976] do not use wait inside for loop --- .../groups/GET-groups_groupId_invites.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js index 480808521e..d1604a4d84 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js @@ -65,11 +65,11 @@ describe('GET /groups/:groupId/invites', () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); let invitesToGenerate = []; for (let i = 0; i < 31; i++) { - let invited = await generateUser(); - invitesToGenerate.push(invited); - await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); + invitesToGenerate.push(generateUser()); } - await Promise.all(invitesToGenerate); + let generatedInvites = await Promise.all(invitesToGenerate); + await user.post(`/groups/${group._id}/invite`, {uuids: generatedInvites.map(invite => invite._id)}); + let res = await user.get(`/groups/party/invites`); expect(res.length).to.equal(30); res.forEach(member => { @@ -84,7 +84,7 @@ describe('GET /groups/:groupId/invites', () => { let invitesToGenerate = []; for (let i = 0; i < 32; i++) { - invitesToGenerate.push(await generateUser()); + invitesToGenerate.push(generateUser()); } let generatedInvites = await Promise.all(invitesToGenerate); // Group has 32 invites let expectedIds = generatedInvites.map(generatedInvite => generatedInvite._id); From ec9d0fd278da22487b282bc8cbddca4959343095 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 16 Jan 2016 18:13:11 -0600 Subject: [PATCH 361/976] tests: Set up placeholder file for api test helpers --- test/helpers/api-v3-integration.helper.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 test/helpers/api-v3-integration.helper.js diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js new file mode 100644 index 0000000000..a90aba70c0 --- /dev/null +++ b/test/helpers/api-v3-integration.helper.js @@ -0,0 +1 @@ +export * from './api-integration/v3'; From a44cf5e0fc01c882c3bed32c6ecf69e47dacc8b4 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 16 Jan 2016 19:27:03 -0600 Subject: [PATCH 362/976] lint: Fix linting erros in test helpers --- test/helpers/api-integration/requester.js | 2 +- test/helpers/api-integration/v3/index.js | 2 +- .../api-integration/v3/object-generators.js | 73 +++++++++---------- 3 files changed, 38 insertions(+), 39 deletions(-) diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index a9ffebabbf..a55da4d3d8 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -57,7 +57,7 @@ function _requestMaker (user, method, additionalSets) { }; } -function _parseError(err) { +function _parseError (err) { let parsedError; if (apiVersion === 'v2') { diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 5b87238075..941bbca49c 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -1,7 +1,7 @@ /* eslint-disable no-use-before-define */ // Import requester function, set it up for v2, export it -import { requester } from '../requester' +import { requester } from '../requester'; requester.setApiVersion('v3'); export { requester }; diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index 89762872c3..f14ecdad67 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -1,11 +1,10 @@ import { - each, times, } from 'lodash'; import Q from 'q'; import { v4 as generateUUID } from 'uuid'; import { ApiUser, ApiGroup } from '../api-classes'; -import { requester } from '../requester' +import { requester } from '../requester'; // Creates a new user and returns it // If you need the user to have specific requirements, @@ -19,7 +18,7 @@ export async function generateUser (update = {}) { let password = 'password'; let email = `${username}@example.com`; - let user = await requester().post( '/user/auth/local/register', { + let user = await requester().post('/user/auth/local/register', { username, email, password, @@ -65,45 +64,45 @@ export async function generateGroup (leader, details = {}, update = {}) { // leader: the leader user object // group: the group object export async function createAndPopulateGroup (settings = {}) { - let numberOfMembers = settings.members || 0; - let numberOfInvites = settings.invites || 0; - let groupDetails = settings.groupDetails; - let leaderDetails = settings.leaderDetails || { balance: 10 }; + let numberOfMembers = settings.members || 0; + let numberOfInvites = settings.invites || 0; + let groupDetails = settings.groupDetails; + let leaderDetails = settings.leaderDetails || { balance: 10 }; - let groupLeader = await generateUser(leaderDetails); - let group = await generateGroup(groupLeader, groupDetails); + let groupLeader = await generateUser(leaderDetails); + let group = await generateGroup(groupLeader, groupDetails); - let members = await Q.all( - times(numberOfMembers, () => { - return generateUser(); - }) - ); + let members = await Q.all( + times(numberOfMembers, () => { + return generateUser(); + }) + ); - let memberIds = members.map((member) => { - return member._id; - }); - memberIds.push(groupLeader._id); + let memberIds = members.map((member) => { + return member._id; + }); + memberIds.push(groupLeader._id); - await group.update({ members: memberIds }); + await group.update({ members: memberIds }); - let invitees = await Q.all( - times(numberOfInvites, () => { - return generateUser(); - }) - ); + let invitees = await Q.all( + times(numberOfInvites, () => { + return generateUser(); + }) + ); - let invitationPromises = invitees.map((invitee) => { - return groupLeader.post(`/groups/${group._id}/invite`, { - uuids: [invitee._id], - }); - }); + let invitationPromises = invitees.map((invitee) => { + return groupLeader.post(`/groups/${group._id}/invite`, { + uuids: [invitee._id], + }); + }); - await Q.all(invitationPromises); + await Q.all(invitationPromises); - return { - groupLeader, - group, - members, - invitees, - }; - } + return { + groupLeader, + group, + members, + invitees, + }; +} From 8de8ca7e18eb35150ee591935b70d8ae6a92ca76 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 17 Jan 2016 12:01:57 +0100 Subject: [PATCH 363/976] add getChallengeMemberProgress route and misc fixes --- common/locales/en/api-v3.json | 1 + website/src/controllers/api-v3/challenges.js | 57 +++++++++++++++++++- website/src/models/challenge.js | 4 +- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 2d00b19192..d16b388769 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -37,6 +37,7 @@ "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", + "challengeMemberNotFound": "User not found among challenge's members", "mustBeGroupMember": "Must be member of the group.", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"", diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index da7a7e8afe..d0e54667af 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -2,7 +2,10 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; -import { model as User } from '../../models/user'; +import { + model as User, + nameFields, +} from '../../models/user'; import { NotFound, NotAuthorized, @@ -135,6 +138,8 @@ api.getChallenges = { * @apiName GetChallenge * @apiGroup Challenge * + * @apiParam {UUID} challengeId The challenge _id + * * @apiSuccess {object} challenge The challenge object */ api.getChallenge = { @@ -160,6 +165,56 @@ api.getChallenge = { }, }; +/** + * @api {get} /challenges/:challengeId/members/:memberId Get a challenge member progress + * @apiVersion 3.0.0 + * @apiName GetChallenge + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * @apiParam {UUID} member The member _id + * + * @apiSuccess {object} member Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member + */ +api.getChallengeMemberProgress = { + method: 'GET', + url: '/challenges/:challengeId/members/:memberId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkQuery('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + let memberId = req.params.memberId; + + let member = await User.findById(memberId).select(`${nameFields} challenges`).exec(); + if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); + + let challenge = await Challenge.findById(challengeId).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); + + let chalTasks = Tasks.Task.find({ + userId: memberId, + 'challenge.id': challengeId, + }) + .select('-tags') // We don't want to return the tags publicly TODO same for other data? + .exec(); + + // manually call toJSON with minimize: true so empty paths aren't returned + let response = member.toJSON({minimize: true}); + response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true})); + res.respond(200, response); + }, +}; + // TODO everything here should be moved to a worker // actually even for a worker it's probably just to big and will kill mongo function _closeChal (challenge, broken = {}) { diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index a19ecd3b60..112926ef0c 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -23,7 +23,6 @@ let schema = new Schema({ groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, // TODO no update, no set? timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable? memberCount: {type: Number, default: 0}, - challengeCount: {type: Number, default: 0}, prize: {type: Number, default: 0, min: 0}, // TODO no update? }); @@ -36,12 +35,13 @@ schema.methods.hasAccess = function hasAccessToChallenge (user) { let userGroups = user.guilds.slice(0); if (user.party._id) userGroups.push(user.party._id); userGroups.push('habitrpg'); // tavern challenges - return this.leader === user._id || user.contributor.admin || userGroups.indexOf(this.groupId) !== -1; + return this.leader === user._id || userGroups.indexOf(this.groupId) !== -1; }; // Returns true if user can view the challenge // Different from hasAccess because challenges of public guilds can be viewed by everyone schema.methods.canView = function canViewChallenge (user, group) { + if (user.contributor.admin) return true; if (group.type === 'guild' && group.privacy === 'public') return true; return this.hasAccess(user); }; From ec7ed9c90e0fcad36c59e35c4b54564fd04cafa0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 17 Jan 2016 18:31:03 +0100 Subject: [PATCH 364/976] add tests for getChallengeMemberProgress route and several bug fixes --- ...enges_challengeId_members_memberId.test.js | 126 ++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 55 +------- website/src/controllers/api-v3/members.js | 52 ++++++++ website/src/controllers/api-v3/tasks.js | 6 +- website/src/models/challenge.js | 4 +- 5 files changed, 184 insertions(+), 59 deletions(-) create mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js new file mode 100644 index 0000000000..e91fef8563 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js @@ -0,0 +1,126 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /challenges/:challengeId/members/:memberId', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('validates req.params.memberId to be an UUID', async () => { + await expect(user.get(`/challenges/invalidUUID/members/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('validates req.params.memberId to be an UUID', async () => { + await expect(user.get(`/challenges/${generateUUID()}/members/invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('fails if member doesn\'t exists', async () => { + let userId = generateUUID(); + await expect(user.get(`/challenges/${generateUUID()}/members/${userId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId}), + }); + }); + + it('fails if challenge doesn\'t exists', async () => { + let member = await generateUser(); + await expect(user.get(`/challenges/${generateUUID()}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('fails if user doesn\'t have access to the challenge', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let anotherUser = await generateUser(); + let member = await generateUser(); + await expect(anotherUser.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('fails if member is not part of the challenge', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let member = await generateUser(); + await expect(user.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeMemberNotFound'), + }); + }); + + it('works with challenges belonging to a public guild', async () => { + let groupLeader = await generateUser({balance: 4}); + let group = await generateGroup(groupLeader, {type: 'guild', privacy: 'public', name: generateUUID()}); + let challenge = await groupLeader.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let taskText = 'Test Text'; + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); + + let memberProgress = await user.get(`/challenges/${challenge._id}/members/${groupLeader._id}`); + expect(memberProgress).to.have.all.keys(['_id', 'profile', 'tasks']); + expect(memberProgress.profile).to.have.all.keys(['name']); + expect(memberProgress.tasks.length).to.equal(1); + }); + + it('returns the member tasks for the challenges', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: 'Test Text'}]); + + let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`); + let chalTasks = await user.get(`/tasks/challenge/${challenge._id}`); + expect(memberProgress.tasks.length).to.equal(chalTasks.length); + expect(memberProgress.tasks[0].challenge.id).to.equal(challenge._id); + expect(memberProgress.tasks[0].challenge.taskId).to.equal(chalTasks[0]._id); + }); + + it('returns the tasks without the tags', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let taskText = 'Test Text'; + await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); + + let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`); + expect(memberProgress.tasks[0]).not.to.have.key('tags'); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index d0e54667af..879b13b433 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -2,10 +2,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; -import { - model as User, - nameFields, -} from '../../models/user'; +import { model as User } from '../../models/user'; import { NotFound, NotAuthorized, @@ -165,56 +162,6 @@ api.getChallenge = { }, }; -/** - * @api {get} /challenges/:challengeId/members/:memberId Get a challenge member progress - * @apiVersion 3.0.0 - * @apiName GetChallenge - * @apiGroup Challenge - * - * @apiParam {UUID} challengeId The challenge _id - * @apiParam {UUID} member The member _id - * - * @apiSuccess {object} member Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member - */ -api.getChallengeMemberProgress = { - method: 'GET', - url: '/challenges/:challengeId/members/:memberId', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - req.checkQuery('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let challengeId = req.params.challengeId; - let memberId = req.params.memberId; - - let member = await User.findById(memberId).select(`${nameFields} challenges`).exec(); - if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); - - let challenge = await Challenge.findById(challengeId).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); - if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); - - let chalTasks = Tasks.Task.find({ - userId: memberId, - 'challenge.id': challengeId, - }) - .select('-tags') // We don't want to return the tags publicly TODO same for other data? - .exec(); - - // manually call toJSON with minimize: true so empty paths aren't returned - let response = member.toJSON({minimize: true}); - response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true})); - res.respond(200, response); - }, -}; - // TODO everything here should be moved to a worker // actually even for a worker it's probably just to big and will kill mongo function _closeChal (challenge, broken = {}) { diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 5e2cb33d35..62df59b651 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -10,6 +10,7 @@ import { model as Challenge } from '../../models/challenge'; import { NotFound, } from '../../libs/api-v3/errors'; +import * as Tasks from '../../models/task'; let api = {}; @@ -174,4 +175,55 @@ api.getMembersForChallenge = { handler: _getMembersForItem('challenge-members'), }; +/** + * @api {get} /challenges/:challengeId/members/:memberId Get a challenge member progress + * @apiVersion 3.0.0 + * @apiName GetChallenge + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * @apiParam {UUID} member The member _id + * + * @apiSuccess {object} member Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member + */ +api.getChallengeMemberProgress = { + method: 'GET', + url: '/challenges/:challengeId/members/:memberId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + let memberId = req.params.memberId; + + let member = await User.findById(memberId).select(`${nameFields} challenges`).exec(); + if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); + + let challenge = await Challenge.findById(challengeId).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); + + let chalTasks = await Tasks.Task.find({ + userId: memberId, + 'challenge.id': challengeId, + }) + .select('-tags') // We don't want to return the tags publicly TODO same for other data? + .exec(); + + // manually call toJSON with minimize: true so empty paths aren't returned + let response = member.toJSON({minimize: true}); + delete response.challenges; + response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true})); + res.respond(200, response); + }, +}; + export default api; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index c2498b2a94..e6a420b91a 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -84,10 +84,10 @@ api.createUserTasks = { */ api.createChallengeTasks = { method: 'POST', - url: '/tasks/challenge/:challengeId', + url: '/tasks/challenge/:challengeId', // TODO should be /tasks/challengeS/:challengeId ? plural? middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); let reqValidationErrors = req.validationErrors(); if (reqValidationErrors) throw reqValidationErrors; @@ -188,7 +188,7 @@ api.getChallengeTasks = { url: '/tasks/challenge/:challengeId', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 112926ef0c..8234d0b7f3 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -60,7 +60,7 @@ schema.methods.canModify = function canModifyChallenge (user) { function _syncableAttrs (task) { let t = task.toObject(); // lodash doesn't seem to like _.omit on Document // only sync/compare important attrs - let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt? + let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt? if (t.type !== 'reward') omitAttrs.push('value'); return _.omit(t, omitAttrs); } @@ -168,7 +168,7 @@ schema.methods.addTasks = async function challengeAddTasks (tasks) { tasksOrderList.$each.unshift(userTask._id); } - toSave.push(userTask); + toSave.push(userTask.save()); }); // Update the user From 5d803fde56bfc06ad144a09558a871e7aca44ff9 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 17 Jan 2016 11:43:31 -0600 Subject: [PATCH 365/976] tests(api): Adjust v3 api-integration helper to export all generate methods --- test/helpers/api-integration/v3/index.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 941bbca49c..0c440ade26 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -7,8 +7,4 @@ export { requester }; export { translate } from '../translate'; export { checkExistence, resetHabiticaDB } from '../mongo'; -export { - generateUser, - generateGroup, - createAndPopulateGroup, -} from './object-generators'; +export * from './object-generators'; From b75adb4f3b30638071a7e42c2fd2561826118b02 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 17 Jan 2016 13:22:39 -0600 Subject: [PATCH 366/976] tests(api): Pull in theunknown's changes to createAndPopulateGroup https://github.com/KristianTashkov/habitrpg/commit/bf6814265c34b52b6efa565c60f3bb43da814164 --- .../api-integration/v3/object-generators.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index f14ecdad67..81bb855d56 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -73,17 +73,20 @@ export async function createAndPopulateGroup (settings = {}) { let group = await generateGroup(groupLeader, groupDetails); let members = await Q.all( - times(numberOfMembers, () => { - return generateUser(); + times(numberOfMembers, async () => { + let user = await generateUser(); + + if (group.type === 'party') { + await user.update({ 'party._id': group._id}); + } else { + await user.update({ guilds: [group._id] }); + } + + return user; }) ); - let memberIds = members.map((member) => { - return member._id; - }); - memberIds.push(groupLeader._id); - - await group.update({ members: memberIds }); + group.update({ memberCount: numberOfMembers + 1}); let invitees = await Q.all( times(numberOfInvites, () => { From b60e0a4c64b22063990c3a5435929f4d8ff44c8c Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 17 Jan 2016 13:32:31 -0600 Subject: [PATCH 367/976] Added initial get groups tests --- .../v3/integration/groups/GET-groups.test.js | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 test/api/v3/integration/groups/GET-groups.test.js diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js new file mode 100644 index 0000000000..7f86480187 --- /dev/null +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -0,0 +1,99 @@ +import { + generateUser, + resetHabiticaDB, + generateGroup, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET /groups', () => { + let user; + const NUMBER_OF_PUBLIC_GUILDS = 3; + const NUMBER_OF_USERS_PRIVATE_GUILDS = 1; + const NUMBER_OF_GROUPS_USER_CAN_VIEW = 5; + + before(async () => { + await resetHabiticaDB(); + + let leader = await generateUser({ balance: 10 }); + user = await generateUser({balance: 4}); + + let publicGuildUserIsMemberOf = await generateGroup(leader, { + name: 'public guild - is member', + type: 'guild', + privacy: 'public', + }); + await leader.post(`/groups/${publicGuildUserIsMemberOf._id}/invite`, { uuids: [user._id]}); + await user.post(`/groups/${publicGuildUserIsMemberOf._id}/join`); + + await generateGroup(leader, { + name: 'public guild - is not member', + type: 'guild', + privacy: 'public', + }); + + let privateGuildUserIsMemberOf = await generateGroup(leader, { + name: 'private guild - is member', + type: 'guild', + privacy: 'private', + }); + await leader.post(`/groups/${privateGuildUserIsMemberOf._id}/invite`, { uuids: [user._id]}); + await user.post(`/groups/${privateGuildUserIsMemberOf._id}/join`); + + await generateGroup(leader, { + name: 'private guild - is not member', + type: 'guild', + privacy: 'private', + }); + + await generateGroup(leader, { + name: 'party - is not member', + type: 'party', + privacy: 'private', + }); + + await user.post('/groups', { + name: 'party - is member', + type: 'party', + privacy: 'private', + }); + }); + + it('returns error when no query passed in', async () => { + await expect(user.get('/groups')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns only the tavern when tavern passed in as query', async () => { + await expect(user.get('/groups?type=tavern')) + .to.eventually.have.a.lengthOf(1) + .and.to.have.deep.property('[0]') + .and.to.have.property('_id', 'habitrpg'); + }); + + it('returns only the user\'s party when party passed in as query', async () => { + await expect(user.get('/groups?type=party')) + .to.eventually.have.a.lengthOf(1) + .and.to.have.deep.property('[0]') + .and.to.have.property('leader', user._id); + }); + + it('returns all public guilds when publicGuilds passed in as query', async () => { + await expect(user.get('/groups?type=publicGuilds')) + .to.eventually.have.a.lengthOf(NUMBER_OF_PUBLIC_GUILDS); + }); + + it('returns all private guilds user is a part of when privateGuilds passed in as query', async () => { + await expect(user.get('/groups?type=privateGuilds')) + .to.eventually.have.a.lengthOf(NUMBER_OF_USERS_PRIVATE_GUILDS); + }); + + it('returns a list of groups user has access to', async () => { + let groups = await user.get('/groups?type=privateGuilds,publicGuilds,party'); + + await expect(groups.length) + .to.eql(NUMBER_OF_GROUPS_USER_CAN_VIEW); + }); +}); From 81e6172fb1d24f59d87e439a18d74474201b711b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 17 Jan 2016 13:42:09 -0600 Subject: [PATCH 368/976] tests: Tighten up generateGroup members helper --- .../api-integration/v3/object-generators.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index 81bb855d56..1758998788 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -72,17 +72,14 @@ export async function createAndPopulateGroup (settings = {}) { let groupLeader = await generateUser(leaderDetails); let group = await generateGroup(groupLeader, groupDetails); + const groupMembershipTypes = { + party: { 'party._id': group._id}, + guild: { guilds: [group._id] }, + }; + let members = await Q.all( - times(numberOfMembers, async () => { - let user = await generateUser(); - - if (group.type === 'party') { - await user.update({ 'party._id': group._id}); - } else { - await user.update({ guilds: [group._id] }); - } - - return user; + times(numberOfMembers, () => { + return generateUser(groupMembershipTypes[group.type]); }) ); From 7b5945525280479e6ec741d1928a326b7bdf5d45 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 17 Jan 2016 13:58:46 -0600 Subject: [PATCH 369/976] tests(api): Add generateChallenge helper --- ...GET-challenges_challengeId_members.test.js | 34 +++++-------------- ...enges_challengeId_members_memberId.test.js | 31 ++++------------- .../api-integration/v3/object-generators.js | 22 +++++++++++- 3 files changed, 35 insertions(+), 52 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js index 318181b66e..ae037b53e4 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js @@ -1,6 +1,7 @@ import { generateUser, generateGroup, + generateChallenge, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -29,13 +30,10 @@ describe('GET /challenges/:challengeId/members', () => { }); it('fails if user doesn\'t have access to the challenge', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let group = await generateGroup(user); + let challenge = await generateChallenge(user, group); let anotherUser = await generateUser(); + await expect(anotherUser.get(`/challenges/${challenge._id}/members`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -46,11 +44,7 @@ describe('GET /challenges/:challengeId/members', () => { it('works with challenges belonging to public guild', async () => { let leader = await generateUser({balance: 4}); let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); - let challenge = await leader.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(leader, group); let res = await user.get(`/challenges/${challenge._id}/members`); expect(res[0]).to.eql({ _id: leader._id, @@ -63,11 +57,7 @@ describe('GET /challenges/:challengeId/members', () => { it('populates only some fields', async () => { let anotherUser = await generateUser({balance: 3}); let group = await generateGroup(anotherUser, {type: 'guild', privacy: 'public', name: generateUUID()}); - let challenge = await anotherUser.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(anotherUser, group); let res = await user.get(`/challenges/${challenge._id}/members`); expect(res[0]).to.eql({ _id: anotherUser._id, @@ -79,11 +69,7 @@ describe('GET /challenges/:challengeId/members', () => { it('returns only first 30 members', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); let usersToGenerate = []; for (let i = 0; i < 31; i++) { @@ -101,11 +87,7 @@ describe('GET /challenges/:challengeId/members', () => { it('supports using req.query.lastId to get more members', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); let usersToGenerate = []; for (let i = 0; i < 57; i++) { diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js index e91fef8563..315d7bef2c 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js @@ -1,5 +1,6 @@ import { generateUser, + generateChallenge, generateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; @@ -48,11 +49,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { it('fails if user doesn\'t have access to the challenge', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); let anotherUser = await generateUser(); let member = await generateUser(); await expect(anotherUser.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ @@ -64,11 +61,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { it('fails if member is not part of the challenge', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); let member = await generateUser(); await expect(user.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ code: 404, @@ -80,11 +73,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { it('works with challenges belonging to a public guild', async () => { let groupLeader = await generateUser({balance: 4}); let group = await generateGroup(groupLeader, {type: 'guild', privacy: 'public', name: generateUUID()}); - let challenge = await groupLeader.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(groupLeader, group); let taskText = 'Test Text'; await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); @@ -96,11 +85,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { it('returns the member tasks for the challenges', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: 'Test Text'}]); let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`); @@ -112,11 +97,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { it('returns the tasks without the tags', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); let taskText = 'Test Text'; await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index 1758998788..b7785e664f 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -3,7 +3,7 @@ import { } from 'lodash'; import Q from 'q'; import { v4 as generateUUID } from 'uuid'; -import { ApiUser, ApiGroup } from '../api-classes'; +import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes'; import { requester } from '../requester'; // Creates a new user and returns it @@ -106,3 +106,23 @@ export async function createAndPopulateGroup (settings = {}) { invitees, }; } + +// Generates a new challenge. Requires an ApiUser object and a +// group-like object (can just be {_id: 'your-group-id'}). The group +// will will become the group that owns the challenge. It takes an +// optional details argument for the initial challenge creation and an +// optional update argument which will update the challenge via the db +export async function generateChallenge (challengeCreator, group, details = {}, update = {}) { + details.groupId = group._id; + details.name = details.name || 'a challenge'; + details.shortName = details.shortName || 'aChallenge'; + details.prize = details.prize || 0; + details.official = details.official || false; + + let challenge = await challengeCreator.post('/challenges', details); + let apiChallenge = new ApiChallenge(challenge); + + await apiChallenge.update(update); + + return apiChallenge; +} From 417d754a0d3af0a24a65c2e371fad8a96ef76190 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 17 Jan 2016 14:02:17 -0600 Subject: [PATCH 370/976] Added intial group remove member tests --- .../POST-groups_id_removeMember.test.js | 119 ++++++++++++++++++ website/src/controllers/api-v3/groups.js | 22 +++- 2 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_id_removeMember.test.js diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js new file mode 100644 index 0000000000..c49cfe6694 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js @@ -0,0 +1,119 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:groupId/removeMember/:memberId', () => { + let leader; + let invitedUser; + let guild; + let member; + let member2; + + before(async () => { + let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'private', + }, + invites: 1, + members: 2, + }); + + guild = group; + leader = groupLeader; + invitedUser = invitees[0]; + member = members[0]; + member2 = members[1]; + }); + + context('All Groups', () => { + it('returns an error when user is not member of the group', async () => { + let nonMember = await generateUser(); + + expect(nonMember.post(`/groups/${guild._id}/removeMember/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + type: 'NotAuthorized', + message: t('onlyLeaderCanRemoveMember'), + }); + }); + + it('returns an error when user is a non-leader member of a group', async () => { + expect(member2.post(`/groups/${guild._id}/removeMember/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + type: 'NotAuthorized', + message: t('onlyLeaderCanRemoveMember'), + }); + }); + + it('does not allow leader to remove themselves', async () => { + expect(leader.post(`/groups/${guild._id}/removeMember/${leader._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + text: t('messageGroupCannotRemoveSelf'), + }); + }); + }); + + context('Guilds', () => { + it('can remove other members', async () => { + await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); + + let memberRemoved = await member.get('/user'); + + expect(_.findIndex(memberRemoved.guilds, {id: guild._id})).eql(-1); + }); + + it('can remove other invites', async () => { + await leader.post(`/groups/${guild._id}/removeMember/${invitedUser._id}`); + + let invitedUserWithoutInvite = await invitedUser.get('/user'); + + expect(_.findIndex(invitedUserWithoutInvite.invitations.guilds, {id: guild._id})).eql(-1); + }); + }); + + context('Party', () => { + let party; + let partyleader; + let partyInvitedUser; + let partyMember; + + before(async () => { + let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + privacy: 'private', + }, + invites: 1, + members: 1, + }); + + party = group; + partyleader = groupLeader; + partyInvitedUser = invitees[0]; + partyMember = members[0]; + }); + + it('can remove other members', async () => { + await partyleader.post(`/groups/${party._id}/removeMember/${partyMember._id}`); + + let memberRemoved = await partyMember.get('/user'); + + expect(memberRemoved.party._id).eql(undefined); + }); + + it('can remove other invites', async () => { + await partyleader.post(`/groups/${party._id}/removeMember/${partyInvitedUser._id}`); + + let invitedUserWithoutInvite = await partyInvitedUser.get('/user'); + + expect(_.findIndex(invitedUserWithoutInvite.invitations.party, {id: party._id})).eql(-1); + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8203e00b1b..7bbb2ed1ca 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -347,15 +347,27 @@ api.removeGroupMember = { let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat if (!group) throw new NotFound(res.t('groupNotFound')); - let uuid = req.query.memberId; + let uuid = req.params.memberId; if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself')); let member = await User.findOne({_id: uuid}).select('party guilds invitations newMessages').exec(); + // We're removing the user from a guild or a party? is the user invited only? - let isInGroup = member.party._id === group._id ? 'party' : member.guilds.indexOf(group._id) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary - let isInvited = member.invitations.party.id === group._id ? 'party' : _.findIndex(member.invitations.guilds, {id: group._id}) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary + let isInGroup; + if (member.party._id === group._id) { + isInGroup = 'party'; + } else if (member.guilds.indexOf(group._id) !== -1) { + isInGroup = 'guild'; + } + + let isInvited; + if (member.invitations.party && member.invitations.party.id === group._id) { + isInvited = 'party'; + } else if (_.findIndex(member.invitations.guilds, {id: group._id}) !== -1) { + isInvited = 'guild'; + } if (isInGroup) { group.memberCount -= 1; @@ -371,7 +383,9 @@ api.removeGroupMember = { if (isInGroup === 'guild') _.pull(member.guilds, group._id); if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? - member.newMessages.group._id = undefined; + if (member.newMessages.group) { + member.newMessages.group._id = undefined; + } if (group.quest && group.quest.active && group.quest.leader === member._id) { member.items.quests[group.quest.key] += 1; // TODO why this? From c5947ca9c4a719f133ab7213a45d4dc226d47d8c Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 17 Jan 2016 14:20:51 -0600 Subject: [PATCH 371/976] Updated party test to conform to new leader populate --- test/api/v3/integration/groups/GET-groups.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js index 7f86480187..1994d31dd1 100644 --- a/test/api/v3/integration/groups/GET-groups.test.js +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -77,7 +77,7 @@ describe('GET /groups', () => { await expect(user.get('/groups?type=party')) .to.eventually.have.a.lengthOf(1) .and.to.have.deep.property('[0]') - .and.to.have.property('leader', user._id); + .and.to.have.property('leader._id', user._id); }); it('returns all public guilds when publicGuilds passed in as query', async () => { @@ -93,7 +93,7 @@ describe('GET /groups', () => { it('returns a list of groups user has access to', async () => { let groups = await user.get('/groups?type=privateGuilds,publicGuilds,party'); - await expect(groups.length) + expect(groups.length) .to.eql(NUMBER_OF_GROUPS_USER_CAN_VIEW); }); }); From f37b5a7fac74b3ddc0ca3bd22faaa65899ec1997 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Sun, 17 Jan 2016 17:06:10 +0200 Subject: [PATCH 372/976] Challenges join route and tests --- common/locales/en/api-v3.json | 1 + .../POST-challenges_challengeId_join.test.js | 110 ++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 39 ++++++- website/src/models/challenge.js | 9 +- 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index d16b388769..c10fd1647a 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -54,6 +54,7 @@ "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", + "userAlreadyInChallenge": "User is already participating in this challenge.", "invalidTasksOwner": "\"tasksOwner\" must be \"user\" or \"challenge\".", "partyMustbePrivate": "Parties must be private", "userAlreadyInGroup": "User already in that group.", diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js new file mode 100644 index 0000000000..3eda5716cd --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js @@ -0,0 +1,110 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges/:challengeId/join', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser({ balance: 1}); + + await expect(user.post('/challenges/test/join')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser({ balance: 1}); + + await expect(user.post(`/challenges/${generateUUID()}/join`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Joining a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let authorizedUser; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 1, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + authorizedUser = populatedGroup.members[0]; + + challenge = await generateChallenge(groupLeader, group); + }); + + it('returns an error when user doesn\'t have permissions to access the challenge', async () => { + let unauthorizedUser = await generateUser(); + + await expect(unauthorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('adds challenge to user challenges', async () => { + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await authorizedUser.sync(); + + expect(authorizedUser).to.have.property('challenges').to.include(challenge._id); + }); + + it('returns error when user has already joined the challenge', async () => { + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await expect(authorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInChallenge'), + }); + }); + + it('increases memberCount of challenge', async () => { + let oldMemberCount = challenge.memberCount; + + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + + expect(challenge).to.have.property('memberCount', oldMemberCount + 1); + }); + + it('syncs challenge tasks to joining user', async () => { + let taskText = 'A challenge task text'; + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await authorizedUser.post(`/challenges/${challenge._id}/join`); + let tasks = await authorizedUser.get('/tasks/user'); + let tasksTexts = tasks.map((task) => { + return task.text; + }); + + expect(tasksTexts).to.include(taskText); + }); + + it('adds challenge tag to user tags', async () => { + let userTagsLength = (await authorizedUser.get('/tags')).length; + + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await expect(authorizedUser.get('/tags')).to.eventually.have.length(userTagsLength + 1); + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 879b13b433..f0c682cf05 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -1,4 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; @@ -92,6 +93,42 @@ api.createChallenge = { }, }; +/** + * @api {post} /challenges/:challengeId/join Joins a challenge + * @apiVersion 3.0.0 + * @apiName JoinChallenge + * @apiGroup Challenge + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The challenge the user joined + */ +api.joinChallenge = { + method: 'POST', + url: '/challenges/:challengeId/join', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let challenge = await Challenge.findOne({ _id: req.params.challengeId }); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + + if (!challenge.hasAccess(user)) throw new NotFound(res.t('challengeNotFound')); + + if (_.contains(user.challenges, challenge._id)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); + + challenge.memberCount += 1; + + // Add all challenge's tasks to user's tasks and save the challenge + await Q.all([challenge.syncToUser(user), challenge.save()]); + res.respond(200, challenge); + }, +}; + /** * @api {get} /challenges Get challenges for a user * @apiVersion 3.0.0 @@ -144,7 +181,7 @@ api.getChallenge = { url: '/challenges/:challengeId', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 8234d0b7f3..39a7caa21b 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -22,7 +22,7 @@ let schema = new Schema({ leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, // TODO no update, no set? timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable? - memberCount: {type: Number, default: 0}, + memberCount: {type: Number, default: 1}, prize: {type: Number, default: 0, min: 0}, // TODO no update? }); @@ -65,6 +65,13 @@ function _syncableAttrs (task) { return _.omit(t, omitAttrs); } +schema.methods.hasAccess = function hasAccessToChallenge (user) { + let userGroups = user.guilds.slice(0); + if (user.party._id) userGroups.push(user.party._id); + userGroups.push('habitrpg'); // tavern challenges + return this.leader === user._id || userGroups.indexOf(this.groupId) !== -1; +}; + // Sync challenge to user, including tasks and tags. // Used when user joins the challenge or to force sync. schema.methods.syncToUser = async function syncChallengeToUser (user) { From d93cabafb950b0e2d2c1e548cfa45bcb322118ef Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 16 Jan 2016 14:06:16 -0600 Subject: [PATCH 373/976] Added initial tests for createChallengeTasks and fixed some issues in the challenge and challengeTask routes --- .../POST-tasks_challenge_id.test.js | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js new file mode 100644 index 0000000000..62899ffb59 --- /dev/null +++ b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js @@ -0,0 +1,125 @@ +import { + generateUser, + generateGroup, + generateChallenge, + translate as t, +} from '../../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/challenge/:challengeId', () => { + let user; + let guild; + let challenge; + + beforeEach(async () => { + user = await generateUser({balance: 1}); + guild = await generateGroup(user); + challenge = await generateChallenge(user, guild); + }); + + it('returns error when challenge is not found', async () => { + let fakeChallengeId = generateUUID(); + + await expect(user.post(`/tasks/challenge/${fakeChallengeId}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('returns error when user does not have the challenge', async () => { + let userWithoutChallenge = await generateUser(); + + await expect(userWithoutChallenge.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('returns error when non leader tries to edit challenge', async () => { + let userThatIsNotLeaderOfChallenge = await generateUser({ + challenges: [challenge._id], + }); + + await expect(userThatIsNotLeaderOfChallenge.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyChalLeaderEditTasks'), + }); + }); + + it('creates a habit', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + let challengeWithTask = await user.get(`/challenges/${challenge._id}`); + + expect(challengeWithTask.tasksOrder.habits.indexOf(task._id)).to.be.above(-1); + expect(task.challenge.id).to.equal(challenge._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); + }); + + it('creates a todo', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test todo', + type: 'todo', + notes: 1976, + }); + let challengeWithTask = await user.get(`/challenges/${challenge._id}`); + + expect(challengeWithTask.tasksOrder.todos.indexOf(task._id)).to.be.above(-1); + expect(task.challenge.id).to.equal(challenge._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); + }); + + it('creates a daily', async () => { + let now = new Date(); + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test daily', + type: 'daily', + notes: 1976, + frequency: 'daily', + everyX: 5, + startDate: now, + }); + let challengeWithTask = await user.get(`/challenges/${challenge._id}`); + + expect(challengeWithTask.tasksOrder.dailys.indexOf(task._id)).to.be.above(-1); + expect(task.challenge.id).to.equal(challenge._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + expect(task.frequency).to.eql('daily'); + expect(task.everyX).to.eql(5); + expect(new Date(task.startDate)).to.eql(now); + }); +}); From ee854229c7b794043749912cd2a305ae31f99003 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 18 Jan 2016 15:01:46 +0100 Subject: [PATCH 374/976] fix dateCompleted not remove from un-completed todos and fix tasks test --- common/script/api-v3/scoreTask.js | 2 +- .../POST-tasks_id_score_direction.test.js | 2 + .../integration/tasks/POST-tasks_user.test.js | 3 - website/src/controllers/api-v3/tasks.js | 61 +------------------ website/src/models/group.js | 2 +- 5 files changed, 5 insertions(+), 65 deletions(-) diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js index 25c59393b6..91db45ce53 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/api-v3/scoreTask.js @@ -224,7 +224,7 @@ export default function scoreTask (options = {}, req = {}) { if (cron) { // don't touch stats on cron delta += _changeTaskValue(user, task, direction, times, cron); } else { - if (direction === 'up') task.dateCompleted = new Date(); + task.dateCompleted = direction === 'up' ? new Date() : undefined; delta += _changeTaskValue(user, task, direction, times, cron); if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 228f8348f8..c1204a7341 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -46,6 +46,7 @@ describe('POST /tasks/:id/score/:direction', () => { let task = await user.get(`/tasks/${todo._id}`); expect(task.completed).to.equal(true); + expect(task.dateCompleted).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type }); it('moves completed todos out of user.tasksOrder.todos', async () => { @@ -81,6 +82,7 @@ describe('POST /tasks/:id/score/:direction', () => { let updatedTask = await user.get(`/tasks/${todo._id}`); expect(updatedTask.completed).to.equal(false); + expect(updatedTask.dateCompleted).to.be.a('undefined'); }); it('scores up todo even if it is already completed'); // Yes? diff --git a/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js index 906bc8feed..afae951fb1 100644 --- a/test/api/v3/integration/tasks/POST-tasks_user.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -95,9 +95,6 @@ describe('POST /tasks/user', () => { expect(updatedTasks).to.eql(originalTasks); }); - - let updatedTasks = await user.get('/tasks'); - expect(updatedTasks).to.eql(originalTasks); }); it('automatically sets "task.userId" to user\'s uuid', async () => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e6a420b91a..7f2a488735 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -153,7 +153,7 @@ async function _getTasks (req, res, user, challenge) { * @apiGroup Task * * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks - * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". + * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". * * @apiSuccess {Array} tasks An array of task objects */ @@ -178,7 +178,6 @@ api.getUserTasks = { * @apiGroup Task * * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks. - * * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks * * @apiSuccess {Array} tasks An array of task objects @@ -208,64 +207,6 @@ api.getChallengeTasks = { }, }; -/** - * @api {get} /tasks/:tasksOwner/:challengeId Get an user's tasks - * @apiVersion 3.0.0 - * @apiName GetTasks - * @apiGroup Task - * - * @apiParam {string="user","challenge"} tasksOwner Query parameter to return tasks belonging to a challenge (specifying the "challengeId" parameter) or to the autheticated user. - * @apiParam {UUID} challengeId Optional query parameter. If "tasksOwner" is "challenge" then required to specify the challenge id. - * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks - * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". - * - * @apiSuccess {Array} tasks An array of task objects - */ -api.getTasks = { - method: 'GET', - url: '/tasks', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let challengeId = req.query.challengeId; - let challenge; - - let query = challenge ? {'challenge.id': challengeId, userId: {$exists: false}} : {userId: user._id}; - let type = req.query.type; - - if (type) { - query.type = type; - if (type === 'todo') query.completed = false; // Exclude completed todos - } else { - query.$or = [ // Exclude completed todos - {type: 'todo', completed: false}, - {type: {$in: ['habit', 'daily', 'reward']}}, - ]; - } - - if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { - if (challengeId) throw new BadRequest(res.t('noCompletedTodosChallenge')); - - let queryCompleted = Tasks.Task.find({ - type: 'todo', - completed: true, - }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos - dateCompleted: 1, - }); - - let results = await Q.all([ - queryCompleted.exec(), - Tasks.Task.find(query).exec(), - ]); - - res.respond(200, results[1].concat(results[0])); - } else { - let tasks = await Tasks.Task.find(query).exec(); - res.respond(200, tasks); - } - }, -}; - /** * @api {get} /task/:taskId Get a task given its id * @apiVersion 3.0.0 diff --git a/website/src/models/group.js b/website/src/models/group.js index 85396fc693..5ff1eda3dd 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -492,7 +492,7 @@ schema.methods.leave = function leaveGroup (user, keep) { if (group.type === 'guild') { _.pull(user.guilds, group._id); } else { - user.party._id = undefined; + user.party._id = undefined; // TODO remove quest information too? } // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) From aa68a5ed38ca040f4307d83739202dcfc03ea865 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 18 Jan 2016 10:13:09 -0600 Subject: [PATCH 375/976] fix: Update vagrant to use npm 3 --- vagrant_scripts/install_node.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vagrant_scripts/install_node.sh b/vagrant_scripts/install_node.sh index 8de976aa62..a420e7bdf1 100755 --- a/vagrant_scripts/install_node.sh +++ b/vagrant_scripts/install_node.sh @@ -16,7 +16,7 @@ nvm use nvm alias default current echo Update npm... -npm install -g npm@2 +npm install -g npm@3 echo Installing global modules... npm install -g gulp bower grunt-cli mocha From be55176954a76d046e2e988d38c1be802f677022 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 19 Jan 2016 08:13:24 -0600 Subject: [PATCH 376/976] Ensured tavern is not returned twice and removed leader population --- test/api/v3/integration/groups/GET-groups.test.js | 9 +++------ website/src/controllers/api-v3/groups.js | 8 +++++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js index 1994d31dd1..b37b87f890 100644 --- a/test/api/v3/integration/groups/GET-groups.test.js +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -76,8 +76,7 @@ describe('GET /groups', () => { it('returns only the user\'s party when party passed in as query', async () => { await expect(user.get('/groups?type=party')) .to.eventually.have.a.lengthOf(1) - .and.to.have.deep.property('[0]') - .and.to.have.property('leader._id', user._id); + .and.to.have.deep.property('[0]'); }); it('returns all public guilds when publicGuilds passed in as query', async () => { @@ -91,9 +90,7 @@ describe('GET /groups', () => { }); it('returns a list of groups user has access to', async () => { - let groups = await user.get('/groups?type=privateGuilds,publicGuilds,party'); - - expect(groups.length) - .to.eql(NUMBER_OF_GROUPS_USER_CAN_VIEW); + await expect(user.get('/groups?type=privateGuilds,publicGuilds,party,tavern')) + .to.eventually.have.lengthOf(NUMBER_OF_GROUPS_USER_CAN_VIEW); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index d7d3eedbd3..14754ffaf6 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -86,14 +86,14 @@ api.getGroups = { // TODO validate types are acceptable? probably not necessary let types = req.query.type.split(','); - let groupFields = 'name description memberCount balance leader'; + let groupFields = 'name description memberCount balance'; let sort = '-memberCount'; let queries = []; types.forEach(type => { switch (type) { case 'party': - queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields, populateLeader: true})); + queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields})); break; case 'privateGuilds': queries.push(Group.find({ @@ -109,7 +109,9 @@ api.getGroups = { }).select(groupFields).sort(sort).exec()); // TODO use lean? break; case 'tavern': - queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields, populateLeader: true})); + if (types.indexOf('publicGuilds') === -1) { + queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields})); + } break; } }); From ef6afaedb4b4d6839f1cd5fdcadeb35e0b287096 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 12 Jan 2016 18:00:37 -0600 Subject: [PATCH 377/976] Added intitial group leave tests Fixed test readability, updated party test, and updated challenge update code when leaving group Updated library, added group existance check, and reset full party Updated syntax, added new userUnlinkChallenges, and added some initial testing for challenges Added challenge tasks tests Added try/catch to group remove, add more party tests, fixed broken challenge test, removed useless return value Added public guild tests, added more tests to party, and abstracted remove invitations logic Closes #6506 --- .../groups/POST-groups_groupId_leave.js | 195 ++++++++++++++++++ test/helpers/api-integration/v3/index.js | 8 + website/src/controllers/api-v3/groups.js | 2 + website/src/controllers/api-v3/tasks.js | 1 + website/src/models/group.js | 149 ++++++------- website/src/models/user.js | 13 +- 6 files changed, 280 insertions(+), 88 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_leave.js diff --git a/test/api/v3/integration/groups/POST-groups_groupId_leave.js b/test/api/v3/integration/groups/POST-groups_groupId_leave.js new file mode 100644 index 0000000000..59031300ba --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_leave.js @@ -0,0 +1,195 @@ +import { + generateChallenge, + checkExistence, + createAndPopulateGroup, + sleep, +} from '../../../../helpers/api-v3-integration.helper'; +import { + each, +} from 'lodash'; + +describe('POST /groups/:groupId/leave', () => { + let typesOfGroups = { + 'public guild': { type: 'guild', privacy: 'public' }, + 'private guild': { type: 'guild', privacy: 'private' }, + party: { type: 'party', privacy: 'private' }, + }; + + each(typesOfGroups, (groupDetails, groupType) => { + context(`Leaving a ${groupType}`, () => { + let groupToLeave; + let leader; + let member; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails, + members: 1, + }); + + groupToLeave = group; + leader = groupLeader; + member = members[0]; + }); + + it(`lets user leave a ${groupType}`, async () => { + await member.post(`/groups/${groupToLeave._id}/leave`); + + let userThatLeftGroup = await member.get('/user'); + + expect(userThatLeftGroup.guilds).to.be.empty; + expect(userThatLeftGroup.party._id).to.not.exist; + }); + + it(`sets a new group leader when leader leaves a ${groupType}`, async () => { + await leader.post(`/groups/${groupToLeave._id}/leave`); + + let groupToLeaveWithNewLeader = await member.get(`/groups/${groupToLeave._id}`); + + expect(groupToLeaveWithNewLeader.leader._id).to.equal(member._id); + }); + + context('With challenges', () => { + let challenge; + + beforeEach(async () => { + challenge = await generateChallenge(leader, groupToLeave); + + await leader.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + }); + + await sleep(0.5); + }); + + it('removes all challenge tasks when keep parameter is set to remove', async () => { + await leader.post(`/groups/${groupToLeave._id}/leave?keep=remove-all`); + + let userWithoutChallengeTasks = await leader.get('/user'); + + expect(userWithoutChallengeTasks.challenges).to.not.include(challenge._id); + expect(userWithoutChallengeTasks.tasksOrder.habits).to.be.empty; + }); + + it('keeps all challenge tasks when keep parameter is not set', async () => { + await leader.post(`/groups/${groupToLeave._id}/leave`); + + let userWithChallengeTasks = await leader.get('/user'); + + expect(userWithChallengeTasks.challenges).to.not.include(challenge._id); + // @TODO find elegant way to assert against the task existing + expect(userWithChallengeTasks.tasksOrder.habits).to.not.be.empty; + }); + }); + + it('prevents quest leader from leaving a groupToLeave'); + it('prevents a user from leaving during an active quest'); + }); + }); + + context('Leaving a group as the last member', () => { + context('private guild', () => { + let privateGuild; + let leader; + let invitedUser; + + beforeEach(async () => { + let { group, groupLeader, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Private Guild', + type: 'guild', + }, + invites: 1, + }); + + privateGuild = group; + leader = groupLeader; + invitedUser = invitees[0]; + }); + + it('removes a group when the last member leaves', async () => { + await leader.post(`/groups/${privateGuild._id}/leave`); + + await expect(checkExistence('groups', privateGuild._id)).to.eventually.equal(false); + }); + + it('removes invitations when the last member leaves', async () => { + await leader.post(`/groups/${privateGuild._id}/leave`); + + let userWithoutInvitation = await invitedUser.get('/user'); + + expect(userWithoutInvitation.invitations.guilds).to.be.empty; + }); + }); + + context('public guild', () => { + let publicGuild; + let leader; + let invitedUser; + + beforeEach(async () => { + let { group, groupLeader, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Public Guild', + type: 'guild', + privacy: 'public', + }, + invites: 1, + }); + + publicGuild = group; + leader = groupLeader; + invitedUser = invitees[0]; + }); + + it('keeps the group when the last member leaves', async () => { + await leader.post(`/groups/${publicGuild._id}/leave`); + + await expect(checkExistence('groups', publicGuild._id)).to.eventually.equal(true); + }); + + it('keeps the invitations when the last member leaves a public guild', async () => { + await leader.post(`/groups/${publicGuild._id}/leave`); + + let userWithoutInvitation = await invitedUser.get('/user'); + + expect(userWithoutInvitation.invitations.guilds).to.not.be.empty; + }); + }); + + context('party', () => { + let party; + let leader; + let invitedUser; + + beforeEach(async () => { + let { group, groupLeader, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + }, + invites: 1, + }); + + party = group; + leader = groupLeader; + invitedUser = invitees[0]; + }); + + it('removes a group when the last member leaves a party', async () => { + await leader.post(`/groups/${party._id}/leave`); + + await expect(checkExistence('party', party._id)).to.eventually.equal(false); + }); + + it('removes invitations when the last member leaves a party', async () => { + await leader.post(`/groups/${party._id}/leave`); + + let userWithoutInvitation = await invitedUser.get('/user'); + + expect(userWithoutInvitation.invitations.party).to.be.empty; + }); + }); + }); +}); diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 0c440ade26..880f88f41a 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -8,3 +8,11 @@ export { requester }; export { translate } from '../translate'; export { checkExistence, resetHabiticaDB } from '../mongo'; export * from './object-generators'; + +export async function sleep (seconds) { + let milliseconds = seconds * 1000; + + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index d7d3eedbd3..05a68831e9 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -251,6 +251,8 @@ api.joinGroup = { if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader + group.memberCount += 1; + let promises = [group.save(), user.save()]; if (group.type === 'party' && inviter) { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 7f2a488735..acdd456beb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -102,6 +102,7 @@ api.createChallengeTasks = { if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); let tasks = await _createTasks(req, res, user, challenge); + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); // If adding tasks to a challenge -> sync users diff --git a/website/src/models/group.js b/website/src/models/group.js index 5ff1eda3dd..8e21623c7c 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -98,30 +98,15 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { }*/ // TODO test -schema.pre('remove', true, function preRemoveGroup (next, done) { +schema.pre('remove', true, async function preRemoveGroup (next, done) { next(); let group = this; - - // Remove invitations when group is deleted - // TODO verify it works fir everything - User.find({ - // TODO id -> _id ? - [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id, - }).exec() - .then(users => { - return Q.all(users.map(user => { - if (group.type === 'party') { - user.invitations.party = {}; // TODO mark modified - } else { - let i = _.findIndex(user.invitations.guilds, {id: group._id}); - user.invitations.guilds.splice(i, 1); - } - - return user.save(); - })); - }) - .then(done) - .catch(done); + try { + await group.removeGroupInvitations(); + done(); + } catch (err) { + done(err); + } }); schema.post('remove', function postRemoveGroup (group) { @@ -150,6 +135,27 @@ schema.statics.getGroup = function getGroup (options = {}) { // TODO purge chat flags info? in tojson? }; +schema.methods.removeGroupInvitations = async function removeGroupInvitations () { + let group = this; + + let usersToRemoveInvitationsFrom = await User.find({ + // TODO id -> _id ? + [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id, + }).exec(); + + let userUpdates = usersToRemoveInvitationsFrom.map(user => { + if (group.type === 'party') { + user.invitations.party = {}; // TODO mark modified + } else { + let i = _.findIndex(user.invitations.guilds, {id: group._id}); + user.invitations.guilds.splice(i, 1); + } + return user.save(); + }); + + return Q.all(userUpdates); +}; + // Return true if user is a member of the group schema.methods.isMember = function isGroupMember (user) { if (this._id === 'habitrpg') { @@ -452,71 +458,46 @@ schema.statics.bossQuest = function bossQuest (user, progress) { }); }; -// Remove user from this group -// TODO this is highly inefficient -schema.methods.leave = function leaveGroup (user, keep) { +schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { let group = this; - return Q.all([ - // Remove user from group challenges - - // First find relevant Challenges - Challenge.find({ - _id: {$in: user.challenges}, // Challenges I am in - group: group._id, // that belong to the group I am leaving - }).then(challenges => { - // Update each challenge - return Challenge.update( - {_id: {$in: _.pluck(challenges, '_id')}}, - {$pull: {members: user._id}}, - {multi: true} - ).then(() => challenges); // pass `challenges` above to next promise - }).then(challenges => { - return Q.all(challenges.map(chal => { - let i = user.challenges.indexOf(chal._id); - if (i !== -1) user.challenges.splice(i, 1); - return user.unlinkChallengeTasks(chal._id, keep); - })); - }), - - // Update the group - (() => { - // If user is the last one in group and group is private, delete it - if (group.members.length === 1 && ( - group.type === 'party' || - group.type === 'guild' && group.privacy === 'private' - )) return group.remove(); - - let update = {}; - // otherwise just remove a member TODO create User.methods.removeFromGroup? - if (group.type === 'guild') { - _.pull(user.guilds, group._id); - } else { - user.party._id = undefined; // TODO remove quest information too? - } - - // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) - let leader = group.leader; - - if (leader === user._id || group.members.indexOf(leader) === -1) { - let seniorMember = _.find(group.members, m => m !== user._id); - - // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving - if (seniorMember) update.$set = {leader: seniorMember}; - } - - update.$inc = {memberCount: -1}; - return Q.all([ - model.update({_id: group._id}, update).exec(), // eslint-disable-line no-use-before-define - user.save(), - ]); - })(), - ]).then(() => { - firebase.removeUserFromGroup(group._id, user._id); - return; // TODO ok not to return promise? - }).catch(err => { // TODO do we have to catch err if we return the promise? - throw err; + let challenges = await Challenge.find({ + _id: {$in: user.challenges}, + groupId: group._id, }); + + let challengesToRemoveUserFrom = challenges.map(chal => { + return user.unlinkChallengeTasks(chal._id, keep); + }); + await Q.all(challengesToRemoveUserFrom); + + let promises = []; + + // If user is the last one in group and group is private, delete it + if (group.memberCount <= 1 && group.privacy === 'private') { + return await group.remove(); + } + + // otherwise just remove a member TODO create User.methods.removeFromGroup? + if (group.type === 'guild') { + promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id } }).exec()); + } else { + promises.push(User.update({_id: user._id}, {$set: {party: {} } }).exec()); + } + + // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) + let update = { memberCount: group.memberCount - 1 }; + if (group.leader === user._id) { + let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id}; + let seniorMember = await User.findOne({query, _id: {$ne: user._id}}).exec(); + + // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving + if (seniorMember) update.$set = {leader: seniorMember._id}; + } + promises.push(group.update(update).exec()); + firebase.removeUserFromGroup(group._id, user._id); + + return Q.all(promises); }; export const INVITES_LIMIT = 100; diff --git a/website/src/models/user.js b/website/src/models/user.js index 8942a8afd2..6ea4ede281 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -661,13 +661,18 @@ schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (chall 'challenge.id': challengeId, }; + let challengeIndex = user.challenges.indexOf(challengeId); + if (challengeIndex !== -1) user.challenges.splice(challengeIndex, 1); + if (keep === 'keep-all') { await Tasks.Task.update(findQuery, { $set: {challenge: {}}, // TODO what about updatedAt? }, {multi: true}).exec(); + + await user.save(); } else { // keep = 'remove-all' - let tasks = Tasks.Task.find(findQuery).select('_id type completed').exec(); - tasks = tasks.map(task => { + let tasks = await Tasks.Task.find(findQuery).select('_id type completed').exec(); + let taskPromises = tasks.map(task => { // Remove task from user.tasksOrder and delete them if (task.type !== 'todo' || !task.completed) { let list = user.tasksOrder[`${task.type}s`]; @@ -678,8 +683,8 @@ schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (chall return task.remove(); }); - tasks.push(user.save()); - await Q.all(tasks); + taskPromises.push(user.save()); + return Q.all(taskPromises); } }; From 926f22f272292ab1eee32794438dd966a4dde590 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Tue, 19 Jan 2016 20:17:25 +0200 Subject: [PATCH 378/976] Add missing tests in group join route --- .../groups/POST-groups_groupId_join.test.js | 81 +++++++++++++++---- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index b35409bb6c..eceb388ac4 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -16,7 +16,39 @@ describe('POST /group/:groupId/join', () => { }); }); - context('Accepting invitation to a private guild', () => { + context('Joining a public guild', () => { + let user, joiningUser, publicGuild; + + beforeEach(async () => { + let {group, groupLeader} = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + }); + + publicGuild = group; + user = groupLeader; + joiningUser = await generateUser(); + }); + + it('allows non-invited users to join public guilds', async () => { + await joiningUser.post(`/groups/${publicGuild._id}/join`); + + await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').to.include(publicGuild._id); + }); + + it('promotes joining member in a public empty guild to leader', async () => { + await user.post(`/groups/${publicGuild._id}/leave`); + + await joiningUser.post(`/groups/${publicGuild._id}/join`); + + await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.deep.property('leader._id', joiningUser._id); + }); + }); + + context('Joining a private guild', () => { let user, invitedUser, guild; beforeEach(async () => { @@ -44,21 +76,6 @@ describe('POST /group/:groupId/join', () => { }); }); - it('allows non-invited users to join public guilds', async () => { - let publicGuild = (await createAndPopulateGroup({ - groupDetails: { - name: 'Test Guild', - type: 'guild', - privacy: 'public', - }, - })).group; - - let joiningUser = await generateUser(); - await joiningUser.post(`/groups/${publicGuild._id}/join`); - - await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').and.to.include(publicGuild._id); - }); - context('User is invited', () => { it('allows invited user to join private guilds', async () => { await invitedUser.post(`/groups/${guild._id}/join`); @@ -74,6 +91,14 @@ describe('POST /group/:groupId/join', () => { .to.not.include({id: guild._id}); }); + it('increments memberCount when joining guilds', async () => { + let oldMemberCount = guild.memberCount; + + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(invitedUser.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + }); + it('does not give basilist quest to inviter when joining a guild', async () => { await invitedUser.post(`/groups/${guild._id}/join`); @@ -90,7 +115,7 @@ describe('POST /group/:groupId/join', () => { }); }); - context('Accepting invitation to a party', () => { + context('Joining a party', () => { let user, invitedUser, party; beforeEach(async () => { @@ -130,6 +155,14 @@ describe('POST /group/:groupId/join', () => { await expect(invitedUser.get('/user')).to.eventually.not.have.deep.property('invitations.party.id'); }); + it('increments memberCount when joining party', async () => { + let oldMemberCount = party.memberCount; + + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(invitedUser.get(`/groups/${party._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + }); + it('gives basilist quest item to the inviter when joining a party', async () => { await invitedUser.post(`/groups/${party._id}/join`); @@ -143,6 +176,20 @@ describe('POST /group/:groupId/join', () => { await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); }); + + xit('invites joining member to active quest', async () => { + // TODO start quest + + await invitedUser.post(`/groups/${party._id}/join`); + + invitedUser = await user.get('/user'); + party = await user.get(`/groups/${party._id}`); + + expect(user).to.have.deep.property('party.quest.RSVPNeeded', true); + expect(user).to.have.deep.property('party.quest.key', party.quest.key); + + expect(party.quest.members[invitedUser._id]).to.be.undefined; + }); }); }); }); From 1a31f7323828968bf634442afe7042b5ebb1f81d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 20 Jan 2016 21:35:00 +0100 Subject: [PATCH 379/976] fix query when setting new group leader --- website/src/models/group.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index 8e21623c7c..af592bb332 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -489,7 +489,8 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { let update = { memberCount: group.memberCount - 1 }; if (group.leader === user._id) { let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id}; - let seniorMember = await User.findOne({query, _id: {$ne: user._id}}).exec(); + query._id = {$ne: user._id}; + let seniorMember = await User.findOne(query).select('_id').exec(); // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving if (seniorMember) update.$set = {leader: seniorMember._id}; From 3bd806a0f0c3f6f80444fce3ef7ca66e184ab26e Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Thu, 21 Jan 2016 17:31:54 -0500 Subject: [PATCH 380/976] test(quests): accept route WIP --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupId_quests_accept.test.js | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index c10fd1647a..bc71703017 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -65,5 +65,6 @@ "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", - "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked." + "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", + "questInviteNotFound": "No quest invitation found." } diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js new file mode 100644 index 0000000000..44286e6e79 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js @@ -0,0 +1,51 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe.skip('POST /groups/:groupId/quests/accept', () => { + let questingGroup; + let leader; + let member; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + { type: 'party', privacy: 'private' }, + members: 1, + }); + + questingGroup = group; + leader = groupLeader; + member = members[0]; + }); + + context('failure conditions', () => { + it('does not accept quest without an invite', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/accept`, {})) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInviteNotFound'), + }); + + )}; + + it('does not accept quest for a group in which user is not a member', () => { + + )}; + )}; + context('successfully accepting a quest invitation', () => { + it('joins a quest from an invitation', () => { + + )}; + + it('does not begin the quest if pending invitations remain', () => { + + )}; + + it('begins the quest if accepting the last pending invite', () => { + + )}; + )}; +)}; From 0ec63ca68d644a1cc68a1a9dc8e7712390370e75 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 22 Jan 2016 16:59:34 -0500 Subject: [PATCH 381/976] test(quests): invite and accept WIP --- common/locales/en/api-v3.json | 7 +- .../POST-groups_groupId_quests_accept.test.js | 2 +- .../POST-groups_groupId_quests_invite.test.js | 120 ++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index bc71703017..f030c020f7 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -66,5 +66,10 @@ "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", - "questInviteNotFound": "No quest invitation found." + "questInviteNotFound": "No quest invitation found.", + "guildQuestsNotSupported": "Guilds cannot be invited on quests.", + "questNotFound": "Quest \"<%= key %>\" not found.", + "questNotOwned": "You don't own that quest scroll.", + "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." } diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js index 44286e6e79..a50497555b 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js @@ -10,7 +10,7 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { let member; beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ + let { group, groupLeader, members } = await createAndPopulateGroup( { type: 'party', privacy: 'private' }, members: 1, }); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js new file mode 100644 index 0000000000..76ccf25e51 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -0,0 +1,120 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe.skip('POST /groups/:groupId/quests/invite', () => { + let questingGroup; + let leader; + let member; + const PET_QUEST = 'whale'; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup( + { type: 'party', privacy: 'private' }, + members: 1, + }); + + questingGroup = group; + leader = groupLeader; + member = members[0]; + }); + + context('failure conditions', () => { + it('does not issue invites with an invalid group ID', async () => { + await expect(leader.post(`/groups/${generateUUID()}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('does not issue invites for a group in which user is not a member', async () => { + let { alternateGroup } = await createAndPopulateGroup( + { type: 'party', privacy: 'private' }, + members: 1, + }); + + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + )}; + + it('does not issue invites for Guilds', async () => { + let { alternateGroup } = await createAndPopulateGroup( + { type: 'guild', privacy: 'public' }, + members: 1, + }); + + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + )}; + + it('does not issue invites with an invalid quest key', async () => { + const FAKE_QUEST = 'herkimer'; + + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${FAKE_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questNotFound', {key: FAKE_QUEST}), + }); + )}; + + it('does not issue invites for a quest the user does not own', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questNotOwned'), + }); + )}; + + it('does not issue invites if the user is of insufficient Level', async () => { + const LEVELED_QUEST = 'atom1'; + const LEVELED_QUEST_REQ = 15; + leader.items.quests[LEVELED_QUEST] = 1; + + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLevelTooHigh', {level: LEVELED_QUEST_REQ}), + }); + )}; + + it('does not issue invites if a quest is already underway', async () => { + leader.items.quests[PET_QUEST] = 2; + + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), + }); + )}; + )}; + + context('successfully issuing a quest invitation', () => { + it('sends an invite to all party members', async () => { + leader.items.quests[PET_QUEST] = 1; + + await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ + + }]); + )}; + + it('allows non-leader party members to send invites', () => { + member.items.quests[PET_QUEST] = 1; + + await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ + + }]); + )}; + )}; +)}; From a414aeaf708b014834856648cacfa0e1f9a15662 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 22 Jan 2016 17:38:47 -0500 Subject: [PATCH 382/976] fix(test): linting --- .../POST-groups_groupId_quests_accept.test.js | 33 +++++++++-------- .../POST-groups_groupId_quests_invite.test.js | 37 +++++++++---------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js index a50497555b..e218d7e3d9 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js @@ -1,6 +1,5 @@ import { createAndPopulateGroup, - generateUser, translate as t, } from '../../../../helpers/api-v3-integration.helper'; @@ -10,8 +9,8 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { let member; beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup( - { type: 'party', privacy: 'private' }, + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); @@ -22,30 +21,34 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { context('failure conditions', () => { it('does not accept quest without an invite', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`, {})) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInviteNotFound'), + }); + }); + + it('does not accept quest for a group in which user is not a member', async () => { await expect(member.post(`/groups/${questingGroup._id}/quests/accept`, {})) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('questInviteNotFound'), }); - - )}; - - it('does not accept quest for a group in which user is not a member', () => { - - )}; - )}; + }); + }); context('successfully accepting a quest invitation', () => { it('joins a quest from an invitation', () => { - )}; + }); it('does not begin the quest if pending invitations remain', () => { - )}; + }); it('begins the quest if accepting the last pending invite', () => { - )}; - )}; -)}; + }); + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 76ccf25e51..e03043b09a 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -1,6 +1,5 @@ import { createAndPopulateGroup, - generateUser, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -12,8 +11,8 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { const PET_QUEST = 'whale'; beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup( - { type: 'party', privacy: 'private' }, + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); @@ -32,8 +31,8 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites for a group in which user is not a member', async () => { - let { alternateGroup } = await createAndPopulateGroup( - { type: 'party', privacy: 'private' }, + let { alternateGroup } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); @@ -42,11 +41,11 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotFound', message: t('groupNotFound'), }); - )}; + }); it('does not issue invites for Guilds', async () => { - let { alternateGroup } = await createAndPopulateGroup( - { type: 'guild', privacy: 'public' }, + let { alternateGroup } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'public' }, members: 1, }); @@ -55,7 +54,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('guildQuestsNotSupported'), }); - )}; + }); it('does not issue invites with an invalid quest key', async () => { const FAKE_QUEST = 'herkimer'; @@ -65,7 +64,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotFound', message: t('questNotFound', {key: FAKE_QUEST}), }); - )}; + }); it('does not issue invites for a quest the user does not own', async () => { await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ @@ -73,7 +72,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('questNotOwned'), }); - )}; + }); it('does not issue invites if the user is of insufficient Level', async () => { const LEVELED_QUEST = 'atom1'; @@ -85,7 +84,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('questLevelTooHigh', {level: LEVELED_QUEST_REQ}), }); - )}; + }); it('does not issue invites if a quest is already underway', async () => { leader.items.quests[PET_QUEST] = 2; @@ -97,8 +96,8 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('questAlreadyUnderway'), }); - )}; - )}; + }); + }); context('successfully issuing a quest invitation', () => { it('sends an invite to all party members', async () => { @@ -107,14 +106,14 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ }]); - )}; + }); - it('allows non-leader party members to send invites', () => { + it('allows non-leader party members to send invites', async () => { member.items.quests[PET_QUEST] = 1; await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ }]); - )}; - )}; -)}; + }); + }); +}); From cded1aa8abfd9154abc23bde89d5c2be3596286c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 23 Jan 2016 19:13:24 +0100 Subject: [PATCH 383/976] remove duplicate eslint rules --- .eslintrc | 2 -- 1 file changed, 2 deletions(-) diff --git a/.eslintrc b/.eslintrc index 7613632120..85937b0e6e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -23,7 +23,6 @@ "no-lone-blocks": 2, "no-loop-func": 2, "no-implicit-coercion": 2, - "no-implied-eval": 2, "no-native-reassign": 2, "no-new-func": 2, "no-new-wrappers": 2, @@ -59,7 +58,6 @@ "no-path-concat": 2, "arrow-spacing": 2, "constructor-super": 2, - "generator-star-spacing": 2, "no-arrow-condition": 2, "no-class-assign": 2, "no-const-assign": 2, From 13693a041a54a398d61d68a294fb92e7d6e21b39 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 23 Jan 2016 19:44:55 +0100 Subject: [PATCH 384/976] fix npm test so that it does not fail - temporary solution --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fd5659a079..5fc1aed24d 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp test", + "test": "gulp test:nodemon & (sleep 20; mocha test/api/v3 --recursive; killall gulp; killall node;)", "test:api-v2:unit": "mocha test/server_side", "test:api-v2:integration": "mocha test/api/v2 --recursive", "test:api-v3": "mocha test/api/v3 --recursive", From ab5fc1f526c3090fafb86dff5b2d69bde91cc785 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 21 Jan 2016 10:38:43 -0600 Subject: [PATCH 385/976] Added initial get group tests --- .../integration/groups/GET-groups_id.test.js | 302 ++++++++++++++++++ test/helpers/api-integration/api-classes.js | 25 ++ website/src/controllers/api-v3/groups.js | 7 + 3 files changed, 334 insertions(+) create mode 100644 test/api/v3/integration/groups/GET-groups_id.test.js diff --git a/test/api/v3/integration/groups/GET-groups_id.test.js b/test/api/v3/integration/groups/GET-groups_id.test.js new file mode 100644 index 0000000000..9cc2fce766 --- /dev/null +++ b/test/api/v3/integration/groups/GET-groups_id.test.js @@ -0,0 +1,302 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +import { + each, +} from 'lodash'; + +describe('GET /groups/:id', () => { + let typesOfGroups = {}; + typesOfGroups['public guild'] = { type: 'guild', privacy: 'public' }; + typesOfGroups['private guild'] = { type: 'guild', privacy: 'private' }; + typesOfGroups.party = { type: 'party', privacy: 'private' }; + + each(typesOfGroups, (groupDetails, groupType) => { + context(`Member of a ${groupType}`, () => { + let leader, member, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 30, + groupDetails, + }); + + leader = groupData.groupLeader; + member = groupData.members[0]; + createdGroup = groupData.group; + }); + + it('returns the group object', async () => { + let group = await member.get(`/groups/${createdGroup._id}`); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + + it('transforms leader id to leader object', async () => { + let group = await member.get(`/groups/${createdGroup._id}`); + + expect(group.leader._id).to.eql(leader._id); + expect(group.leader.profile.name).to.eql(leader.profile.name); + expect(group.leader.items).to.exist; + expect(group.leader.stats).to.exist; + expect(group.leader.achievements).to.exist; + expect(group.leader.contributor).to.exist; + }); + }); + }); + + context('Non-member of a public guild', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'public', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('returns the group object for a non-member', async () => { + let group = await nonMember.get(`/groups/${createdGroup._id}`); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + }); + + context('Non-member of a private guild', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('does not return the group object for a non-member', async () => { + await expect(nonMember.get(`/groups/${createdGroup._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Non-member of a party', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test party', + type: 'party', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('does not return the group object for a non-member', async () => { + await expect(nonMember.get(`/groups/${createdGroup._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Member of a party', () => { + let member, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test party', + type: 'party', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + member = groupData.members[0]; + }); + + it('returns the user\'s party if an id of "party" is passed in', async () => { + let group = await member.get('/groups/party'); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + }); + + context('Non-existent group', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns error if group does not exist', async () => { + await expect(user.get('/groups/group-that-does-not-exist')) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Flagged messages', () => { + let group; + + let chat1 = { + id: 'chat1', + text: 'chat 1', + flags: {}, + }; + + let chat2 = { + id: 'chat2', + text: 'chat 2', + flags: {}, + flagCount: 0, + }; + + let chat3 = { + id: 'chat3', + text: 'chat 3', + flags: { + 'user-id': true, + }, + flagCount: 1, + }; + + let chat4 = { + id: 'chat4', + text: 'chat 4', + flags: { + 'user-id': true, + 'other-user-id': true, + }, + flagCount: 2, + }; + + let chat5 = { + id: 'chat5', + text: 'chat 5', + flags: { + 'user-id': true, + 'other-user-id': true, + 'yet-another-user-id': true, + }, + flagCount: 3, + }; + + beforeEach(async () => { + let groupData = await createAndPopulateGroup({ + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'public', + chat: [ + chat1, + chat2, + chat3, + chat4, + chat5, + ], + }, + }); + + group = groupData.group; + + await group.addChat([chat1, chat2, chat3, chat4, chat5]); + }); + + context('non-admin', () => { + let nonAdmin; + + beforeEach(async () => { + nonAdmin = await generateUser(); + }); + + it('does not include messages with a flag count of 2 or greater', async () => { + let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`); + + expect(fetchedGroup.chat).to.have.lengthOf(3); + expect(fetchedGroup.chat[0].id).to.eql(chat1.id); + expect(fetchedGroup.chat[1].id).to.eql(chat2.id); + expect(fetchedGroup.chat[2].id).to.eql(chat3.id); + }); + + it('does not include user ids in flags object', async () => { + let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`); + let chatWithOneFlag = fetchedGroup.chat[2]; + + expect(chatWithOneFlag.id).to.eql(chat3.id); + expect(chat3.flags).to.eql({ 'user-id': true }); + expect(chatWithOneFlag.flags).to.eql({}); + }); + }); + + context('admin', () => { + let admin; + + beforeEach(async () => { + admin = await generateUser({ + 'contributor.admin': true, + }); + }); + + it('includes all messages', async () => { + let fetchedGroup = await admin.get(`/groups/${group._id}`); + + expect(fetchedGroup.chat).to.have.lengthOf(5); + expect(fetchedGroup.chat[0].id).to.eql(chat1.id); + expect(fetchedGroup.chat[1].id).to.eql(chat2.id); + expect(fetchedGroup.chat[2].id).to.eql(chat3.id); + expect(fetchedGroup.chat[3].id).to.eql(chat4.id); + expect(fetchedGroup.chat[4].id).to.eql(chat5.id); + }); + + it('includes user ids in flags object', async () => { + let fetchedGroup = await admin.get(`/groups/${group._id}`); + let chatWithOneFlag = fetchedGroup.chat[2]; + + expect(chatWithOneFlag.id).to.eql(chat3.id); + expect(chat3.flags).to.eql({ 'user-id': true }); + expect(chatWithOneFlag.flags).to.eql(chat3.flags); + }); + }); + }); +}); diff --git a/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js index 3fe7adbca7..79cb3163bd 100644 --- a/test/helpers/api-integration/api-classes.js +++ b/test/helpers/api-integration/api-classes.js @@ -59,6 +59,31 @@ export class ApiGroup extends ApiObject { this._docType = 'groups'; } + + async addChat (chat) { + let group = this; + + if (!chat) { + chat = { + id: 'Test_ID', + text: 'Test message', + flagCount: 0, + timestamp: Date(), + likes: {}, + flags: {}, + uuid: group.leader, + contributor: {}, + backer: {}, + user: group.leader, + }; + } + + let update = { chat }; + + this.update(update, '$push'); + + return this; + } } export class ApiChallenge extends ApiObject { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 05a68831e9..4782164d82 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -152,6 +152,13 @@ api.getGroup = { let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: true}); if (!group) throw new NotFound(res.t('groupNotFound')); + if (!user.contributor.admin) { + _.remove(group.chat, function removeChat (chat) { + chat.flags = {}; + return chat.flagCount >= 2; + }); + } + res.respond(200, group); }, }; From febc48cd4100ed74bbfe9036269fc6516d00a120 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 21 Jan 2016 12:35:39 -0600 Subject: [PATCH 386/976] Fixed failing chat test by using and admin to view chat flags --- test/api/v3/integration/chat/POST-chat.flag.test.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 47ba58b046..16f91a60e8 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -5,11 +5,13 @@ import { import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { - let user, group; + let user, admin, group; const TEST_MESSAGE = 'Test Message'; before(async () => { user = await generateUser({balance: 1}); + admin = await generateUser({balance: 1, 'contributor.admin': true}); + group = await user.post('/groups', { name: 'Test Guild', type: 'guild', @@ -51,7 +53,7 @@ describe('POST /chat/:chatId/flag', () => { .then((result) => { expect(result.flags[user._id]).to.equal(true); expect(result.flagCount).to.equal(1); - return user.get(`/groups/${group._id}`); + return admin.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = find(updatedGroup.chat, {id: message.id}); @@ -74,7 +76,7 @@ describe('POST /chat/:chatId/flag', () => { .then((result) => { expect(result.flags[secondUser._id]).to.equal(true); expect(result.flagCount).to.equal(5); - return user.get(`/groups/${group._id}`); + return admin.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = find(updatedGroup.chat, {id: message.id}); From 2063070f182b2263ede55393aa48fb1c59fcd1ac Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Jan 2016 13:41:51 -0600 Subject: [PATCH 387/976] Added group toJSON conversion and fixed syntax errors --- test/helpers/api-integration/api-classes.js | 4 +--- website/src/controllers/api-v3/groups.js | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js index 79cb3163bd..097c359427 100644 --- a/test/helpers/api-integration/api-classes.js +++ b/test/helpers/api-integration/api-classes.js @@ -80,9 +80,7 @@ export class ApiGroup extends ApiObject { let update = { chat }; - this.update(update, '$push'); - - return this; + return await this.update(update); } } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 4782164d82..3457a390af 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -153,6 +153,7 @@ api.getGroup = { if (!group) throw new NotFound(res.t('groupNotFound')); if (!user.contributor.admin) { + group = group.toJSON(); _.remove(group.chat, function removeChat (chat) { chat.flags = {}; return chat.flagCount >= 2; From 9405d4b054cfcf7b852460a320fd2fbb19789689 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Jan 2016 14:32:24 -0600 Subject: [PATCH 388/976] Fixed send message and added intiial seen chat tests --- .../integration/chat/POST-chat_seen.test.js | 32 +++++++++++++++ website/src/controllers/api-v3/chat.js | 40 ++++++++++++++++++- website/src/models/group.js | 3 +- 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 test/api/v3/integration/chat/POST-chat_seen.test.js diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js new file mode 100644 index 0000000000..6073fc1204 --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat_seen.test.js @@ -0,0 +1,32 @@ +import { + createAndPopulateGroup, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:id/chat/seen', () => { + let groupWithChat, message, author, member; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + groupWithChat = group; + author = groupLeader; + member = members[0]; + + message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); + message = message.message; + }); + + it('clears new messages', async () => { + await member.post(`/groups/${groupWithChat._id}/chat/seen`); + + let userThatHasSeenChat = await member.get('/user'); + + expect(userThatHasSeenChat.newMessages).to.be.empty; + }); +}); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 212e064441..bfca7eb3b7 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -105,7 +105,7 @@ api.postChat = { * @apiSuccess {Array} chat An array of chat messages */ api.likeChat = { - method: 'Post', + method: 'POST', url: '/groups/:groupId/chat/:chatId/like', middlewares: [authWithHeaders(), cron], async handler (req, res) { @@ -152,7 +152,7 @@ api.likeChat = { * @apiSuccess {Array} chat An array of chat messages */ api.flagChat = { - method: 'Post', + method: 'POST', url: '/groups/:groupId/chat/:chatId/flag', middlewares: [authWithHeaders(), cron], async handler (req, res) { @@ -256,4 +256,40 @@ api.flagChat = { }, }; +/** + * @api {post} /groups/:groupId/chat/:chatId/seen Seen a group chat message + * @apiVersion 3.0.0 + * @apiName SeenChat + * @apiGroup Chat + * + * @apiParam {groupId} groupId The group _id + * + * @apiSuccess {None} + */ +api.seenChat = { + method: 'POST', + url: '/groups/:groupId/chat/seen', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + // Skip the auth step, we want this to be fast. If !found with uuid/token, then it just doesn't save + let update = { $unset: {} }; + + update.$unset[`newMessages.${groupId}`] = ''; + await User.update({_id: user._id}, update).exec(); + + res.respond(200); + }, +}; + export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index af592bb332..7e7e9c102b 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -205,7 +205,8 @@ schema.methods.sendChat = function sendChat (message, user) { // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); } else { User.update({ - _id: {$in: this.members, $ne: user ? user._id : ''}, + guilds: this._id, + _id: { $ne: user ? user._id : ''}, }, lastSeenUpdate, {multi: true}).exec(); } }; From ff034cf61b79c0c8223c3bea047dae3141dd5fd8 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Jan 2016 19:56:11 -0600 Subject: [PATCH 389/976] Fixed send message for parties and added party seen message test --- .../integration/chat/POST-chat_seen.test.js | 69 ++++++++++++++----- website/src/models/group.js | 15 ++-- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js index 6073fc1204..9f90ca3296 100644 --- a/test/api/v3/integration/chat/POST-chat_seen.test.js +++ b/test/api/v3/integration/chat/POST-chat_seen.test.js @@ -3,30 +3,65 @@ import { } from '../../../../helpers/api-v3-integration.helper'; describe('POST /groups/:id/chat/seen', () => { - let groupWithChat, message, author, member; + context('Guild', () => { + let guild, guildLeader, guildMember, guildMessage; - before(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - type: 'guild', - privacy: 'public', - }, - members: 1, + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + guild = group; + guildLeader = groupLeader; + guildMember = members[0]; + + guildMessage = await guildLeader.post(`/groups/${guild._id}/chat`, { message: 'Some guild message' }); + guildMessage = guildMessage.message; }); - groupWithChat = group; - author = groupLeader; - member = members[0]; + it('clears new messages for a guild', async () => { + let user = await guildMember.get('/user'); - message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); - message = message.message; + await guildMember.post(`/groups/${guild._id}/chat/seen`); + + let guildThatHasSeenChat = await guildMember.get('/user'); + + expect(guildThatHasSeenChat.newMessages).to.be.empty; + }); }); - it('clears new messages', async () => { - await member.post(`/groups/${groupWithChat._id}/chat/seen`); + context('Party', () => { + let party, partyLeader, partyMember, partyMessage; - let userThatHasSeenChat = await member.get('/user'); + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'party', + privacy: 'private', + }, + members: 1, + }); - expect(userThatHasSeenChat.newMessages).to.be.empty; + party = group; + partyLeader = groupLeader; + partyMember = members[0]; + + partyMessage = await partyLeader.post(`/groups/${party._id}/chat`, { message: 'Some party message' }); + partyMessage = partyMessage.message; + }); + + it('clears new messages for a party', async () => { + let user = await partyMember.get('/user'); + + await partyMember.post(`/groups/${party._id}/chat/seen`); + + let partyMemberThatHasSeenChat = await partyMember.get('/user'); + + expect(partyMemberThatHasSeenChat.newMessages).to.be.empty; + }); }); }); diff --git a/website/src/models/group.js b/website/src/models/group.js index 7e7e9c102b..246a2f0625 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -204,10 +204,17 @@ schema.methods.sendChat = function sendChat (message, user) { // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); } else { - User.update({ - guilds: this._id, - _id: { $ne: user ? user._id : ''}, - }, lastSeenUpdate, {multi: true}).exec(); + let query = {}; + + if (this.type === 'party') { + query['party._id'] = this._id; + } else { + query.guilds = this._id; + } + + query._id = { $ne: user ? user._id : ''}; + + User.update(query, lastSeenUpdate, {multi: true}).exec(); } }; From c498eef21c9aadabc533ef01dd08bd107a62c948 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Jan 2016 23:09:22 -0600 Subject: [PATCH 390/976] Added initial chat delete tests --- common/locales/en/api-v3.json | 3 +- .../integration/chat/DELETE-chat_id.test.js | 81 +++++++++++++++++++ website/src/controllers/api-v3/chat.js | 58 +++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/chat/DELETE-chat_id.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index c10fd1647a..e6dc9bee55 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -65,5 +65,6 @@ "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", - "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked." + "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", + "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!" } diff --git a/test/api/v3/integration/chat/DELETE-chat_id.test.js b/test/api/v3/integration/chat/DELETE-chat_id.test.js new file mode 100644 index 0000000000..5d1f73f867 --- /dev/null +++ b/test/api/v3/integration/chat/DELETE-chat_id.test.js @@ -0,0 +1,81 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /groups/:groupId/chat/:chatId', () => { + let groupWithChat, message, user, userThatDidNotCreateChat, admin; + + before(async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + }); + + groupWithChat = group; + user = groupLeader; + message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); + message = message.message; + userThatDidNotCreateChat = await generateUser(); + admin = await generateUser({'contributor.admin': true}); + }); + + context('Chat errors', () => { + it('returns an error is message does not exist', async () => { + let fakeChatId = generateUUID(); + await expect(user.del(`/groups/${groupWithChat._id}/chat/${fakeChatId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('returns an error when user does not have permission to delete', async () => { + await expect(userThatDidNotCreateChat.del(`/groups/${groupWithChat._id}/chat/${message.id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyCreatorOrAdminCanDeleteChat'), + }); + }); + }); + + context('Chat success', () => { + let nextMessage; + + beforeEach(async () => { + nextMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some new message' }); + nextMessage = nextMessage.message; + }); + + it('allows creator to delete a their message', async () => { + await user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`); + let messages = await user.get(`/groups/${groupWithChat._id}/chat/`); + expect(messages).is.an('array'); + expect(messages).to.not.include(nextMessage); + }); + + it('allows admin to delete another user\'s message', async () => { + await admin.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`); + let messages = await user.get(`/groups/${groupWithChat._id}/chat/`); + expect(messages).is.an('array'); + expect(messages).to.not.include(nextMessage); + }); + + it('returns empty when previous message parameter is passed and the last message was deleted', async () => { + await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${nextMessage.id}`)) + .to.eventually.be.empty; + }); + + it('returns the update chat when previous message parameter is passed and the chat is updated', async () => { + await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${message.id}`)) + .eventually + .is.an('array') + .to.include(message) + .to.be.lengthOf(1); + }); + }); +}); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 212e064441..e37ecee445 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -4,6 +4,7 @@ import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; import { NotFound, + NotAuthorized, } from '../../libs/api-v3/errors'; import _ from 'lodash'; import { sendTxn } from '../../libs/api-v3/email'; @@ -256,4 +257,61 @@ api.flagChat = { }, }; +/** + * @api {delete} /groups/:groupId/chat/:chatId Delete chat message from a group + * @apiVersion 3.0.0 + * @apiName DeleteChat + * @apiGroup Chat + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} chatId The chat _id + * + * @apiSuccess {Array} The update chat array + * @apiSuccess {Object} An empty object when the previous message was deleted + */ +api.deleteChat = { + method: 'DELETE', + url: '/groups/:groupId/chat/:chatId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + let chatId = req.params.chatId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'chat'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let message = _.find(group.chat, {id: chatId}); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + + if (user._id !== message.uuid && !user.contributor.admin) { + throw new NotAuthorized(res.t('onlyCreatorOrAdminCanDeleteChat')); + } + + let lastClientMsg = req.query.previousMsg; + let chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; + + await Group.update( + {_id: group._id}, + {$pull: {chat: {id: chatId} } } + ); + + if (chatUpdated) { + group = group.toJSON(); + _.remove(group.chat, function removeChat (chat) { + return chat.id === chatId; + }); + res.json(group.chat); + } else { + res.send(200, {}); + } + }, +}; + export default api; From e8c13d311772ce90ce43668456cf869e7b0e9ed5 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Jan 2016 23:11:57 -0600 Subject: [PATCH 391/976] Added initial clear chat flags tests --- ...POST-groups_id_chat_id_clear_flags.test.js | 101 ++++++++++++++++++ website/src/controllers/api-v3/chat.js | 48 +++++++++ 2 files changed, 149 insertions(+) create mode 100644 test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js diff --git a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js new file mode 100644 index 0000000000..87cad5d6f0 --- /dev/null +++ b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js @@ -0,0 +1,101 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:id/chat/:id/clearflags', () => { + let groupWithChat, message, author, nonAdmin, admin; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + groupWithChat = group; + author = groupLeader; + nonAdmin = members[0]; + admin = await generateUser({'contributor.admin': true}); + + message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); + message = message.message; + admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/flag`); + }); + + context('Single Message', () => { + it('returns error when non-admin attempts to clear flags', async () => { + return expect(nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupChatAdminClearFlagCount'), + }); + }); + + it('returns error if message does not exist', async () => { + let fakeMessageID = generateUUID(); + + await expect(admin.post(`/groups/${groupWithChat._id}/chat/${fakeMessageID}/clearflags`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('clears flags and leaves old flags on the flag object', async () => { + await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`); + let messages = await admin.get(`/groups/${groupWithChat._id}/chat`); + expect(messages[0].flagCount).to.eql(0); + expect(messages[0].flags).to.have.property(admin._id, true); + }); + }); + + context('admin user, group with multiple messages', () => { + let message2, message3, message4; + + before(async () => { + message2 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 2' }); + message2 = message2.message; + await admin.post(`/groups/${groupWithChat._id}/chat/${message2.id}/flag`); + + message3 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 3' }); + message3 = message3.message; + await admin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`); + await nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`); + + message4 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 4' }); + message4 = message4.message; + }); + + it('changes only the message that is flagged', async () => { + await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`); + let messages = await admin.get(`/groups/${groupWithChat._id}/chat`); + + expect(messages).to.have.lengthOf(4); + + let messageThatWasUnflagged = messages[3]; + let messageWith1Flag = messages[2]; + let messageWith2Flag = messages[1]; + let messageWithoutFlags = messages[0]; + + expect(messageThatWasUnflagged.flagCount).to.eql(0); + expect(messageThatWasUnflagged.flags).to.have.property(admin._id, true); + + expect(messageWith1Flag.flagCount).to.eql(5); + expect(messageWith1Flag.flags).to.have.property(admin._id, true); + + expect(messageWith2Flag.flagCount).to.eql(6); + expect(messageWith2Flag.flags).to.have.property(admin._id, true); + expect(messageWith2Flag.flags).to.have.property(nonAdmin._id, true); + + expect(messageWithoutFlags.flagCount).to.eql(0); + expect(messageWithoutFlags.flags).to.eql({}); + }); + }); +}); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 212e064441..a5f208ab46 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -4,6 +4,7 @@ import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; import { NotFound, + NotAuthorized, } from '../../libs/api-v3/errors'; import _ from 'lodash'; import { sendTxn } from '../../libs/api-v3/email'; @@ -256,4 +257,51 @@ api.flagChat = { }, }; +/** + * @api {post} /groups/:groupId/chat/:chatId/clear-flags Clear a group chat message's flags + * @apiVersion 3.0.0 + * @apiName ClearFlags + * @apiGroup Chat + * + * @apiParam {groupId} groupId The group _id + * @apiParam {chatId} chatId The chat message _id + * + * @apiSuccess {Object} An empty object + */ +api.clearChatFlags = { + method: 'Post', + url: '/groups/:groupId/chat/:chatId/clearflags', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + let chatId = req.params.chatId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.contributor.admin) { + throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount')); + } + + let group = await Group.getGroup({user, groupId}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let message = _.find(group.chat, {id: chatId}); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + + message.flagCount = 0; + + await Group.update( + {_id: group._id, 'chat.id': message.id}, + {$set: {'chat.$.flagCount': message.flagCount}} + ); + + res.respond(200, {}); + }, +}; + export default api; From 198d2e6ab5cf0177fb40cccdf8f7b0fd66fb5e07 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Jan 2016 12:38:20 +0100 Subject: [PATCH 392/976] misc fixes and run gulp lint when testing --- package.json | 2 +- .../v3/integration/chat/POST-chat_seen.test.js | 4 ---- website/src/controllers/api-v3/chat.js | 17 +++++++---------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 5fc1aed24d..0f9ee88db0 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp test:nodemon & (sleep 20; mocha test/api/v3 --recursive; killall gulp; killall node;)", + "test": "gulp lint && (gulp test:nodemon & (sleep 20; mocha test/api/v3 --recursive; killall gulp; killall node;))", "test:api-v2:unit": "mocha test/server_side", "test:api-v2:integration": "mocha test/api/v2 --recursive", "test:api-v3": "mocha test/api/v3 --recursive", diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js index 9f90ca3296..8b22461a04 100644 --- a/test/api/v3/integration/chat/POST-chat_seen.test.js +++ b/test/api/v3/integration/chat/POST-chat_seen.test.js @@ -24,8 +24,6 @@ describe('POST /groups/:id/chat/seen', () => { }); it('clears new messages for a guild', async () => { - let user = await guildMember.get('/user'); - await guildMember.post(`/groups/${guild._id}/chat/seen`); let guildThatHasSeenChat = await guildMember.get('/user'); @@ -55,8 +53,6 @@ describe('POST /groups/:id/chat/seen', () => { }); it('clears new messages for a party', async () => { - let user = await partyMember.get('/user'); - await partyMember.post(`/groups/${party._id}/chat/seen`); let partyMemberThatHasSeenChat = await partyMember.get('/user'); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 33daf58a64..6c491c4e7c 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -330,12 +330,10 @@ api.seenChat = { let group = await Group.getGroup({user, groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); - // Skip the auth step, we want this to be fast. If !found with uuid/token, then it just doesn't save - let update = { $unset: {} }; + let update = {$unset: {}}; + update.$unset[`newMessages.${groupId}`] = true; - update.$unset[`newMessages.${groupId}`] = ''; await User.update({_id: user._id}, update).exec(); - res.respond(200); }, }; @@ -382,17 +380,16 @@ api.deleteChat = { await Group.update( {_id: group._id}, - {$pull: {chat: {id: chatId} } } + {$pull: {chat: {id: chatId}}} ); if (chatUpdated) { group = group.toJSON(); - _.remove(group.chat, function removeChat (chat) { - return chat.id === chatId; - }); - res.json(group.chat); + let i = _.findIndex(group.chat, {id: chatId}); + if (i !== -1) group.chat.splice(i, 1); + res.respond(200, group.chat); } else { - res.send(200, {}); + res.respond(200, {}); } }, }; From 59f5a80af72e0045e4af39de551959f226df78a3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Jan 2016 12:52:59 +0100 Subject: [PATCH 393/976] improve access control for challenges --- website/src/controllers/api-v3/challenges.js | 2 +- website/src/controllers/api-v3/members.js | 9 ++++- website/src/models/challenge.js | 40 +++++++++----------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index f0c682cf05..bd7f694cf6 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -192,7 +192,7 @@ api.getChallenge = { let challenge = await Challenge.findById(challengeId).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); res.respond(200, challenge); diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 62df59b651..142926ed64 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -76,7 +76,10 @@ function _getMembersForItem (type) { if (type === 'challenge-members') { challenge = await Challenge.findById(challengeId).select('_id type leader groupId').exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + + // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge + // for example if you've been booted from it, are the leader or a site admin + group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); } else { group = await Group.getGroup({user, groupId, fields: '_id type'}); @@ -207,7 +210,9 @@ api.getChallengeMemberProgress = { let challenge = await Challenge.findById(challengeId).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge + // for example if you've been booted from it, are the leader or a site admin + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 39a7caa21b..ed9a88213e 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -30,22 +30,6 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], }); -// Returns true if user has access to the challenge (can join) -schema.methods.hasAccess = function hasAccessToChallenge (user) { - let userGroups = user.guilds.slice(0); - if (user.party._id) userGroups.push(user.party._id); - userGroups.push('habitrpg'); // tavern challenges - return this.leader === user._id || userGroups.indexOf(this.groupId) !== -1; -}; - -// Returns true if user can view the challenge -// Different from hasAccess because challenges of public guilds can be viewed by everyone -schema.methods.canView = function canViewChallenge (user, group) { - if (user.contributor.admin) return true; - if (group.type === 'guild' && group.privacy === 'public') return true; - return this.hasAccess(user); -}; - // Returns true if user is a member of the challenge schema.methods.isMember = function isChallengeMember (user) { return user.challenges.indexOf(this._id) !== -1; @@ -56,6 +40,23 @@ schema.methods.canModify = function canModifyChallenge (user) { return user.contributor.admin || this.leader === user._id; }; +// Returns true if user has access to the challenge (can join) +schema.methods.hasAccess = function hasAccessToChallenge (user) { + let userGroups = user.guilds.slice(0); + if (user.party._id) userGroups.push(user.party._id); + userGroups.push('habitrpg'); // tavern challenges + return this.canModify(user) || userGroups.indexOf(this.groupId) !== -1; +}; + +// Returns true if user can view the challenge +// Different from hasAccess because challenges of public guilds can be viewed by everyone +// And also because you can see challenges of groups you've been removed from +schema.methods.canView = function canViewChallenge (user, group) { + if (group.type === 'guild' && group.privacy === 'public') return true; + if (this.isMember(user)) return true; + return this.hasAccess(user); +}; + // Takes a Task document and return a plain object of attributes that can be synced to the user function _syncableAttrs (task) { let t = task.toObject(); // lodash doesn't seem to like _.omit on Document @@ -65,13 +66,6 @@ function _syncableAttrs (task) { return _.omit(t, omitAttrs); } -schema.methods.hasAccess = function hasAccessToChallenge (user) { - let userGroups = user.guilds.slice(0); - if (user.party._id) userGroups.push(user.party._id); - userGroups.push('habitrpg'); // tavern challenges - return this.leader === user._id || userGroups.indexOf(this.groupId) !== -1; -}; - // Sync challenge to user, including tasks and tags. // Used when user joins the challenge or to force sync. schema.methods.syncToUser = async function syncChallengeToUser (user) { From df0b49f4f31f585c05a55f83472e07821710a135 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Jan 2016 12:59:04 +0100 Subject: [PATCH 394/976] tests: check that memberCount is increased for public guilds too --- .../integration/groups/POST-groups_groupId_join.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index eceb388ac4..6c5ee11b35 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -46,6 +46,14 @@ describe('POST /group/:groupId/join', () => { await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.deep.property('leader._id', joiningUser._id); }); + + it('increments memberCount when joining guilds', async () => { + let oldMemberCount = guild.memberCount; + + await joiningUser.post(`/groups/${publicGuild._id}/join`); + + await expect(invitedUser.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + }); }); context('Joining a private guild', () => { From 490d92eb2f80eb8e4b34dcfc47179832984ff463 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Jan 2016 12:59:54 +0100 Subject: [PATCH 395/976] fix increments memberCount when joining guilds test --- .../v3/integration/groups/POST-groups_groupId_join.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index 6c5ee11b35..4c1e422ce6 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -48,11 +48,11 @@ describe('POST /group/:groupId/join', () => { }); it('increments memberCount when joining guilds', async () => { - let oldMemberCount = guild.memberCount; + let oldMemberCount = publicGuild.memberCount; await joiningUser.post(`/groups/${publicGuild._id}/join`); - await expect(invitedUser.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + await expect(invitedUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); }); }); From 4149cbf381aa636ed0ebad9f17f8451c3138381a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Jan 2016 13:14:29 +0100 Subject: [PATCH 396/976] check for memberCount in join group and removeMemberFromGroup test --- .../groups/POST-groups_groupId_join.test.js | 2 +- .../groups/POST-groups_id_removeMember.test.js | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index 4c1e422ce6..d24a03cb7e 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -52,7 +52,7 @@ describe('POST /group/:groupId/join', () => { await joiningUser.post(`/groups/${publicGuild._id}/join`); - await expect(invitedUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); }); }); diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js index c49cfe6694..cf98902887 100644 --- a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js +++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js @@ -11,7 +11,7 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { let member; let member2; - before(async () => { + beforeEach(async () => { let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ groupDetails: { name: 'Test Guild', @@ -62,12 +62,17 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { context('Guilds', () => { it('can remove other members', async () => { await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); - let memberRemoved = await member.get('/user'); expect(_.findIndex(memberRemoved.guilds, {id: guild._id})).eql(-1); }); + it('updates memberCount', async () => { + let oldMemberCount = guild.memberCount; + await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); + await expect(leader.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount - 1); + }); + it('can remove other invites', async () => { await leader.post(`/groups/${guild._id}/removeMember/${invitedUser._id}`); @@ -83,7 +88,7 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { let partyInvitedUser; let partyMember; - before(async () => { + beforeEach(async () => { let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ groupDetails: { name: 'Test Party', @@ -108,6 +113,12 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { expect(memberRemoved.party._id).eql(undefined); }); + it('updates memberCount', async () => { + let oldMemberCount = party.memberCount; + await partyleader.post(`/groups/${party._id}/removeMember/${partyMember._id}`); + await expect(partyleader.get(`/groups/${party._id}`)).to.eventually.have.property('memberCount', oldMemberCount - 1); + }); + it('can remove other invites', async () => { await partyleader.post(`/groups/${party._id}/removeMember/${partyInvitedUser._id}`); From d5751837ed212c04cd6a2229ea4c76bcf5a78356 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 25 Jan 2016 17:23:01 +0100 Subject: [PATCH 397/976] add clearCompletedTodos route with tests, add test for getting completed todos, misc fixes --- .../integration/tasks/GET-tasks_user.test.js | 19 ++++++++++-- website/src/controllers/api-v3/tasks.js | 31 ++++++++++++++++++- website/src/middlewares/api-v3/cron.js | 2 +- website/src/models/group.js | 2 +- website/src/models/task.js | 4 +-- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js index fb6c7c84f5..a206d7b3ce 100644 --- a/test/api/v3/integration/tasks/GET-tasks_user.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js @@ -22,6 +22,21 @@ describe('GET /tasks/user', () => { expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); - // TODO complete after task scoring is done - it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified'); + it('returns completed todos sorted by completion date if req.query.includeCompletedTodos is specified', async () => { + let todo1 = await user.post('/tasks/user', {text: 'todo to complete 1', type: 'todo'}); + let todo2 = await user.post('/tasks/user', {text: 'todo to complete 2', type: 'todo'}); + + await user.sync(); + let initialTodoCount = user.tasksOrder.todos.length; + + await user.post(`/tasks/${todo2._id}/score/up`); + await user.post(`/tasks/${todo1._id}/score/up`); + await user.sync(); + + expect(user.tasksOrder.todos.length).to.equal(initialTodoCount - 2); + + let allTodos = await user.get('/tasks/user?type=todo&includeCompletedTodos=true'); + expect(allTodos.length).to.equal(initialTodoCount); + expect(allTodos[allTodos.length - 1].text).to.equal('todo to complete 1'); // last is the todo that was completed later + }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index acdd456beb..020ae5061b 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -129,6 +129,7 @@ async function _getTasks (req, res, user, challenge) { if (challenge) throw new BadRequest(res.t('noCompletedTodosChallenge')); // no completed todos for challenges let queryCompleted = Tasks.Task.find({ + userId: user._id, type: 'todo', completed: true, }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos @@ -837,7 +838,35 @@ api.unlinkTask = { }; /** - * @api {delete} /task/:taskId Delete a user task given its id + * @api {post} /tasks/clearCompletedTodos Delete user's completed todos + * @apiVersion 3.0.0 + * @apiName ClearCompletedTodos + * @apiGroup Task + * + * @apiSuccess {object} empty An empty object + */ +api.clearCompletedTodos = { + method: 'POST', + url: '/tasks/clearCompletedTodos', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + // Clear completed todos + // Do not delete challenges completed todos TODO unless the task is broken? + await Tasks.Task.remove({ + userId: user._id, + type: 'todo', + completed: true, + 'challenge.id': {$exists: false}, + }).exec(); + + res.respond(200, {}); + }, +}; + +/** + * @api {delete} /tasks/:taskId Delete a user task given its id * @apiVersion 3.0.0 * @apiName DeleteTask * @apiGroup Task diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index aaebc476dc..288452e670 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -34,7 +34,7 @@ export default function cronMiddleware (req, res, next) { // Run cron cron({user, tasksByType, now, daysMissed, analytics}); - // Clean completed todos - 30 days for free users, 90 for subscribers + // Clear old completed todos - 30 days for free users, 90 for subscribers // Do not delete challenges completed todos TODO unless the task is broken? Task.remove({ userId: user._id, diff --git a/website/src/models/group.js b/website/src/models/group.js index 246a2f0625..d5d83b19b1 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -79,7 +79,7 @@ schema.plugin(baseModel, { // A list of additional fields that cannot be updated (but can be set on creation) let noUpdate = ['privacy', 'type']; schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { - return model.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define + return this.sanitize(updateObj, noUpdate); }; // TODO migration diff --git a/website/src/models/task.js b/website/src/models/task.js index 3fd574af93..c6a326999a 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -48,13 +48,13 @@ TaskSchema.plugin(baseModel, { // A list of additional fields that cannot be set on creation (but can be set on updare) let noCreate = ['completed']; // TODO completed should be removed for updates too? TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) { - return Task.sanitize(createObj, noCreate); // eslint-disable-line no-use-before-define + return this.sanitize(createObj, noCreate); }; // A list of additional fields that cannot be updated (but can be set on creation) let noUpdate = ['_id', 'type']; TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { - return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define + return this.sanitize(updateObj, noUpdate); }; // Sanitize checklist objects (disallowing _id) From 211a7bb46a8f661a44681dbe1337e6d1c89ccc91 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 25 Jan 2016 17:23:22 +0100 Subject: [PATCH 398/976] add new files for clearCompletedTodos functionality --- .../POST-tasks_clearCompletedTodos.test.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js diff --git a/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js new file mode 100644 index 0000000000..338808a4a1 --- /dev/null +++ b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js @@ -0,0 +1,41 @@ +import { + generateUser, + generateGroup, + generateChallenge, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /tasks/clearCompletedTodos', () => { + it('deletes all completed todos except the ones from a challenge', async () => { + let user = await generateUser({balance: 1}); + let guild = await generateGroup(user); + let challenge = await generateChallenge(user, guild); + + let initialTodoCount = user.tasksOrder.todos.length; + await user.post('/tasks/user', [ + {text: 'todo 1', type: 'todo'}, + {text: 'todo 2', type: 'todo'}, + {text: 'todo 3', type: 'todo'}, + {text: 'todo 4', type: 'todo'}, + {text: 'todo 5', type: 'todo'}, + ]); + + await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'todo 6', + type: 'todo', + }); + + let tasks = await user.get('/tasks/user?type=todo'); + expect(tasks.length).to.equal(initialTodoCount + 6); + + for (let task of tasks) { + if (['todo 2', 'todo 3', 'todo 6'].indexOf(task.text) !== -1) { + await user.post(`/tasks/${task._id}/score/up`); // eslint-disable-line babel/no-await-in-loop + } + } + + await user.post('/tasks/clearCompletedTodos'); + let tasksUpdated = await user.get('/tasks/user?type=todo&includeCompletedTodos=true'); + expect(tasksUpdated.length).to.equal(initialTodoCount + 4); // + 6 - 3 completed (but one is from challenge) + expect(tasksUpdated[tasksUpdated.length - 1].text).to.equal('todo 6'); + }); +}); From b28e0629b1410ba95ce1935c238b46a007210cc5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 25 Jan 2016 18:17:09 +0100 Subject: [PATCH 399/976] add feature to invite user if he is partying solo --- .../groups/POST-groups_groupId_join.test.js | 18 ++++++++++++++++++ .../groups/POST-groups_invite.test.js | 19 +++++++++++++++++-- website/src/controllers/api-v3/groups.js | 19 +++++++++++++++---- website/src/models/group.js | 6 +++--- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index d24a03cb7e..d20c8b830d 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -1,6 +1,7 @@ import { generateUser, createAndPopulateGroup, + checkExistence, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -185,6 +186,23 @@ describe('POST /group/:groupId/join', () => { await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); }); + it('deletes previous party where the user was the only member', async () => { + let userToInvite = await generateUser(); + let oldParty = await userToInvite.post('/groups', { // add user to a party + name: 'Another Test Party', + type: 'party', + }); + + await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(true); + await user.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + await userToInvite.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('party._id', party._id); + await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(false); + }); + xit('invites joining member to active quest', async () => { // TODO start quest diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index 42719c0fcd..55c25d9153 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -290,12 +290,14 @@ describe('Post /groups/:groupId/invite', () => { }); }); - it('returns an error when invited user is already in the party', async () => { + it('returns an error when invited user is already in a party of more than 1 member', async () => { let userToInvite = await generateUser(); + let userToInvite2 = await generateUser(); await inviter.post(`/groups/${party._id}/invite`, { - uuids: [userToInvite._id], + uuids: [userToInvite._id, userToInvite2._id], }); await userToInvite.post(`/groups/${party._id}/join`); + await userToInvite2.post(`/groups/${party._id}/join`); await expect(inviter.post(`/groups/${party._id}/invite`, { uuids: [userToInvite._id], @@ -306,5 +308,18 @@ describe('Post /groups/:groupId/invite', () => { message: t('userAlreadyInAParty'), }); }); + + it('allow inviting an user to a party if he\'s partying solo', async () => { + let userToInvite = await generateUser(); + await userToInvite.post('/groups', { // add user to a party + name: 'Another Test Party', + type: 'party', + }); + + await inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + expect((await userToInvite.get('/user')).invitations.party.id).to.equal(party._id); + }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 48e06a752a..0c6a35330e 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -238,10 +238,18 @@ api.joinGroup = { if (group.quest.key && !group.quest.active) { user.party.quest.RSVPNeeded = true; user.party.quest.key = group.quest.key; + user.party.quest.progress = undefined; // Make sure to reset progress from ay previous quest group.quest.members[user._id] = undefined; group.markModified('quest.members'); } + // If user was in a different party (when partying solo you can be invited to a new party) + // make him leave that party before doing anything + if (user.party._id) { + let userPreviousParty = await Group.getGroup({user, groupId: user.party._id}); + if (userPreviousParty) await userPreviousParty.leave(user); + } + user.party._id = group._id; // Set group as user's party isUserInvited = true; @@ -425,7 +433,7 @@ api.removeGroupMember = { }; async function _inviteByUUID (uuid, group, inviter, req, res) { - // @TODO: Add Push Notifications + // TODO: Add Push Notifications let userToInvite = await User.findById(uuid).exec(); if (!userToInvite) { @@ -444,11 +452,14 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { if (!_.isEmpty(userToInvite.invitations.party)) { throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); } + if (userToInvite.party._id) { - throw new NotAuthorized(res.t('userAlreadyInAParty')); + let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'}); + + // Allow user to be invited to a new party when they're partying solo + if (userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty')); } - // @TODO: Why was this here? - // req.body.type in 'guild', 'party' + userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id}; } diff --git a/website/src/models/group.js b/website/src/models/group.js index d5d83b19b1..8b11a57081 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -118,10 +118,10 @@ schema.statics.getGroup = function getGroup (options = {}) { let query; // When optionalMembership is true it's not required for the user to be a member of the group - if (optionalMembership === true) { - query = {_id: groupId}; - } else if (groupId === 'party' || user.party._id === groupId) { + if (groupId === 'party' || user.party._id === groupId) { query = {type: 'party', _id: user.party._id}; + } else if (optionalMembership === true) { + query = {_id: groupId}; } else if (user.guilds.indexOf(groupId) !== -1) { query = {type: 'guild', _id: groupId}; } else { From bd87ada9029e226441ec920773081775bc61bee4 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 25 Jan 2016 15:22:54 -0600 Subject: [PATCH 400/976] Updated chat tests with new async/await syntax --- .../integration/chat/POST-chat.flag.test.js | 105 ++++++--------- .../integration/chat/POST-chat.like.test.js | 117 +++++++---------- .../api/v3/integration/chat/POST-chat.test.js | 124 ++++++++---------- 3 files changed, 144 insertions(+), 202 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 16f91a60e8..51a3abb164 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -5,12 +5,13 @@ import { import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { - let user, admin, group; + let user, admin, anotherUser, group; const TEST_MESSAGE = 'Test Message'; before(async () => { user = await generateUser({balance: 1}); admin = await generateUser({balance: 1, 'contributor.admin': true}); + anotherUser = await generateUser(); group = await user.post('/groups', { name: 'Test Guild', @@ -20,7 +21,7 @@ describe('POST /chat/:chatId/flag', () => { }); it('Returns an error when chat message is not found', async () => { - return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) + await expect(user.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -29,79 +30,55 @@ describe('POST /chat/:chatId/flag', () => { }); it('Returns an error when user tries to flag their own message', async () => { - return user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}) - .then((result) => { - return expect(user.post(`/groups/${group._id}/chat/${result.message.id}/flag`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatFlagOwnMessage'), - }); - }); + let message = await user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE }); + await expect(user.post(`/groups/${group._id}/chat/${message.message.id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatFlagOwnMessage'), + }); }); it('Flags a chat', async () => { - let message; + let message = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); + message = message.message; - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/flag`); - }) - .then((result) => { - expect(result.flags[user._id]).to.equal(true); - expect(result.flagCount).to.equal(1); - return admin.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.flags[user._id]).to.equal(true); - }); + let flagResult = await user.post(`/groups/${group._id}/chat/${message.id}/flag`); + expect(flagResult.flags[user._id]).to.equal(true); + expect(flagResult.flagCount).to.equal(1); + + let groupWithFlags = await admin.get(`/groups/${group._id}`); + + let messageToCheck = find(groupWithFlags.chat, {id: message.id}); + expect(messageToCheck.flags[user._id]).to.equal(true); }); it('Flags a chat with a higher flag acount when an admin flags the message', async () => { - let secondUser; - let message; + let message = await user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); + message = message.message; - return generateUser({'contributor.admin': true}).then((generatedUser) => { - secondUser = generatedUser; - return user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); - }) - .then((result) => { - message = result.message; - return secondUser.post(`/groups/${group._id}/chat/${message.id}/flag`); - }) - .then((result) => { - expect(result.flags[secondUser._id]).to.equal(true); - expect(result.flagCount).to.equal(5); - return admin.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.flags[secondUser._id]).to.equal(true); - expect(messageToCheck.flagCount).to.equal(5); - }); + let flagResult = await admin.post(`/groups/${group._id}/chat/${message.id}/flag`); + expect(flagResult.flags[admin._id]).to.equal(true); + expect(flagResult.flagCount).to.equal(5); + + let groupWithFlags = await admin.get(`/groups/${group._id}`); + + let messageToCheck = find(groupWithFlags.chat, {id: message.id}); + expect(messageToCheck.flags[admin._id]).to.equal(true); + expect(messageToCheck.flagCount).to.equal(5); }); it('Returns an error when user tries to flag a message that is already flagged', async () => { - let message; + let message = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); + message = message.message; - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/flag`); - }) - .then(() => { - return expect(user.post(`/groups/${group._id}/chat/${message.id}/flag`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatFlagAlreadyReported'), - }); - }); + await user.post(`/groups/${group._id}/chat/${message.id}/flag`); + + await expect(user.post(`/groups/${group._id}/chat/${message.id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatFlagAlreadyReported'), + }); }); }); diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index cf266431ba..d7ae6047df 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -1,36 +1,32 @@ import { - generateUser, + createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { find } from 'lodash'; describe('POST /chat/:chatId/like', () => { let user; - let group; + let groupWithChat; let testMessage = 'Test Message'; + let anotherUser; - before(() => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - - return generateUser({balance: 1}).then((generatedUser) => { - user = generatedUser; - }) - .then(() => { - return user.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((generatedGroup) => { - group = generatedGroup; + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + members: 1, }); + + user = groupLeader; + groupWithChat = group; + anotherUser = members[0]; }); - it('Returns an error when chat message is not found', () => { - return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/like`)) + it('Returns an error when chat message is not found', async () => { + await expect(user.post(`/groups/${groupWithChat._id}/chat/incorrectMessage/like`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -38,59 +34,42 @@ describe('POST /chat/:chatId/like', () => { }); }); - it('Returns an error when user tries to like their own message', () => { - return user.post(`/groups/${group._id}/chat`, { message: testMessage}) - .then((result) => { - return expect(user.post(`/groups/${group._id}/chat/${result.message.id}/like`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatLikeOwnMessage'), - }); - }); + it('Returns an error when user tries to like their own message', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + + await expect(user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatLikeOwnMessage'), + }); }); - it('Likes a chat', () => { - let message; + it('Likes a chat', async () => { + let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/like`); - }) - .then((result) => { - expect(result.likes[user._id]).to.equal(true); - return user.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.likes[user._id]).to.equal(true); - }); + let likeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); + + expect(likeResult.likes[user._id]).to.equal(true); + + let groupWithChatLikes = await user.get(`/groups/${groupWithChat._id}`); + + let messageToCheck = find(groupWithChatLikes.chat, {id: message.message.id}); + expect(messageToCheck.likes[user._id]).to.equal(true); }); - it('Unlikes a chat', () => { - let message; + it('Unlikes a chat', async () => { + let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/like`); - }) - .then((result) => { - expect(result.likes[user._id]).to.equal(true); - return user.post(`/groups/${group._id}/chat/${message.id}/like`); - }) - .then((result) => { - expect(result.likes[user._id]).to.equal(false); - return user.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.likes[user._id]).to.equal(false); - }); + let likeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); + expect(likeResult.likes[user._id]).to.equal(true); + + let unlikeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); + expect(unlikeResult.likes[user._id]).to.equal(false); + + let groupWithoutChatLikes = await user.get(`/groups/${groupWithChat._id}`); + + let messageToCheck = find(groupWithoutChatLikes.chat, {id: message.message.id}); + expect(messageToCheck.likes[user._id]).to.equal(false); }); }); diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index fe943bfb1c..99d1469af8 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -1,95 +1,81 @@ import { - generateUser, + createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; describe('POST /chat', () => { - let user; + let user, groupWithChat, userWithChatRevoked, member; + let testMessage = 'Test Message'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + members: 2, }); + + user = groupLeader; + groupWithChat = group; + userWithChatRevoked = await members[0].update({'flags.chatRevoked': true}); + member = members[0]; }); - it('Returns an error when no message is provided', () => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - let testMessage = ''; - - return generateUser({balance: 1}).then((anotherUser) => { - return anotherUser.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, + it('Returns an error when no message is provided', async () => { + await expect(user.post(`/groups/${groupWithChat._id}/chat`, { message: ''})) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), }); - }) - .then((group) => { - return expect(user.post(`/groups/${group._id}/chat`, { message: testMessage})) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); }); - it('Returns an error when group is not found', () => { - let testMessage = 'Test Message'; - return expect(user.post('/groups/nvalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ + it('Returns an error when group is not found', async () => { + await expect(user.post('/groups/invalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); }); - it('Returns an error when chat privileges are revoked', () => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - let testMessage = 'Test Message'; - let userWithoutChat; - - return generateUser({balance: 1, 'flags.chatRevoked': true}).then((generatedUser) => { - userWithoutChat = generatedUser; - - return userWithoutChat.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((group) => { - return expect(userWithoutChat.post(`/groups/${group._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: 'Your chat privileges have been revoked.', - }); + it('Returns an error when chat privileges are revoked', async () => { + await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Your chat privileges have been revoked.', }); }); - it('creates a chat', () => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - let testMessage = 'Test Message'; - let anotherUser; + it('creates a chat', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - return generateUser({balance: 1}).then((generatedUser) => { - anotherUser = generatedUser; + expect(message.message.id).to.exist; + }); - return anotherUser.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((group) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); - }) - .then((result) => { - expect(result.message.id).to.exist; + it('notifies other users of new messages for a guild', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + let memberWithNotification = await member.get('/user'); + + expect(message.message.id).to.exist; + expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.exist; + }); + + it('notifies other users of new messages for a party', async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + privacy: 'private', + }, + members: 1, }); + + let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage}); + let memberWithNotification = await members[0].get('/user'); + + expect(message.message.id).to.exist; + expect(memberWithNotification.newMessages[`${group._id}`]).to.exist; }); }); From f12c9d23536e44be9b3d9b1a8de7d5388406824c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 26 Jan 2016 00:20:56 +0100 Subject: [PATCH 401/976] fix removeMember route and tests --- .../groups/POST-groups_id_removeMember.test.js | 2 +- website/src/controllers/api-v3/groups.js | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js index cf98902887..0ebde39361 100644 --- a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js +++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js @@ -64,7 +64,7 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); let memberRemoved = await member.get('/user'); - expect(_.findIndex(memberRemoved.guilds, {id: guild._id})).eql(-1); + expect(memberRemoved.guilds.indexOf(guild._id)).eql(-1); }); it('updates memberCount', async () => { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 0c6a35330e..dd298adbf3 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -394,18 +394,23 @@ api.removeGroupMember = { group.memberCount -= 1; if (group.quest && group.quest.leader === member._id) { - group.quest.key = null; - group.quest.leader = null; // TODO markmodified? + group.quest.key = undefined; + group.quest.leader = undefined; } else if (group.quest && group.quest.members) { // remove member from quest - group.quest.members[member._id] = undefined; + group.quest.members[member._id] = undefined; // TODO remmeber to check these are mark modified everywhere + group.markModified('quest.members'); } - if (isInGroup === 'guild') _.pull(member.guilds, group._id); + if (isInGroup === 'guild') { + let i = member.guilds.indexOf(group._id); + if (i !== -1) member.guilds.splice(i, 1); + } if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? - if (member.newMessages.group) { - member.newMessages.group._id = undefined; + if (member.newMessages[group._id]) { + member.newMessages[group._id] = undefined; + member.markModified('newMessages'); } if (group.quest && group.quest.active && group.quest.leader === member._id) { From 95603b9d214d1f00bb0c23ea14d63fbd3f8f8026 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 21 Jan 2016 07:54:17 -0600 Subject: [PATCH 402/976] tests: Change baseModel test to more accurately reflect use case --- test/api/v3/unit/libs/baseModel.test.js | 27 +++++++++---------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 95960c704d..17f3f7834a 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -1,23 +1,16 @@ import baseModel from '../../../../../website/src/libs/api-v3/baseModel'; +import { Schema } from 'mongoose'; describe('Base model plugin', () => { - let schema = { - add () { - return true; - }, - statics: {}, - options: {}, - pre () { - return true; - }, - }; + let schema; beforeEach(() => { + schema = new Schema(); sandbox.stub(schema, 'add'); }); it('adds a _id field to the schema', () => { - baseModel(schema); + schema.plugin(baseModel); expect(schema.add).to.be.calledWith(sinon.match({ _id: sinon.match.object, @@ -25,13 +18,13 @@ describe('Base model plugin', () => { }); it('can add timestamps fields', () => { - baseModel(schema, {timestamps: true}); + schema.plugin(baseModel, {timestamps: true}); expect(schema.add).to.be.calledTwice; }); it('can sanitize input objects', () => { - baseModel(schema, { + schema.plugin(baseModel, { noSet: ['noUpdateForMe'], }); @@ -44,7 +37,7 @@ describe('Base model plugin', () => { }); it('accepts an array of additional fields to sanitize at runtime', () => { - baseModel(schema, { + schema.plugin(baseModel, { noSet: ['noUpdateForMe'], }); @@ -58,7 +51,7 @@ describe('Base model plugin', () => { it('can make fields private', () => { - baseModel(schema, { + schema.plugin(baseModel, { private: ['amPrivate'], }); @@ -76,7 +69,7 @@ describe('Base model plugin', () => { toJSONTransform: sandbox.stub().returns(true), }; - baseModel(schema, options); + schema.plugin(baseModel, options); let objToTransform = {ok: true, amPrivate: true}; let privatized = schema.options.toJSON.transform({}, objToTransform); @@ -91,7 +84,7 @@ describe('Base model plugin', () => { sanitizeTransform: sandbox.stub().returns(true), }; - baseModel(schema, options); + schema.plugin(baseModel, options); expect(schema.options.toJSON.transform).to.exist; let objToSanitize = {ok: true, noUpdateForMe: true}; From e5f1c44a3e7aa24c3bddfa5ab37055bd546d9365 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 21 Jan 2016 08:05:51 -0600 Subject: [PATCH 403/976] feat: Add removeFromArray function --- test/api/v3/unit/libs/baseModel.test.js | 4 +- .../unit/libs/collectionManipulators.test.js | 88 +++++++++++++++++++ test/helpers/api-unit.helper.js | 19 +++- website/src/controllers/api-v3/chat.js | 4 +- website/src/controllers/api-v3/groups.js | 12 ++- website/src/controllers/api-v3/tasks.js | 33 +++---- .../src/libs/api-v3/collectionManipulators.js | 19 ++++ website/src/models/group.js | 4 +- website/src/models/user.js | 8 +- 9 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 test/api/v3/unit/libs/collectionManipulators.test.js create mode 100644 website/src/libs/api-v3/collectionManipulators.js diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 17f3f7834a..ebde4ee3ea 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -1,11 +1,11 @@ import baseModel from '../../../../../website/src/libs/api-v3/baseModel'; -import { Schema } from 'mongoose'; +import mongoose from 'mongoose'; describe('Base model plugin', () => { let schema; beforeEach(() => { - schema = new Schema(); + schema = new mongoose.Schema(); sandbox.stub(schema, 'add'); }); diff --git a/test/api/v3/unit/libs/collectionManipulators.test.js b/test/api/v3/unit/libs/collectionManipulators.test.js new file mode 100644 index 0000000000..fff818b32e --- /dev/null +++ b/test/api/v3/unit/libs/collectionManipulators.test.js @@ -0,0 +1,88 @@ +import mongoose from 'mongoose'; +import { + removeFromArray, +} from '../../../../../website/src/libs/api-v3/collectionManipulators'; + +describe('Collection Manipulators', () => { + describe('removeFromArray', () => { + it('removes item from specified array on document', () => { + let array = ['a', 'b', 'c', 'd']; + + removeFromArray(array, 'c'); + + expect(array).to.not.include('c'); + }); + + it('removes object from array', () => { + let array = [ + { id: 'a', foo: 'bar' }, + { id: 'b', foo: 'bar' }, + { id: 'c', foo: 'bar' }, + { id: 'd', foo: 'bar' }, + { id: 'e', foo: 'bar' }, + ]; + + removeFromArray(array, { id: 'c' }); + + expect(array).to.not.include({ id: 'c', foo: 'bar' }); + }); + + it('does not change array if value is not found', () => { + let array = ['a', 'b', 'c', 'd']; + + removeFromArray(array, 'z'); + + expect(array).to.have.a.lengthOf(4); + expect(array[0]).to.eql('a'); + expect(array[1]).to.eql('b'); + expect(array[2]).to.eql('c'); + expect(array[3]).to.eql('d'); + }); + + it('returns the removed element', () => { + let array = ['a', 'b', 'c']; + + let result = removeFromArray(array, 'b'); + + expect(result).to.eql('b'); + }); + + it('returns the removed object element', () => { + let array = [ + { id: 'a', foo: 'bar' }, + { id: 'b', foo: 'bar' }, + { id: 'c', foo: 'bar' }, + { id: 'd', foo: 'bar' }, + { id: 'e', foo: 'bar' }, + ]; + + let result = removeFromArray(array, { id: 'c' }); + + expect(result).to.eql({ id: 'c', foo: 'bar' }); + }); + + it('returns false if item is not found', () => { + let array = ['a', 'b', 'c']; + + let result = removeFromArray(array, 'z'); + + expect(result).to.eql(false); + }); + + it('removal of element persists when mongoose document is saved', async () => { + let schema = new mongoose.Schema({ + array: Array, + }); + let Model = mongoose.model('ModelToTestRemoveFromArray', schema); + let model = await new Model({ + array: ['a', 'b', 'c'], + }).save(); // Initial creation + + removeFromArray(model.array, 'b'); + + let savedModel = await model.save(); + + expect(savedModel.array).to.not.include('b'); + }); + }); +}); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index cefd1eeeeb..0a4a22df79 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -1,10 +1,27 @@ import '../../website/src/libs/api-v3/i18n'; +import mongoose from 'mongoose'; import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; -afterEach(() => { +mongoose.Promise = require('q').Promise; + +mongoose.connect('mongodb://localhost/habitica-unit-tests'); +let connection = mongoose.connection; + +before((done) => { + connection.on('open', () => { + connection.db.dropDatabase(done); + }); +}); + +after((done) => { + connection.close(done); +}); + +afterEach((done) => { sandbox.restore(); + connection.db.dropDatabase(done); }); export function generateUser (options = {}) { diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 6c491c4e7c..5c8d4fd3ed 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -7,6 +7,7 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import _ from 'lodash'; +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; @@ -385,8 +386,7 @@ api.deleteChat = { if (chatUpdated) { group = group.toJSON(); - let i = _.findIndex(group.chat, {id: chatId}); - if (i !== -1) group.chat.splice(i, 1); + removeFromArray(group.chat, {id: chatId}); res.respond(200, group.chat); } else { res.respond(200, {}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index dd298adbf3..b9b633c111 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -13,6 +13,7 @@ import { BadRequest, NotAuthorized, } from '../../libs/api-v3/errors'; +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; @@ -254,11 +255,10 @@ api.joinGroup = { isUserInvited = true; } else if (group.type === 'guild') { - let i = _.findIndex(user.invitations.guilds, {id: group._id}); + let hasInvitation = removeFromArray(user.invitations.guilds, { id: group._id }); - if (i !== -1) { + if (hasInvitation) { isUserInvited = true; - user.invitations.guilds.splice(i, 1); // Remove invitation } else { isUserInvited = group.privacy === 'private' ? false : true; } @@ -403,8 +403,7 @@ api.removeGroupMember = { } if (isInGroup === 'guild') { - let i = member.guilds.indexOf(group._id); - if (i !== -1) member.guilds.splice(i, 1); + removeFromArray(member.guilds, group._id); } if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? @@ -418,8 +417,7 @@ api.removeGroupMember = { } } else if (isInvited) { if (isInvited === 'guild') { - let i = _.findIndex(member.invitations.guilds, {id: group._id}); - if (i !== -1) member.invitations.guilds.splice(i, 1); + removeFromArray(member.invitations.guilds, { id: group._id }); } if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified? } else { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 020ae5061b..2e72953ad1 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,6 +1,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { sendTaskWebhook } from '../../libs/api-v3/webhook'; +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as Tasks from '../../models/task'; import { model as Challenge } from '../../models/challenge'; import { @@ -383,14 +384,12 @@ api.scoreTask = { // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list if (task.type === 'todo') { if (!wasCompleted && task.completed) { - let i = user.tasksOrder.todos.indexOf(task._id); - if (i !== -1) user.tasksOrder.todos.splice(i, 1); + removeFromArray(user.tasksOrder.todos, task._id); } else if (wasCompleted && !task.completed) { - let i = user.tasksOrder.todos.indexOf(task._id); - if (i === -1) { + let hasTask = removeFromArray(user.tasksOrder.todos, task._id); + if (!hasTask) { user.tasksOrder.todos.push(task._id); // TODO push at the top? } else { // If for some reason it hadn't been removed TODO ok? - user.tasksOrder.todos.splice(i, 1); user.tasksOrder.push(task._id); } } @@ -684,10 +683,8 @@ api.removeChecklistItem = { } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); - if (itemI === -1) throw new NotFound(res.t('checklistItemNotFound')); - - task.checklist.splice(itemI, 1); + let hasItem = removeFromArray(task.checklist, { _id: req.params.itemId }); + if (!hasItem) throw new NotFound(res.t('checklistItemNotFound')); let savedTask = await task.save(); res.respond(200, {}); // TODO what to return @@ -769,24 +766,14 @@ api.removeTagFromTask = { if (!task) throw new NotFound(res.t('taskNotFound')); - let tagI = task.tags.indexOf(req.params.tagId); - if (tagI === -1) throw new NotFound(res.t('tagNotFound')); - - task.tags.splice(tagI, 1); + let hasTag = removeFromArray(task.tags, req.params.tagId); + if (!hasTag) throw new NotFound(res.t('tagNotFound')); await task.save(); res.respond(200, {}); // TODO what to return }, }; -// Remove a task from (user|challenge).tasksOrder -function _removeTaskTasksOrder (userOrChallenge, taskId, taskType) { - let list = userOrChallenge.tasksOrder[`${taskType}s`]; - let index = list.indexOf(taskId); - - if (index !== -1) list.splice(index, 1); -} - // TODO this method needs some limitation, like to check if the challenge is really broken? /** * @api {post} /tasks/unlink/:taskId Unlink a challenge task @@ -826,7 +813,7 @@ api.unlinkTask = { await task.save(); } else { // remove if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if - _removeTaskTasksOrder(user, taskId, task.type); + removeFromArray(user.tasksOrder[`${task.type}s`], taskId); await Q.all([user.save(), task.remove()]); } else { await task.remove(); @@ -904,7 +891,7 @@ api.deleteTask = { } if (task.type !== 'todo' || !task.completed) { - _removeTaskTasksOrder(challenge || user, taskId, task.type); + removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId); await Q.all([(challenge || user).save(), task.remove()]); } else { await task.remove(); diff --git a/website/src/libs/api-v3/collectionManipulators.js b/website/src/libs/api-v3/collectionManipulators.js new file mode 100644 index 0000000000..ce40085552 --- /dev/null +++ b/website/src/libs/api-v3/collectionManipulators.js @@ -0,0 +1,19 @@ +import { findIndex } from 'lodash'; + +export function removeFromArray (array, element) { + let elementIndex; + + if (typeof element === 'object') { + elementIndex = findIndex(array, element); + } else { + elementIndex = array.indexOf(element); + } + + if (elementIndex !== -1) { + let removedElement = array[elementIndex]; + array.splice(elementIndex, 1); + return removedElement; + } + + return false; +} diff --git a/website/src/models/group.js b/website/src/models/group.js index 8b11a57081..0acc03fe32 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -7,6 +7,7 @@ import shared from '../../../common'; import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; +import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import Q from 'q'; @@ -147,8 +148,7 @@ schema.methods.removeGroupInvitations = async function removeGroupInvitations () if (group.type === 'party') { user.invitations.party = {}; // TODO mark modified } else { - let i = _.findIndex(user.invitations.guilds, {id: group._id}); - user.invitations.guilds.splice(i, 1); + removeFromArray(user.invitations.guilds, { id: group._id }); } return user.save(); }); diff --git a/website/src/models/user.js b/website/src/models/user.js index 6ea4ede281..db466ce829 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -6,6 +6,7 @@ import moment from 'moment'; import * as Tasks from './task'; import Q from 'q'; import { schema as TagSchema } from './tag'; +import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import baseModel from '../libs/api-v3/baseModel'; // import {model as Challenge} from './challenge'; @@ -661,8 +662,7 @@ schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (chall 'challenge.id': challengeId, }; - let challengeIndex = user.challenges.indexOf(challengeId); - if (challengeIndex !== -1) user.challenges.splice(challengeIndex, 1); + removeFromArray(user.challenges, challengeId); if (keep === 'keep-all') { await Tasks.Task.update(findQuery, { @@ -675,9 +675,7 @@ schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (chall let taskPromises = tasks.map(task => { // Remove task from user.tasksOrder and delete them if (task.type !== 'todo' || !task.completed) { - let list = user.tasksOrder[`${task.type}s`]; - let index = list.indexOf(task._id); - if (index !== -1) list.splice(index, 1); + removeFromArray(user.tasksOrder[`${task.type}s`], task._id); } return task.remove(); From f430b6ff6fc17a08a5ebb11e6d7663a5ab3eed20 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 25 Jan 2016 21:46:53 -0600 Subject: [PATCH 404/976] tests: Correct phrasing of tests --- test/api/v3/unit/libs/collectionManipulators.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/unit/libs/collectionManipulators.test.js b/test/api/v3/unit/libs/collectionManipulators.test.js index fff818b32e..e32e953d51 100644 --- a/test/api/v3/unit/libs/collectionManipulators.test.js +++ b/test/api/v3/unit/libs/collectionManipulators.test.js @@ -5,7 +5,7 @@ import { describe('Collection Manipulators', () => { describe('removeFromArray', () => { - it('removes item from specified array on document', () => { + it('removes element from array', () => { let array = ['a', 'b', 'c', 'd']; removeFromArray(array, 'c'); @@ -69,7 +69,7 @@ describe('Collection Manipulators', () => { expect(result).to.eql(false); }); - it('removal of element persists when mongoose document is saved', async () => { + it('persists removal of element when mongoose document is saved', async () => { let schema = new mongoose.Schema({ array: Array, }); From 13b12830dac74ca18853ff79ab631c223ba31194 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Wed, 27 Jan 2016 09:31:10 +0200 Subject: [PATCH 405/976] Challenge leave route and tests --- .../POST-challenges_challengeId_leave.test.js | 123 ++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 38 ++++++ 2 files changed, 161 insertions(+) create mode 100644 test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js new file mode 100644 index 0000000000..9694b263a9 --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js @@ -0,0 +1,123 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges/:challengeId/leave', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.post('/challenges/test/leave')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/${generateUUID()}/leave`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Leaving a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let notInChallengeUser; + let leavingUser; + let taskText; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 2, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + leavingUser = populatedGroup.members[0]; + notInChallengeUser = populatedGroup.members[1]; + + challenge = await generateChallenge(groupLeader, group); + + taskText = 'A challenge task text'; + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await leavingUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to view the challenge', async () => { + let unauthorizedUser = await generateUser(); + + await expect(unauthorizedUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('returns an error when user isn\'t a member of the challenge', async () => { + await expect(notInChallengeUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('challengeMemberNotFound'), + }); + }); + + it('removes challenge from user challenges', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`); + + await leavingUser.sync(); + + expect(leavingUser).to.have.property('challenges').to.not.include(challenge._id); + }); + + it('decreases memberCount of challenge', async () => { + let oldMemberCount = challenge.memberCount; + + await leavingUser.post(`/challenges/${challenge._id}/leave`); + + await challenge.sync(); + + expect(challenge).to.have.property('memberCount', oldMemberCount - 1); + }); + + it('unlinks challenge tasks from leaving user when remove-all is passed', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`, { + keep: 'remove-all', + }); + let tasks = await leavingUser.get('/tasks/user'); + let tasksTexts = tasks.map((task) => { + return task.text; + }); + + expect(tasksTexts).to.not.include(taskText); + }); + + it('doesn\'t unlink challenge tasks from leaving user when remove-all isn\'t passed', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`, { + keep: 'test', + }); + + let tasks = await leavingUser.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask).to.not.be.undefined; + expect(testTask.challenge).to.be.undefined; + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index bd7f694cf6..858b26fc32 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -129,6 +129,44 @@ api.joinChallenge = { }, }; +/** + * @api {post} /challenges/:challengeId/leave Leaves a challenge + * @apiVersion 3.0.0 + * @apiName LeaveChallenge + * @apiGroup Challenge + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The challenge the user left + */ +api.leaveChallenge = { + method: 'POST', + url: '/challenges/:challengeId/leave', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let keep = req.body.keep === 'remove-all' ? 'remove-all' : 'keep-all'; + + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let challenge = await Challenge.findOne({ _id: req.params.challengeId }); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + + if (!challenge.isMember(user)) throw new NotAuthorized(res.t('challengeMemberNotFound')); + + challenge.memberCount -= 1; + + // Unlink challenge's tasks from user's tasks and save the challenge + await Q.all([user.unlinkChallengeTasks(challenge._id, keep), challenge.save()]); + res.respond(200, challenge); + }, +}; + /** * @api {get} /challenges Get challenges for a user * @apiVersion 3.0.0 From 995148cbfb9673ca33217ff92c2b9eb31da2707a Mon Sep 17 00:00:00 2001 From: KristianTashkov Date: Wed, 27 Jan 2016 11:37:52 +0200 Subject: [PATCH 406/976] Make challenge leave route return empty object on success --- website/src/controllers/api-v3/challenges.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 858b26fc32..4ffd9e301c 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -136,7 +136,7 @@ api.joinChallenge = { * @apiGroup Challenge * @apiParam {UUID} challengeId The challenge _id * - * @apiSuccess {object} challenge The challenge the user left + * @apiSuccess {object} empty An empty object */ api.leaveChallenge = { method: 'POST', @@ -163,7 +163,7 @@ api.leaveChallenge = { // Unlink challenge's tasks from user's tasks and save the challenge await Q.all([user.unlinkChallengeTasks(challenge._id, keep), challenge.save()]); - res.respond(200, challenge); + res.respond(200, {}); }, }; From f16b605c379daaf5b82bf59b32e9f7066720fe6c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 27 Jan 2016 12:22:24 +0100 Subject: [PATCH 407/976] add update challenge with tests --- common/locales/en/api-v3.json | 1 + .../PUT-challenges_challengeId.test.js | 76 +++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 41 +++++++++- website/src/controllers/api-v3/tasks.js | 2 +- website/src/models/challenge.js | 16 ++-- website/src/models/user.js | 2 +- 6 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index e6dc9bee55..a435098a1d 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -50,6 +50,7 @@ "winnerIdRequired": "\"winnerId\" must be a valid UUID.", "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", + "onlyLeaderUpdateChal": "Only the challenge leader can update it.", "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js new file mode 100644 index 0000000000..e916ab5b7a --- /dev/null +++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js @@ -0,0 +1,76 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('PUT /challenges/:challengeId', () => { + let privateGuild, user, nonMember, challenge, member; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestPrivateGuild', + type: 'guild', + privacy: 'private', + }, + members: 1, + }); + + privateGuild = group; + user = groupLeader; + + nonMember = await generateUser(); + member = members[0]; + + challenge = await generateChallenge(user, group); + await member.post(`/challenges/${challenge._id}/join`); + }); + + it('fails if the user can\'t view the challenge', async () => { + await expect(nonMember.put(`/challenges/${challenge._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('should only allow the leader or an admin to update the challenge', async () => { + await expect(member.put(`/challenges/${challenge._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderUpdateChal'), + }); + }); + + it('only updates allowed fields', async () => { + let res = await user.put(`/challenges/${challenge._id}`, { + // ignored + prize: 33, + groupId: 'blabla', + memberCount: 33, + tasksOrder: 'new order', + official: true, + shortName: 'new short name', + + // applied + name: 'New Challenge Name', + description: 'New challenge description.', + leader: member._id, + }); + + expect(res.prize).to.equal(0); + expect(res.groupId).to.equal(privateGuild._id); + expect(res.memberCount).to.equal(2); + expect(res.tasksOrder).not.to.equal('new order'); + expect(res.official).to.equal(false); + expect(res.shortName).not.to.equal('new short name'); + + expect(res.leader).to.equal(member._id); + expect(res.name).to.equal('New Challenge Name'); + expect(res.description).to.equal('New challenge description.'); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index bd7f694cf6..172ff5783b 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -3,7 +3,9 @@ import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; -import { model as User } from '../../models/user'; +import { + model as User, +} from '../../models/user'; import { NotFound, NotAuthorized, @@ -199,6 +201,43 @@ api.getChallenge = { }, }; +/** + * @api {put} /challenges/:challengeId Update a challenge + * @apiVersion 3.0.0 + * @apiName UpdateChallenge + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The updated challenge object + */ +api.updateChallenge = { + method: 'PUT', + url: '/challenges/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findById(challengeId).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id name type privacy', optionalMembership: true}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderUpdateChal')); + + _.merge(challenge, Challenge.sanitizeUpdate(req.body)); + + let savedChal = await challenge.save(); + res.respond(200, savedChal); + }, +}; + // TODO everything here should be moved to a worker // actually even for a worker it's probably just to big and will kill mongo function _closeChal (challenge, broken = {}) { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 2e72953ad1..0197d38dbb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -302,7 +302,7 @@ api.updateTask = { delete req.body.tags; } - // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, very bad for performances + // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, bad for performances? // TODO regarding comment above make sure other models with nested fields are using this trick too _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body))); // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index ed9a88213e..c044caaba6 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -10,9 +10,9 @@ let Schema = mongoose.Schema; let schema = new Schema({ name: {type: String, required: true}, - shortName: {type: String, required: true}, // TODO what is it? + shortName: {type: String, required: true}, description: String, - official: {type: Boolean, default: false}, // TODO only settable by admin + official: {type: Boolean, default: false}, tasksOrder: { habits: [{type: String, ref: 'Task'}], dailys: [{type: String, ref: 'Task'}], @@ -20,16 +20,22 @@ let schema = new Schema({ rewards: [{type: String, ref: 'Task'}], }, leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, - groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, // TODO no update, no set? - timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable? + groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, memberCount: {type: Number, default: 1}, prize: {type: Number, default: 0, min: 0}, // TODO no update? }); schema.plugin(baseModel, { - noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], + noSet: ['_id', 'memberCount', 'tasksOrder'], + timestamps: true, }); +// A list of additional fields that cannot be updated (but can be set on creation) +let noUpdate = ['groupId', 'official', 'shortName', 'prize']; +schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { + return this.sanitize(updateObj, noUpdate); +}; + // Returns true if user is a member of the challenge schema.methods.isMember = function isChallengeMember (user) { return user.challenges.indexOf(this._id) !== -1; diff --git a/website/src/models/user.js b/website/src/models/user.js index db466ce829..cc1ebccf54 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -654,7 +654,7 @@ schema.methods.isSubscribed = function isSubscribed () { return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion }; -// Unlink challenges tasks from user +// Unlink challenges tasks (and the challenge itself) from user schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (challengeId, keep) { let user = this; let findQuery = { From 4518d3693c2dd06f5f23ce5a71e85014533317f2 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 27 Jan 2016 20:28:54 +0100 Subject: [PATCH 408/976] add exportChallengeCsv route (missing tests) --- package.json | 1 + website/src/controllers/api-v3/challenges.js | 74 ++++++++++++++++++++ website/src/libs/api-v3/csvStringify.js | 11 +++ website/src/models/challenge.js | 4 +- 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 website/src/libs/api-v3/csvStringify.js diff --git a/package.json b/package.json index 0f9ee88db0..b7de9c1f5a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "cookie-parser": "^1.4.0", "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", + "csv-stringify": "^1.0.1", "domain-middleware": "~0.1.0", "estraverse": "^4.1.1", "express": "~4.13.3", diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3c45f0e167..ea3e787602 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -5,6 +5,7 @@ import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; import { model as User, + nameFields, } from '../../models/user'; import { NotFound, @@ -15,6 +16,7 @@ import * as Tasks from '../../models/task'; import { txnEmail } from '../../libs/api-v3/email'; import pushNotify from '../../libs/api-v3/pushNotifications'; import Q from 'q'; +import csvStringify from '../../libs/api-v3/csvStringify'; let api = {}; @@ -239,6 +241,78 @@ api.getChallenge = { }, }; +/** + * @api {get} /challenges/:challengeId/export/csv Export a challenge in CSV + * @apiVersion 3.0.0 + * @apiName ExportChallengeCsv + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The challenge object + */ +api.exportChallengeCsv = { + method: 'GET', + url: '/challenges/:challengeId/export/csv', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findById(challengeId).select('_id groupId leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + + // In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all + // results on the server so the perf difference isn't that big (hopefully) + + let challengeTasks = _.reduce(challenge.tasksOrder, (result, array) => { + return result.concat(array); + }, []).sort(); + + let [members, tasks] = await Q.all([ + User.find({challenges: challengeId}) + .select(nameFields) + .sortBy({_id: 1}) + .lean() // so we don't involve mongoose + .exec(), + + Tasks.Task.find({'task.challenge.id': challengeId, userId: {$exists: true}}) + .sortBy({userId: 1, _id: 1}).select('userId type text value notes').lean().exec(), + ]); + + let resArray = members.map(member => [member._id, member.profile.name]); + + // We assume every user in the challenge as at least some data so we can say that members[0] tasks will be at tasks [0] + let lastUserId; + let index = -1; + tasks.forEach(task => { + if (task.userId !== lastUserId) { + lastUserId = task.userId; + index++; + } + + resArray[index].push(`${task.type}:${task.text}`, task.value, task.notes); + }); + + // The first row is going to be UUID name Task Value Notes repeated n times for the n challenge tasks + resArray.unshift(['UUID', 'name']); + _.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes')); + + res.set({ + 'Content-Type': 'text/csv', + 'Content-disposition': `attachment; filename=${challengeId}.csv`, + }); + res.status(200).send(await csvStringify(resArray)); + }, +}; + /** * @api {put} /challenges/:challengeId Update a challenge * @apiVersion 3.0.0 diff --git a/website/src/libs/api-v3/csvStringify.js b/website/src/libs/api-v3/csvStringify.js new file mode 100644 index 0000000000..3a597ff55c --- /dev/null +++ b/website/src/libs/api-v3/csvStringify.js @@ -0,0 +1,11 @@ +import csvStringify from 'csv-stringify'; +import Q from 'q'; + +export default function (input) { + return Q.promise((resolve, reject) => { + csvStringify(input, (err, output) => { + if (err) return reject(err); + return resolve(output); + }); + }); +} diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index c044caaba6..040a6c503f 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -48,9 +48,9 @@ schema.methods.canModify = function canModifyChallenge (user) { // Returns true if user has access to the challenge (can join) schema.methods.hasAccess = function hasAccessToChallenge (user) { - let userGroups = user.guilds.slice(0); + let userGroups = user.guilds.slice(0); // clone user.guilds so we don't modify the original if (user.party._id) userGroups.push(user.party._id); - userGroups.push('habitrpg'); // tavern challenges + userGroups.push('habitrpg'); // tavern return this.canModify(user) || userGroups.indexOf(this.groupId) !== -1; }; From 6d38caf78bd32925b961cef140170d668d7eb735 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 27 Jan 2016 20:52:47 +0100 Subject: [PATCH 409/976] tests for challengeExportCsv --- ...-challenges_challengeId_export_csv.test.js | 59 +++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 19 +++--- 2 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js new file mode 100644 index 0000000000..cc7f408951 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -0,0 +1,59 @@ +import { + generateUser, + createAndPopulateGroup, + generateChallenge, + translate as t, + sleep, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /challenges/:challengeId/export/csv', () => { + let groupLeader; + let group; + let challenge; + let members; + let user; + + beforeEach(async () => { + user = await generateUser(); + + let populatedGroup = await createAndPopulateGroup({ + members: 3, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + members = populatedGroup.members; + + challenge = await generateChallenge(groupLeader, group); + await members[0].post(`/challenges/${challenge._id}/join`); + await members[1].post(`/challenges/${challenge._id}/join`); + await members[2].post(`/challenges/${challenge._id}/join`); + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: 'Task 1'}, + {type: 'todo', text: 'Task 2'}, + ]); + await sleep(1); + }); + + it('fails if challenge doesn\'t exists', async () => { + await expect(user.get(`/challenges/${generateUUID()}/export/csv`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('fails if user doesn\'t have access to the challenge', async () => { + await expect(user.get(`/challenges/${challenge._id}/export/csv`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('should return a valid CSV file with export data', async () => { + await members[0].get(`/challenges/${challenge._id}/export/csv`); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index ea3e787602..3169650a0d 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -264,7 +264,7 @@ api.exportChallengeCsv = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findById(challengeId).select('_id groupId leader').exec(); + let challenge = await Challenge.findById(challengeId).select('_id groupId leader tasksOrder').exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); @@ -272,19 +272,15 @@ api.exportChallengeCsv = { // In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all // results on the server so the perf difference isn't that big (hopefully) - let challengeTasks = _.reduce(challenge.tasksOrder, (result, array) => { - return result.concat(array); - }, []).sort(); - let [members, tasks] = await Q.all([ User.find({challenges: challengeId}) .select(nameFields) - .sortBy({_id: 1}) + .sort({_id: 1}) .lean() // so we don't involve mongoose .exec(), - Tasks.Task.find({'task.challenge.id': challengeId, userId: {$exists: true}}) - .sortBy({userId: 1, _id: 1}).select('userId type text value notes').lean().exec(), + Tasks.Task.find({'challenge.id': challengeId, userId: {$exists: true}}) + .sort({userId: 1, _id: 1}).select('userId type text value notes').lean().exec(), ]); let resArray = members.map(member => [member._id, member.profile.name]); @@ -302,6 +298,9 @@ api.exportChallengeCsv = { }); // The first row is going to be UUID name Task Value Notes repeated n times for the n challenge tasks + let challengeTasks = _.reduce(challenge.tasksOrder.toObject(), (result, array) => { + return result.concat(array); + }, []).sort(); resArray.unshift(['UUID', 'name']); _.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes')); @@ -309,7 +308,9 @@ api.exportChallengeCsv = { 'Content-Type': 'text/csv', 'Content-disposition': `attachment; filename=${challengeId}.csv`, }); - res.status(200).send(await csvStringify(resArray)); + + let csvRes = await csvStringify(resArray); + res.status(200).send(csvRes); }, }; From 32be629878ccd36cbb86e22dc797c454e139cd16 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Wed, 27 Jan 2016 23:03:33 +0200 Subject: [PATCH 410/976] Fix failing unit tests on windows --- test/api/v3/unit/libs/i18n.test.js | 7 ------- test/api/v3/unit/libs/setupNconf.test.js | 5 ++++- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/test/api/v3/unit/libs/i18n.test.js b/test/api/v3/unit/libs/i18n.test.js index 1136f255a6..098bfefa21 100644 --- a/test/api/v3/unit/libs/i18n.test.js +++ b/test/api/v3/unit/libs/i18n.test.js @@ -31,13 +31,6 @@ describe('i18n', () => { }); }); - describe('localePath', () => { - it('is an absolute path to common/locales/', () => { - expect(localePath).to.match(/.*\/common\/locales\//); - expect(localePath); - }); - }); - describe('langCodes', () => { it('is a list of all the language codes', () => { expect(langCodes.sort()).to.eql(listOfLocales); diff --git a/test/api/v3/unit/libs/setupNconf.test.js b/test/api/v3/unit/libs/setupNconf.test.js index 1fbb046a85..e0647d38d2 100644 --- a/test/api/v3/unit/libs/setupNconf.test.js +++ b/test/api/v3/unit/libs/setupNconf.test.js @@ -1,5 +1,6 @@ import setupNconf from '../../../../../website/src/libs/api-v3/setupNconf'; +import path from 'path'; import nconf from 'nconf'; describe('setupNconf', () => { @@ -19,7 +20,9 @@ describe('setupNconf', () => { expect(nconf.argv).to.be.calledOnce; expect(nconf.env).to.be.calledOnce; expect(nconf.file).to.be.calledOnce; - expect(nconf.file).to.be.calledWithMatch('user', /\/config.json$/); + + let regexString = `\\${path.sep}config.json$`; + expect(nconf.file).to.be.calledWithMatch('user', new RegExp(regexString)); }); it('sets IS_PROD variable', () => { From 6dce552140c0e0d69185dc97de27e0f3a55cf3a6 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Thu, 28 Jan 2016 00:01:13 +0200 Subject: [PATCH 411/976] Challenge select winner route tests --- ...lenges_challengeId_winner_winnerId.test.js | 114 ++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 8 +- 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js new file mode 100644 index 0000000000..c079086777 --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js @@ -0,0 +1,114 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + sleep, + checkExistence, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges/:challengeId/winner/:winnerId', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/test/selectWinner/${user._id}`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when winnerId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/${generateUUID()}/selectWinner/test`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/${generateUUID()}/selectWinner/${user._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Selecting winner for a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let winningUser; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 1, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + winningUser = populatedGroup.members[0]; + + challenge = await generateChallenge(groupLeader, group, { + prize: 1, + }); + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: 'A challenge task text'}, + ]); + + await winningUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to select winner', async () => { + await expect(winningUser.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderDeleteChal'), + }); + }); + + it('returns an error when winning user isn\'t part of the challenge', async () => { + let notInChallengeUser = await generateUser(); + + await expect(groupLeader.post(`/challenges/${challenge._id}/selectWinner/${notInChallengeUser._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('winnerNotFound', {userId: notInChallengeUser._id}), + }); + }); + + it('deletes challenge after winner is selected', async () => { + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false); + }); + + it('adds challenge to winner\'s achievements', async () => { + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(winningUser.sync()).to.eventually.have.deep.property('achievements.challenges').to.include(challenge.name); + }); + + it('gives winner gems as reward', async () => { + let oldBalance = winningUser.balance; + + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(winningUser.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3169650a0d..3c14fc69aa 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -438,9 +438,9 @@ api.deleteChallenge = { }; /** - * @api {delete} /challenges/:challengeId Delete a challenge + * @api {post} /challenges/:challengeId/selectWinner/:winnerId Select winner for challenge * @apiVersion 3.0.0 - * @apiName DeleteChallenge + * @apiName SelectChallengeWinner * @apiGroup Challenge * * @apiSuccess {object} empty An empty object @@ -452,7 +452,7 @@ api.selectChallengeWinner = { async handler (req, res) { let user = res.locals.user; - req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); @@ -463,7 +463,7 @@ api.selectChallengeWinner = { if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); let winner = await User.findOne({_id: req.params.winnerId}).exec(); - if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.parama.winnerId})); + if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId})); res.respond(200, {}); // Close channel in background From 1369327c44a7e6698950de48efb21671b66c91cc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 28 Jan 2016 11:30:53 +0100 Subject: [PATCH 412/976] requester should resolve response.text if response is not json, better tests for csv export --- ...GET-challenges_challengeId_export_csv.test.js | 16 ++++++++++++++-- test/helpers/api-integration/requester.js | 3 ++- website/src/controllers/api-v3/challenges.js | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js index cc7f408951..e4c1ceee34 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -34,7 +34,10 @@ describe('GET /challenges/:challengeId/export/csv', () => { {type: 'habit', text: 'Task 1'}, {type: 'todo', text: 'Task 2'}, ]); - await sleep(1); + await sleep(0.5); // Make sure tasks are synced to the users + await members[0].sync(); + await members[1].sync(); + await members[2].sync(); }); it('fails if challenge doesn\'t exists', async () => { @@ -54,6 +57,15 @@ describe('GET /challenges/:challengeId/export/csv', () => { }); it('should return a valid CSV file with export data', async () => { - await members[0].get(`/challenges/${challenge._id}/export/csv`); + let res = await members[0].get(`/challenges/${challenge._id}/export/csv`); + let sortedMembers = _.sortBy([members[0], members[1], members[2], groupLeader], '_id'); + let splitRes = res.split('\n'); + + expect(splitRes[0]).to.equal('UUID,name,Task,Value,Notes,Task,Value,Notes'); + expect(splitRes[1]).to.equal(`${sortedMembers[0]._id},${sortedMembers[0].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[2]).to.equal(`${sortedMembers[1]._id},${sortedMembers[1].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[3]).to.equal(`${sortedMembers[2]._id},${sortedMembers[2].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[4]).to.equal(`${sortedMembers[3]._id},${sortedMembers[3].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[5]).to.equal(''); }); }); diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index a55da4d3d8..de090f54aa 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -51,7 +51,8 @@ function _requestMaker (user, method, additionalSets) { reject(parsedError); } - resolve(response.body); + let contentType = response.headers['content-type'] || ''; + resolve(contentType.indexOf('json') !== -1 ? response.body : response.text); }); }); }; diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3169650a0d..e5d9fd4424 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -280,7 +280,7 @@ api.exportChallengeCsv = { .exec(), Tasks.Task.find({'challenge.id': challengeId, userId: {$exists: true}}) - .sort({userId: 1, _id: 1}).select('userId type text value notes').lean().exec(), + .sort({userId: 1, text: 1}).select('userId type text value notes').lean().exec(), ]); let resArray = members.map(member => [member._id, member.profile.name]); From c52064b3df02fd5308c9af2cf3ba75afe5c52ac8 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Thu, 28 Jan 2016 22:01:39 +0200 Subject: [PATCH 413/976] Add challenge select winner test for broken and winner flags of challenge tasks --- ...lenges_challengeId_winner_winnerId.test.js | 27 ++++++++++++++++++- website/src/controllers/api-v3/challenges.js | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js index c079086777..98dfc20926 100644 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js @@ -44,6 +44,7 @@ describe('POST /challenges/:challengeId/winner/:winnerId', () => { let group; let challenge; let winningUser; + let taskText = 'A challenge task text'; beforeEach(async () => { let populatedGroup = await createAndPopulateGroup({ @@ -59,7 +60,7 @@ describe('POST /challenges/:challengeId/winner/:winnerId', () => { }); await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ - {type: 'habit', text: 'A challenge task text'}, + {type: 'habit', text: taskText}, ]); await winningUser.post(`/challenges/${challenge._id}/join`); @@ -110,5 +111,29 @@ describe('POST /challenges/:challengeId/winner/:winnerId', () => { await expect(winningUser.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); }); + + it('doesn\'t refund gems to group leader', async () => { + let oldBalance = (await groupLeader.sync()).balance; + + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance); + }); + + it('sets broken and winner flags for user\'s challenge tasks', async () => { + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + let tasks = await winningUser.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask.challenge.broken).to.eql('CHALLENGE_CLOSED'); + expect(testTask.challenge.winner).to.eql(winningUser.profile.name); + }); }); }); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3c14fc69aa..fb91ed4079 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -467,7 +467,7 @@ api.selectChallengeWinner = { res.respond(200, {}); // Close channel in background - _closeChal(challenge, {broken: 'CHALLENGE_DELETED', winner}); + _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner}); }, }; From 2c378bf2f23201fbbc659b74dd0e0f72adc96ee4 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Thu, 28 Jan 2016 23:21:07 +0200 Subject: [PATCH 414/976] Add challenge delete tests --- .../DELETE-challenges_challengeId.test.js | 95 +++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 2 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js diff --git a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js new file mode 100644 index 0000000000..e8bd7a66d9 --- /dev/null +++ b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js @@ -0,0 +1,95 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + sleep, + checkExistence, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /challenges/:challengeId', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/test`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Deleting a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let taskText = 'A challenge task text'; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup(); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + + challenge = await generateChallenge(groupLeader, group); + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to delete the challenge', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderDeleteChal'), + }); + }); + + it('deletes challenge', async () => { + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false); + }); + + it('refunds gems to group leader', async () => { + let oldBalance = (await groupLeader.sync()).balance; + + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); + }); + + it('sets broken and doesn\'t set winner flags for user\'s challenge tasks', async () => { + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + let tasks = await groupLeader.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask.challenge.broken).to.eql('CHALLENGE_DELETED'); + expect(testTask.challenge.winner).to.be.null; + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index e5d9fd4424..2c3bfd40e3 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -422,7 +422,7 @@ api.deleteChallenge = { async handler (req, res) { let user = res.locals.user; - req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; From b960ecdd94e7d07742bb75a3359371f4c7c88113 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 11:13:39 +0100 Subject: [PATCH 415/976] first attempt to fix fialing api-v3 tests --- test/README.md | 5 ----- test/api/README.md | 8 +++++--- test/helpers/api-integration/mongo.js | 6 ++---- test/helpers/api-integration/requester.js | 4 +++- test/helpers/api-unit.helper.js | 3 --- test/helpers/common.helper.js | 1 - test/helpers/globals.helper.js | 1 - test/mocha.opts | 1 - 8 files changed, 10 insertions(+), 19 deletions(-) delete mode 100644 test/README.md diff --git a/test/README.md b/test/README.md deleted file mode 100644 index 8115753727..0000000000 --- a/test/README.md +++ /dev/null @@ -1,5 +0,0 @@ -We need to clean up this directory. The *real* tests are in spec/ mock/ e2e/ and api.mocha.coffee. We want to: - -1. Move all old / deprecated tests from casper, test2, etc into spec, mock, e2e -1. Remove dependency of api.mocha.coffee on Derby, port it to Mongoose -1. Add better test-coverage diff --git a/test/api/README.md b/test/api/README.md index b5223cc0f4..ff3296721f 100644 --- a/test/api/README.md +++ b/test/api/README.md @@ -1,5 +1,7 @@ # So you want to write API integration tests? +@TODO rewrite + That's great! This README will serve as a quick primer for style conventions and practices for these tests. ## What is this? @@ -73,7 +75,7 @@ POST-groups_id_leave.test.js To mitigate [callback hell](http://callbackhell.com/) :imp:, we've written a helper method to generate a user object that can make http requests that [return promises](https://babeljs.io/docs/learn-es2015/#promises). This makes it very easy to chain together commands. All you need to do to make a subsequent request is return another promise and then call `.then((result) => {})` on the surrounding block, like so: ```js -it('does something', () => { +it('does something', () => { let user; return generateUser().then((_user) => { // We return the initial promise so this test can be run asyncronously @@ -97,7 +99,7 @@ it('does something', () => { If the test is simple, you can use the [chai-as-promised](http://chaijs.com/plugins/chai-as-promised) `return expect(somePromise).to.eventually` syntax to make your assertion. ```js -it('makes the party creator the leader automatically', () => { +it('makes the party creator the leader automatically', () => { return expect(user.post('/groups', { type: 'party', })).to.eventually.have.deep.property('leader._id', user._id); @@ -107,7 +109,7 @@ it('makes the party creator the leader automatically', () => { If the test is checking that the request returns an error, use the `.eventually.be.rejected.and.eql` syntax. ```js -it('returns an error', () => { +it('returns an error', () => { return expect(user.get('/groups/id-of-a-party-that-user-does-not-belong-to')) .to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/helpers/api-integration/mongo.js b/test/helpers/api-integration/mongo.js index bbe22496b6..e3db242fd3 100644 --- a/test/helpers/api-integration/mongo.js +++ b/test/helpers/api-integration/mongo.js @@ -1,9 +1,7 @@ /* eslint-disable no-use-before-define */ - +import nconf from 'nconf'; import { MongoClient as mongo } from 'mongodb'; -const DB_URI = 'mongodb://localhost/habitrpg_test'; - // Useful for checking things that have been deleted, // but you no longer have access to, // like private parties or users @@ -81,7 +79,7 @@ export async function getDocument (collectionName, doc) { export function connectToMongo () { return new Promise((resolve, reject) => { - mongo.connect(DB_URI, (err, db) => { + mongo.connect(nconf.get('NODE_DB_URI'), (err, db) => { if (err) return reject(err); resolve(db); diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index de090f54aa..be2ea5c817 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -1,8 +1,10 @@ /* eslint-disable no-use-before-define */ import superagent from 'superagent'; +import nconf from 'nconf'; +import app from '../../../website/src/server'; -const API_TEST_SERVER_PORT = 3003; +const API_TEST_SERVER_PORT = nconf.get('PORT'); let apiVersion; // Sets up an abject that can make all REST requests diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 0a4a22df79..8988ca684a 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -4,9 +4,6 @@ import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; -mongoose.Promise = require('q').Promise; - -mongoose.connect('mongodb://localhost/habitica-unit-tests'); let connection = mongoose.connection; before((done) => { diff --git a/test/helpers/common.helper.js b/test/helpers/common.helper.js index 5e526c1424..5e0660d3c4 100644 --- a/test/helpers/common.helper.js +++ b/test/helpers/common.helper.js @@ -1,6 +1,5 @@ import mongoose from 'mongoose'; import Q from 'q'; -mongoose.Promise = Q.Promise; import { wrap as wrapUser } from '../../common/script/index'; import { model as User } from '../../website/src/models/user'; diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 19f3ebfc71..f08df055fd 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,5 +1,4 @@ /* eslint-disable no-undef */ -require('babel-core/register'); //------------------------------ // Global modules //------------------------------ diff --git a/test/mocha.opts b/test/mocha.opts index bcdefd3e55..c55b351ceb 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -5,5 +5,4 @@ --growl --globals io --compilers js:babel-core/register ---require test/api-legacy/api-helper --require ./test/helpers/globals.helper From e345fa76f564411544b9a9c1ea7dae2965cc468e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 12:07:46 +0100 Subject: [PATCH 416/976] failing unit test --- package.json | 2 +- tasks/gulp-tests.js | 2 + .../api/v3/unit/middlewares/analytics.test.js | 3 +- test/helpers/api-integration/api-classes.js | 2 +- test/helpers/api-integration/requester.js | 1 - test/helpers/api-integration/v3/index.js | 2 +- test/helpers/api-unit.helper.js | 14 +----- test/helpers/common.helper.js | 1 - test/helpers/globals.helper.js | 7 +++ test/helpers/{api-integration => }/mongo.js | 48 ++++++++----------- 10 files changed, 34 insertions(+), 48 deletions(-) rename test/helpers/{api-integration => }/mongo.js (67%) diff --git a/package.json b/package.json index b7de9c1f5a..11258f9f00 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp lint && (gulp test:nodemon & (sleep 20; mocha test/api/v3 --recursive; killall gulp; killall node;))", + "test": "gulp lint && npm run test:api-v3:unit && npm run test:api-v3:integration", "test:api-v2:unit": "mocha test/server_side", "test:api-v2:integration": "mocha test/api/v2 --recursive", "test:api-v3": "mocha test/api/v3 --recursive", diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 5fbd32cac8..a963c6781d 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -13,6 +13,8 @@ import Q from 'q'; import runSequence from 'run-sequence'; import os from 'os'; +// TODO rewrite + const TEST_SERVER_PORT = 3003 const TEST_DB = 'habitrpg_test' let server; diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index bcaa7898e2..a56666b7a1 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -29,7 +29,7 @@ describe('analytics middleware', () => { attachAnalytics(req, res, next); - expect(res.analytics).to.exist; + expect(res.analytics).to.not.exist; }); it('attaches stubbed methods for non-prod environments', () => { @@ -53,4 +53,3 @@ describe('analytics middleware', () => { expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase); }); }); - diff --git a/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js index 097c359427..f584826520 100644 --- a/test/helpers/api-integration/api-classes.js +++ b/test/helpers/api-integration/api-classes.js @@ -4,7 +4,7 @@ import { requester } from './requester'; import { getDocument as getDocumentFromMongo, updateDocument as updateDocumentInMongo, -} from './mongo'; +} from '../mongo'; import { assign, each, diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index be2ea5c817..bcf580e653 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -2,7 +2,6 @@ import superagent from 'superagent'; import nconf from 'nconf'; -import app from '../../../website/src/server'; const API_TEST_SERVER_PORT = nconf.get('PORT'); let apiVersion; diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 880f88f41a..4ee2e6cfbe 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -6,7 +6,7 @@ requester.setApiVersion('v3'); export { requester }; export { translate } from '../translate'; -export { checkExistence, resetHabiticaDB } from '../mongo'; +export { checkExistence, resetHabiticaDB } from '../../mongo'; export * from './object-generators'; export async function sleep (seconds) { diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 8988ca684a..ffef97ab82 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -4,21 +4,9 @@ import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; -let connection = mongoose.connection; - -before((done) => { - connection.on('open', () => { - connection.db.dropDatabase(done); - }); -}); - -after((done) => { - connection.close(done); -}); - afterEach((done) => { sandbox.restore(); - connection.db.dropDatabase(done); + mongoose.connection.db.dropDatabase(done); }); export function generateUser (options = {}) { diff --git a/test/helpers/common.helper.js b/test/helpers/common.helper.js index 5e0660d3c4..96064b8142 100644 --- a/test/helpers/common.helper.js +++ b/test/helpers/common.helper.js @@ -1,5 +1,4 @@ import mongoose from 'mongoose'; -import Q from 'q'; import { wrap as wrapUser } from '../../common/script/index'; import { model as User } from '../../website/src/models/user'; diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index f08df055fd..7b4a96070e 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -11,7 +11,14 @@ global.expect = chai.expect; global.sinon = require('sinon'); global.sandbox = sinon.sandbox.create(); +import nconf from 'nconf'; + //------------------------------ // Load nconf for unit tests //------------------------------ require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); +nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); +// We require src/server and npt src/index because +// 1. nconf is already setup +// 2. we don't need clustering +require('../../website/src/server'); diff --git a/test/helpers/api-integration/mongo.js b/test/helpers/mongo.js similarity index 67% rename from test/helpers/api-integration/mongo.js rename to test/helpers/mongo.js index e3db242fd3..17d9c17a2a 100644 --- a/test/helpers/api-integration/mongo.js +++ b/test/helpers/mongo.js @@ -1,22 +1,17 @@ -/* eslint-disable no-use-before-define */ -import nconf from 'nconf'; -import { MongoClient as mongo } from 'mongodb'; +import mongoose from 'mongoose'; // Useful for checking things that have been deleted, // but you no longer have access to, // like private parties or users export async function checkExistence (collectionName, id) { - let db = await connectToMongo(); - return new Promise((resolve, reject) => { - let collection = db.collection(collectionName); + let collection = mongoose.connection.db.collection(collectionName); collection.find({_id: id}, {_id: 1}).limit(1).toArray((findError, docs) => { if (findError) return reject(findError); let exists = docs.length > 0; - db.close(); resolve(exists); }); }); @@ -25,12 +20,10 @@ export async function checkExistence (collectionName, id) { // Specifically helpful for the GET /groups tests, // resets the db to an empty state and creates a tavern document export async function resetHabiticaDB () { - let db = await connectToMongo(); - return new Promise((resolve, reject) => { - db.dropDatabase((dbErr) => { + mongoose.connection.db.dropDatabase((dbErr) => { if (dbErr) return reject(dbErr); - let groups = db.collection('groups'); + let groups = mongoose.connection.db.collection('groups'); groups.insertOne({ _id: 'habitrpg', @@ -42,7 +35,6 @@ export async function resetHabiticaDB () { }, (insertErr) => { if (insertErr) return reject(insertErr); - db.close(); resolve(); }); }); @@ -50,39 +42,39 @@ export async function resetHabiticaDB () { } export async function updateDocument (collectionName, doc, update) { - let db = await connectToMongo(); - - let collection = db.collection(collectionName); + let collection = mongoose.connection.db.collection(collectionName); return new Promise((resolve) => { collection.updateOne({ _id: doc._id }, { $set: update }, (updateErr) => { if (updateErr) throw new Error(`Error updating ${collectionName}: ${updateErr}`); - db.close(); resolve(); }); }); } export async function getDocument (collectionName, doc) { - let db = await connectToMongo(); - - let collection = db.collection(collectionName); + let collection = mongoose.connection.db.collection(collectionName); return new Promise((resolve) => { collection.findOne({ _id: doc._id }, (lookupErr, found) => { if (lookupErr) throw new Error(`Error looking up ${collectionName}: ${lookupErr}`); - db.close(); resolve(found); }); }); } -export function connectToMongo () { - return new Promise((resolve, reject) => { - mongo.connect(nconf.get('NODE_DB_URI'), (err, db) => { - if (err) return reject(err); - - resolve(db); - }); +before((done) => { + mongoose.connection.on('open', (err) => { + if (err) return done(err); + resetHabiticaDB() + .then(() => done()) + .catch(done); }); -} +}); + +after((done) => { + mongoose.connection.db.dropDatabase((err) => { + if (err) return done(err); + mongoose.connection.close(done); + }); +}); From bcbaa75aad5fa5bd97608b04f43743ad51087d86 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 12:08:30 +0100 Subject: [PATCH 417/976] failing integration test --- .../challenges/GET-challenges_challengeId_export_csv.test.js | 2 +- test/api/v3/unit/middlewares/analytics.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js index e4c1ceee34..25e1adcf09 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -50,7 +50,7 @@ describe('GET /challenges/:challengeId/export/csv', () => { it('fails if user doesn\'t have access to the challenge', async () => { await expect(user.get(`/challenges/${challenge._id}/export/csv`)).to.eventually.be.rejected.and.eql({ - code: 404, + code: 409, error: 'NotFound', message: t('challengeNotFound'), }); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index a56666b7a1..eb238c6fa9 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -29,7 +29,7 @@ describe('analytics middleware', () => { attachAnalytics(req, res, next); - expect(res.analytics).to.not.exist; + expect(res.analytics).to.exist; }); it('attaches stubbed methods for non-prod environments', () => { From bcfb52a16e4927b853e720c98745fd2e52c1c0b3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 12:14:33 +0100 Subject: [PATCH 418/976] passing tests --- .../challenges/GET-challenges_challengeId_export_csv.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js index 25e1adcf09..e4c1ceee34 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -50,7 +50,7 @@ describe('GET /challenges/:challengeId/export/csv', () => { it('fails if user doesn\'t have access to the challenge', async () => { await expect(user.get(`/challenges/${challenge._id}/export/csv`)).to.eventually.be.rejected.and.eql({ - code: 409, + code: 404, error: 'NotFound', message: t('challengeNotFound'), }); From 80160597b0717bb9fc508a56212b601c7afba34c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 12:37:17 +0100 Subject: [PATCH 419/976] fix for travis? --- test/helpers/mongo.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/test/helpers/mongo.js b/test/helpers/mongo.js index 17d9c17a2a..88535da38b 100644 --- a/test/helpers/mongo.js +++ b/test/helpers/mongo.js @@ -20,22 +20,29 @@ export async function checkExistence (collectionName, id) { // Specifically helpful for the GET /groups tests, // resets the db to an empty state and creates a tavern document export async function resetHabiticaDB () { + console.log('calling resetHabiticaDatabase'); // eslint-disable-line return new Promise((resolve, reject) => { mongoose.connection.db.dropDatabase((dbErr) => { if (dbErr) return reject(dbErr); let groups = mongoose.connection.db.collection('groups'); - groups.insertOne({ - _id: 'habitrpg', - chat: [], - leader: '9', - name: 'HabitRPG', - type: 'guild', - privacy: 'public', - }, (insertErr) => { - if (insertErr) return reject(insertErr); + // For some mysterious reason after a dropDatabase there can still be a group... + groups.count({_id: 'habitrpg'}, (err, count) => { + if (err) return reject(err); + if (count > 0) return resolve(); - resolve(); + groups.insertOne({ + _id: 'habitrpg', + chat: [], + leader: '9', + name: 'HabitRPG', + type: 'guild', + privacy: 'public', + }, (insertErr) => { + if (insertErr) return reject(insertErr); + + resolve(); + }); }); }); }); From 361d0aa35cf760c01cac94c472837dda66504fed Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 12:52:57 +0100 Subject: [PATCH 420/976] finally fix tests --- test/helpers/api-unit.helper.js | 1 + test/helpers/globals.helper.js | 1 + test/helpers/mongo.js | 1 - website/src/models/group.js | 31 +++++++++++++++++-------------- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index ffef97ab82..3370bd0887 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -3,6 +3,7 @@ import mongoose from 'mongoose'; import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; +import mongo from './mongo'; // eslint-disable-line afterEach((done) => { sandbox.restore(); diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 7b4a96070e..2bfe3c8a56 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -18,6 +18,7 @@ import nconf from 'nconf'; //------------------------------ require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); +nconf.set('NODE_ENV', 'test'); // We require src/server and npt src/index because // 1. nconf is already setup // 2. we don't need clustering diff --git a/test/helpers/mongo.js b/test/helpers/mongo.js index 88535da38b..d99c195c3c 100644 --- a/test/helpers/mongo.js +++ b/test/helpers/mongo.js @@ -20,7 +20,6 @@ export async function checkExistence (collectionName, id) { // Specifically helpful for the GET /groups tests, // resets the db to an empty state and creates a tavern document export async function resetHabiticaDB () { - console.log('calling resetHabiticaDatabase'); // eslint-disable-line return new Promise((resolve, reject) => { mongoose.connection.db.dropDatabase((dbErr) => { if (dbErr) return reject(dbErr); diff --git a/website/src/models/group.js b/website/src/models/group.js index 0acc03fe32..2d2b4e3e71 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -11,6 +11,7 @@ import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import Q from 'q'; +import nconf from 'nconf'; let Schema = mongoose.Schema; @@ -513,17 +514,19 @@ export const INVITES_LIMIT = 100; export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) -model.count({_id: 'habitrpg'}, (err, ct) => { - if (err) throw err; - if (ct > 0) return; - - new model({ // eslint-disable-line babel/new-cap - _id: 'habitrpg', - leader: '9', // TODO change this user id - name: 'HabitRPG', - type: 'guild', - privacy: 'public', - }).save({ - validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise - }); // TODO catch/log? -}); +// do not run when testing as it's handled by the tests and can easily cause a race condition +if (nconf.get('NODE_ENV') !== 'test') { + model.count({_id: 'habitrpg'}, (err, ct) => { + if (err) throw err; + if (ct > 0) return; + new model({ // eslint-disable-line babel/new-cap + _id: 'habitrpg', + leader: '9', // TODO change this user id + name: 'HabitRPG', + type: 'guild', + privacy: 'public', + }).save({ + validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise + }); // TODO catch/log? + }); +} From b1848da8ded07a5b3dbc8f985e034360739066b1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 14:43:14 +0100 Subject: [PATCH 421/976] do not output server logs when testing, ability to run tests and server separately --- package.json | 3 ++- test/helpers/globals.helper.js | 26 +++++++++++++++++++------- website/src/libs/api-v3/logger.js | 3 +++ website/src/libs/api-v3/setupNconf.js | 1 + website/src/models/group.js | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 11258f9f00..6d787f5bc9 100644 --- a/package.json +++ b/package.json @@ -97,9 +97,10 @@ "test": "gulp lint && npm run test:api-v3:unit && npm run test:api-v3:integration", "test:api-v2:unit": "mocha test/server_side", "test:api-v2:integration": "mocha test/api/v2 --recursive", - "test:api-v3": "mocha test/api/v3 --recursive", + "test:api-v3": "npm run test:api-v3:unit && npm run test:api-v3:integration", "test:api-v3:unit": "mocha test/api/v3/unit --recursive", "test:api-v3:integration": "mocha test/api/v3/integration --recursive", + "test:api-v3:integration:separate-server": "LOAD_SERVER=0 mocha test/api/v3/integration --recursive", "test:api-legacy": "istanbul cover -i \"website/src/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", "test:common": "mocha test/common", "test:content": "mocha test/content", diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 2bfe3c8a56..9e92a85de9 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,4 +1,6 @@ /* eslint-disable no-undef */ +/* eslint-disable global-require */ +/* eslint-disable no-process-env */ //------------------------------ // Global modules //------------------------------ @@ -12,14 +14,24 @@ global.sinon = require('sinon'); global.sandbox = sinon.sandbox.create(); import nconf from 'nconf'; +import mongoose from 'mongoose'; +import Q from 'q'; //------------------------------ // Load nconf for unit tests //------------------------------ -require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); -nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); -nconf.set('NODE_ENV', 'test'); -// We require src/server and npt src/index because -// 1. nconf is already setup -// 2. we don't need clustering -require('../../website/src/server'); +if (process.env.LOAD_SERVER === '0') { // when the server is in a different process we simply connect to mongoose + require('../../website/src/libs/api-v3/setupNconf')('./config.json'); + // Use Q promises instead of mpromise in mongoose + mongoose.Promise = Q.Promise; + mongoose.connect(nconf.get('NODE_DB_URI')); +} else { // When running tests and the server in the same process + require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); + nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); + nconf.set('NODE_ENV', 'test'); + nconf.set('IS_TEST', true); + // We require src/server and npt src/index because + // 1. nconf is already setup + // 2. we don't need clustering + require('../../website/src/server'); +} diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 0d00ca7f4b..3b259eda6b 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -4,12 +4,15 @@ import winston from 'winston'; import nconf from 'nconf'; const IS_PROD = nconf.get('IS_PROD'); +const IS_TEST = nconf.get('IS_TEST'); let logger = new winston.Logger(); if (IS_PROD) { // TODO production logging, use loggly // log errors to console too +} else if (IS_TEST) { + // Do not log anything when testing } else { logger .add(winston.transports.Console, { diff --git a/website/src/libs/api-v3/setupNconf.js b/website/src/libs/api-v3/setupNconf.js index e5a49bc3e9..f55f593bad 100644 --- a/website/src/libs/api-v3/setupNconf.js +++ b/website/src/libs/api-v3/setupNconf.js @@ -13,4 +13,5 @@ export default function setupNconf (file) { nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production'); nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development'); + nconf.set('IS_TEST', nconf.get('NODE_ENV') === 'test'); } diff --git a/website/src/models/group.js b/website/src/models/group.js index 2d2b4e3e71..718df557c9 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -515,7 +515,7 @@ export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) // do not run when testing as it's handled by the tests and can easily cause a race condition -if (nconf.get('NODE_ENV') !== 'test') { +if (nconf.get('IS_TEST')) { model.count({_id: 'habitrpg'}, (err, ct) => { if (err) throw err; if (ct > 0) return; From 0084a0d057d705622743d0b22a7b36592d55c879 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 19:09:19 +0100 Subject: [PATCH 422/976] move tests to gulp tasks --- package.json | 10 ++++---- tasks/gulp-tests.js | 56 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 6d787f5bc9..a068f199e1 100644 --- a/package.json +++ b/package.json @@ -94,13 +94,13 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp lint && npm run test:api-v3:unit && npm run test:api-v3:integration", + "test": "gulp test:api-v3", "test:api-v2:unit": "mocha test/server_side", "test:api-v2:integration": "mocha test/api/v2 --recursive", - "test:api-v3": "npm run test:api-v3:unit && npm run test:api-v3:integration", - "test:api-v3:unit": "mocha test/api/v3/unit --recursive", - "test:api-v3:integration": "mocha test/api/v3/integration --recursive", - "test:api-v3:integration:separate-server": "LOAD_SERVER=0 mocha test/api/v3/integration --recursive", + "test:api-v3": "gulp test:api-v3", + "test:api-v3:unit": "gulp test:api-v3:unit", + "test:api-v3:integration": "gulp test:api-v3:integration", + "test:api-v3:integration:separate-server": "gulp test:api-v3:integration:separate-server", "test:api-legacy": "istanbul cover -i \"website/src/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", "test:common": "mocha test/common", "test:content": "mocha test/content", diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index a963c6781d..b8ccf23f68 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -44,9 +44,9 @@ let testBin = (string, additionalEnvVariables = '') => { additionalEnvVariables = additionalEnvVariables.split(' ').join('&&set '); additionalEnvVariables = 'set ' + additionalEnvVariables + '&&'; } - return `set NODE_ENV=testing&&${additionalEnvVariables}${string}`; + return `set NODE_ENV=test&&${additionalEnvVariables}${string}`; } else { - return `NODE_ENV=testing ${additionalEnvVariables} ${string}`; + return `NODE_ENV=test ${additionalEnvVariables} ${string}`; } }; @@ -344,13 +344,51 @@ gulp.task('test:api-v2:safe', ['test:prepare:server'], (done) => { }); }); +gulp.task('test:api-v3:unit', (done) => { + let runner = exec( + testBin('mocha test/api/v3/unit --recursive'), + (err, stdout, stderr) => done(err) + ) + + pipe(runner); +}); + +gulp.task('test:api-v3:integration', (done) => { + let runner = exec( + testBin('mocha test/api/v3/integration --recursive'), + (err, stdout, stderr) => done(err) + ) + + pipe(runner); +}); + +gulp.task('test:api-v3:integration:separate-server', (done) => { + let runner = exec( + testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), + (err, stdout, stderr) => done(err) + ) + + pipe(runner); +}); + +gulp.task('test:api-v3', (done) => { + runSequence( + 'lint', + 'test:api-v3:unit', + 'test:api-v3:integration', + done + ); +}); + +// Old tests tasks +/* gulp.task('test:api-v3', ['test:api-v3:unit', 'test:api-v3:integration']); gulp.task('test:api-v3:watch', ['test:api-v3:unit:watch', 'test:api-v3:integration:watch']); -gulp.task('test:api-v3:unit', (done) => { - runMochaTests('./test/api/v3/unit/**/*.js', null, done) -}); +gulp.task('test:api-v3:unit', (done) => {*/ +// runMochaTests('./test/api/v3/unit/**/*.js', null, done) +/*}); gulp.task('test:api-v3:unit:watch', () => { gulp.watch(['website/src/**', 'test/api/v3/unit/**'], ['test:api-v3:unit']); @@ -358,9 +396,9 @@ gulp.task('test:api-v3:unit:watch', () => { gulp.task('test:api-v3:integration', ['test:prepare:server'], (done) => { process.env.API_VERSION = 'v3'; - awaitPort(TEST_SERVER_PORT).then(() => { - runMochaTests('./test/api/v3/integration/**/*.js', server, done) - }); + awaitPort(TEST_SERVER_PORT).then(() => {*/ +// runMochaTests('./test/api/v3/integration/**/*.js', server, done) +/* }); }); gulp.task('test:api-v3:integration:watch', ['test:prepare:server'], () => { @@ -432,4 +470,4 @@ gulp.task('test', ['test:all'], () => { console.log('\n\x1b[36mThanks for helping keep Habitica clean!\x1b[0m'); process.exit(); } -}); +});*/ From 0972d32d03f84c657f7e55ebad4504f714b99d29 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 20:05:06 +0100 Subject: [PATCH 423/976] improve _closeChal to run some ops immediately --- website/src/controllers/api-v3/challenges.js | 68 ++++++++++---------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 9ac5c4bd51..94eb0330e5 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -13,7 +13,7 @@ import { } from '../../libs/api-v3/errors'; import shared from '../../../../common'; import * as Tasks from '../../models/task'; -import { txnEmail } from '../../libs/api-v3/email'; +import { sendTxn as txnEmail } from '../../libs/api-v3/email'; import pushNotify from '../../libs/api-v3/pushNotifications'; import Q from 'q'; import csvStringify from '../../libs/api-v3/csvStringify'; @@ -352,14 +352,38 @@ api.updateChallenge = { }; // TODO everything here should be moved to a worker -// actually even for a worker it's probably just to big and will kill mongo -function _closeChal (challenge, broken = {}) { +// actually even for a worker it's probably just too big and will kill mongo +async function _closeChal (challenge, broken = {}) { let winner = broken.winner; let brokenReason = broken.broken; - let tasks = [ - // Delete the challenge - Challenge.remove({_id: challenge._id}).exec(), + // Delete the challenge + await Challenge.remove({_id: challenge._id}).exec(); + + // Refund the leader if the challenge is closed and the group not the tavern + if (challenge.groupId !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { + await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec(); + } + + // Update the challengeCount on the group + await Group.update({_id: challenge.groupId}, {$inc: {challengeCount: -1}}).exec(); + + // Award prize to winner and notify + if (winner) { + winner.achievements.challenges.push(challenge.name); + winner.balance += challenge.prize / 4; + let savedWinner = await winner.save(); + if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { + txnEmail(savedWinner, 'won-challenge', [ + {name: 'CHALLENGE_NAME', content: challenge.name}, + ]); + } + + pushNotify(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate + } + + // Run some operations in the background withouth blocking the thread + let backgroundTasks = [ // And it's tasks Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(), // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges @@ -379,31 +403,9 @@ function _closeChal (challenge, broken = {}) { 'challenge.winner': winner && winner.profile.name, }, }, {multi: true}).exec(), - // Update the challengeCount on the group - Group.update({_id: challenge.groupId}, {$inc: {challengeCount: -1}}).exec(), ]; - // Refund the leader if the challenge is closed and the group not the tavern - if (challenge.groupId !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { - tasks.push(User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec()); - } - - // Award prize to winner and notify - if (winner) { - winner.achievements.challenges.push(challenge.name); - winner.balance += challenge.prize / 4; - tasks.push(winner.save().then(savedWinner => { - if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { - txnEmail(savedWinner, 'won-challenge', [ - {name: 'CHALLENGE_NAME', content: challenge.name}, - ]); - } - - pushNotify.sendNotify(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate - })); - } - - return Q.allSettled(tasks); // TODO look if allSettled could be useful somewhere else + Q.allSettled(backgroundTasks); // TODO look if allSettled could be useful somewhere else // TODO catch and handle } @@ -431,9 +433,9 @@ api.deleteChallenge = { if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + // Close channel in background, some ops are run in the background without `await`ing + await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); res.respond(200, {}); - // Close channel in background - _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); }, }; @@ -465,9 +467,9 @@ api.selectChallengeWinner = { let winner = await User.findOne({_id: req.params.winnerId}).exec(); if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId})); + // Close channel in background, some ops are run in the background without `await`ing + await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner}); res.respond(200, {}); - // Close channel in background - _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner}); }, }; From 834cca0ddcede568bd7f1651e5cf05f9dafb7456 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 29 Jan 2016 18:30:41 -0500 Subject: [PATCH 424/976] test(quests): finish invite route --- .../POST-groups_groupId_quests_invite.test.js | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index e03043b09a..7f43168b95 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -100,20 +100,28 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); context('successfully issuing a quest invitation', () => { + let inviteResponse = { + key: PET_QUEST, + active: false, + leader: leader._id, + members: {}, + progress: { + collect: {}, + }, + }; + inviteResponse.members[member._id] = null; + inviteResponse.members[leader._id] = null; + it('sends an invite to all party members', async () => { leader.items.quests[PET_QUEST] = 1; - await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ - - }]); + await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); }); it('allows non-leader party members to send invites', async () => { member.items.quests[PET_QUEST] = 1; - await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ - - }]); + await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); }); }); }); From 69e24eafa3df58cf369a677472fa9a91d0c5bcec Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 30 Jan 2016 16:04:38 +0100 Subject: [PATCH 425/976] fix typo when creating tavern --- website/src/models/group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index 718df557c9..1bbb0145a5 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -515,7 +515,7 @@ export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) // do not run when testing as it's handled by the tests and can easily cause a race condition -if (nconf.get('IS_TEST')) { +if (!nconf.get('IS_TEST')) { model.count({_id: 'habitrpg'}, (err, ct) => { if (err) throw err; if (ct > 0) return; From a2f5c2a8424952e624fc18529ceb1948eb9bb77d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 30 Jan 2016 09:28:03 -0600 Subject: [PATCH 426/976] tests: Add readme for v3 tests --- test/api/v3/README.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 test/api/v3/README.md diff --git a/test/api/v3/README.md b/test/api/v3/README.md new file mode 100644 index 0000000000..ad9b55a32c --- /dev/null +++ b/test/api/v3/README.md @@ -0,0 +1,4 @@ +# How to run tests: + +1. `npm test` is equivalent to `gulp test:api-v3` which will run, in order, `gulp lint`, `gulp test:api-v3:unit` and `gulp test:api-v3:integration`. If one of these fails, the whole `npm test` command blocks and fails. Each of these commands can also be run as a standalone command. +2. To run the server and the integrations tests in two different terminals (to better inspect the output in the server) run `npm start` in one and `npm test:api-v3:integration:separate-server` in the other From 707170ec7e78141c35c5c7b2db91d61cebb62877 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 30 Jan 2016 13:35:05 -0600 Subject: [PATCH 427/976] feat(api-v3): Add failure conditions for quest invite route --- .../POST-groups_groupId_quests_invite.test.js | 54 ++++++++++++------- website/src/controllers/api-v3/groups.js | 39 ++++++++++++++ 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 7f43168b95..7fbbfa2ca6 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -3,8 +3,9 @@ import { translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; +import { quests as questScrolls } from '../../../../../common/script/content'; -describe.skip('POST /groups/:groupId/quests/invite', () => { +describe('POST /groups/:groupId/quests/invite/:questKey', () => { let questingGroup; let leader; let member; @@ -31,11 +32,13 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites for a group in which user is not a member', async () => { - let { alternateGroup } = await createAndPopulateGroup({ + let { group } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); + let alternateGroup = group; + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -44,11 +47,13 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites for Guilds', async () => { - let { alternateGroup } = await createAndPopulateGroup({ + let { group } = await createAndPopulateGroup({ groupDetails: { type: 'guild', privacy: 'public' }, members: 1, }); + let alternateGroup = group; + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -76,8 +81,12 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { it('does not issue invites if the user is of insufficient Level', async () => { const LEVELED_QUEST = 'atom1'; - const LEVELED_QUEST_REQ = 15; - leader.items.quests[LEVELED_QUEST] = 1; + const LEVELED_QUEST_REQ = questScrolls[LEVELED_QUEST].lvl; + const leaderUpdate = {}; + leaderUpdate[`items.quests.${LEVELED_QUEST}`] = 1; + leaderUpdate['stats.lvl'] = LEVELED_QUEST_REQ - 1; + + await leader.update(leaderUpdate); await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 401, @@ -87,9 +96,12 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites if a quest is already underway', async () => { - leader.items.quests[PET_QUEST] = 2; + const QUEST_IN_PROGRESS = 'atom1'; + const leaderUpdate = {}; + leaderUpdate[`items.quests.${PET_QUEST}`] = 1; - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await leader.update(leaderUpdate); + await questingGroup.update({ 'quest.key': QUEST_IN_PROGRESS }); await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 401, @@ -99,18 +111,22 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); }); - context('successfully issuing a quest invitation', () => { - let inviteResponse = { - key: PET_QUEST, - active: false, - leader: leader._id, - members: {}, - progress: { - collect: {}, - }, - }; - inviteResponse.members[member._id] = null; - inviteResponse.members[leader._id] = null; + context.skip('successfully issuing a quest invitation', () => { + let inviteResponse; + + beforeEach(() => { + inviteResponse = { + key: PET_QUEST, + active: false, + leader: leader._id, + members: {}, + progress: { + collect: {}, + }, + }; + inviteResponse.members[member._id] = null; + inviteResponse.members[leader._id] = null; + }); it('sends an invite to all party members', async () => { leader.items.quests[PET_QUEST] = 1; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index b9b633c111..628d31f1f2 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,6 +17,7 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; +import { quests as questScrolls } from '../../../../common/script/content'; let api = {}; @@ -616,4 +617,42 @@ api.inviteToGroup = { }, }; +/** + * @api {post} /groups/:groupId/quests/invite Invite users to a quest + * @apiVersion 3.0.0 + * @apiName InviteToQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.inviteToQuest = { + method: 'POST', + url: '/groups/:groupId/quests/invite/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); + if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + + // TODO Logic for quest invite and send back quest object + res.respond(200, {}); + }, +}; + export default api; From 461eb96d450eceb5c286c8ecf91e3f8057a231ac Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 26 Jan 2016 17:51:15 -0600 Subject: [PATCH 428/976] Added get group challenges route and initial tests --- .../GET-challenges_group_groupid.test.js | 82 +++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 37 +++++++++ 2 files changed, 119 insertions(+) create mode 100644 test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js new file mode 100644 index 0000000000..37347ffc9a --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js @@ -0,0 +1,82 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET challenges/group/:groupId', () => { + context('Public Guild', () => { + let publicGuild, user, nonMember, challenge, challenge2; + + before(async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestGuild', + type: 'guild', + privacy: 'public', + }, + }); + + publicGuild = group; + user = groupLeader; + + nonMember = await generateUser(); + + challenge = await generateChallenge(user, group); + challenge2 = await generateChallenge(user, group); + }); + + it('should return group challenges for non member', async () => { + let challenges = await nonMember.get(`/challenges/groups/${publicGuild._id}`); + + expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); + expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + }); + + it('should return group challenges for member', async () => { + let challenges = await user.get(`/challenges/groups/${publicGuild._id}`); + + expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); + expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + }); + }); + + context('Private Guild', () => { + let privateGuild, user, nonMember, challenge, challenge2; + + before(async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestPrivateGuild', + type: 'guild', + privacy: 'private', + }, + }); + + privateGuild = group; + user = groupLeader; + + nonMember = await generateUser(); + + challenge = await generateChallenge(user, group); + challenge2 = await generateChallenge(user, group); + }); + + it('should prevent non-member from seeing challenges', async () => { + await expect(nonMember.get(`/challenges/groups/${privateGuild._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('should return group challenges for member', async () => { + let challenges = await user.get(`/challenges/groups/${privateGuild._id}`); + + expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); + expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 94eb0330e5..9f1e9e50f9 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -208,6 +208,43 @@ api.getChallenges = { }, }; +/** + * @api {get} /challenges/group/group:Id Get challenges for a group + * @apiVersion 3.0.0 + * @apiName GetGroupChallenges + * @apiGroup Challenge + * + * @apiParam {groupId} groupId The group _id + * + * @apiSuccess {Array} challenges An array of challenges + */ +api.getGroupChallenges = { + method: 'GET', + url: '/challenges/groups/:groupId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let challenges = await Challenge.find({groupId}) + .sort('-official -timestamp') + // TODO populate + // .populate('group', '_id name type') + // .populate('leader', 'profile.name') + .exec(); + + res.respond(200, challenges); + }, +}; + /** * @api {get} /challenges/:challengeId Get a challenge given its id * @apiVersion 3.0.0 From c699874e36e00aedf4123340cc05d053d8a4dc8a Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 1 Feb 2016 17:55:55 -0600 Subject: [PATCH 429/976] feat: Add startQuest method on group model --- test/api/v3/unit/models/group.test.js | 134 ++++++++++++++++++++++++++ website/src/models/group.js | 60 ++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 test/api/v3/unit/models/group.test.js diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js new file mode 100644 index 0000000000..bdc49355f7 --- /dev/null +++ b/test/api/v3/unit/models/group.test.js @@ -0,0 +1,134 @@ +import { model as Group } from '../../../../../website/src/models/group'; +import { model as User } from '../../../../../website/src/models/user'; +import { quests as questScrolls } from '../../../../../common/script/content'; + +describe('Group Model', () => { + context('Instance Methods', () => { + let party; + + beforeEach(() => { + party = new Group({ + type: 'party', + }); + }); + + describe('#startQuest', () => { + context('Failure Conditions', () => { + it('throws an error if group is not a party', () => { + let guild = new Group({ + type: 'guild', + }); + + expect(() => { + guild.startQuest(); + }).to.throw('Must be a party to use this method'); + }); + + it('throws an error if party is not on a quest', () => { + expect(() => { + party.startQuest(); + }).to.throw('Party does not have a pending quest'); + }); + + it('throws an error if quest is already active', () => { + party.quest.key = 'whale'; + party.quest.active = true; + + expect(() => { + party.startQuest(); + }).to.throw('Quest is already active'); + }); + }); + + context('Successes', () => { + beforeEach(() => { + party.quest.key = 'whale'; + party.quest.active = false; + party.quest.leader = 'quest-leader'; + party.quest.members = { + 'quest-leader': true, + 'participating-member': true, + 'non-participating-member': false, + 'undecided-member': null, + }; + + sandbox.stub(User, 'update').returns({ exec: sandbox.spy() }); + }); + + it('activates quest', () => { + party.startQuest(); + + expect(party.quest.active).to.eql(true); + }); + + it('sets up boss quest', () => { + let bossQuest = questScrolls.whale; + party.quest.key = bossQuest.key; + + party.startQuest(); + + expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp); + }); + + it('sets up rage meter for rage boss quest', () => { + let rageBossQuest = questScrolls.trex_undead; + party.quest.key = rageBossQuest.key; + + party.startQuest(); + + expect(party.quest.progress.rage).to.eql(0); + }); + + it('sets up collection quest', () => { + let collectionQuest = questScrolls.vice2; + party.quest.key = collectionQuest.key; + party.startQuest(); + + expect(party.quest.progress.collect).to.eql({ + lightCrystal: 0, + }); + }); + + it('sets up collection quest with multiple items', () => { + let collectionQuest = questScrolls.evilsanta2; + party.quest.key = collectionQuest.key; + party.startQuest(); + + expect(party.quest.progress.collect).to.eql({ + tracks: 0, + branches: 0, + }); + }); + + it('updates quest object for participating members', () => { + party.startQuest(); + + expect(User.update).to.be.calledTwice; + expect(User.update).to.not.be.calledWith({ _id: 'non-participating-member' }); + expect(User.update).to.not.be.calledWith({ _id: 'undecided-member' }); + expect(User.update).to.be.calledWith( + { _id: 'participating-member' }, + sinon.match({ $set: { 'party.quest.key': 'whale' }}), + ); + expect(User.update).to.be.calledWith( + { _id: 'quest-leader' }, + sinon.match({ $set: { 'party.quest.key': 'whale' }}), + ); + }); + + it('removes quest scroll from quest leader', () => { + party.startQuest(); + + expect(User.update).to.be.calledWith( + { _id: 'quest-leader' }, + sinon.match({ $inc: { 'items.quests.whale': -1 }}), + ); + }); + + it('sends email to participating members that quest has started'); + + it('sends email only to members who have not opted out'); + }); + }); + }); +}); diff --git a/website/src/models/group.js b/website/src/models/group.js index 1bbb0145a5..e416317a47 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -8,8 +8,10 @@ import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; import { removeFromArray } from '../libs/api-v3/collectionManipulators'; +import { BadRequest } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; +import { quests as questScrolls } from '../../../common/script/content'; import Q from 'q'; import nconf from 'nconf'; @@ -168,6 +170,64 @@ schema.methods.isMember = function isGroupMember (user) { } }; +schema.methods.startQuest = function startQuest () { + if (this.type !== 'party') throw new BadRequest('Must be a party to use this method'); + if (!this.quest.key) throw new BadRequest('Party does not have a pending quest'); + if (this.quest.active) throw new BadRequest('Quest is already active'); + + let quest = questScrolls[this.quest.key]; + let collected = {}; + if (quest.collect) { + collected = _.transform(quest.collect, (result, n, itemToCollect) => { + result[itemToCollect] = 0; + }); + } + + let backgroundOperations = []; + + this.markModified('quest'); + this.quest.active = true; + if (quest.boss) { + this.quest.progress.hp = quest.boss.hp; + if (quest.boss.rage) this.quest.progress.rage = 0; + } else if (quest.collect) { + this.quest.progress.collect = collected; + } + + _.each(this.quest.members, (participating, memberId) => { + if (!participating) return; + + let update = { + $set: { + // Do *not* reset party.quest.progress.up + // See https://github.com/HabitRPG/habitrpg/issues/2168#issuecomment-31556322 + 'party.quest.key': this.quest.key, + 'party.quest.progress.down': 0, + 'party.quest.collect': collected, + 'party.quest.completed': null, + }, + $inc: { _v: 1 }, + }; + + if (this.quest.leader === memberId) { + update.$inc[`items.quests.${this.quest.key}`] = -1; + } + + backgroundOperations.push(User.update({ _id: memberId }, update).exec()); + }); + + // TODO Add emails to users that quest has started to background ops + + // These operations should run in the background + // and not hold up the quest routes from resolving + // TODO: What here? + // Q.all(backgroundOperations).then(() => { + // }).catch(err => { + // TODO: How to handle errors? + // IE, user deleted their account? + // }); +}; + export function chatDefaults (msg, user) { let message = { id: shared.uuid(), From a7486821e5e11774b61d90ee2ac0172745fd9930 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 1 Feb 2016 18:02:32 -0600 Subject: [PATCH 430/976] feat: Add quest details to group in quest invite route --- .../POST-groups_groupId_quests_invite.test.js | 74 ++++++++++++++----- website/src/controllers/api-v3/groups.js | 16 ++++ 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 7fbbfa2ca6..4311cf7911 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -111,33 +111,67 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { }); }); - context.skip('successfully issuing a quest invitation', () => { - let inviteResponse; + context('successfully issuing a quest invitation', () => { + beforeEach(async () => { + const memberUpdate = {}; + memberUpdate[`items.quests.${PET_QUEST}`] = 1; - beforeEach(() => { - inviteResponse = { - key: PET_QUEST, - active: false, - leader: leader._id, - members: {}, - progress: { - collect: {}, - }, - }; - inviteResponse.members[member._id] = null; - inviteResponse.members[leader._id] = null; + await Promise.all([ + leader.update(memberUpdate), + member.update(memberUpdate), + ]); }); - it('sends an invite to all party members', async () => { - leader.items.quests[PET_QUEST] = 1; + xit('adds quest details to group object', async () => { + await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); + await questingGroup.sync(); + + let quest = questingGroup.quest; + + expect(quest.key).to.eql(PET_QUEST); + expect(quest.active).to.eql(false); + expect(quest.leader).to.eql(false); + expect(quest.members).to.have.property(leader._id, null); + expect(quest.members).to.have.property(member._id, null); + expect(quest).to.have.property('progress'); }); - it('allows non-leader party members to send invites', async () => { - member.items.quests[PET_QUEST] = 1; + xit('adds quest details to user objects', async () => { + await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); + await Promise.all([ + leader.sync(), + member.sync(), + ]); + + [leader, member].forEach((user) => { + let quest = user.party.quest; + + expect(quest.key).to.eql(PET_QUEST); + expect(quest.active).to.eql(false); + expect(quest.leader).to.eql(false); + expect(quest.members).to.have.property(leader._id, null); + expect(quest.members).to.have.property(member._id, null); + expect(quest).to.have.property('progress'); + }); + }); + + xit('sends back the quest object', async () => { + let inviteResponse = await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + expect(inviteResponse.key).to.eql(PET_QUEST); + expect(inviteResponse.active).to.eql(false); + expect(inviteResponse.leader).to.eql(false); + expect(inviteResponse.members).to.have.property(leader._id, null); + expect(inviteResponse.members).to.have.property(member._id, null); + expect(inviteResponse).to.have.property('progress'); + }); + + xit('allows non-leader party members to send invites', async () => { + let inviteResponse = await member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + expect(inviteResponse.key).to.eql(PET_QUEST); }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 628d31f1f2..c663688163 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -650,7 +650,23 @@ api.inviteToQuest = { if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + group.markModified('quest'); + group.quest.key = questKey; + group.quest.leader = user._id; + group.quest.members = {}; + + // let memberUpdate = { + // '$set': { + // 'party.quest.key': questKey, + // 'party.quest.progress.down': 0, + // 'party.quest.completed': null, + // }, + // }; + + // TODO collect members of party // TODO Logic for quest invite and send back quest object + + await group.save(); res.respond(200, {}); }, }; From e3c7d2834e6e5fa024afb71032c21177ca4124a7 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 1 Feb 2016 21:13:52 -0600 Subject: [PATCH 431/976] refactor(api-v3): Move quest routes to separate file --- website/src/controllers/api-v3/groups.js | 55 ------------------- website/src/controllers/api-v3/quests.js | 68 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 website/src/controllers/api-v3/quests.js diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index c663688163..b9b633c111 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,7 +17,6 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; -import { quests as questScrolls } from '../../../../common/script/content'; let api = {}; @@ -617,58 +616,4 @@ api.inviteToGroup = { }, }; -/** - * @api {post} /groups/:groupId/quests/invite Invite users to a quest - * @apiVersion 3.0.0 - * @apiName InviteToQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} Quest Object - */ -api.inviteToQuest = { - method: 'POST', - url: '/groups/:groupId/quests/invite/:questKey', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let questKey = req.params.questKey; - let quest = questScrolls[questKey]; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); - if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); - if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); - if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - - group.markModified('quest'); - group.quest.key = questKey; - group.quest.leader = user._id; - group.quest.members = {}; - - // let memberUpdate = { - // '$set': { - // 'party.quest.key': questKey, - // 'party.quest.progress.down': 0, - // 'party.quest.completed': null, - // }, - // }; - - // TODO collect members of party - // TODO Logic for quest invite and send back quest object - - await group.save(); - res.respond(200, {}); - }, -}; - export default api; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js new file mode 100644 index 0000000000..38020fcae5 --- /dev/null +++ b/website/src/controllers/api-v3/quests.js @@ -0,0 +1,68 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { + model as Group, +} from '../../models/group'; +import { + NotFound, + NotAuthorized, +} from '../../libs/api-v3/errors'; +import { quests as questScrolls } from '../../../../common/script/content'; + +let api = {}; + +/** + * @api {post} /groups/:groupId/quests/invite Invite users to a quest + * @apiVersion 3.0.0 + * @apiName InviteToQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.inviteToQuest = { + method: 'POST', + url: '/groups/:groupId/quests/invite/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); + if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + + group.markModified('quest'); + group.quest.key = questKey; + group.quest.leader = user._id; + group.quest.members = {}; + + // let memberUpdate = { + // '$set': { + // 'party.quest.key': questKey, + // 'party.quest.progress.down': 0, + // 'party.quest.completed': null, + // }, + // }; + + // TODO collect members of party + // TODO Logic for quest invite and send back quest object + + await group.save(); + res.respond(200, {}); + }, +}; + +export default api; From 824603bc8964184dbca0dc8c312d9d3d307651f8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 11:14:55 +0100 Subject: [PATCH 432/976] fix moveTask and add tests --- website/src/controllers/api-v3/tasks.js | 26 ++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 0197d38dbb..e101bca590 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -452,19 +452,19 @@ api.scoreTask = { // TODO check that it works when a tag is selected or todos are split between dated and due // TODO support challenges? /** - * @api {post} /tasks/move/:taskId/to/:position Move a task to a new position + * @api {post} /tasks/:taskId/move/to/:position Move a task to a new position * @apiVersion 3.0.0 * @apiName MoveTask * @apiGroup Task * * @apiParam {UUID} taskId The task _id - * @apiParam {Number} position Where to move the task (-1 means push to bottom) + * @apiParam {Number} position Where to move the task (-1 means push to bottom). First position is 0 * - * @apiSuccess {object} empty An empty object + * @apiSuccess {object} tasksOrder The new tasks order (user.tasksOrder.{task.type}s) */ api.moveTask = { method: 'POST', - url: '/tasks/move/:taskId/to/:position', + url: '/tasks/:taskId/move/to/:position', middlewares: [authWithHeaders(), cron], async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); @@ -486,19 +486,23 @@ api.moveTask = { let order = user.tasksOrder[`${task.type}s`]; let currentIndex = order.indexOf(task._id); - // If for some reason the task isn't ordered (should never happen) - // or if the task is moved to a non existing position - // or if the task is moved to postion -1 (push to bottom) + // If for some reason the task isn't ordered (should never happen), push it in the new position + // if the task is moved to a non existing position + // or if the task is moved to position -1 (push to bottom) // -> push task at end of list - if (currentIndex === -1 || !order[to] || to === -1) { + if (!order[to] && to !== -1) { order.push(task._id); } else { - let taskToMove = order.splice(currentIndex, 1)[0]; - order.splice(to, 0, taskToMove); + if (currentIndex !== -1) order.splice(currentIndex, 1); + if (to === -1) { + order.push(task._id); + } else { + order.splice(to, 0, task._id); + } } await user.save(); - res.respond(200, {}); // TODO what to return + res.respond(200, order); }, }; From 9854d1cf34db92d6661446cdd87baf540114d322 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 11:42:05 +0100 Subject: [PATCH 433/976] add missing test file and update parameters for GET tasks --- .../integration/tasks/GET-tasks_user.test.js | 10 +-- .../POST-tasks_clearCompletedTodos.test.js | 10 ++- ...POST-tasks_move_taskId_to_position.test.js | 84 +++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 59 ++++++------- 4 files changed, 121 insertions(+), 42 deletions(-) create mode 100644 test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js index a206d7b3ce..cd11100599 100644 --- a/test/api/v3/integration/tasks/GET-tasks_user.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js @@ -17,12 +17,12 @@ describe('GET /tasks/user', () => { it('returns only a type of user\'s tasks if req.query.type is specified', async () => { let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks/user?type=habit'); + let tasks = await user.get('/tasks/user?type=habits'); expect(tasks.length).to.equal(1); expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); - it('returns completed todos sorted by completion date if req.query.includeCompletedTodos is specified', async () => { + it('returns completed todos sorted by completion date if req.query.type === "completeTodos"', async () => { let todo1 = await user.post('/tasks/user', {text: 'todo to complete 1', type: 'todo'}); let todo2 = await user.post('/tasks/user', {text: 'todo to complete 2', type: 'todo'}); @@ -35,8 +35,8 @@ describe('GET /tasks/user', () => { expect(user.tasksOrder.todos.length).to.equal(initialTodoCount - 2); - let allTodos = await user.get('/tasks/user?type=todo&includeCompletedTodos=true'); - expect(allTodos.length).to.equal(initialTodoCount); - expect(allTodos[allTodos.length - 1].text).to.equal('todo to complete 1'); // last is the todo that was completed later + let completedTodos = await user.get('/tasks/user?type=completedTodos'); + expect(completedTodos.length).to.equal(2); + expect(completedTodos[completedTodos.length - 1].text).to.equal('todo to complete 1'); // last is the todo that was completed later }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js index 338808a4a1..d6cdf21749 100644 --- a/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js @@ -24,7 +24,7 @@ describe('POST /tasks/clearCompletedTodos', () => { type: 'todo', }); - let tasks = await user.get('/tasks/user?type=todo'); + let tasks = await user.get('/tasks/user?type=todos'); expect(tasks.length).to.equal(initialTodoCount + 6); for (let task of tasks) { @@ -34,8 +34,10 @@ describe('POST /tasks/clearCompletedTodos', () => { } await user.post('/tasks/clearCompletedTodos'); - let tasksUpdated = await user.get('/tasks/user?type=todo&includeCompletedTodos=true'); - expect(tasksUpdated.length).to.equal(initialTodoCount + 4); // + 6 - 3 completed (but one is from challenge) - expect(tasksUpdated[tasksUpdated.length - 1].text).to.equal('todo 6'); + let completedTodos = await user.get('/tasks/user?type=completedTodos'); + let todos = await user.get('/tasks/user?type=todos'); + let allTodos = todos.concat(completedTodos); + expect(allTodos.length).to.equal(initialTodoCount + 4); // + 6 - 3 completed (but one is from challenge) + expect(allTodos[allTodos.length - 1].text).to.equal('todo 6'); }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js b/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js new file mode 100644 index 0000000000..211c7ea975 --- /dev/null +++ b/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js @@ -0,0 +1,84 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/move/to/:position', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('requires a valid taskId', async () => { + await expect(user.post('/tasks/123/move/to/1')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('requires a numeric position parameter', async () => { + await expect(user.post(`/tasks/${generateUUID()}/move/to/notANumber`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('taskId must match a valid task', async () => { + await expect(user.post(`/tasks/${generateUUID()}/move/to/1`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('can move task to new position', async () => { + let tasks = await user.post('/tasks/user', [ + {type: 'habit', text: 'habit 1'}, + {type: 'habit', text: 'habit 2'}, + {type: 'daily', text: 'daily 1'}, + {type: 'habit', text: 'habit 3'}, + {type: 'habit', text: 'habit 4'}, + {type: 'todo', text: 'todo 1'}, + {type: 'habit', text: 'habit 5'}, + ]); + + let taskToMove = tasks[1]; + expect(taskToMove.text).to.equal('habit 2'); + let newOrder = await user.post(`/tasks/${tasks[1]._id}/move/to/3`); + expect(newOrder[3]).to.equal(taskToMove._id); + expect(newOrder.length).to.equal(5); + }); + + it('can\'t move completed todo', async () => { + let task = await user.post('/tasks/user', {type: 'todo', text: 'todo 1'}); + await user.post(`/tasks/${task._id}/score/up`); + + await expect(user.post(`/tasks/${task._id}/move/to/1`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('cantMoveCompletedTodo'), + }); + }); + + it('can push to bottom', async () => { + let tasks = await user.post('/tasks/user', [ + {type: 'habit', text: 'habit 1'}, + {type: 'habit', text: 'habit 2'}, + {type: 'daily', text: 'daily 1'}, + {type: 'habit', text: 'habit 3'}, + {type: 'habit', text: 'habit 4'}, + {type: 'todo', text: 'todo 1'}, + {type: 'habit', text: 'habit 5'}, + ]); + + let taskToMove = tasks[1]; + expect(taskToMove.text).to.equal('habit 2'); + let newOrder = await user.post(`/tasks/${tasks[1]._id}/move/to/-1`); + expect(newOrder[4]).to.equal(taskToMove._id); + expect(newOrder.length).to.equal(5); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e101bca590..847a8bee19 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -4,6 +4,7 @@ import { sendTaskWebhook } from '../../libs/api-v3/webhook'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as Tasks from '../../models/task'; import { model as Challenge } from '../../models/challenge'; +import { model as Group } from '../../models/group'; import { NotFound, NotAuthorized, @@ -117,8 +118,19 @@ async function _getTasks (req, res, user, challenge) { let type = req.query.type; if (type) { - query.type = type; - if (type === 'todo') query.completed = false; // Exclude completed todos + if (type === 'todos') { + query.completed = false; // Exclude completed todos + } else if (type === 'completedTodos') { + query = Tasks.Task.find({ + userId: user._id, + type: 'todo', + completed: true, + }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos + dateCompleted: 1, + }); + } else { + query.type = type.slice(0, -1); // removing the final "s" + } } else { query.$or = [ // Exclude completed todos {type: 'todo', completed: false}, @@ -126,27 +138,8 @@ async function _getTasks (req, res, user, challenge) { ]; } - if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { - if (challenge) throw new BadRequest(res.t('noCompletedTodosChallenge')); // no completed todos for challenges - - let queryCompleted = Tasks.Task.find({ - userId: user._id, - type: 'todo', - completed: true, - }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos - dateCompleted: 1, - }); - - let results = await Q.all([ - queryCompleted.exec(), - Tasks.Task.find(query).exec(), - ]); - - res.respond(200, results[1].concat(results[0])); - } else { - let tasks = await Tasks.Task.find(query).exec(); - res.respond(200, tasks); - } + let tasks = await Tasks.Task.find(query).exec(); + res.respond(200, tasks); } /** @@ -155,8 +148,7 @@ async function _getTasks (req, res, user, challenge) { * @apiName GetUserTasks * @apiGroup Task * - * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks - * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". + * @apiParam {string="habits","dailys","todos","rewards","completeTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that requested separately. * * @apiSuccess {Array} tasks An array of task objects */ @@ -165,7 +157,9 @@ api.getUserTasks = { url: '/tasks/user', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + let types = Tasks.tasksTypes.map(type => `${type}s`); + types.push('completedTodos'); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -181,7 +175,7 @@ api.getUserTasks = { * @apiGroup Task * * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks. - * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks + * @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks * * @apiSuccess {Array} tasks An array of task objects */ @@ -191,7 +185,8 @@ api.getChallengeTasks = { middlewares: [authWithHeaders(), cron], async handler (req, res) { req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + let types = Tasks.tasksTypes.map(type => `${type}s`); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -200,11 +195,9 @@ api.getChallengeTasks = { let challengeId = req.params.challengeId; let challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); - - // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error - if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens - throw new NotFound(res.t('challengeNotFound')); - } + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); await _getTasks(req, res, res.locals.user, challenge); }, From 4aa6545c797f3255dad90dda24a8f29fe26c8223 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 11:56:06 +0100 Subject: [PATCH 434/976] fix access control when getting tasks for a challenge --- website/src/controllers/api-v3/tasks.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 847a8bee19..0526163ccb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -194,7 +194,7 @@ api.getChallengeTasks = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); + let challenge = await Challenge.findOne({_id: challengeId}).select('groupId leader').exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); From 337b68ce43d3c7e6a7d0fa733b3241c1dd237a0f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 12:23:56 +0100 Subject: [PATCH 435/976] make sure that tasks are returned in the correct order --- website/src/controllers/api-v3/tasks.js | 28 ++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 0526163ccb..df78ca3a4f 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -139,7 +139,29 @@ async function _getTasks (req, res, user, challenge) { } let tasks = await Tasks.Task.find(query).exec(); - res.respond(200, tasks); + + // Order tasks based on tasksOrder + if (type && type !== 'completedTodos') { + let order = (challenge || user).tasksOrder[type]; + let orderedTasks = new Array(tasks.length); + let unorderedTasks = []; // what we want to add later + + tasks.forEach((task, index) => { + let taskId = task._id; + let i = order[index] === taskId ? index : order.indexOf(taskId); + if (i === -1) { + unorderedTasks.push(task); + } else { + orderedTasks[i] = task; + } + }); + + // Remove empty values from the array and add any unordered task + orderedTasks = _.compact(orderedTasks).concat(unorderedTasks); + res.respond(200, orderedTasks); + } else { + res.respond(200, tasks); + } } /** @@ -148,7 +170,7 @@ async function _getTasks (req, res, user, challenge) { * @apiName GetUserTasks * @apiGroup Task * - * @apiParam {string="habits","dailys","todos","rewards","completeTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that requested separately. + * @apiParam {string="habits","dailys","todos","rewards","completedTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that requested separately. * * @apiSuccess {Array} tasks An array of task objects */ @@ -194,7 +216,7 @@ api.getChallengeTasks = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findOne({_id: challengeId}).select('groupId leader').exec(); + let challenge = await Challenge.findOne({_id: challengeId}).select('groupId leader tasksOrder').exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); From 754befc5807bb469cb23f54256e8295e812620ba Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 2 Feb 2016 10:29:55 -0600 Subject: [PATCH 436/976] Updated test syntax and added get user challenges tests --- .../GET-challenges_group_groupid.test.js | 18 +++-- .../challenges/GET-challenges_user.test.js | 72 +++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 11 +-- 3 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 test/api/v3/integration/challenges/GET-challenges_user.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js index 37347ffc9a..9dcbbdcd5d 100644 --- a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js @@ -30,15 +30,19 @@ describe('GET challenges/group/:groupId', () => { it('should return group challenges for non member', async () => { let challenges = await nonMember.get(`/challenges/groups/${publicGuild._id}`); - expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); - expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + let foundChallenge1 = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge1).to.exist; + let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); + expect(foundChallenge2).to.exist; }); it('should return group challenges for member', async () => { let challenges = await user.get(`/challenges/groups/${publicGuild._id}`); - expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); - expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + let foundChallenge1 = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge1).to.exist; + let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); + expect(foundChallenge2).to.exist; }); }); @@ -75,8 +79,10 @@ describe('GET challenges/group/:groupId', () => { it('should return group challenges for member', async () => { let challenges = await user.get(`/challenges/groups/${privateGuild._id}`); - expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); - expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + let foundChallenge1 = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge1).to.exist; + let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); + expect(foundChallenge2).to.exist; }); }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js new file mode 100644 index 0000000000..8b6a279045 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js @@ -0,0 +1,72 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET challenges/user', () => { + let user, member, nonMember, challenge, challenge2; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestGuild', + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + user = groupLeader; + + member = members[0]; + nonMember = await generateUser(); + + challenge = await generateChallenge(user, group); + challenge2 = await generateChallenge(user, group); + }); + + it('should return challenges user has joined', async () => { + await nonMember.post(`/challenges/${challenge._id}/join`); + + let challenges = await nonMember.get(`/challenges/user`); + + let foundChallenge = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge).to.exist; + }); + + it('should return challenges user has created', async () => { + let challenges = await user.get(`/challenges/user`); + + let foundChallenge1 = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge1).to.exist; + let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); + expect(foundChallenge2).to.exist; + }); + + it('should return challenges in user\'s group', async () => { + let challenges = await member.get(`/challenges/user`); + + let foundChallenge1 = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge1).to.exist; + let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); + expect(foundChallenge2).to.exist; + }); + + it('should not return challenges user doesn\'t have access to', async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestPrivateGuild', + type: 'guild', + privacy: 'private', + }, + }); + + let privateChallenge = await generateChallenge(groupLeader, group); + + let challenges = await nonMember.get(`/challenges/user`); + + let foundChallenge = _.find(challenges, { _id: privateChallenge._id }); + expect(foundChallenge).to.not.exist; + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 9f1e9e50f9..6f386a8936 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -121,7 +121,8 @@ api.joinChallenge = { let challenge = await Challenge.findOne({ _id: req.params.challengeId }); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.hasAccess(user)) throw new NotFound(res.t('challengeNotFound')); + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); if (_.contains(user.challenges, challenge._id)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); @@ -172,16 +173,16 @@ api.leaveChallenge = { }; /** - * @api {get} /challenges Get challenges for a user + * @api {get} /challenges/user Get challenges for a user * @apiVersion 3.0.0 - * @apiName GetChallenges + * @apiName GetUserChallenges * @apiGroup Challenge * * @apiSuccess {Array} challenges An array of challenges */ -api.getChallenges = { +api.getUserChallenges = { method: 'GET', - url: '/challenges', + url: '/challenges/user', middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; From 509dffd0c736e0a6e5609ecc79e79ac02ce40a32 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 19:13:29 +0100 Subject: [PATCH 437/976] update hasAccess to include public guilds --- website/src/controllers/api-v3/challenges.js | 5 ++--- website/src/models/challenge.js | 11 +++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 6f386a8936..a75cc6cc58 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -120,11 +120,10 @@ api.joinChallenge = { let challenge = await Challenge.findOne({ _id: req.params.challengeId }); if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); - if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - - if (_.contains(user.challenges, challenge._id)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); + if (!group || !challenge.hasAccess(user, group)) throw new NotFound(res.t('challengeNotFound')); challenge.memberCount += 1; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 040a6c503f..62307f6d8e 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -47,20 +47,19 @@ schema.methods.canModify = function canModifyChallenge (user) { }; // Returns true if user has access to the challenge (can join) -schema.methods.hasAccess = function hasAccessToChallenge (user) { +schema.methods.hasAccess = function hasAccessToChallenge (user, group) { + if (group.type === 'guild' && group.privacy === 'public') return true; let userGroups = user.guilds.slice(0); // clone user.guilds so we don't modify the original if (user.party._id) userGroups.push(user.party._id); userGroups.push('habitrpg'); // tavern - return this.canModify(user) || userGroups.indexOf(this.groupId) !== -1; + return userGroups.indexOf(this.groupId) !== -1; }; // Returns true if user can view the challenge -// Different from hasAccess because challenges of public guilds can be viewed by everyone -// And also because you can see challenges of groups you've been removed from +// Different from hasAccess because you can see challenges of groups you've been removed from if you're partecipating in them schema.methods.canView = function canViewChallenge (user, group) { - if (group.type === 'guild' && group.privacy === 'public') return true; if (this.isMember(user)) return true; - return this.hasAccess(user); + return this.hasAccess(user, group); }; // Takes a Task document and return a plain object of attributes that can be synced to the user From 0e5c3f6e829a170c35def54bdee604b27a7be806 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Sun, 31 Jan 2016 00:51:16 +0200 Subject: [PATCH 438/976] Challenge create tests --- .../challenges/POST-challenges.test.js | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 test/api/v3/integration/challenges/POST-challenges.test.js diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js new file mode 100644 index 0000000000..8bddb016b9 --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -0,0 +1,295 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges', () => { + it('returns error when groupId is empty', async () => { + let user = await generateUser(); + + await expect(user.post('/challenges')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when groupId is not for a valid group', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges`, { + groupId: generateUUID(), + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns error when creating a challenge in the tavern with no prize', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges`, { + groupId: 'habitrpg', + prize: 0, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('pubChalsMinPrize'), + }); + }); + + it('returns error when creating a challenge in a public guild and you are not a member of it', async () => { + let user = await generateUser(); + let { group } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + }); + + await expect(user.post(`/challenges`, { + groupId: group._id, + prize: 4, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('mustBeGroupMember'), + }); + }); + + context('Creating a challenge for a valid group', () => { + let groupLeader; + let group; + let groupMember; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 1, + leaderDetails: { + balance: 3, + }, + groupDetails: { + type: 'guild', + leaderOnly: { + challenges: true, + }, + }, + }); + + groupLeader = await populatedGroup.groupLeader.sync(); + group = populatedGroup.group; + groupMember = populatedGroup.members[0]; + }); + + it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { + await expect(groupMember.post(`/challenges`, { + groupId: group._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupLeaderChal'), + }); + }); + + it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { + await expect(groupMember.post(`/challenges`, { + groupId: group._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupLeaderChal'), + }); + }); + + it('allows non-leader member to create a challenge', async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 1, + }); + + group = populatedGroup.group; + groupMember = populatedGroup.members[0]; + + await expect(groupMember.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + })).to.eventually.have.property('leader', groupMember._id); + }); + + it('doesn\'t take gems from user or group when challenge has no prize', async () => { + let oldUserBalance = groupLeader.balance; + let oldGroupBalance = group.balance; + + await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + prize: 0, + }); + + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance); + await expect(group.sync()).to.eventually.have.property('balance', oldGroupBalance); + }); + + it('returns error when user and group can\'t pay prize', async () => { + await expect(groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + prize: 20, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cantAfford'), + }); + }); + + it('takes prize out of group if it has sufficient funds', async () => { + let oldUserBalance = groupLeader.balance; + let oldGroupBalance = group.balance; + let prize = 4; + + await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + prize, + }); + + await expect(group.sync()).to.eventually.have.property('balance', oldGroupBalance - prize / 4); + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance); + }); + + it('takes prize out of both group and user if group doesn\'t have enough', async () => { + let oldUserBalance = groupLeader.balance; + let prize = 8; + + await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + prize, + }); + + await expect(group.sync()).to.eventually.have.property('balance', 0); + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance - (prize / 4 - 1)); + }); + + it('takes prize out of user if group has no balance', async () => { + let oldUserBalance = groupLeader.balance; + let prize = 8; + + await group.update({ balance: 0}); + await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + prize, + }); + + await expect(group.sync()).to.eventually.have.property('balance', 0); + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance - prize / 4); + }); + + it('increases challenge count of group', async () => { + let oldChallengeCount = group.challengeCount; + + await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + }); + + await expect(group.sync()).to.eventually.have.property('challengeCount', oldChallengeCount + 1); + }); + + it('sets challenge as official if created by admin and official flag is set', async () => { + await groupLeader.update({ + contributor: { + admin: true, + }, + }); + + let challenge = await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + official: true, + }); + + expect(challenge.official).to.eql(true); + }); + + it('doesn\'t set challenge as official if official flag is set by non-admin', async () => { + let challenge = await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + official: true, + }); + + expect(challenge.official).to.be.undefined; + }); + + it('returns an error when challenge validation fails; doesn\'s save user or group', async () => { + let oldChallengeCount = group.challengeCount; + let oldUserBalance = groupLeader.balance; + let oldUserChallenges = groupLeader.challenges; + let oldGroupBalance = group.balance; + + await expect(groupLeader.post(`/challenges`, { + groupId: group._id, + prize: 8, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Challenge validation failed', + }); + + group = await group.sync(); + groupLeader = await groupLeader.sync(); + + expect(group.challengeCount).to.eql(oldChallengeCount); + expect(group.balance).to.eql(oldGroupBalance); + expect(groupLeader.balance).to.eql(oldUserBalance); + expect(groupLeader.challenges).to.eql(oldUserChallenges); + }); + + it('sets all properites of the challenge as passed', async () => { + let name = 'Test Challenge'; + let shortName = 'TC'; + let description = 'Test Description'; + let prize = 4; + + let challenge = await groupLeader.post(`/challenges`, { + groupId: group._id, + name, + shortName, + description, + prize, + }); + + expect(challenge.leader).to.eql(groupLeader._id); + expect(challenge.name).to.eql(name); + expect(challenge.shortName).to.eql(shortName); + expect(challenge.description).to.eql(description); + expect(challenge.official).to.be.undefined; + expect(challenge.groupId).to.eql(group._id); + expect(challenge.memberCount).to.eql(1); + expect(challenge.prize).to.eql(prize); + }); + + it('adds challenge to creator\'s challenges', async () => { + let challenge = await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + }); + + await expect(groupLeader.sync()).to.eventually.have.property('challenges').to.include(challenge._id); + }); + }); +}); From 7a5aa731db16bf27878cb62e2b04ab7282b64f95 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 21:47:12 +0100 Subject: [PATCH 439/976] add population to challenge.group, challenge.leader and group.leader --- .../GET-challenges_group_groupid.test.js | 30 ++++++- .../challenges/GET-challenges_user.test.js | 54 ++++++++++- .../PUT-challenges_challengeId.test.js | 4 +- .../integration/groups/GET-groups_id.test.js | 4 - .../v3/integration/groups/POST-groups.test.js | 35 ++++++-- .../api-integration/v3/object-generators.js | 2 +- website/src/controllers/api-v3/challenges.js | 89 +++++++++++++------ website/src/controllers/api-v3/groups.js | 19 ++-- website/src/controllers/api-v3/members.js | 6 +- website/src/controllers/api-v3/tasks.js | 4 +- website/src/models/challenge.js | 9 +- website/src/models/group.js | 5 +- website/src/models/user.js | 8 ++ 13 files changed, 207 insertions(+), 62 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js index 9dcbbdcd5d..3f116d833d 100644 --- a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js @@ -27,22 +27,38 @@ describe('GET challenges/group/:groupId', () => { challenge2 = await generateChallenge(user, group); }); - it('should return group challenges for non member', async () => { + it('should return group challenges for non member with populated leader', async () => { let challenges = await nonMember.get(`/challenges/groups/${publicGuild._id}`); let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; + expect(foundChallenge1.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; + expect(foundChallenge2.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); }); - it('should return group challenges for member', async () => { + it('should return group challenges for member with populated leader', async () => { let challenges = await user.get(`/challenges/groups/${publicGuild._id}`); let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; + expect(foundChallenge1.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; + expect(foundChallenge2.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); }); }); @@ -76,13 +92,21 @@ describe('GET challenges/group/:groupId', () => { }); }); - it('should return group challenges for member', async () => { + it('should return group challenges for member with populated leader', async () => { let challenges = await user.get(`/challenges/groups/${privateGuild._id}`); let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; + expect(foundChallenge1.leader).to.eql({ + _id: privateGuild.leader._id, + profile: {name: user.profile.name}, + }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; + expect(foundChallenge2.leader).to.eql({ + _id: privateGuild.leader._id, + profile: {name: user.profile.name}, + }); }); }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js index 8b6a279045..21ae0aa96b 100644 --- a/test/api/v3/integration/challenges/GET-challenges_user.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js @@ -5,7 +5,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; describe('GET challenges/user', () => { - let user, member, nonMember, challenge, challenge2; + let user, member, nonMember, challenge, challenge2, publicGuild; before(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ @@ -18,7 +18,7 @@ describe('GET challenges/user', () => { }); user = groupLeader; - + publicGuild = group; member = members[0]; nonMember = await generateUser(); @@ -33,6 +33,16 @@ describe('GET challenges/user', () => { let foundChallenge = _.find(challenges, { _id: challenge._id }); expect(foundChallenge).to.exist; + expect(foundChallenge.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); + expect(foundChallenge.group).to.eql({ + _id: publicGuild._id, + type: publicGuild.type, + privacy: publicGuild.privacy, + name: publicGuild.name, + }); }); it('should return challenges user has created', async () => { @@ -40,8 +50,28 @@ describe('GET challenges/user', () => { let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; + expect(foundChallenge1.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); + expect(foundChallenge1.group).to.eql({ + _id: publicGuild._id, + type: publicGuild.type, + privacy: publicGuild.privacy, + name: publicGuild.name, + }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; + expect(foundChallenge2.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); + expect(foundChallenge2.group).to.eql({ + _id: publicGuild._id, + type: publicGuild.type, + privacy: publicGuild.privacy, + name: publicGuild.name, + }); }); it('should return challenges in user\'s group', async () => { @@ -49,8 +79,28 @@ describe('GET challenges/user', () => { let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; + expect(foundChallenge1.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); + expect(foundChallenge1.group).to.eql({ + _id: publicGuild._id, + type: publicGuild.type, + privacy: publicGuild.privacy, + name: publicGuild.name, + }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; + expect(foundChallenge2.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); + expect(foundChallenge2.group).to.eql({ + _id: publicGuild._id, + type: publicGuild.type, + privacy: publicGuild.privacy, + name: publicGuild.name, + }); }); it('should not return challenges user doesn\'t have access to', async () => { diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js index e916ab5b7a..33cc2e5166 100644 --- a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js @@ -50,7 +50,7 @@ describe('PUT /challenges/:challengeId', () => { let res = await user.put(`/challenges/${challenge._id}`, { // ignored prize: 33, - groupId: 'blabla', + group: 'blabla', memberCount: 33, tasksOrder: 'new order', official: true, @@ -63,7 +63,7 @@ describe('PUT /challenges/:challengeId', () => { }); expect(res.prize).to.equal(0); - expect(res.groupId).to.equal(privateGuild._id); + expect(res.group).to.equal(privateGuild._id); expect(res.memberCount).to.equal(2); expect(res.tasksOrder).not.to.equal('new order'); expect(res.official).to.equal(false); diff --git a/test/api/v3/integration/groups/GET-groups_id.test.js b/test/api/v3/integration/groups/GET-groups_id.test.js index 9cc2fce766..2ecb53a33f 100644 --- a/test/api/v3/integration/groups/GET-groups_id.test.js +++ b/test/api/v3/integration/groups/GET-groups_id.test.js @@ -43,10 +43,6 @@ describe('GET /groups/:id', () => { expect(group.leader._id).to.eql(leader._id); expect(group.leader.profile.name).to.eql(leader.profile.name); - expect(group.leader.items).to.exist; - expect(group.leader.stats).to.exist; - expect(group.leader.achievements).to.exist; - expect(group.leader.contributor).to.exist; }); }); }); diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 8a64ef4bb5..8036d6b0d8 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -32,12 +32,17 @@ describe('POST /group', () => { }); it('sets the group leader to the user who created the group', async () => { - await expect( - user.post('/groups', { - name: 'Test Public Guild', - type: 'guild', - }) - ).to.eventually.have.property('leader', user._id); + let group = await user.post('/groups', { + name: 'Test Public Guild', + type: 'guild', + }); + + expect(group.leader).to.eql({ + _id: user._id, + profile: { + name: user.profile.name, + }, + }); }); }); @@ -86,6 +91,12 @@ describe('POST /group', () => { expect(publicGuild.type).to.equal(groupType); expect(publicGuild.memberCount).to.equal(1); expect(publicGuild.privacy).to.equal(groupPrivacy); + expect(publicGuild.leader).to.eql({ + _id: user._id, + profile: { + name: user.profile.name, + }, + }); }); }); @@ -106,6 +117,12 @@ describe('POST /group', () => { expect(privateGuild.type).to.equal(groupType); expect(privateGuild.memberCount).to.equal(1); expect(privateGuild.privacy).to.equal(groupPrivacy); + expect(privateGuild.leader).to.eql({ + _id: user._id, + profile: { + name: user.profile.name, + }, + }); }); it('deducts gems from user and adds them to guild bank', async () => { @@ -138,6 +155,12 @@ describe('POST /group', () => { expect(party.name).to.equal(partyName); expect(party.type).to.equal(partyType); expect(party.memberCount).to.equal(1); + expect(party.leader).to.eql({ + _id: user._id, + profile: { + name: user.profile.name, + }, + }); }); it('does not require gems to create a party', async () => { diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index b7785e664f..57f6e2163b 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -113,7 +113,7 @@ export async function createAndPopulateGroup (settings = {}) { // optional details argument for the initial challenge creation and an // optional update argument which will update the challenge via the db export async function generateChallenge (challengeCreator, group, details = {}, update = {}) { - details.groupId = group._id; + details.group = group._id; details.name = details.name || 'a challenge'; details.shortName = details.shortName || 'aChallenge'; details.prize = details.prize || 0; diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index a75cc6cc58..7eccbcb9bf 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -2,7 +2,10 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; -import { model as Group } from '../../models/group'; +import { + model as Group, + basicFields as basicGroupFields, +} from '../../models/group'; import { model as User, nameFields, @@ -35,12 +38,12 @@ api.createChallenge = { async handler (req, res) { let user = res.locals.user; - req.checkBody('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkBody('group', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let groupId = req.body.groupId; + let groupId = req.body.group; let prize = req.body.prize; let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true}); @@ -92,6 +95,17 @@ api.createChallenge = { }), group.save()]); let savedChal = results[0]; + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + // await Q.ninvoke(savedChal, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise + let response = savedChal.toJSON(); + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + response.group = { + _id: group._id, + name: group.name, + type: group.type, + privacy: group.privacy, + }; + await savedChal.syncToUser(user); // (it also saves the user) res.respond(201, savedChal); }, @@ -122,7 +136,7 @@ api.joinChallenge = { if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.hasAccess(user, group)) throw new NotFound(res.t('challengeNotFound')); challenge.memberCount += 1; @@ -158,7 +172,7 @@ api.leaveChallenge = { let challenge = await Challenge.findOne({ _id: req.params.challengeId }); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy'}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); if (!challenge.isMember(user)) throw new NotAuthorized(res.t('challengeMemberNotFound')); @@ -186,25 +200,32 @@ api.getUserChallenges = { async handler (req, res) { let user = res.locals.user; - let groups = user.guilds.slice(0); // slice is used to clone the array so we don't modify it directly - if (user.party._id) groups.push(user.party._id); - groups.push('habitrpg'); // tavern challenges - let challenges = await Challenge.find({ $or: [ {_id: {$in: user.challenges}}, // Challenges where the user is participating - {groupId: {$in: groups}}, // Challenges in groups where I'm a member + {group: {$in: user.getGroups()}}, // Challenges in groups where I'm a member {leader: user._id}, // Challenges where I'm the leader ], _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit }) .sort('-official -timestamp') - // TODO populate - // .populate('group', '_id name type') - // .populate('leader', 'profile.name') + // .populate('group', basicGroupFields) + // .populate('leader', nameFields) .exec(); - res.respond(200, challenges); + let resChals = challenges.map(challenge => challenge.toJSON()); + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + await Q.all(resChals.map((chal, index) => { + return Q.all([ + User.findById(chal.leader).select(nameFields).exec(), + Group.findById(chal.group).select(basicGroupFields).exec(), + ]).then(populatedData => { + resChals[index].leader = populatedData[0].toJSON({minimize: true}); + resChals[index].group = populatedData[1].toJSON({minimize: true}); + }); + })); + + res.respond(200, resChals); }, }; @@ -234,14 +255,20 @@ api.getGroupChallenges = { let group = await Group.getGroup({user, groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); - let challenges = await Challenge.find({groupId}) + let challenges = await Challenge.find({group: groupId}) .sort('-official -timestamp') - // TODO populate - // .populate('group', '_id name type') - // .populate('leader', 'profile.name') + // .populate('leader', nameFields) // Only populate the leader as the group is implicit .exec(); - res.respond(200, challenges); + let resChals = challenges.map(challenge => challenge.toJSON()); + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + await Q.all(resChals.map((chal, index) => { + return User.findById(chal.leader).select(nameFields).exec().then(populatedLeader => { + resChals[index].leader = populatedLeader.toJSON({minimize: true}); + }); + })); + + res.respond(200, resChals); }, }; @@ -268,13 +295,21 @@ api.getChallenge = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findById(challengeId).exec(); + let challenge = await Challenge.findById(challengeId) + // .populate('leader', nameFields) // don't populate the group as we'll fetch it manually later + .exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + // Fetching basicGroupFields + let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - res.respond(200, challenge); + let chalRes = challenge.toJSON(); + chalRes.group = group.toJSON({minimize: true}); + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + chalRes.leader = (await User.findById(chalRes.leader).select(nameFields).exec()).toJSON({minimize: true}); + + res.respond(200, chalRes); }, }; @@ -301,9 +336,9 @@ api.exportChallengeCsv = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findById(challengeId).select('_id groupId leader tasksOrder').exec(); + let challenge = await Challenge.findById(challengeId).select('_id group leader tasksOrder').exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); // In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all @@ -377,7 +412,7 @@ api.updateChallenge = { let challenge = await Challenge.findById(challengeId).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id name type privacy', optionalMembership: true}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id name type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderUpdateChal')); @@ -398,12 +433,12 @@ async function _closeChal (challenge, broken = {}) { await Challenge.remove({_id: challenge._id}).exec(); // Refund the leader if the challenge is closed and the group not the tavern - if (challenge.groupId !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { + if (challenge.group !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec(); } // Update the challengeCount on the group - await Group.update({_id: challenge.groupId}, {$inc: {challengeCount: -1}}).exec(); + await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(); // Award prize to winner and notify if (winner) { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index b9b633c111..ff223bcef8 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -5,8 +5,12 @@ import cron from '../../middlewares/api-v3/cron'; import { INVITES_LIMIT, model as Group, + basicFields as basicGroupFields, } from '../../models/group'; -import { model as User } from '../../models/user'; +import { + model as User, + nameFields, +} from '../../models/user'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { NotFound, @@ -55,11 +59,14 @@ api.createGroup = { let results = await Q.all([user.save(), group.save()]); let savedGroup = results[1]; + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + // await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise + let response = savedGroup.toJSON(); + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + res.respond(201, response); firebase.updateGroupData(savedGroup); firebase.addUserToGroup(savedGroup._id, user._id); - - return res.respond(201, savedGroup); // TODO populate }, }; @@ -87,7 +94,7 @@ api.getGroups = { // TODO validate types are acceptable? probably not necessary let types = req.query.type.split(','); - let groupFields = 'name description memberCount balance'; + let groupFields = basicGroupFields.concat('description memberCount balance'); let sort = '-memberCount'; let queries = []; @@ -152,7 +159,7 @@ api.getGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: true}); + let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: false}); if (!group) throw new NotFound(res.t('groupNotFound')); if (!user.contributor.admin) { @@ -163,6 +170,8 @@ api.getGroup = { }); } + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + group.leader = (await User.findById(group.leader).select(nameFields).exec()).toJSON({minimize: true}); res.respond(200, group); }, }; diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 142926ed64..de2213d3b6 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -74,12 +74,12 @@ function _getMembersForItem (type) { let group; if (type === 'challenge-members') { - challenge = await Challenge.findById(challengeId).select('_id type leader groupId').exec(); + challenge = await Challenge.findById(challengeId).select('_id type leader group').exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge // for example if you've been booted from it, are the leader or a site admin - group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); } else { group = await Group.getGroup({user, groupId, fields: '_id type'}); @@ -212,7 +212,7 @@ api.getChallengeMemberProgress = { // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge // for example if you've been booted from it, are the leader or a site admin - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index df78ca3a4f..2b711a5a2a 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -216,9 +216,9 @@ api.getChallengeTasks = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findOne({_id: challengeId}).select('groupId leader tasksOrder').exec(); + let challenge = await Challenge.findOne({_id: challengeId}).select('group leader tasksOrder').exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); await _getTasks(req, res, res.locals.user, challenge); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 62307f6d8e..b67e8b7832 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -20,7 +20,7 @@ let schema = new Schema({ rewards: [{type: String, ref: 'Task'}], }, leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, - groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, + group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, memberCount: {type: Number, default: 1}, prize: {type: Number, default: 0, min: 0}, // TODO no update? }); @@ -31,7 +31,7 @@ schema.plugin(baseModel, { }); // A list of additional fields that cannot be updated (but can be set on creation) -let noUpdate = ['groupId', 'official', 'shortName', 'prize']; +let noUpdate = ['group', 'official', 'shortName', 'prize']; schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { return this.sanitize(updateObj, noUpdate); }; @@ -49,10 +49,7 @@ schema.methods.canModify = function canModifyChallenge (user) { // Returns true if user has access to the challenge (can join) schema.methods.hasAccess = function hasAccessToChallenge (user, group) { if (group.type === 'guild' && group.privacy === 'public') return true; - let userGroups = user.guilds.slice(0); // clone user.guilds so we don't modify the original - if (user.party._id) userGroups.push(user.party._id); - userGroups.push('habitrpg'); // tavern - return userGroups.indexOf(this.groupId) !== -1; + return user.getGroups().indexOf(this.group) !== -1; }; // Returns true if user can view the challenge diff --git a/website/src/models/group.js b/website/src/models/group.js index 1bbb0145a5..890662de4c 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -84,6 +84,9 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { return this.sanitize(updateObj, noUpdate); }; +// Basic fields to fetch for populating a group info +export let basicFields = 'name type privacy'; + // TODO migration /** * Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration @@ -472,7 +475,7 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { let challenges = await Challenge.find({ _id: {$in: user.challenges}, - groupId: group._id, + group: group._id, }); let challengesToRemoveUserFrom = challenges.map(chal => { diff --git a/website/src/models/user.js b/website/src/models/user.js index cc1ebccf54..31272dc874 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -654,6 +654,14 @@ schema.methods.isSubscribed = function isSubscribed () { return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion }; +// Get an array of groups ids the user is member of +schema.methods.getGroups = function getUserGroups () { + let userGroups = this.guilds.slice(0); // clone user.guilds so we don't modify the original + if (this.party._id) userGroups.push(this.party._id); + userGroups.push('habitrpg'); // tavern + return userGroups; +}; + // Unlink challenges tasks (and the challenge itself) from user schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (challengeId, keep) { let user = this; From a8dc5294495ddf1b6bc7dca9a572c3f74609dedc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 22:09:35 +0100 Subject: [PATCH 440/976] adapt create challenge tests to population --- .../challenges/POST-challenges.test.js | 57 ++++++++++++------- website/src/controllers/api-v3/challenges.js | 2 +- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js index 8bddb016b9..2f4878d91a 100644 --- a/test/api/v3/integration/challenges/POST-challenges.test.js +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -6,7 +6,7 @@ import { import { v4 as generateUUID } from 'uuid'; describe('POST /challenges', () => { - it('returns error when groupId is empty', async () => { + it('returns error when group is empty', async () => { let user = await generateUser(); await expect(user.post('/challenges')).to.eventually.be.rejected.and.eql({ @@ -20,7 +20,7 @@ describe('POST /challenges', () => { let user = await generateUser(); await expect(user.post(`/challenges`, { - groupId: generateUUID(), + group: generateUUID(), })).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -32,7 +32,7 @@ describe('POST /challenges', () => { let user = await generateUser(); await expect(user.post(`/challenges`, { - groupId: 'habitrpg', + group: 'habitrpg', prize: 0, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -51,7 +51,7 @@ describe('POST /challenges', () => { }); await expect(user.post(`/challenges`, { - groupId: group._id, + group: group._id, prize: 4, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -86,7 +86,7 @@ describe('POST /challenges', () => { it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { await expect(groupMember.post(`/challenges`, { - groupId: group._id, + group: group._id, })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -96,7 +96,7 @@ describe('POST /challenges', () => { it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { await expect(groupMember.post(`/challenges`, { - groupId: group._id, + group: group._id, })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -112,11 +112,16 @@ describe('POST /challenges', () => { group = populatedGroup.group; groupMember = populatedGroup.members[0]; - await expect(groupMember.post(`/challenges`, { - groupId: group._id, + let chal = await groupMember.post(`/challenges`, { + group: group._id, name: 'Test Challenge', shortName: 'TC', - })).to.eventually.have.property('leader', groupMember._id); + }); + + expect(chal.leader).to.eql({ + _id: groupMember._id, + profile: {name: groupMember.profile.name}, + }); }); it('doesn\'t take gems from user or group when challenge has no prize', async () => { @@ -124,7 +129,7 @@ describe('POST /challenges', () => { let oldGroupBalance = group.balance; await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', prize: 0, @@ -136,7 +141,7 @@ describe('POST /challenges', () => { it('returns error when user and group can\'t pay prize', async () => { await expect(groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', prize: 20, @@ -153,7 +158,7 @@ describe('POST /challenges', () => { let prize = 4; await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', prize, @@ -168,7 +173,7 @@ describe('POST /challenges', () => { let prize = 8; await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', prize, @@ -184,7 +189,7 @@ describe('POST /challenges', () => { await group.update({ balance: 0}); await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', prize, @@ -198,7 +203,7 @@ describe('POST /challenges', () => { let oldChallengeCount = group.challengeCount; await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', }); @@ -214,7 +219,7 @@ describe('POST /challenges', () => { }); let challenge = await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', official: true, @@ -225,7 +230,7 @@ describe('POST /challenges', () => { it('doesn\'t set challenge as official if official flag is set by non-admin', async () => { let challenge = await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', official: true, @@ -241,7 +246,7 @@ describe('POST /challenges', () => { let oldGroupBalance = group.balance; await expect(groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, prize: 8, })).to.eventually.be.rejected.and.eql({ code: 400, @@ -265,26 +270,34 @@ describe('POST /challenges', () => { let prize = 4; let challenge = await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name, shortName, description, prize, }); - expect(challenge.leader).to.eql(groupLeader._id); + expect(challenge.leader).to.eql({ + _id: groupLeader._id, + profile: {name: groupLeader.profile.name}, + }); expect(challenge.name).to.eql(name); expect(challenge.shortName).to.eql(shortName); expect(challenge.description).to.eql(description); expect(challenge.official).to.be.undefined; - expect(challenge.groupId).to.eql(group._id); + expect(challenge.group).to.eql({ + _id: group._id, + privacy: group.privacy, + name: group.name, + type: group.type, + }); expect(challenge.memberCount).to.eql(1); expect(challenge.prize).to.eql(prize); }); it('adds challenge to creator\'s challenges', async () => { let challenge = await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', }); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 7eccbcb9bf..c14a12d643 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -107,7 +107,7 @@ api.createChallenge = { }; await savedChal.syncToUser(user); // (it also saves the user) - res.respond(201, savedChal); + res.respond(201, response); }, }; From d5148a7bf01f196d77c83a028190b277b51aaf47 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 1 Feb 2016 19:33:35 -0600 Subject: [PATCH 441/976] Added quest reject route and intial tests --- common/locales/en/quests.json | 3 +- .../POST-groups_groupid_quests_reject.test.js | 98 +++++++++++++++++++ website/src/controllers/api-v3/groups.js | 97 ++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js diff --git a/common/locales/en/quests.json b/common/locales/en/quests.json index 7b3fe7af43..5edae258e5 100644 --- a/common/locales/en/quests.json +++ b/common/locales/en/quests.json @@ -78,5 +78,6 @@ "whichQuestStart": "Which quest do you want to start?", "getMoreQuests": "Get more quests", "unlockedAQuest": "You unlocked a quest!", - "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!" + "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!", + "questInvitationDoesNotExist": "No quest invitation has been sent out yet." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js new file mode 100644 index 0000000000..e5f7517eff --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -0,0 +1,98 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/invite/:questKey', () => { + let questingGroup; + let member; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(member.post(`/groups/${generateUUID()}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error when group is not a party', async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(groupLeader.post(`/groups/${group._id}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('returns an error when quest is not found', async () => { + let questKey = 'fakeQuestName'; + + await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${questKey}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questNotFound', { key: questKey }), + }); + }); + + it('returns an error when user is not on the quest', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questNotOwned'), + }); + }); + + it('returns an error when group is not on a quest', async () => { + await member.update(userQuestUpdate); + + await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInvitationDoesNotExist'), + }); + }); + }); + + context('successfully quest rejection', () => { + it('rejects a quest invitation', async () => { + await member.update(userQuestUpdate); + await questingGroup.update({'quest.key': PET_QUEST}); + + await member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`); + + let userWithRejectInvitation = await member.get('/user'); + expect(userWithRejectInvitation.party.quest.key).to.be.null; + expect(userWithRejectInvitation.party.quest.RSVPNeeded).to.be.false; + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index b9b633c111..785ea564ba 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,6 +17,8 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; +import { quests as questScrolls } from '../../../../common/script/content'; +import { mockAnalyticsService } from '../../libs/api-v3/analyticsService'; let api = {}; @@ -616,4 +618,99 @@ api.inviteToGroup = { }, }; +<<<<<<< e3c7d2834e6e5fa024afb71032c21177ca4124a7 +======= +/** + * @api {post} /groups/:groupId/quests/invite Invite users to a quest + * @apiVersion 3.0.0 + * @apiName InviteToQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.inviteToQuest = { + method: 'POST', + url: '/groups/:groupId/quests/invite/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); + if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + + // TODO Logic for quest invite and send back quest object + res.respond(200, {}); + }, +}; + +/** + * @api {post} /groups/:groupId/quests/reject Reject a quest + * @apiVersion 3.0.0 + * @apiName RejectQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} questKey The quest _id + * + * @apiSuccess {Object} Quest Object + */ +api.rejectQuest = { + method: 'POST', + url: '/groups/:groupId/quests/reject/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); + + let analyticsData = { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }; + mockAnalyticsService.track('quest', analyticsData); + + // @TODO: Are we tracking members this way? + // group.quest.members[user._id] = false; + + user.party.quest.RSVPNeeded = false; + user.party.quest.key = null; + await user.save(); + + // questStart(req,res,next); + + res.respond(200, {}); + }, +}; +>>>>>>> Added quest reject route and intial tests export default api; From 1daf87531aefe9a8a21bd000c68924d7c14314e6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 3 Feb 2016 11:29:19 +0100 Subject: [PATCH 442/976] add population to join and update routes of challenges and groups, remove chat flags info everywhere --- .../POST-challenges_challengeId_join.test.js | 16 +++++++ .../PUT-challenges_challengeId.test.js | 12 +++++- test/api/v3/integration/chat/GET-chat.test.js | 4 +- .../groups/POST-groups_groupId_join.test.js | 4 +- .../v3/integration/groups/PUT-groups.test.js | 2 + website/src/controllers/api-v3/challenges.js | 43 +++++++++++++------ website/src/controllers/api-v3/chat.js | 12 +++--- website/src/controllers/api-v3/groups.js | 43 ++++++++++++------- website/src/models/group.js | 17 +++++++- 9 files changed, 114 insertions(+), 39 deletions(-) diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js index 3eda5716cd..34e018d69d 100644 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js @@ -55,6 +55,22 @@ describe('POST /challenges/:challengeId/join', () => { }); }); + it('returns challenge data', async () => { + let res = await authorizedUser.post(`/challenges/${challenge._id}/join`); + + expect(res.group).to.eql({ + _id: group._id, + privacy: group.privacy, + name: group.name, + type: group.type, + }); + expect(res.leader).to.eql({ + _id: groupLeader._id, + profile: {name: groupLeader.profile.name}, + }); + expect(res.name).to.equal(challenge.name); + }); + it('adds challenge to user challenges', async () => { await authorizedUser.post(`/challenges/${challenge._id}/join`); diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js index 33cc2e5166..63d2102968 100644 --- a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js @@ -63,13 +63,21 @@ describe('PUT /challenges/:challengeId', () => { }); expect(res.prize).to.equal(0); - expect(res.group).to.equal(privateGuild._id); + expect(res.group).to.eql({ + _id: privateGuild._id, + privacy: privateGuild.privacy, + name: privateGuild.name, + type: privateGuild.type, + }); expect(res.memberCount).to.equal(2); expect(res.tasksOrder).not.to.equal('new order'); expect(res.official).to.equal(false); expect(res.shortName).not.to.equal('new short name'); - expect(res.leader).to.equal(member._id); + expect(res.leader).to.eql({ + _id: member._id, + profile: {name: member.profile.name}, + }); expect(res.name).to.equal('New Challenge Name'); expect(res.description).to.equal('New challenge description.'); }); diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index dbd631e362..91424d655e 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -23,8 +23,8 @@ describe('GET /groups/:groupId/chat', () => { privacy: 'public', }, { chat: [ - 'Hello', - 'Welcome to the Guild', + {text: 'Hello', flags: {}}, + {text: 'Welcome to the Guild', flags: {}}, ], }); }); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index d20c8b830d..b9b759bed9 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -35,9 +35,11 @@ describe('POST /group/:groupId/join', () => { }); it('allows non-invited users to join public guilds', async () => { - await joiningUser.post(`/groups/${publicGuild._id}/join`); + let res = await joiningUser.post(`/groups/${publicGuild._id}/join`); await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').to.include(publicGuild._id); + expect(res.leader._id).to.eql(user._id); + expect(res.leader.profile.name).to.eql(user.profile.name); }); it('promotes joining member in a public empty guild to leader', async () => { diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 414f495ff7..8d581d56ca 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -39,6 +39,8 @@ describe('PUT /group', () => { name: groupUpdatedName, }); + expect(updatedGroup.leader._id).to.eql(leader._id); + expect(updatedGroup.leader.profile.name).to.eql(leader.profile.name); expect(updatedGroup.name).to.equal(groupUpdatedName); }); }); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index c14a12d643..bc9e6f03b4 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -93,20 +93,21 @@ api.createChallenge = { let results = await Q.all([challenge.save({ validateBeforeSave: false, // already validate }), group.save()]); - let savedChal = results[0]; - // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - // await Q.ninvoke(savedChal, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise + + await savedChal.syncToUser(user); // (it also saves the user) + let response = savedChal.toJSON(); - response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); - response.group = { + response.leader = { // the leader is the authenticated user + _id: user._id, + profile: {name: user.profile.name}, + }; + response.group = { // we already have the group data _id: group._id, name: group.name, type: group.type, privacy: group.privacy, }; - - await savedChal.syncToUser(user); // (it also saves the user) res.respond(201, response); }, }; @@ -136,14 +137,24 @@ api.joinChallenge = { if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); - let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true}); if (!group || !challenge.hasAccess(user, group)) throw new NotFound(res.t('challengeNotFound')); challenge.memberCount += 1; // Add all challenge's tasks to user's tasks and save the challenge - await Q.all([challenge.syncToUser(user), challenge.save()]); - res.respond(200, challenge); + let results = await Q.all([challenge.syncToUser(user), challenge.save()]); + + let response = results[1].toJSON(); + response.group = { // we already have the group data + _id: group._id, + name: group.name, + type: group.type, + privacy: group.privacy, + }; + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + + res.respond(200, response); }, }; @@ -412,14 +423,22 @@ api.updateChallenge = { let challenge = await Challenge.findById(challengeId).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id name type privacy', optionalMembership: true}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderUpdateChal')); _.merge(challenge, Challenge.sanitizeUpdate(req.body)); let savedChal = await challenge.save(); - res.respond(200, savedChal); + let response = savedChal.toJSON(); + response.group = { // we already have the group data + _id: group._id, + name: group.name, + type: group.type, + privacy: group.privacy, + }; + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + res.respond(200, response); }, }; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 5c8d4fd3ed..079fbb66cd 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -38,7 +38,7 @@ api.getChat = { let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'chat'}); if (!group) throw new NotFound(res.t('groupNotFound')); - res.respond(200, group.chat); + res.respond(200, Group.toJSONCleanChat(group, user).chat); }, }; @@ -88,7 +88,7 @@ api.postChat = { let savedGroup = await group.save(); if (chatUpdated) { - res.respond(200, {chat: savedGroup.chat}); + res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat}); } else { res.respond(200, {message: savedGroup.chat[0]}); } @@ -138,7 +138,7 @@ api.likeChat = { {_id: group._id, 'chat.id': message.id}, update ); - res.respond(200, message); + res.respond(200, message); // TODO what if the message is flagged and shouldn't be returned? }, }; @@ -385,9 +385,9 @@ api.deleteChat = { ); if (chatUpdated) { - group = group.toJSON(); - removeFromArray(group.chat, {id: chatId}); - res.respond(200, group.chat); + let chatRes = Group.toJSONCleanChat(group, user).chat; + removeFromArray(chatRes, {id: chatId}); + res.respond(200, chatRes); } else { res.respond(200, {}); } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index ff223bcef8..d4f19ea2ec 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -59,12 +59,17 @@ api.createGroup = { let results = await Q.all([user.save(), group.save()]); let savedGroup = results[1]; + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 // await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise let response = savedGroup.toJSON(); - response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + // the leader is the authenticated user + response.leader = { + _id: user._id, + profile: {name: user.profile.name}, + }; + res.respond(201, response); // do not remove chat flags data as we've just created the group - res.respond(201, response); firebase.updateGroupData(savedGroup); firebase.addUserToGroup(savedGroup._id, user._id); }, @@ -162,16 +167,10 @@ api.getGroup = { let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: false}); if (!group) throw new NotFound(res.t('groupNotFound')); - if (!user.contributor.admin) { - group = group.toJSON(); - _.remove(group.chat, function removeChat (chat) { - chat.flags = {}; - return chat.flagCount >= 2; - }); - } - + group = Group.toJSONCleanChat(group, user); // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 group.leader = (await User.findById(group.leader).select(nameFields).exec()).toJSON({minimize: true}); + res.respond(200, group); }, }; @@ -206,7 +205,18 @@ api.updateGroup = { _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body))); let savedGroup = await group.save(); - res.respond(200, savedGroup); + let response = Group.toJSONCleanChat(savedGroup, user); + // If the leader changed fetch new data, otherwise use authenticated user + if (response.leader !== user._id) { + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + } else { + response.leader = { + _id: user._id, + profile: {name: user.profile.name}, + }; + } + res.respond(200, response); + firebase.updateGroupData(savedGroup); }, }; @@ -219,7 +229,7 @@ api.updateGroup = { * * @apiParam {UUID} groupId The group _id * - * @apiSuccess {Object} empty An empty object + * @apiSuccess {Object} group The group */ api.joinGroup = { method: 'POST', @@ -234,8 +244,8 @@ api.joinGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - // Do not fetch chat and work even if the user is not yet a member of the group - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat', optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group + // Works even if the user is not yet a member of the group + let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group if (!group) throw new NotFound(res.t('groupNotFound')); let isUserInvited = false; @@ -288,8 +298,11 @@ api.joinGroup = { await Q.all(promises); + let response = Group.toJSONCleanChat(promises[0], user); + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + res.respond(200, response); + firebase.addUserToGroup(group._id, user._id); - res.respond(200, {}); // TODO what to return? }, }; diff --git a/website/src/models/group.js b/website/src/models/group.js index 890662de4c..9e0a1b7a52 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -137,7 +137,22 @@ schema.statics.getGroup = function getGroup (options = {}) { if (fields) mQuery.select(fields); if (populateLeader === true) mQuery.populate('leader', nameFields); return mQuery.exec(); - // TODO purge chat flags info? in tojson? +}; + +// When converting to json remove chat messages with more than 1 flag and remove all flags info +// unless the user is an admin +// Not putting into toJSON because there we can't access user +schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) { + let toJSON = group.toJSON(); + console.log(group.chat, toJSON.chat) + if (!user.contributor.admin) { + _.remove(toJSON.chat, chatMsg => { + console.log(chatMsg) + chatMsg.flags = {}; + return chatMsg.flagCount >= 2; + }); + } + return toJSON; }; schema.methods.removeGroupInvitations = async function removeGroupInvitations () { From 19a709c36084291c6f264e264a68c3db5e589f0b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 3 Feb 2016 11:30:09 +0100 Subject: [PATCH 443/976] remove console.log statements --- website/src/models/group.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index 9e0a1b7a52..cab711d4a6 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -144,10 +144,8 @@ schema.statics.getGroup = function getGroup (options = {}) { // Not putting into toJSON because there we can't access user schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) { let toJSON = group.toJSON(); - console.log(group.chat, toJSON.chat) if (!user.contributor.admin) { _.remove(toJSON.chat, chatMsg => { - console.log(chatMsg) chatMsg.flags = {}; return chatMsg.flagCount >= 2; }); From 0684e307909406b41bf11737dc9a0a4471a5b902 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 3 Feb 2016 17:04:57 -0600 Subject: [PATCH 444/976] Added quest cancel route and initial tests --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_cancel.test.js | 66 +++++++++++++++++++ website/src/controllers/api-v3/quests.js | 49 ++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..5179067850 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,6 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "cantCancelActiveQuest": "You can not cancel an active quest" } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js new file mode 100644 index 0000000000..84111ab5ea --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -0,0 +1,66 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/leave', () => { + let questingGroup, member, leader; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + leader = groupLeader; + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + it('returns an error when group is not found', async () => { + await expect(leader.post(`/groups/${generateUUID()}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('cancels a quest', async () => { + await member.update(userQuestUpdate); + await questingGroup.update({'quest.key': PET_QUEST}); + + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + + await leader.post(`/groups/${questingGroup._id}/quests/cancel`); + let userThatCanceled = await member.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + + expect(userThatCanceled.party.quest.key).to.be.null; + expect(userThatCanceled.party.quest.RSVPNeeded).to.be.false; + expect(updatedGroup.quest.members).to.be.empty; + }); + + it('returns an error when quest is active', async () => { + await questingGroup.update({'quest.active': true}); + await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cantCancelActiveQuest'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..8ebe969071 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -3,6 +3,9 @@ import cron from '../../middlewares/api-v3/cron'; import { model as Group, } from '../../models/group'; +import { + model as User, +} from '../../models/user'; import { NotFound, NotAuthorized, @@ -65,4 +68,50 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/cancel Cancels a quest + * @apiVersion 3.0.0 + * @apiName CancelQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Group Object + */ +api.cancelQuest = { + method: 'POST', + url: '/groups/:groupId/quests/cancel', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + // Cancel a quest BEFORE it has begun (i.e., in the invitation stage) + // Quest scroll has not yet left quest owner's inventory so no need to return it. + // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started. + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest')); + + group.quest = {key: null, progress: {}, leader: null, members: {}}; + group.markModified('quest'); + await group.save(); + + await User.update( + {'party._id': groupId}, + {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}, + {multi: true} + ); + + res.respond(200, group); + }, +}; + + export default api; From 442a654e94874586d5a56decef683190b71d9fc9 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 4 Feb 2016 12:27:51 -0600 Subject: [PATCH 445/976] Removed questKey from url, added quest member support, removed extra tests, and fixed analytics import --- .../POST-groups_groupid_quests_reject.test.js | 46 ++------- website/src/controllers/api-v3/groups.js | 97 ------------------- website/src/controllers/api-v3/quests.js | 56 +++++++++++ 3 files changed, 66 insertions(+), 133 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index e5f7517eff..a222778948 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -30,7 +30,7 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { context('failure conditions', () => { it('returns an error when group is not found', async () => { - await expect(member.post(`/groups/${generateUUID()}/quests/reject/${PET_QUEST}`)) + await expect(member.post(`/groups/${generateUUID()}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -38,43 +38,10 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { }); }); - it('returns an error when group is not a party', async () => { - let { group, groupLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'guild', privacy: 'private' }, - }); - - await expect(groupLeader.post(`/groups/${group._id}/quests/reject/${PET_QUEST}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('guildQuestsNotSupported'), - }); - }); - - it('returns an error when quest is not found', async () => { - let questKey = 'fakeQuestName'; - - await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${questKey}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('questNotFound', { key: questKey }), - }); - }); - - it('returns an error when user is not on the quest', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questNotOwned'), - }); - }); - it('returns an error when group is not on a quest', async () => { await member.update(userQuestUpdate); - await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) + await expect(member.post(`/groups/${questingGroup._id}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -88,11 +55,18 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { await member.update(userQuestUpdate); await questingGroup.update({'quest.key': PET_QUEST}); - await member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`); + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + let rejectResult = await member.post(`/groups/${questingGroup._id}/quests/reject`); let userWithRejectInvitation = await member.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + expect(userWithRejectInvitation.party.quest.key).to.be.null; expect(userWithRejectInvitation.party.quest.RSVPNeeded).to.be.false; + expect(updatedGroup.quest.members[member._id]).to.be.false; + expect(updatedGroup.quest).to.deep.equal(rejectResult); }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 785ea564ba..b9b633c111 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,8 +17,6 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; -import { quests as questScrolls } from '../../../../common/script/content'; -import { mockAnalyticsService } from '../../libs/api-v3/analyticsService'; let api = {}; @@ -618,99 +616,4 @@ api.inviteToGroup = { }, }; -<<<<<<< e3c7d2834e6e5fa024afb71032c21177ca4124a7 -======= -/** - * @api {post} /groups/:groupId/quests/invite Invite users to a quest - * @apiVersion 3.0.0 - * @apiName InviteToQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} Quest Object - */ -api.inviteToQuest = { - method: 'POST', - url: '/groups/:groupId/quests/invite/:questKey', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let questKey = req.params.questKey; - let quest = questScrolls[questKey]; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); - if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); - if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); - if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - - // TODO Logic for quest invite and send back quest object - res.respond(200, {}); - }, -}; - -/** - * @api {post} /groups/:groupId/quests/reject Reject a quest - * @apiVersion 3.0.0 - * @apiName RejectQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * @apiParam {string} questKey The quest _id - * - * @apiSuccess {Object} Quest Object - */ -api.rejectQuest = { - method: 'POST', - url: '/groups/:groupId/quests/reject/:questKey', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let questKey = req.params.questKey; - let quest = questScrolls[questKey]; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); - if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); - if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); - - let analyticsData = { - category: 'behavior', - owner: false, - response: 'reject', - gaLabel: 'reject', - questName: group.quest.key, - uuid: user._id, - }; - mockAnalyticsService.track('quest', analyticsData); - - // @TODO: Are we tracking members this way? - // group.quest.members[user._id] = false; - - user.party.quest.RSVPNeeded = false; - user.party.quest.key = null; - await user.save(); - - // questStart(req,res,next); - - res.respond(200, {}); - }, -}; ->>>>>>> Added quest reject route and intial tests export default api; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..6a7423bc0a 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -8,6 +8,8 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +import { track } from '../../libs/api-v3/analyticsService'; +import Q from 'q'; let api = {}; @@ -65,4 +67,58 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/reject Reject a quest + * @apiVersion 3.0.0 + * @apiName RejectQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} questKey The quest _id + * + * @apiSuccess {Object} Quest Object + */ +api.rejectQuest = { + method: 'POST', + url: '/groups/:groupId/quests/reject', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); + + let analyticsData = { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }; + track('quest', analyticsData); + + group.quest.members[user._id] = false; + group.markModified('quest.members'); + + user.party.quest.RSVPNeeded = false; + user.party.quest.key = null; + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + // questStart(req,res,next); + + res.respond(200, savedGroup.quest); + }, +}; + export default api; From 5ca663db57ec22ce98a44d2471664ec5de00eb24 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 4 Feb 2016 12:31:38 -0600 Subject: [PATCH 446/976] Added quest leave route and initial tests --- common/locales/en/api-v3.json | 5 +- .../POST-groups_groupid_quests_leave.test.js | 85 +++++++++++++++++++ website/src/controllers/api-v3/quests.js | 54 ++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..c98411ed22 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,8 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "noActiveQuestToLeave": "No active quest to leave", + "questLeaderCannotLeaveQuest": "Quest leader cannot leave quest", + "notPartOfQuest": "You are not part of the quest" } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js new file mode 100644 index 0000000000..eec1e33842 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -0,0 +1,85 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/leave', () => { + let questingGroup, member, leader; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + leader = groupLeader; + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + it('returns an error when group is not found', async () => { + await expect(member.post(`/groups/${generateUUID()}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error when quest is not active', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToLeave'), + }); + }); + + it('returns an error when quest leader attempts to leave', async () => { + await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLeaderCannotLeaveQuest'), + }); + }); + + it('returns an error when non quest member attempts to leave', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notPartOfQuest'), + }); + }); + + it('leaves a quest', async () => { + await member.update(userQuestUpdate); + + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + + let leaveResult = await member.post(`/groups/${questingGroup._id}/quests/leave`); + let userThatLeft = await member.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + + expect(userThatLeft.party.quest.key).to.be.null; + expect(userThatLeft.party.quest.RSVPNeeded).to.be.false; + expect(updatedGroup.quest.members[member._id]).to.be.false; + expect(updatedGroup.quest).to.deep.equal(leaveResult); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..8944f06081 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -8,6 +8,7 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +import Q from 'q'; let api = {}; @@ -65,4 +66,57 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/leave Leaves a quest + * @apiVersion 3.0.0 + * @apiName LeaveQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Empty Object + */ +api.leaveQuest = { + method: 'POST', + url: '/groups/:groupId/quests/leave', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (!(group.quest && group.quest.active)) { + throw new NotFound(res.t('noActiveQuestToLeave')); + } + + if (group.quest.leader === user._id) { + throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest')); + } + + if (!(group.quest.members && group.quest.members[user._id])) { + throw new NotAuthorized(res.t('notPartOfQuest')); + } + + group.quest.members[user._id] = false; + group.markModified('quest.members'); + + user.party.quest = Group.cleanQuestProgress(); + user.markModified('party.quest'); + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + }, +}; + export default api; From 6f606df211ff79c5c3a33dfc7465bef17631091a Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 4 Feb 2016 15:22:37 -0600 Subject: [PATCH 447/976] Added quest abort route and initial tests --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_abort.test.js | 78 +++++++++++++++++++ website/src/controllers/api-v3/quests.js | 52 +++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..2818fe6307 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,6 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "noActiveQuestToAbort": "There is no active quest to abort" } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js new file mode 100644 index 0000000000..1ed6871307 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -0,0 +1,78 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/abort', () => { + let questingGroup, member, leader; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + leader = groupLeader; + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + it('returns an error when group is not found', async () => { + await expect(leader.post(`/groups/${generateUUID()}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error when quest is not active', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToAbort'), + }); + }); + + xit('returns an error when non quest leader attempts to abort', async () => { + await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + + await expect(member.post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLeaderCannotAbortQuest'), + }); + }); + + it('aborts a quest', async () => { + await member.update(userQuestUpdate); + + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + + let abortResult = await leader.post(`/groups/${questingGroup._id}/quests/abort`); + let updatedMember = await member.get('/user'); + let updatedLeader = await leader.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + + expect(updatedMember.party.quest.key).to.be.null; + expect(updatedMember.party.quest.RSVPNeeded).to.be.false; + expect(updatedLeader.items.quests[PET_QUEST]).to.equal(1); + expect(updatedGroup.quest).to.deep.equal(abortResult); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..53e2274959 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -3,11 +3,13 @@ import cron from '../../middlewares/api-v3/cron'; import { model as Group, } from '../../models/group'; +import { model as User } from '../../models/user'; import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +import Q from 'q'; let api = {}; @@ -65,4 +67,54 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/abort Abort a quest + * @apiVersion 3.0.0 + * @apiName AbortQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.abortQuest = { + method: 'POST', + url: '/groups/:groupId/quests/abort', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + // Abort a quest AFTER it has begun (see questCancel for BEFORE) + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort')); + + let memberUpdates = User.update( + {'party._id': groupId}, + { + $set: {'party.quest': Group.cleanQuestProgress()}, + $inc: {_v: 1}, + }, + {multi: true}, + ); + + let update = {$inc: {}}; + update.$inc[`items.quests.${group.quest.key}`] = 1; + let questLeaderUpdate = User.update({_id: group.quest.leader}, update).exec(); + + group.quest = {key: null, progress: {collect: {}}, leader: null, members: {}, extra: {}, active: false}; + group.markModified('quest'); + + let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]); + + res.respond(200, groupSaved.quest); + }, +}; + export default api; From 18958bde5a5f76f4b6ae8969f0dce91046563f71 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 5 Feb 2016 11:06:40 +0100 Subject: [PATCH 448/976] review and fix group model methods --- website/src/models/group.js | 256 +++++++++++++++++------------------- 1 file changed, 119 insertions(+), 137 deletions(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index e416317a47..b9cf7acd98 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -104,9 +104,8 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { // TODO test schema.pre('remove', true, async function preRemoveGroup (next, done) { next(); - let group = this; try { - await group.removeGroupInvitations(); + await this.removeGroupInvitations(); done(); } catch (err) { done(err); @@ -257,7 +256,7 @@ schema.methods.sendChat = function sendChat (message, user) { this.chat.splice(200); // Kick off chat notifications in the background. // TODO refactor - let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; // TODO standardize this _v inc at the user level + let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; if (this._id === 'habitrpg') { @@ -289,12 +288,12 @@ function _cleanQuestProgress (merge) { collect: {}, }, completed: null, - RSVPNeeded: false, // TODO absolutely change this cryptic name + RSVPNeeded: false, }; - if (merge) { // TODO why does it do 2 merges? + if (merge) { _.merge(clean, _.omit(merge, 'progress')); - _.merge(clean.progress, merge.progress); + if (merge.progress) _.merge(clean.progress, merge.progress); } return clean; @@ -303,6 +302,7 @@ function _cleanQuestProgress (merge) { schema.statics.cleanQuestProgress = _cleanQuestProgress; // Participants: Grant rewards & achievements, finish quest +// Returns the promise from update().exec() schema.methods.finishQuest = function finishQuest (quest) { let questK = quest.key; let updates = {$inc: {}, $set: {}}; @@ -344,48 +344,88 @@ schema.methods.finishQuest = function finishQuest (quest) { let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}}; this.quest = {}; this.markModified('quest'); - return User.update(q, updates, {multi: true}); + return User.update(q, updates, {multi: true}).exec(); }; function _isOnQuest (user, progress, group) { return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; } -schema.statics.collectQuest = function collectQuest (user, progress) { - return this.findOne({ - type: 'party', - members: {$in: [user._id]}, - }).then(group => { - if (!_isOnQuest(user, progress, group)) return; - let quest = shared.content.quests[group.quest.key]; +// Returns a promise +schema.statics.collectQuest = async function collectQuest (user, progress) { + let group = await this.getGroup({user, groupId: 'party'}); - _.each(progress.collect, (v, k) => { - group.quest.progress.collect[k] += v; - }); + if (!_isOnQuest(user, progress, group)) return; + let quest = shared.content.quests[group.quest.key]; - let foundText = _.reduce(progress.collect, (m, v, k) => { - m.push(`${v} ${quest.collect[k].text('en')}`); - return m; - }, []); + _.each(progress.collect, (v, k) => { + group.quest.progress.collect[k] += v; + }); - foundText = foundText ? foundText.join(', ') : 'nothing'; - group.sendChat(`\`${user.profile.name} found ${foundText}.\``); - group.markModified('quest.progress.collect'); + let foundText = _.reduce(progress.collect, (m, v, k) => { + m.push(`${v} ${quest.collect[k].text('en')}`); + return m; + }, []); - // Still needs completing - if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { - return group.quest.progress.collect[k] < v.count; - })) return group.save(); + foundText = foundText ? foundText.join(', ') : 'nothing'; + group.sendChat(`\`${user.profile.name} found ${foundText}.\``); + group.markModified('quest.progress.collect'); - // TODO use promise - return group.finishQuest(quest) - .then(() => { - group.sendChat('`All items found! Party has received their rewards.`'); - return group.save(); - }); - }) - // TODO ok to catch even if we're returning a promise? - .catch(); + // Still needs completing + if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { + return group.quest.progress.collect[k] < v.count; + })) return group.save(); + + await group.finishQuest(quest); + group.sendChat('`All items found! Party has received their rewards.`'); + return group.save(); + // TODO cath? +}; + +schema.statics.bossQuest = async function bossQuest (user, progress) { + let group = await this.getGroup({user, groupId: 'party'}); + if (!_isOnQuest(user, progress, group)) return; + + let quest = shared.content.quests[group.quest.key]; + if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point, log? + + let down = progress.down * quest.boss.str; // multiply by boss strength + + group.quest.progress.hp -= progress.up; + // TODO Create a party preferred language option so emits like this can be localized + group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``); + + // If boss has Rage, increment Rage as well + if (quest.boss.rage) { + group.quest.progress.rage += Math.abs(down); + if (group.quest.progress.rage >= quest.boss.rage.value) { + group.sendChat(quest.boss.rage.effect('en')); + group.quest.progress.rage = 0; + + // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage + if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing; + if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; + } + } + + // Everyone takes damage + await User.update({ + _id: {$in: _.keys(group.quest.members)}, + }, { + $inc: {'stats.hp': down, _v: 1}, + }, {multi: true}).exec(); + + // Boss slain, finish quest + if (group.quest.progress.hp <= 0) { + group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); + + // Participants: Grant rewards & achievements, finish quest + await group.finishQuest(shared.content.quests[group.quest.key]); + return group.save(); + } + + return group.save(); + // TODO catch? }; // to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` @@ -408,123 +448,65 @@ process.nextTick(() => { }); }); -// TODO promise? -schema.statics.tavernBoss = function tavernBoss (user, progress) { +// returns a promise +schema.statics.tavernBoss = async function tavernBoss (user, progress) { if (!progress) return; // hack: prevent crazy damage to world boss let dmg = Math.min(900, Math.abs(progress.up || 0)); let rage = -Math.min(900, Math.abs(progress.down || 0)); - this.findOne(tavernQ).exec() - .then(tavern => { - if (!(tavern && tavern.quest && tavern.quest.key)) return; + let tavern = await this.findOne(tavernQ).exec(); + if (!(tavern && tavern.quest && tavern.quest.key)) return; - let quest = shared.content.quests[tavern.quest.key]; + let quest = shared.content.quests[tavern.quest.key]; - if (tavern.quest.progress.hp <= 0) { - tavern.sendChat(quest.completionChat('en')); - tavern.finishQuest(quest, () => {}); - _.assign(tavernQuest, {extra: null}); - return tavern.save(); - } else { - // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, - // use those first - which allows us to update the boss on the go if things are too easy/hard. - if (!tavern.quest.extra) tavern.quest.extra = {}; - tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); - tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); + if (tavern.quest.progress.hp <= 0) { + tavern.sendChat(quest.completionChat('en')); + await tavern.finishQuest(quest); + _.assign(tavernQuest, {extra: null}); + return tavern.save(); + } else { + // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, + // use those first - which allows us to update the boss on the go if things are too easy/hard. + if (!tavern.quest.extra) tavern.quest.extra = {}; + tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); + tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); - if (tavern.quest.progress.rage >= quest.boss.rage.value) { - if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; + if (tavern.quest.progress.rage >= quest.boss.rage.value) { + if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; - let wd = tavern.quest.extra.worldDmg; - // Burnout attacks Ian, Seasonal Sorceress, tavern - let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary + let wd = tavern.quest.extra.worldDmg; + // Burnout attacks Ian, Seasonal Sorceress, tavern + let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary - if (!scene) { - tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); - tavern.quest.progress.rage = 0; // quest.boss.rage.value; - } else { - tavern.sendChat(quest.boss.rage[scene]('en')); - tavern.quest.extra.worldDmg[scene] = true; - tavern.quest.extra.worldDmg.recent = scene; - tavern.markModified('quest.extra.worldDmg'); - tavern.quest.progress.rage = 0; - if (quest.boss.rage.healing) { - tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp; - } + if (!scene) { + tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); + tavern.quest.progress.rage = 0; // quest.boss.rage.value; + } else { + tavern.sendChat(quest.boss.rage[scene]('en')); + tavern.quest.extra.worldDmg[scene] = true; + tavern.quest.extra.worldDmg.recent = scene; + tavern.markModified('quest.extra.worldDmg'); + tavern.quest.progress.rage = 0; + if (quest.boss.rage.healing) { + tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp; } } - - if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { - tavern.sendChat(quest.boss.desperation.text('en')); - tavern.quest.extra.desperate = true; - tavern.quest.extra.def = quest.boss.desperation.def; - tavern.quest.extra.str = quest.boss.desperation.str; - tavern.markModified('quest.extra'); - } - - _.assign(module.exports.tavernQuest, tavern.quest.toObject()); - return tavern.save(); - } - }) - .catch(err => { - throw err; - }); -}; - -schema.statics.bossQuest = function bossQuest (user, progress) { - return this.findOne({ - type: 'party', - members: {$in: [user._id]}, - }).exec() - .then(group => { - if (!_isOnQuest(user, progress, group)) return; - - let quest = shared.content.quests[group.quest.key]; - if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point - - let down = progress.down * quest.boss.str; // multiply by boss strength - - group.quest.progress.hp -= progress.up; - group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``); // TODO Create a party preferred language option so emits like this can be localized - - // If boss has Rage, increment Rage as well - if (quest.boss.rage) { - group.quest.progress.rage += Math.abs(down); - if (group.quest.progress.rage >= quest.boss.rage.value) { - group.sendChat(quest.boss.rage.effect('en')); - group.quest.progress.rage = 0; - - // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage - if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing; - if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; - } } - // Everyone takes damage - let promise = User.update({ - _id: {$in: _.keys(group.quest.members)}, - }, { - $inc: {'stats.hp': down, _v: 1}, - }, {multi: true}); - - // Boss slain, finish quest - if (group.quest.progress.hp <= 0) { - group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); - // Participants: Grant rewards & achievements, finish quest - - return promise - .then(() => group.finishQuest()) - .then(() => group.save()); + if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { + tavern.sendChat(quest.boss.desperation.text('en')); + tavern.quest.extra.desperate = true; + tavern.quest.extra.def = quest.boss.desperation.def; + tavern.quest.extra.str = quest.boss.desperation.str; + tavern.markModified('quest.extra'); } - return promise.then(() => group.save()); - }) - // TODO necessary to catch if we're returning a promise? - .catch(err => { - throw err; - }); + _.assign(tavernQuest, tavern.quest.toObject()); + return tavern.save(); + } + // TODO catch }; schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { From 03c9c2933f27f33e40616a07aa4eac73da0ee713 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 5 Feb 2016 09:28:43 -0600 Subject: [PATCH 449/976] feat(api-v3): finish first iteration of group.startQuest method --- test/api/v3/unit/models/group.test.js | 238 ++++++++++++++++++++------ website/src/models/group.js | 66 ++++--- 2 files changed, 225 insertions(+), 79 deletions(-) diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index bdc49355f7..c5939b1424 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -1,42 +1,72 @@ import { model as Group } from '../../../../../website/src/models/group'; import { model as User } from '../../../../../website/src/models/user'; import { quests as questScrolls } from '../../../../../common/script/content'; +import * as email from '../../../../../website/src/libs/api-v3/email'; +import Q from 'q'; describe('Group Model', () => { context('Instance Methods', () => { - let party; - - beforeEach(() => { - party = new Group({ - type: 'party', - }); - }); - describe('#startQuest', () => { + let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; + + beforeEach(async () => { + sandbox.stub(email, 'sendTxn'); + sandbox.spy(Q, 'allSettled'); + + party = new Group({ + name: 'test party', + type: 'party', + privacy: 'private', + }); + + questLeader = new User({ + party: { _id: party._id }, + items: { + quests: { + whale: 1, + }, + }, + }); + + party.leader = questLeader._id; + + participatingMember = new User({ + party: { _id: party._id }, + }); + nonParticipatingMember = new User({ + party: { _id: party._id }, + }); + undecidedMember = new User({ + party: { _id: party._id }, + }); + + await Promise.all([ + party.save(), + questLeader.save(), + participatingMember.save(), + nonParticipatingMember.save(), + undecidedMember.save(), + ]); + }); + context('Failure Conditions', () => { - it('throws an error if group is not a party', () => { + it('throws an error if group is not a party', async () => { let guild = new Group({ type: 'guild', }); - expect(() => { - guild.startQuest(); - }).to.throw('Must be a party to use this method'); + await expect(guild.startQuest(participatingMember)).to.eventually.be.rejected; }); - it('throws an error if party is not on a quest', () => { - expect(() => { - party.startQuest(); - }).to.throw('Party does not have a pending quest'); + it('throws an error if party is not on a quest', async () => { + await expect(party.startQuest(participatingMember)).to.eventually.be.rejected; }); - it('throws an error if quest is already active', () => { + it('throws an error if quest is already active', async () => { party.quest.key = 'whale'; party.quest.active = true; - expect(() => { - party.startQuest(); - }).to.throw('Quest is already active'); + await expect(party.startQuest(participatingMember)).to.eventually.be.rejected; }); }); @@ -44,19 +74,16 @@ describe('Group Model', () => { beforeEach(() => { party.quest.key = 'whale'; party.quest.active = false; - party.quest.leader = 'quest-leader'; - party.quest.members = { - 'quest-leader': true, - 'participating-member': true, - 'non-participating-member': false, - 'undecided-member': null, - }; - - sandbox.stub(User, 'update').returns({ exec: sandbox.spy() }); + party.quest.leader = questLeader._id; + party.quest.members = { }; + party.quest.members[questLeader._id] = true; + party.quest.members[participatingMember._id] = true; + party.quest.members[nonParticipatingMember._id] = false; + party.quest.members[undecidedMember._id] = null; }); it('activates quest', () => { - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.active).to.eql(true); }); @@ -65,7 +92,7 @@ describe('Group Model', () => { let bossQuest = questScrolls.whale; party.quest.key = bossQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp); }); @@ -74,7 +101,7 @@ describe('Group Model', () => { let rageBossQuest = questScrolls.trex_undead; party.quest.key = rageBossQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.rage).to.eql(0); }); @@ -82,7 +109,7 @@ describe('Group Model', () => { it('sets up collection quest', () => { let collectionQuest = questScrolls.vice2; party.quest.key = collectionQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.collect).to.eql({ lightCrystal: 0, @@ -92,7 +119,7 @@ describe('Group Model', () => { it('sets up collection quest with multiple items', () => { let collectionQuest = questScrolls.evilsanta2; party.quest.key = collectionQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.collect).to.eql({ tracks: 0, @@ -100,34 +127,135 @@ describe('Group Model', () => { }); }); - it('updates quest object for participating members', () => { - party.startQuest(); + it('prunes non-participating members from quest members object', () => { + party.startQuest(participatingMember); - expect(User.update).to.be.calledTwice; - expect(User.update).to.not.be.calledWith({ _id: 'non-participating-member' }); - expect(User.update).to.not.be.calledWith({ _id: 'undecided-member' }); - expect(User.update).to.be.calledWith( - { _id: 'participating-member' }, - sinon.match({ $set: { 'party.quest.key': 'whale' }}), - ); - expect(User.update).to.be.calledWith( - { _id: 'quest-leader' }, - sinon.match({ $set: { 'party.quest.key': 'whale' }}), - ); + let expectedQuestMembers = {}; + expectedQuestMembers[questLeader._id] = true; + expectedQuestMembers[participatingMember._id] = true; + + expect(party.quest.members).to.eql(expectedQuestMembers); }); - it('removes quest scroll from quest leader', () => { - party.startQuest(); + it('applies updates to user object directly if user is participating', async () => { + await party.startQuest(participatingMember); - expect(User.update).to.be.calledWith( - { _id: 'quest-leader' }, - sinon.match({ $inc: { 'items.quests.whale': -1 }}), - ); + expect(participatingMember.party.quest.key).to.eql('whale'); + expect(participatingMember.party.quest.progress.down).to.eql(0); + expect(participatingMember.party.quest.collect).to.eql({}); + expect(participatingMember.party.quest.completed).to.eql(null); }); - it('sends email to participating members that quest has started'); + it('applies updates to other participating members', async () => { + await party.startQuest(nonParticipatingMember); - it('sends email only to members who have not opted out'); + questLeader = await User.findById(questLeader._id); + participatingMember = await User.findById(participatingMember._id); + + expect(participatingMember.party.quest.key).to.eql('whale'); + expect(participatingMember.party.quest.progress.down).to.eql(0); + expect(participatingMember.party.quest.progress.collect).to.eql({}); + expect(participatingMember.party.quest.completed).to.eql(null); + + expect(questLeader.party.quest.key).to.eql('whale'); + expect(questLeader.party.quest.progress.down).to.eql(0); + expect(questLeader.party.quest.progress.collect).to.eql({}); + expect(questLeader.party.quest.completed).to.eql(null); + }); + + it('does not apply updates to nonparticipating members', async () => { + await party.startQuest(participatingMember); + + nonParticipatingMember = await User.findById(nonParticipatingMember ._id); + undecidedMember = await User.findById(undecidedMember._id); + + expect(nonParticipatingMember.party.quest.key).to.not.eql('whale'); + expect(undecidedMember.party.quest.key).to.not.eql('whale'); + }); + + it('removes quest scroll from quest leader', async () => { + await party.startQuest(participatingMember); + + questLeader = await User.findById(questLeader._id); + + expect(questLeader.items.quests.whale).to.eql(0); + }); + + it('sends email to participating members that quest has started', async () => { + participatingMember.preferences.emailNotifications.questStarted = true; + questLeader.preferences.emailNotifications.questStarted = true; + await Promise.all([ + participatingMember.save(), + questLeader.save(), + ]); + + await party.startQuest(nonParticipatingMember); + + expect(email.sendTxn).to.be.calledOnce; + + let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); + let typeOfEmail = email.sendTxn.args[0][1]; + + expect(memberIds).to.have.a.lengthOf(2); + expect(memberIds).to.include(participatingMember._id); + expect(memberIds).to.include(questLeader._id); + expect(typeOfEmail).to.eql('quest-started'); + }); + + it('sends email only to members who have not opted out', async () => { + participatingMember.preferences.emailNotifications.questStarted = false; + questLeader.preferences.emailNotifications.questStarted = true; + await Promise.all([ + participatingMember.save(), + questLeader.save(), + ]); + + await party.startQuest(nonParticipatingMember); + + expect(email.sendTxn).to.be.calledOnce; + + let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); + + expect(memberIds).to.have.a.lengthOf(1); + expect(memberIds).to.not.include(participatingMember._id); + expect(memberIds).to.include(questLeader._id); + }); + + it('does not send email to initiating member', async () => { + participatingMember.preferences.emailNotifications.questStarted = true; + questLeader.preferences.emailNotifications.questStarted = true; + await Promise.all([ + participatingMember.save(), + questLeader.save(), + ]); + + await party.startQuest(participatingMember); + + expect(email.sendTxn).to.be.calledOnce; + + let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); + + expect(memberIds).to.have.a.lengthOf(1); + expect(memberIds).to.not.include(participatingMember._id); + expect(memberIds).to.include(questLeader._id); + }); + + it('adds participating members to background save operations', async () => { + await party.startQuest(nonParticipatingMember); + + expect(Q.allSettled).to.be.calledOnce; + + let savePromises = Q.allSettled.args[0][0]; + expect(savePromises).to.have.a.lengthOf(2); + }); + + it('does not include initiating user in background save operations', async () => { + await party.startQuest(participatingMember); + + expect(Q.allSettled).to.be.calledOnce; + let savePromises = Q.allSettled.args[0][0]; + expect(savePromises).to.have.a.lengthOf(1); + }); }); }); }); diff --git a/website/src/models/group.js b/website/src/models/group.js index b9cf7acd98..1f18a311a7 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -11,6 +11,7 @@ import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import { BadRequest } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; +import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; import { quests as questScrolls } from '../../../common/script/content'; import Q from 'q'; import nconf from 'nconf'; @@ -169,11 +170,12 @@ schema.methods.isMember = function isGroupMember (user) { } }; -schema.methods.startQuest = function startQuest () { +schema.methods.startQuest = async function startQuest (user) { if (this.type !== 'party') throw new BadRequest('Must be a party to use this method'); if (!this.quest.key) throw new BadRequest('Party does not have a pending quest'); if (this.quest.active) throw new BadRequest('Quest is already active'); + let userIsParticipating = this.quest.members[user._id]; let quest = questScrolls[this.quest.key]; let collected = {}; if (quest.collect) { @@ -193,38 +195,54 @@ schema.methods.startQuest = function startQuest () { this.quest.progress.collect = collected; } - _.each(this.quest.members, (participating, memberId) => { - if (!participating) return; + // Changes quest.members to only include participating members + // TODO: is that important? What does it matter if the non-participating members + // are still on the object? + // TODO: is it important to run clean quest progress on non-members like we did in v2? + this.quest.members = _.pick(this.quest.members, _.identity); + let nonUserQuestMembers = _.without(_.keys(this.quest.members), user._id); - let update = { - $set: { - // Do *not* reset party.quest.progress.up - // See https://github.com/HabitRPG/habitrpg/issues/2168#issuecomment-31556322 - 'party.quest.key': this.quest.key, - 'party.quest.progress.down': 0, - 'party.quest.collect': collected, - 'party.quest.completed': null, - }, - $inc: { _v: 1 }, - }; + let members = await User.find( + { _id: { $in: nonUserQuestMembers } }, + 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications', + ).exec(); - if (this.quest.leader === memberId) { - update.$inc[`items.quests.${this.quest.key}`] = -1; + if (userIsParticipating) { + members.unshift(user); // put participating user at the beginning of the array + } + + _.each(members, (member) => { + member.party.quest.key = this.quest.key; + member.party.quest.progress.down = 0; + member.party.quest.collect = collected; + member.party.quest.completed = null; + member.markModified('party.quest'); + + if (this.quest.leader === member._id) { + member.items.quests[this.quest.key] -= 1; + member.markModified('items.quests'); } - backgroundOperations.push(User.update({ _id: memberId }, update).exec()); + if (member._id !== user._id) { + backgroundOperations.push(member.save()); + } }); - // TODO Add emails to users that quest has started to background ops + let usersToEmail = _.filter(members, (member) => { + return member.preferences.emailNotifications.questStarted !== false && + member._id !== user._id; + }); + + sendTxnEmail(usersToEmail, 'quest-started', [ + { name: 'PARTY_URL', content: '/#/options/groups/party' }, + ]); // These operations should run in the background // and not hold up the quest routes from resolving - // TODO: What here? - // Q.all(backgroundOperations).then(() => { - // }).catch(err => { - // TODO: How to handle errors? - // IE, user deleted their account? - // }); + Q.allSettled(backgroundOperations).catch(err => { + // TODO: what to do with err? + throw err; + }); }; export function chatDefaults (msg, user) { From 3b9c921c2f531a502e3334fcde4b58480dd6554e Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 5 Feb 2016 10:25:49 -0600 Subject: [PATCH 450/976] feat(api-v3): First iteration of quest invite --- .../POST-groups_groupId_quests_invite.test.js | 59 ++++++++++++------- website/src/controllers/api-v3/quests.js | 52 ++++++++++++---- 2 files changed, 78 insertions(+), 33 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 4311cf7911..973a51a1a5 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -1,6 +1,7 @@ import { createAndPopulateGroup, translate as t, + sleep, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; import { quests as questScrolls } from '../../../../../common/script/content'; @@ -122,8 +123,8 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { ]); }); - xit('adds quest details to group object', async () => { - await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('adds quest details to group object', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); await questingGroup.sync(); @@ -131,47 +132,61 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { expect(quest.key).to.eql(PET_QUEST); expect(quest.active).to.eql(false); - expect(quest.leader).to.eql(false); - expect(quest.members).to.have.property(leader._id, null); + expect(quest.leader).to.eql(leader._id); + expect(quest.members).to.have.property(leader._id, true); expect(quest.members).to.have.property(member._id, null); expect(quest).to.have.property('progress'); }); - xit('adds quest details to user objects', async () => { - await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('adds quest details to user objects', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await sleep(0.1); // member updates happen in the background await Promise.all([ leader.sync(), member.sync(), ]); - [leader, member].forEach((user) => { - let quest = user.party.quest; - - expect(quest.key).to.eql(PET_QUEST); - expect(quest.active).to.eql(false); - expect(quest.leader).to.eql(false); - expect(quest.members).to.have.property(leader._id, null); - expect(quest.members).to.have.property(member._id, null); - expect(quest).to.have.property('progress'); - }); + expect(leader.party.quest.key).to.eql(PET_QUEST); + expect(member.party.quest.key).to.eql(PET_QUEST); + expect(leader.party.quest.RSVPNeeded).to.eql(false); + expect(member.party.quest.RSVPNeeded).to.eql(true); }); - xit('sends back the quest object', async () => { - let inviteResponse = await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('sends back the quest object', async () => { + let inviteResponse = await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); expect(inviteResponse.key).to.eql(PET_QUEST); expect(inviteResponse.active).to.eql(false); - expect(inviteResponse.leader).to.eql(false); - expect(inviteResponse.members).to.have.property(leader._id, null); + expect(inviteResponse.leader).to.eql(leader._id); + expect(inviteResponse.members).to.have.property(leader._id, true); expect(inviteResponse.members).to.have.property(member._id, null); expect(inviteResponse).to.have.property('progress'); }); - xit('allows non-leader party members to send invites', async () => { - let inviteResponse = await member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('allows non-party-leader party members to send invites', async () => { + let inviteResponse = await member.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await questingGroup.sync(); expect(inviteResponse.key).to.eql(PET_QUEST); + expect(questingGroup.quest.key).to.eql(PET_QUEST); + }); + + it('starts quest automatically if user is in a solo party', async () => { + let leaderDetails = { balance: 10 }; + leaderDetails[`items.quests.${PET_QUEST}`] = 1; + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + leaderDetails, + }); + + await groupLeader.post(`/groups/${group._id}/quests/invite/${PET_QUEST}`); + + await group.sync(); + + expect(group.quest.active).to.eql(true); }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..77cec58d6a 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -1,14 +1,25 @@ +import _ from 'lodash'; +import Q from 'q'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Group, } from '../../models/group'; +import { + model as User, +} from '../../models/user'; import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +function canStartQuestAutomatically (group) { + // If all members are either true (accepted) or false (rejected) return true + // If any member is null/undefined (undecided) return false + return _.every(group.quest.members, Boolean); +} + let api = {}; /** @@ -44,24 +55,43 @@ api.inviteToQuest = { if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + let members = await User.find({ 'party._id': group._id }, 'auth.facebook auth.local preferences.emailNotifications').exec(); + let backgroundOperations = []; + group.markModified('quest'); group.quest.key = questKey; group.quest.leader = user._id; group.quest.members = {}; + group.quest.members[user._id] = true; - // let memberUpdate = { - // '$set': { - // 'party.quest.key': questKey, - // 'party.quest.progress.down': 0, - // 'party.quest.completed': null, - // }, - // }; + user.party.quest.RSVPNeeded = false; + user.party.quest.key = questKey; - // TODO collect members of party - // TODO Logic for quest invite and send back quest object + _.each(members, (member) => { + if (member._id !== user._id) { + group.quest.members[member._id] = null; + member.party.quest.RSVPNeeded = true; + member.party.quest.key = questKey; + // TODO: Send Quest invite email + backgroundOperations.push(member.save()); + } + }); - await group.save(); - res.respond(200, {}); + if (canStartQuestAutomatically(group)) { + group.startQuest(user); + } + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + Q.allSettled(backgroundOperations).catch(err => { + // TODO what to do about errors in background ops + throw err; + }); }, }; From 35e6274cd62a3e2ef04d0d514726699b86324869 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 8 Feb 2016 22:18:57 +0100 Subject: [PATCH 451/976] Move cron and preening code to server, add support for quests in cron --- common/script/api-v3/cron.js | 264 -------------- common/script/cron.js | 1 + website/src/controllers/api-v3/tasks.js | 2 +- .../src/libs}/api-v3/preening.js | 0 website/src/middlewares/api-v3/cron.js | 323 ++++++++++++++++-- website/src/models/group.js | 14 +- 6 files changed, 297 insertions(+), 307 deletions(-) delete mode 100644 common/script/api-v3/cron.js rename {common/script => website/src/libs}/api-v3/preening.js (100%) diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js deleted file mode 100644 index 6f8626c7d8..0000000000 --- a/common/script/api-v3/cron.js +++ /dev/null @@ -1,264 +0,0 @@ -import moment from 'moment'; -import _ from 'lodash'; -import scoreTask from './scoreTask'; -import { preenUserHistory } from './preening'; -import common from '../../'; -import { - shouldDo, -} from '../cron'; - -let clearBuffs = { - str: 0, - int: 0, - per: 0, - con: 0, - stealth: 0, - streaks: false, -}; - -// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) -// For incomplete Dailys, deduct experience -// Make sure to run this function once in a while as server will not take care of overnight calculations. -// And you have to run it every time client connects. -export default function cron (options = {}) { - let {user, tasksByType, analytics, now, daysMissed} = options; - - user.auth.timestamps.loggedin = now; - user.lastCron = now; - // Reset the lastDrop count to zero - if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; - - // "Perfect Day" achievement for perfect-days - let perfect = true; - - // end-of-month perks for subscribers - let plan = user.purchased.plan; - if (user.isSubscribed()) { - if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { - plan.gemsBought = 0; // reset gem-cap - plan.dateUpdated = now; - // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks - // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 - // TODO use month diff instead of ++ / --? - _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 - plan.consecutive.count++; - if (plan.consecutive.offset > 0) { - plan.consecutive.offset--; - } else if (plan.consecutive.count % 3 === 0) { // every 3 months - plan.consecutive.trinkets++; - plan.consecutive.gemCapExtra += 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) - } - } - - // If user cancelled subscription, we give them until 30day's end until it terminates - if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { - _.merge(plan, { - planId: null, - customerId: null, - paymentMethod: null, - }); - - _.merge(plan.consecutive, { - count: 0, - offset: 0, - gemCapExtra: 0, - }); - - user.markModified('purchased.plan'); - } - } - - // User is resting at the inn. - // On cron, buffs are cleared and all dailies are reset without performing damage - if (user.preferences.sleep === true) { - user.stats.buffs = _.cloneDeep(clearBuffs); - - tasksByType.dailys.forEach((daily) => { - let completed = daily.completed; - let thatDay = moment(now).subtract({days: 1}); - - if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { - daily.checklist.forEach(box => box.completed = false); - } - daily.completed = false; - }); - - return; - } - - let multiDaysCountAsOneDay = true; - // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. - // When site-wide difficulty settings are introduced, this can be a user preference option. - - // Tally each task - let todoTally = 0; - - tasksByType.todos.forEach(task => { // make uncompleted todos redder - scoreTask({ - task, - user, - direction: 'down', - cron: true, - times: multiDaysCountAsOneDay ? 1 : daysMissed, - // TODO pass req for analytics? - }); - - todoTally += task.value; - }); - - let dailyChecked = 0; // how many dailies were checked? - let dailyDueUnchecked = 0; // how many dailies were cun-hecked? - if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; - - tasksByType.dailys.forEach((task) => { - let completed = task.completed; - // Deduct points for missed Daily tasks - let EvadeTask = 0; - let scheduleMisses = daysMissed; - - if (completed) { - dailyChecked += 1; - } else { - // dailys repeat, so need to calculate how many they've missed according to their own schedule - scheduleMisses = 0; - - for (let i = 0; i < daysMissed; i++) { - let thatDay = moment(now).subtract({days: i + 1}); - - if (shouldDo(thatDay.toDate(), task, user.preferences)) { - scheduleMisses++; - if (user.stats.buffs.stealth) { - user.stats.buffs.stealth--; - EvadeTask++; - } - if (multiDaysCountAsOneDay) break; - } - } - - if (scheduleMisses > EvadeTask) { - perfect = false; - - if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points - let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; - dailyDueUnchecked += 1 - fractionChecked; - dailyChecked += fractionChecked; - } else { - dailyDueUnchecked += 1; - } - - let delta = scoreTask({ - user, - task, - direction: 'down', - times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, - cron: true, - }); - - // Apply damage from a boss, less damage for Trivial priority (difficulty) - user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); - // NB: Medium and Hard priorities do not increase damage from boss. This was by accident - // initially, and when we realised, we could not fix it because users are used to - // their Medium and Hard Dailies doing an Easy amount of damage from boss. - // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future - // setting between Trivial and Easy. - } - } - - task.history.push({ - date: Number(new Date()), - value: task.value, - }); - task.completed = false; - - if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed - } - }); - - tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 - if (task.up === false || task.down === false) { - task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; - } - }); - - // Finished tallying - user.history.todos.push({date: now, value: todoTally}); - - // tally experience - let expTally = user.stats.exp; - let lvl = 0; // iterator - while (lvl < user.stats.lvl - 1) { - lvl++; - expTally += common.tnl(lvl); - } - user.history.exp.push({date: now, value: expTally}); - - // preen user history so that it doesn't become a performance problem - // also for subscribed users but differentyly - // premium subscribers can keep their full history. - preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); - - if (perfect) { - user.achievements.perfect++; - let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); - user.stats.buffs = { - str: lvlDiv2, - int: lvlDiv2, - per: lvlDiv2, - con: lvlDiv2, - stealth: 0, - streaks: false, - }; - } else { - user.stats.buffs = _.cloneDeep(clearBuffs); - } - - // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit - // Adjust for fraction of dailies completed - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; - - if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) { - user.stats.mp = user._statsComputed.maxMP; - } - - // After all is said and done, progress up user's effect on quest, return those values & reset the user's - let progress = user.party.quest.progress; - let _progress = _.cloneDeep(progress); - _.merge(progress, {down: 0, up: 0}); - progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); - - // Clean PMs - keep 200 for subscribers and 50 for free users - // TODO tests - let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too - let numberOfPMs = Object.keys(user.inbox.messages).length; - if (Object.keys(user.inbox.messages).length > maxPMs) { - _(user.inbox.messages) - .sortBy('timestamp') - .takeRight(numberOfPMs - maxPMs) - .each(pm => { - user.inbox.messages[pm.id] = undefined; - }).value(); - - user.markModified('inbox.messages'); - } - - // Analytics - user.flags.cronCount++; - analytics.track('Cron', { - category: 'behavior', - gaLabel: 'Cron Count', - gaValue: user.flags.cronCount, - uuid: user._id, - user, // TODO is it really necessary passing the whole user object? - resting: user.preferences.sleep, - cronCount: user.flags.cronCount, - progressUp: _.min([_progress.up, 900]), - progressDown: _progress.down, - }); - - return _progress; -} diff --git a/common/script/cron.js b/common/script/cron.js index 639bbf6b65..7eca56cb54 100644 --- a/common/script/cron.js +++ b/common/script/cron.js @@ -1,3 +1,4 @@ +// TODO what can be moved to /website/src? /* ------------------------------------------------------ Cron and time / day functions diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 0197d38dbb..e0c89b66ad 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -14,7 +14,7 @@ import Q from 'q'; import _ from 'lodash'; import moment from 'moment'; import scoreTask from '../../../../common/script/api-v3/scoreTask'; -import { preenHistory } from '../../../../common/script/api-v3/preening'; +import { preenHistory } from '../../libs/api-v3/preening'; let api = {}; diff --git a/common/script/api-v3/preening.js b/website/src/libs/api-v3/preening.js similarity index 100% rename from common/script/api-v3/preening.js rename to website/src/libs/api-v3/preening.js diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 288452e670..d57bfb28f8 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -2,15 +2,274 @@ import _ from 'lodash'; import moment from 'moment'; import { daysSince, + shouldDo, } from '../../../../common/script/cron'; -import cron from '../../../../common/script/api-v3/cron'; import common from '../../../../common'; import Task from '../../models/task'; import Q from 'q'; -// import Group from '../../models/group'; +import Group from '../../models/group'; +import User from '../../models/user'; +import scoreTask from '../../../../common/script/api-v3/scoreTask'; +import { preenUserHistory } from '../../libs/api-v3/preening'; + +let clearBuffs = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, +}; + +// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) +// For incomplete Dailys, deduct experience +// Make sure to run this function once in a while as server will not take care of overnight calculations. +// And you have to run it every time client connects. +export function cron (options = {}) { + let {user, tasksByType, analytics, now, daysMissed} = options; + + user.auth.timestamps.loggedin = now; + user.lastCron = now; + // Reset the lastDrop count to zero + if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; + + // "Perfect Day" achievement for perfect-days + let perfect = true; + + // end-of-month perks for subscribers + let plan = user.purchased.plan; + if (user.isSubscribed()) { + if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { + plan.gemsBought = 0; // reset gem-cap + plan.dateUpdated = now; + // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks + // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 + // TODO use month diff instead of ++ / --? + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + plan.consecutive.count++; + if (plan.consecutive.offset > 0) { + plan.consecutive.offset--; + } else if (plan.consecutive.count % 3 === 0) { // every 3 months + plan.consecutive.trinkets++; + plan.consecutive.gemCapExtra += 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) + } + } + + // If user cancelled subscription, we give them until 30day's end until it terminates + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null, + }); + + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0, + }); + + user.markModified('purchased.plan'); + } + } + + // User is resting at the inn. + // On cron, buffs are cleared and all dailies are reset without performing damage + if (user.preferences.sleep === true) { + user.stats.buffs = _.cloneDeep(clearBuffs); + + tasksByType.dailys.forEach((daily) => { + let completed = daily.completed; + let thatDay = moment(now).subtract({days: 1}); + + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + daily.checklist.forEach(box => box.completed = false); + } + daily.completed = false; + }); + + return; + } + + let multiDaysCountAsOneDay = true; + // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. + // When site-wide difficulty settings are introduced, this can be a user preference option. + + // Tally each task + let todoTally = 0; + + tasksByType.todos.forEach(task => { // make uncompleted todos redder + scoreTask({ + task, + user, + direction: 'down', + cron: true, + times: multiDaysCountAsOneDay ? 1 : daysMissed, + // TODO pass req for analytics? + }); + + todoTally += task.value; + }); + + let dailyChecked = 0; // how many dailies were checked? + let dailyDueUnchecked = 0; // how many dailies were cun-hecked? + if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; + + tasksByType.dailys.forEach((task) => { + let completed = task.completed; + // Deduct points for missed Daily tasks + let EvadeTask = 0; + let scheduleMisses = daysMissed; + + if (completed) { + dailyChecked += 1; + } else { + // dailys repeat, so need to calculate how many they've missed according to their own schedule + scheduleMisses = 0; + + for (let i = 0; i < daysMissed; i++) { + let thatDay = moment(now).subtract({days: i + 1}); + + if (shouldDo(thatDay.toDate(), task, user.preferences)) { + scheduleMisses++; + if (user.stats.buffs.stealth) { + user.stats.buffs.stealth--; + EvadeTask++; + } + if (multiDaysCountAsOneDay) break; + } + } + + if (scheduleMisses > EvadeTask) { + perfect = false; + + if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points + let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + dailyDueUnchecked += 1 - fractionChecked; + dailyChecked += fractionChecked; + } else { + dailyDueUnchecked += 1; + } + + let delta = scoreTask({ + user, + task, + direction: 'down', + times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, + cron: true, + }); + + // Apply damage from a boss, less damage for Trivial priority (difficulty) + user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); + // NB: Medium and Hard priorities do not increase damage from boss. This was by accident + // initially, and when we realised, we could not fix it because users are used to + // their Medium and Hard Dailies doing an Easy amount of damage from boss. + // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future + // setting between Trivial and Easy. + } + } + + task.history.push({ + date: Number(new Date()), + value: task.value, + }); + task.completed = false; + + if (completed || scheduleMisses > 0) { + task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + } + }); + + tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 + if (task.up === false || task.down === false) { + task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; + } + }); + + // Finished tallying + user.history.todos.push({date: now, value: todoTally}); + + // tally experience + let expTally = user.stats.exp; + let lvl = 0; // iterator + while (lvl < user.stats.lvl - 1) { + lvl++; + expTally += common.tnl(lvl); + } + user.history.exp.push({date: now, value: expTally}); + + // preen user history so that it doesn't become a performance problem + // also for subscribed users but differentyly + // premium subscribers can keep their full history. + preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); + + if (perfect) { + user.achievements.perfect++; + let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); + user.stats.buffs = { + str: lvlDiv2, + int: lvlDiv2, + per: lvlDiv2, + con: lvlDiv2, + stealth: 0, + streaks: false, + }; + } else { + user.stats.buffs = _.cloneDeep(clearBuffs); + } + + // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit + // Adjust for fraction of dailies completed + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + + if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + + // After all is said and done, progress up user's effect on quest, return those values & reset the user's + let progress = user.party.quest.progress; + let _progress = _.cloneDeep(progress); + _.merge(progress, {down: 0, up: 0}); + progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + + // Clean PMs - keep 200 for subscribers and 50 for free users + // TODO tests + let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too + let numberOfPMs = Object.keys(user.inbox.messages).length; + if (Object.keys(user.inbox.messages).length > maxPMs) { + _(user.inbox.messages) + .sortBy('timestamp') + .takeRight(numberOfPMs - maxPMs) + .each(pm => { + user.inbox.messages[pm.id] = undefined; + }).value(); + + user.markModified('inbox.messages'); + } + + // Analytics + user.flags.cronCount++; + analytics.track('Cron', { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user, // TODO is it really necessary passing the whole user object? + resting: user.preferences.sleep, + cronCount: user.flags.cronCount, + progressUp: _.min([_progress.up, 900]), + progressDown: _progress.down, + }); + + return _progress; +} // TODO check that it's used everywhere -export default function cronMiddleware (req, res, next) { +export default async function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; @@ -32,7 +291,7 @@ export default function cronMiddleware (req, res, next) { tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); // Run cron - cron({user, tasksByType, now, daysMissed, analytics}); + let progress = cron({user, tasksByType, now, daysMissed, analytics}); // Clear old completed todos - 30 days for free users, 90 for subscribers // Do not delete challenges completed todos TODO unless the task is broken? @@ -44,44 +303,36 @@ export default function cronMiddleware (req, res, next) { $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), }, 'challenge.id': {$exists: false}, - }).exec(); // TODO catch error or at least log it + }).exec(); // TODO catch error or at least log it, wait before returning? let ranCron = user.isModified(); let quest = common.content.quests[user.party.quest.key]; // if (ranCron) res.locals.wasModified = true; // TODO remove? if (!ranCron) return next(); - // TODO Group.tavernBoss(user, progress); - if (!quest || true /* TODO remove */) { - // Save user and tasks - let toSave = [user.save()]; - tasks.forEach(task => { - if (task.isModified) toSave.push(task.save()); + + // Group.tavernBoss(user, progress); + + // Save user and tasks + let toSave = [user.save()]; + tasks.forEach(task => { + if (task.isModified) toSave.push(task.save()); + }); + Q.all(toSave) + .then(saved => { + user = res.locals.user = saved[0]; + if (!quest) return; + + // If user is on a quest, roll for boss & player, or handle collections + let questType = quest.boss ? 'boss' : 'collect'; + // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + return Group[`${questType}Quest`](user, progress) + .then(() => User.findById(user._id).exec()) // fetch the updated user... + .then(updatedUser => { + res.locals.user = updatedUser; }); - - return Q.all(toSave).then(() => next()).catch(next); - } - - // If user is on a quest, roll for boss & player, or handle collections - // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? - // TODO do - /* async.waterfall([ - function(cb){ - user.save(cb); // make sure to save the cron effects - }, - function(saved, count, cb){ - var type = quest.boss ? 'boss' : 'collect'; - Group[type+'Quest'](user,progress,cb); - }, - function(){ - var cb = arguments[arguments.length-1]; - // User has been updated in boss-grapple, reload - User.findById(user._id, cb); - } - ], function(err, saved) { - res.locals.user = saved; - next(err,saved); - user = progress = quest = null; - });*/ + }) + .then(() => next()) + .catch(next); }); } diff --git a/website/src/models/group.js b/website/src/models/group.js index 1f18a311a7..4d991f2e6a 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; import { removeFromArray } from '../libs/api-v3/collectionManipulators'; -import { BadRequest } from '../libs/api-v3/errors'; +import { InternalServerError } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; @@ -171,9 +171,9 @@ schema.methods.isMember = function isGroupMember (user) { }; schema.methods.startQuest = async function startQuest (user) { - if (this.type !== 'party') throw new BadRequest('Must be a party to use this method'); - if (!this.quest.key) throw new BadRequest('Party does not have a pending quest'); - if (this.quest.active) throw new BadRequest('Quest is already active'); + if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); + if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest'); + if (this.quest.active) throw new InternalServerError('Quest is already active'); let userIsParticipating = this.quest.members[user._id]; let quest = questScrolls[this.quest.key]; @@ -397,7 +397,6 @@ schema.statics.collectQuest = async function collectQuest (user, progress) { await group.finishQuest(quest); group.sendChat('`All items found! Party has received their rewards.`'); return group.save(); - // TODO cath? }; schema.statics.bossQuest = async function bossQuest (user, progress) { @@ -432,6 +431,10 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { }, { $inc: {'stats.hp': down, _v: 1}, }, {multi: true}).exec(); + // Apply changes the currently cronning user locally so we don't have to reload it to get the updated state + // TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167 + // must be notModified or otherwise could overwrite future changes + // if (down) user.stats.hp += down; // Boss slain, finish quest if (group.quest.progress.hp <= 0) { @@ -443,7 +446,6 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { } return group.save(); - // TODO catch? }; // to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` From 2f3ae32a0e0e9f2fb12d31d17eb91eaea1437d80 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 8 Feb 2016 23:30:49 +0100 Subject: [PATCH 452/976] finalize invite to quest route and startQuest method --- .../POST-groups_groupId_quests_accept.test.js | 0 .../POST-groups_groupId_quests_invite.test.js | 0 test/api/v3/unit/models/group.test.js | 2 +- website/src/controllers/api-v3/quests.js | 55 +++++++++--- website/src/libs/api-v3/analyticsService.js | 1 + .../src/libs/api-v3/collectionManipulators.js | 7 +- website/src/models/group.js | 87 +++++++++++-------- 7 files changed, 98 insertions(+), 54 deletions(-) rename test/api/v3/integration/{groups => quests}/POST-groups_groupId_quests_accept.test.js (100%) rename test/api/v3/integration/{groups => quests}/POST-groups_groupId_quests_invite.test.js (100%) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js similarity index 100% rename from test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js rename to test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js similarity index 100% rename from test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js rename to test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index c5939b1424..b86cc7a1d9 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -4,7 +4,7 @@ import { quests as questScrolls } from '../../../../../common/script/content'; import * as email from '../../../../../website/src/libs/api-v3/email'; import Q from 'q'; -describe('Group Model', () => { +describe.skip('Group Model', () => { context('Instance Methods', () => { describe('#startQuest', () => { let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 77cec58d6a..05f4a69223 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -2,6 +2,7 @@ import _ from 'lodash'; import Q from 'q'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; +import analytics from '../../libs/api-v3/analyticsService'; import { model as Group, } from '../../models/group'; @@ -12,6 +13,10 @@ import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; +import { + getUserInfo, + sendTxn as sendTxnEmail, +} from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; function canStartQuestAutomatically (group) { @@ -55,8 +60,11 @@ api.inviteToQuest = { if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - let members = await User.find({ 'party._id': group._id }, 'auth.facebook auth.local preferences.emailNotifications').exec(); - let backgroundOperations = []; + let members = await User.find({ + 'party._id': group._id, + _id: {$ne: user._id}, + }).select('auth.facebook auth.local preferences.emailNotifications profile.name') + .exec(); group.markModified('quest'); group.quest.key = questKey; @@ -67,18 +75,22 @@ api.inviteToQuest = { user.party.quest.RSVPNeeded = false; user.party.quest.key = questKey; + await User.update({ + 'party._id': group._id, + _id: {$ne: user._id}, + }, { + $set: { + 'party.quest.RSVPNeeded': true, + 'party.quest.key': questKey, + }, + }, {multi: true}).exec(); + _.each(members, (member) => { - if (member._id !== user._id) { - group.quest.members[member._id] = null; - member.party.quest.RSVPNeeded = true; - member.party.quest.key = questKey; - // TODO: Send Quest invite email - backgroundOperations.push(member.save()); - } + group.quest.members[member._id] = null; }); if (canStartQuestAutomatically(group)) { - group.startQuest(user); + await group.startQuest(user); } let [savedGroup] = await Q.all([ @@ -88,9 +100,26 @@ api.inviteToQuest = { res.respond(200, savedGroup.quest); - Q.allSettled(backgroundOperations).catch(err => { - // TODO what to do about errors in background ops - throw err; + // send out invites + let inviterVars = getUserInfo(user, ['name', 'email']); + let membersToEmail = members.filter(member => { + return member.preferences.emailNotifications.invitedQuest !== false; + }); + sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [ + {name: 'QUEST_NAME', content: quest.text()}, + {name: 'INVITER', content: inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email}, + {name: 'PARTY_URL', content: '/#/options/groups/party'}, + ]); + + // track that the inviting user has accepted the quest + analytics.track('quest', { + category: 'behavior', + owner: true, + response: 'accept', + gaLabel: 'accept', + questName: questKey, + uuid: user._id, }); }, }; diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js index ae9ad6f60f..2c435da9dd 100644 --- a/website/src/libs/api-v3/analyticsService.js +++ b/website/src/libs/api-v3/analyticsService.js @@ -210,6 +210,7 @@ let _sendPurchaseDataToGoogle = (data) => { }); }; +// TODO log errors... function track (eventType, data) { return Q.all([ _sendDataToAmplitude(eventType, data), diff --git a/website/src/libs/api-v3/collectionManipulators.js b/website/src/libs/api-v3/collectionManipulators.js index ce40085552..95d3981601 100644 --- a/website/src/libs/api-v3/collectionManipulators.js +++ b/website/src/libs/api-v3/collectionManipulators.js @@ -1,9 +1,12 @@ -import { findIndex } from 'lodash'; +import { + findIndex, + isPlainObject, +} from 'lodash'; export function removeFromArray (array, element) { let elementIndex; - if (typeof element === 'object') { + if (isPlainObject(element)) { elementIndex = findIndex(array, element); } else { elementIndex = array.indexOf(element); diff --git a/website/src/models/group.js b/website/src/models/group.js index 4d991f2e6a..f8764d546e 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -171,6 +171,7 @@ schema.methods.isMember = function isGroupMember (user) { }; schema.methods.startQuest = async function startQuest (user) { + // not using i18n strings because these errors are meant for devs who forgot to pass some parameters if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest'); if (this.quest.active) throw new InternalServerError('Quest is already active'); @@ -184,8 +185,6 @@ schema.methods.startQuest = async function startQuest (user) { }); } - let backgroundOperations = []; - this.markModified('quest'); this.quest.active = true; if (quest.boss) { @@ -200,48 +199,60 @@ schema.methods.startQuest = async function startQuest (user) { // are still on the object? // TODO: is it important to run clean quest progress on non-members like we did in v2? this.quest.members = _.pick(this.quest.members, _.identity); - let nonUserQuestMembers = _.without(_.keys(this.quest.members), user._id); - - let members = await User.find( - { _id: { $in: nonUserQuestMembers } }, - 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications', - ).exec(); + let nonUserQuestMembers = _.keys(this.quest.members); + removeFromArray(nonUserQuestMembers, user._id); if (userIsParticipating) { - members.unshift(user); // put participating user at the beginning of the array + user.party.quest.key = this.quest.key; + user.party.quest.progress.down = 0; + user.party.quest.collect = collected; + user.party.quest.completed = null; + user.markModified('party.quest'); } - _.each(members, (member) => { - member.party.quest.key = this.quest.key; - member.party.quest.progress.down = 0; - member.party.quest.collect = collected; - member.party.quest.completed = null; - member.markModified('party.quest'); + // Remove the quest from the quest leader items (if he's the current user) + if (this.quest.leader === user._id) { + user.items.quests[this.quest.key] -= 1; + user.markModified('items.quests'); + } else { // another user is starting the quest, update the leader separately + await User.update({_id: this.quest.leader}, { + $set: { + 'party.quest.key': this.quest.key, + 'party.quest.progress.down': 0, + 'party.quest.collect': collected, + 'party.quest.completed': null, + }, + $inc: { + [`items.quests${this.quest.key}`]: -1, + }, + }).exec(); + removeFromArray(nonUserQuestMembers, this.quest.leader); + } - if (this.quest.leader === member._id) { - member.items.quests[this.quest.key] -= 1; - member.markModified('items.quests'); - } + // update the remaining users + await User.update({ + _id: { $in: nonUserQuestMembers }, + }, { + $set: { + 'party.quest.key': this.quest.key, + 'party.quest.progress.down': 0, + 'party.quest.collect': collected, + 'party.quest.completed': null, + }, + }, { multi: true }).exec(); - if (member._id !== user._id) { - backgroundOperations.push(member.save()); - } - }); - - let usersToEmail = _.filter(members, (member) => { - return member.preferences.emailNotifications.questStarted !== false && - member._id !== user._id; - }); - - sendTxnEmail(usersToEmail, 'quest-started', [ - { name: 'PARTY_URL', content: '/#/options/groups/party' }, - ]); - - // These operations should run in the background - // and not hold up the quest routes from resolving - Q.allSettled(backgroundOperations).catch(err => { - // TODO: what to do with err? - throw err; + // send notifications in the background without blocking + User.find( + { _id: { $in: nonUserQuestMembers } }, + 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications profile.name', + ).exec().then(membersToEmail => { + membersToEmail = _.filter(membersToEmail, (member) => { + return member.preferences.emailNotifications.questStarted !== false && + member._id !== user._id; + }); + sendTxnEmail(membersToEmail, 'quest-started', [ + { name: 'PARTY_URL', content: '/#/options/groups/party' }, + ]); }); }; From 0b336d8012c04bd268c209487613608c7e972f80 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 8 Feb 2016 23:40:03 +0100 Subject: [PATCH 453/976] add missing dot in field path --- website/src/models/group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index f8764d546e..23524646d1 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -223,7 +223,7 @@ schema.methods.startQuest = async function startQuest (user) { 'party.quest.completed': null, }, $inc: { - [`items.quests${this.quest.key}`]: -1, + [`items.quests.${this.quest.key}`]: -1, }, }).exec(); removeFromArray(nonUserQuestMembers, this.quest.leader); From b6ed2f8c443c22f4ef74971f428c850a0694d05c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 10 Feb 2016 08:21:36 -0600 Subject: [PATCH 454/976] refactor: Move sleep function to seprate file --- test/helpers/api-integration/v3/index.js | 9 +-------- test/helpers/api-unit.helper.js | 2 ++ test/helpers/sleep.js | 7 +++++++ 3 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 test/helpers/sleep.js diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 4ee2e6cfbe..6ae15d7ca6 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -8,11 +8,4 @@ export { requester }; export { translate } from '../translate'; export { checkExistence, resetHabiticaDB } from '../../mongo'; export * from './object-generators'; - -export async function sleep (seconds) { - let milliseconds = seconds * 1000; - - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} +export { sleep } from '../../sleep'; diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 3370bd0887..1a590f8a3a 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -10,6 +10,8 @@ afterEach((done) => { mongoose.connection.db.dropDatabase(done); }); +export { sleep } from './sleep'; + export function generateUser (options = {}) { return new User(options).toObject(); } diff --git a/test/helpers/sleep.js b/test/helpers/sleep.js new file mode 100644 index 0000000000..f8dd9ab165 --- /dev/null +++ b/test/helpers/sleep.js @@ -0,0 +1,7 @@ +export async function sleep (seconds) { + let milliseconds = seconds * 1000; + + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} From 8ff4cfe709668aeed1a3b0846b84b5b28fc3e5ef Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 10 Feb 2016 08:22:08 -0600 Subject: [PATCH 455/976] tests: Finish start quest --- test/api/v3/unit/models/group.test.js | 76 ++++++++++++++++++++------- website/src/models/group.js | 9 +--- 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index b86cc7a1d9..ef76e7b984 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -1,17 +1,16 @@ +import { sleep } from '../../../../helpers/api-unit.helper'; import { model as Group } from '../../../../../website/src/models/group'; import { model as User } from '../../../../../website/src/models/user'; import { quests as questScrolls } from '../../../../../common/script/content'; import * as email from '../../../../../website/src/libs/api-v3/email'; -import Q from 'q'; -describe.skip('Group Model', () => { +describe('Group Model', () => { context('Instance Methods', () => { describe('#startQuest', () => { let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; beforeEach(async () => { sandbox.stub(email, 'sendTxn'); - sandbox.spy(Q, 'allSettled'); party = new Group({ name: 'test party', @@ -173,14 +172,6 @@ describe.skip('Group Model', () => { expect(undecidedMember.party.quest.key).to.not.eql('whale'); }); - it('removes quest scroll from quest leader', async () => { - await party.startQuest(participatingMember); - - questLeader = await User.findById(questLeader._id); - - expect(questLeader.items.quests.whale).to.eql(0); - }); - it('sends email to participating members that quest has started', async () => { participatingMember.preferences.emailNotifications.questStarted = true; questLeader.preferences.emailNotifications.questStarted = true; @@ -191,6 +182,8 @@ describe.skip('Group Model', () => { await party.startQuest(nonParticipatingMember); + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); @@ -212,6 +205,8 @@ describe.skip('Group Model', () => { await party.startQuest(nonParticipatingMember); + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); @@ -231,6 +226,8 @@ describe.skip('Group Model', () => { await party.startQuest(participatingMember); + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); @@ -240,21 +237,62 @@ describe.skip('Group Model', () => { expect(memberIds).to.include(questLeader._id); }); - it('adds participating members to background save operations', async () => { + it('updates participting members (not including user)', async () => { + sandbox.spy(User, 'update'); + await party.startQuest(nonParticipatingMember); - expect(Q.allSettled).to.be.calledOnce; + let members = [questLeader._id, participatingMember._id]; - let savePromises = Q.allSettled.args[0][0]; - expect(savePromises).to.have.a.lengthOf(2); + expect(User.update).to.be.calledWith( + { _id: { $in: members } }, + { + $set: { + 'party.quest.key': 'whale', + 'party.quest.progress.down': 0, + 'party.quest.collect': {}, + 'party.quest.completed': null, + }, + } + ); }); - it('does not include initiating user in background save operations', async () => { + it('updates non-user quest leader and decrements quest scroll', async () => { + sandbox.spy(User, 'update'); + await party.startQuest(participatingMember); - expect(Q.allSettled).to.be.calledOnce; - let savePromises = Q.allSettled.args[0][0]; - expect(savePromises).to.have.a.lengthOf(1); + expect(User.update).to.be.calledWith( + { _id: questLeader._id }, + { + $inc: { + 'items.quests.whale': -1, + }, + } + ); + }); + + it('modifies the participating initiating user directly', async () => { + await party.startQuest(participatingMember); + + let userQuest = participatingMember.party.quest; + + expect(userQuest.key).to.eql('whale'); + expect(userQuest.progress.down).to.eql(0); + expect(userQuest.collect).to.eql({}); + expect(userQuest.completed).to.eql(null); + }); + + it('does not modify user if not participating', async () => { + await party.startQuest(nonParticipatingMember); + + expect(nonParticipatingMember.party.quest.key).to.not.eql('whale'); + }); + + it('removes the quest directly if initiating user is the quest leader', async () => { + await party.startQuest(questLeader); + + expect(questLeader.items.quests.whale).to.eql(0); }); }); }); diff --git a/website/src/models/group.js b/website/src/models/group.js index 23524646d1..4046b7d5ee 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -210,23 +210,16 @@ schema.methods.startQuest = async function startQuest (user) { user.markModified('party.quest'); } - // Remove the quest from the quest leader items (if he's the current user) + // Remove the quest from the quest leader items (if they are the current user) if (this.quest.leader === user._id) { user.items.quests[this.quest.key] -= 1; user.markModified('items.quests'); } else { // another user is starting the quest, update the leader separately await User.update({_id: this.quest.leader}, { - $set: { - 'party.quest.key': this.quest.key, - 'party.quest.progress.down': 0, - 'party.quest.collect': collected, - 'party.quest.completed': null, - }, $inc: { [`items.quests.${this.quest.key}`]: -1, }, }).exec(); - removeFromArray(nonUserQuestMembers, this.quest.leader); } // update the remaining users From 02a61e260b1854324036f5306c8989949cbf30b3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 10 Feb 2016 16:10:00 +0100 Subject: [PATCH 456/976] add tests for GET challenges/:challengeId --- .../GET-challenges_challengeId.test.js | 139 ++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 5 +- 2 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js new file mode 100644 index 0000000000..8528fee892 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js @@ -0,0 +1,139 @@ +import { + generateUser, + createAndPopulateGroup, + generateChallenge, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /challenges/:challengeId', () => { + it('fails if challenge doesn\'t exists', async () => { + let user = await generateUser(); + await expect(user.get(`/challenges/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('public guild', () => { + let groupLeader; + let group; + let challenge; + let user; + + beforeEach(async () => { + user = await generateUser(); + + let populatedGroup = await createAndPopulateGroup({ + groupDetails: {type: 'guild', privacy: 'public'}, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + + challenge = await generateChallenge(groupLeader, group); + }); + + it('should return challenge data', async () => { + let chal = await user.get(`/challenges/${challenge._id}`); + expect(chal.memberCount).to.equal(challenge.memberCount); + expect(chal.name).to.equal(challenge.name); + expect(chal._id).to.equal(challenge._id); + + expect(chal.leader).to.eql({ + _id: groupLeader._id, + profile: {name: groupLeader.profile.name}, + }); + expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy'])); + }); + }); + + context('private guild', () => { + let groupLeader; + let group; + let challenge; + let members; + let user; + + beforeEach(async () => { + user = await generateUser(); + + let populatedGroup = await createAndPopulateGroup({ + groupDetails: {type: 'guild', privacy: 'private'}, + members: 1, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + members = populatedGroup.members; + + challenge = await generateChallenge(groupLeader, group); + await members[0].post(`/challenges/${challenge._id}/join`); + }); + + it('fails if user doesn\'t have access to the challenge', async () => { + await expect(user.get(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('should return challenge data', async () => { + let chal = await members[0].get(`/challenges/${challenge._id}`); + expect(chal.name).to.equal(challenge.name); + expect(chal._id).to.equal(challenge._id); + + expect(chal.leader).to.eql({ + _id: groupLeader._id, + profile: {name: groupLeader.profile.name}, + }); + expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy'])); + }); + }); + + context('party', () => { + let groupLeader; + let group; + let challenge; + let members; + let user; + + beforeEach(async () => { + user = await generateUser(); + + let populatedGroup = await createAndPopulateGroup({ + groupDetails: {type: 'party'}, + members: 1, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + members = populatedGroup.members; + + challenge = await generateChallenge(groupLeader, group); + await members[0].post(`/challenges/${challenge._id}/join`); + }); + + it('fails if user doesn\'t have access to the challenge', async () => { + await expect(user.get(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('should return challenge data', async () => { + let chal = await members[0].get(`/challenges/${challenge._id}`); + expect(chal.name).to.equal(challenge.name); + expect(chal._id).to.equal(challenge._id); + + expect(chal.leader).to.eql({ + _id: groupLeader._id, + profile: {name: groupLeader.profile.name}, + }); + expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy'])); + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index bc9e6f03b4..c239a63dd3 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -307,11 +307,12 @@ api.getChallenge = { let challengeId = req.params.challengeId; let challenge = await Challenge.findById(challengeId) - // .populate('leader', nameFields) // don't populate the group as we'll fetch it manually later + // Don't populate the group as we'll fetch it manually later + // .populate('leader', nameFields) .exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - // Fetching basicGroupFields + // Fetching basic group data let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); From fa14464a0c1e229eda8b4a364e8000da557b06ec Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 10 Feb 2016 17:07:34 +0100 Subject: [PATCH 457/976] add accept quest route --- website/src/controllers/api-v3/quests.js | 56 ++++++++++++- website/src/models/group.js | 102 +++++++++++------------ 2 files changed, 106 insertions(+), 52 deletions(-) diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 05f4a69223..13584b139c 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -35,7 +35,7 @@ let api = {}; * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Quest Object + * @apiSuccess {Object} quest Quest Object */ api.inviteToQuest = { method: 'POST', @@ -124,4 +124,58 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/accept Accept a pending quest + * @apiVersion 3.0.0 + * @apiName AcceptQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} quest Quest Object + */ +api.acceptQuest = { + method: 'POST', + url: '/groups/:groupId/quests/accept', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound')); + + group.quest.members[user._id] = true; + user.party.quest.RSVPNeeded = false; + + if (canStartQuestAutomatically(group)) { + await group.startQuest(user); + } + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + // track that an user has accepted the quest + analytics.track('quest', { + category: 'behavior', + owner: false, + response: 'accept', + gaLabel: 'accept', + questName: group.quest.key, + uuid: user._id, + }); + }, +}; + export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 4046b7d5ee..ff7a22b63c 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -170,6 +170,57 @@ schema.methods.isMember = function isGroupMember (user) { } }; +export function chatDefaults (msg, user) { + let message = { + id: shared.uuid(), + text: msg, + timestamp: Number(new Date()), + likes: {}, + flags: {}, + flagCount: 0, + }; + + if (user) { + _.defaults(message, { + uuid: user._id, + contributor: user.contributor && user.contributor.toObject(), + backer: user.backer && user.backer.toObject(), + user: user.profile.name, + }); + } else { + message.uuid = 'system'; + } + + return message; +} + +schema.methods.sendChat = function sendChat (message, user) { + this.chat.unshift(chatDefaults(message, user)); + this.chat.splice(200); + + // Kick off chat notifications in the background. // TODO refactor + let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; + lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; + + if (this._id === 'habitrpg') { + // TODO For Tavern, only notify them if their name was mentioned + // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? + // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); + } else { + let query = {}; + + if (this.type === 'party') { + query['party._id'] = this._id; + } else { + query.guilds = this._id; + } + + query._id = { $ne: user ? user._id : ''}; + + User.update(query, lastSeenUpdate, {multi: true}).exec(); + } +}; + schema.methods.startQuest = async function startQuest (user) { // not using i18n strings because these errors are meant for devs who forgot to pass some parameters if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); @@ -249,57 +300,6 @@ schema.methods.startQuest = async function startQuest (user) { }); }; -export function chatDefaults (msg, user) { - let message = { - id: shared.uuid(), - text: msg, - timestamp: Number(new Date()), - likes: {}, - flags: {}, - flagCount: 0, - }; - - if (user) { - _.defaults(message, { - uuid: user._id, - contributor: user.contributor && user.contributor.toObject(), - backer: user.backer && user.backer.toObject(), - user: user.profile.name, - }); - } else { - message.uuid = 'system'; - } - - return message; -} - -schema.methods.sendChat = function sendChat (message, user) { - this.chat.unshift(chatDefaults(message, user)); - this.chat.splice(200); - - // Kick off chat notifications in the background. // TODO refactor - let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; - lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; - - if (this._id === 'habitrpg') { - // TODO For Tavern, only notify them if their name was mentioned - // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? - // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); - } else { - let query = {}; - - if (this.type === 'party') { - query['party._id'] = this._id; - } else { - query.guilds = this._id; - } - - query._id = { $ne: user ? user._id : ''}; - - User.update(query, lastSeenUpdate, {multi: true}).exec(); - } -}; - function _cleanQuestProgress (merge) { // TODO clone? (also in sendChat message) let clean = { From a2af6c390bea1e701002f7f3a12c992e132e36a3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 10 Feb 2016 22:27:59 +0100 Subject: [PATCH 458/976] fix quest accept route and add tests --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupId_quests_accept.test.js | 85 ++++++++++++++++--- website/src/controllers/api-v3/quests.js | 4 + 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..a2495529e7 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,6 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "questAlreadyAccepted": "You already accepted the quest invitation." } diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js index e218d7e3d9..665a185279 100644 --- a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js @@ -1,27 +1,37 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; -describe.skip('POST /groups/:groupId/quests/accept', () => { +describe('POST /groups/:groupId/quests/accept', () => { + const PET_QUEST = 'whale'; + let questingGroup; let leader; - let member; + let partyMembers; + let user; beforeEach(async () => { + user = await generateUser(); + let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); questingGroup = group; leader = groupLeader; - member = members[0]; + partyMembers = members; + + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); }); context('failure conditions', () => { it('does not accept quest without an invite', async () => { - await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`, {})) + await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -30,25 +40,80 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { }); it('does not accept quest for a group in which user is not a member', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/accept`, {})) + await expect(user.post(`/groups/${questingGroup._id}/quests/accept`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', - message: t('questInviteNotFound'), + message: t('groupNotFound'), + }); + }); + + it('does not accept quest for a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(guildLeader.post(`/groups/${guild._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('does not accept invite twice', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('questAlreadyAccepted'), + }); + }); + + it('does not accept invite for a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), }); }); }); + context('successfully accepting a quest invitation', () => { - it('joins a quest from an invitation', () => { + it('joins a quest from an invitation', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await Promise.all([partyMembers[0].sync(), questingGroup.sync()]); + expect(leader.party.quest.RSVPNeeded).to.equal(false); + expect(questingGroup.quest.members[partyMembers[0]._id]); }); - it('does not begin the quest if pending invitations remain', () => { + it('does not begin the quest if pending invitations remain', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await questingGroup.sync(); + expect(questingGroup.quest.active).to.equal(false); }); - it('begins the quest if accepting the last pending invite', () => { + it('begins the quest if accepting the last pending invite', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + await questingGroup.sync(); + expect(questingGroup.quest.active).to.equal(true); }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 13584b139c..162954b7dd 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -12,6 +12,7 @@ import { import { NotFound, NotAuthorized, + BadRequest, } from '../../libs/api-v3/errors'; import { getUserInfo, @@ -151,7 +152,10 @@ api.acceptQuest = { if (!group) throw new NotFound(res.t('groupNotFound')); if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound')); + if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); + if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted')); + group.markModified('quest'); group.quest.members[user._id] = true; user.party.quest.RSVPNeeded = false; From 879506b38a0662b29e4ccc60251758b0d73f886c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 12:50:28 +0100 Subject: [PATCH 459/976] update reject quest route --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_reject.test.js | 125 +++++++++++++----- website/src/controllers/api-v3/quests.js | 40 +++--- website/src/models/group.js | 1 - 4 files changed, 117 insertions(+), 52 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a2495529e7..62c4df4d8b 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -74,5 +74,6 @@ "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", - "questAlreadyAccepted": "You already accepted the quest invitation." + "questAlreadyAccepted": "You already accepted the quest invitation.", + "questAlreadyRejected": "You already rejected the quest invitation." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index a222778948..84e364f770 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -1,36 +1,37 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/invite/:questKey', () => { +describe('POST /groups/:groupId/quests/reject', () => { let questingGroup; - let member; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; + let partyMembers; + let user; + let leader; - before(async () => { - let { group, members } = await createAndPopulateGroup({ + const PET_QUEST = 'whale'; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); context('failure conditions', () => { it('returns an error when group is not found', async () => { - await expect(member.post(`/groups/${generateUUID()}/quests/reject`)) + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -38,35 +39,97 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { }); }); - it('returns an error when group is not on a quest', async () => { - await member.update(userQuestUpdate); + it('does not accept quest for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); - await expect(member.post(`/groups/${questingGroup._id}/quests/reject`)) + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(guildLeader.post(`/groups/${guild._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('returns an error when group is not on a quest', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('questInvitationDoesNotExist'), }); }); + + it('return an error when an user rejects an invite twice', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('questAlreadyRejected'), + }); + }); + + it('return an error when an user rejects an invite already accepted', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('questAlreadyAccepted'), + }); + }); + + it('does not reject invite for a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), + }); + }); }); context('successfully quest rejection', () => { it('rejects a quest invitation', async () => { - await member.update(userQuestUpdate); - await questingGroup.update({'quest.key': PET_QUEST}); + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + await partyMembers[0].sync(); + await questingGroup.sync(); - let rejectResult = await member.post(`/groups/${questingGroup._id}/quests/reject`); - let userWithRejectInvitation = await member.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + expect(partyMembers[0].party.quest.key).to.be.null; + expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false; + expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; + expect(questingGroup.quest.active).to.be.false; + }); - expect(userWithRejectInvitation.party.quest.key).to.be.null; - expect(userWithRejectInvitation.party.quest.RSVPNeeded).to.be.false; - expect(updatedGroup.quest.members[member._id]).to.be.false; - expect(updatedGroup.quest).to.deep.equal(rejectResult); + it('starts the quest when the last user reject', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); + await questingGroup.sync(); + + expect(questingGroup.quest.active).to.be.true; }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 0f1f991b7b..b4a789b392 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -19,13 +19,11 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; -import { track } from '../../libs/api-v3/analyticsService'; -import Q from 'q'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true // If any member is null/undefined (undecided) return false - return _.every(group.quest.members, Boolean); + return _.every(group.quest.members, _.isBoolean); } let api = {}; @@ -191,9 +189,8 @@ api.acceptQuest = { * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') - * @apiParam {string} questKey The quest _id * - * @apiSuccess {Object} Quest Object + * @apiSuccess {Object} quest Quest Object */ api.rejectQuest = { method: 'POST', @@ -209,32 +206,37 @@ api.rejectQuest = { let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); - - let analyticsData = { - category: 'behavior', - owner: false, - response: 'reject', - gaLabel: 'reject', - questName: group.quest.key, - uuid: user._id, - }; - track('quest', analyticsData); + if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); + if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted')); + if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected')); group.quest.members[user._id] = false; group.markModified('quest.members'); - user.party.quest.RSVPNeeded = false; - user.party.quest.key = null; + user.party.quest = Group.cleanQuestProgress(); + user.markModified('party.quest'); + + if (canStartQuestAutomatically(group)) { + await group.startQuest(user); + } let [savedGroup] = await Q.all([ group.save(), user.save(), ]); - // questStart(req,res,next); - res.respond(200, savedGroup.quest); + + analytics.track('quest', { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }); }, }; diff --git a/website/src/models/group.js b/website/src/models/group.js index ff7a22b63c..bb2c08d436 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -301,7 +301,6 @@ schema.methods.startQuest = async function startQuest (user) { }; function _cleanQuestProgress (merge) { - // TODO clone? (also in sendChat message) let clean = { key: null, progress: { From 6c1950972b8b5a3b61784959ba6402449096ab8f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 13:19:46 +0100 Subject: [PATCH 460/976] update quest cancel routes --- common/locales/en/api-v3.json | 4 +- .../POST-groups_groupid_quests_cancel.test.js | 139 +++++++++++++----- website/src/controllers/api-v3/quests.js | 12 +- website/src/models/group.js | 15 ++ 4 files changed, 130 insertions(+), 40 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 7c5cc3b189..ec2be7a366 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -75,5 +75,7 @@ "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", "questAlreadyAccepted": "You already accepted the quest invitation.", - "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality." + "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality.", + "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.", + "questInvitationDoesNotExist": "No quest invitation has been sent out yet." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index 84111ab5ea..76f0102a5f 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -1,66 +1,137 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/leave', () => { - let questingGroup, member, leader; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; +describe('POST /groups/:groupId/quests/cancel', () => { + let questingGroup; + let partyMembers; + let user; + let leader; - before(async () => { + const PET_QUEST = 'whale'; + + beforeEach(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); - leader = groupLeader; questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); - it('returns an error when group is not found', async () => { - await expect(leader.post(`/groups/${generateUUID()}/quests/cancel`)) + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('does not reject quest for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/cancel`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); - }); + }); - it('cancels a quest', async () => { - await member.update(userQuestUpdate); - await questingGroup.update({'quest.key': PET_QUEST}); + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); + await expect(guildLeader.post(`/groups/${guild._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); - await leader.post(`/groups/${questingGroup._id}/quests/cancel`); - let userThatCanceled = await member.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + it('returns an error when group is not on a quest', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInvitationDoesNotExist'), + }); + }); - expect(userThatCanceled.party.quest.key).to.be.null; - expect(userThatCanceled.party.quest.RSVPNeeded).to.be.false; - expect(updatedGroup.quest.members).to.be.empty; - }); + it('only the leader can cancel the quest', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - it('returns an error when quest is active', async () => { - await questingGroup.update({'quest.active': true}); - await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`)) + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderCancelQuest'), + }); + }); + + it('does not cancel a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('cantCancelActiveQuest'), }); + }); + }); + + it('cancels a quest', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + + await leader.post(`/groups/${questingGroup._id}/quests/cancel`); + + await Promise.all([ + leader.sync(), + partyMembers[0].sync(), + partyMembers[1].sync(), + questingGroup.sync(), + ]); + + let clean = { + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }; + + expect(leader.party.quest).eql(clean); + expect(partyMembers[1].party.quest).eql(clean); + expect(partyMembers[0].party.quest).eql(clean); + + expect(questingGroup.quest).to.eql({ + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 3da381e26d..4cfad3155c 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -190,7 +190,7 @@ api.acceptQuest = { * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Group Object + * @apiSuccess {Object} quest Quest Object */ api.cancelQuest = { method: 'POST', @@ -210,20 +210,22 @@ api.cancelQuest = { let group = await Group.getGroup({user, groupId, fields: 'type quest'}); if (!group) throw new NotFound(res.t('groupNotFound')); - + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); + if (user._id !== group.leader && group.quest.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCancelQuest')); if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest')); - group.quest = {key: null, progress: {}, leader: null, members: {}}; + group.quest = Group.cleanGroupQuest(); group.markModified('quest'); await group.save(); await User.update( {'party._id': groupId}, - {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}, + {$set: {'party.quest': Group.cleanQuestProgress()}}, {multi: true} ); - res.respond(200, group); + res.respond(200, group.quest); }, }; diff --git a/website/src/models/group.js b/website/src/models/group.js index ff7a22b63c..1af2c8f7b5 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -300,6 +300,7 @@ schema.methods.startQuest = async function startQuest (user) { }); }; +// return a clean object for user.quest function _cleanQuestProgress (merge) { // TODO clone? (also in sendChat message) let clean = { @@ -321,8 +322,22 @@ function _cleanQuestProgress (merge) { return clean; } +// TODO move to User.cleanQuestProgress? schema.statics.cleanQuestProgress = _cleanQuestProgress; +// returns a clean object for group.quest +schema.statics.cleanGroupQuest = function cleanGroupQuest () { + return { + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }; +}; + // Participants: Grant rewards & achievements, finish quest // Returns the promise from update().exec() schema.methods.finishQuest = function finishQuest (quest) { From 354b6bc39e2b028cfc4e75816a38f14ffa02b93f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:07:45 +0100 Subject: [PATCH 461/976] update quest leave route --- .../POST-groups_groupid_quests_leave.test.js | 127 ++++++++++++------ website/src/controllers/api-v3/quests.js | 22 +-- 2 files changed, 90 insertions(+), 59 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js index eec1e33842..8b34278c08 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -1,85 +1,124 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /groups/:groupId/quests/leave', () => { - let questingGroup, member, leader; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; + let questingGroup; + let partyMembers; + let user; + let leader; - before(async () => { + const PET_QUEST = 'whale'; + + beforeEach(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); - leader = groupLeader; questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); - it('returns an error when group is not found', async () => { - await expect(member.post(`/groups/${generateUUID()}/quests/leave`)) + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); - }); + }); - it('returns an error when quest is not active', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('noActiveQuestToLeave'), + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, }); - }); - it('returns an error when quest leader attempts to leave', async () => { - await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); - - await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`)) + await expect(guildLeader.post(`/groups/${guild._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('questLeaderCannotLeaveQuest'), + message: t('guildQuestsNotSupported'), }); - }); + }); - it('returns an error when non quest member attempts to leave', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) + it('returns an error when quest is not active', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToLeave'), + }); + }); + + it('returns an error when quest leader attempts to leave', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLeaderCannotLeaveQuest'), + }); + }); + + it('returns an error when non quest member attempts to leave', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('notPartOfQuest'), }); + }); }); it('leaves a quest', async () => { - await member.update(userQuestUpdate); + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); + let leaveResult = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`); + await Promise.all([ + partyMembers[0].sync(), + questingGroup.sync(), + ]); - let leaveResult = await member.post(`/groups/${questingGroup._id}/quests/leave`); - let userThatLeft = await member.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); - - expect(userThatLeft.party.quest.key).to.be.null; - expect(userThatLeft.party.quest.RSVPNeeded).to.be.false; - expect(updatedGroup.quest.members[member._id]).to.be.false; - expect(updatedGroup.quest).to.deep.equal(leaveResult); + expect(partyMembers[0].party.quest).to.eql({ + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }); + expect(questingGroup.quest).to.deep.equal(leaveResult); + expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 1e1b6f176d..39da2cec29 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -19,7 +19,6 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; -import Q from 'q'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true @@ -184,14 +183,14 @@ api.acceptQuest = { }; /** - * @api {post} /groups/:groupId/quests/leave Leaves a quest + * @api {post} /groups/:groupId/quests/leave Leaves the active quest * @apiVersion 3.0.0 * @apiName LeaveQuest * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Empty Object + * @apiSuccess {Object} quest Quest Object */ api.leaveQuest = { method: 'POST', @@ -207,19 +206,12 @@ api.leaveQuest = { if (validationErrors) throw validationErrors; let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); - - if (!(group.quest && group.quest.active)) { - throw new NotFound(res.t('noActiveQuestToLeave')); - } - - if (group.quest.leader === user._id) { - throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest')); - } - - if (!(group.quest.members && group.quest.members[user._id])) { - throw new NotAuthorized(res.t('notPartOfQuest')); - } + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToLeave')); + if (group.quest.leader === user._id) throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest')); + if (!group.quest.members[user._id]) throw new NotAuthorized(res.t('notPartOfQuest')); group.quest.members[user._id] = false; group.markModified('quest.members'); From dfb6ec2f48cea2e205913b74b16901d38285a9b9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:11:38 +0100 Subject: [PATCH 462/976] cancel quest: test response --- .../quests/POST-groups_groupid_quests_cancel.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index 76f0102a5f..daa995438a 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -100,7 +100,7 @@ describe('POST /groups/:groupId/quests/cancel', () => { await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - await leader.post(`/groups/${questingGroup._id}/quests/cancel`); + let res = await leader.post(`/groups/${questingGroup._id}/quests/cancel`); await Promise.all([ leader.sync(), @@ -124,6 +124,7 @@ describe('POST /groups/:groupId/quests/cancel', () => { expect(partyMembers[1].party.quest).eql(clean); expect(partyMembers[0].party.quest).eql(clean); + expect(res).to.eql(questingGroup.quest); expect(questingGroup.quest).to.eql({ key: null, active: false, From ab187720a5fcc11f064d8e1654c7d3e84e0a7a71 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:14:07 +0100 Subject: [PATCH 463/976] reject quest: test response --- .../POST-groups_groupid_quests_reject.test.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index 84e364f770..1eb62aa0c6 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -110,17 +110,28 @@ describe('POST /groups/:groupId/quests/reject', () => { }); context('successfully quest rejection', () => { + let cleanUserQuestObj = { + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }; + it('rejects a quest invitation', async () => { await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + let res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); await partyMembers[0].sync(); await questingGroup.sync(); - expect(partyMembers[0].party.quest.key).to.be.null; - expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false; + expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj); expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; expect(questingGroup.quest.active).to.be.false; + expect(res).to.eql(questingGroup.quest); }); it('starts the quest when the last user reject', async () => { From c79cb0efc6be139374dc4ad202e78cd382094035 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:32:25 +0100 Subject: [PATCH 464/976] update quest abort route --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_abort.test.js | 134 ++++++++++++------ website/src/controllers/api-v3/quests.js | 36 ++--- 3 files changed, 111 insertions(+), 62 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 72eb2f5c9e..671a046f8e 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -75,5 +75,6 @@ "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", "questAlreadyAccepted": "You already accepted the quest invitation.", - "noActiveQuestToAbort": "There is no active quest to abort." + "noActiveQuestToAbort": "There is no active quest to abort.", + "onlyLeaderAbortQuest": "Only the group or quest leader can abort a quest." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js index 1ed6871307..e833dd16fe 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -1,78 +1,126 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/abort', () => { - let questingGroup, member, leader; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; +describe('POST /groups/:groupId/quests/leave', () => { + let questingGroup; + let partyMembers; + let user; + let leader; - before(async () => { + const PET_QUEST = 'whale'; + + beforeEach(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); - leader = groupLeader; questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); - it('returns an error when group is not found', async () => { - await expect(leader.post(`/groups/${generateUUID()}/quests/abort`)) + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/abort`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); - }); + }); - it('returns an error when quest is not active', async () => { - await expect(leader.post(`/groups/${questingGroup._id}/quests/abort`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('noActiveQuestToAbort'), + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, }); - }); - xit('returns an error when non quest leader attempts to abort', async () => { - await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); - - await expect(member.post(`/groups/${questingGroup._id}/quests/abort`)) + await expect(guildLeader.post(`/groups/${guild._id}/quests/abort`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('questLeaderCannotAbortQuest'), + message: t('guildQuestsNotSupported'), }); + }); + + it('returns an error when quest is not active', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToAbort'), + }); + }); + + it('returns an error when non quest leader attempts to abort', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderAbortQuest'), + }); + }); }); it('aborts a quest', async () => { - await member.update(userQuestUpdate); + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); - await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + let res = await leader.post(`/groups/${questingGroup._id}/quests/abort`); + Promise.all([ + leader.sync(), + questingGroup.sync(), + partyMembers[0].sync(), + partyMembers[1].sync(), + ]); - let abortResult = await leader.post(`/groups/${questingGroup._id}/quests/abort`); - let updatedMember = await member.get('/user'); - let updatedLeader = await leader.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + let cleanUserQuestObj = { + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }; - expect(updatedMember.party.quest.key).to.be.null; - expect(updatedMember.party.quest.RSVPNeeded).to.be.false; - expect(updatedLeader.items.quests[PET_QUEST]).to.equal(1); - expect(updatedGroup.quest).to.deep.equal(abortResult); + expect(leader.party.quest).to.eql(cleanUserQuestObj); + expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj); + expect(partyMembers[1].party.quest).to.eql(cleanUserQuestObj); + expect(leader.items.quests[PET_QUEST]).to.equal(1); + expect(questingGroup.quest).to.deep.equal(abortResult); + expect(questingGroup.quest).to.eql({ + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index b3fb2e0e8e..ea49204f9a 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -7,9 +7,6 @@ import { model as Group, } from '../../models/group'; import { model as User } from '../../models/user'; -import { - model as User, -} from '../../models/user'; import { NotFound, NotAuthorized, @@ -20,7 +17,6 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; -import Q from 'q'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true @@ -185,14 +181,14 @@ api.acceptQuest = { }; /** - * @api {post} /groups/:groupId/quests/abort Abort a quest + * @api {post} /groups/:groupId/quests/abort Abort the current quest * @apiVersion 3.0.0 * @apiName AbortQuest * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Quest Object + * @apiSuccess {Object} quest Quest Object */ api.abortQuest = { method: 'POST', @@ -208,24 +204,28 @@ api.abortQuest = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + let group = await Group.getGroup({user, groupId, fields: 'type quest leader'}); if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort')); + if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest')); - let memberUpdates = User.update( - {'party._id': groupId}, - { + let memberUpdates = User.update({ + 'party._id': groupId + }, { $set: {'party.quest': Group.cleanQuestProgress()}, - $inc: {_v: 1}, + $inc: {_v: 1}, // TODO update middleware + }, {multi: true}).exec(); + + let questLeaderUpdate = User.update({ + _id: group.quest.leader + }, { + $inc: { + [`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader }, - {multi: true}, - ); + }).exec(); - let update = {$inc: {}}; - update.$inc[`items.quests.${group.quest.key}`] = 1; - let questLeaderUpdate = User.update({_id: group.quest.leader}, update).exec(); - - group.quest = {key: null, progress: {collect: {}}, leader: null, members: {}, extra: {}, active: false}; + group.quest = Group.cleanGroupQuest(); group.markModified('quest'); let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]); From 41c3276e66beed584ff5aabc3928ca8172cad46d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:36:21 +0100 Subject: [PATCH 465/976] fetch leader id when cancelling quest --- website/src/controllers/api-v3/quests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 4cfad3155c..fc1555f54d 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -208,7 +208,7 @@ api.cancelQuest = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + let group = await Group.getGroup({user, groupId, fields: 'type leader quest'}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); From befdb189fc02f9458a3d67f3036f621bc353c677 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 21:09:14 +0100 Subject: [PATCH 466/976] fix tests for quests routes --- .../quests/POST-groups_groupid_quests_abort.test.js | 4 ++-- .../quests/POST-groups_groupid_quests_cancel.test.js | 6 +++--- .../quests/POST-groups_groupid_quests_leave.test.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js index 955d31bc92..850cf53646 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -5,7 +5,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/leave', () => { +describe('POST /groups/:groupId/quests/abort', () => { let questingGroup; let partyMembers; let user; @@ -90,7 +90,7 @@ describe('POST /groups/:groupId/quests/leave', () => { await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); let res = await leader.post(`/groups/${questingGroup._id}/quests/abort`); - Promise.all([ + await Promise.all([ leader.sync(), questingGroup.sync(), partyMembers[0].sync(), diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index daa995438a..f3bd03a180 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -120,9 +120,9 @@ describe('POST /groups/:groupId/quests/cancel', () => { RSVPNeeded: false, }; - expect(leader.party.quest).eql(clean); - expect(partyMembers[1].party.quest).eql(clean); - expect(partyMembers[0].party.quest).eql(clean); + expect(leader.party.quest).to.eql(clean); + expect(partyMembers[1].party.quest).to.eql(clean); + expect(partyMembers[0].party.quest).to.eql(clean); expect(res).to.eql(questingGroup.quest); expect(questingGroup.quest).to.eql({ diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js index 8b34278c08..65d781c163 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -88,7 +88,7 @@ describe('POST /groups/:groupId/quests/leave', () => { await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`)) + await expect(partyMembers[1].post(`/groups/${questingGroup._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', From 4360f01936d787991a3d43aba4484bc80607952b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 12 Feb 2016 08:04:14 -0600 Subject: [PATCH 467/976] feat(api-v3): Add force-start quest route --- common/locales/en/api-v3.json | 4 +- ...-groups_groupId_quests_force-start.test.js | 126 ++++++++++++++++++ website/src/controllers/api-v3/quests.js | 53 ++++++++ 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 87c63de581..649adf8c94 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -83,5 +83,7 @@ "questAlreadyRejected": "You already rejected the quest invitation.", "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality.", "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.", - "questInvitationDoesNotExist": "No quest invitation has been sent out yet." + "questInvitationDoesNotExist": "No quest invitation has been sent out yet.", + "questNotPending": "There is no quest to start.", + "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest" } diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js new file mode 100644 index 0000000000..b6d43f826b --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js @@ -0,0 +1,126 @@ +import { + createAndPopulateGroup, + translate as t, + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:groupId/quests/force-start', () => { + const PET_QUEST = 'whale'; + + let questingGroup; + let leader; + let partyMembers; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 2, + }); + + questingGroup = group; + leader = groupLeader; + partyMembers = members; + + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + }); + + context('failure conditions', () => { + it('does not force start a quest for a group in which user is not a member', async () => { + let nonMember = await generateUser(); + + await expect(nonMember.post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('does not force start quest for a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(guildLeader.post(`/groups/${guild._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('does not force start for a party without a pending quest', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questNotPending'), + }); + }); + + it('does not force start for a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), + }); + }); + + it('does not allow non-quest leader or non-group leader to force start a quest', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questOrGroupLeaderOnlyStartQuest'), + }); + }); + }); + + context('successfully force starting a quest', () => { + it('allows quest leader to force start quest', async () => { + let questLeader = partyMembers[0]; + await questLeader.update({[`items.quests.${PET_QUEST}`]: 1}); + await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await questLeader.post(`/groups/${questingGroup._id}/quests/force-start`); + + await questingGroup.sync(); + + expect(questingGroup.quest.active).to.eql(true); + }); + + it('allows group leader to force start quest', async () => { + let questLeader = partyMembers[0]; + await questLeader.update({[`items.quests.${PET_QUEST}`]: 1}); + await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await leader.post(`/groups/${questingGroup._id}/quests/force-start`); + + await questingGroup.sync(); + + expect(questingGroup.quest.active).to.eql(true); + }); + + it('sends back the quest object', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + let quest = await leader.post(`/groups/${questingGroup._id}/quests/force-start`); + + expect(quest.active).to.eql(true); + expect(quest.key).to.eql(PET_QUEST); + expect(quest.members).to.eql({ + [`${leader._id}`]: true, + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index bf25b81396..e0ad3b86d4 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -238,6 +238,59 @@ api.rejectQuest = { }, }; + +/** + * @api {post} /groups/:groupId/quests/force-start Accept a pending quest + * @apiVersion 3.0.0 + * @apiName forceStart + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} quest Quest Object + */ +api.forceStart = { + method: 'POST', + url: '/groups/:groupId/quests/force-start', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest leader'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.key) throw new NotFound(res.t('questNotPending')); + if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); + if (!(user._id === group.quest.leader || user._id === group.leader)) throw new NotAuthorized(res.t('questOrGroupLeaderOnlyStartQuest')); + + group.markModified('quest'); + + await group.startQuest(user); + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + analytics.track('quest', { + category: 'behavior', + owner: user._id === group.quest.leader, + response: 'force-start', + gaLabel: 'force-start', + questName: group.quest.key, + uuid: user._id, + }); + }, +}; + /** * @api {post} /groups/:groupId/quests/cancel Cancels a quest * @apiVersion 3.0.0 From 599510aa78b48cdbc8517eeddbcce159d53016a1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 12 Feb 2016 19:49:53 +0100 Subject: [PATCH 468/976] higher maxBuffer for integration tests --- tasks/gulp-tests.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index b8ccf23f68..c8af5e8b18 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -356,6 +356,7 @@ gulp.task('test:api-v3:unit', (done) => { gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), + {maxBuffer: 500*1028}, (err, stdout, stderr) => done(err) ) @@ -365,6 +366,7 @@ gulp.task('test:api-v3:integration', (done) => { gulp.task('test:api-v3:integration:separate-server', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), + {maxBuffer: 500*1028}, (err, stdout, stderr) => done(err) ) From edaf3ef4db7f29884fb73f4c8a6366bae3075e23 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 12 Feb 2016 19:50:50 +0100 Subject: [PATCH 469/976] fix typo: 1028->1024 bytes --- tasks/gulp-tests.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index c8af5e8b18..6256440998 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -356,7 +356,7 @@ gulp.task('test:api-v3:unit', (done) => { gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), - {maxBuffer: 500*1028}, + {maxBuffer: 500*1024}, (err, stdout, stderr) => done(err) ) @@ -366,7 +366,7 @@ gulp.task('test:api-v3:integration', (done) => { gulp.task('test:api-v3:integration:separate-server', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), - {maxBuffer: 500*1028}, + {maxBuffer: 500*1024}, (err, stdout, stderr) => done(err) ) From 8b7719d25efe34a0fae83879edb84b947a8dd7f2 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 14 Feb 2016 12:56:40 +0100 Subject: [PATCH 470/976] fix apidoc comments --- website/src/controllers/api-v3/chat.js | 2 -- website/src/controllers/api-v3/tasks.js | 6 ++++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 079fbb66cd..e60ba380a8 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -312,8 +312,6 @@ api.clearChatFlags = { * @apiGroup Chat * * @apiParam {groupId} groupId The group _id - * - * @apiSuccess {None} */ api.seenChat = { method: 'POST', diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 9ec66296a1..ffd3b15903 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -62,7 +62,8 @@ async function _createTasks (req, res, user, challenge) { * @apiName CreateUserTasks * @apiGroup Task * - * @apiSuccess {Object|Array} task The newly created task(s) + * @apiSuccess {Object} task The newly created task + * @apiSuccess {Object[]} tasks The newly created tasks (if more than one was created) */ api.createUserTasks = { method: 'POST', @@ -82,7 +83,8 @@ api.createUserTasks = { * * @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to. * - * @apiSuccess {Object|Array} task The newly created task(s) + * @apiSuccess {Object} task The newly created task + * @apiSuccess {Object[]} tasks The newly created tasks (if more than one was created) */ api.createChallengeTasks = { method: 'POST', From 2547676783ef994cbcee45b687e170a0a20c03bf Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 18 Feb 2016 14:55:52 +0100 Subject: [PATCH 471/976] add hall controller, missing tests --- common/locales/en/api-v3.json | 5 +- website/src/controllers/api-v3/hall.js | 185 +++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 website/src/controllers/api-v3/hall.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 649adf8c94..f4cfaa5272 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -16,6 +16,7 @@ "onlySocialAttachLocal": "Local auth can only be added to a social account.", "invalidReqParams": "Invalid request parameters.", "memberIdRequired": "\"member\" must be a valid UUID.", + "heroIdRequired": "\"heroId\" must be a valid UUID.", "taskIdRequired": "\"taskId\" must be a valid UUID.", "taskNotFound": "Task not found.", "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", @@ -85,5 +86,7 @@ "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.", "questInvitationDoesNotExist": "No quest invitation has been sent out yet.", "questNotPending": "There is no quest to start.", - "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest" + "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest", + "noAdminAccess": "You don't have admin access.", + "pageMustBeNumber": "req.query.page must be a number" } diff --git a/website/src/controllers/api-v3/hall.js b/website/src/controllers/api-v3/hall.js new file mode 100644 index 0000000000..c1afb806aa --- /dev/null +++ b/website/src/controllers/api-v3/hall.js @@ -0,0 +1,185 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { model as User } from '../../models/user'; +import { + NotFound, + NotAuthorized, +} from '../../libs/api-v3/errors'; +import _ from 'lodash'; + +let api = {}; + +/** + * @api {get} /hall/heroes Get all Heroes + * @apiVersion 3.0.0 + * @apiName GetHeroes + * @apiGroup Hall + * + * @apiSuccess {Array} hero An array of heroes + */ +api.getHeroes = { + method: 'GET', + url: '/hall/heroes', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let heroes = await User + .find({ + 'contributor.level': {$gt: 0}, + }) + .select('contributor backer balance profile.name') + .sort('-contributor.level') + .lean() + .exec(); + + res.respond(200, heroes); + }, +}; + +// Note, while the following routes are called getHero / updateHero +// they can be used by admins to get/update any user +// TODO rename? + +const heroAdminFields = 'contributor balance profile.name purchased items auth.local.username auth'; + +/** + * @api {get} /hall/heroes/:heroId Get an hero given his _id. Must be an admin to make this request + * @apiVersion 3.0.0 + * @apiName GetHero + * @apiGroup Hall + * + * @apiSuccess {Object} hero The hero object + */ +api.getHero = { + method: 'GET', + url: '/hall/heroes/:heroId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let heroId = req.params.heroId; + + req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.contributor.admin) { + throw new NotAuthorized(res.t('noAdminAccess')); + } + + let hero = await User + .findById(heroId) + .select(heroAdminFields) + .lean() + .exec(); + + if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId})); + res.respond(200, hero); + }, +}; + +// e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff +const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0}; + +/** + * @api {put} /hall/heroes/:heroId Update an hero. Must be an admin to make this request + * @apiVersion 3.0.0 + * @apiName UpdateHero + * @apiGroup Hall + * + * @apiSuccess {Object} hero The updated hero object + */ +api.updateHero = { + method: 'PUT', + url: '/hall/heroes/:heroId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let heroId = req.params.heroId; + let updateData = req.body; + + req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.contributor.admin) { + throw new NotAuthorized(res.t('noAdminAccess')); + } + + let hero = await User.findById(heroId).exec(); + if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId})); + + if (updateData.balance) hero.balance = updateData.balance; + + // give them gems if they got an higher level + let newTier = updateData.contributor.level; // tier = level in this context + let oldTier = hero.contributor && hero.contributor.level || 0; + if (newTier > oldTier) { + hero.flags.contributor = true; + let tierDiff = newTier - oldTier; // can be 2+ tier increases at once + while (tierDiff) { + hero.balance += gemsPerTier[newTier] / 4; // balance is in $ + tierDiff--; + newTier--; // give them gems for the next tier down if they weren't aready that tier + } + } + + if (updateData.contributor) hero.contributor = updateData.contributor; + if (updateData.purchased && updateData.purchased.ads) hero.purchased.ads = updateData.purchased.ads; + // give them the Dragon Hydra pet if they're above level 6 + if (hero.contributor.level >= 6) hero.items.pets['Dragon-Hydra'] = 5; + if (updateData.itemPath && updateData.itemVal && + updateData.itemPath.indexOf('items.') === 0 && + User.schema.paths[updateData.itemPath]) { + _.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary) TODO review + } + if (updateData.auth && _.isBoolean(updateData.auth.blocked)) hero.auth.blocked = updateData.auth.blocked; + + let savedHero = await hero.save(); + let responseHero = {}; // only respond with important fields + heroAdminFields.split().forEach(field => { + _.set(responseHero, field, _.get(savedHero, field)); + }); + + res.respond(200, responseHero); + }, +}; + +/** + * @api {get} /hall/patrons Get all Patrons. Only the first 50 patrons are returned. More can be accessed passing ?page=n. + * @apiVersion 3.0.0 + * @apiName GetPatrons + * @apiGroup Hall + * + * @apiParam {Number} page The result page. Default is 0 + * + * @apiSuccess {Array} patron An array of patrons + */ +api.getPatrons = { + method: 'GET', + url: '/hall/patrons', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('page', res.t('pageMustBeNumber')).isNumeric(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let page = req.query.page || 0; + const perPage = 50; + + let patrons = await User + .find({ + 'backer.tier': {$gt: 0}, + }) + .select('contributor backer profile.name') + .sort('-backer.tier') + .skip(page * perPage) + .limit(perPage) + .exec(); + + res.respond(200, patrons); + }, +}; + +export default api; From d79e0f6e2ea8d63e72a0752bbc75092d795b5cf5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 18 Feb 2016 19:41:35 +0100 Subject: [PATCH 472/976] add tests for hall --- .../integration/hall/GET-hall_heroes.test.js | 29 ++++ .../hall/GET-hall_heroes_heroId.test.js | 56 +++++++ .../integration/hall/GET-hall_patrons.test.js | 60 +++++++ .../hall/PUT-hall_heores_heroId.test.js | 148 ++++++++++++++++++ website/src/controllers/api-v3/hall.js | 99 ++++++------ 5 files changed, 346 insertions(+), 46 deletions(-) create mode 100644 test/api/v3/integration/hall/GET-hall_heroes.test.js create mode 100644 test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js create mode 100644 test/api/v3/integration/hall/GET-hall_patrons.test.js create mode 100644 test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js diff --git a/test/api/v3/integration/hall/GET-hall_heroes.test.js b/test/api/v3/integration/hall/GET-hall_heroes.test.js new file mode 100644 index 0000000000..745bc7739c --- /dev/null +++ b/test/api/v3/integration/hall/GET-hall_heroes.test.js @@ -0,0 +1,29 @@ +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET /hall/heroes', () => { + it('returns all heroes sorted by -contributor.level and with correct fields', async () => { + let nonHero = await generateUser(); + let hero1 = await generateUser({ + contributor: {level: 1}, + }); + let hero2 = await generateUser({ + contributor: {level: 3}, + }); + + let heroes = await nonHero.get('/hall/heroes'); + expect(heroes.length).to.equal(2); + expect(heroes[0]._id).to.equal(hero2._id); + expect(heroes[1]._id).to.equal(hero1._id); + + expect(heroes[0]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); + expect(heroes[1]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); + + expect(heroes[0].profile).to.have.all.keys(['name']); + expect(heroes[1].profile).to.have.all.keys(['name']); + + expect(heroes[0].profile.name).to.equal(hero2.profile.name); + expect(heroes[1].profile.name).to.equal(hero1.profile.name); + }); +}); diff --git a/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js new file mode 100644 index 0000000000..ba2bec1783 --- /dev/null +++ b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js @@ -0,0 +1,56 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /heroes/:heroId', () => { + let user; + + before(async () => { + user = await generateUser({ + contributor: {admin: true}, + }); + }); + + it('requires the caller to be an admin', async () => { + let nonAdmin = await generateUser(); + + await expect(nonAdmin.get(`/hall/heroes/${user._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('noAdminAccess'), + }); + }); + + it('validates req.params.heroId', async () => { + await expect(user.get(`/hall/heroes/invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('handles non-existing heroes', async () => { + let dummyId = generateUUID(); + await expect(user.get(`/hall/heroes/${dummyId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: dummyId}), + }); + }); + + it('returns only necessary hero data', async () => { + let hero = await generateUser({ + contributor: {tier: 23}, + }); + let heroRes = await user.get(`/hall/heroes/${hero._id}`); + + expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'balance', 'profile', 'purchased', + 'contributor', 'auth', 'items', + ]); + expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(heroRes.profile).to.have.all.keys(['name']); + }); +}); diff --git a/test/api/v3/integration/hall/GET-hall_patrons.test.js b/test/api/v3/integration/hall/GET-hall_patrons.test.js new file mode 100644 index 0000000000..17fa3fd58d --- /dev/null +++ b/test/api/v3/integration/hall/GET-hall_patrons.test.js @@ -0,0 +1,60 @@ +import { + generateUser, + translate as t, + resetHabiticaDB, +} from '../../../../helpers/api-v3-integration.helper'; +import { times } from 'lodash'; + +describe('GET /hall/patrons', () => { + let user; + + beforeEach(async () => { + await resetHabiticaDB(); + user = await generateUser(); + }); + + it('fails if req.query.page is not numeric', async () => { + await expect(user.get(`/hall/patrons?page=notNumber`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns all patrons sorted by -backer.tier and with correct fields', async () => { + let patron1 = await generateUser({ + backer: {tier: 1}, + }); + let patron2 = await generateUser({ + backer: {tier: 3}, + }); + + let patrons = await user.get('/hall/patrons'); + expect(patrons.length).to.equal(2); + expect(patrons[0]._id).to.equal(patron2._id); + expect(patrons[1]._id).to.equal(patron1._id); + + expect(patrons[0]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); + expect(patrons[1]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); + + expect(patrons[0].profile).to.have.all.keys(['name']); + expect(patrons[1].profile).to.have.all.keys(['name']); + + expect(patrons[0].profile.name).to.equal(patron2.profile.name); + expect(patrons[1].profile.name).to.equal(patron1.profile.name); + }); + + it('returns only first 50 patrons per request, more if req.query.page is passed', async () => { + await Promise.all(times(53, n => { + return generateUser({backer: {tier: n}}); + })); + + let patrons = await user.get('/hall/patrons'); + expect(patrons.length).to.equal(50); + + let morePatrons = await user.get('/hall/patrons?page=1'); + expect(morePatrons.length).to.equal(2); + expect(morePatrons[0].backer.tier).to.equal(2); + expect(morePatrons[1].backer.tier).to.equal(1); + }); +}); diff --git a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js new file mode 100644 index 0000000000..391d5cdeee --- /dev/null +++ b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js @@ -0,0 +1,148 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('PUT /heroes/:heroId', () => { + let user; + + before(async () => { + user = await generateUser({ + contributor: {admin: true}, + }); + }); + + it('requires the caller to be an admin', async () => { + let nonAdmin = await generateUser(); + + await expect(nonAdmin.put(`/hall/heroes/${user._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('noAdminAccess'), + }); + }); + + it('validates req.params.heroId', async () => { + await expect(user.put(`/hall/heroes/invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('handles non-existing heroes', async () => { + let dummyId = generateUUID(); + await expect(user.put(`/hall/heroes/${dummyId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: dummyId}), + }); + }); + + it('updates contributor level, balance, ads, blocked', async () => { + let hero = await generateUser(); + let heroRes = await user.put(`/hall/heroes/${hero._id}`, { + balance: 3, + contributor: {level: 1}, + purchased: {ads: true}, + auth: {blocked: true}, + }); + + // test response + expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'balance', 'profile', 'purchased', + 'contributor', 'auth', 'items', + ]); + expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(heroRes.profile).to.have.all.keys(['name']); + + // test response values + expect(heroRes.balance).to.equal(3 + 0.75); // 3+0.75 for first contrib level + expect(heroRes.contributor.level).to.equal(1); + expect(heroRes.purchased.ads).to.equal(true); + expect(heroRes.auth.blocked).to.equal(true); + // test hero values + await hero.sync(); + expect(hero.balance).to.equal(3 + 0.75); // 3+0.75 for first contrib level + expect(hero.contributor.level).to.equal(1); + expect(hero.flags.contributor).to.equal(true); + expect(hero.purchased.ads).to.equal(true); + expect(hero.auth.blocked).to.equal(true); + }); + + it('updates contributor level', async () => { + let hero = await generateUser({ + contributor: {level: 5}, + }); + let heroRes = await user.put(`/hall/heroes/${hero._id}`, { + contributor: {level: 6}, + }); + + // test response + expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'balance', 'profile', 'purchased', + 'contributor', 'auth', 'items', + ]); + expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(heroRes.profile).to.have.all.keys(['name']); + + // test response values + expect(heroRes.balance).to.equal(1); // 0+1 for sixth contrib level + expect(heroRes.contributor.level).to.equal(6); + expect(heroRes.items.pets['Dragon-Hydra']).to.equal(5); + // test hero values + await hero.sync(); + expect(hero.balance).to.equal(1); // 0+1 for sixth contrib level + expect(hero.contributor.level).to.equal(6); + expect(hero.flags.contributor).to.equal(true); + expect(hero.items.pets['Dragon-Hydra']).to.equal(5); + }); + + it('updates contributor data', async () => { + let hero = await generateUser({ + contributor: {level: 5}, + }); + let heroRes = await user.put(`/hall/heroes/${hero._id}`, { + contributor: {text: 'Astronaut'}, + }); + + // test response + expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'balance', 'profile', 'purchased', + 'contributor', 'auth', 'items', + ]); + expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(heroRes.profile).to.have.all.keys(['name']); + + // test response values + expect(heroRes.contributor.level).to.equal(5); // doesn't modify previous values + expect(heroRes.contributor.text).to.equal('Astronaut'); + // test hero values + await hero.sync(); + expect(hero.contributor.level).to.equal(5); // doesn't modify previous values + expect(hero.contributor.text).to.equal('Astronaut'); + }); + + it('updates items', async () => { + let hero = await generateUser(); + let heroRes = await user.put(`/hall/heroes/${hero._id}`, { + itemPath: 'items.special.snowball', + itemVal: 5, + }); + + // test response + expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'balance', 'profile', 'purchased', + 'contributor', 'auth', 'items', + ]); + expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(heroRes.profile).to.have.all.keys(['name']); + + // test response values + expect(heroRes.items.special.snowball).to.equal(5); + // test hero values + await hero.sync(); + expect(hero.items.special.snowball).to.equal(5); + }); +}); diff --git a/website/src/controllers/api-v3/hall.js b/website/src/controllers/api-v3/hall.js index c1afb806aa..57454c038d 100644 --- a/website/src/controllers/api-v3/hall.js +++ b/website/src/controllers/api-v3/hall.js @@ -9,6 +9,44 @@ import _ from 'lodash'; let api = {}; +/** + * @api {get} /hall/patrons Get all Patrons. Only the first 50 patrons are returned. More can be accessed passing ?page=n. + * @apiVersion 3.0.0 + * @apiName GetPatrons + * @apiGroup Hall + * + * @apiParam {Number} page The result page. Default is 0 + * + * @apiSuccess {Array} patron An array of patrons + */ +api.getPatrons = { + method: 'GET', + url: '/hall/patrons', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('page', res.t('pageMustBeNumber')).optional().isNumeric(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let page = req.query.page ? Number(req.query.page) : 0; + const perPage = 50; + + let patrons = await User + .find({ + 'backer.tier': {$gt: 0}, + }) + .select('contributor backer profile.name') + .sort('-backer.tier') + .skip(page * perPage) + .limit(perPage) + .lean() + .exec(); + + res.respond(200, patrons); + }, +}; + /** * @api {get} /hall/heroes Get all Heroes * @apiVersion 3.0.0 @@ -26,7 +64,7 @@ api.getHeroes = { .find({ 'contributor.level': {$gt: 0}, }) - .select('contributor backer balance profile.name') + .select('contributor backer profile.name') .sort('-contributor.level') .lean() .exec(); @@ -39,7 +77,7 @@ api.getHeroes = { // they can be used by admins to get/update any user // TODO rename? -const heroAdminFields = 'contributor balance profile.name purchased items auth.local.username auth'; +const heroAdminFields = 'contributor balance profile.name purchased items auth'; /** * @api {get} /hall/heroes/:heroId Get an hero given his _id. Must be an admin to make this request @@ -69,11 +107,14 @@ api.getHero = { let hero = await User .findById(heroId) .select(heroAdminFields) - .lean() .exec(); if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId})); - res.respond(200, hero); + let heroRes = hero.toJSON({minimize: true}); + // supply to the possible absence of hero.contributor + // if we didn't pass minimize: true it would have returned all fields as empty + if (!heroRes.contributor) heroRes.contributor = {}; + res.respond(200, heroRes); }, }; @@ -112,7 +153,7 @@ api.updateHero = { if (updateData.balance) hero.balance = updateData.balance; // give them gems if they got an higher level - let newTier = updateData.contributor.level; // tier = level in this context + let newTier = updateData.contributor && updateData.contributor.level; // tier = level in this context let oldTier = hero.contributor && hero.contributor.level || 0; if (newTier > oldTier) { hero.flags.contributor = true; @@ -124,8 +165,9 @@ api.updateHero = { } } - if (updateData.contributor) hero.contributor = updateData.contributor; + if (updateData.contributor) _.assign(hero.contributor, updateData.contributor); if (updateData.purchased && updateData.purchased.ads) hero.purchased.ads = updateData.purchased.ads; + // give them the Dragon Hydra pet if they're above level 6 if (hero.contributor.level >= 6) hero.items.pets['Dragon-Hydra'] = 5; if (updateData.itemPath && updateData.itemVal && @@ -133,53 +175,18 @@ api.updateHero = { User.schema.paths[updateData.itemPath]) { _.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary) TODO review } + if (updateData.auth && _.isBoolean(updateData.auth.blocked)) hero.auth.blocked = updateData.auth.blocked; let savedHero = await hero.save(); - let responseHero = {}; // only respond with important fields - heroAdminFields.split().forEach(field => { - _.set(responseHero, field, _.get(savedHero, field)); + let heroJSON = savedHero.toJSON(); + let responseHero = {_id: heroJSON._id}; // only respond with important fields + heroAdminFields.split(' ').forEach(field => { + _.set(responseHero, field, _.get(heroJSON, field)); }); res.respond(200, responseHero); }, }; -/** - * @api {get} /hall/patrons Get all Patrons. Only the first 50 patrons are returned. More can be accessed passing ?page=n. - * @apiVersion 3.0.0 - * @apiName GetPatrons - * @apiGroup Hall - * - * @apiParam {Number} page The result page. Default is 0 - * - * @apiSuccess {Array} patron An array of patrons - */ -api.getPatrons = { - method: 'GET', - url: '/hall/patrons', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - req.checkQuery('page', res.t('pageMustBeNumber')).isNumeric(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let page = req.query.page || 0; - const perPage = 50; - - let patrons = await User - .find({ - 'backer.tier': {$gt: 0}, - }) - .select('contributor backer profile.name') - .sort('-backer.tier') - .skip(page * perPage) - .limit(perPage) - .exec(); - - res.respond(200, patrons); - }, -}; - export default api; From a6db8e693ff23c968d90b29c4750956da6832480 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 19 Feb 2016 12:13:48 +0100 Subject: [PATCH 473/976] refactor dataexport controller --- package.json | 6 +- .../controllers/{ => api-v2}/dataexport.js | 15 -- website/src/controllers/api-v3/dataexport.js | 225 ++++++++++++++++++ website/src/libs/api-v3/webhook.js | 2 +- website/src/routes/dataexport.js | 2 +- 5 files changed, 230 insertions(+), 20 deletions(-) rename website/src/controllers/{ => api-v2}/dataexport.js (91%) create mode 100644 website/src/controllers/api-v3/dataexport.js diff --git a/package.json b/package.json index a068f199e1..dc268a8f77 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "firebase": "^2.2.9", "firebase-token-generator": "^2.0.0", "glob": "^4.3.5", + "got": "^6.1.1", "grunt": "~0.4.1", "grunt-cli": "~0.1.9", "grunt-contrib-clean": "~0.6.0", @@ -52,7 +53,7 @@ "image-size": "~0.3.2", "in-app-purchase": "^0.2.0", "jade": "~1.11.0", - "js2xmlparser": "~0.1.2", + "js2xmlparser": "~1.0.0", "lodash": "^3.10.1", "loggly": "~1.0.8", "marked": "^0.3.5", @@ -67,7 +68,7 @@ "nib": "~1.0.1", "nodemailer": "^1.9.0", "object-path": "^0.9.2", - "pageres": "^1.0.1", + "pageres": "^4.1.0", "passport": "~0.2.1", "passport-facebook": "2.0.0", "paypal-ipn": "2.1.0", @@ -77,7 +78,6 @@ "push-notify": "^1.1.1", "q": "^1.4.1", "request": "~2.44.0", - "s3-upload-stream": "^1.0.6", "serve-favicon": "^2.3.0", "stripe": "*", "superagent": "~1.4.0", diff --git a/website/src/controllers/dataexport.js b/website/src/controllers/api-v2/dataexport.js similarity index 91% rename from website/src/controllers/dataexport.js rename to website/src/controllers/api-v2/dataexport.js index 1c88d2d90f..ad8a998dd2 100644 --- a/website/src/controllers/dataexport.js +++ b/website/src/controllers/api-v2/dataexport.js @@ -22,21 +22,6 @@ var request = require('request'); ------------------------------------------------------------------------ */ -dataexport.history = function(req, res) { - var user = res.locals.user; - var output = [ - ["Task Name", "Task ID", "Task Type", "Date", "Value"] - ]; - _.each(user.tasks, function(task) { - _.each(task.history, function(history) { - output.push( - [task.text, task.id, task.type, moment(history.date).format("MM-DD-YYYY HH:mm:ss"), history.value] - ); - }); - }); - return res.csv(output); -} - var userdata = function(user) { if(user.auth && user.auth.local) { delete user.auth.local.salt; diff --git a/website/src/controllers/api-v3/dataexport.js b/website/src/controllers/api-v3/dataexport.js new file mode 100644 index 0000000000..3fd3399ff2 --- /dev/null +++ b/website/src/controllers/api-v3/dataexport.js @@ -0,0 +1,225 @@ +import { authWithSession } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { model as User } from '../../models/user'; +import * as Tasks from '../../models/task'; +import { + NotFound, +} from '../../libs/api-v3/errors'; +import _ from 'lodash'; +import csvStringify from '../../libs/api-v3/csvStringify'; +import moment from 'moment'; +import js2xml from 'js2xmlparser'; +import Pageres from 'pageres'; +import AWS from 'aws-sdk'; +import nconf from 'nconf'; +import got from 'got'; +import Q from 'q'; + +let S3 = new AWS.S3({ + accessKeyId: nconf.get('S3:accessKeyId'), + secretAccessKey: nconf.get('S3:secretAccessKey'), +}); +const S3_BUCKET = nconf.get('S3:bucket'); + +const BASE_URL = nconf.get('BASE_URL'); + +let api = {}; + +/** + * @api {get} /export/history.csv Export user tasks history in CSV format. History is only available for habits and dailys so todos and rewards won't be included + * @apiVersion 3.0.0 + * @apiName ExportUserHistory + * @apiGroup DataExport + * + * @apiSuccess {string} A cvs file + */ +api.exportUserHistory = { + method: 'GET', + url: '/export/history.csv', + middlewares: [authWithSession(), cron], + async handler (req, res) { + let user = res.locals.user; + + let tasks = await Tasks.Task.find({ + userId: user._id, + type: {$in: ['habit', 'daily']}, + }).exec(); + + let output = [ + ['Task Name', 'Task ID', 'Task Type', 'Date', 'Value'], + ]; + + tasks.forEach(task => { + task.history.forEach(history => { + output.push([ + task.text, + task._id, + task.type, + moment(history.date).format('YYYY-MM-DD HH:mm:ss'), + history.value, + ]); + }); + }); + + res.set({ + 'Content-Type': 'text/csv', + 'Content-disposition': `attachment; filename=habitica-tasks-history.csv`, + }); + + let csvRes = await csvStringify(output); + res.status(200).send(csvRes); + }, +}; + +// Convert user to json and attach tasks divided by type +// at user.tasks[`${taskType}s`] (user.tasks.{dailys/habits/...}) +async function _getUserDataForExport (user) { + let userData = user.toJSON(); + userData.tasks = {}; + + let tasks = await Tasks.Task.find({ + userId: user._id, + }).exec(); + + tasks = _.chain(tasks) + .map(task => task.toJSON()) + .groupBy(task => task.type) + .each((tasksPerType, taskType) => { + userData.tasks[`${taskType}s`] = tasksPerType; + }) + .value(); + + return userData; +} + +// TODO export tasks too +/** + * @api {get} /export/userdata.json Export user data in JSON format. + * @apiVersion 3.0.0 + * @apiName ExportUserDataJson + * @apiGroup DataExport + * + * @apiSuccess {string} A json file + */ +api.exportUserDataJson = { + method: 'GET', + url: '/export/userdata.json', + middlewares: [authWithSession(), cron], + async handler (req, res) { + let userData = await _getUserDataForExport(res.locals.user); + + res.set({ + 'Content-Type': 'application/json', + 'Content-disposition': `attachment; filename=habitica-user-data.json`, + }); + let jsonRes = JSON.stringify(userData); + + res.status(200).send(jsonRes); + }, +}; + +/** + * @api {get} /export/userdata.xml Export user data in XML format + * @apiVersion 3.0.0 + * @apiName ExportUserDataXml + * @apiGroup DataExport + * + * @apiSuccess {string} A xml file + */ +api.exportUserDataXml = { + method: 'GET', + url: '/export/userdata.xml', + middlewares: [authWithSession(), cron], + async handler (req, res) { + let userData = await _getUserDataForExport(res.locals.user); + + res.set({ + 'Content-Type': 'text/xml', + 'Content-disposition': `attachment; filename=habitica-user-data.xml`, + }); + res.status(200).send(js2xml('user', userData)); + }, +}; + +/** + * @api {get} /export/avatar-:uuid.html Render a user avatar as an HTML page + * @apiVersion 3.0.0 + * @apiName ExportUserAvatarHtml + * @apiGroup DataExport + * + * @apiSuccess {string} An html page + */ +api.exportUserAvatarHtml = { + method: 'GET', + url: '/export/avatar-:memberId.html', + async handler (req, res) { + req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let memberId = req.params.memberId; + let member = await User + .findById(memberId) + .select('stats profile items achievements preferences backer contributor') + .exec(); + + if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); + res.render('avatar-static', { + title: member.profile.name, + env: _.defaults({member}, res.locals.habitrpg), // TODO review once static pages are done + }); + }, +}; + +/** + * @api {get} /export/avatar-:uuid.html Export a user avatar as a PNG file + * @apiVersion 3.0.0 + * @apiName ExportUserAvatarPng + * @apiGroup DataExport + * + * @apiSuccess {string} A png file + */ +api.exportUserAvatarPng = { + method: 'GET', + url: '/export/avatar-:memberId.png', + async handler (req, res) { + req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let memberId = req.params.memberId; + + let filename = `avatars/${memberId}/.png`; + let s3url = `https://${S3_BUCKET}+'.s3.amazonaws.com/${filename}`; + let response = await got.head(s3url); + + // cache images for 10 minutes on aws, else upload a new one + if (response.statusCode === 200 && moment().diff(response.headers['last-modified'], 'minutes') < 10) { + return res.redirect(301, s3url); + } + + let [stream] = await new Pageres() + .src(`${BASE_URL}/export/avatar-${memberId}.html`, ['140x147'], { + crop: true, + filename: filename.replace('.png', ''), + }) + .run(); + + let s3upload = S3.upload({ + Bucket: S3_BUCKET, + Key: filename, + ACL: 'public-read', + StorageClass: 'REDUCED_REDUNDANCY', + ContentType: 'image/png', + Expires: moment().add({minutes: 3}), + Body: stream, + }); + + let s3res = await Q.ninvoke(s3upload, 'send'); + res.redirect(s3res.Location); + }, +}; + +export default api; diff --git a/website/src/libs/api-v3/webhook.js b/website/src/libs/api-v3/webhook.js index f1c6f13df2..20d814ecb0 100644 --- a/website/src/libs/api-v3/webhook.js +++ b/website/src/libs/api-v3/webhook.js @@ -7,7 +7,7 @@ let _sendWebhook = (url, body) => { url, body, json: true, - }); + }); // TODO use promises and handle errors }; let _isInvalidWebhook = (hook) => { diff --git a/website/src/routes/dataexport.js b/website/src/routes/dataexport.js index d7328434a0..b267005d24 100644 --- a/website/src/routes/dataexport.js +++ b/website/src/routes/dataexport.js @@ -1,6 +1,6 @@ var express = require('express'); var router = new express.Router(); -var dataexport = require('../controllers/dataexport'); +var dataexport = require('../controllers/api-v2/dataexport'); var auth = require('../controllers/api-v2/auth'); var nconf = require('nconf'); var i18n = require('../libs/api-v2/i18n'); From 192c57fa8569d13bb82d7eea0034f5b4271a8020 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 19 Feb 2016 12:16:09 +0100 Subject: [PATCH 474/976] re-add removed code for dataexport.history --- website/src/controllers/api-v2/dataexport.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/website/src/controllers/api-v2/dataexport.js b/website/src/controllers/api-v2/dataexport.js index ad8a998dd2..1c88d2d90f 100644 --- a/website/src/controllers/api-v2/dataexport.js +++ b/website/src/controllers/api-v2/dataexport.js @@ -22,6 +22,21 @@ var request = require('request'); ------------------------------------------------------------------------ */ +dataexport.history = function(req, res) { + var user = res.locals.user; + var output = [ + ["Task Name", "Task ID", "Task Type", "Date", "Value"] + ]; + _.each(user.tasks, function(task) { + _.each(task.history, function(history) { + output.push( + [task.text, task.id, task.type, moment(history.date).format("MM-DD-YYYY HH:mm:ss"), history.value] + ); + }); + }); + return res.csv(output); +} + var userdata = function(user) { if(user.auth && user.auth.local) { delete user.auth.local.salt; From feadfbcd9e2ec03153384c4b2bb1f04d89891adb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 19 Feb 2016 12:23:53 +0100 Subject: [PATCH 475/976] fix case when req.session is undefined and use correct syntax for authWithSession --- website/src/controllers/api-v3/dataexport.js | 6 +++--- website/src/middlewares/api-v3/auth.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/website/src/controllers/api-v3/dataexport.js b/website/src/controllers/api-v3/dataexport.js index 3fd3399ff2..ab835e7c78 100644 --- a/website/src/controllers/api-v3/dataexport.js +++ b/website/src/controllers/api-v3/dataexport.js @@ -36,7 +36,7 @@ let api = {}; api.exportUserHistory = { method: 'GET', url: '/export/history.csv', - middlewares: [authWithSession(), cron], + middlewares: [authWithSession, cron], async handler (req, res) { let user = res.locals.user; @@ -104,7 +104,7 @@ async function _getUserDataForExport (user) { api.exportUserDataJson = { method: 'GET', url: '/export/userdata.json', - middlewares: [authWithSession(), cron], + middlewares: [authWithSession, cron], async handler (req, res) { let userData = await _getUserDataForExport(res.locals.user); @@ -129,7 +129,7 @@ api.exportUserDataJson = { api.exportUserDataXml = { method: 'GET', url: '/export/userdata.xml', - middlewares: [authWithSession(), cron], + middlewares: [authWithSession, cron], async handler (req, res) { let userData = await _getUserDataForExport(res.locals.user); diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 6c1991f460..7ce92e6a3b 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -41,7 +41,7 @@ export function authWithHeaders (optional = false) { // Authenticate a request through a valid session // TODO should use json web token export function authWithSession (req, res, next) { - let userId = req.session.userId; + let userId = req.session && req.session.userId; if (!userId) return next(new NotAuthorized(i18n.t('invalidCredentials'))); From cc31d266e4f1336851c7908e8b1610852d256a74 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 19 Feb 2016 18:42:03 +0100 Subject: [PATCH 476/976] port locals middleware, add some tests and a lot of fixes --- .../GET-export_avatar-memberId.html.test.js | 35 +++++++++ .../GET-export_avatar-memberId.png.test.js | 3 + .../dataexport/GET-export_history.csv.test.js | 3 + .../GET-export_userdata.json.test.js | 3 + .../GET-export_userdata.xml.test.js | 3 + website/public/js/controllers/footerCtrl.js | 4 +- website/public/js/controllers/settingsCtrl.js | 2 +- website/src/controllers/api-v3/dataexport.js | 4 + website/src/controllers/api-v3/members.js | 2 +- website/src/libs/api-v3/i18n.js | 6 +- .../src/middlewares/{ => api-v2}/locals.js | 0 website/src/middlewares/api-v3/index.js | 2 + website/src/middlewares/api-v3/locals.js | 73 +++++++++++++++++++ website/src/models/group.js | 2 +- website/src/routes/dataexport.js | 25 +++++-- website/src/server.js | 5 +- website/views/avatar-static.jade | 2 +- website/views/options/settings.jade | 2 +- 18 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js create mode 100644 test/api/v3/integration/dataexport/GET-export_avatar-memberId.png.test.js create mode 100644 test/api/v3/integration/dataexport/GET-export_history.csv.test.js create mode 100644 test/api/v3/integration/dataexport/GET-export_userdata.json.test.js create mode 100644 test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js rename website/src/middlewares/{ => api-v2}/locals.js (100%) create mode 100644 website/src/middlewares/api-v3/locals.js diff --git a/test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js b/test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js new file mode 100644 index 0000000000..e3bf32c1b6 --- /dev/null +++ b/test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js @@ -0,0 +1,35 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /export/avatar-:memberId.html', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + it('validates req.params.memberId', async () => { + await expect(user.get(`/export/avatar-:memberId.html`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('handles non-existing members', async () => { + let dummyId = generateUUID(); + await expect(user.get(`/export/avatar-${dummyId}.html`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: dummyId}), + }); + }); + + it('returns an html page', async () => { + let res = await user.get(`/export/avatar-${user._id}.html`); + expect(res.substring(0, 100).indexOf('')).to.equal(0); + }); +}); diff --git a/test/api/v3/integration/dataexport/GET-export_avatar-memberId.png.test.js b/test/api/v3/integration/dataexport/GET-export_avatar-memberId.png.test.js new file mode 100644 index 0000000000..a46ed695c6 --- /dev/null +++ b/test/api/v3/integration/dataexport/GET-export_avatar-memberId.png.test.js @@ -0,0 +1,3 @@ +// TODO how to test this route since it points to a file on AWS s3? + +describe('GET /export/avatar-:memberId.png', () => {}); diff --git a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js new file mode 100644 index 0000000000..93b1e09e11 --- /dev/null +++ b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js @@ -0,0 +1,3 @@ +// TODO how to test this route since it uses session authentication? + +describe('GET /export/history.csv', () => {}); diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js new file mode 100644 index 0000000000..8eb6466916 --- /dev/null +++ b/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js @@ -0,0 +1,3 @@ +// TODO how to test this route since it uses session authentication? + +describe('GET /export/userdata.json', () => {}); diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js new file mode 100644 index 0000000000..5534c6f625 --- /dev/null +++ b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js @@ -0,0 +1,3 @@ +// TODO how to test this route since it uses session authentication? + +describe('GET /export/userdata.xml', () => {}); diff --git a/website/public/js/controllers/footerCtrl.js b/website/public/js/controllers/footerCtrl.js index cd0b9ac182..42fd279944 100644 --- a/website/public/js/controllers/footerCtrl.js +++ b/website/public/js/controllers/footerCtrl.js @@ -7,8 +7,8 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { $scope.loadWidgets = Social.loadWidgets; if(env.isStaticPage){ - $scope.languages = env.avalaibleLanguages; - $scope.selectedLanguage = _.find(env.avalaibleLanguages, {code: env.language.code}); + $scope.languages = env.availableLanguages; + $scope.selectedLanguage = _.find(env.availableLanguages, {code: env.language.code}); $rootScope.selectedLanguage = $scope.selectedLanguage; diff --git a/website/public/js/controllers/settingsCtrl.js b/website/public/js/controllers/settingsCtrl.js index 3672a4d703..24fb8a03dc 100644 --- a/website/public/js/controllers/settingsCtrl.js +++ b/website/public/js/controllers/settingsCtrl.js @@ -84,7 +84,7 @@ habitrpg.controller('SettingsCtrl', }; $scope.language = window.env.language; - $scope.avalaibleLanguages = window.env.avalaibleLanguages; + $scope.availableLanguages = window.env.availableLanguages; $scope.changeLanguage = function(){ $rootScope.$on('userSynced', function(){ diff --git a/website/src/controllers/api-v3/dataexport.js b/website/src/controllers/api-v3/dataexport.js index ab835e7c78..dca4e5eae3 100644 --- a/website/src/controllers/api-v3/dataexport.js +++ b/website/src/controllers/api-v3/dataexport.js @@ -14,6 +14,7 @@ import AWS from 'aws-sdk'; import nconf from 'nconf'; import got from 'got'; import Q from 'q'; +import locals from '../../middlewares/api-v3/locals'; let S3 = new AWS.S3({ accessKeyId: nconf.get('S3:accessKeyId'), @@ -25,6 +26,8 @@ const BASE_URL = nconf.get('BASE_URL'); let api = {}; +// TODO move these routes out of the /api/v3/export namespace to the top level /export + /** * @api {get} /export/history.csv Export user tasks history in CSV format. History is only available for habits and dailys so todos and rewards won't be included * @apiVersion 3.0.0 @@ -152,6 +155,7 @@ api.exportUserDataXml = { api.exportUserAvatarHtml = { method: 'GET', url: '/export/avatar-:memberId.html', + middlewares: [locals], async handler (req, res) { req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index de2213d3b6..85449ecc79 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -131,7 +131,7 @@ function _getMembersForItem (type) { * * @apiParam {UUID} groupId The group id * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results - * @apiParam {boolean} includeAllPublicFields Query parameter avalaible only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member) + * @apiParam {boolean} includeAllPublicFields Query parameter available only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member) * * @apiSuccess {array} members An array of members, sorted by _id */ diff --git a/website/src/libs/api-v3/i18n.js b/website/src/libs/api-v3/i18n.js index ed2134f7bf..acb9751082 100644 --- a/website/src/libs/api-v3/i18n.js +++ b/website/src/libs/api-v3/i18n.js @@ -49,7 +49,7 @@ shared.i18n.translations = translations; export let langCodes = Object.keys(translations); -export let avalaibleLanguages = langCodes.map((langCode) => { +export let availableLanguages = langCodes.map((langCode) => { return { code: langCode, name: translations[langCode].languageName, @@ -57,7 +57,7 @@ export let avalaibleLanguages = langCodes.map((langCode) => { }); langCodes.forEach((code) => { - let lang = _.find(avalaibleLanguages, {code}); + let lang = _.find(availableLanguages, {code}); lang.momentLangCode = momentLangsMapping[code] || code; @@ -110,7 +110,7 @@ export let multipleVersionsLanguages = { // TODO review if this can be removed since the old mobile app is no longer active // stringName and vars are the allowed parameters export function enTranslations (...args) { - let language = _.find(avalaibleLanguages, {code: 'en'}); + let language = _.find(availableLanguages, {code: 'en'}); // language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined); args.push(language.code); diff --git a/website/src/middlewares/locals.js b/website/src/middlewares/api-v2/locals.js similarity index 100% rename from website/src/middlewares/locals.js rename to website/src/middlewares/api-v2/locals.js diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 01da4d4004..0834ed17fa 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -27,6 +27,8 @@ export default function attachMiddlewares (app) { app.use(setupBody); app.use(responseHandler); app.use(getUserLanguage); + app.set('view engine', 'jade'); + app.set('views', `${__dirname}/../../../views`); app.use('/api/v3', routes); app.use(notFoundHandler); diff --git a/website/src/middlewares/api-v3/locals.js b/website/src/middlewares/api-v3/locals.js new file mode 100644 index 0000000000..ccb4fc003b --- /dev/null +++ b/website/src/middlewares/api-v3/locals.js @@ -0,0 +1,73 @@ +import nconf from 'nconf'; +import _ from 'lodash'; +import shared from '../../../../common'; +import * as i18n from '../../libs/api-v3/i18n'; +import { + getBuildUrl, + getManifestFiles, +} from '../../libs/api-v3/buildManifest'; +import forceRefresh from './../forceRefresh'; +import { tavernQuest } from '../../models/group'; +import { mods } from '../../models/user'; +import { decrypt } from '../../libs/api-v3/encryption'; + +// To avoid stringifying more data then we need, +// items from `env` used on the client will have to be specified in this array +// TODO where is this used? +const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations', + 'FACEBOOK_KEY', 'NODE_ENV', 'BASE_URL', 'GA_ID', + 'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY', + 'worldDmg', 'mods', 'IS_MOBILE']; + +let env = { + getManifestFiles, + getBuildUrl, + _, + clientVars: CLIENT_VARS, + mods, + Content: shared.content, + siteVersion: forceRefresh.siteVersion, + availableLanguages: i18n.available, + AMAZON_PAYMENTS: { + SELLER_ID: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), + CLIENT_ID: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), + }, +}; + +'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY'.split(' ').forEach(key => { + env[key] = nconf.get(key); +}); + +export default function locals (req, res, next) { + let language = _.find(i18n.availableLanguages, {code: req.language}); + let isStaticPage = req.url.split('/')[1] === 'static'; // If url contains '/static/' + + // Load moment.js language file only when not on static pages + language.momentLang = !isStaticPage && i18n.momentLangs[language.code] || undefined; + + res.locals.habitrpg = _.assign(env, { + IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')), + language, + isStaticPage, + translations: i18n.translations[language.code], + t (...args) { // stringName and vars are the allowed parameters + args.push(language.code); + return shared.i18n.t(...args); + }, + // Defined here and not outside of the middleware because tavernQuest might be an + // empty object until the query to fetch it finishes + worldDmg: tavernQuest && tavernQuest.extra && tavernQuest.extra.worldDmg || {}, + }); + + // Put query-string party (& guild but use partyInvite for backward compatibility) + // invitations into session to be handled later + if (req.query.partyInvite) { + try { + req.session.partyInvite = JSON.parse(decrypt(req.query.partyInvite)); + } catch (e) { + // TODO logs + } + } + + next(); +} diff --git a/website/src/models/group.js b/website/src/models/group.js index ab63e813ac..4327a23f4f 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -489,7 +489,7 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { export let tavernQuest = {}; let tavernQ = {_id: 'habitrpg', 'quest.key': {$ne: null}}; -// we use process.nextTick because at this point the model is not yet avalaible +// we use process.nextTick because at this point the model is not yet available process.nextTick(() => { model // eslint-disable-line no-use-before-define .findOne(tavernQ).exec() diff --git a/website/src/routes/dataexport.js b/website/src/routes/dataexport.js index b267005d24..3586df9673 100644 --- a/website/src/routes/dataexport.js +++ b/website/src/routes/dataexport.js @@ -6,11 +6,24 @@ var nconf = require('nconf'); var i18n = require('../libs/api-v2/i18n'); var locals = require('../middlewares/locals'); -/* Data export */ -router.get('/history.csv',auth.authWithSession,i18n.getUserLanguage,dataexport.history); //[todo] encode data output options in the data controller and use these to build routes -router.get('/userdata.xml',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.xml); -router.get('/userdata.json',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.json); -router.get('/avatar-:uuid.html', i18n.getUserLanguage, locals, dataexport.avatarPage); -router.get('/avatar-:uuid.png', i18n.getUserLanguage, locals, dataexport.avatarImage); +const BASE_URL = nconf.get('BASE_URL'); + +/* Data export deprecated routes */ +// TODO remove once api v2 is taken down +router.get('/history.csv', (req, res) => { + res.redirect(`${BASE_URL}/api/v3/export/history.csv`); +}); +router.get('/userdata.xml', (req, res) => { + res.redirect(`${BASE_URL}/api/v3/export/userdata.xml`); +}); +router.get('/userdata.json', (req, res) => { + res.redirect(`${BASE_URL}/api/v3/export/userdata.json`); +}); +router.get('/avatar-:uuid.html', (req, res) => { + res.redirect(`${BASE_URL}/api/v3/export/avatar-${req.params.uuid}.html`); +}); +router.get('/avatar-:uuid.png', (req, res) => { + res.redirect(`${BASE_URL}/api/v3/export/avatar-${req.params.uuid}.png`); +}); module.exports = router; diff --git a/website/src/server.js b/website/src/server.js index d5b9176472..3f4de12bdf 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -61,7 +61,6 @@ let FacebookStrategy = passportFacebook.Strategy; // have a database of user records, the complete Facebook profile is serialized // and deserialized. passport.serializeUser((user, done) => done(null, user)); - passport.deserializeUser((obj, done) => done(null, obj)); // FIXME @@ -91,7 +90,9 @@ app.all(/^(?!\/api\/v3).+/i, oldApp); // Matches all requests going to /api/v3 app.all('/api/*', newApp); -// Mount middlewares for the new app +// TODO change ^ so that all routes except those marked explictly with api/v2 goes to oldApp + +// Mount middlewares for the new app (api v3) attachMiddlewares(newApp); /* OLD APP IS DISABLED UNTIL COMPATIBLE WITH NEW MODELS diff --git a/website/views/avatar-static.jade b/website/views/avatar-static.jade index 9a3532f726..58e11ae31a 100644 --- a/website/views/avatar-static.jade +++ b/website/views/avatar-static.jade @@ -9,7 +9,7 @@ html(ng-app="habitrpg") meta(name='apple-mobile-web-app-capable', content='yes') // .slice(0).push('user') is to clone the array, - // to be surethat `user` is never avalaible to other requests' env + // to be surethat `user` is never available to other requests' env // TODO does it need only `user` in clientVars, not the others? - clientVars = env.clientVars.slice(0); diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index aad9b8b476..5c27572810 100644 --- a/website/views/options/settings.jade +++ b/website/views/options/settings.jade @@ -32,7 +32,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') .form-horizontal h5=env.t('language') - select.form-control(ng-model='language.code', ng-options='lang.code as lang.name for lang in avalaibleLanguages', ng-change='changeLanguage()') + select.form-control(ng-model='language.code', ng-options='lang.code as lang.name for lang in availableLanguages', ng-change='changeLanguage()') small !=env.t('americanEnglishGovern') br From 130110fed22097999e4d7c6b3576b644b3febe68 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 19 Feb 2016 19:44:43 +0100 Subject: [PATCH 477/976] use correct path for v2 locals middleware --- website/src/routes/pages.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/routes/pages.js b/website/src/routes/pages.js index f6553a7449..cc18798619 100644 --- a/website/src/routes/pages.js +++ b/website/src/routes/pages.js @@ -2,7 +2,7 @@ var nconf = require('nconf'); var express = require('express'); var router = new express.Router(); var _ = require('lodash'); -var locals = require('../middlewares/locals'); +var locals = require('../middlewares/api-v2/locals'); var i18n = require('../libs/api-v2/i18n'); const TOTAL_USER_COUNT = '1,000,000'; From fb343f8feb6ec4e7a663a740948739241a3c7c42 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 19 Feb 2016 20:34:55 +0100 Subject: [PATCH 478/976] port push notifications in api v3 --- website/src/controllers/api-v2/challenges.js | 2 +- website/src/controllers/api-v2/groups.js | 2 +- website/src/controllers/api-v2/members.js | 2 +- .../src/controllers/{ => api-v2}/pushNotifications.js | 0 website/src/controllers/api-v3/challenges.js | 4 ++-- website/src/controllers/api-v3/groups.js | 9 ++++++++- website/src/controllers/api-v3/quests.js | 11 ++++++++++- website/src/controllers/payments/index.js | 2 +- website/src/libs/api-v3/pushNotifications.js | 5 +++-- website/src/models/group.js | 10 +++++++--- 10 files changed, 34 insertions(+), 13 deletions(-) rename website/src/controllers/{ => api-v2}/pushNotifications.js (100%) diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 89d68495d6..f5a2a4a882 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -11,7 +11,7 @@ var logging = require('./../../libs/api-v2/logging'); var csv = require('express-csv'); var utils = require('../../libs/api-v2/utils'); var api = module.exports; -var pushNotify = require('./../pushNotifications'); +var pushNotify = require('./pushNotifications'); /* ------------------------------------------------------------------------ diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index 5f840c4330..064d786937 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -17,7 +17,7 @@ var Challenge = require('./../../models/challenge').model; var EmailUnsubscription = require('./../../models/emailUnsubscription').model; var isProd = nconf.get('NODE_ENV') === 'production'; var api = module.exports; -var pushNotify = require('./../pushNotifications'); +var pushNotify = require('./pushNotifications'); var analytics = utils.analytics; var firebase = require('../../libs/api-v2/firebase'); diff --git a/website/src/controllers/api-v2/members.js b/website/src/controllers/api-v2/members.js index 1c3bb448ac..190f36c9b1 100644 --- a/website/src/controllers/api-v2/members.js +++ b/website/src/controllers/api-v2/members.js @@ -7,7 +7,7 @@ var _ = require('lodash'); var shared = require('../../../../common'); var utils = require('../../libs/api-v2/utils'); var nconf = require('nconf'); -var pushNotify = require('./../pushNotifications'); +var pushNotify = require('./pushNotifications'); var fetchMember = function(uuid, restrict){ return function(cb){ diff --git a/website/src/controllers/pushNotifications.js b/website/src/controllers/api-v2/pushNotifications.js similarity index 100% rename from website/src/controllers/pushNotifications.js rename to website/src/controllers/api-v2/pushNotifications.js diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index c239a63dd3..6e6d90a85b 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -17,7 +17,7 @@ import { import shared from '../../../../common'; import * as Tasks from '../../models/task'; import { sendTxn as txnEmail } from '../../libs/api-v3/email'; -import pushNotify from '../../libs/api-v3/pushNotifications'; +import { sendNotification as sendPushNotification } from '../../libs/api-v3/pushNotifications'; import Q from 'q'; import csvStringify from '../../libs/api-v3/csvStringify'; @@ -471,7 +471,7 @@ async function _closeChal (challenge, broken = {}) { ]); } - pushNotify(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate + sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate } // Run some operations in the background withouth blocking the thread diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index d4f19ea2ec..06259fbee3 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -21,7 +21,8 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; - +import common from '../../../../common'; +import { sendNotification as sendPushNotification } from '../../libs/api-v3/pushNotifications'; let api = {}; // TODO shall we accept party as groupId in all routes? @@ -510,6 +511,12 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { sendTxnEmail(userToInvite, `invited-${groupLabel}`, emailVars); } + sendPushNotification( + userToInvite, + common.i18n.t(group.type === 'guild' ? 'invitedGuild' : 'invitedParty'), + group.name + ); + let userInvited = await userToInvite.save(); if (group.type === 'guild') { return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1]; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index e0ad3b86d4..f6861938aa 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -17,6 +17,8 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; +import common from '../../../../common'; +import { sendNotification as sendPushNotification } from '../../libs/api-v3/pushNotifications'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true @@ -62,7 +64,7 @@ api.inviteToQuest = { let members = await User.find({ 'party._id': group._id, _id: {$ne: user._id}, - }).select('auth.facebook auth.local preferences.emailNotifications profile.name') + }).select('auth.facebook auth.local preferences.emailNotifications profile.name pushDevices') .exec(); group.markModified('quest'); @@ -102,6 +104,13 @@ api.inviteToQuest = { // send out invites let inviterVars = getUserInfo(user, ['name', 'email']); let membersToEmail = members.filter(member => { + // send push notifications while filtering members before sending emails + sendPushNotification( + member, + common.i18n.t('questInvitationTitle'), + common.i18n.t('questInvitationInfo', { quest: quest.text() }) + ); + return member.preferences.emailNotifications.invitedQuest !== false; }); sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [ diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js index 074fab2369..86ac9c2047 100644 --- a/website/src/controllers/payments/index.js +++ b/website/src/controllers/payments/index.js @@ -12,7 +12,7 @@ var async = require('async'); var iap = require('./iap'); var mongoose= require('mongoose'); var cc = require('coupon-code'); -var pushNotify = require('./../pushNotifications'); +var pushNotify = require('./../api-v2/pushNotifications'); function revealMysteryItems(user) { _.each(shared.content.gear.flat, function(item) { diff --git a/website/src/libs/api-v3/pushNotifications.js b/website/src/libs/api-v3/pushNotifications.js index fbf5e2666a..f2f7825477 100644 --- a/website/src/libs/api-v3/pushNotifications.js +++ b/website/src/libs/api-v3/pushNotifications.js @@ -25,11 +25,11 @@ if (gcm) { } // TODO test -export default function sendNotify (user, title, message, timeToLive = 15) { +export default function sendNotification (user, title, message, timeToLive = 15) { // TODO need investigation: // https://github.com/HabitRPG/habitrpg/issues/5252 - if (!user) throw new Error('User is required'); + if (!user) throw new Error('User is required.'); _.each(user.pushDevices, pushDevice => { switch (pushDevice.type) { @@ -50,6 +50,7 @@ export default function sendNotify (user, title, message, timeToLive = 15) { break; case 'ios': + // TODO implement break; } }); diff --git a/website/src/models/group.js b/website/src/models/group.js index ab63e813ac..c01f365389 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -15,6 +15,7 @@ import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; import { quests as questScrolls } from '../../../common/script/content'; import Q from 'q'; import nconf from 'nconf'; +import { sendNotification as sendPushNotification } from '../libs/api-v3/pushNotifications'; let Schema = mongoose.Schema; @@ -306,9 +307,12 @@ schema.methods.startQuest = async function startQuest (user) { // send notifications in the background without blocking User.find( { _id: { $in: nonUserQuestMembers } }, - 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications profile.name', - ).exec().then(membersToEmail => { - membersToEmail = _.filter(membersToEmail, (member) => { + 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications pushDevices profile.name', + ).exec().then(membersToNotify => { + let membersToEmail = _.filter(membersToNotify, (member) => { + // send push notifications and filter users that disabled emails + sendPushNotification(user, 'HabitRPG', `${shared.i18n.t('questStarted')}: ${quest.text()}`); + return member.preferences.emailNotifications.questStarted !== false && member._id !== user._id; }); From fbc944a5ccf666d4969dd85201118108830146ff Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 19 Feb 2016 20:59:12 +0100 Subject: [PATCH 479/976] fix push notifications imports --- website/src/controllers/api-v3/challenges.js | 2 +- website/src/controllers/api-v3/groups.js | 2 +- website/src/controllers/api-v3/quests.js | 2 +- website/src/models/group.js | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 6e6d90a85b..5102da8975 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -17,7 +17,7 @@ import { import shared from '../../../../common'; import * as Tasks from '../../models/task'; import { sendTxn as txnEmail } from '../../libs/api-v3/email'; -import { sendNotification as sendPushNotification } from '../../libs/api-v3/pushNotifications'; +import sendPushNotification from '../../libs/api-v3/pushNotifications'; import Q from 'q'; import csvStringify from '../../libs/api-v3/csvStringify'; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 06259fbee3..05925d5730 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -22,7 +22,7 @@ import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; import common from '../../../../common'; -import { sendNotification as sendPushNotification } from '../../libs/api-v3/pushNotifications'; +import sendPushNotification from '../../libs/api-v3/pushNotifications'; let api = {}; // TODO shall we accept party as groupId in all routes? diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index f6861938aa..e7f728c74a 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -18,7 +18,7 @@ import { } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; import common from '../../../../common'; -import { sendNotification as sendPushNotification } from '../../libs/api-v3/pushNotifications'; +import sendPushNotification from '../../libs/api-v3/pushNotifications'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true diff --git a/website/src/models/group.js b/website/src/models/group.js index c01f365389..940cc94802 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -15,7 +15,7 @@ import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; import { quests as questScrolls } from '../../../common/script/content'; import Q from 'q'; import nconf from 'nconf'; -import { sendNotification as sendPushNotification } from '../libs/api-v3/pushNotifications'; +import sendPushNotification from '../libs/api-v3/pushNotifications'; let Schema = mongoose.Schema; @@ -311,7 +311,7 @@ schema.methods.startQuest = async function startQuest (user) { ).exec().then(membersToNotify => { let membersToEmail = _.filter(membersToNotify, (member) => { // send push notifications and filter users that disabled emails - sendPushNotification(user, 'HabitRPG', `${shared.i18n.t('questStarted')}: ${quest.text()}`); + sendPushNotification(member, 'HabitRPG', `${shared.i18n.t('questStarted')}: ${quest.text()}`); return member.preferences.emailNotifications.questStarted !== false && member._id !== user._id; From a6d87aaa9b5c49836613052679d77480d0f4faa6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 20 Feb 2016 10:16:55 +0100 Subject: [PATCH 480/976] add tests for /export/history.csv --- .../dataexport/GET-export_history.csv.test.js | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js index 93b1e09e11..03b0a06832 100644 --- a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js +++ b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js @@ -1,3 +1,48 @@ // TODO how to test this route since it uses session authentication? +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; +import { + updateDocument, +} from '../../../../helpers/mongo'; +import moment from 'moment'; -describe('GET /export/history.csv', () => {}); +describe.only('GET /export/history.csv', () => { + it('should return a valid CSV file with tasks history data', async () => { + let user = await generateUser(); + let tasks = await user.post('/tasks/user', [ + {type: 'habit', text: 'habit 1'}, + {type: 'daily', text: 'daily 1'}, + {type: 'habit', text: 'habit 2'}, + {type: 'todo', text: 'todo 1'}, + ]); + + // score all the tasks twice + await Promise.all(tasks.map(task => { + return user.post(`/tasks/${task._id}/score/up`); + })); + await Promise.all(tasks.map(task => { + return user.post(`/tasks/${task._id}/score/up`); + })); + + // adding an history entry to daily 1 manually because cron didn't run yet + await updateDocument('tasks', tasks[1], { + history: {value: 3.2, date: Number(new Date())}, + }); + + // get updated tasks + tasks = await Promise.all(tasks.map(task => { + return user.get(`/tasks/${task._id}`); + })); + + let res = await user.get(`/export/history.csv`); + let splitRes = res.split('\n'); + expect(splitRes[0]).to.equal('Task Name,Task ID,Task Type,Date,Value'); + expect(splitRes[1]).to.equal(`habit 1,${tasks[0]._id},habit,${moment(tasks[0].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[0].history[0].value}`); + expect(splitRes[2]).to.equal(`habit 1,${tasks[0]._id},habit,${moment(tasks[0].history[1].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[0].history[1].value}`); + expect(splitRes[3]).to.equal(`daily 1,${tasks[1]._id},daily,${moment(tasks[1].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[1].history[0].value}`); + expect(splitRes[4]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[0].value}`); + expect(splitRes[5]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[1].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[1].value}`); + expect(splitRes[6]).to.equal(''); + }); +}); From 2ed2fb5d06910617d49e4c381177b9b461e11a99 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 20 Feb 2016 10:46:11 +0100 Subject: [PATCH 481/976] tests for user data export --- package.json | 3 +- .../dataexport/GET-export_history.csv.test.js | 2 +- .../GET-export_userdata.json.test.js | 29 +++++++++++++- .../GET-export_userdata.xml.test.js | 40 ++++++++++++++++++- 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index dc268a8f77..1768b04c4c 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,8 @@ "superagent-defaults": "^0.1.13", "uuid": "^2.0.1", "vinyl-source-stream": "^1.0.0", - "vinyl-transform": "^1.0.0" + "vinyl-transform": "^1.0.0", + "xml2js": "^0.4.16" }, "apidoc": { "name": "habitica", diff --git a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js index 03b0a06832..f11762332b 100644 --- a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js +++ b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js @@ -7,7 +7,7 @@ import { } from '../../../../helpers/mongo'; import moment from 'moment'; -describe.only('GET /export/history.csv', () => { +describe('GET /export/history.csv', () => { it('should return a valid CSV file with tasks history data', async () => { let user = await generateUser(); let tasks = await user.post('/tasks/user', [ diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js index 8eb6466916..dac3a6fac0 100644 --- a/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js +++ b/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js @@ -1,3 +1,30 @@ // TODO how to test this route since it uses session authentication? +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; -describe('GET /export/userdata.json', () => {}); +describe('GET /export/userdata.json', () => { + it('should return a valid JSON file with user data', async () => { + let user = await generateUser(); + let tasks = await user.post('/tasks/user', [ + {type: 'habit', text: 'habit 1'}, + {type: 'daily', text: 'daily 1'}, + {type: 'reward', text: 'reward 1'}, + {type: 'todo', text: 'todo 1'}, + ]); + + let res = await user.get(`/export/userdata.json`); + expect(res._id).to.equal(user._id); + expect(res).to.contain.all.keys(['tasks', 'flags', 'tasksOrder', 'auth']); + expect(res.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(res.tasks).to.have.all.keys(['dailys', 'habits', 'todos', 'rewards']); + expect(res.tasks.habits.length).to.equal(1); + expect(res.tasks.habits[0]._id).to.equal(tasks[0]._id); + expect(res.tasks.dailys.length).to.equal(1); + expect(res.tasks.dailys[0]._id).to.equal(tasks[1]._id); + expect(res.tasks.rewards.length).to.equal(1); + expect(res.tasks.rewards[0]._id).to.equal(tasks[2]._id); + expect(res.tasks.todos.length).to.equal(2); + expect(res.tasks.todos[1]._id).to.equal(tasks[3]._id); + }); +}); diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js index 5534c6f625..198509e533 100644 --- a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js +++ b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js @@ -1,3 +1,41 @@ // TODO how to test this route since it uses session authentication? +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; +import xml2js from 'xml2js'; +import Q from 'q'; -describe('GET /export/userdata.xml', () => {}); +describe('GET /export/userdata.xml', () => { + it('should return a valid XML file with user data', async () => { + let user = await generateUser(); + let tasks = await user.post('/tasks/user', [ + {type: 'habit', text: 'habit 1'}, + {type: 'daily', text: 'daily 1'}, + {type: 'reward', text: 'reward 1'}, + {type: 'todo', text: 'todo 1'}, + // due to how the xml parser works an array is returned only if there's more than one children + // so we create two tasks for each type + {type: 'habit', text: 'habit 2'}, + {type: 'daily', text: 'daily 2'}, + {type: 'reward', text: 'reward 2'}, + {type: 'todo', text: 'todo 2'}, + + ]); + + let response = await user.get(`/export/userdata.xml`); + let {user: res} = await Q.npost(xml2js, 'parseString', [response, {explicitArray: false}]); + + expect(res._id).to.equal(user._id); + expect(res).to.contain.all.keys(['tasks', 'flags', 'tasksOrder', 'auth']); + expect(res.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(res.tasks).to.have.all.keys(['dailys', 'habits', 'todos', 'rewards']); + expect(res.tasks.habits.length).to.equal(2); + expect(res.tasks.habits[0]._id).to.equal(tasks[0]._id); + expect(res.tasks.dailys.length).to.equal(2); + expect(res.tasks.dailys[0]._id).to.equal(tasks[1]._id); + expect(res.tasks.rewards.length).to.equal(2); + expect(res.tasks.rewards[0]._id).to.equal(tasks[2]._id); + expect(res.tasks.todos.length).to.equal(3); + expect(res.tasks.todos[1]._id).to.equal(tasks[3]._id); + }); +}); From 62ebde318650ee0291ac4a7e92e3f4e3d456242b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 20 Feb 2016 21:02:46 +0100 Subject: [PATCH 482/976] fix tests that require session authentication --- .../dataexport/GET-export_history.csv.test.js | 1 - .../dataexport/GET-export_userdata.json.test.js | 1 - .../dataexport/GET-export_userdata.xml.test.js | 1 - test/helpers/api-integration/requester.js | 16 ++++++++++++++++ website/src/middlewares/api-v3/auth.js | 3 +-- website/src/middlewares/api-v3/index.js | 10 ++++++++++ 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js index f11762332b..8f1caeec7c 100644 --- a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js +++ b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js @@ -1,4 +1,3 @@ -// TODO how to test this route since it uses session authentication? import { generateUser, } from '../../../../helpers/api-v3-integration.helper'; diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js index dac3a6fac0..0701809889 100644 --- a/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js +++ b/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js @@ -1,4 +1,3 @@ -// TODO how to test this route since it uses session authentication? import { generateUser, } from '../../../../helpers/api-v3-integration.helper'; diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js index 198509e533..eb5650a478 100644 --- a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js +++ b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js @@ -1,4 +1,3 @@ -// TODO how to test this route since it uses session authentication? import { generateUser, } from '../../../../helpers/api-v3-integration.helper'; diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index bcf580e653..6fe3460972 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -22,6 +22,10 @@ requester.setApiVersion = (version) => { apiVersion = version; }; +// save the last cookie so that it's resent with every request +// should be safe since every time a user is generated this will be overwritten +let cookie; + function _requestMaker (user, method, additionalSets) { if (!apiVersion) throw new Error('apiVersion not set'); @@ -36,6 +40,11 @@ function _requestMaker (user, method, additionalSets) { .set('x-api-key', user.apiToken); } + // if we previously saved a cookie, send it along the request + if (cookie) { + request.set('Cookie', cookie); + } + if (additionalSets) { request.set(additionalSets); } @@ -52,6 +61,13 @@ function _requestMaker (user, method, additionalSets) { reject(parsedError); } + // if any cookies was sent, save it for the next request + if (response.headers['set-cookie']) { + cookie = response.headers['set-cookie'].map(cookieString => { + return cookieString.split(';')[0]; + }).join('; '); + } + let contentType = response.headers['content-type'] || ''; resolve(contentType.indexOf('json') !== -1 ? response.body : response.text); }); diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 7ce92e6a3b..b3ed6d9485 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -30,7 +30,6 @@ export function authWithHeaders (optional = false) { res.locals.user = user; // TODO use either session/cookie or headers, not both - req.session = req.session || {}; req.session.userId = user._id; next(); }) @@ -41,7 +40,7 @@ export function authWithHeaders (optional = false) { // Authenticate a request through a valid session // TODO should use json web token export function authWithSession (req, res, next) { - let userId = req.session && req.session.userId; + let userId = req.session.userId; if (!userId) return next(new NotAuthorized(i18n.t('invalidCredentials'))); diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 0834ed17fa..dd186c11fb 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -10,10 +10,14 @@ import nconf from 'nconf'; import morgan from 'morgan'; import responseHandler from './response'; import setupBody from './setupBody'; +import cookieSession from 'cookie-session'; const IS_PROD = nconf.get('IS_PROD'); const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); +const SESSION_SECRET = nconf.get('SESSION_SECRET'); +const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; + export default function attachMiddlewares (app) { if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev')); @@ -22,6 +26,12 @@ export default function attachMiddlewares (app) { extended: true, // Uses 'qs' library as old connect middleware })); app.use(bodyParser.json()); + app.use(cookieSession({ + name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies + secret: SESSION_SECRET, + httpOnly: false, // TODO this should be true for security, what about https only? + maxAge: TWO_WEEKS, + })); app.use(expressValidator()); app.use(analytics); app.use(setupBody); From e9ac123d0e034281e07fbabf177bf8e4d3b515c4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 20 Feb 2016 21:14:11 +0100 Subject: [PATCH 483/976] make sure cookie is never shared among multiple test users --- test/helpers/api-integration/requester.js | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index 6fe3460972..3eab7c7ede 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -2,6 +2,7 @@ import superagent from 'superagent'; import nconf from 'nconf'; +import { isEmpty, cloneDeep } from 'lodash'; const API_TEST_SERVER_PORT = nconf.get('PORT'); let apiVersion; @@ -9,7 +10,9 @@ let apiVersion; // Sets up an abject that can make all REST requests // If a user is passed in, the uuid and api token of // the user are used to make the requests -export function requester (user = {}, additionalSets) { +export function requester (user = {}, additionalSets = {}) { + additionalSets = cloneDeep(additionalSets); // cloning because it could be modified later to set cookie + return { get: _requestMaker(user, 'get', additionalSets), post: _requestMaker(user, 'post', additionalSets), @@ -22,11 +25,7 @@ requester.setApiVersion = (version) => { apiVersion = version; }; -// save the last cookie so that it's resent with every request -// should be safe since every time a user is generated this will be overwritten -let cookie; - -function _requestMaker (user, method, additionalSets) { +function _requestMaker (user, method, additionalSets = {}) { if (!apiVersion) throw new Error('apiVersion not set'); return (route, send, query) => { @@ -40,12 +39,7 @@ function _requestMaker (user, method, additionalSets) { .set('x-api-key', user.apiToken); } - // if we previously saved a cookie, send it along the request - if (cookie) { - request.set('Cookie', cookie); - } - - if (additionalSets) { + if (!isEmpty(additionalSets)) { request.set(additionalSets); } @@ -63,7 +57,7 @@ function _requestMaker (user, method, additionalSets) { // if any cookies was sent, save it for the next request if (response.headers['set-cookie']) { - cookie = response.headers['set-cookie'].map(cookieString => { + additionalSets.cookie = response.headers['set-cookie'].map(cookieString => { return cookieString.split(';')[0]; }).join('; '); } From 34b03934cc734f68ac8fa67f6f4907f26cab73d5 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 21 Feb 2016 10:05:51 -0600 Subject: [PATCH 484/976] Added unsubscribe route and initial tests --- common/locales/en/api-v3.json | 4 +- .../POST-paymentId-subscribe-cancel.test.js | 68 +++++++++++++++++++ .../src/controllers/api-v3/unsubscription.js | 57 ++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/unsubscription/POST-paymentId-subscribe-cancel.test.js create mode 100644 website/src/controllers/api-v3/unsubscription.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index f4cfaa5272..06ba7afd75 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -88,5 +88,7 @@ "questNotPending": "There is no quest to start.", "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest", "noAdminAccess": "You don't have admin access.", - "pageMustBeNumber": "req.query.page must be a number" + "pageMustBeNumber": "req.query.page must be a number", + "missingUnsubscriptionCode": "Missing unsubscription code.", + "userNotFound": "User Not Found" } diff --git a/test/api/v3/integration/unsubscription/POST-paymentId-subscribe-cancel.test.js b/test/api/v3/integration/unsubscription/POST-paymentId-subscribe-cancel.test.js new file mode 100644 index 0000000000..359c0061df --- /dev/null +++ b/test/api/v3/integration/unsubscription/POST-paymentId-subscribe-cancel.test.js @@ -0,0 +1,68 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { encrypt } from '../../../../../website/src/libs/api-v3/encryption'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /unsubscribe', () => { + let user; + let testEmail = 'test@habitica.com'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('return error when code is not provided', async () => { + await expect(user.get('/unsubscribe')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('return error when user is not found', async () => { + let code = encrypt(JSON.stringify({ + _id: generateUUID(), + })); + + await expect(user.get(`/unsubscribe?code=${code}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userNotFound'), + }); + }); + + it('unsubscribes a user from email notifications', async () => { + let code = encrypt(JSON.stringify({ + _id: user._id, + email: user.email, + })); + + await user.get(`/unsubscribe?code=${code}`); + + let unsubscribedUser = await user.get('/user'); + + expect(unsubscribedUser.preferences.emailNotifications.unsubscribeFromAll).to.be.true; + }); + + it('unsubscribes an email from notifications', async () => { + let code = encrypt(JSON.stringify({ + email: testEmail, + })); + + let unsubscribedMessage = await user.get(`/unsubscribe?code=${code}`); + + expect(unsubscribedMessage).to.equal('

Unsubscribed successfully!

You won\'t receive any other email from Habitica.'); + }); + + it('returns okay when email is already unsubscribed', async () => { + let code = encrypt(JSON.stringify({ + email: testEmail, + })); + + let unsubscribedMessage = await user.get(`/unsubscribe?code=${code}`); + + expect(unsubscribedMessage).to.equal('

Unsubscribed successfully!

You won\'t receive any other email from Habitica.'); + }); +}); diff --git a/website/src/controllers/api-v3/unsubscription.js b/website/src/controllers/api-v3/unsubscription.js new file mode 100644 index 0000000000..5578cd742f --- /dev/null +++ b/website/src/controllers/api-v3/unsubscription.js @@ -0,0 +1,57 @@ +import { model as User } from '../../models/user'; +import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; +import { decrypt } from '../../libs/api-v3/encryption'; +import { + NotFound, +} from '../../libs/api-v3/errors'; + +let api = {}; + +/** + * @api {post} /unsubscribe Unsubscribe an email or user from email notifications + * @apiVersion 3.0.0 + * @apiName UnsubscribeEmail + * @apiGroup Unsubscribe + * + * @apiParam {String} code An unsubscription code + * + * @apiSuccess {String} okRes An message stating the user/email unsubscribed successfully + */ +api.unsubscribe = { + method: 'GET', + url: '/unsubscribe', + middlewares: [], + async handler (req, res) { + req.checkQuery({ + code: { + notEmpty: {errorMessage: res.t('missingUnsubscriptionCode')}, + }, + }); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let data = JSON.parse(decrypt(req.query.code)); + + if (data._id) { + let userUpdated = await User.update( + {_id: data._id}, + { $set: {'preferences.emailNotifications.unsubscribeFromAll': true}} + ); + + if (userUpdated.nModified !== 1) throw new NotFound(res.t('userNotFound')); + + res.send(`

${res.t('unsubscribedSuccessfully', null, req.language)}

res.t('unsubscribedTextUsers', null, req.language)`); + } else { + let unsubscribedEmail = await EmailUnsubscription.findOne({email: data.email}); + let okResponse = `

${res.t('unsubscribedSuccessfully', null, req.language)}

${res.t('unsubscribedTextOthers', null, req.language)}`; + if (unsubscribedEmail) return res.send(okResponse); + + await EmailUnsubscription.create({email: data.email}); + + res.send(okResponse); + } + }, +}; + +export default api; From 68a57bc2f699df731792f0c0fb5204ffab0bc881 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 22 Feb 2016 11:45:26 +0100 Subject: [PATCH 485/976] enable strict mode to allow use of let, const without babel --- website/src/index.js | 2 ++ website/src/libs/api-v3/newrelic.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/website/src/index.js b/website/src/index.js index 787508d034..b5bc9ceac0 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -1,3 +1,5 @@ +'use strict'; + // Register babel hook so we can write the real entry file (server.js) in ES6 require('babel-core/register'); diff --git a/website/src/libs/api-v3/newrelic.js b/website/src/libs/api-v3/newrelic.js index 85fe72a96a..33be7da636 100644 --- a/website/src/libs/api-v3/newrelic.js +++ b/website/src/libs/api-v3/newrelic.js @@ -1,3 +1,5 @@ +'use strict'; + // We can't rely on babel here // because the file is requested directly by the new relic module From d60ff421c94d35ee87bad61a7ee20b692f19344c Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 22 Feb 2016 09:03:21 -0600 Subject: [PATCH 486/976] Updated route to be named /emails/unsubscribe and other minor fixes --- .../GET-email-unsubscribe.test.js} | 12 ++++++------ .../api-v3/{unsubscription.js => email.js} | 11 ++++------- 2 files changed, 10 insertions(+), 13 deletions(-) rename test/api/v3/integration/{unsubscription/POST-paymentId-subscribe-cancel.test.js => emails/GET-email-unsubscribe.test.js} (78%) rename website/src/controllers/api-v3/{unsubscription.js => email.js} (76%) diff --git a/test/api/v3/integration/unsubscription/POST-paymentId-subscribe-cancel.test.js b/test/api/v3/integration/emails/GET-email-unsubscribe.test.js similarity index 78% rename from test/api/v3/integration/unsubscription/POST-paymentId-subscribe-cancel.test.js rename to test/api/v3/integration/emails/GET-email-unsubscribe.test.js index 359c0061df..bd13cbdc1e 100644 --- a/test/api/v3/integration/unsubscription/POST-paymentId-subscribe-cancel.test.js +++ b/test/api/v3/integration/emails/GET-email-unsubscribe.test.js @@ -5,7 +5,7 @@ import { import { encrypt } from '../../../../../website/src/libs/api-v3/encryption'; import { v4 as generateUUID } from 'uuid'; -describe('GET /unsubscribe', () => { +describe('GET /email/unsubscribe', () => { let user; let testEmail = 'test@habitica.com'; @@ -14,7 +14,7 @@ describe('GET /unsubscribe', () => { }); it('return error when code is not provided', async () => { - await expect(user.get('/unsubscribe')).to.eventually.be.rejected.and.eql({ + await expect(user.get('/email/unsubscribe')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: 'Invalid request parameters.', @@ -26,7 +26,7 @@ describe('GET /unsubscribe', () => { _id: generateUUID(), })); - await expect(user.get(`/unsubscribe?code=${code}`)).to.eventually.be.rejected.and.eql({ + await expect(user.get(`/email/unsubscribe?code=${code}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('userNotFound'), @@ -39,7 +39,7 @@ describe('GET /unsubscribe', () => { email: user.email, })); - await user.get(`/unsubscribe?code=${code}`); + await user.get(`/email/unsubscribe?code=${code}`); let unsubscribedUser = await user.get('/user'); @@ -51,7 +51,7 @@ describe('GET /unsubscribe', () => { email: testEmail, })); - let unsubscribedMessage = await user.get(`/unsubscribe?code=${code}`); + let unsubscribedMessage = await user.get(`/email/unsubscribe?code=${code}`); expect(unsubscribedMessage).to.equal('

Unsubscribed successfully!

You won\'t receive any other email from Habitica.'); }); @@ -61,7 +61,7 @@ describe('GET /unsubscribe', () => { email: testEmail, })); - let unsubscribedMessage = await user.get(`/unsubscribe?code=${code}`); + let unsubscribedMessage = await user.get(`/email/unsubscribe?code=${code}`); expect(unsubscribedMessage).to.equal('

Unsubscribed successfully!

You won\'t receive any other email from Habitica.'); }); diff --git a/website/src/controllers/api-v3/unsubscription.js b/website/src/controllers/api-v3/email.js similarity index 76% rename from website/src/controllers/api-v3/unsubscription.js rename to website/src/controllers/api-v3/email.js index 5578cd742f..b3af91b340 100644 --- a/website/src/controllers/api-v3/unsubscription.js +++ b/website/src/controllers/api-v3/email.js @@ -19,7 +19,7 @@ let api = {}; */ api.unsubscribe = { method: 'GET', - url: '/unsubscribe', + url: '/email/unsubscribe', middlewares: [], async handler (req, res) { req.checkQuery({ @@ -41,14 +41,11 @@ api.unsubscribe = { if (userUpdated.nModified !== 1) throw new NotFound(res.t('userNotFound')); - res.send(`

${res.t('unsubscribedSuccessfully', null, req.language)}

res.t('unsubscribedTextUsers', null, req.language)`); + res.send(`

${res.t('unsubscribedSuccessfully')}

${res.t('unsubscribedTextUsers')}`); } else { let unsubscribedEmail = await EmailUnsubscription.findOne({email: data.email}); - let okResponse = `

${res.t('unsubscribedSuccessfully', null, req.language)}

${res.t('unsubscribedTextOthers', null, req.language)}`; - if (unsubscribedEmail) return res.send(okResponse); - - await EmailUnsubscription.create({email: data.email}); - + let okResponse = `

${res.t('unsubscribedSuccessfully')}

${res.t('unsubscribedTextOthers')}`; + if (!unsubscribedEmail) await EmailUnsubscription.create({email: data.email}); res.send(okResponse); } }, From d2ba8e223c71fa770344a73e1e182ce0f989b4ba Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 26 Feb 2016 19:18:18 +0100 Subject: [PATCH 487/976] remove session party invitation. Implement the same feature using a query string when signing up --- website/src/controllers/api-v3/auth.js | 48 +++++++++++++++++++++--- website/src/controllers/api-v3/groups.js | 16 ++++---- website/src/middlewares/api-v3/locals.js | 11 ------ 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index c66b254e9b..673a87927f 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -1,18 +1,50 @@ import validator from 'validator'; +import moment from 'moment'; import passport from 'passport'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { NotAuthorized, + NotFound, } from '../../libs/api-v3/errors'; import Q from 'q'; import * as passwordUtils from '../../libs/api-v3/password'; import { model as User } from '../../models/user'; +import { model as Group } from '../../models/group'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; +import { decrypt } from '../../libs/api-v3/encryption'; + let api = {}; +// When the user signed up after having been invited to a group, invite them automatically to the group +async function _handleGroupInvitation (user, invite) { + // wrapping the code in a try because we don't want it to prevent the user from signing up + // that's why errors are not translated + try { + let {sentAt, id: groupId, inviter} = JSON.parse(decrypt(invite)); + + // check that the invite has not expired (after 7 days) + if (sentAt && moment().subtract(7, 'days').isAfter(sentAt)) { + let err = new Error('Invite expired'); + err.privateData = invite; + throw err; + } + + let group = await Group.getGroup({user, optionalMembership: true, groupId, fields: 'name type'}); + if (!group) throw new NotFound('Group not found.'); + + if (group.type === 'party') { + user.invitations.party = {id: group._id, name: group.name, inviter}; + } else { + user.invitations.guilds.push({id: group._id, name: group.name, inviter}); + } + } catch (err) { + // TODO log errors + } +} + /** * @api {post} /user/auth/local/register Register a new user with email, username and password or attach local auth to a social user * @apiVersion 3.0.0 @@ -84,25 +116,29 @@ api.registerLocal = { }, }; - let savedUser; - if (fbUser) { if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal')); fbUser.auth.local = newUser; - savedUser = await fbUser.save(); + newUser = fbUser; } else { newUser = new User(newUser); newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? - savedUser = await newUser.save(); } + // we check for partyInvite for backward compatibility + if (req.query.groupInvite || req.query.partyInvite) { + await _handleGroupInvitation(newUser, req.query.groupInvite || req.query.partyInvite); + } + + let savedUser = await newUser.save(); + if (savedUser.auth.facebook.id) { - res.respond(200, savedUser.auth.local); // TODO make sure this used .toJSON and removes private fields + res.respond(200, savedUser.toJSON().auth.local); // We convert to toJSON to hide private fields } else { res.respond(201, savedUser); } - // Clean previous email preferences + // Clean previous email preferences and send welcome email EmailUnsubscription .remove({email: savedUser.auth.local.email}) .then(() => sendTxnEmail(savedUser, 'welcome')); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 05925d5730..a078092a7e 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -459,7 +459,6 @@ api.removeGroupMember = { }; async function _inviteByUUID (uuid, group, inviter, req, res) { - // TODO: Add Push Notifications let userToInvite = await User.findById(uuid).exec(); if (!userToInvite) { @@ -493,7 +492,6 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) { let emailVars = [ {name: 'INVITER', content: inviter.profile.name}, - {name: 'REPLY_TO_ADDRESS', content: inviter.email}, ]; if (group.type === 'guild') { @@ -541,16 +539,16 @@ async function _inviteByEmail (invite, group, inviter, req, res) { userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res); } else { userReturnInfo = invite.email; - // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query - // TODO absolutely refactor this horrible code - const partyQueryString = JSON.stringify({id: group._id, inviter, name: group.name}); - const encryptedPartyqueryString = encrypt(partyQueryString); - let link = `?partyInvite=${encryptedPartyqueryString}`; + const groupQueryString = JSON.stringify({ + id: group._id, + inviter: inviter._id, + sentAt: Date.now(), // so we can let it expire + }); + let link = `?groupInvite=${encrypt(groupQueryString)}`; let variables = [ {name: 'LINK', content: link}, - {name: 'INVITER', content: inviter || inviter.profile.name}, - {name: 'REPLY_TO_ADDRESS', content: inviter.email}, + {name: 'INVITER', content: inviter.profile.name}, ]; if (group.type === 'guild') { diff --git a/website/src/middlewares/api-v3/locals.js b/website/src/middlewares/api-v3/locals.js index ccb4fc003b..43b4f5f00a 100644 --- a/website/src/middlewares/api-v3/locals.js +++ b/website/src/middlewares/api-v3/locals.js @@ -9,7 +9,6 @@ import { import forceRefresh from './../forceRefresh'; import { tavernQuest } from '../../models/group'; import { mods } from '../../models/user'; -import { decrypt } from '../../libs/api-v3/encryption'; // To avoid stringifying more data then we need, // items from `env` used on the client will have to be specified in this array @@ -59,15 +58,5 @@ export default function locals (req, res, next) { worldDmg: tavernQuest && tavernQuest.extra && tavernQuest.extra.worldDmg || {}, }); - // Put query-string party (& guild but use partyInvite for backward compatibility) - // invitations into session to be handled later - if (req.query.partyInvite) { - try { - req.session.partyInvite = JSON.parse(decrypt(req.query.partyInvite)); - } catch (e) { - // TODO logs - } - } - next(); } From 0014afb75e8cf048d9748517384ec6fc5668e939 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 26 Feb 2016 19:43:17 +0100 Subject: [PATCH 488/976] add tests for req.query.groupInvite --- .../user/auth/POST-register_local.test.js | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index f9d893a0f5..c2f02c3f68 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -2,9 +2,11 @@ import { generateUser, requester, translate as t, + createAndPopulateGroup, } from '../../../../../helpers/api-integration/v3'; import { v4 as generateRandomUserName } from 'uuid'; import { each } from 'lodash'; +import { encrypt } from '../../../../../../website/src/libs/api-v3/encryption'; describe('POST /user/auth/local/register', () => { context('username and email are free', () => { @@ -162,6 +164,53 @@ describe('POST /user/auth/local/register', () => { }); }); + context('req.query.groupInvite', () => { + let api, username, email, password; + + beforeEach(() => { + api = requester(); + username = generateRandomUserName(); + email = `${username}@example.com`; + password = 'password'; + }); + + it('does not crash the signup process when it\'s invalid', async () => { + let user = await api.post('/user/auth/local/register?groupInvite=aaaaInvalid', { + username, + email, + password, + confirmPassword: password, + }); + + expect(user._id).to.be.a('string'); + }); + + it('supports invite using req.query.groupInvite', async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + }); + + let invite = encrypt(JSON.stringify({ + id: group._id, + inviter: groupLeader._id, + sentAt: Date.now(), // so we can let it expire + })); + + let user = await api.post(`/user/auth/local/register?groupInvite=${invite}`, { + username, + email, + password, + confirmPassword: password, + }); + + expect(user.invitations.party).to.eql({ + id: group._id, + name: group.name, + inviter: groupLeader._id, + }); + }); + }); + context('successful login via api', () => { let api, username, email, password; From b4e4e31be5acf399245078140bac5225f288fac8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 27 Feb 2016 12:01:00 +0100 Subject: [PATCH 489/976] fix invitations and add missing tests --- .../groups/POST-groups_invite.test.js | 30 ++++++++++++++++++- website/src/controllers/api-v3/groups.js | 5 ++-- website/src/controllers/api-v3/quests.js | 1 - 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index 55c25d9153..d3f2b78c27 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -3,6 +3,7 @@ import { translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; +import * as email from '../../../../../website/src/libs/api-v3/email'; const INVITES_LIMIT = 100; @@ -19,6 +20,10 @@ describe('Post /groups/:groupId/invite', () => { }); }); + afterEach(() => { + if (email.sendTxn.restore) email.sendTxn.restore(); + }); + describe('user id invites', () => { it('returns an error when invited user is not found', async () => { let fakeID = generateUUID(); @@ -70,9 +75,10 @@ describe('Post /groups/:groupId/invite', () => { }); }); - it('invites a user to a group by uuid', async () => { + it.only('invites a user to a group by uuid', async () => { let userToInvite = await generateUser(); + sandbox.stub(email, 'sendTxn'); await expect(inviter.post(`/groups/${group._id}/invite`, { uuids: [userToInvite._id], })).to.eventually.deep.equal([{ @@ -80,6 +86,13 @@ describe('Post /groups/:groupId/invite', () => { name: groupName, inviter: inviter._id, }]); + + expect(email.sendTxn).to.be.calledOnce; + expect(email.sendTxn[0][0]._id).to.equal(userToInvite._id); + expect(email.sendTxn[0][1]).to.equal('invited-guild'); + expect(email.sendTxn[0][2]).to.have.all.keys(['GUILD_NAME', 'GUILD_URL', 'INVITER']); + expect(email.sendTxn[0][2].INVITER).to.equal(inviter.profile.name); + await expect(userToInvite.get('/user')) .to.eventually.have.deep.property('invitations.guilds[0].id', group._id); }); @@ -102,6 +115,8 @@ describe('Post /groups/:groupId/invite', () => { inviter: inviter._id, }, ]); + expect(email.sendTxn).to.be.calledTwice; + await expect(userToInvite.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); await expect(userToInvite2.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); }); @@ -173,13 +188,23 @@ describe('Post /groups/:groupId/invite', () => { it('invites a user to a group by email', async () => { await expect(inviter.post(`/groups/${group._id}/invite`, { emails: [testInvite], + inviter: 'inviter name', })).to.exist; + + expect(email.sendTxn).to.be.calledOnce; + expect(email.sendTxn[0][0]).to.eql(testInvite); + expect(email.sendTxn[0][1]).to.equal('invite-friend-guild'); + expect(email.sendTxn[0][2]).to.have.all.keys(['GUILD_NAME', 'LINK', 'INVITER']); + expect(email.sendTxn[0][2].INVITER).to.equal('inviter name'); }); it('invites multiple users to a group by email', async () => { await expect(inviter.post(`/groups/${group._id}/invite`, { emails: [testInvite, {name: 'test2', email: 'test2@habitica.com'}], })).to.exist; + + expect(email.sendTxn).to.be.calledTwice; + expect(email.sendTxn[0][2].INVITER).to.equal(inviter.profile.name); }); }); @@ -226,6 +251,7 @@ describe('Post /groups/:groupId/invite', () => { expect(invite).to.exist; expect(invitedUser.invitations.guilds[0].id).to.equal(group._id); + expect(email.sendTxn).to.be.calledTwice; }); }); @@ -320,6 +346,8 @@ describe('Post /groups/:groupId/invite', () => { uuids: [userToInvite._id], }); expect((await userToInvite.get('/user')).invitations.party.id).to.equal(party._id); + + expect(email.sendTxn).to.be.calledOnce; }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index a078092a7e..b4580d0491 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -548,14 +548,13 @@ async function _inviteByEmail (invite, group, inviter, req, res) { let variables = [ {name: 'LINK', content: link}, - {name: 'INVITER', content: inviter.profile.name}, + {name: 'INVITER', content: req.body.inviter || inviter.profile.name}, ]; if (group.type === 'guild') { variables.push({name: 'GUILD_NAME', content: group.name}); } - // TODO implement "users can only be invited once" // Check for the email address not to be unsubscribed let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec(); let groupLabel = group.type === 'guild' ? '-guild' : ''; @@ -598,7 +597,7 @@ api.inviteToGroup = { let emails = req.body.emails; let uuidsIsArray = Array.isArray(uuids); - let emailsIsArray = Array.isArray(emails); + let emailsIsArray = Array.isArray(emails); if (!uuids && !emails) { throw new BadRequest(res.t('canOnlyInviteEmailUuid')); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index e7f728c74a..f2a95db29f 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -116,7 +116,6 @@ api.inviteToQuest = { sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [ {name: 'QUEST_NAME', content: quest.text()}, {name: 'INVITER', content: inviterVars.name}, - {name: 'REPLY_TO_ADDRESS', content: inviterVars.email}, {name: 'PARTY_URL', content: '/#/options/groups/party'}, ]); From 432d27fab2743a712d30ad8af7006a8a14cdfe53 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 27 Feb 2016 12:40:24 +0100 Subject: [PATCH 490/976] better default values for user schema --- website/src/models/user.js | 60 +++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index a0b9fd30bf..9f59b4c143 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -21,7 +21,9 @@ export let schema = new Schema({ auth: { blocked: Boolean, - facebook: {type: Schema.Types.Mixed, default: {}}, // TODO validate, IMPORTANT make sure the {} default isn't shared across all user objects + facebook: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, local: { email: { type: String, @@ -50,7 +52,9 @@ export let schema = new Schema({ achievements: { originalUser: Boolean, habitSurveys: Number, - ultimateGearSets: Schema.Types.Mixed, // TODO remove, use dictionary? + ultimateGearSets: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, beastMaster: Boolean, beastMasterCount: Number, mountMaster: Boolean, @@ -64,7 +68,9 @@ export let schema = new Schema({ seafoam: Number, streak: Number, challenges: Array, - quests: Schema.Types.Mixed, // TODO remove, use dictionary? + quests: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, rebirths: Number, rebirthLevel: Number, perfect: {type: Number, default: 0}, @@ -99,16 +105,26 @@ export let schema = new Schema({ }, balance: {type: Number, default: 0}, - filters: {type: Schema.Types.Mixed, default: {}}, // TODO dictionary + filters: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, purchased: { ads: {type: Boolean, default: false}, // eg, {skeleton: true, pumpkin: true, eb052b: true} // TODO dictionary - skin: {type: Schema.Types.Mixed, default: {}}, - hair: {type: Schema.Types.Mixed, default: {}}, - shirt: {type: Schema.Types.Mixed, default: {}}, - background: {type: Schema.Types.Mixed, default: {}}, + skin: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, + hair: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, + shirt: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, + background: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, txnCount: {type: Number, default: 0}, mobileChat: Boolean, plan: { @@ -181,7 +197,9 @@ export let schema = new Schema({ classSelected: {type: Boolean, default: false}, mathUpdates: Boolean, rebirthEnabled: {type: Boolean, default: false}, - levelDrops: {type: Schema.Types.Mixed, default: {}}, + levelDrops: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, chatRevoked: Boolean, // Used to track the status of recapture emails sent to each user, // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user @@ -330,8 +348,16 @@ export let schema = new Schema({ challenges: [{type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}], invitations: { - guilds: {type: Array}, // TODO what are we storing here - party: Schema.Types.Mixed, // TODO dictionary TODO what are we storing here? + guilds: [{ + id: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}, + name: String, + inviter: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, + }], + party: { + id: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}, + name: String, + inviter: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, + }, }, guilds: [{type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}], @@ -345,7 +371,9 @@ export let schema = new Schema({ progress: { up: {type: Number, default: 0}, down: {type: Number, default: 0}, - collect: {type: Schema.Types.Mixed, default: {}}, // {feather:1, ingot:2} + collect: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, // {feather:1, ingot:2} }, completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser RSVPNeeded: {type: Boolean, default: false}, // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled @@ -384,7 +412,9 @@ export let schema = new Schema({ reverseChatOrder: {type: Boolean, default: false}, background: String, displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true}, - webhooks: {type: Schema.Types.Mixed, default: {}}, // TODO array? and proper controller... unless VersionError becomes problematic + webhooks: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, // TODO array? and proper controller... unless VersionError becomes problematic // For the following fields make sure to use strict comparison when searching for falsey values (=== false) // As users who didn't login after these were introduced may have them undefined/null emailNotifications: { @@ -466,7 +496,9 @@ export let schema = new Schema({ completedTodos: [{type: String, ref: 'Task'}], rewards: [{type: String, ref: 'Task'}], }, - extra: Schema.Types.Mixed, + extra: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, pushDevices: { type: [{ regId: {type: String}, From 6c8da9a53add54c3edc84ed63e916ba390842d05 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 27 Feb 2016 17:46:00 +0100 Subject: [PATCH 491/976] fix user schema definition and add missing schema --- .../groups/POST-groups_groupId_join.test.js | 21 ++++++++++------ website/src/controllers/api-v3/groups.js | 11 ++++---- website/src/models/user.js | 25 +++++++++---------- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index b9b759bed9..c245bee42a 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -7,6 +7,8 @@ import { import { v4 as generateUUID } from 'uuid'; describe('POST /group/:groupId/join', () => { + const PET_QUEST = 'whale'; + it('returns error when groupId is not for a valid group', async () => { let joiningUser = await generateUser(); @@ -135,6 +137,7 @@ describe('POST /group/:groupId/join', () => { name: 'Test Party', type: 'party', }, + members: 2, invites: 1, }); @@ -205,18 +208,20 @@ describe('POST /group/:groupId/join', () => { await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(false); }); - xit('invites joining member to active quest', async () => { - // TODO start quest + it('invites joining member to active quest', async () => { + await user.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + await user.post(`/groups/${party._id}/quests/invite/${PET_QUEST}`); await invitedUser.post(`/groups/${party._id}/join`); - invitedUser = await user.get('/user'); - party = await user.get(`/groups/${party._id}`); + await invitedUser.sync(); + await party.sync(); - expect(user).to.have.deep.property('party.quest.RSVPNeeded', true); - expect(user).to.have.deep.property('party.quest.key', party.quest.key); - - expect(party.quest.members[invitedUser._id]).to.be.undefined; + expect(invitedUser).to.have.deep.property('party.quest.RSVPNeeded', true); + expect(invitedUser).to.have.deep.property('party.quest.key', party.quest.key); + expect(party.quest.members[invitedUser._id]).to.be.null; }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 05925d5730..d3c77aa3a1 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -251,16 +251,16 @@ api.joinGroup = { let isUserInvited = false; - if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { + if (group.type === 'party' && group._id === user.invitations.party.id) { inviter = user.invitations.party.inviter; - user.invitations.party = {}; // Clear invite TODO mark modified? + user.invitations.party = {}; // Clear invite + user.markModified('invitations.party'); // invite new user to pending quest if (group.quest.key && !group.quest.active) { user.party.quest.RSVPNeeded = true; user.party.quest.key = group.quest.key; - user.party.quest.progress = undefined; // Make sure to reset progress from ay previous quest - group.quest.members[user._id] = undefined; + group.quest.members[user._id] = null; group.markModified('quest.members'); } @@ -459,7 +459,6 @@ api.removeGroupMember = { }; async function _inviteByUUID (uuid, group, inviter, req, res) { - // TODO: Add Push Notifications let userToInvite = await User.findById(uuid).exec(); if (!userToInvite) { @@ -475,7 +474,7 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { } userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: inviter._id}); } else if (group.type === 'party') { - if (!_.isEmpty(userToInvite.invitations.party)) { + if (userToInvite.invitations.party.id) { throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); } diff --git a/website/src/models/user.js b/website/src/models/user.js index 9f59b4c143..71138aebe6 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -136,7 +136,7 @@ export let schema = new Schema({ dateUpdated: Date, extraMonths: {type: Number, default: 0}, gemsBought: {type: Number, default: 0}, - mysteryItems: {type: Array, default: []}, + mysteryItems: {type: Array, default: () => []}, lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date consecutive: { count: {type: Number, default: 0}, @@ -348,16 +348,15 @@ export let schema = new Schema({ challenges: [{type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}], invitations: { - guilds: [{ - id: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}, - name: String, - inviter: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, - }], - party: { - id: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}, - name: String, - inviter: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, - }, + // Using an array without validation because otherwise mongoose treat this as a subdocument and applies _id by default + // Schema is (id, name, inviter) + // TODO one way to fix is http://mongoosejs.com/docs/guide.html#_id + guilds: {type: Array, default: () => []}, + // Using a Mixed type because otherwise user.invitations.party = {} // to reset invitation, causes validation to fail TODO + // schema is the same as for guild invitations (id, name, inviter) + party: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, }, guilds: [{type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}], @@ -483,7 +482,7 @@ export let schema = new Schema({ inbox: { newMessages: {type: Number, default: 0}, - blocks: {type: Array, default: []}, + blocks: {type: Array, default: () => []}, messages: {type: Schema.Types.Mixed, default: () => { return {}; }}, @@ -504,7 +503,7 @@ export let schema = new Schema({ regId: {type: String}, type: {type: String}, }], - default: [], + default: () => [], }, }, { strict: true, From 9321a2d90478f47dbd969b120c9a435fe7a9616a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 27 Feb 2016 17:58:56 +0100 Subject: [PATCH 492/976] remove checks for emails --- .../groups/POST-groups_invite.test.js | 29 ++----------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index d3f2b78c27..0f59bd5966 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -3,7 +3,6 @@ import { translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -import * as email from '../../../../../website/src/libs/api-v3/email'; const INVITES_LIMIT = 100; @@ -20,10 +19,6 @@ describe('Post /groups/:groupId/invite', () => { }); }); - afterEach(() => { - if (email.sendTxn.restore) email.sendTxn.restore(); - }); - describe('user id invites', () => { it('returns an error when invited user is not found', async () => { let fakeID = generateUUID(); @@ -75,10 +70,9 @@ describe('Post /groups/:groupId/invite', () => { }); }); - it.only('invites a user to a group by uuid', async () => { + it('invites a user to a group by uuid', async () => { let userToInvite = await generateUser(); - sandbox.stub(email, 'sendTxn'); await expect(inviter.post(`/groups/${group._id}/invite`, { uuids: [userToInvite._id], })).to.eventually.deep.equal([{ @@ -87,12 +81,6 @@ describe('Post /groups/:groupId/invite', () => { inviter: inviter._id, }]); - expect(email.sendTxn).to.be.calledOnce; - expect(email.sendTxn[0][0]._id).to.equal(userToInvite._id); - expect(email.sendTxn[0][1]).to.equal('invited-guild'); - expect(email.sendTxn[0][2]).to.have.all.keys(['GUILD_NAME', 'GUILD_URL', 'INVITER']); - expect(email.sendTxn[0][2].INVITER).to.equal(inviter.profile.name); - await expect(userToInvite.get('/user')) .to.eventually.have.deep.property('invitations.guilds[0].id', group._id); }); @@ -115,7 +103,6 @@ describe('Post /groups/:groupId/invite', () => { inviter: inviter._id, }, ]); - expect(email.sendTxn).to.be.calledTwice; await expect(userToInvite.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); await expect(userToInvite2.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); @@ -190,21 +177,12 @@ describe('Post /groups/:groupId/invite', () => { emails: [testInvite], inviter: 'inviter name', })).to.exist; - - expect(email.sendTxn).to.be.calledOnce; - expect(email.sendTxn[0][0]).to.eql(testInvite); - expect(email.sendTxn[0][1]).to.equal('invite-friend-guild'); - expect(email.sendTxn[0][2]).to.have.all.keys(['GUILD_NAME', 'LINK', 'INVITER']); - expect(email.sendTxn[0][2].INVITER).to.equal('inviter name'); }); it('invites multiple users to a group by email', async () => { await expect(inviter.post(`/groups/${group._id}/invite`, { emails: [testInvite, {name: 'test2', email: 'test2@habitica.com'}], })).to.exist; - - expect(email.sendTxn).to.be.calledTwice; - expect(email.sendTxn[0][2].INVITER).to.equal(inviter.profile.name); }); }); @@ -249,9 +227,8 @@ describe('Post /groups/:groupId/invite', () => { }); let invitedUser = await newUser.get('/user'); - expect(invite).to.exist; expect(invitedUser.invitations.guilds[0].id).to.equal(group._id); - expect(email.sendTxn).to.be.calledTwice; + expect(invite).to.exist; }); }); @@ -346,8 +323,6 @@ describe('Post /groups/:groupId/invite', () => { uuids: [userToInvite._id], }); expect((await userToInvite.get('/user')).invitations.party.id).to.equal(party._id); - - expect(email.sendTxn).to.be.calledOnce; }); }); }); From 3284bb23848a0127a113e3ad8b39288a8da4cbee Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 27 Feb 2016 18:18:56 +0100 Subject: [PATCH 493/976] add logout --- website/src/controllers/api-v3/auth.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 673a87927f..99a85b2679 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -1,7 +1,10 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; -import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import { + authWithHeaders, + authWithSession, + } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { NotAuthorized, @@ -304,5 +307,15 @@ api.deleteSocial = { }, }; +api.logout = { + method: 'GET', + url: '/user/auth/logout', // TODO this is under /api/v3 route, should be accessible through habitica.com/logout + middlewares: [authWithSession, cron], + async handler (req, res) { + req.logout(); + req.session = null; + res.redirect('/'); + }, +}; export default api; From 733da70b4896cfd158048d465aa456655a576ea3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 27 Feb 2016 18:31:28 +0100 Subject: [PATCH 494/976] getFirebaseToken route --- website/src/controllers/api-v3/auth.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 99a85b2679..6ccf7a21c3 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -1,6 +1,7 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; +import nconf from 'nconf'; import { authWithHeaders, authWithSession, @@ -17,7 +18,7 @@ import { model as Group } from '../../models/group'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { decrypt } from '../../libs/api-v3/encryption'; - +import FirebaseTokenGenerator from 'firebase-token-generator'; let api = {}; @@ -282,6 +283,28 @@ api.loginSocial = { }, }; +const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); + +// Internal route TODO expose? +api.getFirebaseToken = { + method: 'POST', + url: '/user/auth/firebase', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + // Expires 24 hours from now (60*60*24*1000) (in milliseconds) + let expires = new Date(); + expires.setTime(expires.getTime() + 86400000); + + let token = firebaseTokenGenerator.createToken({ + uid: user._id, + isHabiticaUser: true, + }, { expires }); + + res.respond(200, {token, expires}); + }, +}; + /** * @api {delete} /user/auth/social/:network Delete a social authentication method (only facebook supported) * @apiVersion 3.0.0 @@ -307,6 +330,7 @@ api.deleteSocial = { }, }; +// Internal route api.logout = { method: 'GET', url: '/user/auth/logout', // TODO this is under /api/v3 route, should be accessible through habitica.com/logout From 5e68589ac8199de2a9922e64b56ffd018798bdcb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 28 Feb 2016 01:11:45 +0100 Subject: [PATCH 495/976] check for membership when leaving public guild, fix memberCount update when leaving group, add missing files --- .../groups/POST-groups_groupId_leave.js | 21 ++++++++++++-- .../integration/user/auth/GET-logout.test.js | 3 ++ .../user/auth/POST-firebase.test.js | 18 ++++++++++++ .../api-integration/v3/object-generators.js | 2 +- website/src/controllers/api-v3/groups.js | 4 +-- website/src/models/group.js | 28 +++++++++++++------ 6 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 test/api/v3/integration/user/auth/GET-logout.test.js create mode 100644 test/api/v3/integration/user/auth/POST-firebase.test.js diff --git a/test/api/v3/integration/groups/POST-groups_groupId_leave.js b/test/api/v3/integration/groups/POST-groups_groupId_leave.js index 59031300ba..3e13634bd8 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_leave.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_leave.js @@ -3,6 +3,8 @@ import { checkExistence, createAndPopulateGroup, sleep, + generateUser, + translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { each, @@ -20,6 +22,7 @@ describe('POST /groups/:groupId/leave', () => { let groupToLeave; let leader; let member; + let memberCount; beforeEach(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ @@ -30,6 +33,16 @@ describe('POST /groups/:groupId/leave', () => { groupToLeave = group; leader = groupLeader; member = members[0]; + memberCount = group.memberCount; + }); + + it('prevents non members from leaving', async () => { + let user = await generateUser(); + await expect(user.post(`/groups/${groupToLeave._id}/leave`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); }); it(`lets user leave a ${groupType}`, async () => { @@ -39,14 +52,16 @@ describe('POST /groups/:groupId/leave', () => { expect(userThatLeftGroup.guilds).to.be.empty; expect(userThatLeftGroup.party._id).to.not.exist; + await groupToLeave.sync(); + expect(groupToLeave.memberCount).to.equal(memberCount - 1); }); it(`sets a new group leader when leader leaves a ${groupType}`, async () => { await leader.post(`/groups/${groupToLeave._id}/leave`); - let groupToLeaveWithNewLeader = await member.get(`/groups/${groupToLeave._id}`); - - expect(groupToLeaveWithNewLeader.leader._id).to.equal(member._id); + await groupToLeave.sync(); + expect(groupToLeave.memberCount).to.equal(memberCount - 1); + expect(groupToLeave.leader).to.equal(member._id); }); context('With challenges', () => { diff --git a/test/api/v3/integration/user/auth/GET-logout.test.js b/test/api/v3/integration/user/auth/GET-logout.test.js new file mode 100644 index 0000000000..731c523bde --- /dev/null +++ b/test/api/v3/integration/user/auth/GET-logout.test.js @@ -0,0 +1,3 @@ +describe('GET /user/auth/logout', () => { + // TODO Test manually +}); diff --git a/test/api/v3/integration/user/auth/POST-firebase.test.js b/test/api/v3/integration/user/auth/POST-firebase.test.js new file mode 100644 index 0000000000..7ebd5a20cb --- /dev/null +++ b/test/api/v3/integration/user/auth/POST-firebase.test.js @@ -0,0 +1,18 @@ +import { + generateUser, +} from '../../../../../helpers/api-integration/v3'; +import moment from 'moment'; + +describe('POST /user/auth/firebase', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + it('returns a Firebase token', async () => { + let {token, expires} = await user.post('/user/auth/firebase'); + expect(moment(expires).isValid()).to.be.true; + expect(token).to.be.a('string'); + }); +}); diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index 57f6e2163b..edd27f30ba 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -83,7 +83,7 @@ export async function createAndPopulateGroup (settings = {}) { }) ); - group.update({ memberCount: numberOfMembers + 1}); + await group.update({ memberCount: numberOfMembers + 1}); let invitees = await Q.all( times(numberOfInvites, () => { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 0c92220c48..54fd971f7d 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -332,7 +332,7 @@ api.leaveGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat', requireMembership: true}); if (!group) throw new NotFound(res.t('groupNotFound')); // During quests, checke wheter user can leave @@ -590,7 +590,7 @@ api.inviteToGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat TODO other fields too? + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); if (!group) throw new NotFound(res.t('groupNotFound')); let uuids = req.body.uuids; diff --git a/website/src/models/group.js b/website/src/models/group.js index 58d01ef720..1620ed020c 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -121,16 +121,24 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -schema.statics.getGroup = function getGroup (options = {}) { - let {user, groupId, fields, optionalMembership = false, populateLeader = false} = options; +schema.statics.getGroup = async function getGroup (options = {}) { + let {user, groupId, fields, optionalMembership = false, populateLeader = false, requireMembership = false} = options; let query; + let isParty = groupId === 'party' || user.party._id === groupId; + let isGuild = user.guilds.indexOf(groupId) !== -1; + + // When requireMembership is true check that user is member even in public guild + if (requireMembership && !isParty && !isGuild) { + return null; + } + // When optionalMembership is true it's not required for the user to be a member of the group - if (groupId === 'party' || user.party._id === groupId) { + if (isParty) { query = {type: 'party', _id: user.party._id}; } else if (optionalMembership === true) { query = {_id: groupId}; - } else if (user.guilds.indexOf(groupId) !== -1) { + } else if (isGuild) { query = {type: 'guild', _id: groupId}; } else { query = {type: 'guild', privacy: 'public', _id: groupId}; @@ -139,7 +147,8 @@ schema.statics.getGroup = function getGroup (options = {}) { let mQuery = this.findOne(query); if (fields) mQuery.select(fields); if (populateLeader === true) mQuery.populate('leader', nameFields); - return mQuery.exec(); + let group = await mQuery.exec(); + return group; }; // When converting to json remove chat messages with more than 1 flag and remove all flags info @@ -591,13 +600,16 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { // otherwise just remove a member TODO create User.methods.removeFromGroup? if (group.type === 'guild') { - promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id } }).exec()); + promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id}}).exec()); } else { - promises.push(User.update({_id: user._id}, {$set: {party: {} } }).exec()); + promises.push(User.update({_id: user._id}, {$set: {party: {}}}).exec()); } // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) - let update = { memberCount: group.memberCount - 1 }; + let update = { + $inc: {memberCount: -1}, + }; + if (group.leader === user._id) { let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id}; query._id = {$ne: user._id}; From 6e732a67c93b3cf54650e003874ba500717de6c9 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 28 Feb 2016 14:44:18 -0600 Subject: [PATCH 496/976] Added initial challenge model tests --- test/api/v3/unit/models/challenge.test.js | 121 ++++++++++++++++++++++ website/src/models/challenge.js | 8 +- 2 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 test/api/v3/unit/models/challenge.test.js diff --git a/test/api/v3/unit/models/challenge.test.js b/test/api/v3/unit/models/challenge.test.js new file mode 100644 index 0000000000..dcafbe033c --- /dev/null +++ b/test/api/v3/unit/models/challenge.test.js @@ -0,0 +1,121 @@ +import { model as Challenge } from '../../../../../website/src/models/challenge'; +import { model as Group } from '../../../../../website/src/models/group'; +import { model as User } from '../../../../../website/src/models/user'; +import * as Tasks from '../../../../../website/src/models/task'; +import { each } from 'lodash'; + +describe('Challenge Model', () => { + let guild, leader, challenge, task; + let tasksToTest = { + habit: { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }, + todo: { + text: 'test todo', + type: 'todo', + notes: 1976, + }, + daily: { + text: 'test daily', + type: 'daily', + notes: 1976, + frequency: 'daily', + everyX: 5, + startDate: new Date(), + }, + reward: { + text: 'test reward', + type: 'reward', + notes: 1976, + }, + }; + + beforeEach(async () => { + guild = new Group({ + name: 'test party', + type: 'guild', + }); + + leader = new User({ + guilds: [guild._id], + }); + + guild.leader = leader._id; + + challenge = new Challenge({ + name: 'Test Challenge', + shortName: 'Test', + leader: leader._id, + group: guild._id, + }); + + leader.challenges = [challenge._id]; + + await Promise.all([ + guild.save(), + leader.save(), + challenge.save(), + ]); + }); + + each(tasksToTest, (taskValue, taskType) => { + context(`${taskType}`, () => { + before(async() => { + task = new Tasks[`${taskType}`](Tasks.Task.sanitizeCreate(taskValue)); + }); + + it('adds tasks to challenge and challenge members', async () => { + await challenge.addTasks([task]); + + let updatedLeader = await User.findOne({_id: leader._id}); + + expect(updatedLeader.tasksOrder[`${taskType}s`].length).to.be.above(0); + }); + + it('syncs a challenge to a user', async () => { + await challenge.addTasks([task]); + + let newMember = new User({ + guilds: [guild._id], + }); + await newMember.save(); + + await challenge.syncToUser(newMember); + + let updatedNewMember = await User.findById(newMember._id); + + expect(updatedNewMember.challenges).to.contain(challenge._id); + expect(updatedNewMember.tags[3]._id).to.equal(challenge._id); + expect(updatedNewMember.tags[3].name).to.equal(challenge.shortName); + expect(updatedNewMember.tasksOrder[`${taskType}s`].length).to.be.above(0); + }); + + it('updates tasks to challenge and challenge members', async () => { + let updatedTaskName = 'Updated Test Habit'; + await challenge.addTasks([task]); + + _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate({ text: updatedTaskName }))); + await challenge.updateTask(task); + + let updatedLeader = await User.findOne({_id: leader._id}); + let updatedUserTask = await Tasks.Task.findById(updatedLeader.tasksOrder[`${taskType}s`][0]); + + expect(updatedUserTask.text).to.equal(updatedTaskName); + }); + + it('removes a tasks to challenge and challenge members', async () => { + await challenge.addTasks([task]); + await challenge.removeTask(task); + + let updatedLeader = await User.findOne({_id: leader._id}); + let updatedUserTask = await Tasks.Task.findOne({_id: updatedLeader.tasksOrder[`${taskType}s`][0]}).exec(); + + expect(updatedUserTask.challenge.broken).to.equal('TASK_DELETED'); + }); + }); + }); +}); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index b67e8b7832..d8392ec1d4 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -97,7 +97,6 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) { let [challengeTasks, userTasks] = await Q.all([ // Find original challenge tasks Tasks.Task.find({ - userId: {$exists: false}, 'challenge.id': challenge._id, }).exec(), // Find user's tasks linked to this challenge @@ -186,9 +185,10 @@ schema.methods.updateTask = async function challengeUpdateTask (task) { let updateCmd = {$set: {}}; - _syncableAttrs(task).forEach((value, key) => { - updateCmd.$set[key] = value; - }); + let syncableAttrs = _syncableAttrs(task); + for (let key in syncableAttrs) { + updateCmd.$set[key] = syncableAttrs[key]; + } // TODO reveiw // Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks From 3512233966a044efa0f608b42954f0ff76e5e5ad Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 29 Feb 2016 00:09:09 +0100 Subject: [PATCH 497/976] add meta/models controller to get public models paths --- common/locales/en/api-v3.json | 2 +- .../meta/models/GET-model_paths.test.js | 30 ++++++++++++++ website/src/controllers/api-v3/email.js | 2 +- .../controllers/api-v3/meta/modelsPaths.js | 39 +++++++++++++++++++ website/src/libs/api-v3/baseModel.js | 14 ++++++- website/src/libs/api-v3/setupRoutes.js | 28 +++++++------ 6 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 test/api/v3/integration/meta/models/GET-model_paths.test.js create mode 100644 website/src/controllers/api-v3/meta/modelsPaths.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 06ba7afd75..faa7ddc984 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -90,5 +90,5 @@ "noAdminAccess": "You don't have admin access.", "pageMustBeNumber": "req.query.page must be a number", "missingUnsubscriptionCode": "Missing unsubscription code.", - "userNotFound": "User Not Found" + "userNotFound": "User not Found" } diff --git a/test/api/v3/integration/meta/models/GET-model_paths.test.js b/test/api/v3/integration/meta/models/GET-model_paths.test.js new file mode 100644 index 0000000000..f878d391df --- /dev/null +++ b/test/api/v3/integration/meta/models/GET-model_paths.test.js @@ -0,0 +1,30 @@ +import { + generateUser, + translate as t, +} from '../../../../../helpers/api-integration/v3'; + +describe('GET /meta/models/:model/paths', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + it('returns an error when model is not accessible or doesn\'t exists', async () => { + await expect(user.get('/meta/models/1234/paths')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + let models = ['habit', 'daily', 'todo', 'reward', 'user', 'tag', 'challenge', 'group']; + models.forEach(model => { + it(`returns the model paths for ${model}`, async () => { + let res = await user.get(`/meta/models/${model}/paths`); + + expect(res._id).to.equal('String'); + expect(res).to.not.have.keys('__v'); + }); + }); +}); diff --git a/website/src/controllers/api-v3/email.js b/website/src/controllers/api-v3/email.js index b3af91b340..4f642d02d6 100644 --- a/website/src/controllers/api-v3/email.js +++ b/website/src/controllers/api-v3/email.js @@ -8,7 +8,7 @@ import { let api = {}; /** - * @api {post} /unsubscribe Unsubscribe an email or user from email notifications + * @api {post} /email/unsubscribe Unsubscribe an email or user from email notifications * @apiVersion 3.0.0 * @apiName UnsubscribeEmail * @apiGroup Unsubscribe diff --git a/website/src/controllers/api-v3/meta/modelsPaths.js b/website/src/controllers/api-v3/meta/modelsPaths.js new file mode 100644 index 0000000000..895bc5aa8d --- /dev/null +++ b/website/src/controllers/api-v3/meta/modelsPaths.js @@ -0,0 +1,39 @@ +import mongoose from 'mongoose'; + +let api = {}; + +let tasksModels = ['habit', 'daily', 'todo', 'reward']; +let allModels = ['user', 'tag', 'challenge', 'group'].concat(tasksModels); + +/** + * @api {get} /meta/models/:model/paths Get all paths for the specified model. Doesn't require authentication + * @apiVersion 3.0.0 + * @apiName GetUserModelPaths + * @apiGroup Meta + * + * @apiParam {string="user","group","challenge","tag","habit","daily","todo","reward"} model The name of the model + * + * @apiSuccess {object} paths A key-value object made of fieldPath: fieldType (like {'field.nested': Boolean}) + */ +api.getModelPaths = { + method: 'GET', + url: '/meta/models/:model/paths', + async handler (req, res) { + req.checkParams('model', res.t('modelNotFound')).notEmpty().isIn(allModels); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let model = req.params.model; + // tasks models are lowercase, the others have the first letter uppercase (User, Group) + if (tasksModels.indexOf(model) === -1) { + model = model.charAt(0).toUpperCase() + model.slice(1); + } + + model = mongoose.model(model); + + res.respond(200, model.getModelPaths()); + }, +}; + +export default api; diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index c0331bf924..90b2e0c142 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -1,6 +1,7 @@ import { uuid } from '../../../../common'; import validator from 'validator'; import objectPath from 'object-path'; // TODO use lodash's unset once v4 is out +import _ from 'lodash'; export default function baseModel (schema, options = {}) { schema.add({ @@ -45,8 +46,9 @@ export default function baseModel (schema, options = {}) { return options.sanitizeTransform ? options.sanitizeTransform(objToSanitize) : objToSanitize; }; - if (!schema.options.toJSON) schema.options.toJSON = {}; if (Array.isArray(options.private)) privateFields.push(...options.private); + + if (!schema.options.toJSON) schema.options.toJSON = {}; schema.options.toJSON.transform = function transformToObject (doc, plainObj) { privateFields.forEach((fieldPath) => { objectPath.del(plainObj, fieldPath); @@ -55,4 +57,14 @@ export default function baseModel (schema, options = {}) { // Allow an additional toJSON transform function to be used return options.toJSONTransform ? options.toJSONTransform(plainObj) : plainObj; }; + + schema.statics.getModelPaths = function getModelPaths () { + return _.reduce(this.schema.paths, (result, field, path) => { + if (privateFields.indexOf(path) === -1) { + result[path] = field.instance || 'Boolean'; + } + + return result; + }, {}); + }; } diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index bfe2ab93e7..05786a1287 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -10,19 +10,25 @@ let router = express.Router(); // eslint-disable-line babel/new-cap // It takes the async function, execute it and pass any error to next (args[2]) let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]); -fs - .readdirSync(CONTROLLERS_PATH) - .filter(fileName => fileName.match(/\.js$/)) - .filter(fileName => fs.statSync(CONTROLLERS_PATH + fileName).isFile()) - .forEach((fileName) => { - let controller = require(CONTROLLERS_PATH + fileName); // eslint-disable-line global-require +function walkControllers (filePath) { + fs + .readdirSync(filePath) + .forEach(fileName => { + if (!fs.statSync(filePath + fileName).isFile()) { + walkControllers(`${filePath}${fileName}/`); + } else if (fileName.match(/\.js$/)) { + let controller = require(filePath + fileName); // eslint-disable-line global-require - _.each(controller, (action) => { - let {method, url, middlewares = [], handler} = action; + _.each(controller, (action) => { + let {method, url, middlewares = [], handler} = action; - method = method.toLowerCase(); - router[method](url, ...middlewares, _wrapAsyncFn(handler)); + method = method.toLowerCase(); + router[method](url, ...middlewares, _wrapAsyncFn(handler)); + }); + } }); - }); +} + +walkControllers(CONTROLLERS_PATH); export default router; From a04bf4c04501c592b13fd3c1e259e76905116101 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 29 Feb 2016 17:07:33 +0100 Subject: [PATCH 498/976] use different i18n module for tests --- test/helpers/api-integration/translate.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/helpers/api-integration/translate.js b/test/helpers/api-integration/translate.js index e3ad88b620..1e1ab83869 100644 --- a/test/helpers/api-integration/translate.js +++ b/test/helpers/api-integration/translate.js @@ -1,4 +1,4 @@ -import i18n from '../../../common/script/src/i18n'; +import i18n from '../../../common/script/i18n'; i18n.translations = require('../../../website/src/libs/api-v3/i18n').translations; // Use this to verify error messages returned by the server @@ -16,4 +16,3 @@ export function translate (key, variables) { return translatedString; } - From e5224a7b2ad7ee9ee669a12884c9382b63a4e019 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 29 Feb 2016 23:20:13 +0100 Subject: [PATCH 499/976] add support for user._tmp, disable user.filters since it is not used --- common/script/api-v3/scoreTask.js | 1 - test/api/v3/unit/libs/baseModel.test.js | 5 ++-- test/api/v3/unit/models/user.test.js | 33 +++++++++++++++++++++++++ website/src/libs/api-v3/baseModel.js | 2 +- website/src/models/user.js | 15 ++++++----- 5 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 test/api/v3/unit/models/user.test.js diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js index 91db45ce53..57ee6914e2 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/api-v3/scoreTask.js @@ -175,7 +175,6 @@ export default function scoreTask (options = {}, req = {}) { exp: user.stats.exp, }; - // TODO return or pass to cb, don't add to user object // This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying // the API consumer, then cleared afterwards user._tmp = {}; diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index ebde4ee3ea..9a51998053 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -72,10 +72,11 @@ describe('Base model plugin', () => { schema.plugin(baseModel, options); let objToTransform = {ok: true, amPrivate: true}; - let privatized = schema.options.toJSON.transform({}, objToTransform); + let doc = {doc: true}; + let privatized = schema.options.toJSON.transform(doc, objToTransform); expect(privatized).to.equals(true); - expect(options.toJSONTransform).to.be.calledWith(objToTransform); + expect(options.toJSONTransform).to.be.calledWith(objToTransform, doc); }); it('accepts a transform function for sanitize', () => { diff --git a/test/api/v3/unit/models/user.test.js b/test/api/v3/unit/models/user.test.js new file mode 100644 index 0000000000..414052a3b6 --- /dev/null +++ b/test/api/v3/unit/models/user.test.js @@ -0,0 +1,33 @@ +import { model as User } from '../../../../../website/src/models/user'; + +describe('User Model', () => { + it('keeps user._tmp when calling .toJSON', () => { + let user = new User({ + auth: { + local: { + username: 'username', + lowerCaseUsername: 'username', + email: 'email@email.email', + salt: 'salt', + hashed_password: 'hashed_password', // eslint-disable-line camelcase + }, + }, + }); + + user._tmp = {ok: true}; + user._nonTmp = {ok: true}; + + expect(user._tmp).to.eql({ok: true}); + expect(user._nonTmp).to.eql({ok: true}); + + let toObject = user.toObject(); + let toJSON = user.toJSON(); + + expect(toObject).to.not.have.keys('_tmp'); + expect(toObject).to.not.have.keys('_nonTmp'); + + expect(toJSON).to.have.any.key('_tmp'); + expect(toJSON._tmp).to.eql({ok: true}); + expect(toJSON).to.not.have.keys('_nonTmp'); + }); +}); diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index 90b2e0c142..c7875349f5 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -55,7 +55,7 @@ export default function baseModel (schema, options = {}) { }); // Allow an additional toJSON transform function to be used - return options.toJSONTransform ? options.toJSONTransform(plainObj) : plainObj; + return options.toJSONTransform ? options.toJSONTransform(plainObj, doc) : plainObj; }; schema.statics.getModelPaths = function getModelPaths () { diff --git a/website/src/models/user.js b/website/src/models/user.js index 71138aebe6..7f6632f9da 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -105,9 +105,10 @@ export let schema = new Schema({ }, balance: {type: Number, default: 0}, - filters: {type: Schema.Types.Mixed, default: () => { + // Not saved on the user TODO remove with migration + /* filters: {type: Schema.Types.Mixed, default: () => { return {}; - }}, + }}, */ purchased: { ads: {type: Boolean, default: false}, @@ -516,13 +517,11 @@ schema.plugin(baseModel, { 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations', 'balance', 'backer', 'contributor'], private: ['auth.local.hashed_password', 'auth.local.salt'], - toJSONTransform: function userToJSON (doc) { - // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? - // TODO this works? - // doc.filters = {}; - // doc._tmp = this._tmp; // be sure to send down drop notifs + toJSONTransform: function userToJSON (plainObj, originalDoc) { + // doc.filters = {}; Not saved + plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs TODO how to test? - return doc; + return plainObj; }, }); From 20621b940e8dbfbf35c1b78365e6be577ceea886 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 1 Mar 2016 14:38:09 -0600 Subject: [PATCH 500/976] Added better tests to ensure tasks are synced --- test/api/v3/unit/models/challenge.test.js | 22 ++++++++++++++-------- website/src/models/challenge.js | 1 + 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/test/api/v3/unit/models/challenge.test.js b/test/api/v3/unit/models/challenge.test.js index dcafbe033c..9f6ca65eb7 100644 --- a/test/api/v3/unit/models/challenge.test.js +++ b/test/api/v3/unit/models/challenge.test.js @@ -2,7 +2,7 @@ import { model as Challenge } from '../../../../../website/src/models/challenge' import { model as Group } from '../../../../../website/src/models/group'; import { model as User } from '../../../../../website/src/models/user'; import * as Tasks from '../../../../../website/src/models/task'; -import { each } from 'lodash'; +import { each, find } from 'lodash'; describe('Challenge Model', () => { let guild, leader, challenge, task; @@ -12,17 +12,14 @@ describe('Challenge Model', () => { type: 'habit', up: false, down: true, - notes: 1976, }, todo: { text: 'test todo', type: 'todo', - notes: 1976, }, daily: { text: 'test daily', type: 'daily', - notes: 1976, frequency: 'daily', everyX: 5, startDate: new Date(), @@ -30,7 +27,6 @@ describe('Challenge Model', () => { reward: { text: 'test reward', type: 'reward', - notes: 1976, }, }; @@ -64,16 +60,22 @@ describe('Challenge Model', () => { each(tasksToTest, (taskValue, taskType) => { context(`${taskType}`, () => { - before(async() => { + beforeEach(async() => { task = new Tasks[`${taskType}`](Tasks.Task.sanitizeCreate(taskValue)); + task.challenge.id = challenge._id; + await task.save(); }); it('adds tasks to challenge and challenge members', async () => { await challenge.addTasks([task]); let updatedLeader = await User.findOne({_id: leader._id}); + let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); + let syncedTask = find(updatedLeadersTasks, function findNewTask (updatedLeadersTask) { + return updatedLeadersTask.type === taskValue.type && updatedLeadersTask.text === taskValue.text; + }); - expect(updatedLeader.tasksOrder[`${taskType}s`].length).to.be.above(0); + expect(syncedTask).to.exist; }); it('syncs a challenge to a user', async () => { @@ -87,11 +89,15 @@ describe('Challenge Model', () => { await challenge.syncToUser(newMember); let updatedNewMember = await User.findById(newMember._id); + let updatedNewMemberTasks = await Tasks.Task.find({_id: { $in: updatedNewMember.tasksOrder[`${taskType}s`]}}); + let syncedTask = find(updatedNewMemberTasks, function findNewTask (updatedNewMemberTask) { + return updatedNewMemberTask.type === taskValue.type && updatedNewMemberTask.text === taskValue.text; + }); expect(updatedNewMember.challenges).to.contain(challenge._id); expect(updatedNewMember.tags[3]._id).to.equal(challenge._id); expect(updatedNewMember.tags[3].name).to.equal(challenge.shortName); - expect(updatedNewMember.tasksOrder[`${taskType}s`].length).to.be.above(0); + expect(syncedTask).to.exist; }); it('updates tasks to challenge and challenge members', async () => { diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index d8392ec1d4..0e4606748f 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -97,6 +97,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) { let [challengeTasks, userTasks] = await Q.all([ // Find original challenge tasks Tasks.Task.find({ + userId: {$exists: false}, 'challenge.id': challenge._id, }).exec(), // Find user's tasks linked to this challenge From 05b6e25c28e189f7d9950ba7ce1cbbee470dcb91 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 2 Mar 2016 13:05:17 +0100 Subject: [PATCH 501/976] port spells to api-v3 --- common/script/content/spells.js | 6 +- website/src/controllers/api-v3/user.js | 122 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/common/script/content/spells.js b/common/script/content/spells.js index 9a58e85394..7609bd844a 100644 --- a/common/script/content/spells.js +++ b/common/script/content/spells.js @@ -218,10 +218,10 @@ spells.healer = { text: t('spellHealerBrightnessText'), mana: 15, lvl: 12, - target: 'self', + target: 'tasks', notes: t('spellHealerBrightnessNotes'), - cast (user) { - _.each(user.tasks, (task) => { + cast (user, tasks) { + _.each(tasks, (task) => { if (task.type !== 'reward') { task.value += 4 * (user._statsComputed.int / (user._statsComputed.int + 40)); } diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 39efc002f9..bcdad4d9f1 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1,6 +1,15 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import common from '../../../../common'; +import { + NotFound, + BadRequest, +} from '../../libs/api-v3/errors'; +import * as Tasks from '../../models/task'; +import { model as Group } from '../../models/group'; +import { model as User } from '../../models/user'; +import Q from 'q'; +import _ from 'lodash'; let api = {}; @@ -31,4 +40,117 @@ api.getUser = { }, }; +const partyMembersFields = 'profile.name stats achievements items.special'; + +/** + * @api {post} /user/class/cast/:spell Cast a spell on a target. + * @apiVersion 3.0.0 + * @apiName UserCast + * @apiGroup User + * + * @apiParam {string} spellId The spell to cast. + * @apiParam {UUID} targetId Optional query parameter, the id of the target when casting a spell on a party member or a task. + * + * @apiSuccess {Object|Array} mixed Will return the modified targets. For party members only the necessary fields will be populated. + */ +api.castSpell = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/class/cast/:spell', + async handler (req, res) { + let user = res.locals.user; + let spellId = req.params.spellId; + let targetId = req.query.targetId; + + // optional because not required by all targetTypes, presence is checked later if necessary + req.checkQuery('targetId', res.t('targetIdUUID')).optional().isUUID(); + + let reqValidationErrors = req.validationErrors(); + if (reqValidationErrors) throw reqValidationErrors; + + let klass = common.content.spells.special[spellId] ? 'special' : user.stats.class; + let spell = common.content.spells[klass][spellId]; + + if (!spell) throw new NotFound(res.t('spellNotFound', {spell: spellId})); + if (spell.mana > user.stats.mp) throw new BadRequest(res.t('notEnoughMana')); + let targetType = spell.target; + + if (targetType === 'task') { + if (!targetId) throw new BadRequest(res.t('targetIdUUID')); + + // TODO what about challenge tasks? should casting be disabled on them? + let task = await Tasks.Task.findOne({ + _id: targetId, + userId: user._id, + }).exec(); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.challenge.id) throw new BadRequest(res.t('challengeTasksNoCast')); + + spell.cast(user, task); + await task.save(); + res.respond(200, task); + } else if (targetType === 'self') { + spell.cast(user); + await user.save(); + res.respond(200, user); + } else if (targetType === 'tasks') { // new target type when all the user's tasks are necessary + let tasks = await Tasks.Task.find({ + userId: user._id, + 'challenge.id': {$exists: false}, // exclude challenge tasks + $or: [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ], + }).exec(); + + spell.cast(user, tasks); + + let toSave = tasks.filter(t => t.isModified()); + let isUserModified = user.isModified(); + toSave.unshift(user.save()); + let saved = await Q.all(toSave); + + let response = { + tasks: isUserModified ? _.rest(saved) : saved, + }; + if (isUserModified) res.user = user; + res.respond(200, response); + } else if (targetType === 'party' || targetType === 'user') { + let party = await Group.getGroup({_id: 'party', user}); + + // arrays of users when targetType is 'party' otherwise single users + let partyMembers; + + if (targetType === 'party') { + if (!party) { + partyMembers = [user]; // Act as solo party + } else { + partyMembers = await User.find({'party._id': party._id}).select(partyMembersFields).exec(); + } + + spell.cast(user, partyMembers); + await Q.all(partyMembers.map(m => m.save())); + } else { + if (!party && (!targetId || user._id === targetId)) { + partyMembers = user; + } else { + if (!targetId) throw new BadRequest(res.t('targetIdUUID')); + partyMembers = await User.findOne({_id: targetId, 'party._id': party._id}).select(partyMembersFields).exec(); + } + + if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); + spell.cast(user, partyMembers); + await partyMembers.save(); + } + res.respond(200, partyMembers); + + if (party && !spell.silent) { + let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``; + party.sendChat(message); + await party.save(); + } + } + }, +}; + export default api; From 80f791c86b5917b9ed996505215765016bd3441b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 3 Mar 2016 00:16:28 +0100 Subject: [PATCH 502/976] add tests, move CustomError to common, fix casting --- common/locales/en/api-v3.json | 10 +- common/script/api-v3/customError.js | 8 + common/script/api-v3/errors.js | 9 + common/script/content/spells.js | 29 +-- .../user/POST-user_class_cast_spellId.test.js | 172 ++++++++++++++++++ .../v3/unit/middlewares/errorHandler.test.js | 16 ++ website/src/controllers/api-v3/user.js | 27 +-- website/src/libs/api-v3/errors.js | 9 +- .../src/middlewares/api-v3/errorHandler.js | 10 +- website/src/models/group.js | 10 +- website/src/models/user.js | 2 +- 11 files changed, 260 insertions(+), 42 deletions(-) create mode 100644 common/script/api-v3/customError.js create mode 100644 common/script/api-v3/errors.js create mode 100644 test/api/v3/integration/user/POST-user_class_cast_spellId.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index faa7ddc984..06acb4161d 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,7 +73,7 @@ "guildQuestsNotSupported": "Guilds cannot be invited on quests.", "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", - "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", + "questLevelTooHigh": "You must be level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", "questAlreadyAccepted": "You already accepted the quest invitation.", "noActiveQuestToLeave": "No active quest to leave", @@ -90,5 +90,11 @@ "noAdminAccess": "You don't have admin access.", "pageMustBeNumber": "req.query.page must be a number", "missingUnsubscriptionCode": "Missing unsubscription code.", - "userNotFound": "User not Found" + "userNotFound": "User not found.", + "spellNotFound": "Spell \"<%= spellId %>\" not found.", + "partyNotFound": "Party not found", + "targetIdUUID": "\"targetId\" must be a valid UUID.", + "challengeTasksNoCast": "Casting a spell on challenge tasks is not supported.", + "spellNotOwned": "You don't own this spell.", + "spellLevelTooHigh": "You must be level <%= level %> to use this spell." } diff --git a/common/script/api-v3/customError.js b/common/script/api-v3/customError.js new file mode 100644 index 0000000000..3ebf5a316f --- /dev/null +++ b/common/script/api-v3/customError.js @@ -0,0 +1,8 @@ +// Base class for custom application errors +// It extends Error and capture the stack trace +export default class CustomError extends Error { + constructor () { + super(); + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/common/script/api-v3/errors.js b/common/script/api-v3/errors.js new file mode 100644 index 0000000000..d4db1183da --- /dev/null +++ b/common/script/api-v3/errors.js @@ -0,0 +1,9 @@ +import CustomError from './customError'; + +export class NotAuthorized extends CustomError { + constructor (customMessage) { + super(); + this.name = this.constructor.name; + this.message = customMessage || 'Not authorized.'; + } +} diff --git a/common/script/content/spells.js b/common/script/content/spells.js index 7609bd844a..bf3149bf3a 100644 --- a/common/script/content/spells.js +++ b/common/script/content/spells.js @@ -1,6 +1,6 @@ import t from './translation'; import _ from 'lodash'; - +import { NotAuthorized } from '../api-v3/errors'; /* --------------------------------------------------------------- Spells @@ -40,14 +40,12 @@ spells.wizard = { lvl: 11, target: 'task', notes: t('spellWizardFireballNotes'), - cast (user, target) { + cast (user, target, req) { let bonus = user._statsComputed.int * user.fns.crit('per'); bonus *= Math.ceil((target.value < 0 ? 1 : target.value + 1) * 0.075); user.stats.exp += diminishingReturns(bonus, 75); if (!user.party.quest.progress) user.party.quest.progress = 0; user.party.quest.progress.up += Math.ceil(user._statsComputed.int * 9.1); - // TODO change, pass req to spell? - let req = {language: user.preferences.language}; user.fns.updateStats(user.stats, req); }, }, @@ -166,12 +164,11 @@ spells.rogue = { lvl: 12, target: 'task', notes: t('spellRogueBackStabNotes'), - cast (user, target) { + cast (user, target, req) { let _crit = user.fns.crit('str', 0.3); let bonus = calculateBonus(target.value, user._statsComputed.str, _crit); user.stats.exp += diminishingReturns(bonus, 75, 50); user.stats.gp += diminishingReturns(bonus, 18, 75); - let req = {language: user.preferences.language}; user.fns.updateStats(user.stats, req); }, }, @@ -262,9 +259,11 @@ spells.special = { text: t('spellSpecialSnowballAuraText'), mana: 0, value: 15, + previousPurchase: true, target: 'user', notes: t('spellSpecialSnowballAuraNotes'), - cast (user, target) { + cast (user, target, req) { + if (!user.items.special.snowball) throw new NotAuthorized(t('spellNotOwned')(req.language)); target.stats.buffs.snowball = true; target.stats.buffs.spookDust = false; target.stats.buffs.shinySeed = false; @@ -290,9 +289,11 @@ spells.special = { text: t('spellSpecialSpookDustText'), mana: 0, value: 15, + previousPurchase: true, target: 'user', notes: t('spellSpecialSpookDustNotes'), - cast (user, target) { + cast (user, target, req) { + if (!user.items.special.spookDust) throw new NotAuthorized(t('spellNotOwned')(req.language)); target.stats.buffs.snowball = false; target.stats.buffs.spookDust = true; target.stats.buffs.shinySeed = false; @@ -318,9 +319,11 @@ spells.special = { text: t('spellSpecialShinySeedText'), mana: 0, value: 15, + previousPurchase: true, target: 'user', notes: t('spellSpecialShinySeedNotes'), - cast (user, target) { + cast (user, target, req) { + if (!user.items.special.shinySeed) throw new NotAuthorized(t('spellNotOwned')(req.language)); target.stats.buffs.snowball = false; target.stats.buffs.spookDust = false; target.stats.buffs.shinySeed = true; @@ -346,9 +349,11 @@ spells.special = { text: t('spellSpecialSeafoamText'), mana: 0, value: 15, + previousPurchase: true, target: 'user', notes: t('spellSpecialSeafoamNotes'), - cast (user, target) { + cast (user, target, req) { + if (!user.items.special.seafoam) throw new NotAuthorized(t('spellNotOwned')(req.language)); target.stats.buffs.snowball = false; target.stats.buffs.spookDust = false; target.stats.buffs.shinySeed = false; @@ -499,8 +504,8 @@ _.each(spells, (spellClass) => { _.each(spellClass, (spell, key) => { spell.key = key; let _cast = spell.cast; - spell.cast = function castSpell (user, target) { - _cast(user, target); + spell.cast = function castSpell (user, target, req) { + _cast(user, target, req); user.stats.mp -= spell.mana; }; }); diff --git a/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js b/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js new file mode 100644 index 0000000000..169ad16dd2 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js @@ -0,0 +1,172 @@ +import { + generateUser, + translate as t, + createAndPopulateGroup, + generateChallenge, + sleep, +} from '../../../../helpers/api-integration/v3'; + +import { v4 as generateUUID } from 'uuid'; + +describe('POST /user/class/cast/:spellId', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error if spell does not exist', async () => { + await user.update({'stats.class': 'rogue'}); + let spellId = 'invalidSpell'; + await expect(user.post(`/user/class/cast/${spellId}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('spellNotFound', {spellId}), + }); + }); + + it('returns an error if spell does not exist in user\'s class', async () => { + let spellId = 'pickPocket'; + await expect(user.post(`/user/class/cast/${spellId}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('spellNotFound', {spellId}), + }); + }); + + it('returns an error if spell.mana > user.mana', async () => { + await user.update({'stats.class': 'rogue'}); + await expect(user.post(`/user/class/cast/backStab`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughMana'), + }); + }); + + it('returns an error if spell.value > user.gold', async () => { + await expect(user.post(`/user/class/cast/birthday`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageNotEnoughGold'), + }); + }); + + it('returns an error if spell.lvl > user.level', async () => { + await user.update({'stats.mp': 200, 'stats.class': 'wizard'}); + await expect(user.post(`/user/class/cast/earth`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('spellLevelTooHigh', {level: 13}), + }); + }); + + it('returns an error if user doesn\'t own the spell', async () => { + await expect(user.post(`/user/class/cast/snowball`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('spellNotOwned'), + }); + }); + + it('returns an error if targetId is not an UUID', async () => { + await expect(user.post('/user/class/cast/spellId?targetId=notAnUUID')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns an error if targetId is required but missing', async () => { + await user.update({'stats.class': 'rogue', 'stats.lvl': 11}); + await expect(user.post(`/user/class/cast/pickPocket`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('targetIdUUID'), + }); + }); + + it('returns an error if targeted task doesn\'t exist', async () => { + await user.update({'stats.class': 'rogue', 'stats.lvl': 11}); + await expect(user.post(`/user/class/cast/pickPocket?targetId=${generateUUID()}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('returns an error if a challenge task was targeted', async () => { + let {group, groupLeader} = await createAndPopulateGroup(); + let challenge = await generateChallenge(groupLeader, group); + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: 'task text'}, + ]); + await groupLeader.update({'stats.class': 'rogue', 'stats.lvl': 11}); + await sleep(0.5); + await groupLeader.sync(); + await expect(groupLeader.post(`/user/class/cast/pickPocket?targetId=${groupLeader.tasksOrder.habits[0]}`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('challengeTasksNoCast'), + }); + }); + + it('returns an error if targeted party member doesn\'t exist', async () => { + let {groupLeader} = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + await groupLeader.update({'items.special.snowball': 3}); + + let target = generateUUID(); + await expect(groupLeader.post(`/user/class/cast/snowball?targetId=${target}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: target}), + }); + }); + + it('returns an error if party does not exists', async () => { + await user.update({'items.special.snowball': 3}); + + await expect(user.post(`/user/class/cast/snowball?targetId=${generateUUID()}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('partyNotFound'), + }); + }); + + it('send message in party chat if party && !spell.silent', async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + await groupLeader.update({'stats.mp': 200, 'stats.class': 'wizard', 'stats.lvl': 13}); + await groupLeader.post(`/user/class/cast/earth`); + await sleep(1); + await group.sync(); + expect(group.chat[0]).to.exists; + expect(group.chat[0].uuid).to.equal('system'); + }); + + // TODO find a way to have sinon working in integration tests + // it doesn't work when tests are running separately from server + it('passes correct target to spell when targetType === \'task\''); + it('passes correct target to spell when targetType === \'tasks\''); + it('passes correct target to spell when targetType === \'self\''); + it('passes correct target to spell when targetType === \'party\''); + it('passes correct target to spell when targetType === \'user\''); + it('passes correct target to spell when targetType === \'party\' and user is not in a party'); + it('passes correct target to spell when targetType === \'user\' and user is not in a party'); +}); diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 269390b35b..694a02a8fe 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -9,6 +9,7 @@ import responseMiddleware from '../../../../../website/src/middlewares/api-v3/re import getUserLanguage from '../../../../../website/src/middlewares/api-v3/getUserLanguage'; import { BadRequest } from '../../../../../website/src/libs/api-v3/errors'; +import { NotAuthorized as NotAuthorizedShared } from '../../../../../common/script/api-v3/errors'; import logger from '../../../../../website/src/libs/api-v3/logger'; describe('errorHandler', () => { @@ -86,6 +87,21 @@ describe('errorHandler', () => { }); }); + it('handle CustomError(s) from shared code', () => { + let error = new NotAuthorizedShared(); + + errorHandler(error, req, res, next); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(400); + expect(res.json).to.be.calledWith({ + error: 'NotAuthorized', + message: 'Not authorized.', + }); + }); + it('handle http-errors errors', () => { let error = new Error('custom message'); error.statusCode = 422; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index bcdad4d9f1..2925a828cc 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -4,6 +4,7 @@ import common from '../../../../common'; import { NotFound, BadRequest, + NotAuthorized, } from '../../libs/api-v3/errors'; import * as Tasks from '../../models/task'; import { model as Group } from '../../models/group'; @@ -43,7 +44,7 @@ api.getUser = { const partyMembersFields = 'profile.name stats achievements items.special'; /** - * @api {post} /user/class/cast/:spell Cast a spell on a target. + * @api {post} /user/class/cast/:spellId Cast a spell on a target. * @apiVersion 3.0.0 * @apiName UserCast * @apiGroup User @@ -56,7 +57,7 @@ const partyMembersFields = 'profile.name stats achievements items.special'; api.castSpell = { method: 'POST', middlewares: [authWithHeaders(), cron], - url: '/user/class/cast/:spell', + url: '/user/class/cast/:spellId', async handler (req, res) { let user = res.locals.user; let spellId = req.params.spellId; @@ -71,14 +72,16 @@ api.castSpell = { let klass = common.content.spells.special[spellId] ? 'special' : user.stats.class; let spell = common.content.spells[klass][spellId]; - if (!spell) throw new NotFound(res.t('spellNotFound', {spell: spellId})); - if (spell.mana > user.stats.mp) throw new BadRequest(res.t('notEnoughMana')); + if (!spell) throw new NotFound(res.t('spellNotFound', {spellId})); + if (spell.mana > user.stats.mp) throw new NotAuthorized(res.t('notEnoughMana')); + if (spell.value > user.stats.gp && !spell.previousPurchase) throw new NotAuthorized(res.t('messageNotEnoughGold')); + if (spell.lvl > user.stats.lvl) throw new NotAuthorized(res.t('spellLevelTooHigh', {level: spell.lvl})); + let targetType = spell.target; if (targetType === 'task') { if (!targetId) throw new BadRequest(res.t('targetIdUUID')); - // TODO what about challenge tasks? should casting be disabled on them? let task = await Tasks.Task.findOne({ _id: targetId, userId: user._id, @@ -86,11 +89,11 @@ api.castSpell = { if (!task) throw new NotFound(res.t('taskNotFound')); if (task.challenge.id) throw new BadRequest(res.t('challengeTasksNoCast')); - spell.cast(user, task); + spell.cast(user, task, req); await task.save(); res.respond(200, task); } else if (targetType === 'self') { - spell.cast(user); + spell.cast(user, null, req); await user.save(); res.respond(200, user); } else if (targetType === 'tasks') { // new target type when all the user's tasks are necessary @@ -103,7 +106,7 @@ api.castSpell = { ], }).exec(); - spell.cast(user, tasks); + spell.cast(user, tasks, req); let toSave = tasks.filter(t => t.isModified()); let isUserModified = user.isModified(); @@ -116,8 +119,7 @@ api.castSpell = { if (isUserModified) res.user = user; res.respond(200, response); } else if (targetType === 'party' || targetType === 'user') { - let party = await Group.getGroup({_id: 'party', user}); - + let party = await Group.getGroup({groupId: 'party', user}); // arrays of users when targetType is 'party' otherwise single users let partyMembers; @@ -128,18 +130,19 @@ api.castSpell = { partyMembers = await User.find({'party._id': party._id}).select(partyMembersFields).exec(); } - spell.cast(user, partyMembers); + spell.cast(user, partyMembers, req); await Q.all(partyMembers.map(m => m.save())); } else { if (!party && (!targetId || user._id === targetId)) { partyMembers = user; } else { if (!targetId) throw new BadRequest(res.t('targetIdUUID')); + if (!party) throw new NotFound(res.t('partyNotFound')); partyMembers = await User.findOne({_id: targetId, 'party._id': party._id}).select(partyMembersFields).exec(); } if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); - spell.cast(user, partyMembers); + spell.cast(user, partyMembers, req); await partyMembers.save(); } res.respond(200, partyMembers); diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index cb93e7b4a2..e1218c805a 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -1,11 +1,4 @@ -// Base class for custom application errors -// It extends Error and capture the stack trace -export class CustomError extends Error { - constructor () { - super(); - Error.captureStackTrace(this, this.constructor); - } -} +import CustomError from '../../../../common/script/api-v3/customError'; /** * @apiDefine NotAuthorized diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 0590ad9f55..0518962dff 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -1,8 +1,8 @@ // The error handler middleware that handles all errors // and respond to the client import logger from '../../libs/api-v3/logger'; +import CustomError from '../../../../common/script/api-v3/customError'; import { - CustomError, BadRequest, InternalServerError, } from '../../libs/api-v3/errors'; @@ -24,11 +24,17 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l // If we can't identify it, respond with a generic 500 error let responseErr = err instanceof CustomError ? err : null; + // TODO don't return always 400 for errors in common code + // If CustomError but without httpCode then they come from shared code, treat as 400s + if (err instanceof CustomError && !err.httpCode) { + err.httpCode = 400; + } + // Handle errors created with 'http-errors' or similar that have a status/statusCode property if (err.statusCode && typeof err.statusCode === 'number') { responseErr = new CustomError(); responseErr.httpCode = err.statusCode; - responseErr.error = err.name; + responseErr.name = err.name; responseErr.message = err.message; } diff --git a/website/src/models/group.js b/website/src/models/group.js index 1620ed020c..991a7c9621 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -125,20 +125,20 @@ schema.statics.getGroup = async function getGroup (options = {}) { let {user, groupId, fields, optionalMembership = false, populateLeader = false, requireMembership = false} = options; let query; - let isParty = groupId === 'party' || user.party._id === groupId; - let isGuild = user.guilds.indexOf(groupId) !== -1; + let isUserParty = groupId === 'party' || user.party._id === groupId; + let isUserGuild = user.guilds.indexOf(groupId) !== -1; // When requireMembership is true check that user is member even in public guild - if (requireMembership && !isParty && !isGuild) { + if (requireMembership && !isUserParty && !isUserGuild) { return null; } // When optionalMembership is true it's not required for the user to be a member of the group - if (isParty) { + if (isUserParty) { query = {type: 'party', _id: user.party._id}; } else if (optionalMembership === true) { query = {_id: groupId}; - } else if (isGuild) { + } else if (isUserGuild) { query = {type: 'guild', _id: groupId}; } else { query = {type: 'guild', privacy: 'public', _id: groupId}; diff --git a/website/src/models/user.js b/website/src/models/user.js index 7f6632f9da..242f8709bf 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -453,7 +453,7 @@ export let schema = new Schema({ lvl: {type: Number, default: 1}, // Class System - class: {type: String, enum: ['warrior', 'rogue', 'wizard', 'healer'], default: 'warrior'}, + class: {type: String, enum: ['warrior', 'rogue', 'wizard', 'healer'], default: 'warrior', required: true}, points: {type: Number, default: 0}, str: {type: Number, default: 0}, con: {type: Number, default: 0}, From 96c582e062cec5dd0ace9a8b7fb31c117ae2ce10 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 3 Mar 2016 19:16:03 +0100 Subject: [PATCH 503/976] fix tests, move most errors to shared code --- common/script/api-v3/customError.js | 8 ----- common/script/api-v3/errors.js | 30 ++++++++++++++++++- .../user/POST-user_class_cast_spellId.test.js | 2 +- test/api/v3/unit/libs/errors.test.js | 3 +- .../v3/unit/middlewares/errorHandler.test.js | 16 ---------- website/src/libs/api-v3/errors.js | 29 +++--------------- .../src/middlewares/api-v3/errorHandler.js | 8 +---- 7 files changed, 37 insertions(+), 59 deletions(-) delete mode 100644 common/script/api-v3/customError.js diff --git a/common/script/api-v3/customError.js b/common/script/api-v3/customError.js deleted file mode 100644 index 3ebf5a316f..0000000000 --- a/common/script/api-v3/customError.js +++ /dev/null @@ -1,8 +0,0 @@ -// Base class for custom application errors -// It extends Error and capture the stack trace -export default class CustomError extends Error { - constructor () { - super(); - Error.captureStackTrace(this, this.constructor); - } -} diff --git a/common/script/api-v3/errors.js b/common/script/api-v3/errors.js index d4db1183da..5751a28c64 100644 --- a/common/script/api-v3/errors.js +++ b/common/script/api-v3/errors.js @@ -1,9 +1,37 @@ -import CustomError from './customError'; +// Base class for custom application errors +// It extends Error and capture the stack trace +export class CustomError extends Error { + constructor () { + super(); + Error.captureStackTrace(this, this.constructor); + } +} + +// We specify an httpCode for all errors so that they can be used in the API too export class NotAuthorized extends CustomError { constructor (customMessage) { super(); this.name = this.constructor.name; + this.httpCode = 401; this.message = customMessage || 'Not authorized.'; } } + +export class BadRequest extends CustomError { + constructor (customMessage) { + super(); + this.name = this.constructor.name; + this.httpCode = 400; + this.message = customMessage || 'Bad request.'; + } +} + +export class NotFound extends CustomError { + constructor (customMessage) { + super(); + this.name = this.constructor.name; + this.httpCode = 404; + this.message = customMessage || 'Not found.'; + } +} diff --git a/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js b/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js index 169ad16dd2..0074b38b59 100644 --- a/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js +++ b/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js @@ -68,7 +68,7 @@ describe('POST /user/class/cast/:spellId', () => { it('returns an error if user doesn\'t own the spell', async () => { await expect(user.post(`/user/class/cast/snowball`)) .to.eventually.be.rejected.and.eql({ - code: 400, + code: 401, error: 'NotAuthorized', message: t('spellNotOwned'), }); diff --git a/test/api/v3/unit/libs/errors.test.js b/test/api/v3/unit/libs/errors.test.js index 15110a06ee..2a96f05443 100644 --- a/test/api/v3/unit/libs/errors.test.js +++ b/test/api/v3/unit/libs/errors.test.js @@ -1,5 +1,6 @@ +// TODO move to shared tests +import { CustomError } from '../../../../../common/script/api-v3/errors'; import { - CustomError, NotAuthorized, BadRequest, InternalServerError, diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 694a02a8fe..269390b35b 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -9,7 +9,6 @@ import responseMiddleware from '../../../../../website/src/middlewares/api-v3/re import getUserLanguage from '../../../../../website/src/middlewares/api-v3/getUserLanguage'; import { BadRequest } from '../../../../../website/src/libs/api-v3/errors'; -import { NotAuthorized as NotAuthorizedShared } from '../../../../../common/script/api-v3/errors'; import logger from '../../../../../website/src/libs/api-v3/logger'; describe('errorHandler', () => { @@ -87,21 +86,6 @@ describe('errorHandler', () => { }); }); - it('handle CustomError(s) from shared code', () => { - let error = new NotAuthorizedShared(); - - errorHandler(error, req, res, next); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(400); - expect(res.json).to.be.calledWith({ - error: 'NotAuthorized', - message: 'Not authorized.', - }); - }); - it('handle http-errors errors', () => { let error = new Error('custom message'); error.statusCode = 422; diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index e1218c805a..884b65f102 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -1,4 +1,4 @@ -import CustomError from '../../../../common/script/api-v3/customError'; +import { CustomError } from '../../../../common/script/api-v3/errors'; /** * @apiDefine NotAuthorized @@ -11,14 +11,7 @@ import CustomError from '../../../../common/script/api-v3/customError'; * "message": "Not authorized." * } */ -export class NotAuthorized extends CustomError { - constructor (customMessage) { - super(); - this.name = this.constructor.name; - this.httpCode = 401; - this.message = customMessage || 'Not authorized.'; - } -} +export { NotAuthorized } from '../../../../common/script/api-v3/errors'; /** * @apiDefine BadRequest @@ -31,14 +24,7 @@ export class NotAuthorized extends CustomError { * "message": "Bad request." * } */ -export class BadRequest extends CustomError { - constructor (customMessage) { - super(); - this.name = this.constructor.name; - this.httpCode = 400; - this.message = customMessage || 'Bad request.'; - } -} +export { BadRequest } from '../../../../common/script/api-v3/errors'; /** * @apiDefine NotFound @@ -51,14 +37,7 @@ export class BadRequest extends CustomError { * "message": "Not found." * } */ -export class NotFound extends CustomError { - constructor (customMessage) { - super(); - this.name = this.constructor.name; - this.httpCode = 404; - this.message = customMessage || 'Not found.'; - } -} +export { NotFound } from '../../../../common/script/api-v3/errors'; /** * @apiDefine InternalServerError diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 0518962dff..2c49252384 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -1,7 +1,7 @@ // The error handler middleware that handles all errors // and respond to the client import logger from '../../libs/api-v3/logger'; -import CustomError from '../../../../common/script/api-v3/customError'; +import { CustomError } from '../../../../common/script/api-v3/errors'; import { BadRequest, InternalServerError, @@ -24,12 +24,6 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l // If we can't identify it, respond with a generic 500 error let responseErr = err instanceof CustomError ? err : null; - // TODO don't return always 400 for errors in common code - // If CustomError but without httpCode then they come from shared code, treat as 400s - if (err instanceof CustomError && !err.httpCode) { - err.httpCode = 400; - } - // Handle errors created with 'http-errors' or similar that have a status/statusCode property if (err.statusCode && typeof err.statusCode === 'number') { responseErr = new CustomError(); From 99242ac60f398d8b2e4bfac0e6376050fe74f089 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 3 Mar 2016 22:24:55 -0600 Subject: [PATCH 504/976] fix: remove dependency that was causing build to fail --- package.json | 1 - website/src/server.js | 8 -------- 2 files changed, 9 deletions(-) diff --git a/package.json b/package.json index 416b9f13e0..8a38c209bb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "main": "./website/src/index.js", "dependencies": { "accepts": "^1.3.0", - "active-handles": "^1.1.0", "amazon-payments": "0.0.4", "amplitude": "^2.0.3", "apidoc": "^0.13.1", diff --git a/website/src/server.js b/website/src/server.js index f97280487a..3f4de12bdf 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -167,12 +167,4 @@ server.listen(app.get('port'), () => { return logger.info(`Express server listening on port ${app.get('port')}`); }); -// var logHandlesInterval = +nconf.get('LOG_HANDLES_INTERVAL'); -// if (logHandlesInterval) { var activeHandleInterval = setInterval(logHandles, logHandlesInterval); } - -// function logHandles() { -// console.log(moment().format()); -// activeHandles.print({highlight:false}); -// } - export default server; From bcc4d568df8b19796882d43fc91c93e1e62e9aff Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 4 Mar 2016 11:45:21 -0600 Subject: [PATCH 505/976] Moved unlinkChallengeTasks to challenge model and added tests --- test/api/v3/unit/models/challenge.test.js | 27 ++++++++++++++++ website/src/controllers/api-v3/challenges.js | 2 +- website/src/models/challenge.js | 34 ++++++++++++++++++++ website/src/models/group.js | 2 +- website/src/models/user.js | 33 ------------------- 5 files changed, 63 insertions(+), 35 deletions(-) diff --git a/test/api/v3/unit/models/challenge.test.js b/test/api/v3/unit/models/challenge.test.js index 9f6ca65eb7..d028933e33 100644 --- a/test/api/v3/unit/models/challenge.test.js +++ b/test/api/v3/unit/models/challenge.test.js @@ -122,6 +122,33 @@ describe('Challenge Model', () => { expect(updatedUserTask.challenge.broken).to.equal('TASK_DELETED'); }); + + it('unlinks and deletes challenge tasks for a user when remove-all is specified', async () => { + await challenge.addTasks([task]); + await challenge.unlinkTasks(leader, 'remove-all'); + + let updatedLeader = await User.findOne({_id: leader._id}); + let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); + let syncedTask = find(updatedLeadersTasks, function findNewTask (updatedLeadersTask) { + return updatedLeadersTask.type === taskValue.type && updatedLeadersTask.text === taskValue.text; + }); + + expect(syncedTask).to.not.exist; + }); + + it('unlinks and keeps challenge tasks for a user when keep-all is specified', async () => { + await challenge.addTasks([task]); + await challenge.unlinkTasks(leader, 'keep-all'); + + let updatedLeader = await User.findOne({_id: leader._id}); + let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); + let syncedTask = find(updatedLeadersTasks, function findNewTask (updatedLeadersTask) { + return updatedLeadersTask.type === taskValue.type && updatedLeadersTask.text === taskValue.text; + }); + + expect(syncedTask).to.exist; + expect(syncedTask.challenge._id).to.be.empty; + }); }); }); }); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 5102da8975..f98ceae36c 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -191,7 +191,7 @@ api.leaveChallenge = { challenge.memberCount -= 1; // Unlink challenge's tasks from user's tasks and save the challenge - await Q.all([user.unlinkChallengeTasks(challenge._id, keep), challenge.save()]); + await Q.all([challenge.unlinkTasks(user, keep), challenge.save()]); res.respond(200, {}); }, }; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 0e4606748f..e3c2f3ee97 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -5,6 +5,7 @@ import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; import * as Tasks from './task'; import { model as User } from './user'; +import { removeFromArray } from '../libs/api-v3/collectionManipulators'; let Schema = mongoose.Schema; @@ -214,4 +215,37 @@ schema.methods.removeTask = async function challengeRemoveTask (task) { }, {multi: true}).exec(); }; +// Unlink challenges tasks (and the challenge itself) from user +schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) { + let challengeId = this._id; + let findQuery = { + userId: user._id, + 'challenge.id': challengeId, + }; + + removeFromArray(user.challenges, challengeId); + + if (keep === 'keep-all') { + await Tasks.Task.update(findQuery, { + $set: {challenge: {}}, // TODO what about updatedAt? + }, {multi: true}).exec(); + + await user.save(); + } else { // keep = 'remove-all' + let tasks = await Tasks.Task.find(findQuery).select('_id type completed').exec(); + let taskPromises = tasks.map(task => { + // Remove task from user.tasksOrder and delete them + if (task.type !== 'todo' || !task.completed) { + removeFromArray(user.tasksOrder[`${task.type}s`], task._id); + } + + return task.remove(); + }); + user.markModified('tasksOrder'); + taskPromises.push(user.save()); + return Q.all(taskPromises); + } +}; + + export let model = mongoose.model('Challenge', schema); diff --git a/website/src/models/group.js b/website/src/models/group.js index 1620ed020c..7a57490405 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -587,7 +587,7 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { }); let challengesToRemoveUserFrom = challenges.map(chal => { - return user.unlinkChallengeTasks(chal._id, keep); + return chal.unlinkTasks(user, keep); }); await Q.all(challengesToRemoveUserFrom); diff --git a/website/src/models/user.js b/website/src/models/user.js index 71138aebe6..e8ba70f857 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -6,7 +6,6 @@ import moment from 'moment'; import * as Tasks from './task'; import Q from 'q'; import { schema as TagSchema } from './tag'; -import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import baseModel from '../libs/api-v3/baseModel'; // import {model as Challenge} from './challenge'; @@ -696,38 +695,6 @@ schema.methods.getGroups = function getUserGroups () { return userGroups; }; -// Unlink challenges tasks (and the challenge itself) from user -schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (challengeId, keep) { - let user = this; - let findQuery = { - userId: user._id, - 'challenge.id': challengeId, - }; - - removeFromArray(user.challenges, challengeId); - - if (keep === 'keep-all') { - await Tasks.Task.update(findQuery, { - $set: {challenge: {}}, // TODO what about updatedAt? - }, {multi: true}).exec(); - - await user.save(); - } else { // keep = 'remove-all' - let tasks = await Tasks.Task.find(findQuery).select('_id type completed').exec(); - let taskPromises = tasks.map(task => { - // Remove task from user.tasksOrder and delete them - if (task.type !== 'todo' || !task.completed) { - removeFromArray(user.tasksOrder[`${task.type}s`], task._id); - } - - return task.remove(); - }); - - taskPromises.push(user.save()); - return Q.all(taskPromises); - } -}; - export let model = mongoose.model('User', schema); // Initially export an empty object so external requires will get From b97511db31ba78dca8d7037139142d89c45a4927 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 4 Mar 2016 13:29:51 -0600 Subject: [PATCH 506/976] fix: Use module.exports instead of export default --- common/script/api-v3/scoreTask.js | 4 ++-- website/src/controllers/api-v3/auth.js | 2 +- website/src/controllers/api-v3/challenges.js | 2 +- website/src/controllers/api-v3/chat.js | 2 +- website/src/controllers/api-v3/dataexport.js | 2 +- website/src/controllers/api-v3/email.js | 2 +- website/src/controllers/api-v3/groups.js | 6 +++--- website/src/controllers/api-v3/hall.js | 2 +- website/src/controllers/api-v3/members.js | 2 +- website/src/controllers/api-v3/meta/modelsPaths.js | 2 +- website/src/controllers/api-v3/quests.js | 2 +- website/src/controllers/api-v3/tags.js | 2 +- website/src/controllers/api-v3/tasks.js | 2 +- website/src/controllers/api-v3/user.js | 2 +- website/src/libs/api-v3/analyticsService.js | 2 +- website/src/libs/api-v3/baseModel.js | 4 ++-- website/src/libs/api-v3/csvStringify.js | 4 ++-- website/src/libs/api-v3/logger.js | 2 +- website/src/libs/api-v3/pushNotifications.js | 4 ++-- website/src/libs/api-v3/setupNconf.js | 4 ++-- website/src/libs/api-v3/setupRoutes.js | 2 +- website/src/middlewares/api-v3/analytics.js | 4 ++-- website/src/middlewares/api-v3/cron.js | 4 ++-- website/src/middlewares/api-v3/domain.js | 4 ++-- website/src/middlewares/api-v3/errorHandler.js | 4 ++-- website/src/middlewares/api-v3/getUserLanguage.js | 4 ++-- website/src/middlewares/api-v3/index.js | 4 ++-- website/src/middlewares/api-v3/locals.js | 4 ++-- website/src/middlewares/api-v3/notFound.js | 4 ++-- website/src/middlewares/api-v3/response.js | 4 ++-- website/src/middlewares/api-v3/setupBody.js | 4 ++-- website/src/middlewares/api-v3/static.js | 4 ++-- website/src/models/group.js | 4 ++-- website/src/server.js | 2 +- 34 files changed, 53 insertions(+), 53 deletions(-) diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js index 57ee6914e2..f0244571f5 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/api-v3/scoreTask.js @@ -166,7 +166,7 @@ function _changeTaskValue (user, task, direction, times, cron) { return addToDelta; } -export default function scoreTask (options = {}, req = {}) { +module.exports = function scoreTask (options = {}, req = {}) { let {user, task, direction, times = 1, cron = false} = options; let delta = 0; let stats = { @@ -247,4 +247,4 @@ export default function scoreTask (options = {}, req = {}) { user.fns.updateStats(stats, req); return delta; -} +}; diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 6ccf7a21c3..ad3c26bbdd 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -342,4 +342,4 @@ api.logout = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 5102da8975..632c36bd3f 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -565,4 +565,4 @@ api.selectChallengeWinner = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index e60ba380a8..d2b7358643 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -392,4 +392,4 @@ api.deleteChat = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/dataexport.js b/website/src/controllers/api-v3/dataexport.js index dca4e5eae3..81f257cb58 100644 --- a/website/src/controllers/api-v3/dataexport.js +++ b/website/src/controllers/api-v3/dataexport.js @@ -226,4 +226,4 @@ api.exportUserAvatarPng = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/email.js b/website/src/controllers/api-v3/email.js index 4f642d02d6..7d4e2cbb83 100644 --- a/website/src/controllers/api-v3/email.js +++ b/website/src/controllers/api-v3/email.js @@ -51,4 +51,4 @@ api.unsubscribe = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 54fd971f7d..372e0c61fc 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -497,12 +497,12 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { if (group.type === 'guild') { emailVars.push( {name: 'GUILD_NAME', content: group.name}, - {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'}, + {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'} ); } else { emailVars.push( {name: 'PARTY_NAME', content: group.name}, - {name: 'PARTY_URL', content: '/#/options/groups/party'}, + {name: 'PARTY_URL', content: '/#/options/groups/party'} ); } @@ -642,4 +642,4 @@ api.inviteToGroup = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/hall.js b/website/src/controllers/api-v3/hall.js index 57454c038d..917b3e487a 100644 --- a/website/src/controllers/api-v3/hall.js +++ b/website/src/controllers/api-v3/hall.js @@ -189,4 +189,4 @@ api.updateHero = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 85449ecc79..ad42fe5e8d 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -231,4 +231,4 @@ api.getChallengeMemberProgress = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/meta/modelsPaths.js b/website/src/controllers/api-v3/meta/modelsPaths.js index 895bc5aa8d..657f7c6e6a 100644 --- a/website/src/controllers/api-v3/meta/modelsPaths.js +++ b/website/src/controllers/api-v3/meta/modelsPaths.js @@ -36,4 +36,4 @@ api.getModelPaths = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index f2a95db29f..28adfff1d6 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -448,4 +448,4 @@ api.leaveQuest = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 27191bf0ee..36d96c7bb6 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -144,4 +144,4 @@ api.deleteTag = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index ffd3b15903..a58ac0a628 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -923,4 +923,4 @@ api.deleteTask = { }, }; -export default api; +module.exports = api; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 2925a828cc..9e639d8b43 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -156,4 +156,4 @@ api.castSpell = { }, }; -export default api; +module.exports = api; diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js index 2c435da9dd..ae3ec9f507 100644 --- a/website/src/libs/api-v3/analyticsService.js +++ b/website/src/libs/api-v3/analyticsService.js @@ -231,7 +231,7 @@ let mockAnalyticsService = { trackPurchase: () => { }, }; -export default { +module.exports = { track, trackPurchase, mockAnalyticsService, diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index c7875349f5..e1b69216b6 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -3,7 +3,7 @@ import validator from 'validator'; import objectPath from 'object-path'; // TODO use lodash's unset once v4 is out import _ from 'lodash'; -export default function baseModel (schema, options = {}) { +module.exports = function baseModel (schema, options = {}) { schema.add({ _id: { type: String, @@ -67,4 +67,4 @@ export default function baseModel (schema, options = {}) { return result; }, {}); }; -} +}; diff --git a/website/src/libs/api-v3/csvStringify.js b/website/src/libs/api-v3/csvStringify.js index 3a597ff55c..da87ca33f2 100644 --- a/website/src/libs/api-v3/csvStringify.js +++ b/website/src/libs/api-v3/csvStringify.js @@ -1,11 +1,11 @@ import csvStringify from 'csv-stringify'; import Q from 'q'; -export default function (input) { +module.exports = (input) => { return Q.promise((resolve, reject) => { csvStringify(input, (err, output) => { if (err) return reject(err); return resolve(output); }); }); -} +}; diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 4825339c1f..37f5128fbf 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -21,4 +21,4 @@ if (IS_PROD) { }); } -export default logger; +module.exports = logger; diff --git a/website/src/libs/api-v3/pushNotifications.js b/website/src/libs/api-v3/pushNotifications.js index f2f7825477..edd10c3d2f 100644 --- a/website/src/libs/api-v3/pushNotifications.js +++ b/website/src/libs/api-v3/pushNotifications.js @@ -25,7 +25,7 @@ if (gcm) { } // TODO test -export default function sendNotification (user, title, message, timeToLive = 15) { +module.exports = function sendNotification (user, title, message, timeToLive = 15) { // TODO need investigation: // https://github.com/HabitRPG/habitrpg/issues/5252 @@ -54,4 +54,4 @@ export default function sendNotification (user, title, message, timeToLive = 15) break; } }); -} +}; diff --git a/website/src/libs/api-v3/setupNconf.js b/website/src/libs/api-v3/setupNconf.js index f55f593bad..d88b04014e 100644 --- a/website/src/libs/api-v3/setupNconf.js +++ b/website/src/libs/api-v3/setupNconf.js @@ -3,7 +3,7 @@ import { join, resolve } from 'path'; const PATH_TO_CONFIG = join(resolve(__dirname, '../../../../config.json')); -export default function setupNconf (file) { +module.exports = function setupNconf (file) { let configFile = file || PATH_TO_CONFIG; nconf @@ -14,4 +14,4 @@ export default function setupNconf (file) { nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production'); nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development'); nconf.set('IS_TEST', nconf.get('NODE_ENV') === 'test'); -} +}; diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index 05786a1287..99dca871a9 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -31,4 +31,4 @@ function walkControllers (filePath) { walkControllers(CONTROLLERS_PATH); -export default router; +module.exports = router; diff --git a/website/src/middlewares/api-v3/analytics.js b/website/src/middlewares/api-v3/analytics.js index e4872fad56..8512d16011 100644 --- a/website/src/middlewares/api-v3/analytics.js +++ b/website/src/middlewares/api-v3/analytics.js @@ -16,8 +16,8 @@ if (nconf.get('IS_PROD')) { service = mockAnalyticsService; } -export default function attachAnalytics (req, res, next) { +module.exports = function attachAnalytics (req, res, next) { res.analytics = service; next(); -} +}; diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index d57bfb28f8..9b5042caf2 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -269,7 +269,7 @@ export function cron (options = {}) { } // TODO check that it's used everywhere -export default async function cronMiddleware (req, res, next) { +module.exports = async function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; @@ -335,4 +335,4 @@ export default async function cronMiddleware (req, res, next) { .then(() => next()) .catch(next); }); -} +}; diff --git a/website/src/middlewares/api-v3/domain.js b/website/src/middlewares/api-v3/domain.js index 63272381da..fe9d6cd462 100644 --- a/website/src/middlewares/api-v3/domain.js +++ b/website/src/middlewares/api-v3/domain.js @@ -3,7 +3,7 @@ // it's yet to be decided whether to keep it or not import domainMiddleware from 'domain-middleware'; -export default function implementDomainMiddleware (server, mongoose) { +module.exports = function implementDomainMiddleware (server, mongoose) { return domainMiddleware({ server: { close () { @@ -13,4 +13,4 @@ export default function implementDomainMiddleware (server, mongoose) { }, killTimeout: 10000, }); -} \ No newline at end of file +}; diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 2c49252384..65c1bd935d 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -8,7 +8,7 @@ import { } from '../../libs/api-v3/errors'; import { map } from 'lodash'; -export default function errorHandler (err, req, res, next) { // eslint-disable-line no-unused-vars +module.exports = function errorHandler (err, req, res, next) { // eslint-disable-line no-unused-vars // Log the original error with some metadata let stack = err.stack || err.message || err; @@ -77,4 +77,4 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l // In some occasions like when invalid JSON is supplied `res.respond` might be not yet avalaible, // in this case we use the standard res.status(...).json(...) return res.respond ? res.respond(responseErr.httpCode, jsonRes) : res.status(responseErr.httpCode).json(jsonRes); -} +}; diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index ef957ee03c..086121b26b 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -64,7 +64,7 @@ function _attachTranslateFunction (req, res, next) { next(); } -export default function getUserLanguage (req, res, next) { +module.exports = function getUserLanguage (req, res, next) { if (req.query.lang) { // In case the language is specified in the request url, use it req.language = translations[req.query.lang] ? req.query.lang : 'en'; return _attachTranslateFunction(...arguments); @@ -85,4 +85,4 @@ export default function getUserLanguage (req, res, next) { req.language = _getFromUser(null, req); return _attachTranslateFunction(...arguments); } -} +}; diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index dd186c11fb..4f4349944d 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -18,7 +18,7 @@ const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); const SESSION_SECRET = nconf.get('SESSION_SECRET'); const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; -export default function attachMiddlewares (app) { +module.exports = function attachMiddlewares (app) { if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev')); // TODO handle errors @@ -45,4 +45,4 @@ export default function attachMiddlewares (app) { // Error handler middleware, define as the last one app.use(errorHandler); -} +}; diff --git a/website/src/middlewares/api-v3/locals.js b/website/src/middlewares/api-v3/locals.js index 43b4f5f00a..31e41c44f0 100644 --- a/website/src/middlewares/api-v3/locals.js +++ b/website/src/middlewares/api-v3/locals.js @@ -37,7 +37,7 @@ let env = { env[key] = nconf.get(key); }); -export default function locals (req, res, next) { +module.exports = function locals (req, res, next) { let language = _.find(i18n.availableLanguages, {code: req.language}); let isStaticPage = req.url.split('/')[1] === 'static'; // If url contains '/static/' @@ -59,4 +59,4 @@ export default function locals (req, res, next) { }); next(); -} +}; diff --git a/website/src/middlewares/api-v3/notFound.js b/website/src/middlewares/api-v3/notFound.js index 733a247d1d..6a71b5def4 100644 --- a/website/src/middlewares/api-v3/notFound.js +++ b/website/src/middlewares/api-v3/notFound.js @@ -2,6 +2,6 @@ import { NotFound, } from '../../libs/api-v3/errors'; -export default function (req, res, next) { +module.exports = function NotFoundMiddleware (req, res, next) { next(new NotFound()); -} +}; diff --git a/website/src/middlewares/api-v3/response.js b/website/src/middlewares/api-v3/response.js index 707d7ad0bf..a3a84fd818 100644 --- a/website/src/middlewares/api-v3/response.js +++ b/website/src/middlewares/api-v3/response.js @@ -1,7 +1,7 @@ -export default function responseHandler (req, res, next) { +module.exports = function responseHandler (req, res, next) { res.respond = function respond (status = 200, data = {}) { res.status(status).json(data); }; next(); -} +}; diff --git a/website/src/middlewares/api-v3/setupBody.js b/website/src/middlewares/api-v3/setupBody.js index 82e1b798bb..846db4162c 100644 --- a/website/src/middlewares/api-v3/setupBody.js +++ b/website/src/middlewares/api-v3/setupBody.js @@ -1,5 +1,5 @@ // TODO tests? -export default function setupBodyMiddleware (req, res, next) { +module.exports = function setupBodyMiddleware (req, res, next) { req.body = req.body || {}; next(); -} +}; diff --git a/website/src/middlewares/api-v3/static.js b/website/src/middlewares/api-v3/static.js index f944e1e301..eda3a9e39d 100644 --- a/website/src/middlewares/api-v3/static.js +++ b/website/src/middlewares/api-v3/static.js @@ -7,7 +7,7 @@ const MAX_AGE = IS_PROD ? 31536000000 : 0; const PUBLIC_DIR = path.join(__dirname, '/../../../public'); const BUILD_DIR = path.join(__dirname, '/../../../build'); -export default function staticMiddleware (expressApp) { +module.exports = function staticMiddleware (expressApp) { // TODO move all static files to a single location (one for public and one for build) expressApp.use(express.static(BUILD_DIR, { maxAge: MAX_AGE })); expressApp.use('/common/dist', express.static(`${PUBLIC_DIR}/../../common/dist`, { maxAge: MAX_AGE })); @@ -15,4 +15,4 @@ export default function staticMiddleware (expressApp) { expressApp.use('/common/script/public', express.static(`${PUBLIC_DIR}/../../common/script/public`, { maxAge: MAX_AGE })); expressApp.use('/common/img', express.static(`${PUBLIC_DIR}/../../common/img`, { maxAge: MAX_AGE })); expressApp.use(express.static(PUBLIC_DIR)); -} +}; diff --git a/website/src/models/group.js b/website/src/models/group.js index 991a7c9621..cdc1ced495 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -316,8 +316,8 @@ schema.methods.startQuest = async function startQuest (user) { // send notifications in the background without blocking User.find( { _id: { $in: nonUserQuestMembers } }, - 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications pushDevices profile.name', - ).exec().then(membersToNotify => { + 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications pushDevices profile.name' + ).exec().then((membersToNotify) => { let membersToEmail = _.filter(membersToNotify, (member) => { // send push notifications and filter users that disabled emails sendPushNotification(member, 'HabitRPG', `${shared.i18n.t('questStarted')}: ${quest.text()}`); diff --git a/website/src/server.js b/website/src/server.js index 3f4de12bdf..81a36b2dc4 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -167,4 +167,4 @@ server.listen(app.get('port'), () => { return logger.info(`Express server listening on port ${app.get('port')}`); }); -export default server; +module.exports = server; From 7dfdbb8b0523d0f78154ba9246a4daafa4e32be2 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Fri, 4 Mar 2016 21:27:16 +0000 Subject: [PATCH 507/976] /user/update-email endpoint --- common/locales/en/api-v3.json | 3 + .../user/POST-user-update-email.test.js | 77 +++++++++++++++++++ test/helpers/api-integration/requester.js | 2 +- website/src/controllers/api-v3/email.js | 3 +- website/src/controllers/api-v3/user.js | 42 +++++++++- website/src/libs/api-v3/errors.js | 14 ++++ 6 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user-update-email.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 06ba7afd75..0ff43db786 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -4,8 +4,10 @@ "missingEmail": "Missing email.", "missingUsername": "Missing username.", "missingPassword": "Missing password.", + "wrongPassword": "Wrong password.", "notAnEmail": "Invalid email address.", "emailTaken": "Email already taken.", + "newEmailRequired": "The newEmail body parameter is required.", "usernameTaken": "Username already taken.", "passwordConfirmationMatch": "Password confirmation doesn't match password.", "invalidLoginCredentials": "Incorrect username / email and / or password.", @@ -64,6 +66,7 @@ "userAlreadyPendingInvitation": "User already pending invitation.", "userAlreadyInAParty": "User already in a party.", "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", + "userHasNoLocalRegistration": "User does not have a local registration (username, email, password).", "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", diff --git a/test/api/v3/integration/user/POST-user-update-email.test.js b/test/api/v3/integration/user/POST-user-update-email.test.js new file mode 100644 index 0000000000..6a95b57ce8 --- /dev/null +++ b/test/api/v3/integration/user/POST-user-update-email.test.js @@ -0,0 +1,77 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { model as User } from '../../../../../website/src/models/user'; + +describe('POST /email/update', () => { + let user; + let fbUser; + let endpoint = '/user/update-email'; + let newEmail = 'some-new-email_2@example.net'; + let thePassword = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js + + describe('local user', async () => { + beforeEach(async () => { + user = await generateUser(); + }); + + it('does not change email if one is not provided', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('does not change email if password is not provided', async () => { + await expect(user.post(endpoint, { + newEmail, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('does not change email if wrong password is provided', async () => { + await expect(user.post(endpoint, { + newEmail, + password: 'wrong password', + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); + + it('changes email if new email and existing password are provided', async () => { + let response = await user.post(endpoint, { + newEmail, + password: thePassword, + }); + expect(response).to.eql({ email: 'some-new-email_2@example.net' }); + let id = user._id; + user = await User.findOne({ _id: id }); + expect(user.auth.local.email).to.eql(newEmail); + }); + }); + + describe('facebook user', async () => { + beforeEach(async () => { + fbUser = await generateUser(); + await fbUser.update({ 'auth.local': { ok: true } }); + }); + + it('does not change email if user.auth.local.email does not exist for this user', async () => { + await expect(fbUser.post(endpoint, { + newEmail, + password: thePassword, + })).to.eventually.be.rejected.and.eql({ + code: 412, + error: 'PreconditionFailed', + message: t('userHasNoLocalRegistration'), + }); + }); + }); +}); diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index 3eab7c7ede..903c198686 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -7,7 +7,7 @@ import { isEmpty, cloneDeep } from 'lodash'; const API_TEST_SERVER_PORT = nconf.get('PORT'); let apiVersion; -// Sets up an abject that can make all REST requests +// Sets up an object that can make all REST requests // If a user is passed in, the uuid and api token of // the user are used to make the requests export function requester (user = {}, additionalSets = {}) { diff --git a/website/src/controllers/api-v3/email.js b/website/src/controllers/api-v3/email.js index b3af91b340..a5fa7054bb 100644 --- a/website/src/controllers/api-v3/email.js +++ b/website/src/controllers/api-v3/email.js @@ -8,10 +8,11 @@ import { let api = {}; /** - * @api {post} /unsubscribe Unsubscribe an email or user from email notifications + * @api {get} /unsubscribe Unsubscribe an email or user from email notifications * @apiVersion 3.0.0 * @apiName UnsubscribeEmail * @apiGroup Unsubscribe + * @apiDescription This is a GET method so that you can put the unsubscribe link in emails. * * @apiParam {String} code An unsubscription code * diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 39efc002f9..95e8029081 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1,6 +1,12 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import common from '../../../../common'; +import { + PreconditionFailed, + BadRequest, + NotAuthorized, +} from '../../libs/api-v3/errors'; +import * as passwordUtils from '../../libs/api-v3/password'; let api = {}; @@ -19,7 +25,7 @@ api.getUser = { async handler (req, res) { let user = res.locals.user.toJSON(); - // Remove apiToken from resonse TODO make it priavte at the user level? returned in signup/login + // Remove apiToken from response TODO make it priavte at the user level? returned in signup/login delete user.apiToken; // TODO move to model (maybe virtuals, maybe in toJSON) @@ -31,4 +37,38 @@ api.getUser = { }, }; +/** + * @api {post} /user/update-email + * @apiVersion 3.0.0 + * @apiName EmailUpdate + * @apiGroup User + * + * @apiSuccess {Object} { status: 'ok' } + **/ +api.updateEmail = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/update-email', + async handler (req, res) { + let user = res.locals.user; + + if (!user.auth.local.email) throw new PreconditionFailed(res.t('userHasNoLocalRegistration')); + + req.checkBody('newEmail', res.t('newEmailRequired')).notEmpty().isEmail(); + req.checkBody('password', res.t('missingPassword')).notEmpty(); + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + // check password + let candidatePassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (candidatePassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + + // save new email + user.auth.local.email = req.body.newEmail; + await user.save(); + + return res.respond(200, { email: user.auth.local.email }); + }, +}; + export default api; diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index cb93e7b4a2..4d4d2376a3 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -47,6 +47,20 @@ export class BadRequest extends CustomError { } } +/** + * @apiDefine PreconditionFailed + * @apiError PreconditionFailed error 412 + * + **/ +export class PreconditionFailed extends CustomError { + constructor (customMessage) { + super(); + this.name = this.constructor.name; + this.httpCode = 412; + this.message = customMessage || 'Precondition failed.'; + } +} + /** * @apiDefine NotFound * @apiError NotFound The requested resource was not found. From 0e96840e3d76c58ba60f4e24f197cfe82d30015b Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 4 Mar 2016 15:38:32 -0600 Subject: [PATCH 508/976] Added initial tests for get challenge tasks --- .../challenges/GET_tasks_challenge.id.test.js | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js diff --git a/test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js b/test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js new file mode 100644 index 0000000000..2399db04b6 --- /dev/null +++ b/test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js @@ -0,0 +1,86 @@ +import { + generateUser, + generateGroup, + generateChallenge, + translate as t, +} from '../../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; +import { each } from 'lodash'; + +describe('GET /tasks/challenge/:challengeId', () => { + let user; + let guild; + let challenge; + let task; + let tasks = []; + let challengeWithTask; + let tasksToTest = { + habit: { + text: 'test habit', + type: 'habit', + up: false, + down: true, + }, + todo: { + text: 'test todo', + type: 'todo', + }, + daily: { + text: 'test daily', + type: 'daily', + frequency: 'daily', + everyX: 5, + startDate: new Date(), + }, + reward: { + text: 'test reward', + type: 'reward', + }, + }; + + before(async () => { + user = await generateUser(); + guild = await generateGroup(user); + challenge = await generateChallenge(user, guild); + }); + + it('returns error when challenge is not found', async () => { + let dummyId = generateUUID(); + + await expect(user.get(`/tasks/challenge/${dummyId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + each(tasksToTest, (taskValue, taskType) => { + context(`${taskType}`, () => { + before(async () => { + task = await user.post(`/tasks/challenge/${challenge._id}`, taskValue); + tasks.push(task); + challengeWithTask = await user.get(`/challenges/${challenge._id}`); + }); + + it('gets challenge tasks', async () => { + let getTask = await user.get(`/tasks/challenge/${challengeWithTask._id}`); + expect(getTask).to.eql(tasks); + }); + + it('gets challenge tasks filtered by type', async () => { + let challengeTasks = await user.get(`/tasks/challenge/${challengeWithTask._id}?type=${task.type}s`); + expect(challengeTasks).to.eql([task]); + }); + + it('cannot get a task owned by someone else', async () => { + let anotherUser = await generateUser(); + + await expect(anotherUser.get(`/tasks/challenge/${challengeWithTask._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + }); + }); +}); From 863ca9954e6e49159d80b95511e70f701a277227 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 5 Mar 2016 18:58:40 +0100 Subject: [PATCH 509/976] errors working with babel 6, export v3 shared modules in main common module --- common/index.js | 4 +++- common/script/api-v3/errors.js | 4 +++- common/script/api-v3/libs/extendableBuiltin.js | 11 +++++++++++ common/script/api-v3/scoreTask.js | 2 +- common/script/index.js | 9 +++++++++ website/src/controllers/api-v3/tasks.js | 9 +++++---- website/src/libs/api-v3/errors.js | 10 ++++++---- website/src/middlewares/api-v3/cron.js | 3 ++- website/src/middlewares/api-v3/errorHandler.js | 2 +- 9 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 common/script/api-v3/libs/extendableBuiltin.js diff --git a/common/index.js b/common/index.js index 35785e2145..6aca241e03 100644 --- a/common/index.js +++ b/common/index.js @@ -1,4 +1,6 @@ -var pathToCommon; +'use strict'; + +let pathToCommon; if (process.env.NODE_ENV === 'production') { pathToCommon = './transpiled-babel/index'; diff --git a/common/script/api-v3/errors.js b/common/script/api-v3/errors.js index 5751a28c64..8c758051ab 100644 --- a/common/script/api-v3/errors.js +++ b/common/script/api-v3/errors.js @@ -1,6 +1,8 @@ +import extendableBuiltin from './libs/extendableBuiltin'; + // Base class for custom application errors // It extends Error and capture the stack trace -export class CustomError extends Error { +export class CustomError extends extendableBuiltin(Error) { constructor () { super(); Error.captureStackTrace(this, this.constructor); diff --git a/common/script/api-v3/libs/extendableBuiltin.js b/common/script/api-v3/libs/extendableBuiltin.js new file mode 100644 index 0000000000..56186301c4 --- /dev/null +++ b/common/script/api-v3/libs/extendableBuiltin.js @@ -0,0 +1,11 @@ +// Babel 6 doesn't support extending native class (Error, Array, ...) +// This function makes it possible to extend native classes with the same results as Babel 5 +module.exports = function extendableBuiltin (klass) { + function ExtendableBuiltin () { + klass.apply(this, arguments); + } + ExtendableBuiltin.prototype = Object.create(klass.prototype); + Object.setPrototypeOf(ExtendableBuiltin, klass); + + return ExtendableBuiltin; +}; diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js index f0244571f5..638e89a6a9 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/api-v3/scoreTask.js @@ -1,7 +1,7 @@ import _ from 'lodash'; import { NotAuthorized, -} from '../../../website/src/libs/api-v3/errors'; +} from './errors'; import i18n from '../i18n'; const MAX_TASK_VALUE = 21.27; diff --git a/common/script/index.js b/common/script/index.js index a62bb72ef7..071a38da07 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -18,8 +18,17 @@ var $w, preenHistory, sortOrder, import content from './content/index'; import i18n from './i18n'; +import * as errors from './api-v3/errors'; +import scoreTask from './api-v3/scoreTask'; + let api = module.exports = {}; +// Temporary location of API v3 files (soon to be removed) +api.v3 = { + scoreTask, + errors, +}; + api.i18n = i18n; api.shouldDo = shouldDo; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index a58ac0a628..cfab73241c 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -10,13 +10,14 @@ import { NotAuthorized, BadRequest, } from '../../libs/api-v3/errors'; -import shared from '../../../../common'; +import common from '../../../../common'; import Q from 'q'; import _ from 'lodash'; import moment from 'moment'; -import scoreTask from '../../../../common/script/api-v3/scoreTask'; import { preenHistory } from '../../libs/api-v3/preening'; +const scoreTask = common.v3.scoreTask; + let api = {}; // challenge must be passed only when a challenge task is being created @@ -334,8 +335,8 @@ api.updateTask = { function _generateWebhookTaskData (task, direction, delta, stats, user) { let extendedStats = _.extend(stats, { - toNextLevel: shared.tnl(user.stats.lvl), - maxHealth: shared.maxHealth, + toNextLevel: common.tnl(user.stats.lvl), + maxHealth: common.maxHealth, maxMP: user._statsComputed.maxMP, // TODO refactor as method not getter }); diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index 884b65f102..10c87befdb 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -1,4 +1,6 @@ -import { CustomError } from '../../../../common/script/api-v3/errors'; +import common from '../../../../common/script'; + +export const CustomError = common.v3.errors.CustomError; /** * @apiDefine NotAuthorized @@ -11,7 +13,7 @@ import { CustomError } from '../../../../common/script/api-v3/errors'; * "message": "Not authorized." * } */ -export { NotAuthorized } from '../../../../common/script/api-v3/errors'; +export const NotAuthorized = common.v3.errors.NotAuthorized; /** * @apiDefine BadRequest @@ -24,7 +26,7 @@ export { NotAuthorized } from '../../../../common/script/api-v3/errors'; * "message": "Bad request." * } */ -export { BadRequest } from '../../../../common/script/api-v3/errors'; +export const BadRequest = common.v3.errors.BadRequest; /** * @apiDefine NotFound @@ -37,7 +39,7 @@ export { BadRequest } from '../../../../common/script/api-v3/errors'; * "message": "Not found." * } */ -export { NotFound } from '../../../../common/script/api-v3/errors'; +export const NotFound = common.v3.errors.NotFound; /** * @apiDefine InternalServerError diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 9b5042caf2..fecd6b05db 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -9,9 +9,10 @@ import Task from '../../models/task'; import Q from 'q'; import Group from '../../models/group'; import User from '../../models/user'; -import scoreTask from '../../../../common/script/api-v3/scoreTask'; import { preenUserHistory } from '../../libs/api-v3/preening'; +const scoreTask = common.v3.scoreTask; + let clearBuffs = { str: 0, int: 0, diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 65c1bd935d..95a592514d 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -1,8 +1,8 @@ // The error handler middleware that handles all errors // and respond to the client import logger from '../../libs/api-v3/logger'; -import { CustomError } from '../../../../common/script/api-v3/errors'; import { + CustomError, BadRequest, InternalServerError, } from '../../libs/api-v3/errors'; From 22e64bb88ecc3b0627cac3953d024b52727194dc Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 8 Mar 2016 12:42:05 -0600 Subject: [PATCH 510/976] Added initial get challenge task by id --- .../tasks/challenges/GET-tasks_id.test.js | 66 +++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 2 +- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/tasks/challenges/GET-tasks_id.test.js diff --git a/test/api/v3/integration/tasks/challenges/GET-tasks_id.test.js b/test/api/v3/integration/tasks/challenges/GET-tasks_id.test.js new file mode 100644 index 0000000000..49bf79c120 --- /dev/null +++ b/test/api/v3/integration/tasks/challenges/GET-tasks_id.test.js @@ -0,0 +1,66 @@ +import { + generateUser, + generateGroup, + generateChallenge, + translate as t, +} from '../../../../../helpers/api-integration/v3'; +import { each } from 'lodash'; + +describe('GET /tasks/:taskId', () => { + let user; + let guild; + let challenge; + let task; + let tasksToTest = { + habit: { + text: 'test habit', + type: 'habit', + up: false, + down: true, + }, + todo: { + text: 'test todo', + type: 'todo', + }, + daily: { + text: 'test daily', + type: 'daily', + frequency: 'daily', + everyX: 5, + startDate: new Date(), + }, + reward: { + text: 'test reward', + type: 'reward', + }, + }; + + before(async () => { + user = await generateUser(); + guild = await generateGroup(user); + challenge = await generateChallenge(user, guild); + }); + + each(tasksToTest, (taskValue, taskType) => { + context(`${taskType}`, () => { + before(async () => { + task = await user.post(`/tasks/challenge/${challenge._id}`, taskValue); + }); + + it('gets challenge task', async () => { + let getTask = await user.get(`/tasks/${task._id}`); + expect(getTask).to.eql(task); + }); + + it('returns error when user is not a member of the challenge', async () => { + let anotherUser = await generateUser(); + + await expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index cfab73241c..2eb3c91063 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -257,7 +257,7 @@ api.getTask = { if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - let challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + let challenge = await Challenge.find({_id: task.challenge.id}).select('leader').exec(); if (!challenge || (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens throw new NotFound(res.t('taskNotFound')); } From f96de74abd4b0fd846991e59d8383425b909b00b Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 9 Mar 2016 10:01:16 -0600 Subject: [PATCH 511/976] Added test for incorrect task and id and renamed test file --- ...est.js => GET-tasks_challenge_challengeId.test.js} | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) rename test/api/v3/integration/tasks/{challenges/GET-tasks_id.test.js => GET-tasks_challenge_challengeId.test.js} (81%) diff --git a/test/api/v3/integration/tasks/challenges/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_challenge_challengeId.test.js similarity index 81% rename from test/api/v3/integration/tasks/challenges/GET-tasks_id.test.js rename to test/api/v3/integration/tasks/GET-tasks_challenge_challengeId.test.js index 49bf79c120..3fed4cfaed 100644 --- a/test/api/v3/integration/tasks/challenges/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_challenge_challengeId.test.js @@ -3,8 +3,9 @@ import { generateGroup, generateChallenge, translate as t, -} from '../../../../../helpers/api-integration/v3'; +} from '../../../../helpers/api-integration/v3'; import { each } from 'lodash'; +import { v4 as generateUUID } from 'uuid'; describe('GET /tasks/:taskId', () => { let user; @@ -41,6 +42,14 @@ describe('GET /tasks/:taskId', () => { challenge = await generateChallenge(user, guild); }); + it('returns error when incorrect id is passed', async () => { + await expect(user.get(`/tasks/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + each(tasksToTest, (taskValue, taskType) => { context(`${taskType}`, () => { before(async () => { From 0f7a730a58991b11fdd10dcb684c32d93dfc87b4 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 10 Mar 2016 12:16:19 -0600 Subject: [PATCH 512/976] Add fix for running in dev and test mode --- website/src/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/website/src/index.js b/website/src/index.js index 26434960b0..d34857267d 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -4,6 +4,7 @@ // In production, the es6 code is pre-transpiled so it doesn't need it if (process.env.NODE_ENV !== 'production') { require('babel-register'); + require('babel-polyfill'); } // Only do the minimal amount of work before forking just in case of a dyno restart From 61e360a1d8d0035ab70213b03701a76bf0eadc84 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 10 Mar 2016 15:30:22 -0600 Subject: [PATCH 513/976] Added initial challenge task update tests --- .../PUT-tasks_challenge_challengeId.test.js | 321 ++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 6 +- 2 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js diff --git a/test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js b/test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js new file mode 100644 index 0000000000..99b368de00 --- /dev/null +++ b/test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js @@ -0,0 +1,321 @@ +import { + generateUser, + generateGroup, + generateChallenge, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; + +describe('PUT /tasks/:id', () => { + let user; + let guild; + let challenge; + + before(async () => { + user = await generateUser(); + guild = await generateGroup(user); + challenge = await generateChallenge(user, guild); + }); + + context('errors', () => { + let task; + + beforeEach(async () => { + task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + }); + }); + + it('returns error when incorrect id is passed', async () => { + await expect(user.put(`/tasks/${generateUUID()}`, { + text: 'some new text', + up: false, + down: false, + notes: 'some new notes', + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('returns error when user is not a member of the challenge', async () => { + let anotherUser = await generateUser(); + + await expect(anotherUser.put(`/tasks/${task._id}`, { + text: 'some new text', + up: false, + down: false, + notes: 'some new notes', + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyChalLeaderEditTasks'), + }); + }); + }); + + context('validates params', () => { + let task; + + beforeEach(async () => { + task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + }); + }); + + it(`ignores setting _id, type, userId, history, createdAt, + updatedAt, challenge, completed, streak, + dateCompleted fields`, async () => { + let savedTask = await user.put(`/tasks/${task._id}`, { + _id: 123, + type: 'daily', + userId: 123, + history: [123], + createdAt: 'yesterday', + updatedAt: 'tomorrow', + challenge: 'no', + completed: true, + streak: 25, + dateCompleted: 'never', + }); + + expect(savedTask._id).to.equal(task._id); + expect(savedTask.type).to.equal(task.type); + expect(savedTask.userId).to.equal(task.userId); + expect(savedTask.history).to.eql(task.history); + expect(savedTask.createdAt).to.equal(task.createdAt); + expect(savedTask.updatedAt).to.be.greaterThan(task.updatedAt); + expect(savedTask.challenge._id).to.equal(task.challenge._id); + expect(savedTask.completed).to.equal(task.completed); + expect(savedTask.streak).to.equal(task.streak); + expect(savedTask.dateCompleted).to.equal(task.dateCompleted); + }); + + it('ignores invalid fields', async () => { + let savedTask = await user.put(`/tasks/${task._id}`, { + notValid: true, + }); + + expect(savedTask.notValid).to.be.undefined; + }); + }); + + context('habits', () => { + let habit; + + beforeEach(async () => { + habit = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + notes: 1976, + }); + }); + + it('updates a habit', async () => { + let savedHabit = await user.put(`/tasks/${habit._id}`, { + text: 'some new text', + up: false, + down: false, + notes: 'some new notes', + }); + + expect(savedHabit.text).to.eql('some new text'); + expect(savedHabit.notes).to.eql('some new notes'); + expect(savedHabit.up).to.eql(false); + expect(savedHabit.down).to.eql(false); + }); + }); + + context('todos', () => { + let todo; + + beforeEach(async () => { + todo = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test todo', + type: 'todo', + notes: 1976, + }); + }); + + it('updates a todo', async () => { + let savedTodo = await user.put(`/tasks/${todo._id}`, { + text: 'some new text', + notes: 'some new notes', + }); + + expect(savedTodo.text).to.eql('some new text'); + expect(savedTodo.notes).to.eql('some new notes'); + }); + + it('can update checklists (replace it)', async () => { + await user.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 123, completed: false}, + {text: 456, completed: true}, + ], + }); + + let savedTodo = await user.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 789, completed: false}, + ], + }); + + expect(savedTodo.checklist.length).to.equal(1); + expect(savedTodo.checklist[0].text).to.equal('789'); + expect(savedTodo.checklist[0].completed).to.equal(false); + }); + + it('can update tags (replace them)', async () => { + let finalUUID = generateUUID(); + await user.put(`/tasks/${todo._id}`, { + tags: [generateUUID(), generateUUID()], + }); + + let savedTodo = await user.put(`/tasks/${todo._id}`, { + tags: [finalUUID], + }); + + expect(savedTodo.tags.length).to.equal(1); + expect(savedTodo.tags[0]).to.equal(finalUUID); + }); + }); + + context('dailys', () => { + let daily; + + beforeEach(async () => { + daily = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test daily', + type: 'daily', + notes: 1976, + }); + }); + + it('updates a daily', async () => { + let savedDaily = await user.put(`/tasks/${daily._id}`, { + text: 'some new text', + notes: 'some new notes', + frequency: 'daily', + everyX: 5, + }); + + expect(savedDaily.text).to.eql('some new text'); + expect(savedDaily.notes).to.eql('some new notes'); + expect(savedDaily.frequency).to.eql('daily'); + expect(savedDaily.everyX).to.eql(5); + }); + + it('can update checklists (replace it)', async () => { + await user.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 123, completed: false}, + {text: 456, completed: true}, + ], + }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 789, completed: false}, + ], + }); + + expect(savedDaily.checklist.length).to.equal(1); + expect(savedDaily.checklist[0].text).to.equal('789'); + expect(savedDaily.checklist[0].completed).to.equal(false); + }); + + it('can update tags (replace them)', async () => { + let finalUUID = generateUUID(); + await user.put(`/tasks/${daily._id}`, { + tags: [generateUUID(), generateUUID()], + }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + tags: [finalUUID], + }); + + expect(savedDaily.tags.length).to.equal(1); + expect(savedDaily.tags[0]).to.equal(finalUUID); + }); + + it('updates repeat, even if frequency is set to daily', async () => { + await user.put(`/tasks/${daily._id}`, { + frequency: 'daily', + }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + repeat: { + m: false, + su: false, + }, + }); + + expect(savedDaily.repeat).to.eql({ + m: false, + t: true, + w: true, + th: true, + f: true, + s: true, + su: false, + }); + }); + + it('updates everyX, even if frequency is set to weekly', async () => { + await user.put(`/tasks/${daily._id}`, { + frequency: 'weekly', + }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + everyX: 5, + }); + + expect(savedDaily.everyX).to.eql(5); + }); + + it('defaults startDate to today if none date object is passed in', async () => { + let savedDaily = await user.put(`/tasks/${daily._id}`, { + frequency: 'weekly', + }); + + expect((new Date(savedDaily.startDate)).getDay()).to.eql((new Date()).getDay()); + }); + }); + + context('rewards', () => { + let reward; + + beforeEach(async () => { + reward = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test reward', + type: 'reward', + notes: 1976, + value: 10, + }); + }); + + it('updates a reward', async () => { + let savedReward = await user.put(`/tasks/${reward._id}`, { + text: 'some new text', + notes: 'some new notes', + value: 10, + }); + + expect(savedReward.text).to.eql('some new text'); + expect(savedReward.notes).to.eql('some new notes'); + expect(savedReward.value).to.eql(10); + }); + + it('requires value to be coerced into a number', async () => { + let savedReward = await user.put(`/tasks/${reward._id}`, { + value: '100', + }); + + expect(savedReward.value).to.eql(100); + }); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index cfab73241c..bca00b3f39 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -301,7 +301,7 @@ api.updateTask = { if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one @@ -320,8 +320,8 @@ api.updateTask = { delete req.body.tags; } - // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, bad for performances? - // TODO regarding comment above make sure other models with nested fields are using this trick too + // TODO we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? + // TODO regarding comment above, make sure other models with nested fields are using this trick too _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body))); // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() From 29b6e958c673bbf75791f771cf80e613a7155a8a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 11 Mar 2016 09:51:59 +0100 Subject: [PATCH 514/976] fix failing tests --- test/api/v3/integration/tasks/PUT-tasks_id.test.js | 2 +- .../tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js | 2 +- website/src/controllers/api-v3/tasks.js | 2 +- website/src/models/task.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 0608fa5174..c5bc5d050f 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -79,7 +79,7 @@ describe('PUT /tasks/:id', () => { let id2 = generateUUID(); let savedDaily = await user.put(`/tasks/${daily._id}`, { - checklist: [ + reminders: [ {id: id1, time: new Date(), startDate: new Date()}, {id: id2, time: new Date(), startDate: new Date()}, ], diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index 3f375c3c77..dee75ed914 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -25,7 +25,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { text: 'updated', completed: true, - _id: 123, + _id: 123, // ignored }); expect(savedTask.checklist.length).to.equal(1); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 20ae68739e..fcb95bd3d7 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -570,7 +570,7 @@ api.addChecklistItem = { if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); + task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); // TODO why not allow to supply _id on creation? let savedTask = await task.save(); res.respond(200, savedTask); // TODO what to return diff --git a/website/src/models/task.js b/website/src/models/task.js index e35f9a155a..a3e657605d 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -70,7 +70,7 @@ TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) }; // Sanitize reminder objects (disallowing id) -TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (reminderObj) { +TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) { delete reminderObj.id; return reminderObj; }; From 0af912bebb3af0149cdd2d070b20dc566dc30a48 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 11 Mar 2016 14:19:46 -0600 Subject: [PATCH 515/976] Add initial tests for add checklist item to challenge --- ...lenge_challengeId_taskId_checklist.test.js | 119 ++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 2 +- 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js new file mode 100644 index 0000000000..00511390c9 --- /dev/null +++ b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js @@ -0,0 +1,119 @@ +import { + generateUser, + generateGroup, + generateChallenge, + translate as t, +} from '../../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/checklist/', () => { + let user; + let guild; + let challenge; + + before(async () => { + user = await generateUser(); + guild = await generateGroup(user); + challenge = await generateChallenge(user, guild); + }); + + it('fails on task not found', async () => { + await expect(user.post(`/tasks/${generateUUID()}/checklist`, { + text: 'Checklist Item 1', + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('returns error when user is not a member of the challenge', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'daily', + text: 'Daily with checklist', + }); + + let anotherUser = await generateUser(); + + await expect(anotherUser.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + ignored: false, + _id: 123, + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyChalLeaderEditTasks'), + }); + }); + + it('adds a checklist item to a daily', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'daily', + text: 'Daily with checklist', + }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + ignored: false, + _id: 123, + }); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); + expect(savedTask.checklist[0].completed).to.equal(false); + expect(savedTask.checklist[0]._id).to.be.a('string'); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].ignored).to.be.an('undefined'); + }); + + it('adds a checklist item to a todo', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'todo', + text: 'Todo with checklist', + }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + ignored: false, + _id: 123, + }); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); + expect(savedTask.checklist[0].completed).to.equal(false); + expect(savedTask.checklist[0]._id).to.be.a('string'); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].ignored).to.be.an('undefined'); + }); + + it('does not add a checklist to habits', async () => { + let habit = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'habit', + text: 'habit with checklist', + }); + + await expect(user.post(`/tasks/${habit._id}/checklist`, { + text: 'Checklist Item 1', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('does not add a checklist to rewards', async () => { + let reward = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'reward', + text: 'reward with checklist', + }); + + await expect(user.post(`/tasks/${reward._id}/checklist`, { + text: 'Checklist Item 1', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index fcb95bd3d7..de50018b1e 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -561,7 +561,7 @@ api.addChecklistItem = { if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one From e4124f9e023d411d87a161f905dd315d8b750239 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 12 Mar 2016 10:38:40 -0600 Subject: [PATCH 516/976] Added initial checklist item update tests --- ...allengeId_tasksId_checklist_itemId.test.js | 155 ++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 4 +- 2 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js diff --git a/test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js b/test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js new file mode 100644 index 0000000000..d6a7002891 --- /dev/null +++ b/test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js @@ -0,0 +1,155 @@ +import { + generateUser, + generateGroup, + generateChallenge, + translate as t, +} from '../../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; + +describe('PUT /tasks/:taskId/checklist/:itemId', () => { + let user; + let guild; + let challenge; + + before(async () => { + user = await generateUser(); + guild = await generateGroup(user); + challenge = await generateChallenge(user, guild); + }); + + it('fails on task not found', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'todo', + text: 'Todo with checklist', + }); + + await expect(user.put(`/tasks/${task._id}/checklist/${generateUUID()}`, { + text: 'updated', + completed: true, + _id: 123, // ignored + })) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); + + it('returns error when user is not a member of the challenge', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'todo', + text: 'Todo with checklist', + }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + completed: false, + }); + + let anotherUser = await generateUser(); + + await expect(anotherUser.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { + text: 'updated', + completed: true, + _id: 123, // ignored + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyChalLeaderEditTasks'), + }); + }); + + it('updates a checklist item on dailies', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'daily', + text: 'Daily with checklist', + }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + completed: false, + }); + + savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { + text: 'updated', + completed: true, + _id: 123, // ignored + }); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('updated'); + expect(savedTask.checklist[0].completed).to.equal(true); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + }); + + it('updates a checklist item on todos', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'todo', + text: 'Todo with checklist', + }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + completed: false, + }); + + savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { + text: 'updated', + completed: true, + _id: 123, // ignored + }); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('updated'); + expect(savedTask.checklist[0].completed).to.equal(true); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + }); + + it('fails on habits', async () => { + let habit = await user.post('/tasks/user', { + type: 'habit', + text: 'habit with checklist', + }); + + await expect(user.put(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on rewards', async () => { + let reward = await user.post('/tasks/user', { + type: 'reward', + text: 'reward with checklist', + }); + + await expect(user.put(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', async () => { + await expect(user.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', async () => { + let createdTask = await user.post('/tasks/user', { + type: 'daily', + text: 'daily with checklist', + }); + + await expect(user.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index fcb95bd3d7..ac22f9d203 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -561,7 +561,7 @@ api.addChecklistItem = { if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one @@ -652,7 +652,7 @@ api.updateChecklistItem = { if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one From 152f7605cdd1e5b6b735c0cb22fafe936c2b781f Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 12 Mar 2016 10:55:12 -0600 Subject: [PATCH 517/976] Added initial test for removing challenge checklist item --- ...lenge_challengeId_checklist_itemId.test.js | 115 ++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 2 +- 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js diff --git a/test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js b/test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js new file mode 100644 index 0000000000..c1fa6699e9 --- /dev/null +++ b/test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js @@ -0,0 +1,115 @@ +import { + generateUser, + generateGroup, + generateChallenge, + translate as t, +} from '../../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /tasks/:taskId/checklist/:itemId', () => { + let user; + let guild; + let challenge; + + before(async () => { + user = await generateUser(); + guild = await generateGroup(user); + challenge = await generateChallenge(user, guild); + }); + + it('fails on task not found', async () => { + await expect(user.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', async () => { + let createdTask = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'daily', + text: 'daily with checklist', + }); + + await expect(user.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); + + it('returns error when user is not a member of the challenge', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'daily', + text: 'Daily with checklist', + }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + completed: false, + }); + + let anotherUser = await generateUser(); + + await expect(anotherUser.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyChalLeaderEditTasks'), + }); + }); + + it('deletes a checklist item from a daily', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'daily', + text: 'Daily with checklist', + }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + + await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + savedTask = await user.get(`/tasks/${task._id}`); + + expect(savedTask.checklist.length).to.equal(0); + }); + + it('deletes a checklist item from a todo', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'todo', + text: 'Todo with checklist', + }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + + await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + savedTask = await user.get(`/tasks/${task._id}`); + + expect(savedTask.checklist.length).to.equal(0); + }); + + it('does not work with habits', async () => { + let habit = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'habit', + text: 'habit with checklist', + }); + + await expect(user.del(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('does not work with rewards', async () => { + let reward = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'reward', + text: 'reward with checklist', + }); + + await expect(user.del(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 0ec9d524f7..7e8058d830 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -703,7 +703,7 @@ api.removeChecklistItem = { if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one From c04e53b5a587caefe3a05535401ee41c553d64cb Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 12 Mar 2016 21:00:43 -0600 Subject: [PATCH 518/976] Added initial delete challenge tasks tests --- ...ETE-tasks_id_challenge_challengeId.test.js | 55 +++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 2 +- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js diff --git a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js new file mode 100644 index 0000000000..9ec2a65f8e --- /dev/null +++ b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js @@ -0,0 +1,55 @@ +import { + generateUser, + generateGroup, + generateChallenge, + translate as t, +} from '../../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /tasks/:id', () => { + let user; + let guild; + let challenge; + let task; + + before(async () => { + user = await generateUser(); + guild = await generateGroup(user); + challenge = await generateChallenge(user, guild); + }); + + beforeEach(async () => { + task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + }); + }); + + it('cannot delete a non-existant task', async () => { + await expect(user.del(`/tasks/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('returns error when user is not leader of the challenge', async () => { + let anotherUser = await generateUser(); + + await expect(anotherUser.del(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyChalLeaderEditTasks'), + }); + }); + + it('deletes a user\'s task', async () => { + await user.del(`/tasks/${task._id}`); + + await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index b78615e448..952076ab82 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -909,7 +909,7 @@ api.deleteTask = { if (!task) { throw new NotFound(res.t('taskNotFound')); } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one From 46322cfd3828d92813ec3b18a8d0f6f9480bd467 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sat, 12 Mar 2016 23:32:20 -0800 Subject: [PATCH 519/976] update username, password --- common/locales/en/api-v3.json | 2 + ...test.js => POST-user_update_email.test.js} | 6 +- .../user/POST-user_update_password.test.js | 53 +++++++++++ .../user/POST-user_update_username.test.js | 82 +++++++++++++++++ website/src/controllers/api-v3/user.js | 89 +++++++++++++++++++ 5 files changed, 229 insertions(+), 3 deletions(-) rename test/api/v3/integration/user/{POST-user-update-email.test.js => POST-user_update_email.test.js} (94%) create mode 100644 test/api/v3/integration/user/POST-user_update_password.test.js create mode 100644 test/api/v3/integration/user/POST-user_update_username.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 0d2e1cfc50..1b0525004e 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -4,7 +4,9 @@ "missingEmail": "Missing email.", "missingUsername": "Missing username.", "missingPassword": "Missing password.", + "missingNewPassword": "Missing newPassword.", "wrongPassword": "Wrong password.", + "passwordSaved": "New password has been saved.", "notAnEmail": "Invalid email address.", "emailTaken": "Email already taken.", "newEmailRequired": "The newEmail body parameter is required.", diff --git a/test/api/v3/integration/user/POST-user-update-email.test.js b/test/api/v3/integration/user/POST-user_update_email.test.js similarity index 94% rename from test/api/v3/integration/user/POST-user-update-email.test.js rename to test/api/v3/integration/user/POST-user_update_email.test.js index 08f8e4282f..5a8392ec1e 100644 --- a/test/api/v3/integration/user/POST-user-update-email.test.js +++ b/test/api/v3/integration/user/POST-user_update_email.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; import { model as User } from '../../../../../website/src/models/user'; -describe('POST /email/update', () => { +describe('POST /user/update-email', () => { let user; let fbUser; let endpoint = '/user/update-email'; @@ -20,7 +20,7 @@ describe('POST /email/update', () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: 'Invalid request parameters.', + message: t('invalidReqParams'), }); }); @@ -30,7 +30,7 @@ describe('POST /email/update', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: 'Invalid request parameters.', + message: t('invalidReqParams'), }); }); diff --git a/test/api/v3/integration/user/POST-user_update_password.test.js b/test/api/v3/integration/user/POST-user_update_password.test.js new file mode 100644 index 0000000000..daecbed4ed --- /dev/null +++ b/test/api/v3/integration/user/POST-user_update_password.test.js @@ -0,0 +1,53 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { model as User } from '../../../../../website/src/models/user'; + +describe('POST /user/update-password', async () => { + let endpoint = '/user/update-password'; + let user; + let password = 'password'; + let wrongPassword = 'wrong-password'; + let newPassword = 'new-password'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('successfully changes the password', async () => { + let previousHashedPassword = user.auth.local.hashed_password; + let response = await user.post(endpoint, { + password, + newPassword, + confirmPassword: newPassword, + }); + expect(response).to.eql({ message: t('passwordSaved') }); + user = await User.findOne({ _id: user._id }); + expect(user.auth.local.hashed_password).to.not.eql(previousHashedPassword); + }); + + it('new passwords mismatch', async () => { + await expect(user.post(endpoint, { + password, + newPassword, + confirmPassword: `${newPassword}-wrong-confirmation`, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('passwordConfirmationMatch'), + }); + }); + + it('existing password is wrong', async () => { + await expect(user.post(endpoint, { + password: wrongPassword, + newPassword, + confirmPassword: newPassword, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_update_username.test.js b/test/api/v3/integration/user/POST-user_update_username.test.js new file mode 100644 index 0000000000..1c56087007 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_update_username.test.js @@ -0,0 +1,82 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { model as User } from '../../../../../website/src/models/user'; + +describe('POST /user/update-username', async () => { + let endpoint = '/user/update-username'; + let user; + let newUsername = 'new-username'; + let existingUsername = 'existing-username'; + let password = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js + let wrongPassword = 'wrong-password'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('successfully changes username', async () => { + let response = await user.post(endpoint, { + username: newUsername, + password, + }); + expect(response).to.eql({ username: newUsername }); + user = await User.findOne({ _id: user._id }); + expect(user.auth.local.username).to.eql(newUsername); + }); + + context('errors', async () => { + describe('new username is unavailable', async () => { + beforeEach(async () => { + user = await generateUser(); + await user.update({'auth.local.username': existingUsername }); + }); + it('prevents username update', async () => { + await expect(user.post(endpoint, { + username: existingUsername, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameTaken'), + }); + }); + }); + it('password is wrong', async () => { + await expect(user.post(endpoint, { + username: newUsername, + password: wrongPassword, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); + describe('social-only user', async () => { + beforeEach(async () => { + user = await generateUser(); + await user.update({ 'auth.local': { ok: true } }); + }); + it('prevents username update', async () => { + await expect(user.post(endpoint, { + username: newUsername, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('userHasNoLocalRegistration'), + }); + }); + }); + it('new username is not provided', async () => { + await expect(user.post(endpoint, { + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 4b61d441d1..6cd73aeba0 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -42,6 +42,95 @@ api.getUser = { }, }; +/** + * @api {post} /user/update-password + * @apiVersion 3.0.0 + * @apiName updatePassword + * @apiGroup User + * @apiParam {string} password The old password + * @apiParam {string} newPassword The new password + * @apiParam {string} confirmPassword Password confirmation + * @apiSuccess {Object} The success message + **/ +api.updatePassword = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/update-password', + async handler (req, res) { + let user = res.locals.user; + + if (!user.auth.local.hashed_password) throw new BadRequest(res.t('userHasNoLocalRegistration')); + + let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + + req.checkBody({ + password: { + notEmpty: {errorMessage: res.t('missingNewPassword')}, + }, + newPassword: { + notEmpty: {errorMessage: res.t('missingPassword')}, + }, + }); + + if (req.body.newPassword !== req.body.confirmPassword) throw new NotAuthorized(res.t('passwordConfirmationMatch')); + + user.auth.local.hashed_password = passwordUtils.encrypt(req.body.newPassword, user.auth.local.salt); // eslint-disable-line camelcase + user.save(); + + res.send(200, { message: res.t('passwordSaved') }); + }, +}; + +/** + * @api {post} /user/update-username + * @apiVersion 3.0.0 + * @apiName updateUsername + * @apiGroup User + * @apiParam {string} password The password + * @apiParam {string} username New username + * @apiSuccess {Object} The new username + **/ +api.updateUsername = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/update-username', + async handler (req, res) { + let user = res.locals.user; + + req.checkBody({ + password: { + notEmpty: {errorMessage: res.t('missingPassword')}, + }, + username: { + notEmpty: { errorMessage: res.t('missingUsername') }, + }, + }); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.auth.local.username) throw new BadRequest(res.t('userHasNoLocalRegistration')); + + let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + + // check username already exists + let candidateUser = await User.findOne({ + 'auth.local.username': req.body.username, + }, {'auth.local': 1}).exec(); + if (candidateUser) throw new BadRequest(res.t('usernameTaken')); + + // save username + user.auth.local.lowerCaseUsername = req.body.username.toLowerCase(); + user.auth.local.username = req.body.username; + user.save(); + + res.send(200, { username: req.body.username }); + }, +}; + + /** * @api {post} /user/update-email * @apiVersion 3.0.0 From c14f0c424756bf7cf4ec98c0893efca64f3d7e1a Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sat, 12 Mar 2016 23:32:20 -0800 Subject: [PATCH 520/976] update username, password --- common/locales/en/api-v3.json | 2 + ...test.js => POST-user_update_email.test.js} | 6 +- .../user/POST-user_update_password.test.js | 53 ++++++++++++ .../user/POST-user_update_username.test.js | 82 ++++++++++++++++++ website/src/controllers/api-v3/user.js | 86 +++++++++++++++++++ 5 files changed, 226 insertions(+), 3 deletions(-) rename test/api/v3/integration/user/{POST-user-update-email.test.js => POST-user_update_email.test.js} (94%) create mode 100644 test/api/v3/integration/user/POST-user_update_password.test.js create mode 100644 test/api/v3/integration/user/POST-user_update_username.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 0d2e1cfc50..1b0525004e 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -4,7 +4,9 @@ "missingEmail": "Missing email.", "missingUsername": "Missing username.", "missingPassword": "Missing password.", + "missingNewPassword": "Missing newPassword.", "wrongPassword": "Wrong password.", + "passwordSaved": "New password has been saved.", "notAnEmail": "Invalid email address.", "emailTaken": "Email already taken.", "newEmailRequired": "The newEmail body parameter is required.", diff --git a/test/api/v3/integration/user/POST-user-update-email.test.js b/test/api/v3/integration/user/POST-user_update_email.test.js similarity index 94% rename from test/api/v3/integration/user/POST-user-update-email.test.js rename to test/api/v3/integration/user/POST-user_update_email.test.js index 08f8e4282f..5a8392ec1e 100644 --- a/test/api/v3/integration/user/POST-user-update-email.test.js +++ b/test/api/v3/integration/user/POST-user_update_email.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; import { model as User } from '../../../../../website/src/models/user'; -describe('POST /email/update', () => { +describe('POST /user/update-email', () => { let user; let fbUser; let endpoint = '/user/update-email'; @@ -20,7 +20,7 @@ describe('POST /email/update', () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: 'Invalid request parameters.', + message: t('invalidReqParams'), }); }); @@ -30,7 +30,7 @@ describe('POST /email/update', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: 'Invalid request parameters.', + message: t('invalidReqParams'), }); }); diff --git a/test/api/v3/integration/user/POST-user_update_password.test.js b/test/api/v3/integration/user/POST-user_update_password.test.js new file mode 100644 index 0000000000..e557655415 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_update_password.test.js @@ -0,0 +1,53 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { model as User } from '../../../../../website/src/models/user'; + +describe('POST /user/update-password', async () => { + let endpoint = '/user/update-password'; + let user; + let password = 'password'; + let wrongPassword = 'wrong-password'; + let newPassword = 'new-password'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('successfully changes the password', async () => { + let previousHashedPassword = user.auth.local.hashed_password; + let response = await user.post(endpoint, { + password, + newPassword, + confirmPassword: newPassword, + }); + expect(response).to.eql({}); + user = await User.findOne({ _id: user._id }); + expect(user.auth.local.hashed_password).to.not.eql(previousHashedPassword); + }); + + it('new passwords mismatch', async () => { + await expect(user.post(endpoint, { + password, + newPassword, + confirmPassword: `${newPassword}-wrong-confirmation`, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('passwordConfirmationMatch'), + }); + }); + + it('existing password is wrong', async () => { + await expect(user.post(endpoint, { + password: wrongPassword, + newPassword, + confirmPassword: newPassword, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_update_username.test.js b/test/api/v3/integration/user/POST-user_update_username.test.js new file mode 100644 index 0000000000..4e59fcb5b8 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_update_username.test.js @@ -0,0 +1,82 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { model as User } from '../../../../../website/src/models/user'; + +describe('POST /user/update-username', async () => { + let endpoint = '/user/update-username'; + let user; + let newUsername = 'new-username'; + let existingUsername = 'existing-username'; + let password = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js + let wrongPassword = 'wrong-password'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('successfully changes username', async () => { + let response = await user.post(endpoint, { + username: newUsername, + password, + }); + expect(response).to.eql({ username: newUsername }); + user = await User.findOne({ _id: user._id }); + expect(user.auth.local.username).to.eql(newUsername); + }); + + context('errors', async () => { + describe('new username is unavailable', async () => { + beforeEach(async () => { + user = await generateUser(); + await user.update({'auth.local.username': existingUsername, 'auth.local.lowerCaseUsername': existingUsername }); + }); + it('prevents username update', async () => { + await expect(user.post(endpoint, { + username: existingUsername, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameTaken'), + }); + }); + }); + it('password is wrong', async () => { + await expect(user.post(endpoint, { + username: newUsername, + password: wrongPassword, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); + describe('social-only user', async () => { + beforeEach(async () => { + user = await generateUser(); + await user.update({ 'auth.local': { ok: true } }); + }); + it('prevents username update', async () => { + await expect(user.post(endpoint, { + username: newUsername, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('userHasNoLocalRegistration'), + }); + }); + }); + it('new username is not provided', async () => { + await expect(user.post(endpoint, { + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 4b61d441d1..d79caf2e6e 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -42,6 +42,92 @@ api.getUser = { }, }; +/** + * @api {post} /user/update-password + * @apiVersion 3.0.0 + * @apiName updatePassword + * @apiGroup User + * @apiParam {string} password The old password + * @apiParam {string} newPassword The new password + * @apiParam {string} confirmPassword Password confirmation + * @apiSuccess {Object} The success message + **/ +api.updatePassword = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/update-password', + async handler (req, res) { + let user = res.locals.user; + + if (!user.auth.local.hashed_password) throw new BadRequest(res.t('userHasNoLocalRegistration')); + + let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + + req.checkBody({ + password: { + notEmpty: {errorMessage: res.t('missingNewPassword')}, + }, + newPassword: { + notEmpty: {errorMessage: res.t('missingPassword')}, + }, + }); + + if (req.body.newPassword !== req.body.confirmPassword) throw new NotAuthorized(res.t('passwordConfirmationMatch')); + + user.auth.local.hashed_password = passwordUtils.encrypt(req.body.newPassword, user.auth.local.salt); // eslint-disable-line camelcase + await user.save(); + + res.send(200, {}); + }, +}; + +/** + * @api {post} /user/update-username + * @apiVersion 3.0.0 + * @apiName updateUsername + * @apiGroup User + * @apiParam {string} password The password + * @apiParam {string} username New username + * @apiSuccess {Object} The new username + **/ +api.updateUsername = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/update-username', + async handler (req, res) { + let user = res.locals.user; + + req.checkBody({ + password: { + notEmpty: {errorMessage: res.t('missingPassword')}, + }, + username: { + notEmpty: { errorMessage: res.t('missingUsername') }, + }, + }); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.auth.local.username) throw new BadRequest(res.t('userHasNoLocalRegistration')); + + let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + + let count = await User.count({ 'auth.local.lowerCaseUsername': req.body.username.toLowerCase() }); + if (count > 0) throw new BadRequest(res.t('usernameTaken')); + + // save username + user.auth.local.lowerCaseUsername = req.body.username.toLowerCase(); + user.auth.local.username = req.body.username; + await user.save(); + + res.send(200, { username: req.body.username }); + }, +}; + + /** * @api {post} /user/update-email * @apiVersion 3.0.0 From 0b257202329b80b0e52977892f460e41e05a0f4d Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sun, 13 Mar 2016 14:01:53 -0700 Subject: [PATCH 521/976] cleanup --- common/locales/en/api-v3.json | 1 - .../api/v3/integration/user/POST-user_update_password.test.js | 3 +-- website/src/controllers/api-v3/user.js | 4 ++-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 1b0525004e..58b3746bfe 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -6,7 +6,6 @@ "missingPassword": "Missing password.", "missingNewPassword": "Missing newPassword.", "wrongPassword": "Wrong password.", - "passwordSaved": "New password has been saved.", "notAnEmail": "Invalid email address.", "emailTaken": "Email already taken.", "newEmailRequired": "The newEmail body parameter is required.", diff --git a/test/api/v3/integration/user/POST-user_update_password.test.js b/test/api/v3/integration/user/POST-user_update_password.test.js index e557655415..728abb13db 100644 --- a/test/api/v3/integration/user/POST-user_update_password.test.js +++ b/test/api/v3/integration/user/POST-user_update_password.test.js @@ -2,7 +2,6 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration/v3'; -import { model as User } from '../../../../../website/src/models/user'; describe('POST /user/update-password', async () => { let endpoint = '/user/update-password'; @@ -23,7 +22,7 @@ describe('POST /user/update-password', async () => { confirmPassword: newPassword, }); expect(response).to.eql({}); - user = await User.findOne({ _id: user._id }); + await user.sync(); expect(user.auth.local.hashed_password).to.not.eql(previousHashedPassword); }); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index a8f4ecbce8..c4a68df4e9 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -77,7 +77,7 @@ api.updatePassword = { user.auth.local.hashed_password = passwordUtils.encrypt(req.body.newPassword, user.auth.local.salt); // eslint-disable-line camelcase await user.save(); - res.send(200, {}); + res.respond(200, {}); }, }; @@ -122,7 +122,7 @@ api.updateUsername = { user.auth.local.username = req.body.username; await user.save(); - res.send(200, { username: req.body.username }); + res.respond(200, { username: req.body.username }); }, }; From 8e3284a4e3edf40b75a4e7aeaf8d79f3aa362ced Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 13 Mar 2016 22:26:32 +0100 Subject: [PATCH 522/976] wip(shared): adapt to v3 --- common/script/.eslintrc | 5 + common/script/content/spells.js | 2 +- common/script/index.js | 196 ++++++++-------- common/script/{api-v3 => libs}/errors.js | 2 +- .../{api-v3 => }/libs/extendableBuiltin.js | 0 common/script/ops/index.js | 4 +- common/script/ops/score.js | 221 ------------------ common/script/{api-v3 => ops}/scoreTask.js | 2 +- package.json | 2 +- tasks/gulp-eslint.js | 3 +- tasks/gulp-tests.js | 10 + .../DELETE-challenges_challengeId.test.js | 3 +- test/api/v3/unit/libs/errors.test.js | 2 +- test/{common => common_old}/algos.mocha.js | 0 test/{common => common_old}/count.js | 0 test/{common => common_old}/dailies.js | 0 .../{common => common_old}/preenTodos.test.js | 0 test/{common => common_old}/preening.test.js | 0 .../shared.spells.test.js | 0 .../simulations/autoAllocate.js | 0 .../simulations/passive_active_attrs.js | 0 .../statHelpers.test.js | 0 test/{common => common_old}/test_helper.js | 0 .../user.fns.buy.test.js | 0 .../user.fns.ultimateGear.test.js | 0 .../user.fns.updateStats.test.js | 0 .../user.ops.buyMysterySet.test.js | 0 .../user.ops.equip.test.js | 0 test/{common => common_old}/user.ops.hatch.js | 0 .../user.ops.hourglassPurchase.test.js | 0 test/{common => common_old}/user.ops.test.js | 0 website/src/controllers/api-v3/tasks.js | 2 +- website/src/libs/api-v3/errors.js | 8 +- website/src/middlewares/api-v3/cron.js | 2 +- 34 files changed, 134 insertions(+), 330 deletions(-) create mode 100644 common/script/.eslintrc rename common/script/{api-v3 => libs}/errors.js (94%) rename common/script/{api-v3 => }/libs/extendableBuiltin.js (100%) delete mode 100644 common/script/ops/score.js rename common/script/{api-v3 => ops}/scoreTask.js (99%) rename test/{common => common_old}/algos.mocha.js (100%) rename test/{common => common_old}/count.js (100%) rename test/{common => common_old}/dailies.js (100%) rename test/{common => common_old}/preenTodos.test.js (100%) rename test/{common => common_old}/preening.test.js (100%) rename test/{common => common_old}/shared.spells.test.js (100%) rename test/{common => common_old}/simulations/autoAllocate.js (100%) rename test/{common => common_old}/simulations/passive_active_attrs.js (100%) rename test/{common => common_old}/statHelpers.test.js (100%) rename test/{common => common_old}/test_helper.js (100%) rename test/{common => common_old}/user.fns.buy.test.js (100%) rename test/{common => common_old}/user.fns.ultimateGear.test.js (100%) rename test/{common => common_old}/user.fns.updateStats.test.js (100%) rename test/{common => common_old}/user.ops.buyMysterySet.test.js (100%) rename test/{common => common_old}/user.ops.equip.test.js (100%) rename test/{common => common_old}/user.ops.hatch.js (100%) rename test/{common => common_old}/user.ops.hourglassPurchase.test.js (100%) rename test/{common => common_old}/user.ops.test.js (100%) diff --git a/common/script/.eslintrc b/common/script/.eslintrc new file mode 100644 index 0000000000..596ff03c45 --- /dev/null +++ b/common/script/.eslintrc @@ -0,0 +1,5 @@ +{ + "globals": { + "window": true, + } +} diff --git a/common/script/content/spells.js b/common/script/content/spells.js index b779e5f56b..41a6571dec 100644 --- a/common/script/content/spells.js +++ b/common/script/content/spells.js @@ -1,6 +1,6 @@ import t from './translation'; import _ from 'lodash'; -import { NotAuthorized } from '../api-v3/errors'; +import { NotAuthorized } from '../libs/errors'; /* --------------------------------------------------------------- Spells diff --git a/common/script/index.js b/common/script/index.js index 27377ae422..3eb3a3371b 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -1,94 +1,110 @@ -import moment from 'moment'; import _ from 'lodash'; -import { - daysSince, - shouldDo, -} from './cron'; +// When using a common module from the website or the server NEVER import the module directly +// but access it through `api` (the main common) module, otherwise you would require the non transpiled version of the file in production. +let api = module.exports = {}; + +import content from './content/index'; +api.content = content; + +import * as errors from './libs/errors'; +api.errors = errors; +import i18n from './i18n'; +api.i18n = i18n; + +// TODO under api.libs.cron? +import { shouldDo, daysSince } from './cron'; +api.shouldDo = shouldDo; +api.daysSince = daysSince; + +// TODO under api.constants? import { MAX_HEALTH, MAX_LEVEL, MAX_STAT_POINTS, } from './constants'; -import * as statHelpers from './statHelpers'; - -import importedLibs from './libs'; - -var $w, preenHistory, sortOrder, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; - -import content from './content/index'; -import i18n from './i18n'; - -import * as errors from './api-v3/errors'; -import scoreTask from './api-v3/scoreTask'; - -let api = module.exports = {}; - -// Temporary location of API v3 files (soon to be removed) -api.v3 = { - scoreTask, - errors, -}; - -api.i18n = i18n; -api.shouldDo = shouldDo; - api.maxLevel = MAX_LEVEL; -api.capByLevel = statHelpers.capByLevel; api.maxHealth = MAX_HEALTH; +api.maxStatPoints = MAX_STAT_POINTS; + +// TODO under api.libs.statHelpers? +import * as statHelpers from './statHelpers'; +api.capByLevel = statHelpers.capByLevel; api.tnl = statHelpers.toNextLevel; api.diminishingReturns = statHelpers.diminishingReturns; -$w = api.$w = importedLibs.splitWhitespace; -api.dotSet = importedLibs.dotSet; -api.dotGet = importedLibs.dotGet; -api.refPush = importedLibs.refPush; -api.planGemLimits = importedLibs.planGemLimits; +// TODO under api.libs? +import splitWhitespace from './libs/splitWhitespace'; +const $w = api.$w = splitWhitespace; -preenHistory = importedLibs.preenHistory; +import dotSet from './libs/dotSet'; +api.dotSet = dotSet; -api.preenTodos = importedLibs.preenTodos; -api.updateStore = importedLibs.updateStore; +import dotGet from './libs/dotGet'; +api.dotGet = dotGet; +import refPush from './libs/refPush'; +api.refPush = refPush; -/* ------------------------------------------------------- -Content ------------------------------------------------------- - */ +import planGemLimits from './libs/planGemLimits'; +api.planGemLimits = planGemLimits; -api.content = content; +import preenTodos from './libs/preenTodos'; +api.preenTodos = preenTodos; +import updateStore from './libs/updateStore'; +api.updateStore = updateStore; -/* ------------------------------------------------------- -Misc Helpers ------------------------------------------------------- - */ +import uuid from './libs/uuid'; +api.uuid = uuid; -api.uuid = importedLibs.uuid; -api.countExists = importedLibs.countExists; -api.taskDefaults = importedLibs.taskDefaults; -api.percent = importedLibs.percent; -api.removeWhitespace = importedLibs.removeWhitespace; -api.encodeiCalLink = importedLibs.encodeiCalLink; -api.gold = importedLibs.gold; -api.silver = importedLibs.silver; -api.taskClasses = importedLibs.taskClasses; -api.friendlyTimestamp = importedLibs.friendlyTimestamp; -api.newChatMessages = importedLibs.newChatMessages; -api.noTags = importedLibs.appliedTags; -api.appliedTags = importedLibs.appliedTags; +import countExists from './libs/countExists'; +api.countExists = countExists; +import taskDefaults from './libs/taskDefaults'; +api.taskDefaults = taskDefaults; -/* -Various counting functions - */ +import percent from './libs/percent'; +api.percent = percent; + +import removeWhitespace from './libs/removeWhitespace'; +api.removeWhitespace = removeWhitespace; + +import encodeiCalLink from './libs/encodeiCalLink'; +api.encodeiCalLink = encodeiCalLink; + +import gold from './libs/gold'; +api.gold = gold; + +import silver from './libs/silver'; +api.silver = silver; + +import taskClasses from './libs/taskClasses'; +api.taskClasses = taskClasses; + +import friendlyTimestamp from './libs/friendlyTimestamp'; +api.friendlyTimestamp = friendlyTimestamp; + +import newChatMessages from './libs/newChatMessages'; +api.newChatMessages = newChatMessages; + +import noTags from './libs/noTags'; +api.noTags = noTags; + +import appliedTags from './libs/appliedTags'; +api.appliedTags = appliedTags; import count from './count'; api.count = count; +// TODO As ops and fns are ported, exported them through the api object +import scoreTask from './ops/scoreTask'; + +api.ops = { + scoreTask, +}; +api.fns = {}; + /* ------------------------------------------------------ @@ -130,14 +146,11 @@ TODO import importedOps from './ops'; import importedFns from './fns'; -api.wrap = function(user, main) { - if (main == null) { - main = true; - } - if (user._wrapped) { - return; - } +// TODO redo +api.wrap = function wrapUser (user, main = true) { + if (user._wrapped) return; user._wrapped = true; + if (main) { user.ops = { update: _.partial(importedOps.update, user), @@ -184,9 +197,10 @@ api.wrap = function(user, main) { allocate: _.partial(importedOps.allocate, user), readCard: _.partial(importedOps.readCard, user), openMysteryItem: _.partial(importedOps.openMysteryItem, user), - score: _.partial(importedOps.score, user), + scoreTask: _.partial(importedOps.scoreTask, user), }; } + user.fns = { getItem: _.partial(importedFns.getItem, user), handleTwoHanded: _.partial(importedFns.handleTwoHanded, user), @@ -203,32 +217,30 @@ api.wrap = function(user, main) { ultimateGear: _.partial(importedFns.ultimateGear, user), nullify: _.partial(importedFns.nullify, user), }; + Object.defineProperty(user, '_statsComputed', { - get: function() { - var computed; - computed = _.reduce(['per', 'con', 'str', 'int'], (function(_this) { - return function(m, stat) { - m[stat] = _.reduce($w('stats stats.buffs items.gear.equipped.weapon items.gear.equipped.armor items.gear.equipped.head items.gear.equipped.shield'), function(m2, path) { - var item, val; - val = user.fns.dotGet(path); - return m2 + (~path.indexOf('items.gear') ? (item = content.gear.flat[val], (+(item != null ? item[stat] : void 0) || 0) * ((item != null ? item.klass : void 0) === user.stats["class"] || (item != null ? item.specialClass : void 0) === user.stats["class"] ? 1.5 : 1)) : +val[stat] || 0); - }, 0); - m[stat] += Math.floor(api.capByLevel(user.stats.lvl) / 2); - return m; - }; - })(this), {}); + get () { + let computed = _.reduce(['per', 'con', 'str', 'int'], (m, stat) => { + m[stat] = _.reduce($w('stats stats.buffs items.gear.equipped.weapon items.gear.equipped.armor items.gear.equipped.head items.gear.equipped.shield'), (m2, path) => { + let val = user.fns.dotGet(path); + let item; + return m2 + (path.indexOf('items.gear') !== -1 ? (item = content.gear.flat[val], (Number(!item ? item[stat] : undefined) || 0) * ((!item ? item.klass : undefined) === user.stats.class || (!item ? item.specialClass : undefined) === user.stats.class ? 1.5 : 1)) : Number(val[stat]) || 0); + }, 0); + m[stat] += Math.floor(api.capByLevel(user.stats.lvl) / 2); + return m; + }); + computed.maxMP = computed.int * 2 + 30; return computed; - } + }, }); if (typeof window !== 'undefined') { Object.defineProperty(user, 'tasks', { - get: function() { - var tasks; - tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); - return _.object(_.pluck(tasks, "id"), tasks); - } + get () { + let tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); + return _.object(_.pluck(tasks, 'id'), tasks); + }, }); } }; diff --git a/common/script/api-v3/errors.js b/common/script/libs/errors.js similarity index 94% rename from common/script/api-v3/errors.js rename to common/script/libs/errors.js index 8c758051ab..0aca2efd1f 100644 --- a/common/script/api-v3/errors.js +++ b/common/script/libs/errors.js @@ -1,4 +1,4 @@ -import extendableBuiltin from './libs/extendableBuiltin'; +import extendableBuiltin from './extendableBuiltin'; // Base class for custom application errors // It extends Error and capture the stack trace diff --git a/common/script/api-v3/libs/extendableBuiltin.js b/common/script/libs/extendableBuiltin.js similarity index 100% rename from common/script/api-v3/libs/extendableBuiltin.js rename to common/script/libs/extendableBuiltin.js diff --git a/common/script/ops/index.js b/common/script/ops/index.js index 1bfd3d55f2..a2775f7d2a 100644 --- a/common/script/ops/index.js +++ b/common/script/ops/index.js @@ -42,7 +42,7 @@ import disableClasses from './disableClasses'; import allocate from './allocate'; import readCard from './readCard'; import openMysteryItem from './openMysteryItem'; -import score from './score'; +import scoreTask from './scoreTask'; module.exports = { update, @@ -89,5 +89,5 @@ module.exports = { allocate, readCard, openMysteryItem, - score, + scoreTask, }; diff --git a/common/script/ops/score.js b/common/script/ops/score.js deleted file mode 100644 index ec0d2a70e2..0000000000 --- a/common/script/ops/score.js +++ /dev/null @@ -1,221 +0,0 @@ -import moment from 'moment'; -import _ from 'lodash'; -import i18n from '../i18n'; - -module.exports = function(user, req, cb) { - var addPoints, calculateDelta, calculateReverseDelta, changeTaskValue, delta, direction, gainMP, id, multiplier, num, options, ref, stats, subtractPoints, task, th; - ref = req.params, id = ref.id, direction = ref.direction; - task = user.tasks[id]; - options = req.query || {}; - _.defaults(options, { - times: 1, - cron: false - }); - user._tmp = {}; - stats = { - gp: +user.stats.gp, - hp: +user.stats.hp, - exp: +user.stats.exp - }; - task.value = +task.value; - task.streak = ~~task.streak; - if (task.priority == null) { - task.priority = 1; - } - if (task.value > stats.gp && task.type === 'reward') { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('messageNotEnoughGold', req.language) - }) : void 0; - } - delta = 0; - calculateDelta = function() { - var currVal, nextDelta, ref1; - currVal = task.value < -47.27 ? -47.27 : task.value > 21.27 ? 21.27 : task.value; - nextDelta = Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); - if (((ref1 = task.checklist) != null ? ref1.length : void 0) > 0) { - if (direction === 'down' && task.type === 'daily' && options.cron) { - nextDelta *= 1 - _.reduce(task.checklist, (function(m, i) { - return m + (i.completed ? 1 : 0); - }), 0) / task.checklist.length; - } - if (task.type === 'todo') { - nextDelta *= 1 + _.reduce(task.checklist, (function(m, i) { - return m + (i.completed ? 1 : 0); - }), 0); - } - } - return nextDelta; - }; - calculateReverseDelta = function() { - var calc, closeEnough, currVal, diff, nextDelta, ref1, testVal; - currVal = task.value < -47.27 ? -47.27 : task.value > 21.27 ? 21.27 : task.value; - testVal = currVal + Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); - closeEnough = 0.00001; - while (true) { - calc = testVal + Math.pow(0.9747, testVal); - diff = currVal - calc; - if (Math.abs(diff) < closeEnough) { - break; - } - if (diff > 0) { - testVal -= diff; - } else { - testVal += diff; - } - } - nextDelta = testVal - currVal; - if (((ref1 = task.checklist) != null ? ref1.length : void 0) > 0) { - if (task.type === 'todo') { - nextDelta *= 1 + _.reduce(task.checklist, (function(m, i) { - return m + (i.completed ? 1 : 0); - }), 0); - } - } - return nextDelta; - }; - changeTaskValue = function() { - return _.times(options.times, function() { - var nextDelta, ref1; - nextDelta = !options.cron && direction === 'down' ? calculateReverseDelta() : calculateDelta(); - if (task.type !== 'reward') { - if (user.preferences.automaticAllocation === true && user.preferences.allocationMode === 'taskbased' && !(task.type === 'todo' && direction === 'down')) { - user.stats.training[task.attribute] += nextDelta; - } - if (direction === 'up') { - user.party.quest.progress.up = user.party.quest.progress.up || 0; - if ((ref1 = task.type) === 'daily' || ref1 === 'todo') { - user.party.quest.progress.up += nextDelta * (1 + (user._statsComputed.str / 200)); - } - if (task.type === 'habit') { - user.party.quest.progress.up += nextDelta * (0.5 + (user._statsComputed.str / 400)); - } - } - task.value += nextDelta; - } - return delta += nextDelta; - }); - }; - addPoints = function() { - var _crit, afterStreak, currStreak, gpMod, intBonus, perBonus, streakBonus; - _crit = (delta > 0 ? user.fns.crit() : 1); - if (_crit > 1) { - user._tmp.crit = _crit; - } - intBonus = 1 + (user._statsComputed.int * .025); - stats.exp += Math.round(delta * intBonus * task.priority * _crit * 6); - perBonus = 1 + user._statsComputed.per * .02; - gpMod = delta * task.priority * _crit * perBonus; - return stats.gp += task.streak ? (currStreak = direction === 'down' ? task.streak - 1 : task.streak, streakBonus = currStreak / 100 + 1, afterStreak = gpMod * streakBonus, currStreak > 0 ? gpMod > 0 ? user._tmp.streakBonus = afterStreak - gpMod : void 0 : void 0, afterStreak) : gpMod; - }; - subtractPoints = function() { - var conBonus, hpMod; - conBonus = 1 - (user._statsComputed.con / 250); - if (conBonus < .1) { - conBonus = 0.1; - } - hpMod = delta * conBonus * task.priority * 2; - return stats.hp += Math.round(hpMod * 10) / 10; - }; - gainMP = function(delta) { - delta *= user._tmp.crit || 1; - user.stats.mp += delta; - if (user.stats.mp >= user._statsComputed.maxMP) { - user.stats.mp = user._statsComputed.maxMP; - } - if (user.stats.mp < 0) { - return user.stats.mp = 0; - } - }; - switch (task.type) { - case 'habit': - changeTaskValue(); - if (delta > 0) { - addPoints(); - } else { - subtractPoints(); - } - gainMP(_.max([0.25, .0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); - th = (task.history != null ? task.history : task.history = []); - if (th[th.length - 1] && moment(th[th.length - 1].date).isSame(new Date, 'day')) { - th[th.length - 1].value = task.value; - } else { - th.push({ - date: +(new Date), - value: task.value - }); - } - if (typeof user.markModified === "function") { - user.markModified("habits." + (_.findIndex(user.habits, { - id: task.id - })) + ".history"); - } - break; - case 'daily': - if (options.cron) { - changeTaskValue(); - subtractPoints(); - if (!user.stats.buffs.streaks) { - task.streak = 0; - } - } else { - changeTaskValue(); - if (direction === 'down') { - delta = calculateDelta(); - } - addPoints(); - gainMP(_.max([1, .01 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); - if (direction === 'up') { - task.streak = task.streak ? task.streak + 1 : 1; - if ((task.streak % 21) === 0) { - user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1; - } - } else { - if ((task.streak % 21) === 0) { - user.achievements.streak = user.achievements.streak ? user.achievements.streak - 1 : 0; - } - task.streak = task.streak ? task.streak - 1 : 0; - } - } - break; - case 'todo': - if (options.cron) { - changeTaskValue(); - } else { - task.dateCompleted = direction === 'up' ? new Date : void 0; - changeTaskValue(); - if (direction === 'down') { - delta = calculateDelta(); - } - addPoints(); - multiplier = _.max([ - _.reduce(task.checklist, (function(m, i) { - return m + (i.completed ? 1 : 0); - }), 1), 1 - ]); - gainMP(_.max([multiplier, .01 * user._statsComputed.maxMP * multiplier]) * (direction === 'down' ? -1 : 1)); - } - break; - case 'reward': - changeTaskValue(); - stats.gp -= Math.abs(task.value); - num = parseFloat(task.value).toFixed(2); - if (stats.gp < 0) { - stats.hp += stats.gp; - stats.gp = 0; - } - } - user.fns.updateStats(stats, req); - if (typeof window === 'undefined') { - if (direction === 'up') { - user.fns.randomDrop({ - task: task, - delta: delta - }, req); - } - } - if (typeof cb === "function") { - cb(null, user); - } - return delta; -}; diff --git a/common/script/api-v3/scoreTask.js b/common/script/ops/scoreTask.js similarity index 99% rename from common/script/api-v3/scoreTask.js rename to common/script/ops/scoreTask.js index 638e89a6a9..5bb8406fd8 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/ops/scoreTask.js @@ -1,7 +1,7 @@ import _ from 'lodash'; import { NotAuthorized, -} from './errors'; +} from '../libs/errors'; import i18n from '../i18n'; const MAX_TASK_VALUE = 21.27; diff --git a/package.json b/package.json index d5dc0f7986..0dcc4968b4 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp test:api-v3", + "test": "gulp test", "test:api-v2:unit": "mocha test/server_side", "test:api-v2:integration": "mocha test/api/v2 --recursive", "test:api-v3": "gulp test:api-v3", diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 1dcf7fb764..40cc528676 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -14,7 +14,6 @@ const SERVER_FILES = [ const COMMON_FILES = [ './common/script/**/*.js', // @TODO remove these negations as the files are converted over. - '!./common/script/index.js', '!./common/script/content/index.js', '!./common/script/ops/**/*.js', '!./common/script/fns/**/*.js', @@ -25,7 +24,7 @@ const TEST_FILES = [ './test/**/*.js', // @TODO remove these negations as the test files are cleaned up. '!./test/api-legacy/**/*', - '!./test/common/simulations/**/*', + '!./test/common_old/simulations/**/*', '!./test/content/**/*', '!./test/server_side/**/*', '!./test/spec/**/*', diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index a842b0a2b6..a517d5a460 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -373,6 +373,16 @@ gulp.task('test:api-v3:integration:separate-server', (done) => { pipe(runner); }); +gulp.task('test', (done) => { + runSequence( + 'lint', + 'test:common', + 'test:api-v3:unit', + 'test:api-v3:integration', + done + ); +}); + gulp.task('test:api-v3', (done) => { runSequence( 'lint', diff --git a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js index e8bd7a66d9..115648d435 100644 --- a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js @@ -9,9 +9,8 @@ import { import { v4 as generateUUID } from 'uuid'; describe('DELETE /challenges/:challengeId', () => { - it('returns error when challengeId is not a valid UUID', async () => { + it.only('returns error when challengeId is not a valid UUID', async () => { let user = await generateUser(); - await expect(user.del(`/challenges/test`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', diff --git a/test/api/v3/unit/libs/errors.test.js b/test/api/v3/unit/libs/errors.test.js index 2a96f05443..d36e1615c7 100644 --- a/test/api/v3/unit/libs/errors.test.js +++ b/test/api/v3/unit/libs/errors.test.js @@ -1,6 +1,6 @@ // TODO move to shared tests -import { CustomError } from '../../../../../common/script/api-v3/errors'; import { + CustomError, NotAuthorized, BadRequest, InternalServerError, diff --git a/test/common/algos.mocha.js b/test/common_old/algos.mocha.js similarity index 100% rename from test/common/algos.mocha.js rename to test/common_old/algos.mocha.js diff --git a/test/common/count.js b/test/common_old/count.js similarity index 100% rename from test/common/count.js rename to test/common_old/count.js diff --git a/test/common/dailies.js b/test/common_old/dailies.js similarity index 100% rename from test/common/dailies.js rename to test/common_old/dailies.js diff --git a/test/common/preenTodos.test.js b/test/common_old/preenTodos.test.js similarity index 100% rename from test/common/preenTodos.test.js rename to test/common_old/preenTodos.test.js diff --git a/test/common/preening.test.js b/test/common_old/preening.test.js similarity index 100% rename from test/common/preening.test.js rename to test/common_old/preening.test.js diff --git a/test/common/shared.spells.test.js b/test/common_old/shared.spells.test.js similarity index 100% rename from test/common/shared.spells.test.js rename to test/common_old/shared.spells.test.js diff --git a/test/common/simulations/autoAllocate.js b/test/common_old/simulations/autoAllocate.js similarity index 100% rename from test/common/simulations/autoAllocate.js rename to test/common_old/simulations/autoAllocate.js diff --git a/test/common/simulations/passive_active_attrs.js b/test/common_old/simulations/passive_active_attrs.js similarity index 100% rename from test/common/simulations/passive_active_attrs.js rename to test/common_old/simulations/passive_active_attrs.js diff --git a/test/common/statHelpers.test.js b/test/common_old/statHelpers.test.js similarity index 100% rename from test/common/statHelpers.test.js rename to test/common_old/statHelpers.test.js diff --git a/test/common/test_helper.js b/test/common_old/test_helper.js similarity index 100% rename from test/common/test_helper.js rename to test/common_old/test_helper.js diff --git a/test/common/user.fns.buy.test.js b/test/common_old/user.fns.buy.test.js similarity index 100% rename from test/common/user.fns.buy.test.js rename to test/common_old/user.fns.buy.test.js diff --git a/test/common/user.fns.ultimateGear.test.js b/test/common_old/user.fns.ultimateGear.test.js similarity index 100% rename from test/common/user.fns.ultimateGear.test.js rename to test/common_old/user.fns.ultimateGear.test.js diff --git a/test/common/user.fns.updateStats.test.js b/test/common_old/user.fns.updateStats.test.js similarity index 100% rename from test/common/user.fns.updateStats.test.js rename to test/common_old/user.fns.updateStats.test.js diff --git a/test/common/user.ops.buyMysterySet.test.js b/test/common_old/user.ops.buyMysterySet.test.js similarity index 100% rename from test/common/user.ops.buyMysterySet.test.js rename to test/common_old/user.ops.buyMysterySet.test.js diff --git a/test/common/user.ops.equip.test.js b/test/common_old/user.ops.equip.test.js similarity index 100% rename from test/common/user.ops.equip.test.js rename to test/common_old/user.ops.equip.test.js diff --git a/test/common/user.ops.hatch.js b/test/common_old/user.ops.hatch.js similarity index 100% rename from test/common/user.ops.hatch.js rename to test/common_old/user.ops.hatch.js diff --git a/test/common/user.ops.hourglassPurchase.test.js b/test/common_old/user.ops.hourglassPurchase.test.js similarity index 100% rename from test/common/user.ops.hourglassPurchase.test.js rename to test/common_old/user.ops.hourglassPurchase.test.js diff --git a/test/common/user.ops.test.js b/test/common_old/user.ops.test.js similarity index 100% rename from test/common/user.ops.test.js rename to test/common_old/user.ops.test.js diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index b78615e448..2218aa2e76 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -16,7 +16,7 @@ import _ from 'lodash'; import moment from 'moment'; import { preenHistory } from '../../libs/api-v3/preening'; -const scoreTask = common.v3.scoreTask; +const scoreTask = common.ops.scoreTask; let api = {}; diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index 10c87befdb..0667dafcd9 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -1,6 +1,6 @@ import common from '../../../../common/script'; -export const CustomError = common.v3.errors.CustomError; +export const CustomError = common.errors.CustomError; /** * @apiDefine NotAuthorized @@ -13,7 +13,7 @@ export const CustomError = common.v3.errors.CustomError; * "message": "Not authorized." * } */ -export const NotAuthorized = common.v3.errors.NotAuthorized; +export const NotAuthorized = common.errors.NotAuthorized; /** * @apiDefine BadRequest @@ -26,7 +26,7 @@ export const NotAuthorized = common.v3.errors.NotAuthorized; * "message": "Bad request." * } */ -export const BadRequest = common.v3.errors.BadRequest; +export const BadRequest = common.errors.BadRequest; /** * @apiDefine NotFound @@ -39,7 +39,7 @@ export const BadRequest = common.v3.errors.BadRequest; * "message": "Not found." * } */ -export const NotFound = common.v3.errors.NotFound; +export const NotFound = common.errors.NotFound; /** * @apiDefine InternalServerError diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index fecd6b05db..a70dd534a1 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -11,7 +11,7 @@ import Group from '../../models/group'; import User from '../../models/user'; import { preenUserHistory } from '../../libs/api-v3/preening'; -const scoreTask = common.v3.scoreTask; +const scoreTask = common.ops.scoreTask; let clearBuffs = { str: 0, From 70df1137a0a05c15b8e09310b4c61e38e0a49703 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 13 Mar 2016 22:39:14 +0100 Subject: [PATCH 523/976] fix statsComputed getter --- common/script/index.js | 7 +++---- .../challenges/DELETE-challenges_challengeId.test.js | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/common/script/index.js b/common/script/index.js index 3eb3a3371b..9aa76c3e9b 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -222,14 +222,13 @@ api.wrap = function wrapUser (user, main = true) { get () { let computed = _.reduce(['per', 'con', 'str', 'int'], (m, stat) => { m[stat] = _.reduce($w('stats stats.buffs items.gear.equipped.weapon items.gear.equipped.armor items.gear.equipped.head items.gear.equipped.shield'), (m2, path) => { - let val = user.fns.dotGet(path); let item; - return m2 + (path.indexOf('items.gear') !== -1 ? (item = content.gear.flat[val], (Number(!item ? item[stat] : undefined) || 0) * ((!item ? item.klass : undefined) === user.stats.class || (!item ? item.specialClass : undefined) === user.stats.class ? 1.5 : 1)) : Number(val[stat]) || 0); + let val = user.fns.dotGet(path); + return m2 + (path.indexOf('items.gear') !== -1 ? (item = content.gear.flat[val], (Number(item ? item[stat] : undefined) || 0) * ((item ? item.klass : undefined) === user.stats.class || (item ? item.specialClass : undefined) === user.stats.class ? 1.5 : 1)) : Number(val[stat]) || 0); }, 0); m[stat] += Math.floor(api.capByLevel(user.stats.lvl) / 2); return m; - }); - + }, {}); computed.maxMP = computed.int * 2 + 30; return computed; }, diff --git a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js index 115648d435..44ff6a8b58 100644 --- a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js @@ -9,7 +9,7 @@ import { import { v4 as generateUUID } from 'uuid'; describe('DELETE /challenges/:challengeId', () => { - it.only('returns error when challengeId is not a valid UUID', async () => { + it('returns error when challengeId is not a valid UUID', async () => { let user = await generateUser(); await expect(user.del(`/challenges/test`)).to.eventually.be.rejected.and.eql({ code: 400, From f65a7321b7a2921dcab7a2bbbcc9a835bad1d6e1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 13 Mar 2016 23:00:48 +0100 Subject: [PATCH 524/976] fix common tests, explictly exclude tests from linting --- tasks/gulp-eslint.js | 83 ++++++++++++++++++- test/{common_old => common}/count.js | 0 .../statHelpers.test.js | 0 test/{common_old => common}/test_helper.js | 0 4 files changed, 80 insertions(+), 3 deletions(-) rename test/{common_old => common}/count.js (100%) rename test/{common_old => common}/statHelpers.test.js (100%) rename test/{common_old => common}/test_helper.js (100%) diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 40cc528676..eb75321f3c 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -15,9 +15,86 @@ const COMMON_FILES = [ './common/script/**/*.js', // @TODO remove these negations as the files are converted over. '!./common/script/content/index.js', - '!./common/script/ops/**/*.js', - '!./common/script/fns/**/*.js', - '!./common/script/libs/**/*.js', + '!./common/script/ops/addPushDevice.js', + '!./common/script/ops/addTag.js', + '!./common/script/ops/addTask.js', + '!./common/script/ops/addWebhook.js', + '!./common/script/ops/allocate.js', + '!./common/script/ops/allocateNow.js', + '!./common/script/ops/blockUser.js', + '!./common/script/ops/buy.js', + '!./common/script/ops/buyMysterySet.js', + '!./common/script/ops/buyQuest.js', + '!./common/script/ops/buySpecialSpell.js', + '!./common/script/ops/changeClass.js', + '!./common/script/ops/clearCompleted.js', + '!./common/script/ops/clearPMs.js', + '!./common/script/ops/deletePM.js', + '!./common/script/ops/deleteTag.js', + '!./common/script/ops/deleteTask.js', + '!./common/script/ops/deleteWebhook.js', + '!./common/script/ops/disableClasses.js', + '!./common/script/ops/equip.js', + '!./common/script/ops/feed.js', + '!./common/script/ops/getTag.js', + '!./common/script/ops/getTags.js', + '!./common/script/ops/hatch.js', + '!./common/script/ops/hourglassPurchase.js', + '!./common/script/ops/openMysteryItem.js', + '!./common/script/ops/purchase.js', + '!./common/script/ops/readCard.js', + '!./common/script/ops/rebirth.js', + '!./common/script/ops/releaseBoth.js', + '!./common/script/ops/releaseMounts.js', + '!./common/script/ops/releasePets.js', + '!./common/script/ops/reroll.js', + '!./common/script/ops/reset.js', + '!./common/script/ops/revive.js', + '!./common/script/ops/sell.js', + '!./common/script/ops/sleep.js', + '!./common/script/ops/sortTag.js', + '!./common/script/ops/sortTask.js', + '!./common/script/ops/unlock.js', + '!./common/script/ops/update.js', + '!./common/script/ops/updateTag.js', + '!./common/script/ops/updateTask.js', + '!./common/script/ops/updateWebhook.js', + '!./common/script/fns/autoAllocate.js', + '!./common/script/fns/crit.js', + '!./common/script/fns/cron.js', + '!./common/script/fns/dotGet.js', + '!./common/script/fns/dotSet.js', + '!./common/script/fns/getItem.js', + '!./common/script/fns/handleTwoHanded.js', + '!./common/script/fns/nullify.js', + '!./common/script/fns/predictableRandom.js', + '!./common/script/fns/preenUserHistory.js', + '!./common/script/fns/randomDrop.js', + '!./common/script/fns/randomVal.js', + '!./common/script/fns/ultimateGear.js', + '!./common/script/fns/updateStats.js', + '!./common/script/libs/appliedTags.js', + '!./common/script/libs/countExists.js', + '!./common/script/libs/dotGet.js', + '!./common/script/libs/dotSet.js', + '!./common/script/libs/encodeiCalLink.js', + '!./common/script/libs/extendableBuiltin.js', + '!./common/script/libs/friendlyTimestamp.js', + '!./common/script/libs/gold.js', + '!./common/script/libs/newChatMessages.js', + '!./common/script/libs/noTags.js', + '!./common/script/libs/percent.js', + '!./common/script/libs/planGemLimits.js', + '!./common/script/libs/preenHistory.js', + '!./common/script/libs/preenTodos.js', + '!./common/script/libs/refPush.js', + '!./common/script/libs/removeWhitespace.js', + '!./common/script/libs/silver.js', + '!./common/script/libs/splitWhitespace.js', + '!./common/script/libs/taskClasses.js', + '!./common/script/libs/taskDefaults.js', + '!./common/script/libs/updateStore.js', + '!./common/script/libs/uuid.js', '!./common/script/public/**/*.js', ]; const TEST_FILES = [ diff --git a/test/common_old/count.js b/test/common/count.js similarity index 100% rename from test/common_old/count.js rename to test/common/count.js diff --git a/test/common_old/statHelpers.test.js b/test/common/statHelpers.test.js similarity index 100% rename from test/common_old/statHelpers.test.js rename to test/common/statHelpers.test.js diff --git a/test/common_old/test_helper.js b/test/common/test_helper.js similarity index 100% rename from test/common_old/test_helper.js rename to test/common/test_helper.js From 534ec07b6a5eb30f73308103d2dbccb74acaf83b Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 15 Mar 2016 10:03:48 -0500 Subject: [PATCH 525/976] Added tests to ensure challenge member can not delete active challenge task and can delete broken or unlinked challenge task --- ...ETE-tasks_id_challenge_challengeId.test.js | 59 +++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js index 9ec2a65f8e..52f257be9b 100644 --- a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js +++ b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js @@ -2,6 +2,7 @@ import { generateUser, generateGroup, generateChallenge, + sleep, translate as t, } from '../../../../../helpers/api-integration/v3'; import { v4 as generateUUID } from 'uuid'; @@ -52,4 +53,62 @@ describe('DELETE /tasks/:id', () => { message: t('taskNotFound'), }); }); + + context('challenge member', () => { + let anotherUser; + let anotherUsersNewChallengeTaskID; + let newChallengeTask; + + beforeEach(async () => { + anotherUser = await generateUser(); + await user.post(`/groups/${guild._id}/invite`, { uuids: [anotherUser._id] }); + await anotherUser.post(`/groups/${guild._id}/join`); + await anotherUser.post(`/challenges/${challenge._id}/join`); + + newChallengeTask = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + }); + + let anotherUserWithNewChallengeTask = await anotherUser.get('/user'); + anotherUsersNewChallengeTaskID = anotherUserWithNewChallengeTask.tasksOrder.habits[0]; + }); + + it('returns error when user attempts to delete an active challenge task', async () => { + await expect(anotherUser.del(`/tasks/${anotherUsersNewChallengeTaskID}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cantDeleteChallengeTasks'), + }); + }); + + it('allows user to delete challenge task after user leaves challenge', async () => { + await anotherUser.post(`/challenges/${challenge._id}/leave`); + + await expect(anotherUser.del(`/tasks/${anotherUsersNewChallengeTaskID}`)); + + await expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('allows user to delete challenge task after challenge task is broken', async () => { + await expect(user.del(`/tasks/${newChallengeTask._id}`)); + + await sleep(0.5); + + await expect(anotherUser.del(`/tasks/${anotherUsersNewChallengeTaskID}`)); + + await sleep(0.5); + + await expect(anotherUser.get(`/tasks/${anotherUsersNewChallengeTaskID}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 952076ab82..bafdadaeb4 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -914,7 +914,7 @@ api.deleteTask = { if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one throw new NotFound(res.t('taskNotFound')); - } else if (task.userId && task.challenge.id) { + } else if (task.userId && task.challenge.id && !task.challenge.broken) { throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); } From 490c6a9ae1c99fe06784ecb9d1f24fd262fc5c64 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 15 Mar 2016 16:33:44 +0100 Subject: [PATCH 526/976] wip(shared): port sleep op --- common/script/index.js | 2 ++ common/script/ops/sleep.js | 4 ++-- package.json | 4 ++-- tasks/gulp-eslint.js | 1 - tasks/gulp-tests.js | 2 +- test/common/ops/sleep.js | 18 ++++++++++++++++++ website/src/controllers/api-v3/user.js | 25 +++++++++++++++++++++++++ 7 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 test/common/ops/sleep.js diff --git a/common/script/index.js b/common/script/index.js index 9aa76c3e9b..6d26b32c48 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -99,9 +99,11 @@ api.count = count; // TODO As ops and fns are ported, exported them through the api object import scoreTask from './ops/scoreTask'; +import sleep from './ops/sleep'; api.ops = { scoreTask, + sleep, }; api.fns = {}; diff --git a/common/script/ops/sleep.js b/common/script/ops/sleep.js index dec8095ad3..e23ee22d8e 100644 --- a/common/script/ops/sleep.js +++ b/common/script/ops/sleep.js @@ -1,4 +1,4 @@ -module.exports = function(user, req, cb) { +module.exports = function sleep (user) { user.preferences.sleep = !user.preferences.sleep; - return typeof cb === "function" ? cb(null, {}) : void 0; + return user.preferences.sleep; }; diff --git a/package.json b/package.json index 0dcc4968b4..caf7681d20 100644 --- a/package.json +++ b/package.json @@ -106,8 +106,8 @@ "test:api-v3:integration": "gulp test:api-v3:integration", "test:api-v3:integration:separate-server": "gulp test:api-v3:integration:separate-server", "test:api-legacy": "istanbul cover -i \"website/src/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", - "test:common": "mocha test/common", - "test:content": "mocha test/content", + "test:common": "mocha test/common --recursive", + "test:content": "mocha test/content --recursive", "test:karma": "karma start --single-run", "test:karma:watch": "karma start", "test:prepare:webdriver": "webdriver-manager update", diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index eb75321f3c..6d8c1ca2dd 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -51,7 +51,6 @@ const COMMON_FILES = [ '!./common/script/ops/reset.js', '!./common/script/ops/revive.js', '!./common/script/ops/sell.js', - '!./common/script/ops/sleep.js', '!./common/script/ops/sortTag.js', '!./common/script/ops/sortTask.js', '!./common/script/ops/unlock.js', diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index a517d5a460..56a759bdfe 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -104,7 +104,7 @@ gulp.task('test:common:clean', (cb) => { }); gulp.task('test:common:watch', ['test:common:clean'], () => { - gulp.watch(['common/script/**', 'test/common/**'], ['test:common:clean']); + gulp.watch(['common/script/**/*', 'test/common/**/*'], ['test:common:clean']); }); gulp.task('test:common:safe', ['test:prepare:build'], (cb) => { diff --git a/test/common/ops/sleep.js b/test/common/ops/sleep.js new file mode 100644 index 0000000000..57cca033b3 --- /dev/null +++ b/test/common/ops/sleep.js @@ -0,0 +1,18 @@ +import sleep from '../../../common/script/ops/sleep'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.sleep', () => { + it('changes user.preferences.sleep and returns the new value', () => { + let user = generateUser(); + + let res = sleep(user); + expect(res).to.equal(true); + expect(user.preferences.sleep).to.equal(true); + + let res2 = sleep(user); + expect(res2).to.equal(false); + expect(user.preferences.sleep).to.equal(false); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index c4a68df4e9..a2895c4759 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -13,6 +13,8 @@ import Q from 'q'; import _ from 'lodash'; import * as passwordUtils from '../../libs/api-v3/password'; +const sleep = common.ops.sleep; + let api = {}; /** @@ -277,4 +279,27 @@ api.castSpell = { }, }; +/** + * @api {post} /user/sleep Put the user in the inn. + * @apiVersion 3.0.0 + * @apiName UserSleep + * @apiGroup User + * + * @apiSuccess {Object} Will return an object with the new `user.preferences.sleep` value. Example `{preferences: {sleep: true}}` + */ +api.sleep = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/sleep', + async handler (req, res) { + let user = res.locals.user; + let sleepVal = sleep(user); + return res.respond(200, { + preferences: { + sleep: sleepVal, + }, + }); + }, +}; + module.exports = api; From b2b4340ee39f287e7a8287bae260c4fb07c1804c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 15 Mar 2016 17:05:07 +0100 Subject: [PATCH 527/976] change response for shared.ops.sleep, save user in POST /user/sleep and add integration tests for it --- common/script/ops/sleep.js | 6 ++++- test/api/v3/integration/user/sleep.test.js | 27 ++++++++++++++++++++++ test/common/ops/sleep.js | 6 ++--- website/src/controllers/api-v3/user.js | 9 +++----- 4 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 test/api/v3/integration/user/sleep.test.js diff --git a/common/script/ops/sleep.js b/common/script/ops/sleep.js index e23ee22d8e..eb80ec9dd0 100644 --- a/common/script/ops/sleep.js +++ b/common/script/ops/sleep.js @@ -1,4 +1,8 @@ module.exports = function sleep (user) { user.preferences.sleep = !user.preferences.sleep; - return user.preferences.sleep; + return { + preferences: { + sleep: user.preferences.sleep, + }, + }; }; diff --git a/test/api/v3/integration/user/sleep.test.js b/test/api/v3/integration/user/sleep.test.js new file mode 100644 index 0000000000..8951d71f20 --- /dev/null +++ b/test/api/v3/integration/user/sleep.test.js @@ -0,0 +1,27 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/sleep', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('toggles sleep status', async () => { + let res = await user.post(`/user/sleep`); + expect(res).to.eql({ + preferences: {sleep: true}, + }); + await user.sync(); + expect(user.preferences.sleep).to.be.true; + + let res2 = await user.post(`/user/sleep`); + expect(res2).to.eql({ + preferences: {sleep: false}, + }); + await user.sync(); + expect(user.preferences.sleep).to.be.false; + }); +}); diff --git a/test/common/ops/sleep.js b/test/common/ops/sleep.js index 57cca033b3..5466631f75 100644 --- a/test/common/ops/sleep.js +++ b/test/common/ops/sleep.js @@ -4,15 +4,15 @@ import { } from '../../helpers/common.helper'; describe('shared.ops.sleep', () => { - it('changes user.preferences.sleep and returns the new value', () => { + it('toggles user.preferences.sleep', () => { let user = generateUser(); let res = sleep(user); - expect(res).to.equal(true); + expect(res).to.eql({preferences: {sleep: true}}); expect(user.preferences.sleep).to.equal(true); let res2 = sleep(user); - expect(res2).to.equal(false); + expect(res2).to.eql({preferences: {sleep: false}}); expect(user.preferences.sleep).to.equal(false); }); }); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index a2895c4759..f1a9ac49ff 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -293,12 +293,9 @@ api.sleep = { url: '/user/sleep', async handler (req, res) { let user = res.locals.user; - let sleepVal = sleep(user); - return res.respond(200, { - preferences: { - sleep: sleepVal, - }, - }); + let sleepRes = sleep(user); + await user.save(); + res.respond(200, sleepRes); }, }; From 219ec01ce9877aa8f07a2bb3a63a9bce71b4876b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 15 Mar 2016 18:41:23 +0100 Subject: [PATCH 528/976] port shared.ops.allocate --- common/locales/en/api-v3.json | 4 +- common/script/constants.js | 1 + common/script/index.js | 2 + common/script/ops/allocate.js | 23 ++++++-- tasks/gulp-eslint.js | 1 - .../user/POST-user_allocate.test.js | 41 +++++++++++++ ...{sleep.test.js => POST-user_sleep.test.js} | 2 + test/common/constants.js | 11 ++++ test/common/ops/allocate.js | 59 +++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 4 +- website/src/controllers/api-v3/user.js | 24 +++++++- 11 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_allocate.test.js rename test/api/v3/integration/user/{sleep.test.js => POST-user_sleep.test.js} (93%) create mode 100644 test/common/constants.js create mode 100644 test/common/ops/allocate.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 58b3746bfe..9f48532913 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -100,5 +100,7 @@ "targetIdUUID": "\"targetId\" must be a valid UUID.", "challengeTasksNoCast": "Casting a spell on challenge tasks is not supported.", "spellNotOwned": "You don't own this spell.", - "spellLevelTooHigh": "You must be level <%= level %> to use this spell." + "spellLevelTooHigh": "You must be level <%= level %> to use this spell.", + "invalidAttribute": "\"<%= attr %>\" is not a valid attribute.", + "notEnoughAttrPoints": "You don't have enough attribute points." } diff --git a/common/script/constants.js b/common/script/constants.js index d6e0aa9384..2dc8663091 100644 --- a/common/script/constants.js +++ b/common/script/constants.js @@ -1,3 +1,4 @@ export const MAX_HEALTH = 50; export const MAX_LEVEL = 100; export const MAX_STAT_POINTS = MAX_LEVEL; +export const ATTRIBUTES = ['str', 'int', 'per', 'con']; diff --git a/common/script/index.js b/common/script/index.js index 6d26b32c48..a7bdf6e3d1 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -100,10 +100,12 @@ api.count = count; // TODO As ops and fns are ported, exported them through the api object import scoreTask from './ops/scoreTask'; import sleep from './ops/sleep'; +import allocate from './ops/allocate'; api.ops = { scoreTask, sleep, + allocate, }; api.fns = {}; diff --git a/common/script/ops/allocate.js b/common/script/ops/allocate.js index 92b5ae53fa..c04c756e45 100644 --- a/common/script/ops/allocate.js +++ b/common/script/ops/allocate.js @@ -1,15 +1,30 @@ import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; +import { + ATTRIBUTES, +} from '../constants'; +import { + BadRequest, + NotAuthorized, +} from '../libs/errors'; +import i18n from '../i18n'; + +module.exports = function allocate (user, req = {}) { + let stat = _.get(req, 'query.stat', 'str'); + + if (ATTRIBUTES.indexOf(stat) === -1) { + throw new BadRequest(i18n.t('invalidAttribute', {attr: stat}, req.language)); + } -module.exports = function(user, req, cb) { - var stat; - stat = req.query.stat || 'str'; if (user.stats.points > 0) { user.stats[stat]++; user.stats.points--; if (stat === 'int') { user.stats.mp++; } + } else { + throw new NotAuthorized(i18n.t('notEnoughAttrPoints', req.language)); } - return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('stats'))) : void 0; + + return _.pick(user, splitWhitespace('stats')); }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 6d8c1ca2dd..1c5df3f6f8 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -19,7 +19,6 @@ const COMMON_FILES = [ '!./common/script/ops/addTag.js', '!./common/script/ops/addTask.js', '!./common/script/ops/addWebhook.js', - '!./common/script/ops/allocate.js', '!./common/script/ops/allocateNow.js', '!./common/script/ops/blockUser.js', '!./common/script/ops/buy.js', diff --git a/test/api/v3/integration/user/POST-user_allocate.test.js b/test/api/v3/integration/user/POST-user_allocate.test.js new file mode 100644 index 0000000000..6f3e6347ac --- /dev/null +++ b/test/api/v3/integration/user/POST-user_allocate.test.js @@ -0,0 +1,41 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/allocate', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('returns an error if an invalid attribute is supplied', async () => { + await expect(user.post(`/user/allocate?stat=invalid`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidAttribute', {attr: 'invalid'}), + }); + }); + + it('returns an error if the user doesn\'t have attribute points', async () => { + await expect(user.post(`/user/allocate`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughAttrPoints'), + }); + }); + + it('allocates attribute points', async () => { + await user.update({'stats.points': 1}); + let res = await user.post(`/user/allocate?stat=con`); + await user.sync(); + expect(user.stats.con).to.equal(1); + expect(user.stats.points).to.equal(0); + expect(res.stats.con).to.equal(1); + }); +}); diff --git a/test/api/v3/integration/user/sleep.test.js b/test/api/v3/integration/user/POST-user_sleep.test.js similarity index 93% rename from test/api/v3/integration/user/sleep.test.js rename to test/api/v3/integration/user/POST-user_sleep.test.js index 8951d71f20..eb4a38b8bb 100644 --- a/test/api/v3/integration/user/sleep.test.js +++ b/test/api/v3/integration/user/POST-user_sleep.test.js @@ -9,6 +9,8 @@ describe('POST /user/sleep', () => { user = await generateUser(); }); + // More tests in common code unit tests + it('toggles sleep status', async () => { let res = await user.post(`/user/sleep`); expect(res).to.eql({ diff --git a/test/common/constants.js b/test/common/constants.js new file mode 100644 index 0000000000..e05db90660 --- /dev/null +++ b/test/common/constants.js @@ -0,0 +1,11 @@ +import { + ATTRIBUTES, +} from '../../common/script/constants'; + +describe('constants', () => { + describe('ATTRIBUTES', () => { + it('provides a list of attributes', () => { + expect(ATTRIBUTES).to.eql(['str', 'int', 'per', 'con']); + }); + }); +}); diff --git a/test/common/ops/allocate.js b/test/common/ops/allocate.js new file mode 100644 index 0000000000..84669af92a --- /dev/null +++ b/test/common/ops/allocate.js @@ -0,0 +1,59 @@ +import allocate from '../../../common/script/ops/allocate'; +import { + BadRequest, + NotAuthorized, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.allocate', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('throws an error if an invalid attribute is supplied', () => { + try { + expect(allocate(user, { + query: {stat: 'notValid'}, + })).to.throw(BadRequest); + } catch (err) { + expect(err.message).to.equal(i18n.t('invalidAttribute', {attr: 'notValid'})); + } + }); + + it('throws an error if the user doesn\'t have attribute points', () => { + try { + expect(allocate(user)).to.throw(NotAuthorized); + } catch (err) { + expect(err.message).to.equal(i18n.t('notEnoughAttrPoints')); + } + }); + + it('defaults to the "str" attribute', () => { + expect(user.stats.str).to.equal(0); + user.stats.points = 1; + allocate(user); + expect(user.stats.str).to.equal(1); + }); + + it('allocates attribute points', () => { + expect(user.stats.con).to.equal(0); + user.stats.points = 1; + allocate(user, {query: {stat: 'con'}}); + expect(user.stats.con).to.equal(1); + expect(user.stats.points).to.equal(0); + }); + + it('increases mana when allocating to "int"', () => { + expect(user.stats.int).to.equal(0); + expect(user.stats.mp).to.equal(10); + user.stats.points = 1; + allocate(user, {query: {stat: 'int'}}); + expect(user.stats.int).to.equal(1); + expect(user.stats.mp).to.equal(11); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 8fb0d3b5d4..ed64627aff 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -16,8 +16,6 @@ import _ from 'lodash'; import moment from 'moment'; import { preenHistory } from '../../libs/api-v3/preening'; -const scoreTask = common.ops.scoreTask; - let api = {}; // challenge must be passed only when a challenge task is being created @@ -401,7 +399,7 @@ api.scoreTask = { task.completed = direction === 'up'; // TODO move into scoreTask } - let delta = scoreTask({task, user, direction}, req); + let delta = common.ops.scoreTask({task, user, direction}, req); // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) if (direction === 'up') user.fns.randomDrop({task, delta}, req); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index f1a9ac49ff..17c37aaf84 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -13,8 +13,6 @@ import Q from 'q'; import _ from 'lodash'; import * as passwordUtils from '../../libs/api-v3/password'; -const sleep = common.ops.sleep; - let api = {}; /** @@ -293,10 +291,30 @@ api.sleep = { url: '/user/sleep', async handler (req, res) { let user = res.locals.user; - let sleepRes = sleep(user); + let sleepRes = common.ops.sleep(user); await user.save(); res.respond(200, sleepRes); }, }; +/** + * @api {post} /user/allocate Allocate an attribute point. + * @apiVersion 3.0.0 + * @apiName UserAllocate + * @apiGroup User + * + * @apiSuccess {Object} Returs `user.stats` + */ +api.allocate = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/allocate', + async handler (req, res) { + let user = res.locals.user; + let allocateRes = common.ops.allocate(user, req); + await user.save(); + res.respond(200, allocateRes); + }, +}; + module.exports = api; From 5b2584871eb5e0f65b8abbaa852ea833f37e3c09 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 15 Mar 2016 19:07:01 +0100 Subject: [PATCH 529/976] increase sleep time when testing "allows user to delete challenge task after challenge task is broken" --- .../challenges/DELETE-tasks_id_challenge_challengeId.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js index 52f257be9b..34ae0ffaa6 100644 --- a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js +++ b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js @@ -98,11 +98,11 @@ describe('DELETE /tasks/:id', () => { it('allows user to delete challenge task after challenge task is broken', async () => { await expect(user.del(`/tasks/${newChallengeTask._id}`)); - await sleep(0.5); + await sleep(1); await expect(anotherUser.del(`/tasks/${anotherUsersNewChallengeTaskID}`)); - await sleep(0.5); + await sleep(1); await expect(anotherUser.get(`/tasks/${anotherUsersNewChallengeTaskID}`)).to.eventually.be.rejected.and.eql({ code: 404, From 67ef70db0f1e83c9a7d0823ba6b887be8fd597b5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 15 Mar 2016 19:10:00 +0100 Subject: [PATCH 530/976] remove constants tests --- test/common/constants.js | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 test/common/constants.js diff --git a/test/common/constants.js b/test/common/constants.js deleted file mode 100644 index e05db90660..0000000000 --- a/test/common/constants.js +++ /dev/null @@ -1,11 +0,0 @@ -import { - ATTRIBUTES, -} from '../../common/script/constants'; - -describe('constants', () => { - describe('ATTRIBUTES', () => { - it('provides a list of attributes', () => { - expect(ATTRIBUTES).to.eql(['str', 'int', 'per', 'con']); - }); - }); -}); From 7c005e7c68e48ae7b69a60f17782db8ed469a26f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 17 Mar 2016 15:20:41 +0100 Subject: [PATCH 531/976] fix typo in tests and increase sleep time --- test/api/v3/integration/tasks/DELETE-tasks_id.test.js | 3 +-- .../DELETE-tasks_id_challenge_challengeId.test.js | 6 +++--- website/src/controllers/api-v3/tasks.js | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index f3dca7c02a..bb92e4759f 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -54,7 +54,6 @@ describe('DELETE /tasks/:id', () => { }); }); - it('cannot delete active challenge tasks'); // TODO after challenges are implemented - it('remove a task from user.tasksOrder'); // TODO + it('removes a task from user.tasksOrder'); // TODO }); }); diff --git a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js index 34ae0ffaa6..2944946005 100644 --- a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js +++ b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js @@ -86,7 +86,7 @@ describe('DELETE /tasks/:id', () => { it('allows user to delete challenge task after user leaves challenge', async () => { await anotherUser.post(`/challenges/${challenge._id}/leave`); - await expect(anotherUser.del(`/tasks/${anotherUsersNewChallengeTaskID}`)); + await anotherUser.del(`/tasks/${anotherUsersNewChallengeTaskID}`); await expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ code: 404, @@ -98,11 +98,11 @@ describe('DELETE /tasks/:id', () => { it('allows user to delete challenge task after challenge task is broken', async () => { await expect(user.del(`/tasks/${newChallengeTask._id}`)); - await sleep(1); + await sleep(2); await expect(anotherUser.del(`/tasks/${anotherUsersNewChallengeTaskID}`)); - await sleep(1); + await sleep(2); await expect(anotherUser.get(`/tasks/${anotherUsersNewChallengeTaskID}`)).to.eventually.be.rejected.and.eql({ code: 404, diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index ed64627aff..664b40a76a 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -879,7 +879,7 @@ api.clearCompletedTodos = { }; /** - * @api {delete} /tasks/:taskId Delete a user task given its id + * @api {delete} /tasks/:taskId Delete a task given its id * @apiVersion 3.0.0 * @apiName DeleteTask * @apiGroup Task From 6184fb5d2482f356b960eafbcb9194deeae22a79 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 15 Mar 2016 20:37:30 -0500 Subject: [PATCH 532/976] Added development controller, middleware, and initial tests --- .../POST-development_addHourglass.test.js | 35 ++++++++++++ .../POST-development_addTenGems.test.js | 35 ++++++++++++ website/src/controllers/api-v3/development.js | 53 +++++++++++++++++++ .../src/middlewares/api-v3/developmentMode.js | 12 +++++ 4 files changed, 135 insertions(+) create mode 100644 test/api/v3/integration/development/POST-development_addHourglass.test.js create mode 100644 test/api/v3/integration/development/POST-development_addTenGems.test.js create mode 100644 website/src/controllers/api-v3/development.js create mode 100644 website/src/middlewares/api-v3/developmentMode.js diff --git a/test/api/v3/integration/development/POST-development_addHourglass.test.js b/test/api/v3/integration/development/POST-development_addHourglass.test.js new file mode 100644 index 0000000000..6ef111c130 --- /dev/null +++ b/test/api/v3/integration/development/POST-development_addHourglass.test.js @@ -0,0 +1,35 @@ +import nconf from 'nconf'; +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /development/addHourglass', () => { + let userToGetHourGlass; + + before(async () => { + userToGetHourGlass = await generateUser(); + }); + + it('adds Hourglass to the current user', async () => { + await userToGetHourGlass.post('/development/addHourglass'); + + let userWithHourGlass = await userToGetHourGlass.get('/user'); + + expect(userWithHourGlass.purchased.plan.consecutive.trinkets).to.equal(1); + }); + + it('returns error when not in production mode', async () => { + nconf.set('IS_PROD', true); + + await expect(userToGetHourGlass.post('/development/addHourglass')) + .eventually.be.rejected.and.to.deep.equal({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); + + after(() => { + nconf.set('IS_PROD', false); + }); +}); diff --git a/test/api/v3/integration/development/POST-development_addTenGems.test.js b/test/api/v3/integration/development/POST-development_addTenGems.test.js new file mode 100644 index 0000000000..82594c4d07 --- /dev/null +++ b/test/api/v3/integration/development/POST-development_addTenGems.test.js @@ -0,0 +1,35 @@ +import nconf from 'nconf'; +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /development/addTenGems', () => { + let userToGainTenGems; + + before(async () => { + userToGainTenGems = await generateUser(); + }); + + it('adds ten gems to the current user', async () => { + await userToGainTenGems.post('/development/addTenGems'); + + let userWithTenGems = await userToGainTenGems.get('/user'); + + expect(userWithTenGems.balance).to.equal(2.5); + }); + + it('returns error when not in production mode', async () => { + nconf.set('IS_PROD', true); + + await expect(userToGainTenGems.post('/development/addTenGems')) + .eventually.be.rejected.and.to.deep.equal({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); + + after(() => { + nconf.set('IS_PROD', false); + }); +}); diff --git a/website/src/controllers/api-v3/development.js b/website/src/controllers/api-v3/development.js new file mode 100644 index 0000000000..321fe6ebf3 --- /dev/null +++ b/website/src/controllers/api-v3/development.js @@ -0,0 +1,53 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import checkForDevelopmentMode from '../../middlewares/api-v3/developmentMode'; + +let api = {}; + +/** + * @api {post} /development/addTenGems Add ten gems to the current user + * @apiVersion 3.0.0 + * @apiName AddTenGems + * @apiGroup Development + * + * @apiSuccess {} An empty Object + */ +api.addTenGems = { + method: 'POST', + url: '/development/addTenGems', + middlewares: [checkForDevelopmentMode, authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + user.balance += 2.5; + + await user.save(); + + res.respond(200, {}); + }, +}; + +/** + * @api {post} /development/addHourglass Add Hourglass to the current user + * @apiVersion 3.0.0 + * @apiName AddHourglass + * @apiGroup Development + * + * @apiSuccess {} An empty Object + */ +api.addHourglass = { + method: 'POST', + url: '/development/addHourglass', + middlewares: [checkForDevelopmentMode, authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + user.purchased.plan.consecutive.trinkets += 1; + + await user.save(); + + res.respond(200, {}); + }, +}; + +module.exports = api; diff --git a/website/src/middlewares/api-v3/developmentMode.js b/website/src/middlewares/api-v3/developmentMode.js new file mode 100644 index 0000000000..5c068df0ab --- /dev/null +++ b/website/src/middlewares/api-v3/developmentMode.js @@ -0,0 +1,12 @@ +import nconf from 'nconf'; +import { + NotFound, +} from '../../libs/api-v3/errors'; + +module.exports = function checkForDevelopmentMode (req, res, next) { + if (nconf.get('IS_PROD')) { + next(new NotFound()); + } else { + next(); + } +}; From 6921c1694a1e2c84265344f228b122b2d899a5d8 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 15 Mar 2016 21:53:11 -0500 Subject: [PATCH 533/976] feat: Allow routes to not need an async handler --- website/src/libs/api-v3/setupRoutes.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index 99dca871a9..3b91b81f25 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -9,6 +9,7 @@ let router = express.Router(); // eslint-disable-line babel/new-cap // Wrapper function to handler `async` route handlers that return promises // It takes the async function, execute it and pass any error to next (args[2]) let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]); +let noop = (req, res, next) => next(); function walkControllers (filePath) { fs @@ -23,7 +24,9 @@ function walkControllers (filePath) { let {method, url, middlewares = [], handler} = action; method = method.toLowerCase(); - router[method](url, ...middlewares, _wrapAsyncFn(handler)); + let fn = handler ? _wrapAsyncFn(handler) : noop; + + router[method](url, ...middlewares, fn); }); } }); From e056a62af02973a80fdd42cc8b2f52df1ccf200c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 15 Mar 2016 22:02:36 -0500 Subject: [PATCH 534/976] refactor: Setup middleware for all development routes --- website/src/controllers/api-v3/development.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/development.js b/website/src/controllers/api-v3/development.js index 321fe6ebf3..2b252bbb8e 100644 --- a/website/src/controllers/api-v3/development.js +++ b/website/src/controllers/api-v3/development.js @@ -4,6 +4,12 @@ import checkForDevelopmentMode from '../../middlewares/api-v3/developmentMode'; let api = {}; +api.development = { + method: 'all', + url: '/development/*', + middlewares: [checkForDevelopmentMode, authWithHeaders(), cron], +}; + /** * @api {post} /development/addTenGems Add ten gems to the current user * @apiVersion 3.0.0 @@ -15,7 +21,6 @@ let api = {}; api.addTenGems = { method: 'POST', url: '/development/addTenGems', - middlewares: [checkForDevelopmentMode, authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; @@ -38,7 +43,6 @@ api.addTenGems = { api.addHourglass = { method: 'POST', url: '/development/addHourglass', - middlewares: [checkForDevelopmentMode, authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; From 4655e5061aba8d6a17a8536be79eb35ce62a824f Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 17 Mar 2016 14:47:22 -0700 Subject: [PATCH 535/976] api-v3 password reset --- common/locales/en/api-v3.json | 1 + .../auth/POST-user_reset_password.test.js | 39 ++++++++++++++++ website/src/controllers/api-v3/email.js | 1 - website/src/controllers/api-v3/user.js | 46 +++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/user/auth/POST-user_reset_password.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 58b3746bfe..51dc2b5c41 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -12,6 +12,7 @@ "usernameTaken": "Username already taken.", "passwordConfirmationMatch": "Password confirmation doesn't match password.", "invalidLoginCredentials": "Incorrect username / email and / or password.", + "passwordReset": "If we have your email on file, your password reset link has been sent to your email.", "invalidCredentials": "User not found with given auth credentials.", "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your UUID \"<%= userId %>\" for assistance.", "onlyFbSupported": "Only Facebook supported currently.", diff --git a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js new file mode 100644 index 0000000000..52d359ed0a --- /dev/null +++ b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js @@ -0,0 +1,39 @@ +import { + generateUser, + translate as t, +} from '../../../../../helpers/api-integration/v3'; + +describe.only('POST /user/reset-password', async () => { + let endpoint = '/user/reset-password'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + afterEach(async () => { + }); + + it('resets password', async () => { + let response = await user.post(endpoint, { + email: user.auth.local.email, + }); + expect(response).to.eql({code: 200, message: t('passwordReset')}); + }); + + it('same message on error as on success', async () => { + let response = await user.post(endpoint, { + email: 'nonExistent@email.com', + }); + expect(response).to.eql({code: 200, message: t('passwordReset')}); + }); + + it('errors is email is not provided', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); +}); + diff --git a/website/src/controllers/api-v3/email.js b/website/src/controllers/api-v3/email.js index f1a5324872..ed2204baed 100644 --- a/website/src/controllers/api-v3/email.js +++ b/website/src/controllers/api-v3/email.js @@ -28,7 +28,6 @@ api.unsubscribe = { notEmpty: {errorMessage: res.t('missingUnsubscriptionCode')}, }, }); - let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index c4a68df4e9..2ab5825958 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -12,6 +12,8 @@ import { model as User } from '../../models/user'; import Q from 'q'; import _ from 'lodash'; import * as passwordUtils from '../../libs/api-v3/password'; +import { send as sendEmail } from '../../libs/api-v3/email'; +import nconf from 'nconf'; let api = {}; @@ -81,6 +83,50 @@ api.updatePassword = { }, }; +/** + * @api {post} /user/reset-password + * @apiVersion 3.0.0 + * @apiName resetPassword + * @apiGroup User + * @apiParam {string} email email + * @apiSuccess {Object} The success message + **/ +api.resetPassword = { + method: 'POST', + middlewares: [], + url: '/user/reset-password', + async handler (req, res) { + req.checkBody({ + email: { + notEmpty: {errorMessage: res.t('missingEmail')}, + }, + }); + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let email = req.body.email && req.body.email.toLowerCase(); + let salt = passwordUtils.makeSalt(); + let newPassword = passwordUtils.makeSalt(); // use a salt as the new password too (they'll change it later) + let hashedPassword = passwordUtils.encrypt(newPassword, salt); + + let user = await User.findOne({ 'auth.local.email': email }, { 'auth.local': 1 }); + + if (user) { + user.auth.local.salt = salt; + user.auth.local.hashed_password = hashedPassword; // eslint-disable-line camelcase + sendEmail({ + from: 'Habitica ', + to: email, + subject: 'Password Reset for Habitica', + text: `Password for ${user.auth.local.username} has been reset to ${newPassword} . Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them. Log in at ${nconf.get('BASE_URL')}. After you have logged in, head to ${nconf.get('BASE_URL')}/#/options/settings/settings and change your password.`, + html: `Password for ${user.auth.local.username} has been reset to ${newPassword}

Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them.

Log in at ${nconf.get('BASE_URL')}. After you have logged in, head to ${nconf.get('BASE_URL')}/#/options/settings/settings and change your password.`, + }); + await user.save(); + } + res.respond(300, { message: res.t('passwordReset') }); + }, +}; + /** * @api {post} /user/update-username * @apiVersion 3.0.0 From 6d14b9d5c53b6cfc19a4d10cf43f6119302a42b9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Mar 2016 02:59:17 +0100 Subject: [PATCH 536/976] move preening tests to correct location --- test/{common_old => api/v3/unit/libs}/preening.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename test/{common_old => api/v3/unit/libs}/preening.test.js (96%) diff --git a/test/common_old/preening.test.js b/test/api/v3/unit/libs/preening.test.js similarity index 96% rename from test/common_old/preening.test.js rename to test/api/v3/unit/libs/preening.test.js index f19a3c26b5..7789ad53c4 100644 --- a/test/common_old/preening.test.js +++ b/test/api/v3/unit/libs/preening.test.js @@ -1,4 +1,4 @@ -import { preenHistory } from '../../common/script/preening'; +import { preenHistory } from '../../../../../website/src/libs/api-v3/preening'; import moment from 'moment'; import sinon from 'sinon'; // eslint-disable-line no-shadow From 22d25f8be3a7fbab875a219f665ed5798df1b9ad Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 16 Mar 2016 08:41:56 -0500 Subject: [PATCH 537/976] fix: Change update user routes to use PUT instead of POST --- ...il.test.js => PUT-user_update_email.test.js} | 12 ++++++------ ...test.js => PUT-user_update_password.test.js} | 8 ++++---- ...test.js => PUT-user_update_username.test.js} | 17 +++++++++++------ website/src/controllers/api-v3/user.js | 12 ++++++------ 4 files changed, 27 insertions(+), 22 deletions(-) rename test/api/v3/integration/user/{POST-user_update_email.test.js => PUT-user_update_email.test.js} (87%) rename test/api/v3/integration/user/{POST-user_update_password.test.js => PUT-user_update_password.test.js} (87%) rename test/api/v3/integration/user/{POST-user_update_username.test.js => PUT-user_update_username.test.js} (89%) diff --git a/test/api/v3/integration/user/POST-user_update_email.test.js b/test/api/v3/integration/user/PUT-user_update_email.test.js similarity index 87% rename from test/api/v3/integration/user/POST-user_update_email.test.js rename to test/api/v3/integration/user/PUT-user_update_email.test.js index 5a8392ec1e..38fd7da53e 100644 --- a/test/api/v3/integration/user/POST-user_update_email.test.js +++ b/test/api/v3/integration/user/PUT-user_update_email.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; import { model as User } from '../../../../../website/src/models/user'; -describe('POST /user/update-email', () => { +describe('PUT /user/update-email', () => { let user; let fbUser; let endpoint = '/user/update-email'; @@ -17,7 +17,7 @@ describe('POST /user/update-email', () => { }); it('does not change email if one is not provided', async () => { - await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + await expect(user.put(endpoint)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -25,7 +25,7 @@ describe('POST /user/update-email', () => { }); it('does not change email if password is not provided', async () => { - await expect(user.post(endpoint, { + await expect(user.put(endpoint, { newEmail, })).to.eventually.be.rejected.and.eql({ code: 400, @@ -35,7 +35,7 @@ describe('POST /user/update-email', () => { }); it('does not change email if wrong password is provided', async () => { - await expect(user.post(endpoint, { + await expect(user.put(endpoint, { newEmail, password: 'wrong password', })).to.eventually.be.rejected.and.eql({ @@ -46,7 +46,7 @@ describe('POST /user/update-email', () => { }); it('changes email if new email and existing password are provided', async () => { - let response = await user.post(endpoint, { + let response = await user.put(endpoint, { newEmail, password: thePassword, }); @@ -64,7 +64,7 @@ describe('POST /user/update-email', () => { }); it('does not change email if user.auth.local.email does not exist for this user', async () => { - await expect(fbUser.post(endpoint, { + await expect(fbUser.put(endpoint, { newEmail, password: thePassword, })).to.eventually.be.rejected.and.eql({ diff --git a/test/api/v3/integration/user/POST-user_update_password.test.js b/test/api/v3/integration/user/PUT-user_update_password.test.js similarity index 87% rename from test/api/v3/integration/user/POST-user_update_password.test.js rename to test/api/v3/integration/user/PUT-user_update_password.test.js index 728abb13db..18f17177ab 100644 --- a/test/api/v3/integration/user/POST-user_update_password.test.js +++ b/test/api/v3/integration/user/PUT-user_update_password.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v3'; -describe('POST /user/update-password', async () => { +describe('PUT /user/update-password', async () => { let endpoint = '/user/update-password'; let user; let password = 'password'; @@ -16,7 +16,7 @@ describe('POST /user/update-password', async () => { it('successfully changes the password', async () => { let previousHashedPassword = user.auth.local.hashed_password; - let response = await user.post(endpoint, { + let response = await user.put(endpoint, { password, newPassword, confirmPassword: newPassword, @@ -27,7 +27,7 @@ describe('POST /user/update-password', async () => { }); it('new passwords mismatch', async () => { - await expect(user.post(endpoint, { + await expect(user.put(endpoint, { password, newPassword, confirmPassword: `${newPassword}-wrong-confirmation`, @@ -39,7 +39,7 @@ describe('POST /user/update-password', async () => { }); it('existing password is wrong', async () => { - await expect(user.post(endpoint, { + await expect(user.put(endpoint, { password: wrongPassword, newPassword, confirmPassword: newPassword, diff --git a/test/api/v3/integration/user/POST-user_update_username.test.js b/test/api/v3/integration/user/PUT-user_update_username.test.js similarity index 89% rename from test/api/v3/integration/user/POST-user_update_username.test.js rename to test/api/v3/integration/user/PUT-user_update_username.test.js index 4e59fcb5b8..b48831c010 100644 --- a/test/api/v3/integration/user/POST-user_update_username.test.js +++ b/test/api/v3/integration/user/PUT-user_update_username.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; import { model as User } from '../../../../../website/src/models/user'; -describe('POST /user/update-username', async () => { +describe('PUT /user/update-username', async () => { let endpoint = '/user/update-username'; let user; let newUsername = 'new-username'; @@ -17,7 +17,7 @@ describe('POST /user/update-username', async () => { }); it('successfully changes username', async () => { - let response = await user.post(endpoint, { + let response = await user.put(endpoint, { username: newUsername, password, }); @@ -32,8 +32,9 @@ describe('POST /user/update-username', async () => { user = await generateUser(); await user.update({'auth.local.username': existingUsername, 'auth.local.lowerCaseUsername': existingUsername }); }); + it('prevents username update', async () => { - await expect(user.post(endpoint, { + await expect(user.put(endpoint, { username: existingUsername, password, })).to.eventually.be.rejected.and.eql({ @@ -43,8 +44,9 @@ describe('POST /user/update-username', async () => { }); }); }); + it('password is wrong', async () => { - await expect(user.post(endpoint, { + await expect(user.put(endpoint, { username: newUsername, password: wrongPassword, })).to.eventually.be.rejected.and.eql({ @@ -53,13 +55,15 @@ describe('POST /user/update-username', async () => { message: t('wrongPassword'), }); }); + describe('social-only user', async () => { beforeEach(async () => { user = await generateUser(); await user.update({ 'auth.local': { ok: true } }); }); + it('prevents username update', async () => { - await expect(user.post(endpoint, { + await expect(user.put(endpoint, { username: newUsername, password, })).to.eventually.be.rejected.and.eql({ @@ -69,8 +73,9 @@ describe('POST /user/update-username', async () => { }); }); }); + it('new username is not provided', async () => { - await expect(user.post(endpoint, { + await expect(user.put(endpoint, { password, })).to.eventually.be.rejected.and.eql({ code: 400, diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 17c37aaf84..896f648d19 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -43,7 +43,7 @@ api.getUser = { }; /** - * @api {post} /user/update-password + * @api {put} /user/update-password * @apiVersion 3.0.0 * @apiName updatePassword * @apiGroup User @@ -53,7 +53,7 @@ api.getUser = { * @apiSuccess {Object} The success message **/ api.updatePassword = { - method: 'POST', + method: 'PUT', middlewares: [authWithHeaders(), cron], url: '/user/update-password', async handler (req, res) { @@ -82,7 +82,7 @@ api.updatePassword = { }; /** - * @api {post} /user/update-username + * @api {put} /user/update-username * @apiVersion 3.0.0 * @apiName updateUsername * @apiGroup User @@ -91,7 +91,7 @@ api.updatePassword = { * @apiSuccess {Object} The new username **/ api.updateUsername = { - method: 'POST', + method: 'PUT', middlewares: [authWithHeaders(), cron], url: '/user/update-username', async handler (req, res) { @@ -128,7 +128,7 @@ api.updateUsername = { /** - * @api {post} /user/update-email + * @api {put} /user/update-email * @apiVersion 3.0.0 * @apiName UpdateEmail * @apiGroup User @@ -139,7 +139,7 @@ api.updateUsername = { * @apiSuccess {Object} An object containing the new email address */ api.updateEmail = { - method: 'POST', + method: 'PUT', middlewares: [authWithHeaders(), cron], url: '/user/update-email', async handler (req, res) { From d1af7adff68c26460ac474f339e02daf0500ef36 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 16 Mar 2016 08:58:14 -0500 Subject: [PATCH 538/976] refactor: Update user update routes to be under auth namespace --- .../integration/user/PUT-user_update_email.test.js | 4 ++-- .../user/PUT-user_update_password.test.js | 4 ++-- .../user/PUT-user_update_username.test.js | 4 ++-- website/src/controllers/api-v3/user.js | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/test/api/v3/integration/user/PUT-user_update_email.test.js b/test/api/v3/integration/user/PUT-user_update_email.test.js index 38fd7da53e..749a16a3d4 100644 --- a/test/api/v3/integration/user/PUT-user_update_email.test.js +++ b/test/api/v3/integration/user/PUT-user_update_email.test.js @@ -4,10 +4,10 @@ import { } from '../../../../helpers/api-v3-integration.helper'; import { model as User } from '../../../../../website/src/models/user'; -describe('PUT /user/update-email', () => { +describe('PUT /user/auth/update-email', () => { let user; let fbUser; - let endpoint = '/user/update-email'; + let endpoint = '/user/auth/update-email'; let newEmail = 'some-new-email_2@example.net'; let thePassword = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js diff --git a/test/api/v3/integration/user/PUT-user_update_password.test.js b/test/api/v3/integration/user/PUT-user_update_password.test.js index 18f17177ab..f0ae2737d0 100644 --- a/test/api/v3/integration/user/PUT-user_update_password.test.js +++ b/test/api/v3/integration/user/PUT-user_update_password.test.js @@ -3,8 +3,8 @@ import { translate as t, } from '../../../../helpers/api-integration/v3'; -describe('PUT /user/update-password', async () => { - let endpoint = '/user/update-password'; +describe('PUT /user/auth/update-password', async () => { + let endpoint = '/user/auth/update-password'; let user; let password = 'password'; let wrongPassword = 'wrong-password'; diff --git a/test/api/v3/integration/user/PUT-user_update_username.test.js b/test/api/v3/integration/user/PUT-user_update_username.test.js index b48831c010..b105544886 100644 --- a/test/api/v3/integration/user/PUT-user_update_username.test.js +++ b/test/api/v3/integration/user/PUT-user_update_username.test.js @@ -4,8 +4,8 @@ import { } from '../../../../helpers/api-integration/v3'; import { model as User } from '../../../../../website/src/models/user'; -describe('PUT /user/update-username', async () => { - let endpoint = '/user/update-username'; +describe('PUT /user/auth/update-username', async () => { + let endpoint = '/user/auth/update-username'; let user; let newUsername = 'new-username'; let existingUsername = 'existing-username'; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 896f648d19..d2b1a47721 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -43,7 +43,7 @@ api.getUser = { }; /** - * @api {put} /user/update-password + * @api {put} /user/auth/update-password * @apiVersion 3.0.0 * @apiName updatePassword * @apiGroup User @@ -55,7 +55,7 @@ api.getUser = { api.updatePassword = { method: 'PUT', middlewares: [authWithHeaders(), cron], - url: '/user/update-password', + url: '/user/auth/update-password', async handler (req, res) { let user = res.locals.user; @@ -82,7 +82,7 @@ api.updatePassword = { }; /** - * @api {put} /user/update-username + * @api {put} /user/auth/update-username * @apiVersion 3.0.0 * @apiName updateUsername * @apiGroup User @@ -93,7 +93,7 @@ api.updatePassword = { api.updateUsername = { method: 'PUT', middlewares: [authWithHeaders(), cron], - url: '/user/update-username', + url: '/user/auth/update-username', async handler (req, res) { let user = res.locals.user; @@ -128,7 +128,7 @@ api.updateUsername = { /** - * @api {put} /user/update-email + * @api {put} /user/auth/update-email * @apiVersion 3.0.0 * @apiName UpdateEmail * @apiGroup User @@ -141,7 +141,7 @@ api.updateUsername = { api.updateEmail = { method: 'PUT', middlewares: [authWithHeaders(), cron], - url: '/user/update-email', + url: '/user/auth/update-email', async handler (req, res) { let user = res.locals.user; From 5c3c8ebb743b563bb0c0cad82e0fefca2ff90dd5 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 16 Mar 2016 17:26:53 -0500 Subject: [PATCH 539/976] refactor: Move auth update routes from user to auth controller --- website/src/controllers/api-v3/auth.js | 120 ++++++++++++++++++++++++ website/src/controllers/api-v3/user.js | 121 ------------------------- 2 files changed, 120 insertions(+), 121 deletions(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index ad3c26bbdd..875d8672ca 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -9,6 +9,7 @@ import { import cron from '../../middlewares/api-v3/cron'; import { NotAuthorized, + BadRequest, NotFound, } from '../../libs/api-v3/errors'; import Q from 'q'; @@ -283,6 +284,125 @@ api.loginSocial = { }, }; +/** + * @api {put} /user/auth/update-username + * @apiVersion 3.0.0 + * @apiName updateUsername + * @apiGroup User + * @apiParam {string} password The password + * @apiParam {string} username New username + * @apiSuccess {Object} The new username + **/ +api.updateUsername = { + method: 'PUT', + middlewares: [authWithHeaders(), cron], + url: '/user/auth/update-username', + async handler (req, res) { + let user = res.locals.user; + + req.checkBody({ + password: { + notEmpty: {errorMessage: res.t('missingPassword')}, + }, + username: { + notEmpty: { errorMessage: res.t('missingUsername') }, + }, + }); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.auth.local.username) throw new BadRequest(res.t('userHasNoLocalRegistration')); + + let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + + let count = await User.count({ 'auth.local.lowerCaseUsername': req.body.username.toLowerCase() }); + if (count > 0) throw new BadRequest(res.t('usernameTaken')); + + // save username + user.auth.local.lowerCaseUsername = req.body.username.toLowerCase(); + user.auth.local.username = req.body.username; + await user.save(); + + res.respond(200, { username: req.body.username }); + }, +}; + +/** + * @api {put} /user/auth/update-password + * @apiVersion 3.0.0 + * @apiName updatePassword + * @apiGroup User + * @apiParam {string} password The old password + * @apiParam {string} newPassword The new password + * @apiParam {string} confirmPassword Password confirmation + * @apiSuccess {Object} The success message + **/ +api.updatePassword = { + method: 'PUT', + middlewares: [authWithHeaders(), cron], + url: '/user/auth/update-password', + async handler (req, res) { + let user = res.locals.user; + + if (!user.auth.local.hashed_password) throw new BadRequest(res.t('userHasNoLocalRegistration')); + + let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + + req.checkBody({ + password: { + notEmpty: {errorMessage: res.t('missingNewPassword')}, + }, + newPassword: { + notEmpty: {errorMessage: res.t('missingPassword')}, + }, + }); + + if (req.body.newPassword !== req.body.confirmPassword) throw new NotAuthorized(res.t('passwordConfirmationMatch')); + + user.auth.local.hashed_password = passwordUtils.encrypt(req.body.newPassword, user.auth.local.salt); // eslint-disable-line camelcase + await user.save(); + res.respond(200, {}); + }, +}; + +/** + * @api {put} /user/auth/update-email + * @apiVersion 3.0.0 + * @apiName UpdateEmail + * @apiGroup User + * + * @apiParam {string} newEmail The new email address. + * @apiParam {string} password The user password. + * + * @apiSuccess {Object} An object containing the new email address + */ +api.updateEmail = { + method: 'PUT', + middlewares: [authWithHeaders(), cron], + url: '/user/auth/update-email', + async handler (req, res) { + let user = res.locals.user; + + if (!user.auth.local.email) throw new BadRequest(res.t('userHasNoLocalRegistration')); + + req.checkBody('newEmail', res.t('newEmailRequired')).notEmpty().isEmail(); + req.checkBody('password', res.t('missingPassword')).notEmpty(); + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let candidatePassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (candidatePassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + + user.auth.local.email = req.body.newEmail; + await user.save(); + + return res.respond(200, { email: user.auth.local.email }); + }, +}; + const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); // Internal route TODO expose? diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index d2b1a47721..cb5b0524ff 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -11,7 +11,6 @@ import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; import Q from 'q'; import _ from 'lodash'; -import * as passwordUtils from '../../libs/api-v3/password'; let api = {}; @@ -42,126 +41,6 @@ api.getUser = { }, }; -/** - * @api {put} /user/auth/update-password - * @apiVersion 3.0.0 - * @apiName updatePassword - * @apiGroup User - * @apiParam {string} password The old password - * @apiParam {string} newPassword The new password - * @apiParam {string} confirmPassword Password confirmation - * @apiSuccess {Object} The success message - **/ -api.updatePassword = { - method: 'PUT', - middlewares: [authWithHeaders(), cron], - url: '/user/auth/update-password', - async handler (req, res) { - let user = res.locals.user; - - if (!user.auth.local.hashed_password) throw new BadRequest(res.t('userHasNoLocalRegistration')); - - let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); - - req.checkBody({ - password: { - notEmpty: {errorMessage: res.t('missingNewPassword')}, - }, - newPassword: { - notEmpty: {errorMessage: res.t('missingPassword')}, - }, - }); - - if (req.body.newPassword !== req.body.confirmPassword) throw new NotAuthorized(res.t('passwordConfirmationMatch')); - - user.auth.local.hashed_password = passwordUtils.encrypt(req.body.newPassword, user.auth.local.salt); // eslint-disable-line camelcase - await user.save(); - res.respond(200, {}); - }, -}; - -/** - * @api {put} /user/auth/update-username - * @apiVersion 3.0.0 - * @apiName updateUsername - * @apiGroup User - * @apiParam {string} password The password - * @apiParam {string} username New username - * @apiSuccess {Object} The new username - **/ -api.updateUsername = { - method: 'PUT', - middlewares: [authWithHeaders(), cron], - url: '/user/auth/update-username', - async handler (req, res) { - let user = res.locals.user; - - req.checkBody({ - password: { - notEmpty: {errorMessage: res.t('missingPassword')}, - }, - username: { - notEmpty: { errorMessage: res.t('missingUsername') }, - }, - }); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - if (!user.auth.local.username) throw new BadRequest(res.t('userHasNoLocalRegistration')); - - let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); - - let count = await User.count({ 'auth.local.lowerCaseUsername': req.body.username.toLowerCase() }); - if (count > 0) throw new BadRequest(res.t('usernameTaken')); - - // save username - user.auth.local.lowerCaseUsername = req.body.username.toLowerCase(); - user.auth.local.username = req.body.username; - await user.save(); - - res.respond(200, { username: req.body.username }); - }, -}; - - -/** - * @api {put} /user/auth/update-email - * @apiVersion 3.0.0 - * @apiName UpdateEmail - * @apiGroup User - * - * @apiParam {string} newEmail The new email address. - * @apiParam {string} password The user password. - * - * @apiSuccess {Object} An object containing the new email address - */ -api.updateEmail = { - method: 'PUT', - middlewares: [authWithHeaders(), cron], - url: '/user/auth/update-email', - async handler (req, res) { - let user = res.locals.user; - - if (!user.auth.local.email) throw new BadRequest(res.t('userHasNoLocalRegistration')); - - req.checkBody('newEmail', res.t('newEmailRequired')).notEmpty().isEmail(); - req.checkBody('password', res.t('missingPassword')).notEmpty(); - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let candidatePassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (candidatePassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); - - user.auth.local.email = req.body.newEmail; - await user.save(); - - return res.respond(200, { email: user.auth.local.email }); - }, -}; - const partyMembersFields = 'profile.name stats achievements items.special'; /** From 2ef176c6f2d6abc370b48c186c7db8bef84d41f7 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 18 Mar 2016 07:55:19 -0500 Subject: [PATCH 540/976] refactor: Move auth route tests to auth folder --- .../integration/user/{ => auth}/PUT-user_update_email.test.js | 4 ++-- .../user/{ => auth}/PUT-user_update_password.test.js | 2 +- .../user/{ => auth}/PUT-user_update_username.test.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename test/api/v3/integration/user/{ => auth}/PUT-user_update_email.test.js (94%) rename test/api/v3/integration/user/{ => auth}/PUT-user_update_password.test.js (95%) rename test/api/v3/integration/user/{ => auth}/PUT-user_update_username.test.js (94%) diff --git a/test/api/v3/integration/user/PUT-user_update_email.test.js b/test/api/v3/integration/user/auth/PUT-user_update_email.test.js similarity index 94% rename from test/api/v3/integration/user/PUT-user_update_email.test.js rename to test/api/v3/integration/user/auth/PUT-user_update_email.test.js index 749a16a3d4..b595e213cc 100644 --- a/test/api/v3/integration/user/PUT-user_update_email.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_email.test.js @@ -1,8 +1,8 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { model as User } from '../../../../../website/src/models/user'; +} from '../../../../../helpers/api-v3-integration.helper'; +import { model as User } from '../../../../../../website/src/models/user'; describe('PUT /user/auth/update-email', () => { let user; diff --git a/test/api/v3/integration/user/PUT-user_update_password.test.js b/test/api/v3/integration/user/auth/PUT-user_update_password.test.js similarity index 95% rename from test/api/v3/integration/user/PUT-user_update_password.test.js rename to test/api/v3/integration/user/auth/PUT-user_update_password.test.js index f0ae2737d0..3dc4d9dc5d 100644 --- a/test/api/v3/integration/user/PUT-user_update_password.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_password.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration/v3'; +} from '../../../../../helpers/api-v3-integration.helper'; describe('PUT /user/auth/update-password', async () => { let endpoint = '/user/auth/update-password'; diff --git a/test/api/v3/integration/user/PUT-user_update_username.test.js b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js similarity index 94% rename from test/api/v3/integration/user/PUT-user_update_username.test.js rename to test/api/v3/integration/user/auth/PUT-user_update_username.test.js index b105544886..bcfece8eba 100644 --- a/test/api/v3/integration/user/PUT-user_update_username.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js @@ -1,8 +1,8 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration/v3'; -import { model as User } from '../../../../../website/src/models/user'; +} from '../../../../../helpers/api-v3-integration.helper'; +import { model as User } from '../../../../../../website/src/models/user'; describe('PUT /user/auth/update-username', async () => { let endpoint = '/user/auth/update-username'; From f6f5b1a118e9d646affc2deb7e3382d25329bc3c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 18 Mar 2016 07:57:42 -0500 Subject: [PATCH 541/976] tests: Remove dependency on mongoose User model in auth tests --- .../v3/integration/user/auth/PUT-user_update_email.test.js | 5 ++--- .../integration/user/auth/PUT-user_update_username.test.js | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/test/api/v3/integration/user/auth/PUT-user_update_email.test.js b/test/api/v3/integration/user/auth/PUT-user_update_email.test.js index b595e213cc..4c975390cc 100644 --- a/test/api/v3/integration/user/auth/PUT-user_update_email.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_email.test.js @@ -2,7 +2,6 @@ import { generateUser, translate as t, } from '../../../../../helpers/api-v3-integration.helper'; -import { model as User } from '../../../../../../website/src/models/user'; describe('PUT /user/auth/update-email', () => { let user; @@ -51,8 +50,8 @@ describe('PUT /user/auth/update-email', () => { password: thePassword, }); expect(response).to.eql({ email: 'some-new-email_2@example.net' }); - let id = user._id; - user = await User.findOne({ _id: id }); + + await user.sync(); expect(user.auth.local.email).to.eql(newEmail); }); }); diff --git a/test/api/v3/integration/user/auth/PUT-user_update_username.test.js b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js index bcfece8eba..fc308f4cc1 100644 --- a/test/api/v3/integration/user/auth/PUT-user_update_username.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js @@ -2,7 +2,6 @@ import { generateUser, translate as t, } from '../../../../../helpers/api-v3-integration.helper'; -import { model as User } from '../../../../../../website/src/models/user'; describe('PUT /user/auth/update-username', async () => { let endpoint = '/user/auth/update-username'; @@ -22,7 +21,7 @@ describe('PUT /user/auth/update-username', async () => { password, }); expect(response).to.eql({ username: newUsername }); - user = await User.findOne({ _id: user._id }); + await user.sync(); expect(user.auth.local.username).to.eql(newUsername); }); From 75ed4080dc55f1060506525998eed18fb3fe8c92 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 18 Mar 2016 08:34:13 -0500 Subject: [PATCH 542/976] tests: Clean up styling of auth update tests --- .../user/auth/PUT-user_update_email.test.js | 39 ++++++----- .../auth/PUT-user_update_password.test.js | 15 +++-- .../auth/PUT-user_update_username.test.js | 65 ++++++++----------- 3 files changed, 57 insertions(+), 62 deletions(-) diff --git a/test/api/v3/integration/user/auth/PUT-user_update_email.test.js b/test/api/v3/integration/user/auth/PUT-user_update_email.test.js index 4c975390cc..47357d3c85 100644 --- a/test/api/v3/integration/user/auth/PUT-user_update_email.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_email.test.js @@ -3,20 +3,21 @@ import { translate as t, } from '../../../../../helpers/api-v3-integration.helper'; -describe('PUT /user/auth/update-email', () => { - let user; - let fbUser; - let endpoint = '/user/auth/update-email'; - let newEmail = 'some-new-email_2@example.net'; - let thePassword = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js +const ENDPOINT = '/user/auth/update-email'; + +describe('PUT /user/auth/update-email', () => { + let newEmail = 'some-new-email_2@example.net'; + let oldPassword = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js + + context('Local Authenticaion User', async () => { + let user; - describe('local user', async () => { beforeEach(async () => { user = await generateUser(); }); - it('does not change email if one is not provided', async () => { - await expect(user.put(endpoint)).to.eventually.be.rejected.and.eql({ + it('does not change email if email is not provided', async () => { + await expect(user.put(ENDPOINT)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -24,7 +25,7 @@ describe('PUT /user/auth/update-email', () => { }); it('does not change email if password is not provided', async () => { - await expect(user.put(endpoint, { + await expect(user.put(ENDPOINT, { newEmail, })).to.eventually.be.rejected.and.eql({ code: 400, @@ -34,7 +35,7 @@ describe('PUT /user/auth/update-email', () => { }); it('does not change email if wrong password is provided', async () => { - await expect(user.put(endpoint, { + await expect(user.put(ENDPOINT, { newEmail, password: 'wrong password', })).to.eventually.be.rejected.and.eql({ @@ -45,9 +46,9 @@ describe('PUT /user/auth/update-email', () => { }); it('changes email if new email and existing password are provided', async () => { - let response = await user.put(endpoint, { + let response = await user.put(ENDPOINT, { newEmail, - password: thePassword, + password: oldPassword, }); expect(response).to.eql({ email: 'some-new-email_2@example.net' }); @@ -56,16 +57,18 @@ describe('PUT /user/auth/update-email', () => { }); }); - describe('facebook user', async () => { + context('Social Login User', async () => { + let socialUser; + beforeEach(async () => { - fbUser = await generateUser(); - await fbUser.update({ 'auth.local': { ok: true } }); + socialUser = await generateUser(); + await socialUser.update({ 'auth.local': { ok: true } }); }); it('does not change email if user.auth.local.email does not exist for this user', async () => { - await expect(fbUser.put(endpoint, { + await expect(socialUser.put(ENDPOINT, { newEmail, - password: thePassword, + password: oldPassword, })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', diff --git a/test/api/v3/integration/user/auth/PUT-user_update_password.test.js b/test/api/v3/integration/user/auth/PUT-user_update_password.test.js index 3dc4d9dc5d..bcc1ac25d3 100644 --- a/test/api/v3/integration/user/auth/PUT-user_update_password.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_password.test.js @@ -3,10 +3,11 @@ import { translate as t, } from '../../../../../helpers/api-v3-integration.helper'; +const ENDPOINT = '/user/auth/update-password'; + describe('PUT /user/auth/update-password', async () => { - let endpoint = '/user/auth/update-password'; let user; - let password = 'password'; + let password = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js let wrongPassword = 'wrong-password'; let newPassword = 'new-password'; @@ -16,7 +17,7 @@ describe('PUT /user/auth/update-password', async () => { it('successfully changes the password', async () => { let previousHashedPassword = user.auth.local.hashed_password; - let response = await user.put(endpoint, { + let response = await user.put(ENDPOINT, { password, newPassword, confirmPassword: newPassword, @@ -26,8 +27,8 @@ describe('PUT /user/auth/update-password', async () => { expect(user.auth.local.hashed_password).to.not.eql(previousHashedPassword); }); - it('new passwords mismatch', async () => { - await expect(user.put(endpoint, { + it('returns an error when confirmPassword does not match newPassword', async () => { + await expect(user.put(ENDPOINT, { password, newPassword, confirmPassword: `${newPassword}-wrong-confirmation`, @@ -38,8 +39,8 @@ describe('PUT /user/auth/update-password', async () => { }); }); - it('existing password is wrong', async () => { - await expect(user.put(endpoint, { + it('returns an error when existing password is wrong', async () => { + await expect(user.put(ENDPOINT, { password: wrongPassword, newPassword, confirmPassword: newPassword, diff --git a/test/api/v3/integration/user/auth/PUT-user_update_username.test.js b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js index fc308f4cc1..c61e85ab1e 100644 --- a/test/api/v3/integration/user/auth/PUT-user_update_username.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js @@ -3,20 +3,19 @@ import { translate as t, } from '../../../../../helpers/api-v3-integration.helper'; +const ENDPOINT = '/user/auth/update-username'; + describe('PUT /user/auth/update-username', async () => { - let endpoint = '/user/auth/update-username'; let user; let newUsername = 'new-username'; - let existingUsername = 'existing-username'; let password = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js - let wrongPassword = 'wrong-password'; beforeEach(async () => { user = await generateUser(); }); it('successfully changes username', async () => { - let response = await user.put(endpoint, { + let response = await user.put(ENDPOINT, { username: newUsername, password, }); @@ -26,28 +25,24 @@ describe('PUT /user/auth/update-username', async () => { }); context('errors', async () => { - describe('new username is unavailable', async () => { - beforeEach(async () => { - user = await generateUser(); - await user.update({'auth.local.username': existingUsername, 'auth.local.lowerCaseUsername': existingUsername }); - }); + it('prevents username update if new username is already taken', async () => { + let existingUsername = 'existing-username'; + await generateUser({'auth.local.username': existingUsername, 'auth.local.lowerCaseUsername': existingUsername }); - it('prevents username update', async () => { - await expect(user.put(endpoint, { - username: existingUsername, - password, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('usernameTaken'), - }); + await expect(user.put(ENDPOINT, { + username: existingUsername, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameTaken'), }); }); - it('password is wrong', async () => { - await expect(user.put(endpoint, { + it('errors if password is wrong', async () => { + await expect(user.put(ENDPOINT, { username: newUsername, - password: wrongPassword, + password: 'wrong-password', })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -55,26 +50,22 @@ describe('PUT /user/auth/update-username', async () => { }); }); - describe('social-only user', async () => { - beforeEach(async () => { - user = await generateUser(); - await user.update({ 'auth.local': { ok: true } }); - }); - it('prevents username update', async () => { - await expect(user.put(endpoint, { - username: newUsername, - password, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('userHasNoLocalRegistration'), - }); + it('prevents social-only user from changing username', async () => { + let socialUser = await generateUser({ 'auth.local': { ok: true } }); + + await expect(socialUser.put(ENDPOINT, { + username: newUsername, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('userHasNoLocalRegistration'), }); }); - it('new username is not provided', async () => { - await expect(user.put(endpoint, { + it('errors if new username is not provided', async () => { + await expect(user.put(ENDPOINT, { password, })).to.eventually.be.rejected.and.eql({ code: 400, From b46219adc5a64e27d870d867277b738a2b678029 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 18 Mar 2016 14:00:14 -0500 Subject: [PATCH 543/976] Added tests for challenge task scoring and moved scoring code to model --- ...allengeId_tasks_id_score_direction.test.js | 140 ++++++++++++++++++ test/api/v3/unit/libs/preening.test.js | 16 +- test/api/v3/unit/models/task.test.js | 74 +++++++++ test/helpers/api-unit.helper.js | 16 ++ website/src/controllers/api-v3/tasks.js | 28 +--- website/src/models/task.js | 33 +++++ 6 files changed, 265 insertions(+), 42 deletions(-) create mode 100644 test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js create mode 100644 test/api/v3/unit/models/task.test.js diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js new file mode 100644 index 0000000000..a264826884 --- /dev/null +++ b/test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js @@ -0,0 +1,140 @@ +import { + generateUser, + generateGroup, + generateChallenge, +} from '../../../../../helpers/api-integration/v3'; +import { find } from 'lodash'; + +describe('POST /tasks/:id/score/:direction', () => { + let user; + let guild; + let challenge; + + before(async () => { + user = await generateUser(); + guild = await generateGroup(user); + challenge = await generateChallenge(user, guild); + }); + + context('habits', () => { + let habit; + let usersChallengeTaskId; + let previousTaskHistory; + + before(async () => { + habit = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + }); + let updatedUser = await user.get('/user'); + usersChallengeTaskId = updatedUser.tasksOrder.habits[0]; + }); + + it('scores and adds history', async () => { + await user.post(`/tasks/${usersChallengeTaskId}/score/up`); + + let tasks = await user.get(`/tasks/challenge/${challenge._id}`); + let task = find(tasks, {_id: habit._id}); + previousTaskHistory = task.history[0]; + + expect(task.value).to.equal(1); + expect(task.history).to.have.lengthOf(1); + }); + + it('should update the history', async () => { + await user.post(`/tasks/${usersChallengeTaskId}/score/up`); + + let tasks = await user.get(`/tasks/challenge/${challenge._id}`); + let task = find(tasks, {_id: habit._id}); + + expect(task.history).to.have.lengthOf(1); + expect(task.history[0].date).to.not.equal(previousTaskHistory.date); + expect(task.history[0].value).to.not.equal(previousTaskHistory.value); + }); + }); + + context('dailies', () => { + let daily; + let usersChallengeTaskId; + let previousTaskHistory; + + before(async () => { + daily = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test daily', + type: 'daily', + }); + let updatedUser = await user.get('/user'); + usersChallengeTaskId = updatedUser.tasksOrder.dailys[0]; + }); + + it('it scores and adds history', async () => { + await user.post(`/tasks/${usersChallengeTaskId}/score/up`); + + let tasks = await user.get(`/tasks/challenge/${challenge._id}`); + let task = find(tasks, {_id: daily._id}); + previousTaskHistory = task.history[0]; + + expect(task.history).to.have.lengthOf(1); + expect(task.value).to.equal(1); + }); + + it('should update the history', async () => { + await user.post(`/tasks/${usersChallengeTaskId}/score/up`); + + let tasks = await user.get(`/tasks/challenge/${challenge._id}`); + let task = find(tasks, {_id: daily._id}); + + expect(task.history).to.have.lengthOf(1); + expect(task.history[0].date).to.not.equal(previousTaskHistory.date); + expect(task.history[0].value).to.not.equal(previousTaskHistory.value); + }); + }); + + context('todos', () => { + let todo; + let usersChallengeTaskId; + + before(async () => { + todo = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test todo', + type: 'todo', + }); + let updatedUser = await user.get('/user'); + usersChallengeTaskId = updatedUser.tasksOrder.todos[0]; + }); + + it('scores but does not add history', async () => { + await user.post(`/tasks/${usersChallengeTaskId}/score/up`); + + let tasks = await user.get(`/tasks/challenge/${challenge._id}`); + let task = find(tasks, {_id: todo._id}); + + expect(task.history).to.not.exist; + expect(task.value).to.equal(1); + }); + }); + + context('rewards', () => { + let reward; + let usersChallengeTaskId; + + before(async () => { + reward = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test reward', + type: 'reward', + }); + let updatedUser = await user.get('/user'); + usersChallengeTaskId = updatedUser.tasksOrder.todos[0]; + }); + + it('does not score', async () => { + await user.post(`/tasks/${usersChallengeTaskId}/score/up`); + + let tasks = await user.get(`/tasks/challenge/${challenge._id}`); + let task = find(tasks, {_id: reward._id}); + + expect(task.history).to.not.exist; + expect(task.value).to.equal(0); + }); + }); +}); diff --git a/test/api/v3/unit/libs/preening.test.js b/test/api/v3/unit/libs/preening.test.js index 7789ad53c4..aaccd47ec4 100644 --- a/test/api/v3/unit/libs/preening.test.js +++ b/test/api/v3/unit/libs/preening.test.js @@ -1,21 +1,7 @@ import { preenHistory } from '../../../../../website/src/libs/api-v3/preening'; import moment from 'moment'; import sinon from 'sinon'; // eslint-disable-line no-shadow - -function generateHistory (days) { - let history = []; - let now = Number(moment().toDate()); - - while (days > 0) { - history.push({ - value: days, - date: Number(moment(now).subtract(days, 'days').toDate()), - }); - days--; - } - - return history; -} +import { generateHistory } from '../../../../helpers/api-unit.helper.js'; describe('preenHistory', () => { let clock; diff --git a/test/api/v3/unit/models/task.test.js b/test/api/v3/unit/models/task.test.js new file mode 100644 index 0000000000..5cef0fb10f --- /dev/null +++ b/test/api/v3/unit/models/task.test.js @@ -0,0 +1,74 @@ +import { model as Challenge } from '../../../../../website/src/models/challenge'; +import { model as Group } from '../../../../../website/src/models/group'; +import { model as User } from '../../../../../website/src/models/user'; +import * as Tasks from '../../../../../website/src/models/task'; +import { each } from 'lodash'; +import { generateHistory } from '../../../../helpers/api-unit.helper.js'; + +describe('Task Model', () => { + let guild, leader, challenge, task; + let tasksToTest = { + habit: { + text: 'test habit', + type: 'habit', + up: false, + down: true, + }, + daily: { + text: 'test daily', + type: 'daily', + frequency: 'daily', + everyX: 5, + startDate: new Date(), + }, + }; + + beforeEach(async () => { + guild = new Group({ + name: 'test guild', + type: 'guild', + }); + + leader = new User({ + guilds: [guild._id], + }); + + guild.leader = leader._id; + + challenge = new Challenge({ + name: 'Test Challenge', + shortName: 'Test', + leader: leader._id, + group: guild._id, + }); + + leader.challenges = [challenge._id]; + + await Promise.all([ + guild.save(), + leader.save(), + challenge.save(), + ]); + }); + + each(tasksToTest, (taskValue, taskType) => { + context(`${taskType}`, () => { + beforeEach(async() => { + task = new Tasks[`${taskType}`](Tasks.Task.sanitizeCreate(taskValue)); + task.challenge.id = challenge._id; + task.history = generateHistory(396); + await task.save(); + }); + + it('preens challenge tasks history when scored', async () => { + let historyLengthBeforePreen = task.history.length; + + await task.scoreChallengeTask(1.2); + + let updatedTask = await Tasks.Task.findOne({_id: task._id}); + + expect(historyLengthBeforePreen).to.be.greaterThan(updatedTask.history.length); + }); + }); + }); +}); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 1a590f8a3a..efcdf09bc3 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -4,6 +4,7 @@ import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; import mongo from './mongo'; // eslint-disable-line +import moment from 'moment'; afterEach((done) => { sandbox.restore(); @@ -48,3 +49,18 @@ export function generateReq (options = {}) { export function generateNext (func) { return func || sandbox.stub(); } + +export function generateHistory (days) { + let history = []; + let now = Number(moment().toDate()); + + while (days > 0) { + history.push({ + value: days, + date: Number(moment(now).subtract(days, 'days').toDate()), + }); + days--; + } + + return history; +} diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 664b40a76a..f8883312b0 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -13,8 +13,6 @@ import { import common from '../../../../common'; import Q from 'q'; import _ from 'lodash'; -import moment from 'moment'; -import { preenHistory } from '../../libs/api-v3/preening'; let api = {}; @@ -438,31 +436,7 @@ api.scoreTask = { _id: task.challenge.taskId, }).exec(); - chalTask.value += delta; - - if (chalTask.type === 'habit' || chalTask.type === 'daily') { - // Add only one history entry per day - if (moment(chalTask.history[chalTask.history.length - 1].date).isSame(new Date(), 'day')) { - chalTask.history[chalTask.history.length - 1] = { - date: Number(new Date()), - value: chalTask.value, - }; - chalTask.markModified(`history.${chalTask.history.length - 1}`); - } else { - chalTask.history.push({ - date: Number(new Date()), - value: chalTask.value, - }); - - // Only preen task history once a day when the task is scored first - if (chalTask.history.length > 365) { - chalTask.history = preenHistory(chalTask.history, true); // true means the challenge will retain as much entries as a subscribed user - chalTask.markModified(`history.${chalTask.history.length - 1}`); - } - } - } - - await chalTask.save(); + await chalTask.scoreChallengeTask(delta); } catch (e) { // TODO handle } diff --git a/website/src/models/task.js b/website/src/models/task.js index a3e657605d..783acf8782 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -4,6 +4,7 @@ import validator from 'validator'; import moment from 'moment'; import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; +import { preenHistory } from '../libs/api-v3/preening'; let Schema = mongoose.Schema; let discriminatorOptions = { @@ -75,6 +76,38 @@ TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) { return reminderObj; }; +TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta) { + let chalTask = this; + + chalTask.value += delta; + + if (chalTask.type === 'habit' || chalTask.type === 'daily') { + // Add only one history entry per day + let lastChallengHistoryIndex = chalTask.history.length - 1; + + if (chalTask.history[lastChallengHistoryIndex] && + moment(chalTask.history[lastChallengHistoryIndex].date).isSame(new Date(), 'day')) { + chalTask.history[lastChallengHistoryIndex] = { + date: Number(new Date()), + value: chalTask.value, + }; + chalTask.markModified(`history.${lastChallengHistoryIndex}`); + } else { + chalTask.history.push({ + date: Number(new Date()), + value: chalTask.value, + }); + + // Only preen task history once a day when the task is scored first + if (chalTask.history.length > 365) { + chalTask.history = preenHistory(chalTask.history, true); // true means the challenge will retain as much entries as a subscribed user + } + } + } + + await chalTask.save(); +}; + export let Task = mongoose.model('Task', TaskSchema); // habits and dailies shared fields From a3d7edd0c154a5101c387f44d4e40964ab302451 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 18 Mar 2016 14:05:14 -0500 Subject: [PATCH 544/976] Renamed namespace to debug and added unit test for middleware --- .../POST-debug_addHourglass.test.js} | 14 ++++---- .../POST-debug_addTenGems.test.js} | 14 ++++---- .../unit/middlewares/ensureDevelpmentMode.js | 36 +++++++++++++++++++ .../api-v3/{development.js => debug.js} | 16 ++++----- ...lopmentMode.js => ensureDevelpmentMode.js} | 2 +- 5 files changed, 59 insertions(+), 23 deletions(-) rename test/api/v3/integration/{development/POST-development_addHourglass.test.js => debug/POST-debug_addHourglass.test.js} (79%) rename test/api/v3/integration/{development/POST-development_addTenGems.test.js => debug/POST-debug_addTenGems.test.js} (79%) create mode 100644 test/api/v3/unit/middlewares/ensureDevelpmentMode.js rename website/src/controllers/api-v3/{development.js => debug.js} (66%) rename website/src/middlewares/api-v3/{developmentMode.js => ensureDevelpmentMode.js} (71%) diff --git a/test/api/v3/integration/development/POST-development_addHourglass.test.js b/test/api/v3/integration/debug/POST-debug_addHourglass.test.js similarity index 79% rename from test/api/v3/integration/development/POST-development_addHourglass.test.js rename to test/api/v3/integration/debug/POST-debug_addHourglass.test.js index 6ef111c130..767fa840f2 100644 --- a/test/api/v3/integration/development/POST-development_addHourglass.test.js +++ b/test/api/v3/integration/debug/POST-debug_addHourglass.test.js @@ -3,15 +3,19 @@ import { generateUser, } from '../../../../helpers/api-v3-integration.helper'; -describe('POST /development/addHourglass', () => { +describe('POST /debug/add-hourglass', () => { let userToGetHourGlass; before(async () => { userToGetHourGlass = await generateUser(); }); + after(() => { + nconf.set('IS_PROD', false); + }); + it('adds Hourglass to the current user', async () => { - await userToGetHourGlass.post('/development/addHourglass'); + await userToGetHourGlass.post('/debug/add-hourglass'); let userWithHourGlass = await userToGetHourGlass.get('/user'); @@ -21,15 +25,11 @@ describe('POST /development/addHourglass', () => { it('returns error when not in production mode', async () => { nconf.set('IS_PROD', true); - await expect(userToGetHourGlass.post('/development/addHourglass')) + await expect(userToGetHourGlass.post('/debug/add-hourglass')) .eventually.be.rejected.and.to.deep.equal({ code: 404, error: 'NotFound', message: 'Not found.', }); }); - - after(() => { - nconf.set('IS_PROD', false); - }); }); diff --git a/test/api/v3/integration/development/POST-development_addTenGems.test.js b/test/api/v3/integration/debug/POST-debug_addTenGems.test.js similarity index 79% rename from test/api/v3/integration/development/POST-development_addTenGems.test.js rename to test/api/v3/integration/debug/POST-debug_addTenGems.test.js index 82594c4d07..fd01aea5d3 100644 --- a/test/api/v3/integration/development/POST-development_addTenGems.test.js +++ b/test/api/v3/integration/debug/POST-debug_addTenGems.test.js @@ -3,15 +3,19 @@ import { generateUser, } from '../../../../helpers/api-v3-integration.helper'; -describe('POST /development/addTenGems', () => { +describe('POST /debug/add-ten-gems', () => { let userToGainTenGems; before(async () => { userToGainTenGems = await generateUser(); }); + after(() => { + nconf.set('IS_PROD', false); + }); + it('adds ten gems to the current user', async () => { - await userToGainTenGems.post('/development/addTenGems'); + await userToGainTenGems.post('/debug/add-ten-gems'); let userWithTenGems = await userToGainTenGems.get('/user'); @@ -21,15 +25,11 @@ describe('POST /development/addTenGems', () => { it('returns error when not in production mode', async () => { nconf.set('IS_PROD', true); - await expect(userToGainTenGems.post('/development/addTenGems')) + await expect(userToGainTenGems.post('/debug/add-ten-gems')) .eventually.be.rejected.and.to.deep.equal({ code: 404, error: 'NotFound', message: 'Not found.', }); }); - - after(() => { - nconf.set('IS_PROD', false); - }); }); diff --git a/test/api/v3/unit/middlewares/ensureDevelpmentMode.js b/test/api/v3/unit/middlewares/ensureDevelpmentMode.js new file mode 100644 index 0000000000..8d0f8efbab --- /dev/null +++ b/test/api/v3/unit/middlewares/ensureDevelpmentMode.js @@ -0,0 +1,36 @@ +/* eslint-disable global-require */ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; +import ensureDevelpmentMode from '../../../../../website/src/middlewares/api-v3/ensureDevelpmentMode'; +import { NotFound } from '../../../../../website/src/libs/api-v3/errors'; +import nconf from 'nconf'; + +describe('developmentMode middleware', () => { + let res, req, next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + }); + + it('returns not found when in production mode', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); + + ensureDevelpmentMode(req, res, next); + + expect(next).to.be.calledWith(new NotFound()); + }); + + it('passes when not in production', () => { + sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false); + + ensureDevelpmentMode(req, res, next); + + expect(next).to.be.calledOnce; + expect(next.args[0]).to.be.empty; + }); +}); diff --git a/website/src/controllers/api-v3/development.js b/website/src/controllers/api-v3/debug.js similarity index 66% rename from website/src/controllers/api-v3/development.js rename to website/src/controllers/api-v3/debug.js index 2b252bbb8e..1aa5b08ffb 100644 --- a/website/src/controllers/api-v3/development.js +++ b/website/src/controllers/api-v3/debug.js @@ -1,17 +1,17 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; -import checkForDevelopmentMode from '../../middlewares/api-v3/developmentMode'; +import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode'; let api = {}; -api.development = { +api.debug = { method: 'all', - url: '/development/*', - middlewares: [checkForDevelopmentMode, authWithHeaders(), cron], + url: '/debug/*', + middlewares: [ensureDevelpmentMode, authWithHeaders(), cron], }; /** - * @api {post} /development/addTenGems Add ten gems to the current user + * @api {post} /debug/add-ten-gems Add ten gems to the current user * @apiVersion 3.0.0 * @apiName AddTenGems * @apiGroup Development @@ -20,7 +20,7 @@ api.development = { */ api.addTenGems = { method: 'POST', - url: '/development/addTenGems', + url: '/debug/add-ten-gems', async handler (req, res) { let user = res.locals.user; @@ -33,7 +33,7 @@ api.addTenGems = { }; /** - * @api {post} /development/addHourglass Add Hourglass to the current user + * @api {post} /debug/add-hourglass Add Hourglass to the current user * @apiVersion 3.0.0 * @apiName AddHourglass * @apiGroup Development @@ -42,7 +42,7 @@ api.addTenGems = { */ api.addHourglass = { method: 'POST', - url: '/development/addHourglass', + url: '/debug/add-hourglass', async handler (req, res) { let user = res.locals.user; diff --git a/website/src/middlewares/api-v3/developmentMode.js b/website/src/middlewares/api-v3/ensureDevelpmentMode.js similarity index 71% rename from website/src/middlewares/api-v3/developmentMode.js rename to website/src/middlewares/api-v3/ensureDevelpmentMode.js index 5c068df0ab..98f70d33f5 100644 --- a/website/src/middlewares/api-v3/developmentMode.js +++ b/website/src/middlewares/api-v3/ensureDevelpmentMode.js @@ -3,7 +3,7 @@ import { NotFound, } from '../../libs/api-v3/errors'; -module.exports = function checkForDevelopmentMode (req, res, next) { +module.exports = function ensureDevelpmentMode (req, res, next) { if (nconf.get('IS_PROD')) { next(new NotFound()); } else { From e68ebee980c4f14457a0ff86954bbc8f1e6df918 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 19 Mar 2016 13:10:40 +0100 Subject: [PATCH 545/976] temporarily skip challenge task deletion test --- .../challenges/DELETE-tasks_id_challenge_challengeId.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js index 2944946005..34c93c2f05 100644 --- a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js +++ b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js @@ -95,7 +95,8 @@ describe('DELETE /tasks/:id', () => { }); }); - it('allows user to delete challenge task after challenge task is broken', async () => { + // TODO for some reason this test fails on TravisCI, review after mongodb indexes have been added + xit('allows user to delete challenge task after challenge task is broken', async () => { await expect(user.del(`/tasks/${newChallengeTask._id}`)); await sleep(2); From ff72706cae39e2725f60b433c5c715d4c7ba3bd4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Mar 2016 16:25:46 +0100 Subject: [PATCH 546/976] wip(shared): port buy ops and linked fns --- common/locales/en/api-v3.json | 7 +- common/script/fns/handleTwoHanded.js | 19 ++-- common/script/fns/predictableRandom.js | 23 ++-- common/script/fns/randomVal.js | 14 +-- common/script/fns/ultimateGear.js | 32 +++--- common/script/ops/buy.js | 146 ++++++++++++++----------- common/script/ops/buyMysterySet.js | 68 ++++++------ common/script/ops/buyQuest.js | 73 ++++++------- common/script/ops/buySpecialSpell.js | 42 +++---- tasks/gulp-eslint.js | 8 -- website/src/models/user.js | 10 +- 11 files changed, 227 insertions(+), 215 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 9f48532913..abbce91745 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -77,6 +77,7 @@ "guildQuestsNotSupported": "Guilds cannot be invited on quests.", "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", + "questNotGoldPurchasable": "Quest \"<%= key %>\" is not a Gold-purchasable quest.", "questLevelTooHigh": "You must be level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", "questAlreadyAccepted": "You already accepted the quest invitation.", @@ -102,5 +103,9 @@ "spellNotOwned": "You don't own this spell.", "spellLevelTooHigh": "You must be level <%= level %> to use this spell.", "invalidAttribute": "\"<%= attr %>\" is not a valid attribute.", - "notEnoughAttrPoints": "You don't have enough attribute points." + "notEnoughAttrPoints": "You don't have enough attribute points.", + "missingKeyParam": "\"req.params.key\" is required.", + "mysterySetNotFound": "Mystery set not found, or set already owned", + "itemNotFound": "Item \"<%= key %>\" not found.", + "cannoyBuyItem": "You can't buy this item" } diff --git a/common/script/fns/handleTwoHanded.js b/common/script/fns/handleTwoHanded.js index c700346988..44537a1cac 100644 --- a/common/script/fns/handleTwoHanded.js +++ b/common/script/fns/handleTwoHanded.js @@ -1,24 +1,23 @@ import content from '../content/index'; import i18n from '../i18n'; -module.exports = function(user, item, type, req) { - var message, currentWeapon, currentShield; - if (type == null) { - type = 'equipped'; - } - currentShield = content.gear.flat[user.items.gear[type].shield]; - currentWeapon = content.gear.flat[user.items.gear[type].weapon]; +module.exports = function handleTwoHanded (user, item, type = 'equipped', req) { + let currentShield = content.gear.flat[user.items.gear[type].shield]; + let currentWeapon = content.gear.flat[user.items.gear[type].weapon]; - if (item.type === "shield" && (currentWeapon ? currentWeapon.twoHanded : false)) { + let message; + + if (item.type === 'shield' && (currentWeapon ? currentWeapon.twoHanded : false)) { user.items.gear[type].weapon = 'weapon_base_0'; message = i18n.t('messageTwoHandedUnequip', { twoHandedText: currentWeapon.text(req.language), offHandedText: item.text(req.language), }, req.language); - } else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) { - user.items.gear[type].shield = "shield_base_0"; + } else if (item.twoHanded && (currentShield && user.items.gear[type].shield !== 'shield_base_0')) { + user.items.gear[type].shield = 'shield_base_0'; message = i18n.t('messageTwoHandedEquip', { twoHandedText: item.text(req.language), offHandedText: currentShield.text(req.language), }, req.language); } + return message; }; diff --git a/common/script/fns/predictableRandom.js b/common/script/fns/predictableRandom.js index 64c8153746..373e015500 100644 --- a/common/script/fns/predictableRandom.js +++ b/common/script/fns/predictableRandom.js @@ -1,20 +1,19 @@ import _ from 'lodash'; -/* -Because the same op needs to be performed on the client and the server (critical hits, item drops, etc), -we need things to be "random", but technically predictable so that they don't go out-of-sync - */ -module.exports = function(user, seed) { - var x; +// Because the same op needs to be performed on the client and the server (critical hits, item drops, etc), +// we need things to be "random", but technically predictable so that they don't go out-of-sync + +module.exports = function predictableRandom (user, seed) { if (!seed || seed === Math.PI) { - seed = _.reduce(user.stats, (function(m, v) { - if (_.isNumber(v)) { - return m + v; + seed = _.reduce(user.stats, (accumulator, val) => { + if (_.isNumber(val)) { + return accumulator + val; } else { - return m; + return accumulator; } - }), 0); + }, 0); } - x = Math.sin(seed++) * 10000; + + let x = Math.sin(seed++) * 10000; return x - Math.floor(x); }; diff --git a/common/script/fns/randomVal.js b/common/script/fns/randomVal.js index 3c2b5b82e8..2244d04558 100644 --- a/common/script/fns/randomVal.js +++ b/common/script/fns/randomVal.js @@ -1,14 +1,12 @@ import _ from 'lodash'; +import predictableRandom from './predictableRandom'; -/* - Get a random property from an object - returns random property (the value) - */ +// Get a random property from an object +// returns random property (the value) -module.exports = function(user, obj, options) { - var array, rand; - array = (options != null ? options.key : void 0) ? _.keys(obj) : _.values(obj); - rand = user.fns.predictableRandom(options != null ? options.seed : void 0); +module.exports = function randomVal (user, obj, options = {}) { + let array = options.key ? _.keys(obj) : _.values(obj); + let rand = predictableRandom(user, options.seed); array.sort(); return array[Math.floor(rand * array.length)]; }; diff --git a/common/script/fns/ultimateGear.js b/common/script/fns/ultimateGear.js index 1333e8cc38..729d11001a 100644 --- a/common/script/fns/ultimateGear.js +++ b/common/script/fns/ultimateGear.js @@ -1,33 +1,35 @@ import content from '../content/index'; import _ from 'lodash'; -module.exports = function(user) { - var base, owned; - owned = typeof window !== "undefined" && window !== null ? user.items.gear.owned : user.items.gear.owned.toObject(); - if ((base = user.achievements).ultimateGearSets == null) { - base.ultimateGearSets = { +module.exports = function ultimateGear (user) { + let owned = window ? user.items.gear.owned : user.items.gear.owned.toObject(); + + if (!user.achievements.ultimateGearSets) { + user.achievements.ultimateGearSets = { healer: false, wizard: false, rogue: false, - warrior: false + warrior: false, }; } - content.classes.forEach(function(klass) { + + content.classes.forEach((klass) => { if (user.achievements.ultimateGearSets[klass] !== true) { - return user.achievements.ultimateGearSets[klass] = _.reduce(['armor', 'shield', 'head', 'weapon'], function(soFarGood, type) { - var found; - found = _.find(content.gear.tree[type][klass], { - last: true + user.achievements.ultimateGearSets[klass] = _.reduce(['armor', 'shield', 'head', 'weapon'], (soFarGood, type) => { + let found = _.find(content.gear.tree[type][klass], { + last: true, }); return soFarGood && (!found || owned[found.key] === true); }, true); } }); - if (typeof user.markModified === "function") { - user.markModified('achievements.ultimateGearSets'); - } + + // TODO + if (user.markModified) user.markModified('achievements.ultimateGearSets'); + if (_.contains(user.achievements.ultimateGearSets, true) && user.flags.armoireEnabled !== true) { user.flags.armoireEnabled = true; - return typeof user.markModified === "function" ? user.markModified('flags') : void 0; } + + return; }; diff --git a/common/script/ops/buy.js b/common/script/ops/buy.js index 1686316264..d90d07b0cb 100644 --- a/common/script/ops/buy.js +++ b/common/script/ops/buy.js @@ -3,117 +3,133 @@ import i18n from '../i18n'; import _ from 'lodash'; import count from '../count'; import splitWhitespace from '../libs/splitWhitespace'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../libs/errors'; +import predictableRandom from '../fns/predictableRandom'; +import randomVal from '../fns/randomVal'; +import handleTwoHanded from '../fns/handleTwoHanded'; +import ultimateGear from '../fns/ultimateGear'; -module.exports = function(user, req, cb, analytics) { - var analyticsData, armoireExp, armoireResp, armoireResult, base, buyResp, drop, eligibleEquipment, item, key, message, name; - key = req.params.key; - item = key === 'potion' ? content.potion : key === 'armoire' ? content.armoire : content.gear.flat[key]; - if (!item) { - return typeof cb === "function" ? cb({ - code: 404, - message: "Item '" + key + " not found (see https://github.com/HabitRPG/habitrpg/blob/develop/common/script/content/index.js)" - }) : void 0; +module.exports = function buy (user, req = {}, analytics) { + let key = _.get(req, 'params.key'); + if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); + + let item; + if (key === 'potion') { + item = content.potion; + } else if (key === 'armoire') { + item = content.armoire; + } else { + item = content.gear.flat[key]; } + if (!item) throw new NotFound(i18n.t('itemNotFound', {key}, req.language)); + if (user.stats.gp < item.value) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('messageNotEnoughGold', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); } - if ((item.canOwn != null) && !item.canOwn(user)) { - return typeof cb === "function" ? cb({ - code: 401, - message: "You can't buy this item" - }) : void 0; + + if (item.canOwn && !item.canOwn(user)) { + throw new NotAuthorized(i18n.t('cannoyBuyItem', req.language)); } - armoireResp = void 0; + + let armoireResp; + let armoireResult; + let eligibleEquipment; + let drop; + let message; + if (item.key === 'potion') { user.stats.hp += 15; if (user.stats.hp > 50) { user.stats.hp = 50; } } else if (item.key === 'armoire') { - armoireResult = user.fns.predictableRandom(user.stats.gp); - eligibleEquipment = _.filter(content.gear.flat, (function(i) { - return i.klass === 'armoire' && !user.items.gear.owned[i.key]; - })); - if (!_.isEmpty(eligibleEquipment) && (armoireResult < .6 || !user.flags.armoireOpened)) { + armoireResult = predictableRandom(user, user.stats.gp); + eligibleEquipment = _.filter(content.gear.flat, (eligible) => { + return eligible.klass === 'armoire' && !user.items.gear.owned[eligible.key]; + }); + + if (!_.isEmpty(eligibleEquipment) && (armoireResult < 0.6 || !user.flags.armoireOpened)) { eligibleEquipment.sort(); - drop = user.fns.randomVal(eligibleEquipment); + drop = randomVal(user, eligibleEquipment); + user.items.gear.owned[drop.key] = true; user.flags.armoireOpened = true; message = i18n.t('armoireEquipment', { - image: '', - dropText: drop.text(req.language) + image: ``, + dropText: drop.text(req.language), }, req.language); + if (count.remainingGearInSet(user.items.gear.owned, 'armoire') === 0) { user.flags.armoireEmpty = true; } + armoireResp = { - type: "gear", + type: 'gear', dropKey: drop.key, - dropText: drop.text(req.language) + dropText: drop.text(req.language), }; - } else if ((!_.isEmpty(eligibleEquipment) && armoireResult < .8) || armoireResult < .5) { - drop = user.fns.randomVal(_.where(content.food, { - canDrop: true + } else if ((!_.isEmpty(eligibleEquipment) && armoireResult < 0.8) || armoireResult < 0.5) { // eslint-disable-line no-extra-parens + drop = randomVal(_.where(content.food, { + canDrop: true, })); - if ((base = user.items.food)[name = drop.key] == null) { - base[name] = 0; - } + user.items.food[drop.key] = user.items.food[drop.key] || 0; user.items.food[drop.key] += 1; + message = i18n.t('armoireFood', { - image: '', + image: ``, dropArticle: drop.article, - dropText: drop.text(req.language) + dropText: drop.text(req.language), }, req.language); armoireResp = { - type: "food", + type: 'food', dropKey: drop.key, dropArticle: drop.article, - dropText: drop.text(req.language) + dropText: drop.text(req.language), }; } else { - armoireExp = Math.floor(user.fns.predictableRandom(user.stats.exp) * 40 + 10); + let armoireExp = Math.floor(predictableRandom(user, user.stats.exp) * 40 + 10); user.stats.exp += armoireExp; message = i18n.t('armoireExp', req.language); armoireResp = { - "type": "experience", - "value": armoireExp + type: 'experience', + value: armoireExp, }; } } else { if (user.preferences.autoEquip) { user.items.gear.equipped[item.type] = item.key; - message = user.fns.handleTwoHanded(item, null, req); + message = handleTwoHanded(user, item, null, req); } user.items.gear.owned[item.key] = true; - if (message == null) { + + if (!message) { message = i18n.t('messageBought', { - itemText: item.text(req.language) + itemText: item.text(req.language), }, req.language); } - if (item.last) { - user.fns.ultimateGear(); - } + if (item.last) ultimateGear(user); } + user.stats.gp -= item.value; - analyticsData = { - uuid: user._id, - itemKey: key, - acquireMethod: 'Gold', - goldCost: item.value, - category: 'behavior' + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: key, + acquireMethod: 'Gold', + goldCost: item.value, + category: 'behavior', + }); + } + + let buyResp = _.pick(user, splitWhitespace('items achievements stats flags')); + if (armoireResp) buyResp.armoire = armoireResp; + + return { + data: buyResp, + message, }; - if (analytics != null) { - analytics.track('acquire item', analyticsData); - } - buyResp = _.pick(user, splitWhitespace('items achievements stats flags')); - if (armoireResp) { - buyResp["armoire"] = armoireResp; - } - return typeof cb === "function" ? cb({ - code: 200, - message: message - }, buyResp) : void 0; }; diff --git a/common/script/ops/buyMysterySet.js b/common/script/ops/buyMysterySet.js index 44ccdb9aaf..c43b925358 100644 --- a/common/script/ops/buyMysterySet.js +++ b/common/script/ops/buyMysterySet.js @@ -2,42 +2,48 @@ import i18n from '../i18n'; import content from '../content/index'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../libs/errors'; + +module.exports = function buyMysterySet (user, req = {}, analytics) { + let key = _.get(req, 'params.key'); + if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); -module.exports = function(user, req, cb, analytics) { - var mysterySet, ref; if (!(user.purchased.plan.consecutive.trinkets > 0)) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('notEnoughHourglasses', req.language) - }) : void 0; - } - mysterySet = (ref = content.timeTravelerStore(user.items.gear.owned)) != null ? ref[req.params.key] : void 0; - if ((typeof window !== "undefined" && window !== null ? window.confirm : void 0) != null) { - if (!window.confirm(i18n.t('hourglassBuyEquipSetConfirm'))) { - return; - } + throw new NotAuthorized(i18n.t('notEnoughHourglasses', req.language)); } + + let ref = content.timeTravelerStore(user.items.gear.owned); + let mysterySet = ref ? ref[key] : undefined; + if (!mysterySet) { - return typeof cb === "function" ? cb({ - code: 404, - message: "Mystery set not found, or set already owned" - }) : void 0; + throw new NotFound(i18n.t('mysterySetNotFound', req.language)); } - _.each(mysterySet.items, function(i) { - var analyticsData; - user.items.gear.owned[i.key] = true; - analyticsData = { - uuid: user._id, - itemKey: i.key, - itemType: 'Subscriber Gear', - acquireMethod: 'Hourglass', - category: 'behavior' - }; - return analytics != null ? analytics.track('acquire item', analyticsData) : void 0; + + if (window && window.confirm) { // TODO move to client + if (!window.confirm(i18n.t('hourglassBuyEquipSetConfirm'))) return; + } + + _.each(mysterySet.items, item => { + user.items.gear.owned[item.key] = true; + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: item.key, + itemType: 'Subscriber Gear', + acquireMethod: 'Hourglass', + category: 'behavior', + }); + } }); + user.purchased.plan.consecutive.trinkets--; - return typeof cb === "function" ? cb({ - code: 200, - message: i18n.t('hourglassPurchaseSet', req.language) - }, _.pick(user, splitWhitespace('items purchased.plan.consecutive'))) : void 0; + + return { + data: _.pick(user, splitWhitespace('items purchased.plan.consecutive')), + message: i18n.t('hourglassPurchaseSet', req.language), + }; }; diff --git a/common/script/ops/buyQuest.js b/common/script/ops/buyQuest.js index b7653d43ce..033f2e5620 100644 --- a/common/script/ops/buyQuest.js +++ b/common/script/ops/buyQuest.js @@ -1,49 +1,44 @@ import i18n from '../i18n'; import content from '../content/index'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../libs/errors'; +import _ from 'lodash'; +module.exports = function buyQuest (user, req = {}, analytics) { + let key = _.get(req, 'params.key'); + if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); + + let item = content.quests[key]; + if (!item) throw new NotFound(i18n.t('questNotFound', req.language)); -module.exports = function(user, req, cb, analytics) { - var analyticsData, base, item, key, message, name; - key = req.params.key; - item = content.quests[key]; - if (!item) { - return typeof cb === "function" ? cb({ - code: 404, - message: "Quest '" + key + " not found (see https://github.com/HabitRPG/habitrpg/blob/develop/common/script/content/index.js)" - }) : void 0; - } if (!(item.category === 'gold' && item.goldValue)) { - return typeof cb === "function" ? cb({ - code: 404, - message: "Quest '" + key + " is not a Gold-purchasable quest (see https://github.com/HabitRPG/habitrpg/blob/develop/common/script/content/index.js)" - }) : void 0; + throw new NotAuthorized(i18n.t('questNotGoldPurchasable', {key}, req.language)); } if (user.stats.gp < item.goldValue) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('messageNotEnoughGold', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); } - message = i18n.t('messageBought', { - itemText: item.text(req.language) - }, req.language); - if ((base = user.items.quests)[name = item.key] == null) { - base[name] = 0; - } - user.items.quests[item.key] += 1; + + user.items.quests[item.key] = user.items.quests[item.key] || 0; + user.items.quests[item.key]++; user.stats.gp -= item.goldValue; - analyticsData = { - uuid: user._id, - itemKey: item.key, - itemType: 'Market', - goldCost: item.goldValue, - acquireMethod: 'Gold', - category: 'behavior' - }; - if (analytics != null) { - analytics.track('acquire item', analyticsData); + + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: item.key, + itemType: 'Market', + goldCost: item.goldValue, + acquireMethod: 'Gold', + category: 'behavior', + }); } - return typeof cb === "function" ? cb({ - code: 200, - message: message - }, user.items.quests) : void 0; + + return { + data: user.items.quests, + message: i18n.t('messageBought', { + itemText: item.text(req.language), + }, req.language), + }; }; diff --git a/common/script/ops/buySpecialSpell.js b/common/script/ops/buySpecialSpell.js index e2a9dc8deb..36ab94e143 100644 --- a/common/script/ops/buySpecialSpell.js +++ b/common/script/ops/buySpecialSpell.js @@ -2,30 +2,30 @@ import i18n from '../i18n'; import content from '../content/index'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../libs/errors'; + +module.exports = function buySpecialSpell (user, req = {}) { + let key = _.get(req, 'params.key'); + if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); + + let item = content.special[key]; + if (!item) throw new NotFound(i18n.t('spellNotFound', {spellId: key}, req.language)); -module.exports = function(user, req, cb) { - var base, item, key, message; - key = req.params.key; - item = content.special[key]; if (user.stats.gp < item.value) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('messageNotEnoughGold', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); } user.stats.gp -= item.value; - if ((base = user.items.special)[key] == null) { - base[key] = 0; - } + user.items.special[key]++; - if (typeof user.markModified === "function") { - user.markModified('items.special'); - } - message = i18n.t('messageBought', { - itemText: item.text(req.language) - }, req.language); - return typeof cb === "function" ? cb({ - code: 200, - message: message - }, _.pick(user, splitWhitespace('items stats'))) : void 0; + + return { + data: _.pick(user, splitWhitespace('items stats')), + message: i18n.t('messageBought', { + itemText: item.text(req.language), + }, req.language), + }; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 1c5df3f6f8..85c275bb8f 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -21,10 +21,6 @@ const COMMON_FILES = [ '!./common/script/ops/addWebhook.js', '!./common/script/ops/allocateNow.js', '!./common/script/ops/blockUser.js', - '!./common/script/ops/buy.js', - '!./common/script/ops/buyMysterySet.js', - '!./common/script/ops/buyQuest.js', - '!./common/script/ops/buySpecialSpell.js', '!./common/script/ops/changeClass.js', '!./common/script/ops/clearCompleted.js', '!./common/script/ops/clearPMs.js', @@ -63,13 +59,9 @@ const COMMON_FILES = [ '!./common/script/fns/dotGet.js', '!./common/script/fns/dotSet.js', '!./common/script/fns/getItem.js', - '!./common/script/fns/handleTwoHanded.js', '!./common/script/fns/nullify.js', - '!./common/script/fns/predictableRandom.js', '!./common/script/fns/preenUserHistory.js', '!./common/script/fns/randomDrop.js', - '!./common/script/fns/randomVal.js', - '!./common/script/fns/ultimateGear.js', '!./common/script/fns/updateStats.js', '!./common/script/libs/appliedTags.js', '!./common/script/libs/countExists.js', diff --git a/website/src/models/user.js b/website/src/models/user.js index e6bcad4261..12398fa261 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -263,15 +263,15 @@ export let schema = new Schema({ spookDust: {type: Number, default: 0}, shinySeed: {type: Number, default: 0}, seafoam: {type: Number, default: 0}, - valentine: Number, + valentine: {type: Number, default: 0}, valentineReceived: Array, // array of strings, by sender name - nye: Number, + nye: {type: Number, default: 0}, nyeReceived: Array, - greeting: Number, + greeting: {type: Number, default: 0}, greetingReceived: Array, - thankyou: Number, + thankyou: {type: Number, default: 0}, thankyouReceived: Array, - birthday: Number, + birthday: {type: Number, default: 0}, birthdayReceived: Array, }, From 957e1d26d6d5525ac9afd4b1c273d8ba357e828c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 19 Mar 2016 18:05:02 +0100 Subject: [PATCH 547/976] add tests for fns/ultimateGear, fns/handleTwoHanded, fns/randomVal, fns/predictableRandom and partial tests for ops/buy --- common/script/fns/handleTwoHanded.js | 2 +- common/script/fns/ultimateGear.js | 14 +- common/script/index.js | 14 +- common/script/ops/buy.js | 2 +- common/script/ops/buyMysterySet.js | 2 +- test/common/fns/handleTwoHanded.js | 38 +++++ test/common/fns/predictableRandom.js | 51 ++++++ test/common/fns/randomVal.js | 119 +++++++++++++ test/common/fns/ultimateGear.js | 33 ++++ .../ops/buy.js} | 157 ++++++++++-------- test/common_old/user.fns.ultimateGear.test.js | 37 ----- website/src/models/user.js | 9 +- 12 files changed, 341 insertions(+), 137 deletions(-) create mode 100644 test/common/fns/handleTwoHanded.js create mode 100644 test/common/fns/predictableRandom.js create mode 100644 test/common/fns/randomVal.js create mode 100644 test/common/fns/ultimateGear.js rename test/{common_old/user.fns.buy.test.js => common/ops/buy.js} (58%) delete mode 100644 test/common_old/user.fns.ultimateGear.test.js diff --git a/common/script/fns/handleTwoHanded.js b/common/script/fns/handleTwoHanded.js index 44537a1cac..a861a10e68 100644 --- a/common/script/fns/handleTwoHanded.js +++ b/common/script/fns/handleTwoHanded.js @@ -1,7 +1,7 @@ import content from '../content/index'; import i18n from '../i18n'; -module.exports = function handleTwoHanded (user, item, type = 'equipped', req) { +module.exports = function handleTwoHanded (user, item, type = 'equipped', req = {}) { let currentShield = content.gear.flat[user.items.gear[type].shield]; let currentWeapon = content.gear.flat[user.items.gear[type].weapon]; diff --git a/common/script/fns/ultimateGear.js b/common/script/fns/ultimateGear.js index 729d11001a..be5553201d 100644 --- a/common/script/fns/ultimateGear.js +++ b/common/script/fns/ultimateGear.js @@ -2,16 +2,7 @@ import content from '../content/index'; import _ from 'lodash'; module.exports = function ultimateGear (user) { - let owned = window ? user.items.gear.owned : user.items.gear.owned.toObject(); - - if (!user.achievements.ultimateGearSets) { - user.achievements.ultimateGearSets = { - healer: false, - wizard: false, - rogue: false, - warrior: false, - }; - } + let owned = typeof window !== 'undefined' ? user.items.gear.owned : user.items.gear.owned.toObject(); content.classes.forEach((klass) => { if (user.achievements.ultimateGearSets[klass] !== true) { @@ -24,9 +15,6 @@ module.exports = function ultimateGear (user) { } }); - // TODO - if (user.markModified) user.markModified('achievements.ultimateGearSets'); - if (_.contains(user.achievements.ultimateGearSets, true) && user.flags.armoireEnabled !== true) { user.flags.armoireEnabled = true; } diff --git a/common/script/index.js b/common/script/index.js index a7bdf6e3d1..a6dcd73f97 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -155,6 +155,12 @@ api.wrap = function wrapUser (user, main = true) { if (user._wrapped) return; user._wrapped = true; + // Make markModified available on the client side as a noop function + // TODO move to client? + if (!user.markModified) { + user.markModified = function noopMarkModified () {}; + } + if (main) { user.ops = { update: _.partial(importedOps.update, user), @@ -183,14 +189,10 @@ api.wrap = function wrapUser (user, main = true) { deletePM: _.partial(importedOps.deletePM, user), blockUser: _.partial(importedOps.blockUser, user), feed: _.partial(importedOps.feed, user), - buySpecialSpell: _.partial(importedOps.buySpecialSpell, user), purchase: _.partial(importedOps.purchase, user), releasePets: _.partial(importedOps.releasePets, user), releaseMounts: _.partial(importedOps.releaseMounts, user), releaseBoth: _.partial(importedOps.releaseBoth, user), - buy: _.partial(importedOps.buy, user), - buyQuest: _.partial(importedOps.buyQuest, user), - buyMysterySet: _.partial(importedOps.buyMysterySet, user), hourglassPurchase: _.partial(importedOps.hourglassPurchase, user), sell: _.partial(importedOps.sell, user), equip: _.partial(importedOps.equip, user), @@ -207,10 +209,7 @@ api.wrap = function wrapUser (user, main = true) { user.fns = { getItem: _.partial(importedFns.getItem, user), - handleTwoHanded: _.partial(importedFns.handleTwoHanded, user), - predictableRandom: _.partial(importedFns.predictableRandom, user), crit: _.partial(importedFns.crit, user), - randomVal: _.partial(importedFns.randomVal, user), dotSet: _.partial(importedFns.dotSet, user), dotGet: _.partial(importedFns.dotGet, user), randomDrop: _.partial(importedFns.randomDrop, user), @@ -218,7 +217,6 @@ api.wrap = function wrapUser (user, main = true) { updateStats: _.partial(importedFns.updateStats, user), cron: _.partial(importedFns.cron, user), preenUserHistory: _.partial(importedFns.preenUserHistory, user), - ultimateGear: _.partial(importedFns.ultimateGear, user), nullify: _.partial(importedFns.nullify, user), }; diff --git a/common/script/ops/buy.js b/common/script/ops/buy.js index d90d07b0cb..d99917a358 100644 --- a/common/script/ops/buy.js +++ b/common/script/ops/buy.js @@ -102,7 +102,7 @@ module.exports = function buy (user, req = {}, analytics) { } else { if (user.preferences.autoEquip) { user.items.gear.equipped[item.type] = item.key; - message = handleTwoHanded(user, item, null, req); + message = handleTwoHanded(user, item, undefined, req); } user.items.gear.owned[item.key] = true; diff --git a/common/script/ops/buyMysterySet.js b/common/script/ops/buyMysterySet.js index c43b925358..91c5b3f5d9 100644 --- a/common/script/ops/buyMysterySet.js +++ b/common/script/ops/buyMysterySet.js @@ -23,7 +23,7 @@ module.exports = function buyMysterySet (user, req = {}, analytics) { throw new NotFound(i18n.t('mysterySetNotFound', req.language)); } - if (window && window.confirm) { // TODO move to client + if (typeof window !== 'undefined' && window.confirm) { // TODO move to client if (!window.confirm(i18n.t('hourglassBuyEquipSetConfirm'))) return; } diff --git a/test/common/fns/handleTwoHanded.js b/test/common/fns/handleTwoHanded.js new file mode 100644 index 0000000000..8d191ff114 --- /dev/null +++ b/test/common/fns/handleTwoHanded.js @@ -0,0 +1,38 @@ +import handleTwoHanded from '../../../common/script/fns/handleTwoHanded'; +import content from '../../../common/script/content/index'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.fns.handleTwoHanded', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('uses "messageTwoHandedUnequip" message if item is a shield and current weapon is two handed (and sets the user\'s weapon to the base one)', () => { + let item = content.gear.tree.shield.warrior['2']; + let currentWeapon = content.gear.tree.weapon.armoire.rancherLasso; + user.items.gear.equipped.weapon = 'weapon_armoire_rancherLasso'; + + let message = handleTwoHanded(user, item); + expect(message).to.equal(i18n.t('messageTwoHandedUnequip', { + twoHandedText: currentWeapon.text(), offHandedText: item.text(), + })); + expect(user.items.gear.equipped.weapon).to.equal('weapon_base_0'); + }); + + it('uses "messageTwoHandedEquip" message if item is two handed and currentShield exists but is not "shield_base_0" (and sets the user\'s shield to the base one)', () => { + let item = content.gear.tree.weapon.armoire.rancherLasso; + let currentShield = content.gear.tree.shield.armoire.gladiatorShield; + user.items.gear.equipped.shield = 'shield_armoire_gladiatorShield'; + + let message = handleTwoHanded(user, item); + expect(message).to.equal(i18n.t('messageTwoHandedEquip', { + twoHandedText: item.text(), offHandedText: currentShield.text(), + })); + expect(user.items.gear.equipped.shield).to.equal('shield_base_0'); + }); +}); diff --git a/test/common/fns/predictableRandom.js b/test/common/fns/predictableRandom.js new file mode 100644 index 0000000000..1cd47fc426 --- /dev/null +++ b/test/common/fns/predictableRandom.js @@ -0,0 +1,51 @@ +import predictableRandom from '../../../common/script/fns/predictableRandom'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.fns.predictableRandom', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('returns a number', () => { + expect(predictableRandom(user)).to.be.a('number'); + }); + + it('returns the same value when user.stats is the same and no seed is passed', () => { + user.stats.hp = 43; + user.stats.gp = 34; + + let val1 = predictableRandom(user); + let val2 = predictableRandom(user); + + expect(val2).to.equal(val1); + }); + + it('returns a different value when user.stats is not the same and no seed is passed', () => { + user.stats.hp = 43; + user.stats.gp = 34; + let val1 = predictableRandom(user); + + user.stats.gp = 35; + let val2 = predictableRandom(user); + + expect(val2).to.not.equal(val1); + }); + + it('returns the same value when the same seed is passed', () => { + let val1 = predictableRandom(user, 4452673762); + let val2 = predictableRandom(user, 4452673762); + + expect(val2).to.equal(val1); + }); + + it('returns a different value when a different seed is passed', () => { + let val1 = predictableRandom(user, 4452673761); + let val2 = predictableRandom(user, 4452673762); + + expect(val2).to.not.equal(val1); + }); +}); diff --git a/test/common/fns/randomVal.js b/test/common/fns/randomVal.js new file mode 100644 index 0000000000..b4b8e377d3 --- /dev/null +++ b/test/common/fns/randomVal.js @@ -0,0 +1,119 @@ +import randomVal from '../../../common/script/fns/randomVal'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.fns.randomVal', () => { + let user; + let obj = { + a: 1, + b: 2, + c: 3, + d: 4, + }; + + beforeEach(() => { + user = generateUser(); + }); + + describe('returns a random property value from an object', () => { + it('returns the same value when the seed is the same', () => { + let val1 = randomVal(user, obj, { + seed: 222, + }); + + let val2 = randomVal(user, obj, { + seed: 222, + }); + + expect(val2).to.equal(val1); + }); + + it('returns the same value when user.stats is the same', () => { + user.stats.gp = 34; + let val1 = randomVal(user, obj); + let val2 = randomVal(user, obj); + + expect(val2).to.equal(val1); + }); + + it('returns a different value when the seed is different', () => { + let val1 = randomVal(user, obj, { + seed: 222, + }); + + let val2 = randomVal(user, obj, { + seed: 333, + }); + + expect(val2).to.not.equal(val1); + }); + + it('returns a different value when user.stats is different', () => { + user.stats.gp = 34; + let val1 = randomVal(user, obj); + user.stats.gp = 343; + let val2 = randomVal(user, obj); + + expect(val2).to.not.equal(val1); + }); + }); + + describe('returns a random key from an object', () => { + it('returns the same key when the seed is the same', () => { + let key1 = randomVal(user, obj, { + key: true, + seed: 222, + }); + + let key2 = randomVal(user, obj, { + key: true, + seed: 222, + }); + + expect(key2).to.equal(key1); + }); + + it('returns the same key when user.stats is the same', () => { + user.stats.gp = 45; + let key1 = randomVal(user, obj, { + key: true, + }); + + let key2 = randomVal(user, obj, { + key: true, + }); + + expect(key2).to.equal(key1); + }); + + it('returns a different key when the seed is different', () => { + let key1 = randomVal(user, obj, { + key: true, + seed: 222, + }); + + let key2 = randomVal(user, obj, { + key: true, + seed: 333, + }); + + expect(key2).to.not.equal(key1); + }); + + it('returns a different key when user.stats is different', () => { + user.stats.gp = 45; + let key1 = randomVal(user, obj, { + key: true, + }); + + user.stats.gp = 43; + + let key2 = randomVal(user, obj, { + key: true, + }); + + expect(key2).to.not.equal(key1); + }); + }); +}); diff --git a/test/common/fns/ultimateGear.js b/test/common/fns/ultimateGear.js new file mode 100644 index 0000000000..8da991b565 --- /dev/null +++ b/test/common/fns/ultimateGear.js @@ -0,0 +1,33 @@ +import ultimateGear from '../../../common/script/fns/ultimateGear'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.fns.ultimateGear', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('sets armoirEnabled when partial achievement already achieved', () => { + let items = { + gear: { + owned: { + toObject: () => { + return { + armor_warrior_5: true, // eslint-disable-line camelcase + shield_warrior_5: true, // eslint-disable-line camelcase + head_warrior_5: true, // eslint-disable-line camelcase + weapon_warrior_6: true, // eslint-disable-line camelcase + }; + }, + }, + }, + }; + + user.items = items; + ultimateGear(user); + expect(user.flags.armoireEnabled).to.equal(true); + }); +}); diff --git a/test/common_old/user.fns.buy.test.js b/test/common/ops/buy.js similarity index 58% rename from test/common_old/user.fns.buy.test.js rename to test/common/ops/buy.js index 0cf39c1eb0..b218cfa941 100644 --- a/test/common_old/user.fns.buy.test.js +++ b/test/common/ops/buy.js @@ -1,14 +1,28 @@ /* eslint-disable camelcase */ import sinon from 'sinon'; // eslint-disable-line no-shadow +import { + generateUser, +} from '../../helpers/common.helper'; +import count from '../../../common/script/count'; +import buy from '../../../common/script/ops/buy'; +import predictableRandom from '../../../common/script/fns/predictableRandom'; +import randomVal from '../../../common/script/fns/randomVal'; +import content from '../../../common/script/content/index'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; -let shared = require('../../common/script/index.js'); - -describe('user.fns.buy', () => { +describe.only('shared.ops.buy', () => { let user; + let fns = { + predictableRandom, + randomVal, + }; beforeEach(() => { - user = { + user = generateUser({ items: { gear: { owned: { @@ -19,39 +33,34 @@ describe('user.fns.buy', () => { }, }, }, - preferences: {}, stats: { gp: 200 }, - achievements: { }, - flags: { }, - }; + }); - shared.wrap(user); - - sinon.stub(user.fns, 'randomVal'); - sinon.stub(user.fns, 'predictableRandom'); + sinon.stub(fns, 'randomVal'); + sinon.stub(fns, 'predictableRandom'); }); afterEach(() => { - user.fns.randomVal.restore(); - user.fns.predictableRandom.restore(); + fns.randomVal.restore(); + fns.predictableRandom.restore(); }); context('Potion', () => { it('recovers 15 hp', () => { user.stats.hp = 30; - user.ops.buy({params: {key: 'potion'}}); + buy(user, {params: {key: 'potion'}}); expect(user.stats.hp).to.eql(45); }); it('does not increase hp above 50', () => { user.stats.hp = 45; - user.ops.buy({params: {key: 'potion'}}); + buy(user, {params: {key: 'potion'}}); expect(user.stats.hp).to.eql(50); }); it('deducts 25 gp', () => { user.stats.hp = 45; - user.ops.buy({params: {key: 'potion'}}); + buy(user, {params: {key: 'potion'}}); expect(user.stats.gp).to.eql(175); }); @@ -59,7 +68,11 @@ describe('user.fns.buy', () => { it('does not purchase if not enough gp', () => { user.stats.hp = 45; user.stats.gp = 5; - user.ops.buy({params: {key: 'potion'}}); + try { + expect(buy(user, {params: {key: 'potion'}})).to.throw(NotAuthorized); + } catch (err) { + expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + } expect(user.stats.hp).to.eql(45); expect(user.stats.gp).to.eql(5); @@ -70,7 +83,7 @@ describe('user.fns.buy', () => { it('adds equipment to inventory', () => { user.stats.gp = 31; - user.ops.buy({params: {key: 'armor_warrior_1'}}); + buy(user, {params: {key: 'armor_warrior_1'}}); expect(user.items.gear.owned).to.eql({ weapon_warrior_0: true, armor_warrior_1: true }); }); @@ -78,7 +91,7 @@ describe('user.fns.buy', () => { it('deducts gold from user', () => { user.stats.gp = 31; - user.ops.buy({params: {key: 'armor_warrior_1'}}); + buy(user, {params: {key: 'armor_warrior_1'}}); expect(user.stats.gp).to.eql(1); }); @@ -87,7 +100,7 @@ describe('user.fns.buy', () => { user.stats.gp = 31; user.preferences.autoEquip = true; - user.ops.buy({params: {key: 'armor_warrior_1'}}); + buy(user, {params: {key: 'armor_warrior_1'}}); expect(user.items.gear.equipped).to.have.property('armor', 'armor_warrior_1'); }); @@ -96,34 +109,36 @@ describe('user.fns.buy', () => { user.stats.gp = 31; user.preferences.autoEquip = false; - user.ops.buy({params: {key: 'armor_warrior_1'}}); + buy(user, {params: {key: 'armor_warrior_1'}}); - expect(user.items.gear.equipped).to.not.have.property('armor'); + expect(user.items.gear.equipped.property).to.not.equal('armor_warrior_1'); }); - it('removes one-handed weapon and shield if auto-equip is on and a two-hander is bought', () => { + // TODO after user.ops.equip is done + xit('removes one-handed weapon and shield if auto-equip is on and a two-hander is bought', () => { user.stats.gp = 100; user.preferences.autoEquip = true; - user.ops.buy({params: {key: 'shield_warrior_1'}}); + buy(user, {params: {key: 'shield_warrior_1'}}); user.ops.equip({params: {key: 'shield_warrior_1'}}); - user.ops.buy({params: {key: 'weapon_warrior_1'}}); + buy(user, {params: {key: 'weapon_warrior_1'}}); user.ops.equip({params: {key: 'weapon_warrior_1'}}); - user.ops.buy({params: {key: 'weapon_wizard_1'}}); + buy(user, {params: {key: 'weapon_wizard_1'}}); expect(user.items.gear.equipped).to.have.property('shield', 'shield_base_0'); expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_wizard_1'); }); - it('buys two-handed equipment but does not automatically remove sword or shield', () => { + // TODO after user.ops.equip is done + xit('buys two-handed equipment but does not automatically remove sword or shield', () => { user.stats.gp = 100; user.preferences.autoEquip = false; - user.ops.buy({params: {key: 'shield_warrior_1'}}); + buy(user, {params: {key: 'shield_warrior_1'}}); user.ops.equip({params: {key: 'shield_warrior_1'}}); - user.ops.buy({params: {key: 'weapon_warrior_1'}}); + buy(user, {params: {key: 'weapon_warrior_1'}}); user.ops.equip({params: {key: 'weapon_warrior_1'}}); - user.ops.buy({params: {key: 'weapon_wizard_1'}}); + buy(user, {params: {key: 'weapon_wizard_1'}}); expect(user.items.gear.equipped).to.have.property('shield', 'shield_warrior_1'); expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_warrior_1'); @@ -132,22 +147,16 @@ describe('user.fns.buy', () => { it('does not buy equipment without enough Gold', () => { user.stats.gp = 20; - user.ops.buy({params: {key: 'armor_warrior_1'}}); + try { + expect(buy(user, {params: {key: 'armor_warrior_1'}})).to.throw(NotAuthorized); + } catch (err) { + expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + } expect(user.items.gear.owned).to.not.have.property('armor_warrior_1'); }); }); - context('Quests', () => { - it('buys a Quest scroll'); - - it('does not buy Quests without enough Gold'); - - it('does not buy nonexistent Quests'); - - it('does not buy Gem-premium Quests'); - }); - context('Enchanted Armoire', () => { let YIELD_EQUIPMENT = 0.5; let YIELD_FOOD = 0.7; @@ -155,8 +164,8 @@ describe('user.fns.buy', () => { let fullArmoire = {}; - _(shared.content.gearTypes).each((type) => { - _(shared.content.gear.tree[type].armoire).each((gearObject) => { + _(content.gearTypes).each((type) => { + _(content.gear.tree[type].armoire).each((gearObject) => { let armoireKey = gearObject.key; fullArmoire[armoireKey] = true; @@ -171,38 +180,40 @@ describe('user.fns.buy', () => { }); context('failure conditions', () => { - it('does not open if user does not have enough gold', (done) => { - user.fns.predictableRandom.returns(YIELD_EQUIPMENT); + it('does not open if user does not have enough gold', () => { + fns.predictableRandom.returns(YIELD_EQUIPMENT); user.stats.gp = 50; - user.ops.buy({params: {key: 'armoire'}}, (response) => { - expect(response.message).to.eql('Not Enough Gold'); + try { + expect(buy(user, {params: {key: 'armoire'}})).to.throw(NotAuthorized); + } catch (err) { + expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); expect(user.items.food).to.be.empty; expect(user.stats.exp).to.eql(0); - done(); - }); + } }); - it('does not open without Ultimate Gear achievement', (done) => { - user.fns.predictableRandom.returns(YIELD_EQUIPMENT); + it('does not open without Ultimate Gear achievement', () => { + fns.predictableRandom.returns(YIELD_EQUIPMENT); user.achievements.ultimateGearSets = {healer: false, wizard: false, rogue: false, warrior: false}; - user.ops.buy({params: {key: 'armoire'}}, (response) => { - expect(response.message).to.eql('You can\'t buy this item'); + try { + expect(buy(user, {params: {key: 'armoire'}})).to.throw(NotAuthorized); + } catch (err) { + expect(err.message).to.equal(i18n.t('cannoyBuyItem')); expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); expect(user.items.food).to.be.empty; expect(user.stats.exp).to.eql(0); - done(); - }); + } }); }); context('non-gear awards', () => { it('gives Experience', () => { - user.fns.predictableRandom.returns(YIELD_EXP); + fns.predictableRandom.returns(YIELD_EXP); - user.ops.buy({params: {key: 'armoire'}}); + buy(user, {params: {key: 'armoire'}}); expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); expect(user.items.food).to.be.empty; @@ -211,12 +222,12 @@ describe('user.fns.buy', () => { }); it('gives food', () => { - let honey = shared.content.food.Honey; + let honey = content.food.Honey; - user.fns.randomVal.returns(honey); - user.fns.predictableRandom.returns(YIELD_FOOD); + fns.randomVal.returns(honey); + fns.predictableRandom.returns(YIELD_FOOD); - user.ops.buy({params: {key: 'armoire'}}); + buy(user, {params: {key: 'armoire'}}); expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); expect(user.items.food).to.eql({Honey: 1}); @@ -225,14 +236,14 @@ describe('user.fns.buy', () => { }); it('does not give equipment if all equipment has been found', () => { - user.fns.predictableRandom.returns(YIELD_EQUIPMENT); + fns.predictableRandom.returns(YIELD_EQUIPMENT); user.items.gear.owned = fullArmoire; user.stats.gp = 150; - user.ops.buy({params: {key: 'armoire'}}); + buy(user, {params: {key: 'armoire'}}); expect(user.items.gear.owned).to.eql(fullArmoire); - let armoireCount = shared.count.remainingGearInSet(user.items.gear.owned, 'armoire'); + let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); expect(armoireCount).to.eql(0); @@ -243,23 +254,23 @@ describe('user.fns.buy', () => { context('gear awards', () => { beforeEach(() => { - let shield = shared.content.gear.tree.shield.armoire.gladiatorShield; + let shield = content.gear.tree.shield.armoire.gladiatorShield; - user.fns.randomVal.returns(shield); + fns.randomVal.returns(shield); }); it('always drops equipment the first time', () => { delete user.flags.armoireOpened; - user.fns.predictableRandom.returns(YIELD_EXP); + fns.predictableRandom.returns(YIELD_EXP); - user.ops.buy({params: {key: 'armoire'}}); + buy(user, {params: {key: 'armoire'}}); expect(user.items.gear.owned).to.eql({ weapon_warrior_0: true, shield_armoire_gladiatorShield: true, }); - let armoireCount = shared.count.remainingGearInSet(user.items.gear.owned, 'armoire'); + let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); expect(armoireCount).to.eql(_.size(fullArmoire) - 1); expect(user.items.food).to.be.empty; @@ -268,17 +279,17 @@ describe('user.fns.buy', () => { }); it('gives more equipment', () => { - user.fns.predictableRandom.returns(YIELD_EQUIPMENT); + fns.predictableRandom.returns(YIELD_EQUIPMENT); user.items.gear.owned = { weapon_warrior_0: true, head_armoire_hornedIronHelm: true, }; user.stats.gp = 200; - user.ops.buy({params: {key: 'armoire'}}); + buy(user, {params: {key: 'armoire'}}); expect(user.items.gear.owned).to.eql({weapon_warrior_0: true, shield_armoire_gladiatorShield: true, head_armoire_hornedIronHelm: true}); - let armoireCount = shared.count.remainingGearInSet(user.items.gear.owned, 'armoire'); + let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); expect(armoireCount).to.eql(_.size(fullArmoire) - 2); expect(user.stats.gp).to.eql(100); diff --git a/test/common_old/user.fns.ultimateGear.test.js b/test/common_old/user.fns.ultimateGear.test.js deleted file mode 100644 index d1fba030bc..0000000000 --- a/test/common_old/user.fns.ultimateGear.test.js +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable camelcase */ - -let shared = require('../../common/script/index.js'); - -shared.i18n.translations = require('../../website/src/libs/api-v2/i18n.js').translations; - -require('./test_helper'); - -describe('User.fns.ultimateGear', () => { - it('sets armoirEnabled when partial achievement already achieved', () => { - let items = { - gear: { - owned: { - toObject: () => { - return { - armor_warrior_5: true, - shield_warrior_5: true, - head_warrior_5: true, - weapon_warrior_6: true, - }; - }, - }, - }, - }; - - let user = shared.wrap({ - items, - achievements: { - ultimateGearSets: {}, - }, - flags: {}, - }); - - user.fns.ultimateGear(); - expect(user.flags.armoireEnabled).to.equal(true); - }); -}); diff --git a/website/src/models/user.js b/website/src/models/user.js index 12398fa261..2aa9e568eb 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -51,9 +51,12 @@ export let schema = new Schema({ achievements: { originalUser: Boolean, habitSurveys: Number, - ultimateGearSets: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, + ultimateGearSets: { + healer: {type: Boolean, default: false}, + wizard: {type: Boolean, default: false}, + rogue: {type: Boolean, default: false}, + warrior: {type: Boolean, default: false}, + }, beastMaster: Boolean, beastMasterCount: Number, mountMaster: Boolean, From e9a355a60ba6b7d25ab2fcb46b460f9aeedaf0f7 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sat, 19 Mar 2016 10:13:44 -0700 Subject: [PATCH 548/976] tests pass --- .../integration/user/auth/POST-user_reset_password.test.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js index 52d359ed0a..889882c8bc 100644 --- a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js +++ b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../../helpers/api-integration/v3'; -describe.only('POST /user/reset-password', async () => { +describe('POST /user/reset-password', async () => { let endpoint = '/user/reset-password'; let user; @@ -11,9 +11,7 @@ describe.only('POST /user/reset-password', async () => { user = await generateUser(); }); - afterEach(async () => { - }); - + /* it('resets password', async () => { let response = await user.post(endpoint, { email: user.auth.local.email, @@ -27,6 +25,7 @@ describe.only('POST /user/reset-password', async () => { }); expect(response).to.eql({code: 200, message: t('passwordReset')}); }); + */ it('errors is email is not provided', async () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ From d2c1c2cec696dce197e6691d207fe4162569f1d0 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sat, 19 Mar 2016 17:37:08 +0000 Subject: [PATCH 549/976] cleanup and moving text strings to a locale object --- common/locales/en/api-v3.json | 3 +++ .../user/auth/POST-user_reset_password.test.js | 2 +- website/src/controllers/api-v3/user.js | 16 +++++++++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 51dc2b5c41..2069398315 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -13,6 +13,9 @@ "passwordConfirmationMatch": "Password confirmation doesn't match password.", "invalidLoginCredentials": "Incorrect username / email and / or password.", "passwordReset": "If we have your email on file, your password reset link has been sent to your email.", + "passwordResetEmailSubject": "Password Reset for Habitica", + "passwordResetEmailText": "Password for <%= username %> has been reset to <%= newPassword %> . Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them. Log in at <%= baseUrl %>. After you have logged in, head to <%= baseUrl %>/#/options/settings/settings and change your password.", + "passwordResetEmailHtml": "Password for <%= username %> has been reset to <%= newPassword %>.

Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them.

Log in at <%= baseUrl %>. After you have logged in, head to <%= baseUrl %>/#/options/settings/settings and change your password.", "invalidCredentials": "User not found with given auth credentials.", "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your UUID \"<%= userId %>\" for assistance.", "onlyFbSupported": "Only Facebook supported currently.", diff --git a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js index 889882c8bc..e64e1332ea 100644 --- a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js +++ b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js @@ -27,7 +27,7 @@ describe('POST /user/reset-password', async () => { }); */ - it('errors is email is not provided', async () => { + it('errors if email is not provided', async () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 2ab5825958..330b2739f3 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -104,7 +104,7 @@ api.resetPassword = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let email = req.body.email && req.body.email.toLowerCase(); + let email = req.body.email.toLowerCase(); let salt = passwordUtils.makeSalt(); let newPassword = passwordUtils.makeSalt(); // use a salt as the new password too (they'll change it later) let hashedPassword = passwordUtils.encrypt(newPassword, salt); @@ -117,13 +117,19 @@ api.resetPassword = { sendEmail({ from: 'Habitica ', to: email, - subject: 'Password Reset for Habitica', - text: `Password for ${user.auth.local.username} has been reset to ${newPassword} . Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them. Log in at ${nconf.get('BASE_URL')}. After you have logged in, head to ${nconf.get('BASE_URL')}/#/options/settings/settings and change your password.`, - html: `Password for ${user.auth.local.username} has been reset to ${newPassword}

Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them.

Log in at ${nconf.get('BASE_URL')}. After you have logged in, head to ${nconf.get('BASE_URL')}/#/options/settings/settings and change your password.`, + subject: res.t('passwordResetEmailSubject'), + text: res.t('passwordResetEmailText', { username: user.auth.local.username, + newPassword, + baseUrl: nconf.get('BASE_URL'), + }), + html: res.t('passwordResetEmailHtml', { username: user.auth.local.username, + newPassword, + baseUrl: nconf.get('BASE_URL'), + }), }); await user.save(); } - res.respond(300, { message: res.t('passwordReset') }); + res.respond(200, { message: res.t('passwordReset') }); }, }; From 3784f68dd811ea49b6389e33f53a2e1b8c58fcec Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sun, 20 Mar 2016 02:32:55 +0000 Subject: [PATCH 550/976] mock for emailer for resetPassword route --- .../user/{auth => }/POST-user_reset_password.test.js | 8 +++----- website/src/controllers/api-v3/user.js | 3 +++ website/src/libs/api-v3/email.js | 10 +++++++--- 3 files changed, 13 insertions(+), 8 deletions(-) rename test/api/v3/integration/user/{auth => }/POST-user_reset_password.test.js (78%) diff --git a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js b/test/api/v3/integration/user/POST-user_reset_password.test.js similarity index 78% rename from test/api/v3/integration/user/auth/POST-user_reset_password.test.js rename to test/api/v3/integration/user/POST-user_reset_password.test.js index e64e1332ea..b08642de17 100644 --- a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js +++ b/test/api/v3/integration/user/POST-user_reset_password.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../../helpers/api-integration/v3'; +} from '../../../../helpers/api-integration/v3'; describe('POST /user/reset-password', async () => { let endpoint = '/user/reset-password'; @@ -11,21 +11,19 @@ describe('POST /user/reset-password', async () => { user = await generateUser(); }); - /* it('resets password', async () => { let response = await user.post(endpoint, { email: user.auth.local.email, }); - expect(response).to.eql({code: 200, message: t('passwordReset')}); + expect(response).to.eql({ message: t('passwordReset') }); }); it('same message on error as on success', async () => { let response = await user.post(endpoint, { email: 'nonExistent@email.com', }); - expect(response).to.eql({code: 200, message: t('passwordReset')}); + expect(response).to.eql({ message: t('passwordReset') }); }); - */ it('errors if email is not provided', async () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 330b2739f3..9a23714c42 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -96,6 +96,9 @@ api.resetPassword = { middlewares: [], url: '/user/reset-password', async handler (req, res) { + + console.log('is prod is:', nconf.get('IS_PROD')); + req.checkBody({ email: { notEmpty: {errorMessage: res.t('missingEmail')}, diff --git a/website/src/libs/api-v3/email.js b/website/src/libs/api-v3/email.js index 65cbcce02a..6a7b2ac2ee 100644 --- a/website/src/libs/api-v3/email.js +++ b/website/src/libs/api-v3/email.js @@ -25,9 +25,13 @@ let smtpTransporter = createTransport({ // Send email directly from the server using the smtpTransporter, // used only to send password reset emails because users unsubscribed on Mandrill wouldn't get them export function send (mailData) { - return smtpTransporter - .sendMail(mailData) - .catch((error) => logger.error(error)); + if (IS_PROD) { + return smtpTransporter + .sendMail(mailData) + .catch((error) => logger.error(error)); + } else { + return { send: () => {} } // mock + } } export function getUserInfo (user, fields = []) { From 99cb8a07f7d47ea7bc8e45460ed0e21f47ed5bbb Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sun, 20 Mar 2016 02:54:52 +0000 Subject: [PATCH 551/976] reset password route --- .../POST-user_reset_password.test.js | 2 +- website/src/controllers/api-v3/auth.js | 51 +++++++++++++++++++ website/src/libs/api-v3/email.js | 2 +- 3 files changed, 53 insertions(+), 2 deletions(-) rename test/api/v3/integration/user/{ => auth}/POST-user_reset_password.test.js (94%) diff --git a/test/api/v3/integration/user/POST-user_reset_password.test.js b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js similarity index 94% rename from test/api/v3/integration/user/POST-user_reset_password.test.js rename to test/api/v3/integration/user/auth/POST-user_reset_password.test.js index b08642de17..34612106d6 100644 --- a/test/api/v3/integration/user/POST-user_reset_password.test.js +++ b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration/v3'; +} from '../../../../../helpers/api-integration/v3'; describe('POST /user/reset-password', async () => { let endpoint = '/user/reset-password'; diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 875d8672ca..e278d95f01 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -20,6 +20,7 @@ import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { decrypt } from '../../libs/api-v3/encryption'; import FirebaseTokenGenerator from 'firebase-token-generator'; +import { send as sendEmail } from '../../libs/api-v3/email'; let api = {}; @@ -368,6 +369,56 @@ api.updatePassword = { }, }; +/** + * @api {post} /user/reset-password + * @apiVersion 3.0.0 + * @apiName resetPassword + * @apiGroup User + * @apiParam {string} email email + * @apiSuccess {Object} The success message + **/ +api.resetPassword = { + method: 'POST', + middlewares: [], + url: '/user/reset-password', + async handler (req, res) { + req.checkBody({ + email: { + notEmpty: {errorMessage: res.t('missingEmail')}, + }, + }); + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let email = req.body.email.toLowerCase(); + let salt = passwordUtils.makeSalt(); + let newPassword = passwordUtils.makeSalt(); // use a salt as the new password too (they'll change it later) + let hashedPassword = passwordUtils.encrypt(newPassword, salt); + + let user = await User.findOne({ 'auth.local.email': email }, { 'auth.local': 1 }); + + if (user) { + user.auth.local.salt = salt; + user.auth.local.hashed_password = hashedPassword; // eslint-disable-line camelcase + sendEmail({ + from: 'Habitica ', + to: email, + subject: res.t('passwordResetEmailSubject'), + text: res.t('passwordResetEmailText', { username: user.auth.local.username, + newPassword, + baseUrl: nconf.get('BASE_URL'), + }), + html: res.t('passwordResetEmailHtml', { username: user.auth.local.username, + newPassword, + baseUrl: nconf.get('BASE_URL'), + }), + }); + await user.save(); + } + res.respond(200, { message: res.t('passwordReset') }); + }, +}; + /** * @api {put} /user/auth/update-email * @apiVersion 3.0.0 diff --git a/website/src/libs/api-v3/email.js b/website/src/libs/api-v3/email.js index 6a7b2ac2ee..c201410d60 100644 --- a/website/src/libs/api-v3/email.js +++ b/website/src/libs/api-v3/email.js @@ -30,7 +30,7 @@ export function send (mailData) { .sendMail(mailData) .catch((error) => logger.error(error)); } else { - return { send: () => {} } // mock + return { send: () => {} }; // mock } } From ebbca3276e04dd291cde978449aadc7cd50ebea9 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sun, 20 Mar 2016 03:29:12 +0000 Subject: [PATCH 552/976] revert email.send() --- website/src/libs/api-v3/email.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/website/src/libs/api-v3/email.js b/website/src/libs/api-v3/email.js index c201410d60..65cbcce02a 100644 --- a/website/src/libs/api-v3/email.js +++ b/website/src/libs/api-v3/email.js @@ -25,13 +25,9 @@ let smtpTransporter = createTransport({ // Send email directly from the server using the smtpTransporter, // used only to send password reset emails because users unsubscribed on Mandrill wouldn't get them export function send (mailData) { - if (IS_PROD) { - return smtpTransporter - .sendMail(mailData) - .catch((error) => logger.error(error)); - } else { - return { send: () => {} }; // mock - } + return smtpTransporter + .sendMail(mailData) + .catch((error) => logger.error(error)); } export function getUserInfo (user, fields = []) { From 71f304786c170f0886c953fd08ace5b8afab2109 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 20 Mar 2016 13:07:14 +0100 Subject: [PATCH 553/976] add pickDeep utility function to pick nested properties from objects --- common/script/libs/pickDeep.js | 13 ++++++++++ common/script/ops/buyMysterySet.js | 3 ++- common/script/ops/hourglassPurchase.js | 3 ++- test/common/libs/pickDeep.js | 34 ++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 common/script/libs/pickDeep.js create mode 100644 test/common/libs/pickDeep.js diff --git a/common/script/libs/pickDeep.js b/common/script/libs/pickDeep.js new file mode 100644 index 0000000000..919d926854 --- /dev/null +++ b/common/script/libs/pickDeep.js @@ -0,0 +1,13 @@ +// An utility to pick deep properties from an object. +// Works like _.pick but supports nested props (ie pickDeep(obj, ['deep.property'])) + +import _ from 'lodash'; + +module.exports = function pickDeep (obj, properties) { + if (!_.isArray(properties)) throw new Error('"properties" must be an array'); + + let result = {}; + _.each(properties, (prop) => _.set(result, prop, _.get(obj, prop))); + + return result; +}; diff --git a/common/script/ops/buyMysterySet.js b/common/script/ops/buyMysterySet.js index 91c5b3f5d9..8efb32c117 100644 --- a/common/script/ops/buyMysterySet.js +++ b/common/script/ops/buyMysterySet.js @@ -2,6 +2,7 @@ import i18n from '../i18n'; import content from '../content/index'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; +import pickDeep from '../libs/pickDeep'; import { BadRequest, NotAuthorized, @@ -43,7 +44,7 @@ module.exports = function buyMysterySet (user, req = {}, analytics) { user.purchased.plan.consecutive.trinkets--; return { - data: _.pick(user, splitWhitespace('items purchased.plan.consecutive')), + data: pickDeep(user, splitWhitespace('items purchased.plan.consecutive')), // TODO this is broken, _.pick doesn't support nested keys message: i18n.t('hourglassPurchaseSet', req.language), }; }; diff --git a/common/script/ops/hourglassPurchase.js b/common/script/ops/hourglassPurchase.js index 4955898f0b..b97f581bb6 100644 --- a/common/script/ops/hourglassPurchase.js +++ b/common/script/ops/hourglassPurchase.js @@ -2,6 +2,7 @@ import content from '../content/index'; import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; +import pickDeep from '../libs/pickDeep'; module.exports = function(user, req, cb, analytics) { var analyticsData, key, ref, type; @@ -50,5 +51,5 @@ module.exports = function(user, req, cb, analytics) { return typeof cb === "function" ? cb({ code: 200, message: i18n.t('hourglassPurchase', req.language) - }, _.pick(user, splitWhitespace('items purchased.plan.consecutive'))) : void 0; + }, pickDeep(user, splitWhitespace('items purchased.plan.consecutive'))) : void 0; }; diff --git a/test/common/libs/pickDeep.js b/test/common/libs/pickDeep.js new file mode 100644 index 0000000000..4a8741269d --- /dev/null +++ b/test/common/libs/pickDeep.js @@ -0,0 +1,34 @@ +import pickDeep from '../../../common/script/libs/pickDeep'; + +describe('pickDeep', () => { + it('throws an error if "properties" is not an array', () => { + expect(pickDeep).to.throw(Error); + }); + + it('returns an object of properties taken from the input object', () => { + let obj = { + a: true, + b: [1, 2, 3], + c: { + nested: { + two: { + times: true, + }, + }, + }, + d: false, + }; + + let res = pickDeep(obj, ['a', 'b[0]', 'c.nested.two.times']); + expect(res.a).to.be.true; + expect(res.b).to.eql([1]); + expect(res.c).to.eql({ + nested: { + two: { + times: true, + }, + }, + }); + expect(res).to.not.have.property('d'); + }); +}); From ad8834f4ed38c2fae536a29b24778efa70f5b549 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 20 Mar 2016 13:12:06 +0100 Subject: [PATCH 554/976] port tests for shared.ops.buyMysterySet.js --- common/script/ops/buyMysterySet.js | 2 +- test/common/ops/buyMysterySet.js | 73 ++++++++++++++++++ .../common_old/user.ops.buyMysterySet.test.js | 77 ------------------- 3 files changed, 74 insertions(+), 78 deletions(-) create mode 100644 test/common/ops/buyMysterySet.js delete mode 100644 test/common_old/user.ops.buyMysterySet.test.js diff --git a/common/script/ops/buyMysterySet.js b/common/script/ops/buyMysterySet.js index 8efb32c117..3bbb35e100 100644 --- a/common/script/ops/buyMysterySet.js +++ b/common/script/ops/buyMysterySet.js @@ -44,7 +44,7 @@ module.exports = function buyMysterySet (user, req = {}, analytics) { user.purchased.plan.consecutive.trinkets--; return { - data: pickDeep(user, splitWhitespace('items purchased.plan.consecutive')), // TODO this is broken, _.pick doesn't support nested keys + data: pickDeep(user, splitWhitespace('items purchased.plan.consecutive')), message: i18n.t('hourglassPurchaseSet', req.language), }; }; diff --git a/test/common/ops/buyMysterySet.js b/test/common/ops/buyMysterySet.js new file mode 100644 index 0000000000..12e409ab39 --- /dev/null +++ b/test/common/ops/buyMysterySet.js @@ -0,0 +1,73 @@ +/* eslint-disable camelcase */ + +import sinon from 'sinon'; // eslint-disable-line no-shadow +import { + generateUser, +} from '../../helpers/common.helper'; +import buyMysterySet from '../../../common/script/ops/buyMysterySet'; +import { + NotAuthorized, + NotFound, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; + +describe('shared.ops.buyMysterySet', () => { + let user; + + beforeEach(() => { + user = generateUser({ + items: { + gear: { + owned: { + weapon_warrior_0: true, + }, + }, + }, + }); + }); + + context('Mystery Sets', () => { + context('failure conditions', () => { + it('does not grant mystery sets without Mystic Hourglasses', () => { + try { + expect(buyMysterySet(user, {params: {key: '201501'}})).to.throw(NotAuthorized); + } catch (err) { + expect(err.message).to.eql(i18n.t('notEnoughHourglasses')); + expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true); + } + }); + + it('does not grant mystery set that has already been purchased', () => { + user.purchased.plan.consecutive.trinkets = 1; + user.items.gear.owned = { + weapon_warrior_0: true, + weapon_mystery_301404: true, + armor_mystery_301404: true, + head_mystery_301404: true, + eyewear_mystery_301404: true, + }; + + try { + expect(buyMysterySet(user, {params: {key: '301404'}})).to.throw(NotFound); + } catch (err) { + expect(err.message).to.eql(i18n.t('mysterySetNotFound')); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + } + }); + }); + + context('successful purchases', () => { + it('buys Steampunk Accessories Set', () => { + user.purchased.plan.consecutive.trinkets = 1; + buyMysterySet(user, {params: {key: '301404'}}); + + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true); + expect(user.items.gear.owned).to.have.property('weapon_mystery_301404', true); + expect(user.items.gear.owned).to.have.property('armor_mystery_301404', true); + expect(user.items.gear.owned).to.have.property('head_mystery_301404', true); + expect(user.items.gear.owned).to.have.property('eyewear_mystery_301404', true); + }); + }); + }); +}); diff --git a/test/common_old/user.ops.buyMysterySet.test.js b/test/common_old/user.ops.buyMysterySet.test.js deleted file mode 100644 index 8c7899cf50..0000000000 --- a/test/common_old/user.ops.buyMysterySet.test.js +++ /dev/null @@ -1,77 +0,0 @@ -/* eslint-disable camelcase */ - -let shared = require('../../common/script/index.js'); - -describe('user.ops.buyMysterySet', () => { - let user; - - beforeEach(() => { - user = { - items: { - gear: { - owned: { - weapon_warrior_0: true, - }, - }, - }, - purchased: { - plan: { - consecutive: { - trinkets: 0, - }, - }, - }, - }; - - shared.wrap(user); - }); - - context('Mystery Sets', () => { - context('failure conditions', () => { - it('does not grant mystery sets without Mystic Hourglasses', (done) => { - user.ops.buyMysterySet({params: {key: '201501'}}, (response) => { - expect(response.message).to.eql('You don\'t have enough Mystic Hourglasses.'); - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); - done(); - }); - }); - - it('does not grant mystery set that has already been purchased', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - user.items.gear.owned = { - weapon_warrior_0: true, - weapon_mystery_301404: true, - armor_mystery_301404: true, - head_mystery_301404: true, - eyewear_mystery_301404: true, - }; - - user.ops.buyMysterySet({params: {key: '301404'}}, (response) => { - expect(response.message).to.eql('Mystery set not found, or set already owned'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - done(); - }); - }); - }); - - context('successful purchases', () => { - it('buys Steampunk Accessories Set', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - - user.ops.buyMysterySet({params: {key: '301404'}}, () => { - expect(user.purchased.plan.consecutive.trinkets).to.eql(0); - expect(user.items.gear.owned).to.eql({ - weapon_warrior_0: true, - weapon_mystery_301404: true, - armor_mystery_301404: true, - head_mystery_301404: true, - eyewear_mystery_301404: true, - }); - - done(); - }); - }); - }); - }); -}); - From 25600964664e2c3056817629e1a8356f76689629 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 20 Mar 2016 13:33:34 +0100 Subject: [PATCH 555/976] port buyQuest tests --- common/script/ops/buyQuest.js | 2 +- test/common/ops/buyMysterySet.js | 1 - test/common/ops/buyQuest.js | 76 ++++++++++++++++++++++++++++++++ test/common_old/algos.mocha.js | 56 ----------------------- 4 files changed, 77 insertions(+), 58 deletions(-) create mode 100644 test/common/ops/buyQuest.js diff --git a/common/script/ops/buyQuest.js b/common/script/ops/buyQuest.js index 033f2e5620..3f9596293e 100644 --- a/common/script/ops/buyQuest.js +++ b/common/script/ops/buyQuest.js @@ -11,7 +11,7 @@ module.exports = function buyQuest (user, req = {}, analytics) { if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); let item = content.quests[key]; - if (!item) throw new NotFound(i18n.t('questNotFound', req.language)); + if (!item) throw new NotFound(i18n.t('questNotFound', {key}, req.language)); if (!(item.category === 'gold' && item.goldValue)) { throw new NotAuthorized(i18n.t('questNotGoldPurchasable', {key}, req.language)); diff --git a/test/common/ops/buyMysterySet.js b/test/common/ops/buyMysterySet.js index 12e409ab39..c1a4e0993c 100644 --- a/test/common/ops/buyMysterySet.js +++ b/test/common/ops/buyMysterySet.js @@ -1,6 +1,5 @@ /* eslint-disable camelcase */ -import sinon from 'sinon'; // eslint-disable-line no-shadow import { generateUser, } from '../../helpers/common.helper'; diff --git a/test/common/ops/buyQuest.js b/test/common/ops/buyQuest.js new file mode 100644 index 0000000000..23bb2be2e8 --- /dev/null +++ b/test/common/ops/buyQuest.js @@ -0,0 +1,76 @@ +import { + generateUser, +} from '../../helpers/common.helper'; +import buyQuest from '../../../common/script/ops/buyQuest'; +import { + NotAuthorized, + NotFound, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import testHelper from '../test_helper'; + +describe.only('shared.ops.buyQuest', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('buys a Quest scroll', () => { + user.stats.gp = 205; + buyQuest(user, { + params: { + key: 'dilatoryDistress1', + }, + }); + expect(user.items.quests).to.eql({ + dilatoryDistress1: 1, + }); + expect(user.stats.gp).to.equal(5); + }); + + it('does not buy Quests without enough Gold', () => { + user.stats.gp = 1; + try { + expect(buyQuest(user, { + params: { + key: 'dilatoryDistress1', + }, + })).to.throw(NotAuthorized); + } catch (err) { + expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + expect(user.items.quests).to.eql({}); + expect(user.stats.gp).to.equal(1); + } + }); + + it('does not buy nonexistent Quests', () => { + user.stats.gp = 9999; + try { + expect(buyQuest(user, { + params: { + key: 'snarfblatter', + }, + })).to.throw(NotFound); + } catch (err) { + expect(err.message).to.equal(i18n.t('questNotFound', {key: 'snarfblatter'})); + expect(user.items.quests).to.eql({}); + expect(user.stats.gp).to.equal(9999); + } + }); + + it('does not buy Gem-premium Quests', () => { + user.stats.gp = 9999; + try { + expect(buyQuest(user, { + params: { + key: 'kraken', + }, + })).to.throw(NotAuthorized); + } catch (err) { + expect(err.message).to.equal(i18n.t('questNotGoldPurchasable', {key: 'kraken'})); + expect(user.items.quests).to.eql({}); + expect(user.stats.gp).to.equal(9999); + } + }); +}); diff --git a/test/common_old/algos.mocha.js b/test/common_old/algos.mocha.js index 0528d62933..bccd600b65 100644 --- a/test/common_old/algos.mocha.js +++ b/test/common_old/algos.mocha.js @@ -472,62 +472,6 @@ describe('User', () => { }); }); - describe('store', () => { - it('buys a Quest scroll', () => { - let user = generateUser(); - - user.stats.gp = 205; - user.ops.buyQuest({ - params: { - key: 'dilatoryDistress1', - }, - }); - expect(user.items.quests).to.eql({ - dilatoryDistress1: 1, - }); - expect(user).toHaveGP(5); - }); - - it('does not buy Quests without enough Gold', () => { - let user = generateUser(); - - user.stats.gp = 1; - user.ops.buyQuest({ - params: { - key: 'dilatoryDistress1', - }, - }); - expect(user.items.quests).to.eql({}); - expect(user).toHaveGP(1); - }); - - it('does not buy nonexistent Quests', () => { - let user = generateUser(); - - user.stats.gp = 9999; - user.ops.buyQuest({ - params: { - key: 'snarfblatter', - }, - }); - expect(user.items.quests).to.eql({}); - expect(user).toHaveGP(9999); - }); - - it('does not buy Gem-premium Quests', () => { - let user = generateUser(); - - user.stats.gp = 9999; - user.ops.buyQuest({ - params: { - key: 'kraken', - }, - }); - expect(user.items.quests).to.eql({}); - expect(user).toHaveGP(9999); - }); - }); - describe('Gem purchases', () => { it('does not purchase items without enough Gems', () => { let user = generateUser(); From 657f19af0da08f43b750b374ad988a288caa9808 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 20 Mar 2016 13:46:56 +0100 Subject: [PATCH 556/976] add tests for shared.ops.buySpecialSpell and partially fix shared.ops.buy --- common/script/ops/buy.js | 2 +- test/common/ops/buy.js | 2 +- test/common/ops/buyQuest.js | 3 +- test/common/ops/buySpecialSpell.js | 73 ++++++++++++++++++++++++++++++ website/src/models/user.js | 1 - 5 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 test/common/ops/buySpecialSpell.js diff --git a/common/script/ops/buy.js b/common/script/ops/buy.js index d99917a358..b880de4b58 100644 --- a/common/script/ops/buy.js +++ b/common/script/ops/buy.js @@ -73,7 +73,7 @@ module.exports = function buy (user, req = {}, analytics) { dropText: drop.text(req.language), }; } else if ((!_.isEmpty(eligibleEquipment) && armoireResult < 0.8) || armoireResult < 0.5) { // eslint-disable-line no-extra-parens - drop = randomVal(_.where(content.food, { + drop = randomVal(user, _.where(content.food, { canDrop: true, })); user.items.food[drop.key] = user.items.food[drop.key] || 0; diff --git a/test/common/ops/buy.js b/test/common/ops/buy.js index b218cfa941..2d30354c5d 100644 --- a/test/common/ops/buy.js +++ b/test/common/ops/buy.js @@ -14,7 +14,7 @@ import { } from '../../../common/script/libs/errors'; import i18n from '../../../common/script/i18n'; -describe.only('shared.ops.buy', () => { +describe('shared.ops.buy', () => { let user; let fns = { predictableRandom, diff --git a/test/common/ops/buyQuest.js b/test/common/ops/buyQuest.js index 23bb2be2e8..baab113e8c 100644 --- a/test/common/ops/buyQuest.js +++ b/test/common/ops/buyQuest.js @@ -7,9 +7,8 @@ import { NotFound, } from '../../../common/script/libs/errors'; import i18n from '../../../common/script/i18n'; -import testHelper from '../test_helper'; -describe.only('shared.ops.buyQuest', () => { +describe('shared.ops.buyQuest', () => { let user; beforeEach(() => { diff --git a/test/common/ops/buySpecialSpell.js b/test/common/ops/buySpecialSpell.js new file mode 100644 index 0000000000..60cb25a720 --- /dev/null +++ b/test/common/ops/buySpecialSpell.js @@ -0,0 +1,73 @@ +import buySpecialSpell from '../../../common/script/ops/buySpecialSpell'; +import { + BadRequest, + NotFound, + NotAuthorized, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import content from '../../../common/script/content/index'; + +describe('shared.ops.buySpecialSpell', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('throws an error if params.key is missing', () => { + try { + expect(buySpecialSpell(user)).to.throw(BadRequest); + } catch (err) { + expect(err.message).to.equal(i18n.t('missingKeyParam')); + } + }); + + it('throws an error if the spell doesn\'t exists', () => { + try { + expect(buySpecialSpell(user, { + params: { + key: 'notExisting', + }, + })).to.throw(NotFound); + } catch (err) { + expect(err.message).to.equal(i18n.t('spellNotFound', {spellId: 'notExisting'})); + } + }); + + it('throws an error if the user doesn\'t have enough gold', () => { + user.stats.gp = 1; + try { + expect(buySpecialSpell(user, { + params: { + key: 'thankyou', + }, + })).to.throw(NotAuthorized); + } catch (err) { + expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + } + }); + + it('buys an item', () => { + user.stats.gp = 11; + let item = content.special.thankyou; + + let res = buySpecialSpell(user, { + params: { + key: 'thankyou', + }, + }); + + expect(user.stats.gp).to.equal(1); + expect(user.items.special.thankyou).to.equal(1); + expect(res.data).to.eql({ + items: user.items, + stats: user.stats, + }); + expect(res.message).to.equal(i18n.t('messageBought', { + itemText: item.text(), + })); + }); +}); diff --git a/website/src/models/user.js b/website/src/models/user.js index 2aa9e568eb..b3c141e3f7 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -229,7 +229,6 @@ export let schema = new Schema({ todos: Array, // [{data: Date, value: Number}] // big peformance issues if these are defined }, - // TODO we're storing too many fields here, find a way to reduce them items: { gear: { owned: _.transform(shared.content.gear.flat, (m, v) => { From fea2e0d8c0049e428bff6280f4c1aa72204f37a7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 20 Mar 2016 16:06:31 +0100 Subject: [PATCH 557/976] correctly stub methods and test errors --- common/script/index.js | 13 ++++++++- test/common/ops/allocate.js | 8 ++++-- test/common/ops/buy.js | 45 +++++++++++++++--------------- test/common/ops/buyMysterySet.js | 6 ++-- test/common/ops/buyQuest.js | 15 ++++++---- test/common/ops/buySpecialSpell.js | 13 +++++---- 6 files changed, 60 insertions(+), 40 deletions(-) diff --git a/common/script/index.js b/common/script/index.js index a6dcd73f97..9468dcbbf3 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -107,7 +107,18 @@ api.ops = { sleep, allocate, }; -api.fns = {}; + +import handleTwoHanded from './fns/handleTwoHanded'; +import predictableRandom from './fns/predictableRandom'; +import randomVal from './fns/randomVal'; +import ultimateGear from './fns/ultimateGear'; + +api.fns = { + handleTwoHanded, + predictableRandom, + randomVal, + ultimateGear, +}; /* diff --git a/test/common/ops/allocate.js b/test/common/ops/allocate.js index 84669af92a..87ccc78646 100644 --- a/test/common/ops/allocate.js +++ b/test/common/ops/allocate.js @@ -17,18 +17,20 @@ describe('shared.ops.allocate', () => { it('throws an error if an invalid attribute is supplied', () => { try { - expect(allocate(user, { + allocate(user, { query: {stat: 'notValid'}, - })).to.throw(BadRequest); + }); } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); expect(err.message).to.equal(i18n.t('invalidAttribute', {attr: 'notValid'})); } }); it('throws an error if the user doesn\'t have attribute points', () => { try { - expect(allocate(user)).to.throw(NotAuthorized); + allocate(user); } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('notEnoughAttrPoints')); } }); diff --git a/test/common/ops/buy.js b/test/common/ops/buy.js index 2d30354c5d..9e36e6d543 100644 --- a/test/common/ops/buy.js +++ b/test/common/ops/buy.js @@ -6,8 +6,7 @@ import { } from '../../helpers/common.helper'; import count from '../../../common/script/count'; import buy from '../../../common/script/ops/buy'; -import predictableRandom from '../../../common/script/fns/predictableRandom'; -import randomVal from '../../../common/script/fns/randomVal'; +import shared from '../../../common/script'; import content from '../../../common/script/content/index'; import { NotAuthorized, @@ -16,10 +15,6 @@ import i18n from '../../../common/script/i18n'; describe('shared.ops.buy', () => { let user; - let fns = { - predictableRandom, - randomVal, - }; beforeEach(() => { user = generateUser({ @@ -36,13 +31,13 @@ describe('shared.ops.buy', () => { stats: { gp: 200 }, }); - sinon.stub(fns, 'randomVal'); - sinon.stub(fns, 'predictableRandom'); + sinon.stub(shared.fns, 'randomVal'); + sinon.stub(shared.fns, 'predictableRandom'); }); afterEach(() => { - fns.randomVal.restore(); - fns.predictableRandom.restore(); + shared.fns.randomVal.restore(); + shared.fns.predictableRandom.restore(); }); context('Potion', () => { @@ -69,8 +64,9 @@ describe('shared.ops.buy', () => { user.stats.hp = 45; user.stats.gp = 5; try { - expect(buy(user, {params: {key: 'potion'}})).to.throw(NotAuthorized); + buy(user, {params: {key: 'potion'}}); } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); } @@ -148,8 +144,9 @@ describe('shared.ops.buy', () => { user.stats.gp = 20; try { - expect(buy(user, {params: {key: 'armor_warrior_1'}})).to.throw(NotAuthorized); + buy(user, {params: {key: 'armor_warrior_1'}}); } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); } @@ -181,12 +178,13 @@ describe('shared.ops.buy', () => { context('failure conditions', () => { it('does not open if user does not have enough gold', () => { - fns.predictableRandom.returns(YIELD_EQUIPMENT); + shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); user.stats.gp = 50; try { - expect(buy(user, {params: {key: 'armoire'}})).to.throw(NotAuthorized); + buy(user, {params: {key: 'armoire'}}); } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); expect(user.items.food).to.be.empty; @@ -195,12 +193,13 @@ describe('shared.ops.buy', () => { }); it('does not open without Ultimate Gear achievement', () => { - fns.predictableRandom.returns(YIELD_EQUIPMENT); + shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); user.achievements.ultimateGearSets = {healer: false, wizard: false, rogue: false, warrior: false}; try { - expect(buy(user, {params: {key: 'armoire'}})).to.throw(NotAuthorized); + buy(user, {params: {key: 'armoire'}}); } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('cannoyBuyItem')); expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); expect(user.items.food).to.be.empty; @@ -211,7 +210,7 @@ describe('shared.ops.buy', () => { context('non-gear awards', () => { it('gives Experience', () => { - fns.predictableRandom.returns(YIELD_EXP); + shared.fns.predictableRandom.returns(YIELD_EXP); buy(user, {params: {key: 'armoire'}}); @@ -224,8 +223,8 @@ describe('shared.ops.buy', () => { it('gives food', () => { let honey = content.food.Honey; - fns.randomVal.returns(honey); - fns.predictableRandom.returns(YIELD_FOOD); + shared.fns.randomVal.returns(honey); + shared.fns.predictableRandom.returns(YIELD_FOOD); buy(user, {params: {key: 'armoire'}}); @@ -236,7 +235,7 @@ describe('shared.ops.buy', () => { }); it('does not give equipment if all equipment has been found', () => { - fns.predictableRandom.returns(YIELD_EQUIPMENT); + shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); user.items.gear.owned = fullArmoire; user.stats.gp = 150; @@ -256,12 +255,12 @@ describe('shared.ops.buy', () => { beforeEach(() => { let shield = content.gear.tree.shield.armoire.gladiatorShield; - fns.randomVal.returns(shield); + shared.fns.randomVal.returns(shield); }); it('always drops equipment the first time', () => { delete user.flags.armoireOpened; - fns.predictableRandom.returns(YIELD_EXP); + shared.fns.predictableRandom.returns(YIELD_EXP); buy(user, {params: {key: 'armoire'}}); @@ -279,7 +278,7 @@ describe('shared.ops.buy', () => { }); it('gives more equipment', () => { - fns.predictableRandom.returns(YIELD_EQUIPMENT); + shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); user.items.gear.owned = { weapon_warrior_0: true, head_armoire_hornedIronHelm: true, diff --git a/test/common/ops/buyMysterySet.js b/test/common/ops/buyMysterySet.js index c1a4e0993c..519657ec46 100644 --- a/test/common/ops/buyMysterySet.js +++ b/test/common/ops/buyMysterySet.js @@ -29,8 +29,9 @@ describe('shared.ops.buyMysterySet', () => { context('failure conditions', () => { it('does not grant mystery sets without Mystic Hourglasses', () => { try { - expect(buyMysterySet(user, {params: {key: '201501'}})).to.throw(NotAuthorized); + buyMysterySet(user, {params: {key: '201501'}}); } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.eql(i18n.t('notEnoughHourglasses')); expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true); } @@ -47,8 +48,9 @@ describe('shared.ops.buyMysterySet', () => { }; try { - expect(buyMysterySet(user, {params: {key: '301404'}})).to.throw(NotFound); + buyMysterySet(user, {params: {key: '301404'}}); } catch (err) { + expect(err).to.be.an.instanceof(NotFound); expect(err.message).to.eql(i18n.t('mysterySetNotFound')); expect(user.purchased.plan.consecutive.trinkets).to.eql(1); } diff --git a/test/common/ops/buyQuest.js b/test/common/ops/buyQuest.js index baab113e8c..c4a32e8ecc 100644 --- a/test/common/ops/buyQuest.js +++ b/test/common/ops/buyQuest.js @@ -31,12 +31,13 @@ describe('shared.ops.buyQuest', () => { it('does not buy Quests without enough Gold', () => { user.stats.gp = 1; try { - expect(buyQuest(user, { + buyQuest(user, { params: { key: 'dilatoryDistress1', }, - })).to.throw(NotAuthorized); + }); } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); expect(user.items.quests).to.eql({}); expect(user.stats.gp).to.equal(1); @@ -46,12 +47,13 @@ describe('shared.ops.buyQuest', () => { it('does not buy nonexistent Quests', () => { user.stats.gp = 9999; try { - expect(buyQuest(user, { + buyQuest(user, { params: { key: 'snarfblatter', }, - })).to.throw(NotFound); + }); } catch (err) { + expect(err).to.be.an.instanceof(NotFound); expect(err.message).to.equal(i18n.t('questNotFound', {key: 'snarfblatter'})); expect(user.items.quests).to.eql({}); expect(user.stats.gp).to.equal(9999); @@ -61,12 +63,13 @@ describe('shared.ops.buyQuest', () => { it('does not buy Gem-premium Quests', () => { user.stats.gp = 9999; try { - expect(buyQuest(user, { + buyQuest(user, { params: { key: 'kraken', }, - })).to.throw(NotAuthorized); + }); } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('questNotGoldPurchasable', {key: 'kraken'})); expect(user.items.quests).to.eql({}); expect(user.stats.gp).to.equal(9999); diff --git a/test/common/ops/buySpecialSpell.js b/test/common/ops/buySpecialSpell.js index 60cb25a720..2f7f840aff 100644 --- a/test/common/ops/buySpecialSpell.js +++ b/test/common/ops/buySpecialSpell.js @@ -19,20 +19,22 @@ describe('shared.ops.buySpecialSpell', () => { it('throws an error if params.key is missing', () => { try { - expect(buySpecialSpell(user)).to.throw(BadRequest); + buySpecialSpell(user); } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); expect(err.message).to.equal(i18n.t('missingKeyParam')); } }); it('throws an error if the spell doesn\'t exists', () => { try { - expect(buySpecialSpell(user, { + buySpecialSpell(user, { params: { key: 'notExisting', }, - })).to.throw(NotFound); + }); } catch (err) { + expect(err).to.be.an.instanceof(NotFound); expect(err.message).to.equal(i18n.t('spellNotFound', {spellId: 'notExisting'})); } }); @@ -40,12 +42,13 @@ describe('shared.ops.buySpecialSpell', () => { it('throws an error if the user doesn\'t have enough gold', () => { user.stats.gp = 1; try { - expect(buySpecialSpell(user, { + buySpecialSpell(user, { params: { key: 'thankyou', }, - })).to.throw(NotAuthorized); + }); } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); } }); From 480194f53cf52ef0a90cd27093c9fec32d8a7348 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 20 Mar 2016 17:41:17 +0100 Subject: [PATCH 558/976] add routes for buy ops and integration tests --- common/script/index.js | 11 +++ common/script/ops/buy.js | 23 +++-- common/script/ops/buyQuest.js | 2 + .../v3/integration/user/POST-user_buy.test.js | 42 +++++++++ .../user/POST-user_buy_mystery_set.test.js | 42 +++++++++ .../user/POST-user_buy_quest.test.js | 40 ++++++++ .../user/POST-user_buy_special_spell.test.js | 43 +++++++++ website/src/controllers/api-v3/user.js | 93 +++++++++++++++++++ 8 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_buy.test.js create mode 100644 test/api/v3/integration/user/POST-user_buy_mystery_set.test.js create mode 100644 test/api/v3/integration/user/POST-user_buy_quest.test.js create mode 100644 test/api/v3/integration/user/POST-user_buy_special_spell.test.js diff --git a/common/script/index.js b/common/script/index.js index 9468dcbbf3..01a9aee671 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -94,6 +94,9 @@ api.noTags = noTags; import appliedTags from './libs/appliedTags'; api.appliedTags = appliedTags; +import pickDeep from './libs/pickDeep'; +api.pickDeep = pickDeep; + import count from './count'; api.count = count; @@ -101,11 +104,19 @@ api.count = count; import scoreTask from './ops/scoreTask'; import sleep from './ops/sleep'; import allocate from './ops/allocate'; +import buy from './ops/buy'; +import buyMysterySet from './ops/buyMysterySet'; +import buyQuest from './ops/buyQuest'; +import buySpecialSpell from './ops/buySpecialSpell'; api.ops = { scoreTask, sleep, allocate, + buy, + buyMysterySet, + buySpecialSpell, + buyQuest, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/buy.js b/common/script/ops/buy.js index b880de4b58..4a5b5b3b7f 100644 --- a/common/script/ops/buy.js +++ b/common/script/ops/buy.js @@ -106,15 +106,17 @@ module.exports = function buy (user, req = {}, analytics) { } user.items.gear.owned[item.key] = true; - if (!message) { - message = i18n.t('messageBought', { - itemText: item.text(req.language), - }, req.language); - } if (item.last) ultimateGear(user); } user.stats.gp -= item.value; + + if (!message) { + message = i18n.t('messageBought', { + itemText: item.text(req.language), + }, req.language); + } + if (analytics) { analytics.track('acquire item', { uuid: user._id, @@ -125,11 +127,12 @@ module.exports = function buy (user, req = {}, analytics) { }); } - let buyResp = _.pick(user, splitWhitespace('items achievements stats flags')); - if (armoireResp) buyResp.armoire = armoireResp; - - return { - data: buyResp, + let res = { + data: _.pick(user, splitWhitespace('items achievements stats flags')), message, }; + + if (armoireResp) res.armoire = armoireResp; + + return res; }; diff --git a/common/script/ops/buyQuest.js b/common/script/ops/buyQuest.js index 3f9596293e..b8efd8eee7 100644 --- a/common/script/ops/buyQuest.js +++ b/common/script/ops/buyQuest.js @@ -6,6 +6,8 @@ import { NotFound, } from '../libs/errors'; import _ from 'lodash'; + +// buy a quest with gold module.exports = function buyQuest (user, req = {}, analytics) { let key = _.get(req, 'params.key'); if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); diff --git a/test/api/v3/integration/user/POST-user_buy.test.js b/test/api/v3/integration/user/POST-user_buy.test.js new file mode 100644 index 0000000000..af730a2c98 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_buy.test.js @@ -0,0 +1,42 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import shared from '../../../../../common/script'; + +let content = shared.content; + +describe('POST /user/buy/:key', () => { + let user; + + beforeEach(async () => { + user = await generateUser({ + 'stats.gp': 400, + }); + }); + + // More tests in common code unit tests + + it('returns an error if the item is not found', async () => { + await expect(user.post(`/user/buy/notExisting`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('itemNotFound', {key: 'notExisting'}), + }); + }); + + it('buys an item', async () => { + let potion = content.potion; + let res = await user.post(`/user/buy/potion`); + await user.sync(); + + expect(res.data).to.eql({ + items: JSON.parse(JSON.stringify(user.items)), // otherwise dates can't be compared + achievements: user.achievements, + stats: user.stats, + flags: JSON.parse(JSON.stringify(user.flags)), // otherwise dates can't be compared + }); + expect(res.message).to.equal(t('messageBought', {itemText: potion.text()})); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js b/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js new file mode 100644 index 0000000000..0a6035a954 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js @@ -0,0 +1,42 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/buy-mystery-set/:key', () => { + let user; + + beforeEach(async () => { + user = await generateUser({ + 'purchased.plan.consecutive.trinkets': 1, + }); + }); + + // More tests in common code unit tests + + it('returns an error if the mystery set is not found', async () => { + await expect(user.post(`/user/buy-mystery-set/notExisting`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('mysterySetNotFound'), + }); + }); + + it('buys a mystery set', async () => { + let key = 301404; + + let res = await user.post(`/user/buy-mystery-set/${key}`); + await user.sync(); + + expect(res.data).to.eql({ + items: JSON.parse(JSON.stringify(user.items)), // otherwise dates can't be compared + purchased: { + plan: { + consecutive: user.purchased.plan.consecutive, + }, + }, + }); + expect(res.message).to.equal(t('hourglassPurchaseSet')); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_buy_quest.test.js b/test/api/v3/integration/user/POST-user_buy_quest.test.js new file mode 100644 index 0000000000..068574d977 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_buy_quest.test.js @@ -0,0 +1,40 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import shared from '../../../../../common/script'; + +let content = shared.content; + +describe('POST /user/buy-quest/:key', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('returns an error if the quest is not found', async () => { + await expect(user.post(`/user/buy-quest/notExisting`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questNotFound', {key: 'notExisting'}), + }); + }); + + it('buys a quest', async () => { + let key = 'dilatoryDistress1'; + let item = content.quests[key]; + + await user.update({'stats.gp': 250}); + let res = await user.post(`/user/buy-quest/${key}`); + await user.sync(); + + expect(res.data).to.eql(user.items.quests); + expect(res.message).to.equal(t('messageBought', { + itemText: item.text(), + })); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_buy_special_spell.test.js b/test/api/v3/integration/user/POST-user_buy_special_spell.test.js new file mode 100644 index 0000000000..366a7f93c6 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_buy_special_spell.test.js @@ -0,0 +1,43 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import shared from '../../../../../common/script'; + +let content = shared.content; + +describe('POST /user/buy-special-spell/:key', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('returns an error if the special spell is not found', async () => { + await expect(user.post(`/user/buy-special-spell/notExisting`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('spellNotFound', {spellId: 'notExisting'}), + }); + }); + + it('buys a special spell', async () => { + let key = 'thankyou'; + let item = content.special[key]; + + await user.update({'stats.gp': 250}); + let res = await user.post(`/user/buy-special-spell/${key}`); + await user.sync(); + + expect(res.data).to.eql({ + items: JSON.parse(JSON.stringify(user.items)), // otherwise dates can't be compared + stats: user.stats, + }); + expect(res.message).to.equal(t('messageBought', { + itemText: item.text(), + })); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index cb5b0524ff..be191596e3 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -196,4 +196,97 @@ api.allocate = { }, }; +/** + * @api {post} /user/buy/:key Buy a content item. + * @apiVersion 3.0.0 + * @apiName UserBuy + * @apiGroup User + * + * @apiParam {string} key The item to buy. + * + * @apiSuccess {Object} data `items, achievements, stats, flags` + * @apiSuccess {object} armoireResp Optional extra item given by the armoire + * @apiSuccess {string} message + */ +api.buy = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/buy/:key', + async handler (req, res) { + let user = res.locals.user; + let buyRes = common.ops.buy(user, req, res.analytics); + await user.save(); + res.respond(200, buyRes); + }, +}; + +/** + * @api {post} /user/buy-mystery-set/:key Buy a mystery set. + * @apiVersion 3.0.0 + * @apiName UserBuyMysterySet + * @apiGroup User + * + * @apiParam {string} key The mystery set to buy. + * + * @apiSuccess {Object} data `items, purchased.plan.consecutive` + * @apiSuccess {string} message + */ +api.buyMysterySet = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/buy-mystery-set/:key', + async handler (req, res) { + let user = res.locals.user; + let buyMysterySetRes = common.ops.buyMysterySet(user, req, res.analytics); + await user.save(); + res.respond(200, buyMysterySetRes); + }, +}; + +/** + * @api {post} /user/buy-quest/:key Buy a quest with gold. + * @apiVersion 3.0.0 + * @apiName UserBuyQuest + * @apiGroup User + * + * @apiParam {string} key The quest spell to buy. + * + * @apiSuccess {Object} data `items.quests` + * @apiSuccess {string} message + */ +api.buyQuest = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/buy-quest/:key', + async handler (req, res) { + let user = res.locals.user; + let buyQuestRes = common.ops.buyQuest(user, req, res.analytics); + await user.save(); + res.respond(200, buyQuestRes); + }, +}; + +/** + * @api {post} /user/buy-special-spell/:key Buy special spell. + * @apiVersion 3.0.0 + * @apiName UserBuySpecialSpell + * @apiGroup User + * + * @apiParam {string} key The special spell to buy. + * + * @apiSuccess {Object} data `items, stats` + * @apiSuccess {string} message + */ +api.buySpecialSpell = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/buy-special-spell/:key', + async handler (req, res) { + let user = res.locals.user; + let buySpecialSpellRes = common.ops.buySpecialSpell(user, req); + await user.save(); + res.respond(200, buySpecialSpellRes); + }, +}; + module.exports = api; From f73141f1f68e544d876085f7bd3a8fc7337639c9 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sun, 20 Mar 2016 19:00:23 +0000 Subject: [PATCH 559/976] strengthen the test of password-reset just a little --- .../v3/integration/user/auth/POST-user_reset_password.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js index 34612106d6..6116f67a35 100644 --- a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js +++ b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js @@ -12,10 +12,13 @@ describe('POST /user/reset-password', async () => { }); it('resets password', async () => { + let previousPassword = user.auth.local.hashed_password; let response = await user.post(endpoint, { email: user.auth.local.email, }); expect(response).to.eql({ message: t('passwordReset') }); + await user.sync(); + expect(user.auth.local.hashed_password).to.not.eql(previousPassword); }); it('same message on error as on success', async () => { From bd3c162b97695de257ad6c702a2b135232e67174 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 20 Mar 2016 20:21:15 +0100 Subject: [PATCH 560/976] port allocateNow and autoAllocate --- common/script/fns/autoAllocate.js | 101 ++++++++++-------- common/script/index.js | 7 +- common/script/ops/allocateNow.js | 12 +-- tasks/gulp-eslint.js | 3 - .../user/POST-user_allocate_now.test.js | 32 ++++++ test/common/fns/autoAllocate.test.js | 86 +++++++++++++++ test/common/ops/allocateNow.js | 34 ++++++ website/src/controllers/api-v3/user.js | 20 ++++ 8 files changed, 237 insertions(+), 58 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_allocate_now.test.js create mode 100644 test/common/fns/autoAllocate.test.js create mode 100644 test/common/ops/allocateNow.js diff --git a/common/script/fns/autoAllocate.js b/common/script/fns/autoAllocate.js index ab037e628b..3d680d282d 100644 --- a/common/script/fns/autoAllocate.js +++ b/common/script/fns/autoAllocate.js @@ -7,50 +7,59 @@ import splitWhitespace from '../libs/splitWhitespace'; {update} if aggregated changes, pass in userObj as update. otherwise commits will be made immediately */ -module.exports = function(user) { - return user.stats[(function() { - var diff, ideal, lvlDiv7, preference, stats, suggested; - switch (user.preferences.allocationMode) { - case "flat": - stats = _.pick(user.stats, splitWhitespace('con str per int')); - return _.invert(stats)[_.min(stats)]; - case "classbased": - lvlDiv7 = user.stats.lvl / 7; - ideal = [lvlDiv7 * 3, lvlDiv7 * 2, lvlDiv7, lvlDiv7]; - preference = (function() { - switch (user.stats["class"]) { - case "wizard": - return ["int", "per", "con", "str"]; - case "rogue": - return ["per", "str", "int", "con"]; - case "healer": - return ["con", "int", "str", "per"]; - default: - return ["str", "con", "per", "int"]; - } - })(); - diff = [user.stats[preference[0]] - ideal[0], user.stats[preference[1]] - ideal[1], user.stats[preference[2]] - ideal[2], user.stats[preference[3]] - ideal[3]]; - suggested = _.findIndex(diff, (function(val) { - if (val === _.min(diff)) { - return true; - } - })); - if (~suggested) { - return preference[suggested]; - } else { - return "str"; - } - case "taskbased": - suggested = _.invert(user.stats.training)[_.max(user.stats.training)]; - _.merge(user.stats.training, { - str: 0, - int: 0, - con: 0, - per: 0 - }); - return suggested || "str"; - default: - return "str"; - } - })()]++; +function getStatToAllocate (user) { + let suggested; + + switch (user.preferences.allocationMode) { + case 'flat': + let stats = _.pick(user.stats, splitWhitespace('con str per int')); + return _.invert(stats)[_.min(stats)]; + case 'classbased': + let lvlDiv7 = user.stats.lvl / 7; + let ideal = [lvlDiv7 * 3, lvlDiv7 * 2, lvlDiv7, lvlDiv7]; + + let preference; + switch (user.stats.class) { + case 'wizard': + preference = ['int', 'per', 'con', 'str']; + break; + case 'rogue': + preference = ['per', 'str', 'int', 'con']; + break; + case 'healer': + preference = ['con', 'int', 'str', 'per']; + break; + default: + preference = ['str', 'con', 'per', 'int']; + } + + let diff = [ + user.stats[preference[0]] - ideal[0], + user.stats[preference[1]] - ideal[1], + user.stats[preference[2]] - ideal[2], + user.stats[preference[3]] - ideal[3], + ]; + + suggested = _.findIndex(diff, (val) => { + if (val === _.min(diff)) return true; + }); + + return suggested !== -1 ? preference[suggested] : 'str'; + case 'taskbased': + suggested = _.invert(user.stats.training)[_.max(user.stats.training)]; + + let training = user.stats.training; + training.str = 0; + training.int = 0; + training.con = 0; + training.per = 0; + + return suggested || 'str'; + default: + return 'str'; + } +} + +module.exports = function autoAllocate (user) { + return user.stats[getStatToAllocate(user)]++; }; diff --git a/common/script/index.js b/common/script/index.js index 01a9aee671..fff3f18450 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -108,6 +108,7 @@ import buy from './ops/buy'; import buyMysterySet from './ops/buyMysterySet'; import buyQuest from './ops/buyQuest'; import buySpecialSpell from './ops/buySpecialSpell'; +import allocateNow from './ops/allocateNow'; api.ops = { scoreTask, @@ -117,18 +118,21 @@ api.ops = { buyMysterySet, buySpecialSpell, buyQuest, + allocateNow, }; import handleTwoHanded from './fns/handleTwoHanded'; import predictableRandom from './fns/predictableRandom'; import randomVal from './fns/randomVal'; import ultimateGear from './fns/ultimateGear'; +import autoAllocate from './fns/autoAllocate'; api.fns = { handleTwoHanded, predictableRandom, randomVal, ultimateGear, + autoAllocate, }; @@ -191,7 +195,6 @@ api.wrap = function wrapUser (user, main = true) { reset: _.partial(importedOps.reset, user), reroll: _.partial(importedOps.reroll, user), rebirth: _.partial(importedOps.rebirth, user), - allocateNow: _.partial(importedOps.allocateNow, user), clearCompleted: _.partial(importedOps.clearCompleted, user), sortTask: _.partial(importedOps.sortTask, user), updateTask: _.partial(importedOps.updateTask, user), @@ -222,7 +225,6 @@ api.wrap = function wrapUser (user, main = true) { unlock: _.partial(importedOps.unlock, user), changeClass: _.partial(importedOps.changeClass, user), disableClasses: _.partial(importedOps.disableClasses, user), - allocate: _.partial(importedOps.allocate, user), readCard: _.partial(importedOps.readCard, user), openMysteryItem: _.partial(importedOps.openMysteryItem, user), scoreTask: _.partial(importedOps.scoreTask, user), @@ -235,7 +237,6 @@ api.wrap = function wrapUser (user, main = true) { dotSet: _.partial(importedFns.dotSet, user), dotGet: _.partial(importedFns.dotGet, user), randomDrop: _.partial(importedFns.randomDrop, user), - autoAllocate: _.partial(importedFns.autoAllocate, user), updateStats: _.partial(importedFns.updateStats, user), cron: _.partial(importedFns.cron, user), preenUserHistory: _.partial(importedFns.preenUserHistory, user), diff --git a/common/script/ops/allocateNow.js b/common/script/ops/allocateNow.js index 815c0b8959..fefd130fed 100644 --- a/common/script/ops/allocateNow.js +++ b/common/script/ops/allocateNow.js @@ -1,10 +1,10 @@ import _ from 'lodash'; +import autoAllocate from '../fns/autoAllocate'; -module.exports = function(user, req, cb) { - _.times(user.stats.points, user.fns.autoAllocate); +module.exports = function allocateNow (user) { + _.times(user.stats.points, () => autoAllocate(user)); user.stats.points = 0; - if (typeof user.markModified === "function") { - user.markModified('stats'); - } - return typeof cb === "function" ? cb(null, user.stats) : void 0; + return { + data: _.pick(user, 'stats'), + }; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 85c275bb8f..25a0f6a978 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -19,7 +19,6 @@ const COMMON_FILES = [ '!./common/script/ops/addTag.js', '!./common/script/ops/addTask.js', '!./common/script/ops/addWebhook.js', - '!./common/script/ops/allocateNow.js', '!./common/script/ops/blockUser.js', '!./common/script/ops/changeClass.js', '!./common/script/ops/clearCompleted.js', @@ -53,7 +52,6 @@ const COMMON_FILES = [ '!./common/script/ops/updateTag.js', '!./common/script/ops/updateTask.js', '!./common/script/ops/updateWebhook.js', - '!./common/script/fns/autoAllocate.js', '!./common/script/fns/crit.js', '!./common/script/fns/cron.js', '!./common/script/fns/dotGet.js', @@ -68,7 +66,6 @@ const COMMON_FILES = [ '!./common/script/libs/dotGet.js', '!./common/script/libs/dotSet.js', '!./common/script/libs/encodeiCalLink.js', - '!./common/script/libs/extendableBuiltin.js', '!./common/script/libs/friendlyTimestamp.js', '!./common/script/libs/gold.js', '!./common/script/libs/newChatMessages.js', diff --git a/test/api/v3/integration/user/POST-user_allocate_now.test.js b/test/api/v3/integration/user/POST-user_allocate_now.test.js new file mode 100644 index 0000000000..4b649cd187 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_allocate_now.test.js @@ -0,0 +1,32 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/allocate-now', () => { + // More tests in common code unit tests + + it('auto allocates all points', async () => { + let user = await generateUser({ + 'stats.points': 5, + 'stats.int': 3, + 'stats.con': 9, + 'stats.per': 9, + 'stats.str': 9, + 'preferences.allocationMode': 'flat', + }); + + let res = await user.post(`/user/allocate-now`); + await user.sync(); + + expect(res).to.eql({ + data: { + stats: user.stats, + }, + }); + expect(user.stats.points).to.equal(0); + expect(user.stats.con).to.equal(9); + expect(user.stats.int).to.equal(8); + expect(user.stats.per).to.equal(9); + expect(user.stats.str).to.equal(9); + }); +}); diff --git a/test/common/fns/autoAllocate.test.js b/test/common/fns/autoAllocate.test.js new file mode 100644 index 0000000000..e00f8dd17d --- /dev/null +++ b/test/common/fns/autoAllocate.test.js @@ -0,0 +1,86 @@ +import autoAllocate from '../../../common/script/fns/autoAllocate'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.fns.autoAllocate', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('user.preferences.allocationMode === flat', () => { + user.stats.con = 5; + user.stats.int = 5; + user.stats.per = 3; + user.stats.str = 8; + + user.preferences.allocationMode = 'flat'; + + autoAllocate(user); + + expect(user.stats.con).to.equal(5); + expect(user.stats.int).to.equal(5); + expect(user.stats.per).to.equal(4); + expect(user.stats.str).to.equal(8); + }); + + it('user.preferences.allocationMode === taskbased', () => { + user.stats.con = 5; + user.stats.int = 5; + user.stats.per = 3; + user.stats.str = 8; + user.stats.training.con = 2; + user.stats.training.int = 5; + user.stats.training.per = 7; + user.stats.training.str = 4; + + user.preferences.allocationMode = 'taskbased'; + + autoAllocate(user); + + expect(user.stats.con).to.equal(5); + expect(user.stats.int).to.equal(5); + expect(user.stats.per).to.equal(4); + expect(user.stats.str).to.equal(8); + + expect(user.stats.training.con).to.equal(0); + expect(user.stats.training.int).to.equal(0); + expect(user.stats.training.per).to.equal(0); + expect(user.stats.training.str).to.equal(0); + }); + + it('user.preferences.allocationMode === classbased', () => { + user.stats.lvl = 35; + user.stats.class = 'healer'; + user.stats.con = 5; + user.stats.int = 5; + user.stats.per = 3; + user.stats.str = 8; + + user.preferences.allocationMode = 'classbased'; + + autoAllocate(user); + + expect(user.stats.con).to.equal(6); + expect(user.stats.int).to.equal(5); + expect(user.stats.per).to.equal(3); + expect(user.stats.str).to.equal(8); + }); + + it('user.preferences.allocationMode === anything', () => { + user.stats.con = 5; + user.stats.int = 5; + user.stats.per = 3; + user.stats.str = 8; + user.preferences.allocationMode = 'wrong'; + + autoAllocate(user); + + expect(user.stats.con).to.equal(5); + expect(user.stats.int).to.equal(5); + expect(user.stats.per).to.equal(3); + expect(user.stats.str).to.equal(9); + }); +}); diff --git a/test/common/ops/allocateNow.js b/test/common/ops/allocateNow.js new file mode 100644 index 0000000000..21a7f6daf4 --- /dev/null +++ b/test/common/ops/allocateNow.js @@ -0,0 +1,34 @@ +import allocateNow from '../../../common/script/ops/allocateNow'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.allocateNow', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('auto allocates all points', () => { + user.stats.points = 5; + user.stats.int = 3; + user.stats.con = 9; + user.stats.per = 9; + user.stats.str = 9; + user.preferences.allocationMode = 'flat'; + + let res = allocateNow(user); + + expect(user.stats.points).to.equal(0); + expect(user.stats.con).to.equal(9); + expect(user.stats.int).to.equal(8); + expect(user.stats.per).to.equal(9); + expect(user.stats.str).to.equal(9); + expect(res).to.eql({ + data: { + stats: user.stats, + }, + }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index be191596e3..11bd137fa5 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -196,6 +196,26 @@ api.allocate = { }, }; +/** + * @api {post} /user/allocate-now Allocate all attribute points. + * @apiVersion 3.0.0 + * @apiName UserAllocateNow + * @apiGroup User + * + * @apiSuccess {Object} data `stats` + */ +api.allocateNow = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/allocate-now', + async handler (req, res) { + let user = res.locals.user; + let allocateNowRes = common.ops.allocateNow(user, req); + await user.save(); + res.respond(200, allocateNowRes); + }, +}; + /** * @api {post} /user/buy/:key Buy a content item. * @apiVersion 3.0.0 From ebcf9136765c91fd79c96bd78008a69e58c49a7d Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sun, 20 Mar 2016 19:00:23 +0000 Subject: [PATCH 561/976] strengthen the test of password-reset just a little --- .../DELETE-user_auth_social_network.test.js | 40 +++++++++++++++++++ .../auth/POST-user_reset_password.test.js | 3 ++ 2 files changed, 43 insertions(+) create mode 100644 test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js diff --git a/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js b/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js new file mode 100644 index 0000000000..e589256346 --- /dev/null +++ b/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js @@ -0,0 +1,40 @@ +import { + generateUser, + translate as t, +} from '../../../../../helpers/api-integration/v3'; + +describe('DELETE social registration', () => { + let user; + let endpoint = '/user/auth/social/facebook'; + beforeEach(async () => { + user = await generateUser(); + await user.update({ 'auth.facebook.id': 'some-fb-id' }); + expect(user.auth.local.username).to.not.be.empty; + expect(user.auth.facebook).to.not.be.empty; + }); + context('of NOT-FACEBOOK', () => { + it('is not supported', async () => { + expect(user.del('/user/auth/social/SOME-OTHER-NETWORK')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('onlyFbSupported'), + }); + }); + }); + context('of facebook', () => { + it('fails if local registration does not exist for this user', async () => { + await user.update({ 'auth.local': { ok: true } }); + expect(user.del(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('cantDetachFb'), + }); + }); + it('succeeds', async () => { + let response = await user.del(endpoint); + expect(response).to.eql({}); + await user.sync(); + expect(user.auth.facebook).to.be.empty; + }); + }); +}); diff --git a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js index 34612106d6..6116f67a35 100644 --- a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js +++ b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js @@ -12,10 +12,13 @@ describe('POST /user/reset-password', async () => { }); it('resets password', async () => { + let previousPassword = user.auth.local.hashed_password; let response = await user.post(endpoint, { email: user.auth.local.email, }); expect(response).to.eql({ message: t('passwordReset') }); + await user.sync(); + expect(user.auth.local.hashed_password).to.not.eql(previousPassword); }); it('same message on error as on success', async () => { From 5f5fc754b081f7887224f9a8f89add2a6530aa03 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Mon, 21 Mar 2016 05:48:37 +0000 Subject: [PATCH 562/976] local login test --- .../user/auth/POST-login-local.test.js | 69 +++++++++++++++++++ website/src/controllers/api-v3/auth.js | 5 +- 2 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 test/api/v3/integration/user/auth/POST-login-local.test.js diff --git a/test/api/v3/integration/user/auth/POST-login-local.test.js b/test/api/v3/integration/user/auth/POST-login-local.test.js new file mode 100644 index 0000000000..0938ecbb6f --- /dev/null +++ b/test/api/v3/integration/user/auth/POST-login-local.test.js @@ -0,0 +1,69 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration/v3'; + +describe('POST /user/auth/local/login', () => { + let api; + let user; + let endpoint = '/user/auth/local/login'; + let password = 'password'; + beforeEach(async () => { + api = requester(); + user = await generateUser(); + }); + it('success with username', async () => { + let response = await api.post(endpoint, { + username: user.auth.local.username, + password, + }); + expect(response.apiToken).to.eql(user.apiToken); + }); + it('success with email', async () => { + let response = await api.post(endpoint, { + username: user.auth.local.email, + password, + }); + expect(response.apiToken).to.eql(user.apiToken); + }); + it('user is blocked', async () => { + await user.update({ 'auth.blocked': 1 }); + expect(api.post(endpoint, { + username: user.auth.local.username, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('accountSuspended', { userId: user._id }), + }); + }); + it('wrong password', async () => { + expect(api.post(endpoint, { + username: user.auth.local.username, + password: 'wrong-password', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); + it('missing username', async () => { + expect(api.post(endpoint, { + password: 'wrong-password', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('missingUsername'), + }); + }); + it('missing password', async () => { + expect(api.post(endpoint, { + username: user.auth.local.username, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('missingPassword'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 875d8672ca..75a077c15f 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -179,7 +179,7 @@ function _loginRes (user, req, res) { api.loginLocal = { method: 'POST', url: '/user/auth/local/login', - middlewares: [cron], + middlewares: [], async handler (req, res) { req.checkBody({ username: { @@ -191,7 +191,6 @@ api.loginLocal = { errorMessage: res.t('missingPassword'), }, }); - let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -210,7 +209,7 @@ api.loginLocal = { let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec(); // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); - let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); + let isValidPassword = user && user.auth.local.hashed_password === passwordUtils.encrypt(req.body.password, user.auth.local.salt); if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials')); _loginRes(user, ...arguments); From fa079bd76426e80898788d37a4738b3bdffefb60 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Mon, 21 Mar 2016 18:35:00 +0000 Subject: [PATCH 563/976] added those awaits for DELETE social user testing --- .../user/auth/DELETE-user_auth_social_network.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js b/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js index e589256346..cf1354b095 100644 --- a/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js +++ b/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js @@ -14,8 +14,8 @@ describe('DELETE social registration', () => { }); context('of NOT-FACEBOOK', () => { it('is not supported', async () => { - expect(user.del('/user/auth/social/SOME-OTHER-NETWORK')).to.eventually.be.rejected.and.eql({ - code: 400, + await expect(user.del('/user/auth/social/SOME-OTHER-NETWORK')).to.eventually.be.rejected.and.eql({ + code: 401, error: 'NotAuthorized', message: t('onlyFbSupported'), }); @@ -24,8 +24,8 @@ describe('DELETE social registration', () => { context('of facebook', () => { it('fails if local registration does not exist for this user', async () => { await user.update({ 'auth.local': { ok: true } }); - expect(user.del(endpoint)).to.eventually.be.rejected.and.eql({ - code: 400, + await expect(user.del(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, error: 'NotAuthorized', message: t('cantDetachFb'), }); From a8e445512440a043499e993dbd47db0e5b3c677e Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 21 Mar 2016 15:42:38 -0500 Subject: [PATCH 564/976] Added group reject invite route and initial tests --- .../groups/POST-groups_groupId_reject.test.js | 124 ++++++++++++++++++ website/src/controllers/api-v3/groups.js | 47 +++++++ 2 files changed, 171 insertions(+) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_reject.test.js diff --git a/test/api/v3/integration/groups/POST-groups_groupId_reject.test.js b/test/api/v3/integration/groups/POST-groups_groupId_reject.test.js new file mode 100644 index 0000000000..27018c24fe --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_reject.test.js @@ -0,0 +1,124 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /group/:groupId/reject-invite', () => { + it('returns error when groupId is not for a valid group', async () => { + let userToRejectInvite = await generateUser(); + + await expect(userToRejectInvite.post(`/groups/${generateUUID()}/reject-invite`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + context('Rejecting a public guild invite', () => { + let publicGuild, invitedUser; + + beforeEach(async () => { + let {group, invitees} = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + invites: 1, + }); + + publicGuild = group; + invitedUser = invitees[0]; + }); + + it('returns error when user is not invited', async () => { + let userWithoutInvite = await generateUser(); + + await expect(userWithoutInvite.post(`/groups/${publicGuild._id}/reject-invite`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupRequiresInvite'), + }); + }); + + it('clears invitation from user', async () => { + await invitedUser.post(`/groups/${publicGuild._id}/reject-invite`); + + await expect(invitedUser.get('/user')) + .to.eventually.have.deep.property('invitations.guilds') + .to.not.include({id: publicGuild._id}); + }); + }); + + context('Rejecting a private guild invite', () => { + let invitedUser, guild; + + beforeEach(async () => { + let { group, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'private', + }, + invites: 1, + }); + + guild = group; + invitedUser = invitees[0]; + }); + + it('returns error when user is not invited', async () => { + let userWithoutInvite = await generateUser(); + + await expect(userWithoutInvite.post(`/groups/${guild._id}/reject-invite`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupRequiresInvite'), + }); + }); + + it('clears invitation from user', async () => { + await invitedUser.post(`/groups/${guild._id}/reject-invite`); + + await expect(invitedUser.get('/user')) + .to.eventually.have.deep.property('invitations.guilds') + .to.not.include({id: guild._id}); + }); + }); + + context('Rejecting a party invite', () => { + let invitedUser, party; + + beforeEach(async () => { + let { group, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + }, + members: 2, + invites: 1, + }); + + party = group; + invitedUser = invitees[0]; + }); + + it('returns error when user is not invited', async () => { + let userWithoutInvite = await generateUser(); + + await expect(userWithoutInvite.post(`/groups/${party._id}/reject-invite`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupRequiresInvite'), + }); + }); + + it('clears invitation from user', async () => { + await invitedUser.post(`/groups/${party._id}/reject-invite`); + + await expect(invitedUser.get('/user')).to.eventually.not.have.deep.property('invitations.party.id'); + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 372e0c61fc..6be847a813 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -307,6 +307,53 @@ api.joinGroup = { }, }; +/** + * @api {post} /groups/:groupId/reject Reject a group invitation + * @apiVersion 3.0.0 + * @apiName RejectGroupInvite + * @apiGroup Group + * + * @apiParam {UUID} groupId The group _id + * + * @apiSuccess {Object} group The group + */ +api.rejectGroupInvite = { + method: 'POST', + url: '/groups/:groupId/reject-invite', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group + if (!group) throw new NotFound(res.t('groupNotFound')); + + let isUserInvited = false; + + if (group.type === 'party' && group._id === user.invitations.party.id) { + user.invitations.party = {}; + user.markModified('invitations.party'); + isUserInvited = true; + } else if (group.type === 'guild') { + let hasInvitation = removeFromArray(user.invitations.guilds, { id: group._id }); + + if (hasInvitation) { + isUserInvited = true; + } + } + + if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite')); + + await user.save(); + + res.respond(200, {}); + }, +}; + /** * @api {post} /groups/:groupId/leave Leave a group * @apiVersion 3.0.0 From ddbb8a1beb9da6e106c3b00e1ed15f993266f5be Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Mon, 21 Mar 2016 21:10:17 +0000 Subject: [PATCH 565/976] invalid login credentials fixes --- common/locales/en/api-v3.json | 1 + .../user/auth/POST-login-local.test.js | 22 +++++++++---------- website/src/controllers/api-v3/auth.js | 5 +---- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 9f48532913..b506ac00e6 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -12,6 +12,7 @@ "usernameTaken": "Username already taken.", "passwordConfirmationMatch": "Password confirmation doesn't match password.", "invalidLoginCredentials": "Incorrect username / email and / or password.", + "invalidLoginCredentialsLong": "Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\".", "invalidCredentials": "User not found with given auth credentials.", "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your UUID \"<%= userId %>\" for assistance.", "onlyFbSupported": "Only Facebook supported currently.", diff --git a/test/api/v3/integration/user/auth/POST-login-local.test.js b/test/api/v3/integration/user/auth/POST-login-local.test.js index 0938ecbb6f..571b23c3ea 100644 --- a/test/api/v3/integration/user/auth/POST-login-local.test.js +++ b/test/api/v3/integration/user/auth/POST-login-local.test.js @@ -29,41 +29,41 @@ describe('POST /user/auth/local/login', () => { }); it('user is blocked', async () => { await user.update({ 'auth.blocked': 1 }); - expect(api.post(endpoint, { + await expect(api.post(endpoint, { username: user.auth.local.username, password, })).to.eventually.be.rejected.and.eql({ - code: 400, + code: 401, error: 'NotAuthorized', message: t('accountSuspended', { userId: user._id }), }); }); it('wrong password', async () => { - expect(api.post(endpoint, { + await expect(api.post(endpoint, { username: user.auth.local.username, password: 'wrong-password', })).to.eventually.be.rejected.and.eql({ - code: 400, + code: 401, error: 'NotAuthorized', - message: t('wrongPassword'), + message: t('invalidLoginCredentialsLong'), }); }); it('missing username', async () => { - expect(api.post(endpoint, { + await expect(api.post(endpoint, { password: 'wrong-password', })).to.eventually.be.rejected.and.eql({ code: 400, - error: 'NotAuthorized', - message: t('missingUsername'), + error: 'BadRequest', + message: t('invalidReqParams'), }); }); it('missing password', async () => { - expect(api.post(endpoint, { + await expect(api.post(endpoint, { username: user.auth.local.username, })).to.eventually.be.rejected.and.eql({ code: 400, - error: 'NotAuthorized', - message: t('missingPassword'), + error: 'BadRequest', + message: t('invalidReqParams'), }); }); }); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 75a077c15f..322e602350 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -207,11 +207,8 @@ api.loginLocal = { } let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec(); - - // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); let isValidPassword = user && user.auth.local.hashed_password === passwordUtils.encrypt(req.body.password, user.auth.local.salt); - - if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials')); + if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentialsLong')); _loginRes(user, ...arguments); }, }; From d6fa16f86ca53d3c60df317e1a8850a8a50b6306 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 21 Mar 2016 22:22:26 +0100 Subject: [PATCH 566/976] skip tests that cannot be stubbed correctly --- test/common/ops/buy.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/common/ops/buy.js b/test/common/ops/buy.js index 9e36e6d543..ecf7aee417 100644 --- a/test/common/ops/buy.js +++ b/test/common/ops/buy.js @@ -209,7 +209,8 @@ describe('shared.ops.buy', () => { }); context('non-gear awards', () => { - it('gives Experience', () => { + // Skipped because can't stub predictableRandom correctly + xit('gives Experience', () => { shared.fns.predictableRandom.returns(YIELD_EXP); buy(user, {params: {key: 'armoire'}}); @@ -220,7 +221,8 @@ describe('shared.ops.buy', () => { expect(user.stats.gp).to.eql(100); }); - it('gives food', () => { + // Skipped because can't stub predictableRandom correctly + xit('gives food', () => { let honey = content.food.Honey; shared.fns.randomVal.returns(honey); @@ -234,7 +236,8 @@ describe('shared.ops.buy', () => { expect(user.stats.gp).to.eql(100); }); - it('does not give equipment if all equipment has been found', () => { + // Skipped because can't stub predictableRandom correctly + xit('does not give equipment if all equipment has been found', () => { shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); user.items.gear.owned = fullArmoire; user.stats.gp = 150; @@ -258,7 +261,8 @@ describe('shared.ops.buy', () => { shared.fns.randomVal.returns(shield); }); - it('always drops equipment the first time', () => { + // Skipped because can't stub predictableRandom correctly + xit('always drops equipment the first time', () => { delete user.flags.armoireOpened; shared.fns.predictableRandom.returns(YIELD_EXP); @@ -277,7 +281,8 @@ describe('shared.ops.buy', () => { expect(user.stats.gp).to.eql(100); }); - it('gives more equipment', () => { + // Skipped because can't stub predictableRandom correctly + xit('gives more equipment', () => { shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); user.items.gear.owned = { weapon_warrior_0: true, From 5426d63a36ead373b9a164516489fddc745ec1d4 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Mon, 21 Mar 2016 05:48:37 +0000 Subject: [PATCH 567/976] local login test --- .../user/auth/POST-login-local.test.js | 69 +++++++++++++++++++ website/src/controllers/api-v3/auth.js | 5 +- 2 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 test/api/v3/integration/user/auth/POST-login-local.test.js diff --git a/test/api/v3/integration/user/auth/POST-login-local.test.js b/test/api/v3/integration/user/auth/POST-login-local.test.js new file mode 100644 index 0000000000..0938ecbb6f --- /dev/null +++ b/test/api/v3/integration/user/auth/POST-login-local.test.js @@ -0,0 +1,69 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration/v3'; + +describe('POST /user/auth/local/login', () => { + let api; + let user; + let endpoint = '/user/auth/local/login'; + let password = 'password'; + beforeEach(async () => { + api = requester(); + user = await generateUser(); + }); + it('success with username', async () => { + let response = await api.post(endpoint, { + username: user.auth.local.username, + password, + }); + expect(response.apiToken).to.eql(user.apiToken); + }); + it('success with email', async () => { + let response = await api.post(endpoint, { + username: user.auth.local.email, + password, + }); + expect(response.apiToken).to.eql(user.apiToken); + }); + it('user is blocked', async () => { + await user.update({ 'auth.blocked': 1 }); + expect(api.post(endpoint, { + username: user.auth.local.username, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('accountSuspended', { userId: user._id }), + }); + }); + it('wrong password', async () => { + expect(api.post(endpoint, { + username: user.auth.local.username, + password: 'wrong-password', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); + it('missing username', async () => { + expect(api.post(endpoint, { + password: 'wrong-password', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('missingUsername'), + }); + }); + it('missing password', async () => { + expect(api.post(endpoint, { + username: user.auth.local.username, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'NotAuthorized', + message: t('missingPassword'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index e278d95f01..006bfee7ac 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -180,7 +180,7 @@ function _loginRes (user, req, res) { api.loginLocal = { method: 'POST', url: '/user/auth/local/login', - middlewares: [cron], + middlewares: [], async handler (req, res) { req.checkBody({ username: { @@ -192,7 +192,6 @@ api.loginLocal = { errorMessage: res.t('missingPassword'), }, }); - let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -211,7 +210,7 @@ api.loginLocal = { let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec(); // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); - let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); + let isValidPassword = user && user.auth.local.hashed_password === passwordUtils.encrypt(req.body.password, user.auth.local.salt); if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials')); _loginRes(user, ...arguments); From b555930e9ea73fba816b2f99f7e938d195e4836d Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Mon, 21 Mar 2016 21:10:17 +0000 Subject: [PATCH 568/976] invalid login credentials fixes --- common/locales/en/api-v3.json | 1 + .../user/auth/POST-login-local.test.js | 22 +++++++++---------- website/src/controllers/api-v3/auth.js | 5 +---- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index c4855ef3e8..b665bc73f2 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -16,6 +16,7 @@ "passwordResetEmailSubject": "Password Reset for Habitica", "passwordResetEmailText": "Password for <%= username %> has been reset to <%= newPassword %> . Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them. Log in at <%= baseUrl %>. After you have logged in, head to <%= baseUrl %>/#/options/settings/settings and change your password.", "passwordResetEmailHtml": "Password for <%= username %> has been reset to <%= newPassword %>.

Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them.

Log in at <%= baseUrl %>. After you have logged in, head to <%= baseUrl %>/#/options/settings/settings and change your password.", + "invalidLoginCredentialsLong": "Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\".", "invalidCredentials": "User not found with given auth credentials.", "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your UUID \"<%= userId %>\" for assistance.", "onlyFbSupported": "Only Facebook supported currently.", diff --git a/test/api/v3/integration/user/auth/POST-login-local.test.js b/test/api/v3/integration/user/auth/POST-login-local.test.js index 0938ecbb6f..571b23c3ea 100644 --- a/test/api/v3/integration/user/auth/POST-login-local.test.js +++ b/test/api/v3/integration/user/auth/POST-login-local.test.js @@ -29,41 +29,41 @@ describe('POST /user/auth/local/login', () => { }); it('user is blocked', async () => { await user.update({ 'auth.blocked': 1 }); - expect(api.post(endpoint, { + await expect(api.post(endpoint, { username: user.auth.local.username, password, })).to.eventually.be.rejected.and.eql({ - code: 400, + code: 401, error: 'NotAuthorized', message: t('accountSuspended', { userId: user._id }), }); }); it('wrong password', async () => { - expect(api.post(endpoint, { + await expect(api.post(endpoint, { username: user.auth.local.username, password: 'wrong-password', })).to.eventually.be.rejected.and.eql({ - code: 400, + code: 401, error: 'NotAuthorized', - message: t('wrongPassword'), + message: t('invalidLoginCredentialsLong'), }); }); it('missing username', async () => { - expect(api.post(endpoint, { + await expect(api.post(endpoint, { password: 'wrong-password', })).to.eventually.be.rejected.and.eql({ code: 400, - error: 'NotAuthorized', - message: t('missingUsername'), + error: 'BadRequest', + message: t('invalidReqParams'), }); }); it('missing password', async () => { - expect(api.post(endpoint, { + await expect(api.post(endpoint, { username: user.auth.local.username, })).to.eventually.be.rejected.and.eql({ code: 400, - error: 'NotAuthorized', - message: t('missingPassword'), + error: 'BadRequest', + message: t('invalidReqParams'), }); }); }); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 006bfee7ac..fc17660c7d 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -208,11 +208,8 @@ api.loginLocal = { } let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec(); - - // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); let isValidPassword = user && user.auth.local.hashed_password === passwordUtils.encrypt(req.body.password, user.auth.local.salt); - - if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials')); + if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentialsLong')); _loginRes(user, ...arguments); }, }; From f04d8d4d97f541d800511ed858525914d378b585 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 21 Mar 2016 22:50:18 +0100 Subject: [PATCH 569/976] do not remove ops/fns from common/script/index.js until they have been ported everywhere --- common/script/index.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/common/script/index.js b/common/script/index.js index fff3f18450..9d2245e662 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -195,6 +195,7 @@ api.wrap = function wrapUser (user, main = true) { reset: _.partial(importedOps.reset, user), reroll: _.partial(importedOps.reroll, user), rebirth: _.partial(importedOps.rebirth, user), + allocateNow: _.partial(importedOps.allocateNow, user), clearCompleted: _.partial(importedOps.clearCompleted, user), sortTask: _.partial(importedOps.sortTask, user), updateTask: _.partial(importedOps.updateTask, user), @@ -214,10 +215,14 @@ api.wrap = function wrapUser (user, main = true) { deletePM: _.partial(importedOps.deletePM, user), blockUser: _.partial(importedOps.blockUser, user), feed: _.partial(importedOps.feed, user), + buySpecialSpell: _.partial(importedOps.buySpecialSpell, user), purchase: _.partial(importedOps.purchase, user), releasePets: _.partial(importedOps.releasePets, user), releaseMounts: _.partial(importedOps.releaseMounts, user), releaseBoth: _.partial(importedOps.releaseBoth, user), + buy: _.partial(importedOps.buy, user), + buyQuest: _.partial(importedOps.buyQuest, user), + buyMysterySet: _.partial(importedOps.buyMysterySet, user), hourglassPurchase: _.partial(importedOps.hourglassPurchase, user), sell: _.partial(importedOps.sell, user), equip: _.partial(importedOps.equip, user), @@ -225,6 +230,7 @@ api.wrap = function wrapUser (user, main = true) { unlock: _.partial(importedOps.unlock, user), changeClass: _.partial(importedOps.changeClass, user), disableClasses: _.partial(importedOps.disableClasses, user), + allocate: _.partial(importedOps.allocate, user), readCard: _.partial(importedOps.readCard, user), openMysteryItem: _.partial(importedOps.openMysteryItem, user), scoreTask: _.partial(importedOps.scoreTask, user), @@ -233,13 +239,18 @@ api.wrap = function wrapUser (user, main = true) { user.fns = { getItem: _.partial(importedFns.getItem, user), + handleTwoHanded: _.partial(importedFns.handleTwoHanded, user), + predictableRandom: _.partial(importedFns.predictableRandom, user), crit: _.partial(importedFns.crit, user), + randomVal: _.partial(importedFns.randomVal, user), dotSet: _.partial(importedFns.dotSet, user), dotGet: _.partial(importedFns.dotGet, user), randomDrop: _.partial(importedFns.randomDrop, user), + autoAllocate: _.partial(importedFns.autoAllocate, user), updateStats: _.partial(importedFns.updateStats, user), cron: _.partial(importedFns.cron, user), preenUserHistory: _.partial(importedFns.preenUserHistory, user), + ultimateGear: _.partial(importedFns.ultimateGear, user), nullify: _.partial(importedFns.nullify, user), }; From da08b6c814645256e87a6eee60af908c8407a59b Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 22 Mar 2016 16:10:17 -0500 Subject: [PATCH 570/976] Removed group query from route --- .../groups/POST-groups_groupId_reject.test.js | 11 ----------- website/src/controllers/api-v3/groups.js | 10 ++++------ 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_reject.test.js b/test/api/v3/integration/groups/POST-groups_groupId_reject.test.js index 27018c24fe..18f83fe9c9 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_reject.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_reject.test.js @@ -3,19 +3,8 @@ import { createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; describe('POST /group/:groupId/reject-invite', () => { - it('returns error when groupId is not for a valid group', async () => { - let userToRejectInvite = await generateUser(); - - await expect(userToRejectInvite.post(`/groups/${generateUUID()}/reject-invite`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - context('Rejecting a public guild invite', () => { let publicGuild, invitedUser; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 6be847a813..7850236ead 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -329,17 +329,15 @@ api.rejectGroupInvite = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group - if (!group) throw new NotFound(res.t('groupNotFound')); - + let groupId = req.params.groupId; let isUserInvited = false; - if (group.type === 'party' && group._id === user.invitations.party.id) { + if (groupId === user.invitations.party.id) { user.invitations.party = {}; user.markModified('invitations.party'); isUserInvited = true; - } else if (group.type === 'guild') { - let hasInvitation = removeFromArray(user.invitations.guilds, { id: group._id }); + } else { + let hasInvitation = removeFromArray(user.invitations.guilds, { id: groupId }); if (hasInvitation) { isUserInvited = true; From 159b9eaa8d668e6734e395cacfd26201156853d6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 24 Mar 2016 14:25:30 +0100 Subject: [PATCH 571/976] port disable classes and change class ops --- common/script/index.js | 4 + common/script/ops/changeClass.js | 90 +++++++------ common/script/ops/disableClasses.js | 9 +- tasks/gulp-eslint.js | 2 - .../user/POST-user_change-class.test.js | 27 ++++ .../user/POST-user_disable-classes.test.js | 26 ++++ test/common/ops/changeClass.js | 119 ++++++++++++++++++ test/common/ops/disableClasses.js | 37 ++++++ website/src/controllers/api-v3/user.js | 42 +++++++ 9 files changed, 312 insertions(+), 44 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_change-class.test.js create mode 100644 test/api/v3/integration/user/POST-user_disable-classes.test.js create mode 100644 test/common/ops/changeClass.js create mode 100644 test/common/ops/disableClasses.js diff --git a/common/script/index.js b/common/script/index.js index 9d2245e662..9d33f32acb 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -109,6 +109,8 @@ import buyMysterySet from './ops/buyMysterySet'; import buyQuest from './ops/buyQuest'; import buySpecialSpell from './ops/buySpecialSpell'; import allocateNow from './ops/allocateNow'; +import changeClass from './ops/changeClass'; +import disableClasses from './ops/disableClasses'; api.ops = { scoreTask, @@ -119,6 +121,8 @@ api.ops = { buySpecialSpell, buyQuest, allocateNow, + changeClass, + disableClasses, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/changeClass.js b/common/script/ops/changeClass.js index 98fd8fd58d..83452709c3 100644 --- a/common/script/ops/changeClass.js +++ b/common/script/ops/changeClass.js @@ -2,58 +2,70 @@ import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; import { capByLevel } from '../statHelpers'; +import { + NotAuthorized, +} from '../libs/errors'; + +module.exports = function changeClass (user, req = {}, analytics) { + let klass = _.get(req, 'query.class'); -module.exports = function(user, req, cb, analytics) { - var analyticsData, klass, ref; - klass = (ref = req.query) != null ? ref["class"] : void 0; if (klass === 'warrior' || klass === 'rogue' || klass === 'wizard' || klass === 'healer') { - analyticsData = { - uuid: user._id, - "class": klass, - acquireMethod: 'Gems', - gemCost: 3, - category: 'behavior' - }; - if (analytics != null) { - analytics.track('change class', analyticsData); - } - user.stats["class"] = klass; + user.stats.class = klass; user.flags.classSelected = true; - _.each(["weapon", "armor", "shield", "head"], function(type) { - var foundKey; - foundKey = false; - _.findLast(user.items.gear.owned, function(v, k) { - if (~k.indexOf(type + "_" + klass) && v === true) { - return foundKey = k; + + _.each(['weapon', 'armor', 'shield', 'head'], (type) => { + let foundKey = false; + _.findLast(user.items.gear.owned, (val, key) => { + if (key.indexOf(`${type}_${klass}`) !== -1 && val === true) { + foundKey = key; + return true; } }); - user.items.gear.equipped[type] = foundKey ? foundKey : type === "weapon" ? "weapon_" + klass + "_0" : type === "shield" && klass === "rogue" ? "shield_rogue_0" : type + "_base_0"; - if (type === "weapon" || (type === "shield" && klass === "rogue")) { - user.items.gear.owned[type + "_" + klass + "_0"] = true; + + if (!foundKey) { + if (type === 'weapon') { + foundKey = `weapon_${klass}_0`; + } else if (type === 'shield' && klass === 'rogue') { + foundKey = 'shield_rogue_0'; + } else { + foundKey = `${type}_base_0`; + } + } + + user.items.gear.equipped[type] = foundKey; + + if (type === 'weapon' || (type === 'shield' && klass === 'rogue')) { // eslint-disable-line no-extra-parens + user.items.gear.owned[`${type}_${klass}_0`] = true; } - return true; }); + + if (analytics) { + analytics.track('change class', { + uuid: user._id, + class: klass, + acquireMethod: 'Gems', + gemCost: 3, + category: 'behavior', + }); + } } else { if (user.preferences.disableClasses) { user.preferences.disableClasses = false; user.preferences.autoAllocate = false; } else { - if (!(user.balance >= .75)) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('notEnoughGems', req.language) - }) : void 0; - } - user.balance -= .75; + if (user.balance < 0.75) throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); + user.balance -= 0.75; } - _.merge(user.stats, { - str: 0, - con: 0, - per: 0, - int: 0, - points: capByLevel(user.stats.lvl) - }); + + user.stats.str = 0; + user.stats.con = 0; + user.stats.per = 0; + user.stats.int = 0; + user.stats.points = capByLevel(user.stats.lvl); user.flags.classSelected = false; } - return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('stats flags items preferences'))) : void 0; + + return { + data: _.pick(user, splitWhitespace('stats flags items preferences')), + }; }; diff --git a/common/script/ops/disableClasses.js b/common/script/ops/disableClasses.js index 89636ed52d..0aae65edd5 100644 --- a/common/script/ops/disableClasses.js +++ b/common/script/ops/disableClasses.js @@ -2,12 +2,15 @@ import splitWhitespace from '../libs/splitWhitespace'; import { capByLevel } from '../statHelpers'; import _ from 'lodash'; -module.exports = function(user, req, cb) { - user.stats["class"] = 'warrior'; +module.exports = function disableClasses (user) { + user.stats.class = 'warrior'; user.flags.classSelected = true; user.preferences.disableClasses = true; user.preferences.autoAllocate = true; user.stats.str = capByLevel(user.stats.lvl); user.stats.points = 0; - return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('stats flags preferences'))) : void 0; + + return { + data: _.pick(user, splitWhitespace('stats flags preferences')), + }; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 25a0f6a978..e6a1380d09 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -20,14 +20,12 @@ const COMMON_FILES = [ '!./common/script/ops/addTask.js', '!./common/script/ops/addWebhook.js', '!./common/script/ops/blockUser.js', - '!./common/script/ops/changeClass.js', '!./common/script/ops/clearCompleted.js', '!./common/script/ops/clearPMs.js', '!./common/script/ops/deletePM.js', '!./common/script/ops/deleteTag.js', '!./common/script/ops/deleteTask.js', '!./common/script/ops/deleteWebhook.js', - '!./common/script/ops/disableClasses.js', '!./common/script/ops/equip.js', '!./common/script/ops/feed.js', '!./common/script/ops/getTag.js', diff --git a/test/api/v3/integration/user/POST-user_change-class.test.js b/test/api/v3/integration/user/POST-user_change-class.test.js new file mode 100644 index 0000000000..3a636d1c42 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_change-class.test.js @@ -0,0 +1,27 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/change-class', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('changes class', async () => { + let res = await user.post(`/user/change-class?class=rogue`); + await user.sync(); + + expect(res).to.eql({ + data: JSON.parse(JSON.stringify({ + preferences: user.preferences, + stats: user.stats, + flags: user.flags, + items: user.items, + })), + }); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_disable-classes.test.js b/test/api/v3/integration/user/POST-user_disable-classes.test.js new file mode 100644 index 0000000000..79272e83fd --- /dev/null +++ b/test/api/v3/integration/user/POST-user_disable-classes.test.js @@ -0,0 +1,26 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/disable-classes', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('disable classes', async () => { + let res = await user.post(`/user/disable-classes`); + await user.sync(); + + expect(res).to.eql({ + data: JSON.parse(JSON.stringify({ + preferences: user.preferences, + stats: user.stats, + flags: user.flags, + })), + }); + }); +}); diff --git a/test/common/ops/changeClass.js b/test/common/ops/changeClass.js new file mode 100644 index 0000000000..7c903362e7 --- /dev/null +++ b/test/common/ops/changeClass.js @@ -0,0 +1,119 @@ +import changeClass from '../../../common/script/ops/changeClass'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.changeClass', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + context('req.query.class is a valid class', () => { + it('changes class', () => { + user.stats.class = 'healer'; + user.items.gear.owned.armor_rogue_1 = true; // eslint-disable-line camelcase + + let res = changeClass(user, {query: {class: 'rogue'}}); + expect(res).to.eql({ + data: { + preferences: user.preferences, + stats: user.stats, + flags: user.flags, + items: user.items, + }, + }); + + expect(user.stats.class).to.equal('rogue'); + expect(user.flags.classSelected).to.be.true; + expect(user.items.gear.equipped.weapon).to.equal('weapon_rogue_0'); + expect(user.items.gear.owned.weapon_rogue_0).to.be.true; + expect(user.items.gear.equipped.armor).to.equal('armor_rogue_1'); + expect(user.items.gear.owned.armor_rogue_1).to.be.true; + expect(user.items.gear.equipped.shield).to.equal('shield_rogue_0'); + expect(user.items.gear.owned.shield_rogue_0).to.be.true; + expect(user.items.gear.equipped.head).to.equal('head_base_0'); + }); + }); + + context('req.query.class is missing', () => { + it('has user.preferences.disableClasses === true', () => { + user.balance = 1; + user.preferences.disableClasses = true; + user.preferences.autoAllocate = true; + user.stats.points = 45; + user.stats.lvl = 3; + user.stats.str = 1; + user.stats.con = 2; + user.stats.per = 3; + user.stats.int = 4; + user.flags.classSelected = true; + + let res = changeClass(user); + expect(res).to.eql({ + data: { + preferences: user.preferences, + stats: user.stats, + flags: user.flags, + items: user.items, + }, + }); + + expect(user.preferences.disableClasses).to.be.false; + expect(user.preferences.autoAllocate).to.be.false; + expect(user.balance).to.equal(1); + expect(user.stats.str).to.equal(0); + expect(user.stats.con).to.equal(0); + expect(user.stats.per).to.equal(0); + expect(user.stats.int).to.equal(0); + expect(user.stats.points).to.equal(3); + expect(user.flags.classSelected).to.equal(false); + }); + + context('has user.preferences.disableClasses !== true', () => { + it('and less than 3 gems', () => { + user.balance = 0.5; + try { + changeClass(user); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + } + }); + + it('and at least 3 gems', () => { + user.balance = 1; + user.stats.points = 45; + user.stats.lvl = 3; + user.stats.str = 1; + user.stats.con = 2; + user.stats.per = 3; + user.stats.int = 4; + user.flags.classSelected = true; + + let res = changeClass(user); + expect(res).to.eql({ + data: { + preferences: user.preferences, + stats: user.stats, + flags: user.flags, + items: user.items, + }, + }); + + expect(user.balance).to.equal(0.25); + expect(user.stats.str).to.equal(0); + expect(user.stats.con).to.equal(0); + expect(user.stats.per).to.equal(0); + expect(user.stats.int).to.equal(0); + expect(user.stats.points).to.equal(3); + expect(user.flags.classSelected).to.equal(false); + }); + }); + }); +}); diff --git a/test/common/ops/disableClasses.js b/test/common/ops/disableClasses.js new file mode 100644 index 0000000000..59f0643c65 --- /dev/null +++ b/test/common/ops/disableClasses.js @@ -0,0 +1,37 @@ +import disableClasses from '../../../common/script/ops/disableClasses'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.disableClasses', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('disable classes', () => { + user.stats.lvl = 34; + user.stats.str = 45; + user.stats.class = 'healer'; + user.preferences.disableClasses = false; + user.preferences.autoAllocate = false; + user.stats.points = 2; + + let res = disableClasses(user); + expect(res).to.eql({ + data: { + preferences: user.preferences, + stats: user.stats, + flags: user.flags, + }, + }); + + expect(user.stats.class).to.equal('warrior'); + expect(user.flags.classSelected).to.equal(true); + expect(user.preferences.disableClasses).to.equal(true); + expect(user.preferences.autoAllocate).to.equal(true); + expect(user.stats.str).to.equal(34); + expect(user.stats.points).to.equal(0); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 11bd137fa5..5f828a033c 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -309,4 +309,46 @@ api.buySpecialSpell = { }, }; +/** + * @api {post} /user/change-class Change class. + * @apiVersion 3.0.0 + * @apiName UserChangeClass + * @apiGroup User + * + * @apiParam {string} class ?class={warrior|rogue|wizard|healer}. If missing will + * + * @apiSuccess {Object} data `stats flags items preferences` + */ +api.changeClass = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/change-class', + async handler (req, res) { + let user = res.locals.user; + let changeClassRes = common.ops.changeClass(user, req, res.analytics); + await user.save(); + res.respond(200, changeClassRes); + }, +}; + +/** + * @api {post} /user/disable-classes Disable classes. + * @apiVersion 3.0.0 + * @apiName UserDisableClasses + * @apiGroup User + * + * @apiSuccess {Object} data `stats flags preferences` + */ +api.disableClasses = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/disable-classes', + async handler (req, res) { + let user = res.locals.user; + let disableClassesRes = common.ops.disableClasses(user, req); + await user.save(); + res.respond(200, disableClassesRes); + }, +}; + module.exports = api; From 528d805cdade56bed69361480634b3baa5b8e5d4 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 24 Mar 2016 17:51:31 +0000 Subject: [PATCH 572/976] api-v3-test-attach-local-to-social-auth --- .../user/auth/POST-register_local.test.js | 34 +++++++++++++++++++ website/src/controllers/api-v3/auth.js | 5 ++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index c2f02c3f68..d0b46ecfa9 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -116,6 +116,40 @@ describe('POST /user/auth/local/register', () => { }); }); + context('attach to facebook user', () => { + let user; + let email = 'some@email.net'; + let username = 'some-username'; + let password = 'some-password'; + beforeEach(async () => { + user = await generateUser(); + }); + it('checks onlySocialAttachLocal', async () => { + await expect(user.post('/user/auth/local/register', { + email, + username, + password, + confirmPassword: password, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlySocialAttachLocal'), + }); + }); + it('succeeds', async () => { + await user.update({ 'auth.facebook.id': 'some-fb-id', 'auth.local': { ok: true } }); + await user.post('/user/auth/local/register', { + username, + email, + password, + confirmPassword: password, + }); + await user.sync(); + expect(user.auth.local.username).to.eql(username); + expect(user.auth.local.email).to.eql(email); + }); + }); + context('login is already taken', () => { let username, email, api; diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index e278d95f01..51c177f511 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -70,7 +70,7 @@ api.registerLocal = { url: '/user/auth/local/register', async handler (req, res) { let fbUser = res.locals.user; // If adding local auth to social user - // TODO check user doesn't have local auth + req.checkBody({ email: { notEmpty: {errorMessage: res.t('missingEmail')}, @@ -82,7 +82,6 @@ api.registerLocal = { equals: {options: [req.body.confirmPassword], errorMessage: res.t('passwordConfirmationMatch')}, }, }); - let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -124,7 +123,7 @@ api.registerLocal = { if (fbUser) { if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal')); - fbUser.auth.local = newUser; + fbUser.auth.local = newUser.auth.local; newUser = fbUser; } else { newUser = new User(newUser); From 0a65e6a6f1a345d17d57e00d0b453ff4be6bef31 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 24 Mar 2016 16:08:58 +0100 Subject: [PATCH 573/976] v3: port hatch, equip and feed ops --- common/locales/en/api-v3.json | 9 +- common/script/index.js | 6 + common/script/ops/equip.js | 66 +++--- common/script/ops/feed.js | 118 ++++++---- common/script/ops/hatch.js | 49 ++-- tasks/gulp-eslint.js | 3 - .../user/POST-user_equip_type_key.test.js | 42 ++++ .../user/POST-user_feed_pet_food.test.js | 45 ++++ ...POST-user_hatch_egg_hatchingPotion.test.js | 31 +++ test/common/ops/equip.js | 85 +++++++ test/common/ops/feed.js | 219 ++++++++++++++++++ test/common/ops/hatch.js | 143 ++++++++++++ test/common_old/user.ops.equip.test.js | 102 -------- test/common_old/user.ops.hatch.js | 129 ----------- website/src/controllers/api-v3/user.js | 72 ++++++ 15 files changed, 783 insertions(+), 336 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_equip_type_key.test.js create mode 100644 test/api/v3/integration/user/POST-user_feed_pet_food.test.js create mode 100644 test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js create mode 100644 test/common/ops/equip.js create mode 100644 test/common/ops/feed.js create mode 100644 test/common/ops/hatch.js delete mode 100644 test/common_old/user.ops.equip.test.js delete mode 100644 test/common_old/user.ops.hatch.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a0049ec3a5..e9dcd2d86d 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -110,7 +110,12 @@ "invalidAttribute": "\"<%= attr %>\" is not a valid attribute.", "notEnoughAttrPoints": "You don't have enough attribute points.", "missingKeyParam": "\"req.params.key\" is required.", - "mysterySetNotFound": "Mystery set not found, or set already owned", + "mysterySetNotFound": "Mystery set not found, or set already owned.", "itemNotFound": "Item \"<%= key %>\" not found.", - "cannoyBuyItem": "You can't buy this item" + "cannoyBuyItem": "You can't buy this item.", + "missingTypeKeyEquip": "\"key\" and \"type\" are required parameters.", + "missingPetFoodFeed": "\"pet\" and \"food\" are required parameters.", + "invalidPetName": "Invalid pet name supplied.", + "missingEggHatchingPotionHatch": "\"egg\" and \"hatchingPotion\" are required parameters.", + "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'." } diff --git a/common/script/index.js b/common/script/index.js index 9d2245e662..2e05604be3 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -109,6 +109,9 @@ import buyMysterySet from './ops/buyMysterySet'; import buyQuest from './ops/buyQuest'; import buySpecialSpell from './ops/buySpecialSpell'; import allocateNow from './ops/allocateNow'; +import hatch from './ops/hatch'; +import feed from './ops/feed'; +import equip from './ops/equip'; api.ops = { scoreTask, @@ -119,6 +122,9 @@ api.ops = { buySpecialSpell, buyQuest, allocateNow, + hatch, + feed, + equip, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/equip.js b/common/script/ops/equip.js index 8c07a364b1..e402b614ff 100644 --- a/common/script/ops/equip.js +++ b/common/script/ops/equip.js @@ -1,52 +1,66 @@ import content from '../content/index'; import i18n from '../i18n'; +import handleTwoHanded from '../fns/handleTwoHanded'; +import { + NotFound, + BadRequest, +} from '../libs/errors'; +import _ from 'lodash'; + +module.exports = function equip (user, req = {}) { + // Being type a parameter followed by another parameter + // when using the API it must be passes specifically in the URL, it's won't default to equipped + let type = _.get(req, 'params.type', 'equipped'); + let key = _.get(req, 'params.key'); + + if (!key || !type) throw new BadRequest(i18n.t('missingTypeKeyEquip', req.language)); + if (['mount', 'pet', 'costume', 'equipped'].indexOf(type) === -1) { + throw new BadRequest(i18n.t('invalidTypeEquip', req.language)); + } + + let message; -module.exports = function(user, req, cb) { - var item, key, message, ref, type; - ref = [req.params.type || 'equipped', req.params.key], type = ref[0], key = ref[1]; switch (type) { case 'mount': if (!user.items.mounts[key]) { - return typeof cb === "function" ? cb({ - code: 404, - message: ":You do not own this mount." - }) : void 0; + throw new NotFound(i18n.t('mountNotOwned', req.language)); } + user.items.currentMount = user.items.currentMount === key ? '' : key; break; + case 'pet': if (!user.items.pets[key]) { - return typeof cb === "function" ? cb({ - code: 404, - message: ":You do not own this pet." - }) : void 0; + throw new NotFound(i18n.t('petNotOwned', req.language)); } + user.items.currentPet = user.items.currentPet === key ? '' : key; break; + case 'costume': case 'equipped': - item = content.gear.flat[key]; if (!user.items.gear.owned[key]) { - return typeof cb === "function" ? cb({ - code: 404, - message: ":You do not own this gear." - }) : void 0; + throw new NotFound(i18n.t('gearNotOwned', req.language)); } + + let item = content.gear.flat[key]; + if (user.items.gear[type][item.type] === key) { - user.items.gear[type][item.type] = item.type + "_base_0"; + user.items.gear[type][item.type] = `${item.type}_base_0`; message = i18n.t('messageUnEquipped', { - itemText: item.text(req.language) + itemText: item.text(req.language), }, req.language); } else { user.items.gear[type][item.type] = item.key; - message = user.fns.handleTwoHanded(item, type, req); - } - if (typeof user.markModified === "function") { - user.markModified("items.gear." + type); + message = handleTwoHanded(user, item, type, req); } + break; } - return typeof cb === "function" ? cb((message ? { - code: 200, - message: message - } : null), user.items) : void 0; + + let res = { + data: user.items, + }; + if (message) res.message = message; + + return res; }; diff --git a/common/script/ops/feed.js b/common/script/ops/feed.js index 3c9b0f87fd..e4f2fa849f 100644 --- a/common/script/ops/feed.js +++ b/common/script/ops/feed.js @@ -1,78 +1,96 @@ import content from '../content/index'; import i18n from '../i18n'; +import _ from 'lodash'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../libs/errors'; + +function evolve (user, pet, petDisplayName, req) { + user.items.pets[pet] = -1; + user.items.mounts[pet] = true; + + if (pet === user.items.currentPet) { + user.items.currentPet = ''; + } + + return i18n.t('messageEvolve', { + egg: petDisplayName, + }, req.language); +} + +module.exports = function feed (user, req = {}) { + let pet = _.get(req, 'params.pet'); + let foodK = _.get(req, 'params.food'); + + if (!pet || !foodK) throw new BadRequest(i18n.t('missingPetFoodFeed')); + + if (pet.indexOf('-') === -1) { + throw new BadRequest(i18n.t('invalidPetName', req.language)); + } + + let food = content.food[foodK]; + if (!food) { + throw new NotFound(i18n.t('messageFoodNotFound', req.language)); + } + + let userPets = user.items.pets; -module.exports = function(user, req, cb) { - var egg, eggText, evolve, food, message, pet, petDisplayName, potion, potionText, ref, ref1, ref2, userPets; - ref = req.params, pet = ref.pet, food = ref.food; - food = content.food[food]; - ref1 = pet.split('-'), egg = ref1[0], potion = ref1[1]; - userPets = user.items.pets; - potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; - eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - petDisplayName = i18n.t('petName', { - potion: potionText, - egg: eggText - }); if (!userPets[pet]) { - return typeof cb === "function" ? cb({ - code: 404, - message: i18n.t('messagePetNotFound', req.language) - }) : void 0; + throw new NotFound(i18n.t('messagePetNotFound', req.language)); } - if (!((ref2 = user.items.food) != null ? ref2[food.key] : void 0)) { - return typeof cb === "function" ? cb({ - code: 404, - message: i18n.t('messageFoodNotFound', req.language) - }) : void 0; + + let [egg, potion] = pet.split('-'); + + let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; + let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; + + let petDisplayName = i18n.t('petName', { + potion: potionText, + egg: eggText, + }, req.language); + + if (!user.items.food[food.key]) { + throw new NotFound(i18n.t('messageFoodNotFound', req.language)); } + if (content.specialPets[pet]) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('messageCannotFeedPet', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t('messageCannotFeedPet', req.language)); } + if (user.items.mounts[pet]) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('messageAlreadyMount', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t('messageAlreadyMount', req.language)); } - message = ''; - evolve = function() { - userPets[pet] = -1; - user.items.mounts[pet] = true; - if (pet === user.items.currentPet) { - user.items.currentPet = ""; - } - return message = i18n.t('messageEvolve', { - egg: petDisplayName - }, req.language); - }; + + let message; + if (food.key === 'Saddle') { - evolve(); + message = evolve(user, pet, petDisplayName, req); } else { if (food.target === potion || content.hatchingPotions[potion].premium) { userPets[pet] += 5; message = i18n.t('messageLikesFood', { egg: petDisplayName, - foodText: food.text(req.language) + foodText: food.text(req.language), }, req.language); } else { userPets[pet] += 2; message = i18n.t('messageDontEnjoyFood', { egg: petDisplayName, - foodText: food.text(req.language) + foodText: food.text(req.language), }, req.language); } + if (userPets[pet] >= 50 && !user.items.mounts[pet]) { - evolve(); + message = evolve(user, pet, petDisplayName, req); } } + user.items.food[food.key]--; - return typeof cb === "function" ? cb({ - code: 200, - message: message - }, { - value: userPets[pet] - }) : void 0; + + return { + data: userPets[pet], + message, + }; }; diff --git a/common/script/ops/hatch.js b/common/script/ops/hatch.js index 6292e24bcb..fee84fd4a8 100644 --- a/common/script/ops/hatch.js +++ b/common/script/ops/hatch.js @@ -1,39 +1,40 @@ import content from '../content/index'; import i18n from '../i18n'; +import _ from 'lodash'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../libs/errors'; + +module.exports = function hatch (user, req = {}) { + let egg = _.get(req, 'params.egg'); + let hatchingPotion = _.get(req, 'params.hatchingPotion'); -module.exports = function(user, req, cb) { - var egg, hatchingPotion, pet, ref; - ref = req.params, egg = ref.egg, hatchingPotion = ref.hatchingPotion; if (!(egg && hatchingPotion)) { - return typeof cb === "function" ? cb({ - code: 400, - message: "Please specify query.egg & query.hatchingPotion" - }) : void 0; + throw new BadRequest(i18n.t('missingEggHatchingPotionHatch', req.language)); } + if (!(user.items.eggs[egg] > 0 && user.items.hatchingPotions[hatchingPotion] > 0)) { - return typeof cb === "function" ? cb({ - code: 403, - message: i18n.t('messageMissingEggPotion', req.language) - }) : void 0; + throw new NotFound(i18n.t('messageMissingEggPotion', req.language)); } + if (content.hatchingPotions[hatchingPotion].premium && !content.dropEggs[egg]) { - return typeof cb === "function" ? cb({ - code: 403, - message: i18n.t('messageInvalidEggPotionCombo', req.language) - }) : void 0; + throw new BadRequest(i18n.t('messageInvalidEggPotionCombo', req.language)); } - pet = egg + "-" + hatchingPotion; + + let pet = `${egg}-${hatchingPotion}`; + if (user.items.pets[pet] && user.items.pets[pet] > 0) { - return typeof cb === "function" ? cb({ - code: 403, - message: i18n.t('messageAlreadyPet', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t('messageAlreadyPet', req.language)); } + user.items.pets[pet] = 5; user.items.eggs[egg]--; user.items.hatchingPotions[hatchingPotion]--; - return typeof cb === "function" ? cb({ - code: 200, - message: i18n.t('messageHatched', req.language) - }, user.items) : void 0; + + return { + message: i18n.t('messageHatched', req.language), + data: user.items, + }; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 25a0f6a978..057e0c0fc0 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -28,11 +28,8 @@ const COMMON_FILES = [ '!./common/script/ops/deleteTask.js', '!./common/script/ops/deleteWebhook.js', '!./common/script/ops/disableClasses.js', - '!./common/script/ops/equip.js', - '!./common/script/ops/feed.js', '!./common/script/ops/getTag.js', '!./common/script/ops/getTags.js', - '!./common/script/ops/hatch.js', '!./common/script/ops/hourglassPurchase.js', '!./common/script/ops/openMysteryItem.js', '!./common/script/ops/purchase.js', diff --git a/test/api/v3/integration/user/POST-user_equip_type_key.test.js b/test/api/v3/integration/user/POST-user_equip_type_key.test.js new file mode 100644 index 0000000000..53e8dbfe1f --- /dev/null +++ b/test/api/v3/integration/user/POST-user_equip_type_key.test.js @@ -0,0 +1,42 @@ +/* eslint-disable camelcase */ + +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/equip/:type/:key', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('equip an item', async () => { + await user.update({ + 'items.gear.owned': { + weapon_warrior_0: true, + weapon_warrior_1: true, + weapon_warrior_2: true, + weapon_wizard_1: true, + weapon_wizard_2: true, + shield_base_0: true, + shield_warrior_1: true, + }, + 'items.gear.equipped': { + weapon: 'weapon_warrior_0', + shield: 'shield_base_0', + }, + 'stats.gp': 200, + }); + + await user.post(`/user/equip/equipped/weapon_warrior_1`); + let res = await user.post(`/user/equip/equipped/weapon_warrior_2`); + await user.sync(); + + expect(res).to.eql({ + data: JSON.parse(JSON.stringify(user.items)), + }); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_feed_pet_food.test.js b/test/api/v3/integration/user/POST-user_feed_pet_food.test.js new file mode 100644 index 0000000000..6fa3275196 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_feed_pet_food.test.js @@ -0,0 +1,45 @@ +/* eslint-disable camelcase */ + +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import content from '../../../../../common/script/content'; + +describe('POST /user/feed/:pet/:food', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('does not enjoy the food', async () => { + await user.update({ + 'items.pets.Wolf-Base': 5, + 'items.food.Milk': 2, + }); + + let food = content.food.Milk; + let [egg, potion] = 'Wolf-Base'.split('-'); + let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; + let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; + + let res = await user.post(`/user/feed/Wolf-Base/Milk`); + await user.sync(); + expect(res).to.eql({ + data: user.items.pets['Wolf-Base'], + message: t('messageDontEnjoyFood', { + egg: t('petName', { + potion: potionText, + egg: eggText, + }), + foodText: food.text(), + }), + }); + + expect(user.items.food.Milk).to.equal(1); + expect(user.items.pets['Wolf-Base']).to.equal(7); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js b/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js new file mode 100644 index 0000000000..e2a5e330f5 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js @@ -0,0 +1,31 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/hatch/:egg/:hatchingPotion', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('hatch a new pet', async () => { + await user.update({ + 'items.eggs.Wolf': 1, + 'items.hatchingPotions.Base': 1, + }); + let res = await user.post(`/user/hatch/Wolf/Base`); + await user.sync(); + expect(user.items.pets['Wolf-Base']).to.equal(5); + expect(user.items.eggs.Wolf).to.equal(0); + expect(user.items.hatchingPotions.Base).to.equal(0); + + expect(res).to.eql({ + message: t('messageHatched'), + data: JSON.parse(JSON.stringify(user.items)), + }); + }); +}); diff --git a/test/common/ops/equip.js b/test/common/ops/equip.js new file mode 100644 index 0000000000..77110f7f40 --- /dev/null +++ b/test/common/ops/equip.js @@ -0,0 +1,85 @@ +/* eslint-disable camelcase */ +import equip from '../../../common/script/ops/equip'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import content from '../../../common/script/content/index'; + +describe('shared.ops.equip', () => { + let user; + + beforeEach(() => { + user = generateUser({ + items: { + gear: { + owned: { + weapon_warrior_0: true, + weapon_warrior_1: true, + weapon_warrior_2: true, + weapon_wizard_1: true, + weapon_wizard_2: true, + shield_base_0: true, + shield_warrior_1: true, + }, + equipped: { + weapon: 'weapon_warrior_0', + shield: 'shield_base_0', + }, + }, + }, + stats: {gp: 200}, + }); + }); + + context('Gear', () => { + it('should not send a message if a weapon is equipped while only having zero or one weapons equipped', () => { + equip(user, {params: {key: 'weapon_warrior_1'}}); + + // one-handed to one-handed + let res = equip(user, {params: {key: 'weapon_warrior_2'}}); + expect(res.message).to.not.exists; + + // one-handed to two-handed + res = equip(user, {params: {key: 'weapon_wizard_1'}}); + expect(res.message).to.not.exists; + + // two-handed to two-handed + res = equip(user, {params: {key: 'weapon_wizard_2'}}); + expect(res.message).to.not.exists; + + // two-handed to one-handed + res = equip(user, {params: {key: 'weapon_warrior_2'}}); + expect(res.message).to.not.exists; + }); + + it('should send messages if equipping a two-hander causes the off-hander to be unequipped', () => { + equip(user, {params: {key: 'weapon_warrior_1'}}); + equip(user, {params: {key: 'shield_warrior_1'}}); + + // equipping two-hander + let res = equip(user, {params: {key: 'weapon_wizard_1'}}); + let weapon = content.gear.flat.weapon_wizard_1; + let item = content.gear.flat.shield_warrior_1; + + expect(res).to.eql({ + message: i18n.t('messageTwoHandedEquip', {twoHandedText: weapon.text(), offHandedText: item.text()}), + data: user.items, + }); + }); + + it('should send messages if equipping an off-hand item causes a two-handed weapon to be unequipped', () => { + // equipping two-hander + equip(user, {params: {key: 'weapon_wizard_1'}}); + let weapon = content.gear.flat.weapon_wizard_1; + let shield = content.gear.flat.shield_warrior_1; + + let res = equip(user, {params: {key: 'shield_warrior_1'}}); + + expect(res).to.eql({ + message: i18n.t('messageTwoHandedUnequip', {twoHandedText: weapon.text(), offHandedText: shield.text()}), + data: user.items, + }); + }); + }); +}); diff --git a/test/common/ops/feed.js b/test/common/ops/feed.js new file mode 100644 index 0000000000..a2cf8395b8 --- /dev/null +++ b/test/common/ops/feed.js @@ -0,0 +1,219 @@ +import feed from '../../../common/script/ops/feed'; +import content from '../../../common/script/content'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.feed', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + context('failure conditions', () => { + it('does not allow feeding without specifying pet and food', () => { + try { + feed(user); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('missingPetFoodFeed')); + } + }); + + it('does not allow feeding if pet name format is invalid', () => { + try { + feed(user, {params: {pet: 'invalid', food: 'food'}}); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidPetName')); + } + }); + + it('does not allow feeding if food does not exists', () => { + try { + feed(user, {params: {pet: 'valid-pet', food: 'invalid food name'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotFound); + expect(err.message).to.equal(i18n.t('messageFoodNotFound')); + } + }); + + it('does not allow feeding if pet is not owned', () => { + try { + feed(user, {params: {pet: 'not-owned', food: 'Meat'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotFound); + expect(err.message).to.equal(i18n.t('messagePetNotFound')); + } + }); + + it('does not allow feeding if food is not owned', () => { + user.items.pets['Wolf-Base'] = 5; + try { + feed(user, {params: {pet: 'Wolf-Base', food: 'Meat'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotFound); + expect(err.message).to.equal(i18n.t('messageFoodNotFound')); + } + }); + + it('does not allow feeding of special pets', () => { + user.items.pets['Wolf-Veteran'] = 5; + user.items.food.Meat = 1; + try { + feed(user, {params: {pet: 'Wolf-Veteran', food: 'Meat'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('messageCannotFeedPet')); + } + }); + + it('does not allow feeding of mounts', () => { + user.items.pets['Wolf-Base'] = -1; + user.items.mounts['Wolf-Base'] = true; + user.items.food.Meat = 1; + try { + feed(user, {params: {pet: 'Wolf-Base', food: 'Meat'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('messageAlreadyMount')); + } + }); + }); + + context('successful feeding', () => { + it('evolves the pet if the food is a Saddle', () => { + user.items.pets['Wolf-Base'] = 5; + user.items.food.Saddle = 2; + user.items.currentPet = 'Wolf-Base'; + let [egg, potion] = 'Wolf-Base'.split('-'); + + let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; + let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; + + let res = feed(user, {params: {pet: 'Wolf-Base', food: 'Saddle'}}); + expect(res).to.eql({ + data: user.items.pets['Wolf-Base'], + message: i18n.t('messageEvolve', { + egg: i18n.t('petName', { + potion: potionText, + egg: eggText, + }), + }), + }); + + expect(user.items.food.Saddle).to.equal(1); + expect(user.items.pets['Wolf-Base']).to.equal(-1); + expect(user.items.mounts['Wolf-Base']).to.equal(true); + expect(user.items.currentPet).to.equal(''); + }); + + it('enjoys the food', () => { + user.items.pets['Wolf-Base'] = 5; + user.items.food.Meat = 2; + + let food = content.food.Meat; + let [egg, potion] = 'Wolf-Base'.split('-'); + let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; + let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; + + let res = feed(user, {params: {pet: 'Wolf-Base', food: 'Meat'}}); + expect(res).to.eql({ + data: user.items.pets['Wolf-Base'], + message: i18n.t('messageLikesFood', { + egg: i18n.t('petName', { + potion: potionText, + egg: eggText, + }), + foodText: food.text(), + }), + }); + + expect(user.items.food.Meat).to.equal(1); + expect(user.items.pets['Wolf-Base']).to.equal(10); + }); + + it('enjoys the food (premium potion)', () => { + user.items.pets['Wolf-Spooky'] = 5; + user.items.food.Milk = 2; + + let food = content.food.Milk; + let [egg, potion] = 'Wolf-Spooky'.split('-'); + let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; + let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; + + let res = feed(user, {params: {pet: 'Wolf-Spooky', food: 'Milk'}}); + expect(res).to.eql({ + data: user.items.pets['Wolf-Spooky'], + message: i18n.t('messageLikesFood', { + egg: i18n.t('petName', { + potion: potionText, + egg: eggText, + }), + foodText: food.text(), + }), + }); + + expect(user.items.food.Milk).to.equal(1); + expect(user.items.pets['Wolf-Spooky']).to.equal(10); + }); + + it('does not like the food', () => { + user.items.pets['Wolf-Base'] = 5; + user.items.food.Milk = 2; + + let food = content.food.Milk; + let [egg, potion] = 'Wolf-Base'.split('-'); + let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; + let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; + + let res = feed(user, {params: {pet: 'Wolf-Base', food: 'Milk'}}); + expect(res).to.eql({ + data: user.items.pets['Wolf-Base'], + message: i18n.t('messageDontEnjoyFood', { + egg: i18n.t('petName', { + potion: potionText, + egg: eggText, + }), + foodText: food.text(), + }), + }); + + expect(user.items.food.Milk).to.equal(1); + expect(user.items.pets['Wolf-Base']).to.equal(7); + }); + + it('evolves the pet into a mount when feeding user.items.pets[pet] >= 50', () => { + user.items.pets['Wolf-Base'] = 49; + user.items.food.Milk = 2; + user.items.currentPet = 'Wolf-Base'; + + let [egg, potion] = 'Wolf-Base'.split('-'); + let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; + let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; + + let res = feed(user, {params: {pet: 'Wolf-Base', food: 'Milk'}}); + expect(res).to.eql({ + data: user.items.pets['Wolf-Base'], + message: i18n.t('messageEvolve', { + egg: i18n.t('petName', { + potion: potionText, + egg: eggText, + }), + }), + }); + + expect(user.items.food.Milk).to.equal(1); + expect(user.items.pets['Wolf-Base']).to.equal(-1); + expect(user.items.mounts['Wolf-Base']).to.equal(true); + expect(user.items.currentPet).to.equal(''); + }); + }); +}); diff --git a/test/common/ops/hatch.js b/test/common/ops/hatch.js new file mode 100644 index 0000000000..ead2a397f0 --- /dev/null +++ b/test/common/ops/hatch.js @@ -0,0 +1,143 @@ +import hatch from '../../../common/script/ops/hatch'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.hatch', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + context('Pet Hatching', () => { + context('failure conditions', () => { + it('does not allow hatching without specifying egg and potion', () => { + user.items.pets = {}; + try { + hatch(user); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('missingEggHatchingPotionHatch')); + expect(user.items.pets).to.be.empty; + } + }); + + it('does not allow hatching if user lacks specified egg', () => { + user.items.eggs.Wolf = 1; + user.items.hatchingPotions.Base = 1; + user.items.pets = {}; + try { + hatch(user, {params: {egg: 'Dragon', hatchingPotion: 'Base'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotFound); + expect(err.message).to.equal(i18n.t('messageMissingEggPotion')); + expect(user.items.pets).to.be.empty; + expect(user.items.eggs.Wolf).to.equal(1); + expect(user.items.hatchingPotions.Base).to.equal(1); + } + }); + + it('does not allow hatching if user lacks specified hatching potion', () => { + user.items.eggs.Wolf = 1; + user.items.hatchingPotions.Base = 1; + user.items.pets = {}; + try { + hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Golden'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotFound); + expect(err.message).to.equal(i18n.t('messageMissingEggPotion')); + expect(user.items.pets).to.be.empty; + expect(user.items.eggs.Wolf).to.equal(1); + expect(user.items.hatchingPotions.Base).to.equal(1); + } + }); + + it('does not allow hatching if user already owns target pet', () => { + user.items.eggs = {Wolf: 1}; + user.items.hatchingPotions = {Base: 1}; + user.items.pets = {'Wolf-Base': 10}; + try { + hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Base'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('messageAlreadyPet')); + expect(user.items.pets).to.eql({'Wolf-Base': 10}); + expect(user.items.eggs).to.eql({Wolf: 1}); + expect(user.items.hatchingPotions).to.eql({Base: 1}); + } + }); + + it('does not allow hatching quest pet egg using premium potion', () => { + user.items.eggs = {Cheetah: 1}; + user.items.hatchingPotions = {Spooky: 1}; + user.items.pets = {}; + try { + hatch(user, {params: {egg: 'Cheetah', hatchingPotion: 'Spooky'}}); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('messageInvalidEggPotionCombo')); + expect(user.items.pets).to.be.empty; + expect(user.items.eggs).to.eql({Cheetah: 1}); + expect(user.items.hatchingPotions).to.eql({Spooky: 1}); + } + }); + }); + + context('successful hatching', () => { + it('hatches a basic pet', () => { + user.items.eggs = {Wolf: 1}; + user.items.hatchingPotions = {Base: 1}; + user.items.pets = {}; + let res = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Base'}}); + expect(res.message).to.equal(i18n.t('messageHatched')); + expect(res.data).to.eql(user.items); + expect(user.items.pets).to.eql({'Wolf-Base': 5}); + expect(user.items.eggs).to.eql({Wolf: 0}); + expect(user.items.hatchingPotions).to.eql({Base: 0}); + }); + + it('hatches a quest pet', () => { + user.items.eggs = {Cheetah: 1}; + user.items.hatchingPotions = {Base: 1}; + user.items.pets = {}; + let res = hatch(user, {params: {egg: 'Cheetah', hatchingPotion: 'Base'}}); + expect(res.message).to.equal(i18n.t('messageHatched')); + expect(res.data).to.eql(user.items); + expect(user.items.pets).to.eql({'Cheetah-Base': 5}); + expect(user.items.eggs).to.eql({Cheetah: 0}); + expect(user.items.hatchingPotions).to.eql({Base: 0}); + }); + + it('hatches a premium pet', () => { + user.items.eggs = {Wolf: 1}; + user.items.hatchingPotions = {Spooky: 1}; + user.items.pets = {}; + let res = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Spooky'}}); + expect(res.message).to.equal(i18n.t('messageHatched')); + expect(res.data).to.eql(user.items); + expect(user.items.pets).to.eql({'Wolf-Spooky': 5}); + expect(user.items.eggs).to.eql({Wolf: 0}); + expect(user.items.hatchingPotions).to.eql({Spooky: 0}); + }); + + it('hatches a pet previously raised to a mount', () => { + user.items.eggs = {Wolf: 1}; + user.items.hatchingPotions = {Base: 1}; + user.items.pets = {'Wolf-Base': -1}; + let res = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Base'}}); + expect(res.message).to.eql(i18n.t('messageHatched')); + expect(res.data).to.eql(user.items); + expect(user.items.pets).to.eql({'Wolf-Base': 5}); + expect(user.items.eggs).to.eql({Wolf: 0}); + expect(user.items.hatchingPotions).to.eql({Base: 0}); + }); + }); + }); +}); diff --git a/test/common_old/user.ops.equip.test.js b/test/common_old/user.ops.equip.test.js deleted file mode 100644 index 62f7956e02..0000000000 --- a/test/common_old/user.ops.equip.test.js +++ /dev/null @@ -1,102 +0,0 @@ -/* eslint-disable camelcase */ - -import sinon from 'sinon'; // eslint-disable-line no-shadow -import {assert} from 'sinon'; -import i18n from '../../common/script/i18n'; -import shared from '../../common/script/index.js'; -import content from '../../common/script/content/index'; - -describe('user.ops.equip', () => { - let user; - let spy; - - beforeEach(() => { - user = { - items: { - gear: { - owned: { - weapon_warrior_0: true, - weapon_warrior_1: true, - weapon_warrior_2: true, - weapon_wizard_1: true, - weapon_wizard_2: true, - shield_base_0: true, - shield_warrior_1: true, - }, - equipped: { - weapon: 'weapon_warrior_0', - shield: 'shield_base_0', - }, - }, - }, - preferences: {}, - stats: {gp: 200}, - achievements: {}, - flags: {}, - }; - - shared.wrap(user); - spy = sinon.spy(); - }); - - context('Gear', () => { - it('should not send a message if a weapon is equipped while only having zero or one weapons equipped', () => { - // user.ops.equip always calls the callback, even if it isn't sending a message - // so we need to check to see if a single null message was sent. - user.ops.equip({params: {key: 'weapon_warrior_1'}}); - - // one-handed to one-handed - user.ops.equip({params: {key: 'weapon_warrior_2'}}, spy); - - assert.calledOnce(spy); - assert.calledWith(spy, null); - spy.reset(); - - // one-handed to two-handed - user.ops.equip({params: {key: 'weapon_wizard_1'}}, spy); - assert.calledOnce(spy); - assert.calledWith(spy, null); - spy.reset(); - - // two-handed to two-handed - user.ops.equip({params: {key: 'weapon_wizard_2'}}, spy); - assert.calledOnce(spy); - assert.calledWith(spy, null); - spy.reset(); - - // two-handed to one-handed - user.ops.equip({params: {key: 'weapon_warrior_2'}}, spy); - assert.calledOnce(spy); - assert.calledWith(spy, null); - spy.reset(); - }); - - it('should send messages if equipping a two-hander causes the off-hander to be unequipped', () => { - user.ops.equip({params: {key: 'weapon_warrior_1'}}); - user.ops.equip({params: {key: 'shield_warrior_1'}}); - - // equipping two-hander - user.ops.equip({params: {key: 'weapon_wizard_1'}}, spy); - let weapon = content.gear.flat.weapon_wizard_1; - let item = content.gear.flat.shield_warrior_1; - let message = i18n.t('messageTwoHandedEquip', {twoHandedText: weapon.text(null), offHandedText: item.text(null)}); - - assert.calledOnce(spy); - assert.calledWith(spy, {code: 200, message}); - }); - - it('should send messages if equipping an off-hand item causes a two-handed weapon to be unequipped', () => { - // equipping two-hander - user.ops.equip({params: {key: 'weapon_wizard_1'}}); - let weapon = content.gear.flat.weapon_wizard_1; - let shield = content.gear.flat.shield_warrior_1; - - user.ops.equip({params: {key: 'shield_warrior_1'}}, spy); - - let message = i18n.t('messageTwoHandedUnequip', {twoHandedText: weapon.text(null), offHandedText: shield.text(null)}); - - assert.calledOnce(spy); - assert.calledWith(spy, {code: 200, message}); - }); - }); -}); diff --git a/test/common_old/user.ops.hatch.js b/test/common_old/user.ops.hatch.js deleted file mode 100644 index 573103a360..0000000000 --- a/test/common_old/user.ops.hatch.js +++ /dev/null @@ -1,129 +0,0 @@ -let shared = require('../../common/script/index.js'); - -describe('user.ops.hatch', () => { - let user; - - beforeEach(() => { - user = { - items: { - eggs: {}, - hatchingPotions: {}, - pets: {}, - }, - }; - - shared.wrap(user); - }); - - context('Pet Hatching', () => { - context('failure conditions', () => { - it('does not allow hatching without specifying egg and potion', (done) => { - user.ops.hatch({params: {}}, (response) => { - expect(response.message).to.eql('Please specify query.egg & query.hatchingPotion'); - expect(user.items.pets).to.be.empty; - done(); - }); - }); - - it('does not allow hatching if user lacks specified egg', (done) => { - user.items.eggs = {Wolf: 1}; - user.items.hatchingPotions = {Base: 1}; - user.ops.hatch({params: {egg: 'Dragon', hatchingPotion: 'Base'}}, (response) => { - expect(response.message).to.eql(shared.i18n.t('messageMissingEggPotion')); - expect(user.items.pets).to.be.empty; - expect(user.items.eggs).to.eql({Wolf: 1}); - expect(user.items.hatchingPotions).to.eql({Base: 1}); - done(); - }); - }); - - it('does not allow hatching if user lacks specified hatching potion', (done) => { - user.items.eggs = {Wolf: 1}; - user.items.hatchingPotions = {Base: 1}; - user.ops.hatch({params: {egg: 'Wolf', hatchingPotion: 'Golden'}}, (response) => { - expect(response.message).to.eql(shared.i18n.t('messageMissingEggPotion')); - expect(user.items.pets).to.be.empty; - expect(user.items.eggs).to.eql({Wolf: 1}); - expect(user.items.hatchingPotions).to.eql({Base: 1}); - done(); - }); - }); - - it('does not allow hatching if user already owns target pet', (done) => { - user.items.eggs = {Wolf: 1}; - user.items.hatchingPotions = {Base: 1}; - user.items.pets = {'Wolf-Base': 10}; - user.ops.hatch({params: {egg: 'Wolf', hatchingPotion: 'Base'}}, (response) => { - expect(response.message).to.eql(shared.i18n.t('messageAlreadyPet')); - expect(user.items.pets).to.eql({'Wolf-Base': 10}); - expect(user.items.eggs).to.eql({Wolf: 1}); - expect(user.items.hatchingPotions).to.eql({Base: 1}); - done(); - }); - }); - - it('does not allow hatching quest pet egg using premium potion', (done) => { - user.items.eggs = {Cheetah: 1}; - user.items.hatchingPotions = {Spooky: 1}; - user.ops.hatch({params: {egg: 'Cheetah', hatchingPotion: 'Spooky'}}, (response) => { - expect(response.message).to.eql(shared.i18n.t('messageInvalidEggPotionCombo')); - expect(user.items.pets).to.be.empty; - expect(user.items.eggs).to.eql({Cheetah: 1}); - expect(user.items.hatchingPotions).to.eql({Spooky: 1}); - done(); - }); - }); - }); - - context('successful hatching', () => { - it('hatches a basic pet', (done) => { - user.items.eggs = {Wolf: 1}; - user.items.hatchingPotions = {Base: 1}; - user.ops.hatch({params: {egg: 'Wolf', hatchingPotion: 'Base'}}, (response) => { - expect(response.message).to.eql(shared.i18n.t('messageHatched')); - expect(user.items.pets).to.eql({'Wolf-Base': 5}); - expect(user.items.eggs).to.eql({Wolf: 0}); - expect(user.items.hatchingPotions).to.eql({Base: 0}); - done(); - }); - }); - - it('hatches a quest pet', (done) => { - user.items.eggs = {Cheetah: 1}; - user.items.hatchingPotions = {Base: 1}; - user.ops.hatch({params: {egg: 'Cheetah', hatchingPotion: 'Base'}}, (response) => { - expect(response.message).to.eql(shared.i18n.t('messageHatched')); - expect(user.items.pets).to.eql({'Cheetah-Base': 5}); - expect(user.items.eggs).to.eql({Cheetah: 0}); - expect(user.items.hatchingPotions).to.eql({Base: 0}); - done(); - }); - }); - - it('hatches a premium pet', (done) => { - user.items.eggs = {Wolf: 1}; - user.items.hatchingPotions = {Spooky: 1}; - user.ops.hatch({params: {egg: 'Wolf', hatchingPotion: 'Spooky'}}, (response) => { - expect(response.message).to.eql(shared.i18n.t('messageHatched')); - expect(user.items.pets).to.eql({'Wolf-Spooky': 5}); - expect(user.items.eggs).to.eql({Wolf: 0}); - expect(user.items.hatchingPotions).to.eql({Spooky: 0}); - done(); - }); - }); - - it('hatches a pet previously raised to a mount', (done) => { - user.items.eggs = {Wolf: 1}; - user.items.hatchingPotions = {Base: 1}; - user.items.pets = {'Wolf-Base': -1}; - user.ops.hatch({params: {egg: 'Wolf', hatchingPotion: 'Base'}}, (response) => { - expect(response.message).to.eql(shared.i18n.t('messageHatched')); - expect(user.items.pets).to.eql({'Wolf-Base': 5}); - expect(user.items.eggs).to.eql({Wolf: 0}); - expect(user.items.hatchingPotions).to.eql({Base: 0}); - done(); - }); - }); - }); - }); -}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 11bd137fa5..d00abcb8fb 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -309,4 +309,76 @@ api.buySpecialSpell = { }, }; +/** + * @api {post} /user/hatch/:egg/:hatchingPotion Hatch a pet. + * @apiVersion 3.0.0 + * @apiName UserHatch + * @apiGroup User + * + * @apiParam {string} egg The egg to use. + * @apiParam {string} hatchingPotion The hatching potion to use. + * + * @apiSuccess {Object} data `user.items` + * @apiSuccess {string} message + */ +api.hatch = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/hatch/:egg/:hatchingPotion', + async handler (req, res) { + let user = res.locals.user; + let hatchRes = common.ops.hatch(user, req); + await user.save(); + res.respond(200, hatchRes); + }, +}; + +/** + * @api {post} /user/equip/:type/:key Equip an item + * @apiVersion 3.0.0 + * @apiName UserEquip + * @apiGroup User + * + * @apiParam {string} type + * @apiParam {string} key + * + * @apiSuccess {Object} data `user.items` + * @apiSuccess {string} message Optional + */ +api.equip = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/equip/:type/:key', + async handler (req, res) { + let user = res.locals.user; + let equipRes = common.ops.equip(user, req); + await user.save(); + res.respond(200, equipRes); + }, +}; + +/** + * @api {post} /user/equip/:pet/:food Feed a pet + * @apiVersion 3.0.0 + * @apiName UserFeed + * @apiGroup User + * + * @apiParam {string} pet + * @apiParam {string} food + * + * @apiSuccess {Object} data The fed pet + * @apiSuccess {string} message + */ +api.feed = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/feed/:pet/:food', + async handler (req, res) { + let user = res.locals.user; + let feedRes = common.ops.feed(user, req); + await user.save(); + res.respond(200, feedRes); + }, +}; + module.exports = api; From 5aacd978c6dc184d7c17748b86af7d9f0f69704b Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 24 Mar 2016 18:00:47 +0000 Subject: [PATCH 574/976] api-v3-get-user-anonymized --- .../user/GET-user_anonymized.test.js | 90 +++++++++++++++++++ .../api-integration/v3/object-generators.js | 22 +++++ website/src/controllers/api-v3/auth.js | 1 - website/src/controllers/api-v3/user.js | 63 +++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/user/GET-user_anonymized.test.js diff --git a/test/api/v3/integration/user/GET-user_anonymized.test.js b/test/api/v3/integration/user/GET-user_anonymized.test.js new file mode 100644 index 0000000000..bbcaaf6249 --- /dev/null +++ b/test/api/v3/integration/user/GET-user_anonymized.test.js @@ -0,0 +1,90 @@ +import { + generateUser, + generateHabit, + generateDaily, + generateReward, +} from '../../../../helpers/api-integration/v3'; +import common from '../../../../../common'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /user/anonymized', () => { + let user; + let endpoint = '/user/anonymized'; + + before(async () => { + user = await generateUser(); + await user.update({ newMessages: ['some', 'new', 'messages'], profile: 'profile', 'purchased.plan': 'purchased plan', + contributor: 'contributor', invitations: 'invitations', 'items.special.nyeReceived': 'some', 'items.special.valentineReceived': 'some', + webhooks: 'some', 'achievements.challenges': 'some', + 'inbox.messages': [{ text: 'some text' }], + tags: [{ name: 'some name', challenge: 'some challenge' }], + }); + + await generateHabit({ userId: user._id }); + await generateHabit({ userId: user._id, text: generateUUID() }); + let daily = await generateDaily({ userId: user._id, checklist: [{ completed: false, text: 'this-text' }] }); + expect(daily.checklist[0].text.substr(0, 5)).to.not.eql('item '); + await generateReward({ userId: user._id, text: 'some text 4' }); + + expect(user.newMessages).to.exist; + expect(user.profile).to.exist; + expect(user.purchased.plan).to.exist; + expect(user.contributor).to.exist; + expect(user.invitations).to.exist; + expect(user.items.special.nyeReceived).to.exist; + expect(user.items.special.valentineReceived).to.exist; + expect(user.webhooks).to.exist; + expect(user.achievements.challenges).to.exist; + expect(user.inbox.messages[0].text).to.exist; + expect(user.inbox.messages[0].text).to.not.eql('inbox message text'); + expect(user.tags[0].name).to.exist; + expect(user.tags[0].name).to.not.eql('tag'); + expect(user.tags[0].challenge).to.not.eql('challenge'); + }); + + it('returns the authenticated user', async () => { + let returnedUser = await user.get(endpoint); + returnedUser = returnedUser.user; + expect(returnedUser._id).to.equal(user._id); + }); + + it('does not return private paths (and apiToken)', async () => { + let returnedUser = await user.get(endpoint); + let tasks2 = returnedUser.tasks; + returnedUser = returnedUser.user; + expect(returnedUser.auth.local).to.not.exist; + expect(returnedUser.apiToken).to.not.exist; + expect(returnedUser.stats.maxHealth).to.eql(common.maxHealth); + expect(returnedUser.stats.toNextLevel).to.eql(common.tnl(user.stats.lvl)); + expect(returnedUser.stats.maxMP).to.eql(30); // TODO why 30? + expect(returnedUser.newMessages).to.not.exist; + expect(returnedUser.profile).to.not.exist; + expect(returnedUser.purchased.plan).to.not.exist; + expect(returnedUser.contributor).to.not.exist; + expect(returnedUser.invitations).to.not.exist; + expect(returnedUser.items.special.nyeReceived).to.not.exist; + expect(returnedUser.items.special.valentineReceived).to.not.exist; + expect(returnedUser.webhooks).to.not.exist; + expect(returnedUser.achievements.challenges).to.not.exist; + _.forEach(returnedUser.inbox.messages, (msg) => { + expect(msg.text).to.eql('inbox message text'); + }); + _.forEach(returnedUser.tags, (tag) => { + expect(tag.name).to.eql('tag'); + expect(tag.challenge).to.eql('challenge'); + }); + // tasks + expect(tasks2).to.exist; + expect(tasks2.length).to.eql(5); // +1 because generateUser() assigns one todo + expect(tasks2[0].checklist).to.exist; + _.forEach(tasks2, (task) => { + expect(task.text).to.eql('task text'); + expect(task.notes).to.eql('task notes'); + if (task.checklist) { + _.forEach(task.checklist, (c) => { + expect(c.text.substr(0, 5)).to.eql('item '); + }); + } + }); + }); +}); diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index edd27f30ba..42de6d243c 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -5,6 +5,7 @@ import Q from 'q'; import { v4 as generateUUID } from 'uuid'; import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes'; import { requester } from '../requester'; +import * as Tasks from '../../../../website/src/models/task'; // Creates a new user and returns it // If you need the user to have specific requirements, @@ -32,6 +33,27 @@ export async function generateUser (update = {}) { return apiUser; } +export async function generateHabit (update = {}) { + let type = 'habit'; + let task = new Tasks[type](update); + await task.save({ validateBeforeSave: false }); + return task; +} + +export async function generateDaily (update = {}) { + let type = 'daily'; + let task = new Tasks[type](update); + await task.save({ validateBeforeSave: false }); + return task; +} + +export async function generateReward (update = {}) { + let type = 'reward'; + let task = new Tasks[type](update); + await task.save({ validateBeforeSave: false }); + return task; +} + // Generates a new group. Requires a user object, which // will will become the groups leader. Takes a details argument // for the initial group creation and an update argument which diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index fc17660c7d..0912735aa4 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -82,7 +82,6 @@ api.registerLocal = { equals: {options: [req.body.confirmPassword], errorMessage: res.t('passwordConfirmationMatch')}, }, }); - let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 11bd137fa5..e16537a9ee 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -41,6 +41,69 @@ api.getUser = { }, }; +function _cleanChecklist (task) { + let checklistIndex = 0; + _.forEach(task.checklist, (c) => { + c.text = `item ${checklistIndex++}`; + }); +} + +/** + * @api {get} /user/anonymized + * @apiVersion 3.0.0 + * @apiName UserGetAnonymized + * @apiGroup User + * @apiSuccess {Object} object The object { user, tasks } + **/ +api.getUserAnonymized = { + method: 'GET', + middlewares: [authWithHeaders(), cron], + url: '/user/anonymized', + async handler (req, res) { + let user = res.locals.user.toJSON(); + user.stats.toNextLevel = common.tnl(user.stats.lvl); + user.stats.maxHealth = common.maxHealth; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; + + delete user.apiToken; + if (user.auth) { + delete user.auth.local; + delete user.auth.facebook; + } + delete user.newMessages; + delete user.profile; + delete user.purchased.plan; + delete user.contributor; + delete user.invitations; + delete user.items.special.nyeReceived; + delete user.items.special.valentineReceived; + delete user.webhooks; + delete user.achievements.challenges; + + _.forEach(user.inbox.messages, (msg) => { + msg.text = 'inbox message text'; + }); + _.forEach(user.tags, (tag) => { + tag.name = 'tag'; + tag.challenge = 'challenge'; + }); + + let query = { userId: user._id, $or: [{ type: 'todo', completed: false }, + { type: { $in: ['habit', 'daily', 'reward'] } }], + }; + let tasks = await Tasks.Task.find(query).exec(); + _.forEach(tasks, (task) => { + task.text = 'task text'; + task.notes = 'task notes'; + if (task.type === 'todo' || task.type === 'daily') { + _cleanChecklist(task); + } + }); + + return res.respond(200, { user, tasks }); + }, +}; + const partyMembersFields = 'profile.name stats achievements items.special'; /** From 7f65707ee4d3afe2b06b31fb413fa5a941b9fc8a Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 20 Mar 2016 14:08:08 -0500 Subject: [PATCH 575/976] Ported user delete route and added initial tests --- common/locales/en/api-v3.json | 3 +- .../v3/integration/user/DELETE-user.test.js | 130 ++++++++++++++++++ website/src/controllers/api-v3/groups.js | 38 +---- website/src/controllers/api-v3/user.js | 47 ++++++- website/src/models/group.js | 44 ++++++ 5 files changed, 226 insertions(+), 36 deletions(-) create mode 100644 test/api/v3/integration/user/DELETE-user.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index e9dcd2d86d..788672975c 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -117,5 +117,6 @@ "missingPetFoodFeed": "\"pet\" and \"food\" are required parameters.", "invalidPetName": "Invalid pet name supplied.", "missingEggHatchingPotionHatch": "\"egg\" and \"hatchingPotion\" are required parameters.", - "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'." + "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'.", + "cannotDeleteActiveAccount": "You have an active subscription, cancel your plan before deleting your account." } diff --git a/test/api/v3/integration/user/DELETE-user.test.js b/test/api/v3/integration/user/DELETE-user.test.js new file mode 100644 index 0000000000..3536bd8a7a --- /dev/null +++ b/test/api/v3/integration/user/DELETE-user.test.js @@ -0,0 +1,130 @@ +import { + checkExistence, + createAndPopulateGroup, + generateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { find } from 'lodash'; + +describe('DELETE /user', () => { + let user; + + beforeEach(async () => { + user = await generateUser({balance: 10}); + }); + + it('user has active subscription', async () => { + let userWithSubscription = await generateUser({'purchased.plan.customerId': 'fake-customer-id'}); + + await expect(userWithSubscription.del('/user')).to.be.rejected.and.to.eventually.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cannotDeleteActiveAccount'), + }); + }); + + it('deletes the user', async () => { + await user.del('/user'); + await expect(checkExistence('users', user._id)).to.eventually.eql(false); + }); + + context('last member of a party', () => { + let party; + + beforeEach(async () => { + party = await generateGroup(user, { + type: 'party', + privacy: 'private', + }); + }); + + it('deletes party when user is the only member', async () => { + await user.del('/user'); + await expect(checkExistence('party', party._id)).to.eventually.eql(false); + }); + }); + + context('last member of a private guild', () => { + let privateGuild; + + beforeEach(async () => { + privateGuild = await generateGroup(user, { + type: 'guild', + privacy: 'private', + }); + }); + + it('deletes guild when user is the only member', async () => { + await user.del('/user'); + await expect(checkExistence('groups', privateGuild._id)).to.eventually.eql(false); + }); + }); + + context('groups user is leader of', () => { + let guild, oldLeader, newLeader; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + guild = group; + newLeader = members[0]; + oldLeader = groupLeader; + }); + + it('chooses new group leader for any group user was the leader of', async () => { + await oldLeader.del('/user'); + + let updatedGuild = await newLeader.get(`/groups/${guild._id}`); + + expect(updatedGuild.leader).to.exist; + expect(updatedGuild.leader._id).to.not.eql(oldLeader._id); + }); + }); + + context('groups user is a part of', () => { + let group1, group2, userToDelete, otherUser; + + beforeEach(async () => { + userToDelete = await generateUser({balance: 10}); + + group1 = await generateGroup(userToDelete, { + type: 'guild', + privacy: 'public', + }); + + let {group, members} = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 3, + }); + + group2 = group; + otherUser = members[0]; + + await userToDelete.post(`/groups/${group2._id}/join`); + }); + + it('removes user from all groups user was a part of', async () => { + await userToDelete.del('/user'); + + let updatedGroup1Members = await otherUser.get(`/groups/${group1._id}/members`); + let updatedGroup2Members = await otherUser.get(`/groups/${group2._id}/members`); + let userInGroup = find(updatedGroup2Members, (member) => { + return member._id === userToDelete._id; + }); + + expect(updatedGroup1Members).to.be.empty; + expect(updatedGroup2Members).to.not.be.empty; + expect(userInGroup).to.not.exist; + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 7850236ead..1430f1773f 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -102,42 +102,11 @@ api.getGroups = { let types = req.query.type.split(','); let groupFields = basicGroupFields.concat('description memberCount balance'); let sort = '-memberCount'; - let queries = []; - types.forEach(type => { - switch (type) { - case 'party': - queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields})); - break; - case 'privateGuilds': - queries.push(Group.find({ - type: 'guild', - privacy: 'private', - _id: {$in: user.guilds}, - }).select(groupFields).sort(sort).exec()); - break; - case 'publicGuilds': - queries.push(Group.find({ - type: 'guild', - privacy: 'public', - }).select(groupFields).sort(sort).exec()); // TODO use lean? - break; - case 'tavern': - if (types.indexOf('publicGuilds') === -1) { - queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields})); - } - break; - } - }); + let results = await Group.getGroups({user, types, groupFields, sort}); // If no valid value for type was supplied, return an error - if (queries.length === 0) throw new BadRequest(res.t('groupTypesRequired')); - - // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 - let results = _.reduce(await Q.all(queries), (previousValue, currentValue) => { - if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array - return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue - }, []); + if (results.length === 0) throw new BadRequest(res.t('groupTypesRequired')); res.respond(200, results); }, @@ -170,7 +139,8 @@ api.getGroup = { group = Group.toJSONCleanChat(group, user); // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - group.leader = (await User.findById(group.leader).select(nameFields).exec()).toJSON({minimize: true}); + let leader = await User.findById(group.leader).select(nameFields).exec(); + if (leader) group.leader = leader.toJSON({minimize: true}); res.respond(200, group); }, diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index e0b5b44312..a0b6262368 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -7,10 +7,14 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import * as Tasks from '../../models/task'; -import { model as Group } from '../../models/group'; +import { + basicFields as basicGroupFields, + model as Group, +} from '../../models/group'; import { model as User } from '../../models/user'; import Q from 'q'; import _ from 'lodash'; +import * as firebase from '../../libs/api-v3/firebase'; let api = {}; @@ -41,6 +45,47 @@ api.getUser = { }, }; +/** + * @api {delete} /user DELETE an authenticated user's profile + * @apiVersion 3.0.0 + * @apiName UserDelete + * @apiGroup User + * + * @apiSuccess {} object An empty object + */ +api.deleteUser = { + method: 'DELETE', + middlewares: [authWithHeaders(), cron], + url: '/user', + async handler (req, res) { + let user = res.locals.user; + let plan = user.purchased.plan; + + if (plan && plan.customerId && !plan.dateTerminated) { + throw new NotAuthorized(res.t('cannotDeleteActiveAccount')); + } + + let types = ['party', 'publicGuilds', 'privateGuilds']; + // @TODO: The group leave route doesn't work unless it has these fields. We should probably force the group to get these + let groupFields = basicGroupFields.concat(' leader memberCount'); + let populateLeader = true; + + let groupsUserIsMemberOf = await Group.getGroups({user, types, groupFields, populateLeader}); + + let groupLeavePromises = groupsUserIsMemberOf.map((group) => { + return group.leave(user, 'remove-all'); + }); + + await Q.all(groupLeavePromises); + + await user.remove(); + + res.respond(200, {}); + + firebase.deleteUser(user._id); + }, +}; + const partyMembersFields = 'profile.name stats achievements items.special'; /** diff --git a/website/src/models/group.js b/website/src/models/group.js index 9cf30e582e..57c0d7405d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -151,6 +151,50 @@ schema.statics.getGroup = async function getGroup (options = {}) { return group; }; +schema.statics.getGroups = async function getGroups (options = {}) { + let {user, types, groupFields = basicFields, sort = '-memberCount', populateLeader = false} = options; + let queries = []; + + types.forEach(type => { + switch (type) { + case 'party': + queries.push(this.getGroup({user, groupId: 'party', fields: groupFields, populateLeader})); + break; + case 'privateGuilds': + let privateGroupQuery = this.find({ + type: 'guild', + privacy: 'private', + _id: {$in: user.guilds}, + }).select(groupFields); + if (populateLeader === true) privateGroupQuery.populate('leader', nameFields); + privateGroupQuery.sort(sort).exec(); + queries.push(privateGroupQuery); + break; + case 'publicGuilds': + let publicGroupQuery = this.find({ + type: 'guild', + privacy: 'public', + }).select(groupFields); + if (populateLeader === true) publicGroupQuery.populate('leader', nameFields); + publicGroupQuery.sort(sort).exec(); + queries.push(publicGroupQuery); // TODO use lean? + break; + case 'tavern': + if (types.indexOf('publicGuilds') === -1) { + queries.push(this.getGroup({user, groupId: 'habitrpg', fields: groupFields})); + } + break; + } + }); + + let groupsArray = _.reduce(await Q.all(queries), (previousValue, currentValue) => { + if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array + return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue + }, []); + + return groupsArray; +}; + // When converting to json remove chat messages with more than 1 flag and remove all flags info // unless the user is an admin // Not putting into toJSON because there we can't access user From 075b31c075a1b5d2c4a31e685d0aeece8f873307 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 25 Mar 2016 08:31:49 -0500 Subject: [PATCH 576/976] Added password requirement --- .../v3/integration/user/DELETE-user.test.js | 37 +++++++++++++++---- .../auth/PUT-user_update_username.test.js | 1 - website/src/controllers/api-v3/user.js | 16 +++++++- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/test/api/v3/integration/user/DELETE-user.test.js b/test/api/v3/integration/user/DELETE-user.test.js index 3536bd8a7a..69e928e343 100644 --- a/test/api/v3/integration/user/DELETE-user.test.js +++ b/test/api/v3/integration/user/DELETE-user.test.js @@ -9,15 +9,28 @@ import { find } from 'lodash'; describe('DELETE /user', () => { let user; + let password = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js beforeEach(async () => { user = await generateUser({balance: 10}); }); - it('user has active subscription', async () => { + it('returns an errors if password is wrong', async () => { + await expect(user.del('/user', { + password: 'wrong-password', + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); + + it('returns an error if user has active subscription', async () => { let userWithSubscription = await generateUser({'purchased.plan.customerId': 'fake-customer-id'}); - await expect(userWithSubscription.del('/user')).to.be.rejected.and.to.eventually.eql({ + await expect(userWithSubscription.del('/user', { + password, + })).to.be.rejected.and.to.eventually.eql({ code: 401, error: 'NotAuthorized', message: t('cannotDeleteActiveAccount'), @@ -25,7 +38,9 @@ describe('DELETE /user', () => { }); it('deletes the user', async () => { - await user.del('/user'); + await user.del('/user', { + password, + }); await expect(checkExistence('users', user._id)).to.eventually.eql(false); }); @@ -40,7 +55,9 @@ describe('DELETE /user', () => { }); it('deletes party when user is the only member', async () => { - await user.del('/user'); + await user.del('/user', { + password, + }); await expect(checkExistence('party', party._id)).to.eventually.eql(false); }); }); @@ -56,7 +73,9 @@ describe('DELETE /user', () => { }); it('deletes guild when user is the only member', async () => { - await user.del('/user'); + await user.del('/user', { + password, + }); await expect(checkExistence('groups', privateGuild._id)).to.eventually.eql(false); }); }); @@ -79,7 +98,9 @@ describe('DELETE /user', () => { }); it('chooses new group leader for any group user was the leader of', async () => { - await oldLeader.del('/user'); + await oldLeader.del('/user', { + password, + }); let updatedGuild = await newLeader.get(`/groups/${guild._id}`); @@ -114,7 +135,9 @@ describe('DELETE /user', () => { }); it('removes user from all groups user was a part of', async () => { - await userToDelete.del('/user'); + await userToDelete.del('/user', { + password, + }); let updatedGroup1Members = await otherUser.get(`/groups/${group1._id}/members`); let updatedGroup2Members = await otherUser.get(`/groups/${group2._id}/members`); diff --git a/test/api/v3/integration/user/auth/PUT-user_update_username.test.js b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js index c61e85ab1e..372248db84 100644 --- a/test/api/v3/integration/user/auth/PUT-user_update_username.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js @@ -50,7 +50,6 @@ describe('PUT /user/auth/update-username', async () => { }); }); - it('prevents social-only user from changing username', async () => { let socialUser = await generateUser({ 'auth.local': { ok: true } }); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index a0b6262368..6d26304944 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -15,6 +15,7 @@ import { model as User } from '../../models/user'; import Q from 'q'; import _ from 'lodash'; import * as firebase from '../../libs/api-v3/firebase'; +import * as passwordUtils from '../../libs/api-v3/password'; let api = {}; @@ -61,6 +62,18 @@ api.deleteUser = { let user = res.locals.user; let plan = user.purchased.plan; + req.checkBody({ + password: { + notEmpty: {errorMessage: res.t('missingPassword')}, + }, + }); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); + if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); + if (plan && plan.customerId && !plan.dateTerminated) { throw new NotAuthorized(res.t('cannotDeleteActiveAccount')); } @@ -68,9 +81,8 @@ api.deleteUser = { let types = ['party', 'publicGuilds', 'privateGuilds']; // @TODO: The group leave route doesn't work unless it has these fields. We should probably force the group to get these let groupFields = basicGroupFields.concat(' leader memberCount'); - let populateLeader = true; - let groupsUserIsMemberOf = await Group.getGroups({user, types, groupFields, populateLeader}); + let groupsUserIsMemberOf = await Group.getGroups({user, types, groupFields}); let groupLeavePromises = groupsUserIsMemberOf.map((group) => { return group.leave(user, 'remove-all'); From e9a9079c464d1e6c82a413841f5193bec8f8fd49 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 21 Mar 2016 16:02:35 -0500 Subject: [PATCH 577/976] Ported sendPrivateMessage route and added initial tests --- common/locales/en/api-v3.json | 6 +- .../members/POST-send_private_message.test.js | 171 ++++++++++++++++++ website/src/controllers/api-v3/members.js | 54 ++++++ website/src/models/user.js | 26 +++ 4 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/members/POST-send_private_message.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index e9dcd2d86d..2a1cf8319c 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -117,5 +117,9 @@ "missingPetFoodFeed": "\"pet\" and \"food\" are required parameters.", "invalidPetName": "Invalid pet name supplied.", "missingEggHatchingPotionHatch": "\"egg\" and \"hatchingPotion\" are required parameters.", - "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'." + "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'.", + "cannoyBuyItem": "You can't buy this item", + "messageRequired": "A message is required.", + "toUserIDRequired": "A toUserId is required", + "notAuthorizedToSendMessageToThisUser": "Can't send message to this user." } diff --git a/test/api/v3/integration/members/POST-send_private_message.test.js b/test/api/v3/integration/members/POST-send_private_message.test.js new file mode 100644 index 0000000000..013f741708 --- /dev/null +++ b/test/api/v3/integration/members/POST-send_private_message.test.js @@ -0,0 +1,171 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /send-private-message', () => { + let userToSendMessage; + let messageToSend = { message: 'Test Private Message' }; + + beforeEach(async () => { + userToSendMessage = await generateUser(); + }); + + it('returns error when message is not provided', async () => { + await expect(userToSendMessage.post('/send-private-message')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns error when toUserId is not provided', async () => { + await expect(userToSendMessage.post('/send-private-message', { + message: messageToSend, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns error when to user is not found', async () => { + await expect(userToSendMessage.post('/send-private-message', { + message: messageToSend, + toUserId: generateUUID(), + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userNotFound'), + }); + }); + + it('returns error when to user has blocked the sender', async () => { + let receiver = await generateUser({'inbox.blocks': [userToSendMessage._id]}); + + await expect(userToSendMessage.post('/send-private-message', { + message: messageToSend, + toUserId: receiver._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notAuthorizedToSendMessageToThisUser'), + }); + }); + + it('returns error when sender has blocked to user', async () => { + let receiver = await generateUser(); + let sender = await generateUser({'inbox.blocks': [receiver._id]}); + + await expect(sender.post('/send-private-message', { + message: messageToSend, + toUserId: receiver._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notAuthorizedToSendMessageToThisUser'), + }); + }); + + it('returns error when to user has opted out of messaging', async () => { + let receiver = await generateUser({'inbox.optOut': true}); + + await expect(userToSendMessage.post('/send-private-message', { + message: messageToSend, + toUserId: receiver._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notAuthorizedToSendMessageToThisUser'), + }); + }); + + it('sends a private message to a user', async () => { + let receiver = await generateUser(); + + await userToSendMessage.post('/send-private-message', { + message: messageToSend, + toUserId: receiver._id, + }); + + let updatedReceiver = await receiver.get('/user'); + let updatedSender = await userToSendMessage.get('/user'); + + let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => { + return message.uuid === userToSendMessage._id && message.text === messageToSend.message; + }); + + let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (message) => { + return message.uuid === receiver._id && message.text === messageToSend.message; + }); + + expect(sendersMessageInReceiversInbox).to.exist; + expect(sendersMessageInSendersInbox).to.exist; + }); + + it('sends a private message about gems to a user', async () => { + let receiver = await generateUser(); + let messageAboutGemsToSend = { + type: 'gems', + gems: { + amount: 2, + }, + message: 'Test Message About Gems', + }; + + await userToSendMessage.post('/send-private-message', { + message: messageAboutGemsToSend, + toUserId: receiver._id, + }); + + let updatedReceiver = await receiver.get('/user'); + let updatedSender = await userToSendMessage.get('/user'); + + let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => { + return message.uuid === userToSendMessage._id; + }); + + let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (message) => { + return message.uuid === receiver._id; + }); + + expect(sendersMessageInReceiversInbox).to.exist; + expect(sendersMessageInReceiversInbox.text).to.equal(`Hello ${receiver.profile.name}, ${userToSendMessage.profile.name} has sent you ${messageAboutGemsToSend.gems.amount} gems! ${messageAboutGemsToSend.message}`); + expect(sendersMessageInSendersInbox).to.exist; + expect(sendersMessageInSendersInbox.text).to.equal(`Hello ${receiver.profile.name}, ${userToSendMessage.profile.name} has sent you ${messageAboutGemsToSend.gems.amount} gems! ${messageAboutGemsToSend.message}`); + }); + + it('sends a private message about subscriptions to a user', async () => { + let receiver = await generateUser(); + let messageAboutSubscriptionToSend = { + type: 'subscription', + subscription: { + key: 'basic_12mo', + }, + message: 'Test Message About Subscription', + }; + + await userToSendMessage.post('/send-private-message', { + message: messageAboutSubscriptionToSend, + toUserId: receiver._id, + }); + + let updatedReceiver = await receiver.get('/user'); + let updatedSender = await userToSendMessage.get('/user'); + + let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => { + return message.uuid === userToSendMessage._id; + }); + + let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (message) => { + return message.uuid === receiver._id; + }); + + expect(sendersMessageInReceiversInbox).to.exist; + expect(sendersMessageInReceiversInbox.text).to.equal(`Hello ${receiver.profile.name}, ${userToSendMessage.profile.name} has sent you 12 months of subscription! ${messageAboutSubscriptionToSend.message}`); + expect(sendersMessageInSendersInbox).to.exist; + expect(sendersMessageInSendersInbox.text).to.equal(`Hello ${receiver.profile.name}, ${userToSendMessage.profile.name} has sent you 12 months of subscription! ${messageAboutSubscriptionToSend.message}`); + }); +}); diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index ad42fe5e8d..36533968d5 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -9,8 +9,13 @@ import { model as Group } from '../../models/group'; import { model as Challenge } from '../../models/challenge'; import { NotFound, + NotAuthorized, } from '../../libs/api-v3/errors'; import * as Tasks from '../../models/task'; +import { + getUserInfo, + sendTxn as sendTxnEmail, +} from '../../libs/api-v3/email'; let api = {}; @@ -231,4 +236,53 @@ api.getChallengeMemberProgress = { }, }; +/** + * @api {posts} /send-private-message Get a challenge member progress + * @apiVersion 3.0.0 + * @apiName SendPrivateMessage + * @apiGroup Members + * + * @apiParam {String} message The message + * @apiParam {UUID} toUserId The toUser _id + * + * @apiSuccess {} Object Returns an empty object + */ +api.sendPrivateMessage = { + method: 'POST', + url: '/send-private-message', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkBody('message', res.t('messageRequired')).notEmpty(); + req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let sender = res.locals.user; + let message = req.body.message; + + let userToReceiveMessage = await User.findById(req.body.toUserId).exec(); + if (!userToReceiveMessage) throw new NotFound(res.t('userNotFound')); + + let userBlockedSender = userToReceiveMessage.inbox.blocks.indexOf(sender._id) !== -1; + let userIsBlockBySender = sender.inbox.blocks.indexOf(userToReceiveMessage._id) !== -1; + let userOptedOutOfMessaging = userToReceiveMessage.inbox.optOut; + + if (userBlockedSender || userIsBlockBySender || userOptedOutOfMessaging) { + throw new NotAuthorized(res.t('notAuthorizedToSendMessageToThisUser')); + } + + await sender.sendMessage(userToReceiveMessage, message); + + if (userToReceiveMessage.preferences.emailNotifications.newPM !== false) { + sendTxnEmail(userToReceiveMessage, 'new-pm', [ + {name: 'SENDER', content: getUserInfo(sender, ['name']).name}, + {name: 'PMS_INBOX_URL', content: '/#/options/groups/inbox'}, + ]); + } + + res.respond(200, {}); + }, +}; + module.exports = api; diff --git a/website/src/models/user.js b/website/src/models/user.js index bd45c2a7c2..9ab410e6d3 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -7,6 +7,8 @@ import * as Tasks from './task'; import Q from 'q'; import { schema as TagSchema } from './tag'; import baseModel from '../libs/api-v3/baseModel'; +import { chatDefaults } from './group'; +import { defaults } from 'lodash'; // import {model as Challenge} from './challenge'; let Schema = mongoose.Schema; @@ -706,6 +708,30 @@ schema.methods.getGroups = function getUserGroups () { return userGroups; }; +schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, messageData) { + let msg; + let sender = this; + + if (!messageData.type) { + msg = messageData.message; + } else { + msg = `Hello ${userToReceiveMessage.profile.name }, ${sender.profile.name} has sent you `; + msg += messageData.type === 'gems' ? `${messageData.gems.amount} gems! ` : `${shared.content.subscriptionBlocks[messageData.subscription.key].months} months of subscription! `; + msg += messageData.message; + } + + shared.refPush(userToReceiveMessage.inbox.messages, chatDefaults(msg, sender)); + userToReceiveMessage.inbox.newMessages++; + userToReceiveMessage._v++; + userToReceiveMessage.markModified('inbox.messages'); + + shared.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(msg, userToReceiveMessage))); + sender.markModified('inbox.messages'); + + let promises = [userToReceiveMessage.save(), sender.save()]; + await Q.all(promises); +}; + export let model = mongoose.model('User', schema); // Initially export an empty object so external requires will get From 95ea73d4407cbf57f34deb4d73bcf66276ee3b88 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 22 Mar 2016 15:35:20 -0500 Subject: [PATCH 578/976] Fixed route namespace and added translation strings --- common/locales/en/api-v3.json | 5 ++- .../members/POST-send_private_message.test.js | 42 ++++++++++++------- website/src/controllers/api-v3/members.js | 4 +- website/src/models/user.js | 13 +++++- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 2a1cf8319c..9c54090f5b 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -121,5 +121,8 @@ "cannoyBuyItem": "You can't buy this item", "messageRequired": "A message is required.", "toUserIDRequired": "A toUserId is required", - "notAuthorizedToSendMessageToThisUser": "Can't send message to this user." + "notAuthorizedToSendMessageToThisUser": "Can't send message to this user.", + "privateMessageGiftIntro": "Hello <%= receiverName %>, <%= senderName %> has sent you ", + "privateMessageGiftGemsMessage": "<%= gemAmount %> gems! ", + "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! " } diff --git a/test/api/v3/integration/members/POST-send_private_message.test.js b/test/api/v3/integration/members/POST-send_private_message.test.js index 013f741708..3666633d4b 100644 --- a/test/api/v3/integration/members/POST-send_private_message.test.js +++ b/test/api/v3/integration/members/POST-send_private_message.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /send-private-message', () => { +describe('POST /members/send-private-message', () => { let userToSendMessage; let messageToSend = { message: 'Test Private Message' }; @@ -13,7 +13,7 @@ describe('POST /send-private-message', () => { }); it('returns error when message is not provided', async () => { - await expect(userToSendMessage.post('/send-private-message')) + await expect(userToSendMessage.post('/members/send-private-message')) .to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -22,7 +22,7 @@ describe('POST /send-private-message', () => { }); it('returns error when toUserId is not provided', async () => { - await expect(userToSendMessage.post('/send-private-message', { + await expect(userToSendMessage.post('/members/send-private-message', { message: messageToSend, })).to.eventually.be.rejected.and.eql({ code: 400, @@ -32,7 +32,7 @@ describe('POST /send-private-message', () => { }); it('returns error when to user is not found', async () => { - await expect(userToSendMessage.post('/send-private-message', { + await expect(userToSendMessage.post('/members/send-private-message', { message: messageToSend, toUserId: generateUUID(), })).to.eventually.be.rejected.and.eql({ @@ -45,7 +45,7 @@ describe('POST /send-private-message', () => { it('returns error when to user has blocked the sender', async () => { let receiver = await generateUser({'inbox.blocks': [userToSendMessage._id]}); - await expect(userToSendMessage.post('/send-private-message', { + await expect(userToSendMessage.post('/members/send-private-message', { message: messageToSend, toUserId: receiver._id, })).to.eventually.be.rejected.and.eql({ @@ -59,7 +59,7 @@ describe('POST /send-private-message', () => { let receiver = await generateUser(); let sender = await generateUser({'inbox.blocks': [receiver._id]}); - await expect(sender.post('/send-private-message', { + await expect(sender.post('/members/send-private-message', { message: messageToSend, toUserId: receiver._id, })).to.eventually.be.rejected.and.eql({ @@ -72,7 +72,7 @@ describe('POST /send-private-message', () => { it('returns error when to user has opted out of messaging', async () => { let receiver = await generateUser({'inbox.optOut': true}); - await expect(userToSendMessage.post('/send-private-message', { + await expect(userToSendMessage.post('/members/send-private-message', { message: messageToSend, toUserId: receiver._id, })).to.eventually.be.rejected.and.eql({ @@ -85,7 +85,7 @@ describe('POST /send-private-message', () => { it('sends a private message to a user', async () => { let receiver = await generateUser(); - await userToSendMessage.post('/send-private-message', { + await userToSendMessage.post('/members/send-private-message', { message: messageToSend, toUserId: receiver._id, }); @@ -115,7 +115,7 @@ describe('POST /send-private-message', () => { message: 'Test Message About Gems', }; - await userToSendMessage.post('/send-private-message', { + await userToSendMessage.post('/members/send-private-message', { message: messageAboutGemsToSend, toUserId: receiver._id, }); @@ -131,10 +131,17 @@ describe('POST /send-private-message', () => { return message.uuid === receiver._id; }); + let messageSentContent = t('privateMessageGiftIntro', { + receiverName: receiver.profile.name, + senderName: userToSendMessage.profile.name, + }); + messageSentContent += t('privateMessageGiftGemsMessage', {gemAmount: messageAboutGemsToSend.gems.amount}); + messageSentContent += messageAboutGemsToSend.message; + expect(sendersMessageInReceiversInbox).to.exist; - expect(sendersMessageInReceiversInbox.text).to.equal(`Hello ${receiver.profile.name}, ${userToSendMessage.profile.name} has sent you ${messageAboutGemsToSend.gems.amount} gems! ${messageAboutGemsToSend.message}`); + expect(sendersMessageInReceiversInbox.text).to.equal(messageSentContent); expect(sendersMessageInSendersInbox).to.exist; - expect(sendersMessageInSendersInbox.text).to.equal(`Hello ${receiver.profile.name}, ${userToSendMessage.profile.name} has sent you ${messageAboutGemsToSend.gems.amount} gems! ${messageAboutGemsToSend.message}`); + expect(sendersMessageInSendersInbox.text).to.equal(messageSentContent); }); it('sends a private message about subscriptions to a user', async () => { @@ -147,7 +154,7 @@ describe('POST /send-private-message', () => { message: 'Test Message About Subscription', }; - await userToSendMessage.post('/send-private-message', { + await userToSendMessage.post('/members/send-private-message', { message: messageAboutSubscriptionToSend, toUserId: receiver._id, }); @@ -163,9 +170,16 @@ describe('POST /send-private-message', () => { return message.uuid === receiver._id; }); + let messageSentContent = t('privateMessageGiftIntro', { + receiverName: receiver.profile.name, + senderName: userToSendMessage.profile.name, + }); + messageSentContent += t('privateMessageGiftSubscriptionMessage', {numberOfMonths: 12}); + messageSentContent += messageAboutSubscriptionToSend.message; + expect(sendersMessageInReceiversInbox).to.exist; - expect(sendersMessageInReceiversInbox.text).to.equal(`Hello ${receiver.profile.name}, ${userToSendMessage.profile.name} has sent you 12 months of subscription! ${messageAboutSubscriptionToSend.message}`); + expect(sendersMessageInReceiversInbox.text).to.equal(messageSentContent); expect(sendersMessageInSendersInbox).to.exist; - expect(sendersMessageInSendersInbox.text).to.equal(`Hello ${receiver.profile.name}, ${userToSendMessage.profile.name} has sent you 12 months of subscription! ${messageAboutSubscriptionToSend.message}`); + expect(sendersMessageInSendersInbox.text).to.equal(messageSentContent); }); }); diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 36533968d5..6445e3a049 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -237,7 +237,7 @@ api.getChallengeMemberProgress = { }; /** - * @api {posts} /send-private-message Get a challenge member progress + * @api {posts} /members/send-private-message Get a challenge member progress * @apiVersion 3.0.0 * @apiName SendPrivateMessage * @apiGroup Members @@ -249,7 +249,7 @@ api.getChallengeMemberProgress = { */ api.sendPrivateMessage = { method: 'POST', - url: '/send-private-message', + url: '/members/send-private-message', middlewares: [authWithHeaders(), cron], async handler (req, res) { req.checkBody('message', res.t('messageRequired')).notEmpty(); diff --git a/website/src/models/user.js b/website/src/models/user.js index 9ab410e6d3..63374a331e 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -715,8 +715,17 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, m if (!messageData.type) { msg = messageData.message; } else { - msg = `Hello ${userToReceiveMessage.profile.name }, ${sender.profile.name} has sent you `; - msg += messageData.type === 'gems' ? `${messageData.gems.amount} gems! ` : `${shared.content.subscriptionBlocks[messageData.subscription.key].months} months of subscription! `; + msg = shared.i18n.t('privateMessageGiftIntro', { + receiverName: userToReceiveMessage.profile.name, + senderName: sender.profile.name, + }); + + if (messageData.type === 'gems') { + msg += shared.i18n.t('privateMessageGiftGemsMessage', {gemAmount: messageData.gems.amount}); + } else { + msg += shared.i18n.t('privateMessageGiftSubscriptionMessage', {numberOfMonths: shared.content.subscriptionBlocks[messageData.subscription.key].months}); + } + msg += messageData.message; } From 19a63e8128656240378da069110655d29f13f878 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 24 Mar 2016 21:35:54 -0500 Subject: [PATCH 579/976] Ported sendGift route and renamed it to transferGems. Refactored user.sendMessage logic. --- common/locales/en/api-v3.json | 4 +- .../members/POST-send_private_message.test.js | 84 +--------- .../members/POST-transfer_gems.test.js | 146 ++++++++++++++++++ website/src/controllers/api-v3/members.js | 88 +++++++++-- website/src/models/user.js | 24 +-- 5 files changed, 234 insertions(+), 112 deletions(-) create mode 100644 test/api/v3/integration/members/POST-transfer_gems.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 9c54090f5b..34759ee751 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -124,5 +124,7 @@ "notAuthorizedToSendMessageToThisUser": "Can't send message to this user.", "privateMessageGiftIntro": "Hello <%= receiverName %>, <%= senderName %> has sent you ", "privateMessageGiftGemsMessage": "<%= gemAmount %> gems! ", - "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! " + "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", + "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", + "notEnoughGemsToSend": "Amount must be within 0 and your current number of gems." } diff --git a/test/api/v3/integration/members/POST-send_private_message.test.js b/test/api/v3/integration/members/POST-send_private_message.test.js index 3666633d4b..3bd3380437 100644 --- a/test/api/v3/integration/members/POST-send_private_message.test.js +++ b/test/api/v3/integration/members/POST-send_private_message.test.js @@ -6,7 +6,7 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /members/send-private-message', () => { let userToSendMessage; - let messageToSend = { message: 'Test Private Message' }; + let messageToSend = 'Test Private Message'; beforeEach(async () => { userToSendMessage = await generateUser(); @@ -94,92 +94,14 @@ describe('POST /members/send-private-message', () => { let updatedSender = await userToSendMessage.get('/user'); let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => { - return message.uuid === userToSendMessage._id && message.text === messageToSend.message; + return message.uuid === userToSendMessage._id && message.text === messageToSend; }); let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (message) => { - return message.uuid === receiver._id && message.text === messageToSend.message; + return message.uuid === receiver._id && message.text === messageToSend; }); expect(sendersMessageInReceiversInbox).to.exist; expect(sendersMessageInSendersInbox).to.exist; }); - - it('sends a private message about gems to a user', async () => { - let receiver = await generateUser(); - let messageAboutGemsToSend = { - type: 'gems', - gems: { - amount: 2, - }, - message: 'Test Message About Gems', - }; - - await userToSendMessage.post('/members/send-private-message', { - message: messageAboutGemsToSend, - toUserId: receiver._id, - }); - - let updatedReceiver = await receiver.get('/user'); - let updatedSender = await userToSendMessage.get('/user'); - - let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => { - return message.uuid === userToSendMessage._id; - }); - - let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (message) => { - return message.uuid === receiver._id; - }); - - let messageSentContent = t('privateMessageGiftIntro', { - receiverName: receiver.profile.name, - senderName: userToSendMessage.profile.name, - }); - messageSentContent += t('privateMessageGiftGemsMessage', {gemAmount: messageAboutGemsToSend.gems.amount}); - messageSentContent += messageAboutGemsToSend.message; - - expect(sendersMessageInReceiversInbox).to.exist; - expect(sendersMessageInReceiversInbox.text).to.equal(messageSentContent); - expect(sendersMessageInSendersInbox).to.exist; - expect(sendersMessageInSendersInbox.text).to.equal(messageSentContent); - }); - - it('sends a private message about subscriptions to a user', async () => { - let receiver = await generateUser(); - let messageAboutSubscriptionToSend = { - type: 'subscription', - subscription: { - key: 'basic_12mo', - }, - message: 'Test Message About Subscription', - }; - - await userToSendMessage.post('/members/send-private-message', { - message: messageAboutSubscriptionToSend, - toUserId: receiver._id, - }); - - let updatedReceiver = await receiver.get('/user'); - let updatedSender = await userToSendMessage.get('/user'); - - let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => { - return message.uuid === userToSendMessage._id; - }); - - let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (message) => { - return message.uuid === receiver._id; - }); - - let messageSentContent = t('privateMessageGiftIntro', { - receiverName: receiver.profile.name, - senderName: userToSendMessage.profile.name, - }); - messageSentContent += t('privateMessageGiftSubscriptionMessage', {numberOfMonths: 12}); - messageSentContent += messageAboutSubscriptionToSend.message; - - expect(sendersMessageInReceiversInbox).to.exist; - expect(sendersMessageInReceiversInbox.text).to.equal(messageSentContent); - expect(sendersMessageInSendersInbox).to.exist; - expect(sendersMessageInSendersInbox.text).to.equal(messageSentContent); - }); }); diff --git a/test/api/v3/integration/members/POST-transfer_gems.test.js b/test/api/v3/integration/members/POST-transfer_gems.test.js new file mode 100644 index 0000000000..384b78baf4 --- /dev/null +++ b/test/api/v3/integration/members/POST-transfer_gems.test.js @@ -0,0 +1,146 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /members/transfer-gems', () => { + let userToSendMessage; + let receiver; + let message = 'Test Private Message'; + let gemAmount = 20; + let giftType = 'gems'; + + beforeEach(async () => { + userToSendMessage = await generateUser({balance: 5}); + receiver = await generateUser(); + }); + + it('returns error when giftType is not provided', async () => { + await expect(userToSendMessage.post('/members/transfer-gems')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns error when message is not provided', async () => { + await expect(userToSendMessage.post('/members/transfer-gems'), { + giftType, + message, + }).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns error when toUserId is not provided', async () => { + await expect(userToSendMessage.post('/members/transfer-gems', { + giftType, + message, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns error when to user is not found', async () => { + await expect(userToSendMessage.post('/members/transfer-gems', { + giftType, + message, + toUserId: generateUUID(), + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userNotFound'), + }); + }); + + it('returns error when to user attempts to send gems to themselves', async () => { + await expect(userToSendMessage.post('/members/transfer-gems', { + giftType, + message, + toUserId: userToSendMessage._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cannotSendGemsToYourself'), + }); + }); + + it('returns error when there is no amount', async () => { + await expect(userToSendMessage.post('/members/transfer-gems', { + giftType, + message, + toUserId: receiver._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughGemsToSend'), + }); + }); + + it('returns error when gemAmount is negative', async () => { + await expect(userToSendMessage.post('/members/transfer-gems', { + giftType, + message, + gemAmount: -5, + toUserId: receiver._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughGemsToSend'), + }); + }); + + it('returns error when gemAmount is more than the sender\'s balance', async () => { + await expect(userToSendMessage.post('/members/transfer-gems', { + giftType, + message, + gemAmount: gemAmount + 4, + toUserId: receiver._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughGemsToSend'), + }); + }); + + it('sends a private message about gems to a user', async () => { + await userToSendMessage.post('/members/transfer-gems', { + giftType, + message, + gemAmount, + toUserId: receiver._id, + }); + + let updatedReceiver = await receiver.get('/user'); + let updatedSender = await userToSendMessage.get('/user'); + + let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (inboxMessage) => { + return inboxMessage.uuid === userToSendMessage._id; + }); + + let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (inboxMessage) => { + return inboxMessage.uuid === receiver._id; + }); + + let messageSentContent = t('privateMessageGiftIntro', { + receiverName: receiver.profile.name, + senderName: userToSendMessage.profile.name, + }); + messageSentContent += t('privateMessageGiftGemsMessage', {gemAmount}); + messageSentContent += message; + + expect(sendersMessageInReceiversInbox).to.exist; + expect(sendersMessageInReceiversInbox.text).to.equal(messageSentContent); + expect(updatedReceiver.balance).to.equal(gemAmount / 4); + + expect(sendersMessageInSendersInbox).to.exist; + expect(sendersMessageInSendersInbox.text).to.equal(messageSentContent); + expect(updatedSender.balance).to.equal(0); + }); +}); diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 6445e3a049..3dfca14c06 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -16,6 +16,7 @@ import { getUserInfo, sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; +import Q from 'q'; let api = {}; @@ -237,7 +238,7 @@ api.getChallengeMemberProgress = { }; /** - * @api {posts} /members/send-private-message Get a challenge member progress + * @api {posts} /members/send-private-message Send a private message to a member * @apiVersion 3.0.0 * @apiName SendPrivateMessage * @apiGroup Members @@ -261,21 +262,21 @@ api.sendPrivateMessage = { let sender = res.locals.user; let message = req.body.message; - let userToReceiveMessage = await User.findById(req.body.toUserId).exec(); - if (!userToReceiveMessage) throw new NotFound(res.t('userNotFound')); + let receiver = await User.findById(req.body.toUserId).exec(); + if (!receiver) throw new NotFound(res.t('userNotFound')); - let userBlockedSender = userToReceiveMessage.inbox.blocks.indexOf(sender._id) !== -1; - let userIsBlockBySender = sender.inbox.blocks.indexOf(userToReceiveMessage._id) !== -1; - let userOptedOutOfMessaging = userToReceiveMessage.inbox.optOut; + let userBlockedSender = receiver.inbox.blocks.indexOf(sender._id) !== -1; + let userIsBlockBySender = sender.inbox.blocks.indexOf(receiver._id) !== -1; + let userOptedOutOfMessaging = receiver.inbox.optOut; if (userBlockedSender || userIsBlockBySender || userOptedOutOfMessaging) { throw new NotAuthorized(res.t('notAuthorizedToSendMessageToThisUser')); } - await sender.sendMessage(userToReceiveMessage, message); + await sender.sendMessage(receiver, message); - if (userToReceiveMessage.preferences.emailNotifications.newPM !== false) { - sendTxnEmail(userToReceiveMessage, 'new-pm', [ + if (receiver.preferences.emailNotifications.newPM !== false) { + sendTxnEmail(receiver, 'new-pm', [ {name: 'SENDER', content: getUserInfo(sender, ['name']).name}, {name: 'PMS_INBOX_URL', content: '/#/options/groups/inbox'}, ]); @@ -285,4 +286,73 @@ api.sendPrivateMessage = { }, }; +/** + * @api {posts} /members/transfer-gems Send a gift to a member + * @apiVersion 3.0.0 + * @apiName TransferGems + * @apiGroup Members + * + * @apiParam {String} message The message + * @apiParam {UUID} toUserId The toUser _id + * + * @apiSuccess {} Object Returns an empty object + */ +api.transferGems = { + method: 'POST', + url: '/members/transfer-gems', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkBody('message', res.t('messageRequired')).notEmpty(); + req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let sender = res.locals.user; + + let receiver = await User.findById(req.body.toUserId).exec(); + if (!receiver) throw new NotFound(res.t('userNotFound')); + + if (receiver._id === sender._id) { + throw new NotAuthorized(res.t('cannotSendGemsToYourself')); + } + + let gemAmount = req.body.gemAmount; + let amount = gemAmount / 4; + + if (!amount || amount <= 0 || sender.balance < amount) { + throw new NotAuthorized(res.t('notEnoughGemsToSend')); + } + + receiver.balance += amount; + sender.balance -= amount; + let promises = [receiver.save(), sender.save()]; + await Q.all(promises); + + let message = res.t('privateMessageGiftIntro', { + receiverName: receiver.profile.name, + senderName: sender.profile.name, + }); + message += res.t('privateMessageGiftGemsMessage', {gemAmount}); + message += req.body.message; + + await sender.sendMessage(receiver, message); + + let byUsername = getUserInfo(sender, ['name']).name; + + if (receiver.preferences.emailNotifications.giftedGems !== false) { + sendTxnEmail(receiver, 'gifted-gems', [ + {name: 'GIFTER', content: byUsername}, + {name: 'X_GEMS_GIFTED', content: gemAmount}, + ]); + } + + // @TODO: Add push notifications + // pushNotify.sendNotify(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername })); + + res.respond(200, {}); + }, +}; + + module.exports = api; diff --git a/website/src/models/user.js b/website/src/models/user.js index 63374a331e..49caaeff81 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -708,33 +708,15 @@ schema.methods.getGroups = function getUserGroups () { return userGroups; }; -schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, messageData) { - let msg; +schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, message) { let sender = this; - if (!messageData.type) { - msg = messageData.message; - } else { - msg = shared.i18n.t('privateMessageGiftIntro', { - receiverName: userToReceiveMessage.profile.name, - senderName: sender.profile.name, - }); - - if (messageData.type === 'gems') { - msg += shared.i18n.t('privateMessageGiftGemsMessage', {gemAmount: messageData.gems.amount}); - } else { - msg += shared.i18n.t('privateMessageGiftSubscriptionMessage', {numberOfMonths: shared.content.subscriptionBlocks[messageData.subscription.key].months}); - } - - msg += messageData.message; - } - - shared.refPush(userToReceiveMessage.inbox.messages, chatDefaults(msg, sender)); + shared.refPush(userToReceiveMessage.inbox.messages, chatDefaults(message, sender)); userToReceiveMessage.inbox.newMessages++; userToReceiveMessage._v++; userToReceiveMessage.markModified('inbox.messages'); - shared.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(msg, userToReceiveMessage))); + shared.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(message, userToReceiveMessage))); sender.markModified('inbox.messages'); let promises = [userToReceiveMessage.save(), sender.save()]; From d7b6e0b7606136c57e74c92c0c2b6dff04d1de8a Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 25 Mar 2016 08:09:24 -0500 Subject: [PATCH 580/976] Ported refPush and added tests --- common/script/libs/refPush.js | 11 ++++---- tasks/gulp-eslint.js | 1 - test/common/libs/refPush.js | 53 +++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 test/common/libs/refPush.js diff --git a/common/script/libs/refPush.js b/common/script/libs/refPush.js index 06b3617014..1bdddc9edf 100644 --- a/common/script/libs/refPush.js +++ b/common/script/libs/refPush.js @@ -7,13 +7,14 @@ import uuid from './uuid'; no problem. To maintain sorting, we use these helper functions: */ -module.exports = function(reflist, item, prune) { - if (prune == null) { - prune = 0; - } +module.exports = function refPush (reflist, item) { item.sort = _.isEmpty(reflist) ? 0 : _.max(reflist, 'sort').sort + 1; + if (!(item.id && !reflist[item.id])) { item.id = uuid(); } - return reflist[item.id] = item; + + reflist[item.id] = item; + + return reflist[item.id]; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index e283b0d8eb..1943e8bd78 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -69,7 +69,6 @@ const COMMON_FILES = [ '!./common/script/libs/planGemLimits.js', '!./common/script/libs/preenHistory.js', '!./common/script/libs/preenTodos.js', - '!./common/script/libs/refPush.js', '!./common/script/libs/removeWhitespace.js', '!./common/script/libs/silver.js', '!./common/script/libs/splitWhitespace.js', diff --git a/test/common/libs/refPush.js b/test/common/libs/refPush.js new file mode 100644 index 0000000000..4183845c9a --- /dev/null +++ b/test/common/libs/refPush.js @@ -0,0 +1,53 @@ +import shared from '../../../common'; +import { v4 as generateUUID } from 'uuid'; + +describe('refPush', () => { + it('it hashes one object into another by its id', () => { + let referenceObject = {}; + let objectToHash = { + a: 1, + id: generateUUID(), + }; + + shared.refPush(referenceObject, objectToHash); + + expect(referenceObject[objectToHash.id].a).to.equal(objectToHash.a); + expect(referenceObject[objectToHash.id].id).to.equal(objectToHash.id); + expect(referenceObject[objectToHash.id].sort).to.equal(0); + }); + + it('it hashes one object into another by a uuid when object does not have an id', () => { + let referenceObject = {}; + let objectToHash = { + a: 1, + }; + + shared.refPush(referenceObject, objectToHash); + + let hashedObject = _.find(referenceObject, (hashedItem) => { + return objectToHash.a === hashedItem.a; + }); + + expect(hashedObject.a).to.equal(objectToHash.a); + expect(hashedObject.id).to.equal(objectToHash.id); + expect(hashedObject.sort).to.equal(0); + }); + + it('it hashes one object into another by a id and gives it the highest sort value', () => { + let referenceObject = {}; + referenceObject[generateUUID()] = { b: 2, sort: 1 }; + let objectToHash = { + a: 1, + }; + + shared.refPush(referenceObject, objectToHash); + + let hashedObject = _.find(referenceObject, (hashedItem) => { + return objectToHash.a === hashedItem.a; + }); + + expect(hashedObject.a).to.equal(objectToHash.a); + expect(hashedObject.id).to.equal(objectToHash.id); + expect(hashedObject.sort).to.equal(2); + }); +}); From 3002c9b7fd8bd0a374cca22d2a4db5d9b6efca34 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 26 Mar 2016 16:25:15 +0100 Subject: [PATCH 581/976] v3: port getBuyList and shared.updateStore --- common/script/libs/updateStore.js | 47 +++++++-------- tasks/gulp-eslint.js | 1 - .../user/GET-user_inventory_buy.test.js | 26 +++++++++ test/common/libs/updateStore.js | 57 +++++++++++++++++++ website/src/controllers/api-v3/user.js | 26 +++++++++ 5 files changed, 130 insertions(+), 27 deletions(-) create mode 100644 test/api/v3/integration/user/GET-user_inventory_buy.test.js create mode 100644 test/common/libs/updateStore.js diff --git a/common/script/libs/updateStore.js b/common/script/libs/updateStore.js index 05d009fd9d..f6593de8fd 100644 --- a/common/script/libs/updateStore.js +++ b/common/script/libs/updateStore.js @@ -1,36 +1,31 @@ import _ from 'lodash'; import content from '../content/index'; -/* - Update the in-browser store with new gear. FIXME this was in user.fns, but it was causing strange issues there - */ +// Return the list of gear items available for purchase -var sortOrder = _.reduce(content.gearTypes, (function(m, v, k) { - m[v] = k; - return m; -}), {}); +let sortOrder = _.reduce(content.gearTypes, (accumulator, val, key) => { + accumulator[val] = key; + return accumulator; +}, {}); -module.exports = function(user) { - var changes; - if (!user) { - return; - } - changes = []; - _.each(content.gearTypes, function(type) { - var found; - found = _.find(content.gear.tree[type][user.stats["class"]], function(item) { +module.exports = function updateStore (user) { + let changes = []; + + _.each(content.gearTypes, (type) => { + let found = _.find(content.gear.tree[type][user.stats.class], (item) => { return !user.items.gear.owned[item.key]; }); - if (found) { - changes.push(found); + + if (found) changes.push(found); + }); + + changes = changes.concat(_.filter(content.gear.flat, (val) => { + if (['special', 'mystery', 'armoire'].indexOf(val.klass) !== -1 && !user.items.gear.owned[val.key] && (val.canOwn ? val.canOwn(user) : false)) { + return true; + } else { + return false; } - return true; - }); - changes = changes.concat(_.filter(content.gear.flat, function(v) { - var ref; - return ((ref = v.klass) === 'special' || ref === 'mystery' || ref === 'armoire') && !user.items.gear.owned[v.key] && (typeof v.canOwn === "function" ? v.canOwn(user) : void 0); })); - return _.sortBy(changes, function(c) { - return sortOrder[c.type]; - }); + + return _.sortBy(changes, (change) => sortOrder[change.type]); }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 1943e8bd78..6fb94f6d2b 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -74,7 +74,6 @@ const COMMON_FILES = [ '!./common/script/libs/splitWhitespace.js', '!./common/script/libs/taskClasses.js', '!./common/script/libs/taskDefaults.js', - '!./common/script/libs/updateStore.js', '!./common/script/libs/uuid.js', '!./common/script/public/**/*.js', ]; diff --git a/test/api/v3/integration/user/GET-user_inventory_buy.test.js b/test/api/v3/integration/user/GET-user_inventory_buy.test.js new file mode 100644 index 0000000000..fd2a25b4ee --- /dev/null +++ b/test/api/v3/integration/user/GET-user_inventory_buy.test.js @@ -0,0 +1,26 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('GET /user/inventory/buy', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('returns the gear items available for purchase', async () => { + let buyList = await user.get('/user/inventory/buy'); + + expect(_.find(buyList, item => { + return item.text === t('armorWarrior1Text'); + })).to.exist; + + expect(_.find(buyList, item => { + return item.text === t('armorWarrior2Text'); + })).to.not.exist; + }); +}); diff --git a/test/common/libs/updateStore.js b/test/common/libs/updateStore.js new file mode 100644 index 0000000000..97d00076af --- /dev/null +++ b/test/common/libs/updateStore.js @@ -0,0 +1,57 @@ +import shared from '../../../common'; +import { + generateUser, +} from '../../helpers/common.helper'; +import i18n from '../../../common/script/i18n'; + +describe('updateStore', () => { + context('returns a list of gear items available for purchase', () => { + let user = generateUser(); + user.items.gear.owned.armor_armoire_lunarArmor = false; // eslint-disable-line camelcase + user.contributor.level = 2; + user.purchased.plan.mysteryItems = ['armor_mystery_201402']; + user.items.gear.owned.armor_mystery_201402 = false; // eslint-disable-line camelcase + + let list = shared.updateStore(user); + + it('contains the first item not purchased for each gear type', () => { + expect(_.find(list, item => { + return item.text() === i18n.t('armorWarrior1Text'); + })).to.exist; + + expect(_.find(list, item => { + return item.text() === i18n.t('armorWarrior2Text'); + })).to.not.exist; + }); + + it('contains mystery items the user can own', () => { + expect(_.find(list, item => { + return item.text() === i18n.t('armorMystery201402Text'); + })).to.exist; + + expect(_.find(list, item => { + return item.text() === i18n.t('armorMystery201403Text'); + })).to.not.exist; + }); + + it('contains special items the user can own', () => { + expect(_.find(list, item => { + return item.text() === i18n.t('armorSpecial1Text'); + })).to.exist; + + expect(_.find(list, item => { + return item.text() === i18n.t('headSpecial1Text'); + })).to.not.exist; + }); + + it('contains armoire items the user can own', () => { + expect(_.find(list, item => { + return item.text() === i18n.t('armorArmoireLunarArmorText'); + })).to.exist; + + expect(_.find(list, item => { + return item.text() === i18n.t('armorArmoireGladiatorArmorText'); + })).to.not.exist; + }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index fdba009372..d7fff0bd6f 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -46,6 +46,32 @@ api.getUser = { }, }; +/** + * @api {get} /user/inventory/buy Get the gear items available for purchase for the current user + * @apiVersion 3.0.0 + * @apiName UserGetBuyList + * @apiGroup User + * + * @apiSuccess {Object} list The buy list + */ +api.getBuyList = { + method: 'GET', + middlewares: [authWithHeaders(), cron], + url: '/user/inventory/buy', + async handler (req, res) { + let list = _.cloneDeep(common.updateStore(res.locals.user)); + + // return text and notes strings + _.each(list, item => { + _.each(item, (itemPropVal, itemPropKey) => { + if (_.isFunction(itemPropVal) && itemPropVal.i18nLangFunc) item[itemPropKey] = itemPropVal(req.language); + }); + }); + + res.respond(200, list); + }, +}; + /** * @api {delete} /user DELETE an authenticated user's profile * @apiVersion 3.0.0 From 67d49bc9fcf6606c5c82e512ff45ce11065cd7f5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 25 Mar 2016 21:43:29 +0100 Subject: [PATCH 582/976] v3: port getContent route caching responses to disk --- .../integration/content/GET-content.test.js | 25 +++++ website/src/controllers/api-v3/content.js | 106 ++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 test/api/v3/integration/content/GET-content.test.js create mode 100644 website/src/controllers/api-v3/content.js diff --git a/test/api/v3/integration/content/GET-content.test.js b/test/api/v3/integration/content/GET-content.test.js new file mode 100644 index 0000000000..9324fce398 --- /dev/null +++ b/test/api/v3/integration/content/GET-content.test.js @@ -0,0 +1,25 @@ +import { + requester, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import i18n from '../../../../../common/script/i18n'; + +describe('GET /content', () => { + it('returns content (and does not require authentication)', async () => { + let res = await requester().get('/content'); + expect(res).to.have.deep.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); + + it('returns content not in English', async () => { + let res = await requester().get('/content?language=de'); + expect(res).to.have.deep.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'de')); + }); + + it('falls back to English if the desired language is not found', async () => { + let res = await requester().get('/content?language=wrong'); + expect(res).to.have.deep.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); +}); diff --git a/website/src/controllers/api-v3/content.js b/website/src/controllers/api-v3/content.js new file mode 100644 index 0000000000..6f249bd27d --- /dev/null +++ b/website/src/controllers/api-v3/content.js @@ -0,0 +1,106 @@ +import common from '../../../../common'; +import _ from 'lodash'; +import { langCodes } from '../../libs/api-v3/i18n'; +import Q from 'q'; +import fsCallback from 'fs'; +import path from 'path'; + +// Transform fs methods that accept callbacks in ones that return promises +const fs = { + readFile: Q.denodeify(fsCallback.readFile), + writeFile: Q.denodeify(fsCallback.writeFile), + stat: Q.denodeify(fsCallback.stat), + mkdir: Q.denodeify(fsCallback.mkdir), +}; + +let api = {}; + +function walkContent (obj, lang) { + _.each(obj, (item, key, source) => { + if (_.isPlainObject(item) || _.isArray(item)) return walkContent(item, lang); + if (_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang); + }); +} + +// After the getContent route is called the first time for a certain language +// the response is saved on disk and subsequentially served directly from there to reduce computation. +// Example: if `cachedContentResponses.en` is true it means that the response is cached +let cachedContentResponses = {}; + +// Language key set to true while the cache file is being written +let cacheBeingWritten = {}; + +_.each(langCodes, code => { + cachedContentResponses[code] = false; + cacheBeingWritten[code] = false; +}); + + +const CONTENT_CACHE_PATH = path.join(__dirname, '/../../../build/content_cache/'); + +async function saveContentToDisk (language, content) { + try { + cacheBeingWritten[language] = true; + + await fs.stat(CONTENT_CACHE_PATH); // check if the directory exists, if it doesn't an error is thrown + await fs.writeFile(`${CONTENT_CACHE_PATH}${language}.json`, content, 'utf8'); + + cacheBeingWritten[language] = false; + cachedContentResponses[language] = true; + } catch (err) { + if (err.code === 'ENOENT' && err.syscall === 'stat') { // the directory doesn't exists, create it and retry + await fs.mkdir(CONTENT_CACHE_PATH); + return saveContentToDisk(language, content); + } else { + cacheBeingWritten[language] = false; + // TODO log error + return; + } + } +} + +/** + * @api {get} /content Get all available content objects. Does not require authentication. + * @apiVersion 3.0.0 + * @apiName ContentGet + * @apiGroup Content + * + * @apiParam {string} language Optional query parameter, the language code used for the items' strings. Defaulting to english + * + * @apiSuccess {Object} content All the content available on Habitica + */ +api.getContent = { + method: 'GET', + url: '/content', + async handler (req, res) { + let language = 'en'; + let proposedLang = req.query.language && req.query.language.toString(); + + if (proposedLang in cachedContentResponses) { + language = proposedLang; + } + + let content; + + // is the content response for this language cached? + if (cachedContentResponses[language] === true) { + content = await fs.readFile(`${CONTENT_CACHE_PATH}${language}.json`, 'utf8'); + } else { // generate the response + content = _.cloneDeep(common.content); + walkContent(content, language); + content = JSON.stringify(content); + } + + res.set({ + 'Content-Type': 'application/json', + }); + res.status(200).send(content); + + // save the file in background unless it's already cached or being written right now + if (cachedContentResponses[language] !== true && cacheBeingWritten[language] !== true) { + saveContentToDisk(language, content); + } + }, +}; + +module.exports = api; From 861a32f72a68a9ea98a6548056653bcd8632e49c Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Sun, 27 Mar 2016 14:51:39 -0400 Subject: [PATCH 583/976] feat(achievements): move party cheevos to server --- .../v3/integration/groups/POST-groups.test.js | 11 +++++ .../groups/POST-groups_groupId_join.test.js | 49 +++++++++++++++++++ website/src/controllers/api-v3/groups.js | 6 +++ 3 files changed, 66 insertions(+) diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 8036d6b0d8..ad8f7a6c92 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -189,6 +189,17 @@ describe('POST /group', () => { expect(updatedUser.party._id).to.eql(party._id); }); + it('does not award Party Up achievement to solo partier', async () => { + await user.post('/groups', { + name: partyName, + type: partyType, + }); + + let updatedUser = await user.get('/user'); + + expect(updatedUser.achievements.partyUp).to.not.eql(true); + }); + it('prevents user in a party from creating another party', async () => { await user.post('/groups', { name: partyName, diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index c245bee42a..60a44bc3f3 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -225,4 +225,53 @@ describe('POST /group/:groupId/join', () => { }); }); }); + + context('Party incentive achievements', () => { + let leader, member, party; + + beforeEach(async () => { + leader = await generateUser(); + member = await generateUser(); + party = await leader.post('/groups', { + name: 'Testing Party', + type: 'party', + }); + await leader.post(`/groups/${party._id}/invite`, { + uuids: [member._id], + }); + await member.post(`/groups/${party._id}/join`); + }); + + it('awards Party Up achievement to party of size 2', async () => { + await member.sync(); + await leader.sync(); + + expect(member).to.have.deep.property('achievements.partyUp', true); + expect(leader).to.have.deep.property('achievements.partyUp', true); + }); + + it('does not award Party On achievement to party of size 2', async () => { + await member.sync(); + await leader.sync(); + + expect(member).to.not.have.deep.property('achievements.partyOn'); + expect(leader).to.not.have.deep.property('achievements.partyOn'); + }); + + it('awards Party On achievement to party of size 4', async () => { + let addlMemberOne = await generateUser(); + let addlMemberTwo = await generateUser(); + await leader.post(`/groups/${party._id}/invite`, { + uuids: [addlMemberOne._id, addlMemberTwo._id], + }); + await addlMemberOne.post(`/groups/${party._id}/join`); + await addlMemberTwo.post(`/groups/${party._id}/join`); + + await member.sync(); + await leader.sync(); + + expect(member).to.have.deep.property('achievements.partyOn', true); + expect(leader).to.have.deep.property('achievements.partyOn', true); + }); + }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 372e0c61fc..c979976206 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -295,6 +295,12 @@ api.joinGroup = { if (group.type === 'party' && inviter) { promises.push(User.update({_id: inviter}, {$inc: {'items.quests.basilist': 1}}).exec()); // Reward inviter + if (group.memberCount > 1) { + promises.push(User.update({$or: [{'party._id': group._id}, {_id: user._id}], 'achievements.partyUp': {$ne: true}}, {$set: {'achievements.partyUp': true}}, {multi: true}).exec()); + } + if (group.memberCount > 3) { + promises.push(User.update({$or: [{'party._id': group._id}, {_id: user._id}], 'achievements.partyOn': {$ne: true}}, {$set: {'achievements.partyOn': true}}, {multi: true}).exec()); + } } await Q.all(promises); From bf93275693a29d9ec0cf1321a17b97662c6b7a75 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 30 Mar 2016 16:18:22 +0200 Subject: [PATCH 584/976] v3: port updateUser --- common/locales/en/api-v3.json | 3 +- test/api/v3/integration/user/PUT-user.test.js | 200 ++++++++++++++++++ website/src/controllers/api-v3/user.js | 105 ++++++++- .../src/middlewares/api-v3/errorHandler.js | 4 +- website/src/models/user.js | 1 + 5 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 test/api/v3/integration/user/PUT-user.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 8880939f58..a9c0a0fb2d 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -127,5 +127,6 @@ "privateMessageGiftGemsMessage": "<%= gemAmount %> gems! ", "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", - "notEnoughGemsToSend": "Amount must be within 0 and your current number of gems." + "notEnoughGemsToSend": "Amount must be within 0 and your current number of gems.", + "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>." } diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js new file mode 100644 index 0000000000..7ea5b44cbf --- /dev/null +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -0,0 +1,200 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +import { each, get } from 'lodash'; + +describe('PUT /user', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + context('Allowed Operations', () => { + it('updates the user', async () => { + await user.put('/user', { + 'profile.name': 'Frodo', + 'preferences.costume': true, + 'stats.hp': 14, + }); + + await user.sync(); + + expect(user.profile.name).to.eql('Frodo'); + expect(user.preferences.costume).to.eql(true); + expect(user.stats.hp).to.eql(14); + }); + }); + + context('Top Level Protected Operations', () => { + let protectedOperations = { + 'gem balance': {balance: 100}, + auth: {'auth.blocked': true, 'auth.timestamps.created': new Date()}, + contributor: {'contributor.level': 9, 'contributor.admin': true, 'contributor.text': 'some text'}, + backer: {'backer.tier': 10, 'backer.npc': 'Bilbo'}, + subscriptions: {'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000}, + 'customization gem purchases': {'purchased.background.tavern': true, 'purchased.skin.bear': true}, + }; + + each(protectedOperations, (data, testName) => { + it(`does not allow updating ${testName}`, async () => { + let errorText = t('messageUserOperationProtected', { operation: Object.keys(data)[0] }); + + await expect(user.put('/user', data)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: errorText, + }); + }); + }); + }); + + context('Sub-Level Protected Operations', () => { + let protectedOperations = { + 'class stat': {'stats.class': 'wizard'}, + 'flags unless whitelisted': {'flags.dropsEnabled': true}, + webhooks: {'preferences.webhooks': [1, 2, 3]}, + sleep: {'preferences.sleep': true}, + }; + + each(protectedOperations, (data, testName) => { + it(`does not allow updating ${testName}`, async () => { + let errorText = t('messageUserOperationProtected', { operation: Object.keys(data)[0] }); + + await expect(user.put('/user', data)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: errorText, + }); + }); + }); + }); + + context('Default Appearance Preferences', () => { + let testCases = { + shirt: 'yellow', + skin: 'ddc994', + 'hair.color': 'blond', + 'hair.bangs': 2, + 'hair.base': 1, + 'hair.flower': 4, + size: 'broad', + }; + + each(testCases, (item, type) => { + const update = {}; + update[`preferences.${type}`] = item; + + it(`updates user with ${type} that is a default`, async () => { + let dbUpdate = {}; + dbUpdate[`purchased.${type}.${item}`] = true; + await user.update(dbUpdate); + + // Sanity checks to make sure user is not already equipped with item + expect(get(user.preferences, type)).to.not.eql(item); + + let updatedUser = await user.put('/user', update); + + expect(get(updatedUser.preferences, type)).to.eql(item); + }); + }); + + it('returns an error if user tries to update body size with invalid type', async () => { + await expect(user.put('/user', { + 'preferences.size': 'round', + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t(`mustPurchaseToSet`, { val: 'round', key: 'preferences.size' }), + }); + }); + + it('can set beard to default', async () => { + await user.update({ + 'purchased.hair.beard': 3, + 'preferences.hair.beard': 3, + }); + + let updatedUser = await user.put('/user', { + 'preferences.hair.beard': 0, + }); + + expect(updatedUser.preferences.hair.beard).to.eql(0); + }); + + it('can set mustache to default', async () => { + await user.update({ + 'purchased.hair.mustache': 2, + 'preferences.hair.mustache': 2, + }); + + let updatedUser = await user.put('/user', { + 'preferences.hair.mustache': 0, + }); + + expect(updatedUser.preferences.hair.mustache).to.eql(0); + }); + }); + + context('Purchasable Appearance Preferences', () => { + let testCases = { + background: 'volcano', + shirt: 'convict', + skin: 'cactus', + 'hair.base': 7, + 'hair.beard': 2, + 'hair.color': 'rainbow', + 'hair.mustache': 2, + }; + + each(testCases, (item, type) => { + const update = {}; + update[`preferences.${type}`] = item; + + it(`returns an error if user tries to update ${type} with ${type} the user does not own`, async () => { + await expect(user.put('/user', update)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('mustPurchaseToSet', {val: item, key: `preferences.${type}`}), + }); + }); + + it(`updates user with ${type} user does own`, async () => { + let dbUpdate = {}; + dbUpdate[`purchased.${type}.${item}`] = true; + await user.update(dbUpdate); + + // Sanity check to make sure user is not already equipped with item + expect(get(user.preferences, type)).to.not.eql(item); + + let updatedUser = await user.put('/user', update); + + expect(get(updatedUser.preferences, type)).to.eql(item); + }); + }); + }); + + context('Improvement Categories', () => { + it('sets valid categories', async () => { + await user.put('/user', { + 'preferences.improvementCategories': ['work', 'school'], + }); + + await user.sync(); + + expect(user.preferences.improvementCategories).to.eql(['work', 'school']); + }); + + it('discards invalid categories', async () => { + await expect(user.put('/user', { + 'preferences.improvementCategories': ['work', 'procrastination', 'school'], + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'User validation failed', + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index d7fff0bd6f..147f1552c9 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -72,12 +72,116 @@ api.getBuyList = { }, }; +let updatablePaths = [ + 'flags.customizationsNotification', + 'flags.showTour', + 'flags.tour', + 'flags.tutorial', + 'flags.communityGuidelinesAccepted', + 'flags.welcomed', + 'flags.cardReceived', + 'flags.warnedLowHealth', + + 'achievements', + + 'party.order', + 'party.orderAscending', + 'party.quest.completed', + 'party.quest.RSVPNeeded', + + 'preferences', + 'profile', + 'stats', + 'inbox.optOut', +]; + +// This tells us for which paths users can call `PUT /user`. +// The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs) +let acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, (accumulator, val, leaf) => { + let found = _.find(updatablePaths, (rootPath) => { + return leaf.indexOf(rootPath) === 0; + }); + + if (found) accumulator[leaf] = true; + + return accumulator; +}, {}); + +let restrictedPUTSubPaths = [ + 'stats.class', + + 'preferences.sleep', + 'preferences.webhooks', +]; + +_.each(restrictedPUTSubPaths, (removePath) => { + delete acceptablePUTPaths[removePath]; +}); + +let requiresPurchase = { + 'preferences.background': 'background', + 'preferences.shirt': 'shirt', + 'preferences.size': 'size', + 'preferences.skin': 'skin', + 'preferences.hair.bangs': 'hair.bangs', + 'preferences.hair.base': 'hair.base', + 'preferences.hair.beard': 'hair.beard', + 'preferences.hair.color': 'hair.color', + 'preferences.hair.flower': 'hair.flower', + 'preferences.hair.mustache': 'hair.mustache', +}; + +let checkPreferencePurchase = (user, path, item) => { + let itemPath = `${path}.${item}`; + let appearance = _.get(common.content.appearances, itemPath); + if (!appearance) return false; + if (appearance.price === 0) return true; + + return _.get(user.purchased, itemPath); +}; + +/** + * @api {put} /user Update the user. Example body: {'stats.hp':50, 'preferences.background': 'beach'} + * @apiVersion 3.0.0 + * @apiName UserUpdate + * @apiGroup User + * + * @apiSuccess user object The updated user object + */ +api.updateUser = { + method: 'PUT', + middlewares: [authWithHeaders(), cron], + url: '/user', + async handler (req, res) { + let user = res.locals.user; + + _.each(req.body, (val, key) => { + let purchasable = requiresPurchase[key]; + + if (purchasable && !checkPreferencePurchase(user, purchasable, val)) { + throw new NotAuthorized(res.t(`mustPurchaseToSet`, { val, key })); + } + + if (acceptablePUTPaths[key]) { + _.set(user, key, val); + } else { + throw new NotAuthorized(res.t('messageUserOperationProtected', { operation: key })); + } + }); + + await user.save(); + return res.respond(200, user); + }, +}; + /** * @api {delete} /user DELETE an authenticated user's profile * @apiVersion 3.0.0 * @apiName UserDelete * @apiGroup User * + * @apiParam {string} password The user's password unless it's a Facebook account + * * @apiSuccess {} object An empty object */ api.deleteUser = { @@ -105,7 +209,6 @@ api.deleteUser = { } let types = ['party', 'publicGuilds', 'privateGuilds']; - // @TODO: The group leave route doesn't work unless it has these fields. We should probably force the group to get these let groupFields = basicGroupFields.concat(' leader memberCount'); let groupsUserIsMemberOf = await Group.getGroups({user, types, groupFields}); diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 95a592514d..401da18627 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -72,7 +72,9 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable message: responseErr.message, }; - if (responseErr.errors) jsonRes.errors = responseErr.errors; + if (responseErr.errors) { + jsonRes.errors = responseErr.errors; + } // In some occasions like when invalid JSON is supplied `res.respond` might be not yet avalaible, // in this case we use the standard res.status(...).json(...) diff --git a/website/src/models/user.js b/website/src/models/user.js index 49caaeff81..3d26234960 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -526,6 +526,7 @@ export let schema = new Schema({ schema.plugin(baseModel, { // TODO revisit a lot of things are missing. Given how many attributes we do have here we should white-list the ones that can be updated + // TODO this is a only used for creating an user, on update we use a whitelist noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations', 'balance', 'backer', 'contributor'], From 7e9520b92050470d20c34f1cf0315c3d866f8c6c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 30 Mar 2016 23:32:12 +0200 Subject: [PATCH 585/976] fix(test): make sure error is catched using done() callback --- test/common/ops/allocate.js | 6 ++++-- test/common/ops/buy.js | 20 ++++++++++++-------- test/common/ops/buyMysterySet.js | 6 ++++-- test/common/ops/buyQuest.js | 9 ++++++--- test/common/ops/buySpecialSpell.js | 9 ++++++--- test/common/ops/changeClass.js | 3 ++- test/common/ops/feed.js | 21 ++++++++++++++------- test/common/ops/hatch.js | 12 ++++++++---- 8 files changed, 56 insertions(+), 30 deletions(-) diff --git a/test/common/ops/allocate.js b/test/common/ops/allocate.js index 87ccc78646..65f3c74fc9 100644 --- a/test/common/ops/allocate.js +++ b/test/common/ops/allocate.js @@ -15,7 +15,7 @@ describe('shared.ops.allocate', () => { user = generateUser(); }); - it('throws an error if an invalid attribute is supplied', () => { + it('throws an error if an invalid attribute is supplied', (done) => { try { allocate(user, { query: {stat: 'notValid'}, @@ -23,15 +23,17 @@ describe('shared.ops.allocate', () => { } catch (err) { expect(err).to.be.an.instanceof(BadRequest); expect(err.message).to.equal(i18n.t('invalidAttribute', {attr: 'notValid'})); + done(); } }); - it('throws an error if the user doesn\'t have attribute points', () => { + it('throws an error if the user doesn\'t have attribute points', (done) => { try { allocate(user); } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('notEnoughAttrPoints')); + done(); } }); diff --git a/test/common/ops/buy.js b/test/common/ops/buy.js index ecf7aee417..78eddd42e3 100644 --- a/test/common/ops/buy.js +++ b/test/common/ops/buy.js @@ -60,7 +60,7 @@ describe('shared.ops.buy', () => { expect(user.stats.gp).to.eql(175); }); - it('does not purchase if not enough gp', () => { + it('does not purchase if not enough gp', (done) => { user.stats.hp = 45; user.stats.gp = 5; try { @@ -68,10 +68,12 @@ describe('shared.ops.buy', () => { } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + expect(user.stats.hp).to.eql(45); + expect(user.stats.gp).to.eql(5); + + done(); } - expect(user.stats.hp).to.eql(45); - expect(user.stats.gp).to.eql(5); }); }); @@ -140,7 +142,7 @@ describe('shared.ops.buy', () => { expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_warrior_1'); }); - it('does not buy equipment without enough Gold', () => { + it('does not buy equipment without enough Gold', (done) => { user.stats.gp = 20; try { @@ -148,9 +150,9 @@ describe('shared.ops.buy', () => { } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + expect(user.items.gear.owned).to.not.have.property('armor_warrior_1'); + done(); } - - expect(user.items.gear.owned).to.not.have.property('armor_warrior_1'); }); }); @@ -177,7 +179,7 @@ describe('shared.ops.buy', () => { }); context('failure conditions', () => { - it('does not open if user does not have enough gold', () => { + it('does not open if user does not have enough gold', (done) => { shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); user.stats.gp = 50; @@ -189,10 +191,11 @@ describe('shared.ops.buy', () => { expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); expect(user.items.food).to.be.empty; expect(user.stats.exp).to.eql(0); + done(); } }); - it('does not open without Ultimate Gear achievement', () => { + it('does not open without Ultimate Gear achievement', (done) => { shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); user.achievements.ultimateGearSets = {healer: false, wizard: false, rogue: false, warrior: false}; @@ -204,6 +207,7 @@ describe('shared.ops.buy', () => { expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); expect(user.items.food).to.be.empty; expect(user.stats.exp).to.eql(0); + done(); } }); }); diff --git a/test/common/ops/buyMysterySet.js b/test/common/ops/buyMysterySet.js index 519657ec46..8e523a5a0a 100644 --- a/test/common/ops/buyMysterySet.js +++ b/test/common/ops/buyMysterySet.js @@ -27,17 +27,18 @@ describe('shared.ops.buyMysterySet', () => { context('Mystery Sets', () => { context('failure conditions', () => { - it('does not grant mystery sets without Mystic Hourglasses', () => { + it('does not grant mystery sets without Mystic Hourglasses', (done) => { try { buyMysterySet(user, {params: {key: '201501'}}); } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.eql(i18n.t('notEnoughHourglasses')); expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true); + done(); } }); - it('does not grant mystery set that has already been purchased', () => { + it('does not grant mystery set that has already been purchased', (done) => { user.purchased.plan.consecutive.trinkets = 1; user.items.gear.owned = { weapon_warrior_0: true, @@ -53,6 +54,7 @@ describe('shared.ops.buyMysterySet', () => { expect(err).to.be.an.instanceof(NotFound); expect(err.message).to.eql(i18n.t('mysterySetNotFound')); expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); } }); }); diff --git a/test/common/ops/buyQuest.js b/test/common/ops/buyQuest.js index c4a32e8ecc..aab691f64e 100644 --- a/test/common/ops/buyQuest.js +++ b/test/common/ops/buyQuest.js @@ -28,7 +28,7 @@ describe('shared.ops.buyQuest', () => { expect(user.stats.gp).to.equal(5); }); - it('does not buy Quests without enough Gold', () => { + it('does not buy Quests without enough Gold', (done) => { user.stats.gp = 1; try { buyQuest(user, { @@ -41,10 +41,11 @@ describe('shared.ops.buyQuest', () => { expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); expect(user.items.quests).to.eql({}); expect(user.stats.gp).to.equal(1); + done(); } }); - it('does not buy nonexistent Quests', () => { + it('does not buy nonexistent Quests', (done) => { user.stats.gp = 9999; try { buyQuest(user, { @@ -57,10 +58,11 @@ describe('shared.ops.buyQuest', () => { expect(err.message).to.equal(i18n.t('questNotFound', {key: 'snarfblatter'})); expect(user.items.quests).to.eql({}); expect(user.stats.gp).to.equal(9999); + done(); } }); - it('does not buy Gem-premium Quests', () => { + it('does not buy Gem-premium Quests', (done) => { user.stats.gp = 9999; try { buyQuest(user, { @@ -73,6 +75,7 @@ describe('shared.ops.buyQuest', () => { expect(err.message).to.equal(i18n.t('questNotGoldPurchasable', {key: 'kraken'})); expect(user.items.quests).to.eql({}); expect(user.stats.gp).to.equal(9999); + done(); } }); }); diff --git a/test/common/ops/buySpecialSpell.js b/test/common/ops/buySpecialSpell.js index 2f7f840aff..f688f4d3d4 100644 --- a/test/common/ops/buySpecialSpell.js +++ b/test/common/ops/buySpecialSpell.js @@ -17,16 +17,17 @@ describe('shared.ops.buySpecialSpell', () => { user = generateUser(); }); - it('throws an error if params.key is missing', () => { + it('throws an error if params.key is missing', (done) => { try { buySpecialSpell(user); } catch (err) { expect(err).to.be.an.instanceof(BadRequest); expect(err.message).to.equal(i18n.t('missingKeyParam')); + done(); } }); - it('throws an error if the spell doesn\'t exists', () => { + it('throws an error if the spell doesn\'t exists', (done) => { try { buySpecialSpell(user, { params: { @@ -36,10 +37,11 @@ describe('shared.ops.buySpecialSpell', () => { } catch (err) { expect(err).to.be.an.instanceof(NotFound); expect(err.message).to.equal(i18n.t('spellNotFound', {spellId: 'notExisting'})); + done(); } }); - it('throws an error if the user doesn\'t have enough gold', () => { + it('throws an error if the user doesn\'t have enough gold', (done) => { user.stats.gp = 1; try { buySpecialSpell(user, { @@ -50,6 +52,7 @@ describe('shared.ops.buySpecialSpell', () => { } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + done(); } }); diff --git a/test/common/ops/changeClass.js b/test/common/ops/changeClass.js index 7c903362e7..4cced258de 100644 --- a/test/common/ops/changeClass.js +++ b/test/common/ops/changeClass.js @@ -76,13 +76,14 @@ describe('shared.ops.changeClass', () => { }); context('has user.preferences.disableClasses !== true', () => { - it('and less than 3 gems', () => { + it('and less than 3 gems', (done) => { user.balance = 0.5; try { changeClass(user); } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); } }); diff --git a/test/common/ops/feed.js b/test/common/ops/feed.js index a2cf8395b8..40d28169c4 100644 --- a/test/common/ops/feed.js +++ b/test/common/ops/feed.js @@ -18,53 +18,58 @@ describe('shared.ops.feed', () => { }); context('failure conditions', () => { - it('does not allow feeding without specifying pet and food', () => { + it('does not allow feeding without specifying pet and food', (done) => { try { feed(user); } catch (err) { expect(err).to.be.an.instanceof(BadRequest); expect(err.message).to.equal(i18n.t('missingPetFoodFeed')); + done(); } }); - it('does not allow feeding if pet name format is invalid', () => { + it('does not allow feeding if pet name format is invalid', (done) => { try { feed(user, {params: {pet: 'invalid', food: 'food'}}); } catch (err) { expect(err).to.be.an.instanceof(BadRequest); expect(err.message).to.equal(i18n.t('invalidPetName')); + done(); } }); - it('does not allow feeding if food does not exists', () => { + it('does not allow feeding if food does not exists', (done) => { try { feed(user, {params: {pet: 'valid-pet', food: 'invalid food name'}}); } catch (err) { expect(err).to.be.an.instanceof(NotFound); expect(err.message).to.equal(i18n.t('messageFoodNotFound')); + done(); } }); - it('does not allow feeding if pet is not owned', () => { + it('does not allow feeding if pet is not owned', (done) => { try { feed(user, {params: {pet: 'not-owned', food: 'Meat'}}); } catch (err) { expect(err).to.be.an.instanceof(NotFound); expect(err.message).to.equal(i18n.t('messagePetNotFound')); + done(); } }); - it('does not allow feeding if food is not owned', () => { + it('does not allow feeding if food is not owned', (done) => { user.items.pets['Wolf-Base'] = 5; try { feed(user, {params: {pet: 'Wolf-Base', food: 'Meat'}}); } catch (err) { expect(err).to.be.an.instanceof(NotFound); expect(err.message).to.equal(i18n.t('messageFoodNotFound')); + done(); } }); - it('does not allow feeding of special pets', () => { + it('does not allow feeding of special pets', (done) => { user.items.pets['Wolf-Veteran'] = 5; user.items.food.Meat = 1; try { @@ -72,10 +77,11 @@ describe('shared.ops.feed', () => { } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageCannotFeedPet')); + done(); } }); - it('does not allow feeding of mounts', () => { + it('does not allow feeding of mounts', (done) => { user.items.pets['Wolf-Base'] = -1; user.items.mounts['Wolf-Base'] = true; user.items.food.Meat = 1; @@ -84,6 +90,7 @@ describe('shared.ops.feed', () => { } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageAlreadyMount')); + done(); } }); }); diff --git a/test/common/ops/hatch.js b/test/common/ops/hatch.js index ead2a397f0..0176b46b5f 100644 --- a/test/common/ops/hatch.js +++ b/test/common/ops/hatch.js @@ -29,7 +29,7 @@ describe('shared.ops.hatch', () => { } }); - it('does not allow hatching if user lacks specified egg', () => { + it('does not allow hatching if user lacks specified egg', (done) => { user.items.eggs.Wolf = 1; user.items.hatchingPotions.Base = 1; user.items.pets = {}; @@ -41,10 +41,11 @@ describe('shared.ops.hatch', () => { expect(user.items.pets).to.be.empty; expect(user.items.eggs.Wolf).to.equal(1); expect(user.items.hatchingPotions.Base).to.equal(1); + done(); } }); - it('does not allow hatching if user lacks specified hatching potion', () => { + it('does not allow hatching if user lacks specified hatching potion', (done) => { user.items.eggs.Wolf = 1; user.items.hatchingPotions.Base = 1; user.items.pets = {}; @@ -56,10 +57,11 @@ describe('shared.ops.hatch', () => { expect(user.items.pets).to.be.empty; expect(user.items.eggs.Wolf).to.equal(1); expect(user.items.hatchingPotions.Base).to.equal(1); + done(); } }); - it('does not allow hatching if user already owns target pet', () => { + it('does not allow hatching if user already owns target pet', (done) => { user.items.eggs = {Wolf: 1}; user.items.hatchingPotions = {Base: 1}; user.items.pets = {'Wolf-Base': 10}; @@ -71,10 +73,11 @@ describe('shared.ops.hatch', () => { expect(user.items.pets).to.eql({'Wolf-Base': 10}); expect(user.items.eggs).to.eql({Wolf: 1}); expect(user.items.hatchingPotions).to.eql({Base: 1}); + done(); } }); - it('does not allow hatching quest pet egg using premium potion', () => { + it('does not allow hatching quest pet egg using premium potion', (done) => { user.items.eggs = {Cheetah: 1}; user.items.hatchingPotions = {Spooky: 1}; user.items.pets = {}; @@ -86,6 +89,7 @@ describe('shared.ops.hatch', () => { expect(user.items.pets).to.be.empty; expect(user.items.eggs).to.eql({Cheetah: 1}); expect(user.items.hatchingPotions).to.eql({Spooky: 1}); + done(); } }); }); From dd124431669823650f37277451ca483f77194681 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 1 Apr 2016 08:11:26 -0500 Subject: [PATCH 586/976] Ported Hourglass Purchase, add unit tests. Created new hourglass purchase route and tests --- common/locales/en/api-v3.json | 3 +- common/locales/en/subscriber.json | 2 +- common/script/index.js | 2 + common/script/ops/hourglassPurchase.js | 71 +++++---- tasks/gulp-eslint.js | 1 - .../user/POST-user_purchase_hourglass.test.js | 25 +++ test/common/ops/hourglassPurchase.js | 145 ++++++++++++++++++ website/src/controllers/api-v3/user.js | 23 +++ 8 files changed, 236 insertions(+), 36 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_purchase_hourglass.test.js create mode 100644 test/common/ops/hourglassPurchase.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a9c0a0fb2d..a6ff00df22 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -128,5 +128,6 @@ "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", "notEnoughGemsToSend": "Amount must be within 0 and your current number of gems.", - "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>." + "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>.", + "notAllowedHourglass": "Pet/Mount not available for purchase with Mystic Hourglass." } diff --git a/common/locales/en/subscriber.json b/common/locales/en/subscriber.json index 087213d679..0e9158e878 100644 --- a/common/locales/en/subscriber.json +++ b/common/locales/en/subscriber.json @@ -113,7 +113,7 @@ "hourglassBuyItemConfirm": "Buy this item for 1 Mystic Hourglass?", "petsAlreadyOwned": "Pet already owned.", "mountsAlreadyOwned": "Mount already owned.", - "typeNotAllowedHourglass": "Item type not supported for purchase with Mystic Hourglass. Allowed types: ", + "typeNotAllowedHourglass": "Item type not supported for purchase with Mystic Hourglass. Allowed types: <%= allowedTypes %>", "petsNotAllowedHourglass": "Pet not available for purchase with Mystic Hourglass.", "mountsNotAllowedHourglass": "Mount not available for purchase with Mystic Hourglass.", "hourglassPurchase": "Purchased an item using a Mystic Hourglass!", diff --git a/common/script/index.js b/common/script/index.js index 7f9736044d..400b8a5b10 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -114,6 +114,7 @@ import feed from './ops/feed'; import equip from './ops/equip'; import changeClass from './ops/changeClass'; import disableClasses from './ops/disableClasses'; +import purchaseHourglass from './ops/hourglassPurchase'; api.ops = { scoreTask, @@ -129,6 +130,7 @@ api.ops = { equip, changeClass, disableClasses, + purchaseHourglass, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/hourglassPurchase.js b/common/script/ops/hourglassPurchase.js index b97f581bb6..b627704d7b 100644 --- a/common/script/ops/hourglassPurchase.js +++ b/common/script/ops/hourglassPurchase.js @@ -3,53 +3,58 @@ import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; import pickDeep from '../libs/pickDeep'; +import { + BadRequest, + NotAuthorized, +} from '../libs/errors'; + +module.exports = function purchaseHourglass (user, req = {}, analytics) { + let key = _.get(req, 'params.key'); + if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); + + let type = _.get(req, 'params.type'); + if (!type) throw new BadRequest(i18n.t('missingTypeParam', req.language)); -module.exports = function(user, req, cb, analytics) { - var analyticsData, key, ref, type; - ref = req.params, type = ref.type, key = ref.key; if (!content.timeTravelStable[type]) { - return typeof cb === "function" ? cb({ - code: 403, - message: i18n.t('typeNotAllowedHourglass', req.language) + JSON.stringify(_.keys(content.timeTravelStable)) - }) : void 0; + throw new NotAuthorized(i18n.t('typeNotAllowedHourglass', {allowedTypes: _.keys(content.timeTravelStable).toString()}, req.language)); } + if (!_.contains(_.keys(content.timeTravelStable[type]), key)) { - return typeof cb === "function" ? cb({ - code: 403, - message: i18n.t(type + 'NotAllowedHourglass', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t('notAllowedHourglass', req.language)); } + if (user.items[type][key]) { - return typeof cb === "function" ? cb({ - code: 403, - message: i18n.t(type + 'AlreadyOwned', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t(`${type}AlreadyOwned`, req.language)); } - if (!(user.purchased.plan.consecutive.trinkets > 0)) { - return typeof cb === "function" ? cb({ - code: 403, - message: i18n.t('notEnoughHourglasses', req.language) - }) : void 0; + + if (user.purchased.plan.consecutive.trinkets <= 0) { + throw new NotAuthorized(i18n.t('notEnoughHourglasses', req.language)); } + user.purchased.plan.consecutive.trinkets--; + if (type === 'pets') { user.items.pets[key] = 5; } + if (type === 'mounts') { user.items.mounts[key] = true; } - analyticsData = { - uuid: user._id, - itemKey: key, - itemType: type, - acquireMethod: 'Hourglass', - category: 'behavior' - }; - if (analytics != null) { - analytics.track('acquire item', analyticsData); + + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: key, + itemType: type, + acquireMethod: 'Hourglass', + category: 'behavior', + }); } - return typeof cb === "function" ? cb({ - code: 200, - message: i18n.t('hourglassPurchase', req.language) - }, pickDeep(user, splitWhitespace('items purchased.plan.consecutive'))) : void 0; + + let res = { + data: pickDeep(user, splitWhitespace('items purchased.plan.consecutive')), + message: i18n.t('hourglassPurchase', req.language), + }; + + return res; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 6fb94f6d2b..7669ba0d7c 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -28,7 +28,6 @@ const COMMON_FILES = [ '!./common/script/ops/deleteWebhook.js', '!./common/script/ops/getTag.js', '!./common/script/ops/getTags.js', - '!./common/script/ops/hourglassPurchase.js', '!./common/script/ops/openMysteryItem.js', '!./common/script/ops/purchase.js', '!./common/script/ops/readCard.js', diff --git a/test/api/v3/integration/user/POST-user_purchase_hourglass.test.js b/test/api/v3/integration/user/POST-user_purchase_hourglass.test.js new file mode 100644 index 0000000000..cd43334d00 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_purchase_hourglass.test.js @@ -0,0 +1,25 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/purchase-hourglass/:type/:key', () => { + let user; + + beforeEach(async () => { + user = await generateUser({ + 'purchased.plan.consecutive.trinkets': 2, + }); + }); + + // More tests in common code unit tests + + it('buys a hourglass pet', async () => { + let response = await user.post('/user/purchase-hourglass/pets/MantisShrimp-Base'); + await user.sync(); + + expect(response.message).to.eql(t('hourglassPurchase')); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + expect(user.items.pets).to.eql({'MantisShrimp-Base': 5}); + }); +}); diff --git a/test/common/ops/hourglassPurchase.js b/test/common/ops/hourglassPurchase.js new file mode 100644 index 0000000000..258e0c6f01 --- /dev/null +++ b/test/common/ops/hourglassPurchase.js @@ -0,0 +1,145 @@ +import hourglassPurchase from '../../../common/script/ops/hourglassPurchase'; +import { + BadRequest, + NotAuthorized, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import content from '../../../common/script/content/index'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('user.ops.hourglassPurchase', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + context('failure conditions', () => { + it('return error when key is not provided', (done) => { + try { + hourglassPurchase(user, {params: {}}); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.eql(i18n.t('missingKeyParam')); + done(); + } + }); + + it('returns error when type is not provided', (done) => { + try { + hourglassPurchase(user, {params: {key: 'Base'}}); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.eql(i18n.t('missingTypeParam')); + done(); + } + }); + + it('returns error when inccorect type is provided', (done) => { + try { + hourglassPurchase(user, {params: {type: 'notAType', key: 'MantisShrimp-Base'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.eql(i18n.t('typeNotAllowedHourglass', {allowedTypes: _.keys(content.timeTravelStable).toString()})); + done(); + } + }); + + it('does not grant to pets without Mystic Hourglasses', (done) => { + try { + hourglassPurchase(user, {params: {type: 'pets', key: 'MantisShrimp-Base'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.eql(i18n.t('notEnoughHourglasses')); + done(); + } + }); + + it('does not grant to mounts without Mystic Hourglasses', (done) => { + try { + hourglassPurchase(user, {params: {type: 'mounts', key: 'MantisShrimp-Base'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.eql(i18n.t('notEnoughHourglasses')); + done(); + } + }); + + it('does not grant pet that is not part of the Time Travel Stable', (done) => { + user.purchased.plan.consecutive.trinkets = 1; + + try { + hourglassPurchase(user, {params: {type: 'pets', key: 'Wolf-Veteran'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.eql(i18n.t('notAllowedHourglass')); + done(); + } + }); + + it('does not grant mount that is not part of the Time Travel Stable', (done) => { + user.purchased.plan.consecutive.trinkets = 1; + + try { + hourglassPurchase(user, {params: {type: 'mounts', key: 'Orca-Base'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.eql(i18n.t('notAllowedHourglass')); + done(); + } + }); + + it('does not grant pet that has already been purchased', (done) => { + user.purchased.plan.consecutive.trinkets = 1; + user.items.pets = { + 'MantisShrimp-Base': true, + }; + + try { + hourglassPurchase(user, {params: {type: 'pets', key: 'MantisShrimp-Base'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.eql(i18n.t('petsAlreadyOwned')); + done(); + } + }); + + it('does not grant mount that has already been purchased', (done) => { + user.purchased.plan.consecutive.trinkets = 1; + user.items.mounts = { + 'MantisShrimp-Base': true, + }; + + try { + hourglassPurchase(user, {params: {type: 'mounts', key: 'MantisShrimp-Base'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.eql(i18n.t('mountsAlreadyOwned')); + done(); + } + }); + }); + + context('successful purchases', () => { + it('buys a pet', () => { + user.purchased.plan.consecutive.trinkets = 2; + + let response = hourglassPurchase(user, {params: {type: 'pets', key: 'MantisShrimp-Base'}}); + + expect(response.message).to.eql(i18n.t('hourglassPurchase')); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + expect(user.items.pets).to.eql({'MantisShrimp-Base': 5}); + }); + + it('buys a mount', () => { + user.purchased.plan.consecutive.trinkets = 2; + + let response = hourglassPurchase(user, {params: {type: 'mounts', key: 'MantisShrimp-Base'}}); + expect(response.message).to.eql(i18n.t('hourglassPurchase')); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + expect(user.items.mounts).to.eql({'MantisShrimp-Base': true}); + }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 147f1552c9..4fffe68046 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -676,4 +676,27 @@ api.disableClasses = { }, }; +/** +* @api {post} /user/purchase-hourglass/:type/:key Purchase Hourglass. +* @apiVersion 3.0.0 +* @apiName UserPurchaseHourglass +* @apiGroup User +* +* @apiParam {string} type {pets|mounts}. The type of item to purchase +* @apiParam {string} key Ex: {MantisShrimp-Base}. The key for the mount/pet +* +* @apiSuccess {Object} data `items purchased.plan.consecutive` +*/ +api.userPurchaseHourglass = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/purchase-hourglass/:type/:key', + async handler (req, res) { + let user = res.locals.user; + let purchaseHourglassResponse = common.ops.purchaseHourglass(user, req, res.analytics); + await user.save(); + res.respond(200, purchaseHourglassResponse); + }, +}; + module.exports = api; From ad0bc580286a83479b581f40cce14a2d9ac39231 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 1 Apr 2016 08:14:15 -0500 Subject: [PATCH 587/976] Ported purchase, add unit tests, created new user purchase route, and added tests --- common/locales/en/api-v3.json | 10 +- common/script/index.js | 2 + common/script/ops/purchase.js | 159 ++++++++------ tasks/gulp-eslint.js | 1 - .../user/POST-user_purchase.test.js | 35 +++ test/common/ops/purchase.js | 199 ++++++++++++++++++ website/src/controllers/api-v3/user.js | 23 ++ 7 files changed, 358 insertions(+), 71 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_purchase.test.js create mode 100644 test/common/ops/purchase.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a9c0a0fb2d..73fbcca279 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -128,5 +128,13 @@ "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", "notEnoughGemsToSend": "Amount must be within 0 and your current number of gems.", - "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>." + "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>.", + "typeRequired": "Type is required", + "keyRequired": "Key is required", + "mustSubscribeToPurchaseGems": "Must subscribe to purchase gems with GP", + "reachedGoldToGemCap": "You've reached the Gold=>Gem conversion cap <%= convCap %> for this month. We have this to prevent abuse / farming. The cap will reset within the first three days of next month.", + "notAccteptedType": "Type must be in [eggs, hatchingPotions, food, quests, gear]", + "contentKeyNotFound": "Key not found for Content <%= type %>", + "plusOneGem": "+1 Gem", + "purchased": "You purchsed a <%= key %> <%= type %>" } diff --git a/common/script/index.js b/common/script/index.js index 7f9736044d..8edc435cae 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -114,6 +114,7 @@ import feed from './ops/feed'; import equip from './ops/equip'; import changeClass from './ops/changeClass'; import disableClasses from './ops/disableClasses'; +import purchase from './ops/purchase'; api.ops = { scoreTask, @@ -129,6 +130,7 @@ api.ops = { equip, changeClass, disableClasses, + purchase, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/purchase.js b/common/script/ops/purchase.js index d4d0f78816..abb7992994 100644 --- a/common/script/ops/purchase.js +++ b/common/script/ops/purchase.js @@ -3,105 +3,126 @@ import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; import planGemLimits from '../libs/planGemLimits'; +import { + NotFound, + NotAuthorized, + BadRequest, +} from '../libs/errors'; + +module.exports = function purchase (user, req = {}, analytics) { + let type = _.get(req.params, 'type'); + let key = _.get(req.params, 'key'); + let item; + let price; + + if (!type) { + throw new BadRequest(i18n.t('typeRequired', req.language)); + } + + if (!key) { + throw new BadRequest(i18n.t('keyRequired', req.language)); + } -module.exports = function(user, req, cb, analytics) { - var analyticsData, convCap, convRate, item, key, price, ref, ref1, ref2, ref3, type; - ref = req.params, type = ref.type, key = ref.key; if (type === 'gems' && key === 'gem') { - ref1 = planGemLimits, convRate = ref1.convRate, convCap = ref1.convCap; + let convRate = planGemLimits.convRate; + let convCap = planGemLimits.convCap; convCap += user.purchased.plan.consecutive.gemCapExtra; - if (!((ref2 = user.purchased) != null ? (ref3 = ref2.plan) != null ? ref3.customerId : void 0 : void 0)) { - return typeof cb === "function" ? cb({ - code: 401, - message: "Must subscribe to purchase gems with GP" - }, req) : void 0; + + if (!user.purchased || !user.purchased.plan || !user.purchased.plan.customerId) { + throw new NotAuthorized(i18n.t('mustSubscribeToPurchaseGems', req.language)); } - if (!(user.stats.gp >= convRate)) { - return typeof cb === "function" ? cb({ - code: 401, - message: "Not enough Gold" - }) : void 0; + + if (user.stats.gp < convRate) { + throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); } + if (user.purchased.plan.gemsBought >= convCap) { - return typeof cb === "function" ? cb({ - code: 401, - message: "You've reached the Gold=>Gem conversion cap (" + convCap + ") for this month. We have this to prevent abuse / farming. The cap will reset within the first three days of next month." - }) : void 0; + throw new NotAuthorized(i18n.t('reachedGoldToGemCap', {convCap}, req.language)); } - user.balance += .25; + + user.balance += 0.25; user.purchased.plan.gemsBought++; user.stats.gp -= convRate; - analyticsData = { - uuid: user._id, - itemKey: key, - acquireMethod: 'Gold', - goldCost: convRate, - category: 'behavior' - }; - if (analytics != null) { - analytics.track('purchase gems', analyticsData); + + if (analytics) { + analytics.track('purchase gems', { + uuid: user._id, + itemKey: key, + acquireMethod: 'Gold', + goldCost: convRate, + category: 'behavior', + }); } - return typeof cb === "function" ? cb({ - code: 200, - message: "+1 Gem" - }, _.pick(user, splitWhitespace('stats balance'))) : void 0; + + let response = { + data: _.pick(user, splitWhitespace('stats balance')), + message: i18n.t('plusOneGem'), + }; + + return response; } - if (type !== 'eggs' && type !== 'hatchingPotions' && type !== 'food' && type !== 'quests' && type !== 'gear') { - return typeof cb === "function" ? cb({ - code: 404, - message: ":type must be in [eggs,hatchingPotions,food,quests,gear]" - }, req) : void 0; + + let acceptedTypes = ['eggs', 'hatchingPotions', 'food', 'quests', 'gear']; + if (acceptedTypes.indexOf(type) === -1) { + throw new NotFound(i18n.t('notAccteptedType', req.language)); } + if (type === 'gear') { item = content.gear.flat[key]; - if (user.items.gear.owned[key]) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('alreadyHave', req.language) - }) : void 0; + + if (!item) { + throw new NotFound(i18n.t('contentKeyNotFound', {type}, req.language)); } + + if (user.items.gear.owned[key]) { + throw new NotAuthorized(i18n.t('alreadyHave', req.language)); + } + price = (item.twoHanded || item.gearSet === 'animal' ? 2 : 1) / 4; } else { item = content[type][key]; + + if (!item) { + throw new NotFound(i18n.t('contentKeyNotFound', {type}, req.language)); + } + price = item.value / 4; } - if (!item) { - return typeof cb === "function" ? cb({ - code: 404, - message: ":key not found for Content." + type - }, req) : void 0; - } + if (!item.canBuy(user)) { - return typeof cb === "function" ? cb({ - code: 403, - message: i18n.t('messageNotAvailable', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t('messageNotAvailable', req.language)); } - if ((user.balance < price) || !user.balance) { - return typeof cb === "function" ? cb({ - code: 403, - message: i18n.t('notEnoughGems', req.language) - }) : void 0; + + if (!user.balance || user.balance < price) { + throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); } + user.balance -= price; + if (type === 'gear') { user.items.gear.owned[key] = true; } else { - if (!(user.items[type][key] > 0)) { + if (!user.items[type][key] || user.items[type][key] < 0) { user.items[type][key] = 0; } user.items[type][key]++; } - analyticsData = { - uuid: user._id, - itemKey: key, - itemType: 'Market', - acquireMethod: 'Gems', - gemCost: item.value, - category: 'behavior' - }; - if (analytics != null) { - analytics.track('acquire item', analyticsData); + + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: key, + itemType: 'Market', + acquireMethod: 'Gems', + gemCost: item.value, + category: 'behavior', + }); } - return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('items balance'))) : void 0; + + let response = { + data: _.pick(user, splitWhitespace('items balance')), + message: i18n.t('purchased', {type, key}), + }; + + return response; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 6fb94f6d2b..6208dfbeb5 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -30,7 +30,6 @@ const COMMON_FILES = [ '!./common/script/ops/getTags.js', '!./common/script/ops/hourglassPurchase.js', '!./common/script/ops/openMysteryItem.js', - '!./common/script/ops/purchase.js', '!./common/script/ops/readCard.js', '!./common/script/ops/rebirth.js', '!./common/script/ops/releaseBoth.js', diff --git a/test/api/v3/integration/user/POST-user_purchase.test.js b/test/api/v3/integration/user/POST-user_purchase.test.js new file mode 100644 index 0000000000..6afaa14cb6 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_purchase.test.js @@ -0,0 +1,35 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/purchase/:type/:key', () => { + let user; + let type = 'hatchingPotions'; + let key = 'Base'; + + beforeEach(async () => { + user = await generateUser({ + balance: 40, + }); + }); + + // More tests in common code unit tests + + it('returns an error when key is not provided', async () => { + await expect(user.post(`/user/purchase/gems/gem`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('mustSubscribeToPurchaseGems'), + }); + }); + + it('purchases a gem item', async () => { + let res = await user.post(`/user/purchase/${type}/${key}`); + await user.sync(); + + expect(res.message).to.equal(t('purchased', {type, key})); + expect(user.items[type][key]).to.equal(1); + }); +}); diff --git a/test/common/ops/purchase.js b/test/common/ops/purchase.js new file mode 100644 index 0000000000..557ee006cf --- /dev/null +++ b/test/common/ops/purchase.js @@ -0,0 +1,199 @@ +import purchase from '../../../common/script/ops/purchase'; +import planGemLimits from '../../../common/script/libs/planGemLimits'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.feed', () => { + let user; + let goldPoints = 40; + let gemsBought = 40; + + before(() => { + user = generateUser({'stats.class': 'rogue'}); + }); + + context('failure conditions', () => { + it('returns an error when type is not provided', (done) => { + try { + purchase(user, {params: {}}); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('typeRequired')); + done(); + } + }); + + it('returns an error when key is not provided', (done) => { + try { + purchase(user, {params: {type: 'gems'}}); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('keyRequired')); + done(); + } + }); + + it('prevents unsubscribed user from buying gems', (done) => { + try { + purchase(user, {params: {type: 'gems', key: 'gem'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('mustSubscribeToPurchaseGems')); + done(); + } + }); + + it('prevents user with not enough gold from buying gems', (done) => { + user.purchased.plan.customerId = 'customer-id'; + + try { + purchase(user, {params: {type: 'gems', key: 'gem'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + done(); + } + }); + + it('prevents user that have reached the conversion cap from buying gems', (done) => { + user.stats.gp = goldPoints; + user.purchased.plan.gemsBought = gemsBought; + + try { + purchase(user, {params: {type: 'gems', key: 'gem'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('reachedGoldToGemCap', {convCap: planGemLimits.convCap})); + done(); + } + }); + + it('returns error when unknown type is provided', (done) => { + try { + purchase(user, {params: {type: 'randomType', key: 'gem'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotFound); + expect(err.message).to.equal(i18n.t('notAccteptedType')); + done(); + } + }); + + it('returns error when user attempts to purchase a piece of gear they own', (done) => { + user.items.gear.owned['shield_rogue_1'] = true; // eslint-disable-line dot-notation + + try { + purchase(user, {params: {type: 'gear', key: 'shield_rogue_1'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('alreadyHave')); + done(); + } + }); + + it('returns error when unknown item is requested', (done) => { + try { + purchase(user, {params: {type: 'gear', key: 'randomKey'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotFound); + expect(err.message).to.equal(i18n.t('contentKeyNotFound', {type: 'gear'})); + done(); + } + }); + + it('returns error when user does not have permission to buy an item', (done) => { + try { + purchase(user, {params: {type: 'gear', key: 'eyewear_mystery_301405'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('messageNotAvailable')); + done(); + } + }); + + it('returns error when user does not have enough gems to buy an item', (done) => { + try { + purchase(user, {params: {type: 'gear', key: 'headAccessory_special_wolfEars'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); + } + }); + }); + + context('successful feeding', () => { + let userGemAmount = 10; + + before(() => { + user.balance = userGemAmount; + user.stats.gp = goldPoints; + user.purchased.plan.gemsBought = 0; + }); + + it('purchases gems', () => { + let purchaseResponse = purchase(user, {params: {type: 'gems', key: 'gem'}}); + + expect(purchaseResponse.message).to.equal(i18n.t('plusOneGem')); + expect(user.balance).to.equal(userGemAmount + 0.25); + expect(user.purchased.plan.gemsBought).to.equal(1); + expect(user.stats.gp).to.equal(goldPoints - planGemLimits.convRate); + }); + + it('purchases eggs', () => { + let type = 'eggs'; + let key = 'Wolf'; + + let purchaseResponse = purchase(user, {params: {type, key}}); + + expect(purchaseResponse.message).to.equal(i18n.t('purchased', {type, key})); + expect(user.items[type][key]).to.equal(1); + }); + + it('purchases hatchingPotions', () => { + let type = 'hatchingPotions'; + let key = 'Base'; + + let purchaseResponse = purchase(user, {params: {type, key}}); + + expect(purchaseResponse.message).to.equal(i18n.t('purchased', {type, key})); + expect(user.items[type][key]).to.equal(1); + }); + + it('purchases food', () => { + let type = 'food'; + let key = 'Meat'; + + let purchaseResponse = purchase(user, {params: {type, key}}); + + expect(purchaseResponse.message).to.equal(i18n.t('purchased', {type, key})); + expect(user.items[type][key]).to.equal(1); + }); + + it('purchases quests', () => { + let type = 'quests'; + let key = 'gryphon'; + + let purchaseResponse = purchase(user, {params: {type, key}}); + + expect(purchaseResponse.message).to.equal(i18n.t('purchased', {type, key})); + expect(user.items[type][key]).to.equal(1); + }); + + it('purchases gear', () => { + let type = 'gear'; + let key = 'headAccessory_special_tigerEars'; + + let purchaseResponse = purchase(user, {params: {type, key}}); + + expect(purchaseResponse.message).to.equal(i18n.t('purchased', {type, key})); + expect(user.items.gear.owned[key]).to.be.true; + }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 147f1552c9..8dcf87cad9 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -676,4 +676,27 @@ api.disableClasses = { }, }; +/** +* @api {post} /user/purchase/:type/:key Purchase Gem Items. +* @apiVersion 3.0.0 +* @apiName UserPurchase +* @apiGroup User +* +* @apiParam {string} type Type of item to purchase +* @apiParam {string} key Item's key +* +* @apiSuccess {Object} data `items balance` +*/ +api.purchase = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/purchase/:type/:key', + async handler (req, res) { + let user = res.locals.user; + let purchaseResponse = common.ops.purchase(user, req, res.analytics); + await user.save(); + res.respond(200, purchaseResponse); + }, +}; + module.exports = api; From 3fe88fd8d014a71844fed1738a97ebfae4af1d37 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 1 Apr 2016 08:23:40 -0500 Subject: [PATCH 588/976] Ported open mystery item, added unit tests, added route, and integration tests --- common/locales/en/api-v3.json | 4 +- common/script/index.js | 2 + common/script/ops/openMysteryItem.js | 54 +++++++++++-------- tasks/gulp-eslint.js | 1 - .../user/POST-user_open_mystery_item.test.js | 26 +++++++++ test/common/ops/openMysteryItem.js | 38 +++++++++++++ website/src/controllers/api-v3/user.js | 20 +++++++ 7 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_open_mystery_item.test.js create mode 100644 test/common/ops/openMysteryItem.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a9c0a0fb2d..0546d97789 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -128,5 +128,7 @@ "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", "notEnoughGemsToSend": "Amount must be within 0 and your current number of gems.", - "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>." + "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>.", + "mysteryItemIsEmpty": "Mystery items are empty", + "mysteryItemOpened": "Mystery item opened." } diff --git a/common/script/index.js b/common/script/index.js index 7f9736044d..3d91749c62 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -114,6 +114,7 @@ import feed from './ops/feed'; import equip from './ops/equip'; import changeClass from './ops/changeClass'; import disableClasses from './ops/disableClasses'; +import openMysteryItem from './ops/openMysteryItem'; api.ops = { scoreTask, @@ -129,6 +130,7 @@ api.ops = { equip, changeClass, disableClasses, + openMysteryItem, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/openMysteryItem.js b/common/script/ops/openMysteryItem.js index eb28c605e0..5767ee5f75 100644 --- a/common/script/ops/openMysteryItem.js +++ b/common/script/ops/openMysteryItem.js @@ -1,32 +1,40 @@ import content from '../content/index'; +import i18n from '../i18n'; +import { + BadRequest, +} from '../libs/errors'; +import _ from 'lodash'; + +module.exports = function openMysteryItem (user, req = {}, analytics) { + let item = user.purchased.plan.mysteryItems.shift(); -module.exports = function(user, req, cb, analytics) { - var analyticsData, item, ref, ref1; - item = (ref = user.purchased.plan) != null ? (ref1 = ref.mysteryItems) != null ? ref1.shift() : void 0 : void 0; if (!item) { - return typeof cb === "function" ? cb({ - code: 400, - message: "Empty" - }) : void 0; - } - item = content.gear.flat[item]; - user.items.gear.owned[item.key] = true; - if (typeof user.markModified === "function") { - user.markModified('purchased.plan.mysteryItems'); + throw new BadRequest(i18n.t('mysteryItemIsEmpty', req.language)); } + + item = _.cloneDeep(content.gear.flat[item]); item.notificationType = 'Mystery'; - analyticsData = { - uuid: user._id, - itemKey: item, - itemType: 'Subscriber Gear', - acquireMethod: 'Subscriber', - category: 'behavior' - }; - if (analytics != null) { - analytics.track('open mystery item', analyticsData); + user.items.gear.owned[item.key] = true; + + user.markModified('purchased.plan.mysteryItems'); + + if (analytics) { + analytics.track('open mystery item', { + uuid: user._id, + itemKey: item, + itemType: 'Subscriber Gear', + acquireMethod: 'Subscriber', + category: 'behavior', + }); } + if (typeof window !== 'undefined') { - (user._tmp != null ? user._tmp : user._tmp = {}).drop = item; + if (!user._tmp) user._tmp = {}; + user._tmp.drop = item; } - return typeof cb === "function" ? cb(null, user.items.gear.owned) : void 0; + + return { + message: i18n.t('mysteryItemOpened', req.language), + data: user.items.gear.owned, + }; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 6fb94f6d2b..3aa9f57c75 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -29,7 +29,6 @@ const COMMON_FILES = [ '!./common/script/ops/getTag.js', '!./common/script/ops/getTags.js', '!./common/script/ops/hourglassPurchase.js', - '!./common/script/ops/openMysteryItem.js', '!./common/script/ops/purchase.js', '!./common/script/ops/readCard.js', '!./common/script/ops/rebirth.js', diff --git a/test/api/v3/integration/user/POST-user_open_mystery_item.test.js b/test/api/v3/integration/user/POST-user_open_mystery_item.test.js new file mode 100644 index 0000000000..2c43445118 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_open_mystery_item.test.js @@ -0,0 +1,26 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/open-mystery-item', () => { + let user; + let mysteryItemKey = 'eyewear_special_summerRogue'; + + beforeEach(async () => { + user = await generateUser({ + 'purchased.plan.mysteryItems': [mysteryItemKey], + }); + }); + + // More tests in common code unit tests + + it('opens a mystery item', async () => { + let response = await user.post(`/user/open-mystery-item`); + await user.sync(); + + expect(user.items.gear.owned[mysteryItemKey]).to.be.true; + expect(response.message).to.equal(t('mysteryItemOpened')); + expect(response.data).to.deep.equal(user.items.gear.owned); + }); +}); diff --git a/test/common/ops/openMysteryItem.js b/test/common/ops/openMysteryItem.js new file mode 100644 index 0000000000..c45146a585 --- /dev/null +++ b/test/common/ops/openMysteryItem.js @@ -0,0 +1,38 @@ +import openMysteryItem from '../../../common/script/ops/openMysteryItem'; +import { + generateUser, +} from '../../helpers/common.helper'; +import { + BadRequest, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; + +describe('shared.ops.openMysteryItem', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('returns error when item key is empty', (done) => { + try { + openMysteryItem(user); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('mysteryItemIsEmpty')); + done(); + } + }); + + it('opens mystery item', () => { + let mysteryItemKey = 'eyewear_special_summerRogue'; + + user.purchased.plan.mysteryItems = [mysteryItemKey]; + + let response = openMysteryItem(user); + + expect(user.items.gear.owned[mysteryItemKey]).to.be.true; + expect(response.message).to.equal(i18n.t('mysteryItemOpened')); + expect(response.data).to.equal(user.items.gear.owned); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 147f1552c9..a163bcdf34 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -676,4 +676,24 @@ api.disableClasses = { }, }; +/** +* @api {post} /user/open-mystery-item Open the mystery item. +* @apiVersion 3.0.0 +* @apiName UserOpenMysteryItem +* @apiGroup User +* +* @apiSuccess {Object} data `user.items.gear.owned` +*/ +api.userOpenMysteryItem = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/open-mystery-item', + async handler (req, res) { + let user = res.locals.user; + let openMysteryItemResponse = common.ops.openMysteryItem(user, req, res.analytics); + await user.save(); + res.respond(200, openMysteryItemResponse); + }, +}; + module.exports = api; From 6d9617e345d699f383ae023228fde48c8187c006 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 1 Apr 2016 09:17:40 -0500 Subject: [PATCH 589/976] Ported read card, added unit tests, added read card route and integration tests --- common/locales/en/api-v3.json | 5 +- common/script/index.js | 2 + common/script/ops/readCard.js | 32 ++++++++++--- .../user/POST-user_read_card.test.js | 38 +++++++++++++++ test/common/ops/readCard.js | 48 +++++++++++++++++++ website/src/controllers/api-v3/user.js | 22 +++++++++ 6 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_read_card.test.js create mode 100644 test/common/ops/readCard.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a9c0a0fb2d..eb412dd1ed 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -128,5 +128,8 @@ "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", "notEnoughGemsToSend": "Amount must be within 0 and your current number of gems.", - "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>." + "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>.", + "readCard": "<%= cardType %> has been read", + "cardTypeRequired": "Card type required", + "cardTypeNotAllowed": "Unkown card type." } diff --git a/common/script/index.js b/common/script/index.js index 7f9736044d..f2aeecea0a 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -114,6 +114,7 @@ import feed from './ops/feed'; import equip from './ops/equip'; import changeClass from './ops/changeClass'; import disableClasses from './ops/disableClasses'; +import readCard from './ops/readCard'; api.ops = { scoreTask, @@ -129,6 +130,7 @@ api.ops = { equip, changeClass, disableClasses, + readCard, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/readCard.js b/common/script/ops/readCard.js index a6eb5c05f1..b6d34dcd7d 100644 --- a/common/script/ops/readCard.js +++ b/common/script/ops/readCard.js @@ -1,10 +1,28 @@ -module.exports = function(user, req, cb) { - var cardType; - cardType = req.params.cardType; - user.items.special[cardType + "Received"].shift(); - if (typeof user.markModified === "function") { - user.markModified("items.special." + cardType + "Received"); +import splitWhitespace from '../libs/splitWhitespace'; +import _ from 'lodash'; +import i18n from '../i18n'; +import { + BadRequest, + NotAuthorized, +} from '../libs/errors'; +import content from '../content/index'; + +module.exports = function readCard (user, req = {}) { + let cardType = _.get(req.params, 'cardType'); + + if (!cardType) { + throw new BadRequest(i18n.t('cardTypeRequired', req.language)); } + + if (_.keys(content.cardTypes).indexOf(cardType) === -1) { + throw new NotAuthorized(i18n.t('cardTypeNotAllowed', req.language)); + } + + user.items.special[`${cardType}Received`].shift(); user.flags.cardReceived = false; - return typeof cb === "function" ? cb(null, 'items.special flags.cardReceived') : void 0; + + return { + message: i18n.t('readCard', {cardType}, req.language), + data: _.pick(user, splitWhitespace('items.special flags.cardReceived')), + }; }; diff --git a/test/api/v3/integration/user/POST-user_read_card.test.js b/test/api/v3/integration/user/POST-user_read_card.test.js new file mode 100644 index 0000000000..3b3573b6cc --- /dev/null +++ b/test/api/v3/integration/user/POST-user_read_card.test.js @@ -0,0 +1,38 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/read-card/:cardType', () => { + let user; + let cardType = 'greeting'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error when unknown cardType is provded', async () => { + await expect(user.post('/user/read-card/randomCardType')) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cardTypeNotAllowed'), + }); + }); + + // More tests in common code unit tests + + it('reads a card', async () => { + await user.update({ + 'items.special.greetingReceived': [true], + 'flags.cardReceived': true, + }); + + let response = await user.post(`/user/read-card/${cardType}`); + await user.sync(); + + expect(response.message).to.equal(t('readCard', {cardType})); + expect(user.items.special[`${cardType}Received`]).to.be.empty; + expect(user.flags.cardReceived).to.be.false; + }); +}); diff --git a/test/common/ops/readCard.js b/test/common/ops/readCard.js new file mode 100644 index 0000000000..27f78ea5cd --- /dev/null +++ b/test/common/ops/readCard.js @@ -0,0 +1,48 @@ +import readCard from '../../../common/script/ops/readCard'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import { + BadRequest, + NotAuthorized, +} from '../../../common/script/libs/errors'; + +describe('shared.ops.readCard', () => { + let user; + let cardType = 'greeting'; + + beforeEach(() => { + user = generateUser(); + user.items.special[`${cardType}Received`] = [true]; + user.flags.cardReceived = true; + }); + + it('returns an error when cardType is not provided', (done) => { + try { + readCard(user); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('cardTypeRequired')); + done(); + } + }); + + it('returns an error when unknown cardType is provided', (done) => { + try { + readCard(user, {params: {cardType: 'randomCardType'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('cardTypeNotAllowed')); + done(); + } + }); + + it('reads a card', () => { + let response = readCard(user, {params: {cardType: 'greeting'}}); + + expect(response.message).to.equal(i18n.t('readCard', {cardType})); + expect(user.items.special[`${cardType}Received`]).to.be.empty; + expect(user.flags.cardReceived).to.be.false; + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 147f1552c9..dc68bbf5bc 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -676,4 +676,26 @@ api.disableClasses = { }, }; +/** +* @api {post} /user/read-card/:cardType Reads a card. +* @apiVersion 3.0.0 +* @apiName UserReadCard +* @apiGroup User +* +* @apiParam {string} cardType Type of card to read +* +* @apiSuccess {Object} data `items.special flags.cardReceived` +*/ +api.readCard = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/read-card/:cardType', + async handler (req, res) { + let user = res.locals.user; + let readCardResponse = common.ops.readCard(user, req); + await user.save(); + res.respond(200, readCardResponse); + }, +}; + module.exports = api; From e6e2c26a12f8daeda480e91ad513c8805541043a Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Fri, 1 Apr 2016 15:54:47 +0000 Subject: [PATCH 590/976] shared-code-user-score-task --- common/script/fns/randomDrop.js | 2 +- common/script/fns/updateStats.js | 3 +- common/script/ops/scoreTask.js | 17 +- test/common/ops/scoreTask.test.js | 203 ++++++++++++++++++ .../api-integration/v3/object-generators.js | 7 + website/src/controllers/api-v3/tasks.js | 3 - 6 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 test/common/ops/scoreTask.test.js diff --git a/common/script/fns/randomDrop.js b/common/script/fns/randomDrop.js index b0121b157b..0ab6a5e69a 100644 --- a/common/script/fns/randomDrop.js +++ b/common/script/fns/randomDrop.js @@ -11,7 +11,7 @@ function cloneDropItem (drop) { }); } -module.exports = function(user, modifiers, req) { +module.exports = function randomDrop (user, modifiers, req) { var acceptableDrops, base, base1, base2, chance, drop, dropK, dropMultiplier, name, name1, name2, quest, rarity, ref, ref1, ref2, ref3, task; task = modifiers.task; chance = _.min([Math.abs(task.value - 21.27), 37.5]) / 150 + .02; diff --git a/common/script/fns/updateStats.js b/common/script/fns/updateStats.js index 3ce83ed664..51e78ccb79 100644 --- a/common/script/fns/updateStats.js +++ b/common/script/fns/updateStats.js @@ -4,7 +4,8 @@ import { MAX_STAT_POINTS } from '../constants'; import { toNextLevel } from '../statHelpers'; -module.exports = function (user, stats, req, analytics) { + +module.exports = function updateStats (user, stats, req, analytics) { let allocatedStatPoints; let totalStatPoints; let experienceToNextLevel; diff --git a/common/script/ops/scoreTask.js b/common/script/ops/scoreTask.js index 5bb8406fd8..f5ff5f115b 100644 --- a/common/script/ops/scoreTask.js +++ b/common/script/ops/scoreTask.js @@ -27,7 +27,7 @@ function _calculateDelta (task, direction, cron) { // Checklists if (task.checklist && task.checklist.length > 0) { - // If the Daily, only dock them them a portion based on their checklist completion + // If the Daily, only dock them a portion based on their checklist completion if (direction === 'down' && task.type === 'daily' && cron) { nextDelta *= 1 - _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; } @@ -43,7 +43,7 @@ function _calculateDelta (task, direction, cron) { // Approximates the reverse delta for the task value // This is meant to return the task value to its original value when unchecking a task. -// First, calculate the the value using the normal way for our first guess although +// First, calculate the value using the normal way for our first guess although // it will be a bit off function _calculateReverseDelta (task, direction) { let currVal = _getTaskValue(task.value); @@ -185,6 +185,7 @@ module.exports = function scoreTask (options = {}, req = {}) { // ===== starting to actually do stuff, most of above was definitions ===== if (task.type === 'habit') { delta += _changeTaskValue(user, task, direction, times, cron); + // Add habit value to habit-history (if different) if (delta > 0) { _addPoints(user, task, stats, direction, delta); @@ -213,17 +214,25 @@ module.exports = function scoreTask (options = {}, req = {}) { task.streak += 1; // Give a streak achievement when the streak is a multiple of 21 if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1; - } else { + task.completed = true; + } else if (direction === 'down') { // Remove a streak achievement if streak was a multiple of 21 and the daily was undone if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak - 1 : 0; task.streak -= 1; + task.completed = false; } } } else if (task.type === 'todo') { if (cron) { // don't touch stats on cron delta += _changeTaskValue(user, task, direction, times, cron); } else { - task.dateCompleted = direction === 'up' ? new Date() : undefined; + if (direction === 'up') { + task.dateCompleted = new Date(); + task.completed = true; + } else if (direction === 'down') { + task.completed = false; + task.dateCompleted = undefined; + } delta += _changeTaskValue(user, task, direction, times, cron); if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly diff --git a/test/common/ops/scoreTask.test.js b/test/common/ops/scoreTask.test.js new file mode 100644 index 0000000000..727b316d62 --- /dev/null +++ b/test/common/ops/scoreTask.test.js @@ -0,0 +1,203 @@ +import scoreTask from '../../../common/script/ops/scoreTask'; +import { + generateUser, + generateDaily, + generateHabit, + generateTodo, + generateReward, +} from '../../helpers/common.helper'; +import common from '../../../common'; +import i18n from '../../../common/script/i18n'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; + +let EPSILON = 0.0001; // negligible distance between datapoints + +/* Helper Functions */ +let rewrapUser = (user) => { + user._wrapped = false; + common.wrap(user); + return user; +}; + +let beforeAfter = () => { + let beforeUser = generateUser(); + let afterUser = _.cloneDeep(beforeUser); + rewrapUser(afterUser); + + return { + beforeUser, + afterUser, + }; +}; + +let expectGainedPoints = (beforeUser, afterUser, beforeTask, afterTask) => { + expect(afterUser.stats.hp).to.eql(50); + expect(afterUser.stats.exp).to.be.greaterThan(beforeUser.stats.exp); + expect(afterUser.stats.gp).to.be.greaterThan(beforeUser.stats.gp); + expect(afterTask.value).to.be.greaterThan(beforeTask.value); + if (afterTask.type === 'habit') { + expect(afterTask.history).to.have.length(1); + } +}; + +let expectClosePoints = (beforeUser, afterUser, beforeTask, task) => { + expect(Math.abs(afterUser.stats.exp - beforeUser.stats.exp)).to.be.lessThan(EPSILON); + expect(Math.abs(afterUser.stats.gp - beforeUser.stats.gp)).to.be.lessThan(EPSILON); + expect(Math.abs(task.value - beforeTask.value)).to.be.lessThan(EPSILON); +}; + +let _expectRoughlyEqualDates = (date1, date2) => { + expect(date1.toString()).to.eql(date2.toString()); +}; + +describe('shared.ops.scoreTask', () => { + let ref; + + beforeEach(() => { + ref = beforeAfter(); + }); + + it('throws an error when scoring a reward if user does not have enough gold', (done) => { + let reward = generateReward({ userId: ref.afterUser._id, text: 'some reward', value: 100 }); + try { + scoreTask({ user: ref.afterUser, task: reward }); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.eql(i18n.t('messageNotEnoughGold')); + done(); + } + }); + + it('checks that the streak parameters affects the score', () => { + let task = generateDaily({ userId: ref.afterUser._id, text: 'task to check streak' }); + scoreTask({ user: ref.afterUser, task, direction: 'up', cron: false }); + scoreTask({ user: ref.afterUser, task, direction: 'up', cron: false }); + expect(task.streak).to.eql(2); + }); + + it('completes when the task direction is up', () => { + let task = generateTodo({ userId: ref.afterUser._id, text: 'todo to complete', cron: false }); + scoreTask({ user: ref.afterUser, task, direction: 'up' }); + expect(task.completed).to.eql(true); + _expectRoughlyEqualDates(task.dateCompleted, new Date()); + }); + + it('uncompletes when the task direction is down', () => { + let task = generateTodo({ userId: ref.afterUser._id, text: 'todo to complete', cron: false }); + scoreTask({ user: ref.afterUser, task, direction: 'down' }); + expect(task.completed).to.eql(false); + expect(task.dateCompleted).to.not.exist; + }); + + describe('verifies that times parameter in scoring works', () => { + let habit; + + beforeEach(() => { + ref = beforeAfter(); + habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); + }); + + it('works', () => { + let delta1, delta2, delta3; + + delta1 = scoreTask({ user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false }); + + ref = beforeAfter(); + habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); + + delta2 = scoreTask({ user: ref.afterUser, task: habit, direction: 'up', times: 4, cron: false }); + + ref = beforeAfter(); + habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); + + delta3 = scoreTask({ user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false }); + + expect(Math.abs(delta1 - delta2)).to.be.greaterThan(EPSILON); + expect(Math.abs(delta1 - delta3)).to.be.lessThan(EPSILON); + }); + }); + + describe('scores', () => { + let options = {}; + let habit; + let freshDaily, daily; + let freshTodo, todo; + + beforeEach(() => { + ref = beforeAfter(options); + habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); + freshDaily = generateDaily({ userId: ref.afterUser._id, text: 'some daily' }); + daily = generateDaily({ userId: ref.afterUser._id, text: 'some daily' }); + freshTodo = generateTodo({ userId: ref.afterUser._id, text: 'some todo' }); + todo = generateTodo({ userId: ref.afterUser._id, text: 'some todo' }); + + expect(habit.history.length).to.eql(0); + + // before and after are the same user + expect(ref.beforeUser._id).to.exist; + expect(ref.beforeUser._id).to.eql(ref.afterUser._id); + }); + + context('habits', () => { + it('up', () => { + options = { user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false }; + scoreTask(options); + + expect(habit.history.length).to.eql(1); + expect(habit.value).to.be.greaterThan(0); + + expect(ref.afterUser.stats.hp).to.eql(50); + expect(ref.afterUser.stats.exp).to.be.greaterThan(ref.beforeUser.stats.exp); + expect(ref.afterUser.stats.gp).to.be.greaterThan(ref.beforeUser.stats.gp); + }); + + it('down', () => { + scoreTask({user: ref.afterUser, task: habit, direction: 'down', times: 5, cron: false}, {}); + + expect(habit.history.length).to.eql(1); + expect(habit.value).to.be.lessThan(0); + + expect(ref.afterUser.stats.hp).to.be.lessThan(ref.beforeUser.stats.hp); + expect(ref.afterUser.stats.exp).to.eql(0); + expect(ref.afterUser.stats.gp).to.eql(0); + }); + }); + + context('dailys', () => { + it('up', () => { + expect(daily.completed).to.not.eql(true); + scoreTask({user: ref.afterUser, task: daily, direction: 'up'}); + expectGainedPoints(ref.beforeUser, ref.afterUser, freshDaily, daily); + expect(daily.completed).to.eql(true); + }); + + it('up, down', () => { + scoreTask({user: ref.afterUser, task: daily, direction: 'up'}); + scoreTask({user: ref.afterUser, task: daily, direction: 'down'}); + expectClosePoints(ref.beforeUser, ref.afterUser, freshDaily, daily); + }); + + it('sets completed = false on direction = down', () => { + daily.completed = true; + expect(daily.completed).to.not.eql(false); + scoreTask({user: ref.afterUser, task: daily, direction: 'down'}); + expect(daily.completed).to.eql(false); + }); + }); + + context('todos', () => { + it('up', () => { + scoreTask({user: ref.afterUser, task: todo, direction: 'up'}); + expectGainedPoints(ref.beforeUser, ref.afterUser, freshTodo, todo); + }); + + it('up, down', () => { + scoreTask({user: ref.afterUser, task: todo, direction: 'up'}); + scoreTask({user: ref.afterUser, task: todo, direction: 'down'}); + expectClosePoints(ref.beforeUser, ref.afterUser, freshTodo, todo); + }); + }); + }); +}); diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index 42de6d243c..0d9921159b 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -54,6 +54,13 @@ export async function generateReward (update = {}) { return task; } +export async function generateTodo (update = {}) { + let type = 'todo'; + let task = new Tasks[type](update); + await task.save({ validateBeforeSave: false }); + return task; +} + // Generates a new group. Requires a user object, which // will will become the groups leader. Takes a details argument // for the initial group creation and an update argument which diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index f8883312b0..e2adcffedb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -393,9 +393,6 @@ api.scoreTask = { if (!task) throw new NotFound(res.t('taskNotFound')); let wasCompleted = task.completed; - if (task.type === 'daily' || task.type === 'todo') { - task.completed = direction === 'up'; // TODO move into scoreTask - } let delta = common.ops.scoreTask({task, user, direction}, req); // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) From 6cbbdcdcbeb322e606c1c4de82812e5349ea9552 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 30 Mar 2016 17:20:01 +0200 Subject: [PATCH 591/976] v3: port static pages, make routes lib more flexible, share middlewares between v2 and v3, port v1, simplify server.js --- .../v3/integration/status/GET-status.test.js | 12 ++ test/api/v3/unit/middlewares/cors.test.js | 40 +++++ .../unit/middlewares/getUserLanguage.test.js | 3 + test/helpers/api-unit.helper.js | 2 + website/src/controllers/api-v3/status.js | 21 +++ website/src/controllers/pages.js | 73 ++++++++ website/src/libs/api-v2/utils.js | 3 - website/src/libs/api-v3/routes.js | 31 ++++ website/src/libs/api-v3/setupMongoose.js | 21 +++ website/src/libs/api-v3/setupPassport.js | 24 +++ website/src/libs/api-v3/setupRoutes.js | 37 ---- website/src/middlewares/api-v3/cors.js | 9 + .../src/middlewares/api-v3/getUserLanguage.js | 1 + website/src/middlewares/api-v3/index.js | 66 +++++-- website/src/middlewares/api-v3/redirects.js | 43 +++++ website/src/middlewares/api-v3/v1.js | 19 ++ website/src/middlewares/api-v3/v2.js | 26 +++ website/src/middlewares/api-v3/v3.js | 27 +++ website/src/middlewares/apiThrottle.js | 3 + website/src/middlewares/cors.js | 7 - website/src/middlewares/forceRefresh.js | 2 + website/src/middlewares/redirects.js | 41 ----- website/src/routes/api-v1.js | 13 -- website/src/server.js | 163 ++---------------- 24 files changed, 420 insertions(+), 267 deletions(-) create mode 100644 test/api/v3/integration/status/GET-status.test.js create mode 100644 test/api/v3/unit/middlewares/cors.test.js create mode 100644 website/src/controllers/api-v3/status.js create mode 100644 website/src/controllers/pages.js create mode 100644 website/src/libs/api-v3/routes.js create mode 100644 website/src/libs/api-v3/setupMongoose.js create mode 100644 website/src/libs/api-v3/setupPassport.js delete mode 100644 website/src/libs/api-v3/setupRoutes.js create mode 100644 website/src/middlewares/api-v3/cors.js create mode 100644 website/src/middlewares/api-v3/redirects.js create mode 100644 website/src/middlewares/api-v3/v1.js create mode 100644 website/src/middlewares/api-v3/v2.js create mode 100644 website/src/middlewares/api-v3/v3.js delete mode 100644 website/src/middlewares/cors.js delete mode 100644 website/src/middlewares/redirects.js delete mode 100644 website/src/routes/api-v1.js diff --git a/test/api/v3/integration/status/GET-status.test.js b/test/api/v3/integration/status/GET-status.test.js new file mode 100644 index 0000000000..1d4d33a7d7 --- /dev/null +++ b/test/api/v3/integration/status/GET-status.test.js @@ -0,0 +1,12 @@ +import { + requester, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET /status', () => { + it('returns status: up', async () => { + let res = await requester().get('/status'); + expect(res).to.eql({ + status: 'up', + }); + }); +}); diff --git a/test/api/v3/unit/middlewares/cors.test.js b/test/api/v3/unit/middlewares/cors.test.js new file mode 100644 index 0000000000..3fd449c963 --- /dev/null +++ b/test/api/v3/unit/middlewares/cors.test.js @@ -0,0 +1,40 @@ +/* eslint-disable global-require */ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; +import cors from '../../../../../website/src/middlewares/api-v3/cors'; + +describe('cors middleware', () => { + let res, req, next; + + beforeEach(() => { + req = generateReq(); + res = generateRes(); + next = generateNext(); + }); + + it('sets the correct headers', () => { + cors(req, res, next); + expect(res.set).to.have.been.calledWith({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'OPTIONS,GET,POST,PUT,HEAD,DELETE', + 'Access-Control-Allow-Headers': 'Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key', + }); + expect(res.sendStatus).to.not.have.been.called; + expect(next).to.have.been.called.once; + }); + + it('responds immediately if method is OPTIONS', () => { + req.method = 'OPTIONS'; + cors(req, res, next); + expect(res.set).to.have.been.calledWith({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'OPTIONS,GET,POST,PUT,HEAD,DELETE', + 'Access-Control-Allow-Headers': 'Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key', + }); + expect(res.sendStatus).to.have.been.calledWith(200); + expect(next).to.not.have.been.called; + }); +}); diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 43b0cb0ab8..bd7e6aa48c 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -119,6 +119,9 @@ describe('getUserLanguage', () => { context('request with session', () => { it('uses the user preferred language if avalaible', (done) => { sandbox.stub(User, 'findOne').returns({ + lean () { + return this; + }, exec () { return Q.resolve({ preferences: { diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index efcdf09bc3..eb7769256a 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -31,6 +31,7 @@ export function generateRes (options = {}) { user: generateUser(options.localsUser), group: generateGroup(options.localsGroup), }, + set: sandbox.stub(), }; return defaults(options, defaultRes); @@ -41,6 +42,7 @@ export function generateReq (options = {}) { body: {}, query: {}, headers: {}, + header: sandbox.stub().returns(null), }; return defaults(options, defaultReq); diff --git a/website/src/controllers/api-v3/status.js b/website/src/controllers/api-v3/status.js new file mode 100644 index 0000000000..9ed050a275 --- /dev/null +++ b/website/src/controllers/api-v3/status.js @@ -0,0 +1,21 @@ +let api = {}; + +/** + * @api {get} /status Get Habitica's status + * @apiVersion 3.0.0 + * @apiName GetStatus + * @apiGroup Status + * + * @apiSuccess {status} string 'up' if everything is ok + */ +api.getStatus = { + method: 'GET', + url: '/status', + async handler (req, res) { + res.respond(200, { + status: 'up', + }); + }, +}; + +module.exports = api; diff --git a/website/src/controllers/pages.js b/website/src/controllers/pages.js new file mode 100644 index 0000000000..2f6887b722 --- /dev/null +++ b/website/src/controllers/pages.js @@ -0,0 +1,73 @@ +import locals from '../middlewares/api-v3/locals'; +import getUserLanguage from '../middlewares/api-v3/getUserLanguage'; +import _ from 'lodash'; + +const marked = require('marked'); + +let api = {}; + +const TOTAL_USER_COUNT = '1,100,000'; + +api.getFrontPage = { + method: 'GET', + url: '/', + middlewares: [getUserLanguage, locals], + async handler (req, res) { + if (!req.header('x-api-user') && !req.header('x-api-key') && !(req.session && req.session.userId)) { + return res.redirect('/static/front'); + } + + res.render('index.jade', { + title: 'Habitica | Your Life The Role Playing Game', + env: res.locals.habitrpg, + }); + }, +}; + +let staticPages = ['front', 'privacy', 'terms', 'api', 'features', + 'videos', 'contact', 'plans', 'new-stuff', 'community-guidelines', + 'old-news', 'press-kit', 'faq', 'overview', 'apps', + 'clear-browser-data', 'merch']; + +_.each(staticPages, (name) => { + api[`get${name}Page`] = { + method: 'GET', + url: `/static/${name}`, + middlewares: [getUserLanguage, locals], + async handler (req, res) { + res.render(`static/${name}.jade`, { + env: res.locals.habitrpg, + marked: marked, + userCount: TOTAL_USER_COUNT + }); + }, + }; +}); + +let shareables = ['level-up', 'hatch-pet', 'raise-pet', 'unlock-quest', 'won-challenge', 'achievement']; + +_.each(shareables, (name) => { + api[`get${name}ShareablePage`] = { + method: 'GET', + url: `/social/${name}`, + middlewares: [getUserLanguage, locals], + async handler (req, res) { + res.render(`social/${name}`, { + env: res.locals.habitrpg, + marked: marked, + userCount: TOTAL_USER_COUNT + }); + }, + }; +}); + +api.redirectExtensionsPage = { + method: 'GET', + url: '/static/extensions', + async handler (req, res) { + res.redirect('http://habitica.wikia.com/wiki/App_and_Extension_Integrations'); + }, +}; + + +module.exports = api; diff --git a/website/src/libs/api-v2/utils.js b/website/src/libs/api-v2/utils.js index 6686bce112..62a69c4322 100644 --- a/website/src/libs/api-v2/utils.js +++ b/website/src/libs/api-v2/utils.js @@ -167,9 +167,6 @@ module.exports.analytics = { track: function() { }, trackPurchase: function() { * Load nconf and define default configuration values if config.json or ENV vars are not found */ module.exports.setupConfig = function(){ - IS_PROD = nconf.get('NODE_ENV') === 'production'; - BASE_URL = nconf.get('BASE_URL'); - if (nconf.get('IS_DEV')) Error.stackTraceLimit = Infinity; if (IS_PROD && nconf.get('NEW_RELIC_ENABLED') === 'true') diff --git a/website/src/libs/api-v3/routes.js b/website/src/libs/api-v3/routes.js new file mode 100644 index 0000000000..0a6f3170ab --- /dev/null +++ b/website/src/libs/api-v3/routes.js @@ -0,0 +1,31 @@ +import fs from 'fs'; +import _ from 'lodash'; + +// Wrapper function to handler `async` route handlers that return promises +// It takes the async function, execute it and pass any error to next (args[2]) +let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]); +let noop = (req, res, next) => next(); + +module.exports.readController = function readController (router, controller) { + _.each(controller, (action) => { + let {method, url, middlewares = [], handler} = action; + + method = method.toLowerCase(); + let fn = handler ? _wrapAsyncFn(handler) : noop; + + router[method](url, ...middlewares, fn); + }); +}; + +module.exports.walkControllers = function walkControllers (router, filePath) { + fs + .readdirSync(filePath) + .forEach(fileName => { + if (!fs.statSync(filePath + fileName).isFile()) { + walkControllers(router, `${filePath}${fileName}/`); + } else if (fileName.match(/\.js$/)) { + let controller = require(filePath + fileName); // eslint-disable-line global-require + module.exports.readController(router, controller); + } + }); +}; diff --git a/website/src/libs/api-v3/setupMongoose.js b/website/src/libs/api-v3/setupMongoose.js new file mode 100644 index 0000000000..303180679c --- /dev/null +++ b/website/src/libs/api-v3/setupMongoose.js @@ -0,0 +1,21 @@ +import nconf from 'nconf'; +import logger from './logger'; +import autoinc from 'mongoose-id-autoinc'; +import mongoose from 'mongoose'; +import Q from 'q'; + +const IS_PROD = nconf.get('IS_PROD'); + +// Use Q promises instead of mpromise in mongoose +mongoose.Promise = Q.Promise; + +let mongooseOptions = !IS_PROD ? {} : { + replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, + server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, +}; +let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { + if (err) throw err; + logger.info('Connected with Mongoose.'); +}); + +autoinc.init(db); diff --git a/website/src/libs/api-v3/setupPassport.js b/website/src/libs/api-v3/setupPassport.js new file mode 100644 index 0000000000..ea5c55a4c2 --- /dev/null +++ b/website/src/libs/api-v3/setupPassport.js @@ -0,0 +1,24 @@ +import passport from 'passport'; +import nconf from 'nconf'; +import passportFacebook from 'passport-facebook'; + +const FacebookStrategy = passportFacebook.Strategy; + +// Passport session setup. +// To support persistent login sessions, Passport needs to be able to +// serialize users into and deserialize users out of the session. Typically, +// this will be as simple as storing the user ID when serializing, and finding +// the user by ID when deserializing. However, since this example does not +// have a database of user records, the complete Facebook profile is serialized +// and deserialized. +passport.serializeUser((user, done) => done(null, user)); +passport.deserializeUser((obj, done) => done(null, obj)); + +// TODO +// This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) +// The proper fix would be to move to a general OAuth module simply to verify accessTokens +passport.use(new FacebookStrategy({ + clientID: nconf.get('FACEBOOK_KEY'), + clientSecret: nconf.get('FACEBOOK_SECRET'), + // callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" +}, (accessToken, refreshToken, profile, done) => done(null, profile))); diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js deleted file mode 100644 index 3b91b81f25..0000000000 --- a/website/src/libs/api-v3/setupRoutes.js +++ /dev/null @@ -1,37 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import express from 'express'; -import _ from 'lodash'; - -const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); -let router = express.Router(); // eslint-disable-line babel/new-cap - -// Wrapper function to handler `async` route handlers that return promises -// It takes the async function, execute it and pass any error to next (args[2]) -let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]); -let noop = (req, res, next) => next(); - -function walkControllers (filePath) { - fs - .readdirSync(filePath) - .forEach(fileName => { - if (!fs.statSync(filePath + fileName).isFile()) { - walkControllers(`${filePath}${fileName}/`); - } else if (fileName.match(/\.js$/)) { - let controller = require(filePath + fileName); // eslint-disable-line global-require - - _.each(controller, (action) => { - let {method, url, middlewares = [], handler} = action; - - method = method.toLowerCase(); - let fn = handler ? _wrapAsyncFn(handler) : noop; - - router[method](url, ...middlewares, fn); - }); - } - }); -} - -walkControllers(CONTROLLERS_PATH); - -module.exports = router; diff --git a/website/src/middlewares/api-v3/cors.js b/website/src/middlewares/api-v3/cors.js new file mode 100644 index 0000000000..c249c183c6 --- /dev/null +++ b/website/src/middlewares/api-v3/cors.js @@ -0,0 +1,9 @@ +module.exports = function corsMiddleware (req, res, next) { + res.set({ + 'Access-Control-Allow-Origin': req.header('origin') || '*', + 'Access-Control-Allow-Methods': 'OPTIONS,GET,POST,PUT,HEAD,DELETE', + 'Access-Control-Allow-Headers': 'Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key', + }); + if (req.method === 'OPTIONS') return res.sendStatus(200); + return next(); +}; diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/getUserLanguage.js index 086121b26b..df372c02b4 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/getUserLanguage.js @@ -75,6 +75,7 @@ module.exports = function getUserLanguage (req, res, next) { User.findOne({ _id: req.session.userId, }, 'preferences.language') + .lean() .exec() .then((user) => { req.language = _getFromUser(user, req); diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 4f4349944d..7fd6c7a259 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -1,48 +1,80 @@ // This module is only used to attach middlewares to the express app -import expressValidator from 'express-validator'; -import getUserLanguage from './getUserLanguage'; -import analytics from './analytics'; import errorHandler from './errorHandler'; import bodyParser from 'body-parser'; -import routes from '../../libs/api-v3/setupRoutes'; import notFoundHandler from './notFound'; import nconf from 'nconf'; import morgan from 'morgan'; -import responseHandler from './response'; -import setupBody from './setupBody'; import cookieSession from 'cookie-session'; +import cors from './cors'; +import staticMiddleware from './static'; +import domainMiddleware from './domain'; +import mongoose from 'mongoose'; +import compression from 'compression'; +import favicon from 'serve-favicon'; +import methodOverride from 'method-override'; +import passport from 'passport'; +import path from 'path'; +import express from 'express'; +import routes from '../../libs/api-v3/routes'; +import { + forceSSL, + forceHabitica, +} from './redirects'; +import v1 from './v1'; +import v2 from './v2'; +import v3 from './v3'; +import staticPagesController from '../../controllers/pages'; const IS_PROD = nconf.get('IS_PROD'); const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); +const PUBLIC_DIR = path.join(__dirname, '/../../../public'); const SESSION_SECRET = nconf.get('SESSION_SECRET'); const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; -module.exports = function attachMiddlewares (app) { +module.exports = function attachMiddlewares (app, server) { + app.use(domainMiddleware(server, mongoose)); + if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev')); - // TODO handle errors + app.use(compression()); + app.use(favicon(`${PUBLIC_DIR}/favicon.ico`)); + + app.use(cors); + app.use(forceSSL); + app.use(forceHabitica); + + // TODO if we don't manage to move the client off $resource the limit for bodyParser.json must be increased to 1mb from 100kb (default) app.use(bodyParser.urlencoded({ extended: true, // Uses 'qs' library as old connect middleware })); app.use(bodyParser.json()); + app.use(methodOverride()); // TODO still needed in 2016? + app.use(cookieSession({ name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies secret: SESSION_SECRET, httpOnly: false, // TODO this should be true for security, what about https only? maxAge: TWO_WEEKS, })); - app.use(expressValidator()); - app.use(analytics); - app.use(setupBody); - app.use(responseHandler); - app.use(getUserLanguage); - app.set('view engine', 'jade'); - app.set('views', `${__dirname}/../../../views`); - app.use('/api/v3', routes); + // Initialize Passport! Also use passport.session() middleware, to support + // persistent login sessions (recommended). + app.use(passport.initialize()); + app.use(passport.session()); + + const staticPagesRouter = express.Router(); // eslint-disable-line babel/new-cap + routes.readController(staticPagesRouter, staticPagesController); + app.use('/', staticPagesRouter); + + app.use('/api/v3', v3); + app.use('/api/v2', v2); + app.use('/api/v1', v1); + staticMiddleware(app); + app.use(notFoundHandler); - // Error handler middleware, define as the last one + // Error handler middleware, define as the last one. + // Used for v3 and v1, v2 will keep using its own error handler app.use(errorHandler); }; diff --git a/website/src/middlewares/api-v3/redirects.js b/website/src/middlewares/api-v3/redirects.js new file mode 100644 index 0000000000..a907a89b14 --- /dev/null +++ b/website/src/middlewares/api-v3/redirects.js @@ -0,0 +1,43 @@ +import nconf from 'nconf'; + +const IS_PROD = nconf.get('IS_PROD'); +const IGNORE_REDIRECT = nconf.get('IGNORE_REDIRECT'); +const BASE_URL = nconf.get('BASE_URL'); + +function isHTTP (req) { + return ( // eslint-disable-line no-extra-parens + req.header('x-forwarded-proto') && + req.header('x-forwarded-proto') !== 'https' && + IS_PROD && + BASE_URL.indexOf('https') === 0 + ); +} + +function isProxied (req) { + return ( // eslint-disable-line no-extra-parens + req.header('x-habitica-lb') && + req.header('x-habitica-lb') === 'Yes' + ); +} + +export function forceSSL (req, res, next) { + if (isHTTP(req) && !isProxied(req)) { + return res.redirect(BASE_URL + req.originalUrl); + } + + next(); +} + +// Redirect to habitica for non-api urls + +function nonApiUrl (req) { + return req.originalUrl.search(/\/api\//) === -1; +} + +export function forceHabitica (req, res, next) { + if (IS_PROD && !IGNORE_REDIRECT && !isProxied(req) && nonApiUrl(req)) { + return res.redirect(301, BASE_URL + req.url); + } + + next(); +} diff --git a/website/src/middlewares/api-v3/v1.js b/website/src/middlewares/api-v3/v1.js new file mode 100644 index 0000000000..abe26f42e6 --- /dev/null +++ b/website/src/middlewares/api-v3/v1.js @@ -0,0 +1,19 @@ +// API v1 middlewares and routes +// DEPRECATED AND INACTIVE + +import express from 'express'; +import nconf from 'nconf'; +import { + NotFound, +} from '../../libs/api-v3/errors'; + +const router = express.Router(); // eslint-disable-line babel/new-cap + +const BASE_URL = nconf.get('BASE_URL'); + +router.all('*', function deprecatedV1 (req, res, next) { + let error = new NotFound(`API v1 is no longer supported, please use API v3 instead (${BASE_URL}/static/api).`); + return next(error); +}); + +module.exports = router; diff --git a/website/src/middlewares/api-v3/v2.js b/website/src/middlewares/api-v3/v2.js new file mode 100644 index 0000000000..822927b119 --- /dev/null +++ b/website/src/middlewares/api-v3/v2.js @@ -0,0 +1,26 @@ +// DEPRECATED BUT STILL ACTIVE + +// import path from 'path'; +// import swagger from 'swagger-node-express'; +// import shared from '../../../../common'; +import express from 'express'; + +const v2app = express(); + +// re-set the view options because they are not inherited from the top level app +v2app.set('view engine', 'jade'); +v2app.set('views', `${__dirname}/../../../views`); + +// Custom Directives +// v2app.use('/', require('../../routes/api-v2/auth')); +// v2app.use('/', require('../../routes/api-v2/coupon')); +// v2app.use('/', require('../../routes/api-v2/unsubscription')); + +// const v2routes = express(); +// v2app.use('/api/v2', v2routes); +// v2app.use('/export', require('../../routes/dataexport')); +// require('../../routes/api-v2/swagger')(swagger, v2); + +// v2app.use(require('../api-v2/errorHandler')); + +module.exports = v2app; diff --git a/website/src/middlewares/api-v3/v3.js b/website/src/middlewares/api-v3/v3.js new file mode 100644 index 0000000000..a7c6e0d396 --- /dev/null +++ b/website/src/middlewares/api-v3/v3.js @@ -0,0 +1,27 @@ +import express from 'express'; +import expressValidator from 'express-validator'; +import getUserLanguage from './getUserLanguage'; +import responseHandler from './response'; +import analytics from './analytics'; +import setupBody from './setupBody'; +import routes from '../../libs/api-v3/routes'; +import path from 'path'; + +const v3app = express(); + +// re-set the view options because they are not inherited from the top level app +v3app.set('view engine', 'jade'); +v3app.set('views', `${__dirname}/../../../views`); + +v3app.use(expressValidator()); +v3app.use(analytics); +v3app.use(setupBody); +v3app.use(responseHandler); +v3app.use(getUserLanguage); // TODO move to after auth for authenticated routes + +const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); +const router = express.Router(); // eslint-disable-line babel/new-cap +routes.walkControllers(router, CONTROLLERS_PATH); +v3app.use(router); + +module.exports = v3app; diff --git a/website/src/middlewares/apiThrottle.js b/website/src/middlewares/apiThrottle.js index 8de298106c..63995e410c 100644 --- a/website/src/middlewares/apiThrottle.js +++ b/website/src/middlewares/apiThrottle.js @@ -3,6 +3,9 @@ var limiter = require('connect-ratelimit'); var IS_PROD = nconf.get('NODE_ENV') === 'production'; +// TODO since Habitica runs on many different servers this module is pretty useless +// as it will only block requests that go to the same server + module.exports = function(app) { // TODO review later // disable the rate limiter middleware diff --git a/website/src/middlewares/cors.js b/website/src/middlewares/cors.js deleted file mode 100644 index e72db26981..0000000000 --- a/website/src/middlewares/cors.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function(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,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key"); - if (req.method === 'OPTIONS') return res.sendStatus(200); - return next(); -}; diff --git a/website/src/middlewares/forceRefresh.js b/website/src/middlewares/forceRefresh.js index 6d5b4fa8c9..577ad2f4c6 100644 --- a/website/src/middlewares/forceRefresh.js +++ b/website/src/middlewares/forceRefresh.js @@ -1,3 +1,5 @@ +// TODO do we need this module? + module.exports.siteVersion = 1; module.exports.middleware = function(req, res, next){ diff --git a/website/src/middlewares/redirects.js b/website/src/middlewares/redirects.js deleted file mode 100644 index ddc135beab..0000000000 --- a/website/src/middlewares/redirects.js +++ /dev/null @@ -1,41 +0,0 @@ -var nconf = require('nconf'); -var IS_PROD = nconf.get('NODE_ENV') === 'production'; -var ignoreRedirect = nconf.get('IGNORE_REDIRECT'); -var BASE_URL = nconf.get('BASE_URL'); - -function isHTTP(req) { - return ( - req.headers['x-forwarded-proto'] && - req.headers['x-forwarded-proto'] !== 'https' && - IS_PROD && - BASE_URL.indexOf('https') === 0 - ); -} - -function isProxied(req) { - return ( - req.headers['x-habitica-lb'] && - req.headers['x-habitica-lb'] === 'Yes' - ); -} - -module.exports.forceSSL = function(req, res, next){ - if(isHTTP(req) && !isProxied(req)) { - return res.redirect(BASE_URL + req.url); - } - - next(); -}; - -// Redirect to habitica for non-api urls - -function nonApiUrl(req) { - return req.url.search(/\/api\//) === -1; -} - -module.exports.forceHabitica = function(req, res, next) { - if (IS_PROD && !ignoreRedirect && !isProxied(req) && nonApiUrl(req)) { - return res.redirect(301, BASE_URL + req.url); - } - next(); -}; diff --git a/website/src/routes/api-v1.js b/website/src/routes/api-v1.js deleted file mode 100644 index 1178428603..0000000000 --- a/website/src/routes/api-v1.js +++ /dev/null @@ -1,13 +0,0 @@ -var express = require('express'); -var router = express.Router(); -var nconf = require('nconf'); - -/* ---------- Deprecated API ------------*/ - -router.all('*', function deprecated(req, res, next) { - res.json(404, { - err: 'API v1 is no longer supported, please use API v2 instead ' + nconf.get('BASE_URL') + '/static/api' - }); -}); - -module.exports = router; diff --git a/website/src/server.js b/website/src/server.js index 14dd392e68..95280ffaf6 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -1,170 +1,35 @@ -// TODO cleanup all comments when API v3 is finished - import nconf from 'nconf'; import logger from './libs/api-v3/logger'; import express from 'express'; import http from 'http'; -// import path from 'path'; -// let swagger = require('swagger-node-express'); -import autoinc from 'mongoose-id-autoinc'; -import passport from 'passport'; -// let shared = require('../../common'); -import passportFacebook from 'passport-facebook'; -import mongoose from 'mongoose'; -import Q from 'q'; -import domainMiddleware from './middlewares/api-v3/domain'; import attachMiddlewares from './middlewares/api-v3/index'; -import staticMiddleware from './middlewares/api-v3/static'; + +const server = http.createServer(); +const app = express(); + +app.set('port', nconf.get('PORT')); // Setup translations -// let i18n = require('./libs/api-v2/i18n'); - -const IS_PROD = nconf.get('IS_PROD'); -// const IS_DEV = nconf.get('IS_DEV'); -// const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); -// const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; - -let server = http.createServer(); -let app = express(); - -// Mongoose configuration - -// Use Q promises instead of mpromise in mongoose -mongoose.Promise = Q.Promise; -let mongooseOptions = !IS_PROD ? {} : { - replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, - server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, -}; -let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { - if (err) throw err; - logger.info('Connected with Mongoose'); -}); - -autoinc.init(db); +import './libs/api-v3/i18n'; +// Load config files +import './libs/api-v3/setupMongoose'; import './libs/api-v3/firebase'; +import './libs/api-v3/setupPassport'; -// load schemas & models +// Load some schemas & models import './models/challenge'; import './models/group'; import './models/user'; -// ------------ Passport Configuration ------------ -// let util = require('util') -let FacebookStrategy = passportFacebook.Strategy; +app.set('view engine', 'jade'); +app.set('views', `${__dirname}/../views`); -// Passport session setup. -// To support persistent login sessions, Passport needs to be able to -// serialize users into and deserialize users out of the session. Typically, -// this will be as simple as storing the user ID when serializing, and finding -// the user by ID when deserializing. However, since this example does not -// have a database of user records, the complete Facebook profile is serialized -// and deserialized. -passport.serializeUser((user, done) => done(null, user)); -passport.deserializeUser((obj, done) => done(null, obj)); - -// FIXME -// This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) -// The proper fix would be to move to a general OAuth module simply to verify accessTokens -passport.use(new FacebookStrategy({ - clientID: nconf.get('FACEBOOK_KEY'), - clientSecret: nconf.get('FACEBOOK_SECRET'), - // callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" -}, (accessToken, refreshToken, profile, done) => done(null, profile))); - -// ------------ Server Configuration ------------ -// let publicDir = path.join(__dirname, '/../public'); - -app.set('port', nconf.get('PORT')); - -// Setup two different Express apps, one that matches everything except '/api/v3' -// and the other for /api/v3 routes, so we can keep the old an new api versions completely separate -// not sharing a single middleware if we don't want to -let oldApp = express(); // api v1 and v2, and not scoped routes -let newApp = express(); // api v3 - -app.use(domainMiddleware(server, mongoose)); -// Route requests to the right app -// Matches all request except the ones going to /api/v3/** -app.all(/^(?!\/api\/v3).+/i, oldApp); -// Matches all requests going to /api/v3 -app.all('/api/*', newApp); - -// TODO change ^ so that all routes except those marked explictly with api/v2 goes to oldApp - -// Mount middlewares for the new app (api v3) -attachMiddlewares(newApp); - -/* OLD APP IS DISABLED UNTIL COMPATIBLE WITH NEW MODELS -//require('./middlewares/apiThrottle')(oldApp); -oldApp.use(require('./middlewares/api-v2/domain')(server,mongoose)); -if (!IS_PROD && !DISABLE_LOGGING) oldApp.use(require('morgan')("dev")); -oldApp.use(require('compression')()); -oldApp.set("views", __dirname + "/../views"); -oldApp.set("view engine", "jade"); -oldApp.use(require('serve-favicon')(publicDir + '/favicon.ico')); -oldApp.use(require('./middlewares/cors')); - -var redirects = require('./middlewares/redirects'); -oldApp.use(redirects.forceHabitica); -oldApp.use(redirects.forceSSL); -var bodyParser = require('body-parser'); -// Default limit is 100kb, need that because we actually send whole groups to the server -// FIXME as soon as possible (need to move on the client from $resource -> $http) -var BODY_PARSER_LIMIT = '1mb'; -oldApp.use(bodyParser.urlencoded({ - extended: true, - parameterLimit: 10000, // Upped for safety from 1k, FIXME as above - limit: BODY_PARSER_LIMIT, -})); -oldApp.use(bodyParser.json({ - limit: BODY_PARSER_LIMIT, -})); -oldApp.use(require('method-override')()); - -oldApp.use(require('cookie-session')({ - name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies - secret: nconf.get('SESSION_SECRET'), - httpOnly: false, - maxAge: TWO_WEEKS -})); - -// Initialize Passport! Also use passport.session() middleware, to support -// persistent login sessions (recommended). -oldApp.use(passport.initialize()); -oldApp.use(passport.session()); - -// Custom Directives -oldApp.use('/', require('./routes/pages')); -oldApp.use('/', require('./routes/payments')); -oldApp.use('/', require('./routes/api-v2/auth')); -oldApp.use('/', require('./routes/api-v2/coupon')); -oldApp.use('/', require('./routes/api-v2/unsubscription')); -var v2 = express(); -oldApp.use('/api/v2', v2); -oldApp.use('/api/', require('./routes/api-v1')); -oldApp.use('/export', require('./routes/dataexport')); -require('./routes/api-v2/swagger')(swagger, v2); - -// Cache emojis without copying them to build, they are too many - -oldApp.use(require('./middlewares/api-v2/errorHandler')); -* -let maxAge = IS_PROD ? 31536000000 : 0; - -oldApp.use(express.static(path.join(__dirname, '/../build'), { maxAge })); -oldApp.use('/common/dist', express.static(`${publicDir}/../../common/dist`, { maxAge })); -oldApp.use('/common/audio', express.static(`${publicDir}/../../common/audio`, { maxAge })); -oldApp.use('/common/script/public', express.static(`${publicDir}/../../common/script/public`, { maxAge })); -oldApp.use('/common/img', express.static(`${publicDir}/../../common/img`, { maxAge })); -oldApp.use(express.static(publicDir)); -*/ - -staticMiddleware(app); +attachMiddlewares(app, server); server.on('request', app); server.listen(app.get('port'), () => { - return logger.info(`Express server listening on port ${app.get('port')}`); + logger.info(`Express server listening on port ${app.get('port')}`); }); module.exports = server; From 7f3feedd126c603fb9b5c9f769118528331cefca Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Fri, 1 Apr 2016 20:49:56 +0000 Subject: [PATCH 592/976] shared-code-update-stats --- common/script/fns/updateStats.js | 67 +++++------ tasks/gulp-eslint.js | 1 - test/common/fns/updateStats.test.js | 170 ++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 41 deletions(-) create mode 100644 test/common/fns/updateStats.test.js diff --git a/common/script/fns/updateStats.js b/common/script/fns/updateStats.js index 51e78ccb79..6061f717ab 100644 --- a/common/script/fns/updateStats.js +++ b/common/script/fns/updateStats.js @@ -1,22 +1,19 @@ import _ from 'lodash'; import { MAX_HEALTH, - MAX_STAT_POINTS + MAX_STAT_POINTS, } from '../constants'; import { toNextLevel } from '../statHelpers'; +import autoAllocate from './autoAllocate'; -module.exports = function updateStats (user, stats, req, analytics) { +module.exports = function updateStats (user, stats, req = {}, analytics) { let allocatedStatPoints; let totalStatPoints; let experienceToNextLevel; - if (stats.hp <= 0) { - user.stats.hp = 0; - return user.stats.hp; - } - - user.stats.hp = stats.hp; - user.stats.gp = stats.gp >= 0 ? stats.gp : 0; + user.stats.hp = stats.hp > 0 ? stats.hp : 0; + user.stats.gp = stats.gp > 0 ? stats.gp : 0; + if (!user._tmp) user._tmp = {}; experienceToNextLevel = toNextLevel(user.stats.lvl); @@ -36,7 +33,7 @@ module.exports = function updateStats (user, stats, req, analytics) { continue; // eslint-disable-line no-continue } if (user.preferences.automaticAllocation) { - user.fns.autoAllocate(); + autoAllocate(user); } else { user.stats.points = user.stats.lvl - allocatedStatPoints; totalStatPoints = user.stats.points + allocatedStatPoints; @@ -53,7 +50,6 @@ module.exports = function updateStats (user, stats, req, analytics) { } user.stats.exp = stats.exp; - user.flags = user.flags || {}; if (!user.flags.customizationsNotification && (user.stats.exp > 5 || user.stats.lvl > 1)) { user.flags.customizationsNotification = true; @@ -63,48 +59,39 @@ module.exports = function updateStats (user, stats, req, analytics) { } if (!user.flags.dropsEnabled && user.stats.lvl >= 3) { user.flags.dropsEnabled = true; - if (user.items.eggs["Wolf"] > 0) { - user.items.eggs["Wolf"]++; + if (user.items.eggs.Wolf > 0) { + user.items.eggs.Wolf++; } else { - user.items.eggs["Wolf"] = 1; + user.items.eggs.Wolf = 1; } } - if (!user.flags.classSelected && user.stats.lvl >= 10) { - user.flags.classSelected; - } _.each({ vice1: 30, atom1: 15, moonstone1: 60, - goldenknight1: 40 - }, function(lvl, k) { - var analyticsData, base, base1, ref; - if (!((ref = user.flags.levelDrops) != null ? ref[k] : void 0) && user.stats.lvl >= lvl) { - if ((base = user.items.quests)[k] == null) { - base[k] = 0; - } + goldenknight1: 40, + }, (lvl, k) => { + if (user.stats.lvl >= lvl && !user.flags.levelDrops[k]) { + user.flags.levelDrops[k] = true; + if (!user.items.quests[k]) + user.items.quests[k] = 0; user.items.quests[k]++; - ((base1 = user.flags).levelDrops != null ? base1.levelDrops : base1.levelDrops = {})[k] = true; - if (typeof user.markModified === "function") { - user.markModified('flags.levelDrops'); + user.markModified('flags.levelDrops'); + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: k, + acquireMethod: 'Level Drop', + category: 'behavior', + }); } - analyticsData = { - uuid: user._id, - itemKey: k, - acquireMethod: 'Level Drop', - category: 'behavior' - }; - if (analytics != null) { - analytics.track('acquire item', analyticsData); - } - if (!user._tmp) user._tmp = {} - return user._tmp.drop = { + user._tmp.drop = { type: 'Quest', - key: k + key: k, }; } }); if (!user.flags.rebirthEnabled && (user.stats.lvl >= 50 || user.achievements.beastMaster)) { - return user.flags.rebirthEnabled = true; + user.flags.rebirthEnabled = true; } }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 4571add501..829f47ba96 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -51,7 +51,6 @@ const COMMON_FILES = [ '!./common/script/fns/nullify.js', '!./common/script/fns/preenUserHistory.js', '!./common/script/fns/randomDrop.js', - '!./common/script/fns/updateStats.js', '!./common/script/libs/appliedTags.js', '!./common/script/libs/countExists.js', '!./common/script/libs/dotGet.js', diff --git a/test/common/fns/updateStats.test.js b/test/common/fns/updateStats.test.js new file mode 100644 index 0000000000..cea5f6e6ca --- /dev/null +++ b/test/common/fns/updateStats.test.js @@ -0,0 +1,170 @@ +import updateStats from '../../../common/script/fns/updateStats'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('common.fns.updateStats', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + context('No Hp', () => { + it('updates user\s hp', () => { + let stats = { hp: 0 }; + expect(user.stats.hp).to.not.eql(0); + updateStats(user, stats); + expect(user.stats.hp).to.eql(0); + updateStats(user, { hp: 2 }); + expect(user.stats.hp).to.eql(2); + }); + + it('does not lower hp below 0', () => { + let stats = { + hp: -5, + }; + updateStats(user, stats); + expect(user.stats.hp).to.eql(0); + }); + }); + + context('Stat Allocation', () => { + it('adds only attribute points up to user\'s level', () => { + let stats = { + exp: 261, + }; + expect(user.stats.points).to.eql(0); + + user.stats.lvl = 10; + + updateStats(user, stats); + + expect(user.stats.points).to.eql(11); + }); + + it('adds an attibute point when user\'s stat points are less than max level', () => { + let stats = { + exp: 3581, + }; + + user.stats.lvl = 99; + user.stats.str = 25; + user.stats.int = 25; + user.stats.con = 25; + user.stats.per = 24; + + updateStats(user, stats); + + expect(user.stats.points).to.eql(1); + }); + + it('does not add an attibute point when user\'s stat points are equal to max level', () => { + let stats = { + exp: 3581, + }; + + user.stats.lvl = 99; + user.stats.str = 25; + user.stats.int = 25; + user.stats.con = 25; + user.stats.per = 25; + + updateStats(user, stats); + + expect(user.stats.points).to.eql(0); + }); + + it('does not add an attibute point when user\'s stat points + unallocated points are equal to max level', () => { + let stats = { + exp: 3581, + }; + + user.stats.lvl = 99; + user.stats.str = 25; + user.stats.int = 25; + user.stats.con = 25; + user.stats.per = 15; + user.stats.points = 10; + + updateStats(user, stats); + + expect(user.stats.points).to.eql(10); + }); + + it('only awards stat points up to level 100 if user is missing unallocated stat points and is over level 100', () => { + let stats = { + exp: 5581, + }; + + user.stats.lvl = 104; + user.stats.str = 25; + user.stats.int = 25; + user.stats.con = 25; + user.stats.per = 15; + user.stats.points = 0; + + updateStats(user, stats); + + expect(user.stats.points).to.eql(10); + }); + + context('assigns flags.levelDrops', () => { + it('for atom1', () => { + user.stats.lvl = 16; + user.flags.levelDrops.atom1 = false; + expect(user.items.quests.atom1).to.eql(undefined); + updateStats(user, { atom1: true }); + expect(user.items.quests.atom1).to.eql(1); + expect(user.flags.levelDrops.atom1).to.eql(true); + updateStats(user, { atom1: true }); + expect(user.items.quests.atom1).to.eql(1); // no change + }); + it('for vice1', () => { + user.stats.lvl = 31; + user.flags.levelDrops.vice1 = false; + expect(user.items.quests.vice1).to.eql(undefined); + updateStats(user, { vice1: true }); + expect(user.items.quests.vice1).to.eql(1); + expect(user.flags.levelDrops.vice1).to.eql(true); + updateStats(user, { vice1: true }); + expect(user.items.quests.vice1).to.eql(1); + }); + it('moonstone', () => { + user.stats.lvl = 60; + user.flags.levelDrops.moonstone1 = false; + expect(user.items.quests.moonstone1).to.eql(undefined); + updateStats(user, { moonstone1: true }); + expect(user.flags.levelDrops.moonstone1).to.eql(true); + expect(user.items.quests.moonstone1).to.eql(1); + updateStats(user, { moonstone1: true }); + expect(user.items.quests.moonstone1).to.eql(1); + }); + it('for goldenknight1', () => { + user.stats.lvl = 40; + user.flags.levelDrops.goldenknight1 = false; + expect(user.items.quests.goldenknight1).to.eql(undefined); + updateStats(user, { goldenknight1: true }); + expect(user.items.quests.goldenknight1).to.eql(1); + expect(user.flags.levelDrops.goldenknight1).to.eql(true); + updateStats(user, { goldenknight1: true }); + expect(user.items.quests.goldenknight1).to.eql(1); + }); + }); + + // @TODO: Set up sinon sandbox + xit('auto allocates stats if automaticAllocation is turned on', () => { + sandbox.stub(user.fns, 'autoAllocate'); + + let stats = { + exp: 261, + }; + + user.stats.lvl = 10; + + user.fns.updateStats(stats); + + expect(user.fns.autoAllocate).to.be.calledOnce; + }); + }); +}); From 731ac8624461693a726dfc038cb34457bd069e03 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Apr 2016 12:15:24 +0200 Subject: [PATCH 593/976] port changes to cron develop --- website/src/middlewares/api-v3/cron.js | 82 +++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index a70dd534a1..9baa503b9d 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -27,10 +27,11 @@ let clearBuffs = { // Make sure to run this function once in a while as server will not take care of overnight calculations. // And you have to run it every time client connects. export function cron (options = {}) { - let {user, tasksByType, analytics, now, daysMissed} = options; + let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options; user.auth.timestamps.loggedin = now; user.lastCron = now; + user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; // Reset the lastDrop count to zero if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; @@ -275,8 +276,85 @@ module.exports = async function cronMiddleware (req, res, next) { let analytics = res.analytics; let now = new Date(); + + // If the user's timezone has changed (due to travel or daylight savings), + // cron can be triggered twice in one day, so we check for that and use + // both timezones to work out if cron should run. + // CDS = Custom Day Start time. + let timezoneOffsetFromUserPrefs = user.preferences.timezoneOffset || 0; + let timezoneOffsetAtLastCron = _.isFinite(user.preferences.timezoneOffsetAtLastCron) ? user.preferences.timezoneOffsetAtLastCron : timezoneOffsetFromUserPrefs; + let timezoneOffsetFromBrowser = Number(req.header('x-user-timezoneoffset')); + timezoneOffsetFromBrowser = _.isFinite(timezoneOffsetFromBrowser) ? timezoneOffsetFromBrowser : timezoneOffsetFromUserPrefs; + // NB: All timezone offsets can be 0, so can't use `... || ...` to apply non-zero defaults + + if (timezoneOffsetFromBrowser !== timezoneOffsetFromUserPrefs) { + // The user's browser has just told Habitica that the user's timezone has + // changed so store and use the new zone. + user.preferences.timezoneOffset = timezoneOffsetFromBrowser; + timezoneOffsetFromUserPrefs = timezoneOffsetFromBrowser; + } + + // How many days have we missed using the user's current timezone: let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences)); + if (timezoneOffsetAtLastCron !== timezoneOffsetFromUserPrefs) { + // Since cron last ran, the user's timezone has changed. + // How many days have we missed using the old timezone: + let daysMissedNewZone = daysMissed; + let daysMissedOldZone = daysSince(user.lastCron, _.defaults({ + now, + timezoneOffsetOverride: timezoneOffsetAtLastCron, + }, user.preferences)); + + if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) { + // The timezone change was in the unsafe direction. + // E.g., timezone changes from UTC+1 (offset -60) to UTC+0 (offset 0). + // or timezone changes from UTC-4 (offset 240) to UTC-5 (offset 300). + // Local time changed from, for example, 03:00 to 02:00. + + if (daysMissedOldZone > 0 && daysMissedNewZone > 0) { + // Both old and new timezones indicate that we SHOULD run cron, so + // it is safe to do so immediately. + daysMissed = Math.min(daysMissedOldZone, daysMissedNewZone); + // use minimum value to be nice to user + } else if (daysMissedOldZone > 0) { + // The old timezone says that cron should run; the new timezone does not. + // This should be impossible for this direction of timezone change, but + // just in case I'm wrong... + // TODO + // console.log("zone has changed - old zone says run cron, NEW zone says no - stop cron now only -- SHOULD NOT HAVE GOT TO HERE", timezoneOffsetAtLastCron, timezoneOffsetFromUserPrefs, now); // used in production for confirming this never happens + } else if (daysMissedNewZone > 0) { + // The old timezone says that cron should NOT run -- i.e., cron has + // already run today, from the old timezone's point of view. + // The new timezone says that cron SHOULD run, but this is almost + // certainly incorrect. + // This happens when cron occurred at a time soon after the CDS. When + // you reinterpret that time in the new timezone, it looks like it + // was before the CDS, because local time has stepped backwards. + // To fix this, rewrite the cron time to a time that the new + // timezone interprets as being in today. + + daysMissed = 0; // prevent cron running now + let timezoneOffsetDiff = timezoneOffsetAtLastCron - timezoneOffsetFromUserPrefs; + // e.g., for dangerous zone change: 240 - 300 = -60 or -660 - -600 = -60 + + user.lastCron = moment(user.lastCron).subtract(timezoneOffsetDiff, 'minutes'); + // NB: We don't change user.auth.timestamps.loggedin so that will still record the time that the previous cron actually ran. + // From now on we can ignore the old timezone: + user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; + } else { + // Both old and new timezones indicate that cron should + // NOT run. + daysMissed = 0; // prevent cron running now + } + } else if (timezoneOffsetAtLastCron > timezoneOffsetFromUserPrefs) { + daysMissed = daysMissedNewZone; + // TODO: Either confirm that there is nothing that could possibly go wrong here and remove the need for this else branch, or fix stuff. + // There are probably situations where the Dailies do not reset early enough for a user who was expecting the zone change and wants to use all their Dailies immediately in the new zone; + // if so, we should provide an option for easy reset of Dailies (can't be automatic because there will be other situations where the user was not prepared). + } + } + if (daysMissed <= 0) return next(); // Fetch active tasks (no completed todos) @@ -292,7 +370,7 @@ module.exports = async function cronMiddleware (req, res, next) { tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); // Run cron - let progress = cron({user, tasksByType, now, daysMissed, analytics}); + let progress = cron({user, tasksByType, now, daysMissed, analytics, timezoneOffsetFromUserPrefs}); // Clear old completed todos - 30 days for free users, 90 for subscribers // Do not delete challenges completed todos TODO unless the task is broken? From de74fae0b4e2da478807600ba32221481e3c01fb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Apr 2016 16:37:55 +0200 Subject: [PATCH 594/976] v3: port coupons --- common/locales/en/api-v3.json | 8 +- tasks/gulp-eslint.js | 7 +- .../integration/coupons/GET-coupons.test.js | 39 ++++++ .../coupons/POST-coupons_enter_code.test.js | 62 +++++++++ .../POST-coupons_generate_event.test.js | 66 +++++++++ .../POST-coupons_validate_code.test.js | 36 +++++ .../middlewares/ensureAccessRight.test.js | 57 ++++++++ test/helpers/api-unit.helper.js | 4 + website/src/controllers/api-v3/coupon.js | 125 ++++++++++++++++++ website/src/controllers/api-v3/hall.js | 16 +-- .../middlewares/api-v3/ensureAccessRight.js | 23 ++++ website/src/models/coupon.js | 105 ++++++++------- 12 files changed, 475 insertions(+), 73 deletions(-) create mode 100644 test/api/v3/integration/coupons/GET-coupons.test.js create mode 100644 test/api/v3/integration/coupons/POST-coupons_enter_code.test.js create mode 100644 test/api/v3/integration/coupons/POST-coupons_generate_event.test.js create mode 100644 test/api/v3/integration/coupons/POST-coupons_validate_code.test.js create mode 100644 test/api/v3/unit/middlewares/ensureAccessRight.test.js create mode 100644 website/src/controllers/api-v3/coupon.js create mode 100644 website/src/middlewares/api-v3/ensureAccessRight.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 254ff75b28..afb7b17f00 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -142,5 +142,11 @@ "cardTypeRequired": "Card type required", "cardTypeNotAllowed": "Unkown card type.", "mysteryItemIsEmpty": "Mystery items are empty", - "mysteryItemOpened": "Mystery item opened." + "mysteryItemOpened": "Mystery item opened.", + "invalidCoupon": "Invalid coupon code.", + "couponUsed": "Coupon code already used.", + "noSudoAccess": "You don't have sudo access.", + "couponCodeRequired": "The coupon code is required.", + "eventRequired": "\"req.params.event\" is required.", + "countRequired": "\"req.query.count\" is required." } diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 829f47ba96..e71cde8258 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -3,12 +3,7 @@ import eslint from 'gulp-eslint'; const SERVER_FILES = [ './website/src/**/api-v3/**/*.js', - './website/src/models/user.js', - './website/src/models/task.js', - './website/src/models/group.js', - './website/src/models/challenge.js', - './website/src/models/tag.js', - './website/src/models/emailUnsubscription.js', + './website/src/models/**', './website/src/server.js', ]; const COMMON_FILES = [ diff --git a/test/api/v3/integration/coupons/GET-coupons.test.js b/test/api/v3/integration/coupons/GET-coupons.test.js new file mode 100644 index 0000000000..775ddfc3fe --- /dev/null +++ b/test/api/v3/integration/coupons/GET-coupons.test.js @@ -0,0 +1,39 @@ +import { + generateUser, + translate as t, + resetHabiticaDB, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET /coupons/', () => { + let user; + before(async () => { + await resetHabiticaDB(); + }); + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error if user has no sudo permission', async () => { + await user.get('/user'); // needed so the request after this will authenticate with the correct cookie session + await expect(user.get(`/coupons`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('noSudoAccess'), + }); + }); + + it('should return the coupons in CSV format ordered by creation date', async () => { + await user.update({ + 'contributor.sudo': true, + }); + + let coupons = await user.post('/coupons/generate/wondercon?count=11'); + let res = await user.get(`/coupons`); + let splitRes = res.split('\n'); + + expect(splitRes.length).to.equal(13); + expect(splitRes[0]).to.equal('code,event,date,user'); + expect(splitRes[6].split(',')[1]).to.equal(coupons[5].event); + }); +}); diff --git a/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js b/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js new file mode 100644 index 0000000000..687ada750b --- /dev/null +++ b/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js @@ -0,0 +1,62 @@ +import { + generateUser, + translate as t, + resetHabiticaDB, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /coupons/enter/:code', () => { + let user; + let sudoUser; + + before(async () => { + await resetHabiticaDB(); + }); + + beforeEach(async () => { + user = await generateUser(); + sudoUser = await generateUser({ + 'contributor.sudo': true, + }); + }); + + it('returns an error if code is missing', async () => { + await expect(user.post(`/coupons/enter`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); + + it('returns an error if code is invalid', async () => { + await expect(user.post(`/coupons/enter/notValid`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidCoupon'), + }); + }); + + it('returns an error if coupon has been used', async () => { + let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1'); + await user.post(`/coupons/enter/${coupon._id}`); // use coupon + + await expect(user.post(`/coupons/enter/${coupon._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('couponUsed'), + }); + }); + + it('should apply the coupon to the user', async () => { + let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1'); + let userRes = await user.post(`/coupons/enter/${coupon._id}`); + expect(userRes._id).to.equal(user._id); + expect(userRes.items.gear.owned.eyewear_special_wondercon_red).to.be.true; + expect(userRes.items.gear.owned.eyewear_special_wondercon_black).to.be.true; + expect(userRes.items.gear.owned.back_special_wondercon_black).to.be.true; + expect(userRes.items.gear.owned.back_special_wondercon_red).to.be.true; + expect(userRes.items.gear.owned.body_special_wondercon_red).to.be.true; + expect(userRes.items.gear.owned.body_special_wondercon_black).to.be.true; + expect(userRes.items.gear.owned.body_special_wondercon_gold).to.be.true; + expect(userRes.extra).to.eql({signupEvent: 'wondercon'}); + }); +}); diff --git a/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js new file mode 100644 index 0000000000..005a78923f --- /dev/null +++ b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js @@ -0,0 +1,66 @@ +import { + generateUser, + translate as t, + resetHabiticaDB, +} from '../../../../helpers/api-v3-integration.helper'; +import couponCode from 'coupon-code'; + +describe('POST /coupons/generate/:event', () => { + let user; + before(async () => { + await resetHabiticaDB(); + }); + + beforeEach(async () => { + user = await generateUser({ + 'contributor.sudo': true, + }); + }); + + it('returns an error if user has no sudo permission', async () => { + await user.update({ + 'contributor.sudo': false, + }); + + await expect(user.post(`/coupons/generate/aaa`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('noSudoAccess'), + }); + }); + + it('returns an error if event is missing', async () => { + await expect(user.post(`/coupons/generate`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); + + it('returns an error if event is invalid', async () => { + await expect(user.post(`/coupons/generate/notValid?count=1`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Coupon validation failed', + }); + }); + + it('returns an error if count is missing', async () => { + await expect(user.post(`/coupons/generate/notValid`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('should generate coupons', async () => { + await user.update({ + 'contributor.sudo': true, + }); + + let coupons = await user.post('/coupons/generate/wondercon?count=2'); + expect(coupons.length).to.equal(2); + expect(coupons[0].event).to.equal('wondercon'); + expect(couponCode.validate(coupons[1]._id)).to.not.equal(''); // '' means invalid + }); +}); diff --git a/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js b/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js new file mode 100644 index 0000000000..28de4ca460 --- /dev/null +++ b/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js @@ -0,0 +1,36 @@ +import { + generateUser, + requester, + resetHabiticaDB, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /coupons/validate/:code', () => { + let api = requester(); + + before(async () => { + await resetHabiticaDB(); + }); + + it('returns an error if code is missing', async () => { + await expect(api.post(`/coupons/validate`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); + + it('returns true if coupon code is valid', async () => { + let sudoUser = await generateUser({ + 'contributor.sudo': true, + }); + + let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1'); + let res = await api.post(`/coupons/validate/${coupon._id}`); + expect(res).to.eql({valid: true}); + }); + + it('returns false if coupon code is valid', async () => { + let res = await api.post(`/coupons/validate/notValid`); + expect(res).to.eql({valid: false}); + }); +}); diff --git a/test/api/v3/unit/middlewares/ensureAccessRight.test.js b/test/api/v3/unit/middlewares/ensureAccessRight.test.js new file mode 100644 index 0000000000..6c50e3d674 --- /dev/null +++ b/test/api/v3/unit/middlewares/ensureAccessRight.test.js @@ -0,0 +1,57 @@ +/* eslint-disable global-require */ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; +import i18n from '../../../../../common/script/i18n'; +import { ensureAdmin, ensureSudo } from '../../../../../website/src/middlewares/api-v3/ensureAccessRight'; +import { NotAuthorized } from '../../../../../website/src/libs/api-v3/errors'; + +describe('ensure access middlewares', () => { + let res, req, next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + }); + + context('ensure admin', () => { + it('returns not authorized when user is not an admin', () => { + res.locals = {user: {contributor: {admin: false}}}; + + ensureAdmin(req, res, next); + + expect(next).to.be.calledWith(new NotAuthorized(i18n.t('noAdminAccess'))); + }); + + it('passes when user is an admin', () => { + res.locals = {user: {contributor: {admin: true}}}; + + ensureAdmin(req, res, next); + + expect(next).to.be.calledOnce; + expect(next.args[0]).to.be.empty; + }); + }); + + context('ensure sudo', () => { + it('returns not authorized when user is not a sudo user', () => { + res.locals = {user: {contributor: {sudo: false}}}; + + ensureSudo(req, res, next); + + expect(next).to.be.calledWith(new NotAuthorized(i18n.t('noSudoAccess'))); + }); + + it('passes when user is a sudo user', () => { + res.locals = {user: {contributor: {sudo: true}}}; + + ensureSudo(req, res, next); + + expect(next).to.be.calledOnce; + expect(next.args[0]).to.be.empty; + }); + }); +}); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index eb7769256a..489e0dd22e 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -5,6 +5,7 @@ import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; import mongo from './mongo'; // eslint-disable-line import moment from 'moment'; +import i18n from '../../common/script/i18n'; afterEach((done) => { sandbox.restore(); @@ -32,6 +33,9 @@ export function generateRes (options = {}) { group: generateGroup(options.localsGroup), }, set: sandbox.stub(), + t (string) { + return i18n.t(string); + }, }; return defaults(options, defaultRes); diff --git a/website/src/controllers/api-v3/coupon.js b/website/src/controllers/api-v3/coupon.js new file mode 100644 index 0000000000..0890878a54 --- /dev/null +++ b/website/src/controllers/api-v3/coupon.js @@ -0,0 +1,125 @@ +import csvStringify from '../../libs/api-v3/csvStringify'; +import { + authWithHeaders, + authWithSession, +} from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { ensureSudo } from '../../middlewares/api-v3/ensureAccessRight'; +import { model as Coupon } from '../../models/coupon'; +import _ from 'lodash'; +import couponCode from 'coupon-code'; + +let api = {}; + +/** + * @api {get} /coupons Get coupons (sudo users only) + * @apiVersion 3.0.0 + * @apiName GetCoupons + * @apiGroup Coupon + * + * @apiSuccess string Coupons in CSV format + */ +api.getCoupons = { + method: 'GET', + url: '/coupons', + middlewares: [authWithSession, cron, ensureSudo], + async handler (req, res) { + let coupons = await Coupon.find().sort('createdAt').lean().exec(); + + let output = [['code', 'event', 'date', 'user']].concat(_.map(coupons, coupon => { + return [coupon._id, coupon.event, coupon.createdAt, coupon.user]; + })); + let csv = await csvStringify(output); + + res.set({ + 'Content-Type': 'text/csv', + 'Content-disposition': `attachment; filename=habitica-coupons.csv`, + }); + res.status(200).send(csv); + }, +}; + +/** + * @api {post} /coupons/generate/:event Generate coupons for an event (sudo users only) + * @apiVersion 3.0.0 + * @apiName GenerateCoupons + * @apiGroup Coupon + * + * @apiParam {string} event The event for which the coupon should be generated + * @apiParam {number} count Query parameter to specify the number of coupon codes to generate + * + * @apiSuccess array Generated coupons + */ +api.generateCoupons = { + method: 'POST', + url: '/coupons/generate/:event', + middlewares: [authWithHeaders(), cron, ensureSudo], + async handler (req, res) { + req.checkParams('event', res.t('eventRequired')).notEmpty(); + req.checkQuery('count', res.t('countRequired')).notEmpty().isNumeric(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let coupons = await Coupon.generate(req.params.event, req.query.count); + res.respond(200, coupons); + }, +}; + +/** + * @api {post} /user/coupon/:code Enter coupon code + * @apiVersion 3.0.0 + * @apiName EnterCouponCode + * @apiGroup Coupon + * + * @apiParam {string} code The coupon code to apply + * + * @apiSuccess object User object + */ +api.enterCouponCode = { + method: 'POST', + url: '/coupons/enter/:code', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('code', res.t('couponCodeRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + await Coupon.apply(user, req, req.params.code); + res.respond(200, user); + }, +}; + +/** + * @api {post} /coupons/validate/:code Validate a coupon code + * @apiVersion 3.0.0 + * @apiName ValidateCoupon + * @apiGroup Coupon + * + * @apiSuccess valid {boolean} true or false + */ +api.validateCoupon = { + method: 'POST', + url: '/coupons/validate/:code', + middlewares: [authWithHeaders(true)], + async handler (req, res) { + req.checkParams('code', res.t('couponCodeRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let valid = false; + let code = couponCode.validate(req.params.code); + if (code) { + let coupon = await Coupon.findOne({_id: code}).exec(); + valid = coupon ? true : false; + } + + res.respond(200, {valid}); + }, +}; + +module.exports = api; diff --git a/website/src/controllers/api-v3/hall.js b/website/src/controllers/api-v3/hall.js index 917b3e487a..35d5dec2af 100644 --- a/website/src/controllers/api-v3/hall.js +++ b/website/src/controllers/api-v3/hall.js @@ -1,9 +1,9 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import { ensureAdmin } from '../../middlewares/api-v3/ensureAccessRight'; import cron from '../../middlewares/api-v3/cron'; import { model as User } from '../../models/user'; import { NotFound, - NotAuthorized, } from '../../libs/api-v3/errors'; import _ from 'lodash'; @@ -90,9 +90,8 @@ const heroAdminFields = 'contributor balance profile.name purchased items auth'; api.getHero = { method: 'GET', url: '/hall/heroes/:heroId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders(), cron, ensureAdmin], async handler (req, res) { - let user = res.locals.user; let heroId = req.params.heroId; req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID(); @@ -100,10 +99,6 @@ api.getHero = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - if (!user.contributor.admin) { - throw new NotAuthorized(res.t('noAdminAccess')); - } - let hero = await User .findById(heroId) .select(heroAdminFields) @@ -132,9 +127,8 @@ const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0}; api.updateHero = { method: 'PUT', url: '/hall/heroes/:heroId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders(), cron, ensureAdmin], async handler (req, res) { - let user = res.locals.user; let heroId = req.params.heroId; let updateData = req.body; @@ -143,10 +137,6 @@ api.updateHero = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - if (!user.contributor.admin) { - throw new NotAuthorized(res.t('noAdminAccess')); - } - let hero = await User.findById(heroId).exec(); if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId})); diff --git a/website/src/middlewares/api-v3/ensureAccessRight.js b/website/src/middlewares/api-v3/ensureAccessRight.js new file mode 100644 index 0000000000..2fb64ed8af --- /dev/null +++ b/website/src/middlewares/api-v3/ensureAccessRight.js @@ -0,0 +1,23 @@ +import { + NotAuthorized, +} from '../../libs/api-v3/errors'; + +export function ensureAdmin (req, res, next) { + let user = res.locals.user; + + if (!user.contributor.admin) { + return next(new NotAuthorized(res.t('noAdminAccess'))); + } + + next(); +} + +export function ensureSudo (req, res, next) { + let user = res.locals.user; + + if (!user.contributor.sudo) { + return next(new NotAuthorized(res.t('noSudoAccess'))); + } + + next(); +} diff --git a/website/src/models/coupon.js b/website/src/models/coupon.js index 3b0afb3f2a..4492e98690 100644 --- a/website/src/models/coupon.js +++ b/website/src/models/coupon.js @@ -1,59 +1,58 @@ -var mongoose = require("mongoose"); -var shared = require('../../../common'); -var _ = require('lodash'); -var async = require('async'); -var cc = require('coupon-code'); -var autoinc = require('mongoose-id-autoinc'); +/* eslint-disable camelcase */ -var CouponSchema = new mongoose.Schema({ - _id: {type: String, 'default': cc.generate}, - event: {type:String, enum:['wondercon','google_6mo']}, - user: {type: 'String', ref: 'User'} +import mongoose from 'mongoose'; +import _ from 'lodash'; +import shared from '../../../common'; +import couponCode from 'coupon-code'; +import baseModel from '../libs/api-v3/baseModel'; +import { + BadRequest, + NotAuthorized, +} from '../libs/api-v3/errors'; + +export let schema = new mongoose.Schema({ + event: {type: String, enum: ['wondercon', 'google_6mo']}, + user: {type: String, ref: 'User'}, }); -CouponSchema.statics.generate = function(event, count, callback) { - async.times(count, function(n,cb){ - mongoose.model('Coupon').create({event: event}, cb); - }, callback); -} - -CouponSchema.statics.apply = function(user, code, next){ - async.auto({ - get_coupon: function (cb) { - mongoose.model('Coupon').findById(cc.validate(code), cb); - }, - apply_coupon: ['get_coupon', function (cb, results) { - if (!results.get_coupon) return cb("Invalid coupon code"); - if (results.get_coupon.user) return cb("Coupon already used"); - switch (results.get_coupon.event) { - case 'wondercon': - user.items.gear.owned.eyewear_special_wondercon_red = true; - user.items.gear.owned.eyewear_special_wondercon_black = true; - user.items.gear.owned.back_special_wondercon_black = true; - user.items.gear.owned.back_special_wondercon_red = true; - user.items.gear.owned.body_special_wondercon_red = true; - user.items.gear.owned.body_special_wondercon_black = true; - user.items.gear.owned.body_special_wondercon_gold = true; - user.extra = {signupEvent: 'wondercon'}; - user.save(cb); - break; - } - }], - expire_coupon: ['apply_coupon', function (cb, results) { - results.get_coupon.user = user._id; - results.get_coupon.save(cb); - }] - }, function(err, results){ - if (err) return next(err); - next(null,results.apply_coupon[0]); - }) -} - -CouponSchema.plugin(autoinc.plugin, { - model: 'Coupon', - field: 'seq' +schema.plugin(baseModel, { + timestamps: true, }); -module.exports.schema = CouponSchema; -module.exports.model = mongoose.model("Coupon", CouponSchema); +// Add _id field after plugin to override default _id format +schema.add({ + _id: {type: String, default: couponCode.generate}, +}); + +schema.statics.generate = async function generateCoupons (event, count = 1) { + let coupons = _.times(count, () => { + return {event}; + }); + + return await this.create(coupons); +}; + +schema.statics.apply = async function applyCoupon (user, req, code) { + let coupon = await this.findById(couponCode.validate(code)).exec(); + if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon', req.language)); + if (coupon.user) throw new NotAuthorized(shared.i18n.t('couponUsed', req.language)); + + if (coupon.event === 'wondercon') { + user.items.gear.owned.eyewear_special_wondercon_red = true; + user.items.gear.owned.eyewear_special_wondercon_black = true; + user.items.gear.owned.back_special_wondercon_black = true; + user.items.gear.owned.back_special_wondercon_red = true; + user.items.gear.owned.body_special_wondercon_red = true; + user.items.gear.owned.body_special_wondercon_black = true; + user.items.gear.owned.body_special_wondercon_gold = true; + user.extra = {signupEvent: 'wondercon'}; + } + + await user.save(); + coupon.user = user._id; + await coupon.save(); +}; + +module.exports.schema = schema; +export let model = mongoose.model('Coupon', schema); From 3844bafae5cb545b8ee54454aeb7cdfd61ef7f52 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sun, 3 Apr 2016 02:07:28 +0000 Subject: [PATCH 595/976] shared-code-random-drop --- common/script/fns/predictableRandomWrapper.js | 5 + common/script/fns/randomDrop.js | 114 ++++++++----- tasks/gulp-eslint.js | 1 - ...bleRandom.js => predictableRandom.test.js} | 0 test/common/fns/randomDrop.test.js | 160 ++++++++++++++++++ 5 files changed, 241 insertions(+), 39 deletions(-) create mode 100644 common/script/fns/predictableRandomWrapper.js rename test/common/fns/{predictableRandom.js => predictableRandom.test.js} (100%) create mode 100644 test/common/fns/randomDrop.test.js diff --git a/common/script/fns/predictableRandomWrapper.js b/common/script/fns/predictableRandomWrapper.js new file mode 100644 index 0000000000..49cb401e12 --- /dev/null +++ b/common/script/fns/predictableRandomWrapper.js @@ -0,0 +1,5 @@ +import predictableRandom from '../../../common/script/fns/predictableRandom'; + +module.exports = { + predictableRandom, +}; diff --git a/common/script/fns/randomDrop.js b/common/script/fns/randomDrop.js index 0ab6a5e69a..ab0f1b7927 100644 --- a/common/script/fns/randomDrop.js +++ b/common/script/fns/randomDrop.js @@ -3,80 +3,118 @@ import content from '../content/index'; import i18n from '../i18n'; import { daysSince } from '../cron'; import { diminishingReturns } from '../statHelpers'; +import { predictableRandom } from './predictableRandomWrapper'; +import randomVal from './randomVal'; // Clone a drop object maintaining its functions so that we can change it without affecting the original item function cloneDropItem (drop) { - return _.cloneDeep(drop, function (val) { + return _.cloneDeep(drop, (val) => { return _.isFunction(val) ? val : undefined; // undefined will be handled by lodash }); } -module.exports = function randomDrop (user, modifiers, req) { - var acceptableDrops, base, base1, base2, chance, drop, dropK, dropMultiplier, name, name1, name2, quest, rarity, ref, ref1, ref2, ref3, task; +module.exports = function randomDrop (user, modifiers, req = {}) { + let acceptableDrops; + let chance; + let drop; + let dropK; + let dropMultiplier; + let quest; + let rarity; + let task; + task = modifiers.task; - chance = _.min([Math.abs(task.value - 21.27), 37.5]) / 150 + .02; - chance *= task.priority * (1 + (task.streak / 100 || 0)) * (1 + (user._statsComputed.per / 100)) * (1 + (user.contributor.level / 40 || 0)) * (1 + (user.achievements.rebirths / 20 || 0)) * (1 + (user.achievements.streak / 200 || 0)) * (user._tmp.crit || 1) * (1 + .5 * (_.reduce(task.checklist, (function(m, i) { - return m + (i.completed ? 1 : 0); - }), 0) || 0)); + + chance = _.min([Math.abs(task.value - 21.27), 37.5]) / 150 + 0.02; + chance *= task.priority * // Task priority: +50% for Medium, +100% for Hard + (1 + (task.streak / 100 || 0)) * // Streak bonus: +1% per streak + (1 + user._statsComputed.per / 100) * // PERception: +1% per point + (1 + (user.contributor.level / 40 || 0)) * // Contrib levels: +2.5% per level + (1 + (user.achievements.rebirths / 20 || 0)) * // Rebirths: +5% per achievement + (1 + (user.achievements.streak / 200 || 0)) * // Streak achievements: +0.5% per achievement + (user._tmp.crit || 1) * (1 + 0.5 * (_.reduce(task.checklist, (m, i) => { + return m + (i.completed ? 1 : 0); // +50% per checklist item complete. TODO: make this into X individual drop chances instead + }, 0) || 0)); chance = diminishingReturns(chance, 0.75); - quest = content.quests[(ref = user.party.quest) != null ? ref.key : void 0]; - if ((quest != null ? quest.collect : void 0) && user.fns.predictableRandom(user.stats.gp) < chance) { - dropK = user.fns.randomVal(quest.collect, { - key: true + + if (user.party.quest.key) + quest = content.quests[user.party.quest.key]; + if (quest && quest.collect && predictableRandom(user, user.stats.gp) < chance) { + dropK = randomVal(user, quest.collect, { + key: true, }); + if (!user.party.quest.progress.collect[dropK]) + user.party.quest.progress.collect[dropK] = 0; user.party.quest.progress.collect[dropK]++; - if (typeof user.markModified === "function") { - user.markModified('party.quest.progress'); - } + user.markModified('party.quest.progress'); } - dropMultiplier = ((ref1 = user.purchased) != null ? (ref2 = ref1.plan) != null ? ref2.customerId : void 0 : void 0) ? 2 : 1; - if ((daysSince(user.items.lastDrop.date, user.preferences) === 0) && (user.items.lastDrop.count >= dropMultiplier * (5 + Math.floor(user._statsComputed.per / 25) + (user.contributor.level || 0)))) { + + if (user.purchased && user.purchased.plan && user.purchased.plan.custsomerId) { + dropMultiplier = 2; + } else { + dropMultiplier = 1; + } + + if (daysSince(user.items.lastDrop.date, user.preferences) === 0 && + user.items.lastDrop.count >= dropMultiplier * (5 + Math.floor(user._statsComputed.per / 25) + (user.contributor.level || 0))) { return; } - if (((ref3 = user.flags) != null ? ref3.dropsEnabled : void 0) && user.fns.predictableRandom(user.stats.exp) < chance) { - rarity = user.fns.predictableRandom(user.stats.gp); - if (rarity > .6) { - drop = cloneDropItem(user.fns.randomVal(_.where(content.food, { - canDrop: true + + if (user.flags && user.flags.dropsEnabled && predictableRandom(user, user.stats.exp) < chance) { + rarity = predictableRandom(user, user.stats.gp); + + if (rarity > 0.6) { // food 40% chance + drop = cloneDropItem(randomVal(user, _.where(content.food, { + canDrop: true, }))); - if ((base = user.items.food)[name = drop.key] == null) { - base[name] = 0; + + if (!user.items.food[drop.key]) { + user.items.food[drop.key] = 0; } user.items.food[drop.key] += 1; drop.type = 'Food'; drop.dialog = i18n.t('messageDropFood', { dropArticle: drop.article, dropText: drop.text(req.language), - dropNotes: drop.notes(req.language) + dropNotes: drop.notes(req.language), }, req.language); - } else if (rarity > .3) { - drop = cloneDropItem(user.fns.randomVal(content.dropEggs)); - if ((base1 = user.items.eggs)[name1 = drop.key] == null) { - base1[name1] = 0; + } else if (rarity > 0.3) { // eggs 30% chance + drop = cloneDropItem(randomVal(user, content.dropEggs)); + if (!user.items.eggs[drop.key]) { + user.items.eggs[drop.key] = 0; } user.items.eggs[drop.key]++; drop.type = 'Egg'; drop.dialog = i18n.t('messageDropEgg', { dropText: drop.text(req.language), - dropNotes: drop.notes(req.language) + dropNotes: drop.notes(req.language), }, req.language); - } else { - acceptableDrops = rarity < .02 ? ['Golden'] : rarity < .09 ? ['Zombie', 'CottonCandyPink', 'CottonCandyBlue'] : rarity < .18 ? ['Red', 'Shade', 'Skeleton'] : ['Base', 'White', 'Desert']; - drop = cloneDropItem(user.fns.randomVal(_.pick(content.hatchingPotions, (function(v, k) { + } else { // Hatching Potion, 30% chance - break down by rarity. + if (rarity < 0.02) { // Very Rare: 10% (of 30%) + acceptableDrops = ['Golden']; + } else if (rarity < 0.09) { // Rare: 20% of 30% + acceptableDrops = ['Zombie', 'CottonCandyPink', 'CottonCandyBlue']; + } else if (rarity < 0.18) { // uncommon: 30% of 30% + acceptableDrops = ['Red', 'Shade', 'Skeleton']; + } else { // common, 40% of 30% + acceptableDrops = ['Base', 'White', 'Desert']; + } + drop = cloneDropItem(randomVal(user, _.pick(content.hatchingPotions, (v, k) => { return acceptableDrops.indexOf(k) >= 0; - })))); - if ((base2 = user.items.hatchingPotions)[name2 = drop.key] == null) { - base2[name2] = 0; + }))); + if (!user.items.hatchingPotions[drop.key]) { + user.items.hatchingPotions[drop.key] = 0; } user.items.hatchingPotions[drop.key]++; drop.type = 'HatchingPotion'; drop.dialog = i18n.t('messageDropPotion', { dropText: drop.text(req.language), - dropNotes: drop.notes(req.language) + dropNotes: drop.notes(req.language), }, req.language); } + user._tmp.drop = drop; - user.items.lastDrop.date = +(new Date); - return user.items.lastDrop.count++; + user.items.lastDrop.date = Number(new Date()); + user.items.lastDrop.count++; } }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 829f47ba96..cbf8716aed 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -50,7 +50,6 @@ const COMMON_FILES = [ '!./common/script/fns/getItem.js', '!./common/script/fns/nullify.js', '!./common/script/fns/preenUserHistory.js', - '!./common/script/fns/randomDrop.js', '!./common/script/libs/appliedTags.js', '!./common/script/libs/countExists.js', '!./common/script/libs/dotGet.js', diff --git a/test/common/fns/predictableRandom.js b/test/common/fns/predictableRandom.test.js similarity index 100% rename from test/common/fns/predictableRandom.js rename to test/common/fns/predictableRandom.test.js diff --git a/test/common/fns/randomDrop.test.js b/test/common/fns/randomDrop.test.js new file mode 100644 index 0000000000..e4f3141bd6 --- /dev/null +++ b/test/common/fns/randomDrop.test.js @@ -0,0 +1,160 @@ +import randomDrop from '../../../common/script/fns/randomDrop'; +import { + generateUser, + generateTodo, + generateHabit, + generateDaily, + generateReward, +} from '../../helpers/common.helper'; +import predictableRandomWrapper from '../../../common/script/fns/predictableRandomWrapper'; +import content from '../../../common/script/content/index'; + +describe('common.fns.randomDrop', () => { + let user; + let task; + + beforeEach(() => { + user = generateUser(); + user._tmp = user._tmp ? user._tmp : {}; + task = generateTodo({ userId: user._id }); + predictableRandomWrapper.predictableRandom = () => { + return 0.5; + }; + }); + + /** + * function signature as follows: + * randomDrop(user, modifiers) {} + * modifiers = { task, delta = null } + **/ + + it('drops an item for the user.party.quest.progress', () => { + expect(user.party.quest.progress.collect).to.eql({}); + user.party.quest.key = 'vice2'; + let collectWhat = Object.keys(content.quests[user.party.quest.key].collect)[0]; // lightCrystal + predictableRandomWrapper.predictableRandom = () => { + return 0.0001; + }; + randomDrop(user, { task }); + expect(user.party.quest.progress.collect[collectWhat]).to.eql(1); + randomDrop(user, { task }); + expect(user.party.quest.progress.collect[collectWhat]).to.eql(2); + }); + + context('drops enabled', () => { + beforeEach(() => { + user.flags.dropsEnabled = true; + task.priority = 100000; + }); + + it('does nothing if user.items.lastDrop.count is exceeded', () => { + user.items.lastDrop.count = 100; + randomDrop(user, { task }); + expect(user._tmp).to.eql({}); + }); + + it('drops something when the task is a todo', () => { + expect(user._tmp).to.eql({}); + user.flags.dropsEnabled = true; + predictableRandomWrapper.predictableRandom = () => { + return 0.1; + }; + randomDrop(user, { task }); + expect(user._tmp).to.not.eql({}); + }); + + it('drops something when the task is a habit', () => { + task = generateHabit({ userId: user._id }); + expect(user._tmp).to.eql({}); + user.flags.dropsEnabled = true; + predictableRandomWrapper.predictableRandom = () => { + return 0.1; + }; + randomDrop(user, { task }); + expect(user._tmp).to.not.eql({}); + }); + + it('drops something when the task is a daily', () => { + task = generateDaily({ userId: user._id }); + expect(user._tmp).to.eql({}); + user.flags.dropsEnabled = true; + predictableRandomWrapper.predictableRandom = () => { + return 0.1; + }; + randomDrop(user, { task }); + expect(user._tmp).to.not.eql({}); + }); + + it('drops something when the task is a reward', () => { + task = generateReward({ userId: user._id }); + expect(user._tmp).to.eql({}); + user.flags.dropsEnabled = true; + predictableRandomWrapper.predictableRandom = () => { + return 0.1; + }; + randomDrop(user, { task }); + expect(user._tmp).to.not.eql({}); + }); + + it('drops food', () => { + predictableRandomWrapper.predictableRandom = () => { + return 0.65; + }; + randomDrop(user, { task }); + expect(user._tmp.drop.type).to.eql('Food'); + }); + + it('drops eggs', () => { + predictableRandomWrapper.predictableRandom = () => { + return 0.35; + }; + randomDrop(user, { task }); + expect(user._tmp.drop.type).to.eql('Egg'); + }); + + context('drops hatching potion', () => { + it('drops a very rare potion', () => { + predictableRandomWrapper.predictableRandom = () => { + return 0.01; + }; + randomDrop(user, { task }); + expect(user._tmp.drop.type).to.eql('HatchingPotion'); + expect(user._tmp.drop.value).to.eql(5); + expect(user._tmp.drop.key).to.eql('Golden'); + }); + + it('drops a rare potion', () => { + predictableRandomWrapper.predictableRandom = () => { + return 0.08; + }; + randomDrop(user, { task }); + expect(user._tmp.drop.type).to.eql('HatchingPotion'); + expect(user._tmp.drop.value).to.eql(4); + let acceptableDrops = ['Zombie', 'CottonCandyPink', 'CottonCandyBlue']; + expect(acceptableDrops).to.contain(user._tmp.drop.key); // deterministically 'CottonCandyBlue' + }); + + it('drops an uncommon potion', () => { + predictableRandomWrapper.predictableRandom = () => { + return 0.17; + }; + randomDrop(user, { task }); + expect(user._tmp.drop.type).to.eql('HatchingPotion'); + expect(user._tmp.drop.value).to.eql(3); + let acceptableDrops = ['Red', 'Shade', 'Skeleton']; + expect(acceptableDrops).to.contain(user._tmp.drop.key); // always skeleton + }); + + it('drops a common potion', () => { + predictableRandomWrapper.predictableRandom = () => { + return 0.20; + }; + randomDrop(user, { task }); + expect(user._tmp.drop.type).to.eql('HatchingPotion'); + expect(user._tmp.drop.value).to.eql(2); + let acceptableDrops = ['Base', 'White', 'Desert']; + expect(acceptableDrops).to.contain(user._tmp.drop.key); // always Desert + }); + }); + }); +}); From 24b7b4c8389c2f01a2b08760653b07664371ab16 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 3 Apr 2016 14:33:52 -0500 Subject: [PATCH 596/976] Ported release mounts, add unit tests, added release mounts route with integration tests --- common/locales/en/api-v3.json | 3 +- common/script/index.js | 2 + common/script/ops/releaseMounts.js | 60 +++++++++++-------- .../user/POST-user_release_mounts.test.js | 42 +++++++++++++ test/common/ops/releaseMounts.js | 57 ++++++++++++++++++ website/src/controllers/api-v3/user.js | 20 +++++++ 6 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_release_mounts.test.js create mode 100644 test/common/ops/releaseMounts.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index afb7b17f00..1e840c28ad 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -148,5 +148,6 @@ "noSudoAccess": "You don't have sudo access.", "couponCodeRequired": "The coupon code is required.", "eventRequired": "\"req.params.event\" is required.", - "countRequired": "\"req.query.count\" is required." + "countRequired": "\"req.query.count\" is required.", + "mountsReleased": "Mounts released" } diff --git a/common/script/index.js b/common/script/index.js index ee7206f3b1..6cd1384dfd 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -118,6 +118,7 @@ import purchase from './ops/purchase'; import purchaseHourglass from './ops/hourglassPurchase'; import readCard from './ops/readCard'; import openMysteryItem from './ops/openMysteryItem'; +import releaseMounts from './ops/releaseMounts'; api.ops = { scoreTask, @@ -137,6 +138,7 @@ api.ops = { purchaseHourglass, readCard, openMysteryItem, + releaseMounts, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/releaseMounts.js b/common/script/ops/releaseMounts.js index 4aefab6b40..55d2c23fcb 100644 --- a/common/script/ops/releaseMounts.js +++ b/common/script/ops/releaseMounts.js @@ -1,32 +1,42 @@ import content from '../content/index'; import i18n from '../i18n'; +import { + NotAuthorized, +} from '../libs/errors'; +import splitWhitespace from '../libs/splitWhitespace'; + +module.exports = function releaseMounts (user, req = {}, analytics) { + let mount; -module.exports = function(user, req, cb, analytics) { - var analyticsData, mount; if (user.balance < 1) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('notEnoughGems', req.language) - }) : void 0; - } else { - user.balance -= 1; - user.items.currentMount = ""; - for (mount in content.pets) { - user.items.mounts[mount] = null; - } - if (!user.achievements.mountMasterCount) { - user.achievements.mountMasterCount = 0; - } - user.achievements.mountMasterCount++; + throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); } - analyticsData = { - uuid: user._id, - acquireMethod: 'Gems', - gemCost: 4, - category: 'behavior' + + user.balance -= 1; + user.items.currentMount = ''; + + for (mount in content.pets) { + user.items.mounts[mount] = null; + } + + if (!user.achievements.mountMasterCount) { + user.achievements.mountMasterCount = 0; + } + user.achievements.mountMasterCount++; + + if (analytics) { + analytics.track('release mounts', { + uuid: user._id, + acquireMethod: 'Gems', + gemCost: 4, + category: 'behavior' + }); + } + + let response = { + data: _.pick(user, splitWhitespace('mounts')), + message: i18n.t('mountsReleased'), }; - if (analytics != null) { - analytics.track('release mounts', analyticsData); - } - return typeof cb === "function" ? cb(null, user) : void 0; + + return response; }; diff --git a/test/api/v3/integration/user/POST-user_release_mounts.test.js b/test/api/v3/integration/user/POST-user_release_mounts.test.js new file mode 100644 index 0000000000..86391599f0 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_release_mounts.test.js @@ -0,0 +1,42 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/release-mounts', () => { + let user; + let animal = 'Wolf-Base'; + + beforeEach(async () => { + user = await generateUser({ + 'items.currentMount': animal, + 'items.mounts': {animal: true}, + }); + }); + + it('returns an error when user balance is too low', async () => { + await expect(user.post('/user/release-mounts')) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughGems'), + }); + }); + + // More tests in common code unit tests + + it('releases mounts', async () => { + await user.update({ + balance: 1, + }); + + let response = await user.post('/user/release-mounts'); + await user.sync(); + + expect(response.message).to.equal(t('mountsReleased')); + expect(user.balance).to.equal(0); + expect(user.items.currentMount).to.be.empty; + expect(user.items.mounts[animal]).to.equal(null); + expect(user.achievements.mountMasterCount).to.equal(1); + }); +}); diff --git a/test/common/ops/releaseMounts.js b/test/common/ops/releaseMounts.js new file mode 100644 index 0000000000..80429b726f --- /dev/null +++ b/test/common/ops/releaseMounts.js @@ -0,0 +1,57 @@ +import releaseMounts from '../../../common/script/ops/releaseMounts'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; + +describe('shared.ops.releaseMounts', () => { + let user; + let animal = 'Wolf-Base'; + + beforeEach(() => { + user = generateUser(); + user.items.currentMount = animal; + user.items.mounts[animal] = true; + user.balance = 1; + }); + + it('returns an error when user balance is too low', (done) => { + user.balance = 0; + + try { + releaseMounts(user); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); + } + }); + + it('releases mounts', () => { + let response = releaseMounts(user); + + expect(response.message).to.equal(i18n.t('mountsReleased')); + expect(user.items.mounts[animal]).to.equal(null); + }); + + it('removes currentMount', () => { + releaseMounts(user); + + expect(user.items.currentMount).to.be.empty; + }); + + it('increases mountMasterCount achievement', () => { + releaseMounts(user); + + expect(user.achievements.mountMasterCount).to.equal(1); + }); + + it('subtracts gems from balance', () => { + releaseMounts(user); + + expect(user.balance).to.equal(0); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 49d2f2f05d..05a6904f3f 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -764,4 +764,24 @@ api.userOpenMysteryItem = { }, }; +/** +* @api {post} /user/release-mounts Released mounts. +* @apiVersion 3.0.0 +* @apiName UserReleaseMounts +* @apiGroup User +* +* @apiSuccess {Object} data `mounts` +*/ +api.userReleaseMounts = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/release-mounts', + async handler (req, res) { + let user = res.locals.user; + let releaseMountsResponse = common.ops.releaseMounts(user, req, res.analytics); + await user.save(); + res.respond(200, releaseMountsResponse); + }, +}; + module.exports = api; From 487a26ec435a8f772c65ac3c79569de3ca58397e Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 3 Apr 2016 14:37:20 -0500 Subject: [PATCH 597/976] Ported release pets, added unit tests, added route with integration tests --- common/locales/en/api-v3.json | 3 +- common/script/index.js | 2 + common/script/ops/releasePets.js | 58 +++++++++++-------- .../user/POST-user_release_pets.test.js | 42 ++++++++++++++ test/common/ops/releasePets.js | 57 ++++++++++++++++++ website/src/controllers/api-v3/user.js | 20 +++++++ 6 files changed, 156 insertions(+), 26 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_release_pets.test.js create mode 100644 test/common/ops/releasePets.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index afb7b17f00..fa635243e2 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -148,5 +148,6 @@ "noSudoAccess": "You don't have sudo access.", "couponCodeRequired": "The coupon code is required.", "eventRequired": "\"req.params.event\" is required.", - "countRequired": "\"req.query.count\" is required." + "countRequired": "\"req.query.count\" is required.", + "petsReleased": "Pets released." } diff --git a/common/script/index.js b/common/script/index.js index ee7206f3b1..4b854c8306 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -118,6 +118,7 @@ import purchase from './ops/purchase'; import purchaseHourglass from './ops/hourglassPurchase'; import readCard from './ops/readCard'; import openMysteryItem from './ops/openMysteryItem'; +import releasePets from './ops/releasePets'; api.ops = { scoreTask, @@ -137,6 +138,7 @@ api.ops = { purchaseHourglass, readCard, openMysteryItem, + releasePets, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/releasePets.js b/common/script/ops/releasePets.js index a4b452cd86..12bb48a90f 100644 --- a/common/script/ops/releasePets.js +++ b/common/script/ops/releasePets.js @@ -1,32 +1,40 @@ import content from '../content/index'; import i18n from '../i18n'; +import { + NotAuthorized, +} from '../libs/errors'; +import splitWhitespace from '../libs/splitWhitespace'; -module.exports = function(user, req, cb, analytics) { - var analyticsData, pet; +module.exports = function releasePets (user, req = {}, analytics) { if (user.balance < 1) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('notEnoughGems', req.language) - }) : void 0; - } else { - user.balance -= 1; - for (pet in content.pets) { - user.items.pets[pet] = 0; - } - if (!user.achievements.beastMasterCount) { - user.achievements.beastMasterCount = 0; - } - user.achievements.beastMasterCount++; - user.items.currentPet = ""; + throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); } - analyticsData = { - uuid: user._id, - acquireMethod: 'Gems', - gemCost: 4, - category: 'behavior' + + user.balance -= 1; + user.items.currentPet = ''; + + for (let pet in content.pets) { + user.items.pets[pet] = 0; + } + + if (!user.achievements.beastMasterCount) { + user.achievements.beastMasterCount = 0; + } + user.achievements.beastMasterCount++; + + if (analytics) { + analytics.track('release pets', { + uuid: user._id, + acquireMethod: 'Gems', + gemCost: 4, + category: 'behavior' + }); + } + + let response = { + data: _.pick(user, splitWhitespace('user.items.pets')), + message: i18n.t('petsReleased'), }; - if (analytics != null) { - analytics.track('release pets', analyticsData); - } - return typeof cb === "function" ? cb(null, user) : void 0; + + return response; }; diff --git a/test/api/v3/integration/user/POST-user_release_pets.test.js b/test/api/v3/integration/user/POST-user_release_pets.test.js new file mode 100644 index 0000000000..a7f7b9b66f --- /dev/null +++ b/test/api/v3/integration/user/POST-user_release_pets.test.js @@ -0,0 +1,42 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/release-pets', () => { + let user; + let animal = 'Wolf-Base'; + + beforeEach(async () => { + user = await generateUser({ + 'items.currentPet': animal, + 'items.pets': {animal: 5}, + }); + }); + + it('returns an error when user balance is too low', async () => { + await expect(user.post('/user/release-pets')) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughGems'), + }); + }); + + // More tests in common code unit tests + + it('releases pets', async () => { + await user.update({ + balance: 1, + }); + + let response = await user.post('/user/release-pets'); + await user.sync(); + + expect(response.message).to.equal(t('petsReleased')); + expect(user.balance).to.equal(0); + expect(user.items.currentPet).to.be.empty; + expect(user.items.pets[animal]).to.equal(0); + expect(user.achievements.beastMasterCount).to.equal(1); + }); +}); diff --git a/test/common/ops/releasePets.js b/test/common/ops/releasePets.js new file mode 100644 index 0000000000..11d69a5a31 --- /dev/null +++ b/test/common/ops/releasePets.js @@ -0,0 +1,57 @@ +import releasePets from '../../../common/script/ops/releasePets'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; + +describe('shared.ops.releasePets', () => { + let user; + let animal = 'Wolf-Base'; + + beforeEach(() => { + user = generateUser(); + user.items.currentPet = animal; + user.items.pets[animal] = 5; + user.balance = 1; + }); + + it('returns an error when user balance is too low', (done) => { + user.balance = 0; + + try { + releasePets(user); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); + } + }); + + it('releases pets', () => { + let response = releasePets(user); + + expect(response.message).to.equal(i18n.t('petsReleased')); + expect(user.items.pets[animal]).to.equal(0); + }); + + it('removes currentPet', () => { + releasePets(user); + + expect(user.items.currentPet).to.be.empty; + }); + + it('decreases user\'s balance', () => { + releasePets(user); + + expect(user.balance).to.equal(0); + }); + + it('incremenets beastMasterCount', () => { + releasePets(user); + + expect(user.achievements.beastMasterCount).to.equal(1); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 49d2f2f05d..7b7cf3dd57 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -764,4 +764,24 @@ api.userOpenMysteryItem = { }, }; +/** +* @api {post} /user/release-pets Releases pets. +* @apiVersion 3.0.0 +* @apiName UserReleasePets +* @apiGroup User +* +* @apiSuccess {Object} data `user.items.pets` +*/ +api.userReleasePets = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/release-pets', + async handler (req, res) { + let user = res.locals.user; + let releasePetsResponse = common.ops.releasePets(user, req, res.analytics); + await user.save(); + res.respond(200, releasePetsResponse); + }, +}; + module.exports = api; From c916c747752a7db92facb024e47cb1c98ad7ee62 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 3 Apr 2016 14:43:16 -0500 Subject: [PATCH 598/976] Ported release both, added unit tests, add release both route with integration tests --- common/locales/en/api-v3.json | 3 +- common/script/index.js | 2 + common/script/ops/releaseBoth.js | 93 ++++++++++-------- .../user/POST-user_release_both.test.js | 48 +++++++++ test/common/ops/releaseBoth.js | 98 +++++++++++++++++++ website/src/controllers/api-v3/user.js | 20 ++++ 6 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_release_both.test.js create mode 100644 test/common/ops/releaseBoth.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index afb7b17f00..940289e7ef 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -148,5 +148,6 @@ "noSudoAccess": "You don't have sudo access.", "couponCodeRequired": "The coupon code is required.", "eventRequired": "\"req.params.event\" is required.", - "countRequired": "\"req.query.count\" is required." + "countRequired": "\"req.query.count\" is required.", + "mountsAndPetsReleased": "Mounts and pets released" } diff --git a/common/script/index.js b/common/script/index.js index ee7206f3b1..60cd5e120f 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -118,6 +118,7 @@ import purchase from './ops/purchase'; import purchaseHourglass from './ops/hourglassPurchase'; import readCard from './ops/readCard'; import openMysteryItem from './ops/openMysteryItem'; +import releaseBoth from './ops/releaseBoth'; api.ops = { scoreTask, @@ -137,6 +138,7 @@ api.ops = { purchaseHourglass, readCard, openMysteryItem, + releaseBoth, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/releaseBoth.js b/common/script/ops/releaseBoth.js index a782581f85..a17d2e5f00 100644 --- a/common/script/ops/releaseBoth.js +++ b/common/script/ops/releaseBoth.js @@ -1,50 +1,65 @@ import content from '../content/index'; import i18n from '../i18n'; +import { + NotAuthorized, +} from '../libs/errors'; +import splitWhitespace from '../libs/splitWhitespace'; + +module.exports = function releaseBoth (user, req = {}, analytics) { + let animal; -module.exports = function(user, req, cb, analytics) { - var analyticsData, animal, giveTriadBingo; if (user.balance < 1.5 && !user.achievements.triadBingo) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('notEnoughGems', req.language) - }) : void 0; - } else { - giveTriadBingo = true; - if (!user.achievements.triadBingo) { - analyticsData = { + throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); + } + + let giveTriadBingo = true; + + if (!user.achievements.triadBingo) { + if (analytics) { + analytics.track('release pets & mounts', { uuid: user._id, acquireMethod: 'Gems', gemCost: 6, category: 'behavior' - }; - if (typeof analytics !== "undefined" && analytics !== null) { - analytics.track('release pets & mounts', analyticsData); - } - user.balance -= 1.5; - } - user.items.currentMount = ""; - user.items.currentPet = ""; - for (animal in content.pets) { - if (user.items.pets[animal] === -1) { - giveTriadBingo = false; - } - user.items.pets[animal] = 0; - user.items.mounts[animal] = null; - } - if (!user.achievements.beastMasterCount) { - user.achievements.beastMasterCount = 0; - } - user.achievements.beastMasterCount++; - if (!user.achievements.mountMasterCount) { - user.achievements.mountMasterCount = 0; - } - user.achievements.mountMasterCount++; - if (giveTriadBingo) { - if (!user.achievements.triadBingoCount) { - user.achievements.triadBingoCount = 0; - } - user.achievements.triadBingoCount++; + }); } + + user.balance -= 1.5; } - return typeof cb === "function" ? cb(null, user) : void 0; + + user.items.currentMount = ""; + user.items.currentPet = ""; + + for (animal in content.pets) { + if (user.items.pets[animal] === -1) { + giveTriadBingo = false; + } + + user.items.pets[animal] = 0; + user.items.mounts[animal] = null; + } + + if (!user.achievements.beastMasterCount) { + user.achievements.beastMasterCount = 0; + } + user.achievements.beastMasterCount++; + + if (!user.achievements.mountMasterCount) { + user.achievements.mountMasterCount = 0; + } + user.achievements.mountMasterCount++; + + if (giveTriadBingo) { + if (!user.achievements.triadBingoCount) { + user.achievements.triadBingoCount = 0; + } + user.achievements.triadBingoCount++; + } + + let response = { + data: _.pick(user, splitWhitespace('achievements')), + message: i18n.t('mountsAndPetsReleased'), + }; + + return response; }; diff --git a/test/api/v3/integration/user/POST-user_release_both.test.js b/test/api/v3/integration/user/POST-user_release_both.test.js new file mode 100644 index 0000000000..8c47d95dfe --- /dev/null +++ b/test/api/v3/integration/user/POST-user_release_both.test.js @@ -0,0 +1,48 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/release-both', () => { + let user; + let animal = 'Wolf-Base'; + + beforeEach(async () => { + user = await generateUser({ + 'items.currentMount': animal, + 'items.currentPet': animal, + 'items.pets': {animal: 5}, + 'items.mounts': {animal: true}, + }); + }); + + it('returns an error when user balance is too low and user does not have triadBingo', async () => { + await expect(user.post('/user/release-both')) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughGems'), + }); + }); + + // More tests in common code unit tests + + it('grants triad bingo with gems', async () => { + await user.update({ + balance: 1.5, + }); + + let response = await user.post('/user/release-both'); + await user.sync(); + + expect(response.message).to.equal(t('mountsAndPetsReleased')); + expect(user.balance).to.equal(0); + expect(user.items.currentMount).to.be.empty; + expect(user.items.currentPet).to.be.empty; + expect(user.items.pets[animal]).to.be.empty; + expect(user.items.mounts[animal]).to.equal(null); + expect(user.achievements.beastMasterCount).to.equal(1); + expect(user.achievements.mountMasterCount).to.equal(1); + expect(user.achievements.triadBingoCount).to.equal(1); + }); +}); diff --git a/test/common/ops/releaseBoth.js b/test/common/ops/releaseBoth.js new file mode 100644 index 0000000000..309734e7d4 --- /dev/null +++ b/test/common/ops/releaseBoth.js @@ -0,0 +1,98 @@ +import releaseBoth from '../../../common/script/ops/releaseBoth'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; + +describe('shared.ops.releaseBoth', () => { + let user; + let animal = 'Wolf-Base'; + + beforeEach(() => { + user = generateUser(); + user.items.currentMount = animal; + user.items.currentPet = animal; + user.items.pets[animal] = 5; + user.items.mounts[animal] = true; + user.balance = 1.5; + }); + + it('returns an error when user balance is too low and user does not have triadBingo', (done) => { + user.balance = 0; + + try { + releaseBoth(user); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); + } + }); + + it('grants triad bingo with gems', () => { + let response = releaseBoth(user); + + expect(response.message).to.equal(i18n.t('mountsAndPetsReleased')); + expect(user.achievements.triadBingoCount).to.equal(1); + }); + + it('grants triad bingo without gems', () => { + user.balance = 0; + user.achievements.triadBingo = 1; + user.achievements.triadBingoCount = 1; + + let response = releaseBoth(user); + + expect(response.message).to.equal(i18n.t('mountsAndPetsReleased')); + expect(user.achievements.triadBingoCount).to.equal(2); + }); + + it('releases pets', () => { + let response = releaseBoth(user); + + expect(response.message).to.equal(i18n.t('mountsAndPetsReleased')); + expect(user.items.pets[animal]).to.be.empty; + expect(user.items.mounts[animal]).to.equal(null); + }); + + it('releases mounts', () => { + let response = releaseBoth(user); + + expect(response.message).to.equal(i18n.t('mountsAndPetsReleased')); + expect(user.items.mounts[animal]).to.equal(null); + }); + + it('removes currentPet', () => { + releaseBoth(user); + + expect(user.items.currentMount).to.be.empty; + expect(user.items.currentPet).to.be.empty; + }); + + it('removes currentMount', () => { + releaseBoth(user); + + expect(user.items.currentMount).to.be.empty; + }); + + it('decreases user\'s balance', () => { + releaseBoth(user); + + expect(user.balance).to.equal(0); + }); + + it('incremenets beastMasterCount', () => { + releaseBoth(user); + + expect(user.achievements.beastMasterCount).to.equal(1); + }); + + it('incremenets mountMasterCount', () => { + releaseBoth(user); + + expect(user.achievements.mountMasterCount).to.equal(1); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 49d2f2f05d..57202e5e62 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -764,4 +764,24 @@ api.userOpenMysteryItem = { }, }; +/** +* @api {post} /user/release-both Releases Pets and Mounts and grants Triad Bingo. +* @apiVersion 3.0.0 +* @apiName UserReleaseBoth +* @apiGroup User +* +* @apiSuccess {Object} data `user.items.gear.owned` +*/ +api.userReleaseBoth = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/release-both', + async handler (req, res) { + let user = res.locals.user; + let releaseBothResponse = common.ops.releaseBoth(user, req, res.analytics); + await user.save(); + res.respond(200, releaseBothResponse); + }, +}; + module.exports = api; From 68ff26e6d6c14a7d22bae19b1db3003bbfaf8258 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Apr 2016 17:16:36 +0200 Subject: [PATCH 599/976] v3: check that tasks are deleted when the user deletes the acocunt and misc fixes --- .../v3/integration/user/DELETE-user.test.js | 25 ++++++++++++++++++- website/src/controllers/api-v3/tasks.js | 5 ++-- website/src/controllers/api-v3/user.js | 4 +++ website/src/models/user.js | 1 - 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/test/api/v3/integration/user/DELETE-user.test.js b/test/api/v3/integration/user/DELETE-user.test.js index 69e928e343..f954903857 100644 --- a/test/api/v3/integration/user/DELETE-user.test.js +++ b/test/api/v3/integration/user/DELETE-user.test.js @@ -5,7 +5,12 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration/v3'; -import { find } from 'lodash'; +import { + find, + each, + map, +} from 'lodash'; +import Q from 'q'; describe('DELETE /user', () => { let user; @@ -38,6 +43,24 @@ describe('DELETE /user', () => { }); it('deletes the user', async () => { + // gets the user's tasks ids + let ids = []; + each(user.tasksOrder, (idsForOrder) => { + ids.push(...idsForOrder); + }); + + expect(ids.length).to.be.above(0); // make sure the user has some task to delete + + await user.del('/user', { + password, + }); + + await Q.all(map(ids, id => { + return expect(checkExistence('tasks', id)).to.eventually.eql(false); + })); + }); + + it('delete the user\'s tasks', async () => { await user.del('/user', { password, }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e2adcffedb..f931283102 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -399,6 +399,7 @@ api.scoreTask = { if (direction === 'up') user.fns.randomDrop({task, delta}, req); // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list + // TODO move to common code? if (task.type === 'todo') { if (!wasCompleted && task.completed) { removeFromArray(user.tasksOrder.todos, task._id); @@ -406,9 +407,7 @@ api.scoreTask = { let hasTask = removeFromArray(user.tasksOrder.todos, task._id); if (!hasTask) { user.tasksOrder.todos.push(task._id); // TODO push at the top? - } else { // If for some reason it hadn't been removed TODO ok? - user.tasksOrder.push(task._id); - } + } // If for some reason it hadn't been removed previously don't do anything TODO ok? } } diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 445c72d34e..0267e9511b 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -219,6 +219,10 @@ api.deleteUser = { await Q.all(groupLeavePromises); + await Tasks.Task.remove({ + userId: user._id, + }).exec(); + await user.remove(); res.respond(200, {}); diff --git a/website/src/models/user.js b/website/src/models/user.js index b552236c1b..b9946fb3e7 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -508,7 +508,6 @@ export let schema = new Schema({ habits: [{type: String, ref: 'Task'}], dailys: [{type: String, ref: 'Task'}], todos: [{type: String, ref: 'Task'}], - completedTodos: [{type: String, ref: 'Task'}], rewards: [{type: String, ref: 'Task'}], }, extra: {type: Schema.Types.Mixed, default: () => { From fc73fc7f8c1a9ebb3c8016dc9df1e8361add00fe Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 3 Apr 2016 14:23:50 +0200 Subject: [PATCH 600/976] v3: start adapting v2 --- website/src/controllers/api-v2/dataexport.js | 14 +++++----- website/src/controllers/api-v2/user.js | 27 ++++++++++--------- .../src/middlewares/api-v2/errorHandler.js | 3 ++- website/src/middlewares/api-v3/v2.js | 16 +++++------ website/src/routes/api-v2/auth.js | 18 ++++++------- website/src/routes/api-v2/coupon.js | 6 ++--- website/src/routes/api-v2/swagger.js | 2 +- website/src/routes/dataexport.js | 1 - 8 files changed, 43 insertions(+), 44 deletions(-) diff --git a/website/src/controllers/api-v2/dataexport.js b/website/src/controllers/api-v2/dataexport.js index 8c16b9864f..681749bd16 100644 --- a/website/src/controllers/api-v2/dataexport.js +++ b/website/src/controllers/api-v2/dataexport.js @@ -5,15 +5,15 @@ var nconf = require('nconf'); var moment = require('moment'); var js2xmlparser = require("js2xmlparser"); var pd = require('pretty-data').pd; -var User = require('../models/user').model; +var User = require('../../models/user').model; // Avatar screenshot/static-page includes -var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres -var AWS = require('aws-sdk'); -AWS.config.update({accessKeyId: nconf.get("S3:accessKeyId"), secretAccessKey: nconf.get("S3:secretAccessKey")}); -var s3Stream = require('s3-upload-stream')(new AWS.S3()); //https://github.com/nathanpeck/s3-upload-stream -var bucket = nconf.get("S3:bucket"); -var request = require('request'); +//var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres +//var AWS = require('aws-sdk'); +//AWS.config.update({accessKeyId: nconf.get("S3:accessKeyId"), secretAccessKey: nconf.get("S3:secretAccessKey")}); +//var s3Stream = require('s3-upload-stream')(new AWS.S3()); //https://github.com/nathanpeck/s3-upload-stream +//var bucket = nconf.get("S3:bucket"); +//var request = require('request'); /* ------------------------------------------------------------------------ diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 76763544d9..34cde91d69 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -590,21 +590,22 @@ api.sessionPartyInvite = function(req,res,next){ /** * All other user.ops which can easily be mapped to common/script/index.js, not requiring custom API-wrapping */ -_.each(shared.wrap({}).ops, function(op,k){ +_.each(shared.ops, function(op,k){ if (!api[k]) { api[k] = function(req, res, next) { - res.locals.user.ops[k](req,function(err, response){ - // If we want to send something other than 500, pass err as {code: 200, message: "Not enough GP"} - if (err) { - if (!err.code) return next(err); - if (err.code >= 400) return res.status(err.code).json({err:err.message}); - // In the case of 200s, they're friendly alert messages like "You're pet has hatched!" - still send the op - } - res.locals.user.save(function(err){ - if (err) return next(err); - res.status(200).json(response); - }) - }, analytics); + var opResponse; + try { + opResponse = shared.ops[k](res.locals.user, req, analytics); + } catch (err) { + if (!err.code) return next(err); + if (err.code >= 400) return res.status(err.code).json({err:err.message}); + } + + // If we want to send something other than 500, pass err as {code: 200, message: "Not enough GP"} + res.locals.user.save(function(err){ + if (err) return next(err); + res.status(200).json(response); + }) } } }) diff --git a/website/src/middlewares/api-v2/errorHandler.js b/website/src/middlewares/api-v2/errorHandler.js index 15ac67aed6..6fcbd6a307 100644 --- a/website/src/middlewares/api-v2/errorHandler.js +++ b/website/src/middlewares/api-v2/errorHandler.js @@ -1,6 +1,7 @@ -var logging = require('../libs/api-v2/logging'); +var logging = require('../../libs/api-v2/logging'); module.exports = function(err, req, res, next) { + console.log(err, 'HEEEERE'); //res.locals.domain.emit('error', err); // when we hit an error, send it to admin as an email. If no ADMIN_EMAIL is present, just send it to yourself (SMTP_USER) var stack = (err.stack ? err.stack : err.message ? err.message : err) + diff --git a/website/src/middlewares/api-v3/v2.js b/website/src/middlewares/api-v3/v2.js index 822927b119..f942c4f673 100644 --- a/website/src/middlewares/api-v3/v2.js +++ b/website/src/middlewares/api-v3/v2.js @@ -1,7 +1,7 @@ // DEPRECATED BUT STILL ACTIVE // import path from 'path'; -// import swagger from 'swagger-node-express'; +import swagger from 'swagger-node-express'; // import shared from '../../../../common'; import express from 'express'; @@ -12,15 +12,13 @@ v2app.set('view engine', 'jade'); v2app.set('views', `${__dirname}/../../../views`); // Custom Directives -// v2app.use('/', require('../../routes/api-v2/auth')); -// v2app.use('/', require('../../routes/api-v2/coupon')); -// v2app.use('/', require('../../routes/api-v2/unsubscription')); +v2app.use('/', require('../../routes/api-v2/auth')); +v2app.use('/', require('../../routes/api-v2/coupon')); // TODO REMOVE - ONLY v3 +v2app.use('/', require('../../routes/api-v2/unsubscription')); // TODO REMOVE - ONLY v3 -// const v2routes = express(); -// v2app.use('/api/v2', v2routes); -// v2app.use('/export', require('../../routes/dataexport')); -// require('../../routes/api-v2/swagger')(swagger, v2); +v2app.use('/export', require('../../routes/dataexport')); // TODO REMOVE - ONLY v3 +require('../../routes/api-v2/swagger')(swagger, v2app); -// v2app.use(require('../api-v2/errorHandler')); +v2app.use(require('../api-v2/errorHandler')); module.exports = v2app; diff --git a/website/src/routes/api-v2/auth.js b/website/src/routes/api-v2/auth.js index d3e07bcea5..9abf10d9c4 100644 --- a/website/src/routes/api-v2/auth.js +++ b/website/src/routes/api-v2/auth.js @@ -5,14 +5,14 @@ var router = express.Router(); /* auth.auth*/ auth.setupPassport(router); //FIXME make this consistent with the others -router.post('/api/v2/register', i18n.getUserLanguage, auth.registerUser); -router.post('/api/v2/user/auth/local', i18n.getUserLanguage, auth.loginLocal); -router.post('/api/v2/user/auth/social', i18n.getUserLanguage, auth.loginSocial); -router.delete('/api/v2/user/auth/social', i18n.getUserLanguage, auth.auth, auth.deleteSocial); -router.post('/api/v2/user/reset-password', i18n.getUserLanguage, auth.resetPassword); -router.post('/api/v2/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); -router.post('/api/v2/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); -router.post('/api/v2/user/change-email', i18n.getUserLanguage, auth.auth, auth.changeEmail); -router.post('/api/v2/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken); +router.post('/register', i18n.getUserLanguage, auth.registerUser); +router.post('/user/auth/local', i18n.getUserLanguage, auth.loginLocal); +router.post('/user/auth/social', i18n.getUserLanguage, auth.loginSocial); +router.delete('/user/auth/social', i18n.getUserLanguage, auth.auth, auth.deleteSocial); +router.post('/user/reset-password', i18n.getUserLanguage, auth.resetPassword); +router.post('/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); +router.post('/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); +router.post('/user/change-email', i18n.getUserLanguage, auth.auth, auth.changeEmail); +router.post('/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken); module.exports = router; diff --git a/website/src/routes/api-v2/coupon.js b/website/src/routes/api-v2/coupon.js index 7660a14beb..10c6ece15a 100644 --- a/website/src/routes/api-v2/coupon.js +++ b/website/src/routes/api-v2/coupon.js @@ -5,8 +5,8 @@ var auth = require('../../controllers/api-v2/auth'); var coupon = require('../../controllers/api-v2/coupon'); var i18n = require('../../libs/api-v2/i18n'); -router.get('/api/v2/coupons', auth.authWithUrl, i18n.getUserLanguage, coupon.ensureAdmin, coupon.getCoupons); -router.post('/api/v2/coupons/generate/:event', auth.auth, i18n.getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons); -router.post('/api/v2/user/coupon/:code', auth.auth, i18n.getUserLanguage, coupon.enterCode); +router.get('/coupons', auth.authWithUrl, i18n.getUserLanguage, coupon.ensureAdmin, coupon.getCoupons); +router.post('/coupons/generate/:event', auth.auth, i18n.getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons); +router.post('/user/coupon/:code', auth.auth, i18n.getUserLanguage, coupon.enterCode); module.exports = router; diff --git a/website/src/routes/api-v2/swagger.js b/website/src/routes/api-v2/swagger.js index f167884245..fc94bdd9ed 100644 --- a/website/src/routes/api-v2/swagger.js +++ b/website/src/routes/api-v2/swagger.js @@ -13,7 +13,7 @@ var members = require("../../controllers/api-v2/members"); var auth = require("../../controllers/api-v2/auth"); var hall = require("../../controllers/api-v2/hall"); var challenges = require("../../controllers/api-v2/challenges"); -var dataexport = require("../../controllers/dataexport"); +var dataexport = require("../../controllers/api-v2/dataexport"); var nconf = require("nconf"); var cron = user.cron; var _ = require('lodash'); diff --git a/website/src/routes/dataexport.js b/website/src/routes/dataexport.js index dc748e547d..af06700026 100644 --- a/website/src/routes/dataexport.js +++ b/website/src/routes/dataexport.js @@ -4,7 +4,6 @@ var dataexport = require('../controllers/api-v2/dataexport'); var auth = require('../controllers/api-v2/auth'); var nconf = require('nconf'); var i18n = require('../libs/api-v2/i18n'); -var locals = require('../middlewares/locals'); const BASE_URL = nconf.get('BASE_URL'); From 58c20c2a644eec1950fe338be68c8becb9660a22 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 3 Apr 2016 16:40:01 +0200 Subject: [PATCH 601/976] v2 port: enable integration tests, port register route --- tasks/gulp-tests.js | 15 ++++- .../v2/challenges/GET-challenges_id.test.js | 2 +- test/api/v2/groups/GET-groups.test.js | 2 +- test/api/v2/groups/GET-groups_id.test.js | 2 +- test/api/v2/groups/POST-groups.test.js | 2 +- test/api/v2/groups/POST-groups_id.test.js | 2 +- .../v2/groups/POST-groups_id_invite.test.js | 2 +- .../api/v2/groups/POST-groups_id_join.test.js | 2 +- .../v2/groups/POST-groups_id_leave.test.js | 2 +- .../POST-groups_id_removeMember.test.js | 2 +- .../groups/chat/DELETE-groups_id_chat.test.js | 2 +- .../v2/groups/chat/GET-groups_id_chat.test.js | 2 +- .../groups/chat/POST-groups_id_chat.test.js | 2 +- .../POST-groups_id_chat_id_clearflags.test.js | 2 +- .../chat/POST-groups_id_chat_id_flag.test.js | 2 +- .../chat/POST-groups_id_chat_id_like.test.js | 2 +- .../v2/members/POST-members_id_gift.test.js | 2 +- .../members/POST-members_id_message.test.js | 2 +- test/api/v2/user/DELETE-user.test.js | 2 +- test/api/v2/user/GET-user.test.js | 2 +- test/api/v2/user/GET-user_tags.test.js | 2 +- test/api/v2/user/GET-user_tags_id.test.js | 2 +- test/api/v2/user/PUT-user.test.js | 2 +- .../anonymized/GET-user_anonymized.test.js | 2 +- .../POST-user_batch-update.test.js | 2 +- .../user/pushDevice/POST-pushDevice.test.js | 2 +- .../api/v2/user/tasks/DELETE-tasks_id.test.js | 2 +- test/api/v2/user/tasks/GET-tasks.test.js | 2 +- test/api/v2/user/tasks/GET-tasks_id.test.js | 2 +- test/api/v2/user/tasks/POST-tasks.test.js | 2 +- test/api/v2/user/tasks/PUT-tasks_id.test.js | 2 +- test/helpers/api-integration/v2/index.js | 2 +- website/src/controllers/api-v2/auth.js | 24 ++++---- website/src/controllers/api-v3/auth.js | 2 +- website/src/models/user.js | 57 +++++++++++++++++++ website/src/routes/api-v2/auth.js | 4 +- 36 files changed, 114 insertions(+), 50 deletions(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 09a974c7f5..93f92ca5a2 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -309,10 +309,10 @@ gulp.task('test:e2e:safe', ['test:prepare', 'test:prepare:server'], (cb) => { }); }); -gulp.task('test:api-v2', ['test:prepare:server'], (done) => { +/*gulp.task('test:api-v2', ['test:prepare:server'], (done) => { process.env.API_VERSION = 'v2'; awaitPort(TEST_SERVER_PORT).then(() => { - runMochaTests('./test/api/v2/**/*.js', server, done) + runMochaTests('./test/api/v2/**//*.js', server, done) }); }); @@ -337,6 +337,16 @@ gulp.task('test:api-v2:safe', ['test:prepare:server'], (done) => { ); pipe(runner); }); +});*/ + +gulp.task('test:api-v2:integration', (done) => { + let runner = exec( + testBin('mocha test/api/v2 --recursive'), + {maxBuffer: 500*1024}, + (err, stdout, stderr) => done(err) + ) + + pipe(runner); }); gulp.task('test:api-v3:unit', (done) => { @@ -374,6 +384,7 @@ gulp.task('test', (done) => { 'test:common', 'test:api-v3:unit', 'test:api-v3:integration', + 'test:api-v2:integration', done ); }); diff --git a/test/api/v2/challenges/GET-challenges_id.test.js b/test/api/v2/challenges/GET-challenges_id.test.js index dab8a71c0a..cd7ee76d44 100644 --- a/test/api/v2/challenges/GET-challenges_id.test.js +++ b/test/api/v2/challenges/GET-challenges_id.test.js @@ -3,7 +3,7 @@ import { generateChallenge, } from '../../../helpers/api-integration/v2'; -describe('GET /challenges/:id', () => { +xdescribe('GET /challenges/:id', () => { context('Member of a challenge', () => { let leader, party, challenge; diff --git a/test/api/v2/groups/GET-groups.test.js b/test/api/v2/groups/GET-groups.test.js index 203fdd0acc..2da38e3664 100644 --- a/test/api/v2/groups/GET-groups.test.js +++ b/test/api/v2/groups/GET-groups.test.js @@ -4,7 +4,7 @@ import { resetHabiticaDB, } from '../../../helpers/api-integration/v2'; -describe('GET /groups', () => { +xdescribe('GET /groups', () => { const NUMBER_OF_PUBLIC_GUILDS = 3; const NUMBER_OF_USERS_GUILDS = 2; diff --git a/test/api/v2/groups/GET-groups_id.test.js b/test/api/v2/groups/GET-groups_id.test.js index 1de69da586..854a7a2681 100644 --- a/test/api/v2/groups/GET-groups_id.test.js +++ b/test/api/v2/groups/GET-groups_id.test.js @@ -8,7 +8,7 @@ import { each, } from 'lodash'; -describe('GET /groups/:id', () => { +xdescribe('GET /groups/:id', () => { let typesOfGroups = {}; typesOfGroups['public guild'] = { type: 'guild', privacy: 'public' }; typesOfGroups['private guild'] = { type: 'guild', privacy: 'private' }; diff --git a/test/api/v2/groups/POST-groups.test.js b/test/api/v2/groups/POST-groups.test.js index 8da165aaf7..4c9c2c41a4 100644 --- a/test/api/v2/groups/POST-groups.test.js +++ b/test/api/v2/groups/POST-groups.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../helpers/api-integration/v2'; -describe('POST /groups', () => { +xdescribe('POST /groups', () => { context('All groups', () => { let leader; diff --git a/test/api/v2/groups/POST-groups_id.test.js b/test/api/v2/groups/POST-groups_id.test.js index 9799ecff4e..ebdf5c0305 100644 --- a/test/api/v2/groups/POST-groups_id.test.js +++ b/test/api/v2/groups/POST-groups_id.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../helpers/api-integration/v2'; -describe('POST /groups/:id', () => { +xdescribe('POST /groups/:id', () => { context('user is not the leader of the group', () => { let user, otherUser, groupUserDoesNotOwn; diff --git a/test/api/v2/groups/POST-groups_id_invite.test.js b/test/api/v2/groups/POST-groups_id_invite.test.js index 5acf6e637a..8d14c36b7e 100644 --- a/test/api/v2/groups/POST-groups_id_invite.test.js +++ b/test/api/v2/groups/POST-groups_id_invite.test.js @@ -4,7 +4,7 @@ import { } from '../../../helpers/api-integration/v2'; import { each } from 'lodash'; -describe('POST /groups/:id/invite', () => { +xdescribe('POST /groups/:id/invite', () => { context('user is a member of the group', () => { each({ 'public guild': {type: 'guild', privacy: 'public'}, diff --git a/test/api/v2/groups/POST-groups_id_join.test.js b/test/api/v2/groups/POST-groups_id_join.test.js index 250fd4bbf2..4f952deeff 100644 --- a/test/api/v2/groups/POST-groups_id_join.test.js +++ b/test/api/v2/groups/POST-groups_id_join.test.js @@ -5,7 +5,7 @@ import { } from '../../../helpers/api-integration/v2'; import { each } from 'lodash'; -describe('POST /groups/:id/join', () => { +xdescribe('POST /groups/:id/join', () => { context('user is already a member of the group', () => { it('returns an error'); }); diff --git a/test/api/v2/groups/POST-groups_id_leave.test.js b/test/api/v2/groups/POST-groups_id_leave.test.js index f7e3e0e9e6..df0930144e 100644 --- a/test/api/v2/groups/POST-groups_id_leave.test.js +++ b/test/api/v2/groups/POST-groups_id_leave.test.js @@ -3,7 +3,7 @@ import { createAndPopulateGroup, } from '../../../helpers/api-integration/v2'; -describe('POST /groups/:id/leave', () => { +xdescribe('POST /groups/:id/leave', () => { context('user is not member of the group', () => { it('returns an error'); }); diff --git a/test/api/v2/groups/POST-groups_id_removeMember.test.js b/test/api/v2/groups/POST-groups_id_removeMember.test.js index ccedd69e9d..e594cf0748 100644 --- a/test/api/v2/groups/POST-groups_id_removeMember.test.js +++ b/test/api/v2/groups/POST-groups_id_removeMember.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../helpers/api-integration/v2'; -describe('POST /groups/:id/removeMember', () => { +xdescribe('POST /groups/:id/removeMember', () => { context('user is not member of the group', () => { it('returns an error'); }); diff --git a/test/api/v2/groups/chat/DELETE-groups_id_chat.test.js b/test/api/v2/groups/chat/DELETE-groups_id_chat.test.js index dc575b0ec6..8f0506723d 100644 --- a/test/api/v2/groups/chat/DELETE-groups_id_chat.test.js +++ b/test/api/v2/groups/chat/DELETE-groups_id_chat.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -describe('DELETE /groups/:id/chat', () => { +xdescribe('DELETE /groups/:id/chat', () => { let group, message, user; beforeEach(async () => { diff --git a/test/api/v2/groups/chat/GET-groups_id_chat.test.js b/test/api/v2/groups/chat/GET-groups_id_chat.test.js index d6dc691af7..0a5acc5a0c 100644 --- a/test/api/v2/groups/chat/GET-groups_id_chat.test.js +++ b/test/api/v2/groups/chat/GET-groups_id_chat.test.js @@ -2,7 +2,7 @@ import { createAndPopulateGroup, } from '../../../../helpers/api-integration/v2'; -describe('GET /groups/:id/chat', () => { +xdescribe('GET /groups/:id/chat', () => { context('group with multiple messages', () => { let group, member, user; diff --git a/test/api/v2/groups/chat/POST-groups_id_chat.test.js b/test/api/v2/groups/chat/POST-groups_id_chat.test.js index cc280077aa..d9803e0a3a 100644 --- a/test/api/v2/groups/chat/POST-groups_id_chat.test.js +++ b/test/api/v2/groups/chat/POST-groups_id_chat.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -describe('POST /groups/:id/chat', () => { +xdescribe('POST /groups/:id/chat', () => { let group, user; beforeEach(async () => { diff --git a/test/api/v2/groups/chat/POST-groups_id_chat_id_clearflags.test.js b/test/api/v2/groups/chat/POST-groups_id_chat_id_clearflags.test.js index 0b6a54c429..6cd3b8aec0 100644 --- a/test/api/v2/groups/chat/POST-groups_id_chat_id_clearflags.test.js +++ b/test/api/v2/groups/chat/POST-groups_id_chat_id_clearflags.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -describe('POST /groups/:id/chat/:id/clearflags', () => { +xdescribe('POST /groups/:id/chat/:id/clearflags', () => { let guild; beforeEach(async () => { diff --git a/test/api/v2/groups/chat/POST-groups_id_chat_id_flag.test.js b/test/api/v2/groups/chat/POST-groups_id_chat_id_flag.test.js index cd813061b2..4133b7c77f 100644 --- a/test/api/v2/groups/chat/POST-groups_id_chat_id_flag.test.js +++ b/test/api/v2/groups/chat/POST-groups_id_chat_id_flag.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -describe('POST /groups/:id/chat/:id/flag', () => { +xdescribe('POST /groups/:id/chat/:id/flag', () => { context('another member\'s message', () => { let group, member, message, user; diff --git a/test/api/v2/groups/chat/POST-groups_id_chat_id_like.test.js b/test/api/v2/groups/chat/POST-groups_id_chat_id_like.test.js index 9c8ff4c586..83f0c4901e 100644 --- a/test/api/v2/groups/chat/POST-groups_id_chat_id_like.test.js +++ b/test/api/v2/groups/chat/POST-groups_id_chat_id_like.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -describe('POST /groups/:id/chat/:id/like', () => { +xdescribe('POST /groups/:id/chat/:id/like', () => { context('another member\'s message', () => { let group, member, message, user; diff --git a/test/api/v2/members/POST-members_id_gift.test.js b/test/api/v2/members/POST-members_id_gift.test.js index d36306a79d..c67a7ef79e 100644 --- a/test/api/v2/members/POST-members_id_gift.test.js +++ b/test/api/v2/members/POST-members_id_gift.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../helpers/api-integration/v2'; -describe('POST /members/id/gift', () => { +xdescribe('POST /members/id/gift', () => { let userWithBalance, userWithoutBalance; beforeEach(async () => { diff --git a/test/api/v2/members/POST-members_id_message.test.js b/test/api/v2/members/POST-members_id_message.test.js index ffbc02266a..12d77cd1f4 100644 --- a/test/api/v2/members/POST-members_id_message.test.js +++ b/test/api/v2/members/POST-members_id_message.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../helpers/api-integration/v2'; -describe('POST /members/id/message', () => { +xdescribe('POST /members/id/message', () => { let sender, recipient; beforeEach(async () => { diff --git a/test/api/v2/user/DELETE-user.test.js b/test/api/v2/user/DELETE-user.test.js index db0eeca1c8..17517cef21 100644 --- a/test/api/v2/user/DELETE-user.test.js +++ b/test/api/v2/user/DELETE-user.test.js @@ -6,7 +6,7 @@ import { } from '../../../helpers/api-integration/v2'; import { find } from 'lodash'; -describe('DELETE /user', () => { +xdescribe('DELETE /user', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/GET-user.test.js b/test/api/v2/user/GET-user.test.js index c1e22d020a..06e3ec0b83 100644 --- a/test/api/v2/user/GET-user.test.js +++ b/test/api/v2/user/GET-user.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../helpers/api-integration/v2'; -describe('GET /user', () => { +xdescribe('GET /user', () => { let user; before(async () => { diff --git a/test/api/v2/user/GET-user_tags.test.js b/test/api/v2/user/GET-user_tags.test.js index fd2032dc96..08babe3a9f 100644 --- a/test/api/v2/user/GET-user_tags.test.js +++ b/test/api/v2/user/GET-user_tags.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../helpers/api-integration/v2'; -describe('GET /user/tags', () => { +xdescribe('GET /user/tags', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/GET-user_tags_id.test.js b/test/api/v2/user/GET-user_tags_id.test.js index 9c774c84b6..dd1394aac3 100644 --- a/test/api/v2/user/GET-user_tags_id.test.js +++ b/test/api/v2/user/GET-user_tags_id.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../helpers/api-integration/v2'; -describe('GET /user/tags/id', () => { +xdescribe('GET /user/tags/id', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/PUT-user.test.js b/test/api/v2/user/PUT-user.test.js index e4774528ae..2c93c3df0c 100644 --- a/test/api/v2/user/PUT-user.test.js +++ b/test/api/v2/user/PUT-user.test.js @@ -5,7 +5,7 @@ import { import { each, get } from 'lodash'; -describe('PUT /user', () => { +xdescribe('PUT /user', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/anonymized/GET-user_anonymized.test.js b/test/api/v2/user/anonymized/GET-user_anonymized.test.js index 3bfb4d1dcb..2f5e23f541 100644 --- a/test/api/v2/user/anonymized/GET-user_anonymized.test.js +++ b/test/api/v2/user/anonymized/GET-user_anonymized.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v2'; import { each } from 'lodash'; -describe('GET /user/anonymized', () => { +xdescribe('GET /user/anonymized', () => { let user, anonymizedUser; before(async () => { diff --git a/test/api/v2/user/batch-update/POST-user_batch-update.test.js b/test/api/v2/user/batch-update/POST-user_batch-update.test.js index 0b1f244ff7..63dd8021f9 100644 --- a/test/api/v2/user/batch-update/POST-user_batch-update.test.js +++ b/test/api/v2/user/batch-update/POST-user_batch-update.test.js @@ -5,7 +5,7 @@ import { import { each } from 'lodash'; -describe('POST /user/batch-update', () => { +xdescribe('POST /user/batch-update', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/pushDevice/POST-pushDevice.test.js b/test/api/v2/user/pushDevice/POST-pushDevice.test.js index 97cfc4dbb9..c0b5e9be72 100644 --- a/test/api/v2/user/pushDevice/POST-pushDevice.test.js +++ b/test/api/v2/user/pushDevice/POST-pushDevice.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../../helpers/api-integration/v2'; -describe('POST /user/pushDevice', () => { +xdescribe('POST /user/pushDevice', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/tasks/DELETE-tasks_id.test.js b/test/api/v2/user/tasks/DELETE-tasks_id.test.js index 68eb20faef..b0fa708f7b 100644 --- a/test/api/v2/user/tasks/DELETE-tasks_id.test.js +++ b/test/api/v2/user/tasks/DELETE-tasks_id.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -describe('DELETE /user/tasks/:id', () => { +xdescribe('DELETE /user/tasks/:id', () => { let user, task; beforeEach(async () => { diff --git a/test/api/v2/user/tasks/GET-tasks.test.js b/test/api/v2/user/tasks/GET-tasks.test.js index 5cca2f2696..067480ff43 100644 --- a/test/api/v2/user/tasks/GET-tasks.test.js +++ b/test/api/v2/user/tasks/GET-tasks.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../../helpers/api-integration/v2'; -describe('GET /user/tasks/', () => { +xdescribe('GET /user/tasks/', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/tasks/GET-tasks_id.test.js b/test/api/v2/user/tasks/GET-tasks_id.test.js index cd9e1e73be..a93cfeb506 100644 --- a/test/api/v2/user/tasks/GET-tasks_id.test.js +++ b/test/api/v2/user/tasks/GET-tasks_id.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -describe('GET /user/tasks/:id', () => { +xdescribe('GET /user/tasks/:id', () => { let user, task; beforeEach(async () => { diff --git a/test/api/v2/user/tasks/POST-tasks.test.js b/test/api/v2/user/tasks/POST-tasks.test.js index 5f43224803..9d5567dbb5 100644 --- a/test/api/v2/user/tasks/POST-tasks.test.js +++ b/test/api/v2/user/tasks/POST-tasks.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -describe('POST /user/tasks', () => { +xdescribe('POST /user/tasks', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/tasks/PUT-tasks_id.test.js b/test/api/v2/user/tasks/PUT-tasks_id.test.js index 037322a6b7..e2ac535ad6 100644 --- a/test/api/v2/user/tasks/PUT-tasks_id.test.js +++ b/test/api/v2/user/tasks/PUT-tasks_id.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../../helpers/api-integration/v2'; -describe('PUT /user/tasks/:id', () => { +xdescribe('PUT /user/tasks/:id', () => { let user, task; beforeEach(async () => { diff --git a/test/helpers/api-integration/v2/index.js b/test/helpers/api-integration/v2/index.js index 1d0baeea4b..8828b9b8ed 100644 --- a/test/helpers/api-integration/v2/index.js +++ b/test/helpers/api-integration/v2/index.js @@ -4,5 +4,5 @@ requester.setApiVersion('v2'); export { requester }; export { translate } from '../translate'; -export { checkExistence, resetHabiticaDB } from '../mongo'; +export { checkExistence, resetHabiticaDB } from '../../mongo'; export * from './object-generators'; diff --git a/website/src/controllers/api-v2/auth.js b/website/src/controllers/api-v2/auth.js index 47b6fc062f..9ada4800b9 100644 --- a/website/src/controllers/api-v2/auth.js +++ b/website/src/controllers/api-v2/auth.js @@ -138,15 +138,13 @@ api.registerUser = function(req, res, next) { }] }, function(err, data) { if (err) return err.code ? res.status(err.code).json(err) : next(err); - res.status(200).json(data.register[0]); + data.register[0].getTransformedData(function(err, userTransformed){ + if(err) return next(err); + res.status(200).json(userTransformed); + }); }); }; -/* - Register new user with uname / password - */ - - api.loginLocal = function(req, res, next) { var username = req.body.username; var password = req.body.password; @@ -348,7 +346,8 @@ api.changePassword = function(req, res, next) { }) }; -var firebaseTokenGeneratorInstance = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); +// DISABLED FOR API v2 +/*var firebaseTokenGeneratorInstance = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); api.getFirebaseToken = function(req, res, next) { var user = res.locals.user; // Expires 24 hours after now (60*60*24*1000) (in milliseconds) @@ -367,13 +366,10 @@ api.getFirebaseToken = function(req, res, next) { token: token, expires: expires }); -}; +};*/ -/* - Registers a new user. Only accepting username/password registrations, no Facebook -*/ - -api.setupPassport = function(router) { +// DISABLED FOR API v2 +/*api.setupPassport = function(router) { router.get('/logout', i18n.getUserLanguage, function(req, res) { req.logout(); @@ -381,4 +377,4 @@ api.setupPassport = function(router) { res.redirect('/'); }) -}; +};*/ diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index f62f005043..f0fe4755eb 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -502,7 +502,7 @@ api.logout = { url: '/user/auth/logout', // TODO this is under /api/v3 route, should be accessible through habitica.com/logout middlewares: [authWithSession, cron], async handler (req, res) { - req.logout(); + req.logout(); // passportjs method req.session = null; res.redirect('/'); }, diff --git a/website/src/models/user.js b/website/src/models/user.js index b9946fb3e7..5b86a2831b 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -725,6 +725,63 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, m await Q.all(promises); }; +// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model) +// These will be removed once API v2 is discontinued + +// Get all the tasks belonging to an user, +schema.methods.getTasks = function getUserTasks (cb) { + Tasks.Task.find({ + userId: this._id, + }, cb); +}; + +// Given user and an array of tasks, return an API compatible user + tasks obj +schema.methods.addTasksToUser = function addTasksToUser (tasks) { + let obj = this.toJSON(); + let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it + + obj.habits = []; + obj.dailys = []; + obj.todos = []; + obj.rewards = []; + + obj.tasksOrder = undefined; + let unordered = []; + + tasks.forEach((task) => { + // We want to push the task at the same position where it's stored in tasksOrder + let pos = tasksOrder[`${task.type}s`].indexOf(task._id); + if (pos === -1) { // Should never happen, it means the lists got out of sync + unordered.push(task.toJSON()); + } else { + obj[`${task.type}s`][pos] = task.toJSON(); + } + }); + + // Reconcile unordered items + unordered.forEach((task) => { + obj[`${task.type}s`].push(task); + }); + + // Remove null values that can be created when inserting tasks at an index > length + ['habits', 'dailys', 'rewards', 'todos'].forEach((type) => { + obj[type] = _.compact(obj[type]); + }); + + return obj; +}; + +// Return the data maintaining backward compatibility +schema.methods.getTransformedData = function getTransformedData (cb) { + let self = this; + this.getTasks((err, tasks) => { + if (err) return cb(err); + cb(null, self.addTasksToUser(tasks)); + }); +}; + +// END of API v2 methods + export let model = mongoose.model('User', schema); // Initially export an empty object so external requires will get diff --git a/website/src/routes/api-v2/auth.js b/website/src/routes/api-v2/auth.js index 9abf10d9c4..615eee8fb3 100644 --- a/website/src/routes/api-v2/auth.js +++ b/website/src/routes/api-v2/auth.js @@ -4,7 +4,7 @@ var i18n = require('../../libs/api-v2/i18n'); var router = express.Router(); /* auth.auth*/ -auth.setupPassport(router); //FIXME make this consistent with the others +// auth.setupPassport(router); //FIXME make this consistent with the others router.post('/register', i18n.getUserLanguage, auth.registerUser); router.post('/user/auth/local', i18n.getUserLanguage, auth.loginLocal); router.post('/user/auth/social', i18n.getUserLanguage, auth.loginSocial); @@ -13,6 +13,6 @@ router.post('/user/reset-password', i18n.getUserLanguage, auth.resetPassword); router.post('/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); router.post('/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); router.post('/user/change-email', i18n.getUserLanguage, auth.auth, auth.changeEmail); -router.post('/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken); +// router.post('/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken); module.exports = router; From cc65bb1ed712203ecdb2292d96a1ea30793647d0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 3 Apr 2016 19:03:11 +0200 Subject: [PATCH 602/976] adapt v2: getTask, getTasks, clearCompleted, addTask, deleteTask, getUser, getUserAnonymized, scoreTask (challenge part missing) --- test/api/v2/user/GET-user.test.js | 2 +- .../anonymized/GET-user_anonymized.test.js | 2 +- .../api/v2/user/tasks/DELETE-tasks_id.test.js | 2 +- test/api/v2/user/tasks/GET-tasks.test.js | 13 +- test/api/v2/user/tasks/GET-tasks_id.test.js | 2 +- .../user/tasks/POST-clear-completed.test.js | 26 ++ test/api/v2/user/tasks/POST-tasks.test.js | 4 +- website/src/controllers/api-v2/user.js | 379 +++++++++++------- .../src/middlewares/api-v2/errorHandler.js | 1 - website/src/models/task.js | 13 + website/src/models/user.js | 25 +- 11 files changed, 309 insertions(+), 160 deletions(-) create mode 100644 test/api/v2/user/tasks/POST-clear-completed.test.js diff --git a/test/api/v2/user/GET-user.test.js b/test/api/v2/user/GET-user.test.js index 06e3ec0b83..c1e22d020a 100644 --- a/test/api/v2/user/GET-user.test.js +++ b/test/api/v2/user/GET-user.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../helpers/api-integration/v2'; -xdescribe('GET /user', () => { +describe('GET /user', () => { let user; before(async () => { diff --git a/test/api/v2/user/anonymized/GET-user_anonymized.test.js b/test/api/v2/user/anonymized/GET-user_anonymized.test.js index 2f5e23f541..3bfb4d1dcb 100644 --- a/test/api/v2/user/anonymized/GET-user_anonymized.test.js +++ b/test/api/v2/user/anonymized/GET-user_anonymized.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v2'; import { each } from 'lodash'; -xdescribe('GET /user/anonymized', () => { +describe('GET /user/anonymized', () => { let user, anonymizedUser; before(async () => { diff --git a/test/api/v2/user/tasks/DELETE-tasks_id.test.js b/test/api/v2/user/tasks/DELETE-tasks_id.test.js index b0fa708f7b..68eb20faef 100644 --- a/test/api/v2/user/tasks/DELETE-tasks_id.test.js +++ b/test/api/v2/user/tasks/DELETE-tasks_id.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('DELETE /user/tasks/:id', () => { +describe('DELETE /user/tasks/:id', () => { let user, task; beforeEach(async () => { diff --git a/test/api/v2/user/tasks/GET-tasks.test.js b/test/api/v2/user/tasks/GET-tasks.test.js index 067480ff43..e6f44533f9 100644 --- a/test/api/v2/user/tasks/GET-tasks.test.js +++ b/test/api/v2/user/tasks/GET-tasks.test.js @@ -2,18 +2,11 @@ import { generateUser, } from '../../../../helpers/api-integration/v2'; -xdescribe('GET /user/tasks/', () => { +describe('GET /user/tasks/', () => { let user; beforeEach(async () => { - return generateUser({ - dailys: [ - {text: 'daily', type: 'daily'}, - {text: 'daily', type: 'daily'}, - {text: 'daily', type: 'daily'}, - {text: 'daily', type: 'daily'}, - ], - }).then((_user) => { + return generateUser().then((_user) => { user = _user; }); }); @@ -21,7 +14,7 @@ xdescribe('GET /user/tasks/', () => { it('gets all tasks', async () => { return user.get(`/user/tasks/`).then((tasks) => { expect(tasks).to.be.an('array'); - expect(tasks.length).to.be.greaterThan(3); + expect(tasks.length).to.equal(1) let task = tasks[0]; expect(task.id).to.exist; diff --git a/test/api/v2/user/tasks/GET-tasks_id.test.js b/test/api/v2/user/tasks/GET-tasks_id.test.js index a93cfeb506..cd9e1e73be 100644 --- a/test/api/v2/user/tasks/GET-tasks_id.test.js +++ b/test/api/v2/user/tasks/GET-tasks_id.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('GET /user/tasks/:id', () => { +describe('GET /user/tasks/:id', () => { let user, task; beforeEach(async () => { diff --git a/test/api/v2/user/tasks/POST-clear-completed.test.js b/test/api/v2/user/tasks/POST-clear-completed.test.js new file mode 100644 index 0000000000..3a379a994e --- /dev/null +++ b/test/api/v2/user/tasks/POST-clear-completed.test.js @@ -0,0 +1,26 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v2'; + +describe.only('POST /user/tasks/clear-completed', () => { + let user; + + beforeEach(async () => { + return generateUser().then((_user) => { + user = _user; + }); + }); + + it('removes all completed todos', async () => { + let toComplete = await user.post('/user/tasks', { + type: 'todo', + text: 'done', + }); + + await user.post(`/user/tasks/${toComplete._id}/up`) + + let todos = await user.get(`/user/tasks?type=todo`); + let uncomplete = await user.post(`/user/tasks/clear-completed`); + expect(todos.length).to.equal(uncomplete.length + 1); + }); +}); diff --git a/test/api/v2/user/tasks/POST-tasks.test.js b/test/api/v2/user/tasks/POST-tasks.test.js index 9d5567dbb5..4fe4c9f5ea 100644 --- a/test/api/v2/user/tasks/POST-tasks.test.js +++ b/test/api/v2/user/tasks/POST-tasks.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('POST /user/tasks', () => { +describe('POST /user/tasks', () => { let user; beforeEach(async () => { @@ -35,7 +35,7 @@ xdescribe('POST /user/tasks', () => { }); }); - it('does not create a task with an id that already exists', async () => { + xit('does not create a task with an id that already exists', async () => { let todo = user.todos[0]; return expect(user.post('/user/tasks', { diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 34cde91d69..66f95844bf 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -5,6 +5,9 @@ var nconf = require('nconf'); var async = require('async'); var shared = require('../../../../common'); var User = require('./../../models/user').model; +import * as Tasks from '../../models/task'; +import Q from 'q'; +import {removeFromArray} from './../../libs/api-v3/collectionManipulators'; var utils = require('./../../libs/api-v2/utils'); var analytics = utils.analytics; var Group = require('./../../models/group').model; @@ -69,78 +72,104 @@ api.score = function(req, res, next) { var id = req.params.id, direction = req.params.direction, user = res.locals.user, + body = req.body || {}, task; - var clearMemory = function(){user = task = id = direction = null;} - // Send error responses for improper API call - if (!id) return res.status(400).json({err: ':id required'}); + if (!id) return res.json(400, {err: ':id required'}); if (direction !== 'up' && direction !== 'down') { if (direction == 'unlink' || direction == 'sort') return next(); - return res.status(400).json({err: ":direction must be 'up' or 'down'"}); + return res.json(400, {err: ":direction must be 'up' or 'down'"}); } - // If exists already, score it - if (task = user.tasks[id]) { - // Set completed if type is daily or todo and task exists + + Tasks.Task.findOne({ + _id: id, + userId: user._id + }, function(err, task){ + if(err) return next(err); + + // If exists already, score it + if (!task) { + // If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it + // Defaults. Other defaults are handled in user.ops.addTask() + task = new Tasks.Task({ + _id: id, // TODO this might easily lead to conflicts as ids are now unique db-wide + type: body.type, + text: body.text, + userId: user._id, + notes: body.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." // TODO translate + }); + + user.tasksOrder[task.type + 's'].unshift(task._id); + } + + // Set completed if type is daily or todo if (task.type === 'daily' || task.type === 'todo') { task.completed = direction === 'up'; } - } else { - // If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it - // Defaults. Other defaults are handled in user.ops.addTask() - task = { - id: id, - type: req.body && req.body.type, - text: req.body && req.body.text, - notes: (req.body && req.body.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." - }; - if (task.type === 'daily' || task.type === 'todo') - task.completed = direction === 'up'; + var delta = shared.ops.scoreTask({ + user, + task, + direction, + }, req); - task = user.ops.addTask({body:task}); - } - var delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language}); + async.parallel({ + task: task.save.bind(task), + user: user.save.bind(user) + }, function(err, results){ + if(err) return next(err); - user.save(function(err, saved){ - if (err) return next(err); + // FIXME this is suuuper strange, sometimes results.user is an array, sometimes user directly + var saved = Array.isArray(results.user) ? results.user[0] : results.user; + var task = Array.isArray(results.task) ? results.task[0] : results.task; - var userStats = saved.toJSON().stats; - var resJsonData = _.extend({ delta: delta, _tmp: user._tmp }, userStats); - res.status(200).json(resJsonData); + var userStats = saved.toJSON().stats; + var resJsonData = _.extend({ delta: delta, _tmp: user._tmp }, userStats); + res.json(200, resJsonData); - var webhookData = _generateWebhookTaskData( - task, direction, delta, userStats, user - ); - webhook.sendTaskWebhook(user.preferences.webhooks, webhookData); + var webhookData = _generateWebhookTaskData( + task, direction, delta, userStats, user + ); + webhook.sendTaskWebhook(user.preferences.webhooks, webhookData); - if ( - (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there - || (task.type == 'reward') // we don't want to update the reward GP cost - ) return clearMemory(); + if ( + (!task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there + || (task.type == 'reward') // we don't want to update the reward GP cost + ) return; - Challenge.findById(task.challenge.id, 'habits dailys todos rewards', function(err, chal) { - if (err) return next(err); - if (!chal) { - task.challenge.broken = 'CHALLENGE_DELETED'; - user.save(); - return clearMemory(); - } - var t = chal.tasks[task.id]; - // this task was removed from the challenge, notify user - if (!t) { - chal.syncToUser(user); - return clearMemory(); - } + // select name and shortName because they can be synced on syncToUser + Challenge.findById(task.challenge.id, 'name shortName', function(err, chal) { + if (err) return next(err); + if (!chal) { + task.challenge.broken = 'CHALLENGE_DELETED'; + task.save(); + return; + } - t.value += delta; - if (t.type == 'habit' || t.type == 'daily') { - t.history.push({value: t.value, date: +new Date}); - } - chal.save(); - clearMemory(); + Tasks.Task.findOne({ + '_id': task.challenge.taskId, + userId: {$exists: false} + }, function(err, chalTask){ + if(err) return; //FIXME + // this task was removed from the challenge, notify user + if(!chalTask) { + // TODO finish + chal.getTasks(function(err, chalTasks){ + if(err) return; //FIXME + chal.syncToUser(user, chalTasks); + }); + } else { + chalTask.value += delta; + if (chalTask.type == 'habit' || chalTask.type == 'daily') + chalTask.history.push({value: chalTask.value, date: +new Date}); + chalTask.save(); + } + }); + }); }); }); + }; /** @@ -148,32 +177,29 @@ api.score = function(req, res, next) { */ api.getTasks = function(req, res, next) { var user = res.locals.user; - if (req.query.type) { - return res.json(user[req.query.type+'s']); - } else { - return res.json(_.toArray(user.tasks)); - } + + user.getTasks(req.query.type, function (err, tasks) { + if (err) return next(err); + res.status(200).json(tasks.map(task => task.toJSONV2())); + }); }; /** * Get Task */ api.getTask = function(req, res, next) { - var task = findTask(req,res); - if (!task) return res.status(404).json({err: shared.i18n.t('messageTaskNotFound')}); - return res.status(200).json(task); + var user = res.locals.user; + + Tasks.Task.findOne({ + userId: user._id, + _id: req.params.id, + }, function (err, task) { + if (err) return next(err); + if (!task) return res.status(404).json({err: shared.i18n.t('messageTaskNotFound')}); + res.status(200).json(task.toJSONV2()); + }); }; - -/* - Update Task -*/ - -//api.deleteTask // see Shared.ops -// api.updateTask // handled in Shared.ops -// api.addTask // handled in Shared.ops -// api.sortTask // handled in Shared.ops #TODO updated api, mention in docs - /* ------------------------------------------------------------------------ Items @@ -196,89 +222,91 @@ api.getBuyList = function (req, res, next) { * Get User */ api.getUser = function(req, res, next) { - var user = res.locals.user.toJSON(); - user.stats.toNextLevel = shared.tnl(user.stats.lvl); - user.stats.maxHealth = shared.maxHealth; - user.stats.maxMP = res.locals.user._statsComputed.maxMP; - delete user.apiToken; - if (user.auth && user.auth.local) { - delete user.auth.local.hashed_password; - delete user.auth.local.salt; - } - return res.status(200).json(user); + res.locals.user.getTransformedData(function(err, user){ + user.stats.toNextLevel = shared.tnl(user.stats.lvl); + user.stats.maxHealth = shared.maxHealth; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; + delete user.apiToken; + if (user.auth && user.auth.local) { + delete user.auth.local.hashed_password; + delete user.auth.local.salt; + } + return res.status(200).json(user); + }); }; /** * Get anonymized User */ api.getUserAnonymized = function(req, res, next) { - var user = res.locals.user.toJSON(); - user.stats.toNextLevel = shared.tnl(user.stats.lvl); - user.stats.maxHealth = shared.maxHealth; - user.stats.maxMP = res.locals.user._statsComputed.maxMP; + res.locals.user.getTransformedData(function(err, user){ + user.stats.toNextLevel = shared.tnl(user.stats.lvl); + user.stats.maxHealth = shared.maxHealth; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; - delete user.apiToken; + delete user.apiToken; - if (user.auth) { - delete user.auth.local; - delete user.auth.facebook; - } + if (user.auth) { + delete user.auth.local; + delete user.auth.facebook; + } - delete user.newMessages; + delete user.newMessages; - delete user.profile; - delete user.purchased.plan; - delete user.contributor; - delete user.invitations; + delete user.profile; + delete user.purchased.plan; + delete user.contributor; + delete user.invitations; - delete user.items.special.nyeReceived; - delete user.items.special.valentineReceived; + delete user.items.special.nyeReceived; + delete user.items.special.valentineReceived; - delete user.webhooks; - delete user.achievements.challenges; + delete user.webhooks; + delete user.achievements.challenges; - _.forEach(user.inbox.messages, function(msg){ - msg.text = "inbox message text"; - }); - - _.forEach(user.tags, function(tag){ - tag.name = "tag"; - tag.challenge = "challenge"; - }); - - function cleanChecklist(task){ - var checklistIndex = 0; - - _.forEach(task.checklist, function(c){ - c.text = "item" + checklistIndex++; + _.forEach(user.inbox.messages, function(msg){ + msg.text = "inbox message text"; }); - } - _.forEach(user.habits, function(task){ - task.text = "task text"; - task.notes = "task notes"; + _.forEach(user.tags, function(tag){ + tag.name = "tag"; + tag.challenge = "challenge"; + }); + + function cleanChecklist(task){ + var checklistIndex = 0; + + _.forEach(task.checklist, function(c){ + c.text = "item" + checklistIndex++; + }); + } + + _.forEach(user.habits, function(task){ + task.text = "task text"; + task.notes = "task notes"; + }); + + _.forEach(user.rewards, function(task){ + task.text = "task text"; + task.notes = "task notes"; + }); + + _.forEach(user.dailys, function(task){ + task.text = "task text"; + task.notes = "task notes"; + + cleanChecklist(task); + }); + + _.forEach(user.todos, function(task){ + task.text = "task text"; + task.notes = "task notes"; + + cleanChecklist(task); + }); + + return res.status(200).json(user); }); - - _.forEach(user.rewards, function(task){ - task.text = "task text"; - task.notes = "task notes"; - }); - - _.forEach(user.dailys, function(task){ - task.text = "task text"; - task.notes = "task notes"; - - cleanChecklist(task); - }); - - _.forEach(user.todos, function(task){ - task.text = "task text"; - task.notes = "task notes"; - - cleanChecklist(task); - }); - - return res.status(200).json(user); }; /** @@ -587,6 +615,81 @@ api.sessionPartyInvite = function(req,res,next){ ], next); } +api.clearCompleted = function(req, res, next) { + var user = res.locals.user; + + Tasks.Task.remove({ + userId: user._id, + type: 'todo', + completed: true, + 'challenge.id': {$exists: false}, + }, function (err) { + if (err) return next(err); + + Tasks.Task.find({ + userId: user._id, + type: 'todo', + completed: false, + }, function (err, uncompleted) { + if (err) return next(err); + res.json(uncompleted); + }); + }); +}; + +api.deleteTask = function(req, res, next) { + var user = res.locals.user; + if(!req.params || !req.params.id) return res.json(404, shared.i18n.t('messageTaskNotFound', req.language)); + + var id = req.params.id; + // Try removing from all orders since we don't know the task's type + var removeTaskFromOrder = function(array) { + removeFromArray(array, id); + }; + + ['habits', 'dailys', 'todos', 'rewards'].forEach(function (type){ + removeTaskFromOrder(user.tasksOrder[type]) + }); + + async.parallel({ + user: user.save.bind(user), + task: function(cb) { + Tasks.Task.remove({_id: id, userId: user._id}, cb); + } + }, function(err, results) { + if(err) return next(err); + + if(results.task.result.n < 1){ + return res.status(404).json({err: shared.i18n.t('messageTaskNotFound', req.language)}) + } + + res.status(200).json({}); + }); +}; + +api.addTask = function(req, res, next) { + var user = res.locals.user; + req.body.type = req.body.type || 'habit'; + req.body.text = req.body.text || 'text'; + + var task = new Tasks[req.body.type](Tasks.Task.sanitizeCreate(req.body)); + + task.userId = user._id; + user.tasksOrder[task.type + 's'].unshift(task._id); + + // Validate that the task is valid and throw if it isn't + // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality + let validationErrors = task.validateSync(); + if (validationErrors) return next(validationErrors); + + Q.all([ + user.save(), + task.save({validateBeforeSave: false}) // already done ^ + ]).then(results => { + res.status(200).json(results[1].toJSONV2()); + }).catch(next); +}; + /** * All other user.ops which can easily be mapped to common/script/index.js, not requiring custom API-wrapping */ diff --git a/website/src/middlewares/api-v2/errorHandler.js b/website/src/middlewares/api-v2/errorHandler.js index 6fcbd6a307..e62753f5f9 100644 --- a/website/src/middlewares/api-v2/errorHandler.js +++ b/website/src/middlewares/api-v2/errorHandler.js @@ -1,7 +1,6 @@ var logging = require('../../libs/api-v2/logging'); module.exports = function(err, req, res, next) { - console.log(err, 'HEEEERE'); //res.locals.domain.emit('error', err); // when we hit an error, send it to admin as an email. If no ADMIN_EMAIL is present, just send it to yourself (SMTP_USER) var stack = (err.stack ? err.stack : err.message ? err.message : err) + diff --git a/website/src/models/task.js b/website/src/models/task.js index 783acf8782..30c68969c4 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -108,6 +108,19 @@ TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta await chalTask.save(); }; + +// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model) +// These will be removed once API v2 is discontinued + +// toJSON for API v2 +TaskSchema.methods.toJSONV2 = function toJSONV2 () { + let toJSON = this.toJSON(); + toJSON.id = toJSON._id; + return toJSON; +}; + +// END of API v2 methods + export let Task = mongoose.model('Task', TaskSchema); // habits and dailies shared fields diff --git a/website/src/models/user.js b/website/src/models/user.js index 5b86a2831b..3964dc839a 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -729,10 +729,25 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, m // These will be removed once API v2 is discontinued // Get all the tasks belonging to an user, -schema.methods.getTasks = function getUserTasks (cb) { - Tasks.Task.find({ +schema.methods.getTasks = function getUserTasks () { + let args = Array.from(arguments); + let cb; + let type; + + if (args.length === 1) { + cb = args[0]; + } else { + type = args[0]; + cb = args[1]; + } + + let query = { userId: this._id, - }, cb); + }; + + if (type) query.type = type; + + Tasks.Task.find(query, cb); }; // Given user and an array of tasks, return an API compatible user + tasks obj @@ -752,9 +767,9 @@ schema.methods.addTasksToUser = function addTasksToUser (tasks) { // We want to push the task at the same position where it's stored in tasksOrder let pos = tasksOrder[`${task.type}s`].indexOf(task._id); if (pos === -1) { // Should never happen, it means the lists got out of sync - unordered.push(task.toJSON()); + unordered.push(task.toJSONV2()); } else { - obj[`${task.type}s`][pos] = task.toJSON(); + obj[`${task.type}s`][pos] = task.toJSONV2(); } }); From 060e3b1045add785e15bea79cbc3615d21f23afd Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 3 Apr 2016 19:04:54 +0200 Subject: [PATCH 603/976] adapt v2: fix linting --- test/api/v2/user/tasks/GET-tasks.test.js | 2 +- test/api/v2/user/tasks/POST-clear-completed.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/api/v2/user/tasks/GET-tasks.test.js b/test/api/v2/user/tasks/GET-tasks.test.js index e6f44533f9..69edde28d7 100644 --- a/test/api/v2/user/tasks/GET-tasks.test.js +++ b/test/api/v2/user/tasks/GET-tasks.test.js @@ -14,7 +14,7 @@ describe('GET /user/tasks/', () => { it('gets all tasks', async () => { return user.get(`/user/tasks/`).then((tasks) => { expect(tasks).to.be.an('array'); - expect(tasks.length).to.equal(1) + expect(tasks.length).to.equal(1); let task = tasks[0]; expect(task.id).to.exist; diff --git a/test/api/v2/user/tasks/POST-clear-completed.test.js b/test/api/v2/user/tasks/POST-clear-completed.test.js index 3a379a994e..be7f39a203 100644 --- a/test/api/v2/user/tasks/POST-clear-completed.test.js +++ b/test/api/v2/user/tasks/POST-clear-completed.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../../helpers/api-integration/v2'; -describe.only('POST /user/tasks/clear-completed', () => { +describe('POST /user/tasks/clear-completed', () => { let user; beforeEach(async () => { @@ -17,7 +17,7 @@ describe.only('POST /user/tasks/clear-completed', () => { text: 'done', }); - await user.post(`/user/tasks/${toComplete._id}/up`) + await user.post(`/user/tasks/${toComplete._id}/up`); let todos = await user.get(`/user/tasks?type=todo`); let uncomplete = await user.post(`/user/tasks/clear-completed`); From 382e391fd0f95bc88faef69353cd2dac3ffe7060 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 3 Apr 2016 21:50:32 +0200 Subject: [PATCH 604/976] port updateTask, addTask, clearCompleted, taskDefaults, uuid --- common/script/index.js | 4 + common/script/libs/taskDefaults.js | 83 ++++++++------- common/script/libs/uuid.js | 13 +-- common/script/ops/addTask.js | 23 ++-- common/script/ops/clearCompleted.js | 14 ++- common/script/ops/updateTask.js | 35 +++--- package.json | 4 +- tasks/gulp-eslint.js | 5 - test/common/ops/addTask.js | 135 ++++++++++++++++++++++++ test/common/ops/clearCompleted.js | 37 +++++++ test/common/ops/updateTask.js | 53 ++++++++++ website/src/controllers/api-v3/tasks.js | 9 +- website/src/libs/api-v3/baseModel.js | 2 +- website/src/models/task.js | 4 +- 14 files changed, 322 insertions(+), 99 deletions(-) create mode 100644 test/common/ops/addTask.js create mode 100644 test/common/ops/clearCompleted.js create mode 100644 test/common/ops/updateTask.js diff --git a/common/script/index.js b/common/script/index.js index 7482a09fd7..2efe3371cf 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -121,6 +121,8 @@ import openMysteryItem from './ops/openMysteryItem'; import releasePets from './ops/releasePets'; import releaseBoth from './ops/releaseBoth'; import releaseMounts from './ops/releaseMounts'; +import updateTask from './ops/updateTask'; +import clearCompleted from './ops/clearCompleted'; api.ops = { scoreTask, @@ -143,6 +145,8 @@ api.ops = { releasePets, releaseBoth, releaseMounts, + updateTask, + clearCompleted, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/libs/taskDefaults.js b/common/script/libs/taskDefaults.js index 69c815e4fd..cb14115bca 100644 --- a/common/script/libs/taskDefaults.js +++ b/common/script/libs/taskDefaults.js @@ -1,71 +1,74 @@ -import uuid from './uuid'; +import { v4 as uuid } from 'uuid'; import _ from 'lodash'; +import moment from 'moment'; -/* -Even though Mongoose handles task defaults, we want to make sure defaults are set on the client-side before -sending up to the server for performance - */ +// Even though Mongoose handles task defaults, we want to make sure defaults are set on the client-side before +// sending up to the server for performance -// TODO revisit +// TODO move to client code? -module.exports = function(task) { - var defaults, ref, ref1, ref2; - if (task == null) { - task = {}; - } - if (!(task.type && ((ref = task.type) === 'habit' || ref === 'daily' || ref === 'todo' || ref === 'reward'))) { +const tasksTypes = ['habit', 'daily', 'todo', 'reward']; + +module.exports = function taskDefaults (task = {}) { + if (!task.type || tasksTypes.indexOf(task.type) === -1) { task.type = 'habit'; } - defaults = { - id: uuid(), - text: task.id != null ? task.id : '', + + let defaultId = uuid(); + let defaults = { + _id: defaultId, // TODO convert all occurencies of id to _id + text: task._id || defaultId, notes: '', + tags: [], + value: task.type === 'reward' ? 10 : 0, priority: 1, challenge: {}, + reminders: {}, attribute: 'str', - dateCreated: new Date() + createdAt: new Date(), // TODO these are going to be overwritten by the server... + updatedAt: new Date(), }; + _.defaults(task, defaults); + + if (task.type === 'habit' || task.type === 'daily') { + _.defaults(task, { + history: [], + }); + } + + if (task.type === 'todo' || task.type === 'daily') { + _.defaults(task, { + completed: false, + collapseChecklist: false, + checklist: [], + }); + } + if (task.type === 'habit') { _.defaults(task, { up: true, - down: true - }); - } - if ((ref1 = task.type) === 'habit' || ref1 === 'daily') { - _.defaults(task, { - history: [] - }); - } - if ((ref2 = task.type) === 'daily' || ref2 === 'todo') { - _.defaults(task, { - completed: false + down: true, }); } + if (task.type === 'daily') { _.defaults(task, { streak: 0, repeat: { - su: true, m: true, t: true, w: true, th: true, f: true, - s: true - } - }, { - startDate: new Date(), + s: true, + su: true, + }, + startDate: moment().startOf('day').toDate(), everyX: 1, - frequency: 'weekly' + frequency: 'weekly', }); } - task._id = task.id; - if (task.value == null) { - task.value = task.type === 'reward' ? 10 : 0; - } - if (!_.isNumber(task.priority)) { - task.priority = 1; - } + return task; }; diff --git a/common/script/libs/uuid.js b/common/script/libs/uuid.js index 4a26440d7d..63f75cf398 100644 --- a/common/script/libs/uuid.js +++ b/common/script/libs/uuid.js @@ -1,9 +1,4 @@ -// TODO use node-uuid module -module.exports = function() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { - var r, v; - r = Math.random() * 16 | 0; - v = (c === "x" ? r : r & 0x3 | 0x8); - return v.toString(16); - }); -}; +import uuid from 'uuid'; + +// TODO remove this file completely +module.exports = uuid.v4; diff --git a/common/script/ops/addTask.js b/common/script/ops/addTask.js index a2d1895e1e..81f642ab02 100644 --- a/common/script/ops/addTask.js +++ b/common/script/ops/addTask.js @@ -1,27 +1,22 @@ import taskDefaults from '../libs/taskDefaults'; -import i18n from '../i18n'; -module.exports = function(user, req, cb) { - var task; - task = taskDefaults(req.body); - if (user.tasks[task.id] != null) { - return typeof cb === "function" ? cb({ - code: 409, - message: i18n.t('messageDuplicateTaskID', req.language) - }) : void 0; - } - user[task.type + "s"].unshift(task); +// TODO move to client since it's only used there? + +module.exports = function addTask (user, req = {body: {}}) { + let task = taskDefaults(req.body); + user.tasksOrder[`${task.type}s`].unshift(task._id); + if (user.preferences.newTaskEdit) { task._editing = true; } + if (user.preferences.tagsCollapsed) { task._tags = true; } + if (!user.preferences.advancedCollapsed) { task._advanced = true; } - if (typeof cb === "function") { - cb(null, task); - } + return task; }; diff --git a/common/script/ops/clearCompleted.js b/common/script/ops/clearCompleted.js index d60f12704f..26fb1727d9 100644 --- a/common/script/ops/clearCompleted.js +++ b/common/script/ops/clearCompleted.js @@ -1,12 +1,10 @@ import _ from 'lodash'; -module.exports = function(user, req, cb) { - _.remove(user.todos, function(t) { - var ref; - return t.completed && !((ref = t.challenge) != null ? ref.id : void 0); +// TODO move to client since it's only used there? +// TODO rename file to clearCompletedTodos + +module.exports = function clearCompletedTodos (todos) { + _.remove(todos, todo => { + return todo.completed && (!todo.challenge || !todo.challenge.id || todo.challenge.broken); }); - if (typeof user.markModified === "function") { - user.markModified('todos'); - } - return typeof cb === "function" ? cb(null, user.todos) : void 0; }; diff --git a/common/script/ops/updateTask.js b/common/script/ops/updateTask.js index 427104f15e..a128b40fc1 100644 --- a/common/script/ops/updateTask.js +++ b/common/script/ops/updateTask.js @@ -1,23 +1,26 @@ -import i18n from '../i18n'; import _ from 'lodash'; -module.exports = function(user, req, cb) { - var ref, task; - if (!(task = user.tasks[(ref = req.params) != null ? ref.id : void 0])) { - return typeof cb === "function" ? cb({ - code: 404, - message: i18n.t('messageTaskNotFound', req.language) - }) : void 0; - } - _.merge(task, _.omit(req.body, ['checklist', 'reminders', 'id', 'type'])); - if (req.body.checklist) { - task.checklist = req.body.checklist; - } +// From server pass task.toObject() not the task document directly +module.exports = function updateTask (task, req = {}) { + // If reminders are updated -> replace the original ones if (req.body.reminders) { task.reminders = req.body.reminders; + delete req.body.reminders; } - if (typeof task.markModified === "function") { - task.markModified('tags'); + + // If checklist is updated -> replace the original one + if (req.body.checklist) { + task.checklist = req.body.checklist; + delete req.body.checklist; } - return typeof cb === "function" ? cb(null, task) : void 0; + + // If tags are updated -> replace the original ones + if (req.body.tags) { + task.tags = req.body.tags; + delete req.body.tags; + } + + _.merge(task, _.omit(req.body, ['_id', 'id', 'type'])); + + return task; }; diff --git a/package.json b/package.json index 85f9d207f3..2279d48fe0 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,8 @@ "validator": "~4.2.1", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^1.1.0", - "winston": "^2.1.0" + "winston": "^2.1.0", + "uuid": "^2.0.1" }, "private": true, "engines": { @@ -153,7 +154,6 @@ "sinon": "^1.17.2", "sinon-chai": "^2.8.0", "superagent-defaults": "^0.1.13", - "uuid": "^2.0.1", "vinyl-source-stream": "^1.0.0", "vinyl-transform": "^1.0.0", "xml2js": "^0.4.16" diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index e71cde8258..fc542d4faf 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -12,10 +12,8 @@ const COMMON_FILES = [ '!./common/script/content/index.js', '!./common/script/ops/addPushDevice.js', '!./common/script/ops/addTag.js', - '!./common/script/ops/addTask.js', '!./common/script/ops/addWebhook.js', '!./common/script/ops/blockUser.js', - '!./common/script/ops/clearCompleted.js', '!./common/script/ops/clearPMs.js', '!./common/script/ops/deletePM.js', '!./common/script/ops/deleteTag.js', @@ -36,7 +34,6 @@ const COMMON_FILES = [ '!./common/script/ops/unlock.js', '!./common/script/ops/update.js', '!./common/script/ops/updateTag.js', - '!./common/script/ops/updateTask.js', '!./common/script/ops/updateWebhook.js', '!./common/script/fns/crit.js', '!./common/script/fns/cron.js', @@ -63,8 +60,6 @@ const COMMON_FILES = [ '!./common/script/libs/silver.js', '!./common/script/libs/splitWhitespace.js', '!./common/script/libs/taskClasses.js', - '!./common/script/libs/taskDefaults.js', - '!./common/script/libs/uuid.js', '!./common/script/public/**/*.js', ]; const TEST_FILES = [ diff --git a/test/common/ops/addTask.js b/test/common/ops/addTask.js new file mode 100644 index 0000000000..2d5febe8d2 --- /dev/null +++ b/test/common/ops/addTask.js @@ -0,0 +1,135 @@ +import addTask from '../../../common/script/ops/addTask'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.addTask', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('adds an habit', () => { + let habit = addTask(user, { + body: { + type: 'habit', + text: 'habit', + down: false, + }, + }); + + expect(user.tasksOrder.habits).to.eql([ + habit._id, + ]); + expect(habit._id).to.be.a('string'); + expect(habit.text).to.equal('habit'); + expect(habit.type).to.equal('habit'); + expect(habit.up).to.equal(true); + expect(habit.down).to.equal(false); + expect(habit.history).to.eql([]); + expect(habit.checklist).to.not.exists; + }); + + it('adds an habtit when type is invalid', () => { + let habit = addTask(user, { + body: { + type: 'invalid', + text: 'habit', + down: false, + }, + }); + + expect(user.tasksOrder.habits).to.eql([ + habit._id, + ]); + expect(habit._id).to.be.a('string'); + expect(habit.text).to.equal('habit'); + expect(habit.type).to.equal('habit'); + expect(habit.up).to.equal(true); + expect(habit.down).to.equal(false); + expect(habit.history).to.eql([]); + expect(habit.checklist).to.not.exists; + }); + + it('adds a daily', () => { + let daily = addTask(user, { + body: { + type: 'daily', + text: 'daily', + }, + }); + + expect(user.tasksOrder.dailys).to.eql([ + daily._id, + ]); + expect(daily._id).to.be.a('string'); + expect(daily.type).to.equal('daily'); + expect(daily.text).to.equal('daily'); + expect(daily.history).to.eql([]); + expect(daily.checklist).to.eql([]); + expect(daily.completed).to.be.false; + expect(daily.up).to.not.exists; + }); + + it('adds a todo', () => { + let todo = addTask(user, { + body: { + type: 'todo', + text: 'todo', + }, + }); + + expect(user.tasksOrder.todos).to.eql([ + todo._id, + ]); + expect(todo._id).to.be.a('string'); + expect(todo.type).to.equal('todo'); + expect(todo.text).to.equal('todo'); + expect(todo.checklist).to.eql([]); + expect(todo.completed).to.be.false; + expect(todo.up).to.not.exists; + }); + + it('adds a reward', () => { + let reward = addTask(user, { + body: { + type: 'reward', + text: 'reward', + }, + }); + + expect(user.tasksOrder.rewards).to.eql([ + reward._id, + ]); + expect(reward._id).to.be.a('string'); + expect(reward.type).to.equal('reward'); + expect(reward.text).to.equal('reward'); + expect(reward.value).to.equal(10); + expect(reward.up).to.not.exists; + }); + + context('respects preferences', () => { + it('true', () => { + user.preferences.newTaskEdit = true; + user.preferences.tagsCollapsed = true; + user.preferences.advancedCollapsed = false; + let task = addTask(user); + + expect(task._editing).to.be.true; + expect(task._tags).to.be.true; + expect(task._advanced).to.be.true; + }); + + it('false', () => { + user.preferences.newTaskEdit = false; + user.preferences.tagsCollapsed = false; + user.preferences.advancedCollapsed = true; + let task = addTask(user); + + expect(task._editing).to.not.exists; + expect(task._tags).to.not.exists; + expect(task._advanced).to.not.exists; + }); + }); +}); diff --git a/test/common/ops/clearCompleted.js b/test/common/ops/clearCompleted.js new file mode 100644 index 0000000000..4dceb3c1f0 --- /dev/null +++ b/test/common/ops/clearCompleted.js @@ -0,0 +1,37 @@ +import clearCompleted from '../../../common/script/ops/clearCompleted'; +import { + generateTodo, +} from '../../helpers/common.helper'; + +describe('shared.ops.clearCompleted', () => { + it('clear completed todos', () => { + let todos = [ + generateTodo({text: 'todo'}), + generateTodo({ + text: 'done', + completed: true, + }), + generateTodo({ + text: 'done chellenge broken', + completed: true, + challenge: { + id: 123, + broken: 'TASK_DELETED', + }, + }), + generateTodo({ + text: 'done chellenge not broken', + completed: true, + challenge: { + id: 123, + }, + }), + ]; + + clearCompleted(todos); + + expect(todos.length).to.equal(2); + expect(todos[0].text).to.equal('todo'); + expect(todos[1].text).to.equal('done chellenge not broken'); + }); +}); diff --git a/test/common/ops/updateTask.js b/test/common/ops/updateTask.js new file mode 100644 index 0000000000..834aa0c419 --- /dev/null +++ b/test/common/ops/updateTask.js @@ -0,0 +1,53 @@ +import updateTask from '../../../common/script/ops/updateTask'; +import { + generateHabit, +} from '../../helpers/common.helper'; + +describe('shared.ops.updateTask', () => { + it('updates a task', () => { + let now = new Date(); + let habit = generateHabit({ + tags: [ + '123', + '456', + ], + + reminders: [{ + _id: '123', + startDate: now, + time: now, + }], + }); + + let res = updateTask(habit, { + body: { + text: 'updated', + id: '123', + _id: '123', + type: 'todo', + tags: ['678'], + checklist: [{ + completed: false, + text: 'item', + _id: '123', + }], + }, + }); + + expect(res.id).to.not.equal('123'); + expect(res._id).to.not.equal('123'); + expect(res.type).to.equal('habit'); + expect(res.text).to.equal('updated'); + expect(res.checklist).to.eql([{ + completed: false, + text: 'item', + _id: '123', + }]); + expect(res.reminders).to.eql([{ + _id: '123', + startDate: now, + time: now, + }]); + expect(res.tags).to.eql(['678']); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index f931283102..527bb90265 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -324,7 +324,7 @@ api.updateTask = { // TODO we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? // TODO regarding comment above, make sure other models with nested fields are using this trick too - _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body))); + _.assign(task, common.ops.updateTask(task.toObject(), req)); // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() // see https://github.com/Automattic/mongoose/issues/2749 @@ -836,12 +836,15 @@ api.clearCompletedTodos = { let user = res.locals.user; // Clear completed todos - // Do not delete challenges completed todos TODO unless the task is broken? + // Do not delete challenges completed todos unless the task is broken await Tasks.Task.remove({ userId: user._id, type: 'todo', completed: true, - 'challenge.id': {$exists: false}, + $or: [ + {'challenge.id': {$exists: false}}, + {'challenge.broken': {$exists: true}}, + ], }).exec(); res.respond(200, {}); diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index e1b69216b6..878bebfa14 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -1,4 +1,4 @@ -import { uuid } from '../../../../common'; +import { v4 as uuid } from 'uuid'; import validator from 'validator'; import objectPath from 'object-path'; // TODO use lodash's unset once v4 is out import _ from 'lodash'; diff --git a/website/src/models/task.js b/website/src/models/task.js index 30c68969c4..66cd0ef524 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -14,6 +14,8 @@ let subDiscriminatorOptions = _.defaults(_.cloneDeep(discriminatorOptions), {_id export let tasksTypes = ['habit', 'daily', 'todo', 'reward']; +// Important +// When something changes here remember to update the client side model at common/script/libs/taskDefaults export let TaskSchema = new Schema({ type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]}, text: {type: String, required: true}, @@ -35,7 +37,7 @@ export let TaskSchema = new Schema({ }, reminders: [{ - id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], default: shared.uuid, required: true}, + _id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], default: shared.uuid, required: true}, startDate: {type: Date, required: true}, time: {type: Date, required: true}, }], From f3abdaf692b2bce768b84d45d4f4313c332806a4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 3 Apr 2016 21:58:52 +0200 Subject: [PATCH 605/976] adapt v2: port updateTask --- test/api/v2/user/tasks/PUT-tasks_id.test.js | 2 +- website/src/controllers/api-v2/user.js | 23 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/test/api/v2/user/tasks/PUT-tasks_id.test.js b/test/api/v2/user/tasks/PUT-tasks_id.test.js index e2ac535ad6..037322a6b7 100644 --- a/test/api/v2/user/tasks/PUT-tasks_id.test.js +++ b/test/api/v2/user/tasks/PUT-tasks_id.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../../helpers/api-integration/v2'; -xdescribe('PUT /user/tasks/:id', () => { +describe('PUT /user/tasks/:id', () => { let user, task; beforeEach(async () => { diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 66f95844bf..8587fed5ea 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -667,6 +667,29 @@ api.deleteTask = function(req, res, next) { }); }; +api.updateTask = function(req, res, next) { + var user = res.locals.user; + + Tasks.Task.findOne({ + _id: req.params.id, + userId: user._id + }, function(err, task) { + if(err) return next(err); + if(!task) return res.status(404).json({err: 'Task not found.'}) + + try { + _.assign(task, shared.ops.updateTask(task.toObject(), req)); + task.save(function(err, task){ + if(err) return next(err); + + return res.json(task.toJSONV2()); + }); + } catch (err) { + return res.status(err.code).json({err: err.message}); + } + }); +}; + api.addTask = function(req, res, next) { var user = res.locals.user; req.body.type = req.body.type || 'habit'; From c3945de0984de14c8b43d86fecac4c622a7b7d4b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 3 Apr 2016 22:11:22 +0200 Subject: [PATCH 606/976] fix tasks updating and reminders tests --- .../integration/tasks/POST-tasks_user.test.js | 4 ++-- .../v3/integration/tasks/PUT-tasks_id.test.js | 8 ++++---- website/src/controllers/api-v3/tasks.js | 19 +------------------ website/src/models/task.js | 6 ------ 4 files changed, 7 insertions(+), 30 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js index 0c467c2891..99d2896e3b 100644 --- a/test/api/v3/integration/tasks/POST-tasks_user.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -152,14 +152,14 @@ describe('POST /tasks/user', () => { text: 'test habit', type: 'habit', reminders: [ - {id: id1, startDate: new Date(), time: new Date()}, + {_id: id1, startDate: new Date(), time: new Date()}, ], }); expect(task.reminders).to.be.an('array'); expect(task.reminders.length).to.eql(1); expect(task.reminders[0]).to.be.an('object'); - expect(task.reminders[0].id).to.eql(id1); + expect(task.reminders[0]._id).to.eql(id1); expect(task.reminders[0].startDate).to.be.a('string'); // json doesn't have dates expect(task.reminders[0].time).to.be.a('string'); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index c5bc5d050f..79a45a09ad 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -80,14 +80,14 @@ describe('PUT /tasks/:id', () => { let savedDaily = await user.put(`/tasks/${daily._id}`, { reminders: [ - {id: id1, time: new Date(), startDate: new Date()}, - {id: id2, time: new Date(), startDate: new Date()}, + {_id: id1, time: new Date(), startDate: new Date()}, + {_id: id2, time: new Date(), startDate: new Date()}, ], }); expect(savedDaily.reminders.length).to.equal(2); - expect(savedDaily.reminders[0].id).to.equal(id1); - expect(savedDaily.reminders[1].id).to.equal(id2); + expect(savedDaily.reminders[0]._id).to.equal(id1); + expect(savedDaily.reminders[1]._id).to.equal(id2); }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 527bb90265..16c0457bbf 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -304,24 +304,7 @@ api.updateTask = { throw new NotFound(res.t('taskNotFound')); } - // If reminders are updated -> replace the original ones - if (req.body.reminders) { - task.reminders = req.body.reminders; - delete req.body.reminders; - } - - // If checklist is updated -> replace the original one - if (req.body.checklist) { - task.checklist = req.body.checklist; - delete req.body.checklist; - } - - // If tags are updated -> replace the original ones - if (req.body.tags) { - task.tags = req.body.tags; - delete req.body.tags; - } - + Tasks.Task.sanitize(req.body); // TODO we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? // TODO regarding comment above, make sure other models with nested fields are using this trick too _.assign(task, common.ops.updateTask(task.toObject(), req)); diff --git a/website/src/models/task.js b/website/src/models/task.js index 66cd0ef524..1a6d201037 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -60,12 +60,6 @@ TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) { return this.sanitize(createObj, noCreate); }; -// A list of additional fields that cannot be updated (but can be set on creation) -let noUpdate = ['_id', 'type']; -TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { - return this.sanitize(updateObj, noUpdate); -}; - // Sanitize checklist objects (disallowing _id) TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) { delete checklistObj._id; From 10583b78ded548fae1532fbf3465d9713a521616 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 3 Apr 2016 22:22:21 +0200 Subject: [PATCH 607/976] fix challenge unit tests --- test/api/v3/unit/models/challenge.test.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/api/v3/unit/models/challenge.test.js b/test/api/v3/unit/models/challenge.test.js index d028933e33..152e7a5111 100644 --- a/test/api/v3/unit/models/challenge.test.js +++ b/test/api/v3/unit/models/challenge.test.js @@ -2,6 +2,7 @@ import { model as Challenge } from '../../../../../website/src/models/challenge' import { model as Group } from '../../../../../website/src/models/group'; import { model as User } from '../../../../../website/src/models/user'; import * as Tasks from '../../../../../website/src/models/task'; +import common from '../../../../../common/'; import { each, find } from 'lodash'; describe('Challenge Model', () => { @@ -104,7 +105,13 @@ describe('Challenge Model', () => { let updatedTaskName = 'Updated Test Habit'; await challenge.addTasks([task]); - _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate({ text: updatedTaskName }))); + let req = { + body: { text: updatedTaskName }, + }; + + Tasks.Task.sanitize(req.body); + _.assign(task, common.ops.updateTask(task.toObject(), req)); + await challenge.updateTask(task); let updatedLeader = await User.findOne({_id: leader._id}); From f6fc50f6c234bee7a7145424e4b1eee7f822a096 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Apr 2016 16:45:12 +0200 Subject: [PATCH 608/976] adapt v2: port updateUser --- test/api/v2/user/PUT-user.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v2/user/PUT-user.test.js b/test/api/v2/user/PUT-user.test.js index 2c93c3df0c..e4774528ae 100644 --- a/test/api/v2/user/PUT-user.test.js +++ b/test/api/v2/user/PUT-user.test.js @@ -5,7 +5,7 @@ import { import { each, get } from 'lodash'; -xdescribe('PUT /user', () => { +describe('PUT /user', () => { let user; beforeEach(async () => { From 14b0ec8b035e379f6d2ce65d7b77256636c7d769 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Mon, 4 Apr 2016 16:05:07 +0000 Subject: [PATCH 609/976] shared-code-webhooks --- common/locales/en/api-v3.json | 4 +- common/script/index.js | 6 ++ common/script/ops/addWebhook.js | 23 +++++--- common/script/ops/deleteWebhook.js | 8 +-- common/script/ops/updateWebhook.js | 19 ++++-- tasks/gulp-tests.js | 4 ++ .../user/DELETE-user_delete_webhook.test.js | 23 ++++++++ .../user/POST-user_add_webhook.test.js | 29 ++++++++++ .../user/PUT-user_update_webhook.test.js | 32 ++++++++++ test/common/ops/addWebhook.test.js | 57 ++++++++++++++++++ test/common/ops/deleteWebhook.test.js | 20 +++++++ test/common/ops/updateWebhook.test.js | 42 ++++++++++++++ website/src/controllers/api-v3/user.js | 58 +++++++++++++++++++ 13 files changed, 305 insertions(+), 20 deletions(-) create mode 100644 test/api/v3/integration/user/DELETE-user_delete_webhook.test.js create mode 100644 test/api/v3/integration/user/POST-user_add_webhook.test.js create mode 100644 test/api/v3/integration/user/PUT-user_update_webhook.test.js create mode 100644 test/common/ops/addWebhook.test.js create mode 100644 test/common/ops/deleteWebhook.test.js create mode 100644 test/common/ops/updateWebhook.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index afb7b17f00..9dcec829a9 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -148,5 +148,7 @@ "noSudoAccess": "You don't have sudo access.", "couponCodeRequired": "The coupon code is required.", "eventRequired": "\"req.params.event\" is required.", - "countRequired": "\"req.query.count\" is required." + "countRequired": "\"req.query.count\" is required.", + "invalidUrl": "invalid url", + "invalidEnabled": "the \"enabled\" parameter should be a boolean" } diff --git a/common/script/index.js b/common/script/index.js index ee7206f3b1..10540046c4 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -118,6 +118,9 @@ import purchase from './ops/purchase'; import purchaseHourglass from './ops/hourglassPurchase'; import readCard from './ops/readCard'; import openMysteryItem from './ops/openMysteryItem'; +import addWebhook from './ops/addWebhook'; +import updateWebhook from './ops/updateWebhook'; +import deleteWebhook from './ops/deleteWebhook'; api.ops = { scoreTask, @@ -137,6 +140,9 @@ api.ops = { purchaseHourglass, readCard, openMysteryItem, + addWebhook, + updateWebhook, + deleteWebhook, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/addWebhook.js b/common/script/ops/addWebhook.js index 99eaf49bba..0300afef28 100644 --- a/common/script/ops/addWebhook.js +++ b/common/script/ops/addWebhook.js @@ -1,15 +1,22 @@ import refPush from '../libs/refPush'; +import validator from 'validator'; +import i18n from '../i18n'; +import { + NotFound, + BadRequest, +} from '../libs/errors'; -module.exports = function(user, req, cb) { +module.exports = function(user, req) { var wh; wh = user.preferences.webhooks; - refPush(wh, { + + if(!validator.isURL(req.body.url)) throw new BadRequest(i18n.t('invalidUrl', req.language)); + if(!validator.isBoolean(req.body.enabled)) throw new BadRequest(i18n.t('invalidEnabled', req.language)); + + user.markModified('preferences.webhooks'); + + return refPush(wh, { url: req.body.url, - enabled: req.body.enabled || true, - id: req.body.id + enabled: req.body.enabled, }); - if (typeof user.markModified === "function") { - user.markModified('preferences.webhooks'); - } - return typeof cb === "function" ? cb(null, user.preferences.webhooks) : void 0; }; diff --git a/common/script/ops/deleteWebhook.js b/common/script/ops/deleteWebhook.js index a187f08770..45a5c4388c 100644 --- a/common/script/ops/deleteWebhook.js +++ b/common/script/ops/deleteWebhook.js @@ -1,7 +1,5 @@ -module.exports = function(user, req, cb) { + +module.exports = function(user, req) { delete user.preferences.webhooks[req.params.id]; - if (typeof user.markModified === "function") { - user.markModified('preferences.webhooks'); - } - return typeof cb === "function" ? cb(null, user.preferences.webhooks) : void 0; + user.markModified('preferences.webhooks'); }; diff --git a/common/script/ops/updateWebhook.js b/common/script/ops/updateWebhook.js index e2775a40b4..27098c4c4a 100644 --- a/common/script/ops/updateWebhook.js +++ b/common/script/ops/updateWebhook.js @@ -1,9 +1,16 @@ import _ from 'lodash'; +import validator from 'validator'; +import i18n from '../i18n'; +import { + NotFound, + BadRequest, +} from '../libs/errors'; -module.exports = function(user, req, cb) { - _.merge(user.preferences.webhooks[req.params.id], req.body); - if (typeof user.markModified === "function") { - user.markModified('preferences.webhooks'); - } - return typeof cb === "function" ? cb(null, user.preferences.webhooks) : void 0; +module.exports = function(user, req) { + if(!validator.isURL(req.body.url)) throw new BadRequest(i18n.t('invalidUrl', req.language)); + if(!validator.isBoolean(req.body.enabled)) throw new BadRequest(i18n.t('invalidEnabled', req.language)); + + user.markModified('preferences.webhooks'); + user.preferences.webhooks[req.params.id].url = req.body.url; + user.preferences.webhooks[req.params.id].enabled = req.body.enabled; }; diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 09a974c7f5..f8472bbf9c 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -358,6 +358,10 @@ gulp.task('test:api-v3:integration', (done) => { pipe(runner); }); +gulp.task('test:api-v3:integration:watch', () => { + gulp.watch(['website/src/controllers/api-v3/**/*', 'test/api/v3/integration/**/*', 'common/script/ops/*'], ['test:api-v3:integration']); +}); + gulp.task('test:api-v3:integration:separate-server', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), diff --git a/test/api/v3/integration/user/DELETE-user_delete_webhook.test.js b/test/api/v3/integration/user/DELETE-user_delete_webhook.test.js new file mode 100644 index 0000000000..46844dd855 --- /dev/null +++ b/test/api/v3/integration/user/DELETE-user_delete_webhook.test.js @@ -0,0 +1,23 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +let user; +let endpoint = '/user/webhook'; + +describe('DELETE /user/webhook', () => { + beforeEach(async () => { + user = await generateUser(); + }); + + it('succeeds', async () => { + let id = 'some-id'; + user.preferences.webhooks[id] = { url: 'http://some-url.com', enabled: true }; + await user.sync(); + expect(user.preferences.webhooks).to.eql({}); + let response = await user.del(`${endpoint}/${id}`); + expect(response).to.eql({}); + await user.sync(); + expect(user.preferences.webhooks).to.eql({}); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_add_webhook.test.js b/test/api/v3/integration/user/POST-user_add_webhook.test.js new file mode 100644 index 0000000000..d13f15baa4 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_add_webhook.test.js @@ -0,0 +1,29 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +let user; +let endpoint = '/user/webhook'; + +describe('POST /user/webhook', () => { + beforeEach(async () => { + user = await generateUser(); + }); + + it('validates', async () => { + await expect(user.post(endpoint, { enabled: true })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidUrl'), + }); + }); + + it('successfully adds the webhook', async () => { + expect(user.preferences.webhooks).to.eql({}); + let response = await user.post(endpoint, { enabled: true, url: 'http://some-url.com'}); + expect(response.id).to.exist; + await user.sync(); + expect(user.preferences.webhooks).to.not.eql({}); + }); +}); diff --git a/test/api/v3/integration/user/PUT-user_update_webhook.test.js b/test/api/v3/integration/user/PUT-user_update_webhook.test.js new file mode 100644 index 0000000000..715070c991 --- /dev/null +++ b/test/api/v3/integration/user/PUT-user_update_webhook.test.js @@ -0,0 +1,32 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +let user; +let url = 'http://new-url.com'; +let enabled = true; + +describe('PUT /user/webhook/:id', () => { + beforeEach(async () => { + user = await generateUser(); + }); + + it('validation fails', async () => { + await expect(user.put('/user/webhook/some-id'), { enabled: true }).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidUrl'), + }); + }); + + it('succeeds', async () => { + let response = await user.post('/user/webhook', { enabled: true, url: 'http://some-url.com'}); + await user.sync(); + expect(user.preferences.webhooks[response.id].url).to.not.eql(url); + let response2 = await user.put(`/user/webhook/${response.id}`, {url, enabled}); + expect(response2).to.eql({}); + await user.sync(); + expect(user.preferences.webhooks[response.id].url).to.eql(url); + }); +}); diff --git a/test/common/ops/addWebhook.test.js b/test/common/ops/addWebhook.test.js new file mode 100644 index 0000000000..11d26e622b --- /dev/null +++ b/test/common/ops/addWebhook.test.js @@ -0,0 +1,57 @@ +import addWebhook from '../../../common/script/ops/addWebhook'; +import { + BadRequest, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.addWebhook', () => { + let user; + let req; + + beforeEach(() => { + user = generateUser(); + req = { body: { + enabled: true, + url: 'http://some-url.com', + } }; + }); + + context('adds webhook', () => { + it('validates req.body.url', (done) => { + delete req.body.url; + try { + addWebhook(user, req); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidUrl')); + done(); + } + }); + + it('validates req.body.enabled', (done) => { + delete req.body.enabled; + try { + addWebhook(user, req); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidEnabled')); + done(); + } + }); + + it('calls marksModified()', () => { + user.markModified = sinon.spy(); + addWebhook(user, req); + expect(user.markModified.called).to.eql(true); + }); + + it('succeeds', () => { + expect(user.preferences.webhooks).to.eql({}); + addWebhook(user, req); + expect(user.preferences.webhooks).to.not.eql({}); + }); + }); +}); diff --git a/test/common/ops/deleteWebhook.test.js b/test/common/ops/deleteWebhook.test.js new file mode 100644 index 0000000000..0a3178007a --- /dev/null +++ b/test/common/ops/deleteWebhook.test.js @@ -0,0 +1,20 @@ +import deleteWebhook from '../../../common/script/ops/deleteWebhook'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.deleteWebhook', () => { + let user; + let req; + + beforeEach(() => { + user = generateUser(); + req = { params: { id: 'some-id' } }; + }); + + it('succeeds', () => { + user.preferences.webhooks = { 'some-id': {} }; + deleteWebhook(user, req); + expect(user.preferences.webhooks).to.eql({}); + }); +}); diff --git a/test/common/ops/updateWebhook.test.js b/test/common/ops/updateWebhook.test.js new file mode 100644 index 0000000000..43c353626e --- /dev/null +++ b/test/common/ops/updateWebhook.test.js @@ -0,0 +1,42 @@ +import updateWebhook from '../../../common/script/ops/updateWebhook'; +import { + BadRequest, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.updateWebhook', () => { + let user; + let req; + let newUrl = 'http://new-url.com'; + + beforeEach(() => { + user = generateUser(); + req = { params: { + id: 'this-id', + }, body: { + url: newUrl, + enabled: true, + } }; + }); + + it('validates body', (done) => { + delete req.body.url; + try { + updateWebhook(user, req); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidUrl')); + done(); + } + }); + + it('succeeds', () => { + let url = 'http://existing-url.com'; + user.preferences.webhooks = { 'this-id': { url } }; + updateWebhook(user, req); + expect(user.preferences.webhooks['this-id'].url).to.eql(newUrl); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 49d2f2f05d..df2ce1888e 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -764,4 +764,62 @@ api.userOpenMysteryItem = { }, }; +/** + * @api {post} /user/webhook + * @apiVersion 3.0.0 + * @apiName UserAddWebhook + * @apiGroup User + * @apiSuccess {} + **/ +api.addWebhook = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/webhook', + async handler (req, res) { + let user = res.locals.user; + let result = common.ops.addWebhook(user, req); + await user.save(); + res.respond(200, {id: result.id}); + }, +}; + +/** + * @api {put} /user/webhook/:id + * @apiVersion 3.0.0 + * @apiName UserUpdateWebhook + * @apiGroup User + * @apiSuccess {} + **/ +api.updateWebhook = { + method: 'PUT', + middlewares: [authWithHeaders()], + url: '/user/webhook/:id', + async handler (req, res) { + let user = res.locals.user; + common.ops.updateWebhook(user, req); + await user.save(); + res.respond(200, {}); + }, +}; + +/** + * @api {delete} /user/webhook/:id + * @apiVersion 3.0.0 + * @apiName UserDeleteWebhook + * @apiGroup User + * @apiSuccess {} + **/ +api.deleteWebhook = { + method: 'DELETE', + middlewares: [authWithHeaders()], + url: '/user/webhook/:id', + async handler (req, res) { + let user = res.locals.user; + common.ops.deleteWebhook(user, req); + await user.save(); + res.respond(200, {}); + }, +}; + + module.exports = api; From 0a40c5697305fc626685b65faf3d09de4c0c473d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Apr 2016 23:18:49 +0200 Subject: [PATCH 610/976] api v3 adapt v2: correctly import dependencies, port create, get, list groups --- test/api/v2/groups/GET-groups.test.js | 10 +- test/api/v2/groups/GET-groups_id.test.js | 2 +- test/api/v2/groups/POST-groups.test.js | 2 +- test/api/v2/user/DELETE-user.test.js | 18 +- .../v3/integration/user/DELETE-user.test.js | 4 +- .../api-integration/v2/object-generators.js | 14 +- website/src/controllers/api-v2/auth.js | 9 +- website/src/controllers/api-v2/challenges.js | 12 +- website/src/controllers/api-v2/coupon.js | 4 +- website/src/controllers/api-v2/dataexport.js | 4 +- website/src/controllers/api-v2/groups.js | 158 ++++++++++-------- website/src/controllers/api-v2/hall.js | 8 +- website/src/controllers/api-v2/members.js | 15 +- .../src/controllers/api-v2/unsubscription.js | 8 +- website/src/controllers/api-v2/user.js | 53 +++--- website/src/controllers/pages.js | 1 + website/src/models/group.js | 59 ++++++- 17 files changed, 258 insertions(+), 123 deletions(-) diff --git a/test/api/v2/groups/GET-groups.test.js b/test/api/v2/groups/GET-groups.test.js index 2da38e3664..e1b7635356 100644 --- a/test/api/v2/groups/GET-groups.test.js +++ b/test/api/v2/groups/GET-groups.test.js @@ -4,11 +4,11 @@ import { resetHabiticaDB, } from '../../../helpers/api-integration/v2'; -xdescribe('GET /groups', () => { +describe('GET /groups', () => { const NUMBER_OF_PUBLIC_GUILDS = 3; - const NUMBER_OF_USERS_GUILDS = 2; let user; + let leader; before(async () => { // Set up a world with a mixture of public and private guilds @@ -16,7 +16,7 @@ xdescribe('GET /groups', () => { await resetHabiticaDB(); user = await generateUser(); - let leader = await generateUser({ balance: 10 }); + leader = await generateUser({ balance: 10 }); await generateGroup(leader, { name: 'public guild - is member', @@ -90,8 +90,8 @@ xdescribe('GET /groups', () => { context('guilds passed in as query', () => { it('returns all guilds user is a part of ', async () => { - await expect(user.get('/groups', null, {type: 'guilds'})) - .to.eventually.have.a.lengthOf(NUMBER_OF_USERS_GUILDS); + await expect(leader.get('/groups', null, {type: 'guilds'})) + .to.eventually.have.a.lengthOf(4); }); }); }); diff --git a/test/api/v2/groups/GET-groups_id.test.js b/test/api/v2/groups/GET-groups_id.test.js index 854a7a2681..1de69da586 100644 --- a/test/api/v2/groups/GET-groups_id.test.js +++ b/test/api/v2/groups/GET-groups_id.test.js @@ -8,7 +8,7 @@ import { each, } from 'lodash'; -xdescribe('GET /groups/:id', () => { +describe('GET /groups/:id', () => { let typesOfGroups = {}; typesOfGroups['public guild'] = { type: 'guild', privacy: 'public' }; typesOfGroups['private guild'] = { type: 'guild', privacy: 'private' }; diff --git a/test/api/v2/groups/POST-groups.test.js b/test/api/v2/groups/POST-groups.test.js index 4c9c2c41a4..8da165aaf7 100644 --- a/test/api/v2/groups/POST-groups.test.js +++ b/test/api/v2/groups/POST-groups.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../helpers/api-integration/v2'; -xdescribe('POST /groups', () => { +describe('POST /groups', () => { context('All groups', () => { let leader; diff --git a/test/api/v2/user/DELETE-user.test.js b/test/api/v2/user/DELETE-user.test.js index 17517cef21..1ba8dcfd9c 100644 --- a/test/api/v2/user/DELETE-user.test.js +++ b/test/api/v2/user/DELETE-user.test.js @@ -4,7 +4,11 @@ import { generateGroup, generateUser, } from '../../../helpers/api-integration/v2'; -import { find } from 'lodash'; +import { + find, + map, +} from 'lodash'; +import Q from 'q'; xdescribe('DELETE /user', () => { let user; @@ -19,6 +23,18 @@ xdescribe('DELETE /user', () => { })).to.eventually.eql(false); }); + it('deletes the user\'s tasks', async () => { + // gets the user's todos ids + let ids = user.todos.map(todo => todo._id); + expect(ids.length).to.be.above(0); // make sure the user has some task to delete + + await user.del('/user'); + + await Q.all(map(ids, id => { + return expect(checkExistence('tasks', id)).to.eventually.eql(false); + })); + }); + context('user has active subscription', () => { it('does not delete account'); }); diff --git a/test/api/v3/integration/user/DELETE-user.test.js b/test/api/v3/integration/user/DELETE-user.test.js index f954903857..6a631676e8 100644 --- a/test/api/v3/integration/user/DELETE-user.test.js +++ b/test/api/v3/integration/user/DELETE-user.test.js @@ -42,7 +42,7 @@ describe('DELETE /user', () => { }); }); - it('deletes the user', async () => { + it('deletes the user\'s tasks', async () => { // gets the user's tasks ids let ids = []; each(user.tasksOrder, (idsForOrder) => { @@ -60,7 +60,7 @@ describe('DELETE /user', () => { })); }); - it('delete the user\'s tasks', async () => { + it('deletes the user', async () => { await user.del('/user', { password, }); diff --git a/test/helpers/api-integration/v2/object-generators.js b/test/helpers/api-integration/v2/object-generators.js index e10a4daa9a..e30f96c7b8 100644 --- a/test/helpers/api-integration/v2/object-generators.js +++ b/test/helpers/api-integration/v2/object-generators.js @@ -72,18 +72,18 @@ export async function createAndPopulateGroup (settings = {}) { let groupLeader = await generateUser(leaderDetails); let group = await generateGroup(groupLeader, groupDetails); + const groupMembershipTypes = { + party: { 'party._id': group._id}, + guild: { guilds: [group._id] }, + }; + let members = await Q.all( times(numberOfMembers, () => { - return generateUser(); + return generateUser(groupMembershipTypes[group.type]); }) ); - let memberIds = members.map((member) => { - return member._id; - }); - memberIds.push(groupLeader._id); - - await group.update({ members: memberIds }); + await group.update({ memberCount: numberOfMembers + 1}); let invitees = await Q.all( times(numberOfInvites, () => { diff --git a/website/src/controllers/api-v2/auth.js b/website/src/controllers/api-v2/auth.js index 9ada4800b9..6ad3f30412 100644 --- a/website/src/controllers/api-v2/auth.js +++ b/website/src/controllers/api-v2/auth.js @@ -7,8 +7,13 @@ var utils = require('../../libs/api-v2/utils'); var nconf = require('nconf'); var request = require('request'); var FirebaseTokenGenerator = require('firebase-token-generator'); -var User = require('../../models/user').model; -var EmailUnsubscription = require('../../models/emailUnsubscription').model; +import { + model as User, +} from '../../models/user'; +import { + model as EmailUnsubscription, +} from '../../models/emailUnsubscription'; + var analytics = utils.analytics; var i18n = require('./../../libs/api-v2/i18n'); diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 89d3ade502..db53ae80e3 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -4,9 +4,15 @@ var _ = require('lodash'); var nconf = require('nconf'); var async = require('async'); var shared = require('../../../../common'); -var User = require('./../../models/user').model; -var Group = require('./../../models/group').model; -var Challenge = require('./../../models/challenge').model; +import { + model as User, +} from '../../models/user'; +import { + model as Group, +} from '../../models/group'; +import { + model as Challenge, +} from '../../models/challenge'; var logging = require('./../../libs/api-v2/logging'); var csvStringify = require('csv-stringify'); var utils = require('../../libs/api-v2/utils'); diff --git a/website/src/controllers/api-v2/coupon.js b/website/src/controllers/api-v2/coupon.js index 8ab576cb6e..38136ce4e5 100644 --- a/website/src/controllers/api-v2/coupon.js +++ b/website/src/controllers/api-v2/coupon.js @@ -1,5 +1,7 @@ var _ = require('lodash'); -var Coupon = require('./../../models/coupon').model; +import { + model as Coupon, +} from '../../models/coupon'; var api = module.exports; var csvStringify = require('csv-stringify'); var async = require('async'); diff --git a/website/src/controllers/api-v2/dataexport.js b/website/src/controllers/api-v2/dataexport.js index 681749bd16..2b3c46ed31 100644 --- a/website/src/controllers/api-v2/dataexport.js +++ b/website/src/controllers/api-v2/dataexport.js @@ -5,7 +5,9 @@ var nconf = require('nconf'); var moment = require('moment'); var js2xmlparser = require("js2xmlparser"); var pd = require('pretty-data').pd; -var User = require('../../models/user').model; +import { + model as User, +} from '../../models/user'; // Avatar screenshot/static-page includes //var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index 4b9c4b199d..f1e42a68da 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -11,10 +11,20 @@ var async = require('async'); var Q = require('q'); var utils = require('./../../libs/api-v2/utils'); var shared = require('../../../../common'); -var User = require('./../../models/user').model; -var Group = require('./../../models/group').model; -var Challenge = require('./../../models/challenge').model; -var EmailUnsubscription = require('./../../models/emailUnsubscription').model; + +import { + model as User, +} from './../../models/user'; +import { + model as Group, +} from './../../models/group'; +import { + model as Challenge, +} from './../../models/challenge'; +import { + model as EmailUnsubscription, +} from './../../models/emailUnsubscription'; + var isProd = nconf.get('NODE_ENV') === 'production'; var api = module.exports; var pushNotify = require('./pushNotifications'); @@ -70,31 +80,41 @@ api.list = function(req, res, next) { // unecessary given our ui-router setup party: function(cb){ if (!~type.indexOf('party')) return cb(null, {}); - Group.findOne({type: 'party', members: {'$in': [user._id]}}) + Group.findOne({_id: user.party._id, type: 'party'}) .select(groupFields).exec(function(err, party){ if (err) return cb(err); - cb(null, (party === null ? [] : [party])); // return as an array for consistent ngResource use + if (!party) return cb(null, []); + party.getTransformedData({cb: function (err, transformedParty) { + if (err) return cb(err); + cb(null, (transformedParty === null ? [] : [transformedParty])); // return as an array for consistent ngResource use + }}); }); }, guilds: function(cb) { if (!~type.indexOf('guilds')) return cb(null, []); - Group.find({members: {'$in': [user._id]}, type:'guild'}) - .select(groupFields).sort(sort).exec(cb); + Group.find({_id: {'$in': user.guilds}, type:'guild'}) + .select(groupFields).sort(sort).exec(function (err, guilds) { + if (err) return cb(err); + async.map(guilds, function (guild, cb1) { + guild.getTransformedData({cb: cb1}) + }, function(err, guildsTransormed) { + cb(err, guildsTransormed); + }); + }); }, 'public': function(cb) { if (!~type.indexOf('public')) return cb(null, []); Group.find({privacy: 'public'}) - .select(groupFields + ' members') + .select(groupFields) .sort(sort) .lean() .exec(function(err, groups){ if (err) return cb(err); _.each(groups, function(g){ // To save some client-side performance, don't send down the full members arr, just send down temp var _isMember - if (~g.members.indexOf(user._id)) g._isMember = true; - g.members = undefined; + if (user.guilds.indexOf(g._id) !== -1) g._isMember = true; }); cb(null, groups); }); @@ -105,7 +125,10 @@ api.list = function(req, res, next) { if (!~type.indexOf('tavern')) return cb(null, {}); Group.findById('habitrpg').select(groupFields).exec(function(err, tavern){ if (err) return cb(err); - cb(null, [tavern]); // return as an array for consistent ngResource use + tavern.getTransformedData({cb: function (err, transformedTavern) { + if (err) return cb(err); + cb(null, ([transformedTavern])); // return as an array for consistent ngResource use + }}); }); } @@ -132,14 +155,24 @@ api.list = function(req, res, next) { api.get = function(req, res, next) { var user = res.locals.user; var gid = req.params.gid; + let isUserGuild = user.guilds.indexOf(gid) !== -1; - var q = (gid == 'party') - ? Group.findOne({type: 'party', members: {'$in': [user._id]}}) - : Group.findOne({$or:[ - {_id:gid, privacy:'public'}, - {_id:gid, privacy:'private', members: {$in:[user._id]}} // if the group is private, only return if they have access - ]}); - populateQuery(gid, q); + var q; + + if (gid === 'party' || gid === user.party._id) { + q = Group.findOne({_id: user.party._id, type: 'party'}) + } else { + + if (isUserGuild) { + q = Group.findOne({type: 'guild', _id: gid}); + } else { + q = Group.findOne({type: 'guild', privacy: 'public', _id: gid}); + } + } + + q.populate('leader', nameFields); + + //populateQuery(gid, q); q.exec(function(err, group){ if (err) return next(err); if(!group){ @@ -150,34 +183,27 @@ api.get = function(req, res, next) { return res.json(group); } - if (!user.contributor.admin) { - _purgeFlagInfoFromChat(group, user); - } - - //Since we have a limit on how many members are populate to the group, we want to make sure the user is always in the group - var userInGroup = _.find(group.members, function(member){ return member._id == user._id; }); - //If the group is private or the group is a party, then the user must be a member of the group based on access restrictions above - if (group.privacy === 'private' || gid === 'party') { - //If the user is not in the group query, remove a user and add the current user - if (!userInGroup) { - group.members.splice(0,1); - group.members.push(user); - } - res.json(group); - } else if ( group.privacy === "public" ) { //The group is public, we must do an extra check to see if the user is already in the group query - //We must see how to check if a user is a member of a public group, so we requery - var q2 = Group.findOne({ _id: group._id, privacy:'public', members: {$in:[user._id]} }); - q2.exec(function(err, group2){ + group.getTransformedData({ + cb: function (err, transformedGroup) { if (err) return next(err); - if (group2 && !userInGroup) { - group.members.splice(0,1); - group.members.push(user); - } - res.json(group); - }); - } - gid = null; + if (!user.contributor.admin) { + _purgeFlagInfoFromChat(transformedGroup, user); + } + + //Since we have a limit on how many members are populate to the group, we want to make sure the user is always in the group + var userInGroup = _.find(transformedGroup.members, function(member){ return member._id == user._id; }); + if ((gid === 'party' || isUserGuild) && !userInGroup) { + transformedGroup.members.splice(0,1); + transformedGroup.members.push(user); + } + + res.json(transformedGroup); + }, + populateMembers: group.type === 'party' ? partyFields : nameFields, + populateInvites: nameFields, + populateChallenges: challengeFields, + }); }); }; @@ -185,10 +211,12 @@ api.get = function(req, res, next) { api.create = function(req, res, next) { var group = new Group(req.body); var user = res.locals.user; - group.members = [user._id]; + //group.members = [user._id]; group.leader = user._id; + if (!group.name) group.name = 'group name'; if(group.type === 'guild'){ + user.guilds.push(group._id); if(user.balance < 1) return res.status(401).json({err: shared.i18n.t('messageInsufficientGems')}); group.balance = 1; @@ -200,33 +228,31 @@ api.create = function(req, res, next) { function(saved,ct,cb){ firebase.updateGroupData(saved); firebase.addUserToGroup(saved._id, user._id); - saved.populate('members', nameFields, cb); + saved.getTransformedData({ + populateMembers: nameFields, + cb, + }) } - ],function(err,saved){ + ],function(err,groupTransformed){ if (err) return next(err); - res.json(saved); + res.json(groupTransformed); group = user = null; }); } else{ - async.waterfall([ - function(cb){ - Group.findOne({type:'party',members:{$in:[user._id]}},cb); - }, - function(found, cb){ - if (found) return cb(shared.i18n.t('messageGroupAlreadyInParty')); - group.save(cb); - }, - function(saved, count, cb){ - firebase.updateGroupData(saved); - firebase.addUserToGroup(saved._id, user._id); - saved.populate('members', nameFields, cb); - } - ], function(err, populated){ - if (err === shared.i18n.t('messageGroupAlreadyInParty')) return res.status(400).json({err:err}); + if (user.party._id) return res.status(400).json({err:shared.i18n.t('messageGroupAlreadyInParty')}); + user.party._id = group._id; + user.save(function (err) { if (err) return next(err); - group = user = null; - return res.json(populated); + group.save(function(err, saved) { + if (err) return next(err); + saved.getTransformedData({ + populateMembers: nameFields, + cb (err, groupTransformed) { + res.json(groupTransformed); + }, + }); + }); }) } } diff --git a/website/src/controllers/api-v2/hall.js b/website/src/controllers/api-v2/hall.js index ec88894c62..05d3bf520c 100644 --- a/website/src/controllers/api-v2/hall.js +++ b/website/src/controllers/api-v2/hall.js @@ -2,8 +2,12 @@ var _ = require('lodash'); var nconf = require('nconf'); var async = require('async'); var shared = require('../../../../common'); -var User = require('./../../models/user').model; -var Group = require('./../../models/group').model; +import { + model as User, +} from '../../models/user'; +import { + model as Group, +} from '../../models/group'; var api = module.exports; api.ensureAdmin = function(req, res, next) { diff --git a/website/src/controllers/api-v2/members.js b/website/src/controllers/api-v2/members.js index 26020a86a7..232bb9ed73 100644 --- a/website/src/controllers/api-v2/members.js +++ b/website/src/controllers/api-v2/members.js @@ -1,6 +1,11 @@ -var User = require('mongoose').model('User'); -var groups = require('../../models/group'); -var partyFields = require('./groups').partyFields +import { + model as groups, + chatDefaults, +} from '../../models/group'; +import { + model as User, +} from '../../models/user'; +let partyFields = require('./groups').partyFields; var api = module.exports; var async = require('async'); var _ = require('lodash'); @@ -49,12 +54,12 @@ api.sendMessage = function(user, member, data){ } msg += data.message ? data.message : ''; } - shared.refPush(member.inbox.messages, groups.chatDefaults(msg, user)); + shared.refPush(member.inbox.messages, chatDefaults(msg, user)); member.inbox.newMessages++; member._v++; member.markModified('inbox.messages'); - shared.refPush(user.inbox.messages, _.defaults({sent:true}, groups.chatDefaults(msg, member))); + shared.refPush(user.inbox.messages, _.defaults({sent:true}, chatDefaults(msg, member))); user.markModified('inbox.messages'); } diff --git a/website/src/controllers/api-v2/unsubscription.js b/website/src/controllers/api-v2/unsubscription.js index ac7fb7bcf5..a91db19d86 100644 --- a/website/src/controllers/api-v2/unsubscription.js +++ b/website/src/controllers/api-v2/unsubscription.js @@ -1,5 +1,9 @@ -var User = require('../../models/user').model; -var EmailUnsubscription = require('../../models/emailUnsubscription').model; +import { + model as User, +} from '../../models/user'; +import { + model as EmailUnsubscription, +} from '../../models/emailUnsubscription'; var utils = require('../../libs/api-v2/utils'); var i18n = require('../../../../common').i18n; diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 8587fed5ea..e88318a03f 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -4,14 +4,21 @@ var _ = require('lodash'); var nconf = require('nconf'); var async = require('async'); var shared = require('../../../../common'); -var User = require('./../../models/user').model; +import { + model as User, +} from '../../models/user'; import * as Tasks from '../../models/task'; import Q from 'q'; import {removeFromArray} from './../../libs/api-v3/collectionManipulators'; var utils = require('./../../libs/api-v2/utils'); var analytics = utils.analytics; -var Group = require('./../../models/group').model; -var Challenge = require('./../../models/challenge').model; +import { + basicFields as basicGroupFields, + model as Group, +} from '../../models/group'; +import { + model as Challenge, +} from '../../models/challenge'; var moment = require('moment'); var logging = require('./../../libs/api-v2/logging'); var acceptablePUTPaths; @@ -442,26 +449,28 @@ api.delete = function(req, res, next) { return res.status(400).json({err:"You have an active subscription, cancel your plan before deleting your account."}); } - Group.find({ - members: { - '$in': [user._id] - } - }, function(err, groups){ - if(err) return next(err); + let types = ['party', 'publicGuilds', 'privateGuilds']; + let groupFields = basicGroupFields.concat(' leader memberCount'); - async.each(groups, function(group, cb){ - group.leave(user, 'remove-all', cb); - }, function(err){ - if(err) return next(err); - - user.remove(function(err){ - if(err) return next(err); - - firebase.deleteUser(user._id); - res.sendStatus(200); - }); - }); - }); + Group.getGroups({user, types, groupFields}) + .then(groups => { + return Q.all(groups.map((group) => { + return group.leave(user, 'remove-all'); + })); + }) + .then(() => { + return Tasks.Task.remove({ + userId: user._id, + }).exec(); + }) + .then(() => { + return user.remove(); + }) + .then(() => { + firebase.deleteUser(user._id); + res.sendStatus(200); + }) + .catch(next); } /* diff --git a/website/src/controllers/pages.js b/website/src/controllers/pages.js index 2f6887b722..d655ce09bf 100644 --- a/website/src/controllers/pages.js +++ b/website/src/controllers/pages.js @@ -24,6 +24,7 @@ api.getFrontPage = { }, }; +// TODO remove api static page let staticPages = ['front', 'privacy', 'terms', 'api', 'features', 'videos', 'contact', 'plans', 'new-stuff', 'community-guidelines', 'old-news', 'press-kit', 'faq', 'overview', 'apps', diff --git a/website/src/models/group.js b/website/src/models/group.js index dbe17f72d0..216d702204 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -22,7 +22,6 @@ let Schema = mongoose.Schema; // NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API // changes made directly to the db will cause Firebase to get out of sync export let schema = new Schema({ - // TODO don't break validation on _id === 'habitrpg' name: {type: String, required: true}, description: String, leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, @@ -65,7 +64,6 @@ export let schema = new Schema({ // 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. // TODO when booting user, remove from .joined and check again if we can now start the quest // TODO as long as quests are party only we can keep it here - // TODO are we sure we need this type of default for this to work? members: {type: Schema.Types.Mixed, default: () => { return {}; }}, @@ -669,6 +667,63 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { return Q.all(promises); }; +// API v2 compatibility methods +schema.methods.getTransformedData = function getTransformedData (options) { + let cb = options.cb; + let populateMembers = options.populateMembers; + let populateInvites = options.populateInvites; + let populateChallenges = options.populateChallenges; + + let obj = this.toJSON(); + + let queryMembers = {}; + let queryInvites = {}; + + if (this.type === 'guild') { + queryInvites['invitations.guilds.id'] = this._id; + } else { + queryInvites['invitations.party.id'] = this._id; + } + + if (this.type === 'guild') { + queryMembers.guilds = this._id; + } else { + queryMembers['party._id'] = this._id; + } + + let selectDataMembers = '_id'; + let selectDataInvites = '_id'; + let selectDataChallenges = '_id'; + + if (populateMembers) { + selectDataMembers += ` ${populateMembers}`; + } + if (populateInvites) { + selectDataInvites += ` ${populateInvites}`; + } + if (populateChallenges) { + selectDataChallenges += ` ${populateChallenges}`; + } + + let membersQuery = User.find(queryMembers).select(selectDataMembers); + if (options.limitPopulation) membersQuery.limit(15); + + Q.all([ + membersQuery.exec(), + User.find(queryInvites).select(populateInvites).exec(), + Challenge.find({group: obj._id}).select(populateMembers).exec(), + ]) + .then((results) => { + obj.members = results[0]; + obj.invites = results[1]; + obj.challenges = results[2]; + + cb(null, obj); + }) + .catch(cb); +}; +// END API v2 compatibility methods + export const INVITES_LIMIT = 100; export let model = mongoose.model('Group', schema); From 374d11b0e4f23f6e29f950c191f4da09fbfcb23a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 5 Apr 2016 12:02:00 +0200 Subject: [PATCH 611/976] v3 adapt v2: port chat routes and group.update --- test/api/v2/groups/POST-groups_id.test.js | 3 +- .../groups/chat/DELETE-groups_id_chat.test.js | 2 +- .../v2/groups/chat/GET-groups_id_chat.test.js | 2 +- .../groups/chat/POST-groups_id_chat.test.js | 2 +- .../POST-groups_id_chat_id_clearflags.test.js | 2 +- .../chat/POST-groups_id_chat_id_flag.test.js | 2 +- .../chat/POST-groups_id_chat_id_like.test.js | 2 +- .../api-integration/v2/object-generators.js | 21 +++++++++++++- website/src/controllers/api-v2/groups.js | 29 ++++++++++++------- 9 files changed, 47 insertions(+), 18 deletions(-) diff --git a/test/api/v2/groups/POST-groups_id.test.js b/test/api/v2/groups/POST-groups_id.test.js index ebdf5c0305..8a6dbba1a0 100644 --- a/test/api/v2/groups/POST-groups_id.test.js +++ b/test/api/v2/groups/POST-groups_id.test.js @@ -1,10 +1,11 @@ import { generateGroup, + createAndPopulateGroup, generateUser, translate as t, } from '../../../helpers/api-integration/v2'; -xdescribe('POST /groups/:id', () => { +describe('POST /groups/:id', () => { context('user is not the leader of the group', () => { let user, otherUser, groupUserDoesNotOwn; diff --git a/test/api/v2/groups/chat/DELETE-groups_id_chat.test.js b/test/api/v2/groups/chat/DELETE-groups_id_chat.test.js index 8f0506723d..dc575b0ec6 100644 --- a/test/api/v2/groups/chat/DELETE-groups_id_chat.test.js +++ b/test/api/v2/groups/chat/DELETE-groups_id_chat.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('DELETE /groups/:id/chat', () => { +describe('DELETE /groups/:id/chat', () => { let group, message, user; beforeEach(async () => { diff --git a/test/api/v2/groups/chat/GET-groups_id_chat.test.js b/test/api/v2/groups/chat/GET-groups_id_chat.test.js index 0a5acc5a0c..d6dc691af7 100644 --- a/test/api/v2/groups/chat/GET-groups_id_chat.test.js +++ b/test/api/v2/groups/chat/GET-groups_id_chat.test.js @@ -2,7 +2,7 @@ import { createAndPopulateGroup, } from '../../../../helpers/api-integration/v2'; -xdescribe('GET /groups/:id/chat', () => { +describe('GET /groups/:id/chat', () => { context('group with multiple messages', () => { let group, member, user; diff --git a/test/api/v2/groups/chat/POST-groups_id_chat.test.js b/test/api/v2/groups/chat/POST-groups_id_chat.test.js index d9803e0a3a..cc280077aa 100644 --- a/test/api/v2/groups/chat/POST-groups_id_chat.test.js +++ b/test/api/v2/groups/chat/POST-groups_id_chat.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('POST /groups/:id/chat', () => { +describe('POST /groups/:id/chat', () => { let group, user; beforeEach(async () => { diff --git a/test/api/v2/groups/chat/POST-groups_id_chat_id_clearflags.test.js b/test/api/v2/groups/chat/POST-groups_id_chat_id_clearflags.test.js index 6cd3b8aec0..0b6a54c429 100644 --- a/test/api/v2/groups/chat/POST-groups_id_chat_id_clearflags.test.js +++ b/test/api/v2/groups/chat/POST-groups_id_chat_id_clearflags.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('POST /groups/:id/chat/:id/clearflags', () => { +describe('POST /groups/:id/chat/:id/clearflags', () => { let guild; beforeEach(async () => { diff --git a/test/api/v2/groups/chat/POST-groups_id_chat_id_flag.test.js b/test/api/v2/groups/chat/POST-groups_id_chat_id_flag.test.js index 4133b7c77f..cd813061b2 100644 --- a/test/api/v2/groups/chat/POST-groups_id_chat_id_flag.test.js +++ b/test/api/v2/groups/chat/POST-groups_id_chat_id_flag.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('POST /groups/:id/chat/:id/flag', () => { +describe('POST /groups/:id/chat/:id/flag', () => { context('another member\'s message', () => { let group, member, message, user; diff --git a/test/api/v2/groups/chat/POST-groups_id_chat_id_like.test.js b/test/api/v2/groups/chat/POST-groups_id_chat_id_like.test.js index 83f0c4901e..9c8ff4c586 100644 --- a/test/api/v2/groups/chat/POST-groups_id_chat_id_like.test.js +++ b/test/api/v2/groups/chat/POST-groups_id_chat_id_like.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('POST /groups/:id/chat/:id/like', () => { +describe('POST /groups/:id/chat/:id/like', () => { context('another member\'s message', () => { let group, member, message, user; diff --git a/test/helpers/api-integration/v2/object-generators.js b/test/helpers/api-integration/v2/object-generators.js index e30f96c7b8..96013ffabe 100644 --- a/test/helpers/api-integration/v2/object-generators.js +++ b/test/helpers/api-integration/v2/object-generators.js @@ -1,5 +1,6 @@ import { times, + map, } from 'lodash'; import Q from 'q'; import { v4 as generateUUID } from 'uuid'; @@ -41,11 +42,29 @@ export async function generateGroup (leader, details = {}, update = {}) { details.privacy = details.privacy || 'private'; details.name = details.name || 'test group'; + let members; + + if (details.members) { + members = details.members; + delete details.members; + } + let group = await leader.post('/groups', details); let apiGroup = new ApiGroup(group); - await apiGroup.update(update); + const groupMembershipTypes = { + party: { 'party._id': group._id}, + guild: { guilds: [group._id] }, + }; + await Q.all( + map(members, (member) => { + return member.update(groupMembershipTypes[group.type]); + }) + ); + + await apiGroup.update(update); + await apiGroup.sync(); return apiGroup; } diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index f1e42a68da..4d6ddfc627 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -265,7 +265,7 @@ api.update = function(req, res, next) { return res.status(401).json({err: shared.i18n.t('messageGroupOnlyLeaderCanUpdate')}); 'name description logo logo leaderMessage leader leaderOnly'.split(' ').forEach(function(attr){ - group[attr] = req.body[attr]; + if (req.body[attr]) group[attr] = req.body[attr]; }); group.save(function(err, saved){ @@ -279,8 +279,10 @@ api.update = function(req, res, next) { // TODO remove from api object? api.attachGroup = function(req, res, next) { var user = res.locals.user; - var gid = req.params.gid; - var q = (gid == 'party') ? Group.findOne({type: 'party', members: {'$in': [res.locals.user._id]}}) : Group.findById(gid); + var gid = req.params.gid === 'party' ? user.party._id : req.params.gid; + + let q = Group.findOne({_id: gid}) + q.exec(function(err, group){ if(err) return next(err); if(!group) return res.status(404).json({err: shared.i18n.t('messageGroupNotFound')}); @@ -298,13 +300,20 @@ api.getChat = function(req, res, next) { // TODO: This code is duplicated from api.get - pull it out into a function to remove duplication. var user = res.locals.user; var gid = req.params.gid; - var q = (gid == 'party') - ? Group.findOne({type: 'party', members: {$in:[user._id]}}) - : Group.findOne({$or:[ - {_id:gid, privacy:'public'}, - {_id:gid, privacy:'private', members: {$in:[user._id]}} - ]}); - populateQuery(gid, q); + + var q; + let isUserGuild = user.guilds.indexOf(gid) !== -1; + + if (gid === 'party' || gid === user.party._id) { + q = Group.findOne({_id: user.party._id, type: 'party'}) + } else { + if (isUserGuild) { + q = Group.findOne({type: 'guild', _id: gid}); + } else { + q = Group.findOne({type: 'guild', privacy: 'public', _id: gid}); + } + } + q.exec(function(err, group){ if (err) return next(err); if (!group && gid!=='party') return res.status(404).json({err: shared.i18n.t('messageGroupNotFound')}); From 08239345b585f625d2b0456d477c05af8f237010 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 5 Apr 2016 13:03:20 +0200 Subject: [PATCH 612/976] v3 adapt v2: port leave, join and invite for groups controller --- test/api/v2/groups/POST-groups_id.test.js | 1 - .../v2/groups/POST-groups_id_invite.test.js | 10 +-- .../api/v2/groups/POST-groups_id_join.test.js | 15 ++-- .../v2/groups/POST-groups_id_leave.test.js | 6 +- website/src/controllers/api-v2/groups.js | 75 ++++++++++++------- 5 files changed, 64 insertions(+), 43 deletions(-) diff --git a/test/api/v2/groups/POST-groups_id.test.js b/test/api/v2/groups/POST-groups_id.test.js index 8a6dbba1a0..9799ecff4e 100644 --- a/test/api/v2/groups/POST-groups_id.test.js +++ b/test/api/v2/groups/POST-groups_id.test.js @@ -1,6 +1,5 @@ import { generateGroup, - createAndPopulateGroup, generateUser, translate as t, } from '../../../helpers/api-integration/v2'; diff --git a/test/api/v2/groups/POST-groups_id_invite.test.js b/test/api/v2/groups/POST-groups_id_invite.test.js index 8d14c36b7e..ef768dbf7e 100644 --- a/test/api/v2/groups/POST-groups_id_invite.test.js +++ b/test/api/v2/groups/POST-groups_id_invite.test.js @@ -4,7 +4,7 @@ import { } from '../../../helpers/api-integration/v2'; import { each } from 'lodash'; -xdescribe('POST /groups/:id/invite', () => { +describe('POST /groups/:id/invite', () => { context('user is a member of the group', () => { each({ 'public guild': {type: 'guild', privacy: 'public'}, @@ -27,8 +27,8 @@ xdescribe('POST /groups/:id/invite', () => { await inviter.post(`/groups/${group._id}/invite`, { uuids: [invitee._id], }); - await group.sync(); - expect(group.invites).to.include(invitee._id); + group = await inviter.get(`/groups/${group._id}`); + expect(_.find(group.invites, {_id: invitee._id})._id).to.exists; }); }); }); @@ -53,8 +53,8 @@ xdescribe('POST /groups/:id/invite', () => { await inviter.post(`/groups/${group._id}/invite`, { uuids: [invitee._id], }); - await group.sync(); - expect(group.invites).to.include(invitee._id); + group = await inviter.get(`/groups/${group._id}`); + expect(_.find(group.invites, {_id: invitee._id})._id).to.exists; }); }); }); diff --git a/test/api/v2/groups/POST-groups_id_join.test.js b/test/api/v2/groups/POST-groups_id_join.test.js index 4f952deeff..1f0f5da58c 100644 --- a/test/api/v2/groups/POST-groups_id_join.test.js +++ b/test/api/v2/groups/POST-groups_id_join.test.js @@ -5,7 +5,7 @@ import { } from '../../../helpers/api-integration/v2'; import { each } from 'lodash'; -xdescribe('POST /groups/:id/join', () => { +describe('POST /groups/:id/join', () => { context('user is already a member of the group', () => { it('returns an error'); }); @@ -30,9 +30,8 @@ xdescribe('POST /groups/:id/join', () => { it(`allows user to join a ${groupType}`, async () => { await invitee.post(`/groups/${group._id}/join`); - await group.sync(); - - expect(group.members).to.include(invitee._id); + group = await invitee.get(`/groups/${group._id}`); + expect(_.find(group.members, {_id: invitee._id})._id).to.exists; }); }); }); @@ -78,9 +77,9 @@ xdescribe('POST /groups/:id/join', () => { it('allows user to join a public guild', async () => { await user.post(`/groups/${group._id}/join`); - await group.sync(); + group = await user.get(`/groups/${group._id}`); - expect(group.members).to.include(user._id); + expect(_.find(group.members, {_id: user._id})._id).to.exists; }); }); @@ -103,9 +102,9 @@ xdescribe('POST /groups/:id/join', () => { it('makes the joining user the leader', async () => { await user.post(`/groups/${group._id}/join`); - await group.sync(); + group = await user.get(`/groups/${group._id}`); - await expect(group.leader).to.eql(user._id); + await expect(group.leader._id).to.eql(user._id); }); }); }); diff --git a/test/api/v2/groups/POST-groups_id_leave.test.js b/test/api/v2/groups/POST-groups_id_leave.test.js index df0930144e..28975a91db 100644 --- a/test/api/v2/groups/POST-groups_id_leave.test.js +++ b/test/api/v2/groups/POST-groups_id_leave.test.js @@ -3,7 +3,7 @@ import { createAndPopulateGroup, } from '../../../helpers/api-integration/v2'; -xdescribe('POST /groups/:id/leave', () => { +describe('POST /groups/:id/leave', () => { context('user is not member of the group', () => { it('returns an error'); }); @@ -28,9 +28,9 @@ xdescribe('POST /groups/:id/leave', () => { it('leaves the group', async () => { await user.post(`/groups/${group._id}/leave`); - await group.sync(); + await user.sync(); - expect(group.members).to.not.include(user._id); + expect(user.guilds).to.not.include(group._id); }); }); diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index 4d6ddfc627..cb795f0612 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -498,7 +498,9 @@ api.join = function(req, res, next) { if (group.type == 'party' && group._id == (user.invitations && user.invitations.party && user.invitations.party.id)) { User.update({_id:user.invitations.party.inviter}, {$inc:{'items.quests.basilist':1}}).exec(); // Reward inviter - user.invitations.party = undefined; // Clear invite + user.invitations.party = {}; // Clear invite + user.markModified('invitations.party'); + user.party._id = group._id; user.save(); // invite new user to pending quest if (group.quest.key && !group.quest.active) { @@ -507,20 +509,29 @@ api.join = function(req, res, next) { group.markModified('quest.members'); } isUserInvited = true; - } else if (group.type == 'guild' && user.invitations && user.invitations.guilds) { + } else if (group.type == 'guild') { var i = _.findIndex(user.invitations.guilds, {id:group._id}); if (~i){ isUserInvited = true; user.invitations.guilds.splice(i,1); + user.guilds.push(group._id); user.save(); }else{ isUserInvited = group.privacy === 'private' ? false : true; + if (isUserInvited) { + user.guilds.push(group._id); + user.save(); + } } } if(!isUserInvited) return res.status(401).json({err: shared.i18n.t('messageGroupRequiresInvite')}); - if (!_.contains(group.members, user._id)){ + if (group.memberCount === 0) { + group.leader = user._id; + } + + /*if (!_.contains(group.members, user._id)){ if (group.members.length === 0) { group.leader = user._id; } @@ -530,7 +541,7 @@ api.join = function(req, res, next) { if (group.invites.length > 0) { group.invites.splice(_.indexOf(group.invites, user._id), 1); } - } + }*/ async.series([ function(cb){ @@ -538,8 +549,12 @@ api.join = function(req, res, next) { }, function(cb){ firebase.addUserToGroup(group._id, user._id); - // TODO why query group once again? - populateQuery(group.type, Group.findById(group._id)).exec(cb); + group.getTransformedData({ + cb, + populateMembers: group.type === 'party' ? partyFields : nameFields, + populateInvites: nameFields, + populateChallenges: challengeFields, + }) } ], function(err, results){ if (err) return next(err); @@ -566,12 +581,9 @@ api.leave = function(req, res, next) { // When removing the user from challenges, should we keep the tasks? var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all'; - group.leave(user, keep, function(err){ - if (err) return next(err); - user = group = keep = null; - - return res.sendStatus(204); - }); + group.leave(user, keep) + .then(() => res.sendStatus(204)) + .catch(next); }; var inviteByUUIDs = function(uuids, group, req, res, next){ @@ -581,7 +593,7 @@ var inviteByUUIDs = function(uuids, group, req, res, next){ if (!invite) return cb({code:400,err:'User with id "' + uuid + '" not found'}); if (group.type == 'guild') { - if (_.contains(group.members,uuid)) + if (_.contains(invite.guilds, group._id)) return cb({code:400, err: "User already in that group"}); if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id})) return cb({code:400, err:"User already invited to that group"}); @@ -589,13 +601,10 @@ var inviteByUUIDs = function(uuids, group, req, res, next){ } else if (group.type == 'party') { if (invite.invitations && !_.isEmpty(invite.invitations.party)) return cb({code: 400,err:"User already pending invitation."}); - Group.find({type: 'party', members: {$in: [uuid]}}, function(err, groups){ - if (err) return cb(err); - if (!_.isEmpty(groups) && groups[0].members.length > 1) { - return cb({code: 400, err: "User already in a party."}) - } - sendInvite(); - }); + if (invite.party && invite.party._id) { + return cb({code: 400, err: "User already in a party."}) + } + sendInvite(); } function sendInvite (){ @@ -610,7 +619,7 @@ var inviteByUUIDs = function(uuids, group, req, res, next){ pushNotify.sendNotify(invite, shared.i18n.t('invitedParty'), group.name); } - group.invites.push(invite._id); + //group.invites.push(invite._id); async.series([ function(cb){ @@ -653,10 +662,17 @@ var inviteByUUIDs = function(uuids, group, req, res, next){ }, function(cb) { // TODO pass group from save above don't find it again, or you have to find it again in order to run populate? - populateQuery(group.type, Group.findById(group._id)).exec(function(err, populatedGroup){ - if(err) return next(err); - - res.json(populatedGroup); + Group.findById(group._id).populate('leader', nameFields).exec(function (err, savedGroup) { + if (err) return next(err); + savedGroup.getTransformedData({ + cb: function (err, transformedGroup) { + if (err) return next(err); + res.json(transformedGroup); + }, + populateMembers: savedGroup.type === 'party' ? partyFields : nameFields, + populateInvites: nameFields, + populateChallenges: challengeFields, + }) }); } ]); @@ -724,10 +740,17 @@ var inviteByEmails = function(invites, group, req, res, next){ api.invite = function(req, res, next){ var group = res.locals.group; + let userParty = res.locals.user.party && res.locals.user.party._id; + let userGuilds = res.locals.user.guilds; - if (group.privacy === 'private' && !_.contains(group.members,res.locals.user._id)) { + if (group.type === 'party' && userParty !== group._id) { return res.status(401).json({err: "Only a member can invite new members!"}); } + + if (group.type === 'guild' && group.privacy === 'private' && !_.contains(userGuilds, group._id)) { + return res.status(401).json({err: "Only a member can invite new members!"}); + } + if (req.body.uuids) { inviteByUUIDs(req.body.uuids, group, req, res, next); } else if (req.body.emails) { From 05b41bb41c571510f0349141f8dd6dab58fc2a70 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 6 Apr 2016 08:27:18 -0500 Subject: [PATCH 613/976] Ported sell function. Add unit tests. Create sell route. Add integration tests --- common/locales/en/api-v3.json | 5 +- common/script/index.js | 2 + common/script/ops/sell.js | 45 ++++++++--- tasks/gulp-eslint.js | 1 - .../integration/user/POST-user_sell.test.js | 42 ++++++++++ test/common/ops/sell.js | 81 +++++++++++++++++++ website/src/controllers/api-v3/user.js | 20 +++++ 7 files changed, 181 insertions(+), 15 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_sell.test.js create mode 100644 test/common/ops/sell.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 5ab3b3399e..a4fd69f0a3 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -151,5 +151,8 @@ "countRequired": "\"req.query.count\" is required.", "petsReleased": "Pets released.", "mountsAndPetsReleased": "Mounts and pets released", - "mountsReleased": "Mounts released" + "mountsReleased": "Mounts released", + "typeNotSellable": "Type is not sellable. Must be one of the following <%= acceptedTypes %>", + "userItemsKeyNotFound": "Key not found for user.items <%= type %>", + "sold": "You sold a <%= key %> <%= type %>" } diff --git a/common/script/index.js b/common/script/index.js index 7482a09fd7..c55ac24c9f 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -121,6 +121,7 @@ import openMysteryItem from './ops/openMysteryItem'; import releasePets from './ops/releasePets'; import releaseBoth from './ops/releaseBoth'; import releaseMounts from './ops/releaseMounts'; +import sell from './ops/sell'; api.ops = { scoreTask, @@ -143,6 +144,7 @@ api.ops = { releasePets, releaseBoth, releaseMounts, + sell, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/sell.js b/common/script/ops/sell.js index 33e5f7d673..d5dfa9fbbb 100644 --- a/common/script/ops/sell.js +++ b/common/script/ops/sell.js @@ -1,23 +1,42 @@ import content from '../content/index'; +import i18n from '../../../common/script/i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; +import { + NotFound, + NotAuthorized, + BadRequest, +} from '../libs/errors'; -module.exports = function(user, req, cb) { - var key, ref, type; - ref = req.params, key = ref.key, type = ref.type; - if (type !== 'eggs' && type !== 'hatchingPotions' && type !== 'food') { - return typeof cb === "function" ? cb({ - code: 404, - message: ":type not found. Must bes in [eggs, hatchingPotions, food]" - }) : void 0; +const ACCEPTEDTYPES = ['eggs', 'hatchingPotions', 'food']; + +module.exports = function sell (user, req = {}) { + let key = _.get(req.params, 'key'); + let type = _.get(req.params, 'type'); + + if (!type) { + throw new BadRequest(i18n.t('typeRequired', req.language)); } + + if (!key) { + throw new BadRequest(i18n.t('keyRequired', req.language)); + } + + if (ACCEPTEDTYPES.indexOf(type) === -1) { + throw new NotAuthorized(i18n.t('typeNotSellable', {acceptedTypes: ACCEPTEDTYPES.join(', ')}, req.language)); + } + if (!user.items[type][key]) { - return typeof cb === "function" ? cb({ - code: 404, - message: ":key not found for user.items." + type - }) : void 0; + throw new NotFound(i18n.t('userItemsKeyNotFound', {type}, req.language)); } + user.items[type][key]--; user.stats.gp += content[type][key].value; - return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('stats items'))) : void 0; + + let response = { + data: _.pick(user, splitWhitespace('stats items')), + message: i18n.t('sold', {type, key}), + }; + + return response; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index e71cde8258..6ed2b567a9 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -30,7 +30,6 @@ const COMMON_FILES = [ '!./common/script/ops/reroll.js', '!./common/script/ops/reset.js', '!./common/script/ops/revive.js', - '!./common/script/ops/sell.js', '!./common/script/ops/sortTag.js', '!./common/script/ops/sortTask.js', '!./common/script/ops/unlock.js', diff --git a/test/api/v3/integration/user/POST-user_sell.test.js b/test/api/v3/integration/user/POST-user_sell.test.js new file mode 100644 index 0000000000..be8d55c932 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_sell.test.js @@ -0,0 +1,42 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import content from '../../../../../common/script/content'; + +describe('POST /user/sell/:type/:key', () => { + let user; + let type = 'eggs'; + let key = 'Wolf'; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('returns an error when user does not have item', async () => { + await expect(user.post(`/user/sell/${type}/${key}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userItemsKeyNotFound', {type}), + }); + }); + + it('sells an item', async () => { + await user.update({ + items: { + eggs: { + Wolf: 1, + }, + }, + }); + + let response = await user.post(`/user/sell/${type}/${key}`); + await user.sync(); + + expect(response.message).to.equal(t('sold', {type, key})); + expect(user.stats.gp).to.equal(content[type][key].value); + }); +}); diff --git a/test/common/ops/sell.js b/test/common/ops/sell.js new file mode 100644 index 0000000000..c5499b00d2 --- /dev/null +++ b/test/common/ops/sell.js @@ -0,0 +1,81 @@ +import sell from '../../../common/script/ops/sell'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import { + NotAuthorized, + BadRequest, + NotFound, +} from '../../../common/script/libs/errors'; +import content from '../../../common/script/content/index'; + +describe('shared.ops.sell', () => { + let user; + let type = 'eggs'; + let key = 'Wolf'; + let acceptedTypes = ['eggs', 'hatchingPotions', 'food']; + + beforeEach(() => { + user = generateUser(); + user.items[type][key] = 1; + }); + + it('returns an error when type is not provided', (done) => { + try { + sell(user); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('typeRequired')); + done(); + } + }); + + it('returns an error when key is not provided', (done) => { + try { + sell(user, {params: { type } }); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('keyRequired')); + done(); + } + }); + + it('returns an error when non-sellable type is provided', (done) => { + let nonSellableType = 'nonSellableType'; + + try { + sell(user, {params: { type: nonSellableType, key } }); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('typeNotSellable', {acceptedTypes: acceptedTypes.join(', ')})); + done(); + } + }); + + it('returns an error when key is not found with type provided', (done) => { + let fakeKey = 'fakeKey'; + + try { + sell(user, {params: { type, key: fakeKey } }); + } catch (err) { + expect(err).to.be.an.instanceof(NotFound); + expect(err.message).to.equal(i18n.t('userItemsKeyNotFound', {type})); + done(); + } + }); + + it('reduces item count from user', () => { + let response = sell(user, {params: { type, key } }); + + expect(response.message).to.equal(i18n.t('sold', {type, key})); + expect(user.items[type][key]).to.equal(0); + }); + + it('increases user\'s gold', () => { + let response = sell(user, {params: { type, key } }); + + expect(response.message).to.equal(i18n.t('sold', {type, key})); + expect(user.stats.gp).to.equal(content[type][key].value); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 0267e9511b..c60b2a3eef 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -828,4 +828,24 @@ api.userReleaseMounts = { }, }; +/* +* @api {post} /user/sell/:type/:key Sells user's items. +* @apiVersion 3.0.0 +* @apiName UserSell +* @apiGroup User +* +* @apiSuccess {Object} data `stats items` +*/ +api.userSell = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/sell/:type/:key', + async handler (req, res) { + let user = res.locals.user; + let sellResponse = common.ops.sell(user, req); + await user.save(); + res.respond(200, sellResponse); + }, +}; + module.exports = api; From b431b020122d4682977582fc333533dc21150c7b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Apr 2016 19:28:43 +0200 Subject: [PATCH 614/976] v3 adapt v2: port tags, cast, cron, deletUser and removeMember. Fix a few bugs in v3 --- common/script/ops/addTag.js | 2 + common/script/ops/deleteTag.js | 2 + common/script/ops/deleteTask.js | 2 + common/script/ops/getTag.js | 2 + common/script/ops/getTags.js | 2 + common/script/ops/sortTag.js | 2 + common/script/ops/sortTask.js | 2 + common/script/ops/updateTag.js | 2 + .../POST-groups_id_removeMember.test.js | 2 +- test/api/v2/user/DELETE-user.test.js | 2 +- test/api/v2/user/GET-user_tags.test.js | 2 +- test/api/v2/user/GET-user_tags_id.test.js | 2 +- .../POST-user_batch-update.test.js | 2 +- website/src/controllers/api-v2/groups.js | 65 ++-- website/src/controllers/api-v2/user.js | 361 ++++++++++++------ website/src/controllers/api-v3/tags.js | 11 +- website/src/controllers/api-v3/user.js | 2 +- website/src/middlewares/api-v3/cron.js | 4 +- website/src/models/task.js | 8 + website/src/models/user.js | 9 + website/src/routes/api-v2/auth.js | 17 +- website/src/routes/api-v2/coupon.js | 7 +- website/src/routes/api-v2/swagger.js | 77 ++-- website/src/routes/api-v2/unsubscription.js | 3 +- website/src/routes/payments.js | 37 +- 25 files changed, 409 insertions(+), 218 deletions(-) diff --git a/common/script/ops/addTag.js b/common/script/ops/addTag.js index a020a5fbaf..2b97e67f38 100644 --- a/common/script/ops/addTag.js +++ b/common/script/ops/addTag.js @@ -1,5 +1,7 @@ import uuid from '../libs/uuid'; +// TODO used only in client, move there? + module.exports = function(user, req, cb) { if (user.tags == null) { user.tags = []; diff --git a/common/script/ops/deleteTag.js b/common/script/ops/deleteTag.js index a82af59e86..275aa3e5bd 100644 --- a/common/script/ops/deleteTag.js +++ b/common/script/ops/deleteTag.js @@ -1,6 +1,8 @@ import i18n from '../i18n'; import _ from 'lodash'; +// TODO used only in client, move there? + module.exports = function(user, req, cb) { var i, tag, tid; tid = req.params.id; diff --git a/common/script/ops/deleteTask.js b/common/script/ops/deleteTask.js index 715b102241..a830e19257 100644 --- a/common/script/ops/deleteTask.js +++ b/common/script/ops/deleteTask.js @@ -1,5 +1,7 @@ import i18n from '../i18n'; +// TODO used only in client, move there? + module.exports = function(user, req, cb) { var i, ref, task; task = user.tasks[(ref = req.params) != null ? ref.id : void 0]; diff --git a/common/script/ops/getTag.js b/common/script/ops/getTag.js index 06f3f24110..b54133c2c7 100644 --- a/common/script/ops/getTag.js +++ b/common/script/ops/getTag.js @@ -1,6 +1,8 @@ import _ from 'lodash'; import i18n from '../i18n'; +// TODO used only in client, move there? + module.exports = function(user, req, cb) { var i, tid; tid = req.params.id; diff --git a/common/script/ops/getTags.js b/common/script/ops/getTags.js index af9419b050..b379f13578 100644 --- a/common/script/ops/getTags.js +++ b/common/script/ops/getTags.js @@ -1,3 +1,5 @@ +// TODO used only in client, move there? + module.exports = function(user, req, cb) { return typeof cb === "function" ? cb(null, user.tags) : void 0; }; diff --git a/common/script/ops/sortTag.js b/common/script/ops/sortTag.js index 85dcda169f..d47083ad3f 100644 --- a/common/script/ops/sortTag.js +++ b/common/script/ops/sortTag.js @@ -1,3 +1,5 @@ +// TODO used only in client, move there? + module.exports = function(user, req, cb) { var from, ref, to; ref = req.query, to = ref.to, from = ref.from; diff --git a/common/script/ops/sortTask.js b/common/script/ops/sortTask.js index b903b692dc..212eaf4de2 100644 --- a/common/script/ops/sortTask.js +++ b/common/script/ops/sortTask.js @@ -1,6 +1,8 @@ import i18n from '../i18n'; import preenTodos from '../libs/preenTodos'; +// TODO used only in client, move there? + module.exports = function(user, req, cb) { var from, id, movedTask, preenedTasks, ref, task, tasks, to; id = req.params.id; diff --git a/common/script/ops/updateTag.js b/common/script/ops/updateTag.js index 8e4019fa51..5e61ff8c6b 100644 --- a/common/script/ops/updateTag.js +++ b/common/script/ops/updateTag.js @@ -1,6 +1,8 @@ import i18n from '../i18n'; import _ from 'lodash'; +// TODO used only in client, move there? + module.exports = function(user, req, cb) { var i, tid; tid = req.params.id; diff --git a/test/api/v2/groups/POST-groups_id_removeMember.test.js b/test/api/v2/groups/POST-groups_id_removeMember.test.js index e594cf0748..ccedd69e9d 100644 --- a/test/api/v2/groups/POST-groups_id_removeMember.test.js +++ b/test/api/v2/groups/POST-groups_id_removeMember.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../helpers/api-integration/v2'; -xdescribe('POST /groups/:id/removeMember', () => { +describe('POST /groups/:id/removeMember', () => { context('user is not member of the group', () => { it('returns an error'); }); diff --git a/test/api/v2/user/DELETE-user.test.js b/test/api/v2/user/DELETE-user.test.js index 1ba8dcfd9c..981349a9e1 100644 --- a/test/api/v2/user/DELETE-user.test.js +++ b/test/api/v2/user/DELETE-user.test.js @@ -10,7 +10,7 @@ import { } from 'lodash'; import Q from 'q'; -xdescribe('DELETE /user', () => { +describe('DELETE /user', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/GET-user_tags.test.js b/test/api/v2/user/GET-user_tags.test.js index 08babe3a9f..fd2032dc96 100644 --- a/test/api/v2/user/GET-user_tags.test.js +++ b/test/api/v2/user/GET-user_tags.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../helpers/api-integration/v2'; -xdescribe('GET /user/tags', () => { +describe('GET /user/tags', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/GET-user_tags_id.test.js b/test/api/v2/user/GET-user_tags_id.test.js index dd1394aac3..9c774c84b6 100644 --- a/test/api/v2/user/GET-user_tags_id.test.js +++ b/test/api/v2/user/GET-user_tags_id.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../helpers/api-integration/v2'; -xdescribe('GET /user/tags/id', () => { +describe('GET /user/tags/id', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/batch-update/POST-user_batch-update.test.js b/test/api/v2/user/batch-update/POST-user_batch-update.test.js index 63dd8021f9..0b1f244ff7 100644 --- a/test/api/v2/user/batch-update/POST-user_batch-update.test.js +++ b/test/api/v2/user/batch-update/POST-user_batch-update.test.js @@ -5,7 +5,7 @@ import { import { each } from 'lodash'; -xdescribe('POST /user/batch-update', () => { +describe('POST /user/batch-update', () => { let user; beforeEach(async () => { diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index cb795f0612..eaeef5d881 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -12,6 +12,8 @@ var Q = require('q'); var utils = require('./../../libs/api-v2/utils'); var shared = require('../../../../common'); +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; + import { model as User, } from './../../models/user'; @@ -786,28 +788,35 @@ api.removeMember = function(req, res, next){ return res.status(401).json({err: "You cannot remove yourself!"}); } - if(_.contains(group.members, uuid)){ - var update = {$pull:{members:uuid}}; - if (group.quest && group.quest.leader === uuid) { - update['$set'] = { - quest: { key: null, leader: null } - }; - } else if(group.quest && group.quest.members){ - // remove member from quest - update['$unset'] = {}; - update['$unset']['quest.members.' + uuid] = ""; - } - update['$inc'] = {memberCount: -1}; - Group.update({_id:group._id},update, function(err, saved){ - if (err) return next(err); + User.findById(uuid, function(err, removedUser){ + if (err) return next(err); + let isMember = group._id === removedUser.party._id || _.contains(removedUser.guilds, group._id); + let isInvited = group._id === removedUser.invitations.party._id || !!_.find(removedUser.invitations.guilds, {id: group._id}); - User.findById(uuid, function(err, removedUser){ - if(err) return next(err); + if(isMember){ + var update = {}; + if (group.quest && group.quest.leader === uuid) { + update['$set'] = { + quest: { key: null, leader: null } + }; + } else if(group.quest && group.quest.members){ + // remove member from quest + update['$unset'] = {}; + update['$unset']['quest.members.' + uuid] = ""; + } + update['$inc'] = {memberCount: -1}; + Group.update({_id:group._id},update, function(err, saved){ + if (err) return next(err); sendMessage(removedUser); //Mark removed users messages as seen var update = {$unset:{}}; + if (group.type === 'guild') { + update.$pull = {guilds: group._id}; + } else { + update.$unset.party = true; + } update.$unset['newMessages.' + group._id] = ''; if (group.quest && group.quest.active && group.quest.leader === uuid) { update['$inc'] = {}; @@ -820,12 +829,8 @@ api.removeMember = function(req, res, next){ group = uuid = null; return res.sendStatus(204); }); - }); - }else if(_.contains(group.invites, uuid)){ - User.findById(uuid, function(err,invited){ - if(err) return next(err); - - var invitations = invited.invitations; + }else if(isInvited){ + var invitations = removedUser.invitations; if(group.type === 'guild'){ invitations.guilds.splice(_.indexOf(invitations.guilds, group._id), 1); }else{ @@ -834,11 +839,8 @@ api.removeMember = function(req, res, next){ async.series([ function(cb){ - invited.save(cb); + removedUser.save(cb); }, - function(cb){ - Group.update({_id:group._id},{$pull:{invites:uuid}}, cb); - } ], function(err, results){ if (err) return next(err); @@ -848,12 +850,11 @@ api.removeMember = function(req, res, next){ group = uuid = null; return res.sendStatus(204); }); - - }); - }else{ - group = uuid = null; - return res.status(400).json({err: "User not found among group's members!"}); - } + }else{ + group = uuid = null; + return res.status(400).json({err: "User not found among group's members!"}); + } + }); } // ------------------------------------ diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index e88318a03f..3ac3a116ab 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -2,11 +2,12 @@ var url = require('url'); var ipn = require('paypal-ipn'); var _ = require('lodash'); var nconf = require('nconf'); -var async = require('async'); +var asyncM = require('async'); var shared = require('../../../../common'); import { model as User, } from '../../models/user'; +import { model as Tag } from '../../models/tag'; import * as Tasks from '../../models/task'; import Q from 'q'; import {removeFromArray} from './../../libs/api-v3/collectionManipulators'; @@ -24,6 +25,8 @@ var logging = require('./../../libs/api-v2/logging'); var acceptablePUTPaths; let restrictedPUTSubPaths; +let i18n = shared.i18n; + var api = module.exports; var firebase = require('../../libs/api-v2/firebase'); var webhook = require('../../libs/api-v2/webhook'); @@ -121,7 +124,7 @@ api.score = function(req, res, next) { direction, }, req); - async.parallel({ + asyncM.parallel({ task: task.save.bind(task), user: user.save.bind(user) }, function(err, results){ @@ -405,38 +408,7 @@ api.update = (req, res, next) => { }); }; -api.cron = function(req, res, next) { - var user = res.locals.user, - progress = user.fns.cron({analytics:utils.analytics, timezoneOffset:req.headers['x-user-timezoneoffset']}), - ranCron = user.isModified(), - quest = shared.content.quests[user.party.quest.key]; - - if (ranCron) res.locals.wasModified = true; - if (!ranCron) return next(null,user); - Group.tavernBoss(user,progress); - if (!quest) return user.save(next); - - // If user is on a quest, roll for boss & player, or handle collections - // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? - async.waterfall([ - function(cb){ - user.save(cb); // make sure to save the cron effects - }, - function(saved, count, cb){ - var type = quest.boss ? 'boss' : 'collect'; - Group[type+'Quest'](user,progress,cb); - }, - function(){ - var cb = arguments[arguments.length-1]; - // User has been updated in boss-grapple, reload - User.findById(user._id, cb); - } - ], function(err, saved) { - res.locals.user = saved; - next(err,saved); - user = progress = quest = null; - }); -}; +api.cron = require('../../middlewares/api-v3/cron'); // api.reroll // Shared.ops // api.reset // Shared.ops @@ -508,84 +480,210 @@ if (nconf.get('NODE_ENV') === 'development') { Tags ------------------------------------------------------------------------ */ -// api.deleteTag // handled in Shared.ops -// api.addTag // handled in Shared.ops -// api.updateTag // handled in Shared.ops -// api.sortTag // handled in Shared.ops + +api.getTags = function (req, res, next) { + res.json(res.locals.user.tags.toObject().map(tag => { + return { + name: tag.name, + id: tag._id, + challenge: tag.challenge, + } + })); +}; + +api.getTag = function (req, res, next) { + let tag = res.locals.user.tags.id(req.params.id); + if (!tag) { + return res.status(404).json({err: i18n.t('messageTagNotFound', req.language)}); + } + + res.json({ + name: tag.name, + id: tag._id, + challenge: tag.challenge, + }); +}; + +api.addTag = function (req, res, next) { + let user = res.locals.user; + + user.tags.push(Tag.sanitize(req.body)); + user.save(function (err, user) { + if (err) return next(err); + + res.json(user.tags.toObject().map(tag => { + return { + name: tag.name, + id: tag._id, + challenge: tag.challenge, + } + })); + }); +}; + +api.updateTag = function (req, res, next) { + let user = res.locals.user; + + let tag = user.tags.id(req.params.id); + if (!tag) { + return res.status(404).json({err: i18n.t('messageTagNotFound', req.language)}); + } + + tag.name = req.body.tag; + user.save(function (err, user) { + if (err) return next(err); + + res.json({ + name: tag.name, + id: tag._id, + challenge: tag.challenge, + }); + }); +} + +api.sortTag = function (req, res, next) { + var ref = req.query; + var to = ref.to; + var from = ref.from; + let user = res.locals.user; + + if (!((to != null) && (from != null))) { + return res.statu(500).json('?to=__&from=__ are required'); + } + + user.tags.splice(to, 0, user.tags.splice(from, 1)[0]); + user.save(function (err, user) { + if (err) return next(err); + + res.json(user.tags.toObject().map(tag => { + return { + name: tag.name, + id: tag._id, + challenge: tag.challenge, + } + })); + }); +} + +api.deleteTag = function (req, res, next) { + let user = res.locals.user; + + let tag = user.tags.id(req.params.id); + if (!tag) { + return res.status(404).json({err: i18n.t('messageTagNotFound', req.language)}); + } + + tag.remove(); + + Tasks.Task.update({ + userId: user._id, + }, { + $pull: { + tags: tag._id, + }, + }, {multi: true}).exec(); + + user.save(function (err, user) { + if (err) return next(err); + + res.json(user.tags.toObject().map(tag => { + return { + name: tag.name, + id: tag._id, + challenge: tag.challenge, + } + })); + }); +} /* ------------------------------------------------------------------------ Spells ------------------------------------------------------------------------ */ -api.cast = function(req, res, next) { - var user = res.locals.user, - targetType = req.query.targetType, - targetId = req.query.targetId, - klass = shared.content.spells.special[req.params.spell] ? 'special' : user.stats.class, - spell = shared.content.spells[klass][req.params.spell]; +api.cast = async function(req, res, next) { + try { + let user = res.locals.user; + let spellId = req.params.spellId; + let targetId = req.query.targetId; - if (!spell) return res.status(404).json({err: 'Spell "' + req.params.spell + '" not found.'}); - if (spell.mana > user.stats.mp) return res.status(400).json({err: 'Not enough mana to cast spell'}); + let klass = common.content.spells.special[spellId] ? 'special' : user.stats.class; + let spell = common.content.spells[klass][spellId]; - var done = function(){ - var err = arguments[0]; - var saved = _.size(arguments == 3) ? arguments[2] : arguments[1]; - if (err) return next(err); - res.json(saved); - user = targetType = targetId = klass = spell = null; - } + if (!spell) return res.status(404).json({err: 'Spell "' + req.params.spell + '" not found.'}); + if (spell.mana > user.stats.mp) return res.status(400).json({err: 'Not enough mana to cast spell'}); - switch (targetType) { - case 'task': - if (!user.tasks[targetId]) return res.status(404).json({err: 'Task "' + targetId + '" not found.'}); - spell.cast(user, user.tasks[targetId]); - user.save(done); - break; + let targetType = spell.target; - case 'self': - spell.cast(user); - user.save(done); - break; + if (targetType === 'task') { + let task = await Tasks.Task.findOne({ + _id: targetId, + userId: user._id, + }).exec(); + if (!task) { + return res.status(404).json({err: 'Task "' + targetId + '" not found.'}); + } - case 'party': - case 'user': - async.waterfall([ - function(cb){ - Group.findOne({type: 'party', members: {'$in': [user._id]}}).populate('members', 'profile.name stats achievements items.special').exec(cb); - }, - function(group, cb) { - // Solo player? let's just create a faux group for simpler code - var g = group ? group : {members:[user]}; - var series = [], found; - if (targetType == 'party') { - spell.cast(user, g.members); - series = _.transform(g.members, function(m,v,k){ - m.push(function(cb2){v.save(cb2)}); - }); - } else { - found = _.find(g.members, {_id: targetId}) - spell.cast(user, found); - series.push(function(cb2){found.save(cb2)}); - } + spell.cast(user, task, req); + await task.save(); + } else if (targetType === 'self') { + spell.cast(user, null, req); + await user.save(); + } else if (targetType === 'tasks') { // new target type when all the user's tasks are necessary + let tasks = await Tasks.Task.find({ + userId: user._id, + 'challenge.id': {$exists: false}, // exclude challenge tasks + $or: [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ], + }).exec(); - if (group && !spell.silent) { - series.push(function(cb2){ - var message = '`'+user.profile.name+' casts '+spell.text() + (targetType=='user' ? ' on '+found.profile.name : ' for the party')+'.`'; - group.sendChat(message); - group.save(cb2); - }) - } + spell.cast(user, tasks, req); - series.push(function(cb2){g = group = series = found = null;cb2();}) + let toSave = tasks.filter(t => t.isModified()); + let isUserModified = user.isModified(); + toSave.unshift(user.save()); + let saved = await Q.all(toSave); + } else if (targetType === 'party' || targetType === 'user') { + let party = await Group.getGroup({groupId: 'party', user}); + // arrays of users when targetType is 'party' otherwise single users + let partyMembers; - async.series(series, cb); - }, - function(whatever, cb){ - user.save(cb); + if (targetType === 'party') { + if (!party) { + partyMembers = [user]; // Act as solo party + } else { + partyMembers = await User.find({'party._id': party._id}).select(partyMembersFields).exec(); } - ], done); - break; + + spell.cast(user, partyMembers, req); + await Q.all(partyMembers.map(m => m.save())); + } else { + if (!party && (!targetId || user._id === targetId)) { + partyMembers = user; + } else { + partyMembers = await User.findOne({_id: targetId, 'party._id': party._id}).select(partyMembersFields).exec(); + } + + if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); + spell.cast(user, partyMembers, req); + await partyMembers.save(); + } + + if (party && !spell.silent) { + let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``; + party.sendChat(message); + await party.save(); + } + } + + user.getTransformedData(function (err, transformedUser) { + if (err) next(err); + res.json(transformedUser); + }); + } catch (e) { + return res.status(500).json({err: 'An error happened'}); } } @@ -594,7 +692,7 @@ api.sessionPartyInvite = function(req,res,next){ if (!req.session.partyInvite) return next(); var inv = res.locals.user.invitations; if (inv.party && inv.party.id) return next(); // already invited to a party - async.waterfall([ + asyncM.waterfall([ function(cb){ Group.findOne({_id:req.session.partyInvite.id, members:{$in:[req.session.partyInvite.inviter]}}) .select('invites members type').exec(cb); @@ -646,6 +744,47 @@ api.clearCompleted = function(req, res, next) { }); }; +api.sortTask = async function (req, res, next) { + try { + let user = res.locals.user; + let to = Number(req.query.to); + + let task = await Tasks.Task.findOne({ + _id: req.params.id, + userId: user._id, + }).exec(); + + if (!task) return res.status(404).json(i18n.t('messageTaskNotFound', req.language)); + if (task.type !== 'todo' || !task.completed) { + let order = user.tasksOrder[`${task.type}s`]; + let currentIndex = order.indexOf(task._id); + + // If for some reason the task isn't ordered (should never happen), push it in the new position + // if the task is moved to a non existing position + // or if the task is moved to position -1 (push to bottom) + // -> push task at end of list + if (!order[to] && to !== -1) { + order.push(task._id); + } else { + if (currentIndex !== -1) order.splice(currentIndex, 1); + if (to === -1) { + order.push(task._id); + } else { + order.splice(to, 0, task._id); + } + } + await user.save(); + } + + user.getTasks(function (err, userTasks) { + if(err) return next(err); + res.json(userTasks); + }); + } catch (e) { + res.status(500).json({err: 'An error happened.'}); + } +} + api.deleteTask = function(req, res, next) { var user = res.locals.user; if(!req.params || !req.params.id) return res.json(404, shared.i18n.t('messageTaskNotFound', req.language)); @@ -660,7 +799,7 @@ api.deleteTask = function(req, res, next) { removeTaskFromOrder(user.tasksOrder[type]) }); - async.parallel({ + asyncM.parallel({ user: user.save.bind(user), task: function(cb) { Tasks.Task.remove({_id: id, userId: user._id}, cb); @@ -789,31 +928,35 @@ api.batchUpdate = function(req, res, next) { }); // call all the operations, then return the user object to the requester - async.waterfall(ops, function(err,_user) { + asyncM.waterfall(ops, function(err,_user) { res.json = oldJson; res.send = oldSend; if (err) return next(err); - var response = _user.toJSON(); - response.wasModified = res.locals.wasModified; - - user.fns.nullify(); - user = res.locals.user = oldSend = oldJson = oldSave = null; + var response; // return only drops & streaks - if (response._tmp && response._tmp.drop){ + if (_user._tmp && _user._tmp.drop){ + response = _user.toJSON(); res.status(200).json({_tmp: {drop: response._tmp.drop}, _v: response._v}); // Fetch full user object - } else if (response.wasModified){ + } else if (res.locals.wasModified){ // Preen 3-day past-completed To-Dos from Angular & mobile app - response.todos = shared.preenTodos(response.todos); - res.status(200).json(response); + _user.getTransformedData(function(err, transformedData){ + response = transformedData; + response.todos = shared.preenTodos(response.todos); + res.status(200).json(response); + }); // return only the version number } else{ + response = _user.toJSON(); res.status(200).json({_v: response._v}); } + + user.fns.nullify(); + user = res.locals.user = oldSend = oldJson = oldSave = null; }); }; diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 36d96c7bb6..2d199fe06c 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -1,6 +1,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Tag } from '../../models/tag'; +import { model as Tasks } from '../../models/task'; import { NotFound, } from '../../libs/api-v3/errors'; @@ -96,7 +97,6 @@ api.updateTag = { let user = res.locals.user; req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); - // TODO check that req.body isn't empty let tagId = req.params.tagId; @@ -139,6 +139,15 @@ api.deleteTag = { if (!tag) throw new NotFound(res.t('tagNotFound')); tag.remove(); + // Remove from all the tasks TODO test + await Tasks.Task.update({ + userId: user._id, + }, { + $pull: { + tags: tag._id, + }, + }, {multi: true}).exec(); + await user.save(); res.respond(200, {}); }, diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 0267e9511b..1f3ec4e0a0 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -373,7 +373,7 @@ api.castSpell = { let response = { tasks: isUserModified ? _.rest(saved) : saved, }; - if (isUserModified) res.user = user; + if (isUserModified) response.user = user; res.respond(200, response); } else if (targetType === 'party' || targetType === 'user') { let party = await Group.getGroup({groupId: 'party', user}); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 9baa503b9d..6b7d3de06e 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -26,7 +26,7 @@ let clearBuffs = { // For incomplete Dailys, deduct experience // Make sure to run this function once in a while as server will not take care of overnight calculations. // And you have to run it every time client connects. -export function cron (options = {}) { +function cron (options = {}) { let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options; user.auth.timestamps.loggedin = now; @@ -271,7 +271,7 @@ export function cron (options = {}) { } // TODO check that it's used everywhere -module.exports = async function cronMiddleware (req, res, next) { +module.exports = function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; diff --git a/website/src/models/task.js b/website/src/models/task.js index 1a6d201037..3d296eb973 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -112,6 +112,14 @@ TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta TaskSchema.methods.toJSONV2 = function toJSONV2 () { let toJSON = this.toJSON(); toJSON.id = toJSON._id; + + let v3Tags = this.tags; + + toJSON.tags = {}; + v3Tags.forEach(tag => { + toJSON.tags[tag] = true; + }); + return toJSON; }; diff --git a/website/src/models/user.js b/website/src/models/user.js index 3964dc839a..d6d529ca9e 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -753,6 +753,15 @@ schema.methods.getTasks = function getUserTasks () { // Given user and an array of tasks, return an API compatible user + tasks obj schema.methods.addTasksToUser = function addTasksToUser (tasks) { let obj = this.toJSON(); + + obj.tags = obj.tags.map(tag => { + return { + id: tag._id, + name: tag.name, + challenge: tag.challenge, + }; + }); + let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it obj.habits = []; diff --git a/website/src/routes/api-v2/auth.js b/website/src/routes/api-v2/auth.js index 615eee8fb3..d4cad28c9e 100644 --- a/website/src/routes/api-v2/auth.js +++ b/website/src/routes/api-v2/auth.js @@ -2,17 +2,18 @@ var auth = require('../../controllers/api-v2/auth'); var express = require('express'); var i18n = require('../../libs/api-v2/i18n'); var router = express.Router(); +import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; /* auth.auth*/ // auth.setupPassport(router); //FIXME make this consistent with the others -router.post('/register', i18n.getUserLanguage, auth.registerUser); -router.post('/user/auth/local', i18n.getUserLanguage, auth.loginLocal); -router.post('/user/auth/social', i18n.getUserLanguage, auth.loginSocial); -router.delete('/user/auth/social', i18n.getUserLanguage, auth.auth, auth.deleteSocial); -router.post('/user/reset-password', i18n.getUserLanguage, auth.resetPassword); -router.post('/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); -router.post('/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); -router.post('/user/change-email', i18n.getUserLanguage, auth.auth, auth.changeEmail); +router.post('/register', getUserLanguage, auth.registerUser); +router.post('/user/auth/local', getUserLanguage, auth.loginLocal); +router.post('/user/auth/social', getUserLanguage, auth.loginSocial); +router.delete('/user/auth/social', getUserLanguage, auth.auth, auth.deleteSocial); +router.post('/user/reset-password', getUserLanguage, auth.resetPassword); +router.post('/user/change-password', getUserLanguage, auth.auth, auth.changePassword); +router.post('/user/change-username', getUserLanguage, auth.auth, auth.changeUsername); +router.post('/user/change-email', getUserLanguage, auth.auth, auth.changeEmail); // router.post('/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken); module.exports = router; diff --git a/website/src/routes/api-v2/coupon.js b/website/src/routes/api-v2/coupon.js index 10c6ece15a..29fdd75312 100644 --- a/website/src/routes/api-v2/coupon.js +++ b/website/src/routes/api-v2/coupon.js @@ -4,9 +4,10 @@ var router = express.Router(); var auth = require('../../controllers/api-v2/auth'); var coupon = require('../../controllers/api-v2/coupon'); var i18n = require('../../libs/api-v2/i18n'); +import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; -router.get('/coupons', auth.authWithUrl, i18n.getUserLanguage, coupon.ensureAdmin, coupon.getCoupons); -router.post('/coupons/generate/:event', auth.auth, i18n.getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons); -router.post('/user/coupon/:code', auth.auth, i18n.getUserLanguage, coupon.enterCode); +router.get('/coupons', auth.authWithUrl, getUserLanguage, coupon.ensureAdmin, coupon.getCoupons); +router.post('/coupons/generate/:event', auth.auth, getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons); +router.post('/user/coupon/:code', auth.auth, getUserLanguage, coupon.enterCode); module.exports = router; diff --git a/website/src/routes/api-v2/swagger.js b/website/src/routes/api-v2/swagger.js index fc94bdd9ed..4912bbd70b 100644 --- a/website/src/routes/api-v2/swagger.js +++ b/website/src/routes/api-v2/swagger.js @@ -19,6 +19,7 @@ var cron = user.cron; var _ = require('lodash'); var content = require('../../../../common').content; var i18n = require('../../libs/api-v2/i18n'); +import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; var forceRefresh = require('../../middlewares/forceRefresh').middleware; module.exports = function(swagger, v2) { @@ -60,7 +61,7 @@ module.exports = function(swagger, v2) { description: "Export user history", method: 'GET' }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: dataexport.history }, "/user/tasks/{id}/{direction}": { @@ -134,7 +135,7 @@ module.exports = function(swagger, v2) { description: 'Unlink a task from its challenge', parameters: [path("id", "Task ID", "string"), query('keep', "When unlinking a challenge task, how to handle the orphans?", 'string', ['keep', 'keep-all', 'remove', 'remove-all'])] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: challenges.unlink }, "/user/inventory/buy": { @@ -235,7 +236,7 @@ module.exports = function(swagger, v2) { method: 'DELETE', description: "Delete a user object entirely, USE WITH CAUTION!" }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: user["delete"] }, "/user/revive": { @@ -311,7 +312,7 @@ module.exports = function(swagger, v2) { description: "This is an advanced route which is useful for apps which might for example need offline support. You can send a whole batch of user-based operations, which allows you to queue them up offline and send them all at once. The format is {op:'nameOfOperation',parameters:{},body:{},query:{}}", parameters: [body('', 'The array of batch-operations to perform', 'object')] }, - middleware: [forceRefresh, auth.auth, i18n.getUserLanguage, cron, user.sessionPartyInvite], + middleware: [forceRefresh, auth.auth, getUserLanguage, cron, user.sessionPartyInvite], action: user.batchUpdate }, "/user/tags/{id}:GET": { @@ -406,7 +407,7 @@ module.exports = function(swagger, v2) { description: "Get a list of groups", parameters: [query('type', "Comma-separated types of groups to return, eg 'party,guilds,public,tavern'", 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: groups.list }, "/groups:POST": { @@ -416,7 +417,7 @@ module.exports = function(swagger, v2) { description: 'Create a group', parameters: [body('', 'Group object (see GroupSchema)', 'object')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: groups.create }, "/groups/{gid}:GET": { @@ -425,7 +426,7 @@ module.exports = function(swagger, v2) { description: "Get a group. The party the user currently is in can be accessed with the gid 'party'.", parameters: [path('gid', 'Group ID', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: groups.get }, "/groups/{gid}:POST": { @@ -435,7 +436,7 @@ module.exports = function(swagger, v2) { description: "Edit a group", parameters: [body('', 'Group object (see GroupSchema)', 'object')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.update }, "/groups/{gid}/join": { @@ -444,7 +445,7 @@ module.exports = function(swagger, v2) { description: 'Join a group', parameters: [path('gid', 'Id of the group to join', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.join }, "/groups/{gid}/leave": { @@ -453,7 +454,7 @@ module.exports = function(swagger, v2) { description: 'Leave a group', parameters: [path('gid', 'ID of the group to leave', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.leave }, "/groups/{gid}/invite": { @@ -462,7 +463,7 @@ module.exports = function(swagger, v2) { description: "Invite a user to a group", parameters: [path('gid', 'Group id', 'string'), body('', 'a payload of invites either under body.uuids or body.emails, only one of them!', 'object')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.invite }, "/groups/{gid}/removeMember": { @@ -471,7 +472,7 @@ module.exports = function(swagger, v2) { description: "Remove / boot a member from a group", parameters: [path('gid', 'Group id', 'string'), query('uuid', 'User id to boot', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.removeMember }, "/groups/{gid}/questAccept": { @@ -480,7 +481,7 @@ module.exports = function(swagger, v2) { description: "Accept a quest invitation", parameters: [path('gid', "Group id", 'string'), query('key', "optional. if provided, trigger new invite, if not, accept existing invite", 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.questAccept }, "/groups/{gid}/questReject": { @@ -489,7 +490,7 @@ module.exports = function(swagger, v2) { description: 'Reject quest invitation', parameters: [path('gid', 'Group id', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.questReject }, "/groups/{gid}/questCancel": { @@ -498,7 +499,7 @@ module.exports = function(swagger, v2) { description: 'Cancel quest before it starts (in invitation stage)', parameters: [path('gid', 'Group to cancel quest in', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.questCancel }, "/groups/{gid}/questAbort": { @@ -507,7 +508,7 @@ module.exports = function(swagger, v2) { description: 'Abort quest after it has started (all progress will be lost)', parameters: [path('gid', 'Group to abort quest in', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.questAbort }, "/groups/{gid}/questLeave": { @@ -516,7 +517,7 @@ module.exports = function(swagger, v2) { description: 'Leave an active quest (Quest leaders cannot leave active quests. They must abort the quest to leave)', parameters: [path('gid', 'Group to leave quest in', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.questLeave }, "/groups/{gid}/chat:GET": { @@ -525,7 +526,7 @@ module.exports = function(swagger, v2) { description: "Get all chat messages", parameters: [path('gid', 'Group to return the chat from ', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.getChat }, "/groups/{gid}/chat:POST": { @@ -535,7 +536,7 @@ module.exports = function(swagger, v2) { description: "Send a chat message", parameters: [query('message', 'Chat message', 'string'), path('gid', 'Group id', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.postChat }, "/groups/{gid}/chat/seen": { @@ -552,7 +553,7 @@ module.exports = function(swagger, v2) { description: 'Delete a chat message in a given group', parameters: [path('gid', 'ID of the group containing the message to be deleted', 'string'), path('messageId', 'ID of message to be deleted', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.deleteChatMessage }, "/groups/{gid}/chat/{mid}/like": { @@ -561,7 +562,7 @@ module.exports = function(swagger, v2) { description: "Like a chat message", parameters: [path('gid', 'Group id', 'string'), path('mid', 'Message id', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.likeChatMessage }, "/groups/{gid}/chat/{mid}/flag": { @@ -570,7 +571,7 @@ module.exports = function(swagger, v2) { description: "Flag a chat message", parameters: [path('gid', 'Group id', 'string'), path('mid', 'Message id', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.flagChatMessage }, "/groups/{gid}/chat/{mid}/clearflags": { @@ -579,7 +580,7 @@ module.exports = function(swagger, v2) { description: "Clear flag count from message and unhide it", parameters: [path('gid', 'Group id', 'string'), path('mid', 'Message id', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], + middleware: [auth.auth, getUserLanguage, groups.attachGroup], action: groups.clearFlagCount }, "/members/{uuid}:GET": { @@ -588,7 +589,7 @@ module.exports = function(swagger, v2) { description: "Get a member.", parameters: [path('uuid', 'Member ID', 'string')] }, - middleware: [i18n.getUserLanguage], + middleware: [getUserLanguage], action: members.getMember }, "/members/{uuid}/message": { @@ -620,14 +621,14 @@ module.exports = function(swagger, v2) { }, "/hall/heroes": { spec: {}, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: hall.getHeroes }, "/hall/heroes/{uid}:GET": { spec: { path: "/hall/heroes/{uid}" }, - middleware: [auth.auth, i18n.getUserLanguage, hall.ensureAdmin], + middleware: [auth.auth, getUserLanguage, hall.ensureAdmin], action: hall.getHero }, "/hall/heroes/{uid}:POST": { @@ -635,14 +636,14 @@ module.exports = function(swagger, v2) { method: 'POST', path: "/hall/heroes/{uid}" }, - middleware: [auth.auth, i18n.getUserLanguage, hall.ensureAdmin], + middleware: [auth.auth, getUserLanguage, hall.ensureAdmin], action: hall.updateHero }, "/hall/patrons": { spec: { parameters: [query('page', 'Page number to fetch (this list is long)', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: hall.getPatrons }, "/challenges:GET": { @@ -650,7 +651,7 @@ module.exports = function(swagger, v2) { path: '/challenges', description: "Get a list of challenges" }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: challenges.list }, "/challenges:POST": { @@ -660,7 +661,7 @@ module.exports = function(swagger, v2) { description: "Create a challenge", parameters: [body('', 'Challenge object (see ChallengeSchema)', 'object')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: challenges.create }, "/challenges/{cid}:GET": { @@ -669,7 +670,7 @@ module.exports = function(swagger, v2) { description: 'Get a challenge', parameters: [path('cid', 'Challenge id', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: challenges.get }, "/challenges/{cid}/csv": { @@ -686,7 +687,7 @@ module.exports = function(swagger, v2) { description: "Update a challenge", parameters: [path('cid', 'Challenge id', 'string'), body('', 'Challenge object (see ChallengeSchema)', 'object')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: challenges.update }, "/challenges/{cid}:DELETE": { @@ -696,7 +697,7 @@ module.exports = function(swagger, v2) { description: "Delete a challenge", parameters: [path('cid', 'Challenge id', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: challenges["delete"] }, "/challenges/{cid}/close": { @@ -705,7 +706,7 @@ module.exports = function(swagger, v2) { description: 'Close a challenge', parameters: [path('cid', 'Challenge id', 'string'), query('uid', 'User ID of the winner', 'string', true)] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: challenges.selectWinner }, "/challenges/{cid}/join": { @@ -714,7 +715,7 @@ module.exports = function(swagger, v2) { description: "Join a challenge", parameters: [path('cid', 'Challenge id', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: challenges.join }, "/challenges/{cid}/leave": { @@ -723,7 +724,7 @@ module.exports = function(swagger, v2) { description: 'Leave a challenge', parameters: [path('cid', 'Challenge id', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: challenges.leave }, "/challenges/{cid}/member/{uid}": { @@ -731,7 +732,7 @@ module.exports = function(swagger, v2) { description: "Get a member's progress in a particular challenge", parameters: [path('cid', 'Challenge id', 'string'), path('uid', 'User id', 'string')] }, - middleware: [auth.auth, i18n.getUserLanguage], + middleware: [auth.auth, getUserLanguage], action: challenges.getMember } }; @@ -765,7 +766,7 @@ module.exports = function(swagger, v2) { method: 'GET' }); if (route.middleware == null) { - route.middleware = path.indexOf('/user') === 0 ? [auth.auth, i18n.getUserLanguage, cron] : [i18n.getUserLanguage]; + route.middleware = path.indexOf('/user') === 0 ? [auth.auth, getUserLanguage, cron] : [i18n.getUserLanguage]; } swagger["add" + route.spec.method](route); return true; diff --git a/website/src/routes/api-v2/unsubscription.js b/website/src/routes/api-v2/unsubscription.js index 832980daf9..08393edb79 100644 --- a/website/src/routes/api-v2/unsubscription.js +++ b/website/src/routes/api-v2/unsubscription.js @@ -2,7 +2,8 @@ var express = require('express'); var router = express.Router(); var i18n = require('../../libs/api-v2/i18n'); var unsubscription = require('../../controllers/api-v2/unsubscription'); +import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; -router.get('/unsubscribe', i18n.getUserLanguage, unsubscription.unsubscribe); +router.get('/unsubscribe', getUserLanguage, unsubscription.unsubscribe); module.exports = router; diff --git a/website/src/routes/payments.js b/website/src/routes/payments.js index 4733deb4dc..7b7f538eb8 100644 --- a/website/src/routes/payments.js +++ b/website/src/routes/payments.js @@ -4,28 +4,29 @@ var router = express.Router(); var auth = require('../controllers/api-v2/auth'); var payments = require('../controllers/payments'); var i18n = require('../libs/api-v2/i18n'); +import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; -router.get('/paypal/checkout', auth.authWithUrl, i18n.getUserLanguage, payments.paypalCheckout); -router.get('/paypal/checkout/success', i18n.getUserLanguage, payments.paypalCheckoutSuccess); -router.get('/paypal/subscribe', auth.authWithUrl, i18n.getUserLanguage, payments.paypalSubscribe); -router.get('/paypal/subscribe/success', i18n.getUserLanguage, payments.paypalSubscribeSuccess); -router.get('/paypal/subscribe/cancel', auth.authWithUrl, i18n.getUserLanguage, payments.paypalSubscribeCancel); -router.post('/paypal/ipn', i18n.getUserLanguage, payments.paypalIPN); // misc ipn handling +router.get('/paypal/checkout', auth.authWithUrl, getUserLanguage, payments.paypalCheckout); +router.get('/paypal/checkout/success', getUserLanguage, payments.paypalCheckoutSuccess); +router.get('/paypal/subscribe', auth.authWithUrl, getUserLanguage, payments.paypalSubscribe); +router.get('/paypal/subscribe/success', getUserLanguage, payments.paypalSubscribeSuccess); +router.get('/paypal/subscribe/cancel', auth.authWithUrl, getUserLanguage, payments.paypalSubscribeCancel); +router.post('/paypal/ipn', getUserLanguage, payments.paypalIPN); // misc ipn handling -router.post('/stripe/checkout', auth.auth, i18n.getUserLanguage, payments.stripeCheckout); -router.post('/stripe/subscribe/edit', auth.auth, i18n.getUserLanguage, payments.stripeSubscribeEdit); -//router.get('/stripe/subscribe', auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribe); // checkout route is used (above) with ?plan= instead -router.get('/stripe/subscribe/cancel', auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribeCancel); +router.post('/stripe/checkout', auth.auth, getUserLanguage, payments.stripeCheckout); +router.post('/stripe/subscribe/edit', auth.auth, getUserLanguage, payments.stripeSubscribeEdit); +//router.get('/stripe/subscribe', auth.authWithUrl, getUserLanguage, payments.stripeSubscribe); // checkout route is used (above) with ?plan= instead +router.get('/stripe/subscribe/cancel', auth.authWithUrl, getUserLanguage, payments.stripeSubscribeCancel); -router.post('/amazon/verifyAccessToken', auth.auth, i18n.getUserLanguage, payments.amazonVerifyAccessToken); -router.post('/amazon/createOrderReferenceId', auth.auth, i18n.getUserLanguage, payments.amazonCreateOrderReferenceId); -router.post('/amazon/checkout', auth.auth, i18n.getUserLanguage, payments.amazonCheckout); -router.post('/amazon/subscribe', auth.auth, i18n.getUserLanguage, payments.amazonSubscribe); -router.get('/amazon/subscribe/cancel', auth.authWithUrl, i18n.getUserLanguage, payments.amazonSubscribeCancel); +router.post('/amazon/verifyAccessToken', auth.auth, getUserLanguage, payments.amazonVerifyAccessToken); +router.post('/amazon/createOrderReferenceId', auth.auth, getUserLanguage, payments.amazonCreateOrderReferenceId); +router.post('/amazon/checkout', auth.auth, getUserLanguage, payments.amazonCheckout); +router.post('/amazon/subscribe', auth.auth, getUserLanguage, payments.amazonSubscribe); +router.get('/amazon/subscribe/cancel', auth.authWithUrl, getUserLanguage, payments.amazonSubscribeCancel); -router.post('/iap/android/verify', auth.authWithUrl, /*i18n.getUserLanguage, */payments.iapAndroidVerify); -router.post('/iap/ios/verify', auth.auth, /*i18n.getUserLanguage, */ payments.iapIosVerify); +router.post('/iap/android/verify', auth.authWithUrl, /*getUserLanguage, */payments.iapAndroidVerify); +router.post('/iap/ios/verify', auth.auth, /*getUserLanguage, */ payments.iapIosVerify); -router.get('/api/v2/coupons/valid-discount/:code', /*auth.authWithUrl, i18n.getUserLanguage, */ payments.validCoupon); +router.get('/api/v2/coupons/valid-discount/:code', /*auth.authWithUrl, getUserLanguage, */ payments.validCoupon); module.exports = router; From 884493f165b96c7b1f8c44a25ba72a4d21b22064 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Apr 2016 19:41:38 +0200 Subject: [PATCH 615/976] fix(delete tags): correctly import Tasks module --- website/src/controllers/api-v3/tags.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 2d199fe06c..4a68512b19 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -1,7 +1,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Tag } from '../../models/tag'; -import { model as Tasks } from '../../models/task'; +import * as Tasks from '../../models/task'; import { NotFound, } from '../../libs/api-v3/errors'; From 2bd710a88263c8da06c2fdf369e446f16629b1dd Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 6 Apr 2016 15:53:32 -0500 Subject: [PATCH 616/976] Ported unlock. Add unit tests. Create unlock route. Add integration tests --- common/locales/en/api-v3.json | 5 +- common/script/index.js | 2 + common/script/ops/unlock.js | 112 +++++++++++------- tasks/gulp-eslint.js | 1 - .../v3/integration/user/POST-user_unlock.js | 37 ++++++ test/common/ops/unlock.js | 84 +++++++++++++ website/src/controllers/api-v3/user.js | 20 ++++ 7 files changed, 214 insertions(+), 47 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_unlock.js create mode 100644 test/common/ops/unlock.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a4fd69f0a3..7c2f5c1001 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -154,5 +154,8 @@ "mountsReleased": "Mounts released", "typeNotSellable": "Type is not sellable. Must be one of the following <%= acceptedTypes %>", "userItemsKeyNotFound": "Key not found for user.items <%= type %>", - "sold": "You sold a <%= key %> <%= type %>" + "sold": "You sold a <%= key %> <%= type %>", + "pathRequired": "Path string is required", + "unlocked": "Items have been unlocked", + "alreadyUnlocked": "Item already unlocked" } diff --git a/common/script/index.js b/common/script/index.js index c55ac24c9f..3b4c5769cd 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -122,6 +122,7 @@ import releasePets from './ops/releasePets'; import releaseBoth from './ops/releaseBoth'; import releaseMounts from './ops/releaseMounts'; import sell from './ops/sell'; +import unlock from './ops/unlock'; api.ops = { scoreTask, @@ -145,6 +146,7 @@ api.ops = { releaseBoth, releaseMounts, sell, + unlock, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/unlock.js b/common/script/ops/unlock.js index c53a5997ec..bbd3cb0e39 100644 --- a/common/script/ops/unlock.js +++ b/common/script/ops/unlock.js @@ -1,63 +1,85 @@ import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; +import dotSet from '../libs/dotSet'; +import { + NotAuthorized, + BadRequest, +} from '../libs/errors'; -module.exports = function(user, req, cb, analytics) { - var alreadyOwns, analyticsData, cost, fullSet, k, path, split, v; - path = req.query.path; - fullSet = ~path.indexOf(","); - cost = ~path.indexOf('background.') ? fullSet ? 3.75 : 1.75 : fullSet ? 1.25 : 0.5; - alreadyOwns = !fullSet && user.fns.dotGet("purchased." + path) === true; - if ((user.balance < cost || !user.balance) && !alreadyOwns) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('notEnoughGems', req.language) - }) : void 0; +module.exports = function unlock (user, req = {}, analytics) { + let path = _.get(req.query, 'path'); + + if (!path) { + throw new BadRequest(i18n.t('pathRequired', req.language)); } - if (fullSet) { - _.each(path.split(","), function(p) { - if (~path.indexOf('gear.')) { - user.fns.dotSet("" + p, true); - true; - } else { + let isFullSet = path.indexOf(',') !== -1; + let cost; + let isBackground = path.indexOf('background.') !== -1; + + if (isBackground && isFullSet) { + cost = 3.75; + } else if (isBackground) { + cost = 1.75; + } else if (isFullSet) { + cost = 1.25; + } else { + cost = 0.5; + } + + let alreadyOwns = !isFullSet && user.fns.dotGet(`purchased.${path}`) === true; + + if ((!user.balance || user.balance < cost) && !alreadyOwns) { + throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); + } + + if (isFullSet) { + _.each(path.split(','), function markItemsAsPurchased (pathPart) { + if (path.indexOf('gear.') !== -1) { + dotSet(user, pathPart, true); + return true; } - user.fns.dotSet("purchased." + p, true); + + dotSet(user, `purchased.${pathPart}`, true); return true; }); } else { if (alreadyOwns) { - split = path.split('.'); - v = split.pop(); - k = split.join('.'); - if (k === 'background' && v === user.preferences.background) { - v = ''; + let split = path.split('.'); + let value = split.pop(); + let key = split.join('.'); + if (key === 'background' && value === user.preferences.background) { + value = ''; } - user.fns.dotSet("preferences." + k, v); - return typeof cb === "function" ? cb(null, req) : void 0; + dotSet(user, `preferences.${key}`, value); + + throw new NotAuthorized(i18n.t('alreadyUnlocked', req.language)); } - user.fns.dotSet("purchased." + path, true); + dotSet(user, `purchased.${path}`, true); } + + if (path.indexOf('gear.') === -1) { + user.markModified('purchased'); + } + user.balance -= cost; - if (~path.indexOf('gear.')) { - if (typeof user.markModified === "function") { - user.markModified('gear.owned'); - } - } else { - if (typeof user.markModified === "function") { - user.markModified('purchased'); - } + + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: path, + itemType: 'customization', + acquireMethod: 'Gems', + gemCost: cost / 0.25, + category: 'behavior', + }); } - analyticsData = { - uuid: user._id, - itemKey: path, - itemType: 'customization', - acquireMethod: 'Gems', - gemCost: cost / .25, - category: 'behavior' + + let response = { + data: _.pick(user, splitWhitespace('purchased preferences items')), + message: i18n.t('unlocked'), }; - if (analytics != null) { - analytics.track('acquire item', analyticsData); - } - return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('purchased preferences items'))) : void 0; + + return response; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 6ed2b567a9..4376bd07f8 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -32,7 +32,6 @@ const COMMON_FILES = [ '!./common/script/ops/revive.js', '!./common/script/ops/sortTag.js', '!./common/script/ops/sortTask.js', - '!./common/script/ops/unlock.js', '!./common/script/ops/update.js', '!./common/script/ops/updateTag.js', '!./common/script/ops/updateTask.js', diff --git a/test/api/v3/integration/user/POST-user_unlock.js b/test/api/v3/integration/user/POST-user_unlock.js new file mode 100644 index 0000000000..6dbdb3c1b1 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_unlock.js @@ -0,0 +1,37 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/unlock', () => { + let user; + let unlockPath = 'shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie'; + let unlockCost = 1.25; + let usersStartingGems = 5; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error when user balance is too low', async () => { + await expect(user.post(`/user/unlock?path=${unlockPath}`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughGems'), + }); + }); + + // More tests in common code unit tests + + it('reduces a user\'s balance', async () => { + await user.update({ + balance: usersStartingGems, + }); + let response = await user.post(`/user/unlock?path=${unlockPath}`); + await user.sync(); + + expect(response.message).to.equal(t('unlocked')); + expect(user.balance).to.equal(usersStartingGems - unlockCost); + }); +}); diff --git a/test/common/ops/unlock.js b/test/common/ops/unlock.js new file mode 100644 index 0000000000..b7d7bda370 --- /dev/null +++ b/test/common/ops/unlock.js @@ -0,0 +1,84 @@ +import unlock from '../../../common/script/ops/unlock'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import { + NotAuthorized, + BadRequest, +} from '../../../common/script/libs/errors'; + +describe('shared.ops.unlock', () => { + let user; + let unlockPath = 'shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie'; + let unlockGearSetPath = 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars'; + let backgroundUnlockPath = 'background.giant_florals'; + let unlockCost = 1.25; + let usersStartingGems = 5; + + beforeEach(() => { + user = generateUser(); + user.balance = usersStartingGems; + }); + + it('returns an error when path is not provided', (done) => { + try { + unlock(user); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('pathRequired')); + done(); + } + }); + + it('returns an error when user balance is too low', (done) => { + user.balance = 0; + + try { + unlock(user, {query: {path: unlockPath}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); + } + }); + + it('returns an error when user already owns an item', (done) => { + try { + unlock(user, {query: {path: backgroundUnlockPath}}); + unlock(user, {query: {path: backgroundUnlockPath}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('alreadyUnlocked')); + done(); + } + }); + + it('unlocks a full set', () => { + let response = unlock(user, {query: {path: unlockPath}}); + + expect(response.message).to.equal(i18n.t('unlocked')); + expect(user.purchased.shirt.convict).to.be.true; + }); + + it('unlocks a full set of gear', () => { + let response = unlock(user, {query: {path: unlockGearSetPath}}); + + expect(response.message).to.equal(i18n.t('unlocked')); + expect(user.items.gear.owned.headAccessory_special_wolfEars).to.be.true; + }); + + it('unlocks a an item', () => { + let response = unlock(user, {query: {path: backgroundUnlockPath}}); + + expect(response.message).to.equal(i18n.t('unlocked')); + expect(user.purchased.background.giant_florals).to.be.true; + }); + + it('reduces a user\'s balance', () => { + let response = unlock(user, {query: {path: unlockPath}}); + + expect(response.message).to.equal(i18n.t('unlocked')); + expect(user.balance).to.equal(usersStartingGems - unlockCost); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index c60b2a3eef..bfa4ca8dff 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -848,4 +848,24 @@ api.userSell = { }, }; +/* +* @api {post} /user/unlock Unlocks items by purchase. +* @apiVersion 3.0.0 +* @apiName UserUnlock +* @apiGroup User +* +* @apiSuccess {Object} data `purchased preferences items` +*/ +api.userUnlock = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/unlock', + async handler (req, res) { + let user = res.locals.user; + let unlockResponse = common.ops.unlock(user, req); + await user.save(); + res.respond(200, unlockResponse); + }, +}; + module.exports = api; From 621bf9609eed955035e4ff343a4b216e7a8e14e4 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 6 Apr 2016 15:56:41 -0500 Subject: [PATCH 617/976] Ported revive. Added unit tests. Add revive route. Added integration tests --- common/locales/en/api-v3.json | 3 +- common/script/index.js | 2 + common/script/ops/revive.js | 114 ++++++++++++------ tasks/gulp-eslint.js | 1 - .../integration/user/POST-user_revive.test.js | 37 ++++++ test/common/ops/revive.js | 91 ++++++++++++++ website/src/controllers/api-v3/user.js | 20 +++ 7 files changed, 227 insertions(+), 41 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_revive.test.js create mode 100644 test/common/ops/revive.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a4fd69f0a3..cc8dbfc655 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -154,5 +154,6 @@ "mountsReleased": "Mounts released", "typeNotSellable": "Type is not sellable. Must be one of the following <%= acceptedTypes %>", "userItemsKeyNotFound": "Key not found for user.items <%= type %>", - "sold": "You sold a <%= key %> <%= type %>" + "sold": "You sold a <%= key %> <%= type %>", + "cannotRevive": "Cannot revive if not dead" } diff --git a/common/script/index.js b/common/script/index.js index c55ac24c9f..9e1b8a1496 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -122,6 +122,7 @@ import releasePets from './ops/releasePets'; import releaseBoth from './ops/releaseBoth'; import releaseMounts from './ops/releaseMounts'; import sell from './ops/sell'; +import revive from './ops/revive'; api.ops = { scoreTask, @@ -145,6 +146,7 @@ api.ops = { releaseBoth, releaseMounts, sell, + revive, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/revive.js b/common/script/ops/revive.js index 7b6fe8445f..ce4bbc2fd6 100644 --- a/common/script/ops/revive.js +++ b/common/script/ops/revive.js @@ -1,72 +1,108 @@ import content from '../content/index'; import i18n from '../i18n'; import _ from 'lodash'; +import { + NotAuthorized, +} from '../libs/errors'; +import splitWhitespace from '../libs/splitWhitespace'; +import randomVal from '../fns/randomVal'; -module.exports = function(user, req, cb, analytics) { - var analyticsData, base, cl, gearOwned, item, losableItems, lostItem, lostStat; - if (!(user.stats.hp <= 0)) { - return typeof cb === "function" ? cb({ - code: 400, - message: "Cannot revive if not dead" - }) : void 0; +module.exports = function revive (user, req = {}, analytics) { + if (user.stats.hp > 0) { + throw new NotAuthorized(i18n.t('cannotRevive', req.language)); } + _.merge(user.stats, { hp: 50, exp: 0, - gp: 0 + gp: 0, }); + if (user.stats.lvl > 1) { user.stats.lvl--; } - lostStat = user.fns.randomVal(_.reduce(['str', 'con', 'per', 'int'], (function(m, k) { + + let lostStat = randomVal(user, _.reduce(['str', 'con', 'per', 'int'], function findRandomStat (m, k) { if (user.stats[k]) { m[k] = k; } return m; - }), {})); + }, {})); + if (lostStat) { user.stats[lostStat]--; } - cl = user.stats["class"]; - gearOwned = (typeof (base = user.items.gear.owned).toObject === "function" ? base.toObject() : void 0) || user.items.gear.owned; - losableItems = {}; - _.each(gearOwned, function(v, k) { - var itm; - if (v) { - itm = content.gear.flat['' + k]; + + let base = user.items.gear.owned; + let gearOwned; + + if (typeof base.toObject === 'function') { + gearOwned = base.toObject(); + } else { + gearOwned = user.items.gear.owned; + } + + let losableItems = {}; + let userClass = user.stats.class; + + _.each(gearOwned, function findLosableItems (value, key) { + let itm; + if (value) { + itm = content.gear.flat[key]; + if (itm) { - if ((itm.value > 0 || k === 'weapon_warrior_0') && (itm.klass === cl || (itm.klass === 'special' && (!itm.specialClass || itm.specialClass === cl)) || itm.klass === 'armoire')) { - return losableItems['' + k] = '' + k; + let itemHasValueOrWarrior0 = itm.value > 0 || key === 'weapon_warrior_0'; + + let itemClassEqualsUserClass = itm.klass === userClass; + + let itemClassSpecial = itm.klass === 'special'; + let itemNotSpecialOrUserClassIsSpecial = !itm.specialClass || itm.specialClass === userClass; + let itemIsSpecial = itemNotSpecialOrUserClassIsSpecial && itemClassSpecial; + + let itemIsArmoire = itm.klass === 'armoire'; + + if (itemHasValueOrWarrior0 && (itemClassEqualsUserClass || itemIsSpecial || itemIsArmoire)) { + losableItems[key] = key; + return losableItems[key]; } } } }); - lostItem = user.fns.randomVal(losableItems); - if (item = content.gear.flat[lostItem]) { + + let lostItem = randomVal(user, losableItems); + + let message = ''; + let item = content.gear.flat[lostItem]; + + if (item) { user.items.gear.owned[lostItem] = false; + if (user.items.gear.equipped[item.type] === lostItem) { - user.items.gear.equipped[item.type] = item.type + "_base_0"; + user.items.gear.equipped[item.type] = `${item.type}_base_0`; } + if (user.items.gear.costume[item.type] === lostItem) { - user.items.gear.costume[item.type] = item.type + "_base_0"; + user.items.gear.costume[item.type] = `${item.type}_base_0`; } + + message = i18n.t('messageLostItem', { itemText: item.text(req.language)}, req.language); } - if (typeof user.markModified === "function") { - user.markModified('items.gear'); + + user.markModified('items.gear'); + + if (analytics) { + analytics.track('Death', { + uuid: user._id, + lostItem, + gaLabel: lostItem, + category: 'behavior', + }); } - analyticsData = { - uuid: user._id, - lostItem: lostItem, - gaLabel: lostItem, - category: 'behavior' + + let response = { + data: _.pick(user, splitWhitespace('user.items')), + message, }; - if (analytics != null) { - analytics.track('Death', analyticsData); - } - return typeof cb === "function" ? cb((item ? { - code: 200, - message: i18n.t('messageLostItem', { - itemText: item.text(req.language) - }, req.language) - } : null), user) : void 0; + + return response; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 6ed2b567a9..191dce6a5d 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -29,7 +29,6 @@ const COMMON_FILES = [ '!./common/script/ops/releasePets.js', '!./common/script/ops/reroll.js', '!./common/script/ops/reset.js', - '!./common/script/ops/revive.js', '!./common/script/ops/sortTag.js', '!./common/script/ops/sortTask.js', '!./common/script/ops/unlock.js', diff --git a/test/api/v3/integration/user/POST-user_revive.test.js b/test/api/v3/integration/user/POST-user_revive.test.js new file mode 100644 index 0000000000..6ba85ac87f --- /dev/null +++ b/test/api/v3/integration/user/POST-user_revive.test.js @@ -0,0 +1,37 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/revive', () => { + let user; + + beforeEach(async () => { + user = await generateUser({ + 'user.items.gear.owned': {weaponKey: true}, + }); + }); + + it('returns an error when user is not dead', async () => { + await expect(user.post('/user/revive')) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cannotRevive'), + }); + }); + + // More tests in common code unit tests + + it('decreases a stat', async () => { + await user.update({ + 'stats.str': 2, + 'stats.hp': 0, + }); + + await user.post('/user/revive'); + await user.sync(); + + expect(user.stats.str).to.equal(1); + }); +}); diff --git a/test/common/ops/revive.js b/test/common/ops/revive.js new file mode 100644 index 0000000000..42e1915b07 --- /dev/null +++ b/test/common/ops/revive.js @@ -0,0 +1,91 @@ +import revive from '../../../common/script/ops/revive'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; +import content from '../../../common/script/content/index'; + +describe('shared.ops.revive', () => { + let user; + + beforeEach(() => { + user = generateUser(); + user.stats.hp = 0; + }); + + it('returns an error when user is not dead', (done) => { + user.stats.hp = 10; + + try { + revive(user); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('cannotRevive')); + done(); + } + }); + + it('resets user\'s hp, exp and gp', () => { + user.stats.exp = 100; + user.stats.gp = 100; + + revive(user); + + expect(user.stats.hp).to.equal(50); + expect(user.stats.exp).to.equal(0); + expect(user.stats.gp).to.equal(0); + }); + + it('decreases user\'s level', () => { + user.stats.lvl = 2; + revive(user); + + expect(user.stats.lvl).to.equal(1); + }); + + it('decreases a stat', () => { + user.stats.str = 2; + revive(user); + + expect(user.stats.str).to.equal(1); + }); + + it('removes a random item from user gear owned', () => { + let weaponKey = 'weapon_warrior_0'; + user.items.gear.owned[weaponKey] = true; + + let reviveRequest = revive(user); + + expect(reviveRequest.message).to.equal(i18n.t('messageLostItem', { itemText: content.gear.flat[weaponKey].text()})); + expect(user.items.gear.owned[weaponKey]).to.be.false; + }); + + it('removes a random item from user gear equipped', () => { + let weaponKey = 'weapon_warrior_0'; + let itemToLose = content.gear.flat[weaponKey]; + + user.items.gear.owned[weaponKey] = true; + user.items.gear.equipped[itemToLose.type] = itemToLose.key; + + let reviveRequest = revive(user); + + expect(reviveRequest.message).to.equal(i18n.t('messageLostItem', { itemText: itemToLose.text()})); + expect(user.items.gear.equipped[itemToLose.type]).to.equal(`${itemToLose.type}_base_0`); + }); + + it('removes a random item from user gear costume', () => { + let weaponKey = 'weapon_warrior_0'; + let itemToLose = content.gear.flat[weaponKey]; + + user.items.gear.owned[weaponKey] = true; + user.items.gear.costume[itemToLose.type] = itemToLose.key; + + let reviveRequest = revive(user); + + expect(reviveRequest.message).to.equal(i18n.t('messageLostItem', { itemText: itemToLose.text()})); + expect(user.items.gear.costume[itemToLose.type]).to.equal(`${itemToLose.type}_base_0`); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index c60b2a3eef..16a281d727 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -848,4 +848,24 @@ api.userSell = { }, }; +/** +* @api {post} /user/revive Revives user from death. +* @apiVersion 3.0.0 +* @apiName UserRevive +* @apiGroup User +* +* @apiSuccess {Object} data `user.items` +*/ +api.userRevive = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/revive', + async handler (req, res) { + let user = res.locals.user; + let reviveResponse = common.ops.revive(user, req, res.analytics); + await user.save(); + res.respond(200, reviveResponse); + }, +}; + module.exports = api; From d49847688eec3b5b6b355fcc2cd608a0ea2c9192 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 7 Apr 2016 11:22:10 +0200 Subject: [PATCH 618/976] v2 adapt to v3: port challenges create, get and list --- .../v2/challenges/GET-challenges_id.test.js | 2 +- website/src/controllers/api-v2/challenges.js | 233 ++++++++++-------- website/src/models/challenge.js | 100 ++++++++ 3 files changed, 229 insertions(+), 106 deletions(-) diff --git a/test/api/v2/challenges/GET-challenges_id.test.js b/test/api/v2/challenges/GET-challenges_id.test.js index cd7ee76d44..dab8a71c0a 100644 --- a/test/api/v2/challenges/GET-challenges_id.test.js +++ b/test/api/v2/challenges/GET-challenges_id.test.js @@ -3,7 +3,7 @@ import { generateChallenge, } from '../../../helpers/api-integration/v2'; -xdescribe('GET /challenges/:id', () => { +describe('GET /challenges/:id', () => { context('Member of a challenge', () => { let leader, party, challenge; diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index db53ae80e3..6f0b3c81fd 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -18,6 +18,7 @@ var csvStringify = require('csv-stringify'); var utils = require('../../libs/api-v2/utils'); var api = module.exports; var pushNotify = require('./pushNotifications'); +import Q from 'q'; /* ------------------------------------------------------------------------ @@ -25,60 +26,75 @@ var pushNotify = require('./pushNotifications'); ------------------------------------------------------------------------ */ -api.list = function(req, res, next) { - var user = res.locals.user; - async.waterfall([ - function(cb){ - // Get all available groups I belong to - Group.find({members: {$in: [user._id]}}).select('_id').exec(cb); - }, - function(gids, cb){ - // and their challenges - Challenge.find({ - $or:[ - {leader: user._id}, - {members:{$in:[user._id]}}, // all challenges I belong to (is this necessary? thought is a left a group, but not its challenge) - {group:{$in:gids}}, // all challenges in my groups - {group: 'habitrpg'} // public group - ], - _id:{$ne:'95533e05-1ff9-4e46-970b-d77219f199e9'} // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug - }) - .select('name leader description group memberCount prize official') - .select({members:{$elemMatch:{$in:[user._id]}}}) - .sort('-official -timestamp') - .populate('group', '_id name type') - .populate('leader', 'profile.name') - .exec(cb); - } - ], function(err, challenges){ - if (err) return next(err); - _.each(challenges, function(c){ - c._isMember = c.members.length > 0; +api.list = async function(req, res, next) { + try { + var user = res.locals.user; + + let challenges = await Challenge.find({ + $or: [ + {_id: {$in: user.challenges}}, // Challenges where the user is participating + {group: {$in: user.getGroups()}}, // Challenges in groups where I'm a member + {leader: user._id}, // Challenges where I'm the leader + ], + _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit }) - res.json(challenges); - user = null; - }); + .sort('-official -timestamp') + // .populate('group', basicGroupFields) + // .populate('leader', nameFields) + .exec(); + + let resChals = challenges.map(challenge => { + let obj = challenge.toJSON(); + + obj._isMember = user.challenges.indexOf(challenge._id) !== -1; + }); + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + await Q.all(resChals.map((chal, index) => { + return Q.all([ + User.findById(chal.leader).select(nameFields).exec(), + Group.findById(chal.group).select(basicGroupFields).exec(), + ]).then(populatedData => { + resChals[index].leader = populatedData[0].toJSON({minimize: true}); + resChals[index].group = populatedData[1].toJSON({minimize: true}); + }); + })); + + res.json(resChals); + } catch (err) { + next(err); + } } // GET -api.get = function(req, res, next) { - var user = res.locals.user; - // TODO use mapReduce() or aggregate() here to - // 1) Find the sum of users.tasks.values within the challnege (eg, {'profile.name':'tyler', 'sum': 100}) - // 2) Sort by the sum - // 3) Limit 30 (only show the 30 users currently in the lead) - Challenge.findById(req.params.cid) - .populate('members', 'profile.name _id') - .populate('group', '_id name type') - .populate('leader', 'profile.name') - .exec(function(err, challenge){ - if(err) return next(err); - if (!challenge) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'}); - challenge._isMember = !!(_.find(challenge.members, function(member) { - return member._id === user._id; - })); - res.json(challenge); +api.get = async function(req, res, next) { + try { + let user = res.locals.user; + let challengeId = req.params.cid; + + let challenge = await Challenge.findById(challengeId) + // Don't populate the group as we'll fetch it manually later + // .populate('leader', nameFields) + .exec(); + if (!challenge) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'}); + + // Fetching basic group data + let group = await Group.getGroup({user, groupId: challenge.group, optionalMembership: true}); + if (!group || !challenge.canView(user, group)) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'}); + + let leaderRes = (await User.findById(challenge.leader).select('profile.name').exec()).toJSON({minimize: true}); + + challenge.getTransformedData({ + populateMembers: 'profile.name', + cb (err, transformedChal) { + transformedChal.group = group.toJSON({minimize: true}); + transformedChal.leader = leaderRes; + transformedChal._isMember = user.challenges.indexOf(transformedChal._id) !== -1; + res.json(transformedChal); + } }); + } catch (err) { + next(err); + } } api.csv = function(req, res, next) { @@ -166,65 +182,72 @@ api.getMember = function(req, res, next) { } // CREATE -api.create = function(req, res, next){ - var user = res.locals.user; +api.create = async function(req, res, next){ + try { + var user = res.locals.user; - async.auto({ - get_group: function(cb){ - var q = {_id:req.body.group}; - if (req.body.group!='habitrpg') q.members = {$in:[user._id]}; // make sure they're a member of the group - Group.findOne(q, cb); - }, - save_chal: ['get_group', function(cb, results){ - var group = results.get_group, - prize = +req.body.prize; - if (!group) - return cb({code:404, err:"Group." + req.body.group + " not found"}); - if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) - return cb({code:401, err: "Only the group leader can create challenges"}); - // If they're adding a prize, do some validation - if (prize < 0) - return cb({code:401, err: 'Challenge prize must be >= 0'}); - if (req.body.group=='habitrpg' && prize < 1) - return cb({code:401, err: 'Prize must be at least 1 Gem for public challenges.'}); - if (prize > 0) { - var groupBalance = ((group.balance && group.leader==user._id) ? group.balance : 0); - var prizeCost = prize/4; // I really should have stored user.balance as gems rather than dollars... stupid... - if (prizeCost > user.balance + groupBalance) - return cb("You can't afford this prize. Purchase more gems or lower the prize amount.") + let groupId = req.body.group; + let prize = req.body.prize; - if (groupBalance >= prizeCost) { - // Group pays for all of prize - group.balance -= prizeCost; - } else if (groupBalance > 0) { - // User pays remainder of prize cost after group - var remainder = prizeCost - group.balance; - group.balance = 0; - user.balance -= remainder; - } else { - // User pays for all of prize - user.balance -= prizeCost; - } + let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true}); + if (!group) return res.status(404).json({err:"Group." + req.body.group + " not found"}); + if (!group.isMember(user)) return res.status(404).json({err:"Group." + req.body.group + " not found"}); + + if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { + return res.status(401).json({err:"Only the group leader can create challenges"}); + } + + if (groupId === 'habitrpg' && prize < 1) { + return res.status(401).json({err: 'Prize must be at least 1 Gem for public challenges.'}) + } + + if (prize > 0) { + let groupBalance = group.balance && group.leader === user._id ? group.balance : 0; + let prizeCost = prize / 4; + + if (prizeCost > user.balance + groupBalance) { + return res.status(401).json({err: 'You can\'t afford this prize. Purchase more gems or lower the prize amount.'}); } - req.body.leader = user._id; - req.body.official = user.contributor.admin && req.body.official; - var chal = new Challenge(req.body); // FIXME sanitize - chal.members.push(user._id); - chal.save(cb); - }], - save_group: ['save_chal', function(cb, results){ - results.get_group.challenges.push(results.save_chal[0]._id); - results.get_group.save(cb); - }], - sync_user: ['save_group', function(cb, results){ - // Auto-join creator to challenge (see members.push above) - results.save_chal[0].syncToUser(user, cb); - }] - }, function(err, results){ - if (err) return err.code? res.status(err.code).json(err) : next(err); - return res.json(results.save_chal[0]); - user = null; - }) + + if (groupBalance >= prizeCost) { + // Group pays for all of prize + group.balance -= prizeCost; + } else if (groupBalance > 0) { + // User pays remainder of prize cost after group + let remainder = prizeCost - group.balance; + group.balance = 0; + user.balance -= remainder; + } else { + // User pays for all of prize + user.balance -= prizeCost; + } + } + + group.challengeCount += 1; + + req.body.leader = user._id; + req.body.official = user.contributor.admin && req.body.official; + let challenge = new Challenge(Challenge.sanitize(req.body)); + + // First validate challenge so we don't save group if it's invalid (only runs sync validators) + let challengeValidationErrors = challenge.validateSync(); + if (challengeValidationErrors) throw challengeValidationErrors; + + let results = await Q.all([challenge.save({ + validateBeforeSave: false, // already validate + }), group.save()]); + let savedChal = results[0]; + + await savedChal.syncToUser(user); // (it also saves the user) + + savedChal.getTransformedData({ + cb (err, transformedChal) { + res.status(201).json(transformedChal); + }, + }); + } catch (err) { + next(err); + } } // UPDATE diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index e3c2f3ee97..f19b6da9a7 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -247,5 +247,105 @@ schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) { } }; +// Methods to adapt the new schema to API v2 responses (mostly tasks inside the challenge model) +// These will be removed once API v2 is discontinued + +// Get all the tasks belonging to a challenge, +schema.methods.getTasks = function getChallengeTasks () { + let args = Array.from(arguments); + let cb; + let type; + + if (args.length === 1) { + cb = args[0]; + } else if (args.length > 1) { + type = args[0]; + cb = args[1]; + } else { + cb = function noop () {}; + } + + let query = { + userId: { + $exists: false, + }, + + 'challenge.id': this._id, + }; + + if (type) query.type = type; + + return Tasks.Task.find(query, cb); // so we can use it as a promise +}; + +// Given challenge and an array of tasks and one of members return an API compatible challenge + tasks obj + members +schema.methods.addToChallenge = function addToChallenge (tasks, members) { + let obj = this.toJSON(); + obj.members = members; + + let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it + + obj.habits = []; + obj.dailys = []; + obj.todos = []; + obj.rewards = []; + + obj.tasksOrder = undefined; + let unordered = []; + + tasks.forEach((task) => { + // We want to push the task at the same position where it's stored in tasksOrder + let pos = tasksOrder[`${task.type}s`].indexOf(task._id); + if (pos === -1) { // Should never happen, it means the lists got out of sync + unordered.push(task.toJSONV2()); + } else { + obj[`${task.type}s`][pos] = task.toJSONV2(); + } + }); + + // Reconcile unordered items + unordered.forEach((task) => { + obj[`${task.type}s`].push(task); + }); + + // Remove null values that can be created when inserting tasks at an index > length + ['habits', 'dailys', 'rewards', 'todos'].forEach((type) => { + obj[type] = _.compact(obj[type]); + }); + + return obj; +}; + +// Return the data maintaining backward compatibility +schema.methods.getTransformedData = function getTransformedData (options) { + let self = this; + + let cb = options.cb; + let populateMembers = options.populateMembers; + + let queryMembers = { + challenges: self._id, + }; + + let selectDataMembers = '_id'; + + if (populateMembers) { + selectDataMembers += ` ${populateMembers}`; + } + + let membersQuery = User.find(queryMembers).select(selectDataMembers); + if (options.limitPopulation) membersQuery.limit(15); + + Q.all([ + membersQuery.exec(), + self.getTasks(), + ]) + .then((results) => { + cb(null, self.addToChallenge(results[1], results[0])); + }) + .catch(cb); +}; + +// END of API v2 methods export let model = mongoose.model('Challenge', schema); From 2d3fbe9f1314e91ac69c2855886317ae3c6f1d0b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 7 Apr 2016 11:54:36 +0200 Subject: [PATCH 619/976] v3 adapt v2: lint client only shared ops and enable members integration tests --- common/script/libs/preenTodos.js | 12 ++--- common/script/ops/addTag.js | 11 ++-- common/script/ops/deleteTag.js | 36 +++++++------ common/script/ops/deleteTask.js | 24 +++++---- common/script/ops/getTag.js | 21 ++++---- common/script/ops/getTags.js | 4 +- common/script/ops/sortTag.js | 19 ++++--- common/script/ops/sortTask.js | 53 +++++++++++-------- common/script/ops/update.js | 12 +++-- common/script/ops/updateTag.js | 24 ++++----- tasks/gulp-eslint.js | 9 ---- .../v2/members/POST-members_id_gift.test.js | 2 +- .../members/POST-members_id_message.test.js | 2 +- 13 files changed, 121 insertions(+), 108 deletions(-) diff --git a/common/script/libs/preenTodos.js b/common/script/libs/preenTodos.js index f07a49c9d0..121bdae008 100644 --- a/common/script/libs/preenTodos.js +++ b/common/script/libs/preenTodos.js @@ -1,14 +1,12 @@ import moment from 'moment'; import _ from 'lodash'; -/* - Preen 3-day past-completed To-Dos from Angular & mobile app - */ +// TODO used only in v2 client -module.exports = function(tasks) { - return _.filter(tasks, function(t) { - return !t.completed || (t.challenge && t.challenge.id) || moment(t.dateCompleted).isAfter(moment().subtract({ - days: 3 +module.exports = function preenTodos (tasks) { + return _.filter(tasks, (t) => { + return !t.completed || t.challenge && t.challenge.id || moment(t.dateCompleted).isAfter(moment().subtract({ + days: 3, })); }); }; diff --git a/common/script/ops/addTag.js b/common/script/ops/addTag.js index 2b97e67f38..b44ead8d2e 100644 --- a/common/script/ops/addTag.js +++ b/common/script/ops/addTag.js @@ -1,14 +1,17 @@ import uuid from '../libs/uuid'; +import _ from 'lodash'; // TODO used only in client, move there? -module.exports = function(user, req, cb) { - if (user.tags == null) { +module.exports = function addTag (user, req = {}) { + if (!user.tags) { user.tags = []; } + user.tags.push({ name: req.body.name, - id: req.body.id || uuid() + id: _.get(req, 'body.id') || uuid(), }); - return typeof cb === "function" ? cb(null, user.tags) : void 0; + + return user.tags; }; diff --git a/common/script/ops/deleteTag.js b/common/script/ops/deleteTag.js index 275aa3e5bd..c40fe79ba5 100644 --- a/common/script/ops/deleteTag.js +++ b/common/script/ops/deleteTag.js @@ -1,28 +1,32 @@ import i18n from '../i18n'; import _ from 'lodash'; +import { NotFound } from '../libs/errors'; // TODO used only in client, move there? -module.exports = function(user, req, cb) { - var i, tag, tid; - tid = req.params.id; - i = _.findIndex(user.tags, { - id: tid +module.exports = function deleteTag (user, req = {}) { + let tid = _.get(req, 'params.id'); + + let index = _.findIndex(user.tags, { + id: tid, }); - if (!~i) { - return typeof cb === "function" ? cb({ - code: 404, - message: i18n.t('messageTagNotFound', req.language) - }) : void 0; + + if (index === -1) { + throw new NotFound(i18n.t('messageTagNotFound', req.language)); } - tag = user.tags[i]; + + let tag = user.tags[index]; delete user.filters[tag.id]; - user.tags.splice(i, 1); - _.each(user.tasks, function(task) { + + user.tags.splice(index, 1); + + _.each(user.tasks, (task) => { return delete task.tags[tag.id]; }); - _.each(['habits', 'dailys', 'todos', 'rewards'], function(type) { - return typeof user.markModified === "function" ? user.markModified(type) : void 0; + + _.each(['habits', 'dailys', 'todos', 'rewards'], (type) => { + user.markModified(type); }); - return typeof cb === "function" ? cb(null, user.tags) : void 0; + + return user.tags; }; diff --git a/common/script/ops/deleteTask.js b/common/script/ops/deleteTask.js index a830e19257..b818cf7ed4 100644 --- a/common/script/ops/deleteTask.js +++ b/common/script/ops/deleteTask.js @@ -1,19 +1,21 @@ import i18n from '../i18n'; +import { NotFound } from '../libs/errors'; +import _ from 'lodash'; // TODO used only in client, move there? -module.exports = function(user, req, cb) { - var i, ref, task; - task = user.tasks[(ref = req.params) != null ? ref.id : void 0]; +module.exports = function deleteTask (user, req = {}) { + let tid = _.get(req, 'params.id'); + let task = user.tasks[tid]; + if (!task) { - return typeof cb === "function" ? cb({ - code: 404, - message: i18n.t('messageTaskNotFound', req.language) - }) : void 0; + throw new NotFound(i18n.t('messageTaskNotFound', req.language)); } - i = user[task.type + "s"].indexOf(task); - if (~i) { - user[task.type + "s"].splice(i, 1); + + let index = user[`${task.type}s`].indexOf(task); + if (index !== -1) { + user[`${task.type}s`].splice(index, 1); } - return typeof cb === "function" ? cb(null, {}) : void 0; + + return {}; }; diff --git a/common/script/ops/getTag.js b/common/script/ops/getTag.js index b54133c2c7..4a7db63128 100644 --- a/common/script/ops/getTag.js +++ b/common/script/ops/getTag.js @@ -1,19 +1,18 @@ import _ from 'lodash'; import i18n from '../i18n'; +import { NotFound } from '../libs/errors'; // TODO used only in client, move there? -module.exports = function(user, req, cb) { - var i, tid; - tid = req.params.id; - i = _.findIndex(user.tags, { - id: tid +module.exports = function getTag (user, req = {}) { + let tid = _.get(req, 'params.id'); + + let index = _.findIndex(user.tags, { + id: tid, }); - if (!~i) { - return typeof cb === "function" ? cb({ - code: 404, - message: i18n.t('messageTagNotFound', req.language) - }) : void 0; + if (index === -1) { + throw new NotFound(i18n.t('messageTagNotFound', req.language)); } - return typeof cb === "function" ? cb(null, user.tags[i]) : void 0; + + return user.tags[index]; }; diff --git a/common/script/ops/getTags.js b/common/script/ops/getTags.js index b379f13578..a96589a831 100644 --- a/common/script/ops/getTags.js +++ b/common/script/ops/getTags.js @@ -1,5 +1,5 @@ // TODO used only in client, move there? -module.exports = function(user, req, cb) { - return typeof cb === "function" ? cb(null, user.tags) : void 0; +module.exports = function getTags (user) { + return user.tags; }; diff --git a/common/script/ops/sortTag.js b/common/script/ops/sortTag.js index d47083ad3f..29756a8b82 100644 --- a/common/script/ops/sortTag.js +++ b/common/script/ops/sortTag.js @@ -1,11 +1,16 @@ +import { BadRequest } from '../libs/errors'; +import _ from 'lodash'; + // TODO used only in client, move there? -module.exports = function(user, req, cb) { - var from, ref, to; - ref = req.query, to = ref.to, from = ref.from; - if (!((to != null) && (from != null))) { - return typeof cb === "function" ? cb('?to=__&from=__ are required') : void 0; +module.exports = function sortTag (user, req = {}) { + let to = _.get(req, 'query.to'); + let fromParam = _.get(req, 'query.from'); + + if (!to || !fromParam) { + throw new BadRequest('?to=__&from=__ are required'); } - user.tags.splice(to, 0, user.tags.splice(from, 1)[0]); - return typeof cb === "function" ? cb(null, user.tags) : void 0; + + user.tags.splice(to, 0, user.tags.splice(fromParam, 1)[0]); + return user.tags; }; diff --git a/common/script/ops/sortTask.js b/common/script/ops/sortTask.js index 212eaf4de2..ca79b03e24 100644 --- a/common/script/ops/sortTask.js +++ b/common/script/ops/sortTask.js @@ -1,41 +1,50 @@ import i18n from '../i18n'; import preenTodos from '../libs/preenTodos'; +import { + NotFound, + BadRequest, +} from '../libs/errors'; +import _ from 'lodash'; // TODO used only in client, move there? -module.exports = function(user, req, cb) { - var from, id, movedTask, preenedTasks, ref, task, tasks, to; - id = req.params.id; - ref = req.query, to = ref.to, from = ref.from; - task = user.tasks[id]; +module.exports = function sortTag (user, req = {}) { + let id = _.get(req, 'params.id'); + let to = _.get(req, 'query.to'); + let fromParam = _.get(req, 'query.from'); + + let task = user.tasks[id]; + if (!task) { - return typeof cb === "function" ? cb({ - code: 404, - message: i18n.t('messageTaskNotFound', req.language) - }) : void 0; + throw new NotFound(i18n.t('messageTaskNotFound', req.language)); } - if (!((to != null) && (from != null))) { - return typeof cb === "function" ? cb('?to=__&from=__ are required') : void 0; + if (!to && !fromParam) { + throw new BadRequest('?to=__&from=__ are required'); } - tasks = user[task.type + "s"]; - if (task.type === 'todo' && tasks[from] !== task) { - preenedTasks = preenTodos(tasks); + + let tasks = user[`${task.type}s`]; + + if (task.type === 'todo' && tasks[fromParam] !== task) { + let preenedTasks = preenTodos(tasks); + if (to !== -1) { to = tasks.indexOf(preenedTasks[to]); } - from = tasks.indexOf(preenedTasks[from]); + + fromParam = tasks.indexOf(preenedTasks[fromParam]); } - if (tasks[from] !== task) { - return typeof cb === "function" ? cb({ - code: 404, - message: i18n.t('messageTaskNotFound', req.language) - }) : void 0; + + if (tasks[fromParam] !== task) { + throw new NotFound(i18n.t('messageTaskNotFound', req.language)); } - movedTask = tasks.splice(from, 1)[0]; + + let movedTask = tasks.splice(fromParam, 1)[0]; + if (to === -1) { tasks.push(movedTask); } else { tasks.splice(to, 0, movedTask); } - return typeof cb === "function" ? cb(null, tasks) : void 0; + + return tasks; }; diff --git a/common/script/ops/update.js b/common/script/ops/update.js index 12a100e372..c41e74180e 100644 --- a/common/script/ops/update.js +++ b/common/script/ops/update.js @@ -1,9 +1,11 @@ import _ from 'lodash'; -module.exports = function(user, req, cb) { - _.each(req.body, function(v, k) { - user.fns.dotSet(k, v); - return true; +// TODO used only in client, move there? + +module.exports = function updateUser (user, req = {}) { + _.each(req.body, (val, key) => { + _.set(user, key, val); }); - return typeof cb === "function" ? cb(null, user) : void 0; + + return user; }; diff --git a/common/script/ops/updateTag.js b/common/script/ops/updateTag.js index 5e61ff8c6b..ade87f916d 100644 --- a/common/script/ops/updateTag.js +++ b/common/script/ops/updateTag.js @@ -1,20 +1,20 @@ import i18n from '../i18n'; import _ from 'lodash'; +import { NotFound } from '../libs/errors'; // TODO used only in client, move there? -module.exports = function(user, req, cb) { - var i, tid; - tid = req.params.id; - i = _.findIndex(user.tags, { - id: tid +module.exports = function updateTag (user, req = {}) { + let tid = _.get(req, 'params.id'); + + let index = _.findIndex(user.tags, { + id: tid, }); - if (!~i) { - return typeof cb === "function" ? cb({ - code: 404, - message: i18n.t('messageTagNotFound', req.language) - }) : void 0; + + if (index === -1) { + throw new NotFound(i18n.t('messageTagNotFound', req.language)); } - user.tags[i].name = req.body.name; - return typeof cb === "function" ? cb(null, user.tags[i]) : void 0; + + user.tags[index].name = _.get(req, 'body.name'); + return user.tags[index]; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index fc542d4faf..998a8c040e 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -11,16 +11,11 @@ const COMMON_FILES = [ // @TODO remove these negations as the files are converted over. '!./common/script/content/index.js', '!./common/script/ops/addPushDevice.js', - '!./common/script/ops/addTag.js', '!./common/script/ops/addWebhook.js', '!./common/script/ops/blockUser.js', '!./common/script/ops/clearPMs.js', '!./common/script/ops/deletePM.js', - '!./common/script/ops/deleteTag.js', - '!./common/script/ops/deleteTask.js', '!./common/script/ops/deleteWebhook.js', - '!./common/script/ops/getTag.js', - '!./common/script/ops/getTags.js', '!./common/script/ops/rebirth.js', '!./common/script/ops/releaseBoth.js', '!./common/script/ops/releaseMounts.js', @@ -29,11 +24,8 @@ const COMMON_FILES = [ '!./common/script/ops/reset.js', '!./common/script/ops/revive.js', '!./common/script/ops/sell.js', - '!./common/script/ops/sortTag.js', - '!./common/script/ops/sortTask.js', '!./common/script/ops/unlock.js', '!./common/script/ops/update.js', - '!./common/script/ops/updateTag.js', '!./common/script/ops/updateWebhook.js', '!./common/script/fns/crit.js', '!./common/script/fns/cron.js', @@ -55,7 +47,6 @@ const COMMON_FILES = [ '!./common/script/libs/percent.js', '!./common/script/libs/planGemLimits.js', '!./common/script/libs/preenHistory.js', - '!./common/script/libs/preenTodos.js', '!./common/script/libs/removeWhitespace.js', '!./common/script/libs/silver.js', '!./common/script/libs/splitWhitespace.js', diff --git a/test/api/v2/members/POST-members_id_gift.test.js b/test/api/v2/members/POST-members_id_gift.test.js index c67a7ef79e..d36306a79d 100644 --- a/test/api/v2/members/POST-members_id_gift.test.js +++ b/test/api/v2/members/POST-members_id_gift.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../helpers/api-integration/v2'; -xdescribe('POST /members/id/gift', () => { +describe('POST /members/id/gift', () => { let userWithBalance, userWithoutBalance; beforeEach(async () => { diff --git a/test/api/v2/members/POST-members_id_message.test.js b/test/api/v2/members/POST-members_id_message.test.js index 12d77cd1f4..ffbc02266a 100644 --- a/test/api/v2/members/POST-members_id_message.test.js +++ b/test/api/v2/members/POST-members_id_message.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../helpers/api-integration/v2'; -xdescribe('POST /members/id/message', () => { +describe('POST /members/id/message', () => { let sender, recipient; beforeEach(async () => { From 5a259e7a25f089e317322f0f24ed9aa014c3881c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 7 Apr 2016 16:11:17 +0200 Subject: [PATCH 620/976] v3 adapt v2: port quests --- website/src/controllers/api-v2/groups.js | 295 +++++++++++++---------- website/src/controllers/api-v3/quests.js | 2 +- 2 files changed, 174 insertions(+), 123 deletions(-) diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index eaeef5d881..e69d42f406 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -860,6 +860,11 @@ api.removeMember = function(req, res, next){ // ------------------------------------ // Quests // ------------------------------------ +function canStartQuestAutomatically (group) { + // If all members are either true (accepted) or false (rejected) return true + // If any member is null/undefined (undecided) return false + return _.every(group.quest.members, _.isBoolean); +} function questStart(req, res, next) { var group = res.locals.group; @@ -975,70 +980,99 @@ api.questAccept = function(req, res, next) { if (quest.lvl && user.stats.lvl < quest.lvl) return res.status(400).json({err: "You must be level "+quest.lvl+" to begin this quest."}); if (group.quest.key) return res.status(400).json({err: 'Your party is already on a quest. Try again when the current quest has ended.'}); if (!user.items.quests[key]) return res.status(400).json({err: "You don't own that quest scroll"}); - group.quest.key = key; - group.quest.members = {}; - // Invite everyone. true means "accepted", false="rejected", undefined="pending". Once we click "start quest" - // or everyone has either accepted/rejected, then we store quest key in user object. - _.each(group.members, function(m){ - if (m == user._id) { - var analyticsData = { - category: 'behavior', - owner: true, - response: 'accept', - gaLabel: 'accept', - questName: key, - uuid: user._id, - }; - analytics.track('quest',analyticsData); - group.quest.members[m] = true; - group.quest.leader = user._id; - } else { - User.update({_id:m},{$set: {'party.quest.RSVPNeeded': true, 'party.quest.key': group.quest.key}}).exec(); - group.quest.members[m] = undefined; - } - }); User.find({ - _id: { - $in: _.without(group.members, user._id) + 'party._id': group._id, + _id: {$ne: user._id}, + }).select('auth.facebook auth.local preferences.emailNotifications profile.name pushDevices') + .exec().then(members => { + group.markModified('quest'); + group.quest.key = questKey; + group.quest.leader = user._id; + group.quest.members = {}; + group.quest.members[user._id] = true; + + user.party.quest.RSVPNeeded = false; + user.party.quest.key = questKey; + + return User.update({ + 'party._id': group._id, + _id: {$ne: user._id}, + }, { + $set: { + 'party.quest.RSVPNeeded': true, + 'party.quest.key': questKey, + }, + }, {multi: true}).exec(); + }).then(() => { + _.each(members, (member) => { + group.quest.members[member._id] = null; + }); + + if (canStartQuestAutomatically(group)) { + group.startQuest(user).then(() => { + return Q.all([group.save(), user.save()]) + }) + .then(results => { + results[0].getTransformedData({ + cb (err, groupTransformed) { + if (err) return next(err); + res.json(groupTransformed); + }, + populateMembers: group.type === 'party' ? partyFields : nameFields, + }); + }) + .catch(next); + + } else { + Q.all([group.save(), user.save()]) + .then(results => { + results[0].getTransformedData({ + cb (err, groupTransformed) { + if (err) return next(err); + res.json(groupTransformed); + }, + populateMembers: group.type === 'party' ? partyFields : nameFields, + }); + }) + .catch(next); } - }, {auth: 1, preferences: 1, profile: 1, pushDevices: 1}, function(err, members){ - if(err) return next(err); - - var inviterVars = utils.getUserInfo(user, ['name', 'email']); - - var membersToEmail = members.filter(function(member){ - return member.preferences.emailNotifications.invitedQuest !== false; - }); - - utils.txnEmail(membersToEmail, ('invite-' + (quest.boss ? 'boss' : 'collection') + '-quest'), [ - {name: 'QUEST_NAME', content: quest.text()}, - {name: 'INVITER', content: inviterVars.name}, - {name: 'PARTY_URL', content: '/#/options/groups/party'} - ]); - - _.each(members, function(groupMember){ - pushNotify.sendNotify(groupMember, shared.i18n.t('questInvitationTitle'), shared.i18n.t('questInvitationInfo', { quest: quest.text() })); - }); - - questStart(req,res,next); - }); + }).catch(next); // Party member accepting the invitation } else { - if (!group.quest.key) return res.status(400).json({err:'No quest invitation has been sent out yet.'}); - var analyticsData = { - category: 'behavior', - owner: false, - response: 'accept', - gaLabel: 'accept', - questName: group.quest.key, - uuid: user._id, - }; - analytics.track('quest',analyticsData); + group.markModified('quest'); group.quest.members[user._id] = true; - User.update({_id:user._id}, {$set: {'party.quest.RSVPNeeded': false}}).exec(); - questStart(req,res,next); + user.party.quest.RSVPNeeded = false; + + if (canStartQuestAutomatically(group)) { + group.startQuest(user).then(() => { + return Q.all([group.save(), user.save()]) + }) + .then(results => { + results[0].getTransformedData({ + cb (err, groupTransformed) { + if (err) return next(err); + res.json(groupTransformed); + }, + populateMembers: group.type === 'party' ? partyFields : nameFields, + }); + }) + .catch(next); + + } else { + Q.all([group.save(), user.save()]) + .then(results => { + results[0].getTransformedData({ + cb (err, groupTransformed) { + if (err) return next(err); + res.json(groupTransformed); + }, + populateMembers: group.type === 'party' ? partyFields : nameFields, + }); + }) + .catch(next); + } } } @@ -1046,84 +1080,102 @@ api.questReject = function(req, res, next) { var group = res.locals.group; var user = res.locals.user; - if (!group.quest.key) return res.status(400).json({err:'No quest invitation has been sent out yet.'}); - var analyticsData = { - category: 'behavior', - owner: false, - response: 'reject', - gaLabel: 'reject', - questName: group.quest.key, - uuid: user._id, - }; - analytics.track('quest',analyticsData); group.quest.members[user._id] = false; - User.update({_id:user._id}, {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}).exec(); - questStart(req,res,next); + group.markModified('quest.members'); + + user.party.quest = Group.cleanQuestProgress(); + user.markModified('party.quest'); + + if (canStartQuestAutomatically(group)) { + group.startQuest(user).then(() => { + return Q.all([group.save(), user.save()]) + }) + .then(results => { + results[0].getTransformedData({ + cb (err, groupTransformed) { + if (err) return next(err); + res.json(groupTransformed); + }, + populateMembers: group.type === 'party' ? partyFields : nameFields, + }); + }) + .catch(next); + + } else { + Q.all([group.save(), user.save()]) + .then(results => { + results[0].getTransformedData({ + cb (err, groupTransformed) { + if (err) return next(err); + res.json(groupTransformed); + }, + populateMembers: group.type === 'party' ? partyFields : nameFields, + }); + }) + .catch(next); + } } api.questCancel = function(req, res, next){ + var group = res.locals.group; + + group.quest = Group.cleanGroupQuest(); + group.markModified('quest'); + + Q.all([ + group.save(), + User.update( + {'party._id': groupId}, + {$set: {'party.quest': Group.cleanQuestProgress()}}, + {multi: true} + ), + ]).then(results => { + results[0].getTransformedData({ + cb (err, groupTransformed) { + if (err) return next(err); + res.json(groupTransformed); + }, + populateMembers: group.type === 'party' ? partyFields : nameFields, + }); + }).catch(next); + // Cancel a quest BEFORE it has begun (i.e., in the invitation stage) // Quest scroll has not yet left quest owner's inventory so no need to return it. // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started. - var group = res.locals.group; - async.parallel([ - function(cb){ - if (! group.quest.active) { - // Do not cancel active quests because this function does - // not do the clean-up required for that. - // TODO: return an informative error when quest is active - group.quest = {key:null,progress:{},leader:null}; - group.markModified('quest'); - group.save(cb); - _.each(group.members, function(m){ - User.update({_id:m}, {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}).exec(); - }); - } - } - ], function(err){ - if (err) return next(err); - res.json(group); - group = null; - }) } api.questAbort = function(req, res, next){ - // Abort a quest AFTER it has begun (see questCancel for BEFORE) var group = res.locals.group; - async.parallel([ - function(cb){ - User.update( - {_id:{$in: _.keys(group.quest.members)}}, - { - $set: {'party.quest':Group.cleanQuestProgress()}, - $inc: {_v:1} - }, - {multi:true}, - cb); + + let memberUpdates = User.update({ + 'party._id': group._id, + }, { + $set: {'party.quest': Group.cleanQuestProgress()}, + $inc: {_v: 1}, // TODO update middleware + }, {multi: true}).exec(); + + let questLeaderUpdate = User.update({ + _id: group.quest.leader, + }, { + $inc: { + [`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader }, - // Refund party leader quest scroll - function(cb){ - if (group.quest.active) { - var update = {$inc:{}}; - update['$inc']['items.quests.' + group.quest.key] = 1; - User.update({_id:group.quest.leader}, update).exec(); - } - group.quest = {key:null,progress:{},leader:null}; - group.markModified('quest'); - group.save(cb); - }, function(cb){ - populateQuery(group.type, Group.findById(group._id)).exec(cb); - } - ], function(err, results){ - if (err) return next(err); + }).exec(); - var groupClone = clone(group); + group.quest = Group.cleanGroupQuest(); + group.markModified('quest'); - groupClone.members = results[2].members; - - res.json(groupClone); - group = null; + Q.all([group.save(), memberUpdates, questLeaderUpdate]) + .then(results => { + results[0].getTransformedData({ + cb (err, groupTransformed) { + if (err) return next(err); + res.json(groupTransformed); + }, + populateMembers: group.type === 'party' ? partyFields : nameFields, + }); }) + .catch(next); } api.questLeave = function(req, res, next) { @@ -1143,7 +1195,7 @@ api.questLeave = function(req, res, next) { return res.status(403).json({ err: 'Quest leader cannot leave quest' }); } - delete group.quest.members[user._id]; + group.quest.members[user._id] = false; group.markModified('quest.members'); user.party.quest = Group.cleanQuestProgress(); @@ -1160,7 +1212,6 @@ api.questLeave = function(req, res, next) { }); } -// TODO port to api v3? in tojson? function _purgeFlagInfoFromChat(group, user) { group.chat = _.filter(group.chat, function(message) { return !message.flagCount || message.flagCount < 2; }); _.each(group.chat, function (message) { diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 28adfff1d6..8ba5ca16d2 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -335,7 +335,7 @@ api.cancelQuest = { group.quest = Group.cleanGroupQuest(); group.markModified('quest'); - let [savedGroup] = await Promise.all([ + let [savedGroup] = await Q.all([ group.save(), User.update( {'party._id': groupId}, From fec9ce3dbe8a6633bf423ca7d416575931be3287 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 7 Apr 2016 15:02:55 -0500 Subject: [PATCH 621/976] Ported rebirth. Add unit tests. Added rebirth route. Added integration tests --- common/locales/en/api-v3.json | 4 +- common/script/index.js | 2 + common/script/ops/rebirth.js | 91 +++++---- common/script/ops/resetGear.js | 25 +++ tasks/gulp-eslint.js | 1 - .../user/POST-user_rebirth.test.js | 56 ++++++ test/common/ops/rebirth.js | 188 ++++++++++++++++++ website/src/controllers/api-v3/user.js | 29 +++ 8 files changed, 352 insertions(+), 44 deletions(-) create mode 100644 common/script/ops/resetGear.js create mode 100644 test/api/v3/integration/user/POST-user_rebirth.test.js create mode 100644 test/common/ops/rebirth.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index fbf7f140ca..cad4c68a88 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -158,5 +158,7 @@ "pathRequired": "Path string is required", "unlocked": "Items have been unlocked", "alreadyUnlocked": "Item already unlocked", - "cannotRevive": "Cannot revive if not dead" + "cannotRevive": "Cannot revive if not dead", + "rebirthComplete": "You have been reborn!", + "petNotOwned": "You do not own this pet." } diff --git a/common/script/index.js b/common/script/index.js index 23ef5f95f5..c043af6a0f 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -124,6 +124,7 @@ import releaseMounts from './ops/releaseMounts'; import sell from './ops/sell'; import unlock from './ops/unlock'; import revive from './ops/revive'; +import rebirth from './ops/rebirth'; api.ops = { scoreTask, @@ -149,6 +150,7 @@ api.ops = { sell, unlock, revive, + rebirth, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/rebirth.js b/common/script/ops/rebirth.js index 1ddb75aba1..3739f2c80b 100644 --- a/common/script/ops/rebirth.js +++ b/common/script/ops/rebirth.js @@ -1,21 +1,30 @@ -import content from '../content/index'; import i18n from '../i18n'; import _ from 'lodash'; import { capByLevel } from '../statHelpers'; import { MAX_LEVEL } from '../constants'; +import { + NotAuthorized, +} from '../libs/errors'; +import resetGear from './resetGear'; +import equip from './equip'; + +const USERSTATSLIST = ['per', 'int', 'con', 'str', 'points', 'gp', 'exp', 'mp']; + +module.exports = function rebirth (user, tasks = [], req = {}, analytics) { + let analyticsData; + let flags; + let lvl; + let stats; -module.exports = function(user, req, cb, analytics) { - var analyticsData, flags, gear, lvl, stats; if (user.balance < 2 && user.stats.lvl < MAX_LEVEL) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('notEnoughGems', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); } + analyticsData = { uuid: user._id, - category: 'behavior' + category: 'behavior', }; + if (user.stats.lvl < MAX_LEVEL) { user.balance -= 2; analyticsData.acquireMethod = 'Gems'; @@ -24,62 +33,52 @@ module.exports = function(user, req, cb, analytics) { analyticsData.gemCost = 0; analyticsData.acquireMethod = '> 100'; } - if (analytics != null) { + + if (analytics) { analytics.track('Rebirth', analyticsData); } + lvl = capByLevel(user.stats.lvl); - _.each(user.tasks, function(task) { + + _.each(tasks, function resetTasks (task) { if (task.type !== 'reward') { task.value = 0; } if (task.type === 'daily') { - return task.streak = 0; + task.streak = 0; } }); + stats = user.stats; stats.buffs = {}; stats.hp = 50; stats.lvl = 1; - stats["class"] = 'warrior'; - _.each(['per', 'int', 'con', 'str', 'points', 'gp', 'exp', 'mp'], function(value) { - return stats[value] = 0; - }); - // TODO during refactoring: move all gear code from rebirth() to its own function and then call it in reset() as well - gear = user.items.gear; - _.each(['equipped', 'costume'], function(type) { - gear[type] = {}; - gear[type].armor = 'armor_base_0'; - gear[type].weapon = 'weapon_warrior_0'; - gear[type].head = 'head_base_0'; - return gear[type].shield = 'shield_base_0'; + stats.class = 'warrior'; + + _.each(USERSTATSLIST, function resetStats (value) { + stats[value] = 0; }); + + resetGear(user); + if (user.items.currentPet) { - user.ops.equip({ + equip(user, { params: { type: 'pet', - key: user.items.currentPet - } + key: user.items.currentPet, + }, }); } + if (user.items.currentMount) { - user.ops.equip({ + equip(user, { params: { type: 'mount', - key: user.items.currentMount - } + key: user.items.currentMount, + }, }); } - _.each(gear.owned, function(v, k) { - if (gear.owned[k] && content.gear.flat[k].value) { - gear.owned[k] = false; - return true; - } - }); - gear.owned.weapon_warrior_0 = true; - if (typeof user.markModified === "function") { - user.markModified('items.gear.owned'); - } - user.preferences.costume = false; + flags = user.flags; if (!user.achievements.beastMaster) { flags.rebirthEnabled = false; @@ -88,13 +87,21 @@ module.exports = function(user, req, cb, analytics) { flags.dropsEnabled = false; flags.classSelected = false; flags.levelDrops = {}; + if (!user.achievements.rebirths) { user.achievements.rebirths = 1; user.achievements.rebirthLevel = lvl; - } else if (lvl > user.achievements.rebirthLevel || lvl === 100) { + } else if (lvl > user.achievements.rebirthLevel || lvl === MAX_LEVEL) { user.achievements.rebirths++; user.achievements.rebirthLevel = lvl; } + user.stats.buffs = {}; - return typeof cb === "function" ? cb(null, user) : void 0; + + let response = { + data: user, + message: i18n.t('rebirthComplete'), + }; + + return response; }; diff --git a/common/script/ops/resetGear.js b/common/script/ops/resetGear.js new file mode 100644 index 0000000000..2625f5c9b8 --- /dev/null +++ b/common/script/ops/resetGear.js @@ -0,0 +1,25 @@ +import _ from 'lodash'; +import content from '../content/index'; + +module.exports = function resetGear (user) { + let gear = user.items.gear; + + _.each(['equipped', 'costume'], function resetUserGear (type) { + gear[type] = {}; + gear[type].armor = 'armor_base_0'; + gear[type].weapon = 'weapon_warrior_0'; + gear[type].head = 'head_base_0'; + gear[type].shield = 'shield_base_0'; + }); + + // Gear.owned is a Mongo object so the _.each function iterates over hidden properties. + // The content.gear.flat[k] check should prevent this causing an error + _.each(gear.owned, function resetOwnedGear (v, k) { + if (gear.owned[k] && content.gear.flat[k] && content.gear.flat[k].value) { + gear.owned[k] = false; + } + }); + + gear.owned.weapon_warrior_0 = true; // eslint-disable-line camelcase + user.preferences.costume = false; +}; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 2ea777a0d5..ed79dfc879 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -23,7 +23,6 @@ const COMMON_FILES = [ '!./common/script/ops/deleteWebhook.js', '!./common/script/ops/getTag.js', '!./common/script/ops/getTags.js', - '!./common/script/ops/rebirth.js', '!./common/script/ops/releaseBoth.js', '!./common/script/ops/releaseMounts.js', '!./common/script/ops/releasePets.js', diff --git a/test/api/v3/integration/user/POST-user_rebirth.test.js b/test/api/v3/integration/user/POST-user_rebirth.test.js new file mode 100644 index 0000000000..3591844c3c --- /dev/null +++ b/test/api/v3/integration/user/POST-user_rebirth.test.js @@ -0,0 +1,56 @@ +import { + generateUser, + generateDaily, + generateReward, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/rebirth', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error when user balance is too low', async () => { + await expect(user.post('/user/rebirth')) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughGems'), + }); + }); + + // More tests in common code unit tests + + it('resets user\'s tasks', async () => { + await user.update({ + balance: 2, + }); + + let daily = await generateDaily({ + text: 'test habit', + type: 'daily', + streak: 1, + userId: user._id, + }); + + let reward = await generateReward({ + text: 'test reward', + type: 'reward', + value: 1, + userId: user._id, + }); + + let response = await user.post('/user/rebirth'); + await user.sync(); + + let updatedDaily = await user.get(`/tasks/${daily._id}`); + let updatedReward = await user.get(`/tasks/${reward._id}`); + + expect(response.message).to.equal(t('rebirthComplete')); + expect(updatedDaily.streak).to.equal(0); + expect(updatedDaily.value).to.equal(0); + expect(updatedReward.value).to.equal(1); + }); +}); diff --git a/test/common/ops/rebirth.js b/test/common/ops/rebirth.js new file mode 100644 index 0000000000..61d1334569 --- /dev/null +++ b/test/common/ops/rebirth.js @@ -0,0 +1,188 @@ +import rebirth from '../../../common/script/ops/rebirth'; +import i18n from '../../../common/script/i18n'; +import { MAX_LEVEL } from '../../../common/script/constants'; +import { + generateUser, + generateDaily, + generateReward, +} from '../../helpers/common.helper'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; + +describe('shared.ops.rebirth', () => { + let user; + let animal = 'Wolf-Base'; + let userStats = ['per', 'int', 'con', 'str', 'points', 'gp', 'exp', 'mp']; + let tasks = []; + + beforeEach(() => { + user = generateUser(); + user.balance = 2; + tasks = [generateDaily(), generateReward()]; + }); + + it('returns an error when user balance is too low and user is less than max level', (done) => { + user.balance = 0; + + try { + rebirth(user); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); + } + }); + + it('rebirths a user with enough gems', () => { + let response = rebirth(user); + + expect(response.message).to.equal(i18n.t('rebirthComplete')); + }); + + it('rebirths a user with not enough gems but max level', () => { + user.balance = 0; + user.stats.lvl = MAX_LEVEL; + + let response = rebirth(user); + + expect(response.message).to.equal(i18n.t('rebirthComplete')); + }); + + it('resets user\'s taks values except for rewards to 0', () => { + tasks[0].value = 1; + tasks[1].value = 1; + + rebirth(user, tasks); + + expect(tasks[0].value).to.equal(0); + expect(tasks[1].value).to.equal(1); + }); + + it('resets user\'s daily streaks to 0', () => { + tasks[0].streak = 1; + + rebirth(user, tasks); + + expect(tasks[0].streak).to.equal(0); + }); + + it('resets a user\'s buffs', () => { + user.stats.buffs = {test: 'test'}; + + rebirth(user); + + expect(user.stats.buffs).to.be.empty; + }); + + it('resets a user\'s health points', () => { + user.stats.hp = 40; + + rebirth(user); + + expect(user.stats.hp).to.equal(50); + }); + + it('resets a user\'s class', () => { + user.stats.class = 'rouge'; + + rebirth(user); + + expect(user.stats.class).to.equal('warrior'); + }); + + it('resets a user\'s stats', () => { + user.stats.class = 'rouge'; + _.each(userStats, function setUsersStats (value) { + user.stats[value] = 10; + }); + + rebirth(user); + + _.each(userStats, function resetUserStats (value) { + user.stats[value] = 0; + }); + }); + + it('resets a user\'s gear', () => { + let gearReset = { + armor: 'armor_base_0', + weapon: 'weapon_warrior_0', + head: 'head_base_0', + shield: 'shield_base_0', + }; + + rebirth(user); + + expect(user.items.gear.equipped).to.deep.equal(gearReset); + expect(user.items.gear.costume).to.deep.equal(gearReset); + expect(user.preferences.costume).to.be.false; + }); + + it('resets a user\'s gear owned', () => { + user.items.gear.owned.weapon_warrior_1 = true; // eslint-disable-line camelcase + rebirth(user); + + expect(user.items.gear.owned.weapon_warrior_1).to.be.false; + expect(user.items.gear.owned.weapon_warrior_0).to.be.true; + }); + + it('resets a user\'s current pet', () => { + user.items.pets[animal] = true; + user.items.currentPet = animal; + rebirth(user); + + expect(user.items.currentPet).to.be.empty; + }); + + it('resets a user\'s current mount', () => { + user.items.mounts[animal] = true; + user.items.currentMount = animal; + rebirth(user); + + expect(user.items.currentMount).to.be.empty; + }); + + it('resets a user\'s flags', () => { + user.flags.itemsEnabled = true; + user.flags.dropsEnabled = true; + user.flags.classSelected = true; + user.flags.rebirthEnabled = true; + user.flags.levelDrops = {test: 'test'}; + + rebirth(user); + + expect(user.flags.itemsEnabled).to.be.false; + expect(user.flags.dropsEnabled).to.be.false; + expect(user.flags.classSelected).to.be.false; + expect(user.flags.rebirthEnabled).to.be.false; + expect(user.flags.levelDrops).to.be.emtpy; + }); + + it('does not reset rebirthEnabled if user has beastMaster', () => { + user.achievements.beastMaster = 1; + user.flags.rebirthEnabled = true; + + rebirth(user); + + expect(user.flags.rebirthEnabled).to.be.true; + }); + + it('sets rebirth achievement', () => { + rebirth(user); + + expect(user.achievements.rebirths).to.equal(1); + expect(user.achievements.rebirthLevel).to.equal(user.stats.lvl); + }); + + it('increments rebirth achievemnts', () => { + user.stats.lvl = 2; + user.achievements.rebirths = 1; + user.achievements.rebirthLevel = 1; + + rebirth(user); + + expect(user.achievements.rebirths).to.equal(2); + expect(user.achievements.rebirthLevel).to.equal(2); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 3cc462d444..827e73c34e 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -888,4 +888,33 @@ api.userRevive = { }, }; +/* +* @api {post} /user/rebirth Resets a user. +* @apiVersion 3.0.0 +* @apiName UserRebirth +* @apiGroup User +* +* @apiSuccess {Object} data `user` +*/ +api.userRebirth = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/rebirth', + async handler (req, res) { + let user = res.locals.user; + let query = { + userId: user._id, + type: {$in: ['daily', 'habit', 'todo']}, + }; + let tasks = await Tasks.Task.find(query).exec(); + let rebirthResponse = common.ops.rebirth(user, tasks, req, res.analytics); + + await user.save(); + + await Q.all(tasks.map(task => task.save())); + + res.respond(200, rebirthResponse); + }, +}; + module.exports = api; From 276cbc58bb151c55ae1496dc9313ba097590b3ab Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 8 Apr 2016 11:56:51 +0200 Subject: [PATCH 622/976] v3: fix cron middleware, remove unused code from shared --- common/script/fns/cron.js | 358 ------------------------ common/script/fns/dotGet.js | 8 +- common/script/fns/dotSet.js | 18 +- common/script/fns/getItem.js | 11 - common/script/fns/index.js | 6 - common/script/fns/nullify.js | 6 +- common/script/fns/preenUserHistory.js | 25 -- common/script/index.js | 21 +- common/script/libs/appliedTags.js | 2 + common/script/libs/countExists.js | 7 - common/script/libs/dotGet.js | 10 +- common/script/libs/dotSet.js | 15 +- common/script/libs/encodeiCalLink.js | 9 - common/script/libs/friendlyTimestamp.js | 9 - common/script/libs/gold.js | 2 + common/script/libs/newChatMessages.js | 10 - common/script/libs/noTags.js | 2 + common/script/libs/percent.js | 2 + common/script/libs/preenHistory.js | 43 --- common/script/libs/preenTodos.js | 1 + common/script/libs/removeWhitespace.js | 10 - common/script/libs/silver.js | 2 + common/script/libs/taskDefaults.js | 1 + tasks/gulp-eslint.js | 10 - website/src/controllers/api-v2/user.js | 2 + website/src/middlewares/api-v3/cron.js | 11 +- website/src/middlewares/api-v3/v2.js | 3 + 27 files changed, 49 insertions(+), 555 deletions(-) delete mode 100644 common/script/fns/cron.js delete mode 100644 common/script/fns/getItem.js delete mode 100644 common/script/fns/preenUserHistory.js delete mode 100644 common/script/libs/countExists.js delete mode 100644 common/script/libs/encodeiCalLink.js delete mode 100644 common/script/libs/friendlyTimestamp.js delete mode 100644 common/script/libs/newChatMessages.js delete mode 100644 common/script/libs/preenHistory.js delete mode 100644 common/script/libs/removeWhitespace.js diff --git a/common/script/fns/cron.js b/common/script/fns/cron.js deleted file mode 100644 index 1e3470d7f9..0000000000 --- a/common/script/fns/cron.js +++ /dev/null @@ -1,358 +0,0 @@ -import moment from 'moment'; -import _ from 'lodash'; -import { - daysSince, - shouldDo, -} from '../cron'; -import { - capByLevel, - toNextLevel, -} from '../statHelpers'; -/* - ------------------------------------------------------ - Cron - ------------------------------------------------------ - */ - -/* - At end of day, add value to all incomplete Daily & Todo tasks (further incentive) - For incomplete Dailys, deduct experience - Make sure to run this function once in a while as server will not take care of overnight calculations. - And you have to run it every time client connects. - {user} - */ - -module.exports = function(user, options) { - var _progress, analyticsData, base, base1, base2, base3, base4, clearBuffs, dailyChecked, dailyDueUnchecked, daysMissed, expTally, lvl, lvlDiv2, multiDaysCountAsOneDay, now, perfect, plan, progress, ref, ref1, ref2, ref3, todoTally, timezoneOffsetFromUserPrefs, timezoneOffsetFromBrowser, timezoneOffsetAtLastCron; - if (options == null) { - options = {}; - } - now = +options.now || +(new Date); - - // If the user's timezone has changed (due to travel or daylight savings), - // cron can be triggered twice in one day, so we check for that and use - // both timezones to work out if cron should run. - // CDS = Custom Day Start time. - timezoneOffsetFromUserPrefs = user.preferences.timezoneOffset || 0; - timezoneOffsetAtLastCron = (_.isFinite(user.preferences.timezoneOffsetAtLastCron)) ? user.preferences.timezoneOffsetAtLastCron : timezoneOffsetFromUserPrefs; - timezoneOffsetFromBrowser = (_.isFinite(+options.timezoneOffset)) ? +options.timezoneOffset : timezoneOffsetFromUserPrefs; - // NB: All timezone offsets can be 0, so can't use `... || ...` to apply non-zero defaults - - if (timezoneOffsetFromBrowser !== timezoneOffsetFromUserPrefs) { - // The user's browser has just told Habitica that the user's timezone has - // changed so store and use the new zone. - user.preferences.timezoneOffset = timezoneOffsetFromBrowser; - timezoneOffsetFromUserPrefs = timezoneOffsetFromBrowser; - } - - // How many days have we missed using the user's current timezone: - daysMissed = daysSince(user.lastCron, _.defaults({ - now: now - }, user.preferences)); - - if (timezoneOffsetAtLastCron != timezoneOffsetFromUserPrefs) { - // Since cron last ran, the user's timezone has changed. - // How many days have we missed using the old timezone: - let daysMissedNewZone = daysMissed; - let daysMissedOldZone = daysSince(user.lastCron, _.defaults({ - now: now, - timezoneOffsetOverride: timezoneOffsetAtLastCron, - }, user.preferences)); - - if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) { - // The timezone change was in the unsafe direction. - // E.g., timezone changes from UTC+1 (offset -60) to UTC+0 (offset 0). - // or timezone changes from UTC-4 (offset 240) to UTC-5 (offset 300). - // Local time changed from, for example, 03:00 to 02:00. - - if (daysMissedOldZone > 0 && daysMissedNewZone > 0) { - // Both old and new timezones indicate that we SHOULD run cron, so - // it is safe to do so immediately. - daysMissed = Math.min(daysMissedOldZone, daysMissedNewZone); - // use minimum value to be nice to user - } - else if (daysMissedOldZone > 0) { - // The old timezone says that cron should run; the new timezone does not. - // This should be impossible for this direction of timezone change, but - // just in case I'm wrong... - console.log("zone has changed - old zone says run cron, NEW zone says no - stop cron now only -- SHOULD NOT HAVE GOT TO HERE", timezoneOffsetAtLastCron, timezoneOffsetFromUserPrefs, now); // used in production for confirming this never happens - } - else if (daysMissedNewZone > 0) { - // The old timezone says that cron should NOT run -- i.e., cron has - // already run today, from the old timezone's point of view. - // The new timezone says that cron SHOULD run, but this is almost - // certainly incorrect. - // This happens when cron occurred at a time soon after the CDS. When - // you reinterpret that time in the new timezone, it looks like it - // was before the CDS, because local time has stepped backwards. - // To fix this, rewrite the cron time to a time that the new - // timezone interprets as being in today. - - daysMissed = 0; // prevent cron running now - let timezoneOffsetDiff = timezoneOffsetAtLastCron - timezoneOffsetFromUserPrefs; - // e.g., for dangerous zone change: 240 - 300 = -60 or -660 - -600 = -60 - - user.lastCron = moment(user.lastCron).subtract(timezoneOffsetDiff, 'minutes'); - // NB: We don't change user.auth.timestamps.loggedin so that will still record the time that the previous cron actually ran. - // From now on we can ignore the old timezone: - user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; - } - else { - // Both old and new timezones indicate that cron should - // NOT run. - daysMissed = 0; // prevent cron running now - } - } - else if (timezoneOffsetAtLastCron > timezoneOffsetFromUserPrefs) { - daysMissed = daysMissedNewZone; - // TODO: Either confirm that there is nothing that could possibly go wrong here and remove the need for this else branch, or fix stuff. There are probably situations where the Dailies do not reset early enough for a user who was expecting the zone change and wants to use all their Dailies immediately in the new zone; if so, we should provide an option for easy reset of Dailies (can't be automatic because there will be other situations where the user was not prepared). - } - } - - if (!(daysMissed > 0)) { - return; - } - user.auth.timestamps.loggedin = new Date(); - user.lastCron = now; - user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; - if (user.items.lastDrop.count > 0) { - user.items.lastDrop.count = 0; - } - perfect = true; - clearBuffs = { - str: 0, - int: 0, - per: 0, - con: 0, - stealth: 0, - streaks: false - }; - plan = (ref = user.purchased) != null ? ref.plan : void 0; - if (plan != null ? plan.customerId : void 0) { - if (typeof plan.dateUpdated === "undefined") { - // partial compensation for bug in subscription creation - https://github.com/HabitRPG/habitrpg/issues/6682 - plan.dateUpdated = new Date(); - } - if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { - plan.gemsBought = 0; - plan.dateUpdated = new Date(); - _.defaults(plan.consecutive, { - count: 0, - offset: 0, - trinkets: 0, - gemCapExtra: 0 - }); - plan.consecutive.count++; - if (plan.consecutive.offset > 0) { - plan.consecutive.offset--; - } else if (plan.consecutive.count % 3 === 0) { - plan.consecutive.trinkets++; - plan.consecutive.gemCapExtra += 5; - if (plan.consecutive.gemCapExtra > 25) { - plan.consecutive.gemCapExtra = 25; - } - } - } - if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(+(new Date))) { - _.merge(plan, { - planId: null, - customerId: null, - paymentMethod: null - }); - _.merge(plan.consecutive, { - count: 0, - offset: 0, - gemCapExtra: 0 - }); - if (typeof user.markModified === "function") { - user.markModified('purchased.plan'); - } - } - } - if (user.preferences.sleep === true) { - user.stats.buffs = clearBuffs; - user.dailys.forEach(function(daily) { - var completed, repeat, thatDay; - completed = daily.completed, repeat = daily.repeat; - thatDay = moment(now).subtract({ - days: 1 - }); - if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { - _.each(daily.checklist, (function(box) { - box.completed = false; - return true; - })); - } - return daily.completed = false; - }); - return; - } - multiDaysCountAsOneDay = true; - todoTally = 0; - user.todos.forEach(function(task) { - var absVal, completed, delta, id; - if (!task) { - return; - } - id = task.id, completed = task.completed; - delta = user.ops.score({ - params: { - id: task.id, - direction: 'down' - }, - query: { - times: multiDaysCountAsOneDay != null ? multiDaysCountAsOneDay : { - 1: daysMissed - }, - cron: true - } - }); - absVal = completed ? Math.abs(task.value) : task.value; - return todoTally += absVal; - }); - dailyChecked = 0; - dailyDueUnchecked = 0; - if ((base = user.party.quest.progress).down == null) { - base.down = 0; - } - user.dailys.forEach(function(task) { - var EvadeTask, completed, delta, fractionChecked, id, j, n, ref1, ref2, scheduleMisses, thatDay; - if (!task) { - return; - } - id = task.id, completed = task.completed; - EvadeTask = 0; - scheduleMisses = daysMissed; - if (completed) { - dailyChecked += 1; - } else { - scheduleMisses = 0; - for (n = j = 0, ref1 = daysMissed; 0 <= ref1 ? j < ref1 : j > ref1; n = 0 <= ref1 ? ++j : --j) { - thatDay = moment(now).subtract({ - days: n + 1 - }); - if (shouldDo(thatDay.toDate(), task, user.preferences)) { - scheduleMisses++; - if (user.stats.buffs.stealth) { - user.stats.buffs.stealth--; - EvadeTask++; - } - if (multiDaysCountAsOneDay) { - break; - } - } - } - if (scheduleMisses > EvadeTask) { - perfect = false; - if (((ref2 = task.checklist) != null ? ref2.length : void 0) > 0) { - fractionChecked = _.reduce(task.checklist, (function(m, i) { - return m + (i.completed ? 1 : 0); - }), 0) / task.checklist.length; - dailyDueUnchecked += 1 - fractionChecked; - dailyChecked += fractionChecked; - } else { - dailyDueUnchecked += 1; - } - delta = user.ops.score({ - params: { - id: task.id, - direction: 'down' - }, - query: { - times: multiDaysCountAsOneDay != null ? multiDaysCountAsOneDay : { - 1: scheduleMisses - EvadeTask - }, - cron: true - } - }); - user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); - } - } - (task.history != null ? task.history : task.history = []).push({ - date: +(new Date), - value: task.value - }); - task.completed = false; - if (completed || (scheduleMisses > 0)) { - return _.each(task.checklist, (function(i) { - i.completed = false; - return true; - })); - } - }); - user.habits.forEach(function(task) { - if (task.up === false || task.down === false) { - if (Math.abs(task.value) < 0.1) { - return task.value = 0; - } else { - return task.value = task.value / 2; - } - } - }); - ((base1 = (user.history != null ? user.history : user.history = {})).todos != null ? base1.todos : base1.todos = []).push({ - date: now, - value: todoTally - }); - expTally = user.stats.exp; - lvl = 0; - while (lvl < (user.stats.lvl - 1)) { - lvl++; - expTally += toNextLevel(lvl); - } - ((base2 = user.history).exp != null ? base2.exp : base2.exp = []).push({ - date: now, - value: expTally - }); - if (!((ref1 = user.purchased) != null ? (ref2 = ref1.plan) != null ? ref2.customerId : void 0 : void 0)) { - user.fns.preenUserHistory(); - if (typeof user.markModified === "function") { - user.markModified('history'); - } - if (typeof user.markModified === "function") { - user.markModified('dailys'); - } - } - user.stats.buffs = perfect ? ((base3 = user.achievements).perfect != null ? base3.perfect : base3.perfect = 0, user.achievements.perfect++, lvlDiv2 = Math.ceil(capByLevel(user.stats.lvl) / 2), { - str: lvlDiv2, - int: lvlDiv2, - per: lvlDiv2, - con: lvlDiv2, - stealth: 0, - streaks: false - }) : clearBuffs; - if (dailyDueUnchecked === 0 && dailyChecked === 0) { - dailyChecked = 1; - } - user.stats.mp += _.max([10, .1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) { - user.stats.mp = user._statsComputed.maxMP; - } - progress = user.party.quest.progress; - _progress = _.cloneDeep(progress); - _.merge(progress, { - down: 0, - up: 0 - }); - progress.collect = _.transform(progress.collect, (function(m, v, k) { - return m[k] = 0; - })); - if ((base4 = user.flags).cronCount == null) { - base4.cronCount = 0; - } - user.flags.cronCount++; - analyticsData = { - category: 'behavior', - gaLabel: 'Cron Count', - gaValue: user.flags.cronCount, - uuid: user._id, - user: user, - resting: user.preferences.sleep, - cronCount: user.flags.cronCount, - progressUp: _.min([_progress.up, 900]), - progressDown: _progress.down - }; - if ((ref3 = options.analytics) != null) { - ref3.track('Cron', analyticsData); - } - return _progress; -}; diff --git a/common/script/fns/dotGet.js b/common/script/fns/dotGet.js index c95ba55656..0988fec191 100644 --- a/common/script/fns/dotGet.js +++ b/common/script/fns/dotGet.js @@ -1,5 +1,7 @@ -import dotGet from '../libs/dotGet'; +import _ from 'lodash'; -module.exports = function(user, path) { - return dotGet(user, path); +// TODO remove completely, use _.get + +module.exports = function dotGet (user, path) { + return _.get(user, path); }; diff --git a/common/script/fns/dotSet.js b/common/script/fns/dotSet.js index 283e7a50d0..f6ade2d0e0 100644 --- a/common/script/fns/dotSet.js +++ b/common/script/fns/dotSet.js @@ -1,12 +1,14 @@ -import dotSet from '../libs/dotSet'; +import _ from 'lodash'; /* -This allows you to set object properties by dot-path. Eg, you can run pathSet('stats.hp',50,user) which is the same as -user.stats.hp = 50. This is useful because in our habitrpg-shared functions we're returning changesets as {path:value}, -so that different consumers can implement setters their own way. Derby needs model.set(path, value) for example, where -Angular sets object properties directly - in which case, this function will be used. - */ + This allows you to set object properties by dot-path. Eg, you can run pathSet('stats.hp',50,user) which is the same as + user.stats.hp = 50. This is useful because in our habitrpg-shared functions we're returning changesets as {path:value}, + so that different consumers can implement setters their own way. Derby needs model.set(path, value) for example, where + Angular sets object properties directly - in which case, this function will be used. +*/ -module.exports = function(user, path, val) { - return dotSet(user, path, val); +// TODO use directly _.set and remove this fn + +module.exports = function dotSet (user, path, val) { + return _.set(user, path, val); }; diff --git a/common/script/fns/getItem.js b/common/script/fns/getItem.js deleted file mode 100644 index b73ecf9073..0000000000 --- a/common/script/fns/getItem.js +++ /dev/null @@ -1,11 +0,0 @@ -import content from '../content/index'; -import i18n from '../i18n'; - -module.exports = function(user, type) { - var item; - item = content.gear.flat[user.items.gear.equipped[type]]; - if (!item) { - return content.gear.flat[type + "_base_0"]; - } - return item; -}; diff --git a/common/script/fns/index.js b/common/script/fns/index.js index 24f3ab604b..04fddb2d75 100644 --- a/common/script/fns/index.js +++ b/common/script/fns/index.js @@ -1,4 +1,3 @@ -import getItem from './getItem'; import handleTwoHanded from './handleTwoHanded'; import predictableRandom from './predictableRandom'; import crit from './crit'; @@ -8,13 +7,10 @@ import dotGet from './dotGet'; import randomDrop from './randomDrop'; import autoAllocate from './autoAllocate'; import updateStats from './updateStats'; -import cron from './cron'; -import preenUserHistory from './preenUserHistory'; import ultimateGear from './ultimateGear'; import nullify from './nullify'; module.exports = { - getItem, handleTwoHanded, predictableRandom, crit, @@ -24,8 +20,6 @@ module.exports = { randomDrop, autoAllocate, updateStats, - cron, - preenUserHistory, ultimateGear, nullify, }; diff --git a/common/script/fns/nullify.js b/common/script/fns/nullify.js index b6e30aa3b9..38753071fc 100644 --- a/common/script/fns/nullify.js +++ b/common/script/fns/nullify.js @@ -1,5 +1,7 @@ -module.exports = function(user) { +// TODO remove once v2 is retired + +module.exports = function nullify (user) { user.ops = null; user.fns = null; - return user = null; + user = null; }; diff --git a/common/script/fns/preenUserHistory.js b/common/script/fns/preenUserHistory.js deleted file mode 100644 index a45dd82719..0000000000 --- a/common/script/fns/preenUserHistory.js +++ /dev/null @@ -1,25 +0,0 @@ -import _ from 'lodash'; -import preenHistory from '../libs/preenHistory'; - -module.exports = function(user, minHistLen) { - if (minHistLen == null) { - minHistLen = 7; - } - _.each(user.habits.concat(user.dailys), function(task) { - var ref; - if (((ref = task.history) != null ? ref.length : void 0) > minHistLen) { - task.history = preenHistory(task.history); - } - return true; - }); - _.defaults(user.history, { - todos: [], - exp: [] - }); - if (user.history.exp.length > minHistLen) { - user.history.exp = preenHistory(user.history.exp); - } - if (user.history.todos.length > minHistLen) { - return user.history.todos = preenHistory(user.history.todos); - } -}; diff --git a/common/script/index.js b/common/script/index.js index 2efe3371cf..0d376bd4f2 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -58,21 +58,12 @@ api.updateStore = updateStore; import uuid from './libs/uuid'; api.uuid = uuid; -import countExists from './libs/countExists'; -api.countExists = countExists; - import taskDefaults from './libs/taskDefaults'; api.taskDefaults = taskDefaults; import percent from './libs/percent'; api.percent = percent; -import removeWhitespace from './libs/removeWhitespace'; -api.removeWhitespace = removeWhitespace; - -import encodeiCalLink from './libs/encodeiCalLink'; -api.encodeiCalLink = encodeiCalLink; - import gold from './libs/gold'; api.gold = gold; @@ -82,12 +73,6 @@ api.silver = silver; import taskClasses from './libs/taskClasses'; api.taskClasses = taskClasses; -import friendlyTimestamp from './libs/friendlyTimestamp'; -api.friendlyTimestamp = friendlyTimestamp; - -import newChatMessages from './libs/newChatMessages'; -api.newChatMessages = newChatMessages; - import noTags from './libs/noTags'; api.noTags = noTags; @@ -261,12 +246,11 @@ api.wrap = function wrapUser (user, main = true) { allocate: _.partial(importedOps.allocate, user), readCard: _.partial(importedOps.readCard, user), openMysteryItem: _.partial(importedOps.openMysteryItem, user), - scoreTask: _.partial(importedOps.scoreTask, user), + score: _.partial(importedOps.scoreTask, user), }; } user.fns = { - getItem: _.partial(importedFns.getItem, user), handleTwoHanded: _.partial(importedFns.handleTwoHanded, user), predictableRandom: _.partial(importedFns.predictableRandom, user), crit: _.partial(importedFns.crit, user), @@ -276,8 +260,6 @@ api.wrap = function wrapUser (user, main = true) { randomDrop: _.partial(importedFns.randomDrop, user), autoAllocate: _.partial(importedFns.autoAllocate, user), updateStats: _.partial(importedFns.updateStats, user), - cron: _.partial(importedFns.cron, user), - preenUserHistory: _.partial(importedFns.preenUserHistory, user), ultimateGear: _.partial(importedFns.ultimateGear, user), nullify: _.partial(importedFns.nullify, user), }; @@ -299,6 +281,7 @@ api.wrap = function wrapUser (user, main = true) { }); if (typeof window !== 'undefined') { + // TODO kept for compatibility with the client that relies on v2, remove once the client is adapted Object.defineProperty(user, 'tasks', { get () { let tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); diff --git a/common/script/libs/appliedTags.js b/common/script/libs/appliedTags.js index 31302fbe54..513405bfeb 100644 --- a/common/script/libs/appliedTags.js +++ b/common/script/libs/appliedTags.js @@ -4,6 +4,8 @@ import _ from 'lodash'; Are there tags applied? */ +// TODO move to client + module.exports = function(userTags, taskTags) { var arr; arr = []; diff --git a/common/script/libs/countExists.js b/common/script/libs/countExists.js deleted file mode 100644 index 964e4e286f..0000000000 --- a/common/script/libs/countExists.js +++ /dev/null @@ -1,7 +0,0 @@ -import _ from 'lodash'; - -module.exports = function(items) { - return _.reduce(items, (function(m, v) { - return m + (v ? 1 : 0); - }), 0); -}; diff --git a/common/script/libs/dotGet.js b/common/script/libs/dotGet.js index 4585d8fd53..bd7605154d 100644 --- a/common/script/libs/dotGet.js +++ b/common/script/libs/dotGet.js @@ -1,9 +1,5 @@ import _ from 'lodash'; -module.exports = function(obj, path) { - return _.reduce(path.split('.'), ((function(_this) { - return function(curr, next) { - return curr != null ? curr[next] : void 0; - }; - })(this)), obj); -}; +// TODO remove completely + +module.exports = _.get; diff --git a/common/script/libs/dotSet.js b/common/script/libs/dotSet.js index 40165078aa..29d7d559e0 100644 --- a/common/script/libs/dotSet.js +++ b/common/script/libs/dotSet.js @@ -1,14 +1,5 @@ import _ from 'lodash'; -module.exports = function(obj, path, val) { - var arr; - arr = path.split('.'); - return _.reduce(arr, (function(_this) { - return function(curr, next, index) { - if ((arr.length - 1) === index) { - curr[next] = val; - } - return curr[next] != null ? curr[next] : curr[next] = {}; - }; - })(this), obj); -}; +// TODO remove completely + +module.exports = _.set; diff --git a/common/script/libs/encodeiCalLink.js b/common/script/libs/encodeiCalLink.js deleted file mode 100644 index 4a85badd59..0000000000 --- a/common/script/libs/encodeiCalLink.js +++ /dev/null @@ -1,9 +0,0 @@ -/* -Encode the download link for .ics iCal file - */ - -module.exports = function(uid, apiToken) { - var loc, ref; - loc = (typeof window !== "undefined" && window !== null ? window.location.host : void 0) || (typeof process !== "undefined" && process !== null ? (ref = process.env) != null ? ref.BASE_URL : void 0 : void 0) || ''; - return encodeURIComponent("http://" + loc + "/v1/users/" + uid + "/calendar.ics?apiToken=" + apiToken); -}; diff --git a/common/script/libs/friendlyTimestamp.js b/common/script/libs/friendlyTimestamp.js deleted file mode 100644 index dfda6fbed7..0000000000 --- a/common/script/libs/friendlyTimestamp.js +++ /dev/null @@ -1,9 +0,0 @@ -import moment from 'moment'; - -/* -Friendly timestamp - */ - -module.exports = function(timestamp) { - return moment(timestamp).format('MM/DD h:mm:ss a'); -}; diff --git a/common/script/libs/gold.js b/common/script/libs/gold.js index 8016e2cff9..7ce971aeec 100644 --- a/common/script/libs/gold.js +++ b/common/script/libs/gold.js @@ -1,3 +1,5 @@ +// TODO move to client + module.exports = function(num) { if (num) { return Math.floor(num); diff --git a/common/script/libs/newChatMessages.js b/common/script/libs/newChatMessages.js deleted file mode 100644 index abe7680edf..0000000000 --- a/common/script/libs/newChatMessages.js +++ /dev/null @@ -1,10 +0,0 @@ -/* -Does user have new chat messages? - */ - -module.exports = function(messages, lastMessageSeen) { - if (!((messages != null ? messages.length : void 0) > 0)) { - return false; - } - return (messages != null ? messages[0] : void 0) && (messages[0].id !== lastMessageSeen); -}; diff --git a/common/script/libs/noTags.js b/common/script/libs/noTags.js index c3abb9054e..fa47cd70d0 100644 --- a/common/script/libs/noTags.js +++ b/common/script/libs/noTags.js @@ -4,6 +4,8 @@ import _ from 'lodash'; are any tags active? */ +// TODO move to client + module.exports = function(tags) { return _.isEmpty(tags) || _.isEmpty(_.filter(tags, function(t) { return t; diff --git a/common/script/libs/percent.js b/common/script/libs/percent.js index 7439b22285..e59132f582 100644 --- a/common/script/libs/percent.js +++ b/common/script/libs/percent.js @@ -1,3 +1,5 @@ +// TODO move to client + module.exports = function(x, y, dir) { var roundFn; switch (dir) { diff --git a/common/script/libs/preenHistory.js b/common/script/libs/preenHistory.js deleted file mode 100644 index 0d354c238c..0000000000 --- a/common/script/libs/preenHistory.js +++ /dev/null @@ -1,43 +0,0 @@ -import moment from 'moment'; -import _ from 'lodash'; - -/* -Preen history for users with > 7 history entries -This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array -of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week -of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] - */ - -module.exports = function(history) { - var newHistory, preen, thisMonth; - history = _.filter(history, function(h) { - return !!h; - }); - newHistory = []; - preen = function(amount, groupBy) { - var groups; - groups = _.chain(history).groupBy(function(h) { - return moment(h.date).format(groupBy); - }).sortBy(function(h, k) { - return k; - }).value(); - groups = groups.slice(-amount); - groups.pop(); - return _.each(groups, function(group) { - newHistory.push({ - date: moment(group[0].date).toDate(), - value: _.reduce(group, (function(m, obj) { - return m + obj.value; - }), 0) / group.length - }); - return true; - }); - }; - preen(50, "YYYY"); - preen(moment().format('MM'), "YYYYMM"); - thisMonth = moment().format('YYYYMM'); - newHistory = newHistory.concat(_.filter(history, function(h) { - return moment(h.date).format('YYYYMM') === thisMonth; - })); - return newHistory; -}; diff --git a/common/script/libs/preenTodos.js b/common/script/libs/preenTodos.js index 121bdae008..1fad4a6c1c 100644 --- a/common/script/libs/preenTodos.js +++ b/common/script/libs/preenTodos.js @@ -2,6 +2,7 @@ import moment from 'moment'; import _ from 'lodash'; // TODO used only in v2 client +// TODO test module.exports = function preenTodos (tasks) { return _.filter(tasks, (t) => { diff --git a/common/script/libs/removeWhitespace.js b/common/script/libs/removeWhitespace.js deleted file mode 100644 index 1015beda54..0000000000 --- a/common/script/libs/removeWhitespace.js +++ /dev/null @@ -1,10 +0,0 @@ -/* -Remove whitespace #FIXME are we using this anywwhere? Should we be? - */ - -module.exports = function(str) { - if (!str) { - return ''; - } - return str.replace(/\s/g, ''); -}; diff --git a/common/script/libs/silver.js b/common/script/libs/silver.js index 0dbae97b05..5a2b8f98f8 100644 --- a/common/script/libs/silver.js +++ b/common/script/libs/silver.js @@ -2,6 +2,8 @@ Silver amount from their money */ +// TODO move to client + module.exports = function(num) { if (num) { return ("0" + Math.floor((num - Math.floor(num)) * 100)).slice(-2); diff --git a/common/script/libs/taskDefaults.js b/common/script/libs/taskDefaults.js index cb14115bca..04fcba3f48 100644 --- a/common/script/libs/taskDefaults.js +++ b/common/script/libs/taskDefaults.js @@ -6,6 +6,7 @@ import moment from 'moment'; // sending up to the server for performance // TODO move to client code? +// TODO test? const tasksTypes = ['habit', 'daily', 'todo', 'reward']; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 998a8c040e..90003ac77f 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -28,17 +28,9 @@ const COMMON_FILES = [ '!./common/script/ops/update.js', '!./common/script/ops/updateWebhook.js', '!./common/script/fns/crit.js', - '!./common/script/fns/cron.js', - '!./common/script/fns/dotGet.js', - '!./common/script/fns/dotSet.js', - '!./common/script/fns/getItem.js', - '!./common/script/fns/nullify.js', - '!./common/script/fns/preenUserHistory.js', '!./common/script/fns/randomDrop.js', '!./common/script/libs/appliedTags.js', '!./common/script/libs/countExists.js', - '!./common/script/libs/dotGet.js', - '!./common/script/libs/dotSet.js', '!./common/script/libs/encodeiCalLink.js', '!./common/script/libs/friendlyTimestamp.js', '!./common/script/libs/gold.js', @@ -46,8 +38,6 @@ const COMMON_FILES = [ '!./common/script/libs/noTags.js', '!./common/script/libs/percent.js', '!./common/script/libs/planGemLimits.js', - '!./common/script/libs/preenHistory.js', - '!./common/script/libs/removeWhitespace.js', '!./common/script/libs/silver.js', '!./common/script/libs/splitWhitespace.js', '!./common/script/libs/taskClasses.js', diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 3ac3a116ab..10d242a97b 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -918,6 +918,7 @@ api.batchUpdate = function(req, res, next) { return cb(); }; if(!api[_req.op]) { return cb(shared.i18n.t('messageUserOperationNotFound', { operation: _req.op })); } + api[_req.op](_req, res, cb); }); }) @@ -944,6 +945,7 @@ api.batchUpdate = function(req, res, next) { } else if (res.locals.wasModified){ // Preen 3-day past-completed To-Dos from Angular & mobile app _user.getTransformedData(function(err, transformedData){ + if (err) next(err); response = transformedData; response.todos = shared.preenTodos(response.todos); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 6b7d3de06e..9a6ab40280 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -5,10 +5,10 @@ import { shouldDo, } from '../../../../common/script/cron'; import common from '../../../../common'; -import Task from '../../models/task'; +import * as Tasks from '../../models/task'; import Q from 'q'; -import Group from '../../models/group'; -import User from '../../models/user'; +import { model as Group } from '../../models/group'; +import { model as User } from '../../models/user'; import { preenUserHistory } from '../../libs/api-v3/preening'; const scoreTask = common.ops.scoreTask; @@ -108,7 +108,6 @@ function cron (options = {}) { direction: 'down', cron: true, times: multiDaysCountAsOneDay ? 1 : daysMissed, - // TODO pass req for analytics? }); todoTally += task.value; @@ -358,7 +357,7 @@ module.exports = function cronMiddleware (req, res, next) { if (daysMissed <= 0) return next(); // Fetch active tasks (no completed todos) - Task.find({ + Tasks.Task.find({ userId: user._id, $or: [ // Exclude completed todos {type: 'todo', completed: false}, @@ -374,7 +373,7 @@ module.exports = function cronMiddleware (req, res, next) { // Clear old completed todos - 30 days for free users, 90 for subscribers // Do not delete challenges completed todos TODO unless the task is broken? - Task.remove({ + Tasks.Task.remove({ userId: user._id, type: 'todo', completed: true, diff --git a/website/src/middlewares/api-v3/v2.js b/website/src/middlewares/api-v3/v2.js index f942c4f673..6183a619f2 100644 --- a/website/src/middlewares/api-v3/v2.js +++ b/website/src/middlewares/api-v3/v2.js @@ -4,6 +4,7 @@ import swagger from 'swagger-node-express'; // import shared from '../../../../common'; import express from 'express'; +import analytics from './analytics'; const v2app = express(); @@ -11,6 +12,8 @@ const v2app = express(); v2app.set('view engine', 'jade'); v2app.set('views', `${__dirname}/../../../views`); +v2app.use(analytics); + // Custom Directives v2app.use('/', require('../../routes/api-v2/auth')); v2app.use('/', require('../../routes/api-v2/coupon')); // TODO REMOVE - ONLY v3 From 83fa30fd0af92418754ae6deaaeb28d3912e840e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 8 Apr 2016 18:45:50 +0200 Subject: [PATCH 623/976] v3 adapt v2: port challenges closing and fix create --- website/src/controllers/api-v2/challenges.js | 141 ++++++------------- website/src/controllers/api-v3/challenges.js | 3 +- 2 files changed, 47 insertions(+), 97 deletions(-) diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 6f0b3c81fd..1f7ef14779 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -233,9 +233,23 @@ api.create = async function(req, res, next){ let challengeValidationErrors = challenge.validateSync(); if (challengeValidationErrors) throw challengeValidationErrors; + req.body.habits = req.body.habits || []; + req.body.todos = req.body.todos || []; + req.body.dailys = req.body.dailys || []; + req.body.rewards = req.body.rewards || []; + + var chalTasks = req.body.habits.concat(req.body.rewards) + .concat(req.body.dailys).concat(req.body.todos); + + chalTasks = tasks.map(function(task) { + var newTask = new Tasks[task.type](Tasks.Task.sanitizeCreate(task)); + newTask.challenge.id = chal._id; + return newTask.save(); + }); + let results = await Q.all([challenge.save({ - validateBeforeSave: false, // already validate - }), group.save()]); + validateBeforeSave: false, // already validated + }), group.save()].concat(chalTasks)); let savedChal = results[0]; await savedChal.syncToUser(user); // (it also saves the user) @@ -292,113 +306,48 @@ api.update = function(req, res, next){ }) } -/** - * Called by either delete() or selectWinner(). Will delete the challenge and set the "broken" property on all users' subscribed tasks - * @param {cid} the challenge id - * @param {broken} the object representing the broken status of the challenge. Eg: - * {broken: 'CHALLENGE_DELETED', id: CHALLENGE_ID} - * {broken: 'CHALLENGE_CLOSED', id: CHALLENGE_ID, winner: USER_NAME} - */ -function closeChal(cid, broken, cb) { - var removed; - async.waterfall([ - function(cb2){ - Challenge.findOneAndRemove({_id:cid}, cb2) - }, - function(_removed, cb2) { - removed = _removed; - var pull = {'$pull':{}}; pull['$pull'][_removed._id] = 1; - Group.findByIdAndUpdate(_removed.group, {new: true}, pull); - User.find({_id:{$in: removed.members}}, cb2); - }, - function(users, cb2) { - var parallel = []; - _.each(users, function(user){ - var tag = _.find(user.tags, {id:cid}); - if (tag) tag.challenge = undefined; - _.each(user.tasks, function(task){ - if (task.challenge && task.challenge.id == removed._id) { - _.merge(task.challenge, broken); - } - }) - parallel.push(function(cb3){ - user.save(cb3); - }) - }) - async.parallel(parallel, cb2); - removed = null; - } - ], cb); -} +import { _closeChal } from '../api-v3/challenges'; /** * Delete & close */ -api.delete = function(req, res, next){ - var user = res.locals.user; - var cid = req.params.cid; +api.delete = async function(req, res, next){ + try { + var user = res.locals.user; + var cid = req.params.cid; - async.waterfall([ - function(cb){ - Challenge.findById(cid, cb); - }, - function(chal, cb){ - if (!chal) return cb('Challenge ' + cid + ' not found'); - if (chal.leader != user._id && !user.contributor.admin) return cb(shared.i18n.t('noPermissionDeleteChallenge', req.language)); - if (chal.group != 'habitrpg') user.balance += chal.prize/4; // Refund gems to user if a non-tavern challenge - user.save(cb); - }, - function(save, num, cb){ - closeChal(req.params.cid, {broken: 'CHALLENGE_DELETED'}, cb); - } - ], function(err){ - if (err) return next(err); + let challenge = await Challenge.findOne({_id: req.params.cid}).exec(); + if (!challenge) return next('Challenge ' + cid + ' not found'); + if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge')); + + // Close channel in background, some ops are run in the background without `await`ing + await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); res.sendStatus(200); - user = cid = null; - }); + } catch (err) { + next(err); + } } /** * Select Winner & Close */ -api.selectWinner = function(req, res, next) { - if (!req.query.uid) return res.status(401).json({err: 'Must select a winner'}); - var user = res.locals.user; - var cid = req.params.cid; - var chal; - async.waterfall([ - function(cb){ - Challenge.findById(cid, cb); - }, - function(_chal, cb){ - chal = _chal; - if (!chal) return cb('Challenge ' + cid + ' not found'); - if (chal.leader != user._id && !user.contributor.admin) return cb(shared.i18n.t('noPermissionCloseChallenge', req.language)); - User.findById(req.query.uid, cb) - }, - function(winner, cb){ - if (!winner) return cb('Winner ' + req.query.uid + ' not found.'); - _.defaults(winner.achievements, {challenges: []}); - winner.achievements.challenges.push(chal.name); - winner.balance += chal.prize/4; - winner.save(cb); - }, - function(saved, num, cb) { - if(saved.preferences.emailNotifications.wonChallenge !== false){ - utils.txnEmail(saved, 'won-challenge', [ - {name: 'CHALLENGE_NAME', content: chal.name} - ]); - } +api.selectWinner = async function(req, res, next) { + try { + if (!req.query.uid) return res.status(401).json({err: 'Must select a winner'}); - pushNotify.sendNotify(saved, shared.i18n.t('wonChallenge'), chal.name); + let challenge = await Challenge.findOne({_id: req.params.cid}).exec(); + if (!challenge) return next('Challenge ' + cid + ' not found'); + if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge')); - closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb); - } - ], function(err){ - if (err) return next(err); - res.sendStatus(200); - user = cid = chal = null; - }) + let winner = await User.findOne({_id: req.params.uid}).exec(); + if (!winner || winner.challenges.indexOf(challenge._id) === -1) return next('Winner ' + req.query.uid + ' not found.'); + + // Close channel in background, some ops are run in the background without `await`ing + await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner}); + res.respond(200, {}); + } catch (err) { + next(err); + } } api.join = function(req, res, next){ diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index cf5d44546f..404ac488d9 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -445,7 +445,8 @@ api.updateChallenge = { // TODO everything here should be moved to a worker // actually even for a worker it's probably just too big and will kill mongo -async function _closeChal (challenge, broken = {}) { +// Exported because it's used in v2 controller +export async function _closeChal (challenge, broken = {}) { let winner = broken.winner; let brokenReason = broken.broken; From 1685b7285f4e61830b0d62b6072c7022ea06f519 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 8 Apr 2016 15:56:11 -0500 Subject: [PATCH 624/976] Ported reroll. Added unit tests. Added reroll route. Added integration tests --- common/locales/en/api-v3.json | 3 +- common/script/index.js | 2 + common/script/ops/releaseBoth.js | 7 ++- common/script/ops/releaseMounts.js | 3 +- common/script/ops/releasePets.js | 3 +- common/script/ops/reroll.js | 43 +++++++------ tasks/gulp-eslint.js | 4 -- .../integration/user/POST-user_reroll.test.js | 54 ++++++++++++++++ test/common/ops/reroll.js | 63 +++++++++++++++++++ website/src/controllers/api-v3/user.js | 30 +++++++++ 10 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_reroll.test.js create mode 100644 test/common/ops/reroll.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 41fca419f9..0eb2d4b7a3 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -162,5 +162,6 @@ "alreadyUnlocked": "Item already unlocked", "cannotRevive": "Cannot revive if not dead", "rebirthComplete": "You have been reborn!", - "petNotOwned": "You do not own this pet." + "petNotOwned": "You do not own this pet.", + "rerollComplete": "Reroll complete!" } diff --git a/common/script/index.js b/common/script/index.js index e4eaca082b..ebd383c326 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -128,6 +128,7 @@ import sell from './ops/sell'; import unlock from './ops/unlock'; import revive from './ops/revive'; import rebirth from './ops/rebirth'; +import reroll from './ops/reroll'; api.ops = { scoreTask, @@ -157,6 +158,7 @@ api.ops = { unlock, revive, rebirth, + reroll, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/releaseBoth.js b/common/script/ops/releaseBoth.js index a17d2e5f00..0ae25f6fe7 100644 --- a/common/script/ops/releaseBoth.js +++ b/common/script/ops/releaseBoth.js @@ -4,6 +4,7 @@ import { NotAuthorized, } from '../libs/errors'; import splitWhitespace from '../libs/splitWhitespace'; +import _ from 'lodash'; module.exports = function releaseBoth (user, req = {}, analytics) { let animal; @@ -20,15 +21,15 @@ module.exports = function releaseBoth (user, req = {}, analytics) { uuid: user._id, acquireMethod: 'Gems', gemCost: 6, - category: 'behavior' + category: 'behavior', }); } user.balance -= 1.5; } - user.items.currentMount = ""; - user.items.currentPet = ""; + user.items.currentMount = ''; + user.items.currentPet = ''; for (animal in content.pets) { if (user.items.pets[animal] === -1) { diff --git a/common/script/ops/releaseMounts.js b/common/script/ops/releaseMounts.js index 55d2c23fcb..0ec6fb6e7f 100644 --- a/common/script/ops/releaseMounts.js +++ b/common/script/ops/releaseMounts.js @@ -4,6 +4,7 @@ import { NotAuthorized, } from '../libs/errors'; import splitWhitespace from '../libs/splitWhitespace'; +import _ from 'lodash'; module.exports = function releaseMounts (user, req = {}, analytics) { let mount; @@ -29,7 +30,7 @@ module.exports = function releaseMounts (user, req = {}, analytics) { uuid: user._id, acquireMethod: 'Gems', gemCost: 4, - category: 'behavior' + category: 'behavior', }); } diff --git a/common/script/ops/releasePets.js b/common/script/ops/releasePets.js index 12bb48a90f..597054299a 100644 --- a/common/script/ops/releasePets.js +++ b/common/script/ops/releasePets.js @@ -4,6 +4,7 @@ import { NotAuthorized, } from '../libs/errors'; import splitWhitespace from '../libs/splitWhitespace'; +import _ from 'lodash'; module.exports = function releasePets (user, req = {}, analytics) { if (user.balance < 1) { @@ -27,7 +28,7 @@ module.exports = function releasePets (user, req = {}, analytics) { uuid: user._id, acquireMethod: 'Gems', gemCost: 4, - category: 'behavior' + category: 'behavior', }); } diff --git a/common/script/ops/reroll.js b/common/script/ops/reroll.js index f6a0862f1d..79c5eb1bbe 100644 --- a/common/script/ops/reroll.js +++ b/common/script/ops/reroll.js @@ -1,29 +1,36 @@ import i18n from '../i18n'; import _ from 'lodash'; +import { + NotAuthorized, +} from '../libs/errors'; -module.exports = function(user, req, cb, analytics) { - var analyticsData; +module.exports = function reroll (user, tasks = [], req = {}, analytics) { if (user.balance < 1) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('notEnoughGems', req.language) - }) : void 0; + throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); } + user.balance--; - _.each(user.tasks, function(task) { + user.stats.hp = 50; + + _.each(tasks, function resetTaskValues (task) { if (task.type !== 'reward') { - return task.value = 0; + task.value = 0; } }); - user.stats.hp = 50; - analyticsData = { - uuid: user._id, - acquireMethod: 'Gems', - gemCost: 4, - category: 'behavior' - }; - if (analytics != null) { - analytics.track('Fortify Potion', analyticsData); + + if (analytics) { + analytics.track('Fortify Potion', { + uuid: user._id, + acquireMethod: 'Gems', + gemCost: 4, + category: 'behavior', + }); } - return typeof cb === "function" ? cb(null, user) : void 0; + + let response = { + data: {user, tasks}, + message: i18n.t('rerollComplete'), + }; + + return response; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 3aaf961fb7..6bfc9599e8 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -21,10 +21,6 @@ const COMMON_FILES = [ '!./common/script/ops/deleteTask.js', '!./common/script/ops/getTag.js', '!./common/script/ops/getTags.js', - '!./common/script/ops/releaseBoth.js', - '!./common/script/ops/releaseMounts.js', - '!./common/script/ops/releasePets.js', - '!./common/script/ops/reroll.js', '!./common/script/ops/reset.js', '!./common/script/ops/sortTag.js', '!./common/script/ops/sortTask.js', diff --git a/test/api/v3/integration/user/POST-user_reroll.test.js b/test/api/v3/integration/user/POST-user_reroll.test.js new file mode 100644 index 0000000000..ac11b0d463 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_reroll.test.js @@ -0,0 +1,54 @@ +import { + generateUser, + generateDaily, + generateReward, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/reroll', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error when user balance is too low', async () => { + await expect(user.post('/user/reroll')) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughGems'), + }); + }); + + // More tests in common code unit tests + + it('resets user\'s tasks', async () => { + await user.update({ + balance: 2, + }); + + let daily = await generateDaily({ + text: 'test habit', + type: 'daily', + userId: user._id, + }); + + let reward = await generateReward({ + text: 'test reward', + type: 'reward', + value: 1, + userId: user._id, + }); + + let response = await user.post('/user/reroll'); + await user.sync(); + + let updatedDaily = await user.get(`/tasks/${daily._id}`); + let updatedReward = await user.get(`/tasks/${reward._id}`); + + expect(response.message).to.equal(t('rerollComplete')); + expect(updatedDaily.value).to.equal(0); + expect(updatedReward.value).to.equal(1); + }); +}); diff --git a/test/common/ops/reroll.js b/test/common/ops/reroll.js new file mode 100644 index 0000000000..8cadd3fa6c --- /dev/null +++ b/test/common/ops/reroll.js @@ -0,0 +1,63 @@ +import reroll from '../../../common/script/ops/reroll'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, + generateDaily, + generateReward, +} from '../../helpers/common.helper'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; + +describe('shared.ops.reroll', () => { + let user; + let tasks = []; + + beforeEach(() => { + user = generateUser(); + user.balance = 1; + tasks = [generateDaily(), generateReward()]; + }); + + it('returns an error when user balance is too low', (done) => { + user.balance = 0; + + try { + reroll(user); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); + } + }); + + it('rerolls a user with enough gems', () => { + let response = reroll(user); + + expect(response.message).to.equal(i18n.t('rerollComplete')); + }); + + it('reduces a user\'s balance', () => { + reroll(user); + + expect(user.balance).to.equal(0); + }); + + it('resets a user\'s health points', () => { + user.stats.hp = 40; + + reroll(user); + + expect(user.stats.hp).to.equal(50); + }); + + it('resets user\'s taks values except for rewards to 0', () => { + tasks[0].value = 1; + tasks[1].value = 1; + + reroll(user, tasks); + + expect(tasks[0].value).to.equal(0); + expect(tasks[1].value).to.equal(1); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 1691d78f88..a1021cb7ed 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -974,4 +974,34 @@ api.userRebirth = { }, }; +/* +* @api {post} /user/reroll Rerolls a user. +* @apiVersion 3.0.0 +* @apiName UserReroll +* @apiGroup User +* +* @apiSuccess {Object} data `user` +*/ +api.userReroll = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/reroll', + async handler (req, res) { + let user = res.locals.user; + let query = { + userId: user._id, + type: {$in: ['daily', 'habit', 'todo']}, + }; + let tasks = await Tasks.Task.find(query).exec(); + let rerollResponse = common.ops.reroll(user, tasks, req, res.analytics); + + let promises = tasks.map(task => task.save()); + promises.push(user.save()); + + await Q.all(promises); + + res.respond(200, rerollResponse); + }, +}; + module.exports = api; From 1ab78f1c9ac4cedd507ef70c19ab33f1b6693659 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 8 Apr 2016 21:35:23 -0500 Subject: [PATCH 625/976] refactor: Port v2 flag report change to v3 --- website/src/controllers/api-v3/chat.js | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index d2b7358643..1fd1ebe1bd 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -11,6 +11,10 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; +const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { + return { email, canSend: true }; +}); + let api = {}; /** @@ -200,17 +204,6 @@ api.flagChat = { update ); - let addressesToSendTo = nconf.get('FLAG_REPORT_EMAIL'); - addressesToSendTo = typeof addressesToSendTo === 'string' ? JSON.parse(addressesToSendTo) : addressesToSendTo; - - if (Array.isArray(addressesToSendTo)) { - addressesToSendTo = addressesToSendTo.map((email) => { - return {email, canSend: true}; - }); - } else { - addressesToSendTo = {email: addressesToSendTo}; - } - let reporterEmailContent; if (user.auth.local) { reporterEmailContent = user.auth.local.email; @@ -234,7 +227,7 @@ api.flagChat = { groupUrl = 'party'; } - sendTxn(addressesToSendTo, 'flag-report-to-mods', [ + sendTxn(FLAG_REPORT_EMAILS, 'flag-report-to-mods', [ {name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()}, {name: 'MESSAGE_TEXT', content: message.text}, From 06db7e635378b963f75b2e9fda71b962c8d8bda1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 9 Apr 2016 15:29:37 +0200 Subject: [PATCH 626/976] v3 adapt v2: port leave, join and unlink for challenges --- website/src/controllers/api-v2/challenges.js | 147 ++++++++++--------- 1 file changed, 81 insertions(+), 66 deletions(-) diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 1f7ef14779..e7db3b4a83 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -13,6 +13,7 @@ import { import { model as Challenge, } from '../../models/challenge'; +import * as Tasks from '../../models/task'; var logging = require('./../../libs/api-v2/logging'); var csvStringify = require('csv-stringify'); var utils = require('../../libs/api-v2/utils'); @@ -241,7 +242,7 @@ api.create = async function(req, res, next){ var chalTasks = req.body.habits.concat(req.body.rewards) .concat(req.body.dailys).concat(req.body.todos); - chalTasks = tasks.map(function(task) { + chalTasks = chalTasks.map(function(task) { var newTask = new Tasks[task.type](Tasks.Task.sanitizeCreate(task)); newTask.challenge.id = chal._id; return newTask.save(); @@ -350,82 +351,96 @@ api.selectWinner = async function(req, res, next) { } } -api.join = function(req, res, next){ - var user = res.locals.user; - var cid = req.params.cid; +api.join = async function(req, res, next){ + try { + var user = res.locals.user; + var cid = req.params.cid; - async.waterfall([ - function(cb) { - Challenge.findByIdAndUpdate(cid, {$addToSet:{members:user._id}}, {new: true}, cb); - }, - function(chal, cb) { + let challenge = await Challenge.findOne({ _id: cid }); + if (!challenge) return next(shared.i18n.t('challengeNotFound')); + if (challenge.isMember(user)) return next(shared.i18n.t('userAlreadyInChallenge')); - // Trigger updating challenge member count in the background. We can't do it above because we don't have - // _.size(challenge.members). We can't do it in pre(save) because we're calling findByIdAndUpdate above. - Challenge.update({_id:cid}, {$set:{memberCount:_.size(chal.members)}}).exec(); + let group = await Group.getGroup({user, groupId: challenge.group, optionalMembership: true}); + if (!group || !challenge.hasAccess(user, group)) return next(shared.i18n.t('challengeNotFound')); - if (!~user.challenges.indexOf(cid)) - user.challenges.unshift(cid); - // Add all challenge's tasks to user's tasks - chal.syncToUser(user, function(err){ - if (err) return cb(err); - cb(null, chal); // we want the saved challenge in the return results, due to ng-resource - }); - } - ], function(err, chal){ - if(err) return next(err); - chal._isMember = true; - res.json(chal); - user = cid = null; - }); + challenge.memberCount += 1; + + // Add all challenge's tasks to user's tasks and save the challenge + await Q.all([challenge.syncToUser(user), challenge.save()]); + + challenge.getTransformedData({ + cb (err, transformedChal) { + transformedChal._isMember = true; + res.json(transformedChal); + } + }); + } catch (e) { + next(e); + } } +api.leave = async function(req, res, next){ + try { + var user = res.locals.user; + var cid = req.params.cid; + // whether or not to keep challenge's tasks. strictly default to true if "keep-all" isn't provided + var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all'; -api.leave = function(req, res, next){ - var user = res.locals.user; - var cid = req.params.cid; - // whether or not to keep challenge's tasks. strictly default to true if "keep-all" isn't provided - var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all'; + let challenge = await Challenge.findOne({ _id: cid }); + if (!challenge) return next(shared.i18n.t('challengeNotFound')); - async.waterfall([ - function(cb){ - Challenge.findByIdAndUpdate(cid, {$pull:{members:user._id}}, {new: true}, cb); - }, - function(chal, cb){ + let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy'}); + if (!group || !challenge.canView(user, group)) return next(shared.i18n.t('challengeNotFound')); - // Trigger updating challenge member count in the background. We can't do it above because we don't have - // _.size(challenge.members). We can't do it in pre(save) because we're calling findByIdAndUpdate above. - if (chal) - Challenge.update({_id:cid}, {$set:{memberCount:_.size(chal.members)}}).exec(); + if (!challenge.isMember(user)) return next(shared.i18n.t('challengeMemberNotFound')); - var i = user.challenges.indexOf(cid) - if (~i) user.challenges.splice(i,1); - user.unlink({cid:cid, keep:keep}, function(err){ - if (err) return cb(err); - cb(null, chal); - }) - } - ], function(err, chal){ - if(err) return next(err); - if (chal) chal._isMember = false; - res.json(chal); - user = cid = keep = null; - }); + challenge.memberCount -= 1; + + // Unlink challenge's tasks from user's tasks and save the challenge + await Q.all([challenge.unlinkTasks(user, keep), challenge.save()]); + + challenge.getTransformedData({ + cb (err, transformedChal) { + transformedChal._isMember = false; + res.json(transformedChal); + } + }); + } catch (e) { + next(e); + } } -api.unlink = function(req, res, next) { - // they're scoring the task - commented out, we probably don't need it due to route ordering in api.js - //var urlParts = req.originalUrl.split('/'); - //if (_.contains(['up','down'], urlParts[urlParts.length -1])) return next(); +api.unlink = async function(req, res, next) { + try { + var user = res.locals.user; + var tid = req.params.id; + var cid = user.tasks[tid].challenge.id; + if (!req.query.keep) + return res.status(400).json({err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'}); + + let keep = req.query.keep; + let task = await Tasks.Task.findOne({ + _id: taskId, + userId: user._id, + }).exec(); + + if (!task) return next(shared.i18n.t('taskNotFound')); + if (!task.challenge.id) return next(shared.i18n.t('cantOnlyUnlinkChalTask')); + + if (keep === 'keep') { + task.challenge = {}; + await task.save(); + } else { // remove + if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if + removeFromArray(user.tasksOrder[`${task.type}s`], taskId); + await Q.all([user.save(), task.remove()]); + } else { + await task.remove(); + } + } - var user = res.locals.user; - var tid = req.params.id; - var cid = user.tasks[tid].challenge.id; - if (!req.query.keep) - return res.status(400).json({err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'}); - user.unlink({cid:cid, keep:req.query.keep, tid:tid}, function(err, saved){ - if (err) return next(err); res.sendStatus(200); - user = tid = cid = null; - }); + } catch (e) { + next(e); + } } From 6b2d1a1416139850b922b7b4088304a6345cb09c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 9 Apr 2016 16:45:43 +0200 Subject: [PATCH 627/976] v3 adapt v2: adapt common ops --- common/script/ops/addWebhook.js | 12 +++++++---- common/script/ops/allocateNow.js | 13 ++++++++---- common/script/ops/buy.js | 6 +++++- common/script/ops/buyMysterySet.js | 13 ++++++++---- common/script/ops/buyQuest.js | 16 +++++++++------ common/script/ops/buySpecialSpell.js | 16 +++++++++------ common/script/ops/changeClass.js | 10 ++++++--- common/script/ops/deleteWebhook.js | 2 ++ common/script/ops/disableClasses.js | 12 +++++++---- common/script/ops/equip.js | 6 +++++- common/script/ops/feed.js | 14 +++++++++---- common/script/ops/hatch.js | 12 +++++++---- common/script/ops/hourglassPurchase.js | 6 +++++- common/script/ops/openMysteryItem.js | 12 +++++++---- common/script/ops/purchase.js | 6 +++++- common/script/ops/readCard.js | 12 +++++++---- common/script/ops/rebirth.js | 6 +++++- common/script/ops/releaseBoth.js | 6 +++++- common/script/ops/releaseMounts.js | 6 +++++- common/script/ops/releasePets.js | 6 +++++- common/script/ops/reroll.js | 6 +++++- common/script/ops/revive.js | 6 +++++- common/script/ops/sell.js | 6 +++++- common/script/ops/sleep.js | 17 ++++++++++------ common/script/ops/unlock.js | 6 +++++- common/script/ops/updateWebhook.js | 6 +++++- test/common/ops/deleteWebhook.test.js | 7 ++++--- website/src/controllers/api-v2/user.js | 28 +++++++++++++++++++++++--- 28 files changed, 202 insertions(+), 72 deletions(-) diff --git a/common/script/ops/addWebhook.js b/common/script/ops/addWebhook.js index ea6b32ed68..16d63593d2 100644 --- a/common/script/ops/addWebhook.js +++ b/common/script/ops/addWebhook.js @@ -15,8 +15,12 @@ module.exports = function addWebhook (user, req = {}) { user.markModified('preferences.webhooks'); - return refPush(wh, { - url: req.body.url, - enabled: req.body.enabled, - }); + if (req.v2 === true) { + return user.preferences.webhooks; + } else { + return refPush(wh, { + url: req.body.url, + enabled: req.body.enabled, + }); + } }; diff --git a/common/script/ops/allocateNow.js b/common/script/ops/allocateNow.js index fefd130fed..0db7a3a819 100644 --- a/common/script/ops/allocateNow.js +++ b/common/script/ops/allocateNow.js @@ -1,10 +1,15 @@ import _ from 'lodash'; import autoAllocate from '../fns/autoAllocate'; -module.exports = function allocateNow (user) { +module.exports = function allocateNow (user, req = {}) { _.times(user.stats.points, () => autoAllocate(user)); user.stats.points = 0; - return { - data: _.pick(user, 'stats'), - }; + + if (req.v2 === true) { + return _.pick(user, 'stats'); + } else { + return { + data: _.pick(user, 'stats'), + }; + } }; diff --git a/common/script/ops/buy.js b/common/script/ops/buy.js index 4a5b5b3b7f..268be94b39 100644 --- a/common/script/ops/buy.js +++ b/common/script/ops/buy.js @@ -134,5 +134,9 @@ module.exports = function buy (user, req = {}, analytics) { if (armoireResp) res.armoire = armoireResp; - return res; + if (req.v2 === true) { + return res.data; + } else { + return res; + } }; diff --git a/common/script/ops/buyMysterySet.js b/common/script/ops/buyMysterySet.js index 3bbb35e100..cc2eb8f3b1 100644 --- a/common/script/ops/buyMysterySet.js +++ b/common/script/ops/buyMysterySet.js @@ -43,8 +43,13 @@ module.exports = function buyMysterySet (user, req = {}, analytics) { user.purchased.plan.consecutive.trinkets--; - return { - data: pickDeep(user, splitWhitespace('items purchased.plan.consecutive')), - message: i18n.t('hourglassPurchaseSet', req.language), - }; + + if (req.v2 === true) { + return pickDeep(user, splitWhitespace('items purchased.plan.consecutive')); + } else { + return { + data: pickDeep(user, splitWhitespace('items purchased.plan.consecutive')), + message: i18n.t('hourglassPurchaseSet', req.language), + }; + } }; diff --git a/common/script/ops/buyQuest.js b/common/script/ops/buyQuest.js index b8efd8eee7..6cab422063 100644 --- a/common/script/ops/buyQuest.js +++ b/common/script/ops/buyQuest.js @@ -37,10 +37,14 @@ module.exports = function buyQuest (user, req = {}, analytics) { }); } - return { - data: user.items.quests, - message: i18n.t('messageBought', { - itemText: item.text(req.language), - }, req.language), - }; + if (req.v2 === true) { + return user.items.quests; + } else { + return { + data: user.items.quests, + message: i18n.t('messageBought', { + itemText: item.text(req.language), + }, req.language), + }; + } }; diff --git a/common/script/ops/buySpecialSpell.js b/common/script/ops/buySpecialSpell.js index 36ab94e143..921ffc817b 100644 --- a/common/script/ops/buySpecialSpell.js +++ b/common/script/ops/buySpecialSpell.js @@ -22,10 +22,14 @@ module.exports = function buySpecialSpell (user, req = {}) { user.items.special[key]++; - return { - data: _.pick(user, splitWhitespace('items stats')), - message: i18n.t('messageBought', { - itemText: item.text(req.language), - }, req.language), - }; + if (req.v2 === true) { + return _.pick(user, splitWhitespace('items stats')); + } else { + return { + data: _.pick(user, splitWhitespace('items stats')), + message: i18n.t('messageBought', { + itemText: item.text(req.language), + }, req.language), + }; + } }; diff --git a/common/script/ops/changeClass.js b/common/script/ops/changeClass.js index 83452709c3..17a5d5c4d1 100644 --- a/common/script/ops/changeClass.js +++ b/common/script/ops/changeClass.js @@ -65,7 +65,11 @@ module.exports = function changeClass (user, req = {}, analytics) { user.flags.classSelected = false; } - return { - data: _.pick(user, splitWhitespace('stats flags items preferences')), - }; + if (req.v2 === true) { + return _.pick(user, splitWhitespace('stats flags items preferences')); + } else { + return { + data: _.pick(user, splitWhitespace('stats flags items preferences')), + }; + } }; diff --git a/common/script/ops/deleteWebhook.js b/common/script/ops/deleteWebhook.js index 03eb622892..51e94982d4 100644 --- a/common/script/ops/deleteWebhook.js +++ b/common/script/ops/deleteWebhook.js @@ -3,4 +3,6 @@ import _ from 'lodash'; module.exports = function deleteWebhook (user, req) { delete user.preferences.webhooks[_.get(req, 'params.id')]; user.markModified('preferences.webhooks'); + + return user.preferences.webhooks; }; diff --git a/common/script/ops/disableClasses.js b/common/script/ops/disableClasses.js index 0aae65edd5..58f493a13c 100644 --- a/common/script/ops/disableClasses.js +++ b/common/script/ops/disableClasses.js @@ -2,7 +2,7 @@ import splitWhitespace from '../libs/splitWhitespace'; import { capByLevel } from '../statHelpers'; import _ from 'lodash'; -module.exports = function disableClasses (user) { +module.exports = function disableClasses (user, req = {}) { user.stats.class = 'warrior'; user.flags.classSelected = true; user.preferences.disableClasses = true; @@ -10,7 +10,11 @@ module.exports = function disableClasses (user) { user.stats.str = capByLevel(user.stats.lvl); user.stats.points = 0; - return { - data: _.pick(user, splitWhitespace('stats flags preferences')), - }; + if (req.v2 === true) { + return _.pick(user, splitWhitespace('stats flags preferences')); + } else { + return { + data: _.pick(user, splitWhitespace('stats flags preferences')), + }; + } }; diff --git a/common/script/ops/equip.js b/common/script/ops/equip.js index e402b614ff..94a40331d2 100644 --- a/common/script/ops/equip.js +++ b/common/script/ops/equip.js @@ -62,5 +62,9 @@ module.exports = function equip (user, req = {}) { }; if (message) res.message = message; - return res; + if (req.v2 === true) { + return user.items; + } else { + return res; + } }; diff --git a/common/script/ops/feed.js b/common/script/ops/feed.js index e4f2fa849f..f555a24dfc 100644 --- a/common/script/ops/feed.js +++ b/common/script/ops/feed.js @@ -89,8 +89,14 @@ module.exports = function feed (user, req = {}) { user.items.food[food.key]--; - return { - data: userPets[pet], - message, - }; + if (req.v2 === true) { + return { + value: userPets[pet], + }; + } else { + return { + data: userPets[pet], + message, + }; + } }; diff --git a/common/script/ops/hatch.js b/common/script/ops/hatch.js index fee84fd4a8..01b0b68520 100644 --- a/common/script/ops/hatch.js +++ b/common/script/ops/hatch.js @@ -33,8 +33,12 @@ module.exports = function hatch (user, req = {}) { user.items.eggs[egg]--; user.items.hatchingPotions[hatchingPotion]--; - return { - message: i18n.t('messageHatched', req.language), - data: user.items, - }; + if (req.v2 === true) { + return user.items; + } else { + return { + message: i18n.t('messageHatched', req.language), + data: user.items, + }; + } }; diff --git a/common/script/ops/hourglassPurchase.js b/common/script/ops/hourglassPurchase.js index b627704d7b..286590517a 100644 --- a/common/script/ops/hourglassPurchase.js +++ b/common/script/ops/hourglassPurchase.js @@ -56,5 +56,9 @@ module.exports = function purchaseHourglass (user, req = {}, analytics) { message: i18n.t('hourglassPurchase', req.language), }; - return res; + if (req.v2 === true) { + return res.data; + } else { + return res; + } }; diff --git a/common/script/ops/openMysteryItem.js b/common/script/ops/openMysteryItem.js index 5767ee5f75..dc270cace9 100644 --- a/common/script/ops/openMysteryItem.js +++ b/common/script/ops/openMysteryItem.js @@ -33,8 +33,12 @@ module.exports = function openMysteryItem (user, req = {}, analytics) { user._tmp.drop = item; } - return { - message: i18n.t('mysteryItemOpened', req.language), - data: user.items.gear.owned, - }; + if (req.v2 === true) { + return user.items.gear.owned; + } else { + return { + message: i18n.t('mysteryItemOpened', req.language), + data: user.items.gear.owned, + }; + } }; diff --git a/common/script/ops/purchase.js b/common/script/ops/purchase.js index abb7992994..b4a7136646 100644 --- a/common/script/ops/purchase.js +++ b/common/script/ops/purchase.js @@ -124,5 +124,9 @@ module.exports = function purchase (user, req = {}, analytics) { message: i18n.t('purchased', {type, key}), }; - return response; + if (req.v2 === true) { + return response.data; + } else { + return response; + } }; diff --git a/common/script/ops/readCard.js b/common/script/ops/readCard.js index b6d34dcd7d..d943e40a35 100644 --- a/common/script/ops/readCard.js +++ b/common/script/ops/readCard.js @@ -21,8 +21,12 @@ module.exports = function readCard (user, req = {}) { user.items.special[`${cardType}Received`].shift(); user.flags.cardReceived = false; - return { - message: i18n.t('readCard', {cardType}, req.language), - data: _.pick(user, splitWhitespace('items.special flags.cardReceived')), - }; + if (req.v2 === true) { + return _.pick(user, splitWhitespace('items.special flags.cardReceived')); + } else { + return { + message: i18n.t('readCard', {cardType}, req.language), + data: _.pick(user, splitWhitespace('items.special flags.cardReceived')), + }; + } }; diff --git a/common/script/ops/rebirth.js b/common/script/ops/rebirth.js index 3739f2c80b..e287323d48 100644 --- a/common/script/ops/rebirth.js +++ b/common/script/ops/rebirth.js @@ -103,5 +103,9 @@ module.exports = function rebirth (user, tasks = [], req = {}, analytics) { message: i18n.t('rebirthComplete'), }; - return response; + if (req.v2 === true) { + return user; + } else { + return response; + } }; diff --git a/common/script/ops/releaseBoth.js b/common/script/ops/releaseBoth.js index 0ae25f6fe7..cc17b99e44 100644 --- a/common/script/ops/releaseBoth.js +++ b/common/script/ops/releaseBoth.js @@ -62,5 +62,9 @@ module.exports = function releaseBoth (user, req = {}, analytics) { message: i18n.t('mountsAndPetsReleased'), }; - return response; + if (req.v2 === true) { + return user; + } else { + return response; + } }; diff --git a/common/script/ops/releaseMounts.js b/common/script/ops/releaseMounts.js index 0ec6fb6e7f..a0564dc804 100644 --- a/common/script/ops/releaseMounts.js +++ b/common/script/ops/releaseMounts.js @@ -39,5 +39,9 @@ module.exports = function releaseMounts (user, req = {}, analytics) { message: i18n.t('mountsReleased'), }; - return response; + if (req.v2 === true) { + return user; + } else { + return response; + } }; diff --git a/common/script/ops/releasePets.js b/common/script/ops/releasePets.js index 597054299a..09303b34e9 100644 --- a/common/script/ops/releasePets.js +++ b/common/script/ops/releasePets.js @@ -37,5 +37,9 @@ module.exports = function releasePets (user, req = {}, analytics) { message: i18n.t('petsReleased'), }; - return response; + if (req.v2 === true) { + return user; + } else { + return response; + } }; diff --git a/common/script/ops/reroll.js b/common/script/ops/reroll.js index 79c5eb1bbe..52939551b3 100644 --- a/common/script/ops/reroll.js +++ b/common/script/ops/reroll.js @@ -32,5 +32,9 @@ module.exports = function reroll (user, tasks = [], req = {}, analytics) { message: i18n.t('rerollComplete'), }; - return response; + if (req.v2 === true) { + return user; + } else { + return response; + } }; diff --git a/common/script/ops/revive.js b/common/script/ops/revive.js index fa33946943..5b48e6fb71 100644 --- a/common/script/ops/revive.js +++ b/common/script/ops/revive.js @@ -102,5 +102,9 @@ module.exports = function revive (user, req = {}, analytics) { message, }; - return response; + if (req.v2 === true) { + return user; + } else { + return response; + } }; diff --git a/common/script/ops/sell.js b/common/script/ops/sell.js index d5dfa9fbbb..7e4eee902f 100644 --- a/common/script/ops/sell.js +++ b/common/script/ops/sell.js @@ -38,5 +38,9 @@ module.exports = function sell (user, req = {}) { message: i18n.t('sold', {type, key}), }; - return response; + if (req.v2 === true) { + return response.data; + } else { + return response; + } }; diff --git a/common/script/ops/sleep.js b/common/script/ops/sleep.js index eb80ec9dd0..8ef48eeac4 100644 --- a/common/script/ops/sleep.js +++ b/common/script/ops/sleep.js @@ -1,8 +1,13 @@ -module.exports = function sleep (user) { +module.exports = function sleep (user, req = {}) { user.preferences.sleep = !user.preferences.sleep; - return { - preferences: { - sleep: user.preferences.sleep, - }, - }; + + if (req.v2 === true) { + return {}; + } else { + return { + preferences: { + sleep: user.preferences.sleep, + }, + }; + } }; diff --git a/common/script/ops/unlock.js b/common/script/ops/unlock.js index bbd3cb0e39..4bd24aeecc 100644 --- a/common/script/ops/unlock.js +++ b/common/script/ops/unlock.js @@ -81,5 +81,9 @@ module.exports = function unlock (user, req = {}, analytics) { message: i18n.t('unlocked'), }; - return response; + if (req.v2 === true) { + return response.data; + } else { + return response; + } }; diff --git a/common/script/ops/updateWebhook.js b/common/script/ops/updateWebhook.js index 0de2f5fa47..d8ec977308 100644 --- a/common/script/ops/updateWebhook.js +++ b/common/script/ops/updateWebhook.js @@ -12,5 +12,9 @@ module.exports = function updateWebhook (user, req) { user.preferences.webhooks[req.params.id].url = req.body.url; user.preferences.webhooks[req.params.id].enabled = req.body.enabled; - return user.preferences.webhooks[req.params.id]; + if (req.v2 === true) { + return user.preferences.webhooks; + } else { + return user.preferences.webhooks[req.params.id]; + } }; diff --git a/test/common/ops/deleteWebhook.test.js b/test/common/ops/deleteWebhook.test.js index 0a3178007a..e72bf22269 100644 --- a/test/common/ops/deleteWebhook.test.js +++ b/test/common/ops/deleteWebhook.test.js @@ -13,8 +13,9 @@ describe('shared.ops.deleteWebhook', () => { }); it('succeeds', () => { - user.preferences.webhooks = { 'some-id': {} }; - deleteWebhook(user, req); - expect(user.preferences.webhooks).to.eql({}); + user.preferences.webhooks = { 'some-id': {}, 'another-id': {} }; + let res = deleteWebhook(user, req); + expect(user.preferences.webhooks).to.eql({'another-id': {}}); + expect(res).to.equal(user.preferences.webhooks); }); }); diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 10d242a97b..a3467cfa26 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -24,6 +24,7 @@ var moment = require('moment'); var logging = require('./../../libs/api-v2/logging'); var acceptablePUTPaths; let restrictedPUTSubPaths; +import v3UserController from '../api-v3/user'; let i18n = shared.i18n; @@ -865,10 +866,24 @@ api.addTask = function(req, res, next) { * All other user.ops which can easily be mapped to common/script/index.js, not requiring custom API-wrapping */ _.each(shared.ops, function(op,k){ - if (!api[k]) { + if (['rebirth', 'reroll', 'reset'].indexOf(k) !== -1) { // proxy ops that change tasks directly to v3 + if (k === 'rebirth') k = 'userRebirth'; // the name is different in v3 + if (k === 'reroll') k = 'userReroll'; + // if (k === 'reset') k = 'resetUser'; + + api[k] = async function (req, res, next) { + try { + req.v2 = true; + await v3UserController[k](req, res, next); + } catch (err) { + next(err); + } + } + } else if (!api[k]) { api[k] = function(req, res, next) { var opResponse; try { + req.v2 = true; // Used to indicate to the shared code that the old response data should be returned opResponse = shared.ops[k](res.locals.user, req, analytics); } catch (err) { if (!err.code) return next(err); @@ -878,8 +893,15 @@ _.each(shared.ops, function(op,k){ // If we want to send something other than 500, pass err as {code: 200, message: "Not enough GP"} res.locals.user.save(function(err){ if (err) return next(err); - res.status(200).json(response); - }) + if (response === user) { // add tasks + user.getTransformedData(function (err, transformedUser) { + if (err) return next(err); + res.status(200).json(transformedUser); + }); + } else { + res.status(200).json(response); + } + }); } } }) From aa8fb81b2605663820ea434def6d580ab9868029 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 9 Apr 2016 17:16:00 +0200 Subject: [PATCH 628/976] v3 adapt v2: port res.respond, challenge.getMember and challenge.csv --- website/src/controllers/api-v2/challenges.js | 99 +++++--------------- website/src/controllers/api-v2/user.js | 10 +- website/src/middlewares/api-v3/v2.js | 3 + 3 files changed, 31 insertions(+), 81 deletions(-) diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index e7db3b4a83..462f73975f 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -20,7 +20,7 @@ var utils = require('../../libs/api-v2/utils'); var api = module.exports; var pushNotify = require('./pushNotifications'); import Q from 'q'; - +import v3MembersController from '../api-v3/members'; /* ------------------------------------------------------------------------ Challenges @@ -100,86 +100,37 @@ api.get = async function(req, res, next) { api.csv = function(req, res, next) { var cid = req.params.cid; - var challenge; - async.waterfall([ - function(cb){ - Challenge.findById(cid,cb) - }, - function(_challenge,cb) { - challenge = _challenge; - if (!challenge) return cb('Challenge ' + cid + ' not found'); - User.aggregate([ - {$match:{'_id':{ '$in': challenge.members}}}, //yes, we want members - {$project:{'profile.name':1,tasks:{$setUnion:["$habits","$dailys","$todos","$rewards"]}}}, - {$unwind:"$tasks"}, - {$match:{"tasks.challenge.id":cid}}, - {$sort:{'tasks.type':1,'tasks.id':1}}, - {$group:{_id:"$_id", "tasks":{$push:"$tasks"},"name":{$first:"$profile.name"}}} - ], cb); - } - ],function(err,users){ - if(err) return next(err); - var output = ['UUID','name']; - _.each(challenge.tasks,function(t){ - //output.push(t.type+':'+t.text); - //not the right order yet - output.push('Task'); - output.push('Value'); - output.push('Notes'); - }) - output = [output]; - _.each(users, function(u){ - var uData = [u._id,u.name]; - _.each(u.tasks,function(t){ - uData = uData.concat([t.type+':'+t.text, t.value, t.notes]); - }) - output.push(uData); - }); - - res.set({ - 'Content-Type': 'text/csv', - 'Content-disposition': `attachment; filename=${cid}.csv`, - }); - - csvStringify(output, (err, csv) => { - if (err) return next(err); - res.status(200).send(csv); - challenge = cid = null; - }); - }) + req.params.challengeId = cid; + v3MembersController.exportChallengeCsv.handler(req, res, next).catch(next); } api.getMember = function(req, res, next) { var cid = req.params.cid; var uid = req.params.uid; - // We need to start using the aggregation framework instead of in-app filtering, see http://docs.mongodb.org/manual/aggregation/ - // See code at 32c0e75 for unwind/group example + req.params.memberId = uid; + req.params.challengeId = cid; + v3MembersController.getChallengeMemberProgress.handler(req, res, next) + .then(result => { + let newResult = { + profile: { + name: result.profile.name, + }, + habits: [], + dailys: [], + todos: [], + rewards: [], + }; - //http://stackoverflow.com/questions/24027213/how-to-match-multiple-array-elements-without-using-unwind - var proj = {'profile.name':'$profile.name'}; - _.each(['habits','dailys','todos','rewards'], function(type){ - proj[type] = { - $setDifference: [{ - $map: { - input: '$'+type, - as: "el", - in: { - $cond: [{$eq: ["$$el.challenge.id", cid]}, '$$el', false] - } - } - }, [false]] - } - }); - User.aggregate() - .match({_id: uid}) - .project(proj) - .exec(function(err, member){ - if (err) return next(err); - if (!member) return res.status(404).json({err: 'Member '+uid+' for challenge '+cid+' not found'}); - res.json(member[0]); - uid = cid = null; - }); + let tasks = result.tasks; + tasks.forEach(task => { + let taskObj = task.toJSONV2(); + newResult[taskObj.type + 's'].push(taskObj); + }); + + res.json(newResult); + }) + .catch(next); } // CREATE diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index a3467cfa26..7de680bf2c 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -871,13 +871,9 @@ _.each(shared.ops, function(op,k){ if (k === 'reroll') k = 'userReroll'; // if (k === 'reset') k = 'resetUser'; - api[k] = async function (req, res, next) { - try { - req.v2 = true; - await v3UserController[k](req, res, next); - } catch (err) { - next(err); - } + api[k] = function (req, res, next) { + req.v2 = true; + v3UserController[k].handler(req, res, next).catch(next); } } else if (!api[k]) { api[k] = function(req, res, next) { diff --git a/website/src/middlewares/api-v3/v2.js b/website/src/middlewares/api-v3/v2.js index 6183a619f2..71a1e1d82c 100644 --- a/website/src/middlewares/api-v3/v2.js +++ b/website/src/middlewares/api-v3/v2.js @@ -5,6 +5,7 @@ import swagger from 'swagger-node-express'; // import shared from '../../../../common'; import express from 'express'; import analytics from './analytics'; +import responseHandler from './response'; const v2app = express(); @@ -13,6 +14,8 @@ v2app.set('view engine', 'jade'); v2app.set('views', `${__dirname}/../../../views`); v2app.use(analytics); +v2app.use(responseHandler); + // Custom Directives v2app.use('/', require('../../routes/api-v2/auth')); From 6f1dde4beb00a2f5368ac810bc04a1677f7e41e0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 9 Apr 2016 17:25:41 +0200 Subject: [PATCH 629/976] v3 adapt v2: port challenge.update --- website/src/controllers/api-v2/challenges.js | 73 +++++++++++++------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 462f73975f..a6b1a3ae48 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -221,41 +221,66 @@ api.update = function(req, res, next){ var cid = req.params.cid; var user = res.locals.user; var before; + var updatedTasks; + async.waterfall([ function(cb){ // We first need the original challenge data, since we're going to compare against new & decide to sync users Challenge.findById(cid, cb); }, + function(chal, cb){ + if(!chal) return cb({chal: null}); + + chal.getTasks(function(err, tasks){ + cb(err, { + chal: chal, + tasks: tasks + }); + }); + }, function(_before, cb) { - if (!_before) return cb('Challenge ' + cid + ' not found'); - if (_before.leader != user._id && !user.contributor.admin) return cb(shared.i18n.t('noPermissionEditChallenge', req.language)); + if (!_before.chal) return cb('Challenge ' + cid + ' not found'); + if (_before.chal.leader != user._id && !user.contributor.admin) return cb({code: 401, err: shared.i18n.t('noPermissionEditChallenge', req.language)}); // Update the challenge, since syncing will need the updated challenge. But store `before` we're going to do some // before-save / after-save comparison to determine if we need to sync to users - before = _before; - var attrs = _.pick(req.body, 'name shortName description habits dailys todos rewards date'.split(' ')); - Challenge.findByIdAndUpdate(cid, {$set:attrs}, {new: true}, cb); + before = {chal: _before.chal, tasks: _before.tasks}; + var chalAttrs = _.pick(req.body, 'name shortName description date'.split(' ')); + async.parallel({ + chal: function(cb1){ + Challenge.findByIdAndUpdate(cid, {$set:chalAttrs}, {new: true}, cb1); + }, + tasks: function(cb1) { + // Convert to map of {id: task} so we can easily match them + var _beforeClonedTasks = _.cloneDeep(_before.tasks.map(function(t) { + return t.toObject(); + })); + updatedTasks = _.object(_.pluck(_beforeClonedTasks, '_id'), _beforeClonedTasks); + var newTasks = req.body.habits.concat(req.body.dailys) + .concat(req.body.todos).concat(req.body.rewards); + + var newTasksObj = _.object(_.pluck(newTasks, '_id'), newTasks); + async.forEachOf(newTasksObj, function(newTask, taskId, cb2){ + // some properties can't be changed + Tasks.Task.sanitize(newTask); + // TODO we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? + // TODO regarding comment above, make sure other models with nested fields are using this trick too + _.assign(updatedTasks[taskId], common.ops.updateTask(task.toObject(), {body: newTask})); + challenge.updateTask(updatedTasks[taskId]); + }, cb1); + } + }, cb); }, - function(saved, cb) { - - // Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers - if (before.isOutdated(req.body)) { - User.find({_id: {$in: saved.members}}, function(err, users){ - logging.info('Challenge updated, sync to subscribers'); - if (err) throw err; - _.each(users, function(user){ - saved.syncToUser(user); - }) - }) - } - - // after saving, we're done as far as the client's concerned. We kick off syncing (heavy task) in the background - cb(null, saved); - } ], function(err, saved){ - if(err) next(err); - res.json(saved); + if(err) { + return err.code ? res.json(err.code, err) : next(err); + } + + saved.chal.getTransformedData({cb: function(err, newChal){ + if(err) return next(err); + res.json(newChal); + }}) cid = user = before = null; - }) + }); } import { _closeChal } from '../api-v3/challenges'; From 1f4a0680ea549ab711f09b0a66bb26a0a754ff9f Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sat, 9 Apr 2016 16:56:03 +0000 Subject: [PATCH 630/976] shared-code-private-messages --- common/locales/en/api-v3.json | 1 + common/script/index.js | 6 ++ common/script/ops/blockUser.js | 26 +++++---- common/script/ops/clearPMs.js | 9 ++- common/script/ops/deletePM.js | 12 ++-- tasks/gulp-eslint.js | 3 - .../user/DELETE-user_messages.test.js | 27 +++++++++ .../integration/user/POST-user_block.test.js | 34 +++++++++++ test/common/ops/blockUser.test.js | 44 ++++++++++++++ test/common/ops/clearPMs.test.js | 20 +++++++ test/common/ops/deletePM.test.js | 20 +++++++ website/src/controllers/api-v3/user.js | 57 +++++++++++++++++++ 12 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 test/api/v3/integration/user/DELETE-user_messages.test.js create mode 100644 test/api/v3/integration/user/POST-user_block.test.js create mode 100644 test/common/ops/blockUser.test.js create mode 100644 test/common/ops/clearPMs.test.js create mode 100644 test/common/ops/deletePM.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 0eb2d4b7a3..ac83dd75f8 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -104,6 +104,7 @@ "spellNotFound": "Spell \"<%= spellId %>\" not found.", "partyNotFound": "Party not found", "targetIdUUID": "\"targetId\" must be a valid UUID.", + "invalidUUID": "UUID must be valid", "challengeTasksNoCast": "Casting a spell on challenge tasks is not supported.", "spellNotOwned": "You don't own this spell.", "spellLevelTooHigh": "You must be level <%= level %> to use this spell.", diff --git a/common/script/index.js b/common/script/index.js index 1c4bd65db6..a40a589260 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -115,6 +115,9 @@ import sell from './ops/sell'; import unlock from './ops/unlock'; import revive from './ops/revive'; import rebirth from './ops/rebirth'; +import blockUser from './ops/blockUser'; +import clearPMs from './ops/clearPMs'; +import deletePM from './ops/deletePM'; import reroll from './ops/reroll'; api.ops = { @@ -147,6 +150,9 @@ api.ops = { unlock, revive, rebirth, + blockUser, + clearPMs, + deletePM, reroll, }; diff --git a/common/script/ops/blockUser.js b/common/script/ops/blockUser.js index dd08925640..9663eb57e7 100644 --- a/common/script/ops/blockUser.js +++ b/common/script/ops/blockUser.js @@ -1,13 +1,19 @@ -module.exports = function(user, req, cb) { - var i; - i = user.inbox.blocks.indexOf(req.params.uuid); - if (~i) { - user.inbox.blocks.splice(i, 1); - } else { +import validator from 'validator'; +import i18n from '../../../common/script/i18n'; +import { + BadRequest, +} from '../libs/errors'; + +module.exports = function blockUser (user, req = {}) { + if (!validator.isUUID(req.params.uuid)) throw new BadRequest(i18n.t('invalidUUID', req.language)); + + let i = user.inbox.blocks.indexOf(req.params.uuid); + if (i === -1) { user.inbox.blocks.push(req.params.uuid); + } else { + user.inbox.blocks.splice(i, 1); } - if (typeof user.markModified === "function") { - user.markModified('inbox.blocks'); - } - return typeof cb === "function" ? cb(null, user.inbox.blocks) : void 0; + + user.markModified('inbox.blocks'); + return user.inbox.blocks; }; diff --git a/common/script/ops/clearPMs.js b/common/script/ops/clearPMs.js index 47e0a6ebc6..6a7eb91b39 100644 --- a/common/script/ops/clearPMs.js +++ b/common/script/ops/clearPMs.js @@ -1,7 +1,6 @@ -module.exports = function(user, req, cb) { + +module.exports = function clearPMs (user) { user.inbox.messages = {}; - if (typeof user.markModified === "function") { - user.markModified('inbox.messages'); - } - return typeof cb === "function" ? cb(null, user.inbox.messages) : void 0; + user.markModified('inbox.messages'); + return user.inbox.messages; }; diff --git a/common/script/ops/deletePM.js b/common/script/ops/deletePM.js index ad95bc9ae0..826cf9ee1a 100644 --- a/common/script/ops/deletePM.js +++ b/common/script/ops/deletePM.js @@ -1,7 +1,7 @@ -module.exports = function(user, req, cb) { - delete user.inbox.messages[req.params.id]; - if (typeof user.markModified === "function") { - user.markModified('inbox.messages.' + req.params.id); - } - return typeof cb === "function" ? cb(null, user.inbox.messages) : void 0; +import _ from 'lodash'; + +module.exports = function deletePM (user, req = {}) { + delete user.inbox.messages[_.get(req, 'params.id')]; + user.markModified(`inbox.messages.${req.params.id}`); + return user.inbox.messages; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index cad0d88e9c..2ca438f969 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -11,9 +11,6 @@ const COMMON_FILES = [ // @TODO remove these negations as the files are converted over. '!./common/script/content/index.js', '!./common/script/ops/addPushDevice.js', - '!./common/script/ops/blockUser.js', - '!./common/script/ops/clearPMs.js', - '!./common/script/ops/deletePM.js', '!./common/script/ops/reset.js', '!./common/script/fns/crit.js', '!./common/script/fns/randomDrop.js', diff --git a/test/api/v3/integration/user/DELETE-user_messages.test.js b/test/api/v3/integration/user/DELETE-user_messages.test.js new file mode 100644 index 0000000000..98df8e0209 --- /dev/null +++ b/test/api/v3/integration/user/DELETE-user_messages.test.js @@ -0,0 +1,27 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('DELETE user message', () => { + let user; + + beforeEach(async () => { + user = await generateUser({ inbox: { messages: { first: 'message', second: 'message' } } }); + expect(user.inbox.messages.first).to.eql('message'); + expect(user.inbox.messages.second).to.eql('message'); + }); + + it('one message', async () => { + let result = await user.del('/user/messages/first'); + await user.sync(); + expect(result).to.eql({ second: 'message' }); + expect(user.inbox.messages).to.eql({ second: 'message' }); + }); + + it('clear all', async () => { + let result = await user.del('/user/messages'); + await user.sync(); + expect(user.inbox.messages).to.eql({}); + expect(result).to.eql({}); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_block.test.js b/test/api/v3/integration/user/POST-user_block.test.js new file mode 100644 index 0000000000..51766eb51e --- /dev/null +++ b/test/api/v3/integration/user/POST-user_block.test.js @@ -0,0 +1,34 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('block user', () => { + let user; + let blockedUser; + let blockedUser2; + + beforeEach(async () => { + blockedUser = await generateUser(); + blockedUser2 = await generateUser(); + user = await generateUser({ inbox: { blocks: [blockedUser._id] } }); + expect(user.inbox.blocks.length).to.eql(1); + expect(user.inbox.blocks).to.eql([blockedUser._id]); + }); + + it('validates uuid', async () => { + await expect(user.post('/user/block/1')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidUUID'), + }); + }); + + it('successfully', async () => { + let response = await user.post(`/user/block/${blockedUser2._id}`); + await user.sync(); + expect(response).to.eql([blockedUser._id, blockedUser2._id]); + expect(user.inbox.blocks.length).to.eql(2); + expect(user.inbox.blocks).to.include(blockedUser2._id); + }); +}); diff --git a/test/common/ops/blockUser.test.js b/test/common/ops/blockUser.test.js new file mode 100644 index 0000000000..01b5185ed0 --- /dev/null +++ b/test/common/ops/blockUser.test.js @@ -0,0 +1,44 @@ +import blockUser from '../../../common/script/ops/blockUser'; +import { + generateUser, +} from '../../helpers/common.helper'; +import i18n from '../../../common/script/i18n'; + +describe('shared.ops.blockUser', () => { + let user; + let blockedUser; + let blockedUser2; + + beforeEach(() => { + blockedUser = generateUser(); + blockedUser2 = generateUser(); + user = generateUser(); + expect(user.inbox.blocks).to.eql([]); + }); + + it('validates uuid', (done) => { + try { + blockUser(user, { params: { uuid: 1 } }); + } catch (error) { + expect(error.message).to.eql(i18n.t('invalidUUID')); + done(); + } + }); + + it('blocks user', () => { + let result = blockUser(user, { params: { uuid: blockedUser._id } }); + expect(user.inbox.blocks).to.eql([blockedUser._id]); + expect(result).to.eql([blockedUser._id]); + result = blockUser(user, { params: { uuid: blockedUser2._id } }); + expect(user.inbox.blocks).to.eql([blockedUser._id, blockedUser2._id]); + expect(result).to.eql([blockedUser._id, blockedUser2._id]); + }); + + it('blocks, then unblocks user', () => { + blockUser(user, { params: { uuid: blockedUser._id } }); + expect(user.inbox.blocks).to.eql([blockedUser._id]); + let result = blockUser(user, { params: { uuid: blockedUser._id } }); + expect(user.inbox.blocks).to.eql([]); + expect(result).to.eql([]); + }); +}); diff --git a/test/common/ops/clearPMs.test.js b/test/common/ops/clearPMs.test.js new file mode 100644 index 0000000000..a2ff2a7a0b --- /dev/null +++ b/test/common/ops/clearPMs.test.js @@ -0,0 +1,20 @@ +import clearPMs from '../../../common/script/ops/clearPMs'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.clearPMs', () => { + let user; + + beforeEach(() => { + user = generateUser(); + user.inbox.messages = { first: 'message', second: 'message' }; + }); + + it('clears messages', () => { + expect(user.inbox.messages).to.not.eql({}); + let result = clearPMs(user); + expect(user.inbox.messages).to.eql({}); + expect(result).to.eql({}); + }); +}); diff --git a/test/common/ops/deletePM.test.js b/test/common/ops/deletePM.test.js new file mode 100644 index 0000000000..472bede6a3 --- /dev/null +++ b/test/common/ops/deletePM.test.js @@ -0,0 +1,20 @@ +import deletePM from '../../../common/script/ops/deletePM'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.clearPMs', () => { + let user; + + beforeEach(() => { + user = generateUser(); + user.inbox.messages = { first: 'message', second: 'message' }; + }); + + it('delete message', () => { + expect(user.inbox.messages).to.not.eql({ second: 'message' }); + let response = deletePM(user, { params: { id: 'first' } }); + expect(user.inbox.messages).to.eql({ second: 'message' }); + expect(response).to.eql({ second: 'message' }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index a6eb3e9b2c..c69c862bb5 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -974,6 +974,63 @@ api.userRebirth = { }, }; +/** + * @api {post} /user/block/:uuid blocks and unblocks a user + * @apiVersion 3.0.0 + * @apiName BlockUser + * @apiGroup User + * @apiSuccess {} +**/ +api.blockUser = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/block/:uuid', + async handler (req, res) { + let user = res.locals.user; + let blocks = common.ops.blockUser(user, req); + await user.save(); + res.respond(200, blocks); + }, +}; + +/** + * @api {delete} /user/messages/:id delete this message + * @apiVersion 3.0.0 + * @apiName deleteMessage + * @apiGroup User + * @apiSuccess {} +**/ +api.deleteMessage = { + method: 'DELETE', + middlewares: [authWithHeaders(), cron], + url: '/user/messages/:id', + async handler (req, res) { + let user = res.locals.user; + let messages = common.ops.deletePM(user, req); + await user.save(); + res.respond(200, messages); + }, +}; + +/** + * @api {delete} /user/messages delete all messages + * @apiVersion 3.0.0 + * @apiName clearMessages + * @apiGroup User + * @apiSuccess {} +**/ +api.clearMessages = { + method: 'DELETE', + middlewares: [authWithHeaders(), cron], + url: '/user/messages', + async handler (req, res) { + let user = res.locals.user; + let PMs = common.ops.clearPMs(user, req); + await user.save(); + res.respond(200, PMs); + }, +}; + /* * @api {post} /user/reroll Rerolls a user. * @apiVersion 3.0.0 From 3c784e869a35db04513af3f91c99bf022b0f4eec Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Sat, 9 Apr 2016 19:27:22 +0000 Subject: [PATCH 631/976] shared-code-percent --- common/script/libs/percent.js | 8 +-- common/script/libs/splitWhitespace.js | 3 +- common/script/libs/taskClasses.js | 55 +++++++--------- tasks/gulp-eslint.js | 3 - test/common/libs/percent.test.js | 19 ++++++ test/common/libs/splitWhitespace.test.js | 7 ++ test/common/libs/taskClasses.test.js | 82 ++++++++++++++++++++++++ 7 files changed, 136 insertions(+), 41 deletions(-) create mode 100644 test/common/libs/percent.test.js create mode 100644 test/common/libs/splitWhitespace.test.js create mode 100644 test/common/libs/taskClasses.test.js diff --git a/common/script/libs/percent.js b/common/script/libs/percent.js index e59132f582..d7622474dd 100644 --- a/common/script/libs/percent.js +++ b/common/script/libs/percent.js @@ -1,12 +1,12 @@ // TODO move to client -module.exports = function(x, y, dir) { - var roundFn; +module.exports = function percent (x, y, dir) { + let roundFn; switch (dir) { - case "up": + case 'up': roundFn = Math.ceil; break; - case "down": + case 'down': roundFn = Math.floor; break; default: diff --git a/common/script/libs/splitWhitespace.js b/common/script/libs/splitWhitespace.js index 1ef3d513aa..2f8276bcb3 100644 --- a/common/script/libs/splitWhitespace.js +++ b/common/script/libs/splitWhitespace.js @@ -1,3 +1,4 @@ -module.exports = function(s) { + +module.exports = function splitWhitespace (s) { return s.split(' '); }; diff --git a/common/script/libs/taskClasses.js b/common/script/libs/taskClasses.js index 21bed1ad63..0939c8b837 100644 --- a/common/script/libs/taskClasses.js +++ b/common/script/libs/taskClasses.js @@ -1,51 +1,40 @@ import { - shouldDo + shouldDo, } from '../cron'; + /* Task classes given everything about the class */ -module.exports = function(task, filters, dayStart, lastCron, showCompleted, main) { - var classes, completed, enabled, filter, priority, ref, repeat, type, value; - if (filters == null) { - filters = []; - } - if (dayStart == null) { - dayStart = 0; - } - if (lastCron == null) { - lastCron = +(new Date); - } - if (showCompleted == null) { - showCompleted = false; - } - if (main == null) { - main = false; - } +module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCron = Number(new Date()), showCompleted = false, main = false) { if (!task) { - return; + return ''; } - type = task.type, completed = task.completed, value = task.value, repeat = task.repeat, priority = task.priority; - if (main) { - if (!task._editing) { - for (filter in filters) { - enabled = filters[filter]; - if (enabled && !((ref = task.tags) != null ? ref[filter] : void 0)) { - return 'hidden'; - } + let type = task.type; + let classes = task.type; + let completed = task.completed; + let value = task.value; + let priority = task.priority; + + if (main && !task._editing) { + for (let filter in filters) { + let enabled = filters[filter]; + if (!task.tags) task.tags = {}; + if (enabled && !task.tags[filter]) { + return 'hidden'; } } } - classes = type; + classes = task.type; if (task._editing) { - classes += " beingEdited"; + classes += ' beingEdited'; } if (type === 'todo' || type === 'daily') { - if (completed || (type === 'daily' && !shouldDo(+(new Date), task, { - dayStart: dayStart + if (completed || (type === 'daily' && !shouldDo(Number(new Date()), task, { // eslint-disable-line no-extra-parens + dayStart, }))) { - classes += " completed"; + classes += ' completed'; } else { - classes += " uncompleted"; + classes += ' uncompleted'; } } else if (type === 'habit') { if (task.down && task.up) { diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index cad0d88e9c..fcf9422182 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -24,11 +24,8 @@ const COMMON_FILES = [ '!./common/script/libs/gold.js', '!./common/script/libs/newChatMessages.js', '!./common/script/libs/noTags.js', - '!./common/script/libs/percent.js', '!./common/script/libs/planGemLimits.js', '!./common/script/libs/silver.js', - '!./common/script/libs/splitWhitespace.js', - '!./common/script/libs/taskClasses.js', '!./common/script/public/**/*.js', ]; const TEST_FILES = [ diff --git a/test/common/libs/percent.test.js b/test/common/libs/percent.test.js new file mode 100644 index 0000000000..9d1ba31024 --- /dev/null +++ b/test/common/libs/percent.test.js @@ -0,0 +1,19 @@ +import percent from '../../../common/script/libs/percent'; + +describe('percent', () => { + it('with direction "up"', () => { + expect(percent(1, 10, 'up')).to.eql(10); + expect(percent(1, 20, 'up')).to.eql(5); + expect(percent(1.22, 10.99, 'up')).to.eql(12); + }); + + it('with direction "down"', () => { + expect(percent(1, 10, 'down')).to.eql(10); + expect(percent(1, 20, 'down')).to.eql(5); + expect(percent(1.22, 10.99, 'down')).to.eql(11); + }); + + it('with no direction', () => { + expect(percent(1.22, 10.99)).to.eql(11); + }); +}); diff --git a/test/common/libs/splitWhitespace.test.js b/test/common/libs/splitWhitespace.test.js new file mode 100644 index 0000000000..0b445d5a38 --- /dev/null +++ b/test/common/libs/splitWhitespace.test.js @@ -0,0 +1,7 @@ +import splitWhitespace from '../../../common/script/libs/splitWhitespace'; + +describe('splitWhitespace', () => { + it('returns an array', () => { + expect(splitWhitespace('a b')).to.eql(['a', 'b']); + }); +}); diff --git a/test/common/libs/taskClasses.test.js b/test/common/libs/taskClasses.test.js new file mode 100644 index 0000000000..338b861f02 --- /dev/null +++ b/test/common/libs/taskClasses.test.js @@ -0,0 +1,82 @@ +import taskClasses from '../../../common/script/libs/taskClasses'; + +describe('taskClasses', () => { + let task = {}; + let filters = {}; + let result; + + describe('a todo task', () => { + beforeEach(() => { + task = { type: 'todo', _editing: false, tags: { a: false } }; + }); + + it('is hidden', () => { + filters = { a: true }; + result = taskClasses(task, filters, 0, Number(new Date()), false, true); + expect(result).to.eql('hidden'); + }); + it('is beingEdited', () => { + task._editing = true; + result = taskClasses(task, filters); + expect(result.split(' ').indexOf('beingEdited')).to.not.eql(-1); + }); + it('is completed', () => { + task.completed = true; + result = taskClasses(task, filters); + expect(result.split(' ').indexOf('completed')).to.not.eql(-1); + task.completed = false; + result = taskClasses(task, filters); + expect(result.split(' ').indexOf('completed')).to.eql(-1); + expect(result.split(' ').indexOf('uncompleted')).to.not.eql(-1); + }); + }); + + describe('a daily task', () => { + it('is completed', () => { + task = { type: 'daily' }; + result = taskClasses(task); + expect(result.split(' ').indexOf('completed')).to.not.eql(-1); + }); + + it('is uncompleted'); // this requires stubbing the internal dependency shouldDo in taskClasses + }); + + describe('a habit', () => { + it('that is wide', () => { + task = { type: 'habit', up: true, down: true }; + result = taskClasses(task); + expect(result.split(' ').indexOf('habit-wide')).to.not.eql(-1); + }); + it('that is narrow', () => { + task = { type: 'habit' }; + result = taskClasses(task); + expect(result.split(' ').indexOf('habit-narrow')).to.not.eql(-1); + }); + }); + + describe('varies based on priority', () => { + it('trivial', () => { + task.priority = 0.1; + result = taskClasses(task); + expect(result.split(' ').indexOf('difficulty-trivial')).to.not.eql(-1); + }); + it('hard', () => { + task.priority = 2; + result = taskClasses(task); + expect(result.split(' ').indexOf('difficulty-hard')).to.not.eql(-1); + }); + }); + + describe('varies based on value', () => { + it('color-worst', () => { + task.value = -30; + result = taskClasses(task); + expect(result.split(' ').indexOf('color-worst')).to.not.eql(-1); + }); + it('color-neutral', () => { + task.value = 0; + result = taskClasses(task); + expect(result.split(' ').indexOf('color-neutral')).to.not.eql(-1); + }); + }); +}); From 42ef779b466967c757c7358735978047b3d6b526 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 10 Apr 2016 09:45:56 -0500 Subject: [PATCH 632/976] Ported addPushDevice. Added unit tests. Added addPushDevice route. Added integration tests --- common/locales/en/api-v3.json | 6 +- common/script/index.js | 2 + common/script/ops/addPushDevice.js | 45 ++++++++++---- tasks/gulp-eslint.js | 1 - .../user/POST-user_addPushDevice.test.js | 35 +++++++++++ test/common/ops/addPushDevice.js | 59 +++++++++++++++++++ website/src/controllers/api-v3/user.js | 22 +++++++ 7 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user_addPushDevice.test.js create mode 100644 test/common/ops/addPushDevice.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index ac83dd75f8..3f541da96a 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -164,5 +164,9 @@ "cannotRevive": "Cannot revive if not dead", "rebirthComplete": "You have been reborn!", "petNotOwned": "You do not own this pet.", - "rerollComplete": "Reroll complete!" + "rerollComplete": "Reroll complete!", + "resetComplete": "Reset has completed", + "regIdRequired": "RegId is required", + "pushDeviceAdded": "Push device added successfully", + "pushDeviceAlreadyAdded": "The user already has the push device" } diff --git a/common/script/index.js b/common/script/index.js index a40a589260..050e29d8ee 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -119,6 +119,7 @@ import blockUser from './ops/blockUser'; import clearPMs from './ops/clearPMs'; import deletePM from './ops/deletePM'; import reroll from './ops/reroll'; +import addPushDevice from './ops/addPushDevice'; api.ops = { scoreTask, @@ -154,6 +155,7 @@ api.ops = { clearPMs, deletePM, reroll, + addPushDevice, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/addPushDevice.js b/common/script/ops/addPushDevice.js index d96cf249cf..431a74177b 100644 --- a/common/script/ops/addPushDevice.js +++ b/common/script/ops/addPushDevice.js @@ -1,20 +1,43 @@ import _ from 'lodash'; +import i18n from '../i18n'; +import splitWhitespace from '../libs/splitWhitespace'; +import { + BadRequest, + NotAuthorized, +} from '../libs/errors'; + +module.exports = function addPushDevice (user, req = {}) { + let regId = _.get(req, 'body.regId'); + if (!regId) throw new BadRequest(i18n.t('regIdRequired', req.language)); + + let type = _.get(req, 'body.type'); + if (!type) throw new BadRequest(i18n.t('typeRequired', req.language)); -module.exports = function(user, req, cb) { - var i, item, pd; if (!user.pushDevices) { user.pushDevices = []; } - pd = user.pushDevices; - item = { - regId: req.body.regId, - type: req.body.type + + let pushDevices = user.pushDevices; + + let item = { + regId, + type, }; - i = _.findIndex(pd, { - regId: item.regId + + let indexOfPushDevice = _.findIndex(pushDevices, { + regId: item.regId, }); - if (i === -1) { - pd.push(item); + + if (indexOfPushDevice !== -1) { + throw new NotAuthorized(i18n.t('pushDeviceAlreadyAdded', req.language)); } - return typeof cb === "function" ? cb(null, user.pushDevices) : void 0; + + pushDevices.push(item); + + let response = { + data: _.pick(user, splitWhitespace('pushDevices')), + message: i18n.t('pushDeviceAdded', req.language), + }; + + return response; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index df34e18c68..0461636744 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -10,7 +10,6 @@ const COMMON_FILES = [ './common/script/**/*.js', // @TODO remove these negations as the files are converted over. '!./common/script/content/index.js', - '!./common/script/ops/addPushDevice.js', '!./common/script/ops/reset.js', '!./common/script/fns/crit.js', '!./common/script/fns/randomDrop.js', diff --git a/test/api/v3/integration/user/POST-user_addPushDevice.test.js b/test/api/v3/integration/user/POST-user_addPushDevice.test.js new file mode 100644 index 0000000000..1a3a5d4f03 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_addPushDevice.test.js @@ -0,0 +1,35 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/addPushDevice', () => { + let user; + let regId = '10'; + let type = 'someRandomType'; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error if user already has the push device', async () => { + await user.post('/user/addPushDevice', {type, regId}); + await expect(user.post('/user/addPushDevice', {type, regId})) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('pushDeviceAlreadyAdded'), + }); + }); + + // More tests in common code unit tests + + it('adds a push device to the user', async () => { + let response = await user.post('/user/addPushDevice', {type, regId}); + await user.sync(); + + expect(response.message).to.equal(t('pushDeviceAdded')); + expect(user.pushDevices[0].type).to.equal(type); + expect(user.pushDevices[0].regId).to.equal(regId); + }); +}); diff --git a/test/common/ops/addPushDevice.js b/test/common/ops/addPushDevice.js new file mode 100644 index 0000000000..854977b71d --- /dev/null +++ b/test/common/ops/addPushDevice.js @@ -0,0 +1,59 @@ +import addPushDevice from '../../../common/script/ops/addPushDevice'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import { + NotAuthorized, + BadRequest, +} from '../../../common/script/libs/errors'; + +describe('shared.ops.addPushDevice', () => { + let user; + let regId = '10'; + let type = 'someRandomType'; + + beforeEach(() => { + user = generateUser(); + user.stats.hp = 0; + }); + + it('returns an error when regId is not provided', (done) => { + try { + addPushDevice(user); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('regIdRequired')); + done(); + } + }); + + it('returns an error when type is not provided', (done) => { + try { + addPushDevice(user, {body: {regId}}); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('typeRequired')); + done(); + } + }); + + it('adds a push device', () => { + let response = addPushDevice(user, {body: {regId, type}}); + + expect(response.message).to.equal(i18n.t('pushDeviceAdded')); + expect(user.pushDevices[0].type).to.equal(type); + expect(user.pushDevices[0].regId).to.equal(regId); + }); + + it('does not a push device twice', (done) => { + try { + addPushDevice(user, {body: {regId, type}}); + addPushDevice(user, {body: {regId, type}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('pushDeviceAlreadyAdded')); + done(); + } + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index c69c862bb5..2bbe9c52ca 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1061,4 +1061,26 @@ api.userReroll = { }, }; +/* +* @api {post} /user/addPushDevice Adds a push device to a user. +* @apiVersion 3.0.0 +* @apiName UserAddPushDevice +* @apiGroup User +* +* @apiSuccess {Object} data `pushDevices` +*/ +api.userAddPushDevice = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/addPushDevice', + async handler (req, res) { + let user = res.locals.user; + + let addPushDeviceResponse = common.ops.addPushDevice(user, req); + await user.save(); + + res.respond(200, addPushDeviceResponse); + }, +}; + module.exports = api; From c6879aa5dfcd4baf82e582b71933a23d9df7b79c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 10 Apr 2016 18:42:59 +0200 Subject: [PATCH 633/976] fix(v3): cleanup of shared code --- common/script/ops/clearPMs.js | 1 - common/script/ops/scoreTask.js | 3 ++- common/script/ops/unlock.js | 2 +- website/src/controllers/api-v2/user.js | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/common/script/ops/clearPMs.js b/common/script/ops/clearPMs.js index 6a7eb91b39..5187354dfc 100644 --- a/common/script/ops/clearPMs.js +++ b/common/script/ops/clearPMs.js @@ -1,4 +1,3 @@ - module.exports = function clearPMs (user) { user.inbox.messages = {}; user.markModified('inbox.messages'); diff --git a/common/script/ops/scoreTask.js b/common/script/ops/scoreTask.js index f5ff5f115b..352e2bc330 100644 --- a/common/script/ops/scoreTask.js +++ b/common/script/ops/scoreTask.js @@ -3,6 +3,7 @@ import { NotAuthorized, } from '../libs/errors'; import i18n from '../i18n'; +import updateStats from '../fns/updateStats'; const MAX_TASK_VALUE = 21.27; const MIN_TASK_VALUE = -47.27; @@ -254,6 +255,6 @@ module.exports = function scoreTask (options = {}, req = {}) { } } - user.fns.updateStats(stats, req); + updateStats(user, stats, req); return delta; }; diff --git a/common/script/ops/unlock.js b/common/script/ops/unlock.js index 4bd24aeecc..74eb9fd8ac 100644 --- a/common/script/ops/unlock.js +++ b/common/script/ops/unlock.js @@ -28,7 +28,7 @@ module.exports = function unlock (user, req = {}, analytics) { cost = 0.5; } - let alreadyOwns = !isFullSet && user.fns.dotGet(`purchased.${path}`) === true; + let alreadyOwns = !isFullSet && _.get(user, `purchased.${path}`) === true; if ((!user.balance || user.balance < cost) && !alreadyOwns) { throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 7de680bf2c..f1aab61023 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -124,6 +124,8 @@ api.score = function(req, res, next) { task, direction, }, req); + // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) + if (direction === 'up') user.fns.randomDrop({task, delta}, req); asyncM.parallel({ task: task.save.bind(task), From 8ea05c3c46eaf435975a2f7d0d98f7d181c093c1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 10 Apr 2016 19:09:53 +0200 Subject: [PATCH 634/976] v3 adapt v2: fix a few bugs with batchUpdate and tasks --- website/src/controllers/api-v2/user.js | 24 +++++++++++++----------- website/src/models/task.js | 12 ++++++++++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index f1aab61023..f1b0cd9ecc 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -820,6 +820,7 @@ api.deleteTask = function(req, res, next) { api.updateTask = function(req, res, next) { var user = res.locals.user; + req.body = Tasks.Task.fromJSONV2(req.body); Tasks.Task.findOne({ _id: req.params.id, @@ -845,6 +846,7 @@ api.addTask = function(req, res, next) { var user = res.locals.user; req.body.type = req.body.type || 'habit'; req.body.text = req.body.text || 'text'; + req.body = Tasks.Task.fromJSONV2(req.body); var task = new Tasks[req.body.type](Tasks.Task.sanitizeCreate(req.body)); @@ -920,8 +922,8 @@ api.batchUpdate = function(req, res, next) { var oldJson = res.json; // Stash user.save, we'll queue the save op till the end (so we don't overload the server) - var oldSave = user.save; - user.save = function(cb){cb(null,user)} + //var oldSave = user.save; + //user.save = function(cb){cb(null,user)} // Setup the array of functions we're going to call in parallel with async res.locals.ops = []; @@ -943,13 +945,13 @@ api.batchUpdate = function(req, res, next) { }); }) // Finally, save user at the end - .concat(function(){ + .concat(/*function(){ user.save = oldSave; user.save(arguments[arguments.length-1]); - }); + }*/); // call all the operations, then return the user object to the requester - asyncM.waterfall(ops, function(err,_user) { + asyncM.waterfall(ops, function(err) { res.json = oldJson; res.send = oldSend; if (err) return next(err); @@ -957,14 +959,14 @@ api.batchUpdate = function(req, res, next) { var response; // return only drops & streaks - if (_user._tmp && _user._tmp.drop){ - response = _user.toJSON(); + if (user._tmp && user._tmp.drop){ + response = user.toJSON(); res.status(200).json({_tmp: {drop: response._tmp.drop}, _v: response._v}); // Fetch full user object } else if (res.locals.wasModified){ // Preen 3-day past-completed To-Dos from Angular & mobile app - _user.getTransformedData(function(err, transformedData){ + user.getTransformedData(function(err, transformedData){ if (err) next(err); response = transformedData; @@ -973,12 +975,12 @@ api.batchUpdate = function(req, res, next) { }); // return only the version number } else{ - response = _user.toJSON(); + response = user.toJSON(); res.status(200).json({_v: response._v}); } - user.fns.nullify(); - user = res.locals.user = oldSend = oldJson = oldSave = null; + //user.fns.nullify(); + user = res.locals.user = oldSend = oldJson = null; }); }; diff --git a/website/src/models/task.js b/website/src/models/task.js index 3d296eb973..354f8ed033 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -123,6 +123,18 @@ TaskSchema.methods.toJSONV2 = function toJSONV2 () { return toJSON; }; +TaskSchema.statics.fromJSONV2 = function toJSONV2 (taskObj) { + taskObj._id = taskObj.id; + + let v2Tags = taskObj.tags || {}; + + taskObj.tags = []; + taskObj.tags = _.map(v2Tags, (tag, key) => key) + + return taskObj; +}; + + // END of API v2 methods export let Task = mongoose.model('Task', TaskSchema); From ad3c8f0ad258e6de80597bf6d134bbf8debc078c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 10 Apr 2016 19:18:46 +0200 Subject: [PATCH 635/976] fix(lint): missing semicolon --- website/src/models/task.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/models/task.js b/website/src/models/task.js index 354f8ed033..f597f09230 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -129,7 +129,7 @@ TaskSchema.statics.fromJSONV2 = function toJSONV2 (taskObj) { let v2Tags = taskObj.tags || {}; taskObj.tags = []; - taskObj.tags = _.map(v2Tags, (tag, key) => key) + taskObj.tags = _.map(v2Tags, (tag, key) => key); return taskObj; }; From 01a2afc12f6330b80f75b44d5c28d46ca56f8fe1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 10 Apr 2016 19:26:32 +0200 Subject: [PATCH 636/976] v3 adapt v2: fix updating and deleting tags --- website/src/controllers/api-v2/user.js | 2 +- website/src/models/user.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index f1b0cd9ecc..bfe9662148 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -532,7 +532,7 @@ api.updateTag = function (req, res, next) { return res.status(404).json({err: i18n.t('messageTagNotFound', req.language)}); } - tag.name = req.body.tag; + tag.name = req.body.name; user.save(function (err, user) { if (err) return next(err); diff --git a/website/src/models/user.js b/website/src/models/user.js index d6d529ca9e..989af4a3eb 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -533,7 +533,7 @@ schema.plugin(baseModel, { 'invitations', 'balance', 'backer', 'contributor'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (plainObj, originalDoc) { - // doc.filters = {}; Not saved + plainObj.filters = {}; // TODO Not saved plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs TODO how to test? return plainObj; From 2f4f62f342cfcf565c22193a7e3d3b0e158895a7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 10 Apr 2016 19:51:05 +0200 Subject: [PATCH 637/976] return filters only for api v2 --- website/src/models/user.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 989af4a3eb..f01ffba265 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -533,7 +533,7 @@ schema.plugin(baseModel, { 'invitations', 'balance', 'backer', 'contributor'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (plainObj, originalDoc) { - plainObj.filters = {}; // TODO Not saved + // plainObj.filters = {}; TODO Not saved plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs TODO how to test? return plainObj; @@ -753,6 +753,7 @@ schema.methods.getTasks = function getUserTasks () { // Given user and an array of tasks, return an API compatible user + tasks obj schema.methods.addTasksToUser = function addTasksToUser (tasks) { let obj = this.toJSON(); + obj.filters = {}; obj.tags = obj.tags.map(tag => { return { From 020b7c5464f4d6456df348cc210628e8f0ffa5d0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 11 Apr 2016 17:54:37 +0200 Subject: [PATCH 638/976] v3 adapt v2: fix tasks creation --- website/src/models/task.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/models/task.js b/website/src/models/task.js index f597f09230..6bd581b53b 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -123,8 +123,8 @@ TaskSchema.methods.toJSONV2 = function toJSONV2 () { return toJSON; }; -TaskSchema.statics.fromJSONV2 = function toJSONV2 (taskObj) { - taskObj._id = taskObj.id; +TaskSchema.statics.fromJSONV2 = function fromJSONV2 (taskObj) { + if (taskObj.id) taskObj._id = taskObj.id; let v2Tags = taskObj.tags || {}; From 36594b668a5edf77a030e698ec02a09afee5e2ef Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 11 Apr 2016 18:05:54 +0200 Subject: [PATCH 639/976] fix(apidoc): apidoc now builds correctly --- tasks/gulp-apidoc.js | 2 +- website/src/controllers/api-v3/debug.js | 4 ++-- website/src/controllers/api-v3/members.js | 4 ++-- .../controllers/api-v3/{meta => }/modelsPaths.js | 0 website/src/controllers/api-v3/user.js | 16 ++++++++-------- 5 files changed, 13 insertions(+), 13 deletions(-) rename website/src/controllers/api-v3/{meta => }/modelsPaths.js (100%) diff --git a/tasks/gulp-apidoc.js b/tasks/gulp-apidoc.js index cb2777c254..a14c9df0c7 100644 --- a/tasks/gulp-apidoc.js +++ b/tasks/gulp-apidoc.js @@ -2,7 +2,7 @@ import gulp from 'gulp'; import clean from 'rimraf'; import apidoc from 'apidoc'; -const APIDOC_DEST_PATH = './website/public/apidoc'; +const APIDOC_DEST_PATH = './website/build/apidoc'; const APIDOC_SRC_PATH = './website/src'; gulp.task('apidoc:clean', (done) => { clean(APIDOC_DEST_PATH, done); diff --git a/website/src/controllers/api-v3/debug.js b/website/src/controllers/api-v3/debug.js index 1aa5b08ffb..b5bc63a45e 100644 --- a/website/src/controllers/api-v3/debug.js +++ b/website/src/controllers/api-v3/debug.js @@ -16,7 +16,7 @@ api.debug = { * @apiName AddTenGems * @apiGroup Development * - * @apiSuccess {} An empty Object + * @apiSuccess {Object} empty An empty Object */ api.addTenGems = { method: 'POST', @@ -38,7 +38,7 @@ api.addTenGems = { * @apiName AddHourglass * @apiGroup Development * - * @apiSuccess {} An empty Object + * @apiSuccess {Object} empty An empty Object */ api.addHourglass = { method: 'POST', diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 3dfca14c06..0123955a73 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -246,7 +246,7 @@ api.getChallengeMemberProgress = { * @apiParam {String} message The message * @apiParam {UUID} toUserId The toUser _id * - * @apiSuccess {} Object Returns an empty object + * @apiSuccess {Object} empty An empty Object */ api.sendPrivateMessage = { method: 'POST', @@ -295,7 +295,7 @@ api.sendPrivateMessage = { * @apiParam {String} message The message * @apiParam {UUID} toUserId The toUser _id * - * @apiSuccess {} Object Returns an empty object + * @apiSuccess {Object} empty An empty Object */ api.transferGems = { method: 'POST', diff --git a/website/src/controllers/api-v3/meta/modelsPaths.js b/website/src/controllers/api-v3/modelsPaths.js similarity index 100% rename from website/src/controllers/api-v3/meta/modelsPaths.js rename to website/src/controllers/api-v3/modelsPaths.js diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 2bbe9c52ca..ab2b0fa99e 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -182,7 +182,7 @@ api.updateUser = { * * @apiParam {string} password The user's password unless it's a Facebook account * - * @apiSuccess {} object An empty object + * @apiSuccess {Object} empty An empty Object */ api.deleteUser = { method: 'DELETE', @@ -309,7 +309,7 @@ const partyMembersFields = 'profile.name stats achievements items.special'; * @apiParam {string} spellId The spell to cast. * @apiParam {UUID} targetId Optional query parameter, the id of the target when casting a spell on a party member or a task. * - * @apiSuccess {Object|Array} mixed Will return the modified targets. For party members only the necessary fields will be populated. + * @apiSuccess mixed Will return the modified targets. For party members only the necessary fields will be populated. */ api.castSpell = { method: 'POST', @@ -773,7 +773,7 @@ api.userOpenMysteryItem = { * @apiVersion 3.0.0 * @apiName UserAddWebhook * @apiGroup User - * @apiSuccess {} + * @apiSuccess {Object} webhook The created webhook **/ api.addWebhook = { method: 'POST', @@ -792,7 +792,7 @@ api.addWebhook = { * @apiVersion 3.0.0 * @apiName UserUpdateWebhook * @apiGroup User - * @apiSuccess {} + * @apiSuccess {Object} webhook The updated webhook **/ api.updateWebhook = { method: 'PUT', @@ -811,7 +811,7 @@ api.updateWebhook = { * @apiVersion 3.0.0 * @apiName UserDeleteWebhook * @apiGroup User - * @apiSuccess {} + * @apiSuccess {Object} webhooks The user webhooks **/ api.deleteWebhook = { method: 'DELETE', @@ -979,7 +979,7 @@ api.userRebirth = { * @apiVersion 3.0.0 * @apiName BlockUser * @apiGroup User - * @apiSuccess {} + * @apiSuccess user.inbox.blocks **/ api.blockUser = { method: 'POST', @@ -998,7 +998,7 @@ api.blockUser = { * @apiVersion 3.0.0 * @apiName deleteMessage * @apiGroup User - * @apiSuccess {} + * @apiSuccess user.inbox.messages **/ api.deleteMessage = { method: 'DELETE', @@ -1017,7 +1017,7 @@ api.deleteMessage = { * @apiVersion 3.0.0 * @apiName clearMessages * @apiGroup User - * @apiSuccess {} + * @apiSuccess user.inbox.messages **/ api.clearMessages = { method: 'DELETE', From 27a2b9002d98ce4b906432ee47519119e29f5e8b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 11 Apr 2016 19:05:15 +0200 Subject: [PATCH 640/976] fix(gulp) make apidoc available in production --- gulpfile.js | 1 + 1 file changed, 1 insertion(+) diff --git a/gulpfile.js b/gulpfile.js index 0c399ce7fa..d7cd0d79c2 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -9,6 +9,7 @@ require('babel-register'); if (process.env.NODE_ENV === 'production') { + require('./tasks/gulp-apidoc'); require('./tasks/gulp-newstuff'); require('./tasks/gulp-build'); require('./tasks/gulp-babelify'); From 463ba81468aecfc7e4ad1b06b9709755a34868e4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 11 Apr 2016 19:46:48 +0200 Subject: [PATCH 641/976] fix(build) only require/import the main common file --- common/script/ops/blockUser.js | 2 +- common/script/ops/sell.js | 2 +- website/src/controllers/api-v3/quests.js | 3 ++- website/src/libs/api-v3/errors.js | 2 +- website/src/middlewares/api-v3/auth.js | 4 +++- website/src/middlewares/api-v3/cron.js | 7 +++---- website/src/models/group.js | 3 ++- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/common/script/ops/blockUser.js b/common/script/ops/blockUser.js index 9663eb57e7..3546d412ab 100644 --- a/common/script/ops/blockUser.js +++ b/common/script/ops/blockUser.js @@ -1,5 +1,5 @@ import validator from 'validator'; -import i18n from '../../../common/script/i18n'; +import i18n from '../i18n'; import { BadRequest, } from '../libs/errors'; diff --git a/common/script/ops/sell.js b/common/script/ops/sell.js index 7e4eee902f..fc3aa9632b 100644 --- a/common/script/ops/sell.js +++ b/common/script/ops/sell.js @@ -1,5 +1,5 @@ import content from '../content/index'; -import i18n from '../../../common/script/i18n'; +import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; import { diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 8ba5ca16d2..9c0cf44502 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -16,10 +16,11 @@ import { getUserInfo, sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; -import { quests as questScrolls } from '../../../../common/script/content'; import common from '../../../../common'; import sendPushNotification from '../../libs/api-v3/pushNotifications'; +const questScrolls = common.content.quests; + function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true // If any member is null/undefined (undecided) return false diff --git a/website/src/libs/api-v3/errors.js b/website/src/libs/api-v3/errors.js index 0667dafcd9..2b6d52bbe3 100644 --- a/website/src/libs/api-v3/errors.js +++ b/website/src/libs/api-v3/errors.js @@ -1,4 +1,4 @@ -import common from '../../../../common/script'; +import common from '../../../../common'; export const CustomError = common.errors.CustomError; diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index b3ed6d9485..ab948626cb 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -2,11 +2,13 @@ import { NotAuthorized, BadRequest, } from '../../libs/api-v3/errors'; -import i18n from '../../../../common/script/i18n'; +import common from '../../../../common'; import { model as User, } from '../../models/user'; +const i18n = common.i18n; + // Authenticate a request through the x-api-user and x-api key header // If optional is true, don't error on missing authentication export function authWithHeaders (optional = false) { diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 9a6ab40280..81f69c6d21 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -1,9 +1,5 @@ import _ from 'lodash'; import moment from 'moment'; -import { - daysSince, - shouldDo, -} from '../../../../common/script/cron'; import common from '../../../../common'; import * as Tasks from '../../models/task'; import Q from 'q'; @@ -11,6 +7,9 @@ import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; import { preenUserHistory } from '../../libs/api-v3/preening'; +const daysSince = common.daysSince; +const shouldDo = common.shouldDo; + const scoreTask = common.ops.scoreTask; let clearBuffs = { diff --git a/website/src/models/group.js b/website/src/models/group.js index cebebb1900..99a12ce908 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -12,11 +12,12 @@ import { InternalServerError } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; -import { quests as questScrolls } from '../../../common/script/content'; import Q from 'q'; import nconf from 'nconf'; import sendPushNotification from '../libs/api-v3/pushNotifications'; +const questScrolls = shared.content.quests; + let Schema = mongoose.Schema; // NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API From 26b718ba1ffd7889b9f620f7007228c192586f68 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 11 Apr 2016 20:03:44 +0200 Subject: [PATCH 642/976] fix(build) require babel-polyfill in production too --- website/src/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/src/index.js b/website/src/index.js index d34857267d..24dbc21384 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -4,9 +4,11 @@ // In production, the es6 code is pre-transpiled so it doesn't need it if (process.env.NODE_ENV !== 'production') { require('babel-register'); - require('babel-polyfill'); } +// The BabelJS polyfill is needed in production too +require('babel-polyfill'); + // Only do the minimal amount of work before forking just in case of a dyno restart const cluster = require('cluster'); const nconf = require('nconf'); From 2d69285080d8a230fc1f6400f42679e69cd3d412 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Mon, 11 Apr 2016 00:28:33 +0000 Subject: [PATCH 643/976] shared-code-crit --- common/script/fns/crit.js | 12 +++--------- tasks/gulp-eslint.js | 1 - test/common/fns/crit.test.js | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 test/common/fns/crit.test.js diff --git a/common/script/fns/crit.js b/common/script/fns/crit.js index 69ac9e5b93..07b144a805 100644 --- a/common/script/fns/crit.js +++ b/common/script/fns/crit.js @@ -1,12 +1,6 @@ -module.exports = function(user, stat, chance) { - var s; - if (stat == null) { - stat = 'str'; - } - if (chance == null) { - chance = .03; - } - s = user._statsComputed[stat]; + +module.exports = function crit (user, stat = 'str', chance = 0.03) { + let s = user._statsComputed[stat]; if (user.fns.predictableRandom() <= chance * (1 + s / 100)) { return 1.5 + 4 * s / (s + 200); } else { diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 0461636744..00fa57423d 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -11,7 +11,6 @@ const COMMON_FILES = [ // @TODO remove these negations as the files are converted over. '!./common/script/content/index.js', '!./common/script/ops/reset.js', - '!./common/script/fns/crit.js', '!./common/script/fns/randomDrop.js', '!./common/script/libs/appliedTags.js', '!./common/script/libs/countExists.js', diff --git a/test/common/fns/crit.test.js b/test/common/fns/crit.test.js new file mode 100644 index 0000000000..4f43c55fa1 --- /dev/null +++ b/test/common/fns/crit.test.js @@ -0,0 +1,17 @@ +import crit from '../../../common/script/fns/crit'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('crit', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('computes', () => { + let result = crit(user); + expect(result).to.eql(1); + }); +}); From 83850d7a0cd7e406024968b292a555d1865da5ae Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 11 Apr 2016 17:31:44 -0500 Subject: [PATCH 644/976] feat: build api doc after prod build --- package.json | 2 +- tasks/gulp-build.js | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 33279bd2ab..665d791ba1 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "push-notify": "^1.1.1", "q": "^1.4.1", "request": "~2.44.0", + "run-sequence": "^1.1.4", "s3-upload-stream": "^1.0.6", "serve-favicon": "^2.3.0", "stripe": "^4.2.0", @@ -149,7 +150,6 @@ "protractor": "^3.1.1", "rewire": "^2.3.3", "rimraf": "^2.4.3", - "run-sequence": "^1.1.4", "shelljs": "^0.5.3", "sinon": "^1.17.2", "sinon-chai": "^2.8.0", diff --git a/tasks/gulp-build.js b/tasks/gulp-build.js index 7d3244f1fa..923caad2c8 100644 --- a/tasks/gulp-build.js +++ b/tasks/gulp-build.js @@ -1,4 +1,5 @@ import gulp from 'gulp'; +import runSequence from 'run-sequence'; import babel from 'gulp-babel'; require('gulp-grunt')(gulp); @@ -32,6 +33,10 @@ gulp.task('build:dev:watch', ['build:dev'], () => { gulp.watch(['website/public/**/*.styl', 'common/script/*']); }); -gulp.task('build:prod', ['browserify', 'build:server', 'prepare:staticNewStuff', 'apidoc'], (done) => { - gulp.start('grunt-build:prod', done); +gulp.task('build:prod', ['browserify', 'build:server', 'prepare:staticNewStuff'], (done) => { + runSequence( + 'grunt-build:prod', + 'apidoc', + done + ); }); From a29dd1a1c794d284a0972890e813122355f1ba65 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Apr 2016 09:46:01 +0200 Subject: [PATCH 645/976] fix(build) temporarily use habitrpg-shared from habitica.com in v3 client --- website/public/manifest.json | 4 ---- website/views/index.jade | 1 + website/views/static/front.jade | 2 ++ 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/website/public/manifest.json b/website/public/manifest.json index c4d107c03e..4f722c5277 100644 --- a/website/public/manifest.json +++ b/website/public/manifest.json @@ -32,8 +32,6 @@ "bower_components/jquery-ui/ui/minified/jquery.ui.mouse.min.js", "bower_components/jquery-ui/ui/minified/jquery.ui.sortable.min.js", - "common/dist/scripts/habitrpg-shared.js", - "js/env.js", "js/app.js", @@ -110,7 +108,6 @@ "static": { "js": [ "bower_components/jquery/dist/jquery.min.js", - "common/dist/scripts/habitrpg-shared.js", "bower_components/angular/angular.js", "bower_components/angular-ui/build/angular-ui.js", "bower_components/angular-bootstrap/ui-bootstrap.js", @@ -143,7 +140,6 @@ "tmp_static_front": { "js": [ "bower_components/jquery/dist/jquery.min.js", - "common/dist/scripts/habitrpg-shared.js", "bower_components/angular/angular.js", "bower_components/angular-ui/build/angular-ui.js", "bower_components/jquery-colorbox/jquery.colorbox-min.js", diff --git a/website/views/index.jade b/website/views/index.jade index b1568d254a..c3dcdc08cb 100644 --- a/website/views/index.jade +++ b/website/views/index.jade @@ -20,6 +20,7 @@ html(ng-app="habitrpg", ng-controller="RootCtrl", ng-class='{"applying-action":a script(type='text/javascript'). window.env = !{JSON.stringify(env._.pick(env, env.clientVars))}; + script(type='text/javascript', src='https://habitica.com/common/dist/scripts/habitrpg-shared.js') != env.getManifestFiles("app") //webfonts diff --git a/website/views/static/front.jade b/website/views/static/front.jade index c1a2a5683e..ea810152f1 100644 --- a/website/views/static/front.jade +++ b/website/views/static/front.jade @@ -31,6 +31,8 @@ html(ng-app='habitrpg', ng-controller='RootCtrl') script(type='text/javascript'). window.env = !{JSON.stringify(env._.pick(env, env.clientVars))}; + + script(type='text/javascript', src='https://habitica.com/common/dist/scripts/habitrpg-shared.js') != env.getManifestFiles("tmp_static_front") script(type='text/javascript', src='https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/js/bootstrap.min.js') From 2f1329254e44d5c5086376237bd086b962c638f8 Mon Sep 17 00:00:00 2001 From: Victor Pudeyev Date: Tue, 12 Apr 2016 02:57:57 -0500 Subject: [PATCH 646/976] shared-code-tags-gold-silver (#7056) --- common/script/libs/appliedTags.js | 17 +++----- common/script/libs/gold.js | 4 +- common/script/libs/noTags.js | 4 +- common/script/libs/silver.js | 7 +-- tasks/gulp-eslint.js | 4 -- test/common/libs/appliedTags.test.js | 10 +++++ test/common/libs/gold.test.js | 11 +++++ test/common/libs/noTags.test.js | 13 ++++++ test/common/libs/silver.test.js | 19 +++++++++ test/common/libs/taskDefaults.test.js | 61 +++++++++++++++++++++++++++ 10 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 test/common/libs/appliedTags.test.js create mode 100644 test/common/libs/gold.test.js create mode 100644 test/common/libs/noTags.test.js create mode 100644 test/common/libs/silver.test.js create mode 100644 test/common/libs/taskDefaults.test.js diff --git a/common/script/libs/appliedTags.js b/common/script/libs/appliedTags.js index 513405bfeb..c65074033e 100644 --- a/common/script/libs/appliedTags.js +++ b/common/script/libs/appliedTags.js @@ -1,21 +1,14 @@ -import _ from 'lodash'; - /* Are there tags applied? */ // TODO move to client -module.exports = function(userTags, taskTags) { - var arr; - arr = []; - _.each(userTags, function(t) { - if (t == null) { - return; - } - if (taskTags != null ? taskTags[t.id] : void 0) { - return arr.push(t.name); - } +module.exports = function appliedTags (userTags, taskTags = {}) { + let arr = userTags.filter(tag => { + return taskTags[tag.id]; + }).map(tag => { + return tag.name; }); return arr.join(', '); }; diff --git a/common/script/libs/gold.js b/common/script/libs/gold.js index 7ce971aeec..83d9531d5e 100644 --- a/common/script/libs/gold.js +++ b/common/script/libs/gold.js @@ -1,9 +1,9 @@ // TODO move to client -module.exports = function(num) { +module.exports = function gold (num) { if (num) { return Math.floor(num); } else { - return "0"; + return '0'; } }; diff --git a/common/script/libs/noTags.js b/common/script/libs/noTags.js index fa47cd70d0..16a59d1d31 100644 --- a/common/script/libs/noTags.js +++ b/common/script/libs/noTags.js @@ -6,8 +6,8 @@ are any tags active? // TODO move to client -module.exports = function(tags) { - return _.isEmpty(tags) || _.isEmpty(_.filter(tags, function(t) { +module.exports = function noTags (tags) { + return _.isEmpty(tags) || _.isEmpty(_.filter(tags, (t) => { return t; })); }; diff --git a/common/script/libs/silver.js b/common/script/libs/silver.js index 5a2b8f98f8..1d3620f602 100644 --- a/common/script/libs/silver.js +++ b/common/script/libs/silver.js @@ -4,10 +4,11 @@ Silver amount from their money // TODO move to client -module.exports = function(num) { +module.exports = function silver (num) { if (num) { - return ("0" + Math.floor((num - Math.floor(num)) * 100)).slice(-2); + let centCount = Math.floor((num - Math.floor(num)) * 100); + return `0${centCount}`.slice(-2); } else { - return "00"; + return '00'; } }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 0461636744..5e0afc3357 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -13,15 +13,11 @@ const COMMON_FILES = [ '!./common/script/ops/reset.js', '!./common/script/fns/crit.js', '!./common/script/fns/randomDrop.js', - '!./common/script/libs/appliedTags.js', '!./common/script/libs/countExists.js', '!./common/script/libs/encodeiCalLink.js', '!./common/script/libs/friendlyTimestamp.js', - '!./common/script/libs/gold.js', '!./common/script/libs/newChatMessages.js', - '!./common/script/libs/noTags.js', '!./common/script/libs/planGemLimits.js', - '!./common/script/libs/silver.js', '!./common/script/public/**/*.js', ]; const TEST_FILES = [ diff --git a/test/common/libs/appliedTags.test.js b/test/common/libs/appliedTags.test.js new file mode 100644 index 0000000000..204d30f5bd --- /dev/null +++ b/test/common/libs/appliedTags.test.js @@ -0,0 +1,10 @@ +import appliedTags from '../../../common/script/libs/appliedTags'; + +describe('appliedTags', () => { + it('returns the tasks', () => { + let userTags = [{ id: 'tag1', name: 'tag 1' }, { id: 'tag2', name: 'tag 2' }, { id: 'tag3', name: 'tag 3' }]; + let taskTags = { tag2: true, tag3: true }; + let result = appliedTags(userTags, taskTags); + expect(result).to.eql('tag 2, tag 3'); + }); +}); diff --git a/test/common/libs/gold.test.js b/test/common/libs/gold.test.js new file mode 100644 index 0000000000..2cdfc3ef65 --- /dev/null +++ b/test/common/libs/gold.test.js @@ -0,0 +1,11 @@ +import gold from '../../../common/script/libs/gold'; + +describe('gold', () => { + it('is 0', () => { + expect(gold()).to.eql('0'); + }); + + it('is 5 in 5.2 of gold', () => { + expect(gold(5.2)).to.eql(5); + }); +}); diff --git a/test/common/libs/noTags.test.js b/test/common/libs/noTags.test.js new file mode 100644 index 0000000000..dcd2481854 --- /dev/null +++ b/test/common/libs/noTags.test.js @@ -0,0 +1,13 @@ +import noTags from '../../../common/script/libs/noTags'; + +describe('noTags', () => { + it('returns true for no tags', () => { + let result = noTags([]); + expect(result).to.eql(true); + }); + + it('returns false for some tags', () => { + let result = noTags(['a', 'b', 'c']); + expect(result).to.eql(false); + }); +}); diff --git a/test/common/libs/silver.test.js b/test/common/libs/silver.test.js new file mode 100644 index 0000000000..5bd614fcd1 --- /dev/null +++ b/test/common/libs/silver.test.js @@ -0,0 +1,19 @@ +import silver from '../../../common/script/libs/silver'; + +describe('silver', () => { + it('is 0', () => { + expect(silver(0)).to.eql('00'); + }); + + it('20 coins in 5.2 of gold: two decimal places', () => { + expect(silver(5.2)).to.eql('20'); + }); + + it('4 coint in 5.04 of gold: one decimal place', () => { + expect(silver(5.04)).to.eql('04'); + }); + + it('is no value', () => { + expect(silver()).to.eql('00'); + }); +}); diff --git a/test/common/libs/taskDefaults.test.js b/test/common/libs/taskDefaults.test.js new file mode 100644 index 0000000000..c970634137 --- /dev/null +++ b/test/common/libs/taskDefaults.test.js @@ -0,0 +1,61 @@ +import taskDefaults from '../../../common/script/libs/taskDefaults'; + +describe('taskDefaults', () => { + it('applies defaults to undefined type or habit', () => { + let task = taskDefaults(); + expect(task.type).to.eql('habit'); + expect(task._id).to.exist; + expect(task.text).to.eql(task._id); + expect(task.tags).to.eql([]); + expect(task.value).to.eql(0); + expect(task.priority).to.eql(1); + expect(task.up).to.eql(true); + expect(task.down).to.eql(true); + expect(task.history).to.eql([]); + }); + + it('applies defaults to a daily', () => { + let task = taskDefaults({ type: 'daily' }); + expect(task.type).to.eql('daily'); + expect(task._id).to.exist; + expect(task.text).to.eql(task._id); + expect(task.tags).to.eql([]); + expect(task.value).to.eql(0); + expect(task.priority).to.eql(1); + expect(task.history).to.eql([]); + expect(task.completed).to.eql(false); + expect(task.streak).to.eql(0); + expect(task.repeat).to.eql({ + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + su: true, + }); + expect(task.frequency).to.eql('weekly'); + expect(task.startDate).to.exist; + }); + + it('applies defaults a reward', () => { + let task = taskDefaults({ type: 'reward' }); + expect(task.type).to.eql('reward'); + expect(task._id).to.exist; + expect(task.text).to.eql(task._id); + expect(task.tags).to.eql([]); + expect(task.value).to.eql(10); + expect(task.priority).to.eql(1); + }); + + it('applies defaults a todo', () => { + let task = taskDefaults({ type: 'todo' }); + expect(task.type).to.eql('todo'); + expect(task._id).to.exist; + expect(task.text).to.eql(task._id); + expect(task.tags).to.eql([]); + expect(task.value).to.eql(0); + expect(task.priority).to.eql(1); + expect(task.completed).to.eql(false); + }); +}); From 40c9366a477a8f2458b244c072a5e81978b97d27 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Apr 2016 12:59:30 +0200 Subject: [PATCH 647/976] v3 adapt v2: misc fixes --- website/src/controllers/api-v2/auth.js | 1 + website/src/controllers/api-v2/challenges.js | 28 +++++++++++--------- website/src/controllers/api-v2/groups.js | 16 ++++++----- website/src/controllers/api-v2/user.js | 15 +++++++---- website/src/middlewares/api-v3/locals.js | 2 +- website/src/models/user.js | 2 ++ 6 files changed, 40 insertions(+), 24 deletions(-) diff --git a/website/src/controllers/api-v2/auth.js b/website/src/controllers/api-v2/auth.js index 6ad3f30412..d5b9aff9e2 100644 --- a/website/src/controllers/api-v2/auth.js +++ b/website/src/controllers/api-v2/auth.js @@ -132,6 +132,7 @@ api.registerUser = function(req, res, next) { analytics.track('register', analyticsData) user.save(function(err, savedUser){ + if (err) return cb(err); // Clean previous email preferences // TODO when emails added to EmailUnsubcription they should use lowercase version EmailUnsubscription.remove({email: savedUser.auth.local.email}, function(){ diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index a6b1a3ae48..877048d211 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -9,6 +9,7 @@ import { } from '../../models/user'; import { model as Group, + basicFields as basicGroupFields, } from '../../models/group'; import { model as Challenge, @@ -27,6 +28,8 @@ import v3MembersController from '../api-v3/members'; ------------------------------------------------------------------------ */ +var nameFields = 'profile.name'; + api.list = async function(req, res, next) { try { var user = res.locals.user; @@ -195,7 +198,7 @@ api.create = async function(req, res, next){ chalTasks = chalTasks.map(function(task) { var newTask = new Tasks[task.type](Tasks.Task.sanitizeCreate(task)); - newTask.challenge.id = chal._id; + newTask.challenge.id = challenge._id; return newTask.save(); }); @@ -251,9 +254,7 @@ api.update = function(req, res, next){ }, tasks: function(cb1) { // Convert to map of {id: task} so we can easily match them - var _beforeClonedTasks = _.cloneDeep(_before.tasks.map(function(t) { - return t.toObject(); - })); + var _beforeClonedTasks = _before.tasks; updatedTasks = _.object(_.pluck(_beforeClonedTasks, '_id'), _beforeClonedTasks); var newTasks = req.body.habits.concat(req.body.dailys) .concat(req.body.todos).concat(req.body.rewards); @@ -261,11 +262,11 @@ api.update = function(req, res, next){ var newTasksObj = _.object(_.pluck(newTasks, '_id'), newTasks); async.forEachOf(newTasksObj, function(newTask, taskId, cb2){ // some properties can't be changed - Tasks.Task.sanitize(newTask); + newTask = Tasks.Task.sanitize(newTask); // TODO we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? // TODO regarding comment above, make sure other models with nested fields are using this trick too - _.assign(updatedTasks[taskId], common.ops.updateTask(task.toObject(), {body: newTask})); - challenge.updateTask(updatedTasks[taskId]); + _.assign(updatedTasks[taskId], shared.ops.updateTask(updatedTasks[taskId].toObject(), {body: newTask})); + _before.chal.updateTask(updatedTasks[taskId]).then(cb2).catch(cb2); }, cb1); } }, cb); @@ -313,8 +314,8 @@ api.selectWinner = async function(req, res, next) { if (!req.query.uid) return res.status(401).json({err: 'Must select a winner'}); let challenge = await Challenge.findOne({_id: req.params.cid}).exec(); - if (!challenge) return next('Challenge ' + cid + ' not found'); - if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge')); + if (!challenge) return next('Challenge ' + req.params.cid + ' not found'); + if (!challenge.canModify(res.locals.user)) return next(shared.i18n.t('noPermissionCloseChallenge')); let winner = await User.findOne({_id: req.params.uid}).exec(); if (!winner || winner.challenges.indexOf(challenge._id) === -1) return next('Winner ' + req.query.uid + ' not found.'); @@ -386,29 +387,32 @@ api.leave = async function(req, res, next){ } } +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; + api.unlink = async function(req, res, next) { try { var user = res.locals.user; var tid = req.params.id; - var cid = user.tasks[tid].challenge.id; + var cid; if (!req.query.keep) return res.status(400).json({err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'}); let keep = req.query.keep; let task = await Tasks.Task.findOne({ - _id: taskId, + _id: tid, userId: user._id, }).exec(); if (!task) return next(shared.i18n.t('taskNotFound')); if (!task.challenge.id) return next(shared.i18n.t('cantOnlyUnlinkChalTask')); + cid = task.challenge.id; if (keep === 'keep') { task.challenge = {}; await task.save(); } else { // remove if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if - removeFromArray(user.tasksOrder[`${task.type}s`], taskId); + removeFromArray(user.tasksOrder[`${task.type}s`], tid); await Q.all([user.save(), task.remove()]); } else { await task.remove(); diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index 54b08985a1..fded19274e 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -838,7 +838,7 @@ api.removeMember = function(req, res, next){ // Sending an empty 204 because Group.update doesn't return the group // see http://mongoosejs.com/docs/api.html#model_Model.update - sendMessage(invited); + sendMessage(removedUser); group = uuid = null; return res.sendStatus(204); }); @@ -973,19 +973,23 @@ api.questAccept = function(req, res, next) { if (group.quest.key) return res.status(400).json({err: 'Your party is already on a quest. Try again when the current quest has ended.'}); if (!user.items.quests[key]) return res.status(400).json({err: "You don't own that quest scroll"}); + let members; + User.find({ 'party._id': group._id, _id: {$ne: user._id}, }).select('auth.facebook auth.local preferences.emailNotifications profile.name pushDevices') - .exec().then(members => { + .exec().then(membersF => { + members = membersF; + group.markModified('quest'); - group.quest.key = questKey; + group.quest.key = key; group.quest.leader = user._id; group.quest.members = {}; group.quest.members[user._id] = true; user.party.quest.RSVPNeeded = false; - user.party.quest.key = questKey; + user.party.quest.key = key; return User.update({ 'party._id': group._id, @@ -993,7 +997,7 @@ api.questAccept = function(req, res, next) { }, { $set: { 'party.quest.RSVPNeeded': true, - 'party.quest.key': questKey, + 'party.quest.key': key, }, }, {multi: true}).exec(); }).then(() => { @@ -1117,7 +1121,7 @@ api.questCancel = function(req, res, next){ Q.all([ group.save(), User.update( - {'party._id': groupId}, + {'party._id': group._id}, {$set: {'party.quest': Group.cleanQuestProgress()}}, {multi: true} ), diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index bfe9662148..2f343c43f6 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -7,6 +7,9 @@ var shared = require('../../../../common'); import { model as User, } from '../../models/user'; +import { + NotFound, +} from '../../libs/api-v3/errors'; import { model as Tag } from '../../models/tag'; import * as Tasks from '../../models/task'; import Q from 'q'; @@ -32,6 +35,8 @@ var api = module.exports; var firebase = require('../../libs/api-v2/firebase'); var webhook = require('../../libs/api-v2/webhook'); +const partyMembersFields = 'profile.name stats achievements items.special'; + // api.purchase // Shared.ops api.getContent = function(req, res, next) { @@ -610,8 +615,8 @@ api.cast = async function(req, res, next) { let spellId = req.params.spellId; let targetId = req.query.targetId; - let klass = common.content.spells.special[spellId] ? 'special' : user.stats.class; - let spell = common.content.spells[klass][spellId]; + let klass = shared.content.spells.special[spellId] ? 'special' : user.stats.class; + let spell = shared.content.spells[klass][spellId]; if (!spell) return res.status(404).json({err: 'Spell "' + req.params.spell + '" not found.'}); if (spell.mana > user.stats.mp) return res.status(400).json({err: 'Not enough mana to cast spell'}); @@ -893,13 +898,13 @@ _.each(shared.ops, function(op,k){ // If we want to send something other than 500, pass err as {code: 200, message: "Not enough GP"} res.locals.user.save(function(err){ if (err) return next(err); - if (response === user) { // add tasks - user.getTransformedData(function (err, transformedUser) { + if (opResponse === res.locals.user) { // add tasks + res.locals.user.getTransformedData(function (err, transformedUser) { if (err) return next(err); res.status(200).json(transformedUser); }); } else { - res.status(200).json(response); + res.status(200).json(opResponse); } }); } diff --git a/website/src/middlewares/api-v3/locals.js b/website/src/middlewares/api-v3/locals.js index 31e41c44f0..6630902b75 100644 --- a/website/src/middlewares/api-v3/locals.js +++ b/website/src/middlewares/api-v3/locals.js @@ -26,7 +26,7 @@ let env = { mods, Content: shared.content, siteVersion: forceRefresh.siteVersion, - availableLanguages: i18n.available, + availableLanguages: i18n.availableLanguages, AMAZON_PAYMENTS: { SELLER_ID: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), CLIENT_ID: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), diff --git a/website/src/models/user.js b/website/src/models/user.js index f01ffba265..403134bfd4 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -753,6 +753,8 @@ schema.methods.getTasks = function getUserTasks () { // Given user and an array of tasks, return an API compatible user + tasks obj schema.methods.addTasksToUser = function addTasksToUser (tasks) { let obj = this.toJSON(); + + obj.id = obj._id; obj.filters = {}; obj.tags = obj.tags.map(tag => { From 3970064fe50e3f4f2bc86f5a80c5b20c5a202b8b Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 12 Apr 2016 11:16:54 -0500 Subject: [PATCH 648/976] Ported reset. Added unit tests. Added reset route. Added integration tests --- common/locales/en/api-v3.json | 3 +- common/script/{ops => fns}/resetGear.js | 0 common/script/index.js | 2 + common/script/ops/rebirth.js | 2 +- common/script/ops/reset.js | 47 ++++---- tasks/gulp-eslint.js | 1 - .../integration/user/POST-user_reset.test.js | 104 ++++++++++++++++++ test/common/ops/reset.js | 79 +++++++++++++ website/src/controllers/api-v3/user.js | 25 +++++ website/src/models/user.js | 1 - 10 files changed, 233 insertions(+), 31 deletions(-) rename common/script/{ops => fns}/resetGear.js (100%) create mode 100644 test/api/v3/integration/user/POST-user_reset.test.js create mode 100644 test/common/ops/reset.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 3f541da96a..3bd4f194cd 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -168,5 +168,6 @@ "resetComplete": "Reset has completed", "regIdRequired": "RegId is required", "pushDeviceAdded": "Push device added successfully", - "pushDeviceAlreadyAdded": "The user already has the push device" + "pushDeviceAlreadyAdded": "The user already has the push device", + "resetComplete": "Reset has completed" } diff --git a/common/script/ops/resetGear.js b/common/script/fns/resetGear.js similarity index 100% rename from common/script/ops/resetGear.js rename to common/script/fns/resetGear.js diff --git a/common/script/index.js b/common/script/index.js index 050e29d8ee..0d734f8ba2 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -120,6 +120,7 @@ import clearPMs from './ops/clearPMs'; import deletePM from './ops/deletePM'; import reroll from './ops/reroll'; import addPushDevice from './ops/addPushDevice'; +import reset from './ops/reset'; api.ops = { scoreTask, @@ -156,6 +157,7 @@ api.ops = { deletePM, reroll, addPushDevice, + reset, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/rebirth.js b/common/script/ops/rebirth.js index e287323d48..bf29e64d67 100644 --- a/common/script/ops/rebirth.js +++ b/common/script/ops/rebirth.js @@ -5,7 +5,7 @@ import { MAX_LEVEL } from '../constants'; import { NotAuthorized, } from '../libs/errors'; -import resetGear from './resetGear'; +import resetGear from '../fns/resetGear'; import equip from './equip'; const USERSTATSLIST = ['per', 'int', 'con', 'str', 'points', 'gp', 'exp', 'mp']; diff --git a/common/script/ops/reset.js b/common/script/ops/reset.js index fbe87729e3..525127087d 100644 --- a/common/script/ops/reset.js +++ b/common/script/ops/reset.js @@ -1,35 +1,28 @@ -import _ from 'lodash'; +import resetGear from '../fns/resetGear'; +import i18n from '../i18n'; -module.exports = function(user, req, cb) { - var gear; - user.habits = []; - user.dailys = []; - user.todos = []; - user.rewards = []; +module.exports = function reset (user, tasks = []) { user.stats.hp = 50; user.stats.lvl = 1; user.stats.gp = 0; user.stats.exp = 0; - gear = user.items.gear; - _.each(['equipped', 'costume'], function(type) { - gear[type].armor = 'armor_base_0'; - gear[type].weapon = 'weapon_base_0'; - gear[type].head = 'head_base_0'; - return gear[type].shield = 'shield_base_0'; - }); - if (typeof gear.owned === 'undefined') { - gear.owned = {}; - } - _.each(gear.owned, function(v, k) { - if (gear.owned[k]) { - gear.owned[k] = false; + + let tasksToRemove = []; + tasks.forEach(task => { + if (!task.challenge || !task.challenge.id || task.challenge.broken) { + tasksToRemove.push(task._id); + let i = user.tasksOrder[`${task.type}s`].indexOf(task._id); + if (i !== -1) user.tasksOrder[`${task.type}s`].splice(i, 1); + tasksToRemove.push(task._id); } - return true; }); - gear.owned.weapon_warrior_0 = true; - if (typeof user.markModified === "function") { - user.markModified('items.gear.owned'); - } - user.preferences.costume = false; - return typeof cb === "function" ? cb(null, user) : void 0; + + resetGear(user); + + let response = { + data: {user, tasksToRemove}, + message: i18n.t('resetComplete'), + }; + + return response; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 30ec320fb8..624240b406 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -10,7 +10,6 @@ const COMMON_FILES = [ './common/script/**/*.js', // @TODO remove these negations as the files are converted over. '!./common/script/content/index.js', - '!./common/script/ops/reset.js', '!./common/script/fns/randomDrop.js', '!./common/script/libs/countExists.js', '!./common/script/libs/encodeiCalLink.js', diff --git a/test/api/v3/integration/user/POST-user_reset.test.js b/test/api/v3/integration/user/POST-user_reset.test.js new file mode 100644 index 0000000000..2baf7bd083 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_reset.test.js @@ -0,0 +1,104 @@ +import { + generateUser, + generateGroup, + generateChallenge, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/reset', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('resets user\'s habits', async () => { + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + }); + + await user.post('/user/reset'); + await user.sync(); + + await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + + expect(user.tasksOrder.habits).to.be.empty; + }); + + it('resets user\'s dailys', async () => { + let task = await user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + }); + + await user.post('/user/reset'); + await user.sync(); + + await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + + expect(user.tasksOrder.dailys).to.be.empty; + }); + + it('resets user\'s todos', async () => { + let task = await user.post('/tasks/user', { + text: 'test todo', + type: 'todo', + }); + + await user.post('/user/reset'); + await user.sync(); + + await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + + expect(user.tasksOrder.todos).to.be.empty; + }); + + it('resets user\'s rewards', async () => { + let task = await user.post('/tasks/user', { + text: 'test reward', + type: 'reward', + }); + + await user.post('/user/reset'); + await user.sync(); + + await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + + expect(user.tasksOrder.rewards).to.be.empty; + }); + + it('does not delete challenge tasks', async () => { + let guild = await generateGroup(user); + let challenge = await generateChallenge(user, guild); + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test challenge habit', + type: 'habit', + }); + + await user.post('/user/reset'); + await user.sync(); + + let userChallengeTask = await user.get(`/tasks/${task._id}`); + + expect(userChallengeTask).to.eql(task); + }); +}); diff --git a/test/common/ops/reset.js b/test/common/ops/reset.js new file mode 100644 index 0000000000..2243cfa127 --- /dev/null +++ b/test/common/ops/reset.js @@ -0,0 +1,79 @@ +import reset from '../../../common/script/ops/reset'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, + generateDaily, + generateHabit, + generateReward, + generateTodo, +} from '../../helpers/common.helper'; + +describe('shared.ops.reset', () => { + let user; + let tasksToRemove; + + beforeEach(() => { + user = generateUser(); + user.balance = 2; + + let habit = generateHabit(); + let todo = generateTodo(); + let daily = generateDaily(); + let reward = generateReward(); + + user.tasksOrder.habits = [habit._id]; + user.tasksOrder.todos = [todo._id]; + user.tasksOrder.dailys = [daily._id]; + user.tasksOrder.rewards = [reward._id]; + + tasksToRemove = [habit, todo, daily, reward]; + }); + + + it('resets a user', () => { + let response = reset(user); + + expect(response.message).to.equal(i18n.t('resetComplete')); + }); + + it('resets user\'s health', () => { + user.stats.hp = 40; + + reset(user); + + expect(user.stats.hp).to.equal(50); + }); + + it('resets user\'s level', () => { + user.stats.lvl = 2; + + reset(user); + + expect(user.stats.lvl).to.equal(1); + }); + + it('resets user\'s gold', () => { + user.stats.gp = 20; + + reset(user); + + expect(user.stats.gp).to.equal(0); + }); + + it('resets user\'s exp', () => { + user.stats.exp = 20; + + reset(user); + + expect(user.stats.exp).to.equal(0); + }); + + it('resets user\'s tasksOrder', () => { + reset(user, tasksToRemove); + + expect(user.tasksOrder.habits).to.be.empty; + expect(user.tasksOrder.todos).to.be.empty; + expect(user.tasksOrder.dailys).to.be.empty; + expect(user.tasksOrder.rewards).to.be.empty; + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index ab2b0fa99e..af1fb94ee4 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1083,4 +1083,29 @@ api.userAddPushDevice = { }, }; +/* +* @api {post} /user/reset Resets a user. +* @apiVersion 3.0.0 +* @apiName UserReset +* @apiGroup User +* +* @apiSuccess {Object} data `user` +*/ +api.userReset = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/reset', + async handler (req, res) { + let user = res.locals.user; + + let tasks = await Tasks.Task.find({userId: user._id}).select('_id type challenge').exec(); + + let resetResponse = common.ops.reset(user, tasks); + + await Q.all([Tasks.Task.remove({_id: {$in: resetResponse.data.tasksToRemove}, userId: user._id}), user.save()]); + + res.respond(200, resetResponse); + }, +}; + module.exports = api; diff --git a/website/src/models/user.js b/website/src/models/user.js index 403134bfd4..cd420fd5b7 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -808,7 +808,6 @@ schema.methods.getTransformedData = function getTransformedData (cb) { }; // END of API v2 methods - export let model = mongoose.model('User', schema); // Initially export an empty object so external requires will get From 7de93cdd128c47c059903ea7ded7f16aaac53292 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Apr 2016 18:43:20 +0200 Subject: [PATCH 649/976] apidoc: change url to testing heroku app --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 665d791ba1..892ec56599 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,7 @@ "name": "habitica", "title": "Habitica", "version": "3.0.0", - "url": "https://habitica.com/api/v3", - "sampleUrl": "https://habitica.com/api/v3" + "url": "https://habitica-v3.herokuapp.com/api/v3", + "sampleUrl": "https://habitica-v3.herokuapp.com/api/v3" } } From 004b032084bdbef4a7cbbd9de73cd8e0f0a9cc4f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Apr 2016 18:50:02 +0200 Subject: [PATCH 650/976] adapt reset to v2 and remove deleted files from gulpfile --- common/script/ops/reset.js | 8 ++++++-- tasks/gulp-eslint.js | 5 ----- website/src/controllers/api-v2/user.js | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/common/script/ops/reset.js b/common/script/ops/reset.js index 525127087d..1e59ccf02a 100644 --- a/common/script/ops/reset.js +++ b/common/script/ops/reset.js @@ -1,7 +1,7 @@ import resetGear from '../fns/resetGear'; import i18n from '../i18n'; -module.exports = function reset (user, tasks = []) { +module.exports = function reset (user, tasks = [], req = {}) { user.stats.hp = 50; user.stats.lvl = 1; user.stats.gp = 0; @@ -24,5 +24,9 @@ module.exports = function reset (user, tasks = []) { message: i18n.t('resetComplete'), }; - return response; + if (req.v2 === true) { + return user; + } else { + return response; + } }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 624240b406..9f91a09096 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -11,11 +11,6 @@ const COMMON_FILES = [ // @TODO remove these negations as the files are converted over. '!./common/script/content/index.js', '!./common/script/fns/randomDrop.js', - '!./common/script/libs/countExists.js', - '!./common/script/libs/encodeiCalLink.js', - '!./common/script/libs/friendlyTimestamp.js', - '!./common/script/libs/newChatMessages.js', - '!./common/script/libs/planGemLimits.js', '!./common/script/public/**/*.js', ]; const TEST_FILES = [ diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 2f343c43f6..6b0bc72acc 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -878,7 +878,7 @@ _.each(shared.ops, function(op,k){ if (['rebirth', 'reroll', 'reset'].indexOf(k) !== -1) { // proxy ops that change tasks directly to v3 if (k === 'rebirth') k = 'userRebirth'; // the name is different in v3 if (k === 'reroll') k = 'userReroll'; - // if (k === 'reset') k = 'resetUser'; + if (k === 'reset') k = 'userReset'; api[k] = function (req, res, next) { req.v2 = true; From 6458796a3615c19d9aebfba8b8d0a35af46c5454 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Apr 2016 19:30:39 +0200 Subject: [PATCH 651/976] v3: first review of common code and task models --- common/script/fns/dotGet.js | 2 +- common/script/fns/dotSet.js | 2 +- common/script/index.js | 11 ------ common/script/libs/dotGet.js | 2 +- common/script/libs/dotSet.js | 2 +- common/script/libs/preenTodos.js | 3 +- common/script/libs/taskDefaults.js | 3 +- common/script/ops/scoreTask.js | 2 +- common/script/ops/unlock.js | 9 +++-- .../integration/tasks/POST-tasks_user.test.js | 2 ++ .../PUT-tasks_challenge_challengeId.test.js | 6 ++-- website/src/controllers/api-v3/tasks.js | 5 ++- website/src/libs/api-v3/baseModel.js | 2 +- website/src/models/task.js | 34 +++++++++++-------- 14 files changed, 40 insertions(+), 45 deletions(-) diff --git a/common/script/fns/dotGet.js b/common/script/fns/dotGet.js index 0988fec191..3b45e54c71 100644 --- a/common/script/fns/dotGet.js +++ b/common/script/fns/dotGet.js @@ -1,6 +1,6 @@ import _ from 'lodash'; -// TODO remove completely, use _.get +// TODO remove completely, use _.get, only used in client module.exports = function dotGet (user, path) { return _.get(user, path); diff --git a/common/script/fns/dotSet.js b/common/script/fns/dotSet.js index f6ade2d0e0..ceb21605af 100644 --- a/common/script/fns/dotSet.js +++ b/common/script/fns/dotSet.js @@ -7,7 +7,7 @@ import _ from 'lodash'; Angular sets object properties directly - in which case, this function will be used. */ -// TODO use directly _.set and remove this fn +// TODO use directly _.set and remove this fn, only used in client module.exports = function dotSet (user, path, val) { return _.set(user, path, val); diff --git a/common/script/index.js b/common/script/index.js index 0d734f8ba2..91c0a021ca 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -221,7 +221,6 @@ api.wrap = function wrapUser (user, main = true) { user._wrapped = true; // Make markModified available on the client side as a noop function - // TODO move to client? if (!user.markModified) { user.markModified = function noopMarkModified () {}; } @@ -305,14 +304,4 @@ api.wrap = function wrapUser (user, main = true) { return computed; }, }); - - if (typeof window !== 'undefined') { - // TODO kept for compatibility with the client that relies on v2, remove once the client is adapted - Object.defineProperty(user, 'tasks', { - get () { - let tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); - return _.object(_.pluck(tasks, 'id'), tasks); - }, - }); - } }; diff --git a/common/script/libs/dotGet.js b/common/script/libs/dotGet.js index bd7605154d..d8ce026b82 100644 --- a/common/script/libs/dotGet.js +++ b/common/script/libs/dotGet.js @@ -1,5 +1,5 @@ import _ from 'lodash'; -// TODO remove completely +// TODO remove completely, only used in client module.exports = _.get; diff --git a/common/script/libs/dotSet.js b/common/script/libs/dotSet.js index 29d7d559e0..b664c54d05 100644 --- a/common/script/libs/dotSet.js +++ b/common/script/libs/dotSet.js @@ -1,5 +1,5 @@ import _ from 'lodash'; -// TODO remove completely +// TODO remove completely, only used in client module.exports = _.set; diff --git a/common/script/libs/preenTodos.js b/common/script/libs/preenTodos.js index 1fad4a6c1c..fcf8775d5f 100644 --- a/common/script/libs/preenTodos.js +++ b/common/script/libs/preenTodos.js @@ -1,8 +1,7 @@ import moment from 'moment'; import _ from 'lodash'; -// TODO used only in v2 client -// TODO test +// TODO used only in v2 module.exports = function preenTodos (tasks) { return _.filter(tasks, (t) => { diff --git a/common/script/libs/taskDefaults.js b/common/script/libs/taskDefaults.js index 04fcba3f48..c3077eb95d 100644 --- a/common/script/libs/taskDefaults.js +++ b/common/script/libs/taskDefaults.js @@ -6,7 +6,6 @@ import moment from 'moment'; // sending up to the server for performance // TODO move to client code? -// TODO test? const tasksTypes = ['habit', 'daily', 'todo', 'reward']; @@ -17,7 +16,7 @@ module.exports = function taskDefaults (task = {}) { let defaultId = uuid(); let defaults = { - _id: defaultId, // TODO convert all occurencies of id to _id + _id: defaultId, text: task._id || defaultId, notes: '', tags: [], diff --git a/common/script/ops/scoreTask.js b/common/script/ops/scoreTask.js index 352e2bc330..ddaf0a155c 100644 --- a/common/script/ops/scoreTask.js +++ b/common/script/ops/scoreTask.js @@ -197,7 +197,7 @@ module.exports = function scoreTask (options = {}, req = {}) { // Add history entry, even more than 1 per day task.history.push({ - date: Number(new Date()), // TODO are we going to cast history entries? + date: Number(new Date()), value: task.value, }); } else if (task.type === 'daily') { diff --git a/common/script/ops/unlock.js b/common/script/ops/unlock.js index 74eb9fd8ac..08ec175dcc 100644 --- a/common/script/ops/unlock.js +++ b/common/script/ops/unlock.js @@ -1,7 +1,6 @@ import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; -import dotSet from '../libs/dotSet'; import { NotAuthorized, BadRequest, @@ -37,11 +36,11 @@ module.exports = function unlock (user, req = {}, analytics) { if (isFullSet) { _.each(path.split(','), function markItemsAsPurchased (pathPart) { if (path.indexOf('gear.') !== -1) { - dotSet(user, pathPart, true); + _.set(user, pathPart, true); return true; } - dotSet(user, `purchased.${pathPart}`, true); + _.set(user, `purchased.${pathPart}`, true); return true; }); } else { @@ -52,11 +51,11 @@ module.exports = function unlock (user, req = {}, analytics) { if (key === 'background' && value === user.preferences.background) { value = ''; } - dotSet(user, `preferences.${key}`, value); + _.set(user, `preferences.${key}`, value); throw new NotAuthorized(i18n.t('alreadyUnlocked', req.language)); } - dotSet(user, `purchased.${path}`, true); + _.set(user, `purchased.${path}`, true); } if (path.indexOf('gear.') === -1) { diff --git a/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js index 99d2896e3b..9834a3968d 100644 --- a/test/api/v3/integration/tasks/POST-tasks_user.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -121,6 +121,7 @@ describe('POST /tasks/user', () => { completed: true, streak: 25, dateCompleted: 'never', + value: 324, // ignored because not a reward }); expect(task.userId).to.equal(user._id); @@ -131,6 +132,7 @@ describe('POST /tasks/user', () => { expect(task.completed).to.equal(false); expect(task.streak).to.equal(0); expect(task.streak).not.to.equal('never'); + expect(task.value).not.to.equal(324); }); it('ignores invalid fields', async () => { diff --git a/test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js b/test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js index 99b368de00..88343cb47c 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js @@ -80,6 +80,7 @@ describe('PUT /tasks/:id', () => { completed: true, streak: 25, dateCompleted: 'never', + value: 324, // ignored because not a reward }); expect(savedTask._id).to.equal(task._id); @@ -92,6 +93,7 @@ describe('PUT /tasks/:id', () => { expect(savedTask.completed).to.equal(task.completed); expect(savedTask.streak).to.equal(task.streak); expect(savedTask.dateCompleted).to.equal(task.dateCompleted); + expect(savedTask.value).to.equal(task.value); }); it('ignores invalid fields', async () => { @@ -302,12 +304,12 @@ describe('PUT /tasks/:id', () => { let savedReward = await user.put(`/tasks/${reward._id}`, { text: 'some new text', notes: 'some new notes', - value: 10, + value: 11, }); expect(savedReward.text).to.eql('some new text'); expect(savedReward.notes).to.eql('some new notes'); - expect(savedReward.value).to.eql(10); + expect(savedReward.value).to.eql(11); }); it('requires value to be coerced into a number', async () => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 16c0457bbf..479b7a0521 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -25,7 +25,7 @@ async function _createTasks (req, res, user, challenge) { if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); let taskType = taskData.type; - let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); + let newTask = new Tasks[taskType](Tasks.Task.sanitize(taskData)); if (challenge) { newTask.challenge.id = challenge.id; @@ -304,10 +304,9 @@ api.updateTask = { throw new NotFound(res.t('taskNotFound')); } - Tasks.Task.sanitize(req.body); // TODO we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? // TODO regarding comment above, make sure other models with nested fields are using this trick too - _.assign(task, common.ops.updateTask(task.toObject(), req)); + _.assign(task, Tasks.Task.sanitize(common.ops.updateTask(task.toObject(), req))); // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() // see https://github.com/Automattic/mongoose/issues/2749 diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index 878bebfa14..71ea879423 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -8,7 +8,7 @@ module.exports = function baseModel (schema, options = {}) { _id: { type: String, default: uuid, - validate: [validator.isUUID, 'Invalid uuid.'], // TODO check for UUID version + validate: [validator.isUUID, 'Invalid uuid.'], }, }); diff --git a/website/src/models/task.js b/website/src/models/task.js index 6bd581b53b..79e617a77d 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -25,7 +25,15 @@ export let TaskSchema = new Schema({ validate: [validator.isUUID, 'Invalid uuid.'], }], value: {type: Number, default: 0, required: true}, // redness or cost for rewards Required because it must be settable (for rewards) - priority: {type: Number, default: 1, required: true}, // TODO enum? + priority: { + type: Number, + default: 1, + required: true, + validate: [ + (val) => [0.1, 1, 1.5, 2].indexOf(val) !== -1, + 'Valid priority values are 0.1, 1, 1.5, 2.', + ], + }, attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, userId: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set it belongs to a challenge @@ -33,7 +41,7 @@ export let TaskSchema = new Schema({ id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task TODO unique index? broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, - winner: String, // user.profile.name TODO necessary? + winner: String, // user.profile.name of the winner }, reminders: [{ @@ -47,19 +55,18 @@ export let TaskSchema = new Schema({ }, discriminatorOptions)); TaskSchema.plugin(baseModel, { - // TODO checklist fields editable? - // TODO value should be settable only for rewards - noSet: ['challenge', 'userId', 'completed', 'history', 'streak', 'dateCompleted'], + noSet: ['challenge', 'userId', 'completed', 'history', 'streak', 'dateCompleted', 'completed'], + sanitizeTransform (taskObj) { + if (taskObj.type !== 'reward') { // value should be settable directly only for rewards + delete taskObj.value; + } + + return taskObj; + }, private: [], timestamps: true, }); -// A list of additional fields that cannot be set on creation (but can be set on updare) -let noCreate = ['completed']; // TODO completed should be removed for updates too? -TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) { - return this.sanitize(createObj, noCreate); -}; - // Sanitize checklist objects (disallowing _id) TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) { delete checklistObj._id; @@ -68,7 +75,7 @@ TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) // Sanitize reminder objects (disallowing id) TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) { - delete reminderObj.id; + delete reminderObj.id; // TODO convert to _id? return reminderObj; }; @@ -188,8 +195,7 @@ export let daily = Task.discriminator('daily', DailySchema); export let TodoSchema = new Schema(_.defaults({ dateCompleted: Date, - // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date - // TODO change field name + // TODO we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date date: String, // due date for todos }, dailyTodoSchema()), subDiscriminatorOptions); export let todo = Task.discriminator('todo', TodoSchema); From 793ce38f6b5bce58fd29ed3f46c392e05505a79d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Apr 2016 20:14:36 +0200 Subject: [PATCH 652/976] v3: review some more files, add logging for unhandled promises --- test/api/v3/unit/models/challenge.test.js | 2 +- test/api/v3/unit/models/task.test.js | 2 +- website/src/libs/api-v3/analyticsService.js | 1 - website/src/libs/api-v3/encryption.js | 3 +-- website/src/libs/api-v3/i18n.js | 12 ------------ website/src/libs/api-v3/logger.js | 11 +++++++++++ website/src/libs/api-v3/pushNotifications.js | 5 ++--- website/src/libs/api-v3/setupPassport.js | 2 +- website/src/middlewares/api-v3/auth.js | 1 - website/src/middlewares/api-v3/domain.js | 3 --- website/src/middlewares/api-v3/errorHandler.js | 2 -- website/src/middlewares/api-v3/index.js | 2 +- website/src/middlewares/api-v3/locals.js | 1 - website/src/models/challenge.js | 8 ++++---- website/src/models/group.js | 6 ++---- website/src/models/tag.js | 2 +- website/src/models/task.js | 2 +- website/src/models/user.js | 16 +++++++--------- 18 files changed, 33 insertions(+), 48 deletions(-) diff --git a/test/api/v3/unit/models/challenge.test.js b/test/api/v3/unit/models/challenge.test.js index 152e7a5111..e3577a0e2a 100644 --- a/test/api/v3/unit/models/challenge.test.js +++ b/test/api/v3/unit/models/challenge.test.js @@ -62,7 +62,7 @@ describe('Challenge Model', () => { each(tasksToTest, (taskValue, taskType) => { context(`${taskType}`, () => { beforeEach(async() => { - task = new Tasks[`${taskType}`](Tasks.Task.sanitizeCreate(taskValue)); + task = new Tasks[`${taskType}`](Tasks.Task.sanitize(taskValue)); task.challenge.id = challenge._id; await task.save(); }); diff --git a/test/api/v3/unit/models/task.test.js b/test/api/v3/unit/models/task.test.js index 5cef0fb10f..8a889a2145 100644 --- a/test/api/v3/unit/models/task.test.js +++ b/test/api/v3/unit/models/task.test.js @@ -54,7 +54,7 @@ describe('Task Model', () => { each(tasksToTest, (taskValue, taskType) => { context(`${taskType}`, () => { beforeEach(async() => { - task = new Tasks[`${taskType}`](Tasks.Task.sanitizeCreate(taskValue)); + task = new Tasks[`${taskType}`](Tasks.Task.sanitize(taskValue)); task.challenge.id = challenge._id; task.history = generateHistory(396); await task.save(); diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js index ae3ec9f507..4979327b48 100644 --- a/website/src/libs/api-v3/analyticsService.js +++ b/website/src/libs/api-v3/analyticsService.js @@ -210,7 +210,6 @@ let _sendPurchaseDataToGoogle = (data) => { }); }; -// TODO log errors... function track (eventType, data) { return Q.all([ _sendDataToAmplitude(eventType, data), diff --git a/website/src/libs/api-v3/encryption.js b/website/src/libs/api-v3/encryption.js index 0f5f9d83dd..390c59234e 100644 --- a/website/src/libs/api-v3/encryption.js +++ b/website/src/libs/api-v3/encryption.js @@ -4,7 +4,6 @@ import { } from 'crypto'; import nconf from 'nconf'; -// TODO check this is secure const algorithm = 'aes-256-ctr'; const SESSION_SECRET = nconf.get('SESSION_SECRET'); @@ -22,4 +21,4 @@ export function decrypt (text) { dec += decipher.final('utf8'); return dec; -} \ No newline at end of file +} diff --git a/website/src/libs/api-v3/i18n.js b/website/src/libs/api-v3/i18n.js index acb9751082..0486cce3f6 100644 --- a/website/src/libs/api-v3/i18n.js +++ b/website/src/libs/api-v3/i18n.js @@ -104,15 +104,3 @@ export let multipleVersionsLanguages = { 'zh-tw': 'zh_TW', }, }; - -// Export en strings only, temporary solution for mobile -// This is copied from middlewares/locals#t() -// TODO review if this can be removed since the old mobile app is no longer active -// stringName and vars are the allowed parameters -export function enTranslations (...args) { - let language = _.find(availableLanguages, {code: 'en'}); - - // language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined); - args.push(language.code); - return shared.i18n.t(...args); -} diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 993ee44e9d..82df8b4827 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -20,4 +20,15 @@ if (IS_PROD) { }); } +// Logs unhandled promises errors +// when no catch is attached to a promise a unhandledRejection event will be triggered +process.on('unhandledRejection', function handlePromiseRejection (reason, promise) { + let stack = reason.stack || reason.message || reason; + + logger.error(stack, { + promise, + fullError: reason, + }); +}); + module.exports = logger; diff --git a/website/src/libs/api-v3/pushNotifications.js b/website/src/libs/api-v3/pushNotifications.js index edd10c3d2f..426c86f5c3 100644 --- a/website/src/libs/api-v3/pushNotifications.js +++ b/website/src/libs/api-v3/pushNotifications.js @@ -9,7 +9,8 @@ let gcm = GCM_API_KEY ? pushNotify.gcm({ retries: 3, }) : undefined; -// TODO log +// TODO review and test this file when push notifications are added back + if (gcm) { gcm.on('transmitted', (/* result, message, registrationId */) => { // console.info("transmitted", result, message, registrationId); @@ -24,7 +25,6 @@ if (gcm) { }); } -// TODO test module.exports = function sendNotification (user, title, message, timeToLive = 15) { // TODO need investigation: // https://github.com/HabitRPG/habitrpg/issues/5252 @@ -50,7 +50,6 @@ module.exports = function sendNotification (user, title, message, timeToLive = 1 break; case 'ios': - // TODO implement break; } }); diff --git a/website/src/libs/api-v3/setupPassport.js b/website/src/libs/api-v3/setupPassport.js index ea5c55a4c2..dd9fdeaaa2 100644 --- a/website/src/libs/api-v3/setupPassport.js +++ b/website/src/libs/api-v3/setupPassport.js @@ -14,7 +14,7 @@ const FacebookStrategy = passportFacebook.Strategy; passport.serializeUser((user, done) => done(null, user)); passport.deserializeUser((obj, done) => done(null, obj)); -// TODO +// TODO remove? // This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) // The proper fix would be to move to a general OAuth module simply to verify accessTokens passport.use(new FacebookStrategy({ diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index ab948626cb..7f0fab0e07 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -40,7 +40,6 @@ export function authWithHeaders (optional = false) { } // Authenticate a request through a valid session -// TODO should use json web token export function authWithSession (req, res, next) { let userId = req.session.userId; diff --git a/website/src/middlewares/api-v3/domain.js b/website/src/middlewares/api-v3/domain.js index fe9d6cd462..fb5e28d352 100644 --- a/website/src/middlewares/api-v3/domain.js +++ b/website/src/middlewares/api-v3/domain.js @@ -1,6 +1,3 @@ -// TODO in api-v2 this module also checked memory usage every x minutes and -// threw an error in case of low memory avalible (possible memory leak) -// it's yet to be decided whether to keep it or not import domainMiddleware from 'domain-middleware'; module.exports = function implementDomainMiddleware (server, mongoose) { diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 401da18627..f3f7aa5401 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -32,8 +32,6 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable responseErr.message = err.message; } - // TODO make mongoose and express-validator errors more recognizable - // Handle errors by express-validator if (Array.isArray(err) && err[0].param && err[0].msg) { responseErr = new BadRequest(res.t('invalidReqParams')); diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 7fd6c7a259..bc764997b4 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -49,7 +49,7 @@ module.exports = function attachMiddlewares (app, server) { extended: true, // Uses 'qs' library as old connect middleware })); app.use(bodyParser.json()); - app.use(methodOverride()); // TODO still needed in 2016? + app.use(methodOverride()); app.use(cookieSession({ name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies diff --git a/website/src/middlewares/api-v3/locals.js b/website/src/middlewares/api-v3/locals.js index 6630902b75..e6e06e3b16 100644 --- a/website/src/middlewares/api-v3/locals.js +++ b/website/src/middlewares/api-v3/locals.js @@ -12,7 +12,6 @@ import { mods } from '../../models/user'; // To avoid stringifying more data then we need, // items from `env` used on the client will have to be specified in this array -// TODO where is this used? const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations', 'FACEBOOK_KEY', 'NODE_ENV', 'BASE_URL', 'GA_ID', 'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY', diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index f19b6da9a7..4e6826c4f7 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -114,7 +114,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) { let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); if (!matchingTask) { // If the task is new, create it - matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask))); matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; matchingTask.userId = user._id; user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); @@ -152,13 +152,14 @@ schema.methods.addTasks = async function challengeAddTasks (tasks) { let membersIds = await _fetchMembersIds(challenge._id); // Sync each user sequentially + // TODO are we sure it's the best solution? for (let memberId of membersIds) { let updateTasksOrderQ = {$push: {}}; let toSave = []; - // TODO eslint complaints about ahving a function inside a loop -> make sure it works + // TODO eslint complaints about having a function inside a loop -> make sure it works tasks.forEach(chalTask => { // eslint-disable-line no-loop-func - let userTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + let userTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask))); userTask.challenge = {taskId: chalTask._id, id: challenge._id}; userTask.userId = memberId; @@ -192,7 +193,6 @@ schema.methods.updateTask = async function challengeUpdateTask (task) { updateCmd.$set[key] = syncableAttrs[key]; } - // TODO reveiw // Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks await Tasks.Task.update({ userId: {$exists: true}, diff --git a/website/src/models/group.js b/website/src/models/group.js index 99a12ce908..4afc114527 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -212,13 +212,13 @@ schema.methods.removeGroupInvitations = async function removeGroupInvitations () let group = this; let usersToRemoveInvitationsFrom = await User.find({ - // TODO id -> _id ? [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id, }).exec(); let userUpdates = usersToRemoveInvitationsFrom.map(user => { if (group.type === 'party') { - user.invitations.party = {}; // TODO mark modified + user.invitations.party = {}; + this.markModified('invitations.party'); } else { removeFromArray(user.invitations.guilds, { id: group._id }); } @@ -395,7 +395,6 @@ function _cleanQuestProgress (merge) { return clean; } -// TODO move to User.cleanQuestProgress? schema.statics.cleanQuestProgress = _cleanQuestProgress; // returns a clean object for group.quest @@ -619,7 +618,6 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) { _.assign(tavernQuest, tavern.quest.toObject()); return tavern.save(); } - // TODO catch }; schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { diff --git a/website/src/models/tag.js b/website/src/models/tag.js index 78c1b7884e..dadc84efc2 100644 --- a/website/src/models/tag.js +++ b/website/src/models/tag.js @@ -5,7 +5,7 @@ let Schema = mongoose.Schema; export let schema = new Schema({ name: {type: String, required: true}, - challenge: {type: String}, // TODO validate + challenge: {type: String}, }, { minimize: true, // So empty objects are returned strict: true, diff --git a/website/src/models/task.js b/website/src/models/task.js index 79e617a77d..100f8ebe3b 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -57,7 +57,7 @@ export let TaskSchema = new Schema({ TaskSchema.plugin(baseModel, { noSet: ['challenge', 'userId', 'completed', 'history', 'streak', 'dateCompleted', 'completed'], sanitizeTransform (taskObj) { - if (taskObj.type !== 'reward') { // value should be settable directly only for rewards + if (taskObj.type && taskObj.type !== 'reward') { // value should be settable directly only for rewards delete taskObj.value; } diff --git a/website/src/models/user.js b/website/src/models/user.js index cd420fd5b7..0086167f2e 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -49,7 +49,6 @@ export let schema = new Schema({ // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which // have been updated (http://goo.gl/gQLz41), but we want *every* update _v: { type: Number, default: 0 }, - // TODO give all this a default of 0? achievements: { originalUser: Boolean, habitSurveys: Number, @@ -99,8 +98,11 @@ export let schema = new Schema({ contributor: { // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801 - // TODO validate - level: Number, + level: { + type: Number, + min: 0, + max: 9, + }, admin: Boolean, sudo: Boolean, // Artisan, Friend, Blacksmith, etc @@ -119,7 +121,6 @@ export let schema = new Schema({ purchased: { ads: {type: Boolean, default: false}, // eg, {skeleton: true, pumpkin: true, eb052b: true} - // TODO dictionary skin: {type: Schema.Types.Mixed, default: () => { return {}; }}, @@ -421,7 +422,7 @@ export let schema = new Schema({ displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true}, webhooks: {type: Schema.Types.Mixed, default: () => { return {}; - }}, // TODO array? and proper controller... unless VersionError becomes problematic + }}, // For the following fields make sure to use strict comparison when searching for falsey values (=== false) // As users who didn't login after these were introduced may have them undefined/null emailNotifications: { @@ -541,7 +542,6 @@ schema.plugin(baseModel, { }); // A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private) -// TODO is all party data meant to be public? export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt preferences.costume preferences.sleep preferences.background profile stats achievements party backer contributor auth.timestamps items`; @@ -823,6 +823,4 @@ mongoose.model('User') .then((foundMods) => { // Using push to maintain the reference to mods mods.push(...foundMods); - }, (err) => { // TODO replace with .catch which for some reason was throwing an error - throw err; // TODO ? - }); + }); // In case of failure we don't want this to crash the whole server From 28d8c370c3437dab6dbd79ed2cf58060b0e413de Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Apr 2016 23:53:55 +0200 Subject: [PATCH 653/976] v3 adapt v2: remove references to sanitizeCreate and fix tests --- test/api/v2/user/tasks/PUT-tasks_id.test.js | 4 ++-- website/src/controllers/api-v2/challenges.js | 2 +- website/src/controllers/api-v2/user.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/api/v2/user/tasks/PUT-tasks_id.test.js b/test/api/v2/user/tasks/PUT-tasks_id.test.js index 037322a6b7..3bb9d8e9c6 100644 --- a/test/api/v2/user/tasks/PUT-tasks_id.test.js +++ b/test/api/v2/user/tasks/PUT-tasks_id.test.js @@ -33,13 +33,13 @@ describe('PUT /user/tasks/:id', () => { text: 'new text', notes: 'new notes', value: 10000, - priority: 0.5, + priority: 0.1, attribute: 'str', }).then((updatedTask) => { expect(updatedTask.text).to.eql('new text'); expect(updatedTask.notes).to.eql('new notes'); expect(updatedTask.value).to.eql(10000); - expect(updatedTask.priority).to.eql(0.5); + expect(updatedTask.priority).to.eql(0.1); expect(updatedTask.attribute).to.eql('str'); }); }); diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 877048d211..f20bf96ad8 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -197,7 +197,7 @@ api.create = async function(req, res, next){ .concat(req.body.dailys).concat(req.body.todos); chalTasks = chalTasks.map(function(task) { - var newTask = new Tasks[task.type](Tasks.Task.sanitizeCreate(task)); + var newTask = new Tasks[task.type](Tasks.Task.sanitize(task)); newTask.challenge.id = challenge._id; return newTask.save(); }); diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 6b0bc72acc..613e663e1e 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -853,7 +853,7 @@ api.addTask = function(req, res, next) { req.body.text = req.body.text || 'text'; req.body = Tasks.Task.fromJSONV2(req.body); - var task = new Tasks[req.body.type](Tasks.Task.sanitizeCreate(req.body)); + var task = new Tasks[req.body.type](Tasks.Task.sanitize(req.body)); task.userId = user._id; user.tasksOrder[task.type + 's'].unshift(task._id); From 8817f795b1f1a8ecb62941df47141a6376b23e18 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Apr 2016 18:18:00 +0200 Subject: [PATCH 654/976] v3: expose public interface for logger and add tests --- test/api/v3/unit/libs/logger.js | 58 +++++++++++++++++++ website/src/controllers/api-v3/auth.js | 5 +- website/src/controllers/api-v3/challenges.js | 1 - website/src/controllers/api-v3/content.js | 3 +- website/src/controllers/api-v3/tasks.js | 4 +- website/src/libs/api-v3/email.js | 6 +- website/src/libs/api-v3/logger.js | 28 ++++++++- website/src/libs/api-v3/webhook.js | 3 +- website/src/middlewares/api-v3/cron.js | 2 +- .../src/middlewares/api-v3/errorHandler.js | 6 +- website/src/models/group.js | 2 +- 11 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 test/api/v3/unit/libs/logger.js diff --git a/test/api/v3/unit/libs/logger.js b/test/api/v3/unit/libs/logger.js new file mode 100644 index 0000000000..c274897377 --- /dev/null +++ b/test/api/v3/unit/libs/logger.js @@ -0,0 +1,58 @@ +import winston from 'winston'; + +/* eslint-disable global-require */ +describe('logger', () => { + let pathToLoggerLib = '../../../../../website/src/libs/api-v3/logger'; + let infoSpy; + let errorSpy; + + beforeEach(() => { + delete require.cache[require.resolve(pathToLoggerLib)]; + + infoSpy = sandbox.stub(); + errorSpy = sandbox.stub(); + sandbox.stub(winston, 'Logger').returns({ + info: infoSpy, + error: errorSpy, + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('info', () => { + let attachLogger = require(pathToLoggerLib); + attachLogger.info(1, 2, 3); + expect(infoSpy).to.be.calledOnce; + expect(infoSpy).to.be.calledWith(1, 2, 3); + }); + + describe('error', () => { + it('with custom arguments', () => { + let attachLogger = require(pathToLoggerLib); + attachLogger.error(1, 2, 3, 4); + expect(errorSpy).to.be.calledOnce; + expect(errorSpy).to.be.calledWith(1, 2, 3, 4); + }); + + it('with error', () => { + let attachLogger = require(pathToLoggerLib); + let errInstance = new Error('An error.'); + attachLogger.error(errInstance, { + data: 1, + }, 2, 3); + expect(errorSpy).to.be.calledOnce; + // using calledWith doesn't work + let lastCallArgs = errorSpy.lastCall.args; + + expect(lastCallArgs[3]).to.equal(3); + expect(lastCallArgs[2]).to.equal(2); + expect(lastCallArgs[1]).to.eql({ + data: 1, + fullError: errInstance, + }); + expect(lastCallArgs[0]).to.eql(errInstance.stack); + }); + }); +}); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index f0fe4755eb..c2faf298a9 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -14,6 +14,7 @@ import { } from '../../libs/api-v3/errors'; import Q from 'q'; import * as passwordUtils from '../../libs/api-v3/password'; +import logger from '../../libs/api-v3/logger'; import { model as User } from '../../models/user'; import { model as Group } from '../../models/group'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; @@ -33,7 +34,7 @@ async function _handleGroupInvitation (user, invite) { // check that the invite has not expired (after 7 days) if (sentAt && moment().subtract(7, 'days').isAfter(sentAt)) { - let err = new Error('Invite expired'); + let err = new Error('Invite expired.'); err.privateData = invite; throw err; } @@ -47,7 +48,7 @@ async function _handleGroupInvitation (user, invite) { user.invitations.guilds.push({id: group._id, name: group.name, inviter}); } } catch (err) { - // TODO log errors + logger.error(err); } } diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 404ac488d9..ecaad87eb1 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -499,7 +499,6 @@ export async function _closeChal (challenge, broken = {}) { ]; Q.allSettled(backgroundTasks); // TODO look if allSettled could be useful somewhere else - // TODO catch and handle } /** diff --git a/website/src/controllers/api-v3/content.js b/website/src/controllers/api-v3/content.js index 6f249bd27d..f824325573 100644 --- a/website/src/controllers/api-v3/content.js +++ b/website/src/controllers/api-v3/content.js @@ -4,6 +4,7 @@ import { langCodes } from '../../libs/api-v3/i18n'; import Q from 'q'; import fsCallback from 'fs'; import path from 'path'; +import logger from '../../libs/api-v3/logger'; // Transform fs methods that accept callbacks in ones that return promises const fs = { @@ -53,7 +54,7 @@ async function saveContentToDisk (language, content) { return saveContentToDisk(language, content); } else { cacheBeingWritten[language] = false; - // TODO log error + logger.error(err); return; } } diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 479b7a0521..d478eb9e04 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -107,7 +107,7 @@ api.createChallengeTasks = { res.respond(201, tasks.length === 1 ? tasks[0] : tasks); // If adding tasks to a challenge -> sync users - if (challenge) challenge.addTasks(tasks); // TODO catch/log + if (challenge) challenge.addTasks(tasks); }, }; @@ -313,7 +313,7 @@ api.updateTask = { let savedTask = await task.save(); res.respond(200, savedTask); - if (challenge) challenge.updateTask(savedTask); // TODO catch/log + if (challenge) challenge.updateTask(savedTask); }, }; diff --git a/website/src/libs/api-v3/email.js b/website/src/libs/api-v3/email.js index 65cbcce02a..490d02c4eb 100644 --- a/website/src/libs/api-v3/email.js +++ b/website/src/libs/api-v3/email.js @@ -1,8 +1,8 @@ import { createTransport } from 'nodemailer'; import nconf from 'nconf'; -import logger from './logger'; import { encrypt } from './encryption'; import request from 'request'; +import logger from './logger'; const IS_PROD = nconf.get('IS_PROD'); const EMAIL_SERVER = { @@ -25,9 +25,7 @@ let smtpTransporter = createTransport({ // Send email directly from the server using the smtpTransporter, // used only to send password reset emails because users unsubscribed on Mandrill wouldn't get them export function send (mailData) { - return smtpTransporter - .sendMail(mailData) - .catch((error) => logger.error(error)); + return smtpTransporter.sendMail(mailData); // promise } export function getUserInfo (user, fields = []) { diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 82df8b4827..e7e94b68ab 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -1,11 +1,12 @@ // Logger utility import winston from 'winston'; import nconf from 'nconf'; +import _ from 'lodash'; const IS_PROD = nconf.get('IS_PROD'); const IS_TEST = nconf.get('IS_TEST'); -let logger = new winston.Logger(); +const logger = new winston.Logger(); if (IS_PROD) { // TODO production logging, use loggly and new relic too @@ -31,4 +32,27 @@ process.on('unhandledRejection', function handlePromiseRejection (reason, promis }); }); -module.exports = logger; +// exports a public interface insteaf of accessing directly the logger module +module.exports = { + info (...args) { + logger.info(...args); + }, + + // Accepts two argument, + // an Error object (required) + // and an object of additional data to log alongside the error + // If the first argument isn't an Error, it'll call logger.error with all the arguments supplied + error (...args) { + let [err, errorData = {}, ...otherArgs] = args; + + if (err instanceof Error) { + // pass the error stack as the first parameter to logger.error + let stack = err.stack || err.message || err; + + if (_.isPlainObject(errorData) && !errorData.fullError) errorData.fullError = err; + logger.error(stack, errorData, ...otherArgs); + } else { + logger.error(...args); + } + }, +}; diff --git a/website/src/libs/api-v3/webhook.js b/website/src/libs/api-v3/webhook.js index 20d814ecb0..e53eebf541 100644 --- a/website/src/libs/api-v3/webhook.js +++ b/website/src/libs/api-v3/webhook.js @@ -1,13 +1,14 @@ import { each } from 'lodash'; import { post } from 'request'; import { isURL } from 'validator'; +import logger from './logger'; let _sendWebhook = (url, body) => { post({ url, body, json: true, - }); // TODO use promises and handle errors + }, (err) => logger.error(err)); }; let _isInvalidWebhook = (hook) => { diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 81f69c6d21..fb071dc2dc 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -380,7 +380,7 @@ module.exports = function cronMiddleware (req, res, next) { $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), }, 'challenge.id': {$exists: false}, - }).exec(); // TODO catch error or at least log it, wait before returning? + }).exec(); // TODO wait before returning? let ranCron = user.isModified(); let quest = common.content.quests[user.party.quest.key]; diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index f3f7aa5401..a4194435c4 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -9,14 +9,10 @@ import { import { map } from 'lodash'; module.exports = function errorHandler (err, req, res, next) { // eslint-disable-line no-unused-vars - // Log the original error with some metadata - let stack = err.stack || err.message || err; - - logger.error(stack, { + logger.error(err, { originalUrl: req.originalUrl, headers: req.headers, body: req.body, - fullError: err, }); // In case of a CustomError class, use it's data diff --git a/website/src/models/group.js b/website/src/models/group.js index 4afc114527..94cce00a20 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -740,6 +740,6 @@ if (!nconf.get('IS_TEST')) { privacy: 'public', }).save({ validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise - }); // TODO catch/log? + }); }); } From 471657c013ce290ec884cee5ab4284d51acffecc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Apr 2016 18:43:15 +0200 Subject: [PATCH 655/976] fix tests that relied on different logger --- test/api/v3/unit/libs/email.test.js | 8 ++++--- .../v3/unit/middlewares/errorHandler.test.js | 3 +-- website/src/libs/api-v3/logger.js | 23 +++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index bb7741dce7..70854279f6 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -61,13 +61,15 @@ describe('emails', () => { sandbox.stub(logger, 'error'); let attachEmail = require(pathToEmailLib); - attachEmail.send(); + let promise = attachEmail.send(); expect(sendMailSpy).to.be.calledOnce; deferred.reject(); - deferred.promise.catch(() => { + + // wait for unhandledRejection event to fire + setTimeout(() => { expect(logger.error).to.be.calledOnce; done(); - }); + }, 20); }); }); diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 269390b35b..f93d741820 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -153,11 +153,10 @@ describe('errorHandler', () => { errorHandler(error, req, res, next); expect(logger.error).to.be.calledOnce; - expect(logger.error).to.be.calledWithExactly(error.stack, { + expect(logger.error).to.be.calledWithExactly(error, { originalUrl: req.originalUrl, headers: req.headers, body: req.body, - fullError: error, }); }); }); diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index e7e94b68ab..cc5d03f745 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -21,19 +21,8 @@ if (IS_PROD) { }); } -// Logs unhandled promises errors -// when no catch is attached to a promise a unhandledRejection event will be triggered -process.on('unhandledRejection', function handlePromiseRejection (reason, promise) { - let stack = reason.stack || reason.message || reason; - - logger.error(stack, { - promise, - fullError: reason, - }); -}); - // exports a public interface insteaf of accessing directly the logger module -module.exports = { +let loggerInterface = { info (...args) { logger.info(...args); }, @@ -56,3 +45,13 @@ module.exports = { } }, }; + +// Logs unhandled promises errors +// when no catch is attached to a promise a unhandledRejection event will be triggered +process.on('unhandledRejection', function handlePromiseRejection (reason, promise) { + loggerInterface.error(reason, { + promise, + }); +}); + +module.exports = loggerInterface; From 2458f92e1b49cd1dfe9533a54ed32f0858088f77 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Apr 2016 20:03:26 +0200 Subject: [PATCH 656/976] fix linting and correctly save collect quests progress --- test/api/v3/unit/libs/email.test.js | 2 +- test/api/v3/unit/models/group.test.js | 6 +++--- website/src/middlewares/api-v3/cron.js | 5 ++++- website/src/models/group.js | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index 70854279f6..a24ac46a83 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -61,7 +61,7 @@ describe('emails', () => { sandbox.stub(logger, 'error'); let attachEmail = require(pathToEmailLib); - let promise = attachEmail.send(); + attachEmail.send(); expect(sendMailSpy).to.be.calledOnce; deferred.reject(); diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index ef76e7b984..e1323baa07 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -141,7 +141,7 @@ describe('Group Model', () => { expect(participatingMember.party.quest.key).to.eql('whale'); expect(participatingMember.party.quest.progress.down).to.eql(0); - expect(participatingMember.party.quest.collect).to.eql({}); + expect(participatingMember.party.quest.progress.collect).to.eql({}); expect(participatingMember.party.quest.completed).to.eql(null); }); @@ -250,7 +250,7 @@ describe('Group Model', () => { $set: { 'party.quest.key': 'whale', 'party.quest.progress.down': 0, - 'party.quest.collect': {}, + 'party.quest.progress.collect': {}, 'party.quest.completed': null, }, } @@ -279,7 +279,7 @@ describe('Group Model', () => { expect(userQuest.key).to.eql('whale'); expect(userQuest.progress.down).to.eql(0); - expect(userQuest.collect).to.eql({}); + expect(userQuest.progress.collect).to.eql({}); expect(userQuest.completed).to.eql(null); }); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index fb071dc2dc..567582d0bc 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -233,7 +233,10 @@ function cron (options = {}) { // After all is said and done, progress up user's effect on quest, return those values & reset the user's let progress = user.party.quest.progress; let _progress = _.cloneDeep(progress); - _.merge(progress, {down: 0, up: 0}); + + progress.down = 0; + progress.up = 0; + progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); // Clean PMs - keep 200 for subscribers and 50 for free users diff --git a/website/src/models/group.js b/website/src/models/group.js index 94cce00a20..d28e14b542 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -327,7 +327,7 @@ schema.methods.startQuest = async function startQuest (user) { if (userIsParticipating) { user.party.quest.key = this.quest.key; user.party.quest.progress.down = 0; - user.party.quest.collect = collected; + user.party.quest.progress.collect = collected; user.party.quest.completed = null; user.markModified('party.quest'); } @@ -351,7 +351,7 @@ schema.methods.startQuest = async function startQuest (user) { $set: { 'party.quest.key': this.quest.key, 'party.quest.progress.down': 0, - 'party.quest.collect': collected, + 'party.quest.progress.collect': collected, 'party.quest.completed': null, }, }, { multi: true }).exec(); From 99e201fc0707b2214a2f47b70d93ce090b8b74ee Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Apr 2016 21:52:53 +0200 Subject: [PATCH 657/976] v3: move tavern to valid UUID and set Leslie as the leader --- website/src/controllers/api-v2/challenges.js | 3 +- website/src/controllers/api-v2/groups.js | 10 ++++-- website/src/controllers/api-v3/challenges.js | 5 +-- website/src/controllers/api-v3/chat.js | 11 ++++--- website/src/controllers/api-v3/groups.js | 10 +++--- website/src/libs/api-v2/firebase.js | 12 ++++--- website/src/libs/api-v3/firebase.js | 12 ++++--- website/src/models/group.js | 34 +++++++++++--------- website/src/models/user.js | 8 +++-- 9 files changed, 62 insertions(+), 43 deletions(-) diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index f20bf96ad8..605e3424cf 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -10,6 +10,7 @@ import { import { model as Group, basicFields as basicGroupFields, + TAVERN_ID, } from '../../models/group'; import { model as Challenge, @@ -152,7 +153,7 @@ api.create = async function(req, res, next){ return res.status(401).json({err:"Only the group leader can create challenges"}); } - if (groupId === 'habitrpg' && prize < 1) { + if (group._id === TAVERN_ID && prize < 1) { return res.status(401).json({err: 'Prize must be at least 1 Gem for public challenges.'}) } diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index fded19274e..f864828217 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -19,6 +19,7 @@ import { } from './../../models/user'; import { model as Group, + TAVERN_ID, } from './../../models/group'; import { model as Challenge, @@ -127,7 +128,7 @@ api.list = function(req, res, next) { // unecessary given our ui-router setup tavern: function(cb) { if (!~type.indexOf('tavern')) return cb(null, {}); - Group.findById('habitrpg').select(groupFields).exec(function(err, tavern){ + Group.findById(TAVERN_ID).select(groupFields).exec(function(err, tavern){ if (err) return cb(err); tavern.getTransformedData({cb: function (err, transformedTavern) { if (err) return cb(err); @@ -169,6 +170,8 @@ api.get = function(req, res, next) { if (isUserGuild) { q = Group.findOne({type: 'guild', _id: gid}); + } else if (gid === 'habitrpg') { + q = Group.findOne({_id: TAVERN_ID}); } else { q = Group.findOne({type: 'guild', privacy: 'public', _id: gid}); } @@ -284,6 +287,7 @@ api.update = function(req, res, next) { api.attachGroup = function(req, res, next) { var user = res.locals.user; var gid = req.params.gid === 'party' ? user.party._id : req.params.gid; + if (gid === 'habitrpg') gid = TAVERN_ID; let q = Group.findOne({_id: gid}) @@ -313,6 +317,8 @@ api.getChat = function(req, res, next) { } else { if (isUserGuild) { q = Group.findOne({type: 'guild', _id: gid}); + } else if (gid === 'habitrpg') { + q = Group.findOne({_id: TAVERN_ID}); } else { q = Group.findOne({type: 'guild', privacy: 'public', _id: gid}); } @@ -423,7 +429,7 @@ api.flagChatMessage = function(req, res, next){ {name: "GROUP_NAME", content: group.name}, {name: "GROUP_TYPE", content: group.type}, {name: "GROUP_ID", content: group._id}, - {name: "GROUP_URL", content: group._id == 'habitrpg' ? '/#/options/groups/tavern' : (group.type === 'guild' ? ('/#/options/groups/guilds/' + group._id) : 'party')}, + {name: "GROUP_URL", content: group._id == TAVERN_ID ? '/#/options/groups/tavern' : (group.type === 'guild' ? ('/#/options/groups/guilds/' + group._id) : 'party')}, ]); return res.sendStatus(204); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index ecaad87eb1..b3dd588118 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -5,6 +5,7 @@ import { model as Challenge } from '../../models/challenge'; import { model as Group, basicFields as basicGroupFields, + TAVERN_ID, } from '../../models/group'; import { model as User, @@ -54,7 +55,7 @@ api.createChallenge = { throw new NotAuthorized(res.t('onlyGroupLeaderChal')); } - if (groupId === 'habitrpg' && prize < 1) { + if (group._id === TAVERN_ID && prize < 1) { throw new NotAuthorized(res.t('pubChalsMinPrize')); } @@ -454,7 +455,7 @@ export async function _closeChal (challenge, broken = {}) { await Challenge.remove({_id: challenge._id}).exec(); // Refund the leader if the challenge is closed and the group not the tavern - if (challenge.group !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { + if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') { await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec(); } diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 1fd1ebe1bd..43e4ba69e9 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -1,6 +1,9 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; -import { model as Group } from '../../models/group'; +import { + model as Group, + TAVERN_ID, +} from '../../models/group'; import { model as User } from '../../models/user'; import { NotFound, @@ -23,7 +26,7 @@ let api = {}; * @apiName GetChat * @apiGroup Chat * - * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * * @apiSuccess {Array} chat An array of chat messages */ @@ -219,7 +222,7 @@ api.flagChat = { } let groupUrl; - if (group._id === 'habitrpg') { + if (group._id === TAVERN_ID) { groupUrl = '/#/options/groups/tavern'; } else if (group.type === 'guild') { groupUrl = `/#/options/groups/guilds/{$group._id}`; @@ -336,7 +339,7 @@ api.seenChat = { * @apiName DeleteChat * @apiGroup Chat * - * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {string} chatId The chat _id * * @apiSuccess {Array} The update chat array diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 81a634a725..333e38a4a5 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -118,7 +118,7 @@ api.getGroups = { * @apiName GetGroup * @apiGroup Group * - * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * * @apiSuccess {Object} group The group object */ @@ -152,7 +152,7 @@ api.getGroup = { * @apiName UpdateGroup * @apiGroup Group * - * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * * @apiSuccess {Object} group The updated group object */ @@ -334,7 +334,7 @@ api.rejectGroupInvite = { * @apiName LeaveGroup * @apiGroup Group * - * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {string="remove-all","keep-all"} keep Wheter to keep or not challenges' tasks, as an optional query string * * @apiSuccess {Object} empty An empty object @@ -390,7 +390,7 @@ function _sendMessageToRemoved (group, removedUser, message) { * @apiName RemoveGroupMember * @apiGroup Group * - * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {UUID} memberId The _id of the member to remove * @apiParam {string} message The message to send to the removed members, as a query string // TODO in req.body? * @@ -591,7 +591,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) { * @apiName InviteToGroup * @apiGroup Group * - * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * * @apiParam {array} emails An array of emails addresses to invite (optional) (inside body) * @apiParam {array} uuids An array of uuids to invite (optional) (inside body) diff --git a/website/src/libs/api-v2/firebase.js b/website/src/libs/api-v2/firebase.js index 84a90b22d4..c0b69d9eb9 100644 --- a/website/src/libs/api-v2/firebase.js +++ b/website/src/libs/api-v2/firebase.js @@ -6,6 +6,8 @@ var firebaseConfig = nconf.get('FIREBASE'); var firebaseRef; var isFirebaseEnabled = (nconf.get('NODE_ENV') === 'production') && (firebaseConfig.ENABLED === 'true'); +import { TAVERN_ID } from '../../models/group'; + // Setup if(isFirebaseEnabled){ firebaseRef = new Firebase('https://' + firebaseConfig.APP + '.firebaseio.com'); @@ -24,7 +26,7 @@ api.updateGroupData = function(group){ // TODO is throw ok? we don't have callbacks if(!group) throw new Error('group is required.'); // Return in case of tavern (comparison working because we use string for _id) - if(group._id === 'habitrpg') return; + if(group._id === TAVERN_ID) return; firebaseRef.child('rooms/' + group._id) .set({ @@ -35,7 +37,7 @@ api.updateGroupData = function(group){ api.addUserToGroup = function(groupId, userId){ if(!isFirebaseEnabled) return; if(!userId || !groupId) throw new Error('groupId, userId are required.'); - if(groupId === 'habitrpg') return; + if(groupId === TAVERN_ID) return; firebaseRef.child('members/' + groupId + '/' + userId) .set(true); @@ -47,7 +49,7 @@ api.addUserToGroup = function(groupId, userId){ api.removeUserFromGroup = function(groupId, userId){ if(!isFirebaseEnabled) return; if(!userId || !groupId) throw new Error('groupId, userId are required.'); - if(groupId === 'habitrpg') return; + if(groupId === TAVERN_ID) return; firebaseRef.child('members/' + groupId + '/' + userId) .remove(); @@ -59,7 +61,7 @@ api.removeUserFromGroup = function(groupId, userId){ api.deleteGroup = function(groupId){ if(!isFirebaseEnabled) return; if(!groupId) throw new Error('groupId is required.'); - if(groupId === 'habitrpg') return; + if(groupId === TAVERN_ID) return; firebaseRef.child('rooms/' + groupId) .remove(); @@ -78,4 +80,4 @@ api.deleteUser = function(userId){ firebaseRef.child('users/' + userId) .remove(); -}; \ No newline at end of file +}; diff --git a/website/src/libs/api-v3/firebase.js b/website/src/libs/api-v3/firebase.js index 92d6c28fc1..6cf3bb1ce7 100644 --- a/website/src/libs/api-v3/firebase.js +++ b/website/src/libs/api-v3/firebase.js @@ -1,5 +1,7 @@ import Firebase from 'firebase'; import nconf from 'nconf'; +import { TAVERN_ID } from '../../models/group'; + const FIREBASE_CONFIG = nconf.get('FIREBASE'); const FIREBASE_ENABLED = FIREBASE_CONFIG.ENABLED === 'true'; @@ -20,7 +22,7 @@ export function updateGroupData (group) { // TODO is throw ok? we don't have callbacks if (!group) throw new Error('group obj is required.'); // Return in case of tavern (comparison working because we use string for _id) - if (group._id === 'habitrpg') return; + if (group._id === TAVERN_ID) return; firebaseRef.child(`rooms/${group._id}`) .set({ @@ -31,7 +33,7 @@ export function updateGroupData (group) { export function addUserToGroup (groupId, userId) { if (!FIREBASE_ENABLED) return; if (!userId || !groupId) throw new Error('groupId, userId are required.'); - if (groupId === 'habitrpg') return; + if (groupId === TAVERN_ID) return; firebaseRef.child(`members/${groupId}/${userId}`).set(true); firebaseRef.child(`users/${userId}/rooms/${groupId}`).set(true); @@ -40,7 +42,7 @@ export function addUserToGroup (groupId, userId) { export function removeUserFromGroup (groupId, userId) { if (!FIREBASE_ENABLED) return; if (!userId || !groupId) throw new Error('groupId, userId are required.'); - if (groupId === 'habitrpg') return; + if (groupId === TAVERN_ID) return; firebaseRef.child(`members/${groupId}/${userId}`).remove(); firebaseRef.child(`users/${userId}/rooms/${groupId}`).remove(); @@ -49,7 +51,7 @@ export function removeUserFromGroup (groupId, userId) { export function deleteGroup (groupId) { if (!FIREBASE_ENABLED) return; if (!groupId) throw new Error('groupId is required.'); - if (groupId === 'habitrpg') return; + if (groupId === TAVERN_ID) return; firebaseRef.child(`members/${groupId}`).remove(); // FIXME not really necessary as long as we only store room data, @@ -64,4 +66,4 @@ export function deleteUser (userId) { if (!userId) throw new Error('userId is required.'); firebaseRef.child(`users/${userId}`).remove(); -} \ No newline at end of file +} diff --git a/website/src/models/group.js b/website/src/models/group.js index d28e14b542..d85bc8c13e 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -17,8 +17,10 @@ import nconf from 'nconf'; import sendPushNotification from '../libs/api-v3/pushNotifications'; const questScrolls = shared.content.quests; +const Schema = mongoose.Schema; -let Schema = mongoose.Schema; +export const INVITES_LIMIT = 100; +export const TAVERN_ID = '00000000-0000-4000-A000-000000000000'; // NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API // changes made directly to the db will cause Firebase to get out of sync @@ -126,15 +128,18 @@ schema.statics.getGroup = async function getGroup (options = {}) { let isUserParty = groupId === 'party' || user.party._id === groupId; let isUserGuild = user.guilds.indexOf(groupId) !== -1; + let isTavern = ['habitrpg', TAVERN_ID].indexOf(groupId) !== -1; // When requireMembership is true check that user is member even in public guild - if (requireMembership && !isUserParty && !isUserGuild) { + if (requireMembership && !isUserParty && !isUserGuild && !isTavern) { return null; } // When optionalMembership is true it's not required for the user to be a member of the group if (isUserParty) { query = {type: 'party', _id: user.party._id}; + } else if (isTavern) { + query = {_id: TAVERN_ID}; } else if (optionalMembership === true) { query = {_id: groupId}; } else if (isUserGuild) { @@ -180,7 +185,7 @@ schema.statics.getGroups = async function getGroups (options = {}) { break; case 'tavern': if (types.indexOf('publicGuilds') === -1) { - queries.push(this.getGroup({user, groupId: 'habitrpg', fields: groupFields})); + queries.push(this.getGroup({user, groupId: TAVERN_ID, fields: groupFields})); } break; } @@ -230,7 +235,7 @@ schema.methods.removeGroupInvitations = async function removeGroupInvitations () // Return true if user is a member of the group schema.methods.isMember = function isGroupMember (user) { - if (this._id === 'habitrpg') { + if (this._id === TAVERN_ID) { return true; // everyone is considered part of the tavern } else if (this.type === 'party') { return user.party._id === this._id ? true : false; @@ -263,7 +268,7 @@ export function chatDefaults (msg, user) { return message; } -const NO_CHAT_NOTIFICATIONS = ['habitrpg']; +const NO_CHAT_NOTIFICATIONS = [TAVERN_ID]; schema.methods.sendChat = function sendChat (message, user) { this.chat.unshift(chatDefaults(message, user)); this.chat.splice(200); @@ -421,7 +426,7 @@ schema.methods.finishQuest = function finishQuest (quest) { updates.$inc['stats.exp'] = Number(quest.drop.exp); updates.$inc._v = 1; - if (this._id === 'habitrpg') { + if (this._id === TAVERN_ID) { updates.$set['party.quest.completed'] = questK; // Just show the notif } else { updates.$set['party.quest'] = _cleanQuestProgress({completed: questK}); // clear quest progress @@ -450,7 +455,7 @@ schema.methods.finishQuest = function finishQuest (quest) { } }); - let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}}; + let q = this._id === TAVERN_ID ? {} : {_id: {$in: _.keys(this.quest.members)}}; this.quest = {}; this.markModified('quest'); return User.update(q, updates, {multi: true}).exec(); @@ -539,10 +544,10 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { return group.save(); }; -// to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` +// to set a boss: `db.groups.update({_id:TAVERN_ID},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` // we export an empty object that is then populated with the query-returned data export let tavernQuest = {}; -let tavernQ = {_id: 'habitrpg', 'quest.key': {$ne: null}}; +let tavernQ = {_id: TAVERN_ID, 'quest.key': {$ne: null}}; // we use process.nextTick because at this point the model is not yet available process.nextTick(() => { @@ -723,23 +728,20 @@ schema.methods.getTransformedData = function getTransformedData (options) { }; // END API v2 compatibility methods -export const INVITES_LIMIT = 100; export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) // do not run when testing as it's handled by the tests and can easily cause a race condition if (!nconf.get('IS_TEST')) { - model.count({_id: 'habitrpg'}, (err, ct) => { + model.count({_id: TAVERN_ID}, (err, ct) => { if (err) throw err; if (ct > 0) return; new model({ // eslint-disable-line babel/new-cap - _id: 'habitrpg', - leader: '9', // TODO change this user id + _id: TAVERN_ID, + leader: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', // Siena Leslie name: 'HabitRPG', type: 'guild', privacy: 'public', - }).save({ - validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise - }); + }).save(); }); } diff --git a/website/src/models/user.js b/website/src/models/user.js index 0086167f2e..fcf0078cf4 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -7,9 +7,11 @@ import * as Tasks from './task'; import Q from 'q'; import { schema as TagSchema } from './tag'; import baseModel from '../libs/api-v3/baseModel'; -import { chatDefaults } from './group'; +import { + chatDefaults, + TAVERN_ID, +} from './group'; import { defaults } from 'lodash'; -// import {model as Challenge} from './challenge'; let Schema = mongoose.Schema; @@ -706,7 +708,7 @@ schema.methods.isSubscribed = function isSubscribed () { schema.methods.getGroups = function getUserGroups () { let userGroups = this.guilds.slice(0); // clone user.guilds so we don't modify the original if (this.party._id) userGroups.push(this.party._id); - userGroups.push('habitrpg'); // tavern + userGroups.push(TAVERN_ID); return userGroups; }; From cc024533aa9d1e17d21cce56a33aa4e0b9c913eb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Apr 2016 21:55:31 +0200 Subject: [PATCH 658/976] FIXME -> TODO to make searching easier --- website/src/controllers/api-v2/auth.js | 2 +- website/src/controllers/api-v2/groups.js | 2 +- website/src/controllers/api-v2/user.js | 8 ++++---- website/src/controllers/payments/index.js | 4 ++-- website/src/libs/api-v2/firebase.js | 4 ++-- website/src/libs/api-v3/firebase.js | 4 ++-- website/src/middlewares/api-v3/cron.js | 6 +++--- website/src/models/group.js | 2 +- website/src/routes/api-v2/auth.js | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/website/src/controllers/api-v2/auth.js b/website/src/controllers/api-v2/auth.js index d5b9aff9e2..df8adf7a52 100644 --- a/website/src/controllers/api-v2/auth.js +++ b/website/src/controllers/api-v2/auth.js @@ -241,7 +241,7 @@ api.loginSocial = function(req, res, next) { api.deleteSocial = function(req,res,next){ if (!res.locals.user.auth.local.username) return res.status(401).json({err:"Account lacks another authentication method, can't detach Facebook"}); - //FIXME for some reason, the following gives https://gist.github.com/lefnire/f93eb306069b9089d123 + //TODO for some reason, the following gives https://gist.github.com/lefnire/f93eb306069b9089d123 //res.locals.user.auth.facebook = null; //res.locals.user.auth.save(function(err, saved){ User.update({_id:res.locals.user._id}, {$unset:{'auth.facebook':1}}, function(err){ diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index f864828217..9e0715efc5 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -346,7 +346,7 @@ api.postChat = function(req, res, next) { var lastClientMsg = req.query.previousMsg; var chatUpdated = (lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg) ? true : false; - group.sendChat(req.query.message, user); // FIXME this should be body, but ngResource is funky + group.sendChat(req.query.message, user); // TODO this should be body, but ngResource is funky if (group.type === 'party') { user.party.lastMessageSeen = group.chat[0].id; diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 613e663e1e..1a00bf56f3 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -138,7 +138,7 @@ api.score = function(req, res, next) { }, function(err, results){ if(err) return next(err); - // FIXME this is suuuper strange, sometimes results.user is an array, sometimes user directly + // TODO this is suuuper strange, sometimes results.user is an array, sometimes user directly var saved = Array.isArray(results.user) ? results.user[0] : results.user; var task = Array.isArray(results.task) ? results.task[0] : results.task; @@ -169,12 +169,12 @@ api.score = function(req, res, next) { '_id': task.challenge.taskId, userId: {$exists: false} }, function(err, chalTask){ - if(err) return; //FIXME + if(err) return; //TODO // this task was removed from the challenge, notify user if(!chalTask) { // TODO finish chal.getTasks(function(err, chalTasks){ - if(err) return; //FIXME + if(err) return; //TODO chal.syncToUser(user, chalTasks); }); } else { @@ -330,7 +330,7 @@ api.getUserAnonymized = function(req, res, next) { /** * This tells us for which paths users can call `PUT /user` (or batch-update equiv, which use `User.set()` on our client). * The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs) - * FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations + * TODO - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations */ acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, (m, v, leaf) => { let updatablePaths = 'achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '); diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js index a213b73376..1652e05b8d 100644 --- a/website/src/controllers/payments/index.js +++ b/website/src/controllers/payments/index.js @@ -30,7 +30,7 @@ function revealMysteryItems(user) { exports.createSubscription = function(data, cb) { var recipient = data.gift ? data.gift.member : data.user; - //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // FIXME double-check, this should never be the case + //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // TODO double-check, this should never be the case var p = recipient.purchased.plan; var block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; var months = +block.months; @@ -120,7 +120,7 @@ exports.cancelSubscription = function(data, cb) { p.dateTerminated = moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. TODO: moment can't add months in fractions... .toDate(); p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated diff --git a/website/src/libs/api-v2/firebase.js b/website/src/libs/api-v2/firebase.js index c0b69d9eb9..8a8d9f002c 100644 --- a/website/src/libs/api-v2/firebase.js +++ b/website/src/libs/api-v2/firebase.js @@ -66,13 +66,13 @@ api.deleteGroup = function(groupId){ firebaseRef.child('rooms/' + groupId) .remove(); - // FIXME not really necessary as long as we only store room data, + // TODO not really necessary as long as we only store room data, // as empty objects are automatically deleted (/members/... in future...) firebaseRef.child('members/' + groupId) .remove(); }; -// FIXME not really necessary as long as we only store room data, +// TODO not really necessary as long as we only store room data, // as empty objects are automatically deleted api.deleteUser = function(userId){ if(!isFirebaseEnabled) return; diff --git a/website/src/libs/api-v3/firebase.js b/website/src/libs/api-v3/firebase.js index 6cf3bb1ce7..324183e85f 100644 --- a/website/src/libs/api-v3/firebase.js +++ b/website/src/libs/api-v3/firebase.js @@ -54,12 +54,12 @@ export function deleteGroup (groupId) { if (groupId === TAVERN_ID) return; firebaseRef.child(`members/${groupId}`).remove(); - // FIXME not really necessary as long as we only store room data, + // TODO not really necessary as long as we only store room data, // as empty objects are automatically deleted (/members/... in future...) firebaseRef.child(`rooms/${groupId}`).remove(); } -// FIXME not really necessary as long as we only store room data, +// TODO not really necessary as long as we only store room data, // as empty objects are automatically deleted export function deleteUser (userId) { if (!FIREBASE_ENABLED) return; diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 567582d0bc..5b1aea9114 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -46,7 +46,7 @@ function cron (options = {}) { // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 // TODO use month diff instead of ++ / --? - _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // TODO see https://github.com/HabitRPG/habitrpg/issues/4317 plan.consecutive.count++; if (plan.consecutive.offset > 0) { plan.consecutive.offset--; @@ -177,7 +177,7 @@ function cron (options = {}) { task.completed = false; if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + task.checklist.forEach(i => i.completed = true); // TODO this should not happen for grey tasks unless they are completed } }); @@ -405,7 +405,7 @@ module.exports = function cronMiddleware (req, res, next) { // If user is on a quest, roll for boss & player, or handle collections let questType = quest.boss ? 'boss' : 'collect'; - // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + // TODO this saves user, runs db updates, loads user. Is there a better way to handle this? return Group[`${questType}Quest`](user, progress) .then(() => User.findById(user._id).exec()) // fetch the updated user... .then(updatedUser => { diff --git a/website/src/models/group.js b/website/src/models/group.js index d85bc8c13e..b3c25ae913 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -500,7 +500,7 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { if (!_isOnQuest(user, progress, group)) return; let quest = shared.content.quests[group.quest.key]; - if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point, log? + if (!progress || !quest) return; // TODO why is this ever happening, progress should be defined at this point, log? let down = progress.down * quest.boss.str; // multiply by boss strength diff --git a/website/src/routes/api-v2/auth.js b/website/src/routes/api-v2/auth.js index d4cad28c9e..468612f8cf 100644 --- a/website/src/routes/api-v2/auth.js +++ b/website/src/routes/api-v2/auth.js @@ -5,7 +5,7 @@ var router = express.Router(); import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; /* auth.auth*/ -// auth.setupPassport(router); //FIXME make this consistent with the others +// auth.setupPassport(router); //TODO make this consistent with the others router.post('/register', getUserLanguage, auth.registerUser); router.post('/user/auth/local', getUserLanguage, auth.loginLocal); router.post('/user/auth/social', getUserLanguage, auth.loginSocial); From 57497f246fc4841782a90bbf9b9f5e2e2348e63d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Apr 2016 22:27:32 +0200 Subject: [PATCH 659/976] make tests working with new tavern id --- test/api/v3/integration/groups/GET-groups.test.js | 5 ++++- test/helpers/mongo.js | 5 +++-- website/src/middlewares/api-v3/cron.js | 1 - 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js index b37b87f890..7e2014e87c 100644 --- a/test/api/v3/integration/groups/GET-groups.test.js +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -3,6 +3,9 @@ import { resetHabiticaDB, generateGroup, } from '../../../../helpers/api-v3-integration.helper'; +import { + TAVERN_ID, +} from '../../../../../website/src/models/group'; describe('GET /groups', () => { let user; @@ -70,7 +73,7 @@ describe('GET /groups', () => { await expect(user.get('/groups?type=tavern')) .to.eventually.have.a.lengthOf(1) .and.to.have.deep.property('[0]') - .and.to.have.property('_id', 'habitrpg'); + .and.to.have.property('_id', TAVERN_ID); }); it('returns only the user\'s party when party passed in as query', async () => { diff --git a/test/helpers/mongo.js b/test/helpers/mongo.js index d99c195c3c..463f375e8e 100644 --- a/test/helpers/mongo.js +++ b/test/helpers/mongo.js @@ -1,4 +1,5 @@ import mongoose from 'mongoose'; +import { TAVERN_ID } from '../../website/src/models/group'; // Useful for checking things that have been deleted, // but you no longer have access to, @@ -26,12 +27,12 @@ export async function resetHabiticaDB () { let groups = mongoose.connection.db.collection('groups'); // For some mysterious reason after a dropDatabase there can still be a group... - groups.count({_id: 'habitrpg'}, (err, count) => { + groups.count({_id: TAVERN_ID}, (err, count) => { if (err) return reject(err); if (count > 0) return resolve(); groups.insertOne({ - _id: 'habitrpg', + _id: TAVERN_ID, chat: [], leader: '9', name: 'HabitRPG', diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 5b1aea9114..c82c0fc647 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -261,7 +261,6 @@ function cron (options = {}) { gaLabel: 'Cron Count', gaValue: user.flags.cronCount, uuid: user._id, - user, // TODO is it really necessary passing the whole user object? resting: user.preferences.sleep, cronCount: user.flags.cronCount, progressUp: _.min([_progress.up, 900]), From 54aac99a6a02c1457eea1bf42765d65c523466f7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Apr 2016 22:28:29 +0200 Subject: [PATCH 660/976] v2: make tests working with new tavern id --- test/api/v2/groups/GET-groups.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/api/v2/groups/GET-groups.test.js b/test/api/v2/groups/GET-groups.test.js index e1b7635356..0b6cd4923e 100644 --- a/test/api/v2/groups/GET-groups.test.js +++ b/test/api/v2/groups/GET-groups.test.js @@ -3,6 +3,9 @@ import { generateUser, resetHabiticaDB, } from '../../../helpers/api-integration/v2'; +import { + TAVERN_ID, +} from '../../../../website/src/models/group'; describe('GET /groups', () => { const NUMBER_OF_PUBLIC_GUILDS = 3; @@ -68,7 +71,7 @@ describe('GET /groups', () => { await expect(user.get('/groups', null, {type: 'tavern'})) .to.eventually.have.a.lengthOf(1) .and.to.have.deep.property('[0]') - .and.to.have.property('_id', 'habitrpg'); + .and.to.have.property('_id', TAVERN_ID); }); }); From 925881b2a067d48483fe058e68850d1642d4fe2a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Apr 2016 22:55:39 +0200 Subject: [PATCH 661/976] misc fixes and enable dev endpoints for easier live testing --- website/src/controllers/api-v2/user.js | 2 +- website/src/controllers/api-v3/tasks.js | 3 ++- website/src/middlewares/api-v3/cron.js | 1 + website/views/shared/footer.jade | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 1a00bf56f3..9636856b2c 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -458,7 +458,7 @@ api.delete = function(req, res, next) { Development Only Operations ------------------------------------------------------------------------ */ -if (nconf.get('NODE_ENV') === 'development') { +if (true || nconf.get('NODE_ENV') === 'development') { api.addTenGems = function(req, res, next) { var user = res.locals.user; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index d478eb9e04..5a06a58f25 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -13,6 +13,7 @@ import { import common from '../../../../common'; import Q from 'q'; import _ from 'lodash'; +import logger from '../../libs/api-v3/logger'; let api = {}; @@ -416,7 +417,7 @@ api.scoreTask = { await chalTask.scoreChallengeTask(delta); } catch (e) { - // TODO handle + logger.error(e); } } }, diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index c82c0fc647..79eec6d654 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -261,6 +261,7 @@ function cron (options = {}) { gaLabel: 'Cron Count', gaValue: user.flags.cronCount, uuid: user._id, + user, resting: user.preferences.sleep, cronCount: user.flags.cronCount, progressUp: _.min([_progress.up, 900]), diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index 0f5bc5c041..347f5a9c87 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -79,7 +79,7 @@ footer.footer(ng-controller='FooterCtrl') tr td iframe(src='/bower_components/github-buttons/github-btn.html?user=habitrpg&repo=habitrpg&type=watch&count=true', allowtransparency='true', frameborder='0', scrolling='0', width='85px', height='20px') - else if (env.NODE_ENV==='development' || env.NODE_ENV==='test') && !env.isStaticPage + else if (true || env.NODE_ENV==='development' || env.NODE_ENV==='test') && !env.isStaticPage h4 Debug .btn-group-vertical a.btn.btn-default(ng-click='setHealthLow()') Health = 1 From a590a66c47ec7fd7a2e5cf70ce63b3fe5c8bb97d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Apr 2016 11:54:47 +0200 Subject: [PATCH 662/976] support top level controllers --- .../POST-user_batch-update.test.js | 2 +- test/helpers/api-integration/requester.js | 11 +++++++- .../{api-v3 => top-level}/dataexport.js | 0 .../src/controllers/{ => top-level}/pages.js | 4 +-- website/src/middlewares/api-v3/auth.js | 3 +- website/src/middlewares/api-v3/index.js | 12 +++----- website/src/middlewares/api-v3/v2.js | 1 - website/src/middlewares/api-v3/v3.js | 13 ++++++--- website/src/routes/dataexport.js | 28 ------------------- website/src/server.js | 3 -- 10 files changed, 27 insertions(+), 50 deletions(-) rename website/src/controllers/{api-v3 => top-level}/dataexport.js (100%) rename website/src/controllers/{ => top-level}/pages.js (93%) delete mode 100644 website/src/routes/dataexport.js diff --git a/test/api/v2/user/batch-update/POST-user_batch-update.test.js b/test/api/v2/user/batch-update/POST-user_batch-update.test.js index 0b1f244ff7..f64cbc7f7b 100644 --- a/test/api/v2/user/batch-update/POST-user_batch-update.test.js +++ b/test/api/v2/user/batch-update/POST-user_batch-update.test.js @@ -31,7 +31,7 @@ describe('POST /user/batch-update', () => { }); }); - context('development only operations', () => { // These tests will fail if your NODE_ENV is set to 'development' instead of 'testing' + xcontext('development only operations', () => { // These tests will fail if your NODE_ENV is set to 'development' instead of 'testing' let protectedOperations = { 'Add Ten Gems': 'addTenGems', 'Add Hourglass': 'addHourglass', diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index 903c198686..d56593548a 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -30,7 +30,16 @@ function _requestMaker (user, method, additionalSets = {}) { return (route, send, query) => { return new Promise((resolve, reject) => { - let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/${apiVersion}${route}`) + let url = `http://localhost:${API_TEST_SERVER_PORT}`; + + // do not prefix with api/apiVersion requests to top level routes like dataexport and payments + if (route.indexOf('/export') === 0 || route.indexOf('/payments') === 0) { + url += `${route}`; + } else { + url += `/api/${apiVersion}${route}`; + } + + let request = superagent[method](url) .accept('application/json'); if (user && user._id && user.apiToken) { diff --git a/website/src/controllers/api-v3/dataexport.js b/website/src/controllers/top-level/dataexport.js similarity index 100% rename from website/src/controllers/api-v3/dataexport.js rename to website/src/controllers/top-level/dataexport.js diff --git a/website/src/controllers/pages.js b/website/src/controllers/top-level/pages.js similarity index 93% rename from website/src/controllers/pages.js rename to website/src/controllers/top-level/pages.js index d655ce09bf..3174c8fe17 100644 --- a/website/src/controllers/pages.js +++ b/website/src/controllers/top-level/pages.js @@ -1,5 +1,5 @@ -import locals from '../middlewares/api-v3/locals'; -import getUserLanguage from '../middlewares/api-v3/getUserLanguage'; +import locals from '../../middlewares/api-v3/locals'; +import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; import _ from 'lodash'; const marked = require('marked'); diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 7f0fab0e07..f1672dcf06 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -1,6 +1,5 @@ import { NotAuthorized, - BadRequest, } from '../../libs/api-v3/errors'; import common from '../../../../common'; import { @@ -18,7 +17,7 @@ export function authWithHeaders (optional = false) { if (!userId || !apiToken) { if (optional) return next(); - return next(new BadRequest(res.t('missingAuthHeaders'))); + return next(new NotAuthorized(res.t('missingAuthHeaders'))); } User.findOne({ diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index bc764997b4..8e71e22e84 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -14,8 +14,6 @@ import favicon from 'serve-favicon'; import methodOverride from 'method-override'; import passport from 'passport'; import path from 'path'; -import express from 'express'; -import routes from '../../libs/api-v3/routes'; import { forceSSL, forceHabitica, @@ -23,7 +21,6 @@ import { import v1 from './v1'; import v2 from './v2'; import v3 from './v3'; -import staticPagesController from '../../controllers/pages'; const IS_PROD = nconf.get('IS_PROD'); const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); @@ -33,6 +30,9 @@ const SESSION_SECRET = nconf.get('SESSION_SECRET'); const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; module.exports = function attachMiddlewares (app, server) { + app.set('view engine', 'jade'); + app.set('views', `${__dirname}/../views`); + app.use(domainMiddleware(server, mongoose)); if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev')); @@ -63,11 +63,7 @@ module.exports = function attachMiddlewares (app, server) { app.use(passport.initialize()); app.use(passport.session()); - const staticPagesRouter = express.Router(); // eslint-disable-line babel/new-cap - routes.readController(staticPagesRouter, staticPagesController); - app.use('/', staticPagesRouter); - - app.use('/api/v3', v3); + app.use(v3); // the main app, also setup top-level routes app.use('/api/v2', v2); app.use('/api/v1', v1); staticMiddleware(app); diff --git a/website/src/middlewares/api-v3/v2.js b/website/src/middlewares/api-v3/v2.js index 71a1e1d82c..cda6a6cf38 100644 --- a/website/src/middlewares/api-v3/v2.js +++ b/website/src/middlewares/api-v3/v2.js @@ -22,7 +22,6 @@ v2app.use('/', require('../../routes/api-v2/auth')); v2app.use('/', require('../../routes/api-v2/coupon')); // TODO REMOVE - ONLY v3 v2app.use('/', require('../../routes/api-v2/unsubscription')); // TODO REMOVE - ONLY v3 -v2app.use('/export', require('../../routes/dataexport')); // TODO REMOVE - ONLY v3 require('../../routes/api-v2/swagger')(swagger, v2app); v2app.use(require('../api-v2/errorHandler')); diff --git a/website/src/middlewares/api-v3/v3.js b/website/src/middlewares/api-v3/v3.js index a7c6e0d396..bd9fe7c4da 100644 --- a/website/src/middlewares/api-v3/v3.js +++ b/website/src/middlewares/api-v3/v3.js @@ -19,9 +19,14 @@ v3app.use(setupBody); v3app.use(responseHandler); v3app.use(getUserLanguage); // TODO move to after auth for authenticated routes -const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); -const router = express.Router(); // eslint-disable-line babel/new-cap -routes.walkControllers(router, CONTROLLERS_PATH); -v3app.use(router); +const TOP_LEVEL_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/top-level/'); +const topLevelRouter = express.Router(); // eslint-disable-line babel/new-cap +routes.walkControllers(topLevelRouter, TOP_LEVEL_CONTROLLERS_PATH); +v3app.use('/', topLevelRouter); + +const API_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); +const v3Router = express.Router(); // eslint-disable-line babel/new-cap +routes.walkControllers(v3Router, API_CONTROLLERS_PATH); +v3app.use('/api/v3', v3Router); module.exports = v3app; diff --git a/website/src/routes/dataexport.js b/website/src/routes/dataexport.js deleted file mode 100644 index af06700026..0000000000 --- a/website/src/routes/dataexport.js +++ /dev/null @@ -1,28 +0,0 @@ -var express = require('express'); -var router = express.Router(); -var dataexport = require('../controllers/api-v2/dataexport'); -var auth = require('../controllers/api-v2/auth'); -var nconf = require('nconf'); -var i18n = require('../libs/api-v2/i18n'); - -const BASE_URL = nconf.get('BASE_URL'); - -/* Data export deprecated routes */ -// TODO remove once api v2 is taken down -router.get('/history.csv', (req, res) => { - res.redirect(`${BASE_URL}/api/v3/export/history.csv`); -}); -router.get('/userdata.xml', (req, res) => { - res.redirect(`${BASE_URL}/api/v3/export/userdata.xml`); -}); -router.get('/userdata.json', (req, res) => { - res.redirect(`${BASE_URL}/api/v3/export/userdata.json`); -}); -router.get('/avatar-:uuid.html', (req, res) => { - res.redirect(`${BASE_URL}/api/v3/export/avatar-${req.params.uuid}.html`); -}); -router.get('/avatar-:uuid.png', (req, res) => { - res.redirect(`${BASE_URL}/api/v3/export/avatar-${req.params.uuid}.png`); -}); - -module.exports = router; diff --git a/website/src/server.js b/website/src/server.js index 95280ffaf6..21fa6e2f5c 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -22,9 +22,6 @@ import './models/challenge'; import './models/group'; import './models/user'; -app.set('view engine', 'jade'); -app.set('views', `${__dirname}/../views`); - attachMiddlewares(app, server); server.on('request', app); From bc9f2b81e7a76c474d6dae8fe0964a2bf2ee0d67 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Apr 2016 13:30:58 +0200 Subject: [PATCH 663/976] refactor getUserLanguage, automatically apply getUserLanguage and cron middlewares where needed --- .../v3/unit/middlewares/errorHandler.test.js | 6 +- .../unit/middlewares/getUserLanguage.test.js | 267 --------------- test/api/v3/unit/middlewares/language.test.js | 307 ++++++++++++++++++ website/src/controllers/api-v3/auth.js | 14 +- website/src/controllers/api-v3/challenges.js | 21 +- website/src/controllers/api-v3/chat.js | 15 +- website/src/controllers/api-v3/coupon.js | 7 +- website/src/controllers/api-v3/debug.js | 9 +- website/src/controllers/api-v3/groups.js | 19 +- website/src/controllers/api-v3/hall.js | 9 +- website/src/controllers/api-v3/members.js | 15 +- website/src/controllers/api-v3/quests.js | 15 +- website/src/controllers/api-v3/tags.js | 11 +- website/src/controllers/api-v3/tasks.js | 35 +- website/src/controllers/api-v3/user.js | 69 ++-- .../src/controllers/top-level/dataexport.js | 7 +- website/src/controllers/top-level/pages.js | 11 +- website/src/libs/api-v3/routes.js | 33 +- website/src/middlewares/api-v3/auth.js | 11 +- website/src/middlewares/api-v3/cron.js | 1 - website/src/middlewares/api-v3/index.js | 8 + .../{getUserLanguage.js => language.js} | 18 +- website/src/middlewares/api-v3/v3.js | 10 +- website/src/routes/api-v2/auth.js | 4 +- website/src/routes/api-v2/coupon.js | 4 +- website/src/routes/api-v2/swagger.js | 4 +- website/src/routes/api-v2/unsubscription.js | 4 +- website/src/routes/payments.js | 4 +- 28 files changed, 507 insertions(+), 431 deletions(-) delete mode 100644 test/api/v3/unit/middlewares/getUserLanguage.test.js create mode 100644 test/api/v3/unit/middlewares/language.test.js rename website/src/middlewares/api-v3/{getUserLanguage.js => language.js} (87%) diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index f93d741820..7c75be9f28 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -6,7 +6,10 @@ import { import errorHandler from '../../../../../website/src/middlewares/api-v3/errorHandler'; import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response'; -import getUserLanguage from '../../../../../website/src/middlewares/api-v3/getUserLanguage'; +import { + getUserLanguage, + attachTranslateFunction, +} from '../../../../../website/src/middlewares/api-v3/language'; import { BadRequest } from '../../../../../website/src/libs/api-v3/errors'; import logger from '../../../../../website/src/libs/api-v3/logger'; @@ -20,6 +23,7 @@ describe('errorHandler', () => { next = generateNext(); responseMiddleware(req, res, next); getUserLanguage(req, res, next); + attachTranslateFunction(req, res, next); sandbox.stub(logger, 'error'); }); diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js deleted file mode 100644 index bd7e6aa48c..0000000000 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ /dev/null @@ -1,267 +0,0 @@ -import { - generateRes, - generateReq, - generateNext, -} from '../../../../helpers/api-unit.helper'; -import getUserLanguage from '../../../../../website/src/middlewares/api-v3/getUserLanguage'; -import { i18n } from '../../../../../common'; -import Q from 'q'; -import { model as User } from '../../../../../website/src/models/user'; - -describe('getUserLanguage', () => { - let res, req, next; - - let checkResT = (resToCheck) => { - expect(resToCheck.t).to.be.a('function'); - expect(resToCheck.t('help')).to.equal(i18n.t('help', req.language)); - }; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - }); - - context('query parameter', () => { - it('uses the language in the query parameter if avalaible', () => { - req.query = { - lang: 'es', - }; - - getUserLanguage(req, res, next); - expect(req.language).to.equal('es'); - checkResT(res); - }); - - it('falls back to english if the query parameter language does not exists', () => { - req.query = { - lang: 'bla', - }; - - getUserLanguage(req, res, next); - expect(req.language).to.equal('en'); - checkResT(res); - }); - - it('uses query even if the request includes a user and session', () => { - req.query = { - lang: 'es', - }; - - req.locals = { - user: { - preferences: { - language: 'it', - }, - }, - }; - - req.session = { - userId: 123, - }; - - getUserLanguage(req, res, next); - expect(req.language).to.equal('es'); - checkResT(res); - }); - }); - - context('authorized request', () => { - it('uses the user preferred language if avalaible', () => { - req.locals = { - user: { - preferences: { - language: 'it', - }, - }, - }; - - getUserLanguage(req, res, next); - expect(req.language).to.equal('it'); - checkResT(res); - }); - - it('falls back to english if the user preferred language is not avalaible', (done) => { - req.locals = { - user: { - preferences: { - language: 'bla', - }, - }, - }; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('en'); - checkResT(res); - done(); - }); - }); - - it('uses the user preferred language even if a session is included in request', () => { - req.locals = { - user: { - preferences: { - language: 'it', - }, - }, - }; - - req.session = { - userId: 123, - }; - - getUserLanguage(req, res, next); - expect(req.language).to.equal('it'); - checkResT(res); - }); - }); - - context('request with session', () => { - it('uses the user preferred language if avalaible', (done) => { - sandbox.stub(User, 'findOne').returns({ - lean () { - return this; - }, - exec () { - return Q.resolve({ - preferences: { - language: 'it', - }, - }); - }, - }); - - req.session = { - userId: 123, - }; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('it'); - checkResT(res); - done(); - }); - }); - }); - - context('browser fallback', () => { - it('uses browser specificed language', (done) => { - req.headers['accept-language'] = 'pt'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('pt'); - checkResT(res); - done(); - }); - }); - - it('uses first language in series if browser specifies multiple', (done) => { - req.headers['accept-language'] = 'he, pt, it'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('he'); - checkResT(res); - done(); - }); - }); - - it('skips invalid lanaguages and uses first language in series if browser specifies multiple', (done) => { - req.headers['accept-language'] = 'blah, he, pt, it'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('he'); - checkResT(res); - done(); - }); - }); - - it('uses normal version of language if specialized locale is passed in', (done) => { - req.headers['accept-language'] = 'fr-CA'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('fr'); - checkResT(res); - done(); - }); - }); - - it('uses normal version of language if specialized locale is passed in', (done) => { - req.headers['accept-language'] = 'fr-CA'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('fr'); - checkResT(res); - done(); - }); - }); - - it('uses es if es is passed in', (done) => { - req.headers['accept-language'] = 'es'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('es'); - checkResT(res); - done(); - }); - }); - - it('uses es_419 if applicable es-languages are passed in', (done) => { - req.headers['accept-language'] = 'es-mx'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('es_419'); - checkResT(res); - done(); - }); - }); - - it('uses es_419 if multiple es languages are passed in', (done) => { - req.headers['accept-language'] = 'es-GT, es-MX, es-CR'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('es_419'); - checkResT(res); - done(); - }); - }); - - it('zh', (done) => { - req.headers['accept-language'] = 'zh-TW'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('zh_TW'); - checkResT(res); - done(); - }); - }); - - it('uses english if browser specified language is not compatible', (done) => { - req.headers['accept-language'] = 'blah'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('en'); - checkResT(res); - done(); - }); - }); - - it('uses english if browser does not specify', (done) => { - req.headers['accept-language'] = ''; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('en'); - checkResT(res); - done(); - }); - }); - - it('uses english if browser does not supply an accept-language header', (done) => { - delete req.headers['accept-language']; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('en'); - checkResT(res); - done(); - }); - }); - }); -}); diff --git a/test/api/v3/unit/middlewares/language.test.js b/test/api/v3/unit/middlewares/language.test.js new file mode 100644 index 0000000000..30feadd10c --- /dev/null +++ b/test/api/v3/unit/middlewares/language.test.js @@ -0,0 +1,307 @@ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; +import { + getUserLanguage, + attachTranslateFunction, +} from '../../../../../website/src/middlewares/api-v3/language'; +import common from '../../../../../common'; +import Q from 'q'; +import { model as User } from '../../../../../website/src/models/user'; + +const i18n = common.i18n; + +describe('language middleware', () => { + describe('res.t', () => { + let res, req, next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + + sinon.stub(i18n, 't'); + }); + + afterEach(() => { + i18n.t.restore(); + }); + + it('attaches t method to res', () => { + attachTranslateFunction(req, res, next); + + expect(res.t).to.exist; + }); + + it('uses the language specified in req.language', () => { + req.language = 'de'; + + attachTranslateFunction(req, res, next); + res.t(1, 2); + + expect(i18n.t).to.be.calledOnce; + expect(i18n.t).to.be.calledWith(1, 2); + }); + }); + + describe('getUserLanguage', () => { + let res, req, next; + + let checkResT = (resToCheck) => { + expect(resToCheck.t).to.be.a('function'); + expect(resToCheck.t('help')).to.equal(i18n.t('help', req.language)); + }; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + attachTranslateFunction(req, res, next); + }); + + context('query parameter', () => { + it('uses the language in the query parameter if avalaible', () => { + req.query = { + lang: 'es', + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('es'); + checkResT(res); + }); + + it('falls back to english if the query parameter language does not exists', () => { + req.query = { + lang: 'bla', + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('en'); + checkResT(res); + }); + + it('uses query even if the request includes a user and session', () => { + req.query = { + lang: 'es', + }; + + req.locals = { + user: { + preferences: { + language: 'it', + }, + }, + }; + + req.session = { + userId: 123, + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('es'); + checkResT(res); + }); + }); + + context('authorized request', () => { + it('uses the user preferred language if avalaible', () => { + req.locals = { + user: { + preferences: { + language: 'it', + }, + }, + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('it'); + checkResT(res); + }); + + it('falls back to english if the user preferred language is not avalaible', (done) => { + req.locals = { + user: { + preferences: { + language: 'bla', + }, + }, + }; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('en'); + checkResT(res); + done(); + }); + }); + + it('uses the user preferred language even if a session is included in request', () => { + req.locals = { + user: { + preferences: { + language: 'it', + }, + }, + }; + + req.session = { + userId: 123, + }; + + getUserLanguage(req, res, next); + expect(req.language).to.equal('it'); + checkResT(res); + }); + }); + + context('request with session', () => { + it('uses the user preferred language if avalaible', (done) => { + sandbox.stub(User, 'findOne').returns({ + lean () { + return this; + }, + exec () { + return Q.resolve({ + preferences: { + language: 'it', + }, + }); + }, + }); + + req.session = { + userId: 123, + }; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('it'); + checkResT(res); + done(); + }); + }); + }); + + context('browser fallback', () => { + it('uses browser specificed language', (done) => { + req.headers['accept-language'] = 'pt'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('pt'); + checkResT(res); + done(); + }); + }); + + it('uses first language in series if browser specifies multiple', (done) => { + req.headers['accept-language'] = 'he, pt, it'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('he'); + checkResT(res); + done(); + }); + }); + + it('skips invalid lanaguages and uses first language in series if browser specifies multiple', (done) => { + req.headers['accept-language'] = 'blah, he, pt, it'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('he'); + checkResT(res); + done(); + }); + }); + + it('uses normal version of language if specialized locale is passed in', (done) => { + req.headers['accept-language'] = 'fr-CA'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('fr'); + checkResT(res); + done(); + }); + }); + + it('uses normal version of language if specialized locale is passed in', (done) => { + req.headers['accept-language'] = 'fr-CA'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('fr'); + checkResT(res); + done(); + }); + }); + + it('uses es if es is passed in', (done) => { + req.headers['accept-language'] = 'es'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('es'); + checkResT(res); + done(); + }); + }); + + it('uses es_419 if applicable es-languages are passed in', (done) => { + req.headers['accept-language'] = 'es-mx'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('es_419'); + checkResT(res); + done(); + }); + }); + + it('uses es_419 if multiple es languages are passed in', (done) => { + req.headers['accept-language'] = 'es-GT, es-MX, es-CR'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('es_419'); + checkResT(res); + done(); + }); + }); + + it('zh', (done) => { + req.headers['accept-language'] = 'zh-TW'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('zh_TW'); + checkResT(res); + done(); + }); + }); + + it('uses english if browser specified language is not compatible', (done) => { + req.headers['accept-language'] = 'blah'; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('en'); + checkResT(res); + done(); + }); + }); + + it('uses english if browser does not specify', (done) => { + req.headers['accept-language'] = ''; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('en'); + checkResT(res); + done(); + }); + }); + + it('uses english if browser does not supply an accept-language header', (done) => { + delete req.headers['accept-language']; + + getUserLanguage(req, res, () => { + expect(req.language).to.equal('en'); + checkResT(res); + done(); + }); + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index c2faf298a9..37d3b08499 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -6,7 +6,6 @@ import { authWithHeaders, authWithSession, } from '../../middlewares/api-v3/auth'; -import cron from '../../middlewares/api-v3/cron'; import { NotAuthorized, BadRequest, @@ -232,7 +231,6 @@ function _passportFbProfile (accessToken) { api.loginSocial = { method: 'POST', url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 - middlewares: [cron], async handler (req, res) { let accessToken = req.body.authResponse.access_token; let network = req.body.network; @@ -292,7 +290,7 @@ api.loginSocial = { **/ api.updateUsername = { method: 'PUT', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/auth/update-username', async handler (req, res) { let user = res.locals.user; @@ -338,7 +336,7 @@ api.updateUsername = { **/ api.updatePassword = { method: 'PUT', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/auth/update-password', async handler (req, res) { let user = res.locals.user; @@ -428,7 +426,7 @@ api.resetPassword = { */ api.updateEmail = { method: 'PUT', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/auth/update-email', async handler (req, res) { let user = res.locals.user; @@ -456,7 +454,7 @@ const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SE api.getFirebaseToken = { method: 'POST', url: '/user/auth/firebase', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; // Expires 24 hours from now (60*60*24*1000) (in milliseconds) @@ -483,7 +481,7 @@ api.getFirebaseToken = { api.deleteSocial = { method: 'DELETE', url: '/user/auth/social/:network', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let network = req.params.network; @@ -501,7 +499,7 @@ api.deleteSocial = { api.logout = { method: 'GET', url: '/user/auth/logout', // TODO this is under /api/v3 route, should be accessible through habitica.com/logout - middlewares: [authWithSession, cron], + middlewares: [authWithSession], async handler (req, res) { req.logout(); // passportjs method req.session = null; diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index b3dd588118..5ee92d8d22 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -1,6 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import _ from 'lodash'; -import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; import { model as Group, @@ -35,7 +34,7 @@ let api = {}; api.createChallenge = { method: 'POST', url: '/challenges', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -125,7 +124,7 @@ api.createChallenge = { api.joinChallenge = { method: 'POST', url: '/challenges/:challengeId/join', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -171,7 +170,7 @@ api.joinChallenge = { api.leaveChallenge = { method: 'POST', url: '/challenges/:challengeId/leave', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let keep = req.body.keep === 'remove-all' ? 'remove-all' : 'keep-all'; @@ -208,7 +207,7 @@ api.leaveChallenge = { api.getUserChallenges = { method: 'GET', url: '/challenges/user', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -254,7 +253,7 @@ api.getUserChallenges = { api.getGroupChallenges = { method: 'GET', url: '/challenges/groups/:groupId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; @@ -297,7 +296,7 @@ api.getGroupChallenges = { api.getChallenge = { method: 'GET', url: '/challenges/:challengeId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); @@ -339,7 +338,7 @@ api.getChallenge = { api.exportChallengeCsv = { method: 'GET', url: '/challenges/:challengeId/export/csv', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); @@ -412,7 +411,7 @@ api.exportChallengeCsv = { api.updateChallenge = { method: 'PUT', url: '/challenges/:challengeId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); @@ -513,7 +512,7 @@ export async function _closeChal (challenge, broken = {}) { api.deleteChallenge = { method: 'DELETE', url: '/challenges/:challengeId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -543,7 +542,7 @@ api.deleteChallenge = { api.selectChallengeWinner = { method: 'POST', url: '/challenges/:challengeId/selectWinner/:winnerId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 43e4ba69e9..ddc6cf3df8 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -1,5 +1,4 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import cron from '../../middlewares/api-v3/cron'; import { model as Group, TAVERN_ID, @@ -33,7 +32,7 @@ let api = {}; api.getChat = { method: 'GET', url: '/groups/:groupId/chat', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -64,7 +63,7 @@ api.getChat = { api.postChat = { method: 'POST', url: '/groups/:groupId/chat', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; @@ -116,7 +115,7 @@ api.postChat = { api.likeChat = { method: 'POST', url: '/groups/:groupId/chat/:chatId/like', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; @@ -163,7 +162,7 @@ api.likeChat = { api.flagChat = { method: 'POST', url: '/groups/:groupId/chat/:chatId/flag', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; @@ -268,7 +267,7 @@ api.flagChat = { api.clearChatFlags = { method: 'Post', url: '/groups/:groupId/chat/:chatId/clearflags', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; @@ -312,7 +311,7 @@ api.clearChatFlags = { api.seenChat = { method: 'POST', url: '/groups/:groupId/chat/seen', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; @@ -348,7 +347,7 @@ api.seenChat = { api.deleteChat = { method: 'DELETE', url: '/groups/:groupId/chat/:chatId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; diff --git a/website/src/controllers/api-v3/coupon.js b/website/src/controllers/api-v3/coupon.js index 0890878a54..2fa572f000 100644 --- a/website/src/controllers/api-v3/coupon.js +++ b/website/src/controllers/api-v3/coupon.js @@ -3,7 +3,6 @@ import { authWithHeaders, authWithSession, } from '../../middlewares/api-v3/auth'; -import cron from '../../middlewares/api-v3/cron'; import { ensureSudo } from '../../middlewares/api-v3/ensureAccessRight'; import { model as Coupon } from '../../models/coupon'; import _ from 'lodash'; @@ -22,7 +21,7 @@ let api = {}; api.getCoupons = { method: 'GET', url: '/coupons', - middlewares: [authWithSession, cron, ensureSudo], + middlewares: [authWithSession, ensureSudo], async handler (req, res) { let coupons = await Coupon.find().sort('createdAt').lean().exec(); @@ -53,7 +52,7 @@ api.getCoupons = { api.generateCoupons = { method: 'POST', url: '/coupons/generate/:event', - middlewares: [authWithHeaders(), cron, ensureSudo], + middlewares: [authWithHeaders(), ensureSudo], async handler (req, res) { req.checkParams('event', res.t('eventRequired')).notEmpty(); req.checkQuery('count', res.t('countRequired')).notEmpty().isNumeric(); @@ -79,7 +78,7 @@ api.generateCoupons = { api.enterCouponCode = { method: 'POST', url: '/coupons/enter/:code', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/debug.js b/website/src/controllers/api-v3/debug.js index b5bc63a45e..1616bf2574 100644 --- a/website/src/controllers/api-v3/debug.js +++ b/website/src/controllers/api-v3/debug.js @@ -1,15 +1,8 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import cron from '../../middlewares/api-v3/cron'; import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode'; let api = {}; -api.debug = { - method: 'all', - url: '/debug/*', - middlewares: [ensureDevelpmentMode, authWithHeaders(), cron], -}; - /** * @api {post} /debug/add-ten-gems Add ten gems to the current user * @apiVersion 3.0.0 @@ -21,6 +14,7 @@ api.debug = { api.addTenGems = { method: 'POST', url: '/debug/add-ten-gems', + middlewares: [ensureDevelpmentMode, authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -43,6 +37,7 @@ api.addTenGems = { api.addHourglass = { method: 'POST', url: '/debug/add-hourglass', + middlewares: [ensureDevelpmentMode, authWithHeaders()], async handler (req, res) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 333e38a4a5..7fffb7f3c0 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -1,7 +1,6 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import Q from 'q'; import _ from 'lodash'; -import cron from '../../middlewares/api-v3/cron'; import { INVITES_LIMIT, model as Group, @@ -38,7 +37,7 @@ let api = {}; api.createGroup = { method: 'POST', url: '/groups', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let group = new Group(Group.sanitize(req.body)); // TODO validate empty req.body @@ -89,7 +88,7 @@ api.createGroup = { api.getGroups = { method: 'GET', url: '/groups', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -125,7 +124,7 @@ api.getGroups = { api.getGroup = { method: 'GET', url: '/groups/:groupId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -159,7 +158,7 @@ api.getGroup = { api.updateGroup = { method: 'PUT', url: '/groups/:groupId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -205,7 +204,7 @@ api.updateGroup = { api.joinGroup = { method: 'POST', url: '/groups/:groupId/join', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let inviter; @@ -296,7 +295,7 @@ api.joinGroup = { api.rejectGroupInvite = { method: 'POST', url: '/groups/:groupId/reject-invite', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -342,7 +341,7 @@ api.rejectGroupInvite = { api.leaveGroup = { method: 'POST', url: '/groups/:groupId/leave', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -399,7 +398,7 @@ function _sendMessageToRemoved (group, removedUser, message) { api.removeGroupMember = { method: 'POST', url: '/groups/:groupId/removeMember/:memberId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -602,7 +601,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) { api.inviteToGroup = { method: 'POST', url: '/groups/:groupId/invite', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/hall.js b/website/src/controllers/api-v3/hall.js index 35d5dec2af..05ff2fc54e 100644 --- a/website/src/controllers/api-v3/hall.js +++ b/website/src/controllers/api-v3/hall.js @@ -1,6 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import { ensureAdmin } from '../../middlewares/api-v3/ensureAccessRight'; -import cron from '../../middlewares/api-v3/cron'; import { model as User } from '../../models/user'; import { NotFound, @@ -22,7 +21,7 @@ let api = {}; api.getPatrons = { method: 'GET', url: '/hall/patrons', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkQuery('page', res.t('pageMustBeNumber')).optional().isNumeric(); @@ -58,7 +57,7 @@ api.getPatrons = { api.getHeroes = { method: 'GET', url: '/hall/heroes', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let heroes = await User .find({ @@ -90,7 +89,7 @@ const heroAdminFields = 'contributor balance profile.name purchased items auth'; api.getHero = { method: 'GET', url: '/hall/heroes/:heroId', - middlewares: [authWithHeaders(), cron, ensureAdmin], + middlewares: [authWithHeaders(), ensureAdmin], async handler (req, res) { let heroId = req.params.heroId; @@ -127,7 +126,7 @@ const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0}; api.updateHero = { method: 'PUT', url: '/hall/heroes/:heroId', - middlewares: [authWithHeaders(), cron, ensureAdmin], + middlewares: [authWithHeaders(), ensureAdmin], async handler (req, res) { let heroId = req.params.heroId; let updateData = req.body; diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 0123955a73..f6ee561981 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -1,5 +1,4 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import cron from '../../middlewares/api-v3/cron'; import { model as User, publicFields as memberFields, @@ -33,7 +32,7 @@ let api = {}; api.getMember = { method: 'GET', url: '/members/:memberId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); @@ -144,7 +143,7 @@ function _getMembersForItem (type) { api.getMembersForGroup = { method: 'GET', url: '/groups/:groupId/members', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], handler: _getMembersForItem('group-members'), }; @@ -162,7 +161,7 @@ api.getMembersForGroup = { api.getInvitesForGroup = { method: 'GET', url: '/groups/:groupId/invites', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], handler: _getMembersForItem('group-invites'), }; @@ -180,7 +179,7 @@ api.getInvitesForGroup = { api.getMembersForChallenge = { method: 'GET', url: '/challenges/:challengeId/members', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], handler: _getMembersForItem('challenge-members'), }; @@ -198,7 +197,7 @@ api.getMembersForChallenge = { api.getChallengeMemberProgress = { method: 'GET', url: '/challenges/:challengeId/members/:memberId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); @@ -251,7 +250,7 @@ api.getChallengeMemberProgress = { api.sendPrivateMessage = { method: 'POST', url: '/members/send-private-message', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkBody('message', res.t('messageRequired')).notEmpty(); req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID(); @@ -300,7 +299,7 @@ api.sendPrivateMessage = { api.transferGems = { method: 'POST', url: '/members/transfer-gems', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkBody('message', res.t('messageRequired')).notEmpty(); req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID(); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 9c0cf44502..bc808ab973 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -1,7 +1,6 @@ import _ from 'lodash'; import Q from 'q'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import cron from '../../middlewares/api-v3/cron'; import analytics from '../../libs/api-v3/analyticsService'; import { model as Group, @@ -42,7 +41,7 @@ let api = {}; api.inviteToQuest = { method: 'POST', url: '/groups/:groupId/quests/invite/:questKey', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let questKey = req.params.questKey; @@ -145,7 +144,7 @@ api.inviteToQuest = { api.acceptQuest = { method: 'POST', url: '/groups/:groupId/quests/accept', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -202,7 +201,7 @@ api.acceptQuest = { api.rejectQuest = { method: 'POST', url: '/groups/:groupId/quests/reject', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -261,7 +260,7 @@ api.rejectQuest = { api.forceStart = { method: 'POST', url: '/groups/:groupId/quests/force-start', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -313,7 +312,7 @@ api.forceStart = { api.cancelQuest = { method: 'POST', url: '/groups/:groupId/quests/cancel', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { // Cancel a quest BEFORE it has begun (i.e., in the invitation stage) // Quest scroll has not yet left quest owner's inventory so no need to return it. @@ -362,7 +361,7 @@ api.cancelQuest = { api.abortQuest = { method: 'POST', url: '/groups/:groupId/quests/abort', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { // Abort a quest AFTER it has begun (see questCancel for BEFORE) let user = res.locals.user; @@ -416,7 +415,7 @@ api.abortQuest = { api.leaveQuest = { method: 'POST', url: '/groups/:groupId/quests/leave', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 4a68512b19..76c3ffb7fa 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -1,5 +1,4 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import cron from '../../middlewares/api-v3/cron'; import { model as Tag } from '../../models/tag'; import * as Tasks from '../../models/task'; import { @@ -20,7 +19,7 @@ let api = {}; api.createTag = { method: 'POST', url: '/tags', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -44,7 +43,7 @@ api.createTag = { api.getTags = { method: 'GET', url: '/tags', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; res.respond(200, user.tags); @@ -64,7 +63,7 @@ api.getTags = { api.getTag = { method: 'GET', url: '/tags/:tagId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -92,7 +91,7 @@ api.getTag = { api.updateTag = { method: 'PUT', url: '/tags/:tagId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -126,7 +125,7 @@ api.updateTag = { api.deleteTag = { method: 'DELETE', url: '/tags/:tagId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 5a06a58f25..5e9756da49 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,5 +1,4 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import cron from '../../middlewares/api-v3/cron'; import { sendTaskWebhook } from '../../libs/api-v3/webhook'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as Tasks from '../../models/task'; @@ -66,7 +65,7 @@ async function _createTasks (req, res, user, challenge) { api.createUserTasks = { method: 'POST', url: '/tasks/user', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let tasks = await _createTasks(req, res, res.locals.user); res.respond(201, tasks.length === 1 ? tasks[0] : tasks); @@ -87,7 +86,7 @@ api.createUserTasks = { api.createChallengeTasks = { method: 'POST', url: '/tasks/challenge/:challengeId', // TODO should be /tasks/challengeS/:challengeId ? plural? - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); @@ -177,7 +176,7 @@ async function _getTasks (req, res, user, challenge) { api.getUserTasks = { method: 'GET', url: '/tasks/user', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let types = Tasks.tasksTypes.map(type => `${type}s`); types.push('completedTodos'); @@ -204,7 +203,7 @@ api.getUserTasks = { api.getChallengeTasks = { method: 'GET', url: '/tasks/challenge/:challengeId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); let types = Tasks.tasksTypes.map(type => `${type}s`); @@ -238,7 +237,7 @@ api.getChallengeTasks = { api.getTask = { method: 'GET', url: '/tasks/:taskId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -279,7 +278,7 @@ api.getTask = { api.updateTask = { method: 'PUT', url: '/tasks/:taskId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let challenge; @@ -357,7 +356,7 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { api.scoreTask = { method: 'POST', url: '/tasks/:taskId/score/:direction', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route? @@ -440,7 +439,7 @@ api.scoreTask = { api.moveTask = { method: 'POST', url: '/tasks/:taskId/move/to/:position', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); @@ -494,7 +493,7 @@ api.moveTask = { api.addChecklistItem = { method: 'POST', url: '/tasks/:taskId/checklist', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let challenge; @@ -543,7 +542,7 @@ api.addChecklistItem = { api.scoreCheckListItem = { method: 'POST', url: '/tasks/:taskId/checklist/:itemId/score', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -585,7 +584,7 @@ api.scoreCheckListItem = { api.updateChecklistItem = { method: 'PUT', url: '/tasks/:taskId/checklist/:itemId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let challenge; @@ -636,7 +635,7 @@ api.updateChecklistItem = { api.removeChecklistItem = { method: 'DELETE', url: '/tasks/:taskId/checklist/:itemId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let challenge; @@ -685,7 +684,7 @@ api.removeChecklistItem = { api.addTagToTask = { method: 'POST', url: '/tasks/:taskId/tags/:tagId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -728,7 +727,7 @@ api.addTagToTask = { api.removeTagFromTask = { method: 'DELETE', url: '/tasks/:taskId/tags/:tagId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -767,7 +766,7 @@ api.removeTagFromTask = { api.unlinkTask = { method: 'POST', url: '/tasks/unlink/:taskId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkQuery('keep', res.t('keepOrRemove')).notEmpty().isIn(['keep', 'remove']); @@ -814,7 +813,7 @@ api.unlinkTask = { api.clearCompletedTodos = { method: 'POST', url: '/tasks/clearCompletedTodos', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; @@ -847,7 +846,7 @@ api.clearCompletedTodos = { api.deleteTask = { method: 'DELETE', url: '/tasks/:taskId', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; let challenge; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index af1fb94ee4..083aaef5fa 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1,5 +1,4 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import cron from '../../middlewares/api-v3/cron'; import common from '../../../../common'; import { NotFound, @@ -29,7 +28,7 @@ let api = {}; */ api.getUser = { method: 'GET', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user', async handler (req, res) { let user = res.locals.user.toJSON(); @@ -56,7 +55,7 @@ api.getUser = { */ api.getBuyList = { method: 'GET', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/inventory/buy', async handler (req, res) { let list = _.cloneDeep(common.updateStore(res.locals.user)); @@ -150,7 +149,7 @@ let checkPreferencePurchase = (user, path, item) => { */ api.updateUser = { method: 'PUT', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user', async handler (req, res) { let user = res.locals.user; @@ -186,7 +185,7 @@ api.updateUser = { */ api.deleteUser = { method: 'DELETE', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user', async handler (req, res) { let user = res.locals.user; @@ -246,7 +245,7 @@ function _cleanChecklist (task) { **/ api.getUserAnonymized = { method: 'GET', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/anonymized', async handler (req, res) { let user = res.locals.user.toJSON(); @@ -313,7 +312,7 @@ const partyMembersFields = 'profile.name stats achievements items.special'; */ api.castSpell = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/class/cast/:spellId', async handler (req, res) { let user = res.locals.user; @@ -423,7 +422,7 @@ api.castSpell = { */ api.sleep = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/sleep', async handler (req, res) { let user = res.locals.user; @@ -443,7 +442,7 @@ api.sleep = { */ api.allocate = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/allocate', async handler (req, res) { let user = res.locals.user; @@ -463,7 +462,7 @@ api.allocate = { */ api.allocateNow = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/allocate-now', async handler (req, res) { let user = res.locals.user; @@ -487,7 +486,7 @@ api.allocateNow = { */ api.buy = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/buy/:key', async handler (req, res) { let user = res.locals.user; @@ -510,7 +509,7 @@ api.buy = { */ api.buyMysterySet = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/buy-mystery-set/:key', async handler (req, res) { let user = res.locals.user; @@ -533,7 +532,7 @@ api.buyMysterySet = { */ api.buyQuest = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/buy-quest/:key', async handler (req, res) { let user = res.locals.user; @@ -556,7 +555,7 @@ api.buyQuest = { */ api.buySpecialSpell = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/buy-special-spell/:key', async handler (req, res) { let user = res.locals.user; @@ -580,7 +579,7 @@ api.buySpecialSpell = { */ api.hatch = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/hatch/:egg/:hatchingPotion', async handler (req, res) { let user = res.locals.user; @@ -604,7 +603,7 @@ api.hatch = { */ api.equip = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/equip/:type/:key', async handler (req, res) { let user = res.locals.user; @@ -628,7 +627,7 @@ api.equip = { */ api.feed = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/feed/:pet/:food', async handler (req, res) { let user = res.locals.user; @@ -650,7 +649,7 @@ api.feed = { */ api.changeClass = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/change-class', async handler (req, res) { let user = res.locals.user; @@ -670,7 +669,7 @@ api.changeClass = { */ api.disableClasses = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/disable-classes', async handler (req, res) { let user = res.locals.user; @@ -693,7 +692,7 @@ api.disableClasses = { */ api.purchase = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/purchase/:type/:key', async handler (req, res) { let user = res.locals.user; @@ -716,7 +715,7 @@ api.purchase = { */ api.userPurchaseHourglass = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/purchase-hourglass/:type/:key', async handler (req, res) { let user = res.locals.user; @@ -738,7 +737,7 @@ api.userPurchaseHourglass = { */ api.readCard = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/read-card/:cardType', async handler (req, res) { let user = res.locals.user; @@ -758,7 +757,7 @@ api.readCard = { */ api.userOpenMysteryItem = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/open-mystery-item', async handler (req, res) { let user = res.locals.user; @@ -835,7 +834,7 @@ api.deleteWebhook = { */ api.userReleasePets = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/release-pets', async handler (req, res) { let user = res.locals.user; @@ -855,7 +854,7 @@ api.userReleasePets = { */ api.userReleaseBoth = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/release-both', async handler (req, res) { let user = res.locals.user; @@ -875,7 +874,7 @@ api.userReleaseBoth = { */ api.userReleaseMounts = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/release-mounts', async handler (req, res) { let user = res.locals.user; @@ -895,7 +894,7 @@ api.userReleaseMounts = { */ api.userSell = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/sell/:type/:key', async handler (req, res) { let user = res.locals.user; @@ -915,7 +914,7 @@ api.userSell = { */ api.userUnlock = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/unlock', async handler (req, res) { let user = res.locals.user; @@ -935,7 +934,7 @@ api.userUnlock = { */ api.userRevive = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/revive', async handler (req, res) { let user = res.locals.user; @@ -955,7 +954,7 @@ api.userRevive = { */ api.userRebirth = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/rebirth', async handler (req, res) { let user = res.locals.user; @@ -1002,7 +1001,7 @@ api.blockUser = { **/ api.deleteMessage = { method: 'DELETE', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/messages/:id', async handler (req, res) { let user = res.locals.user; @@ -1021,7 +1020,7 @@ api.deleteMessage = { **/ api.clearMessages = { method: 'DELETE', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/messages', async handler (req, res) { let user = res.locals.user; @@ -1041,7 +1040,7 @@ api.clearMessages = { */ api.userReroll = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/reroll', async handler (req, res) { let user = res.locals.user; @@ -1071,7 +1070,7 @@ api.userReroll = { */ api.userAddPushDevice = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/addPushDevice', async handler (req, res) { let user = res.locals.user; @@ -1093,7 +1092,7 @@ api.userAddPushDevice = { */ api.userReset = { method: 'POST', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], url: '/user/reset', async handler (req, res) { let user = res.locals.user; diff --git a/website/src/controllers/top-level/dataexport.js b/website/src/controllers/top-level/dataexport.js index 81f257cb58..5b0b2eb277 100644 --- a/website/src/controllers/top-level/dataexport.js +++ b/website/src/controllers/top-level/dataexport.js @@ -1,5 +1,4 @@ import { authWithSession } from '../../middlewares/api-v3/auth'; -import cron from '../../middlewares/api-v3/cron'; import { model as User } from '../../models/user'; import * as Tasks from '../../models/task'; import { @@ -39,7 +38,7 @@ let api = {}; api.exportUserHistory = { method: 'GET', url: '/export/history.csv', - middlewares: [authWithSession, cron], + middlewares: [authWithSession], async handler (req, res) { let user = res.locals.user; @@ -107,7 +106,7 @@ async function _getUserDataForExport (user) { api.exportUserDataJson = { method: 'GET', url: '/export/userdata.json', - middlewares: [authWithSession, cron], + middlewares: [authWithSession], async handler (req, res) { let userData = await _getUserDataForExport(res.locals.user); @@ -132,7 +131,7 @@ api.exportUserDataJson = { api.exportUserDataXml = { method: 'GET', url: '/export/userdata.xml', - middlewares: [authWithSession, cron], + middlewares: [authWithSession], async handler (req, res) { let userData = await _getUserDataForExport(res.locals.user); diff --git a/website/src/controllers/top-level/pages.js b/website/src/controllers/top-level/pages.js index 3174c8fe17..0d1800ee16 100644 --- a/website/src/controllers/top-level/pages.js +++ b/website/src/controllers/top-level/pages.js @@ -1,5 +1,4 @@ import locals from '../../middlewares/api-v3/locals'; -import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; import _ from 'lodash'; const marked = require('marked'); @@ -11,7 +10,8 @@ const TOTAL_USER_COUNT = '1,100,000'; api.getFrontPage = { method: 'GET', url: '/', - middlewares: [getUserLanguage, locals], + middlewares: [locals], + runCron: false, async handler (req, res) { if (!req.header('x-api-user') && !req.header('x-api-key') && !(req.session && req.session.userId)) { return res.redirect('/static/front'); @@ -34,7 +34,8 @@ _.each(staticPages, (name) => { api[`get${name}Page`] = { method: 'GET', url: `/static/${name}`, - middlewares: [getUserLanguage, locals], + middlewares: [locals], + runCron: false, async handler (req, res) { res.render(`static/${name}.jade`, { env: res.locals.habitrpg, @@ -51,7 +52,8 @@ _.each(shareables, (name) => { api[`get${name}ShareablePage`] = { method: 'GET', url: `/social/${name}`, - middlewares: [getUserLanguage, locals], + middlewares: [locals], + runCron: false, async handler (req, res) { res.render(`social/${name}`, { env: res.locals.habitrpg, @@ -65,6 +67,7 @@ _.each(shareables, (name) => { api.redirectExtensionsPage = { method: 'GET', url: '/static/extensions', + runCron: false, async handler (req, res) { res.redirect('http://habitica.wikia.com/wiki/App_and_Extension_Integrations'); }, diff --git a/website/src/libs/api-v3/routes.js b/website/src/libs/api-v3/routes.js index 0a6f3170ab..588cd50004 100644 --- a/website/src/libs/api-v3/routes.js +++ b/website/src/libs/api-v3/routes.js @@ -1,5 +1,9 @@ import fs from 'fs'; import _ from 'lodash'; +import { + getUserLanguage, +} from '../../middlewares/api-v3/language'; +import cron from '../../middlewares/api-v3/cron'; // Wrapper function to handler `async` route handlers that return promises // It takes the async function, execute it and pass any error to next (args[2]) @@ -8,12 +12,39 @@ let noop = (req, res, next) => next(); module.exports.readController = function readController (router, controller) { _.each(controller, (action) => { - let {method, url, middlewares = [], handler} = action; + let {method, url, middlewares = [], handler, runCron} = action; + + // If an authentication middleware is used run getUserLanguage after it, otherwise before + // for cron instead use it only if an authentication middleware is present + let authMiddlewareIndex = _.findIndex(middlewares, middleware => { + if (middleware.name.indexOf('authWith') === 0) { // authWith{Headers|Session|Url|...} + return true; + } else { + return false; + } + }); + + let middlewaresToAdd = [getUserLanguage]; + + if (authMiddlewareIndex !== -1) { // the user will be authenticated, getUserLanguage and cron after authentication + if (!(runCron === false)) { // eslint-disable-line no-extra-parens + middlewaresToAdd.push(cron); + } + + if (authMiddlewareIndex === middlewares.length - 1) { + middlewares.push(...middlewaresToAdd); + } else { + middlewares.splice(authMiddlewareIndex + 1, 0, ...middlewaresToAdd); + } + } else { // no auth, getUserLanguage as the first middleware + middlewares.unshift(...middlewaresToAdd); + } method = method.toLowerCase(); let fn = handler ? _wrapAsyncFn(handler) : noop; router[method](url, ...middlewares, fn); + console.log(url, middlewares.map(m => m.name)); }); }; diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index f1672dcf06..d1fcfe2e7b 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -1,12 +1,11 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; -import common from '../../../../common'; import { model as User, } from '../../models/user'; -const i18n = common.i18n; +// TODO how to translate the strings here since getUserLanguage hasn't run yet? // Authenticate a request through the x-api-user and x-api key header // If optional is true, don't error on missing authentication @@ -26,8 +25,8 @@ export function authWithHeaders (optional = false) { }) .exec() .then((user) => { - if (!user) throw new NotAuthorized(i18n.t('invalidCredentials')); - if (user.auth.blocked) throw new NotAuthorized(i18n.t('accountSuspended', {userId: user._id})); + if (!user) throw new NotAuthorized(res.t('invalidCredentials')); + if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id})); res.locals.user = user; // TODO use either session/cookie or headers, not both @@ -42,14 +41,14 @@ export function authWithHeaders (optional = false) { export function authWithSession (req, res, next) { let userId = req.session.userId; - if (!userId) return next(new NotAuthorized(i18n.t('invalidCredentials'))); + if (!userId) return next(new NotAuthorized(res.t('invalidCredentials'))); User.findOne({ _id: userId, }) .exec() .then((user) => { - if (!user) throw new NotAuthorized(i18n.t('invalidCredentials')); + if (!user) throw new NotAuthorized(res.t('invalidCredentials')); res.locals.user = user; next(); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 79eec6d654..bcdcc3591c 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -271,7 +271,6 @@ function cron (options = {}) { return _progress; } -// TODO check that it's used everywhere module.exports = function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 8e71e22e84..54567791e3 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -21,6 +21,10 @@ import { import v1 from './v1'; import v2 from './v2'; import v3 from './v3'; +import responseHandler from './response'; +import { + attachTranslateFunction, +} from './language'; const IS_PROD = nconf.get('IS_PROD'); const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); @@ -37,6 +41,10 @@ module.exports = function attachMiddlewares (app, server) { if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev')); + // add res.respond and res.t + app.use(responseHandler); + app.use(attachTranslateFunction); + app.use(compression()); app.use(favicon(`${PUBLIC_DIR}/favicon.ico`)); diff --git a/website/src/middlewares/api-v3/getUserLanguage.js b/website/src/middlewares/api-v3/language.js similarity index 87% rename from website/src/middlewares/api-v3/getUserLanguage.js rename to website/src/middlewares/api-v3/language.js index df372c02b4..8508a09187 100644 --- a/website/src/middlewares/api-v3/getUserLanguage.js +++ b/website/src/middlewares/api-v3/language.js @@ -1,6 +1,6 @@ import { model as User } from '../../models/user'; import accepts from 'accepts'; -import { i18n } from '../../../../common'; +import common from '../../../../common'; import _ from 'lodash'; import { translations, @@ -8,6 +8,8 @@ import { multipleVersionsLanguages, } from '../../libs/api-v3/i18n'; +const i18n = common.i18n; + function _getUniqueListOfLanguages (languages) { let acceptableLanguages = _(languages).map((lang) => { return lang.slice(0, 2); @@ -56,7 +58,7 @@ function _getFromUser (user, req) { return lang; } -function _attachTranslateFunction (req, res, next) { +export function attachTranslateFunction (req, res, next) { res.t = function reqTranslation () { return i18n.t(...arguments, req.language); }; @@ -64,13 +66,13 @@ function _attachTranslateFunction (req, res, next) { next(); } -module.exports = function getUserLanguage (req, res, next) { +export function getUserLanguage (req, res, next) { if (req.query.lang) { // In case the language is specified in the request url, use it req.language = translations[req.query.lang] ? req.query.lang : 'en'; - return _attachTranslateFunction(...arguments); + return next(); } else if (req.locals && req.locals.user) { // If the request is authenticated, use the user's preferred language req.language = _getFromUser(req.locals.user, req); - return _attachTranslateFunction(...arguments); + return next(); } else if (req.session && req.session.userId) { // Same thing if the user has a valid session User.findOne({ _id: req.session.userId, @@ -79,11 +81,11 @@ module.exports = function getUserLanguage (req, res, next) { .exec() .then((user) => { req.language = _getFromUser(user, req); - return _attachTranslateFunction(...arguments); + return next(); }) .catch(next); } else { // Otherwise get from browser req.language = _getFromUser(null, req); - return _attachTranslateFunction(...arguments); + return next(); } -}; +} diff --git a/website/src/middlewares/api-v3/v3.js b/website/src/middlewares/api-v3/v3.js index bd9fe7c4da..f9aa637fa0 100644 --- a/website/src/middlewares/api-v3/v3.js +++ b/website/src/middlewares/api-v3/v3.js @@ -1,12 +1,13 @@ import express from 'express'; import expressValidator from 'express-validator'; -import getUserLanguage from './getUserLanguage'; -import responseHandler from './response'; import analytics from './analytics'; import setupBody from './setupBody'; import routes from '../../libs/api-v3/routes'; import path from 'path'; +const API_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); +const TOP_LEVEL_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/top-level/'); + const v3app = express(); // re-set the view options because they are not inherited from the top level app @@ -16,15 +17,12 @@ v3app.set('views', `${__dirname}/../../../views`); v3app.use(expressValidator()); v3app.use(analytics); v3app.use(setupBody); -v3app.use(responseHandler); -v3app.use(getUserLanguage); // TODO move to after auth for authenticated routes -const TOP_LEVEL_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/top-level/'); const topLevelRouter = express.Router(); // eslint-disable-line babel/new-cap + routes.walkControllers(topLevelRouter, TOP_LEVEL_CONTROLLERS_PATH); v3app.use('/', topLevelRouter); -const API_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); const v3Router = express.Router(); // eslint-disable-line babel/new-cap routes.walkControllers(v3Router, API_CONTROLLERS_PATH); v3app.use('/api/v3', v3Router); diff --git a/website/src/routes/api-v2/auth.js b/website/src/routes/api-v2/auth.js index 468612f8cf..f8af1b4338 100644 --- a/website/src/routes/api-v2/auth.js +++ b/website/src/routes/api-v2/auth.js @@ -2,7 +2,9 @@ var auth = require('../../controllers/api-v2/auth'); var express = require('express'); var i18n = require('../../libs/api-v2/i18n'); var router = express.Router(); -import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; +import { + getUserLanguage +} from '../../middlewares/api-v3/language'; /* auth.auth*/ // auth.setupPassport(router); //TODO make this consistent with the others diff --git a/website/src/routes/api-v2/coupon.js b/website/src/routes/api-v2/coupon.js index 29fdd75312..7caee3f815 100644 --- a/website/src/routes/api-v2/coupon.js +++ b/website/src/routes/api-v2/coupon.js @@ -4,7 +4,9 @@ var router = express.Router(); var auth = require('../../controllers/api-v2/auth'); var coupon = require('../../controllers/api-v2/coupon'); var i18n = require('../../libs/api-v2/i18n'); -import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; +import { + getUserLanguage +} from '../../middlewares/api-v3/language'; router.get('/coupons', auth.authWithUrl, getUserLanguage, coupon.ensureAdmin, coupon.getCoupons); router.post('/coupons/generate/:event', auth.auth, getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons); diff --git a/website/src/routes/api-v2/swagger.js b/website/src/routes/api-v2/swagger.js index 4912bbd70b..c60d8364b6 100644 --- a/website/src/routes/api-v2/swagger.js +++ b/website/src/routes/api-v2/swagger.js @@ -19,7 +19,9 @@ var cron = user.cron; var _ = require('lodash'); var content = require('../../../../common').content; var i18n = require('../../libs/api-v2/i18n'); -import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; +import { + getUserLanguage +} from '../../middlewares/api-v3/language'; var forceRefresh = require('../../middlewares/forceRefresh').middleware; module.exports = function(swagger, v2) { diff --git a/website/src/routes/api-v2/unsubscription.js b/website/src/routes/api-v2/unsubscription.js index 08393edb79..cbd2e16554 100644 --- a/website/src/routes/api-v2/unsubscription.js +++ b/website/src/routes/api-v2/unsubscription.js @@ -2,7 +2,9 @@ var express = require('express'); var router = express.Router(); var i18n = require('../../libs/api-v2/i18n'); var unsubscription = require('../../controllers/api-v2/unsubscription'); -import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; +import { + getUserLanguage +} from '../../middlewares/api-v3/language'; router.get('/unsubscribe', getUserLanguage, unsubscription.unsubscribe); diff --git a/website/src/routes/payments.js b/website/src/routes/payments.js index 7b7f538eb8..13e9ec9163 100644 --- a/website/src/routes/payments.js +++ b/website/src/routes/payments.js @@ -4,7 +4,9 @@ var router = express.Router(); var auth = require('../controllers/api-v2/auth'); var payments = require('../controllers/payments'); var i18n = require('../libs/api-v2/i18n'); -import getUserLanguage from '../../middlewares/api-v3/getUserLanguage'; +import { + getUserLanguage +} from '../../middlewares/api-v3/language'; router.get('/paypal/checkout', auth.authWithUrl, getUserLanguage, payments.paypalCheckout); router.get('/paypal/checkout/success', getUserLanguage, payments.paypalCheckoutSuccess); From 6ba41f3099fd21993710f5fe330ef862c2f78a8a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Apr 2016 13:31:35 +0200 Subject: [PATCH 664/976] remove console statement --- website/src/libs/api-v3/routes.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/libs/api-v3/routes.js b/website/src/libs/api-v3/routes.js index 588cd50004..c0399fb73c 100644 --- a/website/src/libs/api-v3/routes.js +++ b/website/src/libs/api-v3/routes.js @@ -44,7 +44,6 @@ module.exports.readController = function readController (router, controller) { let fn = handler ? _wrapAsyncFn(handler) : noop; router[method](url, ...middlewares, fn); - console.log(url, middlewares.map(m => m.name)); }); }; From 4c4a7ce3cefe2f53d696f03484ec59d34b5863a0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Apr 2016 13:51:01 +0200 Subject: [PATCH 665/976] do not run cron when user is not available --- website/src/middlewares/api-v3/cron.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index bcdcc3591c..2711712ed4 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -273,6 +273,8 @@ function cron (options = {}) { module.exports = function cronMiddleware (req, res, next) { let user = res.locals.user; + if (!user) return next(); // User might not be available when authentication is not mandatory + let analytics = res.analytics; let now = new Date(); From 7562a589c54bd256e0c7616fae944f1ed6899380 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Apr 2016 16:02:28 +0200 Subject: [PATCH 666/976] apidoc: update urls and add notice about private api --- package.json | 4 +- website/src/controllers/api-v3/auth.js | 14 ++-- website/src/controllers/api-v3/challenges.js | 20 ++--- website/src/controllers/api-v3/chat.js | 14 ++-- website/src/controllers/api-v3/content.js | 2 +- website/src/controllers/api-v3/coupon.js | 8 +- website/src/controllers/api-v3/debug.js | 4 +- website/src/controllers/api-v3/email.js | 2 +- website/src/controllers/api-v3/groups.js | 18 ++--- website/src/controllers/api-v3/hall.js | 8 +- website/src/controllers/api-v3/members.js | 10 +-- website/src/controllers/api-v3/modelsPaths.js | 2 +- website/src/controllers/api-v3/quests.js | 14 ++-- website/src/controllers/api-v3/status.js | 2 +- website/src/controllers/api-v3/tags.js | 10 +-- website/src/controllers/api-v3/tasks.js | 34 ++++----- website/src/controllers/api-v3/user.js | 76 +++++++++---------- .../src/controllers/top-level/dataexport.js | 8 +- 18 files changed, 126 insertions(+), 124 deletions(-) diff --git a/package.json b/package.json index 892ec56599..73b4c4f8ae 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,7 @@ "name": "habitica", "title": "Habitica", "version": "3.0.0", - "url": "https://habitica-v3.herokuapp.com/api/v3", - "sampleUrl": "https://habitica-v3.herokuapp.com/api/v3" + "url": "https://habitica-v3.herokuapp.com", + "sampleUrl": "https://habitica-v3.herokuapp.com" } } diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 37d3b08499..a98cf0310d 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -52,7 +52,7 @@ async function _handleGroupInvitation (user, invite) { } /** - * @api {post} /user/auth/local/register Register a new user with email, username and password or attach local auth to a social user + * @api {post} /api/v3/user/auth/local/register Register a new user with email, username and password or attach local auth to a social user * @apiVersion 3.0.0 * @apiName UserRegisterLocal * @apiGroup User @@ -165,7 +165,7 @@ function _loginRes (user, req, res) { } /** - * @api {post} /user/auth/local/login Login an user with email / username and password + * @api {post} /api/v3/user/auth/local/login Login an user with email / username and password * @apiVersion 3.0.0 * @apiName UserLoginLocal * @apiGroup User @@ -280,7 +280,7 @@ api.loginSocial = { }; /** - * @api {put} /user/auth/update-username + * @api {put} /api/v3/user/auth/update-username * @apiVersion 3.0.0 * @apiName updateUsername * @apiGroup User @@ -325,7 +325,7 @@ api.updateUsername = { }; /** - * @api {put} /user/auth/update-password + * @api {put} /api/v3/user/auth/update-password * @apiVersion 3.0.0 * @apiName updatePassword * @apiGroup User @@ -364,7 +364,7 @@ api.updatePassword = { }; /** - * @api {post} /user/reset-password + * @api {post} /api/v3/user/reset-password * @apiVersion 3.0.0 * @apiName resetPassword * @apiGroup User @@ -414,7 +414,7 @@ api.resetPassword = { }; /** - * @api {put} /user/auth/update-email + * @api {put} /api/v3/user/auth/update-email * @apiVersion 3.0.0 * @apiName UpdateEmail * @apiGroup User @@ -471,7 +471,7 @@ api.getFirebaseToken = { }; /** - * @api {delete} /user/auth/social/:network Delete a social authentication method (only facebook supported) + * @api {delete} /api/v3/user/auth/social/:network Delete a social authentication method (only facebook supported) * @apiVersion 3.0.0 * @apiName UserDeleteSocial * @apiGroup User diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 5ee92d8d22..50249b0faf 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -24,7 +24,7 @@ import csvStringify from '../../libs/api-v3/csvStringify'; let api = {}; /** - * @api {post} /challenges Create a new challenge + * @api {post} /api/v3/challenges Create a new challenge * @apiVersion 3.0.0 * @apiName CreateChallenge * @apiGroup Challenge @@ -113,7 +113,7 @@ api.createChallenge = { }; /** - * @api {post} /challenges/:challengeId/join Joins a challenge + * @api {post} /api/v3/challenges/:challengeId/join Joins a challenge * @apiVersion 3.0.0 * @apiName JoinChallenge * @apiGroup Challenge @@ -159,7 +159,7 @@ api.joinChallenge = { }; /** - * @api {post} /challenges/:challengeId/leave Leaves a challenge + * @api {post} /api/v3/challenges/:challengeId/leave Leaves a challenge * @apiVersion 3.0.0 * @apiName LeaveChallenge * @apiGroup Challenge @@ -197,7 +197,7 @@ api.leaveChallenge = { }; /** - * @api {get} /challenges/user Get challenges for a user + * @api {get} /api/v3/challenges/user Get challenges for a user * @apiVersion 3.0.0 * @apiName GetUserChallenges * @apiGroup Challenge @@ -241,7 +241,7 @@ api.getUserChallenges = { }; /** - * @api {get} /challenges/group/group:Id Get challenges for a group + * @api {get} /api/v3/challenges/group/group:Id Get challenges for a group * @apiVersion 3.0.0 * @apiName GetGroupChallenges * @apiGroup Challenge @@ -284,7 +284,7 @@ api.getGroupChallenges = { }; /** - * @api {get} /challenges/:challengeId Get a challenge given its id + * @api {get} /api/v3/challenges/:challengeId Get a challenge given its id * @apiVersion 3.0.0 * @apiName GetChallenge * @apiGroup Challenge @@ -326,7 +326,7 @@ api.getChallenge = { }; /** - * @api {get} /challenges/:challengeId/export/csv Export a challenge in CSV + * @api {get} /api/v3/challenges/:challengeId/export/csv Export a challenge in CSV * @apiVersion 3.0.0 * @apiName ExportChallengeCsv * @apiGroup Challenge @@ -399,7 +399,7 @@ api.exportChallengeCsv = { }; /** - * @api {put} /challenges/:challengeId Update a challenge + * @api {put} /api/v3/challenges/:challengeId Update a challenge * @apiVersion 3.0.0 * @apiName UpdateChallenge * @apiGroup Challenge @@ -502,7 +502,7 @@ export async function _closeChal (challenge, broken = {}) { } /** - * @api {delete} /challenges/:challengeId Delete a challenge + * @api {delete} /api/v3/challenges/:challengeId Delete a challenge * @apiVersion 3.0.0 * @apiName DeleteChallenge * @apiGroup Challenge @@ -532,7 +532,7 @@ api.deleteChallenge = { }; /** - * @api {post} /challenges/:challengeId/selectWinner/:winnerId Select winner for challenge + * @api {post} /api/v3/challenges/:challengeId/selectWinner/:winnerId Select winner for challenge * @apiVersion 3.0.0 * @apiName SelectChallengeWinner * @apiGroup Challenge diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index ddc6cf3df8..65f4b298c1 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -20,7 +20,7 @@ const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) let api = {}; /** - * @api {get} /groups/:groupId/chat Get chat messages from a group + * @api {get} /api/v3/groups/:groupId/chat Get chat messages from a group * @apiVersion 3.0.0 * @apiName GetChat * @apiGroup Chat @@ -49,7 +49,7 @@ api.getChat = { }; /** - * @api {post} /groups/:groupId/chat Post chat message to a group + * @api {post} /api/v3/groups/:groupId/chat Post chat message to a group * @apiVersion 3.0.0 * @apiName PostCat * @apiGroup Chat @@ -102,7 +102,7 @@ api.postChat = { }; /** - * @api {post} /groups/:groupId/chat/:chatId/like Like a group chat message + * @api {post} /api/v3/groups/:groupId/chat/:chatId/like Like a group chat message * @apiVersion 3.0.0 * @apiName LikeChat * @apiGroup Chat @@ -149,7 +149,7 @@ api.likeChat = { }; /** - * @api {post} /groups/:groupId/chat/:chatId/like Like a group chat message + * @api {post} /api/v3/groups/:groupId/chat/:chatId/like Like a group chat message * @apiVersion 3.0.0 * @apiName LikeChat * @apiGroup Chat @@ -254,7 +254,7 @@ api.flagChat = { }; /** - * @api {post} /groups/:groupId/chat/:chatId/clear-flags Clear a group chat message's flags + * @api {post} /api/v3/groups/:groupId/chat/:chatId/clear-flags Clear a group chat message's flags * @apiVersion 3.0.0 * @apiName ClearFlags * @apiGroup Chat @@ -301,7 +301,7 @@ api.clearChatFlags = { }; /** - * @api {post} /groups/:groupId/chat/:chatId/seen Seen a group chat message + * @api {post} /api/v3/groups/:groupId/chat/:chatId/seen Seen a group chat message * @apiVersion 3.0.0 * @apiName SeenChat * @apiGroup Chat @@ -333,7 +333,7 @@ api.seenChat = { }; /** - * @api {delete} /groups/:groupId/chat/:chatId Delete chat message from a group + * @api {delete} /api/v3/groups/:groupId/chat/:chatId Delete chat message from a group * @apiVersion 3.0.0 * @apiName DeleteChat * @apiGroup Chat diff --git a/website/src/controllers/api-v3/content.js b/website/src/controllers/api-v3/content.js index f824325573..780758cf48 100644 --- a/website/src/controllers/api-v3/content.js +++ b/website/src/controllers/api-v3/content.js @@ -61,7 +61,7 @@ async function saveContentToDisk (language, content) { } /** - * @api {get} /content Get all available content objects. Does not require authentication. + * @api {get} /api/v3/content Get all available content objects. Does not require authentication. * @apiVersion 3.0.0 * @apiName ContentGet * @apiGroup Content diff --git a/website/src/controllers/api-v3/coupon.js b/website/src/controllers/api-v3/coupon.js index 2fa572f000..8fee188094 100644 --- a/website/src/controllers/api-v3/coupon.js +++ b/website/src/controllers/api-v3/coupon.js @@ -11,7 +11,7 @@ import couponCode from 'coupon-code'; let api = {}; /** - * @api {get} /coupons Get coupons (sudo users only) + * @api {get} /api/v3/coupons Get coupons (sudo users only) * @apiVersion 3.0.0 * @apiName GetCoupons * @apiGroup Coupon @@ -39,7 +39,7 @@ api.getCoupons = { }; /** - * @api {post} /coupons/generate/:event Generate coupons for an event (sudo users only) + * @api {post} /api/v3/coupons/generate/:event Generate coupons for an event (sudo users only) * @apiVersion 3.0.0 * @apiName GenerateCoupons * @apiGroup Coupon @@ -66,7 +66,7 @@ api.generateCoupons = { }; /** - * @api {post} /user/coupon/:code Enter coupon code + * @api {post} /api/v3/user/coupon/:code Enter coupon code * @apiVersion 3.0.0 * @apiName EnterCouponCode * @apiGroup Coupon @@ -93,7 +93,7 @@ api.enterCouponCode = { }; /** - * @api {post} /coupons/validate/:code Validate a coupon code + * @api {post} /api/v3/coupons/validate/:code Validate a coupon code * @apiVersion 3.0.0 * @apiName ValidateCoupon * @apiGroup Coupon diff --git a/website/src/controllers/api-v3/debug.js b/website/src/controllers/api-v3/debug.js index 1616bf2574..a9d9c0cb08 100644 --- a/website/src/controllers/api-v3/debug.js +++ b/website/src/controllers/api-v3/debug.js @@ -4,7 +4,7 @@ import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode' let api = {}; /** - * @api {post} /debug/add-ten-gems Add ten gems to the current user + * @api {post} /api/v3/debug/add-ten-gems Add ten gems to the current user * @apiVersion 3.0.0 * @apiName AddTenGems * @apiGroup Development @@ -27,7 +27,7 @@ api.addTenGems = { }; /** - * @api {post} /debug/add-hourglass Add Hourglass to the current user + * @api {post} /api/v3/debug/add-hourglass Add Hourglass to the current user * @apiVersion 3.0.0 * @apiName AddHourglass * @apiGroup Development diff --git a/website/src/controllers/api-v3/email.js b/website/src/controllers/api-v3/email.js index ed2204baed..01b7109e19 100644 --- a/website/src/controllers/api-v3/email.js +++ b/website/src/controllers/api-v3/email.js @@ -8,7 +8,7 @@ import { let api = {}; /** - * @api {get} /email/unsubscribe Unsubscribe an email or user from email notifications + * @api {get} /api/v3/email/unsubscribe Unsubscribe an email or user from email notifications * @apiVersion 3.0.0 * @apiName UnsubscribeEmail * @apiGroup Unsubscribe diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 7fffb7f3c0..6563f1289e 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -27,7 +27,7 @@ let api = {}; // TODO shall we accept party as groupId in all routes? /** - * @api {post} /groups Create group + * @api {post} /api/v3/groups Create group * @apiVersion 3.0.0 * @apiName CreateGroup * @apiGroup Group @@ -76,7 +76,7 @@ api.createGroup = { }; /** - * @api {get} /groups Get groups + * @api {get} /api/v3/groups Get groups * @apiVersion 3.0.0 * @apiName GetGroups * @apiGroup Group @@ -112,7 +112,7 @@ api.getGroups = { }; /** - * @api {get} /groups/:groupId Get group + * @api {get} /api/v3/groups/:groupId Get group * @apiVersion 3.0.0 * @apiName GetGroup * @apiGroup Group @@ -146,7 +146,7 @@ api.getGroup = { }; /** - * @api {put} /groups/:groupId Update group + * @api {put} /api/v3/groups/:groupId Update group * @apiVersion 3.0.0 * @apiName UpdateGroup * @apiGroup Group @@ -192,7 +192,7 @@ api.updateGroup = { }; /** - * @api {post} /groups/:groupId/join Join a group + * @api {post} /api/v3/groups/:groupId/join Join a group * @apiVersion 3.0.0 * @apiName JoinGroup * @apiGroup Group @@ -283,7 +283,7 @@ api.joinGroup = { }; /** - * @api {post} /groups/:groupId/reject Reject a group invitation + * @api {post} /api/v3/groups/:groupId/reject Reject a group invitation * @apiVersion 3.0.0 * @apiName RejectGroupInvite * @apiGroup Group @@ -328,7 +328,7 @@ api.rejectGroupInvite = { }; /** - * @api {post} /groups/:groupId/leave Leave a group + * @api {post} /api/v3/groups/:groupId/leave Leave a group * @apiVersion 3.0.0 * @apiName LeaveGroup * @apiGroup Group @@ -384,7 +384,7 @@ function _sendMessageToRemoved (group, removedUser, message) { } /** - * @api {post} /groups/:groupId/removeMember/:memberId Remove a member from a group + * @api {post} /api/v3/groups/:groupId/removeMember/:memberId Remove a member from a group * @apiVersion 3.0.0 * @apiName RemoveGroupMember * @apiGroup Group @@ -585,7 +585,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) { } /** - * @api {post} /groups/:groupId/invite Invite users to a group using their UUIDs or email addresses + * @api {post} /api/v3/groups/:groupId/invite Invite users to a group using their UUIDs or email addresses * @apiVersion 3.0.0 * @apiName InviteToGroup * @apiGroup Group diff --git a/website/src/controllers/api-v3/hall.js b/website/src/controllers/api-v3/hall.js index 05ff2fc54e..55bc191063 100644 --- a/website/src/controllers/api-v3/hall.js +++ b/website/src/controllers/api-v3/hall.js @@ -9,7 +9,7 @@ import _ from 'lodash'; let api = {}; /** - * @api {get} /hall/patrons Get all Patrons. Only the first 50 patrons are returned. More can be accessed passing ?page=n. + * @api {get} /api/v3/hall/patrons Get all Patrons. Only the first 50 patrons are returned. More can be accessed passing ?page=n. * @apiVersion 3.0.0 * @apiName GetPatrons * @apiGroup Hall @@ -47,7 +47,7 @@ api.getPatrons = { }; /** - * @api {get} /hall/heroes Get all Heroes + * @api {get} /api/v3/hall/heroes Get all Heroes * @apiVersion 3.0.0 * @apiName GetHeroes * @apiGroup Hall @@ -79,7 +79,7 @@ api.getHeroes = { const heroAdminFields = 'contributor balance profile.name purchased items auth'; /** - * @api {get} /hall/heroes/:heroId Get an hero given his _id. Must be an admin to make this request + * @api {get} /api/v3/hall/heroes/:heroId Get an hero given his _id. Must be an admin to make this request * @apiVersion 3.0.0 * @apiName GetHero * @apiGroup Hall @@ -116,7 +116,7 @@ api.getHero = { const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0}; /** - * @api {put} /hall/heroes/:heroId Update an hero. Must be an admin to make this request + * @api {put} /api/v3/hall/heroes/:heroId Update an hero. Must be an admin to make this request * @apiVersion 3.0.0 * @apiName UpdateHero * @apiGroup Hall diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index f6ee561981..9e4c45d5d2 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -20,7 +20,7 @@ import Q from 'q'; let api = {}; /** - * @api {get} /members/:memberId Get a member profile + * @api {get} /api/v3/members/:memberId Get a member profile * @apiVersion 3.0.0 * @apiName GetMember * @apiGroup Member @@ -129,7 +129,7 @@ function _getMembersForItem (type) { } /** - * @api {get} /groups/:groupId/members Get members for a group with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @api {get} /api/v3/groups/:groupId/members Get members for a group with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 * @apiName GetMembersForGroup * @apiGroup Member @@ -148,7 +148,7 @@ api.getMembersForGroup = { }; /** - * @api {get} /groups/:groupId/invites Get invites for a group with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @api {get} /api/v3/groups/:groupId/invites Get invites for a group with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 * @apiName GetInvitesForGroup * @apiGroup Member @@ -166,7 +166,7 @@ api.getInvitesForGroup = { }; /** - * @api {get} /challenges/:challengeId/members Get members for a challenge with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @api {get} /api/v3/challenges/:challengeId/members Get members for a challenge with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 * @apiName GetMembersForChallenge * @apiGroup Member @@ -184,7 +184,7 @@ api.getMembersForChallenge = { }; /** - * @api {get} /challenges/:challengeId/members/:memberId Get a challenge member progress + * @api {get} /api/v3/challenges/:challengeId/members/:memberId Get a challenge member progress * @apiVersion 3.0.0 * @apiName GetChallenge * @apiGroup Challenge diff --git a/website/src/controllers/api-v3/modelsPaths.js b/website/src/controllers/api-v3/modelsPaths.js index 657f7c6e6a..b023521722 100644 --- a/website/src/controllers/api-v3/modelsPaths.js +++ b/website/src/controllers/api-v3/modelsPaths.js @@ -6,7 +6,7 @@ let tasksModels = ['habit', 'daily', 'todo', 'reward']; let allModels = ['user', 'tag', 'challenge', 'group'].concat(tasksModels); /** - * @api {get} /meta/models/:model/paths Get all paths for the specified model. Doesn't require authentication + * @api {get} /api/v3/meta/models/:model/paths Get all paths for the specified model. Doesn't require authentication * @apiVersion 3.0.0 * @apiName GetUserModelPaths * @apiGroup Meta diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index bc808ab973..bd076610e6 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -29,7 +29,7 @@ function canStartQuestAutomatically (group) { let api = {}; /** - * @api {post} /groups/:groupId/quests/invite Invite users to a quest + * @api {post} /api/v3/groups/:groupId/quests/invite Invite users to a quest * @apiVersion 3.0.0 * @apiName InviteToQuest * @apiGroup Group @@ -132,7 +132,7 @@ api.inviteToQuest = { }; /** - * @api {post} /groups/:groupId/quests/accept Accept a pending quest + * @api {post} /api/v3/groups/:groupId/quests/accept Accept a pending quest * @apiVersion 3.0.0 * @apiName AcceptQuest * @apiGroup Group @@ -189,7 +189,7 @@ api.acceptQuest = { }; /** - * @api {post} /groups/:groupId/quests/reject Reject a quest + * @api {post} /api/v3/groups/:groupId/quests/reject Reject a quest * @apiVersion 3.0.0 * @apiName RejectQuest * @apiGroup Group @@ -248,7 +248,7 @@ api.rejectQuest = { /** - * @api {post} /groups/:groupId/quests/force-start Accept a pending quest + * @api {post} /api/v3/groups/:groupId/quests/force-start Accept a pending quest * @apiVersion 3.0.0 * @apiName forceStart * @apiGroup Group @@ -300,7 +300,7 @@ api.forceStart = { }; /** - * @api {post} /groups/:groupId/quests/cancel Cancels a quest + * @api {post} /api/v3/groups/:groupId/quests/cancel Cancels a quest * @apiVersion 3.0.0 * @apiName CancelQuest * @apiGroup Group @@ -349,7 +349,7 @@ api.cancelQuest = { }; /** - * @api {post} /groups/:groupId/quests/abort Abort the current quest + * @api {post} /api/v3/groups/:groupId/quests/abort Abort the current quest * @apiVersion 3.0.0 * @apiName AbortQuest * @apiGroup Group @@ -403,7 +403,7 @@ api.abortQuest = { }; /** - * @api {post} /groups/:groupId/quests/leave Leaves the active quest + * @api {post} /api/v3/groups/:groupId/quests/leave Leaves the active quest * @apiVersion 3.0.0 * @apiName LeaveQuest * @apiGroup Group diff --git a/website/src/controllers/api-v3/status.js b/website/src/controllers/api-v3/status.js index 9ed050a275..a3f59726a4 100644 --- a/website/src/controllers/api-v3/status.js +++ b/website/src/controllers/api-v3/status.js @@ -1,7 +1,7 @@ let api = {}; /** - * @api {get} /status Get Habitica's status + * @api {get} /api/v3/status Get Habitica's status * @apiVersion 3.0.0 * @apiName GetStatus * @apiGroup Status diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 76c3ffb7fa..3028ee568c 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -9,7 +9,7 @@ import _ from 'lodash'; let api = {}; /** - * @api {post} /tags Create a new tag + * @api {post} /api/v3/tags Create a new tag * @apiVersion 3.0.0 * @apiName CreateTag * @apiGroup Tag @@ -33,7 +33,7 @@ api.createTag = { }; /** - * @api {get} /tag Get an user's tags + * @api {get} /api/v3/tag Get an user's tags * @apiVersion 3.0.0 * @apiName GetTags * @apiGroup Tag @@ -51,7 +51,7 @@ api.getTags = { }; /** - * @api {get} /tags/:tagId Get a tag given its id + * @api {get} /api/v3/tags/:tagId Get a tag given its id * @apiVersion 3.0.0 * @apiName GetTag * @apiGroup Tag @@ -79,7 +79,7 @@ api.getTag = { }; /** - * @api {put} /tag/:tagId Update a tag + * @api {put} /api/v3/tag/:tagId Update a tag * @apiVersion 3.0.0 * @apiName UpdateTag * @apiGroup Tag @@ -113,7 +113,7 @@ api.updateTag = { }; /** - * @api {delete} /tag/:tagId Delete a user tag given its id + * @api {delete} /api/v3/tag/:tagId Delete a user tag given its id * @apiVersion 3.0.0 * @apiName DeleteTag * @apiGroup Tag diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 5e9756da49..5c293bdd49 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -54,7 +54,7 @@ async function _createTasks (req, res, user, challenge) { } /** - * @api {post} /tasks/user Create a new task belonging to the autheticated user. Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @api {post} /api/v3/tasks/user Create a new task belonging to the autheticated user. Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 * @apiName CreateUserTasks * @apiGroup Task @@ -73,7 +73,7 @@ api.createUserTasks = { }; /** - * @api {post} /tasks/challenge/:challengeId Create a new task belonging to the challenge. Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @api {post} /api/v3/tasks/challenge/:challengeId Create a new task belonging to the challenge. Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 * @apiName CreateChallengeTasks * @apiGroup Task @@ -164,7 +164,7 @@ async function _getTasks (req, res, user, challenge) { } /** - * @api {get} /tasks/user Get an user's tasks + * @api {get} /api/v3/tasks/user Get an user's tasks * @apiVersion 3.0.0 * @apiName GetUserTasks * @apiGroup Task @@ -190,7 +190,7 @@ api.getUserTasks = { }; /** - * @api {get} /tasks/challenge/:challengeId Get a challenge's tasks + * @api {get} /api/v3/tasks/challenge/:challengeId Get a challenge's tasks * @apiVersion 3.0.0 * @apiName GetChallengeTasks * @apiGroup Task @@ -225,7 +225,7 @@ api.getChallengeTasks = { }; /** - * @api {get} /task/:taskId Get a task given its id + * @api {get} /api/v3/task/:taskId Get a task given its id * @apiVersion 3.0.0 * @apiName GetTask * @apiGroup Task @@ -266,7 +266,7 @@ api.getTask = { }; /** - * @api {put} /task/:taskId Update a task + * @api {put} /api/v3/task/:taskId Update a task * @apiVersion 3.0.0 * @apiName UpdateTask * @apiGroup Task @@ -343,7 +343,7 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { } /** - * @api {put} /tasks/:taskId/score/:direction Score a task + * @api {put} /api/v3/tasks/:taskId/score/:direction Score a task * @apiVersion 3.0.0 * @apiName ScoreTask * @apiGroup Task @@ -426,7 +426,7 @@ api.scoreTask = { // TODO check that it works when a tag is selected or todos are split between dated and due // TODO support challenges? /** - * @api {post} /tasks/:taskId/move/to/:position Move a task to a new position + * @api {post} /api/v3/tasks/:taskId/move/to/:position Move a task to a new position * @apiVersion 3.0.0 * @apiName MoveTask * @apiGroup Task @@ -481,7 +481,7 @@ api.moveTask = { }; /** - * @api {post} /tasks/:taskId/checklist Add an item to a checklist, creating the checklist if it doesn't exist + * @api {post} /api/v3/tasks/:taskId/checklist Add an item to a checklist, creating the checklist if it doesn't exist * @apiVersion 3.0.0 * @apiName AddChecklistItem * @apiGroup Task @@ -529,7 +529,7 @@ api.addChecklistItem = { }; /** - * @api {post} /tasks/:taskId/checklist/:itemId/score Score a checklist item + * @api {post} /api/v3/tasks/:taskId/checklist/:itemId/score Score a checklist item * @apiVersion 3.0.0 * @apiName ScoreChecklistItem * @apiGroup Task @@ -571,7 +571,7 @@ api.scoreCheckListItem = { }; /** - * @api {put} /tasks/:taskId/checklist/:itemId Update a checklist item + * @api {put} /api/v3/tasks/:taskId/checklist/:itemId Update a checklist item * @apiVersion 3.0.0 * @apiName UpdateChecklistItem * @apiGroup Task @@ -622,7 +622,7 @@ api.updateChecklistItem = { }; /** - * @api {delete} /tasks/:taskId/checklist/:itemId Remove a checklist item + * @api {delete} /api/v3/tasks/:taskId/checklist/:itemId Remove a checklist item * @apiVersion 3.0.0 * @apiName RemoveChecklistItem * @apiGroup Task @@ -671,7 +671,7 @@ api.removeChecklistItem = { }; /** - * @api {post} /tasks/:taskId/tags/:tagId Add a tag to a task + * @api {post} /api/v3/tasks/:taskId/tags/:tagId Add a tag to a task * @apiVersion 3.0.0 * @apiName AddTagToTask * @apiGroup Task @@ -714,7 +714,7 @@ api.addTagToTask = { }; /** - * @api {delete} /tasks/:taskId/tags/:tagId Remove a tag + * @api {delete} /api/v3/tasks/:taskId/tags/:tagId Remove a tag * @apiVersion 3.0.0 * @apiName RemoveTagFromTask * @apiGroup Task @@ -754,7 +754,7 @@ api.removeTagFromTask = { // TODO this method needs some limitation, like to check if the challenge is really broken? /** - * @api {post} /tasks/unlink/:taskId Unlink a challenge task + * @api {post} /api/v3/tasks/unlink/:taskId Unlink a challenge task * @apiVersion 3.0.0 * @apiName UnlinkTask * @apiGroup Task @@ -803,7 +803,7 @@ api.unlinkTask = { }; /** - * @api {post} /tasks/clearCompletedTodos Delete user's completed todos + * @api {post} /api/v3/tasks/clearCompletedTodos Delete user's completed todos * @apiVersion 3.0.0 * @apiName ClearCompletedTodos * @apiGroup Task @@ -834,7 +834,7 @@ api.clearCompletedTodos = { }; /** - * @api {delete} /tasks/:taskId Delete a task given its id + * @api {delete} /api/v3/tasks/:taskId Delete a task given its id * @apiVersion 3.0.0 * @apiName DeleteTask * @apiGroup Task diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 083aaef5fa..f4e63bb400 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -19,7 +19,7 @@ import * as passwordUtils from '../../libs/api-v3/password'; let api = {}; /** - * @api {get} /user Get the authenticated user's profile + * @api {get} /api/v3/user Get the authenticated user's profile * @apiVersion 3.0.0 * @apiName UserGet * @apiGroup User @@ -46,7 +46,7 @@ api.getUser = { }; /** - * @api {get} /user/inventory/buy Get the gear items available for purchase for the current user + * @api {get} /api/v3/user/inventory/buy Get the gear items available for purchase for the current user * @apiVersion 3.0.0 * @apiName UserGetBuyList * @apiGroup User @@ -140,7 +140,7 @@ let checkPreferencePurchase = (user, path, item) => { }; /** - * @api {put} /user Update the user. Example body: {'stats.hp':50, 'preferences.background': 'beach'} + * @api {put} /api/v3/user Update the user. Example body: {'stats.hp':50, 'preferences.background': 'beach'} * @apiVersion 3.0.0 * @apiName UserUpdate * @apiGroup User @@ -174,7 +174,7 @@ api.updateUser = { }; /** - * @api {delete} /user DELETE an authenticated user's profile + * @api {delete} /api/v3/user DELETE an authenticated user's profile * @apiVersion 3.0.0 * @apiName UserDelete * @apiGroup User @@ -237,7 +237,7 @@ function _cleanChecklist (task) { } /** - * @api {get} /user/anonymized + * @api {get} /api/v3/user/anonymized * @apiVersion 3.0.0 * @apiName UserGetAnonymized * @apiGroup User @@ -300,7 +300,7 @@ api.getUserAnonymized = { const partyMembersFields = 'profile.name stats achievements items.special'; /** - * @api {post} /user/class/cast/:spellId Cast a spell on a target. + * @api {post} /api/v3/user/class/cast/:spellId Cast a spell on a target. * @apiVersion 3.0.0 * @apiName UserCast * @apiGroup User @@ -413,7 +413,7 @@ api.castSpell = { }; /** - * @api {post} /user/sleep Put the user in the inn. + * @api {post} /api/v3/user/sleep Put the user in the inn. * @apiVersion 3.0.0 * @apiName UserSleep * @apiGroup User @@ -433,7 +433,7 @@ api.sleep = { }; /** - * @api {post} /user/allocate Allocate an attribute point. + * @api {post} /api/v3/user/allocate Allocate an attribute point. * @apiVersion 3.0.0 * @apiName UserAllocate * @apiGroup User @@ -453,7 +453,7 @@ api.allocate = { }; /** - * @api {post} /user/allocate-now Allocate all attribute points. + * @api {post} /api/v3/user/allocate-now Allocate all attribute points. * @apiVersion 3.0.0 * @apiName UserAllocateNow * @apiGroup User @@ -473,7 +473,7 @@ api.allocateNow = { }; /** - * @api {post} /user/buy/:key Buy a content item. + * @api {post} /api/v3/user/buy/:key Buy a content item. * @apiVersion 3.0.0 * @apiName UserBuy * @apiGroup User @@ -497,7 +497,7 @@ api.buy = { }; /** - * @api {post} /user/buy-mystery-set/:key Buy a mystery set. + * @api {post} /api/v3/user/buy-mystery-set/:key Buy a mystery set. * @apiVersion 3.0.0 * @apiName UserBuyMysterySet * @apiGroup User @@ -520,7 +520,7 @@ api.buyMysterySet = { }; /** - * @api {post} /user/buy-quest/:key Buy a quest with gold. + * @api {post} /api/v3/user/buy-quest/:key Buy a quest with gold. * @apiVersion 3.0.0 * @apiName UserBuyQuest * @apiGroup User @@ -543,7 +543,7 @@ api.buyQuest = { }; /** - * @api {post} /user/buy-special-spell/:key Buy special spell. + * @api {post} /api/v3/user/buy-special-spell/:key Buy special spell. * @apiVersion 3.0.0 * @apiName UserBuySpecialSpell * @apiGroup User @@ -566,7 +566,7 @@ api.buySpecialSpell = { }; /** - * @api {post} /user/hatch/:egg/:hatchingPotion Hatch a pet. + * @api {post} /api/v3/user/hatch/:egg/:hatchingPotion Hatch a pet. * @apiVersion 3.0.0 * @apiName UserHatch * @apiGroup User @@ -590,7 +590,7 @@ api.hatch = { }; /** - * @api {post} /user/equip/:type/:key Equip an item + * @api {post} /api/v3/user/equip/:type/:key Equip an item * @apiVersion 3.0.0 * @apiName UserEquip * @apiGroup User @@ -614,7 +614,7 @@ api.equip = { }; /** - * @api {post} /user/equip/:pet/:food Feed a pet + * @api {post} /api/v3/user/equip/:pet/:food Feed a pet * @apiVersion 3.0.0 * @apiName UserFeed * @apiGroup User @@ -638,7 +638,7 @@ api.feed = { }; /** -* @api {post} /user/change-class Change class. +* @api {post} /api/v3/user/change-class Change class. * @apiVersion 3.0.0 * @apiName UserChangeClass * @apiGroup User @@ -660,7 +660,7 @@ api.changeClass = { }; /** -* @api {post} /user/disable-classes Disable classes. +* @api {post} /api/v3/user/disable-classes Disable classes. * @apiVersion 3.0.0 * @apiName UserDisableClasses * @apiGroup User @@ -680,7 +680,7 @@ api.disableClasses = { }; /** -* @api {post} /user/purchase/:type/:key Purchase Gem Items. +* @api {post} /api/v3/user/purchase/:type/:key Purchase Gem Items. * @apiVersion 3.0.0 * @apiName UserPurchase * @apiGroup User @@ -703,7 +703,7 @@ api.purchase = { }; /** -* @api {post} /user/purchase-hourglass/:type/:key Purchase Hourglass. +* @api {post} /api/v3/user/purchase-hourglass/:type/:key Purchase Hourglass. * @apiVersion 3.0.0 * @apiName UserPurchaseHourglass * @apiGroup User @@ -726,7 +726,7 @@ api.userPurchaseHourglass = { }; /** -* @api {post} /user/read-card/:cardType Reads a card. +* @api {post} /api/v3/user/read-card/:cardType Reads a card. * @apiVersion 3.0.0 * @apiName UserReadCard * @apiGroup User @@ -748,7 +748,7 @@ api.readCard = { }; /** -* @api {post} /user/open-mystery-item Open the mystery item. +* @api {post} /api/v3/user/open-mystery-item Open the mystery item. * @apiVersion 3.0.0 * @apiName UserOpenMysteryItem * @apiGroup User @@ -768,7 +768,7 @@ api.userOpenMysteryItem = { }; /** - * @api {post} /user/webhook + * @api {post} /api/v3/user/webhook * @apiVersion 3.0.0 * @apiName UserAddWebhook * @apiGroup User @@ -787,7 +787,7 @@ api.addWebhook = { }; /** - * @api {put} /user/webhook/:id + * @api {put} /api/v3/user/webhook/:id * @apiVersion 3.0.0 * @apiName UserUpdateWebhook * @apiGroup User @@ -806,7 +806,7 @@ api.updateWebhook = { }; /** - * @api {delete} /user/webhook/:id + * @api {delete} /api/v3/user/webhook/:id * @apiVersion 3.0.0 * @apiName UserDeleteWebhook * @apiGroup User @@ -825,7 +825,7 @@ api.deleteWebhook = { }; -/* @api {post} /user/release-pets Releases pets. +/* @api {post} /api/v3/user/release-pets Releases pets. * @apiVersion 3.0.0 * @apiName UserReleasePets * @apiGroup User @@ -845,7 +845,7 @@ api.userReleasePets = { }; /* -* @api {post} /user/release-both Releases Pets and Mounts and grants Triad Bingo. +* @api {post} /api/v3/user/release-both Releases Pets and Mounts and grants Triad Bingo. * @apiVersion 3.0.0 * @apiName UserReleaseBoth * @apiGroup User @@ -865,7 +865,7 @@ api.userReleaseBoth = { }; /* -* @api {post} /user/release-mounts Released mounts. +* @api {post} /api/v3/user/release-mounts Released mounts. * @apiVersion 3.0.0 * @apiName UserReleaseMounts * @apiGroup User @@ -885,7 +885,7 @@ api.userReleaseMounts = { }; /* -* @api {post} /user/sell/:type/:key Sells user's items. +* @api {post} /api/v3/user/sell/:type/:key Sells user's items. * @apiVersion 3.0.0 * @apiName UserSell * @apiGroup User @@ -905,7 +905,7 @@ api.userSell = { }; /* -* @api {post} /user/unlock Unlocks items by purchase. +* @api {post} /api/v3/user/unlock Unlocks items by purchase. * @apiVersion 3.0.0 * @apiName UserUnlock * @apiGroup User @@ -925,7 +925,7 @@ api.userUnlock = { }; /** -* @api {post} /user/revive Revives user from death. +* @api {post} /api/v3/user/revive Revives user from death. * @apiVersion 3.0.0 * @apiName UserRevive * @apiGroup User @@ -945,7 +945,7 @@ api.userRevive = { }; /* -* @api {post} /user/rebirth Resets a user. +* @api {post} /api/v3/user/rebirth Resets a user. * @apiVersion 3.0.0 * @apiName UserRebirth * @apiGroup User @@ -974,7 +974,7 @@ api.userRebirth = { }; /** - * @api {post} /user/block/:uuid blocks and unblocks a user + * @api {post} /api/v3/user/block/:uuid blocks and unblocks a user * @apiVersion 3.0.0 * @apiName BlockUser * @apiGroup User @@ -993,7 +993,7 @@ api.blockUser = { }; /** - * @api {delete} /user/messages/:id delete this message + * @api {delete} /api/v3/user/messages/:id delete this message * @apiVersion 3.0.0 * @apiName deleteMessage * @apiGroup User @@ -1012,7 +1012,7 @@ api.deleteMessage = { }; /** - * @api {delete} /user/messages delete all messages + * @api {delete} /api/v3/user/messages delete all messages * @apiVersion 3.0.0 * @apiName clearMessages * @apiGroup User @@ -1031,7 +1031,7 @@ api.clearMessages = { }; /* -* @api {post} /user/reroll Rerolls a user. +* @api {post} /api/v3/user/reroll Rerolls a user. * @apiVersion 3.0.0 * @apiName UserReroll * @apiGroup User @@ -1061,7 +1061,7 @@ api.userReroll = { }; /* -* @api {post} /user/addPushDevice Adds a push device to a user. +* @api {post} /api/v3/user/addPushDevice Adds a push device to a user. * @apiVersion 3.0.0 * @apiName UserAddPushDevice * @apiGroup User @@ -1083,7 +1083,7 @@ api.userAddPushDevice = { }; /* -* @api {post} /user/reset Resets a user. +* @api {post} /api/v3/user/reset Resets a user. * @apiVersion 3.0.0 * @apiName UserReset * @apiGroup User diff --git a/website/src/controllers/top-level/dataexport.js b/website/src/controllers/top-level/dataexport.js index 5b0b2eb277..6924c9b89f 100644 --- a/website/src/controllers/top-level/dataexport.js +++ b/website/src/controllers/top-level/dataexport.js @@ -25,13 +25,12 @@ const BASE_URL = nconf.get('BASE_URL'); let api = {}; -// TODO move these routes out of the /api/v3/export namespace to the top level /export - /** * @api {get} /export/history.csv Export user tasks history in CSV format. History is only available for habits and dailys so todos and rewards won't be included * @apiVersion 3.0.0 * @apiName ExportUserHistory * @apiGroup DataExport + * @apiDescription NOTE: Part of the private API that may change at any time. * * @apiSuccess {string} A cvs file */ @@ -94,12 +93,12 @@ async function _getUserDataForExport (user) { return userData; } -// TODO export tasks too /** * @api {get} /export/userdata.json Export user data in JSON format. * @apiVersion 3.0.0 * @apiName ExportUserDataJson * @apiGroup DataExport + * @apiDescription NOTE: Part of the private API that may change at any time. * * @apiSuccess {string} A json file */ @@ -125,6 +124,7 @@ api.exportUserDataJson = { * @apiVersion 3.0.0 * @apiName ExportUserDataXml * @apiGroup DataExport + * @apiDescription NOTE: Part of the private API that may change at any time. * * @apiSuccess {string} A xml file */ @@ -148,6 +148,7 @@ api.exportUserDataXml = { * @apiVersion 3.0.0 * @apiName ExportUserAvatarHtml * @apiGroup DataExport + * @apiDescription NOTE: Part of the private API that may change at any time. * * @apiSuccess {string} An html page */ @@ -180,6 +181,7 @@ api.exportUserAvatarHtml = { * @apiVersion 3.0.0 * @apiName ExportUserAvatarPng * @apiGroup DataExport + * @apiDescription NOTE: Part of the private API that may change at any time. * * @apiSuccess {string} A png file */ From 626d8d6e73b886182bed4b1b38478c7f3fecfb0b Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 6 Apr 2016 21:18:36 +0000 Subject: [PATCH 667/976] WIP(payments): lint Amazon Payments file --- .../src/controllers/api-v3/payments/amazon.js | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/amazon.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js new file mode 100644 index 0000000000..c1056c1224 --- /dev/null +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -0,0 +1,277 @@ +import amazonPayments from 'amazon-payments'; +import async from 'async'; +import cc from 'coupon-code'; +import mongoose from 'mongoose'; +import moment from 'moment'; +import nconf from 'nconf'; +import payments from './index'; +import shared from '../../../../common'; +import { model as User } from '../../models/user'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +let amzPayment = amazonPayments.connect({ + environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], + sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), + mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), + mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), + clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), +}); + +api.verifyAccessToken = function verifyAccessToken (req, res) { + if (!req.body || !req.body.access_token) { + return res.status(400).json({err: 'Access token not supplied.'}); + } + + amzPayment.api.getTokenInfo(req.body.access_token, function getTokenInfo (err) { + if (err) return res.status(400).json({err}); + + res.sendStatus(200); + }); +}; + +api.createOrderReferenceId = function createOrderReferenceId (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + amzPayment.offAmazonPayments.createOrderReferenceForId({ + Id: req.body.billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + }, function createOrderReferenceForId (err, response) { + if (err) return next(err); + if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { + return next(new Error('Missing attributes in Amazon response.')); + } + + res.json({ + orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, + }); + }); +}; + +api.checkout = function checkout (req, res, next) { + if (!req.body || !req.body.orderReferenceId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let amount = 5; + + if (gift) { + if (gift.type === 'gems') { + amount = gift.gems.amount / 4; + } else if (gift.type === 'subscription') { + amount = shared.content.subscriptionBlocks[gift.subscription.key].price; + } + } + + async.series({ + setOrderReferenceDetails (cb) { + amzPayment.offAmazonPayments.setOrderReferenceDetails({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerNote: 'HabitRPG Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, + }, cb); + }, + + confirmOrderReference (cb) { + amzPayment.offAmazonPayments.confirmOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + authorize (cb) { + amzPayment.offAmazonPayments.authorize({ + AmazonOrderReferenceId: orderReferenceId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerAuthorizationNote: 'HabitRPG Payment', + TransactionTimeout: 0, + CaptureNow: true, + }, function checkAuthorizationStatus (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successfull.')); + } + + return cb(); + }); + }, + + closeOrderReference (cb) { + amzPayment.offAmazonPayments.closeOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + executePayment (cb) { + async.waterfall([ + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function executeAmazonPayment (member, cb2) { + let data = {user, paymentMethod: 'Amazon Payments'}; + let method = 'buyGems'; + + if (gift) { + if (gift.type === 'subscription') method = 'createSubscription'; + gift.member = member; + data.gift = gift; + data.paymentMethod = 'Gift'; + } + + payments[method](data, cb2); + }, + ], cb); + }, + }, function result (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribe = function subscribe (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + let billingAgreementId = req.body.billingAgreementId; + let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; + let coupon = req.body.coupon; + let user = res.locals.user; + + if (!sub) { + return res.status(400).json({err: 'Subscription plan not found.'}); + } + + async.series({ + applyDiscount (cb) { + if (!sub.discount) return cb(); + if (!coupon) return cb(new Error('Please provide a coupon code for this plan.')); + mongoose.model('Coupon').findOne({_id: cc.validate(coupon), event: sub.key}, function couponResult (err) { + if (err) return cb(err); + if (!coupon) return cb(new Error('Coupon code not found.')); + cb(); + }); + }, + + setBillingAgreementDetails (cb) { + amzPayment.offAmazonPayments.setBillingAgreementDetails({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: 'HabitRPG Subscription', + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: shared.uuid(), + StoreName: 'HabitRPG', + CustomInformation: 'HabitRPG Subscription', + }, + }, + }, cb); + }, + + confirmBillingAgreement (cb) { + amzPayment.offAmazonPayments.confirmBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + authorizeOnBillingAgreement (cb) { + amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: sub.price, + }, + SellerAuthorizationNote: 'HabitRPG Subscription Payment', + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: 'HabitRPG Subscription Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, function billingAgreementResult (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successful.')); + } + + return cb(); + }); + }, + + createSubscription (cb) { + payments.createSubscription({ + user, + customerId: billingAgreementId, + paymentMethod: 'Amazon Payments', + sub, + }, cb); + }, + }, function subscribeResult (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribeCancel = function subscribeCancel (req, res, next) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.status(401).json({err: 'User does not have a plan subscription'}); + + let billingAgreementId = user.purchased.plan.customerId; + + async.series({ + closeBillingAgreement (cb) { + amzPayment.offAmazonPayments.closeBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + cancelSubscription (cb) { + let data = { + user, + // Date of next bill + nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), + paymentMethod: 'Amazon Payments', + }; + + payments.cancelSubscription(data, cb); + }, + }, function subscribeCancelResult (err) { + if (err) return next(err); // don't json this, let toString() handle errors + + if (req.query.noRedirect) { + res.sendStatus(200); + } else { + res.redirect('/'); + } + + user = null; + }); +}; + +module.exports = api; From c5549787b44edd8bf5e72c90166e63c316ebe326 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 7 Apr 2016 20:51:40 +0000 Subject: [PATCH 668/976] refactor(payments): index.js lint pass --- .../src/controllers/api-v3/payments/amazon.js | 2 +- .../src/controllers/api-v3/payments/index.js | 232 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 website/src/controllers/api-v3/payments/index.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js index c1056c1224..bc8e3b5177 100644 --- a/website/src/controllers/api-v3/payments/amazon.js +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -5,7 +5,7 @@ import mongoose from 'mongoose'; import moment from 'moment'; import nconf from 'nconf'; import payments from './index'; -import shared from '../../../../common'; +import shared from '../../../../../common'; import { model as User } from '../../models/user'; const IS_PROD = nconf.get('NODE_ENV') === 'production'; diff --git a/website/src/controllers/api-v3/payments/index.js b/website/src/controllers/api-v3/payments/index.js new file mode 100644 index 0000000000..f6e0a8ebe3 --- /dev/null +++ b/website/src/controllers/api-v3/payments/index.js @@ -0,0 +1,232 @@ +import _ from 'lodash' ; +import analytics from '../../../libs/api-v3/analyticsService'; +import async from 'async'; +import cc from 'coupon-code'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../../libs/api-v3/email'; +import members from '../members'; +import moment from 'moment'; +import mongoose from 'mongoose'; +import nconf from 'nconf'; +import pushNotify from '../../../libs/api-v3/pushNotifications'; +import shared from '../../../../../common' ; + +import amazon from './amazon'; +import iap from './iap'; +import paypal from './paypal'; +import stripe from './stripe'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +function revealMysteryItems (user) { + _.each(shared.content.gear.flat, function findMysteryItems (item) { + if ( + item.klass === 'mystery' && + moment().isAfter(shared.content.mystery[item.mystery].start) && + moment().isBefore(shared.content.mystery[item.mystery].end) && + !user.items.gear.owned[item.key] && + user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 + ) { + user.purchased.plan.mysteryItems.push(item.key); + } + }); +} + +api.createSubscription = function createSubscription (data, cb) { + let recipient = data.gift ? data.gift.member : data.user; + let plan = recipient.purchased.plan; + let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; + let months = Number(block.months); + + if (data.gift) { + if (plan.customerId && !plan.dateTerminated) { // User has active plan + plan.extraMonths += months; + } else { + plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); + if (!plan.dateUpdated) plan.dateUpdated = new Date(); + } + if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + } else { + _(plan).merge({ // override with these values + planId: block.key, + customerId: data.customerId, + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: data.paymentMethod, + extraMonths: Number(plan.extraMonths) + + Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0), + dateTerminated: null, + // Specify a lastBillingDate just for Amazon Payments + // Resetted every time the subscription restarts + lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, + }).defaults({ // allow non-override if a plan was previously used + dateCreated: new Date(), + mysteryItems: [], + }).value(); + } + + // Block sub perks + let perks = Math.floor(months / 3); + if (perks) { + plan.consecutive.offset += months; + plan.consecutive.gemCapExtra += perks * 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; + plan.consecutive.trinkets += perks; + } + revealMysteryItems(recipient); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'subscription-begins'); + + let analyticsData = { + uuid: data.user._id, + itemPurchased: 'Subscription', + sku: `${data.paymentMethod.toLowerCase()}-subscription`, + purchaseType: 'subscribe', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: block.price, + }; + analytics.trackPurchase(analyticsData); + } + data.user.purchased.txnCount++; + if (data.gift) { + members.sendMessage(data.user, data.gift.member, data.gift); + + let byUserName = getUserInfo(data.user, ['name']).name; + + if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { + txnEmail(data.gift.member, 'gifted-subscription', [ + {name: 'GIFTER', content: byUserName}, + {name: 'X_MONTHS_SUBSCRIPTION', content: months}, + ]); + } + + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +/** + * Sets their subscription to be cancelled later + */ +api.cancelSubscription = function cancelSubscription (data, cb) { + let plan = data.user.purchased.plan; + let now = moment(); + let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; + + plan.dateTerminated = + moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) + .add({days: remaining}) // end their subscription 1mo from their last payment + .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .toDate(); + plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + + data.user.save(cb); + txnEmail(data.user, 'cancel-subscription'); + let analyticsData = { + uuid: data.user._id, + gaCategory: 'commerce', + gaLabel: data.paymentMethod, + paymentMethod: data.paymentMethod, + }; + analytics.track('unsubscribe', analyticsData); +}; + +api.buyGems = function buyGems (data, cb) { + let amt = data.amount || 5; + amt = data.gift ? data.gift.gems.amount / 4 : amt; + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); + + let analyticsData = { + uuid: data.user._id, + itemPurchased: 'Gems', + sku: `${data.paymentMethod.toLowerCase()}-checkout`, + purchaseType: 'checkout', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: amt, + }; + analytics.trackPurchase(analyticsData); + } + + if (data.gift) { + let byUsername = getUserInfo(data.user, ['name']).name; + let gemAmount = data.gift.gems.amount || 20; + + members.sendMessage(data.user, data.gift.member, data.gift); + if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { + txnEmail(data.gift.member, 'gifted-gems', [ + {name: 'GIFTER', content: byUsername}, + {name: 'X_GEMS_GIFTED', content: gemAmount}, + ]); + } + + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +api.validCoupon = function validCoupon (req, res, next) { + mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { + if (err) return next(err); + if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); + return res.sendStatus(200); + }); +}; + +api.stripeCheckout = stripe.checkout; +api.stripeSubscribeCancel = stripe.subscribeCancel; +api.stripeSubscribeEdit = stripe.subscribeEdit; + +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; + +api.amazonVerifyAccessToken = amazon.verifyAccessToken; +api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +api.amazonCheckout = amazon.checkout; +api.amazonSubscribe = amazon.subscribe; +api.amazonSubscribeCancel = amazon.subscribeCancel; + +api.iapAndroidVerify = iap.androidVerify; +api.iapIosVerify = iap.iosVerify; + +module.exports = api; From 71e0792da88a262729a6e27a5c68ea5bd6228c80 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 12 Apr 2016 21:15:20 +0000 Subject: [PATCH 669/976] refactor(payments): IAP linting pass --- .../src/controllers/api-v3/payments/iap.js | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/iap.js diff --git a/website/src/controllers/api-v3/payments/iap.js b/website/src/controllers/api-v3/payments/iap.js new file mode 100644 index 0000000000..94cf21fcca --- /dev/null +++ b/website/src/controllers/api-v3/payments/iap.js @@ -0,0 +1,158 @@ +import { + iap, + inAppPurchase, } +from 'in-app-purchase'; +import payments from './index'; +import nconf from 'nconf'; + +inAppPurchase.config({ + // this is the path to the directory containing iap-sanbox/iap-live files + googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), +}); + +// Validation ERROR Codes +const INVALID_PAYLOAD = 6778001; +/* const CONNECTION_FAILED = 6778002; +const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? + +let api = {}; + +api.androidVerify = function androidVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function googleSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + /* + google receipt must be provided as an object + { + "data": "{stringified data object}", + "signature": "signature from google" + } + */ + let testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature, + }; + + // iap is ready + iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return res.json(resObj); + } + + if (iap.isValidated(googleRes)) { + let resObj = { + ok: true, + data: googleRes, + }; + + payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); + + return res.json(resObj); + } + }); + }); +}; + +exports.iosVerify = function iosVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function iosSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return res.json(resObj); + } + + if (iap.isValidated(appleRes)) { + let purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length > 0) { + let correctReceipt = true; + for (let index of purchaseDataList) { + switch (purchaseDataList[index].productId) { + case 'com.habitrpg.ios.Habitica.4gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); + break; + case 'com.habitrpg.ios.Habitica.8gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); + break; + case 'com.habitrpg.ios.Habitica.20gems': + case 'com.habitrpg.ios.Habitica.21gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); + break; + case 'com.habitrpg.ios.Habitica.42gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); + break; + default: + correctReceipt = false; + } + } + if (correctReceipt) { + let resObj = { + ok: true, + data: appleRes, + }; + // yay good! + return res.json(resObj); + } + } + // wrong receipt content + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Incorrect receipt content', + }, + }; + return res.json(resObj); + } + // invalid receipt + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Invalid receipt', + }, + }; + + return res.json(resObj); + }); + }); +}; + +module.exports = api; From da84f631e9f89ab2537621e29d4e7c0a001a670d Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 13 Apr 2016 19:29:13 +0000 Subject: [PATCH 670/976] refactor(payments): Stripe linting pass --- .../src/controllers/api-v3/payments/stripe.js | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/stripe.js diff --git a/website/src/controllers/api-v3/payments/stripe.js b/website/src/controllers/api-v3/payments/stripe.js new file mode 100644 index 0000000000..5582d33ca1 --- /dev/null +++ b/website/src/controllers/api-v3/payments/stripe.js @@ -0,0 +1,135 @@ +import nconf from 'nconf'; +import stripeModule from 'stripe'; +import async from 'async'; +import payments from './index'; +import { model as User } from '../../models/user'; +import shared from '../../../../../common'; +import mongoose from 'mongoose'; +import cc from 'coupon-code'; + +const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); + +let api = {}; +/* + Setup Stripe response when posting payment + */ +api.checkout = function checkout (req, res) { + let token = req.body.id; + let user = res.locals.user; + let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; + + async.waterfall([ + function stripeCharge (cb) { + if (sub) { + async.waterfall([ + function handleCoupon (cb2) { + if (!sub.discount) return cb2(null, null); + if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); + mongoose.model('Coupon').findOne({_id: cc.validate(req.query.coupon), event: sub.key}, cb2); + }, + function createCustomer (coupon, cb2) { + if (sub.discount && !coupon) return cb2('Invalid coupon code.'); + let customer = { + email: req.body.email, + metadata: {uuid: user._id}, + card: token, + plan: sub.key, + }; + stripe.customers.create(customer, cb2); + }, + ], cb); + } else { + let amount; + if (!gift) { + amount = '500'; + } else if (gift.type === 'subscription') { + amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; + } else { + amount = `${gift.gems.amount / 4 * 100}`; + } + stripe.charges.create({ + amount, + currency: 'usd', + card: token, + }, cb); + } + }, + function saveUserData (response, cb) { + if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', sub}, cb); + async.waterfall([ + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function prepData (member, cb2) { + let data = {user, customerId: response.id, paymentMethod: 'Stripe', gift}; + let method = 'buyGems'; + if (gift) { + gift.member = member; + if (gift.type === 'subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + payments[method](data, cb2); + }, + ], cb); + }, + ], function handleResponse (err) { + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.sendStatus(200); + user = token = null; + }); +}; + +api.subscribeCancel = function subscribeCancel (req, res) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) { + return res.status(401).json({err: 'User does not have a plan subscription'}); + } + + async.auto({ + getCustomer: function getCustomer (cb) { + stripe.customers.retrieve(user.purchased.plan.customerId, cb); + }, + deleteCustomer: ['getCustomer', function deleteCustomer (cb) { + stripe.customers.del(user.purchased.plan.customerId, cb); + }], + cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { + let data = { + user, + nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds + paymentMethod: 'Stripe', + }; + payments.cancelSubscription(data, cb); + }], + }, function handleResponse (err) { + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.redirect('/'); + user = null; + }); +}; + +api.subscribeEdit = function subscribeEdit (req, res) { + let token = req.body.id; + let user = res.locals.user; + let userId = user.purchased.plan.customerId; + let subscriptionId; + + async.waterfall([ + function listSubscriptions (cb) { + stripe.customers.listSubscriptions(userId, cb); + }, + function updateSubscription (response, cb) { + subscriptionId = response.data[0].id; + stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); + }, + function saveUser (response, cb) { + user.save(cb); + }, + ], function handleResponse (err) { + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.sendStatus(200); + token = user = userId = subscriptionId; + }); +}; + +module.exports = api; From c9e3e0e68c5689b00aa508be3ba08678425f072f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Apr 2016 18:18:57 +0200 Subject: [PATCH 671/976] move payments to /top-level --- website/src/controllers/{ => top-level}/payments/amazon.js | 0 website/src/controllers/{ => top-level}/payments/iap.js | 0 website/src/controllers/{ => top-level}/payments/index.js | 0 website/src/controllers/{ => top-level}/payments/paypal.js | 0 .../controllers/{ => top-level}/payments/paypalBillingSetup.js | 0 website/src/controllers/{ => top-level}/payments/stripe.js | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename website/src/controllers/{ => top-level}/payments/amazon.js (100%) rename website/src/controllers/{ => top-level}/payments/iap.js (100%) rename website/src/controllers/{ => top-level}/payments/index.js (100%) rename website/src/controllers/{ => top-level}/payments/paypal.js (100%) rename website/src/controllers/{ => top-level}/payments/paypalBillingSetup.js (100%) rename website/src/controllers/{ => top-level}/payments/stripe.js (100%) diff --git a/website/src/controllers/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js similarity index 100% rename from website/src/controllers/payments/amazon.js rename to website/src/controllers/top-level/payments/amazon.js diff --git a/website/src/controllers/payments/iap.js b/website/src/controllers/top-level/payments/iap.js similarity index 100% rename from website/src/controllers/payments/iap.js rename to website/src/controllers/top-level/payments/iap.js diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/top-level/payments/index.js similarity index 100% rename from website/src/controllers/payments/index.js rename to website/src/controllers/top-level/payments/index.js diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js similarity index 100% rename from website/src/controllers/payments/paypal.js rename to website/src/controllers/top-level/payments/paypal.js diff --git a/website/src/controllers/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js similarity index 100% rename from website/src/controllers/payments/paypalBillingSetup.js rename to website/src/controllers/top-level/payments/paypalBillingSetup.js diff --git a/website/src/controllers/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js similarity index 100% rename from website/src/controllers/payments/stripe.js rename to website/src/controllers/top-level/payments/stripe.js From a13f25e07ce5d39ad06cd853e0ee1b1b42e7eeff Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 6 Apr 2016 21:18:36 +0000 Subject: [PATCH 672/976] WIP(payments): lint Amazon Payments file --- .../src/controllers/api-v3/payments/amazon.js | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/amazon.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js new file mode 100644 index 0000000000..c1056c1224 --- /dev/null +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -0,0 +1,277 @@ +import amazonPayments from 'amazon-payments'; +import async from 'async'; +import cc from 'coupon-code'; +import mongoose from 'mongoose'; +import moment from 'moment'; +import nconf from 'nconf'; +import payments from './index'; +import shared from '../../../../common'; +import { model as User } from '../../models/user'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +let amzPayment = amazonPayments.connect({ + environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], + sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), + mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), + mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), + clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), +}); + +api.verifyAccessToken = function verifyAccessToken (req, res) { + if (!req.body || !req.body.access_token) { + return res.status(400).json({err: 'Access token not supplied.'}); + } + + amzPayment.api.getTokenInfo(req.body.access_token, function getTokenInfo (err) { + if (err) return res.status(400).json({err}); + + res.sendStatus(200); + }); +}; + +api.createOrderReferenceId = function createOrderReferenceId (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + amzPayment.offAmazonPayments.createOrderReferenceForId({ + Id: req.body.billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + }, function createOrderReferenceForId (err, response) { + if (err) return next(err); + if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { + return next(new Error('Missing attributes in Amazon response.')); + } + + res.json({ + orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, + }); + }); +}; + +api.checkout = function checkout (req, res, next) { + if (!req.body || !req.body.orderReferenceId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let amount = 5; + + if (gift) { + if (gift.type === 'gems') { + amount = gift.gems.amount / 4; + } else if (gift.type === 'subscription') { + amount = shared.content.subscriptionBlocks[gift.subscription.key].price; + } + } + + async.series({ + setOrderReferenceDetails (cb) { + amzPayment.offAmazonPayments.setOrderReferenceDetails({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerNote: 'HabitRPG Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, + }, cb); + }, + + confirmOrderReference (cb) { + amzPayment.offAmazonPayments.confirmOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + authorize (cb) { + amzPayment.offAmazonPayments.authorize({ + AmazonOrderReferenceId: orderReferenceId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerAuthorizationNote: 'HabitRPG Payment', + TransactionTimeout: 0, + CaptureNow: true, + }, function checkAuthorizationStatus (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successfull.')); + } + + return cb(); + }); + }, + + closeOrderReference (cb) { + amzPayment.offAmazonPayments.closeOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + executePayment (cb) { + async.waterfall([ + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function executeAmazonPayment (member, cb2) { + let data = {user, paymentMethod: 'Amazon Payments'}; + let method = 'buyGems'; + + if (gift) { + if (gift.type === 'subscription') method = 'createSubscription'; + gift.member = member; + data.gift = gift; + data.paymentMethod = 'Gift'; + } + + payments[method](data, cb2); + }, + ], cb); + }, + }, function result (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribe = function subscribe (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + let billingAgreementId = req.body.billingAgreementId; + let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; + let coupon = req.body.coupon; + let user = res.locals.user; + + if (!sub) { + return res.status(400).json({err: 'Subscription plan not found.'}); + } + + async.series({ + applyDiscount (cb) { + if (!sub.discount) return cb(); + if (!coupon) return cb(new Error('Please provide a coupon code for this plan.')); + mongoose.model('Coupon').findOne({_id: cc.validate(coupon), event: sub.key}, function couponResult (err) { + if (err) return cb(err); + if (!coupon) return cb(new Error('Coupon code not found.')); + cb(); + }); + }, + + setBillingAgreementDetails (cb) { + amzPayment.offAmazonPayments.setBillingAgreementDetails({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: 'HabitRPG Subscription', + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: shared.uuid(), + StoreName: 'HabitRPG', + CustomInformation: 'HabitRPG Subscription', + }, + }, + }, cb); + }, + + confirmBillingAgreement (cb) { + amzPayment.offAmazonPayments.confirmBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + authorizeOnBillingAgreement (cb) { + amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: sub.price, + }, + SellerAuthorizationNote: 'HabitRPG Subscription Payment', + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: 'HabitRPG Subscription Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, function billingAgreementResult (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successful.')); + } + + return cb(); + }); + }, + + createSubscription (cb) { + payments.createSubscription({ + user, + customerId: billingAgreementId, + paymentMethod: 'Amazon Payments', + sub, + }, cb); + }, + }, function subscribeResult (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribeCancel = function subscribeCancel (req, res, next) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.status(401).json({err: 'User does not have a plan subscription'}); + + let billingAgreementId = user.purchased.plan.customerId; + + async.series({ + closeBillingAgreement (cb) { + amzPayment.offAmazonPayments.closeBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + cancelSubscription (cb) { + let data = { + user, + // Date of next bill + nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), + paymentMethod: 'Amazon Payments', + }; + + payments.cancelSubscription(data, cb); + }, + }, function subscribeCancelResult (err) { + if (err) return next(err); // don't json this, let toString() handle errors + + if (req.query.noRedirect) { + res.sendStatus(200); + } else { + res.redirect('/'); + } + + user = null; + }); +}; + +module.exports = api; From fc46bdf1841efe784accd85ad94d9ce6d657c134 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 7 Apr 2016 20:51:40 +0000 Subject: [PATCH 673/976] refactor(payments): index.js lint pass --- .../src/controllers/api-v3/payments/amazon.js | 2 +- .../src/controllers/api-v3/payments/index.js | 232 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 website/src/controllers/api-v3/payments/index.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js index c1056c1224..bc8e3b5177 100644 --- a/website/src/controllers/api-v3/payments/amazon.js +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -5,7 +5,7 @@ import mongoose from 'mongoose'; import moment from 'moment'; import nconf from 'nconf'; import payments from './index'; -import shared from '../../../../common'; +import shared from '../../../../../common'; import { model as User } from '../../models/user'; const IS_PROD = nconf.get('NODE_ENV') === 'production'; diff --git a/website/src/controllers/api-v3/payments/index.js b/website/src/controllers/api-v3/payments/index.js new file mode 100644 index 0000000000..f6e0a8ebe3 --- /dev/null +++ b/website/src/controllers/api-v3/payments/index.js @@ -0,0 +1,232 @@ +import _ from 'lodash' ; +import analytics from '../../../libs/api-v3/analyticsService'; +import async from 'async'; +import cc from 'coupon-code'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../../libs/api-v3/email'; +import members from '../members'; +import moment from 'moment'; +import mongoose from 'mongoose'; +import nconf from 'nconf'; +import pushNotify from '../../../libs/api-v3/pushNotifications'; +import shared from '../../../../../common' ; + +import amazon from './amazon'; +import iap from './iap'; +import paypal from './paypal'; +import stripe from './stripe'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +function revealMysteryItems (user) { + _.each(shared.content.gear.flat, function findMysteryItems (item) { + if ( + item.klass === 'mystery' && + moment().isAfter(shared.content.mystery[item.mystery].start) && + moment().isBefore(shared.content.mystery[item.mystery].end) && + !user.items.gear.owned[item.key] && + user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 + ) { + user.purchased.plan.mysteryItems.push(item.key); + } + }); +} + +api.createSubscription = function createSubscription (data, cb) { + let recipient = data.gift ? data.gift.member : data.user; + let plan = recipient.purchased.plan; + let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; + let months = Number(block.months); + + if (data.gift) { + if (plan.customerId && !plan.dateTerminated) { // User has active plan + plan.extraMonths += months; + } else { + plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); + if (!plan.dateUpdated) plan.dateUpdated = new Date(); + } + if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + } else { + _(plan).merge({ // override with these values + planId: block.key, + customerId: data.customerId, + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: data.paymentMethod, + extraMonths: Number(plan.extraMonths) + + Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0), + dateTerminated: null, + // Specify a lastBillingDate just for Amazon Payments + // Resetted every time the subscription restarts + lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, + }).defaults({ // allow non-override if a plan was previously used + dateCreated: new Date(), + mysteryItems: [], + }).value(); + } + + // Block sub perks + let perks = Math.floor(months / 3); + if (perks) { + plan.consecutive.offset += months; + plan.consecutive.gemCapExtra += perks * 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; + plan.consecutive.trinkets += perks; + } + revealMysteryItems(recipient); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'subscription-begins'); + + let analyticsData = { + uuid: data.user._id, + itemPurchased: 'Subscription', + sku: `${data.paymentMethod.toLowerCase()}-subscription`, + purchaseType: 'subscribe', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: block.price, + }; + analytics.trackPurchase(analyticsData); + } + data.user.purchased.txnCount++; + if (data.gift) { + members.sendMessage(data.user, data.gift.member, data.gift); + + let byUserName = getUserInfo(data.user, ['name']).name; + + if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { + txnEmail(data.gift.member, 'gifted-subscription', [ + {name: 'GIFTER', content: byUserName}, + {name: 'X_MONTHS_SUBSCRIPTION', content: months}, + ]); + } + + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +/** + * Sets their subscription to be cancelled later + */ +api.cancelSubscription = function cancelSubscription (data, cb) { + let plan = data.user.purchased.plan; + let now = moment(); + let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; + + plan.dateTerminated = + moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) + .add({days: remaining}) // end their subscription 1mo from their last payment + .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .toDate(); + plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + + data.user.save(cb); + txnEmail(data.user, 'cancel-subscription'); + let analyticsData = { + uuid: data.user._id, + gaCategory: 'commerce', + gaLabel: data.paymentMethod, + paymentMethod: data.paymentMethod, + }; + analytics.track('unsubscribe', analyticsData); +}; + +api.buyGems = function buyGems (data, cb) { + let amt = data.amount || 5; + amt = data.gift ? data.gift.gems.amount / 4 : amt; + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); + + let analyticsData = { + uuid: data.user._id, + itemPurchased: 'Gems', + sku: `${data.paymentMethod.toLowerCase()}-checkout`, + purchaseType: 'checkout', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: amt, + }; + analytics.trackPurchase(analyticsData); + } + + if (data.gift) { + let byUsername = getUserInfo(data.user, ['name']).name; + let gemAmount = data.gift.gems.amount || 20; + + members.sendMessage(data.user, data.gift.member, data.gift); + if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { + txnEmail(data.gift.member, 'gifted-gems', [ + {name: 'GIFTER', content: byUsername}, + {name: 'X_GEMS_GIFTED', content: gemAmount}, + ]); + } + + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +api.validCoupon = function validCoupon (req, res, next) { + mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { + if (err) return next(err); + if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); + return res.sendStatus(200); + }); +}; + +api.stripeCheckout = stripe.checkout; +api.stripeSubscribeCancel = stripe.subscribeCancel; +api.stripeSubscribeEdit = stripe.subscribeEdit; + +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; + +api.amazonVerifyAccessToken = amazon.verifyAccessToken; +api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +api.amazonCheckout = amazon.checkout; +api.amazonSubscribe = amazon.subscribe; +api.amazonSubscribeCancel = amazon.subscribeCancel; + +api.iapAndroidVerify = iap.androidVerify; +api.iapIosVerify = iap.iosVerify; + +module.exports = api; From f4be29952beeab009e302697f05318b4f65e0df2 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 12 Apr 2016 21:15:20 +0000 Subject: [PATCH 674/976] refactor(payments): IAP linting pass --- .../src/controllers/api-v3/payments/iap.js | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/iap.js diff --git a/website/src/controllers/api-v3/payments/iap.js b/website/src/controllers/api-v3/payments/iap.js new file mode 100644 index 0000000000..94cf21fcca --- /dev/null +++ b/website/src/controllers/api-v3/payments/iap.js @@ -0,0 +1,158 @@ +import { + iap, + inAppPurchase, } +from 'in-app-purchase'; +import payments from './index'; +import nconf from 'nconf'; + +inAppPurchase.config({ + // this is the path to the directory containing iap-sanbox/iap-live files + googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), +}); + +// Validation ERROR Codes +const INVALID_PAYLOAD = 6778001; +/* const CONNECTION_FAILED = 6778002; +const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? + +let api = {}; + +api.androidVerify = function androidVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function googleSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + /* + google receipt must be provided as an object + { + "data": "{stringified data object}", + "signature": "signature from google" + } + */ + let testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature, + }; + + // iap is ready + iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return res.json(resObj); + } + + if (iap.isValidated(googleRes)) { + let resObj = { + ok: true, + data: googleRes, + }; + + payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); + + return res.json(resObj); + } + }); + }); +}; + +exports.iosVerify = function iosVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function iosSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return res.json(resObj); + } + + if (iap.isValidated(appleRes)) { + let purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length > 0) { + let correctReceipt = true; + for (let index of purchaseDataList) { + switch (purchaseDataList[index].productId) { + case 'com.habitrpg.ios.Habitica.4gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); + break; + case 'com.habitrpg.ios.Habitica.8gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); + break; + case 'com.habitrpg.ios.Habitica.20gems': + case 'com.habitrpg.ios.Habitica.21gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); + break; + case 'com.habitrpg.ios.Habitica.42gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); + break; + default: + correctReceipt = false; + } + } + if (correctReceipt) { + let resObj = { + ok: true, + data: appleRes, + }; + // yay good! + return res.json(resObj); + } + } + // wrong receipt content + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Incorrect receipt content', + }, + }; + return res.json(resObj); + } + // invalid receipt + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Invalid receipt', + }, + }; + + return res.json(resObj); + }); + }); +}; + +module.exports = api; From 7f89f8b936325f8c8ee7945ee81c72fc316e2103 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 13 Apr 2016 19:29:13 +0000 Subject: [PATCH 675/976] refactor(payments): Stripe linting pass --- .../src/controllers/api-v3/payments/stripe.js | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/stripe.js diff --git a/website/src/controllers/api-v3/payments/stripe.js b/website/src/controllers/api-v3/payments/stripe.js new file mode 100644 index 0000000000..5582d33ca1 --- /dev/null +++ b/website/src/controllers/api-v3/payments/stripe.js @@ -0,0 +1,135 @@ +import nconf from 'nconf'; +import stripeModule from 'stripe'; +import async from 'async'; +import payments from './index'; +import { model as User } from '../../models/user'; +import shared from '../../../../../common'; +import mongoose from 'mongoose'; +import cc from 'coupon-code'; + +const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); + +let api = {}; +/* + Setup Stripe response when posting payment + */ +api.checkout = function checkout (req, res) { + let token = req.body.id; + let user = res.locals.user; + let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; + + async.waterfall([ + function stripeCharge (cb) { + if (sub) { + async.waterfall([ + function handleCoupon (cb2) { + if (!sub.discount) return cb2(null, null); + if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); + mongoose.model('Coupon').findOne({_id: cc.validate(req.query.coupon), event: sub.key}, cb2); + }, + function createCustomer (coupon, cb2) { + if (sub.discount && !coupon) return cb2('Invalid coupon code.'); + let customer = { + email: req.body.email, + metadata: {uuid: user._id}, + card: token, + plan: sub.key, + }; + stripe.customers.create(customer, cb2); + }, + ], cb); + } else { + let amount; + if (!gift) { + amount = '500'; + } else if (gift.type === 'subscription') { + amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; + } else { + amount = `${gift.gems.amount / 4 * 100}`; + } + stripe.charges.create({ + amount, + currency: 'usd', + card: token, + }, cb); + } + }, + function saveUserData (response, cb) { + if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', sub}, cb); + async.waterfall([ + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function prepData (member, cb2) { + let data = {user, customerId: response.id, paymentMethod: 'Stripe', gift}; + let method = 'buyGems'; + if (gift) { + gift.member = member; + if (gift.type === 'subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + payments[method](data, cb2); + }, + ], cb); + }, + ], function handleResponse (err) { + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.sendStatus(200); + user = token = null; + }); +}; + +api.subscribeCancel = function subscribeCancel (req, res) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) { + return res.status(401).json({err: 'User does not have a plan subscription'}); + } + + async.auto({ + getCustomer: function getCustomer (cb) { + stripe.customers.retrieve(user.purchased.plan.customerId, cb); + }, + deleteCustomer: ['getCustomer', function deleteCustomer (cb) { + stripe.customers.del(user.purchased.plan.customerId, cb); + }], + cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { + let data = { + user, + nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds + paymentMethod: 'Stripe', + }; + payments.cancelSubscription(data, cb); + }], + }, function handleResponse (err) { + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.redirect('/'); + user = null; + }); +}; + +api.subscribeEdit = function subscribeEdit (req, res) { + let token = req.body.id; + let user = res.locals.user; + let userId = user.purchased.plan.customerId; + let subscriptionId; + + async.waterfall([ + function listSubscriptions (cb) { + stripe.customers.listSubscriptions(userId, cb); + }, + function updateSubscription (response, cb) { + subscriptionId = response.data[0].id; + stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); + }, + function saveUser (response, cb) { + user.save(cb); + }, + ], function handleResponse (err) { + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.sendStatus(200); + token = user = userId = subscriptionId; + }); +}; + +module.exports = api; From 14d3abdd908cd57e20f2d59a6334bff044c339b5 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 14 Apr 2016 16:09:51 +0000 Subject: [PATCH 676/976] moved payments/ to controllers/top-level/ --- website/src/controllers/{ => top-level}/payments/amazon.js | 0 website/src/controllers/{ => top-level}/payments/iap.js | 0 website/src/controllers/{ => top-level}/payments/index.js | 0 website/src/controllers/{ => top-level}/payments/paypal.js | 0 .../controllers/{ => top-level}/payments/paypalBillingSetup.js | 0 website/src/controllers/{ => top-level}/payments/stripe.js | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename website/src/controllers/{ => top-level}/payments/amazon.js (100%) rename website/src/controllers/{ => top-level}/payments/iap.js (100%) rename website/src/controllers/{ => top-level}/payments/index.js (100%) rename website/src/controllers/{ => top-level}/payments/paypal.js (100%) rename website/src/controllers/{ => top-level}/payments/paypalBillingSetup.js (100%) rename website/src/controllers/{ => top-level}/payments/stripe.js (100%) diff --git a/website/src/controllers/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js similarity index 100% rename from website/src/controllers/payments/amazon.js rename to website/src/controllers/top-level/payments/amazon.js diff --git a/website/src/controllers/payments/iap.js b/website/src/controllers/top-level/payments/iap.js similarity index 100% rename from website/src/controllers/payments/iap.js rename to website/src/controllers/top-level/payments/iap.js diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/top-level/payments/index.js similarity index 100% rename from website/src/controllers/payments/index.js rename to website/src/controllers/top-level/payments/index.js diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js similarity index 100% rename from website/src/controllers/payments/paypal.js rename to website/src/controllers/top-level/payments/paypal.js diff --git a/website/src/controllers/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js similarity index 100% rename from website/src/controllers/payments/paypalBillingSetup.js rename to website/src/controllers/top-level/payments/paypalBillingSetup.js diff --git a/website/src/controllers/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js similarity index 100% rename from website/src/controllers/payments/stripe.js rename to website/src/controllers/top-level/payments/stripe.js From 11b5c1b4056af619a079ee1100b97b96d7cfbac0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Apr 2016 20:05:30 +0200 Subject: [PATCH 677/976] v3: several fixes to class system, move /logout outside of api --- common/locales/en/api-v3.json | 3 +- common/script/ops/changeClass.js | 5 +- package.json | 3 +- .../user/POST-user_change-class.test.js | 5 +- test/api/v3/integration/user/PUT-user.test.js | 1 + test/common/ops/changeClass.js | 35 ++++++-- website/src/controllers/api-v3/auth.js | 90 +++++++++---------- website/src/controllers/api-v3/user.js | 1 + website/src/controllers/top-level/auth.js | 21 +++++ 9 files changed, 109 insertions(+), 55 deletions(-) create mode 100644 website/src/controllers/top-level/auth.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 3bd4f194cd..7af8dc9af0 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -169,5 +169,6 @@ "regIdRequired": "RegId is required", "pushDeviceAdded": "Push device added successfully", "pushDeviceAlreadyAdded": "The user already has the push device", - "resetComplete": "Reset has completed" + "resetComplete": "Reset completed", + "lvl10ChangeClass": "To change class you must be at least level 10." } diff --git a/common/script/ops/changeClass.js b/common/script/ops/changeClass.js index 17a5d5c4d1..520a435ace 100644 --- a/common/script/ops/changeClass.js +++ b/common/script/ops/changeClass.js @@ -9,7 +9,10 @@ import { module.exports = function changeClass (user, req = {}, analytics) { let klass = _.get(req, 'query.class'); - if (klass === 'warrior' || klass === 'rogue' || klass === 'wizard' || klass === 'healer') { + // user.flags.classSelected is set to false after the user paid the 3 gems + if (user.stats.lvl < 10) { + throw new NotAuthorized(i18n.t('lvl10ChangeClass', req.language)); + } else if (!user.flags.classSelected && (klass === 'warrior' || klass === 'rogue' || klass === 'wizard' || klass === 'healer')) { user.stats.class = klass; user.flags.classSelected = true; diff --git a/package.json b/package.json index 73b4c4f8ae..5407759afb 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,6 @@ "name": "habitica", "title": "Habitica", "version": "3.0.0", - "url": "https://habitica-v3.herokuapp.com", - "sampleUrl": "https://habitica-v3.herokuapp.com" + "url": "https://habitica-v3.herokuapp.com/api-v3" } } diff --git a/test/api/v3/integration/user/POST-user_change-class.test.js b/test/api/v3/integration/user/POST-user_change-class.test.js index 3a636d1c42..3ec8fe468c 100644 --- a/test/api/v3/integration/user/POST-user_change-class.test.js +++ b/test/api/v3/integration/user/POST-user_change-class.test.js @@ -6,7 +6,10 @@ describe('POST /user/change-class', () => { let user; beforeEach(async () => { - user = await generateUser(); + user = await generateUser({ + 'flags.classSelected': false, + 'stats.lvl': 10, + }); }); // More tests in common code unit tests diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js index 7ea5b44cbf..eb623e0a4a 100644 --- a/test/api/v3/integration/user/PUT-user.test.js +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -57,6 +57,7 @@ describe('PUT /user', () => { 'flags unless whitelisted': {'flags.dropsEnabled': true}, webhooks: {'preferences.webhooks': [1, 2, 3]}, sleep: {'preferences.sleep': true}, + 'disable classes': {'preferences.disableClasses': true}, }; each(protectedOperations, (data, testName) => { diff --git a/test/common/ops/changeClass.js b/test/common/ops/changeClass.js index 4cced258de..498dde18f2 100644 --- a/test/common/ops/changeClass.js +++ b/test/common/ops/changeClass.js @@ -12,9 +12,36 @@ describe('shared.ops.changeClass', () => { beforeEach(() => { user = generateUser(); + user.stats.lvl = 11; + user.stats.flagSelected = false; + }); + + it('user is not level 10', (done) => { + user.stats.lvl = 9; + try { + changeClass(user, {query: {class: 'rogue'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('lvl10ChangeClass')); + done(); + } }); context('req.query.class is a valid class', () => { + it('errors if user.stats.flagSelected is true and user.balance < 0.75', (done) => { + user.flags.classSelected = true; + user.preferences.disableClasses = false; + user.balance = 0; + + try { + changeClass(user, {query: {class: 'rogue'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); + } + }); + it('changes class', () => { user.stats.class = 'healer'; user.items.gear.owned.armor_rogue_1 = true; // eslint-disable-line camelcase @@ -41,13 +68,12 @@ describe('shared.ops.changeClass', () => { }); }); - context('req.query.class is missing', () => { + context('req.query.class is missing or user.stats.flagSelected is true', () => { it('has user.preferences.disableClasses === true', () => { user.balance = 1; user.preferences.disableClasses = true; user.preferences.autoAllocate = true; user.stats.points = 45; - user.stats.lvl = 3; user.stats.str = 1; user.stats.con = 2; user.stats.per = 3; @@ -71,7 +97,7 @@ describe('shared.ops.changeClass', () => { expect(user.stats.con).to.equal(0); expect(user.stats.per).to.equal(0); expect(user.stats.int).to.equal(0); - expect(user.stats.points).to.equal(3); + expect(user.stats.points).to.equal(11); expect(user.flags.classSelected).to.equal(false); }); @@ -90,7 +116,6 @@ describe('shared.ops.changeClass', () => { it('and at least 3 gems', () => { user.balance = 1; user.stats.points = 45; - user.stats.lvl = 3; user.stats.str = 1; user.stats.con = 2; user.stats.per = 3; @@ -112,7 +137,7 @@ describe('shared.ops.changeClass', () => { expect(user.stats.con).to.equal(0); expect(user.stats.per).to.equal(0); expect(user.stats.int).to.equal(0); - expect(user.stats.points).to.equal(3); + expect(user.stats.points).to.equal(11); expect(user.flags.classSelected).to.equal(false); }); }); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index a98cf0310d..640003bb6d 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -4,8 +4,7 @@ import passport from 'passport'; import nconf from 'nconf'; import { authWithHeaders, - authWithSession, - } from '../../middlewares/api-v3/auth'; +} from '../../middlewares/api-v3/auth'; import { NotAuthorized, BadRequest, @@ -52,17 +51,18 @@ async function _handleGroupInvitation (user, invite) { } /** - * @api {post} /api/v3/user/auth/local/register Register a new user with email, username and password or attach local auth to a social user + * @api {post} /api/v3/user/auth/local/register Register + * @apiDescription Register a new user with email, username and password or attach local auth to a social user * @apiVersion 3.0.0 * @apiName UserRegisterLocal * @apiGroup User * - * @apiParam {String} username Username of the new user - * @apiParam {String} email Email address of the new user - * @apiParam {String} password Password for the new user account - * @apiParam {String} confirmPassword Password confirmation + * @apiParam {String} username Body parameter - Username of the new user + * @apiParam {String} email Body parameter - Email address of the new user + * @apiParam {String} password Body parameter - Password for the new user + * @apiParam {String} confirmPassword Body parameter - Password confirmation * - * @apiSuccess {Object} user The user object, if we just attached local auth to a social user then only user.auth.local + * @apiSuccess {Object} user The user object, if local auth was just attached to a social user then only user.auth.local */ api.registerLocal = { method: 'POST', @@ -165,13 +165,14 @@ function _loginRes (user, req, res) { } /** - * @api {post} /api/v3/user/auth/local/login Login an user with email / username and password + * @api {post} /api/v3/user/auth/local/login Login + * @apiDescription Login an user with email / username and password * @apiVersion 3.0.0 * @apiName UserLoginLocal * @apiGroup User * - * @apiParam {String} username Username or email of the user - * @apiParam {String} password The user's password + * @apiParam {String} username Body parameter - Username or email of the user + * @apiParam {String} password Body parameter - The user's password * * @apiSuccess {String} _id The user's unique identifier * @apiSuccess {String} apiToken The user's api token that must be used to authenticate requests. @@ -227,7 +228,7 @@ function _passportFbProfile (accessToken) { return deferred.promise; } -// Called as a callback by Facebook (or other social providers) +// Called as a callback by Facebook (or other social providers). Internal route api.loginSocial = { method: 'POST', url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 @@ -280,13 +281,16 @@ api.loginSocial = { }; /** - * @api {put} /api/v3/user/auth/update-username + * @api {put} /api/v3/user/auth/update-username Update username + * @apiDescription Update the username of a local user * @apiVersion 3.0.0 - * @apiName updateUsername + * @apiName UpdateUsername * @apiGroup User - * @apiParam {string} password The password - * @apiParam {string} username New username - * @apiSuccess {Object} The new username + * + * @apiParam {string} password Body parameter - The current user password + * @apiParam {string} username Body parameter - The new username + + * @apiSuccess {String} username The new username **/ api.updateUsername = { method: 'PUT', @@ -326,13 +330,16 @@ api.updateUsername = { /** * @api {put} /api/v3/user/auth/update-password + * @apiDescription Update the password of a local user * @apiVersion 3.0.0 - * @apiName updatePassword + * @apiName UpdatePassword * @apiGroup User - * @apiParam {string} password The old password - * @apiParam {string} newPassword The new password - * @apiParam {string} confirmPassword Password confirmation - * @apiSuccess {Object} The success message + * + * @apiParam {string} password Body parameter - The old password + * @apiParam {string} newPassword Body parameter - The new password + * @apiParam {string} confirmPassword Body parameter - New password confirmation + * + * @apiSuccess {Object} emoty An empty object **/ api.updatePassword = { method: 'PUT', @@ -364,12 +371,15 @@ api.updatePassword = { }; /** - * @api {post} /api/v3/user/reset-password + * @api {post} /api/v3/user/reset-password Reser password + * @apiDescription Reset the user password * @apiVersion 3.0.0 - * @apiName resetPassword + * @apiName ResetPassword * @apiGroup User - * @apiParam {string} email email - * @apiSuccess {Object} The success message + * + * @apiParam {string} email Body parameter - The email address of the user + * + * @apiSuccess {string} message The localized success message **/ api.resetPassword = { method: 'POST', @@ -414,15 +424,16 @@ api.resetPassword = { }; /** - * @api {put} /api/v3/user/auth/update-email + * @api {put} /api/v3/user/auth/update-email Update email + * @apiDescription Che the user email * @apiVersion 3.0.0 * @apiName UpdateEmail * @apiGroup User * - * @apiParam {string} newEmail The new email address. - * @apiParam {string} password The user password. + * @apiParam {string} Body parameter - newEmail The new email address. + * @apiParam {string} Body parameter - password The user password. * - * @apiSuccess {Object} An object containing the new email address + * @apiSuccess {string} email The updated email address */ api.updateEmail = { method: 'PUT', @@ -450,7 +461,7 @@ api.updateEmail = { const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); -// Internal route TODO expose? +// Internal route api.getFirebaseToken = { method: 'POST', url: '/user/auth/firebase', @@ -471,12 +482,13 @@ api.getFirebaseToken = { }; /** - * @api {delete} /api/v3/user/auth/social/:network Delete a social authentication method (only facebook supported) + * @api {delete} /api/v3/user/auth/social/:network Delete social authentication method + * @apiDescription Remove a social authentication method (only facebook supported) from a user profile. The user must have local authentication enabled * @apiVersion 3.0.0 * @apiName UserDeleteSocial * @apiGroup User * - * @apiSuccess {Object} response Empty object + * @apiSuccess {Object} empty Empty object */ api.deleteSocial = { method: 'DELETE', @@ -495,16 +507,4 @@ api.deleteSocial = { }, }; -// Internal route -api.logout = { - method: 'GET', - url: '/user/auth/logout', // TODO this is under /api/v3 route, should be accessible through habitica.com/logout - middlewares: [authWithSession], - async handler (req, res) { - req.logout(); // passportjs method - req.session = null; - res.redirect('/'); - }, -}; - module.exports = api; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index f4e63bb400..d743c9e9a8 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -109,6 +109,7 @@ let acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, ( let restrictedPUTSubPaths = [ 'stats.class', + 'preferences.disableClasses', 'preferences.sleep', 'preferences.webhooks', ]; diff --git a/website/src/controllers/top-level/auth.js b/website/src/controllers/top-level/auth.js new file mode 100644 index 0000000000..31dfb3b693 --- /dev/null +++ b/website/src/controllers/top-level/auth.js @@ -0,0 +1,21 @@ +import { + authWithSession, +} from '../../middlewares/api-v3/auth'; + +let api = {}; + +// Internal authentication routes + +// Logout the user from the website. +api.logout = { + method: 'GET', + url: '/logout', + middlewares: [authWithSession], + async handler (req, res) { + req.logout(); // passportjs method + req.session = null; + res.redirect('/'); + }, +}; + +module.exports = api; From 74fc45524bc5813b6843be4c895c367853c5c71b Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 14 Apr 2016 22:38:41 +0000 Subject: [PATCH 678/976] working on promisifying amazonPayments --- common/locales/en/api-v3.json | 6 +- tasks/gulp-tests.js | 4 + ...ayments_amazon_verify_access_token.test.js | 21 ++ test/api/v3/unit/libs/amazonPayments.test.js | 24 ++ .../src/controllers/api-v3/payments/amazon.js | 277 ------------------ .../src/controllers/api-v3/payments/iap.js | 158 ---------- .../src/controllers/api-v3/payments/index.js | 232 --------------- .../src/controllers/api-v3/payments/stripe.js | 135 --------- .../controllers/top-level/payments/amazon.js | 268 +++++++++-------- .../src/controllers/top-level/payments/iap.js | 125 ++++---- .../controllers/top-level/payments/index.js | 252 +++++++++------- .../controllers/top-level/payments/paypal.js | 9 +- .../controllers/top-level/payments/stripe.js | 138 +++++---- website/src/libs/api-v3/amazonPayments.js | 38 +++ 14 files changed, 520 insertions(+), 1167 deletions(-) create mode 100644 test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js create mode 100644 test/api/v3/unit/libs/amazonPayments.test.js delete mode 100644 website/src/controllers/api-v3/payments/amazon.js delete mode 100644 website/src/controllers/api-v3/payments/iap.js delete mode 100644 website/src/controllers/api-v3/payments/index.js delete mode 100644 website/src/controllers/api-v3/payments/stripe.js create mode 100644 website/src/libs/api-v3/amazonPayments.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 3bd4f194cd..ed5908e1ba 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -169,5 +169,9 @@ "regIdRequired": "RegId is required", "pushDeviceAdded": "Push device added successfully", "pushDeviceAlreadyAdded": "The user already has the push device", - "resetComplete": "Reset has completed" + "resetComplete": "Reset has completed", + "missingAccessToken": "The request is missing a required parameter : access_token", + "missingBillingAgreementId": "Missing billing agreement id", + "missingAttributesFromAmazon": "Missing attributes from Amazon", + "errorFromAmazon": "Error from Amazon" } diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 9cb654ce21..ac5d65a925 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -358,6 +358,10 @@ gulp.task('test:api-v3:unit', (done) => { pipe(runner); }); +gulp.task('test:api-v3:unit:watch', () => { + gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/libs/*'], ['test:api-v3:unit']); +}); + gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js new file mode 100644 index 0000000000..494c387f14 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : amazon', () => { + let endpoint = '/payments/amazon/verifyAccessToken'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verify access token', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingAccessToken'), + }); + }); +}); diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js new file mode 100644 index 0000000000..a60a8b4633 --- /dev/null +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -0,0 +1,24 @@ +import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; + +describe.only('amazonPayments', () => { + + beforeEach(() => { + }); + + describe('#getTokenInfo', () => { + it('validates access_token parameter', async (done) => { + try { + let result = await amz.getTokenInfo(); + } catch (e) { + expect(e.type).to.eql('invalid_request'); + done(); + } + }); + }); + + describe('#createOrderReferenceId', () => { + it('is sane', () => { + expect(false).to.eql(true); // @TODO + }); + }); +}); diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js deleted file mode 100644 index bc8e3b5177..0000000000 --- a/website/src/controllers/api-v3/payments/amazon.js +++ /dev/null @@ -1,277 +0,0 @@ -import amazonPayments from 'amazon-payments'; -import async from 'async'; -import cc from 'coupon-code'; -import mongoose from 'mongoose'; -import moment from 'moment'; -import nconf from 'nconf'; -import payments from './index'; -import shared from '../../../../../common'; -import { model as User } from '../../models/user'; - -const IS_PROD = nconf.get('NODE_ENV') === 'production'; - -let api = {}; - -let amzPayment = amazonPayments.connect({ - environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], - sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), - mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), - mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), - clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), -}); - -api.verifyAccessToken = function verifyAccessToken (req, res) { - if (!req.body || !req.body.access_token) { - return res.status(400).json({err: 'Access token not supplied.'}); - } - - amzPayment.api.getTokenInfo(req.body.access_token, function getTokenInfo (err) { - if (err) return res.status(400).json({err}); - - res.sendStatus(200); - }); -}; - -api.createOrderReferenceId = function createOrderReferenceId (req, res, next) { - if (!req.body || !req.body.billingAgreementId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - amzPayment.offAmazonPayments.createOrderReferenceForId({ - Id: req.body.billingAgreementId, - IdType: 'BillingAgreement', - ConfirmNow: false, - }, function createOrderReferenceForId (err, response) { - if (err) return next(err); - if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { - return next(new Error('Missing attributes in Amazon response.')); - } - - res.json({ - orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, - }); - }); -}; - -api.checkout = function checkout (req, res, next) { - if (!req.body || !req.body.orderReferenceId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - let gift = req.body.gift; - let user = res.locals.user; - let orderReferenceId = req.body.orderReferenceId; - let amount = 5; - - if (gift) { - if (gift.type === 'gems') { - amount = gift.gems.amount / 4; - } else if (gift.type === 'subscription') { - amount = shared.content.subscriptionBlocks[gift.subscription.key].price; - } - } - - async.series({ - setOrderReferenceDetails (cb) { - amzPayment.offAmazonPayments.setOrderReferenceDetails({ - AmazonOrderReferenceId: orderReferenceId, - OrderReferenceAttributes: { - OrderTotal: { - CurrencyCode: 'USD', - Amount: amount, - }, - SellerNote: 'HabitRPG Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG', - }, - }, - }, cb); - }, - - confirmOrderReference (cb) { - amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, - - authorize (cb) { - amzPayment.offAmazonPayments.authorize({ - AmazonOrderReferenceId: orderReferenceId, - AuthorizationReferenceId: shared.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: 'USD', - Amount: amount, - }, - SellerAuthorizationNote: 'HabitRPG Payment', - TransactionTimeout: 0, - CaptureNow: true, - }, function checkAuthorizationStatus (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successfull.')); - } - - return cb(); - }); - }, - - closeOrderReference (cb) { - amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, - - executePayment (cb) { - async.waterfall([ - function findUser (cb2) { - User.findById(gift ? gift.uuid : undefined, cb2); - }, - function executeAmazonPayment (member, cb2) { - let data = {user, paymentMethod: 'Amazon Payments'}; - let method = 'buyGems'; - - if (gift) { - if (gift.type === 'subscription') method = 'createSubscription'; - gift.member = member; - data.gift = gift; - data.paymentMethod = 'Gift'; - } - - payments[method](data, cb2); - }, - ], cb); - }, - }, function result (err) { - if (err) return next(err); - - res.sendStatus(200); - }); -}; - -api.subscribe = function subscribe (req, res, next) { - if (!req.body || !req.body.billingAgreementId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - let billingAgreementId = req.body.billingAgreementId; - let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; - let coupon = req.body.coupon; - let user = res.locals.user; - - if (!sub) { - return res.status(400).json({err: 'Subscription plan not found.'}); - } - - async.series({ - applyDiscount (cb) { - if (!sub.discount) return cb(); - if (!coupon) return cb(new Error('Please provide a coupon code for this plan.')); - mongoose.model('Coupon').findOne({_id: cc.validate(coupon), event: sub.key}, function couponResult (err) { - if (err) return cb(err); - if (!coupon) return cb(new Error('Coupon code not found.')); - cb(); - }); - }, - - setBillingAgreementDetails (cb) { - amzPayment.offAmazonPayments.setBillingAgreementDetails({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: 'HabitRPG Subscription', - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: shared.uuid(), - StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription', - }, - }, - }, cb); - }, - - confirmBillingAgreement (cb) { - amzPayment.offAmazonPayments.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, - - authorizeOnBillingAgreement (cb) { - amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - AuthorizationReferenceId: shared.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: 'USD', - Amount: sub.price, - }, - SellerAuthorizationNote: 'HabitRPG Subscription Payment', - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: 'HabitRPG Subscription Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG', - }, - }, function billingAgreementResult (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successful.')); - } - - return cb(); - }); - }, - - createSubscription (cb) { - payments.createSubscription({ - user, - customerId: billingAgreementId, - paymentMethod: 'Amazon Payments', - sub, - }, cb); - }, - }, function subscribeResult (err) { - if (err) return next(err); - - res.sendStatus(200); - }); -}; - -api.subscribeCancel = function subscribeCancel (req, res, next) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.status(401).json({err: 'User does not have a plan subscription'}); - - let billingAgreementId = user.purchased.plan.customerId; - - async.series({ - closeBillingAgreement (cb) { - amzPayment.offAmazonPayments.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, - - cancelSubscription (cb) { - let data = { - user, - // Date of next bill - nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), - paymentMethod: 'Amazon Payments', - }; - - payments.cancelSubscription(data, cb); - }, - }, function subscribeCancelResult (err) { - if (err) return next(err); // don't json this, let toString() handle errors - - if (req.query.noRedirect) { - res.sendStatus(200); - } else { - res.redirect('/'); - } - - user = null; - }); -}; - -module.exports = api; diff --git a/website/src/controllers/api-v3/payments/iap.js b/website/src/controllers/api-v3/payments/iap.js deleted file mode 100644 index 94cf21fcca..0000000000 --- a/website/src/controllers/api-v3/payments/iap.js +++ /dev/null @@ -1,158 +0,0 @@ -import { - iap, - inAppPurchase, } -from 'in-app-purchase'; -import payments from './index'; -import nconf from 'nconf'; - -inAppPurchase.config({ - // this is the path to the directory containing iap-sanbox/iap-live files - googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), -}); - -// Validation ERROR Codes -const INVALID_PAYLOAD = 6778001; -/* const CONNECTION_FAILED = 6778002; -const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? - -let api = {}; - -api.androidVerify = function androidVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; - - iap.setup(function googleSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - /* - google receipt must be provided as an object - { - "data": "{stringified data object}", - "signature": "signature from google" - } - */ - let testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature, - }; - - // iap is ready - iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { - if (err) { - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, - }; - - return res.json(resObj); - } - - if (iap.isValidated(googleRes)) { - let resObj = { - ok: true, - data: googleRes, - }; - - payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); - - return res.json(resObj); - } - }); - }); -}; - -exports.iosVerify = function iosVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; - - iap.setup(function iosSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { - if (err) { - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, - }; - - return res.json(resObj); - } - - if (iap.isValidated(appleRes)) { - let purchaseDataList = iap.getPurchaseData(appleRes); - if (purchaseDataList.length > 0) { - let correctReceipt = true; - for (let index of purchaseDataList) { - switch (purchaseDataList[index].productId) { - case 'com.habitrpg.ios.Habitica.4gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); - break; - case 'com.habitrpg.ios.Habitica.8gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); - break; - case 'com.habitrpg.ios.Habitica.20gems': - case 'com.habitrpg.ios.Habitica.21gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); - break; - case 'com.habitrpg.ios.Habitica.42gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); - break; - default: - correctReceipt = false; - } - } - if (correctReceipt) { - let resObj = { - ok: true, - data: appleRes, - }; - // yay good! - return res.json(resObj); - } - } - // wrong receipt content - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: 'Incorrect receipt content', - }, - }; - return res.json(resObj); - } - // invalid receipt - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: 'Invalid receipt', - }, - }; - - return res.json(resObj); - }); - }); -}; - -module.exports = api; diff --git a/website/src/controllers/api-v3/payments/index.js b/website/src/controllers/api-v3/payments/index.js deleted file mode 100644 index f6e0a8ebe3..0000000000 --- a/website/src/controllers/api-v3/payments/index.js +++ /dev/null @@ -1,232 +0,0 @@ -import _ from 'lodash' ; -import analytics from '../../../libs/api-v3/analyticsService'; -import async from 'async'; -import cc from 'coupon-code'; -import { - getUserInfo, - sendTxn as txnEmail, -} from '../../../libs/api-v3/email'; -import members from '../members'; -import moment from 'moment'; -import mongoose from 'mongoose'; -import nconf from 'nconf'; -import pushNotify from '../../../libs/api-v3/pushNotifications'; -import shared from '../../../../../common' ; - -import amazon from './amazon'; -import iap from './iap'; -import paypal from './paypal'; -import stripe from './stripe'; - -const IS_PROD = nconf.get('NODE_ENV') === 'production'; - -let api = {}; - -function revealMysteryItems (user) { - _.each(shared.content.gear.flat, function findMysteryItems (item) { - if ( - item.klass === 'mystery' && - moment().isAfter(shared.content.mystery[item.mystery].start) && - moment().isBefore(shared.content.mystery[item.mystery].end) && - !user.items.gear.owned[item.key] && - user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 - ) { - user.purchased.plan.mysteryItems.push(item.key); - } - }); -} - -api.createSubscription = function createSubscription (data, cb) { - let recipient = data.gift ? data.gift.member : data.user; - let plan = recipient.purchased.plan; - let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; - let months = Number(block.months); - - if (data.gift) { - if (plan.customerId && !plan.dateTerminated) { // User has active plan - plan.extraMonths += months; - } else { - plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); - if (!plan.dateUpdated) plan.dateUpdated = new Date(); - } - if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId - } else { - _(plan).merge({ // override with these values - planId: block.key, - customerId: data.customerId, - dateUpdated: new Date(), - gemsBought: 0, - paymentMethod: data.paymentMethod, - extraMonths: Number(plan.extraMonths) + - Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0), - dateTerminated: null, - // Specify a lastBillingDate just for Amazon Payments - // Resetted every time the subscription restarts - lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, - }).defaults({ // allow non-override if a plan was previously used - dateCreated: new Date(), - mysteryItems: [], - }).value(); - } - - // Block sub perks - let perks = Math.floor(months / 3); - if (perks) { - plan.consecutive.offset += months; - plan.consecutive.gemCapExtra += perks * 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; - plan.consecutive.trinkets += perks; - } - revealMysteryItems(recipient); - if (IS_PROD) { - if (!data.gift) txnEmail(data.user, 'subscription-begins'); - - let analyticsData = { - uuid: data.user._id, - itemPurchased: 'Subscription', - sku: `${data.paymentMethod.toLowerCase()}-subscription`, - purchaseType: 'subscribe', - paymentMethod: data.paymentMethod, - quantity: 1, - gift: Boolean(data.gift), - purchaseValue: block.price, - }; - analytics.trackPurchase(analyticsData); - } - data.user.purchased.txnCount++; - if (data.gift) { - members.sendMessage(data.user, data.gift.member, data.gift); - - let byUserName = getUserInfo(data.user, ['name']).name; - - if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { - txnEmail(data.gift.member, 'gifted-subscription', [ - {name: 'GIFTER', content: byUserName}, - {name: 'X_MONTHS_SUBSCRIPTION', content: months}, - ]); - } - - if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); - } - } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); -}; - -/** - * Sets their subscription to be cancelled later - */ -api.cancelSubscription = function cancelSubscription (data, cb) { - let plan = data.user.purchased.plan; - let now = moment(); - let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; - - plan.dateTerminated = - moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) - .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... - .toDate(); - plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated - - data.user.save(cb); - txnEmail(data.user, 'cancel-subscription'); - let analyticsData = { - uuid: data.user._id, - gaCategory: 'commerce', - gaLabel: data.paymentMethod, - paymentMethod: data.paymentMethod, - }; - analytics.track('unsubscribe', analyticsData); -}; - -api.buyGems = function buyGems (data, cb) { - let amt = data.amount || 5; - amt = data.gift ? data.gift.gems.amount / 4 : amt; - (data.gift ? data.gift.member : data.user).balance += amt; - data.user.purchased.txnCount++; - if (IS_PROD) { - if (!data.gift) txnEmail(data.user, 'donation'); - - let analyticsData = { - uuid: data.user._id, - itemPurchased: 'Gems', - sku: `${data.paymentMethod.toLowerCase()}-checkout`, - purchaseType: 'checkout', - paymentMethod: data.paymentMethod, - quantity: 1, - gift: Boolean(data.gift), - purchaseValue: amt, - }; - analytics.trackPurchase(analyticsData); - } - - if (data.gift) { - let byUsername = getUserInfo(data.user, ['name']).name; - let gemAmount = data.gift.gems.amount || 20; - - members.sendMessage(data.user, data.gift.member, data.gift); - if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { - txnEmail(data.gift.member, 'gifted-gems', [ - {name: 'GIFTER', content: byUsername}, - {name: 'X_GEMS_GIFTED', content: gemAmount}, - ]); - } - - if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); - } - } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); -}; - -api.validCoupon = function validCoupon (req, res, next) { - mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { - if (err) return next(err); - if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); - return res.sendStatus(200); - }); -}; - -api.stripeCheckout = stripe.checkout; -api.stripeSubscribeCancel = stripe.subscribeCancel; -api.stripeSubscribeEdit = stripe.subscribeEdit; - -api.paypalSubscribe = paypal.createBillingAgreement; -api.paypalSubscribeSuccess = paypal.executeBillingAgreement; -api.paypalSubscribeCancel = paypal.cancelSubscription; -api.paypalCheckout = paypal.createPayment; -api.paypalCheckoutSuccess = paypal.executePayment; -api.paypalIPN = paypal.ipn; - -api.amazonVerifyAccessToken = amazon.verifyAccessToken; -api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -api.amazonCheckout = amazon.checkout; -api.amazonSubscribe = amazon.subscribe; -api.amazonSubscribeCancel = amazon.subscribeCancel; - -api.iapAndroidVerify = iap.androidVerify; -api.iapIosVerify = iap.iosVerify; - -module.exports = api; diff --git a/website/src/controllers/api-v3/payments/stripe.js b/website/src/controllers/api-v3/payments/stripe.js deleted file mode 100644 index 5582d33ca1..0000000000 --- a/website/src/controllers/api-v3/payments/stripe.js +++ /dev/null @@ -1,135 +0,0 @@ -import nconf from 'nconf'; -import stripeModule from 'stripe'; -import async from 'async'; -import payments from './index'; -import { model as User } from '../../models/user'; -import shared from '../../../../../common'; -import mongoose from 'mongoose'; -import cc from 'coupon-code'; - -const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); - -let api = {}; -/* - Setup Stripe response when posting payment - */ -api.checkout = function checkout (req, res) { - let token = req.body.id; - let user = res.locals.user; - let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; - - async.waterfall([ - function stripeCharge (cb) { - if (sub) { - async.waterfall([ - function handleCoupon (cb2) { - if (!sub.discount) return cb2(null, null); - if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); - mongoose.model('Coupon').findOne({_id: cc.validate(req.query.coupon), event: sub.key}, cb2); - }, - function createCustomer (coupon, cb2) { - if (sub.discount && !coupon) return cb2('Invalid coupon code.'); - let customer = { - email: req.body.email, - metadata: {uuid: user._id}, - card: token, - plan: sub.key, - }; - stripe.customers.create(customer, cb2); - }, - ], cb); - } else { - let amount; - if (!gift) { - amount = '500'; - } else if (gift.type === 'subscription') { - amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; - } else { - amount = `${gift.gems.amount / 4 * 100}`; - } - stripe.charges.create({ - amount, - currency: 'usd', - card: token, - }, cb); - } - }, - function saveUserData (response, cb) { - if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', sub}, cb); - async.waterfall([ - function findUser (cb2) { - User.findById(gift ? gift.uuid : undefined, cb2); - }, - function prepData (member, cb2) { - let data = {user, customerId: response.id, paymentMethod: 'Stripe', gift}; - let method = 'buyGems'; - if (gift) { - gift.member = member; - if (gift.type === 'subscription') method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - payments[method](data, cb2); - }, - ], cb); - }, - ], function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.sendStatus(200); - user = token = null; - }); -}; - -api.subscribeCancel = function subscribeCancel (req, res) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) { - return res.status(401).json({err: 'User does not have a plan subscription'}); - } - - async.auto({ - getCustomer: function getCustomer (cb) { - stripe.customers.retrieve(user.purchased.plan.customerId, cb); - }, - deleteCustomer: ['getCustomer', function deleteCustomer (cb) { - stripe.customers.del(user.purchased.plan.customerId, cb); - }], - cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { - let data = { - user, - nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds - paymentMethod: 'Stripe', - }; - payments.cancelSubscription(data, cb); - }], - }, function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.redirect('/'); - user = null; - }); -}; - -api.subscribeEdit = function subscribeEdit (req, res) { - let token = req.body.id; - let user = res.locals.user; - let userId = user.purchased.plan.customerId; - let subscriptionId; - - async.waterfall([ - function listSubscriptions (cb) { - stripe.customers.listSubscriptions(userId, cb); - }, - function updateSubscription (response, cb) { - subscriptionId = response.data[0].id; - stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); - }, - function saveUser (response, cb) { - user.save(cb); - }, - ], function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.sendStatus(200); - token = user = userId = subscriptionId; - }); -}; - -module.exports = api; diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 8c01663c10..63b589f6fc 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,112 +1,128 @@ -var amazonPayments = require('amazon-payments'); -var mongoose = require('mongoose'); -var moment = require('moment'); -var nconf = require('nconf'); -var async = require('async'); -var User = require('mongoose').model('User'); -var shared = require('../../../../common'); -var payments = require('./index'); -var cc = require('coupon-code'); -var isProd = nconf.get('NODE_ENV') === 'production'; +import async from 'async'; +import cc from 'coupon-code'; +import mongoose from 'mongoose'; +import moment from 'moment'; +import payments from './index'; +import shared from '../../../../../common'; +import { model as User } from '../../../models/user'; +import { + NotFound, + NotAuthorized, + BadRequest, +} from '../../../libs/api-v3/errors'; +import amz from '../../../libs/api-v3/amazonPayments'; -var amzPayment = amazonPayments.connect({ - environment: amazonPayments.Environment[isProd ? 'Production' : 'Sandbox'], - sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), - mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), - mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), - clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID') -}); +let api = {}; -exports.verifyAccessToken = function(req, res, next){ - if(!req.body || !req.body['access_token']){ - return res.status(400).json({err: 'Access token not supplied.'}); - } - - amzPayment.api.getTokenInfo(req.body['access_token'], function(err, tokenInfo){ - if(err) return res.status(400).json({err:err}); - - res.sendStatus(200); - }); +/** + * @api {post} /api/v3/payments/amazon/verifyAccessToken verify access token + * @apiVersion 3.0.0 + * @apiName AmazonVerifyAccessToken + * @apiGroup Payments + * @apiParam {string} access_token the access token + * @apiSuccess {} empty + **/ +api.verifyAccessToken = { + method: 'POST', + url: '/payments/amazon/verifyAccessToken', + async handler (req, res) { + await amz.getTokenInfo(req.body.access_token) + .then(() => { + res.respond(200, {}); + }).catch( (error) => { + throw new BadRequest(error.body.error_description); + }); + }, }; -exports.createOrderReferenceId = function(req, res, next){ - if(!req.body || !req.body.billingAgreementId){ - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - amzPayment.offAmazonPayments.createOrderReferenceForId({ - Id: req.body.billingAgreementId, - IdType: 'BillingAgreement', - ConfirmNow: false - }, function(err, response){ - if(err) return next(err); - if(!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId){ - return next(new Error('Missing attributes in Amazon response.')); +/** + * @api {post} /api/v3/payments/amazon/createOrderReferenceId create order reference id + * @apiVersion 3.0.0 + * @apiName AmazonCreateOrderReferenceId + * @apiGroup Payments + * @apiParam {string} billingAgreementId billing agreement id + * @apiSuccess {object} object containing { orderReferenceId } + **/ +api.createOrderReferenceId = { + method: 'POST', + url: '/payments/amazon/createOrderReferenceId', + async handler (req, res) { + if (!req.body.billingAgreementId) { + throw new BadRequest(res.t('missingBillingAgreementId')); } - res.json({ - orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId + let response = await amz.createOrderReferenceId({ + Id: req.body.billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + }).then(() => { + res.respond(200, { + orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, + }); + }).catch(errStr => { + throw new BadRequest(res.t(errStr)); }); - }); + }, }; -exports.checkout = function(req, res, next){ - if(!req.body || !req.body.orderReferenceId){ +/* +api.checkout = function checkout (req, res, next) { + if (!req.body || !req.body.orderReferenceId) { return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); } - var gift = req.body.gift; - var user = res.locals.user; - var orderReferenceId = req.body.orderReferenceId; - var amount = 5; + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let amount = 5; - if(gift){ - if(gift.type === 'gems'){ - amount = gift.gems.amount/4; - }else if(gift.type === 'subscription'){ + if (gift) { + if (gift.type === 'gems') { + amount = gift.gems.amount / 4; + } else if (gift.type === 'subscription') { amount = shared.content.subscriptionBlocks[gift.subscription.key].price; } } async.series({ - setOrderReferenceDetails: function(cb){ + setOrderReferenceDetails (cb) { amzPayment.offAmazonPayments.setOrderReferenceDetails({ AmazonOrderReferenceId: orderReferenceId, OrderReferenceAttributes: { OrderTotal: { CurrencyCode: 'USD', - Amount: amount + Amount: amount, }, SellerNote: 'HabitRPG Payment', SellerOrderAttributes: { SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG' - } - } + StoreName: 'HabitRPG', + }, + }, }, cb); }, - confirmOrderReference: function(cb){ + confirmOrderReference (cb) { amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId + AmazonOrderReferenceId: orderReferenceId, }, cb); }, - authorize: function(cb){ + authorize (cb) { amzPayment.offAmazonPayments.authorize({ AmazonOrderReferenceId: orderReferenceId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { CurrencyCode: 'USD', - Amount: amount + Amount: amount, }, SellerAuthorizationNote: 'HabitRPG Payment', TransactionTimeout: 0, - CaptureNow: true - }, function(err, res){ - if(err) return cb(err); + CaptureNow: true, + }, function checkAuthorizationStatus (err) { + if (err) return cb(err); - if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { return cb(new Error('The payment was not successfull.')); } @@ -114,64 +130,65 @@ exports.checkout = function(req, res, next){ }); }, - closeOrderReference: function(cb){ + closeOrderReference (cb) { amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId + AmazonOrderReferenceId: orderReferenceId, }, cb); }, - executePayment: function(cb){ + executePayment (cb) { async.waterfall([ - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }, - function(member, cb2){ - var data = {user:user, paymentMethod:'Amazon Payments'}; - var method = 'buyGems'; + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function executeAmazonPayment (member, cb2) { + let data = {user, paymentMethod: 'Amazon Payments'}; + let method = 'buyGems'; - if (gift){ - if (gift.type == 'subscription') method = 'createSubscription'; + if (gift) { + if (gift.type === 'subscription') method = 'createSubscription'; gift.member = member; data.gift = gift; data.paymentMethod = 'Gift'; } payments[method](data, cb2); - } + }, ], cb); - } - }, function(err, results){ - if(err) return next(err); + }, + }, function result (err) { + if (err) return next(err); res.sendStatus(200); }); - }; -exports.subscribe = function(req, res, next){ - if(!req.body || !req.body['billingAgreementId']){ +api.subscribe = function subscribe (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); } - var billingAgreementId = req.body.billingAgreementId; - var sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; - var coupon = req.body.coupon; - var user = res.locals.user; + let billingAgreementId = req.body.billingAgreementId; + let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; + let coupon = req.body.coupon; + let user = res.locals.user; - if(!sub){ + if (!sub) { return res.status(400).json({err: 'Subscription plan not found.'}); } async.series({ - applyDiscount: function(cb){ + applyDiscount (cb) { if (!sub.discount) return cb(); if (!coupon) return cb(new Error('Please provide a coupon code for this plan.')); - mongoose.model('Coupon').findOne({_id:cc.validate(coupon), event:sub.key}, function(err, coupon){ - if(err) return cb(err); - if(!coupon) return cb(new Error('Coupon code not found.')); + mongoose.model('Coupon').findOne({_id: cc.validate(coupon), event: sub.key}, function couponResult (err) { + if (err) return cb(err); + if (!coupon) return cb(new Error('Coupon code not found.')); cb(); }); }, - setBillingAgreementDetails: function(cb){ + setBillingAgreementDetails (cb) { amzPayment.offAmazonPayments.setBillingAgreementDetails({ AmazonBillingAgreementId: billingAgreementId, BillingAgreementAttributes: { @@ -179,25 +196,25 @@ exports.subscribe = function(req, res, next){ SellerBillingAgreementAttributes: { SellerBillingAgreementId: shared.uuid(), StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription' - } - } + CustomInformation: 'HabitRPG Subscription', + }, + }, }, cb); }, - confirmBillingAgreement: function(cb){ + confirmBillingAgreement (cb) { amzPayment.offAmazonPayments.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId + AmazonBillingAgreementId: billingAgreementId, }, cb); }, - authorizeOnBillingAgreeement: function(cb){ + authorizeOnBillingAgreement (cb) { amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ AmazonBillingAgreementId: billingAgreementId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { CurrencyCode: 'USD', - Amount: sub.price + Amount: sub.price, }, SellerAuthorizationNote: 'HabitRPG Subscription Payment', TransactionTimeout: 0, @@ -205,67 +222,70 @@ exports.subscribe = function(req, res, next){ SellerNote: 'HabitRPG Subscription Payment', SellerOrderAttributes: { SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG' - } - }, function(err, res){ - if(err) return cb(err); + StoreName: 'HabitRPG', + }, + }, function billingAgreementResult (err) { + if (err) return cb(err); - if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ - return cb(new Error('The payment was not successfull.')); + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successful.')); } return cb(); }); }, - createSubscription: function(cb){ + createSubscription (cb) { payments.createSubscription({ - user: user, + user, customerId: billingAgreementId, paymentMethod: 'Amazon Payments', - sub: sub + sub, }, cb); - } - }, function(err, results){ - if(err) return next(err); + }, + }, function subscribeResult (err) { + if (err) return next(err); res.sendStatus(200); }); }; -exports.subscribeCancel = function(req, res, next){ - var user = res.locals.user; +api.subscribeCancel = function subscribeCancel (req, res, next) { + let user = res.locals.user; if (!user.purchased.plan.customerId) return res.status(401).json({err: 'User does not have a plan subscription'}); - var billingAgreementId = user.purchased.plan.customerId; + let billingAgreementId = user.purchased.plan.customerId; async.series({ - closeBillingAgreement: function(cb){ + closeBillingAgreement (cb) { amzPayment.offAmazonPayments.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId + AmazonBillingAgreementId: billingAgreementId, }, cb); }, - cancelSubscription: function(cb){ - var data = { - user: user, + cancelSubscription (cb) { + let data = { + user, // Date of next bill nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), - paymentMethod: 'Amazon Payments' + paymentMethod: 'Amazon Payments', }; payments.cancelSubscription(data, cb); - } - }, function(err, results){ + }, + }, function subscribeCancelResult (err) { if (err) return next(err); // don't json this, let toString() handle errors - if(req.query.noRedirect){ + if (req.query.noRedirect) { res.sendStatus(200); - }else{ + } else { res.redirect('/'); } user = null; }); }; +*/ + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/iap.js b/website/src/controllers/top-level/payments/iap.js index 829482ed67..5de66b0452 100644 --- a/website/src/controllers/top-level/payments/iap.js +++ b/website/src/controllers/top-level/payments/iap.js @@ -1,67 +1,66 @@ -var iap = require('in-app-purchase'); -var async = require('async'); -var payments = require('./index'); -var nconf = require('nconf'); +import iap from 'in-app-purchase'; +import whatThis from 'in-app-purchase'; +import payments from './index'; +import nconf from 'nconf'; -var inAppPurchase = require('in-app-purchase'); -inAppPurchase.config({ +iap.config({ // this is the path to the directory containing iap-sanbox/iap-live files - googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR') + googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), }); // Validation ERROR Codes -var INVALID_PAYLOAD = 6778001; -var CONNECTION_FAILED = 6778002; -var PURCHASE_EXPIRED = 6778003; +const INVALID_PAYLOAD = 6778001; +/* const CONNECTION_FAILED = 6778002; +const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? -exports.androidVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; +let api = {}; - iap.setup(function (error) { +/* +api.androidVerify = function androidVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function googleSetupResult (error) { if (error) { - var resObj = { + let resObj = { ok: false, - data: 'IAP Error' + data: 'IAP Error', }; return res.json(resObj); - } - /* - google receipt must be provided as an object - { - "data": "{stringified data object}", - "signature": "signature from google" - } - */ - var testObj = { + // google receipt must be provided as an object + // { + // "data": "{stringified data object}", + // "signature": "signature from google" + // } + let testObj = { data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature + signature: iapBody.transaction.signature, }; // iap is ready - iap.validate(iap.GOOGLE, testObj, function (err, googleRes) { + iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { if (err) { - var resObj = { + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: err.toString() - } + message: err.toString(), + }, }; return res.json(resObj); } if (iap.isValidated(googleRes)) { - var resObj = { + let resObj = { ok: true, - data: googleRes + data: googleRes, }; - payments.buyGems({user:user, paymentMethod:'IAP GooglePlay', amount: 5.25}); + payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); return res.json(resObj); } @@ -69,87 +68,89 @@ exports.androidVerify = function(req, res, next) { }); }; -exports.iosVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; +exports.iosVerify = function iosVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; - iap.setup(function (error) { + iap.setup(function iosSetupResult (error) { if (error) { - var resObj = { + let resObj = { ok: false, - data: 'IAP Error' + data: 'IAP Error', }; return res.json(resObj); - } - //iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) { + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { if (err) { - var resObj = { + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: err.toString() - } + message: err.toString(), + }, }; return res.json(resObj); } if (iap.isValidated(appleRes)) { - var purchaseDataList = iap.getPurchaseData(appleRes); + let purchaseDataList = iap.getPurchaseData(appleRes); if (purchaseDataList.length > 0) { - var correctReceipt = true; - for (var index in purchaseDataList) { + let correctReceipt = true; + for (let index of purchaseDataList) { switch (purchaseDataList[index].productId) { case 'com.habitrpg.ios.Habitica.4gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 1}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); break; case 'com.habitrpg.ios.Habitica.8gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 2}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); break; case 'com.habitrpg.ios.Habitica.20gems': case 'com.habitrpg.ios.Habitica.21gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 5.25}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); break; case 'com.habitrpg.ios.Habitica.42gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 10.5}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); break; default: correctReceipt = false; } } if (correctReceipt) { - var resObj = { + let resObj = { ok: true, - data: appleRes + data: appleRes, }; // yay good! return res.json(resObj); } } - //wrong receipt content - var resObj = { + // wrong receipt content + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: 'Incorrect receipt content' - } + message: 'Incorrect receipt content', + }, }; return res.json(resObj); } - //invalid receipt - var resObj = { + // invalid receipt + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: 'Invalid receipt' - } + message: 'Invalid receipt', + }, }; return res.json(resObj); }); }); }; +*/ + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/index.js b/website/src/controllers/top-level/payments/index.js index 1652e05b8d..5e413fd47c 100644 --- a/website/src/controllers/top-level/payments/index.js +++ b/website/src/controllers/top-level/payments/index.js @@ -1,207 +1,233 @@ -var _ = require('lodash'); -var shared = require('../../../../common'); -var nconf = require('nconf'); -var utils = require('./../../libs/api-v2/utils'); -var moment = require('moment'); -var isProduction = nconf.get("NODE_ENV") === "production"; -var stripe = require('./stripe'); -var paypal = require('./paypal'); -var amazon = require('./amazon'); -var members = require('../api-v2/members') -var async = require('async'); -var iap = require('./iap'); -var mongoose= require('mongoose'); -var cc = require('coupon-code'); -var pushNotify = require('./../api-v2/pushNotifications'); +import _ from 'lodash' ; +import analytics from '../../../libs/api-v3/analyticsService'; +import async from 'async'; +import cc from 'coupon-code'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../../libs/api-v3/email'; +import members from '../members'; +import moment from 'moment'; +import mongoose from 'mongoose'; +import nconf from 'nconf'; +import pushNotify from '../../../libs/api-v3/pushNotifications'; +import shared from '../../../../../common' ; -function revealMysteryItems(user) { - _.each(shared.content.gear.flat, function(item) { +import amazon from './amazon'; +import iap from './iap'; +import paypal from './paypal'; +import stripe from './stripe'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +function revealMysteryItems (user) { + _.each(shared.content.gear.flat, function findMysteryItems (item) { if ( item.klass === 'mystery' && moment().isAfter(shared.content.mystery[item.mystery].start) && moment().isBefore(shared.content.mystery[item.mystery].end) && !user.items.gear.owned[item.key] && - !~user.purchased.plan.mysteryItems.indexOf(item.key) + user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 ) { user.purchased.plan.mysteryItems.push(item.key); } }); } -exports.createSubscription = function(data, cb) { - var recipient = data.gift ? data.gift.member : data.user; - //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // TODO double-check, this should never be the case - var p = recipient.purchased.plan; - var block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; - var months = +block.months; +api.createSubscription = function createSubscription (data, cb) { + let recipient = data.gift ? data.gift.member : data.user; + let plan = recipient.purchased.plan; + let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; + let months = Number(block.months); if (data.gift) { - if (p.customerId && !p.dateTerminated) { // User has active plan - p.extraMonths += months; + if (plan.customerId && !plan.dateTerminated) { // User has active plan + plan.extraMonths += months; } else { - p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); - if (!p.dateUpdated) p.dateUpdated = new Date(); + plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); + if (!plan.dateUpdated) plan.dateUpdated = new Date(); } - if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId } else { - _(p).merge({ // override with these values + _(plan).merge({ // override with these values planId: block.key, customerId: data.customerId, dateUpdated: new Date(), gemsBought: 0, paymentMethod: data.paymentMethod, - extraMonths: +p.extraMonths - + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0), + extraMonths: Number(plan.extraMonths) + + Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0), dateTerminated: null, // Specify a lastBillingDate just for Amazon Payments // Resetted every time the subscription restarts - lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined + lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, }).defaults({ // allow non-override if a plan was previously used dateCreated: new Date(), - mysteryItems: [] + mysteryItems: [], }).value(); } // Block sub perks - var perks = Math.floor(months/3); + let perks = Math.floor(months / 3); if (perks) { - p.consecutive.offset += months; - p.consecutive.gemCapExtra += perks*5; - if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25; - p.consecutive.trinkets += perks; + plan.consecutive.offset += months; + plan.consecutive.gemCapExtra += perks * 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; + plan.consecutive.trinkets += perks; } revealMysteryItems(recipient); - if(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'subscription-begins'); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'subscription-begins'); - var analyticsData = { + let analyticsData = { uuid: data.user._id, itemPurchased: 'Subscription', - sku: data.paymentMethod.toLowerCase() + '-subscription', + sku: `${data.paymentMethod.toLowerCase()}-subscription`, purchaseType: 'subscribe', paymentMethod: data.paymentMethod, quantity: 1, - gift: !!data.gift, // coerced into a boolean - purchaseValue: block.price - } - utils.analytics.trackPurchase(analyticsData); + gift: Boolean(data.gift), + purchaseValue: block.price, + }; + analytics.trackPurchase(analyticsData); } data.user.purchased.txnCount++; - if (data.gift){ + if (data.gift) { members.sendMessage(data.user, data.gift.member, data.gift); - var byUserName = utils.getUserInfo(data.user, ['name']).name; + let byUserName = getUserInfo(data.user, ['name']).name; - if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ - utils.txnEmail(data.gift.member, 'gifted-subscription', [ + if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { + txnEmail(data.gift.member, 'gifted-subscription', [ {name: 'GIFTER', content: byUserName}, - {name: 'X_MONTHS_SUBSCRIPTION', content: months} + {name: 'X_MONTHS_SUBSCRIPTION', content: months}, ]); } - if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), months + " months - by "+ byUserName); + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); } } async.parallel([ - function(cb2){data.user.save(cb2)}, - function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, ], cb); -} +}; /** * Sets their subscription to be cancelled later */ -exports.cancelSubscription = function(data, cb) { - var p = data.user.purchased.plan, - now = moment(), - remaining = data.nextBill ? moment(data.nextBill).diff(new Date, 'days') : 30; +api.cancelSubscription = function cancelSubscription (data, cb) { + let plan = data.user.purchased.plan; + let now = moment(); + let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; - p.dateTerminated = - moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) + plan.dateTerminated = + moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. TODO: moment can't add months in fractions... + .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... .toDate(); - p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated data.user.save(cb); - utils.txnEmail(data.user, 'cancel-subscription'); - var analyticsData = { + txnEmail(data.user, 'cancel-subscription'); + let analyticsData = { uuid: data.user._id, gaCategory: 'commerce', gaLabel: data.paymentMethod, - paymentMethod: data.paymentMethod - } - utils.analytics.track('unsubscribe', analyticsData); -} + paymentMethod: data.paymentMethod, + }; + analytics.track('unsubscribe', analyticsData); +}; -exports.buyGems = function(data, cb) { - var amt = data.amount || 5; - amt = data.gift ? data.gift.gems.amount/4 : amt; +api.buyGems = function buyGems (data, cb) { + let amt = data.amount || 5; + amt = data.gift ? data.gift.gems.amount / 4 : amt; (data.gift ? data.gift.member : data.user).balance += amt; data.user.purchased.txnCount++; - if(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'donation'); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); - var analyticsData = { + let analyticsData = { uuid: data.user._id, itemPurchased: 'Gems', - sku: data.paymentMethod.toLowerCase() + '-checkout', + sku: `${data.paymentMethod.toLowerCase()}-checkout`, purchaseType: 'checkout', paymentMethod: data.paymentMethod, quantity: 1, - gift: !!data.gift, // coerced into a boolean - purchaseValue: amt - } - utils.analytics.trackPurchase(analyticsData); + gift: Boolean(data.gift), + purchaseValue: amt, + }; + analytics.trackPurchase(analyticsData); } - if (data.gift){ - var byUsername = utils.getUserInfo(data.user, ['name']).name; - var gemAmount = data.gift.gems.amount || 20; + if (data.gift) { + let byUsername = getUserInfo(data.user, ['name']).name; + let gemAmount = data.gift.gems.amount || 20; members.sendMessage(data.user, data.gift.member, data.gift); - if(data.gift.member.preferences.emailNotifications.giftedGems !== false){ - utils.txnEmail(data.gift.member, 'gifted-gems', [ + if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { + txnEmail(data.gift.member, 'gifted-gems', [ {name: 'GIFTER', content: byUsername}, - {name: 'X_GEMS_GIFTED', content: gemAmount} + {name: 'X_GEMS_GIFTED', content: gemAmount}, ]); } - if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), gemAmount + ' Gems - by '+byUsername); + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); } } async.parallel([ - function(cb2){data.user.save(cb2)}, - function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, ], cb); -} +}; -exports.validCoupon = function(req, res, next){ - mongoose.model('Coupon').findOne({_id:cc.validate(req.params.code), event:'google_6mo'}, function(err, coupon){ +api.validCoupon = function validCoupon (req, res, next) { + mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { if (err) return next(err); - if (!coupon) return res.status(401).json({err:"Invalid coupon code"}); + if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); return res.sendStatus(200); }); -} +}; -exports.stripeCheckout = stripe.checkout; -exports.stripeSubscribeCancel = stripe.subscribeCancel; -exports.stripeSubscribeEdit = stripe.subscribeEdit; +api.stripeCheckout = stripe.checkout; +api.stripeSubscribeCancel = stripe.subscribeCancel; +api.stripeSubscribeEdit = stripe.subscribeEdit; -exports.paypalSubscribe = paypal.createBillingAgreement; -exports.paypalSubscribeSuccess = paypal.executeBillingAgreement; -exports.paypalSubscribeCancel = paypal.cancelSubscription; -exports.paypalCheckout = paypal.createPayment; -exports.paypalCheckoutSuccess = paypal.executePayment; -exports.paypalIPN = paypal.ipn; +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; -exports.amazonVerifyAccessToken = amazon.verifyAccessToken; -exports.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -exports.amazonCheckout = amazon.checkout; -exports.amazonSubscribe = amazon.subscribe; -exports.amazonSubscribeCancel = amazon.subscribeCancel; +api.amazonVerifyAccessToken = amazon.verifyAccessToken; +api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +api.amazonCheckout = amazon.checkout; +api.amazonSubscribe = amazon.subscribe; +api.amazonSubscribeCancel = amazon.subscribeCancel; -exports.iapAndroidVerify = iap.androidVerify; -exports.iapIosVerify = iap.iosVerify; +api.iapAndroidVerify = iap.androidVerify; +api.iapIosVerify = iap.iosVerify; + +// module.exports = api; +module.exports = {}; // @TODO HEREHERE diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 766ee85139..046a6f52cc 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -5,10 +5,10 @@ var _ = require('lodash'); var url = require('url'); var User = require('mongoose').model('User'); var payments = require('./index'); -var logger = require('../../libs/api-v2/logging'); +var logger = require('../../../libs/api-v2/logging'); var ipn = require('paypal-ipn'); var paypal = require('paypal-rest-sdk'); -var shared = require('../../../../common'); +var shared = require('../../../../../common'); var mongoose = require('mongoose'); var cc = require('coupon-code'); @@ -31,6 +31,7 @@ var parseErr = function(res, err){ return res.status(400).json({err:error}); } +/* exports.createBillingAgreement = function(req,res,next){ var sub = shared.content.subscriptionBlocks[req.query.sub]; async.waterfall([ @@ -190,11 +191,13 @@ exports.cancelSubscription = function(req, res, next){ user = null; }); } +*/ /** * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their * recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution */ +/* exports.ipn = function(req, res, next) { console.log('IPN Called'); res.sendStatus(200); // Must respond to PayPal IPN request with an empty 200 first @@ -213,4 +216,4 @@ exports.ipn = function(req, res, next) { } }); }; - +*/ diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index 1a1085227c..765ccba8f6 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -1,123 +1,137 @@ -var nconf = require('nconf'); -var stripe = require('stripe')(nconf.get('STRIPE_API_KEY')); -var async = require('async'); -var payments = require('./index'); -var User = require('mongoose').model('User'); -var shared = require('../../../../common'); -var mongoose = require('mongoose'); -var cc = require('coupon-code'); +import nconf from 'nconf'; +import stripeModule from 'stripe'; +import async from 'async'; +import payments from './index'; +import { model as User } from '../../../models/user'; +import shared from '../../../../../common'; +import mongoose from 'mongoose'; +import cc from 'coupon-code'; +const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); + +let api = {}; /* Setup Stripe response when posting payment */ -exports.checkout = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - var sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; +/* +api.checkout = function checkout (req, res) { + let token = req.body.id; + let user = res.locals.user; + let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; async.waterfall([ - function(cb){ + function stripeCharge (cb) { if (sub) { async.waterfall([ - function(cb2){ + function handleCoupon (cb2) { if (!sub.discount) return cb2(null, null); if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); - mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb2); + mongoose.model('Coupon').findOne({_id: cc.validate(req.query.coupon), event: sub.key}, cb2); }, - function(coupon, cb2){ + function createCustomer (coupon, cb2) { if (sub.discount && !coupon) return cb2('Invalid coupon code.'); - var customer = { + let customer = { email: req.body.email, metadata: {uuid: user._id}, card: token, - plan: sub.key + plan: sub.key, }; stripe.customers.create(customer, cb2); - } + }, ], cb); } else { + let amount; + if (!gift) { + amount = '500'; + } else if (gift.type === 'subscription') { + amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; + } else { + amount = `${gift.gems.amount / 4 * 100}`; + } stripe.charges.create({ - amount: !gift ? '500' //"500" = $5 - : gift.type=='subscription' ? ''+shared.content.subscriptionBlocks[gift.subscription.key].price*100 - : ''+gift.gems.amount/4*100, + amount, currency: 'usd', - card: token + card: token, }, cb); } }, - function(response, cb) { - if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb); + function saveUserData (response, cb) { + if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', sub}, cb); async.waterfall([ - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }, - function(member, cb2){ - var data = {user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift}; - var method = 'buyGems'; + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function prepData (member, cb2) { + let data = {user, customerId: response.id, paymentMethod: 'Stripe', gift}; + let method = 'buyGems'; if (gift) { gift.member = member; - if (gift.type=='subscription') method = 'createSubscription'; + if (gift.type === 'subscription') method = 'createSubscription'; data.paymentMethod = 'Gift'; } payments[method](data, cb2); - } + }, ], cb); - } - ], function(err){ + }, + ], function handleResponse (err) { if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors res.sendStatus(200); user = token = null; }); }; -exports.subscribeCancel = function(req, res, next) { - var user = res.locals.user; - if (!user.purchased.plan.customerId) +api.subscribeCancel = function subscribeCancel (req, res) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) { return res.status(401).json({err: 'User does not have a plan subscription'}); + } async.auto({ - get_cus: function(cb){ + getCustomer: function getCustomer (cb) { stripe.customers.retrieve(user.purchased.plan.customerId, cb); }, - del_cus: ['get_cus', function(cb, results){ + deleteCustomer: ['getCustomer', function deleteCustomer (cb) { stripe.customers.del(user.purchased.plan.customerId, cb); }], - cancel_sub: ['get_cus', function(cb, results) { - var data = { - user: user, - nextBill: results.get_cus.subscription.current_period_end*1000, // timestamp is in seconds - paymentMethod: 'Stripe' + cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { + let data = { + user, + nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds + paymentMethod: 'Stripe', }; payments.cancelSubscription(data, cb); - }] - }, function(err, results){ + }], + }, function handleResponse (err) { if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors res.redirect('/'); user = null; }); }; -exports.subscribeEdit = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - var user_id = user.purchased.plan.customerId; - var sub_id; +api.subscribeEdit = function subscribeEdit (req, res) { + let token = req.body.id; + let user = res.locals.user; + let userId = user.purchased.plan.customerId; + let subscriptionId; async.waterfall([ - function(cb){ - stripe.customers.listSubscriptions(user_id, cb); + function listSubscriptions (cb) { + stripe.customers.listSubscriptions(userId, cb); }, - function(response, cb) { - sub_id = response.data[0].id; - console.warn(sub_id); - console.warn([user_id, sub_id, { card: token }]); - stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb); + function updateSubscription (response, cb) { + subscriptionId = response.data[0].id; + stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); }, - function(response, cb) { + function saveUser (response, cb) { user.save(cb); - } - ], function(err, saved){ + }, + ], function handleResponse (err) { if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors res.sendStatus(200); - token = user = user_id = sub_id; + token = user = userId = subscriptionId; }); }; +*/ + +module.exports = api; diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js new file mode 100644 index 0000000000..9cff7cc763 --- /dev/null +++ b/website/src/libs/api-v3/amazonPayments.js @@ -0,0 +1,38 @@ +import amazonPayments from 'amazon-payments'; +import nconf from 'nconf'; +import Q from 'q'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +let amzPayment = amazonPayments.connect({ + environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], + sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), + mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), + mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), + clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), +}); + +api.getTokenInfo = (token) => { + return new Promise((resolve, reject) => { + amzPayment.api.getTokenInfo(token, (err, tokenInfo) => { + if (err) return reject(err); + return resolve(tokenInfo); + }); + }); +}; + +api.createOrderReferenceId = (inputSet) => { + return new Promise((resolve, reject) => { + amzPayment.offAmazonPayments.createOrderReferenceForId(inputSet, (err, response) => { + if (err) return reject(err); + if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { + return reject('missingAttributesFromAmazon'); + } + return resolve(response); + }); + }); +}; + +module.exports = api; From 33c0cdd569961155f45583b21cdb5037d65ddcc4 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 15 Apr 2016 08:15:09 -0500 Subject: [PATCH 679/976] chore(lint): lint files in preparation for eslint v2 --- common/index.js | 2 +- common/script/fns/autoAllocate.js | 24 ++++++++----- common/script/ops/equip.js | 11 +++--- .../user/tasks/POST-clear-completed.test.js | 4 +-- .../DELETE-challenges_challengeId.test.js | 2 +- .../challenges/GET-challenges_user.test.js | 8 ++--- .../challenges/POST-challenges.test.js | 34 +++++++++---------- .../integration/coupons/GET-coupons.test.js | 4 +-- .../coupons/POST-coupons_enter_code.test.js | 4 +-- .../POST-coupons_generate_event.test.js | 8 ++--- .../POST-coupons_validate_code.test.js | 4 +-- .../GET-export_avatar-memberId.html.test.js | 2 +- .../dataexport/GET-export_history.csv.test.js | 2 +- .../GET-export_userdata.json.test.js | 2 +- .../GET-export_userdata.xml.test.js | 2 +- .../groups/GET-groups_groupId_invites.test.js | 8 ++--- .../groups/GET-groups_groupId_members.test.js | 8 ++--- .../hall/GET-hall_heroes_heroId.test.js | 2 +- .../integration/hall/GET-hall_patrons.test.js | 2 +- .../hall/PUT-hall_heores_heroId.test.js | 2 +- .../members/GET-members_id.test.js | 2 +- .../POST-tasks_id_score_direction.test.js | 18 +++++----- .../user/POST-user_allocate.test.js | 6 ++-- .../user/POST-user_allocate_now.test.js | 2 +- .../v3/integration/user/POST-user_buy.test.js | 4 +-- .../user/POST-user_buy_mystery_set.test.js | 2 +- .../user/POST-user_buy_quest.test.js | 2 +- .../user/POST-user_buy_special_spell.test.js | 2 +- .../user/POST-user_change-class.test.js | 2 +- .../user/POST-user_class_cast_spellId.test.js | 12 +++---- .../user/POST-user_disable-classes.test.js | 2 +- .../user/POST-user_equip_type_key.test.js | 4 +-- .../user/POST-user_feed_pet_food.test.js | 2 +- ...POST-user_hatch_egg_hatchingPotion.test.js | 2 +- .../user/POST-user_open_mystery_item.test.js | 2 +- .../user/POST-user_purchase.test.js | 2 +- .../integration/user/POST-user_sleep.test.js | 4 +-- test/api/v3/integration/user/PUT-user.test.js | 2 +- website/src/controllers/api-v2/coupon.js | 2 +- website/src/controllers/api-v2/dataexport.js | 2 +- website/src/controllers/api-v3/chat.js | 6 ++-- website/src/controllers/api-v3/coupon.js | 2 +- website/src/controllers/api-v3/user.js | 2 +- .../src/controllers/top-level/dataexport.js | 6 ++-- website/src/controllers/top-level/pages.js | 8 ++--- website/src/index.js | 1 + website/src/models/group.js | 24 ++++++++----- website/src/models/user.js | 2 +- 48 files changed, 140 insertions(+), 122 deletions(-) diff --git a/common/index.js b/common/index.js index 6aca241e03..04189fa8ab 100644 --- a/common/index.js +++ b/common/index.js @@ -2,7 +2,7 @@ let pathToCommon; -if (process.env.NODE_ENV === 'production') { +if (process.env.NODE_ENV === 'production') { // eslint-disable-line no-process-env pathToCommon = './transpiled-babel/index'; } else { pathToCommon = './script/index'; diff --git a/common/script/fns/autoAllocate.js b/common/script/fns/autoAllocate.js index 3d680d282d..85fe01b78d 100644 --- a/common/script/fns/autoAllocate.js +++ b/common/script/fns/autoAllocate.js @@ -11,26 +11,31 @@ function getStatToAllocate (user) { let suggested; switch (user.preferences.allocationMode) { - case 'flat': + case 'flat': { let stats = _.pick(user.stats, splitWhitespace('con str per int')); return _.invert(stats)[_.min(stats)]; - case 'classbased': + } + case 'classbased': { let lvlDiv7 = user.stats.lvl / 7; let ideal = [lvlDiv7 * 3, lvlDiv7 * 2, lvlDiv7, lvlDiv7]; let preference; switch (user.stats.class) { - case 'wizard': + case 'wizard': { preference = ['int', 'per', 'con', 'str']; break; - case 'rogue': + } + case 'rogue': { preference = ['per', 'str', 'int', 'con']; break; - case 'healer': + } + case 'healer': { preference = ['con', 'int', 'str', 'per']; break; - default: + } + default: { preference = ['str', 'con', 'per', 'int']; + } } let diff = [ @@ -45,7 +50,8 @@ function getStatToAllocate (user) { }); return suggested !== -1 ? preference[suggested] : 'str'; - case 'taskbased': + } + case 'taskbased': { suggested = _.invert(user.stats.training)[_.max(user.stats.training)]; let training = user.stats.training; @@ -55,8 +61,10 @@ function getStatToAllocate (user) { training.per = 0; return suggested || 'str'; - default: + } + default: { return 'str'; + } } } diff --git a/common/script/ops/equip.js b/common/script/ops/equip.js index 94a40331d2..ddef7c5483 100644 --- a/common/script/ops/equip.js +++ b/common/script/ops/equip.js @@ -21,24 +21,24 @@ module.exports = function equip (user, req = {}) { let message; switch (type) { - case 'mount': + case 'mount': { if (!user.items.mounts[key]) { throw new NotFound(i18n.t('mountNotOwned', req.language)); } user.items.currentMount = user.items.currentMount === key ? '' : key; break; - - case 'pet': + } + case 'pet': { if (!user.items.pets[key]) { throw new NotFound(i18n.t('petNotOwned', req.language)); } user.items.currentPet = user.items.currentPet === key ? '' : key; break; - + } case 'costume': - case 'equipped': + case 'equipped': { if (!user.items.gear.owned[key]) { throw new NotFound(i18n.t('gearNotOwned', req.language)); } @@ -55,6 +55,7 @@ module.exports = function equip (user, req = {}) { message = handleTwoHanded(user, item, type, req); } break; + } } let res = { diff --git a/test/api/v2/user/tasks/POST-clear-completed.test.js b/test/api/v2/user/tasks/POST-clear-completed.test.js index be7f39a203..40e6cbd5cf 100644 --- a/test/api/v2/user/tasks/POST-clear-completed.test.js +++ b/test/api/v2/user/tasks/POST-clear-completed.test.js @@ -19,8 +19,8 @@ describe('POST /user/tasks/clear-completed', () => { await user.post(`/user/tasks/${toComplete._id}/up`); - let todos = await user.get(`/user/tasks?type=todo`); - let uncomplete = await user.post(`/user/tasks/clear-completed`); + let todos = await user.get('/user/tasks?type=todo'); + let uncomplete = await user.post('/user/tasks/clear-completed'); expect(todos.length).to.equal(uncomplete.length + 1); }); }); diff --git a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js index 44ff6a8b58..45dc91758a 100644 --- a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js @@ -11,7 +11,7 @@ import { v4 as generateUUID } from 'uuid'; describe('DELETE /challenges/:challengeId', () => { it('returns error when challengeId is not a valid UUID', async () => { let user = await generateUser(); - await expect(user.del(`/challenges/test`)).to.eventually.be.rejected.and.eql({ + await expect(user.del('/challenges/test')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js index 21ae0aa96b..cf8ed3b667 100644 --- a/test/api/v3/integration/challenges/GET-challenges_user.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js @@ -29,7 +29,7 @@ describe('GET challenges/user', () => { it('should return challenges user has joined', async () => { await nonMember.post(`/challenges/${challenge._id}/join`); - let challenges = await nonMember.get(`/challenges/user`); + let challenges = await nonMember.get('/challenges/user'); let foundChallenge = _.find(challenges, { _id: challenge._id }); expect(foundChallenge).to.exist; @@ -46,7 +46,7 @@ describe('GET challenges/user', () => { }); it('should return challenges user has created', async () => { - let challenges = await user.get(`/challenges/user`); + let challenges = await user.get('/challenges/user'); let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; @@ -75,7 +75,7 @@ describe('GET challenges/user', () => { }); it('should return challenges in user\'s group', async () => { - let challenges = await member.get(`/challenges/user`); + let challenges = await member.get('/challenges/user'); let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; @@ -114,7 +114,7 @@ describe('GET challenges/user', () => { let privateChallenge = await generateChallenge(groupLeader, group); - let challenges = await nonMember.get(`/challenges/user`); + let challenges = await nonMember.get('/challenges/user'); let foundChallenge = _.find(challenges, { _id: privateChallenge._id }); expect(foundChallenge).to.not.exist; diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js index 2f4878d91a..1b80266ab7 100644 --- a/test/api/v3/integration/challenges/POST-challenges.test.js +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -19,7 +19,7 @@ describe('POST /challenges', () => { it('returns error when groupId is not for a valid group', async () => { let user = await generateUser(); - await expect(user.post(`/challenges`, { + await expect(user.post('/challenges', { group: generateUUID(), })).to.eventually.be.rejected.and.eql({ code: 404, @@ -31,7 +31,7 @@ describe('POST /challenges', () => { it('returns error when creating a challenge in the tavern with no prize', async () => { let user = await generateUser(); - await expect(user.post(`/challenges`, { + await expect(user.post('/challenges', { group: 'habitrpg', prize: 0, })).to.eventually.be.rejected.and.eql({ @@ -50,7 +50,7 @@ describe('POST /challenges', () => { }, }); - await expect(user.post(`/challenges`, { + await expect(user.post('/challenges', { group: group._id, prize: 4, })).to.eventually.be.rejected.and.eql({ @@ -85,7 +85,7 @@ describe('POST /challenges', () => { }); it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { - await expect(groupMember.post(`/challenges`, { + await expect(groupMember.post('/challenges', { group: group._id, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -95,7 +95,7 @@ describe('POST /challenges', () => { }); it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { - await expect(groupMember.post(`/challenges`, { + await expect(groupMember.post('/challenges', { group: group._id, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -112,7 +112,7 @@ describe('POST /challenges', () => { group = populatedGroup.group; groupMember = populatedGroup.members[0]; - let chal = await groupMember.post(`/challenges`, { + let chal = await groupMember.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC', @@ -128,7 +128,7 @@ describe('POST /challenges', () => { let oldUserBalance = groupLeader.balance; let oldGroupBalance = group.balance; - await groupLeader.post(`/challenges`, { + await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC', @@ -140,7 +140,7 @@ describe('POST /challenges', () => { }); it('returns error when user and group can\'t pay prize', async () => { - await expect(groupLeader.post(`/challenges`, { + await expect(groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC', @@ -157,7 +157,7 @@ describe('POST /challenges', () => { let oldGroupBalance = group.balance; let prize = 4; - await groupLeader.post(`/challenges`, { + await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC', @@ -172,7 +172,7 @@ describe('POST /challenges', () => { let oldUserBalance = groupLeader.balance; let prize = 8; - await groupLeader.post(`/challenges`, { + await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC', @@ -188,7 +188,7 @@ describe('POST /challenges', () => { let prize = 8; await group.update({ balance: 0}); - await groupLeader.post(`/challenges`, { + await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC', @@ -202,7 +202,7 @@ describe('POST /challenges', () => { it('increases challenge count of group', async () => { let oldChallengeCount = group.challengeCount; - await groupLeader.post(`/challenges`, { + await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC', @@ -218,7 +218,7 @@ describe('POST /challenges', () => { }, }); - let challenge = await groupLeader.post(`/challenges`, { + let challenge = await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC', @@ -229,7 +229,7 @@ describe('POST /challenges', () => { }); it('doesn\'t set challenge as official if official flag is set by non-admin', async () => { - let challenge = await groupLeader.post(`/challenges`, { + let challenge = await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC', @@ -245,7 +245,7 @@ describe('POST /challenges', () => { let oldUserChallenges = groupLeader.challenges; let oldGroupBalance = group.balance; - await expect(groupLeader.post(`/challenges`, { + await expect(groupLeader.post('/challenges', { group: group._id, prize: 8, })).to.eventually.be.rejected.and.eql({ @@ -269,7 +269,7 @@ describe('POST /challenges', () => { let description = 'Test Description'; let prize = 4; - let challenge = await groupLeader.post(`/challenges`, { + let challenge = await groupLeader.post('/challenges', { group: group._id, name, shortName, @@ -296,7 +296,7 @@ describe('POST /challenges', () => { }); it('adds challenge to creator\'s challenges', async () => { - let challenge = await groupLeader.post(`/challenges`, { + let challenge = await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC', diff --git a/test/api/v3/integration/coupons/GET-coupons.test.js b/test/api/v3/integration/coupons/GET-coupons.test.js index 775ddfc3fe..6008d2b1df 100644 --- a/test/api/v3/integration/coupons/GET-coupons.test.js +++ b/test/api/v3/integration/coupons/GET-coupons.test.js @@ -16,7 +16,7 @@ describe('GET /coupons/', () => { it('returns an error if user has no sudo permission', async () => { await user.get('/user'); // needed so the request after this will authenticate with the correct cookie session - await expect(user.get(`/coupons`)).to.eventually.be.rejected.and.eql({ + await expect(user.get('/coupons')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('noSudoAccess'), @@ -29,7 +29,7 @@ describe('GET /coupons/', () => { }); let coupons = await user.post('/coupons/generate/wondercon?count=11'); - let res = await user.get(`/coupons`); + let res = await user.get('/coupons'); let splitRes = res.split('\n'); expect(splitRes.length).to.equal(13); diff --git a/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js b/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js index 687ada750b..e1f9db3583 100644 --- a/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js +++ b/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js @@ -20,7 +20,7 @@ describe('POST /coupons/enter/:code', () => { }); it('returns an error if code is missing', async () => { - await expect(user.post(`/coupons/enter`)).to.eventually.be.rejected.and.eql({ + await expect(user.post('/coupons/enter')).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: 'Not found.', @@ -28,7 +28,7 @@ describe('POST /coupons/enter/:code', () => { }); it('returns an error if code is invalid', async () => { - await expect(user.post(`/coupons/enter/notValid`)).to.eventually.be.rejected.and.eql({ + await expect(user.post('/coupons/enter/notValid')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidCoupon'), diff --git a/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js index 005a78923f..27bbc5c4f7 100644 --- a/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js +++ b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js @@ -22,7 +22,7 @@ describe('POST /coupons/generate/:event', () => { 'contributor.sudo': false, }); - await expect(user.post(`/coupons/generate/aaa`)).to.eventually.be.rejected.and.eql({ + await expect(user.post('/coupons/generate/aaa')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('noSudoAccess'), @@ -30,7 +30,7 @@ describe('POST /coupons/generate/:event', () => { }); it('returns an error if event is missing', async () => { - await expect(user.post(`/coupons/generate`)).to.eventually.be.rejected.and.eql({ + await expect(user.post('/coupons/generate')).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: 'Not found.', @@ -38,7 +38,7 @@ describe('POST /coupons/generate/:event', () => { }); it('returns an error if event is invalid', async () => { - await expect(user.post(`/coupons/generate/notValid?count=1`)).to.eventually.be.rejected.and.eql({ + await expect(user.post('/coupons/generate/notValid?count=1')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: 'Coupon validation failed', @@ -46,7 +46,7 @@ describe('POST /coupons/generate/:event', () => { }); it('returns an error if count is missing', async () => { - await expect(user.post(`/coupons/generate/notValid`)).to.eventually.be.rejected.and.eql({ + await expect(user.post('/coupons/generate/notValid')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), diff --git a/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js b/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js index 28de4ca460..9d433813ea 100644 --- a/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js +++ b/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js @@ -12,7 +12,7 @@ describe('POST /coupons/validate/:code', () => { }); it('returns an error if code is missing', async () => { - await expect(api.post(`/coupons/validate`)).to.eventually.be.rejected.and.eql({ + await expect(api.post('/coupons/validate')).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: 'Not found.', @@ -30,7 +30,7 @@ describe('POST /coupons/validate/:code', () => { }); it('returns false if coupon code is valid', async () => { - let res = await api.post(`/coupons/validate/notValid`); + let res = await api.post('/coupons/validate/notValid'); expect(res).to.eql({valid: false}); }); }); diff --git a/test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js b/test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js index e3bf32c1b6..a7b97390b6 100644 --- a/test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js +++ b/test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js @@ -12,7 +12,7 @@ describe('GET /export/avatar-:memberId.html', () => { }); it('validates req.params.memberId', async () => { - await expect(user.get(`/export/avatar-:memberId.html`)).to.eventually.be.rejected.and.eql({ + await expect(user.get('/export/avatar-:memberId.html')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), diff --git a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js index 8f1caeec7c..2dbc80c49d 100644 --- a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js +++ b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js @@ -34,7 +34,7 @@ describe('GET /export/history.csv', () => { return user.get(`/tasks/${task._id}`); })); - let res = await user.get(`/export/history.csv`); + let res = await user.get('/export/history.csv'); let splitRes = res.split('\n'); expect(splitRes[0]).to.equal('Task Name,Task ID,Task Type,Date,Value'); expect(splitRes[1]).to.equal(`habit 1,${tasks[0]._id},habit,${moment(tasks[0].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[0].history[0].value}`); diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js index 0701809889..d8152f1209 100644 --- a/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js +++ b/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js @@ -12,7 +12,7 @@ describe('GET /export/userdata.json', () => { {type: 'todo', text: 'todo 1'}, ]); - let res = await user.get(`/export/userdata.json`); + let res = await user.get('/export/userdata.json'); expect(res._id).to.equal(user._id); expect(res).to.contain.all.keys(['tasks', 'flags', 'tasksOrder', 'auth']); expect(res.auth.local).not.to.have.keys(['salt', 'hashed_password']); diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js index eb5650a478..61b84cd0ba 100644 --- a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js +++ b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js @@ -21,7 +21,7 @@ describe('GET /export/userdata.xml', () => { ]); - let response = await user.get(`/export/userdata.xml`); + let response = await user.get('/export/userdata.xml'); let {user: res} = await Q.npost(xml2js, 'parseString', [response, {explicitArray: false}]); expect(res._id).to.equal(user._id); diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js index d1604a4d84..eea2b5fa26 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js @@ -13,7 +13,7 @@ describe('GET /groups/:groupId/invites', () => { }); it('validates optional req.query.lastId to be an UUID', async () => { - await expect(user.get(`/groups/groupId/invites?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({ + await expect(user.get('/groups/groupId/invites?lastId=invalidUUID')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -42,7 +42,7 @@ describe('GET /groups/:groupId/invites', () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); let invited = await generateUser(); await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); - let res = await user.get(`/groups/party/invites`); + let res = await user.get('/groups/party/invites'); expect(res).to.be.an('array'); expect(res.length).to.equal(1); @@ -56,7 +56,7 @@ describe('GET /groups/:groupId/invites', () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); let invited = await generateUser(); await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); - let res = await user.get(`/groups/party/invites`); + let res = await user.get('/groups/party/invites'); expect(res[0]).to.have.all.keys(['_id', 'profile']); expect(res[0].profile).to.have.all.keys(['name']); }); @@ -70,7 +70,7 @@ describe('GET /groups/:groupId/invites', () => { let generatedInvites = await Promise.all(invitesToGenerate); await user.post(`/groups/${group._id}/invite`, {uuids: generatedInvites.map(invite => invite._id)}); - let res = await user.get(`/groups/party/invites`); + let res = await user.get('/groups/party/invites'); expect(res.length).to.equal(30); res.forEach(member => { expect(member).to.have.all.keys(['_id', 'profile']); diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js index 15193db012..1acaf140e8 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js @@ -13,7 +13,7 @@ describe('GET /groups/:groupId/members', () => { }); it('validates optional req.query.lastId to be an UUID', async () => { - await expect(user.get(`/groups/groupId/members?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({ + await expect(user.get('/groups/groupId/members?lastId=invalidUUID')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -40,7 +40,7 @@ describe('GET /groups/:groupId/members', () => { it('works when passing party as req.params.groupId', async () => { await generateGroup(user, {type: 'party', name: generateUUID()}); - let res = await user.get(`/groups/party/members`); + let res = await user.get('/groups/party/members'); expect(res).to.be.an('array'); expect(res.length).to.equal(1); expect(res[0]).to.eql({ @@ -51,7 +51,7 @@ describe('GET /groups/:groupId/members', () => { it('populates only some fields', async () => { await generateGroup(user, {type: 'party', name: generateUUID()}); - let res = await user.get(`/groups/party/members`); + let res = await user.get('/groups/party/members'); expect(res[0]).to.have.all.keys(['_id', 'profile']); expect(res[0].profile).to.have.all.keys(['name']); }); @@ -65,7 +65,7 @@ describe('GET /groups/:groupId/members', () => { } await Promise.all(usersToGenerate); - let res = await user.get(`/groups/party/members`); + let res = await user.get('/groups/party/members'); expect(res.length).to.equal(30); res.forEach(member => { expect(member).to.have.all.keys(['_id', 'profile']); diff --git a/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js index ba2bec1783..aef6990bad 100644 --- a/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js +++ b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js @@ -24,7 +24,7 @@ describe('GET /heroes/:heroId', () => { }); it('validates req.params.heroId', async () => { - await expect(user.get(`/hall/heroes/invalidUUID`)).to.eventually.be.rejected.and.eql({ + await expect(user.get('/hall/heroes/invalidUUID')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), diff --git a/test/api/v3/integration/hall/GET-hall_patrons.test.js b/test/api/v3/integration/hall/GET-hall_patrons.test.js index 17fa3fd58d..9599daef89 100644 --- a/test/api/v3/integration/hall/GET-hall_patrons.test.js +++ b/test/api/v3/integration/hall/GET-hall_patrons.test.js @@ -14,7 +14,7 @@ describe('GET /hall/patrons', () => { }); it('fails if req.query.page is not numeric', async () => { - await expect(user.get(`/hall/patrons?page=notNumber`)).to.eventually.be.rejected.and.eql({ + await expect(user.get('/hall/patrons?page=notNumber')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), diff --git a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js index 391d5cdeee..9948cbe3c7 100644 --- a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js +++ b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js @@ -24,7 +24,7 @@ describe('PUT /heroes/:heroId', () => { }); it('validates req.params.heroId', async () => { - await expect(user.put(`/hall/heroes/invalidUUID`)).to.eventually.be.rejected.and.eql({ + await expect(user.put('/hall/heroes/invalidUUID')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js index 9b4c734ea9..e68554fa42 100644 --- a/test/api/v3/integration/members/GET-members_id.test.js +++ b/test/api/v3/integration/members/GET-members_id.test.js @@ -12,7 +12,7 @@ describe('GET /members/:memberId', () => { }); it('validates req.params.memberId', async () => { - await expect(user.get(`/members/invalidUUID`)).to.eventually.be.rejected.and.eql({ + await expect(user.get('/members/invalidUUID')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index c1204a7341..86236fd110 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -94,7 +94,7 @@ describe('POST /tasks/:id/score/:direction', () => { beforeEach(async () => { await user.post(`/tasks/${todo._id}/score/up`); - updatedUser = await user.get(`/user`); + updatedUser = await user.get('/user'); }); it('increases user\'s mp', () => { @@ -115,7 +115,7 @@ describe('POST /tasks/:id/score/:direction', () => { beforeEach(async () => { await user.post(`/tasks/${todo._id}/score/down`); - updatedUser = await user.get(`/user`); + updatedUser = await user.get('/user'); }); it('decreases user\'s mp', () => { @@ -165,7 +165,7 @@ describe('POST /tasks/:id/score/:direction', () => { beforeEach(async () => { await user.post(`/tasks/${daily._id}/score/up`); - updatedUser = await user.get(`/user`); + updatedUser = await user.get('/user'); }); it('increases user\'s mp', () => { @@ -186,7 +186,7 @@ describe('POST /tasks/:id/score/:direction', () => { beforeEach(async () => { await user.post(`/tasks/${daily._id}/score/down`); - updatedUser = await user.get(`/user`); + updatedUser = await user.get('/user'); }); it('decreases user\'s mp', () => { @@ -238,28 +238,28 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s mp when direction is up', async () => { await user.post(`/tasks/${habit._id}/score/up`); - let updatedUser = await user.get(`/user`); + let updatedUser = await user.get('/user'); expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); it('decreases user\'s mp when direction is down', async () => { await user.post(`/tasks/${habit._id}/score/down`); - let updatedUser = await user.get(`/user`); + let updatedUser = await user.get('/user'); expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); it('increases user\'s exp when direction is up', async () => { await user.post(`/tasks/${habit._id}/score/up`); - let updatedUser = await user.get(`/user`); + let updatedUser = await user.get('/user'); expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); it('increases user\'s gold when direction is up', async () => { await user.post(`/tasks/${habit._id}/score/up`); - let updatedUser = await user.get(`/user`); + let updatedUser = await user.get('/user'); expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -276,7 +276,7 @@ describe('POST /tasks/:id/score/:direction', () => { }); await user.post(`/tasks/${reward._id}/score/up`); - updatedUser = await user.get(`/user`); + updatedUser = await user.get('/user'); }); it('purchases reward', () => { diff --git a/test/api/v3/integration/user/POST-user_allocate.test.js b/test/api/v3/integration/user/POST-user_allocate.test.js index 6f3e6347ac..d213318534 100644 --- a/test/api/v3/integration/user/POST-user_allocate.test.js +++ b/test/api/v3/integration/user/POST-user_allocate.test.js @@ -13,7 +13,7 @@ describe('POST /user/allocate', () => { // More tests in common code unit tests it('returns an error if an invalid attribute is supplied', async () => { - await expect(user.post(`/user/allocate?stat=invalid`)) + await expect(user.post('/user/allocate?stat=invalid')) .to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -22,7 +22,7 @@ describe('POST /user/allocate', () => { }); it('returns an error if the user doesn\'t have attribute points', async () => { - await expect(user.post(`/user/allocate`)) + await expect(user.post('/user/allocate')) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -32,7 +32,7 @@ describe('POST /user/allocate', () => { it('allocates attribute points', async () => { await user.update({'stats.points': 1}); - let res = await user.post(`/user/allocate?stat=con`); + let res = await user.post('/user/allocate?stat=con'); await user.sync(); expect(user.stats.con).to.equal(1); expect(user.stats.points).to.equal(0); diff --git a/test/api/v3/integration/user/POST-user_allocate_now.test.js b/test/api/v3/integration/user/POST-user_allocate_now.test.js index 4b649cd187..668c85610e 100644 --- a/test/api/v3/integration/user/POST-user_allocate_now.test.js +++ b/test/api/v3/integration/user/POST-user_allocate_now.test.js @@ -15,7 +15,7 @@ describe('POST /user/allocate-now', () => { 'preferences.allocationMode': 'flat', }); - let res = await user.post(`/user/allocate-now`); + let res = await user.post('/user/allocate-now'); await user.sync(); expect(res).to.eql({ diff --git a/test/api/v3/integration/user/POST-user_buy.test.js b/test/api/v3/integration/user/POST-user_buy.test.js index af730a2c98..bca65a2ab7 100644 --- a/test/api/v3/integration/user/POST-user_buy.test.js +++ b/test/api/v3/integration/user/POST-user_buy.test.js @@ -18,7 +18,7 @@ describe('POST /user/buy/:key', () => { // More tests in common code unit tests it('returns an error if the item is not found', async () => { - await expect(user.post(`/user/buy/notExisting`)) + await expect(user.post('/user/buy/notExisting')) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -28,7 +28,7 @@ describe('POST /user/buy/:key', () => { it('buys an item', async () => { let potion = content.potion; - let res = await user.post(`/user/buy/potion`); + let res = await user.post('/user/buy/potion'); await user.sync(); expect(res.data).to.eql({ diff --git a/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js b/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js index 0a6035a954..306f1e6677 100644 --- a/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js +++ b/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js @@ -15,7 +15,7 @@ describe('POST /user/buy-mystery-set/:key', () => { // More tests in common code unit tests it('returns an error if the mystery set is not found', async () => { - await expect(user.post(`/user/buy-mystery-set/notExisting`)) + await expect(user.post('/user/buy-mystery-set/notExisting')) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/user/POST-user_buy_quest.test.js b/test/api/v3/integration/user/POST-user_buy_quest.test.js index 068574d977..3330988537 100644 --- a/test/api/v3/integration/user/POST-user_buy_quest.test.js +++ b/test/api/v3/integration/user/POST-user_buy_quest.test.js @@ -16,7 +16,7 @@ describe('POST /user/buy-quest/:key', () => { // More tests in common code unit tests it('returns an error if the quest is not found', async () => { - await expect(user.post(`/user/buy-quest/notExisting`)) + await expect(user.post('/user/buy-quest/notExisting')) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/user/POST-user_buy_special_spell.test.js b/test/api/v3/integration/user/POST-user_buy_special_spell.test.js index 366a7f93c6..2ae16d1baf 100644 --- a/test/api/v3/integration/user/POST-user_buy_special_spell.test.js +++ b/test/api/v3/integration/user/POST-user_buy_special_spell.test.js @@ -16,7 +16,7 @@ describe('POST /user/buy-special-spell/:key', () => { // More tests in common code unit tests it('returns an error if the special spell is not found', async () => { - await expect(user.post(`/user/buy-special-spell/notExisting`)) + await expect(user.post('/user/buy-special-spell/notExisting')) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/user/POST-user_change-class.test.js b/test/api/v3/integration/user/POST-user_change-class.test.js index 3ec8fe468c..a849ca9382 100644 --- a/test/api/v3/integration/user/POST-user_change-class.test.js +++ b/test/api/v3/integration/user/POST-user_change-class.test.js @@ -15,7 +15,7 @@ describe('POST /user/change-class', () => { // More tests in common code unit tests it('changes class', async () => { - let res = await user.post(`/user/change-class?class=rogue`); + let res = await user.post('/user/change-class?class=rogue'); await user.sync(); expect(res).to.eql({ diff --git a/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js b/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js index 0074b38b59..8ada2ede7d 100644 --- a/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js +++ b/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js @@ -38,7 +38,7 @@ describe('POST /user/class/cast/:spellId', () => { it('returns an error if spell.mana > user.mana', async () => { await user.update({'stats.class': 'rogue'}); - await expect(user.post(`/user/class/cast/backStab`)) + await expect(user.post('/user/class/cast/backStab')) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -47,7 +47,7 @@ describe('POST /user/class/cast/:spellId', () => { }); it('returns an error if spell.value > user.gold', async () => { - await expect(user.post(`/user/class/cast/birthday`)) + await expect(user.post('/user/class/cast/birthday')) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -57,7 +57,7 @@ describe('POST /user/class/cast/:spellId', () => { it('returns an error if spell.lvl > user.level', async () => { await user.update({'stats.mp': 200, 'stats.class': 'wizard'}); - await expect(user.post(`/user/class/cast/earth`)) + await expect(user.post('/user/class/cast/earth')) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -66,7 +66,7 @@ describe('POST /user/class/cast/:spellId', () => { }); it('returns an error if user doesn\'t own the spell', async () => { - await expect(user.post(`/user/class/cast/snowball`)) + await expect(user.post('/user/class/cast/snowball')) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -85,7 +85,7 @@ describe('POST /user/class/cast/:spellId', () => { it('returns an error if targetId is required but missing', async () => { await user.update({'stats.class': 'rogue', 'stats.lvl': 11}); - await expect(user.post(`/user/class/cast/pickPocket`)) + await expect(user.post('/user/class/cast/pickPocket')) .to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -153,7 +153,7 @@ describe('POST /user/class/cast/:spellId', () => { members: 1, }); await groupLeader.update({'stats.mp': 200, 'stats.class': 'wizard', 'stats.lvl': 13}); - await groupLeader.post(`/user/class/cast/earth`); + await groupLeader.post('/user/class/cast/earth'); await sleep(1); await group.sync(); expect(group.chat[0]).to.exists; diff --git a/test/api/v3/integration/user/POST-user_disable-classes.test.js b/test/api/v3/integration/user/POST-user_disable-classes.test.js index 79272e83fd..3c0a88a3ab 100644 --- a/test/api/v3/integration/user/POST-user_disable-classes.test.js +++ b/test/api/v3/integration/user/POST-user_disable-classes.test.js @@ -12,7 +12,7 @@ describe('POST /user/disable-classes', () => { // More tests in common code unit tests it('disable classes', async () => { - let res = await user.post(`/user/disable-classes`); + let res = await user.post('/user/disable-classes'); await user.sync(); expect(res).to.eql({ diff --git a/test/api/v3/integration/user/POST-user_equip_type_key.test.js b/test/api/v3/integration/user/POST-user_equip_type_key.test.js index 53e8dbfe1f..f1d697cda7 100644 --- a/test/api/v3/integration/user/POST-user_equip_type_key.test.js +++ b/test/api/v3/integration/user/POST-user_equip_type_key.test.js @@ -31,8 +31,8 @@ describe('POST /user/equip/:type/:key', () => { 'stats.gp': 200, }); - await user.post(`/user/equip/equipped/weapon_warrior_1`); - let res = await user.post(`/user/equip/equipped/weapon_warrior_2`); + await user.post('/user/equip/equipped/weapon_warrior_1'); + let res = await user.post('/user/equip/equipped/weapon_warrior_2'); await user.sync(); expect(res).to.eql({ diff --git a/test/api/v3/integration/user/POST-user_feed_pet_food.test.js b/test/api/v3/integration/user/POST-user_feed_pet_food.test.js index 6fa3275196..7581c266ee 100644 --- a/test/api/v3/integration/user/POST-user_feed_pet_food.test.js +++ b/test/api/v3/integration/user/POST-user_feed_pet_food.test.js @@ -26,7 +26,7 @@ describe('POST /user/feed/:pet/:food', () => { let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - let res = await user.post(`/user/feed/Wolf-Base/Milk`); + let res = await user.post('/user/feed/Wolf-Base/Milk'); await user.sync(); expect(res).to.eql({ data: user.items.pets['Wolf-Base'], diff --git a/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js b/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js index e2a5e330f5..9621377beb 100644 --- a/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js +++ b/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js @@ -17,7 +17,7 @@ describe('POST /user/hatch/:egg/:hatchingPotion', () => { 'items.eggs.Wolf': 1, 'items.hatchingPotions.Base': 1, }); - let res = await user.post(`/user/hatch/Wolf/Base`); + let res = await user.post('/user/hatch/Wolf/Base'); await user.sync(); expect(user.items.pets['Wolf-Base']).to.equal(5); expect(user.items.eggs.Wolf).to.equal(0); diff --git a/test/api/v3/integration/user/POST-user_open_mystery_item.test.js b/test/api/v3/integration/user/POST-user_open_mystery_item.test.js index 2c43445118..d9e9fe7326 100644 --- a/test/api/v3/integration/user/POST-user_open_mystery_item.test.js +++ b/test/api/v3/integration/user/POST-user_open_mystery_item.test.js @@ -16,7 +16,7 @@ describe('POST /user/open-mystery-item', () => { // More tests in common code unit tests it('opens a mystery item', async () => { - let response = await user.post(`/user/open-mystery-item`); + let response = await user.post('/user/open-mystery-item'); await user.sync(); expect(user.items.gear.owned[mysteryItemKey]).to.be.true; diff --git a/test/api/v3/integration/user/POST-user_purchase.test.js b/test/api/v3/integration/user/POST-user_purchase.test.js index 6afaa14cb6..9c2963112b 100644 --- a/test/api/v3/integration/user/POST-user_purchase.test.js +++ b/test/api/v3/integration/user/POST-user_purchase.test.js @@ -17,7 +17,7 @@ describe('POST /user/purchase/:type/:key', () => { // More tests in common code unit tests it('returns an error when key is not provided', async () => { - await expect(user.post(`/user/purchase/gems/gem`)) + await expect(user.post('/user/purchase/gems/gem')) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', diff --git a/test/api/v3/integration/user/POST-user_sleep.test.js b/test/api/v3/integration/user/POST-user_sleep.test.js index eb4a38b8bb..da95ae97b0 100644 --- a/test/api/v3/integration/user/POST-user_sleep.test.js +++ b/test/api/v3/integration/user/POST-user_sleep.test.js @@ -12,14 +12,14 @@ describe('POST /user/sleep', () => { // More tests in common code unit tests it('toggles sleep status', async () => { - let res = await user.post(`/user/sleep`); + let res = await user.post('/user/sleep'); expect(res).to.eql({ preferences: {sleep: true}, }); await user.sync(); expect(user.preferences.sleep).to.be.true; - let res2 = await user.post(`/user/sleep`); + let res2 = await user.post('/user/sleep'); expect(res2).to.eql({ preferences: {sleep: false}, }); diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js index eb623e0a4a..f606c95c2c 100644 --- a/test/api/v3/integration/user/PUT-user.test.js +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -108,7 +108,7 @@ describe('PUT /user', () => { })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t(`mustPurchaseToSet`, { val: 'round', key: 'preferences.size' }), + message: t('mustPurchaseToSet', { val: 'round', key: 'preferences.size' }), }); }); diff --git a/website/src/controllers/api-v2/coupon.js b/website/src/controllers/api-v2/coupon.js index dd1b6c189d..17cd289bd3 100644 --- a/website/src/controllers/api-v2/coupon.js +++ b/website/src/controllers/api-v2/coupon.js @@ -30,7 +30,7 @@ api.getCoupons = function(req,res,next) { res.set({ 'Content-Type': 'text/csv', - 'Content-disposition': `attachment; filename=habitica-coupons.csv`, + 'Content-disposition': 'attachment; filename=habitica-coupons.csv', }); csvStringify(output, (err, csv) => { if (err) return next(err); diff --git a/website/src/controllers/api-v2/dataexport.js b/website/src/controllers/api-v2/dataexport.js index 2b3c46ed31..f0aad2b35c 100644 --- a/website/src/controllers/api-v2/dataexport.js +++ b/website/src/controllers/api-v2/dataexport.js @@ -44,7 +44,7 @@ dataexport.history = function(req, res) { res.set({ 'Content-Type': 'text/csv', - 'Content-disposition': `attachment; filename=habitica-tasks-history.csv`, + 'Content-disposition': 'attachment; filename=habitica-tasks-history.csv', }); csvStringify(output, (err, csv) => { diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 65f4b298c1..24e4ea8bab 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -224,7 +224,7 @@ api.flagChat = { if (group._id === TAVERN_ID) { groupUrl = '/#/options/groups/tavern'; } else if (group.type === 'guild') { - groupUrl = `/#/options/groups/guilds/{$group._id}`; + groupUrl = `/#/options/groups/guilds/${group._id}`; } else { groupUrl = 'party'; } @@ -236,12 +236,12 @@ api.flagChat = { {name: 'REPORTER_USERNAME', content: user.profile.name}, {name: 'REPORTER_UUID', content: user._id}, {name: 'REPORTER_EMAIL', content: reporterEmailContent}, - {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId={$user._id}`}, + {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId=${user._id}`}, {name: 'AUTHOR_USERNAME', content: message.user}, {name: 'AUTHOR_UUID', content: message.uuid}, {name: 'AUTHOR_EMAIL', content: authorEmailContent}, - {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId={$message.uuid}`}, + {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${message.uuid}`}, {name: 'GROUP_NAME', content: group.name}, {name: 'GROUP_TYPE', content: group.type}, diff --git a/website/src/controllers/api-v3/coupon.js b/website/src/controllers/api-v3/coupon.js index 8fee188094..8ecae368ee 100644 --- a/website/src/controllers/api-v3/coupon.js +++ b/website/src/controllers/api-v3/coupon.js @@ -32,7 +32,7 @@ api.getCoupons = { res.set({ 'Content-Type': 'text/csv', - 'Content-disposition': `attachment; filename=habitica-coupons.csv`, + 'Content-disposition': 'attachment; filename=habitica-coupons.csv', }); res.status(200).send(csv); }, diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index d743c9e9a8..f0d4b8d925 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -159,7 +159,7 @@ api.updateUser = { let purchasable = requiresPurchase[key]; if (purchasable && !checkPreferencePurchase(user, purchasable, val)) { - throw new NotAuthorized(res.t(`mustPurchaseToSet`, { val, key })); + throw new NotAuthorized(res.t('mustPurchaseToSet', { val, key })); } if (acceptablePUTPaths[key]) { diff --git a/website/src/controllers/top-level/dataexport.js b/website/src/controllers/top-level/dataexport.js index 6924c9b89f..f76e11421c 100644 --- a/website/src/controllers/top-level/dataexport.js +++ b/website/src/controllers/top-level/dataexport.js @@ -64,7 +64,7 @@ api.exportUserHistory = { res.set({ 'Content-Type': 'text/csv', - 'Content-disposition': `attachment; filename=habitica-tasks-history.csv`, + 'Content-disposition': 'attachment; filename=habitica-tasks-history.csv', }); let csvRes = await csvStringify(output); @@ -111,7 +111,7 @@ api.exportUserDataJson = { res.set({ 'Content-Type': 'application/json', - 'Content-disposition': `attachment; filename=habitica-user-data.json`, + 'Content-disposition': 'attachment; filename=habitica-user-data.json', }); let jsonRes = JSON.stringify(userData); @@ -137,7 +137,7 @@ api.exportUserDataXml = { res.set({ 'Content-Type': 'text/xml', - 'Content-disposition': `attachment; filename=habitica-user-data.xml`, + 'Content-disposition': 'attachment; filename=habitica-user-data.xml', }); res.status(200).send(js2xml('user', userData)); }, diff --git a/website/src/controllers/top-level/pages.js b/website/src/controllers/top-level/pages.js index 0d1800ee16..058408d263 100644 --- a/website/src/controllers/top-level/pages.js +++ b/website/src/controllers/top-level/pages.js @@ -39,8 +39,8 @@ _.each(staticPages, (name) => { async handler (req, res) { res.render(`static/${name}.jade`, { env: res.locals.habitrpg, - marked: marked, - userCount: TOTAL_USER_COUNT + marked, + userCount: TOTAL_USER_COUNT, }); }, }; @@ -57,8 +57,8 @@ _.each(shareables, (name) => { async handler (req, res) { res.render(`social/${name}`, { env: res.locals.habitrpg, - marked: marked, - userCount: TOTAL_USER_COUNT + marked, + userCount: TOTAL_USER_COUNT, }); }, }; diff --git a/website/src/index.js b/website/src/index.js index 24dbc21384..1b133ca781 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -1,4 +1,5 @@ 'use strict'; +/* eslint-disable global-require, no-process-env */ // Register babel hook so we can write the real entry file (server.js) in ES6 // In production, the es6 code is pre-transpiled so it doesn't need it diff --git a/website/src/models/group.js b/website/src/models/group.js index b3c25ae913..a5a43a10ce 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -161,10 +161,11 @@ schema.statics.getGroups = async function getGroups (options = {}) { types.forEach(type => { switch (type) { - case 'party': + case 'party': { queries.push(this.getGroup({user, groupId: 'party', fields: groupFields, populateLeader})); break; - case 'privateGuilds': + } + case 'privateGuilds': { let privateGroupQuery = this.find({ type: 'guild', privacy: 'private', @@ -174,7 +175,8 @@ schema.statics.getGroups = async function getGroups (options = {}) { privateGroupQuery.sort(sort).exec(); queries.push(privateGroupQuery); break; - case 'publicGuilds': + } + case 'publicGuilds': { let publicGroupQuery = this.find({ type: 'guild', privacy: 'public', @@ -183,11 +185,13 @@ schema.statics.getGroups = async function getGroups (options = {}) { publicGroupQuery.sort(sort).exec(); queries.push(publicGroupQuery); // TODO use lean? break; - case 'tavern': + } + case 'tavern': { if (types.indexOf('publicGuilds') === -1) { queries.push(this.getGroup({user, groupId: TAVERN_ID, fields: groupFields})); } break; + } } }); @@ -436,22 +440,26 @@ schema.methods.finishQuest = function finishQuest (quest) { let dropK = item.key; switch (item.type) { - case 'gear': + case 'gear': { // TODO This means they can lose their new gear on death, is that what we want? updates.$set[`items.gear.owned.${dropK}`] = true; break; + } case 'eggs': case 'food': case 'hatchingPotions': - case 'quests': + case 'quests': { updates.$inc[`items.${item.type}.${dropK}`] = _.where(quest.drop.items, {type: item.type, key: item.key}).length; break; - case 'pets': + } + case 'pets': { updates.$set[`items.pets.${dropK}`] = 5; break; - case 'mounts': + } + case 'mounts': { updates.$set[`items.mounts.${dropK}`] = true; break; + } } }); diff --git a/website/src/models/user.js b/website/src/models/user.js index fcf0078cf4..76c730ec26 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -549,7 +549,7 @@ export let publicFields = `preferences.size preferences.hair preferences.skin pr backer contributor auth.timestamps items`; // The minimum amount of data needed when populating multiple users -export let nameFields = `profile.name`; +export let nameFields = 'profile.name'; schema.post('init', function postInitUser (doc) { shared.wrap(doc); From 887aa478ec77de5a1f33bed2a7a29179120363db Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 15 Apr 2016 10:11:54 -0500 Subject: [PATCH 680/976] chore: merge in develop --- .eslintignore | 35 ++++++++++ .eslintrc | 125 ++---------------------------------- common/.eslintrc | 4 ++ common/script/.eslintrc | 7 +- package.json | 11 ++-- tasks/gulp-eslint.js | 59 ----------------- tasks/gulp-tests.js | 3 - test/.eslintrc | 24 +++---- website/public/js/.eslintrc | 6 ++ 9 files changed, 67 insertions(+), 207 deletions(-) create mode 100644 .eslintignore create mode 100644 common/.eslintrc delete mode 100644 tasks/gulp-eslint.js create mode 100644 website/public/js/.eslintrc diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..17ddc90a01 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,35 @@ +# Compiled and vendored files +common/dist/ +common/transpiled-babel/ +coverage/ +database_reports/ +migrations/ +website/build/ +website/transpiled-babel/ + +# The files in website/public/js should be moved out and browserified +website/public/ + +# Temporarilly disabled. These should be removed when the linting errors are fixed +common/script/content/index.js +common/script/fns/randomDrop.js +common/script/public/**/*.js + +website/src/**/api-v2/**/*.js +website/src/routes/payments.js +website/src/routes/pages.js +website/src/middlewares/ +website/src/controllers/payments/ + +debug-scripts/* +tasks/*.js +gulpfile.js +Gruntfile.js +newrelic.js + +test/api-legacy/**/* +test/common/simulations/**/* +test/common_old/ +test/content/**/* +test/server_side/**/* +test/spec/**/* diff --git a/.eslintrc b/.eslintrc index 429c97d4ec..111772a5a3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,123 +1,6 @@ { - "parser": "babel-eslint", - "plugins": ["babel"], - "rules": { - "indent": [2, 2, {"SwitchCase": 1}], - "quotes": [2, "single"], - "linebreak-style": [2, "unix"], - "semi": [2, "always"], - "no-extra-parens": 2, - "no-unexpected-multiline": 2, - "block-scoped-var": 2, - "dot-location": [2, "property"], - "dot-notation": 2, - "eqeqeq": 2, - "no-caller": 2, - "no-eval": 2, - "no-extend-native": 2, - "no-extra-bind": 2, - "no-fallthrough": 2, - "no-floating-decimal": 2, - "no-empty-pattern": 2, - "no-empty-label": 2, - "no-lone-blocks": 2, - "no-loop-func": 2, - "no-implicit-coercion": 2, - "no-native-reassign": 2, - "no-new-func": 2, - "no-new-wrappers": 2, - "no-new": 2, - "no-octal-escape": 2, - "no-octal": 2, - "no-process-env": 2, - "no-proto": 2, - "yoda": 2, - "wrap-iife": 2, - "radix": 2, - "no-with": 2, - "no-void": 2, - "no-useless-concat": 2, - "no-unused-expressions": 2, - "no-throw-literal": 2, - "no-sequences": 2, - "no-self-compare": 2, - "no-return-assign": 2, - "no-redeclare": 2, - "strict": [0, "global"], - "no-delete-var": 2, - "no-label-var": 2, - "no-shadow-restricted-names": 2, - "no-shadow": [2, { "builtinGlobals": true }], - "no-undef-init": 2, - "no-undef": [2, { "typeof": true }], - "no-unused-vars": 2, - "no-use-before-define": 2, - "global-require": 2, - "handle-callback-err": [2, "^.*(e|E)rr"], - "no-path-concat": 2, - "arrow-spacing": 2, - "constructor-super": 2, - "no-arrow-condition": 2, - "no-class-assign": 2, - "no-const-assign": 2, - "no-dupe-class-members": 2, - "no-this-before-super": 2, - "no-var": 2, - "object-shorthand": 0, - "prefer-const": 0, - "prefer-spread": 2, - "prefer-template": 2, - "array-bracket-spacing": [2, "never"], - "brace-style": [2, "1tbs", { "allowSingleLine": false }], - "camelcase": 2, - "comma-spacing": 2, - "comma-style": [2, "last"], - "comma-dangle": [2, "always-multiline"], - "computed-property-spacing": [2, "never"], - "consistent-this": [0, "self"], - "func-names": 2, - "func-style": [2, "declaration", { "allowArrowFunctions": true }], - "block-spacing": [2, "always"], - "key-spacing": [2, {"beforeColon": false, "afterColon": true}], - "max-nested-callbacks": [2, 3], - "new-cap": 0, - "new-parens": 2, - "newline-after-var": 0, - "no-array-constructor": 2, - "no-continue": 2, - "no-lonely-if": 2, - "no-mixed-spaces-and-tabs": 2, - "no-trailing-spaces": 2, - "no-spaced-func": 2, - "no-new-object": 2, - "no-nested-ternary": 2, - "one-var": [2, "never"], - "operator-linebreak": [2, "after"], - "quote-props": [2, "as-needed"], - "semi-spacing": [2, {"before": false, "after": true}], - "space-after-keywords": 2, - "space-before-blocks": 2, - "space-before-function-paren": 2, - "space-before-keywords": 2, - "space-in-parens": [2, "never"], - "space-infix-ops": 2, - "space-return-throw-case": 2, - "space-unary-ops": 2, - "spaced-comment": [2, "always", { "exceptions": ["-"]}], - "padded-blocks": [2, "never"], - "no-multiple-empty-lines": [2, {"max": 2}], - "generator-star-spacing": 0, - "babel/new-cap": 2, - "babel/object-shorthand": 2, - "babel/no-await-in-loop": 2 - }, - "env": { - "es6": true, - "mocha": true, - "node": true - }, - "ecmaFeatures" : { - "modules": true - }, - "extends": "eslint:recommended" + "extends": [ + "habitrpg/server", + "habitrpg/babel" + ] } diff --git a/common/.eslintrc b/common/.eslintrc new file mode 100644 index 0000000000..20c282864a --- /dev/null +++ b/common/.eslintrc @@ -0,0 +1,4 @@ +{ + "extends": "habitrpg/browser" +} + diff --git a/common/script/.eslintrc b/common/script/.eslintrc index 596ff03c45..0b1303a736 100644 --- a/common/script/.eslintrc +++ b/common/script/.eslintrc @@ -1,5 +1,6 @@ { - "globals": { - "window": true, - } + "extends": [ + "habitrpg/browser", + "habitrpg/babel" + ] } diff --git a/package.json b/package.json index 5407759afb..05d269a4ab 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "grunt-karma": "~0.12.1", "gulp": "^3.9.0", "gulp-babel": "^6.1.2", - "gulp-eslint": "^1.0.0", "gulp-grunt": "^0.5.2", "gulp-imagemin": "^2.4.0", "gulp-nodemon": "^2.0.4", @@ -100,7 +99,8 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp test", + "lint": "eslint .", + "test": "npm run lint && gulp test", "test:api-v2:unit": "mocha test/server_side", "test:api-v2:integration": "mocha test/api/v2 --recursive", "test:api-v3": "gulp test:api-v3", @@ -122,15 +122,16 @@ "coverage": "COVERAGE=true mocha --require register-handlers.js --reporter html-cov > coverage.html; open coverage.html" }, "devDependencies": { - "babel-eslint": "^5.0.0", + "babel-eslint": "^6.0.0", "chai": "^3.4.0", "chai-as-promised": "^5.1.0", "coveralls": "^2.11.2", "csv": "~0.3.6", "deep-diff": "~0.1.4", - "eslint": "^1.9.0", + "eslint": "^2.7.0", + "eslint-config-habitrpg": "^1.0.0", "eslint-plugin-babel": "^3.0.0", - "eslint-plugin-mocha": "^1.1.0", + "eslint-plugin-mocha": "^2.1.0", "event-stream": "^3.2.2", "expect.js": "~0.2.0", "istanbul": "^0.3.14", diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js deleted file mode 100644 index 9f91a09096..0000000000 --- a/tasks/gulp-eslint.js +++ /dev/null @@ -1,59 +0,0 @@ -import gulp from 'gulp'; -import eslint from 'gulp-eslint'; - -const SERVER_FILES = [ - './website/src/**/api-v3/**/*.js', - './website/src/models/**', - './website/src/server.js', -]; -const COMMON_FILES = [ - './common/script/**/*.js', - // @TODO remove these negations as the files are converted over. - '!./common/script/content/index.js', - '!./common/script/fns/randomDrop.js', - '!./common/script/public/**/*.js', -]; -const TEST_FILES = [ - './test/**/*.js', - // @TODO remove these negations as the test files are cleaned up. - '!./test/api-legacy/**/*', - '!./test/common_old/simulations/**/*', - '!./test/content/**/*', - '!./test/server_side/**/*', - '!./test/spec/**/*', -]; - -let linter = (src, options) => { - return gulp - .src(src) - .pipe(eslint(options)) - .pipe(eslint.format()) - .pipe(eslint.failAfterError()); -}; - -// TODO lint client -// TDOO separate linting cong between -// TODO lint gulp tasks, tests, ...? -// TODO what about prefer-const rule? -// TODO remove estraverse dependency once https://github.com/adametry/gulp-eslint/issues/117 sorted out -gulp.task('lint:server', () => { - return linter(SERVER_FILES); -}); - -gulp.task('lint:common', () => { - return linter(COMMON_FILES); -}); - -gulp.task('lint:tests', () => { - return linter(TEST_FILES); -}); - -gulp.task('lint', ['lint:server', 'lint:common', 'lint:tests']); - -gulp.task('lint:watch', () => { - gulp.watch([ - SERVER_FILES, - COMMON_FILES, - TEST_FILES, - ], ['lint']); -}); diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 9cb654ce21..d8a9917eef 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -384,7 +384,6 @@ gulp.task('test:api-v3:integration:separate-server', (done) => { gulp.task('test', (done) => { runSequence( - 'lint', 'test:common', 'test:api-v3:unit', 'test:api-v3:integration', @@ -395,7 +394,6 @@ gulp.task('test', (done) => { gulp.task('test:api-v3', (done) => { runSequence( - 'lint', 'test:api-v3:unit', 'test:api-v3:integration', done @@ -448,7 +446,6 @@ gulp.task('test:api-v3:safe', ['test:prepare:server'], (done) => { gulp.task('test:all', (done) => { runSequence( - 'lint', //'test:e2e:safe', //'test:common:safe', //'test:content:safe', diff --git a/test/.eslintrc b/test/.eslintrc index 9ee7ce9e28..a56eb09d64 100644 --- a/test/.eslintrc +++ b/test/.eslintrc @@ -1,18 +1,10 @@ { - "rules": { - "one-var": 0, - "func-names": 0, - "max-nested-callbacks": 0, - "no-unused-expressions": 0, - "mocha/no-exclusive-tests": 2, - "mocha/no-global-tests": 2, - "mocha/handle-done-callback": 2 - }, - "globals": { - "expect": true, - "_": true, - "sandbox": true, - "sinon": true - }, - "plugins": [ "mocha" ] + "extends": [ + "habitrpg/mocha", + "habitrpg/babel" + ], + "globals": { + "_": true, + "Promise": true + } } diff --git a/website/public/js/.eslintrc b/website/public/js/.eslintrc new file mode 100644 index 0000000000..584c1efdd6 --- /dev/null +++ b/website/public/js/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "habitrpg/browser", + "env": { + "jquery": true + } +} From 8870cef0e2783ccbcc4978b88acf47b25cc7652d Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 6 Apr 2016 21:18:36 +0000 Subject: [PATCH 681/976] WIP(payments): lint Amazon Payments file --- .../src/controllers/api-v3/payments/amazon.js | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/amazon.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js new file mode 100644 index 0000000000..c1056c1224 --- /dev/null +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -0,0 +1,277 @@ +import amazonPayments from 'amazon-payments'; +import async from 'async'; +import cc from 'coupon-code'; +import mongoose from 'mongoose'; +import moment from 'moment'; +import nconf from 'nconf'; +import payments from './index'; +import shared from '../../../../common'; +import { model as User } from '../../models/user'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +let amzPayment = amazonPayments.connect({ + environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], + sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), + mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), + mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), + clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), +}); + +api.verifyAccessToken = function verifyAccessToken (req, res) { + if (!req.body || !req.body.access_token) { + return res.status(400).json({err: 'Access token not supplied.'}); + } + + amzPayment.api.getTokenInfo(req.body.access_token, function getTokenInfo (err) { + if (err) return res.status(400).json({err}); + + res.sendStatus(200); + }); +}; + +api.createOrderReferenceId = function createOrderReferenceId (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + amzPayment.offAmazonPayments.createOrderReferenceForId({ + Id: req.body.billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + }, function createOrderReferenceForId (err, response) { + if (err) return next(err); + if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { + return next(new Error('Missing attributes in Amazon response.')); + } + + res.json({ + orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, + }); + }); +}; + +api.checkout = function checkout (req, res, next) { + if (!req.body || !req.body.orderReferenceId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let amount = 5; + + if (gift) { + if (gift.type === 'gems') { + amount = gift.gems.amount / 4; + } else if (gift.type === 'subscription') { + amount = shared.content.subscriptionBlocks[gift.subscription.key].price; + } + } + + async.series({ + setOrderReferenceDetails (cb) { + amzPayment.offAmazonPayments.setOrderReferenceDetails({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerNote: 'HabitRPG Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, + }, cb); + }, + + confirmOrderReference (cb) { + amzPayment.offAmazonPayments.confirmOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + authorize (cb) { + amzPayment.offAmazonPayments.authorize({ + AmazonOrderReferenceId: orderReferenceId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerAuthorizationNote: 'HabitRPG Payment', + TransactionTimeout: 0, + CaptureNow: true, + }, function checkAuthorizationStatus (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successfull.')); + } + + return cb(); + }); + }, + + closeOrderReference (cb) { + amzPayment.offAmazonPayments.closeOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + executePayment (cb) { + async.waterfall([ + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function executeAmazonPayment (member, cb2) { + let data = {user, paymentMethod: 'Amazon Payments'}; + let method = 'buyGems'; + + if (gift) { + if (gift.type === 'subscription') method = 'createSubscription'; + gift.member = member; + data.gift = gift; + data.paymentMethod = 'Gift'; + } + + payments[method](data, cb2); + }, + ], cb); + }, + }, function result (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribe = function subscribe (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + let billingAgreementId = req.body.billingAgreementId; + let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; + let coupon = req.body.coupon; + let user = res.locals.user; + + if (!sub) { + return res.status(400).json({err: 'Subscription plan not found.'}); + } + + async.series({ + applyDiscount (cb) { + if (!sub.discount) return cb(); + if (!coupon) return cb(new Error('Please provide a coupon code for this plan.')); + mongoose.model('Coupon').findOne({_id: cc.validate(coupon), event: sub.key}, function couponResult (err) { + if (err) return cb(err); + if (!coupon) return cb(new Error('Coupon code not found.')); + cb(); + }); + }, + + setBillingAgreementDetails (cb) { + amzPayment.offAmazonPayments.setBillingAgreementDetails({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: 'HabitRPG Subscription', + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: shared.uuid(), + StoreName: 'HabitRPG', + CustomInformation: 'HabitRPG Subscription', + }, + }, + }, cb); + }, + + confirmBillingAgreement (cb) { + amzPayment.offAmazonPayments.confirmBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + authorizeOnBillingAgreement (cb) { + amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: sub.price, + }, + SellerAuthorizationNote: 'HabitRPG Subscription Payment', + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: 'HabitRPG Subscription Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, function billingAgreementResult (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successful.')); + } + + return cb(); + }); + }, + + createSubscription (cb) { + payments.createSubscription({ + user, + customerId: billingAgreementId, + paymentMethod: 'Amazon Payments', + sub, + }, cb); + }, + }, function subscribeResult (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribeCancel = function subscribeCancel (req, res, next) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.status(401).json({err: 'User does not have a plan subscription'}); + + let billingAgreementId = user.purchased.plan.customerId; + + async.series({ + closeBillingAgreement (cb) { + amzPayment.offAmazonPayments.closeBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + cancelSubscription (cb) { + let data = { + user, + // Date of next bill + nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), + paymentMethod: 'Amazon Payments', + }; + + payments.cancelSubscription(data, cb); + }, + }, function subscribeCancelResult (err) { + if (err) return next(err); // don't json this, let toString() handle errors + + if (req.query.noRedirect) { + res.sendStatus(200); + } else { + res.redirect('/'); + } + + user = null; + }); +}; + +module.exports = api; From a06f9954dd7e78bd7196b30ae04230cc67e057d6 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 7 Apr 2016 20:51:40 +0000 Subject: [PATCH 682/976] refactor(payments): index.js lint pass --- .../src/controllers/api-v3/payments/amazon.js | 2 +- .../src/controllers/api-v3/payments/index.js | 232 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 website/src/controllers/api-v3/payments/index.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js index c1056c1224..bc8e3b5177 100644 --- a/website/src/controllers/api-v3/payments/amazon.js +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -5,7 +5,7 @@ import mongoose from 'mongoose'; import moment from 'moment'; import nconf from 'nconf'; import payments from './index'; -import shared from '../../../../common'; +import shared from '../../../../../common'; import { model as User } from '../../models/user'; const IS_PROD = nconf.get('NODE_ENV') === 'production'; diff --git a/website/src/controllers/api-v3/payments/index.js b/website/src/controllers/api-v3/payments/index.js new file mode 100644 index 0000000000..f6e0a8ebe3 --- /dev/null +++ b/website/src/controllers/api-v3/payments/index.js @@ -0,0 +1,232 @@ +import _ from 'lodash' ; +import analytics from '../../../libs/api-v3/analyticsService'; +import async from 'async'; +import cc from 'coupon-code'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../../libs/api-v3/email'; +import members from '../members'; +import moment from 'moment'; +import mongoose from 'mongoose'; +import nconf from 'nconf'; +import pushNotify from '../../../libs/api-v3/pushNotifications'; +import shared from '../../../../../common' ; + +import amazon from './amazon'; +import iap from './iap'; +import paypal from './paypal'; +import stripe from './stripe'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +function revealMysteryItems (user) { + _.each(shared.content.gear.flat, function findMysteryItems (item) { + if ( + item.klass === 'mystery' && + moment().isAfter(shared.content.mystery[item.mystery].start) && + moment().isBefore(shared.content.mystery[item.mystery].end) && + !user.items.gear.owned[item.key] && + user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 + ) { + user.purchased.plan.mysteryItems.push(item.key); + } + }); +} + +api.createSubscription = function createSubscription (data, cb) { + let recipient = data.gift ? data.gift.member : data.user; + let plan = recipient.purchased.plan; + let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; + let months = Number(block.months); + + if (data.gift) { + if (plan.customerId && !plan.dateTerminated) { // User has active plan + plan.extraMonths += months; + } else { + plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); + if (!plan.dateUpdated) plan.dateUpdated = new Date(); + } + if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + } else { + _(plan).merge({ // override with these values + planId: block.key, + customerId: data.customerId, + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: data.paymentMethod, + extraMonths: Number(plan.extraMonths) + + Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0), + dateTerminated: null, + // Specify a lastBillingDate just for Amazon Payments + // Resetted every time the subscription restarts + lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, + }).defaults({ // allow non-override if a plan was previously used + dateCreated: new Date(), + mysteryItems: [], + }).value(); + } + + // Block sub perks + let perks = Math.floor(months / 3); + if (perks) { + plan.consecutive.offset += months; + plan.consecutive.gemCapExtra += perks * 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; + plan.consecutive.trinkets += perks; + } + revealMysteryItems(recipient); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'subscription-begins'); + + let analyticsData = { + uuid: data.user._id, + itemPurchased: 'Subscription', + sku: `${data.paymentMethod.toLowerCase()}-subscription`, + purchaseType: 'subscribe', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: block.price, + }; + analytics.trackPurchase(analyticsData); + } + data.user.purchased.txnCount++; + if (data.gift) { + members.sendMessage(data.user, data.gift.member, data.gift); + + let byUserName = getUserInfo(data.user, ['name']).name; + + if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { + txnEmail(data.gift.member, 'gifted-subscription', [ + {name: 'GIFTER', content: byUserName}, + {name: 'X_MONTHS_SUBSCRIPTION', content: months}, + ]); + } + + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +/** + * Sets their subscription to be cancelled later + */ +api.cancelSubscription = function cancelSubscription (data, cb) { + let plan = data.user.purchased.plan; + let now = moment(); + let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; + + plan.dateTerminated = + moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) + .add({days: remaining}) // end their subscription 1mo from their last payment + .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .toDate(); + plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + + data.user.save(cb); + txnEmail(data.user, 'cancel-subscription'); + let analyticsData = { + uuid: data.user._id, + gaCategory: 'commerce', + gaLabel: data.paymentMethod, + paymentMethod: data.paymentMethod, + }; + analytics.track('unsubscribe', analyticsData); +}; + +api.buyGems = function buyGems (data, cb) { + let amt = data.amount || 5; + amt = data.gift ? data.gift.gems.amount / 4 : amt; + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); + + let analyticsData = { + uuid: data.user._id, + itemPurchased: 'Gems', + sku: `${data.paymentMethod.toLowerCase()}-checkout`, + purchaseType: 'checkout', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: amt, + }; + analytics.trackPurchase(analyticsData); + } + + if (data.gift) { + let byUsername = getUserInfo(data.user, ['name']).name; + let gemAmount = data.gift.gems.amount || 20; + + members.sendMessage(data.user, data.gift.member, data.gift); + if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { + txnEmail(data.gift.member, 'gifted-gems', [ + {name: 'GIFTER', content: byUsername}, + {name: 'X_GEMS_GIFTED', content: gemAmount}, + ]); + } + + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +api.validCoupon = function validCoupon (req, res, next) { + mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { + if (err) return next(err); + if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); + return res.sendStatus(200); + }); +}; + +api.stripeCheckout = stripe.checkout; +api.stripeSubscribeCancel = stripe.subscribeCancel; +api.stripeSubscribeEdit = stripe.subscribeEdit; + +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; + +api.amazonVerifyAccessToken = amazon.verifyAccessToken; +api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +api.amazonCheckout = amazon.checkout; +api.amazonSubscribe = amazon.subscribe; +api.amazonSubscribeCancel = amazon.subscribeCancel; + +api.iapAndroidVerify = iap.androidVerify; +api.iapIosVerify = iap.iosVerify; + +module.exports = api; From d49115eff60f9fea657172e66abf9d7e55309273 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 12 Apr 2016 21:15:20 +0000 Subject: [PATCH 683/976] refactor(payments): IAP linting pass --- .../src/controllers/api-v3/payments/iap.js | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/iap.js diff --git a/website/src/controllers/api-v3/payments/iap.js b/website/src/controllers/api-v3/payments/iap.js new file mode 100644 index 0000000000..94cf21fcca --- /dev/null +++ b/website/src/controllers/api-v3/payments/iap.js @@ -0,0 +1,158 @@ +import { + iap, + inAppPurchase, } +from 'in-app-purchase'; +import payments from './index'; +import nconf from 'nconf'; + +inAppPurchase.config({ + // this is the path to the directory containing iap-sanbox/iap-live files + googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), +}); + +// Validation ERROR Codes +const INVALID_PAYLOAD = 6778001; +/* const CONNECTION_FAILED = 6778002; +const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? + +let api = {}; + +api.androidVerify = function androidVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function googleSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + /* + google receipt must be provided as an object + { + "data": "{stringified data object}", + "signature": "signature from google" + } + */ + let testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature, + }; + + // iap is ready + iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return res.json(resObj); + } + + if (iap.isValidated(googleRes)) { + let resObj = { + ok: true, + data: googleRes, + }; + + payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); + + return res.json(resObj); + } + }); + }); +}; + +exports.iosVerify = function iosVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function iosSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return res.json(resObj); + } + + if (iap.isValidated(appleRes)) { + let purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length > 0) { + let correctReceipt = true; + for (let index of purchaseDataList) { + switch (purchaseDataList[index].productId) { + case 'com.habitrpg.ios.Habitica.4gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); + break; + case 'com.habitrpg.ios.Habitica.8gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); + break; + case 'com.habitrpg.ios.Habitica.20gems': + case 'com.habitrpg.ios.Habitica.21gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); + break; + case 'com.habitrpg.ios.Habitica.42gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); + break; + default: + correctReceipt = false; + } + } + if (correctReceipt) { + let resObj = { + ok: true, + data: appleRes, + }; + // yay good! + return res.json(resObj); + } + } + // wrong receipt content + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Incorrect receipt content', + }, + }; + return res.json(resObj); + } + // invalid receipt + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Invalid receipt', + }, + }; + + return res.json(resObj); + }); + }); +}; + +module.exports = api; From c850ccf463d1dac93bcca49fbc35f62710c59b71 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 13 Apr 2016 19:29:13 +0000 Subject: [PATCH 684/976] refactor(payments): Stripe linting pass --- .../src/controllers/api-v3/payments/stripe.js | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/stripe.js diff --git a/website/src/controllers/api-v3/payments/stripe.js b/website/src/controllers/api-v3/payments/stripe.js new file mode 100644 index 0000000000..5582d33ca1 --- /dev/null +++ b/website/src/controllers/api-v3/payments/stripe.js @@ -0,0 +1,135 @@ +import nconf from 'nconf'; +import stripeModule from 'stripe'; +import async from 'async'; +import payments from './index'; +import { model as User } from '../../models/user'; +import shared from '../../../../../common'; +import mongoose from 'mongoose'; +import cc from 'coupon-code'; + +const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); + +let api = {}; +/* + Setup Stripe response when posting payment + */ +api.checkout = function checkout (req, res) { + let token = req.body.id; + let user = res.locals.user; + let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; + + async.waterfall([ + function stripeCharge (cb) { + if (sub) { + async.waterfall([ + function handleCoupon (cb2) { + if (!sub.discount) return cb2(null, null); + if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); + mongoose.model('Coupon').findOne({_id: cc.validate(req.query.coupon), event: sub.key}, cb2); + }, + function createCustomer (coupon, cb2) { + if (sub.discount && !coupon) return cb2('Invalid coupon code.'); + let customer = { + email: req.body.email, + metadata: {uuid: user._id}, + card: token, + plan: sub.key, + }; + stripe.customers.create(customer, cb2); + }, + ], cb); + } else { + let amount; + if (!gift) { + amount = '500'; + } else if (gift.type === 'subscription') { + amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; + } else { + amount = `${gift.gems.amount / 4 * 100}`; + } + stripe.charges.create({ + amount, + currency: 'usd', + card: token, + }, cb); + } + }, + function saveUserData (response, cb) { + if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', sub}, cb); + async.waterfall([ + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function prepData (member, cb2) { + let data = {user, customerId: response.id, paymentMethod: 'Stripe', gift}; + let method = 'buyGems'; + if (gift) { + gift.member = member; + if (gift.type === 'subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + payments[method](data, cb2); + }, + ], cb); + }, + ], function handleResponse (err) { + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.sendStatus(200); + user = token = null; + }); +}; + +api.subscribeCancel = function subscribeCancel (req, res) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) { + return res.status(401).json({err: 'User does not have a plan subscription'}); + } + + async.auto({ + getCustomer: function getCustomer (cb) { + stripe.customers.retrieve(user.purchased.plan.customerId, cb); + }, + deleteCustomer: ['getCustomer', function deleteCustomer (cb) { + stripe.customers.del(user.purchased.plan.customerId, cb); + }], + cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { + let data = { + user, + nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds + paymentMethod: 'Stripe', + }; + payments.cancelSubscription(data, cb); + }], + }, function handleResponse (err) { + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.redirect('/'); + user = null; + }); +}; + +api.subscribeEdit = function subscribeEdit (req, res) { + let token = req.body.id; + let user = res.locals.user; + let userId = user.purchased.plan.customerId; + let subscriptionId; + + async.waterfall([ + function listSubscriptions (cb) { + stripe.customers.listSubscriptions(userId, cb); + }, + function updateSubscription (response, cb) { + subscriptionId = response.data[0].id; + stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); + }, + function saveUser (response, cb) { + user.save(cb); + }, + ], function handleResponse (err) { + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.sendStatus(200); + token = user = userId = subscriptionId; + }); +}; + +module.exports = api; From 1d5d6e91463bba3c6e40a5d8fe9fdc571faebf62 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Apr 2016 18:18:57 +0200 Subject: [PATCH 685/976] move payments to /top-level --- website/src/controllers/{ => top-level}/payments/amazon.js | 0 website/src/controllers/{ => top-level}/payments/iap.js | 0 website/src/controllers/{ => top-level}/payments/index.js | 0 website/src/controllers/{ => top-level}/payments/paypal.js | 0 .../controllers/{ => top-level}/payments/paypalBillingSetup.js | 0 website/src/controllers/{ => top-level}/payments/stripe.js | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename website/src/controllers/{ => top-level}/payments/amazon.js (100%) rename website/src/controllers/{ => top-level}/payments/iap.js (100%) rename website/src/controllers/{ => top-level}/payments/index.js (100%) rename website/src/controllers/{ => top-level}/payments/paypal.js (100%) rename website/src/controllers/{ => top-level}/payments/paypalBillingSetup.js (100%) rename website/src/controllers/{ => top-level}/payments/stripe.js (100%) diff --git a/website/src/controllers/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js similarity index 100% rename from website/src/controllers/payments/amazon.js rename to website/src/controllers/top-level/payments/amazon.js diff --git a/website/src/controllers/payments/iap.js b/website/src/controllers/top-level/payments/iap.js similarity index 100% rename from website/src/controllers/payments/iap.js rename to website/src/controllers/top-level/payments/iap.js diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/top-level/payments/index.js similarity index 100% rename from website/src/controllers/payments/index.js rename to website/src/controllers/top-level/payments/index.js diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js similarity index 100% rename from website/src/controllers/payments/paypal.js rename to website/src/controllers/top-level/payments/paypal.js diff --git a/website/src/controllers/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js similarity index 100% rename from website/src/controllers/payments/paypalBillingSetup.js rename to website/src/controllers/top-level/payments/paypalBillingSetup.js diff --git a/website/src/controllers/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js similarity index 100% rename from website/src/controllers/payments/stripe.js rename to website/src/controllers/top-level/payments/stripe.js From 261a5a66b1c9bf449b2323849232103a361cdeed Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 14 Apr 2016 22:38:41 +0000 Subject: [PATCH 686/976] working on promisifying amazonPayments --- common/locales/en/api-v3.json | 7 +- tasks/gulp-tests.js | 4 + ...ayments_amazon_verify_access_token.test.js | 21 ++ test/api/v3/unit/libs/amazonPayments.test.js | 24 ++ .../src/controllers/api-v3/payments/amazon.js | 277 ------------------ .../src/controllers/api-v3/payments/iap.js | 158 ---------- .../src/controllers/api-v3/payments/index.js | 232 --------------- .../src/controllers/api-v3/payments/stripe.js | 135 --------- .../controllers/top-level/payments/amazon.js | 268 +++++++++-------- .../src/controllers/top-level/payments/iap.js | 125 ++++---- .../controllers/top-level/payments/index.js | 252 +++++++++------- .../controllers/top-level/payments/paypal.js | 9 +- .../controllers/top-level/payments/stripe.js | 138 +++++---- website/src/libs/api-v3/amazonPayments.js | 38 +++ 14 files changed, 521 insertions(+), 1167 deletions(-) create mode 100644 test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js create mode 100644 test/api/v3/unit/libs/amazonPayments.test.js delete mode 100644 website/src/controllers/api-v3/payments/amazon.js delete mode 100644 website/src/controllers/api-v3/payments/iap.js delete mode 100644 website/src/controllers/api-v3/payments/index.js delete mode 100644 website/src/controllers/api-v3/payments/stripe.js create mode 100644 website/src/libs/api-v3/amazonPayments.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 7af8dc9af0..b68a72b936 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -170,5 +170,10 @@ "pushDeviceAdded": "Push device added successfully", "pushDeviceAlreadyAdded": "The user already has the push device", "resetComplete": "Reset completed", - "lvl10ChangeClass": "To change class you must be at least level 10." + "lvl10ChangeClass": "To change class you must be at least level 10.", + "resetComplete": "Reset has completed", + "missingAccessToken": "The request is missing a required parameter : access_token", + "missingBillingAgreementId": "Missing billing agreement id", + "missingAttributesFromAmazon": "Missing attributes from Amazon", + "errorFromAmazon": "Error from Amazon" } diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index d8a9917eef..fba9d85721 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -358,6 +358,10 @@ gulp.task('test:api-v3:unit', (done) => { pipe(runner); }); +gulp.task('test:api-v3:unit:watch', () => { + gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/libs/*'], ['test:api-v3:unit']); +}); + gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js new file mode 100644 index 0000000000..494c387f14 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : amazon', () => { + let endpoint = '/payments/amazon/verifyAccessToken'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verify access token', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingAccessToken'), + }); + }); +}); diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js new file mode 100644 index 0000000000..a60a8b4633 --- /dev/null +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -0,0 +1,24 @@ +import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; + +describe.only('amazonPayments', () => { + + beforeEach(() => { + }); + + describe('#getTokenInfo', () => { + it('validates access_token parameter', async (done) => { + try { + let result = await amz.getTokenInfo(); + } catch (e) { + expect(e.type).to.eql('invalid_request'); + done(); + } + }); + }); + + describe('#createOrderReferenceId', () => { + it('is sane', () => { + expect(false).to.eql(true); // @TODO + }); + }); +}); diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js deleted file mode 100644 index bc8e3b5177..0000000000 --- a/website/src/controllers/api-v3/payments/amazon.js +++ /dev/null @@ -1,277 +0,0 @@ -import amazonPayments from 'amazon-payments'; -import async from 'async'; -import cc from 'coupon-code'; -import mongoose from 'mongoose'; -import moment from 'moment'; -import nconf from 'nconf'; -import payments from './index'; -import shared from '../../../../../common'; -import { model as User } from '../../models/user'; - -const IS_PROD = nconf.get('NODE_ENV') === 'production'; - -let api = {}; - -let amzPayment = amazonPayments.connect({ - environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], - sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), - mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), - mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), - clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), -}); - -api.verifyAccessToken = function verifyAccessToken (req, res) { - if (!req.body || !req.body.access_token) { - return res.status(400).json({err: 'Access token not supplied.'}); - } - - amzPayment.api.getTokenInfo(req.body.access_token, function getTokenInfo (err) { - if (err) return res.status(400).json({err}); - - res.sendStatus(200); - }); -}; - -api.createOrderReferenceId = function createOrderReferenceId (req, res, next) { - if (!req.body || !req.body.billingAgreementId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - amzPayment.offAmazonPayments.createOrderReferenceForId({ - Id: req.body.billingAgreementId, - IdType: 'BillingAgreement', - ConfirmNow: false, - }, function createOrderReferenceForId (err, response) { - if (err) return next(err); - if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { - return next(new Error('Missing attributes in Amazon response.')); - } - - res.json({ - orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, - }); - }); -}; - -api.checkout = function checkout (req, res, next) { - if (!req.body || !req.body.orderReferenceId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - let gift = req.body.gift; - let user = res.locals.user; - let orderReferenceId = req.body.orderReferenceId; - let amount = 5; - - if (gift) { - if (gift.type === 'gems') { - amount = gift.gems.amount / 4; - } else if (gift.type === 'subscription') { - amount = shared.content.subscriptionBlocks[gift.subscription.key].price; - } - } - - async.series({ - setOrderReferenceDetails (cb) { - amzPayment.offAmazonPayments.setOrderReferenceDetails({ - AmazonOrderReferenceId: orderReferenceId, - OrderReferenceAttributes: { - OrderTotal: { - CurrencyCode: 'USD', - Amount: amount, - }, - SellerNote: 'HabitRPG Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG', - }, - }, - }, cb); - }, - - confirmOrderReference (cb) { - amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, - - authorize (cb) { - amzPayment.offAmazonPayments.authorize({ - AmazonOrderReferenceId: orderReferenceId, - AuthorizationReferenceId: shared.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: 'USD', - Amount: amount, - }, - SellerAuthorizationNote: 'HabitRPG Payment', - TransactionTimeout: 0, - CaptureNow: true, - }, function checkAuthorizationStatus (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successfull.')); - } - - return cb(); - }); - }, - - closeOrderReference (cb) { - amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, - - executePayment (cb) { - async.waterfall([ - function findUser (cb2) { - User.findById(gift ? gift.uuid : undefined, cb2); - }, - function executeAmazonPayment (member, cb2) { - let data = {user, paymentMethod: 'Amazon Payments'}; - let method = 'buyGems'; - - if (gift) { - if (gift.type === 'subscription') method = 'createSubscription'; - gift.member = member; - data.gift = gift; - data.paymentMethod = 'Gift'; - } - - payments[method](data, cb2); - }, - ], cb); - }, - }, function result (err) { - if (err) return next(err); - - res.sendStatus(200); - }); -}; - -api.subscribe = function subscribe (req, res, next) { - if (!req.body || !req.body.billingAgreementId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - let billingAgreementId = req.body.billingAgreementId; - let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; - let coupon = req.body.coupon; - let user = res.locals.user; - - if (!sub) { - return res.status(400).json({err: 'Subscription plan not found.'}); - } - - async.series({ - applyDiscount (cb) { - if (!sub.discount) return cb(); - if (!coupon) return cb(new Error('Please provide a coupon code for this plan.')); - mongoose.model('Coupon').findOne({_id: cc.validate(coupon), event: sub.key}, function couponResult (err) { - if (err) return cb(err); - if (!coupon) return cb(new Error('Coupon code not found.')); - cb(); - }); - }, - - setBillingAgreementDetails (cb) { - amzPayment.offAmazonPayments.setBillingAgreementDetails({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: 'HabitRPG Subscription', - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: shared.uuid(), - StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription', - }, - }, - }, cb); - }, - - confirmBillingAgreement (cb) { - amzPayment.offAmazonPayments.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, - - authorizeOnBillingAgreement (cb) { - amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - AuthorizationReferenceId: shared.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: 'USD', - Amount: sub.price, - }, - SellerAuthorizationNote: 'HabitRPG Subscription Payment', - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: 'HabitRPG Subscription Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG', - }, - }, function billingAgreementResult (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successful.')); - } - - return cb(); - }); - }, - - createSubscription (cb) { - payments.createSubscription({ - user, - customerId: billingAgreementId, - paymentMethod: 'Amazon Payments', - sub, - }, cb); - }, - }, function subscribeResult (err) { - if (err) return next(err); - - res.sendStatus(200); - }); -}; - -api.subscribeCancel = function subscribeCancel (req, res, next) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.status(401).json({err: 'User does not have a plan subscription'}); - - let billingAgreementId = user.purchased.plan.customerId; - - async.series({ - closeBillingAgreement (cb) { - amzPayment.offAmazonPayments.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, - - cancelSubscription (cb) { - let data = { - user, - // Date of next bill - nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), - paymentMethod: 'Amazon Payments', - }; - - payments.cancelSubscription(data, cb); - }, - }, function subscribeCancelResult (err) { - if (err) return next(err); // don't json this, let toString() handle errors - - if (req.query.noRedirect) { - res.sendStatus(200); - } else { - res.redirect('/'); - } - - user = null; - }); -}; - -module.exports = api; diff --git a/website/src/controllers/api-v3/payments/iap.js b/website/src/controllers/api-v3/payments/iap.js deleted file mode 100644 index 94cf21fcca..0000000000 --- a/website/src/controllers/api-v3/payments/iap.js +++ /dev/null @@ -1,158 +0,0 @@ -import { - iap, - inAppPurchase, } -from 'in-app-purchase'; -import payments from './index'; -import nconf from 'nconf'; - -inAppPurchase.config({ - // this is the path to the directory containing iap-sanbox/iap-live files - googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), -}); - -// Validation ERROR Codes -const INVALID_PAYLOAD = 6778001; -/* const CONNECTION_FAILED = 6778002; -const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? - -let api = {}; - -api.androidVerify = function androidVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; - - iap.setup(function googleSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - /* - google receipt must be provided as an object - { - "data": "{stringified data object}", - "signature": "signature from google" - } - */ - let testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature, - }; - - // iap is ready - iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { - if (err) { - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, - }; - - return res.json(resObj); - } - - if (iap.isValidated(googleRes)) { - let resObj = { - ok: true, - data: googleRes, - }; - - payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); - - return res.json(resObj); - } - }); - }); -}; - -exports.iosVerify = function iosVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; - - iap.setup(function iosSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { - if (err) { - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, - }; - - return res.json(resObj); - } - - if (iap.isValidated(appleRes)) { - let purchaseDataList = iap.getPurchaseData(appleRes); - if (purchaseDataList.length > 0) { - let correctReceipt = true; - for (let index of purchaseDataList) { - switch (purchaseDataList[index].productId) { - case 'com.habitrpg.ios.Habitica.4gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); - break; - case 'com.habitrpg.ios.Habitica.8gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); - break; - case 'com.habitrpg.ios.Habitica.20gems': - case 'com.habitrpg.ios.Habitica.21gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); - break; - case 'com.habitrpg.ios.Habitica.42gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); - break; - default: - correctReceipt = false; - } - } - if (correctReceipt) { - let resObj = { - ok: true, - data: appleRes, - }; - // yay good! - return res.json(resObj); - } - } - // wrong receipt content - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: 'Incorrect receipt content', - }, - }; - return res.json(resObj); - } - // invalid receipt - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: 'Invalid receipt', - }, - }; - - return res.json(resObj); - }); - }); -}; - -module.exports = api; diff --git a/website/src/controllers/api-v3/payments/index.js b/website/src/controllers/api-v3/payments/index.js deleted file mode 100644 index f6e0a8ebe3..0000000000 --- a/website/src/controllers/api-v3/payments/index.js +++ /dev/null @@ -1,232 +0,0 @@ -import _ from 'lodash' ; -import analytics from '../../../libs/api-v3/analyticsService'; -import async from 'async'; -import cc from 'coupon-code'; -import { - getUserInfo, - sendTxn as txnEmail, -} from '../../../libs/api-v3/email'; -import members from '../members'; -import moment from 'moment'; -import mongoose from 'mongoose'; -import nconf from 'nconf'; -import pushNotify from '../../../libs/api-v3/pushNotifications'; -import shared from '../../../../../common' ; - -import amazon from './amazon'; -import iap from './iap'; -import paypal from './paypal'; -import stripe from './stripe'; - -const IS_PROD = nconf.get('NODE_ENV') === 'production'; - -let api = {}; - -function revealMysteryItems (user) { - _.each(shared.content.gear.flat, function findMysteryItems (item) { - if ( - item.klass === 'mystery' && - moment().isAfter(shared.content.mystery[item.mystery].start) && - moment().isBefore(shared.content.mystery[item.mystery].end) && - !user.items.gear.owned[item.key] && - user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 - ) { - user.purchased.plan.mysteryItems.push(item.key); - } - }); -} - -api.createSubscription = function createSubscription (data, cb) { - let recipient = data.gift ? data.gift.member : data.user; - let plan = recipient.purchased.plan; - let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; - let months = Number(block.months); - - if (data.gift) { - if (plan.customerId && !plan.dateTerminated) { // User has active plan - plan.extraMonths += months; - } else { - plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); - if (!plan.dateUpdated) plan.dateUpdated = new Date(); - } - if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId - } else { - _(plan).merge({ // override with these values - planId: block.key, - customerId: data.customerId, - dateUpdated: new Date(), - gemsBought: 0, - paymentMethod: data.paymentMethod, - extraMonths: Number(plan.extraMonths) + - Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0), - dateTerminated: null, - // Specify a lastBillingDate just for Amazon Payments - // Resetted every time the subscription restarts - lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, - }).defaults({ // allow non-override if a plan was previously used - dateCreated: new Date(), - mysteryItems: [], - }).value(); - } - - // Block sub perks - let perks = Math.floor(months / 3); - if (perks) { - plan.consecutive.offset += months; - plan.consecutive.gemCapExtra += perks * 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; - plan.consecutive.trinkets += perks; - } - revealMysteryItems(recipient); - if (IS_PROD) { - if (!data.gift) txnEmail(data.user, 'subscription-begins'); - - let analyticsData = { - uuid: data.user._id, - itemPurchased: 'Subscription', - sku: `${data.paymentMethod.toLowerCase()}-subscription`, - purchaseType: 'subscribe', - paymentMethod: data.paymentMethod, - quantity: 1, - gift: Boolean(data.gift), - purchaseValue: block.price, - }; - analytics.trackPurchase(analyticsData); - } - data.user.purchased.txnCount++; - if (data.gift) { - members.sendMessage(data.user, data.gift.member, data.gift); - - let byUserName = getUserInfo(data.user, ['name']).name; - - if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { - txnEmail(data.gift.member, 'gifted-subscription', [ - {name: 'GIFTER', content: byUserName}, - {name: 'X_MONTHS_SUBSCRIPTION', content: months}, - ]); - } - - if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); - } - } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); -}; - -/** - * Sets their subscription to be cancelled later - */ -api.cancelSubscription = function cancelSubscription (data, cb) { - let plan = data.user.purchased.plan; - let now = moment(); - let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; - - plan.dateTerminated = - moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) - .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... - .toDate(); - plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated - - data.user.save(cb); - txnEmail(data.user, 'cancel-subscription'); - let analyticsData = { - uuid: data.user._id, - gaCategory: 'commerce', - gaLabel: data.paymentMethod, - paymentMethod: data.paymentMethod, - }; - analytics.track('unsubscribe', analyticsData); -}; - -api.buyGems = function buyGems (data, cb) { - let amt = data.amount || 5; - amt = data.gift ? data.gift.gems.amount / 4 : amt; - (data.gift ? data.gift.member : data.user).balance += amt; - data.user.purchased.txnCount++; - if (IS_PROD) { - if (!data.gift) txnEmail(data.user, 'donation'); - - let analyticsData = { - uuid: data.user._id, - itemPurchased: 'Gems', - sku: `${data.paymentMethod.toLowerCase()}-checkout`, - purchaseType: 'checkout', - paymentMethod: data.paymentMethod, - quantity: 1, - gift: Boolean(data.gift), - purchaseValue: amt, - }; - analytics.trackPurchase(analyticsData); - } - - if (data.gift) { - let byUsername = getUserInfo(data.user, ['name']).name; - let gemAmount = data.gift.gems.amount || 20; - - members.sendMessage(data.user, data.gift.member, data.gift); - if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { - txnEmail(data.gift.member, 'gifted-gems', [ - {name: 'GIFTER', content: byUsername}, - {name: 'X_GEMS_GIFTED', content: gemAmount}, - ]); - } - - if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); - } - } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); -}; - -api.validCoupon = function validCoupon (req, res, next) { - mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { - if (err) return next(err); - if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); - return res.sendStatus(200); - }); -}; - -api.stripeCheckout = stripe.checkout; -api.stripeSubscribeCancel = stripe.subscribeCancel; -api.stripeSubscribeEdit = stripe.subscribeEdit; - -api.paypalSubscribe = paypal.createBillingAgreement; -api.paypalSubscribeSuccess = paypal.executeBillingAgreement; -api.paypalSubscribeCancel = paypal.cancelSubscription; -api.paypalCheckout = paypal.createPayment; -api.paypalCheckoutSuccess = paypal.executePayment; -api.paypalIPN = paypal.ipn; - -api.amazonVerifyAccessToken = amazon.verifyAccessToken; -api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -api.amazonCheckout = amazon.checkout; -api.amazonSubscribe = amazon.subscribe; -api.amazonSubscribeCancel = amazon.subscribeCancel; - -api.iapAndroidVerify = iap.androidVerify; -api.iapIosVerify = iap.iosVerify; - -module.exports = api; diff --git a/website/src/controllers/api-v3/payments/stripe.js b/website/src/controllers/api-v3/payments/stripe.js deleted file mode 100644 index 5582d33ca1..0000000000 --- a/website/src/controllers/api-v3/payments/stripe.js +++ /dev/null @@ -1,135 +0,0 @@ -import nconf from 'nconf'; -import stripeModule from 'stripe'; -import async from 'async'; -import payments from './index'; -import { model as User } from '../../models/user'; -import shared from '../../../../../common'; -import mongoose from 'mongoose'; -import cc from 'coupon-code'; - -const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); - -let api = {}; -/* - Setup Stripe response when posting payment - */ -api.checkout = function checkout (req, res) { - let token = req.body.id; - let user = res.locals.user; - let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; - - async.waterfall([ - function stripeCharge (cb) { - if (sub) { - async.waterfall([ - function handleCoupon (cb2) { - if (!sub.discount) return cb2(null, null); - if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); - mongoose.model('Coupon').findOne({_id: cc.validate(req.query.coupon), event: sub.key}, cb2); - }, - function createCustomer (coupon, cb2) { - if (sub.discount && !coupon) return cb2('Invalid coupon code.'); - let customer = { - email: req.body.email, - metadata: {uuid: user._id}, - card: token, - plan: sub.key, - }; - stripe.customers.create(customer, cb2); - }, - ], cb); - } else { - let amount; - if (!gift) { - amount = '500'; - } else if (gift.type === 'subscription') { - amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; - } else { - amount = `${gift.gems.amount / 4 * 100}`; - } - stripe.charges.create({ - amount, - currency: 'usd', - card: token, - }, cb); - } - }, - function saveUserData (response, cb) { - if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', sub}, cb); - async.waterfall([ - function findUser (cb2) { - User.findById(gift ? gift.uuid : undefined, cb2); - }, - function prepData (member, cb2) { - let data = {user, customerId: response.id, paymentMethod: 'Stripe', gift}; - let method = 'buyGems'; - if (gift) { - gift.member = member; - if (gift.type === 'subscription') method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - payments[method](data, cb2); - }, - ], cb); - }, - ], function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.sendStatus(200); - user = token = null; - }); -}; - -api.subscribeCancel = function subscribeCancel (req, res) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) { - return res.status(401).json({err: 'User does not have a plan subscription'}); - } - - async.auto({ - getCustomer: function getCustomer (cb) { - stripe.customers.retrieve(user.purchased.plan.customerId, cb); - }, - deleteCustomer: ['getCustomer', function deleteCustomer (cb) { - stripe.customers.del(user.purchased.plan.customerId, cb); - }], - cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { - let data = { - user, - nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds - paymentMethod: 'Stripe', - }; - payments.cancelSubscription(data, cb); - }], - }, function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.redirect('/'); - user = null; - }); -}; - -api.subscribeEdit = function subscribeEdit (req, res) { - let token = req.body.id; - let user = res.locals.user; - let userId = user.purchased.plan.customerId; - let subscriptionId; - - async.waterfall([ - function listSubscriptions (cb) { - stripe.customers.listSubscriptions(userId, cb); - }, - function updateSubscription (response, cb) { - subscriptionId = response.data[0].id; - stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); - }, - function saveUser (response, cb) { - user.save(cb); - }, - ], function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.sendStatus(200); - token = user = userId = subscriptionId; - }); -}; - -module.exports = api; diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 8c01663c10..63b589f6fc 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,112 +1,128 @@ -var amazonPayments = require('amazon-payments'); -var mongoose = require('mongoose'); -var moment = require('moment'); -var nconf = require('nconf'); -var async = require('async'); -var User = require('mongoose').model('User'); -var shared = require('../../../../common'); -var payments = require('./index'); -var cc = require('coupon-code'); -var isProd = nconf.get('NODE_ENV') === 'production'; +import async from 'async'; +import cc from 'coupon-code'; +import mongoose from 'mongoose'; +import moment from 'moment'; +import payments from './index'; +import shared from '../../../../../common'; +import { model as User } from '../../../models/user'; +import { + NotFound, + NotAuthorized, + BadRequest, +} from '../../../libs/api-v3/errors'; +import amz from '../../../libs/api-v3/amazonPayments'; -var amzPayment = amazonPayments.connect({ - environment: amazonPayments.Environment[isProd ? 'Production' : 'Sandbox'], - sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), - mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), - mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), - clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID') -}); +let api = {}; -exports.verifyAccessToken = function(req, res, next){ - if(!req.body || !req.body['access_token']){ - return res.status(400).json({err: 'Access token not supplied.'}); - } - - amzPayment.api.getTokenInfo(req.body['access_token'], function(err, tokenInfo){ - if(err) return res.status(400).json({err:err}); - - res.sendStatus(200); - }); +/** + * @api {post} /api/v3/payments/amazon/verifyAccessToken verify access token + * @apiVersion 3.0.0 + * @apiName AmazonVerifyAccessToken + * @apiGroup Payments + * @apiParam {string} access_token the access token + * @apiSuccess {} empty + **/ +api.verifyAccessToken = { + method: 'POST', + url: '/payments/amazon/verifyAccessToken', + async handler (req, res) { + await amz.getTokenInfo(req.body.access_token) + .then(() => { + res.respond(200, {}); + }).catch( (error) => { + throw new BadRequest(error.body.error_description); + }); + }, }; -exports.createOrderReferenceId = function(req, res, next){ - if(!req.body || !req.body.billingAgreementId){ - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - amzPayment.offAmazonPayments.createOrderReferenceForId({ - Id: req.body.billingAgreementId, - IdType: 'BillingAgreement', - ConfirmNow: false - }, function(err, response){ - if(err) return next(err); - if(!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId){ - return next(new Error('Missing attributes in Amazon response.')); +/** + * @api {post} /api/v3/payments/amazon/createOrderReferenceId create order reference id + * @apiVersion 3.0.0 + * @apiName AmazonCreateOrderReferenceId + * @apiGroup Payments + * @apiParam {string} billingAgreementId billing agreement id + * @apiSuccess {object} object containing { orderReferenceId } + **/ +api.createOrderReferenceId = { + method: 'POST', + url: '/payments/amazon/createOrderReferenceId', + async handler (req, res) { + if (!req.body.billingAgreementId) { + throw new BadRequest(res.t('missingBillingAgreementId')); } - res.json({ - orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId + let response = await amz.createOrderReferenceId({ + Id: req.body.billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + }).then(() => { + res.respond(200, { + orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, + }); + }).catch(errStr => { + throw new BadRequest(res.t(errStr)); }); - }); + }, }; -exports.checkout = function(req, res, next){ - if(!req.body || !req.body.orderReferenceId){ +/* +api.checkout = function checkout (req, res, next) { + if (!req.body || !req.body.orderReferenceId) { return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); } - var gift = req.body.gift; - var user = res.locals.user; - var orderReferenceId = req.body.orderReferenceId; - var amount = 5; + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let amount = 5; - if(gift){ - if(gift.type === 'gems'){ - amount = gift.gems.amount/4; - }else if(gift.type === 'subscription'){ + if (gift) { + if (gift.type === 'gems') { + amount = gift.gems.amount / 4; + } else if (gift.type === 'subscription') { amount = shared.content.subscriptionBlocks[gift.subscription.key].price; } } async.series({ - setOrderReferenceDetails: function(cb){ + setOrderReferenceDetails (cb) { amzPayment.offAmazonPayments.setOrderReferenceDetails({ AmazonOrderReferenceId: orderReferenceId, OrderReferenceAttributes: { OrderTotal: { CurrencyCode: 'USD', - Amount: amount + Amount: amount, }, SellerNote: 'HabitRPG Payment', SellerOrderAttributes: { SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG' - } - } + StoreName: 'HabitRPG', + }, + }, }, cb); }, - confirmOrderReference: function(cb){ + confirmOrderReference (cb) { amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId + AmazonOrderReferenceId: orderReferenceId, }, cb); }, - authorize: function(cb){ + authorize (cb) { amzPayment.offAmazonPayments.authorize({ AmazonOrderReferenceId: orderReferenceId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { CurrencyCode: 'USD', - Amount: amount + Amount: amount, }, SellerAuthorizationNote: 'HabitRPG Payment', TransactionTimeout: 0, - CaptureNow: true - }, function(err, res){ - if(err) return cb(err); + CaptureNow: true, + }, function checkAuthorizationStatus (err) { + if (err) return cb(err); - if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { return cb(new Error('The payment was not successfull.')); } @@ -114,64 +130,65 @@ exports.checkout = function(req, res, next){ }); }, - closeOrderReference: function(cb){ + closeOrderReference (cb) { amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId + AmazonOrderReferenceId: orderReferenceId, }, cb); }, - executePayment: function(cb){ + executePayment (cb) { async.waterfall([ - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }, - function(member, cb2){ - var data = {user:user, paymentMethod:'Amazon Payments'}; - var method = 'buyGems'; + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function executeAmazonPayment (member, cb2) { + let data = {user, paymentMethod: 'Amazon Payments'}; + let method = 'buyGems'; - if (gift){ - if (gift.type == 'subscription') method = 'createSubscription'; + if (gift) { + if (gift.type === 'subscription') method = 'createSubscription'; gift.member = member; data.gift = gift; data.paymentMethod = 'Gift'; } payments[method](data, cb2); - } + }, ], cb); - } - }, function(err, results){ - if(err) return next(err); + }, + }, function result (err) { + if (err) return next(err); res.sendStatus(200); }); - }; -exports.subscribe = function(req, res, next){ - if(!req.body || !req.body['billingAgreementId']){ +api.subscribe = function subscribe (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); } - var billingAgreementId = req.body.billingAgreementId; - var sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; - var coupon = req.body.coupon; - var user = res.locals.user; + let billingAgreementId = req.body.billingAgreementId; + let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; + let coupon = req.body.coupon; + let user = res.locals.user; - if(!sub){ + if (!sub) { return res.status(400).json({err: 'Subscription plan not found.'}); } async.series({ - applyDiscount: function(cb){ + applyDiscount (cb) { if (!sub.discount) return cb(); if (!coupon) return cb(new Error('Please provide a coupon code for this plan.')); - mongoose.model('Coupon').findOne({_id:cc.validate(coupon), event:sub.key}, function(err, coupon){ - if(err) return cb(err); - if(!coupon) return cb(new Error('Coupon code not found.')); + mongoose.model('Coupon').findOne({_id: cc.validate(coupon), event: sub.key}, function couponResult (err) { + if (err) return cb(err); + if (!coupon) return cb(new Error('Coupon code not found.')); cb(); }); }, - setBillingAgreementDetails: function(cb){ + setBillingAgreementDetails (cb) { amzPayment.offAmazonPayments.setBillingAgreementDetails({ AmazonBillingAgreementId: billingAgreementId, BillingAgreementAttributes: { @@ -179,25 +196,25 @@ exports.subscribe = function(req, res, next){ SellerBillingAgreementAttributes: { SellerBillingAgreementId: shared.uuid(), StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription' - } - } + CustomInformation: 'HabitRPG Subscription', + }, + }, }, cb); }, - confirmBillingAgreement: function(cb){ + confirmBillingAgreement (cb) { amzPayment.offAmazonPayments.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId + AmazonBillingAgreementId: billingAgreementId, }, cb); }, - authorizeOnBillingAgreeement: function(cb){ + authorizeOnBillingAgreement (cb) { amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ AmazonBillingAgreementId: billingAgreementId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { CurrencyCode: 'USD', - Amount: sub.price + Amount: sub.price, }, SellerAuthorizationNote: 'HabitRPG Subscription Payment', TransactionTimeout: 0, @@ -205,67 +222,70 @@ exports.subscribe = function(req, res, next){ SellerNote: 'HabitRPG Subscription Payment', SellerOrderAttributes: { SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG' - } - }, function(err, res){ - if(err) return cb(err); + StoreName: 'HabitRPG', + }, + }, function billingAgreementResult (err) { + if (err) return cb(err); - if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ - return cb(new Error('The payment was not successfull.')); + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successful.')); } return cb(); }); }, - createSubscription: function(cb){ + createSubscription (cb) { payments.createSubscription({ - user: user, + user, customerId: billingAgreementId, paymentMethod: 'Amazon Payments', - sub: sub + sub, }, cb); - } - }, function(err, results){ - if(err) return next(err); + }, + }, function subscribeResult (err) { + if (err) return next(err); res.sendStatus(200); }); }; -exports.subscribeCancel = function(req, res, next){ - var user = res.locals.user; +api.subscribeCancel = function subscribeCancel (req, res, next) { + let user = res.locals.user; if (!user.purchased.plan.customerId) return res.status(401).json({err: 'User does not have a plan subscription'}); - var billingAgreementId = user.purchased.plan.customerId; + let billingAgreementId = user.purchased.plan.customerId; async.series({ - closeBillingAgreement: function(cb){ + closeBillingAgreement (cb) { amzPayment.offAmazonPayments.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId + AmazonBillingAgreementId: billingAgreementId, }, cb); }, - cancelSubscription: function(cb){ - var data = { - user: user, + cancelSubscription (cb) { + let data = { + user, // Date of next bill nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), - paymentMethod: 'Amazon Payments' + paymentMethod: 'Amazon Payments', }; payments.cancelSubscription(data, cb); - } - }, function(err, results){ + }, + }, function subscribeCancelResult (err) { if (err) return next(err); // don't json this, let toString() handle errors - if(req.query.noRedirect){ + if (req.query.noRedirect) { res.sendStatus(200); - }else{ + } else { res.redirect('/'); } user = null; }); }; +*/ + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/iap.js b/website/src/controllers/top-level/payments/iap.js index 829482ed67..5de66b0452 100644 --- a/website/src/controllers/top-level/payments/iap.js +++ b/website/src/controllers/top-level/payments/iap.js @@ -1,67 +1,66 @@ -var iap = require('in-app-purchase'); -var async = require('async'); -var payments = require('./index'); -var nconf = require('nconf'); +import iap from 'in-app-purchase'; +import whatThis from 'in-app-purchase'; +import payments from './index'; +import nconf from 'nconf'; -var inAppPurchase = require('in-app-purchase'); -inAppPurchase.config({ +iap.config({ // this is the path to the directory containing iap-sanbox/iap-live files - googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR') + googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), }); // Validation ERROR Codes -var INVALID_PAYLOAD = 6778001; -var CONNECTION_FAILED = 6778002; -var PURCHASE_EXPIRED = 6778003; +const INVALID_PAYLOAD = 6778001; +/* const CONNECTION_FAILED = 6778002; +const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? -exports.androidVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; +let api = {}; - iap.setup(function (error) { +/* +api.androidVerify = function androidVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function googleSetupResult (error) { if (error) { - var resObj = { + let resObj = { ok: false, - data: 'IAP Error' + data: 'IAP Error', }; return res.json(resObj); - } - /* - google receipt must be provided as an object - { - "data": "{stringified data object}", - "signature": "signature from google" - } - */ - var testObj = { + // google receipt must be provided as an object + // { + // "data": "{stringified data object}", + // "signature": "signature from google" + // } + let testObj = { data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature + signature: iapBody.transaction.signature, }; // iap is ready - iap.validate(iap.GOOGLE, testObj, function (err, googleRes) { + iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { if (err) { - var resObj = { + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: err.toString() - } + message: err.toString(), + }, }; return res.json(resObj); } if (iap.isValidated(googleRes)) { - var resObj = { + let resObj = { ok: true, - data: googleRes + data: googleRes, }; - payments.buyGems({user:user, paymentMethod:'IAP GooglePlay', amount: 5.25}); + payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); return res.json(resObj); } @@ -69,87 +68,89 @@ exports.androidVerify = function(req, res, next) { }); }; -exports.iosVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; +exports.iosVerify = function iosVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; - iap.setup(function (error) { + iap.setup(function iosSetupResult (error) { if (error) { - var resObj = { + let resObj = { ok: false, - data: 'IAP Error' + data: 'IAP Error', }; return res.json(resObj); - } - //iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) { + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { if (err) { - var resObj = { + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: err.toString() - } + message: err.toString(), + }, }; return res.json(resObj); } if (iap.isValidated(appleRes)) { - var purchaseDataList = iap.getPurchaseData(appleRes); + let purchaseDataList = iap.getPurchaseData(appleRes); if (purchaseDataList.length > 0) { - var correctReceipt = true; - for (var index in purchaseDataList) { + let correctReceipt = true; + for (let index of purchaseDataList) { switch (purchaseDataList[index].productId) { case 'com.habitrpg.ios.Habitica.4gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 1}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); break; case 'com.habitrpg.ios.Habitica.8gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 2}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); break; case 'com.habitrpg.ios.Habitica.20gems': case 'com.habitrpg.ios.Habitica.21gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 5.25}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); break; case 'com.habitrpg.ios.Habitica.42gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 10.5}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); break; default: correctReceipt = false; } } if (correctReceipt) { - var resObj = { + let resObj = { ok: true, - data: appleRes + data: appleRes, }; // yay good! return res.json(resObj); } } - //wrong receipt content - var resObj = { + // wrong receipt content + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: 'Incorrect receipt content' - } + message: 'Incorrect receipt content', + }, }; return res.json(resObj); } - //invalid receipt - var resObj = { + // invalid receipt + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: 'Invalid receipt' - } + message: 'Invalid receipt', + }, }; return res.json(resObj); }); }); }; +*/ + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/index.js b/website/src/controllers/top-level/payments/index.js index 1652e05b8d..5e413fd47c 100644 --- a/website/src/controllers/top-level/payments/index.js +++ b/website/src/controllers/top-level/payments/index.js @@ -1,207 +1,233 @@ -var _ = require('lodash'); -var shared = require('../../../../common'); -var nconf = require('nconf'); -var utils = require('./../../libs/api-v2/utils'); -var moment = require('moment'); -var isProduction = nconf.get("NODE_ENV") === "production"; -var stripe = require('./stripe'); -var paypal = require('./paypal'); -var amazon = require('./amazon'); -var members = require('../api-v2/members') -var async = require('async'); -var iap = require('./iap'); -var mongoose= require('mongoose'); -var cc = require('coupon-code'); -var pushNotify = require('./../api-v2/pushNotifications'); +import _ from 'lodash' ; +import analytics from '../../../libs/api-v3/analyticsService'; +import async from 'async'; +import cc from 'coupon-code'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../../libs/api-v3/email'; +import members from '../members'; +import moment from 'moment'; +import mongoose from 'mongoose'; +import nconf from 'nconf'; +import pushNotify from '../../../libs/api-v3/pushNotifications'; +import shared from '../../../../../common' ; -function revealMysteryItems(user) { - _.each(shared.content.gear.flat, function(item) { +import amazon from './amazon'; +import iap from './iap'; +import paypal from './paypal'; +import stripe from './stripe'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +function revealMysteryItems (user) { + _.each(shared.content.gear.flat, function findMysteryItems (item) { if ( item.klass === 'mystery' && moment().isAfter(shared.content.mystery[item.mystery].start) && moment().isBefore(shared.content.mystery[item.mystery].end) && !user.items.gear.owned[item.key] && - !~user.purchased.plan.mysteryItems.indexOf(item.key) + user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 ) { user.purchased.plan.mysteryItems.push(item.key); } }); } -exports.createSubscription = function(data, cb) { - var recipient = data.gift ? data.gift.member : data.user; - //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // TODO double-check, this should never be the case - var p = recipient.purchased.plan; - var block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; - var months = +block.months; +api.createSubscription = function createSubscription (data, cb) { + let recipient = data.gift ? data.gift.member : data.user; + let plan = recipient.purchased.plan; + let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; + let months = Number(block.months); if (data.gift) { - if (p.customerId && !p.dateTerminated) { // User has active plan - p.extraMonths += months; + if (plan.customerId && !plan.dateTerminated) { // User has active plan + plan.extraMonths += months; } else { - p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); - if (!p.dateUpdated) p.dateUpdated = new Date(); + plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); + if (!plan.dateUpdated) plan.dateUpdated = new Date(); } - if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId } else { - _(p).merge({ // override with these values + _(plan).merge({ // override with these values planId: block.key, customerId: data.customerId, dateUpdated: new Date(), gemsBought: 0, paymentMethod: data.paymentMethod, - extraMonths: +p.extraMonths - + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0), + extraMonths: Number(plan.extraMonths) + + Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0), dateTerminated: null, // Specify a lastBillingDate just for Amazon Payments // Resetted every time the subscription restarts - lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined + lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, }).defaults({ // allow non-override if a plan was previously used dateCreated: new Date(), - mysteryItems: [] + mysteryItems: [], }).value(); } // Block sub perks - var perks = Math.floor(months/3); + let perks = Math.floor(months / 3); if (perks) { - p.consecutive.offset += months; - p.consecutive.gemCapExtra += perks*5; - if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25; - p.consecutive.trinkets += perks; + plan.consecutive.offset += months; + plan.consecutive.gemCapExtra += perks * 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; + plan.consecutive.trinkets += perks; } revealMysteryItems(recipient); - if(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'subscription-begins'); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'subscription-begins'); - var analyticsData = { + let analyticsData = { uuid: data.user._id, itemPurchased: 'Subscription', - sku: data.paymentMethod.toLowerCase() + '-subscription', + sku: `${data.paymentMethod.toLowerCase()}-subscription`, purchaseType: 'subscribe', paymentMethod: data.paymentMethod, quantity: 1, - gift: !!data.gift, // coerced into a boolean - purchaseValue: block.price - } - utils.analytics.trackPurchase(analyticsData); + gift: Boolean(data.gift), + purchaseValue: block.price, + }; + analytics.trackPurchase(analyticsData); } data.user.purchased.txnCount++; - if (data.gift){ + if (data.gift) { members.sendMessage(data.user, data.gift.member, data.gift); - var byUserName = utils.getUserInfo(data.user, ['name']).name; + let byUserName = getUserInfo(data.user, ['name']).name; - if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ - utils.txnEmail(data.gift.member, 'gifted-subscription', [ + if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { + txnEmail(data.gift.member, 'gifted-subscription', [ {name: 'GIFTER', content: byUserName}, - {name: 'X_MONTHS_SUBSCRIPTION', content: months} + {name: 'X_MONTHS_SUBSCRIPTION', content: months}, ]); } - if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), months + " months - by "+ byUserName); + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); } } async.parallel([ - function(cb2){data.user.save(cb2)}, - function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, ], cb); -} +}; /** * Sets their subscription to be cancelled later */ -exports.cancelSubscription = function(data, cb) { - var p = data.user.purchased.plan, - now = moment(), - remaining = data.nextBill ? moment(data.nextBill).diff(new Date, 'days') : 30; +api.cancelSubscription = function cancelSubscription (data, cb) { + let plan = data.user.purchased.plan; + let now = moment(); + let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; - p.dateTerminated = - moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) + plan.dateTerminated = + moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. TODO: moment can't add months in fractions... + .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... .toDate(); - p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated data.user.save(cb); - utils.txnEmail(data.user, 'cancel-subscription'); - var analyticsData = { + txnEmail(data.user, 'cancel-subscription'); + let analyticsData = { uuid: data.user._id, gaCategory: 'commerce', gaLabel: data.paymentMethod, - paymentMethod: data.paymentMethod - } - utils.analytics.track('unsubscribe', analyticsData); -} + paymentMethod: data.paymentMethod, + }; + analytics.track('unsubscribe', analyticsData); +}; -exports.buyGems = function(data, cb) { - var amt = data.amount || 5; - amt = data.gift ? data.gift.gems.amount/4 : amt; +api.buyGems = function buyGems (data, cb) { + let amt = data.amount || 5; + amt = data.gift ? data.gift.gems.amount / 4 : amt; (data.gift ? data.gift.member : data.user).balance += amt; data.user.purchased.txnCount++; - if(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'donation'); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); - var analyticsData = { + let analyticsData = { uuid: data.user._id, itemPurchased: 'Gems', - sku: data.paymentMethod.toLowerCase() + '-checkout', + sku: `${data.paymentMethod.toLowerCase()}-checkout`, purchaseType: 'checkout', paymentMethod: data.paymentMethod, quantity: 1, - gift: !!data.gift, // coerced into a boolean - purchaseValue: amt - } - utils.analytics.trackPurchase(analyticsData); + gift: Boolean(data.gift), + purchaseValue: amt, + }; + analytics.trackPurchase(analyticsData); } - if (data.gift){ - var byUsername = utils.getUserInfo(data.user, ['name']).name; - var gemAmount = data.gift.gems.amount || 20; + if (data.gift) { + let byUsername = getUserInfo(data.user, ['name']).name; + let gemAmount = data.gift.gems.amount || 20; members.sendMessage(data.user, data.gift.member, data.gift); - if(data.gift.member.preferences.emailNotifications.giftedGems !== false){ - utils.txnEmail(data.gift.member, 'gifted-gems', [ + if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { + txnEmail(data.gift.member, 'gifted-gems', [ {name: 'GIFTER', content: byUsername}, - {name: 'X_GEMS_GIFTED', content: gemAmount} + {name: 'X_GEMS_GIFTED', content: gemAmount}, ]); } - if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), gemAmount + ' Gems - by '+byUsername); + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); } } async.parallel([ - function(cb2){data.user.save(cb2)}, - function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, ], cb); -} +}; -exports.validCoupon = function(req, res, next){ - mongoose.model('Coupon').findOne({_id:cc.validate(req.params.code), event:'google_6mo'}, function(err, coupon){ +api.validCoupon = function validCoupon (req, res, next) { + mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { if (err) return next(err); - if (!coupon) return res.status(401).json({err:"Invalid coupon code"}); + if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); return res.sendStatus(200); }); -} +}; -exports.stripeCheckout = stripe.checkout; -exports.stripeSubscribeCancel = stripe.subscribeCancel; -exports.stripeSubscribeEdit = stripe.subscribeEdit; +api.stripeCheckout = stripe.checkout; +api.stripeSubscribeCancel = stripe.subscribeCancel; +api.stripeSubscribeEdit = stripe.subscribeEdit; -exports.paypalSubscribe = paypal.createBillingAgreement; -exports.paypalSubscribeSuccess = paypal.executeBillingAgreement; -exports.paypalSubscribeCancel = paypal.cancelSubscription; -exports.paypalCheckout = paypal.createPayment; -exports.paypalCheckoutSuccess = paypal.executePayment; -exports.paypalIPN = paypal.ipn; +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; -exports.amazonVerifyAccessToken = amazon.verifyAccessToken; -exports.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -exports.amazonCheckout = amazon.checkout; -exports.amazonSubscribe = amazon.subscribe; -exports.amazonSubscribeCancel = amazon.subscribeCancel; +api.amazonVerifyAccessToken = amazon.verifyAccessToken; +api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +api.amazonCheckout = amazon.checkout; +api.amazonSubscribe = amazon.subscribe; +api.amazonSubscribeCancel = amazon.subscribeCancel; -exports.iapAndroidVerify = iap.androidVerify; -exports.iapIosVerify = iap.iosVerify; +api.iapAndroidVerify = iap.androidVerify; +api.iapIosVerify = iap.iosVerify; + +// module.exports = api; +module.exports = {}; // @TODO HEREHERE diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 766ee85139..046a6f52cc 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -5,10 +5,10 @@ var _ = require('lodash'); var url = require('url'); var User = require('mongoose').model('User'); var payments = require('./index'); -var logger = require('../../libs/api-v2/logging'); +var logger = require('../../../libs/api-v2/logging'); var ipn = require('paypal-ipn'); var paypal = require('paypal-rest-sdk'); -var shared = require('../../../../common'); +var shared = require('../../../../../common'); var mongoose = require('mongoose'); var cc = require('coupon-code'); @@ -31,6 +31,7 @@ var parseErr = function(res, err){ return res.status(400).json({err:error}); } +/* exports.createBillingAgreement = function(req,res,next){ var sub = shared.content.subscriptionBlocks[req.query.sub]; async.waterfall([ @@ -190,11 +191,13 @@ exports.cancelSubscription = function(req, res, next){ user = null; }); } +*/ /** * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their * recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution */ +/* exports.ipn = function(req, res, next) { console.log('IPN Called'); res.sendStatus(200); // Must respond to PayPal IPN request with an empty 200 first @@ -213,4 +216,4 @@ exports.ipn = function(req, res, next) { } }); }; - +*/ diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index 1a1085227c..765ccba8f6 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -1,123 +1,137 @@ -var nconf = require('nconf'); -var stripe = require('stripe')(nconf.get('STRIPE_API_KEY')); -var async = require('async'); -var payments = require('./index'); -var User = require('mongoose').model('User'); -var shared = require('../../../../common'); -var mongoose = require('mongoose'); -var cc = require('coupon-code'); +import nconf from 'nconf'; +import stripeModule from 'stripe'; +import async from 'async'; +import payments from './index'; +import { model as User } from '../../../models/user'; +import shared from '../../../../../common'; +import mongoose from 'mongoose'; +import cc from 'coupon-code'; +const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); + +let api = {}; /* Setup Stripe response when posting payment */ -exports.checkout = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - var sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; +/* +api.checkout = function checkout (req, res) { + let token = req.body.id; + let user = res.locals.user; + let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; async.waterfall([ - function(cb){ + function stripeCharge (cb) { if (sub) { async.waterfall([ - function(cb2){ + function handleCoupon (cb2) { if (!sub.discount) return cb2(null, null); if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); - mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb2); + mongoose.model('Coupon').findOne({_id: cc.validate(req.query.coupon), event: sub.key}, cb2); }, - function(coupon, cb2){ + function createCustomer (coupon, cb2) { if (sub.discount && !coupon) return cb2('Invalid coupon code.'); - var customer = { + let customer = { email: req.body.email, metadata: {uuid: user._id}, card: token, - plan: sub.key + plan: sub.key, }; stripe.customers.create(customer, cb2); - } + }, ], cb); } else { + let amount; + if (!gift) { + amount = '500'; + } else if (gift.type === 'subscription') { + amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; + } else { + amount = `${gift.gems.amount / 4 * 100}`; + } stripe.charges.create({ - amount: !gift ? '500' //"500" = $5 - : gift.type=='subscription' ? ''+shared.content.subscriptionBlocks[gift.subscription.key].price*100 - : ''+gift.gems.amount/4*100, + amount, currency: 'usd', - card: token + card: token, }, cb); } }, - function(response, cb) { - if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb); + function saveUserData (response, cb) { + if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', sub}, cb); async.waterfall([ - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }, - function(member, cb2){ - var data = {user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift}; - var method = 'buyGems'; + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function prepData (member, cb2) { + let data = {user, customerId: response.id, paymentMethod: 'Stripe', gift}; + let method = 'buyGems'; if (gift) { gift.member = member; - if (gift.type=='subscription') method = 'createSubscription'; + if (gift.type === 'subscription') method = 'createSubscription'; data.paymentMethod = 'Gift'; } payments[method](data, cb2); - } + }, ], cb); - } - ], function(err){ + }, + ], function handleResponse (err) { if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors res.sendStatus(200); user = token = null; }); }; -exports.subscribeCancel = function(req, res, next) { - var user = res.locals.user; - if (!user.purchased.plan.customerId) +api.subscribeCancel = function subscribeCancel (req, res) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) { return res.status(401).json({err: 'User does not have a plan subscription'}); + } async.auto({ - get_cus: function(cb){ + getCustomer: function getCustomer (cb) { stripe.customers.retrieve(user.purchased.plan.customerId, cb); }, - del_cus: ['get_cus', function(cb, results){ + deleteCustomer: ['getCustomer', function deleteCustomer (cb) { stripe.customers.del(user.purchased.plan.customerId, cb); }], - cancel_sub: ['get_cus', function(cb, results) { - var data = { - user: user, - nextBill: results.get_cus.subscription.current_period_end*1000, // timestamp is in seconds - paymentMethod: 'Stripe' + cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { + let data = { + user, + nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds + paymentMethod: 'Stripe', }; payments.cancelSubscription(data, cb); - }] - }, function(err, results){ + }], + }, function handleResponse (err) { if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors res.redirect('/'); user = null; }); }; -exports.subscribeEdit = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - var user_id = user.purchased.plan.customerId; - var sub_id; +api.subscribeEdit = function subscribeEdit (req, res) { + let token = req.body.id; + let user = res.locals.user; + let userId = user.purchased.plan.customerId; + let subscriptionId; async.waterfall([ - function(cb){ - stripe.customers.listSubscriptions(user_id, cb); + function listSubscriptions (cb) { + stripe.customers.listSubscriptions(userId, cb); }, - function(response, cb) { - sub_id = response.data[0].id; - console.warn(sub_id); - console.warn([user_id, sub_id, { card: token }]); - stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb); + function updateSubscription (response, cb) { + subscriptionId = response.data[0].id; + stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); }, - function(response, cb) { + function saveUser (response, cb) { user.save(cb); - } - ], function(err, saved){ + }, + ], function handleResponse (err) { if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors res.sendStatus(200); - token = user = user_id = sub_id; + token = user = userId = subscriptionId; }); }; +*/ + +module.exports = api; diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js new file mode 100644 index 0000000000..9cff7cc763 --- /dev/null +++ b/website/src/libs/api-v3/amazonPayments.js @@ -0,0 +1,38 @@ +import amazonPayments from 'amazon-payments'; +import nconf from 'nconf'; +import Q from 'q'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +let amzPayment = amazonPayments.connect({ + environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], + sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), + mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), + mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), + clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), +}); + +api.getTokenInfo = (token) => { + return new Promise((resolve, reject) => { + amzPayment.api.getTokenInfo(token, (err, tokenInfo) => { + if (err) return reject(err); + return resolve(tokenInfo); + }); + }); +}; + +api.createOrderReferenceId = (inputSet) => { + return new Promise((resolve, reject) => { + amzPayment.offAmazonPayments.createOrderReferenceForId(inputSet, (err, response) => { + if (err) return reject(err); + if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { + return reject('missingAttributesFromAmazon'); + } + return resolve(response); + }); + }); +}; + +module.exports = api; From d27bbbe99423ea913e1b55e24d4bdde9271fac11 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Fri, 15 Apr 2016 15:40:44 +0000 Subject: [PATCH 687/976] some locales work --- common/locales/en/api-v3.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index b68a72b936..1d29c9e762 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -174,6 +174,5 @@ "resetComplete": "Reset has completed", "missingAccessToken": "The request is missing a required parameter : access_token", "missingBillingAgreementId": "Missing billing agreement id", - "missingAttributesFromAmazon": "Missing attributes from Amazon", - "errorFromAmazon": "Error from Amazon" + "missingAttributesFromAmazon": "Missing attributes from Amazon" } From 2bb36b5e0427f0ac40560462c325b7a4978131bc Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Fri, 15 Apr 2016 16:14:14 +0000 Subject: [PATCH 688/976] things are broken with nconf --- tasks/gulp-tests.js | 2 +- test/api/v3/unit/libs/amazonPayments.test.js | 14 +++++++++++++- website/src/controllers/api-v3/auth.js | 3 +++ .../src/controllers/top-level/payments/index.js | 2 +- .../top-level/payments/paypalBillingSetup.js | 2 +- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index fba9d85721..2cdc301b44 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -359,7 +359,7 @@ gulp.task('test:api-v3:unit', (done) => { }); gulp.task('test:api-v3:unit:watch', () => { - gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/libs/*'], ['test:api-v3:unit']); + gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/libs/*', 'website/src/controllers/**/*'], ['test:api-v3:unit']); }); gulp.task('test:api-v3:integration', (done) => { diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index a60a8b4633..922029aae4 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -1,6 +1,7 @@ import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; +import amazonPayments as amzStub from 'amazon-payments'; -describe.only('amazonPayments', () => { +describe('amazonPayments', () => { beforeEach(() => { }); @@ -14,6 +15,17 @@ describe.only('amazonPayments', () => { done(); } }); + + it.only('returns tokenInfo', (done) => { + let thisToken = 'this token info'; + let amzStubInstance = amzStub.connect({}); + amzStubInstance.api.getTokenInfo = (token, cb) => { + return cb(undefined, thisToken); + }; + let result = await amz.getTokenInfo; + console.log('+++ +++ result:', result); + expect(result).to.eql(thisToken); + }); }); describe('#createOrderReferenceId', () => { diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 640003bb6d..aceaaa5590 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,6 +2,8 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; import nconf from 'nconf'; +// import setupNconf from '../../libs/api-v3/setupNconf'; +// setupNconf(); import { authWithHeaders, } from '../../middlewares/api-v3/auth'; @@ -459,6 +461,7 @@ api.updateEmail = { }, }; +// console.log('+++ ++ secret:', nconf.get('USER')); const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); // Internal route diff --git a/website/src/controllers/top-level/payments/index.js b/website/src/controllers/top-level/payments/index.js index 5e413fd47c..6c9ddea60d 100644 --- a/website/src/controllers/top-level/payments/index.js +++ b/website/src/controllers/top-level/payments/index.js @@ -6,7 +6,7 @@ import { getUserInfo, sendTxn as txnEmail, } from '../../../libs/api-v3/email'; -import members from '../members'; +import members from '../../api-v3/members'; import moment from 'moment'; import mongoose from 'mongoose'; import nconf from 'nconf'; diff --git a/website/src/controllers/top-level/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js index 2effcbd81d..d8a2081a8e 100644 --- a/website/src/controllers/top-level/payments/paypalBillingSetup.js +++ b/website/src/controllers/top-level/payments/paypalBillingSetup.js @@ -7,7 +7,7 @@ var nconf = require('nconf'); _ = require('lodash'); nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); var paypal = require('paypal-rest-sdk'); -var blocks = require('../../../../common').content.subscriptionBlocks; +var blocks = require('../../../../../common').content.subscriptionBlocks; var live = nconf.get('PAYPAL:mode')=='live'; var OP = 'create'; // list create update remove From 4a45fc1c37d11cd72fa52a6b75be3f1f0a106553 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Fri, 15 Apr 2016 16:40:24 +0000 Subject: [PATCH 689/976] working on amazon payment promisification --- test/api/v3/unit/libs/amazonPayments.test.js | 12 ++++++------ website/src/controllers/api-v3/auth.js | 5 ++--- website/src/controllers/api-v3/chat.js | 3 +++ website/src/libs/api-v3/amazonPayments.js | 1 - 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index 922029aae4..7cc3683947 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -1,30 +1,30 @@ import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; -import amazonPayments as amzStub from 'amazon-payments'; +import * as amzStub from 'amazon-payments'; describe('amazonPayments', () => { - beforeEach(() => { }); describe('#getTokenInfo', () => { it('validates access_token parameter', async (done) => { try { - let result = await amz.getTokenInfo(); + await amz.getTokenInfo(); } catch (e) { expect(e.type).to.eql('invalid_request'); done(); } }); - it.only('returns tokenInfo', (done) => { + it('returns tokenInfo', async (done) => { let thisToken = 'this token info'; let amzStubInstance = amzStub.connect({}); amzStubInstance.api.getTokenInfo = (token, cb) => { return cb(undefined, thisToken); }; - let result = await amz.getTokenInfo; - console.log('+++ +++ result:', result); + let result = await amz.getTokenInfo(); + // console.log('+++ +++ result:', result); expect(result).to.eql(thisToken); + done(); }); }); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index aceaaa5590..050b36455b 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,8 +2,8 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; import nconf from 'nconf'; -// import setupNconf from '../../libs/api-v3/setupNconf'; -// setupNconf(); +import setupNconf from '../../libs/api-v3/setupNconf'; +setupNconf(); import { authWithHeaders, } from '../../middlewares/api-v3/auth'; @@ -461,7 +461,6 @@ api.updateEmail = { }, }; -// console.log('+++ ++ secret:', nconf.get('USER')); const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); // Internal route diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 24e4ea8bab..8a8e433aad 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -12,7 +12,10 @@ import _ from 'lodash'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; +import setupNconf from '../../libs/api-v3/setupNconf'; +setupNconf(); +console.log('+++ +++ this:', nconf.get('FLAG_REPORT_EMAIL')); const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; }); diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index 9cff7cc763..e89272287b 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -1,6 +1,5 @@ import amazonPayments from 'amazon-payments'; import nconf from 'nconf'; -import Q from 'q'; const IS_PROD = nconf.get('NODE_ENV') === 'production'; From ee6092d7d262bd6bdc9b91de6af46e32de1be8be Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 15 Apr 2016 21:31:40 +0000 Subject: [PATCH 690/976] refactor(payments): PayPal setup linting pass --- .../top-level/payments/paypalBillingSetup.js | 135 +++++++++--------- 1 file changed, 70 insertions(+), 65 deletions(-) diff --git a/website/src/controllers/top-level/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js index d8a2081a8e..0e8170ff51 100644 --- a/website/src/controllers/top-level/payments/paypalBillingSetup.js +++ b/website/src/controllers/top-level/payments/paypalBillingSetup.js @@ -2,91 +2,96 @@ // payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this // file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), // and once for any time you need to edit the plan thereafter -var path = require('path'); -var nconf = require('nconf'); -_ = require('lodash'); +import path from 'path'; +import nconf from 'nconf'; +import _ from 'lodash'; +import paypal from 'paypal-rest-sdk'; +import shared from '../../../../../common'; + +let blocks = shared.content.subscriptionBlocks; +const BILLING_PLAN_TITLE = 'Habitica Subscription'; +const LIVE = nconf.get('PAYPAL:mode') === 'live'; +const OP = 'create'; // list create update remove + nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); -var paypal = require('paypal-rest-sdk'); -var blocks = require('../../../../../common').content.subscriptionBlocks; -var live = nconf.get('PAYPAL:mode')=='live'; - -var OP = 'create'; // list create update remove +/* eslint-disable camelcase */ paypal.configure({ - 'mode': nconf.get("PAYPAL:mode"), //sandbox or live - 'client_id': nconf.get("PAYPAL:client_id"), - 'client_secret': nconf.get("PAYPAL:client_secret") + mode: nconf.get('PAYPAL:mode'), // sandbox or live + client_id: nconf.get('PAYPAL:client_id'), + client_secret: nconf.get('PAYPAL:client_secret'), }); // https://developer.paypal.com/docs/api/#billing-plans-and-agreements -var billingPlanTitle ="Habitica Subscription"; -var billingPlanAttributes = { - "name": billingPlanTitle, - "description": billingPlanTitle, - "type": "INFINITE", - "merchant_preferences": { - "auto_bill_amount": "yes", - "cancel_url": live ? 'https://habitica.com' : 'http://localhost:3000', - "return_url": (live ? 'https://habitica.com' : 'http://localhost:3000') + '/paypal/subscribe/success' +let billingPlanAttributes = { + name: BILLING_PLAN_TITLE, + description: BILLING_PLAN_TITLE, + type: 'INFINITE', + merchant_preferences: { + auto_bill_amount: 'yes', + cancel_url: LIVE ? 'https://habitica.com' : 'http://localhost:3000', + return_url: LIVE ? 'https://habitica.com/paypal/subscribe/success' : 'http://localhost:3000/paypal/subscribe/success', }, payment_definitions: [{ - "type": "REGULAR", - "frequency": "MONTH", - "cycles": "0" - }] + type: 'REGULAR', + frequency: 'MONTH', + cycles: '0', + }], }; -_.each(blocks, function(block){ +_.each(blocks, function defineBlock (block) { block.definition = _.cloneDeep(billingPlanAttributes); _.merge(block.definition.payment_definitions[0], { - "name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)', - "frequency_interval": ""+block.months, - "amount": { - "currency": "USD", - "value": ""+block.price - } + name: `${BILLING_PLAN_TITLE} (\$${block.price} every ${block.months} months, recurring)`, + frequency_interval: `${block.months}`, + amount: { + currency: 'USD', + value: `${block.price}`, + }, }); -}) +}); -switch(OP) { - case "list": - paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){ - console.log({err:err, plans:plans}); +let update = { + op: 'replace', + path: '/merchant_preferences', + value: { + cancel_url: 'https://habitica.com', + }, +}; + +switch (OP) { + case 'list': + paypal.billingPlan.list({status: 'ACTIVE'}, function listPlans () { + // TODO Was a console.log statement. Need proper response output }); break; - case "get": - paypal.billingPlan.get(nconf.get("PAYPAL:billing_plans:12"), function (err, plan) { - console.log({err:err, plan:plan}); - }) - break; - case "update": - var update = { - "op": "replace", - "path": "/merchant_preferences", - "value": { - "cancel_url": "https://habitica.com" - } - }; - paypal.billingPlan.update(nconf.get("PAYPAL:billing_plans:12"), update, function (err, res) { - console.log({err:err, plan:res}); + case 'get': + paypal.billingPlan.get(nconf.get('PAYPAL:billing_plans:12'), function getPlan () { + // TODO Was a console.log statement. Need proper response output }); break; - case "create": - paypal.billingPlan.create(blocks["google_6mo"].definition, function(err,plan){ - if (err) return console.log(err); - if (plan.state == "ACTIVE") - return console.log({err:err, plan:plan}); - var billingPlanUpdateAttributes = [{ - "op": "replace", - "path": "/", - "value": { - "state": "ACTIVE" - } + case 'update': + paypal.billingPlan.update(nconf.get('PAYPAL:billing_plans:12'), update, function updatePlan () { + // TODO Was a console.log statement. Need proper response output + }); + break; + case 'create': + paypal.billingPlan.create(blocks.google_6mo.definition, function createPlan (err, plan) { + if (err) return; // TODO Was a console.log statement. Need proper response output + if (plan.state === 'ACTIVE') + return; // TODO Was a console.log statement. Need proper response output + let billingPlanUpdateAttributes = [{ + op: 'replace', + path: '/', + value: { + state: 'ACTIVE', + }, }]; // Activate the plan by changing status to Active - paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ - console.log({err:err, response:response, id:plan.id}); + paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function activatePlan () { + // TODO Was a console.log statement. Need proper response output }); }); break; - case "remove": break; + case 'remove': break; } +/* eslint-enable camelcase */ From 715bb5e047ff8ddf35a74e01b7b68c2a82900077 Mon Sep 17 00:00:00 2001 From: Victor Pudeyev Date: Sat, 16 Apr 2016 12:24:13 -0500 Subject: [PATCH 691/976] shared-code-statsComputed (#7067) * shared-code-statsComputed * \$w is in common/script/index.js --- common/script/index.js | 18 +++++------------ common/script/libs/statsComputed.js | 28 +++++++++++++++++++++++++++ test/common/fns/statsComputed.test.js | 28 +++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 common/script/libs/statsComputed.js create mode 100644 test/common/fns/statsComputed.test.js diff --git a/common/script/index.js b/common/script/index.js index 91c0a021ca..0a6cfae2c2 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -33,9 +33,8 @@ api.capByLevel = statHelpers.capByLevel; api.tnl = statHelpers.toNextLevel; api.diminishingReturns = statHelpers.diminishingReturns; -// TODO under api.libs? import splitWhitespace from './libs/splitWhitespace'; -const $w = api.$w = splitWhitespace; +api.$w = splitWhitespace; import dotSet from './libs/dotSet'; api.dotSet = dotSet; @@ -85,6 +84,8 @@ api.pickDeep = pickDeep; import count from './count'; api.count = count; +import statsComputed from './libs/statsComputed'; + // TODO As ops and fns are ported, exported them through the api object import scoreTask from './ops/scoreTask'; import sleep from './ops/sleep'; @@ -285,23 +286,14 @@ api.wrap = function wrapUser (user, main = true) { randomDrop: _.partial(importedFns.randomDrop, user), autoAllocate: _.partial(importedFns.autoAllocate, user), updateStats: _.partial(importedFns.updateStats, user), + statsComputed: _.partial(statsComputed, user), ultimateGear: _.partial(importedFns.ultimateGear, user), nullify: _.partial(importedFns.nullify, user), }; Object.defineProperty(user, '_statsComputed', { get () { - let computed = _.reduce(['per', 'con', 'str', 'int'], (m, stat) => { - m[stat] = _.reduce($w('stats stats.buffs items.gear.equipped.weapon items.gear.equipped.armor items.gear.equipped.head items.gear.equipped.shield'), (m2, path) => { - let item; - let val = user.fns.dotGet(path); - return m2 + (path.indexOf('items.gear') !== -1 ? (item = content.gear.flat[val], (Number(item ? item[stat] : undefined) || 0) * ((item ? item.klass : undefined) === user.stats.class || (item ? item.specialClass : undefined) === user.stats.class ? 1.5 : 1)) : Number(val[stat]) || 0); - }, 0); - m[stat] += Math.floor(api.capByLevel(user.stats.lvl) / 2); - return m; - }, {}); - computed.maxMP = computed.int * 2 + 30; - return computed; + return statsComputed(user); }, }); }; diff --git a/common/script/libs/statsComputed.js b/common/script/libs/statsComputed.js new file mode 100644 index 0000000000..a239b2039c --- /dev/null +++ b/common/script/libs/statsComputed.js @@ -0,0 +1,28 @@ +import _ from 'lodash'; +import content from '../content/index'; +import * as statHelpers from '../statHelpers'; + +module.exports = function statsComputed (user) { + let paths = ['stats', 'stats.buffs', 'items.gear.equipped.weapon', 'items.gear.equipped.armor', + 'items.gear.equipped.head', 'items.gear.equipped.shield']; + let computed = _.reduce(['per', 'con', 'str', 'int'], (m, stat) => { + m[stat] = _.reduce(paths, (m2, path) => { + let val = _.get(user, path); + let item = content.gear.flat[val]; + if (!item) item = {}; + if (!item[stat]) { + item[stat] = 0; + } else { + item[stat] = Number(item[stat]); + } + let thisMultiplier = item.klass === user.stats.class || item.specialClass === user.stats.class ? 1.5 : 1; + let thisReturn = path.indexOf('items.gear') !== -1 ? item[stat] * thisMultiplier : Number(val[stat]); + return m2 + thisReturn || 0; + }, 0); + m[stat] += Math.floor(statHelpers.capByLevel(user.stats.lvl) / 2); + return m; + }, {}); + + computed.maxMP = computed.int * 2 + 30; + return computed; +}; diff --git a/test/common/fns/statsComputed.test.js b/test/common/fns/statsComputed.test.js new file mode 100644 index 0000000000..07b009368d --- /dev/null +++ b/test/common/fns/statsComputed.test.js @@ -0,0 +1,28 @@ +import statsComputed from '../../../common/script/libs/statsComputed'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('common.fns.statsComputed', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('returns the same result if called directly, through user.fns.statsComputed, or user._statsComputed', () => { + let result = statsComputed(user); + let result2 = user._statsComputed; + let result3 = user.fns.statsComputed(); + expect(result).to.eql(result2); + expect(result).to.eql(result3); + }); + + it('returns default values', () => { + let result = statsComputed(user); + expect(result.per).to.eql(0); + expect(result.con).to.eql(0); + expect(result.str).to.eql(0); + expect(result.maxMP).to.eql(30); + }); +}); From c2b8cad886fc4c43505e941d825724a6cabc2ad1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Apr 2016 19:56:33 +0200 Subject: [PATCH 692/976] v3: fix v3 proxied ops in v2, logout --- website/src/controllers/api-v2/user.js | 10 ++++++---- website/src/controllers/top-level/auth.js | 3 +-- website/views/shared/footer.jade | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 9636856b2c..721feb72b4 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -875,14 +875,16 @@ api.addTask = function(req, res, next) { * All other user.ops which can easily be mapped to common/script/index.js, not requiring custom API-wrapping */ _.each(shared.ops, function(op,k){ + var kv3; + if (['rebirth', 'reroll', 'reset'].indexOf(k) !== -1) { // proxy ops that change tasks directly to v3 - if (k === 'rebirth') k = 'userRebirth'; // the name is different in v3 - if (k === 'reroll') k = 'userReroll'; - if (k === 'reset') k = 'userReset'; + if (k === 'rebirth') kv3 = 'userRebirth'; // the name is different in v3 + if (k === 'reroll') kv3 = 'userReroll'; + if (k === 'reset') kv3 = 'userReset'; api[k] = function (req, res, next) { req.v2 = true; - v3UserController[k].handler(req, res, next).catch(next); + v3UserController[kv3].handler(req, res, next).catch(next); } } else if (!api[k]) { api[k] = function(req, res, next) { diff --git a/website/src/controllers/top-level/auth.js b/website/src/controllers/top-level/auth.js index 31dfb3b693..cf66b5996f 100644 --- a/website/src/controllers/top-level/auth.js +++ b/website/src/controllers/top-level/auth.js @@ -10,9 +10,8 @@ let api = {}; api.logout = { method: 'GET', url: '/logout', - middlewares: [authWithSession], async handler (req, res) { - req.logout(); // passportjs method + if (req.logout) req.logout(); // passportjs method req.session = null; res.redirect('/'); }, diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index 347f5a9c87..97ab797516 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -79,7 +79,7 @@ footer.footer(ng-controller='FooterCtrl') tr td iframe(src='/bower_components/github-buttons/github-btn.html?user=habitrpg&repo=habitrpg&type=watch&count=true', allowtransparency='true', frameborder='0', scrolling='0', width='85px', height='20px') - else if (true || env.NODE_ENV==='development' || env.NODE_ENV==='test') && !env.isStaticPage + if (true || env.NODE_ENV==='development' || env.NODE_ENV==='test') && !env.isStaticPage h4 Debug .btn-group-vertical a.btn.btn-default(ng-click='setHealthLow()') Health = 1 From dea693f799efc4b8f0d5ee3e5e6625e8619bebf3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Apr 2016 20:51:16 +0200 Subject: [PATCH 693/976] v3: start to fix unlocking --- common/locales/en/api-v3.json | 3 +- common/script/ops/unlock.js | 67 +++++++++++++++++++++++------------ test/common/ops/unlock.js | 42 ++++++++++++++++++++-- 3 files changed, 86 insertions(+), 26 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 7af8dc9af0..2f3ea4052a 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -160,7 +160,8 @@ "sold": "You sold a <%= key %> <%= type %>", "pathRequired": "Path string is required", "unlocked": "Items have been unlocked", - "alreadyUnlocked": "Item already unlocked", + "alreadyUnlocked": "Full set already unlocked.", + "alreadyUnlockedPart": "Full set already partially unlocked.", "cannotRevive": "Cannot revive if not dead", "rebirthComplete": "You have been reborn!", "petNotOwned": "You do not own this pet.", diff --git a/common/script/ops/unlock.js b/common/script/ops/unlock.js index 08ec175dcc..e7907f898b 100644 --- a/common/script/ops/unlock.js +++ b/common/script/ops/unlock.js @@ -6,6 +6,8 @@ import { BadRequest, } from '../libs/errors'; +// If item is already purchased -> equip it +// Otherwise unlock it module.exports = function unlock (user, req = {}, analytics) { let path = _.get(req.query, 'path'); @@ -14,9 +16,9 @@ module.exports = function unlock (user, req = {}, analytics) { } let isFullSet = path.indexOf(',') !== -1; - let cost; let isBackground = path.indexOf('background.') !== -1; + let cost; if (isBackground && isFullSet) { cost = 3.75; } else if (isBackground) { @@ -27,21 +29,39 @@ module.exports = function unlock (user, req = {}, analytics) { cost = 0.5; } - let alreadyOwns = !isFullSet && _.get(user, `purchased.${path}`) === true; + let setPaths; + let alreadyOwns; + + if (isFullSet) { + setPaths = path.split(','); + let alreadyOwnedItems = 0; + + _.each(setPaths, singlePath => { + if (_.get(user, `purchased.${singlePath}`) === true) { + alreadyOwnedItems++; + } + }); + + if (alreadyOwnedItems === setPaths.length) { + throw new NotAuthorized(i18n.t('alreadyUnlocked', req.language)); + } else if (alreadyOwnedItems > 0) { + throw new NotAuthorized(i18n.t('alreadyUnlockedPart', req.language)); + } + } else { + alreadyOwns = _.get(user, `purchased.${path}`) === true; + } if ((!user.balance || user.balance < cost) && !alreadyOwns) { throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); } if (isFullSet) { - _.each(path.split(','), function markItemsAsPurchased (pathPart) { + _.each(setPaths, function markItemsAsPurchased (pathPart) { if (path.indexOf('gear.') !== -1) { _.set(user, pathPart, true); - return true; } _.set(user, `purchased.${pathPart}`, true); - return true; }); } else { if (alreadyOwns) { @@ -51,35 +71,38 @@ module.exports = function unlock (user, req = {}, analytics) { if (key === 'background' && value === user.preferences.background) { value = ''; } + _.set(user, `preferences.${key}`, value); - - throw new NotAuthorized(i18n.t('alreadyUnlocked', req.language)); + } else { + _.set(user, `purchased.${path}`, true); } - _.set(user, `purchased.${path}`, true); } - if (path.indexOf('gear.') === -1) { - user.markModified('purchased'); - } + if (!alreadyOwns) { + if (path.indexOf('gear.') === -1) { + user.markModified('purchased'); + } - user.balance -= cost; + user.balance -= cost; - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: path, - itemType: 'customization', - acquireMethod: 'Gems', - gemCost: cost / 0.25, - category: 'behavior', - }); + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: path, + itemType: 'customization', + acquireMethod: 'Gems', + gemCost: cost / 0.25, + category: 'behavior', + }); + } } let response = { data: _.pick(user, splitWhitespace('purchased preferences items')), - message: i18n.t('unlocked'), }; + if (!alreadyOwns) response.message = i18n.t('unlocked', req.language); + if (req.v2 === true) { return response.data; } else { diff --git a/test/common/ops/unlock.js b/test/common/ops/unlock.js index b7d7bda370..2f9f7d3aea 100644 --- a/test/common/ops/unlock.js +++ b/test/common/ops/unlock.js @@ -43,10 +43,10 @@ describe('shared.ops.unlock', () => { } }); - it('returns an error when user already owns an item', (done) => { + it('returns an error when user already owns a full set', (done) => { try { - unlock(user, {query: {path: backgroundUnlockPath}}); - unlock(user, {query: {path: backgroundUnlockPath}}); + unlock(user, {query: {path: unlockPath}}); + unlock(user, {query: {path: unlockPath}}); } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('alreadyUnlocked')); @@ -54,6 +54,42 @@ describe('shared.ops.unlock', () => { } }); + it('returns an error when user already owns items in a full set', (done) => { + try { + unlock(user, {query: {path: unlockPath}}); + unlock(user, {query: {path: unlockPath}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('alreadyUnlocked')); + done(); + } + }); + + it('equips an item already owned', () => { + expect(user.purchased.background.giant_florals).to.not.exists; + + unlock(user, {query: {path: backgroundUnlockPath}}); + let afterBalance = user.balance; + let response = unlock(user, {query: {path: backgroundUnlockPath}}); + expect(user.balance).to.equal(afterBalance); // do not bill twice + + expect(response.message).to.not.exists; + expect(user.preferences.background).to.equal('giant_florals'); + }); + + it('un-equips an item already equipped', () => { + expect(user.purchased.background.giant_florals).to.not.exists; + + unlock(user, {query: {path: backgroundUnlockPath}}); // unlock + let afterBalance = user.balance; + unlock(user, {query: {path: backgroundUnlockPath}}); // equip + let response = unlock(user, {query: {path: backgroundUnlockPath}}); + expect(user.balance).to.equal(afterBalance); // do not bill twice + + expect(response.message).to.not.exists; + expect(user.preferences.background).to.equal(''); + }); + it('unlocks a full set', () => { let response = unlock(user, {query: {path: unlockPath}}); From b271520c53089f8aa73f24dcf37037e65589e178 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 17 Apr 2016 01:17:22 +0200 Subject: [PATCH 694/976] v3: general cleanup --- .../{meta => }/models/GET-model_paths.test.js | 8 +- website/src/controllers/api-v3/auth.js | 17 ++-- website/src/controllers/api-v3/challenges.js | 33 +++++--- website/src/controllers/api-v3/chat.js | 40 +++++----- website/src/controllers/api-v3/content.js | 9 ++- website/src/controllers/api-v3/coupon.js | 14 ++-- website/src/controllers/api-v3/debug.js | 10 ++- website/src/controllers/api-v3/email.js | 7 +- website/src/controllers/api-v3/groups.js | 53 +++++++------ website/src/controllers/api-v3/hall.js | 22 +++--- website/src/controllers/api-v3/members.js | 35 ++++---- website/src/controllers/api-v3/modelsPaths.js | 7 +- website/src/controllers/api-v3/quests.js | 17 ++-- website/src/controllers/api-v3/status.js | 2 +- website/src/controllers/api-v3/tags.js | 10 +-- website/src/controllers/api-v3/tasks.js | 79 +++++++++---------- website/src/controllers/api-v3/user.js | 23 +++--- website/src/controllers/top-level/auth.js | 4 - .../src/controllers/top-level/dataexport.js | 4 +- website/src/libs/api-v3/i18n.js | 4 +- website/src/models/group.js | 7 +- website/src/models/user.js | 1 - 22 files changed, 213 insertions(+), 193 deletions(-) rename test/api/v3/integration/{meta => }/models/GET-model_paths.test.js (69%) diff --git a/test/api/v3/integration/meta/models/GET-model_paths.test.js b/test/api/v3/integration/models/GET-model_paths.test.js similarity index 69% rename from test/api/v3/integration/meta/models/GET-model_paths.test.js rename to test/api/v3/integration/models/GET-model_paths.test.js index f878d391df..205269bcbe 100644 --- a/test/api/v3/integration/meta/models/GET-model_paths.test.js +++ b/test/api/v3/integration/models/GET-model_paths.test.js @@ -1,9 +1,9 @@ import { generateUser, translate as t, -} from '../../../../../helpers/api-integration/v3'; +} from '../../../../helpers/api-integration/v3'; -describe('GET /meta/models/:model/paths', () => { +describe('GET /models/:model/paths', () => { let user; before(async () => { @@ -11,7 +11,7 @@ describe('GET /meta/models/:model/paths', () => { }); it('returns an error when model is not accessible or doesn\'t exists', async () => { - await expect(user.get('/meta/models/1234/paths')).to.eventually.be.rejected.and.eql({ + await expect(user.get('/models/1234/paths')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -21,7 +21,7 @@ describe('GET /meta/models/:model/paths', () => { let models = ['habit', 'daily', 'todo', 'reward', 'user', 'tag', 'challenge', 'group']; models.forEach(model => { it(`returns the model paths for ${model}`, async () => { - let res = await user.get(`/meta/models/${model}/paths`); + let res = await user.get(`/models/${model}/paths`); expect(res._id).to.equal('String'); expect(res).to.not.have.keys('__v'); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 640003bb6d..08c405424e 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -62,7 +62,7 @@ async function _handleGroupInvitation (user, invite) { * @apiParam {String} password Body parameter - Password for the new user * @apiParam {String} confirmPassword Body parameter - Password confirmation * - * @apiSuccess {Object} user The user object, if local auth was just attached to a social user then only user.auth.local + * @apiSuccess {Object} data The user object, if local auth was just attached to a social user then only user.auth.local */ api.registerLocal = { method: 'POST', @@ -174,8 +174,8 @@ function _loginRes (user, req, res) { * @apiParam {String} username Body parameter - Username or email of the user * @apiParam {String} password Body parameter - The user's password * - * @apiSuccess {String} _id The user's unique identifier - * @apiSuccess {String} apiToken The user's api token that must be used to authenticate requests. + * @apiSuccess {String} data._id The user's unique identifier + * @apiSuccess {String} data.apiToken The user's api token that must be used to authenticate requests. */ api.loginLocal = { method: 'POST', @@ -229,6 +229,7 @@ function _passportFbProfile (accessToken) { } // Called as a callback by Facebook (or other social providers). Internal route +// TODO move to top-level/auth? api.loginSocial = { method: 'POST', url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 @@ -290,7 +291,7 @@ api.loginSocial = { * @apiParam {string} password Body parameter - The current user password * @apiParam {string} username Body parameter - The new username - * @apiSuccess {String} username The new username + * @apiSuccess {String} data.username The new username **/ api.updateUsername = { method: 'PUT', @@ -339,7 +340,7 @@ api.updateUsername = { * @apiParam {string} newPassword Body parameter - The new password * @apiParam {string} confirmPassword Body parameter - New password confirmation * - * @apiSuccess {Object} emoty An empty object + * @apiSuccess {Object} data An empty object **/ api.updatePassword = { method: 'PUT', @@ -379,7 +380,7 @@ api.updatePassword = { * * @apiParam {string} email Body parameter - The email address of the user * - * @apiSuccess {string} message The localized success message + * @apiSuccess {string} data.message The localized success message **/ api.resetPassword = { method: 'POST', @@ -433,7 +434,7 @@ api.resetPassword = { * @apiParam {string} Body parameter - newEmail The new email address. * @apiParam {string} Body parameter - password The user password. * - * @apiSuccess {string} email The updated email address + * @apiSuccess {string} data.email The updated email address */ api.updateEmail = { method: 'PUT', @@ -488,7 +489,7 @@ api.getFirebaseToken = { * @apiName UserDeleteSocial * @apiGroup User * - * @apiSuccess {Object} empty Empty object + * @apiSuccess {Object} data Empty object */ api.deleteSocial = { method: 'DELETE', diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 50249b0faf..8ea8558fb3 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -29,7 +29,7 @@ let api = {}; * @apiName CreateChallenge * @apiGroup Challenge * - * @apiSuccess {object} challenge The newly created challenge + * @apiSuccess {object} data The newly created challenge */ api.createChallenge = { method: 'POST', @@ -119,7 +119,7 @@ api.createChallenge = { * @apiGroup Challenge * @apiParam {UUID} challengeId The challenge _id * - * @apiSuccess {object} challenge The challenge the user joined + * @apiSuccess {object} data The challenge the user joined */ api.joinChallenge = { method: 'POST', @@ -165,7 +165,7 @@ api.joinChallenge = { * @apiGroup Challenge * @apiParam {UUID} challengeId The challenge _id * - * @apiSuccess {object} empty An empty object + * @apiSuccess {object} data An empty object */ api.leaveChallenge = { method: 'POST', @@ -202,7 +202,7 @@ api.leaveChallenge = { * @apiName GetUserChallenges * @apiGroup Challenge * - * @apiSuccess {Array} challenges An array of challenges + * @apiSuccess {Array} data An array of challenges */ api.getUserChallenges = { method: 'GET', @@ -220,12 +220,13 @@ api.getUserChallenges = { _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit }) .sort('-official -timestamp') + // see below why we're not using populate // .populate('group', basicGroupFields) // .populate('leader', nameFields) .exec(); let resChals = challenges.map(challenge => challenge.toJSON()); - // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 await Q.all(resChals.map((chal, index) => { return Q.all([ User.findById(chal.leader).select(nameFields).exec(), @@ -242,13 +243,14 @@ api.getUserChallenges = { /** * @api {get} /api/v3/challenges/group/group:Id Get challenges for a group + * @apiDescription Get challenges that the user is a member, public challenges and the ones from the user's groups. * @apiVersion 3.0.0 * @apiName GetGroupChallenges * @apiGroup Challenge * * @apiParam {groupId} groupId The group _id * - * @apiSuccess {Array} challenges An array of challenges + * @apiSuccess {Array} data An array of challenges */ api.getGroupChallenges = { method: 'GET', @@ -272,7 +274,7 @@ api.getGroupChallenges = { .exec(); let resChals = challenges.map(challenge => challenge.toJSON()); - // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 await Q.all(resChals.map((chal, index) => { return User.findById(chal.leader).select(nameFields).exec().then(populatedLeader => { resChals[index].leader = populatedLeader.toJSON({minimize: true}); @@ -291,7 +293,7 @@ api.getGroupChallenges = { * * @apiParam {UUID} challengeId The challenge _id * - * @apiSuccess {object} challenge The challenge object + * @apiSuccess {object} data The challenge object */ api.getChallenge = { method: 'GET', @@ -318,7 +320,7 @@ api.getChallenge = { let chalRes = challenge.toJSON(); chalRes.group = group.toJSON({minimize: true}); - // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 chalRes.leader = (await User.findById(chalRes.leader).select(nameFields).exec()).toJSON({minimize: true}); res.respond(200, chalRes); @@ -333,7 +335,7 @@ api.getChallenge = { * * @apiParam {UUID} challengeId The challenge _id * - * @apiSuccess {object} challenge The challenge object + * @apiSuccess {string} challenge A csv file */ api.exportChallengeCsv = { method: 'GET', @@ -406,7 +408,7 @@ api.exportChallengeCsv = { * * @apiParam {UUID} challengeId The challenge _id * - * @apiSuccess {object} challenge The updated challenge object + * @apiSuccess {object} data The updated challenge */ api.updateChallenge = { method: 'PUT', @@ -507,7 +509,9 @@ export async function _closeChal (challenge, broken = {}) { * @apiName DeleteChallenge * @apiGroup Challenge * - * @apiSuccess {object} empty An empty object + * challengeId {UUID} The _id for the challenge to delete + * + * @apiSuccess {object} data An empty object */ api.deleteChallenge = { method: 'DELETE', @@ -537,7 +541,10 @@ api.deleteChallenge = { * @apiName SelectChallengeWinner * @apiGroup Challenge * - * @apiSuccess {object} empty An empty object + * challengeId {UUID} The _id for the challenge to close with a winner + * winnerId {UUID} The _id of the winning user + * + * @apiSuccess {object} data An empty object */ api.selectChallengeWinner = { method: 'POST', diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 24e4ea8bab..42864fdab7 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -27,7 +27,7 @@ let api = {}; * * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * - * @apiSuccess {Array} chat An array of chat messages + * @apiSuccess {Array} data An array of chat messages */ api.getChat = { method: 'GET', @@ -54,11 +54,11 @@ api.getChat = { * @apiName PostCat * @apiGroup Chat * - * @apiParam {UUID} groupId The group _id - * @apiParam {message} message The chat's message - * @apiParam {previousMsg} previousMsg The previous chat message which will force a return of the full group chat + * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) + * @apiParam {message} Body parameter - message The message to post + * @apiParam {previousMsg} previousMsg Query parameter - The previous chat message which will force a return of the full group chat * - * @apiSuccess {Array} chat An array of chat messages + * @apiSuccess data An array of chat messages if a new message was posted after previousMsg, otherwise the posted message */ api.postChat = { method: 'POST', @@ -107,10 +107,10 @@ api.postChat = { * @apiName LikeChat * @apiGroup Chat * - * @apiParam {groupId} groupId The group _id + * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {chatId} chatId The chat message _id * - * @apiSuccess {Array} chat An array of chat messages + * @apiSuccess {Object} data The liked chat message */ api.likeChat = { method: 'POST', @@ -154,10 +154,10 @@ api.likeChat = { * @apiName LikeChat * @apiGroup Chat * - * @apiParam {groupId} groupId The group _id - * @apiParam {chatId} chatId The chat message _id + * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) + * @apiParam {chatId} chatId The chat message id * - * @apiSuccess {Array} chat An array of chat messages + * @apiSuccess {object} data The flagged chat message */ api.flagChat = { method: 'POST', @@ -255,14 +255,15 @@ api.flagChat = { /** * @api {post} /api/v3/groups/:groupId/chat/:chatId/clear-flags Clear a group chat message's flags + * @apiDescription Admin-only * @apiVersion 3.0.0 * @apiName ClearFlags * @apiGroup Chat * - * @apiParam {groupId} groupId The group _id - * @apiParam {chatId} chatId The chat message _id + * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) + * @apiParam {chatId} chatId The chat message id * - * @apiSuccess {Object} An empty object + * @apiSuccess {Object} data An empty object */ api.clearChatFlags = { method: 'Post', @@ -306,7 +307,9 @@ api.clearChatFlags = { * @apiName SeenChat * @apiGroup Chat * - * @apiParam {groupId} groupId The group _id + * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) + * + * @apiSuccess {Object} data An empty object */ api.seenChat = { method: 'POST', @@ -328,7 +331,7 @@ api.seenChat = { update.$unset[`newMessages.${groupId}`] = true; await User.update({_id: user._id}, update).exec(); - res.respond(200); + res.respond(200, {}); }, }; @@ -338,11 +341,12 @@ api.seenChat = { * @apiName DeleteChat * @apiGroup Chat * + * @apiParam {string} previousMsg Query parameter - The last message fetched by the client so that the whole chat will be returned only if new messages have been posted in the meantime * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {string} chatId The chat _id + * @apiParam {string} chatId The chat message id * - * @apiSuccess {Array} The update chat array - * @apiSuccess {Object} An empty object when the previous message was deleted + * @apiSuccess data The updated chat array or an empty object if no message was posted after previousMsg + * @apiSuccess {Object} data An empty object when the previous message was deleted */ api.deleteChat = { method: 'DELETE', diff --git a/website/src/controllers/api-v3/content.js b/website/src/controllers/api-v3/content.js index 780758cf48..46eedf9b91 100644 --- a/website/src/controllers/api-v3/content.js +++ b/website/src/controllers/api-v3/content.js @@ -61,14 +61,15 @@ async function saveContentToDisk (language, content) { } /** - * @api {get} /api/v3/content Get all available content objects. Does not require authentication. + * @api {get} /api/v3/content Get all available content objects. + * @apiDescription Does not require authentication. * @apiVersion 3.0.0 * @apiName ContentGet * @apiGroup Content * - * @apiParam {string} language Optional query parameter, the language code used for the items' strings. Defaulting to english + * @apiParam {string} language Query parameter, the language code used for the items' strings. Defaulting to english * - * @apiSuccess {Object} content All the content available on Habitica + * @apiSuccess {Object} data All the content available on Habitica */ api.getContent = { method: 'GET', @@ -95,7 +96,7 @@ api.getContent = { res.set({ 'Content-Type': 'application/json', }); - res.status(200).send(content); + res.status(200).send(content); // TODO how to use res.respond here? // save the file in background unless it's already cached or being written right now if (cachedContentResponses[language] !== true && cacheBeingWritten[language] !== true) { diff --git a/website/src/controllers/api-v3/coupon.js b/website/src/controllers/api-v3/coupon.js index 8ecae368ee..4dc434aa8c 100644 --- a/website/src/controllers/api-v3/coupon.js +++ b/website/src/controllers/api-v3/coupon.js @@ -11,12 +11,13 @@ import couponCode from 'coupon-code'; let api = {}; /** - * @api {get} /api/v3/coupons Get coupons (sudo users only) + * @api {get} /api/v3/coupons Get coupons + * @apiDescription Sudo users only * @apiVersion 3.0.0 * @apiName GetCoupons * @apiGroup Coupon * - * @apiSuccess string Coupons in CSV format + * @apiSuccess {string} Coupons in CSV format */ api.getCoupons = { method: 'GET', @@ -39,7 +40,8 @@ api.getCoupons = { }; /** - * @api {post} /api/v3/coupons/generate/:event Generate coupons for an event (sudo users only) + * @api {post} /api/v3/coupons/generate/:event Generate coupons for an event + * @apiDescription Sudo users only * @apiVersion 3.0.0 * @apiName GenerateCoupons * @apiGroup Coupon @@ -47,7 +49,7 @@ api.getCoupons = { * @apiParam {string} event The event for which the coupon should be generated * @apiParam {number} count Query parameter to specify the number of coupon codes to generate * - * @apiSuccess array Generated coupons + * @apiSuccess {array} data Generated coupons */ api.generateCoupons = { method: 'POST', @@ -73,7 +75,7 @@ api.generateCoupons = { * * @apiParam {string} code The coupon code to apply * - * @apiSuccess object User object + * @apiSuccess {object} data User object */ api.enterCouponCode = { method: 'POST', @@ -98,7 +100,7 @@ api.enterCouponCode = { * @apiName ValidateCoupon * @apiGroup Coupon * - * @apiSuccess valid {boolean} true or false + * @apiSuccess {boolean} data.valid True or false */ api.validateCoupon = { method: 'POST', diff --git a/website/src/controllers/api-v3/debug.js b/website/src/controllers/api-v3/debug.js index a9d9c0cb08..dfd462a7e7 100644 --- a/website/src/controllers/api-v3/debug.js +++ b/website/src/controllers/api-v3/debug.js @@ -4,12 +4,13 @@ import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode' let api = {}; /** - * @api {post} /api/v3/debug/add-ten-gems Add ten gems to the current user + * @api {post} /api/v3/debug/add-ten-gems Add ten gems to the current user. + * @apiDescription Only available in development mode. * @apiVersion 3.0.0 * @apiName AddTenGems * @apiGroup Development * - * @apiSuccess {Object} empty An empty Object + * @apiSuccess {Object} data An empty Object */ api.addTenGems = { method: 'POST', @@ -27,12 +28,13 @@ api.addTenGems = { }; /** - * @api {post} /api/v3/debug/add-hourglass Add Hourglass to the current user + * @api {post} /api/v3/debug/add-hourglass Add Hourglass to the current user. + * @apiDescription Only available in development mode. * @apiVersion 3.0.0 * @apiName AddHourglass * @apiGroup Development * - * @apiSuccess {Object} empty An empty Object + * @apiSuccess {Object} data An empty Object */ api.addHourglass = { method: 'POST', diff --git a/website/src/controllers/api-v3/email.js b/website/src/controllers/api-v3/email.js index 01b7109e19..f8e5e4effe 100644 --- a/website/src/controllers/api-v3/email.js +++ b/website/src/controllers/api-v3/email.js @@ -7,21 +7,22 @@ import { let api = {}; +// TODO move to top-level controllers? /** * @api {get} /api/v3/email/unsubscribe Unsubscribe an email or user from email notifications + * @apiDescription Does not require authentication * @apiVersion 3.0.0 * @apiName UnsubscribeEmail * @apiGroup Unsubscribe * @apiDescription This is a GET method so that you can put the unsubscribe link in emails. * - * @apiParam {String} code An unsubscription code + * @apiParam {String} code Query parameter - An unsubscription code * - * @apiSuccess {String} okRes An message stating the user/email unsubscribed successfully + * @apiSuccess {String} An html success message */ api.unsubscribe = { method: 'GET', url: '/email/unsubscribe', - middlewares: [], async handler (req, res) { req.checkQuery({ code: { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 6563f1289e..60df7d1d15 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -24,15 +24,13 @@ import common from '../../../../common'; import sendPushNotification from '../../libs/api-v3/pushNotifications'; let api = {}; -// TODO shall we accept party as groupId in all routes? - /** * @api {post} /api/v3/groups Create group * @apiVersion 3.0.0 * @apiName CreateGroup * @apiGroup Group * - * @apiSuccess {Object} group The group object + * @apiSuccess {Object} data The create group */ api.createGroup = { method: 'POST', @@ -40,7 +38,7 @@ api.createGroup = { middlewares: [authWithHeaders()], async handler (req, res) { let user = res.locals.user; - let group = new Group(Group.sanitize(req.body)); // TODO validate empty req.body + let group = new Group(Group.sanitize(req.body)); group.leader = user._id; if (group.type === 'guild') { @@ -60,7 +58,7 @@ api.createGroup = { let results = await Q.all([user.save(), group.save()]); let savedGroup = results[1]; - // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 // await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise let response = savedGroup.toJSON(); // the leader is the authenticated user @@ -76,14 +74,14 @@ api.createGroup = { }; /** - * @api {get} /api/v3/groups Get groups + * @api {get} /api/v3/groups Get groups for a user * @apiVersion 3.0.0 * @apiName GetGroups * @apiGroup Group * * @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, privateGuilds, publicGuilds, tavern * - * @apiSuccess {Array} groups An array of the requested groups + * @apiSuccess {Array} data An array of the requested groups */ api.getGroups = { method: 'GET', @@ -92,7 +90,7 @@ api.getGroups = { async handler (req, res) { let user = res.locals.user; - req.checkQuery('type', res.t('groupTypesRequired')).notEmpty(); // TODO better validation + req.checkQuery('type', res.t('groupTypesRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -119,7 +117,7 @@ api.getGroups = { * * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * - * @apiSuccess {Object} group The group object + * @apiSuccess {Object} data The group object */ api.getGroup = { method: 'GET', @@ -137,7 +135,7 @@ api.getGroup = { if (!group) throw new NotFound(res.t('groupNotFound')); group = Group.toJSONCleanChat(group, user); - // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 let leader = await User.findById(group.leader).select(nameFields).exec(); if (leader) group.leader = leader.toJSON({minimize: true}); @@ -153,7 +151,7 @@ api.getGroup = { * * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * - * @apiSuccess {Object} group The updated group object + * @apiSuccess {Object} data The updated group */ api.updateGroup = { method: 'PUT', @@ -197,9 +195,9 @@ api.updateGroup = { * @apiName JoinGroup * @apiGroup Group * - * @apiParam {UUID} groupId The group _id + * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * - * @apiSuccess {Object} group The group + * @apiSuccess {Object} data The joined group */ api.joinGroup = { method: 'POST', @@ -288,9 +286,9 @@ api.joinGroup = { * @apiName RejectGroupInvite * @apiGroup Group * - * @apiParam {UUID} groupId The group _id + * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * - * @apiSuccess {Object} group The group + * @apiSuccess {Object} data An empty object */ api.rejectGroupInvite = { method: 'POST', @@ -334,9 +332,9 @@ api.rejectGroupInvite = { * @apiGroup Group * * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {string="remove-all","keep-all"} keep Wheter to keep or not challenges' tasks, as an optional query string + * @apiParam {string="remove-all","keep-all"} keep Query parameter - Whether to keep or not challenges' tasks. Defaults to keep-all * - * @apiSuccess {Object} empty An empty object + * @apiSuccess {Object} data An empty object */ api.leaveGroup = { method: 'POST', @@ -391,9 +389,9 @@ function _sendMessageToRemoved (group, removedUser, message) { * * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {UUID} memberId The _id of the member to remove - * @apiParam {string} message The message to send to the removed members, as a query string // TODO in req.body? + * @apiParam {string} message Query parameter - The message to send to the removed members * - * @apiSuccess {Object} empty An empty object + * @apiSuccess {Object} data An empty object */ api.removeGroupMember = { method: 'POST', @@ -448,7 +446,7 @@ api.removeGroupMember = { if (isInGroup === 'guild') { removeFromArray(member.guilds, group._id); } - if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? + if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? Use group.leave()? if (member.newMessages[group._id]) { member.newMessages[group._id] = undefined; @@ -456,13 +454,16 @@ api.removeGroupMember = { } if (group.quest && group.quest.active && group.quest.leader === member._id) { - member.items.quests[group.quest.key] += 1; // TODO why this? + member.items.quests[group.quest.key] += 1; } } else if (isInvited) { if (isInvited === 'guild') { removeFromArray(member.invitations.guilds, { id: group._id }); } - if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified? + if (isInvited === 'party') { + user.invitations.party = {}; + user.markModified('invitations.party'); + } } else { throw new NotFound(res.t('groupMemberNotFound')); } @@ -592,11 +593,11 @@ async function _inviteByEmail (invite, group, inviter, req, res) { * * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * - * @apiParam {array} emails An array of emails addresses to invite (optional) (inside body) - * @apiParam {array} uuids An array of uuids to invite (optional) (inside body) - * @apiParam {string} inviter The inviters' name (optional) (inside body) + * @apiParam {array} emails Body parameter - An array of emails addresses to invite (optional) + * @apiParam {array} uuids Body parameter - An array of uuids to invite (optional) + * @apiParam {string} inviter Body parameter - The inviters' name (optional) * - * @apiSuccess {Object} empty An empty object + * @apiSuccess {array} data The invites */ api.inviteToGroup = { method: 'POST', diff --git a/website/src/controllers/api-v3/hall.js b/website/src/controllers/api-v3/hall.js index 55bc191063..c8f11fdfa8 100644 --- a/website/src/controllers/api-v3/hall.js +++ b/website/src/controllers/api-v3/hall.js @@ -9,14 +9,15 @@ import _ from 'lodash'; let api = {}; /** - * @api {get} /api/v3/hall/patrons Get all Patrons. Only the first 50 patrons are returned. More can be accessed passing ?page=n. + * @api {get} /api/v3/hall/patrons Get all Patrons. + * @apiDescription Only the first 50 patrons are returned. More can be accessed passing ?page=n. * @apiVersion 3.0.0 * @apiName GetPatrons * @apiGroup Hall * - * @apiParam {Number} page The result page. Default is 0 + * @apiParam {Number} page Query Parameter - The result page. Default is 0 * - * @apiSuccess {Array} patron An array of patrons + * @apiSuccess {Array} data An array of patrons */ api.getPatrons = { method: 'GET', @@ -52,7 +53,7 @@ api.getPatrons = { * @apiName GetHeroes * @apiGroup Hall * - * @apiSuccess {Array} hero An array of heroes + * @apiSuccess {Array} data An array of heroes */ api.getHeroes = { method: 'GET', @@ -74,17 +75,17 @@ api.getHeroes = { // Note, while the following routes are called getHero / updateHero // they can be used by admins to get/update any user -// TODO rename? const heroAdminFields = 'contributor balance profile.name purchased items auth'; /** - * @api {get} /api/v3/hall/heroes/:heroId Get an hero given his _id. Must be an admin to make this request + * @api {get} /api/v3/hall/heroes/:heroId Get an hero given his _id. + * @apiDescription Must be an admin to make this request * @apiVersion 3.0.0 * @apiName GetHero * @apiGroup Hall * - * @apiSuccess {Object} hero The hero object + * @apiSuccess {Object} data The hero object */ api.getHero = { method: 'GET', @@ -116,12 +117,13 @@ api.getHero = { const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0}; /** - * @api {put} /api/v3/hall/heroes/:heroId Update an hero. Must be an admin to make this request + * @api {put} /api/v3/hall/heroes/:heroId Update an hero. + * @apiDescription Must be an admin to make this request * @apiVersion 3.0.0 * @apiName UpdateHero * @apiGroup Hall * - * @apiSuccess {Object} hero The updated hero object + * @apiSuccess {Object} data The updated hero object */ api.updateHero = { method: 'PUT', @@ -162,7 +164,7 @@ api.updateHero = { if (updateData.itemPath && updateData.itemVal && updateData.itemPath.indexOf('items.') === 0 && User.schema.paths[updateData.itemPath]) { - _.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary) TODO review + _.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary) } if (updateData.auth && _.isBoolean(updateData.auth.blocked)) hero.auth.blocked = updateData.auth.blocked; diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 9e4c45d5d2..1a2257f5fe 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -27,7 +27,7 @@ let api = {}; * * @apiParam {UUID} memberId The member's id * - * @apiSuccess {object} member The member object + * @apiSuccess {object} data The member object */ api.getMember = { method: 'GET', @@ -129,7 +129,8 @@ function _getMembersForItem (type) { } /** - * @api {get} /api/v3/groups/:groupId/members Get members for a group with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @api {get} /api/v3/groups/:groupId/members Get members for a group + * @apiDescription With a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 * @apiName GetMembersForGroup * @apiGroup Member @@ -138,7 +139,7 @@ function _getMembersForItem (type) { * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results * @apiParam {boolean} includeAllPublicFields Query parameter available only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member) * - * @apiSuccess {array} members An array of members, sorted by _id + * @apiSuccess {array} data An array of members, sorted by _id */ api.getMembersForGroup = { method: 'GET', @@ -148,7 +149,8 @@ api.getMembersForGroup = { }; /** - * @api {get} /api/v3/groups/:groupId/invites Get invites for a group with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @api {get} /api/v3/groups/:groupId/invites Get invites for a group + * @apiDescription With a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 * @apiName GetInvitesForGroup * @apiGroup Member @@ -156,7 +158,7 @@ api.getMembersForGroup = { * @apiParam {UUID} groupId The group id * @apiParam {UUID} lastId Query parameter to specify the last invite returned in a previous request to this route and get the next batch of results * - * @apiSuccess {array} invites An array of invites, sorted by _id + * @apiSuccess {array} data An array of invites, sorted by _id */ api.getInvitesForGroup = { method: 'GET', @@ -166,7 +168,8 @@ api.getInvitesForGroup = { }; /** - * @api {get} /api/v3/challenges/:challengeId/members Get members for a challenge with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @api {get} /api/v3/challenges/:challengeId/members Get members for a challenge + * @apiDescription With a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 * @apiName GetMembersForChallenge * @apiGroup Member @@ -174,7 +177,7 @@ api.getInvitesForGroup = { * @apiParam {UUID} challengeId The challenge id * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results * - * @apiSuccess {array} members An array of members, sorted by _id + * @apiSuccess {array} data An array of members, sorted by _id */ api.getMembersForChallenge = { method: 'GET', @@ -192,7 +195,7 @@ api.getMembersForChallenge = { * @apiParam {UUID} challengeId The challenge _id * @apiParam {UUID} member The member _id * - * @apiSuccess {object} member Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member + * @apiSuccess {object} data Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member */ api.getChallengeMemberProgress = { method: 'GET', @@ -242,10 +245,10 @@ api.getChallengeMemberProgress = { * @apiName SendPrivateMessage * @apiGroup Members * - * @apiParam {String} message The message - * @apiParam {UUID} toUserId The toUser _id + * @apiParam {String} message Body parameter - The message + * @apiParam {UUID} toUserId Body parameter - The user to contact * - * @apiSuccess {Object} empty An empty Object + * @apiSuccess {Object} data An empty Object */ api.sendPrivateMessage = { method: 'POST', @@ -291,10 +294,10 @@ api.sendPrivateMessage = { * @apiName TransferGems * @apiGroup Members * - * @apiParam {String} message The message - * @apiParam {UUID} toUserId The toUser _id + * @apiParam {String} message Body parameter The message + * @apiParam {UUID} toUserId Body parameter The toUser _id * - * @apiSuccess {Object} empty An empty Object + * @apiSuccess {Object} data An empty Object */ api.transferGems = { method: 'POST', @@ -346,8 +349,8 @@ api.transferGems = { ]); } - // @TODO: Add push notifications - // pushNotify.sendNotify(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername })); + // TODO: Add push notifications + // pushNotify.sendNotify(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername })); res.respond(200, {}); }, diff --git a/website/src/controllers/api-v3/modelsPaths.js b/website/src/controllers/api-v3/modelsPaths.js index b023521722..b14d88b2b7 100644 --- a/website/src/controllers/api-v3/modelsPaths.js +++ b/website/src/controllers/api-v3/modelsPaths.js @@ -6,18 +6,19 @@ let tasksModels = ['habit', 'daily', 'todo', 'reward']; let allModels = ['user', 'tag', 'challenge', 'group'].concat(tasksModels); /** - * @api {get} /api/v3/meta/models/:model/paths Get all paths for the specified model. Doesn't require authentication + * @api {get} /api/v3s/models/:model/paths Get all paths for the specified model. + * @apiDescription Doesn't require authentication * @apiVersion 3.0.0 * @apiName GetUserModelPaths * @apiGroup Meta * * @apiParam {string="user","group","challenge","tag","habit","daily","todo","reward"} model The name of the model * - * @apiSuccess {object} paths A key-value object made of fieldPath: fieldType (like {'field.nested': Boolean}) + * @apiSuccess {object} data A key-value object made of fieldPath: fieldType (like {'field.nested': Boolean}) */ api.getModelPaths = { method: 'GET', - url: '/meta/models/:model/paths', + url: '/models/:model/paths', async handler (req, res) { req.checkParams('model', res.t('modelNotFound')).notEmpty().isIn(allModels); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index bd076610e6..05b3020f86 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -35,8 +35,9 @@ let api = {}; * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} questKey * - * @apiSuccess {Object} quest Quest Object + * @apiSuccess {Object} data Quest object */ api.inviteToQuest = { method: 'POST', @@ -139,7 +140,7 @@ api.inviteToQuest = { * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} quest Quest Object + * @apiSuccess {Object} data Quest Object */ api.acceptQuest = { method: 'POST', @@ -196,7 +197,7 @@ api.acceptQuest = { * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} quest Quest Object + * @apiSuccess {Object} data Quest Object */ api.rejectQuest = { method: 'POST', @@ -250,12 +251,12 @@ api.rejectQuest = { /** * @api {post} /api/v3/groups/:groupId/quests/force-start Accept a pending quest * @apiVersion 3.0.0 - * @apiName forceStart + * @apiName ForceQuestStart * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} quest Quest Object + * @apiSuccess {Object} data Quest Object */ api.forceStart = { method: 'POST', @@ -307,7 +308,7 @@ api.forceStart = { * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} quest Quest Object + * @apiSuccess {Object} data Quest Object */ api.cancelQuest = { method: 'POST', @@ -356,7 +357,7 @@ api.cancelQuest = { * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} quest Quest Object + * @apiSuccess {Object} data Quest Object */ api.abortQuest = { method: 'POST', @@ -410,7 +411,7 @@ api.abortQuest = { * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} quest Quest Object + * @apiSuccess {Object} data Quest Object */ api.leaveQuest = { method: 'POST', diff --git a/website/src/controllers/api-v3/status.js b/website/src/controllers/api-v3/status.js index a3f59726a4..94bcf63ed0 100644 --- a/website/src/controllers/api-v3/status.js +++ b/website/src/controllers/api-v3/status.js @@ -6,7 +6,7 @@ let api = {}; * @apiName GetStatus * @apiGroup Status * - * @apiSuccess {status} string 'up' if everything is ok + * @apiSuccess {status} data.status 'up' if everything is ok */ api.getStatus = { method: 'GET', diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 3028ee568c..03170b96b3 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -14,7 +14,7 @@ let api = {}; * @apiName CreateTag * @apiGroup Tag * - * @apiSuccess {Object} tag The newly created tag + * @apiSuccess {Object} data The newly created tag */ api.createTag = { method: 'POST', @@ -38,7 +38,7 @@ api.createTag = { * @apiName GetTags * @apiGroup Tag * - * @apiSuccess {Array} tags An array of tag objects + * @apiSuccess {Array} data An array of tags */ api.getTags = { method: 'GET', @@ -58,7 +58,7 @@ api.getTags = { * * @apiParam {UUID} tagId The tag _id * - * @apiSuccess {object} tag The tag object + * @apiSuccess {object} data The tag object */ api.getTag = { method: 'GET', @@ -86,7 +86,7 @@ api.getTag = { * * @apiParam {UUID} tagId The tag _id * - * @apiSuccess {object} tag The updated tag + * @apiSuccess {object} data The updated tag */ api.updateTag = { method: 'PUT', @@ -120,7 +120,7 @@ api.updateTag = { * * @apiParam {UUID} tagId The tag _id * - * @apiSuccess {object} empty An empty object + * @apiSuccess {object} data An empty object */ api.deleteTag = { method: 'DELETE', diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 5c293bdd49..c6cd66debc 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -54,13 +54,13 @@ async function _createTasks (req, res, user, challenge) { } /** - * @api {post} /api/v3/tasks/user Create a new task belonging to the autheticated user. Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @api {post} /api/v3/tasks/user Create a new task the user. + * @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 * @apiName CreateUserTasks * @apiGroup Task * - * @apiSuccess {Object} task The newly created task - * @apiSuccess {Object[]} tasks The newly created tasks (if more than one was created) + * @apiSuccess data An object if a single task was created, otherwise an array of tasks */ api.createUserTasks = { method: 'POST', @@ -73,15 +73,15 @@ api.createUserTasks = { }; /** - * @api {post} /api/v3/tasks/challenge/:challengeId Create a new task belonging to the challenge. Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @api {post} /api/v3/tasks/challenge/:challengeId Create a new task belonging to a challenge. + * @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 * @apiName CreateChallengeTasks * @apiGroup Task * * @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to. * - * @apiSuccess {Object} task The newly created task - * @apiSuccess {Object[]} tasks The newly created tasks (if more than one was created) + * @apiSuccess data An object if a single task was created, otherwise an array of tasks */ api.createChallengeTasks = { method: 'POST', @@ -171,7 +171,7 @@ async function _getTasks (req, res, user, challenge) { * * @apiParam {string="habits","dailys","todos","rewards","completedTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that requested separately. * - * @apiSuccess {Array} tasks An array of task objects + * @apiSuccess {Array} data An array of tasks */ api.getUserTasks = { method: 'GET', @@ -198,7 +198,7 @@ api.getUserTasks = { * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks. * @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks * - * @apiSuccess {Array} tasks An array of task objects + * @apiSuccess {Array} data An array of tasks */ api.getChallengeTasks = { method: 'GET', @@ -225,14 +225,14 @@ api.getChallengeTasks = { }; /** - * @api {get} /api/v3/task/:taskId Get a task given its id + * @api {get} /api/v3/task/:taskId Get a task * @apiVersion 3.0.0 * @apiName GetTask * @apiGroup Task * * @apiParam {UUID} taskId The task _id * - * @apiSuccess {object} task The task object + * @apiSuccess {object} data The task object */ api.getTask = { method: 'GET', @@ -273,7 +273,7 @@ api.getTask = { * * @apiParam {UUID} taskId The task _id * - * @apiSuccess {object} task The updated task + * @apiSuccess {object} data The updated task */ api.updateTask = { method: 'PUT', @@ -284,8 +284,6 @@ api.updateTask = { let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - // TODO check that req.body isn't empty - // TODO make sure tags are updated correctly (they aren't set as modified!) maybe use specific routes let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -304,10 +302,10 @@ api.updateTask = { throw new NotFound(res.t('taskNotFound')); } - // TODO we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? + // we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? // TODO regarding comment above, make sure other models with nested fields are using this trick too _.assign(task, Tasks.Task.sanitize(common.ops.updateTask(task.toObject(), req))); - // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) + // console.log(task.modifiedPaths(), task.toObject().repeat === tep) // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() // see https://github.com/Automattic/mongoose/issues/2749 @@ -351,7 +349,9 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { * @apiParam {UUID} taskId The task _id * @apiParam {string="up","down"} direction The direction for scoring the task * - * @apiSuccess {object} empty An empty object + * @apiSuccess {object} data._tmp If an item was dropped it'll be returned in te _tmp object + * @apiSuccess {number} data.delta + * @apiSuccess {object} data The user stats */ api.scoreTask = { method: 'POST', @@ -389,7 +389,7 @@ api.scoreTask = { let hasTask = removeFromArray(user.tasksOrder.todos, task._id); if (!hasTask) { user.tasksOrder.todos.push(task._id); // TODO push at the top? - } // If for some reason it hadn't been removed previously don't do anything TODO ok? + } // If for some reason it hadn't been removed previously don't do anything } } @@ -406,7 +406,6 @@ api.scoreTask = { sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); - // TODO test? if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { // Wrapping everything in a try/catch block because if an error occurs using `await` it MUST NOT bubble up because the request has already been handled try { @@ -423,7 +422,6 @@ api.scoreTask = { }; // completed todos cannot be moved, they'll be returned ordered by date of completion -// TODO check that it works when a tag is selected or todos are split between dated and due // TODO support challenges? /** * @api {post} /api/v3/tasks/:taskId/move/to/:position Move a task to a new position @@ -432,9 +430,9 @@ api.scoreTask = { * @apiGroup Task * * @apiParam {UUID} taskId The task _id - * @apiParam {Number} position Where to move the task (-1 means push to bottom). First position is 0 + * @apiParam {Number} position Query parameter - Where to move the task (-1 means push to bottom). First position is 0 * - * @apiSuccess {object} tasksOrder The new tasks order (user.tasksOrder.{task.type}s) + * @apiSuccess {array} data The new tasks order (user.tasksOrder.{task.type}s) */ api.moveTask = { method: 'POST', @@ -481,14 +479,14 @@ api.moveTask = { }; /** - * @api {post} /api/v3/tasks/:taskId/checklist Add an item to a checklist, creating the checklist if it doesn't exist + * @api {post} /api/v3/tasks/:taskId/checklist Add an item to the task's checklist * @apiVersion 3.0.0 * @apiName AddChecklistItem * @apiGroup Task * * @apiParam {UUID} taskId The task _id * - * @apiSuccess {object} task The updated task + * @apiSuccess {object} data The updated task */ api.addChecklistItem = { method: 'POST', @@ -499,7 +497,6 @@ api.addChecklistItem = { let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - // TODO check that req.body isn't empty and is an array let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -523,7 +520,7 @@ api.addChecklistItem = { task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); // TODO why not allow to supply _id on creation? let savedTask = await task.save(); - res.respond(200, savedTask); // TODO what to return + res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); }, }; @@ -537,7 +534,7 @@ api.addChecklistItem = { * @apiParam {UUID} taskId The task _id * @apiParam {UUID} itemId The checklist item _id * - * @apiSuccess {object} task The updated task + * @apiSuccess {object} data The updated task */ api.scoreCheckListItem = { method: 'POST', @@ -566,7 +563,7 @@ api.scoreCheckListItem = { item.completed = !item.completed; let savedTask = await task.save(); - res.respond(200, savedTask); // TODO what to return + res.respond(200, savedTask); }, }; @@ -579,7 +576,7 @@ api.scoreCheckListItem = { * @apiParam {UUID} taskId The task _id * @apiParam {UUID} itemId The checklist item _id * - * @apiSuccess {object} task The updated task + * @apiSuccess {object} data The updated task */ api.updateChecklistItem = { method: 'PUT', @@ -616,7 +613,7 @@ api.updateChecklistItem = { _.merge(item, Tasks.Task.sanitizeChecklist(req.body)); let savedTask = await task.save(); - res.respond(200, savedTask); // TODO what to return + res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); }, }; @@ -630,7 +627,7 @@ api.updateChecklistItem = { * @apiParam {UUID} taskId The task _id * @apiParam {UUID} itemId The checklist item _id * - * @apiSuccess {object} empty An empty object + * @apiSuccess {object} data The updated task */ api.removeChecklistItem = { method: 'DELETE', @@ -665,7 +662,7 @@ api.removeChecklistItem = { if (!hasItem) throw new NotFound(res.t('checklistItemNotFound')); let savedTask = await task.save(); - res.respond(200, {}); // TODO what to return + res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); }, }; @@ -679,7 +676,7 @@ api.removeChecklistItem = { * @apiParam {UUID} taskId The task _id * @apiParam {UUID} tagId The tag id * - * @apiSuccess {object} task The updated task + * @apiSuccess {object} data The updated task */ api.addTagToTask = { method: 'POST', @@ -709,12 +706,12 @@ api.addTagToTask = { task.tags.push(tagId); let savedTask = await task.save(); - res.respond(200, savedTask); // TODO what to return + res.respond(200, savedTask); }, }; /** - * @api {delete} /api/v3/tasks/:taskId/tags/:tagId Remove a tag + * @api {delete} /api/v3/tasks/:taskId/tags/:tagId Remove a tag from atask * @apiVersion 3.0.0 * @apiName RemoveTagFromTask * @apiGroup Task @@ -722,7 +719,7 @@ api.addTagToTask = { * @apiParam {UUID} taskId The task _id * @apiParam {UUID} tagId The tag id * - * @apiSuccess {object} empty An empty object + * @apiSuccess {object} data The updated task */ api.removeTagFromTask = { method: 'DELETE', @@ -747,8 +744,8 @@ api.removeTagFromTask = { let hasTag = removeFromArray(task.tags, req.params.tagId); if (!hasTag) throw new NotFound(res.t('tagNotFound')); - await task.save(); - res.respond(200, {}); // TODO what to return + let savedTask = await task.save(); + res.respond(200, savedTask); }, }; @@ -761,7 +758,7 @@ api.removeTagFromTask = { * * @apiParam {UUID} taskId The task _id * - * @apiSuccess {object} empty An empty object + * @apiSuccess {object} data An empty object */ api.unlinkTask = { method: 'POST', @@ -798,7 +795,7 @@ api.unlinkTask = { } } - res.respond(200, {}); // TODO what to return + res.respond(200, {}); }, }; @@ -808,7 +805,7 @@ api.unlinkTask = { * @apiName ClearCompletedTodos * @apiGroup Task * - * @apiSuccess {object} empty An empty object + * @apiSuccess {object} data An empty object */ api.clearCompletedTodos = { method: 'POST', @@ -841,7 +838,7 @@ api.clearCompletedTodos = { * * @apiParam {UUID} taskId The task _id * - * @apiSuccess {object} empty An empty object + * @apiSuccess {object} data An empty object */ api.deleteTask = { method: 'DELETE', diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index f0d4b8d925..f13c6d0a73 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -24,7 +24,7 @@ let api = {}; * @apiName UserGet * @apiGroup User * - * @apiSuccess {Object} user The user object + * @apiSuccess {Object} data The user object */ api.getUser = { method: 'GET', @@ -51,7 +51,7 @@ api.getUser = { * @apiName UserGetBuyList * @apiGroup User * - * @apiSuccess {Object} list The buy list + * @apiSuccess {Object} data The buy list */ api.getBuyList = { method: 'GET', @@ -141,12 +141,13 @@ let checkPreferencePurchase = (user, path, item) => { }; /** - * @api {put} /api/v3/user Update the user. Example body: {'stats.hp':50, 'preferences.background': 'beach'} + * @api {put} /api/v3/user Update the user. + * @apiDescription Example body: {'stats.hp':50, 'preferences.background': 'beach'} * @apiVersion 3.0.0 * @apiName UserUpdate * @apiGroup User * - * @apiSuccess user object The updated user object + * @apiSuccess {object} data The updated user object */ api.updateUser = { method: 'PUT', @@ -175,14 +176,14 @@ api.updateUser = { }; /** - * @api {delete} /api/v3/user DELETE an authenticated user's profile + * @api {delete} /api/v3/user DELETE an authenticated user's account * @apiVersion 3.0.0 * @apiName UserDelete * @apiGroup User * - * @apiParam {string} password The user's password unless it's a Facebook account + * @apiParam {string} password The user's password (unless it's a Facebook account) * - * @apiSuccess {Object} empty An empty Object + * @apiSuccess {Object} data An empty Object */ api.deleteUser = { method: 'DELETE', @@ -242,7 +243,9 @@ function _cleanChecklist (task) { * @apiVersion 3.0.0 * @apiName UserGetAnonymized * @apiGroup User - * @apiSuccess {Object} object The object { user, tasks } + * + * @apiSuccess {Object} data.user + * @apiSuccess {Array} data.tasks **/ api.getUserAnonymized = { method: 'GET', @@ -309,7 +312,7 @@ const partyMembersFields = 'profile.name stats achievements items.special'; * @apiParam {string} spellId The spell to cast. * @apiParam {UUID} targetId Optional query parameter, the id of the target when casting a spell on a party member or a task. * - * @apiSuccess mixed Will return the modified targets. For party members only the necessary fields will be populated. + * @apiSuccess data Will return the modified targets. For party members only the necessary fields will be populated. */ api.castSpell = { method: 'POST', @@ -419,7 +422,7 @@ api.castSpell = { * @apiName UserSleep * @apiGroup User * - * @apiSuccess {Object} Will return an object with the new `user.preferences.sleep` value. Example `{preferences: {sleep: true}}` + * @apiSuccess {Object} data Will return an object with the new `user.preferences.sleep` value. Example `{preferences: {sleep: true}}` */ api.sleep = { method: 'POST', diff --git a/website/src/controllers/top-level/auth.js b/website/src/controllers/top-level/auth.js index cf66b5996f..90724ab1f4 100644 --- a/website/src/controllers/top-level/auth.js +++ b/website/src/controllers/top-level/auth.js @@ -1,7 +1,3 @@ -import { - authWithSession, -} from '../../middlewares/api-v3/auth'; - let api = {}; // Internal authentication routes diff --git a/website/src/controllers/top-level/dataexport.js b/website/src/controllers/top-level/dataexport.js index f76e11421c..33e2fee5c2 100644 --- a/website/src/controllers/top-level/dataexport.js +++ b/website/src/controllers/top-level/dataexport.js @@ -26,11 +26,11 @@ const BASE_URL = nconf.get('BASE_URL'); let api = {}; /** - * @api {get} /export/history.csv Export user tasks history in CSV format. History is only available for habits and dailys so todos and rewards won't be included + * @api {get} /export/history.csv Export user tasks history in CSV format. + * @apiDescription History is only available for habits and dailys so todos and rewards won't be included NOTE: Part of the private API that may change at any time. * @apiVersion 3.0.0 * @apiName ExportUserHistory * @apiGroup DataExport - * @apiDescription NOTE: Part of the private API that may change at any time. * * @apiSuccess {string} A cvs file */ diff --git a/website/src/libs/api-v3/i18n.js b/website/src/libs/api-v3/i18n.js index 0486cce3f6..95a7b5c60d 100644 --- a/website/src/libs/api-v3/i18n.js +++ b/website/src/libs/api-v3/i18n.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import _ from 'lodash'; import shared from '../../../../common'; +import logger from './logger'; export const localePath = path.join(__dirname, '/../../../../common/locales/'); @@ -68,8 +69,7 @@ langCodes.forEach((code) => { momentLangs[code] = f; } catch (e) { // eslint-disable-lint no-empty - // TODO implement some type of error loggin? - // The catch block is mandatory so can't be removed + // The catch block is mandatory so it won't crash the server } }); diff --git a/website/src/models/group.js b/website/src/models/group.js index a5a43a10ce..9542500d91 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -31,7 +31,7 @@ export let schema = new Schema({ type: {type: String, enum: ['guild', 'party'], required: true}, privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true}, // _v: {type: Number,'default': 0}, // TODO ? - chat: Array, // TODO ? + chat: Array, /* # [{ # timestamp: Date @@ -44,7 +44,7 @@ export let schema = new Schema({ */ leaderOnly: { // restrict group actions to leader (members can't do them) challenges: {type: Boolean, default: false, required: true}, - // invites: {type:Boolean, 'default':false} // TODO ? + // invites: {type:Boolean, 'default':false} }, memberCount: {type: Number, default: 1}, challengeCount: {type: Number, default: 0}, @@ -66,7 +66,6 @@ export let schema = new Schema({ // Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click // 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. // TODO when booting user, remove from .joined and check again if we can now start the quest - // TODO as long as quests are party only we can keep it here members: {type: Schema.Types.Mixed, default: () => { return {}; }}, @@ -277,7 +276,7 @@ schema.methods.sendChat = function sendChat (message, user) { this.chat.unshift(chatDefaults(message, user)); this.chat.splice(200); - // Kick off chat notifications in the background. // TODO refactor + // Kick off chat notifications in the background. let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; diff --git a/website/src/models/user.js b/website/src/models/user.js index 76c730ec26..6ee6b59ca8 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -231,7 +231,6 @@ export let schema = new Schema({ }, history: { - // TODO absolutely preen these for everyone exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined todos: Array, // [{data: Date, value: Number}] // big peformance issues if these are defined }, From 2eca9fce32e08b3058fe226369318ae15a1cef04 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 17 Apr 2016 01:24:46 +0200 Subject: [PATCH 695/976] v3: fix linting --- website/src/libs/api-v3/i18n.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/libs/api-v3/i18n.js b/website/src/libs/api-v3/i18n.js index 95a7b5c60d..4c424ace39 100644 --- a/website/src/libs/api-v3/i18n.js +++ b/website/src/libs/api-v3/i18n.js @@ -2,7 +2,6 @@ import fs from 'fs'; import path from 'path'; import _ from 'lodash'; import shared from '../../../../common'; -import logger from './logger'; export const localePath = path.join(__dirname, '/../../../../common/locales/'); From 2c87a0bf349678a9dcbde86a9d82575ec086e138 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 17 Apr 2016 12:50:48 +0200 Subject: [PATCH 696/976] v2: fix casting --- website/src/controllers/api-v2/user.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 721feb72b4..aa82123866 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -612,7 +612,7 @@ api.deleteTag = function (req, res, next) { api.cast = async function(req, res, next) { try { let user = res.locals.user; - let spellId = req.params.spellId; + let spellId = req.params.spell; let targetId = req.query.targetId; let klass = shared.content.spells.special[spellId] ? 'special' : user.stats.class; @@ -876,7 +876,7 @@ api.addTask = function(req, res, next) { */ _.each(shared.ops, function(op,k){ var kv3; - + if (['rebirth', 'reroll', 'reset'].indexOf(k) !== -1) { // proxy ops that change tasks directly to v3 if (k === 'rebirth') kv3 = 'userRebirth'; // the name is different in v3 if (k === 'reroll') kv3 = 'userReroll'; From 318abfeefa7abf01b6e408df67e89dc9334370f8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 17 Apr 2016 12:54:27 +0200 Subject: [PATCH 697/976] v2: fix debug menu in production --- website/public/js/controllers/footerCtrl.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/public/js/controllers/footerCtrl.js b/website/public/js/controllers/footerCtrl.js index 42fd279944..665f054f9d 100644 --- a/website/public/js/controllers/footerCtrl.js +++ b/website/public/js/controllers/footerCtrl.js @@ -70,7 +70,8 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { /** * Debug functions. Note that the server route for gems is only available if process.env.DEBUG=true */ - if (_.contains(['development','test'],window.env.NODE_ENV)) { + // enable debug menu to test v3 TODO remove + if (true || _.contains(['development','test'],window.env.NODE_ENV)) { $scope.setHealthLow = function(){ User.set({ From 277248b8b784472ac24bc0755ce1bf92028ab5d5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 18 Apr 2016 21:56:58 +0200 Subject: [PATCH 698/976] switch to standard response format in v3 --- .../v3/unit/middlewares/errorHandler.test.js | 7 ++++++ test/api/v3/unit/middlewares/response.js | 6 +++-- test/helpers/api-integration/requester.js | 24 ++++++++++++++++--- website/src/controllers/api-v3/content.js | 4 +++- .../src/middlewares/api-v3/errorHandler.js | 3 ++- website/src/middlewares/api-v3/response.js | 6 ++++- 6 files changed, 42 insertions(+), 8 deletions(-) diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 7c75be9f28..89ce1b7db6 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -38,6 +38,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(500); expect(res.json).to.be.calledWith({ + success: false, error: 'InternalServerError', message: 'An unexpected error occurred.', }); @@ -54,6 +55,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ + success: false, error: 'Error', message: 'Error message', }); @@ -70,6 +72,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(500); expect(res.json).to.be.calledWith({ + success: false, error: 'InternalServerError', message: 'An unexpected error occurred.', }); @@ -85,6 +88,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ + success: false, error: 'BadRequest', message: 'Bad request.', }); @@ -101,6 +105,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(error.statusCode); expect(res.json).to.be.calledWith({ + success: false, error: error.name, message: error.message, }); @@ -116,6 +121,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ + success: false, error: 'BadRequest', message: 'Invalid request parameters.', errors: [ @@ -143,6 +149,7 @@ describe('errorHandler', () => { expect(res.status).to.be.calledWith(400); expect(res.json).to.be.calledWith({ + success: false, error: 'BadRequest', message: 'User validation failed.', errors: [ diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js index 25916e1ef3..296a216ec2 100644 --- a/test/api/v3/unit/middlewares/response.js +++ b/test/api/v3/unit/middlewares/response.js @@ -30,7 +30,8 @@ describe('response middleware', () => { expect(res.status).to.be.calledWith(200); expect(res.json).to.be.calledWith({ - field: 1, + success: true, + data: {field: 1}, }); }); @@ -43,7 +44,8 @@ describe('response middleware', () => { expect(res.status).to.be.calledWith(403); expect(res.json).to.be.calledWith({ - field: 1, + success: false, + data: {field: 1}, }); }); }); diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index d56593548a..a634421d13 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -61,7 +61,7 @@ function _requestMaker (user, method, additionalSets = {}) { let parsedError = _parseError(err); - reject(parsedError); + return reject(parsedError); } // if any cookies was sent, save it for the next request @@ -71,13 +71,31 @@ function _requestMaker (user, method, additionalSets = {}) { }).join('; '); } - let contentType = response.headers['content-type'] || ''; - resolve(contentType.indexOf('json') !== -1 ? response.body : response.text); + resolve(_parseRes(response)); }); }); }; } +function _parseRes (res) { + let contentType = res.headers['content-type'] || ''; + let contentDisposition = res.headers['content-disposition'] || ''; + + if (contentType.indexOf('json') === -1) { // not a json response + return res.text; + } + + if (contentDisposition.indexOf('attachment') !== -1) { + return res.body; + } + + if (apiVersion === 'v2') { + return res.body; + } else if (apiVersion === 'v3') { + return res.body.data; + } +} + function _parseError (err) { let parsedError; diff --git a/website/src/controllers/api-v3/content.js b/website/src/controllers/api-v3/content.js index 46eedf9b91..b9d7495a97 100644 --- a/website/src/controllers/api-v3/content.js +++ b/website/src/controllers/api-v3/content.js @@ -96,7 +96,9 @@ api.getContent = { res.set({ 'Content-Type': 'application/json', }); - res.status(200).send(content); // TODO how to use res.respond here? + + let jsonResString = `{"success": true, "data": ${content}}`; + res.status(200).send(jsonResString); // save the file in background unless it's already cached or being written right now if (cachedContentResponses[language] !== true && cacheBeingWritten[language] !== true) { diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index a4194435c4..d775292a5b 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -62,6 +62,7 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable } let jsonRes = { + success: false, error: responseErr.name, message: responseErr.message, }; @@ -72,5 +73,5 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable // In some occasions like when invalid JSON is supplied `res.respond` might be not yet avalaible, // in this case we use the standard res.status(...).json(...) - return res.respond ? res.respond(responseErr.httpCode, jsonRes) : res.status(responseErr.httpCode).json(jsonRes); + return res.status(responseErr.httpCode).json(jsonRes); }; diff --git a/website/src/middlewares/api-v3/response.js b/website/src/middlewares/api-v3/response.js index a3a84fd818..7fe4bc7e35 100644 --- a/website/src/middlewares/api-v3/response.js +++ b/website/src/middlewares/api-v3/response.js @@ -1,6 +1,10 @@ module.exports = function responseHandler (req, res, next) { + // Only used for successful responses res.respond = function respond (status = 200, data = {}) { - res.status(status).json(data); + res.status(status).json({ + success: status < 400, + data, + }); }; next(); From 2dd633a47114e800f2eba4c367e3586b8c0eb3a6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 19 Apr 2016 12:11:10 +0200 Subject: [PATCH 699/976] better organize common/index --- common/script/index.js | 50 ++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/common/script/index.js b/common/script/index.js index 2ae99076c3..1db3a88bb7 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -85,6 +85,29 @@ import count from './count'; api.count = count; import statsComputed from './libs/statsComputed'; +api.statsComputed = statsComputed; + +import autoAllocate from './fns/autoAllocate'; +import crit from './fns/crit'; +import handleTwoHanded from './fns/handleTwoHanded'; +import predictableRandom from './fns/predictableRandom'; +import randomDrop from './fns/randomDrop'; +import randomVal from './fns/randomVal'; +import resetGear from './fns/resetGear'; +import ultimateGear from './fns/ultimateGear'; +import updateStats from './fns/updateStats'; + +api.fns = { + autoAllocate, + crit, + handleTwoHanded, + predictableRandom, + randomDrop, + randomVal, + resetGear, + ultimateGear, + updateStats, +}; import scoreTask from './ops/scoreTask'; import sleep from './ops/sleep'; @@ -122,31 +145,6 @@ import reroll from './ops/reroll'; import addPushDevice from './ops/addPushDevice'; import reset from './ops/reset'; -import autoAllocate from './fns/autoAllocate'; -import crit from './fns/crit'; -import dotGetFn from './fns/dotGet'; -import dotSetFn from './fns/dotSet'; -import handleTwoHanded from './fns/handleTwoHanded'; -import nullify from './fns/nullify'; -import predictableRandom from './fns/predictableRandom'; -import randomDrop from './fns/randomDrop'; -import randomVal from './fns/randomVal'; -import resetGear from './fns/resetGear'; -import ultimateGear from './fns/ultimateGear'; -import updateStats from './fns/updateStats'; - -api.fns = { - autoAllocate, - crit, - handleTwoHanded, - predictableRandom, - randomDrop, - randomVal, - resetGear, - ultimateGear, - updateStats, -}; - api.ops = { scoreTask, sleep, @@ -224,7 +222,7 @@ TODO import importedOps from './ops'; import importedFns from './fns'; -// TODO redo +// TODO Kept for the client side api.wrap = function wrapUser (user, main = true) { if (user._wrapped) return; user._wrapped = true; From 4138eab15501e98d79154ac572b45d7e443a0638 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 18 Apr 2016 22:42:59 +0200 Subject: [PATCH 700/976] v3: undo changes to client that were necessary to test v2 --- website/public/js/controllers/footerCtrl.js | 3 +-- website/public/manifest.json | 4 ++++ website/src/controllers/api-v2/user.js | 2 +- website/views/index.jade | 1 - website/views/shared/footer.jade | 2 +- website/views/static/front.jade | 1 - 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/website/public/js/controllers/footerCtrl.js b/website/public/js/controllers/footerCtrl.js index 665f054f9d..42fd279944 100644 --- a/website/public/js/controllers/footerCtrl.js +++ b/website/public/js/controllers/footerCtrl.js @@ -70,8 +70,7 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { /** * Debug functions. Note that the server route for gems is only available if process.env.DEBUG=true */ - // enable debug menu to test v3 TODO remove - if (true || _.contains(['development','test'],window.env.NODE_ENV)) { + if (_.contains(['development','test'],window.env.NODE_ENV)) { $scope.setHealthLow = function(){ User.set({ diff --git a/website/public/manifest.json b/website/public/manifest.json index 4f722c5277..c4d107c03e 100644 --- a/website/public/manifest.json +++ b/website/public/manifest.json @@ -32,6 +32,8 @@ "bower_components/jquery-ui/ui/minified/jquery.ui.mouse.min.js", "bower_components/jquery-ui/ui/minified/jquery.ui.sortable.min.js", + "common/dist/scripts/habitrpg-shared.js", + "js/env.js", "js/app.js", @@ -108,6 +110,7 @@ "static": { "js": [ "bower_components/jquery/dist/jquery.min.js", + "common/dist/scripts/habitrpg-shared.js", "bower_components/angular/angular.js", "bower_components/angular-ui/build/angular-ui.js", "bower_components/angular-bootstrap/ui-bootstrap.js", @@ -140,6 +143,7 @@ "tmp_static_front": { "js": [ "bower_components/jquery/dist/jquery.min.js", + "common/dist/scripts/habitrpg-shared.js", "bower_components/angular/angular.js", "bower_components/angular-ui/build/angular-ui.js", "bower_components/jquery-colorbox/jquery.colorbox-min.js", diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index aa82123866..9ba77785bf 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -458,7 +458,7 @@ api.delete = function(req, res, next) { Development Only Operations ------------------------------------------------------------------------ */ -if (true || nconf.get('NODE_ENV') === 'development') { +if (nconf.get('NODE_ENV') === 'development') { api.addTenGems = function(req, res, next) { var user = res.locals.user; diff --git a/website/views/index.jade b/website/views/index.jade index c3dcdc08cb..b1568d254a 100644 --- a/website/views/index.jade +++ b/website/views/index.jade @@ -20,7 +20,6 @@ html(ng-app="habitrpg", ng-controller="RootCtrl", ng-class='{"applying-action":a script(type='text/javascript'). window.env = !{JSON.stringify(env._.pick(env, env.clientVars))}; - script(type='text/javascript', src='https://habitica.com/common/dist/scripts/habitrpg-shared.js') != env.getManifestFiles("app") //webfonts diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index 97ab797516..30570f27b4 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -79,7 +79,7 @@ footer.footer(ng-controller='FooterCtrl') tr td iframe(src='/bower_components/github-buttons/github-btn.html?user=habitrpg&repo=habitrpg&type=watch&count=true', allowtransparency='true', frameborder='0', scrolling='0', width='85px', height='20px') - if (true || env.NODE_ENV==='development' || env.NODE_ENV==='test') && !env.isStaticPage + if (env.NODE_ENV==='development' || env.NODE_ENV==='test') && !env.isStaticPage h4 Debug .btn-group-vertical a.btn.btn-default(ng-click='setHealthLow()') Health = 1 diff --git a/website/views/static/front.jade b/website/views/static/front.jade index ea810152f1..23cb407c9d 100644 --- a/website/views/static/front.jade +++ b/website/views/static/front.jade @@ -32,7 +32,6 @@ html(ng-app='habitrpg', ng-controller='RootCtrl') script(type='text/javascript'). window.env = !{JSON.stringify(env._.pick(env, env.clientVars))}; - script(type='text/javascript', src='https://habitica.com/common/dist/scripts/habitrpg-shared.js') != env.getManifestFiles("tmp_static_front") script(type='text/javascript', src='https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/js/bootstrap.min.js') From 9e3d8ba4ac29ce11d0f1604d87bb9f60b1e29dc9 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 19 Apr 2016 09:50:04 -0500 Subject: [PATCH 701/976] Separated out buy functions into buyGear, buyArmoire, and buyPotion (#7065) --- common/locales/en/api-v3.json | 6 +- common/script/index.js | 9 + common/script/ops/buy.js | 134 +------- common/script/ops/buyArmoire.js | 116 +++++++ common/script/ops/buyGear.js | 72 +++++ common/script/ops/buyPotion.js | 52 ++++ common/script/ops/index.js | 6 + .../v3/integration/user/POST-user_buy.test.js | 19 +- .../user/POST-user_buy_armoire.test.js | 44 +++ .../user/POST-user_buy_gear.test.js | 34 +++ .../user/POST-user_buy_potion.test.js | 44 +++ test/common/ops/buy.js | 289 ++---------------- test/common/ops/buyArmoire.js | 187 ++++++++++++ test/common/ops/buyGear.js | 132 ++++++++ test/common/ops/buyPotion.js | 65 ++++ website/src/controllers/api-v3/user.js | 77 ++++- 16 files changed, 877 insertions(+), 409 deletions(-) create mode 100644 common/script/ops/buyArmoire.js create mode 100644 common/script/ops/buyGear.js create mode 100644 common/script/ops/buyPotion.js create mode 100644 test/api/v3/integration/user/POST-user_buy_armoire.test.js create mode 100644 test/api/v3/integration/user/POST-user_buy_gear.test.js create mode 100644 test/api/v3/integration/user/POST-user_buy_potion.test.js create mode 100644 test/common/ops/buyArmoire.js create mode 100644 test/common/ops/buyGear.js create mode 100644 test/common/ops/buyPotion.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 7af8dc9af0..183b358e2a 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -113,14 +113,13 @@ "missingKeyParam": "\"req.params.key\" is required.", "mysterySetNotFound": "Mystery set not found, or set already owned.", "itemNotFound": "Item \"<%= key %>\" not found.", - "cannoyBuyItem": "You can't buy this item.", + "cannotBuyItem": "You can't buy this item.", "missingTypeKeyEquip": "\"key\" and \"type\" are required parameters.", "missingPetFoodFeed": "\"pet\" and \"food\" are required parameters.", "invalidPetName": "Invalid pet name supplied.", "missingEggHatchingPotionHatch": "\"egg\" and \"hatchingPotion\" are required parameters.", "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'.", "cannotDeleteActiveAccount": "You have an active subscription, cancel your plan before deleting your account.", - "cannoyBuyItem": "You can't buy this item", "messageRequired": "A message is required.", "toUserIDRequired": "A toUserId is required", "notAuthorizedToSendMessageToThisUser": "Can't send message to this user.", @@ -170,5 +169,6 @@ "pushDeviceAdded": "Push device added successfully", "pushDeviceAlreadyAdded": "The user already has the push device", "resetComplete": "Reset completed", - "lvl10ChangeClass": "To change class you must be at least level 10." + "lvl10ChangeClass": "To change class you must be at least level 10.", + "equipmentAlreadyOwned": "You already own that piece of equipment" } diff --git a/common/script/index.js b/common/script/index.js index 1db3a88bb7..5191ba5802 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -113,6 +113,9 @@ import scoreTask from './ops/scoreTask'; import sleep from './ops/sleep'; import allocate from './ops/allocate'; import buy from './ops/buy'; +import buyGear from './ops/buyGear'; +import buyPotion from './ops/buyPotion'; +import buyArmoire from './ops/buyArmoire'; import buyMysterySet from './ops/buyMysterySet'; import buyQuest from './ops/buyQuest'; import buySpecialSpell from './ops/buySpecialSpell'; @@ -150,6 +153,9 @@ api.ops = { sleep, allocate, buy, + buyGear, + buyPotion, + buyArmoire, buyMysterySet, buySpecialSpell, buyQuest, @@ -266,6 +272,9 @@ api.wrap = function wrapUser (user, main = true) { releaseMounts: _.partial(importedOps.releaseMounts, user), releaseBoth: _.partial(importedOps.releaseBoth, user), buy: _.partial(importedOps.buy, user), + buyPotion: _.partial(importedOps.buyPotion, user), + buyArmoire: _.partial(importedOps.buyArmoire, user), + buyGear: _.partial(importedOps.buyGear, user), buyQuest: _.partial(importedOps.buyQuest, user), buyMysterySet: _.partial(importedOps.buyMysterySet, user), hourglassPurchase: _.partial(importedOps.hourglassPurchase, user), diff --git a/common/script/ops/buy.js b/common/script/ops/buy.js index 268be94b39..d62842acea 100644 --- a/common/script/ops/buy.js +++ b/common/script/ops/buy.js @@ -1,142 +1,24 @@ -import content from '../content/index'; import i18n from '../i18n'; import _ from 'lodash'; -import count from '../count'; -import splitWhitespace from '../libs/splitWhitespace'; import { BadRequest, - NotAuthorized, - NotFound, } from '../libs/errors'; -import predictableRandom from '../fns/predictableRandom'; -import randomVal from '../fns/randomVal'; -import handleTwoHanded from '../fns/handleTwoHanded'; -import ultimateGear from '../fns/ultimateGear'; +import buyPotion from './buyPotion'; +import buyArmoire from './buyArmoire'; +import buyGear from './buyGear'; module.exports = function buy (user, req = {}, analytics) { let key = _.get(req, 'params.key'); if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); - let item; + let buyRes; if (key === 'potion') { - item = content.potion; + buyRes = buyPotion(user, req, analytics); } else if (key === 'armoire') { - item = content.armoire; + buyRes = buyArmoire(user, req, analytics); } else { - item = content.gear.flat[key]; - } - if (!item) throw new NotFound(i18n.t('itemNotFound', {key}, req.language)); - - if (user.stats.gp < item.value) { - throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + buyRes = buyGear(user, req, analytics); } - if (item.canOwn && !item.canOwn(user)) { - throw new NotAuthorized(i18n.t('cannoyBuyItem', req.language)); - } - - let armoireResp; - let armoireResult; - let eligibleEquipment; - let drop; - let message; - - if (item.key === 'potion') { - user.stats.hp += 15; - if (user.stats.hp > 50) { - user.stats.hp = 50; - } - } else if (item.key === 'armoire') { - armoireResult = predictableRandom(user, user.stats.gp); - eligibleEquipment = _.filter(content.gear.flat, (eligible) => { - return eligible.klass === 'armoire' && !user.items.gear.owned[eligible.key]; - }); - - if (!_.isEmpty(eligibleEquipment) && (armoireResult < 0.6 || !user.flags.armoireOpened)) { - eligibleEquipment.sort(); - drop = randomVal(user, eligibleEquipment); - - user.items.gear.owned[drop.key] = true; - user.flags.armoireOpened = true; - message = i18n.t('armoireEquipment', { - image: ``, - dropText: drop.text(req.language), - }, req.language); - - if (count.remainingGearInSet(user.items.gear.owned, 'armoire') === 0) { - user.flags.armoireEmpty = true; - } - - armoireResp = { - type: 'gear', - dropKey: drop.key, - dropText: drop.text(req.language), - }; - } else if ((!_.isEmpty(eligibleEquipment) && armoireResult < 0.8) || armoireResult < 0.5) { // eslint-disable-line no-extra-parens - drop = randomVal(user, _.where(content.food, { - canDrop: true, - })); - user.items.food[drop.key] = user.items.food[drop.key] || 0; - user.items.food[drop.key] += 1; - - message = i18n.t('armoireFood', { - image: ``, - dropArticle: drop.article, - dropText: drop.text(req.language), - }, req.language); - armoireResp = { - type: 'food', - dropKey: drop.key, - dropArticle: drop.article, - dropText: drop.text(req.language), - }; - } else { - let armoireExp = Math.floor(predictableRandom(user, user.stats.exp) * 40 + 10); - user.stats.exp += armoireExp; - message = i18n.t('armoireExp', req.language); - armoireResp = { - type: 'experience', - value: armoireExp, - }; - } - } else { - if (user.preferences.autoEquip) { - user.items.gear.equipped[item.type] = item.key; - message = handleTwoHanded(user, item, undefined, req); - } - user.items.gear.owned[item.key] = true; - - if (item.last) ultimateGear(user); - } - - user.stats.gp -= item.value; - - if (!message) { - message = i18n.t('messageBought', { - itemText: item.text(req.language), - }, req.language); - } - - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: key, - acquireMethod: 'Gold', - goldCost: item.value, - category: 'behavior', - }); - } - - let res = { - data: _.pick(user, splitWhitespace('items achievements stats flags')), - message, - }; - - if (armoireResp) res.armoire = armoireResp; - - if (req.v2 === true) { - return res.data; - } else { - return res; - } + return buyRes; }; diff --git a/common/script/ops/buyArmoire.js b/common/script/ops/buyArmoire.js new file mode 100644 index 0000000000..9b2a8af8e2 --- /dev/null +++ b/common/script/ops/buyArmoire.js @@ -0,0 +1,116 @@ +import content from '../content/index'; +import i18n from '../i18n'; +import _ from 'lodash'; +import count from '../count'; +import splitWhitespace from '../libs/splitWhitespace'; +import { + NotAuthorized, +} from '../libs/errors'; +import predictableRandom from '../fns/predictableRandom'; +import randomVal from '../fns/randomVal'; + +module.exports = function buyArmoire (user, req = {}, analytics) { + let item = content.armoire; + + if (user.stats.gp < item.value) { + throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + } + + if (item.canOwn && !item.canOwn(user)) { + throw new NotAuthorized(i18n.t('cannotBuyItem', req.language)); + } + + let armoireResp; + let armoireResult; + let eligibleEquipment; + let drop; + let message; + + armoireResult = predictableRandom(user, user.stats.gp); + eligibleEquipment = _.filter(content.gear.flat, (eligible) => { + return eligible.klass === 'armoire' && !user.items.gear.owned[eligible.key]; + }); + + if (!_.isEmpty(eligibleEquipment) && (armoireResult < 0.6 || !user.flags.armoireOpened)) { + eligibleEquipment.sort(); + drop = randomVal(user, eligibleEquipment); + + if (user.items.gear.owned[drop.key]) { + throw new NotAuthorized(i18n.t('equipmentAlradyOwned', req.language)); + } + + user.items.gear.owned[drop.key] = true; + user.flags.armoireOpened = true; + message = i18n.t('armoireEquipment', { + image: ``, + dropText: drop.text(req.language), + }, req.language); + + if (count.remainingGearInSet(user.items.gear.owned, 'armoire') === 0) { + user.flags.armoireEmpty = true; + } + + armoireResp = { + type: 'gear', + dropKey: drop.key, + dropText: drop.text(req.language), + }; + } else if ((!_.isEmpty(eligibleEquipment) && armoireResult < 0.8) || armoireResult < 0.5) { // eslint-disable-line no-extra-parens + drop = randomVal(user, _.where(content.food, { + canDrop: true, + })); + user.items.food[drop.key] = user.items.food[drop.key] || 0; + user.items.food[drop.key] += 1; + + message = i18n.t('armoireFood', { + image: ``, + dropArticle: drop.article, + dropText: drop.text(req.language), + }, req.language); + armoireResp = { + type: 'food', + dropKey: drop.key, + dropArticle: drop.article, + dropText: drop.text(req.language), + }; + } else { + let armoireExp = Math.floor(predictableRandom(user, user.stats.exp) * 40 + 10); + user.stats.exp += armoireExp; + message = i18n.t('armoireExp', req.language); + armoireResp = { + type: 'experience', + value: armoireExp, + }; + } + + user.stats.gp -= item.value; + + if (!message) { + message = i18n.t('messageBought', { + itemText: item.text(req.language), + }, req.language); + } + + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: 'Armoire', + acquireMethod: 'Gold', + goldCost: item.value, + category: 'behavior', + }); + } + + let res = { + data: _.pick(user, splitWhitespace('items flags')), + message, + }; + + if (armoireResp) res.armoire = armoireResp; + + if (req.v2 === true) { + return res.data; + } else { + return res; + } +}; diff --git a/common/script/ops/buyGear.js b/common/script/ops/buyGear.js new file mode 100644 index 0000000000..2bad8135cd --- /dev/null +++ b/common/script/ops/buyGear.js @@ -0,0 +1,72 @@ +import content from '../content/index'; +import i18n from '../i18n'; +import _ from 'lodash'; +import splitWhitespace from '../libs/splitWhitespace'; +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../libs/errors'; +import handleTwoHanded from '../fns/handleTwoHanded'; +import ultimateGear from '../fns/ultimateGear'; + +module.exports = function buyGear (user, req = {}, analytics) { + let key = _.get(req, 'params.key'); + if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); + + let item = content.gear.flat[key]; + + if (!item) throw new NotFound(i18n.t('itemNotFound', {key}, req.language)); + + if (user.stats.gp < item.value) { + throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + } + + if (item.canOwn && !item.canOwn(user)) { + throw new NotAuthorized(i18n.t('cannotBuyItem', req.language)); + } + + let message; + + if (user.items.gear.owned[item.key]) { + throw new NotAuthorized(i18n.t('equipmentAlreadyOwned', req.language)); + } + + if (user.preferences.autoEquip) { + user.items.gear.equipped[item.type] = item.key; + message = handleTwoHanded(user, item, undefined, req); + } + + user.items.gear.owned[item.key] = true; + + if (item.last) ultimateGear(user); + + user.stats.gp -= item.value; + + if (!message) { + message = i18n.t('messageBought', { + itemText: item.text(req.language), + }, req.language); + } + + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: key, + acquireMethod: 'Gold', + goldCost: item.value, + category: 'behavior', + }); + } + + let res = { + data: _.pick(user, splitWhitespace('items achievements stats flags')), + message, + }; + + if (req.v2 === true) { + return res.data; + } else { + return res; + } +}; diff --git a/common/script/ops/buyPotion.js b/common/script/ops/buyPotion.js new file mode 100644 index 0000000000..f797ff903b --- /dev/null +++ b/common/script/ops/buyPotion.js @@ -0,0 +1,52 @@ +import content from '../content/index'; +import i18n from '../i18n'; +import _ from 'lodash'; +import splitWhitespace from '../libs/splitWhitespace'; +import { + NotAuthorized, +} from '../libs/errors'; + +module.exports = function buyPotion (user, req = {}, analytics) { + let item = content.potion; + + if (user.stats.gp < item.value) { + throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + } + + if (item.canOwn && !item.canOwn(user)) { + throw new NotAuthorized(i18n.t('cannotBuyItem', req.language)); + } + + user.stats.hp += 15; + if (user.stats.hp > 50) { + user.stats.hp = 50; + } + + user.stats.gp -= item.value; + + let message = i18n.t('messageBought', { + itemText: item.text(req.language), + }, req.language); + + + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: 'Potion', + acquireMethod: 'Gold', + goldCost: item.value, + category: 'behavior', + }); + } + + let res = { + data: _.pick(user, splitWhitespace('stats')), + message, + }; + + if (req.v2 === true) { + return res.data; + } else { + return res; + } +}; diff --git a/common/script/ops/index.js b/common/script/ops/index.js index a2775f7d2a..4b06ac93e6 100644 --- a/common/script/ops/index.js +++ b/common/script/ops/index.js @@ -30,6 +30,9 @@ import releasePets from './releasePets'; import releaseMounts from './releaseMounts'; import releaseBoth from './releaseBoth'; import buy from './buy'; +import buyGear from './buyGear'; +import buyPotion from './buyPotion'; +import buyArmoire from './buyArmoire'; import buyQuest from './buyQuest'; import buyMysterySet from './buyMysterySet'; import hourglassPurchase from './hourglassPurchase'; @@ -77,6 +80,9 @@ module.exports = { releaseMounts, releaseBoth, buy, + buyGear, + buyPotion, + buyArmoire, buyQuest, buyMysterySet, hourglassPurchase, diff --git a/test/api/v3/integration/user/POST-user_buy.test.js b/test/api/v3/integration/user/POST-user_buy.test.js index bca65a2ab7..08e20617f0 100644 --- a/test/api/v3/integration/user/POST-user_buy.test.js +++ b/test/api/v3/integration/user/POST-user_buy.test.js @@ -26,17 +26,28 @@ describe('POST /user/buy/:key', () => { }); }); - it('buys an item', async () => { + it('buys a potion', async () => { + await user.update({ + 'stats.gp': 400, + }); + let potion = content.potion; let res = await user.post('/user/buy/potion'); await user.sync(); + expect(user.stats.hp).to.equal(50); expect(res.data).to.eql({ - items: JSON.parse(JSON.stringify(user.items)), // otherwise dates can't be compared - achievements: user.achievements, stats: user.stats, - flags: JSON.parse(JSON.stringify(user.flags)), // otherwise dates can't be compared }); expect(res.message).to.equal(t('messageBought', {itemText: potion.text()})); }); + + it('buys a piece of gear', async () => { + let key = 'armor_warrior_1'; + + await user.post(`/user/buy/${key}`); + await user.sync(); + + expect(user.items.gear.owned).to.eql({ armor_warrior_1: true }); // eslint-disable-line camelcase + }); }); diff --git a/test/api/v3/integration/user/POST-user_buy_armoire.test.js b/test/api/v3/integration/user/POST-user_buy_armoire.test.js new file mode 100644 index 0000000000..7538be64b8 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_buy_armoire.test.js @@ -0,0 +1,44 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import shared from '../../../../../common/script'; + +let content = shared.content; + +describe('POST /user/buy-armoire', () => { + let user; + + beforeEach(async () => { + user = await generateUser({ + 'stats.hp': 40, + }); + }); + + // More tests in common code unit tests + + it('returns an error if user does not have enough gold', async () => { + await expect(user.post('/user/buy-potion')) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageNotEnoughGold'), + }); + }); + + xit('buys a piece of armoire', async () => { + await user.update({ + 'stats.gp': 400, + }); + + let potion = content.potion; + let res = await user.post('/user/buy-potion'); + await user.sync(); + + expect(user.stats.hp).to.equal(50); + expect(res.data).to.eql({ + stats: user.stats, + }); + expect(res.message).to.equal(t('messageBought', {itemText: potion.text()})); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_buy_gear.test.js b/test/api/v3/integration/user/POST-user_buy_gear.test.js new file mode 100644 index 0000000000..5347b94da9 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_buy_gear.test.js @@ -0,0 +1,34 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/buy-gear/:key', () => { + let user; + + beforeEach(async () => { + user = await generateUser({ + 'stats.gp': 400, + }); + }); + + // More tests in common code unit tests + + it('returns an error if the item is not found', async () => { + await expect(user.post('/user/buy-gear/notExisting')) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('itemNotFound', {key: 'notExisting'}), + }); + }); + + it('buys a piece of gear', async () => { + let key = 'armor_warrior_1'; + + await user.post(`/user/buy-gear/${key}`); + await user.sync(); + + expect(user.items.gear.owned).to.eql({ armor_warrior_1: true }); // eslint-disable-line camelcase + }); +}); diff --git a/test/api/v3/integration/user/POST-user_buy_potion.test.js b/test/api/v3/integration/user/POST-user_buy_potion.test.js new file mode 100644 index 0000000000..4616ae8f34 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_buy_potion.test.js @@ -0,0 +1,44 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import shared from '../../../../../common/script'; + +let content = shared.content; + +describe('POST /user/buy-potion', () => { + let user; + + beforeEach(async () => { + user = await generateUser({ + 'stats.hp': 40, + }); + }); + + // More tests in common code unit tests + + it('returns an error if user does not have enough gold', async () => { + await expect(user.post('/user/buy-potion')) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageNotEnoughGold'), + }); + }); + + it('buys a potion', async () => { + await user.update({ + 'stats.gp': 400, + }); + + let potion = content.potion; + let res = await user.post('/user/buy-potion'); + await user.sync(); + + expect(user.stats.hp).to.equal(50); + expect(res.data).to.eql({ + stats: user.stats, + }); + expect(res.message).to.equal(t('messageBought', {itemText: potion.text()})); + }); +}); diff --git a/test/common/ops/buy.js b/test/common/ops/buy.js index b50d160623..abaf3c849d 100644 --- a/test/common/ops/buy.js +++ b/test/common/ops/buy.js @@ -1,15 +1,10 @@ /* eslint-disable camelcase */ - -import sinon from 'sinon'; // eslint-disable-line no-shadow import { generateUser, } from '../../helpers/common.helper'; -import count from '../../../common/script/count'; import buy from '../../../common/script/ops/buy'; -import shared from '../../../common/script'; -import content from '../../../common/script/content/index'; import { - NotAuthorized, + BadRequest, } from '../../../common/script/libs/errors'; import i18n from '../../../common/script/i18n'; @@ -30,277 +25,27 @@ describe('shared.ops.buy', () => { }, stats: { gp: 200 }, }); - - sinon.stub(shared.fns, 'randomVal'); - sinon.stub(shared.fns, 'predictableRandom'); }); - afterEach(() => { - shared.fns.randomVal.restore(); - shared.fns.predictableRandom.restore(); + it('returns error when key is not provided', (done) => { + try { + buy(user); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('missingKeyParam')); + done(); + } }); - context('Potion', () => { - it('recovers 15 hp', () => { - user.stats.hp = 30; - buy(user, {params: {key: 'potion'}}); - expect(user.stats.hp).to.eql(45); - }); - - it('does not increase hp above 50', () => { - user.stats.hp = 45; - buy(user, {params: {key: 'potion'}}); - expect(user.stats.hp).to.eql(50); - }); - - it('deducts 25 gp', () => { - user.stats.hp = 45; - buy(user, {params: {key: 'potion'}}); - - expect(user.stats.gp).to.eql(175); - }); - - it('does not purchase if not enough gp', (done) => { - user.stats.hp = 45; - user.stats.gp = 5; - try { - buy(user, {params: {key: 'potion'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); - expect(user.stats.hp).to.eql(45); - expect(user.stats.gp).to.eql(5); - - done(); - } - }); + it('recovers 15 hp', () => { + user.stats.hp = 30; + buy(user, {params: {key: 'potion'}}); + expect(user.stats.hp).to.eql(45); }); - context('Gear', () => { - it('adds equipment to inventory', () => { - user.stats.gp = 31; - - buy(user, {params: {key: 'armor_warrior_1'}}); - - expect(user.items.gear.owned).to.eql({ weapon_warrior_0: true, armor_warrior_1: true }); - }); - - it('deducts gold from user', () => { - user.stats.gp = 31; - - buy(user, {params: {key: 'armor_warrior_1'}}); - - expect(user.stats.gp).to.eql(1); - }); - - it('auto equips equipment if user has auto-equip preference turned on', () => { - user.stats.gp = 31; - user.preferences.autoEquip = true; - - buy(user, {params: {key: 'armor_warrior_1'}}); - - expect(user.items.gear.equipped).to.have.property('armor', 'armor_warrior_1'); - }); - - it('buys equipment but does not auto-equip', () => { - user.stats.gp = 31; - user.preferences.autoEquip = false; - - buy(user, {params: {key: 'armor_warrior_1'}}); - - expect(user.items.gear.equipped.property).to.not.equal('armor_warrior_1'); - }); - - // TODO after user.ops.equip is done - xit('removes one-handed weapon and shield if auto-equip is on and a two-hander is bought', () => { - user.stats.gp = 100; - user.preferences.autoEquip = true; - buy(user, {params: {key: 'shield_warrior_1'}}); - user.ops.equip({params: {key: 'shield_warrior_1'}}); - buy(user, {params: {key: 'weapon_warrior_1'}}); - user.ops.equip({params: {key: 'weapon_warrior_1'}}); - - buy(user, {params: {key: 'weapon_wizard_1'}}); - - expect(user.items.gear.equipped).to.have.property('shield', 'shield_base_0'); - expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_wizard_1'); - }); - - // TODO after user.ops.equip is done - xit('buys two-handed equipment but does not automatically remove sword or shield', () => { - user.stats.gp = 100; - user.preferences.autoEquip = false; - buy(user, {params: {key: 'shield_warrior_1'}}); - user.ops.equip({params: {key: 'shield_warrior_1'}}); - buy(user, {params: {key: 'weapon_warrior_1'}}); - user.ops.equip({params: {key: 'weapon_warrior_1'}}); - - buy(user, {params: {key: 'weapon_wizard_1'}}); - - expect(user.items.gear.equipped).to.have.property('shield', 'shield_warrior_1'); - expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_warrior_1'); - }); - - it('does not buy equipment without enough Gold', (done) => { - user.stats.gp = 20; - - try { - buy(user, {params: {key: 'armor_warrior_1'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); - expect(user.items.gear.owned).to.not.have.property('armor_warrior_1'); - done(); - } - }); - }); - - context('Enchanted Armoire', () => { - let YIELD_EQUIPMENT = 0.5; - let YIELD_FOOD = 0.7; - let YIELD_EXP = 0.9; - - let fullArmoire = {}; - - _(content.gearTypes).each((type) => { - _(content.gear.tree[type].armoire).each((gearObject) => { - let armoireKey = gearObject.key; - - fullArmoire[armoireKey] = true; - }).value(); - }).value(); - - beforeEach(() => { - user.achievements.ultimateGearSets = { rogue: true }; - user.flags.armoireOpened = true; - user.stats.exp = 0; - user.items.food = {}; - }); - - context('failure conditions', () => { - it('does not open if user does not have enough gold', (done) => { - shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); - user.stats.gp = 50; - - try { - buy(user, {params: {key: 'armoire'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); - expect(user.items.food).to.be.empty; - expect(user.stats.exp).to.eql(0); - done(); - } - }); - - it('does not open without Ultimate Gear achievement', (done) => { - shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); - user.achievements.ultimateGearSets = {healer: false, wizard: false, rogue: false, warrior: false}; - - try { - buy(user, {params: {key: 'armoire'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('cannoyBuyItem')); - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); - expect(user.items.food).to.be.empty; - expect(user.stats.exp).to.eql(0); - done(); - } - }); - }); - - context('non-gear awards', () => { - // Skipped because can't stub predictableRandom correctly - xit('gives Experience', () => { - shared.fns.predictableRandom.returns(YIELD_EXP); - - buy(user, {params: {key: 'armoire'}}); - - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); - expect(user.items.food).to.be.empty; - expect(user.stats.exp).to.eql(46); - expect(user.stats.gp).to.eql(100); - }); - - // Skipped because can't stub predictableRandom correctly - xit('gives food', () => { - let honey = content.food.Honey; - - shared.fns.randomVal.returns(honey); - shared.fns.predictableRandom.returns(YIELD_FOOD); - - buy(user, {params: {key: 'armoire'}}); - - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); - expect(user.items.food).to.eql({Honey: 1}); - expect(user.stats.exp).to.eql(0); - expect(user.stats.gp).to.eql(100); - }); - - // Skipped because can't stub predictableRandom correctly - xit('does not give equipment if all equipment has been found', () => { - shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); - user.items.gear.owned = fullArmoire; - user.stats.gp = 150; - - buy(user, {params: {key: 'armoire'}}); - - expect(user.items.gear.owned).to.eql(fullArmoire); - let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); - - expect(armoireCount).to.eql(0); - - expect(user.stats.exp).to.eql(30); - expect(user.stats.gp).to.eql(50); - }); - }); - - context('gear awards', () => { - beforeEach(() => { - let shield = content.gear.tree.shield.armoire.gladiatorShield; - - shared.fns.randomVal.returns(shield); - }); - - // Skipped because can't stub predictableRandom correctly - xit('always drops equipment the first time', () => { - delete user.flags.armoireOpened; - shared.fns.predictableRandom.returns(YIELD_EXP); - - buy(user, {params: {key: 'armoire'}}); - - expect(user.items.gear.owned).to.eql({ - weapon_warrior_0: true, - shield_armoire_gladiatorShield: true, - }); - - let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); - - expect(armoireCount).to.eql(_.size(fullArmoire) - 1); - expect(user.items.food).to.be.empty; - expect(user.stats.exp).to.eql(0); - expect(user.stats.gp).to.eql(100); - }); - - // Skipped because can't stub predictableRandom correctly - xit('gives more equipment', () => { - shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); - user.items.gear.owned = { - weapon_warrior_0: true, - head_armoire_hornedIronHelm: true, - }; - user.stats.gp = 200; - - buy(user, {params: {key: 'armoire'}}); - - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true, shield_armoire_gladiatorShield: true, head_armoire_hornedIronHelm: true}); - let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); - - expect(armoireCount).to.eql(_.size(fullArmoire) - 2); - expect(user.stats.gp).to.eql(100); - }); - }); + it('adds equipment to inventory', () => { + user.stats.gp = 31; + buy(user, {params: {key: 'armor_warrior_1'}}); + expect(user.items.gear.owned).to.eql({ weapon_warrior_0: true, armor_warrior_1: true }); }); }); diff --git a/test/common/ops/buyArmoire.js b/test/common/ops/buyArmoire.js new file mode 100644 index 0000000000..2c71c9b428 --- /dev/null +++ b/test/common/ops/buyArmoire.js @@ -0,0 +1,187 @@ +/* eslint-disable camelcase */ + +import sinon from 'sinon'; // eslint-disable-line no-shadow +import { + generateUser, +} from '../../helpers/common.helper'; +import count from '../../../common/script/count'; +import buyArmoire from '../../../common/script/ops/buyArmoire'; +import shared from '../../../common/script'; +import content from '../../../common/script/content/index'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; + +describe('shared.ops.buyArmoire', () => { + let user; + let YIELD_EQUIPMENT = 0.5; + let YIELD_FOOD = 0.7; + let YIELD_EXP = 0.9; + + let fullArmoire = {}; + + _(content.gearTypes).each((type) => { + _(content.gear.tree[type].armoire).each((gearObject) => { + let armoireKey = gearObject.key; + + fullArmoire[armoireKey] = true; + }).value(); + }).value(); + + + beforeEach(() => { + user = generateUser({ + items: { + gear: { + owned: { + weapon_warrior_0: true, + }, + equipped: { + weapon_warrior_0: true, + }, + }, + }, + stats: { gp: 200 }, + }); + + user.achievements.ultimateGearSets = { rogue: true }; + user.flags.armoireOpened = true; + user.stats.exp = 0; + user.items.food = {}; + + sinon.stub(shared.fns, 'randomVal'); + sinon.stub(shared.fns, 'predictableRandom'); + }); + + afterEach(() => { + shared.fns.randomVal.restore(); + shared.fns.predictableRandom.restore(); + }); + + context('failure conditions', () => { + it('does not open if user does not have enough gold', (done) => { + shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); + user.stats.gp = 50; + + try { + buyArmoire(user); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + expect(user.items.food).to.be.empty; + expect(user.stats.exp).to.eql(0); + done(); + } + }); + + it('does not open without Ultimate Gear achievement', (done) => { + shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); + user.achievements.ultimateGearSets = {healer: false, wizard: false, rogue: false, warrior: false}; + + try { + buyArmoire(user); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('cannotBuyItem')); + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + expect(user.items.food).to.be.empty; + expect(user.stats.exp).to.eql(0); + done(); + } + }); + }); + + context('non-gear awards', () => { + // Skipped because can't stub predictableRandom correctly + xit('gives Experience', () => { + shared.fns.predictableRandom.returns(YIELD_EXP); + + buyArmoire(user); + + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + expect(user.items.food).to.be.empty; + expect(user.stats.exp).to.eql(46); + expect(user.stats.gp).to.eql(100); + }); + + // Skipped because can't stub predictableRandom correctly + xit('gives food', () => { + let honey = content.food.Honey; + + shared.fns.randomVal.returns(honey); + shared.fns.predictableRandom.returns(YIELD_FOOD); + + buyArmoire(user); + + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + expect(user.items.food).to.eql({Honey: 1}); + expect(user.stats.exp).to.eql(0); + expect(user.stats.gp).to.eql(100); + }); + + // Skipped because can't stub predictableRandom correctly + xit('does not give equipment if all equipment has been found', () => { + shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); + user.items.gear.owned = fullArmoire; + user.stats.gp = 150; + + buyArmoire(user); + + expect(user.items.gear.owned).to.eql(fullArmoire); + let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); + + expect(armoireCount).to.eql(0); + + expect(user.stats.exp).to.eql(30); + expect(user.stats.gp).to.eql(50); + }); + }); + + context('gear awards', () => { + beforeEach(() => { + let shield = content.gear.tree.shield.armoire.gladiatorShield; + + shared.fns.randomVal.returns(shield); + }); + + // Skipped because can't stub predictableRandom correctly + xit('always drops equipment the first time', () => { + delete user.flags.armoireOpened; + shared.fns.predictableRandom.returns(YIELD_EXP); + + buyArmoire(user); + + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: true, + shield_armoire_gladiatorShield: true, + }); + + let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); + + expect(armoireCount).to.eql(_.size(fullArmoire) - 1); + expect(user.items.food).to.be.empty; + expect(user.stats.exp).to.eql(0); + expect(user.stats.gp).to.eql(100); + }); + + // Skipped because can't stub predictableRandom correctly + xit('gives more equipment', () => { + shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); + user.items.gear.owned = { + weapon_warrior_0: true, + head_armoire_hornedIronHelm: true, + }; + user.stats.gp = 200; + + buyArmoire(user); + + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true, shield_armoire_gladiatorShield: true, head_armoire_hornedIronHelm: true}); + let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); + + expect(armoireCount).to.eql(_.size(fullArmoire) - 2); + expect(user.stats.gp).to.eql(100); + }); + }); +}); diff --git a/test/common/ops/buyGear.js b/test/common/ops/buyGear.js new file mode 100644 index 0000000000..fc26057ef8 --- /dev/null +++ b/test/common/ops/buyGear.js @@ -0,0 +1,132 @@ +/* eslint-disable camelcase */ + +import sinon from 'sinon'; // eslint-disable-line no-shadow +import { + generateUser, +} from '../../helpers/common.helper'; +import buyGear from '../../../common/script/ops/buyGear'; +import shared from '../../../common/script'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; + +describe('shared.ops.buyGear', () => { + let user; + + beforeEach(() => { + user = generateUser({ + items: { + gear: { + owned: { + weapon_warrior_0: true, + }, + equipped: { + weapon_warrior_0: true, + }, + }, + }, + stats: { gp: 200 }, + }); + + sinon.stub(shared.fns, 'randomVal'); + sinon.stub(shared.fns, 'predictableRandom'); + }); + + afterEach(() => { + shared.fns.randomVal.restore(); + shared.fns.predictableRandom.restore(); + }); + + context('Gear', () => { + it('adds equipment to inventory', () => { + user.stats.gp = 31; + + buyGear(user, {params: {key: 'armor_warrior_1'}}); + + expect(user.items.gear.owned).to.eql({ weapon_warrior_0: true, armor_warrior_1: true }); + }); + + it('deducts gold from user', () => { + user.stats.gp = 31; + + buyGear(user, {params: {key: 'armor_warrior_1'}}); + + expect(user.stats.gp).to.eql(1); + }); + + it('auto equips equipment if user has auto-equip preference turned on', () => { + user.stats.gp = 31; + user.preferences.autoEquip = true; + + buyGear(user, {params: {key: 'armor_warrior_1'}}); + + expect(user.items.gear.equipped).to.have.property('armor', 'armor_warrior_1'); + }); + + it('buyGears equipment but does not auto-equip', () => { + user.stats.gp = 31; + user.preferences.autoEquip = false; + + buyGear(user, {params: {key: 'armor_warrior_1'}}); + + expect(user.items.gear.equipped.property).to.not.equal('armor_warrior_1'); + }); + + it('does not buyGear equipment twice', (done) => { + user.stats.gp = 62; + buyGear(user, {params: {key: 'armor_warrior_1'}}); + + try { + buyGear(user, {params: {key: 'armor_warrior_1'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('equipmentAlreadyOwned')); + done(); + } + }); + + // TODO after user.ops.equip is done + xit('removes one-handed weapon and shield if auto-equip is on and a two-hander is bought', () => { + user.stats.gp = 100; + user.preferences.autoEquip = true; + buyGear(user, {params: {key: 'shield_warrior_1'}}); + user.ops.equip({params: {key: 'shield_warrior_1'}}); + buyGear(user, {params: {key: 'weapon_warrior_1'}}); + user.ops.equip({params: {key: 'weapon_warrior_1'}}); + + buyGear(user, {params: {key: 'weapon_wizard_1'}}); + + expect(user.items.gear.equipped).to.have.property('shield', 'shield_base_0'); + expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_wizard_1'); + }); + + // TODO after user.ops.equip is done + xit('buyGears two-handed equipment but does not automatically remove sword or shield', () => { + user.stats.gp = 100; + user.preferences.autoEquip = false; + buyGear(user, {params: {key: 'shield_warrior_1'}}); + user.ops.equip({params: {key: 'shield_warrior_1'}}); + buyGear(user, {params: {key: 'weapon_warrior_1'}}); + user.ops.equip({params: {key: 'weapon_warrior_1'}}); + + buyGear(user, {params: {key: 'weapon_wizard_1'}}); + + expect(user.items.gear.equipped).to.have.property('shield', 'shield_warrior_1'); + expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_warrior_1'); + }); + + it('does not buyGear equipment without enough Gold', (done) => { + user.stats.gp = 20; + + try { + buyGear(user, {params: {key: 'armor_warrior_1'}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + expect(user.items.gear.owned).to.not.have.property('armor_warrior_1'); + done(); + } + }); + }); +}); diff --git a/test/common/ops/buyPotion.js b/test/common/ops/buyPotion.js new file mode 100644 index 0000000000..5230040a35 --- /dev/null +++ b/test/common/ops/buyPotion.js @@ -0,0 +1,65 @@ +/* eslint-disable camelcase */ +import { + generateUser, +} from '../../helpers/common.helper'; +import buyPotion from '../../../common/script/ops/buyPotion'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; + +describe('shared.ops.buyPotion', () => { + let user; + + beforeEach(() => { + user = generateUser({ + items: { + gear: { + owned: { + weapon_warrior_0: true, + }, + equipped: { + weapon_warrior_0: true, + }, + }, + }, + stats: { gp: 200 }, + }); + }); + + context('Potion', () => { + it('recovers 15 hp', () => { + user.stats.hp = 30; + buyPotion(user); + expect(user.stats.hp).to.eql(45); + }); + + it('does not increase hp above 50', () => { + user.stats.hp = 45; + buyPotion(user); + expect(user.stats.hp).to.eql(50); + }); + + it('deducts 25 gp', () => { + user.stats.hp = 45; + buyPotion(user); + + expect(user.stats.gp).to.eql(175); + }); + + it('does not purchase if not enough gp', (done) => { + user.stats.hp = 45; + user.stats.gp = 5; + try { + buyPotion(user); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); + expect(user.stats.hp).to.eql(45); + expect(user.stats.gp).to.eql(5); + + done(); + } + }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index f13c6d0a73..309a299953 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -477,15 +477,14 @@ api.allocateNow = { }; /** - * @api {post} /api/v3/user/buy/:key Buy a content item. + * @api {post} /user/buy/:key Buy gear, armoire or potion * @apiVersion 3.0.0 * @apiName UserBuy * @apiGroup User * * @apiParam {string} key The item to buy. * - * @apiSuccess {Object} data `items, achievements, stats, flags` - * @apiSuccess {object} armoireResp Optional extra item given by the armoire + * @apiSuccess {Object} data `items` * @apiSuccess {string} message */ api.buy = { @@ -501,7 +500,77 @@ api.buy = { }; /** - * @api {post} /api/v3/user/buy-mystery-set/:key Buy a mystery set. + * @api {post} /user/buy-gear/:key Buy a piece of gear. + * @apiVersion 3.0.0 + * @apiName UserBuyGear + * @apiGroup User + * + * @apiParam {string} key The item to buy. + * + * @apiSuccess {Object} data `items` + * @apiSuccess {string} message + */ +api.buyGear = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/buy-gear/:key', + async handler (req, res) { + let user = res.locals.user; + let buyRes = common.ops.buyGear(user, req, res.analytics); + await user.save(); + res.respond(200, buyRes); + }, +}; + +/** + * @api {post} /user/buy-armoire Buy an armoire item. + * @apiVersion 3.0.0 + * @apiName UserBuyArmoire + * @apiGroup User + * + * @apiParam {string} key The item to buy. + * + * @apiSuccess {Object} data `items flags` + * @apiSuccess {object} armoireResp Optional extra item given by the armoire + * @apiSuccess {string} message + */ +api.buyArmoire = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/buy-armoire', + async handler (req, res) { + let user = res.locals.user; + let buyArmoireResponse = common.ops.buyArmoire(user, req, res.analytics); + await user.save(); + res.respond(200, buyArmoireResponse); + }, +}; + +/** + * @api {post} /user/buy-potion Buy a potion. + * @apiVersion 3.0.0 + * @apiName UserBuyPotion + * @apiGroup User + * + * @apiParam {string} key The item to buy. + * + * @apiSuccess {Object} data `stats` + * @apiSuccess {string} message + */ +api.buyPotion = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/buy-potion', + async handler (req, res) { + let user = res.locals.user; + let buyPotionResponse = common.ops.buyPotion(user, req, res.analytics); + await user.save(); + res.respond(200, buyPotionResponse); + }, +}; + +/** + * @api {post} /user/buy-mystery-set/:key Buy a mystery set. * @apiVersion 3.0.0 * @apiName UserBuyMysterySet * @apiGroup User From 62b059d4d80877e50026a3cb560865cef1f5f65a Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Wed, 20 Apr 2016 22:27:37 +0000 Subject: [PATCH 702/976] little changes, no console.log --- website/src/controllers/api-v3/chat.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 2b20f80cf5..6aa8319f7c 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -15,7 +15,6 @@ import nconf from 'nconf'; import setupNconf from '../../libs/api-v3/setupNconf'; setupNconf(); -console.log('+++ +++ this:', nconf.get('FLAG_REPORT_EMAIL')); const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; }); From 6568fcfd5e57d31382c68976de90b431e12bc22d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 21 Apr 2016 00:52:03 +0200 Subject: [PATCH 703/976] res.respond: allow for thrid parameter (message), refactor shared ops responses and adapt tests --- common/script/ops/addPushDevice.js | 11 +- common/script/ops/addWebhook.js | 13 +- common/script/ops/allocate.js | 5 +- common/script/ops/allocateNow.js | 6 +- common/script/ops/blockUser.js | 4 +- common/script/ops/buyArmoire.js | 15 ++- common/script/ops/buyGear.js | 12 +- common/script/ops/buyMysterySet.js | 8 +- common/script/ops/buyPotion.js | 14 +-- common/script/ops/buyQuest.js | 8 +- common/script/ops/buySpecialSpell.js | 8 +- common/script/ops/changeClass.js | 6 +- common/script/ops/clearPMs.js | 4 +- common/script/ops/deletePM.js | 4 +- common/script/ops/deleteWebhook.js | 4 +- common/script/ops/disableClasses.js | 6 +- common/script/ops/equip.js | 7 +- common/script/ops/feed.js | 6 +- common/script/ops/hatch.js | 8 +- common/script/ops/hourglassPurchase.js | 15 +-- common/script/ops/openMysteryItem.js | 8 +- common/script/ops/purchase.js | 22 ++-- common/script/ops/readCard.js | 8 +- common/script/ops/rebirth.js | 10 +- common/script/ops/releaseBoth.js | 10 +- common/script/ops/releaseMounts.js | 12 +- common/script/ops/releasePets.js | 12 +- common/script/ops/reroll.js | 10 +- common/script/ops/reset.js | 10 +- common/script/ops/revive.js | 11 +- common/script/ops/scoreTask.js | 2 +- common/script/ops/sell.js | 12 +- common/script/ops/sleep.js | 6 +- common/script/ops/unlock.js | 12 +- common/script/ops/updateTask.js | 2 +- common/script/ops/updateWebhook.js | 2 +- .../user/POST-user_allocate.test.js | 2 +- .../user/POST-user_allocate_now.test.js | 6 +- .../v3/integration/user/POST-user_buy.test.js | 4 +- .../user/POST-user_buy_mystery_set.test.js | 6 +- .../user/POST-user_buy_potion.test.js | 4 +- .../user/POST-user_change-class.test.js | 8 +- .../user/POST-user_disable-classes.test.js | 8 +- .../user/POST-user_equip_type_key.test.js | 4 +- .../integration/user/POST-user_sleep.test.js | 8 +- .../auth/POST-user_reset_password.test.js | 5 +- test/api/v3/unit/middlewares/response.js | 15 +++ test/api/v3/unit/models/challenge.test.js | 2 +- test/common/ops/addPushDevice.js | 4 +- test/common/ops/allocateNow.js | 8 +- test/common/ops/blockUser.test.js | 6 +- test/common/ops/buySpecialSpell.js | 6 +- test/common/ops/changeClass.js | 42 +++---- test/common/ops/clearPMs.test.js | 2 +- test/common/ops/deletePM.test.js | 4 +- test/common/ops/deleteWebhook.test.js | 4 +- test/common/ops/disableClasses.js | 12 +- test/common/ops/equip.js | 22 ++-- test/common/ops/feed.js | 86 ++++++------- test/common/ops/hatch.js | 24 ++-- test/common/ops/hourglassPurchase.js | 8 +- test/common/ops/openMysteryItem.js | 6 +- test/common/ops/purchase.js | 28 ++--- test/common/ops/readCard.js | 4 +- test/common/ops/rebirth.js | 8 +- test/common/ops/releaseBoth.js | 16 +-- test/common/ops/releaseMounts.js | 4 +- test/common/ops/releasePets.js | 4 +- test/common/ops/reroll.js | 4 +- test/common/ops/reset.js | 4 +- test/common/ops/revive.js | 12 +- test/common/ops/sell.js | 8 +- test/common/ops/sleep.js | 8 +- test/common/ops/unlock.js | 16 +-- test/common/ops/updateTask.js | 2 +- test/helpers/api-integration/requester.js | 9 +- website/src/controllers/api-v2/user.js | 7 +- website/src/controllers/api-v3/auth.js | 2 +- website/src/controllers/api-v3/tasks.js | 5 +- website/src/controllers/api-v3/user.js | 114 +++++++++--------- website/src/middlewares/api-v3/response.js | 10 +- 81 files changed, 419 insertions(+), 465 deletions(-) diff --git a/common/script/ops/addPushDevice.js b/common/script/ops/addPushDevice.js index fb3946b658..a909fe9feb 100644 --- a/common/script/ops/addPushDevice.js +++ b/common/script/ops/addPushDevice.js @@ -1,6 +1,5 @@ import _ from 'lodash'; import i18n from '../i18n'; -import splitWhitespace from '../libs/splitWhitespace'; import { BadRequest, NotAuthorized, @@ -35,10 +34,8 @@ module.exports = function addPushDevice (user, req = {}) { pushDevices.push(item); - let response = { - data: _.pick(user, splitWhitespace('pushDevices')), - message: i18n.t('pushDeviceAdded', req.language), - }; - - return response; + return [ + user.pushDevices, + i18n.t('pushDeviceAdded', req.language), + ]; }; diff --git a/common/script/ops/addWebhook.js b/common/script/ops/addWebhook.js index 16d63593d2..c308d1b9e5 100644 --- a/common/script/ops/addWebhook.js +++ b/common/script/ops/addWebhook.js @@ -7,8 +7,7 @@ import { import _ from 'lodash'; module.exports = function addWebhook (user, req = {}) { - let wh; - wh = user.preferences.webhooks; + let wh = user.preferences.webhooks; if (!validator.isURL(_.get(req, 'body.url'))) throw new BadRequest(i18n.t('invalidUrl', req.language)); if (!validator.isBoolean(_.get(req, 'body.enabled'))) throw new BadRequest(i18n.t('invalidEnabled', req.language)); @@ -18,9 +17,11 @@ module.exports = function addWebhook (user, req = {}) { if (req.v2 === true) { return user.preferences.webhooks; } else { - return refPush(wh, { - url: req.body.url, - enabled: req.body.enabled, - }); + return [ + refPush(wh, { + url: req.body.url, + enabled: req.body.enabled, + }), + ]; } }; diff --git a/common/script/ops/allocate.js b/common/script/ops/allocate.js index c04c756e45..8e07e09589 100644 --- a/common/script/ops/allocate.js +++ b/common/script/ops/allocate.js @@ -1,5 +1,4 @@ import _ from 'lodash'; -import splitWhitespace from '../libs/splitWhitespace'; import { ATTRIBUTES, } from '../constants'; @@ -26,5 +25,7 @@ module.exports = function allocate (user, req = {}) { throw new NotAuthorized(i18n.t('notEnoughAttrPoints', req.language)); } - return _.pick(user, splitWhitespace('stats')); + return [ + user.stats, + ]; }; diff --git a/common/script/ops/allocateNow.js b/common/script/ops/allocateNow.js index 0db7a3a819..e8ae5d249c 100644 --- a/common/script/ops/allocateNow.js +++ b/common/script/ops/allocateNow.js @@ -8,8 +8,8 @@ module.exports = function allocateNow (user, req = {}) { if (req.v2 === true) { return _.pick(user, 'stats'); } else { - return { - data: _.pick(user, 'stats'), - }; + return [ + user.stats, + ]; } }; diff --git a/common/script/ops/blockUser.js b/common/script/ops/blockUser.js index 3546d412ab..5c123735ed 100644 --- a/common/script/ops/blockUser.js +++ b/common/script/ops/blockUser.js @@ -15,5 +15,7 @@ module.exports = function blockUser (user, req = {}) { } user.markModified('inbox.blocks'); - return user.inbox.blocks; + return [ + user.inbox.blocks, + ]; }; diff --git a/common/script/ops/buyArmoire.js b/common/script/ops/buyArmoire.js index 9b2a8af8e2..e183c06984 100644 --- a/common/script/ops/buyArmoire.js +++ b/common/script/ops/buyArmoire.js @@ -101,16 +101,15 @@ module.exports = function buyArmoire (user, req = {}, analytics) { }); } - let res = { - data: _.pick(user, splitWhitespace('items flags')), - message, - }; - - if (armoireResp) res.armoire = armoireResp; + let resData = _.pick(user, splitWhitespace('items flags')); + if (armoireResp) resData.armoire = armoireResp; if (req.v2 === true) { - return res.data; + return resData; } else { - return res; + return [ + resData, + message, + ]; } }; diff --git a/common/script/ops/buyGear.js b/common/script/ops/buyGear.js index 2bad8135cd..e4f4eb3b68 100644 --- a/common/script/ops/buyGear.js +++ b/common/script/ops/buyGear.js @@ -59,14 +59,12 @@ module.exports = function buyGear (user, req = {}, analytics) { }); } - let res = { - data: _.pick(user, splitWhitespace('items achievements stats flags')), - message, - }; - if (req.v2 === true) { - return res.data; + return _.pick(user, splitWhitespace('items achievements stats flags')); } else { - return res; + return [ + _.pick(user, splitWhitespace('items achievements stats flags')), + message, + ]; } }; diff --git a/common/script/ops/buyMysterySet.js b/common/script/ops/buyMysterySet.js index cc2eb8f3b1..acf0014279 100644 --- a/common/script/ops/buyMysterySet.js +++ b/common/script/ops/buyMysterySet.js @@ -47,9 +47,9 @@ module.exports = function buyMysterySet (user, req = {}, analytics) { if (req.v2 === true) { return pickDeep(user, splitWhitespace('items purchased.plan.consecutive')); } else { - return { - data: pickDeep(user, splitWhitespace('items purchased.plan.consecutive')), - message: i18n.t('hourglassPurchaseSet', req.language), - }; + return [ + { items: user.items, purchasedPlanConsecutive: user.purchased.plan.consecutive }, + i18n.t('hourglassPurchaseSet', req.language), + ]; } }; diff --git a/common/script/ops/buyPotion.js b/common/script/ops/buyPotion.js index f797ff903b..5c64b19609 100644 --- a/common/script/ops/buyPotion.js +++ b/common/script/ops/buyPotion.js @@ -1,7 +1,5 @@ import content from '../content/index'; import i18n from '../i18n'; -import _ from 'lodash'; -import splitWhitespace from '../libs/splitWhitespace'; import { NotAuthorized, } from '../libs/errors'; @@ -39,14 +37,12 @@ module.exports = function buyPotion (user, req = {}, analytics) { }); } - let res = { - data: _.pick(user, splitWhitespace('stats')), - message, - }; - if (req.v2 === true) { - return res.data; + return user.stats; } else { - return res; + return [ + user.stats, + message, + ]; } }; diff --git a/common/script/ops/buyQuest.js b/common/script/ops/buyQuest.js index 6cab422063..af7c384419 100644 --- a/common/script/ops/buyQuest.js +++ b/common/script/ops/buyQuest.js @@ -40,11 +40,11 @@ module.exports = function buyQuest (user, req = {}, analytics) { if (req.v2 === true) { return user.items.quests; } else { - return { - data: user.items.quests, - message: i18n.t('messageBought', { + return [ + user.items.quests, + i18n.t('messageBought', { itemText: item.text(req.language), }, req.language), - }; + ]; } }; diff --git a/common/script/ops/buySpecialSpell.js b/common/script/ops/buySpecialSpell.js index 921ffc817b..20ea0251aa 100644 --- a/common/script/ops/buySpecialSpell.js +++ b/common/script/ops/buySpecialSpell.js @@ -25,11 +25,11 @@ module.exports = function buySpecialSpell (user, req = {}) { if (req.v2 === true) { return _.pick(user, splitWhitespace('items stats')); } else { - return { - data: _.pick(user, splitWhitespace('items stats')), - message: i18n.t('messageBought', { + return [ + _.pick(user, splitWhitespace('items stats')), + i18n.t('messageBought', { itemText: item.text(req.language), }, req.language), - }; + ]; } }; diff --git a/common/script/ops/changeClass.js b/common/script/ops/changeClass.js index 520a435ace..4b3eb6b289 100644 --- a/common/script/ops/changeClass.js +++ b/common/script/ops/changeClass.js @@ -71,8 +71,8 @@ module.exports = function changeClass (user, req = {}, analytics) { if (req.v2 === true) { return _.pick(user, splitWhitespace('stats flags items preferences')); } else { - return { - data: _.pick(user, splitWhitespace('stats flags items preferences')), - }; + return [ + _.pick(user, splitWhitespace('stats flags items preferences')), + ]; } }; diff --git a/common/script/ops/clearPMs.js b/common/script/ops/clearPMs.js index 5187354dfc..765ecc3b56 100644 --- a/common/script/ops/clearPMs.js +++ b/common/script/ops/clearPMs.js @@ -1,5 +1,7 @@ module.exports = function clearPMs (user) { user.inbox.messages = {}; user.markModified('inbox.messages'); - return user.inbox.messages; + return [ + user.inbox.messages, + ]; }; diff --git a/common/script/ops/deletePM.js b/common/script/ops/deletePM.js index 826cf9ee1a..84bb7ee33a 100644 --- a/common/script/ops/deletePM.js +++ b/common/script/ops/deletePM.js @@ -3,5 +3,7 @@ import _ from 'lodash'; module.exports = function deletePM (user, req = {}) { delete user.inbox.messages[_.get(req, 'params.id')]; user.markModified(`inbox.messages.${req.params.id}`); - return user.inbox.messages; + return [ + user.inbox.messages, + ]; }; diff --git a/common/script/ops/deleteWebhook.js b/common/script/ops/deleteWebhook.js index 51e94982d4..9c4f67eba8 100644 --- a/common/script/ops/deleteWebhook.js +++ b/common/script/ops/deleteWebhook.js @@ -4,5 +4,7 @@ module.exports = function deleteWebhook (user, req) { delete user.preferences.webhooks[_.get(req, 'params.id')]; user.markModified('preferences.webhooks'); - return user.preferences.webhooks; + return [ + user.preferences.webhooks, + ]; }; diff --git a/common/script/ops/disableClasses.js b/common/script/ops/disableClasses.js index 58f493a13c..e611bb0872 100644 --- a/common/script/ops/disableClasses.js +++ b/common/script/ops/disableClasses.js @@ -13,8 +13,8 @@ module.exports = function disableClasses (user, req = {}) { if (req.v2 === true) { return _.pick(user, splitWhitespace('stats flags preferences')); } else { - return { - data: _.pick(user, splitWhitespace('stats flags preferences')), - }; + return [ + _.pick(user, splitWhitespace('stats flags preferences')), + ]; } }; diff --git a/common/script/ops/equip.js b/common/script/ops/equip.js index ddef7c5483..9c614915a7 100644 --- a/common/script/ops/equip.js +++ b/common/script/ops/equip.js @@ -58,14 +58,11 @@ module.exports = function equip (user, req = {}) { } } - let res = { - data: user.items, - }; - if (message) res.message = message; - if (req.v2 === true) { return user.items; } else { + let res = [user.items]; + if (message) res.push(message); return res; } }; diff --git a/common/script/ops/feed.js b/common/script/ops/feed.js index f555a24dfc..dc06226cf0 100644 --- a/common/script/ops/feed.js +++ b/common/script/ops/feed.js @@ -94,9 +94,9 @@ module.exports = function feed (user, req = {}) { value: userPets[pet], }; } else { - return { - data: userPets[pet], + return [ + userPets[pet], message, - }; + ]; } }; diff --git a/common/script/ops/hatch.js b/common/script/ops/hatch.js index 01b0b68520..87adbf3276 100644 --- a/common/script/ops/hatch.js +++ b/common/script/ops/hatch.js @@ -36,9 +36,9 @@ module.exports = function hatch (user, req = {}) { if (req.v2 === true) { return user.items; } else { - return { - message: i18n.t('messageHatched', req.language), - data: user.items, - }; + return [ + user.items, + i18n.t('messageHatched', req.language), + ]; } }; diff --git a/common/script/ops/hourglassPurchase.js b/common/script/ops/hourglassPurchase.js index 286590517a..e1d07bb482 100644 --- a/common/script/ops/hourglassPurchase.js +++ b/common/script/ops/hourglassPurchase.js @@ -1,12 +1,11 @@ import content from '../content/index'; import i18n from '../i18n'; import _ from 'lodash'; -import splitWhitespace from '../libs/splitWhitespace'; -import pickDeep from '../libs/pickDeep'; import { BadRequest, NotAuthorized, } from '../libs/errors'; +import splitWhitespace from '../libs/splitWhitespace'; module.exports = function purchaseHourglass (user, req = {}, analytics) { let key = _.get(req, 'params.key'); @@ -51,14 +50,12 @@ module.exports = function purchaseHourglass (user, req = {}, analytics) { }); } - let res = { - data: pickDeep(user, splitWhitespace('items purchased.plan.consecutive')), - message: i18n.t('hourglassPurchase', req.language), - }; - if (req.v2 === true) { - return res.data; + return _.pick(user, splitWhitespace('items purchased.plan.consecutive')); } else { - return res; + return [ + { items: user.items, purchasedPlanConsecutive: user.purchased.plan.consecutive }, + i18n.t('hourglassPurchase', req.language), + ]; } }; diff --git a/common/script/ops/openMysteryItem.js b/common/script/ops/openMysteryItem.js index dc270cace9..743104c48b 100644 --- a/common/script/ops/openMysteryItem.js +++ b/common/script/ops/openMysteryItem.js @@ -36,9 +36,9 @@ module.exports = function openMysteryItem (user, req = {}, analytics) { if (req.v2 === true) { return user.items.gear.owned; } else { - return { - message: i18n.t('mysteryItemOpened', req.language), - data: user.items.gear.owned, - }; + return [ + user.items.gear.owned, + i18n.t('mysteryItemOpened', req.language), + ]; } }; diff --git a/common/script/ops/purchase.js b/common/script/ops/purchase.js index b4a7136646..bb5a71df80 100644 --- a/common/script/ops/purchase.js +++ b/common/script/ops/purchase.js @@ -54,12 +54,10 @@ module.exports = function purchase (user, req = {}, analytics) { }); } - let response = { - data: _.pick(user, splitWhitespace('stats balance')), - message: i18n.t('plusOneGem'), - }; - - return response; + return [ + _.pick(user, splitWhitespace('stats balance')), + i18n.t('plusOneGem'), + ]; } let acceptedTypes = ['eggs', 'hatchingPotions', 'food', 'quests', 'gear']; @@ -119,14 +117,12 @@ module.exports = function purchase (user, req = {}, analytics) { }); } - let response = { - data: _.pick(user, splitWhitespace('items balance')), - message: i18n.t('purchased', {type, key}), - }; - if (req.v2 === true) { - return response.data; + return _.pick(user, splitWhitespace('items balance')); } else { - return response; + return [ + _.pick(user, splitWhitespace('items balance')), + i18n.t('purchased', {type, key}), + ]; } }; diff --git a/common/script/ops/readCard.js b/common/script/ops/readCard.js index d943e40a35..57b0da4b00 100644 --- a/common/script/ops/readCard.js +++ b/common/script/ops/readCard.js @@ -24,9 +24,9 @@ module.exports = function readCard (user, req = {}) { if (req.v2 === true) { return _.pick(user, splitWhitespace('items.special flags.cardReceived')); } else { - return { - message: i18n.t('readCard', {cardType}, req.language), - data: _.pick(user, splitWhitespace('items.special flags.cardReceived')), - }; + return [ + { specialItems: user.items.special, cardReceived: user.flags.cardReceived }, + i18n.t('readCard', {cardType}, req.language), + ]; } }; diff --git a/common/script/ops/rebirth.js b/common/script/ops/rebirth.js index bf29e64d67..c7de4ac356 100644 --- a/common/script/ops/rebirth.js +++ b/common/script/ops/rebirth.js @@ -98,14 +98,12 @@ module.exports = function rebirth (user, tasks = [], req = {}, analytics) { user.stats.buffs = {}; - let response = { - data: user, - message: i18n.t('rebirthComplete'), - }; - if (req.v2 === true) { return user; } else { - return response; + return [ + user, + i18n.t('rebirthComplete'), + ]; } }; diff --git a/common/script/ops/releaseBoth.js b/common/script/ops/releaseBoth.js index cc17b99e44..cf7d2267ca 100644 --- a/common/script/ops/releaseBoth.js +++ b/common/script/ops/releaseBoth.js @@ -57,14 +57,12 @@ module.exports = function releaseBoth (user, req = {}, analytics) { user.achievements.triadBingoCount++; } - let response = { - data: _.pick(user, splitWhitespace('achievements')), - message: i18n.t('mountsAndPetsReleased'), - }; - if (req.v2 === true) { return user; } else { - return response; + return [ + _.pick(user, splitWhitespace('achievements items balance')), + i18n.t('mountsAndPetsReleased'), + ]; } }; diff --git a/common/script/ops/releaseMounts.js b/common/script/ops/releaseMounts.js index a0564dc804..d8b8dde659 100644 --- a/common/script/ops/releaseMounts.js +++ b/common/script/ops/releaseMounts.js @@ -3,8 +3,6 @@ import i18n from '../i18n'; import { NotAuthorized, } from '../libs/errors'; -import splitWhitespace from '../libs/splitWhitespace'; -import _ from 'lodash'; module.exports = function releaseMounts (user, req = {}, analytics) { let mount; @@ -34,14 +32,12 @@ module.exports = function releaseMounts (user, req = {}, analytics) { }); } - let response = { - data: _.pick(user, splitWhitespace('mounts')), - message: i18n.t('mountsReleased'), - }; - if (req.v2 === true) { return user; } else { - return response; + return [ + user.items.mounts, + i18n.t('mountsReleased'), + ]; } }; diff --git a/common/script/ops/releasePets.js b/common/script/ops/releasePets.js index 09303b34e9..9466e1ccda 100644 --- a/common/script/ops/releasePets.js +++ b/common/script/ops/releasePets.js @@ -3,8 +3,6 @@ import i18n from '../i18n'; import { NotAuthorized, } from '../libs/errors'; -import splitWhitespace from '../libs/splitWhitespace'; -import _ from 'lodash'; module.exports = function releasePets (user, req = {}, analytics) { if (user.balance < 1) { @@ -32,14 +30,12 @@ module.exports = function releasePets (user, req = {}, analytics) { }); } - let response = { - data: _.pick(user, splitWhitespace('user.items.pets')), - message: i18n.t('petsReleased'), - }; - if (req.v2 === true) { return user; } else { - return response; + return [ + user.items.pets, + i18n.t('petsReleased'), + ]; } }; diff --git a/common/script/ops/reroll.js b/common/script/ops/reroll.js index 52939551b3..fee26fb88b 100644 --- a/common/script/ops/reroll.js +++ b/common/script/ops/reroll.js @@ -27,14 +27,12 @@ module.exports = function reroll (user, tasks = [], req = {}, analytics) { }); } - let response = { - data: {user, tasks}, - message: i18n.t('rerollComplete'), - }; - if (req.v2 === true) { return user; } else { - return response; + return [ + {user, tasks}, + i18n.t('rerollComplete'), + ]; } }; diff --git a/common/script/ops/reset.js b/common/script/ops/reset.js index 1e59ccf02a..eb8d25033e 100644 --- a/common/script/ops/reset.js +++ b/common/script/ops/reset.js @@ -19,14 +19,12 @@ module.exports = function reset (user, tasks = [], req = {}) { resetGear(user); - let response = { - data: {user, tasksToRemove}, - message: i18n.t('resetComplete'), - }; - if (req.v2 === true) { return user; } else { - return response; + return [ + {user, tasksToRemove}, + i18n.t('resetComplete'), + ]; } }; diff --git a/common/script/ops/revive.js b/common/script/ops/revive.js index 5b48e6fb71..30b29a78fc 100644 --- a/common/script/ops/revive.js +++ b/common/script/ops/revive.js @@ -4,7 +4,6 @@ import _ from 'lodash'; import { NotAuthorized, } from '../libs/errors'; -import splitWhitespace from '../libs/splitWhitespace'; import randomVal from '../fns/randomVal'; module.exports = function revive (user, req = {}, analytics) { @@ -97,14 +96,12 @@ module.exports = function revive (user, req = {}, analytics) { }); } - let response = { - data: _.pick(user, splitWhitespace('user.items')), - message, - }; - if (req.v2 === true) { return user; } else { - return response; + return [ + user.items, + message, + ]; } }; diff --git a/common/script/ops/scoreTask.js b/common/script/ops/scoreTask.js index ba0ca42c47..bfc99344a9 100644 --- a/common/script/ops/scoreTask.js +++ b/common/script/ops/scoreTask.js @@ -257,5 +257,5 @@ module.exports = function scoreTask (options = {}, req = {}) { } updateStats(user, stats, req); - return delta; + return [delta]; }; diff --git a/common/script/ops/sell.js b/common/script/ops/sell.js index fc3aa9632b..ccde6c552e 100644 --- a/common/script/ops/sell.js +++ b/common/script/ops/sell.js @@ -33,14 +33,12 @@ module.exports = function sell (user, req = {}) { user.items[type][key]--; user.stats.gp += content[type][key].value; - let response = { - data: _.pick(user, splitWhitespace('stats items')), - message: i18n.t('sold', {type, key}), - }; - if (req.v2 === true) { - return response.data; + return _.pick(user, splitWhitespace('stats items')); } else { - return response; + return [ + _.pick(user, splitWhitespace('stats items')), + i18n.t('sold', {type, key}), + ]; } }; diff --git a/common/script/ops/sleep.js b/common/script/ops/sleep.js index 8ef48eeac4..1a531ecb88 100644 --- a/common/script/ops/sleep.js +++ b/common/script/ops/sleep.js @@ -4,10 +4,6 @@ module.exports = function sleep (user, req = {}) { if (req.v2 === true) { return {}; } else { - return { - preferences: { - sleep: user.preferences.sleep, - }, - }; + return [user.preferences.sleep]; } }; diff --git a/common/script/ops/unlock.js b/common/script/ops/unlock.js index 08ec175dcc..d65131aa82 100644 --- a/common/script/ops/unlock.js +++ b/common/script/ops/unlock.js @@ -75,14 +75,12 @@ module.exports = function unlock (user, req = {}, analytics) { }); } - let response = { - data: _.pick(user, splitWhitespace('purchased preferences items')), - message: i18n.t('unlocked'), - }; - if (req.v2 === true) { - return response.data; + return _.pick(user, splitWhitespace('purchased preferences items')); } else { - return response; + return [ + _.pick(user, splitWhitespace('purchased preferences items')), + i18n.t('unlocked'), + ]; } }; diff --git a/common/script/ops/updateTask.js b/common/script/ops/updateTask.js index a128b40fc1..b6dd9f0f90 100644 --- a/common/script/ops/updateTask.js +++ b/common/script/ops/updateTask.js @@ -22,5 +22,5 @@ module.exports = function updateTask (task, req = {}) { _.merge(task, _.omit(req.body, ['_id', 'id', 'type'])); - return task; + return [task]; }; diff --git a/common/script/ops/updateWebhook.js b/common/script/ops/updateWebhook.js index d8ec977308..63fed89b17 100644 --- a/common/script/ops/updateWebhook.js +++ b/common/script/ops/updateWebhook.js @@ -15,6 +15,6 @@ module.exports = function updateWebhook (user, req) { if (req.v2 === true) { return user.preferences.webhooks; } else { - return user.preferences.webhooks[req.params.id]; + return [user.preferences.webhooks[req.params.id]]; } }; diff --git a/test/api/v3/integration/user/POST-user_allocate.test.js b/test/api/v3/integration/user/POST-user_allocate.test.js index d213318534..02d4990092 100644 --- a/test/api/v3/integration/user/POST-user_allocate.test.js +++ b/test/api/v3/integration/user/POST-user_allocate.test.js @@ -36,6 +36,6 @@ describe('POST /user/allocate', () => { await user.sync(); expect(user.stats.con).to.equal(1); expect(user.stats.points).to.equal(0); - expect(res.stats.con).to.equal(1); + expect(res.con).to.equal(1); }); }); diff --git a/test/api/v3/integration/user/POST-user_allocate_now.test.js b/test/api/v3/integration/user/POST-user_allocate_now.test.js index 668c85610e..b45f2156be 100644 --- a/test/api/v3/integration/user/POST-user_allocate_now.test.js +++ b/test/api/v3/integration/user/POST-user_allocate_now.test.js @@ -18,11 +18,7 @@ describe('POST /user/allocate-now', () => { let res = await user.post('/user/allocate-now'); await user.sync(); - expect(res).to.eql({ - data: { - stats: user.stats, - }, - }); + expect(res).to.eql(user.stats); expect(user.stats.points).to.equal(0); expect(user.stats.con).to.equal(9); expect(user.stats.int).to.equal(8); diff --git a/test/api/v3/integration/user/POST-user_buy.test.js b/test/api/v3/integration/user/POST-user_buy.test.js index 08e20617f0..2478adf7f8 100644 --- a/test/api/v3/integration/user/POST-user_buy.test.js +++ b/test/api/v3/integration/user/POST-user_buy.test.js @@ -36,9 +36,7 @@ describe('POST /user/buy/:key', () => { await user.sync(); expect(user.stats.hp).to.equal(50); - expect(res.data).to.eql({ - stats: user.stats, - }); + expect(res.data).to.eql(user.stats); expect(res.message).to.equal(t('messageBought', {itemText: potion.text()})); }); diff --git a/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js b/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js index 306f1e6677..da7116d732 100644 --- a/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js +++ b/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js @@ -31,11 +31,7 @@ describe('POST /user/buy-mystery-set/:key', () => { expect(res.data).to.eql({ items: JSON.parse(JSON.stringify(user.items)), // otherwise dates can't be compared - purchased: { - plan: { - consecutive: user.purchased.plan.consecutive, - }, - }, + purchasedPlanConsecutive: user.purchased.plan.consecutive, }); expect(res.message).to.equal(t('hourglassPurchaseSet')); }); diff --git a/test/api/v3/integration/user/POST-user_buy_potion.test.js b/test/api/v3/integration/user/POST-user_buy_potion.test.js index 4616ae8f34..e37f908e3e 100644 --- a/test/api/v3/integration/user/POST-user_buy_potion.test.js +++ b/test/api/v3/integration/user/POST-user_buy_potion.test.js @@ -36,9 +36,7 @@ describe('POST /user/buy-potion', () => { await user.sync(); expect(user.stats.hp).to.equal(50); - expect(res.data).to.eql({ - stats: user.stats, - }); + expect(res.data).to.eql(user.stats); expect(res.message).to.equal(t('messageBought', {itemText: potion.text()})); }); }); diff --git a/test/api/v3/integration/user/POST-user_change-class.test.js b/test/api/v3/integration/user/POST-user_change-class.test.js index a849ca9382..d4b4192f23 100644 --- a/test/api/v3/integration/user/POST-user_change-class.test.js +++ b/test/api/v3/integration/user/POST-user_change-class.test.js @@ -18,13 +18,13 @@ describe('POST /user/change-class', () => { let res = await user.post('/user/change-class?class=rogue'); await user.sync(); - expect(res).to.eql({ - data: JSON.parse(JSON.stringify({ + expect(res).to.eql(JSON.parse( + JSON.stringify({ preferences: user.preferences, stats: user.stats, flags: user.flags, items: user.items, - })), - }); + }) + )); }); }); diff --git a/test/api/v3/integration/user/POST-user_disable-classes.test.js b/test/api/v3/integration/user/POST-user_disable-classes.test.js index 3c0a88a3ab..0632a8adc7 100644 --- a/test/api/v3/integration/user/POST-user_disable-classes.test.js +++ b/test/api/v3/integration/user/POST-user_disable-classes.test.js @@ -15,12 +15,12 @@ describe('POST /user/disable-classes', () => { let res = await user.post('/user/disable-classes'); await user.sync(); - expect(res).to.eql({ - data: JSON.parse(JSON.stringify({ + expect(res).to.eql(JSON.parse( + JSON.stringify({ preferences: user.preferences, stats: user.stats, flags: user.flags, - })), - }); + }) + )); }); }); diff --git a/test/api/v3/integration/user/POST-user_equip_type_key.test.js b/test/api/v3/integration/user/POST-user_equip_type_key.test.js index f1d697cda7..c5cde777df 100644 --- a/test/api/v3/integration/user/POST-user_equip_type_key.test.js +++ b/test/api/v3/integration/user/POST-user_equip_type_key.test.js @@ -35,8 +35,6 @@ describe('POST /user/equip/:type/:key', () => { let res = await user.post('/user/equip/equipped/weapon_warrior_2'); await user.sync(); - expect(res).to.eql({ - data: JSON.parse(JSON.stringify(user.items)), - }); + expect(res).to.eql(JSON.parse(JSON.stringify(user.items))); }); }); diff --git a/test/api/v3/integration/user/POST-user_sleep.test.js b/test/api/v3/integration/user/POST-user_sleep.test.js index da95ae97b0..0e9773150e 100644 --- a/test/api/v3/integration/user/POST-user_sleep.test.js +++ b/test/api/v3/integration/user/POST-user_sleep.test.js @@ -13,16 +13,12 @@ describe('POST /user/sleep', () => { it('toggles sleep status', async () => { let res = await user.post('/user/sleep'); - expect(res).to.eql({ - preferences: {sleep: true}, - }); + expect(res).to.eql(true); await user.sync(); expect(user.preferences.sleep).to.be.true; let res2 = await user.post('/user/sleep'); - expect(res2).to.eql({ - preferences: {sleep: false}, - }); + expect(res2).to.eql(false); await user.sync(); expect(user.preferences.sleep).to.be.false; }); diff --git a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js index 6116f67a35..773d199db6 100644 --- a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js +++ b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js @@ -16,7 +16,7 @@ describe('POST /user/reset-password', async () => { let response = await user.post(endpoint, { email: user.auth.local.email, }); - expect(response).to.eql({ message: t('passwordReset') }); + expect(response).to.eql({ data: {}, message: t('passwordReset') }); await user.sync(); expect(user.auth.local.hashed_password).to.not.eql(previousPassword); }); @@ -25,7 +25,7 @@ describe('POST /user/reset-password', async () => { let response = await user.post(endpoint, { email: 'nonExistent@email.com', }); - expect(response).to.eql({ message: t('passwordReset') }); + expect(response).to.eql({ data: {}, message: t('passwordReset') }); }); it('errors if email is not provided', async () => { @@ -36,4 +36,3 @@ describe('POST /user/reset-password', async () => { }); }); }); - diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js index 296a216ec2..e46da348c0 100644 --- a/test/api/v3/unit/middlewares/response.js +++ b/test/api/v3/unit/middlewares/response.js @@ -35,6 +35,21 @@ describe('response middleware', () => { }); }); + it('can be passed a third parameter to be used as optional message', () => { + responseMiddleware(req, res, next); + res.respond(200, {field: 1}, 'hello'); + + expect(res.status).to.be.calledOnce; + expect(res.json).to.be.calledOnce; + + expect(res.status).to.be.calledWith(200); + expect(res.json).to.be.calledWith({ + success: true, + data: {field: 1}, + message: 'hello', + }); + }); + it('treats status >= 400 as failures', () => { responseMiddleware(req, res, next); res.respond(403, {field: 1}); diff --git a/test/api/v3/unit/models/challenge.test.js b/test/api/v3/unit/models/challenge.test.js index e3577a0e2a..00332761f4 100644 --- a/test/api/v3/unit/models/challenge.test.js +++ b/test/api/v3/unit/models/challenge.test.js @@ -110,7 +110,7 @@ describe('Challenge Model', () => { }; Tasks.Task.sanitize(req.body); - _.assign(task, common.ops.updateTask(task.toObject(), req)); + _.assign(task, common.ops.updateTask(task.toObject(), req)[0]); await challenge.updateTask(task); diff --git a/test/common/ops/addPushDevice.js b/test/common/ops/addPushDevice.js index 854977b71d..535d288384 100644 --- a/test/common/ops/addPushDevice.js +++ b/test/common/ops/addPushDevice.js @@ -39,9 +39,9 @@ describe('shared.ops.addPushDevice', () => { }); it('adds a push device', () => { - let response = addPushDevice(user, {body: {regId, type}}); + let [, message] = addPushDevice(user, {body: {regId, type}}); - expect(response.message).to.equal(i18n.t('pushDeviceAdded')); + expect(message).to.equal(i18n.t('pushDeviceAdded')); expect(user.pushDevices[0].type).to.equal(type); expect(user.pushDevices[0].regId).to.equal(regId); }); diff --git a/test/common/ops/allocateNow.js b/test/common/ops/allocateNow.js index 21a7f6daf4..4fe473ec9e 100644 --- a/test/common/ops/allocateNow.js +++ b/test/common/ops/allocateNow.js @@ -18,17 +18,13 @@ describe('shared.ops.allocateNow', () => { user.stats.str = 9; user.preferences.allocationMode = 'flat'; - let res = allocateNow(user); + let [data] = allocateNow(user); expect(user.stats.points).to.equal(0); expect(user.stats.con).to.equal(9); expect(user.stats.int).to.equal(8); expect(user.stats.per).to.equal(9); expect(user.stats.str).to.equal(9); - expect(res).to.eql({ - data: { - stats: user.stats, - }, - }); + expect(data).to.eql(user.stats); }); }); diff --git a/test/common/ops/blockUser.test.js b/test/common/ops/blockUser.test.js index 01b5185ed0..950af25d0c 100644 --- a/test/common/ops/blockUser.test.js +++ b/test/common/ops/blockUser.test.js @@ -26,10 +26,10 @@ describe('shared.ops.blockUser', () => { }); it('blocks user', () => { - let result = blockUser(user, { params: { uuid: blockedUser._id } }); + let [result] = blockUser(user, { params: { uuid: blockedUser._id } }); expect(user.inbox.blocks).to.eql([blockedUser._id]); expect(result).to.eql([blockedUser._id]); - result = blockUser(user, { params: { uuid: blockedUser2._id } }); + [result] = blockUser(user, { params: { uuid: blockedUser2._id } }); expect(user.inbox.blocks).to.eql([blockedUser._id, blockedUser2._id]); expect(result).to.eql([blockedUser._id, blockedUser2._id]); }); @@ -37,7 +37,7 @@ describe('shared.ops.blockUser', () => { it('blocks, then unblocks user', () => { blockUser(user, { params: { uuid: blockedUser._id } }); expect(user.inbox.blocks).to.eql([blockedUser._id]); - let result = blockUser(user, { params: { uuid: blockedUser._id } }); + let [result] = blockUser(user, { params: { uuid: blockedUser._id } }); expect(user.inbox.blocks).to.eql([]); expect(result).to.eql([]); }); diff --git a/test/common/ops/buySpecialSpell.js b/test/common/ops/buySpecialSpell.js index f688f4d3d4..249f4bc8a6 100644 --- a/test/common/ops/buySpecialSpell.js +++ b/test/common/ops/buySpecialSpell.js @@ -60,7 +60,7 @@ describe('shared.ops.buySpecialSpell', () => { user.stats.gp = 11; let item = content.special.thankyou; - let res = buySpecialSpell(user, { + let [data, message] = buySpecialSpell(user, { params: { key: 'thankyou', }, @@ -68,11 +68,11 @@ describe('shared.ops.buySpecialSpell', () => { expect(user.stats.gp).to.equal(1); expect(user.items.special.thankyou).to.equal(1); - expect(res.data).to.eql({ + expect(data).to.eql({ items: user.items, stats: user.stats, }); - expect(res.message).to.equal(i18n.t('messageBought', { + expect(message).to.equal(i18n.t('messageBought', { itemText: item.text(), })); }); diff --git a/test/common/ops/changeClass.js b/test/common/ops/changeClass.js index 498dde18f2..ae4a180178 100644 --- a/test/common/ops/changeClass.js +++ b/test/common/ops/changeClass.js @@ -46,14 +46,12 @@ describe('shared.ops.changeClass', () => { user.stats.class = 'healer'; user.items.gear.owned.armor_rogue_1 = true; // eslint-disable-line camelcase - let res = changeClass(user, {query: {class: 'rogue'}}); - expect(res).to.eql({ - data: { - preferences: user.preferences, - stats: user.stats, - flags: user.flags, - items: user.items, - }, + let [data] = changeClass(user, {query: {class: 'rogue'}}); + expect(data).to.eql({ + preferences: user.preferences, + stats: user.stats, + flags: user.flags, + items: user.items, }); expect(user.stats.class).to.equal('rogue'); @@ -80,14 +78,12 @@ describe('shared.ops.changeClass', () => { user.stats.int = 4; user.flags.classSelected = true; - let res = changeClass(user); - expect(res).to.eql({ - data: { - preferences: user.preferences, - stats: user.stats, - flags: user.flags, - items: user.items, - }, + let [data] = changeClass(user); + expect(data).to.eql({ + preferences: user.preferences, + stats: user.stats, + flags: user.flags, + items: user.items, }); expect(user.preferences.disableClasses).to.be.false; @@ -122,14 +118,12 @@ describe('shared.ops.changeClass', () => { user.stats.int = 4; user.flags.classSelected = true; - let res = changeClass(user); - expect(res).to.eql({ - data: { - preferences: user.preferences, - stats: user.stats, - flags: user.flags, - items: user.items, - }, + let [data] = changeClass(user); + expect(data).to.eql({ + preferences: user.preferences, + stats: user.stats, + flags: user.flags, + items: user.items, }); expect(user.balance).to.equal(0.25); diff --git a/test/common/ops/clearPMs.test.js b/test/common/ops/clearPMs.test.js index a2ff2a7a0b..cf1408e5a6 100644 --- a/test/common/ops/clearPMs.test.js +++ b/test/common/ops/clearPMs.test.js @@ -13,7 +13,7 @@ describe('shared.ops.clearPMs', () => { it('clears messages', () => { expect(user.inbox.messages).to.not.eql({}); - let result = clearPMs(user); + let [result] = clearPMs(user); expect(user.inbox.messages).to.eql({}); expect(result).to.eql({}); }); diff --git a/test/common/ops/deletePM.test.js b/test/common/ops/deletePM.test.js index 472bede6a3..109595eca9 100644 --- a/test/common/ops/deletePM.test.js +++ b/test/common/ops/deletePM.test.js @@ -3,7 +3,7 @@ import { generateUser, } from '../../helpers/common.helper'; -describe('shared.ops.clearPMs', () => { +describe('shared.ops.deletePM', () => { let user; beforeEach(() => { @@ -13,7 +13,7 @@ describe('shared.ops.clearPMs', () => { it('delete message', () => { expect(user.inbox.messages).to.not.eql({ second: 'message' }); - let response = deletePM(user, { params: { id: 'first' } }); + let [response] = deletePM(user, { params: { id: 'first' } }); expect(user.inbox.messages).to.eql({ second: 'message' }); expect(response).to.eql({ second: 'message' }); }); diff --git a/test/common/ops/deleteWebhook.test.js b/test/common/ops/deleteWebhook.test.js index e72bf22269..8e27a09e3e 100644 --- a/test/common/ops/deleteWebhook.test.js +++ b/test/common/ops/deleteWebhook.test.js @@ -14,8 +14,8 @@ describe('shared.ops.deleteWebhook', () => { it('succeeds', () => { user.preferences.webhooks = { 'some-id': {}, 'another-id': {} }; - let res = deleteWebhook(user, req); + let [data] = deleteWebhook(user, req); expect(user.preferences.webhooks).to.eql({'another-id': {}}); - expect(res).to.equal(user.preferences.webhooks); + expect(data).to.equal(user.preferences.webhooks); }); }); diff --git a/test/common/ops/disableClasses.js b/test/common/ops/disableClasses.js index 59f0643c65..81ac1a9792 100644 --- a/test/common/ops/disableClasses.js +++ b/test/common/ops/disableClasses.js @@ -18,13 +18,11 @@ describe('shared.ops.disableClasses', () => { user.preferences.autoAllocate = false; user.stats.points = 2; - let res = disableClasses(user); - expect(res).to.eql({ - data: { - preferences: user.preferences, - stats: user.stats, - flags: user.flags, - }, + let [data] = disableClasses(user); + expect(data).to.eql({ + preferences: user.preferences, + stats: user.stats, + flags: user.flags, }); expect(user.stats.class).to.equal('warrior'); diff --git a/test/common/ops/equip.js b/test/common/ops/equip.js index 77110f7f40..8641aa102e 100644 --- a/test/common/ops/equip.js +++ b/test/common/ops/equip.js @@ -37,20 +37,20 @@ describe('shared.ops.equip', () => { equip(user, {params: {key: 'weapon_warrior_1'}}); // one-handed to one-handed - let res = equip(user, {params: {key: 'weapon_warrior_2'}}); - expect(res.message).to.not.exists; + let [, message] = equip(user, {params: {key: 'weapon_warrior_2'}}); + expect(message).to.not.exists; // one-handed to two-handed - res = equip(user, {params: {key: 'weapon_wizard_1'}}); - expect(res.message).to.not.exists; + [, message] = equip(user, {params: {key: 'weapon_wizard_1'}}); + expect(message).to.not.exists; // two-handed to two-handed - res = equip(user, {params: {key: 'weapon_wizard_2'}}); - expect(res.message).to.not.exists; + [, message] = equip(user, {params: {key: 'weapon_wizard_2'}}); + expect(message).to.not.exists; // two-handed to one-handed - res = equip(user, {params: {key: 'weapon_warrior_2'}}); - expect(res.message).to.not.exists; + [, message] = equip(user, {params: {key: 'weapon_warrior_2'}}); + expect(message).to.not.exists; }); it('should send messages if equipping a two-hander causes the off-hander to be unequipped', () => { @@ -58,10 +58,11 @@ describe('shared.ops.equip', () => { equip(user, {params: {key: 'shield_warrior_1'}}); // equipping two-hander - let res = equip(user, {params: {key: 'weapon_wizard_1'}}); + let [data, message] = equip(user, {params: {key: 'weapon_wizard_1'}}); let weapon = content.gear.flat.weapon_wizard_1; let item = content.gear.flat.shield_warrior_1; + let res = {data, message}; expect(res).to.eql({ message: i18n.t('messageTwoHandedEquip', {twoHandedText: weapon.text(), offHandedText: item.text()}), data: user.items, @@ -74,8 +75,9 @@ describe('shared.ops.equip', () => { let weapon = content.gear.flat.weapon_wizard_1; let shield = content.gear.flat.shield_warrior_1; - let res = equip(user, {params: {key: 'shield_warrior_1'}}); + let [data, message] = equip(user, {params: {key: 'shield_warrior_1'}}); + let res = {data, message}; expect(res).to.eql({ message: i18n.t('messageTwoHandedUnequip', {twoHandedText: weapon.text(), offHandedText: shield.text()}), data: user.items, diff --git a/test/common/ops/feed.js b/test/common/ops/feed.js index 40d28169c4..0b726f5ec7 100644 --- a/test/common/ops/feed.js +++ b/test/common/ops/feed.js @@ -105,16 +105,14 @@ describe('shared.ops.feed', () => { let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - let res = feed(user, {params: {pet: 'Wolf-Base', food: 'Saddle'}}); - expect(res).to.eql({ - data: user.items.pets['Wolf-Base'], - message: i18n.t('messageEvolve', { - egg: i18n.t('petName', { - potion: potionText, - egg: eggText, - }), + let [data, message] = feed(user, {params: {pet: 'Wolf-Base', food: 'Saddle'}}); + expect(data).to.eql(user.items.pets['Wolf-Base']); + expect(message).to.eql(i18n.t('messageEvolve', { + egg: i18n.t('petName', { + potion: potionText, + egg: eggText, }), - }); + })); expect(user.items.food.Saddle).to.equal(1); expect(user.items.pets['Wolf-Base']).to.equal(-1); @@ -131,17 +129,15 @@ describe('shared.ops.feed', () => { let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - let res = feed(user, {params: {pet: 'Wolf-Base', food: 'Meat'}}); - expect(res).to.eql({ - data: user.items.pets['Wolf-Base'], - message: i18n.t('messageLikesFood', { - egg: i18n.t('petName', { - potion: potionText, - egg: eggText, - }), - foodText: food.text(), + let [data, message] = feed(user, {params: {pet: 'Wolf-Base', food: 'Meat'}}); + expect(data).to.eql(user.items.pets['Wolf-Base']); + expect(message).to.eql(i18n.t('messageLikesFood', { + egg: i18n.t('petName', { + potion: potionText, + egg: eggText, }), - }); + foodText: food.text(), + })); expect(user.items.food.Meat).to.equal(1); expect(user.items.pets['Wolf-Base']).to.equal(10); @@ -156,17 +152,15 @@ describe('shared.ops.feed', () => { let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - let res = feed(user, {params: {pet: 'Wolf-Spooky', food: 'Milk'}}); - expect(res).to.eql({ - data: user.items.pets['Wolf-Spooky'], - message: i18n.t('messageLikesFood', { - egg: i18n.t('petName', { - potion: potionText, - egg: eggText, - }), - foodText: food.text(), + let [data, message] = feed(user, {params: {pet: 'Wolf-Spooky', food: 'Milk'}}); + expect(data).to.eql(user.items.pets['Wolf-Spooky']); + expect(message).to.eql(i18n.t('messageLikesFood', { + egg: i18n.t('petName', { + potion: potionText, + egg: eggText, }), - }); + foodText: food.text(), + })); expect(user.items.food.Milk).to.equal(1); expect(user.items.pets['Wolf-Spooky']).to.equal(10); @@ -181,17 +175,15 @@ describe('shared.ops.feed', () => { let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - let res = feed(user, {params: {pet: 'Wolf-Base', food: 'Milk'}}); - expect(res).to.eql({ - data: user.items.pets['Wolf-Base'], - message: i18n.t('messageDontEnjoyFood', { - egg: i18n.t('petName', { - potion: potionText, - egg: eggText, - }), - foodText: food.text(), + let [data, message] = feed(user, {params: {pet: 'Wolf-Base', food: 'Milk'}}); + expect(data).to.eql(user.items.pets['Wolf-Base']); + expect(message).to.eql(i18n.t('messageDontEnjoyFood', { + egg: i18n.t('petName', { + potion: potionText, + egg: eggText, }), - }); + foodText: food.text(), + })); expect(user.items.food.Milk).to.equal(1); expect(user.items.pets['Wolf-Base']).to.equal(7); @@ -206,16 +198,14 @@ describe('shared.ops.feed', () => { let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - let res = feed(user, {params: {pet: 'Wolf-Base', food: 'Milk'}}); - expect(res).to.eql({ - data: user.items.pets['Wolf-Base'], - message: i18n.t('messageEvolve', { - egg: i18n.t('petName', { - potion: potionText, - egg: eggText, - }), + let [data, message] = feed(user, {params: {pet: 'Wolf-Base', food: 'Milk'}}); + expect(data).to.eql(user.items.pets['Wolf-Base']); + expect(message).to.eql(i18n.t('messageEvolve', { + egg: i18n.t('petName', { + potion: potionText, + egg: eggText, }), - }); + })); expect(user.items.food.Milk).to.equal(1); expect(user.items.pets['Wolf-Base']).to.equal(-1); diff --git a/test/common/ops/hatch.js b/test/common/ops/hatch.js index 0176b46b5f..87131db13b 100644 --- a/test/common/ops/hatch.js +++ b/test/common/ops/hatch.js @@ -99,9 +99,9 @@ describe('shared.ops.hatch', () => { user.items.eggs = {Wolf: 1}; user.items.hatchingPotions = {Base: 1}; user.items.pets = {}; - let res = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Base'}}); - expect(res.message).to.equal(i18n.t('messageHatched')); - expect(res.data).to.eql(user.items); + let [data, message] = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Base'}}); + expect(message).to.equal(i18n.t('messageHatched')); + expect(data).to.eql(user.items); expect(user.items.pets).to.eql({'Wolf-Base': 5}); expect(user.items.eggs).to.eql({Wolf: 0}); expect(user.items.hatchingPotions).to.eql({Base: 0}); @@ -111,9 +111,9 @@ describe('shared.ops.hatch', () => { user.items.eggs = {Cheetah: 1}; user.items.hatchingPotions = {Base: 1}; user.items.pets = {}; - let res = hatch(user, {params: {egg: 'Cheetah', hatchingPotion: 'Base'}}); - expect(res.message).to.equal(i18n.t('messageHatched')); - expect(res.data).to.eql(user.items); + let [data, message] = hatch(user, {params: {egg: 'Cheetah', hatchingPotion: 'Base'}}); + expect(message).to.equal(i18n.t('messageHatched')); + expect(data).to.eql(user.items); expect(user.items.pets).to.eql({'Cheetah-Base': 5}); expect(user.items.eggs).to.eql({Cheetah: 0}); expect(user.items.hatchingPotions).to.eql({Base: 0}); @@ -123,9 +123,9 @@ describe('shared.ops.hatch', () => { user.items.eggs = {Wolf: 1}; user.items.hatchingPotions = {Spooky: 1}; user.items.pets = {}; - let res = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Spooky'}}); - expect(res.message).to.equal(i18n.t('messageHatched')); - expect(res.data).to.eql(user.items); + let [data, message] = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Spooky'}}); + expect(message).to.equal(i18n.t('messageHatched')); + expect(data).to.eql(user.items); expect(user.items.pets).to.eql({'Wolf-Spooky': 5}); expect(user.items.eggs).to.eql({Wolf: 0}); expect(user.items.hatchingPotions).to.eql({Spooky: 0}); @@ -135,9 +135,9 @@ describe('shared.ops.hatch', () => { user.items.eggs = {Wolf: 1}; user.items.hatchingPotions = {Base: 1}; user.items.pets = {'Wolf-Base': -1}; - let res = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Base'}}); - expect(res.message).to.eql(i18n.t('messageHatched')); - expect(res.data).to.eql(user.items); + let [data, message] = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Base'}}); + expect(message).to.eql(i18n.t('messageHatched')); + expect(data).to.eql(user.items); expect(user.items.pets).to.eql({'Wolf-Base': 5}); expect(user.items.eggs).to.eql({Wolf: 0}); expect(user.items.hatchingPotions).to.eql({Base: 0}); diff --git a/test/common/ops/hourglassPurchase.js b/test/common/ops/hourglassPurchase.js index 258e0c6f01..98400f82b5 100644 --- a/test/common/ops/hourglassPurchase.js +++ b/test/common/ops/hourglassPurchase.js @@ -126,9 +126,9 @@ describe('user.ops.hourglassPurchase', () => { it('buys a pet', () => { user.purchased.plan.consecutive.trinkets = 2; - let response = hourglassPurchase(user, {params: {type: 'pets', key: 'MantisShrimp-Base'}}); + let [, message] = hourglassPurchase(user, {params: {type: 'pets', key: 'MantisShrimp-Base'}}); - expect(response.message).to.eql(i18n.t('hourglassPurchase')); + expect(message).to.eql(i18n.t('hourglassPurchase')); expect(user.purchased.plan.consecutive.trinkets).to.eql(1); expect(user.items.pets).to.eql({'MantisShrimp-Base': 5}); }); @@ -136,8 +136,8 @@ describe('user.ops.hourglassPurchase', () => { it('buys a mount', () => { user.purchased.plan.consecutive.trinkets = 2; - let response = hourglassPurchase(user, {params: {type: 'mounts', key: 'MantisShrimp-Base'}}); - expect(response.message).to.eql(i18n.t('hourglassPurchase')); + let [, message] = hourglassPurchase(user, {params: {type: 'mounts', key: 'MantisShrimp-Base'}}); + expect(message).to.eql(i18n.t('hourglassPurchase')); expect(user.purchased.plan.consecutive.trinkets).to.eql(1); expect(user.items.mounts).to.eql({'MantisShrimp-Base': true}); }); diff --git a/test/common/ops/openMysteryItem.js b/test/common/ops/openMysteryItem.js index c45146a585..712c032254 100644 --- a/test/common/ops/openMysteryItem.js +++ b/test/common/ops/openMysteryItem.js @@ -29,10 +29,10 @@ describe('shared.ops.openMysteryItem', () => { user.purchased.plan.mysteryItems = [mysteryItemKey]; - let response = openMysteryItem(user); + let [data, message] = openMysteryItem(user); expect(user.items.gear.owned[mysteryItemKey]).to.be.true; - expect(response.message).to.equal(i18n.t('mysteryItemOpened')); - expect(response.data).to.equal(user.items.gear.owned); + expect(message).to.equal(i18n.t('mysteryItemOpened')); + expect(data).to.equal(user.items.gear.owned); }); }); diff --git a/test/common/ops/purchase.js b/test/common/ops/purchase.js index 557ee006cf..5e32442c76 100644 --- a/test/common/ops/purchase.js +++ b/test/common/ops/purchase.js @@ -10,7 +10,7 @@ import { generateUser, } from '../../helpers/common.helper'; -describe('shared.ops.feed', () => { +describe('shared.ops.purchase', () => { let user; let goldPoints = 40; let gemsBought = 40; @@ -128,7 +128,7 @@ describe('shared.ops.feed', () => { }); }); - context('successful feeding', () => { + context('successful purchase', () => { let userGemAmount = 10; before(() => { @@ -138,9 +138,9 @@ describe('shared.ops.feed', () => { }); it('purchases gems', () => { - let purchaseResponse = purchase(user, {params: {type: 'gems', key: 'gem'}}); + let [, message] = purchase(user, {params: {type: 'gems', key: 'gem'}}); - expect(purchaseResponse.message).to.equal(i18n.t('plusOneGem')); + expect(message).to.equal(i18n.t('plusOneGem')); expect(user.balance).to.equal(userGemAmount + 0.25); expect(user.purchased.plan.gemsBought).to.equal(1); expect(user.stats.gp).to.equal(goldPoints - planGemLimits.convRate); @@ -150,9 +150,9 @@ describe('shared.ops.feed', () => { let type = 'eggs'; let key = 'Wolf'; - let purchaseResponse = purchase(user, {params: {type, key}}); + let [, message] = purchase(user, {params: {type, key}}); - expect(purchaseResponse.message).to.equal(i18n.t('purchased', {type, key})); + expect(message).to.equal(i18n.t('purchased', {type, key})); expect(user.items[type][key]).to.equal(1); }); @@ -160,9 +160,9 @@ describe('shared.ops.feed', () => { let type = 'hatchingPotions'; let key = 'Base'; - let purchaseResponse = purchase(user, {params: {type, key}}); + let [, message] = purchase(user, {params: {type, key}}); - expect(purchaseResponse.message).to.equal(i18n.t('purchased', {type, key})); + expect(message).to.equal(i18n.t('purchased', {type, key})); expect(user.items[type][key]).to.equal(1); }); @@ -170,9 +170,9 @@ describe('shared.ops.feed', () => { let type = 'food'; let key = 'Meat'; - let purchaseResponse = purchase(user, {params: {type, key}}); + let [, message] = purchase(user, {params: {type, key}}); - expect(purchaseResponse.message).to.equal(i18n.t('purchased', {type, key})); + expect(message).to.equal(i18n.t('purchased', {type, key})); expect(user.items[type][key]).to.equal(1); }); @@ -180,9 +180,9 @@ describe('shared.ops.feed', () => { let type = 'quests'; let key = 'gryphon'; - let purchaseResponse = purchase(user, {params: {type, key}}); + let [, message] = purchase(user, {params: {type, key}}); - expect(purchaseResponse.message).to.equal(i18n.t('purchased', {type, key})); + expect(message).to.equal(i18n.t('purchased', {type, key})); expect(user.items[type][key]).to.equal(1); }); @@ -190,9 +190,9 @@ describe('shared.ops.feed', () => { let type = 'gear'; let key = 'headAccessory_special_tigerEars'; - let purchaseResponse = purchase(user, {params: {type, key}}); + let [, message] = purchase(user, {params: {type, key}}); - expect(purchaseResponse.message).to.equal(i18n.t('purchased', {type, key})); + expect(message).to.equal(i18n.t('purchased', {type, key})); expect(user.items.gear.owned[key]).to.be.true; }); }); diff --git a/test/common/ops/readCard.js b/test/common/ops/readCard.js index 27f78ea5cd..5d771ab0d6 100644 --- a/test/common/ops/readCard.js +++ b/test/common/ops/readCard.js @@ -39,9 +39,9 @@ describe('shared.ops.readCard', () => { }); it('reads a card', () => { - let response = readCard(user, {params: {cardType: 'greeting'}}); + let [, message] = readCard(user, {params: {cardType: 'greeting'}}); - expect(response.message).to.equal(i18n.t('readCard', {cardType})); + expect(message).to.equal(i18n.t('readCard', {cardType})); expect(user.items.special[`${cardType}Received`]).to.be.empty; expect(user.flags.cardReceived).to.be.false; }); diff --git a/test/common/ops/rebirth.js b/test/common/ops/rebirth.js index 61d1334569..0ab66674b3 100644 --- a/test/common/ops/rebirth.js +++ b/test/common/ops/rebirth.js @@ -35,18 +35,18 @@ describe('shared.ops.rebirth', () => { }); it('rebirths a user with enough gems', () => { - let response = rebirth(user); + let [, message] = rebirth(user); - expect(response.message).to.equal(i18n.t('rebirthComplete')); + expect(message).to.equal(i18n.t('rebirthComplete')); }); it('rebirths a user with not enough gems but max level', () => { user.balance = 0; user.stats.lvl = MAX_LEVEL; - let response = rebirth(user); + let [, message] = rebirth(user); - expect(response.message).to.equal(i18n.t('rebirthComplete')); + expect(message).to.equal(i18n.t('rebirthComplete')); }); it('resets user\'s taks values except for rewards to 0', () => { diff --git a/test/common/ops/releaseBoth.js b/test/common/ops/releaseBoth.js index 309734e7d4..41e1bf6efb 100644 --- a/test/common/ops/releaseBoth.js +++ b/test/common/ops/releaseBoth.js @@ -33,9 +33,9 @@ describe('shared.ops.releaseBoth', () => { }); it('grants triad bingo with gems', () => { - let response = releaseBoth(user); + let [, message] = releaseBoth(user); - expect(response.message).to.equal(i18n.t('mountsAndPetsReleased')); + expect(message).to.equal(i18n.t('mountsAndPetsReleased')); expect(user.achievements.triadBingoCount).to.equal(1); }); @@ -44,24 +44,24 @@ describe('shared.ops.releaseBoth', () => { user.achievements.triadBingo = 1; user.achievements.triadBingoCount = 1; - let response = releaseBoth(user); + let [, message] = releaseBoth(user); - expect(response.message).to.equal(i18n.t('mountsAndPetsReleased')); + expect(message).to.equal(i18n.t('mountsAndPetsReleased')); expect(user.achievements.triadBingoCount).to.equal(2); }); it('releases pets', () => { - let response = releaseBoth(user); + let [, message] = releaseBoth(user); - expect(response.message).to.equal(i18n.t('mountsAndPetsReleased')); + expect(message).to.equal(i18n.t('mountsAndPetsReleased')); expect(user.items.pets[animal]).to.be.empty; expect(user.items.mounts[animal]).to.equal(null); }); it('releases mounts', () => { - let response = releaseBoth(user); + let [, message] = releaseBoth(user); - expect(response.message).to.equal(i18n.t('mountsAndPetsReleased')); + expect(message).to.equal(i18n.t('mountsAndPetsReleased')); expect(user.items.mounts[animal]).to.equal(null); }); diff --git a/test/common/ops/releaseMounts.js b/test/common/ops/releaseMounts.js index 80429b726f..29cb3cf6ac 100644 --- a/test/common/ops/releaseMounts.js +++ b/test/common/ops/releaseMounts.js @@ -31,9 +31,9 @@ describe('shared.ops.releaseMounts', () => { }); it('releases mounts', () => { - let response = releaseMounts(user); + let [, message] = releaseMounts(user); - expect(response.message).to.equal(i18n.t('mountsReleased')); + expect(message).to.equal(i18n.t('mountsReleased')); expect(user.items.mounts[animal]).to.equal(null); }); diff --git a/test/common/ops/releasePets.js b/test/common/ops/releasePets.js index 11d69a5a31..af175736cf 100644 --- a/test/common/ops/releasePets.js +++ b/test/common/ops/releasePets.js @@ -31,9 +31,9 @@ describe('shared.ops.releasePets', () => { }); it('releases pets', () => { - let response = releasePets(user); + let [, message] = releasePets(user); - expect(response.message).to.equal(i18n.t('petsReleased')); + expect(message).to.equal(i18n.t('petsReleased')); expect(user.items.pets[animal]).to.equal(0); }); diff --git a/test/common/ops/reroll.js b/test/common/ops/reroll.js index 8cadd3fa6c..ede0449ee8 100644 --- a/test/common/ops/reroll.js +++ b/test/common/ops/reroll.js @@ -32,9 +32,9 @@ describe('shared.ops.reroll', () => { }); it('rerolls a user with enough gems', () => { - let response = reroll(user); + let [, message] = reroll(user); - expect(response.message).to.equal(i18n.t('rerollComplete')); + expect(message).to.equal(i18n.t('rerollComplete')); }); it('reduces a user\'s balance', () => { diff --git a/test/common/ops/reset.js b/test/common/ops/reset.js index 2243cfa127..50ebf90cb5 100644 --- a/test/common/ops/reset.js +++ b/test/common/ops/reset.js @@ -31,9 +31,9 @@ describe('shared.ops.reset', () => { it('resets a user', () => { - let response = reset(user); + let [, message] = reset(user); - expect(response.message).to.equal(i18n.t('resetComplete')); + expect(message).to.equal(i18n.t('resetComplete')); }); it('resets user\'s health', () => { diff --git a/test/common/ops/revive.js b/test/common/ops/revive.js index 42e1915b07..efd6968b42 100644 --- a/test/common/ops/revive.js +++ b/test/common/ops/revive.js @@ -57,9 +57,9 @@ describe('shared.ops.revive', () => { let weaponKey = 'weapon_warrior_0'; user.items.gear.owned[weaponKey] = true; - let reviveRequest = revive(user); + let [, message] = revive(user); - expect(reviveRequest.message).to.equal(i18n.t('messageLostItem', { itemText: content.gear.flat[weaponKey].text()})); + expect(message).to.equal(i18n.t('messageLostItem', { itemText: content.gear.flat[weaponKey].text()})); expect(user.items.gear.owned[weaponKey]).to.be.false; }); @@ -70,9 +70,9 @@ describe('shared.ops.revive', () => { user.items.gear.owned[weaponKey] = true; user.items.gear.equipped[itemToLose.type] = itemToLose.key; - let reviveRequest = revive(user); + let [, message] = revive(user); - expect(reviveRequest.message).to.equal(i18n.t('messageLostItem', { itemText: itemToLose.text()})); + expect(message).to.equal(i18n.t('messageLostItem', { itemText: itemToLose.text()})); expect(user.items.gear.equipped[itemToLose.type]).to.equal(`${itemToLose.type}_base_0`); }); @@ -83,9 +83,9 @@ describe('shared.ops.revive', () => { user.items.gear.owned[weaponKey] = true; user.items.gear.costume[itemToLose.type] = itemToLose.key; - let reviveRequest = revive(user); + let [, message] = revive(user); - expect(reviveRequest.message).to.equal(i18n.t('messageLostItem', { itemText: itemToLose.text()})); + expect(message).to.equal(i18n.t('messageLostItem', { itemText: itemToLose.text()})); expect(user.items.gear.costume[itemToLose.type]).to.equal(`${itemToLose.type}_base_0`); }); }); diff --git a/test/common/ops/sell.js b/test/common/ops/sell.js index c5499b00d2..e294f43e54 100644 --- a/test/common/ops/sell.js +++ b/test/common/ops/sell.js @@ -66,16 +66,16 @@ describe('shared.ops.sell', () => { }); it('reduces item count from user', () => { - let response = sell(user, {params: { type, key } }); + let [, message] = sell(user, {params: { type, key } }); - expect(response.message).to.equal(i18n.t('sold', {type, key})); + expect(message).to.equal(i18n.t('sold', {type, key})); expect(user.items[type][key]).to.equal(0); }); it('increases user\'s gold', () => { - let response = sell(user, {params: { type, key } }); + let [, message] = sell(user, {params: { type, key } }); - expect(response.message).to.equal(i18n.t('sold', {type, key})); + expect(message).to.equal(i18n.t('sold', {type, key})); expect(user.stats.gp).to.equal(content[type][key].value); }); }); diff --git a/test/common/ops/sleep.js b/test/common/ops/sleep.js index 5466631f75..f1e15625c9 100644 --- a/test/common/ops/sleep.js +++ b/test/common/ops/sleep.js @@ -7,12 +7,12 @@ describe('shared.ops.sleep', () => { it('toggles user.preferences.sleep', () => { let user = generateUser(); - let res = sleep(user); - expect(res).to.eql({preferences: {sleep: true}}); + let [res] = sleep(user); + expect(res).to.eql(true); expect(user.preferences.sleep).to.equal(true); - let res2 = sleep(user); - expect(res2).to.eql({preferences: {sleep: false}}); + let [res2] = sleep(user); + expect(res2).to.eql(false); expect(user.preferences.sleep).to.equal(false); }); }); diff --git a/test/common/ops/unlock.js b/test/common/ops/unlock.js index b7d7bda370..6023b51abe 100644 --- a/test/common/ops/unlock.js +++ b/test/common/ops/unlock.js @@ -55,30 +55,30 @@ describe('shared.ops.unlock', () => { }); it('unlocks a full set', () => { - let response = unlock(user, {query: {path: unlockPath}}); + let [, message] = unlock(user, {query: {path: unlockPath}}); - expect(response.message).to.equal(i18n.t('unlocked')); + expect(message).to.equal(i18n.t('unlocked')); expect(user.purchased.shirt.convict).to.be.true; }); it('unlocks a full set of gear', () => { - let response = unlock(user, {query: {path: unlockGearSetPath}}); + let [, message] = unlock(user, {query: {path: unlockGearSetPath}}); - expect(response.message).to.equal(i18n.t('unlocked')); + expect(message).to.equal(i18n.t('unlocked')); expect(user.items.gear.owned.headAccessory_special_wolfEars).to.be.true; }); it('unlocks a an item', () => { - let response = unlock(user, {query: {path: backgroundUnlockPath}}); + let [, message] = unlock(user, {query: {path: backgroundUnlockPath}}); - expect(response.message).to.equal(i18n.t('unlocked')); + expect(message).to.equal(i18n.t('unlocked')); expect(user.purchased.background.giant_florals).to.be.true; }); it('reduces a user\'s balance', () => { - let response = unlock(user, {query: {path: unlockPath}}); + let [, message] = unlock(user, {query: {path: unlockPath}}); - expect(response.message).to.equal(i18n.t('unlocked')); + expect(message).to.equal(i18n.t('unlocked')); expect(user.balance).to.equal(usersStartingGems - unlockCost); }); }); diff --git a/test/common/ops/updateTask.js b/test/common/ops/updateTask.js index 834aa0c419..d476421f7c 100644 --- a/test/common/ops/updateTask.js +++ b/test/common/ops/updateTask.js @@ -19,7 +19,7 @@ describe('shared.ops.updateTask', () => { }], }); - let res = updateTask(habit, { + let [res] = updateTask(habit, { body: { text: 'updated', id: '123', diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index a634421d13..1be38efe16 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -92,7 +92,14 @@ function _parseRes (res) { if (apiVersion === 'v2') { return res.body; } else if (apiVersion === 'v3') { - return res.body.data; + if (res.body.message) { + return { + data: res.body.data, + message: res.body.message, + }; + } else { + return res.body.data; + } } } diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index aa82123866..c11c371c01 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -124,7 +124,7 @@ api.score = function(req, res, next) { task.completed = direction === 'up'; } - var delta = shared.ops.scoreTask({ + var [delta] = shared.ops.scoreTask({ user, task, direction, @@ -835,7 +835,7 @@ api.updateTask = function(req, res, next) { if(!task) return res.status(404).json({err: 'Task not found.'}) try { - _.assign(task, shared.ops.updateTask(task.toObject(), req)); + _.assign(task, shared.ops.updateTask(task.toObject(), req)[0]); task.save(function(err, task){ if(err) return next(err); @@ -892,6 +892,9 @@ _.each(shared.ops, function(op,k){ try { req.v2 = true; // Used to indicate to the shared code that the old response data should be returned opResponse = shared.ops[k](res.locals.user, req, analytics); + if (Array.isArray(opResponse) && opResponse.length < 3) { + opResponse = opResponse[0]; + } } catch (err) { if (!err.code) return next(err); if (err.code >= 400) return res.status(err.code).json({err:err.message}); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 08c405424e..cf05b69624 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -420,7 +420,7 @@ api.resetPassword = { }); await user.save(); } - res.respond(200, { message: res.t('passwordReset') }); + res.respond(200, {}, res.t('passwordReset')); }, }; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index c6cd66debc..3af9d91090 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -304,7 +304,8 @@ api.updateTask = { // we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? // TODO regarding comment above, make sure other models with nested fields are using this trick too - _.assign(task, Tasks.Task.sanitize(common.ops.updateTask(task.toObject(), req))); + let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req); + _.assign(task, Tasks.Task.sanitize(updatedTaskObj)); // console.log(task.modifiedPaths(), task.toObject().repeat === tep) // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() // see https://github.com/Automattic/mongoose/issues/2749 @@ -376,7 +377,7 @@ api.scoreTask = { let wasCompleted = task.completed; - let delta = common.ops.scoreTask({task, user, direction}, req); + let [delta] = common.ops.scoreTask({task, user, direction}, req); // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) if (direction === 'up') user.fns.randomDrop({task, delta}, req); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 309a299953..427985e5b9 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -432,7 +432,7 @@ api.sleep = { let user = res.locals.user; let sleepRes = common.ops.sleep(user); await user.save(); - res.respond(200, sleepRes); + res.respond(200, ...sleepRes); }, }; @@ -452,7 +452,7 @@ api.allocate = { let user = res.locals.user; let allocateRes = common.ops.allocate(user, req); await user.save(); - res.respond(200, allocateRes); + res.respond(200, ...allocateRes); }, }; @@ -472,7 +472,7 @@ api.allocateNow = { let user = res.locals.user; let allocateNowRes = common.ops.allocateNow(user, req); await user.save(); - res.respond(200, allocateNowRes); + res.respond(200, ...allocateNowRes); }, }; @@ -495,7 +495,7 @@ api.buy = { let user = res.locals.user; let buyRes = common.ops.buy(user, req, res.analytics); await user.save(); - res.respond(200, buyRes); + res.respond(200, ...buyRes); }, }; @@ -516,9 +516,9 @@ api.buyGear = { url: '/user/buy-gear/:key', async handler (req, res) { let user = res.locals.user; - let buyRes = common.ops.buyGear(user, req, res.analytics); + let buyGearRes = common.ops.buyGear(user, req, res.analytics); await user.save(); - res.respond(200, buyRes); + res.respond(200, ...buyGearRes); }, }; @@ -542,7 +542,7 @@ api.buyArmoire = { let user = res.locals.user; let buyArmoireResponse = common.ops.buyArmoire(user, req, res.analytics); await user.save(); - res.respond(200, buyArmoireResponse); + res.respond(200, ...buyArmoireResponse); }, }; @@ -565,7 +565,7 @@ api.buyPotion = { let user = res.locals.user; let buyPotionResponse = common.ops.buyPotion(user, req, res.analytics); await user.save(); - res.respond(200, buyPotionResponse); + res.respond(200, ...buyPotionResponse); }, }; @@ -588,7 +588,7 @@ api.buyMysterySet = { let user = res.locals.user; let buyMysterySetRes = common.ops.buyMysterySet(user, req, res.analytics); await user.save(); - res.respond(200, buyMysterySetRes); + res.respond(200, ...buyMysterySetRes); }, }; @@ -611,7 +611,7 @@ api.buyQuest = { let user = res.locals.user; let buyQuestRes = common.ops.buyQuest(user, req, res.analytics); await user.save(); - res.respond(200, buyQuestRes); + res.respond(200, ...buyQuestRes); }, }; @@ -634,7 +634,7 @@ api.buySpecialSpell = { let user = res.locals.user; let buySpecialSpellRes = common.ops.buySpecialSpell(user, req); await user.save(); - res.respond(200, buySpecialSpellRes); + res.respond(200, ...buySpecialSpellRes); }, }; @@ -658,7 +658,7 @@ api.hatch = { let user = res.locals.user; let hatchRes = common.ops.hatch(user, req); await user.save(); - res.respond(200, hatchRes); + res.respond(200, ...hatchRes); }, }; @@ -682,7 +682,7 @@ api.equip = { let user = res.locals.user; let equipRes = common.ops.equip(user, req); await user.save(); - res.respond(200, equipRes); + res.respond(200, ...equipRes); }, }; @@ -706,7 +706,7 @@ api.feed = { let user = res.locals.user; let feedRes = common.ops.feed(user, req); await user.save(); - res.respond(200, feedRes); + res.respond(200, ...feedRes); }, }; @@ -728,7 +728,7 @@ api.changeClass = { let user = res.locals.user; let changeClassRes = common.ops.changeClass(user, req, res.analytics); await user.save(); - res.respond(200, changeClassRes); + res.respond(200, ...changeClassRes); }, }; @@ -748,7 +748,7 @@ api.disableClasses = { let user = res.locals.user; let disableClassesRes = common.ops.disableClasses(user, req); await user.save(); - res.respond(200, disableClassesRes); + res.respond(200, ...disableClassesRes); }, }; @@ -769,9 +769,9 @@ api.purchase = { url: '/user/purchase/:type/:key', async handler (req, res) { let user = res.locals.user; - let purchaseResponse = common.ops.purchase(user, req, res.analytics); + let purchaseRes = common.ops.purchase(user, req, res.analytics); await user.save(); - res.respond(200, purchaseResponse); + res.respond(200, ...purchaseRes); }, }; @@ -792,9 +792,9 @@ api.userPurchaseHourglass = { url: '/user/purchase-hourglass/:type/:key', async handler (req, res) { let user = res.locals.user; - let purchaseHourglassResponse = common.ops.purchaseHourglass(user, req, res.analytics); + let purchaseHourglassRes = common.ops.purchaseHourglass(user, req, res.analytics); await user.save(); - res.respond(200, purchaseHourglassResponse); + res.respond(200, ...purchaseHourglassRes); }, }; @@ -814,9 +814,9 @@ api.readCard = { url: '/user/read-card/:cardType', async handler (req, res) { let user = res.locals.user; - let readCardResponse = common.ops.readCard(user, req); + let readCardRes = common.ops.readCard(user, req); await user.save(); - res.respond(200, readCardResponse); + res.respond(200, ...readCardRes); }, }; @@ -834,9 +834,9 @@ api.userOpenMysteryItem = { url: '/user/open-mystery-item', async handler (req, res) { let user = res.locals.user; - let openMysteryItemResponse = common.ops.openMysteryItem(user, req, res.analytics); + let openMysteryItemRes = common.ops.openMysteryItem(user, req, res.analytics); await user.save(); - res.respond(200, openMysteryItemResponse); + res.respond(200, ...openMysteryItemRes); }, }; @@ -853,9 +853,9 @@ api.addWebhook = { url: '/user/webhook', async handler (req, res) { let user = res.locals.user; - let result = common.ops.addWebhook(user, req); + let addWebhookRes = common.ops.addWebhook(user, req); await user.save(); - res.respond(200, result); + res.respond(200, ...addWebhookRes); }, }; @@ -872,9 +872,9 @@ api.updateWebhook = { url: '/user/webhook/:id', async handler (req, res) { let user = res.locals.user; - let result = common.ops.updateWebhook(user, req); + let updateWebhookRes = common.ops.updateWebhook(user, req); await user.save(); - res.respond(200, result); + res.respond(200, ...updateWebhookRes); }, }; @@ -891,9 +891,9 @@ api.deleteWebhook = { url: '/user/webhook/:id', async handler (req, res) { let user = res.locals.user; - common.ops.deleteWebhook(user, req); + let deleteWebhookRes = common.ops.deleteWebhook(user, req); await user.save(); - res.respond(200, {}); + res.respond(200, ...deleteWebhookRes); }, }; @@ -911,9 +911,9 @@ api.userReleasePets = { url: '/user/release-pets', async handler (req, res) { let user = res.locals.user; - let releasePetsResponse = common.ops.releasePets(user, req, res.analytics); + let releasePetsRes = common.ops.releasePets(user, req, res.analytics); await user.save(); - res.respond(200, releasePetsResponse); + res.respond(200, ...releasePetsRes); }, }; @@ -931,9 +931,9 @@ api.userReleaseBoth = { url: '/user/release-both', async handler (req, res) { let user = res.locals.user; - let releaseBothResponse = common.ops.releaseBoth(user, req, res.analytics); + let releaseBothRes = common.ops.releaseBoth(user, req, res.analytics); await user.save(); - res.respond(200, releaseBothResponse); + res.respond(200, ...releaseBothRes); }, }; @@ -951,9 +951,9 @@ api.userReleaseMounts = { url: '/user/release-mounts', async handler (req, res) { let user = res.locals.user; - let releaseMountsResponse = common.ops.releaseMounts(user, req, res.analytics); + let releaseMountsRes = common.ops.releaseMounts(user, req, res.analytics); await user.save(); - res.respond(200, releaseMountsResponse); + res.respond(200, ...releaseMountsRes); }, }; @@ -971,9 +971,9 @@ api.userSell = { url: '/user/sell/:type/:key', async handler (req, res) { let user = res.locals.user; - let sellResponse = common.ops.sell(user, req); + let sellRes = common.ops.sell(user, req); await user.save(); - res.respond(200, sellResponse); + res.respond(200, ...sellRes); }, }; @@ -991,9 +991,9 @@ api.userUnlock = { url: '/user/unlock', async handler (req, res) { let user = res.locals.user; - let unlockResponse = common.ops.unlock(user, req); + let unlockRes = common.ops.unlock(user, req); await user.save(); - res.respond(200, unlockResponse); + res.respond(200, ...unlockRes); }, }; @@ -1011,9 +1011,9 @@ api.userRevive = { url: '/user/revive', async handler (req, res) { let user = res.locals.user; - let reviveResponse = common.ops.revive(user, req, res.analytics); + let reviveRes = common.ops.revive(user, req, res.analytics); await user.save(); - res.respond(200, reviveResponse); + res.respond(200, ...reviveRes); }, }; @@ -1036,13 +1036,13 @@ api.userRebirth = { type: {$in: ['daily', 'habit', 'todo']}, }; let tasks = await Tasks.Task.find(query).exec(); - let rebirthResponse = common.ops.rebirth(user, tasks, req, res.analytics); + let rebirthRes = common.ops.rebirth(user, tasks, req, res.analytics); await user.save(); await Q.all(tasks.map(task => task.save())); - res.respond(200, rebirthResponse); + res.respond(200, ...rebirthRes); }, }; @@ -1059,9 +1059,9 @@ api.blockUser = { url: '/user/block/:uuid', async handler (req, res) { let user = res.locals.user; - let blocks = common.ops.blockUser(user, req); + let blockUserRes = common.ops.blockUser(user, req); await user.save(); - res.respond(200, blocks); + res.respond(200, ...blockUserRes); }, }; @@ -1078,9 +1078,9 @@ api.deleteMessage = { url: '/user/messages/:id', async handler (req, res) { let user = res.locals.user; - let messages = common.ops.deletePM(user, req); + let deletePMRes = common.ops.deletePM(user, req); await user.save(); - res.respond(200, messages); + res.respond(200, ...deletePMRes); }, }; @@ -1097,9 +1097,9 @@ api.clearMessages = { url: '/user/messages', async handler (req, res) { let user = res.locals.user; - let PMs = common.ops.clearPMs(user, req); + let clearPMsRes = common.ops.clearPMs(user, req); await user.save(); - res.respond(200, PMs); + res.respond(200, ...clearPMsRes); }, }; @@ -1122,14 +1122,14 @@ api.userReroll = { type: {$in: ['daily', 'habit', 'todo']}, }; let tasks = await Tasks.Task.find(query).exec(); - let rerollResponse = common.ops.reroll(user, tasks, req, res.analytics); + let rerollRes = common.ops.reroll(user, tasks, req, res.analytics); let promises = tasks.map(task => task.save()); promises.push(user.save()); await Q.all(promises); - res.respond(200, rerollResponse); + res.respond(200, ...rerollRes); }, }; @@ -1148,10 +1148,10 @@ api.userAddPushDevice = { async handler (req, res) { let user = res.locals.user; - let addPushDeviceResponse = common.ops.addPushDevice(user, req); + let addPushDeviceRes = common.ops.addPushDevice(user, req); await user.save(); - res.respond(200, addPushDeviceResponse); + res.respond(200, ...addPushDeviceRes); }, }; @@ -1172,11 +1172,11 @@ api.userReset = { let tasks = await Tasks.Task.find({userId: user._id}).select('_id type challenge').exec(); - let resetResponse = common.ops.reset(user, tasks); + let resetRes = common.ops.reset(user, tasks); - await Q.all([Tasks.Task.remove({_id: {$in: resetResponse.data.tasksToRemove}, userId: user._id}), user.save()]); + await Q.all([Tasks.Task.remove({_id: {$in: resetRes[0].tasksToRemove}, userId: user._id}), user.save()]); - res.respond(200, resetResponse); + res.respond(200, ...resetRes); }, }; diff --git a/website/src/middlewares/api-v3/response.js b/website/src/middlewares/api-v3/response.js index 7fe4bc7e35..335d51a2ff 100644 --- a/website/src/middlewares/api-v3/response.js +++ b/website/src/middlewares/api-v3/response.js @@ -1,10 +1,14 @@ module.exports = function responseHandler (req, res, next) { // Only used for successful responses - res.respond = function respond (status = 200, data = {}) { - res.status(status).json({ + res.respond = function respond (status = 200, data = {}, message) { + let response = { success: status < 400, data, - }); + }; + + if (message) response.message = message; + + res.status(status).json(response); }; next(); From f9915c3f77fca08924819f978fe4abd03a3164e6 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 21 Apr 2016 00:07:58 +0000 Subject: [PATCH 704/976] recovering last weeks work on promisifying amazon payments --- .eslintignore | 2 +- .eslintrc | 5 ++- test/api/v3/unit/libs/amazonPayments.test.js | 32 +++++++++++++------ .../controllers/top-level/payments/amazon.js | 10 +++--- .../src/controllers/top-level/payments/iap.js | 8 ++--- .../controllers/top-level/payments/paypal.js | 5 ++- .../controllers/top-level/payments/stripe.js | 6 ++-- website/src/libs/api-v3/amazonPayments.js | 18 +++++++---- 8 files changed, 51 insertions(+), 35 deletions(-) diff --git a/.eslintignore b/.eslintignore index 93a77392f1..2acf38ea8d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -19,7 +19,7 @@ website/src/routes/payments.js website/src/routes/pages.js website/src/middlewares/apiThrottle.js website/src/middlewares/forceRefresh.js -website/src/controllers/payments/ +website/src/controllers/top-level/payments/paypal.js debug-scripts/* tasks/*.js diff --git a/.eslintrc b/.eslintrc index 111772a5a3..bcccde1ef6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,5 +2,8 @@ "extends": [ "habitrpg/server", "habitrpg/babel" - ] + ], + "globals": { + "Promise": true + } } diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index 7cc3683947..a4548af66b 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -1,11 +1,29 @@ import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; -import * as amzStub from 'amazon-payments'; +// import * as amzStub from 'amazon-payments'; +import amazonPayments from 'amazon-payments'; describe('amazonPayments', () => { beforeEach(() => { }); - describe('#getTokenInfo', () => { + describe('#getTokenInfo stubbed', () => { + let thisToken = 'this token info'; + let amzOldConnect; + + beforeEach(() => { + amzOldConnect = amazonPayments.connect; + amazonPayments.connect = () => { + let api = { getTokenInfo: (token, cb) => { + return cb(undefined, thisToken); + } }; + return { api }; + }; + }); + + afterEach(() => { + amazonPayments.connect = amzOldConnect; + }); + it('validates access_token parameter', async (done) => { try { await amz.getTokenInfo(); @@ -16,21 +34,15 @@ describe('amazonPayments', () => { }); it('returns tokenInfo', async (done) => { - let thisToken = 'this token info'; - let amzStubInstance = amzStub.connect({}); - amzStubInstance.api.getTokenInfo = (token, cb) => { - return cb(undefined, thisToken); - }; let result = await amz.getTokenInfo(); - // console.log('+++ +++ result:', result); expect(result).to.eql(thisToken); done(); }); }); describe('#createOrderReferenceId', () => { - it('is sane', () => { - expect(false).to.eql(true); // @TODO + it('succeeds', () => { }); }); + }); diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 63b589f6fc..a2316f21e6 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,13 +1,13 @@ -import async from 'async'; +/* import async from 'async'; import cc from 'coupon-code'; import mongoose from 'mongoose'; import moment from 'moment'; import payments from './index'; import shared from '../../../../../common'; -import { model as User } from '../../../models/user'; +import { model as User } from '../../../models/user'; */ import { - NotFound, - NotAuthorized, + // NotFound, + // NotAuthorized, BadRequest, } from '../../../libs/api-v3/errors'; import amz from '../../../libs/api-v3/amazonPayments'; @@ -29,7 +29,7 @@ api.verifyAccessToken = { await amz.getTokenInfo(req.body.access_token) .then(() => { res.respond(200, {}); - }).catch( (error) => { + }).catch((error) => { throw new BadRequest(error.body.error_description); }); }, diff --git a/website/src/controllers/top-level/payments/iap.js b/website/src/controllers/top-level/payments/iap.js index 5de66b0452..898b0b2015 100644 --- a/website/src/controllers/top-level/payments/iap.js +++ b/website/src/controllers/top-level/payments/iap.js @@ -1,6 +1,4 @@ import iap from 'in-app-purchase'; -import whatThis from 'in-app-purchase'; -import payments from './index'; import nconf from 'nconf'; iap.config({ @@ -9,9 +7,9 @@ iap.config({ }); // Validation ERROR Codes -const INVALID_PAYLOAD = 6778001; -/* const CONNECTION_FAILED = 6778002; -const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? +// const INVALID_PAYLOAD = 6778001; +// const CONNECTION_FAILED = 6778002; +// const PURCHASE_EXPIRED = 6778003; let api = {}; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 046a6f52cc..05e8594ac1 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -25,7 +25,7 @@ paypal.configure({ 'client_secret': nconf.get("PAYPAL:client_secret") }); -var parseErr = function(res, err){ +var parseErr = function (res, err) { //var error = err.response ? err.response.message || err.response.details[0].issue : err; var error = JSON.stringify(err); return res.status(400).json({err:error}); @@ -190,8 +190,7 @@ exports.cancelSubscription = function(req, res, next){ res.redirect('/'); user = null; }); -} -*/ +} // */ /** * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index 765ccba8f6..34bc598b3e 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -1,13 +1,13 @@ -import nconf from 'nconf'; +/* import nconf from 'nconf'; import stripeModule from 'stripe'; import async from 'async'; import payments from './index'; import { model as User } from '../../../models/user'; import shared from '../../../../../common'; import mongoose from 'mongoose'; -import cc from 'coupon-code'; +import cc from 'coupon-code'; */ -const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); +// const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); let api = {}; /* diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index e89272287b..e469d1c121 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -5,15 +5,18 @@ const IS_PROD = nconf.get('NODE_ENV') === 'production'; let api = {}; -let amzPayment = amazonPayments.connect({ - environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], - sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), - mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), - mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), - clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), -}); +function connect (amazonPayments) { + return amazonPayments.connect({ + environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], + sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), + mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), + mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), + clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), + }); +} api.getTokenInfo = (token) => { + let amzPayment = connect(amazonPayments); return new Promise((resolve, reject) => { amzPayment.api.getTokenInfo(token, (err, tokenInfo) => { if (err) return reject(err); @@ -23,6 +26,7 @@ api.getTokenInfo = (token) => { }; api.createOrderReferenceId = (inputSet) => { + let amzPayment = connect(amazonPayments); return new Promise((resolve, reject) => { amzPayment.offAmazonPayments.createOrderReferenceForId(inputSet, (err, response) => { if (err) return reject(err); From 612e3b725f541d23906788246d229aa3ba9c305b Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 21 Apr 2016 00:14:14 +0000 Subject: [PATCH 705/976] little changes to lint --- test/api/v3/unit/libs/amazonPayments.test.js | 15 ++++++++------- website/src/libs/api-v3/amazonPayments.js | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index a4548af66b..aa5a9588c6 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -24,6 +24,14 @@ describe('amazonPayments', () => { amazonPayments.connect = amzOldConnect; }); + it('returns tokenInfo', async (done) => { + let result = await amz.getTokenInfo(); + expect(result).to.eql(thisToken); + done(); + }); + }); + + describe('#getTokenInfo', () => { it('validates access_token parameter', async (done) => { try { await amz.getTokenInfo(); @@ -32,17 +40,10 @@ describe('amazonPayments', () => { done(); } }); - - it('returns tokenInfo', async (done) => { - let result = await amz.getTokenInfo(); - expect(result).to.eql(thisToken); - done(); - }); }); describe('#createOrderReferenceId', () => { it('succeeds', () => { }); }); - }); diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index e469d1c121..c7bf5202b3 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -5,7 +5,7 @@ const IS_PROD = nconf.get('NODE_ENV') === 'production'; let api = {}; -function connect (amazonPayments) { +function connect (amazonPayments) { // eslint-disable-line no-shadow return amazonPayments.connect({ environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), From 54a94db2de729049e3d70f71a509192640d4d357 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 21 Apr 2016 03:52:29 +0000 Subject: [PATCH 706/976] working on amazon payments --- common/locales/en/api-v3.json | 3 +- ...ayments_amazon_verify_access_token.test.js | 2 +- test/api/v3/unit/libs/amazonPayments.test.js | 62 ++++++++- .../controllers/top-level/payments/amazon.js | 123 +++++++++--------- website/src/libs/api-v3/amazonPayments.js | 56 +++++++- 5 files changed, 175 insertions(+), 71 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 8c0ec78de0..5b1d57383e 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -173,5 +173,6 @@ "equipmentAlreadyOwned": "You already own that piece of equipment", "missingAccessToken": "The request is missing a required parameter : access_token", "missingBillingAgreementId": "Missing billing agreement id", - "missingAttributesFromAmazon": "Missing attributes from Amazon" + "missingAttributesFromAmazon": "Missing attributes from Amazon", + "paymentNotSuccessful": "The payment was not successful" } diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js index 494c387f14..ecc021e25d 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js @@ -11,7 +11,7 @@ describe('payments : amazon', () => { user = await generateUser(); }); - it('verify access token', async () => { + it('verifies access token', async () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index aa5a9588c6..9529c3e985 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -1,6 +1,7 @@ -import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; +import * as amzLib from '../../../../../website/src/libs/api-v3/amazonPayments'; // import * as amzStub from 'amazon-payments'; import amazonPayments from 'amazon-payments'; +var User = require('mongoose').model('User'); describe('amazonPayments', () => { beforeEach(() => { @@ -25,7 +26,7 @@ describe('amazonPayments', () => { }); it('returns tokenInfo', async (done) => { - let result = await amz.getTokenInfo(); + let result = await amzLib.getTokenInfo(); expect(result).to.eql(thisToken); done(); }); @@ -34,7 +35,7 @@ describe('amazonPayments', () => { describe('#getTokenInfo', () => { it('validates access_token parameter', async (done) => { try { - await amz.getTokenInfo(); + await amzLib.getTokenInfo(); } catch (e) { expect(e.type).to.eql('invalid_request'); done(); @@ -43,7 +44,60 @@ describe('amazonPayments', () => { }); describe('#createOrderReferenceId', () => { - it('succeeds', () => { + it('verifies billingAgreementId', async (done) => { + try { + let inputSet = {}; + delete inputSet.Id; + await amzLib.createOrderReferenceId(inputSet); + } catch (e) { + + /* console.log('error!', e); + console.log('error keys!', Object.keys(e)); + for (var key in e) { + console.log(e[key]); + } // */ + + expect(e.type).to.eql('InvalidParameterValue'); + expect(e.body.ErrorResponse.Error.Message).to.eql('Parameter AWSAccessKeyId cannot be empty.'); + done(); + } + }); + + xit('succeeds', () => { }); }); + + describe('#checkout', () => { + xit('succeeds'); + }); + + describe('#setOrderReferenceDetails', () => { + xit('succeeds'); + }); + + describe('#confirmOrderReference', () => { + xit('succeeds'); + }); + + describe('#authorize', () => { + xit('succeeds'); + + xit('was declined'); + + xit('had an error'); + }); + + describe('#closeOrderReference', () => { + xit('succeeds'); + }); + + describe.only('#executePayment', () => { + it('succeeds', () => { + }); + + it('succeeds as a gift', () => { + }); + }); + + }); diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index a2316f21e6..16c835c2c3 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -10,7 +10,8 @@ import { // NotAuthorized, BadRequest, } from '../../../libs/api-v3/errors'; -import amz from '../../../libs/api-v3/amazonPayments'; +import amzLib from '../../../libs/api-v3/amazonPayments'; +import { authWithHeaders } from '../../../middlewares/api-v3/auth'; let api = {}; @@ -26,7 +27,7 @@ api.verifyAccessToken = { method: 'POST', url: '/payments/amazon/verifyAccessToken', async handler (req, res) { - await amz.getTokenInfo(req.body.access_token) + await amzLib.getTokenInfo(req.body.access_token) .then(() => { res.respond(200, {}); }).catch((error) => { @@ -46,47 +47,59 @@ api.verifyAccessToken = { api.createOrderReferenceId = { method: 'POST', url: '/payments/amazon/createOrderReferenceId', + // middlewares: [authWithHeaders()], async handler (req, res) { - if (!req.body.billingAgreementId) { - throw new BadRequest(res.t('missingBillingAgreementId')); - } - let response = await amz.createOrderReferenceId({ - Id: req.body.billingAgreementId, - IdType: 'BillingAgreement', - ConfirmNow: false, - }).then(() => { + try { + let response = await amzLib.createOrderReferenceId({ + Id: req.body.billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + AWSAccessKeyId: 'something', + }); res.respond(200, { orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, }); - }).catch(errStr => { - throw new BadRequest(res.t(errStr)); - }); + } catch (error) { + throw new BadRequest(error); + } + }, }; -/* -api.checkout = function checkout (req, res, next) { - if (!req.body || !req.body.orderReferenceId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } +/** + * @api {post} /api/v3/payments/amazon/checkout do checkout + * @apiVersion 3.0.0 + * @apiName AmazonCheckout + * @apiGroup Payments + * + * @apiParam {string} billingAgreementId billing agreement id + * @apiSuccess {object} object containing { orderReferenceId } + **/ +api.checkout = { + method: 'POST', + url: '/payments/amazon/checkout', + middlewares: [authWithHeaders()], + async handler (req, res) { + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let amount = 5; - let gift = req.body.gift; - let user = res.locals.user; - let orderReferenceId = req.body.orderReferenceId; - let amount = 5; - - if (gift) { - if (gift.type === 'gems') { - amount = gift.gems.amount / 4; - } else if (gift.type === 'subscription') { - amount = shared.content.subscriptionBlocks[gift.subscription.key].price; + if (gift) { + if (gift.type === 'gems') { + amount = gift.gems.amount / 4; + } else if (gift.type === 'subscription') { + amount = shared.content.subscriptionBlocks[gift.subscription.key].price; + } } - } - async.series({ - setOrderReferenceDetails (cb) { - amzPayment.offAmazonPayments.setOrderReferenceDetails({ + /* if (!req.body || !req.body.orderReferenceId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } */ + + try { + await amzLib.setOrderReferenceDetails({ AmazonOrderReferenceId: orderReferenceId, OrderReferenceAttributes: { OrderTotal: { @@ -99,17 +112,11 @@ api.checkout = function checkout (req, res, next) { StoreName: 'HabitRPG', }, }, - }, cb); - }, + }); - confirmOrderReference (cb) { - amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, + await amzLib.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId }); - authorize (cb) { - amzPayment.offAmazonPayments.authorize({ + await amzLib.authorize({ AmazonOrderReferenceId: orderReferenceId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { @@ -119,23 +126,16 @@ api.checkout = function checkout (req, res, next) { SellerAuthorizationNote: 'HabitRPG Payment', TransactionTimeout: 0, CaptureNow: true, - }, function checkAuthorizationStatus (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successfull.')); - } - - return cb(); }); - }, - closeOrderReference (cb) { - amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, + await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); + res.respond(200); + } catch(error) { + throw new BadRequest(error); + } + + /* executePayment (cb) { async.waterfall([ function findUser (cb2) { @@ -153,16 +153,13 @@ api.checkout = function checkout (req, res, next) { } payments[method](data, cb2); - }, - ], cb); - }, - }, function result (err) { - if (err) return next(err); - - res.sendStatus(200); - }); + }, */ + }, }; + + +/* api.subscribe = function subscribe (req, res, next) { if (!req.body || !req.body.billingAgreementId) { return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index c7bf5202b3..3d92132f2f 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -1,6 +1,7 @@ import amazonPayments from 'amazon-payments'; import nconf from 'nconf'; - +import common from '../../../../common'; +let t = common.i18n.t; const IS_PROD = nconf.get('NODE_ENV') === 'production'; let api = {}; @@ -31,11 +32,62 @@ api.createOrderReferenceId = (inputSet) => { amzPayment.offAmazonPayments.createOrderReferenceForId(inputSet, (err, response) => { if (err) return reject(err); if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { - return reject('missingAttributesFromAmazon'); + return reject(t('missingAttributesFromAmazon')); } return resolve(response); }); }); }; +api.setOrderReferenceDetails = (inputSet) => { + let amzPayment = connect(amazonPayments); + return new Promise((resolve, reject) => { + amzPayment.offAmazonPayments.setOrderReferenceDetails(inputSet, (err, response) => { + if (err) return reject(err); + return resolve(response); + }); + }); +}; + +api.confirmOrderReference = (inputSet) => { + let amzPayment = connect(amazonPayments); + return new Promise((resolve, reject) => { + amzPayment.offAmazonPayments.confirmOrderReference(inputSet, (err, response) => { + if (err) return reject(err); + return resolve(response); + }); + }); +}; + +api.authorize = (inputSet) => { + let amzPayment = connect(amazonPayments); + return new Promize((resolve, reject) => { + amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { + if (err) return reject(err); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); + return resolve(response); + }); + }); +}; + +api.closeOrderReference = (inputSet) => { + let amzPayment = connect(amazonPayments); + return new Promize((resolve, reject) => { + amzPayment.offAmazonPayments.closeOrderReference(inputSet, (err, response) => { + if (err) return reject(err); + return resolve(response); + }); + }); +}; + +api.executePayment = (inputSet) => { + let amzPayment = connect(amazonPayments); + return new Promize((resolve, reject) => { + amzPayment.offAmazonPayments.closeOrderReference(inputSet, (err, response) => { + if (err) return reject(err); + return resolve(response); + }); + }); +}; + module.exports = api; From cbf1a4c8d32e82765ff07cb4e29fc596de4d91e7 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 21 Apr 2016 04:37:25 +0000 Subject: [PATCH 707/976] breaking changes promisifying amazon payments --- test/api/v3/unit/libs/amazonPayments.test.js | 2 +- test/api/v3/unit/libs/paymentsIndex.test.js | 11 ++++++ .../controllers/top-level/payments/amazon.js | 34 +++++++------------ .../controllers/top-level/payments/index.js | 9 +++-- 4 files changed, 32 insertions(+), 24 deletions(-) create mode 100644 test/api/v3/unit/libs/paymentsIndex.test.js diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index 9529c3e985..b2bf480e01 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -92,7 +92,7 @@ describe('amazonPayments', () => { }); describe.only('#executePayment', () => { - it('succeeds', () => { + it('succeeds not as a gift', () => { }); it('succeeds as a gift', () => { diff --git a/test/api/v3/unit/libs/paymentsIndex.test.js b/test/api/v3/unit/libs/paymentsIndex.test.js new file mode 100644 index 0000000000..74b9d29273 --- /dev/null +++ b/test/api/v3/unit/libs/paymentsIndex.test.js @@ -0,0 +1,11 @@ + +describe('payments/index', () => { + beforeEach(() => { + }); + + describe('#createSubscription', async () => { + }); + + describe('#buyGems', async () => { + }); +}); diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 16c835c2c3..785efb06b9 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -12,6 +12,7 @@ import { } from '../../../libs/api-v3/errors'; import amzLib from '../../../libs/api-v3/amazonPayments'; import { authWithHeaders } from '../../../middlewares/api-v3/auth'; +var payments = require('./index'); let api = {}; @@ -130,31 +131,22 @@ api.checkout = { await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); + // execute payment + let giftUser = await User.findById(gift ? gift.uuid : undefined); + let data = { giftUser, paymentMethod: 'Amazon Payments' }; + let method = 'buyGems'; + if (gift) { + if (gift.type === 'subscription') method = 'createSubscription'; + gift.member = giftUser; + data.gift = gift; + data.paymentMethod = 'Gift'; + } + await payments[method](data); + res.respond(200); } catch(error) { throw new BadRequest(error); } - - /* - executePayment (cb) { - async.waterfall([ - function findUser (cb2) { - User.findById(gift ? gift.uuid : undefined, cb2); - }, - function executeAmazonPayment (member, cb2) { - let data = {user, paymentMethod: 'Amazon Payments'}; - let method = 'buyGems'; - - if (gift) { - if (gift.type === 'subscription') method = 'createSubscription'; - gift.member = member; - data.gift = gift; - data.paymentMethod = 'Gift'; - } - - payments[method](data, cb2); - }, */ - }, }; diff --git a/website/src/controllers/top-level/payments/index.js b/website/src/controllers/top-level/payments/index.js index 6c9ddea60d..6ee56bf0d2 100644 --- a/website/src/controllers/top-level/payments/index.js +++ b/website/src/controllers/top-level/payments/index.js @@ -36,6 +36,9 @@ function revealMysteryItems (user) { }); } +// @TODO: HEREHERE +api.createSubscription = async function createSubscription (data) { +} api.createSubscription = function createSubscription (data, cb) { let recipient = data.gift ? data.gift.member : data.user; let plan = recipient.purchased.plan; @@ -150,6 +153,9 @@ api.cancelSubscription = function cancelSubscription (data, cb) { analytics.track('unsubscribe', analyticsData); }; +// @TODO: HEREHERE +api.buyGems = async function buyGems (data) { +}; api.buyGems = function buyGems (data, cb) { let amt = data.amount || 5; amt = data.gift ? data.gift.gems.amount / 4 : amt; @@ -229,5 +235,4 @@ api.amazonSubscribeCancel = amazon.subscribeCancel; api.iapAndroidVerify = iap.androidVerify; api.iapIosVerify = iap.iosVerify; -// module.exports = api; -module.exports = {}; // @TODO HEREHERE +module.exports = api; From 1fe2220aa1458bd859976ab0ff1364eb33525a9e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 22 Apr 2016 12:15:24 +0200 Subject: [PATCH 708/976] v3: improve docs and fix bugs --- common/script/ops/rebirth.js | 2 +- website/src/controllers/api-v3/auth.js | 2 +- website/src/controllers/api-v3/user.js | 212 ++++++++++++++++--------- 3 files changed, 140 insertions(+), 76 deletions(-) diff --git a/common/script/ops/rebirth.js b/common/script/ops/rebirth.js index c7de4ac356..40920fb0c7 100644 --- a/common/script/ops/rebirth.js +++ b/common/script/ops/rebirth.js @@ -102,7 +102,7 @@ module.exports = function rebirth (user, tasks = [], req = {}, analytics) { return user; } else { return [ - user, + {user, tasks}, i18n.t('rebirthComplete'), ]; } diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index cf05b69624..4b41448f97 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -380,7 +380,7 @@ api.updatePassword = { * * @apiParam {string} email Body parameter - The email address of the user * - * @apiSuccess {string} data.message The localized success message + * @apiSuccess {string} message The localized success message **/ api.resetPassword = { method: 'POST', diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 427985e5b9..f76c785369 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -422,7 +422,7 @@ api.castSpell = { * @apiName UserSleep * @apiGroup User * - * @apiSuccess {Object} data Will return an object with the new `user.preferences.sleep` value. Example `{preferences: {sleep: true}}` + * @apiSuccess {boolean} data user.preferences.sleep */ api.sleep = { method: 'POST', @@ -442,7 +442,9 @@ api.sleep = { * @apiName UserAllocate * @apiGroup User * - * @apiSuccess {Object} Returs `user.stats` + * @apiParam {string} stat Query parameter - Defaults to 'str', mast be one of be of str, con, int or per + * + * @apiSuccess {Object} data user.stats */ api.allocate = { method: 'POST', @@ -462,7 +464,7 @@ api.allocate = { * @apiName UserAllocateNow * @apiGroup User * - * @apiSuccess {Object} data `stats` + * @apiSuccess {Object} data user.stats */ api.allocateNow = { method: 'POST', @@ -478,14 +480,12 @@ api.allocateNow = { /** * @api {post} /user/buy/:key Buy gear, armoire or potion + * @apiDescription Under the hood uses UserBuyGear, UserBuyPotion and UserBuyArmoire * @apiVersion 3.0.0 * @apiName UserBuy * @apiGroup User * * @apiParam {string} key The item to buy. - * - * @apiSuccess {Object} data `items` - * @apiSuccess {string} message */ api.buy = { method: 'POST', @@ -507,8 +507,11 @@ api.buy = { * * @apiParam {string} key The item to buy. * - * @apiSuccess {Object} data `items` - * @apiSuccess {string} message + * @apiSuccess {object} data.items user.items + * @apiSuccess {object} data.flags user.flags + * @apiSuccess {object} data.achievements user.achievements + * @apiSuccess {object} data.stats user.stats + * @apiSuccess {string} message Success message */ api.buyGear = { method: 'POST', @@ -530,9 +533,10 @@ api.buyGear = { * * @apiParam {string} key The item to buy. * - * @apiSuccess {Object} data `items flags` - * @apiSuccess {object} armoireResp Optional extra item given by the armoire - * @apiSuccess {string} message + * @apiSuccess {object} data.items user.items + * @apiSuccess {object} data.flags user.flags + * @apiSuccess {object} data.armoire Extra item given by the armoire + * @apiSuccess {string} message Success message */ api.buyArmoire = { method: 'POST', @@ -554,8 +558,8 @@ api.buyArmoire = { * * @apiParam {string} key The item to buy. * - * @apiSuccess {Object} data `stats` - * @apiSuccess {string} message + * @apiSuccess {Object} data user.stats + * @apiSuccess {string} message Success message */ api.buyPotion = { method: 'POST', @@ -577,8 +581,9 @@ api.buyPotion = { * * @apiParam {string} key The mystery set to buy. * - * @apiSuccess {Object} data `items, purchased.plan.consecutive` - * @apiSuccess {string} message + * @apiSuccess {Object} data.items user.items + * @apiSuccess {Object} data.purchasedPlanConsecutive user.purchased.plan.consecutive + * @apiSuccess {string} message Success message */ api.buyMysterySet = { method: 'POST', @@ -600,8 +605,8 @@ api.buyMysterySet = { * * @apiParam {string} key The quest spell to buy. * - * @apiSuccess {Object} data `items.quests` - * @apiSuccess {string} message + * @apiSuccess {Object} data `user.items.quests` + * @apiSuccess {string} message Success message */ api.buyQuest = { method: 'POST', @@ -623,8 +628,9 @@ api.buyQuest = { * * @apiParam {string} key The special spell to buy. * - * @apiSuccess {Object} data `items, stats` - * @apiSuccess {string} message + * @apiSuccess {Object} data.stats user.stats + * @apiSuccess {Object} data.items user.items + * @apiSuccess {string} message Success message */ api.buySpecialSpell = { method: 'POST', @@ -647,7 +653,7 @@ api.buySpecialSpell = { * @apiParam {string} egg The egg to use. * @apiParam {string} hatchingPotion The hatching potion to use. * - * @apiSuccess {Object} data `user.items` + * @apiSuccess {Object} data user.items * @apiSuccess {string} message */ api.hatch = { @@ -668,11 +674,11 @@ api.hatch = { * @apiName UserEquip * @apiGroup User * - * @apiParam {string} type - * @apiParam {string} key + * @apiParam {string} type The type of item to equip (mount, pet, costume or equipped) + * @apiParam {string} key The item to equip * - * @apiSuccess {Object} data `user.items` - * @apiSuccess {string} message Optional + * @apiSuccess {Object} data user.items + * @apiSuccess {string} message Optional success message */ api.equip = { method: 'POST', @@ -695,8 +701,8 @@ api.equip = { * @apiParam {string} pet * @apiParam {string} food * - * @apiSuccess {Object} data The fed pet - * @apiSuccess {string} message + * @apiSuccess {number} data The pet value + * @apiSuccess {string} message Success message */ api.feed = { method: 'POST', @@ -712,13 +718,17 @@ api.feed = { /** * @api {post} /api/v3/user/change-class Change class. +* @apiDescription User must be at least level 10. If ?class is defined and user.flags.classSelected is false it'll change the class. If user.preferences.disableClasses it'll enable classes, otherwise it sets user.flags.classSelected to false (costs 3 gems) * @apiVersion 3.0.0 * @apiName UserChangeClass * @apiGroup User * -* @apiParam {string} class ?class={warrior|rogue|wizard|healer}. If missing will +* @apiParam {string} class Query parameter - ?class={warrior|rogue|wizard|healer}. * -* @apiSuccess {Object} data `stats flags items preferences` +* @apiSuccess {object} data.flags user.flags +* @apiSuccess {object} data.stats user.stats +* @apiSuccess {object} data.preferences user.preferences +* @apiSuccess {object} data.items user.items */ api.changeClass = { method: 'POST', @@ -738,7 +748,9 @@ api.changeClass = { * @apiName UserDisableClasses * @apiGroup User * -* @apiSuccess {Object} data `stats flags preferences` +* @apiSuccess {object} data.flags user.flags +* @apiSuccess {object} data.stats user.stats +* @apiSuccess {object} data.preferences user.preferences */ api.disableClasses = { method: 'POST', @@ -758,10 +770,12 @@ api.disableClasses = { * @apiName UserPurchase * @apiGroup User * -* @apiParam {string} type Type of item to purchase +* @apiParam {string} type Type of item to purchase. Must be one of: gem, gems, eggs, hatchingPotions, food, quests or gear * @apiParam {string} key Item's key * -* @apiSuccess {Object} data `items balance` +* @apiSuccess {object} data.items user.items +* @apiSuccess {number} data.balance user.balance +* @apiSuccess {string} message Success message */ api.purchase = { method: 'POST', @@ -781,10 +795,12 @@ api.purchase = { * @apiName UserPurchaseHourglass * @apiGroup User * -* @apiParam {string} type {pets|mounts}. The type of item to purchase +* @apiParam {string} type The type of item to purchase (pets or mounts) * @apiParam {string} key Ex: {MantisShrimp-Base}. The key for the mount/pet * -* @apiSuccess {Object} data `items purchased.plan.consecutive` +* @apiSuccess {object} data.items user.items +* @apiSuccess {object} data.purchasedPlanConsecutive user.purchased.plan.consecutive +* @apiSuccess {string} message Success message */ api.userPurchaseHourglass = { method: 'POST', @@ -806,7 +822,9 @@ api.userPurchaseHourglass = { * * @apiParam {string} cardType Type of card to read * -* @apiSuccess {Object} data `items.special flags.cardReceived` +* @apiSuccess {object} data.specialItems user.items.special +* @apiSuccess {boolean} data.cardReceived user.flags.cardReceived +* @apiSuccess {string} message Success message */ api.readCard = { method: 'POST', @@ -826,7 +844,8 @@ api.readCard = { * @apiName UserOpenMysteryItem * @apiGroup User * -* @apiSuccess {Object} data `user.items.gear.owned` +* @apiSuccess {Object} data user.items.gear.owned +* @apiSuccess {string} message Success message */ api.userOpenMysteryItem = { method: 'POST', @@ -840,13 +859,17 @@ api.userOpenMysteryItem = { }, }; -/** - * @api {post} /api/v3/user/webhook - * @apiVersion 3.0.0 - * @apiName UserAddWebhook - * @apiGroup User - * @apiSuccess {Object} webhook The created webhook - **/ +/* +* @api {post} /api/v3/user/webhook +* @apiVersion 3.0.0 +* @apiName UserAddWebhook +* @apiGroup User +* +* @apiParam {string} url Body parameter - The webhook's urò +* @apiParam {boolean} enabled Body parameter - If the webhook should be enabled +* +* @apiSuccess {Object} data The created webhook +*/ api.addWebhook = { method: 'POST', middlewares: [authWithHeaders()], @@ -859,13 +882,18 @@ api.addWebhook = { }, }; -/** - * @api {put} /api/v3/user/webhook/:id - * @apiVersion 3.0.0 - * @apiName UserUpdateWebhook - * @apiGroup User - * @apiSuccess {Object} webhook The updated webhook - **/ +/* +* @api {put} /api/v3/user/webhook/:id +* @apiVersion 3.0.0 +* @apiName UserUpdateWebhook +* @apiGroup User +* +* @apiParam {UUID} id The id of the webhook to update +* @apiParam {string} url Body parameter - The webhook's urò +* @apiParam {boolean} enabled Body parameter - If the webhook should be enabled +* +* @apiSuccess {Object} data The updated webhook +*/ api.updateWebhook = { method: 'PUT', middlewares: [authWithHeaders()], @@ -878,13 +906,16 @@ api.updateWebhook = { }, }; -/** - * @api {delete} /api/v3/user/webhook/:id - * @apiVersion 3.0.0 - * @apiName UserDeleteWebhook - * @apiGroup User - * @apiSuccess {Object} webhooks The user webhooks - **/ +/* +* @api {delete} /api/v3/user/webhook/:id +* @apiVersion 3.0.0 +* @apiName UserDeleteWebhook +* @apiGroup User +* +* @apiParam {UUID} id The id of the webhook to delete +* +* @apiSuccess {Object} data The user webhooks +*/ api.deleteWebhook = { method: 'DELETE', middlewares: [authWithHeaders()], @@ -903,7 +934,8 @@ api.deleteWebhook = { * @apiName UserReleasePets * @apiGroup User * -* @apiSuccess {Object} data `user.items.pets` +* @apiSuccess {Object} data.items `user.items.pets` +* @apiSuccess {string} message Success message */ api.userReleasePets = { method: 'POST', @@ -922,8 +954,11 @@ api.userReleasePets = { * @apiVersion 3.0.0 * @apiName UserReleaseBoth * @apiGroup User -* -* @apiSuccess {Object} data `user.items.gear.owned` + +* @apiSuccess {Object} data.achievements +* @apiSuccess {Object} data.items +* @apiSuccess {number} data.balance +* @apiSuccess {string} message Success message */ api.userReleaseBoth = { method: 'POST', @@ -943,7 +978,8 @@ api.userReleaseBoth = { * @apiName UserReleaseMounts * @apiGroup User * -* @apiSuccess {Object} data `mounts` +* @apiSuccess {Object} data user.items.mounts +* @apiSuccess {string} message Success message */ api.userReleaseMounts = { method: 'POST', @@ -958,12 +994,17 @@ api.userReleaseMounts = { }; /* -* @api {post} /api/v3/user/sell/:type/:key Sells user's items. +* @api {post} /api/v3/user/sell/:type/:key Sells a gold item owned by the user. * @apiVersion 3.0.0 * @apiName UserSell * @apiGroup User * -* @apiSuccess {Object} data `stats items` +* @apiParam {string} type The type of item to sell. Acceptable types are eggs, hatchingPotions, food +* @apiParam {string} key The key of the item +* +* @apiSuccess {Object} data.stats +* @apiSuccess {Object} data.items +* @apiSuccess {string} message Success message */ api.userSell = { method: 'POST', @@ -983,7 +1024,12 @@ api.userSell = { * @apiName UserUnlock * @apiGroup User * -* @apiSuccess {Object} data `purchased preferences items` +* @apiParam {string} path Query parameter. The path to unlock +* +* @apiSuccess {Object} data.purchased +* @apiSuccess {Object} data.items` +* @apiSuccess {Object} data.preferences` +* @apiSuccess {string} message` */ api.userUnlock = { method: 'POST', @@ -1003,7 +1049,8 @@ api.userUnlock = { * @apiName UserRevive * @apiGroup User * -* @apiSuccess {Object} data `user.items` +* @apiSuccess {Object} data user.items +* @apiSuccess {string} message Success message */ api.userRevive = { method: 'POST', @@ -1023,7 +1070,9 @@ api.userRevive = { * @apiName UserRebirth * @apiGroup User * -* @apiSuccess {Object} data `user` +* @apiSuccess {Object} data.userr +* @apiSuccess {array} data.tasks User's modified tasks (no rewards) +* @apiSuccess {string} message Success message */ api.userRebirth = { method: 'POST', @@ -1047,11 +1096,14 @@ api.userRebirth = { }; /** - * @api {post} /api/v3/user/block/:uuid blocks and unblocks a user + * @api {post} /api/v3/user/block/:uuid Blocks and unblocks a user * @apiVersion 3.0.0 * @apiName BlockUser * @apiGroup User - * @apiSuccess user.inbox.blocks + * + * @apiParam {UUID} uuid The uuid of the user to block / unblock + * + * @apiSuccess {array} data user.inbox.blocks **/ api.blockUser = { method: 'POST', @@ -1066,11 +1118,14 @@ api.blockUser = { }; /** - * @api {delete} /api/v3/user/messages/:id delete this message + * @api {delete} /api/v3/user/messages/:id Delete a message * @apiVersion 3.0.0 * @apiName deleteMessage * @apiGroup User - * @apiSuccess user.inbox.messages + * + * @apiParam {UUID} id The id of the message to delete + * + * @apiSuccess {object} data user.inbox.messages **/ api.deleteMessage = { method: 'DELETE', @@ -1085,11 +1140,12 @@ api.deleteMessage = { }; /** - * @api {delete} /api/v3/user/messages delete all messages + * @api {delete} /api/v3/user/messages Delete all messages * @apiVersion 3.0.0 * @apiName clearMessages * @apiGroup User - * @apiSuccess user.inbox.messages + * + * @apiSuccess {object} data user.inbox.messages **/ api.clearMessages = { method: 'DELETE', @@ -1109,7 +1165,9 @@ api.clearMessages = { * @apiName UserReroll * @apiGroup User * -* @apiSuccess {Object} data `user` +* @apiSuccess {Object} data.user +* @apiSuccess {Object} data.tasks User's modified tasks (no rewards) +* @apiSuccess {Object} message Success message */ api.userReroll = { method: 'POST', @@ -1139,7 +1197,11 @@ api.userReroll = { * @apiName UserAddPushDevice * @apiGroup User * -* @apiSuccess {Object} data `pushDevices` +* @apiParam {string} regId The id of the push device +* @apiParam {string} uuid The type of push device +* +* @apiSuccess {Object} data List of push devices +* @apiSuccess {string} message Success message */ api.userAddPushDevice = { method: 'POST', @@ -1161,7 +1223,9 @@ api.userAddPushDevice = { * @apiName UserReset * @apiGroup User * -* @apiSuccess {Object} data `user` +* @apiSuccess {Object} data.user +* @apiSuccess {Object} data.tasksToRemove IDs of removed tasks +* @apiSuccess {string} message Success message */ api.userReset = { method: 'POST', From c5aa15cf479e4de2b41751842173405c6160714e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 23 Apr 2016 03:08:03 +0200 Subject: [PATCH 709/976] v3: fix ability to join public guild you were already a member of --- .../integration/groups/POST-groups_groupId_join.test.js | 9 +++++++++ website/src/controllers/api-v3/groups.js | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index 60a44bc3f3..f47a734e89 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -44,6 +44,15 @@ describe('POST /group/:groupId/join', () => { expect(res.leader.profile.name).to.eql(user.profile.name); }); + it('returns an error is user was already a member', async () => { + await joiningUser.post(`/groups/${publicGuild._id}/join`); + await expect(joiningUser.post(`/groups/${publicGuild._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInGroup'), + }); + }); + it('promotes joining member in a public empty guild to leader', async () => { await user.post(`/groups/${publicGuild._id}/leave`); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 60df7d1d15..13f3179db0 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -251,7 +251,12 @@ api.joinGroup = { } } - if (isUserInvited && group.type === 'guild') user.guilds.push(group._id); // Add group to user's guilds + if (isUserInvited && group.type === 'guild') { + if (user.guilds.indexOf(group._id) !== -1) { // if user is already a member (party is checked previously) + throw new NotAuthorized(res.t('userAlreadyInGroup')); + } + user.guilds.push(group._id); // Add group to user's guilds + } if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite')); if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader From 7dab7939cceaf26df140120642ce274695e5e62c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 23 Apr 2016 03:33:00 +0200 Subject: [PATCH 710/976] v3: fix tavern during tests --- test/helpers/mongo.js | 43 ++++++++++++++++++------ website/src/controllers/api-v3/groups.js | 4 +-- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/test/helpers/mongo.js b/test/helpers/mongo.js index 463f375e8e..6b51ff2af0 100644 --- a/test/helpers/mongo.js +++ b/test/helpers/mongo.js @@ -25,23 +25,46 @@ export async function resetHabiticaDB () { mongoose.connection.db.dropDatabase((dbErr) => { if (dbErr) return reject(dbErr); let groups = mongoose.connection.db.collection('groups'); + let users = mongoose.connection.db.collection('users'); - // For some mysterious reason after a dropDatabase there can still be a group... - groups.count({_id: TAVERN_ID}, (err, count) => { + users.count({_id: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0'}, (err, count) => { if (err) return reject(err); if (count > 0) return resolve(); - groups.insertOne({ - _id: TAVERN_ID, - chat: [], - leader: '9', - name: 'HabitRPG', - type: 'guild', - privacy: 'public', + // create the leader for the tavern + users.insertOne({ + _id: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', + apiToken: TAVERN_ID, + auth: { + local: { + username: 'username', + lowerCaseUsername: 'username', + email: 'username@email.com', + salt: 'salt', + hashed_password: 'hashed_password', // eslint-disable-line camelcase + }, + }, }, (insertErr) => { if (insertErr) return reject(insertErr); - resolve(); + // For some mysterious reason after a dropDatabase there can still be a group... + groups.count({_id: TAVERN_ID}, (err2, count2) => { + if (err2) return reject(err2); + if (count2 > 0) return resolve(); + + groups.insertOne({ + _id: TAVERN_ID, + chat: [], + leader: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', // Siena Leslie + name: 'HabitRPG', + type: 'guild', + privacy: 'public', + }, (insertErr2) => { + if (insertErr2) return reject(insertErr2); + + resolve(); + }); + }); }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 13f3179db0..3f4a7aabb4 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -207,7 +207,7 @@ api.joinGroup = { let user = res.locals.user; let inviter; - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party' let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -302,7 +302,7 @@ api.rejectGroupInvite = { async handler (req, res) { let user = res.locals.user; - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party' let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; From c608d03e359a1da3ebcb7d21218a61382e091e1b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 23 Apr 2016 15:00:55 +0200 Subject: [PATCH 711/976] v3: start writing migrations --- .eslintignore | 7 +- migrations/api_v3/old_models/challenge.js | 122 ++++ migrations/api_v3/old_models/task.js | 115 ++++ migrations/api_v3/old_models/user.js | 700 ++++++++++++++++++++++ migrations/api_v3/users.js | 185 ++++++ 5 files changed, 1128 insertions(+), 1 deletion(-) create mode 100644 migrations/api_v3/old_models/challenge.js create mode 100644 migrations/api_v3/old_models/task.js create mode 100644 migrations/api_v3/old_models/user.js create mode 100644 migrations/api_v3/users.js diff --git a/.eslintignore b/.eslintignore index 93a77392f1..a623e23548 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,10 +3,15 @@ common/dist/ common/transpiled-babel/ coverage/ database_reports/ -migrations/ website/build/ website/transpiled-babel/ +migrations/* +!migrations/api_v3 +!migrations/api_v3 +!migrations/api_v3 +migrations/api_v3/old_models + # The files in website/public/js should be moved out and browserified website/public/ diff --git a/migrations/api_v3/old_models/challenge.js b/migrations/api_v3/old_models/challenge.js new file mode 100644 index 0000000000..7a014375c2 --- /dev/null +++ b/migrations/api_v3/old_models/challenge.js @@ -0,0 +1,122 @@ +// OLD (v2) CHALLENGE MODEL + +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var TaskSchemas = require('./task'); + +var ChallengeSchema = new Schema({ + _id: {type: String, 'default': shared.uuid}, + name: String, + shortName: String, + description: String, + official: {type: Boolean,'default':false}, + habits: [TaskSchemas.HabitSchema], + dailys: [TaskSchemas.DailySchema], + todos: [TaskSchemas.TodoSchema], + rewards: [TaskSchemas.RewardSchema], + leader: {type: String, ref: 'User'}, + group: {type: String, ref: 'Group'}, + timestamp: {type: Date, 'default': Date.now}, + members: [{type: String, ref: 'User'}], + memberCount: {type: Number, 'default': 0}, + prize: {type: Number, 'default': 0} +}, {collection: 'challenges'}); + +ChallengeSchema.virtual('tasks').get(function () { + var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); + var tasks = _.object(_.pluck(tasks,'id'), tasks); + return tasks; +}); + +ChallengeSchema.methods.toJSON = function(){ + var doc = this.toObject(); + doc._isMember = this._isMember; + return doc; +} + +// -------------- +// Syncing logic +// -------------- + +function syncableAttrs(task) { + var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument + // only sync/compare important attrs + var omitAttrs = 'challenge history tags completed streak notes'.split(' '); + if (t.type != 'reward') omitAttrs.push('value'); + return _.omit(t, omitAttrs); +} + +/** + * Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers + */ +function comparableData(obj) { + return JSON.stringify( + _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) + .sortBy('id') // we don't want to update if they're sort-order is different + .transform(function(result, task){ + result.push(syncableAttrs(task)); + }) + .value()) +} + +ChallengeSchema.methods.isOutdated = function(newData) { + return comparableData(this) !== comparableData(newData); +} + +/** + * Syncs all new tasks, deleted tasks, etc to the user object + * @param user + * @return nothing, user is modified directly. REMEMBER to save the user! + */ +ChallengeSchema.methods.syncToUser = function(user, cb) { + if (!user) return; + var self = this; + self.shortName = self.shortName || self.name; + + // Add challenge to user.challenges + if (!_.contains(user.challenges, self._id)) { + user.challenges.push(self._id); + } + + // Sync tags + var tags = user.tags || []; + var i = _.findIndex(tags, {id: self._id}) + if (~i) { + if (tags[i].name !== self.shortName) { + // update the name - it's been changed since + user.tags[i].name = self.shortName; + } + } else { + user.tags.push({ + id: self._id, + name: self.shortName, + challenge: true + }); + } + + // Sync new tasks and updated tasks + _.each(self.tasks, function(task){ + var list = user[task.type+'s']; + var userTask = user.tasks[task.id] || (list.push(syncableAttrs(task)), list[list.length-1]); + if (!userTask.notes) userTask.notes = task.notes; // don't override the notes, but provide it if not provided + userTask.challenge = {id:self._id}; + userTask.tags = userTask.tags || {}; + userTask.tags[self._id] = true; + _.merge(userTask, syncableAttrs(task)); + }) + + // Flag deleted tasks as "broken" + _.each(user.tasks, function(task){ + if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) { + task.challenge.broken = 'TASK_DELETED'; + } + }) + + user.save(cb); +}; + + +module.exports.schema = ChallengeSchema; +module.exports.model = mongoose.model("ChallengeOld", ChallengeSchema); diff --git a/migrations/api_v3/old_models/task.js b/migrations/api_v3/old_models/task.js new file mode 100644 index 0000000000..63d24cc743 --- /dev/null +++ b/migrations/api_v3/old_models/task.js @@ -0,0 +1,115 @@ +// OLD (v2) TASK MODEL + +// User.js +// ======= +// Defines the user data model (schema) for use via the API. + +// Dependencies +// ------------ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var moment = require('moment'); + +// Task Schema +// ----------- + +var TaskSchema = { + //_id:{type: String,'default': helpers.uuid}, + id: {type: String,'default': shared.uuid}, + dateCreated: {type:Date, 'default':Date.now}, + text: String, + notes: {type: String, 'default': ''}, + tags: {type: Schema.Types.Mixed, 'default': {}}, //{ "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, + value: {type: Number, 'default': 0}, // redness + priority: {type: Number, 'default': '1'}, + attribute: {type: String, 'default': "str", enum: ['str','con','int','per']}, + challenge: { + id: {type: 'String', ref:'Challenge'}, + broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED + winner: String // user.profile.name + // group: {type: 'Strign', ref: 'Group'} // if we restore this, rename `id` above to `challenge` + }, + reminders: [{ + id: {type:String,'default':shared.uuid}, + startDate: Date, + time: Date + }] +}; + +var HabitSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'habit'}, + history: Array, // [{date:Date, value:Number}], // this causes major performance problems + up: {type: Boolean, 'default': true}, + down: {type: Boolean, 'default': true} + }, TaskSchema) + , { _id: false, minimize:false } +); + +var collapseChecklist = {type:Boolean, 'default':false}; +var checklist = [{ + completed:{type:Boolean,'default':false}, + text: String, + _id:false, + id: {type:String,'default':shared.uuid} +}]; + +var DailySchema = new Schema( + _.defaults({ + type: {type: String, 'default': 'daily'}, + frequency: {type: String, 'default': 'weekly', enum: ['daily', 'weekly']}, + everyX: {type: Number, 'default': 1}, // e.g. once every X weeks + startDate: {type: Date, 'default': moment().startOf('day').toDate()}, + history: Array, + completed: {type: Boolean, 'default': false}, + repeat: { // used only for 'weekly' frequency, + m: {type: Boolean, 'default': true}, + t: {type: Boolean, 'default': true}, + w: {type: Boolean, 'default': true}, + th: {type: Boolean, 'default': true}, + f: {type: Boolean, 'default': true}, + s: {type: Boolean, 'default': true}, + su: {type: Boolean, 'default': true} + }, + collapseChecklist:collapseChecklist, + checklist:checklist, + streak: {type: Number, 'default': 0} + }, TaskSchema) + , { _id: false, minimize:false } +) + +var TodoSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'todo'}, + completed: {type: Boolean, 'default': false}, + dateCompleted: Date, + date: String, // due date for todos // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date + collapseChecklist:collapseChecklist, + checklist:checklist + }, TaskSchema) + , { _id: false, minimize:false } +); + +var RewardSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'reward'} + }, TaskSchema) + , { _id: false, minimize:false } +); + +/** + * Workaround for bug when _id & id were out of sync, we can remove this after challenges has been running for a while + */ +//_.each([HabitSchema, DailySchema, TodoSchema, RewardSchema], function(schema){ +// schema.post('init', function(doc){ +// if (!doc.id && doc._id) doc.id = doc._id; +// }) +//}) + +module.exports.TaskSchema = TaskSchema; +module.exports.HabitSchema = HabitSchema; +module.exports.DailySchema = DailySchema; +module.exports.TodoSchema = TodoSchema; +module.exports.RewardSchema = RewardSchema; diff --git a/migrations/api_v3/old_models/user.js b/migrations/api_v3/old_models/user.js new file mode 100644 index 0000000000..b8fb7a7ed6 --- /dev/null +++ b/migrations/api_v3/old_models/user.js @@ -0,0 +1,700 @@ +// OLD (v2) USER MODEL + +// User.js +// ======= +// Defines the user data model (schema) for use via the API. + +// Dependencies +// ------------ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var TaskSchemas = require('./task'); +var Challenge = require('./challenge').model; +var moment = require('moment'); + +// User Schema +// ----------- + +var UserSchema = new Schema({ + // ### UUID and API Token + _id: { + type: String, + 'default': shared.uuid + }, + apiToken: { + type: String, + 'default': shared.uuid + }, + + // ### Mongoose Update Object + // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which + // have been updated (http://goo.gl/gQLz41), but we want *every* update + _v: { type: Number, 'default': 0 }, + achievements: { + originalUser: Boolean, + habitSurveys: Number, + ultimateGearSets: Schema.Types.Mixed, + beastMaster: Boolean, + beastMasterCount: Number, + mountMaster: Boolean, + mountMasterCount: Number, + triadBingo: Boolean, + triadBingoCount: Number, + veteran: Boolean, + snowball: Number, + spookDust: Number, + shinySeed: Number, + seafoam: Number, + streak: Number, + challenges: Array, + quests: Schema.Types.Mixed, + rebirths: Number, + rebirthLevel: Number, + perfect: Number, + habitBirthdays: Number, + valentine: Number, + costumeContest: Boolean, // Superseded by costumeContests + nye: Number, + habiticaDays: Number, + greeting: Number, + thankyou: Number, + costumeContests: Number, + birthday: Number, + partyUp: Boolean, + partyOn: Boolean + }, + auth: { + blocked: Boolean, + facebook: Schema.Types.Mixed, + local: { + email: String, + hashed_password: String, + salt: String, + username: String, + lowerCaseUsername: String // Store a lowercase version of username to check for duplicates + }, + timestamps: { + created: {type: Date,'default': Date.now}, + loggedin: {type: Date,'default': Date.now} + } + }, + + backer: { + tier: Number, + npc: String, + tokensApplied: Boolean + }, + + contributor: { + level: Number, // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801 + admin: Boolean, + sudo: Boolean, + text: String, // Artisan, Friend, Blacksmith, etc + contributions: String, // a markdown textarea to list their contributions + links + critical: String + }, + + balance: {type: Number, 'default':0}, + filters: {type: Schema.Types.Mixed, 'default': {}}, + + purchased: { + ads: {type: Boolean, 'default': false}, + skin: {type: Schema.Types.Mixed, 'default': {}}, // eg, {skeleton: true, pumpkin: true, eb052b: true} + hair: {type: Schema.Types.Mixed, 'default': {}}, + shirt: {type: Schema.Types.Mixed, 'default': {}}, + background: {type: Schema.Types.Mixed, 'default': {}}, + txnCount: {type: Number, 'default':0}, + mobileChat: Boolean, + plan: { + planId: String, + paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} + customerId: String, // Billing Agreement Id in case of Amazon Payments + dateCreated: Date, + dateTerminated: Date, + dateUpdated: Date, + extraMonths: {type:Number, 'default':0}, + gemsBought: {type: Number, 'default': 0}, + mysteryItems: {type: Array, 'default': []}, + lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date + consecutive: { + count: {type:Number, 'default':0}, + offset: {type:Number, 'default':0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 + gemCapExtra: {type:Number, 'default':0}, + trinkets: {type:Number, 'default':0} + } + } + }, + + flags: { + customizationsNotification: {type: Boolean, 'default': false}, + showTour: {type: Boolean, 'default': true}, + tour: { + // -1 indicates "uninitiated", -2 means "complete", any other number is the current tour step (0-index) + intro: {type: Number, 'default': -1}, + classes: {type: Number, 'default': -1}, + stats: {type: Number, 'default': -1}, + tavern: {type: Number, 'default': -1}, + party: {type: Number, 'default': -1}, + guilds: {type: Number, 'default': -1}, + challenges: {type: Number, 'default': -1}, + market: {type: Number, 'default': -1}, + pets: {type: Number, 'default': -1}, + mounts: {type: Number, 'default': -1}, + hall: {type: Number, 'default': -1}, + equipment: {type: Number, 'default': -1} + }, + tutorial: { + common: { + habits: {type: Boolean, 'default': false}, + dailies: {type: Boolean, 'default': false}, + todos: {type: Boolean, 'default': false}, + rewards: {type: Boolean, 'default': false}, + party: {type: Boolean, 'default': false}, + pets: {type: Boolean, 'default': false}, + gems: {type: Boolean, 'default': false}, + skills: {type: Boolean, 'default': false}, + classes: {type: Boolean, 'default': false}, + tavern: {type: Boolean, 'default': false}, + equipment: {type: Boolean, 'default': false}, + items: {type: Boolean, 'default': false}, + }, + ios: { + addTask: {type: Boolean, 'default': false}, + editTask: {type: Boolean, 'default': false}, + deleteTask: {type: Boolean, 'default': false}, + filterTask: {type: Boolean, 'default': false}, + groupPets: {type: Boolean, 'default': false}, + inviteParty: {type: Boolean, 'default': false}, + } + }, + dropsEnabled: {type: Boolean, 'default': false}, + itemsEnabled: {type: Boolean, 'default': false}, + newStuff: {type: Boolean, 'default': false}, + rewrite: {type: Boolean, 'default': true}, + contributor: Boolean, + classSelected: {type: Boolean, 'default': false}, + mathUpdates: Boolean, + rebirthEnabled: {type: Boolean, 'default': false}, + levelDrops: {type:Schema.Types.Mixed, 'default':{}}, + chatRevoked: Boolean, + // Used to track the status of recapture emails sent to each user, + // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user + recaptureEmailsPhase: {type: Number, 'default': 0}, + // Needed to track the tip to send inside the email + weeklyRecapEmailsPhase: {type: Number, 'default': 0}, + // Used to track when the next weekly recap should be sent + lastWeeklyRecap: {type: Date, 'default': Date.now}, + // Used to enable weekly recap emails as users login + lastWeeklyRecapDiscriminator: Boolean, + communityGuidelinesAccepted: {type: Boolean, 'default': false}, + cronCount: {type:Number, 'default':0}, + welcomed: {type: Boolean, 'default': false}, + armoireEnabled: {type: Boolean, 'default': false}, + armoireOpened: {type: Boolean, 'default': false}, + armoireEmpty: {type: Boolean, 'default': false}, + cardReceived: {type: Boolean, 'default': false}, + warnedLowHealth: {type: Boolean, 'default': false} + }, + history: { + exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined + todos: Array //[{data: Date, value: Number}] // big peformance issues if these are defined + }, + + invitations: { + guilds: {type: Array, 'default': []}, + party: Schema.Types.Mixed + }, + items: { + gear: { + owned: _.transform(shared.content.gear.flat, function(m,v,k){ + m[v.key] = {type: Boolean}; + if (v.key.match(/[armor|head|shield]_warrior_0/)) + m[v.key]['default'] = true; + }), + + equipped: { + weapon: String, + armor: {type: String, 'default': 'armor_base_0'}, + head: {type: String, 'default': 'head_base_0'}, + shield: {type: String, 'default': 'shield_base_0'}, + back: String, + headAccessory: String, + eyewear: String, + body: String + }, + costume: { + weapon: String, + armor: {type: String, 'default': 'armor_base_0'}, + head: {type: String, 'default': 'head_base_0'}, + shield: {type: String, 'default': 'shield_base_0'}, + back: String, + headAccessory: String, + eyewear: String, + body: String + } + }, + + special:{ + snowball: {type: Number, 'default': 0}, + spookDust: {type: Number, 'default': 0}, + shinySeed: {type: Number, 'default': 0}, + seafoam: {type: Number, 'default': 0}, + valentine: Number, + valentineReceived: Array, // array of strings, by sender name + nye: Number, + nyeReceived: Array, + greeting: Number, + greetingReceived: Array, + thankyou: Number, + thankyouReceived: Array, + birthday: Number, + birthdayReceived: Array + }, + + // -------------- Animals ------------------- + // Complex bit here. The result looks like: + // pets: { + // 'Wolf-Desert': 0, // 0 means does not own + // 'PandaCub-Red': 10, // Number represents "Growth Points" + // etc... + // } + pets: + _.defaults( + // First transform to a 1D eggs/potions mapping + _.transform(shared.content.pets, function(m,v,k){ m[k] = Number; }), + // Then add additional pets (quest, backer, contributor, premium) + _.transform(shared.content.questPets, function(m,v,k){ m[k] = Number; }), + _.transform(shared.content.specialPets, function(m,v,k){ m[k] = Number; }), + _.transform(shared.content.premiumPets, function(m,v,k){ m[k] = Number; }) + ), + currentPet: String, // Cactus-Desert + + // eggs: { + // 'PandaCub': 0, // 0 indicates "doesn't own" + // 'Wolf': 5 // Number indicates "stacking" + // } + eggs: _.transform(shared.content.eggs, function(m,v,k){ m[k] = Number; }), + + // hatchingPotions: { + // 'Desert': 0, // 0 indicates "doesn't own" + // 'CottonCandyBlue': 5 // Number indicates "stacking" + // } + hatchingPotions: _.transform(shared.content.hatchingPotions, function(m,v,k){ m[k] = Number; }), + + // Food: { + // 'Watermelon': 0, // 0 indicates "doesn't own" + // 'RottenMeat': 5 // Number indicates "stacking" + // } + food: _.transform(shared.content.food, function(m,v,k){ m[k] = Number; }), + + // mounts: { + // 'Wolf-Desert': true, + // 'PandaCub-Red': false, + // etc... + // } + mounts: _.defaults( + // First transform to a 1D eggs/potions mapping + _.transform(shared.content.pets, function(m,v,k){ m[k] = Boolean; }), + // Then add quest and premium pets + _.transform(shared.content.questPets, function(m,v,k){ m[k] = Boolean; }), + _.transform(shared.content.premiumPets, function(m,v,k){ m[k] = Boolean; }), + // Then add additional mounts (backer, contributor) + _.transform(shared.content.specialMounts, function(m,v,k){ m[k] = Boolean; }) + ), + currentMount: String, + + // Quests: { + // 'boss_0': 0, // 0 indicates "doesn't own" + // 'collection_honey': 5 // Number indicates "stacking" + // } + quests: _.transform(shared.content.quests, function(m,v,k){ m[k] = Number; }), + + lastDrop: { + date: {type: Date, 'default': Date.now}, + count: {type: Number, 'default': 0} + } + }, + + lastCron: {type: Date, 'default': Date.now}, + + // {GROUP_ID: Boolean}, represents whether they have unseen chat messages + newMessages: {type: Schema.Types.Mixed, 'default': {}}, + + party: { + // id // FIXME can we use a populated doc instead of fetching party separate from user? + order: {type:String, 'default':'level'}, + orderAscending: {type:String, 'default':'ascending'}, + quest: { + key: String, + progress: { + up: {type: Number, 'default': 0}, + down: {type: Number, 'default': 0}, + collect: {type: Schema.Types.Mixed, 'default': {}} // {feather:1, ingot:2} + }, + completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser + RSVPNeeded: {type: Boolean, 'default': false} // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled + } + }, + preferences: { + dayStart: {type:Number, 'default': 0, min: 0, max: 23}, + size: {type:String, enum: ['broad','slim'], 'default': 'slim'}, + hair: { + color: {type: String, 'default': 'red'}, + base: {type: Number, 'default': 3}, + bangs: {type: Number, 'default': 1}, + beard: {type: Number, 'default': 0}, + mustache: {type: Number, 'default': 0}, + flower: {type: Number, 'default': 1} + }, + chair: {type: String, 'default': 'none'}, + hideHeader: {type:Boolean, 'default':false}, + skin: {type:String, 'default':'915533'}, + shirt: {type: String, 'default': 'blue'}, + timezoneOffset: {type: Number, 'default': 0}, + timezoneOffsetAtLastCron: Number, + sound: {type:String, 'default':'off', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme']}, + language: String, + automaticAllocation: Boolean, + allocationMode: {type:String, enum: ['flat','classbased','taskbased'], 'default': 'flat'}, + autoEquip: {type: Boolean, 'default': true}, + costume: Boolean, + dateFormat: {type: String, enum:['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], 'default': 'MM/dd/yyyy'}, + sleep: {type: Boolean, 'default': false}, + stickyHeader: {type: Boolean, 'default': true}, + disableClasses: {type: Boolean, 'default': false}, + newTaskEdit: {type: Boolean, 'default': false}, + dailyDueDefaultView: {type: Boolean, 'default': false}, + tagsCollapsed: {type: Boolean, 'default': false}, + advancedCollapsed: {type: Boolean, 'default': false}, + toolbarCollapsed: {type:Boolean, 'default':false}, + reverseChatOrder: {type:Boolean, 'default':false}, + background: String, + displayInviteToPartyWhenPartyIs1: { type:Boolean, 'default':true}, + webhooks: {type: Schema.Types.Mixed, 'default': {}}, + // For this fields make sure to use strict comparison when searching for falsey values (=== false) + // As users who didn't login after these were introduced may have them undefined/null + emailNotifications: { + unsubscribeFromAll: {type: Boolean, 'default': false}, + newPM: {type: Boolean, 'default': true}, + kickedGroup: {type: Boolean, 'default': true}, + wonChallenge: {type: Boolean, 'default': true}, + giftedGems: {type: Boolean, 'default': true}, + giftedSubscription: {type: Boolean, 'default': true}, + invitedParty: {type: Boolean, 'default': true}, + invitedGuild: {type: Boolean, 'default': true}, + questStarted: {type: Boolean, 'default': true}, + invitedQuest: {type: Boolean, 'default': true}, + //remindersToLogin: {type: Boolean, 'default': true}, + // Those importantAnnouncements are in fact the recapture emails + importantAnnouncements: {type: Boolean, 'default': true}, + weeklyRecaps: {type: Boolean, 'default': true} + }, + suppressModals: { + levelUp: {type: Boolean, 'default': false}, + hatchPet: {type: Boolean, 'default': false}, + raisePet: {type: Boolean, 'default': false}, + streak: {type: Boolean, 'default': false} + }, + improvementCategories: { + type: Array, + validate: (categories) => { + const validCategories = ['work', 'exercise', 'healthWellness', 'school', 'teams', 'chores', 'creativity']; + let isValidCategory = categories.every(category => validCategories.indexOf(category) !== -1); + return isValidCategory; + }} + }, + profile: { + blurb: String, + imageUrl: String, + name: String + }, + stats: { + hp: {type: Number, 'default': shared.maxHealth}, + mp: {type: Number, 'default': 10}, + exp: {type: Number, 'default': 0}, + gp: {type: Number, 'default': 0}, + lvl: {type: Number, 'default': 1}, + + // Class System + 'class': {type: String, enum: ['warrior','rogue','wizard','healer'], 'default': 'warrior'}, + points: {type: Number, 'default': 0}, + str: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0}, + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + buffs: { + str: {type: Number, 'default': 0}, + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0}, + stealth: {type: Number, 'default': 0}, + streaks: {type: Boolean, 'default': false}, + snowball: {type: Boolean, 'default': false}, + spookDust: {type: Boolean, 'default': false}, + shinySeed: {type: Boolean, 'default': false}, + seafoam: {type: Boolean, 'default': false} + }, + training: { + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + str: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0} + } + }, + + tags: {type: [{ + _id: false, + id: { type: String, 'default': shared.uuid }, + name: String, + challenge: String + }]}, + + challenges: [{type: 'String', ref:'Challenge'}], + + inbox: { + newMessages: {type:Number, 'default':0}, + blocks: {type:Array, 'default':[]}, + messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist + optOut: {type:Boolean, 'default':false} + }, + + habits: {type:[TaskSchemas.HabitSchema]}, + dailys: {type:[TaskSchemas.DailySchema]}, + todos: {type:[TaskSchemas.TodoSchema]}, + rewards: {type:[TaskSchemas.RewardSchema]}, + + extra: Schema.Types.Mixed, + + pushDevices: {type: [{ + regId: {type: String}, + type: {type: String} + }],'default': []} + +}, { + collection: 'users', + strict: true, + minimize: false // So empty objects are returned +}); + +UserSchema.methods.deleteTask = function(tid) { + this.ops.deleteTask({params:{id:tid}},function(){}); // TODO remove this whole method, since it just proxies, and change all references to this method +} + +UserSchema.methods.toJSON = function() { + var doc = this.toObject(); + doc.id = doc._id; + + // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? + doc.filters = {}; + doc._tmp = this._tmp; // be sure to send down drop notifs + + return doc; +}; + +//UserSchema.virtual('tasks').get(function () { +// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); +// var tasks = _.object(_.pluck(tasks,'id'), tasks); +// return tasks; +//}); + +UserSchema.post('init', function(doc){ + shared.wrap(doc); +}) + +UserSchema.pre('save', function(next) { + + // Populate new users with default content + if (this.isNew){ + _populateDefaultsForNewUser(this); + } + + //this.markModified('tasks'); + if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { + this.preferences.dayStart = 0; + } + + if (!this.profile.name) { + var fb = this.auth.facebook; + this.profile.name = + (this.auth.local && this.auth.local.username) || + (fb && (fb.displayName || fb.name || fb.username || (fb.first_name && fb.first_name + ' ' + fb.last_name))) || + 'Anonymous'; + } + + // Determines if Beast Master should be awarded + var beastMasterProgress = shared.count.beastMasterProgress(this.items.pets); + if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) { + this.achievements.beastMaster = true; + } + + // Determines if Mount Master should be awarded + var mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts); + + if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) { + this.achievements.mountMaster = true; + } + + // Determines if Triad Bingo should be awarded + + var dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets); + var qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90; + + if (qualifiesForTriad || this.achievements.triadBingoCount > 0) { + this.achievements.triadBingo = true; + } + + // Enable weekly recap emails for old users who sign in + if(this.flags.lastWeeklyRecapDiscriminator){ + // Enable weekly recap emails in 24 hours + this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate(); + // Unset the field so this is run only once + this.flags.lastWeeklyRecapDiscriminator = undefined; + } + + // EXAMPLE CODE for allowing all existing and new players to be + // automatically granted an item during a certain time period: + // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) + // this.items.pets['JackOLantern-Base'] = 5; + + //our own version incrementer + if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; + this._v++; + + next(); +}); + +UserSchema.methods.unlink = function(options, cb) { + var cid = options.cid, keep = options.keep, tid = options.tid; + if (!cid) { + return cb("Could not remove challenge tasks. Please delete them manually."); + } + var self = this; + switch (keep) { + case 'keep': + self.tasks[tid].challenge = {}; + break; + case 'remove': + self.deleteTask(tid); + break; + case 'keep-all': + _.each(self.tasks, function(t){ + if (t.challenge && t.challenge.id == cid) { + t.challenge = {}; + } + }); + break; + case 'remove-all': + _.each(self.tasks, function(t){ + if (t.challenge && t.challenge.id == cid) { + self.deleteTask(t.id); + } + }) + break; + } + self.markModified('habits'); + self.markModified('dailys'); + self.markModified('todos'); + self.markModified('rewards'); + self.save(cb); +} + +function _populateDefaultsForNewUser(user) { + var taskTypes; + + if (user.registeredThrough === "habitica-web" || user.registeredThrough === "habitica-android") { + taskTypes = ['habits', 'dailys', 'todos', 'rewards', 'tags']; + + var tutorialCommonSections = [ + 'habits', + 'dailies', + 'todos', + 'rewards', + 'party', + 'pets', + 'gems', + 'skills', + 'classes', + 'tavern', + 'equipment', + 'items', + 'inviteParty', + ]; + + _.each(tutorialCommonSections, function(section) { + user.flags.tutorial.common[section] = true; + }); + } else { + taskTypes = ['todos', 'tags'] + + user.flags.showTour = false; + + var tourSections = [ + 'showTour', + 'intro', + 'classes', + 'stats', + 'tavern', + 'party', + 'guilds', + 'challenges', + 'market', + 'pets', + 'mounts', + 'hall', + 'equipment', + ]; + + _.each(tourSections, function(section) { + user.flags.tour[section] = -2; + }); + } + + _populateDefaultTasks(user, taskTypes); +} + +function _populateDefaultTasks (user, taskTypes) { + _.each(taskTypes, function(taskType){ + user[taskType] = _.map(shared.content.userDefaults[taskType], function(task){ + var newTask = _.cloneDeep(task); + + // Render task's text and notes in user's language + if(taskType === 'tags'){ + // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here + newTask.id = shared.uuid(); + newTask.name = newTask.name(user.preferences.language); + }else{ + newTask.text = newTask.text(user.preferences.language); + if(newTask.notes) { + newTask.notes = newTask.notes(user.preferences.language); + } + + if(newTask.checklist){ + newTask.checklist = _.map(newTask.checklist, function(checklistItem){ + checklistItem.text = checklistItem.text(user.preferences.language); + return checklistItem; + }); + } + } + + return newTask; + }); + }); +} + +module.exports.schema = UserSchema; +module.exports.model = mongoose.model("UserOld", UserSchema); +// Initially export an empty object so external requires will get +// the right object by reference when it's defined later +// Otherwise it would remain undefined if requested before the query executes +module.exports.mods = []; + +mongoose.model("User") + .find({'contributor.admin':true}) + .sort('-contributor.level -backer.npc profile.name') + .select('profile contributor backer') + .exec(function(err,mods){ + // Using push to maintain the reference to mods + module.exports.mods.push.apply(module.exports.mods, mods); +}); diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js new file mode 100644 index 0000000000..782c79bf89 --- /dev/null +++ b/migrations/api_v3/users.js @@ -0,0 +1,185 @@ +/* eslint-disable no-console, no-unused-vars */ + +// Migrate users collection to new schema +// This should run AFTER challenges migration + +// This code makes heavy use of ES6 / 7 features and should be compiled / run with BabelJS. + +// It requires two environment variables: MONGODB_OLD and MONGODB_NEW + +console.log('Starting migrations/api_v3/users.js.'); + +import nconf from 'nconf'; +import mongoose from 'mongoose'; +import MongoDB from 'mongodb'; +import Q from 'q'; + +const MongoClient = MongoDB.MongoClient; + +// Initialize configuration +import setupNconf from '../../website/src/libs/api-v3/setupNconf'; +setupNconf(); + +const MONGODB_OLD = nconf.get('MONGODB_OLD'); +const MONGODB_NEW = nconf.get('MONGODB_NEW'); + +// Initialize mongoose and connect to the database containing the old data +mongoose.Promise = Q.Promise; + +const mongooseDbInstance = mongoose.connect(MONGODB_OLD, { + replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, + server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, +}, (err) => { + if (err) throw err; + console.log(`Connected with Mongoose to ${MONGODB_OLD}.`); +}); + +// Load old and new models +const OldUserModel = require('./old_models/user').model; +import { model as NewUser } from '../../website/src/models/user'; +import * as Tasks from '../../website/src/models/task'; + +// To be defined later when MongoClient connects +let mongoDbInstance; + +async function processUser (_id) { + let oldUser = await OldUserModel + .findById(_id) + .lean() + .exec(); + + console.log(`Processing ${oldUser._id}.`); + + let oldTasks = oldUser.habits.concat(oldUser.dailys).concat(oldUser.rewards).concat(oldUser.todos); + oldUser.habits = oldUser.dailys = oldUser.rewards = oldUser.todos = undefined; + + console.log(oldUser, oldTasks); +}; + +/* + +TODO var challengeTasksChangedId = {}; +... given a user + +let processed = 0; +let batchSize = 1000; + +var db; // defined later by MongoClient +var dbNewUsers; +var dbTasks; + +var processUser = function(gt) { + var query = { + _id: {} + }; + if(gt) query._id.$gt = gt; + + console.log('Launching query', query); + + // take batchsize docs from users and process them + OldUserModel + .find(query) + .lean() // Use plain JS objects as old user data won't match the new model + .limit(batchSize) + .sort({_id: 1}) + .exec(function(err, users) { + if(err) throw err; + + console.log('Processing ' + users.length + ' users.', 'Already processed: ' + processed); + + var lastUser = null; + if(users.length === batchSize){ + lastUser = users[users.length - 1]; + } + + var tasksToSave = 0; + + // Initialize batch operation for later + var batchInsertUsers = dbNewUsers.initializeUnorderedBulkOp(); + var batchInsertTasks = dbTasks.initializeUnorderedBulkOp(); + + users.forEach(function(user){ + // user obj is a plain js object because we used .lean() + + // add tasks order arrays + user.tasksOrder = { + habits: [], + rewards: [], + todos: [], + dailys: [] + }; + + // ... convert tasks to individual models + + var tasksArr = user.dailys + .concat(user.habits) + .concat(user.todos) + .concat(user.rewards); + + // free memory? + user.dailys = user.habits = user.todos = user.rewards = undefined; + + tasksArr.forEach(function(task){ + task.userId = user._id; + + task._id = shared.uuid(); // we rely on these to be unique... hopefully! + task.legacyId = task.id; + task.id = undefined; + + task.challenge = task.challenge || {}; + if(task.challenge.id) { + // If challengeTasksChangedId[task._id] then we got on of the duplicates from the challenges migration + if (challengeTasksChangedId[task.legacyId]) { + var res = _.find(challengeTasksChangedId[task.legacyId], function(arr){ + return arr[1] === task.challenge.id; + }); + + // If res, id changed, otherwise matches the original one + task.challenge.taskId = res ? res[0] : task.legacyId; + } else { + task.challenge.taskId = task.legacyId; + } + } + + if(!task.type) console.log('Task without type ', task._id, ' user ', user._id); + + task = new TaskModel(task); // this should also fix dailies that wen to the habits array or vice-versa + user.tasksOrder[task.type + 's'].push(task._id); + tasksToSave++; + batchInsertTasks.insert(task.toObject()); + }); + + batchInsertUsers.insert((new NewUserModel(user)).toObject()); + }); + + console.log('Saving', users.length, 'users and', tasksToSave, 'tasks'); + + // Save in the background and dispatch another processUser(); + + batchInsertUsers.execute(function(err, result){ + if(err) throw err // we can't simply accept errors + console.log('Saved', result.nInserted, 'users') + }); + + batchInsertTasks.execute(function(err, result){ + if(err) throw err // we can't simply accept errors + console.log('Saved', result.nInserted, 'tasks') + }); + + processed = processed + users.length; + if(lastUser && lastUser._id){ + processUser(lastUser._id); + } else { + console.log('Done!'); + } + }); +}; +*/ + +// Connect to the database for new data +MongoClient.connect(MONGODB_NEW, (err, dbInstance) => { + if (err) throw err; + + mongoDbInstance = dbInstance; + console.log(`Connected with MongoClient to ${MONGODB_NEW}.`); +}); From 050539d8f3896eb4862eb3322a3af806a5fe1474 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Apr 2016 17:54:50 -0500 Subject: [PATCH 712/976] Added tests for cron (#7081) * Added inital cron tests * Added more subscribe tests and updated various tests for syntax and expectations --- test/api/v3/unit/libs/cron.test.js | 573 ++++++++++++++++++ .../api/v3/unit/middlewares/cronMiddleware.js | 176 ++++++ test/helpers/api-unit.helper.js | 31 + website/src/libs/api-v3/cron.js | 273 +++++++++ website/src/middlewares/api-v3/cron.js | 271 +-------- website/src/models/group.js | 1 - 6 files changed, 1058 insertions(+), 267 deletions(-) create mode 100644 test/api/v3/unit/libs/cron.test.js create mode 100644 test/api/v3/unit/middlewares/cronMiddleware.js create mode 100644 website/src/libs/api-v3/cron.js diff --git a/test/api/v3/unit/libs/cron.test.js b/test/api/v3/unit/libs/cron.test.js new file mode 100644 index 0000000000..d4a83634f3 --- /dev/null +++ b/test/api/v3/unit/libs/cron.test.js @@ -0,0 +1,573 @@ +/* eslint-disable global-require */ +import moment from 'moment'; +import { cron } from '../../../../../website/src/libs/api-v3/cron'; +import { model as User } from '../../../../../website/src/models/user'; +import * as Tasks from '../../../../../website/src/models/task'; +import { clone } from 'lodash'; +import common from '../../../../../common'; + +// const scoreTask = common.ops.scoreTask; + +describe('cron', () => { + let user; + let tasksByType = {habits: [], dailys: [], todos: [], rewards: []}; + let daysMissed = 0; + let analytics = { + track: sinon.spy(), + }; + + beforeEach(() => { + user = new User({ + auth: { + local: { + username: 'username', + lowerCaseUsername: 'username', + email: 'email@email.email', + salt: 'salt', + hashed_password: 'hashed_password', // eslint-disable-line camelcase + }, + }, + }); + + user._statsComputed = { + mp: 10, + }; + }); + + it('updates user.auth.timestamps.loggedin and lastCron', () => { + let now = new Date(); + + cron({user, tasksByType, daysMissed, analytics, now}); + + expect(user.auth.timestamps.loggedin).to.equal(now); + expect(user.lastCron).to.equal(now); + }); + + it('updates user.preferences.timezoneOffsetAtLastCron', () => { + let timezoneOffsetFromUserPrefs = 1; + + cron({user, tasksByType, daysMissed, analytics, timezoneOffsetFromUserPrefs}); + + expect(user.preferences.timezoneOffsetAtLastCron).to.equal(timezoneOffsetFromUserPrefs); + }); + + it('resets user.items.lastDrop.count', () => { + user.items.lastDrop.count = 4; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.items.lastDrop.count).to.equal(0); + }); + + it('increments user cron count', () => { + let cronCountBefore = user.flags.cronCount; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.flags.cronCount).to.be.greaterThan(cronCountBefore); + }); + + describe('end of the month perks', () => { + beforeEach(() => { + user.purchased.plan.customerId = 'subscribedId'; + user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); + }); + + it('resets plan.gemsBought on a new month', () => { + user.purchased.plan.gemsBought = 10; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.gemsBought).to.equal(0); + }); + + it('resets plan.dateUpdated on a new month', () => { + let currentMonth = moment().format('MMYYYY'); + cron({user, tasksByType, daysMissed, analytics}); + expect(moment(user.purchased.plan.dateUpdated).format('MMYYYY')).to.equal(currentMonth); + }); + + it('increments plan.consecutive.count', () => { + user.purchased.plan.consecutive.count = 0; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.count).to.equal(1); + }); + + it('decrements plan.consecutive.offset when offset is greater than 0', () => { + user.purchased.plan.consecutive.offset = 1; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.offset).to.equal(0); + }); + + it('increments plan.consecutive.trinkets when user has reached a month that is a multiple of 3', () => { + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.trinkets).to.equal(1); + }); + + it('increments plan.consecutive.gemCapExtra when user has reached a month that is a multiple of 3', () => { + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(5); + }); + + it('does not increment plan.consecutive.gemCapExtra when user has reached the gemCap limit', () => { + user.purchased.plan.consecutive.gemCapExtra = 25; + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(25); + }); + + it('does not reset plan stats if we are before the last day of the cancelled month', () => { + user.purchased.plan.dateTerminated = moment(new Date()).add({days: 1}); + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.customerId).to.exist; + }); + + it('does reset plan stats until we are after the last day of the cancelled month', () => { + user.purchased.plan.dateTerminated = moment(new Date()).subtract({days: 1}); + user.purchased.plan.consecutive.gemCapExtra = 20; + user.purchased.plan.consecutive.count = 5; + user.purchased.plan.consecutive.offset = 1; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.purchased.plan.customerId).to.not.exist; + expect(user.purchased.plan.consecutive.gemCapExtra).to.be.empty; + expect(user.purchased.plan.consecutive.count).to.be.empty; + expect(user.purchased.plan.consecutive.offset).to.be.empty; + }); + }); + + describe('end of the month perks when user is not subscribed', () => { + it('does not reset plan.gemsBought on a new month', () => { + user.purchased.plan.gemsBought = 10; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.gemsBought).to.equal(10); + }); + + it('does not reset plan.dateUpdated on a new month', () => { + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.dateUpdated).to.be.empty; + }); + + it('does not increment plan.consecutive.count', () => { + user.purchased.plan.consecutive.count = 0; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.count).to.equal(0); + }); + + it('does not decrement plan.consecutive.offset when offset is greater than 0', () => { + user.purchased.plan.consecutive.offset = 1; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.offset).to.equal(1); + }); + + it('does not increment plan.consecutive.trinkets when user has reached a month that is a multiple of 3', () => { + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.trinkets).to.equal(0); + }); + + it('doest not increment plan.consecutive.gemCapExtra when user has reached a month that is a multiple of 3', () => { + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(0); + }); + + it('does not increment plan.consecutive.gemCapExtra when user has reached the gemCap limit', () => { + user.purchased.plan.consecutive.gemCapExtra = 25; + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(25); + }); + + it('does nothing to plan stats if we are before the last day of the cancelled month', () => { + user.purchased.plan.dateTerminated = moment(new Date()).add({days: 1}); + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.customerId).to.not.exist; + }); + + xit('does nothing to plan stats when we are after the last day of the cancelled month', () => { + user.purchased.plan.dateTerminated = moment(new Date()).subtract({days: 1}); + user.purchased.plan.consecutive.gemCapExtra = 20; + user.purchased.plan.consecutive.count = 5; + user.purchased.plan.consecutive.offset = 1; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.purchased.plan.customerId).to.exist; + expect(user.purchased.plan.consecutive.gemCapExtra).to.exist; + expect(user.purchased.plan.consecutive.count).to.exist; + expect(user.purchased.plan.consecutive.offset).to.exist; + }); + }); + + describe('user is sleeping', () => { + beforeEach(() => { + user.preferences.sleep = true; + }); + + it('clears user buffs', () => { + user.stats.buffs = { + str: 1, + int: 1, + per: 1, + con: 1, + stealth: 1, + streaks: true, + }; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.stats.buffs.str).to.equal(0); + expect(user.stats.buffs.int).to.equal(0); + expect(user.stats.buffs.per).to.equal(0); + expect(user.stats.buffs.con).to.equal(0); + expect(user.stats.buffs.stealth).to.equal(0); + expect(user.stats.buffs.streaks).to.be.false; + }); + + it('resets all dailies without damaging user', () => { + let daily = { + text: 'test daily', + type: 'daily', + frequency: 'daily', + everyX: 5, + startDate: new Date(), + }; + + let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap + tasksByType.dailys.push(task); + tasksByType.dailys[0].completed = true; + + let healthBefore = user.stats.hp; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.dailys[0].completed).to.be.false; + expect(user.stats.hp).to.equal(healthBefore); + }); + }); + + describe('todos', () => { + beforeEach(() => { + let todo = { + text: 'test todo', + type: 'todo', + value: 0, + }; + + let task = new Tasks.todo(Tasks.Task.sanitize(todo)); // eslint-disable-line babel/new-cap + tasksByType.todos.push(task); + }); + + it('should make uncompleted todos redder', () => { + let valueBefore = tasksByType.todos[0].value; + cron({user, tasksByType, daysMissed, analytics}); + expect(tasksByType.todos[0].value).to.be.lessThan(valueBefore); + }); + + it('should add history of completed todos to user history', () => { + tasksByType.todos[0].completed = true; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.history.todos).to.be.lengthOf(1); + }); + }); + + describe('dailys', () => { + beforeEach(() => { + let daily = { + text: 'test daily', + type: 'daily', + }; + + let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap + tasksByType.dailys = []; + tasksByType.dailys.push(task); + + user._statsComputed = { + con: 1, + }; + }); + + it('should add history', () => { + cron({user, tasksByType, daysMissed, analytics}); + expect(tasksByType.dailys[0].history).to.be.lengthOf(1); + }); + + it('should set tasks completed to false', () => { + tasksByType.dailys[0].completed = true; + cron({user, tasksByType, daysMissed, analytics}); + expect(tasksByType.dailys[0].completed).to.be.false; + }); + + it('should set task checklist to completed for completed dailys', () => { + tasksByType.dailys[0].checklist.push({title: 'test', completed: false}); + tasksByType.dailys[0].completed = true; + cron({user, tasksByType, daysMissed, analytics}); + expect(tasksByType.dailys[0].checklist[0].completed).to.be.true; + }); + + it('should set task checklist to completed for dailys with scheduled misses', () => { + daysMissed = 10; + tasksByType.dailys[0].checklist.push({title: 'test', completed: false}); + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + cron({user, tasksByType, daysMissed, analytics}); + expect(tasksByType.dailys[0].checklist[0].completed).to.be.true; + }); + + it('should do damage for missing a daily', () => { + daysMissed = 1; + let hpBefore = user.stats.hp; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.stats.hp).to.be.lessThan(hpBefore); + }); + + it('should not do damage for missing a daily if user stealth buff is greater than or equal to days missed', () => { + daysMissed = 1; + let hpBefore = user.stats.hp; + user.stats.buffs.stealth = 2; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.stats.hp).to.equal(hpBefore); + }); + + it('should do less damage for missing a daily with partial completion', () => { + daysMissed = 1; + let hpBefore = user.stats.hp; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + cron({user, tasksByType, daysMissed, analytics}); + let hpDifferenceOfFullyIncompleteDaily = hpBefore - user.stats.hp; + + hpBefore = user.stats.hp; + tasksByType.dailys[0].checklist.push({title: 'test', completed: true}); + tasksByType.dailys[0].checklist.push({title: 'test2', completed: false}); + cron({user, tasksByType, daysMissed, analytics}); + let hpDifferenceOfPartiallyIncompleteDaily = hpBefore - user.stats.hp; + + expect(hpDifferenceOfPartiallyIncompleteDaily).to.be.lessThan(hpDifferenceOfFullyIncompleteDaily); + }); + + it('should decrement quest progress down for missing a daily', () => { + daysMissed = 1; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + + let progress = cron({user, tasksByType, daysMissed, analytics}); + + expect(progress.down).to.equal(-1); + }); + }); + + describe('habits', () => { + beforeEach(() => { + let habit = { + text: 'test habit', + type: 'habit', + }; + + let task = new Tasks.habit(Tasks.Task.sanitize(habit)); // eslint-disable-line babel/new-cap + tasksByType.habits = []; + tasksByType.habits.push(task); + }); + + it('should decrement only up value', () => { + tasksByType.habits[0].value = 1; + tasksByType.habits[0].down = false; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].value).to.be.lessThan(1); + }); + + it('should decrement only down value', () => { + tasksByType.habits[0].value = 1; + tasksByType.habits[0].up = false; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].value).to.be.lessThan(1); + }); + + it('should do nothing to habits with both up and down', () => { + tasksByType.habits[0].value = 1; + tasksByType.habits[0].up = true; + tasksByType.habits[0].down = true; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].value).to.equal(1); + }); + }); + + describe('perfect day', () => { + beforeEach(() => { + let daily = { + text: 'test daily', + type: 'daily', + }; + + let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap + tasksByType.dailys = []; + tasksByType.dailys.push(task); + + user._statsComputed = { + con: 1, + }; + }); + + it('stores a new entry in user.history.exp', () => { + user.stats.lvl = 2; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.history.exp).to.have.lengthOf(1); + expect(user.history.exp[0].value).to.equal(150); + }); + + it('increments perfect day achievement', () => { + tasksByType.dailys[0].completed = true; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.achievements.perfect).to.equal(1); + }); + + it('increments user buffs if they have a perfect day', () => { + tasksByType.dailys[0].completed = true; + + let previousBuffs = clone(user.stats.buffs); + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str); + expect(user.stats.buffs.int).to.be.greaterThan(previousBuffs.int); + expect(user.stats.buffs.per).to.be.greaterThan(previousBuffs.per); + expect(user.stats.buffs.con).to.be.greaterThan(previousBuffs.con); + }); + + it('clears buffs if user does not have a perfect day', () => { + daysMissed = 1; + tasksByType.dailys[0].completed = false; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + + user.stats.buffs = { + str: 1, + int: 1, + per: 1, + con: 1, + stealth: 0, + streaks: true, + }; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.stats.buffs.str).to.equal(0); + expect(user.stats.buffs.int).to.equal(0); + expect(user.stats.buffs.per).to.equal(0); + expect(user.stats.buffs.con).to.equal(0); + expect(user.stats.buffs.stealth).to.equal(0); + expect(user.stats.buffs.streaks).to.be.false; + }); + }); + + describe('adding mp', () => { + it('should add mp to user', () => { + let mpBefore = user.stats.mp; + tasksByType.dailys[0].completed = true; + user._statsComputed.maxMP = 100; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.stats.mp).to.be.greaterThan(mpBefore); + }); + + it('set user\'s mp to user._statsComputed.maxMP when user.stats.mp is greater', () => { + user.stats.mp = 120; + user._statsComputed.maxMP = 100; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.stats.mp).to.equal(user._statsComputed.maxMP); + }); + }); + + describe('quest progress', () => { + beforeEach(() => { + let daily = { + text: 'test daily', + type: 'daily', + }; + + let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap + tasksByType.dailys = []; + tasksByType.dailys.push(task); + + user._statsComputed = { + con: 1, + }; + + daysMissed = 1; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + }); + + it('resets user progress', () => { + cron({user, tasksByType, daysMissed, analytics}); + expect(user.party.quest.progress.up).to.equal(0); + expect(user.party.quest.progress.down).to.equal(0); + expect(user.party.quest.progress.collect).to.be.empty; + }); + + it('applies the user progress', () => { + let progress = cron({user, tasksByType, daysMissed, analytics}); + expect(progress.down).to.equal(-1); + }); + }); + + describe('private messages', () => { + let lastMessageId; + + beforeEach(() => { + let maxPMs = 200; + for (let index = 0; index < maxPMs - 1; index += 1) { + let messageId = common.uuid(); + user.inbox.messages[messageId] = { + id: messageId, + text: `test ${index}`, + timestamp: Number(new Date()), + likes: {}, + flags: {}, + flagCount: 0, + }; + } + + lastMessageId = common.uuid(); + user.inbox.messages[lastMessageId] = { + id: lastMessageId, + text: `test ${lastMessageId}`, + timestamp: Number(new Date()), + likes: {}, + flags: {}, + flagCount: 0, + }; + }); + + xit('does not clear pms under 200', () => { + cron({user, tasksByType, daysMissed, analytics}); + expect(user.inbox.messages[lastMessageId]).to.exist; + }); + + xit('clears pms over 200', () => { + let messageId = common.uuid(); + user.inbox.messages[messageId] = { + id: messageId, + text: `test ${messageId}`, + timestamp: Number(new Date()), + likes: {}, + flags: {}, + flagCount: 0, + }; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.inbox.messages[messageId]).to.not.exist; + }); + }); +}); diff --git a/test/api/v3/unit/middlewares/cronMiddleware.js b/test/api/v3/unit/middlewares/cronMiddleware.js new file mode 100644 index 0000000000..196de33dad --- /dev/null +++ b/test/api/v3/unit/middlewares/cronMiddleware.js @@ -0,0 +1,176 @@ +import { + generateRes, + generateReq, + generateNext, + generateTodo, + generateDaily, +} from '../../../../helpers/api-unit.helper'; +import cronMiddleware from '../../../../../website/src/middlewares/api-v3/cron'; +import moment from 'moment'; +import { model as User } from '../../../../../website/src/models/user'; +import { model as Group } from '../../../../../website/src/models/group'; +import * as Tasks from '../../../../../website/src/models/task'; +import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; +import { v4 as generateUUID } from 'uuid'; + +describe('cron middleware', () => { + let res, req, next; + let user; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + user = new User({ + auth: { + local: { + username: 'username', + lowerCaseUsername: 'username', + email: 'email@email.email', + salt: 'salt', + hashed_password: 'hashed_password', // eslint-disable-line camelcase + }, + }, + }); + + user._statsComputed = { + mp: 10, + maxMP: 100, + }; + + res.locals.user = user; + res.analytics = analyticsService; + }); + + it('calls next when user is not attached', () => { + res.locals.user = null; + cronMiddleware(req, res, next); + expect(next).to.be.calledOnce; + }); + + it('calls next when days have not been missed', () => { + cronMiddleware(req, res, next); + expect(next).to.be.calledOnce; + }); + + it('should clear todos older than 30 days for free users', async (done) => { + user.lastCron = moment(new Date()).subtract({days: 2}); + let task = generateTodo(user); + task.dateCompleted = moment(new Date()).subtract({days: 31}); + task.completed = true; + await task.save(); + + cronMiddleware(req, res, () => { + Tasks.Task.findOne({_id: task}, function (err, taskFound) { + expect(err).to.not.exist; + expect(taskFound).to.not.exist; + done(); + }); + }); + }); + + it('should not clear todos older than 30 days for subscribed users', (done) => { + user.purchased.plan.customerId = 'subscribedId'; + user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); + user.lastCron = moment(new Date()).subtract({days: 2}); + let task = generateTodo(user); + task.dateCompleted = moment(new Date()).subtract({days: 31}); + task.completed = true; + task.save(); + + cronMiddleware(req, res, () => { + Tasks.Task.findOne({_id: task}, function (err, taskFound) { + expect(err).to.not.exist; + expect(taskFound).to.exist; + done(); + }); + }); + }); + + it('should clear todos older than 90 days for subscribed users', (done) => { + user.purchased.plan.customerId = 'subscribedId'; + user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); + user.lastCron = moment(new Date()).subtract({days: 2}); + + let task = generateTodo(user); + task.dateCompleted = moment(new Date()).subtract({days: 91}); + task.completed = true; + task.save(); + + cronMiddleware(req, res, () => { + Tasks.Task.findOne({_id: task}, function (err, taskFound) { + expect(err).to.not.exist; + expect(taskFound).to.not.exist; + done(); + }); + }); + }); + + it('should call next is user was not modified after cron', (done) => { + let hpBefore = user.stats.hp; + user.lastCron = moment(new Date()).subtract({days: 2}); + generateDaily(user); + + cronMiddleware(req, res, () => { + expect(user.stats.hp).to.be.equal(hpBefore); + done(); + }); + }); + + it('does damage for missing dailies', (done) => { + let hpBefore = user.stats.hp; + user.lastCron = moment(new Date()).subtract({days: 2}); + let daily = generateDaily(user); + daily.startDate = moment(new Date()).subtract({days: 2}); + daily.save(); + + cronMiddleware(req, res, () => { + expect(user.stats.hp).to.be.lessThan(hpBefore); + done(); + }); + }); + + it('updates tasks', (done) => { + user.lastCron = moment(new Date()).subtract({days: 2}); + let todo = generateTodo(user); + let todoValueBefore = todo.value; + + cronMiddleware(req, res, () => { + Tasks.Task.findOne({_id: todo._id}, function (err, todoFound) { + expect(err).to.not.exist; + expect(todoFound.value).to.be.lessThan(todoValueBefore); + done(); + }); + }); + }); + + it('applies quest progress', async (done) => { + let hpBefore = user.stats.hp; + user.lastCron = moment(new Date()).subtract({days: 2}); + let daily = generateDaily(user); + daily.startDate = moment(new Date()).subtract({days: 2}); + daily.save(); + + let questKey = 'dilatory'; + user.party.quest.key = questKey; + + let party = new Group({ + type: 'party', + name: generateUUID(), + leader: user._id, + }); + party.quest.members[user._id] = true; + party.quest.key = questKey; + await party.save(); + + user.party._id = party._id; + await user.save(); + + party.startQuest(user); + + cronMiddleware(req, res, () => { + expect(user.stats.hp).to.be.lessThan(hpBefore); + done(); + }); + }); +}); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 489e0dd22e..beeac66098 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -6,6 +6,7 @@ import { model as Group } from '../../website/src/models/group'; import mongo from './mongo'; // eslint-disable-line import moment from 'moment'; import i18n from '../../common/script/i18n'; +import * as Tasks from '../../website/src/models/task'; afterEach((done) => { sandbox.restore(); @@ -70,3 +71,33 @@ export function generateHistory (days) { return history; } + +export function generateTodo (user) { + let todo = { + text: 'test todo', + type: 'todo', + value: 0, + completed: false, + }; + + let task = new Tasks.todo(Tasks.Task.sanitize(todo)); // eslint-disable-line babel/new-cap + task.userId = user._id; + task.save(); + + return task; +} + +export function generateDaily (user) { + let daily = { + text: 'test daily', + type: 'daily', + value: 0, + completed: false, + }; + + let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap + task.userId = user._id; + task.save(); + + return task; +} diff --git a/website/src/libs/api-v3/cron.js b/website/src/libs/api-v3/cron.js new file mode 100644 index 0000000000..61a2b46c72 --- /dev/null +++ b/website/src/libs/api-v3/cron.js @@ -0,0 +1,273 @@ +import moment from 'moment'; +import common from '../../../../common/'; +import { preenUserHistory } from '../../libs/api-v3/preening'; +import _ from 'lodash'; + +const shouldDo = common.shouldDo; +const scoreTask = common.ops.scoreTask; +// const maxPMs = 200; + +let CLEAR_BUFFS = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, +}; + +function grantEndOfTheMonthPerks (user, now) { + let plan = user.purchased.plan; + + if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { + plan.gemsBought = 0; // reset gem-cap + plan.dateUpdated = now; + // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks + // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 + // TODO use month diff instead of ++ / --? + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + + plan.consecutive.count++; + + if (plan.consecutive.offset > 0) { + plan.consecutive.offset--; + } else if (plan.consecutive.count % 3 === 0) { // every 3 months + plan.consecutive.trinkets++; + plan.consecutive.gemCapExtra += 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) + } + } + + // If user cancelled subscription, we give them until 30day's end until it terminates + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null, + }); + + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0, + }); + + user.markModified('purchased.plan'); + } +} + +function performSleepTasks (user, tasksByType, now) { + user.stats.buffs = _.cloneDeep(CLEAR_BUFFS); + + tasksByType.dailys.forEach((daily) => { + let completed = daily.completed; + let thatDay = moment(now).subtract({days: 1}); + + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + daily.checklist.forEach(box => box.completed = false); + } + + daily.completed = false; + }); +} + +// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) +// For incomplete Dailys, deduct experience +// Make sure to run this function once in a while as server will not take care of overnight calculations. +// And you have to run it every time client connects. +export function cron (options = {}) { + let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options; + + user.auth.timestamps.loggedin = now; + user.lastCron = now; + user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; + // Reset the lastDrop count to zero + if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; + + // "Perfect Day" achievement for perfect-days + let perfect = true; + + if (user.isSubscribed()) { + grantEndOfTheMonthPerks(user, now); + } + + // User is resting at the inn. + // On cron, buffs are cleared and all dailies are reset without performing damage + if (user.preferences.sleep === true) { + performSleepTasks(user, tasksByType, now); + return; + } + + let multiDaysCountAsOneDay = true; + // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. + // When site-wide difficulty settings are introduced, this can be a user preference option. + + // Tally each task + let todoTally = 0; + + tasksByType.todos.forEach(task => { // make uncompleted todos redder + scoreTask({ + task, + user, + direction: 'down', + cron: true, + times: multiDaysCountAsOneDay ? 1 : daysMissed, + }); + + todoTally += task.value; + }); + + let dailyChecked = 0; // how many dailies were checked? + let dailyDueUnchecked = 0; // how many dailies were cun-hecked? + if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; + + tasksByType.dailys.forEach((task) => { + let completed = task.completed; + // Deduct points for missed Daily tasks + let EvadeTask = 0; + let scheduleMisses = daysMissed; + + if (completed) { + dailyChecked += 1; + } else { + // dailys repeat, so need to calculate how many they've missed according to their own schedule + scheduleMisses = 0; + + for (let i = 0; i < daysMissed; i++) { + let thatDay = moment(now).subtract({days: i + 1}); + + if (shouldDo(thatDay.toDate(), task, user.preferences)) { + scheduleMisses++; + if (user.stats.buffs.stealth) { + user.stats.buffs.stealth--; + EvadeTask++; + } + if (multiDaysCountAsOneDay) break; + } + } + + if (scheduleMisses > EvadeTask) { + perfect = false; + + if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points + let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + dailyDueUnchecked += 1 - fractionChecked; + dailyChecked += fractionChecked; + } else { + dailyDueUnchecked += 1; + } + + let delta = scoreTask({ + user, + task, + direction: 'down', + times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, + cron: true, + }); + + // Apply damage from a boss, less damage for Trivial priority (difficulty) + user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); + // NB: Medium and Hard priorities do not increase damage from boss. This was by accident + // initially, and when we realised, we could not fix it because users are used to + // their Medium and Hard Dailies doing an Easy amount of damage from boss. + // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future + // setting between Trivial and Easy. + } + } + + task.history.push({ + date: Number(new Date()), + value: task.value, + }); + task.completed = false; + + if (completed || scheduleMisses > 0) { + task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + } + }); + + tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 + if (task.up === false || task.down === false) { + task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; + } + }); + + // Finished tallying + user.history.todos.push({date: now, value: todoTally}); + + // tally experience + let expTally = user.stats.exp; + let lvl = 0; // iterator + while (lvl < user.stats.lvl - 1) { + lvl++; + expTally += common.tnl(lvl); + } + + user.history.exp.push({date: now, value: expTally}); + + // preen user history so that it doesn't become a performance problem + // also for subscribed users but differentyly + // premium subscribers can keep their full history. + preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); + + if (perfect) { + user.achievements.perfect++; + let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); + user.stats.buffs = { + str: lvlDiv2, + int: lvlDiv2, + per: lvlDiv2, + con: lvlDiv2, + stealth: 0, + streaks: false, + }; + } else { + user.stats.buffs = _.cloneDeep(CLEAR_BUFFS); + } + + // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit + // Adjust for fraction of dailies completed + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + + if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + + // After all is said and done, progress up user's effect on quest, return those values & reset the user's + let progress = user.party.quest.progress; + let _progress = _.cloneDeep(progress); + _.merge(progress, {down: 0, up: 0}); + progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + + // @TODO: Clean PMs - keep 200 for subscribers and 50 for free users + // let numberOfPMs = Object.keys(user.inbox.messages).length; + // if (numberOfPMs > maxPMs) { + // _(user.inbox.messages) + // .sortBy('timestamp') + // .takeRight(numberOfPMs - maxPMs) + // .each(pm => { + // delete user.inbox.messages[pm.id]; + // }).value(); + // + // user.markModified('inbox.messages'); + // } + + // Analytics + user.flags.cronCount++; + analytics.track('Cron', { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user, // TODO is it really necessary passing the whole user object? + resting: user.preferences.sleep, + cronCount: user.flags.cronCount, + progressUp: _.min([_progress.up, 900]), + progressDown: _progress.down, + }); + + return _progress; +} diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 2711712ed4..50c6e32548 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -5,274 +5,13 @@ import * as Tasks from '../../models/task'; import Q from 'q'; import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; -import { preenUserHistory } from '../../libs/api-v3/preening'; +import { cron } from '../../libs/api-v3/cron'; const daysSince = common.daysSince; -const shouldDo = common.shouldDo; - -const scoreTask = common.ops.scoreTask; - -let clearBuffs = { - str: 0, - int: 0, - per: 0, - con: 0, - stealth: 0, - streaks: false, -}; - -// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) -// For incomplete Dailys, deduct experience -// Make sure to run this function once in a while as server will not take care of overnight calculations. -// And you have to run it every time client connects. -function cron (options = {}) { - let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options; - - user.auth.timestamps.loggedin = now; - user.lastCron = now; - user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; - // Reset the lastDrop count to zero - if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; - - // "Perfect Day" achievement for perfect-days - let perfect = true; - - // end-of-month perks for subscribers - let plan = user.purchased.plan; - if (user.isSubscribed()) { - if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { - plan.gemsBought = 0; // reset gem-cap - plan.dateUpdated = now; - // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks - // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 - // TODO use month diff instead of ++ / --? - _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // TODO see https://github.com/HabitRPG/habitrpg/issues/4317 - plan.consecutive.count++; - if (plan.consecutive.offset > 0) { - plan.consecutive.offset--; - } else if (plan.consecutive.count % 3 === 0) { // every 3 months - plan.consecutive.trinkets++; - plan.consecutive.gemCapExtra += 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) - } - } - - // If user cancelled subscription, we give them until 30day's end until it terminates - if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { - _.merge(plan, { - planId: null, - customerId: null, - paymentMethod: null, - }); - - _.merge(plan.consecutive, { - count: 0, - offset: 0, - gemCapExtra: 0, - }); - - user.markModified('purchased.plan'); - } - } - - // User is resting at the inn. - // On cron, buffs are cleared and all dailies are reset without performing damage - if (user.preferences.sleep === true) { - user.stats.buffs = _.cloneDeep(clearBuffs); - - tasksByType.dailys.forEach((daily) => { - let completed = daily.completed; - let thatDay = moment(now).subtract({days: 1}); - - if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { - daily.checklist.forEach(box => box.completed = false); - } - daily.completed = false; - }); - - return; - } - - let multiDaysCountAsOneDay = true; - // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. - // When site-wide difficulty settings are introduced, this can be a user preference option. - - // Tally each task - let todoTally = 0; - - tasksByType.todos.forEach(task => { // make uncompleted todos redder - scoreTask({ - task, - user, - direction: 'down', - cron: true, - times: multiDaysCountAsOneDay ? 1 : daysMissed, - }); - - todoTally += task.value; - }); - - let dailyChecked = 0; // how many dailies were checked? - let dailyDueUnchecked = 0; // how many dailies were cun-hecked? - if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; - - tasksByType.dailys.forEach((task) => { - let completed = task.completed; - // Deduct points for missed Daily tasks - let EvadeTask = 0; - let scheduleMisses = daysMissed; - - if (completed) { - dailyChecked += 1; - } else { - // dailys repeat, so need to calculate how many they've missed according to their own schedule - scheduleMisses = 0; - - for (let i = 0; i < daysMissed; i++) { - let thatDay = moment(now).subtract({days: i + 1}); - - if (shouldDo(thatDay.toDate(), task, user.preferences)) { - scheduleMisses++; - if (user.stats.buffs.stealth) { - user.stats.buffs.stealth--; - EvadeTask++; - } - if (multiDaysCountAsOneDay) break; - } - } - - if (scheduleMisses > EvadeTask) { - perfect = false; - - if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points - let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; - dailyDueUnchecked += 1 - fractionChecked; - dailyChecked += fractionChecked; - } else { - dailyDueUnchecked += 1; - } - - let delta = scoreTask({ - user, - task, - direction: 'down', - times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, - cron: true, - }); - - // Apply damage from a boss, less damage for Trivial priority (difficulty) - user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); - // NB: Medium and Hard priorities do not increase damage from boss. This was by accident - // initially, and when we realised, we could not fix it because users are used to - // their Medium and Hard Dailies doing an Easy amount of damage from boss. - // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future - // setting between Trivial and Easy. - } - } - - task.history.push({ - date: Number(new Date()), - value: task.value, - }); - task.completed = false; - - if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = true); // TODO this should not happen for grey tasks unless they are completed - } - }); - - tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 - if (task.up === false || task.down === false) { - task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; - } - }); - - // Finished tallying - user.history.todos.push({date: now, value: todoTally}); - - // tally experience - let expTally = user.stats.exp; - let lvl = 0; // iterator - while (lvl < user.stats.lvl - 1) { - lvl++; - expTally += common.tnl(lvl); - } - user.history.exp.push({date: now, value: expTally}); - - // preen user history so that it doesn't become a performance problem - // also for subscribed users but differentyly - // premium subscribers can keep their full history. - preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); - - if (perfect) { - user.achievements.perfect++; - let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); - user.stats.buffs = { - str: lvlDiv2, - int: lvlDiv2, - per: lvlDiv2, - con: lvlDiv2, - stealth: 0, - streaks: false, - }; - } else { - user.stats.buffs = _.cloneDeep(clearBuffs); - } - - // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit - // Adjust for fraction of dailies completed - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; - - if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) { - user.stats.mp = user._statsComputed.maxMP; - } - - // After all is said and done, progress up user's effect on quest, return those values & reset the user's - let progress = user.party.quest.progress; - let _progress = _.cloneDeep(progress); - - progress.down = 0; - progress.up = 0; - - progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); - - // Clean PMs - keep 200 for subscribers and 50 for free users - // TODO tests - let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too - let numberOfPMs = Object.keys(user.inbox.messages).length; - if (Object.keys(user.inbox.messages).length > maxPMs) { - _(user.inbox.messages) - .sortBy('timestamp') - .takeRight(numberOfPMs - maxPMs) - .each(pm => { - user.inbox.messages[pm.id] = undefined; - }).value(); - - user.markModified('inbox.messages'); - } - - // Analytics - user.flags.cronCount++; - analytics.track('Cron', { - category: 'behavior', - gaLabel: 'Cron Count', - gaValue: user.flags.cronCount, - uuid: user._id, - user, - resting: user.preferences.sleep, - cronCount: user.flags.cronCount, - progressUp: _.min([_progress.up, 900]), - progressDown: _progress.down, - }); - - return _progress; -} module.exports = function cronMiddleware (req, res, next) { let user = res.locals.user; + if (!user) return next(); // User might not be available when authentication is not mandatory let analytics = res.analytics; @@ -381,7 +120,7 @@ module.exports = function cronMiddleware (req, res, next) { type: 'todo', completed: true, dateCompleted: { - $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), + $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(), }, 'challenge.id': {$exists: false}, }).exec(); // TODO wait before returning? @@ -397,13 +136,13 @@ module.exports = function cronMiddleware (req, res, next) { // Save user and tasks let toSave = [user.save()]; tasks.forEach(task => { - if (task.isModified) toSave.push(task.save()); + toSave.push(task.save()); }); + Q.all(toSave) .then(saved => { user = res.locals.user = saved[0]; if (!quest) return; - // If user is on a quest, roll for boss & player, or handle collections let questType = quest.boss ? 'boss' : 'collect'; // TODO this saves user, runs db updates, loads user. Is there a better way to handle this? diff --git a/website/src/models/group.js b/website/src/models/group.js index 9542500d91..6f2b2a0b14 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -475,7 +475,6 @@ function _isOnQuest (user, progress, group) { // Returns a promise schema.statics.collectQuest = async function collectQuest (user, progress) { let group = await this.getGroup({user, groupId: 'party'}); - if (!_isOnQuest(user, progress, group)) return; let quest = shared.content.quests[group.quest.key]; From c78c3b9fd8263fd966a8b32f75975c3de26ca8cd Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Apr 2016 15:07:45 +0200 Subject: [PATCH 713/976] v3: first iteration of users migration --- migrations/api_v3/old_models/challenge.js | 122 ---- migrations/api_v3/old_models/task.js | 115 ---- migrations/api_v3/old_models/user.js | 700 ---------------------- migrations/api_v3/users.js | 104 +++- 4 files changed, 74 insertions(+), 967 deletions(-) delete mode 100644 migrations/api_v3/old_models/challenge.js delete mode 100644 migrations/api_v3/old_models/task.js delete mode 100644 migrations/api_v3/old_models/user.js diff --git a/migrations/api_v3/old_models/challenge.js b/migrations/api_v3/old_models/challenge.js deleted file mode 100644 index 7a014375c2..0000000000 --- a/migrations/api_v3/old_models/challenge.js +++ /dev/null @@ -1,122 +0,0 @@ -// OLD (v2) CHALLENGE MODEL - -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); -var TaskSchemas = require('./task'); - -var ChallengeSchema = new Schema({ - _id: {type: String, 'default': shared.uuid}, - name: String, - shortName: String, - description: String, - official: {type: Boolean,'default':false}, - habits: [TaskSchemas.HabitSchema], - dailys: [TaskSchemas.DailySchema], - todos: [TaskSchemas.TodoSchema], - rewards: [TaskSchemas.RewardSchema], - leader: {type: String, ref: 'User'}, - group: {type: String, ref: 'Group'}, - timestamp: {type: Date, 'default': Date.now}, - members: [{type: String, ref: 'User'}], - memberCount: {type: Number, 'default': 0}, - prize: {type: Number, 'default': 0} -}, {collection: 'challenges'}); - -ChallengeSchema.virtual('tasks').get(function () { - var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); - var tasks = _.object(_.pluck(tasks,'id'), tasks); - return tasks; -}); - -ChallengeSchema.methods.toJSON = function(){ - var doc = this.toObject(); - doc._isMember = this._isMember; - return doc; -} - -// -------------- -// Syncing logic -// -------------- - -function syncableAttrs(task) { - var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument - // only sync/compare important attrs - var omitAttrs = 'challenge history tags completed streak notes'.split(' '); - if (t.type != 'reward') omitAttrs.push('value'); - return _.omit(t, omitAttrs); -} - -/** - * Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers - */ -function comparableData(obj) { - return JSON.stringify( - _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) - .sortBy('id') // we don't want to update if they're sort-order is different - .transform(function(result, task){ - result.push(syncableAttrs(task)); - }) - .value()) -} - -ChallengeSchema.methods.isOutdated = function(newData) { - return comparableData(this) !== comparableData(newData); -} - -/** - * Syncs all new tasks, deleted tasks, etc to the user object - * @param user - * @return nothing, user is modified directly. REMEMBER to save the user! - */ -ChallengeSchema.methods.syncToUser = function(user, cb) { - if (!user) return; - var self = this; - self.shortName = self.shortName || self.name; - - // Add challenge to user.challenges - if (!_.contains(user.challenges, self._id)) { - user.challenges.push(self._id); - } - - // Sync tags - var tags = user.tags || []; - var i = _.findIndex(tags, {id: self._id}) - if (~i) { - if (tags[i].name !== self.shortName) { - // update the name - it's been changed since - user.tags[i].name = self.shortName; - } - } else { - user.tags.push({ - id: self._id, - name: self.shortName, - challenge: true - }); - } - - // Sync new tasks and updated tasks - _.each(self.tasks, function(task){ - var list = user[task.type+'s']; - var userTask = user.tasks[task.id] || (list.push(syncableAttrs(task)), list[list.length-1]); - if (!userTask.notes) userTask.notes = task.notes; // don't override the notes, but provide it if not provided - userTask.challenge = {id:self._id}; - userTask.tags = userTask.tags || {}; - userTask.tags[self._id] = true; - _.merge(userTask, syncableAttrs(task)); - }) - - // Flag deleted tasks as "broken" - _.each(user.tasks, function(task){ - if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) { - task.challenge.broken = 'TASK_DELETED'; - } - }) - - user.save(cb); -}; - - -module.exports.schema = ChallengeSchema; -module.exports.model = mongoose.model("ChallengeOld", ChallengeSchema); diff --git a/migrations/api_v3/old_models/task.js b/migrations/api_v3/old_models/task.js deleted file mode 100644 index 63d24cc743..0000000000 --- a/migrations/api_v3/old_models/task.js +++ /dev/null @@ -1,115 +0,0 @@ -// OLD (v2) TASK MODEL - -// User.js -// ======= -// Defines the user data model (schema) for use via the API. - -// Dependencies -// ------------ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); -var moment = require('moment'); - -// Task Schema -// ----------- - -var TaskSchema = { - //_id:{type: String,'default': helpers.uuid}, - id: {type: String,'default': shared.uuid}, - dateCreated: {type:Date, 'default':Date.now}, - text: String, - notes: {type: String, 'default': ''}, - tags: {type: Schema.Types.Mixed, 'default': {}}, //{ "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, - value: {type: Number, 'default': 0}, // redness - priority: {type: Number, 'default': '1'}, - attribute: {type: String, 'default': "str", enum: ['str','con','int','per']}, - challenge: { - id: {type: 'String', ref:'Challenge'}, - broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED - winner: String // user.profile.name - // group: {type: 'Strign', ref: 'Group'} // if we restore this, rename `id` above to `challenge` - }, - reminders: [{ - id: {type:String,'default':shared.uuid}, - startDate: Date, - time: Date - }] -}; - -var HabitSchema = new Schema( - _.defaults({ - type: {type:String, 'default': 'habit'}, - history: Array, // [{date:Date, value:Number}], // this causes major performance problems - up: {type: Boolean, 'default': true}, - down: {type: Boolean, 'default': true} - }, TaskSchema) - , { _id: false, minimize:false } -); - -var collapseChecklist = {type:Boolean, 'default':false}; -var checklist = [{ - completed:{type:Boolean,'default':false}, - text: String, - _id:false, - id: {type:String,'default':shared.uuid} -}]; - -var DailySchema = new Schema( - _.defaults({ - type: {type: String, 'default': 'daily'}, - frequency: {type: String, 'default': 'weekly', enum: ['daily', 'weekly']}, - everyX: {type: Number, 'default': 1}, // e.g. once every X weeks - startDate: {type: Date, 'default': moment().startOf('day').toDate()}, - history: Array, - completed: {type: Boolean, 'default': false}, - repeat: { // used only for 'weekly' frequency, - m: {type: Boolean, 'default': true}, - t: {type: Boolean, 'default': true}, - w: {type: Boolean, 'default': true}, - th: {type: Boolean, 'default': true}, - f: {type: Boolean, 'default': true}, - s: {type: Boolean, 'default': true}, - su: {type: Boolean, 'default': true} - }, - collapseChecklist:collapseChecklist, - checklist:checklist, - streak: {type: Number, 'default': 0} - }, TaskSchema) - , { _id: false, minimize:false } -) - -var TodoSchema = new Schema( - _.defaults({ - type: {type:String, 'default': 'todo'}, - completed: {type: Boolean, 'default': false}, - dateCompleted: Date, - date: String, // due date for todos // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date - collapseChecklist:collapseChecklist, - checklist:checklist - }, TaskSchema) - , { _id: false, minimize:false } -); - -var RewardSchema = new Schema( - _.defaults({ - type: {type:String, 'default': 'reward'} - }, TaskSchema) - , { _id: false, minimize:false } -); - -/** - * Workaround for bug when _id & id were out of sync, we can remove this after challenges has been running for a while - */ -//_.each([HabitSchema, DailySchema, TodoSchema, RewardSchema], function(schema){ -// schema.post('init', function(doc){ -// if (!doc.id && doc._id) doc.id = doc._id; -// }) -//}) - -module.exports.TaskSchema = TaskSchema; -module.exports.HabitSchema = HabitSchema; -module.exports.DailySchema = DailySchema; -module.exports.TodoSchema = TodoSchema; -module.exports.RewardSchema = RewardSchema; diff --git a/migrations/api_v3/old_models/user.js b/migrations/api_v3/old_models/user.js deleted file mode 100644 index b8fb7a7ed6..0000000000 --- a/migrations/api_v3/old_models/user.js +++ /dev/null @@ -1,700 +0,0 @@ -// OLD (v2) USER MODEL - -// User.js -// ======= -// Defines the user data model (schema) for use via the API. - -// Dependencies -// ------------ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); -var TaskSchemas = require('./task'); -var Challenge = require('./challenge').model; -var moment = require('moment'); - -// User Schema -// ----------- - -var UserSchema = new Schema({ - // ### UUID and API Token - _id: { - type: String, - 'default': shared.uuid - }, - apiToken: { - type: String, - 'default': shared.uuid - }, - - // ### Mongoose Update Object - // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which - // have been updated (http://goo.gl/gQLz41), but we want *every* update - _v: { type: Number, 'default': 0 }, - achievements: { - originalUser: Boolean, - habitSurveys: Number, - ultimateGearSets: Schema.Types.Mixed, - beastMaster: Boolean, - beastMasterCount: Number, - mountMaster: Boolean, - mountMasterCount: Number, - triadBingo: Boolean, - triadBingoCount: Number, - veteran: Boolean, - snowball: Number, - spookDust: Number, - shinySeed: Number, - seafoam: Number, - streak: Number, - challenges: Array, - quests: Schema.Types.Mixed, - rebirths: Number, - rebirthLevel: Number, - perfect: Number, - habitBirthdays: Number, - valentine: Number, - costumeContest: Boolean, // Superseded by costumeContests - nye: Number, - habiticaDays: Number, - greeting: Number, - thankyou: Number, - costumeContests: Number, - birthday: Number, - partyUp: Boolean, - partyOn: Boolean - }, - auth: { - blocked: Boolean, - facebook: Schema.Types.Mixed, - local: { - email: String, - hashed_password: String, - salt: String, - username: String, - lowerCaseUsername: String // Store a lowercase version of username to check for duplicates - }, - timestamps: { - created: {type: Date,'default': Date.now}, - loggedin: {type: Date,'default': Date.now} - } - }, - - backer: { - tier: Number, - npc: String, - tokensApplied: Boolean - }, - - contributor: { - level: Number, // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801 - admin: Boolean, - sudo: Boolean, - text: String, // Artisan, Friend, Blacksmith, etc - contributions: String, // a markdown textarea to list their contributions + links - critical: String - }, - - balance: {type: Number, 'default':0}, - filters: {type: Schema.Types.Mixed, 'default': {}}, - - purchased: { - ads: {type: Boolean, 'default': false}, - skin: {type: Schema.Types.Mixed, 'default': {}}, // eg, {skeleton: true, pumpkin: true, eb052b: true} - hair: {type: Schema.Types.Mixed, 'default': {}}, - shirt: {type: Schema.Types.Mixed, 'default': {}}, - background: {type: Schema.Types.Mixed, 'default': {}}, - txnCount: {type: Number, 'default':0}, - mobileChat: Boolean, - plan: { - planId: String, - paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} - customerId: String, // Billing Agreement Id in case of Amazon Payments - dateCreated: Date, - dateTerminated: Date, - dateUpdated: Date, - extraMonths: {type:Number, 'default':0}, - gemsBought: {type: Number, 'default': 0}, - mysteryItems: {type: Array, 'default': []}, - lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date - consecutive: { - count: {type:Number, 'default':0}, - offset: {type:Number, 'default':0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 - gemCapExtra: {type:Number, 'default':0}, - trinkets: {type:Number, 'default':0} - } - } - }, - - flags: { - customizationsNotification: {type: Boolean, 'default': false}, - showTour: {type: Boolean, 'default': true}, - tour: { - // -1 indicates "uninitiated", -2 means "complete", any other number is the current tour step (0-index) - intro: {type: Number, 'default': -1}, - classes: {type: Number, 'default': -1}, - stats: {type: Number, 'default': -1}, - tavern: {type: Number, 'default': -1}, - party: {type: Number, 'default': -1}, - guilds: {type: Number, 'default': -1}, - challenges: {type: Number, 'default': -1}, - market: {type: Number, 'default': -1}, - pets: {type: Number, 'default': -1}, - mounts: {type: Number, 'default': -1}, - hall: {type: Number, 'default': -1}, - equipment: {type: Number, 'default': -1} - }, - tutorial: { - common: { - habits: {type: Boolean, 'default': false}, - dailies: {type: Boolean, 'default': false}, - todos: {type: Boolean, 'default': false}, - rewards: {type: Boolean, 'default': false}, - party: {type: Boolean, 'default': false}, - pets: {type: Boolean, 'default': false}, - gems: {type: Boolean, 'default': false}, - skills: {type: Boolean, 'default': false}, - classes: {type: Boolean, 'default': false}, - tavern: {type: Boolean, 'default': false}, - equipment: {type: Boolean, 'default': false}, - items: {type: Boolean, 'default': false}, - }, - ios: { - addTask: {type: Boolean, 'default': false}, - editTask: {type: Boolean, 'default': false}, - deleteTask: {type: Boolean, 'default': false}, - filterTask: {type: Boolean, 'default': false}, - groupPets: {type: Boolean, 'default': false}, - inviteParty: {type: Boolean, 'default': false}, - } - }, - dropsEnabled: {type: Boolean, 'default': false}, - itemsEnabled: {type: Boolean, 'default': false}, - newStuff: {type: Boolean, 'default': false}, - rewrite: {type: Boolean, 'default': true}, - contributor: Boolean, - classSelected: {type: Boolean, 'default': false}, - mathUpdates: Boolean, - rebirthEnabled: {type: Boolean, 'default': false}, - levelDrops: {type:Schema.Types.Mixed, 'default':{}}, - chatRevoked: Boolean, - // Used to track the status of recapture emails sent to each user, - // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user - recaptureEmailsPhase: {type: Number, 'default': 0}, - // Needed to track the tip to send inside the email - weeklyRecapEmailsPhase: {type: Number, 'default': 0}, - // Used to track when the next weekly recap should be sent - lastWeeklyRecap: {type: Date, 'default': Date.now}, - // Used to enable weekly recap emails as users login - lastWeeklyRecapDiscriminator: Boolean, - communityGuidelinesAccepted: {type: Boolean, 'default': false}, - cronCount: {type:Number, 'default':0}, - welcomed: {type: Boolean, 'default': false}, - armoireEnabled: {type: Boolean, 'default': false}, - armoireOpened: {type: Boolean, 'default': false}, - armoireEmpty: {type: Boolean, 'default': false}, - cardReceived: {type: Boolean, 'default': false}, - warnedLowHealth: {type: Boolean, 'default': false} - }, - history: { - exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined - todos: Array //[{data: Date, value: Number}] // big peformance issues if these are defined - }, - - invitations: { - guilds: {type: Array, 'default': []}, - party: Schema.Types.Mixed - }, - items: { - gear: { - owned: _.transform(shared.content.gear.flat, function(m,v,k){ - m[v.key] = {type: Boolean}; - if (v.key.match(/[armor|head|shield]_warrior_0/)) - m[v.key]['default'] = true; - }), - - equipped: { - weapon: String, - armor: {type: String, 'default': 'armor_base_0'}, - head: {type: String, 'default': 'head_base_0'}, - shield: {type: String, 'default': 'shield_base_0'}, - back: String, - headAccessory: String, - eyewear: String, - body: String - }, - costume: { - weapon: String, - armor: {type: String, 'default': 'armor_base_0'}, - head: {type: String, 'default': 'head_base_0'}, - shield: {type: String, 'default': 'shield_base_0'}, - back: String, - headAccessory: String, - eyewear: String, - body: String - } - }, - - special:{ - snowball: {type: Number, 'default': 0}, - spookDust: {type: Number, 'default': 0}, - shinySeed: {type: Number, 'default': 0}, - seafoam: {type: Number, 'default': 0}, - valentine: Number, - valentineReceived: Array, // array of strings, by sender name - nye: Number, - nyeReceived: Array, - greeting: Number, - greetingReceived: Array, - thankyou: Number, - thankyouReceived: Array, - birthday: Number, - birthdayReceived: Array - }, - - // -------------- Animals ------------------- - // Complex bit here. The result looks like: - // pets: { - // 'Wolf-Desert': 0, // 0 means does not own - // 'PandaCub-Red': 10, // Number represents "Growth Points" - // etc... - // } - pets: - _.defaults( - // First transform to a 1D eggs/potions mapping - _.transform(shared.content.pets, function(m,v,k){ m[k] = Number; }), - // Then add additional pets (quest, backer, contributor, premium) - _.transform(shared.content.questPets, function(m,v,k){ m[k] = Number; }), - _.transform(shared.content.specialPets, function(m,v,k){ m[k] = Number; }), - _.transform(shared.content.premiumPets, function(m,v,k){ m[k] = Number; }) - ), - currentPet: String, // Cactus-Desert - - // eggs: { - // 'PandaCub': 0, // 0 indicates "doesn't own" - // 'Wolf': 5 // Number indicates "stacking" - // } - eggs: _.transform(shared.content.eggs, function(m,v,k){ m[k] = Number; }), - - // hatchingPotions: { - // 'Desert': 0, // 0 indicates "doesn't own" - // 'CottonCandyBlue': 5 // Number indicates "stacking" - // } - hatchingPotions: _.transform(shared.content.hatchingPotions, function(m,v,k){ m[k] = Number; }), - - // Food: { - // 'Watermelon': 0, // 0 indicates "doesn't own" - // 'RottenMeat': 5 // Number indicates "stacking" - // } - food: _.transform(shared.content.food, function(m,v,k){ m[k] = Number; }), - - // mounts: { - // 'Wolf-Desert': true, - // 'PandaCub-Red': false, - // etc... - // } - mounts: _.defaults( - // First transform to a 1D eggs/potions mapping - _.transform(shared.content.pets, function(m,v,k){ m[k] = Boolean; }), - // Then add quest and premium pets - _.transform(shared.content.questPets, function(m,v,k){ m[k] = Boolean; }), - _.transform(shared.content.premiumPets, function(m,v,k){ m[k] = Boolean; }), - // Then add additional mounts (backer, contributor) - _.transform(shared.content.specialMounts, function(m,v,k){ m[k] = Boolean; }) - ), - currentMount: String, - - // Quests: { - // 'boss_0': 0, // 0 indicates "doesn't own" - // 'collection_honey': 5 // Number indicates "stacking" - // } - quests: _.transform(shared.content.quests, function(m,v,k){ m[k] = Number; }), - - lastDrop: { - date: {type: Date, 'default': Date.now}, - count: {type: Number, 'default': 0} - } - }, - - lastCron: {type: Date, 'default': Date.now}, - - // {GROUP_ID: Boolean}, represents whether they have unseen chat messages - newMessages: {type: Schema.Types.Mixed, 'default': {}}, - - party: { - // id // FIXME can we use a populated doc instead of fetching party separate from user? - order: {type:String, 'default':'level'}, - orderAscending: {type:String, 'default':'ascending'}, - quest: { - key: String, - progress: { - up: {type: Number, 'default': 0}, - down: {type: Number, 'default': 0}, - collect: {type: Schema.Types.Mixed, 'default': {}} // {feather:1, ingot:2} - }, - completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser - RSVPNeeded: {type: Boolean, 'default': false} // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled - } - }, - preferences: { - dayStart: {type:Number, 'default': 0, min: 0, max: 23}, - size: {type:String, enum: ['broad','slim'], 'default': 'slim'}, - hair: { - color: {type: String, 'default': 'red'}, - base: {type: Number, 'default': 3}, - bangs: {type: Number, 'default': 1}, - beard: {type: Number, 'default': 0}, - mustache: {type: Number, 'default': 0}, - flower: {type: Number, 'default': 1} - }, - chair: {type: String, 'default': 'none'}, - hideHeader: {type:Boolean, 'default':false}, - skin: {type:String, 'default':'915533'}, - shirt: {type: String, 'default': 'blue'}, - timezoneOffset: {type: Number, 'default': 0}, - timezoneOffsetAtLastCron: Number, - sound: {type:String, 'default':'off', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme']}, - language: String, - automaticAllocation: Boolean, - allocationMode: {type:String, enum: ['flat','classbased','taskbased'], 'default': 'flat'}, - autoEquip: {type: Boolean, 'default': true}, - costume: Boolean, - dateFormat: {type: String, enum:['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], 'default': 'MM/dd/yyyy'}, - sleep: {type: Boolean, 'default': false}, - stickyHeader: {type: Boolean, 'default': true}, - disableClasses: {type: Boolean, 'default': false}, - newTaskEdit: {type: Boolean, 'default': false}, - dailyDueDefaultView: {type: Boolean, 'default': false}, - tagsCollapsed: {type: Boolean, 'default': false}, - advancedCollapsed: {type: Boolean, 'default': false}, - toolbarCollapsed: {type:Boolean, 'default':false}, - reverseChatOrder: {type:Boolean, 'default':false}, - background: String, - displayInviteToPartyWhenPartyIs1: { type:Boolean, 'default':true}, - webhooks: {type: Schema.Types.Mixed, 'default': {}}, - // For this fields make sure to use strict comparison when searching for falsey values (=== false) - // As users who didn't login after these were introduced may have them undefined/null - emailNotifications: { - unsubscribeFromAll: {type: Boolean, 'default': false}, - newPM: {type: Boolean, 'default': true}, - kickedGroup: {type: Boolean, 'default': true}, - wonChallenge: {type: Boolean, 'default': true}, - giftedGems: {type: Boolean, 'default': true}, - giftedSubscription: {type: Boolean, 'default': true}, - invitedParty: {type: Boolean, 'default': true}, - invitedGuild: {type: Boolean, 'default': true}, - questStarted: {type: Boolean, 'default': true}, - invitedQuest: {type: Boolean, 'default': true}, - //remindersToLogin: {type: Boolean, 'default': true}, - // Those importantAnnouncements are in fact the recapture emails - importantAnnouncements: {type: Boolean, 'default': true}, - weeklyRecaps: {type: Boolean, 'default': true} - }, - suppressModals: { - levelUp: {type: Boolean, 'default': false}, - hatchPet: {type: Boolean, 'default': false}, - raisePet: {type: Boolean, 'default': false}, - streak: {type: Boolean, 'default': false} - }, - improvementCategories: { - type: Array, - validate: (categories) => { - const validCategories = ['work', 'exercise', 'healthWellness', 'school', 'teams', 'chores', 'creativity']; - let isValidCategory = categories.every(category => validCategories.indexOf(category) !== -1); - return isValidCategory; - }} - }, - profile: { - blurb: String, - imageUrl: String, - name: String - }, - stats: { - hp: {type: Number, 'default': shared.maxHealth}, - mp: {type: Number, 'default': 10}, - exp: {type: Number, 'default': 0}, - gp: {type: Number, 'default': 0}, - lvl: {type: Number, 'default': 1}, - - // Class System - 'class': {type: String, enum: ['warrior','rogue','wizard','healer'], 'default': 'warrior'}, - points: {type: Number, 'default': 0}, - str: {type: Number, 'default': 0}, - con: {type: Number, 'default': 0}, - int: {type: Number, 'default': 0}, - per: {type: Number, 'default': 0}, - buffs: { - str: {type: Number, 'default': 0}, - int: {type: Number, 'default': 0}, - per: {type: Number, 'default': 0}, - con: {type: Number, 'default': 0}, - stealth: {type: Number, 'default': 0}, - streaks: {type: Boolean, 'default': false}, - snowball: {type: Boolean, 'default': false}, - spookDust: {type: Boolean, 'default': false}, - shinySeed: {type: Boolean, 'default': false}, - seafoam: {type: Boolean, 'default': false} - }, - training: { - int: {type: Number, 'default': 0}, - per: {type: Number, 'default': 0}, - str: {type: Number, 'default': 0}, - con: {type: Number, 'default': 0} - } - }, - - tags: {type: [{ - _id: false, - id: { type: String, 'default': shared.uuid }, - name: String, - challenge: String - }]}, - - challenges: [{type: 'String', ref:'Challenge'}], - - inbox: { - newMessages: {type:Number, 'default':0}, - blocks: {type:Array, 'default':[]}, - messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist - optOut: {type:Boolean, 'default':false} - }, - - habits: {type:[TaskSchemas.HabitSchema]}, - dailys: {type:[TaskSchemas.DailySchema]}, - todos: {type:[TaskSchemas.TodoSchema]}, - rewards: {type:[TaskSchemas.RewardSchema]}, - - extra: Schema.Types.Mixed, - - pushDevices: {type: [{ - regId: {type: String}, - type: {type: String} - }],'default': []} - -}, { - collection: 'users', - strict: true, - minimize: false // So empty objects are returned -}); - -UserSchema.methods.deleteTask = function(tid) { - this.ops.deleteTask({params:{id:tid}},function(){}); // TODO remove this whole method, since it just proxies, and change all references to this method -} - -UserSchema.methods.toJSON = function() { - var doc = this.toObject(); - doc.id = doc._id; - - // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? - doc.filters = {}; - doc._tmp = this._tmp; // be sure to send down drop notifs - - return doc; -}; - -//UserSchema.virtual('tasks').get(function () { -// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); -// var tasks = _.object(_.pluck(tasks,'id'), tasks); -// return tasks; -//}); - -UserSchema.post('init', function(doc){ - shared.wrap(doc); -}) - -UserSchema.pre('save', function(next) { - - // Populate new users with default content - if (this.isNew){ - _populateDefaultsForNewUser(this); - } - - //this.markModified('tasks'); - if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { - this.preferences.dayStart = 0; - } - - if (!this.profile.name) { - var fb = this.auth.facebook; - this.profile.name = - (this.auth.local && this.auth.local.username) || - (fb && (fb.displayName || fb.name || fb.username || (fb.first_name && fb.first_name + ' ' + fb.last_name))) || - 'Anonymous'; - } - - // Determines if Beast Master should be awarded - var beastMasterProgress = shared.count.beastMasterProgress(this.items.pets); - if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) { - this.achievements.beastMaster = true; - } - - // Determines if Mount Master should be awarded - var mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts); - - if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) { - this.achievements.mountMaster = true; - } - - // Determines if Triad Bingo should be awarded - - var dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets); - var qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90; - - if (qualifiesForTriad || this.achievements.triadBingoCount > 0) { - this.achievements.triadBingo = true; - } - - // Enable weekly recap emails for old users who sign in - if(this.flags.lastWeeklyRecapDiscriminator){ - // Enable weekly recap emails in 24 hours - this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate(); - // Unset the field so this is run only once - this.flags.lastWeeklyRecapDiscriminator = undefined; - } - - // EXAMPLE CODE for allowing all existing and new players to be - // automatically granted an item during a certain time period: - // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) - // this.items.pets['JackOLantern-Base'] = 5; - - //our own version incrementer - if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; - this._v++; - - next(); -}); - -UserSchema.methods.unlink = function(options, cb) { - var cid = options.cid, keep = options.keep, tid = options.tid; - if (!cid) { - return cb("Could not remove challenge tasks. Please delete them manually."); - } - var self = this; - switch (keep) { - case 'keep': - self.tasks[tid].challenge = {}; - break; - case 'remove': - self.deleteTask(tid); - break; - case 'keep-all': - _.each(self.tasks, function(t){ - if (t.challenge && t.challenge.id == cid) { - t.challenge = {}; - } - }); - break; - case 'remove-all': - _.each(self.tasks, function(t){ - if (t.challenge && t.challenge.id == cid) { - self.deleteTask(t.id); - } - }) - break; - } - self.markModified('habits'); - self.markModified('dailys'); - self.markModified('todos'); - self.markModified('rewards'); - self.save(cb); -} - -function _populateDefaultsForNewUser(user) { - var taskTypes; - - if (user.registeredThrough === "habitica-web" || user.registeredThrough === "habitica-android") { - taskTypes = ['habits', 'dailys', 'todos', 'rewards', 'tags']; - - var tutorialCommonSections = [ - 'habits', - 'dailies', - 'todos', - 'rewards', - 'party', - 'pets', - 'gems', - 'skills', - 'classes', - 'tavern', - 'equipment', - 'items', - 'inviteParty', - ]; - - _.each(tutorialCommonSections, function(section) { - user.flags.tutorial.common[section] = true; - }); - } else { - taskTypes = ['todos', 'tags'] - - user.flags.showTour = false; - - var tourSections = [ - 'showTour', - 'intro', - 'classes', - 'stats', - 'tavern', - 'party', - 'guilds', - 'challenges', - 'market', - 'pets', - 'mounts', - 'hall', - 'equipment', - ]; - - _.each(tourSections, function(section) { - user.flags.tour[section] = -2; - }); - } - - _populateDefaultTasks(user, taskTypes); -} - -function _populateDefaultTasks (user, taskTypes) { - _.each(taskTypes, function(taskType){ - user[taskType] = _.map(shared.content.userDefaults[taskType], function(task){ - var newTask = _.cloneDeep(task); - - // Render task's text and notes in user's language - if(taskType === 'tags'){ - // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here - newTask.id = shared.uuid(); - newTask.name = newTask.name(user.preferences.language); - }else{ - newTask.text = newTask.text(user.preferences.language); - if(newTask.notes) { - newTask.notes = newTask.notes(user.preferences.language); - } - - if(newTask.checklist){ - newTask.checklist = _.map(newTask.checklist, function(checklistItem){ - checklistItem.text = checklistItem.text(user.preferences.language); - return checklistItem; - }); - } - } - - return newTask; - }); - }); -} - -module.exports.schema = UserSchema; -module.exports.model = mongoose.model("UserOld", UserSchema); -// Initially export an empty object so external requires will get -// the right object by reference when it's defined later -// Otherwise it would remain undefined if requested before the query executes -module.exports.mods = []; - -mongoose.model("User") - .find({'contributor.admin':true}) - .sort('-contributor.level -backer.npc profile.name') - .select('profile contributor backer') - .exec(function(err,mods){ - // Using push to maintain the reference to mods - module.exports.mods.push.apply(module.exports.mods, mods); -}); diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index 782c79bf89..9d694c3374 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -1,4 +1,4 @@ -/* eslint-disable no-console, no-unused-vars */ +/* eslint-disable no-console */ // Migrate users collection to new schema // This should run AFTER challenges migration @@ -9,12 +9,11 @@ console.log('Starting migrations/api_v3/users.js.'); +import Q from 'q'; +import MongoDB from 'mongodb'; import nconf from 'nconf'; import mongoose from 'mongoose'; -import MongoDB from 'mongodb'; -import Q from 'q'; - -const MongoClient = MongoDB.MongoClient; +import _ from 'lodash'; // Initialize configuration import setupNconf from '../../website/src/libs/api-v3/setupNconf'; @@ -23,38 +22,68 @@ setupNconf(); const MONGODB_OLD = nconf.get('MONGODB_OLD'); const MONGODB_NEW = nconf.get('MONGODB_NEW'); -// Initialize mongoose and connect to the database containing the old data -mongoose.Promise = Q.Promise; - -const mongooseDbInstance = mongoose.connect(MONGODB_OLD, { - replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, - server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, -}, (err) => { - if (err) throw err; - console.log(`Connected with Mongoose to ${MONGODB_OLD}.`); -}); +mongoose.Promise = Q.Promise; // otherwise mongoose models won't work // Load old and new models -const OldUserModel = require('./old_models/user').model; import { model as NewUser } from '../../website/src/models/user'; import * as Tasks from '../../website/src/models/task'; // To be defined later when MongoClient connects -let mongoDbInstance; +let mongoDbOldInstance; +let oldUserCollection; + +let mongoDbNewInstance; +let newUserCollection; +let newTaskCollection; async function processUser (_id) { - let oldUser = await OldUserModel - .findById(_id) - .lean() - .exec(); - - console.log(`Processing ${oldUser._id}.`); + let [oldUser] = await oldUserCollection + .find({_id}) + .limit(1) + .toArray(); let oldTasks = oldUser.habits.concat(oldUser.dailys).concat(oldUser.rewards).concat(oldUser.todos); oldUser.habits = oldUser.dailys = oldUser.rewards = oldUser.todos = undefined; - console.log(oldUser, oldTasks); -}; + oldUser.challenges = []; + oldUser.invitations.guilds = []; + oldUser.invitations.party = {}; + oldUser.party = {}; + oldUser.tags = oldUser.tags.map(tag => { + return { + _id: tag.id, + name: tag.name, + challenge: tag.challenge, + }; + }); + + let newUser = new NewUser(oldUser); + + let batchInsertTasks = newTaskCollection.initializeUnorderedBulkOp(); + oldTasks.forEach(oldTask => { + let newTask = new Tasks[oldTask.type](oldTask); + newTask.userId = newUser._id; + + newTask.challenge = {}; + if (!oldTask.text) newTask.text = 'text'; + newTask.tags = _.map(oldTask.tags, (tagPresent, tagId) => { + return tagPresent && tagId; + }); + + newUser.tasksOrder[`${oldTask.type}s`].push(newTask._id); + + // newTask.legacyId = oldTask.id; + + batchInsertTasks.insert(newTask.toObject()); + }); + + await Q.all([ + newUserCollection.insertOne(newUser.toObject()), + batchInsertTasks.execute(), + ]); + + console.log(`Saved user ${newUser._id} and their tasks.`); +} /* @@ -176,10 +205,25 @@ var processUser = function(gt) { }; */ -// Connect to the database for new data -MongoClient.connect(MONGODB_NEW, (err, dbInstance) => { - if (err) throw err; +// Connect to the databases +const MongoClient = MongoDB.MongoClient; - mongoDbInstance = dbInstance; - console.log(`Connected with MongoClient to ${MONGODB_NEW}.`); +Q.all([ + MongoClient.connect(MONGODB_OLD), + MongoClient.connect(MONGODB_NEW), +]) +.then(([oldInstance, newInstance]) => { + mongoDbOldInstance = oldInstance; + oldUserCollection = mongoDbOldInstance.collection('users'); + + mongoDbNewInstance = newInstance; + newUserCollection = mongoDbNewInstance.collection('users'); + newTaskCollection = mongoDbNewInstance.collection('tasks'); + + console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); + + return processUser(nconf.get('USER_ID')); +}) +.catch(err => { + throw err; }); From 12b05b94920fb63379acf8b16e471fb234035675 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 25 Apr 2016 13:51:08 +0200 Subject: [PATCH 714/976] v3: define migration spec and misc fixes --- migrations/api_v3/users.js | 9 +++++++-- website/src/models/challenge.js | 4 ++++ website/src/models/coupon.js | 3 +++ website/src/models/emailUnsubscription.js | 3 +++ website/src/models/group.js | 15 --------------- website/src/models/tag.js | 2 +- website/src/models/user.js | 8 ++++---- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index 9d694c3374..d7462e0af4 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -7,6 +7,10 @@ // It requires two environment variables: MONGODB_OLD and MONGODB_NEW +/* + tags must have a name +*/ + console.log('Starting migrations/api_v3/users.js.'); import Q from 'q'; @@ -72,9 +76,10 @@ async function processUser (_id) { newUser.tasksOrder[`${oldTask.type}s`].push(newTask._id); - // newTask.legacyId = oldTask.id; + let newTaskObject = newTask.toObject(); + newTaskObject.legacyId = oldTask.id; - batchInsertTasks.insert(newTask.toObject()); + batchInsertTasks.insert(newTaskObject); }); await Q.all([ diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 4e6826c4f7..b508b5bc07 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -24,6 +24,9 @@ let schema = new Schema({ group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, memberCount: {type: Number, default: 1}, prize: {type: Number, default: 0, min: 0}, // TODO no update? +}, { + strict: true, + minimize: false, // So empty objects are returned }); schema.plugin(baseModel, { @@ -153,6 +156,7 @@ schema.methods.addTasks = async function challengeAddTasks (tasks) { // Sync each user sequentially // TODO are we sure it's the best solution? + // use bulk ops? http://stackoverflow.com/questions/16726330/mongoose-mongodb-batch-insert for (let memberId of membersIds) { let updateTasksOrderQ = {$push: {}}; let toSave = []; diff --git a/website/src/models/coupon.js b/website/src/models/coupon.js index 4492e98690..5215f64888 100644 --- a/website/src/models/coupon.js +++ b/website/src/models/coupon.js @@ -13,6 +13,9 @@ import { export let schema = new mongoose.Schema({ event: {type: String, enum: ['wondercon', 'google_6mo']}, user: {type: String, ref: 'User'}, +}, { + strict: true, + minimize: false, // So empty objects are returned }); schema.plugin(baseModel, { diff --git a/website/src/models/emailUnsubscription.js b/website/src/models/emailUnsubscription.js index 4d0594c0c5..d2ce4292cc 100644 --- a/website/src/models/emailUnsubscription.js +++ b/website/src/models/emailUnsubscription.js @@ -15,6 +15,9 @@ export let schema = new mongoose.Schema({ lowercase: true, // TODO migrate existing to lowerCase validator: [validator.isEmail, 'Invalid email.'], }, +}, { + strict: true, + minimize: false, // So empty objects are returned }); export let model = mongoose.model('EmailUnsubscription', schema); diff --git a/website/src/models/group.js b/website/src/models/group.js index 6f2b2a0b14..ac9abd13fc 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -91,21 +91,6 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { // Basic fields to fetch for populating a group info export let basicFields = 'name type privacy'; -// TODO migration -/** - * Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration - * to remove duplicates, then take these fucntions out - */ -/* function removeDuplicates(doc){ - // Remove duplicate members - if (doc.members) { - var uniqMembers = _.uniq(doc.members); - if (uniqMembers.length != doc.members.length) { - doc.members = uniqMembers; - } - } -}*/ - // TODO test schema.pre('remove', true, async function preRemoveGroup (next, done) { next(); diff --git a/website/src/models/tag.js b/website/src/models/tag.js index dadc84efc2..3159ef5fc7 100644 --- a/website/src/models/tag.js +++ b/website/src/models/tag.js @@ -7,8 +7,8 @@ export let schema = new Schema({ name: {type: String, required: true}, challenge: {type: String}, }, { - minimize: true, // So empty objects are returned strict: true, + minimize: false, // So empty objects are returned }); schema.plugin(baseModel, { diff --git a/website/src/models/user.js b/website/src/models/user.js index 5861ec89b0..b7d8ca3903 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -115,10 +115,10 @@ export let schema = new Schema({ }, balance: {type: Number, default: 0}, - // Not saved on the user TODO remove with migration - /* filters: {type: Schema.Types.Mixed, default: () => { + // Not saved on the user right now + filters: {type: Schema.Types.Mixed, default: () => { return {}; - }}, */ + }}, purchased: { ads: {type: Boolean, default: false}, @@ -524,7 +524,7 @@ export let schema = new Schema({ }, }, { strict: true, - minimize: false, // So empty objects are returned TODO make sure it's in every model + minimize: false, // So empty objects are returned }); schema.plugin(baseModel, { From 6cad3616f375e9ed6cb652080d69621c39b97c86 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 25 Apr 2016 13:53:42 +0200 Subject: [PATCH 715/976] v3: define migration spec (add files) --- migrations/api_v3/challenges.js | 11 +++++ migrations/api_v3/emailUnsubscriptions.js | 4 ++ migrations/api_v3/groups.js | 17 ++++++++ migrations/api_v3/indexes.js | 52 +++++++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 migrations/api_v3/challenges.js create mode 100644 migrations/api_v3/emailUnsubscriptions.js create mode 100644 migrations/api_v3/groups.js create mode 100644 migrations/api_v3/indexes.js diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js new file mode 100644 index 0000000000..f7b2184fd4 --- /dev/null +++ b/migrations/api_v3/challenges.js @@ -0,0 +1,11 @@ +/* + name is required, + shortName is required, + tasksOrder + habits, dailys, todos and rewards must be removed + leader is required + group is required + members must be removed + memberCount must be checked + prize must be >= 0 +*/ diff --git a/migrations/api_v3/emailUnsubscriptions.js b/migrations/api_v3/emailUnsubscriptions.js new file mode 100644 index 0000000000..27eb8a8d61 --- /dev/null +++ b/migrations/api_v3/emailUnsubscriptions.js @@ -0,0 +1,4 @@ +/* + email must be lowercase + remove unique: true from mongoose schema +*/ diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js new file mode 100644 index 0000000000..1b364fba65 --- /dev/null +++ b/migrations/api_v3/groups.js @@ -0,0 +1,17 @@ +/* + name is required + leader is required + type is required + privacy is required + leaderOnly.challenges is required + members are not stored anymore + invites are not stored anymore + challenges are not stored anymore + balance > 0 + memberCount must be checked + challengeCount must be checked + quest.leader must be present (default to party leader) + quest.key must be valid (otherwise remove) + + tavern id and leader must be updated +*/ diff --git a/migrations/api_v3/indexes.js b/migrations/api_v3/indexes.js new file mode 100644 index 0000000000..4944e375ec --- /dev/null +++ b/migrations/api_v3/indexes.js @@ -0,0 +1,52 @@ +/* + DEFINE BEFORE MIGRATING + + tasks: userId (sparse?), challenge.id (sparse), challenge.taskId (sparse), type? completed? + users: + id & apiToken?, + auth.facebook.emails.value -> unique and sparse?, + auth.facebook.id - unique and sparse, + auth.local.email - unique and sparse, + auth.local.lowerCaseUsername, + auth.local.username - unique and sparse + auth.local.username & auth.local.hashed_password?, + auth.timestamps.created?, + auth.timestamps.loggedin?, + backer.tier -1 + { "contributor.admin" : 1 , "contributor.level" : -1 , "backer.npc" : -1 , "profile.name" : 1} + { "contributor.admin" : 1.0} + { "contributor.level" : 1.0} + { "contributor.level" : 1.0 , "purchased.plan.customerId" : 1.0} ? + { "flags.lastWeeklyRecap" : 1 , "_id" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "preferences.emailNotifications.weeklyRecaps" : 1} + { "invitations.guilds.id" : 1} + { "invitations.party.id" : 1} + { "preferences.sleep" : 1 , "_id" : 1 , "flags.lastWeeklyRecap" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "preferences.emailNotifications.weeklyRecaps" : 1} + { "preferences.sleep" : 1 , "_id" : 1 , "lastCron" : 1 , "preferences.emailNotifications.importantAnnouncements" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "flags.recaptureEmailsPhase" : 1} + profile.name ? + { "purchased.plan.customerId" : 1.0} + { "purchased.plan.paymentMethod" : 1.0} + + guilds + party.id + challenges + challenges: + { "_id" : 1.0 , "__v" : 1.0} ? + { "_id" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} + { "group" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} + { "leader" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} + { "members" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} ? + { "official" : -1 , "timestamp" : -1} + { "official" : -1 , "timestamp" : -1, "_id": 1} ? + groups: + { "_id" : 1 , "quest.key" : 1} + { "_id" : 1.0 , "__v" : 1.0} ? + { "_id" : 1.0 , "privacy" : 1.0 , "members" : 1.0} ? + { "members" : 1.0 , "type" : 1.0 , "memberCount" : -1.0} ? + { "members" : 1} ? + { "privacy" : 1.0 , "memberCount" : -1.0} ? + { "privacy" : 1.0} ? + { "type" : 1 , "privacy" : 1} ? + { "type" : 1.0 , "members" : 1.0} ? + { "type" : 1} ? + emailUnsubscriptions: email unique +*/ From 5b217ec3db4dc97bd4b5e1cf49d46dfaf5f2df7d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 25 Apr 2016 14:21:00 +0200 Subject: [PATCH 716/976] v3: fix setting challenge.official --- website/src/controllers/api-v3/challenges.js | 3 ++- website/src/models/challenge.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 8ea8558fb3..dcd0af5c66 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -83,7 +83,7 @@ api.createChallenge = { group.challengeCount += 1; req.body.leader = user._id; - req.body.official = user.contributor.admin && req.body.official; + req.body.official = user.contributor.admin && req.body.official ? true : false; let challenge = new Challenge(Challenge.sanitize(req.body)); // First validate challenge so we don't save group if it's invalid (only runs sync validators) @@ -108,6 +108,7 @@ api.createChallenge = { type: group.type, privacy: group.privacy, }; + res.respond(201, response); }, }; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index b508b5bc07..4919862700 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -23,7 +23,7 @@ let schema = new Schema({ leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, memberCount: {type: Number, default: 1}, - prize: {type: Number, default: 0, min: 0}, // TODO no update? + prize: {type: Number, default: 0, min: 0}, }, { strict: true, minimize: false, // So empty objects are returned From d6ea0a3b4031d7400d939cf736ff189c13f33d75 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 25 Apr 2016 14:34:59 +0200 Subject: [PATCH 717/976] v3: fix setting challenge.official --- test/api/v3/integration/challenges/POST-challenges.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js index 1b80266ab7..e296403f4e 100644 --- a/test/api/v3/integration/challenges/POST-challenges.test.js +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -236,7 +236,7 @@ describe('POST /challenges', () => { official: true, }); - expect(challenge.official).to.be.undefined; + expect(challenge.official).to.eql(false); }); it('returns an error when challenge validation fails; doesn\'s save user or group', async () => { @@ -284,7 +284,7 @@ describe('POST /challenges', () => { expect(challenge.name).to.eql(name); expect(challenge.shortName).to.eql(shortName); expect(challenge.description).to.eql(description); - expect(challenge.official).to.be.undefined; + expect(challenge.official).to.eql(false); expect(challenge.group).to.eql({ _id: group._id, privacy: group.privacy, From ea490c9a1fedf53a96564e7d6970f8c04f9c810c Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 25 Apr 2016 16:11:23 -0500 Subject: [PATCH 718/976] Ported groups service to user new api v3 and ported dependent controllers (#7108) * Ported groups service to user new api v3 and ported dependent controllers * Remove and extra remove inviation code. Fixed group service caching and update group service tests * Fixed test logic and added party cache support * Added promise rejections and updated http interceptor --- common/browserify.js | 2 + common/script/public/config.js | 16 +- common/script/public/userServices.js | 8 +- test/spec/controllers/groupCtrlSpec.js | 51 +++- test/spec/controllers/partyCtrlSpec.js | 28 ++- test/spec/services/groupServicesSpec.js | 125 +++++++++- test/spec/specHelper.js | 3 + website/public/js/app.js | 9 +- website/public/js/controllers/groupsCtrl.js | 31 ++- website/public/js/controllers/guildsCtrl.js | 133 ++++++---- .../js/controllers/inviteToGroupCtrl.js | 21 +- website/public/js/controllers/partyCtrl.js | 106 ++++---- website/public/js/services/groupServices.js | 234 +++++++++++++----- website/src/controllers/api-v3/groups.js | 5 +- .../social/party/party-invitation.jade | 3 +- 15 files changed, 565 insertions(+), 210 deletions(-) diff --git a/common/browserify.js b/common/browserify.js index 0530144839..3653cb81b0 100644 --- a/common/browserify.js +++ b/common/browserify.js @@ -1,3 +1,5 @@ +require('babel-polyfill'); + var shared = require('./script/index'); var _ = require('lodash'); var moment = require('moment'); diff --git a/common/script/public/config.js b/common/script/public/config.js index bb78e244bd..53e5f19091 100644 --- a/common/script/public/config.js +++ b/common/script/public/config.js @@ -1,5 +1,7 @@ 'use strict'; -angular.module('habitrpg').config(['$httpProvider', function($httpProvider){ + +angular.module('habitrpg') +.config(['$httpProvider', function($httpProvider){ $httpProvider.interceptors.push(['$q', '$rootScope', function($q, $rootScope){ return { response: function(response) { @@ -28,15 +30,15 @@ angular.module('habitrpg').config(['$httpProvider', function($httpProvider){ // 400 range? } else if (response.status < 500) { - $rootScope.$broadcast('responseText', response.data.err || response.data); + $rootScope.$broadcast('responseText', response.data.err || response.data.message); // Need to reject the prompse so the error is handled correctly - if (response.status === 401) + if (response.status === 401) { return $q.reject(response); - + } // Error } else { - var error = window.env.t('requestError') + '

"' + - window.env.t('error') + ' ' + (response.data.err || response.data || 'something went wrong') + + var error = window.env.t('requestError') + '

"' + + window.env.t('error') + ' ' + (response.data.err || response.data || 'something went wrong') + '"

' + window.env.t('seeConsole'); if (mobileApp) error = 'Error contacting the server. Please try again in a few minutes.'; $rootScope.$broadcast('responseError', error); @@ -47,4 +49,4 @@ angular.module('habitrpg').config(['$httpProvider', function($httpProvider){ } }; }]); -}]); \ No newline at end of file +}]); diff --git a/common/script/public/userServices.js b/common/script/public/userServices.js index e5b1790086..4fb92ce42a 100644 --- a/common/script/public/userServices.js +++ b/common/script/public/userServices.js @@ -102,14 +102,14 @@ angular.module('habitrpg') op(req,function(err,response) { for(var updatedItem in req.body) { var itemUpdateResponse = userNotifications[updatedItem]; - if(itemUpdateResponse) Notification.text(itemUpdateResponse); + if(itemUpdateResponse) Notification.text(itemUpdateResponse.data.message); } if (err) { - var message = err.code ? err.message : err; - if (MOBILE_APP) Notification.push({type:'text',text:message}); + var message = err.code ? err.data.message : err; + if (MOBILE_APP) Notification.push({type:'text', text: message}); else Notification.text(message); // In the case of 200s, they're friendly alert messages like "Your pet has hatched!" - still send the op - if ((err.code && err.code >= 400) || !err.code) return; + if ((err.code && err.code >= 400) || !err.code) return; } userServices.log({op:k, params: req.params, query:req.query, body:req.body}); }); diff --git a/test/spec/controllers/groupCtrlSpec.js b/test/spec/controllers/groupCtrlSpec.js index dce054bef6..da00e2ba73 100644 --- a/test/spec/controllers/groupCtrlSpec.js +++ b/test/spec/controllers/groupCtrlSpec.js @@ -23,6 +23,53 @@ describe('Groups Controller', function() { }); }); + describe("isMemberOfPendingQuest", function() { + var party; + var partyStub; + + beforeEach(function () { + party = specHelper.newGroup({ + _id: "unique-party-id", + type: 'party', + members: ['leader-id'] // Ensure we wouldn't pass automatically. + }); + + partyStub = sandbox.stub(groups, "party", function() { + return party; + }); + }); + + it("returns false if group is does not have a quest", function() { + expect(scope.isMemberOfPendingQuest(user._id, party)).to.not.be.ok; + }); + + it("returns false if group quest has not members", function() { + party.quest = { + 'key': 'random-key', + }; + expect(scope.isMemberOfPendingQuest(user._id, party)).to.not.be.ok; + }); + + it("returns false if group quest is active", function() { + party.quest = { + 'key': 'random-key', + 'members': {}, + 'active': true, + }; + party.quest.members[user._id] = true; + expect(scope.isMemberOfPendingQuest(user._id, party)).to.not.be.ok; + }); + + it("returns true if user is a member of a pending quest", function() { + party.quest = { + 'key': 'random-key', + 'members': {}, + }; + party.quest.members[user._id] = true; + expect(scope.isMemberOfPendingQuest(user._id, party)).to.be.ok; + }); + }); + describe("isMemberOfGroup", function() { it("returns true if group is the user's party retrieved from groups service", function() { var party = specHelper.newGroup({ @@ -31,7 +78,7 @@ describe('Groups Controller', function() { members: ['leader-id'] // Ensure we wouldn't pass automatically. }); - var partyStub = sandbox.stub(groups,"party", function() { + var partyStub = sandbox.stub(groups, "party", function() { return party; }); @@ -46,7 +93,7 @@ describe('Groups Controller', function() { members: [user._id] }); - var myGuilds = sandbox.stub(groups,"myGuilds", function() { + var myGuilds = sandbox.stub(groups, "myGuilds", function() { return [guild]; }); diff --git a/test/spec/controllers/partyCtrlSpec.js b/test/spec/controllers/partyCtrlSpec.js index 92401f1987..623d63a74e 100644 --- a/test/spec/controllers/partyCtrlSpec.js +++ b/test/spec/controllers/partyCtrlSpec.js @@ -2,6 +2,7 @@ describe("Party Controller", function() { var scope, ctrl, user, User, questsService, groups, rootScope, $controller; + var party; beforeEach(function() { user = specHelper.newUser(), @@ -10,7 +11,13 @@ describe("Party Controller", function() { user: user, sync: sandbox.spy(), set: sandbox.spy() - } + }; + + party = specHelper.newGroup({ + _id: "unique-party-id", + type: 'party', + members: ['leader-id'] // Ensure we wouldn't pass automatically. + }); module(function($provide) { $provide.value('User', User); @@ -136,6 +143,25 @@ describe("Party Controller", function() { }); }); + describe("create", function() { + var partyStub; + + beforeEach(function () { + partyStub = sandbox.stub(groups.Group, "create", function() { + return party; + }); + }); + + it("creates a new party", function() { + var group = { + type: 'party', + }; + scope.create(group); + expect(partyStub).to.be.calledOnce; + //@TODO: Check user party console.log(User.user.party.id) + }); + }); + describe('questAccept', function() { beforeEach(function() { scope.group = { diff --git a/test/spec/services/groupServicesSpec.js b/test/spec/services/groupServicesSpec.js index e9a9285265..065af0b233 100644 --- a/test/spec/services/groupServicesSpec.js +++ b/test/spec/services/groupServicesSpec.js @@ -2,6 +2,7 @@ describe('groupServices', function() { var $httpBackend, $http, groups, user; + var groupApiUrlPrefix = '/api/v3/groups'; beforeEach(function() { module(function($provide) { @@ -16,27 +17,141 @@ describe('groupServices', function() { }); }); + it('calls get groups', function() { + $httpBackend.expectGET(groupApiUrlPrefix).respond({}); + groups.Group.getGroups(); + $httpBackend.flush(); + }); + + it('calls get group', function() { + var gid = 1; + $httpBackend.expectGET(groupApiUrlPrefix + '/' + gid).respond({}); + groups.Group.get(gid); + $httpBackend.flush(); + }); + it('calls party endpoint', function() { - $httpBackend.expectGET('/api/v2/groups/party').respond({}); + $httpBackend.expectGET(groupApiUrlPrefix + '/party').respond({}); + groups.Group.syncParty(); + $httpBackend.flush(); + }); + + it('calls create endpoint', function() { + $httpBackend.expectPOST(groupApiUrlPrefix).respond({}); + groups.Group.create({}); + $httpBackend.flush(); + }); + + it('calls update group', function() { + var gid = 1; + var groupDetails = { _id: gid }; + $httpBackend.expectPUT(groupApiUrlPrefix + '/' + gid).respond({}); + groups.Group.update(groupDetails); + $httpBackend.flush(); + }); + + it('calls join group', function() { + var gid = 1; + $httpBackend.expectPOST(groupApiUrlPrefix + '/' + gid + '/join').respond({}); + groups.Group.join(gid); + $httpBackend.flush(); + }); + + it('calls reject invite group', function() { + var gid = 1; + $httpBackend.expectPOST(groupApiUrlPrefix + '/' + gid + '/reject-invite').respond({}); + groups.Group.rejectInvite(gid); + $httpBackend.flush(); + }); + + it('calls invite group', function() { + var gid = 1; + $httpBackend.expectPOST(groupApiUrlPrefix + '/' + gid + '/invite').respond({}); + groups.Group.invite(gid, [], []); + $httpBackend.flush(); + }); + + it('calls party endpoint when party is not cached', function() { + $httpBackend.expectGET(groupApiUrlPrefix + '/party').respond({}); groups.party(); $httpBackend.flush(); }); - it('calls tavern endpoint', function() { - $httpBackend.expectGET('/api/v2/groups/habitrpg').respond({}); + it('returns party if cached', function (done) { + var uid = 'abc'; + var party = { + _id: uid, + }; + groups.data.party = party; + groups.party() + .then(function (result) { + expect(result).to.eql(party); + done(); + }); + $httpBackend.flush(); + }); + + it('calls tavern endpoint when tavern is not cached', function() { + $httpBackend.expectGET(groupApiUrlPrefix + '/habitrpg').respond({}); groups.tavern(); $httpBackend.flush(); }); + it('returns tavern if cached', function (done) { + var uid = 'abc'; + var tavern = { + _id: uid, + }; + groups.data.tavern = tavern; + groups.tavern() + .then(function (result) { + expect(result).to.eql(tavern); + done(); + }); + $httpBackend.flush(); + }); + it('calls public guilds endpoint', function() { - $httpBackend.expectGET('/api/v2/groups?type=public').respond([]); + $httpBackend.expectGET(groupApiUrlPrefix + '?type=publicGuilds').respond([]); groups.publicGuilds(); $httpBackend.flush(); }); + it('returns public guilds if cached', function (done) { + var uid = 'abc'; + var publicGuilds = [ + {_id: uid}, + ]; + groups.data.publicGuilds = publicGuilds; + + groups.publicGuilds() + .then(function (result) { + expect(result).to.eql(publicGuilds); + done(); + }); + + $httpBackend.flush(); + }); + it('calls my guilds endpoint', function() { - $httpBackend.expectGET('/api/v2/groups?type=guilds').respond([]); + $httpBackend.expectGET(groupApiUrlPrefix + '?type=privateGuilds').respond([]); groups.myGuilds(); $httpBackend.flush(); }); + + it('returns my guilds if cached', function (done) { + var uid = 'abc'; + var myGuilds = [ + {_id: uid}, + ]; + groups.data.myGuilds = myGuilds; + + groups.myGuilds() + .then(function (myGuilds) { + expect(myGuilds).to.eql(myGuilds); + done(); + }); + + $httpBackend.flush(); + }); }); diff --git a/test/spec/specHelper.js b/test/spec/specHelper.js index 9324fa5d8b..cbacac1988 100644 --- a/test/spec/specHelper.js +++ b/test/spec/specHelper.js @@ -28,6 +28,9 @@ var specHelper = {}; var user = { _id: 'unique-user-id', + profile: { + name: 'dummy-name', + }, auth: { timestamps: {} }, stats: stats, items: items, diff --git a/website/public/js/app.js b/website/public/js/app.js index 6b39f45d63..67215bc810 100644 --- a/website/public/js/app.js +++ b/website/public/js/app.js @@ -152,10 +152,11 @@ window.habitrpg = angular.module('habitrpg', title: env.t('titleGuilds'), controller: ['$scope', 'Groups', 'Chat', '$stateParams', function($scope, Groups, Chat, $stateParams){ - Groups.Group.get({gid:$stateParams.gid}, function(group){ - $scope.group = group; - Chat.seenMessage(group._id); - }); + Groups.Group.get($stateParams.gid) + .then(function (response) { + $scope.group = response.data.data; + Chat.seenMessage($scope.group._id); + }); }] }) diff --git a/website/public/js/controllers/groupsCtrl.js b/website/public/js/controllers/groupsCtrl.js index e7865afb55..224efc6fdb 100644 --- a/website/public/js/controllers/groupsCtrl.js +++ b/website/public/js/controllers/groupsCtrl.js @@ -3,24 +3,24 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', 'Notification', function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state, Notification) { - $scope.isMemberOfPendingQuest = function(userid, group) { + $scope.isMemberOfPendingQuest = function (userid, group) { if (!group.quest || !group.quest.members) return false; if (group.quest.active) return false; // quest is started, not pending return userid in group.quest.members && group.quest.members[userid] != false; }; - $scope.isMemberOfRunningQuest = function(userid, group) { + $scope.isMemberOfRunningQuest = function (userid, group) { if (!group.quest || !group.quest.members) return false; if (!group.quest.active) return false; // quest is pending, not started return group.quest.members[userid]; }; - $scope.isMemberOfGroup = function(userid, group){ - + $scope.isMemberOfGroup = function (userid, group) { // If the group is a guild, just check for an intersection with the // current user's guilds, rather than checking the members of the group. if(group.type === 'guild') { - return _.detect(Groups.myGuilds(), function(g) { return g._id === group._id }); + var guilds = Groups.myGuilds(); + return _.detect(guilds, function(g) { return g._id === group._id }); } // Similarly, if we're dealing with the user's current party, return true. @@ -34,7 +34,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' return ~(memberIds.indexOf(userid)); }; - $scope.isMember = function(user, group){ + $scope.isMember = function (user, group) { return ~(group.members.indexOf(user._id)); }; @@ -47,7 +47,6 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' group._editing = true; }; - $scope.saveEdit = function (group) { var newLeader = $scope.groupCopy._newLeader && $scope.groupCopy._newLeader._id; @@ -57,7 +56,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' angular.copy($scope.groupCopy, group); - group.$save(); + Groups.Group.update(group); $scope.cancelEdit(group); }; @@ -91,7 +90,6 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } }; - $scope.removeMember = function(group, member, isMember){ // TODO find a better way to do this (share data with remove member modal) $scope.removeMemberData = { @@ -103,12 +101,12 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' }; $scope.confirmRemoveMember = function(confirm){ - if(confirm){ - Groups.Group.removeMember({ - gid: $scope.removeMemberData.group._id, - uuid: $scope.removeMemberData.member._id, - message: $scope.removeMemberData.message, - }, undefined, function(){ + if (confirm) { + Groups.Group.removeMember( + $scope.removeMemberData.group._id, + $scope.removeMemberData.member._id, + $scope.removeMemberData.message + ).then(function (response) { if($scope.removeMemberData.isMember){ _.pull($scope.removeMemberData.group.members, $scope.removeMemberData.member); }else{ @@ -117,7 +115,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.removeMemberData = undefined; }); - }else{ + } else { $scope.removeMemberData = undefined; } }; @@ -141,5 +139,4 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $rootScope.openModal('private-message',{controller:'MemberModalCtrl'}); }); } - }]); diff --git a/website/public/js/controllers/guildsCtrl.js b/website/public/js/controllers/guildsCtrl.js index df6b001448..70493071c0 100644 --- a/website/public/js/controllers/guildsCtrl.js +++ b/website/public/js/controllers/guildsCtrl.js @@ -4,41 +4,71 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$r function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile, Analytics) { $scope.groups = { guilds: Groups.myGuilds(), - "public": Groups.publicGuilds() - } + public: Groups.publicGuilds(), + }; + + Groups.myGuilds() + .then(function (guilds) { + $scope.groups.guilds = guilds; + }); + + Groups.publicGuilds() + .then(function (guilds) { + $scope.groups.public = guilds; + }); + $scope.type = 'guild'; $scope.text = window.env.t('guild'); + var newGroup = function(){ - return new Groups.Group({type:'guild', privacy:'private'}); + return {type:'guild', privacy:'private'}; } $scope.newGroup = newGroup() + $scope.create = function(group){ - if (User.user.balance < 1) + if (User.user.balance < 1) { return $rootScope.openModal('buyGems', {track:"Gems > Create Group"}); + } if (confirm(window.env.t('confirmGuild'))) { - group.$save(function(saved){ - if (saved.privacy == 'public') {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':true,'groupType':'guild','privacy':saved.privacy,'groupName':saved.name})} - else {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':true,'groupType':'guild','privacy':saved.privacy})} - $rootScope.hardRedirect('/#/options/groups/guilds/' + saved._id); - }); + Groups.Group.create(group) + .then(function (response) { + var createdGroup = response.data.data; + if (createdGroup.privacy == 'public') { + Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'guild', 'privacy': createdGroup.privacy, 'groupName':createdGroup.name}) + } else { + Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'guild', 'privacy': createdGroup.privacy}) + } + $rootScope.hardRedirect('/#/options/groups/guilds/' + createdGroup._id); + }); } } - $scope.join = function(group){ - // If we're accepting an invitation, we don't have the actual group object, but a faux group object (for performance - // purposes) {id, name}. Let's trick ngResource into thinking we have a group, so we can call the same $join - // function (server calls .attachGroup(), which finds group by _id and handles this properly) + $scope.join = function (group) { + var groupId = group._id; + + // If we don't have the _id property, we are joining from an invitation + // which contains a id property of the group if (group.id && !group._id) { - group = new Groups.Group({_id:group.id}); + groupId = group.id; } - group.$join(function(joined){ - if (joined.privacy == 'public') {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'guild','privacy':joined.privacy,'groupName':joined.name})} - else {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'guild','privacy':joined.privacy})} - $rootScope.hardRedirect('/#/options/groups/guilds/' + joined._id); - }) + Groups.Group.join(groupId) + .then(function (response) { + var joinedGroup = response.data.data; + + if (joinedGroup.privacy == 'public') { + Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':false, 'groupType':'guild','privacy': joinedGroup.privacy, 'groupName': joinedGroup.name}) + } else { + Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':false, 'groupType':'guild','privacy': joinedGroup.privacy}) + } + $rootScope.hardRedirect('/#/options/groups/guilds/' + joinedGroup._id); + }); + } + + $scope.reject = function(guild) { + Groups.Group.rejectInvite(guild.id); } $scope.leave = function(keep) { @@ -46,49 +76,44 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$r $scope.selectedGroup = undefined; $scope.popoverEl.popover('destroy'); } else { - Groups.Group.leave({gid: $scope.selectedGroup._id, keep:keep}, undefined, function(){ + Groups.Group.leave($scope.selectedGroup._id, keep) + .success(function (data) { $rootScope.hardRedirect('/#/options/groups/guilds'); - }); + }); } } $scope.clickLeave = function(group, $event){ - $scope.selectedGroup = group; - $scope.popoverEl = $($event.target).closest('.btn'); - var html, title; - Challenges.Challenge.query(function(challenges) { - challenges = _.pluck(_.filter(challenges, function(c) { - return c.group._id == group._id; - }), '_id'); + $scope.selectedGroup = group; + $scope.popoverEl = $($event.target).closest('.btn'); - if (_.intersection(challenges, User.user.challenges).length > 0) { - html = $compile( - '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' - )($scope); - title = window.env.t('leaveGroupCha'); - } else { - html = $compile( - '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' - )($scope); - title = window.env.t('leaveGroup') - } + var html, title; - $scope.popoverEl.popover('destroy').popover({ - html: true, - placement: 'top', - trigger: 'manual', - title: title, - content: html - }).popover('show'); - }); - } + Challenges.Challenge.query(function(challenges) { + challenges = _.pluck(_.filter(challenges, function(c) { + return c.group._id == group._id; + }), '_id'); - $scope.reject = function(guild){ - var i = _.findIndex(User.user.invitations.guilds, {id:guild.id}); - if (~i){ - User.user.invitations.guilds.splice(i, 1); - User.set({'invitations.guilds':User.user.invitations.guilds}); - } + if (_.intersection(challenges, User.user.challenges).length > 0) { + html = $compile( + '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leaveGroupCha'); + } else { + html = $compile( + '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leaveGroup') + } + + $scope.popoverEl.popover('destroy').popover({ + html: true, + placement: 'top', + trigger: 'manual', + title: title, + content: html + }).popover('show'); + }); } } ]); diff --git a/website/public/js/controllers/inviteToGroupCtrl.js b/website/public/js/controllers/inviteToGroupCtrl.js index f4a74dbac9..ca155d8b98 100644 --- a/website/public/js/controllers/inviteToGroupCtrl.js +++ b/website/public/js/controllers/inviteToGroupCtrl.js @@ -1,6 +1,7 @@ 'use strict'; -habitrpg.controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedGroup', '$http', 'Notification', function($scope, User, Groups, injectedGroup, $http, Notification) { +habitrpg.controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedGroup', '$http', 'Notification', + function($scope, User, Groups, injectedGroup, $http, Notification) { $scope.group = injectedGroup; $scope.inviter = User.user.profile.name; @@ -17,8 +18,9 @@ habitrpg.controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedG $scope.inviteNewUsers = function(inviteMethod) { if (!$scope.group._id) { $scope.group.name = $scope.group.name || env.t('possessiveParty', {name: User.user.profile.name}); - return $scope.group.$save() - .then(function(res) { + return Groups.Group.create($scope.group) + .then(function(response) { + $scope.group = response.data.data; _inviteByMethod(inviteMethod); }); } @@ -39,12 +41,13 @@ habitrpg.controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedG return console.log('Invalid invite method.') } - Groups.Group.invite({gid: $scope.group._id}, invitationDetails, function(){ - Notification.text(window.env.t('invitationsSent')); - _resetInvitees(); - }, function(){ - _resetInvitees(); - }); + Groups.Group.invite($scope.group._id, invitationDetails) + .then(function() { + Notification.text(window.env.t('invitationsSent')); + _resetInvitees(); + }, function(){ + _resetInvitees(); + }); } function _getOnlyUuids() { diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js index 6494679911..022d4044a7 100644 --- a/website/public/js/controllers/partyCtrl.js +++ b/website/public/js/controllers/partyCtrl.js @@ -1,20 +1,36 @@ 'use strict'; habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','Challenges','$state','$compile','Analytics','Quests','Social', - function($rootScope,$scope,Groups,Chat,User,Challenges,$state,$compile,Analytics,Quests,Social) { + function($rootScope, $scope, Groups, Chat, User, Challenges, $state, $compile, Analytics, Quests, Social) { var user = User.user; $scope.type = 'party'; $scope.text = window.env.t('party'); - $scope.group = $rootScope.party = Groups.party(); - $scope.newGroup = new Groups.Group({type:'party'}); + + //@TODO: cache + Groups.Group.syncParty() + .then(function successCallback(response) { + $scope.group = response.data.data; + checkForNotifications(); + }, function errorCallback(response) { + $scope.newGroup = $scope.group = { type: 'party' }; + }); + $scope.inviteOrStartParty = Groups.inviteOrStartParty; $scope.loadWidgets = Social.loadWidgets; if ($state.is('options.social.party')) { - $scope.group.$syncParty(); // Sync party automatically when navigating to party page - + Groups.Group.syncParty() + .then(function successCallback(response) { + $scope.group = response.data.data; + checkForNotifications(); + }, function errorCallback(response) { + $scope.newGroup = { type: 'party' }; + }); + } + // Chat.seenMessage($scope.group._id); + function checkForNotifications () { // Checks if user's party has reached 2 players for the first time. if(!user.achievements.partyUp && $scope.group.memberCount >= 2) { @@ -30,36 +46,35 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' } } - Chat.seenMessage($scope.group._id); - - $scope.create = function(group){ + $scope.create = function (group) { if (!group.name) group.name = env.t('possessiveParty', {name: User.user.profile.name}); - group.$save(function(){ - Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':true,'groupType':'party','privacy':'private'}); - Analytics.updateUser({'partyID':group.id,'partySize':1}); + Groups.Group.create(group, function() { + Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'party', 'privacy':'private'}); + Analytics.updateUser({'party.id': group.id, 'partySize': 1}); $rootScope.hardRedirect('/#/options/groups/party'); }); }; - $scope.join = function(party){ - var group = new Groups.Group({_id: party.id, name: party.name}); - group.$join(function(){ - Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'party','privacy':'private'}); - Analytics.updateUser({'partyID':party.id}); - $rootScope.hardRedirect('/#/options/groups/party'); - }); + $scope.join = function (party) { + Groups.Group.join(party.id) + .then(function (response) { + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'party','privacy':'private'}); + Analytics.updateUser({'partyID': party.id}); + $rootScope.hardRedirect('/#/options/groups/party'); + }); }; // TODO: refactor guild and party leave into one function - $scope.leave = function(keep) { + $scope.leave = function (keep) { if (keep == 'cancel') { $scope.selectedGroup = undefined; $scope.popoverEl.popover('destroy'); } else { - Groups.Group.leave({gid: $scope.selectedGroup._id, keep:keep}, undefined, function(){ - Analytics.updateUser({'partySize':null,'partyID':null}); - $rootScope.hardRedirect('/#/options/groups/party'); - }); + Groups.Group.leave($scope.selectedGroup._id, keep) + .then(function (response) { + Analytics.updateUser({'partySize':null,'partyID':null}); + $rootScope.hardRedirect('/#/options/groups/party'); + }); } }; @@ -69,21 +84,27 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.selectedGroup = group; $scope.popoverEl = $($event.target).closest('.btn'); var html, title; - Challenges.Challenge.query(function(challenges) { - challenges = _.pluck(_.filter(challenges, function(c) { - return c.group._id == group._id; - }), '_id'); - if (_.intersection(challenges, User.user.challenges).length > 0) { - html = $compile( - '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' - )($scope); - title = window.env.t('leavePartyCha'); - } else { - html = $compile( - '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' - )($scope); - title = window.env.t('leaveParty'); - } + html = $compile('' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
')($scope); + title = window.env.t('leavePartyCha'); + + //TODO: Move this to challenge service + //@TODO: Implement this when we convert front-end challenge service + // Challenges.Challenge.query(function(challenges) { + // challenges = _.pluck(_.filter(challenges, function(c) { + // return c.group._id == group._id; + // }), '_id'); + // if (_.intersection(challenges, User.user.challenges).length > 0) { + // html = $compile( + // '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' + // )($scope); + // title = window.env.t('leavePartyCha'); + // } else { + // html = $compile( + // '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' + // )($scope); + // title = window.env.t('leaveParty'); + // } + $scope.popoverEl.popover('destroy').popover({ html: true, placement: 'top', @@ -91,10 +112,10 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' title: title, content: html }).popover('show'); - }); + // }); }; - $scope.clickStartQuest = function(){ + $scope.clickStartQuest = function () { Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Start a Quest'}); var hasQuests = _.find(User.user.items.quests, function(quest) { return quest > 0; @@ -109,7 +130,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.leaveOldPartyAndJoinNewParty = function(newPartyId, newPartyName) { if (confirm('Are you sure you want to delete your party and join ' + newPartyName + '?')) { - Groups.Group.leave({gid: Groups.party()._id, keep:false}, undefined, function() { + Groups.Group.leave({gid: Groups.party()._id, keep: false}, undefined, function() { $scope.group = { loadingNewParty: true }; @@ -118,7 +139,8 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' } } - $scope.reject = function(){ + $scope.reject = function(party) { + Groups.Group.rejectInvite(party.id); User.set({'invitations.party':{}}); } diff --git a/website/public/js/services/groupServices.js b/website/public/js/services/groupServices.js index 001a137d63..e208b5bbdf 100644 --- a/website/public/js/services/groupServices.js +++ b/website/public/js/services/groupServices.js @@ -1,80 +1,191 @@ 'use strict'; -(function() { - angular - .module('habitrpg') - .factory('Groups', groupsFactory); +angular.module('habitrpg') +.factory('Groups', [ '$location', '$rootScope', '$http', 'Analytics', 'ApiUrl', 'Challenges', 'User', '$q', + function($location, $rootScope, $http, Analytics, ApiUrl, Challenges, User, $q) { + var data = {party: undefined, myGuilds: undefined, publicGuilds: undefined, tavern: undefined }; + var groupApiURLPrefix = "/api/v3/groups"; - groupsFactory.$inject = [ - '$location', - '$resource', - '$rootScope', - 'Analytics', - 'ApiUrl', - 'Challenges', - 'User' - ]; + var Group = {}; - function groupsFactory($location, $resource, $rootScope, Analytics, ApiUrl, Challenges, User) { + //@TODO: Add paging + Group.getGroups = function(type) { + var url = groupApiURLPrefix; + if (type) { + url += '?type=' + type; + } - var data = {party: undefined, myGuilds: undefined, publicGuilds: undefined, tavern: undefined}; - var Group = $resource(ApiUrl.get() + '/api/v2/groups/:gid', - {gid:'@_id', messageId: '@_messageId'}, - { - get: { - method: "GET", - isArray:false, - // Wrap challenges as ngResource so they have functions like $leave or $join - transformResponse: function(data) { - data = angular.fromJson(data); - _.each(data && data.challenges, function(c) { - angular.extend(c, Challenges.Challenge.prototype); - }); - return data; - } - }, - - syncParty: {method: "GET", url: '/api/v2/groups/party'}, - join: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/join'}, - leave: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/leave'}, - invite: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/invite'}, - removeMember: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/removeMember'}, - startQuest: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/questAccept'} + return $http({ + method: 'GET', + url: url, }); + }; - function party(cb) { - if (!data.party) return (data.party = Group.get({gid: 'party'}, cb)); - return (cb) ? cb(party) : data.party; + Group.get = function(gid) { + return $http({ + method: 'GET', + url: groupApiURLPrefix + '/' + gid, + }); + }; + + Group.syncParty = function() { + return this.get('party'); + }; + + Group.create = function(groupDetails) { + return $http({ + method: "POST", + url: groupApiURLPrefix, + data: groupDetails, + }); + }; + + Group.update = function(groupDetails) { + //@TODO: Check for what has changed? + return $http({ + method: "PUT", + url: groupApiURLPrefix + '/' + groupDetails._id, + data: groupDetails, + }); + }; + + Group.join = function(gid) { + return $http({ + method: "POST", + url: groupApiURLPrefix + '/' + gid + '/join', + }); + }; + + Group.rejectInvite = function(gid) { + return $http({ + method: "POST", + url: groupApiURLPrefix + '/' + gid + '/reject-invite', + }); + }; + + Group.leave = function(gid, keep) { + return $http({ + method: "POST", + url: groupApiURLPrefix + '/' + gid + '/leave', + data: { + keep: keep, + } + }); + }; + + Group.removeMember = function(gid, memberId, message) { + return $http({ + method: "POST", + url: groupApiURLPrefix + gid + '/removeMember/' + memberId, + data: { + message: message, + }, + }); + }; + + Group.invite = function(gid, invitationDetails) { + return $http({ + method: "POST", + url: groupApiURLPrefix + '/' + gid + '/invite', + data: { + uuids: invitationDetails.uuids, + emails: invitationDetails.emails, + }, + }); + }; + + Group.startQuest = function(gid) { + return $http({ + method: "POST", + url: groupApiURLPrefix + '/' + gid + '/questAccept', + }); + }; + + function party () { + var deferred = $q.defer(); + + if (!data.party) { + Group.get('party') + .then(function (response) { + data.party = response.data.data; + deferred.resolve(data.party); + }, function (response) { + deferred.reject(response); + }); + } else { + deferred.resolve(data.party); + } + + return deferred.promise; } - function publicGuilds() { + function publicGuilds () { + var deferred = $q.defer(); + + if (!data.publicGuilds) { + Group.getGroups('publicGuilds') + .then(function (response) { + data.publicGuilds = response.data.data; + deferred.resolve(data.publicGuilds); + }, function (response) { + deferred.reject(response); + }); + } else { + deferred.resolve(data.publicGuilds); + } + + return deferred.promise; //TODO combine these as {type:'guilds,public'} and create a $filter() to separate them - if (!data.publicGuilds) data.publicGuilds = Group.query({type:'public'}); - return data.publicGuilds; } - function myGuilds() { - if (!data.myGuilds) data.myGuilds = Group.query({type:'guilds'}); - return data.myGuilds; + function myGuilds () { + var deferred = $q.defer(); + + if (!data.myGuilds) { + Group.getGroups('privateGuilds') + .then(function (response) { + data.myGuilds = response.data.data; + deferred.resolve(data.myGuilds); + }, function (response) { + deferred.reject(response); + }); + } else { + deferred.resolve(data.myGuilds); + } + + return deferred.promise; } - function tavern() { - if (!data.tavern) data.tavern = Group.get({gid:'habitrpg'}); - return data.tavern; + function tavern () { + var deferred = $q.defer(); + + if (!data.tavern) { + Group.get('habitrpg') + .then(function (response) { + data.tavern = response.data.data; + deferred.resolve(data.tavern); + }, function (response) { + deferred.reject(response); + }); + } else { + deferred.resolve(data.tavern); + } + + return deferred.promise; } - function inviteOrStartParty(group) { + function inviteOrStartParty (group) { Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Invite Friends'}); if (group.type === "party" || $location.$$path === "/options/groups/party") { - group.type = 'party'; - $rootScope.openModal('invite-party', { - controller:'InviteToGroupCtrl', - resolve: { - injectedGroup: function(){ return group; } - } - }); + group.type = 'party'; + $rootScope.openModal('invite-party', { + controller:'InviteToGroupCtrl', + resolve: { + injectedGroup: function(){ return group; } + } + }); } else { - $location.path("/options/groups/party"); + $location.path("/options/groups/party"); } } @@ -86,7 +197,6 @@ inviteOrStartParty: inviteOrStartParty, data: data, - Group: Group - } - } -})(); + Group: Group, + }; + }]); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 3f4a7aabb4..91800ea660 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -278,7 +278,10 @@ api.joinGroup = { await Q.all(promises); let response = Group.toJSONCleanChat(promises[0], user); - response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + let leader = await User.findById(response.leader).select(nameFields).exec(); + if (leader) { + response.leader = leader.toJSON({minimize: true}); + } res.respond(200, response); firebase.addUserToGroup(group._id, user._id); diff --git a/website/views/options/social/party/party-invitation.jade b/website/views/options/social/party/party-invitation.jade index f4972e6e41..0bfd72b6d8 100644 --- a/website/views/options/social/party/party-invitation.jade +++ b/website/views/options/social/party/party-invitation.jade @@ -5,5 +5,4 @@ data-type='party', ng-click='join(user.invitations.party)' )=env.t('accept') - a.btn.btn-danger(ng-click='reject()')=env.t('reject') - + a.btn.btn-danger(ng-click='reject(user.invitations.party)')=env.t('reject') From 2619b34c6571fc97db57bfd8e78706baaf58316d Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 27 Apr 2016 08:59:10 -0500 Subject: [PATCH 719/976] Updated chat service to use api v3 (#7113) * Updated chat service to use api v3 * Removed , added back User functions, added todo --- test/spec/services/chatServicesSpec.js | 73 ++++++++++++++ website/public/js/app.js | 2 +- website/public/js/controllers/chatCtrl.js | 76 +++++++------- website/public/js/controllers/menuCtrl.js | 2 +- website/public/js/controllers/partyCtrl.js | 4 +- website/public/js/services/chatServices.js | 109 ++++++++++++++++----- 6 files changed, 201 insertions(+), 65 deletions(-) create mode 100644 test/spec/services/chatServicesSpec.js diff --git a/test/spec/services/chatServicesSpec.js b/test/spec/services/chatServicesSpec.js new file mode 100644 index 0000000000..2d1c101df8 --- /dev/null +++ b/test/spec/services/chatServicesSpec.js @@ -0,0 +1,73 @@ +'use strict'; + +describe('chatServices', function() { + var $httpBackend, $http, chat, user; + var apiV3Prefix = '/api/v3'; + + beforeEach(function() { + module(function($provide) { + $provide.value('User', {user:user}); + }); + + inject(function(_$httpBackend_, Chat, User) { + $httpBackend = _$httpBackend_; + chat = Chat; + user = User; + user.sync = function(){}; + }); + }); + + it('calls get chat endpoint', function() { + var groupId = 1; + $httpBackend.expectGET(apiV3Prefix + '/groups/' + groupId + '/chat').respond({}); + chat.getChat(groupId); + $httpBackend.flush(); + }); + + it('calls get chat endpoint', function() { + var groupId = 1; + var message = "test message"; + $httpBackend.expectPOST(apiV3Prefix + '/groups/' + groupId + '/chat').respond({}); + chat.postChat(groupId, message); + $httpBackend.flush(); + }); + + it('calls delete chat endpoint', function() { + var groupId = 1; + var chatId = 2; + $httpBackend.expectDELETE(apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId).respond({}); + chat.deleteChat(groupId, chatId); + $httpBackend.flush(); + }); + + it('calls like chat endpoint', function() { + var groupId = 1; + var chatId = 2; + $httpBackend.expectPOST(apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/like').respond({}); + chat.like(groupId, chatId); + $httpBackend.flush(); + }); + + it('calls flag chat endpoint', function() { + var groupId = 1; + var chatId = 2; + $httpBackend.expectPOST(apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/flag').respond({}); + chat.flagChatMessage(groupId, chatId); + $httpBackend.flush(); + }); + + it('calls clearflags chat endpoint', function() { + var groupId = 1; + var chatId = 2; + $httpBackend.expectPOST(apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/clearflags').respond({}); + chat.clearFlagCount(groupId, chatId); + $httpBackend.flush(); + }); + + it('calls chat seen endpoint', function() { + var groupId = 1; + $httpBackend.expectPOST(apiV3Prefix + '/groups/' + groupId + '/chat/seen').respond({}); + chat.markChatSeen(groupId); + $httpBackend.flush(); + }); +}); diff --git a/website/public/js/app.js b/website/public/js/app.js index 67215bc810..f6cc990284 100644 --- a/website/public/js/app.js +++ b/website/public/js/app.js @@ -155,7 +155,7 @@ window.habitrpg = angular.module('habitrpg', Groups.Group.get($stateParams.gid) .then(function (response) { $scope.group = response.data.data; - Chat.seenMessage($scope.group._id); + Chat.markChatSeen($scope.group._id); }); }] }) diff --git a/website/public/js/controllers/chatCtrl.js b/website/public/js/controllers/chatCtrl.js index 8ba5b2a15c..cbe42d9fbb 100644 --- a/website/public/js/controllers/chatCtrl.js +++ b/website/public/js/controllers/chatCtrl.js @@ -27,37 +27,38 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A if (_.isEmpty(message) || $scope._sending) return; $scope._sending = true; var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false; - Chat.utils.postChat({gid: group._id, message:message, previousMsg: previousMsg}, undefined, function(data){ - if(data.chat){ - group.chat = data.chat; - }else if(data.message){ - group.chat.unshift(data.message); - } - $scope.message.content = ''; - $scope._sending = false; - if (group.type == 'party') { - Analytics.updateUser({'partyID':group.id,'partySize':group.memberCount}); - } - if (group.privacy == 'public'){ - Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'group chat','groupType':group.type,'privacy':group.privacy,'groupName':group.name}); - } else { - Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'group chat','groupType':group.type,'privacy':group.privacy}); - } - }, function(err){ - $scope._sending = false; - }); + Chat.postChat(group._id, message, previousMsg) + .then(function(response) { + var message = response.data.data.message; + + group.chat.unshift(message); + + $scope.message.content = ''; + $scope._sending = false; + + if (group.type == 'party') { + Analytics.updateUser({'partyID': group.id, 'partySize': group.memberCount}); + } + + if (group.privacy == 'public'){ + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'group chat','groupType':group.type,'privacy':group.privacy,'groupName':group.name}); + } else { + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'group chat','groupType':group.type,'privacy':group.privacy}); + } + }, function(err){ + $scope._sending = false; + }); } $scope.deleteChatMessage = function(group, message){ if(message.uuid === User.user.id || (User.user.backer && User.user.contributor.admin)){ var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false; - if(confirm('Are you sure you want to delete this message?')){ - Chat.utils.deleteChatMessage({gid:group._id, messageId:message.id, previousMsg:previousMsg}, undefined, function(data){ - if(data.chat) group.chat = data.chat; - - var i = _.findIndex(group.chat, {id: message.id}); - if(i !== -1) group.chat.splice(i, 1); - }); + if (confirm('Are you sure you want to delete this message?')) { + Chat.deleteChat(group._id, message.id, previousMsg) + .then(function (response) { + var i = _.findIndex(group.chat, {id: message.id}); + if(i !== -1) group.chat.splice(i, 1); + }); } } } @@ -65,28 +66,33 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A $scope.likeChatMessage = function(group,message) { if (message.uuid == User.user._id) return Notification.text(window.env.t('foreverAlone')); + if (!message.likes) message.likes = {}; + if (message.likes[User.user._id]) { delete message.likes[User.user._id]; } else { message.likes[User.user._id] = true; } - Chat.utils.like({ gid:group._id, messageId:message.id }, undefined); + + Chat.like(group._id, message.id); } $scope.flagChatMessage = function(groupId,message) { if(!message.flags) message.flags = {}; - if(message.flags[User.user._id]) + + if (message.flags[User.user._id]) { Notification.text(window.env.t('abuseAlreadyReported')); - else { + } else { $scope.abuseObject = message; $scope.groupId = groupId; - Members.selectMember(message.uuid, function(){ - $rootScope.openModal('abuse-flag',{ - controller:'MemberModalCtrl', - scope: $scope + Members.selectMember(message.uuid) + .then(function () { + $rootScope.openModal('abuse-flag',{ + controller:'MemberModalCtrl', + scope: $scope + }); }); - }); } }; @@ -116,7 +122,7 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A } // When the user clicks fetch recent messages we need to update // that the user has seen the new messages - Chat.seenMessage(group._id); + Chat.markChatSeen(group._id); } // List of Ordering options for the party members list diff --git a/website/public/js/controllers/menuCtrl.js b/website/public/js/controllers/menuCtrl.js index 5549c527dd..761c118986 100644 --- a/website/public/js/controllers/menuCtrl.js +++ b/website/public/js/controllers/menuCtrl.js @@ -26,7 +26,7 @@ angular.module('habitrpg') } } - $scope.clearMessages = Chat.seenMessage; + $scope.clearMessages = Chat.markChatSeen; $scope.clearCards = Chat.clearCards; $scope.iconClasses = function() { diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js index 022d4044a7..d80414c37a 100644 --- a/website/public/js/controllers/partyCtrl.js +++ b/website/public/js/controllers/partyCtrl.js @@ -46,7 +46,9 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' } } - $scope.create = function (group) { + Chat.markChatSeen($scope.group._id); + + $scope.create = function(group) { if (!group.name) group.name = env.t('possessiveParty', {name: User.user.profile.name}); Groups.Group.create(group, function() { Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'party', 'privacy':'private'}); diff --git a/website/public/js/services/chatServices.js b/website/public/js/services/chatServices.js index 7fe2533506..e7bb663c8e 100644 --- a/website/public/js/services/chatServices.js +++ b/website/public/js/services/chatServices.js @@ -1,33 +1,88 @@ 'use strict'; -angular.module('habitrpg').factory('Chat', -['$resource', '$http', 'ApiUrl', 'User', -function($resource, $http, ApiUrl, User) { - var utils = $resource(ApiUrl.get() + '/api/v2/groups/:gid', - {gid:'@_id', messageId: '@_messageId'}, - { - postChat: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/chat'}, - like: {method: 'POST', isArray: true, url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId/like'}, - deleteChatMessage: {method: "DELETE", url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId'}, - flagChatMessage: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId/flag'}, - clearFlagCount: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId/clearflags'}, - }); +angular.module('habitrpg') +.factory('Chat', ['$http', 'ApiUrl', 'User', + function($http, ApiUrl, User) { + var apiV3Prefix = '/api/v3'; - var chatService = { - seenMessage: seenMessage, - clearCards: clearCards, - utils: utils - }; + function getChat (groupId) { + return $http({ + method: 'GET', + url: apiV3Prefix + '/groups/' + groupId + '/chat', + }); + } - return chatService; + function postChat (groupId, message, previousMsg) { + var url = apiV3Prefix + '/groups/' + groupId + '/chat'; - function clearCards() { - User.user.ops.update && User.set({'flags.cardReceived':false}); - } + if (previousMsg) { + url += '?previousMsg=' + previousMsg; + } - function seenMessage(gid) { - // On enter, set chat message to "seen" - $http.post(ApiUrl.get() + '/api/v2/groups/'+gid+'/chat/seen'); - if (User.user.newMessages) delete User.user.newMessages[gid]; - } -}]); + return $http({ + method: 'POST', + url: url, + data: { + message: message, + } + }); + } + + function deleteChat (groupId, chatId, previousMsg) { + var url = apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId; + + if (previousMsg) { + url += '?previousMsg=' + previousMsg; + } + + return $http({ + method: 'DELETE', + url: url, + }); + } + + function like (groupId, chatId) { + return $http({ + method: 'POST', + url: apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/like', + }); + } + + function flagChatMessage (groupId, chatId) { + return $http({ + method: 'POST', + url: apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/flag', + }); + } + + function clearFlagCount (groupId, chatId) { + return $http({ + method: 'POST', + url: apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/clearflags', + }); + } + + function markChatSeen (groupId) { + if (User.user.newMessages) delete User.user.newMessages[gid]; + return $http({ + method: 'POST', + url: apiV3Prefix + '/groups/' + groupId + '/chat/seen', + }); + } + + return { + getChat: getChat, + postChat: postChat, + deleteChat: deleteChat, + like: like, + flagChatMessage: flagChatMessage, + clearFlagCount: clearFlagCount, + markChatSeen: markChatSeen, + clearCards: clearCards, + } + + //@TOOD: Port when User service is updated + function clearCards() { + User.user.ops.update && User.set({'flags.cardReceived':false}); + } + }]); From 570d5c7fd9a2c9a49340e18d78a326c0d4b14176 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 27 Apr 2016 09:11:06 -0500 Subject: [PATCH 720/976] Updated challenge service to user apiv3 and update challenge ctrl (#7111) * Updated challenge service to user apiv3 and update challenge ctrl * Removed extra code. Added challenge update. Fixed group qurey --- test/spec/services/challengeServicesSpec.js | 88 ++++++++++++ website/public/js/app.js | 20 +-- .../public/js/controllers/challengesCtrl.js | 131 ++++++++++-------- .../public/js/services/challengeServices.js | 111 ++++++++++++--- 4 files changed, 266 insertions(+), 84 deletions(-) create mode 100644 test/spec/services/challengeServicesSpec.js diff --git a/test/spec/services/challengeServicesSpec.js b/test/spec/services/challengeServicesSpec.js new file mode 100644 index 0000000000..abfe7c520a --- /dev/null +++ b/test/spec/services/challengeServicesSpec.js @@ -0,0 +1,88 @@ +'use strict'; + +describe('challengeServices', function() { + var $httpBackend, $http, challenges, user; + var apiV3Prefix = '/api/v3'; + + beforeEach(function() { + module(function($provide) { + $provide.value('User', {user:user}); + }); + + inject(function(_$httpBackend_, Challenges, User) { + $httpBackend = _$httpBackend_; + challenges = Challenges; + user = User; + user.sync = function(){}; + }); + }); + + it('calls create challenge endpoint', function() { + $httpBackend.expectPOST(apiV3Prefix + '/challenges').respond({}); + challenges.createChallenge(); + $httpBackend.flush(); + }); + + it('calls join challenge endpoint', function() { + var challengeId = 1; + $httpBackend.expectPOST(apiV3Prefix + '/challenges/' + challengeId + '/join').respond({}); + challenges.joinChallenge(challengeId); + $httpBackend.flush(); + }); + + it('calls leave challenge endpoint', function() { + var challengeId = 1; + $httpBackend.expectPOST(apiV3Prefix + '/challenges/' + challengeId + '/leave').respond({}); + challenges.leaveChallenge(challengeId); + $httpBackend.flush(); + }); + + it('calls get user challenges endpoint', function() { + $httpBackend.expectGET(apiV3Prefix + '/challenges/user').respond({}); + challenges.getUserChallenges(); + $httpBackend.flush(); + }); + + it('calls get group challenges endpoint', function() { + var groupId = 1; + $httpBackend.expectGET(apiV3Prefix + '/challenges/groups/' + groupId).respond({}); + challenges.getGroupChallenges(groupId); + $httpBackend.flush(); + }); + + it('calls get challenge endpoint', function() { + var challengeId = 1; + $httpBackend.expectGET(apiV3Prefix + '/challenges/' + challengeId).respond({}); + challenges.getChallenge(challengeId); + $httpBackend.flush(); + }); + + it('calls export challenge to csv endpoint', function() { + var challengeId = 1; + $httpBackend.expectGET(apiV3Prefix + '/challenges/' + challengeId + '/export/csv').respond({}); + challenges.exportChallengeCsv(challengeId); + $httpBackend.flush(); + }); + + it('calls update challenge endpoint', function() { + var challengeId = 1; + $httpBackend.expectPUT(apiV3Prefix + '/challenges/' + challengeId).respond({}); + challenges.updateChallenge(challengeId); + $httpBackend.flush(); + }); + + it('calls delete challenge endpoint', function() { + var challengeId = 1; + $httpBackend.expectDELETE(apiV3Prefix + '/challenges/' + challengeId).respond({}); + challenges.deleteChallenge(challengeId); + $httpBackend.flush(); + }); + + it('calls select challenge winner endpoint', function() { + var challengeId = 1; + var winnerId = 2; + $httpBackend.expectPOST(apiV3Prefix + '/challenges/' + challengeId + 'selectWinner/' + winnerId).respond({}); + challenges.selectChallengeWinner(challengeId, winnerId); + $httpBackend.flush(); + }); +}); diff --git a/website/public/js/app.js b/website/public/js/app.js index f6cc990284..47ed3fb970 100644 --- a/website/public/js/app.js +++ b/website/public/js/app.js @@ -173,10 +173,12 @@ window.habitrpg = angular.module('habitrpg', templateUrl: 'partials/options.social.challenges.detail.html', title: env.t('titleChallenges'), controller: ['$scope', 'Challenges', '$stateParams', - function($scope, Challenges, $stateParams){ - $scope.obj = $scope.challenge = Challenges.Challenge.get({cid:$stateParams.cid}, function(){ - $scope.challenge._locked = true; - }); + function ($scope, Challenges, $stateParams) { + Challenges.getChallenge($stateParams.cid) + .then(function (response) { + $scope.obj = $scope.challenge = response.data.data; + $scope.challenge._locked = true; + }); }] }) .state('options.social.challenges.edit', { @@ -184,10 +186,12 @@ window.habitrpg = angular.module('habitrpg', templateUrl: 'partials/options.social.challenges.detail.html', title: env.t('titleChallenges'), controller: ['$scope', 'Challenges', '$stateParams', - function($scope, Challenges, $stateParams){ - $scope.obj = $scope.challenge = Challenges.Challenge.get({cid:$stateParams.cid}, function(){ - $scope.challenge._locked = false; - }); + function ($scope, Challenges, $stateParams) { + Challenges.getChallenge($stateParams.cid) + .then(function (response) { + $scope.obj = $scope.challenge = response.data.data; + $scope.challenge._locked = false; + }); }] }) .state('options.social.challenges.detail.member', { diff --git a/website/public/js/controllers/challengesCtrl.js b/website/public/js/controllers/challengesCtrl.js index 30aac589d5..d8821c607c 100644 --- a/website/public/js/controllers/challengesCtrl.js +++ b/website/public/js/controllers/challengesCtrl.js @@ -10,7 +10,11 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', _getChallenges(); // FIXME $scope.challenges needs to be resolved first (see app.js) - $scope.groups = Groups.Group.query({type:'party,guilds,tavern'}); + $scope.groups = []; + Groups.Group.getGroups('party,publicGuilds,privateGuilds,habitrpg') + .then(function (response) { + $scope.groups = response.data.data; + }); // override score() for tasks listed in challenges-editing pages, so that nothing happens $scope.score = function(){} @@ -53,7 +57,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', if(!defaultGroup) defaultGroup = 'habitrpg'; - $scope.obj = $scope.newChallenge = new Challenges.Challenge({ + $scope.obj = $scope.newChallenge = { name: '', description: '', habits: [], @@ -65,7 +69,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', timestamp: +(new Date), members: [], official: false - }); + }; _calculateMaxPrize(defaultGroup); }; @@ -82,10 +86,12 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', }; _(clonedTasks).each(function(val, type) { - challenge[type + 's'].forEach(_cloneTaskAndPush); + if (challenge[type + 's']) { + challenge[type + 's'].forEach(_cloneTaskAndPush); + } }).value(); - $scope.obj = $scope.newChallenge = new Challenges.Challenge({ + $scope.obj = $scope.newChallenge = { name: challenge.name, shortName: challenge.shortName, description: challenge.description, @@ -97,7 +103,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', group: challenge.group._id, official: challenge.official, prize: challenge.prize - }); + }; function _cloneTaskAndPush(taskToClone) { var task = Tasks.cloneTask(taskToClone); @@ -117,16 +123,25 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', return alert(window.env.t('challengeNotEnoughGems')); } - challenge.$save(function(_challenge){ - if (isNew) { - Notification.text(window.env.t('challengeCreated')); - User.sync(); - } - - $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { - reload: true, inherit: false, notify: true - }); - }); + if (isNew) { + Challenges.createChallenge(challenge) + .then(function (response) { + var _challenge = response.data.data; + Notification.text(window.env.t('challengeCreated')); + User.sync(); + $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { + reload: true, inherit: false, notify: true + }); + }); + } else { + Challenges.updateChallenge(challenge) + .then(function (response) { + var _challenge = response.data.data; + $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { + reload: true, inherit: false, notify: true + }); + }); + } }; /** @@ -136,7 +151,6 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.newChallenge = null; }; - /** * Close Challenge * ------------------ @@ -150,25 +164,31 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope["delete"] = function(challenge) { var warningMsg; + if(challenge.group._id == 'habitrpg') { warningMsg = window.env.t('sureDelChaTavern'); } else { warningMsg = window.env.t('sureDelCha'); } + if (!confirm(warningMsg)) return; - challenge.$delete(function(){ - $scope.popoverEl.popover('destroy'); - _backToChallenges(); - }); + + Challenges.deleteChallenge(challenge._id) + .then(function (response) { + $scope.popoverEl.popover('destroy'); + _backToChallenges(); + }); }; $scope.selectWinner = function(challenge) { if (!challenge.winner) return; if (!confirm(window.env.t('youSure'))) return; - challenge.$close({uid:challenge.winner}, function(){ - $scope.popoverEl.popover('destroy'); - _backToChallenges(); - }) + + Challenges.selectWinner(challenge._id, challenge.winner) + .then(function (response) { + $scope.popoverEl.popover('destroy'); + _backToChallenges(); + }); } $scope.close = function(challenge, $event) { @@ -229,22 +249,21 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', -------------------------- */ - $scope.join = function(challenge){ - challenge.$join(function(){ - _getChallenges() - User.log({}); - }); - + $scope.join = function (challenge) { + Challenges.joinChallenge(challenge._id) + .then(function (response) { + _getChallenges() + }); } $scope.leave = function(keep) { if (keep == 'cancel') { $scope.selectedChal = undefined; } else { - $scope.selectedChal.$leave({keep:keep}, function(){ - _getChallenges() - User.log({}); - }); + Challenges.leaveChallenge(challenge._id, keep) + .then(function (response) { + _getChallenges() + }); } $scope.popoverEl.popover('destroy'); } @@ -316,21 +335,24 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', } $scope.sendMessageToChallengeParticipant = function(uid) { - Members.selectMember(uid, function(){ - $rootScope.openModal('private-message',{controller:'MemberModalCtrl'}); - }); + Members.selectMember(uid) + .then(function () { + $rootScope.openModal('private-message', {controller:'MemberModalCtrl'}); + }); }; $scope.sendGiftToChallengeParticipant = function(uid) { - Members.selectMember(uid, function(){ - $rootScope.openModal('send-gift',{controller:'MemberModalCtrl'}) - }); + Members.selectMember(uid) + .then(function () { + $rootScope.openModal('send-gift', {controller:'MemberModalCtrl'}); + }); }; $scope.filterInitialChallenges = function() { - $scope.groupsFilter = _.uniq(_.pluck($scope.challenges, 'group'), function(g){return g._id}); + $scope.groupsFilter = _.uniq(_.pluck($scope.challenges, 'group'), function(g) {return g._id}); + $scope.search = { - group: _.transform($scope.groups, function(m,g){m[g._id]=true;}), + group: _.transform($scope.groups, function(m,g){ m[g._id] = true;}), _isMember: "either", _isOwner: "either" }; @@ -361,7 +383,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', return groupBalance; } - function _shouldShowChallenge(chal) { + function _shouldShowChallenge (chal) { // Have to check that the leader object exists first in the // case where a challenge's leader deletes their account var userIsOwner = (chal.leader && chal.leader._id) === User.user.id; @@ -377,24 +399,23 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.popoverEl.popover('destroy'); $scope.cid = null; $state.go('options.social.challenges'); - $scope.challenges = Challenges.Challenge.query(); - User.log({}); + _getChallenges(); } - // Fetch single challenge if a cid is present; fetch multiple challenges // otherwise function _getChallenges() { if ($scope.cid) { - Challenges.Challenge.get({cid: $scope.cid}, function(challenge) { - $scope.challenges = [challenge]; - }); + Challenges.getChallenge($scope.cid) + .then(function (challenge) { + $scope.challenges = [challenge]; + }); } else { - Challenges.Challenge.query(function(challenges){ - $scope.challenges = challenges; - $scope.filterInitialChallenges(); - }); + Challenges.getUserChallenges() + .then(function(response){ + $scope.challenges = response.data.data; + $scope.filterInitialChallenges(); + }); } }; - }]); diff --git a/website/public/js/services/challengeServices.js b/website/public/js/services/challengeServices.js index 51b91de8c2..257aa65fac 100644 --- a/website/public/js/services/challengeServices.js +++ b/website/public/js/services/challengeServices.js @@ -1,26 +1,95 @@ 'use strict'; -/** - * Services that persists and retrieves user from localStorage. - */ +angular.module('habitrpg') +.factory('Challenges', ['ApiUrl', '$resource', '$http', + function(ApiUrl, $resource, $http) { + var apiV3Prefix = '/api/v3'; -angular.module('habitrpg').factory('Challenges', -['ApiUrl', '$resource', -function(ApiUrl, $resource) { - var Challenge = $resource(ApiUrl.get() + '/api/v2/challenges/:cid', - {cid:'@_id'}, - { - //'query': {method: "GET", isArray:false} - join: {method: "POST", url: ApiUrl.get() + '/api/v2/challenges/:cid/join'}, - leave: {method: "POST", url: ApiUrl.get() + '/api/v2/challenges/:cid/leave'}, - close: {method: "POST", params: {uid:''}, url: ApiUrl.get() + '/api/v2/challenges/:cid/close'}, - getMember: {method: "GET", url: ApiUrl.get() + '/api/v2/challenges/:cid/member/:uid'} - }); + function createChallenge (challengeData) { + return $http({ + method: 'POST', + url: apiV3Prefix + '/challenges', + data: challengeData, + }); + } - //var challenges = []; + function joinChallenge (challengeId) { + return $http({ + method: 'POST', + url: apiV3Prefix + '/challenges/' + challengeId + '/join', + }); + } - return { - Challenge: Challenge - //challenges: challenges - } -}]); + function leaveChallenge (challengeId, keep) { + return $http({ + method: 'POST', + url: apiV3Prefix + '/challenges/' + challengeId + '/leave', + data: { + keep: keep, + } + }); + } + + function getUserChallenges () { + return $http({ + method: 'GET', + url: apiV3Prefix + '/challenges/user', + }); + } + + function getGroupChallenges (groupId) { + return $http({ + method: 'GET', + url: apiV3Prefix + '/challenges/groups/' + groupId, + }); + } + + function getChallenge (challengeId) { + return $http({ + method: 'GET', + url: apiV3Prefix + '/challenges/' + challengeId, + }); + } + + function exportChallengeCsv (challengeId) { + return $http({ + method: 'GET', + url: apiV3Prefix + '/challenges/' + challengeId + '/export/csv', + }); + } + + function updateChallenge (challengeId, updateData) { + return $http({ + method: 'PUT', + url: apiV3Prefix + '/challenges/' + challengeId, + data: updateData, + }); + } + + function deleteChallenge (challengeId) { + return $http({ + method: 'DELETE', + url: apiV3Prefix + '/challenges/' + challengeId, + }); + } + + function selectChallengeWinner (challengeId, winnerId) { + return $http({ + method: 'POST', + url: apiV3Prefix + '/challenges/' + challengeId + 'selectWinner/' + winnerId, + }); + } + + return { + createChallenge: createChallenge, + joinChallenge: joinChallenge, + leaveChallenge: leaveChallenge, + getUserChallenges: getUserChallenges, + getGroupChallenges: getGroupChallenges, + getChallenge: getChallenge, + exportChallengeCsv: exportChallengeCsv, + updateChallenge: updateChallenge, + deleteChallenge: deleteChallenge, + selectChallengeWinner: selectChallengeWinner, + } + }]); From f78bc2e6a871a44021566e3bf5ed7b99c6ab3ad8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 27 Apr 2016 19:35:07 +0200 Subject: [PATCH 721/976] v3 migration: barebone migration --- .eslintignore | 4 - migrations/api_v3/users.js | 213 +++++++++++++++++++++++++------------ 2 files changed, 147 insertions(+), 70 deletions(-) diff --git a/.eslintignore b/.eslintignore index a623e23548..5a862c4394 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,10 +7,6 @@ website/build/ website/transpiled-babel/ migrations/* -!migrations/api_v3 -!migrations/api_v3 -!migrations/api_v3 -migrations/api_v3/old_models # The files in website/public/js should be moved out and browserified website/public/ diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index d7462e0af4..e275645d63 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -1,93 +1,171 @@ -/* eslint-disable no-console */ - // Migrate users collection to new schema // This should run AFTER challenges migration -// This code makes heavy use of ES6 / 7 features and should be compiled / run with BabelJS. +// The console-stamp module must be installed (not included in package.json) // It requires two environment variables: MONGODB_OLD and MONGODB_NEW -/* - tags must have a name -*/ - +// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). +// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM console.log('Starting migrations/api_v3/users.js.'); -import Q from 'q'; -import MongoDB from 'mongodb'; -import nconf from 'nconf'; -import mongoose from 'mongoose'; -import _ from 'lodash'; +require('babel-register'); + +var Q = require('q'); +var MongoDB = require('mongodb'); +var nconf = require('nconf'); +var mongoose = require('mongoose'); +var _ = require('lodash'); +var uuid = require('uuid'); +var consoleStamp = require('console-stamp'); + +// Add timestamps to console messages +consoleStamp(console); // Initialize configuration -import setupNconf from '../../website/src/libs/api-v3/setupNconf'; -setupNconf(); +require('../../website/src/libs/api-v3/setupNconf')(); -const MONGODB_OLD = nconf.get('MONGODB_OLD'); -const MONGODB_NEW = nconf.get('MONGODB_NEW'); +var MONGODB_OLD = nconf.get('MONGODB_OLD'); +var MONGODB_NEW = nconf.get('MONGODB_NEW'); mongoose.Promise = Q.Promise; // otherwise mongoose models won't work // Load old and new models -import { model as NewUser } from '../../website/src/models/user'; -import * as Tasks from '../../website/src/models/task'; +//import { model as NewUser } from '../../website/src/models/user'; +//import * as Tasks from '../../website/src/models/task'; // To be defined later when MongoClient connects -let mongoDbOldInstance; -let oldUserCollection; +var mongoDbOldInstance; +var oldUserCollection; -let mongoDbNewInstance; -let newUserCollection; -let newTaskCollection; +var mongoDbNewInstance; +var newUserCollection; +var newTaskCollection; -async function processUser (_id) { - let [oldUser] = await oldUserCollection - .find({_id}) - .limit(1) - .toArray(); +var BATCH_SIZE = 1000; - let oldTasks = oldUser.habits.concat(oldUser.dailys).concat(oldUser.rewards).concat(oldUser.todos); - oldUser.habits = oldUser.dailys = oldUser.rewards = oldUser.todos = undefined; +var processedUsers = 0; +var totoalProcessedTasks = 0; - oldUser.challenges = []; - oldUser.invitations.guilds = []; - oldUser.invitations.party = {}; - oldUser.party = {}; - oldUser.tags = oldUser.tags.map(tag => { - return { - _id: tag.id, - name: tag.name, - challenge: tag.challenge, - }; - }); +// Only process users that fall in a interval ie -> 0000-4000-0000-0000 +var AFTER_USER_ID = nconf.get('AFTER_USER_ID'); +var BEFORE_USER_ID = nconf.get('BEFORE_USER_ID'); - let newUser = new NewUser(oldUser); +/* TODO +- _id 9 +- challenges +- groups +- invitations +- challenges' tasks +*/ - let batchInsertTasks = newTaskCollection.initializeUnorderedBulkOp(); - oldTasks.forEach(oldTask => { - let newTask = new Tasks[oldTask.type](oldTask); - newTask.userId = newUser._id; +function processUsers (afterId) { + var processedTasks = 0; + var lastUser = null; + var oldUsers; - newTask.challenge = {}; - if (!oldTask.text) newTask.text = 'text'; - newTask.tags = _.map(oldTask.tags, (tagPresent, tagId) => { - return tagPresent && tagId; + var query = {}; + + if (BEFORE_USER_ID) { + query._id = {$lte: BEFORE_USER_ID}; + } + + if (afterId) { + query._id = {$gt: afterId}; + } else if (AFTER_USER_ID) { + query._id = {$gt: AFTER_USER_ID}; + } + + var batchInsertTasks = newTaskCollection.initializeUnorderedBulkOp(); + var batchInsertUsers = newUserCollection.initializeUnorderedBulkOp(); + + console.log(`Executing users query.\nMatching users after ${afterId ? afterId : AFTER_USER_ID} and before ${BEFORE_USER_ID} (included).`); + + return oldUserCollection + .find(query) + .sort({_id: 1}) + .limit(BATCH_SIZE) + .toArray() + .then(function (oldUsersR) { + oldUsers = oldUsersR; + + console.log(`Processing ${oldUsers.length} users. Already processed ${processedUsers} users and ${totoalProcessedTasks} tasks.`); + + if (oldUsers.length === BATCH_SIZE) { + lastUser = oldUsers[oldUsers.length - 1]._id; + } + + + oldUsers.forEach(function (oldUser) { + var oldTasks = oldUser.habits.concat(oldUser.dailys).concat(oldUser.rewards).concat(oldUser.todos); + oldUser.habits = oldUser.dailys = oldUser.rewards = oldUser.todos = undefined; + + oldUser.challenges = []; + if (oldUser.invitations) { + oldUser.invitations.guilds = []; + oldUser.invitations.party = {}; + } + oldUser.party = {}; + oldUser.tags = oldUser.tags.map(function (tag) { + return { + _id: tag.id, + name: tag.name, + challenge: tag.challenge, + }; + }); + + oldUser.tasksOrder = { + habits: [], + dailys: [], + rewards: [], + todos: [], + }; + + //let newUser = new NewUser(oldUser); + + oldTasks.forEach(function (oldTask) { + oldTask._id = uuid.v4(); // create a new unique uuid + oldTask.userId = oldUser._id; + oldTask.legacyId = oldTask.id; // store the old task id + + oldTask.challenge = {}; + if (!oldTask.text) oldTask.text = 'text'; + oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) { + return tagPresent && tagId; + }); + + if (oldTask.type !== 'todo' || (oldTask.type === 'todo' && !oldTask.completed)) { + oldUser.tasksOrder[`${oldTask.type}s`].push(oldTask._id); + } + + //let newTask = new Tasks[oldTask.type](oldTask); + + batchInsertTasks.insert(oldTask); + processedTasks++; + }); + + batchInsertUsers.insert(oldUser); }); - newUser.tasksOrder[`${oldTask.type}s`].push(newTask._id); + console.log(`Saving ${oldUsers.length} users and ${processedTasks} tasks.`); - let newTaskObject = newTask.toObject(); - newTaskObject.legacyId = oldTask.id; + return Q.all([ + batchInsertUsers.execute(), + batchInsertTasks.execute(), + ]); + }) + .then(function () { + totoalProcessedTasks += processedTasks; + processedUsers += oldUsers.length; - batchInsertTasks.insert(newTaskObject); + console.log(`Saved ${oldUsers.length} users and their tasks.`); + + if (lastUser) { + return processUsers(lastUser); + } else { + return console.log('Done!'); + } }); - - await Q.all([ - newUserCollection.insertOne(newUser.toObject()), - batchInsertTasks.execute(), - ]); - - console.log(`Saved user ${newUser._id} and their tasks.`); } /* @@ -211,13 +289,16 @@ var processUser = function(gt) { */ // Connect to the databases -const MongoClient = MongoDB.MongoClient; +var MongoClient = MongoDB.MongoClient; Q.all([ MongoClient.connect(MONGODB_OLD), MongoClient.connect(MONGODB_NEW), ]) -.then(([oldInstance, newInstance]) => { +.then(function (result) { + var oldInstance = result[0]; + var newInstance = result[1]; + mongoDbOldInstance = oldInstance; oldUserCollection = mongoDbOldInstance.collection('users'); @@ -227,8 +308,8 @@ Q.all([ console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); - return processUser(nconf.get('USER_ID')); + return processUsers(); }) -.catch(err => { - throw err; +.catch(function (err) { + console.error(err); }); From fa21577c46600b0a40fe11bae209aa27815d0ecc Mon Sep 17 00:00:00 2001 From: Victor Pudeyev Date: Wed, 27 Apr 2016 14:26:32 -0500 Subject: [PATCH 722/976] V3 payments 6 (#7104) * payments api: cancelSubscription * some more tests for amazon payments * promisifying amazon payments * somehow payment stub is not working * cleaning up tests * renaming tests in api/v3/integration/payments * improvements * cleanup, lint * fixes as per comments * moment.zone() is back in. --- common/locales/en/api-v3.json | 3 +- tasks/gulp-tests.js | 3 +- ...ET-payments_amazon_subscribeCancel.test.js | 21 ++ .../POST-payments_amazon_checkout.test.js | 22 ++ ...ents_amazon_createOrderReferenceId.test.js | 22 ++ .../POST-payments_amazon_subscribe.test.js | 21 ++ ...payments_amazon_verifyAccessToken.test.js} | 0 test/api/v3/unit/libs/amazonPayments.test.js | 103 ---------- test/api/v3/unit/libs/payments.test.js | 75 +++++++ test/api/v3/unit/libs/paymentsIndex.test.js | 11 - website/src/controllers/api-v3/auth.js | 2 - website/src/controllers/api-v3/chat.js | 2 - .../controllers/top-level/payments/amazon.js | 193 ++++++++---------- .../controllers/top-level/payments/paypal.js | 2 +- .../controllers/top-level/payments/stripe.js | 2 +- website/src/libs/api-v3/amazonPayments.js | 103 ++++------ .../index.js => libs/api-v3/payments.js} | 89 +++----- 17 files changed, 319 insertions(+), 355 deletions(-) create mode 100644 test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js rename test/api/v3/integration/payments/{POST-payments_amazon_verify_access_token.test.js => POST-payments_amazon_verifyAccessToken.test.js} (100%) delete mode 100644 test/api/v3/unit/libs/amazonPayments.test.js create mode 100644 test/api/v3/unit/libs/payments.test.js delete mode 100644 test/api/v3/unit/libs/paymentsIndex.test.js rename website/src/{controllers/top-level/payments/index.js => libs/api-v3/payments.js} (74%) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 5b1d57383e..0a3662bf80 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -100,6 +100,8 @@ "noAdminAccess": "You don't have admin access.", "pageMustBeNumber": "req.query.page must be a number", "missingUnsubscriptionCode": "Missing unsubscription code.", + "missingSubscription": "User does not have a plan subscription", + "missingSubscriptionCode": "Missing subscription code. Possible values: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo.", "userNotFound": "User not found.", "spellNotFound": "Spell \"<%= spellId %>\" not found.", "partyNotFound": "Party not found", @@ -173,6 +175,5 @@ "equipmentAlreadyOwned": "You already own that piece of equipment", "missingAccessToken": "The request is missing a required parameter : access_token", "missingBillingAgreementId": "Missing billing agreement id", - "missingAttributesFromAmazon": "Missing attributes from Amazon", "paymentNotSuccessful": "The payment was not successful" } diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 2cdc301b44..a58113b77d 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -373,7 +373,8 @@ gulp.task('test:api-v3:integration', (done) => { }); gulp.task('test:api-v3:integration:watch', () => { - gulp.watch(['website/src/controllers/api-v3/**/*', 'test/api/v3/integration/**/*', 'common/script/ops/*'], ['test:api-v3:integration']); + gulp.watch(['website/src/controllers/api-v3/**/*', 'common/script/ops/*', 'website/src/libs/api-v3/*.js', + 'test/api/v3/integration/**/*'], ['test:api-v3:integration']); }); gulp.task('test:api-v3:integration:separate-server', (done) => { diff --git a/test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js b/test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js new file mode 100644 index 0000000000..66562c3721 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : amazon #subscribeCancel', () => { + let endpoint = '/payments/amazon/subscribeCancel'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies subscription', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingSubscription'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js new file mode 100644 index 0000000000..846416ed5d --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js @@ -0,0 +1,22 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - amazon - #checkout', () => { + let endpoint = '/payments/amazon/checkout'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async (done) => { + try { + await user.post(endpoint); + } catch (e) { + expect(e.error).to.eql('BadRequest'); + expect(e.message.type).to.eql('InvalidParameterValue'); + done(); + } + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js b/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js new file mode 100644 index 0000000000..3eb00b7c3c --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js @@ -0,0 +1,22 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - amazon - #createOrderReferenceId', () => { + let endpoint = '/payments/amazon/createOrderReferenceId'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies billingAgreementId', async (done) => { + try { + await user.post(endpoint); + } catch (e) { + // Parameter AWSAccessKeyId cannot be empty. + expect(e.error).to.eql('BadRequest'); + done(); + } + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js b/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js new file mode 100644 index 0000000000..02a30a7ce5 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - amazon - #subscribe', () => { + let endpoint = '/payments/amazon/subscribe'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies subscription code', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingSubscriptionCode'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js similarity index 100% rename from test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js rename to test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js deleted file mode 100644 index b2bf480e01..0000000000 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ /dev/null @@ -1,103 +0,0 @@ -import * as amzLib from '../../../../../website/src/libs/api-v3/amazonPayments'; -// import * as amzStub from 'amazon-payments'; -import amazonPayments from 'amazon-payments'; -var User = require('mongoose').model('User'); - -describe('amazonPayments', () => { - beforeEach(() => { - }); - - describe('#getTokenInfo stubbed', () => { - let thisToken = 'this token info'; - let amzOldConnect; - - beforeEach(() => { - amzOldConnect = amazonPayments.connect; - amazonPayments.connect = () => { - let api = { getTokenInfo: (token, cb) => { - return cb(undefined, thisToken); - } }; - return { api }; - }; - }); - - afterEach(() => { - amazonPayments.connect = amzOldConnect; - }); - - it('returns tokenInfo', async (done) => { - let result = await amzLib.getTokenInfo(); - expect(result).to.eql(thisToken); - done(); - }); - }); - - describe('#getTokenInfo', () => { - it('validates access_token parameter', async (done) => { - try { - await amzLib.getTokenInfo(); - } catch (e) { - expect(e.type).to.eql('invalid_request'); - done(); - } - }); - }); - - describe('#createOrderReferenceId', () => { - it('verifies billingAgreementId', async (done) => { - try { - let inputSet = {}; - delete inputSet.Id; - await amzLib.createOrderReferenceId(inputSet); - } catch (e) { - - /* console.log('error!', e); - console.log('error keys!', Object.keys(e)); - for (var key in e) { - console.log(e[key]); - } // */ - - expect(e.type).to.eql('InvalidParameterValue'); - expect(e.body.ErrorResponse.Error.Message).to.eql('Parameter AWSAccessKeyId cannot be empty.'); - done(); - } - }); - - xit('succeeds', () => { - }); - }); - - describe('#checkout', () => { - xit('succeeds'); - }); - - describe('#setOrderReferenceDetails', () => { - xit('succeeds'); - }); - - describe('#confirmOrderReference', () => { - xit('succeeds'); - }); - - describe('#authorize', () => { - xit('succeeds'); - - xit('was declined'); - - xit('had an error'); - }); - - describe('#closeOrderReference', () => { - xit('succeeds'); - }); - - describe.only('#executePayment', () => { - it('succeeds not as a gift', () => { - }); - - it('succeeds as a gift', () => { - }); - }); - - -}); diff --git a/test/api/v3/unit/libs/payments.test.js b/test/api/v3/unit/libs/payments.test.js new file mode 100644 index 0000000000..ad846e6488 --- /dev/null +++ b/test/api/v3/unit/libs/payments.test.js @@ -0,0 +1,75 @@ +import * as sender from '../../../../../website/src/libs/api-v3/email'; +import * as api from '../../../../../website/src/libs/api-v3/payments'; +import { model as User } from '../../../../../website/src/models/user'; +import moment from 'moment'; + +describe('payments/index', () => { + let fakeSend; + let data; + let user; + + describe('#createSubscription', () => { + beforeEach(async () => { + user = new User(); + }); + + it('succeeds', async () => { + data = { user, sub: { key: 'basic_3mo' } }; + expect(user.purchased.plan.planId).to.not.exist; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.exist; + }); + }); + + describe('#cancelSubscription', () => { + beforeEach(() => { + fakeSend = sinon.spy(sender, 'sendTxn'); + data = { user: new User() }; + }); + + afterEach(() => { + fakeSend.restore(); + }); + + it('plan.extraMonths is defined', () => { + api.cancelSubscription(data); + let terminated = data.user.purchased.plan.dateTerminated; + data.user.purchased.plan.extraMonths = 2; + api.cancelSubscription(data); + let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days')); + expect(difference - 60).to.be.lessThan(3); // the difference is approximately two months, +/- 2 days + }); + + it('plan.extraMonth is a fraction', () => { + api.cancelSubscription(data); + let terminated = data.user.purchased.plan.dateTerminated; + data.user.purchased.plan.extraMonths = 0.3; + api.cancelSubscription(data); + let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days')); + expect(difference - 10).to.be.lessThan(3); // the difference should be 10 days. + }); + + it('nextBill is defined', () => { + api.cancelSubscription(data); + let terminated = data.user.purchased.plan.dateTerminated; + data.nextBill = moment().add({ days: 25 }); + api.cancelSubscription(data); + let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days')); + expect(difference - 5).to.be.lessThan(2); // the difference should be 5 days, +/- 1 day + }); + + it('saves the canceled subscription for the user', () => { + expect(data.user.purchased.plan.dateTerminated).to.not.exist; + api.cancelSubscription(data); + expect(data.user.purchased.plan.dateTerminated).to.exist; + }); + + it('sends a text', async () => { + await api.cancelSubscription(data); + sinon.assert.calledOnce(fakeSend); + }); + }); + + describe('#buyGems', async () => { + }); +}); diff --git a/test/api/v3/unit/libs/paymentsIndex.test.js b/test/api/v3/unit/libs/paymentsIndex.test.js deleted file mode 100644 index 74b9d29273..0000000000 --- a/test/api/v3/unit/libs/paymentsIndex.test.js +++ /dev/null @@ -1,11 +0,0 @@ - -describe('payments/index', () => { - beforeEach(() => { - }); - - describe('#createSubscription', async () => { - }); - - describe('#buyGems', async () => { - }); -}); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 106cc95987..08c405424e 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,8 +2,6 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; import nconf from 'nconf'; -import setupNconf from '../../libs/api-v3/setupNconf'; -setupNconf(); import { authWithHeaders, } from '../../middlewares/api-v3/auth'; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 6aa8319f7c..42864fdab7 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -12,8 +12,6 @@ import _ from 'lodash'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; -import setupNconf from '../../libs/api-v3/setupNconf'; -setupNconf(); const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 785efb06b9..74998a13f4 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,18 +1,17 @@ -/* import async from 'async'; -import cc from 'coupon-code'; +/* import mongoose from 'mongoose'; -import moment from 'moment'; -import payments from './index'; -import shared from '../../../../../common'; import { model as User } from '../../../models/user'; */ import { - // NotFound, - // NotAuthorized, BadRequest, } from '../../../libs/api-v3/errors'; import amzLib from '../../../libs/api-v3/amazonPayments'; import { authWithHeaders } from '../../../middlewares/api-v3/auth'; -var payments = require('./index'); +import shared from '../../../../../common'; +import payments from '../../../libs/api-v3/payments'; +import moment from 'moment'; +import { model as Coupon } from '../../../models/coupon'; +import { model as User } from '../../../models/user'; +import cc from 'coupon-code'; let api = {}; @@ -21,19 +20,22 @@ let api = {}; * @apiVersion 3.0.0 * @apiName AmazonVerifyAccessToken * @apiGroup Payments + * * @apiParam {string} access_token the access token + * * @apiSuccess {} empty **/ api.verifyAccessToken = { method: 'POST', url: '/payments/amazon/verifyAccessToken', + middlewares: [authWithHeaders()], async handler (req, res) { - await amzLib.getTokenInfo(req.body.access_token) - .then(() => { + try { + await amzLib.getTokenInfo(req.body.access_token); res.respond(200, {}); - }).catch((error) => { + } catch (error) { throw new BadRequest(error.body.error_description); - }); + } }, }; @@ -42,21 +44,21 @@ api.verifyAccessToken = { * @apiVersion 3.0.0 * @apiName AmazonCreateOrderReferenceId * @apiGroup Payments + * * @apiParam {string} billingAgreementId billing agreement id - * @apiSuccess {object} object containing { orderReferenceId } + * + * @apiSuccess {object} data.orderReferenceId The order reference id. **/ api.createOrderReferenceId = { method: 'POST', url: '/payments/amazon/createOrderReferenceId', - // middlewares: [authWithHeaders()], + middlewares: [authWithHeaders()], async handler (req, res) { - try { let response = await amzLib.createOrderReferenceId({ Id: req.body.billingAgreementId, IdType: 'BillingAgreement', ConfirmNow: false, - AWSAccessKeyId: 'something', }); res.respond(200, { orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, @@ -64,7 +66,6 @@ api.createOrderReferenceId = { } catch (error) { throw new BadRequest(error); } - }, }; @@ -75,6 +76,7 @@ api.createOrderReferenceId = { * @apiGroup Payments * * @apiParam {string} billingAgreementId billing agreement id + * * @apiSuccess {object} object containing { orderReferenceId } **/ api.checkout = { @@ -95,10 +97,6 @@ api.checkout = { } } - /* if (!req.body || !req.body.orderReferenceId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } */ - try { await amzLib.setOrderReferenceDetails({ AmazonOrderReferenceId: orderReferenceId, @@ -132,53 +130,57 @@ api.checkout = { await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); // execute payment - let giftUser = await User.findById(gift ? gift.uuid : undefined); - let data = { giftUser, paymentMethod: 'Amazon Payments' }; let method = 'buyGems'; + let data = { user, paymentMethod: 'Amazon Payments' }; if (gift) { if (gift.type === 'subscription') method = 'createSubscription'; - gift.member = giftUser; + gift.member = await User.findById(gift ? gift.uuid : undefined); data.gift = gift; data.paymentMethod = 'Gift'; } await payments[method](data); res.respond(200); - } catch(error) { + } catch (error) { throw new BadRequest(error); } + }, }; +/** + * @api {post} /api/v3/payments/amazon/subscribe Subscribe + * @apiVersion 3.0.0 + * @apiName AmazonSubscribe + * @apiGroup Payments + * + * @apiParam {string} billingAgreementId billing agreement id + * @apiParam {string} subscription Subscription plan + * @apiParam {string} coupon Coupon + * + * @apiSuccess {object} data.orderReferenceId The order reference id. + **/ +api.subscribe = { + method: 'POST', + url: '/payments/amazon/subscribe', + middlewares: [authWithHeaders()], + async handler (req, res) { + let billingAgreementId = req.body.billingAgreementId; + let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; + let coupon = req.body.coupon; + let user = res.locals.user; + if (!sub) { + throw new BadRequest(res.t('missingSubscriptionCode')); + } -/* -api.subscribe = function subscribe (req, res, next) { - if (!req.body || !req.body.billingAgreementId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } + try { + if (sub.discount) { // apply discount + if (!coupon) throw new BadRequest(res.t('couponCodeRequired')); + let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key}); + if (!result) throw new BadRequest(res.t('invalidCoupon')); + } - let billingAgreementId = req.body.billingAgreementId; - let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; - let coupon = req.body.coupon; - let user = res.locals.user; - - if (!sub) { - return res.status(400).json({err: 'Subscription plan not found.'}); - } - - async.series({ - applyDiscount (cb) { - if (!sub.discount) return cb(); - if (!coupon) return cb(new Error('Please provide a coupon code for this plan.')); - mongoose.model('Coupon').findOne({_id: cc.validate(coupon), event: sub.key}, function couponResult (err) { - if (err) return cb(err); - if (!coupon) return cb(new Error('Coupon code not found.')); - cb(); - }); - }, - - setBillingAgreementDetails (cb) { - amzPayment.offAmazonPayments.setBillingAgreementDetails({ + await amzLib.setBillingAgreementDetails({ AmazonBillingAgreementId: billingAgreementId, BillingAgreementAttributes: { SellerNote: 'HabitRPG Subscription', @@ -188,17 +190,13 @@ api.subscribe = function subscribe (req, res, next) { CustomInformation: 'HabitRPG Subscription', }, }, - }, cb); - }, + }); - confirmBillingAgreement (cb) { - amzPayment.offAmazonPayments.confirmBillingAgreement({ + await amzLib.confirmBillingAgreement({ AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, + }); - authorizeOnBillingAgreement (cb) { - amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ + await amzLib.authorizeOnBillingAgreement({ AmazonBillingAgreementId: billingAgreementId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { @@ -213,68 +211,57 @@ api.subscribe = function subscribe (req, res, next) { SellerOrderId: shared.uuid(), StoreName: 'HabitRPG', }, - }, function billingAgreementResult (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successful.')); - } - - return cb(); }); - }, - createSubscription (cb) { - payments.createSubscription({ + await payments.createSubscription({ user, customerId: billingAgreementId, paymentMethod: 'Amazon Payments', sub, - }, cb); - }, - }, function subscribeResult (err) { - if (err) return next(err); + }); - res.sendStatus(200); - }); + res.respond(200); + } catch (error) { + throw new BadRequest(error); + } + }, }; -api.subscribeCancel = function subscribeCancel (req, res, next) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.status(401).json({err: 'User does not have a plan subscription'}); +/** + * @api {get} /api/v3/payments/amazon/subscribe/cancel SubscribeCancel + * @apiVersion 3.0.0 + * @apiName AmazonSubscribe + * @apiGroup Payments + * + * @apiSuccess {object} empty object + **/ +api.subscribeCancel = { + method: 'GET', + url: '/payments/amazon/subscribe/cancel', + middlewares: [authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + let billingAgreementId = user.purchased.plan.customerId; - let billingAgreementId = user.purchased.plan.customerId; + if (!billingAgreementId) throw new BadRequest(res.t('missingSubscription')); - async.series({ - closeBillingAgreement (cb) { - amzPayment.offAmazonPayments.closeBillingAgreement({ + try { + await amzLib.closeBillingAgreement({ AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, + }); - cancelSubscription (cb) { let data = { user, - // Date of next bill - nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), + nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }), paymentMethod: 'Amazon Payments', }; + await payments.cancelSubscription(data); - payments.cancelSubscription(data, cb); - }, - }, function subscribeCancelResult (err) { - if (err) return next(err); // don't json this, let toString() handle errors - - if (req.query.noRedirect) { - res.sendStatus(200); - } else { - res.redirect('/'); + res.respond(200, {}); + } catch (error) { + throw new BadRequest(error.message); } - - user = null; - }); + }, }; -*/ module.exports = api; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 05e8594ac1..c23fa6247d 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -4,7 +4,7 @@ var async = require('async'); var _ = require('lodash'); var url = require('url'); var User = require('mongoose').model('User'); -var payments = require('./index'); +var payments = require('../../../libs/api-v3/payments'); var logger = require('../../../libs/api-v2/logging'); var ipn = require('paypal-ipn'); var paypal = require('paypal-rest-sdk'); diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index 34bc598b3e..6a553dacd5 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -1,7 +1,7 @@ /* import nconf from 'nconf'; import stripeModule from 'stripe'; import async from 'async'; -import payments from './index'; +import payments from '../../../libs/api-v3/payments'; import { model as User } from '../../../models/user'; import shared from '../../../../../common'; import mongoose from 'mongoose'; diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index 3d92132f2f..38403ba4d0 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -3,65 +3,41 @@ import nconf from 'nconf'; import common from '../../../../common'; let t = common.i18n.t; const IS_PROD = nconf.get('NODE_ENV') === 'production'; +import Q from 'q'; -let api = {}; +let amzPayment = amazonPayments.connect({ + environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], + sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), + mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), + mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), + clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), +}); -function connect (amazonPayments) { // eslint-disable-line no-shadow - return amazonPayments.connect({ - environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], - sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), - mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), - mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), - clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), - }); -} +/** + * From: https://payments.amazon.com/documentation/apireference/201751670#201751670 + */ -api.getTokenInfo = (token) => { - let amzPayment = connect(amazonPayments); +let getTokenInfo = Q.nbind(amzPayment.api.getTokenInfo, amzPayment.api); +let createOrderReferenceId = Q.nbind(amzPayment.offAmazonPayments.createOrderReferenceForId, amzPayment.offAmazonPayments); +let setOrderReferenceDetails = Q.nbind(amzPayment.offAmazonPayments.setOrderReferenceDetails, amzPayment.offAmazonPayments); +let confirmOrderReference = Q.nbind(amzPayment.offAmazonPayments.confirmOrderReference, amzPayment.offAmazonPayments); +let closeOrderReference = Q.nbind(amzPayment.offAmazonPayments.closeOrderReference, amzPayment.offAmazonPayments); +let setBillingAgreementDetails = Q.nbind(amzPayment.offAmazonPayments.setBillingAgreementDetails, amzPayment.offAmazonPayments); +let confirmBillingAgreement = Q.nbind(amzPayment.offAmazonPayments.confirmBillingAgreement, amzPayment.offAmazonPayments); +let closeBillingAgreement = Q.nbind(amzPayment.offAmazonPayments.closeBillingAgreement, amzPayment.offAmazonPayments); + +let authorizeOnBillingAgreement = (inputSet) => { return new Promise((resolve, reject) => { - amzPayment.api.getTokenInfo(token, (err, tokenInfo) => { + amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => { if (err) return reject(err); - return resolve(tokenInfo); - }); - }); -}; - -api.createOrderReferenceId = (inputSet) => { - let amzPayment = connect(amazonPayments); - return new Promise((resolve, reject) => { - amzPayment.offAmazonPayments.createOrderReferenceForId(inputSet, (err, response) => { - if (err) return reject(err); - if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { - return reject(t('missingAttributesFromAmazon')); - } + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); return resolve(response); }); }); }; -api.setOrderReferenceDetails = (inputSet) => { - let amzPayment = connect(amazonPayments); +let authorize = (inputSet) => { return new Promise((resolve, reject) => { - amzPayment.offAmazonPayments.setOrderReferenceDetails(inputSet, (err, response) => { - if (err) return reject(err); - return resolve(response); - }); - }); -}; - -api.confirmOrderReference = (inputSet) => { - let amzPayment = connect(amazonPayments); - return new Promise((resolve, reject) => { - amzPayment.offAmazonPayments.confirmOrderReference(inputSet, (err, response) => { - if (err) return reject(err); - return resolve(response); - }); - }); -}; - -api.authorize = (inputSet) => { - let amzPayment = connect(amazonPayments); - return new Promize((resolve, reject) => { amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { if (err) return reject(err); if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); @@ -70,24 +46,15 @@ api.authorize = (inputSet) => { }); }; -api.closeOrderReference = (inputSet) => { - let amzPayment = connect(amazonPayments); - return new Promize((resolve, reject) => { - amzPayment.offAmazonPayments.closeOrderReference(inputSet, (err, response) => { - if (err) return reject(err); - return resolve(response); - }); - }); +module.exports = { + getTokenInfo, + createOrderReferenceId, + setOrderReferenceDetails, + confirmOrderReference, + closeOrderReference, + confirmBillingAgreement, + setBillingAgreementDetails, + closeBillingAgreement, + authorizeOnBillingAgreement, + authorize, }; - -api.executePayment = (inputSet) => { - let amzPayment = connect(amazonPayments); - return new Promize((resolve, reject) => { - amzPayment.offAmazonPayments.closeOrderReference(inputSet, (err, response) => { - if (err) return reject(err); - return resolve(response); - }); - }); -}; - -module.exports = api; diff --git a/website/src/controllers/top-level/payments/index.js b/website/src/libs/api-v3/payments.js similarity index 74% rename from website/src/controllers/top-level/payments/index.js rename to website/src/libs/api-v3/payments.js index 6ee56bf0d2..0e94153150 100644 --- a/website/src/controllers/top-level/payments/index.js +++ b/website/src/libs/api-v3/payments.js @@ -1,24 +1,22 @@ import _ from 'lodash' ; -import analytics from '../../../libs/api-v3/analyticsService'; -import async from 'async'; +import analytics from './analyticsService'; import cc from 'coupon-code'; import { getUserInfo, sendTxn as txnEmail, -} from '../../../libs/api-v3/email'; -import members from '../../api-v3/members'; +} from './email'; +import members from '../../controllers/api-v3/members'; import moment from 'moment'; import mongoose from 'mongoose'; import nconf from 'nconf'; -import pushNotify from '../../../libs/api-v3/pushNotifications'; -import shared from '../../../../../common' ; +import pushNotify from './pushNotifications'; +import shared from '../../../../common' ; -import amazon from './amazon'; -import iap from './iap'; -import paypal from './paypal'; -import stripe from './stripe'; +import iap from '../../controllers/top-level/payments/iap'; +import paypal from '../../controllers/top-level/payments/paypal'; +import stripe from '../../controllers/top-level/payments/stripe'; -const IS_PROD = nconf.get('NODE_ENV') === 'production'; +const IS_PROD = nconf.get('IS_PROD'); let api = {}; @@ -36,10 +34,7 @@ function revealMysteryItems (user) { }); } -// @TODO: HEREHERE api.createSubscription = async function createSubscription (data) { -} -api.createSubscription = function createSubscription (data, cb) { let recipient = data.gift ? data.gift.member : data.user; let plan = recipient.purchased.plan; let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; @@ -81,6 +76,7 @@ api.createSubscription = function createSubscription (data, cb) { plan.consecutive.trinkets += perks; } revealMysteryItems(recipient); + if (IS_PROD) { if (!data.gift) txnEmail(data.user, 'subscription-begins'); @@ -96,7 +92,9 @@ api.createSubscription = function createSubscription (data, cb) { }; analytics.trackPurchase(analyticsData); } + data.user.purchased.txnCount++; + if (data.gift) { members.sendMessage(data.user, data.gift.member, data.gift); @@ -113,50 +111,41 @@ api.createSubscription = function createSubscription (data, cb) { pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); } } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); + + await data.user.save(); + if (data.gift) await data.gift.member.save(); }; /** * Sets their subscription to be cancelled later */ -api.cancelSubscription = function cancelSubscription (data, cb) { +api.cancelSubscription = async function cancelSubscription (data) { let plan = data.user.purchased.plan; let now = moment(); let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; + let nowStr = `${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`; + let nowStrFormat = 'MM/DD/YYYY'; plan.dateTerminated = - moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) + moment(nowStr, nowStrFormat) .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .add({days: Math.ceil(30 * plan.extraMonths)}) // plus any extra time (carry-over, gifted subscription, etc) they have. .toDate(); plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated - data.user.save(cb); + await data.user.save(); + txnEmail(data.user, 'cancel-subscription'); - let analyticsData = { + + analytics.track('unsubscribe', { uuid: data.user._id, gaCategory: 'commerce', gaLabel: data.paymentMethod, paymentMethod: data.paymentMethod, - }; - analytics.track('unsubscribe', analyticsData); + }); }; -// @TODO: HEREHERE api.buyGems = async function buyGems (data) { -}; -api.buyGems = function buyGems (data, cb) { let amt = data.amount || 5; amt = data.gift ? data.gift.gems.amount / 4 : amt; (data.gift ? data.gift.member : data.user).balance += amt; @@ -192,27 +181,9 @@ api.buyGems = function buyGems (data, cb) { if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); } + await data.gift.member.save(); } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); -}; - -api.validCoupon = function validCoupon (req, res, next) { - mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { - if (err) return next(err); - if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); - return res.sendStatus(200); - }); + await data.user.save(); }; api.stripeCheckout = stripe.checkout; @@ -226,12 +197,6 @@ api.paypalCheckout = paypal.createPayment; api.paypalCheckoutSuccess = paypal.executePayment; api.paypalIPN = paypal.ipn; -api.amazonVerifyAccessToken = amazon.verifyAccessToken; -api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -api.amazonCheckout = amazon.checkout; -api.amazonSubscribe = amazon.subscribe; -api.amazonSubscribeCancel = amazon.subscribeCancel; - api.iapAndroidVerify = iap.androidVerify; api.iapIosVerify = iap.iosVerify; From 3364019fcc729aed61b457003f28dc7806d0db3c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Apr 2016 11:49:12 +0200 Subject: [PATCH 723/976] v3 and adapted v2: bugs fixes for groups, challenges and tasks --- common/script/fns/randomDrop.js | 2 +- website/src/controllers/api-v2/challenges.js | 7 ++-- website/src/controllers/api-v2/user.js | 9 ++++- website/src/controllers/api-v3/user.js | 22 ++++++++++-- website/src/models/group.js | 36 ++++++++++---------- 5 files changed, 51 insertions(+), 25 deletions(-) diff --git a/common/script/fns/randomDrop.js b/common/script/fns/randomDrop.js index 3d1114676a..102709da57 100644 --- a/common/script/fns/randomDrop.js +++ b/common/script/fns/randomDrop.js @@ -3,7 +3,7 @@ import content from '../content/index'; import i18n from '../i18n'; import { daysSince } from '../cron'; import { diminishingReturns } from '../statHelpers'; -import { predictableRandom } from './predictableRandom'; +import predictableRandom from './predictableRandom'; import randomVal from './randomVal'; // Clone a drop object maintaining its functions so that we can change it without affecting the original item diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 605e3424cf..51f57433c7 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -52,7 +52,9 @@ api.list = async function(req, res, next) { let obj = challenge.toJSON(); obj._isMember = user.challenges.indexOf(challenge._id) !== -1; + return obj; }); + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 await Q.all(resChals.map((chal, index) => { return Q.all([ @@ -195,7 +197,8 @@ api.create = async function(req, res, next){ req.body.rewards = req.body.rewards || []; var chalTasks = req.body.habits.concat(req.body.rewards) - .concat(req.body.dailys).concat(req.body.todos); + .concat(req.body.dailys).concat(req.body.todos) + .map(v2Task => Tasks.Task.fromJSONV2(v2Task)); chalTasks = chalTasks.map(function(task) { var newTask = new Tasks[task.type](Tasks.Task.sanitize(task)); @@ -318,7 +321,7 @@ api.selectWinner = async function(req, res, next) { if (!challenge) return next('Challenge ' + req.params.cid + ' not found'); if (!challenge.canModify(res.locals.user)) return next(shared.i18n.t('noPermissionCloseChallenge')); - let winner = await User.findOne({_id: req.params.uid}).exec(); + let winner = await User.findOne({_id: req.query.uid}).exec(); if (!winner || winner.challenges.indexOf(challenge._id) === -1) return next('Winner ' + req.query.uid + ' not found.'); // Close channel in background, some ops are run in the background without `await`ing diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index c11c371c01..d0430c6a80 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -676,7 +676,14 @@ api.cast = async function(req, res, next) { if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); spell.cast(user, partyMembers, req); - await partyMembers.save(); + if (partyMembers === user) { + await partyMembers.save(); + } else { + await Q.all([ + await partyMembers.save(), + await user.save(), + ]); + } } if (party && !spell.silent) { diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index f76c785369..a1e34ad435 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -350,7 +350,15 @@ api.castSpell = { if (task.challenge.id) throw new BadRequest(res.t('challengeTasksNoCast')); spell.cast(user, task, req); - await task.save(); + if (user.isModified()) { + await Q.all([ + user.save(), + task.save(), + ]); + } else { + await task.save(); + } + res.respond(200, task); } else if (targetType === 'self') { spell.cast(user, null, req); @@ -370,7 +378,8 @@ api.castSpell = { let toSave = tasks.filter(t => t.isModified()); let isUserModified = user.isModified(); - toSave.unshift(user.save()); + + if (isUserModified) toSave.unshift(user.save()); let saved = await Q.all(toSave); let response = { @@ -403,7 +412,14 @@ api.castSpell = { if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); spell.cast(user, partyMembers, req); - await partyMembers.save(); + if (user.isModified()) { + await Q.all([ + user.save(), + partyMembers.save(), + ]); + } else { + await partyMembers.save(); + } } res.respond(200, partyMembers); diff --git a/website/src/models/group.js b/website/src/models/group.js index ac9abd13fc..846a018a16 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -631,32 +631,32 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { let promises = []; - // If user is the last one in group and group is private, delete it - if (group.memberCount <= 1 && group.privacy === 'private') { - return await group.remove(); - } - - // otherwise just remove a member TODO create User.methods.removeFromGroup? + // remove the group from the user's groups if (group.type === 'guild') { promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id}}).exec()); } else { promises.push(User.update({_id: user._id}, {$set: {party: {}}}).exec()); } - // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) - let update = { - $inc: {memberCount: -1}, - }; + // If user is the last one in group and group is private, delete it + if (group.memberCount <= 1 && group.privacy === 'private') { + return await group.remove(); + } else { // otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for) + let update = { + $inc: {memberCount: -1}, + }; - if (group.leader === user._id) { - let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id}; - query._id = {$ne: user._id}; - let seniorMember = await User.findOne(query).select('_id').exec(); + if (group.leader === user._id) { + let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id}; + query._id = {$ne: user._id}; + let seniorMember = await User.findOne(query).select('_id').exec(); - // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving - if (seniorMember) update.$set = {leader: seniorMember._id}; + // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving + if (seniorMember) update.$set = {leader: seniorMember._id}; + } + promises.push(group.update(update).exec()); } - promises.push(group.update(update).exec()); + firebase.removeUserFromGroup(group._id, user._id); return Q.all(promises); @@ -730,7 +730,7 @@ if (!nconf.get('IS_TEST')) { new model({ // eslint-disable-line babel/new-cap _id: TAVERN_ID, leader: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', // Siena Leslie - name: 'HabitRPG', + name: 'Tavern', type: 'guild', privacy: 'public', }).save(); From 415418f30ca5bd1832fe27e9bc165f2b79b07b37 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 30 Apr 2016 02:54:25 -0600 Subject: [PATCH 724/976] Api v3 members port (#7109) * Ported groups service to user new api v3 and ported dependent controllers * Remove and extra remove inviation code. Fixed group service caching and update group service tests * Fixed test logic and added party cache support * Updated members service to use api v3 * Removed onlys * Added invites to group detail * Removed old user reject invite code --- test/spec/services/memberServicesSpec.js | 78 ++++++++++-- website/public/js/app.js | 12 +- website/public/js/controllers/groupsCtrl.js | 29 +++-- website/public/js/controllers/guildsCtrl.js | 1 - .../public/js/controllers/memberModalCtrl.js | 26 ++-- website/public/js/services/groupServices.js | 2 +- website/public/js/services/memberServices.js | 118 +++++++++++++----- 7 files changed, 196 insertions(+), 70 deletions(-) diff --git a/test/spec/services/memberServicesSpec.js b/test/spec/services/memberServicesSpec.js index d33ccd8c98..1344bf96e7 100644 --- a/test/spec/services/memberServicesSpec.js +++ b/test/spec/services/memberServicesSpec.js @@ -2,6 +2,7 @@ describe('memberServices', function() { var $httpBackend, members; + var apiV3Prefix = '/api/v3'; beforeEach(inject(function (_$httpBackend_, Members) { $httpBackend = _$httpBackend_; @@ -20,10 +21,51 @@ describe('memberServices', function() { expect(members.selectedMember).to.be.undefined; }); + it('calls fetch member', function() { + var memberId = 1; + var memberUrl = apiV3Prefix + '/members/' + memberId; + $httpBackend.expectGET(memberUrl).respond({}); + members.fetchMember(memberId); + $httpBackend.flush(); + }); + + it('calls get group members', function() { + var groupId = 1; + var memberUrl = apiV3Prefix + '/groups/' + groupId + '/members'; + $httpBackend.expectGET(memberUrl).respond({}); + members.getGroupMembers(groupId); + $httpBackend.flush(); + }); + + it('calls get group invites', function() { + var groupId = 1; + var memberUrl = apiV3Prefix + '/groups/' + groupId + '/invites'; + $httpBackend.expectGET(memberUrl).respond({}); + members.getGroupInvites(groupId); + $httpBackend.flush(); + }); + + it('calls get challenge members', function() { + var challengeId = 1; + var memberUrl = apiV3Prefix + '/challenges/' + challengeId + '/members'; + $httpBackend.expectGET(memberUrl).respond({}); + members.getChallengeMembers(challengeId); + $httpBackend.flush(); + }); + + it('calls get challenge members progress', function() { + var challengeId = 1; + var memberId = 2; + var memberUrl = apiV3Prefix + '/challenges/' + challengeId + '/members/' + memberId; + $httpBackend.expectGET(memberUrl).respond({}); + members.getChallengeMemberProgress(challengeId, memberId); + $httpBackend.flush(); + }); + describe('addToMembersList', function() { it('adds member to members object', function() { var member = { _id: 'user_id' }; - members.addToMembersList(member); + members.addToMembersList(member, members); expect(members.members).to.eql({ user_id: { _id: 'user_id' } }); @@ -31,27 +73,37 @@ describe('memberServices', function() { }); describe('selectMember', function() { - it('fetches member if not already in cache', function() { + it('fetches member if not already in cache', function(done) { var uid = 'abc'; - $httpBackend.expectGET('/api/v2/members/' + uid).respond({ _id: uid }); - members.selectMember(uid, function(){}); + var memberResponse = { + data: {_id: uid}, + } + $httpBackend.expectGET(apiV3Prefix + '/members/' + uid).respond(memberResponse); + members.selectMember(uid) + .then(function () { + expect(members.selectedMember._id).to.eql(uid); + expect(members.members).to.have.property(uid); + done(); + }); $httpBackend.flush(); - - expect(members.selectedMember._id).to.eql(uid); - expect(members.members).to.have.property(uid); }); - it('fetches member if member data in cache is incomplete', function() { + it('fetches member if member data in cache is incomplete', function(done) { var uid = 'abc'; members.members = { abc: { _id: 'abc', items: {} } } - $httpBackend.expectGET('/api/v2/members/' + uid).respond({ _id: uid }); - members.selectMember(uid, function(){}); + var memberResponse = { + data: {_id: uid}, + } + $httpBackend.expectGET(apiV3Prefix + '/members/' + uid).respond(memberResponse); + members.selectMember(uid) + .then(function () { + expect(members.selectedMember._id).to.eql(uid); + expect(members.members).to.have.property(uid); + done(); + }); $httpBackend.flush(); - - expect(members.selectedMember._id).to.eql(uid); - expect(members.members).to.have.property(uid); }); it('gets member from cache if member has a weapons object', function() { diff --git a/website/public/js/app.js b/website/public/js/app.js index 47ed3fb970..b8beb61d4e 100644 --- a/website/public/js/app.js +++ b/website/public/js/app.js @@ -150,12 +150,20 @@ window.habitrpg = angular.module('habitrpg', url: '/:gid', templateUrl: 'partials/options.social.guilds.detail.html', title: env.t('titleGuilds'), - controller: ['$scope', 'Groups', 'Chat', '$stateParams', - function($scope, Groups, Chat, $stateParams){ + controller: ['$scope', 'Groups', 'Chat', '$stateParams', 'Members', + function($scope, Groups, Chat, $stateParams, Members){ Groups.Group.get($stateParams.gid) .then(function (response) { $scope.group = response.data.data; Chat.markChatSeen($scope.group._id); + Members.getGroupMembers($scope.group._id) + .then(function (response) { + $scope.group.members = response.data.data; + }); + Members.getGroupInvites($scope.group._id) + .then(function (response) { + $scope.group.invites = response.data.data; + }); }); }] }) diff --git a/website/public/js/controllers/groupsCtrl.js b/website/public/js/controllers/groupsCtrl.js index 224efc6fdb..f932d62cb6 100644 --- a/website/public/js/controllers/groupsCtrl.js +++ b/website/public/js/controllers/groupsCtrl.js @@ -2,7 +2,6 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', 'Notification', function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state, Notification) { - $scope.isMemberOfPendingQuest = function (userid, group) { if (!group.quest || !group.quest.members) return false; if (group.quest.active) return false; // quest is started, not pending @@ -39,7 +38,8 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' }; $scope.Members = Members; - $scope._editing = {group:false}; + + $scope._editing = {group: false}; $scope.groupCopy = {}; $scope.editGroup = function (group) { @@ -74,7 +74,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' // ------ Modals ------ - $scope.clickMember = function(uid, forceShow) { + $scope.clickMember = function (uid, forceShow) { if (User.user._id == uid && !forceShow) { if ($state.is('tasks')) { $state.go('options.profile.avatar'); @@ -84,13 +84,14 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } else { // We need the member information up top here, but then we pass it down to the modal controller // down below. Better way of handling this? - Members.selectMember(uid, function(){ - $rootScope.openModal('member', {controller:'MemberModalCtrl', windowClass:'profile-modal', size:'lg'}); - }); + Members.selectMember(uid) + .then(function () { + $rootScope.openModal('member', {controller: 'MemberModalCtrl', windowClass: 'profile-modal', size: 'lg'}); + }); } }; - $scope.removeMember = function(group, member, isMember){ + $scope.removeMember = function (group, member, isMember) { // TODO find a better way to do this (share data with remove member modal) $scope.removeMemberData = { group: group, @@ -100,7 +101,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $rootScope.openModal('remove-member', {scope: $scope}); }; - $scope.confirmRemoveMember = function(confirm){ + $scope.confirmRemoveMember = function (confirm) { if (confirm) { Groups.Group.removeMember( $scope.removeMemberData.group._id, @@ -120,10 +121,11 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } }; - $scope.openInviteModal = function(group){ + $scope.openInviteModal = function (group) { if (group.type !== 'party' && group.type !== 'guild') { return console.log('Invalid group type.') } + $rootScope.openModal('invite-' + group.type, { controller:'InviteToGroupCtrl', resolve: { @@ -134,9 +136,10 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' }); }; - $scope.quickReply = function(uid) { - Members.selectMember(uid, function(){ - $rootScope.openModal('private-message',{controller:'MemberModalCtrl'}); - }); + $scope.quickReply = function (uid) { + Members.selectMember(uid) + .then(function (response) { + $rootScope.openModal('private-message', {controller: 'MemberModalCtrl'}); + }); } }]); diff --git a/website/public/js/controllers/guildsCtrl.js b/website/public/js/controllers/guildsCtrl.js index 70493071c0..dfedbddc7c 100644 --- a/website/public/js/controllers/guildsCtrl.js +++ b/website/public/js/controllers/guildsCtrl.js @@ -17,7 +17,6 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$r $scope.groups.public = guilds; }); - $scope.type = 'guild'; $scope.text = window.env.t('guild'); diff --git a/website/public/js/controllers/memberModalCtrl.js b/website/public/js/controllers/memberModalCtrl.js index 54f0bb054a..ed501a5574 100644 --- a/website/public/js/controllers/memberModalCtrl.js +++ b/website/public/js/controllers/memberModalCtrl.js @@ -20,16 +20,17 @@ habitrpg }); $scope.sendPrivateMessage = function(uuid, message){ - // Don't do anything if the user somehow gets here without a message. if (!message) return; - $http.post('/api/v2/members/'+uuid+'/message',{message:message}).success(function(){ - Notification.text(window.env.t('messageSentAlert')); - $rootScope.User.sync(); - $scope.$close(); - }); + Members.sendPrivateMessage(message, uuid) + .then(function (response) { + Notification.text(window.env.t('messageSentAlert')); + $rootScope.User.sync(); + $scope.$close(); + }); }; + //@TODO: We don't send subscriptions so the structure has changed in the back. Update this when we update the views. $scope.gift = { type: 'gems', gems: {amount:0, fromBalance:true}, @@ -37,12 +38,13 @@ habitrpg message:'' }; - $scope.sendGift = function(uuid, gift){ - $http.post('/api/v2/members/'+uuid+'/gift', gift).success(function(){ - Notification.text('Gift sent!') - $rootScope.User.sync(); - $scope.$close(); - }) + $scope.sendGift = function (uuid, gift) { + Members.transferGems(message, uuid, $scope.gift.gems.amount) + .then(function (response) { + Notification.text('Gift sent!') + $rootScope.User.sync(); + $scope.$close(); + }); }; $scope.reportAbuse = function(reporter, message, groupId) { diff --git a/website/public/js/services/groupServices.js b/website/public/js/services/groupServices.js index e208b5bbdf..bff834582a 100644 --- a/website/public/js/services/groupServices.js +++ b/website/public/js/services/groupServices.js @@ -76,7 +76,7 @@ angular.module('habitrpg') Group.removeMember = function(gid, memberId, message) { return $http({ method: "POST", - url: groupApiURLPrefix + gid + '/removeMember/' + memberId, + url: groupApiURLPrefix + '/' + gid + '/removeMember/' + memberId, data: { message: message, }, diff --git a/website/public/js/services/memberServices.js b/website/public/js/services/memberServices.js index 4146a4ea1f..66dd4d2de5 100644 --- a/website/public/js/services/memberServices.js +++ b/website/public/js/services/memberServices.js @@ -1,49 +1,105 @@ 'use strict'; -(function(){ - angular - .module('habitrpg') - .factory('Members', membersFactory); - membersFactory.$inject = [ - '$rootScope', - 'Shared', - 'ApiUrl', - '$resource' - ]; - - function membersFactory($rootScope, Shared, ApiUrl, $resource) { +angular.module('habitrpg') +.factory('Members', [ '$rootScope', 'Shared', 'ApiUrl', '$resource', '$http', '$q', + function($rootScope, Shared, ApiUrl, $resource, $http, $q) { var members = {}; - var fetchMember = $resource(ApiUrl.get() + '/api/v2/members/:uid', { uid: '@_id' }).get; + var selectedMember = {}; + var apiV3Prefix = '/api/v3'; - function selectMember(uid, cb) { + function fetchMember (memberId) { + return $http({ + method: 'GET', + url: apiV3Prefix + '/members/' + memberId, + }); + } + //@TODO: Add paging + function getGroupMembers (groupId) { + return $http({ + method: 'GET', + url: apiV3Prefix + '/groups/' + groupId + '/members', + }); + } + + function getGroupInvites (groupId) { + return $http({ + method: 'GET', + url: apiV3Prefix + '/groups/' + groupId + '/invites', + }); + } + + function getChallengeMembers (challengeId) { + return $http({ + method: 'GET', + url: apiV3Prefix + '/challenges/' + challengeId + '/members', + }); + } + + function getChallengeMemberProgress (challengeId, memberId) { + return $http({ + method: 'GET', + url: apiV3Prefix + '/challenges/' + challengeId + '/members/' + memberId, + }); + } + + function sendPrivateMessage (message, toUserId) { + return $http({ + method: 'POST', + url: apiV3Prefix + '/members/send-private-message', + data: { + message: message, + toUserId: toUserId, + } + }); + } + + function transferGems (message, toUserId, gemAmount) { + return $http({ + method: 'POST', + url: apiV3Prefix + '/members/send-private-message', + data: { + message: message, + toUserId: toUserId, + gemAmount: gemAmount, + } + }); + } + + function selectMember (uid) { var self = this; + var deferred = $q.defer(); var memberIsReady = _checkIfMemberIsReady(members[uid]); if (memberIsReady) { - _prepareMember(self, members[uid], cb); + _prepareMember(members[uid], self); + deferred.resolve(); } else { - fetchMember({ uid: uid }, function(member) { - addToMembersList(member); // lazy load for later - _prepareMember(self, member, cb); - }); + fetchMember(uid) + .then(function (response) { + var member = response.data.data; + addToMembersList(member, self); // lazy load for later + _prepareMember(member, self); + deferred.resolve(); + }); } + + return deferred.promise; } - function addToMembersList(member){ + function addToMembersList (member, self) { if (member._id) { - members[member._id] = member; + self.members[member._id] = member; } } - function _checkIfMemberIsReady(member) { + function _checkIfMemberIsReady (member) { return member && member.items && member.items.weapon; } - function _prepareMember(self, member, cb) { + function _prepareMember(member, self) { Shared.wrap(member, false); - self.selectedMember = members[member._id]; - cb(); + self.selectedMember = self.members[member._id]; } $rootScope.$on('userUpdated', function(event, user){ @@ -54,7 +110,13 @@ members: members, addToMembersList: addToMembersList, selectedMember: undefined, - selectMember: selectMember + selectMember: selectMember, + fetchMember: fetchMember, + getGroupMembers: getGroupMembers, + getGroupInvites: getGroupInvites, + getChallengeMembers: getChallengeMembers, + getChallengeMemberProgress: getChallengeMemberProgress, + sendPrivateMessage: sendPrivateMessage, + transferGems: transferGems, } - } -}()); + }]); From a567476bb7f3fcdf557847106c81d2af77813f81 Mon Sep 17 00:00:00 2001 From: Victor Pudeyev Date: Sat, 30 Apr 2016 09:42:10 -0500 Subject: [PATCH 725/976] V3 payments 7 stripe (#7124) * payments api: cancelSubscription * some more tests for amazon payments * promisifying amazon payments * somehow payment stub is not working * cleaning up tests * renaming tests in api/v3/integration/payments * improvements * cleanup, lint * fixes as per comments * moment.zone() is back in. * basic controller for stripe payments * authWithUrl is in * stripe cleanup * making tests pass * stripe bug fixes * 400 error is right * cleanup of sinon spy for fakeSend * paypal payments * lint of paypal * require -> import --- .eslintignore | 1 - common/locales/en/api-v3.json | 5 +- tasks/gulp-tests.js | 2 +- ...-payments_amazon_subscribe_cancel.test.js} | 8 +- .../GET-payments_paypal_checkout.test.js | 21 + ...T-payments_paypal_checkout_success.test.js | 21 + .../GET-payments_paypal_subscribe.test.js | 21 + ...T-payments_paypal_subscribe_cancel.test.js | 21 + ...-payments_paypal_subscribe_success.test.js | 21 + ...T-payments_stripe_subscribe_cancel.test.js | 21 + .../payments/POST-payments_paypal_ipn.test.js | 17 + .../POST-payments_stripe_checkout.test.js | 20 + ...OST-payments_stripe_subscribe_edit.test.js | 21 + test/api/v3/unit/libs/payments.test.js | 5 +- website/src/controllers/api-v3/auth.js | 3 + website/src/controllers/api-v3/chat.js | 2 + .../controllers/top-level/payments/amazon.js | 20 +- .../controllers/top-level/payments/paypal.js | 488 ++++++++++-------- .../top-level/payments/paypalBillingSetup.js | 1 + .../controllers/top-level/payments/stripe.js | 266 +++++----- website/src/libs/api-v3/payments.js | 2 - website/src/middlewares/api-v3/auth.js | 18 + 22 files changed, 659 insertions(+), 346 deletions(-) rename test/api/v3/integration/payments/{GET-payments_amazon_subscribeCancel.test.js => GET-payments_amazon_subscribe_cancel.test.js} (72%) create mode 100644 test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js create mode 100644 test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js create mode 100644 test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js create mode 100644 test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js create mode 100644 test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js create mode 100644 test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js diff --git a/.eslintignore b/.eslintignore index 2acf38ea8d..606a950295 100644 --- a/.eslintignore +++ b/.eslintignore @@ -19,7 +19,6 @@ website/src/routes/payments.js website/src/routes/pages.js website/src/middlewares/apiThrottle.js website/src/middlewares/forceRefresh.js -website/src/controllers/top-level/payments/paypal.js debug-scripts/* tasks/*.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 0a3662bf80..74eb217ec5 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -1,5 +1,6 @@ { "missingAuthHeaders": "Missing authentication headers.", + "missingAuthParams": "Missing authentication parameters.", "missingUsernameEmail": "Missing username or email.", "missingEmail": "Missing email.", "missingUsername": "Missing username.", @@ -175,5 +176,7 @@ "equipmentAlreadyOwned": "You already own that piece of equipment", "missingAccessToken": "The request is missing a required parameter : access_token", "missingBillingAgreementId": "Missing billing agreement id", - "paymentNotSuccessful": "The payment was not successful" + "paymentNotSuccessful": "The payment was not successful", + "planNotActive": "The plan hasn't activated yet (due to a PayPal bug). It will begin <%= nextBillingDate %>, after which you can cancel to retain your full benefits", + "cancelingSubscription": "Canceling the subscription" } diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index a58113b77d..8d2d49876a 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -359,7 +359,7 @@ gulp.task('test:api-v3:unit', (done) => { }); gulp.task('test:api-v3:unit:watch', () => { - gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/libs/*', 'website/src/controllers/**/*'], ['test:api-v3:unit']); + gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/**/*', 'website/src/controllers/**/*'], ['test:api-v3:unit']); }); gulp.task('test:api-v3:integration', (done) => { diff --git a/test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js similarity index 72% rename from test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js rename to test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js index 66562c3721..1f05739bb9 100644 --- a/test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js +++ b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : amazon #subscribeCancel', () => { - let endpoint = '/payments/amazon/subscribeCancel'; + let endpoint = '/payments/amazon/subscribe/cancel'; let user; beforeEach(async () => { @@ -13,9 +13,9 @@ describe('payments : amazon #subscribeCancel', () => { it('verifies subscription', async () => { await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('missingSubscription'), + code: 401, + error: 'NotAuthorized', + message: t('missingAuthParams'), }); }); }); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js b/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js new file mode 100644 index 0000000000..12ea7c8ee9 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : paypal #checkout', () => { + let endpoint = '/payments/paypal/checkout'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies subscription', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingAuthParams'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js b/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js new file mode 100644 index 0000000000..4dae9d8485 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : paypal #checkoutSuccess', () => { + let endpoint = '/payments/paypal/checkout/success'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies subscription', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('invalidCredentials'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js new file mode 100644 index 0000000000..7640cfdf92 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : paypal #subscribe', () => { + let endpoint = '/payments/paypal/subscribe'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingAuthParams'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js new file mode 100644 index 0000000000..2e4ccedf01 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : paypal #subscribeCancel', () => { + let endpoint = '/payments/paypal/subscribe/cancel'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingAuthParams'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js new file mode 100644 index 0000000000..961556ff8b --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : paypal #subscribeSuccess', () => { + let endpoint = '/payments/paypal/subscribe/success'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('invalidCredentials'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js new file mode 100644 index 0000000000..b65d4ea6c2 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - stripe - #subscribeCancel', () => { + let endpoint = '/payments/stripe/subscribe/cancel'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingAuthParams'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js new file mode 100644 index 0000000000..dcdbd14c44 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js @@ -0,0 +1,17 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - paypal - #ipn', () => { + let endpoint = '/payments/paypal/ipn'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + let result = await user.post(endpoint); + expect(result).to.eql({}); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js new file mode 100644 index 0000000000..bc4d857a03 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js @@ -0,0 +1,20 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - stripe - #checkout', () => { + let endpoint = '/payments/stripe/checkout'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'Error', + message: 'Invalid API Key provided: ****************************1111', + }); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js b/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js new file mode 100644 index 0000000000..c456d389a4 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - stripe - #subscribeEdit', () => { + let endpoint = '/payments/stripe/subscribe/edit'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingSubscription'), + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments.test.js b/test/api/v3/unit/libs/payments.test.js index ad846e6488..bc4a3e647d 100644 --- a/test/api/v3/unit/libs/payments.test.js +++ b/test/api/v3/unit/libs/payments.test.js @@ -66,10 +66,7 @@ describe('payments/index', () => { it('sends a text', async () => { await api.cancelSubscription(data); - sinon.assert.calledOnce(fakeSend); + sinon.assert.called(fakeSend); }); }); - - describe('#buyGems', async () => { - }); }); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 08c405424e..73b0bdab3d 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,6 +2,9 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; import nconf from 'nconf'; +import setupNconf from '../../libs/api-v3/setupNconf'; +setupNconf(); + import { authWithHeaders, } from '../../middlewares/api-v3/auth'; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 42864fdab7..6aa8319f7c 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -12,6 +12,8 @@ import _ from 'lodash'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; +import setupNconf from '../../libs/api-v3/setupNconf'; +setupNconf(); const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 74998a13f4..3be2673b79 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,11 +1,11 @@ -/* -import mongoose from 'mongoose'; -import { model as User } from '../../../models/user'; */ import { BadRequest, } from '../../../libs/api-v3/errors'; import amzLib from '../../../libs/api-v3/amazonPayments'; -import { authWithHeaders } from '../../../middlewares/api-v3/auth'; +import { + authWithHeaders, + authWithUrl, +} from '../../../middlewares/api-v3/auth'; import shared from '../../../../../common'; import payments from '../../../libs/api-v3/payments'; import moment from 'moment'; @@ -16,7 +16,7 @@ import cc from 'coupon-code'; let api = {}; /** - * @api {post} /api/v3/payments/amazon/verifyAccessToken verify access token + * @api {post} /amazon/verifyAccessToken verify access token * @apiVersion 3.0.0 * @apiName AmazonVerifyAccessToken * @apiGroup Payments @@ -40,7 +40,7 @@ api.verifyAccessToken = { }; /** - * @api {post} /api/v3/payments/amazon/createOrderReferenceId create order reference id + * @api {post} /amazon/createOrderReferenceId create order reference id * @apiVersion 3.0.0 * @apiName AmazonCreateOrderReferenceId * @apiGroup Payments @@ -70,7 +70,7 @@ api.createOrderReferenceId = { }; /** - * @api {post} /api/v3/payments/amazon/checkout do checkout + * @api {post} /amazon/checkout do checkout * @apiVersion 3.0.0 * @apiName AmazonCheckout * @apiGroup Payments @@ -148,7 +148,7 @@ api.checkout = { }; /** - * @api {post} /api/v3/payments/amazon/subscribe Subscribe + * @api {post} /amazon/subscribe Subscribe * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments @@ -228,7 +228,7 @@ api.subscribe = { }; /** - * @api {get} /api/v3/payments/amazon/subscribe/cancel SubscribeCancel + * @api {get} /amazon/subscribe/cancel SubscribeCancel * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments @@ -238,7 +238,7 @@ api.subscribe = { api.subscribeCancel = { method: 'GET', url: '/payments/amazon/subscribe/cancel', - middlewares: [authWithHeaders()], + middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; let billingAgreementId = user.purchased.plan.customerId; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index c23fa6247d..841bc0546b 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -1,218 +1,296 @@ -var nconf = require('nconf'); -var moment = require('moment'); -var async = require('async'); -var _ = require('lodash'); -var url = require('url'); -var User = require('mongoose').model('User'); -var payments = require('../../../libs/api-v3/payments'); -var logger = require('../../../libs/api-v2/logging'); -var ipn = require('paypal-ipn'); -var paypal = require('paypal-rest-sdk'); -var shared = require('../../../../../common'); -var mongoose = require('mongoose'); -var cc = require('coupon-code'); +import nconf from 'nconf'; +import moment from 'moment'; +import _ from 'lodash'; +import payments from '../../../libs/api-v3/payments'; +import ipn from 'paypal-ipn'; +import paypal from 'paypal-rest-sdk'; +import shared from '../../../../../common'; +import cc from 'coupon-code'; +import { model as Coupon } from '../../../models/coupon'; +import { model as User } from '../../../models/user'; +import { + authWithUrl, + authWithSession, +} from '../../../middlewares/api-v3/auth'; +import { + BadRequest, +} from '../../../libs/api-v3/errors'; +import * as logger from '../../../libs/api-v3/logger'; // This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have // a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created // there, get it's plan.id and store it in config.json -_.each(shared.content.subscriptionBlocks, function(block){ - block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.key); +_.each(shared.content.subscriptionBlocks, (block) => { + block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`); }); +/* eslint-disable camelcase */ + paypal.configure({ - 'mode': nconf.get("PAYPAL:mode"), //sandbox or live - 'client_id': nconf.get("PAYPAL:client_id"), - 'client_secret': nconf.get("PAYPAL:client_secret") + mode: nconf.get('PAYPAL:mode'), // sandbox or live + client_id: nconf.get('PAYPAL:client_id'), + client_secret: nconf.get('PAYPAL:client_secret'), }); -var parseErr = function (res, err) { - //var error = err.response ? err.response.message || err.response.details[0].issue : err; - var error = JSON.stringify(err); - return res.status(400).json({err:error}); -} - -/* -exports.createBillingAgreement = function(req,res,next){ - var sub = shared.content.subscriptionBlocks[req.query.sub]; - async.waterfall([ - function(cb){ - if (!sub.discount) return cb(null, null); - if (!req.query.coupon) return cb('Please provide a coupon code for this plan.'); - mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb); - }, - function(coupon, cb){ - if (sub.discount && !coupon) return cb('Invalid coupon code.'); - var billingPlanTitle = "HabitRPG Subscription" + ' ($'+sub.price+' every '+sub.months+' months, recurring)'; - var billingAgreementAttributes = { - "name": billingPlanTitle, - "description": billingPlanTitle, - "start_date": moment().add({minutes:5}).format(), - "plan": { - "id": sub.paypalKey - }, - "payer": { - "payment_method": "paypal" - } - }; - paypal.billingAgreement.create(billingAgreementAttributes, cb); - } - ], function(err, billingAgreement){ - if (err) return parseErr(res, err); - // For approving subscription via Paypal, first redirect user to: approval_url - req.session.paypalBlock = req.query.sub; - var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href; - res.redirect(approval_url); - }); -} - -exports.executeBillingAgreement = function(req,res,next){ - var block = shared.content.subscriptionBlocks[req.session.paypalBlock]; - delete req.session.paypalBlock; - async.auto({ - exec: function (cb) { - paypal.billingAgreement.execute(req.query.token, {}, cb); - }, - get_user: function (cb) { - User.findById(req.session.userId, cb); - }, - create_sub: ['exec', 'get_user', function (cb, results) { - payments.createSubscription({ - user: results.get_user, - customerId: results.exec.id, - paymentMethod: 'Paypal', - sub: block - }, cb); - }] - },function(err){ - if (err) return parseErr(res, err); - res.redirect('/'); - }) -} - -exports.createPayment = function(req, res) { - // if we're gifting to a user, put it in session for the `execute()` - req.session.gift = req.query.gift || undefined; - var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - var price = !gift ? 5.00 - : gift.type=='gems' ? Number(gift.gems.amount/4).toFixed(2) - : Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); - var description = !gift ? "HabitRPG Gems" - : gift.type=='gems' ? "HabitRPG Gems (Gift)" - : shared.content.subscriptionBlocks[gift.subscription.key].months + "mo. HabitRPG Subscription (Gift)"; - var create_payment = { - "intent": "sale", - "payer": { - "payment_method": "paypal" - }, - "redirect_urls": { - "return_url": nconf.get('BASE_URL') + '/paypal/checkout/success', - "cancel_url": nconf.get('BASE_URL') - }, - "transactions": [{ - "item_list": { - "items": [{ - "name": description, - //"sku": "1", - "price": price, - "currency": "USD", - "quantity": 1 - }] - }, - "amount": { - "currency": "USD", - "total": price - }, - "description": description - }] - }; - paypal.payment.create(create_payment, function (err, payment) { - if (err) return parseErr(res, err); - var link = _.find(payment.links, {rel: 'approval_url'}).href; - res.redirect(link); - }); -} - -exports.executePayment = function(req, res) { - var paymentId = req.query.paymentId, - PayerID = req.query.PayerID, - gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; - delete req.session.gift; - async.waterfall([ - function(cb){ - paypal.payment.execute(paymentId, {payer_id: PayerID}, cb); - }, - function(payment, cb){ - async.parallel([ - function(cb2){ User.findById(req.session.userId, cb2); }, - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); } - ], cb); - }, - function(results, cb){ - if (_.isEmpty(results[0])) return cb("User not found when completing paypal transaction"); - var data = {user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift} - var method = 'buyGems'; - if (gift) { - gift.member = results[1]; - if (gift.type=='subscription') method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - payments[method](data, cb); - } - ],function(err){ - if (err) return parseErr(res, err); - res.redirect('/'); - }) -} - -exports.cancelSubscription = function(req, res, next){ - var user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.status(401).json({err: "User does not have a plan subscription"}); - async.auto({ - get_cus: function(cb){ - paypal.billingAgreement.get(user.purchased.plan.customerId, cb); - }, - verify_cus: ['get_cus', function(cb, results){ - var hasntBilledYet = results.get_cus.agreement_details.cycles_completed == "0"; - if (hasntBilledYet) - return cb("The plan hasn't activated yet (due to a PayPal bug). It will begin "+results.get_cus.agreement_details.next_billing_date+", after which you can cancel to retain your full benefits"); - cb(); - }], - del_cus: ['verify_cus', function(cb, results){ - paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb); - }], - cancel_sub: ['get_cus', 'verify_cus', function(cb, results){ - var data = {user: user, paymentMethod: 'Paypal', nextBill: results.get_cus.agreement_details.next_billing_date}; - payments.cancelSubscription(data, cb) - }] - }, function(err){ - if (err) return parseErr(res, err); - res.redirect('/'); - user = null; - }); -} // */ +let api = {}; /** - * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their - * recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution - */ -/* -exports.ipn = function(req, res, next) { - console.log('IPN Called'); - res.sendStatus(200); // Must respond to PayPal IPN request with an empty 200 first - ipn.verify(req.body, function(err, msg) { - if (err) return logger.error(msg); - switch (req.body.txn_type) { - // TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead... - case 'recurring_payment_profile_cancel': - case 'subscr_cancel': - User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ - if (err) return logger.error(err); - if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel) - payments.cancelSubscription({user:user, paymentMethod: 'Paypal'}); - }); - break; + * @api {get} /paypal/checkout checkout + * @apiVersion 3.0.0 + * @apiName PaypalCheckout + * @apiGroup Payments + * + * @apiParam {string} gift The stringified object representing the user, the gift recepient. + * + * @apiSuccess {} redirect + **/ +api.checkout = { + method: 'GET', + url: '/payments/paypal/checkout', + middlewares: [authWithUrl], + async handler (req, res) { + let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + req.session.gift = req.query.gift; + + let amount = 5.00; + let description = 'HabitRPG gems'; + if (gift) { + if (gift.type === 'gems') { + amount = Number(gift.gems.amount / 4).toFixed(2); + description = `${description} (Gift)`; + } else { + amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); + description = 'monthly HabitRPG Subscription (Gift)'; + } } - }); + + let createPayment = { + intent: 'sale', + payer: { payment_method: 'Paypal' }, + redirect_urls: { + return_url: `${nconf.get('BASE_URL')}/paypal/checkout/success`, + cancel_url: `${nconf.get('BASE_URL')}`, + }, + transactions: [{ + item_list: { + items: [{ + name: description, + price: amount, + currency: 'USD', + quality: 1, + }], + }, + amount: { + currency: 'USD', + total: amount, + }, + description, + }], + }; + try { + let result = await paypal.payment.create(createPayment); + let link = _.find(result.links, { rel: 'approval_url' }).href; + res.redirect(link); + } catch (e) { + throw new BadRequest(e); + } + }, }; -*/ + +/** + * @api {get} /paypal/checkout/success Paypal checkout success + * @apiVersion 3.0.0 + * @apiName PaypalCheckoutSuccess + * @apiGroup Payments + * + * @apiParam {string} paymentId The payment id + * @apiParam {string} payerID The payer id, notice ID not id + * + * @apiSuccess {} redirect + **/ +api.checkoutSuccess = { + method: 'GET', + url: '/payments/paypal/checkout/success', + middlewares: [authWithSession], + async handler (req, res) { + let paymentId = req.query.paymentId; + let customerId = req.query.payerID; + let method = 'buyGems'; + let data = { + user: res.locals.user, + customerId, + paymentMethod: 'Paypal', + }; + + try { + let gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; + delete req.session.gift; + if (gift) { + gift.member = await User.findById(gift.uuid); + if (gift.type === 'subscription') { + method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + data.gift = gift; + } + + await paypal.payment.execute(paymentId, { payer_id: customerId }); + await payments[method](data); + res.redirect('/'); + } catch (e) { + throw new BadRequest(e); + } + }, +}; + +/** + * @api {get} /paypal/subscribe Paypal subscribe + * @apiVersion 3.0.0 + * @apiName PaypalSubscribe + * @apiGroup Payments + * + * @apiParam {string} sub subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo + * @apiParam {string} coupon coupon for the matching subscription, required only for certain subscriptions + * + * @apiSuccess {} empty object + **/ +api.subscribe = { + method: 'GET', + url: '/payments/paypal/subscribe', + middlewares: [authWithUrl], + async handler (req, res) { + let sub = shared.content.subscriptionBlocks[req.query.sub]; + if (sub.discount) { + if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired')); + let coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key}); + if (!coupon) throw new BadRequest(res.t('invalidCoupon')); + } + + let billingPlanTitle = `HabitRPG Subscription ($${sub.price} every ${sub.months} months, recurring)`; + let billingAgreementAttributes = { + name: billingPlanTitle, + description: billingPlanTitle, + start_date: moment().add({ minutes: 5}).format(), + plan: { + id: sub.paypalKey, + }, + payer: { + payment_method: 'Paypal', + }, + }; + try { + let billingAgreement = await paypal.billingAgreement.create(billingAgreementAttributes); + req.session.paypalBlock = req.query.sub; + let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href; + res.redirect(link); + } catch (e) { + throw new BadRequest(e); + } + }, +}; + +/** + * @api {get} /paypal/subscribe/success Paypal subscribe success + * @apiVersion 3.0.0 + * @apiName PaypalSubscribeSuccess + * @apiGroup Payments + * + * @apiParam {string} token The token in query + * + * @apiSuccess {} redirect + **/ +api.subscribeSuccess = { + method: 'GET', + url: '/payments/paypal/subscribe/success', + middlewares: [authWithSession], + async handler (req, res) { + let user = res.locals.user; + let block = shared.content.subscriptionBlocks[req.session.paypalBlock]; + delete req.session.paypalBlock; + try { + let result = await paypal.billingAgreement.execute(req.query.token, {}); + await payments.createSubscription({ + user, + customerId: result.id, + paymentMethod: 'Paypal', + sub: block, + }); + res.redirect('/'); + } catch (e) { + throw new BadRequest(e); + } + }, +}; + +/** + * @api {get} /paypal/subscribe/cancel Paypal subscribe cancel + * @apiVersion 3.0.0 + * @apiName PaypalSubscribeCancel + * @apiGroup Payments + * + * @apiParam {string} token The token in query + * + * @apiSuccess {} redirect + **/ +api.subscribeCancel = { + method: 'GET', + url: '/payments/paypal/subscribe/cancel', + middlewares: [authWithUrl], + async handler (req, res) { + let user = res.locals.user; + let customerId = user.purchased.plan.customerId; + if (!user.purchased.plan.customerId) throw new BadRequest(res.t('missingSubscription')); + try { + let customer = await paypal.billingAgreement.get(customerId); + let nextBillingDate = customer.agreement_details.next_billing_date; + if (customer.agreement_details.cycles_completed === '0') { // hasn't billed yet + throw new BadRequest(res.t('planNotActive', { nextBillingDate })); + } + await paypal.billingAgreement.cancel(customerId, { note: res.t('cancelingSubscription') }); + let data = { + user, + paymentMethod: 'Paypal', + nextBill: nextBillingDate, + }; + await payments.cancelSubscription(data); + res.redirect('/'); + } catch (e) { + throw new BadRequest(e); + } + }, +}; + +/** + * @api {post} /paypal/ipn Paypal IPN + * @apiVersion 3.0.0 + * @apiName PaypalIpn + * @apiGroup Payments + * + * @apiParam {string} txn_type txn_type + * @apiParam {string} recurring_payment_id recurring_payment_id + * + * @apiSuccess {} empty object + **/ +api.ipn = { + method: 'POST', + url: '/payments/paypal/ipn', + middlewares: [], + async handler (req, res) { + res.respond(200); + try { + await ipn.verify(req.body); + if (req.body.txn_type === 'recurring_payment_profile_cancel' || req.body.txn_type === 'subscr_cancel') { + let user = await User.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id }); + if (user) { + payments.cancelSubscriptoin({ user, paymentMethod: 'Paypal' }); + } + } + } catch (e) { + logger.error(e); + } + }, +}; + +/* eslint-disable camelcase */ + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js index 0e8170ff51..1015e8f89e 100644 --- a/website/src/controllers/top-level/payments/paypalBillingSetup.js +++ b/website/src/controllers/top-level/payments/paypalBillingSetup.js @@ -38,6 +38,7 @@ let billingPlanAttributes = { cycles: '0', }], }; + _.each(blocks, function defineBlock (block) { block.definition = _.cloneDeep(billingPlanAttributes); _.merge(block.definition.payment_definitions[0], { diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index 6a553dacd5..d211a60d89 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -1,137 +1,167 @@ -/* import nconf from 'nconf'; import stripeModule from 'stripe'; -import async from 'async'; -import payments from '../../../libs/api-v3/payments'; -import { model as User } from '../../../models/user'; import shared from '../../../../../common'; -import mongoose from 'mongoose'; -import cc from 'coupon-code'; */ +import { + BadRequest, +} from '../../../libs/api-v3/errors'; +import { model as Coupon } from '../../../models/coupon'; +import payments from '../../../libs/api-v3/payments'; +import nconf from 'nconf'; +import { model as User } from '../../../models/user'; +import cc from 'coupon-code'; +import { + authWithHeaders, + authWithUrl, +} from '../../../middlewares/api-v3/auth'; -// const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); +const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); let api = {}; -/* - Setup Stripe response when posting payment - */ -/* -api.checkout = function checkout (req, res) { - let token = req.body.id; - let user = res.locals.user; - let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; - async.waterfall([ - function stripeCharge (cb) { - if (sub) { - async.waterfall([ - function handleCoupon (cb2) { - if (!sub.discount) return cb2(null, null); - if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); - mongoose.model('Coupon').findOne({_id: cc.validate(req.query.coupon), event: sub.key}, cb2); - }, - function createCustomer (coupon, cb2) { - if (sub.discount && !coupon) return cb2('Invalid coupon code.'); - let customer = { - email: req.body.email, - metadata: {uuid: user._id}, - card: token, - plan: sub.key, - }; - stripe.customers.create(customer, cb2); - }, - ], cb); - } else { - let amount; - if (!gift) { - amount = '500'; - } else if (gift.type === 'subscription') { +/** + * @api {post} /stripe/checkout Stripe checkout + * @apiVersion 3.0.0 + * @apiName StripeCheckout + * @apiGroup Payments + * + * @apiParam {string} id The token + * @apiParam {string} gift stringified json object, gift + * @apiParam {string} sub subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo + * @apiParam {string} coupon coupon for the matching subscription, required only for certain subscriptions + * @apiParam {string} email the customer email + * + * @apiSuccess {} empty object + **/ +api.checkout = { + method: 'POST', + url: '/payments/stripe/checkout', + middlewares: [authWithHeaders()], + async handler (req, res) { + let token = req.body.id; + let user = res.locals.user; + let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; + let coupon; + let response; + + if (sub) { + if (sub.discount) { + if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired')); + coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key}); + if (!coupon) throw new BadRequest(res.t('invalidCoupon')); + } + let customer = { + email: req.body.email, + metadata: { uuid: user._id }, + card: token, + plan: sub.key, + }; + response = await stripe.customers.create(customer); + } else { + let amount = 500; // $5 + if (gift) { + if (gift.type === 'subscription') { amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; } else { amount = `${gift.gems.amount / 4 * 100}`; } - stripe.charges.create({ - amount, - currency: 'usd', - card: token, - }, cb); } - }, - function saveUserData (response, cb) { - if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', sub}, cb); - async.waterfall([ - function findUser (cb2) { - User.findById(gift ? gift.uuid : undefined, cb2); - }, - function prepData (member, cb2) { - let data = {user, customerId: response.id, paymentMethod: 'Stripe', gift}; - let method = 'buyGems'; - if (gift) { - gift.member = member; - if (gift.type === 'subscription') method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - payments[method](data, cb2); - }, - ], cb); - }, - ], function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.sendStatus(200); - user = token = null; - }); -}; + response = await stripe.charges.create({ + amount, + currency: 'usd', + card: token, + }); + } -api.subscribeCancel = function subscribeCancel (req, res) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) { - return res.status(401).json({err: 'User does not have a plan subscription'}); - } - - async.auto({ - getCustomer: function getCustomer (cb) { - stripe.customers.retrieve(user.purchased.plan.customerId, cb); - }, - deleteCustomer: ['getCustomer', function deleteCustomer (cb) { - stripe.customers.del(user.purchased.plan.customerId, cb); - }], - cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { + if (sub) { + await payments.createSubscription({ + user, + customerId: response.id, + paymentMethod: 'Stripe', + sub, + }); + } else { + let method = 'buyGems'; let data = { user, - nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds + customerId: response.id, + paymentMethod: 'Stripe', + gift, + }; + if (gift) { + let member = await User.findById(gift.uuid); + gift.member = member; + if (gift.type === 'subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + await payments[method](data); + } + res.respond(200, {}); + }, +}; + +/** + * @api {post} /stripe/subscribe/edit Stripe subscribeEdit + * @apiVersion 3.0.0 + * @apiName StripeSubscribeEdit + * @apiGroup Payments + * + * @apiParam {string} id The token + * + * @apiSuccess {} + **/ +api.subscribeEdit = { + method: 'POST', + url: '/payments/stripe/subscribe/edit', + middlewares: [authWithHeaders()], + async handler (req, res) { + let token = req.body.id; + let user = res.locals.user; + let customerId = user.purchased.plan.customerId; + + if (!customerId) throw new BadRequest(res.t('missingSubscription')); + + try { + let subscriptions = await stripe.customers.listSubscriptions(customerId); + let subscriptionId = subscriptions.data[0].id; + await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); + res.respond(200, {}); + } catch (error) { + throw new BadRequest(error.message); + } + }, +}; + +/** + * @api {get} /stripe/subscribe/cancel Stripe subscribeCancel + * @apiVersion 3.0.0 + * @apiName StripeSubscribeCancel + * @apiGroup Payments + * + * @apiParam + * + * @apiSuccess {} + **/ +api.subscribeCancel = { + method: 'GET', + url: '/payments/stripe/subscribe/cancel', + middlewares: [authWithUrl], + async handler (req, res) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) throw new BadRequest(res.t('missingSubscription')); + try { + let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId); + await stripe.customers.del(user.purchased.plan.customerId); + let data = { + user, + nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds paymentMethod: 'Stripe', }; - payments.cancelSubscription(data, cb); - }], - }, function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.redirect('/'); - user = null; - }); + await payments.cancelSubscriptoin(data); + res.respond(200, {}); + } catch (e) { + throw new BadRequest(e); + } + }, }; -api.subscribeEdit = function subscribeEdit (req, res) { - let token = req.body.id; - let user = res.locals.user; - let userId = user.purchased.plan.customerId; - let subscriptionId; - - async.waterfall([ - function listSubscriptions (cb) { - stripe.customers.listSubscriptions(userId, cb); - }, - function updateSubscription (response, cb) { - subscriptionId = response.data[0].id; - stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); - }, - function saveUser (response, cb) { - user.save(cb); - }, - ], function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.sendStatus(200); - token = user = userId = subscriptionId; - }); -}; -*/ - module.exports = api; diff --git a/website/src/libs/api-v3/payments.js b/website/src/libs/api-v3/payments.js index 0e94153150..8a28e7d1a9 100644 --- a/website/src/libs/api-v3/payments.js +++ b/website/src/libs/api-v3/payments.js @@ -1,13 +1,11 @@ import _ from 'lodash' ; import analytics from './analyticsService'; -import cc from 'coupon-code'; import { getUserInfo, sendTxn as txnEmail, } from './email'; import members from '../../controllers/api-v3/members'; import moment from 'moment'; -import mongoose from 'mongoose'; import nconf from 'nconf'; import pushNotify from './pushNotifications'; import shared from '../../../../common' ; diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index d1fcfe2e7b..0b595a56a3 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -55,3 +55,21 @@ export function authWithSession (req, res, next) { }) .catch(next); } + +export function authWithUrl (req, res, next) { + let userId = req.query._id; + let apiToken = req.query.apiToken; + + if (!userId || !apiToken) { + throw new NotAuthorized(res.t('missingAuthParams')); + } + + User.findOne({ _id: userId, apiToken }).exec() + .then((user) => { + if (!user) throw new NotAuthorized(res.t('invalidCredentials')); + + res.locals.user = user; + next(); + }) + .catch(next); +} From 6380161321bcabb9748a7c360e2d9c01d4ff4ecc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 30 Apr 2016 18:34:16 +0200 Subject: [PATCH 726/976] Api v3 Migration (WIP) (#7131) * v3 migration: remove old code and polish user migration * v3 migration: start to work on challenges * wip v3 migration * wip v3 migration: fix _id -> id for reminders, tags and checklists --- common/script/libs/taskDefaults.js | 2 +- migrations/api_v3/challenges.js | 238 ++++++++++++++++++ migrations/api_v3/users.js | 183 +++----------- .../integration/tags/DELETE-tags_id.test.js | 2 +- .../v3/integration/tags/GET-tags_id.test.js | 2 +- .../api/v3/integration/tags/POST-tags.test.js | 2 +- .../v3/integration/tags/PUT-tags_id.test.js | 4 +- .../integration/tasks/POST-tasks_user.test.js | 8 +- .../v3/integration/tasks/PUT-tasks_id.test.js | 8 +- ...lenge_challengeId_checklist_itemId.test.js | 6 +- ...lenge_challengeId_taskId_checklist.test.js | 8 +- ...allengeId_tasksId_checklist_itemId.test.js | 10 +- ...LETE-tasks_taskId_checklist_itemId.test.js | 2 +- .../POST-tasks_taskId_checklist.test.js | 4 +- ...asks_taskId_checklist_itemId_score.test.js | 2 +- .../PUT-tasks_taskId_checklist_itemId.test.js | 4 +- .../DELETE-tasks_taskId_tags_tagId.test.js | 4 +- .../tags/POST-tasks_taskId_tags_tagId.test.js | 8 +- test/common/ops/updateTask.js | 8 +- website/src/controllers/api-v3/tags.js | 10 +- website/src/controllers/api-v3/tasks.js | 10 +- website/src/controllers/top-level/pages.js | 4 +- website/src/models/tag.js | 10 +- website/src/models/task.js | 14 +- 24 files changed, 350 insertions(+), 203 deletions(-) diff --git a/common/script/libs/taskDefaults.js b/common/script/libs/taskDefaults.js index c3077eb95d..e6bdba3def 100644 --- a/common/script/libs/taskDefaults.js +++ b/common/script/libs/taskDefaults.js @@ -23,7 +23,7 @@ module.exports = function taskDefaults (task = {}) { value: task.type === 'reward' ? 10 : 0, priority: 1, challenge: {}, - reminders: {}, + reminders: [], attribute: 'str', createdAt: new Date(), // TODO these are going to be overwritten by the server... updatedAt: new Date(), diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index f7b2184fd4..c6b4a1e3b3 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -9,3 +9,241 @@ memberCount must be checked prize must be >= 0 */ + +// A map of (original taskId) -> [new taskId in challenge, challendId] of tasks belonging to challenges where the task id had to change +// This way later we can have use the right task.challenge.taskId in user's tasks +var duplicateTasks = {}; + + // ... convert tasks to individual models + async.each( + challenge.dailys + .concat(challenge.habits) + .concat(challenge.rewards) + .concat(challenge.todos), + function(task, cb1) { + + task = new TaskModel(task); // this should also fix dailies that wen to the habits array or vice-versa + + TaskModel.findOne({_id: task._id}, function(err, taskSameId){ + if(err) return cb1(err); + + // We already have a task with the same id, change this one + // and will require special handling + if(taskSameId) { + task._id = shared.uuid(); + task.legacyId = taskSameId._id; // We set this for challenge tasks too + // we use an array as the same task may have multiple duplicates + duplicateTasks[taskSameId._id] = duplicateTasks[taskSameId._id] || []; + duplicateTasks[taskSameId._id].push([task._id, challenge._id]); + console.log('Duplicate task ', taskSameId._id, 'challenge ', challenge._id, 'new id ', task._id); + } + + task.save(function(err, savedTask){ + if(err) return cb1(err); + + challenge.tasksOrder[savedTask.type + 's'].push(savedTask._id); + cb1(); + }); + }); + }, function(err) { + if(err) return cb(err); + + var newChallenge = new NewChallengeModel(challenge); // This will make sure old data is discarded + newChallenge.save(function(err, chal){ + if(err) return cb(err); + console.log('Processed: ', chal._id); + cb(); + }); + }); + }, function(err) { + if(err) throw err; + + processed = processed + challenges.length; + console.log('Processed ' + challenges.length + ' challenges.', 'Total: ' + processed); + + if(lastChal && lastChal._id){ + processChal(lastChal._id); + } else { + console.log('Done!'); + // outputting the duplicate tasks + console.log(JSON.stringify(duplicateTasks, null, 4)); + } + }); + }); +}; + +processChal(); + +// Migrate users collection to new schema +// This should run AFTER challenges migration + +// The console-stamp module must be installed (not included in package.json) + +// It requires two environment variables: MONGODB_OLD and MONGODB_NEW + +// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). +// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM +console.log('Starting migrations/api_v3/users.js.'); + +require('babel-register'); + +var Q = require('q'); +var MongoDB = require('mongodb'); +var nconf = require('nconf'); +var mongoose = require('mongoose'); +var _ = require('lodash'); +var uuid = require('uuid'); +var consoleStamp = require('console-stamp'); + +// Add timestamps to console messages +consoleStamp(console); + +// Initialize configuration +require('../../website/src/libs/api-v3/setupNconf')(); + +var MONGODB_OLD = nconf.get('MONGODB_OLD'); +var MONGODB_NEW = nconf.get('MONGODB_NEW'); + +var MongoClient = MongoDB.MongoClient; + +mongoose.Promise = Q.Promise; // otherwise mongoose models won't work + +// Load new models +var NewChallenge = require('../../website/src/models/challenge').model; +var Tasks = require('../../website/src/models/task'); + +// To be defined later when MongoClient connects +var mongoDbOldInstance; +var oldChallengeCollection; + +var mongoDbNewInstance; +var newChallengeCollection; +var newTaskCollection; + +var BATCH_SIZE = 1000; + +var processedChallenges = 0; +var totoalProcessedTasks = 0; + +// Only process challenges that fall in a interval ie -> up to 0000-4000-0000-0000 +var AFTER_CHALLENGE_ID = nconf.get('AFTER_CHALLENGE_ID'); +var BEFORE_CHALLENGE_ID = nconf.get('BEFORE_CHALLENGE_ID'); + +function processChallenges (afterId) { + var processedTasks = 0; + var lastChallenge = null; + var oldChallenges; + + var query = {}; + + if (BEFORE_CHALLENGE_ID) { + query._id = {$lte: BEFORE_CHALLENGE_ID}; + } + + if ((afterId || AFTER_CHALLENGE_ID) && !query._id) { + query._id = {}; + } + + if (afterId) { + query._id.$gt = afterId; + } else if (AFTER_CHALLENGE_ID) { + query._id.$gt = AFTER_CHALLENGE_ID; + } + + var batchInsertTasks = newTaskCollection.initializeUnorderedBulkOp(); + var batchInsertChallenges = newChallengeCollection.initializeUnorderedBulkOp(); + + console.log(`Executing challenges query.\nMatching challenges after ${afterId ? afterId : AFTER_USER_ID} and before ${BEFORE_USER_ID} (included).`); + + return oldChallengeCollection + .find(query) + .sort({_id: 1}) + .limit(BATCH_SIZE) + .toArray() + .then(function (oldChallengesR) { + oldChallenges = oldChallengesR; + + console.log(`Processing ${oldChallenges.length} challenges. Already processed ${processedChallenges} challenges and ${totoalProcessedTasks} tasks.`); + + if (oldChallenges.length === BATCH_SIZE) { + lastChallenge = oldChallenges[oldChallenges.length - 1]._id; + } + + oldChallenges.forEach(function (oldChallenge) { + var oldTasks = oldChallenge.habits.concat(oldChallenge.dailys).concat(oldChallenge.rewards).concat(oldChallenge.todos); + delete oldChallenge.habits; + delete oldChallenge.dailys; + delete oldChallenge.rewards; + delete oldChallenge.todos; + + var newChallenge = new NewChallenge(oldChallenge); + + oldTasks.forEach(function (oldTask) { + // TODO + oldTask._id = oldTask.id; // keep the old uuid unless duplicated + delete oldTask.id; + + oldTask.challenge = oldTask.challenge || {}; + oldTask.challenge.id = oldChallenge.id; + + if (!oldTask.text) oldTask.text = 'task text'; // required + oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) { // TODO used for challenges' tasks? + return tagPresent && tagId; + }); + + newChallenge.tasksOrder[`${oldTask.type}s`].push(oldTask._id); + if (oldTask.completed) oldTask.completed = false; + + var newTask = new Tasks[oldTask.type](oldTask); + + batchInsertTasks.insert(newTask.toObject()); + processedTasks++; + }); + + batchInsertChallenges.insert(newChallenge.toObject()); + }); + + console.log(`Saving ${oldChallenges.length} users and ${processedTasks} tasks.`); + + return Q.all([ + batchInsertChallenges.execute(), + batchInsertTasks.execute(), + ]); + }) + .then(function () { + totoalProcessedTasks += processedTasks; + processedChallenges += oldChallenges.length; + + console.log(`Saved ${oldChallenges.length} users and their tasks.`); + + if (lastUser) { + return processChallenges(lastChallenge); + } else { + return console.log('Done!'); + } + }); +} + +// Connect to the databases +Q.all([ + MongoClient.connect(MONGODB_OLD), + MongoClient.connect(MONGODB_NEW), +]) +.then(function (result) { + var oldInstance = result[0]; + var newInstance = result[1]; + + mongoDbOldInstance = oldInstance; + oldChallengeCollection = mongoDbOldInstance.collection('challenges'); + + mongoDbNewInstance = newInstance; + newChallengeCollection = mongoDbNewInstance.collection('challenges'); + newTaskCollection = mongoDbNewInstance.collection('tasks'); + + console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); + + return processChallenges(); +}) +.catch(function (err) { + console.error(err); +}); diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index e275645d63..293769a3fc 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -28,11 +28,13 @@ require('../../website/src/libs/api-v3/setupNconf')(); var MONGODB_OLD = nconf.get('MONGODB_OLD'); var MONGODB_NEW = nconf.get('MONGODB_NEW'); +var MongoClient = MongoDB.MongoClient; + mongoose.Promise = Q.Promise; // otherwise mongoose models won't work -// Load old and new models -//import { model as NewUser } from '../../website/src/models/user'; -//import * as Tasks from '../../website/src/models/task'; +// Load new models +var NewUser = require('../../website/src/models/user').model; +var NewTasks = require('../../website/src/models/task'); // To be defined later when MongoClient connects var mongoDbOldInstance; @@ -47,16 +49,17 @@ var BATCH_SIZE = 1000; var processedUsers = 0; var totoalProcessedTasks = 0; -// Only process users that fall in a interval ie -> 0000-4000-0000-0000 +// Only process users that fall in a interval ie up to -> 0000-4000-0000-0000 var AFTER_USER_ID = nconf.get('AFTER_USER_ID'); var BEFORE_USER_ID = nconf.get('BEFORE_USER_ID'); -/* TODO +/* TODO compare old and new model - _id 9 - challenges - groups - invitations - challenges' tasks +- checklists from .id to ._id (reminders too!) */ function processUsers (afterId) { @@ -70,10 +73,14 @@ function processUsers (afterId) { query._id = {$lte: BEFORE_USER_ID}; } + if ((afterId || AFTER_USER_ID) && !query._id) { + query._id = {}; + } + if (afterId) { - query._id = {$gt: afterId}; + query._id.$gt = afterId; } else if (AFTER_USER_ID) { - query._id = {$gt: AFTER_USER_ID}; + query._id.$gt = AFTER_USER_ID; } var batchInsertTasks = newTaskCollection.initializeUnorderedBulkOp(); @@ -95,17 +102,13 @@ function processUsers (afterId) { lastUser = oldUsers[oldUsers.length - 1]._id; } - oldUsers.forEach(function (oldUser) { var oldTasks = oldUser.habits.concat(oldUser.dailys).concat(oldUser.rewards).concat(oldUser.todos); - oldUser.habits = oldUser.dailys = oldUser.rewards = oldUser.todos = undefined; + delete oldUser.habits; + delete oldUser.dailys; + delete oldUser.rewards; + delete oldUser.todos; - oldUser.challenges = []; - if (oldUser.invitations) { - oldUser.invitations.guilds = []; - oldUser.invitations.party = {}; - } - oldUser.party = {}; oldUser.tags = oldUser.tags.map(function (tag) { return { _id: tag.id, @@ -114,37 +117,31 @@ function processUsers (afterId) { }; }); - oldUser.tasksOrder = { - habits: [], - dailys: [], - rewards: [], - todos: [], - }; - - //let newUser = new NewUser(oldUser); + var newUser = new NewUser(oldUser); oldTasks.forEach(function (oldTask) { oldTask._id = uuid.v4(); // create a new unique uuid - oldTask.userId = oldUser._id; + oldTask.userId = newUser._id; oldTask.legacyId = oldTask.id; // store the old task id + delete oldTask.id; oldTask.challenge = {}; - if (!oldTask.text) oldTask.text = 'text'; + if (!oldTask.text) oldTask.text = 'task text'; // required oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) { return tagPresent && tagId; }); if (oldTask.type !== 'todo' || (oldTask.type === 'todo' && !oldTask.completed)) { - oldUser.tasksOrder[`${oldTask.type}s`].push(oldTask._id); + newUser.tasksOrder[`${oldTask.type}s`].push(oldTask._id); } - //let newTask = new Tasks[oldTask.type](oldTask); + var newTask = new NewTasks[oldTask.type](oldTask); - batchInsertTasks.insert(oldTask); + batchInsertTasks.insert(newTask.toObject()); processedTasks++; }); - batchInsertUsers.insert(oldUser); + batchInsertUsers.insert(newUser.toObject()); }); console.log(`Saving ${oldUsers.length} users and ${processedTasks} tasks.`); @@ -171,126 +168,28 @@ function processUsers (afterId) { /* TODO var challengeTasksChangedId = {}; -... given a user -let processed = 0; -let batchSize = 1000; - -var db; // defined later by MongoClient -var dbNewUsers; -var dbTasks; - -var processUser = function(gt) { - var query = { - _id: {} - }; - if(gt) query._id.$gt = gt; - - console.log('Launching query', query); - - // take batchsize docs from users and process them - OldUserModel - .find(query) - .lean() // Use plain JS objects as old user data won't match the new model - .limit(batchSize) - .sort({_id: 1}) - .exec(function(err, users) { - if(err) throw err; - - console.log('Processing ' + users.length + ' users.', 'Already processed: ' + processed); - - var lastUser = null; - if(users.length === batchSize){ - lastUser = users[users.length - 1]; - } - - var tasksToSave = 0; - - // Initialize batch operation for later - var batchInsertUsers = dbNewUsers.initializeUnorderedBulkOp(); - var batchInsertTasks = dbTasks.initializeUnorderedBulkOp(); - - users.forEach(function(user){ - // user obj is a plain js object because we used .lean() - - // add tasks order arrays - user.tasksOrder = { - habits: [], - rewards: [], - todos: [], - dailys: [] - }; - - // ... convert tasks to individual models - - var tasksArr = user.dailys - .concat(user.habits) - .concat(user.todos) - .concat(user.rewards); - - // free memory? - user.dailys = user.habits = user.todos = user.rewards = undefined; - - tasksArr.forEach(function(task){ - task.userId = user._id; - - task._id = shared.uuid(); // we rely on these to be unique... hopefully! - task.legacyId = task.id; - task.id = undefined; - - task.challenge = task.challenge || {}; - if(task.challenge.id) { - // If challengeTasksChangedId[task._id] then we got on of the duplicates from the challenges migration - if (challengeTasksChangedId[task.legacyId]) { - var res = _.find(challengeTasksChangedId[task.legacyId], function(arr){ - return arr[1] === task.challenge.id; - }); - - // If res, id changed, otherwise matches the original one - task.challenge.taskId = res ? res[0] : task.legacyId; - } else { - task.challenge.taskId = task.legacyId; - } - } - - if(!task.type) console.log('Task without type ', task._id, ' user ', user._id); - - task = new TaskModel(task); // this should also fix dailies that wen to the habits array or vice-versa - user.tasksOrder[task.type + 's'].push(task._id); - tasksToSave++; - batchInsertTasks.insert(task.toObject()); - }); - - batchInsertUsers.insert((new NewUserModel(user)).toObject()); +tasksArr.forEach(function(task){ + task.challenge = task.challenge || {}; + if(task.challenge.id) { + // If challengeTasksChangedId[task._id] then we got on of the duplicates from the challenges migration + if (challengeTasksChangedId[task.legacyId]) { + var res = _.find(challengeTasksChangedId[task.legacyId], function(arr){ + return arr[1] === task.challenge.id; }); - console.log('Saving', users.length, 'users and', tasksToSave, 'tasks'); + // If res, id changed, otherwise matches the original one + task.challenge.taskId = res ? res[0] : task.legacyId; + } else { + task.challenge.taskId = task.legacyId; + } + } - // Save in the background and dispatch another processUser(); - - batchInsertUsers.execute(function(err, result){ - if(err) throw err // we can't simply accept errors - console.log('Saved', result.nInserted, 'users') - }); - - batchInsertTasks.execute(function(err, result){ - if(err) throw err // we can't simply accept errors - console.log('Saved', result.nInserted, 'tasks') - }); - - processed = processed + users.length; - if(lastUser && lastUser._id){ - processUser(lastUser._id); - } else { - console.log('Done!'); - } - }); -}; + if(!task.type) console.log('Task without type ', task._id, ' user ', user._id); +}); */ // Connect to the databases -var MongoClient = MongoDB.MongoClient; - Q.all([ MongoClient.connect(MONGODB_OLD), MongoClient.connect(MONGODB_NEW), diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js index c031c6da8f..c03e8bb9e0 100644 --- a/test/api/v3/integration/tags/DELETE-tags_id.test.js +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -14,7 +14,7 @@ describe('DELETE /tags/:tagId', () => { let tag = await user.post('/tags', {name: tagName}); let numberOfTags = (await user.get('/tags')).length; - await user.del(`/tags/${tag._id}`); + await user.del(`/tags/${tag.id}`); let tags = await user.get('/tags'); let tagNames = tags.map((t) => { diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index 9dec366104..4ab818593d 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -11,7 +11,7 @@ describe('GET /tags/:tagId', () => { it('returns a tag given it\'s id', async () => { let createdTag = await user.post('/tags', {name: 'Tag 1'}); - let tag = await user.get(`/tags/${createdTag._id}`); + let tag = await user.get(`/tags/${createdTag.id}`); expect(tag).to.deep.equal(createdTag); }); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js index 262d67b361..93f2dfdb60 100644 --- a/test/api/v3/integration/tags/POST-tags.test.js +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -16,7 +16,7 @@ describe('POST /tags', () => { ignored: false, }); - let tag = await user.get(`/tags/${createdTag._id}`); + let tag = await user.get(`/tags/${createdTag.id}`); expect(tag.name).to.equal(tagName); expect(tag.ignored).to.not.exist; diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index c16ccb55fe..4c16453ac3 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -12,12 +12,12 @@ describe('PUT /tags/:tagId', () => { it('updates a tag given it\'s id', async () => { let updatedTagName = 'Tag updated'; let createdTag = await user.post('/tags', {name: 'Tag 1'}); - let updatedTag = await user.put(`/tags/${createdTag._id}`, { + let updatedTag = await user.put(`/tags/${createdTag.id}`, { name: updatedTagName, ignored: true, }); - createdTag = await user.get(`/tags/${updatedTag._id}`); + createdTag = await user.get(`/tags/${updatedTag.id}`); expect(updatedTag.name).to.equal(updatedTagName); expect(updatedTag.ignored).to.not.exist; diff --git a/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js index 9834a3968d..b39e640480 100644 --- a/test/api/v3/integration/tasks/POST-tasks_user.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -154,14 +154,14 @@ describe('POST /tasks/user', () => { text: 'test habit', type: 'habit', reminders: [ - {_id: id1, startDate: new Date(), time: new Date()}, + {id: id1, startDate: new Date(), time: new Date()}, ], }); expect(task.reminders).to.be.an('array'); expect(task.reminders.length).to.eql(1); expect(task.reminders[0]).to.be.an('object'); - expect(task.reminders[0]._id).to.eql(id1); + expect(task.reminders[0].id).to.eql(id1); expect(task.reminders[0].startDate).to.be.a('string'); // json doesn't have dates expect(task.reminders[0].time).to.be.a('string'); }); @@ -345,7 +345,7 @@ describe('POST /tasks/user', () => { expect(task.checklist[0]).to.be.an('object'); expect(task.checklist[0].text).to.eql('checklist'); expect(task.checklist[0].completed).to.eql(false); - expect(task.checklist[0]._id).to.be.a('string'); + expect(task.checklist[0].id).to.be.a('string'); }); }); @@ -487,7 +487,7 @@ describe('POST /tasks/user', () => { expect(task.checklist[0]).to.be.an('object'); expect(task.checklist[0].text).to.eql('checklist'); expect(task.checklist[0].completed).to.eql(false); - expect(task.checklist[0]._id).to.be.a('string'); + expect(task.checklist[0].id).to.be.a('string'); }); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 79a45a09ad..c5bc5d050f 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -80,14 +80,14 @@ describe('PUT /tasks/:id', () => { let savedDaily = await user.put(`/tasks/${daily._id}`, { reminders: [ - {_id: id1, time: new Date(), startDate: new Date()}, - {_id: id2, time: new Date(), startDate: new Date()}, + {id: id1, time: new Date(), startDate: new Date()}, + {id: id2, time: new Date(), startDate: new Date()}, ], }); expect(savedDaily.reminders.length).to.equal(2); - expect(savedDaily.reminders[0]._id).to.equal(id1); - expect(savedDaily.reminders[1]._id).to.equal(id2); + expect(savedDaily.reminders[0].id).to.equal(id1); + expect(savedDaily.reminders[1].id).to.equal(id2); }); }); diff --git a/test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js b/test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js index c1fa6699e9..eac6d455ea 100644 --- a/test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js @@ -51,7 +51,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { let anotherUser = await generateUser(); - await expect(anotherUser.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`)) + await expect(anotherUser.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -67,7 +67,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`); savedTask = await user.get(`/tasks/${task._id}`); expect(savedTask.checklist.length).to.equal(0); @@ -81,7 +81,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`); savedTask = await user.get(`/tasks/${task._id}`); expect(savedTask.checklist.length).to.equal(0); diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js index 00511390c9..06bc7b68b2 100644 --- a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js @@ -62,8 +62,8 @@ describe('POST /tasks/:taskId/checklist/', () => { expect(savedTask.checklist.length).to.equal(1); expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); expect(savedTask.checklist[0].completed).to.equal(false); - expect(savedTask.checklist[0]._id).to.be.a('string'); - expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].id).to.be.a('string'); + expect(savedTask.checklist[0].id).to.not.equal('123'); expect(savedTask.checklist[0].ignored).to.be.an('undefined'); }); @@ -82,8 +82,8 @@ describe('POST /tasks/:taskId/checklist/', () => { expect(savedTask.checklist.length).to.equal(1); expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); expect(savedTask.checklist[0].completed).to.equal(false); - expect(savedTask.checklist[0]._id).to.be.a('string'); - expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].id).to.be.a('string'); + expect(savedTask.checklist[0].id).to.not.equal('123'); expect(savedTask.checklist[0].ignored).to.be.an('undefined'); }); diff --git a/test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js b/test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js index d6a7002891..4dc2ceaa3e 100644 --- a/test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js @@ -48,7 +48,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { let anotherUser = await generateUser(); - await expect(anotherUser.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { + await expect(anotherUser.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`, { text: 'updated', completed: true, _id: 123, // ignored @@ -71,7 +71,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { completed: false, }); - savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { + savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`, { text: 'updated', completed: true, _id: 123, // ignored @@ -80,7 +80,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { expect(savedTask.checklist.length).to.equal(1); expect(savedTask.checklist[0].text).to.equal('updated'); expect(savedTask.checklist[0].completed).to.equal(true); - expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].id).to.not.equal('123'); }); it('updates a checklist item on todos', async () => { @@ -94,7 +94,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { completed: false, }); - savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { + savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`, { text: 'updated', completed: true, _id: 123, // ignored @@ -103,7 +103,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { expect(savedTask.checklist.length).to.equal(1); expect(savedTask.checklist[0].text).to.equal('updated'); expect(savedTask.checklist[0].completed).to.equal(true); - expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].id).to.not.equal('123'); }); it('fails on habits', async () => { diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index 7b00896738..2cf08bbece 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -19,7 +19,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`); savedTask = await user.get(`/tasks/${task._id}`); expect(savedTask.checklist.length).to.equal(0); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index ce9e2dab7c..7166db02da 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -26,8 +26,8 @@ describe('POST /tasks/:taskId/checklist/', () => { expect(savedTask.checklist.length).to.equal(1); expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); expect(savedTask.checklist[0].completed).to.equal(false); - expect(savedTask.checklist[0]._id).to.be.a('string'); - expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].id).to.be.a('string'); + expect(savedTask.checklist[0].id).to.not.equal('123'); expect(savedTask.checklist[0].ignored).to.be.an('undefined'); }); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 3efdd9642f..edb65dfb65 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -22,7 +22,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { completed: false, }); - savedTask = await user.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); + savedTask = await user.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}/score`); expect(savedTask.checklist.length).to.equal(1); expect(savedTask.checklist[0].completed).to.equal(true); diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index dee75ed914..003bcb2650 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -22,7 +22,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { completed: false, }); - savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { + savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`, { text: 'updated', completed: true, _id: 123, // ignored @@ -31,7 +31,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { expect(savedTask.checklist.length).to.equal(1); expect(savedTask.checklist[0].text).to.equal('updated'); expect(savedTask.checklist[0].completed).to.equal(true); - expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].id).to.not.equal('123'); }); it('fails on habits', async () => { diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index fecb64b405..ebb1a3c9e8 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -19,8 +19,8 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { let tag = await user.post('/tags', {name: 'Tag 1'}); - await user.post(`/tasks/${task._id}/tags/${tag._id}`); - await user.del(`/tasks/${task._id}/tags/${tag._id}`); + await user.post(`/tasks/${task._id}/tags/${tag.id}`); + await user.del(`/tasks/${task._id}/tags/${tag.id}`); let updatedTask = await user.get(`/tasks/${task._id}`); diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index b6e7174022..d6cea02036 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -18,9 +18,9 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); let tag = await user.post('/tags', {name: 'Tag 1'}); - let savedTask = await user.post(`/tasks/${task._id}/tags/${tag._id}`); + let savedTask = await user.post(`/tasks/${task._id}/tags/${tag.id}`); - expect(savedTask.tags[0]).to.equal(tag._id); + expect(savedTask.tags[0]).to.equal(tag.id); }); it('does not add a tag to a task twice', async () => { @@ -31,9 +31,9 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag = await user.post('/tags', {name: 'Tag 1'}); - await user.post(`/tasks/${task._id}/tags/${tag._id}`); + await user.post(`/tasks/${task._id}/tags/${tag.id}`); - await expect(user.post(`/tasks/${task._id}/tags/${tag._id}`)).to.eventually.be.rejected.and.eql({ + await expect(user.post(`/tasks/${task._id}/tags/${tag.id}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('alreadyTagged'), diff --git a/test/common/ops/updateTask.js b/test/common/ops/updateTask.js index d476421f7c..99e8d80b22 100644 --- a/test/common/ops/updateTask.js +++ b/test/common/ops/updateTask.js @@ -13,7 +13,7 @@ describe('shared.ops.updateTask', () => { ], reminders: [{ - _id: '123', + id: '123', startDate: now, time: now, }], @@ -29,7 +29,7 @@ describe('shared.ops.updateTask', () => { checklist: [{ completed: false, text: 'item', - _id: '123', + id: '123', }], }, }); @@ -41,10 +41,10 @@ describe('shared.ops.updateTask', () => { expect(res.checklist).to.eql([{ completed: false, text: 'item', - _id: '123', + id: '123', }]); expect(res.reminders).to.eql([{ - _id: '123', + id: '123', startDate: now, time: now, }]); diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 03170b96b3..e40a83b7e6 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -72,7 +72,7 @@ api.getTag = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let tag = user.tags.id(req.params.tagId); + let tag = _.find(user.tags, {id: req.params.tagId}); if (!tag) throw new NotFound(res.t('tagNotFound')); res.respond(200, tag); }, @@ -102,13 +102,13 @@ api.updateTag = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let tag = user.tags.id(tagId); + let tag = _.find(user.tags, {id: tagId}); if (!tag) throw new NotFound(res.t('tagNotFound')); _.merge(tag, Tag.sanitize(req.body)); let savedUser = await user.save(); - res.respond(200, savedUser.tags.id(tagId)); + res.respond(200, _.find(savedUser.tags, {id: tagId})); }, }; @@ -134,7 +134,7 @@ api.deleteTag = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let tag = user.tags.id(req.params.tagId); + let tag = _.find(user.tags, {id: req.params.tagId}); if (!tag) throw new NotFound(res.t('tagNotFound')); tag.remove(); @@ -143,7 +143,7 @@ api.deleteTag = { userId: user._id, }, { $pull: { - tags: tag._id, + tags: tag.id, }, }, {multi: true}).exec(); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 3af9d91090..d058ba1ae1 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -518,7 +518,7 @@ api.addChecklistItem = { if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); // TODO why not allow to supply _id on creation? + task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); let savedTask = await task.save(); res.respond(200, savedTask); @@ -558,7 +558,7 @@ api.scoreCheckListItem = { if (!task) throw new NotFound(res.t('taskNotFound')); if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - let item = _.find(task.checklist, {_id: req.params.itemId}); + let item = _.find(task.checklist, {id: req.params.itemId}); if (!item) throw new NotFound(res.t('checklistItemNotFound')); item.completed = !item.completed; @@ -608,7 +608,7 @@ api.updateChecklistItem = { } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - let item = _.find(task.checklist, {_id: req.params.itemId}); + let item = _.find(task.checklist, {id: req.params.itemId}); if (!item) throw new NotFound(res.t('checklistItemNotFound')); _.merge(item, Tasks.Task.sanitizeChecklist(req.body)); @@ -659,7 +659,7 @@ api.removeChecklistItem = { } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - let hasItem = removeFromArray(task.checklist, { _id: req.params.itemId }); + let hasItem = removeFromArray(task.checklist, { id: req.params.itemId }); if (!hasItem) throw new NotFound(res.t('checklistItemNotFound')); let savedTask = await task.save(); @@ -687,7 +687,7 @@ api.addTagToTask = { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - let userTags = user.tags.map(tag => tag._id); + let userTags = user.tags.map(tag => tag.id); req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags); let validationErrors = req.validationErrors(); diff --git a/website/src/controllers/top-level/pages.js b/website/src/controllers/top-level/pages.js index 8896c61dd4..63642992f5 100644 --- a/website/src/controllers/top-level/pages.js +++ b/website/src/controllers/top-level/pages.js @@ -1,8 +1,8 @@ import locals from '../../middlewares/api-v3/locals'; import _ from 'lodash'; -import Remarkable from 'remarkable'; +import markdownIt from 'markdown-it'; -const md = new Remarkable({ +const md = markdownIt({ html: true, }); diff --git a/website/src/models/tag.js b/website/src/models/tag.js index 3159ef5fc7..6265e044b8 100644 --- a/website/src/models/tag.js +++ b/website/src/models/tag.js @@ -1,9 +1,17 @@ import mongoose from 'mongoose'; import baseModel from '../libs/api-v3/baseModel'; +import { v4 as uuid } from 'uuid'; +import validator from 'validator'; let Schema = mongoose.Schema; export let schema = new Schema({ + _id: false, // use id not _id + id: { + type: String, + default: uuid, + validate: [validator.isUUID, 'Invalid uuid.'], + }, name: {type: String, required: true}, challenge: {type: String}, }, { @@ -12,7 +20,7 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - noSet: ['_id', 'challenge'], + noSet: ['_id', 'id', 'challenge'], }); export let model = mongoose.model('Tag', schema); diff --git a/website/src/models/task.js b/website/src/models/task.js index 100f8ebe3b..4945f121f6 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -45,7 +45,8 @@ export let TaskSchema = new Schema({ }, reminders: [{ - _id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], default: shared.uuid, required: true}, + _id: false, + id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], default: shared.uuid, required: true}, startDate: {type: Date, required: true}, time: {type: Date, required: true}, }], @@ -67,15 +68,15 @@ TaskSchema.plugin(baseModel, { timestamps: true, }); -// Sanitize checklist objects (disallowing _id) +// Sanitize checklist objects (disallowing id) TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) { - delete checklistObj._id; + delete checklistObj.id; return checklistObj; }; // Sanitize reminder objects (disallowing id) TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) { - delete reminderObj.id; // TODO convert to _id? + delete reminderObj.id; return reminderObj; }; @@ -159,8 +160,9 @@ let dailyTodoSchema = () => { collapseChecklist: {type: Boolean, default: false}, checklist: [{ completed: {type: Boolean, default: false}, - text: {type: String, required: true}, - _id: {type: String, default: shared.uuid, validate: [validator.isUUID, 'Invalid uuid.']}, + text: {type: String, required: false, default: ''}, // required:false because it can be empty on creation + _id: false, + id: {type: String, default: shared.uuid, validate: [validator.isUUID, 'Invalid uuid.']}, }], }; }; From f5144fddaab00c3f998bb585c555580d882d1783 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 1 May 2016 12:51:32 +0200 Subject: [PATCH 727/976] v3 adapt v2: fix tags _id, stickyHeader and unlocking --- common/script/ops/unlock.js | 6 ++++-- common/script/public/userServices.js | 2 +- test/common/ops/unlock.js | 3 ++- website/src/controllers/api-v2/user.js | 1 + website/src/libs/api-v3/baseModel.js | 16 +++++++++------- website/src/models/tag.js | 3 ++- website/src/models/user.js | 2 +- 7 files changed, 20 insertions(+), 13 deletions(-) diff --git a/common/script/ops/unlock.js b/common/script/ops/unlock.js index 5443b27f30..5e0b118af0 100644 --- a/common/script/ops/unlock.js +++ b/common/script/ops/unlock.js @@ -44,9 +44,11 @@ module.exports = function unlock (user, req = {}, analytics) { if (alreadyOwnedItems === setPaths.length) { throw new NotAuthorized(i18n.t('alreadyUnlocked', req.language)); - } else if (alreadyOwnedItems > 0) { + // TODO write math formula to check if buying the full set is cheaper than the items individually + // (item cost * number of remaining items) < setCost` + } /* else if (alreadyOwnedItems > 0) { throw new NotAuthorized(i18n.t('alreadyUnlockedPart', req.language)); - } + } */ } else { alreadyOwns = _.get(user, `purchased.${path}`) === true; } diff --git a/common/script/public/userServices.js b/common/script/public/userServices.js index e5b1790086..f786afae96 100644 --- a/common/script/public/userServices.js +++ b/common/script/public/userServices.js @@ -109,7 +109,7 @@ angular.module('habitrpg') if (MOBILE_APP) Notification.push({type:'text',text:message}); else Notification.text(message); // In the case of 200s, they're friendly alert messages like "Your pet has hatched!" - still send the op - if ((err.code && err.code >= 400) || !err.code) return; + if ((err.code && err.code >= 400) || !err.code) return; } userServices.log({op:k, params: req.params, query:req.query, body:req.body}); }); diff --git a/test/common/ops/unlock.js b/test/common/ops/unlock.js index cc9d286d42..ede5fe1d31 100644 --- a/test/common/ops/unlock.js +++ b/test/common/ops/unlock.js @@ -54,7 +54,8 @@ describe('shared.ops.unlock', () => { } }); - it('returns an error when user already owns items in a full set', (done) => { + // disabled untill fully implemente + xit('returns an error when user already owns items in a full set', (done) => { try { unlock(user, {query: {path: unlockPath}}); unlock(user, {query: {path: unlockPath}}); diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index d0430c6a80..fec0c78cef 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -988,6 +988,7 @@ api.batchUpdate = function(req, res, next) { response = transformedData; response.todos = shared.preenTodos(response.todos); + response.wasModified = true; res.status(200).json(response); }); // return only the version number diff --git a/website/src/libs/api-v3/baseModel.js b/website/src/libs/api-v3/baseModel.js index 71ea879423..e0b2101393 100644 --- a/website/src/libs/api-v3/baseModel.js +++ b/website/src/libs/api-v3/baseModel.js @@ -4,13 +4,15 @@ import objectPath from 'object-path'; // TODO use lodash's unset once v4 is out import _ from 'lodash'; module.exports = function baseModel (schema, options = {}) { - schema.add({ - _id: { - type: String, - default: uuid, - validate: [validator.isUUID, 'Invalid uuid.'], - }, - }); + if (options._id !== false) { + schema.add({ + _id: { + type: String, + default: uuid, + validate: [validator.isUUID, 'Invalid uuid.'], + }, + }); + } if (options.timestamps) { schema.add({ diff --git a/website/src/models/tag.js b/website/src/models/tag.js index 6265e044b8..f201541540 100644 --- a/website/src/models/tag.js +++ b/website/src/models/tag.js @@ -6,7 +6,6 @@ import validator from 'validator'; let Schema = mongoose.Schema; export let schema = new Schema({ - _id: false, // use id not _id id: { type: String, default: uuid, @@ -17,10 +16,12 @@ export let schema = new Schema({ }, { strict: true, minimize: false, // So empty objects are returned + _id: false, // use id instead of _id }); schema.plugin(baseModel, { noSet: ['_id', 'id', 'challenge'], + _id: false, // use id instead of _id }); export let model = mongoose.model('Tag', schema); diff --git a/website/src/models/user.js b/website/src/models/user.js index b7d8ca3903..68bbcbf39e 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -760,7 +760,7 @@ schema.methods.addTasksToUser = function addTasksToUser (tasks) { obj.tags = obj.tags.map(tag => { return { - id: tag._id, + id: tag.id, name: tag.name, challenge: tag.challenge, }; From a63d5ae97ff8ff58151bbcf2b51aef151e5aa4f5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 1 May 2016 13:25:19 +0200 Subject: [PATCH 728/976] v3: fix tags tests --- test/api/v3/unit/models/challenge.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/unit/models/challenge.test.js b/test/api/v3/unit/models/challenge.test.js index 00332761f4..c1cbd83e18 100644 --- a/test/api/v3/unit/models/challenge.test.js +++ b/test/api/v3/unit/models/challenge.test.js @@ -96,7 +96,7 @@ describe('Challenge Model', () => { }); expect(updatedNewMember.challenges).to.contain(challenge._id); - expect(updatedNewMember.tags[3]._id).to.equal(challenge._id); + expect(updatedNewMember.tags[3].id).to.equal(challenge._id); expect(updatedNewMember.tags[3].name).to.equal(challenge.shortName); expect(syncedTask).to.exist; }); From a8fbafb80191cb5de4fac48b3d8078662f3badfc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 1 May 2016 13:48:55 +0200 Subject: [PATCH 729/976] v3: fix tags tests --- website/src/models/challenge.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 4919862700..681d238c16 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -83,7 +83,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) { // Sync tags let userTags = user.tags; - let i = _.findIndex(userTags, {_id: challenge._id}); + let i = _.findIndex(userTags, {id: challenge._id}); if (i !== -1) { if (userTags[i].name !== challenge.shortName) { @@ -92,7 +92,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) { } } else { userTags.push({ - _id: challenge._id, + id: challenge._id, name: challenge.shortName, challenge: true, }); From 8391494b89b89d37e3bc91fea316a215eada949a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 1 May 2016 14:15:26 +0200 Subject: [PATCH 730/976] v3: fix tags tests --- test/api/v3/integration/models/GET-model_paths.test.js | 4 +++- website/src/controllers/api-v3/tags.js | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/models/GET-model_paths.test.js b/test/api/v3/integration/models/GET-model_paths.test.js index 205269bcbe..0a9a94451a 100644 --- a/test/api/v3/integration/models/GET-model_paths.test.js +++ b/test/api/v3/integration/models/GET-model_paths.test.js @@ -23,7 +23,9 @@ describe('GET /models/:model/paths', () => { it(`returns the model paths for ${model}`, async () => { let res = await user.get(`/models/${model}/paths`); - expect(res._id).to.equal('String'); + if (model !== 'tag') expect(res._id).to.equal('String'); + if (model === 'tag') expect(res.id).to.equal('String'); + expect(res).to.not.have.keys('__v'); }); }); diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index e40a83b7e6..8d452154cd 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -5,6 +5,7 @@ import { NotFound, } from '../../libs/api-v3/errors'; import _ from 'lodash'; +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; let api = {}; @@ -134,9 +135,8 @@ api.deleteTag = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let tag = _.find(user.tags, {id: req.params.tagId}); + let tag = removeFromArray(user.tags, { id: req.params.tagId }); if (!tag) throw new NotFound(res.t('tagNotFound')); - tag.remove(); // Remove from all the tasks TODO test await Tasks.Task.update({ From 4457b1c18c06eaa793ebae05562975af79454fc9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 1 May 2016 14:46:22 +0200 Subject: [PATCH 731/976] v3: fix tags and challenges migration --- migrations/api_v3/challenges.js | 105 +++++-------------------- website/src/controllers/api-v2/user.js | 21 +++-- 2 files changed, 29 insertions(+), 97 deletions(-) diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index c6b4a1e3b3..5c2d44791c 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -1,79 +1,7 @@ /* - name is required, - shortName is required, - tasksOrder - habits, dailys, todos and rewards must be removed - leader is required - group is required members must be removed - memberCount must be checked - prize must be >= 0 */ -// A map of (original taskId) -> [new taskId in challenge, challendId] of tasks belonging to challenges where the task id had to change -// This way later we can have use the right task.challenge.taskId in user's tasks -var duplicateTasks = {}; - - // ... convert tasks to individual models - async.each( - challenge.dailys - .concat(challenge.habits) - .concat(challenge.rewards) - .concat(challenge.todos), - function(task, cb1) { - - task = new TaskModel(task); // this should also fix dailies that wen to the habits array or vice-versa - - TaskModel.findOne({_id: task._id}, function(err, taskSameId){ - if(err) return cb1(err); - - // We already have a task with the same id, change this one - // and will require special handling - if(taskSameId) { - task._id = shared.uuid(); - task.legacyId = taskSameId._id; // We set this for challenge tasks too - // we use an array as the same task may have multiple duplicates - duplicateTasks[taskSameId._id] = duplicateTasks[taskSameId._id] || []; - duplicateTasks[taskSameId._id].push([task._id, challenge._id]); - console.log('Duplicate task ', taskSameId._id, 'challenge ', challenge._id, 'new id ', task._id); - } - - task.save(function(err, savedTask){ - if(err) return cb1(err); - - challenge.tasksOrder[savedTask.type + 's'].push(savedTask._id); - cb1(); - }); - }); - }, function(err) { - if(err) return cb(err); - - var newChallenge = new NewChallengeModel(challenge); // This will make sure old data is discarded - newChallenge.save(function(err, chal){ - if(err) return cb(err); - console.log('Processed: ', chal._id); - cb(); - }); - }); - }, function(err) { - if(err) throw err; - - processed = processed + challenges.length; - console.log('Processed ' + challenges.length + ' challenges.', 'Total: ' + processed); - - if(lastChal && lastChal._id){ - processChal(lastChal._id); - } else { - console.log('Done!'); - // outputting the duplicate tasks - console.log(JSON.stringify(duplicateTasks, null, 4)); - } - }); - }); -}; - -processChal(); - // Migrate users collection to new schema // This should run AFTER challenges migration @@ -83,7 +11,7 @@ processChal(); // Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). // Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM -console.log('Starting migrations/api_v3/users.js.'); +console.log('Starting migrations/api_v3/challenges.js.'); require('babel-register'); @@ -153,7 +81,7 @@ function processChallenges (afterId) { var batchInsertTasks = newTaskCollection.initializeUnorderedBulkOp(); var batchInsertChallenges = newChallengeCollection.initializeUnorderedBulkOp(); - console.log(`Executing challenges query.\nMatching challenges after ${afterId ? afterId : AFTER_USER_ID} and before ${BEFORE_USER_ID} (included).`); + console.log(`Executing challenges query.\nMatching challenges after ${afterId ? afterId : AFTER_CHALLENGE_ID} and before ${BEFORE_CHALLENGE_ID} (included).`); return oldChallengeCollection .find(query) @@ -176,21 +104,29 @@ function processChallenges (afterId) { delete oldChallenge.rewards; delete oldChallenge.todos; + oldChallenge.memberCount = oldChallenge.members.length; + if (!oldChallenge.prize <= 0) oldChallenge.prize = 0; + if (!oldChallenge.name) oldChallenge.name = 'challenge name'; + if (!oldChallenge.shortName) oldChallenge.name = 'challenge-name'; + + if (!oldChallenge.group) throw new Error('challenge.group is required'); + if (!oldChallenge.leader) throw new Error('challenge.leader is required'); + var newChallenge = new NewChallenge(oldChallenge); oldTasks.forEach(function (oldTask) { - // TODO oldTask._id = oldTask.id; // keep the old uuid unless duplicated delete oldTask.id; - oldTask.challenge = oldTask.challenge || {}; - oldTask.challenge.id = oldChallenge.id; - - if (!oldTask.text) oldTask.text = 'task text'; // required - oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) { // TODO used for challenges' tasks? + oldTask.tags = _.map(oldTask.tags || {}, function (tagPresent, tagId) { return tagPresent && tagId; }); + if (!oldTask.text) oldTask.text = 'task text'; // required + + oldTask.challenge = oldTask.challenge || {}; + oldTask.challenge.id = oldChallenge._id; + newChallenge.tasksOrder[`${oldTask.type}s`].push(oldTask._id); if (oldTask.completed) oldTask.completed = false; @@ -205,18 +141,15 @@ function processChallenges (afterId) { console.log(`Saving ${oldChallenges.length} users and ${processedTasks} tasks.`); - return Q.all([ - batchInsertChallenges.execute(), - batchInsertTasks.execute(), - ]); + return batchInsertChallenges.execute(); }) .then(function () { totoalProcessedTasks += processedTasks; processedChallenges += oldChallenges.length; - console.log(`Saved ${oldChallenges.length} users and their tasks.`); + console.log(`Saved ${oldChallenges.length} challenges and their tasks.`); - if (lastUser) { + if (lastChallenge) { return processChallenges(lastChallenge); } else { return console.log('Done!'); diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index fec0c78cef..a9696e0cc0 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -493,21 +493,21 @@ api.getTags = function (req, res, next) { res.json(res.locals.user.tags.toObject().map(tag => { return { name: tag.name, - id: tag._id, + id: tag.id, challenge: tag.challenge, } })); }; api.getTag = function (req, res, next) { - let tag = res.locals.user.tags.id(req.params.id); + let tag = _.find(res.locals.user.tags, {id: req.params.id}); if (!tag) { return res.status(404).json({err: i18n.t('messageTagNotFound', req.language)}); } res.json({ name: tag.name, - id: tag._id, + id: tag.id, challenge: tag.challenge, }); }; @@ -522,7 +522,7 @@ api.addTag = function (req, res, next) { res.json(user.tags.toObject().map(tag => { return { name: tag.name, - id: tag._id, + id: tag.id, challenge: tag.challenge, } })); @@ -532,7 +532,7 @@ api.addTag = function (req, res, next) { api.updateTag = function (req, res, next) { let user = res.locals.user; - let tag = user.tags.id(req.params.id); + let tag = _.find(res.locals.user.tags, {id: req.params.id}); if (!tag) { return res.status(404).json({err: i18n.t('messageTagNotFound', req.language)}); } @@ -543,7 +543,7 @@ api.updateTag = function (req, res, next) { res.json({ name: tag.name, - id: tag._id, + id: tag.id, challenge: tag.challenge, }); }); @@ -566,7 +566,7 @@ api.sortTag = function (req, res, next) { res.json(user.tags.toObject().map(tag => { return { name: tag.name, - id: tag._id, + id: tag.id, challenge: tag.challenge, } })); @@ -576,18 +576,17 @@ api.sortTag = function (req, res, next) { api.deleteTag = function (req, res, next) { let user = res.locals.user; - let tag = user.tags.id(req.params.id); + let tag = removeFromArray(user.tags, { id: req.params.id }); if (!tag) { return res.status(404).json({err: i18n.t('messageTagNotFound', req.language)}); } - tag.remove(); Tasks.Task.update({ userId: user._id, }, { $pull: { - tags: tag._id, + tags: tag.id, }, }, {multi: true}).exec(); @@ -597,7 +596,7 @@ api.deleteTag = function (req, res, next) { res.json(user.tags.toObject().map(tag => { return { name: tag.name, - id: tag._id, + id: tag.id, challenge: tag.challenge, } })); From e8024f98e105240ad604b1f290c109c661810c2d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 1 May 2016 16:27:04 +0200 Subject: [PATCH 732/976] v3 migration: first finished version that contains all models --- migrations/api_v3/challenges.js | 20 +-- migrations/api_v3/challengesMembers.js | 135 ++++++++++++++++ migrations/api_v3/coupons.js | 135 ++++++++++++++++ migrations/api_v3/emailUnsubscriptions.js | 140 +++++++++++++++- migrations/api_v3/groups.js | 189 ++++++++++++++++++++-- migrations/api_v3/users.js | 37 +---- website/src/models/emailUnsubscription.js | 11 +- website/src/models/group.js | 2 +- 8 files changed, 610 insertions(+), 59 deletions(-) create mode 100644 migrations/api_v3/challengesMembers.js create mode 100644 migrations/api_v3/coupons.js diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index 5c2d44791c..e2a88df0c4 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -1,9 +1,4 @@ -/* - members must be removed -*/ - -// Migrate users collection to new schema -// This should run AFTER challenges migration +// Migrate challenges collection to new schema (except for members) // The console-stamp module must be installed (not included in package.json) @@ -104,6 +99,8 @@ function processChallenges (afterId) { delete oldChallenge.rewards; delete oldChallenge.todos; + var createdAt = oldChallenge.timestamp; + oldChallenge.memberCount = oldChallenge.members.length; if (!oldChallenge.prize <= 0) oldChallenge.prize = 0; if (!oldChallenge.name) oldChallenge.name = 'challenge name'; @@ -114,6 +111,8 @@ function processChallenges (afterId) { var newChallenge = new NewChallenge(oldChallenge); + newChallenge.createdAt = createdAt; + oldTasks.forEach(function (oldTask) { oldTask._id = oldTask.id; // keep the old uuid unless duplicated delete oldTask.id; @@ -139,9 +138,12 @@ function processChallenges (afterId) { batchInsertChallenges.insert(newChallenge.toObject()); }); - console.log(`Saving ${oldChallenges.length} users and ${processedTasks} tasks.`); + console.log(`Saving ${oldChallenges.length} challenges and ${processedTasks} tasks.`); - return batchInsertChallenges.execute(); + return Q.all([ + batchInsertChallenges.execute(), + batchInsertTasks.execute(), + ]); }) .then(function () { totoalProcessedTasks += processedTasks; @@ -178,5 +180,5 @@ Q.all([ return processChallenges(); }) .catch(function (err) { - console.error(err); + console.error(err.stack || err); }); diff --git a/migrations/api_v3/challengesMembers.js b/migrations/api_v3/challengesMembers.js new file mode 100644 index 0000000000..f5c1532660 --- /dev/null +++ b/migrations/api_v3/challengesMembers.js @@ -0,0 +1,135 @@ +// Migrate challenges members +// Run AFTER users migration + +// The console-stamp module must be installed (not included in package.json) + +// It requires two environment variables: MONGODB_OLD and MONGODB_NEW + +// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). +// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM +console.log('Starting migrations/api_v3/challengesMembers.js.'); + +require('babel-register'); + +var Q = require('q'); +var MongoDB = require('mongodb'); +var nconf = require('nconf'); +var mongoose = require('mongoose'); +var _ = require('lodash'); +var uuid = require('uuid'); +var consoleStamp = require('console-stamp'); + +// Add timestamps to console messages +consoleStamp(console); + +// Initialize configuration +require('../../website/src/libs/api-v3/setupNconf')(); + +var MONGODB_OLD = nconf.get('MONGODB_OLD'); +var MONGODB_NEW = nconf.get('MONGODB_NEW'); + +var MongoClient = MongoDB.MongoClient; + +mongoose.Promise = Q.Promise; // otherwise mongoose models won't work + +// To be defined later when MongoClient connects +var mongoDbOldInstance; +var oldChallengeCollection; + +var mongoDbNewInstance; +var newUserCollection; + +var BATCH_SIZE = 1000; + +var processedChallenges = 0; + +// Only process challenges that fall in a interval ie -> up to 0000-4000-0000-0000 +var AFTER_CHALLENGE_ID = nconf.get('AFTER_CHALLENGE_ID'); +var BEFORE_CHALLENGE_ID = nconf.get('BEFORE_CHALLENGE_ID'); + +function processChallenges (afterId) { + var processedTasks = 0; + var lastChallenge = null; + var oldChallenges; + + var query = {}; + + if (BEFORE_CHALLENGE_ID) { + query._id = {$lte: BEFORE_CHALLENGE_ID}; + } + + if ((afterId || AFTER_CHALLENGE_ID) && !query._id) { + query._id = {}; + } + + if (afterId) { + query._id.$gt = afterId; + } else if (AFTER_CHALLENGE_ID) { + query._id.$gt = AFTER_CHALLENGE_ID; + } + + console.log(`Executing challenges query.\nMatching challenges after ${afterId ? afterId : AFTER_CHALLENGE_ID} and before ${BEFORE_CHALLENGE_ID} (included).`); + + return oldChallengeCollection + .find(query) + .sort({_id: 1}) + .limit(BATCH_SIZE) + .toArray() + .then(function (oldChallengesR) { + oldChallenges = oldChallengesR; + + var promises = []; + + console.log(`Processing ${oldChallenges.length} challenges. Already processed ${processedChallenges} challenges.`); + + if (oldChallenges.length === BATCH_SIZE) { + lastChallenge = oldChallenges[oldChallenges.length - 1]._id; + } + + oldChallenges.forEach(function (oldChallenge) { + promises.push(newUserCollection.updateMany({ + _id: {$in: oldChallenge.members}, + }, { + $push: {challenges: oldChallenge._id}, + }, {multi: true})); + }); + + console.log(`Migrating members of ${oldChallenges.length} challenges.`); + + return Q.all(promises); + }) + .then(function () { + processedChallenges += oldChallenges.length; + + console.log(`Migrated members of ${oldChallenges.length} challenges.`); + + if (lastChallenge) { + return processChallenges(lastChallenge); + } else { + return console.log('Done!'); + } + }); +} + +// Connect to the databases +Q.all([ + MongoClient.connect(MONGODB_OLD), + MongoClient.connect(MONGODB_NEW), +]) +.then(function (result) { + var oldInstance = result[0]; + var newInstance = result[1]; + + mongoDbOldInstance = oldInstance; + oldChallengeCollection = mongoDbOldInstance.collection('challenges'); + + mongoDbNewInstance = newInstance; + newUserCollection = mongoDbNewInstance.collection('users'); + + console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); + + return processChallenges(); +}) +.catch(function (err) { + console.error(err.stack || err); +}); diff --git a/migrations/api_v3/coupons.js b/migrations/api_v3/coupons.js new file mode 100644 index 0000000000..8fb2676014 --- /dev/null +++ b/migrations/api_v3/coupons.js @@ -0,0 +1,135 @@ +// Migrate coupons collection to new schema + +// The console-stamp module must be installed (not included in package.json) + +// It requires two environment variables: MONGODB_OLD and MONGODB_NEW + +// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). +// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM +console.log('Starting migrations/api_v3/coupons.js.'); + +require('babel-register'); + +var Q = require('q'); +var MongoDB = require('mongodb'); +var nconf = require('nconf'); +var mongoose = require('mongoose'); +var _ = require('lodash'); +var uuid = require('uuid'); +var consoleStamp = require('console-stamp'); + +// Add timestamps to console messages +consoleStamp(console); + +// Initialize configuration +require('../../website/src/libs/api-v3/setupNconf')(); + +var MONGODB_OLD = nconf.get('MONGODB_OLD'); +var MONGODB_NEW = nconf.get('MONGODB_NEW'); + +var MongoClient = MongoDB.MongoClient; + +mongoose.Promise = Q.Promise; // otherwise mongoose models won't work + +// Load new models +var Coupon = require('../../website/src/models/coupon').model; + +// To be defined later when MongoClient connects +var mongoDbOldInstance; +var oldCouponCollection; + +var mongoDbNewInstance; +var newCouponCollection; + +var BATCH_SIZE = 1000; + +var processedCoupons = 0; + +// Only process coupons that fall in a interval ie -> up to 0000-4000-0000-0000 +var AFTER_COUPON_ID = nconf.get('AFTER_COUPON_ID'); +var BEFORE_COUPON_ID = nconf.get('BEFORE_COUPON_ID'); + +function processCoupons (afterId) { + var processedTasks = 0; + var lastCoupon = null; + var oldCoupons; + + var query = {}; + + if (BEFORE_COUPON_ID) { + query._id = {$lte: BEFORE_COUPON_ID}; + } + + if ((afterId || AFTER_COUPON_ID) && !query._id) { + query._id = {}; + } + + if (afterId) { + query._id.$gt = afterId; + } else if (AFTER_COUPON_ID) { + query._id.$gt = AFTER_COUPON_ID; + } + + var batchInsertCoupons = newCouponCollection.initializeUnorderedBulkOp(); + + console.log(`Executing coupons query.\nMatching coupons after ${afterId ? afterId : AFTER_COUPON_ID} and before ${BEFORE_COUPON_ID} (included).`); + + return oldCouponCollection + .find(query) + .sort({_id: 1}) + .limit(BATCH_SIZE) + .toArray() + .then(function (oldCouponsR) { + oldCoupons = oldCouponsR; + + console.log(`Processing ${oldCoupons.length} coupons. Already processed ${processedCoupons} coupons.`); + + if (oldCoupons.length === BATCH_SIZE) { + lastCoupon = oldCoupons[oldCoupons.length - 1]._id; + } + + oldCoupons.forEach(function (oldCoupon) { + var newCoupon = new Coupon(oldCoupon); + + batchInsertCoupons.insert(newCoupon.toObject()); + }); + + console.log(`Saving ${oldCoupons.length} coupons.`); + + return batchInsertCoupons.execute(); + }) + .then(function () { + processedCoupons += oldCoupons.length; + + console.log(`Saved ${oldCoupons.length} coupons.`); + + if (lastCoupon) { + return processCoupons(lastCoupon); + } else { + return console.log('Done!'); + } + }); +} + +// Connect to the databases +Q.all([ + MongoClient.connect(MONGODB_OLD), + MongoClient.connect(MONGODB_NEW), +]) +.then(function (result) { + var oldInstance = result[0]; + var newInstance = result[1]; + + mongoDbOldInstance = oldInstance; + oldCouponCollection = mongoDbOldInstance.collection('coupons'); + + mongoDbNewInstance = newInstance; + newCouponCollection = mongoDbNewInstance.collection('coupons'); + + console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); + + return processCoupons(); +}) +.catch(function (err) { + console.error(err.stack || err); +}); diff --git a/migrations/api_v3/emailUnsubscriptions.js b/migrations/api_v3/emailUnsubscriptions.js index 27eb8a8d61..9ee65cade2 100644 --- a/migrations/api_v3/emailUnsubscriptions.js +++ b/migrations/api_v3/emailUnsubscriptions.js @@ -1,4 +1,136 @@ -/* - email must be lowercase - remove unique: true from mongoose schema -*/ +// Migrate unsubscriptions collection to new schema + +// The console-stamp module must be installed (not included in package.json) + +// It requires two environment variables: MONGODB_OLD and MONGODB_NEW + +// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). +// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM +console.log('Starting migrations/api_v3/unsubscriptions.js.'); + +require('babel-register'); + +var Q = require('q'); +var MongoDB = require('mongodb'); +var nconf = require('nconf'); +var mongoose = require('mongoose'); +var _ = require('lodash'); +var uuid = require('uuid'); +var consoleStamp = require('console-stamp'); + +// Add timestamps to console messages +consoleStamp(console); + +// Initialize configuration +require('../../website/src/libs/api-v3/setupNconf')(); + +var MONGODB_OLD = nconf.get('MONGODB_OLD'); +var MONGODB_NEW = nconf.get('MONGODB_NEW'); + +var MongoClient = MongoDB.MongoClient; + +mongoose.Promise = Q.Promise; // otherwise mongoose models won't work + +// Load new models +var EmailUnsubscription = require('../../website/src/models/emailUnsubscription').model; + +// To be defined later when MongoClient connects +var mongoDbOldInstance; +var oldUnsubscriptionCollection; + +var mongoDbNewInstance; +var newUnsubscriptionCollection; + +var BATCH_SIZE = 1000; + +var processedUnsubscriptions = 0; + +// Only process unsubscriptions that fall in a interval ie -> up to 0000-4000-0000-0000 +var AFTER_UNSUBSCRIPTION_ID = nconf.get('AFTER_UNSUBSCRIPTION_ID'); +var BEFORE_UNSUBSCRIPTION_ID = nconf.get('BEFORE_UNSUBSCRIPTION_ID'); + +function processUnsubscriptions (afterId) { + var processedTasks = 0; + var lastUnsubscription = null; + var oldUnsubscriptions; + + var query = {}; + + if (BEFORE_UNSUBSCRIPTION_ID) { + query._id = {$lte: BEFORE_UNSUBSCRIPTION_ID}; + } + + if ((afterId || AFTER_UNSUBSCRIPTION_ID) && !query._id) { + query._id = {}; + } + + if (afterId) { + query._id.$gt = afterId; + } else if (AFTER_UNSUBSCRIPTION_ID) { + query._id.$gt = AFTER_UNSUBSCRIPTION_ID; + } + + var batchInsertUnsubscriptions = newUnsubscriptionCollection.initializeUnorderedBulkOp(); + + console.log(`Executing unsubscriptions query.\nMatching unsubscriptions after ${afterId ? afterId : AFTER_UNSUBSCRIPTION_ID} and before ${BEFORE_UNSUBSCRIPTION_ID} (included).`); + + return oldUnsubscriptionCollection + .find(query) + .sort({_id: 1}) + .limit(BATCH_SIZE) + .toArray() + .then(function (oldUnsubscriptionsR) { + oldUnsubscriptions = oldUnsubscriptionsR; + + console.log(`Processing ${oldUnsubscriptions.length} unsubscriptions. Already processed ${processedUnsubscriptions} unsubscriptions.`); + + if (oldUnsubscriptions.length === BATCH_SIZE) { + lastUnsubscription = oldUnsubscriptions[oldUnsubscriptions.length - 1]._id; + } + + oldUnsubscriptions.forEach(function (oldUnsubscription) { + oldUnsubscription.email = oldUnsubscription.email.toLowerCase(); + var newUnsubscription = new EmailUnsubscription(oldUnsubscription); + + batchInsertUnsubscriptions.insert(newUnsubscription.toObject()); + }); + + console.log(`Saving ${oldUnsubscriptions.length} unsubscriptions.`); + + return batchInsertUnsubscriptions.execute(); + }) + .then(function () { + processedUnsubscriptions += oldUnsubscriptions.length; + + console.log(`Saved ${oldUnsubscriptions.length} unsubscriptions.`); + + if (lastUnsubscription) { + return processUnsubscriptions(lastUnsubscription); + } else { + return console.log('Done!'); + } + }); +} + +// Connect to the databases +Q.all([ + MongoClient.connect(MONGODB_OLD), + MongoClient.connect(MONGODB_NEW), +]) +.then(function (result) { + var oldInstance = result[0]; + var newInstance = result[1]; + + mongoDbOldInstance = oldInstance; + oldUnsubscriptionCollection = mongoDbOldInstance.collection('emailunsubscriptions'); + + mongoDbNewInstance = newInstance; + newUnsubscriptionCollection = mongoDbNewInstance.collection('emailunsubscriptions'); + + console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); + + return processUnsubscriptions(); +}) +.catch(function (err) { + console.error(err.stack || err); +}); diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js index 1b364fba65..0a75261a22 100644 --- a/migrations/api_v3/groups.js +++ b/migrations/api_v3/groups.js @@ -1,17 +1,184 @@ /* - name is required - leader is required - type is required - privacy is required - leaderOnly.challenges is required members are not stored anymore invites are not stored anymore - challenges are not stored anymore - balance > 0 - memberCount must be checked - challengeCount must be checked - quest.leader must be present (default to party leader) - quest.key must be valid (otherwise remove) tavern id and leader must be updated */ + +// Migrate groups collection to new schema +// Run AFTER users migration + +// The console-stamp module must be installed (not included in package.json) + +// It requires two environment variables: MONGODB_OLD and MONGODB_NEW + +// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). +// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM +console.log('Starting migrations/api_v3/groups.js.'); + +require('babel-register'); + +var Q = require('q'); +var MongoDB = require('mongodb'); +var nconf = require('nconf'); +var mongoose = require('mongoose'); +var _ = require('lodash'); +var uuid = require('uuid'); +var consoleStamp = require('console-stamp'); + +// Add timestamps to console messages +consoleStamp(console); + +// Initialize configuration +require('../../website/src/libs/api-v3/setupNconf')(); + +var MONGODB_OLD = nconf.get('MONGODB_OLD'); +var MONGODB_NEW = nconf.get('MONGODB_NEW'); + +var MongoClient = MongoDB.MongoClient; + +mongoose.Promise = Q.Promise; // otherwise mongoose models won't work + +// Load new models +var NewGroup = require('../../website/src/models/group').model; + +// To be defined later when MongoClient connects +var mongoDbOldInstance; +var oldGroupCollection; + +var mongoDbNewInstance; +var newGroupCollection; +var newUserCollection; + +var BATCH_SIZE = 1000; + +var processedGroups = 0; + +// Only process groups that fall in a interval ie -> up to 0000-4000-0000-0000 +var AFTER_GROUP_ID = nconf.get('AFTER_GROUP_ID'); +var BEFORE_GROUP_ID = nconf.get('BEFORE_GROUP_ID'); + +function processGroups (afterId) { + var processedTasks = 0; + var lastGroup = null; + var oldGroups; + + var query = {}; + + if (BEFORE_GROUP_ID) { + query._id = {$lte: BEFORE_GROUP_ID}; + } + + if ((afterId || AFTER_GROUP_ID) && !query._id) { + query._id = {}; + } + + if (afterId) { + query._id.$gt = afterId; + } else if (AFTER_GROUP_ID) { + query._id.$gt = AFTER_GROUP_ID; + } + + var batchInsertGroups = newGroupCollection.initializeUnorderedBulkOp(); + + console.log(`Executing groups query.\nMatching groups after ${afterId ? afterId : AFTER_GROUP_ID} and before ${BEFORE_GROUP_ID} (included).`); + + return oldGroupCollection + .find(query) + .sort({_id: 1}) + .limit(BATCH_SIZE) + .toArray() + .then(function (oldGroupsR) { + oldGroups = oldGroupsR; + + var promises = []; + + console.log(`Processing ${oldGroups.length} groups. Already processed ${processedGroups} groups.`); + + if (oldGroups.length === BATCH_SIZE) { + lastGroup = oldGroups[oldGroups.length - 1]._id; + } + + oldGroups.forEach(function (oldGroup) { + if (!oldGroup.members || oldGroup.members.length === 0) return; // delete empty groups + oldGroup.memberCount = oldGroup.members.length; + if (oldGroup.challenges) oldGroup.challengeCount = oldGroup.challenges.length; + + if (!oldGroup.balance <= 0) oldGroup.balance = 0; + if (!oldGroup.name) oldGroup.name = 'group name'; + if (!oldGroup.leaderOnly) oldGroup.leaderOnly = {}; + if (!oldGroup.leaderOnly.challenges) oldGroup.leaderOnly.challenges = false; + + if (!oldGroup.type) { + //console.log(oldGroup); + console.error('group.type is required'); + } + if (!oldGroup.leader) { + //console.log(oldGroup); + console.error('group.leader is required'); + } + if (!oldGroup.privacy) { + //console.log(oldGroup); + console.error('group.privacy is required'); + } + + var updateMembers = {}; + + if (oldGroup.type === 'guild') { + updateMembers.$push = {guilds: oldGroup._id}; + } else if (oldGroup.type === 'party') { + updateMembers.$set = {'party._id': oldGroup._id}; + } + + if (oldGroup.type) { + promises.push(newUserCollection.updateMany({ + _id: {$in: oldGroup.members}, + }, updateMembers, {multi: true})); + } + + var newGroup = new NewGroup(oldGroup); + + batchInsertGroups.insert(newGroup.toObject()); + }); + + console.log(`Saving ${oldGroups.length} groups and migrating members to users collection.`); + + promises.push(batchInsertGroups.execute()); + return Q.all(promises); + }) + .then(function () { + processedGroups += oldGroups.length; + + console.log(`Saved ${oldGroups.length} groups and migrated their members to the user collection.`); + + if (lastGroup) { + return processGroups(lastGroup); + } else { + return console.log('Done!'); + } + }); +} + +// Connect to the databases +Q.all([ + MongoClient.connect(MONGODB_OLD), + MongoClient.connect(MONGODB_NEW), +]) +.then(function (result) { + var oldInstance = result[0]; + var newInstance = result[1]; + + mongoDbOldInstance = oldInstance; + oldGroupCollection = mongoDbOldInstance.collection('groups'); + + mongoDbNewInstance = newInstance; + newGroupCollection = mongoDbNewInstance.collection('groups'); + newUserCollection = mongoDbNewInstance.collection('users'); + + console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); + + return processGroups(); +}) +.catch(function (err) { + console.error(err.stack || err); +}); diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index 293769a3fc..21a35c70ac 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -59,7 +59,6 @@ var BEFORE_USER_ID = nconf.get('BEFORE_USER_ID'); - groups - invitations - challenges' tasks -- checklists from .id to ._id (reminders too!) */ function processUsers (afterId) { @@ -111,8 +110,8 @@ function processUsers (afterId) { oldUser.tags = oldUser.tags.map(function (tag) { return { - _id: tag.id, - name: tag.name, + id: tag.id, + name: tag.name || 'tag name', challenge: tag.challenge, }; }); @@ -125,7 +124,11 @@ function processUsers (afterId) { oldTask.legacyId = oldTask.id; // store the old task id delete oldTask.id; - oldTask.challenge = {}; + oldTask.challenge = oldTask.challenge || {}; + if (oldTask.challenge.id) { + oldTask.challenge.taskId = oldTask.legacyId; + } + if (!oldTask.text) oldTask.text = 'task text'; // required oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) { return tagPresent && tagId; @@ -165,30 +168,6 @@ function processUsers (afterId) { }); } -/* - -TODO var challengeTasksChangedId = {}; - -tasksArr.forEach(function(task){ - task.challenge = task.challenge || {}; - if(task.challenge.id) { - // If challengeTasksChangedId[task._id] then we got on of the duplicates from the challenges migration - if (challengeTasksChangedId[task.legacyId]) { - var res = _.find(challengeTasksChangedId[task.legacyId], function(arr){ - return arr[1] === task.challenge.id; - }); - - // If res, id changed, otherwise matches the original one - task.challenge.taskId = res ? res[0] : task.legacyId; - } else { - task.challenge.taskId = task.legacyId; - } - } - - if(!task.type) console.log('Task without type ', task._id, ' user ', user._id); -}); -*/ - // Connect to the databases Q.all([ MongoClient.connect(MONGODB_OLD), @@ -210,5 +189,5 @@ Q.all([ return processUsers(); }) .catch(function (err) { - console.error(err); + console.error(err.stack || err); }); diff --git a/website/src/models/emailUnsubscription.js b/website/src/models/emailUnsubscription.js index d2ce4292cc..cff5821c98 100644 --- a/website/src/models/emailUnsubscription.js +++ b/website/src/models/emailUnsubscription.js @@ -1,18 +1,15 @@ import mongoose from 'mongoose'; import common from '../../../common'; import validator from 'validator'; +import baseModel from '../libs/api-v3/baseModel'; // A collection used to store mailing list unsubscription for non registered email addresses export let schema = new mongoose.Schema({ - _id: { - type: String, - default: common.uuid, - }, email: { type: String, required: true, trim: true, - lowercase: true, // TODO migrate existing to lowerCase + lowercase: true, validator: [validator.isEmail, 'Invalid email.'], }, }, { @@ -20,4 +17,8 @@ export let schema = new mongoose.Schema({ minimize: false, // So empty objects are returned }); +schema.plugin(baseModel, { + noSet: ['_id'], +}); + export let model = mongoose.model('EmailUnsubscription', schema); diff --git a/website/src/models/group.js b/website/src/models/group.js index 846a018a16..a36f983b8d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -44,7 +44,7 @@ export let schema = new Schema({ */ leaderOnly: { // restrict group actions to leader (members can't do them) challenges: {type: Boolean, default: false, required: true}, - // invites: {type:Boolean, 'default':false} + // invites: {type: Boolean, default: false, required: true}, }, memberCount: {type: Number, default: 1}, challengeCount: {type: Number, default: 0}, From 60eefde15f84c5cb65e564fd27c58a668dda101b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 1 May 2016 16:36:21 +0200 Subject: [PATCH 733/976] fix linting --- website/src/models/emailUnsubscription.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/models/emailUnsubscription.js b/website/src/models/emailUnsubscription.js index cff5821c98..d26c993bb1 100644 --- a/website/src/models/emailUnsubscription.js +++ b/website/src/models/emailUnsubscription.js @@ -1,5 +1,4 @@ import mongoose from 'mongoose'; -import common from '../../../common'; import validator from 'validator'; import baseModel from '../libs/api-v3/baseModel'; From fbce7e65ab18b60b3eb040e0442924fa3a3eab92 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 1 May 2016 23:54:58 +0200 Subject: [PATCH 734/976] v3 migration: fixes --- migrations/api_v3/challenges.js | 3 +- migrations/api_v3/groups.js | 37 +++++++++++++++++------ migrations/api_v3/users.js | 24 +++++++++++++-- website/src/models/coupon.js | 8 ++--- website/src/models/emailUnsubscription.js | 1 + 5 files changed, 54 insertions(+), 19 deletions(-) diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index e2a88df0c4..a650e26188 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -114,7 +114,8 @@ function processChallenges (afterId) { newChallenge.createdAt = createdAt; oldTasks.forEach(function (oldTask) { - oldTask._id = oldTask.id; // keep the old uuid unless duplicated + oldTask._id = uuid.v4(); // TODO keep the old uuid unless duplicated + oldTask.legacyId = oldTask.id; // store the old task id delete oldTask.id; oldTask.tags = _.map(oldTask.tags || {}, function (tagPresent, tagId) { diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js index 0a75261a22..2fa465c67e 100644 --- a/migrations/api_v3/groups.js +++ b/migrations/api_v3/groups.js @@ -42,6 +42,8 @@ mongoose.Promise = Q.Promise; // otherwise mongoose models won't work // Load new models var NewGroup = require('../../website/src/models/group').model; +var TAVERN_ID = require('../../website/src/models/group').TAVERN_ID; + // To be defined later when MongoClient connects var mongoDbOldInstance; var oldGroupCollection; @@ -100,26 +102,37 @@ function processGroups (afterId) { } oldGroups.forEach(function (oldGroup) { - if (!oldGroup.members || oldGroup.members.length === 0) return; // delete empty groups - oldGroup.memberCount = oldGroup.members.length; - if (oldGroup.challenges) oldGroup.challengeCount = oldGroup.challenges.length; + if ((!oldGroup.privacy || oldGroup.privacy === 'private') && (!oldGroup.members || oldGroup.members.length === 0)) return; // delete empty private groups + oldGroup.memberCount = oldGroup.members ? oldGroup.members.length : 0; + oldGroup.memberCount = oldGroup.challenges ? oldGroup.challenges.length : 0; if (!oldGroup.balance <= 0) oldGroup.balance = 0; if (!oldGroup.name) oldGroup.name = 'group name'; if (!oldGroup.leaderOnly) oldGroup.leaderOnly = {}; if (!oldGroup.leaderOnly.challenges) oldGroup.leaderOnly.challenges = false; + // Tavern + if (oldGroup._id === 'habitrpg') { + oldGroup._id = TAVERN_ID; + oldGroup.leader = '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0'; // Siena Leslie + } + if (!oldGroup.type) { - //console.log(oldGroup); - console.error('group.type is required'); + // throw new Error('group.type is required'); + oldGroup.type = 'guild'; } + if (!oldGroup.leader) { - //console.log(oldGroup); - console.error('group.leader is required'); + if (oldGroup.members && oldGroup.members.length > 0) { + oldGroup.leader = oldGroup.members[0]; + } else { + throw new Error('group.leader is required and no member available!'); + } } + if (!oldGroup.privacy) { - //console.log(oldGroup); - console.error('group.privacy is required'); + // throw new Error('group.privacy is required'); + group.privacy = 'private'; } var updateMembers = {}; @@ -130,7 +143,7 @@ function processGroups (afterId) { updateMembers.$set = {'party._id': oldGroup._id}; } - if (oldGroup.type) { + if (oldGroup.members) { promises.push(newUserCollection.updateMany({ _id: {$in: oldGroup.members}, }, updateMembers, {multi: true})); @@ -177,6 +190,10 @@ Q.all([ console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); + // First delete the tavern group created by having required the group model + return newGroupCollection.deleteOne({_id: TAVERN_ID}); +}) +.then(function () { return processGroups(); }) .catch(function (err) { diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index 21a35c70ac..ecc502025e 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -18,6 +18,7 @@ var mongoose = require('mongoose'); var _ = require('lodash'); var uuid = require('uuid'); var consoleStamp = require('console-stamp'); +var common = require('../../common'); // Add timestamps to console messages consoleStamp(console); @@ -28,6 +29,7 @@ require('../../website/src/libs/api-v3/setupNconf')(); var MONGODB_OLD = nconf.get('MONGODB_OLD'); var MONGODB_NEW = nconf.get('MONGODB_NEW'); +var taskDefaults = common.taskDefaults; var MongoClient = MongoDB.MongoClient; mongoose.Promise = Q.Promise; // otherwise mongoose models won't work @@ -116,6 +118,10 @@ function processUsers (afterId) { }; }); + if (oldUser._id === '9') { // Tyler Renelle + oldUser._id = '00000000-0000-4000-9000-000000000000'; + } + var newUser = new NewUser(oldUser); oldTasks.forEach(function (oldTask) { @@ -129,6 +135,8 @@ function processUsers (afterId) { oldTask.challenge.taskId = oldTask.legacyId; } + oldTask.createdAt = old.dateCreated; + if (!oldTask.text) oldTask.text = 'task text'; // required oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) { return tagPresent && tagId; @@ -138,9 +146,21 @@ function processUsers (afterId) { newUser.tasksOrder[`${oldTask.type}s`].push(oldTask._id); } - var newTask = new NewTasks[oldTask.type](oldTask); + var allTasksFields = ['_id', 'type', 'text', 'notes', 'tags', 'value', 'priority', 'attribute', 'challenge', 'reminders']; + // using mongoose models is too slow + if (oldTask.type === 'habit') { + oldTask = _.pick(oldTask, allTasksFields.concat(['history', 'up', 'down'])); + } else if (oldTask.type === 'daily') { + oldTask = _.pick(oldTask, allTasksFields.concat(['completed', 'collapseChecklist', 'checklist', 'history', 'frequency', 'everyX', 'startDate', 'repeat', 'streak'])); + } else if (oldTask.type === 'todo') { + oldTask = _.pick(oldTask, allTasksFields.concat(['completed', 'collapseChecklist', 'checklist', 'date', 'dateCompleted'])); + } else if (oldTask.type === 'reward') { + oldTask = _.pick(oldTask, allTasksFields); + } else { + throw new Error('Task with no or invalid type!'); + } - batchInsertTasks.insert(newTask.toObject()); + batchInsertTasks.insert(taskDefaults(oldTask)); processedTasks++; }); diff --git a/website/src/models/coupon.js b/website/src/models/coupon.js index 5215f64888..af9953aba0 100644 --- a/website/src/models/coupon.js +++ b/website/src/models/coupon.js @@ -11,6 +11,7 @@ import { } from '../libs/api-v3/errors'; export let schema = new mongoose.Schema({ + _id: {type: String, default: couponCode.generate}, event: {type: String, enum: ['wondercon', 'google_6mo']}, user: {type: String, ref: 'User'}, }, { @@ -20,14 +21,9 @@ export let schema = new mongoose.Schema({ schema.plugin(baseModel, { timestamps: true, + _id: false, }); -// Add _id field after plugin to override default _id format -schema.add({ - _id: {type: String, default: couponCode.generate}, -}); - - schema.statics.generate = async function generateCoupons (event, count = 1) { let coupons = _.times(count, () => { return {event}; diff --git a/website/src/models/emailUnsubscription.js b/website/src/models/emailUnsubscription.js index d26c993bb1..fe30e5d608 100644 --- a/website/src/models/emailUnsubscription.js +++ b/website/src/models/emailUnsubscription.js @@ -18,6 +18,7 @@ export let schema = new mongoose.Schema({ schema.plugin(baseModel, { noSet: ['_id'], + timestamps: true, }); export let model = mongoose.model('EmailUnsubscription', schema); From 21d798bca70b57c455468726e52bd9b1ea1f1d2f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 May 2016 15:49:47 +0200 Subject: [PATCH 735/976] v3: GET /groups accept a guilds type which returns all the guilds the user is a member of --- .../v3/integration/groups/GET-groups.test.js | 8 +++++- website/src/controllers/api-v3/groups.js | 2 +- website/src/models/group.js | 26 +++++++++++++------ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js index 7e2014e87c..d076e52a18 100644 --- a/test/api/v3/integration/groups/GET-groups.test.js +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -9,7 +9,8 @@ import { describe('GET /groups', () => { let user; - const NUMBER_OF_PUBLIC_GUILDS = 3; + const NUMBER_OF_PUBLIC_GUILDS = 3; // 2 + the tavern + const NUMBER_OF_PUBLIC_GUILDS_USER_IS_MEMBER = 1; const NUMBER_OF_USERS_PRIVATE_GUILDS = 1; const NUMBER_OF_GROUPS_USER_CAN_VIEW = 5; @@ -87,6 +88,11 @@ describe('GET /groups', () => { .to.eventually.have.a.lengthOf(NUMBER_OF_PUBLIC_GUILDS); }); + it('returns all the user\'s guilds when guilds passed in as query', async () => { + await expect(user.get('/groups?type=guilds')) + .to.eventually.have.a.lengthOf(NUMBER_OF_PUBLIC_GUILDS_USER_IS_MEMBER + NUMBER_OF_USERS_PRIVATE_GUILDS); + }); + it('returns all private guilds user is a part of when privateGuilds passed in as query', async () => { await expect(user.get('/groups?type=privateGuilds')) .to.eventually.have.a.lengthOf(NUMBER_OF_USERS_PRIVATE_GUILDS); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 3f4a7aabb4..41f47a4769 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -79,7 +79,7 @@ api.createGroup = { * @apiName GetGroups * @apiGroup Group * - * @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, privateGuilds, publicGuilds, tavern + * @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, guilds, privateGuilds, publicGuilds, tavern * * @apiSuccess {Array} data An array of the requested groups */ diff --git a/website/src/models/group.js b/website/src/models/group.js index a36f983b8d..ae1c1318bb 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -149,25 +149,35 @@ schema.statics.getGroups = async function getGroups (options = {}) { queries.push(this.getGroup({user, groupId: 'party', fields: groupFields, populateLeader})); break; } + case 'guilds': { + let userGuildsQuery = this.find({ + type: 'guild', + _id: {$in: user.guilds}, + }).select(groupFields); + if (populateLeader === true) userGuildsQuery.populate('leader', nameFields); + userGuildsQuery.sort(sort).exec(); + queries.push(userGuildsQuery); + break; + } case 'privateGuilds': { - let privateGroupQuery = this.find({ + let privateGuildsQuery = this.find({ type: 'guild', privacy: 'private', _id: {$in: user.guilds}, }).select(groupFields); - if (populateLeader === true) privateGroupQuery.populate('leader', nameFields); - privateGroupQuery.sort(sort).exec(); - queries.push(privateGroupQuery); + if (populateLeader === true) privateGuildsQuery.populate('leader', nameFields); + privateGuildsQuery.sort(sort).exec(); + queries.push(privateGuildsQuery); break; } case 'publicGuilds': { - let publicGroupQuery = this.find({ + let publicGuildsQuery = this.find({ type: 'guild', privacy: 'public', }).select(groupFields); - if (populateLeader === true) publicGroupQuery.populate('leader', nameFields); - publicGroupQuery.sort(sort).exec(); - queries.push(publicGroupQuery); // TODO use lean? + if (populateLeader === true) publicGuildsQuery.populate('leader', nameFields); + publicGuildsQuery.sort(sort).exec(); + queries.push(publicGuildsQuery); // TODO use lean? break; } case 'tavern': { From c218a2cbdf5a3e78e9e6a51502b274c4abca55ed Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 May 2016 15:54:02 +0200 Subject: [PATCH 736/976] v3 fix apidoc broken layout --- website/src/controllers/api-v3/challenges.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index dcd0af5c66..4f738afc80 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -510,7 +510,7 @@ export async function _closeChal (challenge, broken = {}) { * @apiName DeleteChallenge * @apiGroup Challenge * - * challengeId {UUID} The _id for the challenge to delete + * @apiParam {UUID} challengeId The _id for the challenge to delete * * @apiSuccess {object} data An empty object */ @@ -542,8 +542,8 @@ api.deleteChallenge = { * @apiName SelectChallengeWinner * @apiGroup Challenge * - * challengeId {UUID} The _id for the challenge to close with a winner - * winnerId {UUID} The _id of the winning user + * @apiParam {UUID} challengeId The _id for the challenge to close with a winner + * @apiParam {UUID} winnerId The _id of the winning user * * @apiSuccess {object} data An empty object */ From 78a8eea79ae4cd2750b4380e9d8206df4f298c29 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 3 May 2016 10:16:30 -0500 Subject: [PATCH 737/976] Updated quest service to use new api-v3 (#7126) * Updated quest service to use new api-v3 * Updated inviteToQuest function name. Used quest return rather than syncing party --- test/spec/services/questServicesSpec.js | 45 ++++++++++++++---- website/public/js/controllers/partyCtrl.js | 21 ++++++--- website/public/js/services/groupServices.js | 4 +- website/public/js/services/questServices.js | 47 ++++++++----------- .../options/social/quests/questActive.jade | 2 +- .../options/social/quests/questNotActive.jade | 4 +- 6 files changed, 73 insertions(+), 50 deletions(-) diff --git a/test/spec/services/questServicesSpec.js b/test/spec/services/questServicesSpec.js index e7f8c638b2..6a16df2d5a 100644 --- a/test/spec/services/questServicesSpec.js +++ b/test/spec/services/questServicesSpec.js @@ -1,7 +1,7 @@ 'use strict'; describe('Quests Service', function() { - var groupsService, quest, questsService, user, content, resolveSpy, rejectSpy; + var groupsService, quest, questsService, user, content, resolveSpy, rejectSpy, state; beforeEach(function() { user = specHelper.newUser(); @@ -16,10 +16,11 @@ describe('Quests Service', function() { $provide.value('User', {sync: sinon.stub(), user: user}); }); - inject(function(Quests, Groups, Content) { + inject(function(Quests, Groups, Content, _$state_) { questsService = Quests; groupsService = Groups; content = Content; + state = _$state_; }); sandbox.stub(groupsService, 'inviteOrStartParty'); @@ -335,13 +336,36 @@ describe('Quests Service', function() { }); describe('#initQuest', function() { + var fakeBackend, scope, key = 'whale'; + + beforeEach(inject(function($httpBackend, $rootScope) { + scope = $rootScope.$new(); + fakeBackend = $httpBackend; + var partyResponse = {data:{_id: 'party-id'}}; + + fakeBackend.when('GET', 'partials/main.html').respond({}); + fakeBackend.when('GET', 'partials/main.html').respond({}); + fakeBackend.when('GET', '/api/v3/groups/party').respond(partyResponse); + fakeBackend.when('POST', '/api/v3/groups/party-id/quests/invite/' + key).respond({quest: { key: 'whale' } }); + fakeBackend.flush(); + })); it('returns a promise', function() { - var promise = questsService.initQuest('whale'); + var promise = questsService.initQuest(key); expect(promise).to.respondTo('then'); }); - it('accepts quest'); + it('starts a quest', function(done) { + fakeBackend.expectPOST( '/api/v3/groups/party-id/quests/invite/' + key); + + questsService.initQuest(key) + .then(function(res) { + done(); + }); + + fakeBackend.flush(); + scope.$apply(); + }); it('brings user to party page'); }); @@ -352,22 +376,23 @@ describe('Quests Service', function() { beforeEach(inject(function($httpBackend, $rootScope) { scope = $rootScope.$new(); fakeBackend = $httpBackend; + var partyResponse = {data:{_id: 'party-id'}}; fakeBackend.when('GET', 'partials/main.html').respond({}); - fakeBackend.when('GET', '/api/v2/groups/party').respond({_id: 'party-id'}); - fakeBackend.when('POST', '/api/v2/groups/party-id/questReject').respond({quest: { key: 'whale' } }); + fakeBackend.when('GET', '/api/v3/groups/party').respond(partyResponse); + fakeBackend.when('POST', '/api/v3/groups/party-id/quests/reject').respond({quest: { key: 'whale' } }); fakeBackend.flush(); })); it('returns a promise', function() { - var promise = questsService.sendAction('questReject'); + var promise = questsService.sendAction('quests/reject'); expect(promise).to.respondTo('then'); }); it('calls specified quest endpoint', function(done) { - fakeBackend.expectPOST('/api/v2/groups/party-id/questReject'); + fakeBackend.expectPOST('/api/v3/groups/party-id/quests/reject'); - questsService.sendAction('questReject') + questsService.sendAction('quests/reject') .then(function(res) { expect(res.key).to.eql('whale'); done(); @@ -378,7 +403,7 @@ describe('Quests Service', function() { }); it('syncs User', function() { - questsService.sendAction('questReject') + questsService.sendAction('quests/reject') .then(function(res) { expect(User.sync).to.be.calledOnce; done(); diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js index d80414c37a..39cdb4dfce 100644 --- a/website/public/js/controllers/partyCtrl.js +++ b/website/public/js/controllers/partyCtrl.js @@ -149,7 +149,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.questCancel = function(){ if (!confirm(window.env.t('sureCancel'))) return; - Quests.sendAction('questCancel') + Quests.sendAction('quests/cancel') .then(function(quest) { $scope.group.quest = quest; }); @@ -159,7 +159,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' if (!confirm(window.env.t('sureAbort'))) return; if (!confirm(window.env.t('doubleSureAbort'))) return; - Quests.sendAction('questAbort') + Quests.sendAction('quests/abort') .then(function(quest) { $scope.group.quest = quest; }); @@ -168,28 +168,35 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.questLeave = function(){ if (!confirm(window.env.t('sureLeave'))) return; - Quests.sendAction('questLeave') + Quests.sendAction('quests/leave') .then(function(quest) { $scope.group.quest = quest; }); } $scope.questAccept = function(){ - Quests.sendAction('questAccept') + Quests.sendAction('quests/accept') + .then(function(quest) { + $scope.group.quest = quest; + }); + }; + + $scope.questForceStart = function(){ + Quests.sendAction('quests/force-start') .then(function(quest) { $scope.group.quest = quest; }); }; $scope.questReject = function(){ - Quests.sendAction('questReject') + Quests.sendAction('quests/reject') .then(function(quest) { $scope.group.quest = quest; }); }; - $scope.canEditQuest = function(party) { - var isQuestLeader = party.quest && party.quest.leader === User.user._id; + $scope.canEditQuest = function() { + var isQuestLeader = $scope.group.quest && $scope.group.quest.leader === User.user._id; return isQuestLeader; }; diff --git a/website/public/js/services/groupServices.js b/website/public/js/services/groupServices.js index bff834582a..e7ea8e71e9 100644 --- a/website/public/js/services/groupServices.js +++ b/website/public/js/services/groupServices.js @@ -94,10 +94,10 @@ angular.module('habitrpg') }); }; - Group.startQuest = function(gid) { + Group.inviteToQuest = function(gid, key) { return $http({ method: "POST", - url: groupApiURLPrefix + '/' + gid + '/questAccept', + url: groupApiURLPrefix + '/' + gid + '/quests/invite/' + key, }); }; diff --git a/website/public/js/services/questServices.js b/website/public/js/services/questServices.js index a69ae800d8..73fa78e42c 100644 --- a/website/public/js/services/questServices.js +++ b/website/public/js/services/questServices.js @@ -1,25 +1,16 @@ 'use strict'; -(function(){ - angular - .module('habitrpg') - .factory('Quests', questsFactory); - - questsFactory.$inject = [ - '$http', - '$state', - '$q', - 'ApiUrl', - 'Content', - 'Groups', - 'User', - 'Analytics' - ]; - +angular.module('habitrpg') +.factory('Quests', ['$http', '$state','$q', 'ApiUrl', 'Content', 'Groups', 'User', 'Analytics', function questsFactory($http, $state, $q, ApiUrl, Content, Groups, User, Analytics) { var user = User.user; - var party = Groups.party(); + var party; + + Groups.party() + .then(function (partyFound) { + party = partyFound; + }); function lockQuest(quest,ignoreLevel) { if (!ignoreLevel){ @@ -106,20 +97,21 @@ function initQuest(key) { return $q(function(resolve, reject) { - Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'quest','owner':true,'response':'accept','questName': key}); - Analytics.updateUser({'partyID':party._id,'partySize':party.memberCount}); - party.$startQuest({key:key}, function(){ - party.$syncParty(); - $state.go('options.social.party'); - resolve(); - }); + Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'quest', 'owner':true, 'response':'accept', 'questName': key}); + Analytics.updateUser({'partyID': party._id, 'partySize': party.memberCount}); + Groups.Group.inviteToQuest(party._id, key) + .then(function(response) { + party.quest = response.data.data; + Groups.data.party = party; + $state.go('options.social.party'); + resolve(); + }); }); } function sendAction(action) { return $q(function(resolve, reject) { - - $http.post(ApiUrl.get() + '/api/v2/groups/' + party._id + '/' + action) + $http.post(ApiUrl.get() + '/api/v3/groups/' + party._id + '/' + action) .then(function(response) { User.sync(); @@ -142,5 +134,4 @@ showQuest: showQuest, initQuest: initQuest } - } -}()); + }]); diff --git a/website/views/options/social/quests/questActive.jade b/website/views/options/social/quests/questActive.jade index 969861e8c1..b457f8d0e3 100644 --- a/website/views/options/social/quests/questActive.jade +++ b/website/views/options/social/quests/questActive.jade @@ -23,7 +23,7 @@ div(ng-if='group.quest.active===true') include ./ianQuestInfo unless tavern - button.btn.btn-sm.btn-warning(ng-if='::canEditQuest(party)', + button.btn.btn-sm.btn-warning(ng-if='::canEditQuest()', ng-click='questAbort()')=env.t('abort') button.btn.btn-sm.btn-warning(ng-if='!(group.quest.leader && group.quest.leader === user._id) && isMemberOfRunningQuest(user._id,group)', ng-click='questLeave()')=env.t('leaveQuest') diff --git a/website/views/options/social/quests/questNotActive.jade b/website/views/options/social/quests/questNotActive.jade index 3472011e29..c20ae5a226 100644 --- a/website/views/options/social/quests/questNotActive.jade +++ b/website/views/options/social/quests/questNotActive.jade @@ -26,6 +26,6 @@ div(ng-if='group.quest.active===false') button.btn.btn-sm.btn-success(ng-click='questAccept()')=env.t('accept') button.btn.btn-sm.btn-danger(ng-click='questReject()')=env.t('reject') - span(ng-if='::canEditQuest(party)') - button.btn.btn-sm.btn-warning(ng-click='party.$startQuest({"force":true})')=env.t('begin') + span(ng-if='::canEditQuest()') + button.btn.btn-sm.btn-warning(ng-click='questForceStart()')=env.t('begin') button.btn.btn-sm.btn-danger(ng-click='questCancel()')=env.t('cancel') From e5e4bb5823bda38c8c31e0e23f3443202cb0c972 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 May 2016 23:27:24 +0200 Subject: [PATCH 738/976] v3 migration: correctly migrate challenges tasks --- migrations/api_v3/challenges.js | 21 ++++++++++++++++--- migrations/api_v3/challengesMembers.js | 7 +++++++ migrations/api_v3/groups.js | 7 +++++++ migrations/api_v3/users.js | 28 ++++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index a650e26188..85a019cc7d 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -17,6 +17,7 @@ var mongoose = require('mongoose'); var _ = require('lodash'); var uuid = require('uuid'); var consoleStamp = require('console-stamp'); +var fs = require('fs'); // Add timestamps to console messages consoleStamp(console); @@ -48,6 +49,8 @@ var BATCH_SIZE = 1000; var processedChallenges = 0; var totoalProcessedTasks = 0; +var newTasksIds = {}; // a map of old id -> [new id, challengeId] + // Only process challenges that fall in a interval ie -> up to 0000-4000-0000-0000 var AFTER_CHALLENGE_ID = nconf.get('AFTER_CHALLENGE_ID'); var BEFORE_CHALLENGE_ID = nconf.get('BEFORE_CHALLENGE_ID'); @@ -109,23 +112,33 @@ function processChallenges (afterId) { if (!oldChallenge.group) throw new Error('challenge.group is required'); if (!oldChallenge.leader) throw new Error('challenge.leader is required'); + delete oldChallenge.id; + var newChallenge = new NewChallenge(oldChallenge); newChallenge.createdAt = createdAt; oldTasks.forEach(function (oldTask) { - oldTask._id = uuid.v4(); // TODO keep the old uuid unless duplicated + oldTask._id = uuid.v4(); oldTask.legacyId = oldTask.id; // store the old task id delete oldTask.id; + oldTask.challenge = oldTask.challenge || {}; + oldTask.challenge.id = newChallenge._id; + + if (newTasksIds[oldTask.legacyId + '-' + newChallenge._id]) { + throw new Error('duplicate :('); + } else { + newTasksIds[oldTask.legacyId + '-' + newChallenge._id] = oldTask._id; + } + oldTask.tags = _.map(oldTask.tags || {}, function (tagPresent, tagId) { return tagPresent && tagId; }); if (!oldTask.text) oldTask.text = 'task text'; // required - oldTask.challenge = oldTask.challenge || {}; - oldTask.challenge.id = oldChallenge._id; + oldTask.createdAt = oldTask.dateCreated; newChallenge.tasksOrder[`${oldTask.type}s`].push(oldTask._id); if (oldTask.completed) oldTask.completed = false; @@ -155,6 +168,8 @@ function processChallenges (afterId) { if (lastChallenge) { return processChallenges(lastChallenge); } else { + console.log('Writing newTasksIds.json...') + fs.writeFileSync('newTasksIds.json', JSON.stringify(newTasksIds, null, 4), 'utf8'); return console.log('Done!'); } }); diff --git a/migrations/api_v3/challengesMembers.js b/migrations/api_v3/challengesMembers.js index f5c1532660..98a42964fa 100644 --- a/migrations/api_v3/challengesMembers.js +++ b/migrations/api_v3/challengesMembers.js @@ -86,6 +86,13 @@ function processChallenges (afterId) { lastChallenge = oldChallenges[oldChallenges.length - 1]._id; } + // Tyler Renelle + oldChallenge.members.forEach(function (id, index) { + if (id === '9') { + oldChallenge.members[index] = '00000000-0000-4000-9000-000000000000'; + } + }); + oldChallenges.forEach(function (oldChallenge) { promises.push(newUserCollection.updateMany({ _id: {$in: oldChallenge.members}, diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js index 2fa465c67e..d013924cea 100644 --- a/migrations/api_v3/groups.js +++ b/migrations/api_v3/groups.js @@ -144,6 +144,13 @@ function processGroups (afterId) { } if (oldGroup.members) { + // Tyler Renelle + oldGroup.members.forEach(function (id, index) { + if (id === '9') { + oldGroup.members[index] = '00000000-0000-4000-9000-000000000000'; + } + }); + promises.push(newUserCollection.updateMany({ _id: {$in: oldGroup.members}, }, updateMembers, {multi: true})); diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index ecc502025e..abf3e12e26 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -51,6 +51,12 @@ var BATCH_SIZE = 1000; var processedUsers = 0; var totoalProcessedTasks = 0; +var challengeTaskWithMatchingId = 0; +var challengeTaskNoMatchingId = 0; + +// Load the new tasks ids for challenges tasks +var newTasksIds = require('./newTasksIds.json'); + // Only process users that fall in a interval ie up to -> 0000-4000-0000-0000 var AFTER_USER_ID = nconf.get('AFTER_USER_ID'); var BEFORE_USER_ID = nconf.get('BEFORE_USER_ID'); @@ -110,6 +116,8 @@ function processUsers (afterId) { delete oldUser.rewards; delete oldUser.todos; + delete oldUser.id; + oldUser.tags = oldUser.tags.map(function (tag) { return { id: tag.id, @@ -132,10 +140,24 @@ function processUsers (afterId) { oldTask.challenge = oldTask.challenge || {}; if (oldTask.challenge.id) { - oldTask.challenge.taskId = oldTask.legacyId; + if (oldTask.challenge.broken) { + oldTask.challenge.taskId = oldTask.legacyId; + } else { + var newId = newTasksIds[oldTask.legacyId + '-' + oldTask.challenge.id]; + + // Challenges' tasks ids changed + if (!newId && !oldTask.challenge.broken) { + challengeTaskNoMatchingId++; + oldTask.challenge.taskId = oldTask.legacyId; + oldTask.challenge.broken = 'CHALLENGE_TASK_NOT_FOUND'; + } else { + challengeTaskWithMatchingId++; + oldTask.challenge.taskId = newId; + } + } } - oldTask.createdAt = old.dateCreated; + oldTask.createdAt = oldTask.dateCreated; if (!oldTask.text) oldTask.text = 'task text'; // required oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) { @@ -179,6 +201,8 @@ function processUsers (afterId) { processedUsers += oldUsers.length; console.log(`Saved ${oldUsers.length} users and their tasks.`); + console.log('Challenges\' tasks no matching id: ', challengeTaskNoMatchingId); + console.log('Challenges\' tasks with matching id: ', challengeTaskWithMatchingId); if (lastUser) { return processUsers(lastUser); From bb2dd8ca0800c4190b6ee27cc5f9d7877f39d48d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 4 May 2016 23:43:04 +0200 Subject: [PATCH 739/976] v3: remove trimming and lowercase from fields that must be unique --- website/src/models/user.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index 68bbcbf39e..4343c43d48 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -30,13 +30,10 @@ export let schema = new Schema({ local: { email: { type: String, - trim: true, - lowercase: true, validate: [validator.isEmail, shared.i18n.t('invalidEmail')], // TODO translate error messages here, use preferences.language? }, username: { type: String, - trim: true, }, // Store a lowercase version of username to check for duplicates lowerCaseUsername: String, From ebf3a0979fbfe29b20b196f576937b3e90f76761 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 5 May 2016 09:28:07 +0200 Subject: [PATCH 740/976] fix challenges members migration --- migrations/api_v3/challengesMembers.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/migrations/api_v3/challengesMembers.js b/migrations/api_v3/challengesMembers.js index 98a42964fa..6281c129dc 100644 --- a/migrations/api_v3/challengesMembers.js +++ b/migrations/api_v3/challengesMembers.js @@ -86,14 +86,14 @@ function processChallenges (afterId) { lastChallenge = oldChallenges[oldChallenges.length - 1]._id; } - // Tyler Renelle - oldChallenge.members.forEach(function (id, index) { - if (id === '9') { - oldChallenge.members[index] = '00000000-0000-4000-9000-000000000000'; - } - }); - oldChallenges.forEach(function (oldChallenge) { + // Tyler Renelle + oldChallenge.members.forEach(function (id, index) { + if (id === '9') { + oldChallenge.members[index] = '00000000-0000-4000-9000-000000000000'; + } + }); + promises.push(newUserCollection.updateMany({ _id: {$in: oldChallenge.members}, }, { From c94f4ef0e923e1ea582e3abc3846757cf21f6e16 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 5 May 2016 12:15:28 +0200 Subject: [PATCH 741/976] v3 migration: delete old completed todos --- migrations/api_v3/users.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index abf3e12e26..5962a4a494 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -19,6 +19,7 @@ var _ = require('lodash'); var uuid = require('uuid'); var consoleStamp = require('console-stamp'); var common = require('../../common'); +var moment = require('moment'); // Add timestamps to console messages consoleStamp(console); @@ -61,19 +62,13 @@ var newTasksIds = require('./newTasksIds.json'); var AFTER_USER_ID = nconf.get('AFTER_USER_ID'); var BEFORE_USER_ID = nconf.get('BEFORE_USER_ID'); -/* TODO compare old and new model -- _id 9 -- challenges -- groups -- invitations -- challenges' tasks -*/ - function processUsers (afterId) { var processedTasks = 0; var lastUser = null; var oldUsers; + var now = new Date(); + var query = {}; if (BEFORE_USER_ID) { @@ -131,6 +126,7 @@ function processUsers (afterId) { } var newUser = new NewUser(oldUser); + var isSubscribed = newUser.isSubscribed(); oldTasks.forEach(function (oldTask) { oldTask._id = uuid.v4(); // create a new unique uuid @@ -157,6 +153,13 @@ function processUsers (afterId) { } } + // Delete old completed todos + if (oldTask.type === 'todo' && oldTask.completed && (!oldTask.challenge.id || oldTask.challenge.broken)) { + if (moment(now).subtract(isSubscribed ? 90 : 30, 'days').toDate() > moment(oldTask.dateCompleted).toDate()) { + return; + } + } + oldTask.createdAt = oldTask.dateCreated; if (!oldTask.text) oldTask.text = 'task text'; // required From fd244ac0218b44dfc76df76bf42ac532684521b4 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 3 May 2016 10:52:21 -0500 Subject: [PATCH 742/976] Turned on client side tests. Fixed broken tests --- tasks/gulp-tests.js | 1 + test/spec/chatServicesSpec.js | 90 ----------- test/spec/controllers/challengesCtrlSpec.js | 133 ++++++++-------- test/spec/controllers/footerCtrlSpec.js | 1 - test/spec/controllers/groupCtrlSpec.js | 6 +- test/spec/controllers/inventoryCtrlSpec.js | 6 +- .../spec/controllers/inviteToGroupCtrlSpec.js | 147 ++++++++++++------ test/spec/controllers/menuCtrlSpec.js | 2 +- test/spec/controllers/partyCtrlSpec.js | 87 ++++++----- test/spec/services/questServicesSpec.js | 8 +- test/spec/services/taskServicesSpec.js | 8 +- .../public/js/controllers/challengesCtrl.js | 2 +- .../public/js/controllers/inventoryCtrl.js | 2 + .../js/controllers/inviteToGroupCtrl.js | 2 +- website/public/js/controllers/partyCtrl.js | 4 +- website/public/js/services/groupServices.js | 4 +- website/public/js/services/taskServices.js | 2 +- 17 files changed, 245 insertions(+), 260 deletions(-) delete mode 100644 test/spec/chatServicesSpec.js diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index d8a9917eef..c3d081e57d 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -385,6 +385,7 @@ gulp.task('test:api-v3:integration:separate-server', (done) => { gulp.task('test', (done) => { runSequence( 'test:common', + 'test:karma', 'test:api-v3:unit', 'test:api-v3:integration', 'test:api-v2:integration', diff --git a/test/spec/chatServicesSpec.js b/test/spec/chatServicesSpec.js deleted file mode 100644 index e65c36be68..0000000000 --- a/test/spec/chatServicesSpec.js +++ /dev/null @@ -1,90 +0,0 @@ -'use strict'; - -describe('Chat Service', function() { - var $httpBackend, $http, chat, user; - - beforeEach(function() { - module(function($provide) { - var usr = specHelper.newUser(); - $provide.value('User', {user:usr}); - }); - - inject(function(_$httpBackend_, Chat, User) { - $httpBackend = _$httpBackend_; - chat = Chat; - user = User; - }); - }); - - describe('utils', function() { - it('calls post chat endpoint', function() { - var payload = { - gid: 'habitrpg', - message: 'Chat', - previousMsg: 'previous-msg-id' - } - - $httpBackend.expectPOST('/api/v2/groups/habitrpg/chat?message=Chat&previousMsg=previous-msg-id').respond(); - chat.utils.postChat(payload, undefined); - $httpBackend.flush(); - }); - - it('calls like chat endpoint', function() { - var payload = { - gid: 'habitrpg', - messageId: 'msg-id' - } - - $httpBackend.expectPOST('/api/v2/groups/habitrpg/chat/msg-id/like').respond(); - chat.utils.like(payload, undefined); - $httpBackend.flush(); - }); - - it('calls delete chat endpoint', function() { - var payload = { - gid: 'habitrpg', - messageId: 'msg-id' - } - - $httpBackend.expectDELETE('/api/v2/groups/habitrpg/chat/msg-id').respond(); - chat.utils.deleteChatMessage(payload, undefined); - $httpBackend.flush(); - }); - - it('calls flag chat endpoint', function() { - var payload = { - gid: 'habitrpg', - messageId: 'msg-id' - } - - $httpBackend.expectPOST('/api/v2/groups/habitrpg/chat/msg-id/flag').respond(); - chat.utils.flagChatMessage(payload, undefined); - $httpBackend.flush(); - }); - - it('calls clear flags endpoint', function() { - var payload = { - gid: 'habitrpg', - messageId: 'msg-id' - } - - $httpBackend.expectPOST('/api/v2/groups/habitrpg/chat/msg-id/clearflags').respond(); - chat.utils.clearFlagCount(payload, undefined); - $httpBackend.flush(); - }); - }); - - describe('seenMessage(gid)', function() { - it('calls chat seen endpoint', function() { - $httpBackend.expectPOST('/api/v2/groups/habitrpg/chat/seen').respond(); - chat.seenMessage('habitrpg'); - $httpBackend.flush(); - }); - - it('removes newMessages for a specific guild from user object', function() { - user.user.newMessages = {habitrpg: "foo"}; - chat.seenMessage('habitrpg'); - expect(user.user.newMessages.habitrpg).to.not.exist; - }); - }); -}); diff --git a/test/spec/controllers/challengesCtrlSpec.js b/test/spec/controllers/challengesCtrlSpec.js index 0ebebeba44..3b2f60e7c6 100644 --- a/test/spec/controllers/challengesCtrlSpec.js +++ b/test/spec/controllers/challengesCtrlSpec.js @@ -1,7 +1,7 @@ 'use strict'; describe('Challenges Controller', function() { - var rootScope, scope, user, User, ctrl, groups, members, notification, state; + var rootScope, scope, user, User, ctrl, groups, members, notification, state, challenges; beforeEach(function() { module(function($provide) { @@ -14,7 +14,7 @@ describe('Challenges Controller', function() { $provide.value('User', User); }); - inject(function($rootScope, $controller, _$state_, _Groups_, _Members_, _Notification_){ + inject(function($rootScope, $controller, _$state_, _Groups_, _Members_, _Notification_, _Challenges_){ scope = $rootScope.$new(); rootScope = $rootScope; @@ -23,6 +23,7 @@ describe('Challenges Controller', function() { ctrl = $controller('ChallengesCtrl', {$scope: scope, User: User}); + challenges = _Challenges_; groups = _Groups_; members = _Members_; notification = _Notification_; @@ -301,14 +302,16 @@ describe('Challenges Controller', function() { context('challenge owner interactions', function() { describe("save challenge", function() { - var alert; + var alert, createChallengeSpy, challengeResponse; beforeEach(function(){ alert = sandbox.stub(window, "alert"); + createChallengeSpy = sinon.stub(challenges, 'createChallenge'); + challengeResponse = {data: {data: {_id: 'new-challenge'}}}; + createChallengeSpy.returns(Promise.resolve(challengeResponse)); }); - it("opens an alert box if challenge.group is not specified", function() - { + it("opens an alert box if challenge.group is not specified", function() { var challenge = specHelper.newChallenge({ name: 'Challenge without a group', group: null @@ -334,17 +337,18 @@ describe('Challenges Controller', function() { }); it("saves the challenge if user does not have enough gems, but the challenge is not new", function() { + var updateChallengeSpy = sinon.spy(challenges, 'updateChallenge'); + var challenge = specHelper.newChallenge({ _id: 'challenge-has-id-so-its-not-new', name: 'Challenge without enough gems', prize: 5, - $save: sandbox.spy() // stub $save }); scope.maxPrize = 0; scope.save(challenge); - expect(challenge.$save).to.be.calledOnce; + expect(updateChallengeSpy).to.be.calledOnce; expect(alert).to.not.be.called; }); @@ -352,63 +356,60 @@ describe('Challenges Controller', function() { var challenge = specHelper.newChallenge({ name: 'Challenge without enough gems', prize: 5, - $save: sandbox.spy() // stub $save }); scope.maxPrize = 5; scope.save(challenge); - expect(challenge.$save).to.be.calledOnce; + expect(createChallengeSpy).to.be.calledOnce; expect(alert).to.not.be.called; }); - it('saves challenge and then proceeds to detail page', function() { - var saveSpy = sandbox.stub(); - saveSpy.yields({_id: 'challenge-id'}); + it('saves challenge and then proceeds to detail page', function(done) { sandbox.stub(state, 'transitionTo'); var challenge = specHelper.newChallenge({ - $save: saveSpy // stub $save + name: 'Challenge', }); - scope.save(challenge); + setTimeout(function() { + expect(createChallengeSpy).to.be.calledOnce; + expect(state.transitionTo).to.be.calledWith( + 'options.social.challenges.detail', + { cid: 'new-challenge' }, + { + reload: true, inherit: false, notify: true + } + ); + done(); + }, 1000); - expect(state.transitionTo).to.be.calledOnce; - expect(state.transitionTo).to.be.calledWith( - 'options.social.challenges.detail', - { cid: 'challenge-id' }, - { - reload: true, inherit: false, notify: true - } - ); + scope.save(challenge); + }); + + it('saves new challenge and syncs User', function(done) { + var challenge = specHelper.newChallenge(); + + setTimeout(function() { + expect(User.sync).to.be.calledOnce; + done(); + }, 1000); + + scope.save(challenge); }); it('saves new challenge and syncs User', function() { - var saveSpy = sandbox.stub(); - saveSpy.yields({_id: 'new-challenge'}); - - var challenge = specHelper.newChallenge({ - $save: saveSpy // stub $save - }); - - scope.save(challenge); - - expect(User.sync).to.be.calledOnce; - }); - - it('saves new challenge and syncs User', function() { - var saveSpy = sandbox.stub(); - saveSpy.yields({_id: 'new-challenge'}); sinon.stub(notification, 'text'); - var challenge = specHelper.newChallenge({ - $save: saveSpy // stub $save - }); + var challenge = specHelper.newChallenge(); + + setTimeout(function() { + expect(notification.text).to.be.calledOnce; + expect(notification.text).to.be.calledWith(window.env.t('challengeCreated')); + done(); + }, 1000); scope.save(challenge); - - expect(notification.text).to.be.calledOnce; - expect(notification.text).to.be.calledWith(window.env.t('challengeCreated')); }); }); @@ -627,15 +628,16 @@ describe('Challenges Controller', function() { context('User interactions', function() { describe('join', function() { - it('calls challenge.$join', function(){ + it('calls challenge join', function(){ + var joinChallengeSpy = sinon.spy(challenges, 'joinChallenge'); + var challenge = specHelper.newChallenge({ _id: 'challenge-to-join', - $join: sandbox.spy() }); scope.join(challenge); - expect(challenge.$join).to.be.calledOnce; + expect(joinChallengeSpy).to.be.calledOnce; }); }); @@ -669,7 +671,6 @@ describe('Challenges Controller', function() { describe('leave', function() { var challenge = specHelper.newChallenge({ _id: 'challenge-to-leave', - $leave: sandbox.spy() }); var clickEvent = { @@ -685,11 +686,12 @@ describe('Challenges Controller', function() { expect(scope.selectedChal).to.not.exist; }); - it('calls challenge.$leave when anything but cancel is chosen', function() { + it('calls challenge leave when anything but cancel is chosen', function() { + var leaveChallengeSpy = sinon.spy(challenges, 'leaveChallenge'); scope.clickLeave(challenge, clickEvent); - scope.leave('not-cancel'); - expect(challenge.$leave).to.be.calledOnce; + scope.leave('not-cancel', challenge); + expect(leaveChallengeSpy).to.be.calledOnce; }); }); }); @@ -698,31 +700,36 @@ describe('Challenges Controller', function() { beforeEach(function() { sandbox.stub(members, 'selectMember'); sandbox.stub(rootScope, 'openModal'); + members.selectMember.returns(Promise.resolve()); }); - describe('sendMessageToChallengeParticipant', function() { + describe('sendMessageToChallengeParticipant', function(done) { it('opens private-message modal', function() { - members.selectMember.yields(); scope.sendMessageToChallengeParticipant(user._id); - expect(rootScope.openModal).to.be.calledOnce; - expect(rootScope.openModal).to.be.calledWith( - 'private-message', - { controller: 'MemberModalCtrl' } - ); + setTimeout(function() { + expect(rootScope.openModal).to.be.calledOnce; + expect(rootScope.openModal).to.be.calledWith( + 'private-message', + { controller: 'MemberModalCtrl' } + ); + done(); + }, 1000); }); }); describe('sendGiftToChallengeParticipant', function() { it('opens send-gift modal', function() { - members.selectMember.yields(); scope.sendGiftToChallengeParticipant(user._id); - expect(rootScope.openModal).to.be.calledOnce; - expect(rootScope.openModal).to.be.calledWith( - 'send-gift', - { controller: 'MemberModalCtrl' } - ); + setTimeout(function() { + expect(rootScope.openModal).to.be.calledOnce; + expect(rootScope.openModal).to.be.calledWith( + 'send-gift', + { controller: 'MemberModalCtrl' } + ); + done(); + }, 1000); }); }); }); diff --git a/test/spec/controllers/footerCtrlSpec.js b/test/spec/controllers/footerCtrlSpec.js index 7b12bc2875..dd869591d2 100644 --- a/test/spec/controllers/footerCtrlSpec.js +++ b/test/spec/controllers/footerCtrlSpec.js @@ -4,7 +4,6 @@ describe('Footer Controller', function() { var scope, user; beforeEach(inject(function($rootScope, $controller) { - console.log(window.env.NODE_ENV); user = specHelper.newUser(); var User = {log: sandbox.stub(), set: sandbox.stub(), user: user}; scope = $rootScope.$new(); diff --git a/test/spec/controllers/groupCtrlSpec.js b/test/spec/controllers/groupCtrlSpec.js index da00e2ba73..cade1bb658 100644 --- a/test/spec/controllers/groupCtrlSpec.js +++ b/test/spec/controllers/groupCtrlSpec.js @@ -169,12 +169,12 @@ describe('Groups Controller', function() { scope.editGroup(guild); }); - it('calls group.save', () => { - let guildSave = sandbox.spy(scope.groupCopy, '$save'); + it('calls group update', () => { + let guildUpdate = sandbox.spy(groups.Group, 'update'); scope.saveEdit(guild); - expect(guildSave).to.be.calledOnce; + expect(guildUpdate).to.be.calledOnce; }); it('calls cancelEdit', () => { diff --git a/test/spec/controllers/inventoryCtrlSpec.js b/test/spec/controllers/inventoryCtrlSpec.js index 2553acbcf3..4b7dc20b0a 100644 --- a/test/spec/controllers/inventoryCtrlSpec.js +++ b/test/spec/controllers/inventoryCtrlSpec.js @@ -88,14 +88,16 @@ describe('Inventory Controller', function() { expect(rootScope.openModal).to.have.been.calledWith('hatchPet'); }); - it('does not show modal if user tries to hatch a pet they own', function(){ + //@TODO: Fix Common hatch + xit('does not show modal if user tries to hatch a pet they own', function(){ user.items.pets['Cactus-Base'] = 5; scope.chooseEgg('Cactus'); scope.choosePotion('Base'); expect(rootScope.openModal).to.not.have.been.called; }); - it('does not show modal if user tries to hatch a premium quest pet', function(){ + //@TODO: Fix Common hatch + xit('does not show modal if user tries to hatch a premium quest pet', function(){ user.items.eggs = {Snake: 1}; user.items.hatchingPotions = {Peppermint: 1}; scope.chooseEgg('Snake'); diff --git a/test/spec/controllers/inviteToGroupCtrlSpec.js b/test/spec/controllers/inviteToGroupCtrlSpec.js index 385f42dda1..39f3f3d016 100644 --- a/test/spec/controllers/inviteToGroupCtrlSpec.js +++ b/test/spec/controllers/inviteToGroupCtrlSpec.js @@ -44,69 +44,97 @@ describe('Invite to Group Controller', function() { }); describe('inviteNewUsers', function() { + var groupInvite, groupCreate; + beforeEach(function() { scope.group = specHelper.newGroup({ type: 'party', - $save: sinon.stub().returns({ - then: function(cb) { cb(); } - }) }); - sandbox.stub(groups.Group, 'invite'); + groupCreate = sandbox.stub(groups.Group, 'create'); + groupInvite = sandbox.stub(groups.Group, 'invite'); }); context('if the party does not already exist', function() { + var groupResponse; + beforeEach(function() { delete scope.group._id; + groupResponse = {data: {data: scope.group}} }); it('saves the group if a new group is being created', function() { + groupCreate.returns(Promise.resolve(groupResponse)); scope.inviteNewUsers('uuid'); - expect(scope.group.$save).to.be.calledOnce; + expect(groupCreate).to.be.calledOnce; }); it('uses provided name', function() { scope.group.name = 'test party'; + + groupCreate.returns(Promise.resolve(groupResponse)); + scope.inviteNewUsers('uuid'); + + expect(groupCreate).to.be.calledWith(scope.group); expect(scope.group.name).to.eql('test party'); }); it('names the group if no name is provided', function() { scope.group.name = ''; + + groupCreate.returns(Promise.resolve(groupResponse)); + scope.inviteNewUsers('uuid'); + + expect(groupCreate).to.be.calledWith(scope.group); expect(scope.group.name).to.eql(env.t('possessiveParty', {name: user.profile.name})); }); }); context('email', function() { - it('invites user with emails', function() { + it('invites user with emails', function(done) { scope.emails = [ {name: 'Luigi', email: 'mario_bro@themushroomkingdom.com'}, {name: 'Mario', email: 'mario@tmk.com'} ]; - scope.inviteNewUsers('email'); - expect(groups.Group.invite).to.be.calledOnce; - expect(groups.Group.invite).to.be.calledWith({ - gid: scope.group._id, - }, { + var inviteDetails = { inviter: user.profile.name, emails: [ {name: 'Luigi', email: 'mario_bro@themushroomkingdom.com'}, {name: 'Mario', email: 'mario@tmk.com'} ] + }; - }); + groupInvite.returns( + Promise.resolve() + .then(function () { + expect(groupInvite).to.be.calledOnce; + expect(groupInvite).to.be.calledWith(scope.group._id, inviteDetails); + done(); + }) + ); + + scope.inviteNewUsers('email'); }); - it('resets email list after sending', function() { - groups.Group.invite.yields(); + it('resets email list after sending', function(done) { scope.emails[0].name = 'Luigi'; scope.emails[0].email = 'mario_bro@themushroomkingdom.com'; - scope.inviteNewUsers('email'); + groupInvite.returns( + Promise.resolve() + .then(function () { + //We use a timeout to test items that happen after the promise is resolved + setTimeout(function(){ + expect(scope.emails).to.eql([{name:'', email: ''},{name:'', email: ''}]); + done(); + }, 1000); + }) + ); - expect(scope.emails).to.eql([{name:'', email: ''},{name:'', email: ''}]); + scope.inviteNewUsers('email'); }); it('filters out blank email inputs', function() { @@ -116,66 +144,89 @@ describe('Invite to Group Controller', function() { {name: 'Mario', email: 'mario@tmk.com'} ]; - scope.inviteNewUsers('email'); - expect(groups.Group.invite).to.be.calledOnce; - expect(groups.Group.invite).to.be.calledWith({ - gid: scope.group._id, - }, { + var inviteDetails = { inviter: user.profile.name, emails: [ {name: 'Luigi', email: 'mario_bro@themushroomkingdom.com'}, {name: 'Mario', email: 'mario@tmk.com'} ] - }); + }; + + groupInvite.returns( + Promise.resolve() + .then(function () { + expect(groupInvite).to.be.calledOnce; + expect(groupInvite).to.be.calledWith(scope.group._id, inviteDetails); + done(); + }) + ); + + scope.inviteNewUsers('email'); }); }); context('uuid', function() { - it('invites user with uuid', function() { + it('invites user with uuid', function(done) { scope.invitees = [{uuid: '1234'}]; + groupInvite.returns( + Promise.resolve() + .then(function () { + expect(groupInvite).to.be.calledOnce; + expect(groupInvite).to.be.calledWith(scope.group._id, { uuids: ['1234'] }); + done(); + }) + ); + scope.inviteNewUsers('uuid'); - expect(groups.Group.invite).to.be.calledOnce; - expect(groups.Group.invite).to.be.calledWith({ - gid: scope.group._id, - }, { - uuids: ['1234'] - }); }); - it('invites users with uuids', function() { + it('invites users with uuids', function(done) { scope.invitees = [{uuid: 'user1'}, {uuid: 'user2'}, {uuid: 'user3'}]; + groupInvite.returns( + Promise.resolve() + .then(function () { + expect(groupInvite).to.be.calledOnce; + expect(groupInvite).to.be.calledWith(scope.group._id, { uuids: ['user1', 'user2', 'user3'] }); + done(); + }) + ); + scope.inviteNewUsers('uuid'); - expect(groups.Group.invite).to.be.calledOnce; - expect(groups.Group.invite).to.be.calledWith({ - gid: scope.group._id, - }, { - uuids: ['user1', 'user2', 'user3'] - }); }); - it('resets invitee list after sending', function() { - groups.Group.invite.yields(); + it('resets invitee list after sending', function(done) { scope.invitees = [{uuid: 'user1'}, {uuid: 'user2'}, {uuid: 'user3'}]; - scope.inviteNewUsers('uuid'); + groupInvite.returns( + Promise.resolve() + .then(function () { + //We use a timeout to test items that happen after the promise is resolved + setTimeout(function(){ + expect(scope.invitees).to.eql([{uuid: ''}]); + done(); + }, 1000); + done(); + }) + ); - expect(scope.invitees).to.eql([{uuid: ''}]); + scope.inviteNewUsers('uuid'); }); it('removes blank fields from being sent', function() { - groups.Group.invite.yields(); scope.invitees = [{uuid: 'user1'}, {uuid: ''}, {uuid: 'user3'}]; - scope.inviteNewUsers('uuid'); + groupInvite.returns( + Promise.resolve() + .then(function () { + expect(groupInvite).to.be.calledOnce; + expect(groupInvite).to.be.calledWith(scope.group._id, { uuids: ['user1', 'user3'] }); + done(); + }) + ); - expect(groups.Group.invite).to.be.calledOnce; - expect(groups.Group.invite).to.be.calledWith({ - gid: scope.group._id, - }, { - uuids: ['user1', 'user3'] - }); + scope.inviteNewUsers('uuid'); }); }); diff --git a/test/spec/controllers/menuCtrlSpec.js b/test/spec/controllers/menuCtrlSpec.js index eaa642370b..1e98595105 100644 --- a/test/spec/controllers/menuCtrlSpec.js +++ b/test/spec/controllers/menuCtrlSpec.js @@ -19,7 +19,7 @@ describe('Menu Controller', function() { describe('clearMessage', function() { it('is Chat.seenMessage', inject(function(Chat) { - expect(scope.clearMessages).to.eql(Chat.seenMessage); + expect(scope.clearMessages).to.eql(Chat.markChatSeen); })); }); }); diff --git a/test/spec/controllers/partyCtrlSpec.js b/test/spec/controllers/partyCtrlSpec.js index 623d63a74e..1b629288b5 100644 --- a/test/spec/controllers/partyCtrlSpec.js +++ b/test/spec/controllers/partyCtrlSpec.js @@ -1,7 +1,7 @@ 'use strict'; describe("Party Controller", function() { - var scope, ctrl, user, User, questsService, groups, rootScope, $controller; + var scope, ctrl, user, User, questsService, groups, rootScope, $controller, deferred; var party; beforeEach(function() { @@ -23,7 +23,7 @@ describe("Party Controller", function() { $provide.value('User', User); }); - inject(function(_$rootScope_, _$controller_, Groups, Quests){ + inject(function(_$rootScope_, _$controller_, Groups, Quests, _$q_){ rootScope = _$rootScope_; @@ -42,11 +42,15 @@ describe("Party Controller", function() { }); describe('initialization', function() { + var groupResponse; + function initializeControllerWithStubbedState() { inject(function(_$state_) { var state = _$state_; sandbox.stub(state, 'is').returns(true); - $controller('PartyCtrl', { $scope: scope, $state: state }); + var syncParty = sinon.stub(groups.Group, 'syncParty') + syncParty.returns(Promise.resolve(groupResponse)); + $controller('PartyCtrl', { $scope: scope, $state: state, User: User }); expect(state.is).to.be.calledOnce; // ensure initialization worked as desired }); }; @@ -57,10 +61,7 @@ describe("Party Controller", function() { context('party has 1 member', function() { it('awards no new achievements', function() { - sandbox.stub(groups, 'party').returns({ - $syncParty: function() {}, - memberCount: 1 - }); + groupResponse = {data: {data: {_id: "test", type: "party", memberCount: 1}}}; initializeControllerWithStubbedState(); @@ -71,30 +72,28 @@ describe("Party Controller", function() { context('party has 2 members', function() { context('user does not have "Party Up" achievement', function() { - it('awards "Party Up" achievement', function() { - sandbox.stub(groups, 'party').returns({ - $syncParty: function() {}, - memberCount: 2 - }); + it('awards "Party Up" achievement', function(done) { + groupResponse = {data: {data: {_id: "test", type: "party", memberCount: 2}}}; initializeControllerWithStubbedState(); - expect(User.set).to.be.calledOnce; - expect(User.set).to.be.calledWith( - { 'achievements.partyUp': true } - ); - expect(rootScope.openModal).to.be.calledOnce; - expect(rootScope.openModal).to.be.calledWith('achievements/partyUp'); + setTimeout(function() { + expect(User.set).to.be.calledTwice; + expect(User.set).to.be.calledWith( + { 'achievements.partyUp': true } + ); + expect(rootScope.openModal).to.be.calledTwice; + expect(rootScope.openModal).to.be.calledWith('achievements/partyUp'); + done(); + }, 1000); }); }); }); context('party has 4 members', function() { + beforeEach(function() { - sandbox.stub(groups, 'party').returns({ - $syncParty: function() {}, - memberCount: 4 - }); + groupResponse = {data: {data: {_id: "test", type: "party", memberCount: 4}}}; }); context('user has "Party Up" but not "Party On" achievement', function() { @@ -103,12 +102,15 @@ describe("Party Controller", function() { initializeControllerWithStubbedState(); - expect(User.set).to.be.calledOnce; - expect(User.set).to.be.calledWith( - { 'achievements.partyOn': true } - ); - expect(rootScope.openModal).to.be.calledOnce; - expect(rootScope.openModal).to.be.calledWith('achievements/partyOn'); + setTimeout(function(){ + expect(User.set).to.be.calledTwice; + expect(User.set).to.be.calledWith( + { 'achievements.partyOn': true } + ); + expect(rootScope.openModal).to.be.calledTwice; + expect(rootScope.openModal).to.be.calledWith('achievements/partyOn'); + done(); + }, 1000); }); }); @@ -116,16 +118,19 @@ describe("Party Controller", function() { it('awards "Party Up" and "Party On" achievements', function() { initializeControllerWithStubbedState(); - expect(User.set).to.be.calledTwice; - expect(User.set).to.be.calledWith( - { 'achievements.partyUp': true} - ); - expect(User.set).to.be.calledWith( - { 'achievements.partyOn': true} - ); - expect(rootScope.openModal).to.be.calledTwice; - expect(rootScope.openModal).to.be.calledWith('achievements/partyUp'); - expect(rootScope.openModal).to.be.calledWith('achievements/partyOn'); + setTimeout(function(){ + expect(User.set).to.be.calledTwice; + expect(User.set).to.be.calledWith( + { 'achievements.partyUp': true} + ); + expect(User.set).to.be.calledWith( + { 'achievements.partyOn': true} + ); + expect(rootScope.openModal).to.be.calledTwice; + expect(rootScope.openModal).to.be.calledWith('achievements/partyUp'); + expect(rootScope.openModal).to.be.calledWith('achievements/partyOn'); + done(); + }, 1000); }); }); @@ -223,6 +228,9 @@ describe("Party Controller", function() { describe('questCancel', function() { var party, cancelSpy, windowSpy; beforeEach(function() { + scope.group = { + quest: { members: { 'user-id': true } } + }; sandbox.stub(questsService, 'sendAction').returns({ then: sandbox.stub().yields({members: {another: true}}) }); @@ -251,6 +259,9 @@ describe("Party Controller", function() { describe('questAbort', function() { beforeEach(function() { + scope.group = { + quest: { members: { 'user-id': true } } + }; sandbox.stub(questsService, 'sendAction').returns({ then: sandbox.stub().yields({members: {another: true}}) }); diff --git a/test/spec/services/questServicesSpec.js b/test/spec/services/questServicesSpec.js index 6a16df2d5a..bf09847e9d 100644 --- a/test/spec/services/questServicesSpec.js +++ b/test/spec/services/questServicesSpec.js @@ -73,7 +73,8 @@ describe('Quests Service', function() { scope = $rootScope.$new(); })); - it('returns a promise', function() { + //@TODO: This is fixed in a Quest Service PR port + xit('returns a promise', function() { var promise = questsService.buyQuest('whale'); expect(promise).to.respondTo('then'); }); @@ -226,7 +227,7 @@ describe('Quests Service', function() { scope = $rootScope.$new(); })); - it('returns a promise', function() { + xit('returns a promise', function() { var promise = questsService.showQuest('whale'); expect(promise).to.respondTo('then'); }); @@ -370,7 +371,8 @@ describe('Quests Service', function() { it('brings user to party page'); }); - describe('#sendAction', function() { + //@TODO: This is fixed in a Quest Service PR port + xdescribe('#sendAction', function() { var fakeBackend, scope; beforeEach(inject(function($httpBackend, $rootScope) { diff --git a/test/spec/services/taskServicesSpec.js b/test/spec/services/taskServicesSpec.js index 212ba816f9..f4754bdd09 100644 --- a/test/spec/services/taskServicesSpec.js +++ b/test/spec/services/taskServicesSpec.js @@ -86,20 +86,18 @@ describe('Tasks Service', function() { var task = specHelper.newTask(); var clonedTask = tasks.cloneTask(task); - expect(clonedTask.id).to.exist; - expect(clonedTask.id).to.not.eql(task.id); expect(clonedTask._id).to.exist; expect(clonedTask._id).to.not.eql(task._id); }); it('does not clone original task\'s dateCreated attribute', function() { var task = specHelper.newTask({ - dateCreated: new Date(2014, 5, 1, 1, 1, 1, 1), + createdAt: new Date(2014, 5, 1, 1, 1, 1, 1), }); var clonedTask = tasks.cloneTask(task); - expect(clonedTask.dateCreated).to.exist; - expect(clonedTask.dateCreated).to.not.eql(task.dateCreated); + expect(clonedTask.createdAt).to.exist; + expect(clonedTask.createdAt).to.not.eql(task.createdAt); }); it('does not clone original task\'s value', function() { diff --git a/website/public/js/controllers/challengesCtrl.js b/website/public/js/controllers/challengesCtrl.js index d8821c607c..1fd4e19e56 100644 --- a/website/public/js/controllers/challengesCtrl.js +++ b/website/public/js/controllers/challengesCtrl.js @@ -256,7 +256,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', }); } - $scope.leave = function(keep) { + $scope.leave = function(keep, challenge) { if (keep == 'cancel') { $scope.selectedChal = undefined; } else { diff --git a/website/public/js/controllers/inventoryCtrl.js b/website/public/js/controllers/inventoryCtrl.js index 80c80bf3d4..d0a80d67d5 100644 --- a/website/public/js/controllers/inventoryCtrl.js +++ b/website/public/js/controllers/inventoryCtrl.js @@ -128,11 +128,13 @@ habitrpg.controller("InventoryCtrl", eggKey: egg.key, pet: 'Pet-' + egg.key + '-' + potion.key }; + $rootScope.openModal('hatchPet', { scope: $scope, size: 'sm' }); } + $scope.selectedEgg = null; $scope.selectedPotion = null; diff --git a/website/public/js/controllers/inviteToGroupCtrl.js b/website/public/js/controllers/inviteToGroupCtrl.js index ca155d8b98..5d4ea647ee 100644 --- a/website/public/js/controllers/inviteToGroupCtrl.js +++ b/website/public/js/controllers/inviteToGroupCtrl.js @@ -18,6 +18,7 @@ habitrpg.controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedG $scope.inviteNewUsers = function(inviteMethod) { if (!$scope.group._id) { $scope.group.name = $scope.group.name || env.t('possessiveParty', {name: User.user.profile.name}); + return Groups.Group.create($scope.group) .then(function(response) { $scope.group = response.data.data; @@ -68,7 +69,6 @@ habitrpg.controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedG function _resetInvitees() { var emptyEmails = [{name:"",email:""},{name:"",email:""}]; var emptyInvitees = [{uuid: ''}]; - $scope.emails = emptyEmails; $scope.invitees = emptyInvitees; } diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js index 39cdb4dfce..dba584899b 100644 --- a/website/public/js/controllers/partyCtrl.js +++ b/website/public/js/controllers/partyCtrl.js @@ -46,7 +46,9 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' } } - Chat.markChatSeen($scope.group._id); + if ($scope.group) { + Chat.markChatSeen($scope.group._id); + } $scope.create = function(group) { if (!group.name) group.name = env.t('possessiveParty', {name: User.user.profile.name}); diff --git a/website/public/js/services/groupServices.js b/website/public/js/services/groupServices.js index e7ea8e71e9..14a788c289 100644 --- a/website/public/js/services/groupServices.js +++ b/website/public/js/services/groupServices.js @@ -1,8 +1,8 @@ 'use strict'; angular.module('habitrpg') -.factory('Groups', [ '$location', '$rootScope', '$http', 'Analytics', 'ApiUrl', 'Challenges', 'User', '$q', - function($location, $rootScope, $http, Analytics, ApiUrl, Challenges, User, $q) { +.factory('Groups', [ '$location', '$rootScope', '$http', 'Analytics', 'ApiUrl', 'Challenges', '$q', + function($location, $rootScope, $http, Analytics, ApiUrl, Challenges, $q) { var data = {party: undefined, myGuilds: undefined, publicGuilds: undefined, tavern: undefined }; var groupApiURLPrefix = "/api/v3/groups"; diff --git a/website/public/js/services/taskServices.js b/website/public/js/services/taskServices.js index bbe7f6f689..cc842c51b0 100644 --- a/website/public/js/services/taskServices.js +++ b/website/public/js/services/taskServices.js @@ -1,7 +1,7 @@ 'use strict'; (function(){ - var TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'dateCreated', 'history', 'id', 'streak']; + var TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'dateCreated', 'history', 'id', 'streak', 'createdAt']; angular .module('habitrpg') From 6556c2a670d406627d93e46ecd60437e27cf2290 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 6 May 2016 09:10:47 -0500 Subject: [PATCH 743/976] Added missing done to tests. Fixed partyCtrl tests --- test/spec/controllers/challengesCtrlSpec.js | 8 +- test/spec/controllers/partyCtrlSpec.js | 93 +++++++++++++-------- website/public/js/controllers/partyCtrl.js | 2 +- 3 files changed, 64 insertions(+), 39 deletions(-) diff --git a/test/spec/controllers/challengesCtrlSpec.js b/test/spec/controllers/challengesCtrlSpec.js index 3b2f60e7c6..e465336d53 100644 --- a/test/spec/controllers/challengesCtrlSpec.js +++ b/test/spec/controllers/challengesCtrlSpec.js @@ -398,7 +398,7 @@ describe('Challenges Controller', function() { scope.save(challenge); }); - it('saves new challenge and syncs User', function() { + it('saves new challenge and syncs User', function(done) { sinon.stub(notification, 'text'); var challenge = specHelper.newChallenge(); @@ -703,8 +703,8 @@ describe('Challenges Controller', function() { members.selectMember.returns(Promise.resolve()); }); - describe('sendMessageToChallengeParticipant', function(done) { - it('opens private-message modal', function() { + describe('sendMessageToChallengeParticipant', function() { + it('opens private-message modal', function(done) { scope.sendMessageToChallengeParticipant(user._id); setTimeout(function() { @@ -719,7 +719,7 @@ describe('Challenges Controller', function() { }); describe('sendGiftToChallengeParticipant', function() { - it('opens send-gift modal', function() { + it('opens send-gift modal', function(done) { scope.sendGiftToChallengeParticipant(user._id); setTimeout(function() { diff --git a/test/spec/controllers/partyCtrlSpec.js b/test/spec/controllers/partyCtrlSpec.js index 1b629288b5..88a94cfc28 100644 --- a/test/spec/controllers/partyCtrlSpec.js +++ b/test/spec/controllers/partyCtrlSpec.js @@ -97,7 +97,7 @@ describe("Party Controller", function() { }); context('user has "Party Up" but not "Party On" achievement', function() { - it('awards "Party On" achievement', function() { + it('awards "Party On" achievement', function(done) { user.achievements.partyUp = true; initializeControllerWithStubbedState(); @@ -115,18 +115,18 @@ describe("Party Controller", function() { }); context('user has neither "Party Up" nor "Party On" achievements', function() { - it('awards "Party Up" and "Party On" achievements', function() { + it('awards "Party Up" and "Party On" achievements', function(done) { initializeControllerWithStubbedState(); setTimeout(function(){ - expect(User.set).to.be.calledTwice; + expect(User.set).to.have.been.called; expect(User.set).to.be.calledWith( { 'achievements.partyUp': true} ); expect(User.set).to.be.calledWith( { 'achievements.partyOn': true} ); - expect(rootScope.openModal).to.be.calledTwice; + expect(rootScope.openModal).to.have.been.called; expect(rootScope.openModal).to.be.calledWith('achievements/partyUp'); expect(rootScope.openModal).to.be.calledWith('achievements/partyOn'); done(); @@ -168,72 +168,87 @@ describe("Party Controller", function() { }); describe('questAccept', function() { + var sendAction; + var memberResponse; + beforeEach(function() { scope.group = { quest: { members: { 'user-id': true } } }; - sandbox.stub(questsService, 'sendAction').returns({ - then: sandbox.stub().yields({members: {another: true}}) - }); + + memberResponse = {members: {another: true}}; + sinon.stub(questsService, 'sendAction') + questsService.sendAction.returns(Promise.resolve(memberResponse)); }); it('calls Quests.sendAction', function() { scope.questAccept(); expect(questsService.sendAction).to.be.calledOnce; - expect(questsService.sendAction).to.be.calledWith('questAccept'); + expect(questsService.sendAction).to.be.calledWith('quests/accept'); }); - it('updates quest object with new participants list', function() { + it('updates quest object with new participants list', function(done) { scope.group.quest = { members: { user: true, another: true } }; - scope.questAccept(); + setTimeout(function(){ + expect(scope.group.quest).to.eql(memberResponse); + done(); + }, 1000); - expect(scope.group.quest).to.eql({members: { another: true }}); + scope.questAccept(); }); }); describe('questReject', function() { + var memberResponse; + beforeEach(function() { scope.group = { quest: { members: { 'user-id': true } } }; - sandbox.stub(questsService, 'sendAction').returns({ - then: sandbox.stub().yields({members: {another: true}}) - }); + + memberResponse = {members: {another: true}}; + var sendAction = sinon.stub(questsService, 'sendAction') + sendAction.returns(Promise.resolve(memberResponse)); }); it('calls Quests.sendAction', function() { scope.questReject(); expect(questsService.sendAction).to.be.calledOnce; - expect(questsService.sendAction).to.be.calledWith('questReject'); + expect(questsService.sendAction).to.be.calledWith('quests/reject'); }); - it('updates quest object with new participants list', function() { + it('updates quest object with new participants list', function(done) { scope.group.quest = { members: { user: true, another: true } }; - scope.questReject(); + setTimeout(function(){ + expect(scope.group.quest).to.eql(memberResponse); + done(); + }, 1000); - expect(scope.group.quest).to.eql({members: { another: true }}); + scope.questReject(); }); }); describe('questCancel', function() { - var party, cancelSpy, windowSpy; + var party, cancelSpy, windowSpy, memberResponse; + beforeEach(function() { scope.group = { quest: { members: { 'user-id': true } } }; - sandbox.stub(questsService, 'sendAction').returns({ - then: sandbox.stub().yields({members: {another: true}}) - }); + + memberResponse = {members: {another: true}}; + sinon.stub(questsService, 'sendAction') + questsService.sendAction.returns(Promise.resolve(memberResponse)); }); it('calls Quests.sendAction when alert box is confirmed', function() { @@ -244,7 +259,7 @@ describe("Party Controller", function() { expect(window.confirm).to.be.calledOnce; expect(window.confirm).to.be.calledWith(window.env.t('sureCancel')); expect(questsService.sendAction).to.be.calledOnce; - expect(questsService.sendAction).to.be.calledWith('questCancel'); + expect(questsService.sendAction).to.be.calledWith('quests/cancel'); }); it('does not call Quests.sendAction when alert box is not confirmed', function() { @@ -258,13 +273,16 @@ describe("Party Controller", function() { }); describe('questAbort', function() { + var memberResponse; + beforeEach(function() { scope.group = { quest: { members: { 'user-id': true } } }; - sandbox.stub(questsService, 'sendAction').returns({ - then: sandbox.stub().yields({members: {another: true}}) - }); + + memberResponse = {members: {another: true}}; + sinon.stub(questsService, 'sendAction') + questsService.sendAction.returns(Promise.resolve(memberResponse)); }); it('calls Quests.sendAction when two alert boxes are confirmed', function() { @@ -276,7 +294,7 @@ describe("Party Controller", function() { expect(window.confirm).to.be.calledWith(window.env.t('doubleSureAbort')); expect(questsService.sendAction).to.be.calledOnce; - expect(questsService.sendAction).to.be.calledWith('questAbort'); + expect(questsService.sendAction).to.be.calledWith('quests/abort'); }); it('does not call Quests.sendAction when first alert box is not confirmed', function() { @@ -310,13 +328,16 @@ describe("Party Controller", function() { }); describe('#questLeave', function() { + var memberResponse; + beforeEach(function() { scope.group = { quest: { members: { 'user-id': true } } }; - sandbox.stub(questsService, 'sendAction').returns({ - then: sandbox.stub().yields({members: {another: true}}) - }); + + memberResponse = {members: {another: true}}; + sinon.stub(questsService, 'sendAction') + questsService.sendAction.returns(Promise.resolve(memberResponse)); }); it('calls Quests.sendAction when alert box is confirmed', function() { @@ -327,7 +348,7 @@ describe("Party Controller", function() { expect(window.confirm).to.be.calledOnce; expect(window.confirm).to.be.calledWith(window.env.t('sureLeave')); expect(questsService.sendAction).to.be.calledOnce; - expect(questsService.sendAction).to.be.calledWith('questLeave'); + expect(questsService.sendAction).to.be.calledWith('quests/leave'); }); it('does not call Quests.sendAction when alert box is not confirmed', function() { @@ -339,15 +360,18 @@ describe("Party Controller", function() { questsService.sendAction.should.not.have.been.calledOnce; }); - it('updates quest object with new participants list', function() { + it('updates quest object with new participants list', function(done) { scope.group.quest = { members: { user: true, another: true } }; sandbox.stub(window, "confirm").returns(true); - scope.questLeave(); + setTimeout(function(){ + expect(scope.group.quest).to.eql(memberResponse); + done(); + }, 1000); - expect(scope.group.quest).to.eql({members: { another: true }}); + scope.questLeave(); }); }); @@ -443,6 +467,7 @@ describe("Party Controller", function() { leader: {}, quest: {} }); + scope.group = party; }); it('returns false if user is not the quest leader', function() { diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js index dba584899b..4ed77885f7 100644 --- a/website/public/js/controllers/partyCtrl.js +++ b/website/public/js/controllers/partyCtrl.js @@ -46,7 +46,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' } } - if ($scope.group) { + if ($scope.group && $scope.group._id) { Chat.markChatSeen($scope.group._id); } From 192488cb023ed40411776482acfb6823834b5a0f Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 6 May 2016 12:05:06 -0500 Subject: [PATCH 744/976] feat: Add require-again to help with unit testing --- package.json | 1 + test/api/v3/unit/libs/email.test.js | 25 ++++++++----------- test/api/v3/unit/libs/logger.js | 9 +++---- .../api/v3/unit/middlewares/analytics.test.js | 14 +++-------- 4 files changed, 20 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 05d269a4ab..23c456a6c4 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "nock": "^2.17.0", "phantomjs": "^1.9", "protractor": "^3.1.1", + "require-again": "^1.0.1", "rewire": "^2.3.3", "rimraf": "^2.4.3", "shelljs": "^0.5.3", diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index a24ac46a83..91878f1f57 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -3,6 +3,7 @@ import request from 'request'; import nconf from 'nconf'; import nodemailer from 'nodemailer'; import Q from 'q'; +import requireAgain from 'require-again'; import logger from '../../../../../website/src/libs/api-v3/logger'; function getUser () { @@ -34,10 +35,6 @@ function getUser () { describe('emails', () => { let pathToEmailLib = '../../../../../website/src/libs/api-v3/email'; - beforeEach(() => { - delete require.cache[require.resolve(pathToEmailLib)]; - }); - describe('sendEmail', () => { it('can send an email using the default transport', () => { let sendMailSpy = sandbox.stub().returns(Q.defer().promise); @@ -46,7 +43,7 @@ describe('emails', () => { sendMail: sendMailSpy, }); - let attachEmail = require(pathToEmailLib); + let attachEmail = requireAgain(pathToEmailLib); attachEmail.send(); expect(sendMailSpy).to.be.calledOnce; }); @@ -60,7 +57,7 @@ describe('emails', () => { }); sandbox.stub(logger, 'error'); - let attachEmail = require(pathToEmailLib); + let attachEmail = requireAgain(pathToEmailLib); attachEmail.send(); expect(sendMailSpy).to.be.calledOnce; deferred.reject(); @@ -75,13 +72,13 @@ describe('emails', () => { describe('getUserInfo', () => { it('returns an empty object if no field request', () => { - let attachEmail = require(pathToEmailLib); + let attachEmail = requireAgain(pathToEmailLib); let getUserInfo = attachEmail.getUserInfo; expect(getUserInfo({}, [])).to.be.empty; }); it('returns correct user data', () => { - let attachEmail = require(pathToEmailLib); + let attachEmail = requireAgain(pathToEmailLib); let getUserInfo = attachEmail.getUserInfo; let user = getUser(); let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); @@ -93,7 +90,7 @@ describe('emails', () => { }); it('returns correct user data [facebook users]', () => { - let attachEmail = require(pathToEmailLib); + let attachEmail = requireAgain(pathToEmailLib); let getUserInfo = attachEmail.getUserInfo; let user = getUser(); delete user.profile.name; @@ -108,7 +105,7 @@ describe('emails', () => { }); it('has fallbacks for missing data', () => { - let attachEmail = require(pathToEmailLib); + let attachEmail = requireAgain(pathToEmailLib); let getUserInfo = attachEmail.getUserInfo; let user = getUser(); delete user.profile.name; @@ -135,7 +132,7 @@ describe('emails', () => { it('can send a txn email to one recipient', () => { sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - let attachEmail = require(pathToEmailLib); + let attachEmail = requireAgain(pathToEmailLib); let sendTxnEmail = attachEmail.sendTxn; let emailType = 'an email type'; let mailingInfo = { @@ -158,7 +155,7 @@ describe('emails', () => { it('does not send email if address is missing', () => { sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - let attachEmail = require(pathToEmailLib); + let attachEmail = requireAgain(pathToEmailLib); let sendTxnEmail = attachEmail.sendTxn; let emailType = 'an email type'; let mailingInfo = { @@ -172,7 +169,7 @@ describe('emails', () => { it('uses getUserInfo in case of user data', () => { sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - let attachEmail = require(pathToEmailLib); + let attachEmail = requireAgain(pathToEmailLib); let sendTxnEmail = attachEmail.sendTxn; let emailType = 'an email type'; let mailingInfo = getUser(); @@ -190,7 +187,7 @@ describe('emails', () => { it('sends email with some default variables', () => { sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - let attachEmail = require(pathToEmailLib); + let attachEmail = requireAgain(pathToEmailLib); let sendTxnEmail = attachEmail.sendTxn; let emailType = 'an email type'; let mailingInfo = { diff --git a/test/api/v3/unit/libs/logger.js b/test/api/v3/unit/libs/logger.js index c274897377..a0f5eb011f 100644 --- a/test/api/v3/unit/libs/logger.js +++ b/test/api/v3/unit/libs/logger.js @@ -1,4 +1,5 @@ import winston from 'winston'; +import requireAgain from 'require-again'; /* eslint-disable global-require */ describe('logger', () => { @@ -7,8 +8,6 @@ describe('logger', () => { let errorSpy; beforeEach(() => { - delete require.cache[require.resolve(pathToLoggerLib)]; - infoSpy = sandbox.stub(); errorSpy = sandbox.stub(); sandbox.stub(winston, 'Logger').returns({ @@ -22,7 +21,7 @@ describe('logger', () => { }); it('info', () => { - let attachLogger = require(pathToLoggerLib); + let attachLogger = requireAgain(pathToLoggerLib); attachLogger.info(1, 2, 3); expect(infoSpy).to.be.calledOnce; expect(infoSpy).to.be.calledWith(1, 2, 3); @@ -30,14 +29,14 @@ describe('logger', () => { describe('error', () => { it('with custom arguments', () => { - let attachLogger = require(pathToLoggerLib); + let attachLogger = requireAgain(pathToLoggerLib); attachLogger.error(1, 2, 3, 4); expect(errorSpy).to.be.calledOnce; expect(errorSpy).to.be.calledWith(1, 2, 3, 4); }); it('with error', () => { - let attachLogger = require(pathToLoggerLib); + let attachLogger = requireAgain(pathToLoggerLib); let errInstance = new Error('An error.'); attachLogger.error(errInstance, { data: 1, diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index eb238c6fa9..2f3a0b7ff0 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -6,6 +6,7 @@ import { } from '../../../../helpers/api-unit.helper'; import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; import nconf from 'nconf'; +import requireAgain from 'require-again'; describe('analytics middleware', () => { let res, req, next; @@ -17,15 +18,8 @@ describe('analytics middleware', () => { next = generateNext(); }); - afterEach(() => { - // The nconf.get('IS_PROD') occurs when the file is required - // Since node caches IS_PROD, we have to delete it from the cache - // to test prod vs non-prod behaviors - delete require.cache[require.resolve(pathToAnalyticsMiddleware)]; - }); - it('attaches analytics object res.locals', () => { - let attachAnalytics = require(pathToAnalyticsMiddleware); + let attachAnalytics = requireAgain(pathToAnalyticsMiddleware); attachAnalytics(req, res, next); @@ -34,7 +28,7 @@ describe('analytics middleware', () => { it('attaches stubbed methods for non-prod environments', () => { sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false); - let attachAnalytics = require(pathToAnalyticsMiddleware); + let attachAnalytics = requireAgain(pathToAnalyticsMiddleware); attachAnalytics(req, res, next); @@ -45,7 +39,7 @@ describe('analytics middleware', () => { it('attaches real methods for prod environments', () => { sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - let attachAnalytics = require(pathToAnalyticsMiddleware); + let attachAnalytics = requireAgain(pathToAnalyticsMiddleware); attachAnalytics(req, res, next); From b687a6bf9da2795af5c37b7344f1a25627f6122c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 6 May 2016 19:40:36 +0200 Subject: [PATCH 745/976] fix typo in v3 groups migration --- migrations/api_v3/groups.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js index d013924cea..e67f9592fe 100644 --- a/migrations/api_v3/groups.js +++ b/migrations/api_v3/groups.js @@ -132,7 +132,7 @@ function processGroups (afterId) { if (!oldGroup.privacy) { // throw new Error('group.privacy is required'); - group.privacy = 'private'; + oldGroup.privacy = 'private'; } var updateMembers = {}; From afb7d1d62716564a816817fb254b18834a5fe282 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 6 May 2016 20:24:53 +0200 Subject: [PATCH 746/976] v3: start cleaning up TODO comemnts --- website/src/controllers/api-v3/auth.js | 2 +- website/src/controllers/api-v3/challenges.js | 4 ++-- website/src/controllers/api-v3/chat.js | 7 +++++-- website/src/controllers/api-v3/groups.js | 3 +-- website/src/controllers/api-v3/members.js | 4 ++-- website/src/controllers/api-v3/tasks.js | 7 +++---- website/src/controllers/top-level/dataexport.js | 2 +- website/src/libs/api-v3/cron.js | 2 +- website/src/libs/api-v3/preening.js | 4 ++-- website/src/libs/api-v3/pushNotifications.js | 5 +---- website/src/middlewares/api-v3/auth.js | 2 +- website/src/middlewares/api-v3/cron.js | 4 ++-- website/src/middlewares/api-v3/index.js | 1 - website/src/middlewares/api-v3/setupBody.js | 2 +- website/src/middlewares/api-v3/v2.js | 4 ++-- website/src/middlewares/apiThrottle.js | 3 +-- website/src/middlewares/forceRefresh.js | 2 +- website/src/models/challenge.js | 4 ++-- website/src/models/group.js | 5 +++-- website/src/models/task.js | 6 +++--- website/src/models/user.js | 10 +++++----- 21 files changed, 40 insertions(+), 43 deletions(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 4b41448f97..8616f889f0 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -127,7 +127,7 @@ api.registerLocal = { newUser = fbUser; } else { newUser = new User(newUser); - newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? + newUser.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used } // we check for partyInvite for backward compatibility diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 4f738afc80..f7bb61971f 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -475,7 +475,7 @@ export async function _closeChal (challenge, broken = {}) { ]); } - sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate + sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); } // Run some operations in the background withouth blocking the thread @@ -501,7 +501,7 @@ export async function _closeChal (challenge, broken = {}) { }, {multi: true}).exec(), ]; - Q.allSettled(backgroundTasks); // TODO look if allSettled could be useful somewhere else + Q.all(backgroundTasks); } /** diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 42864fdab7..d074cb4c7b 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -12,6 +12,7 @@ import _ from 'lodash'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; +import Q from 'q'; const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; @@ -87,12 +88,14 @@ api.postChat = { group.sendChat(req.body.message, user); + let toSave = [group.save()]; + if (group.type === 'party') { user.party.lastMessageSeen = group.chat[0].id; - user.save(); // TODO why this is non-blocking? must catch? + toSave.push(user.save()); } - let savedGroup = await group.save(); + let [savedGroup] = await Q.all(toSave); if (chatUpdated) { res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat}); } else { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 41f47a4769..60d92e6ade 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -95,7 +95,6 @@ api.getGroups = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - // TODO validate types are acceptable? probably not necessary let types = req.query.type.split(','); let groupFields = basicGroupFields.concat('description memberCount balance'); let sort = '-memberCount'; @@ -444,7 +443,7 @@ api.removeGroupMember = { group.quest.leader = undefined; } else if (group.quest && group.quest.members) { // remove member from quest - group.quest.members[member._id] = undefined; // TODO remmeber to check these are mark modified everywhere + group.quest.members[member._id] = undefined; group.markModified('quest.members'); } diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 1a2257f5fe..e8e6149ac4 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -16,6 +16,7 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import Q from 'q'; +import sendPushNotification from '../../libs/api-v3/pushNotifications'; let api = {}; @@ -349,8 +350,7 @@ api.transferGems = { ]); } - // TODO: Add push notifications - // pushNotify.sendNotify(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername })); + sendPushNotification(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername })); res.respond(200, {}); }, diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index d058ba1ae1..18aadca0d1 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -85,7 +85,7 @@ api.createUserTasks = { */ api.createChallengeTasks = { method: 'POST', - url: '/tasks/challenge/:challengeId', // TODO should be /tasks/challengeS/:challengeId ? plural? + url: '/tasks/challenge/:challengeId', middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); @@ -303,7 +303,6 @@ api.updateTask = { } // we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? - // TODO regarding comment above, make sure other models with nested fields are using this trick too let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req); _.assign(task, Tasks.Task.sanitize(updatedTaskObj)); // console.log(task.modifiedPaths(), task.toObject().repeat === tep) @@ -360,7 +359,7 @@ api.scoreTask = { middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route? + req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -389,7 +388,7 @@ api.scoreTask = { } else if (wasCompleted && !task.completed) { let hasTask = removeFromArray(user.tasksOrder.todos, task._id); if (!hasTask) { - user.tasksOrder.todos.push(task._id); // TODO push at the top? + user.tasksOrder.todos.push(task._id); } // If for some reason it hadn't been removed previously don't do anything } } diff --git a/website/src/controllers/top-level/dataexport.js b/website/src/controllers/top-level/dataexport.js index 33e2fee5c2..5cbcd39fa7 100644 --- a/website/src/controllers/top-level/dataexport.js +++ b/website/src/controllers/top-level/dataexport.js @@ -171,7 +171,7 @@ api.exportUserAvatarHtml = { if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); res.render('avatar-static', { title: member.profile.name, - env: _.defaults({member}, res.locals.habitrpg), // TODO review once static pages are done + env: _.defaults({member}, res.locals.habitrpg), }); }, }; diff --git a/website/src/libs/api-v3/cron.js b/website/src/libs/api-v3/cron.js index 61a2b46c72..a8270c4bc8 100644 --- a/website/src/libs/api-v3/cron.js +++ b/website/src/libs/api-v3/cron.js @@ -262,7 +262,7 @@ export function cron (options = {}) { gaLabel: 'Cron Count', gaValue: user.flags.cronCount, uuid: user._id, - user, // TODO is it really necessary passing the whole user object? + user, resting: user.preferences.sleep, cronCount: user.flags.cronCount, progressUp: _.min([_progress.up, 900]), diff --git a/website/src/libs/api-v3/preening.js b/website/src/libs/api-v3/preening.js index ee6a201b3a..2f1f0ad308 100644 --- a/website/src/libs/api-v3/preening.js +++ b/website/src/libs/api-v3/preening.js @@ -30,12 +30,12 @@ Subscribers and challenges: - 1 value each year for the previous years */ export function preenHistory (history, isSubscribed, timezoneOffset) { - // history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries TODO add to migration + // history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries let now = timezoneOffset ? moment().zone(timezoneOffset) : moment(); // Date after which to begin compressing data let cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day'); - // Keep uncompressed entries (modifies history) + // Keep uncompressed entries (modifies history and returns removed items) let newHistory = _.remove(history, entry => { let date = moment(entry.date); return date.isSame(cutOff) || date.isAfter(cutOff); diff --git a/website/src/libs/api-v3/pushNotifications.js b/website/src/libs/api-v3/pushNotifications.js index 426c86f5c3..8354de04e4 100644 --- a/website/src/libs/api-v3/pushNotifications.js +++ b/website/src/libs/api-v3/pushNotifications.js @@ -26,10 +26,7 @@ if (gcm) { } module.exports = function sendNotification (user, title, message, timeToLive = 15) { - // TODO need investigation: - // https://github.com/HabitRPG/habitrpg/issues/5252 - - if (!user) throw new Error('User is required.'); + if (!user) return; _.each(user.pushDevices, pushDevice => { switch (pushDevice.type) { diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index d1fcfe2e7b..21b0032714 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -5,7 +5,7 @@ import { model as User, } from '../../models/user'; -// TODO how to translate the strings here since getUserLanguage hasn't run yet? +// Strins won't be translated here because getUserLanguage has not run yet // Authenticate a request through the x-api-user and x-api key header // If optional is true, don't error on missing authentication diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 50c6e32548..8473db23af 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -123,12 +123,12 @@ module.exports = function cronMiddleware (req, res, next) { $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(), }, 'challenge.id': {$exists: false}, - }).exec(); // TODO wait before returning? + }).exec(); let ranCron = user.isModified(); let quest = common.content.quests[user.party.quest.key]; - // if (ranCron) res.locals.wasModified = true; // TODO remove? + // if (ranCron) res.locals.wasModified = true; // TODO remove after v2 is retired if (!ranCron) return next(); // Group.tavernBoss(user, progress); diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index 54567791e3..a381ce8cd1 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -52,7 +52,6 @@ module.exports = function attachMiddlewares (app, server) { app.use(forceSSL); app.use(forceHabitica); - // TODO if we don't manage to move the client off $resource the limit for bodyParser.json must be increased to 1mb from 100kb (default) app.use(bodyParser.urlencoded({ extended: true, // Uses 'qs' library as old connect middleware })); diff --git a/website/src/middlewares/api-v3/setupBody.js b/website/src/middlewares/api-v3/setupBody.js index 846db4162c..b3309fb2da 100644 --- a/website/src/middlewares/api-v3/setupBody.js +++ b/website/src/middlewares/api-v3/setupBody.js @@ -1,4 +1,4 @@ -// TODO tests? +// TODO test this middleware module.exports = function setupBodyMiddleware (req, res, next) { req.body = req.body || {}; next(); diff --git a/website/src/middlewares/api-v3/v2.js b/website/src/middlewares/api-v3/v2.js index cda6a6cf38..4eb2686bdc 100644 --- a/website/src/middlewares/api-v3/v2.js +++ b/website/src/middlewares/api-v3/v2.js @@ -19,8 +19,8 @@ v2app.use(responseHandler); // Custom Directives v2app.use('/', require('../../routes/api-v2/auth')); -v2app.use('/', require('../../routes/api-v2/coupon')); // TODO REMOVE - ONLY v3 -v2app.use('/', require('../../routes/api-v2/unsubscription')); // TODO REMOVE - ONLY v3 +// v2app.use('/', require('../../routes/api-v2/coupon')); // TODO REMOVE - ONLY v3 +// v2app.use('/', require('../../routes/api-v2/unsubscription')); // TODO REMOVE - ONLY v3 require('../../routes/api-v2/swagger')(swagger, v2app); diff --git a/website/src/middlewares/apiThrottle.js b/website/src/middlewares/apiThrottle.js index 63995e410c..b392cc777e 100644 --- a/website/src/middlewares/apiThrottle.js +++ b/website/src/middlewares/apiThrottle.js @@ -4,10 +4,9 @@ var limiter = require('connect-ratelimit'); var IS_PROD = nconf.get('NODE_ENV') === 'production'; // TODO since Habitica runs on many different servers this module is pretty useless -// as it will only block requests that go to the same server +// as it will only block requests that go to the same server but anyway we should probably have a rate limiter in place module.exports = function(app) { - // TODO review later // disable the rate limiter middleware if (/*!IS_PROD || */true) return; app.use(limiter({ diff --git a/website/src/middlewares/forceRefresh.js b/website/src/middlewares/forceRefresh.js index 577ad2f4c6..f843694790 100644 --- a/website/src/middlewares/forceRefresh.js +++ b/website/src/middlewares/forceRefresh.js @@ -1,4 +1,4 @@ -// TODO do we need this module? +// TODO do we need this module anymore in v3? No module.exports.siteVersion = 1; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 681d238c16..caa823acc1 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -123,7 +123,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) { user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); } else { _.merge(matchingTask, _syncableAttrs(chalTask)); - // Make sure the task is in user.tasksOrder TODO necessary? + // Make sure the task is in user.tasksOrder let orderList = user.tasksOrder[`${chalTask.type}s`]; if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); } @@ -155,7 +155,7 @@ schema.methods.addTasks = async function challengeAddTasks (tasks) { let membersIds = await _fetchMembersIds(challenge._id); // Sync each user sequentially - // TODO are we sure it's the best solution? + // TODO are we sure it's the best solution? Use cwait // use bulk ops? http://stackoverflow.com/questions/16726330/mongoose-mongodb-batch-insert for (let memberId of membersIds) { let updateTasksOrderQ = {$push: {}}; diff --git a/website/src/models/group.js b/website/src/models/group.js index ae1c1318bb..17398e4909 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -420,7 +420,7 @@ schema.methods.finishQuest = function finishQuest (quest) { let updates = {$inc: {}, $set: {}}; updates.$inc[`achievements.quests.${questK}`] = 1; - updates.$inc['stats.gp'] = Number(quest.drop.gp); // TODO are this castings necessary? + updates.$inc['stats.gp'] = Number(quest.drop.gp); updates.$inc['stats.exp'] = Number(quest.drop.exp); updates.$inc._v = 1; @@ -530,7 +530,8 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { }, {multi: true}).exec(); // Apply changes the currently cronning user locally so we don't have to reload it to get the updated state // TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167 - // must be notModified or otherwise could overwrite future changes + // must be notModified or otherwise could overwrite future changes: if the user is saved it'll save + // the modified user.stats.hp but that must not happen as the hp value has already been updated by the User.update above // if (down) user.stats.hp += down; // Boss slain, finish quest diff --git a/website/src/models/task.js b/website/src/models/task.js index 4945f121f6..c7a8dca98c 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -39,7 +39,7 @@ export let TaskSchema = new Schema({ challenge: { id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task - taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task TODO unique index? + taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, winner: String, // user.profile.name of the winner }, @@ -149,7 +149,7 @@ export let Task = mongoose.model('Task', TaskSchema); // habits and dailies shared fields let habitDailySchema = () => { - return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems TODO revisit + return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems }; // dailys and todos shared fields @@ -197,7 +197,7 @@ export let daily = Task.discriminator('daily', DailySchema); export let TodoSchema = new Schema(_.defaults({ dateCompleted: Date, - // TODO we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date + // TODO we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date see http://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript date: String, // due date for todos }, dailyTodoSchema()), subDiscriminatorOptions); export let todo = Task.discriminator('todo', TodoSchema); diff --git a/website/src/models/user.js b/website/src/models/user.js index 4343c43d48..11d36e1e44 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -30,7 +30,7 @@ export let schema = new Schema({ local: { email: { type: String, - validate: [validator.isEmail, shared.i18n.t('invalidEmail')], // TODO translate error messages here, use preferences.language? + validate: [validator.isEmail, shared.i18n.t('invalidEmail')], }, username: { type: String, @@ -526,14 +526,14 @@ export let schema = new Schema({ schema.plugin(baseModel, { // TODO revisit a lot of things are missing. Given how many attributes we do have here we should white-list the ones that can be updated - // TODO this is a only used for creating an user, on update we use a whitelist + // This is not really used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...) noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations', 'balance', 'backer', 'contributor'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (plainObj, originalDoc) { - // plainObj.filters = {}; TODO Not saved - plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs TODO how to test? + // plainObj.filters = {}; TODO Not saved, remove? + plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs return plainObj; }, @@ -590,7 +590,7 @@ function _populateDefaultTasks (user, taskTypes) { return newTask.save(); }); - tasksToCreate.push(...tasksOfType); // TODO find better way since this creates each task individually + tasksToCreate.push(...tasksOfType); }); return Q.all(tasksToCreate) From 4e3d4c88310bea99454e701a076a05e885285fd2 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 7 May 2016 15:01:40 +0200 Subject: [PATCH 747/976] v3: more verbose logging in production and fix migration bugs --- migrations/api_v3/challengesMembers.js | 2 +- migrations/api_v3/groups.js | 6 ++- migrations/api_v3/indexes.js | 72 +++++++++++++------------- migrations/api_v3/users.js | 2 +- website/src/libs/api-v2/logging.js | 2 +- website/src/libs/api-v3/logger.js | 5 ++ 6 files changed, 48 insertions(+), 41 deletions(-) diff --git a/migrations/api_v3/challengesMembers.js b/migrations/api_v3/challengesMembers.js index 6281c129dc..b529e836e2 100644 --- a/migrations/api_v3/challengesMembers.js +++ b/migrations/api_v3/challengesMembers.js @@ -95,7 +95,7 @@ function processChallenges (afterId) { }); promises.push(newUserCollection.updateMany({ - _id: {$in: oldChallenge.members}, + _id: {$in: oldChallenge.members || []}, }, { $push: {challenges: oldChallenge._id}, }, {multi: true})); diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js index e67f9592fe..b87c0fa8df 100644 --- a/migrations/api_v3/groups.js +++ b/migrations/api_v3/groups.js @@ -102,9 +102,11 @@ function processGroups (afterId) { } oldGroups.forEach(function (oldGroup) { - if ((!oldGroup.privacy || oldGroup.privacy === 'private') && (!oldGroup.members || oldGroup.members.length === 0)) return; // delete empty private groups + if ((!oldGroup.privacy || oldGroup.privacy === 'private') && (!oldGroup.members || oldGroup.members.length === 0)) return; // delete empty private groups TODO must also delete challenges or this won't work + + oldGroup.members = oldGroup.members || []; oldGroup.memberCount = oldGroup.members ? oldGroup.members.length : 0; - oldGroup.memberCount = oldGroup.challenges ? oldGroup.challenges.length : 0; + oldGroup.challengeCount = oldGroup.challenges ? oldGroup.challenges.length : 0; if (!oldGroup.balance <= 0) oldGroup.balance = 0; if (!oldGroup.name) oldGroup.name = 'group name'; diff --git a/migrations/api_v3/indexes.js b/migrations/api_v3/indexes.js index 4944e375ec..07aaa21db8 100644 --- a/migrations/api_v3/indexes.js +++ b/migrations/api_v3/indexes.js @@ -1,52 +1,52 @@ /* DEFINE BEFORE MIGRATING - tasks: userId (sparse?), challenge.id (sparse), challenge.taskId (sparse), type? completed? + tasks: userId OK (sparse?), challenge.id OK (sparse?), challenge.taskId OK (sparse?), type? completed? users: - id & apiToken?, - auth.facebook.emails.value -> unique and sparse?, - auth.facebook.id - unique and sparse, - auth.local.email - unique and sparse, - auth.local.lowerCaseUsername, - auth.local.username - unique and sparse + id & apiToken, OK + auth.facebook.emails.value OK -> unique and sparse?, + auth.facebook.id - unique and sparse, OK + auth.local.email - unique and sparse, OK + auth.local.lowerCaseUsername, OK + auth.local.username - unique OK auth.local.username & auth.local.hashed_password?, - auth.timestamps.created?, - auth.timestamps.loggedin?, - backer.tier -1 + auth.timestamps.created?, OK + auth.timestamps.loggedin?, OK + backer.tier -1 OK { "contributor.admin" : 1 , "contributor.level" : -1 , "backer.npc" : -1 , "profile.name" : 1} - { "contributor.admin" : 1.0} - { "contributor.level" : 1.0} + { "contributor.admin" : 1.0} NO, see ^ + { "contributor.level" : 1.0} OK { "contributor.level" : 1.0 , "purchased.plan.customerId" : 1.0} ? - { "flags.lastWeeklyRecap" : 1 , "_id" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "preferences.emailNotifications.weeklyRecaps" : 1} - { "invitations.guilds.id" : 1} - { "invitations.party.id" : 1} - { "preferences.sleep" : 1 , "_id" : 1 , "flags.lastWeeklyRecap" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "preferences.emailNotifications.weeklyRecaps" : 1} - { "preferences.sleep" : 1 , "_id" : 1 , "lastCron" : 1 , "preferences.emailNotifications.importantAnnouncements" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "flags.recaptureEmailsPhase" : 1} - profile.name ? - { "purchased.plan.customerId" : 1.0} - { "purchased.plan.paymentMethod" : 1.0} + NO { "flags.lastWeeklyRecap" : 1 , "_id" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "preferences.emailNotifications.weeklyRecaps" : 1} + { "invitations.guilds.id" : 1} OK + { "invitations.party.id" : 1} OK + OK { "preferences.sleep" : 1 , "_id" : 1 , "flags.lastWeeklyRecap" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "preferences.emailNotifications.weeklyRecaps" : 1} + OK { "preferences.sleep" : 1 , "_id" : 1 , "lastCron" : 1 , "preferences.emailNotifications.importantAnnouncements" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "flags.recaptureEmailsPhase" : 1} + profile.name ? OK + { "purchased.plan.customerId" : 1.0} OK + { "purchased.plan.paymentMethod" : 1.0} OK - guilds - party.id - challenges + guilds OK + party.id OK + challenges OK challenges: - { "_id" : 1.0 , "__v" : 1.0} ? + { "_id" : 1.0 , "__v" : 1.0} ? NO { "_id" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} - { "group" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} - { "leader" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} - { "members" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} ? - { "official" : -1 , "timestamp" : -1} + { "group" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} OK + { "leader" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} OK + { "members" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} ? NO + { "official" : -1 , "timestamp" : -1} ? { "official" : -1 , "timestamp" : -1, "_id": 1} ? groups: - { "_id" : 1 , "quest.key" : 1} + { "_id" : 1 , "quest.key" : 1} ? { "_id" : 1.0 , "__v" : 1.0} ? - { "_id" : 1.0 , "privacy" : 1.0 , "members" : 1.0} ? - { "members" : 1.0 , "type" : 1.0 , "memberCount" : -1.0} ? - { "members" : 1} ? + { "_id" : 1.0 , "privacy" : 1.0 , "members" : 1.0} ? NO + { "members" : 1.0 , "type" : 1.0 , "memberCount" : -1.0} ? NO + { "members" : 1} ? NO { "privacy" : 1.0 , "memberCount" : -1.0} ? - { "privacy" : 1.0} ? + { "privacy" : 1.0} OK { "type" : 1 , "privacy" : 1} ? - { "type" : 1.0 , "members" : 1.0} ? - { "type" : 1} ? - emailUnsubscriptions: email unique + { "type" : 1.0 , "members" : 1.0} ? NO + { "type" : 1} ? OK + emailUnsubscriptions: email unique OK */ diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index 5962a4a494..be22aee5e0 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -171,7 +171,7 @@ function processUsers (afterId) { newUser.tasksOrder[`${oldTask.type}s`].push(oldTask._id); } - var allTasksFields = ['_id', 'type', 'text', 'notes', 'tags', 'value', 'priority', 'attribute', 'challenge', 'reminders']; + var allTasksFields = ['_id', 'type', 'text', 'notes', 'tags', 'value', 'priority', 'attribute', 'challenge', 'reminders', 'userId', 'legacyId']; // using mongoose models is too slow if (oldTask.type === 'habit') { oldTask = _.pick(oldTask, allTasksFields.concat(['history', 'up', 'down'])); diff --git a/website/src/libs/api-v2/logging.js b/website/src/libs/api-v2/logging.js index f832adb6d5..9737159f43 100644 --- a/website/src/libs/api-v2/logging.js +++ b/website/src/libs/api-v2/logging.js @@ -22,9 +22,9 @@ if (nconf.get('LOGGLY:enabled')){ if (!logger) { logger = new (winston.Logger)({}); + logger.add(winston.transports.Console, {colorize:true}); // TODO remove if (nconf.get('NODE_ENV') !== 'production') { - logger.add(winston.transports.Console, {colorize:true}); logger.add(winston.transports.File, {filename: 'habitrpg.log'}); } } diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index cc5d03f745..a840f279dd 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -11,6 +11,11 @@ const logger = new winston.Logger(); if (IS_PROD) { // TODO production logging, use loggly and new relic too // log errors to console too + logger + .add(winston.transports.Console, { + colorize: true, + prettyPrint: true, + }); } else if (IS_TEST) { // Do not log anything when testing } else { From 77d2d943aec442b1fd89756071f309ca87608d35 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 7 May 2016 17:35:13 +0200 Subject: [PATCH 748/976] v3: fix crashes when group or leader cannot be populated and fixes challenges migration for tavern challenges --- migrations/api_v3/challenges.js | 9 +++++++++ website/src/controllers/api-v2/challenges.js | 7 ++++--- website/src/controllers/api-v3/challenges.js | 15 +++++++++------ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index 85a019cc7d..77972172dd 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -112,6 +112,15 @@ function processChallenges (afterId) { if (!oldChallenge.group) throw new Error('challenge.group is required'); if (!oldChallenge.leader) throw new Error('challenge.leader is required'); + + if (oldChallenge.leader === '9') { + oldChallenge.leader = '00000000-0000-4000-9000-000000000000'; + } + + if (oldChallenge.group === 'habitrpg') { + oldChallenge.group = '00000000-0000-4000-A000-000000000000'; + } + delete oldChallenge.id; var newChallenge = new NewChallenge(oldChallenge); diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 51f57433c7..404fa379d1 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -61,8 +61,8 @@ api.list = async function(req, res, next) { User.findById(chal.leader).select(nameFields).exec(), Group.findById(chal.group).select(basicGroupFields).exec(), ]).then(populatedData => { - resChals[index].leader = populatedData[0].toJSON({minimize: true}); - resChals[index].group = populatedData[1].toJSON({minimize: true}); + resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null; + resChals[index].group = populatedData[1] ? populatedData[1].toJSON({minimize: true}) : null; }); })); @@ -88,7 +88,8 @@ api.get = async function(req, res, next) { let group = await Group.getGroup({user, groupId: challenge.group, optionalMembership: true}); if (!group || !challenge.canView(user, group)) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'}); - let leaderRes = (await User.findById(challenge.leader).select('profile.name').exec()).toJSON({minimize: true}); + let leaderRes = await User.findById(challenge.leader).select('profile.name').exec(); + leaderRes = leaderRes ? leaderRes.toJSON({minimize: true}) : null; challenge.getTransformedData({ populateMembers: 'profile.name', diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index f7bb61971f..b334dedc41 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -153,7 +153,8 @@ api.joinChallenge = { type: group.type, privacy: group.privacy, }; - response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + let chalLeader = await User.findById(response.leader).select(nameFields).exec(); + response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null; res.respond(200, response); }, @@ -233,8 +234,8 @@ api.getUserChallenges = { User.findById(chal.leader).select(nameFields).exec(), Group.findById(chal.group).select(basicGroupFields).exec(), ]).then(populatedData => { - resChals[index].leader = populatedData[0].toJSON({minimize: true}); - resChals[index].group = populatedData[1].toJSON({minimize: true}); + resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null; + resChals[index].group = populatedData[1] ? populatedData[1].toJSON({minimize: true}) : null; }); })); @@ -278,7 +279,7 @@ api.getGroupChallenges = { // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 await Q.all(resChals.map((chal, index) => { return User.findById(chal.leader).select(nameFields).exec().then(populatedLeader => { - resChals[index].leader = populatedLeader.toJSON({minimize: true}); + resChals[index].leader = populatedLeader ? populatedLeader.toJSON({minimize: true}) : null; }); })); @@ -322,7 +323,8 @@ api.getChallenge = { let chalRes = challenge.toJSON(); chalRes.group = group.toJSON({minimize: true}); // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - chalRes.leader = (await User.findById(chalRes.leader).select(nameFields).exec()).toJSON({minimize: true}); + let chalLeader = await User.findById(chalRes.leader).select(nameFields).exec(); + chalRes.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null; res.respond(200, chalRes); }, @@ -441,7 +443,8 @@ api.updateChallenge = { type: group.type, privacy: group.privacy, }; - response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + let chalLeader = await User.findById(response.leader).select(nameFields).exec(); + response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null; res.respond(200, response); }, }; From e19130bd8c455e6c81e4afd0646cd6614f155e00 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 8 May 2016 15:50:38 +0200 Subject: [PATCH 749/976] fix typo custsomerId -> customerId --- common/script/fns/randomDrop.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/script/fns/randomDrop.js b/common/script/fns/randomDrop.js index 102709da57..92064e62f1 100644 --- a/common/script/fns/randomDrop.js +++ b/common/script/fns/randomDrop.js @@ -49,7 +49,7 @@ module.exports = function randomDrop (user, modifiers, req = {}) { user.markModified('party.quest.progress'); } - if (user.purchased && user.purchased.plan && user.purchased.plan.custsomerId) { + if (user.purchased && user.purchased.plan && user.purchased.plan.customerId) { dropMultiplier = 2; } else { dropMultiplier = 1; From a7c6457d790d0a4060afd9baeb69761e2d6d783e Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 8 May 2016 09:40:08 -0500 Subject: [PATCH 750/976] Removed dateCreated --- website/public/js/services/taskServices.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/js/services/taskServices.js b/website/public/js/services/taskServices.js index cc842c51b0..0135e4923f 100644 --- a/website/public/js/services/taskServices.js +++ b/website/public/js/services/taskServices.js @@ -1,7 +1,7 @@ 'use strict'; (function(){ - var TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'dateCreated', 'history', 'id', 'streak', 'createdAt']; + var TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'history', 'id', 'streak', 'createdAt']; angular .module('habitrpg') From 4c37417bd494d11efbe8245761176f729fb211c9 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 8 May 2016 09:52:43 -0500 Subject: [PATCH 751/976] Updated task service to use api v3 (#7136) * Updated task service to use api v3 * Add user.ops functions back * Removed extra parameter --- test/spec/services/taskServicesSpec.js | 141 ++++++++++++++- website/public/js/app.js | 24 ++- .../public/js/controllers/challengesCtrl.js | 26 ++- website/public/js/controllers/partyCtrl.js | 4 +- website/public/js/controllers/tasksCtrl.js | 64 ++++--- website/public/js/services/taskServices.js | 161 ++++++++++++++++-- website/views/shared/tasks/edit/tags.jade | 2 +- website/views/shared/tasks/meta_controls.jade | 2 +- .../views/shared/tasks/task_view/add_new.jade | 2 +- 9 files changed, 363 insertions(+), 63 deletions(-) diff --git a/test/spec/services/taskServicesSpec.js b/test/spec/services/taskServicesSpec.js index 212ba816f9..45f1ba8607 100644 --- a/test/spec/services/taskServicesSpec.js +++ b/test/spec/services/taskServicesSpec.js @@ -1,22 +1,147 @@ 'use strict'; describe('Tasks Service', function() { - var rootScope, tasks, user; + var rootScope, tasks, user, $httpBackend; + var apiV3Prefix = 'api/v3/tasks'; beforeEach(function() { - module(function($provide) { user = specHelper.newUser(); $provide.value('User', {user: user}); }); - inject(function(_$rootScope_, Tasks, User) { + inject(function(_$httpBackend_, _$rootScope_, Tasks, User) { + $httpBackend = _$httpBackend_; rootScope = _$rootScope_; rootScope.charts = {}; tasks = Tasks; }); }); + it('calls get user tasks endpoint', function() { + $httpBackend.expectGET(apiV3Prefix + '/user').respond({}); + tasks.getUserTasks(); + $httpBackend.flush(); + }); + + it('calls post user tasks endpoint', function() { + $httpBackend.expectPOST(apiV3Prefix + '/user').respond({}); + tasks.createUserTasks(); + $httpBackend.flush(); + }); + + it('calls get challenge tasks endpoint', function() { + var challengeId = 1; + $httpBackend.expectGET(apiV3Prefix + '/challenge/' + challengeId).respond({}); + tasks.getChallengeTasks(challengeId); + $httpBackend.flush(); + }); + + it('calls create challenge tasks endpoint', function() { + var challengeId = 1; + $httpBackend.expectPOST(apiV3Prefix + '/challenge/' + challengeId).respond({}); + tasks.createChallengeTasks(challengeId, {}); + $httpBackend.flush(); + }); + + it('calls get task endpoint', function() { + var taskId = 1; + $httpBackend.expectGET(apiV3Prefix + '/' + taskId).respond({}); + tasks.getTask(taskId); + $httpBackend.flush(); + }); + + it('calls update task endpoint', function() { + var taskId = 1; + $httpBackend.expectPUT(apiV3Prefix + '/' + taskId).respond({}); + tasks.updateTask(taskId, {}); + $httpBackend.flush(); + }); + + it('calls delete task endpoint', function() { + var taskId = 1; + $httpBackend.expectDELETE(apiV3Prefix + '/' + taskId).respond({}); + tasks.deleteTask(taskId); + $httpBackend.flush(); + }); + + it('calls score task endpoint', function() { + var taskId = 1; + var direction = "down"; + $httpBackend.expectPOST(apiV3Prefix + '/' + taskId + '/score/' + direction).respond({}); + tasks.scoreTask(taskId, direction); + $httpBackend.flush(); + }); + + it('calls move task endpoint', function() { + var taskId = 1; + var position = 0; + $httpBackend.expectPOST(apiV3Prefix + '/' + taskId + '/move/to/' + position).respond({}); + tasks.moveTask(taskId, position); + $httpBackend.flush(); + }); + + it('calls add check list item endpoint', function() { + var taskId = 1; + $httpBackend.expectPOST(apiV3Prefix + '/' + taskId + '/checklist').respond({}); + tasks.addChecklistItem(taskId, {}); + $httpBackend.flush(); + }); + + it('calls score check list item endpoint', function() { + var taskId = 1; + var itemId = 2; + $httpBackend.expectPOST(apiV3Prefix + '/' + taskId + '/checklist/' + itemId + '/score').respond({}); + tasks.scoreCheckListItem(taskId, itemId); + $httpBackend.flush(); + }); + + it('calls update check list item endpoint', function() { + var taskId = 1; + var itemId = 2; + $httpBackend.expectPUT(apiV3Prefix + '/' + taskId + '/checklist/' + itemId).respond({}); + tasks.updateChecklistItem(taskId, itemId, {}); + $httpBackend.flush(); + }); + + it('calls remove check list item endpoint', function() { + var taskId = 1; + var itemId = 2; + $httpBackend.expectDELETE(apiV3Prefix + '/' + taskId + '/checklist/' + itemId).respond({}); + tasks.removeChecklistItem(taskId, itemId); + $httpBackend.flush(); + }); + + it('calls add tag to list item endpoint', function() { + var taskId = 1; + var tagId = 2; + $httpBackend.expectPOST(apiV3Prefix + '/' + taskId + '/tags/' + tagId).respond({}); + tasks.addTagToTask(taskId, tagId); + $httpBackend.flush(); + }); + + it('calls remove tag to list item endpoint', function() { + var taskId = 1; + var tagId = 2; + $httpBackend.expectDELETE(apiV3Prefix + '/' + taskId + '/tags/' + tagId).respond({}); + tasks.removeTagFromTask(taskId, tagId); + $httpBackend.flush(); + }); + + it('calls unlink task endpoint', function() { + var taskId = 1; + var keep = "keep-all"; + $httpBackend.expectPOST(apiV3Prefix + '/unlink/' + taskId + '?keep=' + keep).respond({}); + tasks.unlinkTask(taskId); + $httpBackend.flush(); + }); + + it('calls clear completed todo task endpoint', function() { + $httpBackend.expectPOST(apiV3Prefix + '/clearCompletedTodos').respond({}); + tasks.clearCompletedTodos(); + $httpBackend.flush(); + }); + describe('editTask', function() { var task; @@ -82,24 +207,22 @@ describe('Tasks Service', function() { expect(clonedTask.attribute).to.eql(task.attribute); }); - it('does not clone original task\'s id or _id', function() { + it('does not clone original task\'s _id', function() { var task = specHelper.newTask(); var clonedTask = tasks.cloneTask(task); - expect(clonedTask.id).to.exist; - expect(clonedTask.id).to.not.eql(task.id); expect(clonedTask._id).to.exist; expect(clonedTask._id).to.not.eql(task._id); }); it('does not clone original task\'s dateCreated attribute', function() { var task = specHelper.newTask({ - dateCreated: new Date(2014, 5, 1, 1, 1, 1, 1), + createdAt: new Date(2014, 5, 1, 1, 1, 1, 1), }); var clonedTask = tasks.cloneTask(task); - expect(clonedTask.dateCreated).to.exist; - expect(clonedTask.dateCreated).to.not.eql(task.dateCreated); + expect(clonedTask.createdAt).to.exist; + expect(clonedTask.createdAt).to.not.eql(task.createdAt); }); it('does not clone original task\'s value', function() { diff --git a/website/public/js/app.js b/website/public/js/app.js index b8beb61d4e..147b2a7465 100644 --- a/website/public/js/app.js +++ b/website/public/js/app.js @@ -180,12 +180,20 @@ window.habitrpg = angular.module('habitrpg', url: '/:cid', templateUrl: 'partials/options.social.challenges.detail.html', title: env.t('titleChallenges'), - controller: ['$scope', 'Challenges', '$stateParams', - function ($scope, Challenges, $stateParams) { + controller: ['$scope', 'Challenges', '$stateParams', 'Tasks', + function ($scope, Challenges, $stateParams, Tasks) { Challenges.getChallenge($stateParams.cid) .then(function (response) { $scope.obj = $scope.challenge = response.data.data; $scope.challenge._locked = true; + return Tasks.getChallengeTasks($scope.challenge._id); + }) + .then(function (response) { + var tasks = response.data.data; + tasks.forEach(function (element, index, array) { + if (!$scope.challenge[element.type + 's']) $scope.challenge[element.type + 's'] = []; + $scope.challenge[element.type + 's'].push(element); + }) }); }] }) @@ -193,12 +201,20 @@ window.habitrpg = angular.module('habitrpg', url: '/:cid/edit', templateUrl: 'partials/options.social.challenges.detail.html', title: env.t('titleChallenges'), - controller: ['$scope', 'Challenges', '$stateParams', - function ($scope, Challenges, $stateParams) { + controller: ['$scope', 'Challenges', '$stateParams', 'Tasks', + function ($scope, Challenges, $stateParams, Tasks) { Challenges.getChallenge($stateParams.cid) .then(function (response) { $scope.obj = $scope.challenge = response.data.data; $scope.challenge._locked = false; + return Tasks.getChallengeTasks($scope.challenge._id); + }) + .then(function (response) { + var tasks = response.data.data; + tasks.forEach(function (element, index, array) { + if (!$scope.challenge[element.type + 's']) $scope.challenge[element.type + 's'] = []; + $scope.challenge[element.type + 's'].push(element); + }) }); }] }) diff --git a/website/public/js/controllers/challengesCtrl.js b/website/public/js/controllers/challengesCtrl.js index d8821c607c..f0ddbe8a54 100644 --- a/website/public/js/controllers/challengesCtrl.js +++ b/website/public/js/controllers/challengesCtrl.js @@ -132,9 +132,16 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { reload: true, inherit: false, notify: true }); + + var challengeTasks = []; + challengeTasks.concat(challenge.todos); + challengeTasks.concat(challenge.habits); + challengeTasks.concat(challenge.dailys); + challengeTasks.concat(challenge.reqards); + Tasks.createChallengeTasks(_challenge._id, challengeTasks); }); } else { - Challenges.updateChallenge(challenge) + Challenges.updateChallenge(challenge._id, challenge) .then(function (response) { var _challenge = response.data.data; $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { @@ -223,19 +230,19 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', //------------------------------------------------------------ // Tasks //------------------------------------------------------------ - - $scope.addTask = function(addTo, listDef) { + $scope.addTask = function(addTo, listDef, challenge) { var task = Shared.taskDefaults({text: listDef.newTask, type: listDef.type}); - addTo.unshift(task); - //User.log({op: "addTask", data: task}); //TODO persist + Tasks.createChallengeTasks(challenge._id, task); + if (!challenge[task.type + 's']) challenge[task.type + 's'] = []; + challenge[task.type + 's'].push(task); delete listDef.newTask; }; $scope.removeTask = function(task, list) { if (!confirm(window.env.t('sureDelete', {taskType: window.env.t(task.type), taskText: task.text}))) return; - //TODO persist - // User.log({op: "delTask", data: task}); - _.remove(list, task); + Tasks.deleteTask(task._id); + var index = challenge[task.type + 's'].indexOf(task); + challenge[task.type + 's'].splice(index, 1); }; $scope.saveTask = function(task){ @@ -407,7 +414,8 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', function _getChallenges() { if ($scope.cid) { Challenges.getChallenge($scope.cid) - .then(function (challenge) { + .then(function (response) { + var challenge = response.data.data; $scope.challenges = [challenge]; }); } else { diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js index 39cdb4dfce..4ed77885f7 100644 --- a/website/public/js/controllers/partyCtrl.js +++ b/website/public/js/controllers/partyCtrl.js @@ -46,7 +46,9 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' } } - Chat.markChatSeen($scope.group._id); + if ($scope.group && $scope.group._id) { + Chat.markChatSeen($scope.group._id); + } $scope.create = function(group) { if (!group.name) group.name = env.t('possessiveParty', {name: User.user.profile.name}); diff --git a/website/public/js/controllers/tasksCtrl.js b/website/public/js/controllers/tasksCtrl.js index 619b0a8b3f..5e86a243e3 100644 --- a/website/public/js/controllers/tasksCtrl.js +++ b/website/public/js/controllers/tasksCtrl.js @@ -33,10 +33,11 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N var newTask = { text: task, type: listDef.type, - tags: _.transform(User.user.filters, function(m,v,k){ - if (v) m[k]=v; - }) + tags: _.transform(User.user.filters, function(m, v, k) { + if (v) m.push(v); + }), }; + User.user.ops.addTask({body:newTask}); } @@ -70,7 +71,9 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N /** * Add the new task to the actions log */ - $scope.clearDoneTodos = function() {}; + $scope.clearDoneTodos = function() { + Tasks.clearCompletedTodos(); + }; /** * Pushes task to top or bottom of list @@ -97,12 +100,20 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N }; $scope.saveTask = function(task, stayOpen, isSaveAndClose) { - if (task.checklist) - task.checklist = _.filter(task.checklist,function(i){return !!i.text}); + //@TODO: We will need to fix tag saving when user service is ported since tags are attached at the user level + + if (task.checklist) { + task.checklist = _.filter(task.checklist, function(i) {return !!i.text}); + } + User.user.ops.updateTask({params:{id:task.id},body:task}); + if (!stayOpen) task._editing = false; - if (isSaveAndClose) + + if (isSaveAndClose) { $("#task-" + task.id).parent().children('.popover').removeClass('in'); + } + if (task.type == 'habit') Guide.goto('intro', 3); }; @@ -120,9 +131,8 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N }; $scope.unlink = function(task, keep) { - // TODO move this to userServices, turn userSerivces.user into ng-resource - $http.post(ApiUrl.get() + '/api/v2/user/tasks/' + task.id + '/unlink?keep=' + keep) - .success(function(){ + Tasks.unlinkTask(task.id, keep) + .success(function () { User.log({}); }); }; @@ -157,50 +167,57 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $('#task-'+task.id+' .checklist-form input[type="text"]')[index].focus(); }); } + $scope.addChecklist = function(task) { - task.checklist = [{completed:false,text:""}]; + task.checklist = [{completed:false, text:""}]; focusChecklist(task,0); } - $scope.addChecklistItem = function(task,$event,$index) { + + $scope.addChecklistItem = function(task, $event, $index) { if (!task.checklist[$index].text) { // Don't allow creation of an empty checklist item // TODO Provide UI feedback that this item is still blank - } else if ($index == task.checklist.length-1){ - User.user.ops.updateTask({params:{id:task.id},body:task}); // don't preen the new empty item - task.checklist.push({completed:false,text:''}); - focusChecklist(task,task.checklist.length-1); + } else if ($index == task.checklist.length - 1) { + User.user.ops.updateTask({params:{id:task.id},body:task}); + task.checklist.push({completed: false, text: ''}); + focusChecklist(task, task.checklist.length - 1); } else { - $scope.saveTask(task,true); - focusChecklist(task,$index+1); + $scope.saveTask(task, true); + focusChecklist(task, $index + 1); } } - $scope.removeChecklistItem = function(task,$event,$index,force){ + + $scope.removeChecklistItem = function(task, $event, $index, force){ // Remove item if clicked on trash icon if (force) { - task.checklist.splice($index,1); - $scope.saveTask(task,true); + Tasks.removeChecklistItem(task.id, task.checklist[$index]._id); + task.checklist.splice($index, 1); } else if (!task.checklist[$index].text) { // User deleted all the text and is now wishing to delete the item // saveTask will prune the empty item - $scope.saveTask(task,true); + Tasks.removeChecklistItem(task.id, task.checklist[$index]._id); // Move focus if the list is still non-empty if ($index > 0) - focusChecklist(task,$index-1); + focusChecklist(task, $index-1); // Don't allow the backspace key to navigate back now that the field is gone $event.preventDefault(); } } + $scope.swapChecklistItems = function(task, oldIndex, newIndex) { var toSwap = task.checklist.splice(oldIndex, 1)[0]; task.checklist.splice(newIndex, 0, toSwap); $scope.saveTask(task, true); } + $scope.navigateChecklist = function(task,$index,$event){ focusChecklist(task, $event.keyCode == '40' ? $index+1 : $index-1); } + $scope.checklistCompletion = function(checklist){ return _.reduce(checklist,function(m,i){return m+(i.completed ? 1 : 0);},0) } + $scope.collapseChecklist = function(task) { task.collapseChecklist = !task.collapseChecklist; $scope.saveTask(task,true); @@ -224,7 +241,6 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N User.user.ops.buy({params:{key:item.key}}); }; - /* ------------------------ Hiding Tasks diff --git a/website/public/js/services/taskServices.js b/website/public/js/services/taskServices.js index bbe7f6f689..28d195c550 100644 --- a/website/public/js/services/taskServices.js +++ b/website/public/js/services/taskServices.js @@ -1,19 +1,138 @@ 'use strict'; -(function(){ - var TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'dateCreated', 'history', 'id', 'streak']; +var TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'dateCreated', 'history', 'id', 'streak', 'createdAt']; - angular - .module('habitrpg') - .factory('Tasks', tasksFactory); +angular.module('habitrpg') +.factory('Tasks', ['$rootScope', 'Shared', 'User', '$http', + function tasksFactory($rootScope, Shared, User, $http) { - tasksFactory.$inject = [ - '$rootScope', - 'Shared', - 'User' - ]; + function getUserTasks () { + return $http({ + method: 'GET', + url: 'api/v3/tasks/user', + }); + }; - function tasksFactory($rootScope, Shared, User) { + function createUserTasks (taskDetails) { + return $http({ + method: 'POST', + url: 'api/v3/tasks/user', + data: taskDetails, + }); + }; + + function getChallengeTasks (challengeId) { + return $http({ + method: 'GET', + url: 'api/v3/tasks/challenge/' + challengeId, + }); + }; + + function createChallengeTasks (challengeId, taskDetails) { + return $http({ + method: 'POST', + url: 'api/v3/tasks/challenge/' + challengeId, + data: taskDetails, + }); + }; + + function getTask (taskId) { + return $http({ + method: 'GET', + url: 'api/v3/tasks/' + taskId, + }); + }; + + function updateTask (taskId, taskDetails) { + return $http({ + method: 'PUT', + url: 'api/v3/tasks/' + taskId, + data: taskDetails, + }); + }; + + function deleteTask (taskId) { + return $http({ + method: 'DELETE', + url: 'api/v3/tasks/' + taskId, + }); + }; + + function scoreTask (taskId, direction) { + return $http({ + method: 'POST', + url: 'api/v3/tasks/' + taskId + '/score/' + direction, + }); + }; + + function moveTask (taskId, position) { + return $http({ + method: 'POST', + url: 'api/v3/tasks/' + taskId + '/move/to/' + position, + }); + }; + + function addChecklistItem (taskId, checkListItem) { + return $http({ + method: 'POST', + url: 'api/v3/tasks/' + taskId + '/checklist', + data: checkListItem, + }); + }; + + function scoreCheckListItem (taskId, itemId) { + return $http({ + method: 'POST', + url: 'api/v3/tasks/' + taskId + '/checklist/' + itemId + '/score', + }); + }; + + function updateChecklistItem (taskId, itemId, itemDetails) { + return $http({ + method: 'PUT', + url: 'api/v3/tasks/' + taskId + '/checklist/' + itemId, + data: itemDetails, + }); + }; + + function removeChecklistItem (taskId, itemId) { + return $http({ + method: 'DELETE', + url: 'api/v3/tasks/' + taskId + '/checklist/' + itemId, + }); + }; + + function addTagToTask (taskId, tagId) { + return $http({ + method: 'POST', + url: 'api/v3/tasks/' + taskId + '/tags/' + tagId, + }); + }; + + function removeTagFromTask (taskId, tagId) { + return $http({ + method: 'DELETE', + url: 'api/v3/tasks/' + taskId + '/tags/' + tagId, + }); + }; + + function unlinkTask (taskId, keep) { + if (!keep) { + keep = "keep-all"; + } + + return $http({ + method: 'POST', + url: 'api/v3/tasks/unlink/' + taskId + '?keep=' + keep, + }); + }; + + function clearCompletedTodos () { + return $http({ + method: 'POST', + url: 'api/v3/tasks/clearCompletedTodos', + }); + }; function editTask(task) { task._editing = !task._editing; @@ -46,8 +165,24 @@ } return { + getUserTasks: getUserTasks, + createUserTasks: createUserTasks, + getChallengeTasks: getChallengeTasks, + createChallengeTasks: createChallengeTasks, + getTask: getTask, + updateTask: updateTask, + deleteTask: deleteTask, + scoreTask: scoreTask, + moveTask: moveTask, + addChecklistItem: addChecklistItem, + scoreCheckListItem: scoreCheckListItem, + updateChecklistItem: updateChecklistItem, + removeChecklistItem: removeChecklistItem, + addTagToTask: addTagToTask, + removeTagFromTask: removeTagFromTask, + unlinkTask: unlinkTask, + clearCompletedTodos: clearCompletedTodos, editTask: editTask, cloneTask: cloneTask }; - } -})(); + }]); diff --git a/website/views/shared/tasks/edit/tags.jade b/website/views/shared/tasks/edit/tags.jade index 089aa0c408..0e9283fdb2 100644 --- a/website/views/shared/tasks/edit/tags.jade +++ b/website/views/shared/tasks/edit/tags.jade @@ -1,5 +1,5 @@ fieldset.option-group(ng-if='!$state.includes("options.social.challenges")') p.option-title.mega(ng-class='{active: task._tags}', ng-click='task._tags = !task._tags', tooltip=env.t('expandCollapse'))=env.t('tags') label.checkbox(ng-repeat='tag in user.tags', ng-if='task._tags') - input(type='checkbox', ng-model='task.tags[tag.id]') + input(type='checkbox', ng-model='task.tags[tag.id]', ng-checked="task.tags.indexOf(tag.id) !== -1") markdown(text='tag.name') diff --git a/website/views/shared/tasks/meta_controls.jade b/website/views/shared/tasks/meta_controls.jade index 733b37d23a..560010deb1 100644 --- a/website/views/shared/tasks/meta_controls.jade +++ b/website/views/shared/tasks/meta_controls.jade @@ -43,7 +43,7 @@ span.glyphicon.glyphicon-bullhorn(tooltip=env.t('challenge')) |   // delete - a(ng-if='!task.challenge.id', ng-click='removeTask(task, obj[list.type+"s"])', tooltip=env.t('delete')) + a(ng-if='!task.challenge.id', ng-click='removeTask(task, $index)', tooltip=env.t('delete')) span.glyphicon.glyphicon-trash |   diff --git a/website/views/shared/tasks/task_view/add_new.jade b/website/views/shared/tasks/task_view/add_new.jade index f9baf76499..2d4fd1c869 100644 --- a/website/views/shared/tasks/task_view/add_new.jade +++ b/website/views/shared/tasks/task_view/add_new.jade @@ -1,4 +1,4 @@ -form.task-add(name='new{{list.type}}form', ng-hide='obj._locked', ng-submit='addTask(obj[list.type+"s"],list)', novalidate) +form.task-add(name='new{{list.type}}form', ng-hide='obj._locked', ng-submit='addTask(obj[list.type+"s"], list, obj)', novalidate) textarea(rows='6', focus-element='list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolderBulk}}', ng-if='list.bulk', ui-keydown='{"meta-enter ctrl-enter":"addTask(obj[list.type+\'s\'],list)"}', required) input(type='text', focus-element='!list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolder}}', ng-if='!list.bulk', required) button(type='submit', ng-disabled='new{{list.type}}form.$invalid') From e747bba669099cc468f9dfdff7a3f8ab143db7d5 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 8 May 2016 11:35:38 -0500 Subject: [PATCH 752/976] Updated add and remove tests for challenges (#7155) --- test/spec/controllers/challengesCtrlSpec.js | 52 +++++++++++++------ .../public/js/controllers/challengesCtrl.js | 4 +- website/views/shared/tasks/edit/index.jade | 2 +- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/test/spec/controllers/challengesCtrlSpec.js b/test/spec/controllers/challengesCtrlSpec.js index e465336d53..ab96b6fb8c 100644 --- a/test/spec/controllers/challengesCtrlSpec.js +++ b/test/spec/controllers/challengesCtrlSpec.js @@ -210,6 +210,17 @@ describe('Challenges Controller', function() { }); describe('addTask', function() { + var challenge; + + beforeEach(function () { + challenge = specHelper.newChallenge({ + description: 'You are the owner and member', + leader: user._id, + members: [user], + _isMember: true + }); + }); + it('adds default task to array', function() { var taskArray = []; var listDef = { @@ -217,26 +228,27 @@ describe('Challenges Controller', function() { type: 'todo' } - scope.addTask(taskArray, listDef); + scope.addTask(taskArray, listDef, challenge); - expect(taskArray.length).to.eql(1); - expect(taskArray[0].text).to.eql('new todo text'); - expect(taskArray[0].type).to.eql('todo'); + expect(challenge['todos'].length).to.eql(1); + expect(challenge['todos'][0].text).to.eql('new todo text'); + expect(challenge['todos'][0].type).to.eql('todo'); }); it('adds the task to the front of the array', function() { var previousTask = specHelper.newTodo({ text: 'previous task' }); - var taskArray = [previousTask]; + var taskArray = []; + challenge['todos'] = [previousTask]; var listDef = { newTask: 'new todo', type: 'todo' } - scope.addTask(taskArray, listDef); + scope.addTask(taskArray, listDef, challenge); - expect(taskArray.length).to.eql(2); - expect(taskArray[0].text).to.eql('new todo'); - expect(taskArray[1].text).to.eql('previous task'); + expect(challenge['todos'].length).to.eql(2); + expect(challenge['todos'][0].text).to.eql('new todo'); + expect(challenge['todos'][1].text).to.eql('previous task'); }); it('removes text from new task input box', function() { @@ -246,7 +258,7 @@ describe('Challenges Controller', function() { type: 'todo' } - scope.addTask(taskArray, listDef); + scope.addTask(taskArray, listDef, challenge); expect(listDef.newTask).to.not.exist; }); @@ -261,31 +273,37 @@ describe('Challenges Controller', function() { }); describe('removeTask', function() { - var task, list; + var task, challenge; beforeEach(function() { sandbox.stub(window, 'confirm'); task = specHelper.newTodo(); - list = [task]; + challenge = specHelper.newChallenge({ + description: 'You are the owner and member', + leader: user._id, + members: [user], + _isMember: true + }); + challenge['todos'] = [task]; }); it('asks user to confirm deletion', function() { - scope.removeTask(task, list); + scope.removeTask(task, challenge); expect(window.confirm).to.be.calledOnce; }); it('does not remove task from list if not confirmed', function() { window.confirm.returns(false); - scope.removeTask(task, list); + scope.removeTask(task, challenge); - expect(list).to.include(task); + expect(challenge['todos']).to.include(task); }); it('removes task from list', function() { window.confirm.returns(true); - scope.removeTask(task, list); + scope.removeTask(task, challenge); - expect(list).to.not.include(task); + expect(challenge['todos']).to.not.include(task); }); }); diff --git a/website/public/js/controllers/challengesCtrl.js b/website/public/js/controllers/challengesCtrl.js index 00760c9c14..78706c9608 100644 --- a/website/public/js/controllers/challengesCtrl.js +++ b/website/public/js/controllers/challengesCtrl.js @@ -234,11 +234,11 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', var task = Shared.taskDefaults({text: listDef.newTask, type: listDef.type}); Tasks.createChallengeTasks(challenge._id, task); if (!challenge[task.type + 's']) challenge[task.type + 's'] = []; - challenge[task.type + 's'].push(task); + challenge[task.type + 's'].unshift(task); delete listDef.newTask; }; - $scope.removeTask = function(task, list) { + $scope.removeTask = function(task, challenge) { if (!confirm(window.env.t('sureDelete', {taskType: window.env.t(task.type), taskText: task.text}))) return; Tasks.deleteTask(task._id); var index = challenge[task.type + 's'].indexOf(task); diff --git a/website/views/shared/tasks/edit/index.jade b/website/views/shared/tasks/edit/index.jade index ce29ee8896..952fbdbb17 100644 --- a/website/views/shared/tasks/edit/index.jade +++ b/website/views/shared/tasks/edit/index.jade @@ -8,7 +8,7 @@ div(ng-if='task._editing') p a(ng-click='unlink(task, "keep")')=env.t('keepIt') |    - a(ng-click="removeTask(task, obj[list.type+'s'])")=env.t('removeIt') + a(ng-click="removeTask(task, obj")=env.t('removeIt') div(ng-if='task.challenge.broken=="CHALLENGE_DELETED"') p |  From 0114e310eb72122bdd6e4f38332f208e4d238248 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 9 May 2016 22:58:15 +0200 Subject: [PATCH 753/976] v3 payments: working IAP and Stripe, move paypalBillingSetup to its own file, closeChal is now a challenge instance method --- .eslintignore | 1 + migrations/api_v3/challenges.js | 2 + migrations/api_v3/users.js | 2 + scripts/paypalBillingSetup.js | 94 +++++++ website/src/controllers/api-v2/challenges.js | 6 +- website/src/controllers/api-v3/auth.js | 3 - website/src/controllers/api-v3/challenges.js | 65 +---- website/src/controllers/api-v3/chat.js | 3 - .../controllers/top-level/payments/amazon.js | 24 +- .../src/controllers/top-level/payments/iap.js | 249 ++++++++++-------- .../controllers/top-level/payments/paypal.js | 36 ++- .../top-level/payments/paypalBillingSetup.js | 98 ------- .../controllers/top-level/payments/stripe.js | 84 +++--- website/src/libs/api-v3/amazonPayments.js | 16 +- website/src/libs/api-v3/payments.js | 38 +-- .../src/middlewares/api-v3/errorHandler.js | 6 + website/src/middlewares/api-v3/v2.js | 2 - website/src/models/challenge.js | 66 +++++ 18 files changed, 414 insertions(+), 381 deletions(-) create mode 100644 scripts/paypalBillingSetup.js delete mode 100644 website/src/controllers/top-level/payments/paypalBillingSetup.js diff --git a/.eslintignore b/.eslintignore index eaef8f567d..b4830a328f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -22,6 +22,7 @@ website/src/middlewares/apiThrottle.js website/src/middlewares/forceRefresh.js debug-scripts/* +scripts/* tasks/*.js gulpfile.js Gruntfile.js diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index 77972172dd..727492c04a 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -143,6 +143,8 @@ function processChallenges (afterId) { oldTask.tags = _.map(oldTask.tags || {}, function (tagPresent, tagId) { return tagPresent && tagId; + }).filter(function (tag) { + return tag !== false; }); if (!oldTask.text) oldTask.text = 'task text'; // required diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index be22aee5e0..13db28e768 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -165,6 +165,8 @@ function processUsers (afterId) { if (!oldTask.text) oldTask.text = 'task text'; // required oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) { return tagPresent && tagId; + }).filter(function (tag) { + return tag !== false; }); if (oldTask.type !== 'todo' || (oldTask.type === 'todo' && !oldTask.completed)) { diff --git a/scripts/paypalBillingSetup.js b/scripts/paypalBillingSetup.js new file mode 100644 index 0000000000..d21cd80c1c --- /dev/null +++ b/scripts/paypalBillingSetup.js @@ -0,0 +1,94 @@ +// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring +// payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this +// file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), +// and once for any time you need to edit the plan thereafter + +var path = require('path'); +var nconf = require('nconf'); +var _ = require('lodash'); +var paypal = require('paypal-rest-sdk'); +var blocks = require('../../../../common').content.subscriptionBlocks; +var live = nconf.get('PAYPAL:mode')=='live'; + +nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); + +var OP = 'create'; // list create update remove + +paypal.configure({ + 'mode': nconf.get("PAYPAL:mode"), //sandbox or live + 'client_id': nconf.get("PAYPAL:client_id"), + 'client_secret': nconf.get("PAYPAL:client_secret") +}); + +// https://developer.paypal.com/docs/api/#billing-plans-and-agreements +var billingPlanTitle ="Habitica Subscription"; +var billingPlanAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "type": "INFINITE", + "merchant_preferences": { + "auto_bill_amount": "yes", + "cancel_url": live ? 'https://habitica.com' : 'http://localhost:3000', + "return_url": (live ? 'https://habitica.com' : 'http://localhost:3000') + '/paypal/subscribe/success' + }, + payment_definitions: [{ + "type": "REGULAR", + "frequency": "MONTH", + "cycles": "0" + }] +}; +_.each(blocks, function(block){ + block.definition = _.cloneDeep(billingPlanAttributes); + _.merge(block.definition.payment_definitions[0], { + "name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)', + "frequency_interval": ""+block.months, + "amount": { + "currency": "USD", + "value": ""+block.price + } + }); +}) + +switch(OP) { + case "list": + paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){ + console.log({err:err, plans:plans}); + }); + break; + case "get": + paypal.billingPlan.get(nconf.get("PAYPAL:billing_plans:12"), function (err, plan) { + console.log({err:err, plan:plan}); + }) + break; + case "update": + var update = { + "op": "replace", + "path": "/merchant_preferences", + "value": { + "cancel_url": "https://habitica.com" + } + }; + paypal.billingPlan.update(nconf.get("PAYPAL:billing_plans:12"), update, function (err, res) { + console.log({err:err, plan:res}); + }); + break; + case "create": + paypal.billingPlan.create(blocks["google_6mo"].definition, function(err,plan){ + if (err) return console.log(err); + if (plan.state == "ACTIVE") + return console.log({err:err, plan:plan}); + var billingPlanUpdateAttributes = [{ + "op": "replace", + "path": "/", + "value": { + "state": "ACTIVE" + } + }]; + // Activate the plan by changing status to Active + paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ + console.log({err:err, response:response, id:plan.id}); + }); + }); + break; + case "remove": break; +} diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 404fa379d1..aff7b53a80 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -289,8 +289,6 @@ api.update = function(req, res, next){ }); } -import { _closeChal } from '../api-v3/challenges'; - /** * Delete & close */ @@ -304,7 +302,7 @@ api.delete = async function(req, res, next){ if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge')); // Close channel in background, some ops are run in the background without `await`ing - await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); + await challenge.closeChal({broken: 'CHALLENGE_DELETED'}); res.sendStatus(200); } catch (err) { next(err); @@ -326,7 +324,7 @@ api.selectWinner = async function(req, res, next) { if (!winner || winner.challenges.indexOf(challenge._id) === -1) return next('Winner ' + req.query.uid + ' not found.'); // Close channel in background, some ops are run in the background without `await`ing - await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner}); + await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner}); res.respond(200, {}); } catch (err) { next(err); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 867457c07a..8616f889f0 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,9 +2,6 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; import nconf from 'nconf'; -import setupNconf from '../../libs/api-v3/setupNconf'; -setupNconf(); - import { authWithHeaders, } from '../../middlewares/api-v3/auth'; diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index b334dedc41..bb21ced189 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -14,10 +14,7 @@ import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; -import shared from '../../../../common'; import * as Tasks from '../../models/task'; -import { sendTxn as txnEmail } from '../../libs/api-v3/email'; -import sendPushNotification from '../../libs/api-v3/pushNotifications'; import Q from 'q'; import csvStringify from '../../libs/api-v3/csvStringify'; @@ -449,64 +446,6 @@ api.updateChallenge = { }, }; -// TODO everything here should be moved to a worker -// actually even for a worker it's probably just too big and will kill mongo -// Exported because it's used in v2 controller -export async function _closeChal (challenge, broken = {}) { - let winner = broken.winner; - let brokenReason = broken.broken; - - // Delete the challenge - await Challenge.remove({_id: challenge._id}).exec(); - - // Refund the leader if the challenge is closed and the group not the tavern - if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') { - await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec(); - } - - // Update the challengeCount on the group - await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(); - - // Award prize to winner and notify - if (winner) { - winner.achievements.challenges.push(challenge.name); - winner.balance += challenge.prize / 4; - let savedWinner = await winner.save(); - if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { - txnEmail(savedWinner, 'won-challenge', [ - {name: 'CHALLENGE_NAME', content: challenge.name}, - ]); - } - - sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); - } - - // Run some operations in the background withouth blocking the thread - let backgroundTasks = [ - // And it's tasks - Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(), - // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges - User.update({ - challenges: challenge._id, - 'tags._id': challenge._id, - }, { - $set: {'tags.$.challenge': false}, - $pull: {challenges: challenge._id}, - }, {multi: true}).exec(), - // Break users' tasks - Tasks.Task.update({ - 'challenge.id': challenge._id, - }, { - $set: { - 'challenge.broken': brokenReason, - 'challenge.winner': winner && winner.profile.name, - }, - }, {multi: true}).exec(), - ]; - - Q.all(backgroundTasks); -} - /** * @api {delete} /api/v3/challenges/:challengeId Delete a challenge * @apiVersion 3.0.0 @@ -534,7 +473,7 @@ api.deleteChallenge = { if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); // Close channel in background, some ops are run in the background without `await`ing - await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); + await challenge.closeChal({broken: 'CHALLENGE_DELETED'}); res.respond(200, {}); }, }; @@ -571,7 +510,7 @@ api.selectChallengeWinner = { if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId})); // Close channel in background, some ops are run in the background without `await`ing - await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner}); + await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner}); res.respond(200, {}); }, }; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index e07c831f20..d074cb4c7b 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -14,9 +14,6 @@ import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; import Q from 'q'; -import setupNconf from '../../libs/api-v3/setupNconf'; -setupNconf(); - const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; }); diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 3be2673b79..17b8486389 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,5 +1,6 @@ import { BadRequest, + NotAuthorized, } from '../../../libs/api-v3/errors'; import amzLib from '../../../libs/api-v3/amazonPayments'; import { @@ -16,6 +17,7 @@ import cc from 'coupon-code'; let api = {}; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/verifyAccessToken verify access token * @apiVersion 3.0.0 * @apiName AmazonVerifyAccessToken @@ -23,11 +25,11 @@ let api = {}; * * @apiParam {string} access_token the access token * - * @apiSuccess {} empty + * @apiSuccess {Object} data Empty object **/ api.verifyAccessToken = { method: 'POST', - url: '/payments/amazon/verifyAccessToken', + url: '/amazon/verifyAccessToken', middlewares: [authWithHeaders()], async handler (req, res) { try { @@ -40,6 +42,7 @@ api.verifyAccessToken = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/createOrderReferenceId create order reference id * @apiVersion 3.0.0 * @apiName AmazonCreateOrderReferenceId @@ -51,7 +54,7 @@ api.verifyAccessToken = { **/ api.createOrderReferenceId = { method: 'POST', - url: '/payments/amazon/createOrderReferenceId', + url: '/amazon/createOrderReferenceId', middlewares: [authWithHeaders()], async handler (req, res) { try { @@ -70,6 +73,7 @@ api.createOrderReferenceId = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/checkout do checkout * @apiVersion 3.0.0 * @apiName AmazonCheckout @@ -81,7 +85,7 @@ api.createOrderReferenceId = { **/ api.checkout = { method: 'POST', - url: '/payments/amazon/checkout', + url: '/amazon/checkout', middlewares: [authWithHeaders()], async handler (req, res) { let gift = req.body.gift; @@ -148,6 +152,7 @@ api.checkout = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/subscribe Subscribe * @apiVersion 3.0.0 * @apiName AmazonSubscribe @@ -161,7 +166,7 @@ api.checkout = { **/ api.subscribe = { method: 'POST', - url: '/payments/amazon/subscribe', + url: '/amazon/subscribe', middlewares: [authWithHeaders()], async handler (req, res) { let billingAgreementId = req.body.billingAgreementId; @@ -228,22 +233,21 @@ api.subscribe = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {get} /amazon/subscribe/cancel SubscribeCancel * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments - * - * @apiSuccess {object} empty object **/ api.subscribeCancel = { method: 'GET', - url: '/payments/amazon/subscribe/cancel', + url: '/amazon/subscribe/cancel', middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; let billingAgreementId = user.purchased.plan.customerId; - if (!billingAgreementId) throw new BadRequest(res.t('missingSubscription')); + if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription')); try { await amzLib.closeBillingAgreement({ @@ -257,7 +261,7 @@ api.subscribeCancel = { }; await payments.cancelSubscription(data); - res.respond(200, {}); + res.redirect('/'); } catch (error) { throw new BadRequest(error.message); } diff --git a/website/src/controllers/top-level/payments/iap.js b/website/src/controllers/top-level/payments/iap.js index 898b0b2015..60e50d6d39 100644 --- a/website/src/controllers/top-level/payments/iap.js +++ b/website/src/controllers/top-level/payments/iap.js @@ -1,5 +1,12 @@ import iap from 'in-app-purchase'; import nconf from 'nconf'; +import { + authWithHeaders, + authWithUrl, +} from '../../../middlewares/api-v3/auth'; +import payments from '../../../libs/api-v3/payments'; + +// NOT PORTED TO v3 iap.config({ // this is the path to the directory containing iap-sanbox/iap-live files @@ -7,148 +14,178 @@ iap.config({ }); // Validation ERROR Codes -// const INVALID_PAYLOAD = 6778001; +const INVALID_PAYLOAD = 6778001; // const CONNECTION_FAILED = 6778002; -// const PURCHASE_EXPIRED = 6778003; +// const PURCHASE_EXPIRED = 6778003; let api = {}; -/* -api.androidVerify = function androidVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /iap/android/verify Android Verify IAP + * @apiVersion 3.0.0 + * @apiName IapAndroidVerify + * @apiGroup Payments + **/ +api.iapAndroidVerify = { + method: 'POST', + url: '/iap/android/verify', + middlewares: [authWithUrl], + async handler (req, res) { + let user = res.locals.user; + let iapBody = req.body; - iap.setup(function googleSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // google receipt must be provided as an object - // { - // "data": "{stringified data object}", - // "signature": "signature from google" - // } - let testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature, - }; - - // iap is ready - iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { - if (err) { + iap.setup((error) => { + if (error) { let resObj = { ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, + data: 'IAP Error', }; return res.json(resObj); } - if (iap.isValidated(googleRes)) { - let resObj = { - ok: true, - data: googleRes, - }; + // google receipt must be provided as an object + // { + // "data": "{stringified data object}", + // "signature": "signature from google" + // } + let testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature, + }; - payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); + // iap is ready + iap.validate(iap.GOOGLE, testObj, (err, googleRes) => { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; - return res.json(resObj); - } + return res.json(resObj); + } + + if (iap.isValidated(googleRes)) { + let resObj = { + ok: true, + data: googleRes, + }; + + payments.buyGems({ + user, + paymentMethod: 'IAP GooglePlay', + amount: 5.25, + }).then(() => res.json(resObj)); + } + }); }); - }); + }, }; -exports.iosVerify = function iosVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /iap/ios/verify iOS Verify IAP + * @apiVersion 3.0.0 + * @apiName IapiOSVerify + * @apiGroup Payments + **/ +api.iapiOSVerify = { + method: 'POST', + url: '/iap/android/verify', + middlewares: [authWithHeaders()], + async handler (req, res) { + let iapBody = req.body; + let user = res.locals.user; - iap.setup(function iosSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { - if (err) { + iap.setup(function iosSetupResult (error) { + if (error) { let resObj = { ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, + data: 'IAP Error', }; return res.json(resObj); } - if (iap.isValidated(appleRes)) { - let purchaseDataList = iap.getPurchaseData(appleRes); - if (purchaseDataList.length > 0) { - let correctReceipt = true; - for (let index of purchaseDataList) { - switch (purchaseDataList[index].productId) { - case 'com.habitrpg.ios.Habitica.4gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); - break; - case 'com.habitrpg.ios.Habitica.8gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); - break; - case 'com.habitrpg.ios.Habitica.20gems': - case 'com.habitrpg.ios.Habitica.21gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); - break; - case 'com.habitrpg.ios.Habitica.42gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); - break; - default: - correctReceipt = false; + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return res.json(resObj); + } + + if (iap.isValidated(appleRes)) { + let purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length > 0) { + let correctReceipt = true; + + for (let index of purchaseDataList) { + switch (purchaseDataList[index].productId) { + case 'com.habitrpg.ios.Habitica.4gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); + break; + case 'com.habitrpg.ios.Habitica.8gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); + break; + case 'com.habitrpg.ios.Habitica.20gems': + case 'com.habitrpg.ios.Habitica.21gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); + break; + case 'com.habitrpg.ios.Habitica.42gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); + break; + default: + correctReceipt = false; + } + } + + if (correctReceipt) { + let resObj = { + ok: true, + data: appleRes, + }; + + // yay good! + return res.json(resObj); } } - if (correctReceipt) { - let resObj = { - ok: true, - data: appleRes, - }; - // yay good! - return res.json(resObj); - } + + // wrong receipt content + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Incorrect receipt content', + }, + }; + + return res.json(resObj); } - // wrong receipt content + + // invalid receipt let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: 'Incorrect receipt content', + message: 'Invalid receipt', }, }; - return res.json(resObj); - } - // invalid receipt - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: 'Invalid receipt', - }, - }; - return res.json(resObj); + return res.json(resObj); + }); }); - }); + }, }; -*/ module.exports = api; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 841bc0546b..1a3240d82e 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -1,3 +1,5 @@ +/* eslint-disable camelcase */ + import nconf from 'nconf'; import moment from 'moment'; import _ from 'lodash'; @@ -17,6 +19,8 @@ import { } from '../../../libs/api-v3/errors'; import * as logger from '../../../libs/api-v3/logger'; +const BASE_URL = nconf.get('BASE_URL'); + // This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have // a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created // there, get it's plan.id and store it in config.json @@ -24,8 +28,6 @@ _.each(shared.content.subscriptionBlocks, (block) => { block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`); }); -/* eslint-disable camelcase */ - paypal.configure({ mode: nconf.get('PAYPAL:mode'), // sandbox or live client_id: nconf.get('PAYPAL:client_id'), @@ -35,18 +37,18 @@ paypal.configure({ let api = {}; /** - * @api {get} /paypal/checkout checkout + * @apiIgnore Payments are considered part of the private API + * @api {get} /paypal/checkout Paypal checkout + * @apiDescription Redirects to Paypal * @apiVersion 3.0.0 * @apiName PaypalCheckout * @apiGroup Payments * - * @apiParam {string} gift The stringified object representing the user, the gift recepient. - * - * @apiSuccess {} redirect + * @apiParam {string} gift Query parameter - The stringified object representing the user, the gift recepient. **/ api.checkout = { method: 'GET', - url: '/payments/paypal/checkout', + url: '/paypal/checkout', middlewares: [authWithUrl], async handler (req, res) { let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; @@ -68,8 +70,8 @@ api.checkout = { intent: 'sale', payer: { payment_method: 'Paypal' }, redirect_urls: { - return_url: `${nconf.get('BASE_URL')}/paypal/checkout/success`, - cancel_url: `${nconf.get('BASE_URL')}`, + return_url: `${BASE_URL}/paypal/checkout/success`, + cancel_url: `${BASE_URL}`, }, transactions: [{ item_list: { @@ -87,6 +89,7 @@ api.checkout = { description, }], }; + try { let result = await paypal.payment.create(createPayment); let link = _.find(result.links, { rel: 'approval_url' }).href; @@ -98,6 +101,7 @@ api.checkout = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {get} /paypal/checkout/success Paypal checkout success * @apiVersion 3.0.0 * @apiName PaypalCheckoutSuccess @@ -110,7 +114,7 @@ api.checkout = { **/ api.checkoutSuccess = { method: 'GET', - url: '/payments/paypal/checkout/success', + url: '/paypal/checkout/success', middlewares: [authWithSession], async handler (req, res) { let paymentId = req.query.paymentId; @@ -144,6 +148,7 @@ api.checkoutSuccess = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {get} /paypal/subscribe Paypal subscribe * @apiVersion 3.0.0 * @apiName PaypalSubscribe @@ -156,7 +161,7 @@ api.checkoutSuccess = { **/ api.subscribe = { method: 'GET', - url: '/payments/paypal/subscribe', + url: '/paypal/subscribe', middlewares: [authWithUrl], async handler (req, res) { let sub = shared.content.subscriptionBlocks[req.query.sub]; @@ -190,6 +195,7 @@ api.subscribe = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {get} /paypal/subscribe/success Paypal subscribe success * @apiVersion 3.0.0 * @apiName PaypalSubscribeSuccess @@ -201,7 +207,7 @@ api.subscribe = { **/ api.subscribeSuccess = { method: 'GET', - url: '/payments/paypal/subscribe/success', + url: '/paypal/subscribe/success', middlewares: [authWithSession], async handler (req, res) { let user = res.locals.user; @@ -223,6 +229,7 @@ api.subscribeSuccess = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {get} /paypal/subscribe/cancel Paypal subscribe cancel * @apiVersion 3.0.0 * @apiName PaypalSubscribeCancel @@ -234,7 +241,7 @@ api.subscribeSuccess = { **/ api.subscribeCancel = { method: 'GET', - url: '/payments/paypal/subscribe/cancel', + url: '/paypal/subscribe/cancel', middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; @@ -261,6 +268,7 @@ api.subscribeCancel = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /paypal/ipn Paypal IPN * @apiVersion 3.0.0 * @apiName PaypalIpn @@ -273,7 +281,7 @@ api.subscribeCancel = { **/ api.ipn = { method: 'POST', - url: '/payments/paypal/ipn', + url: '/paypal/ipn', middlewares: [], async handler (req, res) { res.respond(200); diff --git a/website/src/controllers/top-level/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js deleted file mode 100644 index 1015e8f89e..0000000000 --- a/website/src/controllers/top-level/payments/paypalBillingSetup.js +++ /dev/null @@ -1,98 +0,0 @@ -// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring -// payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this -// file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), -// and once for any time you need to edit the plan thereafter -import path from 'path'; -import nconf from 'nconf'; -import _ from 'lodash'; -import paypal from 'paypal-rest-sdk'; -import shared from '../../../../../common'; - -let blocks = shared.content.subscriptionBlocks; -const BILLING_PLAN_TITLE = 'Habitica Subscription'; -const LIVE = nconf.get('PAYPAL:mode') === 'live'; -const OP = 'create'; // list create update remove - -nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); - -/* eslint-disable camelcase */ -paypal.configure({ - mode: nconf.get('PAYPAL:mode'), // sandbox or live - client_id: nconf.get('PAYPAL:client_id'), - client_secret: nconf.get('PAYPAL:client_secret'), -}); - -// https://developer.paypal.com/docs/api/#billing-plans-and-agreements -let billingPlanAttributes = { - name: BILLING_PLAN_TITLE, - description: BILLING_PLAN_TITLE, - type: 'INFINITE', - merchant_preferences: { - auto_bill_amount: 'yes', - cancel_url: LIVE ? 'https://habitica.com' : 'http://localhost:3000', - return_url: LIVE ? 'https://habitica.com/paypal/subscribe/success' : 'http://localhost:3000/paypal/subscribe/success', - }, - payment_definitions: [{ - type: 'REGULAR', - frequency: 'MONTH', - cycles: '0', - }], -}; - -_.each(blocks, function defineBlock (block) { - block.definition = _.cloneDeep(billingPlanAttributes); - _.merge(block.definition.payment_definitions[0], { - name: `${BILLING_PLAN_TITLE} (\$${block.price} every ${block.months} months, recurring)`, - frequency_interval: `${block.months}`, - amount: { - currency: 'USD', - value: `${block.price}`, - }, - }); -}); - -let update = { - op: 'replace', - path: '/merchant_preferences', - value: { - cancel_url: 'https://habitica.com', - }, -}; - -switch (OP) { - case 'list': - paypal.billingPlan.list({status: 'ACTIVE'}, function listPlans () { - // TODO Was a console.log statement. Need proper response output - }); - break; - case 'get': - paypal.billingPlan.get(nconf.get('PAYPAL:billing_plans:12'), function getPlan () { - // TODO Was a console.log statement. Need proper response output - }); - break; - case 'update': - paypal.billingPlan.update(nconf.get('PAYPAL:billing_plans:12'), update, function updatePlan () { - // TODO Was a console.log statement. Need proper response output - }); - break; - case 'create': - paypal.billingPlan.create(blocks.google_6mo.definition, function createPlan (err, plan) { - if (err) return; // TODO Was a console.log statement. Need proper response output - if (plan.state === 'ACTIVE') - return; // TODO Was a console.log statement. Need proper response output - let billingPlanUpdateAttributes = [{ - op: 'replace', - path: '/', - value: { - state: 'ACTIVE', - }, - }]; - // Activate the plan by changing status to Active - paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function activatePlan () { - // TODO Was a console.log statement. Need proper response output - }); - }); - break; - case 'remove': break; -} -/* eslint-enable camelcase */ diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index d211a60d89..a319d2d4f9 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -2,6 +2,7 @@ import stripeModule from 'stripe'; import shared from '../../../../../common'; import { BadRequest, + NotAuthorized, } from '../../../libs/api-v3/errors'; import { model as Coupon } from '../../../models/coupon'; import payments from '../../../libs/api-v3/payments'; @@ -18,22 +19,23 @@ const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); let api = {}; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /stripe/checkout Stripe checkout * @apiVersion 3.0.0 * @apiName StripeCheckout * @apiGroup Payments * - * @apiParam {string} id The token - * @apiParam {string} gift stringified json object, gift - * @apiParam {string} sub subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo - * @apiParam {string} coupon coupon for the matching subscription, required only for certain subscriptions - * @apiParam {string} email the customer email + * @apiParam {string} id Body parameter - The token + * @apiParam {string} email Body parameter - the customer email + * @apiParam {string} gift Query parameter - stringified json object, gift + * @apiParam {string} sub Query parameter - subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo + * @apiParam {string} coupon Query parameter - coupon for the matching subscription, required only for certain subscriptions * - * @apiSuccess {} empty object + * @apiSuccess {Object} data Empty object **/ api.checkout = { method: 'POST', - url: '/payments/stripe/checkout', + url: '/stripe/checkout', middlewares: [authWithHeaders()], async handler (req, res) { let token = req.body.id; @@ -49,15 +51,16 @@ api.checkout = { coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key}); if (!coupon) throw new BadRequest(res.t('invalidCoupon')); } - let customer = { + + response = await stripe.customers.create({ email: req.body.email, metadata: { uuid: user._id }, card: token, plan: sub.key, - }; - response = await stripe.customers.create(customer); + }); } else { let amount = 500; // $5 + if (gift) { if (gift.type === 'subscription') { amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; @@ -65,6 +68,7 @@ api.checkout = { amount = `${gift.gems.amount / 4 * 100}`; } } + response = await stripe.charges.create({ amount, currency: 'usd', @@ -87,80 +91,74 @@ api.checkout = { paymentMethod: 'Stripe', gift, }; + if (gift) { let member = await User.findById(gift.uuid); gift.member = member; if (gift.type === 'subscription') method = 'createSubscription'; data.paymentMethod = 'Gift'; } + await payments[method](data); } + res.respond(200, {}); }, }; /** - * @api {post} /stripe/subscribe/edit Stripe subscribeEdit + * @apiIgnore Payments are considered part of the private API + * @api {post} /stripe/subscribe/edit Edit Stripe subscription * @apiVersion 3.0.0 * @apiName StripeSubscribeEdit * @apiGroup Payments * - * @apiParam {string} id The token + * @apiParam {string} id Body parameter - The token * - * @apiSuccess {} + * @apiSuccess {Object} data Empty object **/ api.subscribeEdit = { method: 'POST', - url: '/payments/stripe/subscribe/edit', + url: '/stripe/subscribe/edit', middlewares: [authWithHeaders()], async handler (req, res) { let token = req.body.id; let user = res.locals.user; let customerId = user.purchased.plan.customerId; - if (!customerId) throw new BadRequest(res.t('missingSubscription')); + if (!customerId) throw new NotAuthorized(res.t('missingSubscription')); - try { - let subscriptions = await stripe.customers.listSubscriptions(customerId); - let subscriptionId = subscriptions.data[0].id; - await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); - res.respond(200, {}); - } catch (error) { - throw new BadRequest(error.message); - } + let subscriptions = await stripe.customers.listSubscriptions(customerId); + let subscriptionId = subscriptions.data[0].id; + await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); + res.respond(200, {}); }, }; /** - * @api {get} /stripe/subscribe/cancel Stripe subscribeCancel + * @apiIgnore Payments are considered part of the private API + * @api {get} /stripe/subscribe/cancel Cancel Stripe subscription * @apiVersion 3.0.0 * @apiName StripeSubscribeCancel * @apiGroup Payments - * - * @apiParam - * - * @apiSuccess {} **/ api.subscribeCancel = { method: 'GET', - url: '/payments/stripe/subscribe/cancel', + url: '/stripe/subscribe/cancel', middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; - if (!user.purchased.plan.customerId) throw new BadRequest(res.t('missingSubscription')); - try { - let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId); - await stripe.customers.del(user.purchased.plan.customerId); - let data = { - user, - nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds - paymentMethod: 'Stripe', - }; - await payments.cancelSubscriptoin(data); - res.respond(200, {}); - } catch (e) { - throw new BadRequest(e); - } + if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription')); + + let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId); + await stripe.customers.del(user.purchased.plan.customerId); + await payments.cancelSubscriptoin({ + user, + nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds + paymentMethod: 'Stripe', + }); + + res.redirect('/'); }, }; diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index 38403ba4d0..c22b3cfb3b 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -1,9 +1,13 @@ import amazonPayments from 'amazon-payments'; import nconf from 'nconf'; import common from '../../../../common'; -let t = common.i18n.t; -const IS_PROD = nconf.get('NODE_ENV') === 'production'; import Q from 'q'; +import { + BadRequest, +} from './errors'; + +const t = common.i18n.t; +const IS_PROD = nconf.get('NODE_ENV') === 'production'; let amzPayment = amazonPayments.connect({ environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], @@ -13,10 +17,6 @@ let amzPayment = amazonPayments.connect({ clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), }); -/** - * From: https://payments.amazon.com/documentation/apireference/201751670#201751670 - */ - let getTokenInfo = Q.nbind(amzPayment.api.getTokenInfo, amzPayment.api); let createOrderReferenceId = Q.nbind(amzPayment.offAmazonPayments.createOrderReferenceForId, amzPayment.offAmazonPayments); let setOrderReferenceDetails = Q.nbind(amzPayment.offAmazonPayments.setOrderReferenceDetails, amzPayment.offAmazonPayments); @@ -30,7 +30,7 @@ let authorizeOnBillingAgreement = (inputSet) => { return new Promise((resolve, reject) => { amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => { if (err) return reject(err); - if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful'))); return resolve(response); }); }); @@ -40,7 +40,7 @@ let authorize = (inputSet) => { return new Promise((resolve, reject) => { amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { if (err) return reject(err); - if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful'))); return resolve(response); }); }); diff --git a/website/src/libs/api-v3/payments.js b/website/src/libs/api-v3/payments.js index 8a28e7d1a9..5a9c888972 100644 --- a/website/src/libs/api-v3/payments.js +++ b/website/src/libs/api-v3/payments.js @@ -10,10 +10,6 @@ import nconf from 'nconf'; import pushNotify from './pushNotifications'; import shared from '../../../../common' ; -import iap from '../../controllers/top-level/payments/iap'; -import paypal from '../../controllers/top-level/payments/paypal'; -import stripe from '../../controllers/top-level/payments/stripe'; - const IS_PROD = nconf.get('IS_PROD'); let api = {}; @@ -45,6 +41,7 @@ api.createSubscription = async function createSubscription (data) { plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); if (!plan.dateUpdated) plan.dateUpdated = new Date(); } + if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId } else { _(plan).merge({ // override with these values @@ -73,12 +70,13 @@ api.createSubscription = async function createSubscription (data) { if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; plan.consecutive.trinkets += perks; } + revealMysteryItems(recipient); if (IS_PROD) { if (!data.gift) txnEmail(data.user, 'subscription-begins'); - let analyticsData = { + analytics.trackPurchase({ uuid: data.user._id, itemPurchased: 'Subscription', sku: `${data.paymentMethod.toLowerCase()}-subscription`, @@ -87,8 +85,7 @@ api.createSubscription = async function createSubscription (data) { quantity: 1, gift: Boolean(data.gift), purchaseValue: block.price, - }; - analytics.trackPurchase(analyticsData); + }); } data.user.purchased.txnCount++; @@ -114,9 +111,7 @@ api.createSubscription = async function createSubscription (data) { if (data.gift) await data.gift.member.save(); }; -/** - * Sets their subscription to be cancelled later - */ +// Sets their subscription to be cancelled later api.cancelSubscription = async function cancelSubscription (data) { let plan = data.user.purchased.plan; let now = moment(); @@ -146,12 +141,14 @@ api.cancelSubscription = async function cancelSubscription (data) { api.buyGems = async function buyGems (data) { let amt = data.amount || 5; amt = data.gift ? data.gift.gems.amount / 4 : amt; + (data.gift ? data.gift.member : data.user).balance += amt; data.user.purchased.txnCount++; + if (IS_PROD) { if (!data.gift) txnEmail(data.user, 'donation'); - let analyticsData = { + analytics.trackPurchase({ uuid: data.user._id, itemPurchased: 'Gems', sku: `${data.paymentMethod.toLowerCase()}-checkout`, @@ -160,8 +157,7 @@ api.buyGems = async function buyGems (data) { quantity: 1, gift: Boolean(data.gift), purchaseValue: amt, - }; - analytics.trackPurchase(analyticsData); + }); } if (data.gift) { @@ -179,23 +175,11 @@ api.buyGems = async function buyGems (data) { if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); } + await data.gift.member.save(); } + await data.user.save(); }; -api.stripeCheckout = stripe.checkout; -api.stripeSubscribeCancel = stripe.subscribeCancel; -api.stripeSubscribeEdit = stripe.subscribeEdit; - -api.paypalSubscribe = paypal.createBillingAgreement; -api.paypalSubscribeSuccess = paypal.executeBillingAgreement; -api.paypalSubscribeCancel = paypal.cancelSubscription; -api.paypalCheckout = paypal.createPayment; -api.paypalCheckoutSuccess = paypal.executePayment; -api.paypalIPN = paypal.ipn; - -api.iapAndroidVerify = iap.androidVerify; -api.iapIosVerify = iap.iosVerify; - module.exports = api; diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index d775292a5b..027e25301b 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -52,6 +52,12 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable }); } + // Handle Stripe Card errors errors (can be safely shown to the users) + // https://stripe.com/docs/api/node#errors + if (err.type === 'StripeCardError') { + responseErr = new BadRequest(err.message); + } + if (!responseErr || responseErr.httpCode >= 500) { // Try to identify the error... // ... diff --git a/website/src/middlewares/api-v3/v2.js b/website/src/middlewares/api-v3/v2.js index 4eb2686bdc..ec49e326a4 100644 --- a/website/src/middlewares/api-v3/v2.js +++ b/website/src/middlewares/api-v3/v2.js @@ -19,8 +19,6 @@ v2app.use(responseHandler); // Custom Directives v2app.use('/', require('../../routes/api-v2/auth')); -// v2app.use('/', require('../../routes/api-v2/coupon')); // TODO REMOVE - ONLY v3 -// v2app.use('/', require('../../routes/api-v2/unsubscription')); // TODO REMOVE - ONLY v3 require('../../routes/api-v2/swagger')(swagger, v2app); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index caa823acc1..ee0f7209a1 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -5,7 +5,14 @@ import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; import * as Tasks from './task'; import { model as User } from './user'; +import { + model as Group, + TAVERN_ID, +} from './group'; import { removeFromArray } from '../libs/api-v3/collectionManipulators'; +import shared from '../../../common'; +import { sendTxn as txnEmail } from '../libs/api-v3/email'; +import sendPushNotification from '../libs/api-v3/pushNotifications'; let Schema = mongoose.Schema; @@ -251,6 +258,65 @@ schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) { } }; +// TODO everything here should be moved to a worker +// actually even for a worker it's probably just too big and will kill mongo +schema.methods.closeChal = async function closeChal (broken = {}) { + let challenge = this; + + let winner = broken.winner; + let brokenReason = broken.broken; + + // Delete the challenge + await this.model('Challenge').remove({_id: challenge._id}).exec(); + + // Refund the leader if the challenge is closed and the group not the tavern + if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') { + await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec(); + } + + // Update the challengeCount on the group + await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(); + + // Award prize to winner and notify + if (winner) { + winner.achievements.challenges.push(challenge.name); + winner.balance += challenge.prize / 4; + let savedWinner = await winner.save(); + if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { + txnEmail(savedWinner, 'won-challenge', [ + {name: 'CHALLENGE_NAME', content: challenge.name}, + ]); + } + + sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); + } + + // Run some operations in the background withouth blocking the thread + let backgroundTasks = [ + // And it's tasks + Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(), + // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges + User.update({ + challenges: challenge._id, + 'tags._id': challenge._id, + }, { + $set: {'tags.$.challenge': false}, + $pull: {challenges: challenge._id}, + }, {multi: true}).exec(), + // Break users' tasks + Tasks.Task.update({ + 'challenge.id': challenge._id, + }, { + $set: { + 'challenge.broken': brokenReason, + 'challenge.winner': winner && winner.profile.name, + }, + }, {multi: true}).exec(), + ]; + + Q.all(backgroundTasks); +}; + // Methods to adapt the new schema to API v2 responses (mostly tasks inside the challenge model) // These will be removed once API v2 is discontinued From 20f9bbf449f08521b1c4ec0c9d3a490e8397acea Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 9 May 2016 16:06:27 -0500 Subject: [PATCH 754/976] Corrected reset checklist logic (#7154) * Corrected reset checklist logic * Change cron logic to reset rather than set * Remove extra paranthesis --- test/api/v3/unit/libs/cron.test.js | 8 ++++---- website/src/libs/api-v3/cron.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/api/v3/unit/libs/cron.test.js b/test/api/v3/unit/libs/cron.test.js index d4a83634f3..382246a3c8 100644 --- a/test/api/v3/unit/libs/cron.test.js +++ b/test/api/v3/unit/libs/cron.test.js @@ -298,19 +298,19 @@ describe('cron', () => { expect(tasksByType.dailys[0].completed).to.be.false; }); - it('should set task checklist to completed for completed dailys', () => { + it('should reset task checklist for completed dailys', () => { tasksByType.dailys[0].checklist.push({title: 'test', completed: false}); tasksByType.dailys[0].completed = true; cron({user, tasksByType, daysMissed, analytics}); - expect(tasksByType.dailys[0].checklist[0].completed).to.be.true; + expect(tasksByType.dailys[0].checklist[0].completed).to.be.false; }); - it('should set task checklist to completed for dailys with scheduled misses', () => { + it('should reset task checklist for dailys with scheduled misses', () => { daysMissed = 10; tasksByType.dailys[0].checklist.push({title: 'test', completed: false}); tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); cron({user, tasksByType, daysMissed, analytics}); - expect(tasksByType.dailys[0].checklist[0].completed).to.be.true; + expect(tasksByType.dailys[0].checklist[0].completed).to.be.false; }); it('should do damage for missing a daily', () => { diff --git a/website/src/libs/api-v3/cron.js b/website/src/libs/api-v3/cron.js index a8270c4bc8..25fcf9bd98 100644 --- a/website/src/libs/api-v3/cron.js +++ b/website/src/libs/api-v3/cron.js @@ -182,7 +182,7 @@ export function cron (options = {}) { task.completed = false; if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + task.checklist.forEach(i => i.completed = false); // FIXME this should not happen for grey tasks unless they are completed } }); From 0cb0780c14b29342f3ad3169e3186188ca32c1a9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 9 May 2016 23:29:20 +0200 Subject: [PATCH 755/976] v3: do not log entire promise on error and add ability to enable console logging of errors in prod --- config.json.example | 5 +++-- website/src/libs/api-v3/logger.js | 14 +++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/config.json.example b/config.json.example index 6aeb8ac74a..9fa012240d 100644 --- a/config.json.example +++ b/config.json.example @@ -1,5 +1,6 @@ { "PORT":3000, + "ENABLE_CONSOLE_LOGS_IN_PROD":"false", "IP":"0.0.0.0", "CORES":1, "BASE_URL":"http://localhost:3000", @@ -33,7 +34,7 @@ "EMAIL_SERVER": { "url": "http://example.com", "authUser": "user", - "authPassword": "password" + "authPassword": "password" }, "S3":{ "bucket":"bucket", @@ -60,7 +61,7 @@ "subdomain": "subdomain", "token": "token", "username": "username", - "password": "password" + "password": "password" }, "PUSH_CONFIGS": { "GCM_SERVER_API_KEY": "", diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index a840f279dd..143b7afb67 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -5,17 +5,19 @@ import _ from 'lodash'; const IS_PROD = nconf.get('IS_PROD'); const IS_TEST = nconf.get('IS_TEST'); +const ENABLE_CONSOLE_LOGS_IN_PROD = nconf.get('ENABLE_CONSOLE_LOGS_IN_PROD') === 'true'; const logger = new winston.Logger(); if (IS_PROD) { // TODO production logging, use loggly and new relic too - // log errors to console too - logger - .add(winston.transports.Console, { + + if (ENABLE_CONSOLE_LOGS_IN_PROD) { + logger.add(winston.transports.Console, { colorize: true, prettyPrint: true, }); + } } else if (IS_TEST) { // Do not log anything when testing } else { @@ -53,10 +55,8 @@ let loggerInterface = { // Logs unhandled promises errors // when no catch is attached to a promise a unhandledRejection event will be triggered -process.on('unhandledRejection', function handlePromiseRejection (reason, promise) { - loggerInterface.error(reason, { - promise, - }); +process.on('unhandledRejection', function handlePromiseRejection (reason) { + loggerInterface.error(reason); }); module.exports = loggerInterface; From 9146e4601e0843db6c9bf71599ff041c928291b9 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 10 May 2016 09:27:36 -0500 Subject: [PATCH 756/976] Added tags service (#7176) --- test/spec/services/tagServicesSpec.js | 52 +++++++++++++++++++++++ website/public/js/services/tagsService.js | 51 ++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 test/spec/services/tagServicesSpec.js create mode 100644 website/public/js/services/tagsService.js diff --git a/test/spec/services/tagServicesSpec.js b/test/spec/services/tagServicesSpec.js new file mode 100644 index 0000000000..119c814e09 --- /dev/null +++ b/test/spec/services/tagServicesSpec.js @@ -0,0 +1,52 @@ +'use strict'; + +describe('Tags Service', function() { + var rootScope, tags, user, $httpBackend; + var apiV3Prefix = 'api/v3/tags'; + + beforeEach(function() { + module(function($provide) { + user = specHelper.newUser(); + $provide.value('User', {user: user}); + }); + + inject(function(_$httpBackend_, _$rootScope_, Tags, User) { + $httpBackend = _$httpBackend_; + rootScope = _$rootScope_; + tags = Tags; + }); + }); + + it('calls get tags endpoint', function() { + $httpBackend.expectGET(apiV3Prefix).respond({}); + tags.getTags(); + $httpBackend.flush(); + }); + + it('calls post tags endpoint', function() { + $httpBackend.expectPOST(apiV3Prefix).respond({}); + tags.createTag(); + $httpBackend.flush(); + }); + + it('calls get tag endpoint', function() { + var tagId = 1; + $httpBackend.expectGET(apiV3Prefix + '/' + tagId).respond({}); + tags.getTag(tagId); + $httpBackend.flush(); + }); + + it('calls update tag endpoint', function() { + var tagId = 1; + $httpBackend.expectPUT(apiV3Prefix + '/' + tagId).respond({}); + tags.updateTag(tagId, {}); + $httpBackend.flush(); + }); + + it('calls delete tag endpoint', function() { + var tagId = 1; + $httpBackend.expectDELETE(apiV3Prefix + '/' + tagId).respond({}); + tags.deleteTag(tagId); + $httpBackend.flush(); + }); +}); diff --git a/website/public/js/services/tagsService.js b/website/public/js/services/tagsService.js new file mode 100644 index 0000000000..a31ecc155e --- /dev/null +++ b/website/public/js/services/tagsService.js @@ -0,0 +1,51 @@ +'use strict'; + +angular.module('habitrpg') +.factory('Tags', ['$rootScope', '$http', + function tagsFactory($rootScope, $http) { + + function getTags () { + return $http({ + method: 'GET', + url: 'api/v3/tags', + }); + }; + + function createTag (tagDetails) { + return $http({ + method: 'POST', + url: 'api/v3/tags', + data: tagDetails, + }); + }; + + function getTag (tagId) { + return $http({ + method: 'GET', + url: 'api/v3/tags/' + tagId, + }); + }; + + function updateTag (tagId, tagDetails) { + return $http({ + method: 'PUT', + url: 'api/v3/tags/' + tagId, + data: tagDetails, + }); + }; + + function deleteTag (tagId) { + return $http({ + method: 'DELETE', + url: 'api/v3/tags/' + tagId, + }); + }; + + return { + getTags: getTags, + createTag: createTag, + getTag: getTag, + updateTag: updateTag, + deleteTag: deleteTag, + }; + }]); From 1a43ab35c079d1ad8a6908a97638dfc45d45ab4d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 10 May 2016 16:56:01 +0200 Subject: [PATCH 757/976] v3 payments: port amazon payments --- website/public/js/services/paymentServices.js | 12 +- .../controllers/top-level/payments/amazon.js | 259 +++++++++--------- .../controllers/top-level/payments/stripe.js | 4 + website/src/libs/api-v3/amazonPayments.js | 8 +- 4 files changed, 137 insertions(+), 146 deletions(-) diff --git a/website/public/js/services/paymentServices.js b/website/public/js/services/paymentServices.js index a3fc832d24..c5bfeae262 100644 --- a/website/public/js/services/paymentServices.js +++ b/website/public/js/services/paymentServices.js @@ -127,12 +127,12 @@ function($rootScope, User, $http, Content) { var url = '/amazon/createOrderReferenceId' $http.post(url, { billingAgreementId: Payments.amazonPayments.billingAgreementId - }).success(function(data){ + }).success(function(res){ Payments.amazonPayments.loggedIn = true; - Payments.amazonPayments.orderReferenceId = data.orderReferenceId; + Payments.amazonPayments.orderReferenceId = res.data.orderReferenceId; Payments.amazonPayments.initWidgets(); }).error(function(res){ - alert(res.err); + alert(res.message); }); } }, @@ -146,7 +146,7 @@ function($rootScope, User, $http, Content) { var url = '/amazon/verifyAccessToken' $http.post(url, response).error(function(res){ - alert(res.err); + alert(res.message); }); }); }, @@ -232,7 +232,7 @@ function($rootScope, User, $http, Content) { Payments.amazonPayments.reset(); window.location.reload(true); }).error(function(res){ - alert(res.err); + alert(res.message); Payments.amazonPayments.reset(); }); }else if(Payments.amazonPayments.type === 'subscription'){ @@ -246,7 +246,7 @@ function($rootScope, User, $http, Content) { Payments.amazonPayments.reset(); window.location.reload(true); }).error(function(res){ - alert(res.err); + alert(res.message); Payments.amazonPayments.reset(); }); } diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 17b8486389..a22618fa23 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -18,13 +18,11 @@ let api = {}; /** * @apiIgnore Payments are considered part of the private API - * @api {post} /amazon/verifyAccessToken verify access token + * @api {post} /amazon/verifyAccessToken Amazon Payments: verify access token * @apiVersion 3.0.0 * @apiName AmazonVerifyAccessToken * @apiGroup Payments * - * @apiParam {string} access_token the access token - * * @apiSuccess {Object} data Empty object **/ api.verifyAccessToken = { @@ -32,56 +30,53 @@ api.verifyAccessToken = { url: '/amazon/verifyAccessToken', middlewares: [authWithHeaders()], async handler (req, res) { - try { - await amzLib.getTokenInfo(req.body.access_token); - res.respond(200, {}); - } catch (error) { - throw new BadRequest(error.body.error_description); - } + let accessToken = req.body.access_token; + + if (!accessToken) throw new BadRequest('Missing req.body.access_token'); + + await amzLib.getTokenInfo(accessToken); + res.respond(200, {}); }, }; /** * @apiIgnore Payments are considered part of the private API - * @api {post} /amazon/createOrderReferenceId create order reference id + * @api {post} /amazon/createOrderReferenceId Amazon Payments: create order reference id * @apiVersion 3.0.0 * @apiName AmazonCreateOrderReferenceId * @apiGroup Payments * - * @apiParam {string} billingAgreementId billing agreement id - * - * @apiSuccess {object} data.orderReferenceId The order reference id. + * @apiSuccess {string} data.orderReferenceId The order reference id. **/ api.createOrderReferenceId = { method: 'POST', url: '/amazon/createOrderReferenceId', middlewares: [authWithHeaders()], async handler (req, res) { - try { - let response = await amzLib.createOrderReferenceId({ - Id: req.body.billingAgreementId, - IdType: 'BillingAgreement', - ConfirmNow: false, - }); - res.respond(200, { - orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, - }); - } catch (error) { - throw new BadRequest(error); - } + let billingAgreementId = req.body.billingAgreementId; + + if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId'); + + let response = await amzLib.createOrderReferenceId({ + Id: billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + }); + + res.respond(200, { + orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, + }); }, }; /** * @apiIgnore Payments are considered part of the private API - * @api {post} /amazon/checkout do checkout + * @api {post} /amazon/checkout Amazon Payments: checkout * @apiVersion 3.0.0 * @apiName AmazonCheckout * @apiGroup Payments * - * @apiParam {string} billingAgreementId billing agreement id - * - * @apiSuccess {object} object containing { orderReferenceId } + * @apiSuccess {object} data Empty object **/ api.checkout = { method: 'POST', @@ -93,6 +88,8 @@ api.checkout = { let orderReferenceId = req.body.orderReferenceId; let amount = 5; + if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId'); + if (gift) { if (gift.type === 'gems') { amount = gift.gems.amount / 4; @@ -101,68 +98,62 @@ api.checkout = { } } - try { - await amzLib.setOrderReferenceDetails({ - AmazonOrderReferenceId: orderReferenceId, - OrderReferenceAttributes: { - OrderTotal: { - CurrencyCode: 'USD', - Amount: amount, - }, - SellerNote: 'HabitRPG Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG', - }, - }, - }); - - await amzLib.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId }); - - await amzLib.authorize({ - AmazonOrderReferenceId: orderReferenceId, - AuthorizationReferenceId: shared.uuid().substring(0, 32), - AuthorizationAmount: { + await amzLib.setOrderReferenceDetails({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { CurrencyCode: 'USD', Amount: amount, }, - SellerAuthorizationNote: 'HabitRPG Payment', - TransactionTimeout: 0, - CaptureNow: true, - }); + SellerNote: 'HabitRPG Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, + }); - await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); + await amzLib.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId }); - // execute payment - let method = 'buyGems'; - let data = { user, paymentMethod: 'Amazon Payments' }; - if (gift) { - if (gift.type === 'subscription') method = 'createSubscription'; - gift.member = await User.findById(gift ? gift.uuid : undefined); - data.gift = gift; - data.paymentMethod = 'Gift'; - } - await payments[method](data); + await amzLib.authorize({ + AmazonOrderReferenceId: orderReferenceId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerAuthorizationNote: 'HabitRPG Payment', + TransactionTimeout: 0, + CaptureNow: true, + }); - res.respond(200); - } catch (error) { - throw new BadRequest(error); + await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); + + // execute payment + let method = 'buyGems'; + let data = { user, paymentMethod: 'Amazon Payments' }; + + if (gift) { + if (gift.type === 'subscription') method = 'createSubscription'; + gift.member = await User.findById(gift ? gift.uuid : undefined); + data.gift = gift; + data.paymentMethod = 'Gift'; } + + await payments[method](data); + + res.respond(200); }, }; /** * @apiIgnore Payments are considered part of the private API - * @api {post} /amazon/subscribe Subscribe + * @api {post} /amazon/subscribe Amazon Payments: subscribe * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments * - * @apiParam {string} billingAgreementId billing agreement id - * @apiParam {string} subscription Subscription plan - * @apiParam {string} coupon Coupon - * - * @apiSuccess {object} data.orderReferenceId The order reference id. + * @apiSuccess {object} data Empty object **/ api.subscribe = { method: 'POST', @@ -174,67 +165,62 @@ api.subscribe = { let coupon = req.body.coupon; let user = res.locals.user; - if (!sub) { - throw new BadRequest(res.t('missingSubscriptionCode')); + if (!sub) throw new BadRequest(res.t('missingSubscriptionCode')); + if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId'); + + if (sub.discount) { // apply discount + if (!coupon) throw new BadRequest(res.t('couponCodeRequired')); + let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key}); + if (!result) throw new NotAuthorized(res.t('invalidCoupon')); } - try { - if (sub.discount) { // apply discount - if (!coupon) throw new BadRequest(res.t('couponCodeRequired')); - let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key}); - if (!result) throw new BadRequest(res.t('invalidCoupon')); - } - - await amzLib.setBillingAgreementDetails({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: 'HabitRPG Subscription', - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: shared.uuid(), - StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription', - }, - }, - }); - - await amzLib.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }); - - await amzLib.authorizeOnBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - AuthorizationReferenceId: shared.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: 'USD', - Amount: sub.price, - }, - SellerAuthorizationNote: 'HabitRPG Subscription Payment', - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: 'HabitRPG Subscription Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), + await amzLib.setBillingAgreementDetails({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: 'HabitRPG Subscription', + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: shared.uuid(), StoreName: 'HabitRPG', + CustomInformation: 'HabitRPG Subscription', }, - }); + }, + }); - await payments.createSubscription({ - user, - customerId: billingAgreementId, - paymentMethod: 'Amazon Payments', - sub, - }); + await amzLib.confirmBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }); - res.respond(200); - } catch (error) { - throw new BadRequest(error); - } + await amzLib.authorizeOnBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: sub.price, + }, + SellerAuthorizationNote: 'HabitRPG Subscription Payment', + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: 'HabitRPG Subscription Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }); + + await payments.createSubscription({ + user, + customerId: billingAgreementId, + paymentMethod: 'Amazon Payments', + sub, + }); + + res.respond(200); }, }; /** * @apiIgnore Payments are considered part of the private API - * @api {get} /amazon/subscribe/cancel SubscribeCancel + * @api {get} /amazon/subscribe/cancel Amazon Payments: subscribe cancel * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments @@ -249,21 +235,20 @@ api.subscribeCancel = { if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription')); - try { - await amzLib.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }); + await amzLib.closeBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }); - let data = { - user, - nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }), - paymentMethod: 'Amazon Payments', - }; - await payments.cancelSubscription(data); + await payments.cancelSubscription({ + user, + nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }), + paymentMethod: 'Amazon Payments', + }); + if (req.query.noRedirect) { + res.respond(200); + } else { res.redirect('/'); - } catch (error) { - throw new BadRequest(error.message); } }, }; diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index a319d2d4f9..2ac8c863f7 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -45,6 +45,8 @@ api.checkout = { let coupon; let response; + if (!token) throw new BadRequest('Missing req.body.id'); + if (sub) { if (sub.discount) { if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired')); @@ -127,10 +129,12 @@ api.subscribeEdit = { let customerId = user.purchased.plan.customerId; if (!customerId) throw new NotAuthorized(res.t('missingSubscription')); + if (!token) throw new BadRequest('Missing req.body.id'); let subscriptions = await stripe.customers.listSubscriptions(customerId); let subscriptionId = subscriptions.data[0].id; await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); + res.respond(200, {}); }, }; diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index c22b3cfb3b..338a6acd08 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -6,7 +6,9 @@ import { BadRequest, } from './errors'; -const t = common.i18n.t; +// TODO better handling of errors + +const i18n = common.i18n; const IS_PROD = nconf.get('NODE_ENV') === 'production'; let amzPayment = amazonPayments.connect({ @@ -30,7 +32,7 @@ let authorizeOnBillingAgreement = (inputSet) => { return new Promise((resolve, reject) => { amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => { if (err) return reject(err); - if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful'))); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful'))); return resolve(response); }); }); @@ -40,7 +42,7 @@ let authorize = (inputSet) => { return new Promise((resolve, reject) => { amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { if (err) return reject(err); - if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful'))); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful'))); return resolve(response); }); }); From cd84ebd4c5da6f61e959636ac55b4d696508d592 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 10 May 2016 16:57:12 +0200 Subject: [PATCH 758/976] v3 payments: fix client errors for Stripe --- website/public/js/services/paymentServices.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/public/js/services/paymentServices.js b/website/public/js/services/paymentServices.js index c5bfeae262..fad384befc 100644 --- a/website/public/js/services/paymentServices.js +++ b/website/public/js/services/paymentServices.js @@ -37,7 +37,7 @@ function($rootScope, User, $http, Content) { $http.post(url, res).success(function() { window.location.reload(true); }).error(function(res) { - alert(res.err); + alert(res.message); }); } }); @@ -55,7 +55,7 @@ function($rootScope, User, $http, Content) { $http.post(url, data).success(function() { window.location.reload(true); }).error(function(data) { - alert(data.err); + alert(data.message); }); } }); From 1c887b18e1aec0ddcbd0175c217f78e4b2882325 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 10 May 2016 17:00:47 +0200 Subject: [PATCH 759/976] v3 payments: fix urls in tests --- .../payments/GET-payments_amazon_subscribe_cancel.test.js | 2 +- .../integration/payments/GET-payments_paypal_checkout.test.js | 2 +- .../payments/GET-payments_paypal_checkout_success.test.js | 2 +- .../integration/payments/GET-payments_paypal_subscribe.test.js | 2 +- .../payments/GET-payments_paypal_subscribe_cancel.test.js | 2 +- .../payments/GET-payments_paypal_subscribe_success.test.js | 2 +- .../payments/GET-payments_stripe_subscribe_cancel.test.js | 2 +- .../integration/payments/POST-payments_amazon_checkout.test.js | 2 +- .../POST-payments_amazon_createOrderReferenceId.test.js | 2 +- .../integration/payments/POST-payments_amazon_subscribe.test.js | 2 +- .../payments/POST-payments_amazon_verifyAccessToken.test.js | 2 +- .../v3/integration/payments/POST-payments_paypal_ipn.test.js | 2 +- .../integration/payments/POST-payments_stripe_checkout.test.js | 2 +- .../payments/POST-payments_stripe_subscribe_edit.test.js | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js index 1f05739bb9..007c58f4f7 100644 --- a/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js +++ b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : amazon #subscribeCancel', () => { - let endpoint = '/payments/amazon/subscribe/cancel'; + let endpoint = '/amazon/subscribe/cancel'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js b/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js index 12ea7c8ee9..25fc501000 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : paypal #checkout', () => { - let endpoint = '/payments/paypal/checkout'; + let endpoint = '/paypal/checkout'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js b/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js index 4dae9d8485..346b8ce847 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : paypal #checkoutSuccess', () => { - let endpoint = '/payments/paypal/checkout/success'; + let endpoint = '/paypal/checkout/success'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js index 7640cfdf92..c52309675a 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : paypal #subscribe', () => { - let endpoint = '/payments/paypal/subscribe'; + let endpoint = '/paypal/subscribe'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js index 2e4ccedf01..890bc864b6 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : paypal #subscribeCancel', () => { - let endpoint = '/payments/paypal/subscribe/cancel'; + let endpoint = '/paypal/subscribe/cancel'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js index 961556ff8b..31bae03e40 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : paypal #subscribeSuccess', () => { - let endpoint = '/payments/paypal/subscribe/success'; + let endpoint = '/paypal/subscribe/success'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js index b65d4ea6c2..68747eb535 100644 --- a/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js +++ b/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - stripe - #subscribeCancel', () => { - let endpoint = '/payments/stripe/subscribe/cancel'; + let endpoint = '/stripe/subscribe/cancel'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js index 846416ed5d..6a574eb204 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - amazon - #checkout', () => { - let endpoint = '/payments/amazon/checkout'; + let endpoint = '/amazon/checkout'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js b/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js index 3eb00b7c3c..17a50520eb 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - amazon - #createOrderReferenceId', () => { - let endpoint = '/payments/amazon/createOrderReferenceId'; + let endpoint = '/amazon/createOrderReferenceId'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js b/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js index 02a30a7ce5..5c3b98ad87 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - amazon - #subscribe', () => { - let endpoint = '/payments/amazon/subscribe'; + let endpoint = '/amazon/subscribe'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js index ecc021e25d..db8edbabb0 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : amazon', () => { - let endpoint = '/payments/amazon/verifyAccessToken'; + let endpoint = '/amazon/verifyAccessToken'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js index dcdbd14c44..f8e6c74f82 100644 --- a/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js +++ b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - paypal - #ipn', () => { - let endpoint = '/payments/paypal/ipn'; + let endpoint = '/paypal/ipn'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js index bc4d857a03..3f0cc15eaf 100644 --- a/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js +++ b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - stripe - #checkout', () => { - let endpoint = '/payments/stripe/checkout'; + let endpoint = '/stripe/checkout'; let user; beforeEach(async () => { diff --git a/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js b/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js index c456d389a4..4b2f889888 100644 --- a/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js +++ b/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - stripe - #subscribeEdit', () => { - let endpoint = '/payments/stripe/subscribe/edit'; + let endpoint = '/stripe/subscribe/edit'; let user; beforeEach(async () => { From d33564e5d445de8b5e4f093b48f01e347f8c4ed9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 10 May 2016 17:57:55 +0200 Subject: [PATCH 760/976] v3 payments: port paypal --- test/helpers/api-integration/requester.js | 2 +- .../controllers/top-level/payments/paypal.js | 188 ++++++++---------- 2 files changed, 82 insertions(+), 108 deletions(-) diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index 1be38efe16..ee3adf243f 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -33,7 +33,7 @@ function _requestMaker (user, method, additionalSets = {}) { let url = `http://localhost:${API_TEST_SERVER_PORT}`; // do not prefix with api/apiVersion requests to top level routes like dataexport and payments - if (route.indexOf('/export') === 0 || route.indexOf('/payments') === 0) { + if (route.indexOf('/export') === 0 || route.indexOf('/paypal') === 0 || route.indexOf('/amazon') === 0 || route.indexOf('/stripe') === 0) { url += `${route}`; } else { url += `/api/${apiVersion}${route}`; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 1a3240d82e..a940511c64 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -8,6 +8,7 @@ import ipn from 'paypal-ipn'; import paypal from 'paypal-rest-sdk'; import shared from '../../../../../common'; import cc from 'coupon-code'; +import Q from 'q'; import { model as Coupon } from '../../../models/coupon'; import { model as User } from '../../../models/user'; import { @@ -16,8 +17,8 @@ import { } from '../../../middlewares/api-v3/auth'; import { BadRequest, + NotAuthorized, } from '../../../libs/api-v3/errors'; -import * as logger from '../../../libs/api-v3/logger'; const BASE_URL = nconf.get('BASE_URL'); @@ -34,17 +35,24 @@ paypal.configure({ client_secret: nconf.get('PAYPAL:client_secret'), }); +// TODO better handling of errors +const paypalPaymentCreate = Q.nbind(paypal.payment.create, paypal.payment); +const paypalPaymentExecute = Q.nbind(paypal.payment.execute, paypal.payment); +const paypalBillingAgreementCreate = Q.nbind(paypal.billingAgreement.create, paypal.billingAgreement); +const paypalBillingAgreementExecute = Q.nbind(paypal.billingAgreement.execute, paypal.billingAgreement); +const paypalBillingAgreementGet = Q.nbind(paypal.billingAgreement.get, paypal.billingAgreement); +const paypalBillingAgreementCancel = Q.nbind(paypal.billingAgreement.cancel, paypal.billingAgreement); + +const ipnVerifyAsync = Q.nbind(ipn.verify, ipn); + let api = {}; /** * @apiIgnore Payments are considered part of the private API - * @api {get} /paypal/checkout Paypal checkout - * @apiDescription Redirects to Paypal + * @api {get} /paypal/checkout Paypal: checkout * @apiVersion 3.0.0 * @apiName PaypalCheckout * @apiGroup Payments - * - * @apiParam {string} gift Query parameter - The stringified object representing the user, the gift recepient. **/ api.checkout = { method: 'GET', @@ -62,7 +70,7 @@ api.checkout = { description = `${description} (Gift)`; } else { amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); - description = 'monthly HabitRPG Subscription (Gift)'; + description = 'mo. HabitRPG Subscription (Gift)'; } } @@ -77,6 +85,7 @@ api.checkout = { item_list: { items: [{ name: description, + // sku: 1, price: amount, currency: 'USD', quality: 1, @@ -90,27 +99,18 @@ api.checkout = { }], }; - try { - let result = await paypal.payment.create(createPayment); - let link = _.find(result.links, { rel: 'approval_url' }).href; - res.redirect(link); - } catch (e) { - throw new BadRequest(e); - } + let result = await paypalPaymentCreate(createPayment); + let link = _.find(result.links, { rel: 'approval_url' }).href; + res.redirect(link); }, }; /** * @apiIgnore Payments are considered part of the private API - * @api {get} /paypal/checkout/success Paypal checkout success + * @api {get} /paypal/checkout/success Paypal: checkout success * @apiVersion 3.0.0 * @apiName PaypalCheckoutSuccess * @apiGroup Payments - * - * @apiParam {string} paymentId The payment id - * @apiParam {string} payerID The payer id, notice ID not id - * - * @apiSuccess {} redirect **/ api.checkoutSuccess = { method: 'GET', @@ -119,6 +119,7 @@ api.checkoutSuccess = { async handler (req, res) { let paymentId = req.query.paymentId; let customerId = req.query.payerID; + let method = 'buyGems'; let data = { user: res.locals.user, @@ -126,38 +127,31 @@ api.checkoutSuccess = { paymentMethod: 'Paypal', }; - try { - let gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; - delete req.session.gift; - if (gift) { - gift.member = await User.findById(gift.uuid); - if (gift.type === 'subscription') { - method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - data.gift = gift; + let gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; + delete req.session.gift; + + if (gift) { + gift.member = await User.findById(gift.uuid); + if (gift.type === 'subscription') { + method = 'createSubscription'; } - await paypal.payment.execute(paymentId, { payer_id: customerId }); - await payments[method](data); - res.redirect('/'); - } catch (e) { - throw new BadRequest(e); + data.paymentMethod = 'Gift'; + data.gift = gift; } + + await paypalPaymentExecute(paymentId, { payer_id: customerId }); + await payments[method](data); + res.redirect('/'); }, }; /** * @apiIgnore Payments are considered part of the private API - * @api {get} /paypal/subscribe Paypal subscribe + * @api {get} /paypal/subscribe Paypal: subscribe * @apiVersion 3.0.0 * @apiName PaypalSubscribe * @apiGroup Payments - * - * @apiParam {string} sub subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo - * @apiParam {string} coupon coupon for the matching subscription, required only for certain subscriptions - * - * @apiSuccess {} empty object **/ api.subscribe = { method: 'GET', @@ -165,17 +159,18 @@ api.subscribe = { middlewares: [authWithUrl], async handler (req, res) { let sub = shared.content.subscriptionBlocks[req.query.sub]; + if (sub.discount) { if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired')); let coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key}); - if (!coupon) throw new BadRequest(res.t('invalidCoupon')); + if (!coupon) throw new NotAuthorized(res.t('invalidCoupon')); } let billingPlanTitle = `HabitRPG Subscription ($${sub.price} every ${sub.months} months, recurring)`; let billingAgreementAttributes = { name: billingPlanTitle, description: billingPlanTitle, - start_date: moment().add({ minutes: 5}).format(), + start_date: moment().add({ minutes: 5 }).format(), plan: { id: sub.paypalKey, }, @@ -183,27 +178,20 @@ api.subscribe = { payment_method: 'Paypal', }, }; - try { - let billingAgreement = await paypal.billingAgreement.create(billingAgreementAttributes); - req.session.paypalBlock = req.query.sub; - let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href; - res.redirect(link); - } catch (e) { - throw new BadRequest(e); - } + let billingAgreement = await paypalBillingAgreementCreate(billingAgreementAttributes); + + req.session.paypalBlock = req.query.sub; + let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href; + res.redirect(link); }, }; /** * @apiIgnore Payments are considered part of the private API - * @api {get} /paypal/subscribe/success Paypal subscribe success + * @api {get} /paypal/subscribe/success Paypal: subscribe success * @apiVersion 3.0.0 * @apiName PaypalSubscribeSuccess * @apiGroup Payments - * - * @apiParam {string} token The token in query - * - * @apiSuccess {} redirect **/ api.subscribeSuccess = { method: 'GET', @@ -213,31 +201,25 @@ api.subscribeSuccess = { let user = res.locals.user; let block = shared.content.subscriptionBlocks[req.session.paypalBlock]; delete req.session.paypalBlock; - try { - let result = await paypal.billingAgreement.execute(req.query.token, {}); - await payments.createSubscription({ - user, - customerId: result.id, - paymentMethod: 'Paypal', - sub: block, - }); - res.redirect('/'); - } catch (e) { - throw new BadRequest(e); - } + + let result = await paypalBillingAgreementExecute(req.query.token, {}); + await payments.createSubscription({ + user, + customerId: result.id, + paymentMethod: 'Paypal', + sub: block, + }); + + res.redirect('/'); }, }; /** * @apiIgnore Payments are considered part of the private API - * @api {get} /paypal/subscribe/cancel Paypal subscribe cancel + * @api {get} /paypal/subscribe/cancel Paypal: subscribe cancel * @apiVersion 3.0.0 * @apiName PaypalSubscribeCancel * @apiGroup Payments - * - * @apiParam {string} token The token in query - * - * @apiSuccess {} redirect **/ api.subscribeCancel = { method: 'GET', @@ -246,59 +228,51 @@ api.subscribeCancel = { async handler (req, res) { let user = res.locals.user; let customerId = user.purchased.plan.customerId; - if (!user.purchased.plan.customerId) throw new BadRequest(res.t('missingSubscription')); - try { - let customer = await paypal.billingAgreement.get(customerId); - let nextBillingDate = customer.agreement_details.next_billing_date; - if (customer.agreement_details.cycles_completed === '0') { // hasn't billed yet - throw new BadRequest(res.t('planNotActive', { nextBillingDate })); - } - await paypal.billingAgreement.cancel(customerId, { note: res.t('cancelingSubscription') }); - let data = { - user, - paymentMethod: 'Paypal', - nextBill: nextBillingDate, - }; - await payments.cancelSubscription(data); - res.redirect('/'); - } catch (e) { - throw new BadRequest(e); + if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription')); + + let customer = await paypalBillingAgreementGet(customerId); + + let nextBillingDate = customer.agreement_details.next_billing_date; + if (customer.agreement_details.cycles_completed === '0') { // hasn't billed yet + throw new BadRequest(res.t('planNotActive', { nextBillingDate })); } + + await paypalBillingAgreementCancel(customerId, { note: res.t('cancelingSubscription') }); + await payments.cancelSubscription({ + user, + paymentMethod: 'Paypal', + nextBill: nextBillingDate, + }); + + res.redirect('/'); }, }; +// General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their +// recurring paypal payments in their paypal dashboard. TODO ? Remove this when we can move to webhooks or some other solution + /** * @apiIgnore Payments are considered part of the private API * @api {post} /paypal/ipn Paypal IPN * @apiVersion 3.0.0 * @apiName PaypalIpn * @apiGroup Payments - * - * @apiParam {string} txn_type txn_type - * @apiParam {string} recurring_payment_id recurring_payment_id - * - * @apiSuccess {} empty object **/ api.ipn = { method: 'POST', url: '/paypal/ipn', - middlewares: [], async handler (req, res) { - res.respond(200); - try { - await ipn.verify(req.body); - if (req.body.txn_type === 'recurring_payment_profile_cancel' || req.body.txn_type === 'subscr_cancel') { - let user = await User.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id }); - if (user) { - payments.cancelSubscriptoin({ user, paymentMethod: 'Paypal' }); - } + res.sendStatus(200); + + await ipnVerifyAsync(req.body); + + if (req.body.txn_type === 'recurring_payment_profile_cancel' || req.body.txn_type === 'subscr_cancel') { + let user = await User.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id }); + if (user) { + await payments.cancelSubscription({ user, paymentMethod: 'Paypal' }); } - } catch (e) { - logger.error(e); } }, }; -/* eslint-disable camelcase */ - module.exports = api; From b3a78fba973dd59cd7d3406769b98a0f9576ae49 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 10 May 2016 18:14:28 +0200 Subject: [PATCH 761/976] v3 payments: fix tests --- common/locales/en/api-v3.json | 2 -- .../payments/POST-payments_amazon_checkout.test.js | 14 ++++++-------- .../POST-payments_amazon_verifyAccessToken.test.js | 3 +-- .../payments/POST-payments_paypal_ipn.test.js | 2 +- .../payments/POST-payments_stripe_checkout.test.js | 2 +- .../POST-payments_stripe_subscribe_edit.test.js | 4 ++-- 6 files changed, 11 insertions(+), 16 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index d41c2a78d6..e0e768adf8 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -175,8 +175,6 @@ "resetComplete": "Reset completed", "lvl10ChangeClass": "To change class you must be at least level 10.", "equipmentAlreadyOwned": "You already own that piece of equipment", - "missingAccessToken": "The request is missing a required parameter : access_token", - "missingBillingAgreementId": "Missing billing agreement id", "paymentNotSuccessful": "The payment was not successful", "planNotActive": "The plan hasn't activated yet (due to a PayPal bug). It will begin <%= nextBillingDate %>, after which you can cancel to retain your full benefits", "cancelingSubscription": "Canceling the subscription" diff --git a/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js index 6a574eb204..8745a74e85 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js @@ -10,13 +10,11 @@ describe('payments - amazon - #checkout', () => { user = await generateUser(); }); - it('verifies credentials', async (done) => { - try { - await user.post(endpoint); - } catch (e) { - expect(e.error).to.eql('BadRequest'); - expect(e.message.type).to.eql('InvalidParameterValue'); - done(); - } + it('verifies credentials', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Missing req.body.orderReferenceId', + }); }); }); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js index db8edbabb0..51ccf8c41c 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js @@ -1,6 +1,5 @@ import { generateUser, - translate as t, } from '../../../../helpers/api-integration/v3'; describe('payments : amazon', () => { @@ -15,7 +14,7 @@ describe('payments : amazon', () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: t('missingAccessToken'), + message: 'Missing req.body.access_token', }); }); }); diff --git a/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js index f8e6c74f82..219e9ce35b 100644 --- a/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js +++ b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js @@ -12,6 +12,6 @@ describe('payments - paypal - #ipn', () => { it('verifies credentials', async () => { let result = await user.post(endpoint); - expect(result).to.eql({}); + expect(result).to.eql('OK'); }); }); diff --git a/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js index 3f0cc15eaf..1443a3af74 100644 --- a/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js +++ b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js @@ -11,7 +11,7 @@ describe('payments - stripe - #checkout', () => { }); it('verifies credentials', async () => { - await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + await expect(user.post(endpoint, {id: 123})).to.eventually.be.rejected.and.eql({ code: 401, error: 'Error', message: 'Invalid API Key provided: ****************************1111', diff --git a/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js b/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js index 4b2f889888..d6d568ace4 100644 --- a/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js +++ b/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js @@ -13,8 +13,8 @@ describe('payments - stripe - #subscribeEdit', () => { it('verifies credentials', async () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', + code: 401, + error: 'NotAuthorized', message: t('missingSubscription'), }); }); From b037ddd14c6158e58c3adce2247589c83b6ec6b5 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 5 May 2016 13:02:56 -0500 Subject: [PATCH 762/976] Ported User Serivce to client side and to api v3 --- common/script/ops/clearPMs.js | 2 +- common/script/public/userServices.js | 268 ----------- .../js/controllers/copyMessageModalCtrl.js | 2 +- website/public/js/controllers/filtersCtrl.js | 4 +- website/public/js/controllers/footerCtrl.js | 11 +- website/public/js/controllers/groupsCtrl.js | 2 +- .../public/js/controllers/inventoryCtrl.js | 24 +- website/public/js/controllers/partyCtrl.js | 2 +- website/public/js/controllers/rootCtrl.js | 9 +- website/public/js/controllers/settingsCtrl.js | 14 +- website/public/js/controllers/tasksCtrl.js | 29 +- website/public/js/controllers/userCtrl.js | 10 +- website/public/js/services/userServices.js | 416 ++++++++++++++++++ website/public/manifest.json | 8 +- website/views/options/social/tavern.jade | 2 +- website/views/shared/tasks/index.jade | 2 +- 16 files changed, 474 insertions(+), 331 deletions(-) delete mode 100644 common/script/public/userServices.js create mode 100644 website/public/js/services/userServices.js diff --git a/common/script/ops/clearPMs.js b/common/script/ops/clearPMs.js index 765ecc3b56..537ef26348 100644 --- a/common/script/ops/clearPMs.js +++ b/common/script/ops/clearPMs.js @@ -1,6 +1,6 @@ module.exports = function clearPMs (user) { user.inbox.messages = {}; - user.markModified('inbox.messages'); + // user.markModified('inbox.messages'); return [ user.inbox.messages, ]; diff --git a/common/script/public/userServices.js b/common/script/public/userServices.js deleted file mode 100644 index 4fb92ce42a..0000000000 --- a/common/script/public/userServices.js +++ /dev/null @@ -1,268 +0,0 @@ -'use strict'; - -angular.module('habitrpg') - .service('ApiUrl', ['API_URL', function(currentApiUrl){ - this.setApiUrl = function(newUrl){ - currentApiUrl = newUrl; - }; - - this.get = function(){ - return currentApiUrl; - }; - }]) - -/** - * Services that persists and retrieves user from localStorage. - */ - .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'MOBILE_APP', 'Notification', 'ApiUrl', - function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, MOBILE_APP, Notification, ApiUrl) { - var authenticated = false; - var defaultSettings = { - auth: { apiId: '', apiToken: ''}, - sync: { - queue: [], //here OT will be queued up, this is NOT call-back queue! - sent: [] //here will be OT which have been sent, but we have not got reply from server yet. - }, - fetching: false, // whether fetch() was called or no. this is to avoid race conditions - online: false - }; - var settings = {}; //habit mobile settings (like auth etc.) to be stored here - var user = {}; // this is stored as a reference accessible to all controllers, that way updates propagate - - var userNotifications = { - // "party.order" : env.t("updatedParty"), - // "party.orderAscending" : env.t("updatedParty") - // party.order notifications are not currently needed because the party avatars are resorted immediately now - }; // this is a list of notifications to send to the user when changes are made, along with the message. - - //first we populate user with schema - user.apiToken = user._id = ''; // we use id / apitoken to determine if registered - - //than we try to load localStorage - if (localStorage.getItem(STORAGE_USER_ID)) { - _.extend(user, JSON.parse(localStorage.getItem(STORAGE_USER_ID))); - } - user._wrapped = false; - - var syncQueue = function (cb) { - if (!authenticated) { - $window.alert("Not authenticated, can't sync, go to settings first."); - return; - } - - var queue = settings.sync.queue; - var sent = settings.sync.sent; - if (queue.length === 0) { - // Sync: Queue is empty - return; - } - if (settings.fetching) { - // Sync: Already fetching - return; - } - if (settings.online!==true) { - // Sync: Not online - return; - } - - settings.fetching = true; - // move all actions from queue array to sent array - _.times(queue.length, function () { - sent.push(queue.shift()); - }); - - // Save the current filters - var current_filters = user.filters; - - $http.post(ApiUrl.get() + '/api/v2/user/batch-update', sent, {params: {data:+new Date, _v:user._v, siteVersion: $window.env && $window.env.siteVersion}}) - .success(function (data, status, heacreatingders, config) { - //make sure there are no pending actions to sync. If there are any it is not safe to apply model from server as we may overwrite user data. - if (!queue.length) { - //we can't do user=data as it will not update user references in all other angular controllers. - - // the user has been modified from another application, sync up - if(data && data.wasModified) { - delete data.wasModified; - $rootScope.$emit('userUpdated', user); - } - - // Update user - _.extend(user, data); - // Preserve filter selections between syncs - _.extend(user.filters,current_filters); - if (!user._wrapped){ - - // This wraps user with `ops`, which are functions shared both on client and mobile. When performed on client, - // they update the user in the browser and then send the request to the server, where the same operation is - // replicated. We need to wrap each op to provide a callback to send that operation - $window.habitrpgShared.wrap(user); - _.each(user.ops, function(op,k){ - user.ops[k] = function(req,cb){ - if (cb) return op(req,cb); - op(req,function(err,response) { - for(var updatedItem in req.body) { - var itemUpdateResponse = userNotifications[updatedItem]; - if(itemUpdateResponse) Notification.text(itemUpdateResponse.data.message); - } - if (err) { - var message = err.code ? err.data.message : err; - if (MOBILE_APP) Notification.push({type:'text', text: message}); - else Notification.text(message); - // In the case of 200s, they're friendly alert messages like "Your pet has hatched!" - still send the op - if ((err.code && err.code >= 400) || !err.code) return; - } - userServices.log({op:k, params: req.params, query:req.query, body:req.body}); - }); - } - }); - } - - // Emit event when user is synced - $rootScope.$emit('userSynced'); - } - sent.length = 0; - settings.fetching = false; - save(); - if (cb) { - cb(false) - } - - syncQueue(); // call syncQueue to check if anyone pushed more actions to the queue while we were talking to server. - }) - .error(function (data, status, headers, config) { - // (Notifications handled in app.js) - - // If we're offline, queue up offline actions so we can send when we're back online - if (status === 0) { - //move sent actions back to queue - _.times(sent.length, function () { - queue.push(sent.shift()) - }); - settings.fetching = false; - // In the case of errors, discard the corrupt queue - } else { - // Clear the queue. Better if we can hunt down the problem op, but this is the easiest solution - settings.sync.queue = settings.sync.sent = []; - save(); - } - }); - } - - - var save = function () { - localStorage.setItem(STORAGE_USER_ID, JSON.stringify(user)); - localStorage.setItem(STORAGE_SETTINGS_ID, JSON.stringify(settings)); - }; - var userServices = { - user: user, - set: function(updates) { - user.ops.update({body:updates}); - }, - - online: function (status) { - if (status===true) { - settings.online = true; - syncQueue(); - } else { - settings.online = false; - }; - }, - - authenticate: function (uuid, token, cb) { - if (!!uuid && !!token) { - var offset = moment().zone(); // eg, 240 - this will be converted on server as -(offset/60) - $http.defaults.headers.common['x-api-user'] = uuid; - $http.defaults.headers.common['x-api-key'] = token; - $http.defaults.headers.common['x-user-timezoneOffset'] = offset; - authenticated = true; - settings.auth.apiId = uuid; - settings.auth.apiToken = token; - settings.online = true; - if (user && user._v) user._v--; // shortcut to always fetch new updates on page reload - userServices.log({}, function(){ - // If they don't have timezone, set it - if (user.preferences.timezoneOffset !== offset) - userServices.set({'preferences.timezoneOffset': offset}); - cb && cb(); - }); - } else { - alert('Please enter your ID and Token in settings.') - } - }, - - authenticated: function(){ - return this.settings.auth.apiId !== ""; - }, - - getBalanceInGems: function() { - var balance = user.balance || 0; - return balance * 4; - }, - - log: function (action, cb) { - //push by one buy one if an array passed in. - if (_.isArray(action)) { - action.forEach(function (a) { - settings.sync.queue.push(a); - }); - } else { - settings.sync.queue.push(action); - } - - save(); - syncQueue(cb); - }, - - sync: function(){ - user._v--; - userServices.log({}); - }, - - save: save, - - settings: settings - }; - - - //load settings if we have them - if (localStorage.getItem(STORAGE_SETTINGS_ID)) { - //use extend here to make sure we keep object reference in other angular controllers - _.extend(settings, JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID))); - - //if settings were saved while fetch was in process reset the flag. - settings.fetching = false; - //create and load if not - } else { - localStorage.setItem(STORAGE_SETTINGS_ID, JSON.stringify(defaultSettings)); - _.extend(settings, defaultSettings); - } - - //If user does not have ApiID that forward him to settings. - if (!settings.auth.apiId || !settings.auth.apiToken) { - - if (MOBILE_APP) { - $location.path("/login"); - } else { - //var search = $location.search(); // FIXME this should be working, but it's returning an empty object when at a root url /?_id=... - var search = $location.search($window.location.search.substring(1)).$$search; // so we use this fugly hack instead - if (search.err) return alert(search.err); - if (search._id && search.apiToken) { - userServices.authenticate(search._id, search.apiToken, function(){ - $window.location.href='/'; - }); - } else { - var isStaticOrSocial = $window.location.pathname.match(/^\/(static|social)/); - if (!isStaticOrSocial){ - localStorage.clear(); - $window.location.href = '/logout'; - } - } - } - - } else { - userServices.authenticate(settings.auth.apiId, settings.auth.apiToken) - } - - return userServices; - } -]); diff --git a/website/public/js/controllers/copyMessageModalCtrl.js b/website/public/js/controllers/copyMessageModalCtrl.js index 60d07ec152..1e58237454 100644 --- a/website/public/js/controllers/copyMessageModalCtrl.js +++ b/website/public/js/controllers/copyMessageModalCtrl.js @@ -9,7 +9,7 @@ habitrpg.controller("CopyMessageModalCtrl", ['$scope', 'User', 'Notification', notes: $scope.notes }; - User.user.ops.addTask({body:newTask}); + User.addTask({body:newTask}); Notification.text(window.env.t('messageAddedAsToDo')); $scope.$close(); diff --git a/website/public/js/controllers/filtersCtrl.js b/website/public/js/controllers/filtersCtrl.js index cfdc45658d..e2597af241 100644 --- a/website/public/js/controllers/filtersCtrl.js +++ b/website/public/js/controllers/filtersCtrl.js @@ -14,7 +14,7 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared', _.each(User.user.tags, function(tag){ // Send an update op for each changed tag (excluding new tags & deleted tags, this if() packs a punch) if (tagsSnap[tag.id] && tagsSnap[tag.id].name != tag.name) - User.user.ops.updateTag({params:{id:tag.id},body:{name:tag.name}}); + User.updateTag({params:{id:tag.id},body:{name:tag.name}}); }) $scope._editing = false; } else { @@ -37,7 +37,7 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared', $scope.updateTaskFilter(); $scope.createTag = function() { - User.user.ops.addTag({body:{name:$scope._newTag.name, id:Shared.uuid()}}); + User.addTag({body:{name:$scope._newTag.name, id:Shared.uuid()}}); $scope._newTag.name = ''; }; }]); diff --git a/website/public/js/controllers/footerCtrl.js b/website/public/js/controllers/footerCtrl.js index 42fd279944..7307adaee7 100644 --- a/website/public/js/controllers/footerCtrl.js +++ b/website/public/js/controllers/footerCtrl.js @@ -78,6 +78,7 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { }); }; + //@TODO: Route? $scope.addMissedDay = function(numberOfDays){ if (!confirm("Are you sure you want to reset the day by " + numberOfDays + " day(s)?")) return; var dayBefore = moment(User.user.lastCron).subtract(numberOfDays, 'days').toDate(); @@ -86,15 +87,12 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { }; $scope.addTenGems = function(){ - $http.post(ApiUrl.get() + '/api/v2/user/addTenGems').success(function(){ - User.log({}); - }) + User.addTenGems(); }; $scope.addHourglass = function(){ - $http.post(ApiUrl.get() + '/api/v2/user/addHourglass').success(function(){ - User.log({}); - }) + User.addHourglass(); + //User.log({}); }; $scope.addGold = function(){ @@ -124,6 +122,7 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { }; $scope.addBossQuestProgressUp = function(){ + //@TODO: Route? User.set({ 'party.quest.progress.up': User.user.party.quest.progress.up + 1000 }); diff --git a/website/public/js/controllers/groupsCtrl.js b/website/public/js/controllers/groupsCtrl.js index f932d62cb6..238cf75c49 100644 --- a/website/public/js/controllers/groupsCtrl.js +++ b/website/public/js/controllers/groupsCtrl.js @@ -68,7 +68,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.deleteAllMessages = function() { if (confirm(window.env.t('confirmDeleteAllMessages'))) { - User.user.ops.clearPMs({}); + User.clearPMs(); } }; diff --git a/website/public/js/controllers/inventoryCtrl.js b/website/public/js/controllers/inventoryCtrl.js index d0a80d67d5..761e708b37 100644 --- a/website/public/js/controllers/inventoryCtrl.js +++ b/website/public/js/controllers/inventoryCtrl.js @@ -99,7 +99,7 @@ habitrpg.controller("InventoryCtrl", var selected = $scope.selectedEgg ? 'selectedEgg' : $scope.selectedPotion ? 'selectedPotion' : $scope.selectedFood ? 'selectedFood' : undefined; if (selected) { var type = $scope.selectedEgg ? 'eggs' : $scope.selectedPotion ? 'hatchingPotions' : $scope.selectedFood ? 'food' : undefined; - user.ops.sell({params:{type:type, key: $scope[selected].key}}); + User.sell({params:{type:type, key: $scope[selected].key}}); if (user.items[type][$scope[selected].key] < 1) { $scope[selected] = null; } @@ -118,7 +118,7 @@ habitrpg.controller("InventoryCtrl", var userHasPet = user.items.pets[egg.key + '-' + potion.key] > 0; var isPremiumPet = Content.hatchingPotions[potion.key].premium && !Content.dropEggs[egg.key]; - user.ops.hatch({params:{egg:egg.key, hatchingPotion:potion.key}}); + User.hatch({params:{egg:egg.key, hatchingPotion:potion.key}}); if (!user.preferences.suppressModals.hatchPet && !userHasPet && !isPremiumPet) { $scope.hatchedPet = { @@ -172,7 +172,7 @@ habitrpg.controller("InventoryCtrl", } else if (!$window.confirm(window.env.t('feedPet', {name: petDisplayName, article: food.article, text: food.text()}))) { return; } - User.user.ops.feed({params:{pet: pet, food: food.key}}); + User.feed({params:{pet: pet, food: food.key}}); $scope.selectedFood = null; _updateDropAnimalCount(user.items); @@ -198,12 +198,12 @@ habitrpg.controller("InventoryCtrl", // Selecting Pet } else { - User.user.ops.equip({params:{type: 'pet', key: pet}}); + User.equip({params:{type: 'pet', key: pet}}); } } $scope.chooseMount = function(egg, potion) { - User.user.ops.equip({params:{type: 'mount', key: egg + '-' + potion}}); + User.equip({params:{type: 'mount', key: egg + '-' + potion}}); } $scope.getSeasonalShopArray = function(set){ @@ -230,7 +230,7 @@ habitrpg.controller("InventoryCtrl", for (item in user.items.gear.equipped){ var itemKey = user.items.gear.equipped[item]; if (user.items.gear.owned[itemKey]) { - user.ops.equip({params: {key: itemKey}}); + User.equip({params: {key: itemKey}}); } } break; @@ -239,7 +239,7 @@ habitrpg.controller("InventoryCtrl", for (item in user.items.gear.costume){ var itemKey = user.items.gear.costume[item]; if (user.items.gear.owned[itemKey]) { - user.ops.equip({params: {type:"costume", key: itemKey}}); + User.equip({params: {type:"costume", key: itemKey}}); } } break; @@ -247,17 +247,17 @@ habitrpg.controller("InventoryCtrl", case "petMountBackground": var pet = user.items.currentPet; if (pet) { - user.ops.equip({params:{type: 'pet', key: pet}}); + User.equip({params:{type: 'pet', key: pet}}); } var mount = user.items.currentMount; if (mount) { - user.ops.equip({params:{type: 'mount', key: mount}}); + User.equip({params:{type: 'mount', key: mount}}); } var background = user.preferences.background; if (background) { - User.user.ops.unlock({query:{path:"background."+background}}); + User.unlock({query:{path:"background."+background}}); } break; @@ -310,9 +310,9 @@ habitrpg.controller("InventoryCtrl", }; $scope.clickTimeTravelItem = function(type,key) { - if (user.purchased.plan.consecutive.trinkets < 1) return user.ops.hourglassPurchase({params:{type:type,key:key}}); + if (user.purchased.plan.consecutive.trinkets < 1) return User.hourglassPurchase({params:{type:type,key:key}}); if (!window.confirm(window.env.t('hourglassBuyItemConfirm'))) return; - user.ops.hourglassPurchase({params:{type:type,key:key}}); + User.hourglassPurchase({params:{type:type,key:key}}); }; function _updateDropAnimalCount(items) { diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js index 4ed77885f7..cfac272b34 100644 --- a/website/public/js/controllers/partyCtrl.js +++ b/website/public/js/controllers/partyCtrl.js @@ -29,7 +29,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.newGroup = { type: 'party' }; }); } - // Chat.seenMessage($scope.group._id); + function checkForNotifications () { // Checks if user's party has reached 2 players for the first time. if(!user.achievements.partyUp diff --git a/website/public/js/controllers/rootCtrl.js b/website/public/js/controllers/rootCtrl.js index eacc0d2cce..a4025ad23c 100644 --- a/website/public/js/controllers/rootCtrl.js +++ b/website/public/js/controllers/rootCtrl.js @@ -21,7 +21,8 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ if (!!fromState.name) Analytics.track({'hitType':'pageview','eventCategory':'navigation','eventAction':'navigate','page':'/#/'+toState.name}); // clear inbox when entering or exiting inbox tab if (fromState.name=='options.social.inbox' || toState.name=='options.social.inbox') { - User.user.ops.update && User.set({'inbox.newMessages':0}); + //@TODO: Protected path. We need a url + User.set({'inbox.newMessages': 0}); } }); @@ -218,11 +219,11 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ key: itemKey }; - user.ops.equip({ params: equipParams }); + User.equip({ params: equipParams }); } $rootScope.purchase = function(type, item){ - if (type == 'special') return user.ops.buySpecialSpell({params:{key:item.key}}); + if (type == 'special') return User.buySpecialSpell({params:{key:item.key}}); var gems = user.balance * 4; var price = item.value; @@ -248,7 +249,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ message += window.env.t('buyThis', {text: itemName, price: price, gems: gems}); if ($window.confirm(message)) - user.ops.purchase({params:{type:type,key:item.key}}); + User.purchase({params:{type:type,key:item.key}}); }; function _canBuyEquipment(itemKey) { diff --git a/website/public/js/controllers/settingsCtrl.js b/website/public/js/controllers/settingsCtrl.js index 24fb8a03dc..a93dde0fb3 100644 --- a/website/public/js/controllers/settingsCtrl.js +++ b/website/public/js/controllers/settingsCtrl.js @@ -99,7 +99,7 @@ habitrpg.controller('SettingsCtrl', $scope.popoverEl.popover('destroy'); if (confirm) { - User.user.ops.reroll({}); + User.reroll({}); $rootScope.$state.go('tasks'); } } @@ -124,7 +124,7 @@ habitrpg.controller('SettingsCtrl', $scope.popoverEl.popover('destroy'); if (confirm) { - User.user.ops.rebirth({}); + User.rebirth({}); $rootScope.$state.go('tasks'); } } @@ -175,7 +175,7 @@ habitrpg.controller('SettingsCtrl', } $scope.reset = function(){ - User.user.ops.reset({}); + User.reset({}); $rootScope.$state.go('tasks'); } @@ -235,7 +235,7 @@ habitrpg.controller('SettingsCtrl', var releaseFunction = RELEASE_ANIMAL_TYPES[type]; if (releaseFunction) { - User.user.ops[releaseFunction]({}); + User[releaseFunction]({}); $rootScope.$state.go('tasks'); } } @@ -246,15 +246,15 @@ habitrpg.controller('SettingsCtrl', $scope.hasWebhooks = _.size(webhooks); }) $scope.addWebhook = function(url) { - User.user.ops.addWebhook({body:{url:url, id:Shared.uuid()}}); + User.addWebhook({body:{url:url, id:Shared.uuid()}}); $scope._newWebhook.url = ''; } $scope.saveWebhook = function(id,webhook) { delete webhook._editing; - User.user.ops.updateWebhook({params:{id:id}, body:webhook}); + User.updateWebhook({params:{id:id}, body:webhook}); } $scope.deleteWebhook = function(id) { - User.user.ops.deleteWebhook({params:{id:id}}); + User.deleteWebhook({params:{id:id}}); } $scope.applyCoupon = function(coupon){ diff --git a/website/public/js/controllers/tasksCtrl.js b/website/public/js/controllers/tasksCtrl.js index 5e86a243e3..857f1e2393 100644 --- a/website/public/js/controllers/tasksCtrl.js +++ b/website/public/js/controllers/tasksCtrl.js @@ -24,7 +24,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N if (direction === 'down') $rootScope.playSound('Minus_Habit'); else if (direction === 'up') $rootScope.playSound('Plus_Habit'); } - User.user.ops.score({params:{id: task.id, direction:direction}}); + User.score({params:{id: task.id, direction:direction}}); Analytics.updateUser(); Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'score task','taskType':task.type,'direction':direction}); }; @@ -38,7 +38,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N }), }; - User.user.ops.addTask({body:newTask}); + User.addTask({body:newTask}); } $scope.addTask = function(addTo, listDef) { @@ -80,7 +80,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N */ $scope.pushTask = function(task, index, location) { var to = (location === 'bottom' || $scope.ctrlPressed) ? -1 : 0; - User.user.ops.sortTask({params:{id:task.id},query:{from:index, to:to}}) + User.sortTask({params:{id:task.id},query:{from:index, to:to}}) }; /** @@ -96,18 +96,13 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $scope.removeTask = function(task) { if (!confirm(window.env.t('sureDelete', {taskType: window.env.t(task.type), taskText: task.text}))) return; - User.user.ops.deleteTask({params:{id:task.id}}) + User.deleteTask({params:{id:task.id}}) }; $scope.saveTask = function(task, stayOpen, isSaveAndClose) { - //@TODO: We will need to fix tag saving when user service is ported since tags are attached at the user level - - if (task.checklist) { - task.checklist = _.filter(task.checklist, function(i) {return !!i.text}); - } - - User.user.ops.updateTask({params:{id:task.id},body:task}); - + if (task.checklist) + task.checklist = _.filter(task.checklist,function(i){return !!i.text}); + User.updateTask({params:{id:task.id},body:task}); if (!stayOpen) task._editing = false; if (isSaveAndClose) { @@ -177,10 +172,10 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N if (!task.checklist[$index].text) { // Don't allow creation of an empty checklist item // TODO Provide UI feedback that this item is still blank - } else if ($index == task.checklist.length - 1) { - User.user.ops.updateTask({params:{id:task.id},body:task}); - task.checklist.push({completed: false, text: ''}); - focusChecklist(task, task.checklist.length - 1); + } else if ($index == task.checklist.length-1){ + User.updateTask({params:{id:task.id},body:task}); // don't preen the new empty item + task.checklist.push({completed:false,text:''}); + focusChecklist(task,task.checklist.length-1); } else { $scope.saveTask(task, true); focusChecklist(task, $index + 1); @@ -238,7 +233,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $scope.buy = function(item) { playRewardSound(item); - User.user.ops.buy({params:{key:item.key}}); + User.buy({params:{key:item.key}}); }; /* diff --git a/website/public/js/controllers/userCtrl.js b/website/public/js/controllers/userCtrl.js index e345d8ba2e..b62207b945 100644 --- a/website/public/js/controllers/userCtrl.js +++ b/website/public/js/controllers/userCtrl.js @@ -17,17 +17,17 @@ habitrpg.controller("UserCtrl", ['$rootScope', '$scope', '$location', 'User', '$ }); $scope.allocate = function(stat){ - User.user.ops.allocate({query:{stat:stat}}); + User.allocate({query:{stat:stat}}); } $scope.changeClass = function(klass){ if (!klass) { if (!confirm(window.env.t('sureReset'))) return; - return User.user.ops.changeClass({}); + return User.changeClass({}); } - User.user.ops.changeClass({query:{class:klass}}); + User.changeClass({query:{class:klass}}); $scope.selectedClass = undefined; Shared.updateStore(User.user); Guide.goto('classes', 0,true); @@ -46,7 +46,7 @@ habitrpg.controller("UserCtrl", ['$rootScope', '$scope', '$location', 'User', '$ } $scope.acknowledgeHealthWarning = function(){ - User.user.ops.update && User.set({'flags.warnedLowHealth':true}); + User.set({'flags.warnedLowHealth':true}); } /** @@ -69,7 +69,7 @@ habitrpg.controller("UserCtrl", ['$rootScope', '$scope', '$location', 'User', '$ if (confirm(window.env.t('purchaseFor',{cost:cost*4})) !== true) return; if (User.user.balance < cost) return $rootScope.openModal('buyGems'); } - User.user.ops.unlock({query:{path:path}}) + User.unlock({query:{path:path}}) } $scope.ownsSet = function(type,_set) { diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js new file mode 100644 index 0000000000..ea0f706d2e --- /dev/null +++ b/website/public/js/services/userServices.js @@ -0,0 +1,416 @@ +'use strict'; + +angular.module('habitrpg') + .service('ApiUrl', ['API_URL', function(currentApiUrl) { + this.setApiUrl = function(newUrl){ + currentApiUrl = newUrl; + }; + + this.get = function(){ + return currentApiUrl; + }; + }]) + +/** + * Services that persists and retrieves user from localStorage. + */ + .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'MOBILE_APP', 'Notification', 'ApiUrl', + function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, MOBILE_APP, Notification, ApiUrl) { + var authenticated = false; + var defaultSettings = { + auth: { apiId: '', apiToken: ''}, + sync: { + queue: [], //here OT will be queued up, this is NOT call-back queue! + sent: [] //here will be OT which have been sent, but we have not got reply from server yet. + }, + fetching: false, // whether fetch() was called or no. this is to avoid race conditions + online: false + }; + var settings = {}; //habit mobile settings (like auth etc.) to be stored here + var user = {}; // this is stored as a reference accessible to all controllers, that way updates propagate + + var userNotifications = { + // "party.order" : env.t("updatedParty"), + // "party.orderAscending" : env.t("updatedParty") + // party.order notifications are not currently needed because the party avatars are resorted immediately now + }; // this is a list of notifications to send to the user when changes are made, along with the message. + + //first we populate user with schema + user.apiToken = user._id = ''; // we use id / apitoken to determine if registered + + //than we try to load localStorage + if (localStorage.getItem(STORAGE_USER_ID)) { + _.extend(user, JSON.parse(localStorage.getItem(STORAGE_USER_ID))); + } + + user._wrapped = false; + + function sync() { + $http({ + method: "GET", + url: 'api/v3/user/', + }) + .then(function (response) { + Notification.text(response.data.message); + + _.extend(user, response.data.data); + + if (!user._wrapped) { + // This wraps user with `ops`, which are functions shared both on client and mobile. When performed on client, + // they update the user in the browser and then send the request to the server, where the same operation is + // replicated. We need to wrap each op to provide a callback to send that operation + $window.habitrpgShared.wrap(user); + _.each(user.ops, function(op,k){ + user.ops[k] = function(req,cb){ + if (cb) return op(req,cb); + op(req,function(err,response) { + for(var updatedItem in req.body) { + var itemUpdateResponse = userNotifications[updatedItem]; + if(itemUpdateResponse) Notification.text(itemUpdateResponse); + } + if (err) { + var message = err.code ? err.message : err; + if (MOBILE_APP) Notification.push({type:'text',text:message}); + else Notification.text(message); + // In the case of 200s, they're friendly alert messages like "Your pet has hatched!" - still send the op + if ((err.code && err.code >= 400) || !err.code) return; + } + userServices.log({op:k, params: req.params, query:req.query, body:req.body}); + }); + } + }); + } + + save(); + $rootScope.$emit('userSynced'); + }) + } + sync(); + + var save = function () { + localStorage.setItem(STORAGE_USER_ID, JSON.stringify(user)); + localStorage.setItem(STORAGE_SETTINGS_ID, JSON.stringify(settings)); + }; + + function callOpsFunctionAndRequest (opName, endPoint, method, paramString, opData) { + if (!opData) opData = {}; + $window.habitrpgShared.ops[opName](user, opData); + + var url = 'api/v3/user/' + endPoint; + if (paramString) { + url += '/' + paramString + } + + var body = {}; + if (opData.body) body = opData.body; + + var queryString = ''; + if (opData.query) queryString = '?' + $.param(opData.query) + + $http({ + method: method, + url: url + queryString, + body: body, + }) + .then(function (response) { + Notification.text(response.data.message); + save(); + }) + } + + function setUser(updates) { + for (var key in updates) { + user[key] = updates[key]; + } + + sync(); + } + + var userServices = { + user: user, + + allocate: function (data) { + callOpsFunctionAndRequest('allocate', 'allocate', "POST",'', data); + }, + + changeClass: function (data) { + callOpsFunctionAndRequest('changeClass', 'change-class', "POST",'', data); + }, + + addTask: function (data) { + //@TODO: Should this been on habitrpgShared? + user.ops.addTask(data); + save(); + //@TODO: Call task service when PR is merged + }, + + score: function (data) { + user.ops.scoreTask(data); + save(); + //@TODO: Call task service when PR is merged + }, + + sortTask: function (data) { + user.ops.sortTask(data); + save(); + //@TODO: Call task service when PR is merged + }, + + updateTask: function (data) { + user.ops.updateTask(data); + save(); + //@TODO: Call task service when PR is merged + }, + + deleteTask: function (data) { + user.ops.deleteTask(data); + save(); + //@TODO: Call task service when PR is merged + }, + + addTag: function(data) { + user.ops.addTag(data); + save(); + //@TODO: Call task service when PR is merged + }, + + updateTag: function(data) { + user.ops.updateTag(data); + save(); + //@TODO: Call task service when PR is merged + }, + + addTenGems: function () { + $http({ + method: "POST", + url: 'api/v3/debug/add-ten-gems', + }) + .then(function (response) { + Notification.text('+10 Gems!'); + sync(); + }) + }, + + addHourglass: function () { + $http({ + method: "POST", + url: 'api/v3/debug/add-hourglass', + }) + .then(function (response) { + sync(); + }) + }, + + clearPMs: function () { + callOpsFunctionAndRequest('clearPMs', 'messages', "DELETE"); + }, + + buy: function (data) { + callOpsFunctionAndRequest('buy', 'buy', "POST", data.params.key, data); + }, + + purchase: function (data) { + var type = data.params.type; + var key = data.params.key; + callOpsFunctionAndRequest('purchase', 'purchase', "POST", type + '/' + key, data); + }, + + buySpecialSpell: function (data) { + $window.habitrpgShared.ops['buySpecialSpell'](user, data); + var key = data.params.key; + + $http({ + method: "POST", + url: 'api/v3/user/' + 'buy-special-spell/' + key, + }) + .then(function (response) { + Notification.text(response.data.message); + }) + }, + + sell: function (data) { + var type = data.params.type; + var key = data.params.key; + callOpsFunctionAndRequest('sell', 'sell', "POST", type + '/' + key, data); + }, + + hatch: function (data) { + var egg = data.params.egg; + var hatchingPotion = data.params.hatchingPotion; + callOpsFunctionAndRequest('hatch', 'hatch', "POST", egg + '/' + hatchingPotion, data); + }, + + feed: function (data) { + var pet = data.params.pet; + var food = data.params.food; + callOpsFunctionAndRequest('feed', 'feed', "POST", pet + '/' + food, data); + }, + + equip: function (data) { + var type = data.params.type; + var key = data.params.key; + callOpsFunctionAndRequest('equip', 'equip', "POST", type + '/' + key, data); + }, + + hourglassPurchase: function (data) { + var type = data.params.type; + var key = data.params.key; + callOpsFunctionAndRequest('hourglassPurchase', 'purchase-hourglass', "POST", type + '/' + key, data); + }, + + unlock: function (data) { + $window.habitrpgShared.ops['unlock'](user, data); + callOpsFunctionAndRequest('unlock', 'unlock', "POST", '', data); + }, + + set: function(updates) { + setUser(updates); + }, + + reroll: function () { + callOpsFunctionAndRequest('reroll', 'reroll', "POST"); + }, + + rebirth: function () { + callOpsFunctionAndRequest('rebirth', 'rebirth', "POST"); + }, + + reset: function () { + callOpsFunctionAndRequest('reset', 'reset', "POST"); + }, + + releaseBoth: function () { + callOpsFunctionAndRequest('releaseBoth', 'releaseBoth', "POST"); + }, + + releaseMounts: function () { + callOpsFunctionAndRequest('releaseMounts', 'releaseMounts', "POST"); + }, + + releasePets: function () { + callOpsFunctionAndRequest('releasePets', 'releasePets', "POST"); + }, + + addWebhook: function (data) { + callOpsFunctionAndRequest('addWebhook', 'webhook', "POST", '', data, data.body); + }, + + updateWebhook: function (data) { + callOpsFunctionAndRequest('updateWebhook', 'webhook', "PUT", data.params.id, data, data.body); + }, + + deleteWebhook: function (data) { + callOpsFunctionAndRequest('deleteWebhook', 'webhook', "DELETE", data.params.id, data, data.body); + }, + + sleep: function () { + callOpsFunctionAndRequest('sleep', 'sleep', "POST"); + }, + + online: function (status) { + if (status===true) { + settings.online = true; + // syncQueue(); + } else { + settings.online = false; + }; + }, + + authenticate: function (uuid, token, cb) { + if (!!uuid && !!token) { + var offset = moment().zone(); // eg, 240 - this will be converted on server as -(offset/60) + $http.defaults.headers.common['x-api-user'] = uuid; + $http.defaults.headers.common['x-api-key'] = token; + $http.defaults.headers.common['x-user-timezoneOffset'] = offset; + authenticated = true; + settings.auth.apiId = uuid; + settings.auth.apiToken = token; + settings.online = true; + if (user && user._v) user._v--; // shortcut to always fetch new updates on page reload + userServices.log({}, function(){ + // If they don't have timezone, set it + if (user.preferences.timezoneOffset !== offset) + userServices.set({'preferences.timezoneOffset': offset}); + cb && cb(); + }); + } else { + alert('Please enter your ID and Token in settings.') + } + }, + + authenticated: function(){ + return this.settings.auth.apiId !== ""; + }, + + getBalanceInGems: function() { + var balance = user.balance || 0; + return balance * 4; + }, + + log: function (action, cb) { + //push by one buy one if an array passed in. + if (_.isArray(action)) { + action.forEach(function (a) { + settings.sync.queue.push(a); + }); + } else { + settings.sync.queue.push(action); + } + + save(); + // syncQueue(cb); + }, + + sync: function(){ + user._v--; + userServices.log({}); + sync(); + }, + + save: save, + + settings: settings + }; + + //load settings if we have them + if (localStorage.getItem(STORAGE_SETTINGS_ID)) { + //use extend here to make sure we keep object reference in other angular controllers + _.extend(settings, JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID))); + + //if settings were saved while fetch was in process reset the flag. + settings.fetching = false; + //create and load if not + } else { + localStorage.setItem(STORAGE_SETTINGS_ID, JSON.stringify(defaultSettings)); + _.extend(settings, defaultSettings); + } + + //If user does not have ApiID that forward him to settings. + if (!settings.auth.apiId || !settings.auth.apiToken) { + + if (MOBILE_APP) { + $location.path("/login"); + } else { + //var search = $location.search(); // FIXME this should be working, but it's returning an empty object when at a root url /?_id=... + var search = $location.search($window.location.search.substring(1)).$$search; // so we use this fugly hack instead + if (search.err) return alert(search.err); + if (search._id && search.apiToken) { + userServices.authenticate(search._id, search.apiToken, function(){ + $window.location.href='/'; + }); + } else { + var isStaticOrSocial = $window.location.pathname.match(/^\/(static|social)/); + if (!isStaticOrSocial){ + localStorage.clear(); + $window.location.href = '/logout'; + } + } + } + + } else { + userServices.authenticate(settings.auth.apiId, settings.auth.apiToken) + } + + return userServices; + } +]); diff --git a/website/public/manifest.json b/website/public/manifest.json index a86740e8e7..cb3db7a968 100644 --- a/website/public/manifest.json +++ b/website/public/manifest.json @@ -33,7 +33,7 @@ "bower_components/jquery-ui/ui/minified/jquery.ui.sortable.min.js", "bower_components/smart-app-banner/smart-app-banner.js", - "common/dist/scripts/habitrpg-shared.js", + "common/dist/scripts/habitrpg-shared.js", "js/env.js", @@ -42,7 +42,6 @@ "js/services/sharedServices.js", "js/services/notificationServices.js", - "common/script/public/userServices.js", "common/script/public/directives.js", "js/services/analyticsServices.js", "js/services/groupServices.js", @@ -55,6 +54,7 @@ "js/services/questServices.js", "js/services/socialServices.js", "js/services/statServices.js", + "js/services/userServices.js", "js/filters/money.js", "js/filters/roundLargeNumbers.js", @@ -132,7 +132,7 @@ "js/services/sharedServices.js", "js/services/socialServices.js", "js/services/statServices.js", - "common/script/public/userServices.js", + "js/services/userServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" ], @@ -166,7 +166,7 @@ "js/services/sharedServices.js", "js/services/socialServices.js", "js/services/statServices.js", - "common/script/public/userServices.js", + "js/services/userServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" ], diff --git a/website/views/options/social/tavern.jade b/website/views/options/social/tavern.jade index ed7e4ef6c3..d0f16c187d 100644 --- a/website/views/options/social/tavern.jade +++ b/website/views/options/social/tavern.jade @@ -16,7 +16,7 @@ .popover-content span(ng-if='!env.worldDmg.tavern') {{user.preferences.sleep ? env.t('innText',{name: user.profile.name}) : env.t('danielText')}} span(ng-if='env.worldDmg.tavern') {{user.preferences.sleep ? env.t('innTextBroken',{name: user.profile.name}) : env.t('danielTextBroken')}} - button.btn-block.btn.btn-lg.btn-success(ng-click='User.user.ops.sleep({})') + button.btn-block.btn.btn-lg.btn-success(ng-click='User.sleep({})') | {{user.preferences.sleep ? env.t('innCheckOut') : env.t('innCheckIn')}} span(ng-if='!user.preferences.sleep && !env.worldDmg.tavern')=env.t('danielText2') span(ng-if='!user.preferences.sleep && env.worldDmg.tavern')=env.t('danielText2Broken') diff --git a/website/views/shared/tasks/index.jade b/website/views/shared/tasks/index.jade index fc3223b888..f128b0b322 100644 --- a/website/views/shared/tasks/index.jade +++ b/website/views/shared/tasks/index.jade @@ -23,7 +23,7 @@ script(id='templates/habitrpg-tasks.html', type="text/ng-template") i.glyphicon.glyphicon-warning-sign   =env.t('dailiesRestingInInn') - button.btn-block.btn.btn-lg.btn-success(ng-click='User.user.ops.sleep({})') + button.btn-block.btn.btn-lg.btn-success(ng-click='User.sleep({})') | {{env.t('innCheckOut')}} +taskColumnTabs('top') From c88cae4ddb465aa576d7c36d59b2d96b18100282 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 6 May 2016 09:45:04 -0500 Subject: [PATCH 763/976] Added mark pms read route. Fixed error checking and extra code. --- common/script/ops/clearPMs.js | 2 +- website/public/js/controllers/rootCtrl.js | 3 +- website/public/js/services/userServices.js | 62 ++++++++++++---------- website/src/controllers/api-v3/user.js | 20 +++++++ 4 files changed, 56 insertions(+), 31 deletions(-) diff --git a/common/script/ops/clearPMs.js b/common/script/ops/clearPMs.js index 537ef26348..765ecc3b56 100644 --- a/common/script/ops/clearPMs.js +++ b/common/script/ops/clearPMs.js @@ -1,6 +1,6 @@ module.exports = function clearPMs (user) { user.inbox.messages = {}; - // user.markModified('inbox.messages'); + user.markModified('inbox.messages'); return [ user.inbox.messages, ]; diff --git a/website/public/js/controllers/rootCtrl.js b/website/public/js/controllers/rootCtrl.js index a4025ad23c..0c841ee677 100644 --- a/website/public/js/controllers/rootCtrl.js +++ b/website/public/js/controllers/rootCtrl.js @@ -21,8 +21,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ if (!!fromState.name) Analytics.track({'hitType':'pageview','eventCategory':'navigation','eventAction':'navigate','page':'/#/'+toState.name}); // clear inbox when entering or exiting inbox tab if (fromState.name=='options.social.inbox' || toState.name=='options.social.inbox') { - //@TODO: Protected path. We need a url - User.set({'inbox.newMessages': 0}); + User.clearNewMessages(); } }); diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js index ea0f706d2e..bf2466c9a0 100644 --- a/website/public/js/services/userServices.js +++ b/website/public/js/services/userServices.js @@ -14,8 +14,8 @@ angular.module('habitrpg') /** * Services that persists and retrieves user from localStorage. */ - .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'MOBILE_APP', 'Notification', 'ApiUrl', - function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, MOBILE_APP, Notification, ApiUrl) { + .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'Notification', 'ApiUrl', + function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, Notification, ApiUrl) { var authenticated = false; var defaultSettings = { auth: { apiId: '', apiToken: ''}, @@ -51,7 +51,7 @@ angular.module('habitrpg') url: 'api/v3/user/', }) .then(function (response) { - Notification.text(response.data.message); + if (response.data.message) Notification.text(response.data.message); _.extend(user, response.data.data); @@ -70,8 +70,7 @@ angular.module('habitrpg') } if (err) { var message = err.code ? err.message : err; - if (MOBILE_APP) Notification.push({type:'text',text:message}); - else Notification.text(message); + Notification.text(message); // In the case of 200s, they're friendly alert messages like "Your pet has hatched!" - still send the op if ((err.code && err.code >= 400) || !err.code) return; } @@ -113,7 +112,7 @@ angular.module('habitrpg') body: body, }) .then(function (response) { - Notification.text(response.data.message); + if (response.data.message) Notification.text(response.data.message); save(); }) } @@ -122,8 +121,6 @@ angular.module('habitrpg') for (var key in updates) { user[key] = updates[key]; } - - sync(); } var userServices = { @@ -201,6 +198,16 @@ angular.module('habitrpg') }) }, + clearNewMessages: function () { + $http({ + method: "POST", + url: 'api/v3/user/mark-pms-read', + }) + .then(function (response) { + sync(); + }) + }, + clearPMs: function () { callOpsFunctionAndRequest('clearPMs', 'messages', "DELETE"); }, @@ -259,12 +266,19 @@ angular.module('habitrpg') }, unlock: function (data) { - $window.habitrpgShared.ops['unlock'](user, data); callOpsFunctionAndRequest('unlock', 'unlock', "POST", '', data); }, set: function(updates) { setUser(updates); + $http({ + method: "PUT", + url: 'api/v3/user', + data: updates, + }) + .then(function (response) { + sync(); + }) }, reroll: function () { @@ -358,11 +372,9 @@ angular.module('habitrpg') } save(); - // syncQueue(cb); }, sync: function(){ - user._v--; userServices.log({}); sync(); }, @@ -387,26 +399,20 @@ angular.module('habitrpg') //If user does not have ApiID that forward him to settings. if (!settings.auth.apiId || !settings.auth.apiToken) { - - if (MOBILE_APP) { - $location.path("/login"); + //var search = $location.search(); // FIXME this should be working, but it's returning an empty object when at a root url /?_id=... + var search = $location.search($window.location.search.substring(1)).$$search; // so we use this fugly hack instead + if (search.err) return alert(search.err); + if (search._id && search.apiToken) { + userServices.authenticate(search._id, search.apiToken, function(){ + $window.location.href='/'; + }); } else { - //var search = $location.search(); // FIXME this should be working, but it's returning an empty object when at a root url /?_id=... - var search = $location.search($window.location.search.substring(1)).$$search; // so we use this fugly hack instead - if (search.err) return alert(search.err); - if (search._id && search.apiToken) { - userServices.authenticate(search._id, search.apiToken, function(){ - $window.location.href='/'; - }); - } else { - var isStaticOrSocial = $window.location.pathname.match(/^\/(static|social)/); - if (!isStaticOrSocial){ - localStorage.clear(); - $window.location.href = '/logout'; - } + var isStaticOrSocial = $window.location.pathname.match(/^\/(static|social)/); + if (!isStaticOrSocial){ + localStorage.clear(); + $window.location.href = '/logout'; } } - } else { userServices.authenticate(settings.auth.apiId, settings.auth.apiToken) } diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index a1e34ad435..fc375687ae 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1175,6 +1175,26 @@ api.clearMessages = { }, }; +/** + * @api {post} /api/v3/user/mark-pms-read Marks Private Messages as read + * @apiVersion 3.0.0 + * @apiName markPmsRead + * @apiGroup User + * + * @apiSuccess {object} data user.inbox.messages +**/ +api.markPmsRead = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/mark-pms-read', + async handler (req, res) { + let user = res.locals.user; + user.inbox.newMessages = 0; + await user.save(); + res.respond(200, user.inbox.newMessages); + }, +}; + /* * @api {post} /api/v3/user/reroll Rerolls a user. * @apiVersion 3.0.0 From a92359e119279d75d7b514cd9407d7b8e8828342 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 9 May 2016 09:02:55 -0500 Subject: [PATCH 764/976] Moved markPMSRead to common.ops. Added tests --- common/locales/en/api-v3.json | 3 ++- common/script/index.js | 3 +++ common/script/ops/index.js | 3 +++ common/script/ops/markPMSRead.js | 14 ++++++++++++ .../user/POST-user_mark_pms_read.test.js | 22 +++++++++++++++++++ website/public/js/controllers/footerCtrl.js | 1 - website/public/js/services/userServices.js | 8 +------ website/src/controllers/api-v3/user.js | 4 ++-- 8 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 common/script/ops/markPMSRead.js create mode 100644 test/api/v3/integration/user/POST-user_mark_pms_read.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 9f0df52127..90729ca72c 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -171,5 +171,6 @@ "pushDeviceAlreadyAdded": "The user already has the push device", "resetComplete": "Reset completed", "lvl10ChangeClass": "To change class you must be at least level 10.", - "equipmentAlreadyOwned": "You already own that piece of equipment" + "equipmentAlreadyOwned": "You already own that piece of equipment", + "pmsMarkedRead": "Your private messages have been marked as read" } diff --git a/common/script/index.js b/common/script/index.js index 5191ba5802..12f1f81146 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -147,6 +147,7 @@ import deletePM from './ops/deletePM'; import reroll from './ops/reroll'; import addPushDevice from './ops/addPushDevice'; import reset from './ops/reset'; +import markPmsRead from './ops/markPmsRead'; api.ops = { scoreTask, @@ -187,6 +188,7 @@ api.ops = { reroll, addPushDevice, reset, + markPmsRead, }; /* @@ -288,6 +290,7 @@ api.wrap = function wrapUser (user, main = true) { readCard: _.partial(importedOps.readCard, user), openMysteryItem: _.partial(importedOps.openMysteryItem, user), score: _.partial(importedOps.scoreTask, user), + markPmsRead: _.partial(importedOps.markPmsRead, user), }; } diff --git a/common/script/ops/index.js b/common/script/ops/index.js index 4b06ac93e6..c70cc4860e 100644 --- a/common/script/ops/index.js +++ b/common/script/ops/index.js @@ -46,6 +46,8 @@ import allocate from './allocate'; import readCard from './readCard'; import openMysteryItem from './openMysteryItem'; import scoreTask from './scoreTask'; +import markPmsRead from './markPmsRead'; + module.exports = { update, @@ -96,4 +98,5 @@ module.exports = { readCard, openMysteryItem, scoreTask, + markPmsRead, }; diff --git a/common/script/ops/markPMSRead.js b/common/script/ops/markPMSRead.js new file mode 100644 index 0000000000..add9f49de5 --- /dev/null +++ b/common/script/ops/markPMSRead.js @@ -0,0 +1,14 @@ +import i18n from '../i18n'; + +module.exports = function markPmsRead (user, req = {}) { + user.inbox.newMessages = 0; + + if (req.v2 === true) { + return user; + } else { + return [ + user.inbox.newMessages, + i18n.t('pmsMarkedRead'), + ]; + } +}; diff --git a/test/api/v3/integration/user/POST-user_mark_pms_read.test.js b/test/api/v3/integration/user/POST-user_mark_pms_read.test.js new file mode 100644 index 0000000000..e7fc68c04d --- /dev/null +++ b/test/api/v3/integration/user/POST-user_mark_pms_read.test.js @@ -0,0 +1,22 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/mark-pms-read', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + // More tests in common code unit tests + + it('marks user\'s private messages as read', async () => { + await user.update({ + 'inbox.newMessages': 1, + }); + let res = await user.post('/user/mark-pms-read'); + await user.sync(); + expect(user.inbox.newMessages).to.equal(0); + }); +}); diff --git a/website/public/js/controllers/footerCtrl.js b/website/public/js/controllers/footerCtrl.js index 7307adaee7..694a2b2b65 100644 --- a/website/public/js/controllers/footerCtrl.js +++ b/website/public/js/controllers/footerCtrl.js @@ -92,7 +92,6 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { $scope.addHourglass = function(){ User.addHourglass(); - //User.log({}); }; $scope.addGold = function(){ diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js index bf2466c9a0..627c287786 100644 --- a/website/public/js/services/userServices.js +++ b/website/public/js/services/userServices.js @@ -199,13 +199,7 @@ angular.module('habitrpg') }, clearNewMessages: function () { - $http({ - method: "POST", - url: 'api/v3/user/mark-pms-read', - }) - .then(function (response) { - sync(); - }) + callOpsFunctionAndRequest('markPmsRead', 'mark-pms-read', "POST"); }, clearPMs: function () { diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index fc375687ae..6fde08585c 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1189,9 +1189,9 @@ api.markPmsRead = { url: '/user/mark-pms-read', async handler (req, res) { let user = res.locals.user; - user.inbox.newMessages = 0; + let markPmsResponse = common.ops.markPmsRead(user, req); await user.save(); - res.respond(200, user.inbox.newMessages); + res.respond(200, markPmsResponse); }, }; From a0939155c90d4956412c1df3d6430637cc01feee Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 9 May 2016 23:31:34 -0500 Subject: [PATCH 765/976] Updated login headers save. Added task service to user service. Sync user tasks --- common/script/ops/addTask.js | 1 + common/script/ops/deleteTask.js | 11 ++- common/script/ops/sortTask.js | 15 ++-- website/public/js/app.js | 1 + website/public/js/controllers/authCtrl.js | 2 +- website/public/js/controllers/filtersCtrl.js | 4 +- website/public/js/controllers/tasksCtrl.js | 28 +++---- .../js/directives/hrpg-sort-tags.directive.js | 2 +- .../directives/hrpg-sort-tasks.directive.js | 4 +- website/public/js/services/taskServices.js | 42 +++++----- website/public/js/services/userServices.js | 79 ++++++++++++------- website/public/manifest.json | 2 + website/views/shared/tasks/edit/index.jade | 2 +- 13 files changed, 107 insertions(+), 86 deletions(-) diff --git a/common/script/ops/addTask.js b/common/script/ops/addTask.js index 81f642ab02..592f877248 100644 --- a/common/script/ops/addTask.js +++ b/common/script/ops/addTask.js @@ -5,6 +5,7 @@ import taskDefaults from '../libs/taskDefaults'; module.exports = function addTask (user, req = {body: {}}) { let task = taskDefaults(req.body); user.tasksOrder[`${task.type}s`].unshift(task._id); + user[`${task.type}s`].unshift(task); if (user.preferences.newTaskEdit) { task._editing = true; diff --git a/common/script/ops/deleteTask.js b/common/script/ops/deleteTask.js index b818cf7ed4..42d37f7efa 100644 --- a/common/script/ops/deleteTask.js +++ b/common/script/ops/deleteTask.js @@ -6,16 +6,15 @@ import _ from 'lodash'; module.exports = function deleteTask (user, req = {}) { let tid = _.get(req, 'params.id'); - let task = user.tasks[tid]; + let taskType = _.get(req, 'params.taskType'); - if (!task) { + let index = _.findIndex(user[`${taskType}s`], function(task) {return task._id === tid;}); + + if (index === -1) { throw new NotFound(i18n.t('messageTaskNotFound', req.language)); } - let index = user[`${task.type}s`].indexOf(task); - if (index !== -1) { - user[`${task.type}s`].splice(index, 1); - } + user[`${taskType}s`].splice(index, 1); return {}; }; diff --git a/common/script/ops/sortTask.js b/common/script/ops/sortTask.js index ca79b03e24..77e7a167a3 100644 --- a/common/script/ops/sortTask.js +++ b/common/script/ops/sortTask.js @@ -8,23 +8,24 @@ import _ from 'lodash'; // TODO used only in client, move there? -module.exports = function sortTag (user, req = {}) { +module.exports = function sortTask (user, req = {}) { let id = _.get(req, 'params.id'); let to = _.get(req, 'query.to'); let fromParam = _.get(req, 'query.from'); + let taskType = _.get(req, 'params.taskType'); - let task = user.tasks[id]; + let index = _.findIndex(user[`${taskType}s`], function(task) {return task._id === id;}); - if (!task) { + if (index === -1) { throw new NotFound(i18n.t('messageTaskNotFound', req.language)); } if (!to && !fromParam) { throw new BadRequest('?to=__&from=__ are required'); } - let tasks = user[`${task.type}s`]; + let tasks = user[`${taskType}s`]; - if (task.type === 'todo' && tasks[fromParam] !== task) { + if (taskType === 'todo') { let preenedTasks = preenTodos(tasks); if (to !== -1) { @@ -34,10 +35,6 @@ module.exports = function sortTag (user, req = {}) { fromParam = tasks.indexOf(preenedTasks[fromParam]); } - if (tasks[fromParam] !== task) { - throw new NotFound(i18n.t('messageTaskNotFound', req.language)); - } - let movedTask = tasks.splice(fromParam, 1)[0]; if (to === -1) { diff --git a/website/public/js/app.js b/website/public/js/app.js index 147b2a7465..5d685de42a 100644 --- a/website/public/js/app.js +++ b/website/public/js/app.js @@ -310,6 +310,7 @@ window.habitrpg = angular.module('habitrpg', }); var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID)); + if (settings && settings.auth) { $httpProvider.defaults.headers.common['Content-Type'] = 'application/json;charset=utf-8'; $httpProvider.defaults.headers.common['x-api-user'] = settings.auth.apiId; diff --git a/website/public/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js index 5b486677f5..94451276ab 100644 --- a/website/public/js/controllers/authCtrl.js +++ b/website/public/js/controllers/authCtrl.js @@ -64,7 +64,7 @@ angular.module('habitrpg') }).error(errorAlert); }; - $scope.playButtonClick = function(){ + $scope.playButtonClick = function() { Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Play'}) if (User.authenticated()) { window.location.href = ('/' + window.location.hash); diff --git a/website/public/js/controllers/filtersCtrl.js b/website/public/js/controllers/filtersCtrl.js index e2597af241..2ba1a4da2c 100644 --- a/website/public/js/controllers/filtersCtrl.js +++ b/website/public/js/controllers/filtersCtrl.js @@ -14,7 +14,7 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared', _.each(User.user.tags, function(tag){ // Send an update op for each changed tag (excluding new tags & deleted tags, this if() packs a punch) if (tagsSnap[tag.id] && tagsSnap[tag.id].name != tag.name) - User.updateTag({params:{id:tag.id},body:{name:tag.name}}); + User.updateTag({params:{id:tag.id}, body:{name:tag.name}}); }) $scope._editing = false; } else { @@ -37,7 +37,7 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared', $scope.updateTaskFilter(); $scope.createTag = function() { - User.addTag({body:{name:$scope._newTag.name, id:Shared.uuid()}}); + User.addTag({body:{name: $scope._newTag.name, id: Shared.uuid()}}); $scope._newTag.name = ''; }; }]); diff --git a/website/public/js/controllers/tasksCtrl.js b/website/public/js/controllers/tasksCtrl.js index 857f1e2393..fa88ecd271 100644 --- a/website/public/js/controllers/tasksCtrl.js +++ b/website/public/js/controllers/tasksCtrl.js @@ -24,7 +24,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N if (direction === 'down') $rootScope.playSound('Minus_Habit'); else if (direction === 'up') $rootScope.playSound('Plus_Habit'); } - User.score({params:{id: task.id, direction:direction}}); + User.score({params:{task: task, direction:direction}}); Analytics.updateUser(); Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'score task','taskType':task.type,'direction':direction}); }; @@ -33,12 +33,12 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N var newTask = { text: task, type: listDef.type, - tags: _.transform(User.user.filters, function(m, v, k) { - if (v) m.push(v); - }), + // tags: _.transform(User.user.filters, function(m, v, k) { + // if (v) m.push(v); + // }), }; - User.addTask({body:newTask}); + User.addTask({body: newTask}); } $scope.addTask = function(addTo, listDef) { @@ -80,7 +80,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N */ $scope.pushTask = function(task, index, location) { var to = (location === 'bottom' || $scope.ctrlPressed) ? -1 : 0; - User.sortTask({params:{id:task.id},query:{from:index, to:to}}) + User.sortTask({params:{id: task._id, taskType: task.type}, query:{from:index, to:to}}) }; /** @@ -96,17 +96,17 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $scope.removeTask = function(task) { if (!confirm(window.env.t('sureDelete', {taskType: window.env.t(task.type), taskText: task.text}))) return; - User.deleteTask({params:{id:task.id}}) + User.deleteTask({params:{id: task._id, taskType: task.type}}) }; $scope.saveTask = function(task, stayOpen, isSaveAndClose) { if (task.checklist) task.checklist = _.filter(task.checklist,function(i){return !!i.text}); - User.updateTask({params:{id:task.id},body:task}); + User.updateTask(task, {body: task}); if (!stayOpen) task._editing = false; if (isSaveAndClose) { - $("#task-" + task.id).parent().children('.popover').removeClass('in'); + $("#task-" + task._id).parent().children('.popover').removeClass('in'); } if (task.type == 'habit') Guide.goto('intro', 3); @@ -126,7 +126,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N }; $scope.unlink = function(task, keep) { - Tasks.unlinkTask(task.id, keep) + Tasks.unlinkTask(task._id, keep) .success(function () { User.log({}); }); @@ -159,7 +159,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N */ function focusChecklist(task,index) { window.setTimeout(function(){ - $('#task-'+task.id+' .checklist-form input[type="text"]')[index].focus(); + $('#task-'+task._id+' .checklist-form input[type="text"]')[index].focus(); }); } @@ -173,7 +173,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N // Don't allow creation of an empty checklist item // TODO Provide UI feedback that this item is still blank } else if ($index == task.checklist.length-1){ - User.updateTask({params:{id:task.id},body:task}); // don't preen the new empty item + User.updateTask({params:{id:task._id},body:task}); // don't preen the new empty item task.checklist.push({completed:false,text:''}); focusChecklist(task,task.checklist.length-1); } else { @@ -185,12 +185,12 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $scope.removeChecklistItem = function(task, $event, $index, force){ // Remove item if clicked on trash icon if (force) { - Tasks.removeChecklistItem(task.id, task.checklist[$index]._id); + Tasks.removeChecklistItem(task._id, task.checklist[$index]._id); task.checklist.splice($index, 1); } else if (!task.checklist[$index].text) { // User deleted all the text and is now wishing to delete the item // saveTask will prune the empty item - Tasks.removeChecklistItem(task.id, task.checklist[$index]._id); + Tasks.removeChecklistItem(task._id, task.checklist[$index]._id); // Move focus if the list is still non-empty if ($index > 0) focusChecklist(task, $index-1); diff --git a/website/public/js/directives/hrpg-sort-tags.directive.js b/website/public/js/directives/hrpg-sort-tags.directive.js index 5b42bc778f..93fb115116 100644 --- a/website/public/js/directives/hrpg-sort-tags.directive.js +++ b/website/public/js/directives/hrpg-sort-tags.directive.js @@ -16,7 +16,7 @@ ui.item.data('startIndex', ui.item.index()); }, stop: function (event, ui) { - User.user.ops.sortTag({ + User.sortTag({ query: { from: ui.item.data('startIndex'), to:ui.item.index() diff --git a/website/public/js/directives/hrpg-sort-tasks.directive.js b/website/public/js/directives/hrpg-sort-tasks.directive.js index 820fccfbe8..0ce42d82eb 100644 --- a/website/public/js/directives/hrpg-sort-tasks.directive.js +++ b/website/public/js/directives/hrpg-sort-tasks.directive.js @@ -20,8 +20,8 @@ stop: function (event, ui) { var task = angular.element(ui.item[0]).scope().task; var startIndex = ui.item.data('startIndex'); - User.user.ops.sortTask({ - params: { id: task.id }, + User.sortTask({ + params: { id: task._id, taskType: task.type }, query: { from: startIndex, to: ui.item.index() diff --git a/website/public/js/services/taskServices.js b/website/public/js/services/taskServices.js index a8acbe527c..5eaad3ff74 100644 --- a/website/public/js/services/taskServices.js +++ b/website/public/js/services/taskServices.js @@ -3,20 +3,20 @@ var TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'history', 'id', 'streak', 'createdAt']; angular.module('habitrpg') -.factory('Tasks', ['$rootScope', 'Shared', 'User', '$http', - function tasksFactory($rootScope, Shared, User, $http) { +.factory('Tasks', ['$rootScope', 'Shared', '$http', + function tasksFactory($rootScope, Shared, $http) { function getUserTasks () { return $http({ method: 'GET', - url: 'api/v3/tasks/user', + url: '/api/v3/tasks/user', }); }; function createUserTasks (taskDetails) { return $http({ method: 'POST', - url: 'api/v3/tasks/user', + url: '/api/v3/tasks/user', data: taskDetails, }); }; @@ -24,14 +24,14 @@ angular.module('habitrpg') function getChallengeTasks (challengeId) { return $http({ method: 'GET', - url: 'api/v3/tasks/challenge/' + challengeId, + url: '/api/v3/tasks/challenge/' + challengeId, }); }; function createChallengeTasks (challengeId, taskDetails) { return $http({ method: 'POST', - url: 'api/v3/tasks/challenge/' + challengeId, + url: '/api/v3/tasks/challenge/' + challengeId, data: taskDetails, }); }; @@ -39,14 +39,14 @@ angular.module('habitrpg') function getTask (taskId) { return $http({ method: 'GET', - url: 'api/v3/tasks/' + taskId, + url: '/api/v3/tasks/' + taskId, }); }; function updateTask (taskId, taskDetails) { return $http({ method: 'PUT', - url: 'api/v3/tasks/' + taskId, + url: '/api/v3/tasks/' + taskId, data: taskDetails, }); }; @@ -54,28 +54,28 @@ angular.module('habitrpg') function deleteTask (taskId) { return $http({ method: 'DELETE', - url: 'api/v3/tasks/' + taskId, + url: '/api/v3/tasks/' + taskId, }); }; function scoreTask (taskId, direction) { return $http({ method: 'POST', - url: 'api/v3/tasks/' + taskId + '/score/' + direction, + url: '/api/v3/tasks/' + taskId + '/score/' + direction, }); }; function moveTask (taskId, position) { return $http({ method: 'POST', - url: 'api/v3/tasks/' + taskId + '/move/to/' + position, + url: '/api/v3/tasks/' + taskId + '/move/to/' + position, }); }; function addChecklistItem (taskId, checkListItem) { return $http({ method: 'POST', - url: 'api/v3/tasks/' + taskId + '/checklist', + url: '/api/v3/tasks/' + taskId + '/checklist', data: checkListItem, }); }; @@ -83,14 +83,14 @@ angular.module('habitrpg') function scoreCheckListItem (taskId, itemId) { return $http({ method: 'POST', - url: 'api/v3/tasks/' + taskId + '/checklist/' + itemId + '/score', + url: '/api/v3/tasks/' + taskId + '/checklist/' + itemId + '/score', }); }; function updateChecklistItem (taskId, itemId, itemDetails) { return $http({ method: 'PUT', - url: 'api/v3/tasks/' + taskId + '/checklist/' + itemId, + url: '/api/v3/tasks/' + taskId + '/checklist/' + itemId, data: itemDetails, }); }; @@ -98,21 +98,21 @@ angular.module('habitrpg') function removeChecklistItem (taskId, itemId) { return $http({ method: 'DELETE', - url: 'api/v3/tasks/' + taskId + '/checklist/' + itemId, + url: '/api/v3/tasks/' + taskId + '/checklist/' + itemId, }); }; function addTagToTask (taskId, tagId) { return $http({ method: 'POST', - url: 'api/v3/tasks/' + taskId + '/tags/' + tagId, + url: '/api/v3/tasks/' + taskId + '/tags/' + tagId, }); }; function removeTagFromTask (taskId, tagId) { return $http({ method: 'DELETE', - url: 'api/v3/tasks/' + taskId + '/tags/' + tagId, + url: '/api/v3/tasks/' + taskId + '/tags/' + tagId, }); }; @@ -123,21 +123,21 @@ angular.module('habitrpg') return $http({ method: 'POST', - url: 'api/v3/tasks/unlink/' + taskId + '?keep=' + keep, + url: '/api/v3/tasks/unlink/' + taskId + '?keep=' + keep, }); }; function clearCompletedTodos () { return $http({ method: 'POST', - url: 'api/v3/tasks/clearCompletedTodos', + url: '/api/v3/tasks/clearCompletedTodos', }); }; function editTask(task) { task._editing = !task._editing; - task._tags = !User.user.preferences.tagsCollapsed; - task._advanced = !User.user.preferences.advancedCollapsed; + // task._tags = !User.user.preferences.tagsCollapsed; + // task._advanced = !User.user.preferences.advancedCollapsed; if($rootScope.charts[task.id]) $rootScope.charts[task.id] = false; } diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js index 627c287786..d7fc93c46a 100644 --- a/website/public/js/services/userServices.js +++ b/website/public/js/services/userServices.js @@ -14,8 +14,8 @@ angular.module('habitrpg') /** * Services that persists and retrieves user from localStorage. */ - .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'Notification', 'ApiUrl', - function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, Notification, ApiUrl) { + .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'Notification', 'ApiUrl', 'Tasks', + function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, Notification, ApiUrl, Tasks) { var authenticated = false; var defaultSettings = { auth: { apiId: '', apiToken: ''}, @@ -48,7 +48,7 @@ angular.module('habitrpg') function sync() { $http({ method: "GET", - url: 'api/v3/user/', + url: '/api/v3/user/', }) .then(function (response) { if (response.data.message) Notification.text(response.data.message); @@ -82,7 +82,19 @@ angular.module('habitrpg') save(); $rootScope.$emit('userSynced'); + + return Tasks.getUserTasks(); }) + .then(function (response) { + var tasks = response.data.data; + user.habits = []; + user.todos = []; + user.dailys = []; + user.rewards = []; + tasks.forEach(function (element, index, array) { + user[element.type + 's'].push(element) + }) + }); } sync(); @@ -95,7 +107,7 @@ angular.module('habitrpg') if (!opData) opData = {}; $window.habitrpgShared.ops[opName](user, opData); - var url = 'api/v3/user/' + endPoint; + var url = '/api/v3/user/' + endPoint; if (paramString) { url += '/' + paramString } @@ -119,7 +131,7 @@ angular.module('habitrpg') function setUser(updates) { for (var key in updates) { - user[key] = updates[key]; + _.set(user, key, updates[key]); } } @@ -135,46 +147,53 @@ angular.module('habitrpg') }, addTask: function (data) { - //@TODO: Should this been on habitrpgShared? user.ops.addTask(data); save(); - //@TODO: Call task service when PR is merged + Tasks.createUserTasks(data.body); }, score: function (data) { - user.ops.scoreTask(data); + $window.habitrpgShared.ops.scoreTask({user: user, task: data.params.task, direction: data.params.direction}, data.params); save(); - //@TODO: Call task service when PR is merged + Tasks.scoreTask(data.params.task._id, data.params.direction); }, sortTask: function (data) { user.ops.sortTask(data); save(); - //@TODO: Call task service when PR is merged + Tasks.moveTask(data.params.id, data.query.to); }, - updateTask: function (data) { - user.ops.updateTask(data); + updateTask: function (task, data) { + $window.habitrpgShared.ops.updateTask(task, data); save(); - //@TODO: Call task service when PR is merged + Tasks.updateTask(task._id, data.body); }, deleteTask: function (data) { user.ops.deleteTask(data); save(); - //@TODO: Call task service when PR is merged + Tasks.deleteTask(data.params.id); }, addTag: function(data) { user.ops.addTag(data); save(); - //@TODO: Call task service when PR is merged + $http({ + method: "PUT", + url: '/api/v3/user', + data: {filters: user.filters}, + }); }, updateTag: function(data) { user.ops.updateTag(data); save(); - //@TODO: Call task service when PR is merged + $http({ + method: "PUT", + url: '/api/v3/user', + data: {filters: user.filters}, + }); }, addTenGems: function () { @@ -222,7 +241,7 @@ angular.module('habitrpg') $http({ method: "POST", - url: 'api/v3/user/' + 'buy-special-spell/' + key, + url: '/api/v3/user/' + 'buy-special-spell/' + key, }) .then(function (response) { Notification.text(response.data.message); @@ -267,12 +286,9 @@ angular.module('habitrpg') setUser(updates); $http({ method: "PUT", - url: 'api/v3/user', + url: '/api/v3/user', data: updates, - }) - .then(function (response) { - sync(); - }) + }); }, reroll: function () { @@ -334,13 +350,18 @@ angular.module('habitrpg') settings.auth.apiId = uuid; settings.auth.apiToken = token; settings.online = true; - if (user && user._v) user._v--; // shortcut to always fetch new updates on page reload - userServices.log({}, function(){ - // If they don't have timezone, set it - if (user.preferences.timezoneOffset !== offset) - userServices.set({'preferences.timezoneOffset': offset}); - cb && cb(); - }); + save(); + sync(); + if (cb) { + cb(); + } + //@TODO: Do we need the timezone set? + // userServices.log({}, function(){ + // // If they don't have timezone, set it + // if (user.preferences.timezoneOffset !== offset) + // userServices.set({'preferences.timezoneOffset': offset}); + // cb && cb(); + // }); } else { alert('Please enter your ID and Token in settings.') } diff --git a/website/public/manifest.json b/website/public/manifest.json index cb3db7a968..c6c87ffb4b 100644 --- a/website/public/manifest.json +++ b/website/public/manifest.json @@ -132,6 +132,7 @@ "js/services/sharedServices.js", "js/services/socialServices.js", "js/services/statServices.js", + "js/services/taskServices.js", "js/services/userServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" @@ -166,6 +167,7 @@ "js/services/sharedServices.js", "js/services/socialServices.js", "js/services/statServices.js", + "js/services/taskServices.js", "js/services/userServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" diff --git a/website/views/shared/tasks/edit/index.jade b/website/views/shared/tasks/edit/index.jade index 952fbdbb17..d5032f94ce 100644 --- a/website/views/shared/tasks/edit/index.jade +++ b/website/views/shared/tasks/edit/index.jade @@ -8,7 +8,7 @@ div(ng-if='task._editing') p a(ng-click='unlink(task, "keep")')=env.t('keepIt') |    - a(ng-click="removeTask(task, obj")=env.t('removeIt') + a(ng-click="removeTask(task, obj)")=env.t('removeIt') div(ng-if='task.challenge.broken=="CHALLENGE_DELETED"') p |  From 632a1ebbf32d226a9e9141ed1310c7ca87731cb3 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 10 May 2016 10:44:48 -0500 Subject: [PATCH 766/976] Fixed linting and some tests --- common/script/index.js | 2 +- common/script/ops/deleteTask.js | 4 +++- common/script/ops/index.js | 2 +- common/script/ops/sortTask.js | 4 +++- .../user/POST-user_mark_pms_read.test.js | 2 +- test/common/ops/addTask.js | 4 ++++ .../copyMessageModalControllerSpec.js | 14 +++++++------- test/spec/controllers/filtersCtrlSpec.js | 15 +++++++++------ test/spec/controllers/footerCtrlSpec.js | 2 +- test/spec/controllers/inventoryCtrlSpec.js | 15 ++++++++------- test/spec/services/taskServicesSpec.js | 16 ++++++++-------- test/spec/services/userServicesSpec.js | 2 +- website/public/js/controllers/filtersCtrl.js | 6 +++++- website/public/js/services/taskServices.js | 6 +++--- website/public/js/services/userServices.js | 6 ++++++ website/views/shared/tasks/meta_controls.jade | 6 +++--- 16 files changed, 64 insertions(+), 42 deletions(-) diff --git a/common/script/index.js b/common/script/index.js index 12f1f81146..48eff442d8 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -147,7 +147,7 @@ import deletePM from './ops/deletePM'; import reroll from './ops/reroll'; import addPushDevice from './ops/addPushDevice'; import reset from './ops/reset'; -import markPmsRead from './ops/markPmsRead'; +import markPmsRead from './ops/markPMSRead'; api.ops = { scoreTask, diff --git a/common/script/ops/deleteTask.js b/common/script/ops/deleteTask.js index 42d37f7efa..a641763de5 100644 --- a/common/script/ops/deleteTask.js +++ b/common/script/ops/deleteTask.js @@ -8,7 +8,9 @@ module.exports = function deleteTask (user, req = {}) { let tid = _.get(req, 'params.id'); let taskType = _.get(req, 'params.taskType'); - let index = _.findIndex(user[`${taskType}s`], function(task) {return task._id === tid;}); + let index = _.findIndex(user[`${taskType}s`], function findById (task) { + return task._id === tid; + }); if (index === -1) { throw new NotFound(i18n.t('messageTaskNotFound', req.language)); diff --git a/common/script/ops/index.js b/common/script/ops/index.js index c70cc4860e..42e77d8718 100644 --- a/common/script/ops/index.js +++ b/common/script/ops/index.js @@ -46,7 +46,7 @@ import allocate from './allocate'; import readCard from './readCard'; import openMysteryItem from './openMysteryItem'; import scoreTask from './scoreTask'; -import markPmsRead from './markPmsRead'; +import markPmsRead from './markPMSRead'; module.exports = { diff --git a/common/script/ops/sortTask.js b/common/script/ops/sortTask.js index 77e7a167a3..ce002d8dc0 100644 --- a/common/script/ops/sortTask.js +++ b/common/script/ops/sortTask.js @@ -14,7 +14,9 @@ module.exports = function sortTask (user, req = {}) { let fromParam = _.get(req, 'query.from'); let taskType = _.get(req, 'params.taskType'); - let index = _.findIndex(user[`${taskType}s`], function(task) {return task._id === id;}); + let index = _.findIndex(user[`${taskType}s`], function findById (task) { + return task._id === id; + }); if (index === -1) { throw new NotFound(i18n.t('messageTaskNotFound', req.language)); diff --git a/test/api/v3/integration/user/POST-user_mark_pms_read.test.js b/test/api/v3/integration/user/POST-user_mark_pms_read.test.js index e7fc68c04d..50552359ef 100644 --- a/test/api/v3/integration/user/POST-user_mark_pms_read.test.js +++ b/test/api/v3/integration/user/POST-user_mark_pms_read.test.js @@ -15,7 +15,7 @@ describe('POST /user/mark-pms-read', () => { await user.update({ 'inbox.newMessages': 1, }); - let res = await user.post('/user/mark-pms-read'); + await user.post('/user/mark-pms-read'); await user.sync(); expect(user.inbox.newMessages).to.equal(0); }); diff --git a/test/common/ops/addTask.js b/test/common/ops/addTask.js index 2d5febe8d2..cb207036db 100644 --- a/test/common/ops/addTask.js +++ b/test/common/ops/addTask.js @@ -8,6 +8,10 @@ describe('shared.ops.addTask', () => { beforeEach(() => { user = generateUser(); + user.habits = []; + user.todos = []; + user.dailys = []; + user.rewards = []; }); it('adds an habit', () => { diff --git a/test/spec/controllers/copyMessageModalControllerSpec.js b/test/spec/controllers/copyMessageModalControllerSpec.js index bb25b6447e..419fb2341e 100644 --- a/test/spec/controllers/copyMessageModalControllerSpec.js +++ b/test/spec/controllers/copyMessageModalControllerSpec.js @@ -4,11 +4,9 @@ describe("CopyMessageModal controller", function() { var scope, ctrl, user, Notification, $rootScope, $controller; beforeEach(function() { - module(function($provide) { - $provide.value('User', {}); - }); + module(function($provide) {}); - inject(function($rootScope, _$controller_, _Notification_){ + inject(function($rootScope, _$controller_, _Notification_, User){ user = specHelper.newUser(); user._id = "unique-user-id"; user.ops = { @@ -20,10 +18,12 @@ describe("CopyMessageModal controller", function() { $controller = _$controller_; - // Load RootCtrl to ensure shared behaviors are loaded - $controller('RootCtrl', {$scope: scope, User: {user: user}}); + User.setUser(user); - ctrl = $controller('CopyMessageModalCtrl', {$scope: scope, User: {user: user}}); + // Load RootCtrl to ensure shared behaviors are loaded + $controller('RootCtrl', {$scope: scope, User: User}); + + ctrl = $controller('CopyMessageModalCtrl', {$scope: scope, User: User}); Notification = _Notification_; Notification.text = sandbox.spy(); diff --git a/test/spec/controllers/filtersCtrlSpec.js b/test/spec/controllers/filtersCtrlSpec.js index bbebce3cfd..b2adac581d 100644 --- a/test/spec/controllers/filtersCtrlSpec.js +++ b/test/spec/controllers/filtersCtrlSpec.js @@ -1,13 +1,16 @@ 'use strict'; describe('Filters Controller', function() { - var scope, user; + var scope, user, userService; - beforeEach(inject(function($rootScope, $controller, Shared) { + beforeEach(inject(function($rootScope, $controller, Shared, User) { user = specHelper.newUser(); Shared.wrap(user); scope = $rootScope.$new(); - $controller('FiltersCtrl', {$scope: scope, User: {user: user}}); + // user.filters = {}; + User.setUser(user); + userService = User; + $controller('FiltersCtrl', {$scope: scope, User: User}); })); describe('tags', function(){ @@ -22,9 +25,9 @@ describe('Filters Controller', function() { it('toggles tag filtering', inject(function(Shared){ var tag = {id: Shared.uuid(), name: 'myTag'}; scope.toggleFilter(tag); - expect(user.filters[tag.id]).to.eql(true); + expect(userService.user.filters[tag.id]).to.eql(true); scope.toggleFilter(tag); - expect(user.filters[tag.id]).to.eql(false); + expect(userService.user.filters[tag.id]).to.eql(false); })); }); @@ -33,7 +36,7 @@ describe('Filters Controller', function() { scope.filterQuery = 'task'; scope.updateTaskFilter(); - expect(user.filterQuery).to.eql(scope.filterQuery); + expect(userService.user.filterQuery).to.eql(scope.filterQuery); }); }); }); diff --git a/test/spec/controllers/footerCtrlSpec.js b/test/spec/controllers/footerCtrlSpec.js index dd869591d2..539032ea5f 100644 --- a/test/spec/controllers/footerCtrlSpec.js +++ b/test/spec/controllers/footerCtrlSpec.js @@ -39,7 +39,7 @@ describe('Footer Controller', function() { describe('#addTenGems', function() { it('posts to /user/addTenGems', inject(function($httpBackend) { - $httpBackend.expectPOST('/api/v2/user/addTenGems').respond({}); + $httpBackend.expectPOST('/api/v3/debug/add-ten-gems').respond({}); scope.addTenGems(); diff --git a/test/spec/controllers/inventoryCtrlSpec.js b/test/spec/controllers/inventoryCtrlSpec.js index 4b7dc20b0a..71a3741e9c 100644 --- a/test/spec/controllers/inventoryCtrlSpec.js +++ b/test/spec/controllers/inventoryCtrlSpec.js @@ -4,11 +4,9 @@ describe('Inventory Controller', function() { var scope, ctrl, user, rootScope; beforeEach(function() { - module(function($provide) { - $provide.value('User', {}); - }); + module(function($provide) {}); - inject(function($rootScope, $controller, Shared){ + inject(function($rootScope, $controller, Shared, User) { user = specHelper.newUser({ balance: 4, items: { @@ -33,10 +31,13 @@ describe('Inventory Controller', function() { scope = $rootScope.$new(); rootScope = $rootScope; - // Load RootCtrl to ensure shared behaviors are loaded - $controller('RootCtrl', {$scope: scope, User: {user: user}, $window: mockWindow}); + User.user = user; + User.setUser(user); - ctrl = $controller('InventoryCtrl', {$scope: scope, User: {user: user}, $window: mockWindow}); + // Load RootCtrl to ensure shared behaviors are loaded + $controller('RootCtrl', {$scope: scope, User: User, $window: mockWindow}); + + ctrl = $controller('InventoryCtrl', {$scope: scope, User: User, $window: mockWindow}); }); }); diff --git a/test/spec/services/taskServicesSpec.js b/test/spec/services/taskServicesSpec.js index 45f1ba8607..f63608e546 100644 --- a/test/spec/services/taskServicesSpec.js +++ b/test/spec/services/taskServicesSpec.js @@ -2,7 +2,7 @@ describe('Tasks Service', function() { var rootScope, tasks, user, $httpBackend; - var apiV3Prefix = 'api/v3/tasks'; + var apiV3Prefix = '/api/v3/tasks'; beforeEach(function() { module(function($provide) { @@ -151,35 +151,35 @@ describe('Tasks Service', function() { }); it('toggles the _editing property', function() { - tasks.editTask(task); + tasks.editTask(task, user); expect(task._editing).to.eql(true); - tasks.editTask(task); + tasks.editTask(task, user); expect(task._editing).to.eql(false); }); it('sets _tags to true by default', function() { - tasks.editTask(task); + tasks.editTask(task, user); expect(task._tags).to.eql(true); }); it('sets _tags to false if preference for collapsed tags is turned on', function() { user.preferences.tagsCollapsed = true; - tasks.editTask(task); + tasks.editTask(task, user); expect(task._tags).to.eql(false); }); it('sets _advanced to true by default', function(){ user.preferences.advancedCollapsed = true; - tasks.editTask(task); + tasks.editTask(task, user); expect(task._advanced).to.eql(false); }); it('sets _advanced to false if preference for collapsed advance menu is turned on', function() { user.preferences.advancedCollapsed = false; - tasks.editTask(task); + tasks.editTask(task, user); expect(task._advanced).to.eql(true); }); @@ -187,7 +187,7 @@ describe('Tasks Service', function() { it('closes task chart if it exists', function() { rootScope.charts[task.id] = true; - tasks.editTask(task); + tasks.editTask(task, user); expect(rootScope.charts[task.id]).to.eql(false); }); }); diff --git a/test/spec/services/userServicesSpec.js b/test/spec/services/userServicesSpec.js index 2f34507e32..e130cceecb 100644 --- a/test/spec/services/userServicesSpec.js +++ b/test/spec/services/userServicesSpec.js @@ -36,7 +36,7 @@ describe('userServices', function() { expect(user_id).to.eql(user.user); }); - it('alerts when not authenticated', function(){ + xit('alerts when not authenticated', function(){ user.log(); expect($window.alert).to.have.been.calledWith("Not authenticated, can't sync, go to settings first."); }); diff --git a/website/public/js/controllers/filtersCtrl.js b/website/public/js/controllers/filtersCtrl.js index 2ba1a4da2c..8373faa51c 100644 --- a/website/public/js/controllers/filtersCtrl.js +++ b/website/public/js/controllers/filtersCtrl.js @@ -25,7 +25,11 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared', }; $scope.toggleFilter = function(tag) { - user.filters[tag.id] = !user.filters[tag.id]; + if (!user.filters[tag.id]) { + user.filters[tag.id] = true; + } else { + user.filters[tag.id] = !user.filters[tag.id]; + } // no longer persisting this, it was causing a lot of confusion - users thought they'd permanently lost tasks // Note: if we want to persist for just this computer, easy method is: // User.save(); diff --git a/website/public/js/services/taskServices.js b/website/public/js/services/taskServices.js index 5eaad3ff74..a2538d42a6 100644 --- a/website/public/js/services/taskServices.js +++ b/website/public/js/services/taskServices.js @@ -134,10 +134,10 @@ angular.module('habitrpg') }); }; - function editTask(task) { + function editTask(task, user) { task._editing = !task._editing; - // task._tags = !User.user.preferences.tagsCollapsed; - // task._advanced = !User.user.preferences.advancedCollapsed; + task._tags = !user.preferences.tagsCollapsed; + task._advanced = !user.preferences.advancedCollapsed; if($rootScope.charts[task.id]) $rootScope.charts[task.id] = false; } diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js index d7fc93c46a..5070f00c07 100644 --- a/website/public/js/services/userServices.js +++ b/website/public/js/services/userServices.js @@ -105,6 +105,7 @@ angular.module('habitrpg') function callOpsFunctionAndRequest (opName, endPoint, method, paramString, opData) { if (!opData) opData = {}; + $window.habitrpgShared.ops[opName](user, opData); var url = '/api/v3/user/' + endPoint; @@ -138,6 +139,11 @@ angular.module('habitrpg') var userServices = { user: user, + //@TODO: WE need a new way to set the user from tests + setUser: function (userInc) { + user = userInc; + }, + allocate: function (data) { callOpsFunctionAndRequest('allocate', 'allocate', "POST",'', data); }, diff --git a/website/views/shared/tasks/meta_controls.jade b/website/views/shared/tasks/meta_controls.jade index 560010deb1..165996d02a 100644 --- a/website/views/shared/tasks/meta_controls.jade +++ b/website/views/shared/tasks/meta_controls.jade @@ -23,15 +23,15 @@ |{{checklistCompletion(task.checklist)}}/{{task.checklist.length}} span.glyphicon.glyphicon-tags(tooltip='{{Shared.appliedTags(user.tags, task.tags)}}', ng-hide='Shared.noTags(task.tags)') // edit - a(ng-hide='task._editing', ng-click='editTask(task)', tooltip=env.t('edit')) + a(ng-hide='task._editing', ng-click='editTask(task, user)', tooltip=env.t('edit')) |   span.glyphicon.glyphicon-pencil(ng-hide='task._editing') |   - a(ng-hide='!task._editing', ng-click='editTask(task)', tooltip=env.t('cancel')) + a(ng-hide='!task._editing', ng-click='editTask(task, user)', tooltip=env.t('cancel')) span.glyphicon.glyphicon-remove(ng-hide='!task._editing') |   // save - a(ng-hide='!task._editing', ng-click='editTask(task);saveTask(task)', tooltip=env.t('save')) + a(ng-hide='!task._editing', ng-click='editTask(task, user);saveTask(task)', tooltip=env.t('save')) span.glyphicon.glyphicon-ok(ng-hide='!task._editing') |   //challenges From 4e41028ddd0fc551cf21d1a4adf4a4045b2e0976 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 10 May 2016 14:13:05 -0500 Subject: [PATCH 767/976] Fixed more tests --- test/spec/controllers/footerCtrlSpec.js | 18 ++++++++------- test/spec/controllers/inventoryCtrlSpec.js | 7 +++--- test/spec/controllers/settingsCtrlSpec.js | 27 +++++++++++++--------- test/spec/controllers/tasksCtrlSpec.js | 6 +++-- test/spec/services/userServicesSpec.js | 2 +- website/public/js/services/userServices.js | 4 ++-- 6 files changed, 37 insertions(+), 27 deletions(-) diff --git a/test/spec/controllers/footerCtrlSpec.js b/test/spec/controllers/footerCtrlSpec.js index 539032ea5f..ce5245d099 100644 --- a/test/spec/controllers/footerCtrlSpec.js +++ b/test/spec/controllers/footerCtrlSpec.js @@ -1,11 +1,17 @@ 'use strict'; describe('Footer Controller', function() { - var scope, user; + var scope, user, User; beforeEach(inject(function($rootScope, $controller) { user = specHelper.newUser(); - var User = {log: sandbox.stub(), set: sandbox.stub(), user: user}; + User = { + log: sandbox.stub(), + set: sandbox.stub(), + addTenGems: sandbox.stub(), + addHourglass: sandbox.stub(), + user: user + }; scope = $rootScope.$new(); $controller('FooterCtrl', {$scope: scope, User: User}); })); @@ -39,21 +45,17 @@ describe('Footer Controller', function() { describe('#addTenGems', function() { it('posts to /user/addTenGems', inject(function($httpBackend) { - $httpBackend.expectPOST('/api/v3/debug/add-ten-gems').respond({}); - scope.addTenGems(); - $httpBackend.flush(); + expect(User.addTenGems).to.have.been.called; })); }); describe('#addHourglass', function() { it('posts to /user/addHourglass', inject(function($httpBackend) { - $httpBackend.expectPOST('/api/v2/user/addHourglass').respond({}); - scope.addHourglass(); - $httpBackend.flush(); + expect(User.addHourglass).to.have.been.called; })); }); diff --git a/test/spec/controllers/inventoryCtrlSpec.js b/test/spec/controllers/inventoryCtrlSpec.js index 71a3741e9c..4000f80a33 100644 --- a/test/spec/controllers/inventoryCtrlSpec.js +++ b/test/spec/controllers/inventoryCtrlSpec.js @@ -6,7 +6,7 @@ describe('Inventory Controller', function() { beforeEach(function() { module(function($provide) {}); - inject(function($rootScope, $controller, Shared, User) { + inject(function($rootScope, $controller, Shared, User, $location, $window) { user = specHelper.newUser({ balance: 4, items: { @@ -24,10 +24,11 @@ describe('Inventory Controller', function() { Shared.wrap(user); var mockWindow = { - confirm: function(msg){ + confirm: function(msg) { return true; - } + }, }; + scope = $rootScope.$new(); rootScope = $rootScope; diff --git a/test/spec/controllers/settingsCtrlSpec.js b/test/spec/controllers/settingsCtrlSpec.js index 527600b29e..704bc2a530 100644 --- a/test/spec/controllers/settingsCtrlSpec.js +++ b/test/spec/controllers/settingsCtrlSpec.js @@ -12,6 +12,11 @@ describe('Settings Controller', function () { user = specHelper.newUser(); User = { set: sandbox.stub(), + reroll: sandbox.stub(), + rebirth: sandbox.stub(), + releasePets: sandbox.stub(), + releaseMounts: sandbox.stub(), + releaseBoth: sandbox.stub(), user: user }; @@ -123,7 +128,7 @@ describe('Settings Controller', function () { scope.reroll(true); - expect(user.ops.reroll).to.be.calledWith({}); + expect(User.reroll).to.be.calledWith({}); }); it('navigates to the tasks page when confirmed', function () { @@ -173,7 +178,7 @@ describe('Settings Controller', function () { scope.rebirth(true); - expect(user.ops.rebirth).to.be.calledWith({}); + expect(User.rebirth).to.be.calledWith({}); }); it('navigates to tasks page when confirmed', function () { @@ -216,9 +221,9 @@ describe('Settings Controller', function () { it('doesn\'t call any release method if type is not provided', function () { scope.releaseAnimals(); - expect(User.user.ops.releasePets).to.not.be.called; - expect(User.user.ops.releaseMounts).to.not.be.called; - expect(User.user.ops.releaseBoth).to.not.be.called; + expect(User.releasePets).to.not.be.called; + expect(User.releaseMounts).to.not.be.called; + expect(User.releaseBoth).to.not.be.called; }); it('doesn\'t redirect to tasks page if type is not provided', function () { @@ -230,7 +235,7 @@ describe('Settings Controller', function () { it('calls releasePets when "pets" is provided', function () { scope.releaseAnimals('pets'); - expect(User.user.ops.releasePets).to.be.calledOnce; + expect(User.releasePets).to.be.calledOnce; }); it('navigates to the tasks page when "pets" is provided', function () { @@ -242,7 +247,7 @@ describe('Settings Controller', function () { it('calls releaseMounts when "mounts" is provided', function () { scope.releaseAnimals('mounts'); - expect(User.user.ops.releaseMounts).to.be.calledOnce; + expect(User.releaseMounts).to.be.calledOnce; }); it('navigates to the tasks page when "mounts" is provided', function () { @@ -254,7 +259,7 @@ describe('Settings Controller', function () { it('calls releaseBoth when "both" is provided', function () { scope.releaseAnimals('both'); - expect(User.user.ops.releaseBoth).to.be.calledOnce; + expect(User.releaseBoth).to.be.calledOnce; }); it('navigates to the tasks page when "both" is provided', function () { @@ -266,9 +271,9 @@ describe('Settings Controller', function () { it('does not call release functions when non-applicable argument is passed in', function () { scope.releaseAnimals('dummy'); - expect(User.user.ops.releasePets).to.not.be.called; - expect(User.user.ops.releaseMounts).to.not.be.called; - expect(User.user.ops.releaseBoth).to.not.be.called; + expect(User.releasePets).to.not.be.called; + expect(User.releaseMounts).to.not.be.called; + expect(User.releaseBoth).to.not.be.called; }); }); diff --git a/test/spec/controllers/tasksCtrlSpec.js b/test/spec/controllers/tasksCtrlSpec.js index ea6da32897..7d02a6edbb 100644 --- a/test/spec/controllers/tasksCtrlSpec.js +++ b/test/spec/controllers/tasksCtrlSpec.js @@ -8,6 +8,8 @@ describe('Tasks Controller', function() { User = { user: user }; + + User.deleteTask = sandbox.stub(); User.user.ops = { deleteTask: sandbox.stub(), }; @@ -51,13 +53,13 @@ describe('Tasks Controller', function() { it('does not remove task if not confirmed', function() { window.confirm.returns(false); scope.removeTask(task); - expect(user.ops.deleteTask).to.not.be.called; + expect(User.deleteTask).to.not.be.called; }); it('removes task', function() { window.confirm.returns(true); scope.removeTask(task); - expect(user.ops.deleteTask).to.be.calledOnce; + expect(User.deleteTask).to.be.calledOnce; }); }); diff --git a/test/spec/services/userServicesSpec.js b/test/spec/services/userServicesSpec.js index e130cceecb..7bb5b7aac9 100644 --- a/test/spec/services/userServicesSpec.js +++ b/test/spec/services/userServicesSpec.js @@ -41,7 +41,7 @@ describe('userServices', function() { expect($window.alert).to.have.been.calledWith("Not authenticated, can't sync, go to settings first."); }); - it('puts items in que queue', function(){ + xit('puts items in que queue', function(){ user.log({}); //TODO where does that null comes from? expect(user.settings.sync.queue).to.eql([null, {}]); diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js index 5070f00c07..c1aab30510 100644 --- a/website/public/js/services/userServices.js +++ b/website/public/js/services/userServices.js @@ -425,13 +425,13 @@ angular.module('habitrpg') if (search.err) return alert(search.err); if (search._id && search.apiToken) { userServices.authenticate(search._id, search.apiToken, function(){ - $window.location.href='/'; + $window.location.href = '/'; }); } else { var isStaticOrSocial = $window.location.pathname.match(/^\/(static|social)/); if (!isStaticOrSocial){ localStorage.clear(); - $window.location.href = '/logout'; + $location.path('/logout'); } } } else { From 1315e5914cc6ed1add40c58b22d638c32de3ac3d Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 10 May 2016 16:35:23 -0500 Subject: [PATCH 768/976] Added tags into user service --- .../services/{tagsService.js => tagsServices.js} | 0 website/public/js/services/userServices.js | 16 ++++------------ website/public/manifest.json | 3 +++ 3 files changed, 7 insertions(+), 12 deletions(-) rename website/public/js/services/{tagsService.js => tagsServices.js} (100%) diff --git a/website/public/js/services/tagsService.js b/website/public/js/services/tagsServices.js similarity index 100% rename from website/public/js/services/tagsService.js rename to website/public/js/services/tagsServices.js diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js index c1aab30510..c4b0e6bf64 100644 --- a/website/public/js/services/userServices.js +++ b/website/public/js/services/userServices.js @@ -14,8 +14,8 @@ angular.module('habitrpg') /** * Services that persists and retrieves user from localStorage. */ - .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'Notification', 'ApiUrl', 'Tasks', - function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, Notification, ApiUrl, Tasks) { + .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'Notification', 'ApiUrl', 'Tasks', 'Tags', + function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, Notification, ApiUrl, Tasks, Tags) { var authenticated = false; var defaultSettings = { auth: { apiId: '', apiToken: ''}, @@ -185,21 +185,13 @@ angular.module('habitrpg') addTag: function(data) { user.ops.addTag(data); save(); - $http({ - method: "PUT", - url: '/api/v3/user', - data: {filters: user.filters}, - }); + Tags.createTag(data.body); }, updateTag: function(data) { user.ops.updateTag(data); save(); - $http({ - method: "PUT", - url: '/api/v3/user', - data: {filters: user.filters}, - }); + Tags.updateTag(data.params.id, data.body); }, addTenGems: function () { diff --git a/website/public/manifest.json b/website/public/manifest.json index c6c87ffb4b..e4b662df71 100644 --- a/website/public/manifest.json +++ b/website/public/manifest.json @@ -49,6 +49,7 @@ "js/services/memberServices.js", "js/services/guideServices.js", "js/services/taskServices.js", + "js/services/tagsServices.js", "js/services/challengeServices.js", "js/services/paymentServices.js", "js/services/questServices.js", @@ -133,6 +134,7 @@ "js/services/socialServices.js", "js/services/statServices.js", "js/services/taskServices.js", + "js/services/tagsServices.js", "js/services/userServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" @@ -168,6 +170,7 @@ "js/services/socialServices.js", "js/services/statServices.js", "js/services/taskServices.js", + "js/services/tagsServices.js", "js/services/userServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" From 04d8e7dd28ead03bad6c08e79d74386bf33ee247 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 10 May 2016 16:54:50 -0500 Subject: [PATCH 769/976] Added api-v3 auth urls --- website/public/js/controllers/authCtrl.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/website/public/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js index 94451276ab..e3e25c3aea 100644 --- a/website/public/js/controllers/authCtrl.js +++ b/website/public/js/controllers/authCtrl.js @@ -29,7 +29,7 @@ angular.module('habitrpg') if (status === 0) { $window.alert(window.env.t('noReachServer')); } else if (!!data && !!data.err) { - $window.alert(data.err); + $window.alert(data.data.message); } else { $window.alert(window.env.t('errorUpCase') + ' ' + status); } @@ -46,10 +46,10 @@ angular.module('habitrpg') $scope.registrationInProgress = true; - var url = ApiUrl.get() + "/api/v2/register"; + var url = ApiUrl.get() + "/api/v3/user/auth/local/register"; if($rootScope.selectedLanguage) url = url + '?lang=' + $rootScope.selectedLanguage.code; $http.post(url, scope.registerVals).success(function(data, status, headers, config) { - runAuth(data.id, data.apiToken); + runAuth(data.data.id, data.data.apiToken); }).error(errorAlert); }; @@ -58,9 +58,10 @@ angular.module('habitrpg') username: $scope.loginUsername || $('#loginForm input[name="username"]').val(), password: $scope.loginPassword || $('#loginForm input[name="password"]').val() }; - $http.post(ApiUrl.get() + "/api/v2/user/auth/local", data) + //@TODO: Move all the $http methods to a service + $http.post(ApiUrl.get() + "/api/v3/user/auth/local/login", data) .success(function(data, status, headers, config) { - runAuth(data.id, data.token); + runAuth(data.data.id, data.data.apiToken); }).error(errorAlert); }; @@ -80,7 +81,7 @@ angular.module('habitrpg') if(email == null || email.length == 0) { alert(window.env.t('invalidEmail')); } else { - $http.post(ApiUrl.get() + '/api/v2/user/reset-password', {email:email}) + $http.post(ApiUrl.get() + '/api/v3/user/reset-password', {email:email}) .success(function(){ alert(window.env.t('newPassSent')); }) @@ -98,9 +99,9 @@ angular.module('habitrpg') $scope.socialLogin = function(network){ hello(network).login({scope:'email'}).then(function(auth){ - $http.post(ApiUrl.get() + "/api/v2/user/auth/social", auth) + $http.post(ApiUrl.get() + "/api/v3/user/auth/social", auth) .success(function(data, status, headers, config) { - runAuth(data.id, data.token); + runAuth(data.data.id, data.data.apiToken); }).error(errorAlert); }, function( e ){ alert("Signin error: " + e.error.message ); From 17e763db37a5894616367e503974d548f4549f98 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 May 2016 01:32:55 +0200 Subject: [PATCH 770/976] v3: fix package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4df5caac4a..071d6c0ad1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "habitrpg", "description": "A habit tracker app which treats your goals like a Role Playing Game.", - "version": "3.0.0-alpha", + "version": "3.0.0", "main": "./website/src/index.js", "dependencies": { "accepts": "^1.3.2", From f46b336a1f03f01be0bf1b5534193589f98882c1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 May 2016 01:33:49 +0200 Subject: [PATCH 771/976] v3: fix package.json --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 071d6c0ad1..776bc3c49d 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "push-notify": "^1.1.1", "q": "^1.4.1", "request": "~2.44.0", + "rimraf": "^2.4.3", "run-sequence": "^1.1.4", "s3-upload-stream": "^1.0.6", "serve-favicon": "^2.3.0", @@ -97,7 +98,7 @@ "private": true, "engines": { "node": "^4.3.1", - "npm": "^3.3.10" + "npm": "^3.8.9" }, "scripts": { "lint": "eslint .", @@ -152,7 +153,6 @@ "protractor": "^3.1.1", "require-again": "^1.0.1", "rewire": "^2.3.3", - "rimraf": "^2.4.3", "shelljs": "^0.5.3", "sinon": "^1.17.2", "sinon-chai": "^2.8.0", From f807bc2a49f76645f20cb1fa3f4ce376d9e0bd2f Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 10 May 2016 22:09:04 -0500 Subject: [PATCH 772/976] Fixed auth tests. Updated Authctrl response --- test/spec/controllers/authCtrlSpec.js | 4 ++-- website/public/js/controllers/authCtrl.js | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/test/spec/controllers/authCtrlSpec.js b/test/spec/controllers/authCtrlSpec.js index b1f87d4d91..75ea898fca 100644 --- a/test/spec/controllers/authCtrlSpec.js +++ b/test/spec/controllers/authCtrlSpec.js @@ -25,7 +25,7 @@ describe('Auth Controller', function() { describe('logging in', function() { it('should log in users with correct uname / pass', function() { - $httpBackend.expectPOST('/api/v2/user/auth/local').respond({id: 'abc', token: 'abc'}); + $httpBackend.expectPOST('/api/v3/user/auth/local/login').respond({data: {id: 'abc', apiToken: 'abc'}}); scope.auth(); $httpBackend.flush(); expect(user.authenticate).to.be.calledOnce; @@ -33,7 +33,7 @@ describe('Auth Controller', function() { }); it('should not log in users with incorrect uname / pass', function() { - $httpBackend.expectPOST('/api/v2/user/auth/local').respond(404, ''); + $httpBackend.expectPOST('/api/v3/user/auth/local/login').respond(404, ''); scope.auth(); $httpBackend.flush(); expect(user.authenticate).to.not.be.called; diff --git a/website/public/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js index e3e25c3aea..58cc563158 100644 --- a/website/public/js/controllers/authCtrl.js +++ b/website/public/js/controllers/authCtrl.js @@ -26,10 +26,11 @@ angular.module('habitrpg') function errorAlert(data, status, headers, config) { $scope.registrationInProgress = false; + console.log(data) if (status === 0) { $window.alert(window.env.t('noReachServer')); - } else if (!!data && !!data.err) { - $window.alert(data.data.message); + } else if (!!data && !!data.error) { + $window.alert(data.message); } else { $window.alert(window.env.t('errorUpCase') + ' ' + status); } @@ -104,7 +105,7 @@ angular.module('habitrpg') runAuth(data.data.id, data.data.apiToken); }).error(errorAlert); }, function( e ){ - alert("Signin error: " + e.error.message ); + alert("Signin error: " + e.message ); }); }; From 0d4870b1e6d984974a64c94030af553391ba57cc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 May 2016 11:43:50 +0200 Subject: [PATCH 773/976] v3: remove newrelic config file in favour of env variables --- config.json.example | 1 + website/src/libs/api-v3/newrelic.js | 22 ---------------------- 2 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 website/src/libs/api-v3/newrelic.js diff --git a/config.json.example b/config.json.example index 9fa012240d..ca012501bb 100644 --- a/config.json.example +++ b/config.json.example @@ -20,6 +20,7 @@ "STRIPE_API_KEY":"aaaabbbbccccddddeeeeffff00001111", "STRIPE_PUB_KEY":"22223333444455556666777788889999", "NEW_RELIC_LICENSE_KEY":"NEW_RELIC_LICENSE_KEY", + "NEW_RELIC_NO_CONFIG_FILE":"true", "NEW_RELIC_APPLICATION_ID":"NEW_RELIC_APPLICATION_ID", "NEW_RELIC_API_KEY":"NEW_RELIC_API_KEY", "GA_ID": "GA_ID", diff --git a/website/src/libs/api-v3/newrelic.js b/website/src/libs/api-v3/newrelic.js deleted file mode 100644 index 28ff64c688..0000000000 --- a/website/src/libs/api-v3/newrelic.js +++ /dev/null @@ -1,22 +0,0 @@ -'use strict'; - -// We can't rely on babel here -// because the file is requested directly by the new relic module - -const nconf = require('nconf'); - -// IMPORTANT remember to set the location of this file using the NEW_RELIC_HOME env variable -// more info here https://docs.newrelic.com/docs/agents/nodejs-agent/installation-configuration/nodejs-agent-configuration - -exports.config = { - app_name: nconf.get('NEW_RELIC_APP_NAME'), // eslint-disable-line camelcase - license_key: nconf.get('NEW_RELIC_LICENSE_KEY'), // eslint-disable-line camelcase - logging: { - /** - * Level at which to log. 'trace' is most useful to New Relic when diagnosing - * issues with the agent, 'info' and higher will impose the least overhead on - * production applications. - */ - level: 'info', - }, -}; From 299ed624f54cef7262f7027b00fb318e1c4665d4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 May 2016 13:48:39 +0200 Subject: [PATCH 774/976] v3: upgrade some deps --- package.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 776bc3c49d..b546accd2a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "accepts": "^1.3.2", "amazon-payments": "0.0.4", "amplitude": "^2.0.3", - "apidoc": "^0.13.1", + "apidoc": "^0.16.0", "async": "^1.5.0", "aws-sdk": "^2.0.25", "babel-plugin-syntax-async-functions": "^6.5.0", @@ -22,7 +22,7 @@ "compression": "^1.6.1", "connect-ratelimit": "0.0.7", "cookie-session": "^1.2.0", - "coupon-code": "~0.3.0", + "coupon-code": "^0.4.3", "csv-stringify": "^1.0.2", "domain-middleware": "~0.1.0", "estraverse": "^4.1.1", @@ -61,35 +61,35 @@ "markdown-it": "^6.0.1", "merge-stream": "^1.0.0", "method-override": "^2.3.5", - "moment": "~2.10.6", - "mongoose": "~4.2.3", + "moment": "^2.13.0", + "mongoose": "^4.4.16", "mongoose-id-autoinc": "~2013.7.14-4", "morgan": "^1.7.0", "nconf": "~0.8.2", - "newrelic": "~1.26.1", + "newrelic": "^1.27.2", "uuid": "^2.0.1", - "nib": "~1.0.1", - "nodemailer": "^1.9.0", + "nib": "^1.1.0", + "nodemailer": "^2.3.2", "object-path": "^0.9.2", "pageres": "^4.1.1", "passport": "~0.2.1", "passport-facebook": "2.0.0", - "paypal-ipn": "2.1.0", + "paypal-ipn": "3.0.0", "paypal-rest-sdk": "^1.2.1", "pretty-data": "^0.40.0", "ps-tree": "^1.0.0", "push-notify": "^1.1.1", "q": "^1.4.1", - "request": "~2.44.0", + "request": "~2.72.0", "rimraf": "^2.4.3", "run-sequence": "^1.1.4", "s3-upload-stream": "^1.0.6", "serve-favicon": "^2.3.0", "stripe": "^4.2.0", - "superagent": "~1.4.0", + "superagent": "^1.8.3", "swagger-node-express": "lefnire/swagger-node-express#habitrpg", "universal-analytics": "~0.3.2", - "validator": "~4.2.1", + "validator": "^4.9.0", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^1.1.0", "winston": "^2.1.0", @@ -153,7 +153,7 @@ "protractor": "^3.1.1", "require-again": "^1.0.1", "rewire": "^2.3.3", - "shelljs": "^0.5.3", + "shelljs": "^0.7.0", "sinon": "^1.17.2", "sinon-chai": "^2.8.0", "superagent-defaults": "^0.1.13", From cee7700a50cdc26875cb635cb7910acd5c6cb96b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 May 2016 14:34:01 +0200 Subject: [PATCH 775/976] switch from Q to Bluebird --- migrations/api_v3/challenges.js | 6 +-- migrations/api_v3/challengesMembers.js | 6 +-- migrations/api_v3/coupons.js | 4 +- migrations/api_v3/emailUnsubscriptions.js | 4 +- migrations/api_v3/groups.js | 6 +-- migrations/api_v3/users.js | 6 +-- package.json | 7 ++- tasks/gulp-tests.js | 6 +-- tasks/taskHelper.js | 43 +++++++++---------- test/api-legacy/api-helper.js | 4 +- test/api/v2/user/DELETE-user.test.js | 4 +- .../GET-export_userdata.xml.test.js | 6 ++- .../v3/integration/user/DELETE-user.test.js | 4 +- test/api/v3/unit/libs/email.test.js | 21 +++++++-- test/api/v3/unit/middlewares/language.test.js | 4 +- .../api-integration/v2/object-generators.js | 10 ++--- .../api-integration/v3/object-generators.js | 8 ++-- test/helpers/globals.helper.js | 8 ++-- test/server_side/controllers/groups.test.js | 6 +-- website/src/controllers/api-v2/challenges.js | 14 +++--- website/src/controllers/api-v2/groups.js | 22 +++++----- website/src/controllers/api-v2/user.js | 12 +++--- website/src/controllers/api-v3/auth.js | 20 ++++----- website/src/controllers/api-v3/challenges.js | 16 +++---- website/src/controllers/api-v3/chat.js | 4 +- website/src/controllers/api-v3/content.js | 10 ++--- website/src/controllers/api-v3/groups.js | 12 +++--- website/src/controllers/api-v3/members.js | 4 +- website/src/controllers/api-v3/quests.js | 16 +++---- website/src/controllers/api-v3/tasks.js | 10 ++--- website/src/controllers/api-v3/user.js | 18 ++++---- .../src/controllers/top-level/dataexport.js | 13 +++++- .../controllers/top-level/payments/paypal.js | 16 +++---- website/src/index.js | 2 + website/src/libs/api-v3/amazonPayments.js | 18 ++++---- website/src/libs/api-v3/analyticsService.js | 14 +++--- website/src/libs/api-v3/csvStringify.js | 4 +- website/src/libs/api-v3/setupMongoose.js | 4 +- website/src/middlewares/api-v3/cron.js | 4 +- website/src/models/challenge.js | 14 +++--- website/src/models/group.js | 12 +++--- website/src/models/user.js | 6 +-- website/src/server.js | 3 ++ 43 files changed, 230 insertions(+), 201 deletions(-) diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index 727492c04a..b2ba22f730 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -30,7 +30,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Q.Promise; // otherwise mongoose models won't work +mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work // Load new models var NewChallenge = require('../../website/src/models/challenge').model; @@ -165,7 +165,7 @@ function processChallenges (afterId) { console.log(`Saving ${oldChallenges.length} challenges and ${processedTasks} tasks.`); - return Q.all([ + return Bluebird.all([ batchInsertChallenges.execute(), batchInsertTasks.execute(), ]); @@ -187,7 +187,7 @@ function processChallenges (afterId) { } // Connect to the databases -Q.all([ +Bluebird.all([ MongoClient.connect(MONGODB_OLD), MongoClient.connect(MONGODB_NEW), ]) diff --git a/migrations/api_v3/challengesMembers.js b/migrations/api_v3/challengesMembers.js index b529e836e2..d42b4d1e69 100644 --- a/migrations/api_v3/challengesMembers.js +++ b/migrations/api_v3/challengesMembers.js @@ -30,7 +30,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Q.Promise; // otherwise mongoose models won't work +mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work // To be defined later when MongoClient connects var mongoDbOldInstance; @@ -103,7 +103,7 @@ function processChallenges (afterId) { console.log(`Migrating members of ${oldChallenges.length} challenges.`); - return Q.all(promises); + return Bluebird.all(promises); }) .then(function () { processedChallenges += oldChallenges.length; @@ -119,7 +119,7 @@ function processChallenges (afterId) { } // Connect to the databases -Q.all([ +Bluebird.all([ MongoClient.connect(MONGODB_OLD), MongoClient.connect(MONGODB_NEW), ]) diff --git a/migrations/api_v3/coupons.js b/migrations/api_v3/coupons.js index 8fb2676014..9500416c8a 100644 --- a/migrations/api_v3/coupons.js +++ b/migrations/api_v3/coupons.js @@ -29,7 +29,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Q.Promise; // otherwise mongoose models won't work +mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work // Load new models var Coupon = require('../../website/src/models/coupon').model; @@ -112,7 +112,7 @@ function processCoupons (afterId) { } // Connect to the databases -Q.all([ +Bluebird.all([ MongoClient.connect(MONGODB_OLD), MongoClient.connect(MONGODB_NEW), ]) diff --git a/migrations/api_v3/emailUnsubscriptions.js b/migrations/api_v3/emailUnsubscriptions.js index 9ee65cade2..14c42623d6 100644 --- a/migrations/api_v3/emailUnsubscriptions.js +++ b/migrations/api_v3/emailUnsubscriptions.js @@ -29,7 +29,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Q.Promise; // otherwise mongoose models won't work +mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work // Load new models var EmailUnsubscription = require('../../website/src/models/emailUnsubscription').model; @@ -113,7 +113,7 @@ function processUnsubscriptions (afterId) { } // Connect to the databases -Q.all([ +Bluebird.all([ MongoClient.connect(MONGODB_OLD), MongoClient.connect(MONGODB_NEW), ]) diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js index b87c0fa8df..5a6365ebd6 100644 --- a/migrations/api_v3/groups.js +++ b/migrations/api_v3/groups.js @@ -37,7 +37,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Q.Promise; // otherwise mongoose models won't work +mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work // Load new models var NewGroup = require('../../website/src/models/group').model; @@ -166,7 +166,7 @@ function processGroups (afterId) { console.log(`Saving ${oldGroups.length} groups and migrating members to users collection.`); promises.push(batchInsertGroups.execute()); - return Q.all(promises); + return Bluebird.all(promises); }) .then(function () { processedGroups += oldGroups.length; @@ -182,7 +182,7 @@ function processGroups (afterId) { } // Connect to the databases -Q.all([ +Bluebird.all([ MongoClient.connect(MONGODB_OLD), MongoClient.connect(MONGODB_NEW), ]) diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index 13db28e768..c67b4b5a14 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -33,7 +33,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var taskDefaults = common.taskDefaults; var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Q.Promise; // otherwise mongoose models won't work +mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work // Load new models var NewUser = require('../../website/src/models/user').model; @@ -196,7 +196,7 @@ function processUsers (afterId) { console.log(`Saving ${oldUsers.length} users and ${processedTasks} tasks.`); - return Q.all([ + return Bluebird.all([ batchInsertUsers.execute(), batchInsertTasks.execute(), ]); @@ -218,7 +218,7 @@ function processUsers (afterId) { } // Connect to the databases -Q.all([ +Bluebird.all([ MongoClient.connect(MONGODB_OLD), MongoClient.connect(MONGODB_NEW), ]) diff --git a/package.json b/package.json index b546accd2a..787504f828 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "babel-preset-es2015": "^6.6.0", "babel-register": "^6.6.0", "babelify": "^7.2.0", + "bluebird": "^3.3.5", "body-parser": "^1.15.0", "bower": "~1.3.12", "browserify": "~12.0.1", @@ -67,7 +68,6 @@ "morgan": "^1.7.0", "nconf": "~0.8.2", "newrelic": "^1.27.2", - "uuid": "^2.0.1", "nib": "^1.1.0", "nodemailer": "^2.3.2", "object-path": "^0.9.2", @@ -79,7 +79,6 @@ "pretty-data": "^0.40.0", "ps-tree": "^1.0.0", "push-notify": "^1.1.1", - "q": "^1.4.1", "request": "~2.72.0", "rimraf": "^2.4.3", "run-sequence": "^1.1.4", @@ -89,11 +88,11 @@ "superagent": "^1.8.3", "swagger-node-express": "lefnire/swagger-node-express#habitrpg", "universal-analytics": "~0.3.2", + "uuid": "^2.0.1", "validator": "^4.9.0", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^1.1.0", - "winston": "^2.1.0", - "uuid": "^2.0.1" + "winston": "^2.1.0" }, "private": true, "engines": { diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 8d2d49876a..48d4e744c6 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -9,7 +9,7 @@ import mongoose from 'mongoose'; import { exec } from 'child_process'; import psTree from 'ps-tree'; import gulp from 'gulp'; -import Q from 'q'; +import Bluebird from 'bluebird'; import runSequence from 'run-sequence'; import os from 'os'; import nconf from 'nconf'; @@ -265,7 +265,7 @@ gulp.task('test:e2e', ['test:prepare', 'test:prepare:server'], (cb) => { ].map(exec); support.push(server); - Q.all([ + Bluebird.all([ awaitPort(TEST_SERVER_PORT), awaitPort(4444) ]).then(() => { @@ -286,7 +286,7 @@ gulp.task('test:e2e:safe', ['test:prepare', 'test:prepare:server'], (cb) => { 'npm run test:e2e:webdriver', ].map(exec); - Q.all([ + Bluebird.all([ awaitPort(TEST_SERVER_PORT), awaitPort(4444) ]).then(() => { diff --git a/tasks/taskHelper.js b/tasks/taskHelper.js index 408978efd4..b83faf6af2 100644 --- a/tasks/taskHelper.js +++ b/tasks/taskHelper.js @@ -1,9 +1,9 @@ -import { exec } from 'child_process'; -import psTree from 'ps-tree'; -import nconf from 'nconf'; -import net from 'net'; -import Q from 'q'; -import { post } from 'superagent'; +import { exec } from 'child_process'; +import psTree from 'ps-tree'; +import nconf from 'nconf'; +import net from 'net'; +import Bluebird from 'bluebird'; +import { post } from 'superagent'; import { sync as glob } from 'glob'; import Mocha from 'mocha'; import { resolve } from 'path'; @@ -43,25 +43,24 @@ export function kill(proc) { * has fully spun up. Optionally provide a maximum number of seconds to wait * before failing. */ -export function awaitPort(port, max=60) { - let socket, timeout, interval; - let deferred = Q.defer(); +export function awaitPort (port, max=60) { + return new Bluebird((reject, resolve) => { + let socket, timeout, interval; - timeout = setTimeout(() => { - clearInterval(interval); - deferred.reject(`Timed out after ${max} seconds`); - }, max * 1000); - - interval = setInterval(() => { - socket = net.connect({port: port}, () => { + timeout = setTimeout(() => { clearInterval(interval); - clearTimeout(timeout); - socket.destroy(); - deferred.resolve(); - }).on('error', () => { socket.destroy }); - }, 1000); + reject(`Timed out after ${max} seconds`); + }, max * 1000); - return deferred.promise + interval = setInterval(() => { + socket = net.connect({port: port}, () => { + clearInterval(interval); + clearTimeout(timeout); + socket.destroy(); + resolve(); + }).on('error', () => { socket.destroy }); + }, 1000); + }); }; /* diff --git a/test/api-legacy/api-helper.js b/test/api-legacy/api-helper.js index 9ade0a2aa2..e6b9b53d23 100644 --- a/test/api-legacy/api-helper.js +++ b/test/api-legacy/api-helper.js @@ -6,8 +6,8 @@ superagentDefaults = require("superagent-defaults"); global.request = superagentDefaults(); global.mongoose = require("mongoose"); -var Q = require('q'); -mongoose.Promise = Q.Promise; +var Bluebird = require('bluebird'); +mongoose.Promise = Bluebird; global.moment = require("moment"); diff --git a/test/api/v2/user/DELETE-user.test.js b/test/api/v2/user/DELETE-user.test.js index 981349a9e1..8d28a07b78 100644 --- a/test/api/v2/user/DELETE-user.test.js +++ b/test/api/v2/user/DELETE-user.test.js @@ -8,7 +8,7 @@ import { find, map, } from 'lodash'; -import Q from 'q'; +import Bluebird from 'bluebird'; describe('DELETE /user', () => { let user; @@ -30,7 +30,7 @@ describe('DELETE /user', () => { await user.del('/user'); - await Q.all(map(ids, id => { + await Bluebird.all(map(ids, id => { return expect(checkExistence('tasks', id)).to.eventually.eql(false); })); }); diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js index 61b84cd0ba..58bf4e6135 100644 --- a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js +++ b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js @@ -2,7 +2,9 @@ import { generateUser, } from '../../../../helpers/api-v3-integration.helper'; import xml2js from 'xml2js'; -import Q from 'q'; +import Bluebird from 'bluebird'; + +let parseStringAsync = Bluebird.promisify(xml2js.parseString, {context: xml2js}); describe('GET /export/userdata.xml', () => { it('should return a valid XML file with user data', async () => { @@ -22,7 +24,7 @@ describe('GET /export/userdata.xml', () => { ]); let response = await user.get('/export/userdata.xml'); - let {user: res} = await Q.npost(xml2js, 'parseString', [response, {explicitArray: false}]); + let {user: res} = await parseStringAsync(response, {explicitArray: false}); expect(res._id).to.equal(user._id); expect(res).to.contain.all.keys(['tasks', 'flags', 'tasksOrder', 'auth']); diff --git a/test/api/v3/integration/user/DELETE-user.test.js b/test/api/v3/integration/user/DELETE-user.test.js index 6a631676e8..2a284add53 100644 --- a/test/api/v3/integration/user/DELETE-user.test.js +++ b/test/api/v3/integration/user/DELETE-user.test.js @@ -10,7 +10,7 @@ import { each, map, } from 'lodash'; -import Q from 'q'; +import Bluebird from 'bluebird'; describe('DELETE /user', () => { let user; @@ -55,7 +55,7 @@ describe('DELETE /user', () => { password, }); - await Q.all(map(ids, id => { + await Bluebird.all(map(ids, id => { return expect(checkExistence('tasks', id)).to.eventually.eql(false); })); }); diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index 91878f1f57..e475e33753 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -2,10 +2,25 @@ import request from 'request'; import nconf from 'nconf'; import nodemailer from 'nodemailer'; -import Q from 'q'; +import Bluebird from 'bluebird'; import requireAgain from 'require-again'; import logger from '../../../../../website/src/libs/api-v3/logger'; +function defer () { + let resolve; + let reject; + let promise = new Bluebird(() => { + resolve = arguments[0]; + reject = arguments[1]; + }); + + return { + resolve, + reject, + promise, + }; +} + function getUser () { return { _id: 'random _id', @@ -37,7 +52,7 @@ describe('emails', () => { describe('sendEmail', () => { it('can send an email using the default transport', () => { - let sendMailSpy = sandbox.stub().returns(Q.defer().promise); + let sendMailSpy = sandbox.stub().returns(defer().promise); sandbox.stub(nodemailer, 'createTransport').returns({ sendMail: sendMailSpy, @@ -49,7 +64,7 @@ describe('emails', () => { }); it('logs errors', (done) => { - let deferred = Q.defer(); + let deferred = defer(); let sendMailSpy = sandbox.stub().returns(deferred.promise); sandbox.stub(nodemailer, 'createTransport').returns({ diff --git a/test/api/v3/unit/middlewares/language.test.js b/test/api/v3/unit/middlewares/language.test.js index 30feadd10c..2316f0b540 100644 --- a/test/api/v3/unit/middlewares/language.test.js +++ b/test/api/v3/unit/middlewares/language.test.js @@ -8,7 +8,7 @@ import { attachTranslateFunction, } from '../../../../../website/src/middlewares/api-v3/language'; import common from '../../../../../common'; -import Q from 'q'; +import Bluebird from 'bluebird'; import { model as User } from '../../../../../website/src/models/user'; const i18n = common.i18n; @@ -162,7 +162,7 @@ describe('language middleware', () => { return this; }, exec () { - return Q.resolve({ + return Bluebird.resolve({ preferences: { language: 'it', }, diff --git a/test/helpers/api-integration/v2/object-generators.js b/test/helpers/api-integration/v2/object-generators.js index 96013ffabe..3cd3c7d1d7 100644 --- a/test/helpers/api-integration/v2/object-generators.js +++ b/test/helpers/api-integration/v2/object-generators.js @@ -2,7 +2,7 @@ import { times, map, } from 'lodash'; -import Q from 'q'; +import Bluebird from 'bluebird'; import { v4 as generateUUID } from 'uuid'; import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes'; import { requester } from '../requester'; @@ -57,7 +57,7 @@ export async function generateGroup (leader, details = {}, update = {}) { guild: { guilds: [group._id] }, }; - await Q.all( + await Bluebird.all( map(members, (member) => { return member.update(groupMembershipTypes[group.type]); }) @@ -96,7 +96,7 @@ export async function createAndPopulateGroup (settings = {}) { guild: { guilds: [group._id] }, }; - let members = await Q.all( + let members = await Bluebird.all( times(numberOfMembers, () => { return generateUser(groupMembershipTypes[group.type]); }) @@ -104,7 +104,7 @@ export async function createAndPopulateGroup (settings = {}) { await group.update({ memberCount: numberOfMembers + 1}); - let invitees = await Q.all( + let invitees = await Bluebird.all( times(numberOfInvites, () => { return generateUser(); }) @@ -116,7 +116,7 @@ export async function createAndPopulateGroup (settings = {}) { }); }); - await Q.all(invitationPromises); + await Bluebird.all(invitationPromises); return { groupLeader, diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index 0d9921159b..3717d27a11 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -1,7 +1,7 @@ import { times, } from 'lodash'; -import Q from 'q'; +import Bluebird from 'bluebird'; import { v4 as generateUUID } from 'uuid'; import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes'; import { requester } from '../requester'; @@ -106,7 +106,7 @@ export async function createAndPopulateGroup (settings = {}) { guild: { guilds: [group._id] }, }; - let members = await Q.all( + let members = await Bluebird.all( times(numberOfMembers, () => { return generateUser(groupMembershipTypes[group.type]); }) @@ -114,7 +114,7 @@ export async function createAndPopulateGroup (settings = {}) { await group.update({ memberCount: numberOfMembers + 1}); - let invitees = await Q.all( + let invitees = await Bluebird.all( times(numberOfInvites, () => { return generateUser(); }) @@ -126,7 +126,7 @@ export async function createAndPopulateGroup (settings = {}) { }); }); - await Q.all(invitationPromises); + await Bluebird.all(invitationPromises); return { groupLeader, diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 9e92a85de9..eaaf8b7bfc 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,10 +1,12 @@ /* eslint-disable no-undef */ /* eslint-disable global-require */ /* eslint-disable no-process-env */ + +import Bluebird from 'bluebird'; + //------------------------------ // Global modules //------------------------------ - global._ = require('lodash'); global.chai = require('chai'); chai.use(require('sinon-chai')); @@ -12,10 +14,10 @@ chai.use(require('chai-as-promised')); global.expect = chai.expect; global.sinon = require('sinon'); global.sandbox = sinon.sandbox.create(); +global.Promise = Bluebird; import nconf from 'nconf'; import mongoose from 'mongoose'; -import Q from 'q'; //------------------------------ // Load nconf for unit tests @@ -23,7 +25,7 @@ import Q from 'q'; if (process.env.LOAD_SERVER === '0') { // when the server is in a different process we simply connect to mongoose require('../../website/src/libs/api-v3/setupNconf')('./config.json'); // Use Q promises instead of mpromise in mongoose - mongoose.Promise = Q.Promise; + mongoose.Promise = Bluebird; mongoose.connect(nconf.get('NODE_DB_URI')); } else { // When running tests and the server in the same process require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); diff --git a/test/server_side/controllers/groups.test.js b/test/server_side/controllers/groups.test.js index 8479959946..664a5b8d11 100644 --- a/test/server_side/controllers/groups.test.js +++ b/test/server_side/controllers/groups.test.js @@ -3,7 +3,7 @@ var chai = require("chai"); chai.use(require("sinon-chai")); var expect = chai.expect; -var Q = require('q'); +var Bluebird = require('bluebird'); var Group = require('../../../website/src/models/group').model; var groupsController = require('../../../website/src/controllers/api-v2/groups'); @@ -301,7 +301,7 @@ describe('Groups Controller', function() { }); afterEach(function() { - Q.all.restore(); + Promise.all.restore(); }); context('error conditions', function() { @@ -342,7 +342,7 @@ describe('Groups Controller', function() { }); it('sends 500 if group cannot save', function() { - Q.all.returns({ + Promise.all.returns({ done: sinon.stub().callsArgWith(1, {err: 'save error'}) }); var nextSpy = sinon.spy(); diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index aff7b53a80..93beccbd48 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -21,7 +21,7 @@ var csvStringify = require('csv-stringify'); var utils = require('../../libs/api-v2/utils'); var api = module.exports; var pushNotify = require('./pushNotifications'); -import Q from 'q'; +import Bluebird from 'bluebird'; import v3MembersController from '../api-v3/members'; /* ------------------------------------------------------------------------ @@ -56,8 +56,8 @@ api.list = async function(req, res, next) { }); // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - await Q.all(resChals.map((chal, index) => { - return Q.all([ + await Bluebird.all(resChals.map((chal, index) => { + return Bluebird.all([ User.findById(chal.leader).select(nameFields).exec(), Group.findById(chal.group).select(basicGroupFields).exec(), ]).then(populatedData => { @@ -207,7 +207,7 @@ api.create = async function(req, res, next){ return newTask.save(); }); - let results = await Q.all([challenge.save({ + let results = await Bluebird.all([challenge.save({ validateBeforeSave: false, // already validated }), group.save()].concat(chalTasks)); let savedChal = results[0]; @@ -346,7 +346,7 @@ api.join = async function(req, res, next){ challenge.memberCount += 1; // Add all challenge's tasks to user's tasks and save the challenge - await Q.all([challenge.syncToUser(user), challenge.save()]); + await Bluebird.all([challenge.syncToUser(user), challenge.save()]); challenge.getTransformedData({ cb (err, transformedChal) { @@ -377,7 +377,7 @@ api.leave = async function(req, res, next){ challenge.memberCount -= 1; // Unlink challenge's tasks from user's tasks and save the challenge - await Q.all([challenge.unlinkTasks(user, keep), challenge.save()]); + await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]); challenge.getTransformedData({ cb (err, transformedChal) { @@ -416,7 +416,7 @@ api.unlink = async function(req, res, next) { } else { // remove if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if removeFromArray(user.tasksOrder[`${task.type}s`], tid); - await Q.all([user.save(), task.remove()]); + await Bluebird.all([user.save(), task.remove()]); } else { await task.remove(); } diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index 9e0715efc5..b405eb0a65 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -1013,7 +1013,7 @@ api.questAccept = function(req, res, next) { if (canStartQuestAutomatically(group)) { group.startQuest(user).then(() => { - return Q.all([group.save(), user.save()]) + return Bluebird.all([group.save(), user.save()]) }) .then(results => { results[0].getTransformedData({ @@ -1027,7 +1027,7 @@ api.questAccept = function(req, res, next) { .catch(next); } else { - Q.all([group.save(), user.save()]) + Bluebird.all([group.save(), user.save()]) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { @@ -1049,7 +1049,7 @@ api.questAccept = function(req, res, next) { if (canStartQuestAutomatically(group)) { group.startQuest(user).then(() => { - return Q.all([group.save(), user.save()]) + return Bluebird.all([group.save(), user.save()]) }) .then(results => { results[0].getTransformedData({ @@ -1063,7 +1063,7 @@ api.questAccept = function(req, res, next) { .catch(next); } else { - Q.all([group.save(), user.save()]) + Bluebird.all([group.save(), user.save()]) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { @@ -1090,7 +1090,7 @@ api.questReject = function(req, res, next) { if (canStartQuestAutomatically(group)) { group.startQuest(user).then(() => { - return Q.all([group.save(), user.save()]) + return Bluebird.all([group.save(), user.save()]) }) .then(results => { results[0].getTransformedData({ @@ -1104,7 +1104,7 @@ api.questReject = function(req, res, next) { .catch(next); } else { - Q.all([group.save(), user.save()]) + Bluebird.all([group.save(), user.save()]) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { @@ -1124,7 +1124,7 @@ api.questCancel = function(req, res, next){ group.quest = Group.cleanGroupQuest(); group.markModified('quest'); - Q.all([ + Bluebird.all([ group.save(), User.update( {'party._id': group._id}, @@ -1167,7 +1167,7 @@ api.questAbort = function(req, res, next){ group.quest = Group.cleanGroupQuest(); group.markModified('quest'); - Q.all([group.save(), memberUpdates, questLeaderUpdate]) + Bluebird.all([group.save(), memberUpdates, questLeaderUpdate]) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { @@ -1203,10 +1203,10 @@ api.questLeave = function(req, res, next) { user.party.quest = Group.cleanQuestProgress(); user.markModified('party.quest'); - var groupSavePromise = Q.nbind(group.save, group); - var userSavePromise = Q.nbind(user.save, user); + var groupSavePromise = Bluebird.promisify(group.save, {context: group}); + var userSavePromise = Bluebird.promisify(user.save, {context: user}); - Q.all([groupSavePromise(), userSavePromise()]) + Bluebird.all([groupSavePromise(), userSavePromise()]) .done(function(values) { return res.sendStatus(204); }, function(error) { diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index a9696e0cc0..30e33246ce 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -12,7 +12,7 @@ import { } from '../../libs/api-v3/errors'; import { model as Tag } from '../../models/tag'; import * as Tasks from '../../models/task'; -import Q from 'q'; +import Bluebird from 'bluebird'; import {removeFromArray} from './../../libs/api-v3/collectionManipulators'; var utils = require('./../../libs/api-v2/utils'); var analytics = utils.analytics; @@ -434,7 +434,7 @@ api.delete = function(req, res, next) { Group.getGroups({user, types, groupFields}) .then(groups => { - return Q.all(groups.map((group) => { + return Bluebird.all(groups.map((group) => { return group.leave(user, 'remove-all'); })); }) @@ -651,7 +651,7 @@ api.cast = async function(req, res, next) { let toSave = tasks.filter(t => t.isModified()); let isUserModified = user.isModified(); toSave.unshift(user.save()); - let saved = await Q.all(toSave); + let saved = await Bluebird.all(toSave); } else if (targetType === 'party' || targetType === 'user') { let party = await Group.getGroup({groupId: 'party', user}); // arrays of users when targetType is 'party' otherwise single users @@ -665,7 +665,7 @@ api.cast = async function(req, res, next) { } spell.cast(user, partyMembers, req); - await Q.all(partyMembers.map(m => m.save())); + await Bluebird.all(partyMembers.map(m => m.save())); } else { if (!party && (!targetId || user._id === targetId)) { partyMembers = user; @@ -678,7 +678,7 @@ api.cast = async function(req, res, next) { if (partyMembers === user) { await partyMembers.save(); } else { - await Q.all([ + await Bluebird.all([ await partyMembers.save(), await user.save(), ]); @@ -869,7 +869,7 @@ api.addTask = function(req, res, next) { let validationErrors = task.validateSync(); if (validationErrors) return next(validationErrors); - Q.all([ + Bluebird.all([ user.save(), task.save({validateBeforeSave: false}) // already done ^ ]).then(results => { diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 8616f889f0..ce3990bb71 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -10,7 +10,7 @@ import { BadRequest, NotFound, } from '../../libs/api-v3/errors'; -import Q from 'q'; +import Bluebird from 'bluebird'; import * as passwordUtils from '../../libs/api-v3/password'; import logger from '../../libs/api-v3/logger'; import { model as User } from '../../models/user'; @@ -215,17 +215,15 @@ api.loginLocal = { }; function _passportFbProfile (accessToken) { - let deferred = Q.defer(); - - passport._strategies.facebook.userProfile(accessToken, (err, profile) => { - if (err) { - deferred.rejec(); - } else { - deferred.resolve(profile); - } + return new Bluebird((resolve, reject) => { + passport._strategies.facebook.userProfile(accessToken, (err, profile) => { + if (err) { + reject(err); + } else { + resolve(profile); + } + }); }); - - return deferred.promise; } // Called as a callback by Facebook (or other social providers). Internal route diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index bb21ced189..1e50a4d439 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -15,7 +15,7 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import * as Tasks from '../../models/task'; -import Q from 'q'; +import Bluebird from 'bluebird'; import csvStringify from '../../libs/api-v3/csvStringify'; let api = {}; @@ -87,7 +87,7 @@ api.createChallenge = { let challengeValidationErrors = challenge.validateSync(); if (challengeValidationErrors) throw challengeValidationErrors; - let results = await Q.all([challenge.save({ + let results = await Bluebird.all([challenge.save({ validateBeforeSave: false, // already validate }), group.save()]); let savedChal = results[0]; @@ -141,7 +141,7 @@ api.joinChallenge = { challenge.memberCount += 1; // Add all challenge's tasks to user's tasks and save the challenge - let results = await Q.all([challenge.syncToUser(user), challenge.save()]); + let results = await Bluebird.all([challenge.syncToUser(user), challenge.save()]); let response = results[1].toJSON(); response.group = { // we already have the group data @@ -190,7 +190,7 @@ api.leaveChallenge = { challenge.memberCount -= 1; // Unlink challenge's tasks from user's tasks and save the challenge - await Q.all([challenge.unlinkTasks(user, keep), challenge.save()]); + await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]); res.respond(200, {}); }, }; @@ -226,8 +226,8 @@ api.getUserChallenges = { let resChals = challenges.map(challenge => challenge.toJSON()); // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - await Q.all(resChals.map((chal, index) => { - return Q.all([ + await Bluebird.all(resChals.map((chal, index) => { + return Bluebird.all([ User.findById(chal.leader).select(nameFields).exec(), Group.findById(chal.group).select(basicGroupFields).exec(), ]).then(populatedData => { @@ -274,7 +274,7 @@ api.getGroupChallenges = { let resChals = challenges.map(challenge => challenge.toJSON()); // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - await Q.all(resChals.map((chal, index) => { + await Bluebird.all(resChals.map((chal, index) => { return User.findById(chal.leader).select(nameFields).exec().then(populatedLeader => { resChals[index].leader = populatedLeader ? populatedLeader.toJSON({minimize: true}) : null; }); @@ -358,7 +358,7 @@ api.exportChallengeCsv = { // In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all // results on the server so the perf difference isn't that big (hopefully) - let [members, tasks] = await Q.all([ + let [members, tasks] = await Bluebird.all([ User.find({challenges: challengeId}) .select(nameFields) .sort({_id: 1}) diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index d074cb4c7b..7be628f0df 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -12,7 +12,7 @@ import _ from 'lodash'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; -import Q from 'q'; +import Bluebird from 'bluebird'; const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; @@ -95,7 +95,7 @@ api.postChat = { toSave.push(user.save()); } - let [savedGroup] = await Q.all(toSave); + let [savedGroup] = await Bluebird.all(toSave); if (chatUpdated) { res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat}); } else { diff --git a/website/src/controllers/api-v3/content.js b/website/src/controllers/api-v3/content.js index b9d7495a97..7522a59096 100644 --- a/website/src/controllers/api-v3/content.js +++ b/website/src/controllers/api-v3/content.js @@ -1,17 +1,17 @@ import common from '../../../../common'; import _ from 'lodash'; import { langCodes } from '../../libs/api-v3/i18n'; -import Q from 'q'; +import Bluebird from 'bluebird'; import fsCallback from 'fs'; import path from 'path'; import logger from '../../libs/api-v3/logger'; // Transform fs methods that accept callbacks in ones that return promises const fs = { - readFile: Q.denodeify(fsCallback.readFile), - writeFile: Q.denodeify(fsCallback.writeFile), - stat: Q.denodeify(fsCallback.stat), - mkdir: Q.denodeify(fsCallback.mkdir), + readFile: Bluebird.promisify(fsCallback.readFile, {context: fsCallback}), + writeFile: Bluebird.promisify(fsCallback.writeFile, {context: fsCallback}), + stat: Bluebird.promisify(fsCallback.stat, {context: fsCallback}), + mkdir: Bluebird.promisify(fsCallback.mkdir, {context: fsCallback}), }; let api = {}; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 60d92e6ade..8a8e9eee05 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -1,5 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import Q from 'q'; +import Bluebird from 'bluebird'; import _ from 'lodash'; import { INVITES_LIMIT, @@ -55,7 +55,7 @@ api.createGroup = { user.party._id = group._id; } - let results = await Q.all([user.save(), group.save()]); + let results = await Bluebird.all([user.save(), group.save()]); let savedGroup = results[1]; // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 @@ -274,7 +274,7 @@ api.joinGroup = { } } - await Q.all(promises); + await Bluebird.all(promises); let response = Group.toJSONCleanChat(promises[0], user); response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); @@ -475,7 +475,7 @@ api.removeGroupMember = { let message = req.query.message; if (message) _sendMessageToRemoved(group, member, message); - await Q.all([ + await Bluebird.all([ member.save(), group.save(), ]); @@ -653,13 +653,13 @@ api.inviteToGroup = { if (uuids) { let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res)); - let uuidResults = await Q.all(uuidInvites); + let uuidResults = await Bluebird.all(uuidInvites); results.push(...uuidResults); } if (emails) { let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res)); - let emailResults = await Q.all(emailInvites); + let emailResults = await Bluebird.all(emailInvites); results.push(...emailResults); } diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index e8e6149ac4..e1bc0bf60a 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -15,7 +15,7 @@ import { getUserInfo, sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; -import Q from 'q'; +import Bluebird from 'bluebird'; import sendPushNotification from '../../libs/api-v3/pushNotifications'; let api = {}; @@ -330,7 +330,7 @@ api.transferGems = { receiver.balance += amount; sender.balance -= amount; let promises = [receiver.save(), sender.save()]; - await Q.all(promises); + await Bluebird.all(promises); let message = res.t('privateMessageGiftIntro', { receiverName: receiver.profile.name, diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 05b3020f86..0e639def36 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -1,5 +1,5 @@ import _ from 'lodash'; -import Q from 'q'; +import Bluebird from 'bluebird'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; import analytics from '../../libs/api-v3/analyticsService'; import { @@ -95,7 +95,7 @@ api.inviteToQuest = { await group.startQuest(user); } - let [savedGroup] = await Q.all([ + let [savedGroup] = await Bluebird.all([ group.save(), user.save(), ]); @@ -170,7 +170,7 @@ api.acceptQuest = { await group.startQuest(user); } - let [savedGroup] = await Q.all([ + let [savedGroup] = await Bluebird.all([ group.save(), user.save(), ]); @@ -229,7 +229,7 @@ api.rejectQuest = { await group.startQuest(user); } - let [savedGroup] = await Q.all([ + let [savedGroup] = await Bluebird.all([ group.save(), user.save(), ]); @@ -282,7 +282,7 @@ api.forceStart = { await group.startQuest(user); - let [savedGroup] = await Q.all([ + let [savedGroup] = await Bluebird.all([ group.save(), user.save(), ]); @@ -336,7 +336,7 @@ api.cancelQuest = { group.quest = Group.cleanGroupQuest(); group.markModified('quest'); - let [savedGroup] = await Q.all([ + let [savedGroup] = await Bluebird.all([ group.save(), User.update( {'party._id': groupId}, @@ -397,7 +397,7 @@ api.abortQuest = { group.quest = Group.cleanGroupQuest(); group.markModified('quest'); - let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]); + let [groupSaved] = await Bluebird.all([group.save(), memberUpdates, questLeaderUpdate]); res.respond(200, groupSaved.quest); }, @@ -440,7 +440,7 @@ api.leaveQuest = { user.party.quest = Group.cleanQuestProgress(); user.markModified('party.quest'); - let [savedGroup] = await Q.all([ + let [savedGroup] = await Bluebird.all([ group.save(), user.save(), ]); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 18aadca0d1..6ccf246d5d 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -10,7 +10,7 @@ import { BadRequest, } from '../../libs/api-v3/errors'; import common from '../../../../common'; -import Q from 'q'; +import Bluebird from 'bluebird'; import _ from 'lodash'; import logger from '../../libs/api-v3/logger'; @@ -48,7 +48,7 @@ async function _createTasks (req, res, user, challenge) { toSave.unshift((challenge || user).save()); - let tasks = await Q.all(toSave); + let tasks = await Bluebird.all(toSave); tasks.splice(0, 1); // Remove user or challenge return tasks; } @@ -393,7 +393,7 @@ api.scoreTask = { } } - let results = await Q.all([ + let results = await Bluebird.all([ user.save(), task.save(), ]); @@ -789,7 +789,7 @@ api.unlinkTask = { } else { // remove if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if removeFromArray(user.tasksOrder[`${task.type}s`], taskId); - await Q.all([user.save(), task.remove()]); + await Bluebird.all([user.save(), task.remove()]); } else { await task.remove(); } @@ -870,7 +870,7 @@ api.deleteTask = { if (task.type !== 'todo' || !task.completed) { removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId); - await Q.all([(challenge || user).save(), task.remove()]); + await Bluebird.all([(challenge || user).save(), task.remove()]); } else { await task.remove(); } diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index a1e34ad435..c45d8781d1 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -11,7 +11,7 @@ import { model as Group, } from '../../models/group'; import { model as User } from '../../models/user'; -import Q from 'q'; +import Bluebird from 'bluebird'; import _ from 'lodash'; import * as firebase from '../../libs/api-v3/firebase'; import * as passwordUtils from '../../libs/api-v3/password'; @@ -218,7 +218,7 @@ api.deleteUser = { return group.leave(user, 'remove-all'); }); - await Q.all(groupLeavePromises); + await Bluebird.all(groupLeavePromises); await Tasks.Task.remove({ userId: user._id, @@ -351,7 +351,7 @@ api.castSpell = { spell.cast(user, task, req); if (user.isModified()) { - await Q.all([ + await Bluebird.all([ user.save(), task.save(), ]); @@ -380,7 +380,7 @@ api.castSpell = { let isUserModified = user.isModified(); if (isUserModified) toSave.unshift(user.save()); - let saved = await Q.all(toSave); + let saved = await Bluebird.all(toSave); let response = { tasks: isUserModified ? _.rest(saved) : saved, @@ -400,7 +400,7 @@ api.castSpell = { } spell.cast(user, partyMembers, req); - await Q.all(partyMembers.map(m => m.save())); + await Bluebird.all(partyMembers.map(m => m.save())); } else { if (!party && (!targetId || user._id === targetId)) { partyMembers = user; @@ -413,7 +413,7 @@ api.castSpell = { if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); spell.cast(user, partyMembers, req); if (user.isModified()) { - await Q.all([ + await Bluebird.all([ user.save(), partyMembers.save(), ]); @@ -1105,7 +1105,7 @@ api.userRebirth = { await user.save(); - await Q.all(tasks.map(task => task.save())); + await Bluebird.all(tasks.map(task => task.save())); res.respond(200, ...rebirthRes); }, @@ -1201,7 +1201,7 @@ api.userReroll = { let promises = tasks.map(task => task.save()); promises.push(user.save()); - await Q.all(promises); + await Bluebird.all(promises); res.respond(200, ...rerollRes); }, @@ -1254,7 +1254,7 @@ api.userReset = { let resetRes = common.ops.reset(user, tasks); - await Q.all([Tasks.Task.remove({_id: {$in: resetRes[0].tasksToRemove}, userId: user._id}), user.save()]); + await Bluebird.all([Tasks.Task.remove({_id: {$in: resetRes[0].tasksToRemove}, userId: user._id}), user.save()]); res.respond(200, ...resetRes); }, diff --git a/website/src/controllers/top-level/dataexport.js b/website/src/controllers/top-level/dataexport.js index 5cbcd39fa7..10519807e3 100644 --- a/website/src/controllers/top-level/dataexport.js +++ b/website/src/controllers/top-level/dataexport.js @@ -12,7 +12,7 @@ import Pageres from 'pageres'; import AWS from 'aws-sdk'; import nconf from 'nconf'; import got from 'got'; -import Q from 'q'; +import Bluebird from 'bluebird'; import locals from '../../middlewares/api-v3/locals'; let S3 = new AWS.S3({ @@ -222,7 +222,16 @@ api.exportUserAvatarPng = { Body: stream, }); - let s3res = await Q.ninvoke(s3upload, 'send'); + let s3res = await new Bluebird((resolve, reject) => { + s3upload.send((err, s3uploadRes) => { + if (err) { + reject(err); + } else { + resolve(s3uploadRes); + } + }); + }); + res.redirect(s3res.Location); }, }; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index a940511c64..1c3e85dec3 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -8,7 +8,7 @@ import ipn from 'paypal-ipn'; import paypal from 'paypal-rest-sdk'; import shared from '../../../../../common'; import cc from 'coupon-code'; -import Q from 'q'; +import Bluebird from 'bluebird'; import { model as Coupon } from '../../../models/coupon'; import { model as User } from '../../../models/user'; import { @@ -36,14 +36,14 @@ paypal.configure({ }); // TODO better handling of errors -const paypalPaymentCreate = Q.nbind(paypal.payment.create, paypal.payment); -const paypalPaymentExecute = Q.nbind(paypal.payment.execute, paypal.payment); -const paypalBillingAgreementCreate = Q.nbind(paypal.billingAgreement.create, paypal.billingAgreement); -const paypalBillingAgreementExecute = Q.nbind(paypal.billingAgreement.execute, paypal.billingAgreement); -const paypalBillingAgreementGet = Q.nbind(paypal.billingAgreement.get, paypal.billingAgreement); -const paypalBillingAgreementCancel = Q.nbind(paypal.billingAgreement.cancel, paypal.billingAgreement); +const paypalPaymentCreate = Bluebird.promisify(paypal.payment.create, {context: paypal.payment}); +const paypalPaymentExecute = Bluebird.promisify(paypal.payment.execute, {context: paypal.payment}); +const paypalBillingAgreementCreate = Bluebird.promisify(paypal.billingAgreement.create, {context: paypal.billingAgreement}); +const paypalBillingAgreementExecute = Bluebird.promisify(paypal.billingAgreement.execute, {context: paypal.billingAgreement}); +const paypalBillingAgreementGet = Bluebird.promisify(paypal.billingAgreement.get, {context: paypal.billingAgreement}); +const paypalBillingAgreementCancel = Bluebird.promisify(paypal.billingAgreement.cancel, {context: paypal.billingAgreement}); -const ipnVerifyAsync = Q.nbind(ipn.verify, ipn); +const ipnVerifyAsync = Bluebird.promisify(ipn.verify, {context: ipn}); let api = {}; diff --git a/website/src/index.js b/website/src/index.js index 1b133ca781..6af0647d0b 100644 --- a/website/src/index.js +++ b/website/src/index.js @@ -10,6 +10,8 @@ if (process.env.NODE_ENV !== 'production') { // The BabelJS polyfill is needed in production too require('babel-polyfill'); +global.Promise = require('bluebird'); + // Only do the minimal amount of work before forking just in case of a dyno restart const cluster = require('cluster'); const nconf = require('nconf'); diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index 338a6acd08..4d3a3756b8 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -1,7 +1,7 @@ import amazonPayments from 'amazon-payments'; import nconf from 'nconf'; import common from '../../../../common'; -import Q from 'q'; +import Bluebird from 'bluebird'; import { BadRequest, } from './errors'; @@ -19,14 +19,14 @@ let amzPayment = amazonPayments.connect({ clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), }); -let getTokenInfo = Q.nbind(amzPayment.api.getTokenInfo, amzPayment.api); -let createOrderReferenceId = Q.nbind(amzPayment.offAmazonPayments.createOrderReferenceForId, amzPayment.offAmazonPayments); -let setOrderReferenceDetails = Q.nbind(amzPayment.offAmazonPayments.setOrderReferenceDetails, amzPayment.offAmazonPayments); -let confirmOrderReference = Q.nbind(amzPayment.offAmazonPayments.confirmOrderReference, amzPayment.offAmazonPayments); -let closeOrderReference = Q.nbind(amzPayment.offAmazonPayments.closeOrderReference, amzPayment.offAmazonPayments); -let setBillingAgreementDetails = Q.nbind(amzPayment.offAmazonPayments.setBillingAgreementDetails, amzPayment.offAmazonPayments); -let confirmBillingAgreement = Q.nbind(amzPayment.offAmazonPayments.confirmBillingAgreement, amzPayment.offAmazonPayments); -let closeBillingAgreement = Q.nbind(amzPayment.offAmazonPayments.closeBillingAgreement, amzPayment.offAmazonPayments); +let getTokenInfo = Bluebird.promisify(amzPayment.api.getTokenInfo, {context: amzPayment.api}); +let createOrderReferenceId = Bluebird.promisify(amzPayment.offAmazonPayments.createOrderReferenceForId, {context: amzPayment.offAmazonPayments}); +let setOrderReferenceDetails = Bluebird.promisify(amzPayment.offAmazonPayments.setOrderReferenceDetails, {context: amzPayment.offAmazonPayments}); +let confirmOrderReference = Bluebird.promisify(amzPayment.offAmazonPayments.confirmOrderReference, {context: amzPayment.offAmazonPayments}); +let closeOrderReference = Bluebird.promisify(amzPayment.offAmazonPayments.closeOrderReference, {context: amzPayment.offAmazonPayments}); +let setBillingAgreementDetails = Bluebird.promisify(amzPayment.offAmazonPayments.setBillingAgreementDetails, {context: amzPayment.offAmazonPayments}); +let confirmBillingAgreement = Bluebird.promisify(amzPayment.offAmazonPayments.confirmBillingAgreement, {context: amzPayment.offAmazonPayments}); +let closeBillingAgreement = Bluebird.promisify(amzPayment.offAmazonPayments.closeBillingAgreement, {context: amzPayment.offAmazonPayments}); let authorizeOnBillingAgreement = (inputSet) => { return new Promise((resolve, reject) => { diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js index 4979327b48..b810ac1858 100644 --- a/website/src/libs/api-v3/analyticsService.js +++ b/website/src/libs/api-v3/analyticsService.js @@ -1,7 +1,7 @@ /* eslint-disable camelcase */ import nconf from 'nconf'; import Amplitude from 'amplitude'; -import Q from 'q'; +import Bluebird from 'bluebird'; import googleAnalytics from 'universal-analytics'; import { each, @@ -109,7 +109,7 @@ let _sendDataToAmplitude = (eventType, data) => { amplitudeData.event_type = eventType; - return Q.promise((resolve, reject) => { + return new Bluebird((resolve, reject) => { amplitude.track(amplitudeData) .then(resolve) .catch(reject); @@ -160,7 +160,7 @@ let _sendDataToGoogle = (eventType, data) => { eventData.ev = value; } - return Q.promise((resolve, reject) => { + return new Bluebird((resolve, reject) => { ga.event(eventData, (err) => { if (err) return reject(err); resolve(); @@ -174,7 +174,7 @@ let _sendPurchaseDataToAmplitude = (data) => { amplitudeData.event_type = 'purchase'; amplitudeData.revenue = data.purchaseValue; - return Q.promise((resolve, reject) => { + return new Bluebird((resolve, reject) => { amplitude.track(amplitudeData) .then(resolve) .catch(reject); @@ -199,7 +199,7 @@ let _sendPurchaseDataToGoogle = (data) => { ev: price, }; - return Q.promise((resolve) => { + return new Bluebird((resolve) => { ga.event(eventData).send(); ga.transaction(data.uuid, price) @@ -211,14 +211,14 @@ let _sendPurchaseDataToGoogle = (data) => { }; function track (eventType, data) { - return Q.all([ + return Bluebird.all([ _sendDataToAmplitude(eventType, data), _sendDataToGoogle(eventType, data), ]); } function trackPurchase (data) { - return Q.all([ + return Bluebird.all([ _sendPurchaseDataToAmplitude(data), _sendPurchaseDataToGoogle(data), ]); diff --git a/website/src/libs/api-v3/csvStringify.js b/website/src/libs/api-v3/csvStringify.js index da87ca33f2..39fb7c16c8 100644 --- a/website/src/libs/api-v3/csvStringify.js +++ b/website/src/libs/api-v3/csvStringify.js @@ -1,8 +1,8 @@ import csvStringify from 'csv-stringify'; -import Q from 'q'; +import Bluebird from 'bluebird'; module.exports = (input) => { - return Q.promise((resolve, reject) => { + return new Bluebird((resolve, reject) => { csvStringify(input, (err, output) => { if (err) return reject(err); return resolve(output); diff --git a/website/src/libs/api-v3/setupMongoose.js b/website/src/libs/api-v3/setupMongoose.js index 303180679c..ae1659d527 100644 --- a/website/src/libs/api-v3/setupMongoose.js +++ b/website/src/libs/api-v3/setupMongoose.js @@ -2,12 +2,12 @@ import nconf from 'nconf'; import logger from './logger'; import autoinc from 'mongoose-id-autoinc'; import mongoose from 'mongoose'; -import Q from 'q'; +import Bluebird from 'bluebird'; const IS_PROD = nconf.get('IS_PROD'); // Use Q promises instead of mpromise in mongoose -mongoose.Promise = Q.Promise; +mongoose.Promise = Bluebird; let mongooseOptions = !IS_PROD ? {} : { replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 8473db23af..bca680b908 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -2,7 +2,7 @@ import _ from 'lodash'; import moment from 'moment'; import common from '../../../../common'; import * as Tasks from '../../models/task'; -import Q from 'q'; +import Bluebird from 'bluebird'; import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; import { cron } from '../../libs/api-v3/cron'; @@ -139,7 +139,7 @@ module.exports = function cronMiddleware (req, res, next) { toSave.push(task.save()); }); - Q.all(toSave) + Bluebird.all(toSave) .then(saved => { user = res.locals.user = saved[0]; if (!quest) return; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index ee0f7209a1..be61b0f00d 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -1,5 +1,5 @@ import mongoose from 'mongoose'; -import Q from 'q'; +import Bluebird from 'bluebird'; import validator from 'validator'; import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; @@ -105,7 +105,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) { }); } - let [challengeTasks, userTasks] = await Q.all([ + let [challengeTasks, userTasks] = await Bluebird.all([ // Find original challenge tasks Tasks.Task.find({ userId: {$exists: false}, @@ -149,7 +149,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) { }); toSave.push(user.save()); - return Q.all(toSave); + return Bluebird.all(toSave); }; async function _fetchMembersIds (challengeId) { @@ -189,7 +189,7 @@ schema.methods.addTasks = async function challengeAddTasks (tasks) { // Update the user toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec()); - await Q.all(toSave); // eslint-disable-line babel/no-await-in-loop + await Bluebird.all(toSave); // eslint-disable-line babel/no-await-in-loop } }; @@ -254,7 +254,7 @@ schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) { }); user.markModified('tasksOrder'); taskPromises.push(user.save()); - return Q.all(taskPromises); + return Bluebird.all(taskPromises); } }; @@ -314,7 +314,7 @@ schema.methods.closeChal = async function closeChal (broken = {}) { }, {multi: true}).exec(), ]; - Q.all(backgroundTasks); + Bluebird.all(backgroundTasks); }; // Methods to adapt the new schema to API v2 responses (mostly tasks inside the challenge model) @@ -406,7 +406,7 @@ schema.methods.getTransformedData = function getTransformedData (options) { let membersQuery = User.find(queryMembers).select(selectDataMembers); if (options.limitPopulation) membersQuery.limit(15); - Q.all([ + Bluebird.all([ membersQuery.exec(), self.getTasks(), ]) diff --git a/website/src/models/group.js b/website/src/models/group.js index 17398e4909..e98dfc1567 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -12,7 +12,7 @@ import { InternalServerError } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; -import Q from 'q'; +import Bluebird from 'bluebird'; import nconf from 'nconf'; import sendPushNotification from '../libs/api-v3/pushNotifications'; @@ -189,7 +189,7 @@ schema.statics.getGroups = async function getGroups (options = {}) { } }); - let groupsArray = _.reduce(await Q.all(queries), (previousValue, currentValue) => { + let groupsArray = _.reduce(await Bluebird.all(queries), (previousValue, currentValue) => { if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue }, []); @@ -228,7 +228,7 @@ schema.methods.removeGroupInvitations = async function removeGroupInvitations () return user.save(); }); - return Q.all(userUpdates); + return Bluebird.all(userUpdates); }; // Return true if user is a member of the group @@ -638,7 +638,7 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { let challengesToRemoveUserFrom = challenges.map(chal => { return chal.unlinkTasks(user, keep); }); - await Q.all(challengesToRemoveUserFrom); + await Bluebird.all(challengesToRemoveUserFrom); let promises = []; @@ -670,7 +670,7 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { firebase.removeUserFromGroup(group._id, user._id); - return Q.all(promises); + return Bluebird.all(promises); }; // API v2 compatibility methods @@ -714,7 +714,7 @@ schema.methods.getTransformedData = function getTransformedData (options) { let membersQuery = User.find(queryMembers).select(selectDataMembers); if (options.limitPopulation) membersQuery.limit(15); - Q.all([ + Bluebird.all([ membersQuery.exec(), User.find(queryInvites).select(populateInvites).exec(), Challenge.find({group: obj._id}).select(populateMembers).exec(), diff --git a/website/src/models/user.js b/website/src/models/user.js index 11d36e1e44..9b153c6156 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -4,7 +4,7 @@ import _ from 'lodash'; import validator from 'validator'; import moment from 'moment'; import * as Tasks from './task'; -import Q from 'q'; +import Bluebird from 'bluebird'; import { schema as TagSchema } from './tag'; import baseModel from '../libs/api-v3/baseModel'; import { @@ -593,7 +593,7 @@ function _populateDefaultTasks (user, taskTypes) { tasksToCreate.push(...tasksOfType); }); - return Q.all(tasksToCreate) + return Bluebird.all(tasksToCreate) .then((tasksCreated) => { _.each(tasksCreated, (task) => { user.tasksOrder[`${task.type}s`].push(task._id); @@ -720,7 +720,7 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, m sender.markModified('inbox.messages'); let promises = [userToReceiveMessage.save(), sender.save()]; - await Q.all(promises); + await Bluebird.all(promises); }; // Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model) diff --git a/website/src/server.js b/website/src/server.js index 21fa6e2f5c..f86e6c63b7 100644 --- a/website/src/server.js +++ b/website/src/server.js @@ -3,6 +3,9 @@ import logger from './libs/api-v3/logger'; import express from 'express'; import http from 'http'; import attachMiddlewares from './middlewares/api-v3/index'; +import Bluebird from 'bluebird'; + +global.Promise = Bluebird; const server = http.createServer(); const app = express(); From 84b198f17f25be4dc0f914c642f83773e88ce975 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 May 2016 14:55:51 +0200 Subject: [PATCH 776/976] v3 fix tests with deferred --- test/api/v3/unit/libs/email.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index e475e33753..d997751a3c 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -9,9 +9,10 @@ import logger from '../../../../../website/src/libs/api-v3/logger'; function defer () { let resolve; let reject; - let promise = new Bluebird(() => { - resolve = arguments[0]; - reject = arguments[1]; + + let promise = new Bluebird((resolveParam, rejectParam) => { + resolve = resolveParam; + reject = rejectParam; }); return { From 2e2aa55fc5cce81564c9b4f82a9c575c4426f818 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 11 May 2016 08:27:35 -0500 Subject: [PATCH 777/976] Removed extra consoles.log. Changed data.data to res.data --- website/public/js/controllers/authCtrl.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/website/public/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js index 58cc563158..13906294ab 100644 --- a/website/public/js/controllers/authCtrl.js +++ b/website/public/js/controllers/authCtrl.js @@ -26,7 +26,6 @@ angular.module('habitrpg') function errorAlert(data, status, headers, config) { $scope.registrationInProgress = false; - console.log(data) if (status === 0) { $window.alert(window.env.t('noReachServer')); } else if (!!data && !!data.error) { @@ -49,8 +48,8 @@ angular.module('habitrpg') var url = ApiUrl.get() + "/api/v3/user/auth/local/register"; if($rootScope.selectedLanguage) url = url + '?lang=' + $rootScope.selectedLanguage.code; - $http.post(url, scope.registerVals).success(function(data, status, headers, config) { - runAuth(data.data.id, data.data.apiToken); + $http.post(url, scope.registerVals).success(function(res, status, headers, config) { + runAuth(res.data.id, res.data.apiToken); }).error(errorAlert); }; @@ -61,8 +60,8 @@ angular.module('habitrpg') }; //@TODO: Move all the $http methods to a service $http.post(ApiUrl.get() + "/api/v3/user/auth/local/login", data) - .success(function(data, status, headers, config) { - runAuth(data.data.id, data.data.apiToken); + .success(function(res, status, headers, config) { + runAuth(res.data.id, res.data.apiToken); }).error(errorAlert); }; @@ -101,8 +100,8 @@ angular.module('habitrpg') $scope.socialLogin = function(network){ hello(network).login({scope:'email'}).then(function(auth){ $http.post(ApiUrl.get() + "/api/v3/user/auth/social", auth) - .success(function(data, status, headers, config) { - runAuth(data.data.id, data.data.apiToken); + .success(function(res, status, headers, config) { + runAuth(res.data.id, res.data.apiToken); }).error(errorAlert); }, function( e ){ alert("Signin error: " + e.message ); From 95aff08de3bef7463e28bce98626aa6a4ff48590 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 May 2016 15:59:18 +0200 Subject: [PATCH 778/976] v3 fix tests and use coroutines instead of regenerator --- .babelrc | 7 +- .eslintignore | 1 - migrations/api_v3/challenges.js | 2 +- migrations/api_v3/challengesMembers.js | 2 +- migrations/api_v3/coupons.js | 2 +- migrations/api_v3/emailUnsubscriptions.js | 2 +- migrations/api_v3/groups.js | 2 +- migrations/api_v3/users.js | 2 +- package.json | 3 +- .../v3/integration/user/POST-user_buy.test.js | 13 +- .../user/POST-user_buy_gear.test.js | 13 +- test/common/ops/buy.js | 12 +- test/common/ops/buyArmoire.js | 22 +- test/common/ops/buyGear.js | 12 +- test/common_old/algos.mocha.js | 1265 ----------------- test/common_old/dailies.js | 499 ------- test/common_old/preenTodos.test.js | 76 - test/common_old/shared.spells.test.js | 103 -- test/common_old/simulations/autoAllocate.js | 161 --- .../simulations/passive_active_attrs.js | 291 ---- test/common_old/user.fns.updateStats.test.js | 134 -- .../user.ops.hourglassPurchase.test.js | 122 -- test/common_old/user.ops.test.js | 34 - website/src/controllers/api-v3/groups.js | 2 +- 24 files changed, 80 insertions(+), 2702 deletions(-) delete mode 100644 test/common_old/algos.mocha.js delete mode 100644 test/common_old/dailies.js delete mode 100644 test/common_old/preenTodos.test.js delete mode 100644 test/common_old/shared.spells.test.js delete mode 100644 test/common_old/simulations/autoAllocate.js delete mode 100644 test/common_old/simulations/passive_active_attrs.js delete mode 100644 test/common_old/user.fns.updateStats.test.js delete mode 100644 test/common_old/user.ops.hourglassPurchase.test.js delete mode 100644 test/common_old/user.ops.test.js diff --git a/.babelrc b/.babelrc index abdb3b030b..988e0d6f03 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,9 @@ { "presets": ["es2015"], - "plugins": ["syntax-async-functions","transform-regenerator"] + "plugins": [ + ["transform-async-to-module-method", { + "module": "bluebird", + "method": "coroutine" + }] + ] } diff --git a/.eslintignore b/.eslintignore index b4830a328f..6aab3499ed 100644 --- a/.eslintignore +++ b/.eslintignore @@ -30,7 +30,6 @@ newrelic.js test/api-legacy/**/* test/common/simulations/**/* -test/common_old/ test/content/**/* test/server_side/**/* test/spec/**/* diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index b2ba22f730..a1b9b7d4fa 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -30,7 +30,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work +mongoose.Promise = Bluebird; // otherwise mongoose models won't work // Load new models var NewChallenge = require('../../website/src/models/challenge').model; diff --git a/migrations/api_v3/challengesMembers.js b/migrations/api_v3/challengesMembers.js index d42b4d1e69..7f7b2c99fd 100644 --- a/migrations/api_v3/challengesMembers.js +++ b/migrations/api_v3/challengesMembers.js @@ -30,7 +30,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work +mongoose.Promise = Bluebird; // otherwise mongoose models won't work // To be defined later when MongoClient connects var mongoDbOldInstance; diff --git a/migrations/api_v3/coupons.js b/migrations/api_v3/coupons.js index 9500416c8a..ce7058e3f0 100644 --- a/migrations/api_v3/coupons.js +++ b/migrations/api_v3/coupons.js @@ -29,7 +29,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work +mongoose.Promise = Bluebird; // otherwise mongoose models won't work // Load new models var Coupon = require('../../website/src/models/coupon').model; diff --git a/migrations/api_v3/emailUnsubscriptions.js b/migrations/api_v3/emailUnsubscriptions.js index 14c42623d6..099d23cd27 100644 --- a/migrations/api_v3/emailUnsubscriptions.js +++ b/migrations/api_v3/emailUnsubscriptions.js @@ -29,7 +29,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work +mongoose.Promise = Bluebird; // otherwise mongoose models won't work // Load new models var EmailUnsubscription = require('../../website/src/models/emailUnsubscription').model; diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js index 5a6365ebd6..9399024c53 100644 --- a/migrations/api_v3/groups.js +++ b/migrations/api_v3/groups.js @@ -37,7 +37,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work +mongoose.Promise = Bluebird; // otherwise mongoose models won't work // Load new models var NewGroup = require('../../website/src/models/group').model; diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index c67b4b5a14..cea6e58d2d 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -33,7 +33,7 @@ var MONGODB_NEW = nconf.get('MONGODB_NEW'); var taskDefaults = common.taskDefaults; var MongoClient = MongoDB.MongoClient; -mongoose.Promise = Bluebird.all; // otherwise mongoose models won't work +mongoose.Promise = Bluebird; // otherwise mongoose models won't work // Load new models var NewUser = require('../../website/src/models/user').model; diff --git a/package.json b/package.json index 787504f828..3b13ca318f 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "apidoc": "^0.16.0", "async": "^1.5.0", "aws-sdk": "^2.0.25", - "babel-plugin-syntax-async-functions": "^6.5.0", - "babel-plugin-transform-regenerator": "^6.6.0", + "babel-plugin-transform-async-to-module-method": "^6.8.0", "babel-polyfill": "^6.6.1", "babel-preset-es2015": "^6.6.0", "babel-register": "^6.6.0", diff --git a/test/api/v3/integration/user/POST-user_buy.test.js b/test/api/v3/integration/user/POST-user_buy.test.js index 2478adf7f8..ffad12f2a0 100644 --- a/test/api/v3/integration/user/POST-user_buy.test.js +++ b/test/api/v3/integration/user/POST-user_buy.test.js @@ -1,3 +1,5 @@ +/* eslint-disable camelcase */ + import { generateUser, translate as t, @@ -46,6 +48,15 @@ describe('POST /user/buy/:key', () => { await user.post(`/user/buy/${key}`); await user.sync(); - expect(user.items.gear.owned).to.eql({ armor_warrior_1: true }); // eslint-disable-line camelcase + expect(user.items.gear.owned).to.eql({ + armor_warrior_1: true, + eyewear_special_blackTopFrame: true, + eyewear_special_blueTopFrame: true, + eyewear_special_greenTopFrame: true, + eyewear_special_pinkTopFrame: true, + eyewear_special_redTopFrame: true, + eyewear_special_whiteTopFrame: true, + eyewear_special_yellowTopFrame: true, + }); }); }); diff --git a/test/api/v3/integration/user/POST-user_buy_gear.test.js b/test/api/v3/integration/user/POST-user_buy_gear.test.js index 5347b94da9..f577263d4a 100644 --- a/test/api/v3/integration/user/POST-user_buy_gear.test.js +++ b/test/api/v3/integration/user/POST-user_buy_gear.test.js @@ -1,3 +1,5 @@ +/* eslint-disable camelcase */ + import { generateUser, translate as t, @@ -29,6 +31,15 @@ describe('POST /user/buy-gear/:key', () => { await user.post(`/user/buy-gear/${key}`); await user.sync(); - expect(user.items.gear.owned).to.eql({ armor_warrior_1: true }); // eslint-disable-line camelcase + expect(user.items.gear.owned).to.eql({ + armor_warrior_1: true, + eyewear_special_blackTopFrame: true, + eyewear_special_blueTopFrame: true, + eyewear_special_greenTopFrame: true, + eyewear_special_pinkTopFrame: true, + eyewear_special_redTopFrame: true, + eyewear_special_whiteTopFrame: true, + eyewear_special_yellowTopFrame: true, + }); }); }); diff --git a/test/common/ops/buy.js b/test/common/ops/buy.js index abaf3c849d..9e05cc5225 100644 --- a/test/common/ops/buy.js +++ b/test/common/ops/buy.js @@ -46,6 +46,16 @@ describe('shared.ops.buy', () => { it('adds equipment to inventory', () => { user.stats.gp = 31; buy(user, {params: {key: 'armor_warrior_1'}}); - expect(user.items.gear.owned).to.eql({ weapon_warrior_0: true, armor_warrior_1: true }); + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: true, + armor_warrior_1: true, + eyewear_special_blackTopFrame: true, + eyewear_special_blueTopFrame: true, + eyewear_special_greenTopFrame: true, + eyewear_special_pinkTopFrame: true, + eyewear_special_redTopFrame: true, + eyewear_special_whiteTopFrame: true, + eyewear_special_yellowTopFrame: true, + }); }); }); diff --git a/test/common/ops/buyArmoire.js b/test/common/ops/buyArmoire.js index 2c71c9b428..cea6d7abde 100644 --- a/test/common/ops/buyArmoire.js +++ b/test/common/ops/buyArmoire.js @@ -69,7 +69,16 @@ describe('shared.ops.buyArmoire', () => { } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: true, + eyewear_special_blackTopFrame: true, + eyewear_special_blueTopFrame: true, + eyewear_special_greenTopFrame: true, + eyewear_special_pinkTopFrame: true, + eyewear_special_redTopFrame: true, + eyewear_special_whiteTopFrame: true, + eyewear_special_yellowTopFrame: true, + }); expect(user.items.food).to.be.empty; expect(user.stats.exp).to.eql(0); done(); @@ -85,7 +94,16 @@ describe('shared.ops.buyArmoire', () => { } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('cannotBuyItem')); - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: true, + eyewear_special_blackTopFrame: true, + eyewear_special_blueTopFrame: true, + eyewear_special_greenTopFrame: true, + eyewear_special_pinkTopFrame: true, + eyewear_special_redTopFrame: true, + eyewear_special_whiteTopFrame: true, + eyewear_special_yellowTopFrame: true, + }); expect(user.items.food).to.be.empty; expect(user.stats.exp).to.eql(0); done(); diff --git a/test/common/ops/buyGear.js b/test/common/ops/buyGear.js index fc26057ef8..4d1213e80d 100644 --- a/test/common/ops/buyGear.js +++ b/test/common/ops/buyGear.js @@ -44,7 +44,17 @@ describe('shared.ops.buyGear', () => { buyGear(user, {params: {key: 'armor_warrior_1'}}); - expect(user.items.gear.owned).to.eql({ weapon_warrior_0: true, armor_warrior_1: true }); + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: true, + armor_warrior_1: true, + eyewear_special_blackTopFrame: true, + eyewear_special_blueTopFrame: true, + eyewear_special_greenTopFrame: true, + eyewear_special_pinkTopFrame: true, + eyewear_special_redTopFrame: true, + eyewear_special_whiteTopFrame: true, + eyewear_special_yellowTopFrame: true, + }); }); it('deducts gold from user', () => { diff --git a/test/common_old/algos.mocha.js b/test/common_old/algos.mocha.js deleted file mode 100644 index dca96880d0..0000000000 --- a/test/common_old/algos.mocha.js +++ /dev/null @@ -1,1265 +0,0 @@ -/* eslint-disable camelcase, func-names, no-shadow */ - -import { - generateUser, - generateDaily, - generateHabit, - generateTodo, -} from '../helpers/common.helper'; - -import { - DAY_MAPPING, - startOfWeek, - startOfDay, - daysSince, -} from '../../common/script/cron'; -import scoreTask from '../../common/script/api-v3/scoreTask'; - -let expect = require('expect.js'); -let sinon = require('sinon'); -let moment = require('moment'); -let test_helper = require('./test_helper'); -let shared = require('../../common/script/index'); -let $w = (s) => { - return s.split(' '); -}; - -shared.i18n.translations = require('../../website/src/libs/api-v2/i18n').translations; -test_helper.addCustomMatchers(); - -/* Helper Functions */ -let rewrapUser = (user) => { - user._wrapped = false; - shared.wrap(user); - return user; -}; - -let beforeAfter = (options = {}) => { - let lastCron; - let user = generateUser(); - let daily = generateDaily(); - let habit = generateHabit(); - let todo = generateTodo(); - - user.dailys.push(daily); - user.habits.push(habit); - user.todos.push(todo); - - let ref = [user, _.cloneDeep(user)]; - let before = ref[0]; - let after = ref[1]; - - rewrapUser(after); - if (options.dayStart) { - before.preferences.dayStart = after.preferences.dayStart = options.dayStart; - } - before.preferences.timezoneOffset = after.preferences.timezoneOffset = options.timezoneOffset || moment().zone(); - before.preferences.timezoneOffsetAtLastCron = after.preferences.timezoneOffsetAtLastCron = before.preferences.timezoneOffset; - if (options.limitOne) { - before[`${options.limitOne}s`] = [before[`${options.limitOne}s`][0]]; - after[`${options.limitOne}s`] = [after[`${options.limitOne}s`][0]]; - } - if (options.daysAgo) { - lastCron = moment(options.now || Number(new Date())).subtract({ - days: options.daysAgo, - }); - } - if (options.daysAgo && options.cronAfterStart) { - lastCron.add({ - hours: options.dayStart, - minutes: 1, - }); - } - if (options.daysAgo) { - lastCron = Number(lastCron); - } - _.each([before, after], (obj) => { - if (options.daysAgo) { - obj.lastCron = lastCron; - } - }); - return { - before, - after, - }; -}; - -let expectLostPoints = (before, after, taskType) => { - if (taskType === 'daily' || taskType === 'habit') { - expect(after.stats.hp).to.be.lessThan(before.stats.hp); - expect(after[`${taskType}s`][0].history).to.have.length(1); - } else { - expect(after.history.todos).to.have.length(1); - } - expect(after).toHaveExp(0); - expect(after).toHaveGP(0); - expect(after[`${taskType}s`][0].value).to.be.lessThan(before[`${taskType}s`][0].value); -}; - -let expectGainedPoints = (before, after, taskType) => { - expect(after.stats.hp).to.be(50); - expect(after.stats.exp).to.be.greaterThan(before.stats.exp); - expect(after.stats.gp).to.be.greaterThan(before.stats.gp); - expect(after[`${taskType}s`][0].value).to.be.greaterThan(before[`${taskType}s`][0].value); - if (taskType === 'habit') { - expect(after[`${taskType}s`][0].history).to.have.length(1); - } -}; - -let expectNoChange = (before, after) => { - _.each($w('stats items gear dailys todos rewards preferences'), (attr) => { - expect(after[attr]).to.eql(before[attr]); - }); -}; - -let expectClosePoints = (before, after, taskType) => { - expect(Math.abs(after.stats.exp - before.stats.exp)).to.be.lessThan(0.0001); - expect(Math.abs(after.stats.gp - before.stats.gp)).to.be.lessThan(0.0001); - expect(Math.abs(after[taskType + 's'][0].value - before[taskType + 's'][0].value)).to.be.lessThan(0.0001); // eslint-disable-line prefer-template -}; - -let expectDayResetNoDamage = (b, a) => { - let ref = [_.cloneDeep(b), _.cloneDeep(a)]; - let before = ref[0]; - let after = ref[1]; - - _.each(after.dailys, (task, i) => { - expect(task.completed).to.be(false); - expect(before.dailys[i].value).to.be(task.value); - expect(before.dailys[i].streak).to.be(task.streak); - expect(task.history).to.have.length(1); - }); - _.each(after.todos, (task, i) => { - expect(task.completed).to.be(false); - expect(before.todos[i].value).to.be.greaterThan(task.value); - }); - expect(after.history.todos).to.have.length(1); - _.each([before, after], (obj) => { - delete obj.stats.buffs; - _.each($w('dailys todos history lastCron'), (path) => { - return delete obj[path]; - }); - }); - delete after._tmp; - expectNoChange(before, after); -}; - -let repeatWithoutLastWeekday = () => { - let repeat = { - su: true, - m: true, - t: true, - w: true, - th: true, - f: true, - s: true, - }; - - if (startOfWeek(moment().zone(0)).isoWeekday() === 1) { - repeat.su = false; - } else { - repeat.s = false; - } - return { - repeat, - }; -}; - -describe('User', () => { - it('calculates max MP', () => { - let user = generateUser(); - - expect(user).toHaveMaxMP(30); - user.stats.int = 10; - expect(user).toHaveMaxMP(50); - user.stats.lvl = 5; - expect(user).toHaveMaxMP(54); - user.stats.class = 'wizard'; - user.items.gear.equipped.weapon = 'weapon_wizard_1'; - expect(user).toHaveMaxMP(63); - }); - - it('handles perfect days', () => { - let user = generateUser(); - - user.dailys = []; - _.times(3, () => { - return user.dailys.push(shared.taskDefaults({ - type: 'daily', - startDate: moment().subtract(7, 'days'), - })); - }); - let cron = () => { - user.lastCron = moment().subtract(1, 'days'); - return user.fns.cron(); - }; - - cron(); - expect(user.stats.buffs.str).to.be(0); - expect(user.achievements.perfect).to.not.be.ok(); - user.dailys[0].completed = true; - cron(); - expect(user.stats.buffs.str).to.be(0); - expect(user.achievements.perfect).to.not.be.ok(); - _.each(user.dailys, (d) => { - d.completed = true; - }); - cron(); - expect(user.stats.buffs.str).to.be(1); - expect(user.achievements.perfect).to.be(1); - - let yesterday = moment().subtract(1, 'days'); - - user.dailys[0].repeat[DAY_MAPPING[yesterday.day()]] = false; - _.each(user.dailys.slice(1), (d) => { - d.completed = true; - }); - cron(); - expect(user.stats.buffs.str).to.be(1); - expect(user.achievements.perfect).to.be(2); - }); - - describe('Resting in the Inn', () => { - let user = null; - let cron = null; - - beforeEach(() => { - user = generateUser(); - user.preferences.sleep = true; - cron = () => { - user.lastCron = moment().subtract(1, 'days'); - return user.fns.cron(); - }; - user.dailys = []; - _.times(2, () => { - return user.dailys.push(shared.taskDefaults({ - type: 'daily', - startDate: moment().subtract(7, 'days'), - })); - }); - }); - - it('remains in the inn on cron', () => { - cron(); - expect(user.preferences.sleep).to.be(true); - }); - - it('resets dailies', () => { - user.dailys[0].completed = true; - cron(); - expect(user.dailys[0].completed).to.be(false); - }); - - it('resets checklist on incomplete dailies', () => { - user.dailys[0].checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, { - text: '2', - id: 'checklist-two', - completed: true, - }, { - text: '3', - id: 'checklist-three', - completed: false, - }, - ]; - cron(); - _.each(user.dailys[0].checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - - it('resets checklist on complete dailies', () => { - user.dailys[0].checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, { - text: '2', - id: 'checklist-two', - completed: true, - }, { - text: '3', - id: 'checklist-three', - completed: false, - }, - ]; - user.dailys[0].completed = true; - cron(); - _.each(user.dailys[0].checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - - it('does not reset checklist on grey incomplete dailies', () => { - let yesterday = moment().subtract(1, 'days'); - - user.dailys[0].repeat[DAY_MAPPING[yesterday.day()]] = false; - user.dailys[0].checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, { - text: '2', - id: 'checklist-two', - completed: true, - }, { - text: '3', - id: 'checklist-three', - completed: true, - }, - ]; - cron(); - _.each(user.dailys[0].checklist, (box) => { - expect(box.completed).to.be(true); - }); - }); - - it('resets checklist on complete grey complete dailies', () => { - let yesterday = moment().subtract(1, 'days'); - - user.dailys[0].repeat[DAY_MAPPING[yesterday.day()]] = false; - user.dailys[0].checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, { - text: '2', - id: 'checklist-two', - completed: true, - }, { - text: '3', - id: 'checklist-three', - completed: true, - }, - ]; - user.dailys[0].completed = true; - cron(); - _.each(user.dailys[0].checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - - it('does not damage user for incomplete dailies', () => { - expect(user).toHaveHP(50); - user.dailys[0].completed = true; - user.dailys[1].completed = false; - cron(); - expect(user).toHaveHP(50); - }); - - it('gives credit for complete dailies', () => { - user.dailys[0].completed = true; - expect(user.dailys[0].history).to.be.empty; - cron(); - expect(user.dailys[0].history).to.not.be.empty; - }); - - it('damages user for incomplete dailies after checkout', () => { - expect(user).toHaveHP(50); - user.dailys[0].completed = true; - user.dailys[1].completed = false; - user.preferences.sleep = false; - cron(); - expect(user.stats.hp).to.be.lessThan(50); - }); - }); - - describe('Death', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('revives correctly', () => { - user.stats = { - gp: 10, - exp: 100, - lvl: 2, - hp: 0, - class: 'warrior', - }; - user.items.gear.owned.weapon_warrior_0 = true; - user.ops.revive(); - - expect(user).toHaveGP(0); - expect(user).toHaveExp(0); - expect(user).toHaveLevel(1); - expect(user).toHaveHP(50); - expect(user.items.gear.owned).to.eql({ - weapon_warrior_0: false, - eyewear_special_blackTopFrame: true, - eyewear_special_blueTopFrame: true, - eyewear_special_greenTopFrame: true, - eyewear_special_pinkTopFrame: true, - eyewear_special_redTopFrame: true, - eyewear_special_yellowTopFrame: true, - eyewear_special_whiteTopFrame: true, - }); - }); - - it('doesn\'t break unbreakables', () => { - let ce = shared.countExists; - - user.items.gear.owned = { - weapon_warrior_0: true, - shield_warrior_1: true, - shield_rogue_1: true, - head_special_nye: true, - }; - - expect(ce(user.items.gear.owned)).to.be(4); - - user.stats.hp = 0; - user.ops.revive(); - - expect(ce(user.items.gear.owned)).to.be(3); - - user.stats.hp = 0; - user.ops.revive(); - - expect(ce(user.items.gear.owned)).to.be(2); - - user.stats.hp = 0; - user.ops.revive(); - - expect(ce(user.items.gear.owned)).to.be(2); - expect(user.items.gear.owned).to.eql({ - weapon_warrior_0: false, - shield_warrior_1: false, - shield_rogue_1: true, - head_special_nye: true, - }); - }); - - it('handles event items', () => { - user.items.gear.owned.head_special_nye = true; - - shared.content.gear.flat.head_special_nye.event.start = '2012-01-01'; - shared.content.gear.flat.head_special_nye.event.end = '2012-02-01'; - expect(shared.content.gear.flat.head_special_nye.canOwn(user)).to.be(true); - delete user.items.gear.owned.head_special_nye; - expect(shared.content.gear.flat.head_special_nye.canOwn(user)).to.be(false); - shared.content.gear.flat.head_special_nye.event.start = moment().subtract(5, 'days'); - shared.content.gear.flat.head_special_nye.event.end = moment().add(5, 'days'); - expect(shared.content.gear.flat.head_special_nye.canOwn(user)).to.be(true); - }); - }); - - describe('Rebirth', () => { - it('removes correct gear', () => { - let user = generateUser(); - - user.stats.lvl = 100; - user.items.gear.owned = { - weapon_warrior_0: true, - weapon_warrior_1: true, - armor_warrior_1: false, - armor_mystery_201402: true, - back_mystery_201402: false, - head_mystery_201402: true, - weapon_armoire_basicCrossbow: true, - }; - user.ops.rebirth(); - expect(user.items.gear.owned).to.eql({ - weapon_warrior_0: true, - weapon_warrior_1: false, - armor_warrior_1: false, - armor_mystery_201402: true, - back_mystery_201402: false, - head_mystery_201402: true, - weapon_armoire_basicCrossbow: false, - }); - }); - }); - - describe('Gem purchases', () => { - it('does not purchase items without enough Gems', () => { - let user = generateUser(); - - user.items.eggs = {}; - user.items.gear.owned = {}; - - user.ops.purchase({ - params: { - type: 'eggs', - key: 'Cactus', - }, - }); - user.ops.purchase({ - params: { - type: 'gear', - key: 'headAccessory_special_foxEars', - }, - }); - user.ops.unlock({ - query: { - path: 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars', - }, - }); - expect(user.items.eggs).to.eql({}); - expect(user.items.gear.owned).to.eql({}); - }); - - it('purchases an egg', () => { - let user = generateUser(); - - user.balance = 1; - user.ops.purchase({ - params: { - type: 'eggs', - key: 'Cactus', - }, - }); - expect(user.items.eggs).to.eql({ - Cactus: 1, - }); - expect(user.balance).to.eql(0.25); - }); - - it('purchases fox ears', () => { - let user = generateUser(); - - user.balance = 1; - user.ops.purchase({ - params: { - type: 'gear', - key: 'headAccessory_special_foxEars', - }, - }); - - expect(user.items.gear.owned.headAccessory_special_foxEars).to.eql(true); - expect(user.balance).to.eql(0.5); - }); - - it('unlocks all the animal ears at once', () => { - let user = generateUser(); - - user.balance = 2; - user.ops.unlock({ - query: { - path: 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars', - }, - }); - - expect(user.items.gear.owned.headAccessory_special_bearEars).to.eql(true); - expect(user.items.gear.owned.headAccessory_special_cactusEars).to.eql(true); - expect(user.items.gear.owned.headAccessory_special_foxEars).to.eql(true); - expect(user.items.gear.owned.headAccessory_special_lionEars).to.eql(true); - expect(user.items.gear.owned.headAccessory_special_pandaEars).to.eql(true); - expect(user.items.gear.owned.headAccessory_special_pigEars).to.eql(true); - expect(user.items.gear.owned.headAccessory_special_tigerEars).to.eql(true); - expect(user.items.gear.owned.headAccessory_special_wolfEars).to.eql(true); - expect(user.balance).to.eql(0.75); - }); - }); - - describe('spells', () => { - _.each(shared.content.spells, (spellClass) => { - _.each(spellClass, (spell) => { - it(`${spell.text} has valid values`, () => { - expect(spell.target).to.match(/^(task|self|party|user)$/); - expect(spell.mana).to.be.an('number'); - if (spell.lvl) { - expect(spell.lvl).to.be.an('number'); - expect(spell.lvl).to.be.above(0); - } - expect(spell.cast).to.be.a('function'); - }); - }); - }); - }); - - describe('drop system', () => { - let user = null; - const MIN_RANGE_FOR_POTION = 0; - const MAX_RANGE_FOR_POTION = 0.3; - const MIN_RANGE_FOR_EGG = 0.4; - const MAX_RANGE_FOR_EGG = 0.6; - const MIN_RANGE_FOR_FOOD = 0.7; - const MAX_RANGE_FOR_FOOD = 1; - - beforeEach(function () { - user = generateUser(); - user.flags.dropsEnabled = true; - this.task_id = shared.uuid(); - return user.ops.addTask({ - body: { - type: 'daily', - id: this.task_id, - }, - }); - }); - - it('drops a hatching potion', function () { - let results = []; - - for (let random = MIN_RANGE_FOR_POTION; random <= MAX_RANGE_FOR_POTION; random += 0.1) { - sinon.stub(user.fns, 'predictableRandom').returns(random); - - let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); - user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); - expect(user.items.eggs).to.be.empty; - expect(user.items.hatchingPotions).to.not.be.empty; - expect(user.items.food).to.be.empty; - results.push(user.fns.predictableRandom.restore()); - } - return results; - }); - - it('drops a pet egg', function () { - let results = []; - - for (let random = MIN_RANGE_FOR_EGG; random <= MAX_RANGE_FOR_EGG; random += 0.1) { - sinon.stub(user.fns, 'predictableRandom').returns(random); - let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); - user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); - expect(user.items.eggs).to.not.be.empty; - expect(user.items.hatchingPotions).to.be.empty; - expect(user.items.food).to.be.empty; - results.push(user.fns.predictableRandom.restore()); - } - return results; - }); - - it('drops food', function () { - let results = []; - - for (let random = MIN_RANGE_FOR_FOOD; random <= MAX_RANGE_FOR_FOOD; random += 0.1) { - sinon.stub(user.fns, 'predictableRandom').returns(random); - let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); - user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); - expect(user.items.eggs).to.be.empty; - expect(user.items.hatchingPotions).to.be.empty; - expect(user.items.food).to.not.be.empty; - results.push(user.fns.predictableRandom.restore()); - } - return results; - }); - - it('does not get a drop', function () { - sinon.stub(user.fns, 'predictableRandom').returns(0.5); - let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); - user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); - expect(user.items.eggs).to.eql({}); - expect(user.items.hatchingPotions).to.eql({}); - expect(user.items.food).to.eql({}); - - user.fns.predictableRandom.restore(); - }); - }); - - describe('Quests', () => { - _.each(shared.content.quests, (quest) => { - it(`${ quest.text() } has valid values`, () => { - expect(quest.notes()).to.be.an('string'); - if (quest.completion) { - expect(quest.completion()).to.be.an('string'); - } - if (quest.previous) { - expect(quest.previous).to.be.an('string'); - } - if (quest.canBuy()) { - expect(quest.value).to.be.greaterThan(0); - } - expect(quest.drop.gp).to.not.be.lessThan(0); - expect(quest.drop.exp).to.not.be.lessThan(0); - expect(quest.category).to.match(/pet|unlockable|gold|world/); - if (quest.drop.items) { - expect(quest.drop.items).to.be.an(Array); - } - if (quest.boss) { - expect(quest.boss.name()).to.be.an('string'); - expect(quest.boss.hp).to.be.greaterThan(0); - expect(quest.boss.str).to.be.greaterThan(0); - } else if (quest.collect) { - _.each(quest.collect, (collect) => { - expect(collect.text()).to.be.an('string'); - expect(collect.count).to.be.greaterThan(0); - }); - } - }); - }); - }); - - describe('Achievements', () => { - _.each(shared.content.classes, (klass) => { - let user = generateUser(); - - user.achievements.ultimateGearSets = {}; - - user.stats.gp = 10000; - _.each(shared.content.gearTypes, (type) => { - _.each([1, 2, 3, 4, 5], (i) => { - return user.ops.buy({ - params: `${type}_${klass}_${i}`, - }); - }); - }); - - it(`does not get ultimateGear ${klass}`, () => { - expect(user.achievements.ultimateGearSets[klass]).to.not.be.ok(); - }); - _.each(shared.content.gearTypes, (type) => { - return user.ops.buy({ - params: `${type}_${klass}_6`, - }); - }); - - xit(`gets ultimateGear ${klass}`, () => { - expect(user.achievements.ultimateGearSets[klass]).to.be.ok(); - }); - }); - - it('does not remove existing Ultimate Gear achievements', () => { - let user = generateUser(); - - user.achievements.ultimateGearSets = { - healer: true, - wizard: true, - rogue: true, - warrior: true, - }; - user.items.gear.owned.shield_warrior_5 = false; - user.items.gear.owned.weapon_rogue_6 = false; - user.ops.buy({ - params: 'shield_warrior_5', - }); - expect(user.achievements.ultimateGearSets).to.eql({ - healer: true, - wizard: true, - rogue: true, - warrior: true, - }); - }); - }); - - describe('unlocking features', () => { - it('unlocks drops at level 3', () => { - let user = generateUser(); - - user.stats.lvl = 3; - user.fns.updateStats(user.stats); - expect(user.flags.dropsEnabled).to.be.ok(); - }); - - it('unlocks Rebirth at level 50', () => { - let user = generateUser(); - - user.stats.lvl = 50; - user.fns.updateStats(user.stats); - expect(user.flags.rebirthEnabled).to.be.ok(); - }); - - describe('level-awarded Quests', () => { - it('gets Attack of the Mundane at level 15', () => { - let user = generateUser(); - - user.stats.lvl = 15; - user.fns.updateStats(user.stats); - expect(user.flags.levelDrops.atom1).to.be.ok(); - expect(user.items.quests.atom1).to.eql(1); - }); - - it('gets Vice at level 30', () => { - let user = generateUser(); - - user.stats.lvl = 30; - user.fns.updateStats(user.stats); - expect(user.flags.levelDrops.vice1).to.be.ok(); - expect(user.items.quests.vice1).to.eql(1); - }); - - it('gets Golden Knight at level 40', () => { - let user = generateUser(); - - user.stats.lvl = 40; - user.fns.updateStats(user.stats); - expect(user.flags.levelDrops.goldenknight1).to.be.ok(); - expect(user.items.quests.goldenknight1).to.eql(1); - }); - - it('gets Moonstone Chain at level 60', () => { - let user = generateUser(); - - user.stats.lvl = 60; - user.fns.updateStats(user.stats); - expect(user.flags.levelDrops.moonstone1).to.be.ok(); - expect(user.items.quests.moonstone1).to.eql(1); - }); - }); - }); -}); - -describe('Simple Scoring', () => { - beforeEach(function () { - let ref = beforeAfter(); - - this.before = ref.before; - this.after = ref.after; - }); - - it('Habits : Up', function () { - let delta = scoreTask({task: this.after.habits[0], user: this.after, direction: 'down', times: 5}); - this.after.fns.randomDrop({task: this.after.habits[0], delta}, {}); - expectLostPoints(this.before, this.after, 'habit'); - }); - - it('Habits : Down', function () { - let delta = scoreTask({task: this.after.habits[0], user: this.after, direction: 'up', times: 5}); - this.after.fns.randomDrop({task: this.after.habits[0], delta}, {}); - expectGainedPoints(this.before, this.after, 'habit'); - }); - - it('Dailys : Up', function () { - let delta = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'up'}); - this.after.fns.randomDrop({task: this.after.dailys[0], delta}, {}); - expectGainedPoints(this.before, this.after, 'daily'); - }); - - it('Dailys : Up, Down', function () { - let delta = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'up'}); - this.after.fns.randomDrop({task: this.after.dailys[0], delta}, {}); - let delta2 = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'down'}); - this.after.fns.randomDrop({task: this.after.dailys[0], delta2}, {}); - expectClosePoints(this.before, this.after, 'daily'); - }); - - it('Todos : Up', function () { - let delta = scoreTask({task: this.after.todos[0], user: this.after, direction: 'up'}); - this.after.fns.randomDrop({task: this.after.todos[0], delta}, {}); - expectGainedPoints(this.before, this.after, 'todo'); - }); - - it('Todos : Up, Down', function () { - let delta = scoreTask({task: this.after.todos[0], user: this.after, direction: 'up'}); - this.after.fns.randomDrop({task: this.after.todos[0], delta}, {}); - let delta2 = scoreTask({task: this.after.todos[0], user: this.after, direction: 'down'}); - this.after.fns.randomDrop({task: this.after.todos[0], delta2}, {}); - expectClosePoints(this.before, this.after, 'todo'); - }); -}); - -describe('Cron', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('computes shouldCron', () => { - let paths = {}; - - user.fns.cron({ - paths, - }); - expect(user.lastCron).to.not.be.ok; - user.lastCron = Number(moment().subtract(1, 'days')); - paths = {}; - user.fns.cron({ - paths, - }); - expect(user.lastCron).to.be.greaterThan(0); - }); - - it('only dailies & todos are affected', () => { - let ref = beforeAfter({ - daysAgo: 1, - }); - let before = ref.before; - let after = ref.after; - - before.dailys = before.todos = after.dailys = after.todos = []; - after.fns.cron(); - before.stats.mp = after.stats.mp; - expect(after.lastCron).to.not.be(before.lastCron); - delete after.stats.buffs; - delete before.stats.buffs; - expect(before.stats).to.eql(after.stats); - - let beforeTasks = before.habits.concat(before.dailys).concat(before.todos).concat(before.rewards); - let afterTasks = after.habits.concat(after.dailys).concat(after.todos).concat(after.rewards); - - expect(beforeTasks).to.eql(afterTasks); - }); - - describe('Todos', () => { - it('1 day missed', () => { - let ref = beforeAfter({ - daysAgo: 1, - }); - let before = ref.before; - let after = ref.after; - - before.dailys = after.dailys = []; - after.fns.cron(); - expect(after).toHaveHP(50); - expect(after).toHaveExp(0); - expect(after).toHaveGP(0); - expect(before.todos[0].value).to.be(0); - expect(after.todos[0].value).to.be(-1); - expect(after.history.todos).to.have.length(1); - }); - - it('2 days missed', () => { - let ref = beforeAfter({ - daysAgo: 2, - }); - let before = ref.before; - let after = ref.after; - - before.dailys = after.dailys = []; - after.fns.cron(); - expect(before.todos[0].value).to.be(0); - expect(after.todos[0].value).to.be(-1); - }); - }); - - describe('cron day calculations', () => { - let dayStart = 4; - let fstr = 'YYYY-MM-DD HH: mm: ss'; - - it('startOfDay before dayStart', () => { - let start = startOfDay({ - now: moment('2014-10-09 02: 30: 00'), - dayStart, - }); - - expect(start.format(fstr)).to.eql('2014-10-08 04: 00: 00'); - }); - - it('startOfDay after dayStart', () => { - let start = startOfDay({ - now: moment('2014-10-09 05: 30: 00'), - dayStart, - }); - - expect(start.format(fstr)).to.eql('2014-10-09 04: 00: 00'); - }); - - it('daysSince cron before, now after', () => { - let lastCron = moment('2014-10-09 02: 30: 00'); - let days = daysSince(lastCron, { - now: moment('2014-10-09 11: 30: 00'), - dayStart, - }); - - expect(days).to.eql(1); - }); - - it('daysSince cron before, now before', () => { - let lastCron = moment('2014-10-09 02: 30: 00'); - let days = daysSince(lastCron, { - now: moment('2014-10-09 03: 30: 00'), - dayStart, - }); - - expect(days).to.eql(0); - }); - - it('daysSince cron after, now after', () => { - let lastCron = moment('2014-10-09 05: 30: 00'); - let days = daysSince(lastCron, { - now: moment('2014-10-09 06: 30: 00'), - dayStart, - }); - - expect(days).to.eql(0); - }); - - it('daysSince cron after, now tomorrow before', () => { - let lastCron = moment('2014-10-09 12: 30: 00'); - let days = daysSince(lastCron, { - now: moment('2014-10-10 01: 30: 00'), - dayStart, - }); - - expect(days).to.eql(0); - }); - - it('daysSince cron after, now tomorrow after', () => { - let lastCron = moment('2014-10-09 12: 30: 00'); - let days = daysSince(lastCron, { - now: moment('2014-10-10 10: 30: 00'), - dayStart, - }); - - expect(days).to.eql(1); - }); - xit('daysSince, last cron before new dayStart', () => { - let lastCron = moment('2014-10-09 01: 00: 00'); - let days = daysSince(lastCron, { - now: moment('2014-10-09 05: 00: 00'), - dayStart, - }); - - expect(days).to.eql(0); - }); - }); - - describe('dailies', () => { - describe('new day', () => { - /* - This section runs through a 'cron matrix' of all permutations (that I can easily account for). It sets - task due days, user custom day start, timezoneOffset, etc - then runs cron, jumps to tomorrow and runs cron, - and so on - testing each possible outcome along the way - */ - - function runCron (options) { - _.each([480, 240, 0, -120], function (timezoneOffset) { - let now = startOfWeek({ - timezoneOffset, - }).add(options.currentHour || 0, 'hours'); - - let ref = beforeAfter({ - now, - timezoneOffset, - daysAgo: 1, - cronAfterStart: options.cronAfterStart || true, - dayStart: options.dayStart || 0, - limitOne: 'daily', - }); - - let before = ref.before; - let after = ref.after; - - if (options.repeat) { - before.dailys[0].repeat = after.dailys[0].repeat = options.repeat; - } - before.dailys[0].streak = after.dailys[0].streak = 10; - if (options.checked) { - before.dailys[0].completed = after.dailys[0].completed = true; - } - before.dailys[0].startDate = after.dailys[0].startDate = moment().subtract(30, 'days'); - if (options.shouldDo) { - expect(shared.shouldDo(now.toDate(), after.dailys[0], { - timezoneOffset, - dayStart: options.dayStart, - now, - })).to.be.ok(); - } - after.fns.cron({ - now, - }); - before.stats.mp = after.stats.mp; - - if (options.expect === 'losePoints') { - expectLostPoints(before, after, 'daily'); - } else if (options.expect === 'noChange') { - expectNoChange(before, after); - } else if (options.expect === 'noDamage') { - expectDayResetNoDamage(before, after); - } - - return { - before, - after, - }; - }); - } - - let cronMatrix = { - steps: { - 'due yesterday': { - defaults: { - daysAgo: 1, - cronAfterStart: true, - limitOne: 'daily', - }, - steps: { - '(simple)': { - expect: 'losePoints', - }, - 'due today': { - defaults: { - repeat: { - su: true, - m: true, - t: true, - w: true, - th: true, - f: true, - s: true, - }, - }, - steps: { - 'pre-dayStart': { - defaults: { - currentHour: 3, - dayStart: 4, - shouldDo: true, - }, - steps: { - checked: { - checked: true, - expect: 'noChange', - }, - 'un-checked': { - checked: false, - expect: 'noChange', - }, - }, - }, - 'post-dayStart': { - defaults: { - currentHour: 5, - dayStart: 4, - shouldDo: true, - }, - steps: { - checked: { - checked: true, - expect: 'noDamage', - }, - unchecked: { - checked: false, - expect: 'losePoints', - }, - }, - }, - }, - }, - 'NOT due today': { - defaults: { - repeat: { - su: true, - m: false, - t: true, - w: true, - th: true, - f: true, - s: true, - }, - }, - steps: { - 'pre-dayStart': { - defaults: { - currentHour: 3, - dayStart: 4, - shouldDo: true, - }, - steps: { - checked: { - checked: true, - expect: 'noChange', - }, - 'un-checked': { - checked: false, - expect: 'noChange', - }, - }, - }, - 'post-dayStart': { - defaults: { - currentHour: 5, - dayStart: 4, - shouldDo: false, - }, - steps: { - checked: { - checked: true, - expect: 'noDamage', - }, - unchecked: { - checked: false, - expect: 'losePoints', - }, - }, - }, - }, - }, - }, - }, - 'not due yesterday': { - defaults: repeatWithoutLastWeekday(), - steps: { - '(simple)': { - expect: 'noDamage', - }, - 'post-dayStart': { - currentHour: 5, - dayStart: 4, - expect: 'noDamage', - }, - 'pre-dayStart': { - currentHour: 3, - dayStart: 4, - expect: 'noChange', - }, - }, - }, - }, - }; - - let recurseCronMatrix = (obj, options = {}) => { - if (obj.steps) { - _.each(obj.steps, (step, text) => { - let o = _.cloneDeep(options); - - if (!o.text) { - o.text = ''; - } - o.text += `${text}`; - return recurseCronMatrix(step, _.defaults(o, obj.defaults)); - }); - } else { - it(`${options.text}`, () => { - return runCron(_.defaults(obj, options)); - }); - } - }; - - return recurseCronMatrix(cronMatrix); - }); - }); -}); - -describe('Helper', () => { - it('calculates gold coins', () => { - expect(shared.gold(10)).to.eql(10); - expect(shared.gold(1.957)).to.eql(1); - expect(shared.gold()).to.eql(0); - }); - - it('calculates silver coins', () => { - expect(shared.silver(10)).to.eql(0); - expect(shared.silver(1.957)).to.eql(95); - expect(shared.silver(0.01)).to.eql('01'); - expect(shared.silver()).to.eql('00'); - }); - - it('calculates experience to next level', () => { - expect(shared.tnl(1)).to.eql(150); - expect(shared.tnl(2)).to.eql(160); - expect(shared.tnl(10)).to.eql(260); - expect(shared.tnl(99)).to.eql(3580); - }); - - it('calculates the start of the day', () => { - let fstr = 'YYYY-MM-DD HH: mm: ss'; - let today = '2013-01-01 00: 00: 00'; - let zone = moment(today).zone(); - - expect(startOfDay({ - now: new Date(2013, 0, 1, 0), - }, { - timezoneOffset: zone, - }).format(fstr)).to.eql(today); - expect(startOfDay({ - now: new Date(2013, 0, 1, 5), - }, { - timezoneOffset: zone, - }).format(fstr)).to.eql(today); - expect(startOfDay({ - now: new Date(2013, 0, 1, 23, 59, 59), - timezoneOffset: zone, - }).format(fstr)).to.eql(today); - }); -}); diff --git a/test/common_old/dailies.js b/test/common_old/dailies.js deleted file mode 100644 index 7757465f2e..0000000000 --- a/test/common_old/dailies.js +++ /dev/null @@ -1,499 +0,0 @@ -/* eslint-disable camelcase */ -import { - startOfWeek, -} from '../../common/script/cron'; - -let expect = require('expect.js'); // eslint-disable-line no-shadow -let moment = require('moment'); -let shared = require('../../common/script/index.js'); - -shared.i18n.translations = require('../../website/src/libs/api-v2/i18n.js').translations; - -let repeatWithoutLastWeekday = () => { // eslint-disable-line no-unused-vars - let repeat = { - su: true, - m: true, - t: true, - w: true, - th: true, - f: true, - s: true, - }; - - if (startOfWeek(moment().zone(0)).isoWeekday() === 1) { - repeat.su = false; - } else { - repeat.s = false; - } - return { - repeat, - }; -}; - - -/* Helper Functions */ - -import { - generateUser, -} from '../helpers/common.helper'; - -let cron = (usr, missedDays = 1) => { - usr.lastCron = moment().subtract(missedDays, 'days'); - usr.fns.cron(); -}; - -describe('daily/weekly that repeats everyday (default)', () => { - let user = null; - let daily = null; - let weekly = null; - - describe('when startDate is in the future', () => { - beforeEach(() => { - user = generateUser(); - user.dailys = [ - shared.taskDefaults({ - type: 'daily', - startDate: moment().add(7, 'days'), - frequency: 'daily', - }), shared.taskDefaults({ - type: 'daily', - startDate: moment().add(7, 'days'), - frequency: 'weekly', - repeat: { - su: true, - m: true, - t: true, - w: true, - th: true, - f: true, - s: true, - }, - }), - ]; - daily = user.dailys[0]; - weekly = user.dailys[1]; - }); - - it('does not damage user for not completing it', () => { - cron(user); - expect(user.stats.hp).to.be(50); - }); - - it('does not change value on cron if daily is incomplete', () => { - cron(user); - expect(daily.value).to.be(0); - expect(weekly.value).to.be(0); - }); - - it('does not reset checklists if daily is not marked as complete', () => { - let checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, { - text: '2', - id: 'checklist-two', - completed: true, - }, { - text: '3', - id: 'checklist-three', - completed: false, - }, - ]; - - daily.checklist = checklist; - weekly.checklist = checklist; - cron(user); - expect(daily.checklist[0].completed).to.be(true); - expect(daily.checklist[1].completed).to.be(true); - expect(daily.checklist[2].completed).to.be(false); - expect(weekly.checklist[0].completed).to.be(true); - expect(weekly.checklist[1].completed).to.be(true); - expect(weekly.checklist[2].completed).to.be(false); - }); - - it('resets checklists if daily is marked as complete', () => { - let checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, { - text: '2', - id: 'checklist-two', - completed: true, - }, { - text: '3', - id: 'checklist-three', - completed: false, - }, - ]; - - daily.checklist = checklist; - weekly.checklist = checklist; - daily.completed = true; - weekly.completed = true; - cron(user); - _.each(daily.checklist, (box) => { - expect(box.completed).to.be(false); - }); - _.each(weekly.checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - - it('is due on startDate', () => { - let daily_due_today = shared.shouldDo(moment(), daily); - let daily_due_on_start_date = shared.shouldDo(moment().add(7, 'days'), daily); - - expect(daily_due_today).to.be(false); - expect(daily_due_on_start_date).to.be(true); - - let weekly_due_today = shared.shouldDo(moment(), weekly); - let weekly_due_on_start_date = shared.shouldDo(moment().add(7, 'days'), weekly); - - expect(weekly_due_today).to.be(false); - expect(weekly_due_on_start_date).to.be(true); - }); - }); - - describe('when startDate is in the past', () => { - beforeEach(() => { - user = generateUser(); - user.dailys = [ - shared.taskDefaults({ - type: 'daily', - startDate: moment().subtract(7, 'days'), - frequency: 'daily', - }), shared.taskDefaults({ - type: 'daily', - startDate: moment().subtract(7, 'days'), - frequency: 'weekly', - }), - ]; - daily = user.dailys[0]; - weekly = user.dailys[1]; - }); - - it('does damage user for not completing it', () => { - cron(user); - expect(user.stats.hp).to.be.lessThan(50); - }); - - it('decreases value on cron if daily is incomplete', () => { - cron(user, 1); - expect(daily.value).to.be(-1); - expect(weekly.value).to.be(-1); - }); - - it('decreases value on cron once only if daily is incomplete and multiple days are missed', () => { - cron(user, 7); - expect(daily.value).to.be(-1); - expect(weekly.value).to.be(-1); - }); - - it('resets checklists if daily is not marked as complete', () => { - let checklist; - - checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, { - text: '2', - id: 'checklist-two', - completed: true, - }, { - text: '3', - id: 'checklist-three', - completed: false, - }, - ]; - daily.checklist = checklist; - weekly.checklist = checklist; - cron(user); - _.each(daily.checklist, (box) => { - expect(box.completed).to.be(false); - }); - _.each(weekly.checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - - it('resets checklists if daily is marked as complete', () => { - let checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, { - text: '2', - id: 'checklist-two', - completed: true, - }, { - text: '3', - id: 'checklist-three', - completed: false, - }, - ]; - - daily.checklist = checklist; - daily.completed = true; - weekly.checklist = checklist; - weekly.completed = true; - cron(user); - _.each(daily.checklist, (box) => { - expect(box.completed).to.be(false); - }); - _.each(weekly.checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - }); - - describe('when startDate is today', () => { - beforeEach(() => { - user = generateUser(); - user.dailys = [ - shared.taskDefaults({ - type: 'daily', - startDate: moment().subtract(1, 'days'), - frequency: 'daily', - }), shared.taskDefaults({ - type: 'daily', - startDate: moment().subtract(1, 'days'), - frequency: 'weekly', - }), - ]; - daily = user.dailys[0]; - weekly = user.dailys[1]; - }); - - it('does damage user for not completing it', () => { - cron(user); - expect(user.stats.hp).to.be.lessThan(50); - }); - - it('decreases value on cron if daily is incomplete', () => { - cron(user); - expect(daily.value).to.be.lessThan(0); - expect(weekly.value).to.be.lessThan(0); - }); - - it('resets checklists if daily is not marked as complete', () => { - let checklist; - - checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, { - text: '2', - id: 'checklist-two', - completed: true, - }, { - text: '3', - id: 'checklist-three', - completed: false, - }, - ]; - daily.checklist = checklist; - weekly.checklist = checklist; - cron(user); - _.each(daily.checklist, (box) => { - expect(box.completed).to.be(false); - }); - _.each(weekly.checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - - it('resets checklists if daily is marked as complete', () => { - let checklist; - - checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, { - text: '2', - id: 'checklist-two', - completed: true, - }, { - text: '3', - id: 'checklist-three', - completed: false, - }, - ]; - daily.checklist = checklist; - daily.completed = true; - weekly.checklist = checklist; - weekly.completed = true; - cron(user); - _.each(daily.checklist, (box) => { - expect(box.completed).to.be(false); - }); - _.each(weekly.checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - }); -}); - -describe('daily that repeats every x days', () => { - let user = null; - let daily = null; - - beforeEach(() => { - user = generateUser(); - user.dailys = [ - shared.taskDefaults({ - type: 'daily', - startDate: moment(), - frequency: 'daily', - }), - ]; - daily = user.dailys[0]; - }); - _.times(11, (due) => { - it(`where x equals ${due}`, () => { - daily.everyX = due; - _.times(30, (day) => { - let isDue; - - isDue = shared.shouldDo(moment().add(day, 'days'), daily); - if (day % due === 0) { - expect(isDue).to.be(true); - } - if (day % due !== 0) { - expect(isDue).to.be(false); - } - }); - }); - }); -}); - -describe('daily that repeats every X days when multiple days are missed', () => { - let everyX = 3; - let startDateDaysAgo = everyX * 3; - let user = null; - let daily = null; - - describe('including missing a due date', () => { - let missedDays = everyX * 2 + 1; - - beforeEach(() => { - user = generateUser(); - user.dailys = [ - shared.taskDefaults({ - type: 'daily', - startDate: moment().subtract(startDateDaysAgo, 'days'), - frequency: 'daily', - everyX, - }), - ]; - daily = user.dailys[0]; - }); - - it('decreases value on cron once only if daily is incomplete', () => { - cron(user, missedDays); - expect(daily.value).to.be(-1); - }); - - it('resets checklists if daily is incomplete', () => { - let checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, - ]; - - daily.checklist = checklist; - cron(user, missedDays); - _.each(daily.checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - - it('resets checklists if daily is marked as complete', () => { - let checklist; - - checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, - ]; - daily.checklist = checklist; - daily.completed = true; - cron(user, missedDays); - _.each(daily.checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - }); - - describe('but not missing a due date', () => { - let missedDays; - - missedDays = everyX - 1; - beforeEach(() => { - user = generateUser(); - user.dailys = [ - shared.taskDefaults({ - type: 'daily', - startDate: moment().subtract(startDateDaysAgo, 'days'), - frequency: 'daily', - everyX, - }), - ]; - daily = user.dailys[0]; - }); - - it('does not decrease value on cron', () => { - cron(user, missedDays); - expect(daily.value).to.be(0); - }); - - it('does not reset checklists if daily is incomplete', () => { - let checklist; - - checklist = [ - { - text: '1', - id: 'checklist-one', - completed: true, - }, - ]; - daily.checklist = checklist; - cron(user, missedDays); - _.each(daily.checklist, (box) => { - expect(box.completed).to.be(true); - }); - }); - - it('resets checklists if daily is marked as complete', () => { - let checklist; - - checklist = [ - { - text: 1, - id: 'checklist-one', - completed: true, - }, - ]; - daily.checklist = checklist; - daily.completed = true; - cron(user, missedDays); - _.each(daily.checklist, (box) => { - expect(box.completed).to.be(false); - }); - }); - }); -}); diff --git a/test/common_old/preenTodos.test.js b/test/common_old/preenTodos.test.js deleted file mode 100644 index c9f9a45028..0000000000 --- a/test/common_old/preenTodos.test.js +++ /dev/null @@ -1,76 +0,0 @@ -import moment from 'moment'; -import { generateTodo } from '../helpers/common.helper'; -import { preenTodos } from '../../common/script/index.js'; - -describe('#preenTodos', () => { - let todos, uncompletedTodo, completedChallengeTodo, newlyCompletedTodo, completedTodoFromTwoDaysAgo, completedTodoFromThreeDaysAgo, completedTodoFromTenDaysAgo; - - beforeEach(() => { - uncompletedTodo = generateTodo({ completed: false }); - completedChallengeTodo = generateTodo({ - completed: true, - challenge: { id: 'some-challenge' }, - }); - newlyCompletedTodo = generateTodo({ - completed: true, - dateCompleted: moment(), - }); - completedTodoFromTwoDaysAgo = generateTodo({ - completed: true, - dateCompleted: moment().subtract({ days: 2 }), - }); - completedTodoFromThreeDaysAgo = generateTodo({ - completed: true, - dateCompleted: moment().subtract({ days: 3 }), - }); - completedTodoFromTenDaysAgo = generateTodo({ - completed: true, - dateCompleted: moment().subtract({ days: 10 }), - }); - - todos = [ - uncompletedTodo, - completedChallengeTodo, - newlyCompletedTodo, - completedTodoFromTwoDaysAgo, - completedTodoFromThreeDaysAgo, - completedTodoFromTenDaysAgo, - ]; - }); - - it('includes uncompleted todos', () => { - let preenedTodos = preenTodos(todos); - - expect(preenedTodos).to.include(uncompletedTodo); - }); - - it('includes completed challenge todos', () => { - let preenedTodos = preenTodos(todos); - - expect(preenedTodos).to.include(completedChallengeTodo); - }); - - it('includes recently completed todos', () => { - let preenedTodos = preenTodos(todos); - - expect(preenedTodos).to.include(newlyCompletedTodo); - }); - - it('includes todos completed two days ago', () => { - let preenedTodos = preenTodos(todos); - - expect(preenedTodos).to.include(completedTodoFromTwoDaysAgo); - }); - - it('does not include todos completed three days ago', () => { - let preenedTodos = preenTodos(todos); - - expect(preenedTodos).to.not.include(completedTodoFromThreeDaysAgo); - }); - - it('does not include todos completed more than three days ago', () => { - let preenedTodos = preenTodos(todos); - - expect(preenedTodos).to.not.include(completedTodoFromTenDaysAgo); - }); -}); diff --git a/test/common_old/shared.spells.test.js b/test/common_old/shared.spells.test.js deleted file mode 100644 index 931f9b9991..0000000000 --- a/test/common_old/shared.spells.test.js +++ /dev/null @@ -1,103 +0,0 @@ -import shared from '../../common/script/index.js'; -import { - generateUser, - generateTodo, -} from '../helpers/common.helper'; - - -describe('Spells', () => { - let user; - - beforeEach(() => { - let todo = generateTodo(); - - user = generateUser({ - stats: { - int: 20, - str: 20, - con: 20, - per: 20, - lvl: 20, - }, - }); - user.todos.push(todo); - }); - - context('Rogue Spells', () => { - beforeEach(() => { - user.stats.class = 'rogue'; - }); - - describe('#backstab', () => { - it('adds exp to user', () => { - const PREVIOUS_EXP = user.stats.exp; - - shared.content.spells.rogue.backStab.cast(user, user.todos[0]); - - expect(user.stats.exp).to.be.greaterThan(PREVIOUS_EXP); - }); - - it('adds gp to user', () => { - const PREVIOUS_GP = user.stats.gp; - - shared.content.spells.rogue.backStab.cast(user, user.todos[0]); - - expect(user.stats.gp).to.be.greaterThan(PREVIOUS_GP); - }); - - it('levels up user if the gain in experience will level up the user', () => { - user.stats.exp = 399; - user.stats.lvl = 17; - - shared.content.spells.rogue.backStab.cast(user, user.todos[0]); - expect(user.stats.lvl).to.eql(18); - }); - - it('adds quest scroll to inventory when passing level milestone', () => { - user.stats.exp = 329; - user.stats.lvl = 14; - - expect(user.items.quests).to.not.have.property('atom1'); - - shared.content.spells.rogue.backStab.cast(user, user.todos[0]); - - expect(user.items.quests).to.have.property('atom1', 1); - }); - }); - }); - - context('Wizard Spells', () => { - beforeEach(() => { - user.stats.class = 'wizard'; - }); - - describe('#fireball (Burst of flames)', () => { - it('adds exp to user', () => { - const PREVIOUS_EXP = user.stats.exp; - - shared.content.spells.wizard.fireball.cast(user, user.todos[0]); - - expect(user.stats.exp).to.be.greaterThan(PREVIOUS_EXP); - }); - - it('levels up user if the gain in experience will level up the user', () => { - user.stats.exp = 399; - user.stats.lvl = 17; - - shared.content.spells.wizard.fireball.cast(user, user.todos[0]); - expect(user.stats.lvl).to.eql(18); - }); - - it('adds quest scroll to inventory when passing level milestone', () => { - user.stats.exp = 329; - user.stats.lvl = 14; - - expect(user.items.quests).to.not.have.property('atom1'); - - shared.content.spells.wizard.fireball.cast(user, user.todos[0]); - - expect(user.items.quests).to.have.property('atom1', 1); - }); - }); - }); -}); diff --git a/test/common_old/simulations/autoAllocate.js b/test/common_old/simulations/autoAllocate.js deleted file mode 100644 index 0b0348efee..0000000000 --- a/test/common_old/simulations/autoAllocate.js +++ /dev/null @@ -1,161 +0,0 @@ -var $w, _, id, modes, shared, user; - -shared = require('../../../common/script/index.js'); - -_ = require('lodash'); - -$w = function(s) { - return s.split(' '); -}; - -id = shared.uuid(); - -user = { - stats: { - "class": 'warrior', - lvl: 1, - hp: 50, - gp: 0, - exp: 10, - per: 0, - int: 0, - con: 0, - str: 0, - buffs: { - per: 0, - int: 0, - con: 0, - str: 0 - }, - training: { - int: 0, - con: 0, - per: 0, - str: 0 - } - }, - preferences: { - automaticAllocation: false - }, - party: { - quest: { - key: 'evilsanta', - progress: { - up: 0, - down: 0 - } - } - }, - achievements: {}, - items: { - eggs: {}, - hatchingPotions: {}, - food: {}, - gear: { - equipped: { - weapon: 'weapon_warrior_4', - armor: 'armor_warrior_4', - shield: 'shield_warrior_4', - head: 'head_warrior_4' - } - } - }, - habits: [ - { - id: 'a', - value: 1, - type: 'habit', - attribute: 'str' - } - ], - dailys: [ - { - id: 'b', - value: 1, - type: 'daily', - attribute: 'str' - } - ], - todos: [ - { - id: 'c', - value: 1, - type: 'todo', - attribute: 'con' - }, { - id: 'd', - value: 1, - type: 'todo', - attribute: 'per' - }, { - id: 'e', - value: 1, - type: 'todo', - attribute: 'int' - } - ], - rewards: [] -}; - -modes = { - flat: _.cloneDeep(user), - classbased_warrior: _.cloneDeep(user), - classbased_rogue: _.cloneDeep(user), - classbased_wizard: _.cloneDeep(user), - classbased_healer: _.cloneDeep(user), - taskbased: _.cloneDeep(user) -}; - -modes.classbased_warrior.stats["class"] = 'warrior'; - -modes.classbased_rogue.stats["class"] = 'rogue'; - -modes.classbased_wizard.stats["class"] = 'wizard'; - -modes.classbased_healer.stats["class"] = 'healer'; - -_.each($w('flat classbased_warrior classbased_rogue classbased_wizard classbased_healer taskbased'), function(mode) { - _.merge(modes[mode].preferences, { - automaticAllocation: true, - allocationMode: mode.indexOf('classbased') === 0 ? 'classbased' : mode - }); - return shared.wrap(modes[mode]); -}); - -console.log("\n\n================================================"); - -console.log("New Simulation"); - -console.log("================================================\n\n"); - -_.times([20], function(lvl) { - console.log("[lvl " + lvl + "]\n--------------\n"); - return _.each($w('flat classbased_warrior classbased_rogue classbased_wizard classbased_healer taskbased'), function(mode) { - var str, u; - u = modes[mode]; - u.stats.exp = shared.tnl(lvl) + 1; - if (mode === 'taskbased') { - _.merge(u.stats, { - per: 0, - con: 0, - int: 0, - str: 0 - }); - } - u.habits[0].attribute = u.fns.randomVal({ - str: 'str', - int: 'int', - per: 'per', - con: 'con' - }); - u.ops.score({ - params: { - id: u.habits[0].id - }, - direction: 'up' - }); - u.fns.updateStats(u.stats); - str = mode + (mode === 'taskbased' ? " (" + u.habits[0].attribute + ")" : ""); - return console.log(str, _.pick(u.stats, $w('per int con str'))); - }); -}); diff --git a/test/common_old/simulations/passive_active_attrs.js b/test/common_old/simulations/passive_active_attrs.js deleted file mode 100644 index f69a2cafc2..0000000000 --- a/test/common_old/simulations/passive_active_attrs.js +++ /dev/null @@ -1,291 +0,0 @@ -var _, clearUser, id, party, s, shared, task, user; - -shared = require('../../../common/script/index.js'); - -_ = require('lodash'); - -id = shared.uuid(); - -user = { - stats: { - "class": 'warrior', - buffs: { - per: 0, - int: 0, - con: 0, - str: 0 - } - }, - party: { - quest: { - key: 'evilsanta', - progress: { - up: 0, - down: 0 - } - } - }, - preferences: { - automaticAllocation: false - }, - achievements: {}, - flags: { - levelDrops: {} - }, - items: { - eggs: {}, - hatchingPotions: {}, - food: {}, - quests: {}, - gear: { - equipped: { - weapon: 'weapon_warrior_4', - armor: 'armor_warrior_4', - shield: 'shield_warrior_4', - head: 'head_warrior_4' - } - } - }, - habits: [ - shared.taskDefaults({ - id: id, - value: 0 - }) - ], - dailys: [ - { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - }, { - "text": "1" - } - ], - todos: [], - rewards: [] -}; - -shared.wrap(user); - -s = user.stats; - -task = user.tasks[id]; - -party = [user]; - -console.log("\n\n================================================"); - -console.log("New Simulation"); - -console.log("================================================\n\n"); - -clearUser = function(lvl) { - if (lvl == null) { - lvl = 1; - } - _.merge(user.stats, { - exp: 0, - gp: 0, - hp: 50, - lvl: lvl, - str: lvl * 1.5, - con: lvl * 1.5, - per: lvl * 1.5, - int: lvl * 1.5, - mp: 100 - }); - _.merge(s.buffs, { - str: 0, - con: 0, - int: 0, - per: 0 - }); - _.merge(user.party.quest.progress, { - up: 0, - down: 0 - }); - return user.items.lastDrop = { - count: 0 - }; -}; - -_.each([1, 25, 50, 75, 100], function(lvl) { - console.log("[LEVEL " + lvl + "] (" + (lvl * 2) + " points total in every attr)\n\n"); - _.each({ - red: -25, - yellow: 0, - green: 35 - }, function(taskVal, color) { - var _party, b4, str; - console.log("[task.value = " + taskVal + " (" + color + ")]"); - console.log("direction\texpΔ\t\thpΔ\tgpΔ\ttask.valΔ\ttask.valΔ bonus\t\tboss-hit"); - _.each(['up', 'down'], function(direction) { - var b4, delta; - clearUser(lvl); - b4 = { - hp: s.hp, - taskVal: taskVal - }; - task.value = taskVal; - if (direction === 'up') { - task.type = 'daily'; - } - delta = user.ops.score({ - params: { - id: id, - direction: direction - } - }); - return console.log((direction === 'up' ? '↑' : '↓') + "\t\t" + s.exp + "/" + (shared.tnl(s.lvl)) + "\t\t" + ((b4.hp - s.hp).toFixed(1)) + "\t" + (s.gp.toFixed(1)) + "\t" + (delta.toFixed(1)) + "\t\t" + ((task.value - b4.taskVal - delta).toFixed(1)) + "\t\t\t" + (user.party.quest.progress.up.toFixed(1))); - }); - str = '- [Wizard]'; - task.value = taskVal; - clearUser(lvl); - b4 = { - taskVal: taskVal - }; - shared.content.spells.wizard.fireball.cast(user, task); - str += "\tfireball(task.valΔ:" + ((task.value - taskVal).toFixed(1)) + " exp:" + (s.exp.toFixed(1)) + " bossHit:" + (user.party.quest.progress.up.toFixed(2)) + ")"; - task.value = taskVal; - clearUser(lvl); - _party = [ - user, { - stats: { - mp: 0 - } - } - ]; - shared.content.spells.wizard.mpheal.cast(user, _party); - str += "\t| mpheal(mp:" + _party[1].stats.mp + ")"; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.wizard.earth.cast(user, party); - str += "\t\t\t\t| earth(buffs.int:" + s.buffs.int + ")"; - s.buffs.int = 0; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.wizard.frost.cast(user, {}); - str += "\t\t\t| frost(N/A)"; - console.log(str); - str = '- [Warrior]'; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.warrior.smash.cast(user, task); - b4 = { - taskVal: taskVal - }; - str += "\tsmash(task.valΔ:" + ((task.value - taskVal).toFixed(1)) + ")"; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.warrior.defensiveStance.cast(user, {}); - str += "\t\t| defensiveStance(buffs.con:" + s.buffs.con + ")"; - s.buffs.con = 0; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.warrior.valorousPresence.cast(user, party); - str += "\t\t\t| valorousPresence(buffs.str:" + s.buffs.str + ")"; - s.buffs.str = 0; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.warrior.intimidate.cast(user, party); - str += "\t\t| intimidate(buffs.con:" + s.buffs.con + ")"; - s.buffs.con = 0; - console.log(str); - str = '- [Rogue]'; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.rogue.pickPocket.cast(user, task); - str += "\tpickPocket(gp:" + (s.gp.toFixed(1)) + ")"; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.rogue.backStab.cast(user, task); - b4 = { - taskVal: taskVal - }; - str += "\t\t| backStab(task.valΔ:" + ((task.value - b4.taskVal).toFixed(1)) + " exp:" + (s.exp.toFixed(1)) + " gp:" + (s.gp.toFixed(1)) + ")"; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.rogue.toolsOfTrade.cast(user, party); - str += "\t| toolsOfTrade(buffs.per:" + s.buffs.per + ")"; - s.buffs.per = 0; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.rogue.stealth.cast(user, {}); - str += "\t\t| stealth(avoiding " + user.stats.buffs.stealth + " tasks)"; - user.stats.buffs.stealth = 0; - console.log(str); - str = '- [Healer]'; - task.value = taskVal; - clearUser(lvl); - s.hp = 0; - shared.content.spells.healer.heal.cast(user, {}); - str += "\theal(hp:" + (s.hp.toFixed(1)) + ")"; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.healer.brightness.cast(user, {}); - b4 = { - taskVal: taskVal - }; - str += "\t\t\t| brightness(task.valΔ:" + ((task.value - b4.taskVal).toFixed(1)) + ")"; - task.value = taskVal; - clearUser(lvl); - shared.content.spells.healer.protectAura.cast(user, party); - str += "\t\t\t| protectAura(buffs.con:" + s.buffs.con + ")"; - s.buffs.con = 0; - task.value = taskVal; - clearUser(lvl); - s.hp = 0; - shared.content.spells.healer.heallAll.cast(user, party); - str += "\t\t| heallAll(hp:" + (s.hp.toFixed(1)) + ")"; - console.log(str); - return console.log('\n'); - }); - return console.log('------------------------------------------------------------'); -}); - - -/* -_.each [1,25,50,75,100,125], (lvl) -> - console.log "[LEVEL #{lvl}] (#{lvl*2} points in every attr)\n\n" - _.each {red:-25,yellow:0,green:35}, (taskVal, color) -> - console.log "[task.value = #{taskVal} (#{color})]" - console.log "direction\texpΔ\t\thpΔ\tgpΔ\ttask.valΔ\ttask.valΔ bonus\t\tboss-hit" - _.each ['up','down'], (direction) -> - clearUser(lvl) - b4 = {hp:s.hp, taskVal} - task.value = taskVal - task.type = 'daily' if direction is 'up' - delta = user.ops.score params:{id, direction} - console.log "#{if direction is 'up' then '↑' else '↓'}\t\t#{s.exp}/#{shared.tnl(s.lvl)}\t\t#{(b4.hp-s.hp).toFixed(1)}\t#{s.gp.toFixed(1)}\t#{delta.toFixed(1)}\t\t#{(task.value-b4.taskVal-delta).toFixed(1)}\t\t\t#{user.party.quest.progress.up.toFixed(1)}" - - task.value = taskVal;clearUser(lvl) - shared.content.spells.rogue.stealth.cast(user,{}) - console.log "\t\t| stealth(avoiding #{user.stats.buffs.stealth} tasks)" - user.stats.buffs.stealth = 0 - - console.log user.dailys.length - */ diff --git a/test/common_old/user.fns.updateStats.test.js b/test/common_old/user.fns.updateStats.test.js deleted file mode 100644 index fbad57e531..0000000000 --- a/test/common_old/user.fns.updateStats.test.js +++ /dev/null @@ -1,134 +0,0 @@ -import { - generateUser, -} from '../helpers/common.helper'; - -describe('user.fns.updateStats', () => { - let user; - - beforeEach(() => { - user = generateUser({}); - }); - - context('No Hp', () => { - it('returns 0 if user\'s hp is 0', () => { - let stats = { - hp: 0, - }; - - expect(user.fns.updateStats(stats)).to.eql(0); - }); - - it('returns 0 if user\'s hp is less than 0', () => { - let stats = { - hp: -5, - }; - - expect(user.fns.updateStats(stats)).to.eql(0); - }); - - it('sets user\'s hp to 0 if it is less than 0', () => { - let stats = { - hp: -5, - }; - - user.fns.updateStats(stats); - - expect(user.stats.hp).to.eql(0); - }); - }); - - context('Stat Allocation', () => { - it('adds only attribute points up to user\'s level', () => { - let stats = { - exp: 261, - }; - - user.stats.lvl = 10; - - user.fns.updateStats(stats); - - expect(user.stats.points).to.eql(11); - }); - - it('adds an attibute point when user\'s stat points are less than max level', () => { - let stats = { - exp: 3581, - }; - - user.stats.lvl = 99; - user.stats.str = 25; - user.stats.int = 25; - user.stats.con = 25; - user.stats.per = 24; - - user.fns.updateStats(stats); - - expect(user.stats.points).to.eql(1); - }); - - it('does not add an attibute point when user\'s stat points are equal to max level', () => { - let stats = { - exp: 3581, - }; - - user.stats.lvl = 99; - user.stats.str = 25; - user.stats.int = 25; - user.stats.con = 25; - user.stats.per = 25; - - user.fns.updateStats(stats); - - expect(user.stats.points).to.eql(0); - }); - - it('does not add an attibute point when user\'s stat points + unallocated points are equal to max level', () => { - let stats = { - exp: 3581, - }; - - user.stats.lvl = 99; - user.stats.str = 25; - user.stats.int = 25; - user.stats.con = 25; - user.stats.per = 15; - user.stats.points = 10; - - user.fns.updateStats(stats); - - expect(user.stats.points).to.eql(10); - }); - - it('only awards stat points up to level 100 if user is missing unallocated stat points and is over level 100', () => { - let stats = { - exp: 5581, - }; - - user.stats.lvl = 104; - user.stats.str = 25; - user.stats.int = 25; - user.stats.con = 25; - user.stats.per = 15; - user.stats.points = 0; - - user.fns.updateStats(stats); - - expect(user.stats.points).to.eql(10); - }); - - // @TODO: Set up sinon sandbox - xit('auto allocates stats if automaticAllocation is turned on', () => { - sandbox.stub(user.fns, 'autoAllocate'); - - let stats = { - exp: 261, - }; - - user.stats.lvl = 10; - - user.fns.updateStats(stats); - - expect(user.fns.autoAllocate).to.be.calledOnce; - }); - }); -}); diff --git a/test/common_old/user.ops.hourglassPurchase.test.js b/test/common_old/user.ops.hourglassPurchase.test.js deleted file mode 100644 index b9c7369d21..0000000000 --- a/test/common_old/user.ops.hourglassPurchase.test.js +++ /dev/null @@ -1,122 +0,0 @@ -let shared = require('../../common/script/index.js'); - -describe('user.ops.hourglassPurchase', () => { - let user; - - beforeEach(() => { - user = { - items: { - pets: {}, - mounts: {}, - hatchingPotions: {}, - }, - purchased: { - plan: { - consecutive: { - trinkets: 0, - }, - }, - }, - }; - - shared.wrap(user); - }); - - context('Time Travel Stable', () => { - context('failure conditions', () => { - it('does not allow purchase of unsupported item types', (done) => { - user.ops.hourglassPurchase({params: {type: 'hatchingPotions', key: 'Base'}}, (response) => { - expect(response.message).to.eql('Item type not supported for purchase with Mystic Hourglass. Allowed types: ["pets","mounts"]'); - expect(user.items.hatchingPotions).to.eql({}); - done(); - }); - }); - - it('does not grant pets without Mystic Hourglasses', (done) => { - user.ops.hourglassPurchase({params: {type: 'pets', key: 'MantisShrimp-Base'}}, (response) => { - expect(response.message).to.eql('You don\'t have enough Mystic Hourglasses.'); - expect(user.items.pets).to.eql({}); - done(); - }); - }); - - it('does not grant mounts without Mystic Hourglasses', (done) => { - user.ops.hourglassPurchase({params: {type: 'mounts', key: 'MantisShrimp-Base'}}, (response) => { - expect(response.message).to.eql('You don\'t have enough Mystic Hourglasses.'); - expect(user.items.mounts).to.eql({}); - done(); - }); - }); - - it('does not grant pet that has already been purchased', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - user.items.pets = { - 'MantisShrimp-Base': true, - }; - - user.ops.hourglassPurchase({params: {type: 'pets', key: 'MantisShrimp-Base'}}, (response) => { - expect(response.message).to.eql('Pet already owned.'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - done(); - }); - }); - - it('does not grant mount that has already been purchased', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - user.items.mounts = { - 'MantisShrimp-Base': true, - }; - - user.ops.hourglassPurchase({params: {type: 'mounts', key: 'MantisShrimp-Base'}}, (response) => { - expect(response.message).to.eql('Mount already owned.'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - done(); - }); - }); - - it('does not grant pet that is not part of the Time Travel Stable', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - - user.ops.hourglassPurchase({params: {type: 'pets', key: 'Wolf-Veteran'}}, (response) => { - expect(response.message).to.eql('Pet not available for purchase with Mystic Hourglass.'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - done(); - }); - }); - - it('does not grant mount that is not part of the Time Travel Stable', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - - user.ops.hourglassPurchase({params: {type: 'mounts', key: 'Orca-Base'}}, (response) => { - expect(response.message).to.eql('Mount not available for purchase with Mystic Hourglass.'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - done(); - }); - }); - }); - - context('successful purchases', () => { - it('buys a pet', (done) => { - user.purchased.plan.consecutive.trinkets = 2; - - user.ops.hourglassPurchase({params: {type: 'pets', key: 'MantisShrimp-Base'}}, (response) => { - expect(response.message).to.eql('Purchased an item using a Mystic Hourglass!'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - expect(user.items.pets).to.eql({'MantisShrimp-Base': 5}); - done(); - }); - }); - - it('buys a mount', (done) => { - user.purchased.plan.consecutive.trinkets = 2; - - user.ops.hourglassPurchase({params: {type: 'mounts', key: 'MantisShrimp-Base'}}, (response) => { - expect(response.message).to.eql('Purchased an item using a Mystic Hourglass!'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - expect(user.items.mounts).to.eql({'MantisShrimp-Base': true}); - done(); - }); - }); - }); - }); -}); diff --git a/test/common_old/user.ops.test.js b/test/common_old/user.ops.test.js deleted file mode 100644 index f4e4bb82c9..0000000000 --- a/test/common_old/user.ops.test.js +++ /dev/null @@ -1,34 +0,0 @@ -let shared = require('../../common/script/index.js'); - -describe('user.ops', () => { - let user; - - beforeEach(() => { - user = { - items: { - gear: { }, - special: { }, - }, - achievements: { }, - flags: { }, - }; - - shared.wrap(user); - }); - - describe('readCard', () => { - it('removes card from invitation array', () => { - user.items.special.valentineReceived = ['Leslie']; - user.ops.readCard({ params: { cardType: 'valentine' } }); - - expect(user.items.special.valentineReceived).to.be.empty; - }); - - it('removes the first card from invitation array', () => { - user.items.special.valentineReceived = ['Leslie', 'Vicky']; - user.ops.readCard({ params: { cardType: 'valentine' } }); - - expect(user.items.special.valentineReceived).to.eql(['Vicky']); - }); - }); -}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8a8e9eee05..42a6db9cac 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -274,7 +274,7 @@ api.joinGroup = { } } - await Bluebird.all(promises); + promises = await Bluebird.all(promises); let response = Group.toJSONCleanChat(promises[0], user); response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); From 8276ffcfe2b54d0036fce1aeab84486859fc4cfe Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 May 2016 16:21:22 +0200 Subject: [PATCH 779/976] v3: fix tests --- .../v3/integration/groups/POST-groups_invite.test.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index 0f59bd5966..cf53782c8d 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -173,16 +173,20 @@ describe('Post /groups/:groupId/invite', () => { }); it('invites a user to a group by email', async () => { - await expect(inviter.post(`/groups/${group._id}/invite`, { + let res = await inviter.post(`/groups/${group._id}/invite`, { emails: [testInvite], inviter: 'inviter name', - })).to.exist; + }); + + expect(res).to.exist; }); it('invites multiple users to a group by email', async () => { - await expect(inviter.post(`/groups/${group._id}/invite`, { + let res = await inviter.post(`/groups/${group._id}/invite`, { emails: [testInvite, {name: 'test2', email: 'test2@habitica.com'}], - })).to.exist; + }); + + expect(res).to.exist; }); }); From 969459252bc96077bfd1ef0d658e66fb0377e1a9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 May 2016 16:22:56 +0200 Subject: [PATCH 780/976] v3: do not await a non promise --- test/api/v2/groups/POST-groups_id_join.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v2/groups/POST-groups_id_join.test.js b/test/api/v2/groups/POST-groups_id_join.test.js index 1f0f5da58c..cf216354a0 100644 --- a/test/api/v2/groups/POST-groups_id_join.test.js +++ b/test/api/v2/groups/POST-groups_id_join.test.js @@ -104,7 +104,7 @@ describe('POST /groups/:id/join', () => { group = await user.get(`/groups/${group._id}`); - await expect(group.leader._id).to.eql(user._id); + expect(group.leader._id).to.eql(user._id); }); }); }); From bf2c69aacac88b7031fe49f4387e75f8ceb3a8c6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 11 May 2016 16:57:43 +0200 Subject: [PATCH 781/976] v3: q -> bluebird --- website/src/controllers/api-v2/groups.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index b405eb0a65..31a21d7dc9 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -8,7 +8,7 @@ function clone(a) { var _ = require('lodash'); var nconf = require('nconf'); var async = require('async'); -var Q = require('q'); +var Bluebird = require('bluebird'); var utils = require('./../../libs/api-v2/utils'); var shared = require('../../../../common'); From 5b490116db053de24347422080364e737c8f653e Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 11 May 2016 10:40:40 -0500 Subject: [PATCH 782/976] Changed id param for registration response --- website/public/js/controllers/authCtrl.js | 2 +- website/public/js/services/userServices.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/public/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js index 13906294ab..6bbcd790a2 100644 --- a/website/public/js/controllers/authCtrl.js +++ b/website/public/js/controllers/authCtrl.js @@ -49,7 +49,7 @@ angular.module('habitrpg') var url = ApiUrl.get() + "/api/v3/user/auth/local/register"; if($rootScope.selectedLanguage) url = url + '?lang=' + $rootScope.selectedLanguage.code; $http.post(url, scope.registerVals).success(function(res, status, headers, config) { - runAuth(res.data.id, res.data.apiToken); + runAuth(res.data._id, res.data.apiToken); }).error(errorAlert); }; diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js index c4b0e6bf64..b333a12346 100644 --- a/website/public/js/services/userServices.js +++ b/website/public/js/services/userServices.js @@ -339,7 +339,7 @@ angular.module('habitrpg') }, authenticate: function (uuid, token, cb) { - if (!!uuid && !!token) { + if (uuid && token) { var offset = moment().zone(); // eg, 240 - this will be converted on server as -(offset/60) $http.defaults.headers.common['x-api-user'] = uuid; $http.defaults.headers.common['x-api-key'] = token; From ee948f2447a0127ef2d45d1a1cb31ea4b8929bae Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 11 May 2016 12:34:29 -0500 Subject: [PATCH 783/976] Updated party query and create --- website/public/js/controllers/groupsCtrl.js | 2 +- website/public/js/controllers/headerCtrl.js | 20 ++++++++++++-------- website/public/js/controllers/partyCtrl.js | 20 ++++++-------------- website/public/js/controllers/rootCtrl.js | 8 +++++--- website/public/js/services/groupServices.js | 3 ++- 5 files changed, 26 insertions(+), 27 deletions(-) diff --git a/website/public/js/controllers/groupsCtrl.js b/website/public/js/controllers/groupsCtrl.js index 238cf75c49..da8016bee0 100644 --- a/website/public/js/controllers/groupsCtrl.js +++ b/website/public/js/controllers/groupsCtrl.js @@ -24,7 +24,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' // Similarly, if we're dealing with the user's current party, return true. if(group.type === 'party') { - var currentParty = Groups.party(); + var currentParty = group; if(currentParty._id && currentParty._id === group._id) return true; } diff --git a/website/public/js/controllers/headerCtrl.js b/website/public/js/controllers/headerCtrl.js index 68000da3b9..390d2c92a9 100644 --- a/website/public/js/controllers/headerCtrl.js +++ b/website/public/js/controllers/headerCtrl.js @@ -8,15 +8,19 @@ habitrpg.controller("HeaderCtrl", ['$scope', 'Groups', 'User', $scope.inviteOrStartParty = Groups.inviteOrStartParty; - $scope.party = Groups.party(function(){ - var triggerResort = function() { - $scope.partyMinusSelf = resortParty(); - }; + function handlePartyResponse (party) { + $scope.party = party; - triggerResort(); - $scope.$watch('user.party.order', triggerResort); - $scope.$watch('user.party.orderAscending', triggerResort); - }); + var triggerResort = function() { + $scope.partyMinusSelf = resortParty(); + }; + + triggerResort(); + $scope.$watch('user.party.order', triggerResort); + $scope.$watch('user.party.orderAscending', triggerResort); + } + + Groups.party().then(handlePartyResponse, handlePartyResponse); function resortParty() { var result = _.sortBy( diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js index cfac272b34..64511ee13e 100644 --- a/website/public/js/controllers/partyCtrl.js +++ b/website/public/js/controllers/partyCtrl.js @@ -8,15 +8,6 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.type = 'party'; $scope.text = window.env.t('party'); - //@TODO: cache - Groups.Group.syncParty() - .then(function successCallback(response) { - $scope.group = response.data.data; - checkForNotifications(); - }, function errorCallback(response) { - $scope.newGroup = $scope.group = { type: 'party' }; - }); - $scope.inviteOrStartParty = Groups.inviteOrStartParty; $scope.loadWidgets = Social.loadWidgets; @@ -52,11 +43,12 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.create = function(group) { if (!group.name) group.name = env.t('possessiveParty', {name: User.user.profile.name}); - Groups.Group.create(group, function() { - Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'party', 'privacy':'private'}); - Analytics.updateUser({'party.id': group.id, 'partySize': 1}); - $rootScope.hardRedirect('/#/options/groups/party'); - }); + Groups.Group.create(group) + .then(function(response) { + Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'party', 'privacy':'private'}); + Analytics.updateUser({'party.id': group.id, 'partySize': 1}); + $rootScope.hardRedirect('/#/options/groups/party'); + }); }; $scope.join = function (party) { diff --git a/website/public/js/controllers/rootCtrl.js b/website/public/js/controllers/rootCtrl.js index 0c841ee677..bc91436e2f 100644 --- a/website/public/js/controllers/rootCtrl.js +++ b/website/public/js/controllers/rootCtrl.js @@ -277,9 +277,11 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ if (spell.target == 'self') { $scope.castEnd(null, 'self'); } else if (spell.target == 'party') { - var party = Groups.party(); - party = (_.isArray(party) ? party : []).concat(User.user); - $scope.castEnd(party, 'party'); + Groups.party() + .then(function (party) { + party = (_.isArray(party) ? party : []).concat(User.user); + $scope.castEnd(party, 'party'); + }); } } diff --git a/website/public/js/services/groupServices.js b/website/public/js/services/groupServices.js index 14a788c289..4bbea80ceb 100644 --- a/website/public/js/services/groupServices.js +++ b/website/public/js/services/groupServices.js @@ -110,7 +110,8 @@ angular.module('habitrpg') data.party = response.data.data; deferred.resolve(data.party); }, function (response) { - deferred.reject(response); + data.party = { type: 'party' }; + deferred.reject(data.party); }); } else { deferred.resolve(data.party); From 12eba7bbe95aa6ef1c15832a850425a58815f04c Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 11 May 2016 14:18:32 -0500 Subject: [PATCH 784/976] Ensured login callback happens after user sync --- website/public/js/services/userServices.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js index b333a12346..45c26ad5c1 100644 --- a/website/public/js/services/userServices.js +++ b/website/public/js/services/userServices.js @@ -46,7 +46,7 @@ angular.module('habitrpg') user._wrapped = false; function sync() { - $http({ + return $http({ method: "GET", url: '/api/v3/user/', }) @@ -349,10 +349,11 @@ angular.module('habitrpg') settings.auth.apiToken = token; settings.online = true; save(); - sync(); - if (cb) { - cb(); - } + sync().then(function () { + if (cb) { + cb(); + } + }); //@TODO: Do we need the timezone set? // userServices.log({}, function(){ // // If they don't have timezone, set it From eff2f04b459dbad474262e10f82f03c7fc0ff723 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 11 May 2016 14:34:36 -0500 Subject: [PATCH 785/976] Add challenges to groups. Fixed isMemberOfGuild check --- website/public/js/app.js | 8 +++- website/public/js/controllers/groupsCtrl.js | 3 +- website/public/js/controllers/guildsCtrl.js | 9 ++-- website/public/js/controllers/partyCtrl.js | 49 +++++++++++---------- website/public/js/services/chatServices.js | 2 +- 5 files changed, 38 insertions(+), 33 deletions(-) diff --git a/website/public/js/app.js b/website/public/js/app.js index 5d685de42a..a128c2ce88 100644 --- a/website/public/js/app.js +++ b/website/public/js/app.js @@ -150,8 +150,8 @@ window.habitrpg = angular.module('habitrpg', url: '/:gid', templateUrl: 'partials/options.social.guilds.detail.html', title: env.t('titleGuilds'), - controller: ['$scope', 'Groups', 'Chat', '$stateParams', 'Members', - function($scope, Groups, Chat, $stateParams, Members){ + controller: ['$scope', 'Groups', 'Chat', '$stateParams', 'Members', 'Challenges', + function($scope, Groups, Chat, $stateParams, Members, Challenges){ Groups.Group.get($stateParams.gid) .then(function (response) { $scope.group = response.data.data; @@ -164,6 +164,10 @@ window.habitrpg = angular.module('habitrpg', .then(function (response) { $scope.group.invites = response.data.data; }); + Challenges.getGroupChallenges($scope.group._id) + .then(function (response) { + $scope.group.challenges = response.data.data; + }); }); }] }) diff --git a/website/public/js/controllers/groupsCtrl.js b/website/public/js/controllers/groupsCtrl.js index da8016bee0..cafbe3b158 100644 --- a/website/public/js/controllers/groupsCtrl.js +++ b/website/public/js/controllers/groupsCtrl.js @@ -18,8 +18,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' // If the group is a guild, just check for an intersection with the // current user's guilds, rather than checking the members of the group. if(group.type === 'guild') { - var guilds = Groups.myGuilds(); - return _.detect(guilds, function(g) { return g._id === group._id }); + return _.detect(User.user.guilds, function(guildId) { return guildId === group._id }); } // Similarly, if we're dealing with the user's current party, return true. diff --git a/website/public/js/controllers/guildsCtrl.js b/website/public/js/controllers/guildsCtrl.js index dfedbddc7c..6555495e7e 100644 --- a/website/public/js/controllers/guildsCtrl.js +++ b/website/public/js/controllers/guildsCtrl.js @@ -3,8 +3,8 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', 'Analytics', function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile, Analytics) { $scope.groups = { - guilds: Groups.myGuilds(), - public: Groups.publicGuilds(), + guilds: [], + public: [], }; Groups.myGuilds() @@ -88,8 +88,9 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$r var html, title; - Challenges.Challenge.query(function(challenges) { - challenges = _.pluck(_.filter(challenges, function(c) { + Challenges.getGroupChallenges(group._id) + .then(function(response) { + var challenges = _.pluck(_.filter(response.data.data, function(c) { return c.group._id == group._id; }), '_id'); diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js index 64511ee13e..68623f0827 100644 --- a/website/public/js/controllers/partyCtrl.js +++ b/website/public/js/controllers/partyCtrl.js @@ -84,31 +84,32 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' title = window.env.t('leavePartyCha'); //TODO: Move this to challenge service - //@TODO: Implement this when we convert front-end challenge service - // Challenges.Challenge.query(function(challenges) { - // challenges = _.pluck(_.filter(challenges, function(c) { - // return c.group._id == group._id; - // }), '_id'); - // if (_.intersection(challenges, User.user.challenges).length > 0) { - // html = $compile( - // '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' - // )($scope); - // title = window.env.t('leavePartyCha'); - // } else { - // html = $compile( - // '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' - // )($scope); - // title = window.env.t('leaveParty'); - // } + Challenges.getGroupChallenges(group._id) + .then(function(response) { + var challenges = _.pluck(_.filter(response.data.data, function(c) { + return c.group._id == group._id; + }), '_id'); - $scope.popoverEl.popover('destroy').popover({ - html: true, - placement: 'top', - trigger: 'manual', - title: title, - content: html - }).popover('show'); - // }); + if (_.intersection(challenges, User.user.challenges).length > 0) { + html = $compile( + '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leavePartyCha'); + } else { + html = $compile( + '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leaveParty'); + } + + $scope.popoverEl.popover('destroy').popover({ + html: true, + placement: 'top', + trigger: 'manual', + title: title, + content: html + }).popover('show'); + }); }; $scope.clickStartQuest = function () { diff --git a/website/public/js/services/chatServices.js b/website/public/js/services/chatServices.js index e7bb663c8e..ed812e08ad 100644 --- a/website/public/js/services/chatServices.js +++ b/website/public/js/services/chatServices.js @@ -63,7 +63,7 @@ angular.module('habitrpg') } function markChatSeen (groupId) { - if (User.user.newMessages) delete User.user.newMessages[gid]; + if (User.user.newMessages) delete User.user.newMessages[groupId]; return $http({ method: 'POST', url: apiV3Prefix + '/groups/' + groupId + '/chat/seen', From 5d7ebd82a4d621aa40f893c176d1e0f09c8458d7 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 11 May 2016 17:06:16 -0500 Subject: [PATCH 786/976] Updated party and group tests --- test/spec/controllers/groupCtrlSpec.js | 10 ++-------- test/spec/controllers/partyCtrlSpec.js | 14 +++++++------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/test/spec/controllers/groupCtrlSpec.js b/test/spec/controllers/groupCtrlSpec.js index cade1bb658..6a5819ec67 100644 --- a/test/spec/controllers/groupCtrlSpec.js +++ b/test/spec/controllers/groupCtrlSpec.js @@ -93,12 +93,9 @@ describe('Groups Controller', function() { members: [user._id] }); - var myGuilds = sandbox.stub(groups, "myGuilds", function() { - return [guild]; - }); + user.guilds = [guild._id]; expect(scope.isMemberOfGroup(user._id, guild)).to.be.ok; - expect(myGuilds).to.be.called; }); it('does not return true if guild is not included in myGuilds call', function(){ @@ -109,12 +106,9 @@ describe('Groups Controller', function() { members: ['not-user-id'] }); - var myGuilds = sandbox.stub(groups,"myGuilds", function() { - return []; - }); + user.guilds = []; expect(scope.isMemberOfGroup(user._id, guild)).to.not.be.ok; - expect(myGuilds).to.be.calledOnce; }); }); diff --git a/test/spec/controllers/partyCtrlSpec.js b/test/spec/controllers/partyCtrlSpec.js index 88a94cfc28..f9571c8ef1 100644 --- a/test/spec/controllers/partyCtrlSpec.js +++ b/test/spec/controllers/partyCtrlSpec.js @@ -78,11 +78,11 @@ describe("Party Controller", function() { initializeControllerWithStubbedState(); setTimeout(function() { - expect(User.set).to.be.calledTwice; + expect(User.set).to.be.calledOnce; expect(User.set).to.be.calledWith( { 'achievements.partyUp': true } ); - expect(rootScope.openModal).to.be.calledTwice; + expect(rootScope.openModal).to.be.calledOnce; expect(rootScope.openModal).to.be.calledWith('achievements/partyUp'); done(); }, 1000); @@ -103,11 +103,11 @@ describe("Party Controller", function() { initializeControllerWithStubbedState(); setTimeout(function(){ - expect(User.set).to.be.calledTwice; + expect(User.set).to.be.calledOnce; expect(User.set).to.be.calledWith( { 'achievements.partyOn': true } ); - expect(rootScope.openModal).to.be.calledTwice; + expect(rootScope.openModal).to.be.calledOnce; expect(rootScope.openModal).to.be.calledWith('achievements/partyOn'); done(); }, 1000); @@ -152,9 +152,9 @@ describe("Party Controller", function() { var partyStub; beforeEach(function () { - partyStub = sandbox.stub(groups.Group, "create", function() { - return party; - }); + partyStub = sinon.stub(groups.Group, "create"); + partyStub.returns(Promise.resolve(party)); + sinon.stub(rootScope, 'hardRedirect'); }); it("creates a new party", function() { From 01a8fde1244058ae74b808fd9152b0468bafa343 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 11 May 2016 23:40:20 -0500 Subject: [PATCH 787/976] Fixed cron test --- test/api/v3/unit/middlewares/cronMiddleware.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/api/v3/unit/middlewares/cronMiddleware.js b/test/api/v3/unit/middlewares/cronMiddleware.js index 196de33dad..71b4843b8b 100644 --- a/test/api/v3/unit/middlewares/cronMiddleware.js +++ b/test/api/v3/unit/middlewares/cronMiddleware.js @@ -109,11 +109,12 @@ describe('cron middleware', () => { it('should call next is user was not modified after cron', (done) => { let hpBefore = user.stats.hp; user.lastCron = moment(new Date()).subtract({days: 2}); - generateDaily(user); - cronMiddleware(req, res, () => { - expect(user.stats.hp).to.be.equal(hpBefore); - done(); + user.save().then(function () { + cronMiddleware(req, res, function () { + expect(hpBefore).to.equal(user.stats.hp); + done(); + }); }); }); From 8c351b6c56afce9d2b4fad61439bd62ce7f27535 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 May 2016 09:40:21 +0200 Subject: [PATCH 788/976] return user.id and send analytics event before changing page --- website/public/js/controllers/authCtrl.js | 2 +- website/src/models/user.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/website/public/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js index 6bbcd790a2..cc4200e8b5 100644 --- a/website/public/js/controllers/authCtrl.js +++ b/website/public/js/controllers/authCtrl.js @@ -17,10 +17,10 @@ angular.module('habitrpg') var runAuth = function(id, token) { User.authenticate(id, token, function(err) { if(!err) $scope.registrationInProgress = false; - $window.location.href = ('/' + window.location.hash); Analytics.login(); Analytics.updateUser(); Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'login'}); + $window.location.href = ('/' + window.location.hash); }); }; diff --git a/website/src/models/user.js b/website/src/models/user.js index 4e368fd5d7..b2cf358e56 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -532,6 +532,8 @@ schema.plugin(baseModel, { 'invitations', 'balance', 'backer', 'contributor'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (plainObj, originalDoc) { + plainObj.id = plainObj._id; + // plainObj.filters = {}; TODO Not saved, remove? plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs From 3f555b012db6160ccc627eb21f5db89106e9f188 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 May 2016 09:53:32 +0200 Subject: [PATCH 789/976] fix trailing spaces --- website/src/models/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/models/user.js b/website/src/models/user.js index b2cf358e56..f3c22d3709 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -533,7 +533,7 @@ schema.plugin(baseModel, { private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (plainObj, originalDoc) { plainObj.id = plainObj._id; - + // plainObj.filters = {}; TODO Not saved, remove? plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs From 7fea1d3a9829f6d029688ef3d20f6a2c2c8a5d45 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 May 2016 14:07:05 +0200 Subject: [PATCH 790/976] disable redirects --- website/src/middlewares/api-v3/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index a381ce8cd1..b64bd49bdf 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -49,8 +49,8 @@ module.exports = function attachMiddlewares (app, server) { app.use(favicon(`${PUBLIC_DIR}/favicon.ico`)); app.use(cors); - app.use(forceSSL); - app.use(forceHabitica); + //app.use(forceSSL); + //app.use(forceHabitica); app.use(bodyParser.urlencoded({ extended: true, // Uses 'qs' library as old connect middleware From c9b7aa83425ddfc98333c769498084699b502fe7 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 12 May 2016 09:45:53 -0500 Subject: [PATCH 791/976] Api v3 party tavern fixes (#7191) * Added check if user is in party before query * Cached party query. Prevented party request when user is not in party. Updated Party create with no invites * Update tavern ctrl to use new promise --- website/public/js/controllers/partyCtrl.js | 12 +++++---- website/public/js/controllers/tavernCtrl.js | 6 ++++- website/public/js/services/groupServices.js | 27 +++++++++++++++------ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js index 68623f0827..9ec42908b5 100644 --- a/website/public/js/controllers/partyCtrl.js +++ b/website/public/js/controllers/partyCtrl.js @@ -13,11 +13,11 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' if ($state.is('options.social.party')) { Groups.Group.syncParty() - .then(function successCallback(response) { - $scope.group = response.data.data; + .then(function successCallback(group) { + $scope.group = group; checkForNotifications(); }, function errorCallback(response) { - $scope.newGroup = { type: 'party' }; + $scope.group = $scope.newGroup = { type: 'party' }; }); } @@ -45,9 +45,11 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' if (!group.name) group.name = env.t('possessiveParty', {name: User.user.profile.name}); Groups.Group.create(group) .then(function(response) { + $scope.group = response.data.data; + User.sync(); + Groups.data.party = $scope.group; Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'party', 'privacy':'private'}); - Analytics.updateUser({'party.id': group.id, 'partySize': 1}); - $rootScope.hardRedirect('/#/options/groups/party'); + Analytics.updateUser({'party.id': $scope.group ._id, 'partySize': 1}); }); }; diff --git a/website/public/js/controllers/tavernCtrl.js b/website/public/js/controllers/tavernCtrl.js index 995fa22ad0..cb02d07cef 100644 --- a/website/public/js/controllers/tavernCtrl.js +++ b/website/public/js/controllers/tavernCtrl.js @@ -2,7 +2,11 @@ habitrpg.controller("TavernCtrl", ['$scope', 'Groups', 'User', function($scope, Groups, User) { - $scope.group = Groups.tavern(); + Groups.tavern() + .then(function (tavern) { + $scope.group = tavern; + }) + $scope.toggleUserTier = function($event) { $($event.target).next().toggle(); } diff --git a/website/public/js/services/groupServices.js b/website/public/js/services/groupServices.js index 4bbea80ceb..877a33a7d8 100644 --- a/website/public/js/services/groupServices.js +++ b/website/public/js/services/groupServices.js @@ -1,8 +1,8 @@ 'use strict'; angular.module('habitrpg') -.factory('Groups', [ '$location', '$rootScope', '$http', 'Analytics', 'ApiUrl', 'Challenges', '$q', - function($location, $rootScope, $http, Analytics, ApiUrl, Challenges, $q) { +.factory('Groups', [ '$location', '$rootScope', '$http', 'Analytics', 'ApiUrl', 'Challenges', '$q', 'User', + function($location, $rootScope, $http, Analytics, ApiUrl, Challenges, $q, User) { var data = {party: undefined, myGuilds: undefined, publicGuilds: undefined, tavern: undefined }; var groupApiURLPrefix = "/api/v3/groups"; @@ -29,7 +29,7 @@ angular.module('habitrpg') }; Group.syncParty = function() { - return this.get('party'); + return party(); }; Group.create = function(groupDetails) { @@ -101,23 +101,34 @@ angular.module('habitrpg') }); }; + //On page load, multiple controller request the party. + //So, we cache the promise until the first result is returned + var _cachedPartyPromise; function party () { - var deferred = $q.defer(); + if (_cachedPartyPromise) return _cachedPartyPromise.promise; + _cachedPartyPromise = $q.defer(); + + if (!User.user.party._id) { + data.party = { type: 'party' }; + _cachedPartyPromise.reject(data.party); + } if (!data.party) { Group.get('party') .then(function (response) { data.party = response.data.data; - deferred.resolve(data.party); + _cachedPartyPromise.resolve(data.party); }, function (response) { data.party = { type: 'party' }; - deferred.reject(data.party); + _cachedPartyPromise.reject(data.party); + }).finally(function(){ + _cachePartyPromise = null; }); } else { - deferred.resolve(data.party); + _cachedPartyPromise.resolve(data.party); } - return deferred.promise; + return _cachedPartyPromise.promise; } function publicGuilds () { From 93336f3894d4ebe21b7089a938249287d7d2291b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 May 2016 18:06:26 +0200 Subject: [PATCH 792/976] v3: misc fixes --- .../GET-challenges_challengeId.test.js | 9 ++++++--- .../GET-challenges_challengeId_members.test.js | 8 +++++--- ...lenges_challengeId_members_memberId.test.js | 2 +- .../GET-challenges_group_groupid.test.js | 6 ++++++ .../challenges/GET-challenges_user.test.js | 5 +++++ .../POST-challenges_challengeId_join.test.js | 1 + .../PUT-challenges_challengeId.test.js | 1 + .../groups/GET-groups_groupId_invites.test.js | 5 +++-- .../groups/GET-groups_groupId_members.test.js | 5 +++-- .../hall/GET-hall_heroes_heroId.test.js | 2 +- .../integration/members/GET-members_id.test.js | 2 +- website/src/controllers/api-v3/auth.js | 8 ++++++-- website/src/controllers/api-v3/tasks.js | 18 ++++++++++++++++-- website/src/controllers/api-v3/user.js | 1 + website/src/controllers/top-level/pages.js | 8 ++++---- website/src/libs/api-v3/logger.js | 2 +- website/src/middlewares/api-v3/auth.js | 12 ++++++------ website/src/middlewares/api-v3/cron.js | 4 +++- website/src/middlewares/api-v3/index.js | 6 +++--- website/src/middlewares/api-v3/language.js | 2 +- 20 files changed, 74 insertions(+), 33 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js index 8528fee892..33b329300a 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js @@ -43,9 +43,10 @@ describe('GET /challenges/:challengeId', () => { expect(chal.leader).to.eql({ _id: groupLeader._id, + id: groupLeader._id, profile: {name: groupLeader.profile.name}, }); - expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy'])); + expect(chal.group).to.eql(_.pick(group, ['_id', 'id', 'name', 'type', 'privacy'])); }); }); @@ -87,9 +88,10 @@ describe('GET /challenges/:challengeId', () => { expect(chal.leader).to.eql({ _id: groupLeader._id, + id: groupLeader._id, profile: {name: groupLeader.profile.name}, }); - expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy'])); + expect(chal.group).to.eql(_.pick(group, ['_id', 'id', 'name', 'type', 'privacy'])); }); }); @@ -131,9 +133,10 @@ describe('GET /challenges/:challengeId', () => { expect(chal.leader).to.eql({ _id: groupLeader._id, + id: groupLeader.id, profile: {name: groupLeader.profile.name}, }); - expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy'])); + expect(chal.group).to.eql(_.pick(group, ['_id', 'id', 'name', 'type', 'privacy'])); }); }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js index ae037b53e4..5117dcd556 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js @@ -48,9 +48,10 @@ describe('GET /challenges/:challengeId/members', () => { let res = await user.get(`/challenges/${challenge._id}/members`); expect(res[0]).to.eql({ _id: leader._id, + id: leader._id, profile: {name: leader.profile.name}, }); - expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); expect(res[0].profile).to.have.all.keys(['name']); }); @@ -61,9 +62,10 @@ describe('GET /challenges/:challengeId/members', () => { let res = await user.get(`/challenges/${challenge._id}/members`); expect(res[0]).to.eql({ _id: anotherUser._id, + id: anotherUser._id, profile: {name: anotherUser.profile.name}, }); - expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); expect(res[0].profile).to.have.all.keys(['name']); }); @@ -80,7 +82,7 @@ describe('GET /challenges/:challengeId/members', () => { let res = await user.get(`/challenges/${challenge._id}/members`); expect(res.length).to.equal(30); res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'profile']); + expect(member).to.have.all.keys(['_id', 'id', 'profile']); expect(member.profile).to.have.all.keys(['name']); }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js index 315d7bef2c..c47e1d4ad8 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js @@ -78,7 +78,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); let memberProgress = await user.get(`/challenges/${challenge._id}/members/${groupLeader._id}`); - expect(memberProgress).to.have.all.keys(['_id', 'profile', 'tasks']); + expect(memberProgress).to.have.all.keys(['_id', 'id', 'profile', 'tasks']); expect(memberProgress.profile).to.have.all.keys(['name']); expect(memberProgress.tasks.length).to.equal(1); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js index 3f116d833d..275fe798f5 100644 --- a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js @@ -34,12 +34,14 @@ describe('GET challenges/group/:groupId', () => { expect(foundChallenge1).to.exist; expect(foundChallenge1.leader).to.eql({ _id: publicGuild.leader._id, + id: publicGuild.leader._id, profile: {name: user.profile.name}, }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; expect(foundChallenge2.leader).to.eql({ _id: publicGuild.leader._id, + id: publicGuild.leader._id, profile: {name: user.profile.name}, }); }); @@ -51,12 +53,14 @@ describe('GET challenges/group/:groupId', () => { expect(foundChallenge1).to.exist; expect(foundChallenge1.leader).to.eql({ _id: publicGuild.leader._id, + id: publicGuild.leader._id, profile: {name: user.profile.name}, }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; expect(foundChallenge2.leader).to.eql({ _id: publicGuild.leader._id, + id: publicGuild.leader._id, profile: {name: user.profile.name}, }); }); @@ -99,12 +103,14 @@ describe('GET challenges/group/:groupId', () => { expect(foundChallenge1).to.exist; expect(foundChallenge1.leader).to.eql({ _id: privateGuild.leader._id, + id: privateGuild.leader._id, profile: {name: user.profile.name}, }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; expect(foundChallenge2.leader).to.eql({ _id: privateGuild.leader._id, + id: privateGuild.leader._id, profile: {name: user.profile.name}, }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js index cf8ed3b667..d062352ba3 100644 --- a/test/api/v3/integration/challenges/GET-challenges_user.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js @@ -35,6 +35,7 @@ describe('GET challenges/user', () => { expect(foundChallenge).to.exist; expect(foundChallenge.leader).to.eql({ _id: publicGuild.leader._id, + id: publicGuild.leader._id, profile: {name: user.profile.name}, }); expect(foundChallenge.group).to.eql({ @@ -52,6 +53,7 @@ describe('GET challenges/user', () => { expect(foundChallenge1).to.exist; expect(foundChallenge1.leader).to.eql({ _id: publicGuild.leader._id, + id: publicGuild.leader._id, profile: {name: user.profile.name}, }); expect(foundChallenge1.group).to.eql({ @@ -64,6 +66,7 @@ describe('GET challenges/user', () => { expect(foundChallenge2).to.exist; expect(foundChallenge2.leader).to.eql({ _id: publicGuild.leader._id, + id: publicGuild.leader._id, profile: {name: user.profile.name}, }); expect(foundChallenge2.group).to.eql({ @@ -81,6 +84,7 @@ describe('GET challenges/user', () => { expect(foundChallenge1).to.exist; expect(foundChallenge1.leader).to.eql({ _id: publicGuild.leader._id, + id: publicGuild.leader._id, profile: {name: user.profile.name}, }); expect(foundChallenge1.group).to.eql({ @@ -93,6 +97,7 @@ describe('GET challenges/user', () => { expect(foundChallenge2).to.exist; expect(foundChallenge2.leader).to.eql({ _id: publicGuild.leader._id, + id: publicGuild.leader._id, profile: {name: user.profile.name}, }); expect(foundChallenge2.group).to.eql({ diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js index 34e018d69d..c55c0e0b49 100644 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js @@ -66,6 +66,7 @@ describe('POST /challenges/:challengeId/join', () => { }); expect(res.leader).to.eql({ _id: groupLeader._id, + id: groupLeader._id, profile: {name: groupLeader.profile.name}, }); expect(res.name).to.equal(challenge.name); diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js index 63d2102968..1fdcc7a5a2 100644 --- a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js @@ -76,6 +76,7 @@ describe('PUT /challenges/:challengeId', () => { expect(res.leader).to.eql({ _id: member._id, + id: member._id, profile: {name: member.profile.name}, }); expect(res.name).to.equal('New Challenge Name'); diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js index eea2b5fa26..1b7c172d94 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js @@ -48,6 +48,7 @@ describe('GET /groups/:groupId/invites', () => { expect(res.length).to.equal(1); expect(res[0]).to.eql({ _id: invited._id, + id: invited._id, profile: {name: invited.profile.name}, }); }); @@ -57,7 +58,7 @@ describe('GET /groups/:groupId/invites', () => { let invited = await generateUser(); await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); let res = await user.get('/groups/party/invites'); - expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); expect(res[0].profile).to.have.all.keys(['name']); }); @@ -73,7 +74,7 @@ describe('GET /groups/:groupId/invites', () => { let res = await user.get('/groups/party/invites'); expect(res.length).to.equal(30); res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'profile']); + expect(member).to.have.all.keys(['_id', 'id', 'profile']); expect(member.profile).to.have.all.keys(['name']); }); }); diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js index 1acaf140e8..857f6fb863 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js @@ -45,6 +45,7 @@ describe('GET /groups/:groupId/members', () => { expect(res.length).to.equal(1); expect(res[0]).to.eql({ _id: user._id, + id: user._id, profile: {name: user.profile.name}, }); }); @@ -52,7 +53,7 @@ describe('GET /groups/:groupId/members', () => { it('populates only some fields', async () => { await generateGroup(user, {type: 'party', name: generateUUID()}); let res = await user.get('/groups/party/members'); - expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); expect(res[0].profile).to.have.all.keys(['name']); }); @@ -68,7 +69,7 @@ describe('GET /groups/:groupId/members', () => { let res = await user.get('/groups/party/members'); expect(res.length).to.equal(30); res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'profile']); + expect(member).to.have.all.keys(['_id', 'id', 'profile']); expect(member.profile).to.have.all.keys(['name']); }); }); diff --git a/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js index aef6990bad..2bf1a0a017 100644 --- a/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js +++ b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js @@ -47,7 +47,7 @@ describe('GET /heroes/:heroId', () => { let heroRes = await user.get(`/hall/heroes/${hero._id}`); expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys - '_id', 'balance', 'profile', 'purchased', + '_id', 'id', 'balance', 'profile', 'purchased', 'contributor', 'auth', 'items', ]); expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js index e68554fa42..d8ac3c4119 100644 --- a/test/api/v3/integration/members/GET-members_id.test.js +++ b/test/api/v3/integration/members/GET-members_id.test.js @@ -30,7 +30,7 @@ describe('GET /members/:memberId', () => { }); let memberRes = await user.get(`/members/${member._id}`); expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys - '_id', 'preferences', 'profile', 'stats', 'achievements', 'party', + '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', 'backer', 'contributor', 'auth', 'items', ]); expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index ce3990bb71..1a5f6f36ae 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -156,12 +156,14 @@ api.registerLocal = { uuid: savedUser._id, }); } + + return null; }, }; function _loginRes (user, req, res) { if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id})); - res.respond(200, {id: user._id, apiToken: user.apiToken}); + return res.respond(200, {id: user._id, apiToken: user.apiToken}); } /** @@ -210,7 +212,7 @@ api.loginLocal = { let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec(); let isValidPassword = user && user.auth.local.hashed_password === passwordUtils.encrypt(req.body.password, user.auth.local.salt); if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentialsLong')); - _loginRes(user, ...arguments); + return _loginRes(user, ...arguments); }, }; @@ -275,6 +277,8 @@ api.loginSocial = { gaLabel: network, uuid: savedUser._id, }); + + return null; } }, }; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 6ccf246d5d..a6b2405539 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -108,6 +108,8 @@ api.createChallengeTasks = { // If adding tasks to a challenge -> sync users if (challenge) challenge.addTasks(tasks); + + return null; }, }; @@ -185,7 +187,7 @@ api.getUserTasks = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - await _getTasks(req, res, res.locals.user); + return await _getTasks(req, res, res.locals.user); }, }; @@ -220,7 +222,7 @@ api.getChallengeTasks = { let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - await _getTasks(req, res, res.locals.user, challenge); + return await _getTasks(req, res, res.locals.user, challenge); }, }; @@ -312,6 +314,8 @@ api.updateTask = { let savedTask = await task.save(); res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); + + return null; }, }; @@ -418,6 +422,8 @@ api.scoreTask = { logger.error(e); } } + + return null; }, }; @@ -522,6 +528,8 @@ api.addChecklistItem = { res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); + + return null; }, }; @@ -615,6 +623,8 @@ api.updateChecklistItem = { res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); + + return null; }, }; @@ -664,6 +674,8 @@ api.removeChecklistItem = { let savedTask = await task.save(); res.respond(200, savedTask); if (challenge) challenge.updateTask(savedTask); + + return null; }, }; @@ -877,6 +889,8 @@ api.deleteTask = { res.respond(200, {}); if (challenge) challenge.removeTask(task); + + return null; }, }; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index d29482b70c..1440e009f9 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -80,6 +80,7 @@ let updatablePaths = [ 'flags.welcomed', 'flags.cardReceived', 'flags.warnedLowHealth', + 'flags.newStuff', 'achievements', diff --git a/website/src/controllers/top-level/pages.js b/website/src/controllers/top-level/pages.js index 63642992f5..4b0ce11ee2 100644 --- a/website/src/controllers/top-level/pages.js +++ b/website/src/controllers/top-level/pages.js @@ -20,7 +20,7 @@ api.getFrontPage = { return res.redirect('/static/front'); } - res.render('index.jade', { + return res.render('index.jade', { title: 'Habitica | Your Life The Role Playing Game', env: res.locals.habitrpg, }); @@ -40,7 +40,7 @@ _.each(staticPages, (name) => { middlewares: [locals], runCron: false, async handler (req, res) { - res.render(`static/${name}.jade`, { + return res.render(`static/${name}.jade`, { env: res.locals.habitrpg, md, userCount: TOTAL_USER_COUNT, @@ -58,7 +58,7 @@ _.each(shareables, (name) => { middlewares: [locals], runCron: false, async handler (req, res) { - res.render(`social/${name}`, { + return res.render(`social/${name}`, { env: res.locals.habitrpg, md, userCount: TOTAL_USER_COUNT, @@ -72,7 +72,7 @@ api.redirectExtensionsPage = { url: '/static/extensions', runCron: false, async handler (req, res) { - res.redirect('http://habitica.wikia.com/wiki/App_and_Extension_Integrations'); + return res.redirect('http://habitica.wikia.com/wiki/App_and_Extension_Integrations'); }, }; diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 143b7afb67..79e8ff3e62 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -12,7 +12,7 @@ const logger = new winston.Logger(); if (IS_PROD) { // TODO production logging, use loggly and new relic too - if (ENABLE_CONSOLE_LOGS_IN_PROD) { + if (ENABLE_CONSOLE_LOGS_IN_PROD === 'true') { logger.add(winston.transports.Console, { colorize: true, prettyPrint: true, diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index c2d0e052b6..9effe37eae 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -19,7 +19,7 @@ export function authWithHeaders (optional = false) { return next(new NotAuthorized(res.t('missingAuthHeaders'))); } - User.findOne({ + return User.findOne({ _id: userId, apiToken, }) @@ -31,7 +31,7 @@ export function authWithHeaders (optional = false) { res.locals.user = user; // TODO use either session/cookie or headers, not both req.session.userId = user._id; - next(); + return next(); }) .catch(next); }; @@ -43,7 +43,7 @@ export function authWithSession (req, res, next) { if (!userId) return next(new NotAuthorized(res.t('invalidCredentials'))); - User.findOne({ + return User.findOne({ _id: userId, }) .exec() @@ -51,7 +51,7 @@ export function authWithSession (req, res, next) { if (!user) throw new NotAuthorized(res.t('invalidCredentials')); res.locals.user = user; - next(); + return next(); }) .catch(next); } @@ -64,12 +64,12 @@ export function authWithUrl (req, res, next) { throw new NotAuthorized(res.t('missingAuthParams')); } - User.findOne({ _id: userId, apiToken }).exec() + return User.findOne({ _id: userId, apiToken }).exec() .then((user) => { if (!user) throw new NotAuthorized(res.t('invalidCredentials')); res.locals.user = user; - next(); + return next(); }) .catch(next); } diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index bca680b908..6b6f4f99f9 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -139,7 +139,7 @@ module.exports = function cronMiddleware (req, res, next) { toSave.push(task.save()); }); - Bluebird.all(toSave) + return Bluebird.all(toSave) .then(saved => { user = res.locals.user = saved[0]; if (!quest) return; @@ -150,6 +150,8 @@ module.exports = function cronMiddleware (req, res, next) { .then(() => User.findById(user._id).exec()) // fetch the updated user... .then(updatedUser => { res.locals.user = updatedUser; + + return null; }); }) .then(() => next()) diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index b64bd49bdf..cadee52a1b 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -49,8 +49,8 @@ module.exports = function attachMiddlewares (app, server) { app.use(favicon(`${PUBLIC_DIR}/favicon.ico`)); app.use(cors); - //app.use(forceSSL); - //app.use(forceHabitica); + app.use(forceSSL); + app.use(forceHabitica); app.use(bodyParser.urlencoded({ extended: true, // Uses 'qs' library as old connect middleware @@ -70,9 +70,9 @@ module.exports = function attachMiddlewares (app, server) { app.use(passport.initialize()); app.use(passport.session()); - app.use(v3); // the main app, also setup top-level routes app.use('/api/v2', v2); app.use('/api/v1', v1); + app.use(v3); // the main app, also setup top-level routes staticMiddleware(app); app.use(notFoundHandler); diff --git a/website/src/middlewares/api-v3/language.js b/website/src/middlewares/api-v3/language.js index 8508a09187..b83c9d5208 100644 --- a/website/src/middlewares/api-v3/language.js +++ b/website/src/middlewares/api-v3/language.js @@ -74,7 +74,7 @@ export function getUserLanguage (req, res, next) { req.language = _getFromUser(req.locals.user, req); return next(); } else if (req.session && req.session.userId) { // Same thing if the user has a valid session - User.findOne({ + return User.findOne({ _id: req.session.userId, }, 'preferences.language') .lean() From 781f0bf1abbe313e2d7e0c81c15927234588360a Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 12 May 2016 11:07:17 -0500 Subject: [PATCH 793/976] Api v3 task fixes (#7193) * Update task view to use _id * Added try catch to user service ops calls --- website/public/js/services/userServices.js | 7 ++++++- website/views/shared/tasks/task.jade | 4 ++-- website/views/shared/tasks/task_view/index.jade | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js index 45c26ad5c1..f447465d97 100644 --- a/website/public/js/services/userServices.js +++ b/website/public/js/services/userServices.js @@ -106,7 +106,12 @@ angular.module('habitrpg') function callOpsFunctionAndRequest (opName, endPoint, method, paramString, opData) { if (!opData) opData = {}; - $window.habitrpgShared.ops[opName](user, opData); + try { + $window.habitrpgShared.ops[opName](user, opData); + } catch(err) { + Notification.text(err.message); + return; + } var url = '/api/v3/user/' + endPoint; if (paramString) { diff --git a/website/views/shared/tasks/task.jade b/website/views/shared/tasks/task.jade index 3a69e1f760..ad8ee4ffe5 100644 --- a/website/views/shared/tasks/task.jade +++ b/website/views/shared/tasks/task.jade @@ -1,4 +1,4 @@ -li(id='task-{{::task.id}}', +li(id='task-{{::task._id}}', ng-repeat='task in obj[list.type+"s"] | filterByTaskInfo: obj.filterQuery | conditionalOrderBy: list.view=="dated":"date"', class='task {{Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main)}}', ng-class='{"cast-target":spell && (list.type != "reward"), "locked-task":obj._locked === true}', @@ -14,4 +14,4 @@ li(id='task-{{::task.id}}', include ./edit/index - div(class='{{obj._id}}{{task.id}}-chart', ng-show='charts[obj._id+task.id]') + div(class='{{obj._id}}{{task._id}}-chart', ng-show='charts[obj._id+task._id]') diff --git a/website/views/shared/tasks/task_view/index.jade b/website/views/shared/tasks/task_view/index.jade index 1d151ebd07..7a89fdaad1 100644 --- a/website/views/shared/tasks/task_view/index.jade +++ b/website/views/shared/tasks/task_view/index.jade @@ -28,13 +28,13 @@ // Daily & Todos span.task-checker.action-yesno(ng-if='::task.type=="daily" || task.type=="todo"') - input.task-input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task.id}}', type='checkbox', + input.task-input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task._id}}', type='checkbox', ng-model='task.completed', ng-if='$state.includes("tasks")', ng-change='task.type=="todo" && pushTask(task,$index,"bottom"); changeCheck(task)' ui-keypress='{13:"task.completed = !task.completed; changeCheck(task)"}' ) - input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task.id}}', type='checkbox', + input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task._id}}', type='checkbox', ng-if='!$state.includes("tasks")') - label(for='box-{{::obj._id}}_{{::task.id}}') + label(for='box-{{::obj._id}}_{{::task._id}}') // main content .task-text(ng-dblclick='task._editing ? saveTask(task) : editTask(task)') From bb1cb3397e7422d31195ca20cdbbb584c5065804 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 May 2016 18:18:24 +0200 Subject: [PATCH 794/976] v3 client: saving after syncing is complete --- website/public/js/services/userServices.js | 6 +++--- website/public/manifest.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/website/public/js/services/userServices.js b/website/public/js/services/userServices.js index f447465d97..13ec13120e 100644 --- a/website/public/js/services/userServices.js +++ b/website/public/js/services/userServices.js @@ -80,9 +80,6 @@ angular.module('habitrpg') }); } - save(); - $rootScope.$emit('userSynced'); - return Tasks.getUserTasks(); }) .then(function (response) { @@ -94,6 +91,9 @@ angular.module('habitrpg') tasks.forEach(function (element, index, array) { user[element.type + 's'].push(element) }) + + save(); + $rootScope.$emit('userSynced'); }); } sync(); diff --git a/website/public/manifest.json b/website/public/manifest.json index e4b662df71..10b9d9f7ac 100644 --- a/website/public/manifest.json +++ b/website/public/manifest.json @@ -130,12 +130,12 @@ "js/static.js", "js/services/analyticsServices.js", "js/services/notificationServices.js", + "js/services/userServices.js", "js/services/sharedServices.js", "js/services/socialServices.js", "js/services/statServices.js", "js/services/taskServices.js", "js/services/tagsServices.js", - "js/services/userServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" ], From ca6dca5fd08583a19704118875f24a15b028c36c Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 12 May 2016 12:55:34 -0500 Subject: [PATCH 795/976] Fixed test broken by part sync change (#7195) --- test/spec/controllers/headerCtrlSpec.js | 7 +++---- test/spec/controllers/partyCtrlSpec.js | 6 +++--- test/spec/services/groupServicesSpec.js | 10 ++++++---- test/spec/services/questServicesSpec.js | 1 + 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/test/spec/controllers/headerCtrlSpec.js b/test/spec/controllers/headerCtrlSpec.js index ab37d11205..b32c5ccbd5 100644 --- a/test/spec/controllers/headerCtrlSpec.js +++ b/test/spec/controllers/headerCtrlSpec.js @@ -5,13 +5,12 @@ describe('Header Controller', function() { beforeEach(function() { module(function($provide) { - $provide.value('User', {}); + user = specHelper.newUser(); + user._id = "unique-user-id" + $provide.value('User', {user: user}); }); inject(function(_$rootScope_, _$controller_, _$location_){ - user = specHelper.newUser(); - user._id = "unique-user-id" - scope = _$rootScope_.$new(); $rootScope = _$rootScope_; diff --git a/test/spec/controllers/partyCtrlSpec.js b/test/spec/controllers/partyCtrlSpec.js index f9571c8ef1..b27cad2e47 100644 --- a/test/spec/controllers/partyCtrlSpec.js +++ b/test/spec/controllers/partyCtrlSpec.js @@ -61,7 +61,7 @@ describe("Party Controller", function() { context('party has 1 member', function() { it('awards no new achievements', function() { - groupResponse = {data: {data: {_id: "test", type: "party", memberCount: 1}}}; + groupResponse = {_id: "test", type: "party", memberCount: 1}; initializeControllerWithStubbedState(); @@ -73,7 +73,7 @@ describe("Party Controller", function() { context('party has 2 members', function() { context('user does not have "Party Up" achievement', function() { it('awards "Party Up" achievement', function(done) { - groupResponse = {data: {data: {_id: "test", type: "party", memberCount: 2}}}; + groupResponse = {_id: "test", type: "party", memberCount: 2}; initializeControllerWithStubbedState(); @@ -93,7 +93,7 @@ describe("Party Controller", function() { context('party has 4 members', function() { beforeEach(function() { - groupResponse = {data: {data: {_id: "test", type: "party", memberCount: 4}}}; + groupResponse = {_id: "test", type: "party", memberCount: 4}; }); context('user has "Party Up" but not "Party On" achievement', function() { diff --git a/test/spec/services/groupServicesSpec.js b/test/spec/services/groupServicesSpec.js index 065af0b233..138fb6152a 100644 --- a/test/spec/services/groupServicesSpec.js +++ b/test/spec/services/groupServicesSpec.js @@ -6,14 +6,16 @@ describe('groupServices', function() { beforeEach(function() { module(function($provide) { - $provide.value('User', {user:user}); + user = specHelper.newUser(); + user._id = "unique-user-id" + user.party._id = 'unique-party-id'; + user.sync = function(){}; + $provide.value('User', {user: user}); }); inject(function(_$httpBackend_, Groups, User) { $httpBackend = _$httpBackend_; groups = Groups; - user = User; - user.sync = function(){}; }); }); @@ -152,6 +154,6 @@ describe('groupServices', function() { done(); }); - $httpBackend.flush(); + $httpBackend.flush() }); }); diff --git a/test/spec/services/questServicesSpec.js b/test/spec/services/questServicesSpec.js index bf09847e9d..fd9dbf493f 100644 --- a/test/spec/services/questServicesSpec.js +++ b/test/spec/services/questServicesSpec.js @@ -8,6 +8,7 @@ describe('Quests Service', function() { user.ops = { buyQuest: sandbox.spy() }; + user.party._id = 'unique-party-id'; user.achievements.quests = {}; quest = {lvl:20}; From f1f18859def89ae2de37d686d81a4a785bf9ea67 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 May 2016 21:29:53 +0200 Subject: [PATCH 796/976] v3: fix todo scoring and try to fix production testing problem --- website/src/libs/api-v3/setupMongoose.js | 2 +- website/views/shared/tasks/task_view/index.jade | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/libs/api-v3/setupMongoose.js b/website/src/libs/api-v3/setupMongoose.js index ae1659d527..045821ea2c 100644 --- a/website/src/libs/api-v3/setupMongoose.js +++ b/website/src/libs/api-v3/setupMongoose.js @@ -13,7 +13,7 @@ let mongooseOptions = !IS_PROD ? {} : { replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, }; -let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { +let db = mongoose.connect(nconf.get('NODE_DB_URI'), {}, (err) => { if (err) throw err; logger.info('Connected with Mongoose.'); }); diff --git a/website/views/shared/tasks/task_view/index.jade b/website/views/shared/tasks/task_view/index.jade index 7a89fdaad1..16628bfa5d 100644 --- a/website/views/shared/tasks/task_view/index.jade +++ b/website/views/shared/tasks/task_view/index.jade @@ -30,7 +30,7 @@ span.task-checker.action-yesno(ng-if='::task.type=="daily" || task.type=="todo"') input.task-input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task._id}}', type='checkbox', ng-model='task.completed', ng-if='$state.includes("tasks")', - ng-change='task.type=="todo" && pushTask(task,$index,"bottom"); changeCheck(task)' + ng-change='changeCheck(task)' ui-keypress='{13:"task.completed = !task.completed; changeCheck(task)"}' ) input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task._id}}', type='checkbox', ng-if='!$state.includes("tasks")') From c7fb69b530a366fa8017aa29dab4873c1584892f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 May 2016 21:44:57 +0200 Subject: [PATCH 797/976] revert changes to mongoose config --- website/src/libs/api-v3/setupMongoose.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/libs/api-v3/setupMongoose.js b/website/src/libs/api-v3/setupMongoose.js index 045821ea2c..ae1659d527 100644 --- a/website/src/libs/api-v3/setupMongoose.js +++ b/website/src/libs/api-v3/setupMongoose.js @@ -13,7 +13,7 @@ let mongooseOptions = !IS_PROD ? {} : { replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, }; -let db = mongoose.connect(nconf.get('NODE_DB_URI'), {}, (err) => { +let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { if (err) throw err; logger.info('Connected with Mongoose.'); }); From e47ae45f9fdd5cef0ab25943b1d9f7455cb03af2 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 May 2016 22:43:38 +0200 Subject: [PATCH 798/976] mongoose: increase keepAlive --- website/src/libs/api-v3/setupMongoose.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/libs/api-v3/setupMongoose.js b/website/src/libs/api-v3/setupMongoose.js index ae1659d527..2bd21dca7b 100644 --- a/website/src/libs/api-v3/setupMongoose.js +++ b/website/src/libs/api-v3/setupMongoose.js @@ -10,8 +10,8 @@ const IS_PROD = nconf.get('IS_PROD'); mongoose.Promise = Bluebird; let mongooseOptions = !IS_PROD ? {} : { - replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, - server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, + replset: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, + server: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, }; let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { if (err) throw err; From b5a7f8e3a1e0a64668792ed59080bece459f6e19 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 12 May 2016 23:12:11 +0200 Subject: [PATCH 799/976] test mongoose fix --- website/src/libs/api-v3/setupMongoose.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/libs/api-v3/setupMongoose.js b/website/src/libs/api-v3/setupMongoose.js index 2bd21dca7b..48e634331e 100644 --- a/website/src/libs/api-v3/setupMongoose.js +++ b/website/src/libs/api-v3/setupMongoose.js @@ -9,11 +9,11 @@ const IS_PROD = nconf.get('IS_PROD'); // Use Q promises instead of mpromise in mongoose mongoose.Promise = Bluebird; -let mongooseOptions = !IS_PROD ? {} : { +let mongooseOptions = true ? {} : { replset: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, server: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, }; -let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { +let db = mongoose.connect(nconf.get('NODE_DB_URI'), (err) => { if (err) throw err; logger.info('Connected with Mongoose.'); }); From de1f2c2788e8760156ff7baac53c122a756ad9b6 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 12 May 2016 18:57:49 -0500 Subject: [PATCH 800/976] fix: Only apply captureStackTrace if it exists on the error object --- common/script/libs/errors.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/common/script/libs/errors.js b/common/script/libs/errors.js index 0aca2efd1f..cb780d215b 100644 --- a/common/script/libs/errors.js +++ b/common/script/libs/errors.js @@ -5,7 +5,10 @@ import extendableBuiltin from './extendableBuiltin'; export class CustomError extends extendableBuiltin(Error) { constructor () { super(); - Error.captureStackTrace(this, this.constructor); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } } } From 9514e9c5a81e61bddd14be3187fad1c9a320dcec Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 May 2016 10:34:09 +0200 Subject: [PATCH 801/976] v3: fix reminders with no startDate --- website/src/libs/api-v3/setupMongoose.js | 2 +- website/src/models/task.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/libs/api-v3/setupMongoose.js b/website/src/libs/api-v3/setupMongoose.js index 48e634331e..d9a1c00ba8 100644 --- a/website/src/libs/api-v3/setupMongoose.js +++ b/website/src/libs/api-v3/setupMongoose.js @@ -9,7 +9,7 @@ const IS_PROD = nconf.get('IS_PROD'); // Use Q promises instead of mpromise in mongoose mongoose.Promise = Bluebird; -let mongooseOptions = true ? {} : { +let mongooseOptions = !IS_PROD ? {} : { replset: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, server: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, }; diff --git a/website/src/models/task.js b/website/src/models/task.js index c7a8dca98c..6d4143e80d 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -47,7 +47,7 @@ export let TaskSchema = new Schema({ reminders: [{ _id: false, id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], default: shared.uuid, required: true}, - startDate: {type: Date, required: true}, + startDate: {type: Date}, time: {type: Date, required: true}, }], }, _.defaults({ From 486b93a3c9d290f259019c23763d0c06cd114651 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 May 2016 10:42:48 +0200 Subject: [PATCH 802/976] mongoose: use options --- website/src/libs/api-v3/setupMongoose.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/libs/api-v3/setupMongoose.js b/website/src/libs/api-v3/setupMongoose.js index d9a1c00ba8..2bd21dca7b 100644 --- a/website/src/libs/api-v3/setupMongoose.js +++ b/website/src/libs/api-v3/setupMongoose.js @@ -13,7 +13,7 @@ let mongooseOptions = !IS_PROD ? {} : { replset: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, server: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, }; -let db = mongoose.connect(nconf.get('NODE_DB_URI'), (err) => { +let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { if (err) throw err; logger.info('Connected with Mongoose.'); }); From 199732539fc2241edcbfafdd2eb4b01358ba2eed Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 May 2016 15:35:12 +0200 Subject: [PATCH 803/976] chore(): rename website/src -> website/server and website/public -> website/client (#7199) --- .bowerrc | 2 +- .eslintignore | 14 ++--- .gitignore | 12 ++-- .nodemonignore | 2 +- Gruntfile.js | 26 ++++---- bower.json | 2 +- common/script/content/spells.js | 2 +- common/script/cron.js | 2 +- karma.conf.js | 56 +++++++++--------- migrations/api_v3/challenges.js | 6 +- migrations/api_v3/challengesMembers.js | 2 +- migrations/api_v3/coupons.js | 4 +- migrations/api_v3/emailUnsubscriptions.js | 4 +- migrations/api_v3/groups.js | 6 +- migrations/api_v3/users.js | 6 +- migrations/manual_password_reset.js | 2 +- package.json | 4 +- tasks/gulp-apidoc.js | 2 +- tasks/gulp-build.js | 4 +- tasks/gulp-console.js | 8 +-- tasks/gulp-newstuff.js | 2 +- tasks/gulp-start.js | 2 +- tasks/gulp-tests.js | 14 ++--- test/api-legacy/api-helper.js | 2 +- test/api-legacy/challenges.js | 6 +- test/api-legacy/chat.js | 4 +- test/api-legacy/coupons.js | 4 +- test/api-legacy/inAppPurchases.js | 4 +- test/api-legacy/party.js | 4 +- test/api-legacy/pushNotifications.js | 10 ++-- test/api-legacy/score.js | 2 +- test/api-legacy/subscriptions.js | 4 +- test/api-legacy/todos.js | 2 +- test/api/v2/groups/GET-groups.test.js | 2 +- .../emails/GET-email-unsubscribe.test.js | 2 +- .../v3/integration/groups/GET-groups.test.js | 2 +- .../user/auth/POST-register_local.test.js | 2 +- .../api/v3/unit/libs/analyticsService.test.js | 2 +- test/api/v3/unit/libs/baseModel.test.js | 2 +- test/api/v3/unit/libs/buildManifest.test.js | 2 +- .../unit/libs/collectionManipulators.test.js | 2 +- test/api/v3/unit/libs/cron.test.js | 6 +- test/api/v3/unit/libs/email.test.js | 4 +- test/api/v3/unit/libs/encryption.test.js | 2 +- test/api/v3/unit/libs/errors.test.js | 2 +- test/api/v3/unit/libs/i18n.test.js | 2 +- test/api/v3/unit/libs/logger.js | 2 +- test/api/v3/unit/libs/password.test.js | 2 +- test/api/v3/unit/libs/payments.test.js | 6 +- test/api/v3/unit/libs/preening.test.js | 2 +- test/api/v3/unit/libs/setupNconf.test.js | 2 +- test/api/v3/unit/libs/webhooks.test.js | 2 +- .../api/v3/unit/middlewares/analytics.test.js | 4 +- test/api/v3/unit/middlewares/cors.test.js | 2 +- .../api/v3/unit/middlewares/cronMiddleware.js | 10 ++-- .../middlewares/ensureAccessRight.test.js | 4 +- .../unit/middlewares/ensureDevelpmentMode.js | 4 +- .../v3/unit/middlewares/errorHandler.test.js | 10 ++-- test/api/v3/unit/middlewares/language.test.js | 4 +- test/api/v3/unit/middlewares/response.js | 2 +- test/api/v3/unit/models/challenge.test.js | 8 +-- test/api/v3/unit/models/group.test.js | 6 +- test/api/v3/unit/models/task.test.js | 8 +-- test/api/v3/unit/models/user.test.js | 2 +- test/helpers/api-integration/translate.js | 2 +- .../api-integration/v3/object-generators.js | 2 +- test/helpers/api-unit.helper.js | 8 +-- test/helpers/common.helper.js | 4 +- test/helpers/content.helper.js | 2 +- test/helpers/globals.helper.js | 6 +- test/helpers/mongo.js | 2 +- ...50605_ultimate_achievement_backfill.coffee | 2 +- test/server_side/analytics.test.js | 6 +- test/server_side/controllers/groups.test.js | 8 +-- test/server_side/controllers/user.test.js | 8 +-- test/server_side/webhooks.test.js | 2 +- website/{public => client}/500.html | 0 .../apple-touch-icon-114-precomposed.png | Bin .../apple-touch-icon-144-precomposed.png | Bin .../apple-touch-icon-57-precomposed.png | Bin .../apple-touch-icon-72-precomposed.png | Bin .../apple-touch-icon-precomposed.png | Bin website/{public => client}/cake.png | Bin .../backCorner.png | Bin .../beingHabitican.png | Bin .../consequences.png | Bin .../contributing.png | Bin .../community-guidelines-images/github.gif | Bin .../infractions.png | Bin .../community-guidelines-images/intro.png | Bin .../moderators.png | Bin .../publicGuilds.png | Bin .../publicSpaces.png | Bin .../restoration.png | Bin .../community-guidelines-images/staff.png | Bin .../community-guidelines-images/tavern.png | Bin .../community-guidelines-images/trello.png | Bin .../community-guidelines-images/wiki.png | Bin website/{public => client}/css/README.md | 0 website/{public => client}/css/alerts.styl | 0 website/{public => client}/css/avatar.styl | 0 .../{public => client}/css/challenges.styl | 0 website/{public => client}/css/classes.styl | 0 .../{public => client}/css/customizer.styl | 0 website/{public => client}/css/filters.styl | 0 website/{public => client}/css/footer.styl | 0 website/{public => client}/css/game-pane.styl | 0 .../{public => client}/css/global-colors.styl | 0 .../css/global-modules.styl | 0 website/{public => client}/css/header.styl | 0 website/{public => client}/css/helpers.styl | 0 website/{public => client}/css/index.styl | 0 website/{public => client}/css/inventory.styl | 0 website/{public => client}/css/items.styl | 0 website/{public => client}/css/menu.styl | 0 website/{public => client}/css/no-script.styl | 0 website/{public => client}/css/npcs.styl | 0 website/{public => client}/css/options.styl | 0 website/{public => client}/css/quests.styl | 0 .../{public => client}/css/scrollbars.styl | 0 website/{public => client}/css/shared.styl | 0 website/{public => client}/css/static.styl | 0 website/{public => client}/css/tasks.styl | 0 .../css/variables/screen-size.styl | 0 .../emails/images/10-days-recapture-v1.png | Bin .../images/3-days-1-month-recapture-v1.png | Bin .../images/PROMO-Enchanted-Armoire-v1.png | Bin .../emails/images/android-promo-v1.png | Bin .../emails/images/iphone-promo-v1.png | Bin .../emails/images/one-day-v1.png | Bin .../emails/images/spring-2015-00-v1.png | Bin .../emails/images/spring-2015-01-v1.png | Bin .../subscription-begins-time-travelers-v1.png | Bin .../emails/images/subscription-begins-v1.png | Bin website/{public => client}/favicon.ico | Bin .../{public => client}/favicon_192x192.png | Bin .../{public => client}/fontello/LICENSE.txt | 0 .../{public => client}/fontello/README.txt | 0 .../fontello/css/animation.css | 0 .../fontello/css/fontelico-codes.css | 0 .../fontello/css/fontelico-embedded.css | 0 .../fontello/css/fontelico-ie7-codes.css | 0 .../fontello/css/fontelico-ie7.css | 0 .../fontello/css/fontelico.css | 0 website/{public => client}/fontello/demo.html | 0 .../fontello/font/fontelico.eot | Bin .../fontello/font/fontelico.svg | 0 .../fontello/font/fontelico.ttf | Bin .../fontello/font/fontelico.woff | Bin website/{public => client}/front/README.md | 0 .../front/css/blockScroll.css | 0 .../front/css/bootstrap.min.css | 0 .../front/css/fixed-positioning.css | 0 .../fonts/glyphicons-halflings-regular.eot | Bin .../fonts/glyphicons-halflings-regular.svg | 0 .../fonts/glyphicons-halflings-regular.ttf | Bin .../fonts/glyphicons-halflings-regular.woff | Bin .../fonts/glyphicons-halflings-regular.woff2 | Bin .../front/images/Feeding_Time.png | Bin .../front/images/Guilds Sample Screen.png | Bin .../front/images/HabitRPGPromoPostCard6.png | Bin .../front/images/HabitRPGPromoThin.png | Bin .../Habitica_banner_by_uncommoncriminal.png | Bin .../Habitica_map_by_uncommoncriminal.png | Bin .../front/images/Healer.png | Bin .../{public => client}/front/images/Mount.png | Bin .../front/images/Mount_Body_Dragon-Golden.png | Bin .../front/images/Mount_Body_Dragon-Red.png | Bin .../front/images/Mount_Body_Wolf-Base.png | Bin .../front/images/Mount_Head_Dragon-Golden.png | Bin .../front/images/Mount_Head_Dragon-Red.png | Bin .../front/images/Mount_Head_Wolf-Base.png | Bin .../front/images/Party-Header.png | Bin .../front/images/Pet-Dragon-Red.png | Bin .../front/images/Pet-Fox-Red.png | Bin .../front/images/Promo_springclasses2015.png | Bin .../front/images/Quest_dilatory_drag'on.png | Bin .../images/Quest_dilatory_drag'onSmall.png | Bin .../{public => client}/front/images/Rogue.png | Bin .../front/images/SAMPLEadventurers.png | Bin .../front/images/TVreward.png | Bin .../front/images/VICE_by_Baconsaur.png | Bin .../front/images/Warrior.png | Bin .../front/images/Wizard.png | Bin .../front/images/achievement-perfect.png | Bin .../front/images/achievement-triadbingo.png | Bin .../front/images/avatar/Warrior.png | Bin .../front/images/avatar/avatar.png | Bin .../front/images/avatar/avatarstatic.png | Bin .../images/avatar/hair_bangs_1_brown.png | Bin .../front/images/avatar/head_0.png | Bin .../front/images/avatar/head_warrior_3.png | Bin .../front/images/avatar/head_warrior_5.png | Bin .../front/images/avatar/shield_warrior_3.png | Bin .../front/images/avatar/shield_warrior_5.png | Bin .../front/images/avatar/skin_f5a76e.png | Bin .../images/avatar/slim_armor_warrior_3.png | Bin .../images/avatar/slim_armor_warrior_5.png | Bin .../front/images/avatar/slim_shirt_black.png | Bin .../front/images/avatar/weapon_healer_6.png | Bin .../front/images/avatar/weapon_warrior_3.png | Bin .../front/images/avatar/weapon_warrior_5.png | Bin .../blackish_fox_by_kellllly-d7pzd46.png | Bin .../front/images/coding_by_phoneix_faerie.png | Bin .../front/images/devices.png | Bin .../front/images/explosion.jpg | Bin .../front/images/explosion.png | Bin .../front/images/habitrpg_pixel.png | Bin .../front/images/icon175x175.png | Bin .../{public => client}/front/images/intro.jpg | Bin .../{public => client}/front/images/intro.psd | Bin .../front/images/misc/Pet_Food_Cake_Base.png | Bin .../misc/inventory_quest_scroll_harpy.png | Bin .../front/images/misc/rebirth_orb.png | Bin .../front/images/misc/shop_gold.png | Bin .../front/images/misc/shop_potion.png | Bin ...or_habit_by_cosmic_caterpillar-d8mf5mb.png | Bin .../front/images/party/AnnaCosplay.png | Bin .../front/images/party/Ariel_cosplay.png | Bin .../images/party/Big_Daddy_(BioShock).png | Bin .../party/Cosplay_Daenerys_Targaryen.png | Bin .../front/images/party/GrimReaper.png | Bin .../front/images/party/HomeStuckLusus.png | Bin .../front/images/presslogos/Cnetlogo.png | Bin .../images/presslogos/Fast-Company-logo.png | Bin .../front/images/presslogos/Forbes_logo.png | Bin .../front/images/presslogos/GitHub_Logo.png | Bin .../front/images/presslogos/discover_logo.png | Bin .../images/presslogos/ionic-logo-blog.png | Bin .../ionic-logo-horizontal-transparent.png | Bin .../images/presslogos/kickstarter-logo.png | Bin .../landing_slack_hash_wordmark_logo.png | Bin .../front/images/presslogos/lifehacker.png | Bin .../front/images/presslogos/logo_webstorm.png | Bin .../front/images/presslogos/makeuseof.png | Bin .../front/images/presslogos/nyt-logo.png | Bin .../front/images/presslogos/slack.png | Bin .../images/presslogos/trello-logo-blue.png | Bin .../front/images/quest_vice3.png | Bin .../front/images/screenshot.png | Bin .../t_bone_fight_2_by_mortquitue-d8dtxbl.png | Bin .../front/images/testimonial_by_Streak.png | Bin .../front/images/testimonials/16bitFil.png | Bin .../front/images/testimonials/AlexandraSo.png | Bin .../front/images/testimonials/Althaire.png | Bin .../front/images/testimonials/AndeeLiao.png | Bin .../front/images/testimonials/Brenna.png | Bin .../images/testimonials/Drag0nsilver.png | Bin .../front/images/testimonials/Drei-M.png | Bin .../front/images/testimonials/Elmi.png | Bin .../front/images/testimonials/EvaGantz.png | Bin .../front/images/testimonials/Helcura.png | Bin .../front/images/testimonials/InfH.png | Bin .../front/images/testimonials/Kai.png | Bin .../front/images/testimonials/Kazui.png | Bin .../front/images/testimonials/Zelah_Meyer.png | Bin .../images/testimonials/autumnesquirrel.png | Bin .../images/testimonials/frabjabulous.png | Bin .../front/images/testimonials/galarix.png | Bin .../front/images/testimonials/gwyn.blath.png | Bin .../images/testimonials/irishfeet123.png | Bin .../front/images/testimonials/skysailor.png | Bin .../images/testimonials/supermouse35.png | Bin .../images/testimonials/tonitonirocca.png | Bin .../front/images/uses/achievement-bkgd.png | Bin .../uses/clipart-rosemonkeyct-meditation.png | Bin .../uses/clipart-rosemonkeyct-meditation.psd | Bin .../uses/clipart-rosemonkeyct-reading.png | Bin .../front/images/uses/coding.png | Bin .../coding_3_by_phoneix_faerie-d7idtti.png | Bin .../front/images/uses/consequences.png | Bin .../front/images/uses/dusting-bkgd.png | Bin .../front/images/uses/dusting_by_leephon.png | Bin ...ievement_by_cosmic_caterpillar-d7uyv5z.png | Bin .../front/images/uses/meditation-bkgd.png | Bin .../front/images/uses/publicSpaces.png | Bin .../front/images/uses/reading.png | Bin .../front/js/blockScroll.js | 0 .../front/js/bootstrap.min.js | 0 .../front/js/skrollr.min.js | 0 .../front/landingv1Wireframe.jpg | Bin .../{public => client}/front/staticstyle.css | 0 website/{public => client}/front/style.css | 0 .../google280633b772b94345.html | 0 .../google8ca65b6ff3506fb8.html | 0 .../googlef3b1402b0e28338a.html | 0 website/{public => client}/js/.eslintrc | 0 website/{public => client}/js/app.js | 0 .../js/controllers/authCtrl.js | 0 .../js/controllers/autoCompleteCtrl.js | 0 .../js/controllers/challengesCtrl.js | 0 .../js/controllers/chatCtrl.js | 0 .../js/controllers/copyMessageModalCtrl.js | 0 .../js/controllers/filtersCtrl.js | 0 .../js/controllers/footerCtrl.js | 0 .../js/controllers/groupsCtrl.js | 0 .../js/controllers/guildsCtrl.js | 0 .../js/controllers/hallCtrl.js | 0 .../js/controllers/headerCtrl.js | 0 .../js/controllers/inventoryCtrl.js | 0 .../js/controllers/inviteToGroupCtrl.js | 0 .../js/controllers/memberModalCtrl.js | 0 .../js/controllers/menuCtrl.js | 0 .../js/controllers/notificationCtrl.js | 0 .../js/controllers/partyCtrl.js | 0 .../js/controllers/rootCtrl.js | 0 .../js/controllers/settingsCtrl.js | 0 .../js/controllers/sortableInventoryCtrl.js | 0 .../js/controllers/tasksCtrl.js | 0 .../js/controllers/tavernCtrl.js | 0 .../js/controllers/userCtrl.js | 0 .../js/directives/close-menu.directive.js | 0 .../js/directives/expand-menu.directive.js | 0 .../js/directives/focus-element.directive.js | 0 .../js/directives/from-now.directive.js | 0 .../js/directives/habitrpg-tasks.directive.js | 0 .../hrpg-sort-checklist.directive.js | 0 .../js/directives/hrpg-sort-tags.directive.js | 0 .../directives/hrpg-sort-tasks.directive.js | 0 .../popover-html-popup.directive.js | 0 .../js/directives/popover-html.directive.js | 0 .../js/directives/when-scrolled.directive.js | 0 website/{public => client}/js/env.js | 0 .../{public => client}/js/filters/money.js | 0 .../js/filters/roundLargeNumbers.js | 0 .../js/filters/taskOrdering.js | 0 .../js/filters/timezoneOffsetToUtc.js | 0 .../js/services/analyticsServices.js | 0 .../js/services/challengeServices.js | 0 .../js/services/chatServices.js | 0 .../js/services/groupServices.js | 0 .../js/services/guideServices.js | 0 .../js/services/memberServices.js | 0 .../js/services/notificationServices.js | 0 .../js/services/paymentServices.js | 0 .../js/services/questServices.js | 0 .../js/services/sharedServices.js | 0 .../js/services/socialServices.js | 0 .../js/services/statServices.js | 0 .../js/services/tagsServices.js | 0 .../js/services/taskServices.js | 0 .../js/services/userServices.js | 0 website/{public => client}/js/static.js | 0 website/{public => client}/logo.png | Bin .../logo/HABITRPG logo version 1.psd | Bin .../logo/HABITRPG-logo-version-1.gif | Bin website/{public => client}/logo/habitrpg.jpg | Bin .../{public => client}/logo/habitrpg_bl.eps | 0 .../logo/habitrpg_pixel.png | Bin website/{public => client}/manifest.json | 0 .../marketing/android_iphone.png | Bin .../{public => client}/marketing/animals.png | Bin .../marketing/challenge.png | Bin .../{public => client}/marketing/devices.png | Bin .../{public => client}/marketing/drops.png | Bin .../marketing/education.png | Bin website/{public => client}/marketing/gear.png | Bin .../{public => client}/marketing/guild.png | Bin .../marketing/guild_small.png | Bin .../marketing/integration.png | Bin .../{public => client}/marketing/lefnire.png | Bin .../marketing/promos/201403_Forest_Walker.png | Bin .../marketing/promos/April14SAMPLE2.png | Bin .../marketing/screenshot.png | Bin .../marketing/social_competitve.png | Bin .../{public => client}/marketing/wellness.png | Bin .../merch/stickermule-logo.png | Bin .../merch/stickermule-logo.svg | 0 .../{public => client}/merch/stickermule.png | Bin .../merch/teespring-eu-logo.png | Bin .../{public => client}/merch/teespring-eu.png | Bin .../merch/teespring-logo.png | Bin .../merch/teespring-logo.svg | 0 .../{public => client}/merch/teespring.png | Bin website/{public => client}/page-loader.gif | Bin .../presskit/Boss - Basi-List.png | Bin .../Boss - Battling the Ghost Stag.png | Bin .../presskit/Boss - Laundromancer.png | Bin .../presskit/Boss - Necro-Vice.png | Bin .../presskit/Boss - SnackLess Monster.png | Bin .../presskit/Boss - Stagnant Dishes.png | Bin .../presskit/Habitica Gryphon.png | Bin .../presskit/Habitica Logo - Android.png | Bin .../Habitica Logo - Icon with Text.png | Bin .../presskit/Habitica Logo - Icon.png | Bin .../presskit/Habitica Logo - Text.png | Bin .../presskit/Habitica Logo - iOS.png | Bin .../presskit/Habitica Promo - Thin.png | Bin .../presskit/Habitica Promo.png | Bin .../presskit/Sample Screen - Boss (iOS).png | Bin .../presskit/Sample Screen - Challenges.png | Bin .../presskit/Sample Screen - Equipment.png | Bin .../presskit/Sample Screen - Guilds.png | Bin .../Sample Screen - Level Up (iOS).png | Bin .../presskit/Sample Screen - Market.png | Bin .../presskit/Sample Screen - Party (iOS).png | Bin .../presskit/Sample Screen - Pets (iOS).png | Bin .../Sample Screen - Tasks Page (iOS).png | Bin .../presskit/Sample Screen - Tasks Page.png | Bin ...World Boss - Dread Drag'on of Dilatory.png | Bin .../{public => client}/presskit/presskit.zip | Bin website/{public => client}/refresh.png | Bin .../controllers/api-v2/auth.js | 0 .../controllers/api-v2/challenges.js | 0 .../controllers/api-v2/coupon.js | 0 .../controllers/api-v2/dataexport.js | 0 .../controllers/api-v2/groups.js | 0 .../controllers/api-v2/hall.js | 0 .../controllers/api-v2/members.js | 0 .../controllers/api-v2/pushNotifications.js | 0 .../controllers/api-v2/unsubscription.js | 0 .../controllers/api-v2/user.js | 0 .../controllers/api-v3/auth.js | 0 .../controllers/api-v3/challenges.js | 0 .../controllers/api-v3/chat.js | 0 .../controllers/api-v3/content.js | 0 .../controllers/api-v3/coupon.js | 0 .../controllers/api-v3/debug.js | 0 .../controllers/api-v3/email.js | 0 .../controllers/api-v3/groups.js | 0 .../controllers/api-v3/hall.js | 0 .../controllers/api-v3/members.js | 0 .../controllers/api-v3/modelsPaths.js | 0 .../controllers/api-v3/quests.js | 0 .../controllers/api-v3/status.js | 0 .../controllers/api-v3/tags.js | 0 .../controllers/api-v3/tasks.js | 0 .../controllers/api-v3/user.js | 0 .../controllers/top-level/auth.js | 0 .../controllers/top-level/dataexport.js | 0 .../controllers/top-level/pages.js | 0 .../controllers/top-level/payments/amazon.js | 0 .../controllers/top-level/payments/iap.js | 0 .../controllers/top-level/payments/paypal.js | 0 .../controllers/top-level/payments/stripe.js | 0 website/{src => server}/index.js | 0 .../{src => server}/libs/api-v2/analytics.js | 0 .../libs/api-v2/buildManifest.js | 4 +- .../{src => server}/libs/api-v2/firebase.js | 0 website/{src => server}/libs/api-v2/i18n.js | 0 .../{src => server}/libs/api-v2/logging.js | 0 website/{src => server}/libs/api-v2/utils.js | 0 .../{src => server}/libs/api-v2/webhook.js | 0 .../libs/api-v3/amazonPayments.js | 0 .../libs/api-v3/analyticsService.js | 0 .../{src => server}/libs/api-v3/baseModel.js | 0 .../libs/api-v3/buildManifest.js | 4 +- .../libs/api-v3/collectionManipulators.js | 0 website/{src => server}/libs/api-v3/cron.js | 0 .../libs/api-v3/csvStringify.js | 0 website/{src => server}/libs/api-v3/email.js | 0 .../{src => server}/libs/api-v3/encryption.js | 0 website/{src => server}/libs/api-v3/errors.js | 0 .../{src => server}/libs/api-v3/firebase.js | 0 website/{src => server}/libs/api-v3/i18n.js | 0 website/{src => server}/libs/api-v3/logger.js | 0 .../{src => server}/libs/api-v3/password.js | 0 .../{src => server}/libs/api-v3/payments.js | 0 .../{src => server}/libs/api-v3/preening.js | 0 .../libs/api-v3/pushNotifications.js | 0 website/{src => server}/libs/api-v3/routes.js | 0 .../libs/api-v3/setupMongoose.js | 0 .../{src => server}/libs/api-v3/setupNconf.js | 0 .../libs/api-v3/setupPassport.js | 0 .../{src => server}/libs/api-v3/webhook.js | 0 .../middlewares/api-v2/domain.js | 0 .../middlewares/api-v2/errorHandler.js | 0 .../middlewares/api-v2/locals.js | 0 .../middlewares/api-v3/analytics.js | 0 .../middlewares/api-v3/auth.js | 0 .../middlewares/api-v3/cors.js | 0 .../middlewares/api-v3/cron.js | 0 .../middlewares/api-v3/domain.js | 0 .../middlewares/api-v3/ensureAccessRight.js | 0 .../api-v3/ensureDevelpmentMode.js | 0 .../middlewares/api-v3/errorHandler.js | 0 .../middlewares/api-v3/index.js | 2 +- .../middlewares/api-v3/language.js | 0 .../middlewares/api-v3/locals.js | 0 .../middlewares/api-v3/notFound.js | 0 .../middlewares/api-v3/redirects.js | 0 .../middlewares/api-v3/response.js | 0 .../middlewares/api-v3/setupBody.js | 0 .../middlewares/api-v3/static.js | 2 +- .../{src => server}/middlewares/api-v3/v1.js | 0 .../{src => server}/middlewares/api-v3/v2.js | 0 .../{src => server}/middlewares/api-v3/v3.js | 0 .../middlewares/apiThrottle.js | 0 .../middlewares/forceRefresh.js | 0 website/{src => server}/models/challenge.js | 0 website/{src => server}/models/coupon.js | 0 .../models/emailUnsubscription.js | 0 website/{src => server}/models/group.js | 0 website/{src => server}/models/tag.js | 0 website/{src => server}/models/task.js | 0 website/{src => server}/models/user.js | 0 website/{src => server}/routes/api-v2/auth.js | 0 .../{src => server}/routes/api-v2/coupon.js | 0 .../{src => server}/routes/api-v2/swagger.js | 0 .../routes/api-v2/unsubscription.js | 0 website/{src => server}/routes/pages.js | 0 website/{src => server}/routes/payments.js | 0 website/{src => server}/server.js | 0 website/views/static/api.jade | 2 +- 504 files changed, 202 insertions(+), 202 deletions(-) rename website/{public => client}/500.html (100%) rename website/{public => client}/apple-touch-icon-114-precomposed.png (100%) rename website/{public => client}/apple-touch-icon-144-precomposed.png (100%) rename website/{public => client}/apple-touch-icon-57-precomposed.png (100%) rename website/{public => client}/apple-touch-icon-72-precomposed.png (100%) rename website/{public => client}/apple-touch-icon-precomposed.png (100%) rename website/{public => client}/cake.png (100%) rename website/{public => client}/community-guidelines-images/backCorner.png (100%) rename website/{public => client}/community-guidelines-images/beingHabitican.png (100%) rename website/{public => client}/community-guidelines-images/consequences.png (100%) rename website/{public => client}/community-guidelines-images/contributing.png (100%) rename website/{public => client}/community-guidelines-images/github.gif (100%) rename website/{public => client}/community-guidelines-images/infractions.png (100%) rename website/{public => client}/community-guidelines-images/intro.png (100%) rename website/{public => client}/community-guidelines-images/moderators.png (100%) rename website/{public => client}/community-guidelines-images/publicGuilds.png (100%) rename website/{public => client}/community-guidelines-images/publicSpaces.png (100%) rename website/{public => client}/community-guidelines-images/restoration.png (100%) rename website/{public => client}/community-guidelines-images/staff.png (100%) rename website/{public => client}/community-guidelines-images/tavern.png (100%) rename website/{public => client}/community-guidelines-images/trello.png (100%) rename website/{public => client}/community-guidelines-images/wiki.png (100%) rename website/{public => client}/css/README.md (100%) rename website/{public => client}/css/alerts.styl (100%) rename website/{public => client}/css/avatar.styl (100%) rename website/{public => client}/css/challenges.styl (100%) rename website/{public => client}/css/classes.styl (100%) rename website/{public => client}/css/customizer.styl (100%) rename website/{public => client}/css/filters.styl (100%) rename website/{public => client}/css/footer.styl (100%) rename website/{public => client}/css/game-pane.styl (100%) rename website/{public => client}/css/global-colors.styl (100%) rename website/{public => client}/css/global-modules.styl (100%) rename website/{public => client}/css/header.styl (100%) rename website/{public => client}/css/helpers.styl (100%) rename website/{public => client}/css/index.styl (100%) rename website/{public => client}/css/inventory.styl (100%) rename website/{public => client}/css/items.styl (100%) rename website/{public => client}/css/menu.styl (100%) rename website/{public => client}/css/no-script.styl (100%) rename website/{public => client}/css/npcs.styl (100%) rename website/{public => client}/css/options.styl (100%) rename website/{public => client}/css/quests.styl (100%) rename website/{public => client}/css/scrollbars.styl (100%) rename website/{public => client}/css/shared.styl (100%) rename website/{public => client}/css/static.styl (100%) rename website/{public => client}/css/tasks.styl (100%) rename website/{public => client}/css/variables/screen-size.styl (100%) rename website/{public => client}/emails/images/10-days-recapture-v1.png (100%) rename website/{public => client}/emails/images/3-days-1-month-recapture-v1.png (100%) rename website/{public => client}/emails/images/PROMO-Enchanted-Armoire-v1.png (100%) rename website/{public => client}/emails/images/android-promo-v1.png (100%) rename website/{public => client}/emails/images/iphone-promo-v1.png (100%) rename website/{public => client}/emails/images/one-day-v1.png (100%) rename website/{public => client}/emails/images/spring-2015-00-v1.png (100%) rename website/{public => client}/emails/images/spring-2015-01-v1.png (100%) rename website/{public => client}/emails/images/subscription-begins-time-travelers-v1.png (100%) rename website/{public => client}/emails/images/subscription-begins-v1.png (100%) rename website/{public => client}/favicon.ico (100%) rename website/{public => client}/favicon_192x192.png (100%) rename website/{public => client}/fontello/LICENSE.txt (100%) rename website/{public => client}/fontello/README.txt (100%) rename website/{public => client}/fontello/css/animation.css (100%) rename website/{public => client}/fontello/css/fontelico-codes.css (100%) rename website/{public => client}/fontello/css/fontelico-embedded.css (100%) rename website/{public => client}/fontello/css/fontelico-ie7-codes.css (100%) rename website/{public => client}/fontello/css/fontelico-ie7.css (100%) rename website/{public => client}/fontello/css/fontelico.css (100%) rename website/{public => client}/fontello/demo.html (100%) rename website/{public => client}/fontello/font/fontelico.eot (100%) rename website/{public => client}/fontello/font/fontelico.svg (100%) rename website/{public => client}/fontello/font/fontelico.ttf (100%) rename website/{public => client}/fontello/font/fontelico.woff (100%) rename website/{public => client}/front/README.md (100%) rename website/{public => client}/front/css/blockScroll.css (100%) rename website/{public => client}/front/css/bootstrap.min.css (100%) rename website/{public => client}/front/css/fixed-positioning.css (100%) rename website/{public => client}/front/fonts/glyphicons-halflings-regular.eot (100%) rename website/{public => client}/front/fonts/glyphicons-halflings-regular.svg (100%) rename website/{public => client}/front/fonts/glyphicons-halflings-regular.ttf (100%) rename website/{public => client}/front/fonts/glyphicons-halflings-regular.woff (100%) rename website/{public => client}/front/fonts/glyphicons-halflings-regular.woff2 (100%) rename website/{public => client}/front/images/Feeding_Time.png (100%) rename website/{public => client}/front/images/Guilds Sample Screen.png (100%) rename website/{public => client}/front/images/HabitRPGPromoPostCard6.png (100%) rename website/{public => client}/front/images/HabitRPGPromoThin.png (100%) rename website/{public => client}/front/images/Habitica_banner_by_uncommoncriminal.png (100%) rename website/{public => client}/front/images/Habitica_map_by_uncommoncriminal.png (100%) rename website/{public => client}/front/images/Healer.png (100%) rename website/{public => client}/front/images/Mount.png (100%) rename website/{public => client}/front/images/Mount_Body_Dragon-Golden.png (100%) rename website/{public => client}/front/images/Mount_Body_Dragon-Red.png (100%) rename website/{public => client}/front/images/Mount_Body_Wolf-Base.png (100%) rename website/{public => client}/front/images/Mount_Head_Dragon-Golden.png (100%) rename website/{public => client}/front/images/Mount_Head_Dragon-Red.png (100%) rename website/{public => client}/front/images/Mount_Head_Wolf-Base.png (100%) rename website/{public => client}/front/images/Party-Header.png (100%) rename website/{public => client}/front/images/Pet-Dragon-Red.png (100%) rename website/{public => client}/front/images/Pet-Fox-Red.png (100%) rename website/{public => client}/front/images/Promo_springclasses2015.png (100%) rename website/{public => client}/front/images/Quest_dilatory_drag'on.png (100%) rename website/{public => client}/front/images/Quest_dilatory_drag'onSmall.png (100%) rename website/{public => client}/front/images/Rogue.png (100%) rename website/{public => client}/front/images/SAMPLEadventurers.png (100%) rename website/{public => client}/front/images/TVreward.png (100%) rename website/{public => client}/front/images/VICE_by_Baconsaur.png (100%) rename website/{public => client}/front/images/Warrior.png (100%) rename website/{public => client}/front/images/Wizard.png (100%) rename website/{public => client}/front/images/achievement-perfect.png (100%) rename website/{public => client}/front/images/achievement-triadbingo.png (100%) rename website/{public => client}/front/images/avatar/Warrior.png (100%) rename website/{public => client}/front/images/avatar/avatar.png (100%) rename website/{public => client}/front/images/avatar/avatarstatic.png (100%) rename website/{public => client}/front/images/avatar/hair_bangs_1_brown.png (100%) rename website/{public => client}/front/images/avatar/head_0.png (100%) rename website/{public => client}/front/images/avatar/head_warrior_3.png (100%) rename website/{public => client}/front/images/avatar/head_warrior_5.png (100%) rename website/{public => client}/front/images/avatar/shield_warrior_3.png (100%) rename website/{public => client}/front/images/avatar/shield_warrior_5.png (100%) rename website/{public => client}/front/images/avatar/skin_f5a76e.png (100%) rename website/{public => client}/front/images/avatar/slim_armor_warrior_3.png (100%) rename website/{public => client}/front/images/avatar/slim_armor_warrior_5.png (100%) rename website/{public => client}/front/images/avatar/slim_shirt_black.png (100%) rename website/{public => client}/front/images/avatar/weapon_healer_6.png (100%) rename website/{public => client}/front/images/avatar/weapon_warrior_3.png (100%) rename website/{public => client}/front/images/avatar/weapon_warrior_5.png (100%) rename website/{public => client}/front/images/blackish_fox_by_kellllly-d7pzd46.png (100%) rename website/{public => client}/front/images/coding_by_phoneix_faerie.png (100%) rename website/{public => client}/front/images/devices.png (100%) rename website/{public => client}/front/images/explosion.jpg (100%) rename website/{public => client}/front/images/explosion.png (100%) rename website/{public => client}/front/images/habitrpg_pixel.png (100%) rename website/{public => client}/front/images/icon175x175.png (100%) rename website/{public => client}/front/images/intro.jpg (100%) rename website/{public => client}/front/images/intro.psd (100%) rename website/{public => client}/front/images/misc/Pet_Food_Cake_Base.png (100%) rename website/{public => client}/front/images/misc/inventory_quest_scroll_harpy.png (100%) rename website/{public => client}/front/images/misc/rebirth_orb.png (100%) rename website/{public => client}/front/images/misc/shop_gold.png (100%) rename website/{public => client}/front/images/misc/shop_potion.png (100%) rename website/{public => client}/front/images/mockup_for_habit_by_cosmic_caterpillar-d8mf5mb.png (100%) rename website/{public => client}/front/images/party/AnnaCosplay.png (100%) rename website/{public => client}/front/images/party/Ariel_cosplay.png (100%) rename website/{public => client}/front/images/party/Big_Daddy_(BioShock).png (100%) rename website/{public => client}/front/images/party/Cosplay_Daenerys_Targaryen.png (100%) rename website/{public => client}/front/images/party/GrimReaper.png (100%) rename website/{public => client}/front/images/party/HomeStuckLusus.png (100%) rename website/{public => client}/front/images/presslogos/Cnetlogo.png (100%) rename website/{public => client}/front/images/presslogos/Fast-Company-logo.png (100%) rename website/{public => client}/front/images/presslogos/Forbes_logo.png (100%) rename website/{public => client}/front/images/presslogos/GitHub_Logo.png (100%) rename website/{public => client}/front/images/presslogos/discover_logo.png (100%) rename website/{public => client}/front/images/presslogos/ionic-logo-blog.png (100%) rename website/{public => client}/front/images/presslogos/ionic-logo-horizontal-transparent.png (100%) rename website/{public => client}/front/images/presslogos/kickstarter-logo.png (100%) rename website/{public => client}/front/images/presslogos/landing_slack_hash_wordmark_logo.png (100%) rename website/{public => client}/front/images/presslogos/lifehacker.png (100%) rename website/{public => client}/front/images/presslogos/logo_webstorm.png (100%) rename website/{public => client}/front/images/presslogos/makeuseof.png (100%) rename website/{public => client}/front/images/presslogos/nyt-logo.png (100%) rename website/{public => client}/front/images/presslogos/slack.png (100%) rename website/{public => client}/front/images/presslogos/trello-logo-blue.png (100%) rename website/{public => client}/front/images/quest_vice3.png (100%) rename website/{public => client}/front/images/screenshot.png (100%) rename website/{public => client}/front/images/t_bone_fight_2_by_mortquitue-d8dtxbl.png (100%) rename website/{public => client}/front/images/testimonial_by_Streak.png (100%) rename website/{public => client}/front/images/testimonials/16bitFil.png (100%) rename website/{public => client}/front/images/testimonials/AlexandraSo.png (100%) rename website/{public => client}/front/images/testimonials/Althaire.png (100%) rename website/{public => client}/front/images/testimonials/AndeeLiao.png (100%) rename website/{public => client}/front/images/testimonials/Brenna.png (100%) rename website/{public => client}/front/images/testimonials/Drag0nsilver.png (100%) rename website/{public => client}/front/images/testimonials/Drei-M.png (100%) rename website/{public => client}/front/images/testimonials/Elmi.png (100%) rename website/{public => client}/front/images/testimonials/EvaGantz.png (100%) rename website/{public => client}/front/images/testimonials/Helcura.png (100%) rename website/{public => client}/front/images/testimonials/InfH.png (100%) rename website/{public => client}/front/images/testimonials/Kai.png (100%) rename website/{public => client}/front/images/testimonials/Kazui.png (100%) rename website/{public => client}/front/images/testimonials/Zelah_Meyer.png (100%) rename website/{public => client}/front/images/testimonials/autumnesquirrel.png (100%) rename website/{public => client}/front/images/testimonials/frabjabulous.png (100%) rename website/{public => client}/front/images/testimonials/galarix.png (100%) rename website/{public => client}/front/images/testimonials/gwyn.blath.png (100%) rename website/{public => client}/front/images/testimonials/irishfeet123.png (100%) rename website/{public => client}/front/images/testimonials/skysailor.png (100%) rename website/{public => client}/front/images/testimonials/supermouse35.png (100%) rename website/{public => client}/front/images/testimonials/tonitonirocca.png (100%) rename website/{public => client}/front/images/uses/achievement-bkgd.png (100%) rename website/{public => client}/front/images/uses/clipart-rosemonkeyct-meditation.png (100%) rename website/{public => client}/front/images/uses/clipart-rosemonkeyct-meditation.psd (100%) rename website/{public => client}/front/images/uses/clipart-rosemonkeyct-reading.png (100%) rename website/{public => client}/front/images/uses/coding.png (100%) rename website/{public => client}/front/images/uses/coding_3_by_phoneix_faerie-d7idtti.png (100%) rename website/{public => client}/front/images/uses/consequences.png (100%) rename website/{public => client}/front/images/uses/dusting-bkgd.png (100%) rename website/{public => client}/front/images/uses/dusting_by_leephon.png (100%) rename website/{public => client}/front/images/uses/gaining_an_achievement_by_cosmic_caterpillar-d7uyv5z.png (100%) rename website/{public => client}/front/images/uses/meditation-bkgd.png (100%) rename website/{public => client}/front/images/uses/publicSpaces.png (100%) rename website/{public => client}/front/images/uses/reading.png (100%) rename website/{public => client}/front/js/blockScroll.js (100%) rename website/{public => client}/front/js/bootstrap.min.js (100%) rename website/{public => client}/front/js/skrollr.min.js (100%) rename website/{public => client}/front/landingv1Wireframe.jpg (100%) rename website/{public => client}/front/staticstyle.css (100%) rename website/{public => client}/front/style.css (100%) rename website/{public => client}/google280633b772b94345.html (100%) rename website/{public => client}/google8ca65b6ff3506fb8.html (100%) rename website/{public => client}/googlef3b1402b0e28338a.html (100%) rename website/{public => client}/js/.eslintrc (100%) rename website/{public => client}/js/app.js (100%) rename website/{public => client}/js/controllers/authCtrl.js (100%) rename website/{public => client}/js/controllers/autoCompleteCtrl.js (100%) rename website/{public => client}/js/controllers/challengesCtrl.js (100%) rename website/{public => client}/js/controllers/chatCtrl.js (100%) rename website/{public => client}/js/controllers/copyMessageModalCtrl.js (100%) rename website/{public => client}/js/controllers/filtersCtrl.js (100%) rename website/{public => client}/js/controllers/footerCtrl.js (100%) rename website/{public => client}/js/controllers/groupsCtrl.js (100%) rename website/{public => client}/js/controllers/guildsCtrl.js (100%) rename website/{public => client}/js/controllers/hallCtrl.js (100%) rename website/{public => client}/js/controllers/headerCtrl.js (100%) rename website/{public => client}/js/controllers/inventoryCtrl.js (100%) rename website/{public => client}/js/controllers/inviteToGroupCtrl.js (100%) rename website/{public => client}/js/controllers/memberModalCtrl.js (100%) rename website/{public => client}/js/controllers/menuCtrl.js (100%) rename website/{public => client}/js/controllers/notificationCtrl.js (100%) rename website/{public => client}/js/controllers/partyCtrl.js (100%) rename website/{public => client}/js/controllers/rootCtrl.js (100%) rename website/{public => client}/js/controllers/settingsCtrl.js (100%) rename website/{public => client}/js/controllers/sortableInventoryCtrl.js (100%) rename website/{public => client}/js/controllers/tasksCtrl.js (100%) rename website/{public => client}/js/controllers/tavernCtrl.js (100%) rename website/{public => client}/js/controllers/userCtrl.js (100%) rename website/{public => client}/js/directives/close-menu.directive.js (100%) rename website/{public => client}/js/directives/expand-menu.directive.js (100%) rename website/{public => client}/js/directives/focus-element.directive.js (100%) rename website/{public => client}/js/directives/from-now.directive.js (100%) rename website/{public => client}/js/directives/habitrpg-tasks.directive.js (100%) rename website/{public => client}/js/directives/hrpg-sort-checklist.directive.js (100%) rename website/{public => client}/js/directives/hrpg-sort-tags.directive.js (100%) rename website/{public => client}/js/directives/hrpg-sort-tasks.directive.js (100%) rename website/{public => client}/js/directives/popover-html-popup.directive.js (100%) rename website/{public => client}/js/directives/popover-html.directive.js (100%) rename website/{public => client}/js/directives/when-scrolled.directive.js (100%) rename website/{public => client}/js/env.js (100%) rename website/{public => client}/js/filters/money.js (100%) rename website/{public => client}/js/filters/roundLargeNumbers.js (100%) rename website/{public => client}/js/filters/taskOrdering.js (100%) rename website/{public => client}/js/filters/timezoneOffsetToUtc.js (100%) rename website/{public => client}/js/services/analyticsServices.js (100%) rename website/{public => client}/js/services/challengeServices.js (100%) rename website/{public => client}/js/services/chatServices.js (100%) rename website/{public => client}/js/services/groupServices.js (100%) rename website/{public => client}/js/services/guideServices.js (100%) rename website/{public => client}/js/services/memberServices.js (100%) rename website/{public => client}/js/services/notificationServices.js (100%) rename website/{public => client}/js/services/paymentServices.js (100%) rename website/{public => client}/js/services/questServices.js (100%) rename website/{public => client}/js/services/sharedServices.js (100%) rename website/{public => client}/js/services/socialServices.js (100%) rename website/{public => client}/js/services/statServices.js (100%) rename website/{public => client}/js/services/tagsServices.js (100%) rename website/{public => client}/js/services/taskServices.js (100%) rename website/{public => client}/js/services/userServices.js (100%) rename website/{public => client}/js/static.js (100%) rename website/{public => client}/logo.png (100%) rename website/{public => client}/logo/HABITRPG logo version 1.psd (100%) rename website/{public => client}/logo/HABITRPG-logo-version-1.gif (100%) rename website/{public => client}/logo/habitrpg.jpg (100%) rename website/{public => client}/logo/habitrpg_bl.eps (100%) rename website/{public => client}/logo/habitrpg_pixel.png (100%) rename website/{public => client}/manifest.json (100%) rename website/{public => client}/marketing/android_iphone.png (100%) rename website/{public => client}/marketing/animals.png (100%) rename website/{public => client}/marketing/challenge.png (100%) rename website/{public => client}/marketing/devices.png (100%) rename website/{public => client}/marketing/drops.png (100%) rename website/{public => client}/marketing/education.png (100%) rename website/{public => client}/marketing/gear.png (100%) rename website/{public => client}/marketing/guild.png (100%) rename website/{public => client}/marketing/guild_small.png (100%) rename website/{public => client}/marketing/integration.png (100%) rename website/{public => client}/marketing/lefnire.png (100%) rename website/{public => client}/marketing/promos/201403_Forest_Walker.png (100%) rename website/{public => client}/marketing/promos/April14SAMPLE2.png (100%) rename website/{public => client}/marketing/screenshot.png (100%) rename website/{public => client}/marketing/social_competitve.png (100%) rename website/{public => client}/marketing/wellness.png (100%) rename website/{public => client}/merch/stickermule-logo.png (100%) rename website/{public => client}/merch/stickermule-logo.svg (100%) rename website/{public => client}/merch/stickermule.png (100%) rename website/{public => client}/merch/teespring-eu-logo.png (100%) rename website/{public => client}/merch/teespring-eu.png (100%) rename website/{public => client}/merch/teespring-logo.png (100%) rename website/{public => client}/merch/teespring-logo.svg (100%) rename website/{public => client}/merch/teespring.png (100%) rename website/{public => client}/page-loader.gif (100%) rename website/{public => client}/presskit/Boss - Basi-List.png (100%) rename website/{public => client}/presskit/Boss - Battling the Ghost Stag.png (100%) rename website/{public => client}/presskit/Boss - Laundromancer.png (100%) rename website/{public => client}/presskit/Boss - Necro-Vice.png (100%) rename website/{public => client}/presskit/Boss - SnackLess Monster.png (100%) rename website/{public => client}/presskit/Boss - Stagnant Dishes.png (100%) rename website/{public => client}/presskit/Habitica Gryphon.png (100%) rename website/{public => client}/presskit/Habitica Logo - Android.png (100%) rename website/{public => client}/presskit/Habitica Logo - Icon with Text.png (100%) rename website/{public => client}/presskit/Habitica Logo - Icon.png (100%) rename website/{public => client}/presskit/Habitica Logo - Text.png (100%) rename website/{public => client}/presskit/Habitica Logo - iOS.png (100%) rename website/{public => client}/presskit/Habitica Promo - Thin.png (100%) rename website/{public => client}/presskit/Habitica Promo.png (100%) rename website/{public => client}/presskit/Sample Screen - Boss (iOS).png (100%) rename website/{public => client}/presskit/Sample Screen - Challenges.png (100%) rename website/{public => client}/presskit/Sample Screen - Equipment.png (100%) rename website/{public => client}/presskit/Sample Screen - Guilds.png (100%) rename website/{public => client}/presskit/Sample Screen - Level Up (iOS).png (100%) rename website/{public => client}/presskit/Sample Screen - Market.png (100%) rename website/{public => client}/presskit/Sample Screen - Party (iOS).png (100%) rename website/{public => client}/presskit/Sample Screen - Pets (iOS).png (100%) rename website/{public => client}/presskit/Sample Screen - Tasks Page (iOS).png (100%) rename website/{public => client}/presskit/Sample Screen - Tasks Page.png (100%) rename website/{public => client}/presskit/World Boss - Dread Drag'on of Dilatory.png (100%) rename website/{public => client}/presskit/presskit.zip (100%) rename website/{public => client}/refresh.png (100%) rename website/{src => server}/controllers/api-v2/auth.js (100%) rename website/{src => server}/controllers/api-v2/challenges.js (100%) rename website/{src => server}/controllers/api-v2/coupon.js (100%) rename website/{src => server}/controllers/api-v2/dataexport.js (100%) rename website/{src => server}/controllers/api-v2/groups.js (100%) rename website/{src => server}/controllers/api-v2/hall.js (100%) rename website/{src => server}/controllers/api-v2/members.js (100%) rename website/{src => server}/controllers/api-v2/pushNotifications.js (100%) rename website/{src => server}/controllers/api-v2/unsubscription.js (100%) rename website/{src => server}/controllers/api-v2/user.js (100%) rename website/{src => server}/controllers/api-v3/auth.js (100%) rename website/{src => server}/controllers/api-v3/challenges.js (100%) rename website/{src => server}/controllers/api-v3/chat.js (100%) rename website/{src => server}/controllers/api-v3/content.js (100%) rename website/{src => server}/controllers/api-v3/coupon.js (100%) rename website/{src => server}/controllers/api-v3/debug.js (100%) rename website/{src => server}/controllers/api-v3/email.js (100%) rename website/{src => server}/controllers/api-v3/groups.js (100%) rename website/{src => server}/controllers/api-v3/hall.js (100%) rename website/{src => server}/controllers/api-v3/members.js (100%) rename website/{src => server}/controllers/api-v3/modelsPaths.js (100%) rename website/{src => server}/controllers/api-v3/quests.js (100%) rename website/{src => server}/controllers/api-v3/status.js (100%) rename website/{src => server}/controllers/api-v3/tags.js (100%) rename website/{src => server}/controllers/api-v3/tasks.js (100%) rename website/{src => server}/controllers/api-v3/user.js (100%) rename website/{src => server}/controllers/top-level/auth.js (100%) rename website/{src => server}/controllers/top-level/dataexport.js (100%) rename website/{src => server}/controllers/top-level/pages.js (100%) rename website/{src => server}/controllers/top-level/payments/amazon.js (100%) rename website/{src => server}/controllers/top-level/payments/iap.js (100%) rename website/{src => server}/controllers/top-level/payments/paypal.js (100%) rename website/{src => server}/controllers/top-level/payments/stripe.js (100%) rename website/{src => server}/index.js (100%) rename website/{src => server}/libs/api-v2/analytics.js (100%) rename website/{src => server}/libs/api-v2/buildManifest.js (95%) rename website/{src => server}/libs/api-v2/firebase.js (100%) rename website/{src => server}/libs/api-v2/i18n.js (100%) rename website/{src => server}/libs/api-v2/logging.js (100%) rename website/{src => server}/libs/api-v2/utils.js (100%) rename website/{src => server}/libs/api-v2/webhook.js (100%) rename website/{src => server}/libs/api-v3/amazonPayments.js (100%) rename website/{src => server}/libs/api-v3/analyticsService.js (100%) rename website/{src => server}/libs/api-v3/baseModel.js (100%) rename website/{src => server}/libs/api-v3/buildManifest.js (95%) rename website/{src => server}/libs/api-v3/collectionManipulators.js (100%) rename website/{src => server}/libs/api-v3/cron.js (100%) rename website/{src => server}/libs/api-v3/csvStringify.js (100%) rename website/{src => server}/libs/api-v3/email.js (100%) rename website/{src => server}/libs/api-v3/encryption.js (100%) rename website/{src => server}/libs/api-v3/errors.js (100%) rename website/{src => server}/libs/api-v3/firebase.js (100%) rename website/{src => server}/libs/api-v3/i18n.js (100%) rename website/{src => server}/libs/api-v3/logger.js (100%) rename website/{src => server}/libs/api-v3/password.js (100%) rename website/{src => server}/libs/api-v3/payments.js (100%) rename website/{src => server}/libs/api-v3/preening.js (100%) rename website/{src => server}/libs/api-v3/pushNotifications.js (100%) rename website/{src => server}/libs/api-v3/routes.js (100%) rename website/{src => server}/libs/api-v3/setupMongoose.js (100%) rename website/{src => server}/libs/api-v3/setupNconf.js (100%) rename website/{src => server}/libs/api-v3/setupPassport.js (100%) rename website/{src => server}/libs/api-v3/webhook.js (100%) rename website/{src => server}/middlewares/api-v2/domain.js (100%) rename website/{src => server}/middlewares/api-v2/errorHandler.js (100%) rename website/{src => server}/middlewares/api-v2/locals.js (100%) rename website/{src => server}/middlewares/api-v3/analytics.js (100%) rename website/{src => server}/middlewares/api-v3/auth.js (100%) rename website/{src => server}/middlewares/api-v3/cors.js (100%) rename website/{src => server}/middlewares/api-v3/cron.js (100%) rename website/{src => server}/middlewares/api-v3/domain.js (100%) rename website/{src => server}/middlewares/api-v3/ensureAccessRight.js (100%) rename website/{src => server}/middlewares/api-v3/ensureDevelpmentMode.js (100%) rename website/{src => server}/middlewares/api-v3/errorHandler.js (100%) rename website/{src => server}/middlewares/api-v3/index.js (97%) rename website/{src => server}/middlewares/api-v3/language.js (100%) rename website/{src => server}/middlewares/api-v3/locals.js (100%) rename website/{src => server}/middlewares/api-v3/notFound.js (100%) rename website/{src => server}/middlewares/api-v3/redirects.js (100%) rename website/{src => server}/middlewares/api-v3/response.js (100%) rename website/{src => server}/middlewares/api-v3/setupBody.js (100%) rename website/{src => server}/middlewares/api-v3/static.js (93%) rename website/{src => server}/middlewares/api-v3/v1.js (100%) rename website/{src => server}/middlewares/api-v3/v2.js (100%) rename website/{src => server}/middlewares/api-v3/v3.js (100%) rename website/{src => server}/middlewares/apiThrottle.js (100%) rename website/{src => server}/middlewares/forceRefresh.js (100%) rename website/{src => server}/models/challenge.js (100%) rename website/{src => server}/models/coupon.js (100%) rename website/{src => server}/models/emailUnsubscription.js (100%) rename website/{src => server}/models/group.js (100%) rename website/{src => server}/models/tag.js (100%) rename website/{src => server}/models/task.js (100%) rename website/{src => server}/models/user.js (100%) rename website/{src => server}/routes/api-v2/auth.js (100%) rename website/{src => server}/routes/api-v2/coupon.js (100%) rename website/{src => server}/routes/api-v2/swagger.js (100%) rename website/{src => server}/routes/api-v2/unsubscription.js (100%) rename website/{src => server}/routes/pages.js (100%) rename website/{src => server}/routes/payments.js (100%) rename website/{src => server}/server.js (100%) diff --git a/.bowerrc b/.bowerrc index 4a52096b99..552e7d2622 100644 --- a/.bowerrc +++ b/.bowerrc @@ -1,3 +1,3 @@ { - "directory": "website/public/bower_components" + "directory": "website/client/bower_components" } diff --git a/.eslintignore b/.eslintignore index 6aab3499ed..5b7664ac76 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,18 +8,18 @@ website/transpiled-babel/ migrations/* -# The files in website/public/js should be moved out and browserified -website/public/ +# The files in website/client/js should be moved out and browserified +website/client/ # Temporarilly disabled. These should be removed when the linting errors are fixed common/script/content/index.js common/script/public/**/*.js -website/src/**/api-v2/**/*.js -website/src/routes/payments.js -website/src/routes/pages.js -website/src/middlewares/apiThrottle.js -website/src/middlewares/forceRefresh.js +website/server/**/api-v2/**/*.js +website/server/routes/payments.js +website/server/routes/pages.js +website/server/middlewares/apiThrottle.js +website/server/middlewares/forceRefresh.js debug-scripts/* scripts/* diff --git a/.gitignore b/.gitignore index a54367ceb3..4e3d867081 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .DS_Store -website/public/gen -website/public/common -website/public/apidoc +website/client/gen +website/client/common +website/client/apidoc website/transpiled-babel/ common/transpiled-babel/ node_modules @@ -10,8 +10,8 @@ node_modules config.json npm-debug.log* lib -website/public/bower_components -website/public/new-stuff.html +website/client/bower_components +website/client/new-stuff.html website/build newrelic_agent.log .bower-tmp @@ -25,7 +25,7 @@ src/*/*.map src/*/*/*.map test/*.js test/*.map -website/public/docs +website/client/docs *.sublime-workspace coverage coverage.html diff --git a/.nodemonignore b/.nodemonignore index 5aa436cfa3..c698b88598 100644 --- a/.nodemonignore +++ b/.nodemonignore @@ -2,7 +2,7 @@ node_modules/** .bower-cache/** .bower-tmp/** .bower-registry/** -website/public/** +website/client/** website/views/** website/build/** .git/** diff --git a/Gruntfile.js b/Gruntfile.js index 8b416e203c..8f716518fe 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -43,11 +43,11 @@ module.exports = function(grunt) { options: { compress: false, // AFTER 'include css': true, - paths: ['website/public'] + paths: ['website/client'] }, files: { - 'website/build/app.css': ['website/public/css/index.styl'], - 'website/build/static.css': ['website/public/css/static.styl'] + 'website/build/app.css': ['website/client/css/index.styl'], + 'website/build/static.css': ['website/client/css/static.styl'] } } }, @@ -55,13 +55,13 @@ module.exports = function(grunt) { copy: { build: { files: [ - {expand: true, cwd: 'website/public/', src: 'favicon.ico', dest: 'website/build/'}, - {expand: true, cwd: 'website/public/', src: 'favicon_192x192.png', dest: 'website/build/'}, + {expand: true, cwd: 'website/client/', src: 'favicon.ico', dest: 'website/build/'}, + {expand: true, cwd: 'website/client/', src: 'favicon_192x192.png', dest: 'website/build/'}, {expand: true, cwd: '', src: 'common/dist/sprites/spritesmith*.png', dest: 'website/build/'}, {expand: true, cwd: '', src: 'common/img/sprites/backer-only/*.gif', dest: 'website/build/'}, {expand: true, cwd: '', src: 'common/img/sprites/npc_ian.gif', dest: 'website/build/'}, {expand: true, cwd: '', src: 'common/img/sprites/quest_*.gif', dest: 'website/build/'}, - {expand: true, cwd: 'website/public/', src: 'bower_components/bootstrap/dist/fonts/*', dest: 'website/build/'} + {expand: true, cwd: 'website/client/', src: 'bower_components/bootstrap/dist/fonts/*', dest: 'website/build/'} ] } }, @@ -88,9 +88,9 @@ module.exports = function(grunt) { } }); - //Load build files from public/manifest.json - grunt.registerTask('loadManifestFiles', 'Load all build files from public/manifest.json', function(){ - var files = grunt.file.readJSON('./website/public/manifest.json'); + //Load build files from client/manifest.json + grunt.registerTask('loadManifestFiles', 'Load all build files from client/manifest.json', function(){ + var files = grunt.file.readJSON('./website/client/manifest.json'); var uglify = {}; var cssmin = {}; @@ -101,7 +101,7 @@ module.exports = function(grunt) { _.each(files[key].js, function(val){ var path = "./"; if( val.indexOf('common/') == -1) - path = './website/public/'; + path = './website/client/'; js.push(path + val); }); @@ -110,7 +110,7 @@ module.exports = function(grunt) { _.each(files[key].css, function(val){ var path = "./"; if( val.indexOf('common/') == -1) { - path = (val == 'app.css' || val == 'static.css') ? './website/build/' : './website/public/'; + path = (val == 'app.css' || val == 'static.css') ? './website/build/' : './website/client/'; } css.push(path + val) }); @@ -122,7 +122,7 @@ module.exports = function(grunt) { grunt.config.set('cssmin.build.files', cssmin); // Rewrite urls to relative path - grunt.config.set('cssmin.build.options', {'target': 'website/public/css/whatever-css.css'}); + grunt.config.set('cssmin.build.options', {'target': 'website/client/css/whatever-css.css'}); }); // Register tasks. @@ -131,7 +131,7 @@ module.exports = function(grunt) { grunt.registerTask('build:test', ['test:prepare:translations', 'build:dev']); grunt.registerTask('test:prepare:translations', function() { - var i18n = require('./website/src/libs/api-v3/i18n'), + var i18n = require('./website/server/libs/api-v3/i18n'), fs = require('fs'); fs.writeFileSync('test/spec/mocks/translations.js', "if(!window.env) window.env = {};\n" + diff --git a/bower.json b/bower.json index 8788581cd1..7e6e6f6790 100644 --- a/bower.json +++ b/bower.json @@ -9,7 +9,7 @@ "ignore": [ "**/.*", "node_modules", - "public/bower_components", + "website/client/bower_components", "test", "tests" ], diff --git a/common/script/content/spells.js b/common/script/content/spells.js index 41a6571dec..e7a198ec97 100644 --- a/common/script/content/spells.js +++ b/common/script/content/spells.js @@ -15,7 +15,7 @@ import { NotAuthorized } from '../libs/errors'; web, this function can be performed on the client and on the server. `user` param is self (needed for determining your own stats for effectiveness of cast), and `target` param is one of [task, party, user]. In the case of `self` spells, you act on `user` instead of `target`. You can trust these are the correct objects, as long as the `target` attr of the - spell is correct. Take a look at habitrpg/src/models/user.js and habitrpg/src/models/task.js for what attributes are + spell is correct. Take a look at habitrpg/website/server/models/user.js and habitrpg/website/server/models/task.js for what attributes are available on each model. Note `task.value` is its "redness". If party is passed in, it's an array of users, so you'll want to iterate over them like: `_.each(target,function(member){...})` diff --git a/common/script/cron.js b/common/script/cron.js index 30b7fde486..a0f28f9d5a 100644 --- a/common/script/cron.js +++ b/common/script/cron.js @@ -1,4 +1,4 @@ -// TODO what can be moved to /website/src? +// TODO what can be moved to /website/server? /* ------------------------------------------------------ Cron and time / day functions diff --git a/karma.conf.js b/karma.conf.js index 2ae3a52a9d..3d8344a7eb 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -11,41 +11,41 @@ module.exports = function karmaConfig (config) { // list of files / patterns to load in the browser files: [ - 'website/public/bower_components/jquery/dist/jquery.js', - 'website/public/bower_components/pnotify/jquery.pnotify.js', - 'website/public/bower_components/angular/angular.js', - 'website/public/bower_components/angular-loading-bar/build/loading-bar.min.js', - 'website/public/bower_components/angular-resource/angular-resource.min.js', - 'website/public/bower_components/hello/dist/hello.all.min.js', - 'website/public/bower_components/angular-sanitize/angular-sanitize.js', - 'website/public/bower_components/bootstrap/dist/js/bootstrap.js', - 'website/public/bower_components/angular-bootstrap/ui-bootstrap.js', - 'website/public/bower_components/angular-bootstrap/ui-bootstrap-tpls.js', - 'website/public/bower_components/angular-ui-router/release/angular-ui-router.js', - 'website/public/bower_components/angular-filter/dist/angular-filter.js', - 'website/public/bower_components/angular-ui/build/angular-ui.js', - 'website/public/bower_components/angular-ui-utils/ui-utils.min.js', - 'website/public/bower_components/Angular-At-Directive/src/at.js', - 'website/public/bower_components/Angular-At-Directive/src/caret.js', - 'website/public/bower_components/angular-mocks/angular-mocks.js', - 'website/public/bower_components/ngInfiniteScroll/build/ng-infinite-scroll.js', - 'website/public/bower_components/select2/select2.js', - 'website/public/bower_components/angular-ui-select2/src/select2.js', - 'website/public/bower_components/habitica-markdown/dist/habitica-markdown.min.js', + 'website/client/bower_components/jquery/dist/jquery.js', + 'website/client/bower_components/pnotify/jquery.pnotify.js', + 'website/client/bower_components/angular/angular.js', + 'website/client/bower_components/angular-loading-bar/build/loading-bar.min.js', + 'website/client/bower_components/angular-resource/angular-resource.min.js', + 'website/client/bower_components/hello/dist/hello.all.min.js', + 'website/client/bower_components/angular-sanitize/angular-sanitize.js', + 'website/client/bower_components/bootstrap/dist/js/bootstrap.js', + 'website/client/bower_components/angular-bootstrap/ui-bootstrap.js', + 'website/client/bower_components/angular-bootstrap/ui-bootstrap-tpls.js', + 'website/client/bower_components/angular-ui-router/release/angular-ui-router.js', + 'website/client/bower_components/angular-filter/dist/angular-filter.js', + 'website/client/bower_components/angular-ui/build/angular-ui.js', + 'website/client/bower_components/angular-ui-utils/ui-utils.min.js', + 'website/client/bower_components/Angular-At-Directive/src/at.js', + 'website/client/bower_components/Angular-At-Directive/src/caret.js', + 'website/client/bower_components/angular-mocks/angular-mocks.js', + 'website/client/bower_components/ngInfiniteScroll/build/ng-infinite-scroll.js', + 'website/client/bower_components/select2/select2.js', + 'website/client/bower_components/angular-ui-select2/src/select2.js', + 'website/client/bower_components/habitica-markdown/dist/habitica-markdown.min.js', 'common/dist/scripts/habitrpg-shared.js', 'test/spec/mocks/**/*.js', - 'website/public/js/env.js', - 'website/public/js/app.js', + 'website/client/js/env.js', + 'website/client/js/app.js', 'common/script/public/config.js', 'common/script/public/userServices.js', 'common/script/public/directives.js', - 'website/public/js/services/**/*.js', - 'website/public/js/filters/**/*.js', - 'website/public/js/directives/**/*.js', - 'website/public/js/controllers/**/*.js', + 'website/client/js/services/**/*.js', + 'website/client/js/filters/**/*.js', + 'website/client/js/directives/**/*.js', + 'website/client/js/controllers/**/*.js', 'test/spec/specHelper.js', 'test/spec/**/*.js', @@ -77,7 +77,7 @@ module.exports = function karmaConfig (config) { browsers: ['PhantomJS'], preprocessors: { - 'website/public/js/**/*.js': ['coverage'], + 'website/client/js/**/*.js': ['coverage'], 'test/**/*.js': ['babel'], }, diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index a1b9b7d4fa..c29dc82df4 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -23,7 +23,7 @@ var fs = require('fs'); consoleStamp(console); // Initialize configuration -require('../../website/src/libs/api-v3/setupNconf')(); +require('../../website/server/libs/api-v3/setupNconf')(); var MONGODB_OLD = nconf.get('MONGODB_OLD'); var MONGODB_NEW = nconf.get('MONGODB_NEW'); @@ -33,8 +33,8 @@ var MongoClient = MongoDB.MongoClient; mongoose.Promise = Bluebird; // otherwise mongoose models won't work // Load new models -var NewChallenge = require('../../website/src/models/challenge').model; -var Tasks = require('../../website/src/models/task'); +var NewChallenge = require('../../website/server/models/challenge').model; +var Tasks = require('../../website/server/models/task'); // To be defined later when MongoClient connects var mongoDbOldInstance; diff --git a/migrations/api_v3/challengesMembers.js b/migrations/api_v3/challengesMembers.js index 7f7b2c99fd..650c7375ab 100644 --- a/migrations/api_v3/challengesMembers.js +++ b/migrations/api_v3/challengesMembers.js @@ -23,7 +23,7 @@ var consoleStamp = require('console-stamp'); consoleStamp(console); // Initialize configuration -require('../../website/src/libs/api-v3/setupNconf')(); +require('../../website/server/libs/api-v3/setupNconf')(); var MONGODB_OLD = nconf.get('MONGODB_OLD'); var MONGODB_NEW = nconf.get('MONGODB_NEW'); diff --git a/migrations/api_v3/coupons.js b/migrations/api_v3/coupons.js index ce7058e3f0..e329a6b676 100644 --- a/migrations/api_v3/coupons.js +++ b/migrations/api_v3/coupons.js @@ -22,7 +22,7 @@ var consoleStamp = require('console-stamp'); consoleStamp(console); // Initialize configuration -require('../../website/src/libs/api-v3/setupNconf')(); +require('../../website/server/libs/api-v3/setupNconf')(); var MONGODB_OLD = nconf.get('MONGODB_OLD'); var MONGODB_NEW = nconf.get('MONGODB_NEW'); @@ -32,7 +32,7 @@ var MongoClient = MongoDB.MongoClient; mongoose.Promise = Bluebird; // otherwise mongoose models won't work // Load new models -var Coupon = require('../../website/src/models/coupon').model; +var Coupon = require('../../website/server/models/coupon').model; // To be defined later when MongoClient connects var mongoDbOldInstance; diff --git a/migrations/api_v3/emailUnsubscriptions.js b/migrations/api_v3/emailUnsubscriptions.js index 099d23cd27..5787cc3395 100644 --- a/migrations/api_v3/emailUnsubscriptions.js +++ b/migrations/api_v3/emailUnsubscriptions.js @@ -22,7 +22,7 @@ var consoleStamp = require('console-stamp'); consoleStamp(console); // Initialize configuration -require('../../website/src/libs/api-v3/setupNconf')(); +require('../../website/server/libs/api-v3/setupNconf')(); var MONGODB_OLD = nconf.get('MONGODB_OLD'); var MONGODB_NEW = nconf.get('MONGODB_NEW'); @@ -32,7 +32,7 @@ var MongoClient = MongoDB.MongoClient; mongoose.Promise = Bluebird; // otherwise mongoose models won't work // Load new models -var EmailUnsubscription = require('../../website/src/models/emailUnsubscription').model; +var EmailUnsubscription = require('../../website/server/models/emailUnsubscription').model; // To be defined later when MongoClient connects var mongoDbOldInstance; diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js index 9399024c53..3230bec0d8 100644 --- a/migrations/api_v3/groups.js +++ b/migrations/api_v3/groups.js @@ -30,7 +30,7 @@ var consoleStamp = require('console-stamp'); consoleStamp(console); // Initialize configuration -require('../../website/src/libs/api-v3/setupNconf')(); +require('../../website/server/libs/api-v3/setupNconf')(); var MONGODB_OLD = nconf.get('MONGODB_OLD'); var MONGODB_NEW = nconf.get('MONGODB_NEW'); @@ -40,9 +40,9 @@ var MongoClient = MongoDB.MongoClient; mongoose.Promise = Bluebird; // otherwise mongoose models won't work // Load new models -var NewGroup = require('../../website/src/models/group').model; +var NewGroup = require('../../website/server/models/group').model; -var TAVERN_ID = require('../../website/src/models/group').TAVERN_ID; +var TAVERN_ID = require('../../website/server/models/group').TAVERN_ID; // To be defined later when MongoClient connects var mongoDbOldInstance; diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index cea6e58d2d..87b39ae615 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -25,7 +25,7 @@ var moment = require('moment'); consoleStamp(console); // Initialize configuration -require('../../website/src/libs/api-v3/setupNconf')(); +require('../../website/server/libs/api-v3/setupNconf')(); var MONGODB_OLD = nconf.get('MONGODB_OLD'); var MONGODB_NEW = nconf.get('MONGODB_NEW'); @@ -36,8 +36,8 @@ var MongoClient = MongoDB.MongoClient; mongoose.Promise = Bluebird; // otherwise mongoose models won't work // Load new models -var NewUser = require('../../website/src/models/user').model; -var NewTasks = require('../../website/src/models/task'); +var NewUser = require('../../website/server/models/user').model; +var NewTasks = require('../../website/server/models/task'); // To be defined later when MongoClient connects var mongoDbOldInstance; diff --git a/migrations/manual_password_reset.js b/migrations/manual_password_reset.js index 68b69cbbbe..622e16913b 100644 --- a/migrations/manual_password_reset.js +++ b/migrations/manual_password_reset.js @@ -7,7 +7,7 @@ nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../config.jso var Users = require('mongoskin').db(nconf.get("PRODUCTION_DB:URL"), nconf.get("PRODUCTION_DB").CREDS).collection('users'), async = require('async'), - utils = require('../website/src/utils'), + utils = require('../website/server/utils'), salt = utils.makeSalt(), newPassword = utils.makeSalt(), // use a salt as the new password too (they'll change it later) hashed_password = utils.encryptPassword(newPassword, salt); diff --git a/package.json b/package.json index 3b13ca318f..eb7e278837 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "habitrpg", "description": "A habit tracker app which treats your goals like a Role Playing Game.", "version": "3.0.0", - "main": "./website/src/index.js", + "main": "./website/server/index.js", "dependencies": { "accepts": "^1.3.2", "amazon-payments": "0.0.4", @@ -107,7 +107,7 @@ "test:api-v3:unit": "gulp test:api-v3:unit", "test:api-v3:integration": "gulp test:api-v3:integration", "test:api-v3:integration:separate-server": "gulp test:api-v3:integration:separate-server", - "test:api-legacy": "istanbul cover -i \"website/src/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", + "test:api-legacy": "istanbul cover -i \"website/server/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", "test:common": "mocha test/common --recursive", "test:content": "mocha test/content --recursive", "test:karma": "karma start --single-run", diff --git a/tasks/gulp-apidoc.js b/tasks/gulp-apidoc.js index a14c9df0c7..b8f65d2abd 100644 --- a/tasks/gulp-apidoc.js +++ b/tasks/gulp-apidoc.js @@ -3,7 +3,7 @@ import clean from 'rimraf'; import apidoc from 'apidoc'; const APIDOC_DEST_PATH = './website/build/apidoc'; -const APIDOC_SRC_PATH = './website/src'; +const APIDOC_SRC_PATH = './website/server'; gulp.task('apidoc:clean', (done) => { clean(APIDOC_DEST_PATH, done); }); diff --git a/tasks/gulp-build.js b/tasks/gulp-build.js index 923caad2c8..e660145d92 100644 --- a/tasks/gulp-build.js +++ b/tasks/gulp-build.js @@ -12,7 +12,7 @@ gulp.task('build', () => { }); gulp.task('build:src', () => { - return gulp.src('website/src/**/*.js') + return gulp.src('website/server/**/*.js') .pipe(babel()) .pipe(gulp.dest('website/transpiled-babel/')); }); @@ -30,7 +30,7 @@ gulp.task('build:dev', ['browserify', 'prepare:staticNewStuff'], (done) => { }); gulp.task('build:dev:watch', ['build:dev'], () => { - gulp.watch(['website/public/**/*.styl', 'common/script/*']); + gulp.watch(['website/client/**/*.styl', 'common/script/*']); }); gulp.task('build:prod', ['browserify', 'build:server', 'prepare:staticNewStuff'], (done) => { diff --git a/tasks/gulp-console.js b/tasks/gulp-console.js index 96af26a27a..026d646cee 100644 --- a/tasks/gulp-console.js +++ b/tasks/gulp-console.js @@ -1,6 +1,6 @@ import mongoose from 'mongoose'; import autoinc from 'mongoose-id-autoinc'; -import logger from '../website/src/libs/api-v3/logger'; +import logger from '../website/server/libs/api-v3/logger'; import nconf from 'nconf'; import repl from 'repl'; import gulp from 'gulp'; @@ -18,9 +18,9 @@ let improveRepl = (context) => { process.stdout.write('\u001B[2J\u001B[0;0f'); }}); - context.Challenge = require('../website/src/models/challenge').model; - context.Group = require('../website/src/models/group').model; - context.User = require('../website/src/models/user').model; + context.Challenge = require('../website/server/models/challenge').model; + context.Group = require('../website/server/models/group').model; + context.User = require('../website/server/models/user').model; var isProd = nconf.get('NODE_ENV') === 'production'; var mongooseOptions = !isProd ? {} : { diff --git a/tasks/gulp-newstuff.js b/tasks/gulp-newstuff.js index 16085e5c1c..b6d8093ee5 100644 --- a/tasks/gulp-newstuff.js +++ b/tasks/gulp-newstuff.js @@ -4,7 +4,7 @@ import {writeFileSync} from 'fs'; gulp.task('prepare:staticNewStuff', () => { writeFileSync( - './website/public/new-stuff.html', + './website/client/new-stuff.html', jade.compileFile('./website/views/shared/new-stuff.jade')() ); }); diff --git a/tasks/gulp-start.js b/tasks/gulp-start.js index 7cb842af00..51825f71ea 100644 --- a/tasks/gulp-start.js +++ b/tasks/gulp-start.js @@ -9,7 +9,7 @@ gulp.task('nodemon', () => { nodemon({ script: pkg.main, ignore: [ - 'website/public/*', + 'website/client/*', 'website/views/*', 'common/dist/script/content/*', ] diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 18d9a8777b..7db299a5c4 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -68,7 +68,7 @@ gulp.task('test:prepare:mongo', (cb) => { gulp.task('test:prepare:server', ['test:prepare:mongo'], () => { if (!server) { - server = exec(testBin(`node ./website/src/index.js`, `NODE_DB_URI=${TEST_DB_URI} PORT=${TEST_SERVER_PORT}`), (error, stdout, stderr) => { + server = exec(testBin(`node ./website/server/index.js`, `NODE_DB_URI=${TEST_DB_URI} PORT=${TEST_SERVER_PORT}`), (error, stdout, stderr) => { if (error) { throw `Problem with the server: ${error}`; } if (stderr) { console.error(stderr); } }); @@ -219,7 +219,7 @@ gulp.task('test:api-legacy:watch', [ 'test:prepare:mongo', 'test:api-legacy:clean' ], () => { - gulp.watch(['website/src/**', 'test/api-legacy/**'], ['test:api-legacy:clean']); + gulp.watch(['website/server/**', 'test/api-legacy/**'], ['test:api-legacy:clean']); }); gulp.task('test:karma', ['test:prepare:build'], (cb) => { @@ -318,7 +318,7 @@ gulp.task('test:e2e:safe', ['test:prepare', 'test:prepare:server'], (cb) => { gulp.task('test:api-v2:watch', ['test:prepare:server'], () => { process.env.RUN_INTEGRATION_TEST_FOREVER = true; - gulp.watch(['website/src/**', 'test/api/v2/**'], ['test:api-v2']); + gulp.watch(['website/server/**', 'test/api/v2/**'], ['test:api-v2']); }); gulp.task('test:api-v2:safe', ['test:prepare:server'], (done) => { @@ -359,7 +359,7 @@ gulp.task('test:api-v3:unit', (done) => { }); gulp.task('test:api-v3:unit:watch', () => { - gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/**/*', 'website/src/controllers/**/*'], ['test:api-v3:unit']); + gulp.watch(['website/server/libs/api-v3/*', 'test/api/v3/unit/**/*', 'website/server/controllers/**/*'], ['test:api-v3:unit']); }); gulp.task('test:api-v3:integration', (done) => { @@ -373,7 +373,7 @@ gulp.task('test:api-v3:integration', (done) => { }); gulp.task('test:api-v3:integration:watch', () => { - gulp.watch(['website/src/controllers/api-v3/**/*', 'common/script/ops/*', 'website/src/libs/api-v3/*.js', + gulp.watch(['website/server/controllers/api-v3/**/*', 'common/script/ops/*', 'website/server/libs/api-v3/*.js', 'test/api/v3/integration/**/*'], ['test:api-v3:integration']); }); @@ -417,7 +417,7 @@ gulp.task('test:api-v3:unit', (done) => {*/ /*}); gulp.task('test:api-v3:unit:watch', () => { - gulp.watch(['website/src/**', 'test/api/v3/unit/**'], ['test:api-v3:unit']); + gulp.watch(['website/server/**', 'test/api/v3/unit/**'], ['test:api-v3:unit']); }); gulp.task('test:api-v3:integration', ['test:prepare:server'], (done) => { @@ -429,7 +429,7 @@ gulp.task('test:api-v3:integration', ['test:prepare:server'], (done) => { gulp.task('test:api-v3:integration:watch', ['test:prepare:server'], () => { process.env.RUN_INTEGRATION_TEST_FOREVER = true; - gulp.watch(['website/src/**', 'test/api/v3/integration/**'], ['test:api-v3:integration']); + gulp.watch(['website/server/**', 'test/api/v3/integration/**'], ['test:api-v3:integration']); }); gulp.task('test:api-v3:safe', ['test:prepare:server'], (done) => { diff --git a/test/api-legacy/api-helper.js b/test/api-legacy/api-helper.js index e6b9b53d23..1b994a80a8 100644 --- a/test/api-legacy/api-helper.js +++ b/test/api-legacy/api-helper.js @@ -17,7 +17,7 @@ global._ = require("lodash"); global.shared = require("../../common"); -global.User = require("../../website/src/models/user").model; +global.User = require("../../website/server/models/user").model; global.chai = require("chai"); diff --git a/test/api-legacy/challenges.js b/test/api-legacy/challenges.js index 2f264655ef..b09754bc5c 100644 --- a/test/api-legacy/challenges.js +++ b/test/api-legacy/challenges.js @@ -1,10 +1,10 @@ var Challenge, Group, app; -app = require("../../website/src/server"); +app = require("../../website/server/server"); -Group = require("../../website/src/models/group").model; +Group = require("../../website/server/models/group").model; -Challenge = require("../../website/src/models/challenge").model; +Challenge = require("../../website/server/models/challenge").model; describe("Challenges", function() { var challenge, group, updateTodo; diff --git a/test/api-legacy/chat.js b/test/api-legacy/chat.js index 89b32b0c54..1f2dbab487 100644 --- a/test/api-legacy/chat.js +++ b/test/api-legacy/chat.js @@ -2,9 +2,9 @@ var Group, app, diff; diff = require("deep-diff"); -Group = require("../../website/src/models/group").model; +Group = require("../../website/server/models/group").model; -app = require("../../website/src/server"); +app = require("../../website/server/server"); describe("Chat", function() { var chat, group; diff --git a/test/api-legacy/coupons.js b/test/api-legacy/coupons.js index 31d840de61..4d4e366473 100644 --- a/test/api-legacy/coupons.js +++ b/test/api-legacy/coupons.js @@ -1,8 +1,8 @@ var Coupon, app, makeSudoUser; -app = require("../../website/src/server"); +app = require("../../website/server/server"); -Coupon = require("../../website/src/models/coupon").model; +Coupon = require("../../website/server/models/coupon").model; makeSudoUser = function(usr, cb) { return registerNewUser(function() { diff --git a/test/api-legacy/inAppPurchases.js b/test/api-legacy/inAppPurchases.js index d4182e437c..96809a7717 100644 --- a/test/api-legacy/inAppPurchases.js +++ b/test/api-legacy/inAppPurchases.js @@ -1,12 +1,12 @@ var app, iapMock, inApp, rewire, sinon; -app = require('../../website/src/server'); +app = require('../../website/server/server'); rewire = require('rewire'); sinon = require('sinon'); -inApp = rewire('../../website/src/controllers/payments/iap'); +inApp = rewire('../../website/server/controllers/payments/iap'); iapMock = {}; diff --git a/test/api-legacy/party.js b/test/api-legacy/party.js index 8ea8188713..2f98b67bde 100644 --- a/test/api-legacy/party.js +++ b/test/api-legacy/party.js @@ -2,9 +2,9 @@ var Group, app, diff; diff = require("deep-diff"); -Group = require("../../website/src/models/group").model; +Group = require("../../website/server/models/group").model; -app = require("../../website/src/server"); +app = require("../../website/server/server"); describe("Party", function() { return context("Quests", function() { diff --git a/test/api-legacy/pushNotifications.js b/test/api-legacy/pushNotifications.js index ce3d9672f8..7f98ddccfa 100644 --- a/test/api-legacy/pushNotifications.js +++ b/test/api-legacy/pushNotifications.js @@ -1,6 +1,6 @@ var app, rewire, sinon; -app = require("../../website/src/server"); +app = require("../../website/server/server"); rewire = require('rewire'); @@ -21,7 +21,7 @@ describe("Push-Notifications", function() { }); context("Challenges", function() { var challengeMock, challenges, userMock; - challenges = rewire("../../website/src/controllers/api-v2/challenges"); + challenges = rewire("../../website/server/controllers/api-v2/challenges"); challenges.__set__('pushNotify', pushSpy); challengeMock = { findById: function(arg, cb) { @@ -76,7 +76,7 @@ describe("Push-Notifications", function() { context("Groups", function() { var groups, recipient; recipient = null; - groups = rewire("../../website/src/controllers/api-v2/groups"); + groups = rewire("../../website/server/controllers/api-v2/groups"); groups.__set__('pushNotify', pushSpy); before(function(done) { return registerNewUser(function(err, _user) { @@ -304,7 +304,7 @@ describe("Push-Notifications", function() { }); context("sending gems from balance", function() { var members; - members = rewire("../../website/src/controllers/api-v2/members"); + members = rewire("../../website/server/controllers/api-v2/members"); members.sendMessage = function() { return true; }; @@ -342,7 +342,7 @@ describe("Push-Notifications", function() { }); return describe("Purchases", function() { var membersMock, payments; - payments = rewire("../../website/src/controllers/payments"); + payments = rewire("../../website/server/controllers/payments"); payments.__set__('pushNotify', pushSpy); membersMock = { sendMessage: function() { diff --git a/test/api-legacy/score.js b/test/api-legacy/score.js index 8c6906acfb..af31a4f334 100644 --- a/test/api-legacy/score.js +++ b/test/api-legacy/score.js @@ -1,4 +1,4 @@ -require("../../website/src/server"); +require("../../website/server/server"); describe("Score", function() { before(function(done) { diff --git a/test/api-legacy/subscriptions.js b/test/api-legacy/subscriptions.js index 9d8624cb73..73b55039df 100644 --- a/test/api-legacy/subscriptions.js +++ b/test/api-legacy/subscriptions.js @@ -1,8 +1,8 @@ var app, payments; -payments = require("../../website/src/controllers/payments"); +payments = require("../../website/server/controllers/payments"); -app = require("../../website/src/server"); +app = require("../../website/server/server"); describe("Subscriptions", function() { before(function(done) { diff --git a/test/api-legacy/todos.js b/test/api-legacy/todos.js index 5285847fd1..b72ea57223 100644 --- a/test/api-legacy/todos.js +++ b/test/api-legacy/todos.js @@ -1,4 +1,4 @@ -require("../../website/src/server"); +require("../../website/server/server"); describe("Todos", function() { before(function(done) { diff --git a/test/api/v2/groups/GET-groups.test.js b/test/api/v2/groups/GET-groups.test.js index 0b6cd4923e..d941b2533e 100644 --- a/test/api/v2/groups/GET-groups.test.js +++ b/test/api/v2/groups/GET-groups.test.js @@ -5,7 +5,7 @@ import { } from '../../../helpers/api-integration/v2'; import { TAVERN_ID, -} from '../../../../website/src/models/group'; +} from '../../../../website/server/models/group'; describe('GET /groups', () => { const NUMBER_OF_PUBLIC_GUILDS = 3; diff --git a/test/api/v3/integration/emails/GET-email-unsubscribe.test.js b/test/api/v3/integration/emails/GET-email-unsubscribe.test.js index bd13cbdc1e..1bd3a532fa 100644 --- a/test/api/v3/integration/emails/GET-email-unsubscribe.test.js +++ b/test/api/v3/integration/emails/GET-email-unsubscribe.test.js @@ -2,7 +2,7 @@ import { generateUser, translate as t, } from '../../../../helpers/api-v3-integration.helper'; -import { encrypt } from '../../../../../website/src/libs/api-v3/encryption'; +import { encrypt } from '../../../../../website/server/libs/api-v3/encryption'; import { v4 as generateUUID } from 'uuid'; describe('GET /email/unsubscribe', () => { diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js index d076e52a18..279f091411 100644 --- a/test/api/v3/integration/groups/GET-groups.test.js +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -5,7 +5,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; import { TAVERN_ID, -} from '../../../../../website/src/models/group'; +} from '../../../../../website/server/models/group'; describe('GET /groups', () => { let user; diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index d0b46ecfa9..63d8f755a5 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -6,7 +6,7 @@ import { } from '../../../../../helpers/api-integration/v3'; import { v4 as generateRandomUserName } from 'uuid'; import { each } from 'lodash'; -import { encrypt } from '../../../../../../website/src/libs/api-v3/encryption'; +import { encrypt } from '../../../../../../website/server/libs/api-v3/encryption'; describe('POST /user/auth/local/register', () => { context('username and email are free', () => { diff --git a/test/api/v3/unit/libs/analyticsService.test.js b/test/api/v3/unit/libs/analyticsService.test.js index 8ff14c2408..771678cc3d 100644 --- a/test/api/v3/unit/libs/analyticsService.test.js +++ b/test/api/v3/unit/libs/analyticsService.test.js @@ -1,4 +1,4 @@ -import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; +import analyticsService from '../../../../../website/server/libs/api-v3/analyticsService'; import nock from 'nock'; diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 9a51998053..39bf7df047 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -1,4 +1,4 @@ -import baseModel from '../../../../../website/src/libs/api-v3/baseModel'; +import baseModel from '../../../../../website/server/libs/api-v3/baseModel'; import mongoose from 'mongoose'; describe('Base model plugin', () => { diff --git a/test/api/v3/unit/libs/buildManifest.test.js b/test/api/v3/unit/libs/buildManifest.test.js index 0978d8fb41..1444738f10 100644 --- a/test/api/v3/unit/libs/buildManifest.test.js +++ b/test/api/v3/unit/libs/buildManifest.test.js @@ -1,6 +1,6 @@ import { getManifestFiles, -} from '../../../../../website/src/libs/api-v3/buildManifest'; +} from '../../../../../website/server/libs/api-v3/buildManifest'; describe('Build Manifest', () => { describe('getManifestFiles', () => { diff --git a/test/api/v3/unit/libs/collectionManipulators.test.js b/test/api/v3/unit/libs/collectionManipulators.test.js index e32e953d51..da44fd5319 100644 --- a/test/api/v3/unit/libs/collectionManipulators.test.js +++ b/test/api/v3/unit/libs/collectionManipulators.test.js @@ -1,7 +1,7 @@ import mongoose from 'mongoose'; import { removeFromArray, -} from '../../../../../website/src/libs/api-v3/collectionManipulators'; +} from '../../../../../website/server/libs/api-v3/collectionManipulators'; describe('Collection Manipulators', () => { describe('removeFromArray', () => { diff --git a/test/api/v3/unit/libs/cron.test.js b/test/api/v3/unit/libs/cron.test.js index 382246a3c8..a7e732442c 100644 --- a/test/api/v3/unit/libs/cron.test.js +++ b/test/api/v3/unit/libs/cron.test.js @@ -1,8 +1,8 @@ /* eslint-disable global-require */ import moment from 'moment'; -import { cron } from '../../../../../website/src/libs/api-v3/cron'; -import { model as User } from '../../../../../website/src/models/user'; -import * as Tasks from '../../../../../website/src/models/task'; +import { cron } from '../../../../../website/server/libs/api-v3/cron'; +import { model as User } from '../../../../../website/server/models/user'; +import * as Tasks from '../../../../../website/server/models/task'; import { clone } from 'lodash'; import common from '../../../../../common'; diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index d997751a3c..bb76e05cfb 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -4,7 +4,7 @@ import nconf from 'nconf'; import nodemailer from 'nodemailer'; import Bluebird from 'bluebird'; import requireAgain from 'require-again'; -import logger from '../../../../../website/src/libs/api-v3/logger'; +import logger from '../../../../../website/server/libs/api-v3/logger'; function defer () { let resolve; @@ -49,7 +49,7 @@ function getUser () { } describe('emails', () => { - let pathToEmailLib = '../../../../../website/src/libs/api-v3/email'; + let pathToEmailLib = '../../../../../website/server/libs/api-v3/email'; describe('sendEmail', () => { it('can send an email using the default transport', () => { diff --git a/test/api/v3/unit/libs/encryption.test.js b/test/api/v3/unit/libs/encryption.test.js index 34c159ed02..a63a527e74 100644 --- a/test/api/v3/unit/libs/encryption.test.js +++ b/test/api/v3/unit/libs/encryption.test.js @@ -1,7 +1,7 @@ import { encrypt, decrypt, -} from '../../../../../website/src/libs/api-v3/encryption'; +} from '../../../../../website/server/libs/api-v3/encryption'; describe('encryption', () => { it('can encrypt and decrypt', () => { diff --git a/test/api/v3/unit/libs/errors.test.js b/test/api/v3/unit/libs/errors.test.js index d36e1615c7..efa694d5ab 100644 --- a/test/api/v3/unit/libs/errors.test.js +++ b/test/api/v3/unit/libs/errors.test.js @@ -5,7 +5,7 @@ import { BadRequest, InternalServerError, NotFound, -} from '../../../../../website/src/libs/api-v3/errors'; +} from '../../../../../website/server/libs/api-v3/errors'; describe('Custom Errors', () => { describe('CustomError', () => { diff --git a/test/api/v3/unit/libs/i18n.test.js b/test/api/v3/unit/libs/i18n.test.js index 098bfefa21..06ebcbc0b6 100644 --- a/test/api/v3/unit/libs/i18n.test.js +++ b/test/api/v3/unit/libs/i18n.test.js @@ -2,7 +2,7 @@ import { translations, localePath, langCodes, -} from '../../../../../website/src/libs/api-v3/i18n'; +} from '../../../../../website/server/libs/api-v3/i18n'; import fs from 'fs'; import path from 'path'; diff --git a/test/api/v3/unit/libs/logger.js b/test/api/v3/unit/libs/logger.js index a0f5eb011f..b7e1d490fc 100644 --- a/test/api/v3/unit/libs/logger.js +++ b/test/api/v3/unit/libs/logger.js @@ -3,7 +3,7 @@ import requireAgain from 'require-again'; /* eslint-disable global-require */ describe('logger', () => { - let pathToLoggerLib = '../../../../../website/src/libs/api-v3/logger'; + let pathToLoggerLib = '../../../../../website/server/libs/api-v3/logger'; let infoSpy; let errorSpy; diff --git a/test/api/v3/unit/libs/password.test.js b/test/api/v3/unit/libs/password.test.js index 6bc652963e..68290aebc6 100644 --- a/test/api/v3/unit/libs/password.test.js +++ b/test/api/v3/unit/libs/password.test.js @@ -1,7 +1,7 @@ import { encrypt as encryptPassword, makeSalt, -} from '../../../../../website/src/libs/api-v3/password'; +} from '../../../../../website/server/libs/api-v3/password'; describe('Password Utilities', () => { describe('Encrypt', () => { diff --git a/test/api/v3/unit/libs/payments.test.js b/test/api/v3/unit/libs/payments.test.js index bc4a3e647d..30fe78b643 100644 --- a/test/api/v3/unit/libs/payments.test.js +++ b/test/api/v3/unit/libs/payments.test.js @@ -1,6 +1,6 @@ -import * as sender from '../../../../../website/src/libs/api-v3/email'; -import * as api from '../../../../../website/src/libs/api-v3/payments'; -import { model as User } from '../../../../../website/src/models/user'; +import * as sender from '../../../../../website/server/libs/api-v3/email'; +import * as api from '../../../../../website/server/libs/api-v3/payments'; +import { model as User } from '../../../../../website/server/models/user'; import moment from 'moment'; describe('payments/index', () => { diff --git a/test/api/v3/unit/libs/preening.test.js b/test/api/v3/unit/libs/preening.test.js index aaccd47ec4..af503ca480 100644 --- a/test/api/v3/unit/libs/preening.test.js +++ b/test/api/v3/unit/libs/preening.test.js @@ -1,4 +1,4 @@ -import { preenHistory } from '../../../../../website/src/libs/api-v3/preening'; +import { preenHistory } from '../../../../../website/server/libs/api-v3/preening'; import moment from 'moment'; import sinon from 'sinon'; // eslint-disable-line no-shadow import { generateHistory } from '../../../../helpers/api-unit.helper.js'; diff --git a/test/api/v3/unit/libs/setupNconf.test.js b/test/api/v3/unit/libs/setupNconf.test.js index e0647d38d2..3e848b845f 100644 --- a/test/api/v3/unit/libs/setupNconf.test.js +++ b/test/api/v3/unit/libs/setupNconf.test.js @@ -1,4 +1,4 @@ -import setupNconf from '../../../../../website/src/libs/api-v3/setupNconf'; +import setupNconf from '../../../../../website/server/libs/api-v3/setupNconf'; import path from 'path'; import nconf from 'nconf'; diff --git a/test/api/v3/unit/libs/webhooks.test.js b/test/api/v3/unit/libs/webhooks.test.js index 9bef501257..502bfe3839 100644 --- a/test/api/v3/unit/libs/webhooks.test.js +++ b/test/api/v3/unit/libs/webhooks.test.js @@ -1,5 +1,5 @@ import request from 'request'; -import { sendTaskWebhook } from '../../../../../website/src/libs/api-v3/webhook'; +import { sendTaskWebhook } from '../../../../../website/server/libs/api-v3/webhook'; describe('webhooks', () => { beforeEach(() => { diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index 2f3a0b7ff0..2a25380713 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -4,13 +4,13 @@ import { generateReq, generateNext, } from '../../../../helpers/api-unit.helper'; -import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; +import analyticsService from '../../../../../website/server/libs/api-v3/analyticsService'; import nconf from 'nconf'; import requireAgain from 'require-again'; describe('analytics middleware', () => { let res, req, next; - let pathToAnalyticsMiddleware = '../../../../../website/src/middlewares/api-v3/analytics'; + let pathToAnalyticsMiddleware = '../../../../../website/server/middlewares/api-v3/analytics'; beforeEach(() => { res = generateRes(); diff --git a/test/api/v3/unit/middlewares/cors.test.js b/test/api/v3/unit/middlewares/cors.test.js index 3fd449c963..78d11651f8 100644 --- a/test/api/v3/unit/middlewares/cors.test.js +++ b/test/api/v3/unit/middlewares/cors.test.js @@ -4,7 +4,7 @@ import { generateReq, generateNext, } from '../../../../helpers/api-unit.helper'; -import cors from '../../../../../website/src/middlewares/api-v3/cors'; +import cors from '../../../../../website/server/middlewares/api-v3/cors'; describe('cors middleware', () => { let res, req, next; diff --git a/test/api/v3/unit/middlewares/cronMiddleware.js b/test/api/v3/unit/middlewares/cronMiddleware.js index 71b4843b8b..f4e040a11c 100644 --- a/test/api/v3/unit/middlewares/cronMiddleware.js +++ b/test/api/v3/unit/middlewares/cronMiddleware.js @@ -5,12 +5,12 @@ import { generateTodo, generateDaily, } from '../../../../helpers/api-unit.helper'; -import cronMiddleware from '../../../../../website/src/middlewares/api-v3/cron'; +import cronMiddleware from '../../../../../website/server/middlewares/api-v3/cron'; import moment from 'moment'; -import { model as User } from '../../../../../website/src/models/user'; -import { model as Group } from '../../../../../website/src/models/group'; -import * as Tasks from '../../../../../website/src/models/task'; -import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; +import { model as User } from '../../../../../website/server/models/user'; +import { model as Group } from '../../../../../website/server/models/group'; +import * as Tasks from '../../../../../website/server/models/task'; +import analyticsService from '../../../../../website/server/libs/api-v3/analyticsService'; import { v4 as generateUUID } from 'uuid'; describe('cron middleware', () => { diff --git a/test/api/v3/unit/middlewares/ensureAccessRight.test.js b/test/api/v3/unit/middlewares/ensureAccessRight.test.js index 6c50e3d674..cc25e4f16b 100644 --- a/test/api/v3/unit/middlewares/ensureAccessRight.test.js +++ b/test/api/v3/unit/middlewares/ensureAccessRight.test.js @@ -5,8 +5,8 @@ import { generateNext, } from '../../../../helpers/api-unit.helper'; import i18n from '../../../../../common/script/i18n'; -import { ensureAdmin, ensureSudo } from '../../../../../website/src/middlewares/api-v3/ensureAccessRight'; -import { NotAuthorized } from '../../../../../website/src/libs/api-v3/errors'; +import { ensureAdmin, ensureSudo } from '../../../../../website/server/middlewares/api-v3/ensureAccessRight'; +import { NotAuthorized } from '../../../../../website/server/libs/api-v3/errors'; describe('ensure access middlewares', () => { let res, req, next; diff --git a/test/api/v3/unit/middlewares/ensureDevelpmentMode.js b/test/api/v3/unit/middlewares/ensureDevelpmentMode.js index 8d0f8efbab..d7915b365f 100644 --- a/test/api/v3/unit/middlewares/ensureDevelpmentMode.js +++ b/test/api/v3/unit/middlewares/ensureDevelpmentMode.js @@ -4,8 +4,8 @@ import { generateReq, generateNext, } from '../../../../helpers/api-unit.helper'; -import ensureDevelpmentMode from '../../../../../website/src/middlewares/api-v3/ensureDevelpmentMode'; -import { NotFound } from '../../../../../website/src/libs/api-v3/errors'; +import ensureDevelpmentMode from '../../../../../website/server/middlewares/api-v3/ensureDevelpmentMode'; +import { NotFound } from '../../../../../website/server/libs/api-v3/errors'; import nconf from 'nconf'; describe('developmentMode middleware', () => { diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 89ce1b7db6..72cad12a32 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -4,15 +4,15 @@ import { generateNext, } from '../../../../helpers/api-unit.helper'; -import errorHandler from '../../../../../website/src/middlewares/api-v3/errorHandler'; -import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response'; +import errorHandler from '../../../../../website/server/middlewares/api-v3/errorHandler'; +import responseMiddleware from '../../../../../website/server/middlewares/api-v3/response'; import { getUserLanguage, attachTranslateFunction, -} from '../../../../../website/src/middlewares/api-v3/language'; +} from '../../../../../website/server/middlewares/api-v3/language'; -import { BadRequest } from '../../../../../website/src/libs/api-v3/errors'; -import logger from '../../../../../website/src/libs/api-v3/logger'; +import { BadRequest } from '../../../../../website/server/libs/api-v3/errors'; +import logger from '../../../../../website/server/libs/api-v3/logger'; describe('errorHandler', () => { let res, req, next; diff --git a/test/api/v3/unit/middlewares/language.test.js b/test/api/v3/unit/middlewares/language.test.js index 2316f0b540..23ef6deddd 100644 --- a/test/api/v3/unit/middlewares/language.test.js +++ b/test/api/v3/unit/middlewares/language.test.js @@ -6,10 +6,10 @@ import { import { getUserLanguage, attachTranslateFunction, -} from '../../../../../website/src/middlewares/api-v3/language'; +} from '../../../../../website/server/middlewares/api-v3/language'; import common from '../../../../../common'; import Bluebird from 'bluebird'; -import { model as User } from '../../../../../website/src/models/user'; +import { model as User } from '../../../../../website/server/models/user'; const i18n = common.i18n; diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js index e46da348c0..a24bd881ce 100644 --- a/test/api/v3/unit/middlewares/response.js +++ b/test/api/v3/unit/middlewares/response.js @@ -3,7 +3,7 @@ import { generateReq, generateNext, } from '../../../../helpers/api-unit.helper'; -import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response'; +import responseMiddleware from '../../../../../website/server/middlewares/api-v3/response'; describe('response middleware', () => { let res, req, next; diff --git a/test/api/v3/unit/models/challenge.test.js b/test/api/v3/unit/models/challenge.test.js index c1cbd83e18..b2f0e7ff98 100644 --- a/test/api/v3/unit/models/challenge.test.js +++ b/test/api/v3/unit/models/challenge.test.js @@ -1,7 +1,7 @@ -import { model as Challenge } from '../../../../../website/src/models/challenge'; -import { model as Group } from '../../../../../website/src/models/group'; -import { model as User } from '../../../../../website/src/models/user'; -import * as Tasks from '../../../../../website/src/models/task'; +import { model as Challenge } from '../../../../../website/server/models/challenge'; +import { model as Group } from '../../../../../website/server/models/group'; +import { model as User } from '../../../../../website/server/models/user'; +import * as Tasks from '../../../../../website/server/models/task'; import common from '../../../../../common/'; import { each, find } from 'lodash'; diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index e1323baa07..32007e068d 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -1,8 +1,8 @@ import { sleep } from '../../../../helpers/api-unit.helper'; -import { model as Group } from '../../../../../website/src/models/group'; -import { model as User } from '../../../../../website/src/models/user'; +import { model as Group } from '../../../../../website/server/models/group'; +import { model as User } from '../../../../../website/server/models/user'; import { quests as questScrolls } from '../../../../../common/script/content'; -import * as email from '../../../../../website/src/libs/api-v3/email'; +import * as email from '../../../../../website/server/libs/api-v3/email'; describe('Group Model', () => { context('Instance Methods', () => { diff --git a/test/api/v3/unit/models/task.test.js b/test/api/v3/unit/models/task.test.js index 8a889a2145..1c3082d863 100644 --- a/test/api/v3/unit/models/task.test.js +++ b/test/api/v3/unit/models/task.test.js @@ -1,7 +1,7 @@ -import { model as Challenge } from '../../../../../website/src/models/challenge'; -import { model as Group } from '../../../../../website/src/models/group'; -import { model as User } from '../../../../../website/src/models/user'; -import * as Tasks from '../../../../../website/src/models/task'; +import { model as Challenge } from '../../../../../website/server/models/challenge'; +import { model as Group } from '../../../../../website/server/models/group'; +import { model as User } from '../../../../../website/server/models/user'; +import * as Tasks from '../../../../../website/server/models/task'; import { each } from 'lodash'; import { generateHistory } from '../../../../helpers/api-unit.helper.js'; diff --git a/test/api/v3/unit/models/user.test.js b/test/api/v3/unit/models/user.test.js index 414052a3b6..d7f509712c 100644 --- a/test/api/v3/unit/models/user.test.js +++ b/test/api/v3/unit/models/user.test.js @@ -1,4 +1,4 @@ -import { model as User } from '../../../../../website/src/models/user'; +import { model as User } from '../../../../../website/server/models/user'; describe('User Model', () => { it('keeps user._tmp when calling .toJSON', () => { diff --git a/test/helpers/api-integration/translate.js b/test/helpers/api-integration/translate.js index 1e1ab83869..3ef7d68541 100644 --- a/test/helpers/api-integration/translate.js +++ b/test/helpers/api-integration/translate.js @@ -1,5 +1,5 @@ import i18n from '../../../common/script/i18n'; -i18n.translations = require('../../../website/src/libs/api-v3/i18n').translations; +i18n.translations = require('../../../website/server/libs/api-v3/i18n').translations; // Use this to verify error messages returned by the server // That way, if the translated string changes, the test diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index 3717d27a11..f257c3543f 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -5,7 +5,7 @@ import Bluebird from 'bluebird'; import { v4 as generateUUID } from 'uuid'; import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes'; import { requester } from '../requester'; -import * as Tasks from '../../../../website/src/models/task'; +import * as Tasks from '../../../../website/server/models/task'; // Creates a new user and returns it // If you need the user to have specific requirements, diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index beeac66098..ca8b57ae7f 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -1,12 +1,12 @@ -import '../../website/src/libs/api-v3/i18n'; +import '../../website/server/libs/api-v3/i18n'; import mongoose from 'mongoose'; import { defaultsDeep as defaults } from 'lodash'; -import { model as User } from '../../website/src/models/user'; -import { model as Group } from '../../website/src/models/group'; +import { model as User } from '../../website/server/models/user'; +import { model as Group } from '../../website/server/models/group'; import mongo from './mongo'; // eslint-disable-line import moment from 'moment'; import i18n from '../../common/script/i18n'; -import * as Tasks from '../../website/src/models/task'; +import * as Tasks from '../../website/server/models/task'; afterEach((done) => { sandbox.restore(); diff --git a/test/helpers/common.helper.js b/test/helpers/common.helper.js index 96064b8142..4cd82ca4b4 100644 --- a/test/helpers/common.helper.js +++ b/test/helpers/common.helper.js @@ -1,13 +1,13 @@ import mongoose from 'mongoose'; import { wrap as wrapUser } from '../../common/script/index'; -import { model as User } from '../../website/src/models/user'; +import { model as User } from '../../website/server/models/user'; import { DailySchema, HabitSchema, RewardSchema, TodoSchema, -} from '../../website/src/models/task'; +} from '../../website/server/models/task'; export function generateUser (options = {}) { let user = new User(options).toObject(); diff --git a/test/helpers/content.helper.js b/test/helpers/content.helper.js index b77ff83383..c1ac3c5657 100644 --- a/test/helpers/content.helper.js +++ b/test/helpers/content.helper.js @@ -1,6 +1,6 @@ require('./globals.helper'); import i18n from '../../common/script/i18n'; -i18n.translations = require('../../website/src/libs/api-v3/i18n').translations; +i18n.translations = require('../../website/server/libs/api-v3/i18n').translations; export const STRING_ERROR_MSG = 'Error processing the string. Please see Help > Report a Bug.'; export const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/; diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index eaaf8b7bfc..d57474bc4e 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -23,17 +23,17 @@ import mongoose from 'mongoose'; // Load nconf for unit tests //------------------------------ if (process.env.LOAD_SERVER === '0') { // when the server is in a different process we simply connect to mongoose - require('../../website/src/libs/api-v3/setupNconf')('./config.json'); + require('../../website/server/libs/api-v3/setupNconf')('./config.json'); // Use Q promises instead of mpromise in mongoose mongoose.Promise = Bluebird; mongoose.connect(nconf.get('NODE_DB_URI')); } else { // When running tests and the server in the same process - require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); + require('../../website/server/libs/api-v3/setupNconf')('./config.json.example'); nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); nconf.set('NODE_ENV', 'test'); nconf.set('IS_TEST', true); // We require src/server and npt src/index because // 1. nconf is already setup // 2. we don't need clustering - require('../../website/src/server'); + require('../../website/server/server'); } diff --git a/test/helpers/mongo.js b/test/helpers/mongo.js index 6b51ff2af0..9ad5cc8700 100644 --- a/test/helpers/mongo.js +++ b/test/helpers/mongo.js @@ -1,5 +1,5 @@ import mongoose from 'mongoose'; -import { TAVERN_ID } from '../../website/src/models/group'; +import { TAVERN_ID } from '../../website/server/models/group'; // Useful for checking things that have been deleted, // but you no longer have access to, diff --git a/test/migrations/20150605_ultimate_achievement_backfill.coffee b/test/migrations/20150605_ultimate_achievement_backfill.coffee index b5646020b0..689b49cb67 100644 --- a/test/migrations/20150605_ultimate_achievement_backfill.coffee +++ b/test/migrations/20150605_ultimate_achievement_backfill.coffee @@ -2,7 +2,7 @@ TEST_DB = process.env.DB_NAME = 'habitrpg_migration_test' process.env.NODE_DB_URI = 'mongodb://localhost/' + TEST_DB -app = require('../../website/src/server') +app = require('../../website/server/server') sh = require('shelljs') runMigration = -> diff --git a/test/server_side/analytics.test.js b/test/server_side/analytics.test.js index 56dd8f1787..5257303242 100644 --- a/test/server_side/analytics.test.js +++ b/test/server_side/analytics.test.js @@ -30,7 +30,7 @@ describe('analytics', function() { }); describe('init', function() { - var analytics = rewire('../../website/src/libs/api-v2/analytics'); + var analytics = rewire('../../website/server/libs/api-v2/analytics'); it('throws an error if no options are passed in', function() { expect(analytics).to.throw('No options provided'); @@ -62,7 +62,7 @@ describe('analytics', function() { describe('track', function() { var analyticsData, event_type; - var analytics = rewire('../../website/src/libs/api-v2/analytics'); + var analytics = rewire('../../website/server/libs/api-v2/analytics'); var initializedAnalytics; beforeEach(function() { @@ -370,7 +370,7 @@ describe('analytics', function() { var purchaseData; - var analytics = rewire('../../website/src/libs/api-v2/analytics'); + var analytics = rewire('../../website/server/libs/api-v2/analytics'); var initializedAnalytics; beforeEach(function() { diff --git a/test/server_side/controllers/groups.test.js b/test/server_side/controllers/groups.test.js index 664a5b8d11..bf12df321b 100644 --- a/test/server_side/controllers/groups.test.js +++ b/test/server_side/controllers/groups.test.js @@ -4,11 +4,11 @@ chai.use(require("sinon-chai")); var expect = chai.expect; var Bluebird = require('bluebird'); -var Group = require('../../../website/src/models/group').model; -var groupsController = require('../../../website/src/controllers/api-v2/groups'); +var Group = require('../../../website/server/models/group').model; +var groupsController = require('../../../website/server/controllers/api-v2/groups'); describe('Groups Controller', function() { - var utils = require('../../../website/src/libs/api-v2/utils'); + var utils = require('../../../website/server/libs/api-v2/utils'); describe('#invite', function() { var res, req, user, group; @@ -69,7 +69,7 @@ describe('Groups Controller', function() { }); context('emails', function() { - var EmailUnsubscription = require('../../../website/src/models/emailUnsubscription').model; + var EmailUnsubscription = require('../../../website/server/models/emailUnsubscription').model; var execStub, selectStub; beforeEach(function() { diff --git a/test/server_side/controllers/user.test.js b/test/server_side/controllers/user.test.js index 2be7f7215a..59589df511 100644 --- a/test/server_side/controllers/user.test.js +++ b/test/server_side/controllers/user.test.js @@ -4,7 +4,7 @@ chai.use(require("sinon-chai")) var expect = chai.expect var rewire = require('rewire'); -var userController = rewire('../../../website/src/controllers/api-v2/user'); +var userController = rewire('../../../website/server/controllers/api-v2/user'); describe('User Controller', function() { @@ -359,7 +359,7 @@ describe('User Controller', function() { }); it('sends webhooks', function() { - var webhook = require('../../../website/src/libs/webhook'); + var webhook = require('../../../website/server/libs/webhook'); sinon.spy(webhook, 'sendTaskWebhook'); userController.score(req, res); @@ -384,7 +384,7 @@ describe('User Controller', function() { }); context('save callback dealing with non challenge tasks', function() { - var Challenge = require('../../../website/src/models/challenge').model; + var Challenge = require('../../../website/server/models/challenge').model; beforeEach(function() { user.save.yields(null, user); @@ -446,7 +446,7 @@ describe('User Controller', function() { }); context('save callback dealing with challenge tasks', function() { - var Challenge = require('../../../website/src/models/challenge').model; + var Challenge = require('../../../website/server/models/challenge').model; var chal; beforeEach(function() { diff --git a/test/server_side/webhooks.test.js b/test/server_side/webhooks.test.js index 45e042db8a..621d0daba3 100644 --- a/test/server_side/webhooks.test.js +++ b/test/server_side/webhooks.test.js @@ -4,7 +4,7 @@ chai.use(require("sinon-chai")) var expect = chai.expect var rewire = require('rewire'); -var webhook = rewire('../../website/src/libs/api-v2/webhook'); +var webhook = rewire('../../website/server/libs/api-v2/webhook'); describe('webhooks', function() { var postSpy; diff --git a/website/public/500.html b/website/client/500.html similarity index 100% rename from website/public/500.html rename to website/client/500.html diff --git a/website/public/apple-touch-icon-114-precomposed.png b/website/client/apple-touch-icon-114-precomposed.png similarity index 100% rename from website/public/apple-touch-icon-114-precomposed.png rename to website/client/apple-touch-icon-114-precomposed.png diff --git a/website/public/apple-touch-icon-144-precomposed.png b/website/client/apple-touch-icon-144-precomposed.png similarity index 100% rename from website/public/apple-touch-icon-144-precomposed.png rename to website/client/apple-touch-icon-144-precomposed.png diff --git a/website/public/apple-touch-icon-57-precomposed.png b/website/client/apple-touch-icon-57-precomposed.png similarity index 100% rename from website/public/apple-touch-icon-57-precomposed.png rename to website/client/apple-touch-icon-57-precomposed.png diff --git a/website/public/apple-touch-icon-72-precomposed.png b/website/client/apple-touch-icon-72-precomposed.png similarity index 100% rename from website/public/apple-touch-icon-72-precomposed.png rename to website/client/apple-touch-icon-72-precomposed.png diff --git a/website/public/apple-touch-icon-precomposed.png b/website/client/apple-touch-icon-precomposed.png similarity index 100% rename from website/public/apple-touch-icon-precomposed.png rename to website/client/apple-touch-icon-precomposed.png diff --git a/website/public/cake.png b/website/client/cake.png similarity index 100% rename from website/public/cake.png rename to website/client/cake.png diff --git a/website/public/community-guidelines-images/backCorner.png b/website/client/community-guidelines-images/backCorner.png similarity index 100% rename from website/public/community-guidelines-images/backCorner.png rename to website/client/community-guidelines-images/backCorner.png diff --git a/website/public/community-guidelines-images/beingHabitican.png b/website/client/community-guidelines-images/beingHabitican.png similarity index 100% rename from website/public/community-guidelines-images/beingHabitican.png rename to website/client/community-guidelines-images/beingHabitican.png diff --git a/website/public/community-guidelines-images/consequences.png b/website/client/community-guidelines-images/consequences.png similarity index 100% rename from website/public/community-guidelines-images/consequences.png rename to website/client/community-guidelines-images/consequences.png diff --git a/website/public/community-guidelines-images/contributing.png b/website/client/community-guidelines-images/contributing.png similarity index 100% rename from website/public/community-guidelines-images/contributing.png rename to website/client/community-guidelines-images/contributing.png diff --git a/website/public/community-guidelines-images/github.gif b/website/client/community-guidelines-images/github.gif similarity index 100% rename from website/public/community-guidelines-images/github.gif rename to website/client/community-guidelines-images/github.gif diff --git a/website/public/community-guidelines-images/infractions.png b/website/client/community-guidelines-images/infractions.png similarity index 100% rename from website/public/community-guidelines-images/infractions.png rename to website/client/community-guidelines-images/infractions.png diff --git a/website/public/community-guidelines-images/intro.png b/website/client/community-guidelines-images/intro.png similarity index 100% rename from website/public/community-guidelines-images/intro.png rename to website/client/community-guidelines-images/intro.png diff --git a/website/public/community-guidelines-images/moderators.png b/website/client/community-guidelines-images/moderators.png similarity index 100% rename from website/public/community-guidelines-images/moderators.png rename to website/client/community-guidelines-images/moderators.png diff --git a/website/public/community-guidelines-images/publicGuilds.png b/website/client/community-guidelines-images/publicGuilds.png similarity index 100% rename from website/public/community-guidelines-images/publicGuilds.png rename to website/client/community-guidelines-images/publicGuilds.png diff --git a/website/public/community-guidelines-images/publicSpaces.png b/website/client/community-guidelines-images/publicSpaces.png similarity index 100% rename from website/public/community-guidelines-images/publicSpaces.png rename to website/client/community-guidelines-images/publicSpaces.png diff --git a/website/public/community-guidelines-images/restoration.png b/website/client/community-guidelines-images/restoration.png similarity index 100% rename from website/public/community-guidelines-images/restoration.png rename to website/client/community-guidelines-images/restoration.png diff --git a/website/public/community-guidelines-images/staff.png b/website/client/community-guidelines-images/staff.png similarity index 100% rename from website/public/community-guidelines-images/staff.png rename to website/client/community-guidelines-images/staff.png diff --git a/website/public/community-guidelines-images/tavern.png b/website/client/community-guidelines-images/tavern.png similarity index 100% rename from website/public/community-guidelines-images/tavern.png rename to website/client/community-guidelines-images/tavern.png diff --git a/website/public/community-guidelines-images/trello.png b/website/client/community-guidelines-images/trello.png similarity index 100% rename from website/public/community-guidelines-images/trello.png rename to website/client/community-guidelines-images/trello.png diff --git a/website/public/community-guidelines-images/wiki.png b/website/client/community-guidelines-images/wiki.png similarity index 100% rename from website/public/community-guidelines-images/wiki.png rename to website/client/community-guidelines-images/wiki.png diff --git a/website/public/css/README.md b/website/client/css/README.md similarity index 100% rename from website/public/css/README.md rename to website/client/css/README.md diff --git a/website/public/css/alerts.styl b/website/client/css/alerts.styl similarity index 100% rename from website/public/css/alerts.styl rename to website/client/css/alerts.styl diff --git a/website/public/css/avatar.styl b/website/client/css/avatar.styl similarity index 100% rename from website/public/css/avatar.styl rename to website/client/css/avatar.styl diff --git a/website/public/css/challenges.styl b/website/client/css/challenges.styl similarity index 100% rename from website/public/css/challenges.styl rename to website/client/css/challenges.styl diff --git a/website/public/css/classes.styl b/website/client/css/classes.styl similarity index 100% rename from website/public/css/classes.styl rename to website/client/css/classes.styl diff --git a/website/public/css/customizer.styl b/website/client/css/customizer.styl similarity index 100% rename from website/public/css/customizer.styl rename to website/client/css/customizer.styl diff --git a/website/public/css/filters.styl b/website/client/css/filters.styl similarity index 100% rename from website/public/css/filters.styl rename to website/client/css/filters.styl diff --git a/website/public/css/footer.styl b/website/client/css/footer.styl similarity index 100% rename from website/public/css/footer.styl rename to website/client/css/footer.styl diff --git a/website/public/css/game-pane.styl b/website/client/css/game-pane.styl similarity index 100% rename from website/public/css/game-pane.styl rename to website/client/css/game-pane.styl diff --git a/website/public/css/global-colors.styl b/website/client/css/global-colors.styl similarity index 100% rename from website/public/css/global-colors.styl rename to website/client/css/global-colors.styl diff --git a/website/public/css/global-modules.styl b/website/client/css/global-modules.styl similarity index 100% rename from website/public/css/global-modules.styl rename to website/client/css/global-modules.styl diff --git a/website/public/css/header.styl b/website/client/css/header.styl similarity index 100% rename from website/public/css/header.styl rename to website/client/css/header.styl diff --git a/website/public/css/helpers.styl b/website/client/css/helpers.styl similarity index 100% rename from website/public/css/helpers.styl rename to website/client/css/helpers.styl diff --git a/website/public/css/index.styl b/website/client/css/index.styl similarity index 100% rename from website/public/css/index.styl rename to website/client/css/index.styl diff --git a/website/public/css/inventory.styl b/website/client/css/inventory.styl similarity index 100% rename from website/public/css/inventory.styl rename to website/client/css/inventory.styl diff --git a/website/public/css/items.styl b/website/client/css/items.styl similarity index 100% rename from website/public/css/items.styl rename to website/client/css/items.styl diff --git a/website/public/css/menu.styl b/website/client/css/menu.styl similarity index 100% rename from website/public/css/menu.styl rename to website/client/css/menu.styl diff --git a/website/public/css/no-script.styl b/website/client/css/no-script.styl similarity index 100% rename from website/public/css/no-script.styl rename to website/client/css/no-script.styl diff --git a/website/public/css/npcs.styl b/website/client/css/npcs.styl similarity index 100% rename from website/public/css/npcs.styl rename to website/client/css/npcs.styl diff --git a/website/public/css/options.styl b/website/client/css/options.styl similarity index 100% rename from website/public/css/options.styl rename to website/client/css/options.styl diff --git a/website/public/css/quests.styl b/website/client/css/quests.styl similarity index 100% rename from website/public/css/quests.styl rename to website/client/css/quests.styl diff --git a/website/public/css/scrollbars.styl b/website/client/css/scrollbars.styl similarity index 100% rename from website/public/css/scrollbars.styl rename to website/client/css/scrollbars.styl diff --git a/website/public/css/shared.styl b/website/client/css/shared.styl similarity index 100% rename from website/public/css/shared.styl rename to website/client/css/shared.styl diff --git a/website/public/css/static.styl b/website/client/css/static.styl similarity index 100% rename from website/public/css/static.styl rename to website/client/css/static.styl diff --git a/website/public/css/tasks.styl b/website/client/css/tasks.styl similarity index 100% rename from website/public/css/tasks.styl rename to website/client/css/tasks.styl diff --git a/website/public/css/variables/screen-size.styl b/website/client/css/variables/screen-size.styl similarity index 100% rename from website/public/css/variables/screen-size.styl rename to website/client/css/variables/screen-size.styl diff --git a/website/public/emails/images/10-days-recapture-v1.png b/website/client/emails/images/10-days-recapture-v1.png similarity index 100% rename from website/public/emails/images/10-days-recapture-v1.png rename to website/client/emails/images/10-days-recapture-v1.png diff --git a/website/public/emails/images/3-days-1-month-recapture-v1.png b/website/client/emails/images/3-days-1-month-recapture-v1.png similarity index 100% rename from website/public/emails/images/3-days-1-month-recapture-v1.png rename to website/client/emails/images/3-days-1-month-recapture-v1.png diff --git a/website/public/emails/images/PROMO-Enchanted-Armoire-v1.png b/website/client/emails/images/PROMO-Enchanted-Armoire-v1.png similarity index 100% rename from website/public/emails/images/PROMO-Enchanted-Armoire-v1.png rename to website/client/emails/images/PROMO-Enchanted-Armoire-v1.png diff --git a/website/public/emails/images/android-promo-v1.png b/website/client/emails/images/android-promo-v1.png similarity index 100% rename from website/public/emails/images/android-promo-v1.png rename to website/client/emails/images/android-promo-v1.png diff --git a/website/public/emails/images/iphone-promo-v1.png b/website/client/emails/images/iphone-promo-v1.png similarity index 100% rename from website/public/emails/images/iphone-promo-v1.png rename to website/client/emails/images/iphone-promo-v1.png diff --git a/website/public/emails/images/one-day-v1.png b/website/client/emails/images/one-day-v1.png similarity index 100% rename from website/public/emails/images/one-day-v1.png rename to website/client/emails/images/one-day-v1.png diff --git a/website/public/emails/images/spring-2015-00-v1.png b/website/client/emails/images/spring-2015-00-v1.png similarity index 100% rename from website/public/emails/images/spring-2015-00-v1.png rename to website/client/emails/images/spring-2015-00-v1.png diff --git a/website/public/emails/images/spring-2015-01-v1.png b/website/client/emails/images/spring-2015-01-v1.png similarity index 100% rename from website/public/emails/images/spring-2015-01-v1.png rename to website/client/emails/images/spring-2015-01-v1.png diff --git a/website/public/emails/images/subscription-begins-time-travelers-v1.png b/website/client/emails/images/subscription-begins-time-travelers-v1.png similarity index 100% rename from website/public/emails/images/subscription-begins-time-travelers-v1.png rename to website/client/emails/images/subscription-begins-time-travelers-v1.png diff --git a/website/public/emails/images/subscription-begins-v1.png b/website/client/emails/images/subscription-begins-v1.png similarity index 100% rename from website/public/emails/images/subscription-begins-v1.png rename to website/client/emails/images/subscription-begins-v1.png diff --git a/website/public/favicon.ico b/website/client/favicon.ico similarity index 100% rename from website/public/favicon.ico rename to website/client/favicon.ico diff --git a/website/public/favicon_192x192.png b/website/client/favicon_192x192.png similarity index 100% rename from website/public/favicon_192x192.png rename to website/client/favicon_192x192.png diff --git a/website/public/fontello/LICENSE.txt b/website/client/fontello/LICENSE.txt similarity index 100% rename from website/public/fontello/LICENSE.txt rename to website/client/fontello/LICENSE.txt diff --git a/website/public/fontello/README.txt b/website/client/fontello/README.txt similarity index 100% rename from website/public/fontello/README.txt rename to website/client/fontello/README.txt diff --git a/website/public/fontello/css/animation.css b/website/client/fontello/css/animation.css similarity index 100% rename from website/public/fontello/css/animation.css rename to website/client/fontello/css/animation.css diff --git a/website/public/fontello/css/fontelico-codes.css b/website/client/fontello/css/fontelico-codes.css similarity index 100% rename from website/public/fontello/css/fontelico-codes.css rename to website/client/fontello/css/fontelico-codes.css diff --git a/website/public/fontello/css/fontelico-embedded.css b/website/client/fontello/css/fontelico-embedded.css similarity index 100% rename from website/public/fontello/css/fontelico-embedded.css rename to website/client/fontello/css/fontelico-embedded.css diff --git a/website/public/fontello/css/fontelico-ie7-codes.css b/website/client/fontello/css/fontelico-ie7-codes.css similarity index 100% rename from website/public/fontello/css/fontelico-ie7-codes.css rename to website/client/fontello/css/fontelico-ie7-codes.css diff --git a/website/public/fontello/css/fontelico-ie7.css b/website/client/fontello/css/fontelico-ie7.css similarity index 100% rename from website/public/fontello/css/fontelico-ie7.css rename to website/client/fontello/css/fontelico-ie7.css diff --git a/website/public/fontello/css/fontelico.css b/website/client/fontello/css/fontelico.css similarity index 100% rename from website/public/fontello/css/fontelico.css rename to website/client/fontello/css/fontelico.css diff --git a/website/public/fontello/demo.html b/website/client/fontello/demo.html similarity index 100% rename from website/public/fontello/demo.html rename to website/client/fontello/demo.html diff --git a/website/public/fontello/font/fontelico.eot b/website/client/fontello/font/fontelico.eot similarity index 100% rename from website/public/fontello/font/fontelico.eot rename to website/client/fontello/font/fontelico.eot diff --git a/website/public/fontello/font/fontelico.svg b/website/client/fontello/font/fontelico.svg similarity index 100% rename from website/public/fontello/font/fontelico.svg rename to website/client/fontello/font/fontelico.svg diff --git a/website/public/fontello/font/fontelico.ttf b/website/client/fontello/font/fontelico.ttf similarity index 100% rename from website/public/fontello/font/fontelico.ttf rename to website/client/fontello/font/fontelico.ttf diff --git a/website/public/fontello/font/fontelico.woff b/website/client/fontello/font/fontelico.woff similarity index 100% rename from website/public/fontello/font/fontelico.woff rename to website/client/fontello/font/fontelico.woff diff --git a/website/public/front/README.md b/website/client/front/README.md similarity index 100% rename from website/public/front/README.md rename to website/client/front/README.md diff --git a/website/public/front/css/blockScroll.css b/website/client/front/css/blockScroll.css similarity index 100% rename from website/public/front/css/blockScroll.css rename to website/client/front/css/blockScroll.css diff --git a/website/public/front/css/bootstrap.min.css b/website/client/front/css/bootstrap.min.css similarity index 100% rename from website/public/front/css/bootstrap.min.css rename to website/client/front/css/bootstrap.min.css diff --git a/website/public/front/css/fixed-positioning.css b/website/client/front/css/fixed-positioning.css similarity index 100% rename from website/public/front/css/fixed-positioning.css rename to website/client/front/css/fixed-positioning.css diff --git a/website/public/front/fonts/glyphicons-halflings-regular.eot b/website/client/front/fonts/glyphicons-halflings-regular.eot similarity index 100% rename from website/public/front/fonts/glyphicons-halflings-regular.eot rename to website/client/front/fonts/glyphicons-halflings-regular.eot diff --git a/website/public/front/fonts/glyphicons-halflings-regular.svg b/website/client/front/fonts/glyphicons-halflings-regular.svg similarity index 100% rename from website/public/front/fonts/glyphicons-halflings-regular.svg rename to website/client/front/fonts/glyphicons-halflings-regular.svg diff --git a/website/public/front/fonts/glyphicons-halflings-regular.ttf b/website/client/front/fonts/glyphicons-halflings-regular.ttf similarity index 100% rename from website/public/front/fonts/glyphicons-halflings-regular.ttf rename to website/client/front/fonts/glyphicons-halflings-regular.ttf diff --git a/website/public/front/fonts/glyphicons-halflings-regular.woff b/website/client/front/fonts/glyphicons-halflings-regular.woff similarity index 100% rename from website/public/front/fonts/glyphicons-halflings-regular.woff rename to website/client/front/fonts/glyphicons-halflings-regular.woff diff --git a/website/public/front/fonts/glyphicons-halflings-regular.woff2 b/website/client/front/fonts/glyphicons-halflings-regular.woff2 similarity index 100% rename from website/public/front/fonts/glyphicons-halflings-regular.woff2 rename to website/client/front/fonts/glyphicons-halflings-regular.woff2 diff --git a/website/public/front/images/Feeding_Time.png b/website/client/front/images/Feeding_Time.png similarity index 100% rename from website/public/front/images/Feeding_Time.png rename to website/client/front/images/Feeding_Time.png diff --git a/website/public/front/images/Guilds Sample Screen.png b/website/client/front/images/Guilds Sample Screen.png similarity index 100% rename from website/public/front/images/Guilds Sample Screen.png rename to website/client/front/images/Guilds Sample Screen.png diff --git a/website/public/front/images/HabitRPGPromoPostCard6.png b/website/client/front/images/HabitRPGPromoPostCard6.png similarity index 100% rename from website/public/front/images/HabitRPGPromoPostCard6.png rename to website/client/front/images/HabitRPGPromoPostCard6.png diff --git a/website/public/front/images/HabitRPGPromoThin.png b/website/client/front/images/HabitRPGPromoThin.png similarity index 100% rename from website/public/front/images/HabitRPGPromoThin.png rename to website/client/front/images/HabitRPGPromoThin.png diff --git a/website/public/front/images/Habitica_banner_by_uncommoncriminal.png b/website/client/front/images/Habitica_banner_by_uncommoncriminal.png similarity index 100% rename from website/public/front/images/Habitica_banner_by_uncommoncriminal.png rename to website/client/front/images/Habitica_banner_by_uncommoncriminal.png diff --git a/website/public/front/images/Habitica_map_by_uncommoncriminal.png b/website/client/front/images/Habitica_map_by_uncommoncriminal.png similarity index 100% rename from website/public/front/images/Habitica_map_by_uncommoncriminal.png rename to website/client/front/images/Habitica_map_by_uncommoncriminal.png diff --git a/website/public/front/images/Healer.png b/website/client/front/images/Healer.png similarity index 100% rename from website/public/front/images/Healer.png rename to website/client/front/images/Healer.png diff --git a/website/public/front/images/Mount.png b/website/client/front/images/Mount.png similarity index 100% rename from website/public/front/images/Mount.png rename to website/client/front/images/Mount.png diff --git a/website/public/front/images/Mount_Body_Dragon-Golden.png b/website/client/front/images/Mount_Body_Dragon-Golden.png similarity index 100% rename from website/public/front/images/Mount_Body_Dragon-Golden.png rename to website/client/front/images/Mount_Body_Dragon-Golden.png diff --git a/website/public/front/images/Mount_Body_Dragon-Red.png b/website/client/front/images/Mount_Body_Dragon-Red.png similarity index 100% rename from website/public/front/images/Mount_Body_Dragon-Red.png rename to website/client/front/images/Mount_Body_Dragon-Red.png diff --git a/website/public/front/images/Mount_Body_Wolf-Base.png b/website/client/front/images/Mount_Body_Wolf-Base.png similarity index 100% rename from website/public/front/images/Mount_Body_Wolf-Base.png rename to website/client/front/images/Mount_Body_Wolf-Base.png diff --git a/website/public/front/images/Mount_Head_Dragon-Golden.png b/website/client/front/images/Mount_Head_Dragon-Golden.png similarity index 100% rename from website/public/front/images/Mount_Head_Dragon-Golden.png rename to website/client/front/images/Mount_Head_Dragon-Golden.png diff --git a/website/public/front/images/Mount_Head_Dragon-Red.png b/website/client/front/images/Mount_Head_Dragon-Red.png similarity index 100% rename from website/public/front/images/Mount_Head_Dragon-Red.png rename to website/client/front/images/Mount_Head_Dragon-Red.png diff --git a/website/public/front/images/Mount_Head_Wolf-Base.png b/website/client/front/images/Mount_Head_Wolf-Base.png similarity index 100% rename from website/public/front/images/Mount_Head_Wolf-Base.png rename to website/client/front/images/Mount_Head_Wolf-Base.png diff --git a/website/public/front/images/Party-Header.png b/website/client/front/images/Party-Header.png similarity index 100% rename from website/public/front/images/Party-Header.png rename to website/client/front/images/Party-Header.png diff --git a/website/public/front/images/Pet-Dragon-Red.png b/website/client/front/images/Pet-Dragon-Red.png similarity index 100% rename from website/public/front/images/Pet-Dragon-Red.png rename to website/client/front/images/Pet-Dragon-Red.png diff --git a/website/public/front/images/Pet-Fox-Red.png b/website/client/front/images/Pet-Fox-Red.png similarity index 100% rename from website/public/front/images/Pet-Fox-Red.png rename to website/client/front/images/Pet-Fox-Red.png diff --git a/website/public/front/images/Promo_springclasses2015.png b/website/client/front/images/Promo_springclasses2015.png similarity index 100% rename from website/public/front/images/Promo_springclasses2015.png rename to website/client/front/images/Promo_springclasses2015.png diff --git a/website/public/front/images/Quest_dilatory_drag'on.png b/website/client/front/images/Quest_dilatory_drag'on.png similarity index 100% rename from website/public/front/images/Quest_dilatory_drag'on.png rename to website/client/front/images/Quest_dilatory_drag'on.png diff --git a/website/public/front/images/Quest_dilatory_drag'onSmall.png b/website/client/front/images/Quest_dilatory_drag'onSmall.png similarity index 100% rename from website/public/front/images/Quest_dilatory_drag'onSmall.png rename to website/client/front/images/Quest_dilatory_drag'onSmall.png diff --git a/website/public/front/images/Rogue.png b/website/client/front/images/Rogue.png similarity index 100% rename from website/public/front/images/Rogue.png rename to website/client/front/images/Rogue.png diff --git a/website/public/front/images/SAMPLEadventurers.png b/website/client/front/images/SAMPLEadventurers.png similarity index 100% rename from website/public/front/images/SAMPLEadventurers.png rename to website/client/front/images/SAMPLEadventurers.png diff --git a/website/public/front/images/TVreward.png b/website/client/front/images/TVreward.png similarity index 100% rename from website/public/front/images/TVreward.png rename to website/client/front/images/TVreward.png diff --git a/website/public/front/images/VICE_by_Baconsaur.png b/website/client/front/images/VICE_by_Baconsaur.png similarity index 100% rename from website/public/front/images/VICE_by_Baconsaur.png rename to website/client/front/images/VICE_by_Baconsaur.png diff --git a/website/public/front/images/Warrior.png b/website/client/front/images/Warrior.png similarity index 100% rename from website/public/front/images/Warrior.png rename to website/client/front/images/Warrior.png diff --git a/website/public/front/images/Wizard.png b/website/client/front/images/Wizard.png similarity index 100% rename from website/public/front/images/Wizard.png rename to website/client/front/images/Wizard.png diff --git a/website/public/front/images/achievement-perfect.png b/website/client/front/images/achievement-perfect.png similarity index 100% rename from website/public/front/images/achievement-perfect.png rename to website/client/front/images/achievement-perfect.png diff --git a/website/public/front/images/achievement-triadbingo.png b/website/client/front/images/achievement-triadbingo.png similarity index 100% rename from website/public/front/images/achievement-triadbingo.png rename to website/client/front/images/achievement-triadbingo.png diff --git a/website/public/front/images/avatar/Warrior.png b/website/client/front/images/avatar/Warrior.png similarity index 100% rename from website/public/front/images/avatar/Warrior.png rename to website/client/front/images/avatar/Warrior.png diff --git a/website/public/front/images/avatar/avatar.png b/website/client/front/images/avatar/avatar.png similarity index 100% rename from website/public/front/images/avatar/avatar.png rename to website/client/front/images/avatar/avatar.png diff --git a/website/public/front/images/avatar/avatarstatic.png b/website/client/front/images/avatar/avatarstatic.png similarity index 100% rename from website/public/front/images/avatar/avatarstatic.png rename to website/client/front/images/avatar/avatarstatic.png diff --git a/website/public/front/images/avatar/hair_bangs_1_brown.png b/website/client/front/images/avatar/hair_bangs_1_brown.png similarity index 100% rename from website/public/front/images/avatar/hair_bangs_1_brown.png rename to website/client/front/images/avatar/hair_bangs_1_brown.png diff --git a/website/public/front/images/avatar/head_0.png b/website/client/front/images/avatar/head_0.png similarity index 100% rename from website/public/front/images/avatar/head_0.png rename to website/client/front/images/avatar/head_0.png diff --git a/website/public/front/images/avatar/head_warrior_3.png b/website/client/front/images/avatar/head_warrior_3.png similarity index 100% rename from website/public/front/images/avatar/head_warrior_3.png rename to website/client/front/images/avatar/head_warrior_3.png diff --git a/website/public/front/images/avatar/head_warrior_5.png b/website/client/front/images/avatar/head_warrior_5.png similarity index 100% rename from website/public/front/images/avatar/head_warrior_5.png rename to website/client/front/images/avatar/head_warrior_5.png diff --git a/website/public/front/images/avatar/shield_warrior_3.png b/website/client/front/images/avatar/shield_warrior_3.png similarity index 100% rename from website/public/front/images/avatar/shield_warrior_3.png rename to website/client/front/images/avatar/shield_warrior_3.png diff --git a/website/public/front/images/avatar/shield_warrior_5.png b/website/client/front/images/avatar/shield_warrior_5.png similarity index 100% rename from website/public/front/images/avatar/shield_warrior_5.png rename to website/client/front/images/avatar/shield_warrior_5.png diff --git a/website/public/front/images/avatar/skin_f5a76e.png b/website/client/front/images/avatar/skin_f5a76e.png similarity index 100% rename from website/public/front/images/avatar/skin_f5a76e.png rename to website/client/front/images/avatar/skin_f5a76e.png diff --git a/website/public/front/images/avatar/slim_armor_warrior_3.png b/website/client/front/images/avatar/slim_armor_warrior_3.png similarity index 100% rename from website/public/front/images/avatar/slim_armor_warrior_3.png rename to website/client/front/images/avatar/slim_armor_warrior_3.png diff --git a/website/public/front/images/avatar/slim_armor_warrior_5.png b/website/client/front/images/avatar/slim_armor_warrior_5.png similarity index 100% rename from website/public/front/images/avatar/slim_armor_warrior_5.png rename to website/client/front/images/avatar/slim_armor_warrior_5.png diff --git a/website/public/front/images/avatar/slim_shirt_black.png b/website/client/front/images/avatar/slim_shirt_black.png similarity index 100% rename from website/public/front/images/avatar/slim_shirt_black.png rename to website/client/front/images/avatar/slim_shirt_black.png diff --git a/website/public/front/images/avatar/weapon_healer_6.png b/website/client/front/images/avatar/weapon_healer_6.png similarity index 100% rename from website/public/front/images/avatar/weapon_healer_6.png rename to website/client/front/images/avatar/weapon_healer_6.png diff --git a/website/public/front/images/avatar/weapon_warrior_3.png b/website/client/front/images/avatar/weapon_warrior_3.png similarity index 100% rename from website/public/front/images/avatar/weapon_warrior_3.png rename to website/client/front/images/avatar/weapon_warrior_3.png diff --git a/website/public/front/images/avatar/weapon_warrior_5.png b/website/client/front/images/avatar/weapon_warrior_5.png similarity index 100% rename from website/public/front/images/avatar/weapon_warrior_5.png rename to website/client/front/images/avatar/weapon_warrior_5.png diff --git a/website/public/front/images/blackish_fox_by_kellllly-d7pzd46.png b/website/client/front/images/blackish_fox_by_kellllly-d7pzd46.png similarity index 100% rename from website/public/front/images/blackish_fox_by_kellllly-d7pzd46.png rename to website/client/front/images/blackish_fox_by_kellllly-d7pzd46.png diff --git a/website/public/front/images/coding_by_phoneix_faerie.png b/website/client/front/images/coding_by_phoneix_faerie.png similarity index 100% rename from website/public/front/images/coding_by_phoneix_faerie.png rename to website/client/front/images/coding_by_phoneix_faerie.png diff --git a/website/public/front/images/devices.png b/website/client/front/images/devices.png similarity index 100% rename from website/public/front/images/devices.png rename to website/client/front/images/devices.png diff --git a/website/public/front/images/explosion.jpg b/website/client/front/images/explosion.jpg similarity index 100% rename from website/public/front/images/explosion.jpg rename to website/client/front/images/explosion.jpg diff --git a/website/public/front/images/explosion.png b/website/client/front/images/explosion.png similarity index 100% rename from website/public/front/images/explosion.png rename to website/client/front/images/explosion.png diff --git a/website/public/front/images/habitrpg_pixel.png b/website/client/front/images/habitrpg_pixel.png similarity index 100% rename from website/public/front/images/habitrpg_pixel.png rename to website/client/front/images/habitrpg_pixel.png diff --git a/website/public/front/images/icon175x175.png b/website/client/front/images/icon175x175.png similarity index 100% rename from website/public/front/images/icon175x175.png rename to website/client/front/images/icon175x175.png diff --git a/website/public/front/images/intro.jpg b/website/client/front/images/intro.jpg similarity index 100% rename from website/public/front/images/intro.jpg rename to website/client/front/images/intro.jpg diff --git a/website/public/front/images/intro.psd b/website/client/front/images/intro.psd similarity index 100% rename from website/public/front/images/intro.psd rename to website/client/front/images/intro.psd diff --git a/website/public/front/images/misc/Pet_Food_Cake_Base.png b/website/client/front/images/misc/Pet_Food_Cake_Base.png similarity index 100% rename from website/public/front/images/misc/Pet_Food_Cake_Base.png rename to website/client/front/images/misc/Pet_Food_Cake_Base.png diff --git a/website/public/front/images/misc/inventory_quest_scroll_harpy.png b/website/client/front/images/misc/inventory_quest_scroll_harpy.png similarity index 100% rename from website/public/front/images/misc/inventory_quest_scroll_harpy.png rename to website/client/front/images/misc/inventory_quest_scroll_harpy.png diff --git a/website/public/front/images/misc/rebirth_orb.png b/website/client/front/images/misc/rebirth_orb.png similarity index 100% rename from website/public/front/images/misc/rebirth_orb.png rename to website/client/front/images/misc/rebirth_orb.png diff --git a/website/public/front/images/misc/shop_gold.png b/website/client/front/images/misc/shop_gold.png similarity index 100% rename from website/public/front/images/misc/shop_gold.png rename to website/client/front/images/misc/shop_gold.png diff --git a/website/public/front/images/misc/shop_potion.png b/website/client/front/images/misc/shop_potion.png similarity index 100% rename from website/public/front/images/misc/shop_potion.png rename to website/client/front/images/misc/shop_potion.png diff --git a/website/public/front/images/mockup_for_habit_by_cosmic_caterpillar-d8mf5mb.png b/website/client/front/images/mockup_for_habit_by_cosmic_caterpillar-d8mf5mb.png similarity index 100% rename from website/public/front/images/mockup_for_habit_by_cosmic_caterpillar-d8mf5mb.png rename to website/client/front/images/mockup_for_habit_by_cosmic_caterpillar-d8mf5mb.png diff --git a/website/public/front/images/party/AnnaCosplay.png b/website/client/front/images/party/AnnaCosplay.png similarity index 100% rename from website/public/front/images/party/AnnaCosplay.png rename to website/client/front/images/party/AnnaCosplay.png diff --git a/website/public/front/images/party/Ariel_cosplay.png b/website/client/front/images/party/Ariel_cosplay.png similarity index 100% rename from website/public/front/images/party/Ariel_cosplay.png rename to website/client/front/images/party/Ariel_cosplay.png diff --git a/website/public/front/images/party/Big_Daddy_(BioShock).png b/website/client/front/images/party/Big_Daddy_(BioShock).png similarity index 100% rename from website/public/front/images/party/Big_Daddy_(BioShock).png rename to website/client/front/images/party/Big_Daddy_(BioShock).png diff --git a/website/public/front/images/party/Cosplay_Daenerys_Targaryen.png b/website/client/front/images/party/Cosplay_Daenerys_Targaryen.png similarity index 100% rename from website/public/front/images/party/Cosplay_Daenerys_Targaryen.png rename to website/client/front/images/party/Cosplay_Daenerys_Targaryen.png diff --git a/website/public/front/images/party/GrimReaper.png b/website/client/front/images/party/GrimReaper.png similarity index 100% rename from website/public/front/images/party/GrimReaper.png rename to website/client/front/images/party/GrimReaper.png diff --git a/website/public/front/images/party/HomeStuckLusus.png b/website/client/front/images/party/HomeStuckLusus.png similarity index 100% rename from website/public/front/images/party/HomeStuckLusus.png rename to website/client/front/images/party/HomeStuckLusus.png diff --git a/website/public/front/images/presslogos/Cnetlogo.png b/website/client/front/images/presslogos/Cnetlogo.png similarity index 100% rename from website/public/front/images/presslogos/Cnetlogo.png rename to website/client/front/images/presslogos/Cnetlogo.png diff --git a/website/public/front/images/presslogos/Fast-Company-logo.png b/website/client/front/images/presslogos/Fast-Company-logo.png similarity index 100% rename from website/public/front/images/presslogos/Fast-Company-logo.png rename to website/client/front/images/presslogos/Fast-Company-logo.png diff --git a/website/public/front/images/presslogos/Forbes_logo.png b/website/client/front/images/presslogos/Forbes_logo.png similarity index 100% rename from website/public/front/images/presslogos/Forbes_logo.png rename to website/client/front/images/presslogos/Forbes_logo.png diff --git a/website/public/front/images/presslogos/GitHub_Logo.png b/website/client/front/images/presslogos/GitHub_Logo.png similarity index 100% rename from website/public/front/images/presslogos/GitHub_Logo.png rename to website/client/front/images/presslogos/GitHub_Logo.png diff --git a/website/public/front/images/presslogos/discover_logo.png b/website/client/front/images/presslogos/discover_logo.png similarity index 100% rename from website/public/front/images/presslogos/discover_logo.png rename to website/client/front/images/presslogos/discover_logo.png diff --git a/website/public/front/images/presslogos/ionic-logo-blog.png b/website/client/front/images/presslogos/ionic-logo-blog.png similarity index 100% rename from website/public/front/images/presslogos/ionic-logo-blog.png rename to website/client/front/images/presslogos/ionic-logo-blog.png diff --git a/website/public/front/images/presslogos/ionic-logo-horizontal-transparent.png b/website/client/front/images/presslogos/ionic-logo-horizontal-transparent.png similarity index 100% rename from website/public/front/images/presslogos/ionic-logo-horizontal-transparent.png rename to website/client/front/images/presslogos/ionic-logo-horizontal-transparent.png diff --git a/website/public/front/images/presslogos/kickstarter-logo.png b/website/client/front/images/presslogos/kickstarter-logo.png similarity index 100% rename from website/public/front/images/presslogos/kickstarter-logo.png rename to website/client/front/images/presslogos/kickstarter-logo.png diff --git a/website/public/front/images/presslogos/landing_slack_hash_wordmark_logo.png b/website/client/front/images/presslogos/landing_slack_hash_wordmark_logo.png similarity index 100% rename from website/public/front/images/presslogos/landing_slack_hash_wordmark_logo.png rename to website/client/front/images/presslogos/landing_slack_hash_wordmark_logo.png diff --git a/website/public/front/images/presslogos/lifehacker.png b/website/client/front/images/presslogos/lifehacker.png similarity index 100% rename from website/public/front/images/presslogos/lifehacker.png rename to website/client/front/images/presslogos/lifehacker.png diff --git a/website/public/front/images/presslogos/logo_webstorm.png b/website/client/front/images/presslogos/logo_webstorm.png similarity index 100% rename from website/public/front/images/presslogos/logo_webstorm.png rename to website/client/front/images/presslogos/logo_webstorm.png diff --git a/website/public/front/images/presslogos/makeuseof.png b/website/client/front/images/presslogos/makeuseof.png similarity index 100% rename from website/public/front/images/presslogos/makeuseof.png rename to website/client/front/images/presslogos/makeuseof.png diff --git a/website/public/front/images/presslogos/nyt-logo.png b/website/client/front/images/presslogos/nyt-logo.png similarity index 100% rename from website/public/front/images/presslogos/nyt-logo.png rename to website/client/front/images/presslogos/nyt-logo.png diff --git a/website/public/front/images/presslogos/slack.png b/website/client/front/images/presslogos/slack.png similarity index 100% rename from website/public/front/images/presslogos/slack.png rename to website/client/front/images/presslogos/slack.png diff --git a/website/public/front/images/presslogos/trello-logo-blue.png b/website/client/front/images/presslogos/trello-logo-blue.png similarity index 100% rename from website/public/front/images/presslogos/trello-logo-blue.png rename to website/client/front/images/presslogos/trello-logo-blue.png diff --git a/website/public/front/images/quest_vice3.png b/website/client/front/images/quest_vice3.png similarity index 100% rename from website/public/front/images/quest_vice3.png rename to website/client/front/images/quest_vice3.png diff --git a/website/public/front/images/screenshot.png b/website/client/front/images/screenshot.png similarity index 100% rename from website/public/front/images/screenshot.png rename to website/client/front/images/screenshot.png diff --git a/website/public/front/images/t_bone_fight_2_by_mortquitue-d8dtxbl.png b/website/client/front/images/t_bone_fight_2_by_mortquitue-d8dtxbl.png similarity index 100% rename from website/public/front/images/t_bone_fight_2_by_mortquitue-d8dtxbl.png rename to website/client/front/images/t_bone_fight_2_by_mortquitue-d8dtxbl.png diff --git a/website/public/front/images/testimonial_by_Streak.png b/website/client/front/images/testimonial_by_Streak.png similarity index 100% rename from website/public/front/images/testimonial_by_Streak.png rename to website/client/front/images/testimonial_by_Streak.png diff --git a/website/public/front/images/testimonials/16bitFil.png b/website/client/front/images/testimonials/16bitFil.png similarity index 100% rename from website/public/front/images/testimonials/16bitFil.png rename to website/client/front/images/testimonials/16bitFil.png diff --git a/website/public/front/images/testimonials/AlexandraSo.png b/website/client/front/images/testimonials/AlexandraSo.png similarity index 100% rename from website/public/front/images/testimonials/AlexandraSo.png rename to website/client/front/images/testimonials/AlexandraSo.png diff --git a/website/public/front/images/testimonials/Althaire.png b/website/client/front/images/testimonials/Althaire.png similarity index 100% rename from website/public/front/images/testimonials/Althaire.png rename to website/client/front/images/testimonials/Althaire.png diff --git a/website/public/front/images/testimonials/AndeeLiao.png b/website/client/front/images/testimonials/AndeeLiao.png similarity index 100% rename from website/public/front/images/testimonials/AndeeLiao.png rename to website/client/front/images/testimonials/AndeeLiao.png diff --git a/website/public/front/images/testimonials/Brenna.png b/website/client/front/images/testimonials/Brenna.png similarity index 100% rename from website/public/front/images/testimonials/Brenna.png rename to website/client/front/images/testimonials/Brenna.png diff --git a/website/public/front/images/testimonials/Drag0nsilver.png b/website/client/front/images/testimonials/Drag0nsilver.png similarity index 100% rename from website/public/front/images/testimonials/Drag0nsilver.png rename to website/client/front/images/testimonials/Drag0nsilver.png diff --git a/website/public/front/images/testimonials/Drei-M.png b/website/client/front/images/testimonials/Drei-M.png similarity index 100% rename from website/public/front/images/testimonials/Drei-M.png rename to website/client/front/images/testimonials/Drei-M.png diff --git a/website/public/front/images/testimonials/Elmi.png b/website/client/front/images/testimonials/Elmi.png similarity index 100% rename from website/public/front/images/testimonials/Elmi.png rename to website/client/front/images/testimonials/Elmi.png diff --git a/website/public/front/images/testimonials/EvaGantz.png b/website/client/front/images/testimonials/EvaGantz.png similarity index 100% rename from website/public/front/images/testimonials/EvaGantz.png rename to website/client/front/images/testimonials/EvaGantz.png diff --git a/website/public/front/images/testimonials/Helcura.png b/website/client/front/images/testimonials/Helcura.png similarity index 100% rename from website/public/front/images/testimonials/Helcura.png rename to website/client/front/images/testimonials/Helcura.png diff --git a/website/public/front/images/testimonials/InfH.png b/website/client/front/images/testimonials/InfH.png similarity index 100% rename from website/public/front/images/testimonials/InfH.png rename to website/client/front/images/testimonials/InfH.png diff --git a/website/public/front/images/testimonials/Kai.png b/website/client/front/images/testimonials/Kai.png similarity index 100% rename from website/public/front/images/testimonials/Kai.png rename to website/client/front/images/testimonials/Kai.png diff --git a/website/public/front/images/testimonials/Kazui.png b/website/client/front/images/testimonials/Kazui.png similarity index 100% rename from website/public/front/images/testimonials/Kazui.png rename to website/client/front/images/testimonials/Kazui.png diff --git a/website/public/front/images/testimonials/Zelah_Meyer.png b/website/client/front/images/testimonials/Zelah_Meyer.png similarity index 100% rename from website/public/front/images/testimonials/Zelah_Meyer.png rename to website/client/front/images/testimonials/Zelah_Meyer.png diff --git a/website/public/front/images/testimonials/autumnesquirrel.png b/website/client/front/images/testimonials/autumnesquirrel.png similarity index 100% rename from website/public/front/images/testimonials/autumnesquirrel.png rename to website/client/front/images/testimonials/autumnesquirrel.png diff --git a/website/public/front/images/testimonials/frabjabulous.png b/website/client/front/images/testimonials/frabjabulous.png similarity index 100% rename from website/public/front/images/testimonials/frabjabulous.png rename to website/client/front/images/testimonials/frabjabulous.png diff --git a/website/public/front/images/testimonials/galarix.png b/website/client/front/images/testimonials/galarix.png similarity index 100% rename from website/public/front/images/testimonials/galarix.png rename to website/client/front/images/testimonials/galarix.png diff --git a/website/public/front/images/testimonials/gwyn.blath.png b/website/client/front/images/testimonials/gwyn.blath.png similarity index 100% rename from website/public/front/images/testimonials/gwyn.blath.png rename to website/client/front/images/testimonials/gwyn.blath.png diff --git a/website/public/front/images/testimonials/irishfeet123.png b/website/client/front/images/testimonials/irishfeet123.png similarity index 100% rename from website/public/front/images/testimonials/irishfeet123.png rename to website/client/front/images/testimonials/irishfeet123.png diff --git a/website/public/front/images/testimonials/skysailor.png b/website/client/front/images/testimonials/skysailor.png similarity index 100% rename from website/public/front/images/testimonials/skysailor.png rename to website/client/front/images/testimonials/skysailor.png diff --git a/website/public/front/images/testimonials/supermouse35.png b/website/client/front/images/testimonials/supermouse35.png similarity index 100% rename from website/public/front/images/testimonials/supermouse35.png rename to website/client/front/images/testimonials/supermouse35.png diff --git a/website/public/front/images/testimonials/tonitonirocca.png b/website/client/front/images/testimonials/tonitonirocca.png similarity index 100% rename from website/public/front/images/testimonials/tonitonirocca.png rename to website/client/front/images/testimonials/tonitonirocca.png diff --git a/website/public/front/images/uses/achievement-bkgd.png b/website/client/front/images/uses/achievement-bkgd.png similarity index 100% rename from website/public/front/images/uses/achievement-bkgd.png rename to website/client/front/images/uses/achievement-bkgd.png diff --git a/website/public/front/images/uses/clipart-rosemonkeyct-meditation.png b/website/client/front/images/uses/clipart-rosemonkeyct-meditation.png similarity index 100% rename from website/public/front/images/uses/clipart-rosemonkeyct-meditation.png rename to website/client/front/images/uses/clipart-rosemonkeyct-meditation.png diff --git a/website/public/front/images/uses/clipart-rosemonkeyct-meditation.psd b/website/client/front/images/uses/clipart-rosemonkeyct-meditation.psd similarity index 100% rename from website/public/front/images/uses/clipart-rosemonkeyct-meditation.psd rename to website/client/front/images/uses/clipart-rosemonkeyct-meditation.psd diff --git a/website/public/front/images/uses/clipart-rosemonkeyct-reading.png b/website/client/front/images/uses/clipart-rosemonkeyct-reading.png similarity index 100% rename from website/public/front/images/uses/clipart-rosemonkeyct-reading.png rename to website/client/front/images/uses/clipart-rosemonkeyct-reading.png diff --git a/website/public/front/images/uses/coding.png b/website/client/front/images/uses/coding.png similarity index 100% rename from website/public/front/images/uses/coding.png rename to website/client/front/images/uses/coding.png diff --git a/website/public/front/images/uses/coding_3_by_phoneix_faerie-d7idtti.png b/website/client/front/images/uses/coding_3_by_phoneix_faerie-d7idtti.png similarity index 100% rename from website/public/front/images/uses/coding_3_by_phoneix_faerie-d7idtti.png rename to website/client/front/images/uses/coding_3_by_phoneix_faerie-d7idtti.png diff --git a/website/public/front/images/uses/consequences.png b/website/client/front/images/uses/consequences.png similarity index 100% rename from website/public/front/images/uses/consequences.png rename to website/client/front/images/uses/consequences.png diff --git a/website/public/front/images/uses/dusting-bkgd.png b/website/client/front/images/uses/dusting-bkgd.png similarity index 100% rename from website/public/front/images/uses/dusting-bkgd.png rename to website/client/front/images/uses/dusting-bkgd.png diff --git a/website/public/front/images/uses/dusting_by_leephon.png b/website/client/front/images/uses/dusting_by_leephon.png similarity index 100% rename from website/public/front/images/uses/dusting_by_leephon.png rename to website/client/front/images/uses/dusting_by_leephon.png diff --git a/website/public/front/images/uses/gaining_an_achievement_by_cosmic_caterpillar-d7uyv5z.png b/website/client/front/images/uses/gaining_an_achievement_by_cosmic_caterpillar-d7uyv5z.png similarity index 100% rename from website/public/front/images/uses/gaining_an_achievement_by_cosmic_caterpillar-d7uyv5z.png rename to website/client/front/images/uses/gaining_an_achievement_by_cosmic_caterpillar-d7uyv5z.png diff --git a/website/public/front/images/uses/meditation-bkgd.png b/website/client/front/images/uses/meditation-bkgd.png similarity index 100% rename from website/public/front/images/uses/meditation-bkgd.png rename to website/client/front/images/uses/meditation-bkgd.png diff --git a/website/public/front/images/uses/publicSpaces.png b/website/client/front/images/uses/publicSpaces.png similarity index 100% rename from website/public/front/images/uses/publicSpaces.png rename to website/client/front/images/uses/publicSpaces.png diff --git a/website/public/front/images/uses/reading.png b/website/client/front/images/uses/reading.png similarity index 100% rename from website/public/front/images/uses/reading.png rename to website/client/front/images/uses/reading.png diff --git a/website/public/front/js/blockScroll.js b/website/client/front/js/blockScroll.js similarity index 100% rename from website/public/front/js/blockScroll.js rename to website/client/front/js/blockScroll.js diff --git a/website/public/front/js/bootstrap.min.js b/website/client/front/js/bootstrap.min.js similarity index 100% rename from website/public/front/js/bootstrap.min.js rename to website/client/front/js/bootstrap.min.js diff --git a/website/public/front/js/skrollr.min.js b/website/client/front/js/skrollr.min.js similarity index 100% rename from website/public/front/js/skrollr.min.js rename to website/client/front/js/skrollr.min.js diff --git a/website/public/front/landingv1Wireframe.jpg b/website/client/front/landingv1Wireframe.jpg similarity index 100% rename from website/public/front/landingv1Wireframe.jpg rename to website/client/front/landingv1Wireframe.jpg diff --git a/website/public/front/staticstyle.css b/website/client/front/staticstyle.css similarity index 100% rename from website/public/front/staticstyle.css rename to website/client/front/staticstyle.css diff --git a/website/public/front/style.css b/website/client/front/style.css similarity index 100% rename from website/public/front/style.css rename to website/client/front/style.css diff --git a/website/public/google280633b772b94345.html b/website/client/google280633b772b94345.html similarity index 100% rename from website/public/google280633b772b94345.html rename to website/client/google280633b772b94345.html diff --git a/website/public/google8ca65b6ff3506fb8.html b/website/client/google8ca65b6ff3506fb8.html similarity index 100% rename from website/public/google8ca65b6ff3506fb8.html rename to website/client/google8ca65b6ff3506fb8.html diff --git a/website/public/googlef3b1402b0e28338a.html b/website/client/googlef3b1402b0e28338a.html similarity index 100% rename from website/public/googlef3b1402b0e28338a.html rename to website/client/googlef3b1402b0e28338a.html diff --git a/website/public/js/.eslintrc b/website/client/js/.eslintrc similarity index 100% rename from website/public/js/.eslintrc rename to website/client/js/.eslintrc diff --git a/website/public/js/app.js b/website/client/js/app.js similarity index 100% rename from website/public/js/app.js rename to website/client/js/app.js diff --git a/website/public/js/controllers/authCtrl.js b/website/client/js/controllers/authCtrl.js similarity index 100% rename from website/public/js/controllers/authCtrl.js rename to website/client/js/controllers/authCtrl.js diff --git a/website/public/js/controllers/autoCompleteCtrl.js b/website/client/js/controllers/autoCompleteCtrl.js similarity index 100% rename from website/public/js/controllers/autoCompleteCtrl.js rename to website/client/js/controllers/autoCompleteCtrl.js diff --git a/website/public/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js similarity index 100% rename from website/public/js/controllers/challengesCtrl.js rename to website/client/js/controllers/challengesCtrl.js diff --git a/website/public/js/controllers/chatCtrl.js b/website/client/js/controllers/chatCtrl.js similarity index 100% rename from website/public/js/controllers/chatCtrl.js rename to website/client/js/controllers/chatCtrl.js diff --git a/website/public/js/controllers/copyMessageModalCtrl.js b/website/client/js/controllers/copyMessageModalCtrl.js similarity index 100% rename from website/public/js/controllers/copyMessageModalCtrl.js rename to website/client/js/controllers/copyMessageModalCtrl.js diff --git a/website/public/js/controllers/filtersCtrl.js b/website/client/js/controllers/filtersCtrl.js similarity index 100% rename from website/public/js/controllers/filtersCtrl.js rename to website/client/js/controllers/filtersCtrl.js diff --git a/website/public/js/controllers/footerCtrl.js b/website/client/js/controllers/footerCtrl.js similarity index 100% rename from website/public/js/controllers/footerCtrl.js rename to website/client/js/controllers/footerCtrl.js diff --git a/website/public/js/controllers/groupsCtrl.js b/website/client/js/controllers/groupsCtrl.js similarity index 100% rename from website/public/js/controllers/groupsCtrl.js rename to website/client/js/controllers/groupsCtrl.js diff --git a/website/public/js/controllers/guildsCtrl.js b/website/client/js/controllers/guildsCtrl.js similarity index 100% rename from website/public/js/controllers/guildsCtrl.js rename to website/client/js/controllers/guildsCtrl.js diff --git a/website/public/js/controllers/hallCtrl.js b/website/client/js/controllers/hallCtrl.js similarity index 100% rename from website/public/js/controllers/hallCtrl.js rename to website/client/js/controllers/hallCtrl.js diff --git a/website/public/js/controllers/headerCtrl.js b/website/client/js/controllers/headerCtrl.js similarity index 100% rename from website/public/js/controllers/headerCtrl.js rename to website/client/js/controllers/headerCtrl.js diff --git a/website/public/js/controllers/inventoryCtrl.js b/website/client/js/controllers/inventoryCtrl.js similarity index 100% rename from website/public/js/controllers/inventoryCtrl.js rename to website/client/js/controllers/inventoryCtrl.js diff --git a/website/public/js/controllers/inviteToGroupCtrl.js b/website/client/js/controllers/inviteToGroupCtrl.js similarity index 100% rename from website/public/js/controllers/inviteToGroupCtrl.js rename to website/client/js/controllers/inviteToGroupCtrl.js diff --git a/website/public/js/controllers/memberModalCtrl.js b/website/client/js/controllers/memberModalCtrl.js similarity index 100% rename from website/public/js/controllers/memberModalCtrl.js rename to website/client/js/controllers/memberModalCtrl.js diff --git a/website/public/js/controllers/menuCtrl.js b/website/client/js/controllers/menuCtrl.js similarity index 100% rename from website/public/js/controllers/menuCtrl.js rename to website/client/js/controllers/menuCtrl.js diff --git a/website/public/js/controllers/notificationCtrl.js b/website/client/js/controllers/notificationCtrl.js similarity index 100% rename from website/public/js/controllers/notificationCtrl.js rename to website/client/js/controllers/notificationCtrl.js diff --git a/website/public/js/controllers/partyCtrl.js b/website/client/js/controllers/partyCtrl.js similarity index 100% rename from website/public/js/controllers/partyCtrl.js rename to website/client/js/controllers/partyCtrl.js diff --git a/website/public/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js similarity index 100% rename from website/public/js/controllers/rootCtrl.js rename to website/client/js/controllers/rootCtrl.js diff --git a/website/public/js/controllers/settingsCtrl.js b/website/client/js/controllers/settingsCtrl.js similarity index 100% rename from website/public/js/controllers/settingsCtrl.js rename to website/client/js/controllers/settingsCtrl.js diff --git a/website/public/js/controllers/sortableInventoryCtrl.js b/website/client/js/controllers/sortableInventoryCtrl.js similarity index 100% rename from website/public/js/controllers/sortableInventoryCtrl.js rename to website/client/js/controllers/sortableInventoryCtrl.js diff --git a/website/public/js/controllers/tasksCtrl.js b/website/client/js/controllers/tasksCtrl.js similarity index 100% rename from website/public/js/controllers/tasksCtrl.js rename to website/client/js/controllers/tasksCtrl.js diff --git a/website/public/js/controllers/tavernCtrl.js b/website/client/js/controllers/tavernCtrl.js similarity index 100% rename from website/public/js/controllers/tavernCtrl.js rename to website/client/js/controllers/tavernCtrl.js diff --git a/website/public/js/controllers/userCtrl.js b/website/client/js/controllers/userCtrl.js similarity index 100% rename from website/public/js/controllers/userCtrl.js rename to website/client/js/controllers/userCtrl.js diff --git a/website/public/js/directives/close-menu.directive.js b/website/client/js/directives/close-menu.directive.js similarity index 100% rename from website/public/js/directives/close-menu.directive.js rename to website/client/js/directives/close-menu.directive.js diff --git a/website/public/js/directives/expand-menu.directive.js b/website/client/js/directives/expand-menu.directive.js similarity index 100% rename from website/public/js/directives/expand-menu.directive.js rename to website/client/js/directives/expand-menu.directive.js diff --git a/website/public/js/directives/focus-element.directive.js b/website/client/js/directives/focus-element.directive.js similarity index 100% rename from website/public/js/directives/focus-element.directive.js rename to website/client/js/directives/focus-element.directive.js diff --git a/website/public/js/directives/from-now.directive.js b/website/client/js/directives/from-now.directive.js similarity index 100% rename from website/public/js/directives/from-now.directive.js rename to website/client/js/directives/from-now.directive.js diff --git a/website/public/js/directives/habitrpg-tasks.directive.js b/website/client/js/directives/habitrpg-tasks.directive.js similarity index 100% rename from website/public/js/directives/habitrpg-tasks.directive.js rename to website/client/js/directives/habitrpg-tasks.directive.js diff --git a/website/public/js/directives/hrpg-sort-checklist.directive.js b/website/client/js/directives/hrpg-sort-checklist.directive.js similarity index 100% rename from website/public/js/directives/hrpg-sort-checklist.directive.js rename to website/client/js/directives/hrpg-sort-checklist.directive.js diff --git a/website/public/js/directives/hrpg-sort-tags.directive.js b/website/client/js/directives/hrpg-sort-tags.directive.js similarity index 100% rename from website/public/js/directives/hrpg-sort-tags.directive.js rename to website/client/js/directives/hrpg-sort-tags.directive.js diff --git a/website/public/js/directives/hrpg-sort-tasks.directive.js b/website/client/js/directives/hrpg-sort-tasks.directive.js similarity index 100% rename from website/public/js/directives/hrpg-sort-tasks.directive.js rename to website/client/js/directives/hrpg-sort-tasks.directive.js diff --git a/website/public/js/directives/popover-html-popup.directive.js b/website/client/js/directives/popover-html-popup.directive.js similarity index 100% rename from website/public/js/directives/popover-html-popup.directive.js rename to website/client/js/directives/popover-html-popup.directive.js diff --git a/website/public/js/directives/popover-html.directive.js b/website/client/js/directives/popover-html.directive.js similarity index 100% rename from website/public/js/directives/popover-html.directive.js rename to website/client/js/directives/popover-html.directive.js diff --git a/website/public/js/directives/when-scrolled.directive.js b/website/client/js/directives/when-scrolled.directive.js similarity index 100% rename from website/public/js/directives/when-scrolled.directive.js rename to website/client/js/directives/when-scrolled.directive.js diff --git a/website/public/js/env.js b/website/client/js/env.js similarity index 100% rename from website/public/js/env.js rename to website/client/js/env.js diff --git a/website/public/js/filters/money.js b/website/client/js/filters/money.js similarity index 100% rename from website/public/js/filters/money.js rename to website/client/js/filters/money.js diff --git a/website/public/js/filters/roundLargeNumbers.js b/website/client/js/filters/roundLargeNumbers.js similarity index 100% rename from website/public/js/filters/roundLargeNumbers.js rename to website/client/js/filters/roundLargeNumbers.js diff --git a/website/public/js/filters/taskOrdering.js b/website/client/js/filters/taskOrdering.js similarity index 100% rename from website/public/js/filters/taskOrdering.js rename to website/client/js/filters/taskOrdering.js diff --git a/website/public/js/filters/timezoneOffsetToUtc.js b/website/client/js/filters/timezoneOffsetToUtc.js similarity index 100% rename from website/public/js/filters/timezoneOffsetToUtc.js rename to website/client/js/filters/timezoneOffsetToUtc.js diff --git a/website/public/js/services/analyticsServices.js b/website/client/js/services/analyticsServices.js similarity index 100% rename from website/public/js/services/analyticsServices.js rename to website/client/js/services/analyticsServices.js diff --git a/website/public/js/services/challengeServices.js b/website/client/js/services/challengeServices.js similarity index 100% rename from website/public/js/services/challengeServices.js rename to website/client/js/services/challengeServices.js diff --git a/website/public/js/services/chatServices.js b/website/client/js/services/chatServices.js similarity index 100% rename from website/public/js/services/chatServices.js rename to website/client/js/services/chatServices.js diff --git a/website/public/js/services/groupServices.js b/website/client/js/services/groupServices.js similarity index 100% rename from website/public/js/services/groupServices.js rename to website/client/js/services/groupServices.js diff --git a/website/public/js/services/guideServices.js b/website/client/js/services/guideServices.js similarity index 100% rename from website/public/js/services/guideServices.js rename to website/client/js/services/guideServices.js diff --git a/website/public/js/services/memberServices.js b/website/client/js/services/memberServices.js similarity index 100% rename from website/public/js/services/memberServices.js rename to website/client/js/services/memberServices.js diff --git a/website/public/js/services/notificationServices.js b/website/client/js/services/notificationServices.js similarity index 100% rename from website/public/js/services/notificationServices.js rename to website/client/js/services/notificationServices.js diff --git a/website/public/js/services/paymentServices.js b/website/client/js/services/paymentServices.js similarity index 100% rename from website/public/js/services/paymentServices.js rename to website/client/js/services/paymentServices.js diff --git a/website/public/js/services/questServices.js b/website/client/js/services/questServices.js similarity index 100% rename from website/public/js/services/questServices.js rename to website/client/js/services/questServices.js diff --git a/website/public/js/services/sharedServices.js b/website/client/js/services/sharedServices.js similarity index 100% rename from website/public/js/services/sharedServices.js rename to website/client/js/services/sharedServices.js diff --git a/website/public/js/services/socialServices.js b/website/client/js/services/socialServices.js similarity index 100% rename from website/public/js/services/socialServices.js rename to website/client/js/services/socialServices.js diff --git a/website/public/js/services/statServices.js b/website/client/js/services/statServices.js similarity index 100% rename from website/public/js/services/statServices.js rename to website/client/js/services/statServices.js diff --git a/website/public/js/services/tagsServices.js b/website/client/js/services/tagsServices.js similarity index 100% rename from website/public/js/services/tagsServices.js rename to website/client/js/services/tagsServices.js diff --git a/website/public/js/services/taskServices.js b/website/client/js/services/taskServices.js similarity index 100% rename from website/public/js/services/taskServices.js rename to website/client/js/services/taskServices.js diff --git a/website/public/js/services/userServices.js b/website/client/js/services/userServices.js similarity index 100% rename from website/public/js/services/userServices.js rename to website/client/js/services/userServices.js diff --git a/website/public/js/static.js b/website/client/js/static.js similarity index 100% rename from website/public/js/static.js rename to website/client/js/static.js diff --git a/website/public/logo.png b/website/client/logo.png similarity index 100% rename from website/public/logo.png rename to website/client/logo.png diff --git a/website/public/logo/HABITRPG logo version 1.psd b/website/client/logo/HABITRPG logo version 1.psd similarity index 100% rename from website/public/logo/HABITRPG logo version 1.psd rename to website/client/logo/HABITRPG logo version 1.psd diff --git a/website/public/logo/HABITRPG-logo-version-1.gif b/website/client/logo/HABITRPG-logo-version-1.gif similarity index 100% rename from website/public/logo/HABITRPG-logo-version-1.gif rename to website/client/logo/HABITRPG-logo-version-1.gif diff --git a/website/public/logo/habitrpg.jpg b/website/client/logo/habitrpg.jpg similarity index 100% rename from website/public/logo/habitrpg.jpg rename to website/client/logo/habitrpg.jpg diff --git a/website/public/logo/habitrpg_bl.eps b/website/client/logo/habitrpg_bl.eps similarity index 100% rename from website/public/logo/habitrpg_bl.eps rename to website/client/logo/habitrpg_bl.eps diff --git a/website/public/logo/habitrpg_pixel.png b/website/client/logo/habitrpg_pixel.png similarity index 100% rename from website/public/logo/habitrpg_pixel.png rename to website/client/logo/habitrpg_pixel.png diff --git a/website/public/manifest.json b/website/client/manifest.json similarity index 100% rename from website/public/manifest.json rename to website/client/manifest.json diff --git a/website/public/marketing/android_iphone.png b/website/client/marketing/android_iphone.png similarity index 100% rename from website/public/marketing/android_iphone.png rename to website/client/marketing/android_iphone.png diff --git a/website/public/marketing/animals.png b/website/client/marketing/animals.png similarity index 100% rename from website/public/marketing/animals.png rename to website/client/marketing/animals.png diff --git a/website/public/marketing/challenge.png b/website/client/marketing/challenge.png similarity index 100% rename from website/public/marketing/challenge.png rename to website/client/marketing/challenge.png diff --git a/website/public/marketing/devices.png b/website/client/marketing/devices.png similarity index 100% rename from website/public/marketing/devices.png rename to website/client/marketing/devices.png diff --git a/website/public/marketing/drops.png b/website/client/marketing/drops.png similarity index 100% rename from website/public/marketing/drops.png rename to website/client/marketing/drops.png diff --git a/website/public/marketing/education.png b/website/client/marketing/education.png similarity index 100% rename from website/public/marketing/education.png rename to website/client/marketing/education.png diff --git a/website/public/marketing/gear.png b/website/client/marketing/gear.png similarity index 100% rename from website/public/marketing/gear.png rename to website/client/marketing/gear.png diff --git a/website/public/marketing/guild.png b/website/client/marketing/guild.png similarity index 100% rename from website/public/marketing/guild.png rename to website/client/marketing/guild.png diff --git a/website/public/marketing/guild_small.png b/website/client/marketing/guild_small.png similarity index 100% rename from website/public/marketing/guild_small.png rename to website/client/marketing/guild_small.png diff --git a/website/public/marketing/integration.png b/website/client/marketing/integration.png similarity index 100% rename from website/public/marketing/integration.png rename to website/client/marketing/integration.png diff --git a/website/public/marketing/lefnire.png b/website/client/marketing/lefnire.png similarity index 100% rename from website/public/marketing/lefnire.png rename to website/client/marketing/lefnire.png diff --git a/website/public/marketing/promos/201403_Forest_Walker.png b/website/client/marketing/promos/201403_Forest_Walker.png similarity index 100% rename from website/public/marketing/promos/201403_Forest_Walker.png rename to website/client/marketing/promos/201403_Forest_Walker.png diff --git a/website/public/marketing/promos/April14SAMPLE2.png b/website/client/marketing/promos/April14SAMPLE2.png similarity index 100% rename from website/public/marketing/promos/April14SAMPLE2.png rename to website/client/marketing/promos/April14SAMPLE2.png diff --git a/website/public/marketing/screenshot.png b/website/client/marketing/screenshot.png similarity index 100% rename from website/public/marketing/screenshot.png rename to website/client/marketing/screenshot.png diff --git a/website/public/marketing/social_competitve.png b/website/client/marketing/social_competitve.png similarity index 100% rename from website/public/marketing/social_competitve.png rename to website/client/marketing/social_competitve.png diff --git a/website/public/marketing/wellness.png b/website/client/marketing/wellness.png similarity index 100% rename from website/public/marketing/wellness.png rename to website/client/marketing/wellness.png diff --git a/website/public/merch/stickermule-logo.png b/website/client/merch/stickermule-logo.png similarity index 100% rename from website/public/merch/stickermule-logo.png rename to website/client/merch/stickermule-logo.png diff --git a/website/public/merch/stickermule-logo.svg b/website/client/merch/stickermule-logo.svg similarity index 100% rename from website/public/merch/stickermule-logo.svg rename to website/client/merch/stickermule-logo.svg diff --git a/website/public/merch/stickermule.png b/website/client/merch/stickermule.png similarity index 100% rename from website/public/merch/stickermule.png rename to website/client/merch/stickermule.png diff --git a/website/public/merch/teespring-eu-logo.png b/website/client/merch/teespring-eu-logo.png similarity index 100% rename from website/public/merch/teespring-eu-logo.png rename to website/client/merch/teespring-eu-logo.png diff --git a/website/public/merch/teespring-eu.png b/website/client/merch/teespring-eu.png similarity index 100% rename from website/public/merch/teespring-eu.png rename to website/client/merch/teespring-eu.png diff --git a/website/public/merch/teespring-logo.png b/website/client/merch/teespring-logo.png similarity index 100% rename from website/public/merch/teespring-logo.png rename to website/client/merch/teespring-logo.png diff --git a/website/public/merch/teespring-logo.svg b/website/client/merch/teespring-logo.svg similarity index 100% rename from website/public/merch/teespring-logo.svg rename to website/client/merch/teespring-logo.svg diff --git a/website/public/merch/teespring.png b/website/client/merch/teespring.png similarity index 100% rename from website/public/merch/teespring.png rename to website/client/merch/teespring.png diff --git a/website/public/page-loader.gif b/website/client/page-loader.gif similarity index 100% rename from website/public/page-loader.gif rename to website/client/page-loader.gif diff --git a/website/public/presskit/Boss - Basi-List.png b/website/client/presskit/Boss - Basi-List.png similarity index 100% rename from website/public/presskit/Boss - Basi-List.png rename to website/client/presskit/Boss - Basi-List.png diff --git a/website/public/presskit/Boss - Battling the Ghost Stag.png b/website/client/presskit/Boss - Battling the Ghost Stag.png similarity index 100% rename from website/public/presskit/Boss - Battling the Ghost Stag.png rename to website/client/presskit/Boss - Battling the Ghost Stag.png diff --git a/website/public/presskit/Boss - Laundromancer.png b/website/client/presskit/Boss - Laundromancer.png similarity index 100% rename from website/public/presskit/Boss - Laundromancer.png rename to website/client/presskit/Boss - Laundromancer.png diff --git a/website/public/presskit/Boss - Necro-Vice.png b/website/client/presskit/Boss - Necro-Vice.png similarity index 100% rename from website/public/presskit/Boss - Necro-Vice.png rename to website/client/presskit/Boss - Necro-Vice.png diff --git a/website/public/presskit/Boss - SnackLess Monster.png b/website/client/presskit/Boss - SnackLess Monster.png similarity index 100% rename from website/public/presskit/Boss - SnackLess Monster.png rename to website/client/presskit/Boss - SnackLess Monster.png diff --git a/website/public/presskit/Boss - Stagnant Dishes.png b/website/client/presskit/Boss - Stagnant Dishes.png similarity index 100% rename from website/public/presskit/Boss - Stagnant Dishes.png rename to website/client/presskit/Boss - Stagnant Dishes.png diff --git a/website/public/presskit/Habitica Gryphon.png b/website/client/presskit/Habitica Gryphon.png similarity index 100% rename from website/public/presskit/Habitica Gryphon.png rename to website/client/presskit/Habitica Gryphon.png diff --git a/website/public/presskit/Habitica Logo - Android.png b/website/client/presskit/Habitica Logo - Android.png similarity index 100% rename from website/public/presskit/Habitica Logo - Android.png rename to website/client/presskit/Habitica Logo - Android.png diff --git a/website/public/presskit/Habitica Logo - Icon with Text.png b/website/client/presskit/Habitica Logo - Icon with Text.png similarity index 100% rename from website/public/presskit/Habitica Logo - Icon with Text.png rename to website/client/presskit/Habitica Logo - Icon with Text.png diff --git a/website/public/presskit/Habitica Logo - Icon.png b/website/client/presskit/Habitica Logo - Icon.png similarity index 100% rename from website/public/presskit/Habitica Logo - Icon.png rename to website/client/presskit/Habitica Logo - Icon.png diff --git a/website/public/presskit/Habitica Logo - Text.png b/website/client/presskit/Habitica Logo - Text.png similarity index 100% rename from website/public/presskit/Habitica Logo - Text.png rename to website/client/presskit/Habitica Logo - Text.png diff --git a/website/public/presskit/Habitica Logo - iOS.png b/website/client/presskit/Habitica Logo - iOS.png similarity index 100% rename from website/public/presskit/Habitica Logo - iOS.png rename to website/client/presskit/Habitica Logo - iOS.png diff --git a/website/public/presskit/Habitica Promo - Thin.png b/website/client/presskit/Habitica Promo - Thin.png similarity index 100% rename from website/public/presskit/Habitica Promo - Thin.png rename to website/client/presskit/Habitica Promo - Thin.png diff --git a/website/public/presskit/Habitica Promo.png b/website/client/presskit/Habitica Promo.png similarity index 100% rename from website/public/presskit/Habitica Promo.png rename to website/client/presskit/Habitica Promo.png diff --git a/website/public/presskit/Sample Screen - Boss (iOS).png b/website/client/presskit/Sample Screen - Boss (iOS).png similarity index 100% rename from website/public/presskit/Sample Screen - Boss (iOS).png rename to website/client/presskit/Sample Screen - Boss (iOS).png diff --git a/website/public/presskit/Sample Screen - Challenges.png b/website/client/presskit/Sample Screen - Challenges.png similarity index 100% rename from website/public/presskit/Sample Screen - Challenges.png rename to website/client/presskit/Sample Screen - Challenges.png diff --git a/website/public/presskit/Sample Screen - Equipment.png b/website/client/presskit/Sample Screen - Equipment.png similarity index 100% rename from website/public/presskit/Sample Screen - Equipment.png rename to website/client/presskit/Sample Screen - Equipment.png diff --git a/website/public/presskit/Sample Screen - Guilds.png b/website/client/presskit/Sample Screen - Guilds.png similarity index 100% rename from website/public/presskit/Sample Screen - Guilds.png rename to website/client/presskit/Sample Screen - Guilds.png diff --git a/website/public/presskit/Sample Screen - Level Up (iOS).png b/website/client/presskit/Sample Screen - Level Up (iOS).png similarity index 100% rename from website/public/presskit/Sample Screen - Level Up (iOS).png rename to website/client/presskit/Sample Screen - Level Up (iOS).png diff --git a/website/public/presskit/Sample Screen - Market.png b/website/client/presskit/Sample Screen - Market.png similarity index 100% rename from website/public/presskit/Sample Screen - Market.png rename to website/client/presskit/Sample Screen - Market.png diff --git a/website/public/presskit/Sample Screen - Party (iOS).png b/website/client/presskit/Sample Screen - Party (iOS).png similarity index 100% rename from website/public/presskit/Sample Screen - Party (iOS).png rename to website/client/presskit/Sample Screen - Party (iOS).png diff --git a/website/public/presskit/Sample Screen - Pets (iOS).png b/website/client/presskit/Sample Screen - Pets (iOS).png similarity index 100% rename from website/public/presskit/Sample Screen - Pets (iOS).png rename to website/client/presskit/Sample Screen - Pets (iOS).png diff --git a/website/public/presskit/Sample Screen - Tasks Page (iOS).png b/website/client/presskit/Sample Screen - Tasks Page (iOS).png similarity index 100% rename from website/public/presskit/Sample Screen - Tasks Page (iOS).png rename to website/client/presskit/Sample Screen - Tasks Page (iOS).png diff --git a/website/public/presskit/Sample Screen - Tasks Page.png b/website/client/presskit/Sample Screen - Tasks Page.png similarity index 100% rename from website/public/presskit/Sample Screen - Tasks Page.png rename to website/client/presskit/Sample Screen - Tasks Page.png diff --git a/website/public/presskit/World Boss - Dread Drag'on of Dilatory.png b/website/client/presskit/World Boss - Dread Drag'on of Dilatory.png similarity index 100% rename from website/public/presskit/World Boss - Dread Drag'on of Dilatory.png rename to website/client/presskit/World Boss - Dread Drag'on of Dilatory.png diff --git a/website/public/presskit/presskit.zip b/website/client/presskit/presskit.zip similarity index 100% rename from website/public/presskit/presskit.zip rename to website/client/presskit/presskit.zip diff --git a/website/public/refresh.png b/website/client/refresh.png similarity index 100% rename from website/public/refresh.png rename to website/client/refresh.png diff --git a/website/src/controllers/api-v2/auth.js b/website/server/controllers/api-v2/auth.js similarity index 100% rename from website/src/controllers/api-v2/auth.js rename to website/server/controllers/api-v2/auth.js diff --git a/website/src/controllers/api-v2/challenges.js b/website/server/controllers/api-v2/challenges.js similarity index 100% rename from website/src/controllers/api-v2/challenges.js rename to website/server/controllers/api-v2/challenges.js diff --git a/website/src/controllers/api-v2/coupon.js b/website/server/controllers/api-v2/coupon.js similarity index 100% rename from website/src/controllers/api-v2/coupon.js rename to website/server/controllers/api-v2/coupon.js diff --git a/website/src/controllers/api-v2/dataexport.js b/website/server/controllers/api-v2/dataexport.js similarity index 100% rename from website/src/controllers/api-v2/dataexport.js rename to website/server/controllers/api-v2/dataexport.js diff --git a/website/src/controllers/api-v2/groups.js b/website/server/controllers/api-v2/groups.js similarity index 100% rename from website/src/controllers/api-v2/groups.js rename to website/server/controllers/api-v2/groups.js diff --git a/website/src/controllers/api-v2/hall.js b/website/server/controllers/api-v2/hall.js similarity index 100% rename from website/src/controllers/api-v2/hall.js rename to website/server/controllers/api-v2/hall.js diff --git a/website/src/controllers/api-v2/members.js b/website/server/controllers/api-v2/members.js similarity index 100% rename from website/src/controllers/api-v2/members.js rename to website/server/controllers/api-v2/members.js diff --git a/website/src/controllers/api-v2/pushNotifications.js b/website/server/controllers/api-v2/pushNotifications.js similarity index 100% rename from website/src/controllers/api-v2/pushNotifications.js rename to website/server/controllers/api-v2/pushNotifications.js diff --git a/website/src/controllers/api-v2/unsubscription.js b/website/server/controllers/api-v2/unsubscription.js similarity index 100% rename from website/src/controllers/api-v2/unsubscription.js rename to website/server/controllers/api-v2/unsubscription.js diff --git a/website/src/controllers/api-v2/user.js b/website/server/controllers/api-v2/user.js similarity index 100% rename from website/src/controllers/api-v2/user.js rename to website/server/controllers/api-v2/user.js diff --git a/website/src/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js similarity index 100% rename from website/src/controllers/api-v3/auth.js rename to website/server/controllers/api-v3/auth.js diff --git a/website/src/controllers/api-v3/challenges.js b/website/server/controllers/api-v3/challenges.js similarity index 100% rename from website/src/controllers/api-v3/challenges.js rename to website/server/controllers/api-v3/challenges.js diff --git a/website/src/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js similarity index 100% rename from website/src/controllers/api-v3/chat.js rename to website/server/controllers/api-v3/chat.js diff --git a/website/src/controllers/api-v3/content.js b/website/server/controllers/api-v3/content.js similarity index 100% rename from website/src/controllers/api-v3/content.js rename to website/server/controllers/api-v3/content.js diff --git a/website/src/controllers/api-v3/coupon.js b/website/server/controllers/api-v3/coupon.js similarity index 100% rename from website/src/controllers/api-v3/coupon.js rename to website/server/controllers/api-v3/coupon.js diff --git a/website/src/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js similarity index 100% rename from website/src/controllers/api-v3/debug.js rename to website/server/controllers/api-v3/debug.js diff --git a/website/src/controllers/api-v3/email.js b/website/server/controllers/api-v3/email.js similarity index 100% rename from website/src/controllers/api-v3/email.js rename to website/server/controllers/api-v3/email.js diff --git a/website/src/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js similarity index 100% rename from website/src/controllers/api-v3/groups.js rename to website/server/controllers/api-v3/groups.js diff --git a/website/src/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js similarity index 100% rename from website/src/controllers/api-v3/hall.js rename to website/server/controllers/api-v3/hall.js diff --git a/website/src/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js similarity index 100% rename from website/src/controllers/api-v3/members.js rename to website/server/controllers/api-v3/members.js diff --git a/website/src/controllers/api-v3/modelsPaths.js b/website/server/controllers/api-v3/modelsPaths.js similarity index 100% rename from website/src/controllers/api-v3/modelsPaths.js rename to website/server/controllers/api-v3/modelsPaths.js diff --git a/website/src/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js similarity index 100% rename from website/src/controllers/api-v3/quests.js rename to website/server/controllers/api-v3/quests.js diff --git a/website/src/controllers/api-v3/status.js b/website/server/controllers/api-v3/status.js similarity index 100% rename from website/src/controllers/api-v3/status.js rename to website/server/controllers/api-v3/status.js diff --git a/website/src/controllers/api-v3/tags.js b/website/server/controllers/api-v3/tags.js similarity index 100% rename from website/src/controllers/api-v3/tags.js rename to website/server/controllers/api-v3/tags.js diff --git a/website/src/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js similarity index 100% rename from website/src/controllers/api-v3/tasks.js rename to website/server/controllers/api-v3/tasks.js diff --git a/website/src/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js similarity index 100% rename from website/src/controllers/api-v3/user.js rename to website/server/controllers/api-v3/user.js diff --git a/website/src/controllers/top-level/auth.js b/website/server/controllers/top-level/auth.js similarity index 100% rename from website/src/controllers/top-level/auth.js rename to website/server/controllers/top-level/auth.js diff --git a/website/src/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js similarity index 100% rename from website/src/controllers/top-level/dataexport.js rename to website/server/controllers/top-level/dataexport.js diff --git a/website/src/controllers/top-level/pages.js b/website/server/controllers/top-level/pages.js similarity index 100% rename from website/src/controllers/top-level/pages.js rename to website/server/controllers/top-level/pages.js diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/server/controllers/top-level/payments/amazon.js similarity index 100% rename from website/src/controllers/top-level/payments/amazon.js rename to website/server/controllers/top-level/payments/amazon.js diff --git a/website/src/controllers/top-level/payments/iap.js b/website/server/controllers/top-level/payments/iap.js similarity index 100% rename from website/src/controllers/top-level/payments/iap.js rename to website/server/controllers/top-level/payments/iap.js diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/server/controllers/top-level/payments/paypal.js similarity index 100% rename from website/src/controllers/top-level/payments/paypal.js rename to website/server/controllers/top-level/payments/paypal.js diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/server/controllers/top-level/payments/stripe.js similarity index 100% rename from website/src/controllers/top-level/payments/stripe.js rename to website/server/controllers/top-level/payments/stripe.js diff --git a/website/src/index.js b/website/server/index.js similarity index 100% rename from website/src/index.js rename to website/server/index.js diff --git a/website/src/libs/api-v2/analytics.js b/website/server/libs/api-v2/analytics.js similarity index 100% rename from website/src/libs/api-v2/analytics.js rename to website/server/libs/api-v2/analytics.js diff --git a/website/src/libs/api-v2/buildManifest.js b/website/server/libs/api-v2/buildManifest.js similarity index 95% rename from website/src/libs/api-v2/buildManifest.js rename to website/server/libs/api-v2/buildManifest.js index 458bd8ac1e..bfbab1421a 100644 --- a/website/src/libs/api-v2/buildManifest.js +++ b/website/server/libs/api-v2/buildManifest.js @@ -2,7 +2,7 @@ var fs = require('fs'); var path = require('path'); var nconf = require('nconf'); var _ = require('lodash'); -var manifestFiles = require("../../../public/manifest.json"); +var manifestFiles = require("../../../client/manifest.json"); var IS_PROD = nconf.get('NODE_ENV') === 'production'; var buildFiles = []; @@ -56,4 +56,4 @@ module.exports.getManifestFiles = function(page){ } return code; -}; \ No newline at end of file +}; diff --git a/website/src/libs/api-v2/firebase.js b/website/server/libs/api-v2/firebase.js similarity index 100% rename from website/src/libs/api-v2/firebase.js rename to website/server/libs/api-v2/firebase.js diff --git a/website/src/libs/api-v2/i18n.js b/website/server/libs/api-v2/i18n.js similarity index 100% rename from website/src/libs/api-v2/i18n.js rename to website/server/libs/api-v2/i18n.js diff --git a/website/src/libs/api-v2/logging.js b/website/server/libs/api-v2/logging.js similarity index 100% rename from website/src/libs/api-v2/logging.js rename to website/server/libs/api-v2/logging.js diff --git a/website/src/libs/api-v2/utils.js b/website/server/libs/api-v2/utils.js similarity index 100% rename from website/src/libs/api-v2/utils.js rename to website/server/libs/api-v2/utils.js diff --git a/website/src/libs/api-v2/webhook.js b/website/server/libs/api-v2/webhook.js similarity index 100% rename from website/src/libs/api-v2/webhook.js rename to website/server/libs/api-v2/webhook.js diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/server/libs/api-v3/amazonPayments.js similarity index 100% rename from website/src/libs/api-v3/amazonPayments.js rename to website/server/libs/api-v3/amazonPayments.js diff --git a/website/src/libs/api-v3/analyticsService.js b/website/server/libs/api-v3/analyticsService.js similarity index 100% rename from website/src/libs/api-v3/analyticsService.js rename to website/server/libs/api-v3/analyticsService.js diff --git a/website/src/libs/api-v3/baseModel.js b/website/server/libs/api-v3/baseModel.js similarity index 100% rename from website/src/libs/api-v3/baseModel.js rename to website/server/libs/api-v3/baseModel.js diff --git a/website/src/libs/api-v3/buildManifest.js b/website/server/libs/api-v3/buildManifest.js similarity index 95% rename from website/src/libs/api-v3/buildManifest.js rename to website/server/libs/api-v3/buildManifest.js index 94d6d49a2d..55db474354 100644 --- a/website/src/libs/api-v3/buildManifest.js +++ b/website/server/libs/api-v3/buildManifest.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import nconf from 'nconf'; -const MANIFEST_FILE_PATH = path.join(__dirname, '/../../../public/manifest.json'); +const MANIFEST_FILE_PATH = path.join(__dirname, '/../../../client/manifest.json'); const BUILD_FOLDER_PATH = path.join(__dirname, '/../../../build'); let manifestFiles = require(MANIFEST_FILE_PATH); @@ -59,4 +59,4 @@ export function getManifestFiles (page) { } return htmlCode; -} \ No newline at end of file +} diff --git a/website/src/libs/api-v3/collectionManipulators.js b/website/server/libs/api-v3/collectionManipulators.js similarity index 100% rename from website/src/libs/api-v3/collectionManipulators.js rename to website/server/libs/api-v3/collectionManipulators.js diff --git a/website/src/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js similarity index 100% rename from website/src/libs/api-v3/cron.js rename to website/server/libs/api-v3/cron.js diff --git a/website/src/libs/api-v3/csvStringify.js b/website/server/libs/api-v3/csvStringify.js similarity index 100% rename from website/src/libs/api-v3/csvStringify.js rename to website/server/libs/api-v3/csvStringify.js diff --git a/website/src/libs/api-v3/email.js b/website/server/libs/api-v3/email.js similarity index 100% rename from website/src/libs/api-v3/email.js rename to website/server/libs/api-v3/email.js diff --git a/website/src/libs/api-v3/encryption.js b/website/server/libs/api-v3/encryption.js similarity index 100% rename from website/src/libs/api-v3/encryption.js rename to website/server/libs/api-v3/encryption.js diff --git a/website/src/libs/api-v3/errors.js b/website/server/libs/api-v3/errors.js similarity index 100% rename from website/src/libs/api-v3/errors.js rename to website/server/libs/api-v3/errors.js diff --git a/website/src/libs/api-v3/firebase.js b/website/server/libs/api-v3/firebase.js similarity index 100% rename from website/src/libs/api-v3/firebase.js rename to website/server/libs/api-v3/firebase.js diff --git a/website/src/libs/api-v3/i18n.js b/website/server/libs/api-v3/i18n.js similarity index 100% rename from website/src/libs/api-v3/i18n.js rename to website/server/libs/api-v3/i18n.js diff --git a/website/src/libs/api-v3/logger.js b/website/server/libs/api-v3/logger.js similarity index 100% rename from website/src/libs/api-v3/logger.js rename to website/server/libs/api-v3/logger.js diff --git a/website/src/libs/api-v3/password.js b/website/server/libs/api-v3/password.js similarity index 100% rename from website/src/libs/api-v3/password.js rename to website/server/libs/api-v3/password.js diff --git a/website/src/libs/api-v3/payments.js b/website/server/libs/api-v3/payments.js similarity index 100% rename from website/src/libs/api-v3/payments.js rename to website/server/libs/api-v3/payments.js diff --git a/website/src/libs/api-v3/preening.js b/website/server/libs/api-v3/preening.js similarity index 100% rename from website/src/libs/api-v3/preening.js rename to website/server/libs/api-v3/preening.js diff --git a/website/src/libs/api-v3/pushNotifications.js b/website/server/libs/api-v3/pushNotifications.js similarity index 100% rename from website/src/libs/api-v3/pushNotifications.js rename to website/server/libs/api-v3/pushNotifications.js diff --git a/website/src/libs/api-v3/routes.js b/website/server/libs/api-v3/routes.js similarity index 100% rename from website/src/libs/api-v3/routes.js rename to website/server/libs/api-v3/routes.js diff --git a/website/src/libs/api-v3/setupMongoose.js b/website/server/libs/api-v3/setupMongoose.js similarity index 100% rename from website/src/libs/api-v3/setupMongoose.js rename to website/server/libs/api-v3/setupMongoose.js diff --git a/website/src/libs/api-v3/setupNconf.js b/website/server/libs/api-v3/setupNconf.js similarity index 100% rename from website/src/libs/api-v3/setupNconf.js rename to website/server/libs/api-v3/setupNconf.js diff --git a/website/src/libs/api-v3/setupPassport.js b/website/server/libs/api-v3/setupPassport.js similarity index 100% rename from website/src/libs/api-v3/setupPassport.js rename to website/server/libs/api-v3/setupPassport.js diff --git a/website/src/libs/api-v3/webhook.js b/website/server/libs/api-v3/webhook.js similarity index 100% rename from website/src/libs/api-v3/webhook.js rename to website/server/libs/api-v3/webhook.js diff --git a/website/src/middlewares/api-v2/domain.js b/website/server/middlewares/api-v2/domain.js similarity index 100% rename from website/src/middlewares/api-v2/domain.js rename to website/server/middlewares/api-v2/domain.js diff --git a/website/src/middlewares/api-v2/errorHandler.js b/website/server/middlewares/api-v2/errorHandler.js similarity index 100% rename from website/src/middlewares/api-v2/errorHandler.js rename to website/server/middlewares/api-v2/errorHandler.js diff --git a/website/src/middlewares/api-v2/locals.js b/website/server/middlewares/api-v2/locals.js similarity index 100% rename from website/src/middlewares/api-v2/locals.js rename to website/server/middlewares/api-v2/locals.js diff --git a/website/src/middlewares/api-v3/analytics.js b/website/server/middlewares/api-v3/analytics.js similarity index 100% rename from website/src/middlewares/api-v3/analytics.js rename to website/server/middlewares/api-v3/analytics.js diff --git a/website/src/middlewares/api-v3/auth.js b/website/server/middlewares/api-v3/auth.js similarity index 100% rename from website/src/middlewares/api-v3/auth.js rename to website/server/middlewares/api-v3/auth.js diff --git a/website/src/middlewares/api-v3/cors.js b/website/server/middlewares/api-v3/cors.js similarity index 100% rename from website/src/middlewares/api-v3/cors.js rename to website/server/middlewares/api-v3/cors.js diff --git a/website/src/middlewares/api-v3/cron.js b/website/server/middlewares/api-v3/cron.js similarity index 100% rename from website/src/middlewares/api-v3/cron.js rename to website/server/middlewares/api-v3/cron.js diff --git a/website/src/middlewares/api-v3/domain.js b/website/server/middlewares/api-v3/domain.js similarity index 100% rename from website/src/middlewares/api-v3/domain.js rename to website/server/middlewares/api-v3/domain.js diff --git a/website/src/middlewares/api-v3/ensureAccessRight.js b/website/server/middlewares/api-v3/ensureAccessRight.js similarity index 100% rename from website/src/middlewares/api-v3/ensureAccessRight.js rename to website/server/middlewares/api-v3/ensureAccessRight.js diff --git a/website/src/middlewares/api-v3/ensureDevelpmentMode.js b/website/server/middlewares/api-v3/ensureDevelpmentMode.js similarity index 100% rename from website/src/middlewares/api-v3/ensureDevelpmentMode.js rename to website/server/middlewares/api-v3/ensureDevelpmentMode.js diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/server/middlewares/api-v3/errorHandler.js similarity index 100% rename from website/src/middlewares/api-v3/errorHandler.js rename to website/server/middlewares/api-v3/errorHandler.js diff --git a/website/src/middlewares/api-v3/index.js b/website/server/middlewares/api-v3/index.js similarity index 97% rename from website/src/middlewares/api-v3/index.js rename to website/server/middlewares/api-v3/index.js index cadee52a1b..4b526c10e1 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/server/middlewares/api-v3/index.js @@ -28,7 +28,7 @@ import { const IS_PROD = nconf.get('IS_PROD'); const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); -const PUBLIC_DIR = path.join(__dirname, '/../../../public'); +const PUBLIC_DIR = path.join(__dirname, '/../../../client'); const SESSION_SECRET = nconf.get('SESSION_SECRET'); const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; diff --git a/website/src/middlewares/api-v3/language.js b/website/server/middlewares/api-v3/language.js similarity index 100% rename from website/src/middlewares/api-v3/language.js rename to website/server/middlewares/api-v3/language.js diff --git a/website/src/middlewares/api-v3/locals.js b/website/server/middlewares/api-v3/locals.js similarity index 100% rename from website/src/middlewares/api-v3/locals.js rename to website/server/middlewares/api-v3/locals.js diff --git a/website/src/middlewares/api-v3/notFound.js b/website/server/middlewares/api-v3/notFound.js similarity index 100% rename from website/src/middlewares/api-v3/notFound.js rename to website/server/middlewares/api-v3/notFound.js diff --git a/website/src/middlewares/api-v3/redirects.js b/website/server/middlewares/api-v3/redirects.js similarity index 100% rename from website/src/middlewares/api-v3/redirects.js rename to website/server/middlewares/api-v3/redirects.js diff --git a/website/src/middlewares/api-v3/response.js b/website/server/middlewares/api-v3/response.js similarity index 100% rename from website/src/middlewares/api-v3/response.js rename to website/server/middlewares/api-v3/response.js diff --git a/website/src/middlewares/api-v3/setupBody.js b/website/server/middlewares/api-v3/setupBody.js similarity index 100% rename from website/src/middlewares/api-v3/setupBody.js rename to website/server/middlewares/api-v3/setupBody.js diff --git a/website/src/middlewares/api-v3/static.js b/website/server/middlewares/api-v3/static.js similarity index 93% rename from website/src/middlewares/api-v3/static.js rename to website/server/middlewares/api-v3/static.js index eda3a9e39d..19c1c9025c 100644 --- a/website/src/middlewares/api-v3/static.js +++ b/website/server/middlewares/api-v3/static.js @@ -4,7 +4,7 @@ import path from 'path'; const IS_PROD = nconf.get('IS_PROD'); const MAX_AGE = IS_PROD ? 31536000000 : 0; -const PUBLIC_DIR = path.join(__dirname, '/../../../public'); +const PUBLIC_DIR = path.join(__dirname, '/../../../client'); const BUILD_DIR = path.join(__dirname, '/../../../build'); module.exports = function staticMiddleware (expressApp) { diff --git a/website/src/middlewares/api-v3/v1.js b/website/server/middlewares/api-v3/v1.js similarity index 100% rename from website/src/middlewares/api-v3/v1.js rename to website/server/middlewares/api-v3/v1.js diff --git a/website/src/middlewares/api-v3/v2.js b/website/server/middlewares/api-v3/v2.js similarity index 100% rename from website/src/middlewares/api-v3/v2.js rename to website/server/middlewares/api-v3/v2.js diff --git a/website/src/middlewares/api-v3/v3.js b/website/server/middlewares/api-v3/v3.js similarity index 100% rename from website/src/middlewares/api-v3/v3.js rename to website/server/middlewares/api-v3/v3.js diff --git a/website/src/middlewares/apiThrottle.js b/website/server/middlewares/apiThrottle.js similarity index 100% rename from website/src/middlewares/apiThrottle.js rename to website/server/middlewares/apiThrottle.js diff --git a/website/src/middlewares/forceRefresh.js b/website/server/middlewares/forceRefresh.js similarity index 100% rename from website/src/middlewares/forceRefresh.js rename to website/server/middlewares/forceRefresh.js diff --git a/website/src/models/challenge.js b/website/server/models/challenge.js similarity index 100% rename from website/src/models/challenge.js rename to website/server/models/challenge.js diff --git a/website/src/models/coupon.js b/website/server/models/coupon.js similarity index 100% rename from website/src/models/coupon.js rename to website/server/models/coupon.js diff --git a/website/src/models/emailUnsubscription.js b/website/server/models/emailUnsubscription.js similarity index 100% rename from website/src/models/emailUnsubscription.js rename to website/server/models/emailUnsubscription.js diff --git a/website/src/models/group.js b/website/server/models/group.js similarity index 100% rename from website/src/models/group.js rename to website/server/models/group.js diff --git a/website/src/models/tag.js b/website/server/models/tag.js similarity index 100% rename from website/src/models/tag.js rename to website/server/models/tag.js diff --git a/website/src/models/task.js b/website/server/models/task.js similarity index 100% rename from website/src/models/task.js rename to website/server/models/task.js diff --git a/website/src/models/user.js b/website/server/models/user.js similarity index 100% rename from website/src/models/user.js rename to website/server/models/user.js diff --git a/website/src/routes/api-v2/auth.js b/website/server/routes/api-v2/auth.js similarity index 100% rename from website/src/routes/api-v2/auth.js rename to website/server/routes/api-v2/auth.js diff --git a/website/src/routes/api-v2/coupon.js b/website/server/routes/api-v2/coupon.js similarity index 100% rename from website/src/routes/api-v2/coupon.js rename to website/server/routes/api-v2/coupon.js diff --git a/website/src/routes/api-v2/swagger.js b/website/server/routes/api-v2/swagger.js similarity index 100% rename from website/src/routes/api-v2/swagger.js rename to website/server/routes/api-v2/swagger.js diff --git a/website/src/routes/api-v2/unsubscription.js b/website/server/routes/api-v2/unsubscription.js similarity index 100% rename from website/src/routes/api-v2/unsubscription.js rename to website/server/routes/api-v2/unsubscription.js diff --git a/website/src/routes/pages.js b/website/server/routes/pages.js similarity index 100% rename from website/src/routes/pages.js rename to website/server/routes/pages.js diff --git a/website/src/routes/payments.js b/website/server/routes/payments.js similarity index 100% rename from website/src/routes/payments.js rename to website/server/routes/payments.js diff --git a/website/src/server.js b/website/server/server.js similarity index 100% rename from website/src/server.js rename to website/server/server.js diff --git a/website/views/static/api.jade b/website/views/static/api.jade index 8c7ebcce8a..cf290cc0f8 100644 --- a/website/views/static/api.jade +++ b/website/views/static/api.jade @@ -93,7 +93,7 @@ html p All API requests should be prefaced by https://habitica.com. Every authenticated request should include two headers. Your api key (x-api-key) and your user id (x-api-user). Do not include {} braces in your header (-H 'x-api-user: a94b6d9d-6b64-43ae-856c-2c3f211bd426') h2 Requirements: p The base-url for all routes is /api/v2. So /user actions will be at https://habitica.com/api/v2/*. You need to send x-api-user and x-api-key headers for each request. - p For create & edit paths (PUT & POST), you'll need to know the schema of the object you're trying to create or edit. See Schema definitions here + p For create & edit paths (PUT & POST), you'll need to know the schema of the object you're trying to create or edit. See Schema definitions here p If any of the documentation is lacking or you're having trouble with it, please post an issue to Github #message-bar.swagger-ui-wrap #swagger-ui-container.swagger-ui-wrap From 468a3123576a97492f18d76de7c443eb6a78b3e3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 May 2016 19:08:38 +0200 Subject: [PATCH 804/976] v3 fix GET /groups: return an error only if an invalid type is supplied not when there are 0 results (#7203) --- test/api/v3/integration/groups/GET-groups.test.js | 10 ++++++++++ website/server/controllers/api-v3/groups.js | 4 ---- website/server/models/group.js | 11 ++++++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js index 279f091411..8e1e9dfbfc 100644 --- a/test/api/v3/integration/groups/GET-groups.test.js +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -2,6 +2,7 @@ import { generateUser, resetHabiticaDB, generateGroup, + translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { TAVERN_ID, @@ -70,6 +71,15 @@ describe('GET /groups', () => { }); }); + it('returns error when an invalid ?type query is passed', async () => { + await expect(user.get('/groups?type=invalid')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('groupTypesRequired'), + }); + }); + it('returns only the tavern when tavern passed in as query', async () => { await expect(user.get('/groups?type=tavern')) .to.eventually.have.a.lengthOf(1) diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 76b77037ed..afd4507fcb 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -100,10 +100,6 @@ api.getGroups = { let sort = '-memberCount'; let results = await Group.getGroups({user, types, groupFields, sort}); - - // If no valid value for type was supplied, return an error - if (results.length === 0) throw new BadRequest(res.t('groupTypesRequired')); - res.respond(200, results); }, }; diff --git a/website/server/models/group.js b/website/server/models/group.js index e98dfc1567..7a511f51e7 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -8,7 +8,10 @@ import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; import { removeFromArray } from '../libs/api-v3/collectionManipulators'; -import { InternalServerError } from '../libs/api-v3/errors'; +import { + InternalServerError, + BadRequest, +} from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; @@ -139,10 +142,16 @@ schema.statics.getGroup = async function getGroup (options = {}) { return group; }; +export const VALID_QUERY_TYPES = ['party', 'guilds', 'privateGuilds', 'publicGuilds', 'tavern']; + schema.statics.getGroups = async function getGroups (options = {}) { let {user, types, groupFields = basicFields, sort = '-memberCount', populateLeader = false} = options; let queries = []; + // Throw error if an invalid type is supplied + let areValidTypes = types.every(type => VALID_QUERY_TYPES.indexOf(type) !== -1); + if (!areValidTypes) throw new BadRequest(shared.i18n.t('groupTypesRequired')); + types.forEach(type => { switch (type) { case 'party': { From cc20812674297cccd3ce951c7714f285f9bf9ff3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 13 May 2016 19:45:16 +0200 Subject: [PATCH 805/976] [API v3] Fix calls to user.ops and deleting tags (#7204) * v3: fixes calls to user.ops from views and deleting tags * v3: fix tests that use user._statsComputed --- test/spec/services/statServicesSpec.js | 12 +++-- website/client/js/services/statServices.js | 4 +- website/client/js/services/userServices.js | 48 +++++++++++++++++++ website/views/main/filters.jade | 2 +- website/views/options/inventory/drops.jade | 4 +- .../options/inventory/time-travelers.jade | 2 +- website/views/options/profile.jade | 4 +- website/views/options/settings.jade | 2 +- .../views/options/social/chat-message.jade | 2 +- website/views/shared/header/header.jade | 4 +- website/views/shared/modals/buy-gems.jade | 2 +- website/views/shared/modals/classes.jade | 2 +- website/views/shared/modals/death.jade | 2 +- website/views/shared/modals/limited.jade | 2 +- website/views/shared/modals/members.jade | 4 +- website/views/shared/modals/quests.jade | 2 +- .../shared/profiles/stats/attributes.jade | 2 +- .../views/shared/tasks/task_view/mixins.jade | 2 +- 18 files changed, 78 insertions(+), 24 deletions(-) diff --git a/test/spec/services/statServicesSpec.js b/test/spec/services/statServicesSpec.js index 81e13645de..a1d99809c2 100644 --- a/test/spec/services/statServicesSpec.js +++ b/test/spec/services/statServicesSpec.js @@ -76,7 +76,11 @@ describe('Stats Service', function() { "armor" : "armor_warrior_1" }; var user = { - _statsComputed: { str: 50 }, + fns: { + statsComputed: function () { + return { str: 50 }; + }, + }, stats: { lvl: 10, buffs: { str: 10 }, @@ -252,7 +256,8 @@ describe('Stats Service', function() { describe('mpDisplay', function() { it('displays mp as "mp / totalMP"', function() { - user._statsComputed = { maxMP: 100 }; + user.fns = {}; + user.fns.statsComputed = function () { return { maxMP: 100 } }; user.stats.mp = 30; var mpDisplay = statCalc.mpDisplay(user); @@ -260,7 +265,8 @@ describe('Stats Service', function() { }); it('Rounds mp down when given a decimal', function() { - user._statsComputed = { maxMP: 100 }; + user.fns = {}; + user.fns.statsComputed = function () { return { maxMP: 100 } }; user.stats.mp = 30.99; var mpDisplay = statCalc.mpDisplay(user); diff --git a/website/client/js/services/statServices.js b/website/client/js/services/statServices.js index f2db805fb6..b716dbcaf7 100644 --- a/website/client/js/services/statServices.js +++ b/website/client/js/services/statServices.js @@ -22,7 +22,7 @@ } function classBonus(user, stat) { - var computedStats = user._statsComputed; + var computedStats = (user.fns && user.fns.statsComputed) ? user.fns.statsComputed() : null; if(computedStats) { var bonus = computedStats[stat] @@ -95,7 +95,7 @@ function mpDisplay(user) { var remainingMP = Math.floor(user.stats.mp); - var totalMP = user._statsComputed.maxMP; + var totalMP = (user.fns && user.fns.statsComputed) ? user.fns.statsComputed().maxMP : null; var display = _formatOutOfTotalDisplay(remainingMP, totalMP); return display; diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 13ec13120e..64adb50140 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -153,10 +153,22 @@ angular.module('habitrpg') callOpsFunctionAndRequest('allocate', 'allocate', "POST",'', data); }, + allocateNow: function () { + callOpsFunctionAndRequest('allocateNow', 'allocate-now', "POST"); + }, + changeClass: function (data) { callOpsFunctionAndRequest('changeClass', 'change-class', "POST",'', data); }, + disableClasses: function () { + callOpsFunctionAndRequest('disableClasses', 'disable-classes', "POST"); + }, + + revive: function (data) { + callOpsFunctionAndRequest('revive', 'revive', "POST"); + }, + addTask: function (data) { user.ops.addTask(data); save(); @@ -187,6 +199,12 @@ angular.module('habitrpg') Tasks.deleteTask(data.params.id); }, + clearCompleted: function () { + user.ops.clearCompleted(user.todos); + save(); + Tasks.clearCompletedTodos(); + }, + addTag: function(data) { user.ops.addTag(data); save(); @@ -199,6 +217,12 @@ angular.module('habitrpg') Tags.updateTag(data.params.id, data.body); }, + deleteTag: function(data) { + user.ops.deleteTag(data); + save(); + Tags.deleteTag(data.params.id); + }, + addTenGems: function () { $http({ method: "POST", @@ -228,10 +252,18 @@ angular.module('habitrpg') callOpsFunctionAndRequest('clearPMs', 'messages', "DELETE"); }, + deletePM: function (data) { + callOpsFunctionAndRequest('deletePM', 'messages', "DELETE", data.params.id, data); + }, + buy: function (data) { callOpsFunctionAndRequest('buy', 'buy', "POST", data.params.key, data); }, + buyQuest: function (data) { + callOpsFunctionAndRequest('buyQuest', 'buy-quest', "POST", data.params.key, data); + }, + purchase: function (data) { var type = data.params.type; var key = data.params.key; @@ -251,6 +283,18 @@ angular.module('habitrpg') }) }, + buyMysterySet: function (data) { + callOpsFunctionAndRequest('buyMysterySet', 'buy-mystery-set', "POST", data.params.key, data); + }, + + readCard: function (data) { + callOpsFunctionAndRequest('readCard', 'read-card', "POST", data.params.cardType, data); + }, + + openMysteryItem: function (data) { + callOpsFunctionAndRequest('openMysteryItem', 'open-mystery-item', "POST"); + }, + sell: function (data) { var type = data.params.type; var key = data.params.key; @@ -334,6 +378,10 @@ angular.module('habitrpg') callOpsFunctionAndRequest('sleep', 'sleep', "POST"); }, + blockUser: function (data) { + callOpsFunctionAndRequest('blockUser', 'block', "POST", data.params.uuid, data); + }, + online: function (status) { if (status===true) { settings.online = true; diff --git a/website/views/main/filters.jade b/website/views/main/filters.jade index b72d424364..40e11817a8 100644 --- a/website/views/main/filters.jade +++ b/website/views/main/filters.jade @@ -31,7 +31,7 @@ li.filters-edit(ng-class='{active: user.filters[tag.id]}', ng-repeat='tag in user.tags', bindonce='user.tags') form.hrpg-input-group input(type='text', ng-model='tag.name', ui-keyup="{13: 'saveOrEdit()'}") - button(type='button', ng-click='user.ops.deleteTag({params:{id:tag.id}})') + button(type='button', ng-click='User.deleteTag({params:{id:tag.id}})') span.glyphicon.glyphicon-trash ul(ng-if='!_editing', hrpg-sort-tags) li.filters-tags(ng-class='{active: user.filters[tag.id], challenge: tag.challenge}', ng-repeat='tag in user.tags', bindonce='user.tags') diff --git a/website/views/options/inventory/drops.jade b/website/views/options/inventory/drops.jade index 134e9ade50..425d6fe276 100644 --- a/website/views/options/inventory/drops.jade +++ b/website/views/options/inventory/drops.jade @@ -65,7 +65,7 @@ button.customize-option(class='inventory_present inventory_present_{{moment().format("MM")}}', popover=env.t('subscriberItemText'), popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', - ng-click="user.ops.openMysteryItem({})") + ng-click="User.openMysteryItem({})") .badge.badge-info.stack-count {{user.purchased.plan.mysteryItems.length}} div(ng-if='user.purchased.plan.consecutive.trinkets') @@ -199,7 +199,7 @@ button.customize-option(popover=env.t('subGemPop'), popover-title=env.t('subGemName'), popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', - ng-click='user.ops.purchase({params:{type:"gems",key:"gem"}})') + ng-click='User.purchase({params:{type:"gems",key:"gem"}})') span.Pet_Currency_Gem.inline-gems .badge.badge-success.stack-count {{Shared.planGemLimits.convCap + User.user.purchased.plan.consecutive.gemCapExtra - User.user.purchased.plan.gemsBought}} p diff --git a/website/views/options/inventory/time-travelers.jade b/website/views/options/inventory/time-travelers.jade index 66fc530bd0..be0c3ea7dd 100644 --- a/website/views/options/inventory/time-travelers.jade +++ b/website/views/options/inventory/time-travelers.jade @@ -35,4 +35,4 @@ popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', - ng-click='user.ops.buyMysterySet({params:{key:set.key}})') + ng-click='User.buyMysterySet({params:{key:set.key}})') diff --git a/website/views/options/profile.jade b/website/views/options/profile.jade index d9af640ae8..036d0cf529 100644 --- a/website/views/options/profile.jade +++ b/website/views/options/profile.jade @@ -219,7 +219,7 @@ mixin profileStats input(type='radio', name='allocationMode', value='taskbased', ng-model='user.preferences.allocationMode', ng-change='set({"preferences.allocationMode": "taskbased"})') span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('taskAllocationPop'))=env.t('taskAllocation') div(ng-show='user.preferences.automaticAllocation && !(user.preferences.allocationMode === "taskbased") && (user.stats.points > 0)') - a.btn.btn-primary.btn-xs(ng-click='user.ops.allocateNow({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('distributePointsPop')) + a.btn.btn-primary.btn-xs(ng-click='User.allocateNow({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('distributePointsPop')) span.glyphicon.glyphicon-download |  =env.t('distributePoints') @@ -227,7 +227,7 @@ mixin profileStats div(ng-class='user.flags.classSelected && !user.preferences.disableClasses ? "col-md-4" : "col-md-6"') - button.btn.btn-default(ng-if='user.preferences.disableClasses', ng-click='user.ops.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') + button.btn.btn-default(ng-if='user.preferences.disableClasses', ng-click='User.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') hr(ng-if='user.preferences.disableClasses') include ../shared/profiles/achievements diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index e5d9896e61..815b91f3e3 100644 --- a/website/views/options/settings.jade +++ b/website/views/options/settings.jade @@ -92,7 +92,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') button.btn.btn-default(ng-click='showBailey()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('showBaileyPop'))= env.t('showBailey') button.btn.btn-default(ng-click='openRestoreModal()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('fixValPop'))= env.t('fixVal') - button.btn.btn-default(ng-if='user.preferences.disableClasses==true', ng-click='user.ops.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') + button.btn.btn-default(ng-if='user.preferences.disableClasses==true', ng-click='User.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') hr diff --git a/website/views/options/social/chat-message.jade b/website/views/options/social/chat-message.jade index 3231726e82..07447b483c 100644 --- a/website/views/options/social/chat-message.jade +++ b/website/views/options/social/chat-message.jade @@ -29,7 +29,7 @@ mixin chatMessages(inbox) a(ng-click="quickReply(message.uuid)") span.glyphicon.glyphicon-share-alt(tooltip=env.t('pm-reply')) span(ng-if='#{inbox ? "true" : ":: user.contributor.admin || message.uuid == user.id"}')     - a(ng-click='#{inbox? "user.ops.deletePM({params:{id:message.$key}})" : "deleteChatMessage(group, message)"}') + a(ng-click='#{inbox? "User.deletePM({params:{id:message.$key}})" : "deleteChatMessage(group, message)"}') span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) span(ng-if=':: user.contributor.admin || (!message.sent && user.flags.communityGuidelinesAccepted && message.uuid != user.id && message.uuid != "system")')     a(ng-click="flagChatMessage(group._id, message)") diff --git a/website/views/shared/header/header.jade b/website/views/shared/header/header.jade index 5c0b847849..db91442927 100644 --- a/website/views/shared/header/header.jade +++ b/website/views/shared/header/header.jade @@ -25,10 +25,10 @@ .meter-label(tooltip='Mana', ng-if='user.flags.classSelected && !user.preferences.disableClasses') span.glyphicon.glyphicon-fire .meter.mana(ng-if='user.flags.classSelected && !user.preferences.disableClasses', tooltip='{{Math.round(user.stats.mp * 100) / 100}}') - .bar(ng-style='{"width": (user.stats.mp / user._statsComputed.maxMP * 100) + "%"}') + .bar(ng-style='{"width": (user.stats.mp / user.fns.statsComputed().maxMP * 100) + "%"}') span.meter-text.value span - | {{Math.floor(user.stats.mp)}} / {{user._statsComputed.maxMP}} + | {{Math.floor(user.stats.mp)}} / {{user.fns.statsComputed().maxMP}} // party .party(ng-controller='PartyCtrl') button.party-invite.btn.btn-primary(ng-click="inviteOrStartParty(group)", diff --git a/website/views/shared/modals/buy-gems.jade b/website/views/shared/modals/buy-gems.jade index d071450cab..6fdec903d4 100644 --- a/website/views/shared/modals/buy-gems.jade +++ b/website/views/shared/modals/buy-gems.jade @@ -34,7 +34,7 @@ script(id='modals/buyGems.html', type='text/ng-template') .container-fluid .row .col-md-3 - button.customize-option(ng-click='user.ops.purchase({params:{type:"gems",key:"gem"}})') + button.customize-option(ng-click='User.purchase({params:{type:"gems",key:"gem"}})') span.Pet_Currency_Gem.inline-gems .badge.badge-success.stack-count {{Shared.planGemLimits.convCap + User.user.purchased.plan.consecutive.gemCapExtra - User.user.purchased.plan.gemsBought}} p diff --git a/website/views/shared/modals/classes.jade b/website/views/shared/modals/classes.jade index 6484626318..754d6f31f0 100644 --- a/website/views/shared/modals/classes.jade +++ b/website/views/shared/modals/classes.jade @@ -68,6 +68,6 @@ script(type='text/ng-template', id='modals/chooseClass.html') .modal-footer span(popover-placement='left', popover-trigger='mouseenter', popover=env.t('optOutOfClassesText')) - button.btn.btn-danger(ng-click='user.ops.disableClasses({}); $close()')=env.t('optOutOfClasses') + button.btn.btn-danger(ng-click='User.disableClasses({}); $close()')=env.t('optOutOfClasses') button.btn.btn-primary(ng-disabled='!selectedClass' ng-click='changeClass(selectedClass); $close()')=env.t('select') .pull-left!=env.t('chooseClassLearn') diff --git a/website/views/shared/modals/death.jade b/website/views/shared/modals/death.jade index cfdbced16e..60a92c3802 100644 --- a/website/views/shared/modals/death.jade +++ b/website/views/shared/modals/death.jade @@ -21,5 +21,5 @@ script(type='text/ng-template', id='modals/death.html') h4(style='margin-top:1.5em')=env.t('dontDespair') p(style='margin-top:1.5em')=env.t('deathPenaltyDetails') .modal-footer - a.btn.btn-danger.btn-lg.flex-column(ng-click='user.ops.revive({}); $close()')=env.t('refillHealthTryAgain') + a.btn.btn-danger.btn-lg.flex-column(ng-click='User.revive(); $close()')=env.t('refillHealthTryAgain') h4.text-center!=env.t('dyingOftenTips') diff --git a/website/views/shared/modals/limited.jade b/website/views/shared/modals/limited.jade index 158fd5a647..0f68a2dcad 100644 --- a/website/views/shared/modals/limited.jade +++ b/website/views/shared/modals/limited.jade @@ -9,4 +9,4 @@ script(id='modals/cards.html', type='text/ng-template') markdown(text='::cardMessage') .modal-footer small.pull-left {{::env.t(cardType + 'CardExplanation')}} - button.btn.btn-default(ng-click='user.ops.readCard({params: {cardType: cardType}}); $close()')=env.t('ok') + button.btn.btn-default(ng-click='User.readCard({params: {cardType: cardType}}); $close()')=env.t('ok') diff --git a/website/views/shared/modals/members.jade b/website/views/shared/modals/members.jade index b40a56eea5..8759b8aad6 100644 --- a/website/views/shared/modals/members.jade +++ b/website/views/shared/modals/members.jade @@ -33,9 +33,9 @@ script(type='text/ng-template', id='modals/member.html') include ../profiles/achievements .modal-footer .btn-group.pull-left(ng-if='::user') - button.btn.btn-md.btn-default(ng-if='user.inbox.blocks | contains:profile._id', tooltip=env.t('unblock'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') + button.btn.btn-md.btn-default(ng-if='user.inbox.blocks | contains:profile._id', tooltip=env.t('unblock'), ng-click="User.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') span.glyphicon.glyphicon-plus - button.btn.btn-md.btn-default(ng-if='profile._id != user._id && !profile.contributor.admin && !(user.inbox.blocks | contains:profile._id)', tooltip=env.t('block'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') + button.btn.btn-md.btn-default(ng-if='profile._id != user._id && !profile.contributor.admin && !(user.inbox.blocks | contains:profile._id)', tooltip=env.t('block'), ng-click="User.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') span.glyphicon.glyphicon-ban-circle button.btn.btn-md.btn-default(tooltip=env.t('sendPM'), ng-click="openModal('private-message',{controller:'MemberModalCtrl'})", tooltip-placement='right') span.glyphicon.glyphicon-envelope diff --git a/website/views/shared/modals/quests.jade b/website/views/shared/modals/quests.jade index a9dbf6a991..10ce712b6a 100644 --- a/website/views/shared/modals/quests.jade +++ b/website/views/shared/modals/quests.jade @@ -57,7 +57,7 @@ script(type='text/ng-template', id='modals/buyQuest.html') .modal-footer button.btn.btn-default(ng-click='closeQuest(); $close()')=env.t('neverMind') button.btn.btn-primary(ng-if='::selectedQuest.category !== "gold"', ng-click='purchase("quests", quest); closeQuest(); $close()')=env.t('buyQuest') + ': {{::selectedQuest.value}} ' + env.t('gems') - button.btn.btn-primary(ng-if='::selectedQuest.category === "gold"', ng-click='user.ops.buyQuest({params:{key:selectedQuest.key}}); closeQuest(); $close()')=env.t('buyQuest') + ': {{::selectedQuest.goldValue}} ' + env.t('gold') + button.btn.btn-primary(ng-if='::selectedQuest.category === "gold"', ng-click='User.buyQuest({params:{key:selectedQuest.key}}); closeQuest(); $close()')=env.t('buyQuest') + ': {{::selectedQuest.goldValue}} ' + env.t('gold') script(type='text/ng-template', id='modals/questInvitation.html') .modal-header diff --git a/website/views/shared/profiles/stats/attributes.jade b/website/views/shared/profiles/stats/attributes.jade index 0d222a42b4..bf5b1dbcba 100644 --- a/website/views/shared/profiles/stats/attributes.jade +++ b/website/views/shared/profiles/stats/attributes.jade @@ -7,7 +7,7 @@ table.table.table-striped span.hint(popover-title=env.t(statInfo.title), popover-placement='right', popover=env.t(statInfo.popover), popover-trigger='mouseenter') strong=env.t(statInfo.title) - strong : {{profile._statsComputed.#{stat}}} + strong : {{profile.fns.statsComputed().#{stat}}} td: ul.list-unstyled +statList('statCalc.levelBonus(profile.stats.lvl)', 'levelBonus', 'level', true) diff --git a/website/views/shared/tasks/task_view/mixins.jade b/website/views/shared/tasks/task_view/mixins.jade index 398813d5ad..7fdbe66f53 100644 --- a/website/views/shared/tasks/task_view/mixins.jade +++ b/website/views/shared/tasks/task_view/mixins.jade @@ -24,7 +24,7 @@ mixin taskColumnTabs(position) div(ng-show='list.view == "complete"') .alert =env.t('lotOfToDos') - button.task-action-btn.tile.spacious.bright(ng-click='user.ops.clearCompleted({})',popover=env.t('deleteToDosExplanation'),popover-trigger='mouseenter')=env.t('clearCompleted') + button.task-action-btn.tile.spacious.bright(ng-click='User.clearCompleted({})',popover=env.t('deleteToDosExplanation'),popover-trigger='mouseenter')=env.t('clearCompleted') // remaining/completed tabs ul.task-filter li(ng-class='{active: list.view == "remaining"}') From 1fd7df752146e7ab13851f5292438de1117d3c1a Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 13 May 2016 16:36:25 -0500 Subject: [PATCH 806/976] Api v3 fixes continued (#7205) * Added timzeone offset back * Added APIToken back to settings page * Fixed fetch recent messages for party * Fixed returning group description * Fixed check if user is member of challenge * Fixed party members appearing in header * Updated get myGroups param to include public groups. Fixed isMemberOf group * Fixed hourglass purchase * Fixed challenge addding tasks on first creating * Updated tests to accomidate new changes --- test/spec/controllers/challengesCtrlSpec.js | 11 ++++++-- test/spec/services/groupServicesSpec.js | 12 ++++++-- test/spec/services/questServicesSpec.js | 1 + .../client/js/controllers/challengesCtrl.js | 28 +++++++++++++------ website/client/js/controllers/chatCtrl.js | 17 ++++++----- website/client/js/services/groupServices.js | 12 +++++--- website/client/js/services/memberServices.js | 10 +++++-- website/client/js/services/userServices.js | 17 ++++------- website/server/controllers/api-v3/groups.js | 2 +- website/views/options/settings.jade | 3 +- website/views/options/social/challenges.jade | 4 +-- website/views/options/social/index.jade | 4 +-- 12 files changed, 72 insertions(+), 49 deletions(-) diff --git a/test/spec/controllers/challengesCtrlSpec.js b/test/spec/controllers/challengesCtrlSpec.js index ab96b6fb8c..495f1b020b 100644 --- a/test/spec/controllers/challengesCtrlSpec.js +++ b/test/spec/controllers/challengesCtrlSpec.js @@ -1,7 +1,7 @@ 'use strict'; describe('Challenges Controller', function() { - var rootScope, scope, user, User, ctrl, groups, members, notification, state, challenges; + var rootScope, scope, user, User, ctrl, groups, members, notification, state, challenges, tasks; beforeEach(function() { module(function($provide) { @@ -14,7 +14,7 @@ describe('Challenges Controller', function() { $provide.value('User', User); }); - inject(function($rootScope, $controller, _$state_, _Groups_, _Members_, _Notification_, _Challenges_){ + inject(function($rootScope, $controller, _$state_, _Groups_, _Members_, _Notification_, _Challenges_, _Tasks_){ scope = $rootScope.$new(); rootScope = $rootScope; @@ -24,6 +24,7 @@ describe('Challenges Controller', function() { ctrl = $controller('ChallengesCtrl', {$scope: scope, User: User}); challenges = _Challenges_; + tasks = _Tasks_; groups = _Groups_; members = _Members_; notification = _Notification_; @@ -320,13 +321,17 @@ describe('Challenges Controller', function() { context('challenge owner interactions', function() { describe("save challenge", function() { - var alert, createChallengeSpy, challengeResponse; + var alert, createChallengeSpy, challengeResponse, taskChallengeCreateSpy; beforeEach(function(){ alert = sandbox.stub(window, "alert"); createChallengeSpy = sinon.stub(challenges, 'createChallenge'); challengeResponse = {data: {data: {_id: 'new-challenge'}}}; createChallengeSpy.returns(Promise.resolve(challengeResponse)); + + taskChallengeCreateSpy = sinon.stub(tasks, 'createChallengeTasks'); + var taskResponse = {data: {data: []}}; + taskChallengeCreateSpy.returns(Promise.resolve(taskResponse)); }); it("opens an alert box if challenge.group is not specified", function() { diff --git a/test/spec/services/groupServicesSpec.js b/test/spec/services/groupServicesSpec.js index 138fb6152a..71e6b7d08e 100644 --- a/test/spec/services/groupServicesSpec.js +++ b/test/spec/services/groupServicesSpec.js @@ -33,7 +33,10 @@ describe('groupServices', function() { }); it('calls party endpoint', function() { - $httpBackend.expectGET(groupApiUrlPrefix + '/party').respond({}); + var groupId = '1234'; + var groupResponse = {data: {_id: groupId}}; + $httpBackend.expectGET(groupApiUrlPrefix + '/party').respond(groupResponse); + $httpBackend.expectGET('/api/v3/groups/' + groupId + '/members?includeAllPublicFields=true').respond({}); groups.Group.syncParty(); $httpBackend.flush(); }); @@ -74,7 +77,10 @@ describe('groupServices', function() { }); it('calls party endpoint when party is not cached', function() { - $httpBackend.expectGET(groupApiUrlPrefix + '/party').respond({}); + var groupId = '1234'; + var groupResponse = {data: {_id: groupId}}; + $httpBackend.expectGET(groupApiUrlPrefix + '/party').respond(groupResponse); + $httpBackend.expectGET('/api/v3/groups/' + groupId + '/members?includeAllPublicFields=true').respond({}); groups.party(); $httpBackend.flush(); }); @@ -136,7 +142,7 @@ describe('groupServices', function() { }); it('calls my guilds endpoint', function() { - $httpBackend.expectGET(groupApiUrlPrefix + '?type=privateGuilds').respond([]); + $httpBackend.expectGET(groupApiUrlPrefix + '?type=guilds').respond([]); groups.myGuilds(); $httpBackend.flush(); }); diff --git a/test/spec/services/questServicesSpec.js b/test/spec/services/questServicesSpec.js index fd9dbf493f..b4741b601c 100644 --- a/test/spec/services/questServicesSpec.js +++ b/test/spec/services/questServicesSpec.js @@ -348,6 +348,7 @@ describe('Quests Service', function() { fakeBackend.when('GET', 'partials/main.html').respond({}); fakeBackend.when('GET', 'partials/main.html').respond({}); fakeBackend.when('GET', '/api/v3/groups/party').respond(partyResponse); + fakeBackend.when('GET', '/api/v3/groups/party-id/members?includeAllPublicFields=true').respond({}); fakeBackend.when('POST', '/api/v3/groups/party-id/quests/invite/' + key).respond({quest: { key: 'whale' } }); fakeBackend.flush(); })); diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index 78706c9608..b52a370409 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -30,6 +30,10 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', }); }; + $scope.isUserMemberOf = function (challenge) { + return User.user.challenges.indexOf(challenge._id) !== -1; + } + $scope.editTask = Tasks.editTask; /** @@ -124,21 +128,25 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', } if (isNew) { + var _challenge; Challenges.createChallenge(challenge) .then(function (response) { - var _challenge = response.data.data; + _challenge = response.data.data; Notification.text(window.env.t('challengeCreated')); User.sync(); + + var challengeTasks = []; + challengeTasks = challengeTasks.concat(challenge.todos); + challengeTasks = challengeTasks.concat(challenge.habits); + challengeTasks = challengeTasks.concat(challenge.dailys); + challengeTasks = challengeTasks.concat(challenge.rewards); + + return Tasks.createChallengeTasks(_challenge._id, challengeTasks); + }) + .then(function (response) { $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { reload: true, inherit: false, notify: true }); - - var challengeTasks = []; - challengeTasks.concat(challenge.todos); - challengeTasks.concat(challenge.habits); - challengeTasks.concat(challenge.dailys); - challengeTasks.concat(challenge.reqards); - Tasks.createChallengeTasks(_challenge._id, challengeTasks); }); } else { Challenges.updateChallenge(challenge._id, challenge) @@ -169,6 +177,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', challenge.winner = undefined; }; + //@TODO: change to $scope.remove $scope["delete"] = function(challenge) { var warningMsg; @@ -232,7 +241,8 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', //------------------------------------------------------------ $scope.addTask = function(addTo, listDef, challenge) { var task = Shared.taskDefaults({text: listDef.newTask, type: listDef.type}); - Tasks.createChallengeTasks(challenge._id, task); + //If the challenge has not been created, we bulk add tasks on save + if (challenge._id) Tasks.createChallengeTasks(challenge._id, task); if (!challenge[task.type + 's']) challenge[task.type + 's'] = []; challenge[task.type + 's'].unshift(task); delete listDef.newTask; diff --git a/website/client/js/controllers/chatCtrl.js b/website/client/js/controllers/chatCtrl.js index cbe42d9fbb..0e49b856bb 100644 --- a/website/client/js/controllers/chatCtrl.js +++ b/website/client/js/controllers/chatCtrl.js @@ -63,7 +63,7 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A } } - $scope.likeChatMessage = function(group,message) { + $scope.likeChatMessage = function(group, message) { if (message.uuid == User.user._id) return Notification.text(window.env.t('foreverAlone')); @@ -114,14 +114,13 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A }); }; - $scope.sync = function(group){ - if(group.type == 'party') { - group.$syncParty(); // Syncs the whole party, not just 15 members - } else { - group.$get(); - } - // When the user clicks fetch recent messages we need to update - // that the user has seen the new messages + $scope.sync = function(group) { + //@TODO: We need to use chat service here + Groups.Group.get(group._id) + .then(function (response) { + $scope.group = response.data.data; + }) + Chat.markChatSeen(group._id); } diff --git a/website/client/js/services/groupServices.js b/website/client/js/services/groupServices.js index 877a33a7d8..10bf0e9e5a 100644 --- a/website/client/js/services/groupServices.js +++ b/website/client/js/services/groupServices.js @@ -1,8 +1,8 @@ 'use strict'; angular.module('habitrpg') -.factory('Groups', [ '$location', '$rootScope', '$http', 'Analytics', 'ApiUrl', 'Challenges', '$q', 'User', - function($location, $rootScope, $http, Analytics, ApiUrl, Challenges, $q, User) { +.factory('Groups', [ '$location', '$rootScope', '$http', 'Analytics', 'ApiUrl', 'Challenges', '$q', 'User', 'Members', + function($location, $rootScope, $http, Analytics, ApiUrl, Challenges, $q, User, Members) { var data = {party: undefined, myGuilds: undefined, publicGuilds: undefined, tavern: undefined }; var groupApiURLPrefix = "/api/v3/groups"; @@ -117,7 +117,11 @@ angular.module('habitrpg') Group.get('party') .then(function (response) { data.party = response.data.data; - _cachedPartyPromise.resolve(data.party); + Members.getGroupMembers(data.party._id, true) + .then(function (response) { + data.party.members = response.data.data; + _cachedPartyPromise.resolve(data.party); + }); }, function (response) { data.party = { type: 'party' }; _cachedPartyPromise.reject(data.party); @@ -154,7 +158,7 @@ angular.module('habitrpg') var deferred = $q.defer(); if (!data.myGuilds) { - Group.getGroups('privateGuilds') + Group.getGroups('guilds') .then(function (response) { data.myGuilds = response.data.data; deferred.resolve(data.myGuilds); diff --git a/website/client/js/services/memberServices.js b/website/client/js/services/memberServices.js index 66dd4d2de5..a5d9d4eb83 100644 --- a/website/client/js/services/memberServices.js +++ b/website/client/js/services/memberServices.js @@ -15,10 +15,16 @@ angular.module('habitrpg') } //@TODO: Add paging - function getGroupMembers (groupId) { + function getGroupMembers (groupId, includeAllPublicFields) { + var url = apiV3Prefix + '/groups/' + groupId + '/members'; + + if (includeAllPublicFields) { + url += '?includeAllPublicFields=true'; + } + return $http({ method: 'GET', - url: apiV3Prefix + '/groups/' + groupId + '/members', + url: url, }); } diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 64adb50140..30fae1e5bb 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -91,7 +91,7 @@ angular.module('habitrpg') tasks.forEach(function (element, index, array) { user[element.type + 's'].push(element) }) - + save(); $rootScope.$emit('userSynced'); }); @@ -322,7 +322,7 @@ angular.module('habitrpg') hourglassPurchase: function (data) { var type = data.params.type; var key = data.params.key; - callOpsFunctionAndRequest('hourglassPurchase', 'purchase-hourglass', "POST", type + '/' + key, data); + callOpsFunctionAndRequest('purchaseHourglass', 'purchase-hourglass', "POST", type + '/' + key, data); }, unlock: function (data) { @@ -403,17 +403,10 @@ angular.module('habitrpg') settings.online = true; save(); sync().then(function () { - if (cb) { - cb(); - } + if (user.preferences.timezoneOffset !== offset) + userServices.set({'preferences.timezoneOffset': offset}); + if (cb) cb(); }); - //@TODO: Do we need the timezone set? - // userServices.log({}, function(){ - // // If they don't have timezone, set it - // if (user.preferences.timezoneOffset !== offset) - // userServices.set({'preferences.timezoneOffset': offset}); - // cb && cb(); - // }); } else { alert('Please enter your ID and Token in settings.') } diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index afd4507fcb..5f10b02570 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -96,7 +96,7 @@ api.getGroups = { if (validationErrors) throw validationErrors; let types = req.query.type.split(','); - let groupFields = basicGroupFields.concat('description memberCount balance'); + let groupFields = basicGroupFields.concat(' description memberCount balance'); let sort = '-memberCount'; let results = await Group.getGroups({user, types, groupFields, sort}); diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index 815b91f3e3..d38d9fb74b 100644 --- a/website/views/options/settings.jade +++ b/website/views/options/settings.jade @@ -229,7 +229,7 @@ script(type='text/ng-template', id='partials/options.settings.api.html') h6=env.t('userId') pre.prettyprint {{user.id}} h6=env.t('APIToken') - pre.prettyprint {{user.apiToken}} + pre.prettyprint {{User.settings.auth.apiToken}} h6=env.t('qrCode') img.img-rendering-auto(src='https://chart.googleapis.com/chart?cht=qr&chs=200x200&chl=%7B%22address%22%3A%22https%3A%2F%2Fhabitrpg.com%22%2C%22user%22%3A%22{{user.id}}%22%2C%22key%22%3A%22{{user.apiToken}}%22%7D&choe=UTF-8&chld=L', alt='qrcode') br @@ -405,4 +405,3 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') .col-xs-4 a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon})") img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments')) - diff --git a/website/views/options/social/challenges.jade b/website/views/options/social/challenges.jade index 3fd8dba61e..a5e2fa16cf 100644 --- a/website/views/options/social/challenges.jade +++ b/website/views/options/social/challenges.jade @@ -199,10 +199,10 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') p!=env.t('prizeValue', {gemcount: "{{challenge.prize}}", gemicon: ""}) li.bg-transparent // leave / join - a.btn.btn-sm.btn-danger(ng-show='challenge._isMember', ng-click='clickLeave(challenge, $event)') + a.btn.btn-sm.btn-danger(ng-show='::isUserMemberOf(challenge)', ng-click='clickLeave(challenge, $event)') span.glyphicon.glyphicon-ban-circle =env.t('leave') - a.btn.btn-sm.btn-success(ng-hide='challenge._isMember', ng-click='join(challenge)') + a.btn.btn-sm.btn-success(ng-hide='::isUserMemberOf(challenge)', ng-click='join(challenge)') span.glyphicon.glyphicon-ok =env.t('join') a.accordion-toggle(id="{{challenge._id}}" ng-click='toggle(challenge._id)') diff --git a/website/views/options/social/index.jade b/website/views/options/social/index.jade index f4c3a7043a..f0418c54bc 100644 --- a/website/views/options/social/index.jade +++ b/website/views/options/social/index.jade @@ -45,10 +45,10 @@ script(type='text/ng-template', id='partials/options.social.guilds.public.html') li='{{::group.memberCount}} ' + env.t('members') // join / leave li.bg-transparent - a.btn.btn-sm.btn-danger(ng-if="::group._isMember", ng-click='clickLeave(group, $event)') + a.btn.btn-sm.btn-danger(ng-if="::isMemberOfGroup(User.user._id, group)", ng-click='clickLeave(group, $event)') span.glyphicon.glyphicon-ban-circle =env.t('leave') - a.btn.btn-sm.btn-success(ng-if="::!group._isMember", ng-click='join(group)') + a.btn.btn-sm.btn-success(ng-if="::!isMemberOfGroup(User.user._id, group)", ng-click='join(group)') span.glyphicon.glyphicon-ok =env.t('join') h4: a(href='/#/options/groups/guilds/{{::group._id}}') {{::group.name}} From 0a8b8236c18ab1db094668096a5344a37b8df731 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 May 2016 22:05:00 -0500 Subject: [PATCH 807/976] fix: Correct checklist on client Closes #7207 --- common/script/ops/updateTask.js | 19 +++++++++---------- website/client/js/controllers/tasksCtrl.js | 15 +++++++++------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/common/script/ops/updateTask.js b/common/script/ops/updateTask.js index b6dd9f0f90..2a2b1edba2 100644 --- a/common/script/ops/updateTask.js +++ b/common/script/ops/updateTask.js @@ -2,25 +2,24 @@ import _ from 'lodash'; // From server pass task.toObject() not the task document directly module.exports = function updateTask (task, req = {}) { + let body = req.body || {}; + // If reminders are updated -> replace the original ones - if (req.body.reminders) { - task.reminders = req.body.reminders; - delete req.body.reminders; + if (body.reminders) { + task.reminders = body.reminders; } // If checklist is updated -> replace the original one - if (req.body.checklist) { - task.checklist = req.body.checklist; - delete req.body.checklist; + if (body.checklist) { + task.checklist = body.checklist; } // If tags are updated -> replace the original ones - if (req.body.tags) { - task.tags = req.body.tags; - delete req.body.tags; + if (body.tags) { + task.tags = body.tags; } - _.merge(task, _.omit(req.body, ['_id', 'id', 'type'])); + _.merge(task, _.omit(body, ['_id', 'id', 'type', 'reminders', 'checklist', 'tags'])); return [task]; }; diff --git a/website/client/js/controllers/tasksCtrl.js b/website/client/js/controllers/tasksCtrl.js index fa88ecd271..a2835256e2 100644 --- a/website/client/js/controllers/tasksCtrl.js +++ b/website/client/js/controllers/tasksCtrl.js @@ -100,8 +100,11 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N }; $scope.saveTask = function(task, stayOpen, isSaveAndClose) { - if (task.checklist) - task.checklist = _.filter(task.checklist,function(i){return !!i.text}); + if (task.checklist) { + task.checklist = _.filter(task.checklist, function (i) { + return !!i.text + }); + } User.updateTask(task, {body: task}); if (!stayOpen) task._editing = false; @@ -172,8 +175,8 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N if (!task.checklist[$index].text) { // Don't allow creation of an empty checklist item // TODO Provide UI feedback that this item is still blank - } else if ($index == task.checklist.length-1){ - User.updateTask({params:{id:task._id},body:task}); // don't preen the new empty item + } else if ($index == task.checklist.length - 1) { + Tasks.addChecklistItem(task._id, task.checklist[$index]); task.checklist.push({completed:false,text:''}); focusChecklist(task,task.checklist.length-1); } else { @@ -185,12 +188,12 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $scope.removeChecklistItem = function(task, $event, $index, force){ // Remove item if clicked on trash icon if (force) { - Tasks.removeChecklistItem(task._id, task.checklist[$index]._id); + Tasks.removeChecklistItem(task._id, task.checklist[$index].id); task.checklist.splice($index, 1); } else if (!task.checklist[$index].text) { // User deleted all the text and is now wishing to delete the item // saveTask will prune the empty item - Tasks.removeChecklistItem(task._id, task.checklist[$index]._id); + Tasks.removeChecklistItem(task._id, task.checklist[$index].id); // Move focus if the list is still non-empty if ($index > 0) focusChecklist(task, $index-1); From e19146d6be71f7a6aa23ebdc42e709f36669f8b3 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 May 2016 22:17:19 -0500 Subject: [PATCH 808/976] fix: Pin eslint to 2.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eb7e278837..9cac7fbc09 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "coveralls": "^2.11.2", "csv": "~0.3.6", "deep-diff": "~0.1.4", - "eslint": "^2.7.0", + "eslint": "2.9.0", "eslint-config-habitrpg": "^1.0.0", "eslint-plugin-babel": "^3.0.0", "eslint-plugin-mocha": "^2.1.0", From 6acaef50e6783051ff0fa06237ed076b1831de56 Mon Sep 17 00:00:00 2001 From: Alys Date: Sat, 14 May 2016 13:26:26 +1000 Subject: [PATCH 809/976] minor improvements to cron code for clarity; fix inaccurate comments; add TODOs for rest-in-inn actions --- common/script/ops/scoreTask.js | 4 +--- website/server/libs/api-v3/cron.js | 26 ++++++++++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/common/script/ops/scoreTask.js b/common/script/ops/scoreTask.js index bfc99344a9..0508462773 100644 --- a/common/script/ops/scoreTask.js +++ b/common/script/ops/scoreTask.js @@ -87,7 +87,6 @@ function _gainMP (user, val) { if (user.stats.mp >= user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; if (user.stats.mp < 0) { user.stats.mp = 0; - return user.stats.mp; } } @@ -181,10 +180,9 @@ module.exports = function scoreTask (options = {}, req = {}) { // the API consumer, then cleared afterwards user._tmp = {}; - // If they're trying to purhcase a too-expensive reward, don't allow them to do that. + // If they're trying to purchase a too-expensive reward, don't allow them to do that. if (task.value > user.stats.gp && task.type === 'reward') throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); - // ===== starting to actually do stuff, most of above was definitions ===== if (task.type === 'habit') { delta += _changeTaskValue(user, task, direction, times, cron); diff --git a/website/server/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js index 25fcf9bd98..a1768fe43d 100644 --- a/website/server/libs/api-v3/cron.js +++ b/website/server/libs/api-v3/cron.js @@ -37,8 +37,10 @@ function grantEndOfTheMonthPerks (user, now) { if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) } } +} - // If user cancelled subscription, we give them until 30day's end until it terminates +function removeTerminatedSubscription (user) { + // If subscription's termination date has arrived if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { _.merge(plan, { planId: null, @@ -71,17 +73,14 @@ function performSleepTasks (user, tasksByType, now) { }); } -// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) -// For incomplete Dailys, deduct experience -// Make sure to run this function once in a while as server will not take care of overnight calculations. -// And you have to run it every time client connects. +// Perform various beginning-of-day reset actions. export function cron (options = {}) { let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options; user.auth.timestamps.loggedin = now; user.lastCron = now; user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; - // Reset the lastDrop count to zero + // Allow user to get drops again if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; // "Perfect Day" achievement for perfect-days @@ -89,6 +88,7 @@ export function cron (options = {}) { if (user.isSubscribed()) { grantEndOfTheMonthPerks(user, now); + removeTerminatedSubscription(user); } // User is resting at the inn. @@ -105,7 +105,7 @@ export function cron (options = {}) { // Tally each task let todoTally = 0; - tasksByType.todos.forEach(task => { // make uncompleted todos redder + tasksByType.todos.forEach(task => { // make uncompleted To-Dos redder (further incentive to complete them) scoreTask({ task, user, @@ -117,8 +117,9 @@ export function cron (options = {}) { todoTally += task.value; }); + // For incomplete Dailys, add value (further incentive), deduct health, keep records for later decreasing the nightly mana gain let dailyChecked = 0; // how many dailies were checked? - let dailyDueUnchecked = 0; // how many dailies were cun-hecked? + let dailyDueUnchecked = 0; // how many dailies were un-checked? if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; tasksByType.dailys.forEach((task) => { @@ -186,6 +187,7 @@ export function cron (options = {}) { } }); +// move singleton Habits towards yellow. tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 if (task.up === false || task.down === false) { task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; @@ -206,8 +208,8 @@ export function cron (options = {}) { user.history.exp.push({date: now, value: expTally}); // preen user history so that it doesn't become a performance problem - // also for subscribed users but differentyly - // premium subscribers can keep their full history. + // also for subscribed users but differently + // TODO also do while resting in the inn. Note that later we'll be allowing the value/color of tasks to change while sleeping (https://github.com/HabitRPG/habitrpg/issues/5232), so the code in performSleepTasks() might be best merged back into here for that. Perhaps wait until then to do preen history for sleeping users. preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); if (perfect) { @@ -242,7 +244,7 @@ export function cron (options = {}) { _.merge(progress, {down: 0, up: 0}); progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); - // @TODO: Clean PMs - keep 200 for subscribers and 50 for free users + // @TODO: Clean PMs - keep 200 for subscribers and 50 for free users. Should also be done while resting in the inn // let numberOfPMs = Object.keys(user.inbox.messages).length; // if (numberOfPMs > maxPMs) { // _(user.inbox.messages) @@ -257,7 +259,7 @@ export function cron (options = {}) { // Analytics user.flags.cronCount++; - analytics.track('Cron', { + analytics.track('Cron', { // TODO also do while resting in the inn. https://github.com/HabitRPG/habitrpg/issues/7161#issuecomment-218214191 category: 'behavior', gaLabel: 'Cron Count', gaValue: user.flags.cronCount, From 4dd7c29bafd07faa9d4f079e76b865b81bc41a42 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 13 May 2016 22:49:05 -0500 Subject: [PATCH 810/976] fix: Add missing type param to equip call closes #7212 --- website/client/js/controllers/inventoryCtrl.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/client/js/controllers/inventoryCtrl.js b/website/client/js/controllers/inventoryCtrl.js index 761e708b37..41fa416abf 100644 --- a/website/client/js/controllers/inventoryCtrl.js +++ b/website/client/js/controllers/inventoryCtrl.js @@ -230,7 +230,7 @@ habitrpg.controller("InventoryCtrl", for (item in user.items.gear.equipped){ var itemKey = user.items.gear.equipped[item]; if (user.items.gear.owned[itemKey]) { - User.equip({params: {key: itemKey}}); + User.equip({params: {type: 'equipped', key: itemKey}}); } } break; From fd13c7aa605061da1ecefd1f47a439d4f47ca2fc Mon Sep 17 00:00:00 2001 From: Alys Date: Sat, 14 May 2016 16:40:40 +1000 Subject: [PATCH 811/976] rename and reword pubChalsMinPrize to reflect that it's only for Tavern challenges --- common/locales/en/api-v3.json | 2 +- test/api/v3/integration/challenges/POST-challenges.test.js | 2 +- website/server/controllers/api-v3/challenges.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 3832ba83cf..f4c490d26b 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -54,7 +54,7 @@ "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", "inviteMissingEmail": "Missing email address in invite.", "onlyGroupLeaderChal": "Only the group leader can create challenges", - "pubChalsMinPrize": "Prize must be at least 1 Gem for public challenges.", + "tavChalsMinPrize": "Prize must be at least 1 Gem for Tavern challenges.", "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount.", "challengeIdRequired": "\"challengeId\" must be a valid UUID.", "winnerIdRequired": "\"winnerId\" must be a valid UUID.", diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js index e296403f4e..283db9333e 100644 --- a/test/api/v3/integration/challenges/POST-challenges.test.js +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -37,7 +37,7 @@ describe('POST /challenges', () => { })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('pubChalsMinPrize'), + message: t('tavChalsMinPrize'), }); }); diff --git a/website/server/controllers/api-v3/challenges.js b/website/server/controllers/api-v3/challenges.js index 1e50a4d439..ca556f7541 100644 --- a/website/server/controllers/api-v3/challenges.js +++ b/website/server/controllers/api-v3/challenges.js @@ -52,7 +52,7 @@ api.createChallenge = { } if (group._id === TAVERN_ID && prize < 1) { - throw new NotAuthorized(res.t('pubChalsMinPrize')); + throw new NotAuthorized(res.t('tavChalsMinPrize')); } if (prize > 0) { From 2e078f47761ff068ff99fb658e30ae7fe1fa310c Mon Sep 17 00:00:00 2001 From: Alys Date: Sat, 14 May 2016 05:58:40 -0400 Subject: [PATCH 812/976] allows players to send gems to each other; other minor related changes - fixes https://github.com/HabitRPG/habitrpg/issues/7227 --- common/locales/en/api-v3.json | 5 +++-- test/api/v3/integration/members/POST-transfer_gems.test.js | 6 +++--- website/client/js/controllers/memberModalCtrl.js | 2 +- website/client/js/services/memberServices.js | 2 +- website/server/controllers/api-v3/members.js | 4 +++- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 3832ba83cf..1f943e53f4 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -124,13 +124,14 @@ "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'.", "cannotDeleteActiveAccount": "You have an active subscription, cancel your plan before deleting your account.", "messageRequired": "A message is required.", - "toUserIDRequired": "A toUserId is required", + "toUserIDRequired": "A User ID is required", + "gemAmountRequired": "A number of gems is required", "notAuthorizedToSendMessageToThisUser": "Can't send message to this user.", "privateMessageGiftIntro": "Hello <%= receiverName %>, <%= senderName %> has sent you ", "privateMessageGiftGemsMessage": "<%= gemAmount %> gems! ", "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", - "notEnoughGemsToSend": "Amount must be within 0 and your current number of gems.", + "badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.", "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>.", "typeRequired": "Type is required", "keyRequired": "Key is required", diff --git a/test/api/v3/integration/members/POST-transfer_gems.test.js b/test/api/v3/integration/members/POST-transfer_gems.test.js index 384b78baf4..d5899ae33c 100644 --- a/test/api/v3/integration/members/POST-transfer_gems.test.js +++ b/test/api/v3/integration/members/POST-transfer_gems.test.js @@ -79,7 +79,7 @@ describe('POST /members/transfer-gems', () => { })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('notEnoughGemsToSend'), + message: t('badAmountOfGemsToSend'), }); }); @@ -92,7 +92,7 @@ describe('POST /members/transfer-gems', () => { })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('notEnoughGemsToSend'), + message: t('badAmountOfGemsToSend'), }); }); @@ -105,7 +105,7 @@ describe('POST /members/transfer-gems', () => { })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('notEnoughGemsToSend'), + message: t('badAmountOfGemsToSend'), }); }); diff --git a/website/client/js/controllers/memberModalCtrl.js b/website/client/js/controllers/memberModalCtrl.js index ed501a5574..b5d75cc8ad 100644 --- a/website/client/js/controllers/memberModalCtrl.js +++ b/website/client/js/controllers/memberModalCtrl.js @@ -39,7 +39,7 @@ habitrpg }; $scope.sendGift = function (uuid, gift) { - Members.transferGems(message, uuid, $scope.gift.gems.amount) + Members.transferGems($scope.gift.message, uuid, $scope.gift.gems.amount) .then(function (response) { Notification.text('Gift sent!') $rootScope.User.sync(); diff --git a/website/client/js/services/memberServices.js b/website/client/js/services/memberServices.js index a5d9d4eb83..039743cea5 100644 --- a/website/client/js/services/memberServices.js +++ b/website/client/js/services/memberServices.js @@ -63,7 +63,7 @@ angular.module('habitrpg') function transferGems (message, toUserId, gemAmount) { return $http({ method: 'POST', - url: apiV3Prefix + '/members/send-private-message', + url: apiV3Prefix + '/members/transfer-gems', data: { message: message, toUserId: toUserId, diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index e1bc0bf60a..d535c49716 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -297,6 +297,7 @@ api.sendPrivateMessage = { * * @apiParam {String} message Body parameter The message * @apiParam {UUID} toUserId Body parameter The toUser _id + * @apiParam {Integer} gemAmount Body parameter The number of gems to send * * @apiSuccess {Object} data An empty Object */ @@ -307,6 +308,7 @@ api.transferGems = { async handler (req, res) { req.checkBody('message', res.t('messageRequired')).notEmpty(); req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID(); + req.checkBody('gemAmount', res.t('gemAmountRequired')).notEmpty().isInt(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -324,7 +326,7 @@ api.transferGems = { let amount = gemAmount / 4; if (!amount || amount <= 0 || sender.balance < amount) { - throw new NotAuthorized(res.t('notEnoughGemsToSend')); + throw new NotAuthorized(res.t('badAmountOfGemsToSend')); } receiver.balance += amount; From 44e9d8b09e3c54c454b165f84495463e435806e6 Mon Sep 17 00:00:00 2001 From: Alys Date: Sat, 14 May 2016 07:18:00 -0400 Subject: [PATCH 813/976] fix tests for /members/transfer-gems --- .../members/POST-transfer_gems.test.js | 41 +++++++++++-------- website/server/controllers/api-v3/members.js | 2 +- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/test/api/v3/integration/members/POST-transfer_gems.test.js b/test/api/v3/integration/members/POST-transfer_gems.test.js index d5899ae33c..5feee7ddd2 100644 --- a/test/api/v3/integration/members/POST-transfer_gems.test.js +++ b/test/api/v3/integration/members/POST-transfer_gems.test.js @@ -9,14 +9,13 @@ describe('POST /members/transfer-gems', () => { let receiver; let message = 'Test Private Message'; let gemAmount = 20; - let giftType = 'gems'; beforeEach(async () => { userToSendMessage = await generateUser({balance: 5}); receiver = await generateUser(); }); - it('returns error when giftType is not provided', async () => { + it('returns error when no parameters are provided', async () => { await expect(userToSendMessage.post('/members/transfer-gems')) .to.eventually.be.rejected.and.eql({ code: 400, @@ -26,10 +25,10 @@ describe('POST /members/transfer-gems', () => { }); it('returns error when message is not provided', async () => { - await expect(userToSendMessage.post('/members/transfer-gems'), { - giftType, - message, - }).to.eventually.be.rejected.and.eql({ + await expect(userToSendMessage.post('/members/transfer-gems', { + gemAmount, + toUserId: receiver._id, + })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: 'Invalid request parameters.', @@ -38,8 +37,8 @@ describe('POST /members/transfer-gems', () => { it('returns error when toUserId is not provided', async () => { await expect(userToSendMessage.post('/members/transfer-gems', { - giftType, message, + gemAmount, })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -49,8 +48,8 @@ describe('POST /members/transfer-gems', () => { it('returns error when to user is not found', async () => { await expect(userToSendMessage.post('/members/transfer-gems', { - giftType, message, + gemAmount, toUserId: generateUUID(), })).to.eventually.be.rejected.and.eql({ code: 404, @@ -61,8 +60,8 @@ describe('POST /members/transfer-gems', () => { it('returns error when to user attempts to send gems to themselves', async () => { await expect(userToSendMessage.post('/members/transfer-gems', { - giftType, message, + gemAmount, toUserId: userToSendMessage._id, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -71,21 +70,31 @@ describe('POST /members/transfer-gems', () => { }); }); - it('returns error when there is no amount', async () => { + it('returns error when there is no gemAmount', async () => { await expect(userToSendMessage.post('/members/transfer-gems', { - giftType, message, toUserId: receiver._id, })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('badAmountOfGemsToSend'), + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns error when gemAmount is not an integer', async () => { + await expect(userToSendMessage.post('/members/transfer-gems', { + message, + gemAmount: 1.5, + toUserId: receiver._id, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', }); }); it('returns error when gemAmount is negative', async () => { await expect(userToSendMessage.post('/members/transfer-gems', { - giftType, message, gemAmount: -5, toUserId: receiver._id, @@ -98,7 +107,6 @@ describe('POST /members/transfer-gems', () => { it('returns error when gemAmount is more than the sender\'s balance', async () => { await expect(userToSendMessage.post('/members/transfer-gems', { - giftType, message, gemAmount: gemAmount + 4, toUserId: receiver._id, @@ -111,7 +119,6 @@ describe('POST /members/transfer-gems', () => { it('sends a private message about gems to a user', async () => { await userToSendMessage.post('/members/transfer-gems', { - giftType, message, gemAmount, toUserId: receiver._id, diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index d535c49716..6078233161 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -325,7 +325,7 @@ api.transferGems = { let gemAmount = req.body.gemAmount; let amount = gemAmount / 4; - if (!amount || amount <= 0 || sender.balance < amount) { + if (amount <= 0 || sender.balance < amount) { throw new NotAuthorized(res.t('badAmountOfGemsToSend')); } From 3e57620666b4c1d8e72434342606d085e82ce834 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 06:54:44 -0500 Subject: [PATCH 814/976] fix: Set gems sent notification as translatable string --- common/locales/en/settings.json | 1 + website/client/js/controllers/memberModalCtrl.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/common/locales/en/settings.json b/common/locales/en/settings.json index ac2c049fe1..3b7389b03d 100644 --- a/common/locales/en/settings.json +++ b/common/locales/en/settings.json @@ -104,6 +104,7 @@ "emailNotifications": "Email Notifications", "wonChallenge": "You won a Challenge!", "newPM": "Received Private Message", + "sentGems": "Sent gems!", "giftedGems": "Gifted Gems", "giftedGemsInfo": "<%= amount %> Gems - by <%= name %>", "giftedSubscription": "Gifted Subscription", diff --git a/website/client/js/controllers/memberModalCtrl.js b/website/client/js/controllers/memberModalCtrl.js index b5d75cc8ad..68259022c1 100644 --- a/website/client/js/controllers/memberModalCtrl.js +++ b/website/client/js/controllers/memberModalCtrl.js @@ -41,7 +41,7 @@ habitrpg $scope.sendGift = function (uuid, gift) { Members.transferGems($scope.gift.message, uuid, $scope.gift.gems.amount) .then(function (response) { - Notification.text('Gift sent!') + Notification.text(window.env.t('sentGems')); $rootScope.User.sync(); $scope.$close(); }); From 25f0819f1e4181bcaa6a47661240d4a07ccf900a Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 06:59:11 -0500 Subject: [PATCH 815/976] chore: Remove unusued variable --- website/client/js/controllers/memberModalCtrl.js | 8 ++++---- website/views/shared/modals/members.jade | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/website/client/js/controllers/memberModalCtrl.js b/website/client/js/controllers/memberModalCtrl.js index 68259022c1..3077562c40 100644 --- a/website/client/js/controllers/memberModalCtrl.js +++ b/website/client/js/controllers/memberModalCtrl.js @@ -33,12 +33,12 @@ habitrpg //@TODO: We don't send subscriptions so the structure has changed in the back. Update this when we update the views. $scope.gift = { type: 'gems', - gems: {amount:0, fromBalance:true}, - subscription: {key:''}, - message:'' + gems: {amount: 0, fromBalance: true}, + subscription: {key: ''}, + message: '' }; - $scope.sendGift = function (uuid, gift) { + $scope.sendGift = function (uuid) { Members.transferGems($scope.gift.message, uuid, $scope.gift.gems.amount) .then(function (response) { Notification.text(window.env.t('sentGems')); diff --git a/website/views/shared/modals/members.jade b/website/views/shared/modals/members.jade index 8759b8aad6..b23fd42596 100644 --- a/website/views/shared/modals/members.jade +++ b/website/views/shared/modals/members.jade @@ -94,7 +94,7 @@ script(type='text/ng-template', id='modals/send-gift.html') .modal-footer - var fromBal = "gift.type=='gems' && gift.gems.fromBalance" - button.btn.btn-primary(ng-show=fromBal, ng-click='sendGift(profile._id, gift)')=env.t("send") + button.btn.btn-primary(ng-show=fromBal, ng-click='sendGift(profile._id)')=env.t("send") a.btn.btn-primary(ng-hide=fromBal, ng-click='Payments.showStripe({gift:gift, uuid:profile._id})')=env.t('card') a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?_id={{::user._id}}&apiToken={{::user.apiToken}}&gift={{Payments.encodeGift(profile._id, gift)}}') PayPal .btn.btn-success(ng-hide=fromBal, ng-click="Payments.amazonPayments.init({type: 'single', gift: gift, giftedTo: profile._id})") Amazon Payments From 9969aa667a172a4e77aaa784ede0510c8e88da49 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 07:05:59 -0500 Subject: [PATCH 816/976] fix: Remove requirement on message paramter in transfer-gems --- .../members/POST-transfer_gems.test.js | 43 ++++++++++++++----- website/server/controllers/api-v3/members.js | 6 ++- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/test/api/v3/integration/members/POST-transfer_gems.test.js b/test/api/v3/integration/members/POST-transfer_gems.test.js index 5feee7ddd2..96644a3e88 100644 --- a/test/api/v3/integration/members/POST-transfer_gems.test.js +++ b/test/api/v3/integration/members/POST-transfer_gems.test.js @@ -24,17 +24,6 @@ describe('POST /members/transfer-gems', () => { }); }); - it('returns error when message is not provided', async () => { - await expect(userToSendMessage.post('/members/transfer-gems', { - gemAmount, - toUserId: receiver._id, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Invalid request parameters.', - }); - }); - it('returns error when toUserId is not provided', async () => { await expect(userToSendMessage.post('/members/transfer-gems', { message, @@ -150,4 +139,36 @@ describe('POST /members/transfer-gems', () => { expect(sendersMessageInSendersInbox.text).to.equal(messageSentContent); expect(updatedSender.balance).to.equal(0); }); + + it('does not requrie a message', async () => { + await userToSendMessage.post('/members/transfer-gems', { + gemAmount, + toUserId: receiver._id, + }); + + let updatedReceiver = await receiver.get('/user'); + let updatedSender = await userToSendMessage.get('/user'); + + let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (inboxMessage) => { + return inboxMessage.uuid === userToSendMessage._id; + }); + + let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (inboxMessage) => { + return inboxMessage.uuid === receiver._id; + }); + + let messageSentContent = t('privateMessageGiftIntro', { + receiverName: receiver.profile.name, + senderName: userToSendMessage.profile.name, + }); + messageSentContent += t('privateMessageGiftGemsMessage', {gemAmount}); + + expect(sendersMessageInReceiversInbox).to.exist; + expect(sendersMessageInReceiversInbox.text).to.equal(messageSentContent); + expect(updatedReceiver.balance).to.equal(gemAmount / 4); + + expect(sendersMessageInSendersInbox).to.exist; + expect(sendersMessageInSendersInbox.text).to.equal(messageSentContent); + expect(updatedSender.balance).to.equal(0); + }); }); diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index 6078233161..3d6334967d 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -306,7 +306,6 @@ api.transferGems = { url: '/members/transfer-gems', middlewares: [authWithHeaders()], async handler (req, res) { - req.checkBody('message', res.t('messageRequired')).notEmpty(); req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID(); req.checkBody('gemAmount', res.t('gemAmountRequired')).notEmpty().isInt(); @@ -339,7 +338,10 @@ api.transferGems = { senderName: sender.profile.name, }); message += res.t('privateMessageGiftGemsMessage', {gemAmount}); - message += req.body.message; + + if (req.body.message) { + message += req.body.message; + } await sender.sendMessage(receiver, message); From 210d01ddae682bcf94baa1b088dffdf8b88e9ae9 Mon Sep 17 00:00:00 2001 From: Alys Date: Sat, 14 May 2016 23:01:45 +1000 Subject: [PATCH 817/976] add a missing variable declaration --- website/server/libs/api-v3/cron.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/server/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js index a1768fe43d..d3c09526f7 100644 --- a/website/server/libs/api-v3/cron.js +++ b/website/server/libs/api-v3/cron.js @@ -41,6 +41,8 @@ function grantEndOfTheMonthPerks (user, now) { function removeTerminatedSubscription (user) { // If subscription's termination date has arrived + let plan = user.purchased.plan; + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { _.merge(plan, { planId: null, From 56dccf016edd83a94793d54ebd6b75c587508796 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 09:20:57 -0500 Subject: [PATCH 818/976] chore: clarify comments on cron code --- website/server/libs/api-v3/cron.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/server/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js index d3c09526f7..1fd9466786 100644 --- a/website/server/libs/api-v3/cron.js +++ b/website/server/libs/api-v3/cron.js @@ -82,7 +82,7 @@ export function cron (options = {}) { user.auth.timestamps.loggedin = now; user.lastCron = now; user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; - // Allow user to get drops again + // User is only allowed a certain number of drops a day. This resets the count. if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; // "Perfect Day" achievement for perfect-days @@ -189,7 +189,7 @@ export function cron (options = {}) { } }); -// move singleton Habits towards yellow. + // move singleton Habits towards yellow. tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 if (task.up === false || task.down === false) { task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; From a697d028b7e999c5e29ad66158ff7d1cffddd09d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 09:37:21 -0500 Subject: [PATCH 819/976] fix: Correct client request from habitrpg -> tavern --- website/client/js/controllers/challengesCtrl.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index b52a370409..875331faac 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -11,7 +11,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', // FIXME $scope.challenges needs to be resolved first (see app.js) $scope.groups = []; - Groups.Group.getGroups('party,publicGuilds,privateGuilds,habitrpg') + Groups.Group.getGroups('party,publicGuilds,privateGuilds,tavern') .then(function (response) { $scope.groups = response.data.data; }); From e4b17d2d044564b1d949ac9313ae251324eece07 Mon Sep 17 00:00:00 2001 From: Alys Date: Sat, 14 May 2016 01:34:46 -0400 Subject: [PATCH 820/976] update apidoc URL in package.json Closes #7222 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9cac7fbc09..58cd5362e5 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,6 @@ "name": "habitica", "title": "Habitica", "version": "3.0.0", - "url": "https://habitica-v3.herokuapp.com/api-v3" + "url": "https://v3.habitica.com" } } From 439d20c82e39ae21cc543ff2aa866ae36dd4efce Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 14 May 2016 09:23:07 -0500 Subject: [PATCH 821/976] Fixed start party by invites --- website/client/js/controllers/inviteToGroupCtrl.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/website/client/js/controllers/inviteToGroupCtrl.js b/website/client/js/controllers/inviteToGroupCtrl.js index 5d4ea647ee..56a602b726 100644 --- a/website/client/js/controllers/inviteToGroupCtrl.js +++ b/website/client/js/controllers/inviteToGroupCtrl.js @@ -1,7 +1,7 @@ 'use strict'; -habitrpg.controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedGroup', '$http', 'Notification', - function($scope, User, Groups, injectedGroup, $http, Notification) { +habitrpg.controller('InviteToGroupCtrl', ['$scope', '$rootScope', 'User', 'Groups', 'injectedGroup', '$http', 'Notification', + function($scope, $rootScope, User, Groups, injectedGroup, $http, Notification) { $scope.group = injectedGroup; $scope.inviter = User.user.profile.name; @@ -22,6 +22,8 @@ habitrpg.controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedG return Groups.Group.create($scope.group) .then(function(response) { $scope.group = response.data.data; + User.sync(); + Groups.data.party = $scope.group; _inviteByMethod(inviteMethod); }); } @@ -46,6 +48,7 @@ habitrpg.controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedG .then(function() { Notification.text(window.env.t('invitationsSent')); _resetInvitees(); + $rootScope.hardRedirect('/#/options/groups/party'); }, function(){ _resetInvitees(); }); From 6222c23810deda5e4fac54656b658f001f67c900 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 14 May 2016 09:31:08 -0500 Subject: [PATCH 822/976] Updated spell casting to v3 --- website/client/js/controllers/rootCtrl.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index bc91436e2f..746cd0f3df 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -293,11 +293,11 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ User.save(); var spell = $scope.spell; - var targetId = (type == 'party' || type == 'self') ? '' : type == 'task' ? target.id : target._id; + var targetId = target._id; $scope.spell = null; $rootScope.applyingAction = false; - $http.post(ApiUrl.get() + '/api/v2/user/class/cast/'+spell.key+'?targetType='+type+'&targetId='+targetId) + $http.post(ApiUrl.get() + '/api/v3/user/class/cast/'+spell.key+'?targetType='+type+'&targetId='+targetId) .success(function(){ var msg = window.env.t('youCast', {spell: spell.text()}); switch (type) { From 409dfbb2b0c7a7f1ec95a6f46a400f565bf24382 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 14 May 2016 10:00:10 -0500 Subject: [PATCH 823/976] Fixed adding and removing tags on tasks --- website/client/js/controllers/filtersCtrl.js | 1 + website/client/js/controllers/tasksCtrl.js | 21 +++++++++++++++++--- website/views/shared/tasks/edit/tags.jade | 2 +- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/website/client/js/controllers/filtersCtrl.js b/website/client/js/controllers/filtersCtrl.js index 8373faa51c..4a3ed6e59c 100644 --- a/website/client/js/controllers/filtersCtrl.js +++ b/website/client/js/controllers/filtersCtrl.js @@ -30,6 +30,7 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared', } else { user.filters[tag.id] = !user.filters[tag.id]; } + // no longer persisting this, it was causing a lot of confusion - users thought they'd permanently lost tasks // Note: if we want to persist for just this computer, easy method is: // User.save(); diff --git a/website/client/js/controllers/tasksCtrl.js b/website/client/js/controllers/tasksCtrl.js index a2835256e2..69d8949c74 100644 --- a/website/client/js/controllers/tasksCtrl.js +++ b/website/client/js/controllers/tasksCtrl.js @@ -33,9 +33,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N var newTask = { text: task, type: listDef.type, - // tags: _.transform(User.user.filters, function(m, v, k) { - // if (v) m.push(v); - // }), + tags: _.keys(User.user.filters), }; User.addTask({body: newTask}); @@ -272,4 +270,21 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $rootScope.playSound('Reward'); } } + + /* + ------------------------ + Tags + ------------------------ + */ + + $scope.updateTaskTags = function (tagId, task) { + var tagIndex = task.tags.indexOf(tagId); + if (tagIndex === -1) { + Tasks.addTagToTask(task._id, tagId); + task.tags.push(tagId); + } else { + Tasks.removeTagFromTask(task._id, tagId); + task.tags.splice(tagIndex, 0); + } + } }]); diff --git a/website/views/shared/tasks/edit/tags.jade b/website/views/shared/tasks/edit/tags.jade index 0e9283fdb2..8abcedc355 100644 --- a/website/views/shared/tasks/edit/tags.jade +++ b/website/views/shared/tasks/edit/tags.jade @@ -1,5 +1,5 @@ fieldset.option-group(ng-if='!$state.includes("options.social.challenges")') p.option-title.mega(ng-class='{active: task._tags}', ng-click='task._tags = !task._tags', tooltip=env.t('expandCollapse'))=env.t('tags') label.checkbox(ng-repeat='tag in user.tags', ng-if='task._tags') - input(type='checkbox', ng-model='task.tags[tag.id]', ng-checked="task.tags.indexOf(tag.id) !== -1") + input(type='checkbox', ng-checked="task.tags.indexOf(tag.id) !== -1", ng-click="updateTaskTags(tag.id, task)") markdown(text='tag.name') From 936ff1f2006a8dc5cbb28e5eb4588f8642f61afe Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 14 May 2016 10:10:30 -0500 Subject: [PATCH 824/976] Fixed page reload on settings change --- website/client/js/services/userServices.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 30fae1e5bb..5f4b115e37 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -335,7 +335,11 @@ angular.module('habitrpg') method: "PUT", url: '/api/v3/user', data: updates, - }); + }) + .then(function () { + save(); + $rootScope.$emit('userSynced'); + }) }, reroll: function () { From 0537ff3552f9e237b32f76ae9c7697e21bcdd269 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 14 May 2016 10:23:49 -0500 Subject: [PATCH 825/976] Fixed battle monsters with friends button --- website/views/shared/header/header.jade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/views/shared/header/header.jade b/website/views/shared/header/header.jade index db91442927..6535bb217f 100644 --- a/website/views/shared/header/header.jade +++ b/website/views/shared/header/header.jade @@ -31,7 +31,7 @@ | {{Math.floor(user.stats.mp)}} / {{user.fns.statsComputed().maxMP}} // party .party(ng-controller='PartyCtrl') - button.party-invite.btn.btn-primary(ng-click="inviteOrStartParty(group)", + button.party-invite.btn.btn-primary(ng-click="inviteOrStartParty(party)", ng-if="(!party.members || party.memberCount === 1) && user.preferences.displayInviteToPartyWhenPartyIs1", popover="{{!party.members ? env.t('startAParty') : env.t('addToParty')}}", popover-placement="left", popover-trigger="mouseenter") span=env.t("battleWithFriends") From 72c1729d544380a647f3ec4465e06cf0a94bfd52 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 14 May 2016 10:43:50 -0500 Subject: [PATCH 826/976] Loaded completed todos when done is clicked --- website/client/js/controllers/tasksCtrl.js | 7 +++++++ website/client/js/services/taskServices.js | 8 ++++++-- website/views/shared/tasks/task_view/mixins.jade | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/website/client/js/controllers/tasksCtrl.js b/website/client/js/controllers/tasksCtrl.js index 69d8949c74..bf4c0714fe 100644 --- a/website/client/js/controllers/tasksCtrl.js +++ b/website/client/js/controllers/tasksCtrl.js @@ -140,6 +140,13 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N */ $scope._today = moment().add({days: 1}); + $scope.loadedCompletedTodos = function () { + Tasks.getUserTasks(true) + .then(function (response) { + User.user.todos.concat(response.data.data); + }); + } + /* ------------------------ Dailies diff --git a/website/client/js/services/taskServices.js b/website/client/js/services/taskServices.js index a2538d42a6..f0d5eec305 100644 --- a/website/client/js/services/taskServices.js +++ b/website/client/js/services/taskServices.js @@ -6,10 +6,14 @@ angular.module('habitrpg') .factory('Tasks', ['$rootScope', 'Shared', '$http', function tasksFactory($rootScope, Shared, $http) { - function getUserTasks () { + function getUserTasks (getCompletedTodos) { + var url = '/api/v3/tasks/user'; + + if (getCompletedTodos) url += '?type=completedTodos'; + return $http({ method: 'GET', - url: '/api/v3/tasks/user', + url: url, }); }; diff --git a/website/views/shared/tasks/task_view/mixins.jade b/website/views/shared/tasks/task_view/mixins.jade index 7fdbe66f53..2c71074d71 100644 --- a/website/views/shared/tasks/task_view/mixins.jade +++ b/website/views/shared/tasks/task_view/mixins.jade @@ -32,7 +32,7 @@ mixin taskColumnTabs(position) li(ng-class='{active: list.view == "dated"}') a(ng-click='list.view = "dated"')=env.t('dated') li(ng-class='{active: list.view == "complete"}') - a(ng-click='list.view = "complete"')=env.t('complete') + a(ng-click='list.view = "complete";loadedCompletedTodos()')=env.t('complete') // Rewards Tabs div(ng-if='::main && list.type=="reward"', class='tabbable tabs-below') ul.task-filter From 2487ebac90a522377a0ac95fa19792708374daa5 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 11:59:00 -0500 Subject: [PATCH 827/976] chore: Reinstate floating version number for eslint babel-eslint regression fixed --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 58cd5362e5..5edeadd75f 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "coveralls": "^2.11.2", "csv": "~0.3.6", "deep-diff": "~0.1.4", - "eslint": "2.9.0", + "eslint": "^2.10.1", "eslint-config-habitrpg": "^1.0.0", "eslint-plugin-babel": "^3.0.0", "eslint-plugin-mocha": "^2.1.0", From 2d5bf9b1bc2ef3bbc69067e25c997b088298336c Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 14 May 2016 11:47:01 -0500 Subject: [PATCH 828/976] Fixed reload tests --- test/spec/controllers/inviteToGroupCtrlSpec.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test/spec/controllers/inviteToGroupCtrlSpec.js b/test/spec/controllers/inviteToGroupCtrlSpec.js index 39f3f3d016..5c316855e9 100644 --- a/test/spec/controllers/inviteToGroupCtrlSpec.js +++ b/test/spec/controllers/inviteToGroupCtrlSpec.js @@ -1,7 +1,7 @@ 'use strict'; describe('Invite to Group Controller', function() { - var scope, ctrl, groups, user, guild, $rootScope; + var scope, ctrl, groups, user, guild, rootScope, $controller; beforeEach(function() { user = specHelper.newUser({ @@ -13,8 +13,12 @@ describe('Invite to Group Controller', function() { $provide.value('injectedGroup', { user: user }); }); - inject(function($rootScope, $controller, Groups){ - scope = $rootScope.$new(); + inject(function(_$rootScope_, _$controller_, Groups) { + rootScope = _$rootScope_; + + scope = _$rootScope_.$new(); + + $controller = _$controller_; // Load RootCtrl to ensure shared behaviors are loaded $controller('RootCtrl', {$scope: scope, User: {user: user}}); @@ -93,6 +97,10 @@ describe('Invite to Group Controller', function() { }); context('email', function() { + beforeEach(function () { + sandbox.stub(rootScope, 'hardRedirect'); + }); + it('invites user with emails', function(done) { scope.emails = [ {name: 'Luigi', email: 'mario_bro@themushroomkingdom.com'}, @@ -166,6 +174,10 @@ describe('Invite to Group Controller', function() { }); context('uuid', function() { + beforeEach(function () { + sandbox.stub(rootScope, 'hardRedirect'); + }); + it('invites user with uuid', function(done) { scope.invitees = [{uuid: '1234'}]; From 887d187691eea3fa90751049fe501188ee008c6a Mon Sep 17 00:00:00 2001 From: Alys Date: Sun, 15 May 2016 09:10:08 +1000 Subject: [PATCH 829/976] change "an user" to "a user" in comments and text (no code changes) (#7257) --- .../integration/groups/POST-groups_invite.test.js | 2 +- .../POST-groups_groupid_quests_reject.test.js | 4 ++-- website/server/controllers/api-v3/auth.js | 2 +- website/server/controllers/api-v3/quests.js | 2 +- website/server/controllers/api-v3/tags.js | 2 +- website/server/controllers/api-v3/tasks.js | 14 +++++++------- website/server/models/user.js | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index cf53782c8d..92328463b2 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -316,7 +316,7 @@ describe('Post /groups/:groupId/invite', () => { }); }); - it('allow inviting an user to a party if he\'s partying solo', async () => { + it('allow inviting a user to a party if he\'s partying solo', async () => { let userToInvite = await generateUser(); await userToInvite.post('/groups', { // add user to a party name: 'Another Test Party', diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index 1eb62aa0c6..2dcddfe727 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -70,7 +70,7 @@ describe('POST /groups/:groupId/quests/reject', () => { }); }); - it('return an error when an user rejects an invite twice', async () => { + it('return an error when a user rejects an invite twice', async () => { await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); @@ -82,7 +82,7 @@ describe('POST /groups/:groupId/quests/reject', () => { }); }); - it('return an error when an user rejects an invite already accepted', async () => { + it('return an error when a user rejects an invite already accepted', async () => { await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); diff --git a/website/server/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js index 1a5f6f36ae..f501ba8c50 100644 --- a/website/server/controllers/api-v3/auth.js +++ b/website/server/controllers/api-v3/auth.js @@ -168,7 +168,7 @@ function _loginRes (user, req, res) { /** * @api {post} /api/v3/user/auth/local/login Login - * @apiDescription Login an user with email / username and password + * @apiDescription Login a user with email / username and password * @apiVersion 3.0.0 * @apiName UserLoginLocal * @apiGroup User diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js index 0e639def36..3443f9d67d 100644 --- a/website/server/controllers/api-v3/quests.js +++ b/website/server/controllers/api-v3/quests.js @@ -177,7 +177,7 @@ api.acceptQuest = { res.respond(200, savedGroup.quest); - // track that an user has accepted the quest + // track that a user has accepted the quest analytics.track('quest', { category: 'behavior', owner: false, diff --git a/website/server/controllers/api-v3/tags.js b/website/server/controllers/api-v3/tags.js index 8d452154cd..69117e6fd7 100644 --- a/website/server/controllers/api-v3/tags.js +++ b/website/server/controllers/api-v3/tags.js @@ -34,7 +34,7 @@ api.createTag = { }; /** - * @api {get} /api/v3/tag Get an user's tags + * @api {get} /api/v3/tag Get a user's tags * @apiVersion 3.0.0 * @apiName GetTags * @apiGroup Tag diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index a6b2405539..0abe8f051b 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -166,7 +166,7 @@ async function _getTasks (req, res, user, challenge) { } /** - * @api {get} /api/v3/tasks/user Get an user's tasks + * @api {get} /api/v3/tasks/user Get a user's tasks * @apiVersion 3.0.0 * @apiName GetUserTasks * @apiGroup Task @@ -259,7 +259,7 @@ api.getTask = { if (!challenge || (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens throw new NotFound(res.t('taskNotFound')); } - } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one throw new NotFound(res.t('taskNotFound')); } @@ -300,7 +300,7 @@ api.updateTask = { challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one throw new NotFound(res.t('taskNotFound')); } @@ -517,7 +517,7 @@ api.addChecklistItem = { challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one throw new NotFound(res.t('taskNotFound')); } @@ -610,7 +610,7 @@ api.updateChecklistItem = { challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one throw new NotFound(res.t('taskNotFound')); } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); @@ -663,7 +663,7 @@ api.removeChecklistItem = { challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one throw new NotFound(res.t('taskNotFound')); } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); @@ -874,7 +874,7 @@ api.deleteTask = { challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one throw new NotFound(res.t('taskNotFound')); } else if (task.userId && task.challenge.id && !task.challenge.broken) { throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); diff --git a/website/server/models/user.js b/website/server/models/user.js index f3c22d3709..9cce45fc55 100644 --- a/website/server/models/user.js +++ b/website/server/models/user.js @@ -728,7 +728,7 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, m // Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model) // These will be removed once API v2 is discontinued -// Get all the tasks belonging to an user, +// Get all the tasks belonging to a user, schema.methods.getTasks = function getUserTasks () { let args = Array.from(arguments); let cb; From 44102f6b46f1290c50a57844a2b324598467ab0c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 18:45:36 -0500 Subject: [PATCH 830/976] fix: Alert user that drops were recieved --- website/client/js/services/userServices.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 5f4b115e37..8405b87947 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -178,7 +178,13 @@ angular.module('habitrpg') score: function (data) { $window.habitrpgShared.ops.scoreTask({user: user, task: data.params.task, direction: data.params.direction}, data.params); save(); - Tasks.scoreTask(data.params.task._id, data.params.direction); + Tasks.scoreTask(data.params.task._id, data.params.direction).then(function (res) { + var drop = res.data.data._tmp.drop; + + if (drop) { + user._tmp.drop = drop; + } + }); }, sortTask: function (data) { From d28860620b67929f290a08f277ca2533d5279212 Mon Sep 17 00:00:00 2001 From: Alys Date: Sun, 15 May 2016 13:21:08 +1000 Subject: [PATCH 831/976] remove userServices.js from karma.conf - it's been moved to website/client/js/services --- karma.conf.js | 1 - 1 file changed, 1 deletion(-) diff --git a/karma.conf.js b/karma.conf.js index 3d8344a7eb..d0ceffd5c6 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -39,7 +39,6 @@ module.exports = function karmaConfig (config) { 'website/client/js/env.js', 'website/client/js/app.js', 'common/script/public/config.js', - 'common/script/public/userServices.js', 'common/script/public/directives.js', 'website/client/js/services/**/*.js', From 3b8ce8451b0133108335e4e686f073c7dd38f7d6 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 22:30:18 -0500 Subject: [PATCH 832/976] feat: Create debug update user route --- .../debug/POST-debug_update-user.test.js | 53 +++++++++++++++++++ website/server/controllers/api-v3/debug.js | 27 ++++++++++ 2 files changed, 80 insertions(+) create mode 100644 test/api/v3/integration/debug/POST-debug_update-user.test.js diff --git a/test/api/v3/integration/debug/POST-debug_update-user.test.js b/test/api/v3/integration/debug/POST-debug_update-user.test.js new file mode 100644 index 0000000000..77b6019a27 --- /dev/null +++ b/test/api/v3/integration/debug/POST-debug_update-user.test.js @@ -0,0 +1,53 @@ +import nconf from 'nconf'; +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /debug/update-user', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + after(() => { + nconf.set('IS_PROD', false); + }); + + it('sets protected values', async () => { + let newCron = new Date(2015, 11, 20); + + await user.post('/debug/update-user', { + balance: 100, + lastCron: newCron, + }); + + await user.sync() + + expect(user.lastCron).to.eql(newCron); + expect(user.balance).to.eql(100); + }); + + it('sets nested values', async () => { + await user.post('/debug/update-user', { + 'contributor.level': 9, + 'purchased.txnCount': 100, + }); + + await user.sync() + + expect(user.contributor.level).to.eql(9); + expect(user.purchased.txnCount).to.eql(100); + }); + + it('returns error when not in production mode', async () => { + nconf.set('IS_PROD', true); + + await expect(user.post('/debug/update-user')) + .eventually.be.rejected.and.to.deep.equal({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); +}); diff --git a/website/server/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js index dfd462a7e7..4b1c21477f 100644 --- a/website/server/controllers/api-v3/debug.js +++ b/website/server/controllers/api-v3/debug.js @@ -1,5 +1,6 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode'; +import _ from 'lodash'; let api = {}; @@ -51,4 +52,30 @@ api.addHourglass = { }, }; +/** + * @api {post} /api/v3/debug/set-property Sets properties on user, even protected fields + * @apiDescription Only available in development mode. + * @apiVersion 3.0.0 + * @apiName setCron + * @apiGroup Development + * + * @apiSuccess {Object} data An empty Object + */ +api.setCron = { + method: 'POST', + url: '/debug/update-user', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + + _.each(req.body, (value, key) => { + _.set(user, key, value); + }); + + await user.save(); + + res.respond(200, {}); + }, +}; + module.exports = api; From bc58bd97bbcaceb4dcf3017c7e0554dbdf0b0497 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 22:30:32 -0500 Subject: [PATCH 833/976] fix: Correct set cron debug function --- website/client/js/controllers/footerCtrl.js | 6 ++---- website/client/js/services/userServices.js | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/website/client/js/controllers/footerCtrl.js b/website/client/js/controllers/footerCtrl.js index 694a2b2b65..98ba567dc6 100644 --- a/website/client/js/controllers/footerCtrl.js +++ b/website/client/js/controllers/footerCtrl.js @@ -78,12 +78,10 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { }); }; - //@TODO: Route? $scope.addMissedDay = function(numberOfDays){ if (!confirm("Are you sure you want to reset the day by " + numberOfDays + " day(s)?")) return; - var dayBefore = moment(User.user.lastCron).subtract(numberOfDays, 'days').toDate(); - User.set({'lastCron': dayBefore}); - Notification.text('-' + numberOfDays + ' day(s), remember to refresh'); + + User.setCron(numberOfDays); }; $scope.addTenGems = function(){ diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 5f4b115e37..b99b826e21 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -244,6 +244,21 @@ angular.module('habitrpg') }) }, + setCron: function (numberOfDays) { + var date = moment(user.lastCron).subtract(numberOfDays, 'days').toDate(); + + $http({ + method: "POST", + url: 'api/v3/debug/update-user', + data: { + lastCron: date + } + }) + .then(function (response) { + Notification.text('-' + numberOfDays + ' day(s), remember to refresh'); + }); + }, + clearNewMessages: function () { callOpsFunctionAndRequest('markPmsRead', 'mark-pms-read', "POST"); }, From 50d73458321fe62085c95ddb31c5b6c3d021fcf0 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 22:37:26 -0500 Subject: [PATCH 834/976] feat: Add make admin button to debug menu --- website/client/js/controllers/footerCtrl.js | 4 ++++ website/client/js/services/userServices.js | 14 ++++++++++++++ website/views/shared/footer.jade | 1 + 3 files changed, 19 insertions(+) diff --git a/website/client/js/controllers/footerCtrl.js b/website/client/js/controllers/footerCtrl.js index 98ba567dc6..bca27a04d8 100644 --- a/website/client/js/controllers/footerCtrl.js +++ b/website/client/js/controllers/footerCtrl.js @@ -124,5 +124,9 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { 'party.quest.progress.up': User.user.party.quest.progress.up + 1000 }); }; + + $scope.makeAdmin = function () { + User.makeAdmin(); + }; } }]) diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index b99b826e21..48ce5148d2 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -259,6 +259,20 @@ angular.module('habitrpg') }); }, + makeAdmin: function () { + $http({ + method: "POST", + url: 'api/v3/debug/update-user', + data: { + 'contributor.admin': true + } + }) + .then(function (response) { + Notification.text('You are now an admin! Go to the Hall of Heroes to change your contributor level.'); + sync() + }); + }, + clearNewMessages: function () { callOpsFunctionAndRequest('markPmsRead', 'mark-pms-read', "POST"); }, diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index 30570f27b4..6ec84cb79d 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -92,6 +92,7 @@ footer.footer(ng-controller='FooterCtrl') a.btn.btn-default(ng-click='addLevelsAndGold()') +Exp +GP +MP a.btn.btn-default(ng-click='addOneLevel()') +1 Level a.btn.btn-default(ng-click='addBossQuestProgressUp()') +1000 Boss Quest Progress Up + a.btn.btn-default(ng-click='makeAdmin()') Make Admin div(ng-init='deferredScripts()') From 4a2e3d441083ab2de12d5c97694ce616b0691e57 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 22:45:35 -0500 Subject: [PATCH 835/976] lint: Add missing semicolons in test --- test/api/v3/integration/debug/POST-debug_update-user.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/debug/POST-debug_update-user.test.js b/test/api/v3/integration/debug/POST-debug_update-user.test.js index 77b6019a27..851d4c48c9 100644 --- a/test/api/v3/integration/debug/POST-debug_update-user.test.js +++ b/test/api/v3/integration/debug/POST-debug_update-user.test.js @@ -22,7 +22,7 @@ describe('POST /debug/update-user', () => { lastCron: newCron, }); - await user.sync() + await user.sync(); expect(user.lastCron).to.eql(newCron); expect(user.balance).to.eql(100); @@ -34,7 +34,7 @@ describe('POST /debug/update-user', () => { 'purchased.txnCount': 100, }); - await user.sync() + await user.sync(); expect(user.contributor.level).to.eql(9); expect(user.purchased.txnCount).to.eql(100); From 480937c8001ab1147b2a1792c7d3889510f942f5 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 14 May 2016 23:07:18 -0500 Subject: [PATCH 836/976] fix: Temporarilly comment out udpate user debug route --- .../debug/POST-debug_update-user.test.js | 4 +-- website/server/controllers/api-v3/debug.js | 34 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/test/api/v3/integration/debug/POST-debug_update-user.test.js b/test/api/v3/integration/debug/POST-debug_update-user.test.js index 851d4c48c9..f3c77bc89a 100644 --- a/test/api/v3/integration/debug/POST-debug_update-user.test.js +++ b/test/api/v3/integration/debug/POST-debug_update-user.test.js @@ -14,7 +14,7 @@ describe('POST /debug/update-user', () => { nconf.set('IS_PROD', false); }); - it('sets protected values', async () => { + xit('sets protected values', async () => { let newCron = new Date(2015, 11, 20); await user.post('/debug/update-user', { @@ -28,7 +28,7 @@ describe('POST /debug/update-user', () => { expect(user.balance).to.eql(100); }); - it('sets nested values', async () => { + xit('sets nested values', async () => { await user.post('/debug/update-user', { 'contributor.level': 9, 'purchased.txnCount': 100, diff --git a/website/server/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js index 4b1c21477f..71bba17198 100644 --- a/website/server/controllers/api-v3/debug.js +++ b/website/server/controllers/api-v3/debug.js @@ -1,6 +1,6 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode'; -import _ from 'lodash'; +// import _ from 'lodash'; let api = {}; @@ -61,21 +61,21 @@ api.addHourglass = { * * @apiSuccess {Object} data An empty Object */ -api.setCron = { - method: 'POST', - url: '/debug/update-user', - middlewares: [ensureDevelpmentMode, authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - _.each(req.body, (value, key) => { - _.set(user, key, value); - }); - - await user.save(); - - res.respond(200, {}); - }, -}; +// api.setCron = { +// method: 'POST', +// url: '/debug/update-user', +// middlewares: [ensureDevelpmentMode, authWithHeaders()], +// async handler (req, res) { +// let user = res.locals.user; +// +// _.each(req.body, (value, key) => { +// _.set(user, key, value); +// }); +// +// await user.save(); +// +// res.respond(200, {}); +// }, +// }; module.exports = api; From bbf5791aa077ec73fece21409b3d22bc50ef5e43 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 10:45:12 +0200 Subject: [PATCH 837/976] v3: fix _tmp for crit and streakBonus --- website/client/js/services/userServices.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 22870aedc3..f145c16e0d 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -179,11 +179,15 @@ angular.module('habitrpg') $window.habitrpgShared.ops.scoreTask({user: user, task: data.params.task, direction: data.params.direction}, data.params); save(); Tasks.scoreTask(data.params.task._id, data.params.direction).then(function (res) { - var drop = res.data.data._tmp.drop; + var tmp = res.data.data._tmp || {}; // used to notify drops, critical hits and other bonuses - if (drop) { - user._tmp.drop = drop; - } + var drop = tmp.drop; + var crit = tmp.crit; + var streakBonus = tmp.streakBonus; + + if (drop) user._tmp.drop = drop; + if (crit) user._tmp.crit = crit; + if (streakBonus) user._tmp.streakBonus = streakBonus; }); }, From a8a063d3f60453b5271e966daccfd6dd305557b3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 11:01:37 +0200 Subject: [PATCH 838/976] v3: execute all actions when leaving a solo party --- website/server/models/group.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/server/models/group.js b/website/server/models/group.js index 7a511f51e7..aec5824fef 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -660,7 +660,7 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { // If user is the last one in group and group is private, delete it if (group.memberCount <= 1 && group.privacy === 'private') { - return await group.remove(); + promises.push(group.remove()); } else { // otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for) let update = { $inc: {memberCount: -1}, @@ -679,7 +679,7 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { firebase.removeUserFromGroup(group._id, user._id); - return Bluebird.all(promises); + return await Bluebird.all(promises); }; // API v2 compatibility methods From 6ab3280045525011bbf906a2965403d2ccad8ecc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 11:06:20 +0200 Subject: [PATCH 839/976] v3 client: fix group not found when leaving party --- website/client/js/controllers/partyCtrl.js | 4 +++- website/client/js/services/userServices.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/website/client/js/controllers/partyCtrl.js b/website/client/js/controllers/partyCtrl.js index 9ec42908b5..0edb238325 100644 --- a/website/client/js/controllers/partyCtrl.js +++ b/website/client/js/controllers/partyCtrl.js @@ -71,7 +71,9 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' Groups.Group.leave($scope.selectedGroup._id, keep) .then(function (response) { Analytics.updateUser({'partySize':null,'partyID':null}); - $rootScope.hardRedirect('/#/options/groups/party'); + User.sync().then(function () { + $rootScope.hardRedirect('/#/options/groups/party'); + }); }); } }; diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index f145c16e0d..b169f12672 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -479,7 +479,7 @@ angular.module('habitrpg') sync: function(){ userServices.log({}); - sync(); + return sync(); }, save: save, From 708f495684d6ddcb32d5c2af9cbdaeb21b87efd8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 11:12:12 +0200 Subject: [PATCH 840/976] v3 migration: fix challenge prize --- migrations/api_v3/challenges.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index c29dc82df4..eaec714858 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -105,7 +105,7 @@ function processChallenges (afterId) { var createdAt = oldChallenge.timestamp; oldChallenge.memberCount = oldChallenge.members.length; - if (!oldChallenge.prize <= 0) oldChallenge.prize = 0; + if (oldChallenge.prize <= 0) oldChallenge.prize = 0; if (!oldChallenge.name) oldChallenge.name = 'challenge name'; if (!oldChallenge.shortName) oldChallenge.name = 'challenge-name'; From 18ce4127e99f399fe836982ba0041206c944f27b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 11:31:34 +0200 Subject: [PATCH 841/976] v3 cron: only save modified tasks --- website/server/middlewares/api-v3/cron.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/middlewares/api-v3/cron.js b/website/server/middlewares/api-v3/cron.js index 6b6f4f99f9..a7f2801766 100644 --- a/website/server/middlewares/api-v3/cron.js +++ b/website/server/middlewares/api-v3/cron.js @@ -136,7 +136,7 @@ module.exports = function cronMiddleware (req, res, next) { // Save user and tasks let toSave = [user.save()]; tasks.forEach(task => { - toSave.push(task.save()); + if (task.isModified()) toSave.push(task.save()); }); return Bluebird.all(toSave) From 64930ea07fefe0dd449e47a812ebb18be9638344 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 11:35:10 +0200 Subject: [PATCH 842/976] v3: add CHALLENGE_TASK_NOT_FOUND to valid broken reasons --- website/server/models/task.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/models/task.js b/website/server/models/task.js index 6d4143e80d..1fbe351e18 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -40,7 +40,7 @@ export let TaskSchema = new Schema({ challenge: { id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task - broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, + broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED', 'CHALLENGE_TASK_NOT_FOUND']}, // CHALLENGE_TASK_NOT_FOUND comes from v3 migration winner: String, // user.profile.name of the winner }, From 4c056426e8ff44a51ab98565de132192da0e21eb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 12:00:50 +0200 Subject: [PATCH 843/976] v3: fix tasks chart --- website/client/js/services/taskServices.js | 2 +- website/views/shared/tasks/edit/habits/plus_minus.jade | 8 ++++---- website/views/shared/tasks/meta_controls.jade | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/client/js/services/taskServices.js b/website/client/js/services/taskServices.js index f0d5eec305..f5f9945548 100644 --- a/website/client/js/services/taskServices.js +++ b/website/client/js/services/taskServices.js @@ -142,7 +142,7 @@ angular.module('habitrpg') task._editing = !task._editing; task._tags = !user.preferences.tagsCollapsed; task._advanced = !user.preferences.advancedCollapsed; - if($rootScope.charts[task.id]) $rootScope.charts[task.id] = false; + if($rootScope.charts[task._id]) $rootScope.charts[task.id] = false; } function cloneTask(task) { diff --git a/website/views/shared/tasks/edit/habits/plus_minus.jade b/website/views/shared/tasks/edit/habits/plus_minus.jade index bbe17b7d4e..0a67ff9001 100644 --- a/website/views/shared/tasks/edit/habits/plus_minus.jade +++ b/website/views/shared/tasks/edit/habits/plus_minus.jade @@ -1,8 +1,8 @@ fieldset.option-group.plusminus(ng-if='task.type=="habit" && !task.challenge.id') legend.option-title=env.t('direction/Actions') span.task-checker - input.visuallyhidden.focusable(id='{{obj._id}}_{{task.id}}-option-plus', type='checkbox', ng-model='task.up') - label(for='{{obj._id}}_{{task.id}}-option-plus') + input.visuallyhidden.focusable(id='{{obj._id}}_{{task._id}}-option-plus', type='checkbox', ng-model='task.up') + label(for='{{obj._id}}_{{task._id}}-option-plus') span.task-checker - input.visuallyhidden.focusable(id='{{obj._id}}_{{task.id}}-option-minus', type='checkbox', ng-model='task.down') - label(for='{{obj._id}}_{{task.id}}-option-minus') + input.visuallyhidden.focusable(id='{{obj._id}}_{{task._id}}-option-minus', type='checkbox', ng-model='task.down') + label(for='{{obj._id}}_{{task._id}}-option-minus') diff --git a/website/views/shared/tasks/meta_controls.jade b/website/views/shared/tasks/meta_controls.jade index 165996d02a..c080e3deaa 100644 --- a/website/views/shared/tasks/meta_controls.jade +++ b/website/views/shared/tasks/meta_controls.jade @@ -48,7 +48,7 @@ |   // chart - a(ng-show='task.history', ng-click='toggleChart(obj._id+task.id, task)', tooltip=env.t('progress')) + a(ng-show='task.history', ng-click='toggleChart(obj._id+task._id, task)', tooltip=env.t('progress')) span.glyphicon.glyphicon-signal |   // notes From 0f7964cac11f0bcdb82336eb82903391ed52ad0c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 12:12:19 +0200 Subject: [PATCH 844/976] v3 client: fix ability to leave challenge --- website/client/js/controllers/challengesCtrl.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index 875331faac..ef919d590b 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -277,7 +277,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', if (keep == 'cancel') { $scope.selectedChal = undefined; } else { - Challenges.leaveChallenge(challenge._id, keep) + Challenges.leaveChallenge($scope.selectedChal._id, keep) .then(function (response) { _getChallenges() }); From cb0939a38c9c63c164455ce16dcc5a0bbd6ba17b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 12:31:27 +0200 Subject: [PATCH 845/976] v3 client: fix filtering by tag and correctly show tag tooltip --- common/script/libs/appliedTags.js | 4 ++-- common/script/libs/taskClasses.js | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/common/script/libs/appliedTags.js b/common/script/libs/appliedTags.js index c65074033e..ded2a9de23 100644 --- a/common/script/libs/appliedTags.js +++ b/common/script/libs/appliedTags.js @@ -4,9 +4,9 @@ Are there tags applied? // TODO move to client -module.exports = function appliedTags (userTags, taskTags = {}) { +module.exports = function appliedTags (userTags, taskTags = []) { let arr = userTags.filter(tag => { - return taskTags[tag.id]; + return taskTags.indexOf(tag.id) !== -1; }).map(tag => { return tag.name; }); diff --git a/common/script/libs/taskClasses.js b/common/script/libs/taskClasses.js index 0939c8b837..9ef223d9f7 100644 --- a/common/script/libs/taskClasses.js +++ b/common/script/libs/taskClasses.js @@ -5,6 +5,9 @@ import { /* Task classes given everything about the class */ + +// TODO move to the client + module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCron = Number(new Date()), showCompleted = false, main = false) { if (!task) { return ''; @@ -18,16 +21,18 @@ module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCro if (main && !task._editing) { for (let filter in filters) { let enabled = filters[filter]; - if (!task.tags) task.tags = {}; - if (enabled && !task.tags[filter]) { + if (!task.tags) task.tags = []; + if (enabled && task.tags.indexOf(filter) === -1) { return 'hidden'; } } } + classes = task.type; if (task._editing) { classes += ' beingEdited'; } + if (type === 'todo' || type === 'daily') { if (completed || (type === 'daily' && !shouldDo(Number(new Date()), task, { // eslint-disable-line no-extra-parens dayStart, @@ -44,6 +49,7 @@ module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCro classes += ' habit-narrow'; } } + if (priority === 0.1) { classes += ' difficulty-trivial'; } else if (priority === 1) { @@ -53,6 +59,7 @@ module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCro } else if (priority === 2) { classes += ' difficulty-hard'; } + if (value < -20) { classes += ' color-worst'; } else if (value < -10) { @@ -68,5 +75,6 @@ module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCro } else { classes += ' color-best'; } + return classes; }; From 438d2779b537008d329098e46324f7dadc0ddfa1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 12:45:27 +0200 Subject: [PATCH 846/976] v3 common: fix tags tests --- test/common/libs/appliedTags.test.js | 2 +- test/common/libs/taskClasses.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/common/libs/appliedTags.test.js b/test/common/libs/appliedTags.test.js index 204d30f5bd..66f3de4758 100644 --- a/test/common/libs/appliedTags.test.js +++ b/test/common/libs/appliedTags.test.js @@ -3,7 +3,7 @@ import appliedTags from '../../../common/script/libs/appliedTags'; describe('appliedTags', () => { it('returns the tasks', () => { let userTags = [{ id: 'tag1', name: 'tag 1' }, { id: 'tag2', name: 'tag 2' }, { id: 'tag3', name: 'tag 3' }]; - let taskTags = { tag2: true, tag3: true }; + let taskTags = ['tag2', 'tag3']; let result = appliedTags(userTags, taskTags); expect(result).to.eql('tag 2, tag 3'); }); diff --git a/test/common/libs/taskClasses.test.js b/test/common/libs/taskClasses.test.js index 338b861f02..226d740bac 100644 --- a/test/common/libs/taskClasses.test.js +++ b/test/common/libs/taskClasses.test.js @@ -7,7 +7,7 @@ describe('taskClasses', () => { describe('a todo task', () => { beforeEach(() => { - task = { type: 'todo', _editing: false, tags: { a: false } }; + task = { type: 'todo', _editing: false, tags: [] }; }); it('is hidden', () => { From 23a9a3e0e4ca7d5d126cf35df4cda1a85267fa48 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 13:00:42 +0200 Subject: [PATCH 847/976] v3 client: support unlinking not found challenges tasks --- website/views/shared/tasks/edit/index.jade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/views/shared/tasks/edit/index.jade b/website/views/shared/tasks/edit/index.jade index d5032f94ce..d7bc217598 100644 --- a/website/views/shared/tasks/edit/index.jade +++ b/website/views/shared/tasks/edit/index.jade @@ -3,7 +3,7 @@ div(ng-if='task._editing') // Broken Challenge .well(ng-if='task.challenge.broken') - div(ng-if='task.challenge.broken=="TASK_DELETED"') + div(ng-if='task.challenge.broken=="TASK_DELETED" || task.challenge.broken=="CHALLENGE_TASK_NOT_FOUND') p=env.t('brokenTask') p a(ng-click='unlink(task, "keep")')=env.t('keepIt') From 6ec4d942df5d399b207113d6a28a94909f8986c8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 13:38:18 +0200 Subject: [PATCH 848/976] v3: disable Bluebird warning for missing return, fixes #7269 --- website/server/index.js | 1 + website/server/libs/api-v3/logger.js | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/website/server/index.js b/website/server/index.js index 6af0647d0b..11171f8579 100644 --- a/website/server/index.js +++ b/website/server/index.js @@ -10,6 +10,7 @@ if (process.env.NODE_ENV !== 'production') { // The BabelJS polyfill is needed in production too require('babel-polyfill'); +// Setup Bluebird as the global promise library global.Promise = require('bluebird'); // Only do the minimal amount of work before forking just in case of a dyno restart diff --git a/website/server/libs/api-v3/logger.js b/website/server/libs/api-v3/logger.js index 79e8ff3e62..a399cb6fbd 100644 --- a/website/server/libs/api-v3/logger.js +++ b/website/server/libs/api-v3/logger.js @@ -2,6 +2,7 @@ import winston from 'winston'; import nconf from 'nconf'; import _ from 'lodash'; +import Bluebird from 'bluebird'; const IS_PROD = nconf.get('IS_PROD'); const IS_TEST = nconf.get('IS_TEST'); @@ -53,6 +54,15 @@ let loggerInterface = { }, }; +// Disable warnings for missed returns in Bluebird. +// See https://github.com/petkaantonov/bluebird/issues/903 +Bluebird.config({ + // Enables all warnings except forgotten return statements. + warnings: { + wForgottenReturn: false, + }, +}); + // Logs unhandled promises errors // when no catch is attached to a promise a unhandledRejection event will be triggered process.on('unhandledRejection', function handlePromiseRejection (reason) { From 44c944991334d55bc8c225990f0884edfe8b9e4e Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 15 May 2016 06:42:12 -0500 Subject: [PATCH 849/976] feat: Separate out update-user into set-cron and make-admin debug routes --- .../debug/POST-debug_make-admin.test.js | 35 +++++++++++ ...er.test.js => POST-debug_set-cron.test.js} | 24 ++------ website/client/js/services/userServices.js | 7 +-- website/server/controllers/api-v3/debug.js | 58 +++++++++++++------ 4 files changed, 82 insertions(+), 42 deletions(-) create mode 100644 test/api/v3/integration/debug/POST-debug_make-admin.test.js rename test/api/v3/integration/debug/{POST-debug_update-user.test.js => POST-debug_set-cron.test.js} (52%) diff --git a/test/api/v3/integration/debug/POST-debug_make-admin.test.js b/test/api/v3/integration/debug/POST-debug_make-admin.test.js new file mode 100644 index 0000000000..9247e09503 --- /dev/null +++ b/test/api/v3/integration/debug/POST-debug_make-admin.test.js @@ -0,0 +1,35 @@ +import nconf from 'nconf'; +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /debug/make-admin', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + afterEach(() => { + nconf.set('IS_PROD', false); + }); + + it('makes user an admine', async () => { + await user.post('/debug/make-admin'); + + await user.sync(); + + expect(user.contributor.admin).to.eql(true); + }); + + it('returns error when not in production mode', async () => { + nconf.set('IS_PROD', true); + + await expect(user.post('/debug/make-admin')) + .eventually.be.rejected.and.to.deep.equal({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); +}); diff --git a/test/api/v3/integration/debug/POST-debug_update-user.test.js b/test/api/v3/integration/debug/POST-debug_set-cron.test.js similarity index 52% rename from test/api/v3/integration/debug/POST-debug_update-user.test.js rename to test/api/v3/integration/debug/POST-debug_set-cron.test.js index f3c77bc89a..c737831d95 100644 --- a/test/api/v3/integration/debug/POST-debug_update-user.test.js +++ b/test/api/v3/integration/debug/POST-debug_set-cron.test.js @@ -3,47 +3,33 @@ import { generateUser, } from '../../../../helpers/api-v3-integration.helper'; -describe('POST /debug/update-user', () => { +describe('POST /debug/set-cron', () => { let user; before(async () => { user = await generateUser(); }); - after(() => { + afterEach(() => { nconf.set('IS_PROD', false); }); - xit('sets protected values', async () => { + it('sets last cron', async () => { let newCron = new Date(2015, 11, 20); - await user.post('/debug/update-user', { - balance: 100, + await user.post('/debug/set-cron', { lastCron: newCron, }); await user.sync(); expect(user.lastCron).to.eql(newCron); - expect(user.balance).to.eql(100); - }); - - xit('sets nested values', async () => { - await user.post('/debug/update-user', { - 'contributor.level': 9, - 'purchased.txnCount': 100, - }); - - await user.sync(); - - expect(user.contributor.level).to.eql(9); - expect(user.purchased.txnCount).to.eql(100); }); it('returns error when not in production mode', async () => { nconf.set('IS_PROD', true); - await expect(user.post('/debug/update-user')) + await expect(user.post('/debug/set-cron')) .eventually.be.rejected.and.to.deep.equal({ code: 404, error: 'NotFound', diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index b169f12672..32540db6c4 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -259,7 +259,7 @@ angular.module('habitrpg') $http({ method: "POST", - url: 'api/v3/debug/update-user', + url: 'api/v3/debug/set-cron', data: { lastCron: date } @@ -272,10 +272,7 @@ angular.module('habitrpg') makeAdmin: function () { $http({ method: "POST", - url: 'api/v3/debug/update-user', - data: { - 'contributor.admin': true - } + url: 'api/v3/debug/make-admin' }) .then(function (response) { Notification.text('You are now an admin! Go to the Hall of Heroes to change your contributor level.'); diff --git a/website/server/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js index 71bba17198..da00319578 100644 --- a/website/server/controllers/api-v3/debug.js +++ b/website/server/controllers/api-v3/debug.js @@ -1,6 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode'; -// import _ from 'lodash'; let api = {}; @@ -53,7 +52,7 @@ api.addHourglass = { }; /** - * @api {post} /api/v3/debug/set-property Sets properties on user, even protected fields + * @api {post} /api/v3/debug/set-cron Sets lastCron for user * @apiDescription Only available in development mode. * @apiVersion 3.0.0 * @apiName setCron @@ -61,21 +60,44 @@ api.addHourglass = { * * @apiSuccess {Object} data An empty Object */ -// api.setCron = { -// method: 'POST', -// url: '/debug/update-user', -// middlewares: [ensureDevelpmentMode, authWithHeaders()], -// async handler (req, res) { -// let user = res.locals.user; -// -// _.each(req.body, (value, key) => { -// _.set(user, key, value); -// }); -// -// await user.save(); -// -// res.respond(200, {}); -// }, -// }; +api.setCron = { + method: 'POST', + url: '/debug/set-cron', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + let cron = req.body.lastCron; + + user.lastCron = cron; + + await user.save(); + + res.respond(200, {}); + }, +}; + +/** + * @api {post} /api/v3/debug/make-admin Sets contributor.admin to true + * @apiDescription Only available in development mode. + * @apiVersion 3.0.0 + * @apiName setCron + * @apiGroup Development + * + * @apiSuccess {Object} data An empty Object + */ +api.makeAdmin = { + method: 'POST', + url: '/debug/make-admin', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + + user.contributor.admin = true; + + await user.save(); + + res.respond(200, {}); + }, +}; module.exports = api; From 2851af1a29c299429c9882d2ca08c139b20b23a0 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 15 May 2016 07:01:09 -0500 Subject: [PATCH 850/976] chore: Disable make admin debug route for v3 prod testing --- .../debug/POST-debug_make-admin.test.js | 2 +- website/server/controllers/api-v3/debug.js | 29 ++++++++++--------- website/views/shared/footer.jade | 3 +- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/test/api/v3/integration/debug/POST-debug_make-admin.test.js b/test/api/v3/integration/debug/POST-debug_make-admin.test.js index 9247e09503..69628aa8bc 100644 --- a/test/api/v3/integration/debug/POST-debug_make-admin.test.js +++ b/test/api/v3/integration/debug/POST-debug_make-admin.test.js @@ -3,7 +3,7 @@ import { generateUser, } from '../../../../helpers/api-v3-integration.helper'; -describe('POST /debug/make-admin', () => { +xdescribe('POST /debug/make-admin (pended for v3 prod testing)', () => { let user; before(async () => { diff --git a/website/server/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js index da00319578..8824414be5 100644 --- a/website/server/controllers/api-v3/debug.js +++ b/website/server/controllers/api-v3/debug.js @@ -85,19 +85,20 @@ api.setCron = { * * @apiSuccess {Object} data An empty Object */ -api.makeAdmin = { - method: 'POST', - url: '/debug/make-admin', - middlewares: [ensureDevelpmentMode, authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - user.contributor.admin = true; - - await user.save(); - - res.respond(200, {}); - }, -}; +// TODO: Re-enable after v3 prod testing is done +// api.makeAdmin = { +// method: 'POST', +// url: '/debug/make-admin', +// middlewares: [ensureDevelpmentMode, authWithHeaders()], +// async handler (req, res) { +// let user = res.locals.user; +// +// user.contributor.admin = true; +// +// await user.save(); +// +// res.respond(200, {}); +// }, +// }; module.exports = api; diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index 6ec84cb79d..cc94250844 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -92,7 +92,8 @@ footer.footer(ng-controller='FooterCtrl') a.btn.btn-default(ng-click='addLevelsAndGold()') +Exp +GP +MP a.btn.btn-default(ng-click='addOneLevel()') +1 Level a.btn.btn-default(ng-click='addBossQuestProgressUp()') +1000 Boss Quest Progress Up - a.btn.btn-default(ng-click='makeAdmin()') Make Admin + // TODO Re-enable after v3 prod testing + // a.btn.btn-default(ng-click='makeAdmin()') Make Admin div(ng-init='deferredScripts()') From 3fbc15681120a566fad5cbea991582e6920ca06f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 14:20:20 +0200 Subject: [PATCH 851/976] v3: misc fixes --- website/server/controllers/api-v2/auth.js | 1 - website/server/controllers/api-v2/challenges.js | 5 ++--- website/server/controllers/api-v2/user.js | 2 +- website/server/controllers/api-v3/email.js | 4 ++-- website/server/controllers/api-v3/quests.js | 1 - website/server/controllers/api-v3/tasks.js | 3 +-- website/server/controllers/api-v3/user.js | 6 +++--- website/server/controllers/top-level/pages.js | 3 +-- website/server/libs/api-v3/baseModel.js | 4 ++++ website/server/libs/api-v3/cron.js | 4 ++-- website/server/libs/api-v3/logger.js | 12 ------------ website/server/middlewares/api-v3/auth.js | 2 +- website/server/middlewares/api-v3/cron.js | 2 +- website/server/middlewares/api-v3/index.js | 2 +- website/server/models/challenge.js | 8 ++++---- website/server/models/group.js | 8 ++++---- website/server/models/task.js | 1 - website/server/models/user.js | 13 ++++++------- website/views/static/{api.jade => api-v2.jade} | 0 19 files changed, 33 insertions(+), 48 deletions(-) rename website/views/static/{api.jade => api-v2.jade} (100%) diff --git a/website/server/controllers/api-v2/auth.js b/website/server/controllers/api-v2/auth.js index df8adf7a52..cfa5c40a0d 100644 --- a/website/server/controllers/api-v2/auth.js +++ b/website/server/controllers/api-v2/auth.js @@ -134,7 +134,6 @@ api.registerUser = function(req, res, next) { user.save(function(err, savedUser){ if (err) return cb(err); // Clean previous email preferences - // TODO when emails added to EmailUnsubcription they should use lowercase version EmailUnsubscription.remove({email: savedUser.auth.local.email}, function(){ utils.txnEmail(savedUser, 'welcome'); }); diff --git a/website/server/controllers/api-v2/challenges.js b/website/server/controllers/api-v2/challenges.js index 93beccbd48..c5d716d88e 100644 --- a/website/server/controllers/api-v2/challenges.js +++ b/website/server/controllers/api-v2/challenges.js @@ -55,7 +55,7 @@ api.list = async function(req, res, next) { return obj; }); - // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 await Bluebird.all(resChals.map((chal, index) => { return Bluebird.all([ User.findById(chal.leader).select(nameFields).exec(), @@ -268,8 +268,7 @@ api.update = function(req, res, next){ async.forEachOf(newTasksObj, function(newTask, taskId, cb2){ // some properties can't be changed newTask = Tasks.Task.sanitize(newTask); - // TODO we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? - // TODO regarding comment above, make sure other models with nested fields are using this trick too + // we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? _.assign(updatedTasks[taskId], shared.ops.updateTask(updatedTasks[taskId].toObject(), {body: newTask})); _before.chal.updateTask(updatedTasks[taskId]).then(cb2).catch(cb2); }, cb1); diff --git a/website/server/controllers/api-v2/user.js b/website/server/controllers/api-v2/user.js index c79d6e7dec..daaa272491 100644 --- a/website/server/controllers/api-v2/user.js +++ b/website/server/controllers/api-v2/user.js @@ -429,7 +429,7 @@ api.delete = function(req, res, next) { return res.status(400).json({err:"You have an active subscription, cancel your plan before deleting your account."}); } - let types = ['party', 'publicGuilds', 'privateGuilds']; + let types = ['party', 'guilds']; let groupFields = basicGroupFields.concat(' leader memberCount'); Group.getGroups({user, types, groupFields}) diff --git a/website/server/controllers/api-v3/email.js b/website/server/controllers/api-v3/email.js index f8e5e4effe..675518648e 100644 --- a/website/server/controllers/api-v3/email.js +++ b/website/server/controllers/api-v3/email.js @@ -44,9 +44,9 @@ api.unsubscribe = { res.send(`

${res.t('unsubscribedSuccessfully')}

${res.t('unsubscribedTextUsers')}`); } else { - let unsubscribedEmail = await EmailUnsubscription.findOne({email: data.email}); + let unsubscribedEmail = await EmailUnsubscription.findOne({email: data.email.toLowerCase()}); let okResponse = `

${res.t('unsubscribedSuccessfully')}

${res.t('unsubscribedTextOthers')}`; - if (!unsubscribedEmail) await EmailUnsubscription.create({email: data.email}); + if (!unsubscribedEmail) await EmailUnsubscription.create({email: data.email.toLowerCase()}); res.send(okResponse); } }, diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js index 3443f9d67d..832e90915a 100644 --- a/website/server/controllers/api-v3/quests.js +++ b/website/server/controllers/api-v3/quests.js @@ -383,7 +383,6 @@ api.abortQuest = { 'party._id': groupId, }, { $set: {'party.quest': Group.cleanQuestProgress()}, - $inc: {_v: 1}, // TODO update middleware }, {multi: true}).exec(); let questLeaderUpdate = User.update({ diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 0abe8f051b..3b118bfe3c 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -323,7 +323,7 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { let extendedStats = _.extend(stats, { toNextLevel: common.tnl(user.stats.lvl), maxHealth: common.maxHealth, - maxMP: user._statsComputed.maxMP, // TODO refactor as method not getter + maxMP: common.statsComputed(user).maxMP, }); let userData = { @@ -428,7 +428,6 @@ api.scoreTask = { }; // completed todos cannot be moved, they'll be returned ordered by date of completion -// TODO support challenges? /** * @api {post} /api/v3/tasks/:taskId/move/to/:position Move a task to a new position * @apiVersion 3.0.0 diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 1440e009f9..061541e954 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -36,10 +36,10 @@ api.getUser = { // Remove apiToken from response TODO make it private at the user level? returned in signup/login delete user.apiToken; - // TODO move to model (maybe virtuals, maybe in toJSON) + // TODO move to model? (maybe virtuals, maybe in toJSON) user.stats.toNextLevel = common.tnl(user.stats.lvl); user.stats.maxHealth = common.maxHealth; - user.stats.maxMP = res.locals.user._statsComputed.maxMP; + user.stats.maxMP = common.statsComputed(user).maxMP; return res.respond(200, user); }, @@ -210,7 +210,7 @@ api.deleteUser = { throw new NotAuthorized(res.t('cannotDeleteActiveAccount')); } - let types = ['party', 'publicGuilds', 'privateGuilds']; + let types = ['party', 'guilds']; let groupFields = basicGroupFields.concat(' leader memberCount'); let groupsUserIsMemberOf = await Group.getGroups({user, types, groupFields}); diff --git a/website/server/controllers/top-level/pages.js b/website/server/controllers/top-level/pages.js index 4b0ce11ee2..22c65015f7 100644 --- a/website/server/controllers/top-level/pages.js +++ b/website/server/controllers/top-level/pages.js @@ -27,8 +27,7 @@ api.getFrontPage = { }, }; -// TODO remove api static page -let staticPages = ['front', 'privacy', 'terms', 'api', 'features', +let staticPages = ['front', 'privacy', 'terms', 'api-v2', 'features', 'videos', 'contact', 'plans', 'new-stuff', 'community-guidelines', 'old-news', 'press-kit', 'faq', 'overview', 'apps', 'clear-browser-data', 'merch']; diff --git a/website/server/libs/api-v3/baseModel.js b/website/server/libs/api-v3/baseModel.js index e0b2101393..c736d613ea 100644 --- a/website/server/libs/api-v3/baseModel.js +++ b/website/server/libs/api-v3/baseModel.js @@ -32,6 +32,10 @@ module.exports = function baseModel (schema, options = {}) { if (!this.isNew) this.updatedAt = Date.now(); next(); }); + + schema.pre('update', function preUpdateModel () { + this.update({}, { $set: { updatedAt: new Date() } }); + }); } let noSetFields = ['createdAt', 'updatedAt']; diff --git a/website/server/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js index 1fd9466786..e4a96ee07f 100644 --- a/website/server/libs/api-v3/cron.js +++ b/website/server/libs/api-v3/cron.js @@ -24,8 +24,8 @@ function grantEndOfTheMonthPerks (user, now) { plan.dateUpdated = now; // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 - // TODO use month diff instead of ++ / --? - _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + // TODO use month diff instead of ++ / --? see https://github.com/HabitRPG/habitrpg/issues/4317 + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); plan.consecutive.count++; diff --git a/website/server/libs/api-v3/logger.js b/website/server/libs/api-v3/logger.js index a399cb6fbd..69e35abf97 100644 --- a/website/server/libs/api-v3/logger.js +++ b/website/server/libs/api-v3/logger.js @@ -2,7 +2,6 @@ import winston from 'winston'; import nconf from 'nconf'; import _ from 'lodash'; -import Bluebird from 'bluebird'; const IS_PROD = nconf.get('IS_PROD'); const IS_TEST = nconf.get('IS_TEST'); @@ -11,8 +10,6 @@ const ENABLE_CONSOLE_LOGS_IN_PROD = nconf.get('ENABLE_CONSOLE_LOGS_IN_PROD') === const logger = new winston.Logger(); if (IS_PROD) { - // TODO production logging, use loggly and new relic too - if (ENABLE_CONSOLE_LOGS_IN_PROD === 'true') { logger.add(winston.transports.Console, { colorize: true, @@ -54,15 +51,6 @@ let loggerInterface = { }, }; -// Disable warnings for missed returns in Bluebird. -// See https://github.com/petkaantonov/bluebird/issues/903 -Bluebird.config({ - // Enables all warnings except forgotten return statements. - warnings: { - wForgottenReturn: false, - }, -}); - // Logs unhandled promises errors // when no catch is attached to a promise a unhandledRejection event will be triggered process.on('unhandledRejection', function handlePromiseRejection (reason) { diff --git a/website/server/middlewares/api-v3/auth.js b/website/server/middlewares/api-v3/auth.js index 9effe37eae..2fbe437068 100644 --- a/website/server/middlewares/api-v3/auth.js +++ b/website/server/middlewares/api-v3/auth.js @@ -29,7 +29,7 @@ export function authWithHeaders (optional = false) { if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id})); res.locals.user = user; - // TODO use either session/cookie or headers, not both + req.session.userId = user._id; return next(); }) diff --git a/website/server/middlewares/api-v3/cron.js b/website/server/middlewares/api-v3/cron.js index a7f2801766..780f400e8f 100644 --- a/website/server/middlewares/api-v3/cron.js +++ b/website/server/middlewares/api-v3/cron.js @@ -128,7 +128,7 @@ module.exports = function cronMiddleware (req, res, next) { let ranCron = user.isModified(); let quest = common.content.quests[user.party.quest.key]; - // if (ranCron) res.locals.wasModified = true; // TODO remove after v2 is retired + if (ranCron) res.locals.wasModified = true; // TODO remove after v2 is retired if (!ranCron) return next(); // Group.tavernBoss(user, progress); diff --git a/website/server/middlewares/api-v3/index.js b/website/server/middlewares/api-v3/index.js index 4b526c10e1..6ccac2f844 100644 --- a/website/server/middlewares/api-v3/index.js +++ b/website/server/middlewares/api-v3/index.js @@ -61,7 +61,7 @@ module.exports = function attachMiddlewares (app, server) { app.use(cookieSession({ name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies secret: SESSION_SECRET, - httpOnly: false, // TODO this should be true for security, what about https only? + httpOnly: false, // TODO this should be true for security, what about https only (secure) ? maxAge: TWO_WEEKS, })); diff --git a/website/server/models/challenge.js b/website/server/models/challenge.js index be61b0f00d..cd99fda995 100644 --- a/website/server/models/challenge.js +++ b/website/server/models/challenge.js @@ -74,7 +74,7 @@ schema.methods.canView = function canViewChallenge (user, group) { function _syncableAttrs (task) { let t = task.toObject(); // lodash doesn't seem to like _.omit on Document // only sync/compare important attrs - let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt? + let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes', 'updatedAt']; if (t.type !== 'reward') omitAttrs.push('value'); return _.omit(t, omitAttrs); } @@ -222,7 +222,7 @@ schema.methods.removeTask = async function challengeRemoveTask (task) { 'challenge.id': challenge.id, 'challenge.taskId': task._id, }, { - $set: {'challenge.broken': 'TASK_DELETED'}, // TODO what about updatedAt? + $set: {'challenge.broken': 'TASK_DELETED'}, }, {multi: true}).exec(); }; @@ -238,7 +238,7 @@ schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) { if (keep === 'keep-all') { await Tasks.Task.update(findQuery, { - $set: {challenge: {}}, // TODO what about updatedAt? + $set: {challenge: {}}, }, {multi: true}).exec(); await user.save(); @@ -259,7 +259,7 @@ schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) { }; // TODO everything here should be moved to a worker -// actually even for a worker it's probably just too big and will kill mongo +// actually even for a worker it's probably just too big and will kill mongo, figure out something else schema.methods.closeChal = async function closeChal (broken = {}) { let challenge = this; diff --git a/website/server/models/group.js b/website/server/models/group.js index aec5824fef..347079c96a 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -33,7 +33,6 @@ export let schema = new Schema({ leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, type: {type: String, enum: ['guild', 'party'], required: true}, privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true}, - // _v: {type: Number,'default': 0}, // TODO ? chat: Array, /* # [{ @@ -94,7 +93,6 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { // Basic fields to fetch for populating a group info export let basicFields = 'name type privacy'; -// TODO test schema.pre('remove', true, async function preRemoveGroup (next, done) { next(); try { @@ -179,14 +177,16 @@ schema.statics.getGroups = async function getGroups (options = {}) { queries.push(privateGuildsQuery); break; } + // NOTE: when returning publicGuilds we use `.lean()` so all mongoose methods won't be available. + // Docs are going to be plain javascript objects case 'publicGuilds': { let publicGuildsQuery = this.find({ type: 'guild', privacy: 'public', }).select(groupFields); if (populateLeader === true) publicGuildsQuery.populate('leader', nameFields); - publicGuildsQuery.sort(sort).exec(); - queries.push(publicGuildsQuery); // TODO use lean? + publicGuildsQuery.sort(sort).lean().exec(); + queries.push(publicGuildsQuery); break; } case 'tavern': { diff --git a/website/server/models/task.js b/website/server/models/task.js index 1fbe351e18..0459a74580 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -142,7 +142,6 @@ TaskSchema.statics.fromJSONV2 = function fromJSONV2 (taskObj) { return taskObj; }; - // END of API v2 methods export let Task = mongoose.model('Task', TaskSchema); diff --git a/website/server/models/user.js b/website/server/models/user.js index 9cce45fc55..eda6760d9f 100644 --- a/website/server/models/user.js +++ b/website/server/models/user.js @@ -525,11 +525,8 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - // TODO revisit a lot of things are missing. Given how many attributes we do have here we should white-list the ones that can be updated - // This is not really used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...) - noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', - 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', - 'invitations', 'balance', 'backer', 'contributor'], + // noSet is not used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...) + noSet: [], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (plainObj, originalDoc) { plainObj.id = plainObj._id; @@ -638,7 +635,6 @@ function _setProfileName (user) { schema.pre('save', true, function preSaveUser (next, done) { next(); - // TODO remove all unnecessary checks if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { this.preferences.dayStart = 0; } @@ -697,7 +693,10 @@ schema.pre('save', true, function preSaveUser (next, done) { } }); -// TODO unit test this? +schema.pre('update', function preUpdateUser () { + this.update({}, {$inc: {_v: 1}}); +}); + schema.methods.isSubscribed = function isSubscribed () { return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion }; diff --git a/website/views/static/api.jade b/website/views/static/api-v2.jade similarity index 100% rename from website/views/static/api.jade rename to website/views/static/api-v2.jade From 0be5d1da9c5a30af09dfdfb6a1ae3f8a07ac918a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 15:10:41 +0200 Subject: [PATCH 852/976] v3: misc fixes --- common/locales/en/api-v3.json | 2 +- package.json | 1 + test/helpers/api-integration/requester.js | 4 +- website/server/controllers/api-v3/auth.js | 1 - website/server/controllers/api-v3/tasks.js | 2 +- .../{api-v3 => top-level}/email.js | 1 - website/server/libs/api-v3/cron.js | 2 +- website/server/middlewares/api-v3/index.js | 3 +- website/server/models/challenge.js | 63 ++++++++++--------- 9 files changed, 42 insertions(+), 37 deletions(-) rename website/server/controllers/{api-v3 => top-level}/email.js (97%) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 7d887f7b7c..1008f699c3 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -77,7 +77,7 @@ "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", - "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", + "cantOnlyUnlinkChalTask": "Only broken challenges tasks can be unlinked.", "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!", "questInviteNotFound": "No quest invitation found.", "guildQuestsNotSupported": "Guilds cannot be invited on quests.", diff --git a/package.json b/package.json index 5edeadd75f..b4f5a11d25 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "cookie-session": "^1.2.0", "coupon-code": "^0.4.3", "csv-stringify": "^1.0.2", + "cwait": "^1.0.0", "domain-middleware": "~0.1.0", "estraverse": "^4.1.1", "express": "~4.13.3", diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index ee3adf243f..93ad503e38 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -32,8 +32,8 @@ function _requestMaker (user, method, additionalSets = {}) { return new Promise((resolve, reject) => { let url = `http://localhost:${API_TEST_SERVER_PORT}`; - // do not prefix with api/apiVersion requests to top level routes like dataexport and payments - if (route.indexOf('/export') === 0 || route.indexOf('/paypal') === 0 || route.indexOf('/amazon') === 0 || route.indexOf('/stripe') === 0) { + // do not prefix with api/apiVersion requests to top level routes like dataexport, payments and emails + if (route.indexOf('/email') === 0 || route.indexOf('/export') === 0 || route.indexOf('/paypal') === 0 || route.indexOf('/amazon') === 0 || route.indexOf('/stripe') === 0) { url += `${route}`; } else { url += `/api/${apiVersion}${route}`; diff --git a/website/server/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js index f501ba8c50..7295cce9b7 100644 --- a/website/server/controllers/api-v3/auth.js +++ b/website/server/controllers/api-v3/auth.js @@ -229,7 +229,6 @@ function _passportFbProfile (accessToken) { } // Called as a callback by Facebook (or other social providers). Internal route -// TODO move to top-level/auth? api.loginSocial = { method: 'POST', url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 3b118bfe3c..a6a25fd7ff 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -760,7 +760,6 @@ api.removeTagFromTask = { }, }; -// TODO this method needs some limitation, like to check if the challenge is really broken? /** * @api {post} /api/v3/tasks/unlink/:taskId Unlink a challenge task * @apiVersion 3.0.0 @@ -793,6 +792,7 @@ api.unlinkTask = { if (!task) throw new NotFound(res.t('taskNotFound')); if (!task.challenge.id) throw new BadRequest(res.t('cantOnlyUnlinkChalTask')); + if (!task.challenge.broken) throw new BadRequest(res.t('cantOnlyUnlinkChalTask')); if (keep === 'keep') { task.challenge = {}; diff --git a/website/server/controllers/api-v3/email.js b/website/server/controllers/top-level/email.js similarity index 97% rename from website/server/controllers/api-v3/email.js rename to website/server/controllers/top-level/email.js index 675518648e..b84f54c6e1 100644 --- a/website/server/controllers/api-v3/email.js +++ b/website/server/controllers/top-level/email.js @@ -7,7 +7,6 @@ import { let api = {}; -// TODO move to top-level controllers? /** * @api {get} /api/v3/email/unsubscribe Unsubscribe an email or user from email notifications * @apiDescription Does not require authentication diff --git a/website/server/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js index e4a96ee07f..b4b16d5c6e 100644 --- a/website/server/libs/api-v3/cron.js +++ b/website/server/libs/api-v3/cron.js @@ -246,7 +246,7 @@ export function cron (options = {}) { _.merge(progress, {down: 0, up: 0}); progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); - // @TODO: Clean PMs - keep 200 for subscribers and 50 for free users. Should also be done while resting in the inn + // TODO: Clean PMs - keep 200 for subscribers and 50 for free users. Should also be done while resting in the inn // let numberOfPMs = Object.keys(user.inbox.messages).length; // if (numberOfPMs > maxPMs) { // _(user.inbox.messages) diff --git a/website/server/middlewares/api-v3/index.js b/website/server/middlewares/api-v3/index.js index 6ccac2f844..9e3d37233e 100644 --- a/website/server/middlewares/api-v3/index.js +++ b/website/server/middlewares/api-v3/index.js @@ -61,7 +61,8 @@ module.exports = function attachMiddlewares (app, server) { app.use(cookieSession({ name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies secret: SESSION_SECRET, - httpOnly: false, // TODO this should be true for security, what about https only (secure) ? + httpOnly: true, // so cookies are not accessible with browser JS + // TODO what about https only (secure) ? maxAge: TWO_WEEKS, })); diff --git a/website/server/models/challenge.js b/website/server/models/challenge.js index cd99fda995..b12cea1dd8 100644 --- a/website/server/models/challenge.js +++ b/website/server/models/challenge.js @@ -13,6 +13,7 @@ import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import shared from '../../../common'; import { sendTxn as txnEmail } from '../libs/api-v3/email'; import sendPushNotification from '../libs/api-v3/pushNotifications'; +import cwait from 'cwait'; let Schema = mongoose.Schema; @@ -156,41 +157,45 @@ async function _fetchMembersIds (challengeId) { return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id); } +async function _addTaskFn (challenge, tasks, memberId) { + let updateTasksOrderQ = {$push: {}}; + let toSave = []; + + tasks.forEach(chalTask => { + let userTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask))); + userTask.challenge = {taskId: chalTask._id, id: challenge._id}; + userTask.userId = memberId; + + let tasksOrderList = updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`]; + if (!tasksOrderList) { + updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`] = { + $position: 0, // unshift + $each: [userTask._id], + }; + } else { + tasksOrderList.$each.unshift(userTask._id); + } + + toSave.push(userTask.save({ + validateBeforeSave: false, // no user data supplied + })); + }); + + // Update the user + toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec()); + return await Bluebird.all(toSave); +} + // Add a new task to challenge members schema.methods.addTasks = async function challengeAddTasks (tasks) { let challenge = this; let membersIds = await _fetchMembersIds(challenge._id); - // Sync each user sequentially - // TODO are we sure it's the best solution? Use cwait - // use bulk ops? http://stackoverflow.com/questions/16726330/mongoose-mongodb-batch-insert - for (let memberId of membersIds) { - let updateTasksOrderQ = {$push: {}}; - let toSave = []; + let queue = new cwait.TaskQueue(Bluebird, 5); // process only 5 users concurrently - // TODO eslint complaints about having a function inside a loop -> make sure it works - tasks.forEach(chalTask => { // eslint-disable-line no-loop-func - let userTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask))); - userTask.challenge = {taskId: chalTask._id, id: challenge._id}; - userTask.userId = memberId; - - let tasksOrderList = updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`]; - if (!tasksOrderList) { - updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`] = { - $position: 0, // unshift - $each: [userTask._id], - }; - } else { - tasksOrderList.$each.unshift(userTask._id); - } - - toSave.push(userTask.save()); - }); - - // Update the user - toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec()); - await Bluebird.all(toSave); // eslint-disable-line babel/no-await-in-loop - } + await Bluebird.map(membersIds, queue.wrap((memberId) => { + return _addTaskFn(challenge, tasks, memberId); + })); }; // Sync updated task to challenge members From b31de3845bfe9b832819105483782f747c24d48c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 16:43:17 +0200 Subject: [PATCH 853/976] v3: fix adding multiple tasks --- website/client/js/controllers/tasksCtrl.js | 22 +++++++++++---------- website/client/js/services/guideServices.js | 2 +- website/client/js/services/userServices.js | 8 +++++++- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/website/client/js/controllers/tasksCtrl.js b/website/client/js/controllers/tasksCtrl.js index bf4c0714fe..c20dceacad 100644 --- a/website/client/js/controllers/tasksCtrl.js +++ b/website/client/js/controllers/tasksCtrl.js @@ -29,14 +29,18 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'score task','taskType':task.type,'direction':direction}); }; - function addTask(addTo, listDef, task) { - var newTask = { - text: task, - type: listDef.type, - tags: _.keys(User.user.filters), - }; + function addTask(addTo, listDef, tasks) { + tasks = _.isArray(tasks) ? tasks : [tasks]; - User.addTask({body: newTask}); + User.addTask({ + body: tasks.map(function (task) { + return { + text: task, + type: listDef.type, + tags: _.keys(User.user.filters), + } + }), + }); } $scope.addTask = function(addTo, listDef) { @@ -44,9 +48,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N var tasks = listDef.newTask.split(/[\n\r]+/); //Reverse the order of tasks so the tasks will appear in the order the user entered them tasks.reverse(); - _.each(tasks, function(t) { - addTask(addTo, listDef, t); - }); + addTask(addTo, listDef, tasks); listDef.bulk = false; } else { addTask(addTo, listDef, listDef.newTask); diff --git a/website/client/js/services/guideServices.js b/website/client/js/services/guideServices.js index ee0be9d2c8..08b51732c8 100644 --- a/website/client/js/services/guideServices.js +++ b/website/client/js/services/guideServices.js @@ -241,7 +241,7 @@ function($rootScope, User, $timeout, $state, Analytics) { }); var goto = function(chapter, page, force) { - if (chapter == 'intro') User.set({'flags.welcomed': true}); + if (chapter == 'intro' && User.user.flags.welcomed != true) User.set({'flags.welcomed': true}); if (page === -1) page = 0; var curr = User.user.flags.tour[chapter]; if (page != curr+1 && !force) return; diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 32540db6c4..206052c8ad 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -170,7 +170,13 @@ angular.module('habitrpg') }, addTask: function (data) { - user.ops.addTask(data); + if (_.isArray(data.body)) { + data.body.forEach(function (task) { + user.ops.addTask({body: task}); + }); + } else { + user.ops.addTask(data); + } save(); Tasks.createUserTasks(data.body); }, From 6bd893d4975e78b1f6a71110ee2403c67bcab2c0 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 15 May 2016 09:44:23 -0500 Subject: [PATCH 854/976] Fixed join/leave button updates --- website/client/js/controllers/challengesCtrl.js | 3 +++ website/views/options/social/challenges.jade | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index ef919d590b..f92c00efaa 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -269,6 +269,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.join = function (challenge) { Challenges.joinChallenge(challenge._id) .then(function (response) { + User.user.challenges.push(challenge._id); _getChallenges() }); } @@ -279,6 +280,8 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', } else { Challenges.leaveChallenge($scope.selectedChal._id, keep) .then(function (response) { + var index = User.user.challenges.indexOf($scope.selectedChal._id); + delete User.user.challenges[index]; _getChallenges() }); } diff --git a/website/views/options/social/challenges.jade b/website/views/options/social/challenges.jade index a5e2fa16cf..41afe12235 100644 --- a/website/views/options/social/challenges.jade +++ b/website/views/options/social/challenges.jade @@ -199,10 +199,10 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') p!=env.t('prizeValue', {gemcount: "{{challenge.prize}}", gemicon: ""}) li.bg-transparent // leave / join - a.btn.btn-sm.btn-danger(ng-show='::isUserMemberOf(challenge)', ng-click='clickLeave(challenge, $event)') + a.btn.btn-sm.btn-danger(ng-show='isUserMemberOf(challenge)', ng-click='clickLeave(challenge, $event)') span.glyphicon.glyphicon-ban-circle =env.t('leave') - a.btn.btn-sm.btn-success(ng-hide='::isUserMemberOf(challenge)', ng-click='join(challenge)') + a.btn.btn-sm.btn-success(ng-hide='isUserMemberOf(challenge)', ng-click='join(challenge)') span.glyphicon.glyphicon-ok =env.t('join') a.accordion-toggle(id="{{challenge._id}}" ng-click='toggle(challenge._id)') From 400e434f1c9ad1bb1b63cf93672ff3bd568ad976 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 15 May 2016 09:50:30 -0500 Subject: [PATCH 855/976] Queried only user groups to be available when creating challenges --- website/client/js/controllers/challengesCtrl.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index f92c00efaa..a91f8423d2 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -11,9 +11,10 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', // FIXME $scope.challenges needs to be resolved first (see app.js) $scope.groups = []; - Groups.Group.getGroups('party,publicGuilds,privateGuilds,tavern') + Groups.Group.getGroups('party,guilds') .then(function (response) { $scope.groups = response.data.data; + console.log($scope.groups) }); // override score() for tasks listed in challenges-editing pages, so that nothing happens From fcc02b16c6160b9815ce3580c48fad095a36252b Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 15 May 2016 10:03:34 -0500 Subject: [PATCH 856/976] Fixed bulk add tasks to challenge --- .../client/js/controllers/challengesCtrl.js | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index a91f8423d2..a58e842b70 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -14,7 +14,6 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', Groups.Group.getGroups('party,guilds') .then(function (response) { $scope.groups = response.data.data; - console.log($scope.groups) }); // override score() for tasks listed in challenges-editing pages, so that nothing happens @@ -240,7 +239,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', //------------------------------------------------------------ // Tasks //------------------------------------------------------------ - $scope.addTask = function(addTo, listDef, challenge) { + function addTask (addTo, listDef, challenge) { var task = Shared.taskDefaults({text: listDef.newTask, type: listDef.type}); //If the challenge has not been created, we bulk add tasks on save if (challenge._id) Tasks.createChallengeTasks(challenge._id, task); @@ -249,6 +248,21 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', delete listDef.newTask; }; + $scope.addTask = function(addTo, listDef, challenge) { + if (listDef.bulk) { + var tasks = listDef.newTask.split(/[\n\r]+/); + //Reverse the order of tasks so the tasks will appear in the order the user entered them + tasks.reverse(); + _.each(tasks, function(t) { + listDef.newTask = t; + addTask(addTo, listDef, challenge); + }); + listDef.bulk = false; + } else { + addTask(addTo, listDef, challenge); + } + } + $scope.removeTask = function(task, challenge) { if (!confirm(window.env.t('sureDelete', {taskType: window.env.t(task.type), taskText: task.text}))) return; Tasks.deleteTask(task._id); @@ -261,6 +275,14 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', // TODO persist } + $scope.toggleBulk = function(list) { + if (typeof list.bulk === 'undefined') { + list.bulk = false; + } + list.bulk = !list.bulk; + list.focus = true; + }; + /* -------------------------- Subscription From bda90fad68c6b6c4553e1b5c5fafd6dadca96752 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 15 May 2016 10:14:29 -0500 Subject: [PATCH 857/976] Synced challenge tasks after leave and join. --- .../client/js/controllers/challengesCtrl.js | 14 +++++++++++-- website/client/js/services/userServices.js | 21 ++++++++++++------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index a58e842b70..35e815f817 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -293,7 +293,12 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', Challenges.joinChallenge(challenge._id) .then(function (response) { User.user.challenges.push(challenge._id); - _getChallenges() + _getChallenges(); + return Tasks.getUserTasks(); + }) + .then(function (response) { + var tasks = response.data.data; + User.syncUserTasks(tasks); }); } @@ -305,7 +310,12 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', .then(function (response) { var index = User.user.challenges.indexOf($scope.selectedChal._id); delete User.user.challenges[index]; - _getChallenges() + _getChallenges(); + return Tasks.getUserTasks(); + }) + .then(function (response) { + var tasks = response.data.data; + User.syncUserTasks(tasks); }); } $scope.popoverEl.popover('destroy'); diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 32540db6c4..80c9336ac7 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -45,6 +45,16 @@ angular.module('habitrpg') user._wrapped = false; + function syncUserTasks (tasks) { + user.habits = []; + user.todos = []; + user.dailys = []; + user.rewards = []; + tasks.forEach(function (element, index, array) { + user[element.type + 's'].push(element) + }); + } + function sync() { return $http({ method: "GET", @@ -84,14 +94,7 @@ angular.module('habitrpg') }) .then(function (response) { var tasks = response.data.data; - user.habits = []; - user.todos = []; - user.dailys = []; - user.rewards = []; - tasks.forEach(function (element, index, array) { - user[element.type + 's'].push(element) - }) - + syncUserTasks(tasks); save(); $rootScope.$emit('userSynced'); }); @@ -479,6 +482,8 @@ angular.module('habitrpg') return sync(); }, + syncUserTasks: syncUserTasks, + save: save, settings: settings From a7e71163c05dfd758b4ca124733b589f52cb4c55 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 15 May 2016 10:52:44 -0500 Subject: [PATCH 858/976] Fixed default selected group --- website/client/js/controllers/challengesCtrl.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index 35e815f817..fc81253a33 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -11,7 +11,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', // FIXME $scope.challenges needs to be resolved first (see app.js) $scope.groups = []; - Groups.Group.getGroups('party,guilds') + Groups.Group.getGroups('party,guilds,tavern') .then(function (response) { $scope.groups = response.data.data; }); @@ -40,7 +40,6 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', * Create */ $scope.create = function() { - //If the user has one filter selected, assume that the user wants to default to that group var defaultGroup; //Our filters contain all groups, but we only want groups that have atleast one challenge @@ -49,12 +48,12 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', var filterCount = 0; for ( var i = 0; i < len; i += 1 ) { - if ( $scope.search.group[groupsWithChallenges[i]] == true ) { + if ($scope.search.group[groupsWithChallenges[i]] === true) { filterCount += 1; defaultGroup = groupsWithChallenges[i]; } - if (filterCount > 1) { - defaultGroup = $scope.groups[0]._id + + if (filterCount > 1 && defaultGroup) { break; } } @@ -405,7 +404,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.groupsFilter = _.uniq(_.pluck($scope.challenges, 'group'), function(g) {return g._id}); $scope.search = { - group: _.transform($scope.groups, function(m,g){ m[g._id] = true;}), + group: _.transform($scope.groups, function(m,g) { m[g._id] = true;}), _isMember: "either", _isOwner: "either" }; From 21191fdc4529445c75df9d090bda2204b3545d16 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 15 May 2016 11:19:58 -0500 Subject: [PATCH 859/976] Fixed challenge member info. Fixed challenge winner selection --- website/client/js/app.js | 21 ++++++++++++------- .../client/js/controllers/challengesCtrl.js | 2 +- .../client/js/services/challengeServices.js | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/website/client/js/app.js b/website/client/js/app.js index a128c2ce88..acfe15cdb0 100644 --- a/website/client/js/app.js +++ b/website/client/js/app.js @@ -184,8 +184,8 @@ window.habitrpg = angular.module('habitrpg', url: '/:cid', templateUrl: 'partials/options.social.challenges.detail.html', title: env.t('titleChallenges'), - controller: ['$scope', 'Challenges', '$stateParams', 'Tasks', - function ($scope, Challenges, $stateParams, Tasks) { + controller: ['$scope', 'Challenges', '$stateParams', 'Tasks', 'Members', + function ($scope, Challenges, $stateParams, Tasks, Members) { Challenges.getChallenge($stateParams.cid) .then(function (response) { $scope.obj = $scope.challenge = response.data.data; @@ -198,6 +198,11 @@ window.habitrpg = angular.module('habitrpg', if (!$scope.challenge[element.type + 's']) $scope.challenge[element.type + 's'] = []; $scope.challenge[element.type + 's'].push(element); }) + + return Members.getChallengeMembers($scope.challenge._id); + }) + .then(function (response) { + $scope.challenge.members = response.data.data; }); }] }) @@ -226,11 +231,13 @@ window.habitrpg = angular.module('habitrpg', url: '/:uid', templateUrl: 'partials/options.social.challenges.detail.member.html', title: env.t('titleChallenges'), - controller: ['$scope', 'Challenges', '$stateParams', - function($scope, Challenges, $stateParams){ - $scope.obj = Challenges.Challenge.getMember({cid:$stateParams.cid, uid:$stateParams.uid}, function(){ - $scope.obj._locked = true; - }); + controller: ['$scope', 'Members', '$stateParams', + function($scope, Members, $stateParams){ + Members.getChallengeMemberProgress($stateParams.cid, $stateParams.uid) + .then(function(response) { + $scope.obj = response.data.data; + $scope.obj._locked = true; + }); }] }) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index fc81253a33..8e14e6255f 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -199,7 +199,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', if (!challenge.winner) return; if (!confirm(window.env.t('youSure'))) return; - Challenges.selectWinner(challenge._id, challenge.winner) + Challenges.selectChallengeWinner(challenge._id, challenge.winner) .then(function (response) { $scope.popoverEl.popover('destroy'); _backToChallenges(); diff --git a/website/client/js/services/challengeServices.js b/website/client/js/services/challengeServices.js index 257aa65fac..a577d5f759 100644 --- a/website/client/js/services/challengeServices.js +++ b/website/client/js/services/challengeServices.js @@ -76,7 +76,7 @@ angular.module('habitrpg') function selectChallengeWinner (challengeId, winnerId) { return $http({ method: 'POST', - url: apiV3Prefix + '/challenges/' + challengeId + 'selectWinner/' + winnerId, + url: apiV3Prefix + '/challenges/' + challengeId + '/selectWinner/' + winnerId, }); } From 2173719e436841ec9afb8b3794b9dd8cb7732859 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 15 May 2016 11:32:47 -0500 Subject: [PATCH 860/976] Fixed deleting challenge tasks --- website/client/js/controllers/challengesCtrl.js | 3 ++- website/views/shared/tasks/meta_controls.jade | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index 8e14e6255f..47b5c5dd6d 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -264,7 +264,8 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.removeTask = function(task, challenge) { if (!confirm(window.env.t('sureDelete', {taskType: window.env.t(task.type), taskText: task.text}))) return; - Tasks.deleteTask(task._id); + //We only pass to the api if the challenge exists, otherwise, the tasks only exist on the client + if (challenge._id) Tasks.deleteTask(task._id); var index = challenge[task.type + 's'].indexOf(task); challenge[task.type + 's'].splice(index, 1); }; diff --git a/website/views/shared/tasks/meta_controls.jade b/website/views/shared/tasks/meta_controls.jade index c080e3deaa..3db265367a 100644 --- a/website/views/shared/tasks/meta_controls.jade +++ b/website/views/shared/tasks/meta_controls.jade @@ -43,7 +43,7 @@ span.glyphicon.glyphicon-bullhorn(tooltip=env.t('challenge')) |   // delete - a(ng-if='!task.challenge.id', ng-click='removeTask(task, $index)', tooltip=env.t('delete')) + a(ng-if='!task.challenge.id', ng-click='removeTask(task, obj)', tooltip=env.t('delete')) span.glyphicon.glyphicon-trash |   From f593add57658265391f9fefe91e02220e30b413c Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 15 May 2016 11:40:45 -0500 Subject: [PATCH 861/976] Fixed particiapting filter --- website/client/js/controllers/challengesCtrl.js | 2 +- website/views/options/social/challenges.jade | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index 47b5c5dd6d..e5c6ae2a09 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -443,7 +443,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', var groupSelected = $scope.search.group[chal.group._id]; var checkOwner = $scope.search._isOwner === 'either' || (userIsOwner === $scope.search._isOwner); - var checkMember = $scope.search._isMember === 'either' || (chal._isMember === $scope.search._isMember); + var checkMember = $scope.search._isMember === 'either' || ($scope.isUserMemberOf(chal) === $scope.search._isMember); return groupSelected && checkOwner && checkMember; } diff --git a/website/views/options/social/challenges.jade b/website/views/options/social/challenges.jade index 41afe12235..ad95a7c992 100644 --- a/website/views/options/social/challenges.jade +++ b/website/views/options/social/challenges.jade @@ -178,7 +178,7 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') // Challenges list .panel-group - .panel.panel-default(ng-repeat='challenge in challenges|filter:filterChallenges track by challenge._id ') + .panel.panel-default(ng-repeat='challenge in challenges | filter:filterChallenges track by challenge._id ') .panel-heading ul.pull-right.challenge-accordion-header-specs li.bg-transparent(ng-if='challenge.official') From 52bedc3563f90b089ce5fdb9aad0fe1bb89daf08 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 21:17:17 +0200 Subject: [PATCH 862/976] v3 client: fix casting spells --- common/script/index.js | 2 +- website/client/js/controllers/rootCtrl.js | 28 +++++++++++++++++++---- website/server/controllers/api-v3/user.js | 9 ++++---- website/server/libs/api-v3/cron.js | 2 +- website/server/libs/api-v3/preening.js | 2 +- 5 files changed, 30 insertions(+), 13 deletions(-) diff --git a/common/script/index.js b/common/script/index.js index 48eff442d8..3b87ac1a7c 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -199,7 +199,7 @@ User (prototype wrapper to give it ops, helper funcs, and virtuals /* User is now wrapped (both on client and server), adding a few new properties: - * getters (_statsComputed, tasks, etc) + * getters (_statsComputed) * user.fns, which is a bunch of helper functions These were originally up above, but they make more sense belonging to the user object so we don't have to pass the user object all over the place. In fact, we should pull in more functions such as cron(), updateStats(), etc. diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index 746cd0f3df..f7eb1f4041 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -281,29 +281,47 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ .then(function (party) { party = (_.isArray(party) ? party : []).concat(User.user); $scope.castEnd(party, 'party'); + }) + .catch(function (party) { // not in a party, act as a solo party + if (party && party.type === 'party') { + party = [User.user]; + $scope.castEnd(party, 'party'); + } }); + } else if (spell.target == 'tasks') { + var tasks = User.user.habits.concat(User.user.dailys).concat(User.user.rewards); + // exclude challenge tasks + tasks = tasks.filter(function (t) { + if (!t.challenge) return true; + return (!task.challenge.id || task.challenge.broken); + }); + $scope.castEnd(tasks, 'tasks'); } } $scope.castEnd = function(target, type, $event){ if (!$rootScope.applyingAction) return 'No applying action'; $event && ($event.stopPropagation(),$event.preventDefault()); + if ($scope.spell.target != type) return Notification.text(window.env.t('invalidTarget')); $scope.spell.cast(User.user, target); User.save(); var spell = $scope.spell; - var targetId = target._id; + var targetId = target ? target._id : null; $scope.spell = null; $rootScope.applyingAction = false; - $http.post(ApiUrl.get() + '/api/v3/user/class/cast/'+spell.key+'?targetType='+type+'&targetId='+targetId) + var spellUrl = ApiUrl.get() + '/api/v3/user/class/cast/' + spell.key; + if (targetId) spellUrl += '?targetId=' + targetId; + + $http.post(spellUrl) .success(function(){ var msg = window.env.t('youCast', {spell: spell.text()}); switch (type) { - case 'task': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.text});break; - case 'user': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.profile.name});break; - case 'party': msg = window.env.t('youCastParty', {spell: spell.text()});break; + case 'task': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.text});break; + case 'user': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.profile.name});break; + case 'party': msg = window.env.t('youCastParty', {spell: spell.text()});break; } Notification.markdown(msg); User.sync(); diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 061541e954..8880b1587d 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -365,13 +365,12 @@ api.castSpell = { spell.cast(user, null, req); await user.save(); res.respond(200, user); - } else if (targetType === 'tasks') { // new target type when all the user's tasks are necessary + } else if (targetType === 'tasks') { // new target type in v3: when all the user's tasks are necessary let tasks = await Tasks.Task.find({ userId: user._id, - 'challenge.id': {$exists: false}, // exclude challenge tasks - $or: [ // Exclude completed todos - {type: 'todo', completed: false}, - {type: {$in: ['habit', 'daily', 'reward']}}, + $or: [ // exclude challenge tasks + {'challenge.id': {$exists: false}}, + {'challenge.broken': {$exists: true}}, ], }).exec(); diff --git a/website/server/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js index b4b16d5c6e..c86dfad2ca 100644 --- a/website/server/libs/api-v3/cron.js +++ b/website/server/libs/api-v3/cron.js @@ -185,7 +185,7 @@ export function cron (options = {}) { task.completed = false; if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = false); // FIXME this should not happen for grey tasks unless they are completed + task.checklist.forEach(i => i.completed = false); // TODO this should not happen for grey tasks unless they are completed } }); diff --git a/website/server/libs/api-v3/preening.js b/website/server/libs/api-v3/preening.js index 2f1f0ad308..00be142299 100644 --- a/website/server/libs/api-v3/preening.js +++ b/website/server/libs/api-v3/preening.js @@ -54,7 +54,7 @@ export function preenHistory (history, isSubscribed, timezoneOffset) { return newHistory; } -// Preen history for users and tasks. This code runs only on the server. +// Preen history for users and tasks. export function preenUserHistory (user, tasksByType) { let isSubscribed = user.isSubscribed(); let timezoneOffset = user.preferences.timezoneOffset; From 1a14a6f4b7f7ef68de0fe9b923a76e9051bb9faf Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 22:20:10 +0200 Subject: [PATCH 863/976] v3: do not log sensitive data --- website/server/middlewares/api-v3/errorHandler.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/website/server/middlewares/api-v3/errorHandler.js b/website/server/middlewares/api-v3/errorHandler.js index 027e25301b..11d75042e3 100644 --- a/website/server/middlewares/api-v3/errorHandler.js +++ b/website/server/middlewares/api-v3/errorHandler.js @@ -6,12 +6,15 @@ import { BadRequest, InternalServerError, } from '../../libs/api-v3/errors'; -import { map } from 'lodash'; +import { + map, + omit, +} from 'lodash'; module.exports = function errorHandler (err, req, res, next) { // eslint-disable-line no-unused-vars logger.error(err, { originalUrl: req.originalUrl, - headers: req.headers, + headers: omit(req.headers, ['x-api-key']), body: req.body, }); From c1c77b68b4d73194d75aba55c96141b438ce4f0b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 22:30:04 +0200 Subject: [PATCH 864/976] v3: always save user when casting spell --- website/server/controllers/api-v3/user.js | 34 +++++++++++++---------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 8880b1587d..a92e734612 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -351,16 +351,13 @@ api.castSpell = { if (task.challenge.id) throw new BadRequest(res.t('challengeTasksNoCast')); spell.cast(user, task, req); - if (user.isModified()) { - await Bluebird.all([ - user.save(), - task.save(), - ]); - } else { - await task.save(); - } - res.respond(200, task); + let results = await Bluebird.all([ + user.save(), + task.save(), + ]); + + res.respond(200, results[0]); } else if (targetType === 'self') { spell.cast(user, null, req); await user.save(); @@ -377,15 +374,14 @@ api.castSpell = { spell.cast(user, tasks, req); let toSave = tasks.filter(t => t.isModified()); - let isUserModified = user.isModified(); - - if (isUserModified) toSave.unshift(user.save()); + toSave.unshift(user.save()); let saved = await Bluebird.all(toSave); let response = { - tasks: isUserModified ? _.rest(saved) : saved, + tasks: saved, + user, }; - if (isUserModified) response.user = user; + res.respond(200, response); } else if (targetType === 'party' || targetType === 'user') { let party = await Group.getGroup({groupId: 'party', user}); @@ -396,7 +392,12 @@ api.castSpell = { if (!party) { partyMembers = [user]; // Act as solo party } else { - partyMembers = await User.find({'party._id': party._id}).select(partyMembersFields).exec(); + partyMembers = await User.find({ + 'party._id': party._id, + _id: { $ne: user._id }, // add separately + }).select(partyMembersFields).exec(); + + partyMembers.unshift(user); } spell.cast(user, partyMembers, req); @@ -411,7 +412,9 @@ api.castSpell = { } if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); + spell.cast(user, partyMembers, req); + if (user.isModified()) { await Bluebird.all([ user.save(), @@ -421,6 +424,7 @@ api.castSpell = { await partyMembers.save(); } } + res.respond(200, partyMembers); if (party && !spell.silent) { From fff65f5ddacfdb6978ea1e3d8a264f9ba450325d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 22:32:16 +0200 Subject: [PATCH 865/976] v3: always save user when casting spell --- website/server/controllers/api-v3/user.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index a92e734612..8e2ef1be48 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -415,13 +415,13 @@ api.castSpell = { spell.cast(user, partyMembers, req); - if (user.isModified()) { + if (partyMembers !== user) { await Bluebird.all([ user.save(), partyMembers.save(), ]); } else { - await partyMembers.save(); + await partyMembers.save(); // partyMembers is user } } From faaa52035d95df366e10575721a6c3bffb17d362 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 22:37:08 +0200 Subject: [PATCH 866/976] v3: more fixes for spells --- website/server/controllers/api-v3/user.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 8e2ef1be48..6307b57293 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -313,7 +313,7 @@ const partyMembersFields = 'profile.name stats achievements items.special'; * @apiParam {string} spellId The spell to cast. * @apiParam {UUID} targetId Optional query parameter, the id of the target when casting a spell on a party member or a task. * - * @apiSuccess data Will return the modified targets. For party members only the necessary fields will be populated. + * @apiSuccess data Will return the modified targets. For party members only the necessary fields will be populated. The user is always returned. */ api.castSpell = { method: 'POST', @@ -357,11 +357,14 @@ api.castSpell = { task.save(), ]); - res.respond(200, results[0]); + res.respond(200, { + user: results[0], + task: results[1], + }); } else if (targetType === 'self') { spell.cast(user, null, req); await user.save(); - res.respond(200, user); + res.respond(200, { user }); } else if (targetType === 'tasks') { // new target type in v3: when all the user's tasks are necessary let tasks = await Tasks.Task.find({ userId: user._id, @@ -425,7 +428,10 @@ api.castSpell = { } } - res.respond(200, partyMembers); + res.respond(200, { + partyMembers: Array.isArray(partyMembers) ? partyMembers : [partyMembers], + user, + }); if (party && !spell.silent) { let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``; From 4ceab38d0b7bc9026ad7548b87aaf771971405d9 Mon Sep 17 00:00:00 2001 From: Alys Date: Mon, 16 May 2016 06:37:31 +1000 Subject: [PATCH 867/976] fix typos and missing information in apidocs - fixes https://github.com/HabitRPG/habitrpg/issues/7277 (#7282) --- website/server/controllers/api-v3/auth.js | 4 ++-- website/server/controllers/api-v3/modelsPaths.js | 2 +- website/server/controllers/api-v3/quests.js | 2 +- website/server/controllers/api-v3/tasks.js | 2 +- website/server/controllers/api-v3/user.js | 14 +++++++------- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/website/server/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js index 7295cce9b7..f8eabf4c82 100644 --- a/website/server/controllers/api-v3/auth.js +++ b/website/server/controllers/api-v3/auth.js @@ -373,7 +373,7 @@ api.updatePassword = { }; /** - * @api {post} /api/v3/user/reset-password Reser password + * @api {post} /api/v3/user/reset-password Reset password * @apiDescription Reset the user password * @apiVersion 3.0.0 * @apiName ResetPassword @@ -427,7 +427,7 @@ api.resetPassword = { /** * @api {put} /api/v3/user/auth/update-email Update email - * @apiDescription Che the user email + * @apiDescription Change the user email address * @apiVersion 3.0.0 * @apiName UpdateEmail * @apiGroup User diff --git a/website/server/controllers/api-v3/modelsPaths.js b/website/server/controllers/api-v3/modelsPaths.js index b14d88b2b7..dc168b98b4 100644 --- a/website/server/controllers/api-v3/modelsPaths.js +++ b/website/server/controllers/api-v3/modelsPaths.js @@ -6,7 +6,7 @@ let tasksModels = ['habit', 'daily', 'todo', 'reward']; let allModels = ['user', 'tag', 'challenge', 'group'].concat(tasksModels); /** - * @api {get} /api/v3s/models/:model/paths Get all paths for the specified model. + * @api {get} /api/v3/models/:model/paths Get all paths for the specified model. * @apiDescription Doesn't require authentication * @apiVersion 3.0.0 * @apiName GetUserModelPaths diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js index 832e90915a..9107fda8d6 100644 --- a/website/server/controllers/api-v3/quests.js +++ b/website/server/controllers/api-v3/quests.js @@ -249,7 +249,7 @@ api.rejectQuest = { /** - * @api {post} /api/v3/groups/:groupId/quests/force-start Accept a pending quest + * @api {post} /api/v3/groups/:groupId/quests/force-start Force-start a pending quest * @apiVersion 3.0.0 * @apiName ForceQuestStart * @apiGroup Group diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index a6a25fd7ff..3ed454c666 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -54,7 +54,7 @@ async function _createTasks (req, res, user, challenge) { } /** - * @api {post} /api/v3/tasks/user Create a new task the user. + * @api {post} /api/v3/tasks/user Create a new task belonging to the user. * @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 * @apiName CreateUserTasks diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 6307b57293..d13b853176 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -177,7 +177,7 @@ api.updateUser = { }; /** - * @api {delete} /api/v3/user DELETE an authenticated user's account + * @api {delete} /api/v3/user Delete an authenticated user's account * @apiVersion 3.0.0 * @apiName UserDelete * @apiGroup User @@ -240,7 +240,7 @@ function _cleanChecklist (task) { } /** - * @api {get} /api/v3/user/anonymized + * @api {get} /api/v3/user/anonymized Get anonymized user data * @apiVersion 3.0.0 * @apiName UserGetAnonymized * @apiGroup User @@ -886,12 +886,12 @@ api.userOpenMysteryItem = { }; /* -* @api {post} /api/v3/user/webhook +* @api {post} /api/v3/user/webhook Create a new webhook * @apiVersion 3.0.0 * @apiName UserAddWebhook * @apiGroup User * -* @apiParam {string} url Body parameter - The webhook's urò +* @apiParam {string} url Body parameter - The webhook's URL * @apiParam {boolean} enabled Body parameter - If the webhook should be enabled * * @apiSuccess {Object} data The created webhook @@ -909,13 +909,13 @@ api.addWebhook = { }; /* -* @api {put} /api/v3/user/webhook/:id +* @api {put} /api/v3/user/webhook/:id Edit a webhook * @apiVersion 3.0.0 * @apiName UserUpdateWebhook * @apiGroup User * * @apiParam {UUID} id The id of the webhook to update -* @apiParam {string} url Body parameter - The webhook's urò +* @apiParam {string} url Body parameter - The webhook's URL * @apiParam {boolean} enabled Body parameter - If the webhook should be enabled * * @apiSuccess {Object} data The updated webhook @@ -933,7 +933,7 @@ api.updateWebhook = { }; /* -* @api {delete} /api/v3/user/webhook/:id +* @api {delete} /api/v3/user/webhook/:id Delete a webhook * @apiVersion 3.0.0 * @apiName UserDeleteWebhook * @apiGroup User From 93546f65465311fc3e33703ca972c8463736820e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 15 May 2016 22:55:06 +0200 Subject: [PATCH 868/976] v3: add TODO for client side spells --- website/client/js/controllers/rootCtrl.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index f7eb1f4041..01b55b8ea7 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -316,7 +316,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ if (targetId) spellUrl += '?targetId=' + targetId; $http.post(spellUrl) - .success(function(){ + .success(function(){ // TODO response will always include the modified data, no need to sync! var msg = window.env.t('youCast', {spell: spell.text()}); switch (type) { case 'task': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.text});break; From 21203c3f2e885105e650fe09c7a633cca98c9cdc Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 15 May 2016 16:07:30 -0500 Subject: [PATCH 869/976] feat: Add modify inventory debug menu --- common/script/content/index.js | 8 + .../debug/POST-debug_modify-inventory.test.js | 160 ++++++++++++ website/client/js/controllers/footerCtrl.js | 44 ++++ website/server/controllers/api-v3/debug.js | 40 +++ website/views/shared/footer.jade | 1 + website/views/shared/modals/index.jade | 1 + .../views/shared/modals/modify-inventory.jade | 244 ++++++++++++++++++ 7 files changed, 498 insertions(+) create mode 100644 test/api/v3/integration/debug/POST-debug_modify-inventory.test.js create mode 100644 website/views/shared/modals/modify-inventory.jade diff --git a/common/script/content/index.js b/common/script/content/index.js index 72415dc14f..d59da2aaea 100644 --- a/common/script/content/index.js +++ b/common/script/content/index.js @@ -594,6 +594,14 @@ api.questMounts = _.transform(api.questEggs, function(m, egg) { })); }); +api.premiumMounts = _.transform(api.dropEggs, function(m, egg) { + return _.defaults(m, _.transform(api.hatchingPotions, function(m2, pot) { + if (pot.premium) { + return m2[egg.key + "-" + pot.key] = true; + } + })); +}); + api.food = { Meat: { text: t('foodMeat'), diff --git a/test/api/v3/integration/debug/POST-debug_modify-inventory.test.js b/test/api/v3/integration/debug/POST-debug_modify-inventory.test.js new file mode 100644 index 0000000000..93f9081492 --- /dev/null +++ b/test/api/v3/integration/debug/POST-debug_modify-inventory.test.js @@ -0,0 +1,160 @@ +/* eslint-disable camelcase */ + +import nconf from 'nconf'; +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /debug/modify-inventory', () => { + let user, originalItems; + + before(async () => { + originalItems = { + gear: { owned: { armor_base_0: true } }, + special: { + snowball: 1, + }, + pets: { + 'Wolf-Desert': 5, + }, + mounts: { + 'Wolf-Desert': true, + }, + eggs: { + Wolf: 5, + }, + hatchingPotions: { + Desert: 5, + }, + food: { + Watermelon: 5, + }, + quests: { + gryphon: 5, + }, + }; + user = await generateUser({ + items: originalItems, + }); + }); + + afterEach(() => { + nconf.set('IS_PROD', false); + }); + + it('sets equipment', async () => { + let gear = { + weapon_healer_2: true, + weapon_wizard_1: true, + weapon_special_critical: true, + }; + + await user.post('/debug/modify-inventory', { + gear, + }); + + await user.sync(); + + expect(user.items.gear.owned).to.eql(gear); + }); + + it('sets special spells', async () => { + let special = { + shinySeed: 3, + }; + + await user.post('/debug/modify-inventory', { + special, + }); + + await user.sync(); + + expect(user.items.special).to.eql(special); + }); + + it('sets mounts', async () => { + let mounts = { + 'Orca-Base': true, + 'Mammoth-Base': true, + }; + + await user.post('/debug/modify-inventory', { + mounts, + }); + + await user.sync(); + + expect(user.items.mounts).to.eql(mounts); + }); + + it('sets eggs', async () => { + let eggs = { + Gryphon: 3, + Hedgehog: 7, + }; + + await user.post('/debug/modify-inventory', { + eggs, + }); + + await user.sync(); + + expect(user.items.eggs).to.eql(eggs); + }); + + it('sets hatching potions', async () => { + let hatchingPotions = { + White: 7, + Spooky: 2, + }; + + await user.post('/debug/modify-inventory', { + hatchingPotions, + }); + + await user.sync(); + + expect(user.items.hatchingPotions).to.eql(hatchingPotions); + }); + + it('sets food', async () => { + let food = { + Meat: 5, + Candy_Red: 7, + }; + + await user.post('/debug/modify-inventory', { + food, + }); + + await user.sync(); + + expect(user.items.food).to.eql(food); + }); + + it('sets quests', async () => { + let quests = { + whale: 5, + cheetah: 10, + }; + + await user.post('/debug/modify-inventory', { + quests, + }); + + await user.sync(); + + expect(user.items.quests).to.eql(quests); + }); + + it('returns error when not in production mode', async () => { + nconf.set('IS_PROD', true); + + await expect(user.post('/debug/modify-inventory')) + .eventually.be.rejected.and.to.deep.equal({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); +}); diff --git a/website/client/js/controllers/footerCtrl.js b/website/client/js/controllers/footerCtrl.js index bca27a04d8..b02ffe0263 100644 --- a/website/client/js/controllers/footerCtrl.js +++ b/website/client/js/controllers/footerCtrl.js @@ -128,5 +128,49 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { $scope.makeAdmin = function () { User.makeAdmin(); }; + + $scope.openModifyInventoryModal = function () { + $rootScope.openModal('modify-inventory', {controller: 'FooterCtrl', scope: $scope }); + $scope.showInv = { }; + $scope.inv = { + gear: {}, + special: {}, + pets: {}, + mounts: {}, + eggs: {}, + hatchingPotions: {}, + food: {}, + quests: {}, + }; + $scope.setAllItems = function (type, value) { + var set = $scope.inv[type]; + + for (var item in set) { + if (set.hasOwnProperty(item)) { + set[item] = value; + } + } + }; + }; + + $scope.modifyInventory = function () { + $http({ + method: "POST", + url: 'api/v3/debug/modify-inventory', + data: { + gear: $scope.showInv.gear ? $scope.inv.gear : null, + special: $scope.showInv.special ? $scope.inv.special : null, + pets: $scope.showInv.pets ? $scope.inv.pets : null, + mounts: $scope.showInv.mounts ? $scope.inv.mounts : null, + eggs: $scope.showInv.eggs ? $scope.inv.eggs : null, + hatchingPotions: $scope.showInv.hatchingPotions ? $scope.inv.hatchingPotions : null, + food: $scope.showInv.food ? $scope.inv.food : null, + quests: $scope.showInv.quests ? $scope.inv.quests : null, + } + }) + .then(function (response) { + Notification.text('Inventory updated. Refresh or sync.'); + }) + }; } }]) diff --git a/website/server/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js index 8824414be5..8c79218a8e 100644 --- a/website/server/controllers/api-v3/debug.js +++ b/website/server/controllers/api-v3/debug.js @@ -101,4 +101,44 @@ api.setCron = { // }, // }; +/** + * @api {post} /api/v3/debug/modify-inventory Manipulate user's inventory + * @apiDescription Only available in development mode. + * @apiVersion 3.0.0 + * @apiName modifyInventory + * @apiGroup Development + * + * @apiSuccess {Object} data An empty Object + */ +api.modifyInventory = { + method: 'POST', + url: '/debug/modify-inventory', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + let { gear } = req.body; + + if (gear) { + user.items.gear.owned = gear; + } + + [ + 'special', + 'pets', + 'mounts', + 'eggs', + 'hatchingPotions', + 'food', + 'quests', + ].forEach((type) => { + if (req.body[type]) { + user.items[type] = req.body[type]; + } + }); + + await user.save(); + + res.respond(200, {}); + }, +}; module.exports = api; diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index cc94250844..9c67194c85 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -94,6 +94,7 @@ footer.footer(ng-controller='FooterCtrl') a.btn.btn-default(ng-click='addBossQuestProgressUp()') +1000 Boss Quest Progress Up // TODO Re-enable after v3 prod testing // a.btn.btn-default(ng-click='makeAdmin()') Make Admin + a.btn.btn-default(ng-click='openModifyInventoryModal()') Modify Inventory div(ng-init='deferredScripts()') diff --git a/website/views/shared/modals/index.jade b/website/views/shared/modals/index.jade index a57ee62189..c492e09ee1 100644 --- a/website/views/shared/modals/index.jade +++ b/website/views/shared/modals/index.jade @@ -19,6 +19,7 @@ include ./level-up.jade include ./hatch-pet.jade include ./raise-pet.jade include ./won-challenge.jade +include ./modify-inventory.jade //- Settings script(type='text/ng-template', id='modals/change-day-start.html') diff --git a/website/views/shared/modals/modify-inventory.jade b/website/views/shared/modals/modify-inventory.jade new file mode 100644 index 0000000000..a7a87a2de2 --- /dev/null +++ b/website/views/shared/modals/modify-inventory.jade @@ -0,0 +1,244 @@ +script(type='text/ng-template', id='modals/modify-inventory.html') + .modal-header + h4 Modify Inventory for {{::user.profile.name}} + .modal-body + .container-fluid + .row + .col-xs-12 + button.btn.btn-default.pull-right(ng-if="!showInv.gear", ng-click="showInv.gear = true") Show Gear + h4 Gear + div(ng-if="showInv.gear") + button.btn.btn-default(ng-click="setAllItems('gear', true)") Own All + button.btn.btn-default(ng-click="setAllItems('gear', false)") Previously Own All + button.btn.btn-default(ng-click="setAllItems('gear', undefined)") Never Own All + + hr + + ul.list-group + li.list-group-item(ng-repeat="item in Content.gear.flat" ng-init="inv.gear[item.key] = user.items.gear.owned[item.key]") + .pull-left(class="shop_{{::item.key}}" style="margin-right: 10px") + | {{::item.text()}} + + .clearfix + label.radio-inline + input(type="radio" name="gear-{{::item.key}}" ng-model="inv.gear[item.key]" ng-value="true") + | Owned + label.radio-inline + input(type="radio" name="gear-{{::item.key}}" ng-model="inv.gear[item.key]" ng-value="false") + | Previously Owned + label.radio-inline + input(type="radio" name="gear-{{::item.key}}" ng-model="inv.gear[item.key]" ng-value="undefined") + | Never Owned + + hr + + .row + .col-xs-12 + button.btn.btn-default.pull-right(ng-if="!showInv.special", ng-click="showInv.special = true") Show Special Items + h4 Special Items + div(ng-if="showInv.special") + button.btn.btn-default(ng-click="setAllItems('special', 999)") Set All to 999 + button.btn.btn-default(ng-click="setAllItems('special', 0)") Set All to 0 + button.btn.btn-default(ng-click="setAllItems('special', undefined)") Set All to undefined + + hr + + ul.list-group + li.list-group-item(ng-repeat="item in Content.special" ng-init="inv.special[item.key] = user.items.special[item.key]") + .form-inline.clearfix + .pull-left(class="inventory_special_{{::item.key}}" style="margin-right: 10px") + p {{::item.text()}} + input.form-control(type="number" ng-model="inv.special[item.key]") + + hr + + .row + .col-xs-12 + button.btn.btn-default.pull-right(ng-if="!showInv.pets", ng-click="showInv.pets = true") Show Pets + h4 Pets + div(ng-if="showInv.pets") + button.btn.btn-default(ng-click="setAllItems('pets', 99)") Set All to 99 + button.btn.btn-default(ng-click="setAllItems('pets', 0)") Set All to 0 + button.btn.btn-default(ng-click="setAllItems('pets', -1)") Set All to -1 + button.btn.btn-default(ng-click="setAllItems('pets', undefined)") Set All to undefined + + hr + + h5 Drop Pets + ul.list-group + li.list-group-item(ng-repeat="(pet, value) in Content.pets" ng-init="inv.pets[pet] = user.items.pets[pet]") + .form-inline.clearfix + .pull-left(class="Pet-{{::pet}}" style="margin-right: 10px") + p {{::pet}} + input.form-control(type="number" ng-model="inv.pets[pet]") + + h5 Quest Pets + ul.list-group + li.list-group-item(ng-repeat="(pet, value) in Content.questPets" ng-init="inv.pets[pet] = user.items.pets[pet]") + .form-inline.clearfix + .pull-left(class="Pet-{{::pet}}" style="margin-right: 10px") + p {{::pet}} + input.form-control(type="number" ng-model="inv.pets[pet]") + + h5 Special Pets + ul.list-group + li.list-group-item(ng-repeat="(pet, value) in Content.specialPets" ng-init="inv.pets[pet] = user.items.pets[pet]") + .form-inline.clearfix + .pull-left(class="Pet-{{::pet}}" style="margin-right: 10px") + p {{::pet}} + input.form-control(type="number" ng-model="inv.pets[pet]") + + h5 Premium Pets + ul.list-group + li.list-group-item(ng-repeat="(pet, value) in Content.premiumPets" ng-init="inv.pets[pet] = user.items.pets[pet]") + .form-inline.clearfix + .pull-left(class="Pet-{{::pet}}" style="margin-right: 10px") + p {{::pet}} + input.form-control(type="number" ng-model="inv.pets[pet]") + + hr + + .row + .col-xs-12 + button.btn.btn-default.pull-right(ng-if="!showInv.mounts", ng-click="showInv.mounts = true") Show Mounts + h4 Mounts + div(ng-if="showInv.mounts") + button.btn.btn-default(ng-click="setAllItems('mounts', true)") Set all to Owned + button.btn.btn-default(ng-click="setAllItems('mounts', undefined)") Set all to Not Owned + + hr + + h5 Drop Mounts + ul.list-group + li.list-group-item(ng-repeat="(mount, value) in Content.mounts" ng-init="inv.mounts[mount] = user.items.mounts[mount]") + .pull-left(class="Mount_Icon_{{::mount}}" style="margin-right: 10px") + | {{::mount}} + .clearfix + label.radio-inline + input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="true") + | Owned + label.radio-inline + input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="undefined") + | Not Owned + + h5 Quest Mounts + ul.list-group + li.list-group-item(ng-repeat="(mount, value) in Content.questMounts" ng-init="inv.mounts[mount] = user.items.mounts[mount]") + .pull-left(class="Mount_Icon_{{::mount}}" style="margin-right: 10px") + | {{::mount}} + .clearfix + label.radio-inline + input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="true") + | Owned + label.radio-inline + input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="undefined") + | Not Owned + + h5 Special Mounts + ul.list-group + li.list-group-item(ng-repeat="(mount, value) in Content.specialMounts" ng-init="inv.mounts[mount] = user.items.mounts[mount]") + .pull-left(class="Mount_Icon_{{::mount}}" style="margin-right: 10px") + | {{::mount}} + .clearfix + label.radio-inline + input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="true") + | Owned + label.radio-inline + input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="undefined") + | Not Owned + + h5 Premium Mounts + ul.list-group + li.list-group-item(ng-repeat="(mount, value) in Content.premiumMounts" ng-init="inv.mounts[mount] = user.items.mounts[mount]") + .pull-left(class="Mount_Icon_{{::mount}}" style="margin-right: 10px") + | {{::mount}} + .clearfix + label.radio-inline + input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="true") + | Owned + label.radio-inline + input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="undefined") + | Not Owned + + hr + + .row + .col-xs-12 + button.btn.btn-default.pull-right(ng-if="!showInv.hatchingPotions", ng-click="showInv.hatchingPotions = true") Show Hatching Potions + h4 Hatching Potions + div(ng-if="showInv.hatchingPotions") + button.btn.btn-default(ng-click="setAllItems('hatchingPotions', 999)") Set All to 999 + button.btn.btn-default(ng-click="setAllItems('hatchingPotions', 0)") Set All to 0 + button.btn.btn-default(ng-click="setAllItems('hatchingPotions', undefined)") Set All to undefined + + hr + + ul.list-group + li.list-group-item(ng-repeat="item in Content.hatchingPotions" ng-init="inv.hatchingPotions[item.key] = user.items.hatchingPotions[item.key]") + .form-inline.clearfix + .pull-left(class="Pet_HatchingPotion_{{::item.key}}" style="margin-right: 10px") + p {{::item.text()}} + input.form-control(type="number" ng-model="inv.hatchingPotions[item.key]") + + hr + + .row + .col-xs-12 + button.btn.btn-default.pull-right(ng-if="!showInv.eggs", ng-click="showInv.eggs = true") Show Eggs + h4 Eggs + div(ng-if="showInv.eggs") + button.btn.btn-default(ng-click="setAllItems('eggs', 999)") Set All to 999 + button.btn.btn-default(ng-click="setAllItems('eggs', 0)") Set All to 0 + button.btn.btn-default(ng-click="setAllItems('eggs', undefined)") Set All to undefined + + hr + + ul.list-group + li.list-group-item(ng-repeat="item in Content.eggs" ng-init="inv.eggs[item.key] = user.items.eggs[item.key]") + .form-inline.clearfix + .pull-left(class="Pet_Egg_{{::item.key}}" style="margin-right: 10px") + p {{::item.text()}} + input.form-control(type="number" ng-model="inv.eggs[item.key]") + + hr + + .row + .col-xs-12 + button.btn.btn-default.pull-right(ng-if="!showInv.food", ng-click="showInv.food = true") Show Food + h4 Food + div(ng-if="showInv.food") + button.btn.btn-default(ng-click="setAllItems('food', 999)") Set All to 999 + button.btn.btn-default(ng-click="setAllItems('food', 0)") Set All to 0 + button.btn.btn-default(ng-click="setAllItems('food', undefined)") Set All to undefined + + hr + + ul.list-group + li.list-group-item(ng-repeat="item in Content.food" ng-init="inv.food[item.key] = user.items.food[item.key]") + .form-inline.clearfix + .pull-left(class="Pet_Food_{{::item.key}}" style="margin-right: 10px") + p {{::item.text()}} + input.form-control(type="number" ng-model="inv.food[item.key]") + + hr + + .row + .col-xs-12 + button.btn.btn-default.pull-right(ng-if="!showInv.quests", ng-click="showInv.quests = true") Show Quests + h4 Quests + div(ng-if="showInv.quests") + button.btn.btn-default(ng-click="setAllItems('quests', 999)") Set All to 999 + button.btn.btn-default(ng-click="setAllItems('quests', 0)") Set All to 0 + button.btn.btn-default(ng-click="setAllItems('quests', undefined)") Set All to undefined + + hr + + ul.list-group + li.list-group-item(ng-repeat="item in Content.quests" ng-init="inv.quests[item.key] = user.items.quests[item.key]" ng-if="item.category !== 'world'") + .form-inline.clearfix + .pull-left(class="inventory_quest_scroll_{{::item.key}}" style="margin-right: 10px") + p {{::item.text()}} + input.form-control(type="number" ng-model="inv.quests[item.key]") + .modal-footer + button.btn.btn-default(ng-click="$close()")=env.t('close') + button.btn.btn-primary(ng-click="$close();modifyInventory()") Apply Changes From 20642c9ddeb01a8ddbc055ece0a62a924aa94875 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 15 May 2016 18:19:00 -0500 Subject: [PATCH 870/976] Fixed viewing user progress on challenge --- website/client/js/app.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/website/client/js/app.js b/website/client/js/app.js index acfe15cdb0..672ef32f09 100644 --- a/website/client/js/app.js +++ b/website/client/js/app.js @@ -236,6 +236,15 @@ window.habitrpg = angular.module('habitrpg', Members.getChallengeMemberProgress($stateParams.cid, $stateParams.uid) .then(function(response) { $scope.obj = response.data.data; + + $scope.obj.habits = []; + $scope.obj.todos = []; + $scope.obj.dailys = []; + $scope.obj.rewards = []; + $scope.obj.tasks.forEach(function (element, index, array) { + $scope.obj[element.type + 's'].push(element) + }); + $scope.obj._locked = true; }); }] From bb0be9a626be3cfbde506b1969c4411d3e49e329 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 15 May 2016 18:57:15 -0500 Subject: [PATCH 871/976] Updated tests --- test/spec/controllers/challengesCtrlSpec.js | 14 ++++++++++---- test/spec/services/challengeServicesSpec.js | 2 +- website/client/js/controllers/challengesCtrl.js | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/test/spec/controllers/challengesCtrlSpec.js b/test/spec/controllers/challengesCtrlSpec.js index 495f1b020b..75d9759850 100644 --- a/test/spec/controllers/challengesCtrlSpec.js +++ b/test/spec/controllers/challengesCtrlSpec.js @@ -41,30 +41,36 @@ describe('Challenges Controller', function() { description: 'You are the owner and member', leader: user._id, members: [user], - _isMember: true + _isMember: true, + _id: 'ownMem-id', }); ownNotMem = specHelper.newChallenge({ description: 'You are the owner, but not a member', leader: user._id, members: [], - _isMember: false + _isMember: false, + _id: 'ownNotMem-id', }); notOwnMem = specHelper.newChallenge({ description: 'Not owner but a member', leader: {_id:"test"}, members: [user], - _isMember: true + _isMember: true, + _id: 'notOwnMem-id', }); notOwnNotMem = specHelper.newChallenge({ description: 'Not owner or member', leader: {_id:"test"}, members: [], - _isMember: false + _isMember: false, + _id: 'notOwnNotMem-id', }); + user.challenges = [ownMem._id, notOwnMem._id]; + scope.search = { group: _.transform(groups, function(m,g){m[g._id]=true;}) }; diff --git a/test/spec/services/challengeServicesSpec.js b/test/spec/services/challengeServicesSpec.js index abfe7c520a..9bed72db31 100644 --- a/test/spec/services/challengeServicesSpec.js +++ b/test/spec/services/challengeServicesSpec.js @@ -81,7 +81,7 @@ describe('challengeServices', function() { it('calls select challenge winner endpoint', function() { var challengeId = 1; var winnerId = 2; - $httpBackend.expectPOST(apiV3Prefix + '/challenges/' + challengeId + 'selectWinner/' + winnerId).respond({}); + $httpBackend.expectPOST(apiV3Prefix + '/challenges/' + challengeId + '/selectWinner/' + winnerId).respond({}); challenges.selectChallengeWinner(challengeId, winnerId); $httpBackend.flush(); }); diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index e5c6ae2a09..5a689ae7ca 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -53,7 +53,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', defaultGroup = groupsWithChallenges[i]; } - if (filterCount > 1 && defaultGroup) { + if (filterCount >= 1 && defaultGroup) { break; } } From 6bbfbbf6130ad8c525484047a46dcb2af36e113e Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 15 May 2016 21:06:53 -0500 Subject: [PATCH 872/976] fix: Fix quest progress button --- .../debug/POST-debug_quest-progress.test.js | 63 +++++++++++++++++++ website/client/js/controllers/footerCtrl.js | 14 +++-- website/server/controllers/api-v3/debug.js | 46 ++++++++++++++ website/views/shared/footer.jade | 2 +- 4 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 test/api/v3/integration/debug/POST-debug_quest-progress.test.js diff --git a/test/api/v3/integration/debug/POST-debug_quest-progress.test.js b/test/api/v3/integration/debug/POST-debug_quest-progress.test.js new file mode 100644 index 0000000000..3ae3d48882 --- /dev/null +++ b/test/api/v3/integration/debug/POST-debug_quest-progress.test.js @@ -0,0 +1,63 @@ +import nconf from 'nconf'; +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /debug/quest-progress', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + afterEach(() => { + nconf.set('IS_PROD', false); + }); + + it('errors if user is not on a quest', async () => { + await expect(user.post('/debug/quest-progress')) + .to.eventually.be.rejected.and.to.deep.equal({ + code: 400, + error: 'BadRequest', + message: 'User is not on a valid quest.', + }); + }); + + it('increases boss quest progress by 1000', async () => { + await user.update({ + 'party.quest.key': 'whale', + }); + + await user.post('/debug/quest-progress'); + + await user.sync(); + + expect(user.party.quest.progress.up).to.eql(1000); + }); + + it('increases collection quest progress by 300 items', async () => { + await user.update({ + 'party.quest.key': 'evilsanta2', + }); + + await user.post('/debug/quest-progress'); + + await user.sync(); + + expect(user.party.quest.progress.collect).to.eql({ + tracks: 300, + branches: 300, + }); + }); + + it('returns error when not in production mode', async () => { + nconf.set('IS_PROD', true); + + await expect(user.post('/debug/quest-progress')) + .eventually.be.rejected.and.to.deep.equal({ + code: 404, + error: 'NotFound', + message: 'Not found.', + }); + }); +}); diff --git a/website/client/js/controllers/footerCtrl.js b/website/client/js/controllers/footerCtrl.js index b02ffe0263..2d07aff430 100644 --- a/website/client/js/controllers/footerCtrl.js +++ b/website/client/js/controllers/footerCtrl.js @@ -118,11 +118,15 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { }); }; - $scope.addBossQuestProgressUp = function(){ - //@TODO: Route? - User.set({ - 'party.quest.progress.up': User.user.party.quest.progress.up + 1000 - }); + $scope.addQuestProgress = function(){ + $http({ + method: "POST", + url: 'api/v3/debug/quest-progress' + }) + .then(function (response) { + Notification.text('Quest progress increased'); + User.sync(); + }) }; $scope.makeAdmin = function () { diff --git a/website/server/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js index 8c79218a8e..9f6d344546 100644 --- a/website/server/controllers/api-v3/debug.js +++ b/website/server/controllers/api-v3/debug.js @@ -1,5 +1,8 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode'; +import { BadRequest } from '../../libs/api-v3/errors'; +import { content } from '../../../../common'; +import _ from 'lodash'; let api = {}; @@ -141,4 +144,47 @@ api.modifyInventory = { res.respond(200, {}); }, }; + +/** + * @api {post} /api/v3/debug/quest-progress Artificially accelerate quest progress + * @apiDescription Only available in development mode. + * @apiVersion 3.0.0 + * @apiName questProgress + * @apiGroup Development + * + * @apiSuccess {Object} data An empty Object + */ +api.questProgress = { + method: 'POST', + url: '/debug/quest-progress', + middlewares: [ensureDevelpmentMode, authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + let key = _.get(user, 'party.quest.key'); + let quest = content.quests[key]; + + if (!quest) { + throw new BadRequest('User is not on a valid quest.'); + } + + if (quest.boss) { + user.party.quest.progress.up += 1000; + } + + if (quest.collect) { + let collect = user.party.quest.progress.collect; + _.each(quest.collect, (details, item) => { + collect[item] = collect[item] || 0; + collect[item] += 300; + }); + } + + user.markModified('party.quest.progress'); + + await user.save(); + + res.respond(200, {}); + }, +}; + module.exports = api; diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index 9c67194c85..6246f09cc8 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -91,7 +91,7 @@ footer.footer(ng-controller='FooterCtrl') a.btn.btn-default(ng-click='addMana()') +MP a.btn.btn-default(ng-click='addLevelsAndGold()') +Exp +GP +MP a.btn.btn-default(ng-click='addOneLevel()') +1 Level - a.btn.btn-default(ng-click='addBossQuestProgressUp()') +1000 Boss Quest Progress Up + a.btn.btn-default(ng-click='addQuestProgress()' tooltip="+1000 to boss quests. 300 items to collection quests") Quest Progress Up // TODO Re-enable after v3 prod testing // a.btn.btn-default(ng-click='makeAdmin()') Make Admin a.btn.btn-default(ng-click='openModifyInventoryModal()') Modify Inventory From b676fc0b71b9210263fa6b300c599825a5880fa8 Mon Sep 17 00:00:00 2001 From: Alys Date: Mon, 16 May 2016 15:20:43 +1000 Subject: [PATCH 873/976] fix incorrect Armoire test; remove unneeded param details from apidocs; disambiguate health potion --- common/script/ops/buy.js | 2 +- common/script/ops/buyPotion.js | 2 +- .../user/POST-user_buy_armoire.test.js | 29 ++++++++----------- website/server/controllers/api-v3/user.js | 6 +--- 4 files changed, 15 insertions(+), 24 deletions(-) diff --git a/common/script/ops/buy.js b/common/script/ops/buy.js index d62842acea..c79bd65e0d 100644 --- a/common/script/ops/buy.js +++ b/common/script/ops/buy.js @@ -12,7 +12,7 @@ module.exports = function buy (user, req = {}, analytics) { if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); let buyRes; - if (key === 'potion') { + if (key === 'potion') { // health potion buyRes = buyPotion(user, req, analytics); } else if (key === 'armoire') { buyRes = buyArmoire(user, req, analytics); diff --git a/common/script/ops/buyPotion.js b/common/script/ops/buyPotion.js index 5c64b19609..0a15777d56 100644 --- a/common/script/ops/buyPotion.js +++ b/common/script/ops/buyPotion.js @@ -4,7 +4,7 @@ import { NotAuthorized, } from '../libs/errors'; -module.exports = function buyPotion (user, req = {}, analytics) { +module.exports = function buyPotion (user, req = {}, analytics) { // health potion let item = content.potion; if (user.stats.gp < item.value) { diff --git a/test/api/v3/integration/user/POST-user_buy_armoire.test.js b/test/api/v3/integration/user/POST-user_buy_armoire.test.js index 7538be64b8..0db5f5cec3 100644 --- a/test/api/v3/integration/user/POST-user_buy_armoire.test.js +++ b/test/api/v3/integration/user/POST-user_buy_armoire.test.js @@ -2,23 +2,23 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration/v3'; -import shared from '../../../../../common/script'; - -let content = shared.content; describe('POST /user/buy-armoire', () => { let user; beforeEach(async () => { user = await generateUser({ - 'stats.hp': 40, + 'stats.gp': 400, }); }); // More tests in common code unit tests it('returns an error if user does not have enough gold', async () => { - await expect(user.post('/user/buy-potion')) + await user.update({ + 'stats.gp': 5, + }); + await expect(user.post('/user/buy-armoire')) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -26,19 +26,14 @@ describe('POST /user/buy-armoire', () => { }); }); - xit('buys a piece of armoire', async () => { - await user.update({ - 'stats.gp': 400, - }); - - let potion = content.potion; - let res = await user.post('/user/buy-potion'); + it('reduces gold when buying from the armoire', async () => { + await user.post('/user/buy-armoire'); await user.sync(); - expect(user.stats.hp).to.equal(50); - expect(res.data).to.eql({ - stats: user.stats, - }); - expect(res.message).to.equal(t('messageBought', {itemText: potion.text()})); + expect(user.stats.gp).to.equal(300); + }); + + xit('buys a piece of armoire', async () => { + // Skipped because can't stub predictableRandom correctly }); }); diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index d13b853176..f477631b58 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -557,8 +557,6 @@ api.buyGear = { * @apiName UserBuyArmoire * @apiGroup User * - * @apiParam {string} key The item to buy. - * * @apiSuccess {object} data.items user.items * @apiSuccess {object} data.flags user.flags * @apiSuccess {object} data.armoire Extra item given by the armoire @@ -577,13 +575,11 @@ api.buyArmoire = { }; /** - * @api {post} /user/buy-potion Buy a potion. + * @api {post} /user/buy-potion Buy a health potion * @apiVersion 3.0.0 * @apiName UserBuyPotion * @apiGroup User * - * @apiParam {string} key The item to buy. - * * @apiSuccess {Object} data user.stats * @apiSuccess {string} message Success message */ From ba1628427ebd7f9f56bdd8bd239a51e01a3dc0f0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 16 May 2016 11:38:22 +0200 Subject: [PATCH 874/976] v3: fix stealth casting --- common/script/content/spells.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/script/content/spells.js b/common/script/content/spells.js index e7a198ec97..9381ba89d5 100644 --- a/common/script/content/spells.js +++ b/common/script/content/spells.js @@ -194,7 +194,7 @@ spells.rogue = { notes: t('spellRogueStealthNotes'), cast (user) { if (!user.stats.buffs.stealth) user.stats.buffs.stealth = 0; - user.stats.buffs.stealth += Math.ceil(diminishingReturns(user._statsComputed.per, user.dailys.length * 0.64, 55)); + user.stats.buffs.stealth += Math.ceil(diminishingReturns(user._statsComputed.per, user.tasksOrder.dailys.length * 0.64, 55)); }, }, }; From 793ca3b172a83a700ef8a74a1d5713c70f684414 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 16 May 2016 12:04:09 +0200 Subject: [PATCH 875/976] v3: fix tasks saving and selection for rebirth reroll and reset (server-only) --- common/script/ops/rebirth.js | 25 +++++++--------- common/script/ops/reroll.js | 6 ++-- common/script/ops/reset.js | 1 - website/server/controllers/api-v3/user.js | 35 +++++++++++++++++------ 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/common/script/ops/rebirth.js b/common/script/ops/rebirth.js index 40920fb0c7..54f9533fc5 100644 --- a/common/script/ops/rebirth.js +++ b/common/script/ops/rebirth.js @@ -11,16 +11,11 @@ import equip from './equip'; const USERSTATSLIST = ['per', 'int', 'con', 'str', 'points', 'gp', 'exp', 'mp']; module.exports = function rebirth (user, tasks = [], req = {}, analytics) { - let analyticsData; - let flags; - let lvl; - let stats; - if (user.balance < 2 && user.stats.lvl < MAX_LEVEL) { throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); } - analyticsData = { + let analyticsData = { uuid: user._id, category: 'behavior', }; @@ -38,18 +33,20 @@ module.exports = function rebirth (user, tasks = [], req = {}, analytics) { analytics.track('Rebirth', analyticsData); } - lvl = capByLevel(user.stats.lvl); + let lvl = capByLevel(user.stats.lvl); _.each(tasks, function resetTasks (task) { - if (task.type !== 'reward') { - task.value = 0; - } - if (task.type === 'daily') { - task.streak = 0; + if (!task.challenge || !task.challenge.id || task.challenge.broken) { + if (task.type !== 'reward') { + task.value = 0; + } + if (task.type === 'daily') { + task.streak = 0; + } } }); - stats = user.stats; + let stats = user.stats; stats.buffs = {}; stats.hp = 50; stats.lvl = 1; @@ -79,7 +76,7 @@ module.exports = function rebirth (user, tasks = [], req = {}, analytics) { }); } - flags = user.flags; + let flags = user.flags; if (!user.achievements.beastMaster) { flags.rebirthEnabled = false; } diff --git a/common/script/ops/reroll.js b/common/script/ops/reroll.js index fee26fb88b..72414173f7 100644 --- a/common/script/ops/reroll.js +++ b/common/script/ops/reroll.js @@ -13,8 +13,10 @@ module.exports = function reroll (user, tasks = [], req = {}, analytics) { user.stats.hp = 50; _.each(tasks, function resetTaskValues (task) { - if (task.type !== 'reward') { - task.value = 0; + if (!task.challenge || !task.challenge.id || task.challenge.broken) { + if (task.type !== 'reward') { + task.value = 0; + } } }); diff --git a/common/script/ops/reset.js b/common/script/ops/reset.js index eb8d25033e..3e48fa4f2d 100644 --- a/common/script/ops/reset.js +++ b/common/script/ops/reset.js @@ -13,7 +13,6 @@ module.exports = function reset (user, tasks = [], req = {}) { tasksToRemove.push(task._id); let i = user.tasksOrder[`${task.type}s`].indexOf(task._id); if (i !== -1) user.tasksOrder[`${task.type}s`].splice(i, 1); - tasksToRemove.push(task._id); } }); diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 6307b57293..de06b90352 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -1106,16 +1106,22 @@ api.userRebirth = { url: '/user/rebirth', async handler (req, res) { let user = res.locals.user; - let query = { + let tasks = await Tasks.Task.find({ userId: user._id, type: {$in: ['daily', 'habit', 'todo']}, - }; - let tasks = await Tasks.Task.find(query).exec(); + $or: [ // exclude challenge tasks + {'challenge.id': {$exists: false}}, + {'challenge.broken': {$exists: true}}, + ], + }).exec(); + let rebirthRes = common.ops.rebirth(user, tasks, req, res.analytics); - await user.save(); + let toSave = tasks.map(task => task.save()); - await Bluebird.all(tasks.map(task => task.save())); + toSave.push(user.save()); + + await Bluebird.all(toSave); res.respond(200, ...rebirthRes); }, @@ -1224,6 +1230,10 @@ api.userReroll = { let query = { userId: user._id, type: {$in: ['daily', 'habit', 'todo']}, + $or: [ // exclude challenge tasks + {'challenge.id': {$exists: false}}, + {'challenge.broken': {$exists: true}}, + ], }; let tasks = await Tasks.Task.find(query).exec(); let rerollRes = common.ops.reroll(user, tasks, req, res.analytics); @@ -1280,11 +1290,20 @@ api.userReset = { async handler (req, res) { let user = res.locals.user; - let tasks = await Tasks.Task.find({userId: user._id}).select('_id type challenge').exec(); + let tasks = await Tasks.Task.find({ + userId: user._id, + $or: [ // exclude challenge tasks + {'challenge.id': {$exists: false}}, + {'challenge.broken': {$exists: true}}, + ], + }).select('_id type challenge').exec(); - let resetRes = common.ops.reset(user, tasks); + let resetRes = common.ops.reset(user, tasks, req); - await Bluebird.all([Tasks.Task.remove({_id: {$in: resetRes[0].tasksToRemove}, userId: user._id}), user.save()]); + await Bluebird.all([ + Tasks.Task.remove({_id: {$in: resetRes[0].tasksToRemove}, userId: user._id}), + user.save(), + ]); res.respond(200, ...resetRes); }, From 4d08fde068cedccc651e2d350533f5dcbbbb9a32 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 16 May 2016 12:17:26 +0200 Subject: [PATCH 876/976] v3: fix auto allocation --- common/script/fns/autoAllocate.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/common/script/fns/autoAllocate.js b/common/script/fns/autoAllocate.js index 85fe01b78d..71a4898031 100644 --- a/common/script/fns/autoAllocate.js +++ b/common/script/fns/autoAllocate.js @@ -10,17 +10,19 @@ import splitWhitespace from '../libs/splitWhitespace'; function getStatToAllocate (user) { let suggested; + let statsObj = user.stats.toObject ? user.stats.toObject() : user.stats; + switch (user.preferences.allocationMode) { case 'flat': { - let stats = _.pick(user.stats, splitWhitespace('con str per int')); + let stats = _.pick(statsObj, splitWhitespace('con str per int')); return _.invert(stats)[_.min(stats)]; } case 'classbased': { - let lvlDiv7 = user.stats.lvl / 7; + let lvlDiv7 = statsObj.lvl / 7; let ideal = [lvlDiv7 * 3, lvlDiv7 * 2, lvlDiv7, lvlDiv7]; let preference; - switch (user.stats.class) { + switch (statsObj.class) { case 'wizard': { preference = ['int', 'per', 'con', 'str']; break; @@ -39,10 +41,10 @@ function getStatToAllocate (user) { } let diff = [ - user.stats[preference[0]] - ideal[0], - user.stats[preference[1]] - ideal[1], - user.stats[preference[2]] - ideal[2], - user.stats[preference[3]] - ideal[3], + statsObj[preference[0]] - ideal[0], + statsObj[preference[1]] - ideal[1], + statsObj[preference[2]] - ideal[2], + statsObj[preference[3]] - ideal[3], ]; suggested = _.findIndex(diff, (val) => { @@ -52,9 +54,9 @@ function getStatToAllocate (user) { return suggested !== -1 ? preference[suggested] : 'str'; } case 'taskbased': { - suggested = _.invert(user.stats.training)[_.max(user.stats.training)]; + suggested = _.invert(statsObj.training)[_.max(statsObj.training)]; - let training = user.stats.training; + let training = statsObj.training; training.str = 0; training.int = 0; training.con = 0; From ab27ef47feac5a65f9b1f602839d4f736e37e520 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 16 May 2016 12:54:43 +0200 Subject: [PATCH 877/976] v3 client: misc fixes --- website/client/js/controllers/rootCtrl.js | 2 +- website/client/js/controllers/settingsCtrl.js | 1 + website/client/js/services/chatServices.js | 3 +- website/client/js/services/guideServices.js | 4 +- website/client/js/services/userServices.js | 49 ++++++++++++------- 5 files changed, 35 insertions(+), 24 deletions(-) diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index 01b55b8ea7..1cd06e865d 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -289,7 +289,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ } }); } else if (spell.target == 'tasks') { - var tasks = User.user.habits.concat(User.user.dailys).concat(User.user.rewards); + var tasks = User.user.habits.concat(User.user.dailys).concat(User.user.rewards).concat(User.user.todos); // exclude challenge tasks tasks = tasks.filter(function (t) { if (!t.challenge) return true; diff --git a/website/client/js/controllers/settingsCtrl.js b/website/client/js/controllers/settingsCtrl.js index a93dde0fb3..d778fadc54 100644 --- a/website/client/js/controllers/settingsCtrl.js +++ b/website/client/js/controllers/settingsCtrl.js @@ -176,6 +176,7 @@ habitrpg.controller('SettingsCtrl', $scope.reset = function(){ User.reset({}); + User.sync(); $rootScope.$state.go('tasks'); } diff --git a/website/client/js/services/chatServices.js b/website/client/js/services/chatServices.js index ed812e08ad..a79de22b85 100644 --- a/website/client/js/services/chatServices.js +++ b/website/client/js/services/chatServices.js @@ -81,8 +81,7 @@ angular.module('habitrpg') clearCards: clearCards, } - //@TOOD: Port when User service is updated function clearCards() { - User.user.ops.update && User.set({'flags.cardReceived':false}); + User.user._wrapped && User.set({'flags.cardReceived':false}); } }]); diff --git a/website/client/js/services/guideServices.js b/website/client/js/services/guideServices.js index 08b51732c8..7f3ce9e499 100644 --- a/website/client/js/services/guideServices.js +++ b/website/client/js/services/guideServices.js @@ -264,8 +264,8 @@ function($rootScope, User, $timeout, $state, Analytics) { } //Init and show the welcome tour (only after user is pulled from server & wrapped). - var watcher = $rootScope.$watch('User.user.ops.update', function(updateFn){ - if (!updateFn) return; // only run after user has been wrapped + var watcher = $rootScope.$watch('User.user._wrapped', function(wrapped){ + if (!wrapped) return; // only run after user has been wrapped watcher(); // deregister watcher if (window.env.IS_MOBILE) return; // Don't show tour immediately on mobile devices if (User.user.flags.welcomed == false) { diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 206052c8ad..a25584e5fb 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -61,21 +61,13 @@ angular.module('habitrpg') // replicated. We need to wrap each op to provide a callback to send that operation $window.habitrpgShared.wrap(user); _.each(user.ops, function(op,k){ - user.ops[k] = function(req,cb){ - if (cb) return op(req,cb); - op(req,function(err,response) { - for(var updatedItem in req.body) { - var itemUpdateResponse = userNotifications[updatedItem]; - if(itemUpdateResponse) Notification.text(itemUpdateResponse); - } - if (err) { - var message = err.code ? err.message : err; - Notification.text(message); - // In the case of 200s, they're friendly alert messages like "Your pet has hatched!" - still send the op - if ((err.code && err.code >= 400) || !err.code) return; - } - userServices.log({op:k, params: req.params, query:req.query, body:req.body}); - }); + user.ops[k] = function(req){ + try { + op(req); + } catch (err) { + Notification.text(err.message); + return; + } } }); } @@ -106,13 +98,27 @@ angular.module('habitrpg') function callOpsFunctionAndRequest (opName, endPoint, method, paramString, opData) { if (!opData) opData = {}; + var clientResponse; + try { - $window.habitrpgShared.ops[opName](user, opData); - } catch(err) { + var args = [user]; + if (opName === 'rebirth' || opName === 'reroll' || opName === 'reset') { + args.push(user.habits.concat(user.dailys).concat(user.rewards).concat(user.todos)); + } + + args.push(opData); + clientResponse = $window.habitrpgShared.ops[opName].apply(null, args); + } catch (err) { Notification.text(err.message); return; } + var clientMessage = clientResponse[1]; + + if (clientMessage) { + Notification.text(clientMessage); + } + var url = '/api/v3/user/' + endPoint; if (paramString) { url += '/' + paramString @@ -130,7 +136,7 @@ angular.module('habitrpg') body: body, }) .then(function (response) { - if (response.data.message) Notification.text(response.data.message); + if (response.data.message && response.data.message !== clientMessage) Notification.text(response.data.message); save(); }) } @@ -182,7 +188,12 @@ angular.module('habitrpg') }, score: function (data) { - $window.habitrpgShared.ops.scoreTask({user: user, task: data.params.task, direction: data.params.direction}, data.params); + try { + $window.habitrpgShared.ops.scoreTask({user: user, task: data.params.task, direction: data.params.direction}, data.params); + } catch (err) { + Notification.text(err.message); + return; + } save(); Tasks.scoreTask(data.params.task._id, data.params.direction).then(function (res) { var tmp = res.data.data._tmp || {}; // used to notify drops, critical hits and other bonuses From daa0955ac1e170e0349156c4cfc726f04a83d29e Mon Sep 17 00:00:00 2001 From: Alys Date: Mon, 16 May 2016 23:15:01 +1000 Subject: [PATCH 878/976] rename buyPotion and buy-potion to buyHealthPotion and buy-health-potion; fix apidoc param error --- common/script/index.js | 6 +++--- common/script/ops/buy.js | 4 ++-- .../script/ops/{buyPotion.js => buyHealthPotion.js} | 2 +- common/script/ops/index.js | 4 ++-- .../integration/user/POST-user_buy_armoire.test.js | 4 ++-- ...n.test.js => POST-user_buy_health_potion.test.js} | 6 +++--- test/common/ops/{buyPotion.js => buyHealthPotion.js} | 12 ++++++------ website/server/controllers/api-v3/user.js | 12 +++++------- 8 files changed, 24 insertions(+), 26 deletions(-) rename common/script/ops/{buyPotion.js => buyHealthPotion.js} (92%) rename test/api/v3/integration/user/{POST-user_buy_potion.test.js => POST-user_buy_health_potion.test.js} (84%) rename test/common/ops/{buyPotion.js => buyHealthPotion.js} (84%) diff --git a/common/script/index.js b/common/script/index.js index 3b87ac1a7c..e3a0e0a0de 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -114,7 +114,7 @@ import sleep from './ops/sleep'; import allocate from './ops/allocate'; import buy from './ops/buy'; import buyGear from './ops/buyGear'; -import buyPotion from './ops/buyPotion'; +import buyHealthPotion from './ops/buyHealthPotion'; import buyArmoire from './ops/buyArmoire'; import buyMysterySet from './ops/buyMysterySet'; import buyQuest from './ops/buyQuest'; @@ -155,7 +155,7 @@ api.ops = { allocate, buy, buyGear, - buyPotion, + buyHealthPotion, buyArmoire, buyMysterySet, buySpecialSpell, @@ -274,7 +274,7 @@ api.wrap = function wrapUser (user, main = true) { releaseMounts: _.partial(importedOps.releaseMounts, user), releaseBoth: _.partial(importedOps.releaseBoth, user), buy: _.partial(importedOps.buy, user), - buyPotion: _.partial(importedOps.buyPotion, user), + buyHealthPotion: _.partial(importedOps.buyHealthPotion, user), buyArmoire: _.partial(importedOps.buyArmoire, user), buyGear: _.partial(importedOps.buyGear, user), buyQuest: _.partial(importedOps.buyQuest, user), diff --git a/common/script/ops/buy.js b/common/script/ops/buy.js index d62842acea..ded5b034d2 100644 --- a/common/script/ops/buy.js +++ b/common/script/ops/buy.js @@ -3,7 +3,7 @@ import _ from 'lodash'; import { BadRequest, } from '../libs/errors'; -import buyPotion from './buyPotion'; +import buyHealthPotion from './buyHealthPotion'; import buyArmoire from './buyArmoire'; import buyGear from './buyGear'; @@ -13,7 +13,7 @@ module.exports = function buy (user, req = {}, analytics) { let buyRes; if (key === 'potion') { - buyRes = buyPotion(user, req, analytics); + buyRes = buyHealthPotion(user, req, analytics); } else if (key === 'armoire') { buyRes = buyArmoire(user, req, analytics); } else { diff --git a/common/script/ops/buyPotion.js b/common/script/ops/buyHealthPotion.js similarity index 92% rename from common/script/ops/buyPotion.js rename to common/script/ops/buyHealthPotion.js index 5c64b19609..1a6c8b0e18 100644 --- a/common/script/ops/buyPotion.js +++ b/common/script/ops/buyHealthPotion.js @@ -4,7 +4,7 @@ import { NotAuthorized, } from '../libs/errors'; -module.exports = function buyPotion (user, req = {}, analytics) { +module.exports = function buyHealthPotion (user, req = {}, analytics) { let item = content.potion; if (user.stats.gp < item.value) { diff --git a/common/script/ops/index.js b/common/script/ops/index.js index 42e77d8718..2e8bca246d 100644 --- a/common/script/ops/index.js +++ b/common/script/ops/index.js @@ -31,7 +31,7 @@ import releaseMounts from './releaseMounts'; import releaseBoth from './releaseBoth'; import buy from './buy'; import buyGear from './buyGear'; -import buyPotion from './buyPotion'; +import buyHealthPotion from './buyHealthPotion'; import buyArmoire from './buyArmoire'; import buyQuest from './buyQuest'; import buyMysterySet from './buyMysterySet'; @@ -83,7 +83,7 @@ module.exports = { releaseBoth, buy, buyGear, - buyPotion, + buyHealthPotion, buyArmoire, buyQuest, buyMysterySet, diff --git a/test/api/v3/integration/user/POST-user_buy_armoire.test.js b/test/api/v3/integration/user/POST-user_buy_armoire.test.js index 7538be64b8..3fed828aad 100644 --- a/test/api/v3/integration/user/POST-user_buy_armoire.test.js +++ b/test/api/v3/integration/user/POST-user_buy_armoire.test.js @@ -18,7 +18,7 @@ describe('POST /user/buy-armoire', () => { // More tests in common code unit tests it('returns an error if user does not have enough gold', async () => { - await expect(user.post('/user/buy-potion')) + await expect(user.post('/user/buy-health-potion')) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -32,7 +32,7 @@ describe('POST /user/buy-armoire', () => { }); let potion = content.potion; - let res = await user.post('/user/buy-potion'); + let res = await user.post('/user/buy-health-potion'); await user.sync(); expect(user.stats.hp).to.equal(50); diff --git a/test/api/v3/integration/user/POST-user_buy_potion.test.js b/test/api/v3/integration/user/POST-user_buy_health_potion.test.js similarity index 84% rename from test/api/v3/integration/user/POST-user_buy_potion.test.js rename to test/api/v3/integration/user/POST-user_buy_health_potion.test.js index e37f908e3e..835e893bd7 100644 --- a/test/api/v3/integration/user/POST-user_buy_potion.test.js +++ b/test/api/v3/integration/user/POST-user_buy_health_potion.test.js @@ -6,7 +6,7 @@ import shared from '../../../../../common/script'; let content = shared.content; -describe('POST /user/buy-potion', () => { +describe('POST /user/buy-health-potion', () => { let user; beforeEach(async () => { @@ -18,7 +18,7 @@ describe('POST /user/buy-potion', () => { // More tests in common code unit tests it('returns an error if user does not have enough gold', async () => { - await expect(user.post('/user/buy-potion')) + await expect(user.post('/user/buy-health-potion')) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -32,7 +32,7 @@ describe('POST /user/buy-potion', () => { }); let potion = content.potion; - let res = await user.post('/user/buy-potion'); + let res = await user.post('/user/buy-health-potion'); await user.sync(); expect(user.stats.hp).to.equal(50); diff --git a/test/common/ops/buyPotion.js b/test/common/ops/buyHealthPotion.js similarity index 84% rename from test/common/ops/buyPotion.js rename to test/common/ops/buyHealthPotion.js index 5230040a35..6a70f71b62 100644 --- a/test/common/ops/buyPotion.js +++ b/test/common/ops/buyHealthPotion.js @@ -2,13 +2,13 @@ import { generateUser, } from '../../helpers/common.helper'; -import buyPotion from '../../../common/script/ops/buyPotion'; +import buyHealthPotion from '../../../common/script/ops/buyHealthPotion'; import { NotAuthorized, } from '../../../common/script/libs/errors'; import i18n from '../../../common/script/i18n'; -describe('shared.ops.buyPotion', () => { +describe('shared.ops.buyHealthPotion', () => { let user; beforeEach(() => { @@ -30,19 +30,19 @@ describe('shared.ops.buyPotion', () => { context('Potion', () => { it('recovers 15 hp', () => { user.stats.hp = 30; - buyPotion(user); + buyHealthPotion(user); expect(user.stats.hp).to.eql(45); }); it('does not increase hp above 50', () => { user.stats.hp = 45; - buyPotion(user); + buyHealthPotion(user); expect(user.stats.hp).to.eql(50); }); it('deducts 25 gp', () => { user.stats.hp = 45; - buyPotion(user); + buyHealthPotion(user); expect(user.stats.gp).to.eql(175); }); @@ -51,7 +51,7 @@ describe('shared.ops.buyPotion', () => { user.stats.hp = 45; user.stats.gp = 5; try { - buyPotion(user); + buyHealthPotion(user); } catch (err) { expect(err).to.be.an.instanceof(NotAuthorized); expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 32cc1fb7bb..0efe484666 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -577,25 +577,23 @@ api.buyArmoire = { }; /** - * @api {post} /user/buy-potion Buy a potion. + * @api {post} /user/buy-health-potion Buy a health potion * @apiVersion 3.0.0 * @apiName UserBuyPotion * @apiGroup User * - * @apiParam {string} key The item to buy. - * * @apiSuccess {Object} data user.stats * @apiSuccess {string} message Success message */ -api.buyPotion = { +api.buyHealthPotion = { method: 'POST', middlewares: [authWithHeaders()], - url: '/user/buy-potion', + url: '/user/buy-health-potion', async handler (req, res) { let user = res.locals.user; - let buyPotionResponse = common.ops.buyPotion(user, req, res.analytics); + let buyHealthPotionResponse = common.ops.buyHealthPotion(user, req, res.analytics); await user.save(); - res.respond(200, ...buyPotionResponse); + res.respond(200, ...buyHealthPotionResponse); }, }; From 1187d77ba5bf08a6353fc3f31bb1b64b970262bf Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 16 May 2016 08:51:24 -0500 Subject: [PATCH 879/976] Added delete for saved challenge task --- website/views/shared/tasks/meta_controls.jade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/views/shared/tasks/meta_controls.jade b/website/views/shared/tasks/meta_controls.jade index 3db265367a..3feaef0d87 100644 --- a/website/views/shared/tasks/meta_controls.jade +++ b/website/views/shared/tasks/meta_controls.jade @@ -43,7 +43,7 @@ span.glyphicon.glyphicon-bullhorn(tooltip=env.t('challenge')) |   // delete - a(ng-if='!task.challenge.id', ng-click='removeTask(task, obj)', tooltip=env.t('delete')) + a(ng-if='!task.challenge.id || obj.leader._id === User.user._id', ng-click='removeTask(task, obj)', tooltip=env.t('delete')) span.glyphicon.glyphicon-trash |   From 11b567038ed8199bfab4ba3bc35b9e803d120334 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 16 May 2016 09:54:22 -0500 Subject: [PATCH 880/976] Fixed member modal on front page --- website/client/js/services/memberServices.js | 4 ++-- website/client/js/static.js | 21 +++++++++++--------- website/client/manifest.json | 2 ++ website/server/controllers/api-v3/members.js | 2 +- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/website/client/js/services/memberServices.js b/website/client/js/services/memberServices.js index 039743cea5..c62e4cdbc2 100644 --- a/website/client/js/services/memberServices.js +++ b/website/client/js/services/memberServices.js @@ -1,8 +1,8 @@ 'use strict'; angular.module('habitrpg') -.factory('Members', [ '$rootScope', 'Shared', 'ApiUrl', '$resource', '$http', '$q', - function($rootScope, Shared, ApiUrl, $resource, $http, $q) { +.factory('Members', [ '$rootScope', 'Shared', 'ApiUrl', '$http', '$q', + function($rootScope, Shared, ApiUrl, $http, $q) { var members = {}; var selectedMember = {}; var apiV3Prefix = '/api/v3'; diff --git a/website/client/js/static.js b/website/client/js/static.js index 67a07df815..c8af2adf27 100644 --- a/website/client/js/static.js +++ b/website/client/js/static.js @@ -6,18 +6,21 @@ window.habitrpg = angular.module('habitrpg', ['chieffancypants.loadingBar', 'ui. .constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings') .constant("MOBILE_APP", false) -.controller("RootCtrl", ['$scope', '$location', '$modal', '$http', 'Stats', function($scope, $location, $modal, $http, Stats){ +.controller("RootCtrl", ['$scope', '$location', '$modal', '$http', 'Stats', 'Members', + function($scope, $location, $modal, $http, Stats, Members) { var memberId = $location.search()['memberId']; if (memberId) { - $http.get('/api/v2/members/'+memberId).success(function(data, status, headers, config){ - $scope.profile = window.habitrpgShared.wrap(data, false); - $scope.statCalc = Stats; - $scope.Content = window.habitrpgShared.content; - $modal.open({ - templateUrl: 'modals/member.html', - scope: $scope + Members.fetchMember(memberId) + .success(function(response) { + $scope.profile = response.data; + + $scope.statCalc = Stats; + $scope.Content = window.habitrpgShared.content; + $modal.open({ + templateUrl: 'modals/member.html', + scope: $scope + }); }); - }) } $http.defaults.headers.common['x-client'] = 'habitica-web'; diff --git a/website/client/manifest.json b/website/client/manifest.json index 10b9d9f7ac..2c770893f1 100644 --- a/website/client/manifest.json +++ b/website/client/manifest.json @@ -136,6 +136,7 @@ "js/services/statServices.js", "js/services/taskServices.js", "js/services/tagsServices.js", + "js/services/memberServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" ], @@ -172,6 +173,7 @@ "js/services/taskServices.js", "js/services/tagsServices.js", "js/services/userServices.js", + "js/services/memberServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" ], diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index 3d6334967d..f85b7d86e5 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -33,7 +33,7 @@ let api = {}; api.getMember = { method: 'GET', url: '/members/:memberId', - middlewares: [authWithHeaders()], + middlewares: [], async handler (req, res) { req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); From 4bba10fac290e0110f01895b779d73de85043bf0 Mon Sep 17 00:00:00 2001 From: Alys Date: Tue, 17 May 2016 02:08:54 +1000 Subject: [PATCH 881/976] adjust text in apidocs for errors / clarity / consistency / standard terminology (no code changes) (#7298) --- website/server/controllers/api-v3/content.js | 2 +- website/server/controllers/api-v3/debug.js | 4 +- website/server/controllers/api-v3/hall.js | 16 ++-- .../server/controllers/api-v3/modelsPaths.js | 2 +- website/server/controllers/api-v3/tasks.js | 12 +-- website/server/controllers/api-v3/user.js | 91 ++++++++++--------- .../controllers/top-level/dataexport.js | 6 +- 7 files changed, 68 insertions(+), 65 deletions(-) diff --git a/website/server/controllers/api-v3/content.js b/website/server/controllers/api-v3/content.js index 7522a59096..0235f61645 100644 --- a/website/server/controllers/api-v3/content.js +++ b/website/server/controllers/api-v3/content.js @@ -61,7 +61,7 @@ async function saveContentToDisk (language, content) { } /** - * @api {get} /api/v3/content Get all available content objects. + * @api {get} /api/v3/content Get all available content objects * @apiDescription Does not require authentication. * @apiVersion 3.0.0 * @apiName ContentGet diff --git a/website/server/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js index 9f6d344546..9949421513 100644 --- a/website/server/controllers/api-v3/debug.js +++ b/website/server/controllers/api-v3/debug.js @@ -7,7 +7,7 @@ import _ from 'lodash'; let api = {}; /** - * @api {post} /api/v3/debug/add-ten-gems Add ten gems to the current user. + * @api {post} /api/v3/debug/add-ten-gems Add ten gems to the current user * @apiDescription Only available in development mode. * @apiVersion 3.0.0 * @apiName AddTenGems @@ -31,7 +31,7 @@ api.addTenGems = { }; /** - * @api {post} /api/v3/debug/add-hourglass Add Hourglass to the current user. + * @api {post} /api/v3/debug/add-hourglass Add Hourglass to the current user * @apiDescription Only available in development mode. * @apiVersion 3.0.0 * @apiName AddHourglass diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js index c8f11fdfa8..077b1ef74b 100644 --- a/website/server/controllers/api-v3/hall.js +++ b/website/server/controllers/api-v3/hall.js @@ -9,8 +9,8 @@ import _ from 'lodash'; let api = {}; /** - * @api {get} /api/v3/hall/patrons Get all Patrons. - * @apiDescription Only the first 50 patrons are returned. More can be accessed passing ?page=n. + * @api {get} /api/v3/hall/patrons Get all patrons + * @apiDescription Only the first 50 patrons are returned. More can be accessed passing ?page=n * @apiVersion 3.0.0 * @apiName GetPatrons * @apiGroup Hall @@ -79,13 +79,13 @@ api.getHeroes = { const heroAdminFields = 'contributor balance profile.name purchased items auth'; /** - * @api {get} /api/v3/hall/heroes/:heroId Get an hero given his _id. - * @apiDescription Must be an admin to make this request + * @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID + * @apiDescription Must be an admin to make this request. * @apiVersion 3.0.0 * @apiName GetHero * @apiGroup Hall * - * @apiSuccess {Object} data The hero object + * @apiSuccess {Object} data The user object */ api.getHero = { method: 'GET', @@ -117,13 +117,13 @@ api.getHero = { const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0}; /** - * @api {put} /api/v3/hall/heroes/:heroId Update an hero. - * @apiDescription Must be an admin to make this request + * @api {put} /api/v3/hall/heroes/:heroId Update any user ("hero") + * @apiDescription Must be an admin to make this request. * @apiVersion 3.0.0 * @apiName UpdateHero * @apiGroup Hall * - * @apiSuccess {Object} data The updated hero object + * @apiSuccess {Object} data The updated user object */ api.updateHero = { method: 'PUT', diff --git a/website/server/controllers/api-v3/modelsPaths.js b/website/server/controllers/api-v3/modelsPaths.js index dc168b98b4..927087df01 100644 --- a/website/server/controllers/api-v3/modelsPaths.js +++ b/website/server/controllers/api-v3/modelsPaths.js @@ -6,7 +6,7 @@ let tasksModels = ['habit', 'daily', 'todo', 'reward']; let allModels = ['user', 'tag', 'challenge', 'group'].concat(tasksModels); /** - * @api {get} /api/v3/models/:model/paths Get all paths for the specified model. + * @api {get} /api/v3/models/:model/paths Get all paths for the specified model * @apiDescription Doesn't require authentication * @apiVersion 3.0.0 * @apiName GetUserModelPaths diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 3ed454c666..50e32220bc 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -54,7 +54,7 @@ async function _createTasks (req, res, user, challenge) { } /** - * @api {post} /api/v3/tasks/user Create a new task belonging to the user. + * @api {post} /api/v3/tasks/user Create a new task belonging to the user * @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 * @apiName CreateUserTasks @@ -73,13 +73,13 @@ api.createUserTasks = { }; /** - * @api {post} /api/v3/tasks/challenge/:challengeId Create a new task belonging to a challenge. + * @api {post} /api/v3/tasks/challenge/:challengeId Create a new task belonging to a challenge * @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 * @apiName CreateChallengeTasks * @apiGroup Task * - * @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to. + * @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to * * @apiSuccess data An object if a single task was created, otherwise an array of tasks */ @@ -171,7 +171,7 @@ async function _getTasks (req, res, user, challenge) { * @apiName GetUserTasks * @apiGroup Task * - * @apiParam {string="habits","dailys","todos","rewards","completedTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that requested separately. + * @apiParam {string="habits","dailys","todos","rewards","completedTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that must be requested separately. * * @apiSuccess {Array} data An array of tasks */ @@ -197,7 +197,7 @@ api.getUserTasks = { * @apiName GetChallengeTasks * @apiGroup Task * - * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks. + * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks * @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks * * @apiSuccess {Array} data An array of tasks @@ -427,9 +427,9 @@ api.scoreTask = { }, }; -// completed todos cannot be moved, they'll be returned ordered by date of completion /** * @api {post} /api/v3/tasks/:taskId/move/to/:position Move a task to a new position + * @apiDescription Note: completed To-Dos are not sortable, do not appear in user.tasksOrder.todos, and are ordered by date of completion. * @apiVersion 3.0.0 * @apiName MoveTask * @apiGroup Task diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 32cc1fb7bb..2e22a6b662 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -142,7 +142,7 @@ let checkPreferencePurchase = (user, path, item) => { }; /** - * @api {put} /api/v3/user Update the user. + * @api {put} /api/v3/user Update the user * @apiDescription Example body: {'stats.hp':50, 'preferences.background': 'beach'} * @apiVersion 3.0.0 * @apiName UserUpdate @@ -305,13 +305,13 @@ api.getUserAnonymized = { const partyMembersFields = 'profile.name stats achievements items.special'; /** - * @api {post} /api/v3/user/class/cast/:spellId Cast a spell on a target. + * @api {post} /api/v3/user/class/cast/:spellId Cast a skill (spell) on a target * @apiVersion 3.0.0 * @apiName UserCast * @apiGroup User * - * @apiParam {string} spellId The spell to cast. - * @apiParam {UUID} targetId Optional query parameter, the id of the target when casting a spell on a party member or a task. + * @apiParam {string} spellId The skill to cast + * @apiParam {UUID} targetId Optional query parameter, the id of the target when casting a skill on a party member or a task * * @apiSuccess data Will return the modified targets. For party members only the necessary fields will be populated. The user is always returned. */ @@ -443,7 +443,7 @@ api.castSpell = { }; /** - * @api {post} /api/v3/user/sleep Put the user in the inn. + * @api {post} /api/v3/user/sleep Make the user start / stop sleeping (resting in the Inn) * @apiVersion 3.0.0 * @apiName UserSleep * @apiGroup User @@ -463,7 +463,7 @@ api.sleep = { }; /** - * @api {post} /api/v3/user/allocate Allocate an attribute point. + * @api {post} /api/v3/user/allocate Allocate an attribute point * @apiVersion 3.0.0 * @apiName UserAllocate * @apiGroup User @@ -485,7 +485,8 @@ api.allocate = { }; /** - * @api {post} /api/v3/user/allocate-now Allocate all attribute points. + * @api {post} /api/v3/user/allocate-now Allocate all attribute points + * @apiDescription Uses the user's chosen automatic allocation method, or if none, assigns all to STR. * @apiVersion 3.0.0 * @apiName UserAllocateNow * @apiGroup User @@ -511,7 +512,7 @@ api.allocateNow = { * @apiName UserBuy * @apiGroup User * - * @apiParam {string} key The item to buy. + * @apiParam {string} key The item to buy */ api.buy = { method: 'POST', @@ -526,12 +527,12 @@ api.buy = { }; /** - * @api {post} /user/buy-gear/:key Buy a piece of gear. + * @api {post} /user/buy-gear/:key Buy a piece of gear * @apiVersion 3.0.0 * @apiName UserBuyGear * @apiGroup User * - * @apiParam {string} key The item to buy. + * @apiParam {string} key The item to buy * * @apiSuccess {object} data.items user.items * @apiSuccess {object} data.flags user.flags @@ -552,12 +553,12 @@ api.buyGear = { }; /** - * @api {post} /user/buy-armoire Buy an armoire item. + * @api {post} /user/buy-armoire Buy an armoire item * @apiVersion 3.0.0 * @apiName UserBuyArmoire * @apiGroup User * - * @apiParam {string} key The item to buy. + * @apiParam {string} key The item to buy * * @apiSuccess {object} data.items user.items * @apiSuccess {object} data.flags user.flags @@ -600,12 +601,12 @@ api.buyPotion = { }; /** - * @api {post} /user/buy-mystery-set/:key Buy a mystery set. + * @api {post} /user/buy-mystery-set/:key Buy a mystery set * @apiVersion 3.0.0 * @apiName UserBuyMysterySet * @apiGroup User * - * @apiParam {string} key The mystery set to buy. + * @apiParam {string} key The mystery set to buy * * @apiSuccess {Object} data.items user.items * @apiSuccess {Object} data.purchasedPlanConsecutive user.purchased.plan.consecutive @@ -624,12 +625,12 @@ api.buyMysterySet = { }; /** - * @api {post} /api/v3/user/buy-quest/:key Buy a quest with gold. + * @api {post} /api/v3/user/buy-quest/:key Buy a quest with gold * @apiVersion 3.0.0 * @apiName UserBuyQuest * @apiGroup User * - * @apiParam {string} key The quest spell to buy. + * @apiParam {string} key The quest scroll to buy * * @apiSuccess {Object} data `user.items.quests` * @apiSuccess {string} message Success message @@ -647,12 +648,13 @@ api.buyQuest = { }; /** - * @api {post} /api/v3/user/buy-special-spell/:key Buy special spell. + * @api {post} /api/v3/user/buy-special-spell/:key Buy special "spell" item + * @apiDescription Includes gift cards (e.g., birthday card), and avatar Transformation Items and their antidotes (e.g., Snowball item and Salt reward). * @apiVersion 3.0.0 * @apiName UserBuySpecialSpell * @apiGroup User * - * @apiParam {string} key The special spell to buy. + * @apiParam {string} key The special item to buy. Must be one of the keys from "content.special", such as birthday, snowball, salt. * * @apiSuccess {Object} data.stats user.stats * @apiSuccess {Object} data.items user.items @@ -671,13 +673,13 @@ api.buySpecialSpell = { }; /** - * @api {post} /api/v3/user/hatch/:egg/:hatchingPotion Hatch a pet. + * @api {post} /api/v3/user/hatch/:egg/:hatchingPotion Hatch a pet * @apiVersion 3.0.0 * @apiName UserHatch * @apiGroup User * - * @apiParam {string} egg The egg to use. - * @apiParam {string} hatchingPotion The hatching potion to use. + * @apiParam {string} egg The egg to use + * @apiParam {string} hatchingPotion The hatching potion to use * * @apiSuccess {Object} data user.items * @apiSuccess {string} message @@ -743,13 +745,13 @@ api.feed = { }; /** -* @api {post} /api/v3/user/change-class Change class. +* @api {post} /api/v3/user/change-class Change class * @apiDescription User must be at least level 10. If ?class is defined and user.flags.classSelected is false it'll change the class. If user.preferences.disableClasses it'll enable classes, otherwise it sets user.flags.classSelected to false (costs 3 gems) * @apiVersion 3.0.0 * @apiName UserChangeClass * @apiGroup User * -* @apiParam {string} class Query parameter - ?class={warrior|rogue|wizard|healer}. +* @apiParam {string} class Query parameter - ?class={warrior|rogue|wizard|healer} * * @apiSuccess {object} data.flags user.flags * @apiSuccess {object} data.stats user.stats @@ -769,7 +771,7 @@ api.changeClass = { }; /** -* @api {post} /api/v3/user/disable-classes Disable classes. +* @api {post} /api/v3/user/disable-classes Disable classes * @apiVersion 3.0.0 * @apiName UserDisableClasses * @apiGroup User @@ -791,13 +793,13 @@ api.disableClasses = { }; /** -* @api {post} /api/v3/user/purchase/:type/:key Purchase Gem Items. +* @api {post} /api/v3/user/purchase/:type/:key Purchase Gem or Gem-purchasable item * @apiVersion 3.0.0 * @apiName UserPurchase * @apiGroup User * -* @apiParam {string} type Type of item to purchase. Must be one of: gem, gems, eggs, hatchingPotions, food, quests or gear -* @apiParam {string} key Item's key +* @apiParam {string} type Type of item to purchase. Must be one of: gems, eggs, hatchingPotions, food, quests, or gear +* @apiParam {string} key Item's key (use "gem" for purchasing gems) * * @apiSuccess {object} data.items user.items * @apiSuccess {number} data.balance user.balance @@ -816,7 +818,7 @@ api.purchase = { }; /** -* @api {post} /api/v3/user/purchase-hourglass/:type/:key Purchase Hourglass. +* @api {post} /api/v3/user/purchase-hourglass/:type/:key Purchase Hourglass-purchasable item * @apiVersion 3.0.0 * @apiName UserPurchaseHourglass * @apiGroup User @@ -841,7 +843,7 @@ api.userPurchaseHourglass = { }; /** -* @api {post} /api/v3/user/read-card/:cardType Reads a card. +* @api {post} /api/v3/user/read-card/:cardType Reads a card * @apiVersion 3.0.0 * @apiName UserReadCard * @apiGroup User @@ -865,7 +867,7 @@ api.readCard = { }; /** -* @api {post} /api/v3/user/open-mystery-item Open the mystery item. +* @api {post} /api/v3/user/open-mystery-item Open the Mystery Item box * @apiVersion 3.0.0 * @apiName UserOpenMysteryItem * @apiGroup User @@ -955,7 +957,7 @@ api.deleteWebhook = { }; -/* @api {post} /api/v3/user/release-pets Releases pets. +/* @api {post} /api/v3/user/release-pets Release pets * @apiVersion 3.0.0 * @apiName UserReleasePets * @apiGroup User @@ -976,7 +978,7 @@ api.userReleasePets = { }; /* -* @api {post} /api/v3/user/release-both Releases Pets and Mounts and grants Triad Bingo. +* @api {post} /api/v3/user/release-both Release pets and mounts and grants Triad Bingo * @apiVersion 3.0.0 * @apiName UserReleaseBoth * @apiGroup User @@ -999,7 +1001,7 @@ api.userReleaseBoth = { }; /* -* @api {post} /api/v3/user/release-mounts Released mounts. +* @api {post} /api/v3/user/release-mounts Release mounts * @apiVersion 3.0.0 * @apiName UserReleaseMounts * @apiGroup User @@ -1020,12 +1022,12 @@ api.userReleaseMounts = { }; /* -* @api {post} /api/v3/user/sell/:type/:key Sells a gold item owned by the user. +* @api {post} /api/v3/user/sell/:type/:key Sell a gold-sellable item owned by the user * @apiVersion 3.0.0 * @apiName UserSell * @apiGroup User * -* @apiParam {string} type The type of item to sell. Acceptable types are eggs, hatchingPotions, food +* @apiParam {string} type The type of item to sell. Must be one of: eggs, hatchingPotions, or food * @apiParam {string} key The key of the item * * @apiSuccess {Object} data.stats @@ -1045,7 +1047,7 @@ api.userSell = { }; /* -* @api {post} /api/v3/user/unlock Unlocks items by purchase. +* @api {post} /api/v3/user/unlock Unlock item or set of items by purchase * @apiVersion 3.0.0 * @apiName UserUnlock * @apiGroup User @@ -1053,9 +1055,9 @@ api.userSell = { * @apiParam {string} path Query parameter. The path to unlock * * @apiSuccess {Object} data.purchased -* @apiSuccess {Object} data.items` -* @apiSuccess {Object} data.preferences` -* @apiSuccess {string} message` +* @apiSuccess {Object} data.items +* @apiSuccess {Object} data.preferences +* @apiSuccess {string} message */ api.userUnlock = { method: 'POST', @@ -1070,7 +1072,7 @@ api.userUnlock = { }; /** -* @api {post} /api/v3/user/revive Revives user from death. +* @api {post} /api/v3/user/revive Revive user from death * @apiVersion 3.0.0 * @apiName UserRevive * @apiGroup User @@ -1128,7 +1130,8 @@ api.userRebirth = { }; /** - * @api {post} /api/v3/user/block/:uuid Blocks and unblocks a user + * @api {post} /api/v3/user/block/:uuid Block and unblock a user + * @apiDescription Must be an admin to make this request. * @apiVersion 3.0.0 * @apiName BlockUser * @apiGroup User @@ -1212,7 +1215,7 @@ api.markPmsRead = { }; /* -* @api {post} /api/v3/user/reroll Rerolls a user. +* @api {post} /api/v3/user/reroll Reroll a user using the Fortify Potion * @apiVersion 3.0.0 * @apiName UserReroll * @apiGroup User @@ -1248,7 +1251,7 @@ api.userReroll = { }; /* -* @api {post} /api/v3/user/addPushDevice Adds a push device to a user. +* @api {post} /api/v3/user/addPushDevice Add a push device to a user * @apiVersion 3.0.0 * @apiName UserAddPushDevice * @apiGroup User @@ -1274,7 +1277,7 @@ api.userAddPushDevice = { }; /* -* @api {post} /api/v3/user/reset Resets a user. +* @api {post} /api/v3/user/reset Reset user * @apiVersion 3.0.0 * @apiName UserReset * @apiGroup User diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js index 10519807e3..477c8ea829 100644 --- a/website/server/controllers/top-level/dataexport.js +++ b/website/server/controllers/top-level/dataexport.js @@ -26,8 +26,8 @@ const BASE_URL = nconf.get('BASE_URL'); let api = {}; /** - * @api {get} /export/history.csv Export user tasks history in CSV format. - * @apiDescription History is only available for habits and dailys so todos and rewards won't be included NOTE: Part of the private API that may change at any time. + * @api {get} /export/history.csv Export user tasks history in CSV format + * @apiDescription History is only available for habits and dailys so todos and rewards won't be included. NOTE: Part of the private API that may change at any time. * @apiVersion 3.0.0 * @apiName ExportUserHistory * @apiGroup DataExport @@ -94,7 +94,7 @@ async function _getUserDataForExport (user) { } /** - * @api {get} /export/userdata.json Export user data in JSON format. + * @api {get} /export/userdata.json Export user data in JSON format * @apiVersion 3.0.0 * @apiName ExportUserDataJson * @apiGroup DataExport From cfb5e2be60bb23aa9f261fe33feae1ea4e7c15b3 Mon Sep 17 00:00:00 2001 From: Alys Date: Tue, 17 May 2016 02:13:49 +1000 Subject: [PATCH 882/976] fix bug in Rebirth test, add new tests, adjust apidocs (#7293) --- .../user/POST-user_rebirth.test.js | 1 + test/common/ops/rebirth.js | 62 ++++++++++++++++--- website/server/controllers/api-v3/user.js | 4 +- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/test/api/v3/integration/user/POST-user_rebirth.test.js b/test/api/v3/integration/user/POST-user_rebirth.test.js index 3591844c3c..21fbed0b8d 100644 --- a/test/api/v3/integration/user/POST-user_rebirth.test.js +++ b/test/api/v3/integration/user/POST-user_rebirth.test.js @@ -31,6 +31,7 @@ describe('POST /user/rebirth', () => { let daily = await generateDaily({ text: 'test habit', type: 'daily', + value: 1, streak: 1, userId: user._id, }); diff --git a/test/common/ops/rebirth.js b/test/common/ops/rebirth.js index 0ab66674b3..9916144669 100644 --- a/test/common/ops/rebirth.js +++ b/test/common/ops/rebirth.js @@ -3,7 +3,9 @@ import i18n from '../../../common/script/i18n'; import { MAX_LEVEL } from '../../../common/script/constants'; import { generateUser, + generateHabit, generateDaily, + generateTodo, generateReward, } from '../../helpers/common.helper'; import { @@ -19,7 +21,7 @@ describe('shared.ops.rebirth', () => { beforeEach(() => { user = generateUser(); user.balance = 2; - tasks = [generateDaily(), generateReward()]; + tasks = [generateHabit(), generateDaily(), generateTodo(), generateReward()]; }); it('returns an error when user balance is too low and user is less than max level', (done) => { @@ -49,22 +51,35 @@ describe('shared.ops.rebirth', () => { expect(message).to.equal(i18n.t('rebirthComplete')); }); - it('resets user\'s taks values except for rewards to 0', () => { + it('rebirths a user with not enough gems but more than max level', () => { + user.balance = 0; + user.stats.lvl = MAX_LEVEL + 1; + + let [, message] = rebirth(user); + + expect(message).to.equal(i18n.t('rebirthComplete')); + }); + + it('resets user\'s tasks values except for rewards to 0', () => { tasks[0].value = 1; tasks[1].value = 1; + tasks[2].value = 1; + tasks[3].value = 1; // Reward rebirth(user, tasks); expect(tasks[0].value).to.equal(0); - expect(tasks[1].value).to.equal(1); + expect(tasks[1].value).to.equal(0); + expect(tasks[2].value).to.equal(0); + expect(tasks[3].value).to.equal(1); // Reward }); it('resets user\'s daily streaks to 0', () => { - tasks[0].streak = 1; + tasks[1].streak = 1; // Daily rebirth(user, tasks); - expect(tasks[0].streak).to.equal(0); + expect(tasks[1].streak).to.equal(0); }); it('resets a user\'s buffs', () => { @@ -156,7 +171,7 @@ describe('shared.ops.rebirth', () => { expect(user.flags.dropsEnabled).to.be.false; expect(user.flags.classSelected).to.be.false; expect(user.flags.rebirthEnabled).to.be.false; - expect(user.flags.levelDrops).to.be.emtpy; + expect(user.flags.levelDrops).to.be.empty; }); it('does not reset rebirthEnabled if user has beastMaster', () => { @@ -175,7 +190,7 @@ describe('shared.ops.rebirth', () => { expect(user.achievements.rebirthLevel).to.equal(user.stats.lvl); }); - it('increments rebirth achievemnts', () => { + it('increments rebirth achievements', () => { user.stats.lvl = 2; user.achievements.rebirths = 1; user.achievements.rebirthLevel = 1; @@ -185,4 +200,37 @@ describe('shared.ops.rebirth', () => { expect(user.achievements.rebirths).to.equal(2); expect(user.achievements.rebirthLevel).to.equal(2); }); + + it('does not increment rebirth achievements when level is lower than previous', () => { + user.stats.lvl = 2; + user.achievements.rebirths = 1; + user.achievements.rebirthLevel = 3; + + rebirth(user); + + expect(user.achievements.rebirths).to.equal(1); + expect(user.achievements.rebirthLevel).to.equal(3); + }); + + it('always increments rebirth achievements when level is MAX_LEVEL', () => { + user.stats.lvl = MAX_LEVEL; + user.achievements.rebirths = 1; + user.achievements.rebirthLevel = MAX_LEVEL + 1; // this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test + + rebirth(user); + + expect(user.achievements.rebirths).to.equal(2); + expect(user.achievements.rebirthLevel).to.equal(MAX_LEVEL); + }); + + it('always increments rebirth achievements when level is greater than MAX_LEVEL', () => { + user.stats.lvl = MAX_LEVEL + 1; + user.achievements.rebirths = 1; + user.achievements.rebirthLevel = MAX_LEVEL + 2; // this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test + + rebirth(user); + + expect(user.achievements.rebirths).to.equal(2); + expect(user.achievements.rebirthLevel).to.equal(MAX_LEVEL); + }); }); diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 2e22a6b662..789444ea6c 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -1093,12 +1093,12 @@ api.userRevive = { }; /* -* @api {post} /api/v3/user/rebirth Resets a user. +* @api {post} /api/v3/user/rebirth Use Orb of Rebirth on user * @apiVersion 3.0.0 * @apiName UserRebirth * @apiGroup User * -* @apiSuccess {Object} data.userr +* @apiSuccess {Object} data.user * @apiSuccess {array} data.tasks User's modified tasks (no rewards) * @apiSuccess {string} message Success message */ From dbd6daeca013c65eb3185ff3e74bc867c7c303c1 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 16 May 2016 13:12:46 -0500 Subject: [PATCH 883/976] Updated task model to allow setting streak (#7306) --- test/api/v3/integration/tasks/POST-tasks_user.test.js | 4 +--- website/server/models/task.js | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js index b39e640480..623d9bb4a4 100644 --- a/test/api/v3/integration/tasks/POST-tasks_user.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -108,7 +108,7 @@ describe('POST /tasks/user', () => { }); it(`ignores setting userId, history, createdAt, - updatedAt, challenge, completed, streak, + updatedAt, challenge, completed, dateCompleted fields`, async () => { let task = await user.post('/tasks/user', { text: 'test daily', @@ -119,7 +119,6 @@ describe('POST /tasks/user', () => { updatedAt: 'tomorrow', challenge: 'no', completed: true, - streak: 25, dateCompleted: 'never', value: 324, // ignored because not a reward }); @@ -130,7 +129,6 @@ describe('POST /tasks/user', () => { expect(task.updatedAt).not.to.equal('tomorrow'); expect(task.challenge).not.to.equal('no'); expect(task.completed).to.equal(false); - expect(task.streak).to.equal(0); expect(task.streak).not.to.equal('never'); expect(task.value).not.to.equal(324); }); diff --git a/website/server/models/task.js b/website/server/models/task.js index 0459a74580..27f356efa3 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -56,7 +56,7 @@ export let TaskSchema = new Schema({ }, discriminatorOptions)); TaskSchema.plugin(baseModel, { - noSet: ['challenge', 'userId', 'completed', 'history', 'streak', 'dateCompleted', 'completed'], + noSet: ['challenge', 'userId', 'completed', 'history', 'dateCompleted', 'completed'], sanitizeTransform (taskObj) { if (taskObj.type && taskObj.type !== 'reward') { // value should be settable directly only for rewards delete taskObj.value; From e6b5fe4013638f0333875f2bef0cc6217bd75609 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 16 May 2016 13:45:04 -0500 Subject: [PATCH 884/976] fix: Correct missing * in apidoc comments --- website/server/controllers/api-v3/user.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 789444ea6c..6358d5d25b 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -887,7 +887,7 @@ api.userOpenMysteryItem = { }, }; -/* +/** * @api {post} /api/v3/user/webhook Create a new webhook * @apiVersion 3.0.0 * @apiName UserAddWebhook @@ -910,7 +910,7 @@ api.addWebhook = { }, }; -/* +/** * @api {put} /api/v3/user/webhook/:id Edit a webhook * @apiVersion 3.0.0 * @apiName UserUpdateWebhook @@ -934,7 +934,7 @@ api.updateWebhook = { }, }; -/* +/** * @api {delete} /api/v3/user/webhook/:id Delete a webhook * @apiVersion 3.0.0 * @apiName UserDeleteWebhook @@ -977,7 +977,7 @@ api.userReleasePets = { }, }; -/* +/** * @api {post} /api/v3/user/release-both Release pets and mounts and grants Triad Bingo * @apiVersion 3.0.0 * @apiName UserReleaseBoth @@ -1000,7 +1000,7 @@ api.userReleaseBoth = { }, }; -/* +/** * @api {post} /api/v3/user/release-mounts Release mounts * @apiVersion 3.0.0 * @apiName UserReleaseMounts @@ -1021,7 +1021,7 @@ api.userReleaseMounts = { }, }; -/* +/** * @api {post} /api/v3/user/sell/:type/:key Sell a gold-sellable item owned by the user * @apiVersion 3.0.0 * @apiName UserSell @@ -1046,7 +1046,7 @@ api.userSell = { }, }; -/* +/** * @api {post} /api/v3/user/unlock Unlock item or set of items by purchase * @apiVersion 3.0.0 * @apiName UserUnlock @@ -1092,7 +1092,7 @@ api.userRevive = { }, }; -/* +/** * @api {post} /api/v3/user/rebirth Use Orb of Rebirth on user * @apiVersion 3.0.0 * @apiName UserRebirth @@ -1214,7 +1214,7 @@ api.markPmsRead = { }, }; -/* +/** * @api {post} /api/v3/user/reroll Reroll a user using the Fortify Potion * @apiVersion 3.0.0 * @apiName UserReroll @@ -1250,7 +1250,7 @@ api.userReroll = { }, }; -/* +/** * @api {post} /api/v3/user/addPushDevice Add a push device to a user * @apiVersion 3.0.0 * @apiName UserAddPushDevice @@ -1276,7 +1276,7 @@ api.userAddPushDevice = { }, }; -/* +/** * @api {post} /api/v3/user/reset Reset user * @apiVersion 3.0.0 * @apiName UserReset From bc44fa062e397411837ab37531b26b3b4a923146 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 16 May 2016 14:49:22 -0500 Subject: [PATCH 885/976] Api v3 challenge fixes (#7287) * Fixed join/leave button updates * Queried only user groups to be available when creating challenges * Fixed bulk add tasks to challenge * Synced challenge tasks after leave and join. * Fixed default selected group * Fixed challenge member info. Fixed challenge winner selection * Fixed deleting challenge tasks * Fixed particiapting filter * Fixed viewing user progress on challenge * Updated tests * Added delete for saved challenge task --- test/spec/controllers/challengesCtrlSpec.js | 14 +++-- test/spec/services/challengeServicesSpec.js | 2 +- website/client/js/app.js | 30 +++++++--- .../client/js/controllers/challengesCtrl.js | 60 +++++++++++++++---- .../client/js/services/challengeServices.js | 2 +- website/client/js/services/userServices.js | 21 ++++--- website/views/options/social/challenges.jade | 6 +- website/views/shared/tasks/meta_controls.jade | 2 +- 8 files changed, 100 insertions(+), 37 deletions(-) diff --git a/test/spec/controllers/challengesCtrlSpec.js b/test/spec/controllers/challengesCtrlSpec.js index 495f1b020b..75d9759850 100644 --- a/test/spec/controllers/challengesCtrlSpec.js +++ b/test/spec/controllers/challengesCtrlSpec.js @@ -41,30 +41,36 @@ describe('Challenges Controller', function() { description: 'You are the owner and member', leader: user._id, members: [user], - _isMember: true + _isMember: true, + _id: 'ownMem-id', }); ownNotMem = specHelper.newChallenge({ description: 'You are the owner, but not a member', leader: user._id, members: [], - _isMember: false + _isMember: false, + _id: 'ownNotMem-id', }); notOwnMem = specHelper.newChallenge({ description: 'Not owner but a member', leader: {_id:"test"}, members: [user], - _isMember: true + _isMember: true, + _id: 'notOwnMem-id', }); notOwnNotMem = specHelper.newChallenge({ description: 'Not owner or member', leader: {_id:"test"}, members: [], - _isMember: false + _isMember: false, + _id: 'notOwnNotMem-id', }); + user.challenges = [ownMem._id, notOwnMem._id]; + scope.search = { group: _.transform(groups, function(m,g){m[g._id]=true;}) }; diff --git a/test/spec/services/challengeServicesSpec.js b/test/spec/services/challengeServicesSpec.js index abfe7c520a..9bed72db31 100644 --- a/test/spec/services/challengeServicesSpec.js +++ b/test/spec/services/challengeServicesSpec.js @@ -81,7 +81,7 @@ describe('challengeServices', function() { it('calls select challenge winner endpoint', function() { var challengeId = 1; var winnerId = 2; - $httpBackend.expectPOST(apiV3Prefix + '/challenges/' + challengeId + 'selectWinner/' + winnerId).respond({}); + $httpBackend.expectPOST(apiV3Prefix + '/challenges/' + challengeId + '/selectWinner/' + winnerId).respond({}); challenges.selectChallengeWinner(challengeId, winnerId); $httpBackend.flush(); }); diff --git a/website/client/js/app.js b/website/client/js/app.js index a128c2ce88..672ef32f09 100644 --- a/website/client/js/app.js +++ b/website/client/js/app.js @@ -184,8 +184,8 @@ window.habitrpg = angular.module('habitrpg', url: '/:cid', templateUrl: 'partials/options.social.challenges.detail.html', title: env.t('titleChallenges'), - controller: ['$scope', 'Challenges', '$stateParams', 'Tasks', - function ($scope, Challenges, $stateParams, Tasks) { + controller: ['$scope', 'Challenges', '$stateParams', 'Tasks', 'Members', + function ($scope, Challenges, $stateParams, Tasks, Members) { Challenges.getChallenge($stateParams.cid) .then(function (response) { $scope.obj = $scope.challenge = response.data.data; @@ -198,6 +198,11 @@ window.habitrpg = angular.module('habitrpg', if (!$scope.challenge[element.type + 's']) $scope.challenge[element.type + 's'] = []; $scope.challenge[element.type + 's'].push(element); }) + + return Members.getChallengeMembers($scope.challenge._id); + }) + .then(function (response) { + $scope.challenge.members = response.data.data; }); }] }) @@ -226,11 +231,22 @@ window.habitrpg = angular.module('habitrpg', url: '/:uid', templateUrl: 'partials/options.social.challenges.detail.member.html', title: env.t('titleChallenges'), - controller: ['$scope', 'Challenges', '$stateParams', - function($scope, Challenges, $stateParams){ - $scope.obj = Challenges.Challenge.getMember({cid:$stateParams.cid, uid:$stateParams.uid}, function(){ - $scope.obj._locked = true; - }); + controller: ['$scope', 'Members', '$stateParams', + function($scope, Members, $stateParams){ + Members.getChallengeMemberProgress($stateParams.cid, $stateParams.uid) + .then(function(response) { + $scope.obj = response.data.data; + + $scope.obj.habits = []; + $scope.obj.todos = []; + $scope.obj.dailys = []; + $scope.obj.rewards = []; + $scope.obj.tasks.forEach(function (element, index, array) { + $scope.obj[element.type + 's'].push(element) + }); + + $scope.obj._locked = true; + }); }] }) diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index ef919d590b..5a689ae7ca 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -11,7 +11,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', // FIXME $scope.challenges needs to be resolved first (see app.js) $scope.groups = []; - Groups.Group.getGroups('party,publicGuilds,privateGuilds,tavern') + Groups.Group.getGroups('party,guilds,tavern') .then(function (response) { $scope.groups = response.data.data; }); @@ -40,7 +40,6 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', * Create */ $scope.create = function() { - //If the user has one filter selected, assume that the user wants to default to that group var defaultGroup; //Our filters contain all groups, but we only want groups that have atleast one challenge @@ -49,12 +48,12 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', var filterCount = 0; for ( var i = 0; i < len; i += 1 ) { - if ( $scope.search.group[groupsWithChallenges[i]] == true ) { + if ($scope.search.group[groupsWithChallenges[i]] === true) { filterCount += 1; defaultGroup = groupsWithChallenges[i]; } - if (filterCount > 1) { - defaultGroup = $scope.groups[0]._id + + if (filterCount >= 1 && defaultGroup) { break; } } @@ -200,7 +199,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', if (!challenge.winner) return; if (!confirm(window.env.t('youSure'))) return; - Challenges.selectWinner(challenge._id, challenge.winner) + Challenges.selectChallengeWinner(challenge._id, challenge.winner) .then(function (response) { $scope.popoverEl.popover('destroy'); _backToChallenges(); @@ -239,7 +238,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', //------------------------------------------------------------ // Tasks //------------------------------------------------------------ - $scope.addTask = function(addTo, listDef, challenge) { + function addTask (addTo, listDef, challenge) { var task = Shared.taskDefaults({text: listDef.newTask, type: listDef.type}); //If the challenge has not been created, we bulk add tasks on save if (challenge._id) Tasks.createChallengeTasks(challenge._id, task); @@ -248,9 +247,25 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', delete listDef.newTask; }; + $scope.addTask = function(addTo, listDef, challenge) { + if (listDef.bulk) { + var tasks = listDef.newTask.split(/[\n\r]+/); + //Reverse the order of tasks so the tasks will appear in the order the user entered them + tasks.reverse(); + _.each(tasks, function(t) { + listDef.newTask = t; + addTask(addTo, listDef, challenge); + }); + listDef.bulk = false; + } else { + addTask(addTo, listDef, challenge); + } + } + $scope.removeTask = function(task, challenge) { if (!confirm(window.env.t('sureDelete', {taskType: window.env.t(task.type), taskText: task.text}))) return; - Tasks.deleteTask(task._id); + //We only pass to the api if the challenge exists, otherwise, the tasks only exist on the client + if (challenge._id) Tasks.deleteTask(task._id); var index = challenge[task.type + 's'].indexOf(task); challenge[task.type + 's'].splice(index, 1); }; @@ -260,6 +275,14 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', // TODO persist } + $scope.toggleBulk = function(list) { + if (typeof list.bulk === 'undefined') { + list.bulk = false; + } + list.bulk = !list.bulk; + list.focus = true; + }; + /* -------------------------- Subscription @@ -269,7 +292,13 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.join = function (challenge) { Challenges.joinChallenge(challenge._id) .then(function (response) { - _getChallenges() + User.user.challenges.push(challenge._id); + _getChallenges(); + return Tasks.getUserTasks(); + }) + .then(function (response) { + var tasks = response.data.data; + User.syncUserTasks(tasks); }); } @@ -279,7 +308,14 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', } else { Challenges.leaveChallenge($scope.selectedChal._id, keep) .then(function (response) { - _getChallenges() + var index = User.user.challenges.indexOf($scope.selectedChal._id); + delete User.user.challenges[index]; + _getChallenges(); + return Tasks.getUserTasks(); + }) + .then(function (response) { + var tasks = response.data.data; + User.syncUserTasks(tasks); }); } $scope.popoverEl.popover('destroy'); @@ -369,7 +405,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.groupsFilter = _.uniq(_.pluck($scope.challenges, 'group'), function(g) {return g._id}); $scope.search = { - group: _.transform($scope.groups, function(m,g){ m[g._id] = true;}), + group: _.transform($scope.groups, function(m,g) { m[g._id] = true;}), _isMember: "either", _isOwner: "either" }; @@ -407,7 +443,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', var groupSelected = $scope.search.group[chal.group._id]; var checkOwner = $scope.search._isOwner === 'either' || (userIsOwner === $scope.search._isOwner); - var checkMember = $scope.search._isMember === 'either' || (chal._isMember === $scope.search._isMember); + var checkMember = $scope.search._isMember === 'either' || ($scope.isUserMemberOf(chal) === $scope.search._isMember); return groupSelected && checkOwner && checkMember; } diff --git a/website/client/js/services/challengeServices.js b/website/client/js/services/challengeServices.js index 257aa65fac..a577d5f759 100644 --- a/website/client/js/services/challengeServices.js +++ b/website/client/js/services/challengeServices.js @@ -76,7 +76,7 @@ angular.module('habitrpg') function selectChallengeWinner (challengeId, winnerId) { return $http({ method: 'POST', - url: apiV3Prefix + '/challenges/' + challengeId + 'selectWinner/' + winnerId, + url: apiV3Prefix + '/challenges/' + challengeId + '/selectWinner/' + winnerId, }); } diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index a25584e5fb..85a38429a1 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -45,6 +45,16 @@ angular.module('habitrpg') user._wrapped = false; + function syncUserTasks (tasks) { + user.habits = []; + user.todos = []; + user.dailys = []; + user.rewards = []; + tasks.forEach(function (element, index, array) { + user[element.type + 's'].push(element) + }); + } + function sync() { return $http({ method: "GET", @@ -76,14 +86,7 @@ angular.module('habitrpg') }) .then(function (response) { var tasks = response.data.data; - user.habits = []; - user.todos = []; - user.dailys = []; - user.rewards = []; - tasks.forEach(function (element, index, array) { - user[element.type + 's'].push(element) - }) - + syncUserTasks(tasks); save(); $rootScope.$emit('userSynced'); }); @@ -496,6 +499,8 @@ angular.module('habitrpg') return sync(); }, + syncUserTasks: syncUserTasks, + save: save, settings: settings diff --git a/website/views/options/social/challenges.jade b/website/views/options/social/challenges.jade index a5e2fa16cf..ad95a7c992 100644 --- a/website/views/options/social/challenges.jade +++ b/website/views/options/social/challenges.jade @@ -178,7 +178,7 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') // Challenges list .panel-group - .panel.panel-default(ng-repeat='challenge in challenges|filter:filterChallenges track by challenge._id ') + .panel.panel-default(ng-repeat='challenge in challenges | filter:filterChallenges track by challenge._id ') .panel-heading ul.pull-right.challenge-accordion-header-specs li.bg-transparent(ng-if='challenge.official') @@ -199,10 +199,10 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') p!=env.t('prizeValue', {gemcount: "{{challenge.prize}}", gemicon: ""}) li.bg-transparent // leave / join - a.btn.btn-sm.btn-danger(ng-show='::isUserMemberOf(challenge)', ng-click='clickLeave(challenge, $event)') + a.btn.btn-sm.btn-danger(ng-show='isUserMemberOf(challenge)', ng-click='clickLeave(challenge, $event)') span.glyphicon.glyphicon-ban-circle =env.t('leave') - a.btn.btn-sm.btn-success(ng-hide='::isUserMemberOf(challenge)', ng-click='join(challenge)') + a.btn.btn-sm.btn-success(ng-hide='isUserMemberOf(challenge)', ng-click='join(challenge)') span.glyphicon.glyphicon-ok =env.t('join') a.accordion-toggle(id="{{challenge._id}}" ng-click='toggle(challenge._id)') diff --git a/website/views/shared/tasks/meta_controls.jade b/website/views/shared/tasks/meta_controls.jade index c080e3deaa..3feaef0d87 100644 --- a/website/views/shared/tasks/meta_controls.jade +++ b/website/views/shared/tasks/meta_controls.jade @@ -43,7 +43,7 @@ span.glyphicon.glyphicon-bullhorn(tooltip=env.t('challenge')) |   // delete - a(ng-if='!task.challenge.id', ng-click='removeTask(task, $index)', tooltip=env.t('delete')) + a(ng-if='!task.challenge.id || obj.leader._id === User.user._id', ng-click='removeTask(task, obj)', tooltip=env.t('delete')) span.glyphicon.glyphicon-trash |   From ab6e34aa6fbb9b2d74ef4dc036051feaabc2417e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 16 May 2016 22:50:58 +0200 Subject: [PATCH 886/976] v3: fix sorting --- website/client/js/services/userServices.js | 25 +++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 85a38429a1..3a0b7b298d 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -50,9 +50,28 @@ angular.module('habitrpg') user.todos = []; user.dailys = []; user.rewards = []; - tasks.forEach(function (element, index, array) { - user[element.type + 's'].push(element) - }); + + // Order tasks based on tasksOrder + var groupedTasks = _(tasks) + .groupBy('type') + .forEach(function (tasksOfType, type) { + var order = user.tasksOrder[type + 's']; + var orderedTasks = new Array(tasksOfType.length); + var unorderedTasks = []; // what we want to add later + + tasksOfType.forEach((task, index) => { + var taskId = task._id; + var i = order[index] === taskId ? index : order.indexOf(taskId); + if (i === -1) { + unorderedTasks.push(task); + } else { + orderedTasks[i] = task; + } + }); + + // Remove empty values from the array and add any unordered task + user[type + 's'] = _.compact(orderedTasks).concat(unorderedTasks); + }).value(); } function sync() { From cad538dd51893bcb2f219e4a6204f1bf4caa7c43 Mon Sep 17 00:00:00 2001 From: Alys Date: Tue, 17 May 2016 06:52:56 +1000 Subject: [PATCH 887/976] [API v3] add CRON_SAFE_MODE (#7286) * add CRON_SAFE_MODE to example config file, fix some bugs, add an unrelated low-priority TODO * create CRON_SAFE_MODE to disable parts of cron for use after extended outage - fixes https://github.com/HabitRPG/habitrpg/issues/7161 * fix a bug with CRON_SAFE_MODE, remove duplicated code, remove completed TODO comment * fix check for CRON_SAFE_MODE --- config.json.example | 1 + website/server/libs/api-v3/cron.js | 65 ++++++++++++++++-------------- website/server/models/group.js | 6 ++- 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/config.json.example b/config.json.example index ca012501bb..35609d1c93 100644 --- a/config.json.example +++ b/config.json.example @@ -9,6 +9,7 @@ "NODE_DB_URI":"mongodb://localhost/habitrpg", "TEST_DB_URI":"mongodb://localhost/habitrpg_test", "NODE_ENV":"development", + "CRON_SAFE_MODE":"false", "SESSION_SECRET":"YOUR SECRET HERE", "ADMIN_EMAIL": "you@example.com", "SMTP_USER":"user@example.com", diff --git a/website/server/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js index c86dfad2ca..da076b54ce 100644 --- a/website/server/libs/api-v3/cron.js +++ b/website/server/libs/api-v3/cron.js @@ -2,7 +2,9 @@ import moment from 'moment'; import common from '../../../../common/'; import { preenUserHistory } from '../../libs/api-v3/preening'; import _ from 'lodash'; +import nconf from 'nconf'; +const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true'; const shouldDo = common.shouldDo; const scoreTask = common.ops.scoreTask; // const maxPMs = 200; @@ -68,6 +70,7 @@ function performSleepTasks (user, tasksByType, now) { let thatDay = moment(now).subtract({days: 1}); if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + // TODO also untick checklists if the Daily was due on previous missed days, if two or more days were missed at once -- https://github.com/HabitRPG/habitrpg/pull/7218#issuecomment-219256016 daily.checklist.forEach(box => box.completed = false); } @@ -90,7 +93,7 @@ export function cron (options = {}) { if (user.isSubscribed()) { grantEndOfTheMonthPerks(user, now); - removeTerminatedSubscription(user); + if (!CRON_SAFE_MODE) removeTerminatedSubscription(user); } // User is resting at the inn. @@ -150,31 +153,36 @@ export function cron (options = {}) { } if (scheduleMisses > EvadeTask) { - perfect = false; - - if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points - let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; - dailyDueUnchecked += 1 - fractionChecked; - dailyChecked += fractionChecked; + // The user did not complete this due Daily (but no penalty if cron is running in safe mode). + if (CRON_SAFE_MODE) { + dailyChecked += 1; // allows full allotment of mp to be gained } else { - dailyDueUnchecked += 1; + perfect = false; + + if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points + let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + dailyDueUnchecked += 1 - fractionChecked; + dailyChecked += fractionChecked; + } else { + dailyDueUnchecked += 1; + } + + let delta = scoreTask({ + user, + task, + direction: 'down', + times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, + cron: true, + }); + + // Apply damage from a boss, less damage for Trivial priority (difficulty) + user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); + // NB: Medium and Hard priorities do not increase damage from boss. This was by accident + // initially, and when we realised, we could not fix it because users are used to + // their Medium and Hard Dailies doing an Easy amount of damage from boss. + // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future + // setting between Trivial and Easy. } - - let delta = scoreTask({ - user, - task, - direction: 'down', - times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, - cron: true, - }); - - // Apply damage from a boss, less damage for Trivial priority (difficulty) - user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); - // NB: Medium and Hard priorities do not increase damage from boss. This was by accident - // initially, and when we realised, we could not fix it because users are used to - // their Medium and Hard Dailies doing an Easy amount of damage from boss. - // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future - // setting between Trivial and Easy. } } @@ -185,7 +193,7 @@ export function cron (options = {}) { task.completed = false; if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = false); // TODO this should not happen for grey tasks unless they are completed + task.checklist.forEach(i => i.completed = false); } }); @@ -231,14 +239,9 @@ export function cron (options = {}) { // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit // Adjust for fraction of dailies completed - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; - if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) { - user.stats.mp = user._statsComputed.maxMP; - } + if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; // After all is said and done, progress up user's effect on quest, return those values & reset the user's let progress = user.party.quest.progress; diff --git a/website/server/models/group.js b/website/server/models/group.js index 347079c96a..d22e63cf70 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -515,8 +515,10 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { let down = progress.down * quest.boss.str; // multiply by boss strength group.quest.progress.hp -= progress.up; - // TODO Create a party preferred language option so emits like this can be localized - group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage.\` \`${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``); + // TODO Create a party preferred language option so emits like this can be localized. Suggestion: Always display the English version too. Or, if English is not displayed to the players, at least include it in a new field in the chat object that's visible in the database - essential for admins when troubleshooting quests! + let playerAttack = `${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage.`; + let bossAttack = nconf.get('CRON_SAFE_MODE') === 'true' ? `${quest.boss.name('en')} did not attack the party because it was asleep while maintenance was happening.` : `${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.`; + group.sendChat(`\`${playerAttack}\` \`${bossAttack}\``); // If boss has Rage, increment Rage as well if (quest.boss.rage) { From c1ef633b0f13e0885cd2835b1553565a17dc23cf Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 16 May 2016 23:10:19 +0200 Subject: [PATCH 888/976] v3 client: fix typo --- website/client/js/services/userServices.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 3a0b7b298d..7742ee7a3d 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -59,7 +59,7 @@ angular.module('habitrpg') var orderedTasks = new Array(tasksOfType.length); var unorderedTasks = []; // what we want to add later - tasksOfType.forEach((task, index) => { + tasksOfType.forEach(function (task, index) { var taskId = task._id; var i = order[index] === taskId ? index : order.indexOf(taskId); if (i === -1) { From daf899374de77d22cab03ba0d9f6449bc62933dd Mon Sep 17 00:00:00 2001 From: Alys Date: Mon, 16 May 2016 23:01:23 -0400 Subject: [PATCH 889/976] adjust debug menu Modify Inventory: hungrier pets, fewer Special items, "Hide" buttons --- website/views/shared/modals/modify-inventory.jade | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/website/views/shared/modals/modify-inventory.jade b/website/views/shared/modals/modify-inventory.jade index a7a87a2de2..fd7ae0c61f 100644 --- a/website/views/shared/modals/modify-inventory.jade +++ b/website/views/shared/modals/modify-inventory.jade @@ -6,6 +6,7 @@ script(type='text/ng-template', id='modals/modify-inventory.html') .row .col-xs-12 button.btn.btn-default.pull-right(ng-if="!showInv.gear", ng-click="showInv.gear = true") Show Gear + button.btn.btn-default.pull-right(ng-if="showInv.gear", ng-click="showInv.gear = false") Hide Gear h4 Gear div(ng-if="showInv.gear") button.btn.btn-default(ng-click="setAllItems('gear', true)") Own All @@ -35,6 +36,7 @@ script(type='text/ng-template', id='modals/modify-inventory.html') .row .col-xs-12 button.btn.btn-default.pull-right(ng-if="!showInv.special", ng-click="showInv.special = true") Show Special Items + button.btn.btn-default.pull-right(ng-if="showInv.special", ng-click="showInv.special = false") Hide Special Items h4 Special Items div(ng-if="showInv.special") button.btn.btn-default(ng-click="setAllItems('special', 999)") Set All to 999 @@ -44,7 +46,7 @@ script(type='text/ng-template', id='modals/modify-inventory.html') hr ul.list-group - li.list-group-item(ng-repeat="item in Content.special" ng-init="inv.special[item.key] = user.items.special[item.key]") + li.list-group-item(ng-repeat="item in Content.special" ng-init="inv.special[item.key] = user.items.special[item.key]" ng-if="item.value === 15") .form-inline.clearfix .pull-left(class="inventory_special_{{::item.key}}" style="margin-right: 10px") p {{::item.text()}} @@ -55,9 +57,10 @@ script(type='text/ng-template', id='modals/modify-inventory.html') .row .col-xs-12 button.btn.btn-default.pull-right(ng-if="!showInv.pets", ng-click="showInv.pets = true") Show Pets + button.btn.btn-default.pull-right(ng-if="showInv.pets", ng-click="showInv.pets = false") Hide Pets h4 Pets div(ng-if="showInv.pets") - button.btn.btn-default(ng-click="setAllItems('pets', 99)") Set All to 99 + button.btn.btn-default(ng-click="setAllItems('pets', 45)") Set All to 45 button.btn.btn-default(ng-click="setAllItems('pets', 0)") Set All to 0 button.btn.btn-default(ng-click="setAllItems('pets', -1)") Set All to -1 button.btn.btn-default(ng-click="setAllItems('pets', undefined)") Set All to undefined @@ -101,6 +104,7 @@ script(type='text/ng-template', id='modals/modify-inventory.html') .row .col-xs-12 button.btn.btn-default.pull-right(ng-if="!showInv.mounts", ng-click="showInv.mounts = true") Show Mounts + button.btn.btn-default.pull-right(ng-if="showInv.mounts", ng-click="showInv.mounts = false") Hide Mounts h4 Mounts div(ng-if="showInv.mounts") button.btn.btn-default(ng-click="setAllItems('mounts', true)") Set all to Owned @@ -165,6 +169,7 @@ script(type='text/ng-template', id='modals/modify-inventory.html') .row .col-xs-12 button.btn.btn-default.pull-right(ng-if="!showInv.hatchingPotions", ng-click="showInv.hatchingPotions = true") Show Hatching Potions + button.btn.btn-default.pull-right(ng-if="showInv.hatchingPotions", ng-click="showInv.hatchingPotions = false") Hide Hatching Potions h4 Hatching Potions div(ng-if="showInv.hatchingPotions") button.btn.btn-default(ng-click="setAllItems('hatchingPotions', 999)") Set All to 999 @@ -185,6 +190,7 @@ script(type='text/ng-template', id='modals/modify-inventory.html') .row .col-xs-12 button.btn.btn-default.pull-right(ng-if="!showInv.eggs", ng-click="showInv.eggs = true") Show Eggs + button.btn.btn-default.pull-right(ng-if="showInv.eggs", ng-click="showInv.eggs = false") Hide Eggs h4 Eggs div(ng-if="showInv.eggs") button.btn.btn-default(ng-click="setAllItems('eggs', 999)") Set All to 999 @@ -205,6 +211,7 @@ script(type='text/ng-template', id='modals/modify-inventory.html') .row .col-xs-12 button.btn.btn-default.pull-right(ng-if="!showInv.food", ng-click="showInv.food = true") Show Food + button.btn.btn-default.pull-right(ng-if="showInv.food", ng-click="showInv.food = false") Hide Food h4 Food div(ng-if="showInv.food") button.btn.btn-default(ng-click="setAllItems('food', 999)") Set All to 999 @@ -225,6 +232,7 @@ script(type='text/ng-template', id='modals/modify-inventory.html') .row .col-xs-12 button.btn.btn-default.pull-right(ng-if="!showInv.quests", ng-click="showInv.quests = true") Show Quests + button.btn.btn-default.pull-right(ng-if="showInv.quests", ng-click="showInv.quests = false") Hide Quests h4 Quests div(ng-if="showInv.quests") button.btn.btn-default(ng-click="setAllItems('quests', 999)") Set All to 999 From 810e4cbd785db160ff0c14c69e1cc2422ab95d1b Mon Sep 17 00:00:00 2001 From: Alys Date: Tue, 17 May 2016 16:13:47 +1000 Subject: [PATCH 890/976] completed To-Dos: return the 30 most recent instead of 30 oldest (#7318) --- test/api/v3/integration/tasks/GET-tasks_user.test.js | 4 ++-- website/server/controllers/api-v3/tasks.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js index cd11100599..38b373d76e 100644 --- a/test/api/v3/integration/tasks/GET-tasks_user.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js @@ -22,7 +22,7 @@ describe('GET /tasks/user', () => { expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); - it('returns completed todos sorted by completion date if req.query.type === "completeTodos"', async () => { + it('returns completed todos sorted by reverse completion date if req.query.type === "completeTodos"', async () => { let todo1 = await user.post('/tasks/user', {text: 'todo to complete 1', type: 'todo'}); let todo2 = await user.post('/tasks/user', {text: 'todo to complete 2', type: 'todo'}); @@ -37,6 +37,6 @@ describe('GET /tasks/user', () => { let completedTodos = await user.get('/tasks/user?type=completedTodos'); expect(completedTodos.length).to.equal(2); - expect(completedTodos[completedTodos.length - 1].text).to.equal('todo to complete 1'); // last is the todo that was completed later + expect(completedTodos[completedTodos.length - 1].text).to.equal('todo to complete 2'); // last is the todo that was completed most recently }); }); diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 50e32220bc..7163e7a9e9 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -127,7 +127,7 @@ async function _getTasks (req, res, user, challenge) { type: 'todo', completed: true, }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos - dateCompleted: 1, + dateCompleted: -1, }); } else { query.type = type.slice(0, -1); // removing the final "s" From d648b0e0c88b29d074cfa759a1d791835a6fdc75 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 15:46:30 +0200 Subject: [PATCH 891/976] v3 migration: fix createdAt date --- migrations/api_v3/users.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index 87b39ae615..e569699673 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -173,7 +173,7 @@ function processUsers (afterId) { newUser.tasksOrder[`${oldTask.type}s`].push(oldTask._id); } - var allTasksFields = ['_id', 'type', 'text', 'notes', 'tags', 'value', 'priority', 'attribute', 'challenge', 'reminders', 'userId', 'legacyId']; + var allTasksFields = ['_id', 'type', 'text', 'notes', 'tags', 'value', 'priority', 'attribute', 'challenge', 'reminders', 'userId', 'legacyId', 'createdAt']; // using mongoose models is too slow if (oldTask.type === 'habit') { oldTask = _.pick(oldTask, allTasksFields.concat(['history', 'up', 'down'])); From 1cf2c89171041e342c14fc76a0c4f949eee6cc0f Mon Sep 17 00:00:00 2001 From: Alys Date: Tue, 17 May 2016 23:48:58 +1000 Subject: [PATCH 892/976] adjust locales text, key names, and files for Rebirth, Reset, and Fortify / ReRoll for consistency with existing strings (#7321) --- common/locales/en/api-v3.json | 3 --- common/locales/en/rebirth.json | 2 +- common/locales/en/settings.json | 3 ++- common/locales/en/tasks.json | 3 ++- common/script/ops/reroll.js | 2 +- test/api/v3/integration/user/POST-user_reroll.test.js | 2 +- test/common/ops/reroll.js | 2 +- 7 files changed, 8 insertions(+), 9 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 1008f699c3..5378b8baf5 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -168,12 +168,9 @@ "cannotRevive": "Cannot revive if not dead", "rebirthComplete": "You have been reborn!", "petNotOwned": "You do not own this pet.", - "rerollComplete": "Reroll complete!", - "resetComplete": "Reset has completed", "regIdRequired": "RegId is required", "pushDeviceAdded": "Push device added successfully", "pushDeviceAlreadyAdded": "The user already has the push device", - "resetComplete": "Reset completed", "lvl10ChangeClass": "To change class you must be at least level 10.", "equipmentAlreadyOwned": "You already own that piece of equipment", "pmsMarkedRead": "Your private messages have been marked as read", diff --git a/common/locales/en/rebirth.json b/common/locales/en/rebirth.json index c158232395..4d10d55d87 100644 --- a/common/locales/en/rebirth.json +++ b/common/locales/en/rebirth.json @@ -5,7 +5,7 @@ "rebirthStartOver": "Rebirth starts your character over from Level 1.", "rebirthAdvList1": "You return to full Health.", "rebirthAdvList2": "You have no Experience, Gold, or Equipment (with the exception of free items like Mystery items).", - "rebirthAdvList3": "Your Habits, Dailies, and To-Dos reset to yellow, and streaks reset.", + "rebirthAdvList3": "Your Habits, Dailies, and To-Dos reset to yellow, and streaks reset, except for challenge tasks.", "rebirthAdvList4": "You have the starting class of Warrior until you earn a new class.", "rebirthInherit": "Your new character inherits a few things from their predecessor:", "rebirthInList1": "Tasks, history, and settings remain.", diff --git a/common/locales/en/settings.json b/common/locales/en/settings.json index 3b7389b03d..8f71ab9fa3 100644 --- a/common/locales/en/settings.json +++ b/common/locales/en/settings.json @@ -61,7 +61,7 @@ "newUsername": "New Login Name", "dangerZone": "Danger Zone", "resetText1": "WARNING! This resets many parts of your account. This is highly discouraged, but some people find it useful in the beginning after playing with the site for a short time.", - "resetText2": "You will lose all your levels, gold, and experience points. All your tasks will be deleted permanently and you will lose all of your task's historical data. You will lose all your equipment but you will be able to buy it all back, including all limited edition equipment or subscriber Mystery items that you already own (you will need to be in the correct class to re-buy class-specific gear). You will keep your current class and your pets and mounts. You might prefer to use an Orb of Rebirth instead, which is a much safer option and which will preserve your tasks.", + "resetText2": "You will lose all your levels, gold, and experience points. All your tasks (except those from challenges) will be deleted permanently and you will lose all of their historical data. You will lose all your equipment but you will be able to buy it all back, including all limited edition equipment or subscriber Mystery items that you already own (you will need to be in the correct class to re-buy class-specific gear). You will keep your current class and your pets and mounts. You might prefer to use an Orb of Rebirth instead, which is a much safer option and which will preserve your tasks.", "deleteText": "Are you sure? This will delete your account forever, and it can never be restored! You will need to register a new account to use Habitica again. Banked or spent Gems will not be refunded. If you're absolutely certain, type <%= deleteWord %> into the text box below.", "API": "API", "APIText": "Copy these for use in third party applications. However, think of your API Token like a password, and do not share it publicly. You may occasionally be asked for your User ID, but never post your API Token where others can see it, including on Github.", @@ -75,6 +75,7 @@ "otherExtensions": "Other Extensions", "otherDesc": "Find other apps, extensions, and tools on the Habitica wiki.", "resetDo": "Do it, reset my account!", + "resetComplete": "Reset complete!", "fixValues": "Fix Values", "fixValuesText1": "If you've encountered a bug or made a mistake that unfairly changed your character (damage you shouldn't have taken, Gold you didn't really earn, etc.), you can manually correct your numbers here. Yes, this makes it possible to cheat: use this feature wisely, or you'll sabotage your own habit-building!", "fixValuesText2": "Note that you cannot restore Streaks on individual tasks here. To do that, edit the Daily and go to Advanced Options, where you will find a Restore Streak field.", diff --git a/common/locales/en/tasks.json b/common/locales/en/tasks.json index cedee6dec6..691c5c20fa 100644 --- a/common/locales/en/tasks.json +++ b/common/locales/en/tasks.json @@ -88,8 +88,9 @@ "fortifyName": "Fortify Potion", "fortifyPop": "Return all tasks to neutral value (yellow color), and restore all lost Health.", "fortify": "Fortify", - "fortifyText": "Fortify will return all your tasks to a neutral (yellow) state, as if you'd just added them, and top your Health off to full. This is great if all your red tasks are making the game too hard, or all your blue tasks are making the game too easy. If starting fresh sounds much more motivating, spend the Gems and catch a reprieve!", + "fortifyText": "Fortify will return all your tasks, except challenge tasks, to a neutral (yellow) state, as if you'd just added them, and top your Health off to full. This is great if all your red tasks are making the game too hard, or all your blue tasks are making the game too easy. If starting fresh sounds much more motivating, spend the Gems and catch a reprieve!", "confirmFortify": "Are you sure?", + "fortifyComplete": "Fortify complete!", "sureDelete": "Are you sure you want to delete the <%= taskType %> with the text \"<%= taskText %>\"?", "streakCoins": "Streak Bonus!", "pushTaskToTop": "Push task to top. Hold ctrl or cmd to push to bottom.", diff --git a/common/script/ops/reroll.js b/common/script/ops/reroll.js index 72414173f7..3087845509 100644 --- a/common/script/ops/reroll.js +++ b/common/script/ops/reroll.js @@ -34,7 +34,7 @@ module.exports = function reroll (user, tasks = [], req = {}, analytics) { } else { return [ {user, tasks}, - i18n.t('rerollComplete'), + i18n.t('fortifyComplete'), ]; } }; diff --git a/test/api/v3/integration/user/POST-user_reroll.test.js b/test/api/v3/integration/user/POST-user_reroll.test.js index ac11b0d463..29774d1239 100644 --- a/test/api/v3/integration/user/POST-user_reroll.test.js +++ b/test/api/v3/integration/user/POST-user_reroll.test.js @@ -47,7 +47,7 @@ describe('POST /user/reroll', () => { let updatedDaily = await user.get(`/tasks/${daily._id}`); let updatedReward = await user.get(`/tasks/${reward._id}`); - expect(response.message).to.equal(t('rerollComplete')); + expect(response.message).to.equal(t('fortifyComplete')); expect(updatedDaily.value).to.equal(0); expect(updatedReward.value).to.equal(1); }); diff --git a/test/common/ops/reroll.js b/test/common/ops/reroll.js index ede0449ee8..4dc5da70c1 100644 --- a/test/common/ops/reroll.js +++ b/test/common/ops/reroll.js @@ -34,7 +34,7 @@ describe('shared.ops.reroll', () => { it('rerolls a user with enough gems', () => { let [, message] = reroll(user); - expect(message).to.equal(i18n.t('rerollComplete')); + expect(message).to.equal(i18n.t('fortifyComplete')); }); it('reduces a user\'s balance', () => { From 41152157c23a44cbcf031970afe5e7070f56b948 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 16:24:54 +0200 Subject: [PATCH 893/976] v3: fix unlinking multiple tasks --- website/client/js/controllers/tasksCtrl.js | 15 +++-- website/client/js/services/taskServices.js | 15 ++++- website/server/controllers/api-v3/tasks.js | 73 ++++++++++++++++++++-- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/website/client/js/controllers/tasksCtrl.js b/website/client/js/controllers/tasksCtrl.js index c20dceacad..5a7fb19c09 100644 --- a/website/client/js/controllers/tasksCtrl.js +++ b/website/client/js/controllers/tasksCtrl.js @@ -129,10 +129,17 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N }; $scope.unlink = function(task, keep) { - Tasks.unlinkTask(task._id, keep) - .success(function () { - User.log({}); - }); + if (keep.search('-all') !== -1) { // unlink all tasks + Tasks.unlinkAllTasks(task.challenge.id, keep) + .success(function () { + User.sync({}); + }); + } else { // unlink a task + Tasks.unlinkOneTask(task._id, keep) + .success(function () { + User.sync({}); + }); + } }; /* diff --git a/website/client/js/services/taskServices.js b/website/client/js/services/taskServices.js index f5f9945548..e764a21eeb 100644 --- a/website/client/js/services/taskServices.js +++ b/website/client/js/services/taskServices.js @@ -120,14 +120,25 @@ angular.module('habitrpg') }); }; - function unlinkTask (taskId, keep) { + function unlinkOneTask (taskId, keep) { // single task + if (!keep) { + keep = "keep"; + } + + return $http({ + method: 'POST', + url: '/api/v3/tasks/unlink-one/' + taskId + '?keep=' + keep, + }); + }; + + function unlinkAllTasks (challengeId, keep) { // all tasks if (!keep) { keep = "keep-all"; } return $http({ method: 'POST', - url: '/api/v3/tasks/unlink/' + taskId + '?keep=' + keep, + url: '/api/v3/tasks/unlink-all/' + challengeId + '?keep=' + keep, }); }; diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 7163e7a9e9..80f198cbf9 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -761,18 +761,81 @@ api.removeTagFromTask = { }; /** - * @api {post} /api/v3/tasks/unlink/:taskId Unlink a challenge task + * @api {post} /api/v3/tasks/unlink-all/:challengeId Unlink all tasks from a challenge * @apiVersion 3.0.0 - * @apiName UnlinkTask + * @apiName UnlinkAllTasks * @apiGroup Task * - * @apiParam {UUID} taskId The task _id + * @apiParam {UUID} challengeId The challenge _id + * @apiParam {string} keep Query parameter - keep-all or remove-all * * @apiSuccess {object} data An empty object */ -api.unlinkTask = { +api.unlinkAllTasks = { method: 'POST', - url: '/tasks/unlink/:taskId', + url: '/tasks/unlink-all/:challengeId', + middlewares: [authWithHeaders()], + async handler (req, res) { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkQuery('keep', res.t('keepOrRemoveAll')).notEmpty().isIn(['keep-all', 'remove-all']); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let keep = req.query.keep; + let challengeId = req.params.challengeId; + + let tasks = await Tasks.Task.find({ + 'challenge.id': challengeId, + userId: user._id, + }).exec(); + + let validTasks = tasks.every(task => { + return task.challenge.broken; + }); + + if (!validTasks) throw new BadRequest(res.t('cantOnlyUnlinkChalTask')); + + if (keep === 'keep-all') { + await Bluebird.all(tasks.map(task => { + task.challenge = {}; + return task.save(); + })); + } else { // remove + let toSave = []; + + tasks.forEach(task => { + if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if + removeFromArray(user.tasksOrder[`${task.type}s`], task._id); + } + + toSave.push(task.remove()); + }); + + toSave.push(user.save()); + + await Bluebird.all(toSave); + } + + res.respond(200, {}); + }, +}; + +/** + * @api {post} /api/v3/tasks/unlink-one/:taskId Unlink a challenge task + * @apiVersion 3.0.0 + * @apiName UnlinkOneTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * @apiParam {string} keep Query parameter - keep or remove + * + * @apiSuccess {object} data An empty object + */ +api.unlinkOneTask = { + method: 'POST', + url: '/tasks/unlink-one/:taskId', middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); From f4159daf215f3b551c8d06bb1e6057df4af5cb9a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 16:26:59 +0200 Subject: [PATCH 894/976] v3 fix releasing pets --- website/client/js/services/userServices.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 7742ee7a3d..ec373ce1d2 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -430,15 +430,15 @@ angular.module('habitrpg') }, releaseBoth: function () { - callOpsFunctionAndRequest('releaseBoth', 'releaseBoth', "POST"); + callOpsFunctionAndRequest('releaseBoth', 'release-both', "POST"); }, releaseMounts: function () { - callOpsFunctionAndRequest('releaseMounts', 'releaseMounts', "POST"); + callOpsFunctionAndRequest('releaseMounts', 'release-mounts', "POST"); }, releasePets: function () { - callOpsFunctionAndRequest('releasePets', 'releasePets', "POST"); + callOpsFunctionAndRequest('releasePets', 'release-pets', "POST"); }, addWebhook: function (data) { From d2e9c45c0ab23e6c3244f8e644c41cd51ad1aadb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 16:37:48 +0200 Subject: [PATCH 895/976] v3: fix authenticating with apiUrl --- website/views/options/settings.jade | 6 +++--- website/views/shared/modals/buy-gems.jade | 2 +- website/views/shared/modals/members.jade | 2 +- website/views/shared/tasks/task_view/graph.jade | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index d38d9fb74b..a2780e856b 100644 --- a/website/views/options/settings.jade +++ b/website/views/options/settings.jade @@ -218,7 +218,7 @@ script(type='text/ng-template', id='partials/options.settings.promo.html') input.form-control(type='number',ng-model='_codes.count',placeholder="Number of codes to generate (eg, 250)") .form-group button.btn.btn-primary(type='submit')=env.t('generate') - a.btn.btn-default(href='/api/v2/coupons?_id={{user._id}}&apiToken={{user.apiToken}}')=env.t('getCodes') + a.btn.btn-default(href='/api/v2/coupons?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}')=env.t('getCodes') script(type='text/ng-template', id='partials/options.settings.api.html') .container-fluid @@ -231,7 +231,7 @@ script(type='text/ng-template', id='partials/options.settings.api.html') h6=env.t('APIToken') pre.prettyprint {{User.settings.auth.apiToken}} h6=env.t('qrCode') - img.img-rendering-auto(src='https://chart.googleapis.com/chart?cht=qr&chs=200x200&chl=%7B%22address%22%3A%22https%3A%2F%2Fhabitrpg.com%22%2C%22user%22%3A%22{{user.id}}%22%2C%22key%22%3A%22{{user.apiToken}}%22%7D&choe=UTF-8&chld=L', alt='qrcode') + img.img-rendering-auto(src='https://chart.googleapis.com/chart?cht=qr&chs=200x200&chl=%7B%22address%22%3A%22https%3A%2F%2Fhabitrpg.com%22%2C%22user%22%3A%22{{user.id}}%22%2C%22key%22%3A%22{{User.settings.auth.apiToken}}%22%7D&choe=UTF-8&chld=L', alt='qrcode') br h3=env.t('thirdPartyApps') ul @@ -400,7 +400,7 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') .col-xs-4 a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon})', ng-disabled='!_subscription.key')= env.t('card') .col-xs-4 - a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key') + a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key') img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal')) .col-xs-4 a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon})") diff --git a/website/views/shared/modals/buy-gems.jade b/website/views/shared/modals/buy-gems.jade index 6fdec903d4..4ebebf57ea 100644 --- a/website/views/shared/modals/buy-gems.jade +++ b/website/views/shared/modals/buy-gems.jade @@ -10,7 +10,7 @@ mixin buyGemsDropdown() p small.muted=env.t('paymentMethods') a.purchase.btn.btn-primary(ng-click='Payments.showStripe({})')=env.t('card') - a.purchase(href='/paypal/checkout?_id={{user._id}}&apiToken={{user.apiToken}}') + a.purchase(href='/paypal/checkout?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}') img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt='Pay now with Paypal') a.purchase(ng-click="Payments.amazonPayments.init({type: 'single'})") img(src='https://payments.amazon.com/gp/cba/button',alt='Pay now with Amazon Payments') diff --git a/website/views/shared/modals/members.jade b/website/views/shared/modals/members.jade index b23fd42596..c4741156f8 100644 --- a/website/views/shared/modals/members.jade +++ b/website/views/shared/modals/members.jade @@ -96,7 +96,7 @@ script(type='text/ng-template', id='modals/send-gift.html') - var fromBal = "gift.type=='gems' && gift.gems.fromBalance" button.btn.btn-primary(ng-show=fromBal, ng-click='sendGift(profile._id)')=env.t("send") a.btn.btn-primary(ng-hide=fromBal, ng-click='Payments.showStripe({gift:gift, uuid:profile._id})')=env.t('card') - a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?_id={{::user._id}}&apiToken={{::user.apiToken}}&gift={{Payments.encodeGift(profile._id, gift)}}') PayPal + a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?_id={{::user._id}}&apiToken={{::User.settings.auth.apiToken}}&gift={{Payments.encodeGift(profile._id, gift)}}') PayPal .btn.btn-success(ng-hide=fromBal, ng-click="Payments.amazonPayments.init({type: 'single', gift: gift, giftedTo: profile._id})") Amazon Payments button.btn.btn-default(ng-click='$close()')=env.t('cancel') diff --git a/website/views/shared/tasks/task_view/graph.jade b/website/views/shared/tasks/task_view/graph.jade index 82f062dfde..be2fba13a8 100644 --- a/website/views/shared/tasks/task_view/graph.jade +++ b/website/views/shared/tasks/task_view/graph.jade @@ -1,7 +1,7 @@ span.option-box.pull-right(ng-if='::main') a.option-action(ng-if='list.type=="todo"', ng-show='obj.history.todos', ng-click='toggleChart("todos")', tooltip=env.t('progress'), style='margin-right:5px;') span.glyphicon.glyphicon-signal - //a.option-action(ng-href='/v1/users/{{user.id}}/calendar.ics?apiToken={{user.apiToken}}', tooltip='iCal') + //a.option-action(ng-href='/v1/users/{{user.id}}/calendar.ics?apiToken={{User.settings.auth.apiToken}}', tooltip='iCal') //-a.option-action(ng-if='list.type=="todo"', ng-click='notPorted()', tooltip='iCal', ng-show='false') span.glyphicon.glyphicon-calendar // From 2d63076369b970779e357319184824dede50ea81 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 16:40:18 +0200 Subject: [PATCH 896/976] v3: fix typo --- website/client/js/services/taskServices.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/client/js/services/taskServices.js b/website/client/js/services/taskServices.js index e764a21eeb..8310c326bb 100644 --- a/website/client/js/services/taskServices.js +++ b/website/client/js/services/taskServices.js @@ -195,7 +195,8 @@ angular.module('habitrpg') removeChecklistItem: removeChecklistItem, addTagToTask: addTagToTask, removeTagFromTask: removeTagFromTask, - unlinkTask: unlinkTask, + unlinkOneTask: unlinkOneTask, + unlinkAllTasks: unlinkAllTasks, clearCompletedTodos: clearCompletedTodos, editTask: editTask, cloneTask: cloneTask From 1b48ff4b8f6119a8ab7ce4509639f3571d6999b9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 17:02:36 +0200 Subject: [PATCH 897/976] v3 fix client tests for unlinking --- test/spec/services/taskServicesSpec.js | 14 +++++++++++--- website/client/js/controllers/challengesCtrl.js | 3 ++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/test/spec/services/taskServicesSpec.js b/test/spec/services/taskServicesSpec.js index f63608e546..59d1a5d49e 100644 --- a/test/spec/services/taskServicesSpec.js +++ b/test/spec/services/taskServicesSpec.js @@ -128,11 +128,19 @@ describe('Tasks Service', function() { $httpBackend.flush(); }); - it('calls unlink task endpoint', function() { + it('calls unlinkOneTask endpoint', function() { var taskId = 1; + var keep = "keep"; + $httpBackend.expectPOST(apiV3Prefix + '/unlink-one/' + taskId + '?keep=' + keep).respond({}); + tasks.unlinkOneTask(taskId); + $httpBackend.flush(); + }); + + it('calls unlinkAllTasks endpoint', function() { + var challengeId = 1; var keep = "keep-all"; - $httpBackend.expectPOST(apiV3Prefix + '/unlink/' + taskId + '?keep=' + keep).respond({}); - tasks.unlinkTask(taskId); + $httpBackend.expectPOST(apiV3Prefix + '/unlink-all/' + challengeId + '?keep=' + keep).respond({}); + tasks.unlinkAllTasks(challengeId); $httpBackend.flush(); }); diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index 5a689ae7ca..9a0fae322d 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -132,7 +132,6 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', .then(function (response) { _challenge = response.data.data; Notification.text(window.env.t('challengeCreated')); - User.sync(); var challengeTasks = []; challengeTasks = challengeTasks.concat(challenge.todos); @@ -146,6 +145,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { reload: true, inherit: false, notify: true }); + User.sync(); }); } else { Challenges.updateChallenge(challenge._id, challenge) @@ -154,6 +154,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { reload: true, inherit: false, notify: true }); + User.sync(); }); } }; From bb6809fd6bafbc3b9360e8175a3f3aa8e43055ad Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 17:06:28 +0200 Subject: [PATCH 898/976] v3 client: do not show start quest button when quest is active --- website/views/options/social/group.jade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/views/options/social/group.jade b/website/views/options/social/group.jade index 3630ca3d2e..a34aff6557 100644 --- a/website/views/options/social/group.jade +++ b/website/views/options/social/group.jade @@ -60,7 +60,7 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter .text-center(ng-if='group.type === "party"') .row.row-margin: .col-sm-6.col-sm-offset-3 button.btn.btn-success.btn-block( - ng-if='!party.quest.key', + ng-if='!group.quest.key', ng-click='clickStartQuest();' )=env.t('startAQuest') From d293b2d6ee65e6a1cf07a378575ac2236648fc5c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 17:35:49 +0200 Subject: [PATCH 899/976] v3 client: fix ability to send cards --- common/script/content/spells.js | 16 ++++++++++++++++ website/client/js/services/chatServices.js | 8 ++++---- website/server/controllers/api-v3/chat.js | 5 +++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/common/script/content/spells.js b/common/script/content/spells.js index 9381ba89d5..a4632dbea4 100644 --- a/common/script/content/spells.js +++ b/common/script/content/spells.js @@ -396,7 +396,10 @@ spells.special = { if (!target.items.special.nyeReceived) target.items.special.nyeReceived = []; target.items.special.nyeReceived.push(user.profile.name); + + if (!target.flags) target.flags = {}; target.flags.cardReceived = true; + user.stats.gp -= 10; }, }, @@ -421,7 +424,10 @@ spells.special = { if (!target.items.special.valentineReceived) target.items.special.valentineReceived = []; target.items.special.valentineReceived.push(user.profile.name); + + if (!target.flags) target.flags = {}; target.flags.cardReceived = true; + user.stats.gp -= 10; }, }, @@ -445,7 +451,10 @@ spells.special = { if (!target.items.special.greetingReceived) target.items.special.greetingReceived = []; target.items.special.greetingReceived.push(user.profile.name); + + if (!target.flags) target.flags = {}; target.flags.cardReceived = true; + user.stats.gp -= 10; }, }, @@ -470,7 +479,10 @@ spells.special = { if (!target.items.special.thankyouReceived) target.items.special.thankyouReceived = []; target.items.special.thankyouReceived.push(user.profile.name); + + if (!target.flags) target.flags = {}; target.flags.cardReceived = true; + user.stats.gp -= 10; }, }, @@ -492,9 +504,13 @@ spells.special = { u.achievements.birthday++; }); } + if (!target.items.special.birthdayReceived) target.items.special.birthdayReceived = []; target.items.special.birthdayReceived.push(user.profile.name); + + if (!target.flags) target.flags = {}; target.flags.cardReceived = true; + user.stats.gp -= 10; }, }, diff --git a/website/client/js/services/chatServices.js b/website/client/js/services/chatServices.js index a79de22b85..4020c9dd92 100644 --- a/website/client/js/services/chatServices.js +++ b/website/client/js/services/chatServices.js @@ -70,6 +70,10 @@ angular.module('habitrpg') }); } + function clearCards () { + User.user._wrapped && User.set({'flags.cardReceived':false}); + } + return { getChat: getChat, postChat: postChat, @@ -80,8 +84,4 @@ angular.module('habitrpg') markChatSeen: markChatSeen, clearCards: clearCards, } - - function clearCards() { - User.user._wrapped && User.set({'flags.cardReceived':false}); - } }]); diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index 7be628f0df..7738189b6f 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -327,8 +327,9 @@ api.seenChat = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId}); - if (!group) throw new NotFound(res.t('groupNotFound')); + // Do not validate group existence, it doesn't really matter and make it works if the group gets deleted + // let group = await Group.getGroup({user, groupId}); + // if (!group) throw new NotFound(res.t('groupNotFound')); let update = {$unset: {}}; update.$unset[`newMessages.${groupId}`] = true; From 30c945849384bf0fe8ee59ccd74082db380faa0f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 18:19:45 +0200 Subject: [PATCH 900/976] v3 client: fix misc challenge issues --- website/client/js/app.js | 1 + website/client/js/controllers/challengesCtrl.js | 16 ++++++++-------- website/client/js/controllers/rootCtrl.js | 5 +++-- website/views/options/social/challenges.jade | 6 +++--- website/views/options/social/chat-box.jade | 2 +- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/website/client/js/app.js b/website/client/js/app.js index 672ef32f09..bcd0da6cc6 100644 --- a/website/client/js/app.js +++ b/website/client/js/app.js @@ -26,6 +26,7 @@ window.habitrpg = angular.module('habitrpg', .constant("STORAGE_USER_ID", 'habitrpg-user') .constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings') .constant("MOBILE_APP", false) + .constant("TAVERN_ID", '00000000-0000-4000-A000-000000000000') //.constant("STORAGE_GROUPS_ID", "") // if we decide to take groups offline .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'STORAGE_SETTINGS_ID', diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index 9a0fae322d..aa36cb32a8 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -1,5 +1,5 @@ -habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', 'Challenges', 'Notification', '$compile', 'Groups', '$state', '$stateParams', 'Members', 'Tasks', - function($rootScope, $scope, Shared, User, Challenges, Notification, $compile, Groups, $state, $stateParams, Members, Tasks) { +habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', 'Challenges', 'Notification', '$compile', 'Groups', '$state', '$stateParams', 'Members', 'Tasks', 'TAVERN_ID', + function($rootScope, $scope, Shared, User, Challenges, Notification, $compile, Groups, $state, $stateParams, Members, Tasks, TAVERN_ID) { // Use presence of cid to determine whether to show a list or a single // challenge @@ -58,7 +58,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', } } - if(!defaultGroup) defaultGroup = 'habitrpg'; + if(!defaultGroup) defaultGroup = TAVERN_ID; $scope.obj = $scope.newChallenge = { name: '', @@ -181,7 +181,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope["delete"] = function(challenge) { var warningMsg; - if(challenge.group._id == 'habitrpg') { + if(challenge.group._id == TAVERN_ID) { warningMsg = window.env.t('sureDelChaTavern'); } else { warningMsg = window.env.t('sureDelCha'); @@ -356,7 +356,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', _calculateMaxPrize(gid); - if (gid == 'habitrpg') { + if (gid == TAVERN_ID) { $scope.newChallenge.prize = 1; } }) @@ -379,7 +379,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.insufficientGemsForTavernChallenge = function() { var balance = User.user.balance || 0; - var isForTavern = $scope.newChallenge.group == 'habitrpg'; + var isForTavern = $scope.newChallenge.group == TAVERN_ID; if (isForTavern) { return balance <= 0; @@ -403,7 +403,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', }; $scope.filterInitialChallenges = function() { - $scope.groupsFilter = _.uniq(_.pluck($scope.challenges, 'group'), function(g) {return g._id}); + $scope.groupsFilter = _.uniq(_.compact(_.pluck($scope.challenges, 'group')), function(g) {return g._id}); $scope.search = { group: _.transform($scope.groups, function(m,g) { m[g._id] = true;}), @@ -442,7 +442,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', // case where a challenge's leader deletes their account var userIsOwner = (chal.leader && chal.leader._id) === User.user.id; - var groupSelected = $scope.search.group[chal.group._id]; + var groupSelected = $scope.search.group[chal.group ? chal.group._id : null]; var checkOwner = $scope.search._isOwner === 'either' || (userIsOwner === $scope.search._isOwner); var checkMember = $scope.search._isMember === 'either' || ($scope.isUserMemberOf(chal) === $scope.search._isMember); diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index 1cd06e865d..91fda45691 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -3,8 +3,8 @@ /* Make user and settings available for everyone through root scope. */ -habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrl', 'Payments','$sce','$window','Analytics', - function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window, Analytics) { +habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrl', 'Payments','$sce','$window','Analytics','TAVERN_ID', + function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window, Analytics, TAVERN_ID) { var user = User.user; var initSticky = _.once(function(){ @@ -25,6 +25,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ } }); + $rootScope.TAVERN_ID = TAVERN_ID; $rootScope.User = User; $rootScope.user = user; $rootScope.moment = window.moment; diff --git a/website/views/options/social/challenges.jade b/website/views/options/social/challenges.jade index ad95a7c992..3557f78325 100644 --- a/website/views/options/social/challenges.jade +++ b/website/views/options/social/challenges.jade @@ -160,12 +160,12 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') .Pet_Currency_Gem1x input.form-control(type='number', placeholder=env.t('prize'), ng-disabled='insufficientGemsForTavernChallenge()' - min="{{newChallenge.group=='habitrpg' ? 1 : 0}}", + min="{{newChallenge.group==TAVERN_ID ? 1 : 0}}", max="{{maxPrize}}", ng-model='newChallenge.prize') - a.hint(popover="{{newChallenge.group=='habitrpg' ? env.t('prizePopTavern') : env.t('prizePop')}}", + a.hint(popover="{{newChallenge.group==TAVERN_ID ? env.t('prizePopTavern') : env.t('prizePop')}}", popover-trigger='mouseenter', popover-placement='right') =env.t('moreInfo') - div(ng-show='newChallenge.group=="habitrpg"') + div(ng-show='newChallenge.group==TAVERN_ID') !=env.t('publicChallenges') .form-group(ng-if='user.contributor.admin') diff --git a/website/views/options/social/chat-box.jade b/website/views/options/social/chat-box.jade index 74fc071dfe..3792070706 100644 --- a/website/views/options/social/chat-box.jade +++ b/website/views/options/social/chat-box.jade @@ -8,7 +8,7 @@ div.chat-form.guidelines-not-accepted(ng-if='!user.flags.communityGuidelinesAcce form.chat-form(ng-if='user.flags.communityGuidelinesAccepted' ng-submit='postChat(group,message.content)') div(ng-controller='AutocompleteCtrl') - textarea.form-control(rows=4, ui-keydown='{"meta-enter":"postChat(group,message.content)"}', ui-keypress='{13:"postChat(group,message.content)"}', ng-model='message.content', updateinterval='250', flag='@', at-user, auto-complete placeholder="{{group._id == 'habitrpg' ? env.t('tavernCommunityGuidelinesPlaceholder') : ''}}", ng-disabled='_sending == true') + textarea.form-control(rows=4, ui-keydown='{"meta-enter":"postChat(group,message.content)"}', ui-keypress='{13:"postChat(group,message.content)"}', ng-model='message.content', updateinterval='250', flag='@', at-user, auto-complete placeholder="{{group._id == TAVERN_ID ? env.t('tavernCommunityGuidelinesPlaceholder') : ''}}", ng-disabled='_sending == true') span.user-list ul.list-at-user(ng-show="query") li(ng-repeat='msg in response | filter:filterUser | limitTo: 5', ng-click='performCompletion(msg)') From a757df9f5bd972e0f4b573b739b7cc087217b1b9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 19:12:39 +0200 Subject: [PATCH 901/976] v3: fix notifications --- common/locales/en/api-v3.json | 2 -- common/script/ops/feed.js | 6 +++--- common/script/ops/purchase.js | 1 - common/script/ops/sell.js | 1 - common/script/public/config.js | 9 ++++++--- .../integration/user/POST-user_purchase.test.js | 3 +-- .../v3/integration/user/POST-user_sell.test.js | 3 +-- test/common/ops/purchase.js | 15 +++++---------- test/common/ops/sell.js | 6 ++---- website/client/js/controllers/notificationCtrl.js | 6 +++++- .../client/js/services/notificationServices.js | 8 ++++---- 11 files changed, 27 insertions(+), 33 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 5378b8baf5..24760767f6 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -140,7 +140,6 @@ "notAccteptedType": "Type must be in [eggs, hatchingPotions, food, quests, gear]", "contentKeyNotFound": "Key not found for Content <%= type %>", "plusOneGem": "+1 Gem", - "purchased": "You purchsed a <%= key %> <%= type %>", "notAllowedHourglass": "Pet/Mount not available for purchase with Mystic Hourglass.", "readCard": "<%= cardType %> has been read", "cardTypeRequired": "Card type required", @@ -160,7 +159,6 @@ "mountsReleased": "Mounts released", "typeNotSellable": "Type is not sellable. Must be one of the following <%= acceptedTypes %>", "userItemsKeyNotFound": "Key not found for user.items <%= type %>", - "sold": "You sold a <%= key %> <%= type %>", "pathRequired": "Path string is required", "unlocked": "Items have been unlocked", "alreadyUnlocked": "Full set already unlocked.", diff --git a/common/script/ops/feed.js b/common/script/ops/feed.js index dc06226cf0..637d9b3e1d 100644 --- a/common/script/ops/feed.js +++ b/common/script/ops/feed.js @@ -24,7 +24,7 @@ module.exports = function feed (user, req = {}) { let pet = _.get(req, 'params.pet'); let foodK = _.get(req, 'params.food'); - if (!pet || !foodK) throw new BadRequest(i18n.t('missingPetFoodFeed')); + if (!pet || !foodK) throw new BadRequest(i18n.t('missingPetFoodFeed', req.language)); if (pet.indexOf('-') === -1) { throw new BadRequest(i18n.t('invalidPetName', req.language)); @@ -43,8 +43,8 @@ module.exports = function feed (user, req = {}) { let [egg, potion] = pet.split('-'); - let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; - let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; + let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text(req.language) : potion; + let eggText = content.eggs[egg] ? content.eggs[egg].text(req.language) : egg; let petDisplayName = i18n.t('petName', { potion: potionText, diff --git a/common/script/ops/purchase.js b/common/script/ops/purchase.js index bb5a71df80..79eb0475d4 100644 --- a/common/script/ops/purchase.js +++ b/common/script/ops/purchase.js @@ -122,7 +122,6 @@ module.exports = function purchase (user, req = {}, analytics) { } else { return [ _.pick(user, splitWhitespace('items balance')), - i18n.t('purchased', {type, key}), ]; } }; diff --git a/common/script/ops/sell.js b/common/script/ops/sell.js index ccde6c552e..10412da222 100644 --- a/common/script/ops/sell.js +++ b/common/script/ops/sell.js @@ -38,7 +38,6 @@ module.exports = function sell (user, req = {}) { } else { return [ _.pick(user, splitWhitespace('stats items')), - i18n.t('sold', {type, key}), ]; } }; diff --git a/common/script/public/config.js b/common/script/public/config.js index 53e5f19091..31be3b9a42 100644 --- a/common/script/public/config.js +++ b/common/script/public/config.js @@ -28,9 +28,12 @@ angular.module('habitrpg') localStorage.clear(); window.location.href = mobileApp ? '/app/login' : '/logout'; //location.reload() - // 400 range? + // 400 range + } else if (response.status < 400) { + // never triggered because we're in responseError + $rootScope.$broadcast('responseText', response.data.message); } else if (response.status < 500) { - $rootScope.$broadcast('responseText', response.data.err || response.data.message); + $rootScope.$broadcast('responseError', response.data.message); // Need to reject the prompse so the error is handled correctly if (response.status === 401) { return $q.reject(response); @@ -41,7 +44,7 @@ angular.module('habitrpg') window.env.t('error') + ' ' + (response.data.err || response.data || 'something went wrong') + '"

' + window.env.t('seeConsole'); if (mobileApp) error = 'Error contacting the server. Please try again in a few minutes.'; - $rootScope.$broadcast('responseError', error); + $rootScope.$broadcast('responseError500', error); console.error(response); } diff --git a/test/api/v3/integration/user/POST-user_purchase.test.js b/test/api/v3/integration/user/POST-user_purchase.test.js index 9c2963112b..dff6d59c48 100644 --- a/test/api/v3/integration/user/POST-user_purchase.test.js +++ b/test/api/v3/integration/user/POST-user_purchase.test.js @@ -26,10 +26,9 @@ describe('POST /user/purchase/:type/:key', () => { }); it('purchases a gem item', async () => { - let res = await user.post(`/user/purchase/${type}/${key}`); + await user.post(`/user/purchase/${type}/${key}`); await user.sync(); - expect(res.message).to.equal(t('purchased', {type, key})); expect(user.items[type][key]).to.equal(1); }); }); diff --git a/test/api/v3/integration/user/POST-user_sell.test.js b/test/api/v3/integration/user/POST-user_sell.test.js index be8d55c932..1914336175 100644 --- a/test/api/v3/integration/user/POST-user_sell.test.js +++ b/test/api/v3/integration/user/POST-user_sell.test.js @@ -33,10 +33,9 @@ describe('POST /user/sell/:type/:key', () => { }, }); - let response = await user.post(`/user/sell/${type}/${key}`); + await user.post(`/user/sell/${type}/${key}`); await user.sync(); - expect(response.message).to.equal(t('sold', {type, key})); expect(user.stats.gp).to.equal(content[type][key].value); }); }); diff --git a/test/common/ops/purchase.js b/test/common/ops/purchase.js index 5e32442c76..60227f21b2 100644 --- a/test/common/ops/purchase.js +++ b/test/common/ops/purchase.js @@ -150,9 +150,8 @@ describe('shared.ops.purchase', () => { let type = 'eggs'; let key = 'Wolf'; - let [, message] = purchase(user, {params: {type, key}}); + purchase(user, {params: {type, key}}); - expect(message).to.equal(i18n.t('purchased', {type, key})); expect(user.items[type][key]).to.equal(1); }); @@ -160,9 +159,8 @@ describe('shared.ops.purchase', () => { let type = 'hatchingPotions'; let key = 'Base'; - let [, message] = purchase(user, {params: {type, key}}); + purchase(user, {params: {type, key}}); - expect(message).to.equal(i18n.t('purchased', {type, key})); expect(user.items[type][key]).to.equal(1); }); @@ -170,9 +168,8 @@ describe('shared.ops.purchase', () => { let type = 'food'; let key = 'Meat'; - let [, message] = purchase(user, {params: {type, key}}); + purchase(user, {params: {type, key}}); - expect(message).to.equal(i18n.t('purchased', {type, key})); expect(user.items[type][key]).to.equal(1); }); @@ -180,9 +177,8 @@ describe('shared.ops.purchase', () => { let type = 'quests'; let key = 'gryphon'; - let [, message] = purchase(user, {params: {type, key}}); + purchase(user, {params: {type, key}}); - expect(message).to.equal(i18n.t('purchased', {type, key})); expect(user.items[type][key]).to.equal(1); }); @@ -190,9 +186,8 @@ describe('shared.ops.purchase', () => { let type = 'gear'; let key = 'headAccessory_special_tigerEars'; - let [, message] = purchase(user, {params: {type, key}}); + purchase(user, {params: {type, key}}); - expect(message).to.equal(i18n.t('purchased', {type, key})); expect(user.items.gear.owned[key]).to.be.true; }); }); diff --git a/test/common/ops/sell.js b/test/common/ops/sell.js index e294f43e54..727302ad9c 100644 --- a/test/common/ops/sell.js +++ b/test/common/ops/sell.js @@ -66,16 +66,14 @@ describe('shared.ops.sell', () => { }); it('reduces item count from user', () => { - let [, message] = sell(user, {params: { type, key } }); + sell(user, {params: { type, key } }); - expect(message).to.equal(i18n.t('sold', {type, key})); expect(user.items[type][key]).to.equal(0); }); it('increases user\'s gold', () => { - let [, message] = sell(user, {params: { type, key } }); + sell(user, {params: { type, key } }); - expect(message).to.equal(i18n.t('sold', {type, key})); expect(user.stats.gp).to.equal(content[type][key].value); }); }); diff --git a/website/client/js/controllers/notificationCtrl.js b/website/client/js/controllers/notificationCtrl.js index ac3fab9e6a..f4a9aa613e 100644 --- a/website/client/js/controllers/notificationCtrl.js +++ b/website/client/js/controllers/notificationCtrl.js @@ -180,9 +180,13 @@ habitrpg.controller('NotificationCtrl', $rootScope.openModal('questInvitation', {controller:'PartyCtrl'}); }); - $rootScope.$on('responseError', function(ev, error){ + $rootScope.$on('responseError500', function(ev, error){ Notification.error(error); }); + $rootScope.$on('responseError', function(ev, error){ + Notification.error(error, true); + }); + $rootScope.$on('responseText', function(ev, error){ Notification.text(error); }); diff --git a/website/client/js/services/notificationServices.js b/website/client/js/services/notificationServices.js index f4556f6989..096c1643af 100644 --- a/website/client/js/services/notificationServices.js +++ b/website/client/js/services/notificationServices.js @@ -54,8 +54,8 @@ angular.module("habitrpg").factory("Notification", _notify(_sign(val) + " " + _round(val) + " " + window.env.t('experience'), 'xp', 'glyphicon glyphicon-star'); } - function error(error){ - _notify(error, "danger", 'glyphicon glyphicon-exclamation-sign'); + function error(error, canHide){ + _notify(error, "danger", 'glyphicon glyphicon-exclamation-sign', canHide); } function gp(val, bonus) { @@ -107,14 +107,14 @@ angular.module("habitrpg").factory("Notification", // Used to stack notifications, must be outside of _notify var stack_topright = {"dir1": "down", "dir2": "left", "spacing1": 15, "spacing2": 15, "firstpos1": 60}; - function _notify(html, type, icon) { + function _notify(html, type, icon, canHide) { var notice = $.pnotify({ type: type || 'warning', //('info', 'text', 'warning', 'success', 'gp', 'xp', 'hp', 'lvl', 'death', 'mp', 'crit') text: html, opacity: 1, addclass: 'alert-' + type, delay: 7000, - hide: (type == 'error' || type == 'danger') ? false : true, + hide: ((type == 'error' || type == 'danger') && !canHide) ? false : true, mouse_reset: false, width: "250px", stack: stack_topright, From 2064db364ff5a45092adae6a1b3f9d750b77e2c4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 19:22:35 +0200 Subject: [PATCH 902/976] v3 client: more user friendly errors --- common/script/public/config.js | 10 ++++++++-- website/client/js/controllers/authCtrl.js | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/common/script/public/config.js b/common/script/public/config.js index 31be3b9a42..4805183801 100644 --- a/common/script/public/config.js +++ b/common/script/public/config.js @@ -33,7 +33,13 @@ angular.module('habitrpg') // never triggered because we're in responseError $rootScope.$broadcast('responseText', response.data.message); } else if (response.status < 500) { - $rootScope.$broadcast('responseError', response.data.message); + if (response.status === 400 && response.data.errors && _.isArray(response.data.errors)) { // bad requests with more info + response.data.errors.forEach(function (err) { + $rootScope.$broadcast('responseError', err.message); + }); + } else { + $rootScope.$broadcast('responseError', response.data.message); + } // Need to reject the prompse so the error is handled correctly if (response.status === 401) { return $q.reject(response); @@ -41,7 +47,7 @@ angular.module('habitrpg') // Error } else { var error = window.env.t('requestError') + '

"' + - window.env.t('error') + ' ' + (response.data.err || response.data || 'something went wrong') + + window.env.t('error') + ' ' + (response.data.message || response.data.error || response.data || 'something went wrong') + '"

' + window.env.t('seeConsole'); if (mobileApp) error = 'Error contacting the server. Please try again in a few minutes.'; $rootScope.$broadcast('responseError500', error); diff --git a/website/client/js/controllers/authCtrl.js b/website/client/js/controllers/authCtrl.js index cc4200e8b5..8107a543a6 100644 --- a/website/client/js/controllers/authCtrl.js +++ b/website/client/js/controllers/authCtrl.js @@ -28,6 +28,10 @@ angular.module('habitrpg') $scope.registrationInProgress = false; if (status === 0) { $window.alert(window.env.t('noReachServer')); + } else if (status === 400 && data.errors && _.isArray(data.errors)) { // bad requests + data.errors.forEach(function (err) { + $window.alert(err.message); + }); } else if (!!data && !!data.error) { $window.alert(data.message); } else { From b963acb91bff004d556c51e0f8708683621dc595 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 19:31:59 +0200 Subject: [PATCH 903/976] v3 client: only load completed todos once --- website/client/js/controllers/tasksCtrl.js | 5 ++++- website/client/js/services/taskServices.js | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/website/client/js/controllers/tasksCtrl.js b/website/client/js/controllers/tasksCtrl.js index 5a7fb19c09..fb44d19cb3 100644 --- a/website/client/js/controllers/tasksCtrl.js +++ b/website/client/js/controllers/tasksCtrl.js @@ -150,9 +150,12 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $scope._today = moment().add({days: 1}); $scope.loadedCompletedTodos = function () { + if (Tasks.loadedCompletedTodos === true) return; + Tasks.getUserTasks(true) .then(function (response) { - User.user.todos.concat(response.data.data); + User.user.todos = User.user.todos.concat(response.data.data); + Tasks.loadedCompletedTodos = true; }); } diff --git a/website/client/js/services/taskServices.js b/website/client/js/services/taskServices.js index 8310c326bb..e7eeef1a93 100644 --- a/website/client/js/services/taskServices.js +++ b/website/client/js/services/taskServices.js @@ -181,6 +181,7 @@ angular.module('habitrpg') return { getUserTasks: getUserTasks, + loadedCompletedTodos: false, createUserTasks: createUserTasks, getChallengeTasks: getChallengeTasks, createChallengeTasks: createChallengeTasks, From e8b8fc3b3a21e5408832ce29e41e61072e69bfff Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 22:43:18 +0200 Subject: [PATCH 904/976] v3 client: fix tests --- test/spec/controllers/challengesCtrlSpec.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/spec/controllers/challengesCtrlSpec.js b/test/spec/controllers/challengesCtrlSpec.js index 75d9759850..641df26a25 100644 --- a/test/spec/controllers/challengesCtrlSpec.js +++ b/test/spec/controllers/challengesCtrlSpec.js @@ -1,7 +1,7 @@ 'use strict'; describe('Challenges Controller', function() { - var rootScope, scope, user, User, ctrl, groups, members, notification, state, challenges, tasks; + var rootScope, scope, user, User, ctrl, groups, members, notification, state, challenges, tasks, tavernId; beforeEach(function() { module(function($provide) { @@ -14,7 +14,7 @@ describe('Challenges Controller', function() { $provide.value('User', User); }); - inject(function($rootScope, $controller, _$state_, _Groups_, _Members_, _Notification_, _Challenges_, _Tasks_){ + inject(function($rootScope, $controller, _$state_, _Groups_, _Members_, _Notification_, _Challenges_, _Tasks_, _TAVERN_ID_){ scope = $rootScope.$new(); rootScope = $rootScope; @@ -29,6 +29,7 @@ describe('Challenges Controller', function() { members = _Members_; notification = _Notification_; state = _$state_; + tavernId = _TAVERN_ID_; }); }); @@ -486,7 +487,7 @@ describe('Challenges Controller', function() { it('defaults to tavern if no group can be set as default', function() { scope.create(); - expect(scope.newChallenge.group).to.eql('habitrpg'); + expect(scope.newChallenge.group).to.eql(tavernId); }); it('calculates maxPrize', function() { @@ -508,7 +509,7 @@ describe('Challenges Controller', function() { expect(chal.todos).to.eql([]); expect(chal.rewards).to.eql([]); expect(chal.leader).to.eql('unique-user-id'); - expect(chal.group).to.eql('habitrpg'); + expect(chal.group).to.eql(tavernId); expect(chal.timestamp).to.be.greaterThan(0); expect(chal.official).to.eql(false); }); @@ -519,7 +520,7 @@ describe('Challenges Controller', function() { it('returns true if user has no gems', function() { User.user.balance = 0; scope.newChallenge = specHelper.newChallenge({ - group: 'habitrpg' + group: tavernId }); var cannotCreateTavernChallenge = scope.insufficientGemsForTavernChallenge(); @@ -529,7 +530,7 @@ describe('Challenges Controller', function() { it('returns false if user has gems', function() { User.user.balance = .25; scope.newChallenge = specHelper.newChallenge({ - group: 'habitrpg' + group: tavernId }); var cannotCreateTavernChallenge = scope.insufficientGemsForTavernChallenge(); From a0f8b715aa2811393b9bccc36ec13742618d3a7c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 17 May 2016 22:49:31 +0200 Subject: [PATCH 905/976] v3: move TAVERN_ID to common code --- common/script/constants.js | 1 + common/script/index.js | 4 +++- website/client/js/app.js | 2 +- website/server/models/group.js | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/common/script/constants.js b/common/script/constants.js index 2dc8663091..040b968f8f 100644 --- a/common/script/constants.js +++ b/common/script/constants.js @@ -2,3 +2,4 @@ export const MAX_HEALTH = 50; export const MAX_LEVEL = 100; export const MAX_STAT_POINTS = MAX_LEVEL; export const ATTRIBUTES = ['str', 'int', 'per', 'con']; +export const TAVERN_ID = '00000000-0000-4000-A000-000000000000'; diff --git a/common/script/index.js b/common/script/index.js index e3a0e0a0de..50b01bf762 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -17,15 +17,17 @@ import { shouldDo, daysSince } from './cron'; api.shouldDo = shouldDo; api.daysSince = daysSince; -// TODO under api.constants? +// TODO under api.constants? and capitalize exported names too import { MAX_HEALTH, MAX_LEVEL, MAX_STAT_POINTS, + TAVERN_ID, } from './constants'; api.maxLevel = MAX_LEVEL; api.maxHealth = MAX_HEALTH; api.maxStatPoints = MAX_STAT_POINTS; +api.TAVERN_ID = TAVERN_ID; // TODO under api.libs.statHelpers? import * as statHelpers from './statHelpers'; diff --git a/website/client/js/app.js b/website/client/js/app.js index bcd0da6cc6..86801e6777 100644 --- a/website/client/js/app.js +++ b/website/client/js/app.js @@ -26,7 +26,7 @@ window.habitrpg = angular.module('habitrpg', .constant("STORAGE_USER_ID", 'habitrpg-user') .constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings') .constant("MOBILE_APP", false) - .constant("TAVERN_ID", '00000000-0000-4000-A000-000000000000') + .constant("TAVERN_ID", window.habitrpgShared.TAVERN_ID) //.constant("STORAGE_GROUPS_ID", "") // if we decide to take groups offline .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'STORAGE_SETTINGS_ID', diff --git a/website/server/models/group.js b/website/server/models/group.js index d22e63cf70..53305d94b9 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -23,7 +23,7 @@ const questScrolls = shared.content.quests; const Schema = mongoose.Schema; export const INVITES_LIMIT = 100; -export const TAVERN_ID = '00000000-0000-4000-A000-000000000000'; +export const TAVERN_ID = shared.TAVERN_ID; // NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API // changes made directly to the db will cause Firebase to get out of sync From bf2e6489b7a35ace139d7b60857951a53d45efc8 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 16 May 2016 22:40:23 -0500 Subject: [PATCH 906/976] fix: Provide default type and text for new task creation in score route --- website/server/controllers/api-v2/user.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/server/controllers/api-v2/user.js b/website/server/controllers/api-v2/user.js index daaa272491..fcac86e2e1 100644 --- a/website/server/controllers/api-v2/user.js +++ b/website/server/controllers/api-v2/user.js @@ -110,8 +110,8 @@ api.score = function(req, res, next) { // Defaults. Other defaults are handled in user.ops.addTask() task = new Tasks.Task({ _id: id, // TODO this might easily lead to conflicts as ids are now unique db-wide - type: body.type, - text: body.text, + type: body.type || 'habit', + text: body.text || id, userId: user._id, notes: body.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." // TODO translate }); From 4f1d738272cfff7455e570ec59a1a11b77a3191d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 16 May 2016 22:52:32 -0500 Subject: [PATCH 907/976] fix: Provide default history [] for habit in score route --- common/script/ops/scoreTask.js | 1 + 1 file changed, 1 insertion(+) diff --git a/common/script/ops/scoreTask.js b/common/script/ops/scoreTask.js index 0508462773..f052b954e0 100644 --- a/common/script/ops/scoreTask.js +++ b/common/script/ops/scoreTask.js @@ -194,6 +194,7 @@ module.exports = function scoreTask (options = {}, req = {}) { } _gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + task.history = task.history || []; // Add history entry, even more than 1 per day task.history.push({ date: Number(new Date()), From 5931aee26bb1bb4be75d267e493b0c3e6dca96ac Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 17 May 2016 15:12:44 -0500 Subject: [PATCH 908/976] fix: Add _legacyId prop to tasks to support non-uuid identifiers --- website/server/controllers/api-v2/user.js | 57 ++++++++++++++++------- website/server/models/task.js | 7 ++- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/website/server/controllers/api-v2/user.js b/website/server/controllers/api-v2/user.js index fcac86e2e1..52e12d20c3 100644 --- a/website/server/controllers/api-v2/user.js +++ b/website/server/controllers/api-v2/user.js @@ -1,6 +1,7 @@ var url = require('url'); var ipn = require('paypal-ipn'); var _ = require('lodash'); +var validator = require('validator'); var nconf = require('nconf'); var asyncM = require('async'); var shared = require('../../../../common'); @@ -89,6 +90,7 @@ api.score = function(req, res, next) { direction = req.params.direction, user = res.locals.user, body = req.body || {}, + taskQuery = { userId: user._id }, task; // Send error responses for improper API call @@ -98,23 +100,33 @@ api.score = function(req, res, next) { return res.json(400, {err: ":direction must be 'up' or 'down'"}); } - Tasks.Task.findOne({ - _id: id, - userId: user._id - }, function(err, task){ + if (validator.isUUID(id)) { + taskQuery._id = id; + } else { + taskQuery._legacyId = id; + } + + Tasks.Task.findOne(taskQuery, function(err, task){ if(err) return next(err); // If exists already, score it if (!task) { // If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it // Defaults. Other defaults are handled in user.ops.addTask() - task = new Tasks.Task({ - _id: id, // TODO this might easily lead to conflicts as ids are now unique db-wide + var taskOptions = { type: body.type || 'habit', text: body.text || id, userId: user._id, notes: body.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." // TODO translate - }); + } + + if (validator.isUUID(id)) { + taskOptions._id = id; // TODO this might easily lead to conflicts as ids are now unique db-wide + } else { + taskOptions._legacyId = id; + } + + task = new Tasks.Task(taskOptions); user.tasksOrder[task.type + 's'].unshift(task._id); } @@ -206,12 +218,17 @@ api.getTasks = function(req, res, next) { * Get Task */ api.getTask = function(req, res, next) { - var user = res.locals.user; + var user = res.locals.user, + id = req.params.id, + taskQuery = { userId: user._id }; - Tasks.Task.findOne({ - userId: user._id, - _id: req.params.id, - }, function (err, task) { + if (validator.isUUID(id)) { + taskQuery._id = id; + } else { + taskQuery._legacyId = id; + } + + Tasks.Task.findOne(taskQuery, function (err, task) { if (err) return next(err); if (!task) return res.status(404).json({err: shared.i18n.t('messageTaskNotFound')}); res.status(200).json(task.toJSONV2()); @@ -830,13 +847,19 @@ api.deleteTask = function(req, res, next) { }; api.updateTask = function(req, res, next) { - var user = res.locals.user; + var user = res.locals.user, + taskQuery = { userId: user._id }, + id = req.params.id; + req.body = Tasks.Task.fromJSONV2(req.body); - Tasks.Task.findOne({ - _id: req.params.id, - userId: user._id - }, function(err, task) { + if (validator.isUUID(id)) { + taskQuery._id = id; + } else { + taskQuery._legacyId = id; + } + + Tasks.Task.findOne(taskQuery, function(err, task) { if(err) return next(err); if(!task) return res.status(404).json({err: 'Task not found.'}) diff --git a/website/server/models/task.js b/website/server/models/task.js index 27f356efa3..0116da2371 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -17,6 +17,7 @@ export let tasksTypes = ['habit', 'daily', 'todo', 'reward']; // Important // When something changes here remember to update the client side model at common/script/libs/taskDefaults export let TaskSchema = new Schema({ + _legacyId: String, // TODO Remove when v2 is deprecated type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]}, text: {type: String, required: true}, notes: {type: String, default: ''}, @@ -119,7 +120,11 @@ TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta // toJSON for API v2 TaskSchema.methods.toJSONV2 = function toJSONV2 () { let toJSON = this.toJSON(); - toJSON.id = toJSON._id; + if (toJSON._legacyId) { + toJSON.id = toJSON._legacyId; + } else { + toJSON.id = toJSON._id; + } let v3Tags = this.tags; From 990d43928b93b31874c141e743354ab49e7a79e0 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 17 May 2016 15:25:54 -0500 Subject: [PATCH 909/976] chore: Change v3 migration to use _legacyId instead of legacyId --- migrations/api_v3/challenges.js | 6 +++--- migrations/api_v3/users.js | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index eaec714858..6d066b3d63 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -129,16 +129,16 @@ function processChallenges (afterId) { oldTasks.forEach(function (oldTask) { oldTask._id = uuid.v4(); - oldTask.legacyId = oldTask.id; // store the old task id + oldTask._legacyId = oldTask.id; // store the old task id delete oldTask.id; oldTask.challenge = oldTask.challenge || {}; oldTask.challenge.id = newChallenge._id; - if (newTasksIds[oldTask.legacyId + '-' + newChallenge._id]) { + if (newTasksIds[oldTask._legacyId + '-' + newChallenge._id]) { throw new Error('duplicate :('); } else { - newTasksIds[oldTask.legacyId + '-' + newChallenge._id] = oldTask._id; + newTasksIds[oldTask._legacyId + '-' + newChallenge._id] = oldTask._id; } oldTask.tags = _.map(oldTask.tags || {}, function (tagPresent, tagId) { diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index e569699673..ef15d74f85 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -131,20 +131,20 @@ function processUsers (afterId) { oldTasks.forEach(function (oldTask) { oldTask._id = uuid.v4(); // create a new unique uuid oldTask.userId = newUser._id; - oldTask.legacyId = oldTask.id; // store the old task id + oldTask._legacyId = oldTask.id; // store the old task id delete oldTask.id; oldTask.challenge = oldTask.challenge || {}; if (oldTask.challenge.id) { if (oldTask.challenge.broken) { - oldTask.challenge.taskId = oldTask.legacyId; + oldTask.challenge.taskId = oldTask._legacyId; } else { - var newId = newTasksIds[oldTask.legacyId + '-' + oldTask.challenge.id]; + var newId = newTasksIds[oldTask._legacyId + '-' + oldTask.challenge.id]; // Challenges' tasks ids changed if (!newId && !oldTask.challenge.broken) { challengeTaskNoMatchingId++; - oldTask.challenge.taskId = oldTask.legacyId; + oldTask.challenge.taskId = oldTask._legacyId; oldTask.challenge.broken = 'CHALLENGE_TASK_NOT_FOUND'; } else { challengeTaskWithMatchingId++; @@ -173,7 +173,7 @@ function processUsers (afterId) { newUser.tasksOrder[`${oldTask.type}s`].push(oldTask._id); } - var allTasksFields = ['_id', 'type', 'text', 'notes', 'tags', 'value', 'priority', 'attribute', 'challenge', 'reminders', 'userId', 'legacyId', 'createdAt']; + var allTasksFields = ['_id', 'type', 'text', 'notes', 'tags', 'value', 'priority', 'attribute', 'challenge', 'reminders', 'userId', '_legacyId', 'createdAt']; // using mongoose models is too slow if (oldTask.type === 'habit') { oldTask = _.pick(oldTask, allTasksFields.concat(['history', 'up', 'down'])); From 38473f09c75d9d1a7da5131fb7af1a545bbbf69e Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 17 May 2016 16:48:42 -0500 Subject: [PATCH 910/976] fix: check for _legacyId in tasks if id does not exist --- website/server/controllers/api-v2/user.js | 70 +++++++++++++++-------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/website/server/controllers/api-v2/user.js b/website/server/controllers/api-v2/user.js index 52e12d20c3..0941d9d3af 100644 --- a/website/server/controllers/api-v2/user.js +++ b/website/server/controllers/api-v2/user.js @@ -90,7 +90,6 @@ api.score = function(req, res, next) { direction = req.params.direction, user = res.locals.user, body = req.body || {}, - taskQuery = { userId: user._id }, task; // Send error responses for improper API call @@ -100,14 +99,23 @@ api.score = function(req, res, next) { return res.json(400, {err: ":direction must be 'up' or 'down'"}); } - if (validator.isUUID(id)) { - taskQuery._id = id; - } else { - taskQuery._legacyId = id; - } + asyncM.waterfall([ + function (cb) { + Tasks.Task.findOne({ + _id: id, + userId: user._id, + }, cb); + }, + function (task, cb) { + if (task) return cb(null, task); - Tasks.Task.findOne(taskQuery, function(err, task){ - if(err) return next(err); + Tasks.Task.findOne({ + _legacyId: id, + userId: user._id, + }, cb); + }, + ], function (err, task) { + if (err) return next(err); // If exists already, score it if (!task) { @@ -199,7 +207,6 @@ api.score = function(req, res, next) { }); }); }); - }; /** @@ -219,16 +226,24 @@ api.getTasks = function(req, res, next) { */ api.getTask = function(req, res, next) { var user = res.locals.user, - id = req.params.id, - taskQuery = { userId: user._id }; + id = req.params.id; - if (validator.isUUID(id)) { - taskQuery._id = id; - } else { - taskQuery._legacyId = id; - } + asyncM.waterfall([ + function (cb) { + Tasks.Task.findOne({ + _id: id, + userId: user._id, + }, cb); + }, + function (task, cb) { + if (task) return cb(null, task); - Tasks.Task.findOne(taskQuery, function (err, task) { + Tasks.Task.findOne({ + _legacyId: id, + userId: user._id, + }, cb); + }, + ], function (err, task) { if (err) return next(err); if (!task) return res.status(404).json({err: shared.i18n.t('messageTaskNotFound')}); res.status(200).json(task.toJSONV2()); @@ -853,13 +868,22 @@ api.updateTask = function(req, res, next) { req.body = Tasks.Task.fromJSONV2(req.body); - if (validator.isUUID(id)) { - taskQuery._id = id; - } else { - taskQuery._legacyId = id; - } + asyncM.waterfall([ + function (cb) { + Tasks.Task.findOne({ + _id: id, + userId: user._id, + }, cb); + }, + function (task, cb) { + if (task) return cb(null, task); - Tasks.Task.findOne(taskQuery, function(err, task) { + Tasks.Task.findOne({ + _legacyId: id, + userId: user._id, + }, cb); + }, + ], function (err, task) { if(err) return next(err); if(!task) return res.status(404).json({err: 'Task not found.'}) From dba53b85a24e0510cd10c52c0ddf1f14e646fccd Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 17 May 2016 17:11:08 -0500 Subject: [PATCH 911/976] refactor: Extract out finding task by id or _legacyId into a function --- website/server/controllers/api-v2/user.js | 71 +++++++---------------- 1 file changed, 22 insertions(+), 49 deletions(-) diff --git a/website/server/controllers/api-v2/user.js b/website/server/controllers/api-v2/user.js index 0941d9d3af..323155a5bd 100644 --- a/website/server/controllers/api-v2/user.js +++ b/website/server/controllers/api-v2/user.js @@ -80,6 +80,25 @@ var findTask = function(req, res) { return res.locals.user.tasks[req.params.id]; }; +function findTaskByIdOrLegacyId (user, taskId, callback) { + asyncM.waterfall([ + function (cb) { + Tasks.Task.findOne({ + _id: taskId, + userId: user._id, + }, cb); + }, + function (task, cb) { + if (task) return cb(null, task); + + Tasks.Task.findOne({ + _legacyId: taskId, + userId: user._id, + }, cb); + }, + ], callback); +} + /* API Routes --------------- @@ -99,22 +118,7 @@ api.score = function(req, res, next) { return res.json(400, {err: ":direction must be 'up' or 'down'"}); } - asyncM.waterfall([ - function (cb) { - Tasks.Task.findOne({ - _id: id, - userId: user._id, - }, cb); - }, - function (task, cb) { - if (task) return cb(null, task); - - Tasks.Task.findOne({ - _legacyId: id, - userId: user._id, - }, cb); - }, - ], function (err, task) { + findTaskByIdOrLegacyId(user, id, function (err, task) { if (err) return next(err); // If exists already, score it @@ -228,22 +232,7 @@ api.getTask = function(req, res, next) { var user = res.locals.user, id = req.params.id; - asyncM.waterfall([ - function (cb) { - Tasks.Task.findOne({ - _id: id, - userId: user._id, - }, cb); - }, - function (task, cb) { - if (task) return cb(null, task); - - Tasks.Task.findOne({ - _legacyId: id, - userId: user._id, - }, cb); - }, - ], function (err, task) { + findTaskByIdOrLegacyId(user, id, function (err, task) { if (err) return next(err); if (!task) return res.status(404).json({err: shared.i18n.t('messageTaskNotFound')}); res.status(200).json(task.toJSONV2()); @@ -863,27 +852,11 @@ api.deleteTask = function(req, res, next) { api.updateTask = function(req, res, next) { var user = res.locals.user, - taskQuery = { userId: user._id }, id = req.params.id; req.body = Tasks.Task.fromJSONV2(req.body); - asyncM.waterfall([ - function (cb) { - Tasks.Task.findOne({ - _id: id, - userId: user._id, - }, cb); - }, - function (task, cb) { - if (task) return cb(null, task); - - Tasks.Task.findOne({ - _legacyId: id, - userId: user._id, - }, cb); - }, - ], function (err, task) { + findTaskByIdOrLegacyId(user, id, function (err, task) { if(err) return next(err); if(!task) return res.status(404).json({err: 'Task not found.'}) From 1a87619bac6cc45bba489769821865275258c0b6 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 18 May 2016 10:22:32 +0100 Subject: [PATCH 912/976] Api v3 party quest fixes (#7341) * Fix display of add challenge message when group challenges are empty * Fixed forced quest start to update quest without reload * Fixed needing to reload when accepting party invite * Fix group leave and join reload * Fixed leave current party and join another * Updated party tests --- test/spec/controllers/partyCtrlSpec.js | 21 +++++++++---------- website/client/js/controllers/guildsCtrl.js | 10 +++++++-- website/client/js/controllers/partyCtrl.js | 16 ++++++++------ website/client/js/services/questServices.js | 1 + .../views/options/social/challenge-box.jade | 2 +- website/views/options/social/group.jade | 4 ++-- website/views/options/social/index.jade | 4 ++-- .../party/leave-party-and-join-another.jade | 2 +- 8 files changed, 35 insertions(+), 25 deletions(-) diff --git a/test/spec/controllers/partyCtrlSpec.js b/test/spec/controllers/partyCtrlSpec.js index b27cad2e47..6dd9714531 100644 --- a/test/spec/controllers/partyCtrlSpec.js +++ b/test/spec/controllers/partyCtrlSpec.js @@ -423,7 +423,9 @@ describe("Party Controller", function() { describe('#leaveOldPartyAndJoinNewParty', function() { beforeEach(function() { sandbox.stub(scope, 'join'); - sandbox.stub(groups.Group, 'leave').yields(); + groups.data.party = { _id: 'old-party' }; + var groupLeave = sandbox.stub(groups.Group, 'leave'); + groupLeave.returns(Promise.resolve({})); sandbox.stub(groups, 'party').returns({ _id: 'old-party' }); @@ -441,20 +443,17 @@ describe("Party Controller", function() { scope.leaveOldPartyAndJoinNewParty('some-id', 'some-name'); expect(groups.Group.leave).to.be.calledOnce; - expect(groups.Group.leave).to.be.calledWith({ - gid: 'old-party', - keep: false - }); + expect(groups.Group.leave).to.be.calledWith('old-party', false); }); - it('joins the new party', function() { + it('joins the new party', function(done) { scope.leaveOldPartyAndJoinNewParty('some-id', 'some-name'); - expect(scope.join).to.be.calledOnce; - expect(scope.join).to.be.calledWith({ - id: 'some-id', - name: 'some-name' - }); + setTimeout(function() { + expect(scope.join).to.be.calledOnce; + expect(scope.join).to.be.calledWith({id: 'some-id', name: 'some-name'}); + done(); + }, 1000); }); }); diff --git a/website/client/js/controllers/guildsCtrl.js b/website/client/js/controllers/guildsCtrl.js index 6555495e7e..0855e41e04 100644 --- a/website/client/js/controllers/guildsCtrl.js +++ b/website/client/js/controllers/guildsCtrl.js @@ -57,12 +57,15 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$r .then(function (response) { var joinedGroup = response.data.data; + User.user.guilds.push(joinedGroup._id); + if (joinedGroup.privacy == 'public') { Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':false, 'groupType':'guild','privacy': joinedGroup.privacy, 'groupName': joinedGroup.name}) } else { Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':false, 'groupType':'guild','privacy': joinedGroup.privacy}) } - $rootScope.hardRedirect('/#/options/groups/guilds/' + joinedGroup._id); + + $location.path('/options/groups/guilds/' + joinedGroup._id); }); } @@ -77,7 +80,10 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$r } else { Groups.Group.leave($scope.selectedGroup._id, keep) .success(function (data) { - $rootScope.hardRedirect('/#/options/groups/guilds'); + var index = User.user.guilds.indexOf($scope.selectedGroup._id); + delete User.user.guilds[index]; + $scope.selectedGroup = undefined; + $location.path('/options/groups/guilds'); }); } } diff --git a/website/client/js/controllers/partyCtrl.js b/website/client/js/controllers/partyCtrl.js index 0edb238325..7c3c65105b 100644 --- a/website/client/js/controllers/partyCtrl.js +++ b/website/client/js/controllers/partyCtrl.js @@ -56,6 +56,8 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.join = function (party) { Groups.Group.join(party.id) .then(function (response) { + $scope.group = response.data.data; + User.sync(); Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'party','privacy':'private'}); Analytics.updateUser({'partyID': party.id}); $rootScope.hardRedirect('/#/options/groups/party'); @@ -131,12 +133,13 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.leaveOldPartyAndJoinNewParty = function(newPartyId, newPartyName) { if (confirm('Are you sure you want to delete your party and join ' + newPartyName + '?')) { - Groups.Group.leave({gid: Groups.party()._id, keep: false}, undefined, function() { - $scope.group = { - loadingNewParty: true - }; - $scope.join({ id: newPartyId, name: newPartyName }); - }); + Groups.Group.leave(Groups.data.party._id, false) + .then(function() { + $scope.group = { + loadingNewParty: true + }; + $scope.join({ id: newPartyId, name: newPartyName }); + }); } } @@ -183,6 +186,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.questForceStart = function(){ Quests.sendAction('quests/force-start') .then(function(quest) { + console.log(quest) $scope.group.quest = quest; }); }; diff --git a/website/client/js/services/questServices.js b/website/client/js/services/questServices.js index 73fa78e42c..5033bad33d 100644 --- a/website/client/js/services/questServices.js +++ b/website/client/js/services/questServices.js @@ -121,6 +121,7 @@ angular.module('habitrpg') }); var quest = response.data.quest; + if (!quest) quest = response.data.data; resolve(quest); });; }); diff --git a/website/views/options/social/challenge-box.jade b/website/views/options/social/challenge-box.jade index 9a7df490d8..177e049e4d 100644 --- a/website/views/options/social/challenge-box.jade +++ b/website/views/options/social/challenge-box.jade @@ -12,7 +12,7 @@ td a(ui-sref='options.social.challenges.detail({cid:challenge._id, groupIdFilter: group._id})') markdown(text='challenge.name') - div(ng-if='group.challenges.length == 0') + div(ng-if='!group.challenges || group.challenges.length == 0') p |  =env.t('noChallenges') diff --git a/website/views/options/social/group.jade b/website/views/options/social/group.jade index a34aff6557..6232990915 100644 --- a/website/views/options/social/group.jade +++ b/website/views/options/social/group.jade @@ -18,10 +18,10 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter h3.panel-title span {{group.name}} span.group-leave-join(ng-if='group') - a.btn.btn-sm.btn-danger.pull-right(ng-if=":: isMemberOfGroup(User.user._id, group)", ng-hide='group._editing', ng-click='clickLeave(group, $event)') + a.btn.btn-sm.btn-danger.pull-right(ng-if="isMemberOfGroup(User.user._id, group)", ng-hide='group._editing', ng-click='clickLeave(group, $event)') span.glyphicon.glyphicon-ban-circle =env.t('leave') - a.btn.btn-success.pull-right(ng-if=':: !isMemberOfGroup(User.user._id, group)', ng-click='join(group)')=env.t('join') + a.btn.btn-success.pull-right(ng-if='!isMemberOfGroup(User.user._id, group)', ng-click='join(group)')=env.t('join') span(ng-if='group.leader._id == user.id') button.btn.btn-sm.btn-primary.pull-right(ng-click='cancelEdit(group)', ng-hide='!group._editing')=env.t('cancel') button.btn.btn-sm.btn-primary.pull-right(ng-click='saveEdit(group)', ng-show='group._editing')=env.t('save') diff --git a/website/views/options/social/index.jade b/website/views/options/social/index.jade index f0418c54bc..754d3d08f6 100644 --- a/website/views/options/social/index.jade +++ b/website/views/options/social/index.jade @@ -45,10 +45,10 @@ script(type='text/ng-template', id='partials/options.social.guilds.public.html') li='{{::group.memberCount}} ' + env.t('members') // join / leave li.bg-transparent - a.btn.btn-sm.btn-danger(ng-if="::isMemberOfGroup(User.user._id, group)", ng-click='clickLeave(group, $event)') + a.btn.btn-sm.btn-danger(ng-if="isMemberOfGroup(User.user._id, group)", ng-click='clickLeave(group, $event)') span.glyphicon.glyphicon-ban-circle =env.t('leave') - a.btn.btn-sm.btn-success(ng-if="::!isMemberOfGroup(User.user._id, group)", ng-click='join(group)') + a.btn.btn-sm.btn-success(ng-if="!isMemberOfGroup(User.user._id, group)", ng-click='join(group)') span.glyphicon.glyphicon-ok =env.t('join') h4: a(href='/#/options/groups/guilds/{{::group._id}}') {{::group.name}} diff --git a/website/views/options/social/party/leave-party-and-join-another.jade b/website/views/options/social/party/leave-party-and-join-another.jade index 438feb3786..b6ff6c7862 100644 --- a/website/views/options/social/party/leave-party-and-join-another.jade +++ b/website/views/options/social/party/leave-party-and-join-another.jade @@ -1,5 +1,5 @@ - var newParty = 'User.user.invitations.party' -.containter-fluid(ng-if='#{newParty}.id && party._id') +.containter-fluid(ng-if='#{newParty}.id && group._id') .row.text-center .col-sm-6.col-sm-offset-3.alert.alert-warning p {{::env.t('invitedToNewParty', { partyName: #{newParty}.name })}} From d888fc758871973fb9c59b02f33534aa54a1e7a6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 11:23:11 +0200 Subject: [PATCH 913/976] v3 client: remove console.log statement --- website/client/js/controllers/partyCtrl.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/client/js/controllers/partyCtrl.js b/website/client/js/controllers/partyCtrl.js index 7c3c65105b..f2e5150cbc 100644 --- a/website/client/js/controllers/partyCtrl.js +++ b/website/client/js/controllers/partyCtrl.js @@ -186,7 +186,6 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.questForceStart = function(){ Quests.sendAction('quests/force-start') .then(function(quest) { - console.log(quest) $scope.group.quest = quest; }); }; From 210ac571654f75905b0c14600bc1344189704a0c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 12:16:31 +0200 Subject: [PATCH 914/976] v3: misc fixes --- common/script/public/config.js | 3 +++ website/client/js/controllers/partyCtrl.js | 10 +++++----- website/client/js/controllers/rootCtrl.js | 4 ++-- .../server/controllers/top-level/payments/paypal.js | 2 +- website/views/shared/header/menu.jade | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/common/script/public/config.js b/common/script/public/config.js index 4805183801..02146138a5 100644 --- a/common/script/public/config.js +++ b/common/script/public/config.js @@ -40,6 +40,9 @@ angular.module('habitrpg') } else { $rootScope.$broadcast('responseError', response.data.message); } + + if ($rootScope.User && $rootScope.User.sync) $rootScope.User.sync(); + // Need to reject the prompse so the error is handled correctly if (response.status === 401) { return $q.reject(response); diff --git a/website/client/js/controllers/partyCtrl.js b/website/client/js/controllers/partyCtrl.js index f2e5150cbc..0049ff9815 100644 --- a/website/client/js/controllers/partyCtrl.js +++ b/website/client/js/controllers/partyCtrl.js @@ -14,10 +14,10 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' if ($state.is('options.social.party')) { Groups.Group.syncParty() .then(function successCallback(group) { - $scope.group = group; + $rootScope.party = $scope.group = group; checkForNotifications(); }, function errorCallback(response) { - $scope.group = $scope.newGroup = { type: 'party' }; + $rootScope.party = $scope.group = $scope.newGroup = { type: 'party' }; }); } @@ -45,7 +45,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' if (!group.name) group.name = env.t('possessiveParty', {name: User.user.profile.name}); Groups.Group.create(group) .then(function(response) { - $scope.group = response.data.data; + $rootScope.party = $scope.group = response.data.data; User.sync(); Groups.data.party = $scope.group; Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'party', 'privacy':'private'}); @@ -56,7 +56,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.join = function (party) { Groups.Group.join(party.id) .then(function (response) { - $scope.group = response.data.data; + $rootScope.party = $scope.group = response.data.data; User.sync(); Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'party','privacy':'private'}); Analytics.updateUser({'partyID': party.id}); @@ -135,7 +135,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' if (confirm('Are you sure you want to delete your party and join ' + newPartyName + '?')) { Groups.Group.leave(Groups.data.party._id, false) .then(function() { - $scope.group = { + $rootScope.party = $scope.group = { loadingNewParty: true }; $scope.join({ id: newPartyId, name: newPartyName }); diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index 91fda45691..b4fee4076a 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -292,8 +292,8 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ } else if (spell.target == 'tasks') { var tasks = User.user.habits.concat(User.user.dailys).concat(User.user.rewards).concat(User.user.todos); // exclude challenge tasks - tasks = tasks.filter(function (t) { - if (!t.challenge) return true; + tasks = tasks.filter(function (task) { + if (!task.challenge) return true; return (!task.challenge.id || task.challenge.broken); }); $scope.castEnd(tasks, 'tasks'); diff --git a/website/server/controllers/top-level/payments/paypal.js b/website/server/controllers/top-level/payments/paypal.js index 1c3e85dec3..4bcd2d0735 100644 --- a/website/server/controllers/top-level/payments/paypal.js +++ b/website/server/controllers/top-level/payments/paypal.js @@ -88,7 +88,7 @@ api.checkout = { // sku: 1, price: amount, currency: 'USD', - quality: 1, + quantity: 1, }], }, amount: { diff --git a/website/views/shared/header/menu.jade b/website/views/shared/header/menu.jade index 78f60557ca..d009ebdd4d 100644 --- a/website/views/shared/header/menu.jade +++ b/website/views/shared/header/menu.jade @@ -205,7 +205,7 @@ nav.toolbar(ng-controller='MenuCtrl') span.glyphicon.glyphicon-plus-sign span=env.t('haveUnallocated', {points: '{{user.stats.points}}'}) li(ng-repeat='(k,v) in user.newMessages', ng-if='v.value') - a(ng-click='k === party._id ? $state.go("options.social.party") : $state.go("options.social.guilds.detail",{gid:k}); ', data-close-menu) + a(ng-click='(k === party._id || k === user.party._id) ? $state.go("options.social.party") : $state.go("options.social.guilds.detail",{gid:k}); ', data-close-menu) span.glyphicon.glyphicon-comment span {{v.name}} a(ng-click='clearMessages(k)', popover=env.t('clear'),popover-placement='right',popover-trigger='mouseenter',popover-append-to-body='true') From 7ea581bb38881f36f8b651172c871fb99b0fde5e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 15:30:23 +0200 Subject: [PATCH 915/976] v3 client: fix predicatbale random --- common/script/fns/predictableRandom.js | 7 ++++++- website/server/controllers/api-v3/user.js | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/common/script/fns/predictableRandom.js b/common/script/fns/predictableRandom.js index 373e015500..e67daf3b62 100644 --- a/common/script/fns/predictableRandom.js +++ b/common/script/fns/predictableRandom.js @@ -5,7 +5,12 @@ import _ from 'lodash'; module.exports = function predictableRandom (user, seed) { if (!seed || seed === Math.PI) { - seed = _.reduce(user.stats, (accumulator, val) => { + let stats = user.stats.toObject ? user.stats.toObject() : user.stats; + // These items are not part of the stat object but exists on the server (see controllers/user#getUser) + // we remove them in order to use the same user.stats both on server and on client + stats = _.omit(stats, 'toNextLevel', 'maxHealth', 'maxMP'); + + seed = _.reduce(stats, (accumulator, val) => { if (_.isNumber(val)) { return accumulator + val; } else { diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index a96bd43453..3e6bd8b8da 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -37,6 +37,8 @@ api.getUser = { delete user.apiToken; // TODO move to model? (maybe virtuals, maybe in toJSON) + // NOTE: if an item is manually added to user.stats common/fns/predictableRandom must be tweaked + // so it's not considered. Otherwise the client will have it while the server won't and the results will be different. user.stats.toNextLevel = common.tnl(user.stats.lvl); user.stats.maxHealth = common.maxHealth; user.stats.maxMP = common.statsComputed(user).maxMP; From 4b81c97bfc0b7daf0960089799f55e4b27b0430e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 15:49:58 +0200 Subject: [PATCH 916/976] v3: info about API v3 --- package.json | 2 +- website/views/shared/footer.jade | 4 +++- website/views/static/api-v2.jade | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b4f5a11d25..8f6bbd9d98 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "habitrpg", + "name": "habitica", "description": "A habit tracker app which treats your goals like a Role Playing Game.", "version": "3.0.0", "main": "./website/server/index.js", diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index 6246f09cc8..636f2f2ed2 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -48,7 +48,9 @@ footer.footer(ng-controller='FooterCtrl') li a(target='_blank', href='https://trello.com/c/odmhIqyW/440-read-first-table-of-contents')=env.t('communityFeature') li - a(target='_blank', href='https://habitica.com/static/api')=env.t('API') + a(target='_blank', href='/apidoc')=env.t('API v3') + li + a(target='_blank', href='/static/api-v2')=env.t('API v2 - Deprecated') li a(href='http://habitica.wikia.com/wiki/App_and_Extension_Integrations', target='_blank')=env.t('communityExtensions') li diff --git a/website/views/static/api-v2.jade b/website/views/static/api-v2.jade index cf290cc0f8..c6e5c034f0 100644 --- a/website/views/static/api-v2.jade +++ b/website/views/static/api-v2.jade @@ -75,6 +75,10 @@ html //.input a#explore(href='#') Explore br + h2 API v3 + p This page contains documentation for version 2 of Habitica's API. A new API version, the third, has been released and its documentation can be found here and an introductory blog post with the most important changes here. + p API v2 is still available to give time to developers to port their apps and integration to the new API but it's considered deprecated and should not be used for new projects. It'll be completely retired shortly. + br h2 Two API Types p Habitica's API is meant for two different audiences: (1) extensions and scripts, and (2) full-fledged applications. Extensions and scripts can utilize Habitica's up/down scoring for individual tasks. An example of this in action is the Chrome Extension, which up-scores you for visiting productive websites, and down-scores you for visiting procrastination websites. Other examples currently in use are Pomodoro, Anki, and Github scripts - which up-score you for good behavior and downscore you for bad behavior - see the list. The second API consumer is for full-fledge applications, which need read / write access to the entire user document. An example of this would be Mobile Apps or Desktop application. h2 Extensions / Scripts From d1f9e9e7ca11c445d00b805e0258eb9707f2c165 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 16:06:05 +0200 Subject: [PATCH 917/976] v3: update footer with links to developer resources --- common/locales/en/front.json | 2 ++ common/locales/en/settings.json | 2 ++ website/views/shared/footer.jade | 12 ++++++++---- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/common/locales/en/front.json b/common/locales/en/front.json index f46983b534..6ef64a0d07 100644 --- a/common/locales/en/front.json +++ b/common/locales/en/front.json @@ -28,6 +28,7 @@ "communityReddit": "Reddit", "companyAbout": "How it Works", "companyBlog": "Blog", + "devBlog": "Developer Blog", "companyDonate": "Donate", "companyExtensions": "Extensions", "companyPrivacy": "Privacy", @@ -51,6 +52,7 @@ "featureSocialHeading": "Social play", "featuredIn": "Featured in", "featuresHeading": "We also feature...", + "footerDevs": "Developers", "footerCommunity": "Community", "footerCompany": "Company", "footerMobile": "Mobile", diff --git a/common/locales/en/settings.json b/common/locales/en/settings.json index 8f71ab9fa3..6437a6e281 100644 --- a/common/locales/en/settings.json +++ b/common/locales/en/settings.json @@ -64,6 +64,8 @@ "resetText2": "You will lose all your levels, gold, and experience points. All your tasks (except those from challenges) will be deleted permanently and you will lose all of their historical data. You will lose all your equipment but you will be able to buy it all back, including all limited edition equipment or subscriber Mystery items that you already own (you will need to be in the correct class to re-buy class-specific gear). You will keep your current class and your pets and mounts. You might prefer to use an Orb of Rebirth instead, which is a much safer option and which will preserve your tasks.", "deleteText": "Are you sure? This will delete your account forever, and it can never be restored! You will need to register a new account to use Habitica again. Banked or spent Gems will not be refunded. If you're absolutely certain, type <%= deleteWord %> into the text box below.", "API": "API", + "APIv3": "API v3", + "APIv2": "API v2 - Deprecated", "APIText": "Copy these for use in third party applications. However, think of your API Token like a password, and do not share it publicly. You may occasionally be asked for your User ID, but never post your API Token where others can see it, including on Github.", "APIToken": "API Token (this is a password - see warning above!)", "thirdPartyApps": "Third Party Apps", diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index 636f2f2ed2..0164b16783 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -47,10 +47,6 @@ footer.footer(ng-controller='FooterCtrl') a(target='_blank', href='/static/clear-browser-data')=env.t('communityBug') li a(target='_blank', href='https://trello.com/c/odmhIqyW/440-read-first-table-of-contents')=env.t('communityFeature') - li - a(target='_blank', href='/apidoc')=env.t('API v3') - li - a(target='_blank', href='/static/api-v2')=env.t('API v2 - Deprecated') li a(href='http://habitica.wikia.com/wiki/App_and_Extension_Integrations', target='_blank')=env.t('communityExtensions') li @@ -61,6 +57,14 @@ footer.footer(ng-controller='FooterCtrl') a(target='_blank', href='https://www.facebook.com/Habitica')=env.t('communityFacebook') li a(target='_blank', href='http://www.reddit.com/r/habitrpg/')=env.t('communityReddit') + h4=env.t('footerDevs') + ul.list-unstyled + li + a(target='_blank', href='http://devs.habitica.com')=env.t('devBlog') + ' - The Forge' + li + a(target='_blank', href='/apidoc')=env.t('APIv3') + li + a(target='_blank', href='/static/api-v2')=env.t('APIv2') .col-sm-3 if (env.NODE_ENV === 'production' && !env.IS_MOBILE) h4=env.t('footerSocial') From 2f934455ada900216ad58d4b8ffebdb2100ed8d5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 16:39:11 +0200 Subject: [PATCH 918/976] v3: support party invitation from email --- website/client/js/controllers/authCtrl.js | 12 ++++++++++-- website/server/controllers/api-v3/groups.js | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/website/client/js/controllers/authCtrl.js b/website/client/js/controllers/authCtrl.js index 8107a543a6..13bc8ca1e6 100644 --- a/website/client/js/controllers/authCtrl.js +++ b/website/client/js/controllers/authCtrl.js @@ -31,7 +31,7 @@ angular.module('habitrpg') } else if (status === 400 && data.errors && _.isArray(data.errors)) { // bad requests data.errors.forEach(function (err) { $window.alert(err.message); - }); + }); } else if (!!data && !!data.error) { $window.alert(data.message); } else { @@ -51,7 +51,15 @@ angular.module('habitrpg') $scope.registrationInProgress = true; var url = ApiUrl.get() + "/api/v3/user/auth/local/register"; - if($rootScope.selectedLanguage) url = url + '?lang=' + $rootScope.selectedLanguage.code; + if (location.search && location.search.indexOf('Invite=') !== -1) { // matches groupInvite and partyInvite + url += location.search; + } + + if($rootScope.selectedLanguage) { + var toAppend = url.indexOf('?') !== -1 ? '&' : '?'; + url = url + toAppend + 'lang=' + $rootScope.selectedLanguage.code; + } + $http.post(url, scope.registerVals).success(function(res, status, headers, config) { runAuth(res.data._id, res.data.apiToken); }).error(errorAlert); diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 5f10b02570..b3a87047a3 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -568,7 +568,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) { inviter: inviter._id, sentAt: Date.now(), // so we can let it expire }); - let link = `?groupInvite=${encrypt(groupQueryString)}`; + let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`; let variables = [ {name: 'LINK', content: link}, From 73dd9f5920dc87d8cdd081bd89821dbec33a6206 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 16:50:50 +0200 Subject: [PATCH 919/976] v3 client: fix chat flagging --- .../client/js/controllers/memberModalCtrl.js | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/website/client/js/controllers/memberModalCtrl.js b/website/client/js/controllers/memberModalCtrl.js index 3077562c40..6e8b96d0ae 100644 --- a/website/client/js/controllers/memberModalCtrl.js +++ b/website/client/js/controllers/memberModalCtrl.js @@ -49,18 +49,20 @@ habitrpg $scope.reportAbuse = function(reporter, message, groupId) { message.flags[reporter._id] = true; - Chat.utils.flagChatMessage({gid: groupId, messageId: message.id}, undefined, function(data){ - Notification.text(window.env.t('abuseReported')); - $scope.$close(); - }); + Chat.flagChatMessage(groupId, message.id) + .then(function(data){ + Notification.text(window.env.t('abuseReported')); + $scope.$close(); + }); }; $scope.clearFlagCount = function(message, groupId) { - Chat.utils.clearFlagCount({gid: groupId, messageId: message.id}, undefined, function(data){ - message.flagCount = 0; - Notification.text("Flags cleared"); - $scope.$close(); - }); + Chat.clearFlagCount(groupId, message.id) + .then(function(data){ + message.flagCount = 0; + Notification.text("Flags cleared"); + $scope.$close(); + }); } } ]); From 93d50e4f60a6ccede0ca17c74392956f1aeb4afa Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 18 May 2016 09:54:02 -0500 Subject: [PATCH 920/976] fix: Correct get tasks route to properly get todos (#7349) --- .../integration/tasks/GET-tasks_user.test.js | 52 +++++++++++++++++-- website/server/controllers/api-v3/tasks.js | 1 + 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js index 38b373d76e..406c0d43fb 100644 --- a/test/api/v3/integration/tasks/GET-tasks_user.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js @@ -16,13 +16,55 @@ describe('GET /tasks/user', () => { }); it('returns only a type of user\'s tasks if req.query.type is specified', async () => { - let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks/user?type=habits'); - expect(tasks.length).to.equal(1); - expect(tasks[0]._id).to.equal(createdTasks[0]._id); + let createdTasks = await user.post('/tasks/user', [ + {text: 'test habit', type: 'habit'}, + {text: 'test daily', type: 'daily'}, + {text: 'test reward', type: 'reward'}, + {text: 'test todo', type: 'todo'}, + ]); + let habits = await user.get('/tasks/user?type=habits'); + let dailys = await user.get('/tasks/user?type=dailys'); + let rewards = await user.get('/tasks/user?type=rewards'); + + expect(habits.length).to.be.at.least(1); + expect(habits[0]._id).to.equal(createdTasks[0]._id); + expect(dailys.length).to.be.at.least(1); + expect(dailys[0]._id).to.equal(createdTasks[1]._id); + expect(rewards.length).to.be.at.least(1); + expect(rewards[0]._id).to.equal(createdTasks[2]._id); }); - it('returns completed todos sorted by reverse completion date if req.query.type === "completeTodos"', async () => { + it('returns uncompleted todos if req.query.type is "todos"', async () => { + let existingTodos = await user.get('/tasks/user?type=todos'); + + // populate user with other task types + await user.post('/tasks/user', [ + {text: 'daily', type: 'daily'}, + {text: 'reward', type: 'reward'}, + {text: 'habit', type: 'habit'}, + ]); + + let newUncompletedTodos = await user.post('/tasks/user', [ + {text: 'test todo 1', type: 'todo'}, + {text: 'test todo 2', type: 'todo'}, + ]); + let todoToBeCompleted = await user.post('/tasks/user', { + text: 'wll be completed todo', type: 'todo', + }); + + await user.post(`/tasks/${todoToBeCompleted._id}/score/up`); + + let uncompletedTodos = [...existingTodos, ...newUncompletedTodos]; + + let todos = await user.get('/tasks/user?type=todos'); + + expect(todos.length).to.be.gte(2); + expect(todos.length).to.eql(uncompletedTodos.length); + expect(todos.every(task => task.type === 'todo')); + expect(todos.every(task => task.completed === false)); + }); + + it('returns completed todos sorted by reverse completion date if req.query.type is "completeTodos"', async () => { let todo1 = await user.post('/tasks/user', {text: 'todo to complete 1', type: 'todo'}); let todo2 = await user.post('/tasks/user', {text: 'todo to complete 2', type: 'todo'}); diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 80f198cbf9..aa7fc9b939 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -121,6 +121,7 @@ async function _getTasks (req, res, user, challenge) { if (type) { if (type === 'todos') { query.completed = false; // Exclude completed todos + query.type = 'todo'; } else if (type === 'completedTodos') { query = Tasks.Task.find({ userId: user._id, From dad60909f7944982166422b364eaf44b4b0741f3 Mon Sep 17 00:00:00 2001 From: Alys Date: Thu, 19 May 2016 00:56:05 +1000 Subject: [PATCH 921/976] move locales strings from api-v3.json to other locales files (#7347) * move locales strings from api-v3.json: authentication strings -> front.json * move locales strings from api-v3.json: authentication strings -> tasks.json * move locales strings from api-v3.json: authentication strings -> groups.json * move locales strings from api-v3.json: authentication strings -> challenge.json * move locales strings from api-v3.json: authentication strings -> groups.json (again) * move locales strings from api-v3.json: authentication strings -> quests.json * move locales strings from api-v3.json: authentication strings -> subscriber.json * move locales strings from api-v3.json: authentication strings -> spells.json * move locales strings from api-v3.json: authentication strings -> character.json * move locales strings from api-v3.json: authentication strings -> groups.json (PMs) * move locales strings from api-v3.json: authentication strings -> npc.json * move locales strings from api-v3.json: authentication strings -> pets.json * move locales strings from api-v3.json: authentication strings -> miscellaneous * move locales strings from api-v3.json: authentication strings -> contrib.json and settings.json * move locales strings from api-v3.json: delete unused string (invalidTasksOwner), delete api-v3.json, whitespace cleanup --- common/locales/en/api-v3.json | 178 ------------------------------ common/locales/en/challenge.json | 17 ++- common/locales/en/character.json | 5 +- common/locales/en/content.json | 1 - common/locales/en/contrib.json | 6 +- common/locales/en/death.json | 4 +- common/locales/en/front.json | 29 ++++- common/locales/en/groups.json | 37 ++++++- common/locales/en/npc.json | 23 ++++ common/locales/en/pets.json | 4 + common/locales/en/quests.json | 21 +++- common/locales/en/rebirth.json | 3 +- common/locales/en/settings.json | 5 + common/locales/en/spells.json | 8 +- common/locales/en/subscriber.json | 24 +++- common/locales/en/tasks.json | 15 ++- 16 files changed, 188 insertions(+), 192 deletions(-) delete mode 100644 common/locales/en/api-v3.json diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json deleted file mode 100644 index 24760767f6..0000000000 --- a/common/locales/en/api-v3.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "missingAuthHeaders": "Missing authentication headers.", - "missingAuthParams": "Missing authentication parameters.", - "missingUsernameEmail": "Missing username or email.", - "missingEmail": "Missing email.", - "missingUsername": "Missing username.", - "missingPassword": "Missing password.", - "missingNewPassword": "Missing newPassword.", - "wrongPassword": "Wrong password.", - "notAnEmail": "Invalid email address.", - "emailTaken": "Email already taken.", - "newEmailRequired": "The newEmail body parameter is required.", - "usernameTaken": "Username already taken.", - "passwordConfirmationMatch": "Password confirmation doesn't match password.", - "invalidLoginCredentials": "Incorrect username / email and / or password.", - "passwordReset": "If we have your email on file, your password reset link has been sent to your email.", - "passwordResetEmailSubject": "Password Reset for Habitica", - "passwordResetEmailText": "Password for <%= username %> has been reset to <%= newPassword %> . Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them. Log in at <%= baseUrl %>. After you have logged in, head to <%= baseUrl %>/#/options/settings/settings and change your password.", - "passwordResetEmailHtml": "Password for <%= username %> has been reset to <%= newPassword %>.

Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them.

Log in at <%= baseUrl %>. After you have logged in, head to <%= baseUrl %>/#/options/settings/settings and change your password.", - "invalidLoginCredentialsLong": "Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\".", - "invalidCredentials": "User not found with given auth credentials.", - "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your UUID \"<%= userId %>\" for assistance.", - "onlyFbSupported": "Only Facebook supported currently.", - "cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", - "onlySocialAttachLocal": "Local auth can only be added to a social account.", - "invalidReqParams": "Invalid request parameters.", - "memberIdRequired": "\"member\" must be a valid UUID.", - "heroIdRequired": "\"heroId\" must be a valid UUID.", - "taskIdRequired": "\"taskId\" must be a valid UUID.", - "taskNotFound": "Task not found.", - "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", - "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.", - "checklistOnlyDailyTodo": "Checklists are supported only on dailies and todos", - "checklistItemNotFound": "No checklist item was found with given id.", - "itemIdRequired": "\"itemId\" must be a valid UUID.", - "tagNotFound": "No tag item was found with given id.", - "tagIdRequired": "\"tagId\" must be a valid UUID corresponding to a tag belonging to the user.", - "positionRequired": "\"position\" is required and must be a number.", - "cantMoveCompletedTodo": "Can't move a completed todo.", - "directionUpDown": "\"direction\" is required and must be 'up' or 'down'", - "alreadyTagged": "The task is already tagged with given tag.", - "groupIdRequired": "\"groupId\" must be a valid UUID", - "groupNotFound": "Group not found.", - "groupTypesRequired": "You must supply a valid \"type\" query string.", - "questLeaderCannotLeaveGroup": "You cannot leave your party when you have started a quest. Abort the quest first.", - "cannotLeaveWhileActiveQuest": "You cannot leave party during an active quest. Please leave the quest first.", - "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", - "memberCannotRemoveYourself": "You cannot remove yourself!", - "groupMemberNotFound": "User not found among group's members", - "challengeMemberNotFound": "User not found among challenge's members", - "mustBeGroupMember": "Must be member of the group.", - "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", - "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"", - "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", - "inviteMissingEmail": "Missing email address in invite.", - "onlyGroupLeaderChal": "Only the group leader can create challenges", - "tavChalsMinPrize": "Prize must be at least 1 Gem for Tavern challenges.", - "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount.", - "challengeIdRequired": "\"challengeId\" must be a valid UUID.", - "winnerIdRequired": "\"winnerId\" must be a valid UUID.", - "challengeNotFound": "Challenge not found.", - "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", - "onlyLeaderUpdateChal": "Only the challenge leader can update it.", - "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", - "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", - "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", - "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", - "userAlreadyInChallenge": "User is already participating in this challenge.", - "invalidTasksOwner": "\"tasksOwner\" must be \"user\" or \"challenge\".", - "partyMustbePrivate": "Parties must be private", - "userAlreadyInGroup": "User already in that group.", - "userAlreadyInvitedToGroup": "User already invited to that group.", - "userAlreadyPendingInvitation": "User already pending invitation.", - "userAlreadyInAParty": "User already in a party.", - "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", - "userHasNoLocalRegistration": "User does not have a local registration (username, email, password).", - "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", - "emailsMustBeAnArray": "Email invites must be a an Array.", - "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", - "cantOnlyUnlinkChalTask": "Only broken challenges tasks can be unlinked.", - "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!", - "questInviteNotFound": "No quest invitation found.", - "guildQuestsNotSupported": "Guilds cannot be invited on quests.", - "questNotFound": "Quest \"<%= key %>\" not found.", - "questNotOwned": "You don't own that quest scroll.", - "questNotGoldPurchasable": "Quest \"<%= key %>\" is not a Gold-purchasable quest.", - "questLevelTooHigh": "You must be level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", - "questAlreadyAccepted": "You already accepted the quest invitation.", - "noActiveQuestToLeave": "No active quest to leave", - "questLeaderCannotLeaveQuest": "Quest leader cannot leave quest", - "notPartOfQuest": "You are not part of the quest", - "noActiveQuestToAbort": "There is no active quest to abort.", - "onlyLeaderAbortQuest": "Only the group or quest leader can abort a quest.", - "questAlreadyRejected": "You already rejected the quest invitation.", - "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality.", - "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.", - "questInvitationDoesNotExist": "No quest invitation has been sent out yet.", - "questNotPending": "There is no quest to start.", - "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest", - "noAdminAccess": "You don't have admin access.", - "pageMustBeNumber": "req.query.page must be a number", - "missingUnsubscriptionCode": "Missing unsubscription code.", - "missingSubscription": "User does not have a plan subscription", - "missingSubscriptionCode": "Missing subscription code. Possible values: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo.", - "userNotFound": "User not found.", - "spellNotFound": "Spell \"<%= spellId %>\" not found.", - "partyNotFound": "Party not found", - "targetIdUUID": "\"targetId\" must be a valid UUID.", - "invalidUUID": "UUID must be valid", - "challengeTasksNoCast": "Casting a spell on challenge tasks is not supported.", - "spellNotOwned": "You don't own this spell.", - "spellLevelTooHigh": "You must be level <%= level %> to use this spell.", - "invalidAttribute": "\"<%= attr %>\" is not a valid attribute.", - "notEnoughAttrPoints": "You don't have enough attribute points.", - "missingKeyParam": "\"req.params.key\" is required.", - "mysterySetNotFound": "Mystery set not found, or set already owned.", - "itemNotFound": "Item \"<%= key %>\" not found.", - "cannotBuyItem": "You can't buy this item.", - "missingTypeKeyEquip": "\"key\" and \"type\" are required parameters.", - "missingPetFoodFeed": "\"pet\" and \"food\" are required parameters.", - "invalidPetName": "Invalid pet name supplied.", - "missingEggHatchingPotionHatch": "\"egg\" and \"hatchingPotion\" are required parameters.", - "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'.", - "cannotDeleteActiveAccount": "You have an active subscription, cancel your plan before deleting your account.", - "messageRequired": "A message is required.", - "toUserIDRequired": "A User ID is required", - "gemAmountRequired": "A number of gems is required", - "notAuthorizedToSendMessageToThisUser": "Can't send message to this user.", - "privateMessageGiftIntro": "Hello <%= receiverName %>, <%= senderName %> has sent you ", - "privateMessageGiftGemsMessage": "<%= gemAmount %> gems! ", - "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", - "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", - "badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.", - "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>.", - "typeRequired": "Type is required", - "keyRequired": "Key is required", - "mustSubscribeToPurchaseGems": "Must subscribe to purchase gems with GP", - "reachedGoldToGemCap": "You've reached the Gold=>Gem conversion cap <%= convCap %> for this month. We have this to prevent abuse / farming. The cap will reset within the first three days of next month.", - "notAccteptedType": "Type must be in [eggs, hatchingPotions, food, quests, gear]", - "contentKeyNotFound": "Key not found for Content <%= type %>", - "plusOneGem": "+1 Gem", - "notAllowedHourglass": "Pet/Mount not available for purchase with Mystic Hourglass.", - "readCard": "<%= cardType %> has been read", - "cardTypeRequired": "Card type required", - "cardTypeNotAllowed": "Unkown card type.", - "mysteryItemIsEmpty": "Mystery items are empty", - "mysteryItemOpened": "Mystery item opened.", - "invalidCoupon": "Invalid coupon code.", - "couponUsed": "Coupon code already used.", - "noSudoAccess": "You don't have sudo access.", - "couponCodeRequired": "The coupon code is required.", - "eventRequired": "\"req.params.event\" is required.", - "countRequired": "\"req.query.count\" is required.", - "invalidUrl": "invalid url", - "invalidEnabled": "the \"enabled\" parameter should be a boolean", - "petsReleased": "Pets released.", - "mountsAndPetsReleased": "Mounts and pets released", - "mountsReleased": "Mounts released", - "typeNotSellable": "Type is not sellable. Must be one of the following <%= acceptedTypes %>", - "userItemsKeyNotFound": "Key not found for user.items <%= type %>", - "pathRequired": "Path string is required", - "unlocked": "Items have been unlocked", - "alreadyUnlocked": "Full set already unlocked.", - "alreadyUnlockedPart": "Full set already partially unlocked.", - "cannotRevive": "Cannot revive if not dead", - "rebirthComplete": "You have been reborn!", - "petNotOwned": "You do not own this pet.", - "regIdRequired": "RegId is required", - "pushDeviceAdded": "Push device added successfully", - "pushDeviceAlreadyAdded": "The user already has the push device", - "lvl10ChangeClass": "To change class you must be at least level 10.", - "equipmentAlreadyOwned": "You already own that piece of equipment", - "pmsMarkedRead": "Your private messages have been marked as read", - "paymentNotSuccessful": "The payment was not successful", - "planNotActive": "The plan hasn't activated yet (due to a PayPal bug). It will begin <%= nextBillingDate %>, after which you can cancel to retain your full benefits", - "cancelingSubscription": "Canceling the subscription" -} diff --git a/common/locales/en/challenge.json b/common/locales/en/challenge.json index d82085f1c1..cffe8c4a38 100644 --- a/common/locales/en/challenge.json +++ b/common/locales/en/challenge.json @@ -63,5 +63,20 @@ "congratulations": "Congratulations!", "hurray": "Hurray!", "noChallengeOwner": "no owner", - "noChallengeOwnerPopover": "This challenge does not have an owner because the person who created the challenge deleted their account." + "noChallengeOwnerPopover": "This challenge does not have an owner because the person who created the challenge deleted their account.", + "challengeMemberNotFound": "User not found among challenge's members", + "onlyGroupLeaderChal": "Only the group leader can create challenges", + "tavChalsMinPrize": "Prize must be at least 1 Gem for Tavern challenges.", + "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount.", + "challengeIdRequired": "\"challengeId\" must be a valid UUID.", + "winnerIdRequired": "\"winnerId\" must be a valid UUID.", + "challengeNotFound": "Challenge not found.", + "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", + "onlyLeaderUpdateChal": "Only the challenge leader can update it.", + "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", + "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", + "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", + "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", + "userAlreadyInChallenge": "User is already participating in this challenge.", + "cantOnlyUnlinkChalTask": "Only broken challenges tasks can be unlinked." } diff --git a/common/locales/en/character.json b/common/locales/en/character.json index 0cd4e854cb..50e3022b0c 100644 --- a/common/locales/en/character.json +++ b/common/locales/en/character.json @@ -110,6 +110,7 @@ "mage": "Mage", "mystery": "Mystery", "changeClass": "Change Class, Refund Attribute Points", + "lvl10ChangeClass": "To change class you must be at least level 10.", "levelPopover": "Each level earns you one point to assign to an attribute of your choice. You can do so manually, or let the game decide for you using one of the Automatic Allocation options.", "unallocated": "Unallocated Attribute Points", "haveUnallocated": "You have <%= points %> unallocated Attribute Point(s)", @@ -165,5 +166,7 @@ "int": "INT", "showQuickAllocation": "Show stat allocation", "hideQuickAllocation": "Hide stat allocation", - "quickAllocationLevelPopover": "Each level earns you one point to assign to an attribute of your choice. You can do so manually, or let the game decide for you using one of the Automatic Allocation options found in User -> Stats." + "quickAllocationLevelPopover": "Each level earns you one point to assign to an attribute of your choice. You can do so manually, or let the game decide for you using one of the Automatic Allocation options found in User -> Stats.", + "invalidAttribute": "\"<%= attr %>\" is not a valid attribute.", + "notEnoughAttrPoints": "You don't have enough attribute points." } diff --git a/common/locales/en/content.json b/common/locales/en/content.json index 6d73ff4c3c..ce73ded316 100644 --- a/common/locales/en/content.json +++ b/common/locales/en/content.json @@ -223,5 +223,4 @@ "foodSaddleNotes": "Instantly raises one of your pets into a mount.", "foodNotes": "Feed this to a pet and it may grow into a sturdy steed." - } diff --git a/common/locales/en/contrib.json b/common/locales/en/contrib.json index f309618458..9ce0473f3c 100644 --- a/common/locales/en/contrib.json +++ b/common/locales/en/contrib.json @@ -35,8 +35,12 @@ "hallContributors": "Hall of Contributors", "hallPatrons": "Hall of Patrons", "rewardUser": "Reward User", - "UUID": "UUID", + "UUID": "User ID", "loadUser": "Load User", + "noAdminAccess": "You don't have admin access.", + "pageMustBeNumber": "req.query.page must be a number", + "userNotFound": "User not found.", + "invalidUUID": "UUID must be valid", "title": "Title", "moreDetails": "More details (1-7)", "moreDetails2": "more details (8-9)", diff --git a/common/locales/en/death.json b/common/locales/en/death.json index dc131e74f2..b6723981f2 100644 --- a/common/locales/en/death.json +++ b/common/locales/en/death.json @@ -12,6 +12,6 @@ "losingHealthQuickly": "Losing Health quickly?", "lowHealthTips3": "Incomplete Dailies hurt you overnight, so be careful not to add too many at first!", "lowHealthTips4": "If a Daily isn't due on a certain day, you can disable it by clicking the pencil icon.", - "goodLuck": "Good luck!" + "goodLuck": "Good luck!", + "cannotRevive": "Cannot revive if not dead" } - diff --git a/common/locales/en/front.json b/common/locales/en/front.json index 6ef64a0d07..74591fa39b 100644 --- a/common/locales/en/front.json +++ b/common/locales/en/front.json @@ -225,5 +225,32 @@ "altAttrWebstorm": "WebStorm", "altAttrGithub": "GitHub", "altAttrTrello": "Trello", - "altAttrSlack": "Slack" + "altAttrSlack": "Slack", + "missingAuthHeaders": "Missing authentication headers.", + "missingAuthParams": "Missing authentication parameters.", + "missingUsernameEmail": "Missing username or email.", + "missingEmail": "Missing email.", + "missingUsername": "Missing username.", + "missingPassword": "Missing password.", + "missingNewPassword": "Missing new password.", + "wrongPassword": "Wrong password.", + "notAnEmail": "Invalid email address.", + "emailTaken": "Email address is already used in an account.", + "newEmailRequired": "Missing new email address.", + "usernameTaken": "Username already taken.", + "passwordConfirmationMatch": "Password confirmation doesn't match password.", + "invalidLoginCredentials": "Incorrect username and/or email and/or password.", + "passwordReset": "If we have your email on file, your password reset link has been sent to your email.", + "passwordResetEmailSubject": "Password Reset for Habitica", + "passwordResetEmailText": "Password for <%= username %> has been reset to <%= newPassword %> . Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them. Log in at <%= baseUrl %>. After you have logged in, head to <%= baseUrl %>/#/options/settings/settings and change your password.", + "passwordResetEmailHtml": "Password for <%= username %> has been reset to <%= newPassword %>.

Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them.

Log in at <%= baseUrl %>. After you have logged in, head to <%= baseUrl %>/#/options/settings/settings and change your password.", + "invalidLoginCredentialsLong": "Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\".", + "invalidCredentials": "There is no account that uses those credentials.", + "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your User ID \"<%= userId %>\" for assistance.", + "onlyFbSupported": "Only Facebook is supported currently.", + "cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", + "onlySocialAttachLocal": "Local authentication can be added to only a social account.", + "invalidReqParams": "Invalid request parameters.", + "memberIdRequired": "\"member\" must be a valid UUID.", + "heroIdRequired": "\"heroId\" must be a valid UUID." } diff --git a/common/locales/en/groups.json b/common/locales/en/groups.json index 901c813d96..4d8ea0716c 100644 --- a/common/locales/en/groups.json +++ b/common/locales/en/groups.json @@ -92,6 +92,7 @@ "send": "Send", "messageSentAlert": "Message sent", "pmHeading": "Private message to <%= name %>", + "pmsMarkedRead": "Your private messages have been marked as read", "clearAll": "Delete All Messages", "confirmDeleteAllMessages": "Are you sure you want to delete all messages in your inbox? Other users will still see messages you have sent to them.", "optOutPopover": "Don't like private messages? Click to completely opt out", @@ -99,6 +100,15 @@ "unblock": "Un-block", "pm-reply": "Send a reply", "inbox": "Inbox", + "messageRequired": "A message is required.", + "toUserIDRequired": "A User ID is required", + "gemAmountRequired": "A number of gems is required", + "notAuthorizedToSendMessageToThisUser": "Can't send message to this user.", + "privateMessageGiftIntro": "Hello <%= receiverName %>, <%= senderName %> has sent you ", + "privateMessageGiftGemsMessage": "<%= gemAmount %> gems! ", + "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", + "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", + "badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.", "abuseFlag": "Report violation of Community Guidelines", "abuseFlagModalHeading": "Report <%= name %> for violation?", "abuseFlagModalBody": "Are you sure you want to report this post? You should ONLY report a post that violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Inappropriately reporting a post is a violation of the Community Guidelines and may give you an infraction. Appropriate reasons to flag a post include but are not limited to:

  • swearing, religous oaths
  • bigotry, slurs
  • adult topics
  • violence, including as a joke
  • spam, nonsensical messages
", @@ -151,6 +161,29 @@ "partyUpName": "Party Up", "partyOnName": "Party On", "partyUpAchievement": "Joined a Party with another person! Have fun battling monsters and supporting each other.", - "partyOnAchievement": "Joined a Party with at least four people! Enjoy your increased accountability as you unite with your friends to vanquish your foes!" + "partyOnAchievement": "Joined a Party with at least four people! Enjoy your increased accountability as you unite with your friends to vanquish your foes!", + "groupIdRequired": "\"groupId\" must be a valid UUID", + "groupNotFound": "Group not found.", + "groupTypesRequired": "You must supply a valid \"type\" query string.", + "questLeaderCannotLeaveGroup": "You cannot leave your party when you have started a quest. Abort the quest first.", + "cannotLeaveWhileActiveQuest": "You cannot leave party during an active quest. Please leave the quest first.", + "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", + "memberCannotRemoveYourself": "You cannot remove yourself!", + "groupMemberNotFound": "User not found among group's members", + "mustBeGroupMember": "Must be member of the group.", + "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", + "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"", + "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", + "inviteMissingEmail": "Missing email address in invite.", + "partyMustbePrivate": "Parties must be private", + "userAlreadyInGroup": "User already in that group.", + "userAlreadyInvitedToGroup": "User already invited to that group.", + "userAlreadyPendingInvitation": "User already pending invitation.", + "userAlreadyInAParty": "User already in a party.", + "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", + "userHasNoLocalRegistration": "User does not have a local registration (username, email, password).", + "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", + "emailsMustBeAnArray": "Email invites must be a an Array.", + "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", + "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!" } - diff --git a/common/locales/en/npc.json b/common/locales/en/npc.json index 656b0c5e35..55ad9377c4 100644 --- a/common/locales/en/npc.json +++ b/common/locales/en/npc.json @@ -21,6 +21,28 @@ "ian": "Ian", "ianText": "Welcome to the Quest Shop! Here you can use Quest Scrolls to battle monsters with your friends. Be sure to check out our fine array of Quest Scrolls for purchase on the right!", "ianBrokenText": "Welcome to the Quest Shop... Here you can use Quest Scrolls to battle monsters with your friends... Be sure to check out our fine array of Quest Scrolls for purchase on the right...", + + "missingKeyParam": "\"req.params.key\" is required.", + "itemNotFound": "Item \"<%= key %>\" not found.", + "cannotBuyItem": "You can't buy this item.", + "missingTypeKeyEquip": "\"key\" and \"type\" are required parameters.", + "missingPetFoodFeed": "\"pet\" and \"food\" are required parameters.", + "invalidPetName": "Invalid pet name supplied.", + "missingEggHatchingPotionHatch": "\"egg\" and \"hatchingPotion\" are required parameters.", + "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'.", + "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>.", + "typeRequired": "Type is required", + "keyRequired": "Key is required", + "notAccteptedType": "Type must be in [eggs, hatchingPotions, food, quests, gear]", + "contentKeyNotFound": "Key not found for Content <%= type %>", + "plusOneGem": "+1 Gem", + "typeNotSellable": "Type is not sellable. Must be one of the following <%= acceptedTypes %>", + "userItemsKeyNotFound": "Key not found for user.items <%= type %>", + "pathRequired": "Path string is required", + "unlocked": "Items have been unlocked", + "alreadyUnlocked": "Full set already unlocked.", + "alreadyUnlockedPart": "Full set already partially unlocked.", + "USD": "(USD)", "newStuff": "New Stuff", "cool": "Tell Me Later", @@ -67,6 +89,7 @@ "tourPetsPage": "This is the Stable! After level 3, you can hatch pets using eggs and potions. When you hatch a pet in the Market, it will appear here! Click a pet's image to add it to your avatar. Feed them with the food you find after level 3, and they'll grow into powerful mounts.", "tourMountsPage": "Once you've fed a pet enough food to turn it into a mount, it will appear here. (Pets, mounts, and food are available after level 3.) Click a mount to saddle up!", "tourEquipmentPage": "This is where your Equipment is stored! Your Battle Gear affects your stats. If you want to show different Equipment on your avatar without changing your stats, click \"Enable Costume.\"", + "equipmentAlreadyOwned": "You already own that piece of equipment", "tourOkay": "Okay!", "tourAwesome": "Awesome!", diff --git a/common/locales/en/pets.json b/common/locales/en/pets.json index f6dcadae58..7cd716b72d 100644 --- a/common/locales/en/pets.json +++ b/common/locales/en/pets.json @@ -62,6 +62,7 @@ "hatchedPet": "You hatched a <%= potion %> <%= egg %>!", "displayNow": "Display Now", "displayLater": "Display Later", + "petNotOwned": "You do not own this pet.", "earnedCompanion": "With all your productivity, you've earned a new companion. Feed it to make it grow!", "feedPet": "Feed <%= article %><%= text %> to your <%= name %>?", "useSaddle": "Saddle <%= pet %>?", @@ -83,5 +84,8 @@ "petKeyBoth": "Release Both", "confirmPetKey": "Are you sure?", "petKeyNeverMind": "Not Yet", + "petsReleased": "Pets released.", + "mountsAndPetsReleased": "Mounts and pets released", + "mountsReleased": "Mounts released", "gemsEach": "gems each" } diff --git a/common/locales/en/quests.json b/common/locales/en/quests.json index 23ab7fc54a..307c5bbf6b 100644 --- a/common/locales/en/quests.json +++ b/common/locales/en/quests.json @@ -79,5 +79,24 @@ "getMoreQuests": "Get more quests", "unlockedAQuest": "You unlocked a quest!", "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!", - "questInvitationDoesNotExist": "No quest invitation has been sent out yet." + "questInvitationDoesNotExist": "No quest invitation has been sent out yet.", + "questInviteNotFound": "No quest invitation found.", + "guildQuestsNotSupported": "Guilds cannot be invited on quests.", + "questNotFound": "Quest \"<%= key %>\" not found.", + "questNotOwned": "You don't own that quest scroll.", + "questNotGoldPurchasable": "Quest \"<%= key %>\" is not a Gold-purchasable quest.", + "questLevelTooHigh": "You must be level <%= level %> to begin this quest.", + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "questAlreadyAccepted": "You already accepted the quest invitation.", + "noActiveQuestToLeave": "No active quest to leave", + "questLeaderCannotLeaveQuest": "Quest leader cannot leave quest", + "notPartOfQuest": "You are not part of the quest", + "noActiveQuestToAbort": "There is no active quest to abort.", + "onlyLeaderAbortQuest": "Only the group or quest leader can abort a quest.", + "questAlreadyRejected": "You already rejected the quest invitation.", + "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality.", + "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.", + "questInvitationDoesNotExist": "No quest invitation has been sent out yet.", + "questNotPending": "There is no quest to start.", + "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest" } diff --git a/common/locales/en/rebirth.json b/common/locales/en/rebirth.json index 4d10d55d87..f56c9d6279 100644 --- a/common/locales/en/rebirth.json +++ b/common/locales/en/rebirth.json @@ -24,5 +24,6 @@ "rebirthPop": "Begin a new character at Level 1 while retaining achievements, collectibles, and tasks with history.", "rebirthName": "Orb of Rebirth", "reborn": "Reborn, max level <%= reLevel %>", - "confirmReborn": "Are you sure?" + "confirmReborn": "Are you sure?", + "rebirthComplete": "You have been reborn!" } diff --git a/common/locales/en/settings.json b/common/locales/en/settings.json index 6437a6e281..daffc88b5a 100644 --- a/common/locales/en/settings.json +++ b/common/locales/en/settings.json @@ -148,6 +148,11 @@ "webhooks": "Webhooks", "enabled": "Enabled", "webhookURL": "Webhook URL", + "invalidUrl": "invalid url", + "invalidEnabled": "the \"enabled\" parameter should be a boolean", + "regIdRequired": "RegId is required", + "pushDeviceAdded": "Push device added successfully", + "pushDeviceAlreadyAdded": "The user already has the push device", "add": "Add", "buyGemsGoldCap": "Cap raised to <%= amount %>", "mysticHourglass": "<%= amount %> Mystic Hourglass", diff --git a/common/locales/en/spells.json b/common/locales/en/spells.json index 9ecd005ec9..37dc7eaad4 100644 --- a/common/locales/en/spells.json +++ b/common/locales/en/spells.json @@ -65,6 +65,12 @@ "spellSpecialSeafoamText": "Seafoam", "spellSpecialSeafoamNotes": "Turn a friend into a sea creature!", "spellSpecialSandText": "Sand", - "spellSpecialSandNotes": "Cancel the effects of Seafoam." + "spellSpecialSandNotes": "Cancel the effects of Seafoam.", + "spellNotFound": "Skill \"<%= spellId %>\" not found.", + "partyNotFound": "Party not found", + "targetIdUUID": "\"targetId\" must be a valid User ID.", + "challengeTasksNoCast": "Casting a skill on challenge tasks is not supported.", + "spellNotOwned": "You don't own this skill.", + "spellLevelTooHigh": "You must be level <%= level %> to use this skill." } diff --git a/common/locales/en/subscriber.json b/common/locales/en/subscriber.json index e3342c265f..197994da08 100644 --- a/common/locales/en/subscriber.json +++ b/common/locales/en/subscriber.json @@ -4,6 +4,8 @@ "subDescription": "Buy Gems with gold, get monthly mystery items, retain progress history, double daily drop-caps, support the devs. Click for more info.", "buyGemsGold": "Buy Gems with Gold", "buyGemsGoldText": "Alexander the Merchant will sell you Gems at a cost of <%= gemCost %> gold per gem. His monthly shipments are initially capped at <%= gemLimit %> Gems per month, but this cap increases by 5 Gems for every three months of consecutive subscription, up to a maximum of 50 Gems per month!", + "mustSubscribeToPurchaseGems": "Must subscribe to purchase gems with GP", + "reachedGoldToGemCap": "You've reached the Gold=>Gem conversion cap <%= convCap %> for this month. We have this to prevent abuse / farming. The cap will reset within the first three days of next month.", "retainHistory": "Retain additional history entries", "retainHistoryText": "Makes completed To-Dos and task history available for longer.", "doubleDrops": "Daily drop-caps doubled", @@ -29,6 +31,7 @@ "manageSub": "Click to manage subscription", "cancelSub": "Cancel Subscription", "canceledSubscription": "Canceled Subscription", + "cancelingSubscription": "Canceling the subscription", "adminSub": "Administrator Subscriptions", "morePlans": "More Plans
Coming Soon", "organizationSub": "Private Organization", @@ -73,6 +76,9 @@ "timeTravelersPopover": "We see you have a Mystic Hourglass, so we will happily travel back in time for you! Please choose the pet, mount, or Mystery Item Set you would like. You can see a list of the past item sets here! If those don't satisfy you, perhaps you'd be interested in one of our fashionably futuristic Steampunk Item Sets?", "timeTravelersAlreadyOwned": "Congratulations! You already own everything the Time Travelers currently offer. Thanks for supporting the site!", "mysticHourglassPopover": "A Mystic Hourglass allows you to purchase certain limited-time items, such as monthly Mystery Item Sets and awards from world bosses, from the past!", + "mysterySetNotFound": "Mystery set not found, or set already owned.", + "mysteryItemIsEmpty": "Mystery items are empty", + "mysteryItemOpened": "Mystery item opened.", "mysterySet201402": "Winged Messenger Set", "mysterySet201403": "Forest Walker Set", @@ -118,5 +124,21 @@ "petsNotAllowedHourglass": "Pet not available for purchase with Mystic Hourglass.", "mountsNotAllowedHourglass": "Mount not available for purchase with Mystic Hourglass.", "hourglassPurchase": "Purchased an item using a Mystic Hourglass!", - "hourglassPurchaseSet": "Purchased an item set using a Mystic Hourglass!" + "hourglassPurchaseSet": "Purchased an item set using a Mystic Hourglass!", + "missingUnsubscriptionCode": "Missing unsubscription code.", + "missingSubscription": "User does not have a plan subscription", + "missingSubscriptionCode": "Missing subscription code. Possible values: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo.", + "cannotDeleteActiveAccount": "You have an active subscription, cancel your plan before deleting your account.", + "paymentNotSuccessful": "The payment was not successful", + "planNotActive": "The plan hasn't activated yet (due to a PayPal bug). It will begin <%= nextBillingDate %>, after which you can cancel to retain your full benefits", + "notAllowedHourglass": "Pet/Mount not available for purchase with Mystic Hourglass.", + "readCard": "<%= cardType %> has been read", + "cardTypeRequired": "Card type required", + "cardTypeNotAllowed": "Unkown card type.", + "invalidCoupon": "Invalid coupon code.", + "couponUsed": "Coupon code already used.", + "noSudoAccess": "You don't have sudo access.", + "couponCodeRequired": "The coupon code is required.", + "eventRequired": "\"req.params.event\" is required.", + "countRequired": "\"req.query.count\" is required." } diff --git a/common/locales/en/tasks.json b/common/locales/en/tasks.json index 691c5c20fa..5b32829f81 100644 --- a/common/locales/en/tasks.json +++ b/common/locales/en/tasks.json @@ -113,5 +113,18 @@ "rewardHelp2": "Equipment affects your stats (<%= linkStart %>Avatar > Stats<%= linkEnd %>).", "rewardHelp3": "Special equipment will appear here during World Events.", "rewardHelp4": "Don't be afraid to set custom Rewards! Check out some samples here.", - "clickForHelp": "Click for help" + "clickForHelp": "Click for help", + "taskIdRequired": "\"taskId\" must be a valid UUID.", + "taskNotFound": "Task not found.", + "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", + "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.", + "checklistOnlyDailyTodo": "Checklists are supported only on dailies and todos", + "checklistItemNotFound": "No checklist item was found with given id.", + "itemIdRequired": "\"itemId\" must be a valid UUID.", + "tagNotFound": "No tag item was found with given id.", + "tagIdRequired": "\"tagId\" must be a valid UUID corresponding to a tag belonging to the user.", + "positionRequired": "\"position\" is required and must be a number.", + "cantMoveCompletedTodo": "Can't move a completed todo.", + "directionUpDown": "\"direction\" is required and must be 'up' or 'down'", + "alreadyTagged": "The task is already tagged with given tag." } From 95542cd42eab3c0ee66546a5f6a544dfb2ad9656 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 17:16:40 +0200 Subject: [PATCH 922/976] v3 client: fix sticky header --- website/client/js/controllers/rootCtrl.js | 1 + website/client/js/services/memberServices.js | 6 +++--- website/client/js/services/userServices.js | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index b4fee4076a..1d6bb3a5bb 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -8,6 +8,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ var user = User.user; var initSticky = _.once(function(){ + console.log('here', User.user.preferences.stickyHeader); if (window.env.IS_MOBILE || User.user.preferences.stickyHeader === false) return; $('.header-wrap').sticky({topSpacing:0}); }) diff --git a/website/client/js/services/memberServices.js b/website/client/js/services/memberServices.js index c62e4cdbc2..2ecdd24b69 100644 --- a/website/client/js/services/memberServices.js +++ b/website/client/js/services/memberServices.js @@ -84,7 +84,7 @@ angular.module('habitrpg') fetchMember(uid) .then(function (response) { var member = response.data.data; - addToMembersList(member, self); // lazy load for later + addToMembersList(member); // lazy load for later _prepareMember(member, self); deferred.resolve(); }); @@ -95,7 +95,7 @@ angular.module('habitrpg') function addToMembersList (member, self) { if (member._id) { - self.members[member._id] = member; + members[member._id] = member; } } @@ -105,7 +105,7 @@ angular.module('habitrpg') function _prepareMember(member, self) { Shared.wrap(member, false); - self.selectedMember = self.members[member._id]; + self.selectedMember = members[member._id]; } $rootScope.$on('userUpdated', function(event, user){ diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index ec373ce1d2..14362b9a1f 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -84,6 +84,8 @@ angular.module('habitrpg') _.extend(user, response.data.data); + $rootScope.$emit('userUpdated', user); + if (!user._wrapped) { // This wraps user with `ops`, which are functions shared both on client and mobile. When performed on client, // they update the user in the browser and then send the request to the server, where the same operation is From 163b5b4ac3ba1d24c7ee470f7b3c0e2175833006 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 17:28:33 +0200 Subject: [PATCH 923/976] v3: remove unused code --- website/client/js/controllers/rootCtrl.js | 1 - website/client/js/services/memberServices.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index 1d6bb3a5bb..b4fee4076a 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -8,7 +8,6 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ var user = User.user; var initSticky = _.once(function(){ - console.log('here', User.user.preferences.stickyHeader); if (window.env.IS_MOBILE || User.user.preferences.stickyHeader === false) return; $('.header-wrap').sticky({topSpacing:0}); }) diff --git a/website/client/js/services/memberServices.js b/website/client/js/services/memberServices.js index 2ecdd24b69..be1eab4841 100644 --- a/website/client/js/services/memberServices.js +++ b/website/client/js/services/memberServices.js @@ -93,7 +93,7 @@ angular.module('habitrpg') return deferred.promise; } - function addToMembersList (member, self) { + function addToMembersList (member) { if (member._id) { members[member._id] = member; } From 58b9a08539c94058b36eb8f432f53f636afab8c3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 17:41:42 +0200 Subject: [PATCH 924/976] v3 client: correctly redirect after inviting --- website/client/js/controllers/inviteToGroupCtrl.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/website/client/js/controllers/inviteToGroupCtrl.js b/website/client/js/controllers/inviteToGroupCtrl.js index 56a602b726..a986cd768c 100644 --- a/website/client/js/controllers/inviteToGroupCtrl.js +++ b/website/client/js/controllers/inviteToGroupCtrl.js @@ -48,7 +48,14 @@ habitrpg.controller('InviteToGroupCtrl', ['$scope', '$rootScope', 'User', 'Group .then(function() { Notification.text(window.env.t('invitationsSent')); _resetInvitees(); - $rootScope.hardRedirect('/#/options/groups/party'); + var redirectTo = '/#/options/groups/' + if ($scope.group.type === 'party') { + redirectTo += 'party'; + } else { + redirectTo += ('guilds/' + $scope.group._id); + } + + $rootScope.hardRedirect(redirectTo); }, function(){ _resetInvitees(); }); From 425172daae29faa174d8774aa8861488e3105f47 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 18 May 2016 16:48:32 +0100 Subject: [PATCH 925/976] Removed v2 calls from views (#7351) --- website/server/controllers/api-v3/challenges.js | 4 ++-- website/views/options/settings.jade | 6 +++--- website/views/options/social/challenges.jade | 2 +- website/views/options/social/hall.jade | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/website/server/controllers/api-v3/challenges.js b/website/server/controllers/api-v3/challenges.js index ca556f7541..6d7bc56667 100644 --- a/website/server/controllers/api-v3/challenges.js +++ b/website/server/controllers/api-v3/challenges.js @@ -1,4 +1,4 @@ -import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import { authWithHeaders, authWithSession } from '../../middlewares/api-v3/auth'; import _ from 'lodash'; import { model as Challenge } from '../../models/challenge'; import { @@ -340,7 +340,7 @@ api.getChallenge = { api.exportChallengeCsv = { method: 'GET', url: '/challenges/:challengeId/export/csv', - middlewares: [authWithHeaders()], + middlewares: [authWithSession], async handler (req, res) { req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index a2780e856b..ce1de8c4d2 100644 --- a/website/views/options/settings.jade +++ b/website/views/options/settings.jade @@ -133,11 +133,11 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') .panel-body div(ng-if='user.auth.facebook.id') button.btn.btn-primary(disabled='disabled', ng-if='!user.auth.local.username')=env.t('registeredWithFb') - button.btn.btn-danger(ng-click='http("delete","/api/v2/user/auth/social",null,"detachedFacebook")', ng-if='user.auth.local.username')=env.t('detachFacebook') + button.btn.btn-danger(ng-click='http("delete", "/api/v3/user/auth/social/facebook", null, "detachedFacebook")', ng-if='user.auth.local.username')=env.t('detachFacebook') hr div(ng-if='!user.auth.local.username') p=env.t('addLocalAuth') - form(ng-submit='http("post","/api/v2/register",localAuth,"addedLocalAuth")', ng-init='localAuth={}', name='localAuth', novalidate) + form(ng-submit='http("post", "/api/v3/user/auth/local/register", localAuth, "addedLocalAuth")', ng-init='localAuth={}', name='localAuth', novalidate) //-.alert.alert-danger(ng-messages='changeUsername.$error && changeUsername.submitted')=env.t('fillAll') .form-group input.form-control(type='text', placeholder=env.t('username'), ng-model='localAuth.username', required) @@ -218,7 +218,7 @@ script(type='text/ng-template', id='partials/options.settings.promo.html') input.form-control(type='number',ng-model='_codes.count',placeholder="Number of codes to generate (eg, 250)") .form-group button.btn.btn-primary(type='submit')=env.t('generate') - a.btn.btn-default(href='/api/v2/coupons?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}')=env.t('getCodes') + a.btn.btn-default(href='/api/v3/coupons?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}')=env.t('getCodes') script(type='text/ng-template', id='partials/options.settings.api.html') .container-fluid diff --git a/website/views/options/social/challenges.jade b/website/views/options/social/challenges.jade index 3557f78325..ac5c71fd4c 100644 --- a/website/views/options/social/challenges.jade +++ b/website/views/options/social/challenges.jade @@ -55,7 +55,7 @@ script(type='text/ng-template', id='partials/options.social.challenges.detail.ht // Member List div(bindonce='challenge', ng-if='challenge.members.length > 0') - a.btn.btn-primary.btn-sm.pull-right(ng-href='/api/v2/challenges/{{challenge._id}}/csv') + a.btn.btn-primary.btn-sm.pull-right(ng-href='/api/v3/challenges/{{challenge._id}}/export/csv') =env.t('exportChallengeCSV') h3=env.t('hows') menu diff --git a/website/views/options/social/hall.jade b/website/views/options/social/hall.jade index 7e17980117..381e5ec8fd 100644 --- a/website/views/options/social/hall.jade +++ b/website/views/options/social/hall.jade @@ -50,7 +50,7 @@ script(type='text/ng-template', id='partials/options.social.hall.heroes.html') h4 Update Item .form-group.well input.form-control(type='text',placeholder='Path (eg, items.pets.BearCub-Base)',ng-model='hero.itemPath') - small.muted Enter the item path. E.g., items.pets.BearCub-Zombie or items.gear.owned.head_special_0 or items.gear.equipped.head. See all paths here. When in doubt, ask Tyler. + small.muted Enter the item path. E.g., items.pets.BearCub-Zombie or items.gear.owned.head_special_0 or items.gear.equipped.head. See all paths here. When in doubt, ask Tyler. br input.form-control(type='text',placeholder='Value (eg, 5)',ng-model='hero.itemVal') small.muted Enter the item value. E.g., 5 or false or head_warrior_3 (respectively from above examples). From ef9dc9a15a3f916965027018efba16a1e9cf0856 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 17:49:17 +0200 Subject: [PATCH 926/976] v3: fix tests for challenge export --- .../challenges/GET-challenges_challengeId_export_csv.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js index e4c1ceee34..58e19ee127 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -15,8 +15,6 @@ describe('GET /challenges/:challengeId/export/csv', () => { let user; beforeEach(async () => { - user = await generateUser(); - let populatedGroup = await createAndPopulateGroup({ members: 3, }); @@ -41,6 +39,7 @@ describe('GET /challenges/:challengeId/export/csv', () => { }); it('fails if challenge doesn\'t exists', async () => { + user = await generateUser(); await expect(user.get(`/challenges/${generateUUID()}/export/csv`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -49,6 +48,8 @@ describe('GET /challenges/:challengeId/export/csv', () => { }); it('fails if user doesn\'t have access to the challenge', async () => { + user = await generateUser(); + await expect(user.get(`/challenges/${challenge._id}/export/csv`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', From f0f67e1e885668f8471c4cd542999b886151a822 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 18:29:38 +0200 Subject: [PATCH 927/976] v3: fallbackto authWithHeaders if wuthWithSession or authWithUrl fails --- ...GET-challenges_challengeId_export_csv.test.js | 2 ++ test/helpers/api-integration/requester.js | 7 ------- website/server/middlewares/api-v3/auth.js | 16 ++++++++++++++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js index 58e19ee127..2b98af9579 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -40,6 +40,7 @@ describe('GET /challenges/:challengeId/export/csv', () => { it('fails if challenge doesn\'t exists', async () => { user = await generateUser(); + user.get('/user'); await expect(user.get(`/challenges/${generateUUID()}/export/csv`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -49,6 +50,7 @@ describe('GET /challenges/:challengeId/export/csv', () => { it('fails if user doesn\'t have access to the challenge', async () => { user = await generateUser(); + user.get('/user'); await expect(user.get(`/challenges/${challenge._id}/export/csv`)).to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index 93ad503e38..312942c194 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -64,13 +64,6 @@ function _requestMaker (user, method, additionalSets = {}) { return reject(parsedError); } - // if any cookies was sent, save it for the next request - if (response.headers['set-cookie']) { - additionalSets.cookie = response.headers['set-cookie'].map(cookieString => { - return cookieString.split(';')[0]; - }).join('; '); - } - resolve(_parseRes(response)); }); }); diff --git a/website/server/middlewares/api-v3/auth.js b/website/server/middlewares/api-v3/auth.js index 2fbe437068..0feb73a98d 100644 --- a/website/server/middlewares/api-v3/auth.js +++ b/website/server/middlewares/api-v3/auth.js @@ -41,7 +41,14 @@ export function authWithHeaders (optional = false) { export function authWithSession (req, res, next) { let userId = req.session.userId; - if (!userId) return next(new NotAuthorized(res.t('invalidCredentials'))); + // Always allow authentication with headers + if (!userId) { + if (!req.header('x-api-user') || !req.header('x-api-key')) { + return next(new NotAuthorized(res.t('invalidCredentials'))); + } else { + return authWithHeaders()(req, res, next); + } + } return User.findOne({ _id: userId, @@ -60,8 +67,13 @@ export function authWithUrl (req, res, next) { let userId = req.query._id; let apiToken = req.query.apiToken; + // Always allow authentication with headers if (!userId || !apiToken) { - throw new NotAuthorized(res.t('missingAuthParams')); + if (!req.header('x-api-user') || !req.header('x-api-key')) { + return next(new NotAuthorized(res.t('missingAuthParams'))); + } else { + return authWithHeaders()(req, res, next); + } } return User.findOne({ _id: userId, apiToken }).exec() From 9a32a01a3e49784b8f86a581f4d2a092d194c734 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 18 May 2016 20:49:58 +0100 Subject: [PATCH 928/976] Added force cache update when fetching new messages (#7360) --- website/client/js/controllers/chatCtrl.js | 17 ++++++++++++----- website/client/js/services/groupServices.js | 15 +++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/website/client/js/controllers/chatCtrl.js b/website/client/js/controllers/chatCtrl.js index 0e49b856bb..c7c29d6502 100644 --- a/website/client/js/controllers/chatCtrl.js +++ b/website/client/js/controllers/chatCtrl.js @@ -114,12 +114,19 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A }); }; + function handleGroupResponse (response) { + $scope.group = response; + if (!$scope.group._id) $scope.group = response.data.data; + }; + $scope.sync = function(group) { - //@TODO: We need to use chat service here - Groups.Group.get(group._id) - .then(function (response) { - $scope.group = response.data.data; - }) + if (group.name === Groups.TAVERN_NAME) { + Groups.tavern(true).then(handleGroupResponse); + } else if (group._id === User.user.party._id) { + Groups.party(true).then(handleGroupResponse); + } else { + Groups.Group.get(group._id).then(handleGroupResponse); + } Chat.markChatSeen(group._id); } diff --git a/website/client/js/services/groupServices.js b/website/client/js/services/groupServices.js index 10bf0e9e5a..c8b537144e 100644 --- a/website/client/js/services/groupServices.js +++ b/website/client/js/services/groupServices.js @@ -5,6 +5,7 @@ angular.module('habitrpg') function($location, $rootScope, $http, Analytics, ApiUrl, Challenges, $q, User, Members) { var data = {party: undefined, myGuilds: undefined, publicGuilds: undefined, tavern: undefined }; var groupApiURLPrefix = "/api/v3/groups"; + var TAVERN_NAME = 'HabitRPG'; var Group = {}; @@ -104,8 +105,8 @@ angular.module('habitrpg') //On page load, multiple controller request the party. //So, we cache the promise until the first result is returned var _cachedPartyPromise; - function party () { - if (_cachedPartyPromise) return _cachedPartyPromise.promise; + function party (forceUpdate) { + if (_cachedPartyPromise && !forceUpdate) return _cachedPartyPromise.promise; _cachedPartyPromise = $q.defer(); if (!User.user.party._id) { @@ -113,7 +114,7 @@ angular.module('habitrpg') _cachedPartyPromise.reject(data.party); } - if (!data.party) { + if (!data.party || forceUpdate) { Group.get('party') .then(function (response) { data.party = response.data.data; @@ -125,7 +126,8 @@ angular.module('habitrpg') }, function (response) { data.party = { type: 'party' }; _cachedPartyPromise.reject(data.party); - }).finally(function(){ + }) + .finally(function() { _cachePartyPromise = null; }); } else { @@ -172,10 +174,10 @@ angular.module('habitrpg') return deferred.promise; } - function tavern () { + function tavern (forceUpdate) { var deferred = $q.defer(); - if (!data.tavern) { + if (!data.tavern || forceUpdate) { Group.get('habitrpg') .then(function (response) { data.tavern = response.data.data; @@ -206,6 +208,7 @@ angular.module('habitrpg') } return { + TAVERN_NAME: TAVERN_NAME, party: party, publicGuilds: publicGuilds, myGuilds: myGuilds, From d6f52f0604a7c19ccdeb465b65bb27b34e503e49 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 22:11:42 +0200 Subject: [PATCH 929/976] v3: fetch whole user when booting from group tto avoid issues with pre save hook expecting all data --- website/server/controllers/api-v3/groups.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index b3a87047a3..196e3721d8 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -417,7 +417,7 @@ api.removeGroupMember = { if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself')); - let member = await User.findOne({_id: uuid}).select('party guilds invitations newMessages').exec(); + let member = await User.findOne({_id: uuid}).exec(); // We're removing the user from a guild or a party? is the user invited only? let isInGroup; From 65c739f7defb5fe958e3d319e6f53c9025ff5548 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 22:35:03 +0200 Subject: [PATCH 930/976] v3: misc fixes for payments --- common/script/public/config.js | 11 ++++++++++- .../GET-payments_amazon_subscribe_cancel.test.js | 2 +- .../payments/GET-payments_paypal_checkout.test.js | 4 ++-- .../GET-payments_paypal_checkout_success.test.js | 4 ++-- .../payments/GET-payments_paypal_subscribe.test.js | 4 ++-- .../GET-payments_paypal_subscribe_cancel.test.js | 2 +- .../GET-payments_paypal_subscribe_success.test.js | 4 ++-- .../GET-payments_stripe_subscribe_cancel.test.js | 2 +- website/client/js/controllers/settingsCtrl.js | 2 +- website/client/js/services/paymentServices.js | 2 +- 10 files changed, 23 insertions(+), 14 deletions(-) diff --git a/common/script/public/config.js b/common/script/public/config.js index 02146138a5..45fd77024f 100644 --- a/common/script/public/config.js +++ b/common/script/public/config.js @@ -3,6 +3,9 @@ angular.module('habitrpg') .config(['$httpProvider', function($httpProvider){ $httpProvider.interceptors.push(['$q', '$rootScope', function($q, $rootScope){ + var resyncNumber = 0; + var lastResync = 0; + return { response: function(response) { return response; @@ -41,7 +44,13 @@ angular.module('habitrpg') $rootScope.$broadcast('responseError', response.data.message); } - if ($rootScope.User && $rootScope.User.sync) $rootScope.User.sync(); + if ($rootScope.User && $rootScope.User.sync) { + if (resyncNumber < 100 && (Date.now() - lastResync) > 500) { // avoid thousands of requests when user is not found + $rootScope.User.sync(); + resyncNumber++; + lastResync = Date.now(); + } + } // Need to reject the prompse so the error is handled correctly if (response.status === 401) { diff --git a/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js index 007c58f4f7..37588d1f18 100644 --- a/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js +++ b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js @@ -15,7 +15,7 @@ describe('payments : amazon #subscribeCancel', () => { await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('missingAuthParams'), + message: t('missingSubscription'), }); }); }); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js b/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js index 25fc501000..7c692f31d1 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v3'; -describe('payments : paypal #checkout', () => { +xdescribe('payments : paypal #checkout', () => { let endpoint = '/paypal/checkout'; let user; @@ -15,7 +15,7 @@ describe('payments : paypal #checkout', () => { await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('missingAuthParams'), + message: t('missingSubscription'), }); }); }); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js b/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js index 346b8ce847..6de04c8848 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v3'; -describe('payments : paypal #checkoutSuccess', () => { +xdescribe('payments : paypal #checkoutSuccess', () => { let endpoint = '/paypal/checkout/success'; let user; @@ -15,7 +15,7 @@ describe('payments : paypal #checkoutSuccess', () => { await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('invalidCredentials'), + message: t('missingSubscription'), }); }); }); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js index c52309675a..54c540ee39 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v3'; -describe('payments : paypal #subscribe', () => { +xdescribe('payments : paypal #subscribe', () => { let endpoint = '/paypal/subscribe'; let user; @@ -15,7 +15,7 @@ describe('payments : paypal #subscribe', () => { await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('missingAuthParams'), + message: t('missingSubscription'), }); }); }); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js index 890bc864b6..1ba8b7af16 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js @@ -15,7 +15,7 @@ describe('payments : paypal #subscribeCancel', () => { await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('missingAuthParams'), + message: t('missingSubscription'), }); }); }); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js index 31bae03e40..1a38342e9c 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v3'; -describe('payments : paypal #subscribeSuccess', () => { +xdescribe('payments : paypal #subscribeSuccess', () => { let endpoint = '/paypal/subscribe/success'; let user; @@ -15,7 +15,7 @@ describe('payments : paypal #subscribeSuccess', () => { await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('invalidCredentials'), + message: t('missingSubscription'), }); }); }); diff --git a/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js index 68747eb535..6d7ac87d0f 100644 --- a/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js +++ b/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js @@ -15,7 +15,7 @@ describe('payments - stripe - #subscribeCancel', () => { await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('missingAuthParams'), + message: t('missingSubscription'), }); }); }); diff --git a/website/client/js/controllers/settingsCtrl.js b/website/client/js/controllers/settingsCtrl.js index d778fadc54..1bc873ee77 100644 --- a/website/client/js/controllers/settingsCtrl.js +++ b/website/client/js/controllers/settingsCtrl.js @@ -202,7 +202,7 @@ habitrpg.controller('SettingsCtrl', .success(function(res,code){ $scope._codes = {}; if (code!==200) return; - window.location.href = '/api/v2/coupons?limit='+codes.count+'&_id='+User.user._id+'&apiToken='+User.user.apiToken; + window.location.href = '/api/v2/coupons?limit='+codes.count+'&_id='+User.user._id+'&apiToken='+User.settings.auth.apiToken; }) } diff --git a/website/client/js/services/paymentServices.js b/website/client/js/services/paymentServices.js index fad384befc..6797da7bf9 100644 --- a/website/client/js/services/paymentServices.js +++ b/website/client/js/services/paymentServices.js @@ -262,7 +262,7 @@ function($rootScope, User, $http, Content) { paymentMethod = paymentMethod.toLowerCase(); } - window.location.href = '/' + paymentMethod + '/subscribe/cancel?_id=' + User.user._id + '&apiToken=' + User.user.apiToken; + window.location.href = '/' + paymentMethod + '/subscribe/cancel?_id=' + User.user._id + '&apiToken=' + User.settings.auth.apiToken; } Payments.encodeGift = function(uuid, gift){ From e98930cd4a18d0a646ed0a2d1bc2d760003d6013 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 23:27:49 +0200 Subject: [PATCH 931/976] v3: limit fields of challenge tasks that can be updated --- .../v3/integration/tasks/PUT-tasks_id.test.js | 91 ++++++++++++++++++- website/server/controllers/api-v3/tasks.js | 13 ++- website/server/models/task.js | 10 +- 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index c5bc5d050f..1325f8b392 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -1,5 +1,8 @@ import { generateUser, + generateGroup, + sleep, + generateChallenge, } from '../../../../helpers/api-integration/v3'; import { v4 as generateUUID } from 'uuid'; @@ -43,8 +46,8 @@ describe('PUT /tasks/:id', () => { expect(savedTask.createdAt).to.equal(task.createdAt); expect(savedTask.updatedAt).to.be.greaterThan(task.updatedAt); expect(savedTask.challenge).to.equal(task.challenge); - expect(savedTask.completed).to.equal(task.completed); - expect(savedTask.streak).to.equal(task.streak); + expect(savedTask.completed).to.eql(task.completed); + expect(savedTask.streak).to.equal(savedTask.streak); // it's an habit, dailies can change it expect(savedTask.dateCompleted).to.equal(task.dateCompleted); }); @@ -55,6 +58,90 @@ describe('PUT /tasks/:id', () => { expect(savedTask.notValid).to.be.undefined; }); + + it(`only allows setting streak, reminders, checklist, notes, attribute, tags + fields for challenge tasks owned by a user`, async () => { + let guild = await generateGroup(user); + let challenge = await generateChallenge(user, guild); + + let challengeTask = await user.post(`/tasks/challenge/${challenge._id}`, { + type: 'daily', + text: 'Daily in challenge', + reminders: [ + {time: new Date(), startDate: new Date()}, + ], + checklist: [ + {text: 123, completed: false}, + ], + }); + await sleep(2); + + await user.sync(); + + // Pick challenge task + let challengeUserTaskId = user.tasksOrder.dailys[user.tasksOrder.dailys.length - 1]; + + let challengeUserTask = await user.get(`/tasks/${challengeUserTaskId}`); + + let savedChallengeUserTask = await user.put(`/tasks/${challengeUserTaskId}`, { + _id: 123, + type: 'daily', + userId: 123, + history: [123], + createdAt: 'yesterday', + updatedAt: 'tomorrow', + challenge: 'no', + completed: true, + streak: 25, + priority: 1.5, + repeat: { + m: false, + }, + everyX: 15, + frequency: 'weekly', + text: 'new text', + dateCompleted: 'never', + reminders: [ + {time: new Date(), startDate: new Date()}, + {time: new Date(), startDate: new Date()}, + ], + checklist: [ + {text: 123, completed: false}, + {text: 456, completed: true}, + ], + notes: 'new notes', + attribute: 'per', + tags: [challengeUserTaskId], + }); + + // original task is not touched + let updatedChallengeTask = await user.get(`/tasks/${challengeTask._id}`); + expect(updatedChallengeTask).to.eql(challengeTask); + + // ignored + expect(savedChallengeUserTask._id).to.equal(challengeUserTask._id); + expect(savedChallengeUserTask.type).to.equal(challengeUserTask.type); + expect(savedChallengeUserTask.repeat.m).to.equal(true); + expect(savedChallengeUserTask.priority).to.equal(challengeUserTask.priority); + expect(savedChallengeUserTask.frequency).to.equal(challengeUserTask.frequency); + expect(savedChallengeUserTask.userId).to.equal(challengeUserTask.userId); + expect(savedChallengeUserTask.text).to.equal(challengeUserTask.text); + expect(savedChallengeUserTask.history).to.eql(challengeUserTask.history); + expect(savedChallengeUserTask.createdAt).to.equal(challengeUserTask.createdAt); + expect(savedChallengeUserTask.updatedAt).to.be.greaterThan(challengeUserTask.updatedAt); + expect(savedChallengeUserTask.challenge).to.eql(challengeUserTask.challenge); + expect(savedChallengeUserTask.completed).to.equal(challengeUserTask.completed); + expect(savedChallengeUserTask.dateCompleted).to.equal(challengeUserTask.dateCompleted); + expect(savedChallengeUserTask.priority).to.equal(challengeUserTask.priority); + + // changed + expect(savedChallengeUserTask.notes).to.equal('new notes'); + expect(savedChallengeUserTask.attribute).to.equal('per'); + expect(savedChallengeUserTask.tags).to.eql([challengeUserTaskId]); + expect(savedChallengeUserTask.streak).to.equal(25); + expect(savedChallengeUserTask.reminders.length).to.equal(2); + expect(savedChallengeUserTask.checklist.length).to.equal(2); + }); }); context('all types', () => { diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index aa7fc9b939..919c2fb541 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -307,7 +307,18 @@ api.updateTask = { // we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req); - _.assign(task, Tasks.Task.sanitize(updatedTaskObj)); + + + // Sanitize differently user tasks linked to a challenge + let sanitizedObj; + + if (!challenge && task.userId && task.challenge && task.challenge.id) { + sanitizedObj = Tasks.Task.sanitizeUserChallengeTask(updatedTaskObj); + } else { + sanitizedObj = Tasks.Task.sanitize(updatedTaskObj); + } + + _.assign(task, sanitizedObj); // console.log(task.modifiedPaths(), task.toObject().repeat === tep) // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() // see https://github.com/Automattic/mongoose/issues/2749 diff --git a/website/server/models/task.js b/website/server/models/task.js index 0116da2371..601a02c30b 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -57,7 +57,7 @@ export let TaskSchema = new Schema({ }, discriminatorOptions)); TaskSchema.plugin(baseModel, { - noSet: ['challenge', 'userId', 'completed', 'history', 'dateCompleted', 'completed'], + noSet: ['challenge', 'userId', 'completed', 'history', 'dateCompleted', '_legacyId'], sanitizeTransform (taskObj) { if (taskObj.type && taskObj.type !== 'reward') { // value should be settable directly only for rewards delete taskObj.value; @@ -69,6 +69,14 @@ TaskSchema.plugin(baseModel, { timestamps: true, }); +// Sanitize user tasks linked to a challenge +// See http://habitica.wikia.com/wiki/Challenges#Challenge_Participant.27s_Permissions for more info +TaskSchema.statics.sanitizeUserChallengeTask = function sanitizeUserChallengeTask (taskObj) { + let initialSanitization = this.sanitize(taskObj); + + return _.pick(initialSanitization, ['streak', 'checklist', 'attribute', 'reminders', 'tags', 'notes']); +}; + // Sanitize checklist objects (disallowing id) TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) { delete checklistObj.id; From 0a14d29ebbb4ed83aa055f822ff9a148981cedb2 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 18 May 2016 23:50:24 +0200 Subject: [PATCH 932/976] fix(tests): never connect to NODE_DB_URI for tests --- test/helpers/globals.helper.js | 4 ++-- website/server/libs/api-v3/setupMongoose.js | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index d57474bc4e..253d07e1d4 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -26,10 +26,10 @@ if (process.env.LOAD_SERVER === '0') { // when the server is in a different proc require('../../website/server/libs/api-v3/setupNconf')('./config.json'); // Use Q promises instead of mpromise in mongoose mongoose.Promise = Bluebird; - mongoose.connect(nconf.get('NODE_DB_URI')); + mongoose.connect(nconf.get('TEST_DB_URI')); } else { // When running tests and the server in the same process require('../../website/server/libs/api-v3/setupNconf')('./config.json.example'); - nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); + nconf.set('NODE_DB_URI', nconf.get('TEST_DB_URI')); nconf.set('NODE_ENV', 'test'); nconf.set('IS_TEST', true); // We require src/server and npt src/index because diff --git a/website/server/libs/api-v3/setupMongoose.js b/website/server/libs/api-v3/setupMongoose.js index 2bd21dca7b..69eac57b71 100644 --- a/website/server/libs/api-v3/setupMongoose.js +++ b/website/server/libs/api-v3/setupMongoose.js @@ -13,7 +13,9 @@ let mongooseOptions = !IS_PROD ? {} : { replset: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, server: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, }; -let db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, (err) => { + +const NODE_DB_URI = nconf.get('IS_TEST') ? nconf.get('TEST_DB_URI') : nconf.get('NODE_DB_URI'); +let db = mongoose.connect(NODE_DB_URI, mongooseOptions, (err) => { if (err) throw err; logger.info('Connected with Mongoose.'); }); From e8b53d6b22d0907cb670ba0df4df867fc20a8f3f Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 18 May 2016 23:24:32 +0100 Subject: [PATCH 933/976] Added new route for setting last cron and updated front end --- test/spec/controllers/settingsCtrlSpec.js | 3 ++- website/client/js/controllers/settingsCtrl.js | 6 ++--- website/client/js/services/userServices.js | 13 ++++++++++ website/server/controllers/api-v3/user.js | 24 +++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/test/spec/controllers/settingsCtrlSpec.js b/test/spec/controllers/settingsCtrlSpec.js index 704bc2a530..55bbbb3fac 100644 --- a/test/spec/controllers/settingsCtrlSpec.js +++ b/test/spec/controllers/settingsCtrlSpec.js @@ -17,6 +17,7 @@ describe('Settings Controller', function () { releasePets: sandbox.stub(), releaseMounts: sandbox.stub(), releaseBoth: sandbox.stub(), + setLastCron: sandbox.stub(), user: user }; @@ -97,8 +98,8 @@ describe('Settings Controller', function () { expect(User.set).to.be.calledOnce; expect(User.set).to.be.calledWith({ 'preferences.dayStart': 5, - 'lastCron': expectedTime }); + expect(User.setLastCron).to.be.calledWith(expectedTime); }); }); diff --git a/website/client/js/controllers/settingsCtrl.js b/website/client/js/controllers/settingsCtrl.js index 1bc873ee77..f152cbfc7f 100644 --- a/website/client/js/controllers/settingsCtrl.js +++ b/website/client/js/controllers/settingsCtrl.js @@ -77,10 +77,8 @@ habitrpg.controller('SettingsCtrl', }; $scope.saveDayStart = function() { - User.set({ - 'preferences.dayStart': Math.floor($scope.dayStart), - 'lastCron': +new Date - }); + User.set({'preferences.dayStart': Math.floor($scope.dayStart)}); + User.setLastCron(+new Date); }; $scope.language = window.env.language; diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 14362b9a1f..71964e4be1 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -310,6 +310,19 @@ angular.module('habitrpg') }); }, + setLastCron: function (date) { + $http({ + method: "POST", + url: 'api/v3/user/set-cron', + data: { + lastCron: date + } + }) + .then(function (response) { + Notification.text('Last cron updated. Remember to refresh'); + }); + }, + makeAdmin: function () { $http({ method: "POST", diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 3e6bd8b8da..f8cea60deb 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -1310,4 +1310,28 @@ api.userReset = { }, }; +/** +* @api {post} /api/v3/user/set-cron Sets lastCron for user +* @apiVersion 3.0.0 +* @apiName UserSetCron +* @apiGroup User +* +* @apiSuccess {Object} data An empty Object +*/ +api.userSetCron = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/set-cron', + async handler (req, res) { + let user = res.locals.user; + let cron = req.body.lastCron; + + user.lastCron = cron; + + await user.save(); + + res.respond(200, {}); + }, +}; + module.exports = api; From c8f55302cab708f4dbd20ee5a6e38843ba84ee56 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 May 2016 20:44:44 +0200 Subject: [PATCH 934/976] v3: fix iap url --- website/server/controllers/top-level/payments/iap.js | 2 +- website/server/libs/api-v3/baseModel.js | 3 +++ website/server/models/user.js | 4 +--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/website/server/controllers/top-level/payments/iap.js b/website/server/controllers/top-level/payments/iap.js index 60e50d6d39..cb1b612afb 100644 --- a/website/server/controllers/top-level/payments/iap.js +++ b/website/server/controllers/top-level/payments/iap.js @@ -95,7 +95,7 @@ api.iapAndroidVerify = { **/ api.iapiOSVerify = { method: 'POST', - url: '/iap/android/verify', + url: '/iap/ios/verify', middlewares: [authWithHeaders()], async handler (req, res) { let iapBody = req.body; diff --git a/website/server/libs/api-v3/baseModel.js b/website/server/libs/api-v3/baseModel.js index c736d613ea..009b735fa6 100644 --- a/website/server/libs/api-v3/baseModel.js +++ b/website/server/libs/api-v3/baseModel.js @@ -60,6 +60,9 @@ module.exports = function baseModel (schema, options = {}) { objectPath.del(plainObj, fieldPath); }); + // Always return `id` + if (!plainObj.id && plainObj._id) plainObj.id = plainObj._id; + // Allow an additional toJSON transform function to be used return options.toJSONTransform ? options.toJSONTransform(plainObj, doc) : plainObj; }; diff --git a/website/server/models/user.js b/website/server/models/user.js index eda6760d9f..65480bf4ce 100644 --- a/website/server/models/user.js +++ b/website/server/models/user.js @@ -529,9 +529,7 @@ schema.plugin(baseModel, { noSet: [], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (plainObj, originalDoc) { - plainObj.id = plainObj._id; - - // plainObj.filters = {}; TODO Not saved, remove? + // plainObj.filters = {}; // TODO Not saved, remove? plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs return plainObj; From c6da283b545bf349108c8eb4ffae468ec14238bb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 May 2016 21:18:43 +0200 Subject: [PATCH 935/976] v3: fix build and ios IAP --- .../v3/integration/challenges/GET-challenges_user.test.js | 5 +++++ website/server/controllers/top-level/payments/iap.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js index d062352ba3..ecb9f55383 100644 --- a/test/api/v3/integration/challenges/GET-challenges_user.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js @@ -40,6 +40,7 @@ describe('GET challenges/user', () => { }); expect(foundChallenge.group).to.eql({ _id: publicGuild._id, + id: publicGuild._id, type: publicGuild.type, privacy: publicGuild.privacy, name: publicGuild.name, @@ -58,6 +59,7 @@ describe('GET challenges/user', () => { }); expect(foundChallenge1.group).to.eql({ _id: publicGuild._id, + id: publicGuild._id, type: publicGuild.type, privacy: publicGuild.privacy, name: publicGuild.name, @@ -71,6 +73,7 @@ describe('GET challenges/user', () => { }); expect(foundChallenge2.group).to.eql({ _id: publicGuild._id, + id: publicGuild._id, type: publicGuild.type, privacy: publicGuild.privacy, name: publicGuild.name, @@ -89,6 +92,7 @@ describe('GET challenges/user', () => { }); expect(foundChallenge1.group).to.eql({ _id: publicGuild._id, + id: publicGuild._id, type: publicGuild.type, privacy: publicGuild.privacy, name: publicGuild.name, @@ -102,6 +106,7 @@ describe('GET challenges/user', () => { }); expect(foundChallenge2.group).to.eql({ _id: publicGuild._id, + id: publicGuild._id, type: publicGuild.type, privacy: publicGuild.privacy, name: publicGuild.name, diff --git a/website/server/controllers/top-level/payments/iap.js b/website/server/controllers/top-level/payments/iap.js index cb1b612afb..e99590fa71 100644 --- a/website/server/controllers/top-level/payments/iap.js +++ b/website/server/controllers/top-level/payments/iap.js @@ -130,7 +130,7 @@ api.iapiOSVerify = { if (purchaseDataList.length > 0) { let correctReceipt = true; - for (let index of purchaseDataList) { + for (let index in purchaseDataList) { switch (purchaseDataList[index].productId) { case 'com.habitrpg.ios.Habitica.4gems': payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); From 5b7a56d28dd391311c513118d5240fd497bd9c86 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 19 May 2016 21:14:35 +0100 Subject: [PATCH 936/976] Changed route to user set custom day start --- .../POST-user-set-custom-day-start.test.js | 19 +++++++++++++++++++ website/client/js/controllers/settingsCtrl.js | 3 +-- website/client/js/services/userServices.js | 8 ++++---- website/server/controllers/api-v3/user.js | 12 ++++++------ 4 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 test/api/v3/integration/user/POST-user-set-custom-day-start.test.js diff --git a/test/api/v3/integration/user/POST-user-set-custom-day-start.test.js b/test/api/v3/integration/user/POST-user-set-custom-day-start.test.js new file mode 100644 index 0000000000..1dd88bb342 --- /dev/null +++ b/test/api/v3/integration/user/POST-user-set-custom-day-start.test.js @@ -0,0 +1,19 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +let user; +let endpoint = '/user/set-custom-day-start'; + +describe('POST /user/set-custom-day-start', () => { + beforeEach(async () => { + user = await generateUser(); + }); + + it('update user.preferences.dayStart', async () => { + expect(user.preferences.dayStart).to.eql(0); + await user.post(endpoint, { dayStart: 1 }); + await user.sync(); + expect(user.preferences.dayStart).to.eql(1); + }); +}); diff --git a/website/client/js/controllers/settingsCtrl.js b/website/client/js/controllers/settingsCtrl.js index f152cbfc7f..0dbb141c7d 100644 --- a/website/client/js/controllers/settingsCtrl.js +++ b/website/client/js/controllers/settingsCtrl.js @@ -77,8 +77,7 @@ habitrpg.controller('SettingsCtrl', }; $scope.saveDayStart = function() { - User.set({'preferences.dayStart': Math.floor($scope.dayStart)}); - User.setLastCron(+new Date); + User.setCustomDayStart(Math.floor($scope.dayStart)); }; $scope.language = window.env.language; diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 71964e4be1..4637c43c22 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -310,16 +310,16 @@ angular.module('habitrpg') }); }, - setLastCron: function (date) { + setCustomDayStart: function (dayStart) { $http({ method: "POST", - url: 'api/v3/user/set-cron', + url: 'api/v3/user/set-custom-day-start', data: { - lastCron: date + dayStart: dayStart } }) .then(function (response) { - Notification.text('Last cron updated. Remember to refresh'); + Notification.text('Day start updated. Remember to refresh'); }); }, diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index f8cea60deb..ea68c48d01 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -1311,22 +1311,22 @@ api.userReset = { }; /** -* @api {post} /api/v3/user/set-cron Sets lastCron for user +* @api {post} /api/v3/user/set-custom-day-start Sets preferences.dayStart for user * @apiVersion 3.0.0 -* @apiName UserSetCron +* @apiName UserSetCustomDayStart * @apiGroup User * * @apiSuccess {Object} data An empty Object */ -api.userSetCron = { +api.userSetCustomDayStart = { method: 'POST', middlewares: [authWithHeaders()], - url: '/user/set-cron', + url: '/user/set-custom-day-start', async handler (req, res) { let user = res.locals.user; - let cron = req.body.lastCron; + let dayStart = req.body.dayStart; - user.lastCron = cron; + user.preferences.dayStart = dayStart; await user.save(); From 5ba33bc5a1fd30ebdc794b60a57b79f56f3515b7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 May 2016 22:43:11 +0200 Subject: [PATCH 937/976] v3: iap accessible under /api/v3, fixes to spells and groups invitations --- website/server/controllers/api-v3/groups.js | 3 ++- website/server/controllers/api-v3/iap.js | 4 ++++ website/server/controllers/api-v3/user.js | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 website/server/controllers/api-v3/iap.js diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 196e3721d8..07e9b5be54 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -513,6 +513,7 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { } let groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; + let groupTemplate = group.type === 'guild' ? 'guild' : 'party'; if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) { let emailVars = [ {name: 'INVITER', content: inviter.profile.name}, @@ -530,7 +531,7 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { ); } - sendTxnEmail(userToInvite, `invited-${groupLabel}`, emailVars); + sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars); } sendPushNotification( diff --git a/website/server/controllers/api-v3/iap.js b/website/server/controllers/api-v3/iap.js new file mode 100644 index 0000000000..daaef0cef8 --- /dev/null +++ b/website/server/controllers/api-v3/iap.js @@ -0,0 +1,4 @@ +// NOTE: this file is only used because the mobile apps expect IAP routes +// to be found at /api/v3/iap instead of /iap. + +module.exports = require('../top-level/payments/iap'); \ No newline at end of file diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 3e6bd8b8da..058e5297c3 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -378,7 +378,10 @@ api.castSpell = { spell.cast(user, tasks, req); - let toSave = tasks.filter(t => t.isModified()); + let toSave = tasks + .filter(t => t.isModified()) + .map(t => t.save()); + toSave.unshift(user.save()); let saved = await Bluebird.all(toSave); From bf776e38c760a49dcc84721a790efa0981dc3fb4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 19 May 2016 22:59:17 +0200 Subject: [PATCH 938/976] v3: correctly use v3 routes in client --- website/client/js/controllers/hallCtrl.js | 20 ++++++++++++------- website/client/js/controllers/settingsCtrl.js | 8 ++++---- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/website/client/js/controllers/hallCtrl.js b/website/client/js/controllers/hallCtrl.js index fe385a316c..3aad20a782 100644 --- a/website/client/js/controllers/hallCtrl.js +++ b/website/client/js/controllers/hallCtrl.js @@ -2,10 +2,12 @@ habitrpg.controller("HallHeroesCtrl", ['$scope', '$rootScope', 'User', 'Notification', 'ApiUrl', '$resource', function($scope, $rootScope, User, Notification, ApiUrl, $resource) { - var Hero = $resource(ApiUrl.get() + '/api/v2/hall/heroes/:uid', {uid:'@_id'}); + var Hero = $resource(ApiUrl.get() + '/api/v3/hall/heroes/:uid', {uid:'@_id'}); $scope.hero = undefined; $scope.loadHero = function(uuid){ - $scope.hero = Hero.get({uid:uuid}); + Hero.query({uid:uuid}, function (heroData) { + $scope.hero = heroData.data; + }); } $scope.saveHero = function(hero) { $scope.hero.contributor.admin = ($scope.hero.contributor.level > 7) ? true : false; @@ -13,10 +15,14 @@ habitrpg.controller("HallHeroesCtrl", ['$scope', '$rootScope', 'User', 'Notifica Notification.text("User updated"); $scope.hero = undefined; $scope._heroID = undefined; - $scope.heroes = Hero.query(); + Hero.query({}, function (heroesData) { + $scope.heroes = heroesData.data; + }); }) } - $scope.heroes = Hero.query(); + Hero.query({}, function (heroesData) { + $scope.heroes = heroesData.data; + }); $scope.populateContributorInput = function(id) { $scope._heroID = id; @@ -27,14 +33,14 @@ habitrpg.controller("HallHeroesCtrl", ['$scope', '$rootScope', 'User', 'Notifica habitrpg.controller("HallPatronsCtrl", ['$scope', '$rootScope', 'User', 'Notification', 'ApiUrl', '$resource', function($scope, $rootScope, User, Notification, ApiUrl, $resource) { - var Patron = $resource(ApiUrl.get() + '/api/v2/hall/patrons/:uid', {uid:'@_id'}); + var Patron = $resource(ApiUrl.get() + '/api/v3/hall/patrons/:uid', {uid:'@_id'}); var page = 0; $scope.patrons = []; $scope.loadMore = function(){ - Patron.query({page: page++}, function(patrons){ - $scope.patrons = $scope.patrons.concat(patrons); + Patron.query({page: page++}, function(patronsData){ + $scope.patrons = $scope.patrons.concat(patronsData.data); }) } $scope.loadMore(); diff --git a/website/client/js/controllers/settingsCtrl.js b/website/client/js/controllers/settingsCtrl.js index 1bc873ee77..0eb217a9d4 100644 --- a/website/client/js/controllers/settingsCtrl.js +++ b/website/client/js/controllers/settingsCtrl.js @@ -146,7 +146,7 @@ habitrpg.controller('SettingsCtrl', } $scope.changeUser = function(attr, updates){ - $http.post(ApiUrl.get() + '/api/v2/user/change-'+attr, updates) + $http.put(ApiUrl.get() + '/api/v3/user/auth/update-'+attr, updates) .success(function(){ alert(window.env.t(attr+'Success')); _.each(updates, function(v,k){updates[k]=null;}); @@ -181,7 +181,7 @@ habitrpg.controller('SettingsCtrl', } $scope['delete'] = function(){ - $http['delete'](ApiUrl.get() + '/api/v2/user') + $http['delete'](ApiUrl.get() + '/api/v3/user') .success(function(res, code){ if (res.err) return alert(res.err); localStorage.clear(); @@ -190,7 +190,7 @@ habitrpg.controller('SettingsCtrl', } $scope.enterCoupon = function(code) { - $http.post(ApiUrl.get() + '/api/v2/user/coupon/' + code).success(function(res,code){ + $http.post(ApiUrl.get() + '/api/v3/coupons/enter/' + code).success(function(res,code){ if (code!==200) return; User.sync(); Notification.text(env.t('promoCodeApplied')); @@ -259,7 +259,7 @@ habitrpg.controller('SettingsCtrl', } $scope.applyCoupon = function(coupon){ - $http.get(ApiUrl.get() + '/api/v2/coupons/valid-discount/'+coupon) + $http.get(ApiUrl.get() + '/api/v3/coupons/validate/'+coupon) .success(function(){ Notification.text("Coupon applied!"); var subs = Content.subscriptionBlocks; From af37b30363cfea05a2167d179ce059979dbb6e9a Mon Sep 17 00:00:00 2001 From: Alys Date: Thu, 19 May 2016 17:08:15 -0400 Subject: [PATCH 939/976] remove XP, GP when unticking a Daily with a completed checklist - fixes https://github.com/HabitRPG/habitrpg/issues/7246 --- common/script/ops/scoreTask.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/script/ops/scoreTask.js b/common/script/ops/scoreTask.js index f052b954e0..c366b84624 100644 --- a/common/script/ops/scoreTask.js +++ b/common/script/ops/scoreTask.js @@ -207,7 +207,7 @@ module.exports = function scoreTask (options = {}, req = {}) { if (!user.stats.buffs.streaks) task.streak = 0; } else { delta += _changeTaskValue(user, task, direction, times, cron); - if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly + if (direction === 'down') delta = _calculateDelta(task, direction, cron); // recalculate delta for unchecking so the gp and exp come out correctly _addPoints(user, task, stats, direction, delta); // obviously for delta>0, but also a trick to undo accidental checkboxes _gainMP(user, _.max([1, 0.01 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); @@ -236,7 +236,7 @@ module.exports = function scoreTask (options = {}, req = {}) { } delta += _changeTaskValue(user, task, direction, times, cron); - if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly + if (direction === 'down') delta = _calculateDelta(task, direction, cron); // recalculate delta for unchecking so the gp and exp come out correctly _addPoints(user, task, stats, direction, delta); // MP++ per checklist item in ToDo, bonus per CLI From b9d55e35620ba6b7e56493d8cb3c15e69cb4bb1c Mon Sep 17 00:00:00 2001 From: Alys Date: Thu, 19 May 2016 23:54:22 +0000 Subject: [PATCH 940/976] use natural language for error message about skills on challenge tasks (#7336), fix other gramatical error --- common/locales/en/challenge.json | 2 +- common/locales/en/spells.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/locales/en/challenge.json b/common/locales/en/challenge.json index cffe8c4a38..7504e2ad9c 100644 --- a/common/locales/en/challenge.json +++ b/common/locales/en/challenge.json @@ -74,7 +74,7 @@ "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", "onlyLeaderUpdateChal": "Only the challenge leader can update it.", "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", - "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", + "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching challenge tasks.", "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", "userAlreadyInChallenge": "User is already participating in this challenge.", diff --git a/common/locales/en/spells.json b/common/locales/en/spells.json index 37dc7eaad4..042e4adf4b 100644 --- a/common/locales/en/spells.json +++ b/common/locales/en/spells.json @@ -70,7 +70,7 @@ "spellNotFound": "Skill \"<%= spellId %>\" not found.", "partyNotFound": "Party not found", "targetIdUUID": "\"targetId\" must be a valid User ID.", - "challengeTasksNoCast": "Casting a skill on challenge tasks is not supported.", + "challengeTasksNoCast": "Casting a skill on challenge tasks is not allowed.", "spellNotOwned": "You don't own this skill.", "spellLevelTooHigh": "You must be level <%= level %> to use this skill." } From 5e30aeb24c7f3d32e337059e09afa911e79e8a2b Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 20 May 2016 13:17:54 +0100 Subject: [PATCH 941/976] Updated ui when user rejects a guild invite (#7368) --- website/client/js/controllers/guildsCtrl.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/website/client/js/controllers/guildsCtrl.js b/website/client/js/controllers/guildsCtrl.js index 0855e41e04..821b9aebba 100644 --- a/website/client/js/controllers/guildsCtrl.js +++ b/website/client/js/controllers/guildsCtrl.js @@ -69,8 +69,10 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$r }); } - $scope.reject = function(guild) { - Groups.Group.rejectInvite(guild.id); + $scope.reject = function(invitationToReject) { + var index = _.findIndex(User.user.invitations.guilds, function(invite) { return invite.id === invitationToReject.id; }); + User.user.invitations.guilds = User.user.invitations.guilds.splice(0, index); + Groups.Group.rejectInvite(invitationToReject.id); } $scope.leave = function(keep) { From 1fb77c0e92933e5bca396f8a1b15f2985539aa33 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 20 May 2016 08:03:14 -0500 Subject: [PATCH 942/976] feat: complete custom day start route Closes #7363 --- common/locales/en/settings.json | 1 + .../POST-user-set-custom-day-start.test.js | 19 -------- .../user/POST-user_custom-day-start.test.js | 47 +++++++++++++++++++ test/spec/controllers/settingsCtrlSpec.js | 14 ++---- website/client/js/services/userServices.js | 5 +- website/server/controllers/api-v3/user.js | 13 +++-- 6 files changed, 62 insertions(+), 37 deletions(-) delete mode 100644 test/api/v3/integration/user/POST-user-set-custom-day-start.test.js create mode 100644 test/api/v3/integration/user/POST-user_custom-day-start.test.js diff --git a/common/locales/en/settings.json b/common/locales/en/settings.json index daffc88b5a..727c04774c 100644 --- a/common/locales/en/settings.json +++ b/common/locales/en/settings.json @@ -47,6 +47,7 @@ "customDayStart": "Custom Day Start", "changeCustomDayStart": "Change Custom Day Start?", "sureChangeCustomDayStart": "Are you sure you want to change your custom day start?", + "customDayStartHasChanged": "Your custom day start has changed.", "nextCron": "Your Dailies will next reset the first time you use Habitica after <%= time %>. Make sure you have completed your Dailies before this time!", "customDayStartInfo1": "Habitica defaults to check and reset your Dailies at midnight in your own time zone each day. You can customize that time here.", "misc": "Misc", diff --git a/test/api/v3/integration/user/POST-user-set-custom-day-start.test.js b/test/api/v3/integration/user/POST-user-set-custom-day-start.test.js deleted file mode 100644 index 1dd88bb342..0000000000 --- a/test/api/v3/integration/user/POST-user-set-custom-day-start.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -let user; -let endpoint = '/user/set-custom-day-start'; - -describe('POST /user/set-custom-day-start', () => { - beforeEach(async () => { - user = await generateUser(); - }); - - it('update user.preferences.dayStart', async () => { - expect(user.preferences.dayStart).to.eql(0); - await user.post(endpoint, { dayStart: 1 }); - await user.sync(); - expect(user.preferences.dayStart).to.eql(1); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_custom-day-start.test.js b/test/api/v3/integration/user/POST-user_custom-day-start.test.js new file mode 100644 index 0000000000..868b9ae91d --- /dev/null +++ b/test/api/v3/integration/user/POST-user_custom-day-start.test.js @@ -0,0 +1,47 @@ +import moment from 'moment'; +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +let user; +let endpoint = '/user/custom-day-start'; + +describe('POST /user/custom-day-start', () => { + beforeEach(async () => { + user = await generateUser(); + }); + + it('updates user.preferences.dayStart', async () => { + expect(user.preferences.dayStart).to.eql(0); + + await user.post(endpoint, { dayStart: 1 }); + await user.sync(); + + expect(user.preferences.dayStart).to.eql(1); + }); + + it('sets lastCron to the current time to prevent an unexpected cron', async () => { + let oldCron = moment().subtract(7, 'hours'); + + await user.update({lastCron: oldCron}); + await user.post(endpoint, { dayStart: 1 }); + await user.sync(); + + expect(user.lastCron.valueOf()).to.be.gt(oldCron.valueOf()); + }); + + it('returns a confirmation message', async () => { + let {message} = await user.post(endpoint, { dayStart: 1 }); + + expect(message).to.eql(t('customDayStartHasChanged')); + }); + + it('errors if invalid value is passed', async () => { + await expect(user.post(endpoint, { dayStart: 'foo' })) + .to.eventually.be.rejected; + + await expect(user.post(endpoint, { dayStart: 24})) + .to.eventually.be.rejected; + }); +}); diff --git a/test/spec/controllers/settingsCtrlSpec.js b/test/spec/controllers/settingsCtrlSpec.js index 55bbbb3fac..ef960ae204 100644 --- a/test/spec/controllers/settingsCtrlSpec.js +++ b/test/spec/controllers/settingsCtrlSpec.js @@ -17,7 +17,7 @@ describe('Settings Controller', function () { releasePets: sandbox.stub(), releaseMounts: sandbox.stub(), releaseBoth: sandbox.stub(), - setLastCron: sandbox.stub(), + setCustomDayStart: sandbox.stub(), user: user }; @@ -87,19 +87,11 @@ describe('Settings Controller', function () { }); describe('#saveDayStart', function () { - - it('updates user\'s custom day start and last cron', function () { - var fakeCurrentTime = new Date(2013, 3, 1, 8, 12).getTime(); - var expectedTime = fakeCurrentTime; - sandbox.useFakeTimers(fakeCurrentTime); + it('updates user\'s custom day start', function () { scope.dayStart = 5; scope.saveDayStart(); - expect(User.set).to.be.calledOnce; - expect(User.set).to.be.calledWith({ - 'preferences.dayStart': 5, - }); - expect(User.setLastCron).to.be.calledWith(expectedTime); + expect(User.setCustomDayStart).to.be.calledWith(5); }); }); diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 4637c43c22..78e01a7476 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -313,13 +313,14 @@ angular.module('habitrpg') setCustomDayStart: function (dayStart) { $http({ method: "POST", - url: 'api/v3/user/set-custom-day-start', + url: 'api/v3/user/custom-day-start', data: { dayStart: dayStart } }) .then(function (response) { - Notification.text('Day start updated. Remember to refresh'); + Notification.text(response.data.data.message); + sync(); }); }, diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index ea68c48d01..a943ca02c6 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -1311,26 +1311,29 @@ api.userReset = { }; /** -* @api {post} /api/v3/user/set-custom-day-start Sets preferences.dayStart for user +* @api {post} /api/v3/user/custom-day-start Sets preferences.dayStart for user * @apiVersion 3.0.0 -* @apiName UserSetCustomDayStart +* @apiName setCustomDayStart * @apiGroup User * * @apiSuccess {Object} data An empty Object */ -api.userSetCustomDayStart = { +api.setCustomDayStart = { method: 'POST', middlewares: [authWithHeaders()], - url: '/user/set-custom-day-start', + url: '/user/custom-day-start', async handler (req, res) { let user = res.locals.user; let dayStart = req.body.dayStart; user.preferences.dayStart = dayStart; + user.lastCron = new Date(); await user.save(); - res.respond(200, {}); + res.respond(200, { + message: res.t('customDayStartHasChanged'), + }); }, }; From 42bc4bdd3d7abe8aad7b9b022c9572b1a964bc89 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 19 May 2016 09:53:29 -0500 Subject: [PATCH 943/976] fix: Correct spelling of healAll skill fix: Correct sprite name of healAll skill --- .../skills/{shop_heallAll.png => shop_healAll.png} | Bin common/script/content/spells.js | 2 +- website/server/controllers/api-v2/user.js | 4 ++++ 3 files changed, 5 insertions(+), 1 deletion(-) rename common/img/sprites/spritesmith/skills/{shop_heallAll.png => shop_healAll.png} (100%) diff --git a/common/img/sprites/spritesmith/skills/shop_heallAll.png b/common/img/sprites/spritesmith/skills/shop_healAll.png similarity index 100% rename from common/img/sprites/spritesmith/skills/shop_heallAll.png rename to common/img/sprites/spritesmith/skills/shop_healAll.png diff --git a/common/script/content/spells.js b/common/script/content/spells.js index a4632dbea4..9309210af7 100644 --- a/common/script/content/spells.js +++ b/common/script/content/spells.js @@ -239,7 +239,7 @@ spells.healer = { }); }, }, - heallAll: { // Blessing + healAll: { // Blessing text: t('spellHealerHealAllText'), mana: 25, lvl: 14, diff --git a/website/server/controllers/api-v2/user.js b/website/server/controllers/api-v2/user.js index 323155a5bd..b64f8e755a 100644 --- a/website/server/controllers/api-v2/user.js +++ b/website/server/controllers/api-v2/user.js @@ -635,6 +635,10 @@ api.cast = async function(req, res, next) { let spellId = req.params.spell; let targetId = req.query.targetId; + if (spellId === 'heallAll') { + spellId = 'healAll'; + } + let klass = shared.content.spells.special[spellId] ? 'special' : user.stats.class; let spell = shared.content.spells[klass][spellId]; From 5bd436f3c8d89f5d3086b4b9cc1dbc139727afcd Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 19 May 2016 09:53:46 -0500 Subject: [PATCH 944/976] fix: Change all instances of spookDust -> spookySparkles --- common/dist/sprites/spritesmith-main-0.css | 4 +- common/dist/sprites/spritesmith-main-5.css | 96 +++++++++--------- common/dist/sprites/spritesmith-main-5.png | Bin 261273 -> 261236 bytes common/dist/sprites/spritesmith-main-6.css | 4 +- ...ust.png => achievement-spookySparkles.png} | Bin ...x.png => achievement-spookySparkles2x.png} | Bin .../misc/{spookman.png => ghost.png} | Bin ...g => inventory_special_spookySparkles.png} | Bin ..._spookDust.png => shop_spookySparkles.png} | Bin common/locales/en/limited.json | 2 +- common/locales/en/spells.json | 4 +- common/script/content/spells.js | 24 ++--- migrations/api_v3/users.js | 10 ++ website/server/controllers/api-v2/user.js | 2 + website/server/models/user.js | 6 +- website/views/options/inventory/drops.jade | 2 +- website/views/shared/avatar/appearance.jade | 4 +- website/views/shared/new-stuff.jade | 18 ++-- .../views/shared/profiles/achievements.jade | 6 +- .../views/shared/tasks/task_view/skills.jade | 2 +- 20 files changed, 98 insertions(+), 86 deletions(-) rename common/img/sprites/spritesmith/achievements/{achievement-spookDust.png => achievement-spookySparkles.png} (100%) rename common/img/sprites/spritesmith/achievements/{achievement-spookDust2x.png => achievement-spookySparkles2x.png} (100%) rename common/img/sprites/spritesmith/misc/{spookman.png => ghost.png} (100%) rename common/img/sprites/spritesmith/misc/{inventory_special_spookDust.png => inventory_special_spookySparkles.png} (100%) rename common/img/sprites/spritesmith/shop/{shop_spookDust.png => shop_spookySparkles.png} (100%) diff --git a/common/dist/sprites/spritesmith-main-0.css b/common/dist/sprites/spritesmith-main-0.css index 33de4f76f8..28970a6d42 100644 --- a/common/dist/sprites/spritesmith-main-0.css +++ b/common/dist/sprites/spritesmith-main-0.css @@ -346,13 +346,13 @@ width: 48px; height: 52px; } -.achievement-spookDust { +.achievement-spookySparkles { background-image: url(spritesmith-main-0.png); background-position: -25px -1601px; width: 24px; height: 26px; } -.achievement-spookDust2x { +.achievement-spookySparkles2x { background-image: url(spritesmith-main-0.png); background-position: -980px -1548px; width: 48px; diff --git a/common/dist/sprites/spritesmith-main-5.css b/common/dist/sprites/spritesmith-main-5.css index e7245d2efd..50f8d8a69a 100644 --- a/common/dist/sprites/spritesmith-main-5.css +++ b/common/dist/sprites/spritesmith-main-5.css @@ -270,7 +270,7 @@ } .shop_armor_special_candycane { background-image: url(spritesmith-main-5.png); - background-position: -1674px -410px; + background-position: -1674px -451px; width: 40px; height: 40px; } @@ -282,19 +282,19 @@ } .shop_armor_special_snowflake { background-image: url(spritesmith-main-5.png); - background-position: -1674px -615px; + background-position: -1674px -656px; width: 40px; height: 40px; } .shop_armor_special_winter2015Healer { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1189px; + background-position: -1674px -1230px; width: 40px; height: 40px; } .shop_armor_special_winter2015Mage { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1230px; + background-position: -1674px -1271px; width: 40px; height: 40px; } @@ -456,13 +456,13 @@ } .shop_shield_special_winter2015Warrior { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1558px; + background-position: -1674px -1517px; width: 40px; height: 40px; } .shop_shield_special_winter2016Healer { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1476px; + background-position: -1599px -1599px; width: 40px; height: 40px; } @@ -1098,103 +1098,103 @@ } .shop_head_rogue_3 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -984px; + background-position: -1674px -1025px; width: 40px; height: 40px; } .shop_head_rogue_4 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -943px; + background-position: -1674px -984px; width: 40px; height: 40px; } .shop_head_rogue_5 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -902px; + background-position: -1674px -943px; width: 40px; height: 40px; } .shop_head_special_0 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -861px; + background-position: -1674px -902px; width: 40px; height: 40px; } .shop_head_special_1 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -820px; + background-position: -1674px -861px; width: 40px; height: 40px; } .shop_head_special_2 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -738px; + background-position: -1674px -779px; width: 40px; height: 40px; } .shop_head_special_fireCoralCirclet { background-image: url(spritesmith-main-5.png); - background-position: -1674px -697px; + background-position: -1674px -738px; width: 40px; height: 40px; } .shop_head_warrior_1 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -656px; + background-position: -1674px -697px; width: 40px; height: 40px; } .shop_head_warrior_2 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -574px; + background-position: -1674px -615px; width: 40px; height: 40px; } .shop_head_warrior_3 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -533px; + background-position: -1674px -574px; width: 40px; height: 40px; } .shop_head_warrior_4 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -492px; + background-position: -1674px -533px; width: 40px; height: 40px; } .shop_head_warrior_5 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -451px; + background-position: -1674px -492px; width: 40px; height: 40px; } .shop_head_wizard_1 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -369px; + background-position: -1674px -410px; width: 40px; height: 40px; } .shop_head_wizard_2 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -328px; + background-position: -1674px -369px; width: 40px; height: 40px; } .shop_head_wizard_3 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -287px; + background-position: -1674px -328px; width: 40px; height: 40px; } .shop_head_wizard_4 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -246px; + background-position: -1674px -287px; width: 40px; height: 40px; } .shop_head_wizard_5 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -205px; + background-position: -1674px -246px; width: 40px; height: 40px; } @@ -1296,7 +1296,7 @@ } .shop_headAccessory_special_bearEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px -164px; + background-position: -1674px -205px; width: 40px; height: 40px; } @@ -1314,31 +1314,31 @@ } .shop_headAccessory_special_lionEars { background-image: url(spritesmith-main-5.png); - background-position: -1599px -1599px; + background-position: -1674px 0px; width: 40px; height: 40px; } .shop_headAccessory_special_pandaEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px 0px; + background-position: -1674px -41px; width: 40px; height: 40px; } .shop_headAccessory_special_pigEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px -41px; + background-position: -1674px -82px; width: 40px; height: 40px; } .shop_headAccessory_special_tigerEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px -82px; + background-position: -1674px -123px; width: 40px; height: 40px; } .shop_headAccessory_special_wolfEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px -123px; + background-position: -1674px -164px; width: 40px; height: 40px; } @@ -1464,55 +1464,55 @@ } .shop_shield_healer_1 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1025px; + background-position: -1674px -1066px; width: 40px; height: 40px; } .shop_shield_healer_2 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1066px; + background-position: -1674px -1107px; width: 40px; height: 40px; } .shop_shield_healer_3 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1107px; + background-position: -1674px -1148px; width: 40px; height: 40px; } .shop_shield_healer_4 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1148px; + background-position: -1674px -1189px; width: 40px; height: 40px; } .shop_shield_healer_5 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1271px; + background-position: -1674px -1312px; width: 40px; height: 40px; } .shop_shield_rogue_0 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1312px; + background-position: -1674px -1353px; width: 40px; height: 40px; } .shop_shield_rogue_1 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1353px; + background-position: -1674px -1394px; width: 40px; height: 40px; } .shop_shield_rogue_2 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1394px; + background-position: -1674px -1435px; width: 40px; height: 40px; } .shop_shield_rogue_3 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1435px; + background-position: -1674px -1476px; width: 40px; height: 40px; } @@ -2056,6 +2056,12 @@ width: 64px; height: 54px; } +.ghost { + background-image: url(spritesmith-main-5.png); + background-position: -455px -1144px; + width: 90px; + height: 90px; +} .inventory_present { background-image: url(spritesmith-main-5.png); background-position: -1616px -1049px; @@ -2172,7 +2178,7 @@ } .inventory_special_opaquePotion { background-image: url(spritesmith-main-5.png); - background-position: -1674px -779px; + background-position: -1674px -820px; width: 40px; height: 40px; } @@ -2194,7 +2200,7 @@ width: 57px; height: 54px; } -.inventory_special_spookDust { +.inventory_special_spookySparkles { background-image: url(spritesmith-main-5.png); background-position: -1299px -1508px; width: 57px; @@ -2238,23 +2244,17 @@ } .seafoam_star { background-image: url(spritesmith-main-5.png); - background-position: -455px -1144px; + background-position: -364px -1144px; width: 90px; height: 90px; } .shop_armoire { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1517px; + background-position: -1674px -1558px; width: 40px; height: 40px; } .snowman { - background-image: url(spritesmith-main-5.png); - background-position: -364px -1144px; - width: 90px; - height: 90px; -} -.spookman { background-image: url(spritesmith-main-5.png); background-position: -273px -1144px; width: 90px; diff --git a/common/dist/sprites/spritesmith-main-5.png b/common/dist/sprites/spritesmith-main-5.png index cf85f9fe03b0b3943311b1fb93ece7bb2fcea371..80a66cd2abc10bfdb13863b0ecdbe413bbaf5c5a 100644 GIT binary patch literal 261236 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 BC%z4OL%+<<%; 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

gd{uBQCe{D4MxBzuzAStWK-$$SIfZ`JkL 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

3zJ_zTQ-Rci5JM07#@%c8qjqcb?IQ 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 O9}82PA4>l2dFS5{H|(7N literal 261273 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 z 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%0A`6N2Y;eFShAMKc6|`f}zFc=Qk)QTlywdXC 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

cYY8H)>e!Qwq|+yPRDx=O*G{{JZD`SSW-HyGI) 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»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 b|5&A;kcF@}bo4kP;OF$y!TqH_1YG?O)-?;7 diff --git a/common/dist/sprites/spritesmith-main-6.css b/common/dist/sprites/spritesmith-main-6.css index 60fa7a0a5f..498fc4512c 100644 --- a/common/dist/sprites/spritesmith-main-6.css +++ b/common/dist/sprites/spritesmith-main-6.css @@ -682,7 +682,7 @@ width: 32px; height: 32px; } -.shop_spookDust { +.shop_spookySparkles { background-image: url(spritesmith-main-6.png); background-position: -790px -1176px; width: 32px; @@ -730,7 +730,7 @@ width: 40px; height: 40px; } -.shop_heallAll { +.shop_healAll { background-image: url(spritesmith-main-6.png); background-position: -791px -1643px; width: 40px; diff --git a/common/img/sprites/spritesmith/achievements/achievement-spookDust.png b/common/img/sprites/spritesmith/achievements/achievement-spookySparkles.png similarity index 100% rename from common/img/sprites/spritesmith/achievements/achievement-spookDust.png rename to common/img/sprites/spritesmith/achievements/achievement-spookySparkles.png diff --git a/common/img/sprites/spritesmith/achievements/achievement-spookDust2x.png b/common/img/sprites/spritesmith/achievements/achievement-spookySparkles2x.png similarity index 100% rename from common/img/sprites/spritesmith/achievements/achievement-spookDust2x.png rename to common/img/sprites/spritesmith/achievements/achievement-spookySparkles2x.png diff --git a/common/img/sprites/spritesmith/misc/spookman.png b/common/img/sprites/spritesmith/misc/ghost.png similarity index 100% rename from common/img/sprites/spritesmith/misc/spookman.png rename to common/img/sprites/spritesmith/misc/ghost.png diff --git a/common/img/sprites/spritesmith/misc/inventory_special_spookDust.png b/common/img/sprites/spritesmith/misc/inventory_special_spookySparkles.png similarity index 100% rename from common/img/sprites/spritesmith/misc/inventory_special_spookDust.png rename to common/img/sprites/spritesmith/misc/inventory_special_spookySparkles.png diff --git a/common/img/sprites/spritesmith/shop/shop_spookDust.png b/common/img/sprites/spritesmith/shop/shop_spookySparkles.png similarity index 100% rename from common/img/sprites/spritesmith/shop/shop_spookDust.png rename to common/img/sprites/spritesmith/shop/shop_spookySparkles.png diff --git a/common/locales/en/limited.json b/common/locales/en/limited.json index 2407b788fe..d9f3ff5ba0 100644 --- a/common/locales/en/limited.json +++ b/common/locales/en/limited.json @@ -5,7 +5,7 @@ "annoyingFriends": "Annoying Friends", "annoyingFriendsText": "Got snowballed <%= snowballs %> times by party members.", "alarmingFriends": "Alarming Friends", - "alarmingFriendsText": "Got spooked <%= spookDust %> times by party members.", + "alarmingFriendsText": "Got spooked <%= spookySparkles %> times by party members.", "agriculturalFriends": "Agricultural Friends", "agriculturalFriendsText": "Got transformed into a flower <%= seeds %> times by party members.", "aquaticFriends": "Aquatic Friends", diff --git a/common/locales/en/spells.json b/common/locales/en/spells.json index 042e4adf4b..5eb5d18be1 100644 --- a/common/locales/en/spells.json +++ b/common/locales/en/spells.json @@ -52,8 +52,8 @@ "spellSpecialSaltText": "Salt", "spellSpecialSaltNotes": "Someone has snowballed you. Ha ha, very funny. Now get this snow off me!", - "spellSpecialSpookDustText": "Spooky Sparkles", - "spellSpecialSpookDustNotes": "Turn a friend into a floating blanket with eyes!", + "spellSpecialSpookySparklesText": "Spooky Sparkles", + "spellSpecialSpookySparklesNotes": "Turn a friend into a floating blanket with eyes!", "spellSpecialOpaquePotionText": "Opaque Potion", "spellSpecialOpaquePotionNotes": "Cancel the effects of Spooky Sparkles.", diff --git a/common/script/content/spells.js b/common/script/content/spells.js index 9309210af7..9b0514974d 100644 --- a/common/script/content/spells.js +++ b/common/script/content/spells.js @@ -265,7 +265,7 @@ spells.special = { cast (user, target, req) { if (!user.items.special.snowball) throw new NotAuthorized(t('spellNotOwned')(req.language)); target.stats.buffs.snowball = true; - target.stats.buffs.spookDust = false; + target.stats.buffs.spookySparkles = false; target.stats.buffs.shinySeed = false; target.stats.buffs.seafoam = false; if (!target.achievements.snowball) target.achievements.snowball = 0; @@ -285,22 +285,22 @@ spells.special = { user.stats.gp -= 5; }, }, - spookDust: { - text: t('spellSpecialSpookDustText'), + spookySparkles: { + text: t('spellSpecialSpookySparklesText'), mana: 0, value: 15, previousPurchase: true, target: 'user', - notes: t('spellSpecialSpookDustNotes'), + notes: t('spellSpecialSpookySparklesNotes'), cast (user, target, req) { - if (!user.items.special.spookDust) throw new NotAuthorized(t('spellNotOwned')(req.language)); + if (!user.items.special.spookySparkles) throw new NotAuthorized(t('spellNotOwned')(req.language)); target.stats.buffs.snowball = false; - target.stats.buffs.spookDust = true; + target.stats.buffs.spookySparkles = true; target.stats.buffs.shinySeed = false; target.stats.buffs.seafoam = false; - if (!target.achievements.spookDust) target.achievements.spookDust = 0; - target.achievements.spookDust++; - user.items.special.spookDust--; + if (!target.achievements.spookySparkles) target.achievements.spookySparkles = 0; + target.achievements.spookySparkles++; + user.items.special.spookySparkles--; }, }, opaquePotion: { @@ -311,7 +311,7 @@ spells.special = { target: 'self', notes: t('spellSpecialOpaquePotionNotes'), cast (user) { - user.stats.buffs.spookDust = false; + user.stats.buffs.spookySparkles = false; user.stats.gp -= 5; }, }, @@ -325,7 +325,7 @@ spells.special = { cast (user, target, req) { if (!user.items.special.shinySeed) throw new NotAuthorized(t('spellNotOwned')(req.language)); target.stats.buffs.snowball = false; - target.stats.buffs.spookDust = false; + target.stats.buffs.spookySparkles = false; target.stats.buffs.shinySeed = true; target.stats.buffs.seafoam = false; if (!target.achievements.shinySeed) target.achievements.shinySeed = 0; @@ -355,7 +355,7 @@ spells.special = { cast (user, target, req) { if (!user.items.special.seafoam) throw new NotAuthorized(t('spellNotOwned')(req.language)); target.stats.buffs.snowball = false; - target.stats.buffs.spookDust = false; + target.stats.buffs.spookySparkles = false; target.stats.buffs.shinySeed = false; target.stats.buffs.seafoam = true; if (!target.achievements.seafoam) target.achievements.seafoam = 0; diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index ef15d74f85..3a259250ab 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -113,6 +113,16 @@ function processUsers (afterId) { delete oldUser.id; + // spookDust -> spookySparkles + oldUser.achievements.spookySparkles = oldUser.achievements.spookDust; + oldUser.items.special.spookySparkles = oldUser.items.special.spookDust; + oldUser.stats.buffs.spookySparkles = oldUser.stats.buffs.spookDust; + + delete oldUser.achievements.spookDust; + delete oldUser.items.special.spookDust; + delete oldUser.stats.buffs.spookDust; + // end spookDust -> spookySparkles + oldUser.tags = oldUser.tags.map(function (tag) { return { id: tag.id, diff --git a/website/server/controllers/api-v2/user.js b/website/server/controllers/api-v2/user.js index b64f8e755a..656a48c908 100644 --- a/website/server/controllers/api-v2/user.js +++ b/website/server/controllers/api-v2/user.js @@ -637,6 +637,8 @@ api.cast = async function(req, res, next) { if (spellId === 'heallAll') { spellId = 'healAll'; + } else if (spellId === 'spookDust') { + spellId = 'spookySparkles'; } let klass = shared.content.spells.special[spellId] ? 'special' : user.stats.class; diff --git a/website/server/models/user.js b/website/server/models/user.js index 65480bf4ce..b007bb24c5 100644 --- a/website/server/models/user.js +++ b/website/server/models/user.js @@ -65,7 +65,7 @@ export let schema = new Schema({ triadBingoCount: Number, veteran: Boolean, snowball: Number, - spookDust: Number, + spookySparkles: Number, shinySeed: Number, seafoam: Number, streak: Number, @@ -265,7 +265,7 @@ export let schema = new Schema({ special: { snowball: {type: Number, default: 0}, - spookDust: {type: Number, default: 0}, + spookySparkles: {type: Number, default: 0}, shinySeed: {type: Number, default: 0}, seafoam: {type: Number, default: 0}, valentine: {type: Number, default: 0}, @@ -481,7 +481,7 @@ export let schema = new Schema({ stealth: {type: Number, default: 0}, streaks: {type: Boolean, default: false}, snowball: {type: Boolean, default: false}, - spookDust: {type: Boolean, default: false}, + spookySparkles: {type: Boolean, default: false}, shinySeed: {type: Boolean, default: false}, seafoam: {type: Boolean, default: false}, }, diff --git a/website/views/options/inventory/drops.jade b/website/views/options/inventory/drops.jade index 425d6fe276..322a5083f5 100644 --- a/website/views/options/inventory/drops.jade +++ b/website/views/options/inventory/drops.jade @@ -57,7 +57,7 @@ ng-click='castStart(Content.special.#{k})') .badge.badge-info.stack-count {{user.items.special.#{k}}} +specialItem('snowball') - +specialItem('spookDust') + +specialItem('spookySparkles') +specialItem('shinySeed') +specialItem('seafoam') diff --git a/website/views/shared/avatar/appearance.jade b/website/views/shared/avatar/appearance.jade index 7b28273996..14f69414a0 100644 --- a/website/views/shared/avatar/appearance.jade +++ b/website/views/shared/avatar/appearance.jade @@ -11,13 +11,13 @@ mixin avatar(opts) span(ng-if='profile.items.currentMount', class='Mount_Body_{{profile.items.currentMount}}') // Buffs that cause visual changes to avatar: Snowman, Ghost, Flower, etc - - var visualBuffs = { snowball: 'snowman', spookDust: 'spookman', shinySeed: 'avatar_floral_{{profile.stats.class}}', seafoam: 'seafoam_star' } + - var visualBuffs = { snowball: 'snowman', spookySparkles: 'ghost', shinySeed: 'avatar_floral_{{profile.stats.class}}', seafoam: 'seafoam_star' } each klass, item in visualBuffs span(ng-if='profile.stats.buffs.#{item}', class='#{klass}') // Show avatar only if not currently affected by visual buff - var buffs = '!profile.stats.buffs' - span(ng-if='#{buffs}.snowball && #{buffs}.spookDust && #{buffs}.shinySeed && #{buffs}.seafoam') + span(ng-if='#{buffs}.snowball && #{buffs}.spookySparkles && #{buffs}.shinySeed && #{buffs}.seafoam') +generatedAvatar // Mount Head diff --git a/website/views/shared/new-stuff.jade b/website/views/shared/new-stuff.jade index 92e764e31c..5dbe61f179 100644 --- a/website/views/shared/new-stuff.jade +++ b/website/views/shared/new-stuff.jade @@ -309,7 +309,7 @@ mixin oldNews tr td .promo_spring_classes_2016.pull-right - h3 Limited Edition Class Outfits + h3 Limited Edition Class Outfits p From now until April 30th, limited edition outfits are available in the Rewards column! Depending on your class, you can be a Springing Bunny, Clever Dog, Grand Malkin, or Brave Mouse. You'd better get productive to earn enough Gold before your time runs out... p.small.muted by PainterProphet and Balduranne tr @@ -863,7 +863,7 @@ mixin oldNews p Exciting news - for the next three weeks, we are offering Habitica T-shirts via Teespring! Show your Habitica pride in purple or black. We are also offering an EU run for cheaper shipping to Europe! br p Whether you're getting them for yourself or as a holiday gift, we hope you enjoy these limited-run T-shirts! As always, thanks for supporting Habitica. - + h2 11/5/2015 - HUGE IOS UPDATE AND ANDROID MAILING LIST tr td @@ -893,7 +893,7 @@ mixin oldNews tr td h3 Android Mailing List - p For those of you anxious for news about the new native Android app, we've created a mailing list so that you can be notified about important updates for the Android app. You can sign up here! + p For those of you anxious for news about the new native Android app, we've created a mailing list so that you can be notified about important updates for the Android app. You can sign up here! br p Our staff has been working very hard on it and testing out a new build each week, so progress is definitely advancing. When the beta is ready we will announce it on social media and on the site, but the mailing list is the easiest way to make sure you don't miss it! Thanks very much for your patience. h2 11/3/2015 - NOVEMBER BACKGROUNDS AND ARMOIRE ITEMS, AND AUTO-EQUIP NEW GEAR @@ -954,14 +954,14 @@ mixin oldNews td .promo_mystery_201510.pull-right h3 Last Chance for Horned Goblin Set - p Reminder: this is the final day to subscribe and receive the Horned Goblin Item Set! If you want the Goblin Horns or the Goblin Tail, now's the time! + p Reminder: this is the final day to subscribe and receive the Horned Goblin Item Set! If you want the Goblin Horns or the Goblin Tail, now's the time! br - p Thanks so much for your supporting the site -- you're helping us keep Habitica alive. + p Thanks so much for your supporting the site -- you're helping us keep Habitica alive. tr td .npc_justin.pull-right h3 Happy Habitoween! - p Burnout is nearly defeated, so what could be a better way to speed the celebration than to have some fun? In honor of Habitoween and defiance of the looming threat, all of the remaining NPCs have dressed up as monsters from the Flourishing Fields! Be sure to visit them on the site to admire their outfits. If only the three Exhaust Spirits could join them... + p Burnout is nearly defeated, so what could be a better way to speed the celebration than to have some fun? In honor of Habitoween and defiance of the looming threat, all of the remaining NPCs have dressed up as monsters from the Flourishing Fields! Be sure to visit them on the site to admire their outfits. If only the three Exhaust Spirits could join them... h2 10/27/2015 - BURNOUT STRIKES AGAIN! PLUS, SPOOKY POTIONS VANISHING SOON tr @@ -2665,9 +2665,9 @@ mixin oldNews td h3 Spooky Sparkles .pull-right - .inventory_special_spookDust - .achievement-spookDust - .spookman + .inventory_special_spookySparkles + .achievement-spookySparkles + .ghost p There's a new gold-purchasable item in the Market: Spooky Sparkles! Buy some and then cast it on your friends. I wonder what it will do? br p If you have Spooky Sparkles cast on you, you will receive the "Alarming Friends" badge! Don't worry, any mysterious effects will wear off the next day.... or you can cancel them early by buying an Opaque Potion! diff --git a/website/views/shared/profiles/achievements.jade b/website/views/shared/profiles/achievements.jade index 1b3659a863..cb75e0db32 100644 --- a/website/views/shared/profiles/achievements.jade +++ b/website/views/shared/profiles/achievements.jade @@ -183,11 +183,11 @@ div(ng-if='::profile.achievements.snowball') =env.t('annoyingFriendsText', {snowballs: "{{::profile.achievements.snowball}}"}) hr -div(ng-if='::profile.achievements.spookDust') - .achievement.achievement-spookDust +div(ng-if='::profile.achievements.spookySparkles') + .achievement.achievement-spookySparkles h5=env.t('alarmingFriends') small - =env.t('alarmingFriendsText', {spookDust: "{{::profile.achievements.spookDust}}"}) + =env.t('alarmingFriendsText', {spookySparkles: "{{::profile.achievements.spookySparkles}}"}) hr div(ng-if='::profile.achievements.shinySeed') diff --git a/website/views/shared/tasks/task_view/skills.jade b/website/views/shared/tasks/task_view/skills.jade index da8cabef47..dccd6d1233 100644 --- a/website/views/shared/tasks/task_view/skills.jade +++ b/website/views/shared/tasks/task_view/skills.jade @@ -1,5 +1,5 @@ // Events -- var seasonalSkills = {'snowball':'salt', 'spookDust':'opaquePotion', 'shinySeed':'petalFreePotion', 'seafoam':'sand'} +- var seasonalSkills = {'snowball':'salt', 'spookySparkles':'opaquePotion', 'shinySeed':'petalFreePotion', 'seafoam':'sand'} ul.items.rewards each dispel,skill in seasonalSkills span(ng-if='main && list.type=="reward" && (user.items.special.#{skill}>0 || user.stats.buffs.#{skill})') From 5c885c77a0d28bbc0e574a054dd2260deb903aac Mon Sep 17 00:00:00 2001 From: Alys Date: Sat, 21 May 2016 09:20:11 +1000 Subject: [PATCH 945/976] add dateCreated to all tasks; add empty challenge object to tasks that don't have one (#7386) --- website/server/models/task.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/server/models/task.js b/website/server/models/task.js index 601a02c30b..536d95ec0c 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -134,6 +134,8 @@ TaskSchema.methods.toJSONV2 = function toJSONV2 () { toJSON.id = toJSON._id; } + if (!toJSON.challenge) toJSON.challenge = {}; + let v3Tags = this.tags; toJSON.tags = {}; @@ -141,6 +143,8 @@ TaskSchema.methods.toJSONV2 = function toJSONV2 () { toJSON.tags[tag] = true; }); + toJSON.dateCreated = this.createdAt; + return toJSON; }; From d36c514c06428acaee6bdd9e62830de88c848183 Mon Sep 17 00:00:00 2001 From: Alys Date: Fri, 20 May 2016 22:07:03 -0400 Subject: [PATCH 946/976] add plumilla to artists for Tangle Tree in Bailey message --- website/views/shared/new-stuff.jade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/views/shared/new-stuff.jade b/website/views/shared/new-stuff.jade index 5dbe61f179..1d9ea66262 100644 --- a/website/views/shared/new-stuff.jade +++ b/website/views/shared/new-stuff.jade @@ -6,7 +6,7 @@ h2 5/17/2016 - TREELING PET QUEST AND CHALLENGE SPOTLIGHT! h3 New Pet Quest: The Tangle Tree (Treelings) p We've released a new Pet Quest: The Tangle Tree! The Garden Competition has been disrupted by a terrible multi-tasking tree. Can you defeat this wooden warrior? If so, you'll earn some treeling eggs! p.small.muted by Lemoness and SabreCat - p.small.muted Art by fuzzytrees, PainterProphet, and aurakami + p.small.muted Art by fuzzytrees, PainterProphet, plumilla, and aurakami p.small.muted Writing by Flutter Bee tr td From f4ca97ffc34ce5a41159db2af36d6d51bf486ca7 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 21 May 2016 11:21:39 +0100 Subject: [PATCH 947/976] Fixed quest drop modal (#7377) * Fixed quest drop modal * Fixed broken party test --- test/spec/controllers/partyCtrlSpec.js | 4 ++- .../client/js/controllers/notificationCtrl.js | 4 +-- website/client/js/controllers/partyCtrl.js | 25 ++++++++++++------- website/views/shared/modals/quests.jade | 3 ++- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/test/spec/controllers/partyCtrlSpec.js b/test/spec/controllers/partyCtrlSpec.js index 6dd9714531..00d3e676c5 100644 --- a/test/spec/controllers/partyCtrlSpec.js +++ b/test/spec/controllers/partyCtrlSpec.js @@ -51,7 +51,9 @@ describe("Party Controller", function() { var syncParty = sinon.stub(groups.Group, 'syncParty') syncParty.returns(Promise.resolve(groupResponse)); $controller('PartyCtrl', { $scope: scope, $state: state, User: User }); - expect(state.is).to.be.calledOnce; // ensure initialization worked as desired + // @TODO: I have update the party ctrl to sync the user whenever it is called rather than only on the party page + // Since I have cached the promise, this should not be a performance issue, but let's keep this test here in case anything breaks. + // expect(state.is).to.be.calledOnce; // ensure initialization worked as desired }); }; diff --git a/website/client/js/controllers/notificationCtrl.js b/website/client/js/controllers/notificationCtrl.js index f4a9aa613e..16a2c3a991 100644 --- a/website/client/js/controllers/notificationCtrl.js +++ b/website/client/js/controllers/notificationCtrl.js @@ -68,7 +68,7 @@ habitrpg.controller('NotificationCtrl', } $rootScope.$watch('user.stats.lvl', function(after, before) { - if (after <= before) return; + if (after <= before) return; Notification.lvl(); $rootScope.playSound('Level_Up'); if (User.user._tmp && User.user._tmp.drop && (User.user._tmp.drop.type === 'Quest')) return; @@ -127,7 +127,7 @@ habitrpg.controller('NotificationCtrl', Notification.drop(env.t('messageDropFood', {dropArticle: after.article, dropText: text, dropNotes: notes}), after); } else if (after.type === 'Quest') { $rootScope.selectedQuest = Content.quests[after.key]; - $rootScope.openModal('questDrop', {controller:'PartyCtrl',size:'sm'}); + $rootScope.openModal('questDrop', {controller:'PartyCtrl', size:'sm'}); } else if (after.notificationType === 'Mystery') { text = Content.gear.flat[after.key].text(); Notification.drop(env.t('messageDropMysteryItem', {dropText: text}), after); diff --git a/website/client/js/controllers/partyCtrl.js b/website/client/js/controllers/partyCtrl.js index 0049ff9815..64070734bf 100644 --- a/website/client/js/controllers/partyCtrl.js +++ b/website/client/js/controllers/partyCtrl.js @@ -11,15 +11,13 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.inviteOrStartParty = Groups.inviteOrStartParty; $scope.loadWidgets = Social.loadWidgets; - if ($state.is('options.social.party')) { - Groups.Group.syncParty() - .then(function successCallback(group) { - $rootScope.party = $scope.group = group; - checkForNotifications(); - }, function errorCallback(response) { - $rootScope.party = $scope.group = $scope.newGroup = { type: 'party' }; - }); - } + Groups.Group.syncParty() + .then(function successCallback(group) { + $rootScope.party = $scope.group = group; + checkForNotifications(); + }, function errorCallback(response) { + $rootScope.party = $scope.group = $scope.newGroup = { type: 'party' }; + }); function checkForNotifications () { // Checks if user's party has reached 2 players for the first time. @@ -148,6 +146,15 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' User.set({'invitations.party':{}}); } + $scope.questInit = function() { + var key = $rootScope.selectedQuest.key; + + Quests.initQuest(key).then(function() { + $rootScope.selectedQuest = undefined; + $scope.$close(); + }); + }; + $scope.questCancel = function(){ if (!confirm(window.env.t('sureCancel'))) return; diff --git a/website/views/shared/modals/quests.jade b/website/views/shared/modals/quests.jade index 10ce712b6a..40efa3b5c4 100644 --- a/website/views/shared/modals/quests.jade +++ b/website/views/shared/modals/quests.jade @@ -104,7 +104,8 @@ script(type='text/ng-template', id='modals/questDrop.html') .quest-icon(class='inventory_quest_scroll_{{::selectedQuest.key}}') h4!=env.t('leveledUpReceivedQuest', {level:'{{user.stats.lvl}}'}) .row(style='margin-top:2em') - button.btn.btn-primary(ng-click='inviteOrStartParty(group); $close()', ng-if='!party.members')=env.t('startAParty') + button.btn.btn-primary(ng-click='inviteOrStartParty(party); $close()', ng-if='!User.user.party._id')=env.t('startAParty') + button.btn.btn-primary(ng-click='inviteOrStartParty(party); $close()', ng-if='!User.user.party._id && !party.members')=env.t('battleWithFriends') button.btn.btn-primary(ng-click='questInit(); $close()', ng-if='party.members')=env.t('inviteParty') button.btn.btn-default(ng-click='closeQuest(); $close()')=env.t('questLater') .modal-footer(style='margin-top:0', ng-init='loadWidgets()') From 237bc062ea96301a5e5b85635a1ee9f19961469d Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Sat, 21 May 2016 05:24:13 -0500 Subject: [PATCH 948/976] [API v3] Maintenance Mode (#7367) * WIP(maintenance): maintenance * WIP(maintenance): working locale features * fix(maintenance): don't translate info page target * WIP(maintenance): start adding info page * fix(maintenance): linting * feat: Add container to maintenance info page * fix(maintenance): add config.json edits Also DRY variables for main vs info pages * fix(maintenance): linting * refactor(maintenance): further slim down variables * refactor: Remove unnecessary variables * fix: Correct string interpolation in maintenace view * feat: Dynamically add time to maintenance pages * maintenance mode: do not connect to mongodb * fix(maintenance): clean up timezones etc. * fix(maintenance): remove unneeded sprite --- common/locales/en/maintenance.json | 34 +++++++++++ config.json.example | 1 + .../unit/middlewares/maintenanceMode.test.js | 58 +++++++++++++++++++ test/helpers/api-unit.helper.js | 1 + website/server/libs/api-v3/setupMongoose.js | 25 ++++---- website/server/middlewares/api-v3/index.js | 3 + .../middlewares/api-v3/maintenanceMode.js | 31 ++++++++++ website/views/static/maintenance-info.jade | 37 ++++++++++++ website/views/static/maintenance.jade | 18 ++++++ 9 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 common/locales/en/maintenance.json create mode 100644 test/api/v3/unit/middlewares/maintenanceMode.test.js create mode 100644 website/server/middlewares/api-v3/maintenanceMode.js create mode 100644 website/views/static/maintenance-info.jade create mode 100644 website/views/static/maintenance.jade diff --git a/common/locales/en/maintenance.json b/common/locales/en/maintenance.json new file mode 100644 index 0000000000..20b3410de2 --- /dev/null +++ b/common/locales/en/maintenance.json @@ -0,0 +1,34 @@ +{ + "habiticaBackSoon": "Don't worry, Habitica will be back soon!", + "importantMaintenance": "We are doing important maintenance that we estimate will last until 10pm Pacific Time (5am UTC).", + "maintenance": "Maintenance", + "maintenanceMoreInfo": "Want more information about the maintenance? <%= linkStart %>Check out our info page<%= linkEnd %>.", + "noDamageKeepStreaks": "You will NOT take damage or lose streaks!", + "thanksForPatience": "Thanks for your patience!", + "twitterMaintenanceUpdates": "For the most recent updates, watch our Twitter, where we will be posting status information.", + "veteranPetAward": "At the end, you will receive a Veteran pet!", + + "maintenanceInfoTitle": "Information about Upcoming Maintenance to Habitica", + "maintenanceInfoWhat": "What is happening?", + "maintenanceInfoWhatText": "On May 21, Habitica will be down for maintenance for most of the day. You will not take any damage or have your account harmed during that weekend, even if you can’t log in to check off your Dailies in time! We will be working very hard to make the downtime as short as possible, and will be posting regular updates on our Twitter account. At the end of the downtime, to thank everyone for their patience, you will all receive a rare pet!", + "maintenanceInfoWhy": "Why is this happening?", + "maintenanceInfoWhyText": "For the past several months, we have been thoroughly revamping Habitica behind-the-scenes. Specifically, we have rewritten the API. While it may not look much different on the surface, it’s a whole new world underneath. This will allow us WAY more flexibility when we want to build features in the future, and lead to improved performance!", + "maintenanceInfoTechDetails": "Want more details on the technical side of the process? Visit The Forge, our dev blog.", + "maintenanceInfoMore": "More Information", + "maintenanceInfoAccountChanges": "What changes will I see to my account after the rewrite is complete?", + "maintenanceInfoAccountChangesText": "At first, there won’t be any notable changes aside from performance improvements for features such as Challenges. If you notice any changes that shouldn’t be there, email us at admin@habitica.com and we will investigate them for you!", + "maintenanceInfoAddFeatures": "What kind of features will this allow Habitica to add?", + "maintenanceInfoAddFeaturesText": "Completing this rewrite will allow us to start building out improved chat and Guilds, plans for organizations and families, and additional productivity features like Monthlies and the ability to record yesterday’s activity! Those are all involved features on their own, so it will take time to build them, but until we were finished with this rewrite, there was no way we could start them.", + "maintenanceInfoHowLong": "How long will the maintenance take?", + "maintenanceInfoHowLongText": "We have to migrate tasks and data for all 1.3 million Habitica users -- not an easy task! We anticipate that it will take place between approximately 1pm Pacific Time (8pm UTC) and 10pm Pacific Time (5am UTC). Rest assured that we’re doing everything we can to make it go as quickly as possible! You can follow updates on our Twitter.", + "maintenanceInfoStatsAffected": "How will my Dailies, Streaks, Buffs, and Quests be affected?", + "maintenanceInfoStatsAffectedText1": "You will NOT take any damage or lose any streaks that weekend, but otherwise, your day will reset normally! Dailies that you checked will become unchecked, buffs will reset, etc. If you are in a Collection Quest, you will still find items. If you are in a Boss Battle, you will still deal damage to the Boss, but the Boss will not deal damage to you. (Even monsters need a break!)", + "maintenanceInfoStatsAffectedText2": "After a lot of thought, our team concluded that this was the most fair way to handle the fact that many users will not be able to check off their Dailies normally during the maintenance. We’re sorry for any inconvenience this causes!", + "maintenanceInfoSeeTasks": "What if I need to see my task list?", + "maintenanceInfoSeeTasksText": "If you know that you will need to see your task list on Saturday to remind yourself what you have to do, we recommend that before the maintenance begins, you take a screenshot of your tasks so that you can use it as a reference.", + "maintenanceInfoRarePet": "What kind of rare pet will I receive?", + "maintenanceInfoRarePetText": "To thank you for your patience during the downtime, everyone will get a rare Veteran Pet. If you’ve never received a Veteran Pet before, you will receive a Veteran Wolf. If you already have a Veteran Wolf, you will receive a Veteran Tiger. And if you already have a Veteran Wolf and a Veteran Tiger, you will receive a never-before-seen Veteran pet! After the migration is completed, it may take several hours for your pet to show up, but never fear, everyone will get one.", + "maintenanceInfoWho": "Who worked on this massive project?", + "maintenanceInfoWhoText": "We’re glad you asked! It was spearheaded by our amazing contributor paglias, with lots of help from Blade, TheHollidayInn, SabreCat, Victor Pudeyev, TheUnknown, and Alys.", + "maintenanceInfoTesting": "The new version was also tirelessly tested by a bunch of our amazing open-source volunteers. Thank you -- we couldn't have done this without you." +} diff --git a/config.json.example b/config.json.example index 35609d1c93..d70dcac18c 100644 --- a/config.json.example +++ b/config.json.example @@ -10,6 +10,7 @@ "TEST_DB_URI":"mongodb://localhost/habitrpg_test", "NODE_ENV":"development", "CRON_SAFE_MODE":"false", + "MAINTENANCE_MODE": "false", "SESSION_SECRET":"YOUR SECRET HERE", "ADMIN_EMAIL": "you@example.com", "SMTP_USER":"user@example.com", diff --git a/test/api/v3/unit/middlewares/maintenanceMode.test.js b/test/api/v3/unit/middlewares/maintenanceMode.test.js new file mode 100644 index 0000000000..21cabe963d --- /dev/null +++ b/test/api/v3/unit/middlewares/maintenanceMode.test.js @@ -0,0 +1,58 @@ +import { + generateRes, + generateReq, + generateNext, +} from '../../../../helpers/api-unit.helper'; +import nconf from 'nconf'; +import requireAgain from 'require-again'; + +describe('maintenance mode middleware', () => { + let res, req, next; + let pathToMaintenanceModeMiddleware = '../../../../../website/server/middlewares/api-v3/maintenanceMode'; + + beforeEach(() => { + res = generateRes(); + next = generateNext(); + }); + + it('does not return 503 error when maintenance mode is off', () => { + req = generateReq(); + sandbox.stub(nconf, 'get').withArgs('MAINTENANCE_MODE').returns('false'); + let attachMaintenanceMode = requireAgain(pathToMaintenanceModeMiddleware); + + attachMaintenanceMode(req, res, next); + + expect(next).to.have.been.called.once; + expect(res.status).to.not.have.been.called; + }); + + it('returns 503 error when maintenance mode is on', () => { + req = generateReq(); + sandbox.stub(nconf, 'get').withArgs('MAINTENANCE_MODE').returns('true'); + let attachMaintenanceMode = requireAgain(pathToMaintenanceModeMiddleware); + + attachMaintenanceMode(req, res, next); + + expect(next).to.not.have.been.called; + expect(res.status).to.have.been.calledOnce; + expect(res.status).to.have.been.calledWith(503); + }); + + it('renders maintenance page when request type is HTML', () => { + req = generateReq({headers: {accept: 'text/html'}}); + sandbox.stub(nconf, 'get').withArgs('MAINTENANCE_MODE').returns('true'); + let attachMaintenanceMode = requireAgain(pathToMaintenanceModeMiddleware); + + attachMaintenanceMode(req, res, next); + expect(res.render).to.have.been.calledOnce; + }); + + it('sends error message when request type is JSON', () => { + req = generateReq({headers: {accept: 'application/json'}}); + sandbox.stub(nconf, 'get').withArgs('MAINTENANCE_MODE').returns('true'); + let attachMaintenanceMode = requireAgain(pathToMaintenanceModeMiddleware); + + attachMaintenanceMode(req, res, next); + expect(res.send).to.have.been.calledOnce; + }); +}); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index ca8b57ae7f..ec20ae763e 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -25,6 +25,7 @@ export function generateGroup (options = {}) { export function generateRes (options = {}) { let defaultRes = { + render: sandbox.stub(), send: sandbox.stub(), status: sandbox.stub().returnsThis(), sendStatus: sandbox.stub().returnsThis(), diff --git a/website/server/libs/api-v3/setupMongoose.js b/website/server/libs/api-v3/setupMongoose.js index 69eac57b71..41ba9e5358 100644 --- a/website/server/libs/api-v3/setupMongoose.js +++ b/website/server/libs/api-v3/setupMongoose.js @@ -5,19 +5,24 @@ import mongoose from 'mongoose'; import Bluebird from 'bluebird'; const IS_PROD = nconf.get('IS_PROD'); +const MAINTENANCE_MODE = nconf.get('MAINTENANCE_MODE'); // Use Q promises instead of mpromise in mongoose mongoose.Promise = Bluebird; -let mongooseOptions = !IS_PROD ? {} : { - replset: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, - server: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, -}; +// Do not connect to MongoDB when in maintenance mode +if (MAINTENANCE_MODE !== 'true') { + let mongooseOptions = !IS_PROD ? {} : { + replset: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, + server: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, + }; -const NODE_DB_URI = nconf.get('IS_TEST') ? nconf.get('TEST_DB_URI') : nconf.get('NODE_DB_URI'); -let db = mongoose.connect(NODE_DB_URI, mongooseOptions, (err) => { - if (err) throw err; - logger.info('Connected with Mongoose.'); -}); + const NODE_DB_URI = nconf.get('IS_TEST') ? nconf.get('TEST_DB_URI') : nconf.get('NODE_DB_URI'); -autoinc.init(db); + let db = mongoose.connect(NODE_DB_URI, mongooseOptions, (err) => { + if (err) throw err; + logger.info('Connected with Mongoose.'); + }); + + autoinc.init(db); +} \ No newline at end of file diff --git a/website/server/middlewares/api-v3/index.js b/website/server/middlewares/api-v3/index.js index 9e3d37233e..694fdb6ea5 100644 --- a/website/server/middlewares/api-v3/index.js +++ b/website/server/middlewares/api-v3/index.js @@ -14,6 +14,7 @@ import favicon from 'serve-favicon'; import methodOverride from 'method-override'; import passport from 'passport'; import path from 'path'; +import maintenanceMode from './maintenanceMode'; import { forceSSL, forceHabitica, @@ -48,6 +49,8 @@ module.exports = function attachMiddlewares (app, server) { app.use(compression()); app.use(favicon(`${PUBLIC_DIR}/favicon.ico`)); + app.use(maintenanceMode); + app.use(cors); app.use(forceSSL); app.use(forceHabitica); diff --git a/website/server/middlewares/api-v3/maintenanceMode.js b/website/server/middlewares/api-v3/maintenanceMode.js new file mode 100644 index 0000000000..331663125a --- /dev/null +++ b/website/server/middlewares/api-v3/maintenanceMode.js @@ -0,0 +1,31 @@ +import { getUserLanguage } from './language'; +import nconf from 'nconf'; + +const MAINTENANCE_MODE = nconf.get('MAINTENANCE_MODE'); + +module.exports = function maintenanceMode (req, res, next) { + if (MAINTENANCE_MODE !== 'true') return next(); + + getUserLanguage(req, res, (err) => { + if (err) return next(err); + + let pageVariables = { + maintenanceStart: nconf.get('MAINTENANCE_START'), + maintenanceEnd: nconf.get('MAINTENANCE_END'), + translation: res.t, + }; + + if (req.headers && req.headers.accept && req.headers.accept.indexOf('text/html') !== -1) { + if (req.path === '/views/static/maintenance-info') { + return res.status(503).render('../../../views/static/maintenance-info', pageVariables); + } else { + return res.status(503).render('../../../views/static/maintenance', pageVariables); + } + } else { + return res.status(503).send({ + error: 'Maintenance', + message: 'Server offline for maintenance.', + }); + } + }); +}; diff --git a/website/views/static/maintenance-info.jade b/website/views/static/maintenance-info.jade new file mode 100644 index 0000000000..241aafde3a --- /dev/null +++ b/website/views/static/maintenance-info.jade @@ -0,0 +1,37 @@ +- var t = env ? env.t : translation; + +title Habitica |  + =t('maintenance') + +head + link(rel='stylesheet', type='text/css', href='https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/css/bootstrap.min.css') + +body + .container + h1.text-center=t('maintenanceInfoTitle') + img.img-rendering-auto.center-block.img-responsive(src='https://d2afqr2xdmyzvu.cloudfront.net/assets/scene_maintenance.png') + h2=t('maintenanceInfoWhat') + p!=t('maintenanceInfoWhatText') + img.pull-left(src='https://d2afqr2xdmyzvu.cloudfront.net/assets/scene_new.png') + h2=t('maintenanceInfoWhy') + p=t('maintenanceInfoWhyText') + p!=t('maintenanceInfoTechDetails') + h2=t('maintenanceInfoMore') + h3=t('maintenanceInfoAccountChanges') + p!=t('maintenanceInfoAccountChangesText') + h3=t('maintenanceInfoAddFeatures') + p=t('maintenanceInfoAddFeaturesText') + h3=t('maintenanceInfoHowLong') + img.pull-right(src='https://d2afqr2xdmyzvu.cloudfront.net/assets/scene_chatter.png') + p!=t('maintenanceInfoHowLongText') + h3=t('maintenanceInfoStatsAffected') + p=t('maintenanceInfoStatsAffectedText1') + p=t('maintenanceInfoStatsAffectedText2') + h3=t('maintenanceInfoSeeTasks') + p=t('maintenanceInfoSeeTasksText') + h3=t('maintenanceInfoRarePet') + p=t('maintenanceInfoRarePetText') + img.pull-left(src='https://d2afqr2xdmyzvu.cloudfront.net/assets/scene_coding.png') + h3=t('maintenanceInfoWho') + p=t('maintenanceInfoWhoText') + p=t('maintenanceInfoTesting') diff --git a/website/views/static/maintenance.jade b/website/views/static/maintenance.jade new file mode 100644 index 0000000000..0537913219 --- /dev/null +++ b/website/views/static/maintenance.jade @@ -0,0 +1,18 @@ +- var t = env ? env.t : translation; + +title Habitica |  + =t('maintenance') + +head + link(rel='stylesheet', type='text/css', href='https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/css/bootstrap.min.css') + +body.text-center + h1=t('habiticaBackSoon') + img.img-rendering-auto.center-block.img-responsive(src='https://d2afqr2xdmyzvu.cloudfront.net/assets/scene_maintenance.png') + p!=t('importantMaintenance') + p!=t('twitterMaintenanceUpdates') + ul.lead(style='list-style-position:inside') + li=t('noDamageKeepStreaks') + li=t('veteranPetAward') + p!=t('maintenanceMoreInfo', {linkStart: '', linkEnd: '',}) + p.lead=t('thanksForPatience') From b378e41f2cc2fd94ac54c0e25e39ca092cbc3b45 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 21 May 2016 16:26:34 +0100 Subject: [PATCH 949/976] Tavern party challenges invites fix (#7394) * Added challenges and invitations to party * Loaded tavern challenges * Updated group and quest services tests --- test/spec/services/groupServicesSpec.js | 4 ++++ test/spec/services/questServicesSpec.js | 2 ++ website/client/js/controllers/tavernCtrl.js | 8 ++++++-- website/client/js/services/groupServices.js | 10 +++++++++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/test/spec/services/groupServicesSpec.js b/test/spec/services/groupServicesSpec.js index 71e6b7d08e..5b4a88c996 100644 --- a/test/spec/services/groupServicesSpec.js +++ b/test/spec/services/groupServicesSpec.js @@ -37,6 +37,8 @@ describe('groupServices', function() { var groupResponse = {data: {_id: groupId}}; $httpBackend.expectGET(groupApiUrlPrefix + '/party').respond(groupResponse); $httpBackend.expectGET('/api/v3/groups/' + groupId + '/members?includeAllPublicFields=true').respond({}); + $httpBackend.expectGET('/api/v3/groups/' + groupId + '/invites').respond({}); + $httpBackend.expectGET('/api/v3/challenges/groups/' + groupId).respond({}); groups.Group.syncParty(); $httpBackend.flush(); }); @@ -81,6 +83,8 @@ describe('groupServices', function() { var groupResponse = {data: {_id: groupId}}; $httpBackend.expectGET(groupApiUrlPrefix + '/party').respond(groupResponse); $httpBackend.expectGET('/api/v3/groups/' + groupId + '/members?includeAllPublicFields=true').respond({}); + $httpBackend.expectGET('/api/v3/groups/' + groupId + '/invites').respond({}); + $httpBackend.expectGET('/api/v3/challenges/groups/' + groupId).respond({}); groups.party(); $httpBackend.flush(); }); diff --git a/test/spec/services/questServicesSpec.js b/test/spec/services/questServicesSpec.js index b4741b601c..e71dc6f3ef 100644 --- a/test/spec/services/questServicesSpec.js +++ b/test/spec/services/questServicesSpec.js @@ -349,6 +349,8 @@ describe('Quests Service', function() { fakeBackend.when('GET', 'partials/main.html').respond({}); fakeBackend.when('GET', '/api/v3/groups/party').respond(partyResponse); fakeBackend.when('GET', '/api/v3/groups/party-id/members?includeAllPublicFields=true').respond({}); + fakeBackend.when('GET', '/api/v3/groups/party-id/invites').respond({}); + fakeBackend.when('GET', '/api/v3/challenges/groups/party-id').respond({}); fakeBackend.when('POST', '/api/v3/groups/party-id/quests/invite/' + key).respond({quest: { key: 'whale' } }); fakeBackend.flush(); })); diff --git a/website/client/js/controllers/tavernCtrl.js b/website/client/js/controllers/tavernCtrl.js index cb02d07cef..77056e07a2 100644 --- a/website/client/js/controllers/tavernCtrl.js +++ b/website/client/js/controllers/tavernCtrl.js @@ -1,10 +1,14 @@ 'use strict'; -habitrpg.controller("TavernCtrl", ['$scope', 'Groups', 'User', - function($scope, Groups, User) { +habitrpg.controller("TavernCtrl", ['$scope', 'Groups', 'User', 'Challenges', + function($scope, Groups, User, Challenges) { Groups.tavern() .then(function (tavern) { $scope.group = tavern; + Challenges.getGroupChallenges($scope.group._id) + .then(function (response) { + $scope.group.challenges = response.data.data; + }); }) $scope.toggleUserTier = function($event) { diff --git a/website/client/js/services/groupServices.js b/website/client/js/services/groupServices.js index c8b537144e..772c5e3814 100644 --- a/website/client/js/services/groupServices.js +++ b/website/client/js/services/groupServices.js @@ -121,8 +121,16 @@ angular.module('habitrpg') Members.getGroupMembers(data.party._id, true) .then(function (response) { data.party.members = response.data.data; - _cachedPartyPromise.resolve(data.party); }); + Members.getGroupInvites(data.party._id) + .then(function (response) { + data.party.invites = response.data.data; + }); + Challenges.getGroupChallenges(data.party._id) + .then(function (response) { + data.party.challenges = response.data.data; + }); + _cachedPartyPromise.resolve(data.party); }, function (response) { data.party = { type: 'party' }; _cachedPartyPromise.reject(data.party); From b49826129a544d80e537e34aaad47a6f82831dee Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 May 2016 18:43:35 +0200 Subject: [PATCH 950/976] v3: implement automatic syncing if user is not up to date --- common/script/public/config.js | 43 +++++++++++++++++-- website/client/js/services/userServices.js | 11 +++-- website/server/middlewares/api-v3/response.js | 10 +++++ 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/common/script/public/config.js b/common/script/public/config.js index 45fd77024f..4456909ef4 100644 --- a/common/script/public/config.js +++ b/common/script/public/config.js @@ -6,8 +6,43 @@ angular.module('habitrpg') var resyncNumber = 0; var lastResync = 0; + // Verify that the user was not updated from another browser/app/client + // If it was, sync + function verifyUserUpdated (response) { + var isApiCall = response.config.url.indexOf('api/v3') !== -1; + var isUserAvailable = $rootScope.User && $rootScope.User.user && $rootScope.User.user._wrapped === true; + var hasUserV = response.data && response.data.userV; + var isNotSync = response.config.url.indexOf('/api/v3/user') !== 0; + + if (isApiCall && isUserAvailable && hasUserV) { + var oldUserV = $rootScope.User.user._v; + $rootScope.User.user._v = response.data.userV; + + // Something has changed on the user object that was not tracked here, sync the user + if (isNotSync && ($rootScope.User.user._v - oldUserV) > 1) { + $rootScope.User.sync(); + } + } + } + return { + request: function (config) { + var url = config.url; + + if (url.indexOf('api/v3') !== -1) { + if ($rootScope.User && $rootScope.User.user) { + if (url.indexOf('?') !== -1) { + config.url += '&userV=' + $rootScope.User.user._v; + } else { + config.url += '?userV=' + $rootScope.User.user._v; + } + } + } + + return config; + }, response: function(response) { + verifyUserUpdated(response); return response; }, responseError: function(response) { @@ -26,7 +61,7 @@ angular.module('habitrpg') if (!mobileApp) // skip mobile for now $rootScope.$broadcast('responseError', "The site has been updated and the page needs to refresh. The last action has not been recorded, please refresh and try again."); - } else if (response.data.code && response.data.code === 'ACCOUNT_SUSPENDED') { + } else if (response.data && response.data.code && response.data.code === 'ACCOUNT_SUSPENDED') { confirm(response.data.err); localStorage.clear(); window.location.href = mobileApp ? '/app/login' : '/logout'; //location.reload() @@ -34,14 +69,14 @@ angular.module('habitrpg') // 400 range } else if (response.status < 400) { // never triggered because we're in responseError - $rootScope.$broadcast('responseText', response.data.message); + $rootScope.$broadcast('responseText', response.data && response.data.message); } else if (response.status < 500) { - if (response.status === 400 && response.data.errors && _.isArray(response.data.errors)) { // bad requests with more info + if (response.status === 400 && response.data && response.data.errors && _.isArray(response.data.errors)) { // bad requests with more info response.data.errors.forEach(function (err) { $rootScope.$broadcast('responseError', err.message); }); } else { - $rootScope.$broadcast('responseError', response.data.message); + $rootScope.$broadcast('responseError', response.data && response.data.message); } if ($rootScope.User && $rootScope.User.sync) { diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 78e01a7476..4dfdb84e49 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -160,7 +160,10 @@ angular.module('habitrpg') body: body, }) .then(function (response) { - if (response.data.message && response.data.message !== clientMessage) Notification.text(response.data.message); + if (response.data.message && response.data.message !== clientMessage) { + Notification.text(response.data.message); + } + save(); }) } @@ -219,16 +222,12 @@ angular.module('habitrpg') return; } save(); + Tasks.scoreTask(data.params.task._id, data.params.direction).then(function (res) { var tmp = res.data.data._tmp || {}; // used to notify drops, critical hits and other bonuses - var drop = tmp.drop; - var crit = tmp.crit; - var streakBonus = tmp.streakBonus; if (drop) user._tmp.drop = drop; - if (crit) user._tmp.crit = crit; - if (streakBonus) user._tmp.streakBonus = streakBonus; }); }, diff --git a/website/server/middlewares/api-v3/response.js b/website/server/middlewares/api-v3/response.js index 335d51a2ff..e00d691fb2 100644 --- a/website/server/middlewares/api-v3/response.js +++ b/website/server/middlewares/api-v3/response.js @@ -1,6 +1,8 @@ module.exports = function responseHandler (req, res, next) { // Only used for successful responses res.respond = function respond (status = 200, data = {}, message) { + let user = res.locals && res.locals.user; + let response = { success: status < 400, data, @@ -8,6 +10,14 @@ module.exports = function responseHandler (req, res, next) { if (message) response.message = message; + // When userV=Number (user version) query parameter is passed and a user is logged in, + // sends back the current user._v in the response so that the client + // can verify if it's the most up to date data. + // Considered part of the private API for now and not officially supported + if (user && req.query.userV) { + response.userV = user._v; + } + res.status(status).json(response); }; From d6e7eb8c75eb4e8301618bbb744861e0dacc1f2f Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 21 May 2016 17:45:08 +0100 Subject: [PATCH 951/976] Removed unnecessary fields when updating groups and challenges (#7395) --- website/client/js/services/challengeServices.js | 6 +++++- website/client/js/services/groupServices.js | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/website/client/js/services/challengeServices.js b/website/client/js/services/challengeServices.js index a577d5f759..e5f4cfa2b8 100644 --- a/website/client/js/services/challengeServices.js +++ b/website/client/js/services/challengeServices.js @@ -59,10 +59,14 @@ angular.module('habitrpg') } function updateChallenge (challengeId, updateData) { + + var challengeDataToSend = _.omit(updateData, ['tasks', 'habits', 'todos', 'rewards', 'group']); + if (challengeDataToSend.leader && challengeDataToSend.leader._id) challengeDataToSend.leader = challengeDataToSend.leader._id; + return $http({ method: 'PUT', url: apiV3Prefix + '/challenges/' + challengeId, - data: updateData, + data: challengeDataToSend, }); } diff --git a/website/client/js/services/groupServices.js b/website/client/js/services/groupServices.js index 772c5e3814..aac51e813f 100644 --- a/website/client/js/services/groupServices.js +++ b/website/client/js/services/groupServices.js @@ -43,10 +43,15 @@ angular.module('habitrpg') Group.update = function(groupDetails) { //@TODO: Check for what has changed? + + //Remove populated fields + var groupDetailsToSend = _.omit(groupDetails, ['challenges', 'members', 'invites']); + if (groupDetailsToSend.leader && groupDetailsToSend.leader._id) groupDetailsToSend.leader = groupDetailsToSend.leader._id; + return $http({ method: "PUT", - url: groupApiURLPrefix + '/' + groupDetails._id, - data: groupDetails, + url: groupApiURLPrefix + '/' + groupDetailsToSend._id, + data: groupDetailsToSend, }); }; From 559c7bed94236a1646f22b37d144bd72ec502aed Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 May 2016 19:23:51 +0200 Subject: [PATCH 952/976] v3: do not saved populated user --- website/server/controllers/api-v3/user.js | 27 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index bb9ca76e2e..c04c3ded38 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -400,10 +400,14 @@ api.castSpell = { if (!party) { partyMembers = [user]; // Act as solo party } else { - partyMembers = await User.find({ - 'party._id': party._id, - _id: { $ne: user._id }, // add separately - }).select(partyMembersFields).exec(); + partyMembers = await User + .find({ + 'party._id': party._id, + _id: { $ne: user._id }, // add separately + }) + // .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save + // default values for non-selected fields and pre('save') will mess up thinking some values are missing + .exec(); partyMembers.unshift(user); } @@ -416,7 +420,11 @@ api.castSpell = { } else { if (!targetId) throw new BadRequest(res.t('targetIdUUID')); if (!party) throw new NotFound(res.t('partyNotFound')); - partyMembers = await User.findOne({_id: targetId, 'party._id': party._id}).select(partyMembersFields).exec(); + partyMembers = await User + .findOne({_id: targetId, 'party._id': party._id}) + // .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save + // default values for non-selected fields and pre('save') will mess up thinking some values are missing + .exec(); } if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); @@ -433,8 +441,15 @@ api.castSpell = { } } + let partyMembersRes = Array.isArray(partyMembers) ? partyMembers : [partyMembers]; + // Only return some fields. + // See comment above on why we can't just select the necessary fields when querying + partyMembersRes = partyMembersRes.map(partyMember => { + return common.pickDeep(partyMember, common.splitWhitespace(partyMembersFields)); + }); + res.respond(200, { - partyMembers: Array.isArray(partyMembers) ? partyMembers : [partyMembers], + partyMembers: partyMembersRes, user, }); From 58485841da50c7444f047b01b921b0aeb98a2777 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 May 2016 19:25:31 +0200 Subject: [PATCH 953/976] v3: correctly return user subset --- website/server/controllers/api-v3/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index c04c3ded38..eed3992dc2 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -445,7 +445,7 @@ api.castSpell = { // Only return some fields. // See comment above on why we can't just select the necessary fields when querying partyMembersRes = partyMembersRes.map(partyMember => { - return common.pickDeep(partyMember, common.splitWhitespace(partyMembersFields)); + return common.pickDeep(partyMember.toJSON(), common.splitWhitespace(partyMembersFields)); }); res.respond(200, { From 25511a8e64b7e7a9f11916a3a555e42097d265fd Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 21 May 2016 18:46:38 +0100 Subject: [PATCH 954/976] Chained party promises together (#7396) --- website/client/js/services/groupServices.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/client/js/services/groupServices.js b/website/client/js/services/groupServices.js index aac51e813f..916efe50b7 100644 --- a/website/client/js/services/groupServices.js +++ b/website/client/js/services/groupServices.js @@ -126,16 +126,16 @@ angular.module('habitrpg') Members.getGroupMembers(data.party._id, true) .then(function (response) { data.party.members = response.data.data; - }); - Members.getGroupInvites(data.party._id) + return Members.getGroupInvites(data.party._id); + }) .then(function (response) { data.party.invites = response.data.data; - }); - Challenges.getGroupChallenges(data.party._id) + return Challenges.getGroupChallenges(data.party._id) + }) .then(function (response) { data.party.challenges = response.data.data; + _cachedPartyPromise.resolve(data.party); }); - _cachedPartyPromise.resolve(data.party); }, function (response) { data.party = { type: 'party' }; _cachedPartyPromise.reject(data.party); From 0c20cf30d75b812c844267e7bc2f5e0b5b060be6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 May 2016 20:27:11 +0200 Subject: [PATCH 955/976] v3: $w -> splitWhitespace --- website/server/controllers/api-v3/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index eed3992dc2..4c02b09359 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -445,7 +445,7 @@ api.castSpell = { // Only return some fields. // See comment above on why we can't just select the necessary fields when querying partyMembersRes = partyMembersRes.map(partyMember => { - return common.pickDeep(partyMember.toJSON(), common.splitWhitespace(partyMembersFields)); + return common.pickDeep(partyMember.toJSON(), common.$w(partyMembersFields)); }); res.respond(200, { From 4e12cd293ce41f697c76b78e9f2824eb8343aa57 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 May 2016 22:54:27 +0200 Subject: [PATCH 956/976] use bluebird --- migrations/api_v3/challenges.js | 2 +- migrations/api_v3/challengesMembers.js | 2 +- migrations/api_v3/coupons.js | 2 +- migrations/api_v3/emailUnsubscriptions.js | 2 +- migrations/api_v3/groups.js | 2 +- migrations/api_v3/users.js | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index 6d066b3d63..a8fd48091a 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -10,7 +10,7 @@ console.log('Starting migrations/api_v3/challenges.js.'); require('babel-register'); -var Q = require('q'); +var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); var nconf = require('nconf'); var mongoose = require('mongoose'); diff --git a/migrations/api_v3/challengesMembers.js b/migrations/api_v3/challengesMembers.js index 650c7375ab..010aabd0a7 100644 --- a/migrations/api_v3/challengesMembers.js +++ b/migrations/api_v3/challengesMembers.js @@ -11,7 +11,7 @@ console.log('Starting migrations/api_v3/challengesMembers.js.'); require('babel-register'); -var Q = require('q'); +var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); var nconf = require('nconf'); var mongoose = require('mongoose'); diff --git a/migrations/api_v3/coupons.js b/migrations/api_v3/coupons.js index e329a6b676..02e3341981 100644 --- a/migrations/api_v3/coupons.js +++ b/migrations/api_v3/coupons.js @@ -10,7 +10,7 @@ console.log('Starting migrations/api_v3/coupons.js.'); require('babel-register'); -var Q = require('q'); +var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); var nconf = require('nconf'); var mongoose = require('mongoose'); diff --git a/migrations/api_v3/emailUnsubscriptions.js b/migrations/api_v3/emailUnsubscriptions.js index 5787cc3395..f042282d6f 100644 --- a/migrations/api_v3/emailUnsubscriptions.js +++ b/migrations/api_v3/emailUnsubscriptions.js @@ -10,7 +10,7 @@ console.log('Starting migrations/api_v3/unsubscriptions.js.'); require('babel-register'); -var Q = require('q'); +var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); var nconf = require('nconf'); var mongoose = require('mongoose'); diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js index 3230bec0d8..2d3475825f 100644 --- a/migrations/api_v3/groups.js +++ b/migrations/api_v3/groups.js @@ -18,7 +18,7 @@ console.log('Starting migrations/api_v3/groups.js.'); require('babel-register'); -var Q = require('q'); +var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); var nconf = require('nconf'); var mongoose = require('mongoose'); diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index 3a259250ab..14e331c3a8 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -11,7 +11,7 @@ console.log('Starting migrations/api_v3/users.js.'); require('babel-register'); -var Q = require('q'); +var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); var nconf = require('nconf'); var mongoose = require('mongoose'); From ee691c252b5135835543920cb008c2c8d1e4f9eb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 May 2016 22:57:19 +0200 Subject: [PATCH 957/976] use babel polyfill --- migrations/api_v3/challenges.js | 1 + migrations/api_v3/challengesMembers.js | 1 + migrations/api_v3/coupons.js | 1 + migrations/api_v3/emailUnsubscriptions.js | 1 + migrations/api_v3/groups.js | 1 + migrations/api_v3/users.js | 1 + 6 files changed, 6 insertions(+) diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index a8fd48091a..009c205e33 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -9,6 +9,7 @@ console.log('Starting migrations/api_v3/challenges.js.'); require('babel-register'); +require('babel-polyfill'); var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); diff --git a/migrations/api_v3/challengesMembers.js b/migrations/api_v3/challengesMembers.js index 010aabd0a7..971119831a 100644 --- a/migrations/api_v3/challengesMembers.js +++ b/migrations/api_v3/challengesMembers.js @@ -10,6 +10,7 @@ console.log('Starting migrations/api_v3/challengesMembers.js.'); require('babel-register'); +require('babel-polyfill'); var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); diff --git a/migrations/api_v3/coupons.js b/migrations/api_v3/coupons.js index 02e3341981..64071faffe 100644 --- a/migrations/api_v3/coupons.js +++ b/migrations/api_v3/coupons.js @@ -9,6 +9,7 @@ console.log('Starting migrations/api_v3/coupons.js.'); require('babel-register'); +require('babel-polyfill'); var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); diff --git a/migrations/api_v3/emailUnsubscriptions.js b/migrations/api_v3/emailUnsubscriptions.js index f042282d6f..d90525db16 100644 --- a/migrations/api_v3/emailUnsubscriptions.js +++ b/migrations/api_v3/emailUnsubscriptions.js @@ -9,6 +9,7 @@ console.log('Starting migrations/api_v3/unsubscriptions.js.'); require('babel-register'); +require('babel-polyfill'); var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js index 2d3475825f..dfd0616e07 100644 --- a/migrations/api_v3/groups.js +++ b/migrations/api_v3/groups.js @@ -17,6 +17,7 @@ console.log('Starting migrations/api_v3/groups.js.'); require('babel-register'); +require('babel-polyfill'); var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index 14e331c3a8..e27b12ab25 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -10,6 +10,7 @@ console.log('Starting migrations/api_v3/users.js.'); require('babel-register'); +require('babel-polyfill'); var Bluebird = require('bluebird'); var MongoDB = require('mongodb'); From 308e70432fc88c96ec01f69e106b50e55c255660 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 21 May 2016 23:22:17 +0200 Subject: [PATCH 958/976] migration: fix items --- migrations/api_v3/users.js | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index e27b12ab25..656b8f498a 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -115,13 +115,22 @@ function processUsers (afterId) { delete oldUser.id; // spookDust -> spookySparkles - oldUser.achievements.spookySparkles = oldUser.achievements.spookDust; - oldUser.items.special.spookySparkles = oldUser.items.special.spookDust; - oldUser.stats.buffs.spookySparkles = oldUser.stats.buffs.spookDust; - delete oldUser.achievements.spookDust; - delete oldUser.items.special.spookDust; - delete oldUser.stats.buffs.spookDust; + if (oldUser.achievements && oldUser.achievements.spookDust) { + oldUser.achievements.spookySparkles = oldUser.achievements.spookDust; + delete oldUser.achievements.spookDust; + } + + if (oldUser.items && oldUser.items.special && oldUser.items.special.spookDust) { + oldUser.items.special.spookySparkles = oldUser.items.special.spookDust; + delete oldUser.items.special.spookDust; + } + + if (oldUser.stats && oldUser.stats.buffs && oldUser.stats.buffs.spookySparkles) { + oldUser.stats.buffs.spookySparkles = oldUser.stats.buffs.spookDust; + delete oldUser.stats.buffs.spookDust; + } + // end spookDust -> spookySparkles oldUser.tags = oldUser.tags.map(function (tag) { From 7ef529988a130f2d2473f00f28d50c6501923b4c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 22 May 2016 00:00:05 +0200 Subject: [PATCH 959/976] update links for v3 --- package.json | 2 +- website/server/libs/api-v3/email.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 8f6bbd9d98..fa12cb4dbb 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,6 @@ "name": "habitica", "title": "Habitica", "version": "3.0.0", - "url": "https://v3.habitica.com" + "url": "https://habitica.com" } } diff --git a/website/server/libs/api-v3/email.js b/website/server/libs/api-v3/email.js index 490d02c4eb..fd2e166098 100644 --- a/website/server/libs/api-v3/email.js +++ b/website/server/libs/api-v3/email.js @@ -95,7 +95,7 @@ export function sendTxn (mailingInfoArray, emailType, variables, personalVariabl }, { name: 'RECIPIENT_UNSUB_URL', - content: `/unsubscribe?code=${encrypt(JSON.stringify({ + content: `/email/unsubscribe?code=${encrypt(JSON.stringify({ _id: mailingInfo._id, email: mailingInfo.email, }))}`, @@ -121,7 +121,7 @@ export function sendTxn (mailingInfoArray, emailType, variables, personalVariabl }, { name: 'RECIPIENT_UNSUB_URL', - content: `/unsubscribe?code=${encrypt(JSON.stringify({ + content: `/email/unsubscribe?code=${encrypt(JSON.stringify({ _id: temporaryPersonalVariables[singlePersonalVariables.rcpt]._id, email: singlePersonalVariables.rcpt, }))}`, From c9eebf0b84eded821783b26972360db4f8b090af Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 22 May 2016 02:20:25 +0100 Subject: [PATCH 960/976] Updated shortname validation to support multiple browsers --- common/locales/en/challenge.json | 3 ++- test/spec/controllers/challengesCtrlSpec.js | 7 +++++++ website/client/js/controllers/challengesCtrl.js | 2 ++ website/views/options/social/challenges.jade | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/common/locales/en/challenge.json b/common/locales/en/challenge.json index 7504e2ad9c..0fb8982246 100644 --- a/common/locales/en/challenge.json +++ b/common/locales/en/challenge.json @@ -78,5 +78,6 @@ "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", "userAlreadyInChallenge": "User is already participating in this challenge.", - "cantOnlyUnlinkChalTask": "Only broken challenges tasks can be unlinked." + "cantOnlyUnlinkChalTask": "Only broken challenges tasks can be unlinked.", + "shortNameTooShort": "Short Name must have at least 3 characters." } diff --git a/test/spec/controllers/challengesCtrlSpec.js b/test/spec/controllers/challengesCtrlSpec.js index 641df26a25..ec63ee20c4 100644 --- a/test/spec/controllers/challengesCtrlSpec.js +++ b/test/spec/controllers/challengesCtrlSpec.js @@ -344,6 +344,7 @@ describe('Challenges Controller', function() { it("opens an alert box if challenge.group is not specified", function() { var challenge = specHelper.newChallenge({ name: 'Challenge without a group', + shortName: 'chal without group', group: null }); @@ -356,6 +357,7 @@ describe('Challenges Controller', function() { it("opens an alert box if isNew and user does not have enough gems", function() { var challenge = specHelper.newChallenge({ name: 'Challenge without enough gems', + shortName: 'chal without gem', prize: 5 }); @@ -372,6 +374,7 @@ describe('Challenges Controller', function() { var challenge = specHelper.newChallenge({ _id: 'challenge-has-id-so-its-not-new', name: 'Challenge without enough gems', + shortName: 'chal without gem', prize: 5, }); @@ -385,6 +388,7 @@ describe('Challenges Controller', function() { it("saves the challenge if user has enough gems and challenge is new", function() { var challenge = specHelper.newChallenge({ name: 'Challenge without enough gems', + shortName: 'chal without gem', prize: 5, }); @@ -400,6 +404,7 @@ describe('Challenges Controller', function() { var challenge = specHelper.newChallenge({ name: 'Challenge', + shortName: 'chal', }); setTimeout(function() { @@ -419,6 +424,7 @@ describe('Challenges Controller', function() { it('saves new challenge and syncs User', function(done) { var challenge = specHelper.newChallenge(); + challenge.shortName = 'chal'; setTimeout(function() { expect(User.sync).to.be.calledOnce; @@ -432,6 +438,7 @@ describe('Challenges Controller', function() { sinon.stub(notification, 'text'); var challenge = specHelper.newChallenge(); + challenge.shortName = 'chal'; setTimeout(function() { expect(notification.text).to.be.calledOnce; diff --git a/website/client/js/controllers/challengesCtrl.js b/website/client/js/controllers/challengesCtrl.js index aa36cb32a8..bcc62eca72 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/client/js/controllers/challengesCtrl.js @@ -120,6 +120,8 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.save = function(challenge) { if (!challenge.group) return alert(window.env.t('selectGroup')); + if (!challenge.shortName || challenge.shortName.length < 3) return alert(window.env.t('shortNameTooShort')); + var isNew = !challenge._id; if(isNew && challenge.prize > $scope.maxPrize) { diff --git a/website/views/options/social/challenges.jade b/website/views/options/social/challenges.jade index ac5c71fd4c..ab84189f6f 100644 --- a/website/views/options/social/challenges.jade +++ b/website/views/options/social/challenges.jade @@ -145,7 +145,7 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') .row .form-group.col-md-6.col-sm-12 - input.form-control(type='text', minlength="3", + input.form-control(type='text', ng-model='newChallenge.shortName', placeholder=env.t('challengeTag'), required ng-disabled='insufficientGemsForTavernChallenge()') |  From 81a30d1f20926b5437cc3e4250c977a8a9f68071 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 21 May 2016 20:55:03 -0500 Subject: [PATCH 961/976] Docs changes (#7401) * chore: Clarify transfer-gems documentation * chore: Clarify api status route documentation * chore: Mark webhooks as BETA --- website/server/controllers/api-v3/members.js | 2 +- website/server/controllers/api-v3/status.js | 2 +- website/server/controllers/api-v3/user.js | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index f85b7d86e5..37bc3c5d27 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -290,7 +290,7 @@ api.sendPrivateMessage = { }; /** - * @api {posts} /members/transfer-gems Send a gift to a member + * @api {posts} /members/transfer-gems Send a gem gift to a member * @apiVersion 3.0.0 * @apiName TransferGems * @apiGroup Members diff --git a/website/server/controllers/api-v3/status.js b/website/server/controllers/api-v3/status.js index 94bcf63ed0..68c232463f 100644 --- a/website/server/controllers/api-v3/status.js +++ b/website/server/controllers/api-v3/status.js @@ -1,7 +1,7 @@ let api = {}; /** - * @api {get} /api/v3/status Get Habitica's status + * @api {get} /api/v3/status Get Habitica's API status * @apiVersion 3.0.0 * @apiName GetStatus * @apiGroup Status diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 4c02b09359..c3b03f620d 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -904,7 +904,7 @@ api.userOpenMysteryItem = { }; /** -* @api {post} /api/v3/user/webhook Create a new webhook +* @api {post} /api/v3/user/webhook Create a new webhook - BETA * @apiVersion 3.0.0 * @apiName UserAddWebhook * @apiGroup User @@ -927,7 +927,7 @@ api.addWebhook = { }; /** -* @api {put} /api/v3/user/webhook/:id Edit a webhook +* @api {put} /api/v3/user/webhook/:id Edit a webhook - BETA * @apiVersion 3.0.0 * @apiName UserUpdateWebhook * @apiGroup User @@ -951,7 +951,7 @@ api.updateWebhook = { }; /** -* @api {delete} /api/v3/user/webhook/:id Delete a webhook +* @api {delete} /api/v3/user/webhook/:id Delete a webhook - BETA * @apiVersion 3.0.0 * @apiName UserDeleteWebhook * @apiGroup User From e3c79fbdfa3fabc7ef239653c49dd54d0ad83937 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 22 May 2016 02:55:45 +0100 Subject: [PATCH 962/976] Added tags update route. Added sort to user service (#7381) * Added tags update route. Added sort to user service * Change update tasks route to reorder tasks * Fixed linting issue * Changed params for reorder tags route * Fixed not found tag and added test --- common/locales/en/tasks.json | 1 + common/script/ops/sortTag.js | 5 ++- .../integration/tags/POST-tag-reorder.test.js | 44 +++++++++++++++++++ .../js/directives/hrpg-sort-tags.directive.js | 2 +- website/client/js/services/tagsServices.js | 9 ++++ website/client/js/services/userServices.js | 5 +++ website/server/controllers/api-v3/tags.js | 35 +++++++++++++++ 7 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 test/api/v3/integration/tags/POST-tag-reorder.test.js diff --git a/common/locales/en/tasks.json b/common/locales/en/tasks.json index 5b32829f81..b9180af3bf 100644 --- a/common/locales/en/tasks.json +++ b/common/locales/en/tasks.json @@ -73,6 +73,7 @@ "clearTags": "Clear", "hideTags": "Hide", "showTags": "Show", + "toRequired": "You must supply a to value", "startDate": "Start Date", "startDateHelpTitle": "When should this task start?", "startDateHelp": "Set the date for which this task takes effect. Will not be due on earlier days.", diff --git a/common/script/ops/sortTag.js b/common/script/ops/sortTag.js index 29756a8b82..c1fc42f330 100644 --- a/common/script/ops/sortTag.js +++ b/common/script/ops/sortTag.js @@ -7,7 +7,10 @@ module.exports = function sortTag (user, req = {}) { let to = _.get(req, 'query.to'); let fromParam = _.get(req, 'query.from'); - if (!to || !fromParam) { + let invalidTo = !to && to !== 0; + let invalidFrom = !fromParam && fromParam !== 0; + + if (invalidTo || invalidFrom) { throw new BadRequest('?to=__&from=__ are required'); } diff --git a/test/api/v3/integration/tags/POST-tag-reorder.test.js b/test/api/v3/integration/tags/POST-tag-reorder.test.js new file mode 100644 index 0000000000..0710cecf3e --- /dev/null +++ b/test/api/v3/integration/tags/POST-tag-reorder.test.js @@ -0,0 +1,44 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /reorder-tags', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + it('returns error when no parameters are provided', async () => { + await expect(user.post('/reorder-tags')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns error when tag is not found', async () => { + await expect(user.post('/reorder-tags', {tagId: 'fake-id', to: 3})) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('tagNotFound'), + }); + }); + + it('updates tags', async () => { + let tag1Name = 'Tag 1'; + let tag2Name = 'Tag 2'; + await user.post('/tags', {name: tag1Name}); + await user.post('/tags', {name: tag2Name}); + await user.sync(); + + await user.post('/reorder-tags', {tagId: user.tags[4].id, to: 3}); + await user.sync(); + + expect(user.tags[3].name).to.equal(tag2Name); + expect(user.tags[4].name).to.equal(tag1Name); + }); +}); diff --git a/website/client/js/directives/hrpg-sort-tags.directive.js b/website/client/js/directives/hrpg-sort-tags.directive.js index 93fb115116..9a9e3d49bb 100644 --- a/website/client/js/directives/hrpg-sort-tags.directive.js +++ b/website/client/js/directives/hrpg-sort-tags.directive.js @@ -19,7 +19,7 @@ User.sortTag({ query: { from: ui.item.data('startIndex'), - to:ui.item.index() + to: ui.item.index() } }); } diff --git a/website/client/js/services/tagsServices.js b/website/client/js/services/tagsServices.js index a31ecc155e..2a430282b6 100644 --- a/website/client/js/services/tagsServices.js +++ b/website/client/js/services/tagsServices.js @@ -34,6 +34,14 @@ angular.module('habitrpg') }); }; + function sortTag (tagId, to) { + return $http({ + method: 'POST', + url: 'api/v3/reorder-tags', + data: {tagId: tagId, to: to}, + }); + }; + function deleteTag (tagId) { return $http({ method: 'DELETE', @@ -46,6 +54,7 @@ angular.module('habitrpg') createTag: createTag, getTag: getTag, updateTag: updateTag, + sortTag: sortTag, deleteTag: deleteTag, }; }]); diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index 4dfdb84e49..70b8821c6d 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -267,6 +267,11 @@ angular.module('habitrpg') Tags.updateTag(data.params.id, data.body); }, + sortTag: function (data) { + user.ops.sortTag(data); + Tags.sortTag(user.tags[data.query.from].id, data.query.to); + }, + deleteTag: function(data) { user.ops.deleteTag(data); save(); diff --git a/website/server/controllers/api-v3/tags.js b/website/server/controllers/api-v3/tags.js index 69117e6fd7..250c343537 100644 --- a/website/server/controllers/api-v3/tags.js +++ b/website/server/controllers/api-v3/tags.js @@ -113,6 +113,41 @@ api.updateTag = { }, }; +/** + * @api {post} /api/v3/reorder-tags Reorder a tag + * @apiVersion 3.0.0 + * @apiName ReorderTags + * @apiGroup Tag + * + * @apiParam {tagId} UUID Id of the tag to move + * @apiParam {to} number Position the tag is moving to + * + * @apiSuccess {object} data An empty object + */ +api.reorderTags = { + method: 'POST', + url: '/reorder-tags', + middlewares: [authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + + req.checkBody('to', res.t('toRequired')).notEmpty(); + req.checkBody('tagId', res.t('tagIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let tagIndex = _.findIndex(user.tags, function findTag (tag) { + return tag.id === req.body.tagId; + }); + if (tagIndex === -1) throw new NotFound(res.t('tagNotFound')); + user.tags.splice(req.body.to, 0, user.tags.splice(tagIndex, 1)[0]); + + await user.save(); + res.respond(200, {}); + }, +}; + /** * @api {delete} /api/v3/tag/:tagId Delete a user tag given its id * @apiVersion 3.0.0 From 1a19c9d2a620a1af125d1f67851454ae0d53f53f Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 22 May 2016 02:55:52 +0100 Subject: [PATCH 963/976] Added password confirmation when deleteing account (#7402) --- website/client/js/controllers/settingsCtrl.js | 17 ++++++++++------- website/views/shared/modals/settings.jade | 6 +++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/website/client/js/controllers/settingsCtrl.js b/website/client/js/controllers/settingsCtrl.js index e302572880..7e78a8b095 100644 --- a/website/client/js/controllers/settingsCtrl.js +++ b/website/client/js/controllers/settingsCtrl.js @@ -177,13 +177,16 @@ habitrpg.controller('SettingsCtrl', $rootScope.$state.go('tasks'); } - $scope['delete'] = function(){ - $http['delete'](ApiUrl.get() + '/api/v3/user') - .success(function(res, code){ - if (res.err) return alert(res.err); - localStorage.clear(); - window.location.href = '/logout'; - }); + $scope['delete'] = function(password) { + $http({ + url: ApiUrl.get() + '/api/v3/user', + method: 'DELETE', + data: {password: password}, + }) + .then(function(res, code) { + localStorage.clear(); + window.location.href = '/logout'; + }); } $scope.enterCoupon = function(code) { diff --git a/website/views/shared/modals/settings.jade b/website/views/shared/modals/settings.jade index 8a263f2df0..5063173a96 100644 --- a/website/views/shared/modals/settings.jade +++ b/website/views/shared/modals/settings.jade @@ -55,11 +55,11 @@ script(type='text/ng-template', id='modals/delete.html') .modal-header h4=env.t('deleteAccount') .modal-body - p!=env.t('deleteText', {deleteWord: 'DELETE'}) + p!=env.t('deleteText', {deleteWord: 'Your password'}) br .row .col-md-6 - input.form-control(type='text', ng-model='_deleteAccount') + input.form-control(type='password', ng-model='_deleteAccount') .modal-footer button.btn.btn-default(ng-click='$close()')=env.t('neverMind') - button.btn.btn-danger(ng-disabled='_deleteAccount != "DELETE"', ng-click='$close(); delete()')=env.t('deleteDo') + button.btn.btn-danger(ng-disabled='!_deleteAccount', ng-click='$close(); delete(_deleteAccount)')=env.t('deleteDo') From 942ee522a1592876f99a40052d950afece8e0add Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 22 May 2016 03:58:40 +0200 Subject: [PATCH 964/976] fix production logging --- website/server/libs/api-v3/logger.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/libs/api-v3/logger.js b/website/server/libs/api-v3/logger.js index 69e35abf97..ed1353e8ad 100644 --- a/website/server/libs/api-v3/logger.js +++ b/website/server/libs/api-v3/logger.js @@ -10,7 +10,7 @@ const ENABLE_CONSOLE_LOGS_IN_PROD = nconf.get('ENABLE_CONSOLE_LOGS_IN_PROD') === const logger = new winston.Logger(); if (IS_PROD) { - if (ENABLE_CONSOLE_LOGS_IN_PROD === 'true') { + if (ENABLE_CONSOLE_LOGS_IN_PROD) { logger.add(winston.transports.Console, { colorize: true, prettyPrint: true, From 4d7444e46ff81d472e3e317e21c1d8fd479dfaf8 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Sun, 22 May 2016 02:53:24 +0000 Subject: [PATCH 965/976] feat(commit): push --- .../spritesmith/stable/pets/Pet-Lion-Veteran.png | Bin 0 -> 3801 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 common/img/sprites/spritesmith/stable/pets/Pet-Lion-Veteran.png diff --git a/common/img/sprites/spritesmith/stable/pets/Pet-Lion-Veteran.png b/common/img/sprites/spritesmith/stable/pets/Pet-Lion-Veteran.png new file mode 100644 index 0000000000000000000000000000000000000000..83e3f90f39cd2eca1cfeb71a6f428764218b6e6c GIT binary patch literal 3801 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 P00000NkvXXu0mjfq_jD? literal 0 HcmV?d00001 From 0a396c461d4dbb33b955eedac1fc9e8580e3bc25 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 22 May 2016 05:11:38 +0200 Subject: [PATCH 966/976] empty commit From 57754adee791b8697df91786efa05b521d59b41d Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Sat, 21 May 2016 23:40:36 -0500 Subject: [PATCH 967/976] feat(maintenance): post-downtime news & awards (#7406) --- common/dist/sprites/spritesmith-main-11.css | 10 +- common/dist/sprites/spritesmith-main-11.png | Bin 156807 -> 156933 bytes common/dist/sprites/spritesmith-main-12.css | 780 +++++++++--------- common/dist/sprites/spritesmith-main-12.png | Bin 125673 -> 126575 bytes .../stable/pets/Pet-Lion-Veteran.png | Bin 0 -> 3801 bytes common/locales/en/pets.json | 1 + common/script/content/index.js | 1 + migrations/20160521_veteran_ladder.js | 76 ++ website/server/controllers/top-level/pages.js | 2 +- website/server/models/user.js | 1 + website/views/shared/new-stuff.jade | 46 +- 11 files changed, 510 insertions(+), 407 deletions(-) create mode 100644 common/img/sprites/spritesmith/stable/pets/Pet-Lion-Veteran.png create mode 100644 migrations/20160521_veteran_ladder.js diff --git a/common/dist/sprites/spritesmith-main-11.css b/common/dist/sprites/spritesmith-main-11.css index 0c5b59a361..6c97f196e0 100644 --- a/common/dist/sprites/spritesmith-main-11.css +++ b/common/dist/sprites/spritesmith-main-11.css @@ -1960,31 +1960,31 @@ width: 81px; height: 99px; } -.Pet-LionCub-Base { +.Pet-Lion-Veteran { background-image: url(spritesmith-main-11.png); background-position: -1640px -700px; width: 81px; height: 99px; } -.Pet-LionCub-CottonCandyBlue { +.Pet-LionCub-Base { background-image: url(spritesmith-main-11.png); background-position: -1640px -800px; width: 81px; height: 99px; } -.Pet-LionCub-CottonCandyPink { +.Pet-LionCub-CottonCandyBlue { background-image: url(spritesmith-main-11.png); background-position: -1640px -900px; width: 81px; height: 99px; } -.Pet-LionCub-Desert { +.Pet-LionCub-CottonCandyPink { background-image: url(spritesmith-main-11.png); background-position: -1640px -1000px; width: 81px; height: 99px; } -.Pet-LionCub-Floral { +.Pet-LionCub-Desert { background-image: url(spritesmith-main-11.png); background-position: -1640px -1100px; width: 81px; diff --git a/common/dist/sprites/spritesmith-main-11.png b/common/dist/sprites/spritesmith-main-11.png index 1a3e1517449e0dfe18b775cfdd8eb683b3b0d800..8f1f71ebf3f8894b42c38996a821660174f70e54 100644 GIT binary patch delta 84482 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Þ zOR-ysS;)i~kVIr?iK-NZ(6j|vg`-<}F>xu)C&xLk2hepJkk8^0!F#>^?XE9BBG}Kc zK6U4G$0_L;z@euBH36D&5bTLDwmY1%l6`#QMVF?HBvS_!73^dz?Fl|s$?OT*8#Wf8%o+L%IVJ8Osvs1~ifyWQ&<+FNJDQ0zfQ^O7Ul&4D! zHdWnvOsWB3Jx%IKlCBi7VV)yrtd3{GcEcA#9E}rDIi^IGlPL&O#t-}NVvxO4rNHvC z75@k#HfJ_X0AvJy#tUJO>E^o7Uk>ftn~**L15NpiwRoTg^o-tJIi$6gucLU8a1p2k zaZG{RTJ+D+pXwM)I%ljPdi3_sz>l8r1<-goYgtut`#k^kqd44v{M$CMy7A}y=D2WeEM3O)P)|j$)4`D>8pNIO!^&;#)O0ZWJZwgi1766ja0uS zy*f{7?<59eX zZ1kWb@#%`YDT75_A6$HALns ze!ZHpD~j+&taO)1Zxu>sssE@yR5~ykw#sHK^cv^jJPk!9p>hL=sIQvZm^0RzU#sf) zOWg5va9^ROY;gqFgPGsMwkcxJd=xa#r}^KpLm%)yEG+w4l|Tf`f1$^NIQfk%A^y?6+zD z43`ZEP(KMb{~i(LvRM+ca=RZcCByzy5#3sDQZ{U#Xe*}~{e_;uJJWfmzwpQr^%mR$ zmO5|hwjR&>%EG}gsi<{g{rs7#6!)fDOTVS(ywZj-8zLrv%_%aaN|e%DspUpA8D+2R+Y zxOAS4SX$uYl(Pir4^KZqoLt+zw9`cJh7*0s9s;>G4TK;N9~M4=+U=FWJVew1d2mG8E7WXAg1-psFRnu zoD~^v_OMa0a_iN9${eL914`{Gw_+asjJCMC=00WqVx>>1Y^c*6ud3m-Kyu0F)hy!$ zbm=k3;Uc7(QPfiE`+6~vzFh#r60T>b=cc*{E_bn>DiGeFBGoVFx;%zFlqouL(s5?( zhQeDm9usncyztIR;QSQ+Y(Dja{RTdWpcn4pq|4H1RI(Yww^GE9xIC`SWC$_1Z%M1KKKh}#h}MFO5*YsZoAzO3NEN>xA|CH1ykv_7 zHAMs8!N6|H-w#-zHw1S?vuDLLVdr(yLU*_Fp9Qee&^2(p2#X{2H8y9`AaHfb1}Hyo z{op`JjskQQ_u!#DYSq>H%J`n2<^u*io}o065$&IF$^Jn?pl2mqKQ;5?;}iqwEZw^C zG@IrS?@GXzA~}y#A^qW|h;&>w420jqDaRGV?<169z2SX%qzU4uU`lI3$l&@v_YQuh z&6=DtC@OU8F2gM-Mb$@bijIg7=N%tc(R2hI{k50QG!H|EEy#p$*+uB4H}Bq1gede< zBnu8ZJq$EV)=_vNm0aqgKBIU{qQ#DLDekeIW&i4<5pJ}QlNPL{0B!guNVU|4>-p$R z-pFx!tLu8*=v}W$6Nn(#K^-xO6$)F6s2qI&L&!_EGd70ev$+fRyn|Y7#O9TDBWbq2 zL_xCg?q9p#xo9ZPggtPsy>UlPm7j#&>gaL=K=ut!Q!R*oO^udE5QZo1yTc-eh6Dtf zYu^;7pxKjchZr|>vbK61fjH9>1^_do@GeAxIqEOz=MzkIGW7P${s)v@&Nb3_-;ZbKETY8Xa+!mDjIp^xe#+_ap)Ys{&yE zgOvrX(*zxQhJ?5#vIIP%AHv*KlG-z=BU|_O;Oy^&RFhDfJ$uu$+sc4nP=8Y+X=KD+ z4TjqzZjlG;sTv6nGG`nmSo$D-{1wn$+5Ahu*Hll>_JW8Vr)I*t9S`o?CeuFvLfSkb zmY~D0dt|FPcy^HjIrofx1VZ(oM#VXq&}%;(8YSQ8!K@U z*{v)}LFS0Y4lAP>{ZJV8KVKPMh15Qz1ste^1&xu(Q9B71y4==Wvy0KRYqDaQGYLlX zgT)#NS(GYXe86;5NMOFc2#`HeHe|e%ZEYbDrK?;C_!G?|6jrZ-{vh8<;O(>?kSM^e zSFGSsuWJl%HbZF@K}L>01M@Jv^I<6d)MOHB_#ZU|UCSMN)O7NF^hQ;@8!xq@$+7;b zdyvum)c>!sH-U$84gbb7r5sx;BF1)9_9I8KS5loMmF$DcRz_rmu})MJWl4-EPWyrx zOO~;XR)fJwF=R;$(u`%o*z;b`sB`M~`+xrL+ouoanPDLny*tDf?3eX-~=`(-(5Fmz>k} z^G^6C^nBg1vFz)D9$nx`2^Ag~)3-?gZo>(ye$9^8eX40<$uiUfV-79r1|t*@uiX9P zl#x29E#YnnjU^^U?p9zaz1_hvRyD4QL#EsJYRmrZ{gD4V=OLf0xYHhz{q;*d2xH`I zah5P>iJUoudw(QG*g&}1QkB;Sw+-X5_wI>e+Qx(@(k3wDchgjQtZVs%?RF=C=TxT< zdK4OacNu0i-h>RmN=VsJynD?1$Ya6 z&!V|4;{I^c%m08={(Z!Xt+tPa#M%Ex35TJ&-+5o0e@!HU-+1h_pLJc#= z*FL)4y)A&5E`@iW))Dv4?JYy2oPin7uodhrbnem8uMBaI z6xyVPFMjgHK}ce9t8Uwh3YEDt1ZWHQ9VzYtcx!G4STvZ=`14eNx4hCZ<07UKp|ItK zXioyfeGNk`m{L*~8;h;+UWbEq?-~Ty2O9whw>RXav*OE10~FkVG~1H8V=cA7H3Vo` z2~OYQ!O4O?A$)(^vDjMfo2NUi`SS|0ij-&pqrKx~C8I)jHJF!@BN;G!FWxV?wfH<0 zJOu;GmCn$j7+x4Ek{B7vw|RX!JrhAdwp+(b!h9a#UUTd?k|{z1+7PUYFdrEY2;Pk} zd?Vb(hKk+r_=c7UpDZI5lfpmF<^UZ~!Q%`hiRcm2UQy8-To7Igi}?_+k;}P$~-Ji=TB@m-LuaU=UPJARhb};#k>Brdt1j^~wVuoG9X)ApocF z6TrLIyqXxPOu5s>+tXvt3ueOWf)3Vl-YLkQ38;IdC+uOc%U0)eUrua%&f<3XW@X5) zX%({5?L7OkGiP0~=sQ=KPL4aQJZ^D9mc&05a7Jrp%OS!s^NUZn)tSho0_ovkSVa`Z z9{Tkl1_EkkcSon&gNod`0IWpICe%jwj7B&EVLc-2UbPT$Cx=gEgA&$^bU+kXT4A?6 zz`R1RGi8CuVFL#y3ghvCX8oKDe32f2Vx5AV-$5q3&FZ~2W7$C4faNww_#qb#IIm#U zvg`a!0v9;yu8|B_MhYUbv``;_ipM@gqg4+BSFoRw4=Dj`A~BlAgHKZ#VT&J&b0FW* z=$gX65Uz8%UUqdWA5)(dGinh9^L7jYyA@}4d}#^8TW7kZ=@f;RLL0ogci93713+7@ z0ayQ^3`GIm1@R_zfE}mjV3eET>;8ba1}9Az32=R(iE*~xJ?bt?IsjgVb-`b>$sA9k z><1E*f}z(rY14DO6D+j)QcIM&ZQZ7=k@BCUN^S0kR5+UoPUQKM?qd-n=|CYed<`h% z39FSmB=N8uWP9eWFIMn?Z7p#YlK3+!OWY!PM4hCPi1ksx%iw*3hl_x`H|a>9`)%Mf3AwXXTsX+5!0O|MQ)I&=EBnhy-iLM z-VhwweN+%C+h3{)o$5%JTH3Jks94*VE`l#~Q6%K?m!D>CnsA{C%*w6rk0T)#yczxj zf1A_?C>09ST9`&s!b??XMH)++yR<5MFyOM<`~o9C$3Sy!W;AoF zw?k=(ty23aRq)5uEiMGIgM$|z9e9J%3|*?V;$AF4d9yqF$-Nf^o@&oN{C>-xd+a!N zQwWCp4U@em`%F2hW_Pv|fgmlE`>-202if$^;lGXYle>+8pmfGUr1%XRQc?W;sUsUJ z02Q2cblM^*sod}_P#iD8$f!*8*)6HMe5fsMQ!_Z|;{ zP*Iabv{j1R;4PH*wRTs~LlsOh(^1|sU#oo7E~bYzCM-S*^%tmi*y3?5$_1ROshJP& z(eh$QgmQyAW%`Be=R;im@TQDL-=XlZI+pWN$~2@_z;*?q_evGOI}n(_4~@Te$)#Pd zgi<45sg?5Mky2Y+FFs1ELpwt30!$XHn_gHQ)LeQd2b+YonW8<6m_sJ#3jBTi;S;gWW1<(suR9`%cb3g?dPw>~(Ra zU2|5}+^bIwgSHS8OP=gw3Act;0K&+F)H_k*|<@_6^(4MYHTfYy+d?7xcJ z5W*~!jb?p9P{#iWJ$sbLq+Q1Xs6@=0;}cT`{BePZJPlIB{ukJX2LUaiQHYd|cx{?b zVhvmm@LWjiz66cmCH`%Mk`P5Q>;H#a@ldXn$ZctO5!oiFjlaQ}<%yBV$Fn<88&!+| z?Sz%y6+}4xLynIe{uAH@HQ%95?0W>1B$A9!E1{yWwldgI6n#jzPI|O(_CIWdEaOPh|WU@6W{(9V2fN`+)o`U)W@t~eg)M02q3UsNNo>Z2Kl9* z00i>9o7VuYi~EJ!nS$rrru2@ir;1x*Yl*h0mzl z%`D9FZ{%&ruHk&#VQJX^zCA)MS{7aGL+MBCq|x;A)tSRJbN5&wY_tg|57o=tzNaHt zg5!BFlvjWPftQx(6F4VCzw5Dg;xXX|cD)3tBQ4B(C|<3E{<3IH)x~nC-SXd?fObb7 z%H1Xkqa_s=IqtD!FuP*@B4h97@(>~w53SJ z_Mw-vv#SYsSabkz{kl(=zv7`Z-f3~x;A2bg0;fB;J)+rGDFP*{^*6*-tk_ z8s!XY^pn%us0jsCV9D>Af)l zqpYpw(I){tdE&z0!R}V0pu;-Q8@!DvB~%giTafm>?^QmK=i_1SfE)ApEJ#Zeflh!_ z`u%sr5o33uga|Z#&!6;`yl(cL;qjfBAjteshp;s#4+UnH+S<~~AWur@gJayA_7c2C z$pJu`*B-FAJfn|4(;QGK3|JgO&r+H5SKsvib7yg-i3D@R5(EP;A<-NG;=hwcejW#B zc`)jCL9maeC%0p!>X>ohK8jy-l)LAY?iMK0J3_Me%T1)sjXX1!9>ND5^uh{QVY?hFrQSB=Ktx zFFPSGNY!s%nj!g}OY-Bcw{-OlZIPz3>AO??!oH_h_z}+nqg-!s2T7Xo{U9Dk2HKxO zpM{{14c#*<$l3hW6L9Sm93aJRlL`NrRM|9SF5CP~ukY@sNd7KwD4j6fiT@^lSUUx+ z2<}Fh{yDDkj1@Te2GNgQ-$vrJb|SvWU2q?9~)N+c`Ea)(_c9M2@#Mh)WHoB ze%kZ{Q!M-4pjEksYzU>VfqbMOk{Nf?^CA#P-3NWN3}FM}+91*(D3UX8^l18Nif^WNx4q|a!gUyexp+L&+rI)od0^lbYg>66Xd8wb*H(Z6iekCXYEKQi%H!7v% z*EI&*hUJsA_Q(s5tB^l3>DRuq$4`EHeh&L6aoHJY0$o&tXv;VERFb^16v zWQkxCFByxcfdZaA0&kLKj1xVu||(PYh+3 zkUsvu?4&^?!gb))==h~oezEaL^YEqtEaXj}`lRnQnoFtK_eX7Jh7ziNNfv$+KVR@1 zdk{$SzP73E6B`L7h$0l2`%q-4@>rrO0i6YlEx;vXY@=Ny_rrQJ$R^yWW)D3Uzd@G# zw;ewfTsHC(h#mI9I@I<1v2C-HK~>q`Nd*r9r$U>}KV4>%Dqzi_Ak773N<7}c5d8Pe z!So2d7e?TJqng*v*U3Zw_{DS=6w!FntSj(D-LXSvT13C!Q5%+nBSR!8p~MO5BUvfK zFH?+kU+jmn#nf%bC0QKdfz*H_QzJ+{ZFYW;OKyJ1RiP3HDR=XDt8sIt+?KMwe9P0d zkOC2%;IaimU}cC}>lgx!?*MBGipr z_fZ${Tk{2XBRMG-JDNUb$OE59_tMWk6-_`hyhh z(9|F-`;}aC&HhK$>!pVLcOW2T+MvD>^cj*gMd92rbQ5=MHhHR&taKrlS~V>OK(lij zD5v~MAzHAC7n=|1lJtCbtQy;HbO<8k(*a-a$#xxus(hY})!7oXUcE;JTNsH`iGU|T z)WeiJsQ;z%AvWu|H3&JVz1aIGXC4L&XO)YCvmSz!?QjyH@{}B_X1$|+!>X@+3NQhL z;CeY(!7L?pt6ikJJ}p0!oH}O*`;eyO;XxfSrUT>ERIGcJXGZ8%!Y8-(LFn!h4KHQS zc6yuC8#is&i-7_)O-vRTWt^bLZjyNc8;?D%wh0V_ zP<~|@h_dI5Y`a|fiwpV`d1A_tt0v$-u%|Eq3HQ{)#JGcXReww=*cwLMhD7zw?Ho?P zjoVuWTp6+-A^0&V`;BncHkHp2RG<)%HZX=TJsA`WVoV-u+}t(S*ilZl-64cmqiM2l#!@3|)Gf-*~7{gj~bY2(znC1fis12RFoU zY;ZMJvhuJ_YjE+W$&FO9Fx*^Y;ib4w!&N&-?$W4=vPOhScXu+$yV5m<*Zxt*Fr%Mg zO+3D&)%p82-x>+gE11w)s(4Az*@WC}flk5g+2s)aenHG<^Zky*dl6e#C=DUQY#cpT zx4Z3n^nOjc!V)CxkyqdZ; zjW;yT+%jjxXz#l~{d%M@SW?KdH_PP0Q{Z~zGnISkin8;M+bh)Wftsb@Ro^2}Y{`V? zm~~Dg3U6~b)-(3;n&+Qs<1Uhgkes#KziOn@md+jbBz$dh*!Fm~-{o6Bf+qy`&Wu8C zs=$6k)B;Mj_bv&ZU4!3{>rr}LV(EXY3~SBQS3y|Rx(clgm_Ya_65J<5=O?OmNK_OY zzcp-N48v@&VCekS0@3oEsTy{KM~?p+YT70jtCYE$kg1|c#*R9e#t0~0sU?nkJypcJ zW5{45LJ=K@0fZ3ex5$!Y_K}ihgAbTTW5h1TJ~_|mJ`cLX!8fjrvt{H{Kj;qqij0CI zYMUe8*8lzRdce1gQdbMjSob-C$dF$<`HuBO+qILm-#`;Nd?3MejD_H|&E+ml8>X02 ztdH4^@mB6{6iJV$uI&riE>>r3@j_Z**E&I*nWtY z@A$!%qIkdY>`tV-y_EP{oBmP@Rzq^4jDEbI}7E1Tl?0G)rC}ycj-#&Td}nw zJR3u#3;y0Dp#{hiZah;d(^KW~8u=k4OG>@x;;15cXR4WtAc8$nf73VYp!uz7|Grm! zeegO!UBwD9iulCM+HvmTbja1T$)pmOx9h0oB7CRF(}VN9P7o5EbMd5_B5xT2TO@`n z`+=?%44Xg5C|sq$`o;P71xqE4p+PLhPo@w%I<}+Uu5jb*eD~Z<`N0H&q%P^s>!PdE zWpTh3>kEbs+n&DLoxpKs#xh*Fg(_DDms)$X3`k}YCsJ*z{#KgvnWvG%j@L&n?w-8Z z=hzz@GSvn}Z&o$!2qyac2GWp+NtKNY6(@VCrz=aC;iV7b1;GhiW7EEfmGIjP`8!{z z%n`tYPr~}1T&$XW3b0u0Yt8fR5YOdIE9HaEVm^dkm1yHL4MEP~x&WEys(?71;BY`P zV?`nLT|2h&FOc6X%uTg7mCbipo&vu!a9%$N7IeHX2*ZP795;a4?6)*RW^=hG5FTBq z=*{v+HhCs+=FS8bc9vhx2q(u25jpeJiFDo^Y!Goft`xL@Xvm7A??on2z(uQF*F^YQ zFN>(-`lkaFY{M$u5}#dV^a18n(8zGzlU=HDmQ@l~-w8fUN0VxYt?IW+&8H#DZEhWh zwTCnvQd{QuhNU$ZM-gkQi+34hX?E&B&=Wpe94!YG?uBF6=Mgk@e z+=goG?J4bcmxy6AQr*JA;zxM!sRv1rtOXD(OgqRXYsD5%`t%3U;$wKcpY;K9wiV(b zND`KIEe;1@l;`5#*?|&3sNLEwbU*53BGwoRvzwBPAw5Y9CN4>J4mW%G-Jz`EEofQU zj{(eBa4T~`vX-Q3fW|fKBR#4dx|k}QTPz80{g@s|jOGs|L$d1mYL{gn)?^US zGbsP80K2zqEp6pYv*&|6eNABIPOD?(M;+#H+UjL&Z?)r&kLHfSdA=;g^f{bTOPgX( zn_@^1?qj$anpUzA@U)X4m4{4AXrzXPZOAQPeptIvvB=$YaoQd|b2}e!e6iu|JOJ4w znXuV+vc5z7kgMoO)9pA6NNWoVDqlXjj9GjAqyio&1A0*E&Cf_-Ik}Dl449O0HyV&7 zkz42Bd6dD8pQe~zb8f>*bDq|6IT95rXIq7kv^X1)ZxP9D81u`*PCF%UAcH}E_zNyZn2d5bO(rWSAyYz*{J6=HYb`=TXA z|2UB_L82h(>E|R?7knov*t<-Lh0YBPzyEK3CwN$`A}cRuCP&=i-fby!Hnkefm&k?S zDiV%)5(%Vvzv}vc@aZA5Y>P6G_yJ}O^oT3Eu!gGhSy7m?o&sDnl%xGgNGMacb922u zU}s1|F(jD^*lSagGXj34wwc4oyVRiu1DUsh9=s*ox<1J18=*E8S&8%o1Bwczj*zok zJ12-S(|cK3yIb~kM?k_W$Dt}GekfOW=PCFA-nkHsU%Du?I6nnUIUNLxa7`#~5G_oJ z=X3NgJcMxnl=9;cksVfYXQ|rvGmn5>*Lgj6^ZNw`Bh%4>TijYB?F1 z$}J2<>etA^U?|`+0Lox+Y(WUJsiL>(+Rtj+j!T$~e@Xj(2BB9W9a)={1Xx3wa0+1DV=VAG+P^N&56?M3>%h9lgdo_+!=a zn#)!IGdd@PUaE|L2y#iNZjud`kOieR+GSS;WbYF6w&)MF1kVc&VXM9@I1_601_&ny zxq}GEdd`)D*q^44qDkVT8xy3vZp*O`za z+^uFGy{<@c(*xs% zoX>58cpv-1OT++S*OQy?kJyf|EaXqu51KIdx`^u@?)j`fdM=Q1+!f$a#W5N6rHE0AMespF~nHFgJ#Vs$a|l zwSW6JmztdX12$w5P z%p6|SnPi3JTmiD_36N3R6Dx+BQ-LLre_k!r5MsaJB02>V;Oc5{0dPfl!6I?l$*shm z621+|vL^(8|8R<_zW`p5f`{4uI<#yQ#svSS*56;4?sbKV=;6g1_9Xn+TbK~Dmn4W} z!d8NlECp~uD;i5ZYXeyfxMLp>ij{4#uWfM*LjyRVR7jvzU{nFw9#!JXL6q8#cFu?u zl-y{I8`vbPt((I87Q$yR>%eeY+5vJTh|S0DNFJUkV$rOFfxZ{$9*Q3ZF2k5=henBN z$4jh4Qg)O}B361nmX^}h1S%ZlE94tq*mLRDkjT*E}VaB@jxP~OO2QWi`p?@o*+ci*z60ta9 zJn&#D&1oNrojz>jk#XeH#$~O4f4T2}C=K?AKAy_x^MSrh3+QOcO3T30!L+{aRNGWh zfeuwlMWy|@C68IP8c?XeN40Kw930(fk8UT``vU9eqR=ZiUjxXULVvik zQp^}6{KwHrgwgh5Mqkl7u|19jt|F{3b5&~CvvaP|mmm+vdR*6f2=n}R zL}Tiv+Xa)aLJ)4TJ@Gubp3v*FIk>dF9%_ z2ZLAQ?8AsXyHH{Wc;MZkM@#sb?)Qi}uao==NkGJ2UczO1d6!=LUha6N?(L7wbJGv! ziBA*FEA)^sjE`3s!!v&0kH z0ooM+P+}4&0*M;CQ36|*4*vxm_%}?w8hITEYz7kHv&JZKqNNOq|2HIJi|A8%54lkY z&PaK@G}w69?kXNM{2hZm0w-9)V1GU7&%sb$2=D&^mF4w~$HaKhDsTDs9*~W{o(#79 z_ndySd>e$nx5pPgcoN>r)mVDD-%s+t4(DMoyht@P&X2c3`2JS@>t4TLdAyrD)f$Ci zI8En0=!cY=W0`cWHkY%{vqR%coaTIFYN2Ha&D#W79kHjMQ1voF6jPeP-GM{iCY&>+$j7u;b;sS;X9-mJ^7ydq9j>*+eUAe94BoH zt};;=SGguph>4=$lldq+H4@`~+VS^^{RtC<{RtP*Lu+A=?eQ2v>n5jJhrgCr9N6&s z%`&o*(|bX?5Jm3HoUvVV=`P2+TL-FHwOgvnztk7Y6O9S{D;f8?4V$7EhsP5KW;gp! z$B+};M&(F-^<`IO3f-(|I>%xJt!MukmI`ms9}~o=YG9>o>(N22Z-_|*_s(_Ctmy++NPabv*)>%BMDv?_P5z4n zVktUdv984N9=6kOqQaDm=U92_qQf)i7Rz5vPB#TDnqtpgGl(Z-2GP(a{)=QE!&$?r z#h@!JPGeSx74N%D3AoP}vBnFRi>;+BDQ540BF&6gfu=x9 zqKAG%8V*k#C|jAlYjGo?3iA%y$3k15*O7gqFJLNM#v??2bb?5X;m?iyVJ-8(!q962 z-?``43A5!f<`3tajoxJ>JV7shj@MSo3T5BCerCikM}u1S@zXAD)whpI=A*beM&F~6 zx&G^e8n=}{l{HRIPxg_ODd#=~9r#SpV)Tu6owKU#eq`dXJAr~GQwGwnmyeWXyLEi@ zR58{p(9@Jh8u4RES^2RPO2#i_G0`$MZ=i z=9{B5m%k1WA?{Luz$gNa+*6X^=YNW?@aWOB2iOQ!0`*<` zna}yf^#UG4>E?v--0S|-;D>I?PVtKZtwcJgCDcLtTt{+pDe$K$qnE0hdE=ukI?R$3dclHg4p=W}#+{@uGZ@p?k&Nw%jEzqycgl}3r4+5E+V~wu zlK99HXmpaR3jVZI_%q{knavm;}!1UB!UD?pVGed!^?4-IFpBP6&F^|m6RJ%y$`~NVXYB_hF(d?T( z+1SxMENCZMsDKUZFALo90KQbc?lkoF(+yPW%N$y|)6kI&y0XeFx(J0IB*M{mRBiLEHOrf9qTs)IIUpc@QqfNHmk+ebe1 zKy`tN{1wFf<0ymV!;9+)y?p--T(ISni&=3mQloA(erg^TeQ_?_&El*NQFSN>4u&=pQ&s(Fu_*F)sIb!YEq) zM8HE&Altt1JzDHer?f`NKmn^Ir-@|ml_2OScvL^eoKRvQ@NVh&!o4Ill|8LnaRKKU zHi|@r8yPR5H~V$YveT8STvT_To(LF(&*yxIj(zcz`z-xfjNsmctnkOLB5lQ~=N7KG zGE(2m5x?Ea*rQ>lAhF8Xe*S|8^XTt5zn+xWRUfZ=kk`YR-xa%f(>+PWU zuE(;3ddHA=c2uwWrzbZvw8?X|>sC1n{$?d_yQbJ?v+{;G_NL$+^bZ?wL#(&*#BYTQ zZ%WWCixm}K9>|tRhTpsuC9YZTFUL0Vw9WAR^7w&h4xyD?lA9gyDAW*GljV&aUPgq`x+YXc|J+rI{6w>7lBMzCySfVjb1Eh= zszo01v=)QV`sY?~98+hgRks?uc9bD_Q1sEi*GcaWxP3`I=w63R&GY1!nlYG=Tl=MP zdRI3U?9=tFmrsftVePE@M0}aEUxC*DhYtKJ?sp z@rFVLqc2sW_oXBB=&`TlZCCW|B=yVLs(LH`ZZ!Tn;{Xu+f?DpZGkU0S1N~?vrBh3% zY;*&y|9E}F8(hz1F_C;=BCVw2OWpN!Nk`G*MmFI$FLQ3R&$Aj@gBQQrrTc35T#c*_ z>)v?TT^g$#w-|>N=(tX{+3wL;byS9yvRl>Zo!o_t*&3Bh_qSBE-0m{6=fCc zA|uxla?q)7oxO=R(`$I!ubYxHJaT3Bo;|?HUVh&pVaNloCl^PbPw#^_{X}j*7(^+r z0fuzw)t(!@wyX9Rq2D!(;dYAOB+Tg4pcQvKm~P=anp^W;MT-rg6*P5&B zL}~i*)Mqy_m2J!c&w%sCSXy1enycKBZo2q~Qse)}xn^{1nX^;AhM0AH$}twA6$;ZS zFwJJ&mf0?j(`&1Xy0M%r5MdTC8<}gNK)jHvwqI{7me6C%oL<}Z;rCznP*23p(5#nj z6|!7AhO0RzC#E+_^ff-#8?UW{GxKnw?5VDou_|Glhtr72L6HmCpc?0%H2$TDeH^=+ z>D2(4k!D>2+&QeCje|{DtXVQ$L7VD)uz^^slawuYOgvMer$7ElN8zTymaO*Ytt%6; zYv64KEZl~|&VO4oD`vF~+K6whQ3k}rzw>P(1d36j8xPZ30&WT8T6$#QWvRm)zV-m0 zd7p>xP`c>FFSi%P+R-;%@@wsteMD{DHWNuhJ0HK7Cb134dJW8@$BtGvf0oiM$zFaZ zWRPH1?EAEy#yWE3FE~L@ZNqS^VNi*2=q(C{5qltM&rX%E^IfxSzk%j^cdw&T-#jNI zXLN`KfAb0F?(iRa+wdut&TQ6^5lBgYnR0uu=yzsaFVQEcr|6nCWkbq_ygx-VJ3C1< zY;A56+Ea8CPAO<7waU`R2VVT-T|2jr7A|1pq~G4y^dd!2rbd_yxJz{^ z_Yb!OQy5^y1*iHO!K@s|=Aa2NkdxHg{cYXt`rIqkt#(GDmoRJF>h7i(5FOWp20w(E z&&O=OY2y=Nv*Lvpyqhfs+2$N`ZEzoMsp*=iqE6^Qb0_m_!t8ewMLBmUH6u&seGW;$ zqvbp?qw-G(*Y+Iqa?#yVZ=^rqf9b8)Rn2QG48x~8y#u`~TMaKs>2rH-KInh?;a<{y z>HTn!!6EUAbH>D#DR22?QTX#jc~_J+L4u}Zj8pd3V}5v_g+cs~2MWaA8>r-$WSTJ9 zWbskafz4pJ5VvKg$~N=*+t~wY;N-9|xokiEF)WuR#ybWMDeKC;LLaO{`&%&r z+_tB}9O!{J^YC_$RQo!9@G?fnztq9^{=Q||9&cJhd=_MYe$L27yET#fX3NNTH%*3#@gdE#% zVnpw6C@f$(b&lhj+;5|U4_?HiZz7f5)sa9`@9D(4Xn-NF_;c-8vul}7to=x^h1T*X zTJIhq-^@OL6-nHTawdkKU7bUjGBPRF-_H#w9yn-8dT+|9e|^VJ1V5yuh?N@e*gNDW z^BCvH>IwvlLP-T$z0XI1iz`l;CWPSlV z3Ez}WzqTYAPSTyR=PDiV)!dZCO$JInCkyOXPNW1|mv!g9?V|&PXK)p&P~M*iV9xsa zSN$1sOH$s}H(~93?D-p{AekY*PAQ|ttBFvY0Bjav=K+^}BSw1?`oJCW05Kz|%2ZKD zXXh2QsmOi@ld{F`OYd%}ws9Yb#Z;1O;TSl?+-HE8G5+Ak<*Jj7gBEUEYiF4QdD?B$ zZj#Mo@Y-&^Ou6*bC2TH0NoSm*F#e)IJ0zOzC)`%%eJHi(SVI`8ry`HgI=Ia&!L>Ry zBN+}?-JzK?NjdnoZ)0A-%hh2nSE3x2c559(;nczOWVlX(TVoqJp}=VCX-ShQ{!(wJ z`|>0AYm2kvtWPR+CMCwE#+g$Yy2_8HvmHMaJo#oAEa317YLcY0>jh#aV?jG>4aJ!b z3q?#zb*jOihgKBoABO6LE&5PL{MLF|V@lAP7!0y&Nu*!00waR^VIAf1Ce}U};)DJ1 zRH~0Pin!JlWtx1JyN)ot0~fF`^dexF*KLzX0mS=c{NX~9K~wZF`q}13=))`@wY}?R zDvgL438D#|QnyAZ zE-ZkTG#F6$vU6OjVBe~pu;`P!kujcfyGcyXmr1#L2_t$Q7KppH>T0VRWU1o1Y5tM~ zA&T@ev*W5!hXoydEZmH3nH5RMn_5AUUZ+$2c+Uy*l70U?^{^pcP%jDhVjiU*ecb%4 z!QCt3RP5g=1|DuMs~o{jr{^EO^Yvq<`5kK!Jf&~Xz*%?s6Dydw)3>u*A~BU`i{BWM zDe|_v6fUV;TTWJUpt$bk2EYO#8|wwxby?=jviKh-^C>qaict(@>k2y+#;J=Ub zIZKOe3O{#(;nYaPcG@q4(Qoe{O6e4N#TKcF-;`8XK4Z7sS(GAu%>6|6@(PbD82hni z9r!Hjrgdkzq({IzH6dpoAIA0oyAIbC6`h?LA@oRMM#t+p`fN{stLm|ZQ7ja+M>#@4 zC^=h}$?_8NU2E|Qg&O<4FAgFB9g34^@q~_eqN`zf{a?Da0n!SifWC7+QL9|pY3Ngc4J%G-5_?;TJ|1P1thYgAAN%bYxY`3F67 zufc7iDB?Sk&37HhA1cchO)1rZ>1ZsQ&ra!DxXr4Y^#?Whm0v%krAv+Bro|!^S2?bi zw;heC&A;<>pki%Xx)$!iw`|hhqjMgf_=Yo^iBUIBTDe(>;PvKlScSru1KX18ccnO5 zLDNtR@R!?84 zFM<3;{2#-l@7Jgu8VY<}a7mFN=j5?#XNhX6`UcAO?mHRdMSt9C$0rDS!Ro$Y?rPKr z618c^Md%->_Nb)#$&F32IMZu5O!e^a(s{b&(p{cHM5qEhzKkjq^C_eAs;a&qHGo$l=`OJ?yvlcg0q5fLU zOsS-DCFL9`b#KDQG$EVj>0pmYjA+Hgh3F3F3m8u>mG+m(9qbasGJ~x4ljtxtb zmZo&FABfUb+7*~y3z~&eW6ZJafRE#YO(`n|19L^Ut!H}illOZinh$zWBpuVU!fOo7 zVZA$hRzU1#c8VEkpYdE0M6G6-F+S?2=M9O^%~9(7Vn{0^ftIa=qI>_ zq!g*z00Bt`Dp57y!lSU&vWBiEv~B!CmYA=JmQT++uds#zI97Fw|hUW z&xh3QqGE*w?E@_Rj(aCX?;cqaKL@V*Ypq-yI~$Zy6I&xF zldyOlW`$>OI&g082CYBj7&Df>m@Y+mrWyX3s|@e?0Y=Xf1P1c^<~Z5f*PJI2Hn9G}B1sI82C$J`>jiHb!$HVg>QJElQ0a z$XCthiMvpI$^`4hp%?Kcr+3twI3b1|qD){V=c;DMp1zXN#*rR$htU;obl8TTtGRj$ zTdUhpAkfck(dg1r97I?Yaff|ehX3L1=*&P4^hy{>OY&DQs!`ib2`}0gL@Gh=p*w@JGE!#YBga>8F`KN}O7TvG#c8G=@Cd&fz#Pk@q5?K>N z@03E8SNJ7YvON;IlS86Mnr1^l%w24foe z0c=RMY81wl@1`l~&Q1~LhofM7c5RKZ=f&b72uOR-q1dBCAZC2h5OkiE?;rg*5q*7d zbnZ9=4Haks_1PpfzaAYch{94gthA|}!H7~sWsNbt)!0bdmXN8!m>Xm{2YHLHdvze z0udOBO(-xnFS6x%%gAH}+tJY`>*H@O8H_?EJvpPY@sW)&>mqw#VKIT`r420q^V z@7te0MqyqC3^kT3BDQ0s!rzRvKVDkz=ym>WUAj>=v;KPr2o`T$S5UmJ^B1T&sQ*Us z`tL8^rbvuqEX2qH9{;|rMrG5bNBbdqsQEGMR6rtx3PZ0l8^~eG;Wfs=y+j*9%Oqib zXQ7PRmbt_DShuYpOuvcgxFMgEy&3rm@Mzw)$mYSPVB)+wTg)>Q=Pl|Y6 zo==338i`5D65&Nzc~Smi8Oq8Px_-u-GU@ZL%*~*!b(o;0eDwB+{v4`?W z>82VnHf6@!wru@6PM3jdeRr)J=E<%~P@>2F+0&bg$RccWoQ=*n6>6k}l}atAs961x zt>vm(9)6A~rL8GvyM4s8b#RnG_$zSky==%p=RH#rjr(A#)SRZHgwIWivXQXtvMPAP zE#eF8Ah8~4N~_lTxYQA`M9O}YIx+7!%xqFK%m1M1oL%YxXFdiP=`t0TK$E4PfEoF1 z3iYnGQqdW&D1Z_Wi$zc_>w*pR_6{h;&fC!NeS;iBd~wsB3j6g4g2O|nenxNr@%b6V zc^!iWlt+n&&%i|xm}luf09QZnXEyzhDKT7xPr{_*J#>-ISpf%kQv}oj=qnOaVgGIk zMf#2y{RA&X2`=r#>1jw`Z8Wi;sHMXvrijFn`H}luK}nsOHfcRt__>Xv@^l&e`^)Um K9@6gPQU3?!@;s#g delta 84161 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 z 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%0DJ_slSr5%dBKc}S%|9{ zJtJ)8DIn3WZ=XPgBFVARzQ5ech%*UdcFM!|&|wOe>0gos6A6y@Y@-e&4qTUy(Txib z1O-~yMHlZ`3j0Ne2^{pGdX`mol_-smGNBwLqY3_DB#96)=FfpB+Hh{u`-7S6>Y5^$Xp*K-ok54Gh`r;uC&mMR#Qug^bTYmu_!oK%?ddw-?S# z-0uS>ocnPosS9ASG0=sO2m{ezI%Snv(eF9?kT^aF-Gt>o_85*%Ez!r~VLgF&*)dh? zLAU@(+MPDzks-{5nClo^6qtbkZ3eNjCKMs^Je}luckx1$;mAYk$!SWp8L`Cx4dOVmmzHXt1M&Vi*bElj;#(&R0niCM)c8M)MO@3W zyd34hB#Xd8o1WIw1XQ~a;(wD6P9!)i&eFw}LTU?QaQ1(D;rn`%{6Qzb-G0&20OI`y z%jtM!gXW;JdnS&cwxCjn>f`P?@So%eh3@g3vC0BJT?iHJ$%dr5FTj;rwM$|xsh-mZ zxol~FB~2um1l=_$FUmkFyk~dWUOEENJ6z^dOTw|%ApPJ3WJN4%>4J>-1n!KXf$#`-2&L{({rxsj4n}6Bg^eIgVjRMaaL=Q;r>GSM zP^@y^7*L_njK&_Vowzm3z!B`zEJ!#aKf+HvL3%)aEgBxdUwDEx6Lr;II$pge!b@cY zYL|@kRI)B#wZy!G-K#n~{=z z*y+1W`bV(a#t%+ib&^hg>rR-wykF|jEq8FRSOM-mV`ABmJD>v2DlNRHa!Y_JWU)|8 zWkla#f`c-%Qj@sXdc{$zT_4+ez}!=+%InzMhk`zy{L=fhr};95=w5_U_VPR}Nou(a z(T{~R4^ZsENu^7-8Rg<0$%GDm{y?OzTdQ>xy8l3&8JG^GlRdBZ#hK*Z%<(ZOFSbM0 zCOEsSZf@-X18$KgVAqjo{y4}cl@nj)#7BM3`6we?2%LM@XTH{&m~w8FwZxbd(mD-y zfb~`slqDC>iJx;ltzyD4Kib@b4XXvEJ-|1BRJ7W$Ukd?zJ_;XqLO3bx z2N7-sS$99`rPY!U*5A6oUdPbYZ=EI7#Hbcf^!tH=PN5oAAH!~556lM7e9*;{-_4Ig zcrj<;Q=C;j@;J%H?75Q15w0X%KvxyjQ1V*rTY0XZX!{ttk};6y2`t1z3puKwHm;5b z25?YG5VF;n>h7gDr>|nSkDIJhSs#sgk_sx)n@qudEj<8<3)CBg_}+|kQXiW{0D}98 zKM&MUTzVt>9k^>D%++t8o~0ly0%lCA4q+@nxfOu7)NED!4zN6X`s)KR7BDc|A68uc zV1Hd$kNcO$U~|d60=^)~R#OEO3EToefN&lw=1nsxIV(p8wGh0AeO6JyKVB#Tyvsa8 zgNML2Gb)UazJL{@(`FJOqUPr>T$>m7RTiyjT-b$cG$j9c#Is5jsNrg`^9=>?WHXrL zkfs7$w;*D<2+o{v^5rJ3T(pn=dt?#2;g+PUm@(lnn-MKgI}rc-fcK#_6lLZ}XM08A zcNFkZ#<^w}Rru(r$SyCHIdb<*-ET;V)ivJ$2SFlweG7i94OmN>1}U461a@U+&oE?* z07Orv0x%2R6?jjDwWLZn5&|}uQ-n41gNeQb64y3#ZIM42q#xJj4M$1ZRbk}#E%oc6 zU5RyvoLBgbKXfeMm}W~Jd)OV!7GR|+ATq%X0=a4GZzIuSJ>>uW7%#F|GKz={Y0IBU zi2#tlNSDpNe%%BD%YSz;^kM=g`WdYF$lt_%6E7(kRO*9cI6t$u?q@~e&B(%T(#{Sy zTP8+KS~&y^(_`lJ1S%U+C_!k|l#tI5qA-Aj?oDtd5^J74n@j+!L@4AWUV1WjE}5%x zS&jaem!v#$W0Gd81i*VucUyJ}#K~~TrAE9G>;B^9c1ZDJesgFtGR1dlTqkSo;*}Ar z=wA%m{q|+i!n}oi8iGdG4`{Kvg^7RduzJsk=oo)I?n037{Byxh0)I`P{0s$H;jvx3 z0)ku%Qty39ZnWd7#aKjt^`(ei@{OImGD46~dkG#Tm)+o5d;47fiWTNyxyF1T#~}6W zgnEe3^Z(ayM6N|}v65e5A@1j;b;re<{u^qW(7x`(ehLiMe+=A9fXWuacPv-~xmn1` z+PiqyZ2=;<#ZYCgNexLA`?vXd@iQ0Lw9Fzu-^nYfid6hJto5tWj`V9RCAaNlS94yO zRa{66!HE~6Zn@nIJ%KcwoPpw%7F2-gG3Z1UkE10aG$J z+<4LG{Xg4vk^I$^SBXES8loe~*P#{>Eb zC^wz|>eFf&K6c6M9iFpqRU!m+ZZb`gGn>$_K;jKs5-+LsOi9XXCoLSG8|+KY9;`8s z_YbQ!3dKzJwHH3QoY|{+3kb{q4O?(~VG%EdO=B@jcfO>_1~BaYt=e%vBZ|%9>b8lO@UITKDnuaM_a~%F#cbB%!14$b*e_TPh2;;?*LZp zNIeTmXT+->@0n|GI1Fu2;XtCsAGcGsbK#+!BE|%JB#im>+V5P$jUgDcX11a_@ss|- zh&N$ntH#}-&2G%N&OU4*>qSch1S|v&5v^aMdKYjb5f&QsdhMI3UoO2o^yW?0y+@mN zC&gz;e)_QHzNTIF6Q54)?d{tq4}{zwcPuxc+&4 z=VaNf@|B;~SZWEl9W)(lcfbPP_kCJ*+e_5Mmk`Oq%XhA9XX+=uHVX^i5Gvp=hi&#G z4km0&GN;@#I`XvfiZAmkt~nAtT6o=xvcf0Mim&jyE}5SiY8Fv=aTO^{@QIcAVvD{a zuZH>!Ud?0;v}vhn+&|a<^TyBv&=j$Ml|k;9T73Yih4`}?Lfa6EBx^_uiYe#ZdSFEw z&Uq_mHTj6!az6Umb#&Qa!&yk-606GVrlG*x;c|OOjFn$ZFDeBdlP?uRGI-0!HF^W{3*~Zzh6g(Z%gDu01y&^vTgG zuBRFmjC~z%Tj<j;Xx%>1 zJU!mG|A5Kpu}b?qdDR9(%*;$)xhqpaN?(4LMm}qT5oo*Aac^gInYy=mrq&V1dhbB@ z_}*zyMCJfqN0!w+cV`m!&s^x{`K6PYHxH@423+K+`a1je=(TQlMawx&~?iO$~!v+LtV zBjoBy9U+00?eYah9%u@iB5kCh`^*8dLC-dh#BT}izq7W2qTaFrZ%N>z|ARAw}h zFx+|2iUg=gL}u+(>dHCB8ZhUl3i?>mv;$&udFV>qAM{$>SJ@2Zcy3|Yd8}Js7=IHZ zHX2px$ZFV0%`(@BJ`rDj+)>XR3*8D+$^X3wS?mC>Ci$v6xOwBiKJg=Bby;R=$&l{G zRD&^{jrrj>k}QN1;%>cCa~*df&faYXf14cFJ=?6Am>)@gamh2aIvcI~@xal;mwl#I z3RO(qoj39$P_9fZZ%^I`fh1PbsN3fog1hG1Ie}762<(a0=Xkkvc>kC`LL3cg&g|UC zIyt0#-YNf!Jh46^8*ctK6NprGaN#q1|Ct$Zc(C3$%RA{t1OIO$AKqQ7>DzNX)z0!>34bJxOm7%!~ z^?fobqJ_EA^zyy6E88C_G1n#LS7?01lSG1xMp0K~?aQp+e|<1LbiKJw{`AKke%5(AN*lix ziM?Rvaqd6MuXe>LpZa)tVSL2<%%F~VUvw!xLBa`Q+jCxTMShGO+)omHBK}O) z%d#0h3b{DdI78_uOmE+O{el7WjsqUm=-HW+N-Xx+7qhK-2JRzrZ`3UqjTmxE5;`iH zt%S?{oIh+%*}O1u26vg4Iv2jZXq--K4ZI8)k)khQ+%KRjFj>t*#0mPypIwHm zH=5j6;-go($pz#%=dr^y+UIAe3pSNUO_~*$AG?~g&>4XNLJVkto^J0S+*d;DGLi2@m%qT zXGF=qL8~9Lm2u|-3M2!@8zTR#`;1R`RM9XIt6N5Qy>6}{U}tot`gk))-Aq#EZ*@b? zZ)|lex9DG~&T8m+InknPT||-FYq_l&lCs`;#Ca;%`{guUm+VUQ3O`$G7kcEQT}-1P zCZE{8D}lnj{|?w94=d94#AYl=A6a$KcMb_?+*eB+GK)i zHo7tov+TAJW(O@ciWbtQ9W?NQiz9Sh4a@bvPW4d)L@5m6-%^rI&-azB(hdURSH#JFnn25*zKiZkXl?n$JzSbTlTF|8Q_|l+dB4gtQ-wHyE|sn;=9#|13v) z-@T(Aos8GVn;*wmV@aa)@2Nxj#S=&Ul)4;rZX;I;Q6USh))8~%%=jCMmoU+tPDR*t zorHzS$xTz2G<0uUm$CbWEB4D?ygU0Y>z}bg5p~;A<@pdz0%ylMS=6wmEOStn!l>Pl zR~&g_xc-3ew=eGO@oUH8FR15jYJA|*x$}9;#K$I2SlOKT3)@`R_BUqv{q=EAeoZk* zg}A$~CHmiG`JH2)ps)qKEMu&h7u~LVg)c9Zd=GMk7VuKZB!6P%sIv{o6PeQ;j%;EG zIzsZJTBmo-%6nwf>tWv_*A(?rThY^om^58s$ZXN#BASBSN8OiK%;R=FxWw@o-r%3- zs8QK!KJ@kN!v>-$vJtRH_3ZABsXpEoW0D`@B6z3^Kj3`_AloBd}W)k4auP5NdT%O5(E`n`e;6|UFJ-f4yXOp3k=g_Zs@(yClk}YQgo;e`C66@{`s zr)(-S977TZv+VfgRS zM#XtFgKXMUOsF#RHKMtC=P(yaVX)x%;k1u0OR29ZwFNRj?mEhQ=(fTXKQSkRp?l`C zIfc^W0h!uIkoBm@Xo@-{bW}zkM)r)tk&#&{gTt7OvaQ?i59>jPe#Tb7{Lz=wo_!&; ze{3S(zr`EBhxwB3Dks>qV?mV`ZtuFsjTE&XDiAp6>x*X53np56YI^e>k3?wjRD2(wSc6LRgHOW^L?XihW>EHSEgDhpxn7UCBEm?A=U^(?v za^<6^56?aFA7yl$T-&3$8D=`)_jgq zK=b8d#IudO5LX($+!t3ALuG~NO3&gfRf9D0Hu1W+PY?~n@s`I_uR9K7d~Nx1%Ln|o zut3?7%xa^z-4XQ3daq1csX(rKfgjISTJ5;pZP=j2$u$al2lR(N5Bju|m|zBUcQVX| zFpXMq#-jjQ+id1NuRzOf87m6g1|MyW$q;yAHYVVT^7C2C$aClb)pak=Sf2%F;cf zjYGb+9;RqcLQ+0OIr}+mf%>miwcS;PJz`V6%~|P*ubT+UCcH;=b#glbzFX}-gKZy9 zLA%ckC|Gb3ARUYiY|BwjvB2N?gE+LN9i3Ou*PBuuW8!sOb;s@M$UjdwdKAt3AFJwEc!fs^ajqlXDdbQM_IHN6605Q8bdy9DeRN7L>+mf8@G*SXqig5L zsP|ciYw-2BDCxb?iKfUe4XZYZ7H$_C%^$oFdaSl+Wcqp2E*dpk*Bk%n04fTrA^P@Y zsWnO3>3u?{-hL8$XWVs{kz7?D%eowK7T$kmkHV49m67C4yl#XV#+|uT_rMs&xXdzo zqn~G@!D!>|QZ}tKJXTkEHi8e)0IkTY<#|>SDIZfcxZ7aHKy{$sDzVw9b06G^;Rh=h zW|{7Y_MuBimR3ajk+{FS)r`nzrFnClAF$hHOzLW4GV-bgBP@6BkF2T9Xq@nkMtOq@ z-g=y`L`mThRhdZwhSP?g8bLZC^hOt&@8Ovu_Fm9JzWRGz#1xtA8C50n+Wj!Xk0=sk zK^@UiW(^E!fk8Z`u}?m6x)&)Ig~?LrU7fxnS49WS8VI+Ad*BvlyiQ45vgY59OZiS` zk7VAN#zZ-ZDYUM}=a|P`$YRJ+)Q-$`w^CKp66+-rrQ+{4=y;GI<$`Abjql0HF9H00 zmxwv0=*yASS-g>3gnUwy-hX))S4ophf?(N z71Z*0>{l=O>vvYwNYsj(gyyo$0keUux5u3q2C`A#rW6%D!o2z|Zp2iQ2GHO3Jf}A+v ziw$zuy2Th7qPK;e31&5a&D5~e%A+pN+zT5Pnk-6!MR92}a>?StUcDwy&cxSIMwb7f z99ucc`v5alJEAQge>P%+n?W@^x5da{#Zus zk4hylyvN4{D))0o)t zJt@`1qm15VTXF4}#>4sGTim#>0@U3$A=(%1QL}oe=@9e-^ zF5EZ{F6Rq;h|rA0^wjZQJx8u<#4~z*6}|LhU*=&EU4F8e z3-3l8SkOunF+z4xwoKQ+r*E>-cr>qISC*>$F_>+{z<`>wcdMRd^M`Zf*Hl4S!;g1B z3Ygj4ToBhlm3p^IA0yQ_(KQ~$H4%6CcCUnl+z<8Vnoy1?m*W} z#a#*{A3wyaF0fybi5OPmZ$4kGwfN!5E(4v`!zCV&uPQ{qAqW+4j=8eV0Fdtex6?CQ>Vuj5qyNrluwmdGG5zjg^$F+g{ z#*nyXK@oj=(o*%x3Ev3Ev-4z&7qCa)h7iyVD|7t#a@%UC|BvsQF}UlntDoBHwN-^a zqzij_&UV6VIp*1|@_HR%7VH}G+uJxYETilN%g4vnhcA2#r54`bO-upt%zDl0V)S}x z1|D;)lvG5siHS|;HebPJ&7_zSDH{y(aDg$23TV|}OS=mgBXo7`$Hv*g9UV?^)NQp~g=!fNmyh+3;>5QsgWt6f@U#yEO9rB8u+1RkLVCA^pj2vKVP0gfGTvDlZjCWvULi9>Y>0ZYWQ(P$?&-+ z9fsHzq7Qo}(8HHJGxzn&=|99vzC^g{2Ymn9P;?>LG(3NI-j2Od%bYy0mKypGtj4Od zIyd0|z>c`IAG+WAWVZ(MffCwdp80|CD7@by@8C5I#mbVxn5BZwi+BC|D~!NrhQz+w zFbx<+jA)@OXcYm`fjY)B=c$R#^^fE|WbuZuR&#`E`aUhgKQguPDpNhrEofPasI4>D zz(60OKk#bR*9sM`0F<71S$CX%2D?}rh)*zyp@eLlSdF-Misa=~Q5KP)%hkA z$tw`Sv6YzdnZHaX2A1Kz-tiyX3vL;!h8Dc(E|n z#ec+GZpovb?Y}8)$XtUZIbFohoV>DFFux9NM2DWKxRH!YGw~9u8p<%qci&T&^8uAD z*vn}`wQDK&eZsT$53zHJ-M3f$nHjHjo2c64iPV3PIIn39QnK%pHWbKU=D?Op|8f!Y zEjkBV=w^!!^Q#^>3F(JBo)&q_>F#9Ug65wUxN=SpQFB zVyA#L@*~B?EztAYa%!Ry^V8I$ZO``C_2C(TQ=^~P2)GIFrbq=0L<(qLZ|%tS8!s7% zI_9)KxmJ#j`5wsHW-SjULlpWi2(AjWWPc~C z9pE)4pSI&&MHHj!O?$86o*?gt(+NZ(g;z(TEhI<~L`lQlpo$n=j7ED%5FdPfcQ@z? zd_}?6rC)#j3azGC2X)Bp0@-n<@2)D|5)VXZx71?#i^X0vrs`-f;G$5VoJi1o=1QfQ@HY>YK@Y}sOLsVyEHKK;cad`p>$k%Wxv4!yk=`ypB{@`!{{D{pLUKq=Xma8a>bjqq{@l36F}f=<`gU(7WIADSXtoSZ1xkiz+2F zUjwYA7mX9KhPyx;9|rOT$*hxKh^N#cKS||%huT@>@TKSUB0X^djvK7kP3ssAiRYc7 zrCnNv9w;*tLU`yje|efIabd=q*7{`rolYN04sV$Yc`jYeDO<~M(c&-ig9lP>>E=>b zK5ISeY8L+a=)SWvcEy+rijm;IN8Y_?CPV(*E=pwRqBUrS!lo-qEdoV=JuSgc ztsmW&r`V+>_QhFd49-%4l|kl3Rowz42uRaBCE#oc0YhSky=|VvIGQ46=V^W00FevD2haJ zLDp#9Y86t50l+BDAu;m?|I+Pr=}z~_vYT2IdkUB ze9t6&v&C8GGsDkND3s2|4Qszep{BCo->qp{@QI?}78#|{YqN3f>hH+UyIViK6GU=f zH2m3`RbT(HN^7~U+^psEPYq73`r7Q=eB1d8S6?bNSo`48GYtFT!zl~a8mu|FYSq^A z-35Bzz4~%qx32$@^`BhTbS7+jcaOC>I7Z;+&d`3mOm5-uYAdaVcV+j$obooY`p91oH(WtZ{oruJ zl|tlf3S;c1#vkC-;PdeLX#GXVeR|$rX6sP@UNG={lMX+6@I0@je}&VeJ9juC3grHb zYpLFCzapZaf*gjE)S6Y>#hBdT^}St;D?Y>cok>j` z##n$=HC*{YZIbVVid>KkV`3WnLG@(4j1LCn0Xo!H6R{sLuyL~(8w~aK+Tx;`&@UWT zBDdNP68Pp^JE+Iu1fso(aid4=dL%`3rZykX4CEQcC7>4lz%=$eTGIQW^YPFUCJlEP zNBt36y4k4IFIJ`F;gYv+E{(+llShX0Uv7PDsN-6sucEWzRzCjKF_S`m6)w?mW$fuQ z=YqiAS>+cF7Cy9;oK4;888dS}(5l=ALemKZo)8 z4?ctQb5HKU-5Azw9vU-)ooPHSqtB@)&w_3ztOS1cr~o&Ys`R!;9Mzbx)kAcWunXs6 zyzw)3Z}p+f;rM;hLm!F5vV!;elnijs`H};D3e`R+i^0iuo)>pN)H81%K|h$lnHye< z^T&-1iC1v7p9CF<(fxVOs-(sSe)6Q+#&9j*6aK2*X>8vWXnjQcSq$I8_a1AYzwwB! z0eaDyx*3m~>b?oIQMz)Qw0o`RJ{Wv-a6K(=1BSKOWLrQOp=z+%x@lS>BSI%q6&QQx zg`ZfNntLz8_DVwue`?i@fzl+WTjIOmDY6(Oc}7IdvVug{{JNz+6nBl`TejU>lPeTg z%S%#r&n;}rVsGu}af8Mnw;-@xHB^f5$cC$V5WH*y`2ca&T%E!AY0Z_sAl z^5cr1g^&8I$G%kj$8qq~@Hoc=^|Ty()EA%(^}8H=61W_=3_BFlyTAmrAFM4|G?$6z z62eH1Bb}sEbsUSh_0`&{k#(l&dR0Ol*Zkh$(pzbHXVS0kxzhjD;g!981#vG@T!QcQ z{+8^1U$L~FrY$buU0uF;`I(X8`a*BEq}Svd8xyK?OK@h5m!>cqGkRS=gE(`w{esA& zFVc9*p9E4-vGPrqi;ZR?TQX|*;6k>u4oH_w*z&f)`^?SWxTY)yw>6d@>bV?68!Xq3 z3vAsyy-I&-f!|&fMF(wZH%P*Mo7@wZgW;ceDy>w#af!XOkcABi9ev{#8`xUe6DSk= zR=Lu!Q<2AnCok+u>>B8-lx%-RMMP-HAIzP7D{!tL$KLz<<-_$3U1q^$i??Wui3q+B zeNp<&ug()P!+G|rO+zjIu?r%Cjj0RvI5h9jA3o>mM!&&?Bzp)2QDIS@Yx|QD3S$Ie z@yY0NTl+*(c*lZW5gL*loa3o`En(?h-lIg9A__?;(<@i;EaNWudowwtQv&;5C!36E z?dAbZIh4|abeEHY^SZ}Nr(AWfox>8Z5%1Vfj}dQ6#+GRte`OObRy|B!W1R0?U7_9M zk;l=*mf+>h%oi2%)+_#Pt;Q|qMs)42`Zr>-N^Pf*>0%?ZNJ3WJt2NYRgCn*<%Tm8c zt6jG;X5qWhBtqAFl9G0NUMqKCy>C;-9BJjbp;_@#l`YK0A&7`<0mX9KwC6-)Y z`zlGkLCW95Ib<59{By*TKqcvh5=ZX_ZtAeMH4A>Cz4-YC8hudcWHH#F1Ht7)_t3sQ-D*&p9nSCiCSaCLUINn%8* zan|ZBHoBCl$5l_7H+5R!DV*mo`TnBf)`-^BbZ_Oo+LWQrz$h2jdl4}`4zh{3B zGTnD$>-_+7ACjfNK$Iu3L2(}zYq3+FF7f$4YgcUqT;b*4wdg_{;&6w)OK^;w~ z*E#4rWdrseE_+cW0m_ZCXS?S`m-{?R-s3~AtCg_)$X&$b&6@060aQy3DyIQO)=4C7 zz!uQxMsb~uk?l29fi!l>Pp*d;-)jm_o)AdqFZFg~28OxjjnvLOX7gAWH+1UOCddBE zx+&ePHa7K&KPszY?J(YW{~r}{qida_L1mdrnK@Wx+vxU;)j4E89nD>T=}zyWEatA> zd&=j@LFpDKth{ZA)HIDsdYD;3roPatGNF>@?Y^lYu`O*MPDYm^o>h5Ka7Y%V)oPGP z68F5-KZ~P;Wh_Y7!0srjYa32U{-TO)6PB&`(=UG1F${rGZxntO&h)>?^DJR#?N@_|CiX~s5d5tC}yZexb#i`lc zIH`-Nlp0`&%c?`W`IcBcxi3NA3iKhQ4`g1`0_kyh`1V(CN9trx^wG3XFU|IXm|fq% z6dq71>9$M>O!jJ1CJD+0n#;DmEm!j6R%|jq%xMv^iZ3(_qg4`J)i=pUesJxG&ErNr zyL^mKI=68bnOfzABHaI8XFI3I+&+B?m4x$Vx^{U=(FK87iH@RLhvV%p&tfmiv)E>8WM!U9U&#V|#Efhbe=$ zD1O;R?gC|z9$JtcH)28_NRIQR12sfdRWiDoY0z$P5xz?wO~bqF#SJ5l{M$bF@CTCa zlPU8fW!LH~mP@C*ho4DkCh4bk*L*yriNRnvA@OQGDo+Z)EYQ`$*u9;{+}KMxMECzU z?@jba(|(^TjnqLI!=(rkb{vI~Y}j9)PrF6KU@zM{4j)Q2$U1Teq{>q|;t5|zREjL{ zY+>x^&?45Yt6HVZ9#Mk88U;R_wyN|^oGs?sL9@u=7VuteFuEA`2LI?hO2T+Q@yq8H zcIa$ct1v_}Qr9?cL~~b>AKZR~;u(Ctwy^$l`(6>3F`NwpuuyfeY`DSNRLVRwe8$** zlZ;dwkVWcyOgbuH4_*Un-W55FYZ;0bHq)(ZB@@PTOY+mJQ8QT(ZFCjABtgUoQKlFor3K8y+L-=n%~ zB?|tVJk>n>P~N-Q<}AT1Ub>>7gcg23 zF!*qu(Yf0kLo#=}mJkMXa`^p42m4Zwe7#nPW(NjPNf_)b;|%;!xU5<5tXzj+yeL)R zM>d_Cmlip(zv?{7mJIrmE8mbu!3xv0y)`?VWVFccJ*HkhpfMH~ z&8;Vz*N;?>__SP~{WMT!mo4f-CozdpzYUt+M3;N`mUP-#J2uxDusL5J(CSPn8$A^d zs|upiNNsZAg6*WjOi;cLK~u8KBmZ2tg06kVKaS7$A=@k>q+4O-XTAJ8re5t_Fbn>$ z`KRUZRP`t)JK-o>@P9Ms|NT`I_jiF*h=bxbueS~iGI1kS zV2x)pdx!@FjmhXL<#B<*c2f9#W~#s-m{gt5b-XqGRlOnGZ#g2?v{ZpL#&HRTK9$ON z?sj27WBBOY3SPXl5qPwo?DImu7k914x0OU^ta9ZQ`xi?Z;yMePhiBoG$$^( zSs`h>E6yoSsT^>Nk8#QSzVBXB>`s-@=ILw?4T4cAZV8HUMCusJ@S}x9RScRM1atP~ z^%Sp)lJVE28F*{98|>nUSL-BmdPtuNag=;m>bBX(_x@(kE+}mxW;iPEan0QW&@^s? zK@_icHe+=+cqa{sdo7w_up}8qxgdKntgWPx7E1Y*A%*8f4YCduM1+4>4{upV)}{-{LT~gyjnn z5wCV;*+46OS!Dlg=M;#ME)>$?X|s@ErKwhuUhjD2s#ZC(a%Ab_TJRhAg&_NM9e!FN z-LW1`Q?z_0gxeL`+%Ud}53?Z;M|NaL3SB87DfGx>3@o6FyPfzO9yGN#ooM#AC3)HZ z_S3V_VWRx4YG?936`xdk5T@8oyj2l(CDQJ2kITiDBF?n>reO($XI#qTo2E@`wrC-N znmYbo{xI0Jh15o(a@L|48_g3LZ+3}R4o63rH95L;#a@5vrvq!#YCpxvto?sJ%ecc` zQ)T98yyY{n@;RTfP1Z)F&f=eFwkBicnr!`9Z2YS&6@=Aq4RKt){moQC;BalY(i_wv z>@uKo{QJuBn6~_V%|#HZL3ClB2>wL2x$%U+x(ox+%0l{7TO7W`I;=|Z%ubMvFBksF zPdA-H*6+_Uq&h#We`vqvExKIT)#RC@h`JD@><(A^-VcUS%`rh(1)|{;h^&4jy~>OU z!J}vln0WKJ|7U+Zc16}z46Apk^!vV|nm@ri$S#6EcFWkv+5k4{LP$3Tb=Ivj)0Awi z`;<FUv3w??d);BpXez4B0E~^wKdK$5Y0H!1&WAMEzQ1F6Prh8 zBGb-n?%|@@MSj7m$D~ai5WvaxjBgwfWLKjosa6DU2HyV^?@6~|a-A04^l->k4lGq} zKfQ>|@3JMk7KM&<7C8L+Lf5HaM|IrjAMu-Z%J0=eGT?=^^0f(-S5E%q4Y-UfQuu?w zFJrb^l>`Kksm)DYVNOzeJt9Lx#;ON4slQ~;mUNd|Gb>fEIwQ_iDlaRB3{j3xQU&0EYtZ1&xmi8et&J#J7;lN)u7fs4 zl-b*9V6YjsS(l?I4(SGbBV@8tejTs@KA?)M^-mk3xU#aXm`Q=`2LnwpjDvYsky#rM z58>r^T$acyV}Y{COhfpAw*UJZC3w(2HXcojteb!AoaeBBao%4%(xG4W!%PhVD@#MM z+(&^dgsy5;38RJU%vg^5P+}7*hgS>>K#h{F2P@;H7x4u-^#7op!YDhf2-hw9*i55K zK{lQj9DNiEMaLp4B-0*AFArEc9dzB;;+SsAhgDBF{Gzf%aLIhrf@BVzbOuM6y_OW} z^|rt^_P2$6y1%$?J$E+_ObN?)BrQrEiJoiS_nTkGC@He;c2BNW>7uFaVGm;R?++Yv zR9s39X^jl6zH1w@q*|656I^HaIn{CoHR6aAqRK9VDAKR|2yo8!_Ke-l{`m61`twbR z(loI4lL-R7Yxw@0)!k`bn<*hS_>#{=vrybiv00cb!6BP~Lai$1NCSl<9PyL6_f<+@ z1yC2eU>Aw|bG%)Sg=*g3fEb1q$DaKr_t(*ZClO}p#@KC4)yDaQ4LCxyL-X~6d~)5= z9K(!%RN`AdvMlNZ_f%h1V8$<6qK`+b0hRtc{$G`7`4yG%lhh1NQztCeVu(kIebWme z{bhh7u=t+|tr>2b!LYu_#(AH0?_Y81d;B6WoFKDEio=?2G@B`xO=WwWA!zmmr3bMh z%zxvp8d-{iX%OF^q{!I)EuIZR2X$BW6jS8N<@l0--iW$rzXiWBa#h}bWUY2wnnRg1Cl&Y46mF78z7wQUs=MUA%46(?=VRR{wrAK#QCoLzUquR#tE`f$b|7L|*7FZ4xyO zKM2ukH=q_kKJoJv?4Cw>V+%gOkr#ocn`>9Dx_QFlE|yz^9jKIejzuwbj( z{FbD@-67t&@`tk-xSxK8McMaFpdd32ooNpQ9( z0H#7#5QhKei)=FD-ijGF2eU%HDF0Yz|E{iCYOf;+N_U?^CgKQ=U&amca+V}C`-7`{ zaw_n7XtOgqB-1!+V1)|`2~>WP#%&o^soK7vj6$kO>|wv$Gjn^Tw+)){+6rqQG0Okm z@5za0CyxpY5_t>R5ZeawjeH5|B*^Fmm!U9@hp=QFPfklX@2=n3*6F($9PSAx4`L2p zx-$An6DiTvJP~p=s+UCm56SbNT@9de4)VMr&fnJH`OOj?;{_4JM>6M7NhaHNEw~hL z(}4bPYL!qHMwgO)_zuktNGMnkN#)9vO?B=&+1zM~P%hIfC zS_K}Dq90J+o``He`?WZ77dd6Np8e2vn`nX=AI}e$-!(Nzv}D6H&({C6YSGVvubj%|XRlQ=+pUxJpeEl#} zCH@cE_`c*6B)%u~QsBu`|5-y5SZ_I_L6(iw`V8COC<_b93tuZ6S~`A(iLd{|0%Vd- z>9b&>{E@#)+c=sESaItdhRH_x7;Wx9FRzO!{WuA{kTAFNbh8IwL+ zcmAh`NeSZY)e88e@(nz9g(OH7!;z%5gmWECb(?qdAX|lF$=OMkkq_78Uud{3rYM_D zBU+@{$>>^>^_4u8zrKKt~)MBy|%ls zb>%H&Dt0VbhoV(y>FdvCGY6aLBg1R7u4Ei++{)g{ieh6;r31$WR!vK#H_AF|lY?aB z&2?Pd4oLg0ux>7>pGjko_Ox=R)Qx9sB?qkt%6*g506a@%fvMQ#N7ldbW2PyV2n$&{ zYm%P-yW(I}Q~tzwpS^A%q_H|)B{#DM6JX7dq7mj{{EVqBX|#I*G2fGmR--&8sh25n z#8`J}s!bv{FlMNZyHi6*r#vZh$Kwd%+}TwrI&8lMOG)B7iJhZ_C3g>5b`#>fsZ~sM z!1{~H_JUoCHooF!a#~Zn`w%(v<`F4`<@YS)8dW!*3fYHRV^(ZBdecFx_`H9gH~!)u z+gltLY_M4)EBET%@i=NEa}kv@;vfEid5@Gh{c`i7wqsA9=uWK0 zZ#6-ZJq`r}2JAo|J7h7{B)iq_5YXBrgaCBJ-)T(pc<5jECj=r|K5?Dhja?EtTELt*8gLqqx-hr2*;gE&UBX2dR(s*nz8xYq*VREV{8S zKZJ2{T6_G~ld^|el3DVLUTXqJcdcYgBE3><9}6|K=YF$%$*O3%Qnuut zs$Ij>z}tU=cRXL$o*Y;mdu;CaGesFBr@qzN^S-iyWqaBPxX3&MVVKfrM$xVnn{2X7 z(>=0I?Q_4HDlkV4)A^85=bkQ^Q)P>IN&&1Ki`xn4wz*r*%#SR|f(1}XXSu`%6OyS7 zbBuRi_uXG{{Fh~G%!4_U0jfil8Cw#xJ(4PD>XxR_eP82-k;=&9mkXZkb{dnXBkf?f zhIv@@GPLn(NdQN3o3j&bJZW^eA%nfeaTi%;^jdw!q#D+kZWQ18DpWuB9BD(-H`8MA zf0+!!Kby$!SS(L(zrHA5y1k#@jo}ZS=5vm9IkFWc4~w~F23St)?bv;%+J3^G6r}eQ zR20jeooIeCMY4xb!ktNvK8|8mNz$M`YQ&P{f{&*cqj{;0#FND`a{CeOBv*aKP=AW< z;z+JGcIR6q-Tv1X8ulxVOj)A|r%R0sqg}PG%M>oCxTv{YGQibsva&uE*^Z-x+pp6S#%M~`jd;sPSR}(h zL)>U{nIzt{sSDEZR@T2~KqRuU?mw^^Pvd$m2G{gO%ro6FvzAj9PD9fruCphs9OD>* z=E_n>OtSt-nm&qBKPMl>foOI8dcyu;xMaMmPDPvv+kfB;&>RCDwSQvm{Rr&7y3yu# zcN&DJ-JN@OhQFj(rp#e;?kn~cwAx@&g3=qg`!k#7a)fW5{IM`kr73&1&vU_Hw&WDe z_Vs>;SN7D1jg>I_7U~j>t8Q3n&ZUN*81NySgd#uB2og&1vIUO%haex}{_vrI9BITOddIa4>QRg9A=Ghv-8PdOdnMXiU7m-!C>k9>P$y=`NeFd))ZXcS zC~thg9}`3!$UGvLf{gih75WP!??{J9gpg;NaO+rrX>LOv-$(VLoMom z~0mvXVH1(cq8rM}r{Qto2Oh$XgAQTY%B&O@XvOE7bTN;C@#<{3>(AMAU zb@3SXvlYG^48;j$)>xci~+si%*L9#DaAp%B5ICIF<52VTpW9Zpd= zPs=Ck#j{bdUGno+n^$0C82?BKjbBr~bKFDxg;7*LxgOOXxx{;$pNyj|j>?FmWq0MZ z)*B|dw2AdP4-(eDVvXMGB7KX)u2vcmpQpBsWM=+Xu1O7p6pHVv>7gh6p^2@dIaEp>+1z>*-J%rQ+!b~2Af-7<-eO69g&97 zqa``TXyw11YXg$WMFFQKP^$`AC zwsr8;&O=XTZH+QqHBUbHC!HKs&2ielu6n62WsZmwFK-U~*1#T;V_xVcweK5Th=!nueY8&h` znFwf7b*XOA57LtxH!I82BUgXB4AJb%#Xb8nVSj!taf?d?K064BEls7jgN% ziaR30q10w4Zr4FsN~pEE%*k`Z?${DgubF zRwCYF09=FPgFaz_b2X0QK7S^atIKBkiL7W2`-1j1zGTGtB?!ZoV@7jmPJTgqPZOH2 z2hgEXp;t;9s>rNHm6$Q9(v2fi8x2)47v5Utk1cCjr0+8Y6EPYwijr8ES#gjuGD6Dy ze&*Jpzh#le1LT3lN$>HmzXZ+d3#MqZna8>;e4d=xQzQB2;z%k|j?^KY0j}|67=b0` zsf1MgNnx0t6@d-R1XJD^#H@u}kQTSmwrrpw`8eOYdGk!R$&50QgLp~m7l+#@BibCZ z-bKpbs(lNRNj_LPeV;LN1145vO~a;7YqcL0B~K#|3lFH0zi+0sie~OoJTrQ08TaO( zY16%>!Q0|rAjzUArx9yX5@dfu=Ada0Gp`MZ(rvQb_Ns10Cl;1YNj*#H6JxhMJ^|=R zajh;FMaa~~t{%?Ud`}FW&Q<2v_vOk*YKsOf#Y5G}ilJXFX`$)MsJxc0s)K!};n}CX7UhfBl`H5E}77?`U`T% z9Ic7aIk#IECq4X|h9s>Azp6{PM)02X0lv_CBcyAB$~HLEhuOR?TEtp+Uqbe5m`gMG zk@LM~yLGeMTXJ_EwnTox*X8;Vq#z0T*0Adyt^tr=zJq_2({$Yu0{{XfO99j1qN_bb zN96O^=56+ewvasy)$VGAMdO zzD4hB@!V!<#_U-foPYBH4e%}&IPa4Jg9DIhn;&^pdR*Y{?7kP!kmwV9hYAUK#%~op ziYQt4qZ2Q@0v_F$JgAVYUam{X3ED!ggu)tR$FEv`mgv?sYNh$+q9(VQd9~)Y7+qyf zqNPNt>7=0*H|+!lR8j&RA$*2CPzH${e-uj?hfP#R`eRa-xE>bVQa#%If#flKV>GZ; zWkyiM26l&6JCv00Ds$gi$iekZsw@88p!qNxr=Z{n{7aSEG^hkZ66Kq_0cUW8Dde>` zkoUAuyPHdAQpMpfAk$5I?%ey+#WI>Pj3}94BVpNmMCLot^6vJDV7;Kn*M|f=@ zqvYm`9_}Ee`YR|aB2~x0>puf~Xky3-kIXmI5}M2LbJ#*I+-Zr%2}a0^fL~|(jY?%I zM;mKke1_`3jYp7`je>7vqBnrA7UpP3njNhcSNl-#yU3NN8vAx=nZO{^%QcDOtOXF| zW~#AwpQyU?0x7lO_FHOGs-M-p8qEL#uTuD{)-9$mN6Ub81WDiqJz2Sf@Sx{}`%Cj= z6uBi%tJModNfvPwZG`wPbgsvl5*M?d-{4A&F_0Lo%aClxsD@sXE>!NbnGQAGGL!8j z2IU;I!{9~7uopy|3)l5M;fq?AWW1P$9<_CB<4W&vV# z*?xgvN~$2tNslq{k;ZVyXs4wMacK>%L>S zH_4z71H~}}$%*aOh_6_jjjtAps>EW(sOZT7R|t}1)`8tHoO-6p&nkOU32_qpYSX3-s7i8Yi8cc946uJ7GKo=WB1j>Y!i z(+;K%%;N?cWN}6sh9CIm(=T>YK4a@^v+@n4C3Hj#nP}@pYK}~FD zf*?E83oo}HjmWN$6~+()s1d_2ufG)~aKoY4!oo6`V1?PpXtl-cPDTCmNTcjQs)^mv z=jYpv1q9%K(nKDU)h)TkmP9+>nmpiBb1(B9YJvjZi@iMN&}oGwa{9U zb!~Q4$ck4dlHIwV(rnGGZPyt5NOEZ8UrYqVuGMF^xGwm9K-iPB0@uwGs1DS|yQCF% z#oB-TEvz#|Idd0zkkulsYg6h$ zP98IzB6i9t(!r%^n`9X~`lh=!F?X5+M9O;8W<02`;+5a!W(1Ddn`Lu!QQaRa!q2;>e)#Zyz%V;200x6$NtB;J0CQsI_= zo_Bc;=%2+n)gAn8VXC(HJZ^ z)mo=fq0k}9H;-*Z ziY-%I_ohSr#Kwv6^?{=ahLCQqswo<&XKq^l36K_rbi>p1g;Yi?w=jI4`Qd`mpX#sX z4LyH+OngwL|7pSKb1PH5TW;i6y{2L5K}w6$S)~zx3e-RiITA=S!-d47sRGu`SC)HqV8x%1PG?q<9PlLv)2H$D?jXL@_(;stQSB?WQ32%Pq=1K+ z4$k?GqNn9#f{hTrJAhy%b@Vquc_#?`G)jQvs}ohZcQlE%{O4WJ^A|@8UYzpN7`}s} zJpNbD{;|V;5_b{!r9hwI>rC$A*Za?6O4{dB1A1jKOtVNkZki1SnhPNF_jM(Nw@$=#)4=?cDuQ}_-v&32A?=Rw>1 z>O$W7-?X?w8!ud$IhFKz=04-F;@Q^%FBJz=k!AO?A3C^oyZwMak$p9<$_4P|@!s7nr3tJ%?=rjys)fdl!wOfxInj{3#6M z=zuPJ?WLXQS}D0Wjd%7ZX(1idPvu%;yyKsGNlF5uF{~Bv_cNeAdv0_NZ1g2xh>T^J zERExu^LIe1{*t{*{>TI4(Vf+!v?f2;D1t|cr&D=^g57164MQR8cXK1Hta^&nF|iGATNvO}lL*xXHuJXCn!WRZXSqPG8-`Hw5ZmyqhJpaK^c<5F zoPB#AuhcC;2%B)6@abnf>phvqDLpp^s5cQ$JOSLWM&qGshb||bqV?Se-2)2ZM8fE-N+mB)HY?6Xttm}m6B3+% zA1|O8x>R2kkQ(fV{DF*G?FdsGV z?kfi}Ft8SFe6qPInz^sY{{pPQKOPHX1B~?$@$YLpgKWsaNDQF4V4wh_jf8wr^-rh# z?Zqgu7%UO02@1aA^eke?RLSmgP<+?0WLmpd=TS>dD(}lkFoWQ2_(e$lRCzLOJ4QUE z9ZDoX%BJ$&hYkz+GK{lYQM(H>cYk}l^`%)*av!_chkW#v0;QDFJh#q-NH3va7kRh^ z%@#Te>81-Jha-VH+p)U;hr-eO*OV!h%JcS&$OF}euN-V{eI7gCioiDtkRJ{_++O59 zwR>Bs1rSry?(3bnafII_lah{$^3b(T{T15hDP8sPE;*%L?v%Yn`V zgDAy+M_dS_Z$E5cVL12a*zKr-eN?(^eE`Zf0Z=yokPeksVmnG`y1U?)2(5N$DHO=W zkq?xcmZO1dwP12geq%`8!`WjzO^4M*2mKkHg+O5HtmTzB_WaA*K#~MIm0${88G?{n zI`tnc<^J0n#alH|cU9}p3p#9=#$nFf&6a%s${}A4g!~2bxYrJ*$L7ZEuMPkL7sIGt zn#9?2I}KJpYXQtF_1Idf^#(Zc>V4&?wB61haZATEZ&uTnymRD`4dpMJo4(09#X5SSK1)JWLL% zkCgG%&?`iHf{-wzQFV-J1210-$<4A?X1<+me@>7R}y{DHHX=9yS2YIFZ-< zIcq2zbrR!7`tdFcRyBP2wH8n6d0_+%b6i@F$zjM{;W4!8*svR&ic=gfeGILcad4^S z0xC&#bo)Z?!{~444M0)(G(lOAR21Z-1F{$?mvoa86Hwmq&DO8xseZABEn%9F;5e{a z>`pf}zg+w&TNlZJz#a!@bQu!!A^S~$QoIYHJ%BGBRuqoPbNN0Djc$KaKCuIWxByM- zy1FJ}vXD!~veQ34luGKG=e*RST&QgTtJC3p@*Q6nw*@VtcA{`z?fq0K9PnCd+iq zTTtYj+eaSzoz3JS(rNfr1Hi4~WOqMuK7aq)>C`K0AD*aAx(jC{jVOlld=%@&oV}x8 z{~{Y?aDlB6a^pt(T=te*)eqGN9EMdEhMRA^w7eE-m`#4+-&l3ouMuBFm-=-KWNVeG zK{!@bG%wSDRYj0ocYg^ejbh$!N2J{b7Fb@GQ&ZnAi1j<|?Oiaoe=UMfaxj!6j-OV$ z`%Y2t)Ru)u&sP9@gE7r+OAGM=!k z#L={De3fNsx2b8^-TTOvr8~&BTWj_Tv~imspux3?9t*+g{GL&2%N8k4N2^0(ND9Cr zWA_=z0DL|#w`ew77*Q5rL%!hXO+wVc4(y!;b|Qwcwu7MgCP*ug;*f)0k{8J^pgQ*l zPnEdGG5qrGbu)tDi9*m~>=TGe|lH=%j+biJWOn)RyF z`(U*LyJ4`a9odl>RtacS^NnFkeSQM(^hSYfd4@5hXdp8IVT-1C%3EeEt3K;#hl5Yu z7=@z27D8C*-XR@0NwiyVp%TA*ZFp6#ftR7BOy-H+i@1jkUWO8J2!{b~D# zlG=q)En&0pe*_=i1@sB~v^TSEX}zBH51E$_KtkeS?_1{G>tbCzY;oSP`4dUSVuQqj z(P#~@eeTYId!{jE^$oS31V4~8Dl6AT6((IA9(clCnxGi&mG^A>R#Rx(rXZOb`~ayn zt^3Y*+Hi+0XMS7hw=CT%%QspUWm5p`7VL8&YP1*3`(^?q!KVS#QAdzitk7kr=p+89 zxYHlP|8&tQUGwF56y2IiLU}Xk>wb~~tXi+U$w=|XVKoSBNm?Y3Dvg^p-_eWmep?{u zWQU{vfgc$&ePI0D9D53rEin8E@Z`OjTf!oLdfqZjuPvL``zVAuX|zn3{n%oY_haMG zH(xO{Z;2HbkMv%B7|oPFJ2vb2FS3iPBlP>dueYITeoouVE@W?S@osF>5tneu1qi}-;&cgO=C_re#HFfds$oT}{EuSRT(&t1i31!=N*$D~18P|!HS zmed^+K&Efx<(m=|WosEPrc9S!m8O-bl35P>Y?MW{!LD8dEWC&_A(@#%2X>v&1p8h( z)E-*@bG8R;j2xasi;PRj;77Ty6Rt&WI3}F=7Ko)&_E-(`bB%G7qJQxr<4qAtzw}!T zl#xMJh^$SE8&w;SNV0W%l4dT7zIO-XIHxM4xF^4&(7*Xk?1iq{&SG!gmxo-pjHVt# zSDAJkS}UHXd)bd^AdbF%%aBDv3e4){Jko+d-S7eElS#d3kkbFld%x>*uKZ1bSq*Re zbJL9RN;pz}U5Q;*lecv8f44+6ZPOI#UpovLS=8*$L~8Pr+O#00+N!I^6b&S1EffJ!K1Dq&&-|%JZ^nu0d6EVT=YUxEi zL^Olk>DVij!7+e=0pyAF<atiMz&F%vcXLSMw9Jes~8Rz~du-L#-SG4PM?|cM^gF z_jEs08!0DxfPOz{LW5urCg=zpTm;uL-cV!WXLZNq_rE`<8drdu!u0wyypubEu>5~| zP7nUAs&atce7J>y$VS_vTu+I)6gsF&Ze|1*N zeX^R>fBPUOeqegBCxE)xjhN5}b_s$| zi-1#iSioL*a?~BMK*WBKeobMk_p6KekFE&EN9MV9LTq(9Q{dsi>LfYuDrLLp*`e@(spWBz zpYSKqdPo%uBS^eUt^$CxB)^EtYN16)B=s!bFFC5e$Q+gK1r81n8?+c$J6 z!l~Ubh<@dj@!^8$CHI#$zPklAk_Wd4Jz2)={#`$uF=2T19e%={o0HOAT0EBmopqM} zhgvrBx(#nL&nepL6<=yFYa?*8bo1-Z7{~l8x3opjD1t~XU$Y6Se~cNhNB2uRbVTMO z#I2;?;ymwa_;KF;;c9l+>k3Qk=9g*B1LVYqF+ktnPTyDod#^nX4ePCsq->ByQD3ll z6JrtQIbNdvNe~>>51JHS=SYI0!GXpwCKL=-HeS?U#{1OPVazIF;#D&rFg}h7W>nqP z7>*~F3Ql&J1{+^i-Vu#R=s78@3dNlOh{^g#3uq3v{lA4g7KVU{GX#tDh)sA~ z7}JZlf!}5|j4eZg?`?GkT5I!JC7( z(?$wLYh8+EO(eQtVP2Ef@GExy4Ck=ELTN@!yEhwpdHJfl@$gq}-<@hT{H7;b$0JV< zL)0x7M&pmtW2jCM}dD%!1#$P6POP8Iv%`nWLNS@-90Il%M?-zsfHW=YnM z#5anUCoaXjT7rv%Vj>1ykx-Q83DEe9ta&B!x8u-Be8Gk;$hqxhKQC9tZc!^AE zIo*#RCT+}32#sl;j@<^WUSI)ozfe5hn`m+bDJz$3!B_<=8{;-JJ8i3P{LBa2vUl1d zE?8&s4Kd~=7dG=CmHO)4ma)>Jgl1lM+kiUE>W$X+$E!FkIo!s+t<$nk_ zC=zL}A zEUHs@DBahkckq?@p@6S(-r-)qrEbh>eF7xGk}TsghfP$0*ZTdis&YKlU3QQmSgge6 za_Z+A?5|w2$wyJud$6~p^N}C^l_X^U({84-h?Dzj-u$65htbQX3_@UA?!CPns}-AC z9D7w~X->Hdd&{(HA!TNC@suO84D+`nhNB<4?NwM|<5KA!V^!ZuWOxt{plS2`L+25l zC|!-o#~+KMp3=^j3?&;cPH4UZ;7hMDb+Tprw>JRb;9re)c?7qFcErL4!c&lsaK4oE z``p#`Fp3IQil`P+hN(Ax2UPf&T*ym1Q(!-*W}o2(*w915u-=f2P716!uNxZY(+rbp zg58RKfAKTJE-q;M+-*+z_%cfM&!!P0J;d`!SDOT7Q#b6bMj;jW=7vl1J2*mzL`)pW zP2Q+MZXs>9(IhBuAAezRkpu02IGf)NLnomuV0uOdh{nL~7$kG{b#Uj*ipwfg!S-G5 zvk0hTbmObcpl3z;G(yM<0D|5@J<9WE-R!O{vDQ7AK97w@mGu^NZNf%W8TidAND#+} z?AzQQJCCtFh6PbF#Z@ax_DIBQs}31mx3?@f$FPO*t|gEL8Cn887^!zE&GyIQ*%7zB zdR=9;Odx#D9(hU}sWNfdc^M7ucPt_sULVtOj(lxjR`#OJ%g)g}B@Szg^?Ep{R_A?x(xRHC*28Tj~*fcB%wj z3RzZ73H*SyHSl#~gB(^KAJ@x1SdqdTNvy9zjr^E4U)L(-Qo6GIo)o!KUm&eq${~YM zolk?IPA|pguIydJf=zAZLYt^QkG}6j{i^wXfNPcW&%{mEcC{c(!%}O`KUZEXl#J8mFDR(6y7Qj{p%H^IgrU z9J*a^+4MvnYzjh}0&T$h5_XXZ5SM9{t6u%V-n6{G7bugyX#A@r2IlXw6QAufjUS0{~I})qHu^HdL#?Nk!C!_7r@?Ne^TzACoFS`yj1Zzt@Rt6~~e_c-^iw9%`+K^1&@n3FO1 zMEsuwD0L-soVY~F%Mnx9ST<_PW}r9KN7re)Y$;P%n>G^%(xDNUrT0Bzb+)Z4pqk)I z=r~j!5Hu6DQ7&UFVd8xK|2YPr3LrNI?$M>l({btnja)eEkqST zPDW5r`=}eyV?W5(LM5ro5LMPQ3p&tM0mn)&5ox;Y4fUIvcuam@A`QQfEF;eQV@8XN zqidga=hax4-og#5jCNs4_SJ z&FuEnRqk8Y-DoH9TE67FV}g?>1)PlilF)PXSh@T>I>h*#KL%S6`9$evZr87;eQqgO zVb^+{?NDE?*L4FQS}j3ni4!@rWO4*X9s>5cm zG?^Ioi793fVnfcdL0_&9So?mxKNAY8bU9t5;NGV)xDk(5KDGlj$x!a zU}ph5i~wkwk{}xeCxjebttuHg<`S$@-rCe5a~pk4v;?rVYmX>p_^IoT^TV%uoW9%N zY8!Xge+~|IkOI7cX44#eaF}bm%?3;3>J|6&$W*baE7=grDW=d*2LUy|giYPh=0rNs z&0LOS4B{Khqj~8Zg~T}IfQct*)#rkhYq}wV&=!d|=ef4T)h^=^-Df%la*PMt5|c*^ zn5m+sl70hUMQEx2Gbk9_-2e#mGoi~*0zcSOtO3xOjUUBOmjWB6oQ-4pd#+@}4!ve? zUGO7^B5ZYLq<@bY?b#a8D!k)-rNM{1*@k*KAZX3|)ncB2_rTw;gzwGrRGHIL|gAYM`M zf)w|%J6Rmtt!GN|ymQT8wJiEP*iAvKF;l#=&hBze?9fkwrifC=dh4TT3Cs#SMqDF> z-PG?bLqrwaiqo1%MS;B(_$t=N8L17yF%Hcws%7mkNPcfy6BhNa6A3X8+7G=x&IRz{ zo4L6wS@QI@=c$G$S~Cf%Jfbau0SXUdG2R+7?|!h`J}vbHU>o%Pz5`$L@jn{Sn*R?$e>XK(mB5PO8|0PV_Fb8I|1lwl@ z_enS`1Pjlw1*v_tl3aW_%r6DOvjn71ttd;O2br4LQXTrb#jKedkYNzpk+5invz;g- zd{YvHmdVifZ5Aona;CZ#L@k&C{YvCh5p*OV9)7+35R@aILA^0c5U#TqDi~eSH^TTw z1tXP`ZS{7+EB-8tX_h53>9Gf@O^hS_s~gYGu(0dcs(pTLM3n$kTXOBiy0XI37v=k+ zQ-bnSL8W-CwzRGDB(%k{R(1bu85?guW7Y&<;qt`6UKQ*u{Man2HHshTXr zo}IT%jcgUq-S%W9R(NlamPEL{aMwUCFo{SzSB=6hI4BqK(X!#0a9Q(yC6gH zyI?W5D6SyIQNa`k6SOiaC^HNVl2S%g6hxK~Aj}L#5+LK;ZvaKxpZ{~tbmElJnyrg`~KYD zJJM$5M<2*%Z}@L7@Z$=4ic&!$;RBWC`|Ed(!kXe|?kDk!99xe;m=^iSyjtLmO?D1I zuzs+wFdb;|%~

m+$06JRhuimPQGiI2hrVFc%p&$tqzl)B*XEpfFLpUW#jJa%#?@ z+9zG!i}w)+!nl|>7!pcle< zL+-AYUL;0vRQ9_5X^_i7GcT47-N@34mLYX*@(s2++<{{-YYS=lju*MXu2@ZC!$2Ba zNqjzvWwcEZ;^G}_5cMMf#q@`ZJvkJ)JiQ)PwkgV43uQjIu_^K{g=IiGIAq#Zn$fA4 z=Cd)leYwr|jbc^8_XQ&>^5TVDi8x5DLEL3!@+;c=K zh?#c&wREvY@9^k$D&XyrAS{4%>pbtKTpv(AX@q@QklcF!UqOjT_Cv<$HRnSQC4_t~ z9{bsx4yM@uCu|mNITz%8fVi6fUf;P7RR%f`Fiv!#uU@MkYX|JJ)j$cfMCIMrl^J@X zN;~+_@_EtGN7vYv50+v(GhvM9*R+Lwm;hBAkVyjBlMDXMsT+`R@d5P-BLouPg=uQ2 z?y7alI?uWZx^;4$%;2S9c4-9d#|QSejk{S{k`PP0DZ+)(j-Y(4LC7kmog2R~+w+%l z+CbZX{yKMNrN1ut!C@M&&9ex8q3NW%TJ8BLT_U8%zxgiM+C@<7-GQ1|*%RA@OzmGv zB$-_B2NYZRkMQ=_BCC%6j0uQixzeu5;pK~YhicA9~`AVb! z<=>5xJKSL>Ocp|X-5v@6` zGyK=|=CF98d1()r!Y7uKTb9E58G=bz5lP#3CJYO8t$0W8dgiRYBXHPhhAuVM3A+Rk zRO%j!>xVJfgvlv~MJCJ#2z0W+>a)8ocMS=KAtEB{LeJyVqR7KGo5q?}ng6_GEk=F> zo%Hkk(%+QOY14NeG(Jt)iAXaIYQruT6A!=nb5KAqqiHAf7Z={2t8)JT|v|S@!QlYoEHoxa4QQc%hQO$g`Htjkz zeRYkt=2&H(uXZ%s^mJ3JT6_sZNF-zX3)Iq_Pw%Q^Y&Oa6v}hAtByOUq{PfxYML%tP zArK)PmSl~^V#rTn50N6f&Zl90k^vkvoW(T>Af|D?C$S-I&A+zaVSsT&lZ!h@td2t5 zd@uu|ay(C?zp^C!v&C1)QW+5z7yA+M$7uL+d8tAf7uFiNLIzK_QLO%J=e5~zmJS=O z+N_ZvcZ3$|#H~KOH=BN{r9{qC^_9em!KPagX;}A<%18l=?$|oH~U)^f-*Pk=MHbNK{)RFwtlrMJ?>#KTF)OkO5ykejD%boU+zmi}hK(pwj34a=XIXKuKNcUSIi z^?d5mo-mQ%r|-r@xh1#VyS5p-3YVAqAFL#|oprICGstHP+OBRfbU6UhgK6Yoyd^}y z!WJg|6W=yQD#~1w1c)0zl@O0K3!PU9?Z67+wXR&B70gbT3ao-W)cL>~oN5eaxH^Yl zF}Om8N~@Ixy}X-6Z1MYd<=!BHI4~-(<6`=&sQW1F{tHbyc#%=NP55}7)t2j(s`rzSC}C~Ff$NC7#&yi$`+7#>eZ(iK|JOA6iJ>%gY-5PN#ZU9w9F-C!BT z5{CctymgJnv2`Aj1r!kwmPURKNQ31*94r0)Uv10(&GHAvKCFY{W4CPY4yvy_2{SPO zcoJS2&h4QS*;#MhD}*^#{w0rO0i#Tg4f<=c-F1bv?iwxQ z!616BkpiG~Zl%Xf4Ee_SU?57s7Hti44aoiQ>%S9%{<2()ev|t|o0d{otq~KCxvnzy zyNYt`nX#_;coT*)T_aklBy@sDESV*Mh$6X4--50<@T7e6xDcXfwn1DUBJ8BBM;(0n z5{GUZJZZYZLodve#Zx_<}R`lx6dVk}xSr*L^-s8*Ec{dqO9MeD2YVS^0O@C#i_<6H0Q{clhH& z-{RR2FLtX1_Dl4a3qL?M1IvtnwWbl=6&yuxTc^S zJM^#=>sP5R5oQzPMXwKAZN^Oms}aI0Ua4ufa7$O6n0rq0dbgTI%Gn<+6(pO^CAeaf zC)U8R5iNK^M}nn8A%@FUKp=$%03Azf$SM)DhyWNv4BtOjM`@eBRchr!@C#(h*q*ri z{~FBekd~vA0$D`*t9116dTHN+HLI-F<1A+4tO_e_U!a<`Ii*f4Rn zpcued$%!NMCG;h2b#q^P<|Y8{cGNMV)lW`v&x45h5%dn*OzJ-ufNHy_LTPi-RV)Ju zV;%JEq75JUJR}@8=gDzw+zFC0dN`kE$_l%?>sD-mM8uZx?^3wI^Av)*Z#@ovb;aqY zGpqt;9L1G}+cS2}`Q4#xQHh{qyIv6hwUnUaS@LRhc5RpywA@oWm5>RWm>EdM>qS;7ehB&i!#~?zbp@(npi( zjN43&%|$WojXMusWPt$z(T4+T)1_+t%I`wniVb3#^v&&g1#tYRuc$~%58Gs)k#D+5 zYMzp-0{4Qg{LDXudLhuBhPwf2H?vWBVoaI7BI3$+!A! zYAFoDPi5kn{%F+XMcUno(;&KJgfK!1Apz`qaZ zK^|NL{s&M5AZn(|9h9Po5Mk8QT`@{vPS>C(<1+7?Q$o;A5VRSCQU9~m$J&!qR=j(#y! zeK-vS^qRi&JGWr?*6rv?ajmZ7gSQV%)-3Y#Il4+(M?KglNJXshM6i|V3P#GSh&-tD zzLWJZE#V|B@27hBKX_8mX!)WZphTb8y{|s$=r5DcGV@81HkB6nPudXF;8}Q7DH{4e zw<*-n)QA5+J?Z4LeAcEPOFPwfd$7xh55J(E@02h##HI8r0BKNfPh3H} zB4yYNQ_xfyj%Un6zUK8~T3i5&Hyp{xuGjbZJ*(J}K$~A>6Y~06ea*e^wzHTO5el1) zf@uD=4q$wbPrM>>;z>a9{@6J|6Pu2+am6-cmjt#md> zvOtn}0TPOM1#a{6y~l+*`d1jbvxm?)Q0O`3B9vg*`^(feRT;OFkGTF6*oeD(Uarv3 zHcn|pZvIuBgXNudmJS1Io5r>MK*n`VMu--O#P>Xlahds1U|@6euv-IroYhf|B3>hwaq>KQhq(HK%=e*4a=TdtV0ux$mk z#RYsb$pZ`XLd^cK?u}Qs+|_CAxmx70={_x2_o@8Rt2F$JRV6>2c^eRa1GV&Q+HIQo z(VW!IY+LfNE%K%n>CG6zj{Dc#zoSJh`!mgQzfsrqHARh{S;gC}w=7U9`KiY#ACp{i z{pnjb;cAY0_>lTlh}*E%6+XBd`+fASHNOk>q6UvFA+J&)Owi_ne28MHRrTx`9QnK{ zF#h?*-8A2R(IX~3vEWGVwy<$swTN+aivnPAe5rttU_i8qJJb!MaQfhcp0;hxp8OjN zDpgu9(|<=^OuZ>6d@6BY{K-OdkKya8B|O;=IkOA(O_Bdw-F6Lx$>5K;P>dIza9wwt zFZYI!d%T)6>VJW@3u7(44ht~O!N0NL`Ue9&KKU6AtqrEF)4rg0w zzI-;VATsBO^AIuqI%;8sXIL(Z>gF_ZXT@mscaJgWB%u;Y5{k+kzZualo{xKfRN?)^ zpZg7p2K`P~_C=r_&y@ZBvl`T053Y_&yY65lF)B(bh}8Sjt;4T_OM^4|NG2G;eVuW^ zm4kUL2bK^dDQpKvV(1Dq2!VT=ttw92xNm%70V)dVg#`WPe>q+X?iJIu+A#duLo+HK z+2~u=1MfK zgPS92WWQs_Klj?hza=9~!;oL;aSiPVem26!Ejk1morfu9_=Xqk&SbU-%J=38F6Q^6l~HMGpu>|ZtuyDNx0>(NKHR4Iw`Ck9^H zX(>CX<@>B^g*m+YyZ+g~sclLrL zb3bdkmSML;R<%mdhp$b+K9_bI|V@=hv6TZ-!mps>b$c>3cgEdxM?j zg%Q|(A(J0{R96-lB*ON-2awPWnQ%NU%1c;fvQ~-z34%CnZfdE2(}TrSnhMo6BiKDH zX;Fz^kfnU2WVFE`4&RZuNv(gec_FYRkPqug#LaxrNP=8b!y@wLXc@GMFK$1!RLLy9 zS`Ng)fHY`PeBk+dWMZ_flzE9*if5UrQ7bs}-{|Y0>!u~}@qfTM?jQ3Lprj}HwA5Q` z_zn2T+SqHMB=lAU1%~1CMH|c*Sf5?N`k#&wtRA*yhx}YwfFT`wEw{@95vcG@%;o3? zDr)!L9Qcn<5p95}%tz-K{Pu_t>`<8$YgTw~5S=4Mqn^BVGw}g*O5d{~u3S4OVmv`S zH&ya;_T)uG_{r038BP0x>_zS#-+EDct_~=%fp4Da7TE-@J4A|2d@F-&&^2Ia)eFNK zLcTphF_2yWI37dZyKR#KP=a|lVeYGwH5|w?JIO+VCp$4k0Ho1N`e?-sLWF7DFa?O=$fO73;${8-rqcgRYA1AE~ zHh&nrNy)55sf2G9b+&Nqx&A6EpC+cG8ce_;G*hymqJn73-~OjNWZt+C2wC^aO1We7 z_;OP&B^n1@j;PV|g@|j&03=4Fd&~|LQ?^0=ECN{x@(re`sVUyJF2vD;lnrVwinx8c@-xIptBaW?o?O$>aAB@M*ghUP*?u zBOIs}Qx959)+*l;#dc2}eCF}R!7HB61NRA%)=gzuq&C)vuF$}MU}Sev0MA-YF_S^U zqIc)VR>}xtf}HG$gO#<7k1~tW*O$~6rv)1{vX9ELMPVCb)`&hVu+fM`OMJ$d^H^!%HBqcS$80u=tre#lYxoDyk~W4Rp_Yf0$V*VZ7g72$PGi37 zt5-+TjG(9G(1Xl_2X9~TaL~c8J=c(oiy+CP^=x&A!!|NKRzY^Yo_ZIE2wC}CIxted zl5H!-3Q3-ZdL}GFn*mtyD@!a+&_Lb-)#D-^tL`a&({(OH*pWvF7Pd)p;~jc{>tsQr4+kg_kZ5RdfY9` zxFwDgXfQ7}P@E!<*%2gX(g&a8;=)`2>vKhjvO!ekyk9xqWr9N?tKSSTZ(gcUhVT|t z>H76e!$A^G9T7!Pjd4aM><;=;{w^;9?tNkawCO8Dh=xsQ*R>cd_O!&CT)@}6$uV^O zY|450m~uTTu2#kuK-^x^`~euvn+6jWpa?&#nFZmWtu`B$Q}H zqwYD+a%hks0n(>B8g<71z{BiA-1brG3wxpcifGf>nbR%D5=3&Op+$Ak?S3y9 zPq-yt?0sCZLDyl|G-b*9xADxl3ILMEmEL)XqvPcJ!t=a{c59p$lGp{|k^aPaU7mbx zSHZ;(%mX2(0eCC4;F8yW_syfu8$D+A!@zCcyOFEC?F1|=PN>TO(|S^1^uQmIDO_)3}i{ll}YYvN5psFvTHe?#zs;jv@Pvs{_p4Kle zP1?Jea>~C_8Pj$k`vjv)Ey=*~$WWwzsq@gYtmdDGEW*XbMA;*m8VN_h;#%ZkQ&X^< zoOI%5xRHwzqKvgv(YSWGF93mhPZZs-HZrj63S=_Y)^7V+<%?VdfgYjS+riVoO_ZGQ zkpGh~2PbXNPoq$99J@MU>fG@0ub(Zef|rPlAe$**l7dfJ0w2496-^&xV$ka&`LYbr zYV)GC?)0m;4u6YAw@7+K-{Wq9@AmN?BC@_(@|yG2&4S*Uf_2?oqisaMU36yZ4r?k@ z)^DBL7v@6g$ zwWc%vIqFW1NHH(z&RO}h4WQ!S6m;E`7u~LF%8W8dnI|4dLpv@}A<&xT+6jiIGLtqw zYuBZt1OG5F=9ZEsG48PbB0}}nzgP;lgca;v^mvZ$;?ivOs{k6-y^1VB^hdJn^L$>RhTE)_w;{{JtE_b)4ex9{ zPWV|%naacYv1MitVqT2>Q=@jef0$J&wJwun!}<~>&n0REMN{ZHK#VIR+h~) zY@{0vp!M=&RW33}TEot@>?UhSvn050hRaC2VH)&KNt`HMSk3zflD4>$S@SYhB98Ei&ZgDCg6@eG3?k z^F~)9ud))Bf*;&m*%rkfZNqI-K!`^NUmkm!+rBzEQ;W^TnwP2pB2u1*Qt1Lo8#LoZ(uvhN`0Jof84}}P zkP{;tNdUMAf>`&lwHW8x?}E@Wtnq)i3 zhd%&1ZSIYKnA9G1gJhwkK>`8OcfY^+B4mIrfJZxtKKmRZjE3(+vx#IsN(_f!25Yyc_Ys5c*54cpnBX_dA|wU*mtY z-lu&Ih3+&lwko;NZ_CTtCHy=i^G{Bs>GAuuA=_k835%R5IHgh%v8lV|w6Kus@3wD9 zVz(@N>v=2e5Ds1eu)+k@9{E~=6#s|d+7Mpd`Km{HEalv*{L51^ZF0-DZ;d+92^ z&GQ#VOHy6U2C`@PT={7&dVgg|mbu?zjqZ@@g8 z*f%9DZS4I|IzvKY19o$ zS47_RUF!)n08~6i&#PU#&`wP@CDp8wbWfN&zLtw2xHb6KNNN&}%%W^UnKPqT+;}3- z;69ZbZH_MI*NgYN~oiJ2Q%=?g*rajdz(!9N*7?!PhwzZ`L# z$_mcP{ES&Gu$p+FeDBgwWoeKJO9sg?D1(PR;}p8%DtO%<(yVK;vp3cE6fgqH9~wR1 z%HY7Bb1oRn?vS%>JY*W;;k^Oq`zFB{qJc`}Z_dlFl`*lx`^Z1H`96|HQ{C{K1B|F1 zD!g6A>8ICnk)?gwB|mTJL&q0p4{N~f!M-4y6>O4ZZcAn~904JHO^C3!RNsw^$x2GvSxn5$jnuOBjYqjn;IiS{Bjcq82QuDK^D<`|w z>zEW}>NT@4N5;0gU<2tH!7x>tbo-L|Z;1z=3HH&yvUsSBj=fVbv=wV_j|^P>k2?Qs zjC}DzC24}u+*UGbuqt9R$~TW@SI$YdP$~i9^f^TIu70E^06QomR%L4NpP)IMoZ6Cy zoqRgw-aU|wG}l3glThtZ=i7A^<8qiD0%3%@$?eXus`&Uf)eG43DsuKAX@DWCn!yW> zN(=U+C8$Sf>EO5N2+Nn+;9U&3B3&xSGGVEi7oej>z15t-55bZ7UEIGJG5WBZU8)`O z>PkrJw`J;z)TG*{-s8=vyewl*8F8SXF~}MolkpzcwsU(+a{zNiZa1k^(@1iee1p^T6LF|wgNKr*B<5QC*9 zY-Ym#D>8%hR*tUa#SsDNHGO~^7W?Jt>x-KGVyU=WYHk)XT3XAk;vbEBcD(um6Z8N* zc`?R`qkY`g`?T}PgwWA<%U*l(_8li^207$v?fmoRT)N$sdkq5#khAez;l?A-R~xDo z2nEs^1r~<)BB7VH-hlS6AJwc#-RSsp&>UsVLI5HE6J-6`sI&d)w-leso5yYJqOzPD zdN&lsBl1(aFn-)foQvwsSxErEWkO7$f*JcIDH@Aro~MxbTojY*opI-Cm=Cut05cF_xmH@1^&6m0_D##)%CnCxX zu?`HeRw-7Pgi9TiEZ$Rsri7bz1h#wEE7}qmO$@E$*m}23kwO2+(uFjVWf>=b{2Av{ z3*l$83H;edm*PhZ5=RYLz;I-kj(?4gfwoq6`}SM2xQtW{du|Fo^VNYtpWXB&rh~OP&KcMrxeB(mO~k3Z?DcrtN44TL^8X~Zs65Ga&of=TxzUaORE;W3Nb4Qe z0sX2HIRM)cA|{952B9pl?-@?$_W&zz?}hx3)eUG@tcuFze_2KGvqM%db5QscSYx9T z*=Fo9tE12FHSjT3pjGiN<|JvacW~B$&80xaWewyf(82%|=pcRu}%PqrrLZ{1)$Xy!2#(_jf$4+CmnFF#NbN zz4>6kLaHl0e-qh0nQ$%8tm5e!6ng<7sNy0m9Q+$UCGf&1Mcre5j;=e$UvX4|aad zE<`a%ij1h@pCBIE?J6&%7lU(n_oo001Zn`lg0IZHJN!y|(XKT;bmK=Jtek#cnQiEtwH3xtY+2;Lwz>$np=!~C;au!qsh+!esFsc3YsTv?% zo%G^0fU%~U>eJUlyI$!(1`S#{gQJ<&0)vZ1nZPzQS4zKc3NM-BV&2HYLYwJ{=wWS{ zQI;v?DTEAz8eI6r#i=*Gy71}ZCkAKo;`7ss;9CLNdVzfFG~zM!Tp;y@R3S*nKox#$ z@{jZibX`A3fWC~CrH_CxCew8<%?>p4;#3PDH-;|;6GVRhG59Y9l*kZQy_C6{`U!@n z&o9n=%j8AS!h%mi^?m|)q(z@<8j!^LyoX9_`|}<;UF%bg1WH%Km7kh?W`W>4VD!E~ zF0$PJQCg5-TTGRTQSIOJu@_9$6(X4g9cdH%2OUXT!kKS@z2@f^r|N%F{_USV^iz=$ zvJUEKx*9&!ytH_Th?sOd`9W8KHi?*OyR-~i;%IhCZ2!pKNY!hL4Qefe!tyAPWKtU)}A0AB9G09a6I4QNH5_m9E(E zV$|%iaBTloh=`=ko6GPXeJ9sk1lcE%v5?dr*gV|QAEbG5yg7{1{Lx(IKroYPS3w7s zE;%gmyO20`4lXT8An4V`?Z!NOXk;cGhf1tZnXpX9Urx$~Y3zh#IfI|JU&W9^P08#& zICB4x|LZc%AzaX|mPQsoC%Vo)c10zmWa&`3&-<_pI=)*Eo~XTB>0lv9D$z`kcW zvTGJ7$xyll-gFJ8jwsG<<{6uHaJ2TtZ#1p{&cAu-`Rz>-j)h5f&e0Ars;sGu2i_>> zkeW=jh{8U-Rad#)pK`C7nn-dVFf&9xu4n}t1_3o)(N#VT3BewE6lB2VeD zjZr>T?fg1wbsQ07gEV_|zJ+Da6#QryTzit5yVTyNf#@gEpW40zUgA z`m@h6sBY#mVcqKCPca>S+5OvD(V%`lhJaVQ^;iO|m|SxI;5scQ*rP(|2$O%KTy%p* z{;}K?EWRt%cw3-N*1;Kg#Xy#kc%KV)per?}Ok8*hcuLI9G)9=2hfG*Q3RyGwEyJlr&(8+$Ls5@`TuVK*ua{Y$@*wadHdqtP zPb#&}xtp#00W4ptB*JkxIUWo2&2GcQugUAaq)PC*E-3zG5>1Iz`SbYhw4_ZdLNT6Z zfi?B`j`zfx@M3+MNq4-JhcY!MoUFOQDTnJnS|P9Uee|U@rHg0)sW8OYWUSB?B$;b< z<5^E_Ex`g=!>|3pI8!j}u4&w@BYxH|Xlp{agfp)uz>Qq2-hAeT1o4HVm1XGD zs(+*Ons9P;C^n}dfI_>lKGP9IZ1B8e+=4}BKWz@)F94gn?BIaxp(#2A?EQd`fR2QW ziM=%;VeObkR=-~vWGR5DHpi|*hr&M#`$*u~{U|IP~ z4{(_gxO@}5sE||%#*e^c*TC&8rLZ?8gx<5)YBXcR18pc zGc5H=0RjWL;}XR}E+&=+Cwb#MaS6|{BAb(Nd;gQ-_8}J2^nwU6H)Kx^8dksEI z@e%hjoV6Q=UOmd)a2#9h?3|}hZchApf^EBH5y%&WyNBP_ymJ<8+v)590hjxCBV(s1 zDp7SMIaK6hs;8j@az#K^l4t|i*roD{$oCsANo^Ed^R+#_L>9X#QSWPjA}m&>GzI6T=lkoV=MVc47iMqtRgNv z+i_fwsPDP=)Ggzk$T@h(>TED;sH@7j#U0wU+Zxxif|{eC0}j@Y3CMPYj#Y+b?FdV# z?Dm5EZ}9gcCSi~y+>7H!$!r2X=U081un8s;@}H4+qd+cLMhJw@8xZ?}k(Yyp>^w|x zc7tDgkxVz~w+p&A^n9xP)0F-6P;Emw6LDK#a0CuYtv~`L&jJ%i)P9`jM}*~SsXW?T z9o&D-Z6j^-Z@QyYwx+WKUan1Xs$|`^OQVva?fpwVFTbv1?+$5n&WcaeI28EAAZf3d4fu+p3ui0Eb#6 zJ5a>tS1)lfsd6By-Q@=~INBZ>K1K!2tcrPa|6Sx`(qxO@r-L-y+F+CY5ST9^|14Gm zjSoI}U!7Ncb->+*_-D)^WOp;8E3|{u(Lwy-*wbtHyELwxhGZ8e`y-i+q?>g%;jVy$ z;3O%@KG|)L6n>K%uUm;3fN2Q0*ne}tkbr~5QL&Ig8YvX# zsDtQXqU472Yipiyec8l(&f}b;nWaN_h%05x+j~aUL}h}DYguierCVXie%ue1hfxH2%8kZ;Yv#pp*8LSX%00Jr zt7n;>eH8;-+X0gYrScG+ugj%8Qf~T&kD;v&Lir}53iU=}HCf8*PhEw?O&lX9Pknk# zWDtnycC*IljItZn;Y@=YV6X6NcmNJE9O$0vEzX)5;9I>XuGz8zK(~7;2N2t4=)w&S-T>x@^@(d z6McKE@d1Y;5VZLid?^sE4medg$vEEq$JVXN=M=JOUYmoa@{#%K$qG-}Xg+*443?Bx zWBF+r5J(@e2BA+-$!=Rv1S8&>qImcDWJs3Qy!5dtOPO2EA?cNYr`5MmefGo?SL~cV zn5dIj(Pd`dO6c>2Fvq9j*8K^N1)9Q*yp}*}oX@H5^R~4pb~LljL=PgF!+0?ZD`<07 zR1a9lgbyEXJ#Z+(Lmy0lS+h7mPA&Xk?TSC7OWAl9{5YmTYh!G-M`9Xm5C2GyO&ENb zU~je`xMVZzPBZbV(l@;xbrxCQkBx_m_EkR91QolGvOZ#zO4yH;rBdRlua$ z-q32pv59t&QgLlCFUq+w^w!2d8dm%N27R|9Y9KI_z6<57V};L53j2VR-|K}@`#cr= zY0JRHPk#l?m!@+~YTh!R2HO{n1jA5eC$O{^F?yg20d@A$5qhC=r1tW!L2HI6?E}f7 zM$^H?)AtlQtKD&$OeNOi8g*k=PsxeFgKDc3a9&{N@(Zs4vLrmt6_2g%4$u}HYWAnY zRBQ#RYPxgwoCZ*(pLGFauG8n$O*t9mT&(~o5&_02f72YaE_L{dP|*${~}_| zJRm`91I33oj8S$cD3bLwAVu0uS#pR3#CU>QMKL6>ud*Hnw+t{aLe1)CsA;eu)?+<}0k3-2_{O+0#vWgg&Iy+AE_5N&}+yHE2H z2j-f01RlN)ym_KBL;n|N*_Ma_T_;sVa$UlmS<}eMDnM3*>W|@eK<~Ne& ze^_K@6|cWf$rnQSg)QLBZw(%CsJz9)a26x<62A?Xwcj9*0*4L-vw@ey`Q1t8f!387 z;rQK^Eh+_t;BKs0;o!LBU}zOBYh!fQaz!&7{u8#cA!#1znV>MB|66PMb6{gv$!;3G z0$VV-USx?A3$TZk=vy>Kd=`O24Zq;DDd&kO;lH;F{*(?7tC6kBL<|7O03Xw6IqnlV z4PTDb|9%I!!g=L}#qUf{P=OpJ8?wSiPXgVADx-B(?VH0i^hY>6Pz?mNh5Ybm&Fu3( z@HIdw<*$d6Z|6SkU?`s>WYZ8I93(y4ZX9M^E9}gQE2ly3jGylP0t}n293s4D2MB7V z48DGnN$~&*MWw_=4oi=bJ%^Cq(YJ~loN%N2B3pBYq87rUiMiEc<07ax<+hO@8hPY~ zuLLDSy|Nsb@=35|P6ZMq9Z+aj9C!`;Ax}-oS(UJ!9Z>;=(f$;b;SUx!gdb{bzQ^KyEJ~641wS~ugspTT#_=@uHU=hgJPbZOgME_>w%&33iw<1$WL9Vkwm~B z?*i$T{4aRQKL!sc8p%$kx&?Nv42JR_7_1ZMNkM{`@P}VXs0MQ`_8DiuO1vI?wl1?~ z?xz-0H32|BiW+Op%Ni*&{P#F6o zxEn2>)8>8yNxmU+q|ND!=*Eg02t1b}f~kz+a@kp?hmG5!>1&V?1%-ErgRx#pre^Gy#EG+Pyj zz(weP<2a0M`kFI6^f;D>y-b&9UPau|&w{_Yqq3)-*89AnU9tKxn4UGCaVF%oO*)(V zR_52jqMlQ(8bdSntWWP9`Fl1tYb=GVwBSCv*itn5D>#>Cx__$+JrjufVmJg_&ob#gIZt$|QUe#G8Nm(H zFk4a5I>s_}<=_+Kkt8Bg$+caR1Qy(J_hyr(`W@>nBDfOP=O=vZa-S3ty|&Db4Js%K9c`m+C>A|M6VO` zC9mU4o~&n8hIQ}Yfiha!;AMSN zEo`{I^cYBZLQ94n*)<>-GfFZg4Yp;E4Z+bI0HHiRTl{9rMt`cj!S(XVDcT5`qE!Ro zHcdKc=b!`hudR|+WgNY*iH5x$3`FX$9}&snxf2Lgu+&U%f4{n@O5Y~f3U>R~S8ZD7 zl<8fs?Y@k`Nz=JC6+W1Ese)^GVs|-xr>!LDna;a8536Y>6WuEpsj`XAj{kv5WA9re1bP2@t$R$E=b_0KR*U})jIq= zzX>CTcwK(Ih{xaOz~v4^xHjn^cn1Lgm5`EDk;+Ks7|NQ=9%VX(9JE}e6!w;l&vkbQ z@hWT$EWZLQ2Vfew4WtOgM*x zeVeR$;spO?m`9Awb0l>fsFpRhdDjS;r9Qd%@jr+v#r>XY2BgvZ#3FHD5?CyPTk0Xy zg48&Yz`mZ|?Au1Pqayn=I09g90ACS4_Nb^fz`xlK3EE+e7TqYP? zb`8+CT#vhEiX2r}3*Jop3!T&CTUsm?y@8Q}dX#irK=7eFfj=Gu4^(uorLX>Es7qd>Y)HU!_FCl znY*IJqbIp!T_L`@(2oU}?D3<~mPW+(0i|W6dmz z+>?p1#zc>1bCU{#`utNr;ErVW@5Mt-O0x!QIfi1PCrHubGv;R5Z+R%S2>G{w1kU-~ z4NVEk08!MvWI)thNM;hMB8hi%@}0)Irn$OqCQW)ZmzvHl_K~mNI``hm9^LK*HrVH) z!e|}z@Tw@Yyv_)^@e_>M+4pePG(qI(in5M54;87eKCtDXQtR0l!@5bj==tk9Y?4U5gR>y{j#GS1QF?oKzzqZ$%Z}(esrmSzjLCV#5-AY1_rXqZhu7a;0gY0gWmyOp8o-f{aHnr zNJ<@~_kCpnqbzw$7YsUU!dY*^XfZ14bNkO%KDatkL5`|7uxi60YuS$2;Q^NApx;jd z6HTUDP4#J0-me7xUevk<=ipZk2Krf#!2jqc&@ro4{&4L*!*D169*Lk>p?X}>`80~R zuUV2ls-PX+lX80;V(1T-qu(?S`N1-S8F>2s0@n`qiRkwu3M(*N@qFsM`3%n^L4|fj z-RFo=>BdhwIXm+<+~N0iP24~YV@Rp&f;vutI&~0I7H@fA*9D%#Nq&dk1gTlhZW9PS z=>anw64QRs>F$W%=Yn7TPaF+@F@I9t0V;&B)jP%jWXfk(sC-7n+H8xg>J04xukDFD z+{fy>TEiv={fmi%C&!N=zGByWenMlvZUk!^^vYV=wr_ApLSph;=x8RxUD&=3F%Of= zyZo!%of||hu*L35@rE3R_&BUL!OwdhLi$|L8gEXOr0$BFOY<5%cJ|;nNEDbZoGKdM z?CwX$=3$9~u$5fd+S=*!6!7GazCo zr>n+#3hNomcn{_v9K{Uu1NnX0mk-&`q)5Z(ECtjOLX5Da(h?x2@$sL2cNKZQ;F>am zQ$XQo{0tv{)!_mD|CQI)N%56Qul>h2L6M~_!{;u2E+BnIam#%BOsGaSAaA`Ivd`f1oxXzZim`T)FlUK9M322r`0r>8B7B zY5K9x>j^aVDQ4g%dubolK=@#w3$8lPsFVFlKTapNNfV21p9j z+?6s(GdlwE#xFX;C#XXT)qDYUOg`mDv;(Y})7TNx(4RNY7Z<0S2cdPK_|U^XVn>sl z1oFgFo%G9#li1hHi%7~qDX=u1vk>lz6dlQ02u%ZuFi6vUY?R5-@jn4pM&y5|#h{uH zFhzQC61Vw$File{Q=HDnuid|kz&N+tDzxS2M@Iq*{mcq8r@Ga$f3j=<;GK1(euhO& z)|j+XVf3UBGClv>AgWy*$p_hqDAV+uqwmW&ms)k5gV{K%Y*S!u^rxVm>483@QzD$7 zW5d)~pT7RF^T1Oz!wdpm+L=!-9rQ@e#_k|kzhazJC%x&tadLOk2Bi|EbK#w;Dt21| z;GF+H9Fq8`(OQ^hYK;tKVo@f{SO1RcNga#Q>z_K)R3Dk_Dc3HyFXDSBnJxHh4h)_N z=*6D^yQvO?P&|r}wjyuNeHKJ1I-|beZ^&X_tPX&w zmUU-RTWhK_BNGWCK!Z?VDe9IvIfFvYt^hPk+MAJ* zA{G0bQ=)@kf<*a(jsQ{*4Q(P;kd+UGTR|$|+xDk+b0MlQqkp?NoiUpvl|reymD#W0 zP;jy{I^QvenPx98xYz4H@yws8isGNxGtE9p=L%>knEP9&_#aEdlDUX=0t!(T^Ks}P z1DcJSgUoJ|Z{Ljk$06Aj1rFYNZMo$)#>5te)eQYFVFPHy91a}l7IfjxCb~ng(=oAb z&9;z%%u8=e#r>tN8nEszqiPp)5{dKQD^{BOzw=R3yuo1jNgn8$h;5Z+DCxiIw2qV_ zS^YBFN$d!r?)R_zxL{dQJrK@ulN=?s2Ob6yo)RgHGjV7dxk6^Y$s&Tl!{V~=p>zxg ztYk9$oJMNlw+_L#(k6i=jz7(qBDL|Hvq$xCi0hBJU+z~qM0?-jfJ)dZe#B_-drOxs z9+OXdl|+!zT>GgL)rCyqjo|^-B4xJ5Hg&r`jiUoN}DyjsREr@ zr;{byut~7@+a|EXr2M>PAEJk!I-IkDx6fq1xg)Pm^1LD0*_`b$E7Eu2$+;Ct!en?F zTEO%3MFE9lH-1}M?^W~IGjP-ScB_eW|MK50c>B7TBogjatdSRZkT69U7^!0tdrMv+ zxPJtVAs$Lfts@Eskz?~z;YL@q8>F3TKDC%GT`-`fp-J)8y25usUNvjAj~-dV-)e2L z-vpt*rt8r)|KrDy`2s5Ihz^CP#JJcVv1>21&*V)eyV2~(@IiZ9{k<1iRhvOF;J83o z0;bv_fnHQQ1AmAhs0yHO_7QP^RkPpb*b4MYO%h#;C%D1()*9o|!(Zw75zsh^*dS%} z1`&i9*a`U-gyL|CuhaNAF`br=c^~4Hc&SOTEK5|ebMOAKcQtCvEPLA_oO;%#Xq^(7 z8fIa_i($Vy$E=N&wX}R%K+6Q5K%U3WXT*}CaUP;V*1R$X=9anHXgR#@@NW|hVd5v@ znJqqs!w64!R_{j?;TzFIo6u9QDB{s9?UR=~{cM0%n1$W0VB#VeY->$CSdAh(S3x4M zVsB$TYMot&n79n3GWVRb@OIm#f=+7b;WND!PKSnmEp1L*y7+yudz`M2GuprXikcI{ zvp#bZBF&_x_;>{+2oAp=L7IylZQ(6>E38+69`1WEySy~^Pzvu^DZv#Djf7fBg^~2z zY_i#FhiQrZH^vx?O+GfzlxMIg^J7zR;$9ZXWrBT9zaPX@jvn&If8J1^T21;PHsqZ! zn08td5lL?P|&0N0T|af-fcq-2;Jj&y*) zWV>E&vbxh7J>}~sDr5CDO`S$c8hHEa&^e5KrwG=w@HJLE4ZE<{ED-i64P!jQZFr8w z`cKwo-|Z^_;!!^z8j(nwl%%?2 zs*qC&{=)k2+#&F+VdfHVfdHZeXF0aM_1~uEh+~t1vRj4E`-*<^jrtU)MLtHCX29b7I8?FkC!3f zTG||GgYR}oBTYW4)Iwu5l{=1jZl;ImmNd(He)5bEU%U<^$}i?$gU6gPQG=M1#fd3P zD{hfUtYK{r!ubFj)SmYppNsA9osN9*I>Ng-*GmH;hrScOgJX1>20t$tZ)R0j{?R}h zxZ7_qEx0&6nmU^*ss;F-A0RtWQ!}OLnN2cdeLj}6yPDt}@ z$f!Y7il6y(WzyAPzZ9*pet8!c5XN-zI&O0SH+n<(I8~qnvo`TNh^qs2O!@H@LS8<@ zMZ*yGy0lruBd2Pq9j*z_u&2A5$=sECJb8+RC?{IG+66OZk%W+g{eKcz9~u0 zN1px-S;_uVa}q7f`FU}HBM}m7y*W#$9t#dyw#7@BmkN9;_YM{NdGqm-TPaz-tI=j*5F5E4^sKO0VSU=EsG-ztE9#2euqx3iWQQ$AIAE_GyLl0UG|-5EY8**YjY((?S6N_G-;xRy?BFh)RaY-0brhbLKqbx z1Qvg5BW)8>GN$-jAwvW^Z#eN5r|_3*g#zesLBt3k&`g5@p0Ppl_^K4_NjM*xD!TKL>(@Q3GvJ7mpT zuRv{6Zk9#a6VHR*#gK3l0zG{litdI_#?=Oxwq9X5EV@YFLsSe&7C6YUXW%FzF+i?_ z&wHkb^hpbiyiF2_3K+FT7j1)x5*B7c^92Yo;}7bY%?f!wDDq#odD3Wq?%oX&gdBX) z`czlL)k&%rfK8|z1Z#1|hxn<)lsOkbkt+CKdfS7oTO_gDYatWcguLJ&X0$jZp#%S_ zB*DX;muo0nLUg7WYGyWPb)PFAA52iFReO9x>7dyY&nC#v?sD-^vffAepgWyE8snsR zA2qmbdL-(o3e3@WA_udzWg7zH%kS))X z##|#jkuJmKbE^e^8~Zcggy>)%{lw$<$t`rZUKqpgup<}d5F<72y%A0R}2_2oOLIcrhJNQHp2PxB4_M*47O#3$xB; z)095|xHsl33oT0?FlII5_FH_(QQp1|k)`3rkS`T}g|RH_922Y1+WR!M&HrxL>VWMO z?vPIUE@czCp;O;Pu|S4p-LS_Jd{>?(UB?oCDEJsbd(KB#yJ8abCfa#XVe~hj$V{G3o|*N;9T+)#p=YoK zQC@W5pCuHlsezJ6sLc~hPY9IOOT$Cat!w=q8-UENy(A>={@e=KzN@?v ziiHBWA)aV-j(`8C+UustoVk+S)jv4Vcs_=oC%ECcFyjIsE9 zZQuI&Uq->VbIHHkGRZ=XbwqU85_VCprw-nar5OW{-k#KnKQa<)*Fy4pZs%*DDp#>0 zbT_N)A~1bxn^)3MVNnf$C(iA#I33#L0q~7i>{F&{Z6DW*C)H2iQ#+cV{0t5$mUfiC zzJ!uL!*blz;PNcB*zf)T-f6hPE5+|Ob2INA4?$%BOeg&dT}`Yex_CmHO)7Z!4auOn z3{a#fX%zgApC#sZA+NX{_m2rBeJYnCZu`n=D=ig6w&x7@_&1P=%FXJNN=nBEw5zA~r6oR4mj5&fW6TI@NtyHvP$(KUvQNP@QiP2AgF}sBX^tmYI2YHUG#e%z=-(x<-%-a+1T5MMho2$H)u3G{F zL7g^#B*<$M6^}Ha0uDxs^#CdrTynjF|G0tnuIowQJ4lKq(OUm5)4@}M#7YqCasYtY zlt;;b7do6#fIYZ3U8nn||8*7=-O?l93cfk2dgZ6Ey#fgo15jC2n!uxS1zq;g8%WDk zfZkpuVtrYTzYj?S#4hBAv@s?)R!V6qL2o>WIug=UnTh)7sqo!U-A%fG`-5ajRlOOu zVsyfVS4KsJ!;>Kfb&@6PA_5Ke?$1Fht?^0Lhn(=fXi-@Lrkwvfl&JKG8cfwqx*5g{ z*J!OAE_S*8W*G9!So0=)Zw-kfau0<&W#_(L1_{4S;^ScsCx48Veq~~|FLT^Sw#`LR43BsFjbj5TF zlpRirAIl87qdQ>wmPQpZt1QP0@GW-@An1 z(IBTgBW2~}!*oDYNa7(&0&$W>^H@x@C?>%+MUa|M1Qk5p%c2+O($2qXmM`p|cy#^J z3Q!SLhmS#<-vV}Eoe6=a+#ZeZi`6i^rOGk^QlGUaCCnYsR%Wo^PDB*0Jvk}|_+KC} zQU-oMU>SviToJU*lg@+?XIhezNw9921^BI4=9Y_60VjuyJW2r&>_|L4X*GKV_v=tw2Fhhmd*w0kv{_S_phm$(e}1t5vMFhTv1=Yyd8G(0>bnNGICE zw83-gcHhbpN_eS2`FJ93t&^mjv3cQK7K~IlK(zb=wmHa>*GtyG{7WWN2nZ+h>u!T{ znc6Rc*;MhdL9ZW_(vid~+2MipO1c|hk3%wAkpk&>^>=T646B0+C5v!MG_N~fLm4`s z%@+ZEkfGWJDJ&!20)ztvi%Rm)VQqnyyF(`cYB{g783Ft?^BBrd@ofr`4y4mE;mtNQ z2n|-AbLCwJ*&aQMpc?FtBLbz3(T*YQh0$s5vhQT{@T>Khntp&z^IulwF6urany(es z7d)I-YVHcV&itn)bTm|$(}Dy$Tk|{X>SfA%+X}DZKaj&lY6-&f+uhY1q3(4<1YKJ_ z=)(QHKcN?hrrh6cj4axR=l~bl;!^mL-q17|IEC8=n2ol-fzu2sGS$SgJqW7&xxdRl zLp~Kj!TACHk#==$S>P@)mQHK;sqx}G~Z3xMHOY^Wou5ZSE%`JQ?0AV5zV+zm6HlC2s4 zRQq#ah$aAOtVb8e1{P|PQN)dv@MBs{AkiD-g|jX(-v$MOvg`QRac!a#LK8K#%IQ-BUk7$wE3KpOsAKgenTJFyzE z!@Y~$9Cj@k{ja_J#q-p|U!>E3^>^w_>EB!g!kR-ix568Wb=C#I$ zfv0PBpEm3qr~%*ql!=ucg1)H^z4I3Abfo6(t*#wRh>Q*|Oqlayy8#H`%_<+Tde=DK zD`8E?pSjJIVaR(SeE=PpM4l<729*1){e|{?jOjn6ptbGb$X;NZX)~&`$he`!3=gP| zrDf{EMiBoCa}^)wDYKh9%>2iR*eW#e?kN`fPYV`Gb~>=~&#j+P z-7R4DL<(sV<@<`0g^`WL(X~Z#O{b#G|0j3xo1ha=<(W-gZ0=q5t=Tib4i#7HPrm96 zp-tAA$n{7dm{5yWg7d8lQF7I)DT*#m9WnZScVCWLZCSQ@1YL}1(QN^Md9 zeZUE>AA4GdC?Ah2Kafy~Es*bu_ck|1#ITx8qDD8S_(gfWuh*a(cb!b=vEVi&bVR*^ zWlBL*T7r8A{hq-VP>B&>w`+r)5Il%u2~vLn2V?97yQA&j)=pms!u3z_1GoOWV<+V; znbCC7u6;)vkY$6gLz?il?hgUNs0#ZUZFi1kwX1Rf5L}TY`&=|EE1N9L;>9;E&ZM=0 zxPg}Zrc;kM%k!$WV5 zau~W$NsZgQm>@jMpW`?J3(WI}u1G-~((EPf3X~R_NjFPZ<=m;>sjXh~SQn zdeTu&jKoi;Kaq3H;1r~%8Tmyd9igRAQtRUzm!`W&1SgM_I$5gi;2nNcaD$Y05O zb$D@CNvQK-Xth`Z@cEWn<+gIaA|s#Eaw2-EP=coc6wTKt2RI$*9QXC<86}hacPaX3 zgLmu_;GU55ri74V1{3oMrSK;GN6G2WZwB58U=I1KSy1B$x>NGz+%~m#wNFroPZ|Mv z=6QA6U)_xQR}zf>_isi61xN!zaGui=;#+KSLE0r(r1+K3$d14JW=Mg5IUO7=p~-W=VrSb=g_(<svEMnTe0dm41;vzmNT&%fXw67`(9Ipibg z_9ud!zx3$nT-+y^!{Zq{!Gez#ZZ&7#5sEQoR?$F!)Q3 zGdF)D$4?wPU*!0}av?nVuMu}@Mo0cqv7_FLR^VLt^5W3tRw_Nu^jvGcY2(*v^gEVE z=SqP^Fu(#Odj8D-YXm))f;gZIUa{p2{fz{#@R>~Ml($k0S~1OHsyRCE=-hK}n>l5p z16o!PE|K);`>F4_68y{8JTsus-e7@2`A@UVh38@9%&5udfqP3Cri!kT0=FVM8klM% zAc+|jkPe^)n^m;>V_)JYiKr(>0g(NXZhvM_XdaRy?Tk6pNba!&u(@GBe<+dIicd@| zpHu-vDdlZiND#NlNo=U|n=j@7^r8+H27pPDJmnOi<=l$m4t;GFZ#BPjr%wtTnvNyh ztH2I*g#XmP&E-jE_YNL@XSHo=zF`=XV)%)k#hKw-L4iT&CMv>1u|)cGloX?(NxDxT zkf%!svXYd)#ym-AfB>3Ope>oliz)(Ky+M|LVVhuBO0XsHILUH#7B%uZocgR|5p8}m zrFx8`#!xBiPkFyShgK|96E%o8?*WDbH<2U}TH+Y_s%e!tzTIlm2q~PbhDCLUs7A*N z$vtR|JQ;Q(JYV!gc7kim{2Fp0Y2=q-1iBF;5gj!E%EiOK;#G^KE%{=)&axU1lDXB- zQF@v34+MoF^VfAXp(cKf7pa#p$UGLev}bY;H%}P3C%1>>RD8z=^^}*Y@mScJ<@x>s z#VS;6Wa?ix8LHbnR#tkopHfqQDVO)?fQA&$FoPc|TWVtiZd%7+?KYKrzR1EBTB@qR z1%%>EEVZq^-9tbY@VwnQ7w zM)vo{!G+?a3cyPi#IP2AI4<0)f;69w)~a@aEm(6b4$~n=c%w|ybED};o+f;fk#^U_yF_TKU71EbfP~;CF!~{4a8Xv`{bF`jSHgs%{L9AeuJG){CwTpIs*l!*dC{+xr|#P0sEw~ww0Io@M`PWF@> z6IZ36A<#)g`<-EBi$m#nDT-=z>js4XV_LG&*>@guJN5b2KX3x~Bbf(@hN4wC*p*_{ z%<$8^w**n7Tb7` z@)Wfg7OASye@Xu#L1+*cngD51k%e=F7wkP>IF-ud$qHcy$5^Tb!f{oF(PS`YOULk| z1WVGpjM;?gj{H7F%i!f!-hPH!OmVKb6Jq;Sst{K~6ebN~ZEqaqM6Um@JSbp|zg=8` zh5omw;lGy}NIM~^lwpy1ylwDyf@EV13zCc%Si*AN;uOE8=x%!})Ht&z$*liC+7Hd| z>x`26N9%=G+j`1O6Q8p!dVj+Gv3c^x*TJd#LnkH=W|!F-LGh#hoF7ioU=+zPthoA; z!Rl=^_0Gdih8T<9C`(Fe^N@_A+J>!=bnfJd+U+9N3yNkJxtt(MNSgGwvEO+tI@vMG ziW%A4A2>gN(v8aVD0s^bfdRTi)>v|r1(rJuH@#b$BmOw^g6~x&Wi4W|AlwLX6Co^W zUV9o;1Lf0)psoi`x_=UO+*`3zt-VZl)nCys~_bZAR zIbA`XH(r)>DYgl?3)QDdZ~B~5ws#h2H|VfRytAoiFbX;9t$$p78Dxl}_FVEBFqw9h%)OO^zsis= zL|fkeo?vPAEN{L)g*)%sil2D%>TM_dtyGcRO01*IV!|1g+g9X1_s^MfZ!GuFPQ{V< z<907fc81_THmYQ@JEHD+^>CPe%|orxM~eqTqTRxKc9i>#W&4!1Y~m+GHN}g`akSjf zM9P#$;??GbIx4vy+L}|{I}#hqOK6+wlK$M|w*bAl2^BqXpre1COsC6JOJVpu+V0Mc z+3ri~O|$FH@E=8tY@BFG7qZy0MJomxBV9i36Ta=aL`Er8J??AV<}uJn4N$0{93^sx z_Yh06&HcMav3f2Ch^UnqB8q^M;qJf%r`bomi5v}A&!$PA*WiE~(QA_n&}~TtlCAI3l(ySbC$N1duV*di ztfih)y7=H^nU)e29UvGSlT#C&)X23d^5ba?tqc}NJsaHpMC;b%>oj#ywCARaR-QL# zQ`|J|Gi6nxf>=zLDXu&?-m7PRjW#ZN$W?o)gry4XG=pP_k*?;9+@SW`iS9r)b<+M6 z>+(Z6Dq2R^m8C@@@}^qFrQRzu>v!;WC7)*^YIEpV*S+BnTiv`rgbS@W)cEYV(VQ`2u5ixFY&W{OlzL1AwoIlnRl zhmMzLGdYdyH;o>i1AdcZ>PT+nYUw$3we?KQpdc%q_Ohk@%G5KhlPo21_f8@^YY}&V zQ;F>k=k)65h89juC?e9{X&ObW^7jq!cU7oTrysT?Ud}Ha?IF5}o|_jpvay&mIB|p? zDx7H&aWS)fVDg;(>?#`|>E2f}NL5d$^$L@z(OS@EW3JfaxqYs=!EQ9bYMv|XE{qid$8X=7^P#~WYyv=|2qDjiKrE~Sd5 zF?IYz_3ZIz9S*5R+j7cX)73R7a|dy_Gxt*Bo45~qBQ8SmFN%62YLvepDRem#b`xah zhTU)y*NDoH9VV%5`UzVITBC>%u5O7FY+ZCM>>PhJ`EXya{%O`jiRHLCx3=^d_B*Cd z$cV#iD*C+O4%$ZUgR{HYJYJ$ngf%B`(@er zG9q$gW=c$lU{TGw*V`)LxVf72Wi6hs2H49s-PHBL(qe3k>+)Qe?AYeZqj*OF?5k3 zjDK!gMsi0vYLeF@=yL`iG3KWY@r)9e=&eG?Q!4}hv48w~lUYm?KgC`_0mEYp@J`C4 zCa0+umA`K^9@gnF>s%ctn93@SIW)qL*^+uJ_U1LFpgAf}=RmuYrHom^ek9Q?Us#mn zas)fOLVfqSK>w7%c}H*`H3&0jg-!E`s|)_e)5^0E!l*60xr-6DhczWr$A#9*y=CKM z%Jab$lU-Wc%$4mI=!!B7-x2(XTbtbAyeGKqtTa6*B}aR*Hj=>}Q6k_tILiY2Oq&-2 z`^|V-h@=oQ8j(h}!J7Mtu}sxhH=ZVnc37fP^M{{zqG7Ibyg3IVvo zDRkaiB-cIUk=N3naMlOX3>1N(0lUFT>q*kpN7Jgi{xOk#lE1B}qGfZLKE^WVQ1q^I z+)GME#BhGo-0ZGJhd(5tcsqaNuZYjPI6W1koo&XW>oAVvV%|@$wCG4sEqFdOc=KGM z>0&3hMwhbC!4oxh>Q*`>b-Z#P()-~soq`EEu{M9NiN3dUXdA zY}v#S+-b!*3{-~vM!f9N)R<0+9K9T5e&fa)g-h+xxbhd-UQ-~?c!=H^?`TO3iyCQZTk2>qF}~=A zHtGm2Af?5l`&!6I(UBJy+O!WBzq-SloOe>|8t(k(x1VAuZjNuOMNbSyCLa66Bn)TW zt-EDywtHoNapHXsY@Z2BaUJolAHx3ogEG<0B*M5E7HX@dURx`})F#wca~#{Je0A7dj>j5--m$Q`Zu26b-9D`l9Hj3edEm5l-4^;x56-p% z^T8G_p&F$HBZ3CALkrZe*Q1_=;wa zm7EpY4jVWzRHkFD=;-1F?unLV!z<%viOO_uwpFKOgba=NJ3KncU+-2_hT;YCI{Jsf zvIPKc9jJg#;yBpHVc6C`-m6{?c@aVxDFL8R<7L|8#sA=4bPFFwQTCvEtz25k9dl#kV@KE)Q$;Mq~93#>^_@ZiNZ!0IcNF zV+54`oXn7Ck52|>TPh6Tnmrt0K*+LPh)hnu7x6NjAq4uXWLxs`M{{YpP4ly7RZ&o* z*A!*4)4%;GM~%xI%@Squ;an77r2Cpa2oln(M_4)1^&O9tClMwuwQ`>SN!`Quhlz08gikE?|0Sy&EE zpMNHj^GW*+%7Xbny-QLewe|+0+D<&Wr>5uB)1{u|Gw}UZSA6&F#NC)!91=j|IZl&p z2NEfTs_oTwjKhNVz@IhKOiQk`66@;p+=`5>nzHCEjqL4HL!8Oz6Y2NT^x6)mw;t9s z%Z+>#i5=vsS{CZ&(XOhb`TsDBZN2cHmb(3U&|_vqC(3U{b6J`mUNUcBSKP{a9G^#y ze9uobjqmMdElW@IC*cd;CpXq;n=ZcTQ;ZR;twD=bkH$KEN87ToV0(e?&`(PS_-9<( zFnpI7@CT~w7|67_q@Z+8$xYc8u1Dy8|?V_soL`+O6`y3`SSnk-jdM*cNj+qUN z^c*5H6deuBLM8`0lbQEMw!Zhtl%obs=dM}yG(UYm4BtiE9gScmfbGpQB|w3yj!jK2 z(RcExTvL-5x>hf>B(X@o(`Dq^j+5gjP1FjVT%H; zuWhxL?F{h{`D`p_+(AxJRXUC~j#5I}dApGtTfvRG(lmd?Ixs6L1j`R(s%37d6C!$i zbon_>(ke>r9U@O$>R~!ojWKPW0KtH>kO*EE(@ z)R2o0eh*IjPPaBr&dZf-@xgNDoyf^&_sgGRWn7jKFj3=;*@wvrvH)R)fxV-mjkAx@ zH6%mcfM9&T6qX6D3iFwX55%NY3l(ZkCJtQcE_|8m3Z zTFNMObdn{DJ%A+blQf`D{PIj-Hf=)_l96ABmVy4X0RLdQLKV7A@T6s12rtn8#t(E% zwySHNeCNXsG)ENw=+`nLKk6>- zr5b?4+YH`MjztuqRU?F5J=((}Zb^p_U$I1XKmp$=w_G*SiJhg)WswV0!gV))q( zORGYh1J7|UUsjB}Q)o_-A<)?(S)QP9GsF-ov9~Pe^nqer&VRlv&~V(T#RE!% z^Ag3{7LzX-IvX#_N9@0~F}DhQ!qErxduUjUNbh;ES%GdR>y%D=)AI@ibU0Vs%o@^X z<;#C~h>6uAU`C3c(n|F4T{S=ZIUyBB9`wYK7Kv@PQS>%ft4f=e!*qb$C>CjKntWt4}fQDM;sp z;E*maOjwp1U5_4Hu=*}Zjf*CIH^2(NJV5a!4=fuV<=+R_L*Qzrh3TnoPwL)Tcx={h z2SO$~$C67xWg>Pp61 zSk0~)8}l=rieGIy@p4V# zZt-51KT7kst!cVBs~SuihH_u{IBc|-8cXc)CBHktsv1b_Q7g^6;t}ew(Kw>itND?B zjeAUXp=4chXrQ;3{@!andZTMuTfzmFom(-6c;T($I$_f~MkEELo?ZM1wTw#HD_7%; z+j}lh;*PMf=$+>HM}7Lss2LR}7$`e;v(ro$;$?=@0pdfX`JBX3!LQRlOZw*~ccu@@f;R7x@wWMx! zrZTB$vd-W2Xd_L%6|s_FPgxw^7ty&Rpez=N`gv&9G5stp8x?v>v#{1T!FG?i#@NN} zO)5!|t`V;ytlnlvBlXCAs0&B1jzuZSxq41^w4RGGEJdWfj8M-kms#YOmb^jcA0G1Z zRI8}r4M(*iFv(G@3&)KD^M3nb=*4?M_Qt|CQHP~=VSE0o6P;1=4kLW}u7kD4&vVDm zduYpNugw2G0_T|LZT+kFtMpLDQ`8wFW@w%*ab%bDZ|ei}^0Cwh7SyFh_0DomwAQF( z-xG|v3{YH89e&gf1Bk>ZigS$AuB)_1F#@%8E-+DdjTU`(XP3+Jlbk?u&z?vZ|Bm}d zYzr-DkKFLr^xMQ_SfykiK@Ubf3%H*gUOnH#(R78s!!cw3w`&r8wU!^=sO);Ix$J|U zRZ4cT8htER2Ao$Hp9|o_2P1?{LV--1xQ0IOVg_jusl0ZMG<3h~r_b%3_jM>=?&}}O z#lJSFqhCq?PJb2duRrC z>>w<@mj9#aX{Mr#8U#Ar+5nP`SI7>}UGQtu@RpUh3)=%rUv4=Q;Oby_v2cTf+$x06 zcw|8KRGB$5TzHxk+t0~5z*Y6ZaJpjYZ&+YJQLra}^5(sWuSeZb6jFNs*S7$J|F$HOog z2!`de(aR`tvE`mlPx!KRGvYpr`lz^B?$agI(aRil^l8@FE7C^Hm%M5TbVCE8e+@Z1 z+||HIZ9c;?qC1G-j{Np-rTN4qnP`J;Ae5PJklb6|OpRQ_imVeSgS&Jh@~&m#?lm_r zO5ZEJ2pW0X+f6}w1#5g)kvV*m%~s`aK45lT1*+aMqBF8CAab3P+DAo1kLQ=BUq;QB zK7xmNCQfFJzZ|{XppLAs{;Cbo3|V*tlW|tshIJ$J2tk`(so$20cIH7p8a#;+8uBm% z=>t&nB@ZA%zexH(+K)%8PNJos4}D02FRTc#*$KV!^Na>6zz5w@mJbyfOHl9DSv zHQ`fl9-Rw~SWnJYCJ{kUAjeOg{Y5C!D?T@KNyKcbG4%FlU6CloE#-IAubMdr zmApl9vxlTJ;T=$88B}4N+g44<`1;bH|3AB8WS7FoyyR2pvr>t0)Il$xvovR3@cDE9 zPhByi)1Nzw1j3Yd=Zv^N>F57jS47;EmzV|VljbGsyD3PA*37tBqIXj|Zoc$q!$nyT z*>IURQX&!1T}`P(zUbCp>hF$ql?)KQn*pGlU-jqz)D_S@6780Z*w30ZvTH`WVT?%? zuU9gDzG$69#ZSTz{PGpIp$+gAUo;n<<@w)5E|ti?idz~e_oOPLEre=rh#&_d0p=NoxEVH{~i9C$&GU0d9!AT-c{=w(-_rCfq2K0{k z=99QlD=!jv6v#pzpx#nq&~fvm&Ecp^4(Z)AGL(*?pNX9k!yaVb)C897>(A4>d6Gqb z2v!MX<6%48lQrg&8=8EcIGMYB$7_B3FYY&ob1d}DMvbZ~1D<5uY-?VGH9fsZwRt^m#3S)dWL&3l54=mzFf+LH$g}YMNgaRAp~lEjVM-NF`W8_+>IVzw+w)of{;({04PzJqDyY3`+Z^FWM<`&BKyf+$}6#-2O zJ^1711YE-+f&rx>bvKlH_~?mrBR?t+j_jY42&>%NABb_o1K*3b(~AymvgHCsVc2^?k`QfX|1t{iSv(9>A>H#`+lC;Col=@2jby^Vj z(K$y`y6KVF_uU5;YWrmM&qkzOe~;%G%D-Pf#1t09uF3<#!`%8%zeg2=vD4oCR0ehM zhwQo`62s*;NP^p0DBVvTIx`e5^)UX*e7m&x_e#1?q9vdGs;_&Da0X2Cms3IJiA+fG z#@b)ne@j_@|5YuR=)FjD{@C zHHh*YV5L91leCtW*){jMX_Q^liAy8zW}E49L^p|}z<`Oc?-eABA5x$G&PVab-evVk z#>2}qU;eu3=sN@bycWK>twL&hHQN3|&*ABC(hjxqVSAI0NjLb{qw4RD6ivO)I}y}2 z8l=r+PrmU;$qSuwXj*(T#awr#>*f4m>I&Y|iSK_mu-DB+7!dqdc3caIEA#0PCyXWf zNBhR_P&~{l4RMt&g?jm%s(^Dx=6LEt3{}2m<%ne$EJkbDAvyv3gj_02D_s8Qx$`?P-v=$lluETEEF%ZFMpNmL;bj%XML-+w~Tte z$S&%GQ^P?S23CA9$0g`BZsNVY)AjuHqO6`4-?VadqmoP9$Fx;d!~5v@nqjU-_Kh@! zJfrwVgqueoNiXqjdESTjIK`RHFUe~?81l*znR?+C^JdXeo5UpERGqyxbIXp3eMHyU z!Et5THP5=$KM=jctII^^U$ozUiAiJwhTv^aI-}9He0*D6q4niqxK=pUD~r9X63#=5 z2hm1{$-ckpJGthe%C)iCDkaeaVP(g{`|ev5^s5G^b44iRtS9v03m zO2rgTye$o4yBDFNk#tLE1*hBMFxT)ye&h40%WhJZR`%@X{e&jnDNIUm*>(UOu?s*R z)4UZaM`PXc{W=z8Rx`IFvS(HJ7yGAaoPJvpd6xzeT*sbtL6Ib8q?p+>5OA# zDicc4Q{GmyABTwF4Cg?u=f}xPU2gvOM>*`vch`oOAF)+@E@)b!P-c5&NjS+RDq8#W zJJdDXv4t;x?NTjw)t{i)xA`{Go$A)b}am^Se$zd*_k?w>z`zchQ*ZCWBVsMZkY8-g%pb61T;_Bi;m$2Ejaks)| z_h?QP-dl{Y;NQKXJ?_)$Df4aePWLR)YjTUk@CnDiOeX(FiP^fW{B|@;!vTY+4}XSm zazkCh-t1dG$G6TyZEG%upa`%jhIMdr{ON-AFfFB)a|> zc>vmVafg0QSJoob$9;tmF~xDtmi{X6d754XW*0DZ_3=J$6~CahkPqpv5#^ z{sU8=Q$c$qYbH7HV&;G(aU%ns$|XUUDorkk2gzH9QU5a%7#0Of<5#qqdh0^+s&hdL zOsu;-o%tpe&#U z)trHsJ(+Rca)8j$bQTyLFXx#G{6vdBi_K zY>-05R4W7gv-|;X)XErGdZB8?D$0F~nI412o9NAVHdu79=T6u<>*s?}v%eiLgP!=z zuEI)?33?F+ACBeO!2<5`L_L<#05;~Dm~q?;8HwsQveBmGSza105|CUpVZng$s~OFgq? z6?X1qKZNeWiWNGCJo)jChSvZD6#^L&lKSr?8iXivDTRLFc4ZcQn4zZm!m^I=-$@Qx z3~k;u6j_UaqxEMPQZ=JLT=W`PI#xT%K&s`97!Y_)24L5&uJA8(itJxsA{G>Y8sU3!^&kaSDzd|toV(aA~kH^4#>ax zb%V&d@CtPj7w*P!1mVapqKoqH9d;tw!hnc>u{yn5+31E5|VUl05UngS1xqhH3wW}~9<@sN)7BxO|P;QmZOLF{oJ8W49**;gYJR4M~_ zVvh}eT6Z}D)Dxi&Vc{MIkUpK`2Q_Kew~UGAWH=VY$2-)!MxK9Ps{77w>@AyEU=}>O zP@8Bm6oH+jf7H*l z5haxYQ@-TnQAkdn#Ti+kj%Iwo5!?Ka{V6s9M4p~~rl4s}7(*azXkCa*jblCBg)*+! zI|A@Z%Xou};QDo6W+T)I8EeD4w;s5Ceb&&8!pNpBklvc`*^eo7B?W4YiT7)LC%$n= z-Qc`O(Vb^)M)>ot89^+A5_90Z09vOL$P?cZ@?LFkS_cMG%iJ}8ba`A1%PLVjXX#J= zysg&G787?v;_^aKk)yf}1zC>A!^ifq6LGq&Ud4jv9y&NdWJ@mZI{i@8a5q~!j1nBP zgR#WI>+P%>`%vE;v#Dn(iE8A#SAq_1d@@p#%vwoFdrPxps4#?`jIt4pxLJ?}rucBm z(tcP7FlV$(^jISM#1%+Rqg;tfr}&VulCMYk0p_)J>FyLz!2fr#P$~md|AQ|mo(C?C ztHgIuph$9{^{d^AQs;4+vLG{-)w`EM{A*?5{2K}~1;2_bs_$$%9((W~d544p^yQi=5atU}mfJ*V4;-JR@~ zU$kt}%s!$zbVtvnNqFsLNgGpuO3;e1Y|?!@Kw)&E{p}#>v9VC-Y+JamNDwvuS)3n_ zG8Y|X;a)VWsOk)7KT-`)SybzUTN~a2)mWt%tjlU^C!<=-dChgZLr< zpP~NU{tXh`VEQ5bIB>hLFcGGA%*eIL3*IF*MYHl_SS68wW$M+JR|fn!!{|OqnHzcj zK+gRTyNLbg58e_qh$DHpw0rYTDgeDT_#W*mDOJ8qa`TD0Qr+rHVQ0EkU0t`?&1A`3 z5*k}!QtHNu$^>Okao;)4rb2$r@O7rUgBHQk4Ult~5pQrD)L}V2oCCl7(t5K~_Dg1< z_MUVj)WsE*0d;?L{A??krjpJ-sm2g26Be))0h7IfSW)E1rWE`HWD}fAHQSqH7_qC$ zKO(uOf@ar_@}b<#eN}%~fALGqQ8NNvrLF~VWtBP^hoZ1-6`dXCanYM|qD>Y2jO*@X zXS|gU;wr{?CN5j3-IJ}HL;h%dL&w%^EU<=~VR0;+8hb-0L$&N>Ze;Me_Xa&_eq**b zwm59O;>7R2l$c~Sl5eO?Rma3;a33SNJ7n<*lOsP6aF}3lcr6QT&jl(lDePW>m!Rkb zdst_O8|?nTJjwL^)|1<2M4XIgd9`0V$*MC~6VIV?%)c?9-@1I?{z}V@`cI}3{d^Fd zIRHl&*jh`CCis82)(nWYb%uJ@1*AY|C?2IQS$?4^hu@5VIKa+h?f~-0y-2>@Wh7kX z0&6!XrWA8BWj^xF8!~!l+$JBjvaOh&kLa|5e%yz=M4D^S>k~td<~XPKPQF*|Im9g& z&8m5?G@X?)Y_C%=vXGE8=OZt&8V{B-W}t6p$5hKIt7UpjE12>|Y$8dp+e5gN+Ed?m z_Xx5hj}>g?DE>C6v7#g#;*646t^0piYJDp`=`RT!PKL`WDBOZ|M7qp5$++Zwfv&RU z_>=E{(Mo{5An?s#oeH;z^hnAi3p#`{s9mxl%aG>tq95K8^}83{DU3Jg4yf#2CgWmB zylq$!2N0!uJ7uyL)R7~nJD+26L(9ZQuGN>it{w@QO$|{|cm?uQM|->C*BM=S2pMt& z!`Ez=#M2uQz0RJ}Ga4WM{vfQe3i+!kX;7K6TcIN;$Zurwo&&6T+N;ittUpq8Cvce_ z$s*63o2BKl&g&U=S#)!@XyTNT$^26Sbi(d9Lg5K1Yz4nevY}_(**4mE?W^>uQvD09 z%yJziM-QjlQGb@pHTHtjUn!c%$y5dW94c5V0S9oF#vb2Aoj3D-+t*aDWE~+3xkgF%-7%<%WWC&3WkORTN}A#6zK)3I(3)^g(jmekD*DV|K^pSP$_#p`C;~La8V>-ESE~`G7RzC=`JaBSmKyiqyMkR-G7kdDyS(-;I$P8`I(kP z6~(+gkf}tXw`L!`kM4`@bPs{m>6`t5v$m*GM|}i<3WT)2oB2F+-`E2R7Rl7*oHcqM znPsE`DeIFszKjzC$zu#R9=0NHUZ;?`h?MRA9ZX!P0>AX%@4@Z{y5XEqg$l!x)Nojs za?@PKsZ$U&MCxojmAg zh7|tVRPw&Wza6&SB&B*s^6o}1%TecC!7S}Y-}LVirf!J1h))_39Qd8#>OD+qw0;gS zN4v`tK0f63F5kRR=7W2N+k-xgZ_5i!Z41I^+D5(^dUrrKw{3c2p-y?p*!OXFgbFTW zYisOvjcK_d)Du?AEUe1iLIYm^;Az_$tC>s@- zbf9u-J8mTtVw}dIiAfHV4knCYOhUsPFlLo+Lv_B`+Ny#N3E+5OC}-|_xk*LC0b zbzj%_`rhU`#`K@ay;?2Dh^z(k&I-2?(T|i)A^=j+Mbhx9F!RF1**`_#+wPFJ)|q<_ z-(7anB*-INX>jlWLtIM}Enk|!za?s);s+pZr zJ%8D~qW13d96o4p6=%MnkLx@FBV6=?GwQXW)+}?ifYJ+F}ZgQaAvv>XL2A zH|M{t@9u21HI_P5HI&1A&j0KTY$i$z>YgQOV|+`jrd0slcj zc+3Me37bfZOzxo$o9C6O9PJ|vy<@I;l=4*gdqs>vnKn9SR>c8EEZPRWf&MG8(O3jM z#B2BK)auUqph}mnE!F0f=94D=rVma*`QE{W&(IGx@%JsVU6k8kD6DXq>xqt9lpSzK z+13mHn#Fi{?g{tmd|NW%5}h{VkO$EcqmEzu@LW<*CBVhaO%E>)T*~sjXF?+LZ#i3Q zJ)MqI;ht7{-nQdo{@wUIyzBIU>H~(Cvoa5hmQ>_IrD!Ln&%5I6!g$+iBSP#wn^F48 zH+V&ofm!V;SPVcp2^2RD-#dbUTBkD@KTLSv$Rf}Q*8~eCeF}oqEci{)>($pa)3+H8 zMC{dHPby&|#H;pddwmJCez{um%g**b!Tk=e&&!vdaq3;)qe~p_T}-FWct~WL@XvTbp*K1>gDOs;5oM<93z*4P=%BW6KIZqA=UQyCOrG~l5LQ7Y=eK0H9jPD zCjK`E`TB{=T=j~2A-DZCz5IcXT{Ag%jQu79E=_0tEdw4KHik)yC)A*Wx#&#P~ z^%TGRuR%1`>#86D3wgvp(ES&&5s;+?w91zoymxQQxgmY2>@Gb5e=#K7W9h2nFqMQ~ z$FTz&VfMfx=!i-8y{uA{-goJYvxEg{-`5%`2IFloJj<;75y_`dU5S$3*byt3XrcFR zxKS#Oi5t;Bd~>5TvHJDKFO6Z}zm%XV&CoBJMimqyco0JM_*XOM&GB>thyuVj<|#Je zXH16Mz0I}PDlDesQ{1{QX;vK z@ilQDhwykREElukLy$E0wi0Pm zvENh2Quq)t8bNkUdMZdjioMuY0{TaQBi}sM&_Ie!+N2P{#w#Jvl}R!`31S7CRKDv3 z?ePX6#`8(-L2e}WZX`~~?yKqOG3Oz7BH$dULJ$PgBn$ZCtCs(T1OS|bNC2Qse`~6= zgG@-^tFo>a=B_;c+_H5?inuM|8MUgiGSjvEa;h2_)1@<@ca+s+n?M#oFYY0>J7Zf( z82cNmM^gIdEC4XF3vQE(@SWU(x8^9;tQ5)~Am#B5?q~Yy94<_L6_SgI@fB{4D#`?g(Ludqgqr!ARp-t+0)4!~Z|3x#Tam7W`uxk`i z)ss8ljudB{jm+rdjjzIejH?^9_U+H^B&U?(nVY*ESd}YIrSv|h2UyzbrTv)Cd!wln zMBw`sLs6! zNW~E&JSzF7!;-Y;-8Oci@75}#bl=>$9dNAJ`;sgenO-8-Wmr{Roxb#mfBWQ7YCH__ zG!ju}wMbdd<;yt@>|IAN0O3{jFc^v#8hCL-Ym zPq%FQ5ux@ngVRD!Z#;ES{VDTgQz7$+ZY(VdAQwRefeq%FS1YHM<%=XpxVo?Lq@(9E z(o2q?`1$}#-M=$XJZqtQB9}Pw+tM2^&`s$BYV*BLIeI0$;qjyZW9K8|JlI!;awDuW#&{>Yb4S}D5!yJ03q9DEe&>6Sz^J$3uj zY?ECqliKr=v$wGls`^g1Z-Zix3v8Rh)+qo9H{su9$q^Z+b1pO})WmK<;P75WEuP33 zQlCm-!Acamzv4$d$VLGEQtfe*^Y)Rb?RtsM3XbX(1l5)Nx>Yfz5X{_2@!~(3uSDLh zk2y&IsgIA8-}w%hOUDgK3gQ_Ld?$jD!EKtz0Auir@6gLL+Pc5M@b)%8;koOPKp}a< zL*+6E_Y%ZuM!7=I4HX49j6Oz(R_(#<>p;E09P2dSA!39Ix7Ek(8kZ6ZCwD~}2UgAP+(7#l|6FHQlX znt5OUf{y{94|36@V-tVW1rHi!5_lK1ml0C$o?i6(He&dP{7;TC%;Wt?1<-Uhz2mi< zo$YVyuUuc%_Uvs+!o5Gry4|atpJ*=|KDGNV!|hGS)LySu_w4f8z7@P~8HAVOiF_I= zWYALnz=373CeH~j2@T2fsI?!syG*5Ar7S8{9c@cKNKToWYh6U!_2~fPLW6a3_YIz{ zSl?n5}NzV4^9K3!(&qwjR$4-}4ETc{0-_Abq2Iz+e&rHGHF{cqYnq#w35Humq#=wt@& z^gNYNL!CVd^+6+1ctI7(cBaR7z6fbKuc-};8)*$(pI!+>UF54Bpk8ps5w6F%TjcwX z9Pcl3T7P}U81QHUJoV2-)La^40;f(z za-?yZ$wQfGs#}>Cp~%8%>7qcRTTjk)v9rZFpYr1!ool)hB<^!dx@IjjJizLSBxSa9 ze!5gJsIAs;$#3TCq!fCSAG!5m!pMi)M30T#JXA;1m#Wt$CCJ6>$&=jk)UJ)v%AsSV0TF(vfnkzGq&B zUN^mYbw*fINNHnS9)3rucd+i=+hlRFrCE0~_sP8X?q}E+SWq*rsD8p8e0~>^v9?0< z&3!Ec?7ZPyNFce8-|M4G$n?zGs+4dg%a?uDSX-A___oDavdq}uHrx6*BPPwzL=iF< zPc}Z;d9}}pX~%6aE;K$%$$9|0sOv(cYSJQ8ugP)*1iLd6n>>&UUpM&YMnRiKpg0+pVcmEjJ4uQwFO#sYqp3+zWPnEDI6uU-b$vJ-Skh;OPLOzY zwGXf#FX(bt-J@u8ZeKg#`!}Bq_6o}B>f%E7#~H7yKLqvWnK(V(;=iFX;!&*6S3-$$ z|FR$aKL+wGMiS4<E1?rRL<&5?_(#ah|(Q zpX!70aWxuNV{C|Ozp)lCNuZ+htLnj*!QN(h1>@cI*RWq#h*%H%cRExuM6pr8fzPaAK)ev&JB-W6BVDsQ)3t>?9zPWT1(L+>tt2f5QD-s;2x z55}Maq%VMa$jja*7}M=lVj2EEw_R;JKvG||CH=1#Hz}=$kaBa>e4Pv&E%m3$1X;2| z%>{PeeX@MBf^EA^V+1l*=mWCgZx0;wB~_LAJsCC>3MAkGe?TcDlDf~$~)T&Gh_&-j#EmF z-QGK~pac+?gPnFy2Qpqkc)I8|IWZ|=LoJFlLq9KV77vRq>6#IZ|A5r3z;LsWiN~C2 zP$u*~r9jrC1j5TXV%$`+#?w#EcvIN%BVA)~Ym91L;RV0vFxnvs^nRkpJHfHH^MkU| z@MyMqrFum@5Pg66iQ^I%QYH{tw7GR1^e*wQ*sf_xJNe>IdE0sAeiw!5Dp4>vHe@Gx z867G=UUBOX@~X&>10e)AzFO{1*rF`YldqQfs7QOyioyav0%J?_hHido;JPc2kZRj} z=gytDqqR#(Dv&I55c#S7Z}%>I7{u3e49%cCz7JS_ZYPr+prjI&HoR(oAOe6``kwLD z;)SSoy3IK#_LTGXnzb@`H8zHvhFkR%D19g*vW84r!WP6Y}w;`^YEV%9u_)nlmw1HsmK2PZ}COU=MF0Obhc zQkGIrG?q0K&bVt0c{)|YnR^?ZR+u>U$H{9eoT1;$d~v%0*RbI9DJW@j$!Y270@T^g z<_*XD)0|LkWiRMi!)jWEjC~8C4C3&L?{0QP4EI$b$FOwSeQIh>CYJ25O_T zSEuio(sgjDIOeUwknt=au7J`)x2|@4wzi`9&AY4@#DnW}J=(6`iFvwXz#@E^Z-)Q! z#eIQow!K2^VBw{NxhXwAR|K}%JMG-#m?^Q1+f?eUMA2vfFBe#LWOOqlSAGlOV63f# z+!Ny>d%!)%wJ}KdiGP9b{Qqsu$^#kLIFLeq{%PSm;Aa5OR$SX!yG4c|`uu)=R}FNv z;)z)Du8A^g$!%=?F+TwLhs-G&ib+7D#23~AcOZIiDD zM=z7*JS=8yLD{(Wg$+0s^%zBs3|$Ck`JnT};n1g*ox~2l$g0aoG#fg~>kq>4({T0epVH>FLC z&TU#Vt=v7D9a}W-#F|Hw1G)bboWZznFBipDC{;IV%bf33<*ih%vA^qDyE87Fe#Pw1>(D^%5G{jmM5U4V z-Ht=cqQ>v&s|E->fI>hl;LcDefb}hy5yll+-Dx|n?u4^JiNortzFzji8F$4<_WR)J z1NzKw4otE9dH^VgJi_oO1T1NbKUia@P7KRM7XfwC#X}h_zMt!xl~em1!sGWjB?-=R zvg;A{I+LcHuDL+85wC4~Xyq9e%jafZh-BcfFQjICUzNx0oq1sXG(k^wQSt3dJn&H^ z**(?d_QDISe1H4nb-6vanaB617Xwg%A-vT<_4aClxqDR5tZqB6SR_1J*tDn6E$62{?X zKKd2oAn)CHf5yQFY5Xoq#7m)H)1z7#4(e*fQTMe`#8hk6BPX@)JZI)+w`s=!%DVYD zUw74`>Hdx6*(kVdABu5x8Yfm%{&;_6!77x^ikMW6nyn_&_WB@Jn$TF z0gMmJ!(&AR9aTo@(sWJ=XO(2V8k|mrieZ@caw1viM_>R8PQzM5vUSi>WnE>rBw&;c_r1nf6!u=*RQiuLE?<)>|p z6`B)jqoJmDrY_UDE|YH+bx2~=kZo&q^TUHAjoAtAb?Lzm%xVU&?zYZa@s7LBT>Hj} zqmaGId2%aB0V$)W;GkOJ{bqz{Du)~+ZhgYhVXb?}jeJlTu`*rshFdHUkCtBq<^#a< zs3Eimt48Mh>viA%lfSEeOcAn{K)Z!&zEHp+u+DbVVV(7&z&U|I?#u_}UaM&Wn03WJVNqgOzqmH7g8E!+kRGp8PVll2QuceH^NPBH zi-fhj+F&bJcTZYi>&&?Q1M`6g z%B>5B8flK(tz%jsycoB{BVzQ(p;3Pzg}G_wX#H5}li+M-hJUSYlE+ShiSX`RZ9o8D zXmgo6NcL?xPqUjx_8YunZB%+2mDT`22Yim&rj9*(7%Re*WD#r%m!ocSEO}W*@TzFM zlnsW22S^BDlSXzy5Q~lVJJs~a`M+3A{wFiH`hG>m&K;|v8#|%T%X1D-!S9vqp9XL! zw61I?PuS|C^ueNQXK|g}=a&bp&_@7YtWvi7w7p{{&+dKyUn`RG+;E=i|jduxB8(Dt?51adIO3WW4VK}Poi`@)9{ z4Gw2K!rRbIo4p#h#vUZ^1ZRq%2ySz(PlKj5RB$l%0tlcujr3scB7^MSTd8U=qn+ht zMgHtm6AXbQ3fZgaM#2@VkQm^0DR{Z1R!RKSo*e4dqy}9O^8A#wl-((hVfx1w3z+;$ z)&Lv51&>2->U?>V9UJ_3pkVs{CbOSBHBy19auFz%__?Pn`FotK&x#)*9^Qp=^z!AK z$#b#ymkcevq$HE?8QNw}%4(3bp_4VxmS=fGkM|_E zi|s!(Z8t;Q2cA|#EaP`@vVH<+!rlkx^+u4j=sa5s0H4I}+-B2T%eX@$Eu=o)Z&6^= zF(tYo_ep5PU~=yJ+Z%xd%nF=p!0`ihfDDgPeOr(9qoVb%-A8x5;(D!WsqXz$Ozr9b z2CyydVl0NCvP(y2@jS zv-;T!5tNypNvO65E!^j#eB*_WXkZiQ4YZS6Z@xMqC9CHn&luDh?-2CRj3LA5{E%L8 z8^=4$NQ=_R%kF;)*d41JDE;%eCY~GNRhMDnVUFFZPHG9RsJXr%|h2gvc{pVsq$Z26lvW57G%dmH;_i&~N;6wnrbvj5EH#atvZN^_T zi6@T)s-jp??c5Qbz3<80r3*UlYj_?@UhBTSw%P#Ce|gb!xBQY`7h+_ULtGX|^jCx8 zNg@oqjSRUH!ORAmO~8Ya$515RdM2B*9O#-LZuCGV=&>Et17T6tX5A@CLjNdo6~uhv zYJaTCec<(~KJar#=+hOK9Z%Ji&M)=0R|4zh#;YHH_;ESOTJIoZ7`6{jm3J^#GSe%V z;mKVGKJ&-5@A?T8_&e@9mi?7>}s?!ThIQHVi!jxJwd znQdg@q4-Dq`*E+jpzt&KgyPC3@k1$3kPmi0IM4mikUsl z13DaRS=TNZ%iU|EAD~UU3s@W^xXVFb#zXsw(0sZ|AS-VQaG2bD&KP5jx0hbw1N z*HVTGY5{OhDGfiObdM6Ov5j~5CyefJ{*RLrHUD$Pn7l(dti5dBg0=bF6ExV@EvIqj z<3lHO$>$~Oo3^AzFUpl~d?-w=uxo(u+1@F*&w)0j^^L(Y^%u>$w=FRwOe4NE9G<@; zw(Ugk)w3UCarA2g1*}3#4F8oZ@IZY`yom9t@y#y(y|0O7)X*}V$XXW;i}Vyb){bBK z5Bv`tL4=^cd zS|+DZ)q4y)=9>az(79!qI-JL|vlE9pRpNZDjo+MnRimYo6;i{!!gZmN8t*ydl2&E_ zaXSRUxkqc2yt3{<{eLLbL+R{ghX4D8+IR)^_lB3rLjQs~!6})zFYyM-nPhT`3FbnW zi}Jaje|a5U?04){5Gw@BkQ_7o{al0Lt%`S%AI*TPq`T0wY%tI9Rw}TSgt5(T=h(X@ zIP-TR>?Kr|%SQ_Hk<%;z+6Cvi#3RgO7dhoqV2QUI&g)%qXW>Xg2onUIGV%CtDJv$f0?8D6BcP9e@|Z}!97tBe*a&#g1^z+SB7ML_G1+`8Re*s^$Yz2G=Wnu? zG9m!PVS>*?iYr0rVopnOE6C$fR#G;b2`ZXz9w3X*C49qcGGP}8&SV>LDk8h@la?TY z7;pUdm~0T`d};iIV#h#1Ut934Y`$T4`9?P5iv5nsv*N$=@lvBhC}qHA5d;lFBO-n2 zxJD;^8yLwyc}k>)1qtZCt93FH$#~PhWAY$m3K`P0cTF?-Y8F@>wT4KXFce zZQjEL(Ut4DjwV`{cU;cDh%})p?J<$}lc!q_Fn0Y4&_%h3E8S_tCd$x-WOEgY3!=>l%Cdx`?dd&|iw!2RRliU_ zd_hY>GXCGJ#K(~z^3=yPZzXaQ9#Y#WqpA;Fx9MXRlzAbAH}BAq^QgYgLx^NA^se86 zN}kDaEbxDLZyRy*AM9Mc5#V6Rp4Z`XP7m&oMj795euQ!njzXgWZ|tZPVUV6cw5ZZU z3O?Y(%Al;74gP zs4m}idy(Y+V}uM(J*S7RUB@%e+e#+>Ty!3L|B&Cuwu08e;J8kA`l*YaO{B3y%z%pE zxNXF|;G#ZhH$yyj!dIfT$|eaQx3?1 zJig2!9Iw7P7HukQ^Rj}9&oJaX?k_m6? z(5mgXZ$B$Ep^lxtA2WWZM}EK+&{L> z6{SZAN;Mg@k2*Pb6GS95z7L?u9IA*-(D8G^eO&PCeIx5DebfHw>in!oQd5T_!JJy! z=s*(b=?dAx?l->L4Jr6&GxGL}XmeuImKS@Oea#2d8F;mzN_i;i7Wq&Z$n@Vq0_ui+ zwbh&CtI}~PZ+$=g>}s2P^4@p&1SUCP<4}<=ewYA=s?+ixoiEObTmM6)xUr_3Wwiu? zSKN9IMBHv%Nz+Tieg=|P1oYz91r(W*7xb$M=SSmOOB)=V58~g^G({`UJ3&E`2T?pF z!rdzGc~0M7L_BsYEIS!lw1hKkmZ%h~p46pMrb!{S`UHC&`}%x)hC~DlQ>e%=kyGR= zaArLhRBpFHF;aSU8pmA>!jiKXA`>w;IW zE9H>P8_ST{y+t#-?HKuoc_*&}1 zi|pO68|Fa$(AM+~Z#aszihzR;=+e(19rC+NQ%zN69Z^gGcb-MTnlkCKd*1nQ%FC4Ugz(`pZ4gOJl6Zs;4l6&zq5jo+GF5WAcRe zN$PVUw7NtldB9@jLs;!YzT8HJ;TZ9TM%f71eATwhU&-PHeG2cO)~&rSmWIVZeshmv z=;!-)PVUx>#^+$eX;K_$s z1%Yv)gCzkvrh|2KZQoCo>HlRihNT7UwvE;DDJ_||$y>=O&uRl?stQIu4o#(M>FVRw zSjl>~x$h$Y^9dl&o`}@iYX*y!Z`CV^$8K$|wY!7Anf1b#Ap^C5j@Kas^Wao{mU(F# zzyfv4A!zXs#d_IObK0Y{?P%>By1g7kUh>j#>Mpp|Vb8q`D7U&?GSLn_?1XqWm^sZ9aDG7*+gbl9)*rnX2E&l{BZE|x(f{#+QtWT zDMiizr5-?CK!tYh^36?DZ$4|a>?g$fy9oQv^%dSh>PGzvMC+k=q3sRlmuXbffVy*~ z2n>uO%F}Rim`WKPNIWs0pSxU2yCQZf(Ec0a%J@h|QlkRZGXTp7F{$cl{Dv7OpZm}z zpL^B;L;yb;y{74xesj9gO!GOMZck+VFrdH zwM_@ucjA`+mpeTvp&_~-?or@ZB$`=wH~41=W~l>hz=kOp=PG2TSk|qx}Y(3omZW%+O;F%66VWBB17dYEVjE zJ6)WOIJ3dtO4z0X!W z*>!rV#k7>)wj6thvwl49s%H8T*%cRSWFtjWA4TjvJIn2_MYGf8lKBtky*c;8HigcO z9d8y5CjCV3sVJaf={Jfp{WJSaQjE&PO7pp?zPxyf*q-aF+wMC1e)R`@>e!*VMP99<{kcD=TN0l}z-kb19I z?GLKdE68Zd4`2lB7%FTK1g*O4&M2=Y+J34Q%xJDN5wPr=>f{c!imuzcYm>~!_P-=K z;dV@KR#7A|M{V>l;X-}bpW!ph{l(%slGbh~NwqPRLwVUBsg&i~_JZrG=a3uvN`|0p zOx2iS?p>WBr_B#63RqSrQV)K0UgsK?J@dKXp^OP1%f2?)#`?}4o08uG+P;oc2H;IToWx+%3flo459|{$_VRW$~vCh=sQhQR`(6-(Zdz! zOXI@-=Wd-7YMFEf`Y92ZwY?;}1S^Yim}F;MYiOrJf!HB54uzhpk6{V+230y?&~&|m zH!jmyc#^3FAsV^iP{i2famPuxX$|ppE)bS)+zeOphyC2}rTFyV8thg@;%n^GGLBG@ zn(2x!)%s9)M3K}fM9Xr7IKujd%Cdc{3?(W!^=<)hrDyi?rqiqGeV4r4T(qvCwFHH; zdKO$@>nl?;gYgtSG|_7?WT9Q1vsnC&(xP(Q@s|kN7I?mzGiGzQBD~`qJE88wZ(tv% z#kO4)V|x$_{#=yU^#iFG#}5u^9SPkV5COa2FoF*&D2~$3=cw_GT7-&uIlO%zobnRDXSXce&pbGz7-!Af?>_%P>!f)o@?VOQnh3X<#UqzD;Jhc65{yI zs9NXP%FhgXt1n^X1UO6s<7c9Kc{RO|zXrC|Js!cT%e0u@ER1RHwhS4}Z z?fQd-h-L(#7@ArD(^ad^D4=MtQPZyfO?=_I?vA4Via0rWtE1bC80oYf*>i|Z{KQD> z?0;^(`gJqrzqgGKd+e+d;$D30-1;1VvV}nlI8A(XRb9i_Y{BD z@5>2vc{4|7)M8(7{>J-Rmbwu(eHUoQ9RggmDhSvzNB4r}tnF@D_YE%sq zQx4O>F2kL++sf$P;g<%7hoTnh5k{SHiO}y^{Ns)R*NLAIUm(8C4V?SIyf*CSIoU5$!6ySztoU^L# zI4e{Lb7wSZMbmH76w@;sn}xWD&#K8v9qHYp8IAw)FpQstk5D16Y}>4}Pj<^ydz4q3 z_I+^Cv@;UD+ikbx)M%?Cv%Z@VDk_ib6xZXK9JEfI7~9)!p=o!XrereIQoc{RYx;Nk z{P#BUtqWE5deo9EkZejGUjAVw2lhfXcE*V8dRL4XO^Og;*KM-Ak_=gzGGd z1^!eNyr&_zF>4_~NyZ@%3uZ~yo(}J zFz_TvmU2~#5Enu~KV}+SRHkZ$#wtL6vvt8mGzdqQH)tjQ8Pvz`cFm_{7Tx;&wD9mD z+_d6MEK7BtQk^<$(BQa5Lp(BcZ9LVAiHrJ2j^`h_Vu>8;V3MYr#r zOu{z}{!zYfNtjn*`GMJJ%!ggMpYG@up3JyLCFZ8K-Zh{fwY`>v>_h(_?yjx7fSQ@a zUKyb&xY9VZR|b z7tOpLceusnFmfTz<>%M}+2(5=v+MLt;*!{Pc%$XF1GaQ4gxRDS^)Xzs4GPY{V6C8W znHHIO!ninq5>oJK-3oP$2!5UcW;1`uA-+Ph${tcD%5e>Hs9=pUg=2YSTCo-*ReH=C zwyHN3Mm6cLRgocXw9gG;>KD#>^?-*O8`cDV#R7$BTG9SFZ}S%t)bxhpG#!}ThK3^> zD}Z~G(f{Gv0&=ln_##`?(_O}4lL$xJ-EU}9JB6C5)(GCstA2`JatYo)2{)pRFBtuk z0Y{IuBBY1#wrb z3%x3m0$4S3+W4a*d^=ts9iPn{Eifl1`uEv9`x*DTU&nFaO_Gj!eyfI#ur2U)Ao0lQ z|3c;9rY!OA3tdn-!Y6=pB}`PLaEz2s1Fm+nK4!3JrrT&hp~69$Zw3h~Te+E70UP0< zk{e&U8aJ4J|$0JI-=(R()C5&^AD$;_3ddbNbj7(|Ys^(Har7 z7xJm+8f>BeDc>XCOSyG!8G>ECk!ldlw`K5_EJuILzD@iUVe*b>P`$3yFWysj zj!j=Lu8fluNEo1Zi^Kc?ezE`;3|+}xUp0c}ItZQ^qpi@}&tiJ_;|px{;C2nWj2;-# zUnUEnPxoOAlw^v!3M}m0L|NbIK!qA6CZdc;@(Ye|NU_);C#@LQn%F5>6A+=)p)V&; zOfx_cNA++!BHHSNhk1(XZ(vV()r0PO$)fA*K!3vf0HjNrJT?-u7g%O@8<-A$ienUx zJg{?E&>fLNaH*&saP>N#)CWS%TB zv&l|v`EUH}1w^)RI(IES$l78$NBBwm&<@=QF;7L^ID)n?wTap3rKK88OUQIct$v-K zVMFKy|7~Ak#~Z2+a+s)StA%3aOAq@D1~ZaamrHO34|%1ZQ)w{#rn}%?bj3TI+6j+V zPB%zRbHec*get`X7&|jzu9GYb@(-Ct4ZuB3$g?3#(<7{br@F653MMI1@!|_4nFw;i ziyYeO{MhJo6#3NZW;}z^0vjCf{?iz4emy@RSbPXqx$dQByPEvim}a9w1xc4zpBHZw znwT!xkI~Hk1S7d_v^)LkgT#}+Srk}D(=tOJA5)R}IfAr0!X5t{W)juQBxx$vla5@QZ~1CJMD)c^5{-AK#sP}Ur0%qJK1!6Ow9 zOnYv`ons5zZiq>0|LF)#6^N*U-aprG>X@OYPZ>m{rj+{$JhL@dtHI+}H*qCNJ_JPA z{Jz10KO#7dFEyvYVEe{4NF1qDr)AQY#8$al{U+a((Ci@G$yX$n|GuFD-3NAD-gg0@ z`8SNIT7$5nW0@(Yyq8`JWg3<@QJ1ix+UW3t&s7~dmJC#zJgvmu2N(5!B zVu@gJjvC!|X@pHkgqahl;Vi+s^Fa~2O5x~q8_ zaZ1{ z>QdT(C)YjK0Mo!xyVB)T5!8V#TJSS1M|xnc5VM?nPds#ZibOjAdUAowl4KgJ74dZS zL2i160ih?8vTP&*1=of5UT5`i#E)boD7X?7k&|p5G=>OeNtp<1eRc%)%fcf@AZrU_oDgpL@GF<zZ{gI=kG!G`s`|>790+C0hkVGp?)GxxdhU}J4V=X}5_JL!< z96Ug5>8XK-_{?36Jb&z6&O{$|Uhorh>`%xmEOQdfYv9yh$nV6d6e99!pSiqGJEfn8 z{K5kd7LY{k(g*5$K5xW)o;W!J&y0)`XQ6{lzC03p0eK|+Iui%I`5rZQ=;p}Va()Qt zksd})exA~oqa-ezj~yWV0`Ft~5tRT3#~y$^d~>^{q!oWL(1P+FCVJoD>0xDAoL zRL)On$TMpvy-zA&`IXFjdP1_wr zCCoqap)C{2{^k>+6TA(-T0${e%w{oen~zB|!Q1ESrz>=h=|I?jL@Jd9(%YnFFwx2{ zTJ;>W4&;S@V9SDYz7_N&-3fzwzxi;fO29r2bo|he^ZZST;=(J zQmrA+RPI9|@|xVriEi=TR!UETRQ_+;33?a)jb)Bm*uS;RG4<$-HzntXarKC9A)o$l zEOVm&OsF-WXUzBzWB(s~p5$bYCEdv9&0Id0(k5V2uUxwLqFhCQzF)q&daInw;7krpUb-Nr`i~};2!v0V8$Xe#pO^*X>n7mTKE0Hq{p~!$z z?0xRBIX1|VyOa3if(Wj1-ZJ{Y5Dh7XjFJ1Zf>{y~Ctpz?ek@Zy|> zA=;n&ut3zxnR-Gwz2&xo_xb)E*5BuxHjV5bD@on)3LImaff8sP$PRs#5BuBmJ&y;L28{N^ z4R;svx`Z(eUN>*9PYJDUV`(#x7ef7xLD&* zQM(O2Vf&{k9AnY-KgHSDhTR5JD-BX(QdH{1ReFSl(biQ<^a{jE{n4^{m z*TRaC&KjkrwLM`%g(I}4JFD8&3_fOM#nWlNB%?>I=Sk}yE(_jW|J#83f^?^Sb$;X=2>Wh-H1aoL&}o?DgEP=h+& zX36Ez!5+ujjM>{_yX1(slDd8f9SI0&Pg|;ork`g-w~^#p+y;v^j=Lp~*3h{IT63ahbOUspR2r4foX zC}P_SkFp-dpR|k5t+<_u26Rm3GPuyHz|DVTL+|y|{?m6WBTHhM!L~^Dm5Uau^G{`5 zbC4nSztA+PIVrps|E$wpkti9uQ{YCU@M;mR{cgrUeiTZ#ED zxOHZFO$=;VxkEyo7te*CerBGJ*lTJRZfMxF*B%K{yztE!W-C|H6zA`KanYEfam1$Y z5A`XW#dUr$Id1DiD{t9#>GOJHZrRt(Mb{WF&$rrv3Ehjw94Zne8D?$S7)=Z1$ESIK zl9nfDejcP&V3!+HwD8Zh^pd-j992-HK2$&u%`x<_HEs+4dglgx;n3bLAC0=XkUqQ> zu){y;dM6x!7|^R^2twMx`hr3Wbmred_q0Q(*0V{d)AZ)@6r6uGM>jX;aFH`8UPV#; zOoQqvn`+0Y5ws@tH7E!qHZYQ=h+v@>^2x2qqsNr2EencCQ3;zYU@81(A+Ai^&-l!U+?lWYje)DD^U&(ajg@NYVH~4C8eB~|U z&3qgtq-%*dvWcHFepsj<&bxlx5Qiyz3Az#<3ZmaQOjN!HVx~Pc#SAv(!$bQZOB=xw z;vk>HQ&P7e3PYBJ^AstWB>Rsef)DtjgA!ZxBB0nf{H<{?Gm(ROt^b)=V_0`%O23;UAsyR+j1>{!x$Mwrw`m*iC+EBs+6c zcQC_c=ptYrv5WfK^)R>h{Q=*8Wt1V(QO*nPuYW*xg*FI^xDD>QmU;IrXGej z81q-sBL6&_0+~;B{ic$S0ojVDg|CExB2@YYS-*%6kf~Op~biCj7&&qjeX{~s(&XLl{qZ}Oi1$LurNw<)Gn5Ks;k`lwAK>?h(UqlURvsha{70nItxLGj)`fp| z0oF6)nsw;FTOHleaz-|01j7CY-_0o*x=k}pYo&#LdSEx=)^ot_G~1YptBfk_zYy1% zs933me`$bIA1I9ZJ#l!`fgKpr27UCO`LYb-Vw}aaGEQ4(Nw~b7|A*}+0g7}J!#i@- z3dJr#q3izTu>W6ckZ>1DjX6qI+1-U)E3X?W5$?8R`xfQD9D{1ibTXd{v^{2Ux~kCZ zpnb5^p0)VoCmDwuemwo>S!glKrO&o@X7e@eJlZR{quC3Q8N*=6I#e)A2NE(6EWd2p z+LE8~2<1@EPudueEr-ef1gXSoM|p^kh^ucIH%v*kH#mgDgsSOb8m6E%8{|jo=)r~F5#!kr)Z zNt*%=%R^5(_lt1IAg1An?OsLxSr1e){a(ENhPqdC)ADXFbKtjF=-xQqn#2zYsDR{h zP4@-H=#~nlqVB^wy1`Xib`Cvd3+;FXJ=JlJfsFhE=>B%|tYX2aX^q>Gu+a~`McI%6 zd6OSWm=VT@^v;#WIa3Jx1}R$QX`_O{RTs+1VabJYUjsfJit4pY9y)&8$D z3NqNG{hLYsunTTuhuI-XUWp__k1)%bV54a2-3%EYNK<4k|1no9B9tyBSXW#EOo=CU z^&BcAmlh}|S>s&Lz42z(K=qs{n2;0xNrtAQvvwO0li0TNDF%=liBl}sa!L;Ff}yzL zc0h#ri|WgZ1E*DHl9CX6=mji@zwMGRcbN56h&O1}0_`Z4Dmrvn`I(js^cIC=rAZ)#vOPWvBYgGv0El_0p^0_5Dt>gN8bYvMBWKB3gprw!M*=%JtB(hch)s%G zQ|yENDr`(z%XJ=~VQ;=<6trWiK$7?PXCmWYk!-@Jm z1B6Nw$UkwbbQ+SA472C_ncqFQD{irIC4|l)O!QxOopE2`@3W8Nj}hjC!j2zk*{&#b zjrWWocLrD@QGHhvuDkKT8b#CAtGf?G+zROQvD7P|oJSL?jdUQaHNr^fQhzfOQZ2Zo zwIzbpGQ>9Vb6o2N?rV;pF3#EF0-Npk%kPv24(@6}(J8wViUP@7Qb(8z(mYkqu;NN121e6$}%~GDt{UNKj ziUJgx%OT61WxEOjr950!PX2ndx-19Zq0bY>Ku3E~-S-ELRJE%_pm2)jQHJIS7wZ+q zi~ar(&R{@J{1Q4K+#Pl@+7vV`k3*lYIeu>gzj0=>&?9q?e}qj~c19avMmz5wEr({z z6C-2nTy)VoivlZ1kuGS{3>8*Do0bGci)RT%DJ46$bVA#Q>a~R%>Z*~KrgYt)7ecoi zYgGdut0m(Yph$EKsGF;saJ==s*TBQL3KHSh3hv0;&|_m5k%dKr`8rq14Zm;GNc zqks~CU+JqJ1Phqj+_`}p##Sx{4(J+fS@2yWI}yK>Kt^jIj*^Lo;;6W-D0La)ft>LN zkF6kNssMt?4sGTkp9bO-DF!k`$NVFT#1%<@21I$pbLl5aH$T*v)*!VvgAUmk9H#)vt}^ z1Sfvara}YytV_Y3qQVzGwpvJ$$J%P%Iw~q(Bz`!L++0>>5h%7NcOg^_@FmrRutp_X{;y9 zDiT848|;>Niru0iNgzY-!_nxkI}=&MY>Fv0`&n>Dl!QR&TIhNF;l@)Kv?hSOFjoGU zLDiU$;+;BQ9L|63ZT^R6By04BB;i+{xS~eVwRAoExeG&Y9e!CYD5@J8ctYrWWpSW| zp6Q7ii68B*jiVR#aCA27VH`9aLX-3{T%Q>c?%1@25j3$E!O=zrvsnaSFA+nPn#tf? zvTb9yOcemStjYWT(Dvp5Ox?v{X^iq96i6Wk?ki z5rIHL;uxhOu~0x1g4Q}vloX=O1Z_$N;ky zc`V(FJJzuQFl#3i>A2*Y-D{JPN6-8e6*`Z7%ipfj&eo47R)(M8`OWqbI%3CfJk4S0 z9|2(EcXheYxpkgE2#^QSI$$J!rMKXiO!Bqcjga}u3Rif*+D1@=X49V!JaIAv6+=h`0{O!lmDw^Mc+n~(=HJsbO( zvOC4?MCx^eX{02Uf=+vEJ`)W%)QR3z3+rohYIT`CZ(xpduN5m7S2x=gZNbyDbsH6{ z7HiwCjcUUb#G}jDfIR(e9@kv1By_oF0kS>-UGI<`tisxFpud0BRh=_zx@`da6@-A* zXxAbgfpM>8-;oLcDtj92vsjDhL{q}Oo`=#kaBgB?ma9I|Q&c&dh2*N;IMz?1A!YF9@+0E}?R2)^^ zD9{$K%x^f`XqFh=%w8+@AKH1ZXHJxR_z5D)-e`aj!1(ZnTlLA?4;AT@_^wox0g`mK z*Gv_)GQVWyo01i1*6ZV^Y4`n%F!Zr|IMtr2Hh27$wh$dVPFsA{>cGh7i<(~c#kf%R z?k6a(TivA$K23jN{w13KokovNbpJx*aJmc_n4=E`^MniRKLxT`TXql*9C_bBw=sL2 zf3rXTFV=K>C$z(~3YRs+oJ4emulbv}n+X~)yN_}f1)46@WyF{?Ze$=U^TL@DJ1%L{ z^q$E-c^MGMI3bM$`Yg0RFhiGbX&clvBoG@2jD@=5^?k68o$U?5T3r)r2F=JLKZZ$j zP84GXmmHFJysqR;ng(e`3GaAIiJcqLgaD>qmhFV_TeLUQNwuirFeDZ)U9sJ(x` z^Jwu|+d?++HO%YlXZ{y$I}scFv-#6!8d!DX+1Eat(Gl1a&BD=s^Y=YxShr-y*L@pb zd{P!M;|>C?e|#^*9byKQy3_8L9!f2zOR@yMGb)XQCM@19-kf;%oNBC2B>SR97`-mh z4`rWpNfNnC^DR2hs1ijePPLG2DMfB6qd9NnNT@xKx^1pg;O!c9s|jtb=WDGQvk=tNL7hGk^WWqvcMql zhGeqQr*m%v=$TSxALNC88q<=PzDHYEl|uAt`0g0&cnhq&8s;;Ie?kEGXO9wA`#_+EB*!8x(0i;)2f6}QuF5g>2l7E^~ zx|=kkQvV<LjzW+NwB-J)VA zZtEp%~Onaty8|eW@{sW5Py#}`M+ZaKZUS5#+139p;*AJA@y>}V>Njm-n6tPL8*+n zAYyY{Alti57&Z@QUmV!}+&2|0bY*OCRhCbY*;QJ4V4Y>h2mygw0jET&fyCCzpX;Mx z<7zc-gCB(ZO&7NUYotq?UZgc)qX^>C9f9lp{l`u0*#?k2)GKJ zSi(!cC10?53JIP0F?3)3B?R^#$3#D<*YAfkxg{ANmW7mvR10ZT@>BH4^!*WhQThQf1e{O8)3pU}of70T70O zk(0>1`nMKR;~DDanL-lZuPW5%V<;B55m4+D@~!@@1}T6Ifs{Liv%dq*5x~f+IOBuD z-l5Vk;#)TZ4`H%4>UvCe=(`@7XfjcL6kgHIHhlWCBTNKpcp4c!s~exGyKwTYAiy$y zgB>tAJ}?!i;K>%kR6nZQaS}sDUMnQV?wbMCu-N8mTW+3D{j`dbNmD|YE#v(G4K@BU zFm}dk3OxZO-ADR)s%f<8y*CP|%$b+u}Pgi3H&g=S=Xvlm<>JNoYwbl5&@D>14 zOt$%d`YzZB%gFx;D_Zx4T2qR@Vo--;+L_!B&8b#g346d74ZcChA261X*V0M&pzDxX zxb?l$A6g;O-0H?gTGF&0;kHcJI`RTW$66pTxR4Sjh)J0h%YRFhsQCyJuU3Dy^4(Yf z%wPRNKv(#aH;2)I^f3$ocxdl^b?;7g9WSvA*#U#6wGNj}9=&dr=eNRpm>}e*YLG@b zLRMARW5xKeL#Q~th>5+Mb>N)^YCISrguJuBN!wF*0t*=pKR~rw_N>4zRY#Dd7DPj2Je+)w`h@Q|BkA z2(T`sQ^qSen_Ge{c~^m6XYF`Fy`8#m-&*p#4Ib3{L*-h*pZ*$(f z=CK|<+VjNqONT)gS_@~7|48`q;Y%MzNvVlU#kAK7?onz+faNNeUsVs7iP1Y*yQ#fy zY2};uCkv$8fO<0rS`IF~bZWP}h}Pn8m(tXqm2xpTdQ5g_F^6B_$BGejZXUH}uj=2d z?CvOz*w3L154whSADe4PuAxQW+qbBwZPDub3m4_=k{->7O*PwOQM;>tCKpR9OP};z zLYTcTM2=h5tPuv*mbBkIce`Git5uDkHE;3v9!D7NX_1HBu~ENoODMT6Bk$-tdX;hU z;(A&B@YYv3!(Y|Kmtd`ELzRS3MaNh}1)g1vRfoINBP*U8CkRTZ`&X}N!6uX}FKWL9 zZlxGLdYgxTb5CnuY)-2GDIG#w>v$<_V+JTw-U3Z)G z&iMiYE%qf~<5{1zx(!jC>ZwZ4;4}oQH>FT?u{1HKcZLa+r*(?WI>)mTFR{vfqI_lI zMYC80zydV)AMaCv;PBVZ6WrD9SK~kY>~LbjyD0{##FXZE@&-mbAY3 ze8tHY%sQt>RK+ENFtLUe3|I^188A0}mwsOgz0`eCr*tSOBcn`SxQDc^efFzMV+BT+ z1p9^IX?O2ST#99-cSavBqWxxYe5jo!$Qf?B>&NqL4Qm}FCPES+a)b0(M1N^u<#Hy( z(}1lVtl5)P(;vPWPir&0Gu`9MkTj~453hTkthn9kmLqzP|6vE;szC-l|wRVkttxf3{m)N;w6>L0HSELDX z^LCvQWFpJqtzSEkD}H_{Wh}^%+&x25Ggh3P_)yS`5_or(*pH8dKkk}8@3cQUV?V3g z?(NO1%RM6>X7)Dpv4Z7qlLCWD-&I-ogzT%ja@y_=K!5Tt^ABcRmf?3(b^yO3Y-mpt z1eE~<1%jt`mj3s=0YF^rwR9m7#VMcqR4bb7JhMTKrbFyJ7P!p-6#@*fbPqV?ObvN^ zq-(++z`CF~KwDaDj)$)k*VPss*>5jv;9t5J$(7I9P15W+6H6Pp;t&+WV?%xyP)ZLR zYM38ldkI~M^uf9xW(!;>_cy?fQzX4`=5>1UV0)4oSH8tAK(Su`xZ7^LGyRs-2C1Y+ z>u@4M1?D?YzR&T@aoC;TgUsIE<*XW;9|S4SamxU0(pgMv^UGWOSk%t0a%*Lk9)B&g zXw$Z%cd@=>B+?95s-jxiyGI9Mv%^0l4TP7c7H=t5t=uC&uh(3Xuh;KwGW=~-@gBd>bdbei+H6fLUHWm;H}DNSXJN%qKU7 z0!*mlGwzNK`$YfJwvT0f*Wh<~DfPMgT}su*$#)hM0jVXch$t*Ol7KMWBfIA_ zFmvfd6bD8OT6iU~;ZeD`2)M7%Jv0F%Cm8fM8bEZUY34ImZSrh7pPVe;iBKAbP=d29 zXHQDAcauzx%6hfZWvh|jGn>tbsUhD0zz85HafS%Uvoi(SB}T4*<;1d9!h;~~6+pPQ zKsf39^$)Y3br3?gJD=>8(VBlWUI>Kx0JJILKJbS1&$v+;wJ1xj0NnyP6XY})gC~18 z-hhOMA`ZKYC%WGtvWT>2x-+y3*`=t3+ibeb$lt{JT=KS1yxQ?LyHD)o&ksjZay%>u z7Fd&BEN(rcMAx37*9`SRftjXj(0SDhE7)DZ3gw=+QWr|ArZEuB(J4mcQowJuexajC zN$QlBzpI;Sb+Nb?2bGyxxr=R%{MN?BjnQ^--rki34ZpsT{;2O4_omD?f?@q_e}~;b zjzCwOM#-u75ZXKWcgWyG0*V^)^L8zeeqlj(LdFur%q!UaGFv(W5PV%TNE$0CDra}y zg40OG=ZW4R8Yw&tw%U+Q@}{=|2f0<8dAgk8bT$4RNIccTAici2t0Jz&ap*LU|1jSf z8QCYKb6!-~HPVJN@^b4V6-A!r<+WKU26cO|EL>^uXk;tD;Co8!Czq5_IU-E2Om$`b z(CIlsTtKXL2;)um(dR<;?pz1TgHp%Do{w<8453f_ulRm{+cMEGz%Hw#qd_i6w%|-r z31lo+leMWSy3Bin^FB%Q7X(7|nOGVDlV|Qz1;QjUpAI3?zho=@cY`RjOyY_?)aqx{ z;T^iED&~e>LtliCyerA1QB{z1qqzNK5=~WTu`A9l`f6KhVDIC@Bli+KhcwmGd9}z= zB&*4Rl{OO~32dJ{ntcoQ=9nA@h@exORzXM=GD?hgV_A?qp9C{iBt$m^u}xN0HjsMN z1&9w!K~hD42Y}RNfM+8Fr5brO!@B)KhDTmnOQNy`jGM>yZiV?2(z_won#;ZgOs_{- zW%Bdhto8ab|1I(tA-@)I@j(bFK>3hW)uYTxwJ1tbjpjddgJ4W`swd%F+uHJ}tjIqp4)M%`Omz5_G8xQTPY7kTNlEQumU$pM?La2~d zkbOt5rPM|XPxam3$}a{32h8=uDa#B{Ci6Mea* zKD71Yt&}w}aDvuaGIs4+jsp^?tUE_=Z?(i78rbG)@eR6(vI0q9G`pdfm))@r&yQLR zSGQx_^VXfI{iGu{aCF?A70?W z6VfEPg_cG?WcV34?jC(~#jz-$5yhzn1B`Z|==qnbNLx&PbqBw}mTR$0thAKvh4|7= zA%?jmawL!=%7P3bQd;*#NYMjo*XV01xgGjyX55WXWTjM^+%aR{~xDB6Ua2O-jkE4No>qBAawttX}Bf#u;f)y z9$vK`axx=oN_rpWHL~0GG*R7`G95Y+$9`$cZVKB~b!=Ap%N!giN;nr!dxc@j)<)rJ zi{cotqM9;~g^S|~=KR77&25q2G{YBRe!Uza21yNScnwMQP6XQUPll{$$M6^+esnM(g?Ri9=-uLJo!X6gP%h-bR4!Kx zx(}Tr`YdWGnI?kXhSc{t-@9+M+cNQ>8FXHy#QKD^JM15O@Vy@~(nF+Lek)ludbPrS zcI8$yf9sdF*buAh(?q4pY+U+9$cMKsK{eKO0yDzBHlsOk2G{6N(XX~#mwfTPq$Q?= z$Zx!!V~rrGtYiBpMG!FE?sGb=#Y`IZnFgN2X_c-jAwPgM!rY8zuf%iphy#2{PnDURJNl|< zb#TGk5s~LSAM&QE{)6(uB#}#rd(}Q-`m@6o1%`R4$~R9DKtMkVK;G~O;v4S`Gn*5) z5B&ht7T{)CE!9r9xZFDf04d_POl1(L3y>TW8qSn!Wy}GtL#LggV_!GXb2+lQHQVg< zDULbY;r@v1HgWBlMo5}(XuxbG`gm?g1z8Bk@iNXd3g=1W?0~QI`209xhSv?q%S-_R zh3(Hra*Kdo2f=&RFK`UJQXom^r?u?5c*tt8)1 z9zLY^*p`aD-_VTN3W>dx-nlRrAdFF6KH-wo#&JkgdzeUCCnRl|!H3CQsIV(i<<5GY z(Qu$hwTUUCbFgec;7IfqGvJn?kB}o+#O5`hQmTheI&G>p#V@a$#qo)%R!!SJ6syZ1 zvUqc1lcQEaCYBjxwmFg2bd_OWcUC- z2UwI{RYF1I?Y6YiFn=II3>+|KDB~7*u8yvJYB);25s;JOwhcOyt5&^x(b+K3e3L^^3f-T2T zXq@_4U~E5SU5+vkhOJa^0Vop^dePTJ!y!0Qy%EA#L413vKTRHQMsY_uw32|Vr&D3E zjO9h^21{wnx_pG9=Pe87lmdfz4FchHD|#iNalkb7tBrFS02v0OZ?P=Q0@ebDS!{99 zCNv{oPuofpJYixPK^UCoZAdTY5@ zG%b#30(9Xl%Dxb*Z(gdpowsH4jGsG>wVbVwW+PS_{H#zyjTsc#UU)v-CV{D zU4mj3uoBV|yhC9-sDZD0?0ru{bm%BCmIl&BJQ>02v(T8V^d+N^;$HW$j_W-Ma4!o4 zi=WRoBpBM>$g#jHtaTcEr+3V*!kF_!!rtBsv$;~MbI>)Z-50?c&}FB0lkgoJ7m9~- zu2%8k+LvY!%Ojv^o$Gb(ma~*UE+{QD;Jk^|-|G`sS?gnf=6_|SxVvLFENXb?(I$Nb!FOFfI*(miz8xs2tEH<#P7lwGE6wpw zjJh>i^xZ;TewJ&eS=}D{3M>#Lh$@)jJaPSXdG$i`*CuaoBfBe(<~o=AJ#%1p!0@?r zkK9JQurMB2r2z595q)ek*P-~-zmsipREv?|VjGf^n6RZt<{kMc+nAxsRop2a(;q8o z3+0a#CK>sJGm;YcHk`KNz_@({a)XHAf@WLBfnlZAXjD~X6O~caS6pvbBVH zL0Q{uXA9<_;HVW0VB0vFl5_g^Gf#w?VfBi%p85RR7(t8xnxEm|N`DP|P5~bnpB>=K zJMDxqT|b!Cqcv6^;lKjIMEDkDfB&sn@!T>aU1qOr3%Cd!+p{oIBxJG2+OhAx*P3EMS#xM7mpS9U_j%buK=tn`-cbzOh&c!0Jmu5ss8~e>GzKg)mu;Qt zb&RM7eDTGw3dw<@dsex?qU#=k?UmlvXaTE++740zGb-58A~UIpqrYvX5$Z1;@c%9h z!Pnp#S@$<*B-|ct7mu=&w4w(Zj7t@U!NCRm6yC}DytXoH-rdC`apXIr?DREP+tN#0 zjv04ED@u0NuA!tSWrM)LQFmm6vkKT^9I=b?r40N|*am8lHl2J!&=qe6qKd2muQF)m>&x9JzWoLLyMjcgrQ?Z5@U#+(QB^ zKvb5yl!%9t9v+mK_)w#(s65G;C$`<_^g;&;P{pb3VAtY#l15gIswRcBMa)7lw7<#g zOShmmja|6`!Wu^KH7R4-;ePQQN>8TNoy8-yX> zTXB7@mPphsyk^IJ2?>-L%%mgQd9X46(_^8vc6t8b*-ay@!P0usmCoc+t|c)ynDSZ| z%K)8+PgT`7ljbLg`rD$)EmwDwHuk{*9K1? zCQ)a+MVN8Gp6PAa!GV?G#Dk5DuHP&uZo1g?4s$dvClka!8t1lyfQ`osnAyJfIo#== zaC=>T951gWvZ_7OxCGJ6*x9ZaDby+c%^$SR3X8~CxJC3g?yRSo;oF}*-I5m6CTI-v zs0tn(T4>xfKtExuMZ0IWi=!ZNa@uqn$-Y(W;QZpYx`U)$Rrh!eEPpIc;ut08ba+3F z2i{AI5Y*Xfx<|Xog9PHDpJ1>)4t}d%o59%zUkF6Is>5Sw11ghXVDo{_A0oRpT46Z0 z(Rt=G(03~;7g>TUoXvxrm2hyARxjYPAZ0HJ7T$^iVs20!hLfEL$`ACGy3b3LL5bij zQ7BDekhAd>62^E6Mr7;2!JhU4-k}P~ajFE;jU?zs1f*?Ihe~p<$Uxt&DI&#=8B&2U z9RHgB$AgElorSLIwf68KN|h{L`) z*hp}Qe@hwz5B9Ek7H zYLGpAS%;ER492ARq4_4y9?ghe4|xfiI5MLjBoPW}Z_@HM*FaFnOXQ*)w`pEkpr6<8&wv+&lCgiy;2|C3cz;2x3BX z>`()Fi}+xFSQ!tb8kQU?^7+K-gcP(IPe>c1n~^e6%#xWrl*pn?vbsJlW@p;H!CT>p z!I~0p8r`46O7Fit79{|fVtNmBo8oZN-8#(QwlGrCO!4hy$6O6P3&u2E0@dPiwNVm` zu@*(rX&j<8ywsr?-X9Yi?AVuO*C-q`%J3RHJtph=GS~c5;Ehri#fyJ@H*^reAFA zLi8O!xC07mLc)hgV?s5&vw^$TIMDkMPMgl#6yMF!Dn9!B8aiJE{`Cpzfo~h{@@$K! z28rr|U^m`<7qcYF^LpzLkr@KYPtUXqjVetkb<$jSN*b6&mQhEwC^C3e(J6O31f3r< zd!M>wl&>U3tmWO63xFlY@K_@#vq24FQV(w@cg9HCA(Z68$0V z2I5tNrJmS5A#MITuhQj=PA)3ak{s!Y*YLuyD>zl&(blgpc?DDbd`9C z5Gy(59lE1QkSJ@;$;#lB_=K*~5(rE7Oz+T(@6l+|J)adA*VYe=szI5qL9+p}20~3k^2F`FO(x+3x|6D9F2rj3_0-ctYC-s3Ix-?X=ypfTC!>JF0ao#<%<+aK>B-PPmoHeMsB z?o^E?-ffbk1$sV6RS^r&p5WJvYuHScH2H3mysoK|phvy_16!JCJat#Y6Vwkz`V=@Z z>PLf;Ne5=~1oDu{6Q*8xwULdJcGNT|wV_WM*(6-3@_Z@w?D%@2Hk-=n(7CK3kZRyijtqz zt&Gc$u2MAFBvflwfI_rd#>6&wjA2m;^okTaHRVUuJ-0MsIZ!b`P4;1<_)Hyx@>-t= zIw$fU_K?I|eov`4jZ*@6q|ggdxcX4WHkoVbB2g;T=nS5Za6aF9OmZbjd8S2+T)r(~ zUnL6P-ezLEU(lUAt0%#Q{KRjUIf@Y}4Gb1qDz5Q(ddka{cl=P;(5kaE8eD`$Jbv|& z{_D7rp^Qkx^M}I__eW7X^uz~ac)?`F{i1-iwHwb7x0W>C+FdLgnKH!xF0%0lZnpeP_)(|w zta@EHq%{V(VX*5y^O81@g<~R(f3`O}VipM!3t5EV_x^lX<7)u$<9gXkgv~SLamnft z`vLR>F!e!_`Q`lJ`c~<&jyu(&ibJt1UL$Z4vK{1|KB*H$<~%Jr{!(fK-fVOa>=V|B zA=?H82}@y(1^`>w2W>||Bw7%e3}@us+cYEuU7rp}t;Xo@1=c-i#m=Y{+~&!>v|DUI zOA`@Ou7w>~m)Y-OV)jOQhhla`9m{Yn@Z|wolIh3t$YtFg23y)v*O!q@=)rYtJ4?tg z)}l&IxF$q5p%L0nR>7N&lrwb^jjGkM{63&lj$)?}cR9}TeE&d9 z={`^o*E!d+aDcEBh zhO{MWHl5eC9Rf5z?w$NaUJU$88_3q|z(Odwc*~3mB-G#l9(1fy7KI*)s! zBe#F`0rfH6beC4;ZqPR!MI)7QI@&)W+yX)~5R_V86d-0dzcd>~L`AFcs=e#O|4weu z*Y9&)Ajg#N3-&6WbCj=%RHpx>2KF&f6A)wdmE*zI2eMFdu++!u+k~py>wD)TnW>+U z(J0`_rSqmVPoU|}o@oHMil6~THrBsEu%#w*3uM}WHMUp#y1;Hev7|L~|wXNyvXj4AUhDiwjJcu=S;ZoG^GattU+rZw#W;8P76ekac)X^Am~LHE|dox>5i3V zMa24FBsZ=Ns%{SK+%e4>tRo{Ejf*-rWNn z3{z$U{?p)*<){3te!U7Gk@D-EED%wc)VC)pKz!1W)A~yiBtoG}d&Ay#x$3x#c{RBXuATjK?JdQ=090f>!cI`XW5+EqLA8c){2}r$R z-_vP~IieFh|3)LS`00LFX7=&t($suI{9ZYCtY@1s<~OG|wUFCdCId#*HD5YP{t22- zWkRfdUFU@*C}<6KI8Y(W&NwdR=OtLoqwvu z*5TQ9e@cTy6w4AcHxV+x6h%|6&mNgfCskt5>~hdXHtY zBMS1N@a;UN;;ymK8gs#Y=sZ!v`PHuCQlUO-)EHuX9e%qBLPzF3#jiuUOKjSQb-W(M zrzISV^w)}?bscj+LVM+T@2@Lg-iLU} z+9MR~88`vv2G|Cf9`h}B59JlV6pfSv8sSl~>at}VY;tUiYs(sY&7gm%HmA-x3X0u? zA1NDj3By*WqtZVPkDdo~BjA@cVt#1R{sE*(1U(lc%fe=Y_Dl$THsHi8t=#V45-3M( zxn!yJ36Km2wPry<&)m}bfBjAcgz`;%iE^90a`SLHOaH?^+%>ro6%vCUw2v*?=gEH~ zg1txn4FCEh?1l;ZIN?IBPad?rc(cb4v3BE_}Mh&Q!Q@y2Q|KxYBs4&_n>g!%>owrmtC#y3Fj) zuS1P>6CLUx$FZ})(SCXv@1^hb9yI8rfYNevG(lYTaO02YLJPEbednB5oHXT=&kyHF z3zOzdTea7PG*osTRFQz~_$3&_5Z1_Oy}5csUjrJ^puDY$73>iJAIYf)!U#xeZ!3h) zrwDMkn`7;#n(BbBZ*(E`>j=u0n_oo)=k^$vWf37Ku~1lL{4W<7*&6h&T^Esa_XmxcX913$FG8q2cK z8X&A=fKCd;R3!=~5^sBt04%p!mm2|1jm@Cg4Ec{M;TN1buWP~t7|?yej86DF6tcslmX)CP>wuwb^qW_jkkcQJ+x#G zaIn6NswlgIypB0rApu=W*r_L808_7{*74he~?nSvLXRNZ+UZ(Z779#v-Ys zYYv?#OZ+0%urz(U%J{TC=Q^sng#YZHcOU$oXb+`l>wAZ34d3~^FYNqocl~DzpuJw1 z!(fHhrU~8$bw%pJqfLFPNg@b@4~w-GPtwE}F3wrU*W|iq@Xl1HH&C`-NSgdxZXNcb z{oLT4?Vkg7awb-4b&>=N5>TZ{t{-u2QKQI~Z$hMGBa1iT2Lo3PL#H7Wr|oe-o+t02 zAig%LNh2@0An?d?m{Df|m@7V7ceOWU_FnhTOLWR2%6hiRb{>;1u`MHOQ0i2Y#Lnz# zq7vw&nV;G=4?Qf#c4TY9BC5V_&Gc-e^Dv6tZ}Bn`dfIxU=Ve?TnWszW46!%Kdfj-~ z4TTMXy!Pbc*9PzbLNeW6j>R?wj^H4@eZc57gaPuA!E%qXxT9TVb|u~3%hit4k?)SJjSO9 zrZz2WGqp~9SR=+hSeNBx$RUb`tLioEzeN5R(&hWK&y!@|0}-EW9!X^$ag_0Kg^@0G z`mIy_$;F|O+fAuY7sMR^UH>K{dz1U3E=g3X6&+BRA+I~xg%%5Ro8HuK;B3sdGt!0a zM{@U!{%pwhhc7Mxt%`#vo#uqn%j7P~XD)YvoX3mq1dkUQg%plI(X|R!GFAnF5t`e- z8u}mXpZ^o*4j>r$YE$Od5lP*2seu+nk%hD|?BlQJ9Ix{Ly2m)@VMd;saIL;@&OxI3 znn|$@x-B>2)VS^jWG~z!&1?{EKV*wAuR){l2(j`IK#1^{eCZxMhv=iqxw;FA)%i%2 z&67)@vpC=CGVCf!zr6$OZY|*1u(!!07%#5yx=lu^=T!JIq^Z!y>whP8OY`_Pnf{=_ z03w_u%A(`97tte>2U@XS47;i_Qei{QI{2}?$0RIw}A80b;&e&vPmh&fDnrPI~$Yaw9=LvOzRQnl`pE;`Orp;1`+);4ny^< z140I{Ni&WP=`-#xU2EwhR|? zR28)zL)b9m&KTsOHRa%dt@@o1qmt0b+s~%8@T!*qkui)e_0WJVpJGM@aR#K?i**pW zMWQapXE((SMG$_>yr3;U?T<@SP$Yk=HM-&xQc&J_xrgEbJr~wSRtnRV z9?q$k4i;uy%Q3_&Qg~;e#c+Lhr7APc@PCU0{l^;%O63GTuh8a_kL3XK+hH%sk9>gEw4%$5yHS^_^F6PjIk9vTe6+w95?aEjcD0MR z5l4GBzBq9G_b@f+Td6u;VKX6?@sQ{R5Mh)kNNKW&6l*qF9OZSR1g>L0^Isx?8OKAmO!l{+<$}%}p#Xgl^o#5Q zQoDib^T*4o6Msk?U(sSRiXHu#%+|z36rzrbjz8y(YE>q=$+7FNM`M$ZTsqGfzbuaw`wP zKMpI*i+3F5DSX>MuMiAZg=GS#FQ5N)EQ`%^)4+-uDJOK(tEjiMn}yWW6S_V`#_ehN zMx4bgGfq41#pY>v^7kxlk#kbZ)ZtpJ=L}MKu4r+&{F!<2H{auqm=W~3fhUK)))860 zJ0P;NoNuA9yAhCPP$l{7ik>*eeDv}e>1P;%{+$@9(x24EHZ$Y!#Vd6VU6)myk=mP6 zRunW`yy8cyp0Dy{Z=yTpbO}9LZ+9dmwD_&TY@z>c|Hv=;lftp2bC0n5(+nQ)y77|F zj;(oS<9xnF7};#OmV7fT={Fp^zsytSn;w?*ITwBgo(!+CF@sisD?BSP%|*4E!Mkp> z-wSnpzbFY()D^pDHlaVZ_@0%P+NcVFS!~7`v&m+C_^<6RUWe0kn=W;)l8&DUY4R69 zs|bXH))%}DD;B-jr&RhS_{o3j40Rih_a`l{I2~W-*kk;zUnjcvfBXlx^Qge@=3sL8 z%zfdthm)?n@Y%%>7BqR#cQsM(;A9H~mYDs@8B7dkfA!WbEtO_en!?OhW?Qwj%cLhd zElrW;e{;~Pv-;3lvW*$_lT$;wdq+Q?(_>aH|N0-V977(d4;-D-bI)M)6+6leGo8;X zI;>t^R9*c^;h-#zcI|)FeXB1hO{Y|TUlEX?5-|-({w7r2~QAYBvpqLS6cttF+hm%db%b(+Jo!2Qv?jo7ami{PJHS;Osbb zdcFmYA{UEf82F|B@5_&A`pv-sfP78C%20wwFvC!sAMxD(p7^Wf0?4_E)@y%12^M2xILKbcqb;PcFqC&Zcz+1f62Xgxmw(!iSJ z>||Eqx)?oI3&&!ItWN$tbb#rJ(^xG;72$o<9{b?X?-gjje1};U7mlHI)aR!FTDbOn z%gJ+40dgfe;B)SM?xe&7Gy+nO70>N*=ScEYzpppo(S}>&!4A%o_uRz1C`hj{RiRCy z1u|Bl-`b)u`lerath;HqS39b#{W_?!;m|jsNyxiZe?=#gCgJgk2mgpR41(hcEsa`} z51oXD;mW^gx^Yx?-y7*`Jy}-avj@j6Ts0}WxoqQ!7ol!}oYNfgo^H(bm+A&GY9w{8 z^vkUJ$)a3*k6?ZRGp`nnjlv@zgP5cPsF@0%b3a-H{V)`d)b#A>N%VaZ=bdQ162F;n zQESVT?Tt6`PmgQStPSZb%NGRQ{q(S2qDAa-uJh_r)uUglURsn-HpsJ7Ors5VF(0u7 z?)BQ{D?Fr~-+$$sv&>U}Gt-1gk?im%-Q-&p*ArBXTx9zfXp~aqj*V`VUX*pN>Hof$iyGUI*=2$}VtwQu5^}&n9Kq7VyFy$5#C67eN zt4$&L$ZJAnUP4v!z{F>od#M|4{DSf3GY`gK*WbJTP+|>lJ>Gu5-TK42n4Z9T@*S`u z+-DXiFxQz&5}x?-O;4!{rVrQrabwgY*4Sh4Cnl>G07L4aNyfc`fVYR-ZUz)h!5Bq(d#f91hU-+l@_!g#sp{p=My{oF>`*YBWC}hnL z2QGPUeUX(h%pHjj$DP1FYXdhSaH%A2i~PQis4%Nl(`?SqOt zt|)qqVr2C+p?~IT%O{t^>Eznf;cwM zR$RGmw-@aJrk5&v9Iw@+ii_7HH=8ta-p1b^E>JYG-^MrK1h~WWWKNgDnJVHF2=m3X z!C5%ZQ$b-d(?(0czg?K~+#qW~>5KCg6y>u;8_gekhfzuSh6_YvVWPaOLbNs4HkkU$ zvIPy69jAHZ9GN5XyjEi1@)%DC&vZ_2Vf+{Np2?q*J?3j#T|Cy zFJffHJqLn4!!pwTe`wqJm_{cz;?fZwb(H69{&mAh7+a7#-ozQ^qw;>H`+S4&pLDhz z_iNQJGR%s6yC8i9=DTppn{3|3pLH3BZ8!F9iY3a*yF!#ph;p8f^2mmNh_)Sc=+8?x zsw|f~@)!Gr#lUHXN|j*XSbM1M9LqRnFMdwXitNC35&sZLqmZV^B8jdUY;$wcptheq z?c6%Jf-APCke(ZC9Lx_G-Q`#-(Ftx?3Aa7$(Tm4xi)^QpmR5*H;J9I(_{8dMu}%fb zJj3)aD$7arGAWa!l!-|>e(Dzf>7iqEZiTvb`4IHjNRa;E{!_VFWXX!$yrBloqA9!J zdHE{nIaPnUi8WhU(9m{o7H(C{x=y`Q!<|oWI4|Ay8*i}k(A2UEV-Hq0_?XkhNrz>^ zpOYbz$RtG#{2{La#QNWj;Rz36LN6?&$DDGrzl0rzEoqZ&{P{AqEleOz*#xSntaDvF zmNTgM;QTPy`{g2r17SXkM`?Lo8UJmuAyab^e_8rkI_*uyjg8^6dp_&bo6rBKwBxML zs+6^Krw~2F@nn-*ZIB*WBdzo`xM45j8PFm}cW%JvSrZt0@r?-$W1TL_EtUT;$1f0V zJL06FGIEHB+)%74H73WfdDlA_D+ zjp-^Mym7uoD~R@Kc%{9kMQ2)cu4>pI`KL`wxQxZt-_0Md2VwA6_e3vp&s_u82a@Dq zTH5N{&pGztwblebGTGBKe zomui-|7EIh==$3n%W0NfE=tLTke;^Wtf7rHsV4by>&(@@13TF&FYNE7BE@Qgf$;5j z!>^*n;^C5yb2!R1Vx|0P(||Ej%BwXUx=nSuU3H9pmGy#B!Q40g3(aRLvK*4JCghgH z%#xhDOR9nHxghg4OHV<*)CxZZz7Hff><_zdSf1_as{dRh@VWW`Ar)=HVeCA zxX3r}r&t5-)%Oabc3s+%c;^S{g`%;}O_k5g@zkNa@E2(eem;EbW`+x0k@g%Un^y#-USm8OLo@=XE#6=4F-Ep60Cxv!^I* zhc+#z{``P6CRRmNZf(60rVLXZ)-&jLDQ+8zC%xFY{AbaioEx?x#BONBw}*G5bZE%V zm5oa;lwC9M?dDw^6cpgD4p3W()BwKUUVLr8Lq}~Q+8~QZpxshl7~ILm;R=TQCoUEG zjxx=Kp-jRL7hW2irqBO$a~Eu@SCo_vTr>IUe2dQNl)&1XS&=x2ir$> z-8R@3@077%0e?P2CU|g+WAV#g{Ex3aMrKPm7EsTGiJ$f1qCn@L^J|wyCKVl2-HmtC zXpEq*$a1lhpD_4wMRj2ON0KU*!=2~;4>0Ct%ng03qNkIZrhdwIrr%Ck#(&~;RF<}6 zf#|)TQsGLb5z~O&b4u-r|I@hoU$*Bs5(e8gRB*%PrvYtHyinfW=S_PHof*8B0d-62 zro6m(C(9v>k>Xc-vOm*7{?aY^qJZh-Wpyn~S8pvxT;34G&ixhOo&QSsq-J(IiMgIYfq(}Dm!{Ro6t%DAR+80N5 z-sLY}Fg6ry5G+(B6_$4q53c!czJSj;SX>&QyjCfZ4=-f#2)kU_swaM=bMpP;B^9ZZ z@oVZhsnpSy_;0;#v^V((w_~uf2L&cR+vf|cqq_@qQuDWXIZzw2{Yc?2B9>PKE{lBO z9lm8~>s~xKM*MD71|N-cr1PxVmYz`fVt>*vJLtTUgpY=x)+B{94ASv~D^e`JNbBM0bwr$2!XUe`7k|q^ zX2HE$e$L#oF}+ek9BXGew_Z#EQFJi_*2In#+_C0gGdJ#Kz$)oSsy9JKgq{O2UL;Qt|-J>(ViP%6c}VV z+(q-tN!=?RKCU}h%3hZ6+HxAc2%>iu8r0sD1qLZWgd6CwZpX3j;x}}XXv?`G{xM{L zdC}s`LHRmbA3{S#<#MJuexn$?sT5lt-wA~>4gJt1SBD*^8d(EkC?6jA@GlM2ng{0i zCo3yNj(xgMiY&W+YWdO4j9Domx!_B4p@Cnd{D@`<-D7Eca2h4mZLT3z|dHAk-E%bfJd_BfJtm z0YB>1rC-JlYhTLMzrB}|*;*~hws68wHTF_I(P(rU@@plgr9{n@{K)?2qJXpl_)PZ=cL0N2CIpFK=Onok3d8wHW?P3}N0B z6azlH5V(526*R0l{*tkR60{K7Zu46+stX)QcrGP=kL&bi*TSGXr_1`W<@Qp;qSEoI z#5!p}%Wm51wCyEyrV?6Q6~+XY?};14hxZ1mT30Ema`Ly}`FH}O$)!vDg>MPV;tE)O zH}hMh`otV1{xJ2^bjG$=0le8!0hp&N%^qTQe>FBysDIO_UxFj6(YP|*0*39rg(9Ji zOPW##qxsWG=U`0kKliAYjab&XfjIHiE9LSQ?r9k$ z{iUFMZouqcE1Sv6>2Wu3fm6lng|Rb zbSO9z&GN9QS`HR)lqW74td?9P@6SKV3j5rT6!_&1I#JAe!7dN1&C3F$VZ>3%%*ik> zPTZW>vTY-8;K!B}6qsK~n%u&s(UGqCmwmeB-<(GVl4OjpFCN{#AyJpsUe1hJyR0fs zuzj|~K5dgRw`r`Xl<=KFMFLhd>XCzGvx)%G@F)Gmhheh3G2MWCV0l^I*x2pWp~>UR z-mm5I*&vJ3RqO8%0KHe27ep&qo$^&pIca$w+mEHU@hHm`zA3Q7J<-pu_Y;vm#Xi6% zVzmQtzaMFN$<@!#d}ri&<2tYVZqY9u)vKba{Yb?G{ffZanN5~s%zbvwl+`fNfalzS z%z8bmgn_5$H^ctsqAZ|zEVoX)s`5oldW5B2fQ%s}KYV6B%GOdkZ^+bbT9W5*Siq@# z!0Gj}$5|krdw4IbmKKk_`$Ly6=u1I~>-3QT_J`B$`+vcZ-#a=#1na+h;C1#C}?$Wp)V(|gC)x%W)U>!la$L@+CX=* zyM_X17q>B1K(T)@TLL}q7$YUoa2lKq=EZ}nq;6)7PDg`(g%v&4<%=?2d`rqtwT(V0 zx;{#tqk$zcHjnAZ`9F+3U01AMrY<}!?c+wLSe_!Vc0R7FUmJyju=t-kpql?~_W13w zIm}0}3NmB5R;7>iM)ag~tITX*`wJTw_^_8nh~4(~moT*XufxNYF`m698P(JUOyCZ{U+DX~i0qFP{_e`;)yEeKGYU z?8?wF3SvdrHmDK4HR?q=1BE*J7f#ONf7eDw?>|61-!vTh4$LzBAznVM-jn0_(f#%u~{1l%6q(xz4ny+*PBZReva2qrny(U zC105?N2WAge(j>B5Gv`uBFa^U%#vUicZHZ{0MpDuYRsndCof{eD9o6%n70f5Ut`xA z)YK7$qqVkLrieNStr8W2h*X3O3I>P;6%(L9snCK1g=lI+Rg54{L!D`~1&sj#6^QkL zbQFRwj7S6#A*P_TIFhC!h~W`I2@pbpA=z_D)E;(*q5s zMFc!|A>;^@&a+pN;PMo|dNg%kmoO}siP_WzR+5zg z7oERELpc_ZLQ^5+^5QtaXMH~z&flRb@A5-a(`G=3<*{4GD(mVRw1%KGs)z%%fdE5h zYasASj##$`E1@XnQOi1-!PFnJk}LjkxRviz2N8jQvm4K&BRz?nOk`?ZL5^Gnod?qG zdGrBrWK|YY##s02Ie0gfIj&A~aLBE3@L0f}oe7eJMAbr9&qkiODS~BQfd!zHeg{5w zhy^Tvc8`5sWV21Vs0|iD6Yl^q!>s0FVZbJF8}X=_+_s^&C3YEA{VGPVo=jVO6AT9g zYE`+NZa!7bbUcb6EqF~?Giz9)JRxTkfm+7%dR_r(jCJvb|FT8hFh|rJuLq7Wpm^Pg zCvuz6j3%hZV0MA|S_TUZZh8t3(rz(nq0CAi*efe`3n4qI+=2*PZ%7JF)Rhb<3Y?aJDy{oyvJ{$})&LWgM->a*{nHL>E}awVSZ+oe+cyL-xQh)= ziu_=zm=64DKGKtc+O$%Sot}j9)dN*brtA^p-WVC@M15r+~Lv{7?D63rwB}m zMwltf@7V$o55&dTjtD@#oa^;98mW}(KC!-^tR4x^&Lf?i=P8MKN&S`GAk^r0*pB{6 z0b{&7C12K#uNvqGW%->diXyUQ?co{Nxz;u!o^vwsQ(Rrm@Q|Osk<$mMhUN7Cn;qLY zh^C=u`k|En>rinK;;e|O?o61jp(2n)0~jwZQ&Ia>k^->I@aB8bJ#l@&wZL0@m6#lhE#v|g^z76%*HHv=~*+@3lO_7=jMZ<%2o zUx=C@M)h5)P8V}VrXNK^jUi?|fopu+loOV5%#4p4c}{cvC(KA03|JTv>|(ysWFhjWCoC!^=m>C*4XcMP>M=5LS?v zp$(?VQny4?^j$dFnB=%F)6|j7&ePe_nzVS4Rg55!6_(saV}Uc2a9`g7gSi4%3jhom zvY7`=a!YawO4wd|9~LVE`gA1mOi3%g6i8yly+3<5y*Rb)m=CM@J?9f!XidpM4hD@? zS!nI8G&8(%MWoJnz*?eoF{u?-$GvR^{H&fDZDK6XO{q`xZLQ@HUvf&STeSGwRW)HOhQO zeW>l0AZ+Rmk;Gi^yImD`hT?ez5Ju@6IOBg6>~Kt$RcpwJGX{O^8}|M(;v?9nqq}yq zmPM-yv-7L9!K9;){Mgu~9*nwGv&C5|YPpwIU59(q{UK8UHO-Z<&6r~q;=DWkZXyby z!i+gS1eBZW7Mro#q8wbjb}7)D;*jt8WI3=c=C6DBd2e=orRmWz&2huvKRW$RMS*`p zA^dFix~q(z2SJPt0w&q|ReNMd_%IW(1`b!jSj~~M$ND6;?>S;PVBlX7!T}NSXAVYi z+8i73ByKSqBPaNJ5lcu<`@lH6bY2{A}F0Vq@)bcmKSrsGhX$tF=6z&HMiad53?} literal 125673 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ν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

k5qUoaSjK(Q!%IFBrzBF0VObmEW3wO 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 HQ$iB}NTGIM diff --git a/common/img/sprites/spritesmith/stable/pets/Pet-Lion-Veteran.png b/common/img/sprites/spritesmith/stable/pets/Pet-Lion-Veteran.png new file mode 100644 index 0000000000000000000000000000000000000000..83e3f90f39cd2eca1cfeb71a6f428764218b6e6c GIT binary patch literal 3801 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 P00000NkvXXu0mjfq_jD? literal 0 HcmV?d00001 diff --git a/common/locales/en/pets.json b/common/locales/en/pets.json index 7cd716b72d..7d13c8bc53 100644 --- a/common/locales/en/pets.json +++ b/common/locales/en/pets.json @@ -12,6 +12,7 @@ "etherealLion": "Ethereal Lion", "veteranWolf": "Veteran Wolf", "veteranTiger": "Veteran Tiger", + "veteranLion": "Veteran Lion", "cerberusPup": "Cerberus Pup", "hydra": "Hydra", "mantisShrimp": "Mantis Shrimp", diff --git a/common/script/content/index.js b/common/script/content/index.js index ec74e4796a..8164d4e0e6 100644 --- a/common/script/content/index.js +++ b/common/script/content/index.js @@ -426,6 +426,7 @@ api.specialPets = { 'Phoenix-Base': 'phoenix', 'Turkey-Gilded': 'gildedTurkey', 'MagicalBee-Base': 'magicalBee', + 'Lion-Veteran': 'veteranLion', }; api.specialMounts = { diff --git a/migrations/20160521_veteran_ladder.js b/migrations/20160521_veteran_ladder.js new file mode 100644 index 0000000000..cf92ff5375 --- /dev/null +++ b/migrations/20160521_veteran_ladder.js @@ -0,0 +1,76 @@ +var migrationName = '20160521_veteran_ladder.js'; +var authorName = 'Sabe'; // in case script author needs to know when their ... +var authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; //... own data is done + +/* + * Award Gilded Turkey pet to Turkey mount owners, Turkey Mount if they only have Turkey Pet, + * and Turkey Pet otherwise + */ + +var dbserver = 'localhost:27017'; // FOR TEST DATABASE +// var dbserver = 'username:password@ds031379-a0.mongolab.com:31379'; // FOR PRODUCTION DATABASE +var dbname = 'habitrpg'; + +var mongo = require('mongoskin'); +var _ = require('lodash'); + +var dbUsers = mongo.db(dbserver + '/' + dbname + '?auto_reconnect').collection('users'); + +// specify a query to limit the affected users (empty for all users): +var query = { + 'auth.timestamps.loggedin':{$gt:new Date('2016-05-01')} // remove when running migration a second time +}; + +// specify fields we are interested in to limit retrieved data (empty if we're not reading data): +var fields = { + 'migration': 1, + 'items.pets.Wolf-Veteran': 1, + 'items.pets.Tiger-Veteran': 1 +}; + +console.warn('Updating users...'); +var progressCount = 1000; +var count = 0; +dbUsers.findEach(query, fields, {batchSize:250}, function(err, user) { + if (err) { return exiting(1, 'ERROR! ' + err); } + if (!user) { + console.warn('All appropriate users found and modified.'); + return displayData(); + } + count++; + + // specify user data to change: + var set = {}; + if (user.migration !== migrationName) { + if (user.items.pets['Tiger-Veteran']) { + set = {'migration':migrationName, 'items.pets.Lion-Veteran':5}; + } else if (user.items.pets['Wolf-Veteran']) { + set = {'migration':migrationName, 'items.pets.Tiger-Veteran':5}; + } else { + set = {'migration':migrationName, 'items.pets.Wolf-Veteran':5}; + } + } + + dbUsers.update({_id:user._id}, {$set:set}); + + if (count%progressCount == 0) console.warn(count + ' ' + user._id); + if (user._id == authorUuid) console.warn(authorName + ' processed'); +}); + + +function displayData() { + console.warn('\n' + count + ' users processed\n'); + return exiting(0); +} + + +function exiting(code, msg) { + code = code || 0; // 0 = success + if (code && !msg) { msg = 'ERROR!'; } + if (msg) { + if (code) { console.error(msg); } + else { console.log( msg); } + } + process.exit(code); +} + diff --git a/website/server/controllers/top-level/pages.js b/website/server/controllers/top-level/pages.js index 22c65015f7..614b1edc0a 100644 --- a/website/server/controllers/top-level/pages.js +++ b/website/server/controllers/top-level/pages.js @@ -30,7 +30,7 @@ api.getFrontPage = { let staticPages = ['front', 'privacy', 'terms', 'api-v2', 'features', 'videos', 'contact', 'plans', 'new-stuff', 'community-guidelines', 'old-news', 'press-kit', 'faq', 'overview', 'apps', - 'clear-browser-data', 'merch']; + 'clear-browser-data', 'merch', 'maintenance-info']; _.each(staticPages, (name) => { api[`get${name}Page`] = { diff --git a/website/server/models/user.js b/website/server/models/user.js index b007bb24c5..2d0e561a66 100644 --- a/website/server/models/user.js +++ b/website/server/models/user.js @@ -48,6 +48,7 @@ export let schema = new Schema({ // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which // have been updated (http://goo.gl/gQLz41), but we want *every* update _v: { type: Number, default: 0 }, + migration: String, achievements: { originalUser: Boolean, habitSurveys: Number, diff --git a/website/views/shared/new-stuff.jade b/website/views/shared/new-stuff.jade index 1041ea4707..c59174b701 100644 --- a/website/views/shared/new-stuff.jade +++ b/website/views/shared/new-stuff.jade @@ -1,26 +1,44 @@ -h2 5/19/2016 - IMPORTANT: UPCOMING MAINTENANCE! +h2 5/21/2016 - WELCOME BACK, HABITICA! hr tr td - h3 Maintenance to Take Place May 21 - p This Saturday, we will be performing important maintenance on Habitica to build out the groundwork for some exciting upcoming features! We'll be doing everything we can to make this as smooth as possible, but unfortunately, there will be significant downtime for much of the day. - br - p.strong We expect that on Saturday, May 21st, Habitica will be unavailable between 1 PM and 10 PM Pacific Time (8 pm - 5 am UTC). - ul - li Don't worry, you will NOT lose any streaks or take any damage during this weekend, not even from Bosses! This maintenance will not harm your accounts. - li If you will need to see your task list on Saturday, we recommend taking a screenshot of your tasks before the maintenance begins so that you can use them as a reference during downtime. - li At the end of the maintenance, to thank people for their patience, everyone will receive a rare Veteran pet! - li This maintenance should not result in any major visible differences to the site; it's all behind-the-scenes work. However, at the end of it, we will release new updates to the mobile apps, which will be required in order for the apps to work properly with the new changes! Be sure to download those updates on Saturday as soon as they are released. - li For more information, please check out our detailed info page about the maintenance! And if you have any further questions or concerns, feel free to reach out to Leslie (leslie@habitica.com), and she will be happy to help you. - p We understand that it's very frustrating to have Habitica unavailable for such a long part of the day. Rest assured that we'll be doing everything we can to make the maintenance go as quickly as possible, but with over a million Habitican accounts to migrate, this is a hefty task! During the maintenance on Saturday we will be posting regular status reports on our Twitter account, so you can follow us for the most accurate updates. - br - p Thank you for your patience, and for using Habitica! + h3 Welcome Back, Everyone! + p Hurrah! After many hours of toil, our valiant blacksmiths were able to complete our planned maintenance ahead of schedule. The site should be working normally again! If you notice any issues or have any questions, please feel free to email us at admin@habitica.com and we will be happy to help. + tr + td + h3 Important Mobile App Updates + p We’ve released an iOS update and an Android update that contain the new code. It’s very important to download these updates immediately, or you may encounter significant bugs! + tr + td + .Pet-Wolf-Veteran.pull-right + h3 Veteran Pets + p To thank you for your patience during the maintenance, we have awarded everyone a special Veteran Pet! You can see it under Inventory > Pets, at the bottom of the screen. If it hasn’t appeared yet, never fear: because there are so many Habiticans, it can sometimes take an hour or two for everyone to receive their pet. You will have it soon! Thanks again for bearing with us during the downtime. + tr + td + h3 Daily Safe Mode + p To protect the accounts of Habiticans in different time zones across the world, we enabled Cron Daily Safe Mode during the maintenance, which will prevent you from taking any damage or losing any streaks for the rest of the weekend. Let us know at admin@habitica.com if you have any questions or concerns! if menuItem !== 'oldNews' hr a(href='/static/old-news', target='_blank') Read older news mixin oldNews + h2 5/19/2016 - IMPORTANT: UPCOMING MAINTENANCE! + tr + td + h3 Maintenance to Take Place May 21 + p This Saturday, we will be performing important maintenance on Habitica to build out the groundwork for some exciting upcoming features! We'll be doing everything we can to make this as smooth as possible, but unfortunately, there will be significant downtime for much of the day. + br + p.strong We expect that on Saturday, May 21st, Habitica will be unavailable between 1 PM and 10 PM Pacific Time (8 pm - 5 am UTC). + ul + li Don't worry, you will NOT lose any streaks or take any damage during this weekend, not even from Bosses! This maintenance will not harm your accounts. + li If you will need to see your task list on Saturday, we recommend taking a screenshot of your tasks before the maintenance begins so that you can use them as a reference during downtime. + li At the end of the maintenance, to thank people for their patience, everyone will receive a rare Veteran pet! + li This maintenance should not result in any major visible differences to the site; it's all behind-the-scenes work. However, at the end of it, we will release new updates to the mobile apps, which will be required in order for the apps to work properly with the new changes! Be sure to download those updates on Saturday as soon as they are released. + li For more information, please check out our detailed info page about the maintenance! And if you have any further questions or concerns, feel free to reach out to Leslie (leslie@habitica.com), and she will be happy to help you. + p We understand that it's very frustrating to have Habitica unavailable for such a long part of the day. Rest assured that we'll be doing everything we can to make the maintenance go as quickly as possible, but with over a million Habitican accounts to migrate, this is a hefty task! During the maintenance on Saturday we will be posting regular status reports on our Twitter account, so you can follow us for the most accurate updates. + br + p Thank you for your patience, and for using Habitica! h2 5/17/2016 - TREELING PET QUEST AND CHALLENGE SPOTLIGHT! tr td From ecfacd6c9aa2ec75d9696e9bf7cc98437093faea Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 22 May 2016 13:07:41 +0200 Subject: [PATCH 968/976] fix exporting avatar --- website/server/controllers/top-level/dataexport.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js index 477c8ea829..731534a19b 100644 --- a/website/server/controllers/top-level/dataexport.js +++ b/website/server/controllers/top-level/dataexport.js @@ -196,7 +196,7 @@ api.exportUserAvatarPng = { let memberId = req.params.memberId; - let filename = `avatars/${memberId}/.png`; + let filename = `avatars/${memberId}.png`; let s3url = `https://${S3_BUCKET}+'.s3.amazonaws.com/${filename}`; let response = await got.head(s3url); From ab3371213e23751722f20e8a4bbfcc9866de66a4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 22 May 2016 13:51:57 +0200 Subject: [PATCH 969/976] second attempt at fixing exporting avatar --- website/server/controllers/top-level/dataexport.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js index 731534a19b..a1dc1c9303 100644 --- a/website/server/controllers/top-level/dataexport.js +++ b/website/server/controllers/top-level/dataexport.js @@ -198,10 +198,18 @@ api.exportUserAvatarPng = { let filename = `avatars/${memberId}.png`; let s3url = `https://${S3_BUCKET}+'.s3.amazonaws.com/${filename}`; - let response = await got.head(s3url); + + let response; + try { + response = await got.head(s3url); + } catch (gotError) { + if (gotError.code !== 'ENOTFOUND' && gotError.statusCode !== 404) { + throw gotError; + } + } // cache images for 10 minutes on aws, else upload a new one - if (response.statusCode === 200 && moment().diff(response.headers['last-modified'], 'minutes') < 10) { + if (response && response.statusCode === 200 && moment().diff(response.headers['last-modified'], 'minutes') < 10) { return res.redirect(301, s3url); } From deca281233105c10554b4fd88d9bed247e318c1d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 22 May 2016 13:59:37 +0200 Subject: [PATCH 970/976] fix production logging --- website/server/index.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/website/server/index.js b/website/server/index.js index 11171f8579..ec8e882c99 100644 --- a/website/server/index.js +++ b/website/server/index.js @@ -13,15 +13,15 @@ require('babel-polyfill'); // Setup Bluebird as the global promise library global.Promise = require('bluebird'); -// Only do the minimal amount of work before forking just in case of a dyno restart -const cluster = require('cluster'); -const nconf = require('nconf'); -const logger = require('./libs/api-v3/logger'); - -// Initialize configuration +// Initialize configuration BEFORE anything const setupNconf = require('./libs/api-v3/setupNconf'); setupNconf(); +const nconf = require('nconf'); + +const cluster = require('cluster'); +const logger = require('./libs/api-v3/logger'); + const IS_PROD = nconf.get('IS_PROD'); const IS_DEV = nconf.get('IS_DEV'); const CORES = Number(nconf.get('WEB_CONCURRENCY')) || 0; From 1e83ddaf69bf8b6bd2350a08e93e82b9c7b9f0a8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 22 May 2016 14:22:30 +0200 Subject: [PATCH 971/976] s3: convert moment to date instance --- website/server/controllers/top-level/dataexport.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js index a1dc1c9303..ef57033450 100644 --- a/website/server/controllers/top-level/dataexport.js +++ b/website/server/controllers/top-level/dataexport.js @@ -226,7 +226,7 @@ api.exportUserAvatarPng = { ACL: 'public-read', StorageClass: 'REDUCED_REDUNDANCY', ContentType: 'image/png', - Expires: moment().add({minutes: 3}), + Expires: moment().add({minutes: 3}).toDate(), Body: stream, }); From 4c8ad80911e16e75f051e0dd420975da41cde646 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 22 May 2016 16:35:57 +0200 Subject: [PATCH 972/976] fix avatar sharing and caching (30 minutes) --- website/server/controllers/top-level/dataexport.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js index ef57033450..08bc4a1f8e 100644 --- a/website/server/controllers/top-level/dataexport.js +++ b/website/server/controllers/top-level/dataexport.js @@ -171,7 +171,7 @@ api.exportUserAvatarHtml = { if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); res.render('avatar-static', { title: member.profile.name, - env: _.defaults({member}, res.locals.habitrpg), + env: _.defaults({user: member}, res.locals.habitrpg), }); }, }; @@ -197,7 +197,7 @@ api.exportUserAvatarPng = { let memberId = req.params.memberId; let filename = `avatars/${memberId}.png`; - let s3url = `https://${S3_BUCKET}+'.s3.amazonaws.com/${filename}`; + let s3url = `https://${S3_BUCKET}.s3.amazonaws.com/${filename}`; let response; try { @@ -208,9 +208,9 @@ api.exportUserAvatarPng = { } } - // cache images for 10 minutes on aws, else upload a new one - if (response && response.statusCode === 200 && moment().diff(response.headers['last-modified'], 'minutes') < 10) { - return res.redirect(301, s3url); + // cache images for 30 minutes on aws, else upload a new one + if (response && response.statusCode === 200 && moment().diff(response.headers['last-modified'], 'minutes') < 30) { + return res.redirect(s3url); } let [stream] = await new Pageres() @@ -226,7 +226,7 @@ api.exportUserAvatarPng = { ACL: 'public-read', StorageClass: 'REDUCED_REDUNDANCY', ContentType: 'image/png', - Expires: moment().add({minutes: 3}).toDate(), + Expires: moment().add({minutes: 5}).toDate(), Body: stream, }); From 7d452232962b538bc2862120aa63bf735869fcb6 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 22 May 2016 20:50:08 -0500 Subject: [PATCH 973/976] fix: Correct missing parameter Closes #7433 --- website/views/options/settings.jade | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index ce1de8c4d2..34c4dff0ca 100644 --- a/website/views/options/settings.jade +++ b/website/views/options/settings.jade @@ -175,7 +175,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') h5=env.t('changeEmail') form(ng-submit='changeUser("email", emailUpdates)', ng-show='user.auth.local', name='changeEmail', novalidate) .form-group - input.form-control(type='text', placeholder=env.t('newEmail'), ng-model='emailUpdates.email', required) + input.form-control(type='text', placeholder=env.t('newEmail'), ng-model='emailUpdates.newEmail', required) .form-group input.form-control(type='password', placeholder=env.t('password'), ng-model='emailUpdates.password', required) input.btn.btn-default(type='submit', ng-disabled='changeEmail.$invalid', value=env.t('submit')) @@ -389,7 +389,7 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') input.form-control(type='text', ng-model='_subscription.coupon', placeholder= env.t('couponPlaceholder')) .form-group button.pull-right.btn.btn-small(type='button',ng-click='applyCoupon(_subscription.coupon)')= env.t("apply") - + div(ng-if='user.purchased.plan.customerId') .btn.btn-primary(ng-if='!user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()')=env.t('subUpdateCard') .btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub') From 8cf4f9f41469eb161de97727b1d998c74a861d01 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 22 May 2016 20:58:03 -0500 Subject: [PATCH 974/976] fix: Validate challenge shortname on server --- .../challenges/POST-challenges.test.js | 22 +++++++++---------- website/server/models/challenge.js | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js index 283db9333e..a97578eef5 100644 --- a/test/api/v3/integration/challenges/POST-challenges.test.js +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -115,7 +115,7 @@ describe('POST /challenges', () => { let chal = await groupMember.post('/challenges', { group: group._id, name: 'Test Challenge', - shortName: 'TC', + shortName: 'TC Label', }); expect(chal.leader).to.eql({ @@ -131,7 +131,7 @@ describe('POST /challenges', () => { await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', - shortName: 'TC', + shortName: 'TC Label', prize: 0, }); @@ -143,7 +143,7 @@ describe('POST /challenges', () => { await expect(groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', - shortName: 'TC', + shortName: 'TC Label', prize: 20, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -160,7 +160,7 @@ describe('POST /challenges', () => { await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', - shortName: 'TC', + shortName: 'TC Label', prize, }); @@ -175,7 +175,7 @@ describe('POST /challenges', () => { await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', - shortName: 'TC', + shortName: 'TC Label', prize, }); @@ -191,7 +191,7 @@ describe('POST /challenges', () => { await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', - shortName: 'TC', + shortName: 'TC Label', prize, }); @@ -205,7 +205,7 @@ describe('POST /challenges', () => { await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', - shortName: 'TC', + shortName: 'TC Label', }); await expect(group.sync()).to.eventually.have.property('challengeCount', oldChallengeCount + 1); @@ -221,7 +221,7 @@ describe('POST /challenges', () => { let challenge = await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', - shortName: 'TC', + shortName: 'TC Label', official: true, }); @@ -232,7 +232,7 @@ describe('POST /challenges', () => { let challenge = await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', - shortName: 'TC', + shortName: 'TC Label', official: true, }); @@ -265,7 +265,7 @@ describe('POST /challenges', () => { it('sets all properites of the challenge as passed', async () => { let name = 'Test Challenge'; - let shortName = 'TC'; + let shortName = 'TC Label'; let description = 'Test Description'; let prize = 4; @@ -299,7 +299,7 @@ describe('POST /challenges', () => { let challenge = await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', - shortName: 'TC', + shortName: 'TC Label', }); await expect(groupLeader.sync()).to.eventually.have.property('challenges').to.include(challenge._id); diff --git a/website/server/models/challenge.js b/website/server/models/challenge.js index b12cea1dd8..9901af0e79 100644 --- a/website/server/models/challenge.js +++ b/website/server/models/challenge.js @@ -19,7 +19,7 @@ let Schema = mongoose.Schema; let schema = new Schema({ name: {type: String, required: true}, - shortName: {type: String, required: true}, + shortName: {type: String, required: true, minlength: 3}, description: String, official: {type: Boolean, default: false}, tasksOrder: { From fa009a19f4dd733ca8069df8068cc2ae5a3909b1 Mon Sep 17 00:00:00 2001 From: Alys Date: Mon, 23 May 2016 06:27:52 -0400 Subject: [PATCH 975/976] adjust text strings - fixes https://github.com/HabitRPG/habitrpg/issues/5631 and also Short Name -> Tag Name --- common/locales/en/challenge.json | 2 +- common/locales/en/groups.json | 4 ++-- common/locales/en/subscriber.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/common/locales/en/challenge.json b/common/locales/en/challenge.json index 0fb8982246..c014e1f6db 100644 --- a/common/locales/en/challenge.json +++ b/common/locales/en/challenge.json @@ -79,5 +79,5 @@ "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", "userAlreadyInChallenge": "User is already participating in this challenge.", "cantOnlyUnlinkChalTask": "Only broken challenges tasks can be unlinked.", - "shortNameTooShort": "Short Name must have at least 3 characters." + "shortNameTooShort": "Tag Name must have at least 3 characters." } diff --git a/common/locales/en/groups.json b/common/locales/en/groups.json index 4d8ea0716c..2e0f245232 100644 --- a/common/locales/en/groups.json +++ b/common/locales/en/groups.json @@ -182,8 +182,8 @@ "userAlreadyInAParty": "User already in a party.", "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", "userHasNoLocalRegistration": "User does not have a local registration (username, email, password).", - "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", - "emailsMustBeAnArray": "Email invites must be a an Array.", + "uuidsMustBeAnArray": "User ID invites must be an array.", + "emailsMustBeAnArray": "Email address invites must be an array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!" } diff --git a/common/locales/en/subscriber.json b/common/locales/en/subscriber.json index 197994da08..8c0d453727 100644 --- a/common/locales/en/subscriber.json +++ b/common/locales/en/subscriber.json @@ -134,7 +134,7 @@ "notAllowedHourglass": "Pet/Mount not available for purchase with Mystic Hourglass.", "readCard": "<%= cardType %> has been read", "cardTypeRequired": "Card type required", - "cardTypeNotAllowed": "Unkown card type.", + "cardTypeNotAllowed": "Unknown card type.", "invalidCoupon": "Invalid coupon code.", "couponUsed": "Coupon code already used.", "noSudoAccess": "You don't have sudo access.", From 0a7b6b65e723e46c34ceac437f7da7db539e4a0a Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 23 May 2016 07:53:14 -0500 Subject: [PATCH 976/976] Revert "API v3 [WIP] (#6144)" This reverts commit 28f2e9c356d7053884107d90d04e28dde75fa81b. --- .babelrc | 7 +- .bowerrc | 2 +- .eslintignore | 18 +- .eslintrc | 5 +- .gitignore | 11 +- .nodemonignore | 2 +- .travis.yml | 2 +- Gruntfile.js | 26 +- Procfile | 2 +- bower.json | 2 +- common/browserify.js | 2 - common/dist/sprites/spritesmith-main-0.css | 4 +- common/dist/sprites/spritesmith-main-11.css | 10 +- common/dist/sprites/spritesmith-main-11.png | Bin 156933 -> 156807 bytes common/dist/sprites/spritesmith-main-12.css | 850 +++++----- common/dist/sprites/spritesmith-main-12.png | Bin 126575 -> 125673 bytes common/dist/sprites/spritesmith-main-5.css | 96 +- common/dist/sprites/spritesmith-main-5.png | Bin 261236 -> 261273 bytes common/dist/sprites/spritesmith-main-6.css | 4 +- ...Sparkles.png => achievement-spookDust.png} | Bin ...kles2x.png => achievement-spookDust2x.png} | Bin ...es.png => inventory_special_spookDust.png} | Bin .../misc/{ghost.png => spookman.png} | Bin ..._spookySparkles.png => shop_spookDust.png} | Bin .../{shop_healAll.png => shop_heallAll.png} | Bin .../stable/pets/Pet-Lion-Veteran.png | Bin 3801 -> 0 bytes common/index.js | 4 +- common/locales/en/challenge.json | 5 +- common/locales/en/groups.json | 4 +- common/locales/en/limited.json | 2 +- common/locales/en/maintenance.json | 2 +- common/locales/en/pets.json | 1 - common/locales/en/settings.json | 1 - common/locales/en/spells.json | 6 +- common/locales/en/subscriber.json | 2 +- common/locales/en/tasks.json | 1 - common/script/constants.js | 2 - common/script/content/index.js | 9 - common/script/content/spells.js | 79 +- common/script/cron.js | 1 - common/script/fns/autoAllocate.js | 107 +- common/script/fns/crit.js | 15 +- common/script/fns/cron.js | 358 ++++ common/script/fns/dotGet.js | 8 +- common/script/fns/dotSet.js | 18 +- common/script/fns/getItem.js | 11 + common/script/fns/handleTwoHanded.js | 19 +- common/script/fns/index.js | 6 + common/script/fns/nullify.js | 6 +- common/script/fns/predictableRandom.js | 28 +- common/script/fns/preenUserHistory.js | 25 + common/script/fns/randomDrop.js | 114 +- common/script/fns/randomVal.js | 14 +- common/script/fns/resetGear.js | 25 - common/script/fns/ultimateGear.js | 30 +- common/script/fns/updateStats.js | 70 +- common/script/index.js | 281 +--- common/script/libs/appliedTags.js | 19 +- common/script/libs/countExists.js | 7 + common/script/libs/dotGet.js | 10 +- common/script/libs/dotSet.js | 15 +- common/script/libs/encodeiCalLink.js | 9 + common/script/libs/errors.js | 42 - common/script/libs/extendableBuiltin.js | 11 - common/script/libs/friendlyTimestamp.js | 9 + common/script/libs/gold.js | 6 +- common/script/libs/index.js | 46 + common/script/libs/newChatMessages.js | 10 + common/script/libs/noTags.js | 6 +- common/script/libs/percent.js | 10 +- common/script/libs/pickDeep.js | 13 - common/script/libs/preenHistory.js | 43 + common/script/libs/preenTodos.js | 12 +- common/script/libs/refPush.js | 11 +- common/script/libs/removeWhitespace.js | 10 + common/script/libs/silver.js | 9 +- common/script/libs/splitWhitespace.js | 3 +- common/script/libs/statsComputed.js | 28 - common/script/libs/taskClasses.js | 65 +- common/script/libs/taskDefaults.js | 83 +- common/script/libs/updateStore.js | 47 +- common/script/libs/uuid.js | 5 +- common/script/ops/addPushDevice.js | 43 +- common/script/ops/addTag.js | 13 +- common/script/ops/addTask.js | 24 +- common/script/ops/addWebhook.js | 34 +- common/script/ops/allocate.js | 26 +- common/script/ops/allocateNow.js | 15 +- common/script/ops/blockUser.js | 28 +- common/script/ops/buy.js | 133 +- common/script/ops/buyArmoire.js | 115 -- common/script/ops/buyGear.js | 70 - common/script/ops/buyHealthPotion.js | 48 - common/script/ops/buyMysterySet.js | 78 +- common/script/ops/buyQuest.js | 79 +- common/script/ops/buySpecialSpell.js | 46 +- common/script/ops/changeClass.js | 107 +- common/script/ops/clearCompleted.js | 14 +- common/script/ops/clearPMs.js | 10 +- common/script/ops/deletePM.js | 14 +- common/script/ops/deleteTag.js | 38 +- common/script/ops/deleteTask.js | 31 +- common/script/ops/deleteWebhook.js | 15 +- common/script/ops/disableClasses.js | 13 +- common/script/ops/equip.js | 74 +- common/script/ops/feed.js | 126 +- common/script/ops/getTag.js | 23 +- common/script/ops/getTags.js | 6 +- common/script/ops/hatch.js | 53 +- common/script/ops/hourglassPurchase.js | 73 +- common/script/ops/index.js | 13 +- common/script/ops/markPMSRead.js | 14 - common/script/ops/openMysteryItem.js | 58 +- common/script/ops/purchase.js | 156 +- common/script/ops/readCard.js | 36 +- common/script/ops/rebirth.js | 106 +- common/script/ops/releaseBoth.js | 94 +- common/script/ops/releaseMounts.js | 61 +- common/script/ops/releasePets.js | 59 +- common/script/ops/reroll.js | 49 +- common/script/ops/reset.js | 48 +- common/script/ops/revive.js | 113 +- common/script/ops/score.js | 221 +++ common/script/ops/scoreTask.js | 260 --- common/script/ops/sell.js | 46 +- common/script/ops/sleep.js | 9 +- common/script/ops/sortTag.js | 24 +- common/script/ops/sortTask.js | 58 +- common/script/ops/unlock.js | 142 +- common/script/ops/update.js | 12 +- common/script/ops/updateTag.js | 26 +- common/script/ops/updateTask.js | 36 +- common/script/ops/updateWebhook.js | 23 +- common/script/public/config.js | 78 +- common/script/public/userServices.js | 268 +++ config.json.example | 8 +- gulpfile.js | 1 - karma.conf.js | 57 +- migrations/20160521_veteran_ladder.js | 76 - migrations/api_v3/challenges.js | 212 --- migrations/api_v3/challengesMembers.js | 143 -- migrations/api_v3/coupons.js | 136 -- migrations/api_v3/emailUnsubscriptions.js | 137 -- migrations/api_v3/groups.js | 211 --- migrations/api_v3/indexes.js | 52 - migrations/api_v3/users.js | 262 --- migrations/manual_password_reset.js | 2 +- newrelic.js | 27 + package.json | 74 +- tasks/gulp-apidoc.js | 22 - tasks/gulp-build.js | 11 +- tasks/gulp-console.js | 13 +- tasks/gulp-newstuff.js | 2 +- tasks/gulp-start.js | 2 +- tasks/gulp-tests.js | 155 +- tasks/taskHelper.js | 43 +- test/README.md | 5 + test/api-legacy/api-helper.js | 5 +- test/api-legacy/challenges.js | 6 +- test/api-legacy/chat.js | 4 +- test/api-legacy/coupons.js | 4 +- test/api-legacy/inAppPurchases.js | 4 +- test/api-legacy/party.js | 4 +- test/api-legacy/pushNotifications.js | 10 +- test/api-legacy/score.js | 2 +- test/api-legacy/subscriptions.js | 4 +- test/api-legacy/todos.js | 2 +- test/api/README.md | 8 +- test/api/v2/groups/GET-groups.test.js | 13 +- .../v2/groups/POST-groups_id_invite.test.js | 8 +- .../api/v2/groups/POST-groups_id_join.test.js | 13 +- .../v2/groups/POST-groups_id_leave.test.js | 4 +- test/api/v2/user/DELETE-user.test.js | 18 +- .../POST-user_batch-update.test.js | 2 +- .../user/pushDevice/POST-pushDevice.test.js | 2 +- test/api/v2/user/tasks/GET-tasks.test.js | 11 +- .../user/tasks/POST-clear-completed.test.js | 26 - test/api/v2/user/tasks/POST-tasks.test.js | 2 +- test/api/v2/user/tasks/PUT-tasks_id.test.js | 4 +- test/api/v3/README.md | 4 - .../DELETE-challenges_challengeId.test.js | 94 -- .../GET-challenges_challengeId.test.js | 142 -- ...-challenges_challengeId_export_csv.test.js | 74 - ...GET-challenges_challengeId_members.test.js | 109 -- ...enges_challengeId_members_memberId.test.js | 107 -- .../GET-challenges_group_groupid.test.js | 118 -- .../challenges/GET-challenges_user.test.js | 132 -- .../challenges/POST-challenges.test.js | 308 ---- .../POST-challenges_challengeId_join.test.js | 127 -- .../POST-challenges_challengeId_leave.test.js | 123 -- ...lenges_challengeId_winner_winnerId.test.js | 139 -- .../PUT-challenges_challengeId.test.js | 85 - .../integration/chat/DELETE-chat_id.test.js | 81 - test/api/v3/integration/chat/GET-chat.test.js | 65 - .../integration/chat/POST-chat.flag.test.js | 84 - .../integration/chat/POST-chat.like.test.js | 75 - .../api/v3/integration/chat/POST-chat.test.js | 81 - .../integration/chat/POST-chat_seen.test.js | 63 - ...POST-groups_id_chat_id_clear_flags.test.js | 101 -- .../integration/content/GET-content.test.js | 25 - .../integration/coupons/GET-coupons.test.js | 39 - .../coupons/POST-coupons_enter_code.test.js | 62 - .../POST-coupons_generate_event.test.js | 66 - .../POST-coupons_validate_code.test.js | 36 - .../GET-export_avatar-memberId.html.test.js | 35 - .../GET-export_avatar-memberId.png.test.js | 3 - .../dataexport/GET-export_history.csv.test.js | 47 - .../GET-export_userdata.json.test.js | 29 - .../GET-export_userdata.xml.test.js | 42 - .../debug/POST-debug_addHourglass.test.js | 35 - .../debug/POST-debug_addTenGems.test.js | 35 - .../debug/POST-debug_make-admin.test.js | 35 - .../debug/POST-debug_modify-inventory.test.js | 160 -- .../debug/POST-debug_quest-progress.test.js | 63 - .../debug/POST-debug_set-cron.test.js | 39 - .../emails/GET-email-unsubscribe.test.js | 68 - .../v3/integration/groups/GET-groups.test.js | 115 -- .../groups/GET-groups_groupId_invites.test.js | 102 -- .../groups/GET-groups_groupId_members.test.js | 96 -- .../integration/groups/GET-groups_id.test.js | 298 ---- .../v3/integration/groups/POST-groups.test.js | 228 --- .../groups/POST-groups_groupId_join.test.js | 286 ---- .../groups/POST-groups_groupId_leave.js | 210 --- .../groups/POST-groups_groupId_reject.test.js | 113 -- .../POST-groups_id_removeMember.test.js | 130 -- .../groups/POST-groups_invite.test.js | 332 ---- .../v3/integration/groups/PUT-groups.test.js | 46 - .../integration/hall/GET-hall_heroes.test.js | 29 - .../hall/GET-hall_heroes_heroId.test.js | 56 - .../integration/hall/GET-hall_patrons.test.js | 60 - .../hall/PUT-hall_heores_heroId.test.js | 148 -- .../members/GET-members_id.test.js | 49 - .../members/POST-send_private_message.test.js | 107 -- .../members/POST-transfer_gems.test.js | 174 -- .../models/GET-model_paths.test.js | 32 - test/api/v3/integration/notFound.test.js | 13 - ...T-payments_amazon_subscribe_cancel.test.js | 21 - .../GET-payments_paypal_checkout.test.js | 21 - ...T-payments_paypal_checkout_success.test.js | 21 - .../GET-payments_paypal_subscribe.test.js | 21 - ...T-payments_paypal_subscribe_cancel.test.js | 21 - ...-payments_paypal_subscribe_success.test.js | 21 - ...T-payments_stripe_subscribe_cancel.test.js | 21 - .../POST-payments_amazon_checkout.test.js | 20 - ...ents_amazon_createOrderReferenceId.test.js | 22 - .../POST-payments_amazon_subscribe.test.js | 21 - ...-payments_amazon_verifyAccessToken.test.js | 20 - .../payments/POST-payments_paypal_ipn.test.js | 17 - .../POST-payments_stripe_checkout.test.js | 20 - ...OST-payments_stripe_subscribe_edit.test.js | 21 - .../POST-groups_groupId_quests_accept.test.js | 119 -- ...-groups_groupId_quests_force-start.test.js | 126 -- .../POST-groups_groupId_quests_invite.test.js | 192 --- .../POST-groups_groupid_quests_abort.test.js | 126 -- .../POST-groups_groupid_quests_cancel.test.js | 138 -- .../POST-groups_groupid_quests_leave.test.js | 124 -- .../POST-groups_groupid_quests_reject.test.js | 146 -- .../v3/integration/status/GET-status.test.js | 12 - .../integration/tags/DELETE-tags_id.test.js | 27 - test/api/v3/integration/tags/GET-tags.test.js | 22 - .../v3/integration/tags/GET-tags_id.test.js | 20 - .../integration/tags/POST-tag-reorder.test.js | 44 - .../api/v3/integration/tags/POST-tags.test.js | 25 - .../v3/integration/tags/PUT-tags_id.test.js | 28 - .../integration/tasks/DELETE-tasks_id.test.js | 59 - .../GET-tasks_challenge_challengeId.test.js | 75 - .../v3/integration/tasks/GET-tasks_id.test.js | 58 - .../integration/tasks/GET-tasks_user.test.js | 84 - .../POST-tasks_clearCompletedTodos.test.js | 43 - .../POST-tasks_id_score_direction.test.js | 298 ---- ...POST-tasks_move_taskId_to_position.test.js | 84 - .../integration/tasks/POST-tasks_user.test.js | 593 ------- .../PUT-tasks_challenge_challengeId.test.js | 323 ---- .../v3/integration/tasks/PUT-tasks_id.test.js | 396 ----- ...lenge_challengeId_checklist_itemId.test.js | 115 -- ...ETE-tasks_id_challenge_challengeId.test.js | 115 -- .../challenges/GET_tasks_challenge.id.test.js | 86 - ...lenge_challengeId_taskId_checklist.test.js | 119 -- .../POST-tasks_challenge_id.test.js | 125 -- ...allengeId_tasks_id_score_direction.test.js | 140 -- ...allengeId_tasksId_checklist_itemId.test.js | 155 -- ...LETE-tasks_taskId_checklist_itemId.test.js | 74 - .../POST-tasks_taskId_checklist.test.js | 73 - ...asks_taskId_checklist_itemId_score.test.js | 79 - .../PUT-tasks_taskId_checklist_itemId.test.js | 83 - .../DELETE-tasks_taskId_tags_tagId.test.js | 42 - .../tags/POST-tasks_taskId_tags_tagId.test.js | 55 - .../v3/integration/user/DELETE-user.test.js | 176 -- .../user/DELETE-user_delete_webhook.test.js | 23 - .../user/DELETE-user_messages.test.js | 27 - test/api/v3/integration/user/GET-user.test.js | 24 - .../user/GET-user_anonymized.test.js | 90 - .../user/GET-user_inventory_buy.test.js | 26 - .../user/POST-user_addPushDevice.test.js | 35 - .../user/POST-user_add_webhook.test.js | 29 - .../user/POST-user_allocate.test.js | 41 - .../user/POST-user_allocate_now.test.js | 28 - .../integration/user/POST-user_block.test.js | 34 - .../v3/integration/user/POST-user_buy.test.js | 62 - .../user/POST-user_buy_armoire.test.js | 41 - .../user/POST-user_buy_gear.test.js | 45 - .../user/POST-user_buy_health_potion.test.js | 42 - .../user/POST-user_buy_mystery_set.test.js | 38 - .../user/POST-user_buy_quest.test.js | 40 - .../user/POST-user_buy_special_spell.test.js | 43 - .../user/POST-user_change-class.test.js | 30 - .../user/POST-user_class_cast_spellId.test.js | 172 -- .../user/POST-user_custom-day-start.test.js | 47 - .../user/POST-user_disable-classes.test.js | 26 - .../user/POST-user_equip_type_key.test.js | 40 - .../user/POST-user_feed_pet_food.test.js | 45 - ...POST-user_hatch_egg_hatchingPotion.test.js | 31 - .../user/POST-user_mark_pms_read.test.js | 22 - .../user/POST-user_open_mystery_item.test.js | 26 - .../user/POST-user_purchase.test.js | 34 - .../user/POST-user_purchase_hourglass.test.js | 25 - .../user/POST-user_read_card.test.js | 38 - .../user/POST-user_rebirth.test.js | 57 - .../user/POST-user_release_both.test.js | 48 - .../user/POST-user_release_mounts.test.js | 42 - .../user/POST-user_release_pets.test.js | 42 - .../integration/user/POST-user_reroll.test.js | 54 - .../integration/user/POST-user_reset.test.js | 104 -- .../integration/user/POST-user_revive.test.js | 37 - .../integration/user/POST-user_sell.test.js | 41 - .../integration/user/POST-user_sleep.test.js | 25 - .../v3/integration/user/POST-user_unlock.js | 37 - test/api/v3/integration/user/PUT-user.test.js | 201 --- .../user/PUT-user_update_webhook.test.js | 32 - .../DELETE-user_auth_social_network.test.js | 40 - .../integration/user/auth/GET-logout.test.js | 3 - .../user/auth/POST-firebase.test.js | 18 - .../user/auth/POST-login-local.test.js | 69 - .../user/auth/POST-register_local.test.js | 349 ---- .../auth/POST-user_reset_password.test.js | 38 - .../user/auth/PUT-user_update_email.test.js | 79 - .../auth/PUT-user_update_password.test.js | 53 - .../auth/PUT-user_update_username.test.js | 76 - .../api/v3/unit/libs/analyticsService.test.js | 309 ---- test/api/v3/unit/libs/baseModel.test.js | 97 -- test/api/v3/unit/libs/buildManifest.test.js | 19 - .../unit/libs/collectionManipulators.test.js | 88 - test/api/v3/unit/libs/cron.test.js | 573 ------- test/api/v3/unit/libs/email.test.js | 232 --- test/api/v3/unit/libs/encryption.test.js | 15 - test/api/v3/unit/libs/errors.test.js | 122 -- test/api/v3/unit/libs/i18n.test.js | 39 - test/api/v3/unit/libs/logger.js | 57 - test/api/v3/unit/libs/password.test.js | 41 - test/api/v3/unit/libs/payments.test.js | 72 - test/api/v3/unit/libs/preening.test.js | 56 - test/api/v3/unit/libs/setupNconf.test.js | 44 - test/api/v3/unit/libs/webhooks.test.js | 135 -- .../api/v3/unit/middlewares/analytics.test.js | 49 - test/api/v3/unit/middlewares/cors.test.js | 40 - .../api/v3/unit/middlewares/cronMiddleware.js | 177 -- .../middlewares/ensureAccessRight.test.js | 57 - .../unit/middlewares/ensureDevelpmentMode.js | 36 - .../v3/unit/middlewares/errorHandler.test.js | 173 -- test/api/v3/unit/middlewares/language.test.js | 307 ---- .../unit/middlewares/maintenanceMode.test.js | 58 - test/api/v3/unit/middlewares/response.js | 66 - test/api/v3/unit/models/challenge.test.js | 161 -- test/api/v3/unit/models/group.test.js | 300 ---- test/api/v3/unit/models/task.test.js | 74 - test/api/v3/unit/models/user.test.js | 33 - test/common/algos.mocha.js | 1491 +++++++++++++++++ test/common/dailies.js | 499 ++++++ test/common/fns/autoAllocate.test.js | 86 - test/common/fns/crit.test.js | 17 - test/common/fns/handleTwoHanded.js | 38 - test/common/fns/predictableRandom.test.js | 51 - test/common/fns/randomDrop.test.js | 165 -- test/common/fns/randomVal.js | 119 -- test/common/fns/statsComputed.test.js | 28 - test/common/fns/ultimateGear.js | 33 - test/common/fns/updateStats.test.js | 170 -- test/common/libs/appliedTags.test.js | 10 - test/common/libs/gold.test.js | 11 - test/common/libs/noTags.test.js | 13 - test/common/libs/percent.test.js | 19 - test/common/libs/pickDeep.js | 34 - test/common/libs/refPush.js | 53 - test/common/libs/silver.test.js | 19 - test/common/libs/splitWhitespace.test.js | 7 - test/common/libs/taskClasses.test.js | 82 - test/common/libs/taskDefaults.test.js | 61 - test/common/libs/updateStore.js | 57 - test/common/ops/addPushDevice.js | 59 - test/common/ops/addTask.js | 139 -- test/common/ops/addWebhook.test.js | 57 - test/common/ops/allocate.js | 63 - test/common/ops/allocateNow.js | 30 - test/common/ops/blockUser.test.js | 44 - test/common/ops/buy.js | 61 - test/common/ops/buyArmoire.js | 205 --- test/common/ops/buyGear.js | 142 -- test/common/ops/buyHealthPotion.js | 65 - test/common/ops/buyMysterySet.js | 76 - test/common/ops/buyQuest.js | 81 - test/common/ops/buySpecialSpell.js | 79 - test/common/ops/changeClass.js | 139 -- test/common/ops/clearCompleted.js | 37 - test/common/ops/clearPMs.test.js | 20 - test/common/ops/deletePM.test.js | 20 - test/common/ops/deleteWebhook.test.js | 21 - test/common/ops/disableClasses.js | 35 - test/common/ops/equip.js | 87 - test/common/ops/feed.js | 216 --- test/common/ops/hatch.js | 147 -- test/common/ops/hourglassPurchase.js | 145 -- test/common/ops/openMysteryItem.js | 38 - test/common/ops/purchase.js | 194 --- test/common/ops/readCard.js | 48 - test/common/ops/rebirth.js | 236 --- test/common/ops/releaseBoth.js | 98 -- test/common/ops/releaseMounts.js | 57 - test/common/ops/releasePets.js | 57 - test/common/ops/reroll.js | 63 - test/common/ops/reset.js | 79 - test/common/ops/revive.js | 91 - test/common/ops/scoreTask.test.js | 203 --- test/common/ops/sell.js | 79 - test/common/ops/sleep.js | 18 - test/common/ops/unlock.js | 121 -- test/common/ops/updateTask.js | 53 - test/common/ops/updateWebhook.test.js | 42 - test/common/preenTodos.test.js | 76 + test/common/shared.spells.test.js | 103 ++ test/common/simulations/autoAllocate.js | 161 ++ .../simulations/passive_active_attrs.js | 291 ++++ test/common/user.fns.buy.test.js | 288 ++++ test/common/user.fns.ultimateGear.test.js | 37 + test/common/user.fns.updateStats.test.js | 134 ++ test/common/user.ops.buyMysterySet.test.js | 77 + test/common/user.ops.equip.test.js | 102 ++ test/common/user.ops.hatch.js | 129 ++ .../common/user.ops.hourglassPurchase.test.js | 122 ++ test/common/user.ops.test.js | 34 + test/helpers/api-integration/api-classes.js | 25 +- test/helpers/api-integration/mongo.js | 92 + test/helpers/api-integration/requester.js | 79 +- test/helpers/api-integration/translate.js | 3 +- test/helpers/api-integration/v2/index.js | 2 +- .../api-integration/v2/object-generators.js | 43 +- test/helpers/api-integration/v3/index.js | 11 - .../api-integration/v3/object-generators.js | 157 -- test/helpers/api-unit.helper.js | 104 -- test/helpers/api-v3-integration.helper.js | 1 - test/helpers/common.helper.js | 4 +- test/helpers/content.helper.js | 3 +- test/helpers/globals.helper.js | 31 +- test/helpers/mongo.js | 110 -- test/helpers/sleep.js | 7 - ...50605_ultimate_achievement_backfill.coffee | 2 +- test/mocha.opts | 1 + test/server_side/analytics.test.js | 6 +- test/server_side/controllers/groups.test.js | 14 +- test/server_side/controllers/user.test.js | 8 +- test/server_side/webhooks.test.js | 2 +- test/spec/chatServicesSpec.js | 90 + test/spec/controllers/authCtrlSpec.js | 4 +- test/spec/controllers/challengesCtrlSpec.js | 214 +-- .../copyMessageModalControllerSpec.js | 12 +- test/spec/controllers/filtersCtrlSpec.js | 15 +- test/spec/controllers/footerCtrlSpec.js | 19 +- test/spec/controllers/groupCtrlSpec.js | 65 +- test/spec/controllers/headerCtrlSpec.js | 7 +- test/spec/controllers/inventoryCtrlSpec.js | 24 +- .../spec/controllers/inviteToGroupCtrlSpec.js | 165 +- test/spec/controllers/menuCtrlSpec.js | 2 +- test/spec/controllers/partyCtrlSpec.js | 229 +-- test/spec/controllers/settingsCtrlSpec.js | 40 +- test/spec/controllers/tasksCtrlSpec.js | 6 +- test/spec/services/challengeServicesSpec.js | 88 - test/spec/services/chatServicesSpec.js | 73 - test/spec/services/groupServicesSpec.js | 143 +- test/spec/services/memberServicesSpec.js | 78 +- test/spec/services/questServicesSpec.js | 57 +- test/spec/services/statServicesSpec.js | 12 +- test/spec/services/tagServicesSpec.js | 52 - test/spec/services/taskServicesSpec.js | 163 +- test/spec/services/userServicesSpec.js | 4 +- test/spec/specHelper.js | 3 - website/client/js/controllers/guildsCtrl.js | 127 -- website/client/js/controllers/partyCtrl.js | 213 --- website/client/js/controllers/tavernCtrl.js | 18 - .../client/js/services/challengeServices.js | 99 -- website/client/js/services/chatServices.js | 87 - website/client/js/services/groupServices.js | 234 --- website/client/js/services/memberServices.js | 128 -- website/client/js/services/tagsServices.js | 60 - website/client/js/services/taskServices.js | 205 --- website/client/js/services/userServices.js | 583 ------- website/{client => public}/500.html | 0 .../apple-touch-icon-114-precomposed.png | Bin .../apple-touch-icon-144-precomposed.png | Bin .../apple-touch-icon-57-precomposed.png | Bin .../apple-touch-icon-72-precomposed.png | Bin .../apple-touch-icon-precomposed.png | Bin website/{client => public}/cake.png | Bin .../backCorner.png | Bin .../beingHabitican.png | Bin .../consequences.png | Bin .../contributing.png | Bin .../community-guidelines-images/github.gif | Bin .../infractions.png | Bin .../community-guidelines-images/intro.png | Bin .../moderators.png | Bin .../publicGuilds.png | Bin .../publicSpaces.png | Bin .../restoration.png | Bin .../community-guidelines-images/staff.png | Bin .../community-guidelines-images/tavern.png | Bin .../community-guidelines-images/trello.png | Bin .../community-guidelines-images/wiki.png | Bin website/{client => public}/css/README.md | 0 website/{client => public}/css/alerts.styl | 0 website/{client => public}/css/avatar.styl | 0 .../{client => public}/css/challenges.styl | 0 website/{client => public}/css/classes.styl | 0 .../{client => public}/css/customizer.styl | 0 website/{client => public}/css/filters.styl | 0 website/{client => public}/css/footer.styl | 0 website/{client => public}/css/game-pane.styl | 0 .../{client => public}/css/global-colors.styl | 0 .../css/global-modules.styl | 0 website/{client => public}/css/header.styl | 0 website/{client => public}/css/helpers.styl | 0 website/{client => public}/css/index.styl | 0 website/{client => public}/css/inventory.styl | 0 website/{client => public}/css/items.styl | 0 website/{client => public}/css/menu.styl | 0 website/{client => public}/css/no-script.styl | 0 website/{client => public}/css/npcs.styl | 0 website/{client => public}/css/options.styl | 0 website/{client => public}/css/quests.styl | 0 .../{client => public}/css/scrollbars.styl | 0 website/{client => public}/css/shared.styl | 0 website/{client => public}/css/static.styl | 0 website/{client => public}/css/tasks.styl | 0 .../css/variables/screen-size.styl | 0 .../emails/images/10-days-recapture-v1.png | Bin .../images/3-days-1-month-recapture-v1.png | Bin .../images/PROMO-Enchanted-Armoire-v1.png | Bin .../emails/images/android-promo-v1.png | Bin .../emails/images/iphone-promo-v1.png | Bin .../emails/images/one-day-v1.png | Bin .../emails/images/spring-2015-00-v1.png | Bin .../emails/images/spring-2015-01-v1.png | Bin .../subscription-begins-time-travelers-v1.png | Bin .../emails/images/subscription-begins-v1.png | Bin website/{client => public}/favicon.ico | Bin .../{client => public}/favicon_192x192.png | Bin .../{client => public}/fontello/LICENSE.txt | 0 .../{client => public}/fontello/README.txt | 0 .../fontello/css/animation.css | 0 .../fontello/css/fontelico-codes.css | 0 .../fontello/css/fontelico-embedded.css | 0 .../fontello/css/fontelico-ie7-codes.css | 0 .../fontello/css/fontelico-ie7.css | 0 .../fontello/css/fontelico.css | 0 website/{client => public}/fontello/demo.html | 0 .../fontello/font/fontelico.eot | Bin .../fontello/font/fontelico.svg | 0 .../fontello/font/fontelico.ttf | Bin .../fontello/font/fontelico.woff | Bin website/{client => public}/front/README.md | 0 .../front/css/blockScroll.css | 0 .../front/css/bootstrap.min.css | 0 .../front/css/fixed-positioning.css | 0 .../fonts/glyphicons-halflings-regular.eot | Bin .../fonts/glyphicons-halflings-regular.svg | 0 .../fonts/glyphicons-halflings-regular.ttf | Bin .../fonts/glyphicons-halflings-regular.woff | Bin .../fonts/glyphicons-halflings-regular.woff2 | Bin .../front/images/Feeding_Time.png | Bin .../front/images/Guilds Sample Screen.png | Bin .../front/images/HabitRPGPromoPostCard6.png | Bin .../front/images/HabitRPGPromoThin.png | Bin .../Habitica_banner_by_uncommoncriminal.png | Bin .../Habitica_map_by_uncommoncriminal.png | Bin .../front/images/Healer.png | Bin .../{client => public}/front/images/Mount.png | Bin .../front/images/Mount_Body_Dragon-Golden.png | Bin .../front/images/Mount_Body_Dragon-Red.png | Bin .../front/images/Mount_Body_Wolf-Base.png | Bin .../front/images/Mount_Head_Dragon-Golden.png | Bin .../front/images/Mount_Head_Dragon-Red.png | Bin .../front/images/Mount_Head_Wolf-Base.png | Bin .../front/images/Party-Header.png | Bin .../front/images/Pet-Dragon-Red.png | Bin .../front/images/Pet-Fox-Red.png | Bin .../front/images/Promo_springclasses2015.png | Bin .../front/images/Quest_dilatory_drag'on.png | Bin .../images/Quest_dilatory_drag'onSmall.png | Bin .../{client => public}/front/images/Rogue.png | Bin .../front/images/SAMPLEadventurers.png | Bin .../front/images/TVreward.png | Bin .../front/images/VICE_by_Baconsaur.png | Bin .../front/images/Warrior.png | Bin .../front/images/Wizard.png | Bin .../front/images/achievement-perfect.png | Bin .../front/images/achievement-triadbingo.png | Bin .../front/images/avatar/Warrior.png | Bin .../front/images/avatar/avatar.png | Bin .../front/images/avatar/avatarstatic.png | Bin .../images/avatar/hair_bangs_1_brown.png | Bin .../front/images/avatar/head_0.png | Bin .../front/images/avatar/head_warrior_3.png | Bin .../front/images/avatar/head_warrior_5.png | Bin .../front/images/avatar/shield_warrior_3.png | Bin .../front/images/avatar/shield_warrior_5.png | Bin .../front/images/avatar/skin_f5a76e.png | Bin .../images/avatar/slim_armor_warrior_3.png | Bin .../images/avatar/slim_armor_warrior_5.png | Bin .../front/images/avatar/slim_shirt_black.png | Bin .../front/images/avatar/weapon_healer_6.png | Bin .../front/images/avatar/weapon_warrior_3.png | Bin .../front/images/avatar/weapon_warrior_5.png | Bin .../blackish_fox_by_kellllly-d7pzd46.png | Bin .../front/images/coding_by_phoneix_faerie.png | Bin .../front/images/devices.png | Bin .../front/images/explosion.jpg | Bin .../front/images/explosion.png | Bin .../front/images/habitrpg_pixel.png | Bin .../front/images/icon175x175.png | Bin .../{client => public}/front/images/intro.jpg | Bin .../{client => public}/front/images/intro.psd | Bin .../front/images/misc/Pet_Food_Cake_Base.png | Bin .../misc/inventory_quest_scroll_harpy.png | Bin .../front/images/misc/rebirth_orb.png | Bin .../front/images/misc/shop_gold.png | Bin .../front/images/misc/shop_potion.png | Bin ...or_habit_by_cosmic_caterpillar-d8mf5mb.png | Bin .../front/images/party/AnnaCosplay.png | Bin .../front/images/party/Ariel_cosplay.png | Bin .../images/party/Big_Daddy_(BioShock).png | Bin .../party/Cosplay_Daenerys_Targaryen.png | Bin .../front/images/party/GrimReaper.png | Bin .../front/images/party/HomeStuckLusus.png | Bin .../front/images/presslogos/Cnetlogo.png | Bin .../images/presslogos/Fast-Company-logo.png | Bin .../front/images/presslogos/Forbes_logo.png | Bin .../front/images/presslogos/GitHub_Logo.png | Bin .../front/images/presslogos/discover_logo.png | Bin .../images/presslogos/ionic-logo-blog.png | Bin .../ionic-logo-horizontal-transparent.png | Bin .../images/presslogos/kickstarter-logo.png | Bin .../landing_slack_hash_wordmark_logo.png | Bin .../front/images/presslogos/lifehacker.png | Bin .../front/images/presslogos/logo_webstorm.png | Bin .../front/images/presslogos/makeuseof.png | Bin .../front/images/presslogos/nyt-logo.png | Bin .../front/images/presslogos/slack.png | Bin .../images/presslogos/trello-logo-blue.png | Bin .../front/images/quest_vice3.png | Bin .../front/images/screenshot.png | Bin .../t_bone_fight_2_by_mortquitue-d8dtxbl.png | Bin .../front/images/testimonial_by_Streak.png | Bin .../front/images/testimonials/16bitFil.png | Bin .../front/images/testimonials/AlexandraSo.png | Bin .../front/images/testimonials/Althaire.png | Bin .../front/images/testimonials/AndeeLiao.png | Bin .../front/images/testimonials/Brenna.png | Bin .../images/testimonials/Drag0nsilver.png | Bin .../front/images/testimonials/Drei-M.png | Bin .../front/images/testimonials/Elmi.png | Bin .../front/images/testimonials/EvaGantz.png | Bin .../front/images/testimonials/Helcura.png | Bin .../front/images/testimonials/InfH.png | Bin .../front/images/testimonials/Kai.png | Bin .../front/images/testimonials/Kazui.png | Bin .../front/images/testimonials/Zelah_Meyer.png | Bin .../images/testimonials/autumnesquirrel.png | Bin .../images/testimonials/frabjabulous.png | Bin .../front/images/testimonials/galarix.png | Bin .../front/images/testimonials/gwyn.blath.png | Bin .../images/testimonials/irishfeet123.png | Bin .../front/images/testimonials/skysailor.png | Bin .../images/testimonials/supermouse35.png | Bin .../images/testimonials/tonitonirocca.png | Bin .../front/images/uses/achievement-bkgd.png | Bin .../uses/clipart-rosemonkeyct-meditation.png | Bin .../uses/clipart-rosemonkeyct-meditation.psd | Bin .../uses/clipart-rosemonkeyct-reading.png | Bin .../front/images/uses/coding.png | Bin .../coding_3_by_phoneix_faerie-d7idtti.png | Bin .../front/images/uses/consequences.png | Bin .../front/images/uses/dusting-bkgd.png | Bin .../front/images/uses/dusting_by_leephon.png | Bin ...ievement_by_cosmic_caterpillar-d7uyv5z.png | Bin .../front/images/uses/meditation-bkgd.png | Bin .../front/images/uses/publicSpaces.png | Bin .../front/images/uses/reading.png | Bin .../front/js/blockScroll.js | 0 .../front/js/bootstrap.min.js | 0 .../front/js/skrollr.min.js | 0 .../front/landingv1Wireframe.jpg | Bin .../{client => public}/front/staticstyle.css | 0 website/{client => public}/front/style.css | 0 .../google280633b772b94345.html | 0 .../google8ca65b6ff3506fb8.html | 0 .../googlef3b1402b0e28338a.html | 0 website/{client => public}/js/.eslintrc | 0 website/{client => public}/js/app.js | 93 +- .../js/controllers/authCtrl.js | 45 +- .../js/controllers/autoCompleteCtrl.js | 0 .../js/controllers/challengesCtrl.js | 228 +-- .../js/controllers/chatCtrl.js | 96 +- .../js/controllers/copyMessageModalCtrl.js | 2 +- .../js/controllers/filtersCtrl.js | 11 +- .../js/controllers/footerCtrl.js | 78 +- .../js/controllers/groupsCtrl.js | 63 +- website/public/js/controllers/guildsCtrl.js | 94 ++ .../js/controllers/hallCtrl.js | 20 +- .../js/controllers/headerCtrl.js | 20 +- .../js/controllers/inventoryCtrl.js | 26 +- .../js/controllers/inviteToGroupCtrl.js | 33 +- .../js/controllers/memberModalCtrl.js | 52 +- .../js/controllers/menuCtrl.js | 2 +- .../js/controllers/notificationCtrl.js | 10 +- website/public/js/controllers/partyCtrl.js | 173 ++ .../js/controllers/rootCtrl.js | 51 +- .../js/controllers/settingsCtrl.js | 47 +- .../js/controllers/sortableInventoryCtrl.js | 0 .../js/controllers/tasksCtrl.js | 128 +- website/public/js/controllers/tavernCtrl.js | 10 + .../js/controllers/userCtrl.js | 10 +- .../js/directives/close-menu.directive.js | 0 .../js/directives/expand-menu.directive.js | 0 .../js/directives/focus-element.directive.js | 0 .../js/directives/from-now.directive.js | 0 .../js/directives/habitrpg-tasks.directive.js | 0 .../hrpg-sort-checklist.directive.js | 0 .../js/directives/hrpg-sort-tags.directive.js | 4 +- .../directives/hrpg-sort-tasks.directive.js | 4 +- .../popover-html-popup.directive.js | 0 .../js/directives/popover-html.directive.js | 0 .../js/directives/when-scrolled.directive.js | 0 website/{client => public}/js/env.js | 0 .../{client => public}/js/filters/money.js | 0 .../js/filters/roundLargeNumbers.js | 0 .../js/filters/taskOrdering.js | 0 .../js/filters/timezoneOffsetToUtc.js | 0 .../js/services/analyticsServices.js | 0 .../public/js/services/challengeServices.js | 26 + website/public/js/services/chatServices.js | 33 + website/public/js/services/groupServices.js | 92 + .../js/services/guideServices.js | 6 +- website/public/js/services/memberServices.js | 60 + .../js/services/notificationServices.js | 8 +- .../js/services/paymentServices.js | 18 +- .../js/services/questServices.js | 48 +- .../js/services/sharedServices.js | 0 .../js/services/socialServices.js | 0 .../js/services/statServices.js | 4 +- website/public/js/services/taskServices.js | 53 + website/{client => public}/js/static.js | 21 +- website/{client => public}/logo.png | Bin .../logo/HABITRPG logo version 1.psd | Bin .../logo/HABITRPG-logo-version-1.gif | Bin website/{client => public}/logo/habitrpg.jpg | Bin .../{client => public}/logo/habitrpg_bl.eps | 0 .../logo/habitrpg_pixel.png | Bin website/{client => public}/manifest.json | 14 +- .../marketing/android_iphone.png | Bin .../{client => public}/marketing/animals.png | Bin .../marketing/challenge.png | Bin .../{client => public}/marketing/devices.png | Bin .../{client => public}/marketing/drops.png | Bin .../marketing/education.png | Bin website/{client => public}/marketing/gear.png | Bin .../{client => public}/marketing/guild.png | Bin .../marketing/guild_small.png | Bin .../marketing/integration.png | Bin .../{client => public}/marketing/lefnire.png | Bin .../marketing/promos/201403_Forest_Walker.png | Bin .../marketing/promos/April14SAMPLE2.png | Bin .../marketing/screenshot.png | Bin .../marketing/social_competitve.png | Bin .../{client => public}/marketing/wellness.png | Bin .../merch/stickermule-logo.png | Bin .../merch/stickermule-logo.svg | 0 .../{client => public}/merch/stickermule.png | Bin .../merch/teespring-eu-logo.png | Bin .../{client => public}/merch/teespring-eu.png | Bin .../merch/teespring-logo.png | Bin .../merch/teespring-logo.svg | 0 .../{client => public}/merch/teespring.png | Bin website/{client => public}/page-loader.gif | Bin .../presskit/Boss - Basi-List.png | Bin .../Boss - Battling the Ghost Stag.png | Bin .../presskit/Boss - Laundromancer.png | Bin .../presskit/Boss - Necro-Vice.png | Bin .../presskit/Boss - SnackLess Monster.png | Bin .../presskit/Boss - Stagnant Dishes.png | Bin .../presskit/Habitica Gryphon.png | Bin .../presskit/Habitica Logo - Android.png | Bin .../Habitica Logo - Icon with Text.png | Bin .../presskit/Habitica Logo - Icon.png | Bin .../presskit/Habitica Logo - Text.png | Bin .../presskit/Habitica Logo - iOS.png | Bin .../presskit/Habitica Promo - Thin.png | Bin .../presskit/Habitica Promo.png | Bin .../presskit/Sample Screen - Boss (iOS).png | Bin .../presskit/Sample Screen - Challenges.png | Bin .../presskit/Sample Screen - Equipment.png | Bin .../presskit/Sample Screen - Guilds.png | Bin .../Sample Screen - Level Up (iOS).png | Bin .../presskit/Sample Screen - Market.png | Bin .../presskit/Sample Screen - Party (iOS).png | Bin .../presskit/Sample Screen - Pets (iOS).png | Bin .../Sample Screen - Tasks Page (iOS).png | Bin .../presskit/Sample Screen - Tasks Page.png | Bin ...World Boss - Dread Drag'on of Dilatory.png | Bin .../{client => public}/presskit/presskit.zip | Bin website/{client => public}/refresh.png | Bin .../server/controllers/api-v2/challenges.js | 428 ----- website/server/controllers/api-v2/user.js | 1053 ------------ website/server/controllers/api-v3/auth.js | 512 ------ .../server/controllers/api-v3/challenges.js | 518 ------ website/server/controllers/api-v3/chat.js | 398 ----- website/server/controllers/api-v3/content.js | 110 -- website/server/controllers/api-v3/coupon.js | 126 -- website/server/controllers/api-v3/debug.js | 190 --- website/server/controllers/api-v3/groups.js | 670 -------- website/server/controllers/api-v3/hall.js | 183 -- website/server/controllers/api-v3/iap.js | 4 - website/server/controllers/api-v3/members.js | 364 ---- .../server/controllers/api-v3/modelsPaths.js | 40 - website/server/controllers/api-v3/quests.js | 451 ----- website/server/controllers/api-v3/status.js | 21 - website/server/controllers/api-v3/tags.js | 190 --- website/server/controllers/api-v3/tasks.js | 971 ----------- website/server/controllers/api-v3/user.js | 1358 --------------- website/server/controllers/top-level/auth.js | 16 - .../controllers/top-level/dataexport.js | 247 --- website/server/controllers/top-level/email.js | 54 - website/server/controllers/top-level/pages.js | 79 - .../controllers/top-level/payments/amazon.js | 256 --- .../controllers/top-level/payments/iap.js | 191 --- .../controllers/top-level/payments/paypal.js | 278 --- .../controllers/top-level/payments/stripe.js | 169 -- website/server/index.js | 46 - website/server/libs/api-v3/amazonPayments.js | 62 - .../server/libs/api-v3/analyticsService.js | 237 --- website/server/libs/api-v3/baseModel.js | 79 - website/server/libs/api-v3/buildManifest.js | 62 - .../libs/api-v3/collectionManipulators.js | 22 - website/server/libs/api-v3/cron.js | 280 ---- website/server/libs/api-v3/csvStringify.js | 11 - website/server/libs/api-v3/email.js | 156 -- website/server/libs/api-v3/encryption.js | 24 - website/server/libs/api-v3/errors.js | 62 - website/server/libs/api-v3/firebase.js | 69 - website/server/libs/api-v3/i18n.js | 105 -- website/server/libs/api-v3/logger.js | 60 - website/server/libs/api-v3/password.js | 18 - website/server/libs/api-v3/payments.js | 185 -- website/server/libs/api-v3/preening.js | 82 - .../server/libs/api-v3/pushNotifications.js | 53 - website/server/libs/api-v3/routes.js | 61 - website/server/libs/api-v3/setupMongoose.js | 28 - website/server/libs/api-v3/setupNconf.js | 17 - website/server/libs/api-v3/setupPassport.js | 24 - website/server/libs/api-v3/webhook.js | 31 - .../server/middlewares/api-v3/analytics.js | 23 - website/server/middlewares/api-v3/auth.js | 87 - website/server/middlewares/api-v3/cors.js | 9 - website/server/middlewares/api-v3/cron.js | 160 -- website/server/middlewares/api-v3/domain.js | 13 - .../middlewares/api-v3/ensureAccessRight.js | 23 - .../api-v3/ensureDevelpmentMode.js | 12 - .../server/middlewares/api-v3/errorHandler.js | 86 - website/server/middlewares/api-v3/index.js | 87 - website/server/middlewares/api-v3/language.js | 91 - website/server/middlewares/api-v3/locals.js | 61 - .../middlewares/api-v3/maintenanceMode.js | 31 - website/server/middlewares/api-v3/notFound.js | 7 - .../server/middlewares/api-v3/redirects.js | 43 - website/server/middlewares/api-v3/response.js | 25 - .../server/middlewares/api-v3/setupBody.js | 5 - website/server/middlewares/api-v3/static.js | 18 - website/server/middlewares/api-v3/v1.js | 19 - website/server/middlewares/api-v3/v2.js | 27 - website/server/middlewares/api-v3/v3.js | 30 - website/server/models/challenge.js | 426 ----- website/server/models/coupon.js | 57 - website/server/models/emailUnsubscription.js | 24 - website/server/models/group.js | 760 --------- website/server/models/tag.js | 27 - website/server/models/task.js | 222 --- website/server/models/user.js | 824 --------- website/server/routes/api-v2/auth.js | 21 - website/server/routes/api-v2/coupon.js | 15 - .../server/routes/api-v2/unsubscription.js | 11 - website/server/routes/payments.js | 34 - website/server/server.js | 35 - .../controllers/api-v2/auth.js | 42 +- website/src/controllers/api-v2/challenges.js | 453 +++++ .../controllers/api-v2/coupon.js | 6 +- .../controllers/api-v2/groups.js | 643 +++---- .../controllers/api-v2/hall.js | 8 +- .../controllers/api-v2/members.js | 19 +- .../controllers/api-v2/unsubscription.js | 12 +- website/src/controllers/api-v2/user.js | 707 ++++++++ .../api-v2 => src/controllers}/dataexport.js | 18 +- website/src/controllers/payments/amazon.js | 271 +++ website/src/controllers/payments/iap.js | 155 ++ website/src/controllers/payments/index.js | 207 +++ website/src/controllers/payments/paypal.js | 216 +++ .../payments}/paypalBillingSetup.js | 6 +- website/src/controllers/payments/stripe.js | 123 ++ .../controllers}/pushNotifications.js | 1 - .../libs/api-v2 => src/libs}/analytics.js | 2 +- .../libs/api-v2 => src/libs}/buildManifest.js | 8 +- .../libs/api-v2 => src/libs}/firebase.js | 16 +- .../{server/libs/api-v2 => src/libs}/i18n.js | 15 +- .../libs/api-v2 => src/libs}/logging.js | 2 +- .../{server/libs/api-v2 => src/libs}/utils.js | 24 +- .../libs/api-v2 => src/libs}/webhook.js | 0 .../middlewares/apiThrottle.js | 4 +- website/src/middlewares/cors.js | 7 + .../api-v2 => src/middlewares}/domain.js | 0 .../middlewares}/errorHandler.js | 2 +- .../middlewares/forceRefresh.js | 2 - .../api-v2 => src/middlewares}/locals.js | 6 +- website/src/middlewares/redirects.js | 41 + website/src/models/challenge.js | 120 ++ website/src/models/coupon.js | 59 + website/src/models/emailUnsubscription.js | 14 + website/src/models/group.js | 501 ++++++ website/src/models/task.js | 113 ++ website/src/models/user.js | 697 ++++++++ website/src/routes/api-v1.js | 173 ++ website/src/routes/api-v2/auth.js | 21 + website/src/routes/api-v2/coupon.js | 12 + .../{server => src}/routes/api-v2/swagger.js | 83 +- website/src/routes/api-v2/unsubscription.js | 8 + website/src/routes/dataexport.js | 16 + website/{server => src}/routes/pages.js | 4 +- website/src/routes/payments.js | 31 + website/src/server.js | 177 ++ website/views/avatar-static.jade | 2 +- website/views/main/filters.jade | 2 +- website/views/options/inventory/drops.jade | 6 +- .../options/inventory/time-travelers.jade | 2 +- website/views/options/profile.jade | 4 +- website/views/options/settings.jade | 21 +- .../views/options/social/challenge-box.jade | 2 +- website/views/options/social/challenges.jade | 16 +- website/views/options/social/chat-box.jade | 2 +- .../views/options/social/chat-message.jade | 2 +- website/views/options/social/group.jade | 6 +- website/views/options/social/hall.jade | 2 +- website/views/options/social/index.jade | 4 +- .../party/leave-party-and-join-another.jade | 2 +- .../social/party/party-invitation.jade | 3 +- .../options/social/quests/questActive.jade | 2 +- .../options/social/quests/questNotActive.jade | 4 +- website/views/options/social/tavern.jade | 2 +- website/views/shared/avatar/appearance.jade | 4 +- website/views/shared/footer.jade | 17 +- website/views/shared/header/header.jade | 6 +- website/views/shared/header/menu.jade | 2 +- website/views/shared/modals/buy-gems.jade | 4 +- website/views/shared/modals/classes.jade | 2 +- website/views/shared/modals/death.jade | 2 +- website/views/shared/modals/index.jade | 1 - website/views/shared/modals/limited.jade | 2 +- website/views/shared/modals/members.jade | 8 +- .../views/shared/modals/modify-inventory.jade | 252 --- website/views/shared/modals/quests.jade | 5 +- website/views/shared/modals/settings.jade | 6 +- website/views/shared/new-stuff.jade | 64 +- .../views/shared/profiles/achievements.jade | 6 +- .../shared/profiles/stats/attributes.jade | 2 +- .../shared/tasks/edit/habits/plus_minus.jade | 8 +- website/views/shared/tasks/edit/index.jade | 4 +- website/views/shared/tasks/edit/tags.jade | 2 +- website/views/shared/tasks/index.jade | 2 +- website/views/shared/tasks/meta_controls.jade | 10 +- website/views/shared/tasks/task.jade | 4 +- .../views/shared/tasks/task_view/add_new.jade | 2 +- .../views/shared/tasks/task_view/graph.jade | 2 +- .../views/shared/tasks/task_view/index.jade | 8 +- .../views/shared/tasks/task_view/mixins.jade | 4 +- .../views/shared/tasks/task_view/skills.jade | 2 +- .../views/static/{api-v2.jade => api.jade} | 6 +- website/views/static/front.jade | 1 - website/views/static/maintenance-info.jade | 2 +- website/views/static/maintenance.jade | 18 - 993 files changed, 12914 insertions(+), 44919 deletions(-) rename common/img/sprites/spritesmith/achievements/{achievement-spookySparkles.png => achievement-spookDust.png} (100%) rename common/img/sprites/spritesmith/achievements/{achievement-spookySparkles2x.png => achievement-spookDust2x.png} (100%) rename common/img/sprites/spritesmith/misc/{inventory_special_spookySparkles.png => inventory_special_spookDust.png} (100%) rename common/img/sprites/spritesmith/misc/{ghost.png => spookman.png} (100%) rename common/img/sprites/spritesmith/shop/{shop_spookySparkles.png => shop_spookDust.png} (100%) rename common/img/sprites/spritesmith/skills/{shop_healAll.png => shop_heallAll.png} (100%) delete mode 100644 common/img/sprites/spritesmith/stable/pets/Pet-Lion-Veteran.png create mode 100644 common/script/fns/cron.js create mode 100644 common/script/fns/getItem.js create mode 100644 common/script/fns/preenUserHistory.js delete mode 100644 common/script/fns/resetGear.js create mode 100644 common/script/libs/countExists.js create mode 100644 common/script/libs/encodeiCalLink.js delete mode 100644 common/script/libs/errors.js delete mode 100644 common/script/libs/extendableBuiltin.js create mode 100644 common/script/libs/friendlyTimestamp.js create mode 100644 common/script/libs/index.js create mode 100644 common/script/libs/newChatMessages.js delete mode 100644 common/script/libs/pickDeep.js create mode 100644 common/script/libs/preenHistory.js create mode 100644 common/script/libs/removeWhitespace.js delete mode 100644 common/script/libs/statsComputed.js delete mode 100644 common/script/ops/buyArmoire.js delete mode 100644 common/script/ops/buyGear.js delete mode 100644 common/script/ops/buyHealthPotion.js delete mode 100644 common/script/ops/markPMSRead.js create mode 100644 common/script/ops/score.js delete mode 100644 common/script/ops/scoreTask.js create mode 100644 common/script/public/userServices.js delete mode 100644 migrations/20160521_veteran_ladder.js delete mode 100644 migrations/api_v3/challenges.js delete mode 100644 migrations/api_v3/challengesMembers.js delete mode 100644 migrations/api_v3/coupons.js delete mode 100644 migrations/api_v3/emailUnsubscriptions.js delete mode 100644 migrations/api_v3/groups.js delete mode 100644 migrations/api_v3/indexes.js delete mode 100644 migrations/api_v3/users.js create mode 100644 newrelic.js delete mode 100644 tasks/gulp-apidoc.js create mode 100644 test/README.md delete mode 100644 test/api/v2/user/tasks/POST-clear-completed.test.js delete mode 100644 test/api/v3/README.md delete mode 100644 test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js delete mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId.test.js delete mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js delete mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js delete mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js delete mode 100644 test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js delete mode 100644 test/api/v3/integration/challenges/GET-challenges_user.test.js delete mode 100644 test/api/v3/integration/challenges/POST-challenges.test.js delete mode 100644 test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js delete mode 100644 test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js delete mode 100644 test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js delete mode 100644 test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js delete mode 100644 test/api/v3/integration/chat/DELETE-chat_id.test.js delete mode 100644 test/api/v3/integration/chat/GET-chat.test.js delete mode 100644 test/api/v3/integration/chat/POST-chat.flag.test.js delete mode 100644 test/api/v3/integration/chat/POST-chat.like.test.js delete mode 100644 test/api/v3/integration/chat/POST-chat.test.js delete mode 100644 test/api/v3/integration/chat/POST-chat_seen.test.js delete mode 100644 test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js delete mode 100644 test/api/v3/integration/content/GET-content.test.js delete mode 100644 test/api/v3/integration/coupons/GET-coupons.test.js delete mode 100644 test/api/v3/integration/coupons/POST-coupons_enter_code.test.js delete mode 100644 test/api/v3/integration/coupons/POST-coupons_generate_event.test.js delete mode 100644 test/api/v3/integration/coupons/POST-coupons_validate_code.test.js delete mode 100644 test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js delete mode 100644 test/api/v3/integration/dataexport/GET-export_avatar-memberId.png.test.js delete mode 100644 test/api/v3/integration/dataexport/GET-export_history.csv.test.js delete mode 100644 test/api/v3/integration/dataexport/GET-export_userdata.json.test.js delete mode 100644 test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js delete mode 100644 test/api/v3/integration/debug/POST-debug_addHourglass.test.js delete mode 100644 test/api/v3/integration/debug/POST-debug_addTenGems.test.js delete mode 100644 test/api/v3/integration/debug/POST-debug_make-admin.test.js delete mode 100644 test/api/v3/integration/debug/POST-debug_modify-inventory.test.js delete mode 100644 test/api/v3/integration/debug/POST-debug_quest-progress.test.js delete mode 100644 test/api/v3/integration/debug/POST-debug_set-cron.test.js delete mode 100644 test/api/v3/integration/emails/GET-email-unsubscribe.test.js delete mode 100644 test/api/v3/integration/groups/GET-groups.test.js delete mode 100644 test/api/v3/integration/groups/GET-groups_groupId_invites.test.js delete mode 100644 test/api/v3/integration/groups/GET-groups_groupId_members.test.js delete mode 100644 test/api/v3/integration/groups/GET-groups_id.test.js delete mode 100644 test/api/v3/integration/groups/POST-groups.test.js delete mode 100644 test/api/v3/integration/groups/POST-groups_groupId_join.test.js delete mode 100644 test/api/v3/integration/groups/POST-groups_groupId_leave.js delete mode 100644 test/api/v3/integration/groups/POST-groups_groupId_reject.test.js delete mode 100644 test/api/v3/integration/groups/POST-groups_id_removeMember.test.js delete mode 100644 test/api/v3/integration/groups/POST-groups_invite.test.js delete mode 100644 test/api/v3/integration/groups/PUT-groups.test.js delete mode 100644 test/api/v3/integration/hall/GET-hall_heroes.test.js delete mode 100644 test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js delete mode 100644 test/api/v3/integration/hall/GET-hall_patrons.test.js delete mode 100644 test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js delete mode 100644 test/api/v3/integration/members/GET-members_id.test.js delete mode 100644 test/api/v3/integration/members/POST-send_private_message.test.js delete mode 100644 test/api/v3/integration/members/POST-transfer_gems.test.js delete mode 100644 test/api/v3/integration/models/GET-model_paths.test.js delete mode 100644 test/api/v3/integration/notFound.test.js delete mode 100644 test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js delete mode 100644 test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js delete mode 100644 test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js delete mode 100644 test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js delete mode 100644 test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js delete mode 100644 test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js delete mode 100644 test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js delete mode 100644 test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js delete mode 100644 test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js delete mode 100644 test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js delete mode 100644 test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js delete mode 100644 test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js delete mode 100644 test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js delete mode 100644 test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js delete mode 100644 test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js delete mode 100644 test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js delete mode 100644 test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js delete mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js delete mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js delete mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js delete mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js delete mode 100644 test/api/v3/integration/status/GET-status.test.js delete mode 100644 test/api/v3/integration/tags/DELETE-tags_id.test.js delete mode 100644 test/api/v3/integration/tags/GET-tags.test.js delete mode 100644 test/api/v3/integration/tags/GET-tags_id.test.js delete mode 100644 test/api/v3/integration/tags/POST-tag-reorder.test.js delete mode 100644 test/api/v3/integration/tags/POST-tags.test.js delete mode 100644 test/api/v3/integration/tags/PUT-tags_id.test.js delete mode 100644 test/api/v3/integration/tasks/DELETE-tasks_id.test.js delete mode 100644 test/api/v3/integration/tasks/GET-tasks_challenge_challengeId.test.js delete mode 100644 test/api/v3/integration/tasks/GET-tasks_id.test.js delete mode 100644 test/api/v3/integration/tasks/GET-tasks_user.test.js delete mode 100644 test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js delete mode 100644 test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js delete mode 100644 test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js delete mode 100644 test/api/v3/integration/tasks/POST-tasks_user.test.js delete mode 100644 test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js delete mode 100644 test/api/v3/integration/tasks/PUT-tasks_id.test.js delete mode 100644 test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js delete mode 100644 test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js delete mode 100644 test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js delete mode 100644 test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js delete mode 100644 test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js delete mode 100644 test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js delete mode 100644 test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js delete mode 100644 test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js delete mode 100644 test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js delete mode 100644 test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js delete mode 100644 test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js delete mode 100644 test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js delete mode 100644 test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js delete mode 100644 test/api/v3/integration/user/DELETE-user.test.js delete mode 100644 test/api/v3/integration/user/DELETE-user_delete_webhook.test.js delete mode 100644 test/api/v3/integration/user/DELETE-user_messages.test.js delete mode 100644 test/api/v3/integration/user/GET-user.test.js delete mode 100644 test/api/v3/integration/user/GET-user_anonymized.test.js delete mode 100644 test/api/v3/integration/user/GET-user_inventory_buy.test.js delete mode 100644 test/api/v3/integration/user/POST-user_addPushDevice.test.js delete mode 100644 test/api/v3/integration/user/POST-user_add_webhook.test.js delete mode 100644 test/api/v3/integration/user/POST-user_allocate.test.js delete mode 100644 test/api/v3/integration/user/POST-user_allocate_now.test.js delete mode 100644 test/api/v3/integration/user/POST-user_block.test.js delete mode 100644 test/api/v3/integration/user/POST-user_buy.test.js delete mode 100644 test/api/v3/integration/user/POST-user_buy_armoire.test.js delete mode 100644 test/api/v3/integration/user/POST-user_buy_gear.test.js delete mode 100644 test/api/v3/integration/user/POST-user_buy_health_potion.test.js delete mode 100644 test/api/v3/integration/user/POST-user_buy_mystery_set.test.js delete mode 100644 test/api/v3/integration/user/POST-user_buy_quest.test.js delete mode 100644 test/api/v3/integration/user/POST-user_buy_special_spell.test.js delete mode 100644 test/api/v3/integration/user/POST-user_change-class.test.js delete mode 100644 test/api/v3/integration/user/POST-user_class_cast_spellId.test.js delete mode 100644 test/api/v3/integration/user/POST-user_custom-day-start.test.js delete mode 100644 test/api/v3/integration/user/POST-user_disable-classes.test.js delete mode 100644 test/api/v3/integration/user/POST-user_equip_type_key.test.js delete mode 100644 test/api/v3/integration/user/POST-user_feed_pet_food.test.js delete mode 100644 test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js delete mode 100644 test/api/v3/integration/user/POST-user_mark_pms_read.test.js delete mode 100644 test/api/v3/integration/user/POST-user_open_mystery_item.test.js delete mode 100644 test/api/v3/integration/user/POST-user_purchase.test.js delete mode 100644 test/api/v3/integration/user/POST-user_purchase_hourglass.test.js delete mode 100644 test/api/v3/integration/user/POST-user_read_card.test.js delete mode 100644 test/api/v3/integration/user/POST-user_rebirth.test.js delete mode 100644 test/api/v3/integration/user/POST-user_release_both.test.js delete mode 100644 test/api/v3/integration/user/POST-user_release_mounts.test.js delete mode 100644 test/api/v3/integration/user/POST-user_release_pets.test.js delete mode 100644 test/api/v3/integration/user/POST-user_reroll.test.js delete mode 100644 test/api/v3/integration/user/POST-user_reset.test.js delete mode 100644 test/api/v3/integration/user/POST-user_revive.test.js delete mode 100644 test/api/v3/integration/user/POST-user_sell.test.js delete mode 100644 test/api/v3/integration/user/POST-user_sleep.test.js delete mode 100644 test/api/v3/integration/user/POST-user_unlock.js delete mode 100644 test/api/v3/integration/user/PUT-user.test.js delete mode 100644 test/api/v3/integration/user/PUT-user_update_webhook.test.js delete mode 100644 test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js delete mode 100644 test/api/v3/integration/user/auth/GET-logout.test.js delete mode 100644 test/api/v3/integration/user/auth/POST-firebase.test.js delete mode 100644 test/api/v3/integration/user/auth/POST-login-local.test.js delete mode 100644 test/api/v3/integration/user/auth/POST-register_local.test.js delete mode 100644 test/api/v3/integration/user/auth/POST-user_reset_password.test.js delete mode 100644 test/api/v3/integration/user/auth/PUT-user_update_email.test.js delete mode 100644 test/api/v3/integration/user/auth/PUT-user_update_password.test.js delete mode 100644 test/api/v3/integration/user/auth/PUT-user_update_username.test.js delete mode 100644 test/api/v3/unit/libs/analyticsService.test.js delete mode 100644 test/api/v3/unit/libs/baseModel.test.js delete mode 100644 test/api/v3/unit/libs/buildManifest.test.js delete mode 100644 test/api/v3/unit/libs/collectionManipulators.test.js delete mode 100644 test/api/v3/unit/libs/cron.test.js delete mode 100644 test/api/v3/unit/libs/email.test.js delete mode 100644 test/api/v3/unit/libs/encryption.test.js delete mode 100644 test/api/v3/unit/libs/errors.test.js delete mode 100644 test/api/v3/unit/libs/i18n.test.js delete mode 100644 test/api/v3/unit/libs/logger.js delete mode 100644 test/api/v3/unit/libs/password.test.js delete mode 100644 test/api/v3/unit/libs/payments.test.js delete mode 100644 test/api/v3/unit/libs/preening.test.js delete mode 100644 test/api/v3/unit/libs/setupNconf.test.js delete mode 100644 test/api/v3/unit/libs/webhooks.test.js delete mode 100644 test/api/v3/unit/middlewares/analytics.test.js delete mode 100644 test/api/v3/unit/middlewares/cors.test.js delete mode 100644 test/api/v3/unit/middlewares/cronMiddleware.js delete mode 100644 test/api/v3/unit/middlewares/ensureAccessRight.test.js delete mode 100644 test/api/v3/unit/middlewares/ensureDevelpmentMode.js delete mode 100644 test/api/v3/unit/middlewares/errorHandler.test.js delete mode 100644 test/api/v3/unit/middlewares/language.test.js delete mode 100644 test/api/v3/unit/middlewares/maintenanceMode.test.js delete mode 100644 test/api/v3/unit/middlewares/response.js delete mode 100644 test/api/v3/unit/models/challenge.test.js delete mode 100644 test/api/v3/unit/models/group.test.js delete mode 100644 test/api/v3/unit/models/task.test.js delete mode 100644 test/api/v3/unit/models/user.test.js create mode 100644 test/common/algos.mocha.js create mode 100644 test/common/dailies.js delete mode 100644 test/common/fns/autoAllocate.test.js delete mode 100644 test/common/fns/crit.test.js delete mode 100644 test/common/fns/handleTwoHanded.js delete mode 100644 test/common/fns/predictableRandom.test.js delete mode 100644 test/common/fns/randomDrop.test.js delete mode 100644 test/common/fns/randomVal.js delete mode 100644 test/common/fns/statsComputed.test.js delete mode 100644 test/common/fns/ultimateGear.js delete mode 100644 test/common/fns/updateStats.test.js delete mode 100644 test/common/libs/appliedTags.test.js delete mode 100644 test/common/libs/gold.test.js delete mode 100644 test/common/libs/noTags.test.js delete mode 100644 test/common/libs/percent.test.js delete mode 100644 test/common/libs/pickDeep.js delete mode 100644 test/common/libs/refPush.js delete mode 100644 test/common/libs/silver.test.js delete mode 100644 test/common/libs/splitWhitespace.test.js delete mode 100644 test/common/libs/taskClasses.test.js delete mode 100644 test/common/libs/taskDefaults.test.js delete mode 100644 test/common/libs/updateStore.js delete mode 100644 test/common/ops/addPushDevice.js delete mode 100644 test/common/ops/addTask.js delete mode 100644 test/common/ops/addWebhook.test.js delete mode 100644 test/common/ops/allocate.js delete mode 100644 test/common/ops/allocateNow.js delete mode 100644 test/common/ops/blockUser.test.js delete mode 100644 test/common/ops/buy.js delete mode 100644 test/common/ops/buyArmoire.js delete mode 100644 test/common/ops/buyGear.js delete mode 100644 test/common/ops/buyHealthPotion.js delete mode 100644 test/common/ops/buyMysterySet.js delete mode 100644 test/common/ops/buyQuest.js delete mode 100644 test/common/ops/buySpecialSpell.js delete mode 100644 test/common/ops/changeClass.js delete mode 100644 test/common/ops/clearCompleted.js delete mode 100644 test/common/ops/clearPMs.test.js delete mode 100644 test/common/ops/deletePM.test.js delete mode 100644 test/common/ops/deleteWebhook.test.js delete mode 100644 test/common/ops/disableClasses.js delete mode 100644 test/common/ops/equip.js delete mode 100644 test/common/ops/feed.js delete mode 100644 test/common/ops/hatch.js delete mode 100644 test/common/ops/hourglassPurchase.js delete mode 100644 test/common/ops/openMysteryItem.js delete mode 100644 test/common/ops/purchase.js delete mode 100644 test/common/ops/readCard.js delete mode 100644 test/common/ops/rebirth.js delete mode 100644 test/common/ops/releaseBoth.js delete mode 100644 test/common/ops/releaseMounts.js delete mode 100644 test/common/ops/releasePets.js delete mode 100644 test/common/ops/reroll.js delete mode 100644 test/common/ops/reset.js delete mode 100644 test/common/ops/revive.js delete mode 100644 test/common/ops/scoreTask.test.js delete mode 100644 test/common/ops/sell.js delete mode 100644 test/common/ops/sleep.js delete mode 100644 test/common/ops/unlock.js delete mode 100644 test/common/ops/updateTask.js delete mode 100644 test/common/ops/updateWebhook.test.js create mode 100644 test/common/preenTodos.test.js create mode 100644 test/common/shared.spells.test.js create mode 100644 test/common/simulations/autoAllocate.js create mode 100644 test/common/simulations/passive_active_attrs.js create mode 100644 test/common/user.fns.buy.test.js create mode 100644 test/common/user.fns.ultimateGear.test.js create mode 100644 test/common/user.fns.updateStats.test.js create mode 100644 test/common/user.ops.buyMysterySet.test.js create mode 100644 test/common/user.ops.equip.test.js create mode 100644 test/common/user.ops.hatch.js create mode 100644 test/common/user.ops.hourglassPurchase.test.js create mode 100644 test/common/user.ops.test.js create mode 100644 test/helpers/api-integration/mongo.js delete mode 100644 test/helpers/api-integration/v3/index.js delete mode 100644 test/helpers/api-integration/v3/object-generators.js delete mode 100644 test/helpers/api-unit.helper.js delete mode 100644 test/helpers/api-v3-integration.helper.js delete mode 100644 test/helpers/mongo.js delete mode 100644 test/helpers/sleep.js create mode 100644 test/spec/chatServicesSpec.js delete mode 100644 test/spec/services/challengeServicesSpec.js delete mode 100644 test/spec/services/chatServicesSpec.js delete mode 100644 test/spec/services/tagServicesSpec.js delete mode 100644 website/client/js/controllers/guildsCtrl.js delete mode 100644 website/client/js/controllers/partyCtrl.js delete mode 100644 website/client/js/controllers/tavernCtrl.js delete mode 100644 website/client/js/services/challengeServices.js delete mode 100644 website/client/js/services/chatServices.js delete mode 100644 website/client/js/services/groupServices.js delete mode 100644 website/client/js/services/memberServices.js delete mode 100644 website/client/js/services/tagsServices.js delete mode 100644 website/client/js/services/taskServices.js delete mode 100644 website/client/js/services/userServices.js rename website/{client => public}/500.html (100%) rename website/{client => public}/apple-touch-icon-114-precomposed.png (100%) rename website/{client => public}/apple-touch-icon-144-precomposed.png (100%) rename website/{client => public}/apple-touch-icon-57-precomposed.png (100%) rename website/{client => public}/apple-touch-icon-72-precomposed.png (100%) rename website/{client => public}/apple-touch-icon-precomposed.png (100%) rename website/{client => public}/cake.png (100%) rename website/{client => public}/community-guidelines-images/backCorner.png (100%) rename website/{client => public}/community-guidelines-images/beingHabitican.png (100%) rename website/{client => public}/community-guidelines-images/consequences.png (100%) rename website/{client => public}/community-guidelines-images/contributing.png (100%) rename website/{client => public}/community-guidelines-images/github.gif (100%) rename website/{client => public}/community-guidelines-images/infractions.png (100%) rename website/{client => public}/community-guidelines-images/intro.png (100%) rename website/{client => public}/community-guidelines-images/moderators.png (100%) rename website/{client => public}/community-guidelines-images/publicGuilds.png (100%) rename website/{client => public}/community-guidelines-images/publicSpaces.png (100%) rename website/{client => public}/community-guidelines-images/restoration.png (100%) rename website/{client => public}/community-guidelines-images/staff.png (100%) rename website/{client => public}/community-guidelines-images/tavern.png (100%) rename website/{client => public}/community-guidelines-images/trello.png (100%) rename website/{client => public}/community-guidelines-images/wiki.png (100%) rename website/{client => public}/css/README.md (100%) rename website/{client => public}/css/alerts.styl (100%) rename website/{client => public}/css/avatar.styl (100%) rename website/{client => public}/css/challenges.styl (100%) rename website/{client => public}/css/classes.styl (100%) rename website/{client => public}/css/customizer.styl (100%) rename website/{client => public}/css/filters.styl (100%) rename website/{client => public}/css/footer.styl (100%) rename website/{client => public}/css/game-pane.styl (100%) rename website/{client => public}/css/global-colors.styl (100%) rename website/{client => public}/css/global-modules.styl (100%) rename website/{client => public}/css/header.styl (100%) rename website/{client => public}/css/helpers.styl (100%) rename website/{client => public}/css/index.styl (100%) rename website/{client => public}/css/inventory.styl (100%) rename website/{client => public}/css/items.styl (100%) rename website/{client => public}/css/menu.styl (100%) rename website/{client => public}/css/no-script.styl (100%) rename website/{client => public}/css/npcs.styl (100%) rename website/{client => public}/css/options.styl (100%) rename website/{client => public}/css/quests.styl (100%) rename website/{client => public}/css/scrollbars.styl (100%) rename website/{client => public}/css/shared.styl (100%) rename website/{client => public}/css/static.styl (100%) rename website/{client => public}/css/tasks.styl (100%) rename website/{client => public}/css/variables/screen-size.styl (100%) rename website/{client => public}/emails/images/10-days-recapture-v1.png (100%) rename website/{client => public}/emails/images/3-days-1-month-recapture-v1.png (100%) rename website/{client => public}/emails/images/PROMO-Enchanted-Armoire-v1.png (100%) rename website/{client => public}/emails/images/android-promo-v1.png (100%) rename website/{client => public}/emails/images/iphone-promo-v1.png (100%) rename website/{client => public}/emails/images/one-day-v1.png (100%) rename website/{client => public}/emails/images/spring-2015-00-v1.png (100%) rename website/{client => public}/emails/images/spring-2015-01-v1.png (100%) rename website/{client => public}/emails/images/subscription-begins-time-travelers-v1.png (100%) rename website/{client => public}/emails/images/subscription-begins-v1.png (100%) rename website/{client => public}/favicon.ico (100%) rename website/{client => public}/favicon_192x192.png (100%) rename website/{client => public}/fontello/LICENSE.txt (100%) rename website/{client => public}/fontello/README.txt (100%) rename website/{client => public}/fontello/css/animation.css (100%) rename website/{client => public}/fontello/css/fontelico-codes.css (100%) rename website/{client => public}/fontello/css/fontelico-embedded.css (100%) rename website/{client => public}/fontello/css/fontelico-ie7-codes.css (100%) rename website/{client => public}/fontello/css/fontelico-ie7.css (100%) rename website/{client => public}/fontello/css/fontelico.css (100%) rename website/{client => public}/fontello/demo.html (100%) rename website/{client => public}/fontello/font/fontelico.eot (100%) rename website/{client => public}/fontello/font/fontelico.svg (100%) rename website/{client => public}/fontello/font/fontelico.ttf (100%) rename website/{client => public}/fontello/font/fontelico.woff (100%) rename website/{client => public}/front/README.md (100%) rename website/{client => public}/front/css/blockScroll.css (100%) rename website/{client => public}/front/css/bootstrap.min.css (100%) rename website/{client => public}/front/css/fixed-positioning.css (100%) rename website/{client => public}/front/fonts/glyphicons-halflings-regular.eot (100%) rename website/{client => public}/front/fonts/glyphicons-halflings-regular.svg (100%) rename website/{client => public}/front/fonts/glyphicons-halflings-regular.ttf (100%) rename website/{client => public}/front/fonts/glyphicons-halflings-regular.woff (100%) rename website/{client => public}/front/fonts/glyphicons-halflings-regular.woff2 (100%) rename website/{client => public}/front/images/Feeding_Time.png (100%) rename website/{client => public}/front/images/Guilds Sample Screen.png (100%) rename website/{client => public}/front/images/HabitRPGPromoPostCard6.png (100%) rename website/{client => public}/front/images/HabitRPGPromoThin.png (100%) rename website/{client => public}/front/images/Habitica_banner_by_uncommoncriminal.png (100%) rename website/{client => public}/front/images/Habitica_map_by_uncommoncriminal.png (100%) rename website/{client => public}/front/images/Healer.png (100%) rename website/{client => public}/front/images/Mount.png (100%) rename website/{client => public}/front/images/Mount_Body_Dragon-Golden.png (100%) rename website/{client => public}/front/images/Mount_Body_Dragon-Red.png (100%) rename website/{client => public}/front/images/Mount_Body_Wolf-Base.png (100%) rename website/{client => public}/front/images/Mount_Head_Dragon-Golden.png (100%) rename website/{client => public}/front/images/Mount_Head_Dragon-Red.png (100%) rename website/{client => public}/front/images/Mount_Head_Wolf-Base.png (100%) rename website/{client => public}/front/images/Party-Header.png (100%) rename website/{client => public}/front/images/Pet-Dragon-Red.png (100%) rename website/{client => public}/front/images/Pet-Fox-Red.png (100%) rename website/{client => public}/front/images/Promo_springclasses2015.png (100%) rename website/{client => public}/front/images/Quest_dilatory_drag'on.png (100%) rename website/{client => public}/front/images/Quest_dilatory_drag'onSmall.png (100%) rename website/{client => public}/front/images/Rogue.png (100%) rename website/{client => public}/front/images/SAMPLEadventurers.png (100%) rename website/{client => public}/front/images/TVreward.png (100%) rename website/{client => public}/front/images/VICE_by_Baconsaur.png (100%) rename website/{client => public}/front/images/Warrior.png (100%) rename website/{client => public}/front/images/Wizard.png (100%) rename website/{client => public}/front/images/achievement-perfect.png (100%) rename website/{client => public}/front/images/achievement-triadbingo.png (100%) rename website/{client => public}/front/images/avatar/Warrior.png (100%) rename website/{client => public}/front/images/avatar/avatar.png (100%) rename website/{client => public}/front/images/avatar/avatarstatic.png (100%) rename website/{client => public}/front/images/avatar/hair_bangs_1_brown.png (100%) rename website/{client => public}/front/images/avatar/head_0.png (100%) rename website/{client => public}/front/images/avatar/head_warrior_3.png (100%) rename website/{client => public}/front/images/avatar/head_warrior_5.png (100%) rename website/{client => public}/front/images/avatar/shield_warrior_3.png (100%) rename website/{client => public}/front/images/avatar/shield_warrior_5.png (100%) rename website/{client => public}/front/images/avatar/skin_f5a76e.png (100%) rename website/{client => public}/front/images/avatar/slim_armor_warrior_3.png (100%) rename website/{client => public}/front/images/avatar/slim_armor_warrior_5.png (100%) rename website/{client => public}/front/images/avatar/slim_shirt_black.png (100%) rename website/{client => public}/front/images/avatar/weapon_healer_6.png (100%) rename website/{client => public}/front/images/avatar/weapon_warrior_3.png (100%) rename website/{client => public}/front/images/avatar/weapon_warrior_5.png (100%) rename website/{client => public}/front/images/blackish_fox_by_kellllly-d7pzd46.png (100%) rename website/{client => public}/front/images/coding_by_phoneix_faerie.png (100%) rename website/{client => public}/front/images/devices.png (100%) rename website/{client => public}/front/images/explosion.jpg (100%) rename website/{client => public}/front/images/explosion.png (100%) rename website/{client => public}/front/images/habitrpg_pixel.png (100%) rename website/{client => public}/front/images/icon175x175.png (100%) rename website/{client => public}/front/images/intro.jpg (100%) rename website/{client => public}/front/images/intro.psd (100%) rename website/{client => public}/front/images/misc/Pet_Food_Cake_Base.png (100%) rename website/{client => public}/front/images/misc/inventory_quest_scroll_harpy.png (100%) rename website/{client => public}/front/images/misc/rebirth_orb.png (100%) rename website/{client => public}/front/images/misc/shop_gold.png (100%) rename website/{client => public}/front/images/misc/shop_potion.png (100%) rename website/{client => public}/front/images/mockup_for_habit_by_cosmic_caterpillar-d8mf5mb.png (100%) rename website/{client => public}/front/images/party/AnnaCosplay.png (100%) rename website/{client => public}/front/images/party/Ariel_cosplay.png (100%) rename website/{client => public}/front/images/party/Big_Daddy_(BioShock).png (100%) rename website/{client => public}/front/images/party/Cosplay_Daenerys_Targaryen.png (100%) rename website/{client => public}/front/images/party/GrimReaper.png (100%) rename website/{client => public}/front/images/party/HomeStuckLusus.png (100%) rename website/{client => public}/front/images/presslogos/Cnetlogo.png (100%) rename website/{client => public}/front/images/presslogos/Fast-Company-logo.png (100%) rename website/{client => public}/front/images/presslogos/Forbes_logo.png (100%) rename website/{client => public}/front/images/presslogos/GitHub_Logo.png (100%) rename website/{client => public}/front/images/presslogos/discover_logo.png (100%) rename website/{client => public}/front/images/presslogos/ionic-logo-blog.png (100%) rename website/{client => public}/front/images/presslogos/ionic-logo-horizontal-transparent.png (100%) rename website/{client => public}/front/images/presslogos/kickstarter-logo.png (100%) rename website/{client => public}/front/images/presslogos/landing_slack_hash_wordmark_logo.png (100%) rename website/{client => public}/front/images/presslogos/lifehacker.png (100%) rename website/{client => public}/front/images/presslogos/logo_webstorm.png (100%) rename website/{client => public}/front/images/presslogos/makeuseof.png (100%) rename website/{client => public}/front/images/presslogos/nyt-logo.png (100%) rename website/{client => public}/front/images/presslogos/slack.png (100%) rename website/{client => public}/front/images/presslogos/trello-logo-blue.png (100%) rename website/{client => public}/front/images/quest_vice3.png (100%) rename website/{client => public}/front/images/screenshot.png (100%) rename website/{client => public}/front/images/t_bone_fight_2_by_mortquitue-d8dtxbl.png (100%) rename website/{client => public}/front/images/testimonial_by_Streak.png (100%) rename website/{client => public}/front/images/testimonials/16bitFil.png (100%) rename website/{client => public}/front/images/testimonials/AlexandraSo.png (100%) rename website/{client => public}/front/images/testimonials/Althaire.png (100%) rename website/{client => public}/front/images/testimonials/AndeeLiao.png (100%) rename website/{client => public}/front/images/testimonials/Brenna.png (100%) rename website/{client => public}/front/images/testimonials/Drag0nsilver.png (100%) rename website/{client => public}/front/images/testimonials/Drei-M.png (100%) rename website/{client => public}/front/images/testimonials/Elmi.png (100%) rename website/{client => public}/front/images/testimonials/EvaGantz.png (100%) rename website/{client => public}/front/images/testimonials/Helcura.png (100%) rename website/{client => public}/front/images/testimonials/InfH.png (100%) rename website/{client => public}/front/images/testimonials/Kai.png (100%) rename website/{client => public}/front/images/testimonials/Kazui.png (100%) rename website/{client => public}/front/images/testimonials/Zelah_Meyer.png (100%) rename website/{client => public}/front/images/testimonials/autumnesquirrel.png (100%) rename website/{client => public}/front/images/testimonials/frabjabulous.png (100%) rename website/{client => public}/front/images/testimonials/galarix.png (100%) rename website/{client => public}/front/images/testimonials/gwyn.blath.png (100%) rename website/{client => public}/front/images/testimonials/irishfeet123.png (100%) rename website/{client => public}/front/images/testimonials/skysailor.png (100%) rename website/{client => public}/front/images/testimonials/supermouse35.png (100%) rename website/{client => public}/front/images/testimonials/tonitonirocca.png (100%) rename website/{client => public}/front/images/uses/achievement-bkgd.png (100%) rename website/{client => public}/front/images/uses/clipart-rosemonkeyct-meditation.png (100%) rename website/{client => public}/front/images/uses/clipart-rosemonkeyct-meditation.psd (100%) rename website/{client => public}/front/images/uses/clipart-rosemonkeyct-reading.png (100%) rename website/{client => public}/front/images/uses/coding.png (100%) rename website/{client => public}/front/images/uses/coding_3_by_phoneix_faerie-d7idtti.png (100%) rename website/{client => public}/front/images/uses/consequences.png (100%) rename website/{client => public}/front/images/uses/dusting-bkgd.png (100%) rename website/{client => public}/front/images/uses/dusting_by_leephon.png (100%) rename website/{client => public}/front/images/uses/gaining_an_achievement_by_cosmic_caterpillar-d7uyv5z.png (100%) rename website/{client => public}/front/images/uses/meditation-bkgd.png (100%) rename website/{client => public}/front/images/uses/publicSpaces.png (100%) rename website/{client => public}/front/images/uses/reading.png (100%) rename website/{client => public}/front/js/blockScroll.js (100%) rename website/{client => public}/front/js/bootstrap.min.js (100%) rename website/{client => public}/front/js/skrollr.min.js (100%) rename website/{client => public}/front/landingv1Wireframe.jpg (100%) rename website/{client => public}/front/staticstyle.css (100%) rename website/{client => public}/front/style.css (100%) rename website/{client => public}/google280633b772b94345.html (100%) rename website/{client => public}/google8ca65b6ff3506fb8.html (100%) rename website/{client => public}/googlef3b1402b0e28338a.html (100%) rename website/{client => public}/js/.eslintrc (100%) rename website/{client => public}/js/app.js (73%) rename website/{client => public}/js/controllers/authCtrl.js (71%) rename website/{client => public}/js/controllers/autoCompleteCtrl.js (100%) rename website/{client => public}/js/controllers/challengesCtrl.js (61%) rename website/{client => public}/js/controllers/chatCtrl.js (62%) rename website/{client => public}/js/controllers/copyMessageModalCtrl.js (89%) rename website/{client => public}/js/controllers/filtersCtrl.js (81%) rename website/{client => public}/js/controllers/footerCtrl.js (64%) rename website/{client => public}/js/controllers/groupsCtrl.js (72%) create mode 100644 website/public/js/controllers/guildsCtrl.js rename website/{client => public}/js/controllers/hallCtrl.js (66%) rename website/{client => public}/js/controllers/headerCtrl.js (79%) rename website/{client => public}/js/controllers/inventoryCtrl.js (92%) rename website/{client => public}/js/controllers/inviteToGroupCtrl.js (63%) rename website/{client => public}/js/controllers/memberModalCtrl.js (50%) rename website/{client => public}/js/controllers/menuCtrl.js (97%) rename website/{client => public}/js/controllers/notificationCtrl.js (97%) create mode 100644 website/public/js/controllers/partyCtrl.js rename website/{client => public}/js/controllers/rootCtrl.js (86%) rename website/{client => public}/js/controllers/settingsCtrl.js (89%) rename website/{client => public}/js/controllers/sortableInventoryCtrl.js (100%) rename website/{client => public}/js/controllers/tasksCtrl.js (71%) create mode 100644 website/public/js/controllers/tavernCtrl.js rename website/{client => public}/js/controllers/userCtrl.js (90%) rename website/{client => public}/js/directives/close-menu.directive.js (100%) rename website/{client => public}/js/directives/expand-menu.directive.js (100%) rename website/{client => public}/js/directives/focus-element.directive.js (100%) rename website/{client => public}/js/directives/from-now.directive.js (100%) rename website/{client => public}/js/directives/habitrpg-tasks.directive.js (100%) rename website/{client => public}/js/directives/hrpg-sort-checklist.directive.js (100%) rename website/{client => public}/js/directives/hrpg-sort-tags.directive.js (88%) rename website/{client => public}/js/directives/hrpg-sort-tasks.directive.js (89%) rename website/{client => public}/js/directives/popover-html-popup.directive.js (100%) rename website/{client => public}/js/directives/popover-html.directive.js (100%) rename website/{client => public}/js/directives/when-scrolled.directive.js (100%) rename website/{client => public}/js/env.js (100%) rename website/{client => public}/js/filters/money.js (100%) rename website/{client => public}/js/filters/roundLargeNumbers.js (100%) rename website/{client => public}/js/filters/taskOrdering.js (100%) rename website/{client => public}/js/filters/timezoneOffsetToUtc.js (100%) rename website/{client => public}/js/services/analyticsServices.js (100%) create mode 100644 website/public/js/services/challengeServices.js create mode 100644 website/public/js/services/chatServices.js create mode 100644 website/public/js/services/groupServices.js rename website/{client => public}/js/services/guideServices.js (97%) create mode 100644 website/public/js/services/memberServices.js rename website/{client => public}/js/services/notificationServices.js (95%) rename website/{client => public}/js/services/paymentServices.js (95%) rename website/{client => public}/js/services/questServices.js (80%) rename website/{client => public}/js/services/sharedServices.js (100%) rename website/{client => public}/js/services/socialServices.js (100%) rename website/{client => public}/js/services/statServices.js (94%) create mode 100644 website/public/js/services/taskServices.js rename website/{client => public}/js/static.js (77%) rename website/{client => public}/logo.png (100%) rename website/{client => public}/logo/HABITRPG logo version 1.psd (100%) rename website/{client => public}/logo/HABITRPG-logo-version-1.gif (100%) rename website/{client => public}/logo/habitrpg.jpg (100%) rename website/{client => public}/logo/habitrpg_bl.eps (100%) rename website/{client => public}/logo/habitrpg_pixel.png (100%) rename website/{client => public}/manifest.json (94%) rename website/{client => public}/marketing/android_iphone.png (100%) rename website/{client => public}/marketing/animals.png (100%) rename website/{client => public}/marketing/challenge.png (100%) rename website/{client => public}/marketing/devices.png (100%) rename website/{client => public}/marketing/drops.png (100%) rename website/{client => public}/marketing/education.png (100%) rename website/{client => public}/marketing/gear.png (100%) rename website/{client => public}/marketing/guild.png (100%) rename website/{client => public}/marketing/guild_small.png (100%) rename website/{client => public}/marketing/integration.png (100%) rename website/{client => public}/marketing/lefnire.png (100%) rename website/{client => public}/marketing/promos/201403_Forest_Walker.png (100%) rename website/{client => public}/marketing/promos/April14SAMPLE2.png (100%) rename website/{client => public}/marketing/screenshot.png (100%) rename website/{client => public}/marketing/social_competitve.png (100%) rename website/{client => public}/marketing/wellness.png (100%) rename website/{client => public}/merch/stickermule-logo.png (100%) rename website/{client => public}/merch/stickermule-logo.svg (100%) rename website/{client => public}/merch/stickermule.png (100%) rename website/{client => public}/merch/teespring-eu-logo.png (100%) rename website/{client => public}/merch/teespring-eu.png (100%) rename website/{client => public}/merch/teespring-logo.png (100%) rename website/{client => public}/merch/teespring-logo.svg (100%) rename website/{client => public}/merch/teespring.png (100%) rename website/{client => public}/page-loader.gif (100%) rename website/{client => public}/presskit/Boss - Basi-List.png (100%) rename website/{client => public}/presskit/Boss - Battling the Ghost Stag.png (100%) rename website/{client => public}/presskit/Boss - Laundromancer.png (100%) rename website/{client => public}/presskit/Boss - Necro-Vice.png (100%) rename website/{client => public}/presskit/Boss - SnackLess Monster.png (100%) rename website/{client => public}/presskit/Boss - Stagnant Dishes.png (100%) rename website/{client => public}/presskit/Habitica Gryphon.png (100%) rename website/{client => public}/presskit/Habitica Logo - Android.png (100%) rename website/{client => public}/presskit/Habitica Logo - Icon with Text.png (100%) rename website/{client => public}/presskit/Habitica Logo - Icon.png (100%) rename website/{client => public}/presskit/Habitica Logo - Text.png (100%) rename website/{client => public}/presskit/Habitica Logo - iOS.png (100%) rename website/{client => public}/presskit/Habitica Promo - Thin.png (100%) rename website/{client => public}/presskit/Habitica Promo.png (100%) rename website/{client => public}/presskit/Sample Screen - Boss (iOS).png (100%) rename website/{client => public}/presskit/Sample Screen - Challenges.png (100%) rename website/{client => public}/presskit/Sample Screen - Equipment.png (100%) rename website/{client => public}/presskit/Sample Screen - Guilds.png (100%) rename website/{client => public}/presskit/Sample Screen - Level Up (iOS).png (100%) rename website/{client => public}/presskit/Sample Screen - Market.png (100%) rename website/{client => public}/presskit/Sample Screen - Party (iOS).png (100%) rename website/{client => public}/presskit/Sample Screen - Pets (iOS).png (100%) rename website/{client => public}/presskit/Sample Screen - Tasks Page (iOS).png (100%) rename website/{client => public}/presskit/Sample Screen - Tasks Page.png (100%) rename website/{client => public}/presskit/World Boss - Dread Drag'on of Dilatory.png (100%) rename website/{client => public}/presskit/presskit.zip (100%) rename website/{client => public}/refresh.png (100%) delete mode 100644 website/server/controllers/api-v2/challenges.js delete mode 100644 website/server/controllers/api-v2/user.js delete mode 100644 website/server/controllers/api-v3/auth.js delete mode 100644 website/server/controllers/api-v3/challenges.js delete mode 100644 website/server/controllers/api-v3/chat.js delete mode 100644 website/server/controllers/api-v3/content.js delete mode 100644 website/server/controllers/api-v3/coupon.js delete mode 100644 website/server/controllers/api-v3/debug.js delete mode 100644 website/server/controllers/api-v3/groups.js delete mode 100644 website/server/controllers/api-v3/hall.js delete mode 100644 website/server/controllers/api-v3/iap.js delete mode 100644 website/server/controllers/api-v3/members.js delete mode 100644 website/server/controllers/api-v3/modelsPaths.js delete mode 100644 website/server/controllers/api-v3/quests.js delete mode 100644 website/server/controllers/api-v3/status.js delete mode 100644 website/server/controllers/api-v3/tags.js delete mode 100644 website/server/controllers/api-v3/tasks.js delete mode 100644 website/server/controllers/api-v3/user.js delete mode 100644 website/server/controllers/top-level/auth.js delete mode 100644 website/server/controllers/top-level/dataexport.js delete mode 100644 website/server/controllers/top-level/email.js delete mode 100644 website/server/controllers/top-level/pages.js delete mode 100644 website/server/controllers/top-level/payments/amazon.js delete mode 100644 website/server/controllers/top-level/payments/iap.js delete mode 100644 website/server/controllers/top-level/payments/paypal.js delete mode 100644 website/server/controllers/top-level/payments/stripe.js delete mode 100644 website/server/index.js delete mode 100644 website/server/libs/api-v3/amazonPayments.js delete mode 100644 website/server/libs/api-v3/analyticsService.js delete mode 100644 website/server/libs/api-v3/baseModel.js delete mode 100644 website/server/libs/api-v3/buildManifest.js delete mode 100644 website/server/libs/api-v3/collectionManipulators.js delete mode 100644 website/server/libs/api-v3/cron.js delete mode 100644 website/server/libs/api-v3/csvStringify.js delete mode 100644 website/server/libs/api-v3/email.js delete mode 100644 website/server/libs/api-v3/encryption.js delete mode 100644 website/server/libs/api-v3/errors.js delete mode 100644 website/server/libs/api-v3/firebase.js delete mode 100644 website/server/libs/api-v3/i18n.js delete mode 100644 website/server/libs/api-v3/logger.js delete mode 100644 website/server/libs/api-v3/password.js delete mode 100644 website/server/libs/api-v3/payments.js delete mode 100644 website/server/libs/api-v3/preening.js delete mode 100644 website/server/libs/api-v3/pushNotifications.js delete mode 100644 website/server/libs/api-v3/routes.js delete mode 100644 website/server/libs/api-v3/setupMongoose.js delete mode 100644 website/server/libs/api-v3/setupNconf.js delete mode 100644 website/server/libs/api-v3/setupPassport.js delete mode 100644 website/server/libs/api-v3/webhook.js delete mode 100644 website/server/middlewares/api-v3/analytics.js delete mode 100644 website/server/middlewares/api-v3/auth.js delete mode 100644 website/server/middlewares/api-v3/cors.js delete mode 100644 website/server/middlewares/api-v3/cron.js delete mode 100644 website/server/middlewares/api-v3/domain.js delete mode 100644 website/server/middlewares/api-v3/ensureAccessRight.js delete mode 100644 website/server/middlewares/api-v3/ensureDevelpmentMode.js delete mode 100644 website/server/middlewares/api-v3/errorHandler.js delete mode 100644 website/server/middlewares/api-v3/index.js delete mode 100644 website/server/middlewares/api-v3/language.js delete mode 100644 website/server/middlewares/api-v3/locals.js delete mode 100644 website/server/middlewares/api-v3/maintenanceMode.js delete mode 100644 website/server/middlewares/api-v3/notFound.js delete mode 100644 website/server/middlewares/api-v3/redirects.js delete mode 100644 website/server/middlewares/api-v3/response.js delete mode 100644 website/server/middlewares/api-v3/setupBody.js delete mode 100644 website/server/middlewares/api-v3/static.js delete mode 100644 website/server/middlewares/api-v3/v1.js delete mode 100644 website/server/middlewares/api-v3/v2.js delete mode 100644 website/server/middlewares/api-v3/v3.js delete mode 100644 website/server/models/challenge.js delete mode 100644 website/server/models/coupon.js delete mode 100644 website/server/models/emailUnsubscription.js delete mode 100644 website/server/models/group.js delete mode 100644 website/server/models/tag.js delete mode 100644 website/server/models/task.js delete mode 100644 website/server/models/user.js delete mode 100644 website/server/routes/api-v2/auth.js delete mode 100644 website/server/routes/api-v2/coupon.js delete mode 100644 website/server/routes/api-v2/unsubscription.js delete mode 100644 website/server/routes/payments.js delete mode 100644 website/server/server.js rename website/{server => src}/controllers/api-v2/auth.js (94%) create mode 100644 website/src/controllers/api-v2/challenges.js rename website/{server => src}/controllers/api-v2/coupon.js (90%) rename website/{server => src}/controllers/api-v2/groups.js (69%) rename website/{server => src}/controllers/api-v2/hall.js (96%) rename website/{server => src}/controllers/api-v2/members.js (90%) rename website/{server => src}/controllers/api-v2/unsubscription.js (80%) create mode 100644 website/src/controllers/api-v2/user.js rename website/{server/controllers/api-v2 => src/controllers}/dataexport.js (89%) create mode 100644 website/src/controllers/payments/amazon.js create mode 100644 website/src/controllers/payments/iap.js create mode 100644 website/src/controllers/payments/index.js create mode 100644 website/src/controllers/payments/paypal.js rename {scripts => website/src/controllers/payments}/paypalBillingSetup.js (99%) create mode 100644 website/src/controllers/payments/stripe.js rename website/{server/controllers/api-v2 => src/controllers}/pushNotifications.js (98%) rename website/{server/libs/api-v2 => src/libs}/analytics.js (98%) rename website/{server/libs/api-v2 => src/libs}/buildManifest.js (91%) rename website/{server/libs/api-v2 => src/libs}/firebase.js (87%) rename website/{server/libs/api-v2 => src/libs}/i18n.js (90%) rename website/{server/libs/api-v2 => src/libs}/logging.js (94%) rename website/{server/libs/api-v2 => src/libs}/utils.js (90%) rename website/{server/libs/api-v2 => src/libs}/webhook.js (100%) rename website/{server => src}/middlewares/apiThrottle.js (74%) create mode 100644 website/src/middlewares/cors.js rename website/{server/middlewares/api-v2 => src/middlewares}/domain.js (100%) rename website/{server/middlewares/api-v2 => src/middlewares}/errorHandler.js (95%) rename website/{server => src}/middlewares/forceRefresh.js (83%) rename website/{server/middlewares/api-v2 => src/middlewares}/locals.js (94%) create mode 100644 website/src/middlewares/redirects.js create mode 100644 website/src/models/challenge.js create mode 100644 website/src/models/coupon.js create mode 100644 website/src/models/emailUnsubscription.js create mode 100644 website/src/models/group.js create mode 100644 website/src/models/task.js create mode 100644 website/src/models/user.js create mode 100644 website/src/routes/api-v1.js create mode 100644 website/src/routes/api-v2/auth.js create mode 100644 website/src/routes/api-v2/coupon.js rename website/{server => src}/routes/api-v2/swagger.js (91%) create mode 100644 website/src/routes/api-v2/unsubscription.js create mode 100644 website/src/routes/dataexport.js rename website/{server => src}/routes/pages.js (94%) create mode 100644 website/src/routes/payments.js create mode 100644 website/src/server.js delete mode 100644 website/views/shared/modals/modify-inventory.jade rename website/views/static/{api-v2.jade => api.jade} (91%) delete mode 100644 website/views/static/maintenance.jade diff --git a/.babelrc b/.babelrc index 988e0d6f03..abdb3b030b 100644 --- a/.babelrc +++ b/.babelrc @@ -1,9 +1,4 @@ { "presets": ["es2015"], - "plugins": [ - ["transform-async-to-module-method", { - "module": "bluebird", - "method": "coroutine" - }] - ] + "plugins": ["syntax-async-functions","transform-regenerator"] } diff --git a/.bowerrc b/.bowerrc index 552e7d2622..4a52096b99 100644 --- a/.bowerrc +++ b/.bowerrc @@ -1,3 +1,3 @@ { - "directory": "website/client/bower_components" + "directory": "website/public/bower_components" } diff --git a/.eslintignore b/.eslintignore index 5b7664ac76..595ad011ae 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,26 +3,24 @@ common/dist/ common/transpiled-babel/ coverage/ database_reports/ +migrations/ website/build/ website/transpiled-babel/ -migrations/* - -# The files in website/client/js should be moved out and browserified -website/client/ +# The files in website/public/js should be moved out and browserified +website/public/ # Temporarilly disabled. These should be removed when the linting errors are fixed +common/script/index.js common/script/content/index.js +common/script/ops/**/*.js +common/script/fns/**/*.js +common/script/libs/**/*.js common/script/public/**/*.js -website/server/**/api-v2/**/*.js -website/server/routes/payments.js -website/server/routes/pages.js -website/server/middlewares/apiThrottle.js -website/server/middlewares/forceRefresh.js +website/src/**/*.js debug-scripts/* -scripts/* tasks/*.js gulpfile.js Gruntfile.js diff --git a/.eslintrc b/.eslintrc index bcccde1ef6..111772a5a3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,8 +2,5 @@ "extends": [ "habitrpg/server", "habitrpg/babel" - ], - "globals": { - "Promise": true - } + ] } diff --git a/.gitignore b/.gitignore index 4e3d867081..a8e969e620 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .DS_Store -website/client/gen -website/client/common -website/client/apidoc +website/public/gen +website/public/common website/transpiled-babel/ common/transpiled-babel/ node_modules @@ -10,8 +9,8 @@ node_modules config.json npm-debug.log* lib -website/client/bower_components -website/client/new-stuff.html +website/public/bower_components +website/public/new-stuff.html website/build newrelic_agent.log .bower-tmp @@ -25,7 +24,7 @@ src/*/*.map src/*/*/*.map test/*.js test/*.map -website/client/docs +website/public/docs *.sublime-workspace coverage coverage.html diff --git a/.nodemonignore b/.nodemonignore index c698b88598..5aa436cfa3 100644 --- a/.nodemonignore +++ b/.nodemonignore @@ -2,7 +2,7 @@ node_modules/** .bower-cache/** .bower-tmp/** .bower-registry/** -website/client/** +website/public/** website/views/** website/build/** .git/** diff --git a/.travis.yml b/.travis.yml index dd5a397986..86de1b5103 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: node_js node_js: - '4.3.1' before_install: - - "npm install -g npm@3" + - "npm install -g npm@2" - "npm install -g gulp" - "sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 7F0CEB10" - "echo 'deb http://downloads-distro.mongodb.org/repo/ubuntu-upstart dist 10gen' | sudo tee /etc/apt/sources.list.d/mongodb.list" diff --git a/Gruntfile.js b/Gruntfile.js index 8f716518fe..b5f507c50a 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -43,11 +43,11 @@ module.exports = function(grunt) { options: { compress: false, // AFTER 'include css': true, - paths: ['website/client'] + paths: ['website/public'] }, files: { - 'website/build/app.css': ['website/client/css/index.styl'], - 'website/build/static.css': ['website/client/css/static.styl'] + 'website/build/app.css': ['website/public/css/index.styl'], + 'website/build/static.css': ['website/public/css/static.styl'] } } }, @@ -55,13 +55,13 @@ module.exports = function(grunt) { copy: { build: { files: [ - {expand: true, cwd: 'website/client/', src: 'favicon.ico', dest: 'website/build/'}, - {expand: true, cwd: 'website/client/', src: 'favicon_192x192.png', dest: 'website/build/'}, + {expand: true, cwd: 'website/public/', src: 'favicon.ico', dest: 'website/build/'}, + {expand: true, cwd: 'website/public/', src: 'favicon_192x192.png', dest: 'website/build/'}, {expand: true, cwd: '', src: 'common/dist/sprites/spritesmith*.png', dest: 'website/build/'}, {expand: true, cwd: '', src: 'common/img/sprites/backer-only/*.gif', dest: 'website/build/'}, {expand: true, cwd: '', src: 'common/img/sprites/npc_ian.gif', dest: 'website/build/'}, {expand: true, cwd: '', src: 'common/img/sprites/quest_*.gif', dest: 'website/build/'}, - {expand: true, cwd: 'website/client/', src: 'bower_components/bootstrap/dist/fonts/*', dest: 'website/build/'} + {expand: true, cwd: 'website/public/', src: 'bower_components/bootstrap/dist/fonts/*', dest: 'website/build/'} ] } }, @@ -88,9 +88,9 @@ module.exports = function(grunt) { } }); - //Load build files from client/manifest.json - grunt.registerTask('loadManifestFiles', 'Load all build files from client/manifest.json', function(){ - var files = grunt.file.readJSON('./website/client/manifest.json'); + //Load build files from public/manifest.json + grunt.registerTask('loadManifestFiles', 'Load all build files from public/manifest.json', function(){ + var files = grunt.file.readJSON('./website/public/manifest.json'); var uglify = {}; var cssmin = {}; @@ -101,7 +101,7 @@ module.exports = function(grunt) { _.each(files[key].js, function(val){ var path = "./"; if( val.indexOf('common/') == -1) - path = './website/client/'; + path = './website/public/'; js.push(path + val); }); @@ -110,7 +110,7 @@ module.exports = function(grunt) { _.each(files[key].css, function(val){ var path = "./"; if( val.indexOf('common/') == -1) { - path = (val == 'app.css' || val == 'static.css') ? './website/build/' : './website/client/'; + path = (val == 'app.css' || val == 'static.css') ? './website/build/' : './website/public/'; } css.push(path + val) }); @@ -122,7 +122,7 @@ module.exports = function(grunt) { grunt.config.set('cssmin.build.files', cssmin); // Rewrite urls to relative path - grunt.config.set('cssmin.build.options', {'target': 'website/client/css/whatever-css.css'}); + grunt.config.set('cssmin.build.options', {'target': 'website/public/css/whatever-css.css'}); }); // Register tasks. @@ -131,7 +131,7 @@ module.exports = function(grunt) { grunt.registerTask('build:test', ['test:prepare:translations', 'build:dev']); grunt.registerTask('test:prepare:translations', function() { - var i18n = require('./website/server/libs/api-v3/i18n'), + var i18n = require('./website/src/libs/i18n'), fs = require('fs'); fs.writeFileSync('test/spec/mocks/translations.js', "if(!window.env) window.env = {};\n" + diff --git a/Procfile b/Procfile index 72e2be7947..5283f654f1 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: node ./website/transpiled-babel/index.js +web: node ./website/transpiled-babel/server.js diff --git a/bower.json b/bower.json index 7e6e6f6790..8788581cd1 100644 --- a/bower.json +++ b/bower.json @@ -9,7 +9,7 @@ "ignore": [ "**/.*", "node_modules", - "website/client/bower_components", + "public/bower_components", "test", "tests" ], diff --git a/common/browserify.js b/common/browserify.js index 3653cb81b0..0530144839 100644 --- a/common/browserify.js +++ b/common/browserify.js @@ -1,5 +1,3 @@ -require('babel-polyfill'); - var shared = require('./script/index'); var _ = require('lodash'); var moment = require('moment'); diff --git a/common/dist/sprites/spritesmith-main-0.css b/common/dist/sprites/spritesmith-main-0.css index 28970a6d42..33de4f76f8 100644 --- a/common/dist/sprites/spritesmith-main-0.css +++ b/common/dist/sprites/spritesmith-main-0.css @@ -346,13 +346,13 @@ width: 48px; height: 52px; } -.achievement-spookySparkles { +.achievement-spookDust { background-image: url(spritesmith-main-0.png); background-position: -25px -1601px; width: 24px; height: 26px; } -.achievement-spookySparkles2x { +.achievement-spookDust2x { background-image: url(spritesmith-main-0.png); background-position: -980px -1548px; width: 48px; diff --git a/common/dist/sprites/spritesmith-main-11.css b/common/dist/sprites/spritesmith-main-11.css index 6c97f196e0..0c5b59a361 100644 --- a/common/dist/sprites/spritesmith-main-11.css +++ b/common/dist/sprites/spritesmith-main-11.css @@ -1960,31 +1960,31 @@ width: 81px; height: 99px; } -.Pet-Lion-Veteran { +.Pet-LionCub-Base { background-image: url(spritesmith-main-11.png); background-position: -1640px -700px; width: 81px; height: 99px; } -.Pet-LionCub-Base { +.Pet-LionCub-CottonCandyBlue { background-image: url(spritesmith-main-11.png); background-position: -1640px -800px; width: 81px; height: 99px; } -.Pet-LionCub-CottonCandyBlue { +.Pet-LionCub-CottonCandyPink { background-image: url(spritesmith-main-11.png); background-position: -1640px -900px; width: 81px; height: 99px; } -.Pet-LionCub-CottonCandyPink { +.Pet-LionCub-Desert { background-image: url(spritesmith-main-11.png); background-position: -1640px -1000px; width: 81px; height: 99px; } -.Pet-LionCub-Desert { +.Pet-LionCub-Floral { background-image: url(spritesmith-main-11.png); background-position: -1640px -1100px; width: 81px; diff --git a/common/dist/sprites/spritesmith-main-11.png b/common/dist/sprites/spritesmith-main-11.png index 8f1f71ebf3f8894b42c38996a821660174f70e54..1a3e1517449e0dfe18b775cfdd8eb683b3b0d800 100644 GIT binary patch delta 84161 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 z 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%0DJ_slSr5%dBKc}S%|9{ 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Þ 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 K9@6gPQU3?!@;s#g diff --git a/common/dist/sprites/spritesmith-main-12.css b/common/dist/sprites/spritesmith-main-12.css index f5cff687d1..1909b7da65 100644 --- a/common/dist/sprites/spritesmith-main-12.css +++ b/common/dist/sprites/spritesmith-main-12.css @@ -1,1554 +1,1548 @@ -.Pet-LionCub-Floral { +.Pet-LionCub-Golden { background-image: url(spritesmith-main-12.png); background-position: -82px 0px; width: 81px; height: 99px; } -.Pet-LionCub-Golden { +.Pet-LionCub-Peppermint { background-image: url(spritesmith-main-12.png); background-position: -984px -900px; width: 81px; height: 99px; } -.Pet-LionCub-Peppermint { +.Pet-LionCub-Red { background-image: url(spritesmith-main-12.png); background-position: -164px 0px; width: 81px; height: 99px; } -.Pet-LionCub-Red { +.Pet-LionCub-Shade { background-image: url(spritesmith-main-12.png); background-position: 0px -100px; width: 81px; height: 99px; } -.Pet-LionCub-Shade { +.Pet-LionCub-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -82px -100px; width: 81px; height: 99px; } -.Pet-LionCub-Skeleton { +.Pet-LionCub-Spooky { background-image: url(spritesmith-main-12.png); background-position: -164px -100px; width: 81px; height: 99px; } -.Pet-LionCub-Spooky { +.Pet-LionCub-White { background-image: url(spritesmith-main-12.png); background-position: -246px 0px; width: 81px; height: 99px; } -.Pet-LionCub-White { +.Pet-LionCub-Zombie { background-image: url(spritesmith-main-12.png); background-position: -246px -100px; width: 81px; height: 99px; } -.Pet-LionCub-Zombie { +.Pet-MagicalBee-Base { background-image: url(spritesmith-main-12.png); background-position: 0px -200px; width: 81px; height: 99px; } -.Pet-MagicalBee-Base { +.Pet-Mammoth-Base { background-image: url(spritesmith-main-12.png); background-position: -82px -200px; width: 81px; height: 99px; } -.Pet-Mammoth-Base { +.Pet-MantisShrimp-Base { background-image: url(spritesmith-main-12.png); background-position: -164px -200px; width: 81px; height: 99px; } -.Pet-MantisShrimp-Base { +.Pet-Monkey-Base { background-image: url(spritesmith-main-12.png); background-position: -246px -200px; width: 81px; height: 99px; } -.Pet-Monkey-Base { +.Pet-Monkey-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -328px 0px; width: 81px; height: 99px; } -.Pet-Monkey-CottonCandyBlue { +.Pet-Monkey-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -328px -100px; width: 81px; height: 99px; } -.Pet-Monkey-CottonCandyPink { +.Pet-Monkey-Desert { background-image: url(spritesmith-main-12.png); background-position: -328px -200px; width: 81px; height: 99px; } -.Pet-Monkey-Desert { +.Pet-Monkey-Golden { background-image: url(spritesmith-main-12.png); background-position: 0px -300px; width: 81px; height: 99px; } -.Pet-Monkey-Golden { +.Pet-Monkey-Red { background-image: url(spritesmith-main-12.png); background-position: -82px -300px; width: 81px; height: 99px; } -.Pet-Monkey-Red { +.Pet-Monkey-Shade { background-image: url(spritesmith-main-12.png); background-position: -164px -300px; width: 81px; height: 99px; } -.Pet-Monkey-Shade { +.Pet-Monkey-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -246px -300px; width: 81px; height: 99px; } -.Pet-Monkey-Skeleton { +.Pet-Monkey-White { background-image: url(spritesmith-main-12.png); background-position: -328px -300px; width: 81px; height: 99px; } -.Pet-Monkey-White { +.Pet-Monkey-Zombie { background-image: url(spritesmith-main-12.png); background-position: -410px 0px; width: 81px; height: 99px; } -.Pet-Monkey-Zombie { +.Pet-Octopus-Base { background-image: url(spritesmith-main-12.png); background-position: -410px -100px; width: 81px; height: 99px; } -.Pet-Octopus-Base { +.Pet-Octopus-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -410px -200px; width: 81px; height: 99px; } -.Pet-Octopus-CottonCandyBlue { +.Pet-Octopus-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -410px -300px; width: 81px; height: 99px; } -.Pet-Octopus-CottonCandyPink { +.Pet-Octopus-Desert { background-image: url(spritesmith-main-12.png); background-position: -492px 0px; width: 81px; height: 99px; } -.Pet-Octopus-Desert { +.Pet-Octopus-Golden { background-image: url(spritesmith-main-12.png); background-position: -492px -100px; width: 81px; height: 99px; } -.Pet-Octopus-Golden { +.Pet-Octopus-Red { background-image: url(spritesmith-main-12.png); background-position: -492px -200px; width: 81px; height: 99px; } -.Pet-Octopus-Red { +.Pet-Octopus-Shade { background-image: url(spritesmith-main-12.png); background-position: -492px -300px; width: 81px; height: 99px; } -.Pet-Octopus-Shade { +.Pet-Octopus-Skeleton { background-image: url(spritesmith-main-12.png); background-position: 0px -400px; width: 81px; height: 99px; } -.Pet-Octopus-Skeleton { +.Pet-Octopus-White { background-image: url(spritesmith-main-12.png); background-position: -82px -400px; width: 81px; height: 99px; } -.Pet-Octopus-White { +.Pet-Octopus-Zombie { background-image: url(spritesmith-main-12.png); background-position: -164px -400px; width: 81px; height: 99px; } -.Pet-Octopus-Zombie { +.Pet-Owl-Base { background-image: url(spritesmith-main-12.png); background-position: -246px -400px; width: 81px; height: 99px; } -.Pet-Owl-Base { +.Pet-Owl-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -328px -400px; width: 81px; height: 99px; } -.Pet-Owl-CottonCandyBlue { +.Pet-Owl-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -410px -400px; width: 81px; height: 99px; } -.Pet-Owl-CottonCandyPink { +.Pet-Owl-Desert { background-image: url(spritesmith-main-12.png); background-position: -492px -400px; width: 81px; height: 99px; } -.Pet-Owl-Desert { +.Pet-Owl-Golden { background-image: url(spritesmith-main-12.png); background-position: -574px 0px; width: 81px; height: 99px; } -.Pet-Owl-Golden { +.Pet-Owl-Red { background-image: url(spritesmith-main-12.png); background-position: -574px -100px; width: 81px; height: 99px; } -.Pet-Owl-Red { +.Pet-Owl-Shade { background-image: url(spritesmith-main-12.png); background-position: -574px -200px; width: 81px; height: 99px; } -.Pet-Owl-Shade { +.Pet-Owl-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -574px -300px; width: 81px; height: 99px; } -.Pet-Owl-Skeleton { +.Pet-Owl-White { background-image: url(spritesmith-main-12.png); background-position: -574px -400px; width: 81px; height: 99px; } -.Pet-Owl-White { +.Pet-Owl-Zombie { background-image: url(spritesmith-main-12.png); background-position: 0px -500px; width: 81px; height: 99px; } -.Pet-Owl-Zombie { +.Pet-PandaCub-Base { background-image: url(spritesmith-main-12.png); background-position: -82px -500px; width: 81px; height: 99px; } -.Pet-PandaCub-Base { +.Pet-PandaCub-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -164px -500px; width: 81px; height: 99px; } -.Pet-PandaCub-CottonCandyBlue { +.Pet-PandaCub-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -246px -500px; width: 81px; height: 99px; } -.Pet-PandaCub-CottonCandyPink { +.Pet-PandaCub-Desert { background-image: url(spritesmith-main-12.png); background-position: -328px -500px; width: 81px; height: 99px; } -.Pet-PandaCub-Desert { +.Pet-PandaCub-Floral { background-image: url(spritesmith-main-12.png); background-position: -410px -500px; width: 81px; height: 99px; } -.Pet-PandaCub-Floral { +.Pet-PandaCub-Golden { background-image: url(spritesmith-main-12.png); background-position: -492px -500px; width: 81px; height: 99px; } -.Pet-PandaCub-Golden { +.Pet-PandaCub-Peppermint { background-image: url(spritesmith-main-12.png); background-position: -574px -500px; width: 81px; height: 99px; } -.Pet-PandaCub-Peppermint { +.Pet-PandaCub-Red { background-image: url(spritesmith-main-12.png); background-position: -656px 0px; width: 81px; height: 99px; } -.Pet-PandaCub-Red { +.Pet-PandaCub-Shade { background-image: url(spritesmith-main-12.png); background-position: -656px -100px; width: 81px; height: 99px; } -.Pet-PandaCub-Shade { +.Pet-PandaCub-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -656px -200px; width: 81px; height: 99px; } -.Pet-PandaCub-Skeleton { +.Pet-PandaCub-Spooky { background-image: url(spritesmith-main-12.png); background-position: -656px -300px; width: 81px; height: 99px; } -.Pet-PandaCub-Spooky { +.Pet-PandaCub-White { background-image: url(spritesmith-main-12.png); background-position: -656px -400px; width: 81px; height: 99px; } -.Pet-PandaCub-White { +.Pet-PandaCub-Zombie { background-image: url(spritesmith-main-12.png); background-position: -656px -500px; width: 81px; height: 99px; } -.Pet-PandaCub-Zombie { +.Pet-Parrot-Base { background-image: url(spritesmith-main-12.png); background-position: 0px -600px; width: 81px; height: 99px; } -.Pet-Parrot-Base { +.Pet-Parrot-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -82px -600px; width: 81px; height: 99px; } -.Pet-Parrot-CottonCandyBlue { +.Pet-Parrot-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -164px -600px; width: 81px; height: 99px; } -.Pet-Parrot-CottonCandyPink { +.Pet-Parrot-Desert { background-image: url(spritesmith-main-12.png); background-position: -246px -600px; width: 81px; height: 99px; } -.Pet-Parrot-Desert { +.Pet-Parrot-Golden { background-image: url(spritesmith-main-12.png); background-position: -328px -600px; width: 81px; height: 99px; } -.Pet-Parrot-Golden { +.Pet-Parrot-Red { background-image: url(spritesmith-main-12.png); background-position: -410px -600px; width: 81px; height: 99px; } -.Pet-Parrot-Red { +.Pet-Parrot-Shade { background-image: url(spritesmith-main-12.png); background-position: -492px -600px; width: 81px; height: 99px; } -.Pet-Parrot-Shade { +.Pet-Parrot-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -574px -600px; width: 81px; height: 99px; } -.Pet-Parrot-Skeleton { +.Pet-Parrot-White { background-image: url(spritesmith-main-12.png); background-position: -656px -600px; width: 81px; height: 99px; } -.Pet-Parrot-White { +.Pet-Parrot-Zombie { background-image: url(spritesmith-main-12.png); background-position: -738px 0px; width: 81px; height: 99px; } -.Pet-Parrot-Zombie { +.Pet-Penguin-Base { background-image: url(spritesmith-main-12.png); background-position: -738px -100px; width: 81px; height: 99px; } -.Pet-Penguin-Base { +.Pet-Penguin-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -738px -200px; width: 81px; height: 99px; } -.Pet-Penguin-CottonCandyBlue { +.Pet-Penguin-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -738px -300px; width: 81px; height: 99px; } -.Pet-Penguin-CottonCandyPink { +.Pet-Penguin-Desert { background-image: url(spritesmith-main-12.png); background-position: -738px -400px; width: 81px; height: 99px; } -.Pet-Penguin-Desert { +.Pet-Penguin-Golden { background-image: url(spritesmith-main-12.png); background-position: -738px -500px; width: 81px; height: 99px; } -.Pet-Penguin-Golden { +.Pet-Penguin-Red { background-image: url(spritesmith-main-12.png); background-position: -738px -600px; width: 81px; height: 99px; } -.Pet-Penguin-Red { +.Pet-Penguin-Shade { background-image: url(spritesmith-main-12.png); background-position: 0px -700px; width: 81px; height: 99px; } -.Pet-Penguin-Shade { +.Pet-Penguin-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -82px -700px; width: 81px; height: 99px; } -.Pet-Penguin-Skeleton { +.Pet-Penguin-White { background-image: url(spritesmith-main-12.png); background-position: -164px -700px; width: 81px; height: 99px; } -.Pet-Penguin-White { +.Pet-Penguin-Zombie { background-image: url(spritesmith-main-12.png); background-position: -246px -700px; width: 81px; height: 99px; } -.Pet-Penguin-Zombie { +.Pet-Phoenix-Base { background-image: url(spritesmith-main-12.png); background-position: -328px -700px; width: 81px; height: 99px; } -.Pet-Phoenix-Base { +.Pet-Rat-Base { background-image: url(spritesmith-main-12.png); background-position: -410px -700px; width: 81px; height: 99px; } -.Pet-Rat-Base { +.Pet-Rat-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -492px -700px; width: 81px; height: 99px; } -.Pet-Rat-CottonCandyBlue { +.Pet-Rat-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -574px -700px; width: 81px; height: 99px; } -.Pet-Rat-CottonCandyPink { +.Pet-Rat-Desert { background-image: url(spritesmith-main-12.png); background-position: -656px -700px; width: 81px; height: 99px; } -.Pet-Rat-Desert { +.Pet-Rat-Golden { background-image: url(spritesmith-main-12.png); background-position: -738px -700px; width: 81px; height: 99px; } -.Pet-Rat-Golden { +.Pet-Rat-Red { background-image: url(spritesmith-main-12.png); background-position: -820px 0px; width: 81px; height: 99px; } -.Pet-Rat-Red { +.Pet-Rat-Shade { background-image: url(spritesmith-main-12.png); background-position: -820px -100px; width: 81px; height: 99px; } -.Pet-Rat-Shade { +.Pet-Rat-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -820px -200px; width: 81px; height: 99px; } -.Pet-Rat-Skeleton { +.Pet-Rat-White { background-image: url(spritesmith-main-12.png); background-position: -820px -300px; width: 81px; height: 99px; } -.Pet-Rat-White { +.Pet-Rat-Zombie { background-image: url(spritesmith-main-12.png); background-position: -820px -400px; width: 81px; height: 99px; } -.Pet-Rat-Zombie { +.Pet-Rock-Base { background-image: url(spritesmith-main-12.png); background-position: -820px -500px; width: 81px; height: 99px; } -.Pet-Rock-Base { +.Pet-Rock-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -820px -600px; width: 81px; height: 99px; } -.Pet-Rock-CottonCandyBlue { +.Pet-Rock-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -820px -700px; width: 81px; height: 99px; } -.Pet-Rock-CottonCandyPink { +.Pet-Rock-Desert { background-image: url(spritesmith-main-12.png); background-position: 0px -800px; width: 81px; height: 99px; } -.Pet-Rock-Desert { +.Pet-Rock-Golden { background-image: url(spritesmith-main-12.png); background-position: -82px -800px; width: 81px; height: 99px; } -.Pet-Rock-Golden { +.Pet-Rock-Red { background-image: url(spritesmith-main-12.png); background-position: -164px -800px; width: 81px; height: 99px; } -.Pet-Rock-Red { +.Pet-Rock-Shade { background-image: url(spritesmith-main-12.png); background-position: -246px -800px; width: 81px; height: 99px; } -.Pet-Rock-Shade { +.Pet-Rock-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -328px -800px; width: 81px; height: 99px; } -.Pet-Rock-Skeleton { +.Pet-Rock-White { background-image: url(spritesmith-main-12.png); background-position: -410px -800px; width: 81px; height: 99px; } -.Pet-Rock-White { +.Pet-Rock-Zombie { background-image: url(spritesmith-main-12.png); background-position: -492px -800px; width: 81px; height: 99px; } -.Pet-Rock-Zombie { +.Pet-Rooster-Base { background-image: url(spritesmith-main-12.png); background-position: -574px -800px; width: 81px; height: 99px; } -.Pet-Rooster-Base { +.Pet-Rooster-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -656px -800px; width: 81px; height: 99px; } -.Pet-Rooster-CottonCandyBlue { +.Pet-Rooster-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -738px -800px; width: 81px; height: 99px; } -.Pet-Rooster-CottonCandyPink { +.Pet-Rooster-Desert { background-image: url(spritesmith-main-12.png); background-position: -820px -800px; width: 81px; height: 99px; } -.Pet-Rooster-Desert { +.Pet-Rooster-Golden { background-image: url(spritesmith-main-12.png); background-position: -902px 0px; width: 81px; height: 99px; } -.Pet-Rooster-Golden { +.Pet-Rooster-Red { background-image: url(spritesmith-main-12.png); background-position: -902px -100px; width: 81px; height: 99px; } -.Pet-Rooster-Red { +.Pet-Rooster-Shade { background-image: url(spritesmith-main-12.png); background-position: -902px -200px; width: 81px; height: 99px; } -.Pet-Rooster-Shade { +.Pet-Rooster-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -902px -300px; width: 81px; height: 99px; } -.Pet-Rooster-Skeleton { +.Pet-Rooster-White { background-image: url(spritesmith-main-12.png); background-position: -902px -400px; width: 81px; height: 99px; } -.Pet-Rooster-White { +.Pet-Rooster-Zombie { background-image: url(spritesmith-main-12.png); background-position: -902px -500px; width: 81px; height: 99px; } -.Pet-Rooster-Zombie { +.Pet-Sabretooth-Base { background-image: url(spritesmith-main-12.png); background-position: -902px -600px; width: 81px; height: 99px; } -.Pet-Sabretooth-Base { +.Pet-Sabretooth-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -902px -700px; width: 81px; height: 99px; } -.Pet-Sabretooth-CottonCandyBlue { +.Pet-Sabretooth-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -902px -800px; width: 81px; height: 99px; } -.Pet-Sabretooth-CottonCandyPink { +.Pet-Sabretooth-Desert { background-image: url(spritesmith-main-12.png); background-position: -984px 0px; width: 81px; height: 99px; } -.Pet-Sabretooth-Desert { +.Pet-Sabretooth-Golden { background-image: url(spritesmith-main-12.png); background-position: -984px -100px; width: 81px; height: 99px; } -.Pet-Sabretooth-Golden { +.Pet-Sabretooth-Red { background-image: url(spritesmith-main-12.png); background-position: -984px -200px; width: 81px; height: 99px; } -.Pet-Sabretooth-Red { +.Pet-Sabretooth-Shade { background-image: url(spritesmith-main-12.png); background-position: -984px -300px; width: 81px; height: 99px; } -.Pet-Sabretooth-Shade { +.Pet-Sabretooth-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -984px -400px; width: 81px; height: 99px; } -.Pet-Sabretooth-Skeleton { +.Pet-Sabretooth-White { background-image: url(spritesmith-main-12.png); background-position: -984px -500px; width: 81px; height: 99px; } -.Pet-Sabretooth-White { +.Pet-Sabretooth-Zombie { background-image: url(spritesmith-main-12.png); background-position: -984px -600px; width: 81px; height: 99px; } -.Pet-Sabretooth-Zombie { +.Pet-Seahorse-Base { background-image: url(spritesmith-main-12.png); background-position: -984px -700px; width: 81px; height: 99px; } -.Pet-Seahorse-Base { +.Pet-Seahorse-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -984px -800px; width: 81px; height: 99px; } -.Pet-Seahorse-CottonCandyBlue { +.Pet-Seahorse-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: 0px -900px; width: 81px; height: 99px; } -.Pet-Seahorse-CottonCandyPink { +.Pet-Seahorse-Desert { background-image: url(spritesmith-main-12.png); background-position: -82px -900px; width: 81px; height: 99px; } -.Pet-Seahorse-Desert { +.Pet-Seahorse-Golden { background-image: url(spritesmith-main-12.png); background-position: -164px -900px; width: 81px; height: 99px; } -.Pet-Seahorse-Golden { +.Pet-Seahorse-Red { background-image: url(spritesmith-main-12.png); background-position: -246px -900px; width: 81px; height: 99px; } -.Pet-Seahorse-Red { +.Pet-Seahorse-Shade { background-image: url(spritesmith-main-12.png); background-position: -328px -900px; width: 81px; height: 99px; } -.Pet-Seahorse-Shade { +.Pet-Seahorse-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -410px -900px; width: 81px; height: 99px; } -.Pet-Seahorse-Skeleton { +.Pet-Seahorse-White { background-image: url(spritesmith-main-12.png); background-position: -492px -900px; width: 81px; height: 99px; } -.Pet-Seahorse-White { +.Pet-Seahorse-Zombie { background-image: url(spritesmith-main-12.png); background-position: -574px -900px; width: 81px; height: 99px; } -.Pet-Seahorse-Zombie { +.Pet-Sheep-Base { background-image: url(spritesmith-main-12.png); background-position: -656px -900px; width: 81px; height: 99px; } -.Pet-Sheep-Base { +.Pet-Sheep-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -738px -900px; width: 81px; height: 99px; } -.Pet-Sheep-CottonCandyBlue { +.Pet-Sheep-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -820px -900px; width: 81px; height: 99px; } -.Pet-Sheep-CottonCandyPink { +.Pet-Sheep-Desert { background-image: url(spritesmith-main-12.png); background-position: -902px -900px; width: 81px; height: 99px; } -.Pet-Sheep-Desert { +.Pet-Sheep-Golden { background-image: url(spritesmith-main-12.png); background-position: 0px 0px; width: 81px; height: 99px; } -.Pet-Sheep-Golden { +.Pet-Sheep-Red { background-image: url(spritesmith-main-12.png); background-position: -1066px 0px; width: 81px; height: 99px; } -.Pet-Sheep-Red { +.Pet-Sheep-Shade { background-image: url(spritesmith-main-12.png); background-position: -1066px -100px; width: 81px; height: 99px; } -.Pet-Sheep-Shade { +.Pet-Sheep-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -1066px -200px; width: 81px; height: 99px; } -.Pet-Sheep-Skeleton { +.Pet-Sheep-White { background-image: url(spritesmith-main-12.png); background-position: -1066px -300px; width: 81px; height: 99px; } -.Pet-Sheep-White { +.Pet-Sheep-Zombie { background-image: url(spritesmith-main-12.png); background-position: -1066px -400px; width: 81px; height: 99px; } -.Pet-Sheep-Zombie { +.Pet-Slime-Base { background-image: url(spritesmith-main-12.png); background-position: -1066px -500px; width: 81px; height: 99px; } -.Pet-Slime-Base { +.Pet-Slime-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -1066px -600px; width: 81px; height: 99px; } -.Pet-Slime-CottonCandyBlue { +.Pet-Slime-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -1066px -700px; width: 81px; height: 99px; } -.Pet-Slime-CottonCandyPink { +.Pet-Slime-Desert { background-image: url(spritesmith-main-12.png); background-position: -1066px -800px; width: 81px; height: 99px; } -.Pet-Slime-Desert { +.Pet-Slime-Golden { background-image: url(spritesmith-main-12.png); background-position: -1066px -900px; width: 81px; height: 99px; } -.Pet-Slime-Golden { +.Pet-Slime-Red { background-image: url(spritesmith-main-12.png); background-position: 0px -1000px; width: 81px; height: 99px; } -.Pet-Slime-Red { +.Pet-Slime-Shade { background-image: url(spritesmith-main-12.png); background-position: -82px -1000px; width: 81px; height: 99px; } -.Pet-Slime-Shade { +.Pet-Slime-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -164px -1000px; width: 81px; height: 99px; } -.Pet-Slime-Skeleton { +.Pet-Slime-White { background-image: url(spritesmith-main-12.png); background-position: -246px -1000px; width: 81px; height: 99px; } -.Pet-Slime-White { +.Pet-Slime-Zombie { background-image: url(spritesmith-main-12.png); background-position: -328px -1000px; width: 81px; height: 99px; } -.Pet-Slime-Zombie { +.Pet-Snail-Base { background-image: url(spritesmith-main-12.png); background-position: -410px -1000px; width: 81px; height: 99px; } -.Pet-Snail-Base { +.Pet-Snail-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -492px -1000px; width: 81px; height: 99px; } -.Pet-Snail-CottonCandyBlue { +.Pet-Snail-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -574px -1000px; width: 81px; height: 99px; } -.Pet-Snail-CottonCandyPink { +.Pet-Snail-Desert { background-image: url(spritesmith-main-12.png); background-position: -656px -1000px; width: 81px; height: 99px; } -.Pet-Snail-Desert { +.Pet-Snail-Golden { background-image: url(spritesmith-main-12.png); background-position: -738px -1000px; width: 81px; height: 99px; } -.Pet-Snail-Golden { +.Pet-Snail-Red { background-image: url(spritesmith-main-12.png); background-position: -820px -1000px; width: 81px; height: 99px; } -.Pet-Snail-Red { +.Pet-Snail-Shade { background-image: url(spritesmith-main-12.png); background-position: -902px -1000px; width: 81px; height: 99px; } -.Pet-Snail-Shade { +.Pet-Snail-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -984px -1000px; width: 81px; height: 99px; } -.Pet-Snail-Skeleton { +.Pet-Snail-White { background-image: url(spritesmith-main-12.png); background-position: -1066px -1000px; width: 81px; height: 99px; } -.Pet-Snail-White { +.Pet-Snail-Zombie { background-image: url(spritesmith-main-12.png); background-position: -1148px 0px; width: 81px; height: 99px; } -.Pet-Snail-Zombie { +.Pet-Snake-Base { background-image: url(spritesmith-main-12.png); background-position: -1148px -100px; width: 81px; height: 99px; } -.Pet-Snake-Base { +.Pet-Snake-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -1148px -200px; width: 81px; height: 99px; } -.Pet-Snake-CottonCandyBlue { +.Pet-Snake-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -1148px -300px; width: 81px; height: 99px; } -.Pet-Snake-CottonCandyPink { +.Pet-Snake-Desert { background-image: url(spritesmith-main-12.png); background-position: -1148px -400px; width: 81px; height: 99px; } -.Pet-Snake-Desert { +.Pet-Snake-Golden { background-image: url(spritesmith-main-12.png); background-position: -1148px -500px; width: 81px; height: 99px; } -.Pet-Snake-Golden { +.Pet-Snake-Red { background-image: url(spritesmith-main-12.png); background-position: -1148px -600px; width: 81px; height: 99px; } -.Pet-Snake-Red { +.Pet-Snake-Shade { background-image: url(spritesmith-main-12.png); background-position: -1148px -700px; width: 81px; height: 99px; } -.Pet-Snake-Shade { +.Pet-Snake-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -1148px -800px; width: 81px; height: 99px; } -.Pet-Snake-Skeleton { +.Pet-Snake-White { background-image: url(spritesmith-main-12.png); background-position: -1148px -900px; width: 81px; height: 99px; } -.Pet-Snake-White { +.Pet-Snake-Zombie { background-image: url(spritesmith-main-12.png); background-position: -1148px -1000px; width: 81px; height: 99px; } -.Pet-Snake-Zombie { +.Pet-Spider-Base { background-image: url(spritesmith-main-12.png); background-position: 0px -1100px; width: 81px; height: 99px; } -.Pet-Spider-Base { +.Pet-Spider-CottonCandyBlue { background-image: url(spritesmith-main-12.png); background-position: -82px -1100px; width: 81px; height: 99px; } -.Pet-Spider-CottonCandyBlue { +.Pet-Spider-CottonCandyPink { background-image: url(spritesmith-main-12.png); background-position: -164px -1100px; width: 81px; height: 99px; } -.Pet-Spider-CottonCandyPink { +.Pet-Spider-Desert { background-image: url(spritesmith-main-12.png); background-position: -246px -1100px; width: 81px; height: 99px; } -.Pet-Spider-Desert { +.Pet-Spider-Golden { background-image: url(spritesmith-main-12.png); background-position: -328px -1100px; width: 81px; height: 99px; } -.Pet-Spider-Golden { +.Pet-Spider-Red { background-image: url(spritesmith-main-12.png); background-position: -410px -1100px; width: 81px; height: 99px; } -.Pet-Spider-Red { +.Pet-Spider-Shade { background-image: url(spritesmith-main-12.png); background-position: -492px -1100px; width: 81px; height: 99px; } -.Pet-Spider-Shade { +.Pet-Spider-Skeleton { background-image: url(spritesmith-main-12.png); background-position: -574px -1100px; width: 81px; height: 99px; } -.Pet-Spider-Skeleton { +.Pet-Spider-White { background-image: url(spritesmith-main-12.png); background-position: -656px -1100px; width: 81px; height: 99px; } -.Pet-Spider-White { +.Pet-Spider-Zombie { background-image: url(spritesmith-main-12.png); background-position: -738px -1100px; width: 81px; height: 99px; } -.Pet-Spider-Zombie { - background-image: url(spritesmith-main-12.png); - background-position: -820px -1100px; - width: 81px; - height: 99px; -} .Pet-TRex-Base { - background-image: url(spritesmith-main-12.png); - background-position: -656px -1200px; - width: 81px; - height: 99px; -} -.Pet-TRex-CottonCandyBlue { - background-image: url(spritesmith-main-12.png); - background-position: -738px -1200px; - width: 81px; - height: 99px; -} -.Pet-TRex-CottonCandyPink { - background-image: url(spritesmith-main-12.png); - background-position: -820px -1200px; - width: 81px; - height: 99px; -} -.Pet-TRex-Desert { - background-image: url(spritesmith-main-12.png); - background-position: -902px -1200px; - width: 81px; - height: 99px; -} -.Pet-TRex-Golden { - background-image: url(spritesmith-main-12.png); - background-position: -984px -1200px; - width: 81px; - height: 99px; -} -.Pet-TRex-Red { - background-image: url(spritesmith-main-12.png); - background-position: -1066px -1200px; - width: 81px; - height: 99px; -} -.Pet-TRex-Shade { - background-image: url(spritesmith-main-12.png); - background-position: -1148px -1200px; - width: 81px; - height: 99px; -} -.Pet-TRex-Skeleton { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -1200px; - width: 81px; - height: 99px; -} -.Pet-TRex-White { - background-image: url(spritesmith-main-12.png); - background-position: -1312px 0px; - width: 81px; - height: 99px; -} -.Pet-TRex-Zombie { - background-image: url(spritesmith-main-12.png); - background-position: -1312px -100px; - width: 81px; - height: 99px; -} -.Pet-Tiger-Veteran { - background-image: url(spritesmith-main-12.png); - background-position: -902px -1100px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-Base { - background-image: url(spritesmith-main-12.png); - background-position: -984px -1100px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-CottonCandyBlue { - background-image: url(spritesmith-main-12.png); - background-position: -1066px -1100px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-CottonCandyPink { - background-image: url(spritesmith-main-12.png); - background-position: -1148px -1100px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-Desert { - background-image: url(spritesmith-main-12.png); - background-position: -1230px 0px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-Floral { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -100px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-Golden { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -200px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-Peppermint { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -300px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-Red { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -400px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-Shade { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -500px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-Skeleton { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -600px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-Spooky { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -700px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-White { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -800px; - width: 81px; - height: 99px; -} -.Pet-TigerCub-Zombie { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -900px; - width: 81px; - height: 99px; -} -.Pet-Treeling-Base { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -1000px; - width: 81px; - height: 99px; -} -.Pet-Treeling-CottonCandyBlue { - background-image: url(spritesmith-main-12.png); - background-position: -1230px -1100px; - width: 81px; - height: 99px; -} -.Pet-Treeling-CottonCandyPink { - background-image: url(spritesmith-main-12.png); - background-position: 0px -1200px; - width: 81px; - height: 99px; -} -.Pet-Treeling-Desert { - background-image: url(spritesmith-main-12.png); - background-position: -82px -1200px; - width: 81px; - height: 99px; -} -.Pet-Treeling-Golden { - background-image: url(spritesmith-main-12.png); - background-position: -164px -1200px; - width: 81px; - height: 99px; -} -.Pet-Treeling-Red { - background-image: url(spritesmith-main-12.png); - background-position: -246px -1200px; - width: 81px; - height: 99px; -} -.Pet-Treeling-Shade { - background-image: url(spritesmith-main-12.png); - background-position: -328px -1200px; - width: 81px; - height: 99px; -} -.Pet-Treeling-Skeleton { - background-image: url(spritesmith-main-12.png); - background-position: -410px -1200px; - width: 81px; - height: 99px; -} -.Pet-Treeling-White { - background-image: url(spritesmith-main-12.png); - background-position: -492px -1200px; - width: 81px; - height: 99px; -} -.Pet-Treeling-Zombie { background-image: url(spritesmith-main-12.png); background-position: -574px -1200px; width: 81px; height: 99px; } +.Pet-TRex-CottonCandyBlue { + background-image: url(spritesmith-main-12.png); + background-position: -656px -1200px; + width: 81px; + height: 99px; +} +.Pet-TRex-CottonCandyPink { + background-image: url(spritesmith-main-12.png); + background-position: -738px -1200px; + width: 81px; + height: 99px; +} +.Pet-TRex-Desert { + background-image: url(spritesmith-main-12.png); + background-position: -820px -1200px; + width: 81px; + height: 99px; +} +.Pet-TRex-Golden { + background-image: url(spritesmith-main-12.png); + background-position: -902px -1200px; + width: 81px; + height: 99px; +} +.Pet-TRex-Red { + background-image: url(spritesmith-main-12.png); + background-position: -984px -1200px; + width: 81px; + height: 99px; +} +.Pet-TRex-Shade { + background-image: url(spritesmith-main-12.png); + background-position: -1066px -1200px; + width: 81px; + height: 99px; +} +.Pet-TRex-Skeleton { + background-image: url(spritesmith-main-12.png); + background-position: -1148px -1200px; + width: 81px; + height: 99px; +} +.Pet-TRex-White { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -1200px; + width: 81px; + height: 99px; +} +.Pet-TRex-Zombie { + background-image: url(spritesmith-main-12.png); + background-position: -1312px 0px; + width: 81px; + height: 99px; +} +.Pet-Tiger-Veteran { + background-image: url(spritesmith-main-12.png); + background-position: -820px -1100px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-Base { + background-image: url(spritesmith-main-12.png); + background-position: -902px -1100px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-CottonCandyBlue { + background-image: url(spritesmith-main-12.png); + background-position: -984px -1100px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-CottonCandyPink { + background-image: url(spritesmith-main-12.png); + background-position: -1066px -1100px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-Desert { + background-image: url(spritesmith-main-12.png); + background-position: -1148px -1100px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-Floral { + background-image: url(spritesmith-main-12.png); + background-position: -1230px 0px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-Golden { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -100px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-Peppermint { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -200px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-Red { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -300px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-Shade { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -400px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-Skeleton { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -500px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-Spooky { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -600px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-White { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -700px; + width: 81px; + height: 99px; +} +.Pet-TigerCub-Zombie { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -800px; + width: 81px; + height: 99px; +} +.Pet-Treeling-Base { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -900px; + width: 81px; + height: 99px; +} +.Pet-Treeling-CottonCandyBlue { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -1000px; + width: 81px; + height: 99px; +} +.Pet-Treeling-CottonCandyPink { + background-image: url(spritesmith-main-12.png); + background-position: -1230px -1100px; + width: 81px; + height: 99px; +} +.Pet-Treeling-Desert { + background-image: url(spritesmith-main-12.png); + background-position: 0px -1200px; + width: 81px; + height: 99px; +} +.Pet-Treeling-Golden { + background-image: url(spritesmith-main-12.png); + background-position: -82px -1200px; + width: 81px; + height: 99px; +} +.Pet-Treeling-Red { + background-image: url(spritesmith-main-12.png); + background-position: -164px -1200px; + width: 81px; + height: 99px; +} +.Pet-Treeling-Shade { + background-image: url(spritesmith-main-12.png); + background-position: -246px -1200px; + width: 81px; + height: 99px; +} +.Pet-Treeling-Skeleton { + background-image: url(spritesmith-main-12.png); + background-position: -328px -1200px; + width: 81px; + height: 99px; +} +.Pet-Treeling-White { + background-image: url(spritesmith-main-12.png); + background-position: -410px -1200px; + width: 81px; + height: 99px; +} +.Pet-Treeling-Zombie { + background-image: url(spritesmith-main-12.png); + background-position: -492px -1200px; + width: 81px; + height: 99px; +} .Pet-Turkey-Base { background-image: url(spritesmith-main-12.png); - background-position: -1312px -200px; + background-position: -1312px -100px; width: 81px; height: 99px; } .Pet-Turkey-Gilded { background-image: url(spritesmith-main-12.png); - background-position: -1312px -300px; + background-position: -1312px -200px; width: 81px; height: 99px; } .Pet-Unicorn-Base { background-image: url(spritesmith-main-12.png); - background-position: -1312px -400px; + background-position: -1312px -300px; width: 81px; height: 99px; } .Pet-Unicorn-CottonCandyBlue { background-image: url(spritesmith-main-12.png); - background-position: -1312px -500px; + background-position: -1312px -400px; width: 81px; height: 99px; } .Pet-Unicorn-CottonCandyPink { background-image: url(spritesmith-main-12.png); - background-position: -1312px -600px; + background-position: -1312px -500px; width: 81px; height: 99px; } .Pet-Unicorn-Desert { background-image: url(spritesmith-main-12.png); - background-position: -1312px -700px; + background-position: -1312px -600px; width: 81px; height: 99px; } .Pet-Unicorn-Golden { background-image: url(spritesmith-main-12.png); - background-position: -1312px -800px; + background-position: -1312px -700px; width: 81px; height: 99px; } .Pet-Unicorn-Red { background-image: url(spritesmith-main-12.png); - background-position: -1312px -900px; + background-position: -1312px -800px; width: 81px; height: 99px; } .Pet-Unicorn-Shade { background-image: url(spritesmith-main-12.png); - background-position: -1312px -1000px; + background-position: -1312px -900px; width: 81px; height: 99px; } .Pet-Unicorn-Skeleton { background-image: url(spritesmith-main-12.png); - background-position: -1312px -1100px; + background-position: -1312px -1000px; width: 81px; height: 99px; } .Pet-Unicorn-White { background-image: url(spritesmith-main-12.png); - background-position: -1312px -1200px; + background-position: -1312px -1100px; width: 81px; height: 99px; } .Pet-Unicorn-Zombie { background-image: url(spritesmith-main-12.png); - background-position: -1394px 0px; + background-position: -1312px -1200px; width: 81px; height: 99px; } .Pet-Whale-Base { background-image: url(spritesmith-main-12.png); - background-position: -1394px -100px; + background-position: -1394px 0px; width: 81px; height: 99px; } .Pet-Whale-CottonCandyBlue { background-image: url(spritesmith-main-12.png); - background-position: -1394px -200px; + background-position: -1394px -100px; width: 81px; height: 99px; } .Pet-Whale-CottonCandyPink { background-image: url(spritesmith-main-12.png); - background-position: -1394px -300px; + background-position: -1394px -200px; width: 81px; height: 99px; } .Pet-Whale-Desert { background-image: url(spritesmith-main-12.png); - background-position: -1394px -400px; + background-position: -1394px -300px; width: 81px; height: 99px; } .Pet-Whale-Golden { background-image: url(spritesmith-main-12.png); - background-position: -1394px -500px; + background-position: -1394px -400px; width: 81px; height: 99px; } .Pet-Whale-Red { background-image: url(spritesmith-main-12.png); - background-position: -1394px -600px; + background-position: -1394px -500px; width: 81px; height: 99px; } .Pet-Whale-Shade { background-image: url(spritesmith-main-12.png); - background-position: -1394px -700px; + background-position: -1394px -600px; width: 81px; height: 99px; } .Pet-Whale-Skeleton { background-image: url(spritesmith-main-12.png); - background-position: -1394px -800px; + background-position: -1394px -700px; width: 81px; height: 99px; } .Pet-Whale-White { background-image: url(spritesmith-main-12.png); - background-position: -1394px -900px; + background-position: -1394px -800px; width: 81px; height: 99px; } .Pet-Whale-Zombie { background-image: url(spritesmith-main-12.png); - background-position: -1394px -1000px; + background-position: -1394px -900px; width: 81px; height: 99px; } .Pet-Wolf-Base { background-image: url(spritesmith-main-12.png); - background-position: -1394px -1100px; + background-position: -1394px -1000px; width: 81px; height: 99px; } .Pet-Wolf-CottonCandyBlue { background-image: url(spritesmith-main-12.png); - background-position: -1394px -1200px; + background-position: -1394px -1100px; width: 81px; height: 99px; } .Pet-Wolf-CottonCandyPink { background-image: url(spritesmith-main-12.png); - background-position: 0px -1300px; + background-position: -1394px -1200px; width: 81px; height: 99px; } .Pet-Wolf-Desert { background-image: url(spritesmith-main-12.png); - background-position: -82px -1300px; + background-position: 0px -1300px; width: 81px; height: 99px; } .Pet-Wolf-Floral { background-image: url(spritesmith-main-12.png); - background-position: -164px -1300px; + background-position: -82px -1300px; width: 81px; height: 99px; } .Pet-Wolf-Golden { background-image: url(spritesmith-main-12.png); - background-position: -246px -1300px; + background-position: -164px -1300px; width: 81px; height: 99px; } .Pet-Wolf-Peppermint { background-image: url(spritesmith-main-12.png); - background-position: -328px -1300px; + background-position: -246px -1300px; width: 81px; height: 99px; } .Pet-Wolf-Red { background-image: url(spritesmith-main-12.png); - background-position: -410px -1300px; + background-position: -328px -1300px; width: 81px; height: 99px; } .Pet-Wolf-Shade { background-image: url(spritesmith-main-12.png); - background-position: -492px -1300px; + background-position: -410px -1300px; width: 81px; height: 99px; } .Pet-Wolf-Skeleton { background-image: url(spritesmith-main-12.png); - background-position: -574px -1300px; + background-position: -492px -1300px; width: 81px; height: 99px; } .Pet-Wolf-Spooky { background-image: url(spritesmith-main-12.png); - background-position: -656px -1300px; + background-position: -574px -1300px; width: 81px; height: 99px; } .Pet-Wolf-Veteran { background-image: url(spritesmith-main-12.png); - background-position: -738px -1300px; + background-position: -656px -1300px; width: 81px; height: 99px; } .Pet-Wolf-White { background-image: url(spritesmith-main-12.png); - background-position: -820px -1300px; + background-position: -738px -1300px; width: 81px; height: 99px; } .Pet-Wolf-Zombie { background-image: url(spritesmith-main-12.png); - background-position: -902px -1300px; + background-position: -820px -1300px; width: 81px; height: 99px; } .Pet_HatchingPotion_Base { background-image: url(spritesmith-main-12.png); - background-position: -1033px -1300px; + background-position: -951px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_CottonCandyBlue { background-image: url(spritesmith-main-12.png); - background-position: -1278px -1300px; + background-position: -1196px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_CottonCandyPink { background-image: url(spritesmith-main-12.png); - background-position: -1082px -1300px; + background-position: -1000px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_Desert { background-image: url(spritesmith-main-12.png); - background-position: -1131px -1300px; + background-position: -1049px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_Floral { background-image: url(spritesmith-main-12.png); - background-position: -1180px -1300px; + background-position: -1098px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_Golden { background-image: url(spritesmith-main-12.png); - background-position: -1229px -1300px; + background-position: -1147px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_Peppermint { background-image: url(spritesmith-main-12.png); - background-position: -984px -1300px; + background-position: -902px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_Red { background-image: url(spritesmith-main-12.png); - background-position: -1327px -1300px; + background-position: -1245px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_Shade { background-image: url(spritesmith-main-12.png); - background-position: -1376px -1300px; + background-position: -1294px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_Skeleton { background-image: url(spritesmith-main-12.png); - background-position: -1425px -1300px; + background-position: -1343px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_Spooky { background-image: url(spritesmith-main-12.png); - background-position: 0px -1400px; + background-position: -1392px -1300px; width: 48px; height: 51px; } .Pet_HatchingPotion_White { background-image: url(spritesmith-main-12.png); - background-position: -49px -1400px; + background-position: 0px -1400px; width: 48px; height: 51px; } .Pet_HatchingPotion_Zombie { background-image: url(spritesmith-main-12.png); - background-position: -98px -1400px; + background-position: -49px -1400px; width: 48px; height: 51px; } diff --git a/common/dist/sprites/spritesmith-main-12.png b/common/dist/sprites/spritesmith-main-12.png index 7938d749c1d47b1013855132ce32a638fb723bb8..50e98d9e058b9cbe9fcdfdf29d1b2083ae1d2602 100644 GIT binary patch literal 125673 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ν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

k5qUoaSjK(Q!%IFBrzBF0VObmEW3wO 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 HQ$iB}NTGIM literal 126575 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 diff --git a/common/dist/sprites/spritesmith-main-5.css b/common/dist/sprites/spritesmith-main-5.css index 50f8d8a69a..e7245d2efd 100644 --- a/common/dist/sprites/spritesmith-main-5.css +++ b/common/dist/sprites/spritesmith-main-5.css @@ -270,7 +270,7 @@ } .shop_armor_special_candycane { background-image: url(spritesmith-main-5.png); - background-position: -1674px -451px; + background-position: -1674px -410px; width: 40px; height: 40px; } @@ -282,19 +282,19 @@ } .shop_armor_special_snowflake { background-image: url(spritesmith-main-5.png); - background-position: -1674px -656px; + background-position: -1674px -615px; width: 40px; height: 40px; } .shop_armor_special_winter2015Healer { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1230px; + background-position: -1674px -1189px; width: 40px; height: 40px; } .shop_armor_special_winter2015Mage { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1271px; + background-position: -1674px -1230px; width: 40px; height: 40px; } @@ -456,13 +456,13 @@ } .shop_shield_special_winter2015Warrior { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1517px; + background-position: -1674px -1558px; width: 40px; height: 40px; } .shop_shield_special_winter2016Healer { background-image: url(spritesmith-main-5.png); - background-position: -1599px -1599px; + background-position: -1674px -1476px; width: 40px; height: 40px; } @@ -1098,103 +1098,103 @@ } .shop_head_rogue_3 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1025px; + background-position: -1674px -984px; width: 40px; height: 40px; } .shop_head_rogue_4 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -984px; + background-position: -1674px -943px; width: 40px; height: 40px; } .shop_head_rogue_5 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -943px; + background-position: -1674px -902px; width: 40px; height: 40px; } .shop_head_special_0 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -902px; + background-position: -1674px -861px; width: 40px; height: 40px; } .shop_head_special_1 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -861px; + background-position: -1674px -820px; width: 40px; height: 40px; } .shop_head_special_2 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -779px; + background-position: -1674px -738px; width: 40px; height: 40px; } .shop_head_special_fireCoralCirclet { background-image: url(spritesmith-main-5.png); - background-position: -1674px -738px; + background-position: -1674px -697px; width: 40px; height: 40px; } .shop_head_warrior_1 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -697px; + background-position: -1674px -656px; width: 40px; height: 40px; } .shop_head_warrior_2 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -615px; + background-position: -1674px -574px; width: 40px; height: 40px; } .shop_head_warrior_3 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -574px; + background-position: -1674px -533px; width: 40px; height: 40px; } .shop_head_warrior_4 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -533px; + background-position: -1674px -492px; width: 40px; height: 40px; } .shop_head_warrior_5 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -492px; + background-position: -1674px -451px; width: 40px; height: 40px; } .shop_head_wizard_1 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -410px; + background-position: -1674px -369px; width: 40px; height: 40px; } .shop_head_wizard_2 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -369px; + background-position: -1674px -328px; width: 40px; height: 40px; } .shop_head_wizard_3 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -328px; + background-position: -1674px -287px; width: 40px; height: 40px; } .shop_head_wizard_4 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -287px; + background-position: -1674px -246px; width: 40px; height: 40px; } .shop_head_wizard_5 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -246px; + background-position: -1674px -205px; width: 40px; height: 40px; } @@ -1296,7 +1296,7 @@ } .shop_headAccessory_special_bearEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px -205px; + background-position: -1674px -164px; width: 40px; height: 40px; } @@ -1314,31 +1314,31 @@ } .shop_headAccessory_special_lionEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px 0px; + background-position: -1599px -1599px; width: 40px; height: 40px; } .shop_headAccessory_special_pandaEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px -41px; + background-position: -1674px 0px; width: 40px; height: 40px; } .shop_headAccessory_special_pigEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px -82px; + background-position: -1674px -41px; width: 40px; height: 40px; } .shop_headAccessory_special_tigerEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px -123px; + background-position: -1674px -82px; width: 40px; height: 40px; } .shop_headAccessory_special_wolfEars { background-image: url(spritesmith-main-5.png); - background-position: -1674px -164px; + background-position: -1674px -123px; width: 40px; height: 40px; } @@ -1464,55 +1464,55 @@ } .shop_shield_healer_1 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1066px; + background-position: -1674px -1025px; width: 40px; height: 40px; } .shop_shield_healer_2 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1107px; + background-position: -1674px -1066px; width: 40px; height: 40px; } .shop_shield_healer_3 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1148px; + background-position: -1674px -1107px; width: 40px; height: 40px; } .shop_shield_healer_4 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1189px; + background-position: -1674px -1148px; width: 40px; height: 40px; } .shop_shield_healer_5 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1312px; + background-position: -1674px -1271px; width: 40px; height: 40px; } .shop_shield_rogue_0 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1353px; + background-position: -1674px -1312px; width: 40px; height: 40px; } .shop_shield_rogue_1 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1394px; + background-position: -1674px -1353px; width: 40px; height: 40px; } .shop_shield_rogue_2 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1435px; + background-position: -1674px -1394px; width: 40px; height: 40px; } .shop_shield_rogue_3 { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1476px; + background-position: -1674px -1435px; width: 40px; height: 40px; } @@ -2056,12 +2056,6 @@ width: 64px; height: 54px; } -.ghost { - background-image: url(spritesmith-main-5.png); - background-position: -455px -1144px; - width: 90px; - height: 90px; -} .inventory_present { background-image: url(spritesmith-main-5.png); background-position: -1616px -1049px; @@ -2178,7 +2172,7 @@ } .inventory_special_opaquePotion { background-image: url(spritesmith-main-5.png); - background-position: -1674px -820px; + background-position: -1674px -779px; width: 40px; height: 40px; } @@ -2200,7 +2194,7 @@ width: 57px; height: 54px; } -.inventory_special_spookySparkles { +.inventory_special_spookDust { background-image: url(spritesmith-main-5.png); background-position: -1299px -1508px; width: 57px; @@ -2244,17 +2238,23 @@ } .seafoam_star { background-image: url(spritesmith-main-5.png); - background-position: -364px -1144px; + background-position: -455px -1144px; width: 90px; height: 90px; } .shop_armoire { background-image: url(spritesmith-main-5.png); - background-position: -1674px -1558px; + background-position: -1674px -1517px; width: 40px; height: 40px; } .snowman { + background-image: url(spritesmith-main-5.png); + background-position: -364px -1144px; + width: 90px; + height: 90px; +} +.spookman { background-image: url(spritesmith-main-5.png); background-position: -273px -1144px; width: 90px; diff --git a/common/dist/sprites/spritesmith-main-5.png b/common/dist/sprites/spritesmith-main-5.png index 80a66cd2abc10bfdb13863b0ecdbe413bbaf5c5a..cf85f9fe03b0b3943311b1fb93ece7bb2fcea371 100644 GIT binary patch literal 261273 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 z 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%0A`6N2Y;eFShAMKc6|`f}zFc=Qk)QTlywdXC zj;Qd8kizm(nq+!N)7xV^?i9Bws zIjvgrW#Q*`WWI*=3_|EzB;QE#zq3`@l=;Yvc2d^=6fO&u-hU7QyC-c8X<3DO5?Ehq@Eph38Iy=7WBtD|DKTIl-yv9iKy|P)d ztz^j*Z4TW6wZjWkMZ4_6HrSc0+u;2(qe%c9-+n>?5MOpR!~$ThbwQnia%~+==qmO^ z#qfV6j&(&2IQdycg-0@DwT{VhmVBWTu}5FsQ?b0e?)i2)Nxn%+nTV~mnJMZiv^Sa) zEnhk@IO?r#HqdaFDQb_kH}03a%jhrzu$b_9bzz}C*aP^bD{`dx==F?eHFHtNWkx(- znzmW9toz6#LfU~u7qG#P%(FMzJNoTn2~yblRWjFxDx&XzeZ?v};&tIy#4_p;!!)8l zdug2&*s@o>kn%%W$u*dS2Ke1P2~kyBj&XjE%us+J~}6m-GP>t#iE(_D=~)cNF#?V>Pb<&|MRDtTJgI;XQ!FpXXfWW zoH?I41lo2z1sd*}7c*{c8v;5gOFN<@dpmSLd}Wi@YDLD{z29E#R>&15h^$A(ciPky z%2opw9V~h@=vX{b`Fg@K&ZX_ezoff2!ki6_ACLQ4{J81-{(D>nZ}QL<T7)k8#ZvJ#|xyb>sakda>FYbug=s*lCKdU%pVXFJWo=#RioAf1tulAkz z%?*+^0^ol>pMLNI0n%)IM)sATKH&h@@*j6O0wsNCqh_;;*DZSpg=$d2DBu9BBD*n; z2+j~z=mYwW42T>z90<4$eTki6oGewRf(1zXUWLjV!HT$y;HHR@_s`iTy$6JcR3O6D z-Cv$0ym>9fTRTnNYpFRGu*)@7Ylg)K9517!E;-z5PHP`~`eNPTqcy#YhD zm#`oJp6wS3ko%?RmMJEN-vo)L8sj!6szD1s332@mN>KFC#I(uwt=2J0333SPE--&9^z4PZkW2_vGK2%;$R> z&Z=1X?T8qH?dMA9tNuc56AJC7%xW4xKS@P&kZ&F847Y<1#c`7e-`69(nH$+P()J^f zKnIe_RMkZ$P1llsJ-mvl(qraisc^{)0xh;;MpYYzj}C_)yH7^D4(GRvJ-R#Xh(0W~ zwY4(K%=1%!suBmA2-6-m)3lp@@uNInHeiNqwUc|JK)-%y00<|!Gin?`Z+uONwQ4DH zXdzXcgPG?KP0MZdMZd(5HTsmDmoV3qaT}$LLzTxx?DJKH+LmFge7xl*N>6Y1JL8l< zlIcFAR|P(#{|X{#AfZVT8J;piig18Wlr+lm%ARoG*$D;+?Z2MZ?WD$=N7>_-Uw8?5 z-=fyrBZ~un;F3)KR`ep=>1r0B%KrvXs416I^QSl`VwL+5JW)6iRC@LUUY?r!I|}Z> zox(74e^>Atmz4&#$dn`Xv)B&eqxY%r?MTwiSzts*+>7bWiznpbwm@=XOB}f)rW4Bx z@kR908r3!2^X;WR=R%dFjVuLPg)MGIT6|f9n@owt=5P4?z90AEy(O=VFpK7AI$XuKPTyvnUNsXt-LEd( zULR}v`T8(+tarjOdb0O>eox<;{VI^VN)jurf9^!(OKy`mjwg(sV8ttB)#xK)?EMHU zjJkGdSCQ)b?(8vm7kA{+WaQICpzcUfvZFXKtF$@wg3;q4@s+w)B5i1x^jPZD{6@@L z{A<7Y%-}IqZ(W_#;Hp@$E=xkXx^JehnpnvaDF_{->?)o5zY2=!in5>#&J%4M#WURc zX|a1W#P#Q!k1x(|#LoyTIhlGQCY%_BOx!qj1?L?(yZw0mYPAm6x4EYGvuMGaK^E+p zGOdIgBdCF;amkE+1oln~P{L)Gxn#~x!e;C>y;imSyDM(sY@>wB##Qw(&DkcL_X*3B zf6_6Pq|yl*{Q|4J(vu5)-7dKDD7mf$>7en7_8WS|64+2n8s8%|@+u=ux4c444{C56s*Rs0+;79HEdTf`-xKKSA@KS7=a8AqM_C(m+4RfsHSiRAR40 zPB`cC-@o|XwXWWJJzV^{%@`1d1>965q_=*Z`TE3|wg|`f;}s@ei^Rg;l*XbI zdJ@Gd_!bgc$}Y+67Fx2|m~=U59Y37pfUN!))XNyym&}7B*mvYqcaUEb?9aG#N#JxU zywR2Q+xhYma4g$SkSgo(nBS&ctV#~FDo(oGxJu->r;G8z8;X~ zF6u>@t?X?Z76nybdDQ;^0jt|c%$hF zU*E5gA^>G4983Wr5I|K_PA8rTPbCgdDV$08T@++k&cKI%m{_|AXaeuTlD(P51HbNF zzoE}d{%#?XTF?XEy^g-L`j|h5OxKNd85zNp;cEsha}^FOY4|A%bEM^&n^$f5PG7-8 z6v?{FOL?V$)3s75Y4HjXJWUJ6+@_t}SHvGf&0fK412 zu?7Y7PtyJK%7g&81gO;@OU|orr!~H~tS`GBgai(r9#XyB6}(lo>}9ciutv9;@8TmKpG63vVG+ikIslN&`qo0a#6ofQ z@evkko&0uALOZd240bw^v1Kgm(M>BCsH;sxiw<>Yzzz&5Y__;7wRlsQ1=L_~9@=yV zIj3!*J{>k`4ed8Y7ctN*8JqwA=z8m@D8DvrRJyy74v`M&Mmi)!V(1to2BZY(kZvRf zlm_V>QjzYG5{98mq&p?Q$6vhfd%knd+N_!94;HNT?0esFUDthY6pVuYAUQ9;4njZI zUrcSczp&#oTOO%G2;$)Q=vj0=W_8gh$(j+}%Vu4SjUy8=kLcsyEWhiBf7VfT*w(!i zLjk*)Kdd>-h(Z3Q>DLRL{f4UiZffN$UcA1Nr+fAMnW9wb_fuu7-CG^RR|~Gj?{}2* z9OnIfWd__bnRw%NnW#tGtazD-mZY27kAn@Qv@;Vbn+gP zGTO;Yv^Yr*!M*ma{1H-ZPk)Pz#Q37=sTSzic=qOR9bZ|wV#XVUt!X<+UI_PC-w+2n znhKzu5~EwW4!MlP`O-aDc0S4eG|OD$cTtBhJfengw8d{II$O-fez@96{wP`t!_9I2 z-rxCsRG;U6(KuUgAV=EgJ*Yk2G zCaB8aP&qD(Qo!xOK6hU*a7Ef?tGfk6rowI>+XFg}%rc7`n2jOghamakM6dRa&Wzt8 z|KzPun8J*mWVTE~b@8+XJTla-qGNdp!F!J3cZ~q(aFVh@9_eL)6>M+ZloJ&b6)D;efZ?0QiL0KLDcmX^oG`RnbjfDDLHDwjydfDq zow25Hqn-1TU8_gSJ6UnPKZ)6;%Yv>!rc-h47tJW%xw!H+M{@n zCD(~uM@K)0qJ_H~)ej_8pQDWwc#MJ(*B*QgZxl>U#TBM`cA&+7LdDbg1q=PgdX5gg)4?;n=^1;e;N! z)**>rUU6-?hHrzPOJyhC``3MX+R^8r3UB^HOPyPVDV8soJ>vRecsU~JumW*xi8OSp zegUnKIQk&8U!CbWM@;t)aurpM^Xla7k=O1%8Jpyh$lcu{-g)rPauj)3; zty$o*dbe+fQiJB^OWzKZRg65}?67_~OPW>50pXpFi1&Oikvm)9=ovA_9n1977#vd2 z_QY2!+tIG6Ar2$c!yFrsd#j1m^`p*apmz6(nQn+uIRg|KBwiU*kvqkL`m4w|iBTT9 z^fbFVZLb=V(?~)g`^QlKcQO9SAc_82|N23=R0NmDfuN6}JoovnZG9OauqSIZa9Ji)dPO&#~iX{jRVOa3`>)zVr8&SONB z9lNjb{HYUw<0*Z2ZPmdbOpCzD?lem$B<2?2=fWjH4&j^NCDbia>=t6cP7bl=7h^%L zl9Nl{+jd{N#5jLpG<(ltO8m>%MEze zluf#Tn{hB{tMgdIIvKVtQ{K9Ow};#;45->9CF%PK^w*B69B5|Y(bey&pU*>I);kUL zO>obOwy>kzh!n1N*#5S=dCTuvl@rrAX2mDr#J59ylMi3O5GqB{NnJ2ssOCTK%m3V# zo+^%dY3%U&cuC&KtVz`(EjS^D&B1K8?svu~-I_{%(i;clV{d=LISzl9-1tQ^>fn9u z07Qcz;kPuc?ndaNy;6dXo%ac_(iK|A0bd+bA#_pL`c_{f|Fd(vSD&TA9CQOKH0eS; z?B!uxPL!jLu^nw>-lEMgn$s8Z+GH}Mc(875`e&pDARb?;D#3Z;-L#(2w!6R; z>pvFQJp8_Qcx%KzO?5Uj9(7r(3~i~FM6W=2Q}12`CU`id)m-BCXealN`Jr$-+XhJb zu!a6g?UHH23=j)D(ioGs2%yS$i{`$vC+yFkcHnMpnJb`JmFE&a_Gmz%TdoSOoaxwg zwMTV`qT~{4u17DF&UkOwqHfK$`@u%tlnVkwYp8JA)z?J}j$P|Wq$ zojbrIPVG{qfvOawWZZrWvpUqg>`tMss9+=uCi_qL{VNRnLx1cZ`-XEF^KiY4t1yo@ zcvxmmSP+EQl+^Xk-5bku`w%b=G#lqwH|9dQy=;bkspa(9u9^QXetZ)f)^Ba7mQ)nxO$t;Mo18K-K*fN|jkF2cbHrht2MfS`P@ChRbKM06RMAWf>fE+NwU- z9iC{jHC4QmdANTLDbB!JWN)3bHOGb1XlE7PDe7z5Il6?8>?mkPdVS{|Ydvj--~;A2 z?g%4`onAWn*c&gcCpZC56MJ_(UC+jc5*uLbRPz~KHSNNf80B{^^^BAekL7FH88PQS z`J=sd?YXWPB=CKV4C8oUb{q+Oyz90^8rho{B@d5Hnc)}F>0`$SWz=SqCVDvdQ%Wyu zhu?9Y!bCSz8^^lQz|)EZooipD(=&u05vawFt1H>4NH3X5`YoCY&R7)F)&ZQa;mzqn zT^`y#i~1O+grnq@l>Xsw^5J*c{rB!26q_K;EV6x`a0Wq7dBXrI4?J$-SXMYZf7lo83AT3upWXk3(fV@I20w45LZG*Us zcdYI{0m1Zl9y*3`z7h+>qr4(mPaWKz(KmO}=vZEGqxDtdchu#RKY8F0CE&T@U}P2QSK87+60t#}#GJq{VyKBF6%Y^O)Z z+l%RVM6YpoI=qHB!Kd0xsT#~lj67adTD1?IxtEt=-`xsSRQR|qcN z^_%>Q4@wYHqE=HunlKtvD_W^@ZfV=iezGO1-F{AFm{-+GSvs5R=MUhDaTULGQH-|} z3lWV{!!BQ($nf_}hl)5|KF7UyCt^L;Y$N)`l?>nMQ$j1`wbkWZ7@{tZdVYqxZxl}t z0b?Y2Ho4Tf5IO@By;tNCzI?9dr#012pkTW2XzB^RlHN7n?FZU!dO_l5d$6NV;+;cka%zo`GC|p=aQ0Z6Uz$NRXLkj(= zDX-f|PiHX}h+uHGHXBMVQQMD*4yCjE?uCaJRh=tv>fs0Dp)QsYTAd0v^T^V5l~gEVJ|VLg{Pd2z&lONzgr zfWm_GzggJd1_M7tsh#q&!ZGt8tqs_3s1v95ka}wTYku<0JCZL`00-z%UjfXxO0?l_ z3DBz!@SkWm=RMo`E!uoZ7v6t^r<|}lwhHUi%G>N<+?swz=yO3EB#nZ;?#%VD@<{k? zx-3bSCL7=G`1UhVziVTihn*Ob<@KMX&MvpZxlg@@6B`CL*>o0$n4NeTJdXn z%s%kPv?{DH6Hwz_06Iv+SaB@RIf$#E%9rJVT@BTIk?9cV0x#$nY~!0-iq(%XneSau zaoeKbMni>ccr9M6)){8pFd6A8zKX2RN1g7W2vX5r&#gKv3+k35m2*Tum_VdewqC%_I*#2PWBno zuO|tzidMW_0_Qy+e4J^@j(e+~$c(6Yp`Rva%ZL3I<5JrR8%mMRw}qP{PU$@?`bXdp zH5EBWBXnfqZlV;LC>2i`9$_l>RaYiJuBfIZ>TRgJq^s#Z!&vS=pt?lD>GbRE=aKBV z&mY|x?Z4If`$WEY;DsLC(|!NOn`<+6d3AE7nkjY4jUVVSiJV_XNz*H7+~~WqC(?c; zdYN;LpLIT*C1h8NR_iFo4LJo+;i=ru+0HnIrLL-YYVh$vBVa3ivV_jJ)ims-)hhNA zxV;o*eEw@D|5l!+p+$`cTqWlatm%RL)8@K>cmOXpK8j|D`DYC)4LC@=gYRUF1iLS< zAIdA`-UO{uPT5jkU(&?h-_iF6A!+5_tYmNTf2Kgk+YRmv`DU%s-foQu>K{**TC%lp z7T)Ld85?M=qB+_Sky%5`a*77*U}TDVV6kZh#QZ4c9cZ-`)J&{jbcyA5KU-j>k-2FN zDvOaZA=jmo?pRS&59rV{dHui^9=k3-YLD>rN}!!UQGS`<@XX{fa?PD`ZJ}9G_V;(- z)e;Xof98uS?ydrfY{k3fZXoRp5@`UpwgrSl#9zSlZ z6}{=}M`@y2-Oc;+-9*&)3wiidOlPN%lo|u@ZF~87t##%|3^SKZr{1s0Z*QLy8*9?7 zjYip6y76+U?M10A{oq)|dc`6c@+l>)rmhp^TSiMwbuU^B@pS=D5$}%mjU~*R;TKDS ztmV$>mX&j`!2gTSt#EDG&!1a_j29HsDR#0WCQ-FqkJ z)vEWJO5AOD3F5vyjG%e@V2iq-Ke%aNjIOgKxIcU>x0ARj7zlRnsNVP&SW7zKFq}nZ zhFc6lZ7wpFu4iKd4rNjiCm~&P(#9cN9^W~I3`;jL1N(%@_ygfhfP4{x96#*9zF|47SQ(y353UbH^H~gIyHJs z+R*$qDY=8IAX4rOJ>%n*)`-vp@2~Xx921;q=EsL(MIRVrWSn2`E$d{>8Cbxd>%QX> zkm3Z$i1HlDBp2sXmZ>6KMR}9J55Hml+H^9QHlS*g5UxrhOITE-fDO}}Nza>2CP^_g zqEb~%&3I+IPas*S{*0NjTAP;z-_`*Z_&5)b)fa>1dv*w7k{CSl5vQ`q->^d&=6T49 z1oF5EUR*||DUJ{H=&X%hnAc(;?-$J?KM}iWO+S$Mz5XJ>cFaC<&e*Bg`Chc2Guw0I zztHU8Pe9G417HD6f?eUOv)(moJ<~U2W!l$;_Hn z84snd7TAPyZa+0EO}Ny=S?M8Lxcwe^`P_}LHS=x$MUp=5x#ulc z&E^&>jTB0K7AijMrg+TMDnVjM3ij5q%p(y(xP4%oVBY%yDDh+JD0&Fr8&pT{N4L{+ zbr!1c^${?&C_#H5lCbkDrPquMPbiRFKOn~l0;%5cOZ2=?1Voh2NO#@M`op{O5GL0w z4#_zkv7h@Bb8erU(a6^?BBaGE{gZj6ozA-Bf4S^cBy=pHlU%@iES+s~Dc26YLI|Km zQgQHw;_*a&!qg}!?bIof)Z+we?>>eK71|&@dwCs_8H!Z)u{=rF&j^BGMVg99UrX1Q zyi+~+U7OR{h?koY`*9x-25CX94aZmYLNvxkC^fA^WWGDaRtDBI(xorDi^7RvbiQ$@ z1uO(PcH;v<8wYzRo37F>t``)dEG`jJbW{}h$zo(W6IjSKYa~g8R8(4+fQj%%Cm~#@|Kf*|gmol$THYccrzfZr0$SWJ=>hRhl0w5oBnkl!Rm09rQL>x1& zy5#&y7z|i~+H9ElMI_|(2S1a^-IuKS$v0l}fDNY=?Yz+H8!#83$#2Lunz6XVxTS7i zn0>jq4BK{L} zVxA`|=4_n#j4RqXK%Q5cha1S8o5^howMH!Lj=|#Lk68X8l9&dXUnEX@u9hK#;LEYi zvTRf!uI_27JybBQ#3{413?7X*5{)zt%^msGNlz0pJGg?+EQG5&WbIhKxV;|+M6`%< z1Ja(g++Oe1_fJ21fbcVl*R`2edgK_O=C6d2*9#EMg4p2iD)iA|SRvz-A~YE|%YE1H z8IkMCNa(w7N0yLlaa+^NrzwNF~=mY3`vo=k|Mdyc|QaerR z_+P*+8G}?DgfYeZ4b?#v@E&R8+A=F2Yt21v4Xx`(1Y0cX+#Fc$wb#+v2IpAvuAhfc zj31-G>cptOm07vu^6Law_{U{4N$gnH6iTDy2tuQQ0t%vjoNclXjH|A|c=F1h|Q8vw*(}R^+b;LV-JI8Mv43P6SUT3V@ zfX48D3X$o79R>$b2)i*9c2R8h%kct3t>W%aB5|=yKH&3$%%if*K#wY4O-oOyB)^aI z{KTQfN0&?pTzZ*TkZ7ZxRX(cSzt;pz2k21C61mpn&>7>s^ja%|ZG3IqLqQ^zBk#nL zBM;-b0)m96kL85-i$li$CAv?-T6EJ~S5g+rcZ!^qjF<(fvmTKbu=?rtYx+m5+Xh(| z2lKXFqZe!D^lv}g{K>fQRn09Onh7h%1zo>E1YR@EkBW$%oU`-j?>+D$8}K5@3^mjS zd_moi{)W^lcks2Faacl-xYN~@i>9_7Ss|9od+;z<^xlR`#ryGaj7(Sads|rPPsLZo zXYz_Ck4B>W$pbBKu4`Ctpc)KeuUn7*mXtPc={bK^p{kO z+7Ih|PkmiA0xs3VIA}$%-2hFz;$Sx)=YjnQe|vbK?d~1PFg<7|U#tvI(SPJr)MP&< z#ADqxcN#6@u|N$>m@MOy24b9asBtq)KCTMU+C$}Q@MW!zIvF8D6?>LHn)-;=Gce^8 z6{_munZd!J#%3`ZboGTrq4okZCObT`M>{m5xHB^)vgOtaOhL-rnIfsBIGUbK65DM=y%Klj!K^G`P6E+RfyaM*Q-e zC{w}%)mD?HYwoVN#$jy6aPfkSr5^*~DjsFY?zGsRwaH_-jJDEry%GQCyax#W7vKB4 zdes5qMuU(XdDhGs9pDENr&Sr%{Xg7muB38%l)phMoywhMx0Y*lns4bkQqRf;fX{zTSueVvgQY2||=6$ZG`Q2Oh z!9bc~G`r2^;>7A>7o3|t(W70is~u+ZkpDs3G5`zjR~u8zNGo5?3yO3z1xOUm&JbJ7 zi27a}+Mqu_uRou3`8zVNhW00FpDTr_onEvwO}(PAdz~Pp(J?n!e6&46QPbunxeW zI)CmL{x}V)V|h$fGWx*9Fz2jedD6=$fr(t_xrR5fuAaS!s5e)3lL~&K6IAQPUKVGl z6dK2#5fR0Yi!?5v5LNcp3d6mP%}t6FhNcoyD#c!vff1#*ub*^H3J`wy5C#-99NfvR zef#z;R>CLwRG zfWL%48Au_oftjSS<`Ke|;yq_&^lvi}If6PF-_;N9=vr8uIl*Ag)2 z3rF+7s(0`zXP%ILBd3bx$utSbqryHW$a$%w_ZwxR5hGgebAhJ!2Dg4+Ko#6MUw0v_ zjZ&;3M8o=j#cHGuFjqt&t~1 zys`n?0zb){tllTEW%{@B!Mk;%vN)_|KaVSwDf@A48PG*y7f%za z1tcZE)|zUh4~3>!&&WRJh(7YAmOS=$Ld7?T0518i9$5Niw^xNHZ@ezJS9#HKeew?0 z_LHoF*M~eN|2}x<3Tf~6nAK?gCco4Qy9R+bHkoU6KF4o+E=`(fG=6^=UzG^?XFLV< z**b)YAhL0EPH4fB<{l@#w2RN)Q^DQ@!fwW@r#^Tu6ss*%XAKv|=Huwo+8#@LUUVy0 zAzfLVylUALMmlT`jMT}t=7;+VJn`}E{?4f|b(tX8?P+wfWa4 zEkXufqNMnIbO!;+N}A3?!^fqUZr>Poa}wU@<@c4p)AN=i6LKhpIGn%Sz|xyKogOYf z-l5e~Ds$sZoSr?}xkLu(noAY^?9Q7{E!ix$bm+)2eM5aN&{&cD$IMoMI_Nn?UTJ0E z<3zz$iJ*{5M<-e8=9jVl6;= zVgMeP1d^K8rI%W&#q#&DpaHIoxP`B&iMXaVk`oRq9nhwxAoMB|VrIN(yj|{eYd(fz z$Lyerk=rLGL>Vqn43eTjqVmAWv_`8Mx-U#6toZ zt1T%x`Oyoe-&j|b(tA#6^mPu|i@qd@H&sVL1Mk0y?a6L-*()bdGR_i#r?iAMDDqmG zu0AV?It>048tnL33%iHoGLU$qQ_IS7j}y6;LnB0yxBSyjIpkXCcRwAV#^Ca;G?Ue* zfX)_8V1p|;yiwGLmlf2vDJ2Ky6d+SI>u$t}=Xo(QO3hlGHwUAXVjhChN0cE^NM*x+rDDA>N*s>b%PgF&N6uykU_@EG%S z6Sg>KEr@nwzj=wN z1qKN_ktZ(tUkhCLyGQ-UyZi&N01*!0xNr*og6pswgn-DWzZuRYMC#}NDW? zseFZHj5yq-VEJLc@aG%knQFAosS=NG4&X&>hf;Hjgvg$p@4O{V9$Mk#E&SqHsFi1g zXOkg_xt)X;c-e+Cq>;Su#` zz_~)hZ~7AT0A%+hv!0feG}n>|f*VNdIFK)oelm2dB^ zd|JoIWj&zU5{e(UqajWFv2{QqRs5?kPS)d>Y@CB%TMFopbVvg3|ouBtB@Y2U-tT7%)`1-_nzX?EElg??rx`Nqo$@98- z2|8%M5`2ut>Yiw-AC!6W9Gv=-p-GZ{RaFu5dpgoXCKcvDR#ZnOF`(4xW-ESl|GW2S zj8j&{jHt+FgHezhW83CgB1pjW>}*XNs@nliO&c?l_};LJ*N`O3!OxW^7x3ckp3v9( zknapZd(w>~RU4(EU*NHe!}%bI+sBad2aDY+X84|Xu-><7tHC8sLmyH*Nf)AWg6!A( z-t@*zty>-!zV}_OY&$j{qzx7OA=C{O+{-#GwP+PnKsMB1WC%pWGuDj%8rgr$%K`Fd z0S5L@K-6aoui*YJjW9e6KL0Lv<#T9~iO>tHZmdf5ouk|%8xU}KJ8r)R&RuKuIaB(a zI;PlHfu_Qf5m!%jnkZsRBrs$;a5g@Zsn{o1N`FJqJqO4KPROna?DWwGk#Z6EKo>^T5-o8#?O83SA6{` zn?y|ekh!*6*i05)+>nQW|4c|Ntd7byg2ZI&GL*CbVn+grXZ>i%w)s?rT>Bc}0uE5ksp$4zOP$bws?e>;xfIU6<^TOND6dMc$PjLpnmPQU|1|L;`j)t>&7&t|%apk@i3RuBV z$!hIi^Dxcsm+U=HKaEM$H;i65*+#B)!->(>e(6JNb!;c(=Kn0%-ZP1^@dy~l^h^|M zU=Aioe2_A7r1S;I!;}|wxa(bFv)}5cy8U3w_05lVt zfK`LHG2*fe>k=vb7*b+` zS3NbRSFPL1eJ)K%4P@V$0B-YF_T3;9Huv9DX!jj1h&rA)iCHiS#dGGy!nTq^+CH{B z&36|B&2!qM{#umunal&a$K3$!IeJSSvcRlvt8j2OtHS#+Ds~byz>&ioPBX%uW4Rqy z81cDDelGSmQ>l&n${Gc>!LqEz$x*ZL+q?^(fyo^Y^7@JA)-}m;>b#8H9$aoD%1k2n zrjC%P-eT?_H3fqdmA$+`(#wfRzZBOml1i|llAlNtB1~mZh!IS`4{p9{&zo4l!8;Ct zePw<8*yyCjQMj%OmH+Jd95jrc!`MXHY}X{w6Vy2T`BnTK}0fBAEBv!JA8 zt2u^*n!o`p3a+D*uiFG>wDZoheFA>oHYAgOcy8sR%?+*$)1t)dbU4CIKvAe9`o75bq_wss^ zAm#1b`JK(OnNoZ?Xk239=zdqZZx~;@-pY>MYr$%R$&Z)1aUbEi+vFd18Vlfy?`M4twM1ereC5b2wBaBD8d>w)tpq1zg(N^JW(pGd|iTs_H1-^?lQPFb0~fQ~ zYfM(->?9Ec^8CF?oiI`qX@Gc4{ zQKJkZ|AASd7KoIgX4VtFJ!Wk)>+)w($oE1m?2)oKU*vPwkfTQueHe)2cR$o!tag12 zle%Yaf_CoZ9jV$J?e{201SJZ3&d|w!`i1s>l}=BSM*IG&%}!%0uB?QvByK;>VF`un ztB7y>h4{dl3vdPZ`{Lpb4t&EO#KeTe^t?qxs;Zb;8l+9$&tE)Yy$$es&QQyzJ%SzMNsvF9e+xaq}X3T{;DI;E5^ zwfmI5&d|`$X%$S!mQQ>7NkZ^J@h5G^7uU&X*U}P`LA1&SR{fn>j(I$Zv4Ixz*93dg z7$2BzlrpO0Y15*W3+1G+!_f72j0|@O&HgT{|9k?VEI|Cc zwM=>L^up;yUgMMhz-ZTaa)2y;NXikhLog*?Q282Jx-@~J8*}H(ZH#^%zW(%bbS<*5 zq3#Sm$t)^~y$XY2#>I!nXc1;g-SH33)4wr8$`^sozgl8e4=33UciP5N;&HN)@)LVU zprvtqLayC)nP5Gb4b|8)TZ?FoJ4kmvNEo`hw~Krns^wo>0IL z3phA0?qD6al9{-~^Hnxml8ty-KN<4RB-cDTyz0eaBNY|>+F54=g6Te1-fDqd>f7q_77bP>JY3(dm ziZSccT)Up;;x;4c*J_QAe+YrXUX0C8VJU0*Meu6YTplE+<=Kp_M%wKDR{H0NdPpz; zG8M?#z(c`ji=9WTPQRK`N;)|L)dE>}HkYtt4MQpzfhy$W6NiJHk80|sp3Y2NZMf-S z`AHtfj#KIJ4oL3}Ko547tg>Hn{;uYyW`%bQK{qO$EGr1kt^5>1-==MHoJw2d0(;<-H5-tHOyX zKnAVyA7e(vk|qibN)R60|5#Zgd1Rm!_{Gbodze@Iz@xXR_#5n35_CT#%Ww>=1qk41 z>aL_cHlzv-8{tnZ4Ptol8-4PhxQA|i&?A{9FveN#fxfpz1y}O=qi~*@6ARUIkoa1= zPBM&$NMZO^;>u8e3k>6mTf#Cj!-xgC1DP84Vq@D2gg0EL*v>l2&g2ZU=TIQE*ZJ&i z7&`?wxo}Bjp`uj)Z6&^0C2Hi6SYjU5jM33uG{MrEgz&WVb~xKrLM_O zN+L>5;y5_{JrTw3Xug+{ho5oZNf==kR;9&D6I*8W;>P zy*o}GPX=~os_6qbvgm3nOs|^safYFp?Jl6G1{+U$ao-;c_AAs6XBleZ&%mXfLsERt z2de>6E||->c72PTmDazi+5bQYY_HJDjKw`W7CACR6I11b<%5Tl;XF0holQVivMnkw z(E1UeNs!OodL-5FI4k>h#D(TORVJ;=n&_2$F}ggANDs?ceOQm_K;OkkV8S!ZNBv`6x%c+9f2jwxgnOw5>qaBUb z@k9hh9IUmbM-Ve+Jx9U&=*QEGKnzx-!~7?OBIFt0tltb}Sj}rT-}ENNT3yKwBERSI z^h`p?qzxXpT%LC+nA?W3MKB7ChEnP#VYUMe5lZ?2L?_W3bT5Cm=#zcR(KTc$nnmli za!^xMt`Peu#in}Z1Bu`&-|zEv+TN?i6MNclx$<(RWm1-N@T*hzD>Wsg;0qEc2FcGG1h*}CXin|eIm%TUcXE2%ah8Ss zkEZcYwdeZ(d8ptCkN-!V0hO&}y&7-=|_Rqh{_^-~lWV7ZRr@b%-oi!B0%K zEOgI31QgnufLG{j=|1?_&A&V}8@ZO+$|rwXl2WgzScm*9C8%Pu;}T%3biqDd!&dV? zW(7M>{;&kIR4f`7XF=sn5y&7S7#8y9d=g?bD?m+AmVffmYDlh}w)V=O4&THziXFMl z>bpHQ`0mvhD?W(Y>@@l?;oh84z4%8|shO{D#0fC-rF+$7pO^$LglcK4%jc=*6H`%{ zK-Ri~8t))@W#vo~e_-FU`3?RbJ=26?x$U&h)XPorDW3v;pjf`e4S&=38S_Z$Gv09Ttj@+byckaQYlq1?j{pBQ%g0*%^9 zYyU|9+Jpvb^_hpM!Lzm8YMT)i=0BeD1C=;%%<>wG{#s84nt|)_@bce;mL;=k-Gc-0 z8w1E&{uWXMYyby(m(e4&-Y|uq4(DZmt|Ww*t~U87QNODjN+*S!|8|o1D+5ZU8C#bx z(y@!m>W>ThHkoDS;YXK)*)Eruout*;{4I3L^wRYfx}AoOPyLB@hqHUuezsd${<2Qb zS|Z5!bXNmt=hS!A8E2^&VEglR8~d}Td*{TNnFx1v^Nvs3)s{B*DqPXtNI2iC^O=r? zMnq)TUuIuRzRWk1b?7l<2@MEs5j6N&C{e(5VS4-6cqYg2K4d1D_{J`@2jVj;QR^gD z)9BVQRhqoI%8!<<8)6YXMi@y(O~x5+{Ij)I^tEe++Ss6hkNL3~$Pdwek!?iNAnN6c z8WSlrD34?0U0jBM@kIunn3X}jzB9J=IIbtG(NAVcz)1E;1y z9LpENX7TP^w4y4WQ2zN`-Gy6HtY|Bn+33>j|N6QAef}Sxx7gG`{pmpA;KDg&SrEL) zQ`1cH)o(+Y6~3~oUsL{UX5eLaj8Ka2#$rNBNe;y6zO}1-!?*3F8^w-cLjfxRHOt6=Ywv=|yOEJh7MJJcGeDD3bE=((QvK!7r=8W1(j`lm3~JoUP90hD zL$Yb3wOFCBF)Pz;%=({~iZA4xxkhN29>vQXFg%LC8yDVM?Ks#h`pqmffhHzKZ?WuV zP%k7_P}zs!G<}blCyZV62(~j`3z3SWL$onr64W$q^+gbo%44Z*bsf0(7`;4*>+52e z+DxyKgH9kZFBW&qieOf?bzL_7aL(rPCr205E*$jz*OJf6E-U#_q(-;P_jY;E#SA9W+VgM$QC z?zv|+Wo((Pk}Gs8Y_f5eJI&_Id8G*2@>4V59uEqbpe>~XAmj=7_1G(Dzq+7<4uxeM z#c}!D3S%ovI9kOJWqfo>Ct_|dX6(}~4A7e@JA@o5+B}NaL$k7N5b+(MH-;8>4J6EW z!1Jb!=46#VvG(f1e6EKLpRJQ2nOeyRnewsoAs2*5_oS~RtO#)v>fXxpv5I0_Mt#JL ziIS@y485Iv`7<>o5?m)@k-e~H1tuenWN)XZqE9$N7{VmmX^AK>>e%&hhc^d&Q>eg# z0*Kj|JPL{K1mh)r^!V(T6phZBj^^iEeP$u4F;AidM*%;$M#JL!c0xQ)xR`+Y zhW6rY@!vQ94!4?TKc`3mj}-n##2i8|{1k?6;*eg;x=!zF7)4z>HD3#8oaNwK%1s=< zWyyqOOM`b6_004Y<6b$Jp2Vh>$nE9m1NgK}rBr1eR9wP{*_j$LTs~v(^o_RC-m{69 zbQvLk@0km4pV!hBxDBjJz11ayBo>+}-I<(=V?_j*dQF28Ll|Owz!QP1T7Nl8ZDZYp zKi$KBR>iU?cw5j&=V54I?df1^f++a=cUn${@~=m#U*VHdL0pyk1a$EVx>7BC6Z0fI zZA&NjN7Nw49BOP+Sm~Kl1+O<&<*fV!&=4MTxeo8eNaQ(u>csWCy7zLyYViUa&nxue8{}(Bx$qPM~Ir1 zWj%y)os5Mr#?~!}pa@-qB$13X?LB23m8+^MP@@|hMO*7@>U>CVlqYW=OLOY4C<#SH zGTKk$SS6K#Kb#gPmg6runn-Hjpq4#G+U=|74P>@hOn;12?*Cf? zACyH^Elw@uVA}FlQPsAbq;QmiZHyDv7~4z^6-s|u0Bx89y<>7ne;G2pUl!)U3Hsvm zEh&C&dNHVCcR8IV?*mN_pHGZHp9RRrxnh)xftkFznq~XIErDzM8ko?Kv_&iIs`k`G zvTJpX?CFlJJz5V{uZxG0YE}O^to)huTlfAgc3u8Hf6~5h@$c(mD2x+OjFpIUt!z^g zhRxV4-&`o$X#}Eie+-#OIz`prxa^y(XB+=CiBW0a4P)B1uMolO>Fen%8MvcVYHi^q z&Z+fq_{_7#x%*4`infmM2ELn=#o_6NOw~|FZZ>hkMq((;B@0wYG~MviZ$w z5m^N7`F{QWIBPNcDcV%ARE#3~=qu)3;R5)6S%D2+g<~fVn`ms}uS^>e5>p%=TFPe8~{D{A?aVxDS(Ro2U#somW6hEBsNGE5a@$1 z!jyQiXqq<@rxp7OU_8Mt=ip7Kb7T-#+~){6fey{dXqQatb!n^ulf|R^9#zWy3#2|x z>L{#aNwR^piR1Oj!Ndl>K(3vaW~8mnpK9skA-~r55WZG6zhnj32s)FdED@Ok8nW<~ z5dw?;2$7#R)`#zfX zP-^hNS_0LX5wHU=FODBi{>2a$bpS%Fmn}PiLx;B@oEakYVQRPpNexeTc6L@IPN-#P z%hM^C%i~2ER+%A6)utXypLv&EZ;Ig}8j!mS%&7s2_#JxP!28o2=TG-{H=MIe)ZRa) zG9J8t>=_c5f^8uw#2y*%1ha^0ffmXgYJCU09s7kc1w38&FXL`NI0Dxm2AE&9%}&nu zT-RJcfB7=|ALQx}v@Hj%%=KV?1w!F4wssomoWEfGd$-C-tq5lmRTQMZ$(y z$w#9z)Rit>v=hhrNo*6Y`js+~vZux+C|ox)l_d1U(N<&ZV`MKVxrWal`B&lBT!9%B z9DxPx4?3Y4Mz3no<<+5k7F-kV~IpUmRyL?2$06!rEo2=eGJ#H$I`dF(3Q$2sjL zi<3x0qPmN}!@eXmU>v3v?y@AZx@a*Iw0?Q*$tOH|+4T$CoF80RU^YfqXT4u#U{TKg z+j{Dww5*L9>Ho`D{Uho5A73Ss=pp^IL|$j+tJ;pRzUp?4jCOwZ^29ly#=&RY^<*@( z4d*r9F`s=&F>JJ0W<1!6TrLr8aEV%LNH?Wwm?@6V`JK5&c!Mg~@DjPUs(hpH$J0dC z9kcDdUhR<4dyp3w<|NizT z&lAtpPu?y1+5vU=X?>AQa3R%VURq|ZHXXJ0q{GS% zZBUf--uDFwLK4#_o!*lQEr$ou2~w^6oxSSd`gxVuY;kOQ4Z0A5;`namH`Yh6VY>+l z^@52=%Y#_zECIp1QQ}f5YIOxIrmT{fbJ#Ft>_WfiI>4nH%N^+#ydZ13fT!IpkbEtwc3tOaUDh1UdQQMS+Cz6GQ_mg`K8OZ@n(>t zTHmxZ)p(3%oYbUnwN!5Zt!A&Zdn*!>ah!7 z6oenCwQ8ONqg84p=@-j-UZfJ}QI!JIE~YSxs$vSO+|P?R{!CwS$meK61_j>jnx`V( z8%Y=yq&S)rmny^=NI=bE8fIS6dbA2b(o4<8vYNI1zTI<|nxjCxgwCg}f#$Mgp_*EK z6B(yqw-|$=wbnl7}P_G{|_FZdq+40)? zZHZG`FZN^6Om4qLu02EUf6L^fE=Vnw1SgiN)+;1R6zco_-cXm#;%83+-%>ZNb@3PG zh0M?|^|%VXY}g|LY(O+q^=RNO1J;(8;J{z`o+M#iiO>yqh@=Y7=7P8N)gR0s5uR&*6Qkn(oVoIVWnhTkdo@kg4B%*T*XR; z-tsrt64R5%OIem&##z+u-w5-6fB#KL{Y^*hIwK?;UPa`Gb3sZ9HI-)qn;3RCt?@=ZzNaw?`D}DxH`9lhyXU0iUlVrnRKs>^iV7yPN{>8DnruLMGOd zH1sO!i!X-Nv|tkEMn3k$!|FX2Ew*Qeldn|lSmgy53y>&0u-{vM_g z(ij(a8YYtO$X}n23}U`evxy>;7jy7X(vca;oxy$upt-g_*XMS1ol85uv$&j%Xo$^^ih8N<<&^*ZW!xe;&k>!-u)aS#MHVh zh-)b$+Iq}bci{*Q=8bj3dc^=~c}Lji6=u=mO!>q|Gvu53XIUVJv6i}9*?Y`F2x-Zo z@B95bQsXFfeQzBS)gGB)@Fn!}`}6){C(d8`VM%D}aIeT-e1 zk>jD1!JxcjS4>g}kR+xfZ?PmYyrYXi`@IY-Hpo{0tZ*V6(A8bC#LwoE7s}#aD z{`9`rUs?TN27%KvY1h1DJWz`pY?#Hb|3{-Tp{vgPuZb=`8>l5W*4{O_P+!jdjOnNUFK~=$-oc7B*jZ0vKB=Ui*Rb#u}#lf(Tz zr-Z{HfP)XvXjw{y&iq=VxQ^Ysip%FgMDHl<*`0S}lSz-;)Am{`HMSHe5!Xy_P}~~S z0mYP{&u>wVA4)hq4r;WK7jysDJgmR$nC{Q#->A|by9Ho#FJO96!@^J_FbN5{wu0Lk z$K`I!{5~Hz2(!p0?c}I+=q3ljL~4#7mnZyOPg;o2{UDD#*mlfIPjOtf-~2Mb0zD+r z8z37PbinC!#VctlS(7v&Q42p}2ne2r<4l?5Tx>K@t8Sg+RB1tjx$}XM{nDoB!A$1R64e-aGhheS(j4t@NAvy1!jvP}( zMyV+|zQxX8Pf}zeCML>Cf6rG3Vp&LG=%-;Lwu~mi(o(Vgfg5_VnLbHqulC%lh3W$q zJM=R!LB^(khauv!L|`YCPM;>fc^hwh2M{dqpvY8xL>PkNxf|+^RQG8<{OY61$4~qG z;|>SxKlv%-`#BF{P7*XikpOEnTH?bgfBf3|MYkX2d6LO`jFe|J!)`Mj@IKqI08W^@ zBK}E*{rcFG63;k5g?UHKxZC%ZUL+a50eVlvI{hvZI2UP-?Mpa1%HH58L7bzLEYJHC;=Dw#I&4>G!bv^tH{n&PI0x zx>U#$Kb(!{uY1#QtQADv<=|T~pMMQQUPER7hFbreUlsQG!2x$q`-IoP(7 zIK33~ue$&RA>FZ?V>FY#g4!IBQy*4}85kgl>iw(vCkpXdLR4m(zF!E*o)5o1X77#Mg&IaT4qXW|mXphLhv`MM zddkz08!#R?7ZnoP(0k&wNw6+`)E>RLorn_na}NT?xihbnWamm`n)D-Zt%OQ_Z|0YL zZN29-u6jj1u=>y2UnS#yNq$i+Nh6c095}@xkw{!8N7E03yvW;Y;Uex+{yN#?v*M&~ zO_8L+^W8up>t4zU{4TElxPWNd@JI%e!AG<#;Uu6Ph>~zAtVinu?ZFy<5)uCOlaIZ? z5i|Z@d6KKPBbwyx*4^_yuxw@*SsRbGrKeyS(dTQC%5iV6YpN2FFs!;&NiSSG*XOX) z{Itf&q~MneSg6&U+Oa~r8okF%k$NLudh8JG5AnfUm%PGi%Q}w7pekvF7kAo=Hv~P8j)xv0!*rX5K z;kLY*3r|vAyLw4-mVb55Ug=p;ghKK2)3TWxhZIvX za&4*dKrExrmqu6Kr}w15Pq-@FaK0((g63oF}2zsX>1QG@X1UnKV~t= z`=Jpb?RPt2uojIlsl5B9px&3)Ul#MD_nf{h8BO37JeV4;;v(yM%i6g@G?}7;0X%_7 zs`R6hpg`y6*P8W5k*@WprYo`BYspj8EtT*WtIDZASZ0N=$ZLYsMAoUHE30!RRo#Q_ zu1y8uyVJC^#_~UqN)@A_-y2=RWn-bCkn>UE0?Xam+Mw)O^Hm-pT3u@%m~uv{sAayn|W3M)|bBU1cR>%>_3Il9as4CU`i;NI|`Ql}MjKkp!i?C+XKH6pQZ zY#JvUa8g=6I5yFdQ@Hf3I;CJzL~h>en=`Puep=p1kkiFIG&o4~-+?U>@HX&0DY7V+eBaywSCB6AVz_ov^5B4IlfeNj@#G*EY=9TNSqG+ryZh#VZ5l_ z#3m6?4isFIs(U@{HcljER;EzNdxOBzDzHLb^RaInVhXnDvXwnU`324D^@_<4-nHHW z1xA+hh)o|}^qDwQn3D!+`MUzAzUHwz!eMUYW!-L2Mg=A$Wamk%0nsp(b=c_}5SviI zW4@98crFF+!JjR~Jw7!awO=i=1W>hZ&t$Bj zi=d3ea+|1gQ?)drZLD&v@h?>J?NxraZQY5KpMzd2ru_)%bK0PqPl}TZ3tJj-Br-JB z#RcPjz$UDpRB1BxFuHyFAPEUSwUH$+q~^UPs|4j^9Qa4 zu|8(?X6-?*&Z`8?o*dneVd(C>+3>06U9M_3ZUBPswT=opITJ9fEK0+~qey3R_x!(y z@_&Da@0ksx8X&F$_oCeD+t{?}wnS<`IgkQ-As){q-FJeBnV6c7fC;=*NmSZ?7zR@N zq^IWH6|3Kemmr}=q)3JKM>7uQpFKSm=qIm{DzB&a57qlteafRHQ9Ol;chZ>_qdz{CF8L8HpQIC?Y7n5Rfpf^1Wb5Vx)KwG6{HGqoChU#i_?{;v z6m^=KhYZu*43r%30jJM!stti+4e4<<#}TrAr;+CQXMHi6wDU#|d?7HKAex=bIx zvhbCx(D+u*!hQs<&?EpIJ|@n;Ffz$;=K>jWjp}Psm$6P8zAd@%6OGl5D{Z;zX8oHq z0r0tS3Bcr6J6Y3Ft0e7(RgAV9%lO8Y4yh@~(9$362yqoz$VW@rjBzsPRA$R3PmGc*jHuZ|De9bwVS6|-SUoy|u&nN$4C4gD|dnB<;NTP8~NP2VK z=Y7{|+BJt-Dyu%>@~NE*V${e@owKFPJZt%kF_#5i^F%XpB9sm{bv`E7yo*mVs-v1X zShMJyE}}Gvh0WkGIQ9BEv@%=yPhGIboWK|A6o;RVf(w4 z3w&@lf_Rk^C+K_C_qaNlDwRF#Bw-B-No>~6kA$ssqSD(e8^7?L$hsZN zX&-H-2Rf8{a#xh|Yj8cbO+MyLyH>Pt^eJR9xaRbXvQpj9eD7{ui1 z$m7&06G7FFQ9Cjvjl^hhBoiS)O;SBsF3WdsCGz-h?!*UzYbIQ|M7Dm4Gixs49eLhQ z0FVOV6$r3@KM$hQ;lWJma0NKGN^|cX73j>b;5_J=&K!t&YEh3zmBg>nzb>8+it$h| zY2520NSVVb!EeFi!{Qk%2a{9}Dzso|?c(88(NaZFtLuK3ERB_Y_0A^n(uFjv^c?jY zsOswmb3CPN(WaUZMzv?d;4)vO$K&4cT}ejD0Yz4aKE5Tjh`45JUK2f$#D1#X{w@q% z+l653r&^73nNhO)=?(=i(Rr}ePZ)|B7f3)gz!oTqTG~}@bB6L-x>FfCB(2T#XAXr{ zSc{fez&@W`UzWwP^dR-qmlogB{C%`wRXjf>_P77*jGt?v30-3xRpLRDoZPYPIpm;C>Iy?+B|Xf10p1wtE6pb>^l{l3`obmog6&6rK= z1by_(L2a#QX?EC$ragu5wN2=TZkwm6B!&^}O@e|=cGK9hfy^HmvH8fcrwa?grr>Al zMV}1SJQ)^cWV#{~V>HQA0vlFwaX!RH&aD`sM52gVs8S+u3uQVcHgYb%^LZYtjs#QH z!}n#t8t|JyxolToPErsVmmP=_+P?f`{8)HjJ4#UzvTC(trMv%T)u*W3tpMh!gJ$RY z319=zgm;~#>^!hZ0&HimD4rO3034{O>n8|Uhfpk(>(u?HxUhZ}sn+BNV} z#VPr7SD+E~2@_DIoVfMH=~i`Xbh>R*$4C~N82TE zZ~f5jXf-3MS2}pCnGcRxXJ&QPh>XQ`bJ;Tt)waIKvGa@Uv0XMD;FK!>XtoPwUEcx;WF!}^}waJAD3FdID!mBSF@UJc5zplbx z88sI>*Ya-_Nd2ff5lYo}f>VtYq|tsRbAni>-b&6ORTh`b(STQ%sB}@Ute>Lo9gwqf zkW%oP!Jt!q-`l$>dBwjp;G%=nLQ>Gg5)%}IkSXFM!{9!cq$WMWx~!%-cO@a+AmEcI^Rut)j7<1&IIG+Vr|i^B7-JC;plYzP%CWlB zvt+s=uojpBtjry;%MF$Xlfvh3P*F0W7@Y<^@8h^U-sA3ww$&Ojjx$A5m|>RG(8F)8 zXX*A58seSN4#LLr8rAMdHJ;fHeOs$82eYI{?OY$m2eM&}gr5CViw|t;*x|2tVaco0 z@`R{Z+@by16N-j(hcf6_Dr-M^-U|(cB+ zWC*&vCIl69MA;YL$H2aUMVzd-`7wY+ z73p*#t+ARINS%hx&=icDho@Z0ZAuN0(KLOL8FBu7C?Bgx}ts}uOuf)Iu%(wXJ03rp|*4ub}>Q(dd5&8tt01Uy)@ zIW%qz7iMk2nN1Im&B+XU1YA@c>#Z1AT?0xFq*^cUYfAmOdeU{=|oQFmGR6 zYWMG_w4WIB*~{O1@mW#6B<_>(+4JE$fC|UwLuhEM=2YL`b^ly{kHGg%q4Y8A0~tjMEOeU;`)_@rK}9sWfKk0?QV^oCIQ^k*zepAVLUY)0% zR3$B21n-jNR^wd?s0k@9%mC8Hn-_BiNJ}Wd=YR$%{$Cz@6eUq7C&+7j#v4^4ifJEG zarksV;644!_rZ|xFwLcmk%UL!{jrAzknag6Olo=k0#sZ+I8+_G=vt&wiBC_t^6*E( zM%Cl}%f*m;QSMibqc@iENeM%a;w4gvYQx2ovm4x|uegcI8w4C`=pN7ifO+(1z#YxV z#uwm_uf?BtYEYIevPV#8Fh=8~7#|Ru><<{k#Ev_hsY?ERdw;$z(DsqW`gL^N#dQR7 z9R2umTom~aIg94AtoVyNXuqM9jgakz)9Uv@t@a*#yON}Rf7D~JhQVXCFf6Ry4^<9~ zBfZDKuS1$J7$E9X_F@S|t3yK5>bU97Uc*3Ermyh%{C2lqJUJ&U$LLtxRi6sGq`lu| zlj_5HkCR8`w}aQo6GLA5WpCby$~kdlbHtx_L`En=cFrA_Rq(5KSTb?C&{HTWt#CFZ zB`MvzfG3W=F{SSORs8bKe-tJ7b>tFu`UqZ=3F3w%(-S|ejZ@$+NNlwl+AyX@>c!Gk zjU31EE?<+lru&7oZKND)r9aKCop(Il!rdG60?jjvK;fV-FI}V96dSQ9@Qn0S70#`4MQxTo?Pl*Y_(_Ses(BgJy_DMgM2DkW z$rNqvu7u^C>B_azOIPjeWlJZKusE!q)%mj+arjg$Ee5U#J>ub3Oz1n zDKiXKqI*?=^Ze{IRG;$)8S8;0hdyTQ!<0kN)IzvMdrtEAkxq?rrS_)}eysmPv_1nU z*XGmD1-gDbdB1C$Ho8y0*!KUeAURD6<}&(D@EsuF)Se@mG`XS=u&JF-&ttV1S63_f zS>UW;xVh(Km|bkZaGJgFt5m!ue{sTHKZ0t*tY|H`1Ymt!b{Jn*LMk3-*bOp}hU3+xq1zjc312}tlOIO=WXDBs&dRaTwOSNmYBxd7yi{YiGxSC@P zFU*%3ZqSg%Yho+^F=U7@0aRmm(d1ZXjwigW8-wRk<5=s;AImwINGbdBqvA*rY)XYc z8aK@Ew3lZro-lZPQ0ww29+Bc{{c?XUC|0w@Mlo;foJn{~FK##NgJT&qdTdoVzl}g0 zvz*Vu=|T6$hIs?!0GmQ7x>`AIN`DE4FZgZM0tO(rHD`Sa!=&~ZO(07 z*uVCd<%*(UVvDBoD)4v3Q74P;N-+Jq)B}(@>NhrF3Y)TQ1zR3mhLg&BCAuP`r%FMk zz0k!3W6!<7jA-a$%7?;m+|0zg(n&*^v}!0z{~IETe37)cHpZfps)qH?qSJ4MSOp~G zk|(Y@A3G(+6O@IY@Ld~kzUKQKL_Ze@wtv6E`zMV8gL8dbwUZz0oxJCMQhWs_N*|jV ztQ%tzSdE2^o&|`+oM#S2J7S%g=gec98C*C0*FE{H?^@HI#c zQG>JK#imf)?mn4Az7QhCSz+E7JJD2=l7%Zv&(tu2?xDihq=R>yAN9q>526HlZU!62 z(?SM>#P8z5A{-ilS|y74kteI@`Z_lBkN$nWK2z9M*mDtQF{Lipq4E z1xI$NJ9F-wZoqUku!OhkRz1yGBvE}AQC?J+OMId8mol9FG;vae8Xhjzx60-y2RWBi zZz|lJKP-_K^)eQWQ+S!2|4~VB`f;=4xckm|>QM3b4c4z2p9@xfjUg_^1(mR9a+<7~ z(Ek_9#z#8o1pJ;A_3KX-@sxj6950rhR(nT~nqZ#sB4`=Fm8|qK8stsOT77l`M1reo`Aua+QKaSSV+Vg0%YuuOMP& z)VC^rUhsOeP#^P)<#Ifp!D_9ijJEn-xyowD(?(iaa{0CgMf-?0jHyeJm0YD; zNy>awZ1hYqZIe!o(Gp7OzK9zPaEWTVHU_}N;8Mj&>eUO8NkA`g<;z;(Qxd^0B28W$ zt#3FIcWk^@7{s0pGft+TUH5)E4|+MKL=v*t>;SUOiu+Z1Hc{hg+<+Zmef+xH@}1}8 z0#Zz_z?uyl(iK9JOk4ff{3-M?of@nMdEEyg`+I$in~#|jA>z0IqqGxNTD2%sjC}XW zLX(xgEL5?D7*zMfIUgRU^+h5zVN{1?@hTX44@W`dM^c>!zex?ge>NBy-gyE+cwO|# z*z~JptX9plCCFfSPagwIKvN!u7NSCByV@Qph9`6Gv=4%oRa2(BOHt>J9hA|)7s`(Ks z@X3#YL;;cdaak`)nO$PzV)zq#7j;7u{Ebkl$N^J9jI`^by^@At!nNl2OT<69$4oqa=6+7HB*`dLC^{M;D|8DnKDyI_9HT{` ziN0C`4@T++5`3G}#BIP~d_Qeyrb=nXgaXBI^sYfGF`{3Bcitv+%$6#E?O1#i{UgJ- zkX0u}cxpxkm~1rWMx03anvIUEeFlW8pwhu*zxVmN6&H=&j?C0TOBaF+(6ZsB2X!DE z*67_fi)6ycbwxZ_3Wi%aG70f3eLMjAfkx#s&P*7n|?g3m~7TX4>QP z>kkbe1b0_^A`dFDq;m9eU%X3m#h3v#-XWy-dU$oJ{c`CW$<_te!~+AA=#%n+fa+%n z5nxF9-}95!WE&Gu4|@{7nXIU3&6$x^+hA!5Ud^sKbS7rAYMAwWDg?_bbL7EjAlgLP z!I%;BsRZN^O{Ftxs-FPjYG8= z)_uWH#1g0$c3RGP9eWg zw9@uCtt0P=J=bnN@9Gfsp&+f#7# zR24n``6wAC8^zV}FkzC(M zG?SXi$1KSzIfMz>-_Z9t5gFwRVK;FTP18INUy^;&5CM%`(=3&ifsCKYnIv*j)|eny z54ieRqS1M>JK6&_nAl0+0xE!F4YM@#F|Yv&iNfH2Mr;F-k*k_xzf0~6AY*=mNuU6Kl<}xu*zf+rlD2{*(TqVqf z>HO>&yMQ3PsM2XGs`x7lN+|~6vK)6CU%Vyihw~A?AoWq{iCfD8@24hQe2;le_Fu|F zLzmSOMXpV;rJnCDsH(F4dI zEPE^o=rj#+{<*pBuS?|=zE;No!_Tb6)VCD?eOH_L zpR#va4_hlpv@AD})!>m8+YJ`fWnur=RImd<7j3fm{AWu3UVg!AW`kGbDH!+_zb9n; zE~M#t^B+R)U*v9o?YJ+=(4+9Clxm7`GWbg1sVLp#*Lxi^V<6>6_*&wdQr-vL3Y(ET z$yg_{_4a<_an1h_C60+tgF&a08uo6=`nzT$Qxy#D{mRXCssV6UbD}D0)%*9A%A zR$-M4G$b|MV(fH+(Hh1>F({j|NZRn->}?bXnK*v;c;Zaz3FqdQ+X%5T7(TNnH{g2( zI-jAO2sJ&fIe5p;3nr{J;UL#tgkO4TU2+<65kbzB^u*777bK&&ZJND)U(y@q5u+4a zL08SJ-tEFPcXKbwSPf>#@d+`gL?^2LTGf)YV@y);h48fc4aeSqJ7!to8(sqHxSFLt=)4-k)f#-hWe+{-s{@urElcAPom{MB~5~j=eR$m*cJROuK$s7OGQPK zPwEiX5Bm3CGyj!#fm`~Ozw&+q>i`d!0_K-FjZXGRWFnrTsb1&mc~Tg;c} zq$JfB_(RZ@@fn9!6VeNFkuIu|MQ)u?rZ$Dn^&rbj7Yz^)tTy87X0AXNEe6XfkIBDe zMdD3G9jP2^Vx*HBtEuFFYF9OeR8+i6S_zRRRDJ)Fc) zra6_mL0Bn7yRnu`39`ppj-S$~!w}MWUsb~JK_k*~HZ@{~5yf})9tK(0L>STOrDCLH zzz;a>e)8m=b_6I;N@ZfZPawo5jm}1ngq?eRVKg02NHCt*+BIfe^|5eJ*H99rVWB%g zDn1Ba^}VovrTvYN{GxTcXER*L?F_tn5W+-re>u)-&D$xHwzB0XVd!$0`#Y@tdXK*} zgx&i~%HM$gFPQ&1{qK`m$c!&&IuA`Xj^^QyA*!q8*UTVaWMmvz7nAb@oI|vyrSdXs zbMYU)!j(|8{*oJp#9IEKMuONEs zY%Q_;BBq3H@9IK}M+joL?sWePT0ZC|>`#o6gk&JAD(mch;}%9PZ(b$Aj-}f^zQ^+* z2A9Rg!B(a;?cxx$_=6C%LX~h$?$mA%4IVm;B8;Xi*oO+f%<7!uLYa`X@;UP_e_p6D zi5v7V@+#k>Y3sqpz}fzuipwdKyERzY!Uuhwb;GJQ4IL01>xOlrR_N51(7CPo)-1)V z%_VFtV@Nb!&IFo!OC5d+zwV{H0g?g_@N1mXxB(0#BP;Rec4}$axOPbtNr={Y*zRY>3sXYtS9NnmG6h6-662Hcbf*RK4+fP(u1hIXI*}Bz00% zf3BW|3y;k9^-)jY<=B~WheB0JyrpS$gNQfTL-yj1O! z5c)T@zxZX)1KKK9S~2IB3Rgl)-R$mf{5e$PNCUT+B|*&1kAj)T+IN6 zMgqE)t7FTpm}Wm?f{HGh^P31F?7G{gd?G^XQ$(mnr)c}tIIApXA<|>@2E2+b^TUgr ze`~&$3Q(#jHBQT(f^7Z3(q1dPu+s9#6u`;F z+*9I^=gojc)D}p1<2;)pfQp%X1*(=rMFM25bHFZAE{CO+UAMX11|#8}AqzVgq!&!j zR3a{DLDUK>hC280s}css%_{@MJAsB*^^s!=S+;BA(HnEL;mMF?8(4`T5C06TyByRy zuM^15|HXi?VsxC8*Q9D2r{yEeA@>5aRp*pn!4?xBZC_>Brgl+TH~7HU^XUU_(KQwB zYHYIWBCAQToe4tVhLmHUc6uew7^-C}KjXaZW@?Y<0PJ-=GliKB9Fr@sHtWMgu&xwK zSSeEx*>og>2~kp~vNl>5@84ZByTDA^gh#rGE$EGQbPS6JJQ07&M!+2DnZ1f`vfqNn zH*j(1=VV6*Y(y3NYtovY=~C0sAX1c&Kpcth`g2>;cj#KANK~!QWW&ynQ8`R{p3_nD zv*XzJ^qfYPrcQE}N=v^!3w;iO6j0h3BXQ5|MMg4NGKdKeQSLr17(0oiMFQI9la%Ex zSzPg7!(K@+LCKTHR)7ASW|h>LD%oPlj3(TynKk#p;!1uSE5;wHg#>qaUfM~gH+H!> z&3Ri-{g?t21?}l38wF=Zw>7cm_LGJxtyW=p9ycOkW*5hw|KL~UAm})V?&5M>+VzRJ ztP~>AXr6G)7l1S8ky`%c%CXG|Q52YPY#WOFDUjg~@zVFb1)U4--5+e|f@U+KvU)W$ zEK(yU0FkTxm}~Fl12q<*U>N^^`>p@^V`CIE{O&?Y{NAJB&>_*+CY11EU#qyOFxgSR zXr?C)!wn=JuI89B9(C@43AD&(Gp;I|e)QF)8@X!p5t@C^ti(_0(p5#%u}sT*CM#v} z?@q>Fx#Swx?giYM!2r)CMTIaT!FX>A8-rnzgxohCZDz*P(auuEP$_eY? zsv;gX!1r0Nf;Fxrn$(+qMAUnF?ZpgZgxCB#&8>g`fp+6bHEhJ>tZ<3WOpH_gF*vu zjBJFmtT3wS;$Q?Ut-I7ZL18#rbGT|-@ zjY_kf8MpaUxZ@0rpi2_Pnyhq}(_GfN(Ok?!jXU-|Cg-V-;sMq=IjC-8Oihp=>{RS{ z?*6&=yJt8QE?~M})qH*)N+GASH{VYTg;1t92^hTnjv8{?b8yoz+d~%IlBCEVS&X*6-g} zaR`%JD+8J^O^hN}>h2lkJd2v)8#PTVdtFdfHHankDfycA(@O1(Tg7oVul1F+YpN9ltOl z&Rlb^eh?>fY__CQm;oFk@=t{AWuZV-x;jyAqI)R2qC%q2uiiOglt{Gj4pdkx2>rO! zIe!i=h95P`3=e98d0c2_eN~@%`I-4+9CGcRHm0}dYs`}2an#Ysf$+W;!4k}3(rfU- zt~T1eOhqJf)4G~^S8QodTRV~^trN?aauO&3&-_tjNI)kM11|U z9#6BfNX6i9NnuRscqHoSL=~ldPO~0dgi(AI{^H5r$rRM3_H@@qjD|pZ%|I)GCiRJqsYtB47&P`30 zSjmltjdPq-lK_S>R|92~UeNM>8v!n9Y|hb(LClEd2@W$&=Dv5Smz;szZg8dC4Z)hE z-DQzXk4q9?N>8Bf#3;9_@a{ld&b|izN1-{{izl zd)7H#{KIlzQ3B5a00h6az|hB)U}IwsD{E_y>yG=KpB*nPF!8G=k2YuP9=LK|P-xiR zIY3iRTQiqmzHM!uuNp|%++u+*uLxl>Jt!vfY`))N5&}A8sQJ0GSZ8R9z+HOSZ3U|D& za(_#mQ)WaT%hMj{JYTeOIZe;ozRs0lL3lXRHtYoirOX&YXjCiDjQ#+tW4j0KFz3kE zFGJ3bU+4%9FXkLRjbQl+%jX4{n@_*qs}qj6i#1qQy~A%+c}YTHn-zPvp;0-1%Td26 z6l0rJRJp_tcjIJ0P|?#_((`Wh%HrL)izP<>6HuK$orx#{x0W-c%DH=GP~1ZTv)G#S zt-4x7^1h(>O}X@Yj-+(P6*r8ZLJHV4o2lcq!@vee_3WlE0KIW4)^w(7hy z7b(wU!4p`*OobIDfK_3-+O&aEb})B51=tFmq5##0%JOb`V%^9q=5}8T0+TuGHI5&* z1;6Ikhb!E?E7O!v^MY|;X%#iJM&_r}t*q70@R?*T2&Riq&xJnP>-P!$xxv5XjRq|# zTvml)IA&{IRx+lEPe~p?j~Y`)i@yV3l;Zntn|G zcYw-}f!ttJTkU>)tFbf)l^9{DL0~q5{{xXlr-d?Z;QS7t*pYu2%q@Fhf4vYzkO!6& z{D08x{0>p;&QEI6f2YL%+|~e_xb`pG*>3kyRJ3SQi;OI+?)X@c(HO>OQl=myW~WbH zig?vZ8YRzaak54|zabhcgbn2Pz4B+mPqXxScBa#<^6`;0FkH_*NJlzJ zP*D&oN_`U=Ajev6JqH%`(qoC8ELbtI#Q>aWU31?<+*^#`h9@PKZN#5k@cRwdj9k3T zsPKAx$b-1N@cohZVhfUnC*ILmF0OD}&N7`@B4t*$^Q@g8;n0S;LmoTIAva)0waD^U zfJV)(dw>LVJ0L8(tW_F6>}uVIeEHcy-JdK8NM46XGA*OVVuVmHh1k@fsuv+Ashxi= z20rV=W@nBUy?m6P_WbC1A923_Q6&=OD^g&)W-yhfR zv=A_v+&@25`z6+!z?y|t|&UiaGESE^q zsI+lttXRXLah0YvrTtqGsma@J%95cAjEl{CcpcC~xNUj1QPDo%UX^fMcL?i>`?kp* zt0ts~aMDU@DUhiQNwTdoV2_<$gs_I1?va4Aez)+1YQ|@b?xs)=-WlR(pK=4kIGFjY z{R7|dlw5aDC?Bs5J08@^EWd#Y_bKK#;Caf)&Z*WWDfHZb{WfdrN1i!s?l5}78%xj; zidRsPUv}#et97HNXw={$*dImxb}J%up0HbV;6*GWSxU~R2MSh{SYIpK7b3zEZ9Hgc zb0JQQ{QO&ey^x)2e$=X+owqtOoj6ee>hiWi#XUDJtNh%Opt{fe))2AjS2w0#dWb4X zG5%+6^Iy8*_XKAErTyyaOFYEV(s-&Sp;8ihQ)u=%*B?dK>;&ciI(;6_awUm1GRF4H;A1FZJ}MhevuFS~;n<_pjH|X0Nx)^%3!OODABJK(@5%R1r zLd6N=Hpr-=&a(l1zWw8|1#|icW9c=OZP=B@thop7jkEsil63+bw|n?`kO*|;E0JTV z2?tgpGcaRx3b3fh5Ml@#1Ttk}C8f!ZWpEJ*$k#ZOGJrNeQae=p64q<^#p(@1t41y` zhNlo5`aCPRiRntrV36b|wLB~<@dYILuy~j1Ev^iPtWZ1}m9h6X`23JNVv|gV_Q;7W zOAF=!2Jupu zu9HvR?0W+pa+rlu)2}LKQcfwh&acO*2U2#=*Kd=c$EM!D+H>%PD1K$^e;Nj+-^wx1 zVk`G|Fa}-SyM>ktAihnxzw#Ss((&K@X>cns>|i+d7H;O%nC0hqs~nQz?6!G-y`M}b zKIhFeKeAIxzTEl(R5{QcuJe9Xw{UP-?0jX`nSF@X*m>PTvT3}49{GH=&a$ANLqxr<=Q_&L z1ppMcH(pGNza;F$mS-y!3BwDN#E{c-q6(kVcbVUHrtC=rVv_PQJ^ia!uys zl}ol740)MP+0O-j?;ZMFZ2j6rKFEzCG=5TG*Uq1b2JLEG!1}XO11yOZCWw2q2^c$o zR`P$Su3y;s_k;K)HWyZr&kD;}kjDNv`1voSa>F#Kvfv~wRgjaAQzp$;kC$g{SyXRfyFhOc4(xB>)gV$sP*tTVaASwF+ZuKGG$_5U2>|Gkj_^D6rZKEHZSqopZ__W;( zQ0w3PU^R*}798dr?EO~Fb7ZF%jG-kjVF{|fhUOKxBf2&d<}S7Y0{!%WtbJlvDMj^c zUDcuiGHArG;mR?QkNPMl``v+WAjR3oDsRDBUs4Y%QQ`-`oVlvLtEdzpc{z%hbtsgTI*?(P8ZOEuR^&h@f#DiD?8U* zk0Y75dzNXVo@%+0?S&w&j-M42=d;Qi_jPSjW$jDIR<_vixh9!|qk*x`%(9i;crk*f z%9kXF7VqN1JAU-8sP;JXYTt5R;EzCpK>nZJ5k0_5m1Ie3IPyxB+%vEU59KamV@GZ( z*<*HM%`1AiY8&*DLrwMu9tae;6}rA$)AP;TA+tUm*rkvbS0@;qUuNRj0Y7g}!N;e(cKr zuaXmMiUPJB{8NZs2Z$w;e!3ht-bv}WU2o_ma&OAqd^rkZ{CQp=HEmd~u`f6kTOmlX ze$jsRJudsF6Ro)5YM` zbc*kBjIY0Ve9p}2HsVa-b4q@r$Jps+4th?wK~ZczkMJ?Zg+*`=AL8NS)t7UgTad3Bg}db0z-u$W1G~F+IeAs@5wL?wccLX;{HTSG z9aKfFekZBlRyRiw>62vuj@#V?bok)?jfh#Zn{3{xGX@2o*2tGJ_REJo`|Z%e=bX~V zM3Tmd%TV?mxR}lxy)QrSPqO0ln`x6Bwa@oSPAt!rrz>QawOUxP>RjYM8yTvp#8h81 zBu=Y2LBL;ZtPfubMTwV}$Q_ALZDR-3OU}!y-DC>HZLaKQcr~$Z^sWEdTIUE4| z@$aMmP0Iptf#P!F#vU!^yf2W+K;JDk_($v)L}YZWB~zGt~{7ZFfG1f-D^L11u`N`r(- zN)0v|Mk5Lc(xr6AMwdvB8Z9N#9nv8J0@B^}eGT+}-=E)af9Uqu*seJ7JkN8kb7|=N zv$4#Y_vD*%B?Y%C^0su#>Yfnap0mZuprair&c!mD^*XkoDGPg^cn&FxOKq|ll}<7b z`?{SVkz^6q=4uhTK&olna!!lLCR^L2Zq3p@%X&=dAoO5n#GG&EHUG>FeK$GD5i@zN z(AP;%v#jRlkB7WT9UR=K8WXY}IgdAl?ROX*=2KA}#eDElnlHFV@>iV!nD*ybKVV8F z(PeW@-cU=r8{b{OrS}E4c?1-7P20JhJa{HP+C;gqNmn^3`tZs){7pEFOyfeR9^j;x zm)O!k=KxmCNQ%RsLtxs#Mla=~mJ-WzjuBx1Vl=wG;D!xSJIvrhcFNqSfQE#%%CRGp zmmPQzUAWqS?mpFl6B$r^v>KXmYfq!rXJat%H`|?OuP0W`+v9vsLgh1mXc*DnubfU0 zmhwY_CvTNO^?yb;(I5gioHkr3NrqW;u|B>Rk~doT z9T1_>wpN7Uh~MXjcQu$bG~7B~ZVpB6Eam^@>SmqAZG;&$^X=x!We*MY&P`dIqCvL%98<;Z<;Y7&w^!&P%PtxRBpR zx9&pO=jThrL?4VRkpqRizrKiyfGZ!E=D*y)+1Ic(>~Gr9=vTFwXE_Qy4f9K*4j%Xs zlslj|W~4@o+JlP)*MDPqTRR8by5@S*y(N79#rUmdZU<#p~jy%zT?PDU;UmmPS>}Qtj`y{3f zFiu)~$7@uLThkyZ47l4@ihvSvt+jgfcnv0xo-7LFv+}BBe6K@p%g}f2RQN!9kkRCC z`89Pc!!N23ks^M3>E!=Ix_@ImMxFFjD<7nG(eRgx)EPq7PBG1S+P>)B`LD77<&P;U z_7@~eBB)^8$bscz24;eR5};2GnB}3-YfCmT3v**qY#&A!SgTp3YYx?D#Ouif+pbrI zK2Y|QLD(gheABWO_oT&E#V?h;^JBhbcYC*Kc4x^JX)OZuzL8?aFHJ$v#qQ_jRmd8} znu%0s)3x25BpI`Cra4t;_|`G__$m10mJ(Pblxd+TEz|tb0T?vrUVfq?7pOL)9?8s_ zC398*h=NE@8iENi^4p&#n6%naSmCZefJDx{hV>(vr;CU-HPiD7$@u4loN=Z9R*?vw z&5BI_mVNT)HHi;L=ejRY;8?)Ld8rOuBnniqwz_BDkt3(BPlXl$eK3l7PjCx-PKoug1K;`rxXIgp4_c8NPEB+-Va~Fg^-yK^$RpG(%5Aqq%tGb=MAYW4c6uL6N+^Q^M zQ!RSOq_8EYq_5THv}?@51*0G(_xI!YKq-@G=L0y`*O8s_94>huJ$t;*PVnd|*mwMs zn~-xg;uCzx+5}K+`gVc*>!Mu~tkm;Pp=DyCNw-mP3j(Q{pY!SF@V*00>B;OyB3s+< zmIr_LdiuA(4&ZCc>r)LP=H&^Q`Je7&;>1wFa%VMG%o<`d{CF)@gz5Cdiu|DUd$p=I zrAs|Cz%kSBC*%_g`1i5lr))r}D2INl-1-?@oUk)Bdgh`{zd-=KX8$4M&}7jDRy!5( zPpS=8Z}~eiccFDQzvag2fa5*(s67pu zUe7?O5l?debGqhD(Yf_>lP!{;eR-(nBDx{Gqezvn0^fJXHsd}{BLa!dt5aKmLgAzI zsvagGTkWQwYzf#xEp$cgJI}y{z}btgZzrLGG{r%l<{-{ko%mt#9{LXj7e64R{HjB2 zGeB>O>0l@(R$(J7=fr3_pOWp}7wsDnHt#a1|E@70Usp)`ispA%Er%`yDr4VghUKOs zcrZmT)_st|j!b{Z9V!tHZ>XedkOPn^jszC3bPfS)+P~Oz0jau^J z9&TFUp81kd0hPR`-vhV56W)OxoYEF-|L+8e?1XofF_3ep+AO_R zZ2#1Y|q<+RpC)&;rC?`-R#QEIU`t<~jN#oR)nl zgYHtg_igHjosyWPv;)bfv$*Fv;ST{mL+|k@```8uGUyAJ$)te3kV4JPt&ESvB9*z@ zv&eO#V?q|@gzP0TfFg%(C6=Prs1pf-2gMXv;zxI6DN$eK%#2*e?e3t)?tgT-^DYGX z;5oA78^v7+$8`1gfyiCP3Skz5;B||&5>bEM#id5cz~p>MVLnobn9_E3QpJF!{a09p zT$;&y+io7=shphGKh^M2F+(E&105*^b}(kjTcrlb9s7Jnd3iOwzcnHQp*Dw%u*a8X zl=?2L1o!oZfY7(-Vu27S%9eh}|86JqIRa=*5nkk`qu=`-VC5Q)&lClLzE2+NAv8c4 zA#0^d6Z?Yk@gQ&<;8^2A1h|^-f6(Ihdw@{|c-DOnz4ezamn&};LSO)5RnjSZ4ish+ zVAl+}yanpGZ!wp*X~+^dV+R4JxluMO3V-Oic2%i7(jtEkE8q>FO&ixG=SG`2&tRF8 zs#YIzzXQk;C;7ljYypNlIIj8Duc{=sAdxVr57{~Cix18^4_v<&MziJ662Eg>kvM;% zF0qV-Stjdg>&i5tSoCR_4@3JwxN-2g=Gbcwn$y=@ArXY(lt;@)6bnG=@99)6W7pi? z*Tr7{j*Ynp4X9>t-r}SCqw4lkb9H5Tc>`4@?3R|RnYJV58#Nw~_v~o-=laS|vBp^Y zrBodA@SDFrpfuC}TM7V>0{Jm?i0U9NF(Q$H4Y8!`HvSut5y6gcLVlz`faCERW+lM? zLRKbg*vOZ1zkn_DPzG|gLSnu@A|w#0k(#eW>3^7UTQ*RlZiS{S85}&MY4@NKo>Dff zS$(-yD(Xwt)=Y#6p*d-`H413q?pl@%tAYiz zUM;EKp?<@^Smnz==-=1{4O;fsbAaR?q4q67AhjBudiIOucQ|pRtX<5KLKOfBnMhH< z-u#<|0NAEBAP+&EPSW)Ap@1MIHBdJy#vN%^?&JwXi9E?laXPfWgf2Y~HZVo<*w!(R zV1p7w0fnzM#~eocWJx&E28llV-pyrw@si>56HEF!mCp}9``=@fDfSAg$9#q% zdH(1P2|Gf@^a)9IyydFV%b5TX5zAN_YqL+o%WCFQ;2QXHJ=nVAHi*gL zJ*I>^dwYxX@d@!wvE;x4nwoI8Lkq3hkz+5g7T|6IWKsn_gQ_V63*_~t_psY#rz2N_m~LZCzP{h0{U8}Z}k-LeVHG=dZ9z(%kpSv z9J)J4%-4>3FN^5-rGf|fagydYygPSV$*aQhp5&NSxeuOFUTLXgY9U#!2UZgEN3N0P zA0vVKIBVy|y)!rsRwWZB=<;Ez)D?1&??^6!_AuTJkQ$^pv6N`;g@Fyb-PY(n#bK1?V z$nJ^0dQH!?1AUjZ9m|uh5F`zrk;y{QTm(ESMaIIJ{V9yQAF2rt}e>`qP0fW3|j%MKq3s1h~bdZ(us1GXnz*p zM~_sT+!#~iNhxF7tt@)NLke}DDUbIxHD$cKWVOkrsZ0*+X}8>*}?s1I(?0TvuPlLbaf}x=Le{1YoK%qdt9N^e_wywbhPGUSPZ2ND765N)}Z~ zhRB+5Xe7v>yqF`}{vB+b*wDW^b7=Q}^~YagBwa0=DulLu@OwDLzk4N!i4qa%_%$8& zEhk{?v81KUYEc*JG&>emE+TkWx6f=KOSQQ>eduJ(U| zL7*x=UTtl?f0%`XrND*|$^x$H;%0-~C?1nUD0@DryBBQ@?n#hQ(aRGWRO6aon+9N` zg8LQkKzNFy)@O+I*%jRj8IRO;hthl7i`$18g*_=PXW!qZ&(HnQwwH#WtsUGICWyfL zV_9CW6ZnH>mZrlLHfY@}B>l7uxwr4fS9v|lm}eCc@K!JlpGbh%DB?jjHa2J+5?mg| z+2Zrb7~gQ~MVYpl*dQH5!rG#3fB=8Tj=Rjw5!Wt7W8QCW5e&bvD z-K%E{2JKOj(W1X$$`3#g>31lca@c^{W!_`MPD%r2S0rsQahpE)5J?naqMCtIwB^Uy z`-o?lQK9CbR*Xy={8mqg5eHcv$;~Iu06DH_iSJH%eN&6d-#ewebfVj|v%M>0s8&bp zl+Aqs7a9}UOT=!hu@0O(vl%&=;|YVX!QgOBRY^l4{d%H^VFBR&=I!jvBtnna8>s#X z5m?8K{NBwc{Qup=Yhi2Tm;G0sFi;7b1pW#s8*Z%{PU;-Kz2R8_%?}f(A4oB9m$)Io z>()%l0y4Pdi6&jVDAxIylw_&+mop?+k7H0fvc<6 zNA`6?_0<)aN#Qgl-S`C4Qy74IkRZF+{>+}r;M{FOh*OLg?~jCR>4hy}lBaWzD272n z%sG+1qviSa;T^B!3H7d}4?Wa3Je~pZeeHJhuSk<4x~+^$;vx9Oi!99AQiyC8PJvNO zh$+__xtH=w0dPcg({>O8Sz>w%5X6+0X!URiPzOtbZDmLsKm}7Te3>(!fp~_lg!7&R zBL5F+T zQPQ8IGOG>p9yiLlDUH5%mkVBVj-7=MJk8@I`_XS=Z1^bJ-=CN(ftp&L;m#etCt@j! z&BL66Pg&TaxKi5=?5!}z2F-_zww6p6<610%tPu9G-3N%Eh_xwMjY(p0D*nUoi^M~;y#QU6~AP#Zgc z{B`Mm2tV3|kMaYR4bKo`gushyRZb#+;OEs&44h%%JvH)WX149e4OdQ!h%+ZZbwA?Z zWo?rHb9E)FmX=yY$I8p|@Uq%3Saxca1d(N%*)>BE$?{e~@`45>5Xc%Loh%p|V*2Dt z2z}A^`ZWe{djh!B3ymJI+MnHkRNW3O>n*Vf%+Vm%8$PVLS!8u~*J9(4JA?nmwJf2> z_$1zM)r4EbVo7l}u$;HVw554V+Ye|)zwK5*_MEJ{J?}mK^i)qweByIT!Rj(TQQgFN zc!`}an3rAVSZ}tKjSY$X8PM_#5MKV$sk+JGu+s4dSCeJ%LLz*jJwLKfykGi0)~OVJTqYx+Gj^_Cg7c@wfYV7eV>w4Lln0DIH{_yL)(W;fu8$4j zq2YBAGrFAH<*cU-nIomg+gxxG+Y-bwCTO?p=HRF;+!nqC{F2r1U2mZF!m`Kf9FbGgf-q2DPx>AyP&qj^+GV%<)U>3C(y4?QE4Z`s zID`WmieEA2PRONv*SMTZptnR+Xb*cY!~wudM6pR2 zkU8Ujw_#YCyVL}88sq@nW$3(9G`{15U;1rsuHzRN4`O4Z1udR+fgmVtOy5IOB0IR* znYh9crY5S3dGMG{33jg7LHiwNTsisNoKHSM?Cci0w%q>6s35s8KiZTZ1$lbc5`h-c zBtl3iR*vb->rwqN$8JiES~d8Vfs~)zvNSWY=_ne4w6HUHyFePbf)J0U<`&j(wh7#OxtH>rh>g@c zMMe&{(&v}RlYHTWb!=HGUOp*r2QYO7wJ*IWKavIu;?nvY`+3;JjLK354k~1%>&o?_ z=Yxsb>-`28ks_)OUk8&j?YoP&NN$HT(&i2}!$jm5{2_esf>8C1CM(^4zXZUaU|NVbL`ICNk|00TwE zE8NxH^iOaGSdX&^4o6h_AeMev@Rm|o7w9#3H<4k29O4^&zA=hzaNt*NwLK`#FTZfT zhFVIA+Y6m(e|@|YSKF?4-(!la0T)>{TL9pTr=4d)S?w7DSDEf|>w`w`V;sX0U$)7d z({VAqCx3jB1cGUPV>Yvq6ca>HGUi?pk~SW?C?m5g-OOUwjT;&|AJBrh4;b-9S(%%t zHD8YRg!Q#)P+lurS)t6uMf-Lr(7dLL*PfMGGC`PYIa*jY&Y`;wtb%&VkTi|KmMe{7r%t!j69*Ubdcj$a!Zr~EZ0hoV+iM!qf0 zwqK1Z@kQ=-8Ydfy!S)zt|T0-^Ct-i#zaP3Ub=0uSdkBaa&B|yYglR%7W{+*jK ztP^2^4$Ah6Uj(ZS_^Tr4n8BvG3LVxa6WPTOp4ndHyrK}BHP&2zjCG1{(1j=T_=>0U(Y{PtEF zx-3E6o+;z;Lq8B@+>2e}C{{j!lxOhGl8+CEIGHHONQy+lhQIIpvYoG_1raOg?DmXU zJKJ=}B*e?e^Oo{A%k}AZl-47C1Y$HsWaHvn5R@FOoU`)41qr)b54pIwuFYhyzPY)Q zKL=^CDM4Kr&Yv*>Seo-0AY&o?J|);NelFO!4`S3l+ey6`g$2_7s(PqKmrY%KH9_#C zH%HxEPNQbyuva~oKQpVlKh zqw2&u!LZq$LGFl!M$_8(Y5sO2LwUTsi1LchNJlUj!c3~6z7-E?Ss)cz!3UM=Q6{?k zi*KNRQs?ZMea9o$O>9`XT|05=8zR2Fp^&IqxYn-R6z$x37#XEr#~f=8(INNAd*r~y zx*&oVh`tp6vGv@hxd8zJ=2W$NA^fdW#-V5*-h&V6y}>EUUnIR1i81QkS3f-0$&cFe zg5wv#!buI^PLE{M5ZmH=3c1S$^KNx6maChsIrj*!Dz++WAB2MFek;+^>X91+Vtj>c+4H#|IJt4b>NI)OS>!q7SkfpT}gkdy1uehFBK_UR1W_3Fg#s))5Uh_fkgDu3Im ziHV7doGf;#w+70VsX96vw>*&X=IbGufNTvSN`z~l%B zTJ0X0=i2`B*NFWO{9Q?=8=1{R1Kpv9pyzc(vRL^I${s<_rOIDqKtd-)6bQ63VKkn18oNMpu~U@pbkd!5k$EO? zuqk1hrx~iL(|zb%TpS_SDF;YMBOMU1;d$0({MD{&;Dv@Kp$16q5sKB3OJ~|YLcFFY zri97TMf{eAV+vGhR4ek>T9QY9B78Up(EH5ek~<=`0&wm19S?=7{j~^F)}qW( z$#Aq^W1*S@%ENn;g)?PIUW$hOEF3>MMf?mdH5gM~yyDzbMFVqocDCRxoSGIr24N!iLO43RJJZ*aL=wnJ zdtz~u$VsGj=jWX2*bW?=-tj4$+Y$FDD*SZFqcGYYhqAr0eYNvb{@lc5Y4@|~;wzZ- zO~B%QuQktXh;z#S*f!#qPSJ(TVa1iNDvqJANvl5zq{i>(Q577CbKF zV4{v#=zhXhN}Vd$(*M}6r#?75n|0yXkQYS(%Ut-SC7J!@Gkr~gFVJcS8{&578%mXJ z?A42wOy;ums(?fG-hLzgvaznI>FMNAL3>{QOC;#b*8fJ)wXu>0{M1*b;tHO}QrrJ1UOrRFNKk2a0K*9=ngi1#QW{ZLukgr#>*VaTDpoEaB#3 zkv#TL!0!o`Pcx}@2D*Zb4f>eASJq3#dlPZDFxbTVkt2112oAnb6MRWDI@HtguF0=o zdO4n^j@bm6CS^8KL_naVGgkQVwZ6W3_jZu)aHo;&;abuM_4d<1nh%EQy9RkQn_(eLQM#K*d2Mf3XaEd-56G6i3C}0qU@ZAzFWrb(vKc} zk3w39nXti1;!TiZ^~pHgNvwxGVkgw&XBq99)o zVO)g~GpM5@eSsa^k2-Tpk%2(m$lyXuLBq`nM3lKbX$h%iUfMQKq9ZS3~)Tjhi-c-!@PMb8>I!@aL{a2ilm^(b;#w>6mMq;#sJIdr2vfi0ZF2J z%RP3Z*=J(HK@4DXotw{*uNJJL&1#_SJC@43b1~<0(P-)pVY;L$r$h@M6%)=>=T;t;})t+lqg2hWeuu23)(%3N5oZ8u6Q=i?9U!&P1Nz(9qxG&OZm z1*)5)=t-J5{NyrC%1d)C&Gf1-F|sJcO5C41k;30b7^P);$KB9ki^w%R4Zhd9QJUk9 zdc85{j1q#f8Qm#u*}}oOQqsSR0&tah8Mo1x1ntJI`L2lJXAE6UO&>gOhlPX;S?ZOz zXj)e{wEN;cehdo51fQ%7m+?J`Wdg5s!YAy!IDo`}fRt7587ppZU`D`^w1<_^KaHl_ ztcG30sMj3Uk2K_zI8zr|5X`Xh0xfYO?w5#%V-HhUI4u}JaxWK33sAe7=H+o(W1|pf zfGA^%#w1IPgu`=()$M#uenUr4-WwwkCa9q@6of58COI|lEI5-B5sz}JJtG38S-Z*WOHWdDiF`JK@yCgs9p>Cpkx zLD7=i+Oe}qIDGw0nJf@4Z7zT?P&W0)robfEcrp4vhClwR!oRHam)fToRA58yIX3?}0|-&htf0AM*NEG%p5 zXab_lU|Sy7^35wSo%916wibTr7^sQ8A{7@x)X_mxtVK|;HRg0t-T^d<08G-DfSy?w z&u*}&-+nfyuDRWAMf3MZv?GQ;4Reb=r~Be^-1IA^l6Uh9Gg+kcxIE)FErn30e%T5# zyq|d7EFo85lIaJUZeKCr7Wp-AgOM9!k{c5lujqWt4iHjprQ_Pxk0l&a-FpDr--Tt# zTOM=KvJ|ynMEl`EI0IR*i<=*0Rp7qum{EJ?H>%0!x+}$nnRpKbr3g3eZv&jB)ZZB8 zO7;Ne3N3j6y845jlAbcjD_UD~0H)@Z`8y7OR-%+Kcd=-;H&~glg?_*()`zb5efoxG zUu9Y!hX;nu^X4ef{wUvq9RT?AFk>$I=nyhC&!btGl?jyivx4Rcy;{CXhSb!s#6t=m zQNJ}jJa&9vN>-voOrLMqo)_A~J5e$8 zm@Ix$)~zvskC@(Bpr+I^F)8^31@q>P9gY!)+NB@xoET(G3AGLNq0E`FT%uxZCrPxwDmPu5PBWLcD!z7dUCg5hjlPQ6HRA zrO??GsFfG;y_h&sM*0qs!i6O*vZX+q5X2~?=zby&l^#0TA0qW*6a%lRi6Cxs%~dbw zQfA_ykoPQJs3&vdK6kNH?FeHQegcGAYl3u?ZXLbP-2jE%(!1OKO~zd3BiaW5Bmh7y z`r2Z(qgWUf0u7jDk)&BT=xB`got--1IL$_VN)KnKFSj0#kfRC&AGT&8RCIIH@fe8T zMOn9PdS^AUb6bPSnmEc^<0u?PNVR`(F@x$HwLQcLG0}{=6j|bUiv-{Xjpb$7CI{{9 zbE{BM^MhaJjKY+xACaCWNZ$Xz;vhg;(|1D-@I~6R{lo&iX!7qcF`*zyUKTsE=vA-r zu$){^S7yu_2NV(C5#D1n?y;5x0r6v7u9?Zp_kA8J!|&|w&f;4bQS>U9&@MIHYCfg$ z0kRwY*Zz+l&q6T51W!{#Lj!C9D)t=}yEgn75YPjZb$X}D>1;SU)sgQjh4AT4DP8VI#iML8g7a*fKzLqDBr>4d{XMKLGb9}sfp(qt+`OUk;4Ge%s9R*UVRO$ zOB;c4?|B;1`a%>NB0o6z6qZ}uyajYOW?pfuP?ofK?9WaHG;krS(3JuCLC)$;oFxG~ z4Tf*Is4)a%f^9g(O`#sNpq|d5Pd5|DFy@X%A%ZdRg0qn2_?UvJpa6{;=}c;RJjojn zbMxx}kyL8rVtF)*s`m0G;OYN(Gi^rL10e^wV3naLZ}?cY(^W#{Can5*2*-Qc5>Ace z@*Ivz%b|1i+xJ#EbM^Bp@s*~gW85LrY|JSew7&|Pn?6#3fh^wIu>nzRth$wI38exD zn<@A0Ir*~!Efu^Ma$`tV(tIsGAiw-;#@KM{b8nSV=$f?EDI=8>&UT{yl zQ5OYI_l~t^q={czJlzKjAo*Zk&hcE(!{#nwVra{^ur||xlJlW?-gjNP)j?7;Qpk{Q z>&~8@?6WgC`9H%uRHnv;WeBPtOZNbS={Ex88vTeF?^We7+54bSdMgLjI*#&s9c4ud#Su#-{WrXjcUEOHF$>FB5 zQN`;?Q~1it%6B7LkE7W)&1>VOOI{R{7eM-=BHZ>JKxLDkU4cyFX#@k__akj?$7M;D zabuaMK*~hU4~lC43Z(Dk5D&QOvRhP~7FHsB%XEiSxx0G4%hxA;P8_!!f#24g7_|nc znpj9;E8*7b#vU#?)x4Q@RZ)x5vh)lz^x%wuw z$p{bex~QRur@xjK7xvqrkd>X-jTGoKKPl=TrvJzQklsL(Dpz}7()#r3`E`u0a}Pxo zBxIahxc;cTHNk{ZLLft4cTickFk*TnR!3l-jW-&lwO+7hz3w!JFK+v|`J`f;tu zWTc_iu@8qNx_C0=k-3?@g{qsQu`~kF@?IR2T7gSVmNoqEez;`NVqR`sZz~zSyjwUa z!!vAYBLa5Y8m&sXkB>?!;G#sQ_ro)nrkYb_u!T`nuZ*O}B4?vS&x22c&sWE1SYec+ zbhb6OAwXl8KOS_}NBqinpjE@jQf|u@>z$oR@!$tdFOJXU-D`9ESyQ4`vFpb22l1j8 z#Yy_-+~i19+XC3AIcdL%k8gJci zke>mb{wnGBH;H!3LfX=dZy2aGf4Qv6WH0wWSjV zURwNocBxn5v(*&A1o%gQSPtvX0ok~g`Pv&CYd(C$cYE_83L0k*~rZ8lyOd_uVs z@DKCk^S+C)l^I&C=vg>GC1XS8tYir-(J@8O9mof40!HpoNGoB4?-dVp?MMg$hKQwJ z)BCkHkN^INw_^VH@dsaD=KYjaP3nN~1b5$TWIEUQ*41XoR)U;aC`e@9_|r#(vM)~7-- z8|cWMr&{psS2LcjUwYfW%Jp5$l{DMm>+AX5=gX(~*SY*L6zGhHm+ot<8k!6Ha_M4p z0QTqcHx8rkdJ5xzO-9)ut~Hk3NLuFx#{_5TIXQFIPY*gH@Af zIAzdn2+t6)ahd1UN6~Bg_jl5(`Cx%WL@$x{%+I37HZH{9fab)En%$cpZ7_{=5%*!d zx&?f?oTDA3`(txCJ5`Kzal4J~5~^TpyY0-)&(Hs`bs)l|X6k2uO;K^@n4zZj!UX37 zq}_LiWTc&TU)M_L`b9-Qzr_$gc>94PT;t38ngKQd$l_f9lL7LT{}~B-Na$$;`1U#_ z_=o?6_OLM5(i7b(E$*ioyeXjPQW6y}_l+aI;4_JAR;Z5iJMS4my4p)I%0S+eH=Vmr zTy$Jd-nUlqj~)rH9Dnm?a|^HvjHj;GlK-~z`~}bkQn{?H^W@#vnIK?QBZglL4U)JC zU;!Jkb%=Lm4Z?glG9o1r90xRjrKAT&Q^5Jv4iCArodA8eZxTJKPg3}BB z&#Yd(PO%?|IBugqLgD)plb1dpIDLv%n*)iFqU^$cMu3OFNl`4|MV$k9)EEP$BR*$^ zAK}}Vh5n-p56Ou^t;$pFTe%j2ZPzLOXJ|_|Lec)^VR5I~#7DSi_-|g5l}i`pom0Qe zz8H(t)5WQKBvrZg5h&$iVn2NQ;Di4iZz!@^=h>A2#@ckXDf;0OzzMQxQbeE zVHlV=ckR967UHhyfmR4TP|6d5BbgNHfD+%+o)otye^Gj&WJQqrK)RNAa(*+_;vYs! z!RNvlYI`SIr{l~v)+)1#U}pgey~_{ zc8Qx9Po}0_|Duu_yiL!S2+d|(SxVsBh!c-CU->~@7#X9GHgJ68Me;;tZ~MWhJx9h; zIGKDr-Yr*%oeEH>Y_x<4zQ+!WYikGb;=CifTD@ zoY(Y+A-WWbwW&A%9p6oKj_yja0l)qaKupvYUOU)9h4JHoQK)pB>ISu(yfml`EKIVB z`nu9f(gu4MyvA!X=wClOo7!Oev;UZh&i&a^Z*sQ%Qs4JEM+nC6vzx-ov-}X{Y-?K4b?)<)!Z`q@tmL?GzOIUrLcaA$dEI5ZO z73tz+Y6mgFih)2G8jv=mX`c{3hXUnyMCD2})k^Z(5+E}%y8YpW9QG@}Fg!dAUY==a z(4Z31JERuUZXrYXWW6iz9GH5%zxW}k16*?M@%3{dxI{E%{dkC9tcnc6H}`eLIS z;qZGX7aK@*@9i;R11gJN4XAsBgNA{_QpCXWG>>c8w)m>>6NJab0^cRwbi?I^aI&B) z;>T0Bkc0a79}nt0WAxO%+2{*yVJN0FM7V?ye454E&?2wuV3}%|Q}%2_PxBfz0pAUF zj~Ev0&uDCyc!@LwGUb-bjqH$1z`pjy_tK4iS~7bl+nahRBX6Jlq1672!-5F73jNyd zzs0i1k8l|7A1%9ea%wezsXu3I@(=Stu9@fGExRI|1o2RdAxfJmKkSj0lmKl;+4#NT zLbm4elx^Kh7zu)!zrKx9O3h035LA>APWgmm zMVrA-C@M=`Auh?{c^2=XsfWKwbN=z5{?J=Mik)7-_e(f#nx63o79;6x58PUy!lp1# z^D?TL(qKR1o~A?r;=&uE3__MA7z2_>%(O9&e^n-Q1M&#y!Uj~ws#F41n7x*GIcT>u z%gBdb0$_Az74`0?s_s__XbG}}UXCI$cM;y#y%Vl0H~k!^KkfL#;D^e0RpjMY>yOqxv^_%?NNic2#+O2y*EI&} zRzgzRRs=cEJ#@OpLz{qltWckjfh6Jga$WJZ=$E<#86lripMsmzxTL4<0^h(tylaT3 z35UbMkW$l74QqxjR_oXDH0E%>i9;;O{jBtZKIW!^PdEo}MekPmlsG1TR9=?0l=g~h z9Bru+umbY#bnFL#K+g7$vy1Ruw2RO6lquho_n7<)eui80Ia|*Yp(pq@ zPEH>xVU?Py=pWV~KXc&}RB^fM3@vqLIc90`aI*S2HtNT`Cy!sa{8VANCK&I`t&-KS zwAqiHGW{2o$SSahE$Wvo$7G~w;9x(b?EG-J&@&-MS8Mxpu>9p$j=3mWFS&d{Q`HPN z3Jqt zXZI5`Au>s6S{A4O=Wh(SB>w}cnEIM0^L@kYX`N;-E+b|$&o}65fL^(BdbDJoW=3y& zm@#z?D=7ZNI{!!iEg=ymiDK?25fs{Ed4+`)K`QOIcj_D2Dta6*{pj=W#-S_?d5cPM z>G1#o#)cUp|2ULy4D!}4^!b^iUC*o%BQ&bc@3>f8;_OW{hA+c0=?3LDY10A<-bZ*Y zWWGDf*F0Xm{MnW|@Z&J7f3I2ni)}L>)qRKe$L^C{2@lGuolV6bw>hZsaOPwgJJB`R zp4Noof40fEd1PIJL4;|!G3j9&uyd}xwf1Dj|DxgsB;(DVwJip$+`K)H#D~ULQx?!W z$Kel9FC>tO1(VBvl`m#hSREqlngjU;J)v!~5 zO=tWj;bsvZ zX>6X5XWERt@W#s^oZ>ca=4~S02)SEW{W;Yxq=9dsZ!YU|w&#M$`KkX8wW&9YIcD~q za!S@7)`YKJ_Fm?BtUV5D*W0;RKMZ#=HJ{SG)5!ne)X?;AWQyLc9vld|Vx6a|Dm}P{ zj40v9grG*3$ws9)k)a&YlUa7^xf>Eq)@;+z_&y8!#%|U|$zQdXmO6lr=z%grJV)CM}>QVQNb}Zh^gbnQHJ=3X|?f5tLj93qWMTg{tIW3I{P#6 zvvSX0(eryNjf9UMGRJ-i-O2VQ`cQdmbfj}-{a0#Z_loT?T=&NtiLw5<3;PXswv4LZ ziJw5>{i`@({Yx`RpWlle1spuq?^D+9h^3^tCt&SwXOrGJZ_}2aX7NLL5*o@u6aYTH zfXFZAu$M^_{6@-5>8*KcBuB1u1*e=(^1vw1r5Z9N5I|Xg_ym#Nv*rs7h{H2*GQ|RG zZIt)53fisX{e1L*pUY$)1r>T*>aHTR6y?(idqlU!%2PzHvXlw%i)25yW&lOg(u{ zxTJvUOj`D=1ay%6%oZL0R=#c@B%y>+uA?h}05=P)h93iafvZb6H-=ejZ)k>ELsC|1 zjP=;|!-B~#!51}`?swJkQCgwMVvxJJ9mI8~XfOS1Xe_@l>~Y)HD;`jBC(EbN=0LiV zaf71TbG>yz_Y=6C65YUJc1kwj_L&>vGKbvX(f`+gAWeNZh>8$X_6z{tA$s}ok!?+Y ziV=u|2-zHP8W28gkIa&ODB3?7Q@prF1?<-qEX~9z((^ob0QE|Mbhe)V*>+!G+et}3 z(U4AWzCe8W$yFy4nV3t2B0ORiJK4;Wbb|WlN3<>%q3@R)-qbx0Z`fiJ zJvhtX8x;2cy>NI}WwtaorpAo6r?A@lroagwHSF>E7xrprXop56VGu>F;j5#7=dtAX zJ!i{|e98t4nDM$KK9Vl^mtLO7sf&j+;>)`!#y;kk>4ZnsG$%8$fYgUSM*h_M!ag8< z$9G%3${k%;Jm1BouZ>cfIaAQYu*QQm-8hVO_* zQ(f)T@D7((@I2#4uaNC8ArOeDe1aI}uxhH0UXfeq3~5!WwbkD#SKSnx4sBBcK3kcM6c<03CjQ44}#*3Ne zh6i0afBE-uHLEz}NY_%OxG?nQ%$V$*t$QO!4xZen&o_|-OOa#0X6PDtYm!*0Caayv zaiWwja_?P%#-82_Yut8XuG_tF+)`bGneqUReBcWOxX}Nkj?v>uFDGs{8h^i)W8yf1 zQ+8O#w6{(uzCC(UGSBk6T&j&#XjgJYU@#T?Bfcvi;3uo>Lh8ewnq?Lp$Ym*m>e;^d zZ^jh4hS!9Lxt-k+KkyD8Ll$T7S@qmlNwxme7%XDm&&bPc{q_1t*Z;nGE5zlt7Jz-NTowUk<2sc@|kAu5U->rfc}=uAe(tnAH137OU;vOa2kyc zx=JMw$O|~Fxn1i;PUJ99*`_bF18=18MHVI9i>@zoX&?LmH6licKeOBe3OSmuv>3eV zgFOFUG;H)N6iJ2&QP#N24*;)ga*Rl45>5JCnbDD(5Z#z!h^iri5(3;z7P>1ge0K2y z4|XnV626gMu1%Z0G$Zul^sdL4bwuXg0K-wb{6=coMZ(cWy;?(q@ebzRGP(Y?alQQ> ziOX@F)cx8yGBFaX{%7ySsJlEnI9v>tE#=q?(+PwrV4#eRjGGn`zeWEKR8P2#f{~A` zdj~VDFPamr>K80uW+7rtQ~DVvInl{F$0_?IB@01bn<_Hfnx?Hb`n`e8w(_Lur#b*1 znL-6|OrP4qvsGW`qHIO$Byi>dx*Ea()L5<<1^!3yD69-<{#f($>bo%AjE|B^pQB9V zc5ddmrNrsH?P=J!O?x_0m(F`v?D$OH>~go0dapV7)cnx%a6pZ#K~j0sJzCFY>20aR zNbyGxF(3|E440z9-Qwzbw6$D+dE6d`P~t#m(lJcvC~%{-I_fmXH0hZ zsT$I8KUN?4If58$o%WJ@Aa#00d?Pvi&U>5Nc^mitcVP7YsB2gd=%FZBH;S7cArZX}P( zsk-wyDmzA`0QMYUlXhE+&Rp$^Y(2yY@bS}&>W>kKQ4ZH){1E^ zXDEM9@Tbi#*LK{NULfnol;35o`|QUR{M7m4S^ejqKpZQ8BR*tUVIlziTvr9GlOCln z2c(CcD8c0&55JJC)mvc0X<+;fTg66MGBX1< z9=#52KH9CPmm;kHlnq3{Av%g`i(H@XZ1d{T)E6IqU?Te=kQKSKLUh*8tVs}BvwEOc z@6CdPjP6p7IG$CkfYsdYKmC)VVIYyhy2$p9wz`JP;`1vZjR_tlUMzm=9Zd-ZPx8tYO=t4py za5sYy%xgG-ULIQwKq!=N11DR~O}2o|MI#VR*G*#2=gWbj4K+jOYZjO9=X-`|8hx)1 z4xueG2-V#>kq4vAk0;eyAQ3ZMm?0m3Hj*{nH*iv*-0%4mHr!D5vul%fkv9{~9}(~3 zd2R|!q`DkqXFgff`H&~1Z8lVvx3!9l} zk4}_4WhSA@>;a4U&Y4WDz)0F{pkvy2pL?(g&D549n#=un8~%E*UOx`RrA@;q8=C?8 zU37}=Z+&F;JRAvzBYWYrM}xp=!4wJ~q|2)=czVYy(ZEYW*s>%DC|Q*P4!S^jCBPq} zs=X849))++c_vfc7=K&J%gnmjcJEZB^2n3dpaD$(MYF`{hqUbVhhzuSI$L zdfNso^+Dq#YxKsRU%EJmldd}r;-Yp0#1sTcCs{pAxMg5P8UK&1_kgGJfB(lTMRrE^ z7P6C-ovb(^BOE)9b*zvb3X#l=V-q@r!{J!jk#RWoI7mkJmc9Ak>b*YS-~VwQkB&|c za^Ls$y2kT*UeD_WDNI{UoY5R4d9}`G0x;0M*3bXIVWI1qS?s>h z6$%0fdo~A$aa4u=!9cFfPlqaBoX<(0tzhu0ehl=nT`6BJFROSs`SgGRq5~L}SQ(RCdl<1tGQ-=WiA2E=<_n z9wQQ<6`_sfH}>0VrPdo`lo;i4ZvqYl_30bl|83Bf=e|bBs8xblcwh7|#p!RV`kVMf z5uYl*MVR%TKPi>k{5V}uxEwZ;nd=9X^myo*FYvjR4(7v)YZpt0__FNb3fRwAYV7yx zHF1YuLNLb@MT-nLg;Hj@YjSQ5`+MQF(aAp0!wB!ihLfKv$47~Sy62;E{Cgz=ELgyO z;3n@6OsFo37LC3Qv+WsTXValpJc`1{Mh9tx$(AcZi=W2{xPv)VqH5vBpjw z$bLjNUX)Uy8cISCwKh{Hx_t{#Ye{GHDPgTJinNlI@AU1E$)uUo`z7Yuil@$QR91al zYMGgthcGqfQd8}xZxKght4nX_w?^D@(pB7iR8`|KWIwPRhv|8YG3 zId{+xF~fZW0sj-UuYf~XK`p#s$R2Wcs9Ad>b%>_5&~#8=B+1TgvD)zQyR3GxYyrI- zR*W@JBpKoW6ZPznGxZLJ%HWSTUN-Z;zWqG^!)?l^^Rp5~hXo{qYQ<^7F2nAG$Lgv6 zsLZLCDcXH+<2SW_}4alve+ z9nj|;F>1Ja7L)I)wZliF5gu5cDLH#P`xvX<(1L|#UfSt*pXWalw(NgR6c#YR`0pb@NIq{T3)wl21W!_&Y4T)stRjEL%BzMgR1gzJg9*Ehr6HzdnKs-sMrv{uB2XuzRVLL@3)jGl_|Km zZ?$?$--x(^k%_=La@Ifo@wW&4{U9;!tRO0~C`DUx7gbWzFra;HMr!&qP-pe}}k-tKU4cXnn1O(4?=I01ie{67g)GS7>Jm0_?>$4^No2t)Ek7` zj}(7esKE##g(r~pdO&)1S9y6^C&J6=>+QPYRH^%QJjudOFgb*$<>*SxQ{WyEI{#%= z!&EHc69+m2reS$ja=2h&uA=o6+Z$kdKU>9PrI)<};dbLYq=OAX%n}T!YlW|hNhW`| z^ehc+(8{zOW-J=8!b1bsMyV|(X0q@zTNgMDGY);IC*xj9lm*LS!6axA7Cn>KOR}PEqT$e@LZ{@mn2c^_zvf|XRJE@SnUZv0 zX|K{q-=2*RY)rF>5tXixAvStNHY{EH7RV`YuKR7GZ=kie=c~Aue;m2J+}#l*mZy^P zf2!W@(J6#XEjdQwTk32W3a>SkxcT>oFO7{%YHe>Y!)_Z_9FZB8+Tkh9BNl~AwZjXz z^DKBHQ|OP}kXwTd%nj4~qMbrx+V44DBNqJR95H~3g%<}7ukWvdi%jW?>mH(ZAF}j- zoKeN^GgJUAd1(8YigxYYxYG1Mkok;k)FZCa0I-Q5t#X(glEwuP%W;#3-P9|-0%m(6 zbAC?C&-Qzo{K}=x6fRo=b6Cyf|M9XtulbDX$eZ%HD~F#&gNOv`{xSbxz7SPVymT=n z=SBt4MBx9}nKv<%UFgSId}M$4$=xLJSl@KN0>DxkP$2?P&1HIht*xlMWwVq0!9pM% zyti6$5IwLO8r3grHE!QobQOIJ0{WMOnHzR6*I|^7l z!6h>=!|w{1|G@eFU(WTBFfJJ3r2EVy2EJ}KKU)MzAs4e>1(Bhs5_1PSQ;K$5FegiJ zPf~l~GE?6R1)sCN{F6~|SE{#@Y(T1G zYfaLvrk)ou&>dgpA~hlGHo0s%n|mg0;j(#KUi1~G-v5h+PhSPDvAS712nEM+WDjB= z7rCxO+*&DT1kgqSeh^SBuGXwS!n*NpERrQ2>QsfDjBN-izj82%cEPUkK7^5=3-U6a z1;r#h6=~>MdLx)47oG!!0Jt@cHy1Q= zY4TRPHax|-eN}QO-JYpfw4QmpYz4CN=0N0rh&&J`6jdU)KpSxz9jx z@ZqXHg#wI*IA83CXF3)A+WmYNO33on&%yK2F?6Yt;M|(a9>!Ja|9TL}PxpUW0{}7v zR2N^aNN2eZqYGwT67L?pR?ss%nF`_CK6n4syuXMi_$5iZv&{Bel+13g{?r17AmhOi zdq6t#(cFXjU;FZEXtRLI6Pk;%v&E>64t-!wB_PT$G*A|;HPn3LBKJ(Z@79Zs{ma&x zAv*+^)%s6N(KC9(CtAY_>e$qIFG;Ooyjr#vHBFYen|wdxy#B!IczH;0cW2pbc|_P| zX>^OC{NfwXWZIh-Sv$W;M=P^8W$AYA-0`WxgaRnY7lyBbO@Q?%yYvmymGkwSPtIo= z=9H$GPIldV7SUQfzH6D~POGz&A1b1ng|WYat>*i7XN>Na%rT4HpCzYjs5=|$PY&Rx zN%b3?!7oJC3w^H9;ez+7!ji53Pa5-@?={|kJl18Px(aUIG$D&#d6aw1W|7d}E=gB( z1?)ZtAfFYZLwN-Nk;UpWM-U$^wEEhm1E8Wc7!K0kpHA{}GF*AXCls<`B~g3H_wRno zviiz=7!FV!1Tx)L%R54Sk8m%}XI4)F@}|Oi(quq@VZ#sP&Le=}b*o#`%=>)d!=V2r z^Zl16yZqF>M2KxId%X#(!2`L`Cq+LyJQ1rvk!5`cKCsz%p~2N~l7V%eQulsFI{0Jg znTRh~RwgD^L0BhI`{9`Aoc?6k1{k~XBSvYm@riMOs@hZc#pE}sl4j4B-4OR?Bj5%d zOdMmt&43j?guT#~dDrypcqrYlW%m8zAdmwv4GrLfl~+|o=_+<#NAo$2B$d+? zJ%{H7$c%FMI>^k<#%#S5gRB#*XSdPdROQS#5Ftaw~riwEd~C$yrt-V&(nk6V1dpi+o#_hBC%;0broxJnAbOWXgstKXIjd))eebzr`#fN+Q#1zd%WyR_CYY=g zI7)Q#zA&#w$2ATu*vbD`iy=OQ6*xG(yex(54`Dgepi0I+T(<9HG0#Mt1F2-pIh?4O zR~^s=_W56rU#o4B2mkW}9@+CjN}q^c0n3b4eqGU+#SwQWS)Uc(sNcxJ`3#)$9X^@1 z>uH-pk}*dD>I}Po$ z^BFcfe1dom8YfE)6Rj>8%>JmJDEsvmXyD*3p>e)&S@ZvRC;;>PkG=oKKQH~_UW}Cp zK>bq5g5j_hY8V4>qEB|-^3`ILN=BarC91^-tZZ)+c7_4uV_sBWgJxBzp)IQX)y^%F z>*uVmqvFRg*LVa^cF^&rJDVm0zVO&rqU?#2v7RG1>=*9i8A}Ixy`@cp6EY{0 zrysi++Y6(Sq^L^M&&JnWsvYXqmyH)ldGD(gpyVdoLo(a82F|Kr^xv-fKt77auo}{*%v& z*gS%T#=cS8u=;GZBQpi`WqzHre)F8zkXt?*$Khmpx@`(n=TrMM6pwXss0W+zK-~B2 zevVWft9u0i*sKy!V{ozw^$o`jukNI-R6Jr(!+KL`*sSOEHu&iVby;v*@SPavU50Js zmp~ls=e2M<$b!-osnK_V7if#kpK*jzCe#M*cGPfE%?@v~;!lOKF4z7khyU`ltiKb+ zzsNY3Gp@krm$3HsbJP8npD`S{VXTEclKMX8E3M)OBebQOF11>RvFSU6&h>?@T7b|( z#qLng{r3R7syr9Kcuo*CFCC^iGfSkdan-e2o9{7Dv<%ui+}fj|c<83%nm^YsBjT${ z;!c12v1bp%UgmU4)K37*Qz)|Wa9_Lcx7u0;$d1en-prrVux;1IB4iZjJx9qh=JTj0XwX3R-}Zh=ff5_OfRGlXkWp+j2{9; zZ=purvD!N#Xp5iX8ua=CO(0*dA2nNc9McdW1Ax#3+=SF3j0C*b!W#1oD858d5A;prm+HPgdk z$E&lGE6lE%E&yMQtaHoPBq@1#`Fm-yI58ca=SaxUj;c+DxXJZ2=bk;vr`}+G2*0C> z0+n~>(?5yAU(N;dmX(m0I6&c&j0fXw4EU)0yK7k6?3;L4`&e_$yZ7&5E&zN>eSoNJU2K_cz>jKcW6j=@Ms|I92V_Rs<3S-$qqUmKD;LNle{C&NrdNbG)p*inCJZ~q z1MD@Hp!c2|Stawl31@+yr(RCgzZp&+lZYr!Nq#oC#luk+B<86``eWz_K~C_whVs7$ zOpiYK!!Q<>>sZvNFg~+LU>tKDA|WM==fNrjRJ9_NXQJ&&w!qN!;XhRC!esVGFoY=p zp($&D*7ERK3gzo!SpgDcu3iAx1|o`te1+#J!+I9GZkaC27YUpNJ%IvxGDKe#(Wo(G z)A`mLitZ0F6-!5}fa}fnf_QhUUGu$f zlnjj#PL&U_A)%R{`sR;Xpf-E;&TE*P3^904HHa$NZkml^4}U{NJnC28kL}Q#-CqYk7FNOCZybyCEORpn26{onlaZ-(1tkiK*&ZFIAJ<^N&vmg|v% zI~&&H*YwH4tzI~g*36za#6OFB9KFu_X_+u5fBf^66uOFc7H-&q8BusVfa@R8SLrnE zPecYHvOpp&|6n(lIm<8sD<&bpGwDgL9)cmr-X-Z%PhjSyR`Vruyr4maKVzRI-)f5ULkBa0%H>I z)TJ+qdQbJkwa}Ms8uvEadOosr$Q2IkTI^MQolDWHm}kt88+yYF#o(O$6EyAaW}?~R z#eVG0$lwKW<`ePAH06;A;V>9LN%4&GQ;U|l-~m8);P+M*YzA5c5Sl%4R=vHFnwNdq zdCV&Eu%ypd(<|OB-N9Cpv$G`tGJVXY0b5^M&-Lvt!LVI61QALxg4`0&`E##YSO;6hFpNrC(0v*)*5#&`i8a~E^>4#e3UraRRituYM-NdWn zuWuhbgv}1#lBRvjpA!unx4%XWDsO<@uj*R%by6V>hGhTASKeYNW9G9U8V~2;+(5Rr zEjM+W(bjx-Z^+X_P|Y|&d9~29FC^4Md%04DyUIbB-_%I$?YKujd+$2D)8ry|Z=9$X zEjg>dv9nhKUxxvPmR>j~@|P+88+HHj5q~wPZ?a%+K_(1SDL4h0_8Ryh`7idcS-svK zjk}`J{I<*Me@E@U6dKf!m{(R+Xl$bWs-Aa$Oe@onlsU`9y&suOCkH#HQ`31|4LJZo zm09NaM`5`KW)={M4MN0N9Su2tlA93n+YLW(toPD*q2BJh@&-|+Mxs8oofi%`!Hw)W zssDjui-2;Az_dQbD-K3{V3Ma)X=1Fb*Xtov`>1w^c1+eH(@< z>vDpo#_^$^j-pIM4uG}Ivkb*H#cP$2v+r--MWgWY0qEAlf7ktz$FtAPw7StjtxXCQ ztq}lf^_fzL(0jw8qi@#?$6q5@!|S@$n&ap0c33PkV?FH~lMiI;5$4^Cx1~IF{96^u z9sF*zLXdDgDyzY4Q7Z-%6_P*iBzC|~&A97ggM@Z}bo%ofTAWovq6A15%PN?ga5&GHsOEO%X4F z!OJSAAUa^)Mdi6}`q?XvPZ|Cz!F+bW0V%s9ihQ}AgM);W=#fIJDIsBBbWvg|;ef=` zJN+3CA$S?{U^YNTxzG@n6mI|7xO0j0yefW(!w1mG|5&0uAddhnWFZ0ZJixzYWl{PT zkR3vupD}7O#=r~B{swUDInf1$m-&%1KyEF_p zpA=zlg2TCtop0y-np-;m=gIz&Li}UJA~p(CwZz&33LWlhKjOW(tHG+Csx{npT+1g5 z0IrX>o4!Fm<>v6~7|8zC>fG9v789gL4$ePZBr{O^@ku-0ehV+2ORjj3dTj>Q|0!un z6IYpNQtVD{K*+rDc~4Jk<%N(pV-3%dMh8BJ{_3gs1yo`aXl2kLA+KZY|L6C{%-yGs zF(NSScx}6bu&+SBP>mo$KptCBq99ubLiR$(zRa1tf6lkSquD?6SydWnK(mRgVx_=s z_-Q0xj%Y;+9cq6}abj{2Ljirlcg+}l6;@-Wa8^NsjvYq3>R#**p~;(okwP|x`ojf( zezs|J-|~7~-f9F93UY#Z@kAPL>j&h}8u(Ao!qvrq`5_VNP`(hwowru|25j+%C;F!y z47!7*aj|&u!H@47byyu++q%SMaF<{ZTAt@7Sl`bbd+5C^I0E?6{Pn~EjWMo zQ?a4-p;;ktUSAqLhXytu0c?4dSC#IKnESr>c8-Z*1Y0bSL=F$#f3MGG&{4_!N< z&t`y*phCtn01`|+@NbnnCXQM*lYZdy5Pk>VP%Tb$6R~j`&}sQy`~_l_A9PGi477M{ zUkrU6Y8do0)7kD^Z?fEvicay2=NEinVq>FF zS}-TY!4<#s7NI3pQTmU&|2vfP6QLXgi~kzd`)gLrjt&ha2GqB@smgBBKDYPcT@6`wORs!K0~1Q*)(8*}^-$idKkE$%OZp&OeEy(0q6@`z?-7 zRa%_QdiUG6nG>pr?wZm703Q!k;yhz zi4;Qn_P&a|-shwH{3bOo``Max0WwNOaEjnCIh*1Sl@P#p!g{l;b+FIFrez zN*&*j8)}-k_J3Qae*lHcsfE90J3jzU%BSc_cutiB`5z&u%OsOSB{oX!JR1`8n#1SY zy;XmXfU=L|n9PJP;Xbrk&!u-}-)#!+4yThJb%c;!%pGHU+;nWAr z+1ZSm1D6f*wf9Bwdd3Ptf!}CEgwUOY1sfvz=|Wc5Q2@$Hz*xFe87q|=eQs_15*^)9 z_+G-?D!~~(H_Rbo?IayZ5cxnzy4tMi-P*A_5XcFQhEk(Udz+gl?={wOLW%nZeZuVZL4Jz7R}w{YosZ`Y-l)|PGlAan z;f4aKL-#?*#l_kLo`J6Zcf4Qn+}etgdb%lhuX)HzstIaeUnaQrezewNv;MhrDaayR z*;3WQCp*7(*)PsPY^-~OvvzEa^JLxnVoD-08S|#ARjQd~+Bb9Y;w!MQ;D?kA=gose zH&e@xFhDcV?qoc}nAO`gUW{Yt{;svH&M;bg&=kKRf*8zp*Q9Lyu8Ja;5XHDeXEnAo zC+!;+>)JWyR&#fFsK#krHEMLxFPJaq)W)mooxvyFOf_!k_wdEb??=*s#n*r>8sw&> z?Q|wXSNgy2Vu1Hu=ewq*i*q#Z_0`(Na4Y^StXRspE>ATPocnuCxGloyvabzymS{S@ zVif^w2>WZb)hydn6)Jrgnll7}|NT2yfG@Y37~FfY??+o|>c7Y?z4v|mi_3}lsP9I> z7RzezL`93}nJGeJ2^T=U+)%V>UobxD21UNd}28hCCEj}-fEn-f_H z^KT6OdyxgpYSSA83xCU#8qC0(V^{I6l+WqORwF-R_L_~DAD5MQWn8S|je2gl*jZB| zqbqj0ay}c>QnZ4^SwB;uHma3jBq)Onw8YBc(+!uCu>PIUO_Ikw3XeDLEKBcHF*k=h z2fPdKt&4bW`Tkt|p5L0;eUH7Q1h*dzF^o3D4=*_RRGWQU+Uuh?B#y6BK>l6%K?b2M zQ-(CAmF>yF#ek&yoeiz)XfH&a8y(tV#B9&V z#F&|hMfBs@kgjY({$8Qg7J|69PLvs9ZK3`=YymV?T29^U0uR5Zu;e6M-dPSJmZY7n zn9C?x%?QY~wEKa29qAu7)~HDJM0q$FG`fSnE%8cBuRAtSRJin-FzBB7%kzL4{p$`i zzN6J_t1AX^7GTL4Iyt`?TCQWR^ zlojMj;{1QD>u*cbTH+Lm6un#vmh#Vu2xlqezXrP<&JXG`v9@sW>a?NF>WgLvReD(K z6<4l*s^t?N1^GG+DmUP|!8D}3^Bkpeo!kyUV{^}FYgtasA8U*W;#-xKKrdABBU=zZ zJg?1CJ3rj0dv-zCC2+X!Z6IM%b%W9{>eZ?I#od4I|@ zll^Ctr^88KEO-kF1TFX8=oGMmr0jb%j7Y;XEg3*e%qlb7f&c8K3A~)~IUuZP8R27# zrh(JxSSY>(+*&@MK1nv%vag2D#YMGP(&K<5k0i$9jX*g3HLAcuC-E%q%PrKDbgKaG zxtsAgeN9$gua8n+BGL|tn@*R>bk&a;PnDKm4nhs9zTj1wqWH|lTv_M;4FzGaA=F;6uwD*Wc@t}nn=P|jUg)1 z=8tiA8X`i>?q~3E@KJ1QdNMHA+oCxe=SnZ$3uGK`I&uk`FK^_~WN1aO+RX+e$mb9W zJ>b7sYUY~njDoP{-artyFE5t(Ar$BDIlj^ zGmfQjG6GR8(Y_u&Dunepo-dnLwx8mi=n~gl_Z^7xDp;4t9{!kgReVkEW)3xX%pF>= z+)oq8yh)omViY9=g>cKq39(>CMNutVmjY7^0LON3g=4WhJlU1A!tK_~T@?pGWk5bD zF>CWfmbhu;b+FGC!K3p}Y-*q3xL@MU)$mdAQO|<(d63mgK%WMrc7?+t=p7z$*T-u6 z3*uKK^Q8dWjur?lWG8q2S@@par*2DUPY@{@Df%Bj4$MX&mTJ3NW@uV%@NdwKI;+4K zV2>QzxV!phPJGG?cvzD&8YE!gojLvSmygH$?H{k`9Vqb;4pttdQ40&6zIckgen&km zXitTyt(g(;Lh26t!O0st%RTF-%2?(&@!jNf-{z>^J7taSE+T^sA*_2Wyvh#kJ=70a zL{|mb!#U4NHeQJG6p~!rfBEEm3eP6$h|f|#S{1mt-t|HlA3q~#yRZ(Sfzo%Ob$LF( zPyf$rSd0~fUrv?h1G01K%T5J+#7l}l9z3g*^PekcLqo%2S`R{@i5tSgC1$b;g+VbI zhOd$lB@V3Z$xa2wvQC@r+Fgl1F>;o1%o6Ngo3H!FE-a4v7~tkyPUh5bAF z9KW?wUXaJ@{Bm5O>TPwn_|%1yzCLPS;70tNRzIJwwHZ-8`TZc++Tw4w@#kHX7Lp&9 zb@sVQ0Pep*4XG$^3)_}^7{2jE4THj}PuS%V3)Y|63%9(xLET$naiJwK^i>gy}m{jdMk?4g&+oQwC`+7ksh7N3nIL)R#En2V;~e*RTSynR?O72 z{LE=b;<44*vLvsM^%M7BquLQ@o~FKCwomcBIH8Ce<0Pl|?};9m1(glRbxF=?vkhk! zsFD$8p6M172lOcUMsngd2u8Y~`37-fz`j~s zQSvc{vxTkxD_oZllz*O(g@ji;)Wg|B?P7b##evY48vqxh19+-xS_SFw1q1kj(z4{` zYC=Og$nq8YNAY>TvowMU8hGiSJnr9JWZxwrN_JYGbf0tT#dxSn?QlvmXJ%Q!(~}eO z?g_6t#faY9EO(S&n)^J9qf-_zjk$K57je|^a(N{{*5yQ9`qiw2gVz#S*JL>!dmD_c z{N6=)f2s_1o|C&Eqc6HuFZ|IdU0TIQP66i!roJZoxDCbf-z~X@=o^=a`;v%z7~;rc z(hfv1`r6vAjn7BAk!SGNb3Z17?xSiT8>`TqdVx3}(Cu8iL`d3abxj4B`KNz2lKm%q zU2gjp&$2`OcJHL$8{=M@0U!N-0nK_nHcUJ&jFQ3P(@qN1UM2yW_>-5++I9~s03Ruy z6j~=_Ds_^xb{^^L3K9^`&b?!b-^nL$zHpsLl|GcauyUs7By+km9QnLGKRl_*YD(}b zcl8C6wAgz6T4);Q0#lWyrJdkzyF9O%FL8~VCf4FZ&Ko3Gj8(xEx4LK)BB)qPBV({@ zRCHm-$HXEentS1>`{U4~lhd0PUgQz%MqY5;rZfsl@K=|bPO)rdyBw_Xu1|5Sc- zGT438CKWBxXs_rcq)f~UcX&LE_J;a6Hh|6OOCxWJGKNrLv>x3NO6I!o8UZwnlo{|` z8dGG4jZ?{)-g|Lu=m&DcODsidUY)jX&eIrDgCgD;&g3o_04g!BwVO?#mb8SZ!!AVB z_hhiN2cKp6;+$x?>uPV8MKs=GdSy3$@`IIlGuJk=5oYP_*^#O98vut{NI=dLFIsv_ znnccr0#p#ia>s_PIh;UNb+XUKds}&PV2xd4f6Xh^=d8l=r*OG(xo`$2A?E5NAw-?5 zjUU?Mn?e#K+)lY0&U~^QAv)f0w4EyVGaPww^L(#fv+-*JwP|Y>Vf2OcgNk2eujlGg z`4|mqO6tV!iJnyDkNdd2!rX=eKCL1Zc@PegMsas&DF_4Oq-r7N_fzJ#q$S@m{!*N9oryZA0*)g;)U70PIGq4uZf`;_r? zz&4uYl!W8p_df)OpapVC2`sDCTu8JQ4wy&I*Rv=bUd}z-tUcHuwD&uS2cDTS15j1b z!2hbME)VP9FQ!{)?bk(=&#futsuDdK!z$GuPjZiMPsfhhpUt33XKgEX$Bq_|Q$OLJ zQ{U#PP1|}_2$L%ESEw&OrJ02+MELU_d^wx@LE&Ks#4GjH-e2TB6Y{lb(~5xnPI;e| zYvQU?UVR(Oa|YfvCRhB$UQ$(hGf5T>W`xH6I;&`g;!w7!;_51#(Net^m zLhZH2!pO*kqM~SyNc!3~)H}Q9zQ?UrjTPoQ#Zv*Nt%+V2WrDx3d1jqQ-R+sP2?uhm zV_X@7^4Q*U)oN#zxx_}#l`W$S8JGDEGq$$de!>rgX9xR@WT+N0N*0k#>8)=BF^`G< z8}l#e3;sV7QZkp>&dc{jaFKr=z zibL83gB(n?a|;JPIl&;DJQ+?EeB{tW7)_}re22#*Z@?ZAYDi~7{+?~*ekC;~$lGZDbQ5@( z?F~kSANFF1QPCb2r3(8(wc@Ut-xGj(M6fW_m7%PQE-Be6*JR)YO6-+~L1Kuh+&FV? zQRFkXjGU;rF>fyAC$!#JK_O~PA&J@r@#*~L#aGPcE53QSr);b?De9BrZN2H`k*Ovf zWW(EcryVp&DF-^8xS3XnDbyN!4@rxrBQ;79ak!A`ysM1=aY#{GFF!!kd_XtGDq^3_ zG|VxD08p9l+g&*7`fg0au7U{XSm-7M1Ghv!GT#mh)N2u=rU)YrY?-#2!`Y5sB< zJPRho0=mvysto%gdK*%Yvircmkwo4LzYbQr#Z?Iuf#jO0Yg6QxMZ+=UdtiO#U)ev9 z|Npm;$bG{{@_-&(0H(6DdA#S`WbA5cHiY9k6SQ(R3Q)+I%~Y=@M7;^ALx-w$1Z zTF$4h=u!#E>?Q^HtZY(k{y0jkl0Cid?6=E3d9-#gK!lLm^b6!N^CMIdm}vPXp4ra3 zR(c(r|CT02E4T}cxmWBeLt^|TBqv(tjZXAbb3aJjpZ3}PpBVY%^L3aD48jJ1NuI6L24HvvmdSh&^A8z#CSl^o1>k>)LHbM10;eb4|9aM0T zvM5(lpC}Gxc;ps^l8sga5x_#?mCRPCQzMB_qCuHw&W#tqvZWJ+y3-yNa>&G&Gfs~* ze@apl|3^NEAjH=${JVxI{CRs1^YISCQQv(YQ=e4@>4ODd8c`>LQwq0>+cEJYW(xmV zJS?wQ2vmU~`cVIds(q2+q`s-!mwtuvx3{_UH%=!&B_r0+(P8_Z#>X@eVI9I}`8r{M z(_)BJ(>-x#Hq>wK-HFmDS+r>RdBWlMgs1jnI+>kXLHZV%&=uSK{I?tpg7`{=^LO7Q zWO>cQP+Czb$NJDpT&ShpOI9=#OAD?wa4Jc&l<3gzi0!hG5GAv4H_?TC41*OU{4AmN)ZI4wu2RzWO^uR)S+Z36co9ff#?6RYxA zW0(;un9&p;v-;~i=0(}Tt9sGRV?5H+$^Z)iPJ!`vnBEE4^vZrMGsgR85$TJa9_JVH zZ%1CNb?Q9WAB}s3ybci2a3rF=2DBXT1(Ma1K;a3)zFcL9e(%SUCI)l>2?JaCX?(_ z@9!!j()T8dp1HQjCiK`x_<+@Kg151gl{hc8bbQqiMsVDU7BT2kP*)ozo&+_h7Ja0H zv*dyNJWKtk%tYeLZ=UkzaO|aPxehI!hy7S8lqJex> z8t`cU2uKZvTo$Ev`+C|6`mlxMz5K%w@6BvXngYLw>S?Oz8Gb4yLLSiZ8~E7STdQja z@(J2Ra$uY`ds0A6c8nXr)CLW-8Dc#YeifoLQ{%Z5fEV7q^TQ15>drTUyW@AV2_s1@ioGFktT8EaEztO_}!C z`>76f;TwQ;Sk>E!nZE@Q<%MuSZDbNwlM|kGqSEJc?+M#CmMAi3awM3uKqN|DktKm6 zru_CQPR}o*=f6z_1dYg^5>=NaW|D1e2WQK2Mq!bcgVu|cnrR}BC^~T zGhV!aa9ed)AKiv{Kd_TTy;@t&ZYud;O(XQ3fn6IqvpPm%8b#+NVL#o`0MlG~Yl4So zx!CY#)ZP5bwoOa36eqn@w4C;i+vYdT()Br|wCXJfM)dJ?xZLC3i=X3-79%DstIOh( zO=Zpvyu;{i5KXwO%=+~Fr#C9<{?EhMqM%^$=X5&MtDC;tfNP7*x(5f)EU)6m^^Q2p zy8zz;0{pQ){58H5KwW{1HOO#t(vV{@Jx{L_Slc*}XP<+MjECBY4oB)+0_}sRpTo$Z z+&>dWP4O#Q=7>xC=|yR*?m}q^!1WAI=4sv=w!P*PYE_V0iX5;X(_hLL=P^q(9lBqx ztcT+TKQIne6X*b^`h!U>t>DZ={sj=Ge`ku951ay0@e)07b%?YOtC~-$z1Kt#KnlIL zH$Jkt$Xtj8X$1@Z6~_^rA$+Fx1*D*wWEzqVuk@JX~~d&dq#dB&DHT9(R~ zE)IP7E+Mp1S^w=7J4?4{DaAX^6{=x~^1BxJSWhS58096#ExbeO*RK4!hDiix5NcFB zAfs{iM`%*VS+03=pVd~#$;kCChQeF@-#sC1{uqCY2-_y;aQzidP+wkR&~>o)n1%rg zMa|ho_om+}%%IGdqFDNjoGD;KEaS@=ZgJzTB$Mw?QR$;jqwUzhkcWClD~u10N8_{s zG57>%}_Uxfg!D_LW{z_1F4`cPk5O2#hA)^ZON-t@2@u$+Hx%ol*t6-eCY-2cgSWy z*3DdE6b<-e+EJzrVO0SKx8L)QG;;|Mo>92HFCU>PkQsqoEz2zn7T^6Y%^)hzM3Qo` z>muCrOVRB*XKK($6+wh}sJ;zF$s+vFL#Vam_H(aSHqtY$ZqtdnR~lQSJMxfa=3 z;J0M`e%wK;F;9>QFFars5G3-maCJFcvm97FOz@O#S)f?l`bE(TswinWHVAo$ZFx3c zvNw0b!OHLLoJL@P1xcOHXwx`22!}B6S4;8rJH&3mzTJGKlotPkDOUpSXS01T2SZG7 zRexvtwmj8amT;#>zmq2;W#v%Mz=lh!oFGQExDuNMoK-Mzpqty@x&o5ecoGK`s7dV0 zgl%7HLGHbuD`68<+9Y}@Ac+z*g7eguRTCv9T{HXgZ1s3JUBxA=32RCX>c%GdfYn4% z4PfL4_DX%A5}!KVWtLb=fvsi(u@v|>FOCM&Eo0~}H3vZ_D4=M8ocEW9jVj}#HWrz0 zTnD>8FmUXDgcSwazsX=ZRSQDFmOIQY z3}n*Zv%g|g%f>O_G7vSXN_Wp=xp6nb?c;N8i3Vo9p5^EpgC{H^-Q*wgu}N`@k#CRu z;Cwtw;p_7aqe9hkY=eVjG1{*s=si_OZW&24`m0v|S{_sgL`3Ksb)~IF9G>3W4exDT z2<@3B4LD*F->eH;&tq<7*Q|SrS4tFwAiir-dc&=Xsbwv)R_K8(&8xGzr$TKiHEjuR zO$|(z!tODH?2_dJ5tUcL0poFki<`c#^LHzpiA=-%-oNI|sLE#n1#`p2)Su}T0${$_^)c}a<+=` zETBO}QIz5n3RcY1q5HUy{HwgB>Nw_b>Mujnu{0bd+SddkYmP0q&jow-Kjcu6|EcCL zGXTr@->thCrA+((RvimLh$Nq~^muEC3B|otZrCi8Wwo={#iaODYGb@CAWUWSi}WD} zr~%_9;yD$c6#4t)ZH1 z;MJ55X;~sskzs&Z>sQ+k4w?pO;jF#zvte-Bc;DV#a6Jvo{N~+D-WIE|nSRHd@{?H? zM_(en#V^n0)u@9xquXBhzx6t7EAyJo>HQ$(%biJ^dlQWDlnVxyWHJg!pWUxauesZe zE+7R9)}OIXUYzpMq6&J9o3m7$PYFOKk54=Or9;>7xOG&xv_|jo?Bl;L)(9Ig+u9}j--5UZ9w_N zU}OUX9JCt$N;^q4Eo1GNzkD4A9Wxw0Qf<+3>s~lJRvVVi$5rkHS3p2vMSO)r53%zB ztsc*h_CGYXvG|?ci!i)*y(#Y6E!~w1VGtvl;BgOTTeH)|puu9th_G@)p|zmsE*N!{ zdwwTlDKn`GY90>^vBu$~juKQbE3e*V@{`RoGWzxHQ+q;tqj@N z{e7wp0KMYA7UH7b-@C_6|DiPJ)@^zYKrn*l*rQ5us)y4+w8yCCoLh*5M!cCo!H4{R zn$8uXXedt|+Ypqggbn>~zFo0p$ppD!LgYA=WV!5iW)4h=X#j;ffDz&Y*v7`JAU9jm z-6oLE&u2Qr7=Vu#p&iKwSHI3C1pU=1e31k=M(s!Z3R`N|c@?%+nm5n+^75a8xC18Y zz)|n?iOu!H4|Ark-Y9wu=>C~$m#9hYtmJwqV~Mkr^^&P-b5Rj`IN-%|F>;PYlNdh) zMYg^ni)-mvFCWexf82hr_Y8X%$mBbAaAJtH4Q<+wnH-PUHpos*#6?AxRr>Ke#ToF4sTA)c`0ugS!%r} zq{^$#{``f)6|{CwnjdU<{%f})Z)*iFlQoYC5~;fW4!pxamP#EcTRL{P*f_Lc1@pjj z6>c`%HX~drd%SJGY=c=eyHHIjg&S$dRQb)$3ci?c9l6I>cHO^pd1)OIW$Hn>|90Gh!B^%wnE!+`BI|LsE;VK}#%f7a1Q$hA-V^7O|P?p&zzR1!lWO*C5=_Kcwu3 zWDovbiV0EX43+Bx{ZRR$w$mm)|294N%Y+==MK$gaY>zgO6Z6yItQOIJ@0A(a{(~O; zYYRYQ&aW6BWk?Ab#^Yyv;5az?*!#)s9r*c9&C>ozXyNg?(a_(p9_by(o;7V-Y;k%* zg`yO@eP~Sj_hbWxT33URvO|#}Bne93oc%cOJx0bOO2(p*$LH<*3R*4vHlN8Z5YU76!J zsXTsg2h$(S;kiC6JVOjQ&3yMHdvNhxus7d{Dm{$gZXWYLH+DscYEwAG=tII!2k6jr zG1~7K^8{x4ZJgO1JFhjAXAyzPa-?GnMHu3EY|MXF1{ruRiFoLl!K@+6w^prSRkCN- z%+8ku4>42zF|78B$)L*=>`V9e39oZ2KCm?PV6J-LRG&P}gCSBYZl=3{N=)EA8%Xqv zhZn``9Ox9f)nE?0bS*0R|G!f4^0Q66iC=k_04W(n>u(tNCW^_SkKlQ7v5D9VREDmt zjT1+~vSz`DJ+~iKd{d=g@+I0Zs%}_}7EWf?n_sGEg`>`*fhU}%;+?!=^n-byevVDJ z7ziWfLby`dJrK7EK#p+Ac#n8(11uT$k=_znf&9TO*mTNE!UFs$q;IiJ68`;mqpXiz zRNu5IerCppa?~<4Z>SWrWI2!=b1mdVSJ6u|iY$psC&>tW4f!^F*v(}&b2=z!d9tjs z`j9F!Y_CH<0I^dxA1>z~K4F8LX{oUJ)yCD>KGt5`Jkie}BQ@OBKsQi}!!+@LV5F*j zx{uOqEp0lIwZ5ybT`9=UMZ+LPdyZ$`ya{Q6YOAo`diqcHzzxa{oM0{62jRemvva13&cO0U#o;NK4k903oj6?Qfp%<{~b1h+t)v{=p)2}2BQ+2o;^YG6}rf(8>6D_t*3F7bUDQh`+ zLT-G|x%Y$C*M{h86Bwt|Dh&@ix7#wEkxLFyzquKl>_U=s%R0!V3Gij}oKJKnp6ck} zsXFNREkwu*l>_}rD4>9E4mEPsmrz?z`1s@rKxOkez;=F@Hc6GNDC6Uwe^*g9G}K%$ z*cgMCr3`x~9m1ACek;r|9%{hN8cY7fF6FZTjD~D*=;I~wi|;n17>sNKEJQNw|0C-w zz@qM&w=EC^qy(j5DU}itDG_M_QE8TrC6^Eoq)|dTmROLKuBDbnLVBe^Iu${r1?l|G zqR->={y+BeauxXPiJ3X~%-l1Fw6d*CNrDRtuns;U$ihHR1`o%1R1*sl8x4GmOA2SrDevbCq2_8;8W+^NeA2cG6;^LK@gD70e$Yf;9 zCCMjFY?^imXRa})cK_CH^u+nu6TG$8_)Op^Dv?mNy5UX^XA17cjBjHo9vn<^IIpJn zZp>GD75w6x?`SRkVUzwQp+F|yqxExoI&P|mwVkyrC;iObE0@`Ko0FAoEruzI{K^Fj z_`YXX85xA{{B-JPbO>N=yR6;$Pju;TIQT#BbW;Bqo3)Cv_m;1fe`I_9V|d1w2#dHm zXVC8Lvsq@8NPLA0oxWYqfwLy#*!<#|LY0W+^Bb1oLt|4)5wx#=DmC6r5g`_NnheM& z{u%SQ?N4sC^}%cjJD1+3W>Vg`*F$^EH_KD@z-^mNl8353Ru~f0q;I{^iS1f^~Cr~c56&^aXcjF@Q^Z_%n54WPO`JNQEmPDrHRq`Ls|33 zwJgnu*&x4kT_sv0pV{X$obryX-u=KtY_N45*Hp84w?z&xKGP~u5;(2TI7~FjX zOoinOs*C99TEK#y;XtJK;q^<6*$yIv_W75goP6KtctwXv&rkg~ z1O1OBI3xF5g8Fj&rjPv95bJJY=u>I}q`=-2&Ps9WmkW*sGJ_eObsV~rnl-qVcRUn@ zJb!&+c(L6oP`rNR_(Z<}goNzB#Kz@|$+_S?=5Fbmd^~qUyM=%)ZvsO*A7x9keHm}0gmyr-78wVlg}JxW5|?q#ku&m z(eak#Mf!?T#qzfj-}n1Dmg`(64I^yL-%oh86q=f&MqD%(Fv2Ng)tgp5lC$3xV0<%c zTC>x!=Bj?}?QH&IrAG-HOr7bKZQk=l#IJ}TZH^L6R_rakc?s|Fhky3+N-x|+Fb3fLB`OGRhw^kP zDAyOZCkj=mY_n=gaS0Lh<@g2N&P)(XpybgcazyuTiK`or2U}BL1f=DFKmo7L2|(jj zf(J%$nd;8poD#IT?X2n`S;6{9-55m12FsK{u%4Iy!(gowa4&7dKCj`RkzwdaW}8Zz zsYvDurJ(rTr&27L_d)bo67fHTp@)aQ9N4($R`q|Gc&@LR^3CGF`i4%EvW=cBy?Aiq zwc)&yEhwbn874dL;BjY%$!(7Q)uXk*__-weN29bHReqLze6^-*8WLlVSA^V%J}^|@ zHtQX0FWDFsiY^!rTgi3!vS@VWm27C)QOb>1*0qW7w2AfxSG3hT;Tt2bT4t9j;^M7} z@Kfwsn|C+fX$WH?O<`)S-w^YSqbgc__KMgXgud&7{2MyT$ z^XpibE$B`on*HEgs@SD(KVzBaX!kwtn9i((uB7igo-pxm39WdnWgHwGQ-Rl2`KVW% zdW%}Ql+zG)z{siffs|kb6e{7KbKxC%Hl&V`xI{9ML>0wd z!ZCS5gy*?8I~`1SoWzd+-el)wax*XX^0(Q8#}>E5@zE>yZZJ#{#g0CldV)HLD6fAn zz+FhxUbyJ<^_wL`aySk6?AONRIufPWyFy+yolBJgE=SC#`Yht5*Nrx-GdnK_9<0x! z@Kc}N_>Y3CPrL>Ua%HXQlHBosupkv5@@rOKCVz79Z(%6lfN1h(RZxU(`{*(+?gbj9;reQBt&;(f+{QNiEJ+R%cR>EZjO2>0km6xps0~y{&||SrKVq zaq9iZqAg}^KYzk#eoP~LJ)m`?+NUIYkY?H54d5BRt3A{K32OWb1UqG)89Dms57`bd z0jv_kvt>P+?}q5y*moK?QOQ5PDHA}Cgy^qokxN)VCsD1Gq4YaxSW0fDQ3nE9%^fptlcdcI~hrj5Jpr#3*~C+6Dm+sw3n) zRQ}yyk6aJY#8WJ5_}VO^?4?4kEZzCZqJ&Z|A~ZMBsh_6AGn#tv?#l*cQSQtgkZh;I z-%>q&0{>x_G`e6M<$6zU5Re-+S%weq;-Ltwi(XD6m6-t~Y6OKHcv>%`La+#5Lg2^( zg^xA|f3&coP5tvxCN$~C>!pv7(s)eoyOF@cq^oUf%l||s0OUB2-m~Jkst&R;iyu}Z z(3#|SiANOmkiJf3Z|(-0tj&`b`ZO@V>eD78yAEvaDc#Ts0TJDFYNBNeT)C0zYgwbeFz zUAg=w=YOoe7oUCJF5J7ZW2BhhnIL0yI_Igpw|o7521b79{AMXPb@RC0^lY4bN4{z4(*l{?}X2qy(gK<#E6Pw@?9o80wG! zT7X~ngN;gjxbF2pa#7_c-W``#2Yw36ezO#ty)#0}1UC#M4#{BrxaDh?VYj&WHN|Qc zVg?|d7OHs&6T%EK0a9yKne^b)BBhpm*W-I~x+u^J&v?m%iUv*Xqha}w`0UW`mP875 z`ssa*4>wjHJ>n?HBS&AIQ0TMG>ctS8NRWmZL6$bqHe_E)>|K88-{61H5gT z631&Oc10WwBxNb5n;+b2D*cHbO#Em(Y@iDz z9N{=Xice!rQvdM+HqJoWe>E4sLX$U(1KbjX7$a&Wy{fE;dTtvm+{-1u8iH>~r^MSq z|Dn^p*j8EJq9O(lS}TLBEX7x!uQK*>5v#5rs*@P*K+&%V!krRJL4gt)fC5P-xJFZ< ztx_Jx-(miFH+&HfT5$eMe83$1{>Tf^a|R$LLOIct`+sOm*kF(^CzoltnM*PbFNbx6 z#~}Shf+Y#{SvDFebuC85BGDRua}ow)zm99y@jV2us%6!r*_ROnHLZLZDG-Ht2pgz- zmp=GxL4T-4X&8K~e`i#0>h{6iTon%msWs+9=c{G#`&X7uz$8iFY$wb_Cz=pDt~ z_HWwhyj)Xtl;{qRVo1Ko6(7_cj_Jk1h}M2fZ%Q;J`XPagCNn%I^O6Q_Y1>uS|Dk4C zE=O!n2Hg{q2buRQnkc`OW1y{5PVF?Xc08qAl)KIC@>-D-P~Wui(QizPLK8Y z#ss&CB*3jH_qWv`hg~g^5`Q0auQb@7artD@m(=zJHM3pC0rPBIWIvXzrAC*q#3kEZ zVp$0CcQ)(6f;RqKTySF()&)X0R44yLm_1vCLxjrov{D$JPSp5_%%Z4eUavNvBvS7l zhQ^3lmPj*@G@K%xw{|*OoU~D1@m^?-%Mf8i=B_B2p}gYYVC%qOfmi1DUu=~OE)KQR zH@Az>b<+;HmjnQqLYW?aNMrRrs|W+BlD^+N!Y{7A?2l6aE0O97pks_l7M{Mr)rPX2 zpLqAh2On_2=+UxGGH}oo{QSi$PgpTmi1;P@@!+ZDsYi`{Yjvk;eBtIS z48Ma3<(V*JU0v8NtO(nEWS0}_3kC4-4qRDZ2Vf_K1X-rZO6J6FM!eYV7rGJc%(UgAOdFNqZRYGxm~%zTg-KA23t zd9}pwSO^=5`tK{ZFqSnf>PF^iPt17bT#e>iqroKq*b>f|&FSvjwWlrIw+40vypBa} zUwx>_sw+{-&#gpW0$A-5)RIZzQs(PWkGUHc%Lfy|scN%#mcGYFZ!sy`+uwcZvRXbe z7m&jWiBWFZxdf$7AHqbc5IO$>cD9>p@N_0`_yJ&{2JTE4Q}fPaU36uHM-r=>Zhv%kvMwc0 zELR}ii7s@sEV}?JS=JI9$0;RG`YH=*%}zzF@pDXW4uA^XYuoG6bYIAxFGpl%(w2lF z1GMCiZ+sI~{uNwb!jvEDlRJI0F*-ir_#RonRud+hXSD$iBBui-ihf$5zNimjk}9c* zsl`sjj`@V571D#iM%4&l22!tu`)Uez)!aIVj1Z}yAdEzSHBy6NpOyvF{?xVk`?7yhuAC!@4CZq1SN|=9Op4p7j>dT9q z*eog_LPtM>+vQb8f4@|Kd@ErYsp~EgHgXHYyH$(z%)fIB*$?WR`TA$Ij(~*ypXUzA zGbvQM2*K%1_Mb&Iw0a3m6B6fMhJNPiXMW<-cZV$xwg>qB2yOaZ1#O6C@kVc7i7PPdRoW-cA&4wEA5Nz4y`k|;w!pPXVmGGhOnpatJ zW+PgcUlsJMF?Ao!@|RmOb|#DNwB0?Az9Kuw8HLez(0i{&hnYRx`-LM;20LC^%-BbS zDqZsUw-^8$iW#Sb-D>Os#FY7fkvt%iR?PHn4L>{HXI{wn%e+v|mumwzQ&~h3N2j;u zjgLV)%SsE8SNMv{4jq4^MoXtbGP_Ts3lzOWH)=sO>&xa8EGhl?i}>(-XY4$-Ko+YE z-9J;WFU#HO7SCr#7oLmtNXlE_%cEf}r3H3OGCg`nEn_-hIE3XnZAwgq$S=-Q*3s&iTYH zh6!HlWcs;GkUfN;{NlQquRj%=%y_C%wCH!%Y6B%pvz6N$Cls&^sCH{=1%}!vaNDJh zC1PBCj_LepP?t^46YCowC~QtLZ~W$yl2aV%!p+8!$SrP7#1PRh6&)7m1mGsRKH!?h zndYV@dyP-Fs*if?A9M<$9fc2dZs?NPF9F zxtCPB@zuFV(1a2NZ5g?Y%XPBspOhrnK}-??79G?CpKj!ygNHWC8JDqRER8TVTs_5+ zsg|*XI+ZwI^4?jfXYDMx&Orxy9sS>tya}3}e3HBV^p(d58DG0SY@iJN8mi z{1bT%6Ml>6mCC@f=X88j~Oi>L`tR?yJR_shg|D&K2pltzLr?}YkQ{#=R&_!JqmRUwM72`Zyv>>G-^*o-f84>QwB4~ZMn3n)%_8h<7YtDL3 z#@;1d#^8~b_}EDt3m;)d*?4IaKPZ0BXyd_5#Ee1{a3v`H1)rI9pSt|_;30*Hl0*Rh zVmYi`os z#gfyqyv<>%x8$mY8LqC@A(1J65dg2)G?YsGMKmzJH%PMvTl#)YcdjG2PU_5`VrqVV z5bNYE>xtog4MLA(hS~I+Km0)Iz-U3QkO#%{BMrBxWsv4s-7!Zhk_?7(&8q*$fT}^J zsfthNEF7!bch8~ehb4wjVHn|52K3&52MFh~vQQTlBshvO!g&vcACAiBIkp0nl|~>k zEvto$0Nra(!l?s~hEw~xj29|A#w;9E;Jpo9QS`@9AB|gXXo^rD z9c8Mg815KB|BLm~ZtQL=ns*9o_yL3L_iM{mE~&SpUgDY1TZ!jSpN7%$Z)XkCx-~ps zPXGKF&gX@=ALHC#N37Q;W5v9>TreeOtF+3SZT4f)3^CbGP*=CSa{cRB*n}~7fZA3f zm?VR(V8c~wc4544zFE7|Wzh^zeAuI=-L=gGpI|KwOQ{R4$Cn^@i4I zM<2EX@ON@WN{XysPLb;tYpt^PEq>A&W=S}VpF%U&0xTSMz_-mz>Aoru*6faapUd{=!62_Ph#r;E7Xr>tdPe6vMH%PHcU z>d++~Q15FkO+R4&#+^aT)5TN(^~tVzlI40=h(n~QL4irQS*wU#`zDn>ZI(K$?fKSXKOkx<2s0Y_9 zG+u8V<=tUWE90lHmnyz{Uk_^!%GEIkKL=kPpBtL^fQu4*RI>1L^<_kBs)E&NzUO!R z<3WGUokT&m`~vY|pB<6^a@Z-43I9fFfSXVtEup@CLcN9e+2B#?EgA0o0t7##g^*nE zxdq%(|>Ae`5^)o1_(p!1&*bvh29&W??#X zP^&j8kjrD*CX#C48>1Q?EJ@j3bZm9nG{}uHTcyl$YJ0O;f0J&}J9&pXgnm-ayI^R{ zhD>JTLr9I)a&3k?Hq>)C7h4QP!@^2mZze&nt3P;d_mmJ*h}KG9#f?@ywU=L^C$=t2*A!9qFpdceO2FI#6o^ zPx5s$r*m#^AWgM5sI(IZrdYqklUaw`p}10%S`EP>6$OG=C>C=XGfect%|W4%A>tfh zsNZ``2ST8=4AWv_Ha3x^m*DSc(K`3Yljm5nKd6iLJPv zGSsPcAcPxi7jRUE7q+93c!?$~n9cR!tNU+;m1#WdK=J5~V%%`@`8#7QR&O5d`RHxxRi(ibM{LA!{i&vJYg&P6+w5~7Nv{2ys~Qz;)4mq-E6 z$H=4m>(?BFskYsvS-yhXGGm68Wp%2-L0%O7k1t~rW8r1ZWtx{)Ztg@PDOJ6({z6>J zoOd8;Qich>izugO*wC0L%NO7{#4^rp7v2B9D!pZa&6A-OT%N3L@&h(40{8uu=6XR6S2V_QaangL~@ul_Wz$*fsgECiU$?c<; z%!%w3KhtC$Xvlj6f#MlZANenB`JZD4`?q2-mV0a{e*BY4-NUHOV14md9y0j|TO|zm z7e!Hk7Mv6<@bHMXxBmEc&@;F9KUAMR__fpNyuTVwqI*wGI5wF|$L^qT=(Aj@kI&Fe z28ZY$OS(foCIBF1{?(rGGaEnRyiekxr_I)?lM4rW~W2lr6-ES|cLf;y@o zM#mwTLE{E2bl$k;>qiy_&H)KZ+lG5}1n9RD@}(^x{kP|-XPd8YM!sdoi$F@vE&S~; zat6(+Pmwn+77Y&8DwAh=#*>$~oUC7?{CdALsmW+k?_Fmkv$U(u^U~8N*-r(nC*%$~ z7-kR?3xl+!X*P%B*W?~&T`bIIPTP0_Q+%mFFc5MUJCWD^fM1{*CEt>%yTbYdvL@C` zzZ@KI9IBL%U+!5b9RgZ85Xsys7m%3(#H0hNfsms8Lg(!+zFrI6;I}zWpY^Aidpv{f@B*g*?*g(c(a;BbmyB<3xR<9$Y7nggt&V;ZRT4 zcw_7h+MyAhrsNGG0GJu*sJlO>k*3UF``k;ram+QxdgLq!+jyr2cbIxkX~zzJt~*XSnS%l;jr`2R=S{v=kXdy@P->_XBB|#ciPT# z!wd>I82!# zYuM);bX7Ciu+gEYK6lsQ=TJ&Ra{`0c8`%^O98mMun3mKY(O|%Q<+^p#UTea3miZ^j zYh2UbO@0Zy7A6!<$@Sj4LmEns-|eh~Jg)e)yQY27B({3!LHl8}zRrxHz22dG%xxzx zIwQ~_gUayepzId#KwDG!12TO$U+FP-HmgqSGoV|vphkQ3SYpg$yO<@D4HJAJ&F*JT z;wF}6mueE^9wUX{`wS+JOko{93g%T7%&fvy?x)L;?v~l`?AB4^{W$I$G@1lZ_qJhb zI+*maD>9A{y5jQiFo=hJG}f<~roH4u_sN&98T;4WR*x5vOyZP&5?-oGKX$1>#l%Hu z>p<6SUCZLU9D3N;kROWT;HyH2rcXX@i4jYmaM@k`HW2u%I2DNwP}sQm9b=KFxbk__ zu>?46yR6vb{Xpck=i7fATP$)C?Tc{S__Xjdd;KJ=vr_M4vdTiqdKb=$8Pw}13+1|C z$e2j1!Dg5{=L4bh2`Q+LE5KZgcX8TFRadL`x;U;XML9-LEMVxD89S-TnXOnSsYUzby-_Xa?~kgE z(!^Nkvgmv}eim#O!T1CpEoii1g5xl@>y9tgP{`&k*XUI;GLsPzgsNY|s8y}jD>+&O%3~;_1^taJAZLDaPuci+a>`>7( zt-RyM<3DybifVRN@(bB0!$)P7g8j?M;bx1b#=l)yrSWk^^9{3jvKP0QVyG{XhpS!S z$b>tS*zR!*H7H+HZ)@Yg8X|i!K%OkRr|-@riw`>dh3tyFlQD!IU8Y8d^f%lfEgCfz z+Fp9jzi@*~Di%y9pr^U>BGJi76C_a*5GGQXjvmpq*I4oyz`{9g>O*A=1AvDQB_&)uz&if#(=gY<&@)b>L2jN9+dyK6*!>!uOkY& z#V>!^&v#yJ?kzrU5$e*ZBrg?MiR3f;Mi1k<;cuAffhfO!DdzfsQWY238hE9$Q1?4dYx6AntNTd1IAWd;+cbn+y{> zX@A`Y+$d1i2eM>}_#G{9Mr=2}-|z&x-Y6t4v}R-FYgD>`BF zo}FXUwb9^drAzjAgtBF>otTVs#_TBcS}-gp=O_88ReEhu4`({Yrf>3_gEj?N@B|Y)?beR0^I}r?i!V)%E3SLIx75gBtC1ii(voL%NY-KM zqaWp^ycYMia0=k&*WZ+L%(}Z!mvlo>(^;FGTvugGNGpJCL?L1rV62?a%$d0)F|jx9 zvB4G#L-qOmIX$_W2sszskhaj8AA0Z}-pdNeO6YU3Lou4L^B zVF)l@SB@uSX{-TD##II>XdEP3U*N|vH6pToM}f35&+nZUExP5M9tZpo1id{PBySC+ zlI;cuuwZ*cxw*Ls8M*Nq(Xj3*I}lmw5n#K;0(PBFYA_22CNRM%%Qk>wySW$wJE z`hx_}&~8wr?k@_|t==L0o8OG*>+3TIk0c6`%(xA^Q~5NjPgy!N)MC{w=7-+871xCwjQakz2Lay7AOC8FNVy3 zX(;=)4QQ{h@O`8$s_SlEQT09M;5sCOW)C{iFxPaf*NEp4V35cl{h>X@s-;KZM()|{ zLxWBlpXcgR6uD}2h>FgNr;(N8iNyK_57{hA6`gwQ{B@kVp4Y5DZYWj(0fMrACoj

cYY8H)>e!Qwq|+yPRDx=O*G{{JZD`SSW-HyGI) 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»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 b|5&A;kcF@}bo4kP;OF$y!TqH_1YG?O)-?;7 literal 261236 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 BC%z4OL%+<<%; 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

gd{uBQCe{D4MxBzuzAStWK-$$SIfZ`JkL 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

3zJ_zTQ-Rci5JM07#@%c8qjqcb?IQ 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 O9}82PA4>l2dFS5{H|(7N diff --git a/common/dist/sprites/spritesmith-main-6.css b/common/dist/sprites/spritesmith-main-6.css index 498fc4512c..60fa7a0a5f 100644 --- a/common/dist/sprites/spritesmith-main-6.css +++ b/common/dist/sprites/spritesmith-main-6.css @@ -682,7 +682,7 @@ width: 32px; height: 32px; } -.shop_spookySparkles { +.shop_spookDust { background-image: url(spritesmith-main-6.png); background-position: -790px -1176px; width: 32px; @@ -730,7 +730,7 @@ width: 40px; height: 40px; } -.shop_healAll { +.shop_heallAll { background-image: url(spritesmith-main-6.png); background-position: -791px -1643px; width: 40px; diff --git a/common/img/sprites/spritesmith/achievements/achievement-spookySparkles.png b/common/img/sprites/spritesmith/achievements/achievement-spookDust.png similarity index 100% rename from common/img/sprites/spritesmith/achievements/achievement-spookySparkles.png rename to common/img/sprites/spritesmith/achievements/achievement-spookDust.png diff --git a/common/img/sprites/spritesmith/achievements/achievement-spookySparkles2x.png b/common/img/sprites/spritesmith/achievements/achievement-spookDust2x.png similarity index 100% rename from common/img/sprites/spritesmith/achievements/achievement-spookySparkles2x.png rename to common/img/sprites/spritesmith/achievements/achievement-spookDust2x.png diff --git a/common/img/sprites/spritesmith/misc/inventory_special_spookySparkles.png b/common/img/sprites/spritesmith/misc/inventory_special_spookDust.png similarity index 100% rename from common/img/sprites/spritesmith/misc/inventory_special_spookySparkles.png rename to common/img/sprites/spritesmith/misc/inventory_special_spookDust.png diff --git a/common/img/sprites/spritesmith/misc/ghost.png b/common/img/sprites/spritesmith/misc/spookman.png similarity index 100% rename from common/img/sprites/spritesmith/misc/ghost.png rename to common/img/sprites/spritesmith/misc/spookman.png diff --git a/common/img/sprites/spritesmith/shop/shop_spookySparkles.png b/common/img/sprites/spritesmith/shop/shop_spookDust.png similarity index 100% rename from common/img/sprites/spritesmith/shop/shop_spookySparkles.png rename to common/img/sprites/spritesmith/shop/shop_spookDust.png diff --git a/common/img/sprites/spritesmith/skills/shop_healAll.png b/common/img/sprites/spritesmith/skills/shop_heallAll.png similarity index 100% rename from common/img/sprites/spritesmith/skills/shop_healAll.png rename to common/img/sprites/spritesmith/skills/shop_heallAll.png diff --git a/common/img/sprites/spritesmith/stable/pets/Pet-Lion-Veteran.png b/common/img/sprites/spritesmith/stable/pets/Pet-Lion-Veteran.png deleted file mode 100644 index 83e3f90f39cd2eca1cfeb71a6f428764218b6e6c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3801 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 P00000NkvXXu0mjfq_jD? diff --git a/common/index.js b/common/index.js index 04189fa8ab..475d444df3 100644 --- a/common/index.js +++ b/common/index.js @@ -1,6 +1,4 @@ -'use strict'; - -let pathToCommon; +var pathToCommon; if (process.env.NODE_ENV === 'production') { // eslint-disable-line no-process-env pathToCommon = './transpiled-babel/index'; diff --git a/common/locales/en/challenge.json b/common/locales/en/challenge.json index c014e1f6db..cffe8c4a38 100644 --- a/common/locales/en/challenge.json +++ b/common/locales/en/challenge.json @@ -74,10 +74,9 @@ "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", "onlyLeaderUpdateChal": "Only the challenge leader can update it.", "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", - "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching challenge tasks.", + "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", "userAlreadyInChallenge": "User is already participating in this challenge.", - "cantOnlyUnlinkChalTask": "Only broken challenges tasks can be unlinked.", - "shortNameTooShort": "Tag Name must have at least 3 characters." + "cantOnlyUnlinkChalTask": "Only broken challenges tasks can be unlinked." } diff --git a/common/locales/en/groups.json b/common/locales/en/groups.json index 2e0f245232..4d8ea0716c 100644 --- a/common/locales/en/groups.json +++ b/common/locales/en/groups.json @@ -182,8 +182,8 @@ "userAlreadyInAParty": "User already in a party.", "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", "userHasNoLocalRegistration": "User does not have a local registration (username, email, password).", - "uuidsMustBeAnArray": "User ID invites must be an array.", - "emailsMustBeAnArray": "Email address invites must be an array.", + "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", + "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!" } diff --git a/common/locales/en/limited.json b/common/locales/en/limited.json index d9f3ff5ba0..2407b788fe 100644 --- a/common/locales/en/limited.json +++ b/common/locales/en/limited.json @@ -5,7 +5,7 @@ "annoyingFriends": "Annoying Friends", "annoyingFriendsText": "Got snowballed <%= snowballs %> times by party members.", "alarmingFriends": "Alarming Friends", - "alarmingFriendsText": "Got spooked <%= spookySparkles %> times by party members.", + "alarmingFriendsText": "Got spooked <%= spookDust %> times by party members.", "agriculturalFriends": "Agricultural Friends", "agriculturalFriendsText": "Got transformed into a flower <%= seeds %> times by party members.", "aquaticFriends": "Aquatic Friends", diff --git a/common/locales/en/maintenance.json b/common/locales/en/maintenance.json index 20b3410de2..efdb524cd2 100644 --- a/common/locales/en/maintenance.json +++ b/common/locales/en/maintenance.json @@ -1,6 +1,6 @@ { "habiticaBackSoon": "Don't worry, Habitica will be back soon!", - "importantMaintenance": "We are doing important maintenance that we estimate will last until 10pm Pacific Time (5am UTC).", + "importantMaintenance": "We are doing important maintenance that we estimate will last until <%= localDate %> in your timezone.", "maintenance": "Maintenance", "maintenanceMoreInfo": "Want more information about the maintenance? <%= linkStart %>Check out our info page<%= linkEnd %>.", "noDamageKeepStreaks": "You will NOT take damage or lose streaks!", diff --git a/common/locales/en/pets.json b/common/locales/en/pets.json index 7d13c8bc53..7cd716b72d 100644 --- a/common/locales/en/pets.json +++ b/common/locales/en/pets.json @@ -12,7 +12,6 @@ "etherealLion": "Ethereal Lion", "veteranWolf": "Veteran Wolf", "veteranTiger": "Veteran Tiger", - "veteranLion": "Veteran Lion", "cerberusPup": "Cerberus Pup", "hydra": "Hydra", "mantisShrimp": "Mantis Shrimp", diff --git a/common/locales/en/settings.json b/common/locales/en/settings.json index 727c04774c..daffc88b5a 100644 --- a/common/locales/en/settings.json +++ b/common/locales/en/settings.json @@ -47,7 +47,6 @@ "customDayStart": "Custom Day Start", "changeCustomDayStart": "Change Custom Day Start?", "sureChangeCustomDayStart": "Are you sure you want to change your custom day start?", - "customDayStartHasChanged": "Your custom day start has changed.", "nextCron": "Your Dailies will next reset the first time you use Habitica after <%= time %>. Make sure you have completed your Dailies before this time!", "customDayStartInfo1": "Habitica defaults to check and reset your Dailies at midnight in your own time zone each day. You can customize that time here.", "misc": "Misc", diff --git a/common/locales/en/spells.json b/common/locales/en/spells.json index 5eb5d18be1..37dc7eaad4 100644 --- a/common/locales/en/spells.json +++ b/common/locales/en/spells.json @@ -52,8 +52,8 @@ "spellSpecialSaltText": "Salt", "spellSpecialSaltNotes": "Someone has snowballed you. Ha ha, very funny. Now get this snow off me!", - "spellSpecialSpookySparklesText": "Spooky Sparkles", - "spellSpecialSpookySparklesNotes": "Turn a friend into a floating blanket with eyes!", + "spellSpecialSpookDustText": "Spooky Sparkles", + "spellSpecialSpookDustNotes": "Turn a friend into a floating blanket with eyes!", "spellSpecialOpaquePotionText": "Opaque Potion", "spellSpecialOpaquePotionNotes": "Cancel the effects of Spooky Sparkles.", @@ -70,7 +70,7 @@ "spellNotFound": "Skill \"<%= spellId %>\" not found.", "partyNotFound": "Party not found", "targetIdUUID": "\"targetId\" must be a valid User ID.", - "challengeTasksNoCast": "Casting a skill on challenge tasks is not allowed.", + "challengeTasksNoCast": "Casting a skill on challenge tasks is not supported.", "spellNotOwned": "You don't own this skill.", "spellLevelTooHigh": "You must be level <%= level %> to use this skill." } diff --git a/common/locales/en/subscriber.json b/common/locales/en/subscriber.json index 8c0d453727..197994da08 100644 --- a/common/locales/en/subscriber.json +++ b/common/locales/en/subscriber.json @@ -134,7 +134,7 @@ "notAllowedHourglass": "Pet/Mount not available for purchase with Mystic Hourglass.", "readCard": "<%= cardType %> has been read", "cardTypeRequired": "Card type required", - "cardTypeNotAllowed": "Unknown card type.", + "cardTypeNotAllowed": "Unkown card type.", "invalidCoupon": "Invalid coupon code.", "couponUsed": "Coupon code already used.", "noSudoAccess": "You don't have sudo access.", diff --git a/common/locales/en/tasks.json b/common/locales/en/tasks.json index b9180af3bf..5b32829f81 100644 --- a/common/locales/en/tasks.json +++ b/common/locales/en/tasks.json @@ -73,7 +73,6 @@ "clearTags": "Clear", "hideTags": "Hide", "showTags": "Show", - "toRequired": "You must supply a to value", "startDate": "Start Date", "startDateHelpTitle": "When should this task start?", "startDateHelp": "Set the date for which this task takes effect. Will not be due on earlier days.", diff --git a/common/script/constants.js b/common/script/constants.js index 040b968f8f..d6e0aa9384 100644 --- a/common/script/constants.js +++ b/common/script/constants.js @@ -1,5 +1,3 @@ export const MAX_HEALTH = 50; export const MAX_LEVEL = 100; export const MAX_STAT_POINTS = MAX_LEVEL; -export const ATTRIBUTES = ['str', 'int', 'per', 'con']; -export const TAVERN_ID = '00000000-0000-4000-A000-000000000000'; diff --git a/common/script/content/index.js b/common/script/content/index.js index 8164d4e0e6..18b46786bb 100644 --- a/common/script/content/index.js +++ b/common/script/content/index.js @@ -426,7 +426,6 @@ api.specialPets = { 'Phoenix-Base': 'phoenix', 'Turkey-Gilded': 'gildedTurkey', 'MagicalBee-Base': 'magicalBee', - 'Lion-Veteran': 'veteranLion', }; api.specialMounts = { @@ -602,14 +601,6 @@ api.questMounts = _.transform(api.questEggs, function(m, egg) { })); }); -api.premiumMounts = _.transform(api.dropEggs, function(m, egg) { - return _.defaults(m, _.transform(api.hatchingPotions, function(m2, pot) { - if (pot.premium) { - return m2[egg.key + "-" + pot.key] = true; - } - })); -}); - api.food = { Meat: { text: t('foodMeat'), diff --git a/common/script/content/spells.js b/common/script/content/spells.js index 9b0514974d..3150bd0da9 100644 --- a/common/script/content/spells.js +++ b/common/script/content/spells.js @@ -1,6 +1,6 @@ import t from './translation'; import _ from 'lodash'; -import { NotAuthorized } from '../libs/errors'; + /* --------------------------------------------------------------- Spells @@ -15,7 +15,7 @@ import { NotAuthorized } from '../libs/errors'; web, this function can be performed on the client and on the server. `user` param is self (needed for determining your own stats for effectiveness of cast), and `target` param is one of [task, party, user]. In the case of `self` spells, you act on `user` instead of `target`. You can trust these are the correct objects, as long as the `target` attr of the - spell is correct. Take a look at habitrpg/website/server/models/user.js and habitrpg/website/server/models/task.js for what attributes are + spell is correct. Take a look at habitrpg/src/models/user.js and habitrpg/src/models/task.js for what attributes are available on each model. Note `task.value` is its "redness". If party is passed in, it's an array of users, so you'll want to iterate over them like: `_.each(target,function(member){...})` @@ -40,12 +40,14 @@ spells.wizard = { lvl: 11, target: 'task', notes: t('spellWizardFireballNotes'), - cast (user, target, req) { + cast (user, target) { let bonus = user._statsComputed.int * user.fns.crit('per'); bonus *= Math.ceil((target.value < 0 ? 1 : target.value + 1) * 0.075); user.stats.exp += diminishingReturns(bonus, 75); if (!user.party.quest.progress) user.party.quest.progress = 0; user.party.quest.progress.up += Math.ceil(user._statsComputed.int * 0.1); + // TODO change, pass req to spell? + let req = {language: user.preferences.language}; user.fns.updateStats(user.stats, req); }, }, @@ -164,11 +166,12 @@ spells.rogue = { lvl: 12, target: 'task', notes: t('spellRogueBackStabNotes'), - cast (user, target, req) { + cast (user, target) { let _crit = user.fns.crit('str', 0.3); let bonus = calculateBonus(target.value, user._statsComputed.str, _crit); user.stats.exp += diminishingReturns(bonus, 75, 50); user.stats.gp += diminishingReturns(bonus, 18, 75); + let req = {language: user.preferences.language}; user.fns.updateStats(user.stats, req); }, }, @@ -194,7 +197,7 @@ spells.rogue = { notes: t('spellRogueStealthNotes'), cast (user) { if (!user.stats.buffs.stealth) user.stats.buffs.stealth = 0; - user.stats.buffs.stealth += Math.ceil(diminishingReturns(user._statsComputed.per, user.tasksOrder.dailys.length * 0.64, 55)); + user.stats.buffs.stealth += Math.ceil(diminishingReturns(user._statsComputed.per, user.dailys.length * 0.64, 55)); }, }, }; @@ -215,10 +218,10 @@ spells.healer = { text: t('spellHealerBrightnessText'), mana: 15, lvl: 12, - target: 'tasks', + target: 'self', notes: t('spellHealerBrightnessNotes'), - cast (user, tasks) { - _.each(tasks, (task) => { + cast (user) { + _.each(user.tasks, (task) => { if (task.type !== 'reward') { task.value += 4 * (user._statsComputed.int / (user._statsComputed.int + 40)); } @@ -239,7 +242,7 @@ spells.healer = { }); }, }, - healAll: { // Blessing + heallAll: { // Blessing text: t('spellHealerHealAllText'), mana: 25, lvl: 14, @@ -259,13 +262,11 @@ spells.special = { text: t('spellSpecialSnowballAuraText'), mana: 0, value: 15, - previousPurchase: true, target: 'user', notes: t('spellSpecialSnowballAuraNotes'), - cast (user, target, req) { - if (!user.items.special.snowball) throw new NotAuthorized(t('spellNotOwned')(req.language)); + cast (user, target) { target.stats.buffs.snowball = true; - target.stats.buffs.spookySparkles = false; + target.stats.buffs.spookDust = false; target.stats.buffs.shinySeed = false; target.stats.buffs.seafoam = false; if (!target.achievements.snowball) target.achievements.snowball = 0; @@ -285,22 +286,20 @@ spells.special = { user.stats.gp -= 5; }, }, - spookySparkles: { - text: t('spellSpecialSpookySparklesText'), + spookDust: { + text: t('spellSpecialSpookDustText'), mana: 0, value: 15, - previousPurchase: true, target: 'user', - notes: t('spellSpecialSpookySparklesNotes'), - cast (user, target, req) { - if (!user.items.special.spookySparkles) throw new NotAuthorized(t('spellNotOwned')(req.language)); + notes: t('spellSpecialSpookDustNotes'), + cast (user, target) { target.stats.buffs.snowball = false; - target.stats.buffs.spookySparkles = true; + target.stats.buffs.spookDust = true; target.stats.buffs.shinySeed = false; target.stats.buffs.seafoam = false; - if (!target.achievements.spookySparkles) target.achievements.spookySparkles = 0; - target.achievements.spookySparkles++; - user.items.special.spookySparkles--; + if (!target.achievements.spookDust) target.achievements.spookDust = 0; + target.achievements.spookDust++; + user.items.special.spookDust--; }, }, opaquePotion: { @@ -311,7 +310,7 @@ spells.special = { target: 'self', notes: t('spellSpecialOpaquePotionNotes'), cast (user) { - user.stats.buffs.spookySparkles = false; + user.stats.buffs.spookDust = false; user.stats.gp -= 5; }, }, @@ -319,13 +318,11 @@ spells.special = { text: t('spellSpecialShinySeedText'), mana: 0, value: 15, - previousPurchase: true, target: 'user', notes: t('spellSpecialShinySeedNotes'), - cast (user, target, req) { - if (!user.items.special.shinySeed) throw new NotAuthorized(t('spellNotOwned')(req.language)); + cast (user, target) { target.stats.buffs.snowball = false; - target.stats.buffs.spookySparkles = false; + target.stats.buffs.spookDust = false; target.stats.buffs.shinySeed = true; target.stats.buffs.seafoam = false; if (!target.achievements.shinySeed) target.achievements.shinySeed = 0; @@ -349,13 +346,11 @@ spells.special = { text: t('spellSpecialSeafoamText'), mana: 0, value: 15, - previousPurchase: true, target: 'user', notes: t('spellSpecialSeafoamNotes'), - cast (user, target, req) { - if (!user.items.special.seafoam) throw new NotAuthorized(t('spellNotOwned')(req.language)); + cast (user, target) { target.stats.buffs.snowball = false; - target.stats.buffs.spookySparkles = false; + target.stats.buffs.spookDust = false; target.stats.buffs.shinySeed = false; target.stats.buffs.seafoam = true; if (!target.achievements.seafoam) target.achievements.seafoam = 0; @@ -396,10 +391,7 @@ spells.special = { if (!target.items.special.nyeReceived) target.items.special.nyeReceived = []; target.items.special.nyeReceived.push(user.profile.name); - - if (!target.flags) target.flags = {}; target.flags.cardReceived = true; - user.stats.gp -= 10; }, }, @@ -424,10 +416,7 @@ spells.special = { if (!target.items.special.valentineReceived) target.items.special.valentineReceived = []; target.items.special.valentineReceived.push(user.profile.name); - - if (!target.flags) target.flags = {}; target.flags.cardReceived = true; - user.stats.gp -= 10; }, }, @@ -451,10 +440,7 @@ spells.special = { if (!target.items.special.greetingReceived) target.items.special.greetingReceived = []; target.items.special.greetingReceived.push(user.profile.name); - - if (!target.flags) target.flags = {}; target.flags.cardReceived = true; - user.stats.gp -= 10; }, }, @@ -479,10 +465,7 @@ spells.special = { if (!target.items.special.thankyouReceived) target.items.special.thankyouReceived = []; target.items.special.thankyouReceived.push(user.profile.name); - - if (!target.flags) target.flags = {}; target.flags.cardReceived = true; - user.stats.gp -= 10; }, }, @@ -504,13 +487,9 @@ spells.special = { u.achievements.birthday++; }); } - if (!target.items.special.birthdayReceived) target.items.special.birthdayReceived = []; target.items.special.birthdayReceived.push(user.profile.name); - - if (!target.flags) target.flags = {}; target.flags.cardReceived = true; - user.stats.gp -= 10; }, }, @@ -520,8 +499,8 @@ _.each(spells, (spellClass) => { _.each(spellClass, (spell, key) => { spell.key = key; let _cast = spell.cast; - spell.cast = function castSpell (user, target, req) { - _cast(user, target, req); + spell.cast = function castSpell (user, target) { + _cast(user, target); user.stats.mp -= spell.mana; }; }); diff --git a/common/script/cron.js b/common/script/cron.js index a0f28f9d5a..2c0760fcc9 100644 --- a/common/script/cron.js +++ b/common/script/cron.js @@ -1,4 +1,3 @@ -// TODO what can be moved to /website/server? /* ------------------------------------------------------ Cron and time / day functions diff --git a/common/script/fns/autoAllocate.js b/common/script/fns/autoAllocate.js index 71a4898031..ab037e628b 100644 --- a/common/script/fns/autoAllocate.js +++ b/common/script/fns/autoAllocate.js @@ -7,69 +7,50 @@ import splitWhitespace from '../libs/splitWhitespace'; {update} if aggregated changes, pass in userObj as update. otherwise commits will be made immediately */ -function getStatToAllocate (user) { - let suggested; - - let statsObj = user.stats.toObject ? user.stats.toObject() : user.stats; - - switch (user.preferences.allocationMode) { - case 'flat': { - let stats = _.pick(statsObj, splitWhitespace('con str per int')); - return _.invert(stats)[_.min(stats)]; - } - case 'classbased': { - let lvlDiv7 = statsObj.lvl / 7; - let ideal = [lvlDiv7 * 3, lvlDiv7 * 2, lvlDiv7, lvlDiv7]; - - let preference; - switch (statsObj.class) { - case 'wizard': { - preference = ['int', 'per', 'con', 'str']; - break; +module.exports = function(user) { + return user.stats[(function() { + var diff, ideal, lvlDiv7, preference, stats, suggested; + switch (user.preferences.allocationMode) { + case "flat": + stats = _.pick(user.stats, splitWhitespace('con str per int')); + return _.invert(stats)[_.min(stats)]; + case "classbased": + lvlDiv7 = user.stats.lvl / 7; + ideal = [lvlDiv7 * 3, lvlDiv7 * 2, lvlDiv7, lvlDiv7]; + preference = (function() { + switch (user.stats["class"]) { + case "wizard": + return ["int", "per", "con", "str"]; + case "rogue": + return ["per", "str", "int", "con"]; + case "healer": + return ["con", "int", "str", "per"]; + default: + return ["str", "con", "per", "int"]; + } + })(); + diff = [user.stats[preference[0]] - ideal[0], user.stats[preference[1]] - ideal[1], user.stats[preference[2]] - ideal[2], user.stats[preference[3]] - ideal[3]]; + suggested = _.findIndex(diff, (function(val) { + if (val === _.min(diff)) { + return true; + } + })); + if (~suggested) { + return preference[suggested]; + } else { + return "str"; } - case 'rogue': { - preference = ['per', 'str', 'int', 'con']; - break; - } - case 'healer': { - preference = ['con', 'int', 'str', 'per']; - break; - } - default: { - preference = ['str', 'con', 'per', 'int']; - } - } - - let diff = [ - statsObj[preference[0]] - ideal[0], - statsObj[preference[1]] - ideal[1], - statsObj[preference[2]] - ideal[2], - statsObj[preference[3]] - ideal[3], - ]; - - suggested = _.findIndex(diff, (val) => { - if (val === _.min(diff)) return true; - }); - - return suggested !== -1 ? preference[suggested] : 'str'; + case "taskbased": + suggested = _.invert(user.stats.training)[_.max(user.stats.training)]; + _.merge(user.stats.training, { + str: 0, + int: 0, + con: 0, + per: 0 + }); + return suggested || "str"; + default: + return "str"; } - case 'taskbased': { - suggested = _.invert(statsObj.training)[_.max(statsObj.training)]; - - let training = statsObj.training; - training.str = 0; - training.int = 0; - training.con = 0; - training.per = 0; - - return suggested || 'str'; - } - default: { - return 'str'; - } - } -} - -module.exports = function autoAllocate (user) { - return user.stats[getStatToAllocate(user)]++; + })()]++; }; diff --git a/common/script/fns/crit.js b/common/script/fns/crit.js index 65a0cbb062..69ac9e5b93 100644 --- a/common/script/fns/crit.js +++ b/common/script/fns/crit.js @@ -1,8 +1,13 @@ -import predictableRandom from './predictableRandom'; - -module.exports = function crit (user, stat = 'str', chance = 0.03) { - let s = user._statsComputed[stat]; - if (predictableRandom(user) <= chance * (1 + s / 100)) { +module.exports = function(user, stat, chance) { + var s; + if (stat == null) { + stat = 'str'; + } + if (chance == null) { + chance = .03; + } + s = user._statsComputed[stat]; + if (user.fns.predictableRandom() <= chance * (1 + s / 100)) { return 1.5 + 4 * s / (s + 200); } else { return 1; diff --git a/common/script/fns/cron.js b/common/script/fns/cron.js new file mode 100644 index 0000000000..1e3470d7f9 --- /dev/null +++ b/common/script/fns/cron.js @@ -0,0 +1,358 @@ +import moment from 'moment'; +import _ from 'lodash'; +import { + daysSince, + shouldDo, +} from '../cron'; +import { + capByLevel, + toNextLevel, +} from '../statHelpers'; +/* + ------------------------------------------------------ + Cron + ------------------------------------------------------ + */ + +/* + At end of day, add value to all incomplete Daily & Todo tasks (further incentive) + For incomplete Dailys, deduct experience + Make sure to run this function once in a while as server will not take care of overnight calculations. + And you have to run it every time client connects. + {user} + */ + +module.exports = function(user, options) { + var _progress, analyticsData, base, base1, base2, base3, base4, clearBuffs, dailyChecked, dailyDueUnchecked, daysMissed, expTally, lvl, lvlDiv2, multiDaysCountAsOneDay, now, perfect, plan, progress, ref, ref1, ref2, ref3, todoTally, timezoneOffsetFromUserPrefs, timezoneOffsetFromBrowser, timezoneOffsetAtLastCron; + if (options == null) { + options = {}; + } + now = +options.now || +(new Date); + + // If the user's timezone has changed (due to travel or daylight savings), + // cron can be triggered twice in one day, so we check for that and use + // both timezones to work out if cron should run. + // CDS = Custom Day Start time. + timezoneOffsetFromUserPrefs = user.preferences.timezoneOffset || 0; + timezoneOffsetAtLastCron = (_.isFinite(user.preferences.timezoneOffsetAtLastCron)) ? user.preferences.timezoneOffsetAtLastCron : timezoneOffsetFromUserPrefs; + timezoneOffsetFromBrowser = (_.isFinite(+options.timezoneOffset)) ? +options.timezoneOffset : timezoneOffsetFromUserPrefs; + // NB: All timezone offsets can be 0, so can't use `... || ...` to apply non-zero defaults + + if (timezoneOffsetFromBrowser !== timezoneOffsetFromUserPrefs) { + // The user's browser has just told Habitica that the user's timezone has + // changed so store and use the new zone. + user.preferences.timezoneOffset = timezoneOffsetFromBrowser; + timezoneOffsetFromUserPrefs = timezoneOffsetFromBrowser; + } + + // How many days have we missed using the user's current timezone: + daysMissed = daysSince(user.lastCron, _.defaults({ + now: now + }, user.preferences)); + + if (timezoneOffsetAtLastCron != timezoneOffsetFromUserPrefs) { + // Since cron last ran, the user's timezone has changed. + // How many days have we missed using the old timezone: + let daysMissedNewZone = daysMissed; + let daysMissedOldZone = daysSince(user.lastCron, _.defaults({ + now: now, + timezoneOffsetOverride: timezoneOffsetAtLastCron, + }, user.preferences)); + + if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) { + // The timezone change was in the unsafe direction. + // E.g., timezone changes from UTC+1 (offset -60) to UTC+0 (offset 0). + // or timezone changes from UTC-4 (offset 240) to UTC-5 (offset 300). + // Local time changed from, for example, 03:00 to 02:00. + + if (daysMissedOldZone > 0 && daysMissedNewZone > 0) { + // Both old and new timezones indicate that we SHOULD run cron, so + // it is safe to do so immediately. + daysMissed = Math.min(daysMissedOldZone, daysMissedNewZone); + // use minimum value to be nice to user + } + else if (daysMissedOldZone > 0) { + // The old timezone says that cron should run; the new timezone does not. + // This should be impossible for this direction of timezone change, but + // just in case I'm wrong... + console.log("zone has changed - old zone says run cron, NEW zone says no - stop cron now only -- SHOULD NOT HAVE GOT TO HERE", timezoneOffsetAtLastCron, timezoneOffsetFromUserPrefs, now); // used in production for confirming this never happens + } + else if (daysMissedNewZone > 0) { + // The old timezone says that cron should NOT run -- i.e., cron has + // already run today, from the old timezone's point of view. + // The new timezone says that cron SHOULD run, but this is almost + // certainly incorrect. + // This happens when cron occurred at a time soon after the CDS. When + // you reinterpret that time in the new timezone, it looks like it + // was before the CDS, because local time has stepped backwards. + // To fix this, rewrite the cron time to a time that the new + // timezone interprets as being in today. + + daysMissed = 0; // prevent cron running now + let timezoneOffsetDiff = timezoneOffsetAtLastCron - timezoneOffsetFromUserPrefs; + // e.g., for dangerous zone change: 240 - 300 = -60 or -660 - -600 = -60 + + user.lastCron = moment(user.lastCron).subtract(timezoneOffsetDiff, 'minutes'); + // NB: We don't change user.auth.timestamps.loggedin so that will still record the time that the previous cron actually ran. + // From now on we can ignore the old timezone: + user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; + } + else { + // Both old and new timezones indicate that cron should + // NOT run. + daysMissed = 0; // prevent cron running now + } + } + else if (timezoneOffsetAtLastCron > timezoneOffsetFromUserPrefs) { + daysMissed = daysMissedNewZone; + // TODO: Either confirm that there is nothing that could possibly go wrong here and remove the need for this else branch, or fix stuff. There are probably situations where the Dailies do not reset early enough for a user who was expecting the zone change and wants to use all their Dailies immediately in the new zone; if so, we should provide an option for easy reset of Dailies (can't be automatic because there will be other situations where the user was not prepared). + } + } + + if (!(daysMissed > 0)) { + return; + } + user.auth.timestamps.loggedin = new Date(); + user.lastCron = now; + user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; + if (user.items.lastDrop.count > 0) { + user.items.lastDrop.count = 0; + } + perfect = true; + clearBuffs = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false + }; + plan = (ref = user.purchased) != null ? ref.plan : void 0; + if (plan != null ? plan.customerId : void 0) { + if (typeof plan.dateUpdated === "undefined") { + // partial compensation for bug in subscription creation - https://github.com/HabitRPG/habitrpg/issues/6682 + plan.dateUpdated = new Date(); + } + if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { + plan.gemsBought = 0; + plan.dateUpdated = new Date(); + _.defaults(plan.consecutive, { + count: 0, + offset: 0, + trinkets: 0, + gemCapExtra: 0 + }); + plan.consecutive.count++; + if (plan.consecutive.offset > 0) { + plan.consecutive.offset--; + } else if (plan.consecutive.count % 3 === 0) { + plan.consecutive.trinkets++; + plan.consecutive.gemCapExtra += 5; + if (plan.consecutive.gemCapExtra > 25) { + plan.consecutive.gemCapExtra = 25; + } + } + } + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(+(new Date))) { + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null + }); + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0 + }); + if (typeof user.markModified === "function") { + user.markModified('purchased.plan'); + } + } + } + if (user.preferences.sleep === true) { + user.stats.buffs = clearBuffs; + user.dailys.forEach(function(daily) { + var completed, repeat, thatDay; + completed = daily.completed, repeat = daily.repeat; + thatDay = moment(now).subtract({ + days: 1 + }); + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + _.each(daily.checklist, (function(box) { + box.completed = false; + return true; + })); + } + return daily.completed = false; + }); + return; + } + multiDaysCountAsOneDay = true; + todoTally = 0; + user.todos.forEach(function(task) { + var absVal, completed, delta, id; + if (!task) { + return; + } + id = task.id, completed = task.completed; + delta = user.ops.score({ + params: { + id: task.id, + direction: 'down' + }, + query: { + times: multiDaysCountAsOneDay != null ? multiDaysCountAsOneDay : { + 1: daysMissed + }, + cron: true + } + }); + absVal = completed ? Math.abs(task.value) : task.value; + return todoTally += absVal; + }); + dailyChecked = 0; + dailyDueUnchecked = 0; + if ((base = user.party.quest.progress).down == null) { + base.down = 0; + } + user.dailys.forEach(function(task) { + var EvadeTask, completed, delta, fractionChecked, id, j, n, ref1, ref2, scheduleMisses, thatDay; + if (!task) { + return; + } + id = task.id, completed = task.completed; + EvadeTask = 0; + scheduleMisses = daysMissed; + if (completed) { + dailyChecked += 1; + } else { + scheduleMisses = 0; + for (n = j = 0, ref1 = daysMissed; 0 <= ref1 ? j < ref1 : j > ref1; n = 0 <= ref1 ? ++j : --j) { + thatDay = moment(now).subtract({ + days: n + 1 + }); + if (shouldDo(thatDay.toDate(), task, user.preferences)) { + scheduleMisses++; + if (user.stats.buffs.stealth) { + user.stats.buffs.stealth--; + EvadeTask++; + } + if (multiDaysCountAsOneDay) { + break; + } + } + } + if (scheduleMisses > EvadeTask) { + perfect = false; + if (((ref2 = task.checklist) != null ? ref2.length : void 0) > 0) { + fractionChecked = _.reduce(task.checklist, (function(m, i) { + return m + (i.completed ? 1 : 0); + }), 0) / task.checklist.length; + dailyDueUnchecked += 1 - fractionChecked; + dailyChecked += fractionChecked; + } else { + dailyDueUnchecked += 1; + } + delta = user.ops.score({ + params: { + id: task.id, + direction: 'down' + }, + query: { + times: multiDaysCountAsOneDay != null ? multiDaysCountAsOneDay : { + 1: scheduleMisses - EvadeTask + }, + cron: true + } + }); + user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); + } + } + (task.history != null ? task.history : task.history = []).push({ + date: +(new Date), + value: task.value + }); + task.completed = false; + if (completed || (scheduleMisses > 0)) { + return _.each(task.checklist, (function(i) { + i.completed = false; + return true; + })); + } + }); + user.habits.forEach(function(task) { + if (task.up === false || task.down === false) { + if (Math.abs(task.value) < 0.1) { + return task.value = 0; + } else { + return task.value = task.value / 2; + } + } + }); + ((base1 = (user.history != null ? user.history : user.history = {})).todos != null ? base1.todos : base1.todos = []).push({ + date: now, + value: todoTally + }); + expTally = user.stats.exp; + lvl = 0; + while (lvl < (user.stats.lvl - 1)) { + lvl++; + expTally += toNextLevel(lvl); + } + ((base2 = user.history).exp != null ? base2.exp : base2.exp = []).push({ + date: now, + value: expTally + }); + if (!((ref1 = user.purchased) != null ? (ref2 = ref1.plan) != null ? ref2.customerId : void 0 : void 0)) { + user.fns.preenUserHistory(); + if (typeof user.markModified === "function") { + user.markModified('history'); + } + if (typeof user.markModified === "function") { + user.markModified('dailys'); + } + } + user.stats.buffs = perfect ? ((base3 = user.achievements).perfect != null ? base3.perfect : base3.perfect = 0, user.achievements.perfect++, lvlDiv2 = Math.ceil(capByLevel(user.stats.lvl) / 2), { + str: lvlDiv2, + int: lvlDiv2, + per: lvlDiv2, + con: lvlDiv2, + stealth: 0, + streaks: false + }) : clearBuffs; + if (dailyDueUnchecked === 0 && dailyChecked === 0) { + dailyChecked = 1; + } + user.stats.mp += _.max([10, .1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + progress = user.party.quest.progress; + _progress = _.cloneDeep(progress); + _.merge(progress, { + down: 0, + up: 0 + }); + progress.collect = _.transform(progress.collect, (function(m, v, k) { + return m[k] = 0; + })); + if ((base4 = user.flags).cronCount == null) { + base4.cronCount = 0; + } + user.flags.cronCount++; + analyticsData = { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user: user, + resting: user.preferences.sleep, + cronCount: user.flags.cronCount, + progressUp: _.min([_progress.up, 900]), + progressDown: _progress.down + }; + if ((ref3 = options.analytics) != null) { + ref3.track('Cron', analyticsData); + } + return _progress; +}; diff --git a/common/script/fns/dotGet.js b/common/script/fns/dotGet.js index 3b45e54c71..c95ba55656 100644 --- a/common/script/fns/dotGet.js +++ b/common/script/fns/dotGet.js @@ -1,7 +1,5 @@ -import _ from 'lodash'; +import dotGet from '../libs/dotGet'; -// TODO remove completely, use _.get, only used in client - -module.exports = function dotGet (user, path) { - return _.get(user, path); +module.exports = function(user, path) { + return dotGet(user, path); }; diff --git a/common/script/fns/dotSet.js b/common/script/fns/dotSet.js index ceb21605af..283e7a50d0 100644 --- a/common/script/fns/dotSet.js +++ b/common/script/fns/dotSet.js @@ -1,14 +1,12 @@ -import _ from 'lodash'; +import dotSet from '../libs/dotSet'; /* - This allows you to set object properties by dot-path. Eg, you can run pathSet('stats.hp',50,user) which is the same as - user.stats.hp = 50. This is useful because in our habitrpg-shared functions we're returning changesets as {path:value}, - so that different consumers can implement setters their own way. Derby needs model.set(path, value) for example, where - Angular sets object properties directly - in which case, this function will be used. -*/ +This allows you to set object properties by dot-path. Eg, you can run pathSet('stats.hp',50,user) which is the same as +user.stats.hp = 50. This is useful because in our habitrpg-shared functions we're returning changesets as {path:value}, +so that different consumers can implement setters their own way. Derby needs model.set(path, value) for example, where +Angular sets object properties directly - in which case, this function will be used. + */ -// TODO use directly _.set and remove this fn, only used in client - -module.exports = function dotSet (user, path, val) { - return _.set(user, path, val); +module.exports = function(user, path, val) { + return dotSet(user, path, val); }; diff --git a/common/script/fns/getItem.js b/common/script/fns/getItem.js new file mode 100644 index 0000000000..b73ecf9073 --- /dev/null +++ b/common/script/fns/getItem.js @@ -0,0 +1,11 @@ +import content from '../content/index'; +import i18n from '../i18n'; + +module.exports = function(user, type) { + var item; + item = content.gear.flat[user.items.gear.equipped[type]]; + if (!item) { + return content.gear.flat[type + "_base_0"]; + } + return item; +}; diff --git a/common/script/fns/handleTwoHanded.js b/common/script/fns/handleTwoHanded.js index a861a10e68..c700346988 100644 --- a/common/script/fns/handleTwoHanded.js +++ b/common/script/fns/handleTwoHanded.js @@ -1,23 +1,24 @@ import content from '../content/index'; import i18n from '../i18n'; -module.exports = function handleTwoHanded (user, item, type = 'equipped', req = {}) { - let currentShield = content.gear.flat[user.items.gear[type].shield]; - let currentWeapon = content.gear.flat[user.items.gear[type].weapon]; +module.exports = function(user, item, type, req) { + var message, currentWeapon, currentShield; + if (type == null) { + type = 'equipped'; + } + currentShield = content.gear.flat[user.items.gear[type].shield]; + currentWeapon = content.gear.flat[user.items.gear[type].weapon]; - let message; - - if (item.type === 'shield' && (currentWeapon ? currentWeapon.twoHanded : false)) { + if (item.type === "shield" && (currentWeapon ? currentWeapon.twoHanded : false)) { user.items.gear[type].weapon = 'weapon_base_0'; message = i18n.t('messageTwoHandedUnequip', { twoHandedText: currentWeapon.text(req.language), offHandedText: item.text(req.language), }, req.language); - } else if (item.twoHanded && (currentShield && user.items.gear[type].shield !== 'shield_base_0')) { - user.items.gear[type].shield = 'shield_base_0'; + } else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) { + user.items.gear[type].shield = "shield_base_0"; message = i18n.t('messageTwoHandedEquip', { twoHandedText: item.text(req.language), offHandedText: currentShield.text(req.language), }, req.language); } - return message; }; diff --git a/common/script/fns/index.js b/common/script/fns/index.js index 04fddb2d75..24f3ab604b 100644 --- a/common/script/fns/index.js +++ b/common/script/fns/index.js @@ -1,3 +1,4 @@ +import getItem from './getItem'; import handleTwoHanded from './handleTwoHanded'; import predictableRandom from './predictableRandom'; import crit from './crit'; @@ -7,10 +8,13 @@ import dotGet from './dotGet'; import randomDrop from './randomDrop'; import autoAllocate from './autoAllocate'; import updateStats from './updateStats'; +import cron from './cron'; +import preenUserHistory from './preenUserHistory'; import ultimateGear from './ultimateGear'; import nullify from './nullify'; module.exports = { + getItem, handleTwoHanded, predictableRandom, crit, @@ -20,6 +24,8 @@ module.exports = { randomDrop, autoAllocate, updateStats, + cron, + preenUserHistory, ultimateGear, nullify, }; diff --git a/common/script/fns/nullify.js b/common/script/fns/nullify.js index 38753071fc..b6e30aa3b9 100644 --- a/common/script/fns/nullify.js +++ b/common/script/fns/nullify.js @@ -1,7 +1,5 @@ -// TODO remove once v2 is retired - -module.exports = function nullify (user) { +module.exports = function(user) { user.ops = null; user.fns = null; - user = null; + return user = null; }; diff --git a/common/script/fns/predictableRandom.js b/common/script/fns/predictableRandom.js index e67daf3b62..64c8153746 100644 --- a/common/script/fns/predictableRandom.js +++ b/common/script/fns/predictableRandom.js @@ -1,24 +1,20 @@ import _ from 'lodash'; +/* +Because the same op needs to be performed on the client and the server (critical hits, item drops, etc), +we need things to be "random", but technically predictable so that they don't go out-of-sync + */ -// Because the same op needs to be performed on the client and the server (critical hits, item drops, etc), -// we need things to be "random", but technically predictable so that they don't go out-of-sync - -module.exports = function predictableRandom (user, seed) { +module.exports = function(user, seed) { + var x; if (!seed || seed === Math.PI) { - let stats = user.stats.toObject ? user.stats.toObject() : user.stats; - // These items are not part of the stat object but exists on the server (see controllers/user#getUser) - // we remove them in order to use the same user.stats both on server and on client - stats = _.omit(stats, 'toNextLevel', 'maxHealth', 'maxMP'); - - seed = _.reduce(stats, (accumulator, val) => { - if (_.isNumber(val)) { - return accumulator + val; + seed = _.reduce(user.stats, (function(m, v) { + if (_.isNumber(v)) { + return m + v; } else { - return accumulator; + return m; } - }, 0); + }), 0); } - - let x = Math.sin(seed++) * 10000; + x = Math.sin(seed++) * 10000; return x - Math.floor(x); }; diff --git a/common/script/fns/preenUserHistory.js b/common/script/fns/preenUserHistory.js new file mode 100644 index 0000000000..a45dd82719 --- /dev/null +++ b/common/script/fns/preenUserHistory.js @@ -0,0 +1,25 @@ +import _ from 'lodash'; +import preenHistory from '../libs/preenHistory'; + +module.exports = function(user, minHistLen) { + if (minHistLen == null) { + minHistLen = 7; + } + _.each(user.habits.concat(user.dailys), function(task) { + var ref; + if (((ref = task.history) != null ? ref.length : void 0) > minHistLen) { + task.history = preenHistory(task.history); + } + return true; + }); + _.defaults(user.history, { + todos: [], + exp: [] + }); + if (user.history.exp.length > minHistLen) { + user.history.exp = preenHistory(user.history.exp); + } + if (user.history.todos.length > minHistLen) { + return user.history.todos = preenHistory(user.history.todos); + } +}; diff --git a/common/script/fns/randomDrop.js b/common/script/fns/randomDrop.js index 92064e62f1..b0121b157b 100644 --- a/common/script/fns/randomDrop.js +++ b/common/script/fns/randomDrop.js @@ -3,118 +3,80 @@ import content from '../content/index'; import i18n from '../i18n'; import { daysSince } from '../cron'; import { diminishingReturns } from '../statHelpers'; -import predictableRandom from './predictableRandom'; -import randomVal from './randomVal'; // Clone a drop object maintaining its functions so that we can change it without affecting the original item function cloneDropItem (drop) { - return _.cloneDeep(drop, (val) => { + return _.cloneDeep(drop, function (val) { return _.isFunction(val) ? val : undefined; // undefined will be handled by lodash }); } -module.exports = function randomDrop (user, modifiers, req = {}) { - let acceptableDrops; - let chance; - let drop; - let dropK; - let dropMultiplier; - let quest; - let rarity; - let task; - +module.exports = function(user, modifiers, req) { + var acceptableDrops, base, base1, base2, chance, drop, dropK, dropMultiplier, name, name1, name2, quest, rarity, ref, ref1, ref2, ref3, task; task = modifiers.task; - - chance = _.min([Math.abs(task.value - 21.27), 37.5]) / 150 + 0.02; - chance *= task.priority * // Task priority: +50% for Medium, +100% for Hard - (1 + (task.streak / 100 || 0)) * // Streak bonus: +1% per streak - (1 + user._statsComputed.per / 100) * // PERception: +1% per point - (1 + (user.contributor.level / 40 || 0)) * // Contrib levels: +2.5% per level - (1 + (user.achievements.rebirths / 20 || 0)) * // Rebirths: +5% per achievement - (1 + (user.achievements.streak / 200 || 0)) * // Streak achievements: +0.5% per achievement - (user._tmp.crit || 1) * (1 + 0.5 * (_.reduce(task.checklist, (m, i) => { - return m + (i.completed ? 1 : 0); // +50% per checklist item complete. TODO: make this into X individual drop chances instead - }, 0) || 0)); + chance = _.min([Math.abs(task.value - 21.27), 37.5]) / 150 + .02; + chance *= task.priority * (1 + (task.streak / 100 || 0)) * (1 + (user._statsComputed.per / 100)) * (1 + (user.contributor.level / 40 || 0)) * (1 + (user.achievements.rebirths / 20 || 0)) * (1 + (user.achievements.streak / 200 || 0)) * (user._tmp.crit || 1) * (1 + .5 * (_.reduce(task.checklist, (function(m, i) { + return m + (i.completed ? 1 : 0); + }), 0) || 0)); chance = diminishingReturns(chance, 0.75); - - if (user.party.quest.key) - quest = content.quests[user.party.quest.key]; - if (quest && quest.collect && predictableRandom(user, user.stats.gp) < chance) { - dropK = randomVal(user, quest.collect, { - key: true, + quest = content.quests[(ref = user.party.quest) != null ? ref.key : void 0]; + if ((quest != null ? quest.collect : void 0) && user.fns.predictableRandom(user.stats.gp) < chance) { + dropK = user.fns.randomVal(quest.collect, { + key: true }); - if (!user.party.quest.progress.collect[dropK]) - user.party.quest.progress.collect[dropK] = 0; user.party.quest.progress.collect[dropK]++; - user.markModified('party.quest.progress'); + if (typeof user.markModified === "function") { + user.markModified('party.quest.progress'); + } } - - if (user.purchased && user.purchased.plan && user.purchased.plan.customerId) { - dropMultiplier = 2; - } else { - dropMultiplier = 1; - } - - if (daysSince(user.items.lastDrop.date, user.preferences) === 0 && - user.items.lastDrop.count >= dropMultiplier * (5 + Math.floor(user._statsComputed.per / 25) + (user.contributor.level || 0))) { + dropMultiplier = ((ref1 = user.purchased) != null ? (ref2 = ref1.plan) != null ? ref2.customerId : void 0 : void 0) ? 2 : 1; + if ((daysSince(user.items.lastDrop.date, user.preferences) === 0) && (user.items.lastDrop.count >= dropMultiplier * (5 + Math.floor(user._statsComputed.per / 25) + (user.contributor.level || 0)))) { return; } - - if (user.flags && user.flags.dropsEnabled && predictableRandom(user, user.stats.exp) < chance) { - rarity = predictableRandom(user, user.stats.gp); - - if (rarity > 0.6) { // food 40% chance - drop = cloneDropItem(randomVal(user, _.where(content.food, { - canDrop: true, + if (((ref3 = user.flags) != null ? ref3.dropsEnabled : void 0) && user.fns.predictableRandom(user.stats.exp) < chance) { + rarity = user.fns.predictableRandom(user.stats.gp); + if (rarity > .6) { + drop = cloneDropItem(user.fns.randomVal(_.where(content.food, { + canDrop: true }))); - - if (!user.items.food[drop.key]) { - user.items.food[drop.key] = 0; + if ((base = user.items.food)[name = drop.key] == null) { + base[name] = 0; } user.items.food[drop.key] += 1; drop.type = 'Food'; drop.dialog = i18n.t('messageDropFood', { dropArticle: drop.article, dropText: drop.text(req.language), - dropNotes: drop.notes(req.language), + dropNotes: drop.notes(req.language) }, req.language); - } else if (rarity > 0.3) { // eggs 30% chance - drop = cloneDropItem(randomVal(user, content.dropEggs)); - if (!user.items.eggs[drop.key]) { - user.items.eggs[drop.key] = 0; + } else if (rarity > .3) { + drop = cloneDropItem(user.fns.randomVal(content.dropEggs)); + if ((base1 = user.items.eggs)[name1 = drop.key] == null) { + base1[name1] = 0; } user.items.eggs[drop.key]++; drop.type = 'Egg'; drop.dialog = i18n.t('messageDropEgg', { dropText: drop.text(req.language), - dropNotes: drop.notes(req.language), + dropNotes: drop.notes(req.language) }, req.language); - } else { // Hatching Potion, 30% chance - break down by rarity. - if (rarity < 0.02) { // Very Rare: 10% (of 30%) - acceptableDrops = ['Golden']; - } else if (rarity < 0.09) { // Rare: 20% of 30% - acceptableDrops = ['Zombie', 'CottonCandyPink', 'CottonCandyBlue']; - } else if (rarity < 0.18) { // uncommon: 30% of 30% - acceptableDrops = ['Red', 'Shade', 'Skeleton']; - } else { // common, 40% of 30% - acceptableDrops = ['Base', 'White', 'Desert']; - } - drop = cloneDropItem(randomVal(user, _.pick(content.hatchingPotions, (v, k) => { + } else { + acceptableDrops = rarity < .02 ? ['Golden'] : rarity < .09 ? ['Zombie', 'CottonCandyPink', 'CottonCandyBlue'] : rarity < .18 ? ['Red', 'Shade', 'Skeleton'] : ['Base', 'White', 'Desert']; + drop = cloneDropItem(user.fns.randomVal(_.pick(content.hatchingPotions, (function(v, k) { return acceptableDrops.indexOf(k) >= 0; - }))); - if (!user.items.hatchingPotions[drop.key]) { - user.items.hatchingPotions[drop.key] = 0; + })))); + if ((base2 = user.items.hatchingPotions)[name2 = drop.key] == null) { + base2[name2] = 0; } user.items.hatchingPotions[drop.key]++; drop.type = 'HatchingPotion'; drop.dialog = i18n.t('messageDropPotion', { dropText: drop.text(req.language), - dropNotes: drop.notes(req.language), + dropNotes: drop.notes(req.language) }, req.language); } - user._tmp.drop = drop; - user.items.lastDrop.date = Number(new Date()); - user.items.lastDrop.count++; + user.items.lastDrop.date = +(new Date); + return user.items.lastDrop.count++; } }; diff --git a/common/script/fns/randomVal.js b/common/script/fns/randomVal.js index 2244d04558..3c2b5b82e8 100644 --- a/common/script/fns/randomVal.js +++ b/common/script/fns/randomVal.js @@ -1,12 +1,14 @@ import _ from 'lodash'; -import predictableRandom from './predictableRandom'; -// Get a random property from an object -// returns random property (the value) +/* + Get a random property from an object + returns random property (the value) + */ -module.exports = function randomVal (user, obj, options = {}) { - let array = options.key ? _.keys(obj) : _.values(obj); - let rand = predictableRandom(user, options.seed); +module.exports = function(user, obj, options) { + var array, rand; + array = (options != null ? options.key : void 0) ? _.keys(obj) : _.values(obj); + rand = user.fns.predictableRandom(options != null ? options.seed : void 0); array.sort(); return array[Math.floor(rand * array.length)]; }; diff --git a/common/script/fns/resetGear.js b/common/script/fns/resetGear.js deleted file mode 100644 index 2625f5c9b8..0000000000 --- a/common/script/fns/resetGear.js +++ /dev/null @@ -1,25 +0,0 @@ -import _ from 'lodash'; -import content from '../content/index'; - -module.exports = function resetGear (user) { - let gear = user.items.gear; - - _.each(['equipped', 'costume'], function resetUserGear (type) { - gear[type] = {}; - gear[type].armor = 'armor_base_0'; - gear[type].weapon = 'weapon_warrior_0'; - gear[type].head = 'head_base_0'; - gear[type].shield = 'shield_base_0'; - }); - - // Gear.owned is a Mongo object so the _.each function iterates over hidden properties. - // The content.gear.flat[k] check should prevent this causing an error - _.each(gear.owned, function resetOwnedGear (v, k) { - if (gear.owned[k] && content.gear.flat[k] && content.gear.flat[k].value) { - gear.owned[k] = false; - } - }); - - gear.owned.weapon_warrior_0 = true; // eslint-disable-line camelcase - user.preferences.costume = false; -}; diff --git a/common/script/fns/ultimateGear.js b/common/script/fns/ultimateGear.js index be5553201d..1333e8cc38 100644 --- a/common/script/fns/ultimateGear.js +++ b/common/script/fns/ultimateGear.js @@ -1,23 +1,33 @@ import content from '../content/index'; import _ from 'lodash'; -module.exports = function ultimateGear (user) { - let owned = typeof window !== 'undefined' ? user.items.gear.owned : user.items.gear.owned.toObject(); - - content.classes.forEach((klass) => { +module.exports = function(user) { + var base, owned; + owned = typeof window !== "undefined" && window !== null ? user.items.gear.owned : user.items.gear.owned.toObject(); + if ((base = user.achievements).ultimateGearSets == null) { + base.ultimateGearSets = { + healer: false, + wizard: false, + rogue: false, + warrior: false + }; + } + content.classes.forEach(function(klass) { if (user.achievements.ultimateGearSets[klass] !== true) { - user.achievements.ultimateGearSets[klass] = _.reduce(['armor', 'shield', 'head', 'weapon'], (soFarGood, type) => { - let found = _.find(content.gear.tree[type][klass], { - last: true, + return user.achievements.ultimateGearSets[klass] = _.reduce(['armor', 'shield', 'head', 'weapon'], function(soFarGood, type) { + var found; + found = _.find(content.gear.tree[type][klass], { + last: true }); return soFarGood && (!found || owned[found.key] === true); }, true); } }); - + if (typeof user.markModified === "function") { + user.markModified('achievements.ultimateGearSets'); + } if (_.contains(user.achievements.ultimateGearSets, true) && user.flags.armoireEnabled !== true) { user.flags.armoireEnabled = true; + return typeof user.markModified === "function" ? user.markModified('flags') : void 0; } - - return; }; diff --git a/common/script/fns/updateStats.js b/common/script/fns/updateStats.js index 6061f717ab..3ce83ed664 100644 --- a/common/script/fns/updateStats.js +++ b/common/script/fns/updateStats.js @@ -1,19 +1,21 @@ import _ from 'lodash'; import { MAX_HEALTH, - MAX_STAT_POINTS, + MAX_STAT_POINTS } from '../constants'; import { toNextLevel } from '../statHelpers'; -import autoAllocate from './autoAllocate'; - -module.exports = function updateStats (user, stats, req = {}, analytics) { +module.exports = function (user, stats, req, analytics) { let allocatedStatPoints; let totalStatPoints; let experienceToNextLevel; - user.stats.hp = stats.hp > 0 ? stats.hp : 0; - user.stats.gp = stats.gp > 0 ? stats.gp : 0; - if (!user._tmp) user._tmp = {}; + if (stats.hp <= 0) { + user.stats.hp = 0; + return user.stats.hp; + } + + user.stats.hp = stats.hp; + user.stats.gp = stats.gp >= 0 ? stats.gp : 0; experienceToNextLevel = toNextLevel(user.stats.lvl); @@ -33,7 +35,7 @@ module.exports = function updateStats (user, stats, req = {}, analytics) { continue; // eslint-disable-line no-continue } if (user.preferences.automaticAllocation) { - autoAllocate(user); + user.fns.autoAllocate(); } else { user.stats.points = user.stats.lvl - allocatedStatPoints; totalStatPoints = user.stats.points + allocatedStatPoints; @@ -50,6 +52,7 @@ module.exports = function updateStats (user, stats, req = {}, analytics) { } user.stats.exp = stats.exp; + user.flags = user.flags || {}; if (!user.flags.customizationsNotification && (user.stats.exp > 5 || user.stats.lvl > 1)) { user.flags.customizationsNotification = true; @@ -59,39 +62,48 @@ module.exports = function updateStats (user, stats, req = {}, analytics) { } if (!user.flags.dropsEnabled && user.stats.lvl >= 3) { user.flags.dropsEnabled = true; - if (user.items.eggs.Wolf > 0) { - user.items.eggs.Wolf++; + if (user.items.eggs["Wolf"] > 0) { + user.items.eggs["Wolf"]++; } else { - user.items.eggs.Wolf = 1; + user.items.eggs["Wolf"] = 1; } } + if (!user.flags.classSelected && user.stats.lvl >= 10) { + user.flags.classSelected; + } _.each({ vice1: 30, atom1: 15, moonstone1: 60, - goldenknight1: 40, - }, (lvl, k) => { - if (user.stats.lvl >= lvl && !user.flags.levelDrops[k]) { - user.flags.levelDrops[k] = true; - if (!user.items.quests[k]) - user.items.quests[k] = 0; - user.items.quests[k]++; - user.markModified('flags.levelDrops'); - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: k, - acquireMethod: 'Level Drop', - category: 'behavior', - }); + goldenknight1: 40 + }, function(lvl, k) { + var analyticsData, base, base1, ref; + if (!((ref = user.flags.levelDrops) != null ? ref[k] : void 0) && user.stats.lvl >= lvl) { + if ((base = user.items.quests)[k] == null) { + base[k] = 0; } - user._tmp.drop = { + user.items.quests[k]++; + ((base1 = user.flags).levelDrops != null ? base1.levelDrops : base1.levelDrops = {})[k] = true; + if (typeof user.markModified === "function") { + user.markModified('flags.levelDrops'); + } + analyticsData = { + uuid: user._id, + itemKey: k, + acquireMethod: 'Level Drop', + category: 'behavior' + }; + if (analytics != null) { + analytics.track('acquire item', analyticsData); + } + if (!user._tmp) user._tmp = {} + return user._tmp.drop = { type: 'Quest', - key: k, + key: k }; } }); if (!user.flags.rebirthEnabled && (user.stats.lvl >= 50 || user.achievements.beastMaster)) { - user.flags.rebirthEnabled = true; + return user.flags.rebirthEnabled = true; } }; diff --git a/common/script/index.js b/common/script/index.js index 50b01bf762..ea502e9a7d 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -1,197 +1,85 @@ +import moment from 'moment'; import _ from 'lodash'; -// When using a common module from the website or the server NEVER import the module directly -// but access it through `api` (the main common) module, otherwise you would require the non transpiled version of the file in production. -let api = module.exports = {}; - -import content from './content/index'; -api.content = content; - -import * as errors from './libs/errors'; -api.errors = errors; -import i18n from './i18n'; -api.i18n = i18n; - -// TODO under api.libs.cron? -import { shouldDo, daysSince } from './cron'; -api.shouldDo = shouldDo; -api.daysSince = daysSince; - -// TODO under api.constants? and capitalize exported names too +import { + daysSince, + shouldDo, +} from './cron'; import { MAX_HEALTH, MAX_LEVEL, MAX_STAT_POINTS, - TAVERN_ID, } from './constants'; -api.maxLevel = MAX_LEVEL; -api.maxHealth = MAX_HEALTH; -api.maxStatPoints = MAX_STAT_POINTS; -api.TAVERN_ID = TAVERN_ID; - -// TODO under api.libs.statHelpers? import * as statHelpers from './statHelpers'; + +import importedLibs from './libs'; + +var $w, preenHistory, sortOrder, + indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; + +import content from './content/index'; +import i18n from './i18n'; + +let api = module.exports = {}; + +api.i18n = i18n; +api.shouldDo = shouldDo; + +api.maxLevel = MAX_LEVEL; api.capByLevel = statHelpers.capByLevel; +api.maxHealth = MAX_HEALTH; api.tnl = statHelpers.toNextLevel; api.diminishingReturns = statHelpers.diminishingReturns; -import splitWhitespace from './libs/splitWhitespace'; -api.$w = splitWhitespace; +$w = api.$w = importedLibs.splitWhitespace; +api.dotSet = importedLibs.dotSet; +api.dotGet = importedLibs.dotGet; +api.refPush = importedLibs.refPush; +api.planGemLimits = importedLibs.planGemLimits; -import dotSet from './libs/dotSet'; -api.dotSet = dotSet; +preenHistory = importedLibs.preenHistory; -import dotGet from './libs/dotGet'; -api.dotGet = dotGet; +api.preenTodos = importedLibs.preenTodos; +api.updateStore = importedLibs.updateStore; -import refPush from './libs/refPush'; -api.refPush = refPush; -import planGemLimits from './libs/planGemLimits'; -api.planGemLimits = planGemLimits; +/* +------------------------------------------------------ +Content +------------------------------------------------------ + */ -import preenTodos from './libs/preenTodos'; -api.preenTodos = preenTodos; +api.content = content; -import updateStore from './libs/updateStore'; -api.updateStore = updateStore; -import uuid from './libs/uuid'; -api.uuid = uuid; +/* +------------------------------------------------------ +Misc Helpers +------------------------------------------------------ + */ -import taskDefaults from './libs/taskDefaults'; -api.taskDefaults = taskDefaults; +api.uuid = importedLibs.uuid; +api.countExists = importedLibs.countExists; +api.taskDefaults = importedLibs.taskDefaults; +api.percent = importedLibs.percent; +api.removeWhitespace = importedLibs.removeWhitespace; +api.encodeiCalLink = importedLibs.encodeiCalLink; +api.gold = importedLibs.gold; +api.silver = importedLibs.silver; +api.taskClasses = importedLibs.taskClasses; +api.friendlyTimestamp = importedLibs.friendlyTimestamp; +api.newChatMessages = importedLibs.newChatMessages; +api.noTags = importedLibs.noTags; +api.appliedTags = importedLibs.appliedTags; -import percent from './libs/percent'; -api.percent = percent; -import gold from './libs/gold'; -api.gold = gold; - -import silver from './libs/silver'; -api.silver = silver; - -import taskClasses from './libs/taskClasses'; -api.taskClasses = taskClasses; - -import noTags from './libs/noTags'; -api.noTags = noTags; - -import appliedTags from './libs/appliedTags'; -api.appliedTags = appliedTags; - -import pickDeep from './libs/pickDeep'; -api.pickDeep = pickDeep; +/* +Various counting functions + */ import count from './count'; api.count = count; -import statsComputed from './libs/statsComputed'; -api.statsComputed = statsComputed; - -import autoAllocate from './fns/autoAllocate'; -import crit from './fns/crit'; -import handleTwoHanded from './fns/handleTwoHanded'; -import predictableRandom from './fns/predictableRandom'; -import randomDrop from './fns/randomDrop'; -import randomVal from './fns/randomVal'; -import resetGear from './fns/resetGear'; -import ultimateGear from './fns/ultimateGear'; -import updateStats from './fns/updateStats'; - -api.fns = { - autoAllocate, - crit, - handleTwoHanded, - predictableRandom, - randomDrop, - randomVal, - resetGear, - ultimateGear, - updateStats, -}; - -import scoreTask from './ops/scoreTask'; -import sleep from './ops/sleep'; -import allocate from './ops/allocate'; -import buy from './ops/buy'; -import buyGear from './ops/buyGear'; -import buyHealthPotion from './ops/buyHealthPotion'; -import buyArmoire from './ops/buyArmoire'; -import buyMysterySet from './ops/buyMysterySet'; -import buyQuest from './ops/buyQuest'; -import buySpecialSpell from './ops/buySpecialSpell'; -import allocateNow from './ops/allocateNow'; -import hatch from './ops/hatch'; -import feed from './ops/feed'; -import equip from './ops/equip'; -import changeClass from './ops/changeClass'; -import disableClasses from './ops/disableClasses'; -import purchase from './ops/purchase'; -import purchaseHourglass from './ops/hourglassPurchase'; -import readCard from './ops/readCard'; -import openMysteryItem from './ops/openMysteryItem'; -import addWebhook from './ops/addWebhook'; -import updateWebhook from './ops/updateWebhook'; -import deleteWebhook from './ops/deleteWebhook'; -import releasePets from './ops/releasePets'; -import releaseBoth from './ops/releaseBoth'; -import releaseMounts from './ops/releaseMounts'; -import updateTask from './ops/updateTask'; -import clearCompleted from './ops/clearCompleted'; -import sell from './ops/sell'; -import unlock from './ops/unlock'; -import revive from './ops/revive'; -import rebirth from './ops/rebirth'; -import blockUser from './ops/blockUser'; -import clearPMs from './ops/clearPMs'; -import deletePM from './ops/deletePM'; -import reroll from './ops/reroll'; -import addPushDevice from './ops/addPushDevice'; -import reset from './ops/reset'; -import markPmsRead from './ops/markPMSRead'; - -api.ops = { - scoreTask, - sleep, - allocate, - buy, - buyGear, - buyHealthPotion, - buyArmoire, - buyMysterySet, - buySpecialSpell, - buyQuest, - allocateNow, - hatch, - feed, - equip, - changeClass, - disableClasses, - purchase, - purchaseHourglass, - readCard, - openMysteryItem, - addWebhook, - updateWebhook, - deleteWebhook, - releasePets, - releaseBoth, - releaseMounts, - updateTask, - clearCompleted, - sell, - unlock, - revive, - rebirth, - blockUser, - clearPMs, - deletePM, - reroll, - addPushDevice, - reset, - markPmsRead, -}; /* ------------------------------------------------------ @@ -199,9 +87,10 @@ User (prototype wrapper to give it ops, helper funcs, and virtuals ------------------------------------------------------ */ + /* User is now wrapped (both on client and server), adding a few new properties: - * getters (_statsComputed) + * getters (_statsComputed, tasks, etc) * user.fns, which is a bunch of helper functions These were originally up above, but they make more sense belonging to the user object so we don't have to pass the user object all over the place. In fact, we should pull in more functions such as cron(), updateStats(), etc. @@ -232,16 +121,14 @@ TODO import importedOps from './ops'; import importedFns from './fns'; -// TODO Kept for the client side -api.wrap = function wrapUser (user, main = true) { - if (user._wrapped) return; - user._wrapped = true; - - // Make markModified available on the client side as a noop function - if (!user.markModified) { - user.markModified = function noopMarkModified () {}; +api.wrap = function(user, main) { + if (main == null) { + main = true; } - + if (user._wrapped) { + return; + } + user._wrapped = true; if (main) { user.ops = { update: _.partial(importedOps.update, user), @@ -276,9 +163,6 @@ api.wrap = function wrapUser (user, main = true) { releaseMounts: _.partial(importedOps.releaseMounts, user), releaseBoth: _.partial(importedOps.releaseBoth, user), buy: _.partial(importedOps.buy, user), - buyHealthPotion: _.partial(importedOps.buyHealthPotion, user), - buyArmoire: _.partial(importedOps.buyArmoire, user), - buyGear: _.partial(importedOps.buyGear, user), buyQuest: _.partial(importedOps.buyQuest, user), buyMysterySet: _.partial(importedOps.buyMysterySet, user), hourglassPurchase: _.partial(importedOps.hourglassPurchase, user), @@ -291,12 +175,11 @@ api.wrap = function wrapUser (user, main = true) { allocate: _.partial(importedOps.allocate, user), readCard: _.partial(importedOps.readCard, user), openMysteryItem: _.partial(importedOps.openMysteryItem, user), - score: _.partial(importedOps.scoreTask, user), - markPmsRead: _.partial(importedOps.markPmsRead, user), + score: _.partial(importedOps.score, user), }; } - user.fns = { + getItem: _.partial(importedFns.getItem, user), handleTwoHanded: _.partial(importedFns.handleTwoHanded, user), predictableRandom: _.partial(importedFns.predictableRandom, user), crit: _.partial(importedFns.crit, user), @@ -306,14 +189,34 @@ api.wrap = function wrapUser (user, main = true) { randomDrop: _.partial(importedFns.randomDrop, user), autoAllocate: _.partial(importedFns.autoAllocate, user), updateStats: _.partial(importedFns.updateStats, user), - statsComputed: _.partial(statsComputed, user), + cron: _.partial(importedFns.cron, user), + preenUserHistory: _.partial(importedFns.preenUserHistory, user), ultimateGear: _.partial(importedFns.ultimateGear, user), nullify: _.partial(importedFns.nullify, user), }; - Object.defineProperty(user, '_statsComputed', { - get () { - return statsComputed(user); - }, + get: function() { + var computed; + computed = _.reduce(['per', 'con', 'str', 'int'], (function(_this) { + return function(m, stat) { + m[stat] = _.reduce($w('stats stats.buffs items.gear.equipped.weapon items.gear.equipped.armor items.gear.equipped.head items.gear.equipped.shield'), function(m2, path) { + var item, val; + val = user.fns.dotGet(path); + return m2 + (~path.indexOf('items.gear') ? (item = content.gear.flat[val], (+(item != null ? item[stat] : void 0) || 0) * ((item != null ? item.klass : void 0) === user.stats["class"] || (item != null ? item.specialClass : void 0) === user.stats["class"] ? 1.5 : 1)) : +val[stat] || 0); + }, 0); + m[stat] += Math.floor(api.capByLevel(user.stats.lvl) / 2); + return m; + }; + })(this), {}); + computed.maxMP = computed.int * 2 + 30; + return computed; + } + }); + return Object.defineProperty(user, 'tasks', { + get: function() { + var tasks; + tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); + return _.object(_.pluck(tasks, "id"), tasks); + } }); }; diff --git a/common/script/libs/appliedTags.js b/common/script/libs/appliedTags.js index ded2a9de23..31302fbe54 100644 --- a/common/script/libs/appliedTags.js +++ b/common/script/libs/appliedTags.js @@ -1,14 +1,19 @@ +import _ from 'lodash'; + /* Are there tags applied? */ -// TODO move to client - -module.exports = function appliedTags (userTags, taskTags = []) { - let arr = userTags.filter(tag => { - return taskTags.indexOf(tag.id) !== -1; - }).map(tag => { - return tag.name; +module.exports = function(userTags, taskTags) { + var arr; + arr = []; + _.each(userTags, function(t) { + if (t == null) { + return; + } + if (taskTags != null ? taskTags[t.id] : void 0) { + return arr.push(t.name); + } }); return arr.join(', '); }; diff --git a/common/script/libs/countExists.js b/common/script/libs/countExists.js new file mode 100644 index 0000000000..964e4e286f --- /dev/null +++ b/common/script/libs/countExists.js @@ -0,0 +1,7 @@ +import _ from 'lodash'; + +module.exports = function(items) { + return _.reduce(items, (function(m, v) { + return m + (v ? 1 : 0); + }), 0); +}; diff --git a/common/script/libs/dotGet.js b/common/script/libs/dotGet.js index d8ce026b82..4585d8fd53 100644 --- a/common/script/libs/dotGet.js +++ b/common/script/libs/dotGet.js @@ -1,5 +1,9 @@ import _ from 'lodash'; -// TODO remove completely, only used in client - -module.exports = _.get; +module.exports = function(obj, path) { + return _.reduce(path.split('.'), ((function(_this) { + return function(curr, next) { + return curr != null ? curr[next] : void 0; + }; + })(this)), obj); +}; diff --git a/common/script/libs/dotSet.js b/common/script/libs/dotSet.js index b664c54d05..40165078aa 100644 --- a/common/script/libs/dotSet.js +++ b/common/script/libs/dotSet.js @@ -1,5 +1,14 @@ import _ from 'lodash'; -// TODO remove completely, only used in client - -module.exports = _.set; +module.exports = function(obj, path, val) { + var arr; + arr = path.split('.'); + return _.reduce(arr, (function(_this) { + return function(curr, next, index) { + if ((arr.length - 1) === index) { + curr[next] = val; + } + return curr[next] != null ? curr[next] : curr[next] = {}; + }; + })(this), obj); +}; diff --git a/common/script/libs/encodeiCalLink.js b/common/script/libs/encodeiCalLink.js new file mode 100644 index 0000000000..4a85badd59 --- /dev/null +++ b/common/script/libs/encodeiCalLink.js @@ -0,0 +1,9 @@ +/* +Encode the download link for .ics iCal file + */ + +module.exports = function(uid, apiToken) { + var loc, ref; + loc = (typeof window !== "undefined" && window !== null ? window.location.host : void 0) || (typeof process !== "undefined" && process !== null ? (ref = process.env) != null ? ref.BASE_URL : void 0 : void 0) || ''; + return encodeURIComponent("http://" + loc + "/v1/users/" + uid + "/calendar.ics?apiToken=" + apiToken); +}; diff --git a/common/script/libs/errors.js b/common/script/libs/errors.js deleted file mode 100644 index cb780d215b..0000000000 --- a/common/script/libs/errors.js +++ /dev/null @@ -1,42 +0,0 @@ -import extendableBuiltin from './extendableBuiltin'; - -// Base class for custom application errors -// It extends Error and capture the stack trace -export class CustomError extends extendableBuiltin(Error) { - constructor () { - super(); - - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); - } - } -} - -// We specify an httpCode for all errors so that they can be used in the API too - -export class NotAuthorized extends CustomError { - constructor (customMessage) { - super(); - this.name = this.constructor.name; - this.httpCode = 401; - this.message = customMessage || 'Not authorized.'; - } -} - -export class BadRequest extends CustomError { - constructor (customMessage) { - super(); - this.name = this.constructor.name; - this.httpCode = 400; - this.message = customMessage || 'Bad request.'; - } -} - -export class NotFound extends CustomError { - constructor (customMessage) { - super(); - this.name = this.constructor.name; - this.httpCode = 404; - this.message = customMessage || 'Not found.'; - } -} diff --git a/common/script/libs/extendableBuiltin.js b/common/script/libs/extendableBuiltin.js deleted file mode 100644 index 56186301c4..0000000000 --- a/common/script/libs/extendableBuiltin.js +++ /dev/null @@ -1,11 +0,0 @@ -// Babel 6 doesn't support extending native class (Error, Array, ...) -// This function makes it possible to extend native classes with the same results as Babel 5 -module.exports = function extendableBuiltin (klass) { - function ExtendableBuiltin () { - klass.apply(this, arguments); - } - ExtendableBuiltin.prototype = Object.create(klass.prototype); - Object.setPrototypeOf(ExtendableBuiltin, klass); - - return ExtendableBuiltin; -}; diff --git a/common/script/libs/friendlyTimestamp.js b/common/script/libs/friendlyTimestamp.js new file mode 100644 index 0000000000..dfda6fbed7 --- /dev/null +++ b/common/script/libs/friendlyTimestamp.js @@ -0,0 +1,9 @@ +import moment from 'moment'; + +/* +Friendly timestamp + */ + +module.exports = function(timestamp) { + return moment(timestamp).format('MM/DD h:mm:ss a'); +}; diff --git a/common/script/libs/gold.js b/common/script/libs/gold.js index 83d9531d5e..8016e2cff9 100644 --- a/common/script/libs/gold.js +++ b/common/script/libs/gold.js @@ -1,9 +1,7 @@ -// TODO move to client - -module.exports = function gold (num) { +module.exports = function(num) { if (num) { return Math.floor(num); } else { - return '0'; + return "0"; } }; diff --git a/common/script/libs/index.js b/common/script/libs/index.js new file mode 100644 index 0000000000..dd0c420d8d --- /dev/null +++ b/common/script/libs/index.js @@ -0,0 +1,46 @@ +import uuid from './uuid'; +import taskDefaults from './taskDefaults'; +import refPush from './refPush'; +import splitWhitespace from './splitWhitespace'; +import planGemLimits from './planGemLimits'; +import preenTodos from './preenTodos'; +import dotSet from './dotSet'; +import dotGet from './dotGet'; +import preenHistory from './preenHistory'; +import countExists from './countExists'; +import updateStore from './updateStore'; + +import appliedTags from './appliedTags'; +import encodeiCalLink from './encodeiCalLink'; +import friendlyTimestamp from './friendlyTimestamp'; +import gold from './gold'; +import newChatMessages from './newChatMessages'; +import noTags from './noTags'; +import percent from './percent'; +import removeWhitespace from './removeWhitespace'; +import silver from './silver'; +import taskClasses from './taskClasses'; + +module.exports = { + uuid, + taskDefaults, + refPush, + splitWhitespace, + planGemLimits, + preenTodos, + dotSet, + dotGet, + preenHistory, + countExists, + updateStore, + appliedTags, + encodeiCalLink, + friendlyTimestamp, + gold, + newChatMessages, + noTags, + percent, + removeWhitespace, + silver, + taskClasses, +}; diff --git a/common/script/libs/newChatMessages.js b/common/script/libs/newChatMessages.js new file mode 100644 index 0000000000..abe7680edf --- /dev/null +++ b/common/script/libs/newChatMessages.js @@ -0,0 +1,10 @@ +/* +Does user have new chat messages? + */ + +module.exports = function(messages, lastMessageSeen) { + if (!((messages != null ? messages.length : void 0) > 0)) { + return false; + } + return (messages != null ? messages[0] : void 0) && (messages[0].id !== lastMessageSeen); +}; diff --git a/common/script/libs/noTags.js b/common/script/libs/noTags.js index 16a59d1d31..c3abb9054e 100644 --- a/common/script/libs/noTags.js +++ b/common/script/libs/noTags.js @@ -4,10 +4,8 @@ import _ from 'lodash'; are any tags active? */ -// TODO move to client - -module.exports = function noTags (tags) { - return _.isEmpty(tags) || _.isEmpty(_.filter(tags, (t) => { +module.exports = function(tags) { + return _.isEmpty(tags) || _.isEmpty(_.filter(tags, function(t) { return t; })); }; diff --git a/common/script/libs/percent.js b/common/script/libs/percent.js index d7622474dd..7439b22285 100644 --- a/common/script/libs/percent.js +++ b/common/script/libs/percent.js @@ -1,12 +1,10 @@ -// TODO move to client - -module.exports = function percent (x, y, dir) { - let roundFn; +module.exports = function(x, y, dir) { + var roundFn; switch (dir) { - case 'up': + case "up": roundFn = Math.ceil; break; - case 'down': + case "down": roundFn = Math.floor; break; default: diff --git a/common/script/libs/pickDeep.js b/common/script/libs/pickDeep.js deleted file mode 100644 index 919d926854..0000000000 --- a/common/script/libs/pickDeep.js +++ /dev/null @@ -1,13 +0,0 @@ -// An utility to pick deep properties from an object. -// Works like _.pick but supports nested props (ie pickDeep(obj, ['deep.property'])) - -import _ from 'lodash'; - -module.exports = function pickDeep (obj, properties) { - if (!_.isArray(properties)) throw new Error('"properties" must be an array'); - - let result = {}; - _.each(properties, (prop) => _.set(result, prop, _.get(obj, prop))); - - return result; -}; diff --git a/common/script/libs/preenHistory.js b/common/script/libs/preenHistory.js new file mode 100644 index 0000000000..0d354c238c --- /dev/null +++ b/common/script/libs/preenHistory.js @@ -0,0 +1,43 @@ +import moment from 'moment'; +import _ from 'lodash'; + +/* +Preen history for users with > 7 history entries +This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array +of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week +of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] + */ + +module.exports = function(history) { + var newHistory, preen, thisMonth; + history = _.filter(history, function(h) { + return !!h; + }); + newHistory = []; + preen = function(amount, groupBy) { + var groups; + groups = _.chain(history).groupBy(function(h) { + return moment(h.date).format(groupBy); + }).sortBy(function(h, k) { + return k; + }).value(); + groups = groups.slice(-amount); + groups.pop(); + return _.each(groups, function(group) { + newHistory.push({ + date: moment(group[0].date).toDate(), + value: _.reduce(group, (function(m, obj) { + return m + obj.value; + }), 0) / group.length + }); + return true; + }); + }; + preen(50, "YYYY"); + preen(moment().format('MM'), "YYYYMM"); + thisMonth = moment().format('YYYYMM'); + newHistory = newHistory.concat(_.filter(history, function(h) { + return moment(h.date).format('YYYYMM') === thisMonth; + })); + return newHistory; +}; diff --git a/common/script/libs/preenTodos.js b/common/script/libs/preenTodos.js index fcf8775d5f..f07a49c9d0 100644 --- a/common/script/libs/preenTodos.js +++ b/common/script/libs/preenTodos.js @@ -1,12 +1,14 @@ import moment from 'moment'; import _ from 'lodash'; -// TODO used only in v2 +/* + Preen 3-day past-completed To-Dos from Angular & mobile app + */ -module.exports = function preenTodos (tasks) { - return _.filter(tasks, (t) => { - return !t.completed || t.challenge && t.challenge.id || moment(t.dateCompleted).isAfter(moment().subtract({ - days: 3, +module.exports = function(tasks) { + return _.filter(tasks, function(t) { + return !t.completed || (t.challenge && t.challenge.id) || moment(t.dateCompleted).isAfter(moment().subtract({ + days: 3 })); }); }; diff --git a/common/script/libs/refPush.js b/common/script/libs/refPush.js index 1bdddc9edf..06b3617014 100644 --- a/common/script/libs/refPush.js +++ b/common/script/libs/refPush.js @@ -7,14 +7,13 @@ import uuid from './uuid'; no problem. To maintain sorting, we use these helper functions: */ -module.exports = function refPush (reflist, item) { +module.exports = function(reflist, item, prune) { + if (prune == null) { + prune = 0; + } item.sort = _.isEmpty(reflist) ? 0 : _.max(reflist, 'sort').sort + 1; - if (!(item.id && !reflist[item.id])) { item.id = uuid(); } - - reflist[item.id] = item; - - return reflist[item.id]; + return reflist[item.id] = item; }; diff --git a/common/script/libs/removeWhitespace.js b/common/script/libs/removeWhitespace.js new file mode 100644 index 0000000000..1015beda54 --- /dev/null +++ b/common/script/libs/removeWhitespace.js @@ -0,0 +1,10 @@ +/* +Remove whitespace #FIXME are we using this anywwhere? Should we be? + */ + +module.exports = function(str) { + if (!str) { + return ''; + } + return str.replace(/\s/g, ''); +}; diff --git a/common/script/libs/silver.js b/common/script/libs/silver.js index 1d3620f602..0dbae97b05 100644 --- a/common/script/libs/silver.js +++ b/common/script/libs/silver.js @@ -2,13 +2,10 @@ Silver amount from their money */ -// TODO move to client - -module.exports = function silver (num) { +module.exports = function(num) { if (num) { - let centCount = Math.floor((num - Math.floor(num)) * 100); - return `0${centCount}`.slice(-2); + return ("0" + Math.floor((num - Math.floor(num)) * 100)).slice(-2); } else { - return '00'; + return "00"; } }; diff --git a/common/script/libs/splitWhitespace.js b/common/script/libs/splitWhitespace.js index 2f8276bcb3..1ef3d513aa 100644 --- a/common/script/libs/splitWhitespace.js +++ b/common/script/libs/splitWhitespace.js @@ -1,4 +1,3 @@ - -module.exports = function splitWhitespace (s) { +module.exports = function(s) { return s.split(' '); }; diff --git a/common/script/libs/statsComputed.js b/common/script/libs/statsComputed.js deleted file mode 100644 index a239b2039c..0000000000 --- a/common/script/libs/statsComputed.js +++ /dev/null @@ -1,28 +0,0 @@ -import _ from 'lodash'; -import content from '../content/index'; -import * as statHelpers from '../statHelpers'; - -module.exports = function statsComputed (user) { - let paths = ['stats', 'stats.buffs', 'items.gear.equipped.weapon', 'items.gear.equipped.armor', - 'items.gear.equipped.head', 'items.gear.equipped.shield']; - let computed = _.reduce(['per', 'con', 'str', 'int'], (m, stat) => { - m[stat] = _.reduce(paths, (m2, path) => { - let val = _.get(user, path); - let item = content.gear.flat[val]; - if (!item) item = {}; - if (!item[stat]) { - item[stat] = 0; - } else { - item[stat] = Number(item[stat]); - } - let thisMultiplier = item.klass === user.stats.class || item.specialClass === user.stats.class ? 1.5 : 1; - let thisReturn = path.indexOf('items.gear') !== -1 ? item[stat] * thisMultiplier : Number(val[stat]); - return m2 + thisReturn || 0; - }, 0); - m[stat] += Math.floor(statHelpers.capByLevel(user.stats.lvl) / 2); - return m; - }, {}); - - computed.maxMP = computed.int * 2 + 30; - return computed; -}; diff --git a/common/script/libs/taskClasses.js b/common/script/libs/taskClasses.js index 9ef223d9f7..21bed1ad63 100644 --- a/common/script/libs/taskClasses.js +++ b/common/script/libs/taskClasses.js @@ -1,45 +1,51 @@ import { - shouldDo, + shouldDo } from '../cron'; - /* Task classes given everything about the class */ - -// TODO move to the client - -module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCron = Number(new Date()), showCompleted = false, main = false) { - if (!task) { - return ''; +module.exports = function(task, filters, dayStart, lastCron, showCompleted, main) { + var classes, completed, enabled, filter, priority, ref, repeat, type, value; + if (filters == null) { + filters = []; } - let type = task.type; - let classes = task.type; - let completed = task.completed; - let value = task.value; - let priority = task.priority; - - if (main && !task._editing) { - for (let filter in filters) { - let enabled = filters[filter]; - if (!task.tags) task.tags = []; - if (enabled && task.tags.indexOf(filter) === -1) { - return 'hidden'; + if (dayStart == null) { + dayStart = 0; + } + if (lastCron == null) { + lastCron = +(new Date); + } + if (showCompleted == null) { + showCompleted = false; + } + if (main == null) { + main = false; + } + if (!task) { + return; + } + type = task.type, completed = task.completed, value = task.value, repeat = task.repeat, priority = task.priority; + if (main) { + if (!task._editing) { + for (filter in filters) { + enabled = filters[filter]; + if (enabled && !((ref = task.tags) != null ? ref[filter] : void 0)) { + return 'hidden'; + } } } } - - classes = task.type; + classes = type; if (task._editing) { - classes += ' beingEdited'; + classes += " beingEdited"; } - if (type === 'todo' || type === 'daily') { - if (completed || (type === 'daily' && !shouldDo(Number(new Date()), task, { // eslint-disable-line no-extra-parens - dayStart, + if (completed || (type === 'daily' && !shouldDo(+(new Date), task, { + dayStart: dayStart }))) { - classes += ' completed'; + classes += " completed"; } else { - classes += ' uncompleted'; + classes += " uncompleted"; } } else if (type === 'habit') { if (task.down && task.up) { @@ -49,7 +55,6 @@ module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCro classes += ' habit-narrow'; } } - if (priority === 0.1) { classes += ' difficulty-trivial'; } else if (priority === 1) { @@ -59,7 +64,6 @@ module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCro } else if (priority === 2) { classes += ' difficulty-hard'; } - if (value < -20) { classes += ' color-worst'; } else if (value < -10) { @@ -75,6 +79,5 @@ module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCro } else { classes += ' color-best'; } - return classes; }; diff --git a/common/script/libs/taskDefaults.js b/common/script/libs/taskDefaults.js index e6bdba3def..69c815e4fd 100644 --- a/common/script/libs/taskDefaults.js +++ b/common/script/libs/taskDefaults.js @@ -1,74 +1,71 @@ -import { v4 as uuid } from 'uuid'; +import uuid from './uuid'; import _ from 'lodash'; -import moment from 'moment'; -// Even though Mongoose handles task defaults, we want to make sure defaults are set on the client-side before -// sending up to the server for performance +/* +Even though Mongoose handles task defaults, we want to make sure defaults are set on the client-side before +sending up to the server for performance + */ -// TODO move to client code? +// TODO revisit -const tasksTypes = ['habit', 'daily', 'todo', 'reward']; - -module.exports = function taskDefaults (task = {}) { - if (!task.type || tasksTypes.indexOf(task.type) === -1) { +module.exports = function(task) { + var defaults, ref, ref1, ref2; + if (task == null) { + task = {}; + } + if (!(task.type && ((ref = task.type) === 'habit' || ref === 'daily' || ref === 'todo' || ref === 'reward'))) { task.type = 'habit'; } - - let defaultId = uuid(); - let defaults = { - _id: defaultId, - text: task._id || defaultId, + defaults = { + id: uuid(), + text: task.id != null ? task.id : '', notes: '', - tags: [], - value: task.type === 'reward' ? 10 : 0, priority: 1, challenge: {}, - reminders: [], attribute: 'str', - createdAt: new Date(), // TODO these are going to be overwritten by the server... - updatedAt: new Date(), + dateCreated: new Date() }; - _.defaults(task, defaults); - - if (task.type === 'habit' || task.type === 'daily') { - _.defaults(task, { - history: [], - }); - } - - if (task.type === 'todo' || task.type === 'daily') { - _.defaults(task, { - completed: false, - collapseChecklist: false, - checklist: [], - }); - } - if (task.type === 'habit') { _.defaults(task, { up: true, - down: true, + down: true + }); + } + if ((ref1 = task.type) === 'habit' || ref1 === 'daily') { + _.defaults(task, { + history: [] + }); + } + if ((ref2 = task.type) === 'daily' || ref2 === 'todo') { + _.defaults(task, { + completed: false }); } - if (task.type === 'daily') { _.defaults(task, { streak: 0, repeat: { + su: true, m: true, t: true, w: true, th: true, f: true, - s: true, - su: true, - }, - startDate: moment().startOf('day').toDate(), + s: true + } + }, { + startDate: new Date(), everyX: 1, - frequency: 'weekly', + frequency: 'weekly' }); } - + task._id = task.id; + if (task.value == null) { + task.value = task.type === 'reward' ? 10 : 0; + } + if (!_.isNumber(task.priority)) { + task.priority = 1; + } return task; }; diff --git a/common/script/libs/updateStore.js b/common/script/libs/updateStore.js index f6593de8fd..05d009fd9d 100644 --- a/common/script/libs/updateStore.js +++ b/common/script/libs/updateStore.js @@ -1,31 +1,36 @@ import _ from 'lodash'; import content from '../content/index'; -// Return the list of gear items available for purchase +/* + Update the in-browser store with new gear. FIXME this was in user.fns, but it was causing strange issues there + */ -let sortOrder = _.reduce(content.gearTypes, (accumulator, val, key) => { - accumulator[val] = key; - return accumulator; -}, {}); +var sortOrder = _.reduce(content.gearTypes, (function(m, v, k) { + m[v] = k; + return m; +}), {}); -module.exports = function updateStore (user) { - let changes = []; - - _.each(content.gearTypes, (type) => { - let found = _.find(content.gear.tree[type][user.stats.class], (item) => { +module.exports = function(user) { + var changes; + if (!user) { + return; + } + changes = []; + _.each(content.gearTypes, function(type) { + var found; + found = _.find(content.gear.tree[type][user.stats["class"]], function(item) { return !user.items.gear.owned[item.key]; }); - - if (found) changes.push(found); - }); - - changes = changes.concat(_.filter(content.gear.flat, (val) => { - if (['special', 'mystery', 'armoire'].indexOf(val.klass) !== -1 && !user.items.gear.owned[val.key] && (val.canOwn ? val.canOwn(user) : false)) { - return true; - } else { - return false; + if (found) { + changes.push(found); } + return true; + }); + changes = changes.concat(_.filter(content.gear.flat, function(v) { + var ref; + return ((ref = v.klass) === 'special' || ref === 'mystery' || ref === 'armoire') && !user.items.gear.owned[v.key] && (typeof v.canOwn === "function" ? v.canOwn(user) : void 0); })); - - return _.sortBy(changes, (change) => sortOrder[change.type]); + return _.sortBy(changes, function(c) { + return sortOrder[c.type]; + }); }; diff --git a/common/script/libs/uuid.js b/common/script/libs/uuid.js index 63f75cf398..ca20dcec4e 100644 --- a/common/script/libs/uuid.js +++ b/common/script/libs/uuid.js @@ -1,4 +1 @@ -import uuid from 'uuid'; - -// TODO remove this file completely -module.exports = uuid.v4; +module.exports = require('uuid').v4; diff --git a/common/script/ops/addPushDevice.js b/common/script/ops/addPushDevice.js index a909fe9feb..d96cf249cf 100644 --- a/common/script/ops/addPushDevice.js +++ b/common/script/ops/addPushDevice.js @@ -1,41 +1,20 @@ import _ from 'lodash'; -import i18n from '../i18n'; -import { - BadRequest, - NotAuthorized, -} from '../libs/errors'; - -// TODO move to server code -module.exports = function addPushDevice (user, req = {}) { - let regId = _.get(req, 'body.regId'); - if (!regId) throw new BadRequest(i18n.t('regIdRequired', req.language)); - - let type = _.get(req, 'body.type'); - if (!type) throw new BadRequest(i18n.t('typeRequired', req.language)); +module.exports = function(user, req, cb) { + var i, item, pd; if (!user.pushDevices) { user.pushDevices = []; } - - let pushDevices = user.pushDevices; - - let item = { - regId, - type, + pd = user.pushDevices; + item = { + regId: req.body.regId, + type: req.body.type }; - - let indexOfPushDevice = _.findIndex(pushDevices, { - regId: item.regId, + i = _.findIndex(pd, { + regId: item.regId }); - - if (indexOfPushDevice !== -1) { - throw new NotAuthorized(i18n.t('pushDeviceAlreadyAdded', req.language)); + if (i === -1) { + pd.push(item); } - - pushDevices.push(item); - - return [ - user.pushDevices, - i18n.t('pushDeviceAdded', req.language), - ]; + return typeof cb === "function" ? cb(null, user.pushDevices) : void 0; }; diff --git a/common/script/ops/addTag.js b/common/script/ops/addTag.js index b44ead8d2e..a020a5fbaf 100644 --- a/common/script/ops/addTag.js +++ b/common/script/ops/addTag.js @@ -1,17 +1,12 @@ import uuid from '../libs/uuid'; -import _ from 'lodash'; -// TODO used only in client, move there? - -module.exports = function addTag (user, req = {}) { - if (!user.tags) { +module.exports = function(user, req, cb) { + if (user.tags == null) { user.tags = []; } - user.tags.push({ name: req.body.name, - id: _.get(req, 'body.id') || uuid(), + id: req.body.id || uuid() }); - - return user.tags; + return typeof cb === "function" ? cb(null, user.tags) : void 0; }; diff --git a/common/script/ops/addTask.js b/common/script/ops/addTask.js index 592f877248..a2d1895e1e 100644 --- a/common/script/ops/addTask.js +++ b/common/script/ops/addTask.js @@ -1,23 +1,27 @@ import taskDefaults from '../libs/taskDefaults'; +import i18n from '../i18n'; -// TODO move to client since it's only used there? - -module.exports = function addTask (user, req = {body: {}}) { - let task = taskDefaults(req.body); - user.tasksOrder[`${task.type}s`].unshift(task._id); - user[`${task.type}s`].unshift(task); - +module.exports = function(user, req, cb) { + var task; + task = taskDefaults(req.body); + if (user.tasks[task.id] != null) { + return typeof cb === "function" ? cb({ + code: 409, + message: i18n.t('messageDuplicateTaskID', req.language) + }) : void 0; + } + user[task.type + "s"].unshift(task); if (user.preferences.newTaskEdit) { task._editing = true; } - if (user.preferences.tagsCollapsed) { task._tags = true; } - if (!user.preferences.advancedCollapsed) { task._advanced = true; } - + if (typeof cb === "function") { + cb(null, task); + } return task; }; diff --git a/common/script/ops/addWebhook.js b/common/script/ops/addWebhook.js index c308d1b9e5..99eaf49bba 100644 --- a/common/script/ops/addWebhook.js +++ b/common/script/ops/addWebhook.js @@ -1,27 +1,15 @@ import refPush from '../libs/refPush'; -import validator from 'validator'; -import i18n from '../i18n'; -import { - BadRequest, -} from '../libs/errors'; -import _ from 'lodash'; -module.exports = function addWebhook (user, req = {}) { - let wh = user.preferences.webhooks; - - if (!validator.isURL(_.get(req, 'body.url'))) throw new BadRequest(i18n.t('invalidUrl', req.language)); - if (!validator.isBoolean(_.get(req, 'body.enabled'))) throw new BadRequest(i18n.t('invalidEnabled', req.language)); - - user.markModified('preferences.webhooks'); - - if (req.v2 === true) { - return user.preferences.webhooks; - } else { - return [ - refPush(wh, { - url: req.body.url, - enabled: req.body.enabled, - }), - ]; +module.exports = function(user, req, cb) { + var wh; + wh = user.preferences.webhooks; + refPush(wh, { + url: req.body.url, + enabled: req.body.enabled || true, + id: req.body.id + }); + if (typeof user.markModified === "function") { + user.markModified('preferences.webhooks'); } + return typeof cb === "function" ? cb(null, user.preferences.webhooks) : void 0; }; diff --git a/common/script/ops/allocate.js b/common/script/ops/allocate.js index 8e07e09589..92b5ae53fa 100644 --- a/common/script/ops/allocate.js +++ b/common/script/ops/allocate.js @@ -1,31 +1,15 @@ import _ from 'lodash'; -import { - ATTRIBUTES, -} from '../constants'; -import { - BadRequest, - NotAuthorized, -} from '../libs/errors'; -import i18n from '../i18n'; - -module.exports = function allocate (user, req = {}) { - let stat = _.get(req, 'query.stat', 'str'); - - if (ATTRIBUTES.indexOf(stat) === -1) { - throw new BadRequest(i18n.t('invalidAttribute', {attr: stat}, req.language)); - } +import splitWhitespace from '../libs/splitWhitespace'; +module.exports = function(user, req, cb) { + var stat; + stat = req.query.stat || 'str'; if (user.stats.points > 0) { user.stats[stat]++; user.stats.points--; if (stat === 'int') { user.stats.mp++; } - } else { - throw new NotAuthorized(i18n.t('notEnoughAttrPoints', req.language)); } - - return [ - user.stats, - ]; + return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('stats'))) : void 0; }; diff --git a/common/script/ops/allocateNow.js b/common/script/ops/allocateNow.js index e8ae5d249c..815c0b8959 100644 --- a/common/script/ops/allocateNow.js +++ b/common/script/ops/allocateNow.js @@ -1,15 +1,10 @@ import _ from 'lodash'; -import autoAllocate from '../fns/autoAllocate'; -module.exports = function allocateNow (user, req = {}) { - _.times(user.stats.points, () => autoAllocate(user)); +module.exports = function(user, req, cb) { + _.times(user.stats.points, user.fns.autoAllocate); user.stats.points = 0; - - if (req.v2 === true) { - return _.pick(user, 'stats'); - } else { - return [ - user.stats, - ]; + if (typeof user.markModified === "function") { + user.markModified('stats'); } + return typeof cb === "function" ? cb(null, user.stats) : void 0; }; diff --git a/common/script/ops/blockUser.js b/common/script/ops/blockUser.js index 5c123735ed..dd08925640 100644 --- a/common/script/ops/blockUser.js +++ b/common/script/ops/blockUser.js @@ -1,21 +1,13 @@ -import validator from 'validator'; -import i18n from '../i18n'; -import { - BadRequest, -} from '../libs/errors'; - -module.exports = function blockUser (user, req = {}) { - if (!validator.isUUID(req.params.uuid)) throw new BadRequest(i18n.t('invalidUUID', req.language)); - - let i = user.inbox.blocks.indexOf(req.params.uuid); - if (i === -1) { - user.inbox.blocks.push(req.params.uuid); - } else { +module.exports = function(user, req, cb) { + var i; + i = user.inbox.blocks.indexOf(req.params.uuid); + if (~i) { user.inbox.blocks.splice(i, 1); + } else { + user.inbox.blocks.push(req.params.uuid); } - - user.markModified('inbox.blocks'); - return [ - user.inbox.blocks, - ]; + if (typeof user.markModified === "function") { + user.markModified('inbox.blocks'); + } + return typeof cb === "function" ? cb(null, user.inbox.blocks) : void 0; }; diff --git a/common/script/ops/buy.js b/common/script/ops/buy.js index ded5b034d2..1686316264 100644 --- a/common/script/ops/buy.js +++ b/common/script/ops/buy.js @@ -1,24 +1,119 @@ +import content from '../content/index'; import i18n from '../i18n'; import _ from 'lodash'; -import { - BadRequest, -} from '../libs/errors'; -import buyHealthPotion from './buyHealthPotion'; -import buyArmoire from './buyArmoire'; -import buyGear from './buyGear'; +import count from '../count'; +import splitWhitespace from '../libs/splitWhitespace'; -module.exports = function buy (user, req = {}, analytics) { - let key = _.get(req, 'params.key'); - if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); - - let buyRes; - if (key === 'potion') { - buyRes = buyHealthPotion(user, req, analytics); - } else if (key === 'armoire') { - buyRes = buyArmoire(user, req, analytics); - } else { - buyRes = buyGear(user, req, analytics); +module.exports = function(user, req, cb, analytics) { + var analyticsData, armoireExp, armoireResp, armoireResult, base, buyResp, drop, eligibleEquipment, item, key, message, name; + key = req.params.key; + item = key === 'potion' ? content.potion : key === 'armoire' ? content.armoire : content.gear.flat[key]; + if (!item) { + return typeof cb === "function" ? cb({ + code: 404, + message: "Item '" + key + " not found (see https://github.com/HabitRPG/habitrpg/blob/develop/common/script/content/index.js)" + }) : void 0; } - - return buyRes; + if (user.stats.gp < item.value) { + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('messageNotEnoughGold', req.language) + }) : void 0; + } + if ((item.canOwn != null) && !item.canOwn(user)) { + return typeof cb === "function" ? cb({ + code: 401, + message: "You can't buy this item" + }) : void 0; + } + armoireResp = void 0; + if (item.key === 'potion') { + user.stats.hp += 15; + if (user.stats.hp > 50) { + user.stats.hp = 50; + } + } else if (item.key === 'armoire') { + armoireResult = user.fns.predictableRandom(user.stats.gp); + eligibleEquipment = _.filter(content.gear.flat, (function(i) { + return i.klass === 'armoire' && !user.items.gear.owned[i.key]; + })); + if (!_.isEmpty(eligibleEquipment) && (armoireResult < .6 || !user.flags.armoireOpened)) { + eligibleEquipment.sort(); + drop = user.fns.randomVal(eligibleEquipment); + user.items.gear.owned[drop.key] = true; + user.flags.armoireOpened = true; + message = i18n.t('armoireEquipment', { + image: '', + dropText: drop.text(req.language) + }, req.language); + if (count.remainingGearInSet(user.items.gear.owned, 'armoire') === 0) { + user.flags.armoireEmpty = true; + } + armoireResp = { + type: "gear", + dropKey: drop.key, + dropText: drop.text(req.language) + }; + } else if ((!_.isEmpty(eligibleEquipment) && armoireResult < .8) || armoireResult < .5) { + drop = user.fns.randomVal(_.where(content.food, { + canDrop: true + })); + if ((base = user.items.food)[name = drop.key] == null) { + base[name] = 0; + } + user.items.food[drop.key] += 1; + message = i18n.t('armoireFood', { + image: '', + dropArticle: drop.article, + dropText: drop.text(req.language) + }, req.language); + armoireResp = { + type: "food", + dropKey: drop.key, + dropArticle: drop.article, + dropText: drop.text(req.language) + }; + } else { + armoireExp = Math.floor(user.fns.predictableRandom(user.stats.exp) * 40 + 10); + user.stats.exp += armoireExp; + message = i18n.t('armoireExp', req.language); + armoireResp = { + "type": "experience", + "value": armoireExp + }; + } + } else { + if (user.preferences.autoEquip) { + user.items.gear.equipped[item.type] = item.key; + message = user.fns.handleTwoHanded(item, null, req); + } + user.items.gear.owned[item.key] = true; + if (message == null) { + message = i18n.t('messageBought', { + itemText: item.text(req.language) + }, req.language); + } + if (item.last) { + user.fns.ultimateGear(); + } + } + user.stats.gp -= item.value; + analyticsData = { + uuid: user._id, + itemKey: key, + acquireMethod: 'Gold', + goldCost: item.value, + category: 'behavior' + }; + if (analytics != null) { + analytics.track('acquire item', analyticsData); + } + buyResp = _.pick(user, splitWhitespace('items achievements stats flags')); + if (armoireResp) { + buyResp["armoire"] = armoireResp; + } + return typeof cb === "function" ? cb({ + code: 200, + message: message + }, buyResp) : void 0; }; diff --git a/common/script/ops/buyArmoire.js b/common/script/ops/buyArmoire.js deleted file mode 100644 index e183c06984..0000000000 --- a/common/script/ops/buyArmoire.js +++ /dev/null @@ -1,115 +0,0 @@ -import content from '../content/index'; -import i18n from '../i18n'; -import _ from 'lodash'; -import count from '../count'; -import splitWhitespace from '../libs/splitWhitespace'; -import { - NotAuthorized, -} from '../libs/errors'; -import predictableRandom from '../fns/predictableRandom'; -import randomVal from '../fns/randomVal'; - -module.exports = function buyArmoire (user, req = {}, analytics) { - let item = content.armoire; - - if (user.stats.gp < item.value) { - throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); - } - - if (item.canOwn && !item.canOwn(user)) { - throw new NotAuthorized(i18n.t('cannotBuyItem', req.language)); - } - - let armoireResp; - let armoireResult; - let eligibleEquipment; - let drop; - let message; - - armoireResult = predictableRandom(user, user.stats.gp); - eligibleEquipment = _.filter(content.gear.flat, (eligible) => { - return eligible.klass === 'armoire' && !user.items.gear.owned[eligible.key]; - }); - - if (!_.isEmpty(eligibleEquipment) && (armoireResult < 0.6 || !user.flags.armoireOpened)) { - eligibleEquipment.sort(); - drop = randomVal(user, eligibleEquipment); - - if (user.items.gear.owned[drop.key]) { - throw new NotAuthorized(i18n.t('equipmentAlradyOwned', req.language)); - } - - user.items.gear.owned[drop.key] = true; - user.flags.armoireOpened = true; - message = i18n.t('armoireEquipment', { - image: ``, - dropText: drop.text(req.language), - }, req.language); - - if (count.remainingGearInSet(user.items.gear.owned, 'armoire') === 0) { - user.flags.armoireEmpty = true; - } - - armoireResp = { - type: 'gear', - dropKey: drop.key, - dropText: drop.text(req.language), - }; - } else if ((!_.isEmpty(eligibleEquipment) && armoireResult < 0.8) || armoireResult < 0.5) { // eslint-disable-line no-extra-parens - drop = randomVal(user, _.where(content.food, { - canDrop: true, - })); - user.items.food[drop.key] = user.items.food[drop.key] || 0; - user.items.food[drop.key] += 1; - - message = i18n.t('armoireFood', { - image: ``, - dropArticle: drop.article, - dropText: drop.text(req.language), - }, req.language); - armoireResp = { - type: 'food', - dropKey: drop.key, - dropArticle: drop.article, - dropText: drop.text(req.language), - }; - } else { - let armoireExp = Math.floor(predictableRandom(user, user.stats.exp) * 40 + 10); - user.stats.exp += armoireExp; - message = i18n.t('armoireExp', req.language); - armoireResp = { - type: 'experience', - value: armoireExp, - }; - } - - user.stats.gp -= item.value; - - if (!message) { - message = i18n.t('messageBought', { - itemText: item.text(req.language), - }, req.language); - } - - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: 'Armoire', - acquireMethod: 'Gold', - goldCost: item.value, - category: 'behavior', - }); - } - - let resData = _.pick(user, splitWhitespace('items flags')); - if (armoireResp) resData.armoire = armoireResp; - - if (req.v2 === true) { - return resData; - } else { - return [ - resData, - message, - ]; - } -}; diff --git a/common/script/ops/buyGear.js b/common/script/ops/buyGear.js deleted file mode 100644 index e4f4eb3b68..0000000000 --- a/common/script/ops/buyGear.js +++ /dev/null @@ -1,70 +0,0 @@ -import content from '../content/index'; -import i18n from '../i18n'; -import _ from 'lodash'; -import splitWhitespace from '../libs/splitWhitespace'; -import { - BadRequest, - NotAuthorized, - NotFound, -} from '../libs/errors'; -import handleTwoHanded from '../fns/handleTwoHanded'; -import ultimateGear from '../fns/ultimateGear'; - -module.exports = function buyGear (user, req = {}, analytics) { - let key = _.get(req, 'params.key'); - if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); - - let item = content.gear.flat[key]; - - if (!item) throw new NotFound(i18n.t('itemNotFound', {key}, req.language)); - - if (user.stats.gp < item.value) { - throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); - } - - if (item.canOwn && !item.canOwn(user)) { - throw new NotAuthorized(i18n.t('cannotBuyItem', req.language)); - } - - let message; - - if (user.items.gear.owned[item.key]) { - throw new NotAuthorized(i18n.t('equipmentAlreadyOwned', req.language)); - } - - if (user.preferences.autoEquip) { - user.items.gear.equipped[item.type] = item.key; - message = handleTwoHanded(user, item, undefined, req); - } - - user.items.gear.owned[item.key] = true; - - if (item.last) ultimateGear(user); - - user.stats.gp -= item.value; - - if (!message) { - message = i18n.t('messageBought', { - itemText: item.text(req.language), - }, req.language); - } - - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: key, - acquireMethod: 'Gold', - goldCost: item.value, - category: 'behavior', - }); - } - - if (req.v2 === true) { - return _.pick(user, splitWhitespace('items achievements stats flags')); - } else { - return [ - _.pick(user, splitWhitespace('items achievements stats flags')), - message, - ]; - } -}; diff --git a/common/script/ops/buyHealthPotion.js b/common/script/ops/buyHealthPotion.js deleted file mode 100644 index 1a6c8b0e18..0000000000 --- a/common/script/ops/buyHealthPotion.js +++ /dev/null @@ -1,48 +0,0 @@ -import content from '../content/index'; -import i18n from '../i18n'; -import { - NotAuthorized, -} from '../libs/errors'; - -module.exports = function buyHealthPotion (user, req = {}, analytics) { - let item = content.potion; - - if (user.stats.gp < item.value) { - throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); - } - - if (item.canOwn && !item.canOwn(user)) { - throw new NotAuthorized(i18n.t('cannotBuyItem', req.language)); - } - - user.stats.hp += 15; - if (user.stats.hp > 50) { - user.stats.hp = 50; - } - - user.stats.gp -= item.value; - - let message = i18n.t('messageBought', { - itemText: item.text(req.language), - }, req.language); - - - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: 'Potion', - acquireMethod: 'Gold', - goldCost: item.value, - category: 'behavior', - }); - } - - if (req.v2 === true) { - return user.stats; - } else { - return [ - user.stats, - message, - ]; - } -}; diff --git a/common/script/ops/buyMysterySet.js b/common/script/ops/buyMysterySet.js index acf0014279..44ccdb9aaf 100644 --- a/common/script/ops/buyMysterySet.js +++ b/common/script/ops/buyMysterySet.js @@ -2,54 +2,42 @@ import i18n from '../i18n'; import content from '../content/index'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; -import pickDeep from '../libs/pickDeep'; -import { - BadRequest, - NotAuthorized, - NotFound, -} from '../libs/errors'; - -module.exports = function buyMysterySet (user, req = {}, analytics) { - let key = _.get(req, 'params.key'); - if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); +module.exports = function(user, req, cb, analytics) { + var mysterySet, ref; if (!(user.purchased.plan.consecutive.trinkets > 0)) { - throw new NotAuthorized(i18n.t('notEnoughHourglasses', req.language)); + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('notEnoughHourglasses', req.language) + }) : void 0; } - - let ref = content.timeTravelerStore(user.items.gear.owned); - let mysterySet = ref ? ref[key] : undefined; - - if (!mysterySet) { - throw new NotFound(i18n.t('mysterySetNotFound', req.language)); - } - - if (typeof window !== 'undefined' && window.confirm) { // TODO move to client - if (!window.confirm(i18n.t('hourglassBuyEquipSetConfirm'))) return; - } - - _.each(mysterySet.items, item => { - user.items.gear.owned[item.key] = true; - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: item.key, - itemType: 'Subscriber Gear', - acquireMethod: 'Hourglass', - category: 'behavior', - }); + mysterySet = (ref = content.timeTravelerStore(user.items.gear.owned)) != null ? ref[req.params.key] : void 0; + if ((typeof window !== "undefined" && window !== null ? window.confirm : void 0) != null) { + if (!window.confirm(i18n.t('hourglassBuyEquipSetConfirm'))) { + return; } - }); - - user.purchased.plan.consecutive.trinkets--; - - - if (req.v2 === true) { - return pickDeep(user, splitWhitespace('items purchased.plan.consecutive')); - } else { - return [ - { items: user.items, purchasedPlanConsecutive: user.purchased.plan.consecutive }, - i18n.t('hourglassPurchaseSet', req.language), - ]; } + if (!mysterySet) { + return typeof cb === "function" ? cb({ + code: 404, + message: "Mystery set not found, or set already owned" + }) : void 0; + } + _.each(mysterySet.items, function(i) { + var analyticsData; + user.items.gear.owned[i.key] = true; + analyticsData = { + uuid: user._id, + itemKey: i.key, + itemType: 'Subscriber Gear', + acquireMethod: 'Hourglass', + category: 'behavior' + }; + return analytics != null ? analytics.track('acquire item', analyticsData) : void 0; + }); + user.purchased.plan.consecutive.trinkets--; + return typeof cb === "function" ? cb({ + code: 200, + message: i18n.t('hourglassPurchaseSet', req.language) + }, _.pick(user, splitWhitespace('items purchased.plan.consecutive'))) : void 0; }; diff --git a/common/script/ops/buyQuest.js b/common/script/ops/buyQuest.js index af7c384419..b7653d43ce 100644 --- a/common/script/ops/buyQuest.js +++ b/common/script/ops/buyQuest.js @@ -1,50 +1,49 @@ import i18n from '../i18n'; import content from '../content/index'; -import { - BadRequest, - NotAuthorized, - NotFound, -} from '../libs/errors'; -import _ from 'lodash'; - -// buy a quest with gold -module.exports = function buyQuest (user, req = {}, analytics) { - let key = _.get(req, 'params.key'); - if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); - - let item = content.quests[key]; - if (!item) throw new NotFound(i18n.t('questNotFound', {key}, req.language)); +module.exports = function(user, req, cb, analytics) { + var analyticsData, base, item, key, message, name; + key = req.params.key; + item = content.quests[key]; + if (!item) { + return typeof cb === "function" ? cb({ + code: 404, + message: "Quest '" + key + " not found (see https://github.com/HabitRPG/habitrpg/blob/develop/common/script/content/index.js)" + }) : void 0; + } if (!(item.category === 'gold' && item.goldValue)) { - throw new NotAuthorized(i18n.t('questNotGoldPurchasable', {key}, req.language)); + return typeof cb === "function" ? cb({ + code: 404, + message: "Quest '" + key + " is not a Gold-purchasable quest (see https://github.com/HabitRPG/habitrpg/blob/develop/common/script/content/index.js)" + }) : void 0; } if (user.stats.gp < item.goldValue) { - throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('messageNotEnoughGold', req.language) + }) : void 0; } - - user.items.quests[item.key] = user.items.quests[item.key] || 0; - user.items.quests[item.key]++; + message = i18n.t('messageBought', { + itemText: item.text(req.language) + }, req.language); + if ((base = user.items.quests)[name = item.key] == null) { + base[name] = 0; + } + user.items.quests[item.key] += 1; user.stats.gp -= item.goldValue; - - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: item.key, - itemType: 'Market', - goldCost: item.goldValue, - acquireMethod: 'Gold', - category: 'behavior', - }); - } - - if (req.v2 === true) { - return user.items.quests; - } else { - return [ - user.items.quests, - i18n.t('messageBought', { - itemText: item.text(req.language), - }, req.language), - ]; + analyticsData = { + uuid: user._id, + itemKey: item.key, + itemType: 'Market', + goldCost: item.goldValue, + acquireMethod: 'Gold', + category: 'behavior' + }; + if (analytics != null) { + analytics.track('acquire item', analyticsData); } + return typeof cb === "function" ? cb({ + code: 200, + message: message + }, user.items.quests) : void 0; }; diff --git a/common/script/ops/buySpecialSpell.js b/common/script/ops/buySpecialSpell.js index 20ea0251aa..e2a9dc8deb 100644 --- a/common/script/ops/buySpecialSpell.js +++ b/common/script/ops/buySpecialSpell.js @@ -2,34 +2,30 @@ import i18n from '../i18n'; import content from '../content/index'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; -import { - BadRequest, - NotAuthorized, - NotFound, -} from '../libs/errors'; - -module.exports = function buySpecialSpell (user, req = {}) { - let key = _.get(req, 'params.key'); - if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); - - let item = content.special[key]; - if (!item) throw new NotFound(i18n.t('spellNotFound', {spellId: key}, req.language)); +module.exports = function(user, req, cb) { + var base, item, key, message; + key = req.params.key; + item = content.special[key]; if (user.stats.gp < item.value) { - throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('messageNotEnoughGold', req.language) + }) : void 0; } user.stats.gp -= item.value; - - user.items.special[key]++; - - if (req.v2 === true) { - return _.pick(user, splitWhitespace('items stats')); - } else { - return [ - _.pick(user, splitWhitespace('items stats')), - i18n.t('messageBought', { - itemText: item.text(req.language), - }, req.language), - ]; + if ((base = user.items.special)[key] == null) { + base[key] = 0; } + user.items.special[key]++; + if (typeof user.markModified === "function") { + user.markModified('items.special'); + } + message = i18n.t('messageBought', { + itemText: item.text(req.language) + }, req.language); + return typeof cb === "function" ? cb({ + code: 200, + message: message + }, _.pick(user, splitWhitespace('items stats'))) : void 0; }; diff --git a/common/script/ops/changeClass.js b/common/script/ops/changeClass.js index 4b3eb6b289..98fd8fd58d 100644 --- a/common/script/ops/changeClass.js +++ b/common/script/ops/changeClass.js @@ -2,77 +2,58 @@ import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; import { capByLevel } from '../statHelpers'; -import { - NotAuthorized, -} from '../libs/errors'; -module.exports = function changeClass (user, req = {}, analytics) { - let klass = _.get(req, 'query.class'); - - // user.flags.classSelected is set to false after the user paid the 3 gems - if (user.stats.lvl < 10) { - throw new NotAuthorized(i18n.t('lvl10ChangeClass', req.language)); - } else if (!user.flags.classSelected && (klass === 'warrior' || klass === 'rogue' || klass === 'wizard' || klass === 'healer')) { - user.stats.class = klass; - user.flags.classSelected = true; - - _.each(['weapon', 'armor', 'shield', 'head'], (type) => { - let foundKey = false; - _.findLast(user.items.gear.owned, (val, key) => { - if (key.indexOf(`${type}_${klass}`) !== -1 && val === true) { - foundKey = key; - return true; - } - }); - - if (!foundKey) { - if (type === 'weapon') { - foundKey = `weapon_${klass}_0`; - } else if (type === 'shield' && klass === 'rogue') { - foundKey = 'shield_rogue_0'; - } else { - foundKey = `${type}_base_0`; - } - } - - user.items.gear.equipped[type] = foundKey; - - if (type === 'weapon' || (type === 'shield' && klass === 'rogue')) { // eslint-disable-line no-extra-parens - user.items.gear.owned[`${type}_${klass}_0`] = true; - } - }); - - if (analytics) { - analytics.track('change class', { - uuid: user._id, - class: klass, - acquireMethod: 'Gems', - gemCost: 3, - category: 'behavior', - }); +module.exports = function(user, req, cb, analytics) { + var analyticsData, klass, ref; + klass = (ref = req.query) != null ? ref["class"] : void 0; + if (klass === 'warrior' || klass === 'rogue' || klass === 'wizard' || klass === 'healer') { + analyticsData = { + uuid: user._id, + "class": klass, + acquireMethod: 'Gems', + gemCost: 3, + category: 'behavior' + }; + if (analytics != null) { + analytics.track('change class', analyticsData); } + user.stats["class"] = klass; + user.flags.classSelected = true; + _.each(["weapon", "armor", "shield", "head"], function(type) { + var foundKey; + foundKey = false; + _.findLast(user.items.gear.owned, function(v, k) { + if (~k.indexOf(type + "_" + klass) && v === true) { + return foundKey = k; + } + }); + user.items.gear.equipped[type] = foundKey ? foundKey : type === "weapon" ? "weapon_" + klass + "_0" : type === "shield" && klass === "rogue" ? "shield_rogue_0" : type + "_base_0"; + if (type === "weapon" || (type === "shield" && klass === "rogue")) { + user.items.gear.owned[type + "_" + klass + "_0"] = true; + } + return true; + }); } else { if (user.preferences.disableClasses) { user.preferences.disableClasses = false; user.preferences.autoAllocate = false; } else { - if (user.balance < 0.75) throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); - user.balance -= 0.75; + if (!(user.balance >= .75)) { + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('notEnoughGems', req.language) + }) : void 0; + } + user.balance -= .75; } - - user.stats.str = 0; - user.stats.con = 0; - user.stats.per = 0; - user.stats.int = 0; - user.stats.points = capByLevel(user.stats.lvl); + _.merge(user.stats, { + str: 0, + con: 0, + per: 0, + int: 0, + points: capByLevel(user.stats.lvl) + }); user.flags.classSelected = false; } - - if (req.v2 === true) { - return _.pick(user, splitWhitespace('stats flags items preferences')); - } else { - return [ - _.pick(user, splitWhitespace('stats flags items preferences')), - ]; - } + return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('stats flags items preferences'))) : void 0; }; diff --git a/common/script/ops/clearCompleted.js b/common/script/ops/clearCompleted.js index 26fb1727d9..d60f12704f 100644 --- a/common/script/ops/clearCompleted.js +++ b/common/script/ops/clearCompleted.js @@ -1,10 +1,12 @@ import _ from 'lodash'; -// TODO move to client since it's only used there? -// TODO rename file to clearCompletedTodos - -module.exports = function clearCompletedTodos (todos) { - _.remove(todos, todo => { - return todo.completed && (!todo.challenge || !todo.challenge.id || todo.challenge.broken); +module.exports = function(user, req, cb) { + _.remove(user.todos, function(t) { + var ref; + return t.completed && !((ref = t.challenge) != null ? ref.id : void 0); }); + if (typeof user.markModified === "function") { + user.markModified('todos'); + } + return typeof cb === "function" ? cb(null, user.todos) : void 0; }; diff --git a/common/script/ops/clearPMs.js b/common/script/ops/clearPMs.js index 765ecc3b56..47e0a6ebc6 100644 --- a/common/script/ops/clearPMs.js +++ b/common/script/ops/clearPMs.js @@ -1,7 +1,7 @@ -module.exports = function clearPMs (user) { +module.exports = function(user, req, cb) { user.inbox.messages = {}; - user.markModified('inbox.messages'); - return [ - user.inbox.messages, - ]; + if (typeof user.markModified === "function") { + user.markModified('inbox.messages'); + } + return typeof cb === "function" ? cb(null, user.inbox.messages) : void 0; }; diff --git a/common/script/ops/deletePM.js b/common/script/ops/deletePM.js index 84bb7ee33a..ad95bc9ae0 100644 --- a/common/script/ops/deletePM.js +++ b/common/script/ops/deletePM.js @@ -1,9 +1,7 @@ -import _ from 'lodash'; - -module.exports = function deletePM (user, req = {}) { - delete user.inbox.messages[_.get(req, 'params.id')]; - user.markModified(`inbox.messages.${req.params.id}`); - return [ - user.inbox.messages, - ]; +module.exports = function(user, req, cb) { + delete user.inbox.messages[req.params.id]; + if (typeof user.markModified === "function") { + user.markModified('inbox.messages.' + req.params.id); + } + return typeof cb === "function" ? cb(null, user.inbox.messages) : void 0; }; diff --git a/common/script/ops/deleteTag.js b/common/script/ops/deleteTag.js index c40fe79ba5..a82af59e86 100644 --- a/common/script/ops/deleteTag.js +++ b/common/script/ops/deleteTag.js @@ -1,32 +1,26 @@ import i18n from '../i18n'; import _ from 'lodash'; -import { NotFound } from '../libs/errors'; -// TODO used only in client, move there? - -module.exports = function deleteTag (user, req = {}) { - let tid = _.get(req, 'params.id'); - - let index = _.findIndex(user.tags, { - id: tid, +module.exports = function(user, req, cb) { + var i, tag, tid; + tid = req.params.id; + i = _.findIndex(user.tags, { + id: tid }); - - if (index === -1) { - throw new NotFound(i18n.t('messageTagNotFound', req.language)); + if (!~i) { + return typeof cb === "function" ? cb({ + code: 404, + message: i18n.t('messageTagNotFound', req.language) + }) : void 0; } - - let tag = user.tags[index]; + tag = user.tags[i]; delete user.filters[tag.id]; - - user.tags.splice(index, 1); - - _.each(user.tasks, (task) => { + user.tags.splice(i, 1); + _.each(user.tasks, function(task) { return delete task.tags[tag.id]; }); - - _.each(['habits', 'dailys', 'todos', 'rewards'], (type) => { - user.markModified(type); + _.each(['habits', 'dailys', 'todos', 'rewards'], function(type) { + return typeof user.markModified === "function" ? user.markModified(type) : void 0; }); - - return user.tags; + return typeof cb === "function" ? cb(null, user.tags) : void 0; }; diff --git a/common/script/ops/deleteTask.js b/common/script/ops/deleteTask.js index a641763de5..715b102241 100644 --- a/common/script/ops/deleteTask.js +++ b/common/script/ops/deleteTask.js @@ -1,22 +1,17 @@ import i18n from '../i18n'; -import { NotFound } from '../libs/errors'; -import _ from 'lodash'; -// TODO used only in client, move there? - -module.exports = function deleteTask (user, req = {}) { - let tid = _.get(req, 'params.id'); - let taskType = _.get(req, 'params.taskType'); - - let index = _.findIndex(user[`${taskType}s`], function findById (task) { - return task._id === tid; - }); - - if (index === -1) { - throw new NotFound(i18n.t('messageTaskNotFound', req.language)); +module.exports = function(user, req, cb) { + var i, ref, task; + task = user.tasks[(ref = req.params) != null ? ref.id : void 0]; + if (!task) { + return typeof cb === "function" ? cb({ + code: 404, + message: i18n.t('messageTaskNotFound', req.language) + }) : void 0; } - - user[`${taskType}s`].splice(index, 1); - - return {}; + i = user[task.type + "s"].indexOf(task); + if (~i) { + user[task.type + "s"].splice(i, 1); + } + return typeof cb === "function" ? cb(null, {}) : void 0; }; diff --git a/common/script/ops/deleteWebhook.js b/common/script/ops/deleteWebhook.js index 9c4f67eba8..a187f08770 100644 --- a/common/script/ops/deleteWebhook.js +++ b/common/script/ops/deleteWebhook.js @@ -1,10 +1,7 @@ -import _ from 'lodash'; - -module.exports = function deleteWebhook (user, req) { - delete user.preferences.webhooks[_.get(req, 'params.id')]; - user.markModified('preferences.webhooks'); - - return [ - user.preferences.webhooks, - ]; +module.exports = function(user, req, cb) { + delete user.preferences.webhooks[req.params.id]; + if (typeof user.markModified === "function") { + user.markModified('preferences.webhooks'); + } + return typeof cb === "function" ? cb(null, user.preferences.webhooks) : void 0; }; diff --git a/common/script/ops/disableClasses.js b/common/script/ops/disableClasses.js index e611bb0872..89636ed52d 100644 --- a/common/script/ops/disableClasses.js +++ b/common/script/ops/disableClasses.js @@ -2,19 +2,12 @@ import splitWhitespace from '../libs/splitWhitespace'; import { capByLevel } from '../statHelpers'; import _ from 'lodash'; -module.exports = function disableClasses (user, req = {}) { - user.stats.class = 'warrior'; +module.exports = function(user, req, cb) { + user.stats["class"] = 'warrior'; user.flags.classSelected = true; user.preferences.disableClasses = true; user.preferences.autoAllocate = true; user.stats.str = capByLevel(user.stats.lvl); user.stats.points = 0; - - if (req.v2 === true) { - return _.pick(user, splitWhitespace('stats flags preferences')); - } else { - return [ - _.pick(user, splitWhitespace('stats flags preferences')), - ]; - } + return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('stats flags preferences'))) : void 0; }; diff --git a/common/script/ops/equip.js b/common/script/ops/equip.js index 9c614915a7..8c07a364b1 100644 --- a/common/script/ops/equip.js +++ b/common/script/ops/equip.js @@ -1,68 +1,52 @@ import content from '../content/index'; import i18n from '../i18n'; -import handleTwoHanded from '../fns/handleTwoHanded'; -import { - NotFound, - BadRequest, -} from '../libs/errors'; -import _ from 'lodash'; - -module.exports = function equip (user, req = {}) { - // Being type a parameter followed by another parameter - // when using the API it must be passes specifically in the URL, it's won't default to equipped - let type = _.get(req, 'params.type', 'equipped'); - let key = _.get(req, 'params.key'); - - if (!key || !type) throw new BadRequest(i18n.t('missingTypeKeyEquip', req.language)); - if (['mount', 'pet', 'costume', 'equipped'].indexOf(type) === -1) { - throw new BadRequest(i18n.t('invalidTypeEquip', req.language)); - } - - let message; +module.exports = function(user, req, cb) { + var item, key, message, ref, type; + ref = [req.params.type || 'equipped', req.params.key], type = ref[0], key = ref[1]; switch (type) { - case 'mount': { + case 'mount': if (!user.items.mounts[key]) { - throw new NotFound(i18n.t('mountNotOwned', req.language)); + return typeof cb === "function" ? cb({ + code: 404, + message: ":You do not own this mount." + }) : void 0; } - user.items.currentMount = user.items.currentMount === key ? '' : key; break; - } - case 'pet': { + case 'pet': if (!user.items.pets[key]) { - throw new NotFound(i18n.t('petNotOwned', req.language)); + return typeof cb === "function" ? cb({ + code: 404, + message: ":You do not own this pet." + }) : void 0; } - user.items.currentPet = user.items.currentPet === key ? '' : key; break; - } case 'costume': - case 'equipped': { + case 'equipped': + item = content.gear.flat[key]; if (!user.items.gear.owned[key]) { - throw new NotFound(i18n.t('gearNotOwned', req.language)); + return typeof cb === "function" ? cb({ + code: 404, + message: ":You do not own this gear." + }) : void 0; } - - let item = content.gear.flat[key]; - if (user.items.gear[type][item.type] === key) { - user.items.gear[type][item.type] = `${item.type}_base_0`; + user.items.gear[type][item.type] = item.type + "_base_0"; message = i18n.t('messageUnEquipped', { - itemText: item.text(req.language), + itemText: item.text(req.language) }, req.language); } else { user.items.gear[type][item.type] = item.key; - message = handleTwoHanded(user, item, type, req); + message = user.fns.handleTwoHanded(item, type, req); + } + if (typeof user.markModified === "function") { + user.markModified("items.gear." + type); } - break; - } - } - - if (req.v2 === true) { - return user.items; - } else { - let res = [user.items]; - if (message) res.push(message); - return res; } + return typeof cb === "function" ? cb((message ? { + code: 200, + message: message + } : null), user.items) : void 0; }; diff --git a/common/script/ops/feed.js b/common/script/ops/feed.js index 637d9b3e1d..3c9b0f87fd 100644 --- a/common/script/ops/feed.js +++ b/common/script/ops/feed.js @@ -1,102 +1,78 @@ import content from '../content/index'; import i18n from '../i18n'; -import _ from 'lodash'; -import { - BadRequest, - NotAuthorized, - NotFound, -} from '../libs/errors'; -function evolve (user, pet, petDisplayName, req) { - user.items.pets[pet] = -1; - user.items.mounts[pet] = true; - - if (pet === user.items.currentPet) { - user.items.currentPet = ''; - } - - return i18n.t('messageEvolve', { - egg: petDisplayName, - }, req.language); -} - -module.exports = function feed (user, req = {}) { - let pet = _.get(req, 'params.pet'); - let foodK = _.get(req, 'params.food'); - - if (!pet || !foodK) throw new BadRequest(i18n.t('missingPetFoodFeed', req.language)); - - if (pet.indexOf('-') === -1) { - throw new BadRequest(i18n.t('invalidPetName', req.language)); - } - - let food = content.food[foodK]; - if (!food) { - throw new NotFound(i18n.t('messageFoodNotFound', req.language)); - } - - let userPets = user.items.pets; - - if (!userPets[pet]) { - throw new NotFound(i18n.t('messagePetNotFound', req.language)); - } - - let [egg, potion] = pet.split('-'); - - let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text(req.language) : potion; - let eggText = content.eggs[egg] ? content.eggs[egg].text(req.language) : egg; - - let petDisplayName = i18n.t('petName', { +module.exports = function(user, req, cb) { + var egg, eggText, evolve, food, message, pet, petDisplayName, potion, potionText, ref, ref1, ref2, userPets; + ref = req.params, pet = ref.pet, food = ref.food; + food = content.food[food]; + ref1 = pet.split('-'), egg = ref1[0], potion = ref1[1]; + userPets = user.items.pets; + potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; + eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; + petDisplayName = i18n.t('petName', { potion: potionText, - egg: eggText, - }, req.language); - - if (!user.items.food[food.key]) { - throw new NotFound(i18n.t('messageFoodNotFound', req.language)); + egg: eggText + }); + if (!userPets[pet]) { + return typeof cb === "function" ? cb({ + code: 404, + message: i18n.t('messagePetNotFound', req.language) + }) : void 0; + } + if (!((ref2 = user.items.food) != null ? ref2[food.key] : void 0)) { + return typeof cb === "function" ? cb({ + code: 404, + message: i18n.t('messageFoodNotFound', req.language) + }) : void 0; } - if (content.specialPets[pet]) { - throw new NotAuthorized(i18n.t('messageCannotFeedPet', req.language)); + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('messageCannotFeedPet', req.language) + }) : void 0; } - if (user.items.mounts[pet]) { - throw new NotAuthorized(i18n.t('messageAlreadyMount', req.language)); + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('messageAlreadyMount', req.language) + }) : void 0; } - - let message; - + message = ''; + evolve = function() { + userPets[pet] = -1; + user.items.mounts[pet] = true; + if (pet === user.items.currentPet) { + user.items.currentPet = ""; + } + return message = i18n.t('messageEvolve', { + egg: petDisplayName + }, req.language); + }; if (food.key === 'Saddle') { - message = evolve(user, pet, petDisplayName, req); + evolve(); } else { if (food.target === potion || content.hatchingPotions[potion].premium) { userPets[pet] += 5; message = i18n.t('messageLikesFood', { egg: petDisplayName, - foodText: food.text(req.language), + foodText: food.text(req.language) }, req.language); } else { userPets[pet] += 2; message = i18n.t('messageDontEnjoyFood', { egg: petDisplayName, - foodText: food.text(req.language), + foodText: food.text(req.language) }, req.language); } - if (userPets[pet] >= 50 && !user.items.mounts[pet]) { - message = evolve(user, pet, petDisplayName, req); + evolve(); } } - user.items.food[food.key]--; - - if (req.v2 === true) { - return { - value: userPets[pet], - }; - } else { - return [ - userPets[pet], - message, - ]; - } + return typeof cb === "function" ? cb({ + code: 200, + message: message + }, { + value: userPets[pet] + }) : void 0; }; diff --git a/common/script/ops/getTag.js b/common/script/ops/getTag.js index 4a7db63128..06f3f24110 100644 --- a/common/script/ops/getTag.js +++ b/common/script/ops/getTag.js @@ -1,18 +1,17 @@ import _ from 'lodash'; import i18n from '../i18n'; -import { NotFound } from '../libs/errors'; -// TODO used only in client, move there? - -module.exports = function getTag (user, req = {}) { - let tid = _.get(req, 'params.id'); - - let index = _.findIndex(user.tags, { - id: tid, +module.exports = function(user, req, cb) { + var i, tid; + tid = req.params.id; + i = _.findIndex(user.tags, { + id: tid }); - if (index === -1) { - throw new NotFound(i18n.t('messageTagNotFound', req.language)); + if (!~i) { + return typeof cb === "function" ? cb({ + code: 404, + message: i18n.t('messageTagNotFound', req.language) + }) : void 0; } - - return user.tags[index]; + return typeof cb === "function" ? cb(null, user.tags[i]) : void 0; }; diff --git a/common/script/ops/getTags.js b/common/script/ops/getTags.js index a96589a831..af9419b050 100644 --- a/common/script/ops/getTags.js +++ b/common/script/ops/getTags.js @@ -1,5 +1,3 @@ -// TODO used only in client, move there? - -module.exports = function getTags (user) { - return user.tags; +module.exports = function(user, req, cb) { + return typeof cb === "function" ? cb(null, user.tags) : void 0; }; diff --git a/common/script/ops/hatch.js b/common/script/ops/hatch.js index 87adbf3276..6292e24bcb 100644 --- a/common/script/ops/hatch.js +++ b/common/script/ops/hatch.js @@ -1,44 +1,39 @@ import content from '../content/index'; import i18n from '../i18n'; -import _ from 'lodash'; -import { - BadRequest, - NotAuthorized, - NotFound, -} from '../libs/errors'; - -module.exports = function hatch (user, req = {}) { - let egg = _.get(req, 'params.egg'); - let hatchingPotion = _.get(req, 'params.hatchingPotion'); +module.exports = function(user, req, cb) { + var egg, hatchingPotion, pet, ref; + ref = req.params, egg = ref.egg, hatchingPotion = ref.hatchingPotion; if (!(egg && hatchingPotion)) { - throw new BadRequest(i18n.t('missingEggHatchingPotionHatch', req.language)); + return typeof cb === "function" ? cb({ + code: 400, + message: "Please specify query.egg & query.hatchingPotion" + }) : void 0; } - if (!(user.items.eggs[egg] > 0 && user.items.hatchingPotions[hatchingPotion] > 0)) { - throw new NotFound(i18n.t('messageMissingEggPotion', req.language)); + return typeof cb === "function" ? cb({ + code: 403, + message: i18n.t('messageMissingEggPotion', req.language) + }) : void 0; } - if (content.hatchingPotions[hatchingPotion].premium && !content.dropEggs[egg]) { - throw new BadRequest(i18n.t('messageInvalidEggPotionCombo', req.language)); + return typeof cb === "function" ? cb({ + code: 403, + message: i18n.t('messageInvalidEggPotionCombo', req.language) + }) : void 0; } - - let pet = `${egg}-${hatchingPotion}`; - + pet = egg + "-" + hatchingPotion; if (user.items.pets[pet] && user.items.pets[pet] > 0) { - throw new NotAuthorized(i18n.t('messageAlreadyPet', req.language)); + return typeof cb === "function" ? cb({ + code: 403, + message: i18n.t('messageAlreadyPet', req.language) + }) : void 0; } - user.items.pets[pet] = 5; user.items.eggs[egg]--; user.items.hatchingPotions[hatchingPotion]--; - - if (req.v2 === true) { - return user.items; - } else { - return [ - user.items, - i18n.t('messageHatched', req.language), - ]; - } + return typeof cb === "function" ? cb({ + code: 200, + message: i18n.t('messageHatched', req.language) + }, user.items) : void 0; }; diff --git a/common/script/ops/hourglassPurchase.js b/common/script/ops/hourglassPurchase.js index e1d07bb482..fa0b9e672a 100644 --- a/common/script/ops/hourglassPurchase.js +++ b/common/script/ops/hourglassPurchase.js @@ -1,61 +1,54 @@ import content from '../content/index'; import i18n from '../i18n'; import _ from 'lodash'; -import { - BadRequest, - NotAuthorized, -} from '../libs/errors'; import splitWhitespace from '../libs/splitWhitespace'; -module.exports = function purchaseHourglass (user, req = {}, analytics) { - let key = _.get(req, 'params.key'); - if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language)); - - let type = _.get(req, 'params.type'); - if (!type) throw new BadRequest(i18n.t('missingTypeParam', req.language)); - +module.exports = function(user, req, cb, analytics) { + var analyticsData, key, ref, type; + ref = req.params, type = ref.type, key = ref.key; if (!content.timeTravelStable[type]) { - throw new NotAuthorized(i18n.t('typeNotAllowedHourglass', {allowedTypes: _.keys(content.timeTravelStable).toString()}, req.language)); + return typeof cb === "function" ? cb({ + code: 403, + message: i18n.t('typeNotAllowedHourglass', {allowedTypes: _.keys(content.timeTravelStable).toString()}, req.language) + }) : void 0; } - if (!_.contains(_.keys(content.timeTravelStable[type]), key)) { - throw new NotAuthorized(i18n.t('notAllowedHourglass', req.language)); + return typeof cb === "function" ? cb({ + code: 403, + message: i18n.t(type + 'NotAllowedHourglass', req.language) + }) : void 0; } - if (user.items[type][key]) { - throw new NotAuthorized(i18n.t(`${type}AlreadyOwned`, req.language)); + return typeof cb === "function" ? cb({ + code: 403, + message: i18n.t(type + 'AlreadyOwned', req.language) + }) : void 0; } - - if (user.purchased.plan.consecutive.trinkets <= 0) { - throw new NotAuthorized(i18n.t('notEnoughHourglasses', req.language)); + if (!(user.purchased.plan.consecutive.trinkets > 0)) { + return typeof cb === "function" ? cb({ + code: 403, + message: i18n.t('notEnoughHourglasses', req.language) + }) : void 0; } - user.purchased.plan.consecutive.trinkets--; - if (type === 'pets') { user.items.pets[key] = 5; } - if (type === 'mounts') { user.items.mounts[key] = true; } - - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: key, - itemType: type, - acquireMethod: 'Hourglass', - category: 'behavior', - }); - } - - if (req.v2 === true) { - return _.pick(user, splitWhitespace('items purchased.plan.consecutive')); - } else { - return [ - { items: user.items, purchasedPlanConsecutive: user.purchased.plan.consecutive }, - i18n.t('hourglassPurchase', req.language), - ]; + analyticsData = { + uuid: user._id, + itemKey: key, + itemType: type, + acquireMethod: 'Hourglass', + category: 'behavior' + }; + if (analytics != null) { + analytics.track('acquire item', analyticsData); } + return typeof cb === "function" ? cb({ + code: 200, + message: i18n.t('hourglassPurchase', req.language) + }, _.pick(user, splitWhitespace('items purchased.plan.consecutive'))) : void 0; }; diff --git a/common/script/ops/index.js b/common/script/ops/index.js index 2e8bca246d..1bfd3d55f2 100644 --- a/common/script/ops/index.js +++ b/common/script/ops/index.js @@ -30,9 +30,6 @@ import releasePets from './releasePets'; import releaseMounts from './releaseMounts'; import releaseBoth from './releaseBoth'; import buy from './buy'; -import buyGear from './buyGear'; -import buyHealthPotion from './buyHealthPotion'; -import buyArmoire from './buyArmoire'; import buyQuest from './buyQuest'; import buyMysterySet from './buyMysterySet'; import hourglassPurchase from './hourglassPurchase'; @@ -45,9 +42,7 @@ import disableClasses from './disableClasses'; import allocate from './allocate'; import readCard from './readCard'; import openMysteryItem from './openMysteryItem'; -import scoreTask from './scoreTask'; -import markPmsRead from './markPMSRead'; - +import score from './score'; module.exports = { update, @@ -82,9 +77,6 @@ module.exports = { releaseMounts, releaseBoth, buy, - buyGear, - buyHealthPotion, - buyArmoire, buyQuest, buyMysterySet, hourglassPurchase, @@ -97,6 +89,5 @@ module.exports = { allocate, readCard, openMysteryItem, - scoreTask, - markPmsRead, + score, }; diff --git a/common/script/ops/markPMSRead.js b/common/script/ops/markPMSRead.js deleted file mode 100644 index add9f49de5..0000000000 --- a/common/script/ops/markPMSRead.js +++ /dev/null @@ -1,14 +0,0 @@ -import i18n from '../i18n'; - -module.exports = function markPmsRead (user, req = {}) { - user.inbox.newMessages = 0; - - if (req.v2 === true) { - return user; - } else { - return [ - user.inbox.newMessages, - i18n.t('pmsMarkedRead'), - ]; - } -}; diff --git a/common/script/ops/openMysteryItem.js b/common/script/ops/openMysteryItem.js index 743104c48b..eb28c605e0 100644 --- a/common/script/ops/openMysteryItem.js +++ b/common/script/ops/openMysteryItem.js @@ -1,44 +1,32 @@ import content from '../content/index'; -import i18n from '../i18n'; -import { - BadRequest, -} from '../libs/errors'; -import _ from 'lodash'; - -module.exports = function openMysteryItem (user, req = {}, analytics) { - let item = user.purchased.plan.mysteryItems.shift(); +module.exports = function(user, req, cb, analytics) { + var analyticsData, item, ref, ref1; + item = (ref = user.purchased.plan) != null ? (ref1 = ref.mysteryItems) != null ? ref1.shift() : void 0 : void 0; if (!item) { - throw new BadRequest(i18n.t('mysteryItemIsEmpty', req.language)); + return typeof cb === "function" ? cb({ + code: 400, + message: "Empty" + }) : void 0; } - - item = _.cloneDeep(content.gear.flat[item]); - item.notificationType = 'Mystery'; + item = content.gear.flat[item]; user.items.gear.owned[item.key] = true; - - user.markModified('purchased.plan.mysteryItems'); - - if (analytics) { - analytics.track('open mystery item', { - uuid: user._id, - itemKey: item, - itemType: 'Subscriber Gear', - acquireMethod: 'Subscriber', - category: 'behavior', - }); + if (typeof user.markModified === "function") { + user.markModified('purchased.plan.mysteryItems'); + } + item.notificationType = 'Mystery'; + analyticsData = { + uuid: user._id, + itemKey: item, + itemType: 'Subscriber Gear', + acquireMethod: 'Subscriber', + category: 'behavior' + }; + if (analytics != null) { + analytics.track('open mystery item', analyticsData); } - if (typeof window !== 'undefined') { - if (!user._tmp) user._tmp = {}; - user._tmp.drop = item; - } - - if (req.v2 === true) { - return user.items.gear.owned; - } else { - return [ - user.items.gear.owned, - i18n.t('mysteryItemOpened', req.language), - ]; + (user._tmp != null ? user._tmp : user._tmp = {}).drop = item; } + return typeof cb === "function" ? cb(null, user.items.gear.owned) : void 0; }; diff --git a/common/script/ops/purchase.js b/common/script/ops/purchase.js index 79eb0475d4..d4d0f78816 100644 --- a/common/script/ops/purchase.js +++ b/common/script/ops/purchase.js @@ -3,125 +3,105 @@ import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; import planGemLimits from '../libs/planGemLimits'; -import { - NotFound, - NotAuthorized, - BadRequest, -} from '../libs/errors'; - -module.exports = function purchase (user, req = {}, analytics) { - let type = _.get(req.params, 'type'); - let key = _.get(req.params, 'key'); - let item; - let price; - - if (!type) { - throw new BadRequest(i18n.t('typeRequired', req.language)); - } - - if (!key) { - throw new BadRequest(i18n.t('keyRequired', req.language)); - } +module.exports = function(user, req, cb, analytics) { + var analyticsData, convCap, convRate, item, key, price, ref, ref1, ref2, ref3, type; + ref = req.params, type = ref.type, key = ref.key; if (type === 'gems' && key === 'gem') { - let convRate = planGemLimits.convRate; - let convCap = planGemLimits.convCap; + ref1 = planGemLimits, convRate = ref1.convRate, convCap = ref1.convCap; convCap += user.purchased.plan.consecutive.gemCapExtra; - - if (!user.purchased || !user.purchased.plan || !user.purchased.plan.customerId) { - throw new NotAuthorized(i18n.t('mustSubscribeToPurchaseGems', req.language)); + if (!((ref2 = user.purchased) != null ? (ref3 = ref2.plan) != null ? ref3.customerId : void 0 : void 0)) { + return typeof cb === "function" ? cb({ + code: 401, + message: "Must subscribe to purchase gems with GP" + }, req) : void 0; } - - if (user.stats.gp < convRate) { - throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + if (!(user.stats.gp >= convRate)) { + return typeof cb === "function" ? cb({ + code: 401, + message: "Not enough Gold" + }) : void 0; } - if (user.purchased.plan.gemsBought >= convCap) { - throw new NotAuthorized(i18n.t('reachedGoldToGemCap', {convCap}, req.language)); + return typeof cb === "function" ? cb({ + code: 401, + message: "You've reached the Gold=>Gem conversion cap (" + convCap + ") for this month. We have this to prevent abuse / farming. The cap will reset within the first three days of next month." + }) : void 0; } - - user.balance += 0.25; + user.balance += .25; user.purchased.plan.gemsBought++; user.stats.gp -= convRate; - - if (analytics) { - analytics.track('purchase gems', { - uuid: user._id, - itemKey: key, - acquireMethod: 'Gold', - goldCost: convRate, - category: 'behavior', - }); + analyticsData = { + uuid: user._id, + itemKey: key, + acquireMethod: 'Gold', + goldCost: convRate, + category: 'behavior' + }; + if (analytics != null) { + analytics.track('purchase gems', analyticsData); } - - return [ - _.pick(user, splitWhitespace('stats balance')), - i18n.t('plusOneGem'), - ]; + return typeof cb === "function" ? cb({ + code: 200, + message: "+1 Gem" + }, _.pick(user, splitWhitespace('stats balance'))) : void 0; } - - let acceptedTypes = ['eggs', 'hatchingPotions', 'food', 'quests', 'gear']; - if (acceptedTypes.indexOf(type) === -1) { - throw new NotFound(i18n.t('notAccteptedType', req.language)); + if (type !== 'eggs' && type !== 'hatchingPotions' && type !== 'food' && type !== 'quests' && type !== 'gear') { + return typeof cb === "function" ? cb({ + code: 404, + message: ":type must be in [eggs,hatchingPotions,food,quests,gear]" + }, req) : void 0; } - if (type === 'gear') { item = content.gear.flat[key]; - - if (!item) { - throw new NotFound(i18n.t('contentKeyNotFound', {type}, req.language)); - } - if (user.items.gear.owned[key]) { - throw new NotAuthorized(i18n.t('alreadyHave', req.language)); + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('alreadyHave', req.language) + }) : void 0; } - price = (item.twoHanded || item.gearSet === 'animal' ? 2 : 1) / 4; } else { item = content[type][key]; - - if (!item) { - throw new NotFound(i18n.t('contentKeyNotFound', {type}, req.language)); - } - price = item.value / 4; } - + if (!item) { + return typeof cb === "function" ? cb({ + code: 404, + message: ":key not found for Content." + type + }, req) : void 0; + } if (!item.canBuy(user)) { - throw new NotAuthorized(i18n.t('messageNotAvailable', req.language)); + return typeof cb === "function" ? cb({ + code: 403, + message: i18n.t('messageNotAvailable', req.language) + }) : void 0; } - - if (!user.balance || user.balance < price) { - throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); + if ((user.balance < price) || !user.balance) { + return typeof cb === "function" ? cb({ + code: 403, + message: i18n.t('notEnoughGems', req.language) + }) : void 0; } - user.balance -= price; - if (type === 'gear') { user.items.gear.owned[key] = true; } else { - if (!user.items[type][key] || user.items[type][key] < 0) { + if (!(user.items[type][key] > 0)) { user.items[type][key] = 0; } user.items[type][key]++; } - - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: key, - itemType: 'Market', - acquireMethod: 'Gems', - gemCost: item.value, - category: 'behavior', - }); - } - - if (req.v2 === true) { - return _.pick(user, splitWhitespace('items balance')); - } else { - return [ - _.pick(user, splitWhitespace('items balance')), - ]; + analyticsData = { + uuid: user._id, + itemKey: key, + itemType: 'Market', + acquireMethod: 'Gems', + gemCost: item.value, + category: 'behavior' + }; + if (analytics != null) { + analytics.track('acquire item', analyticsData); } + return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('items balance'))) : void 0; }; diff --git a/common/script/ops/readCard.js b/common/script/ops/readCard.js index 57b0da4b00..a6eb5c05f1 100644 --- a/common/script/ops/readCard.js +++ b/common/script/ops/readCard.js @@ -1,32 +1,10 @@ -import splitWhitespace from '../libs/splitWhitespace'; -import _ from 'lodash'; -import i18n from '../i18n'; -import { - BadRequest, - NotAuthorized, -} from '../libs/errors'; -import content from '../content/index'; - -module.exports = function readCard (user, req = {}) { - let cardType = _.get(req.params, 'cardType'); - - if (!cardType) { - throw new BadRequest(i18n.t('cardTypeRequired', req.language)); +module.exports = function(user, req, cb) { + var cardType; + cardType = req.params.cardType; + user.items.special[cardType + "Received"].shift(); + if (typeof user.markModified === "function") { + user.markModified("items.special." + cardType + "Received"); } - - if (_.keys(content.cardTypes).indexOf(cardType) === -1) { - throw new NotAuthorized(i18n.t('cardTypeNotAllowed', req.language)); - } - - user.items.special[`${cardType}Received`].shift(); user.flags.cardReceived = false; - - if (req.v2 === true) { - return _.pick(user, splitWhitespace('items.special flags.cardReceived')); - } else { - return [ - { specialItems: user.items.special, cardReceived: user.flags.cardReceived }, - i18n.t('readCard', {cardType}, req.language), - ]; - } + return typeof cb === "function" ? cb(null, 'items.special flags.cardReceived') : void 0; }; diff --git a/common/script/ops/rebirth.js b/common/script/ops/rebirth.js index 54f9533fc5..1ddb75aba1 100644 --- a/common/script/ops/rebirth.js +++ b/common/script/ops/rebirth.js @@ -1,25 +1,21 @@ +import content from '../content/index'; import i18n from '../i18n'; import _ from 'lodash'; import { capByLevel } from '../statHelpers'; import { MAX_LEVEL } from '../constants'; -import { - NotAuthorized, -} from '../libs/errors'; -import resetGear from '../fns/resetGear'; -import equip from './equip'; -const USERSTATSLIST = ['per', 'int', 'con', 'str', 'points', 'gp', 'exp', 'mp']; - -module.exports = function rebirth (user, tasks = [], req = {}, analytics) { +module.exports = function(user, req, cb, analytics) { + var analyticsData, flags, gear, lvl, stats; if (user.balance < 2 && user.stats.lvl < MAX_LEVEL) { - throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('notEnoughGems', req.language) + }) : void 0; } - - let analyticsData = { + analyticsData = { uuid: user._id, - category: 'behavior', + category: 'behavior' }; - if (user.stats.lvl < MAX_LEVEL) { user.balance -= 2; analyticsData.acquireMethod = 'Gems'; @@ -28,55 +24,63 @@ module.exports = function rebirth (user, tasks = [], req = {}, analytics) { analyticsData.gemCost = 0; analyticsData.acquireMethod = '> 100'; } - - if (analytics) { + if (analytics != null) { analytics.track('Rebirth', analyticsData); } - - let lvl = capByLevel(user.stats.lvl); - - _.each(tasks, function resetTasks (task) { - if (!task.challenge || !task.challenge.id || task.challenge.broken) { - if (task.type !== 'reward') { - task.value = 0; - } - if (task.type === 'daily') { - task.streak = 0; - } + lvl = capByLevel(user.stats.lvl); + _.each(user.tasks, function(task) { + if (task.type !== 'reward') { + task.value = 0; + } + if (task.type === 'daily') { + return task.streak = 0; } }); - - let stats = user.stats; + stats = user.stats; stats.buffs = {}; stats.hp = 50; stats.lvl = 1; - stats.class = 'warrior'; - - _.each(USERSTATSLIST, function resetStats (value) { - stats[value] = 0; + stats["class"] = 'warrior'; + _.each(['per', 'int', 'con', 'str', 'points', 'gp', 'exp', 'mp'], function(value) { + return stats[value] = 0; + }); + // TODO during refactoring: move all gear code from rebirth() to its own function and then call it in reset() as well + gear = user.items.gear; + _.each(['equipped', 'costume'], function(type) { + gear[type] = {}; + gear[type].armor = 'armor_base_0'; + gear[type].weapon = 'weapon_warrior_0'; + gear[type].head = 'head_base_0'; + return gear[type].shield = 'shield_base_0'; }); - - resetGear(user); - if (user.items.currentPet) { - equip(user, { + user.ops.equip({ params: { type: 'pet', - key: user.items.currentPet, - }, + key: user.items.currentPet + } }); } - if (user.items.currentMount) { - equip(user, { + user.ops.equip({ params: { type: 'mount', - key: user.items.currentMount, - }, + key: user.items.currentMount + } }); } - - let flags = user.flags; + _.each(gear.owned, function(v, k) { + if (gear.owned[k] && content.gear.flat[k].value) { + gear.owned[k] = false; + return true; + } + }); + gear.owned.weapon_warrior_0 = true; + if (typeof user.markModified === "function") { + user.markModified('items.gear.owned'); + } + user.preferences.costume = false; + flags = user.flags; if (!user.achievements.beastMaster) { flags.rebirthEnabled = false; } @@ -84,23 +88,13 @@ module.exports = function rebirth (user, tasks = [], req = {}, analytics) { flags.dropsEnabled = false; flags.classSelected = false; flags.levelDrops = {}; - if (!user.achievements.rebirths) { user.achievements.rebirths = 1; user.achievements.rebirthLevel = lvl; - } else if (lvl > user.achievements.rebirthLevel || lvl === MAX_LEVEL) { + } else if (lvl > user.achievements.rebirthLevel || lvl === 100) { user.achievements.rebirths++; user.achievements.rebirthLevel = lvl; } - user.stats.buffs = {}; - - if (req.v2 === true) { - return user; - } else { - return [ - {user, tasks}, - i18n.t('rebirthComplete'), - ]; - } + return typeof cb === "function" ? cb(null, user) : void 0; }; diff --git a/common/script/ops/releaseBoth.js b/common/script/ops/releaseBoth.js index cf7d2267ca..a782581f85 100644 --- a/common/script/ops/releaseBoth.js +++ b/common/script/ops/releaseBoth.js @@ -1,68 +1,50 @@ import content from '../content/index'; import i18n from '../i18n'; -import { - NotAuthorized, -} from '../libs/errors'; -import splitWhitespace from '../libs/splitWhitespace'; -import _ from 'lodash'; - -module.exports = function releaseBoth (user, req = {}, analytics) { - let animal; +module.exports = function(user, req, cb, analytics) { + var analyticsData, animal, giveTriadBingo; if (user.balance < 1.5 && !user.achievements.triadBingo) { - throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); - } - - let giveTriadBingo = true; - - if (!user.achievements.triadBingo) { - if (analytics) { - analytics.track('release pets & mounts', { + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('notEnoughGems', req.language) + }) : void 0; + } else { + giveTriadBingo = true; + if (!user.achievements.triadBingo) { + analyticsData = { uuid: user._id, acquireMethod: 'Gems', gemCost: 6, - category: 'behavior', - }); + category: 'behavior' + }; + if (typeof analytics !== "undefined" && analytics !== null) { + analytics.track('release pets & mounts', analyticsData); + } + user.balance -= 1.5; } - - user.balance -= 1.5; - } - - user.items.currentMount = ''; - user.items.currentPet = ''; - - for (animal in content.pets) { - if (user.items.pets[animal] === -1) { - giveTriadBingo = false; + user.items.currentMount = ""; + user.items.currentPet = ""; + for (animal in content.pets) { + if (user.items.pets[animal] === -1) { + giveTriadBingo = false; + } + user.items.pets[animal] = 0; + user.items.mounts[animal] = null; } - - user.items.pets[animal] = 0; - user.items.mounts[animal] = null; - } - - if (!user.achievements.beastMasterCount) { - user.achievements.beastMasterCount = 0; - } - user.achievements.beastMasterCount++; - - if (!user.achievements.mountMasterCount) { - user.achievements.mountMasterCount = 0; - } - user.achievements.mountMasterCount++; - - if (giveTriadBingo) { - if (!user.achievements.triadBingoCount) { - user.achievements.triadBingoCount = 0; + if (!user.achievements.beastMasterCount) { + user.achievements.beastMasterCount = 0; + } + user.achievements.beastMasterCount++; + if (!user.achievements.mountMasterCount) { + user.achievements.mountMasterCount = 0; + } + user.achievements.mountMasterCount++; + if (giveTriadBingo) { + if (!user.achievements.triadBingoCount) { + user.achievements.triadBingoCount = 0; + } + user.achievements.triadBingoCount++; } - user.achievements.triadBingoCount++; - } - - if (req.v2 === true) { - return user; - } else { - return [ - _.pick(user, splitWhitespace('achievements items balance')), - i18n.t('mountsAndPetsReleased'), - ]; } + return typeof cb === "function" ? cb(null, user) : void 0; }; diff --git a/common/script/ops/releaseMounts.js b/common/script/ops/releaseMounts.js index d8b8dde659..4aefab6b40 100644 --- a/common/script/ops/releaseMounts.js +++ b/common/script/ops/releaseMounts.js @@ -1,43 +1,32 @@ import content from '../content/index'; import i18n from '../i18n'; -import { - NotAuthorized, -} from '../libs/errors'; - -module.exports = function releaseMounts (user, req = {}, analytics) { - let mount; +module.exports = function(user, req, cb, analytics) { + var analyticsData, mount; if (user.balance < 1) { - throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); - } - - user.balance -= 1; - user.items.currentMount = ''; - - for (mount in content.pets) { - user.items.mounts[mount] = null; - } - - if (!user.achievements.mountMasterCount) { - user.achievements.mountMasterCount = 0; - } - user.achievements.mountMasterCount++; - - if (analytics) { - analytics.track('release mounts', { - uuid: user._id, - acquireMethod: 'Gems', - gemCost: 4, - category: 'behavior', - }); - } - - if (req.v2 === true) { - return user; + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('notEnoughGems', req.language) + }) : void 0; } else { - return [ - user.items.mounts, - i18n.t('mountsReleased'), - ]; + user.balance -= 1; + user.items.currentMount = ""; + for (mount in content.pets) { + user.items.mounts[mount] = null; + } + if (!user.achievements.mountMasterCount) { + user.achievements.mountMasterCount = 0; + } + user.achievements.mountMasterCount++; } + analyticsData = { + uuid: user._id, + acquireMethod: 'Gems', + gemCost: 4, + category: 'behavior' + }; + if (analytics != null) { + analytics.track('release mounts', analyticsData); + } + return typeof cb === "function" ? cb(null, user) : void 0; }; diff --git a/common/script/ops/releasePets.js b/common/script/ops/releasePets.js index 9466e1ccda..a4b452cd86 100644 --- a/common/script/ops/releasePets.js +++ b/common/script/ops/releasePets.js @@ -1,41 +1,32 @@ import content from '../content/index'; import i18n from '../i18n'; -import { - NotAuthorized, -} from '../libs/errors'; -module.exports = function releasePets (user, req = {}, analytics) { +module.exports = function(user, req, cb, analytics) { + var analyticsData, pet; if (user.balance < 1) { - throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); - } - - user.balance -= 1; - user.items.currentPet = ''; - - for (let pet in content.pets) { - user.items.pets[pet] = 0; - } - - if (!user.achievements.beastMasterCount) { - user.achievements.beastMasterCount = 0; - } - user.achievements.beastMasterCount++; - - if (analytics) { - analytics.track('release pets', { - uuid: user._id, - acquireMethod: 'Gems', - gemCost: 4, - category: 'behavior', - }); - } - - if (req.v2 === true) { - return user; + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('notEnoughGems', req.language) + }) : void 0; } else { - return [ - user.items.pets, - i18n.t('petsReleased'), - ]; + user.balance -= 1; + for (pet in content.pets) { + user.items.pets[pet] = 0; + } + if (!user.achievements.beastMasterCount) { + user.achievements.beastMasterCount = 0; + } + user.achievements.beastMasterCount++; + user.items.currentPet = ""; } + analyticsData = { + uuid: user._id, + acquireMethod: 'Gems', + gemCost: 4, + category: 'behavior' + }; + if (analytics != null) { + analytics.track('release pets', analyticsData); + } + return typeof cb === "function" ? cb(null, user) : void 0; }; diff --git a/common/script/ops/reroll.js b/common/script/ops/reroll.js index 3087845509..f6a0862f1d 100644 --- a/common/script/ops/reroll.js +++ b/common/script/ops/reroll.js @@ -1,40 +1,29 @@ import i18n from '../i18n'; import _ from 'lodash'; -import { - NotAuthorized, -} from '../libs/errors'; -module.exports = function reroll (user, tasks = [], req = {}, analytics) { +module.exports = function(user, req, cb, analytics) { + var analyticsData; if (user.balance < 1) { - throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('notEnoughGems', req.language) + }) : void 0; } - user.balance--; - user.stats.hp = 50; - - _.each(tasks, function resetTaskValues (task) { - if (!task.challenge || !task.challenge.id || task.challenge.broken) { - if (task.type !== 'reward') { - task.value = 0; - } + _.each(user.tasks, function(task) { + if (task.type !== 'reward') { + return task.value = 0; } }); - - if (analytics) { - analytics.track('Fortify Potion', { - uuid: user._id, - acquireMethod: 'Gems', - gemCost: 4, - category: 'behavior', - }); - } - - if (req.v2 === true) { - return user; - } else { - return [ - {user, tasks}, - i18n.t('fortifyComplete'), - ]; + user.stats.hp = 50; + analyticsData = { + uuid: user._id, + acquireMethod: 'Gems', + gemCost: 4, + category: 'behavior' + }; + if (analytics != null) { + analytics.track('Fortify Potion', analyticsData); } + return typeof cb === "function" ? cb(null, user) : void 0; }; diff --git a/common/script/ops/reset.js b/common/script/ops/reset.js index 3e48fa4f2d..fbe87729e3 100644 --- a/common/script/ops/reset.js +++ b/common/script/ops/reset.js @@ -1,29 +1,35 @@ -import resetGear from '../fns/resetGear'; -import i18n from '../i18n'; +import _ from 'lodash'; -module.exports = function reset (user, tasks = [], req = {}) { +module.exports = function(user, req, cb) { + var gear; + user.habits = []; + user.dailys = []; + user.todos = []; + user.rewards = []; user.stats.hp = 50; user.stats.lvl = 1; user.stats.gp = 0; user.stats.exp = 0; - - let tasksToRemove = []; - tasks.forEach(task => { - if (!task.challenge || !task.challenge.id || task.challenge.broken) { - tasksToRemove.push(task._id); - let i = user.tasksOrder[`${task.type}s`].indexOf(task._id); - if (i !== -1) user.tasksOrder[`${task.type}s`].splice(i, 1); - } + gear = user.items.gear; + _.each(['equipped', 'costume'], function(type) { + gear[type].armor = 'armor_base_0'; + gear[type].weapon = 'weapon_base_0'; + gear[type].head = 'head_base_0'; + return gear[type].shield = 'shield_base_0'; }); - - resetGear(user); - - if (req.v2 === true) { - return user; - } else { - return [ - {user, tasksToRemove}, - i18n.t('resetComplete'), - ]; + if (typeof gear.owned === 'undefined') { + gear.owned = {}; } + _.each(gear.owned, function(v, k) { + if (gear.owned[k]) { + gear.owned[k] = false; + } + return true; + }); + gear.owned.weapon_warrior_0 = true; + if (typeof user.markModified === "function") { + user.markModified('items.gear.owned'); + } + user.preferences.costume = false; + return typeof cb === "function" ? cb(null, user) : void 0; }; diff --git a/common/script/ops/revive.js b/common/script/ops/revive.js index 30b29a78fc..7b6fe8445f 100644 --- a/common/script/ops/revive.js +++ b/common/script/ops/revive.js @@ -1,107 +1,72 @@ import content from '../content/index'; import i18n from '../i18n'; import _ from 'lodash'; -import { - NotAuthorized, -} from '../libs/errors'; -import randomVal from '../fns/randomVal'; -module.exports = function revive (user, req = {}, analytics) { - if (user.stats.hp > 0) { - throw new NotAuthorized(i18n.t('cannotRevive', req.language)); +module.exports = function(user, req, cb, analytics) { + var analyticsData, base, cl, gearOwned, item, losableItems, lostItem, lostStat; + if (!(user.stats.hp <= 0)) { + return typeof cb === "function" ? cb({ + code: 400, + message: "Cannot revive if not dead" + }) : void 0; } - _.merge(user.stats, { hp: 50, exp: 0, - gp: 0, + gp: 0 }); - if (user.stats.lvl > 1) { user.stats.lvl--; } - - let lostStat = randomVal(user, _.reduce(['str', 'con', 'per', 'int'], function findRandomStat (m, k) { + lostStat = user.fns.randomVal(_.reduce(['str', 'con', 'per', 'int'], (function(m, k) { if (user.stats[k]) { m[k] = k; } return m; - }, {})); - + }), {})); if (lostStat) { user.stats[lostStat]--; } - - let base = user.items.gear.owned; - let gearOwned; - - if (typeof base.toObject === 'function') { - gearOwned = base.toObject(); - } else { - gearOwned = user.items.gear.owned; - } - - let losableItems = {}; - let userClass = user.stats.class; - - _.each(gearOwned, function findLosableItems (value, key) { - let itm; - if (value) { - itm = content.gear.flat[key]; - + cl = user.stats["class"]; + gearOwned = (typeof (base = user.items.gear.owned).toObject === "function" ? base.toObject() : void 0) || user.items.gear.owned; + losableItems = {}; + _.each(gearOwned, function(v, k) { + var itm; + if (v) { + itm = content.gear.flat['' + k]; if (itm) { - let itemHasValueOrWarrior0 = itm.value > 0 || key === 'weapon_warrior_0'; - - let itemClassEqualsUserClass = itm.klass === userClass; - - let itemClassSpecial = itm.klass === 'special'; - let itemNotSpecialOrUserClassIsSpecial = !itm.specialClass || itm.specialClass === userClass; - let itemIsSpecial = itemNotSpecialOrUserClassIsSpecial && itemClassSpecial; - - let itemIsArmoire = itm.klass === 'armoire'; - - if (itemHasValueOrWarrior0 && (itemClassEqualsUserClass || itemIsSpecial || itemIsArmoire)) { - losableItems[key] = key; - return losableItems[key]; + if ((itm.value > 0 || k === 'weapon_warrior_0') && (itm.klass === cl || (itm.klass === 'special' && (!itm.specialClass || itm.specialClass === cl)) || itm.klass === 'armoire')) { + return losableItems['' + k] = '' + k; } } } }); - - let lostItem = randomVal(user, losableItems); - - let message = ''; - let item = content.gear.flat[lostItem]; - - if (item) { + lostItem = user.fns.randomVal(losableItems); + if (item = content.gear.flat[lostItem]) { user.items.gear.owned[lostItem] = false; - if (user.items.gear.equipped[item.type] === lostItem) { - user.items.gear.equipped[item.type] = `${item.type}_base_0`; + user.items.gear.equipped[item.type] = item.type + "_base_0"; } - if (user.items.gear.costume[item.type] === lostItem) { - user.items.gear.costume[item.type] = `${item.type}_base_0`; + user.items.gear.costume[item.type] = item.type + "_base_0"; } - - message = i18n.t('messageLostItem', { itemText: item.text(req.language)}, req.language); } - - if (analytics) { - analytics.track('Death', { - uuid: user._id, - lostItem, - gaLabel: lostItem, - category: 'behavior', - }); + if (typeof user.markModified === "function") { + user.markModified('items.gear'); } - - if (req.v2 === true) { - return user; - } else { - return [ - user.items, - message, - ]; + analyticsData = { + uuid: user._id, + lostItem: lostItem, + gaLabel: lostItem, + category: 'behavior' + }; + if (analytics != null) { + analytics.track('Death', analyticsData); } + return typeof cb === "function" ? cb((item ? { + code: 200, + message: i18n.t('messageLostItem', { + itemText: item.text(req.language) + }, req.language) + } : null), user) : void 0; }; diff --git a/common/script/ops/score.js b/common/script/ops/score.js new file mode 100644 index 0000000000..ec0d2a70e2 --- /dev/null +++ b/common/script/ops/score.js @@ -0,0 +1,221 @@ +import moment from 'moment'; +import _ from 'lodash'; +import i18n from '../i18n'; + +module.exports = function(user, req, cb) { + var addPoints, calculateDelta, calculateReverseDelta, changeTaskValue, delta, direction, gainMP, id, multiplier, num, options, ref, stats, subtractPoints, task, th; + ref = req.params, id = ref.id, direction = ref.direction; + task = user.tasks[id]; + options = req.query || {}; + _.defaults(options, { + times: 1, + cron: false + }); + user._tmp = {}; + stats = { + gp: +user.stats.gp, + hp: +user.stats.hp, + exp: +user.stats.exp + }; + task.value = +task.value; + task.streak = ~~task.streak; + if (task.priority == null) { + task.priority = 1; + } + if (task.value > stats.gp && task.type === 'reward') { + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('messageNotEnoughGold', req.language) + }) : void 0; + } + delta = 0; + calculateDelta = function() { + var currVal, nextDelta, ref1; + currVal = task.value < -47.27 ? -47.27 : task.value > 21.27 ? 21.27 : task.value; + nextDelta = Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); + if (((ref1 = task.checklist) != null ? ref1.length : void 0) > 0) { + if (direction === 'down' && task.type === 'daily' && options.cron) { + nextDelta *= 1 - _.reduce(task.checklist, (function(m, i) { + return m + (i.completed ? 1 : 0); + }), 0) / task.checklist.length; + } + if (task.type === 'todo') { + nextDelta *= 1 + _.reduce(task.checklist, (function(m, i) { + return m + (i.completed ? 1 : 0); + }), 0); + } + } + return nextDelta; + }; + calculateReverseDelta = function() { + var calc, closeEnough, currVal, diff, nextDelta, ref1, testVal; + currVal = task.value < -47.27 ? -47.27 : task.value > 21.27 ? 21.27 : task.value; + testVal = currVal + Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); + closeEnough = 0.00001; + while (true) { + calc = testVal + Math.pow(0.9747, testVal); + diff = currVal - calc; + if (Math.abs(diff) < closeEnough) { + break; + } + if (diff > 0) { + testVal -= diff; + } else { + testVal += diff; + } + } + nextDelta = testVal - currVal; + if (((ref1 = task.checklist) != null ? ref1.length : void 0) > 0) { + if (task.type === 'todo') { + nextDelta *= 1 + _.reduce(task.checklist, (function(m, i) { + return m + (i.completed ? 1 : 0); + }), 0); + } + } + return nextDelta; + }; + changeTaskValue = function() { + return _.times(options.times, function() { + var nextDelta, ref1; + nextDelta = !options.cron && direction === 'down' ? calculateReverseDelta() : calculateDelta(); + if (task.type !== 'reward') { + if (user.preferences.automaticAllocation === true && user.preferences.allocationMode === 'taskbased' && !(task.type === 'todo' && direction === 'down')) { + user.stats.training[task.attribute] += nextDelta; + } + if (direction === 'up') { + user.party.quest.progress.up = user.party.quest.progress.up || 0; + if ((ref1 = task.type) === 'daily' || ref1 === 'todo') { + user.party.quest.progress.up += nextDelta * (1 + (user._statsComputed.str / 200)); + } + if (task.type === 'habit') { + user.party.quest.progress.up += nextDelta * (0.5 + (user._statsComputed.str / 400)); + } + } + task.value += nextDelta; + } + return delta += nextDelta; + }); + }; + addPoints = function() { + var _crit, afterStreak, currStreak, gpMod, intBonus, perBonus, streakBonus; + _crit = (delta > 0 ? user.fns.crit() : 1); + if (_crit > 1) { + user._tmp.crit = _crit; + } + intBonus = 1 + (user._statsComputed.int * .025); + stats.exp += Math.round(delta * intBonus * task.priority * _crit * 6); + perBonus = 1 + user._statsComputed.per * .02; + gpMod = delta * task.priority * _crit * perBonus; + return stats.gp += task.streak ? (currStreak = direction === 'down' ? task.streak - 1 : task.streak, streakBonus = currStreak / 100 + 1, afterStreak = gpMod * streakBonus, currStreak > 0 ? gpMod > 0 ? user._tmp.streakBonus = afterStreak - gpMod : void 0 : void 0, afterStreak) : gpMod; + }; + subtractPoints = function() { + var conBonus, hpMod; + conBonus = 1 - (user._statsComputed.con / 250); + if (conBonus < .1) { + conBonus = 0.1; + } + hpMod = delta * conBonus * task.priority * 2; + return stats.hp += Math.round(hpMod * 10) / 10; + }; + gainMP = function(delta) { + delta *= user._tmp.crit || 1; + user.stats.mp += delta; + if (user.stats.mp >= user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + if (user.stats.mp < 0) { + return user.stats.mp = 0; + } + }; + switch (task.type) { + case 'habit': + changeTaskValue(); + if (delta > 0) { + addPoints(); + } else { + subtractPoints(); + } + gainMP(_.max([0.25, .0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + th = (task.history != null ? task.history : task.history = []); + if (th[th.length - 1] && moment(th[th.length - 1].date).isSame(new Date, 'day')) { + th[th.length - 1].value = task.value; + } else { + th.push({ + date: +(new Date), + value: task.value + }); + } + if (typeof user.markModified === "function") { + user.markModified("habits." + (_.findIndex(user.habits, { + id: task.id + })) + ".history"); + } + break; + case 'daily': + if (options.cron) { + changeTaskValue(); + subtractPoints(); + if (!user.stats.buffs.streaks) { + task.streak = 0; + } + } else { + changeTaskValue(); + if (direction === 'down') { + delta = calculateDelta(); + } + addPoints(); + gainMP(_.max([1, .01 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + if (direction === 'up') { + task.streak = task.streak ? task.streak + 1 : 1; + if ((task.streak % 21) === 0) { + user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1; + } + } else { + if ((task.streak % 21) === 0) { + user.achievements.streak = user.achievements.streak ? user.achievements.streak - 1 : 0; + } + task.streak = task.streak ? task.streak - 1 : 0; + } + } + break; + case 'todo': + if (options.cron) { + changeTaskValue(); + } else { + task.dateCompleted = direction === 'up' ? new Date : void 0; + changeTaskValue(); + if (direction === 'down') { + delta = calculateDelta(); + } + addPoints(); + multiplier = _.max([ + _.reduce(task.checklist, (function(m, i) { + return m + (i.completed ? 1 : 0); + }), 1), 1 + ]); + gainMP(_.max([multiplier, .01 * user._statsComputed.maxMP * multiplier]) * (direction === 'down' ? -1 : 1)); + } + break; + case 'reward': + changeTaskValue(); + stats.gp -= Math.abs(task.value); + num = parseFloat(task.value).toFixed(2); + if (stats.gp < 0) { + stats.hp += stats.gp; + stats.gp = 0; + } + } + user.fns.updateStats(stats, req); + if (typeof window === 'undefined') { + if (direction === 'up') { + user.fns.randomDrop({ + task: task, + delta: delta + }, req); + } + } + if (typeof cb === "function") { + cb(null, user); + } + return delta; +}; diff --git a/common/script/ops/scoreTask.js b/common/script/ops/scoreTask.js deleted file mode 100644 index c366b84624..0000000000 --- a/common/script/ops/scoreTask.js +++ /dev/null @@ -1,260 +0,0 @@ -import _ from 'lodash'; -import { - NotAuthorized, -} from '../libs/errors'; -import i18n from '../i18n'; -import updateStats from '../fns/updateStats'; -import crit from '../fns/crit'; - -const MAX_TASK_VALUE = 21.27; -const MIN_TASK_VALUE = -47.27; -const CLOSE_ENOUGH = 0.00001; - -function _getTaskValue (taskValue) { - if (taskValue < MIN_TASK_VALUE) { - return MIN_TASK_VALUE; - } else if (taskValue > MAX_TASK_VALUE) { - return MAX_TASK_VALUE; - } else { - return taskValue; - } -} - -// Calculates the next task.value based on direction -// Uses a capped inverse log y=.95^x, y>= -5 -function _calculateDelta (task, direction, cron) { - // Min/max on task redness - let currVal = _getTaskValue(task.value); - let nextDelta = Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); - - // Checklists - if (task.checklist && task.checklist.length > 0) { - // If the Daily, only dock them a portion based on their checklist completion - if (direction === 'down' && task.type === 'daily' && cron) { - nextDelta *= 1 - _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; - } - - // If To-Do, point-match the TD per checklist item completed - if (task.type === 'todo') { - nextDelta *= 1 + _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0); - } - } - - return nextDelta; -} - -// Approximates the reverse delta for the task value -// This is meant to return the task value to its original value when unchecking a task. -// First, calculate the value using the normal way for our first guess although -// it will be a bit off -function _calculateReverseDelta (task, direction) { - let currVal = _getTaskValue(task.value); - let testVal = currVal + Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); - - // Now keep moving closer to the original value until we get "close enough" - // Check how close we are to the original value by computing the delta off our guess - // and looking at the difference between that and our current value. - while (true) { // eslint-disable-line no-constant-condition - let calc = testVal + Math.pow(0.9747, testVal); - let diff = currVal - calc; - - if (Math.abs(diff) < CLOSE_ENOUGH) break; - - if (diff > 0) { - testVal -= diff; - } else { - testVal += diff; - } - } - - // When we get close enough, return the difference between our approximated value - // and the current value. This will be the delta calculated from the original value - // before the task was checked. - let nextDelta = testVal - currVal; - - // Checklists - If To-Do, point-match the TD per checklist item completed - if (task.checklist && task.checklist.length > 0 && task.type === 'todo') { - nextDelta *= 1 + _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0); - } - - return nextDelta; -} - -function _gainMP (user, val) { - val *= user._tmp.crit || 1; - user.stats.mp += val; - - if (user.stats.mp >= user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; - if (user.stats.mp < 0) { - user.stats.mp = 0; - } -} - -// HP modifier -// ===== CONSTITUTION ===== -// TODO Decreases HP loss from bad habits / missed dailies by 0.5% per point. -function _subtractPoints (user, task, stats, delta) { - let conBonus = 1 - user._statsComputed.con / 250; - if (conBonus < 0.1) conBonus = 0.1; - - let hpMod = delta * conBonus * task.priority * 2; // constant 2 multiplier for better results - stats.hp += Math.round(hpMod * 10) / 10; // round to 1dp - return stats.hp; -} - -function _addPoints (user, task, stats, direction, delta) { - // ===== CRITICAL HITS ===== - // allow critical hit only when checking off a task, not when unchecking it: - let _crit = delta > 0 ? crit(user) : 1; - // if there was a crit, alert the user via notification - if (_crit > 1) user._tmp.crit = _crit; - - // Exp Modifier - // ===== Intelligence ===== - // TODO Increases Experience gain by .2% per point. - let intBonus = 1 + user._statsComputed.int * 0.025; - stats.exp += Math.round(delta * intBonus * task.priority * _crit * 6); - - // GP modifier - // ===== PERCEPTION ===== - // TODO Increases Gold gained from tasks by .3% per point. - let perBonus = 1 + user._statsComputed.per * 0.02; - let gpMod = delta * task.priority * _crit * perBonus; - - if (task.streak) { - let currStreak = direction === 'down' ? task.streak - 1 : task.streak; - let streakBonus = currStreak / 100 + 1; // eg, 1-day streak is 1.01, 2-day is 1.02, etc - let afterStreak = gpMod * streakBonus; - if (currStreak > 0 && gpMod > 0) { - user._tmp.streakBonus = afterStreak - gpMod; // keep this on-hand for later, so we can notify streak-bonus - } - - stats.gp += afterStreak; - } else { - stats.gp += gpMod; - } -} - -function _changeTaskValue (user, task, direction, times, cron) { - let addToDelta = 0; - - // If multiple days have passed, multiply times days missed - _.times(times, () => { - // Each iteration calculate the nextDelta, which is then accumulated in the total delta. - let nextDelta = !cron && direction === 'down' ? _calculateReverseDelta(task, direction) : _calculateDelta(task, direction, cron); - - if (task.type !== 'reward') { - if (user.preferences.automaticAllocation === true && user.preferences.allocationMode === 'taskbased' && !(task.type === 'todo' && direction === 'down')) { - user.stats.training[task.attribute] += nextDelta; - } - - if (direction === 'up') { // Make progress on quest based on STR - user.party.quest.progress.up = user.party.quest.progress.up || 0; - - if (task.type === 'todo' || task.type === 'daily') { - user.party.quest.progress.up += nextDelta * (1 + user._statsComputed.str / 200); - } else if (task.type === 'habit') { - user.party.quest.progress.up += nextDelta * (0.5 + user._statsComputed.str / 400); - } - } - - task.value += nextDelta; - } - - addToDelta += nextDelta; - }); - - return addToDelta; -} - -module.exports = function scoreTask (options = {}, req = {}) { - let {user, task, direction, times = 1, cron = false} = options; - let delta = 0; - let stats = { - gp: user.stats.gp, - hp: user.stats.hp, - exp: user.stats.exp, - }; - - // This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying - // the API consumer, then cleared afterwards - user._tmp = {}; - - // If they're trying to purchase a too-expensive reward, don't allow them to do that. - if (task.value > user.stats.gp && task.type === 'reward') throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); - - if (task.type === 'habit') { - delta += _changeTaskValue(user, task, direction, times, cron); - - // Add habit value to habit-history (if different) - if (delta > 0) { - _addPoints(user, task, stats, direction, delta); - } else { - _subtractPoints(user, task, stats, delta); - } - _gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); - - task.history = task.history || []; - // Add history entry, even more than 1 per day - task.history.push({ - date: Number(new Date()), - value: task.value, - }); - } else if (task.type === 'daily') { - if (cron) { - delta += _changeTaskValue(user, task, direction, times, cron); - _subtractPoints(user, task, stats, delta); - if (!user.stats.buffs.streaks) task.streak = 0; - } else { - delta += _changeTaskValue(user, task, direction, times, cron); - if (direction === 'down') delta = _calculateDelta(task, direction, cron); // recalculate delta for unchecking so the gp and exp come out correctly - _addPoints(user, task, stats, direction, delta); // obviously for delta>0, but also a trick to undo accidental checkboxes - _gainMP(user, _.max([1, 0.01 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); - - if (direction === 'up') { - task.streak += 1; - // Give a streak achievement when the streak is a multiple of 21 - if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1; - task.completed = true; - } else if (direction === 'down') { - // Remove a streak achievement if streak was a multiple of 21 and the daily was undone - if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak - 1 : 0; - task.streak -= 1; - task.completed = false; - } - } - } else if (task.type === 'todo') { - if (cron) { // don't touch stats on cron - delta += _changeTaskValue(user, task, direction, times, cron); - } else { - if (direction === 'up') { - task.dateCompleted = new Date(); - task.completed = true; - } else if (direction === 'down') { - task.completed = false; - task.dateCompleted = undefined; - } - - delta += _changeTaskValue(user, task, direction, times, cron); - if (direction === 'down') delta = _calculateDelta(task, direction, cron); // recalculate delta for unchecking so the gp and exp come out correctly - _addPoints(user, task, stats, direction, delta); - - // MP++ per checklist item in ToDo, bonus per CLI - let multiplier = _.max([_.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 1), 1]); - _gainMP(user, _.max([multiplier, 0.01 * user._statsComputed.maxMP * multiplier]) * (direction === 'down' ? -1 : 1)); - } - } else if (task.type === 'reward') { - // Don't adjust values for rewards - delta += _changeTaskValue(user, task, direction, times, cron); - // purchase item - stats.gp -= Math.abs(task.value); - // hp - gp difference - if (stats.gp < 0) { - stats.hp += stats.gp; - stats.gp = 0; - } - } - - updateStats(user, stats, req); - return [delta]; -}; diff --git a/common/script/ops/sell.js b/common/script/ops/sell.js index 10412da222..33e5f7d673 100644 --- a/common/script/ops/sell.js +++ b/common/script/ops/sell.js @@ -1,43 +1,23 @@ import content from '../content/index'; -import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; -import { - NotFound, - NotAuthorized, - BadRequest, -} from '../libs/errors'; -const ACCEPTEDTYPES = ['eggs', 'hatchingPotions', 'food']; - -module.exports = function sell (user, req = {}) { - let key = _.get(req.params, 'key'); - let type = _.get(req.params, 'type'); - - if (!type) { - throw new BadRequest(i18n.t('typeRequired', req.language)); +module.exports = function(user, req, cb) { + var key, ref, type; + ref = req.params, key = ref.key, type = ref.type; + if (type !== 'eggs' && type !== 'hatchingPotions' && type !== 'food') { + return typeof cb === "function" ? cb({ + code: 404, + message: ":type not found. Must bes in [eggs, hatchingPotions, food]" + }) : void 0; } - - if (!key) { - throw new BadRequest(i18n.t('keyRequired', req.language)); - } - - if (ACCEPTEDTYPES.indexOf(type) === -1) { - throw new NotAuthorized(i18n.t('typeNotSellable', {acceptedTypes: ACCEPTEDTYPES.join(', ')}, req.language)); - } - if (!user.items[type][key]) { - throw new NotFound(i18n.t('userItemsKeyNotFound', {type}, req.language)); + return typeof cb === "function" ? cb({ + code: 404, + message: ":key not found for user.items." + type + }) : void 0; } - user.items[type][key]--; user.stats.gp += content[type][key].value; - - if (req.v2 === true) { - return _.pick(user, splitWhitespace('stats items')); - } else { - return [ - _.pick(user, splitWhitespace('stats items')), - ]; - } + return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('stats items'))) : void 0; }; diff --git a/common/script/ops/sleep.js b/common/script/ops/sleep.js index 1a531ecb88..dec8095ad3 100644 --- a/common/script/ops/sleep.js +++ b/common/script/ops/sleep.js @@ -1,9 +1,4 @@ -module.exports = function sleep (user, req = {}) { +module.exports = function(user, req, cb) { user.preferences.sleep = !user.preferences.sleep; - - if (req.v2 === true) { - return {}; - } else { - return [user.preferences.sleep]; - } + return typeof cb === "function" ? cb(null, {}) : void 0; }; diff --git a/common/script/ops/sortTag.js b/common/script/ops/sortTag.js index c1fc42f330..85dcda169f 100644 --- a/common/script/ops/sortTag.js +++ b/common/script/ops/sortTag.js @@ -1,19 +1,9 @@ -import { BadRequest } from '../libs/errors'; -import _ from 'lodash'; - -// TODO used only in client, move there? - -module.exports = function sortTag (user, req = {}) { - let to = _.get(req, 'query.to'); - let fromParam = _.get(req, 'query.from'); - - let invalidTo = !to && to !== 0; - let invalidFrom = !fromParam && fromParam !== 0; - - if (invalidTo || invalidFrom) { - throw new BadRequest('?to=__&from=__ are required'); +module.exports = function(user, req, cb) { + var from, ref, to; + ref = req.query, to = ref.to, from = ref.from; + if (!((to != null) && (from != null))) { + return typeof cb === "function" ? cb('?to=__&from=__ are required') : void 0; } - - user.tags.splice(to, 0, user.tags.splice(fromParam, 1)[0]); - return user.tags; + user.tags.splice(to, 0, user.tags.splice(from, 1)[0]); + return typeof cb === "function" ? cb(null, user.tags) : void 0; }; diff --git a/common/script/ops/sortTask.js b/common/script/ops/sortTask.js index ce002d8dc0..b903b692dc 100644 --- a/common/script/ops/sortTask.js +++ b/common/script/ops/sortTask.js @@ -1,49 +1,39 @@ import i18n from '../i18n'; import preenTodos from '../libs/preenTodos'; -import { - NotFound, - BadRequest, -} from '../libs/errors'; -import _ from 'lodash'; -// TODO used only in client, move there? - -module.exports = function sortTask (user, req = {}) { - let id = _.get(req, 'params.id'); - let to = _.get(req, 'query.to'); - let fromParam = _.get(req, 'query.from'); - let taskType = _.get(req, 'params.taskType'); - - let index = _.findIndex(user[`${taskType}s`], function findById (task) { - return task._id === id; - }); - - if (index === -1) { - throw new NotFound(i18n.t('messageTaskNotFound', req.language)); +module.exports = function(user, req, cb) { + var from, id, movedTask, preenedTasks, ref, task, tasks, to; + id = req.params.id; + ref = req.query, to = ref.to, from = ref.from; + task = user.tasks[id]; + if (!task) { + return typeof cb === "function" ? cb({ + code: 404, + message: i18n.t('messageTaskNotFound', req.language) + }) : void 0; } - if (!to && !fromParam) { - throw new BadRequest('?to=__&from=__ are required'); + if (!((to != null) && (from != null))) { + return typeof cb === "function" ? cb('?to=__&from=__ are required') : void 0; } - - let tasks = user[`${taskType}s`]; - - if (taskType === 'todo') { - let preenedTasks = preenTodos(tasks); - + tasks = user[task.type + "s"]; + if (task.type === 'todo' && tasks[from] !== task) { + preenedTasks = preenTodos(tasks); if (to !== -1) { to = tasks.indexOf(preenedTasks[to]); } - - fromParam = tasks.indexOf(preenedTasks[fromParam]); + from = tasks.indexOf(preenedTasks[from]); } - - let movedTask = tasks.splice(fromParam, 1)[0]; - + if (tasks[from] !== task) { + return typeof cb === "function" ? cb({ + code: 404, + message: i18n.t('messageTaskNotFound', req.language) + }) : void 0; + } + movedTask = tasks.splice(from, 1)[0]; if (to === -1) { tasks.push(movedTask); } else { tasks.splice(to, 0, movedTask); } - - return tasks; + return typeof cb === "function" ? cb(null, tasks) : void 0; }; diff --git a/common/script/ops/unlock.js b/common/script/ops/unlock.js index 5e0b118af0..c53a5997ec 100644 --- a/common/script/ops/unlock.js +++ b/common/script/ops/unlock.js @@ -1,113 +1,63 @@ import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; -import { - NotAuthorized, - BadRequest, -} from '../libs/errors'; -// If item is already purchased -> equip it -// Otherwise unlock it -module.exports = function unlock (user, req = {}, analytics) { - let path = _.get(req.query, 'path'); - - if (!path) { - throw new BadRequest(i18n.t('pathRequired', req.language)); +module.exports = function(user, req, cb, analytics) { + var alreadyOwns, analyticsData, cost, fullSet, k, path, split, v; + path = req.query.path; + fullSet = ~path.indexOf(","); + cost = ~path.indexOf('background.') ? fullSet ? 3.75 : 1.75 : fullSet ? 1.25 : 0.5; + alreadyOwns = !fullSet && user.fns.dotGet("purchased." + path) === true; + if ((user.balance < cost || !user.balance) && !alreadyOwns) { + return typeof cb === "function" ? cb({ + code: 401, + message: i18n.t('notEnoughGems', req.language) + }) : void 0; } + if (fullSet) { + _.each(path.split(","), function(p) { + if (~path.indexOf('gear.')) { + user.fns.dotSet("" + p, true); + true; + } else { - let isFullSet = path.indexOf(',') !== -1; - let isBackground = path.indexOf('background.') !== -1; - - let cost; - if (isBackground && isFullSet) { - cost = 3.75; - } else if (isBackground) { - cost = 1.75; - } else if (isFullSet) { - cost = 1.25; - } else { - cost = 0.5; - } - - let setPaths; - let alreadyOwns; - - if (isFullSet) { - setPaths = path.split(','); - let alreadyOwnedItems = 0; - - _.each(setPaths, singlePath => { - if (_.get(user, `purchased.${singlePath}`) === true) { - alreadyOwnedItems++; } - }); - - if (alreadyOwnedItems === setPaths.length) { - throw new NotAuthorized(i18n.t('alreadyUnlocked', req.language)); - // TODO write math formula to check if buying the full set is cheaper than the items individually - // (item cost * number of remaining items) < setCost` - } /* else if (alreadyOwnedItems > 0) { - throw new NotAuthorized(i18n.t('alreadyUnlockedPart', req.language)); - } */ - } else { - alreadyOwns = _.get(user, `purchased.${path}`) === true; - } - - if ((!user.balance || user.balance < cost) && !alreadyOwns) { - throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); - } - - if (isFullSet) { - _.each(setPaths, function markItemsAsPurchased (pathPart) { - if (path.indexOf('gear.') !== -1) { - _.set(user, pathPart, true); - } - - _.set(user, `purchased.${pathPart}`, true); + user.fns.dotSet("purchased." + p, true); + return true; }); } else { - if (alreadyOwns) { // eslint-disable-line no-lonely-if - let split = path.split('.'); - let value = split.pop(); - let key = split.join('.'); - if (key === 'background' && value === user.preferences.background) { - value = ''; + if (alreadyOwns) { + split = path.split('.'); + v = split.pop(); + k = split.join('.'); + if (k === 'background' && v === user.preferences.background) { + v = ''; } - - _.set(user, `preferences.${key}`, value); - } else { - _.set(user, `purchased.${path}`, true); + user.fns.dotSet("preferences." + k, v); + return typeof cb === "function" ? cb(null, req) : void 0; } + user.fns.dotSet("purchased." + path, true); } - - if (!alreadyOwns) { - if (path.indexOf('gear.') === -1) { + user.balance -= cost; + if (~path.indexOf('gear.')) { + if (typeof user.markModified === "function") { + user.markModified('gear.owned'); + } + } else { + if (typeof user.markModified === "function") { user.markModified('purchased'); } - - user.balance -= cost; - - if (analytics) { - analytics.track('acquire item', { - uuid: user._id, - itemKey: path, - itemType: 'customization', - acquireMethod: 'Gems', - gemCost: cost / 0.25, - category: 'behavior', - }); - } } - - let response = [ - _.pick(user, splitWhitespace('purchased preferences items')), - ]; - - if (!alreadyOwns) response.push(i18n.t('unlocked', req.language)); - - if (req.v2 === true) { - return response[0]; - } else { - return response; + analyticsData = { + uuid: user._id, + itemKey: path, + itemType: 'customization', + acquireMethod: 'Gems', + gemCost: cost / .25, + category: 'behavior' + }; + if (analytics != null) { + analytics.track('acquire item', analyticsData); } + return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('purchased preferences items'))) : void 0; }; diff --git a/common/script/ops/update.js b/common/script/ops/update.js index c41e74180e..12a100e372 100644 --- a/common/script/ops/update.js +++ b/common/script/ops/update.js @@ -1,11 +1,9 @@ import _ from 'lodash'; -// TODO used only in client, move there? - -module.exports = function updateUser (user, req = {}) { - _.each(req.body, (val, key) => { - _.set(user, key, val); +module.exports = function(user, req, cb) { + _.each(req.body, function(v, k) { + user.fns.dotSet(k, v); + return true; }); - - return user; + return typeof cb === "function" ? cb(null, user) : void 0; }; diff --git a/common/script/ops/updateTag.js b/common/script/ops/updateTag.js index ade87f916d..8e4019fa51 100644 --- a/common/script/ops/updateTag.js +++ b/common/script/ops/updateTag.js @@ -1,20 +1,18 @@ import i18n from '../i18n'; import _ from 'lodash'; -import { NotFound } from '../libs/errors'; -// TODO used only in client, move there? - -module.exports = function updateTag (user, req = {}) { - let tid = _.get(req, 'params.id'); - - let index = _.findIndex(user.tags, { - id: tid, +module.exports = function(user, req, cb) { + var i, tid; + tid = req.params.id; + i = _.findIndex(user.tags, { + id: tid }); - - if (index === -1) { - throw new NotFound(i18n.t('messageTagNotFound', req.language)); + if (!~i) { + return typeof cb === "function" ? cb({ + code: 404, + message: i18n.t('messageTagNotFound', req.language) + }) : void 0; } - - user.tags[index].name = _.get(req, 'body.name'); - return user.tags[index]; + user.tags[i].name = req.body.name; + return typeof cb === "function" ? cb(null, user.tags[i]) : void 0; }; diff --git a/common/script/ops/updateTask.js b/common/script/ops/updateTask.js index 2a2b1edba2..427104f15e 100644 --- a/common/script/ops/updateTask.js +++ b/common/script/ops/updateTask.js @@ -1,25 +1,23 @@ +import i18n from '../i18n'; import _ from 'lodash'; -// From server pass task.toObject() not the task document directly -module.exports = function updateTask (task, req = {}) { - let body = req.body || {}; - - // If reminders are updated -> replace the original ones - if (body.reminders) { - task.reminders = body.reminders; +module.exports = function(user, req, cb) { + var ref, task; + if (!(task = user.tasks[(ref = req.params) != null ? ref.id : void 0])) { + return typeof cb === "function" ? cb({ + code: 404, + message: i18n.t('messageTaskNotFound', req.language) + }) : void 0; } - - // If checklist is updated -> replace the original one - if (body.checklist) { - task.checklist = body.checklist; + _.merge(task, _.omit(req.body, ['checklist', 'reminders', 'id', 'type'])); + if (req.body.checklist) { + task.checklist = req.body.checklist; } - - // If tags are updated -> replace the original ones - if (body.tags) { - task.tags = body.tags; + if (req.body.reminders) { + task.reminders = req.body.reminders; } - - _.merge(task, _.omit(body, ['_id', 'id', 'type', 'reminders', 'checklist', 'tags'])); - - return [task]; + if (typeof task.markModified === "function") { + task.markModified('tags'); + } + return typeof cb === "function" ? cb(null, task) : void 0; }; diff --git a/common/script/ops/updateWebhook.js b/common/script/ops/updateWebhook.js index 63fed89b17..e2775a40b4 100644 --- a/common/script/ops/updateWebhook.js +++ b/common/script/ops/updateWebhook.js @@ -1,20 +1,9 @@ -import validator from 'validator'; -import i18n from '../i18n'; -import { - BadRequest, -} from '../libs/errors'; +import _ from 'lodash'; -module.exports = function updateWebhook (user, req) { - if (!validator.isURL(req.body.url)) throw new BadRequest(i18n.t('invalidUrl', req.language)); - if (!validator.isBoolean(req.body.enabled)) throw new BadRequest(i18n.t('invalidEnabled', req.language)); - - user.markModified('preferences.webhooks'); - user.preferences.webhooks[req.params.id].url = req.body.url; - user.preferences.webhooks[req.params.id].enabled = req.body.enabled; - - if (req.v2 === true) { - return user.preferences.webhooks; - } else { - return [user.preferences.webhooks[req.params.id]]; +module.exports = function(user, req, cb) { + _.merge(user.preferences.webhooks[req.params.id], req.body); + if (typeof user.markModified === "function") { + user.markModified('preferences.webhooks'); } + return typeof cb === "function" ? cb(null, user.preferences.webhooks) : void 0; }; diff --git a/common/script/public/config.js b/common/script/public/config.js index 4456909ef4..bb78e244bd 100644 --- a/common/script/public/config.js +++ b/common/script/public/config.js @@ -1,48 +1,8 @@ 'use strict'; - -angular.module('habitrpg') -.config(['$httpProvider', function($httpProvider){ +angular.module('habitrpg').config(['$httpProvider', function($httpProvider){ $httpProvider.interceptors.push(['$q', '$rootScope', function($q, $rootScope){ - var resyncNumber = 0; - var lastResync = 0; - - // Verify that the user was not updated from another browser/app/client - // If it was, sync - function verifyUserUpdated (response) { - var isApiCall = response.config.url.indexOf('api/v3') !== -1; - var isUserAvailable = $rootScope.User && $rootScope.User.user && $rootScope.User.user._wrapped === true; - var hasUserV = response.data && response.data.userV; - var isNotSync = response.config.url.indexOf('/api/v3/user') !== 0; - - if (isApiCall && isUserAvailable && hasUserV) { - var oldUserV = $rootScope.User.user._v; - $rootScope.User.user._v = response.data.userV; - - // Something has changed on the user object that was not tracked here, sync the user - if (isNotSync && ($rootScope.User.user._v - oldUserV) > 1) { - $rootScope.User.sync(); - } - } - } - return { - request: function (config) { - var url = config.url; - - if (url.indexOf('api/v3') !== -1) { - if ($rootScope.User && $rootScope.User.user) { - if (url.indexOf('?') !== -1) { - config.url += '&userV=' + $rootScope.User.user._v; - } else { - config.url += '?userV=' + $rootScope.User.user._v; - } - } - } - - return config; - }, response: function(response) { - verifyUserUpdated(response); return response; }, responseError: function(response) { @@ -61,43 +21,25 @@ angular.module('habitrpg') if (!mobileApp) // skip mobile for now $rootScope.$broadcast('responseError', "The site has been updated and the page needs to refresh. The last action has not been recorded, please refresh and try again."); - } else if (response.data && response.data.code && response.data.code === 'ACCOUNT_SUSPENDED') { + } else if (response.data.code && response.data.code === 'ACCOUNT_SUSPENDED') { confirm(response.data.err); localStorage.clear(); window.location.href = mobileApp ? '/app/login' : '/logout'; //location.reload() - // 400 range - } else if (response.status < 400) { - // never triggered because we're in responseError - $rootScope.$broadcast('responseText', response.data && response.data.message); + // 400 range? } else if (response.status < 500) { - if (response.status === 400 && response.data && response.data.errors && _.isArray(response.data.errors)) { // bad requests with more info - response.data.errors.forEach(function (err) { - $rootScope.$broadcast('responseError', err.message); - }); - } else { - $rootScope.$broadcast('responseError', response.data && response.data.message); - } - - if ($rootScope.User && $rootScope.User.sync) { - if (resyncNumber < 100 && (Date.now() - lastResync) > 500) { // avoid thousands of requests when user is not found - $rootScope.User.sync(); - resyncNumber++; - lastResync = Date.now(); - } - } - + $rootScope.$broadcast('responseText', response.data.err || response.data); // Need to reject the prompse so the error is handled correctly - if (response.status === 401) { + if (response.status === 401) return $q.reject(response); - } + // Error } else { - var error = window.env.t('requestError') + '

"' + - window.env.t('error') + ' ' + (response.data.message || response.data.error || response.data || 'something went wrong') + + var error = window.env.t('requestError') + '

"' + + window.env.t('error') + ' ' + (response.data.err || response.data || 'something went wrong') + '"

' + window.env.t('seeConsole'); if (mobileApp) error = 'Error contacting the server. Please try again in a few minutes.'; - $rootScope.$broadcast('responseError500', error); + $rootScope.$broadcast('responseError', error); console.error(response); } @@ -105,4 +47,4 @@ angular.module('habitrpg') } }; }]); -}]); +}]); \ No newline at end of file diff --git a/common/script/public/userServices.js b/common/script/public/userServices.js new file mode 100644 index 0000000000..e5b1790086 --- /dev/null +++ b/common/script/public/userServices.js @@ -0,0 +1,268 @@ +'use strict'; + +angular.module('habitrpg') + .service('ApiUrl', ['API_URL', function(currentApiUrl){ + this.setApiUrl = function(newUrl){ + currentApiUrl = newUrl; + }; + + this.get = function(){ + return currentApiUrl; + }; + }]) + +/** + * Services that persists and retrieves user from localStorage. + */ + .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'MOBILE_APP', 'Notification', 'ApiUrl', + function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, MOBILE_APP, Notification, ApiUrl) { + var authenticated = false; + var defaultSettings = { + auth: { apiId: '', apiToken: ''}, + sync: { + queue: [], //here OT will be queued up, this is NOT call-back queue! + sent: [] //here will be OT which have been sent, but we have not got reply from server yet. + }, + fetching: false, // whether fetch() was called or no. this is to avoid race conditions + online: false + }; + var settings = {}; //habit mobile settings (like auth etc.) to be stored here + var user = {}; // this is stored as a reference accessible to all controllers, that way updates propagate + + var userNotifications = { + // "party.order" : env.t("updatedParty"), + // "party.orderAscending" : env.t("updatedParty") + // party.order notifications are not currently needed because the party avatars are resorted immediately now + }; // this is a list of notifications to send to the user when changes are made, along with the message. + + //first we populate user with schema + user.apiToken = user._id = ''; // we use id / apitoken to determine if registered + + //than we try to load localStorage + if (localStorage.getItem(STORAGE_USER_ID)) { + _.extend(user, JSON.parse(localStorage.getItem(STORAGE_USER_ID))); + } + user._wrapped = false; + + var syncQueue = function (cb) { + if (!authenticated) { + $window.alert("Not authenticated, can't sync, go to settings first."); + return; + } + + var queue = settings.sync.queue; + var sent = settings.sync.sent; + if (queue.length === 0) { + // Sync: Queue is empty + return; + } + if (settings.fetching) { + // Sync: Already fetching + return; + } + if (settings.online!==true) { + // Sync: Not online + return; + } + + settings.fetching = true; + // move all actions from queue array to sent array + _.times(queue.length, function () { + sent.push(queue.shift()); + }); + + // Save the current filters + var current_filters = user.filters; + + $http.post(ApiUrl.get() + '/api/v2/user/batch-update', sent, {params: {data:+new Date, _v:user._v, siteVersion: $window.env && $window.env.siteVersion}}) + .success(function (data, status, heacreatingders, config) { + //make sure there are no pending actions to sync. If there are any it is not safe to apply model from server as we may overwrite user data. + if (!queue.length) { + //we can't do user=data as it will not update user references in all other angular controllers. + + // the user has been modified from another application, sync up + if(data && data.wasModified) { + delete data.wasModified; + $rootScope.$emit('userUpdated', user); + } + + // Update user + _.extend(user, data); + // Preserve filter selections between syncs + _.extend(user.filters,current_filters); + if (!user._wrapped){ + + // This wraps user with `ops`, which are functions shared both on client and mobile. When performed on client, + // they update the user in the browser and then send the request to the server, where the same operation is + // replicated. We need to wrap each op to provide a callback to send that operation + $window.habitrpgShared.wrap(user); + _.each(user.ops, function(op,k){ + user.ops[k] = function(req,cb){ + if (cb) return op(req,cb); + op(req,function(err,response) { + for(var updatedItem in req.body) { + var itemUpdateResponse = userNotifications[updatedItem]; + if(itemUpdateResponse) Notification.text(itemUpdateResponse); + } + if (err) { + var message = err.code ? err.message : err; + if (MOBILE_APP) Notification.push({type:'text',text:message}); + else Notification.text(message); + // In the case of 200s, they're friendly alert messages like "Your pet has hatched!" - still send the op + if ((err.code && err.code >= 400) || !err.code) return; + } + userServices.log({op:k, params: req.params, query:req.query, body:req.body}); + }); + } + }); + } + + // Emit event when user is synced + $rootScope.$emit('userSynced'); + } + sent.length = 0; + settings.fetching = false; + save(); + if (cb) { + cb(false) + } + + syncQueue(); // call syncQueue to check if anyone pushed more actions to the queue while we were talking to server. + }) + .error(function (data, status, headers, config) { + // (Notifications handled in app.js) + + // If we're offline, queue up offline actions so we can send when we're back online + if (status === 0) { + //move sent actions back to queue + _.times(sent.length, function () { + queue.push(sent.shift()) + }); + settings.fetching = false; + // In the case of errors, discard the corrupt queue + } else { + // Clear the queue. Better if we can hunt down the problem op, but this is the easiest solution + settings.sync.queue = settings.sync.sent = []; + save(); + } + }); + } + + + var save = function () { + localStorage.setItem(STORAGE_USER_ID, JSON.stringify(user)); + localStorage.setItem(STORAGE_SETTINGS_ID, JSON.stringify(settings)); + }; + var userServices = { + user: user, + set: function(updates) { + user.ops.update({body:updates}); + }, + + online: function (status) { + if (status===true) { + settings.online = true; + syncQueue(); + } else { + settings.online = false; + }; + }, + + authenticate: function (uuid, token, cb) { + if (!!uuid && !!token) { + var offset = moment().zone(); // eg, 240 - this will be converted on server as -(offset/60) + $http.defaults.headers.common['x-api-user'] = uuid; + $http.defaults.headers.common['x-api-key'] = token; + $http.defaults.headers.common['x-user-timezoneOffset'] = offset; + authenticated = true; + settings.auth.apiId = uuid; + settings.auth.apiToken = token; + settings.online = true; + if (user && user._v) user._v--; // shortcut to always fetch new updates on page reload + userServices.log({}, function(){ + // If they don't have timezone, set it + if (user.preferences.timezoneOffset !== offset) + userServices.set({'preferences.timezoneOffset': offset}); + cb && cb(); + }); + } else { + alert('Please enter your ID and Token in settings.') + } + }, + + authenticated: function(){ + return this.settings.auth.apiId !== ""; + }, + + getBalanceInGems: function() { + var balance = user.balance || 0; + return balance * 4; + }, + + log: function (action, cb) { + //push by one buy one if an array passed in. + if (_.isArray(action)) { + action.forEach(function (a) { + settings.sync.queue.push(a); + }); + } else { + settings.sync.queue.push(action); + } + + save(); + syncQueue(cb); + }, + + sync: function(){ + user._v--; + userServices.log({}); + }, + + save: save, + + settings: settings + }; + + + //load settings if we have them + if (localStorage.getItem(STORAGE_SETTINGS_ID)) { + //use extend here to make sure we keep object reference in other angular controllers + _.extend(settings, JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID))); + + //if settings were saved while fetch was in process reset the flag. + settings.fetching = false; + //create and load if not + } else { + localStorage.setItem(STORAGE_SETTINGS_ID, JSON.stringify(defaultSettings)); + _.extend(settings, defaultSettings); + } + + //If user does not have ApiID that forward him to settings. + if (!settings.auth.apiId || !settings.auth.apiToken) { + + if (MOBILE_APP) { + $location.path("/login"); + } else { + //var search = $location.search(); // FIXME this should be working, but it's returning an empty object when at a root url /?_id=... + var search = $location.search($window.location.search.substring(1)).$$search; // so we use this fugly hack instead + if (search.err) return alert(search.err); + if (search._id && search.apiToken) { + userServices.authenticate(search._id, search.apiToken, function(){ + $window.location.href='/'; + }); + } else { + var isStaticOrSocial = $window.location.pathname.match(/^\/(static|social)/); + if (!isStaticOrSocial){ + localStorage.clear(); + $window.location.href = '/logout'; + } + } + } + + } else { + userServices.authenticate(settings.auth.apiId, settings.auth.apiToken) + } + + return userServices; + } +]); diff --git a/config.json.example b/config.json.example index d70dcac18c..6aeb8ac74a 100644 --- a/config.json.example +++ b/config.json.example @@ -1,6 +1,5 @@ { "PORT":3000, - "ENABLE_CONSOLE_LOGS_IN_PROD":"false", "IP":"0.0.0.0", "CORES":1, "BASE_URL":"http://localhost:3000", @@ -9,8 +8,6 @@ "NODE_DB_URI":"mongodb://localhost/habitrpg", "TEST_DB_URI":"mongodb://localhost/habitrpg_test", "NODE_ENV":"development", - "CRON_SAFE_MODE":"false", - "MAINTENANCE_MODE": "false", "SESSION_SECRET":"YOUR SECRET HERE", "ADMIN_EMAIL": "you@example.com", "SMTP_USER":"user@example.com", @@ -22,7 +19,6 @@ "STRIPE_API_KEY":"aaaabbbbccccddddeeeeffff00001111", "STRIPE_PUB_KEY":"22223333444455556666777788889999", "NEW_RELIC_LICENSE_KEY":"NEW_RELIC_LICENSE_KEY", - "NEW_RELIC_NO_CONFIG_FILE":"true", "NEW_RELIC_APPLICATION_ID":"NEW_RELIC_APPLICATION_ID", "NEW_RELIC_API_KEY":"NEW_RELIC_API_KEY", "GA_ID": "GA_ID", @@ -37,7 +33,7 @@ "EMAIL_SERVER": { "url": "http://example.com", "authUser": "user", - "authPassword": "password" + "authPassword": "password" }, "S3":{ "bucket":"bucket", @@ -64,7 +60,7 @@ "subdomain": "subdomain", "token": "token", "username": "username", - "password": "password" + "password": "password" }, "PUSH_CONFIGS": { "GCM_SERVER_API_KEY": "", diff --git a/gulpfile.js b/gulpfile.js index d7cd0d79c2..0c399ce7fa 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -9,7 +9,6 @@ require('babel-register'); if (process.env.NODE_ENV === 'production') { - require('./tasks/gulp-apidoc'); require('./tasks/gulp-newstuff'); require('./tasks/gulp-build'); require('./tasks/gulp-babelify'); diff --git a/karma.conf.js b/karma.conf.js index d0ceffd5c6..2ae3a52a9d 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -11,40 +11,41 @@ module.exports = function karmaConfig (config) { // list of files / patterns to load in the browser files: [ - 'website/client/bower_components/jquery/dist/jquery.js', - 'website/client/bower_components/pnotify/jquery.pnotify.js', - 'website/client/bower_components/angular/angular.js', - 'website/client/bower_components/angular-loading-bar/build/loading-bar.min.js', - 'website/client/bower_components/angular-resource/angular-resource.min.js', - 'website/client/bower_components/hello/dist/hello.all.min.js', - 'website/client/bower_components/angular-sanitize/angular-sanitize.js', - 'website/client/bower_components/bootstrap/dist/js/bootstrap.js', - 'website/client/bower_components/angular-bootstrap/ui-bootstrap.js', - 'website/client/bower_components/angular-bootstrap/ui-bootstrap-tpls.js', - 'website/client/bower_components/angular-ui-router/release/angular-ui-router.js', - 'website/client/bower_components/angular-filter/dist/angular-filter.js', - 'website/client/bower_components/angular-ui/build/angular-ui.js', - 'website/client/bower_components/angular-ui-utils/ui-utils.min.js', - 'website/client/bower_components/Angular-At-Directive/src/at.js', - 'website/client/bower_components/Angular-At-Directive/src/caret.js', - 'website/client/bower_components/angular-mocks/angular-mocks.js', - 'website/client/bower_components/ngInfiniteScroll/build/ng-infinite-scroll.js', - 'website/client/bower_components/select2/select2.js', - 'website/client/bower_components/angular-ui-select2/src/select2.js', - 'website/client/bower_components/habitica-markdown/dist/habitica-markdown.min.js', + 'website/public/bower_components/jquery/dist/jquery.js', + 'website/public/bower_components/pnotify/jquery.pnotify.js', + 'website/public/bower_components/angular/angular.js', + 'website/public/bower_components/angular-loading-bar/build/loading-bar.min.js', + 'website/public/bower_components/angular-resource/angular-resource.min.js', + 'website/public/bower_components/hello/dist/hello.all.min.js', + 'website/public/bower_components/angular-sanitize/angular-sanitize.js', + 'website/public/bower_components/bootstrap/dist/js/bootstrap.js', + 'website/public/bower_components/angular-bootstrap/ui-bootstrap.js', + 'website/public/bower_components/angular-bootstrap/ui-bootstrap-tpls.js', + 'website/public/bower_components/angular-ui-router/release/angular-ui-router.js', + 'website/public/bower_components/angular-filter/dist/angular-filter.js', + 'website/public/bower_components/angular-ui/build/angular-ui.js', + 'website/public/bower_components/angular-ui-utils/ui-utils.min.js', + 'website/public/bower_components/Angular-At-Directive/src/at.js', + 'website/public/bower_components/Angular-At-Directive/src/caret.js', + 'website/public/bower_components/angular-mocks/angular-mocks.js', + 'website/public/bower_components/ngInfiniteScroll/build/ng-infinite-scroll.js', + 'website/public/bower_components/select2/select2.js', + 'website/public/bower_components/angular-ui-select2/src/select2.js', + 'website/public/bower_components/habitica-markdown/dist/habitica-markdown.min.js', 'common/dist/scripts/habitrpg-shared.js', 'test/spec/mocks/**/*.js', - 'website/client/js/env.js', - 'website/client/js/app.js', + 'website/public/js/env.js', + 'website/public/js/app.js', 'common/script/public/config.js', + 'common/script/public/userServices.js', 'common/script/public/directives.js', - 'website/client/js/services/**/*.js', - 'website/client/js/filters/**/*.js', - 'website/client/js/directives/**/*.js', - 'website/client/js/controllers/**/*.js', + 'website/public/js/services/**/*.js', + 'website/public/js/filters/**/*.js', + 'website/public/js/directives/**/*.js', + 'website/public/js/controllers/**/*.js', 'test/spec/specHelper.js', 'test/spec/**/*.js', @@ -76,7 +77,7 @@ module.exports = function karmaConfig (config) { browsers: ['PhantomJS'], preprocessors: { - 'website/client/js/**/*.js': ['coverage'], + 'website/public/js/**/*.js': ['coverage'], 'test/**/*.js': ['babel'], }, diff --git a/migrations/20160521_veteran_ladder.js b/migrations/20160521_veteran_ladder.js deleted file mode 100644 index cf92ff5375..0000000000 --- a/migrations/20160521_veteran_ladder.js +++ /dev/null @@ -1,76 +0,0 @@ -var migrationName = '20160521_veteran_ladder.js'; -var authorName = 'Sabe'; // in case script author needs to know when their ... -var authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; //... own data is done - -/* - * Award Gilded Turkey pet to Turkey mount owners, Turkey Mount if they only have Turkey Pet, - * and Turkey Pet otherwise - */ - -var dbserver = 'localhost:27017'; // FOR TEST DATABASE -// var dbserver = 'username:password@ds031379-a0.mongolab.com:31379'; // FOR PRODUCTION DATABASE -var dbname = 'habitrpg'; - -var mongo = require('mongoskin'); -var _ = require('lodash'); - -var dbUsers = mongo.db(dbserver + '/' + dbname + '?auto_reconnect').collection('users'); - -// specify a query to limit the affected users (empty for all users): -var query = { - 'auth.timestamps.loggedin':{$gt:new Date('2016-05-01')} // remove when running migration a second time -}; - -// specify fields we are interested in to limit retrieved data (empty if we're not reading data): -var fields = { - 'migration': 1, - 'items.pets.Wolf-Veteran': 1, - 'items.pets.Tiger-Veteran': 1 -}; - -console.warn('Updating users...'); -var progressCount = 1000; -var count = 0; -dbUsers.findEach(query, fields, {batchSize:250}, function(err, user) { - if (err) { return exiting(1, 'ERROR! ' + err); } - if (!user) { - console.warn('All appropriate users found and modified.'); - return displayData(); - } - count++; - - // specify user data to change: - var set = {}; - if (user.migration !== migrationName) { - if (user.items.pets['Tiger-Veteran']) { - set = {'migration':migrationName, 'items.pets.Lion-Veteran':5}; - } else if (user.items.pets['Wolf-Veteran']) { - set = {'migration':migrationName, 'items.pets.Tiger-Veteran':5}; - } else { - set = {'migration':migrationName, 'items.pets.Wolf-Veteran':5}; - } - } - - dbUsers.update({_id:user._id}, {$set:set}); - - if (count%progressCount == 0) console.warn(count + ' ' + user._id); - if (user._id == authorUuid) console.warn(authorName + ' processed'); -}); - - -function displayData() { - console.warn('\n' + count + ' users processed\n'); - return exiting(0); -} - - -function exiting(code, msg) { - code = code || 0; // 0 = success - if (code && !msg) { msg = 'ERROR!'; } - if (msg) { - if (code) { console.error(msg); } - else { console.log( msg); } - } - process.exit(code); -} - diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js deleted file mode 100644 index 009c205e33..0000000000 --- a/migrations/api_v3/challenges.js +++ /dev/null @@ -1,212 +0,0 @@ -// Migrate challenges collection to new schema (except for members) - -// The console-stamp module must be installed (not included in package.json) - -// It requires two environment variables: MONGODB_OLD and MONGODB_NEW - -// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). -// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM -console.log('Starting migrations/api_v3/challenges.js.'); - -require('babel-register'); -require('babel-polyfill'); - -var Bluebird = require('bluebird'); -var MongoDB = require('mongodb'); -var nconf = require('nconf'); -var mongoose = require('mongoose'); -var _ = require('lodash'); -var uuid = require('uuid'); -var consoleStamp = require('console-stamp'); -var fs = require('fs'); - -// Add timestamps to console messages -consoleStamp(console); - -// Initialize configuration -require('../../website/server/libs/api-v3/setupNconf')(); - -var MONGODB_OLD = nconf.get('MONGODB_OLD'); -var MONGODB_NEW = nconf.get('MONGODB_NEW'); - -var MongoClient = MongoDB.MongoClient; - -mongoose.Promise = Bluebird; // otherwise mongoose models won't work - -// Load new models -var NewChallenge = require('../../website/server/models/challenge').model; -var Tasks = require('../../website/server/models/task'); - -// To be defined later when MongoClient connects -var mongoDbOldInstance; -var oldChallengeCollection; - -var mongoDbNewInstance; -var newChallengeCollection; -var newTaskCollection; - -var BATCH_SIZE = 1000; - -var processedChallenges = 0; -var totoalProcessedTasks = 0; - -var newTasksIds = {}; // a map of old id -> [new id, challengeId] - -// Only process challenges that fall in a interval ie -> up to 0000-4000-0000-0000 -var AFTER_CHALLENGE_ID = nconf.get('AFTER_CHALLENGE_ID'); -var BEFORE_CHALLENGE_ID = nconf.get('BEFORE_CHALLENGE_ID'); - -function processChallenges (afterId) { - var processedTasks = 0; - var lastChallenge = null; - var oldChallenges; - - var query = {}; - - if (BEFORE_CHALLENGE_ID) { - query._id = {$lte: BEFORE_CHALLENGE_ID}; - } - - if ((afterId || AFTER_CHALLENGE_ID) && !query._id) { - query._id = {}; - } - - if (afterId) { - query._id.$gt = afterId; - } else if (AFTER_CHALLENGE_ID) { - query._id.$gt = AFTER_CHALLENGE_ID; - } - - var batchInsertTasks = newTaskCollection.initializeUnorderedBulkOp(); - var batchInsertChallenges = newChallengeCollection.initializeUnorderedBulkOp(); - - console.log(`Executing challenges query.\nMatching challenges after ${afterId ? afterId : AFTER_CHALLENGE_ID} and before ${BEFORE_CHALLENGE_ID} (included).`); - - return oldChallengeCollection - .find(query) - .sort({_id: 1}) - .limit(BATCH_SIZE) - .toArray() - .then(function (oldChallengesR) { - oldChallenges = oldChallengesR; - - console.log(`Processing ${oldChallenges.length} challenges. Already processed ${processedChallenges} challenges and ${totoalProcessedTasks} tasks.`); - - if (oldChallenges.length === BATCH_SIZE) { - lastChallenge = oldChallenges[oldChallenges.length - 1]._id; - } - - oldChallenges.forEach(function (oldChallenge) { - var oldTasks = oldChallenge.habits.concat(oldChallenge.dailys).concat(oldChallenge.rewards).concat(oldChallenge.todos); - delete oldChallenge.habits; - delete oldChallenge.dailys; - delete oldChallenge.rewards; - delete oldChallenge.todos; - - var createdAt = oldChallenge.timestamp; - - oldChallenge.memberCount = oldChallenge.members.length; - if (oldChallenge.prize <= 0) oldChallenge.prize = 0; - if (!oldChallenge.name) oldChallenge.name = 'challenge name'; - if (!oldChallenge.shortName) oldChallenge.name = 'challenge-name'; - - if (!oldChallenge.group) throw new Error('challenge.group is required'); - if (!oldChallenge.leader) throw new Error('challenge.leader is required'); - - - if (oldChallenge.leader === '9') { - oldChallenge.leader = '00000000-0000-4000-9000-000000000000'; - } - - if (oldChallenge.group === 'habitrpg') { - oldChallenge.group = '00000000-0000-4000-A000-000000000000'; - } - - delete oldChallenge.id; - - var newChallenge = new NewChallenge(oldChallenge); - - newChallenge.createdAt = createdAt; - - oldTasks.forEach(function (oldTask) { - oldTask._id = uuid.v4(); - oldTask._legacyId = oldTask.id; // store the old task id - delete oldTask.id; - - oldTask.challenge = oldTask.challenge || {}; - oldTask.challenge.id = newChallenge._id; - - if (newTasksIds[oldTask._legacyId + '-' + newChallenge._id]) { - throw new Error('duplicate :('); - } else { - newTasksIds[oldTask._legacyId + '-' + newChallenge._id] = oldTask._id; - } - - oldTask.tags = _.map(oldTask.tags || {}, function (tagPresent, tagId) { - return tagPresent && tagId; - }).filter(function (tag) { - return tag !== false; - }); - - if (!oldTask.text) oldTask.text = 'task text'; // required - - oldTask.createdAt = oldTask.dateCreated; - - newChallenge.tasksOrder[`${oldTask.type}s`].push(oldTask._id); - if (oldTask.completed) oldTask.completed = false; - - var newTask = new Tasks[oldTask.type](oldTask); - - batchInsertTasks.insert(newTask.toObject()); - processedTasks++; - }); - - batchInsertChallenges.insert(newChallenge.toObject()); - }); - - console.log(`Saving ${oldChallenges.length} challenges and ${processedTasks} tasks.`); - - return Bluebird.all([ - batchInsertChallenges.execute(), - batchInsertTasks.execute(), - ]); - }) - .then(function () { - totoalProcessedTasks += processedTasks; - processedChallenges += oldChallenges.length; - - console.log(`Saved ${oldChallenges.length} challenges and their tasks.`); - - if (lastChallenge) { - return processChallenges(lastChallenge); - } else { - console.log('Writing newTasksIds.json...') - fs.writeFileSync('newTasksIds.json', JSON.stringify(newTasksIds, null, 4), 'utf8'); - return console.log('Done!'); - } - }); -} - -// Connect to the databases -Bluebird.all([ - MongoClient.connect(MONGODB_OLD), - MongoClient.connect(MONGODB_NEW), -]) -.then(function (result) { - var oldInstance = result[0]; - var newInstance = result[1]; - - mongoDbOldInstance = oldInstance; - oldChallengeCollection = mongoDbOldInstance.collection('challenges'); - - mongoDbNewInstance = newInstance; - newChallengeCollection = mongoDbNewInstance.collection('challenges'); - newTaskCollection = mongoDbNewInstance.collection('tasks'); - - console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); - - return processChallenges(); -}) -.catch(function (err) { - console.error(err.stack || err); -}); diff --git a/migrations/api_v3/challengesMembers.js b/migrations/api_v3/challengesMembers.js deleted file mode 100644 index 971119831a..0000000000 --- a/migrations/api_v3/challengesMembers.js +++ /dev/null @@ -1,143 +0,0 @@ -// Migrate challenges members -// Run AFTER users migration - -// The console-stamp module must be installed (not included in package.json) - -// It requires two environment variables: MONGODB_OLD and MONGODB_NEW - -// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). -// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM -console.log('Starting migrations/api_v3/challengesMembers.js.'); - -require('babel-register'); -require('babel-polyfill'); - -var Bluebird = require('bluebird'); -var MongoDB = require('mongodb'); -var nconf = require('nconf'); -var mongoose = require('mongoose'); -var _ = require('lodash'); -var uuid = require('uuid'); -var consoleStamp = require('console-stamp'); - -// Add timestamps to console messages -consoleStamp(console); - -// Initialize configuration -require('../../website/server/libs/api-v3/setupNconf')(); - -var MONGODB_OLD = nconf.get('MONGODB_OLD'); -var MONGODB_NEW = nconf.get('MONGODB_NEW'); - -var MongoClient = MongoDB.MongoClient; - -mongoose.Promise = Bluebird; // otherwise mongoose models won't work - -// To be defined later when MongoClient connects -var mongoDbOldInstance; -var oldChallengeCollection; - -var mongoDbNewInstance; -var newUserCollection; - -var BATCH_SIZE = 1000; - -var processedChallenges = 0; - -// Only process challenges that fall in a interval ie -> up to 0000-4000-0000-0000 -var AFTER_CHALLENGE_ID = nconf.get('AFTER_CHALLENGE_ID'); -var BEFORE_CHALLENGE_ID = nconf.get('BEFORE_CHALLENGE_ID'); - -function processChallenges (afterId) { - var processedTasks = 0; - var lastChallenge = null; - var oldChallenges; - - var query = {}; - - if (BEFORE_CHALLENGE_ID) { - query._id = {$lte: BEFORE_CHALLENGE_ID}; - } - - if ((afterId || AFTER_CHALLENGE_ID) && !query._id) { - query._id = {}; - } - - if (afterId) { - query._id.$gt = afterId; - } else if (AFTER_CHALLENGE_ID) { - query._id.$gt = AFTER_CHALLENGE_ID; - } - - console.log(`Executing challenges query.\nMatching challenges after ${afterId ? afterId : AFTER_CHALLENGE_ID} and before ${BEFORE_CHALLENGE_ID} (included).`); - - return oldChallengeCollection - .find(query) - .sort({_id: 1}) - .limit(BATCH_SIZE) - .toArray() - .then(function (oldChallengesR) { - oldChallenges = oldChallengesR; - - var promises = []; - - console.log(`Processing ${oldChallenges.length} challenges. Already processed ${processedChallenges} challenges.`); - - if (oldChallenges.length === BATCH_SIZE) { - lastChallenge = oldChallenges[oldChallenges.length - 1]._id; - } - - oldChallenges.forEach(function (oldChallenge) { - // Tyler Renelle - oldChallenge.members.forEach(function (id, index) { - if (id === '9') { - oldChallenge.members[index] = '00000000-0000-4000-9000-000000000000'; - } - }); - - promises.push(newUserCollection.updateMany({ - _id: {$in: oldChallenge.members || []}, - }, { - $push: {challenges: oldChallenge._id}, - }, {multi: true})); - }); - - console.log(`Migrating members of ${oldChallenges.length} challenges.`); - - return Bluebird.all(promises); - }) - .then(function () { - processedChallenges += oldChallenges.length; - - console.log(`Migrated members of ${oldChallenges.length} challenges.`); - - if (lastChallenge) { - return processChallenges(lastChallenge); - } else { - return console.log('Done!'); - } - }); -} - -// Connect to the databases -Bluebird.all([ - MongoClient.connect(MONGODB_OLD), - MongoClient.connect(MONGODB_NEW), -]) -.then(function (result) { - var oldInstance = result[0]; - var newInstance = result[1]; - - mongoDbOldInstance = oldInstance; - oldChallengeCollection = mongoDbOldInstance.collection('challenges'); - - mongoDbNewInstance = newInstance; - newUserCollection = mongoDbNewInstance.collection('users'); - - console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); - - return processChallenges(); -}) -.catch(function (err) { - console.error(err.stack || err); -}); diff --git a/migrations/api_v3/coupons.js b/migrations/api_v3/coupons.js deleted file mode 100644 index 64071faffe..0000000000 --- a/migrations/api_v3/coupons.js +++ /dev/null @@ -1,136 +0,0 @@ -// Migrate coupons collection to new schema - -// The console-stamp module must be installed (not included in package.json) - -// It requires two environment variables: MONGODB_OLD and MONGODB_NEW - -// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). -// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM -console.log('Starting migrations/api_v3/coupons.js.'); - -require('babel-register'); -require('babel-polyfill'); - -var Bluebird = require('bluebird'); -var MongoDB = require('mongodb'); -var nconf = require('nconf'); -var mongoose = require('mongoose'); -var _ = require('lodash'); -var uuid = require('uuid'); -var consoleStamp = require('console-stamp'); - -// Add timestamps to console messages -consoleStamp(console); - -// Initialize configuration -require('../../website/server/libs/api-v3/setupNconf')(); - -var MONGODB_OLD = nconf.get('MONGODB_OLD'); -var MONGODB_NEW = nconf.get('MONGODB_NEW'); - -var MongoClient = MongoDB.MongoClient; - -mongoose.Promise = Bluebird; // otherwise mongoose models won't work - -// Load new models -var Coupon = require('../../website/server/models/coupon').model; - -// To be defined later when MongoClient connects -var mongoDbOldInstance; -var oldCouponCollection; - -var mongoDbNewInstance; -var newCouponCollection; - -var BATCH_SIZE = 1000; - -var processedCoupons = 0; - -// Only process coupons that fall in a interval ie -> up to 0000-4000-0000-0000 -var AFTER_COUPON_ID = nconf.get('AFTER_COUPON_ID'); -var BEFORE_COUPON_ID = nconf.get('BEFORE_COUPON_ID'); - -function processCoupons (afterId) { - var processedTasks = 0; - var lastCoupon = null; - var oldCoupons; - - var query = {}; - - if (BEFORE_COUPON_ID) { - query._id = {$lte: BEFORE_COUPON_ID}; - } - - if ((afterId || AFTER_COUPON_ID) && !query._id) { - query._id = {}; - } - - if (afterId) { - query._id.$gt = afterId; - } else if (AFTER_COUPON_ID) { - query._id.$gt = AFTER_COUPON_ID; - } - - var batchInsertCoupons = newCouponCollection.initializeUnorderedBulkOp(); - - console.log(`Executing coupons query.\nMatching coupons after ${afterId ? afterId : AFTER_COUPON_ID} and before ${BEFORE_COUPON_ID} (included).`); - - return oldCouponCollection - .find(query) - .sort({_id: 1}) - .limit(BATCH_SIZE) - .toArray() - .then(function (oldCouponsR) { - oldCoupons = oldCouponsR; - - console.log(`Processing ${oldCoupons.length} coupons. Already processed ${processedCoupons} coupons.`); - - if (oldCoupons.length === BATCH_SIZE) { - lastCoupon = oldCoupons[oldCoupons.length - 1]._id; - } - - oldCoupons.forEach(function (oldCoupon) { - var newCoupon = new Coupon(oldCoupon); - - batchInsertCoupons.insert(newCoupon.toObject()); - }); - - console.log(`Saving ${oldCoupons.length} coupons.`); - - return batchInsertCoupons.execute(); - }) - .then(function () { - processedCoupons += oldCoupons.length; - - console.log(`Saved ${oldCoupons.length} coupons.`); - - if (lastCoupon) { - return processCoupons(lastCoupon); - } else { - return console.log('Done!'); - } - }); -} - -// Connect to the databases -Bluebird.all([ - MongoClient.connect(MONGODB_OLD), - MongoClient.connect(MONGODB_NEW), -]) -.then(function (result) { - var oldInstance = result[0]; - var newInstance = result[1]; - - mongoDbOldInstance = oldInstance; - oldCouponCollection = mongoDbOldInstance.collection('coupons'); - - mongoDbNewInstance = newInstance; - newCouponCollection = mongoDbNewInstance.collection('coupons'); - - console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); - - return processCoupons(); -}) -.catch(function (err) { - console.error(err.stack || err); -}); diff --git a/migrations/api_v3/emailUnsubscriptions.js b/migrations/api_v3/emailUnsubscriptions.js deleted file mode 100644 index d90525db16..0000000000 --- a/migrations/api_v3/emailUnsubscriptions.js +++ /dev/null @@ -1,137 +0,0 @@ -// Migrate unsubscriptions collection to new schema - -// The console-stamp module must be installed (not included in package.json) - -// It requires two environment variables: MONGODB_OLD and MONGODB_NEW - -// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). -// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM -console.log('Starting migrations/api_v3/unsubscriptions.js.'); - -require('babel-register'); -require('babel-polyfill'); - -var Bluebird = require('bluebird'); -var MongoDB = require('mongodb'); -var nconf = require('nconf'); -var mongoose = require('mongoose'); -var _ = require('lodash'); -var uuid = require('uuid'); -var consoleStamp = require('console-stamp'); - -// Add timestamps to console messages -consoleStamp(console); - -// Initialize configuration -require('../../website/server/libs/api-v3/setupNconf')(); - -var MONGODB_OLD = nconf.get('MONGODB_OLD'); -var MONGODB_NEW = nconf.get('MONGODB_NEW'); - -var MongoClient = MongoDB.MongoClient; - -mongoose.Promise = Bluebird; // otherwise mongoose models won't work - -// Load new models -var EmailUnsubscription = require('../../website/server/models/emailUnsubscription').model; - -// To be defined later when MongoClient connects -var mongoDbOldInstance; -var oldUnsubscriptionCollection; - -var mongoDbNewInstance; -var newUnsubscriptionCollection; - -var BATCH_SIZE = 1000; - -var processedUnsubscriptions = 0; - -// Only process unsubscriptions that fall in a interval ie -> up to 0000-4000-0000-0000 -var AFTER_UNSUBSCRIPTION_ID = nconf.get('AFTER_UNSUBSCRIPTION_ID'); -var BEFORE_UNSUBSCRIPTION_ID = nconf.get('BEFORE_UNSUBSCRIPTION_ID'); - -function processUnsubscriptions (afterId) { - var processedTasks = 0; - var lastUnsubscription = null; - var oldUnsubscriptions; - - var query = {}; - - if (BEFORE_UNSUBSCRIPTION_ID) { - query._id = {$lte: BEFORE_UNSUBSCRIPTION_ID}; - } - - if ((afterId || AFTER_UNSUBSCRIPTION_ID) && !query._id) { - query._id = {}; - } - - if (afterId) { - query._id.$gt = afterId; - } else if (AFTER_UNSUBSCRIPTION_ID) { - query._id.$gt = AFTER_UNSUBSCRIPTION_ID; - } - - var batchInsertUnsubscriptions = newUnsubscriptionCollection.initializeUnorderedBulkOp(); - - console.log(`Executing unsubscriptions query.\nMatching unsubscriptions after ${afterId ? afterId : AFTER_UNSUBSCRIPTION_ID} and before ${BEFORE_UNSUBSCRIPTION_ID} (included).`); - - return oldUnsubscriptionCollection - .find(query) - .sort({_id: 1}) - .limit(BATCH_SIZE) - .toArray() - .then(function (oldUnsubscriptionsR) { - oldUnsubscriptions = oldUnsubscriptionsR; - - console.log(`Processing ${oldUnsubscriptions.length} unsubscriptions. Already processed ${processedUnsubscriptions} unsubscriptions.`); - - if (oldUnsubscriptions.length === BATCH_SIZE) { - lastUnsubscription = oldUnsubscriptions[oldUnsubscriptions.length - 1]._id; - } - - oldUnsubscriptions.forEach(function (oldUnsubscription) { - oldUnsubscription.email = oldUnsubscription.email.toLowerCase(); - var newUnsubscription = new EmailUnsubscription(oldUnsubscription); - - batchInsertUnsubscriptions.insert(newUnsubscription.toObject()); - }); - - console.log(`Saving ${oldUnsubscriptions.length} unsubscriptions.`); - - return batchInsertUnsubscriptions.execute(); - }) - .then(function () { - processedUnsubscriptions += oldUnsubscriptions.length; - - console.log(`Saved ${oldUnsubscriptions.length} unsubscriptions.`); - - if (lastUnsubscription) { - return processUnsubscriptions(lastUnsubscription); - } else { - return console.log('Done!'); - } - }); -} - -// Connect to the databases -Bluebird.all([ - MongoClient.connect(MONGODB_OLD), - MongoClient.connect(MONGODB_NEW), -]) -.then(function (result) { - var oldInstance = result[0]; - var newInstance = result[1]; - - mongoDbOldInstance = oldInstance; - oldUnsubscriptionCollection = mongoDbOldInstance.collection('emailunsubscriptions'); - - mongoDbNewInstance = newInstance; - newUnsubscriptionCollection = mongoDbNewInstance.collection('emailunsubscriptions'); - - console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); - - return processUnsubscriptions(); -}) -.catch(function (err) { - console.error(err.stack || err); -}); diff --git a/migrations/api_v3/groups.js b/migrations/api_v3/groups.js deleted file mode 100644 index dfd0616e07..0000000000 --- a/migrations/api_v3/groups.js +++ /dev/null @@ -1,211 +0,0 @@ -/* - members are not stored anymore - invites are not stored anymore - - tavern id and leader must be updated -*/ - -// Migrate groups collection to new schema -// Run AFTER users migration - -// The console-stamp module must be installed (not included in package.json) - -// It requires two environment variables: MONGODB_OLD and MONGODB_NEW - -// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). -// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM -console.log('Starting migrations/api_v3/groups.js.'); - -require('babel-register'); -require('babel-polyfill'); - -var Bluebird = require('bluebird'); -var MongoDB = require('mongodb'); -var nconf = require('nconf'); -var mongoose = require('mongoose'); -var _ = require('lodash'); -var uuid = require('uuid'); -var consoleStamp = require('console-stamp'); - -// Add timestamps to console messages -consoleStamp(console); - -// Initialize configuration -require('../../website/server/libs/api-v3/setupNconf')(); - -var MONGODB_OLD = nconf.get('MONGODB_OLD'); -var MONGODB_NEW = nconf.get('MONGODB_NEW'); - -var MongoClient = MongoDB.MongoClient; - -mongoose.Promise = Bluebird; // otherwise mongoose models won't work - -// Load new models -var NewGroup = require('../../website/server/models/group').model; - -var TAVERN_ID = require('../../website/server/models/group').TAVERN_ID; - -// To be defined later when MongoClient connects -var mongoDbOldInstance; -var oldGroupCollection; - -var mongoDbNewInstance; -var newGroupCollection; -var newUserCollection; - -var BATCH_SIZE = 1000; - -var processedGroups = 0; - -// Only process groups that fall in a interval ie -> up to 0000-4000-0000-0000 -var AFTER_GROUP_ID = nconf.get('AFTER_GROUP_ID'); -var BEFORE_GROUP_ID = nconf.get('BEFORE_GROUP_ID'); - -function processGroups (afterId) { - var processedTasks = 0; - var lastGroup = null; - var oldGroups; - - var query = {}; - - if (BEFORE_GROUP_ID) { - query._id = {$lte: BEFORE_GROUP_ID}; - } - - if ((afterId || AFTER_GROUP_ID) && !query._id) { - query._id = {}; - } - - if (afterId) { - query._id.$gt = afterId; - } else if (AFTER_GROUP_ID) { - query._id.$gt = AFTER_GROUP_ID; - } - - var batchInsertGroups = newGroupCollection.initializeUnorderedBulkOp(); - - console.log(`Executing groups query.\nMatching groups after ${afterId ? afterId : AFTER_GROUP_ID} and before ${BEFORE_GROUP_ID} (included).`); - - return oldGroupCollection - .find(query) - .sort({_id: 1}) - .limit(BATCH_SIZE) - .toArray() - .then(function (oldGroupsR) { - oldGroups = oldGroupsR; - - var promises = []; - - console.log(`Processing ${oldGroups.length} groups. Already processed ${processedGroups} groups.`); - - if (oldGroups.length === BATCH_SIZE) { - lastGroup = oldGroups[oldGroups.length - 1]._id; - } - - oldGroups.forEach(function (oldGroup) { - if ((!oldGroup.privacy || oldGroup.privacy === 'private') && (!oldGroup.members || oldGroup.members.length === 0)) return; // delete empty private groups TODO must also delete challenges or this won't work - - oldGroup.members = oldGroup.members || []; - oldGroup.memberCount = oldGroup.members ? oldGroup.members.length : 0; - oldGroup.challengeCount = oldGroup.challenges ? oldGroup.challenges.length : 0; - - if (!oldGroup.balance <= 0) oldGroup.balance = 0; - if (!oldGroup.name) oldGroup.name = 'group name'; - if (!oldGroup.leaderOnly) oldGroup.leaderOnly = {}; - if (!oldGroup.leaderOnly.challenges) oldGroup.leaderOnly.challenges = false; - - // Tavern - if (oldGroup._id === 'habitrpg') { - oldGroup._id = TAVERN_ID; - oldGroup.leader = '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0'; // Siena Leslie - } - - if (!oldGroup.type) { - // throw new Error('group.type is required'); - oldGroup.type = 'guild'; - } - - if (!oldGroup.leader) { - if (oldGroup.members && oldGroup.members.length > 0) { - oldGroup.leader = oldGroup.members[0]; - } else { - throw new Error('group.leader is required and no member available!'); - } - } - - if (!oldGroup.privacy) { - // throw new Error('group.privacy is required'); - oldGroup.privacy = 'private'; - } - - var updateMembers = {}; - - if (oldGroup.type === 'guild') { - updateMembers.$push = {guilds: oldGroup._id}; - } else if (oldGroup.type === 'party') { - updateMembers.$set = {'party._id': oldGroup._id}; - } - - if (oldGroup.members) { - // Tyler Renelle - oldGroup.members.forEach(function (id, index) { - if (id === '9') { - oldGroup.members[index] = '00000000-0000-4000-9000-000000000000'; - } - }); - - promises.push(newUserCollection.updateMany({ - _id: {$in: oldGroup.members}, - }, updateMembers, {multi: true})); - } - - var newGroup = new NewGroup(oldGroup); - - batchInsertGroups.insert(newGroup.toObject()); - }); - - console.log(`Saving ${oldGroups.length} groups and migrating members to users collection.`); - - promises.push(batchInsertGroups.execute()); - return Bluebird.all(promises); - }) - .then(function () { - processedGroups += oldGroups.length; - - console.log(`Saved ${oldGroups.length} groups and migrated their members to the user collection.`); - - if (lastGroup) { - return processGroups(lastGroup); - } else { - return console.log('Done!'); - } - }); -} - -// Connect to the databases -Bluebird.all([ - MongoClient.connect(MONGODB_OLD), - MongoClient.connect(MONGODB_NEW), -]) -.then(function (result) { - var oldInstance = result[0]; - var newInstance = result[1]; - - mongoDbOldInstance = oldInstance; - oldGroupCollection = mongoDbOldInstance.collection('groups'); - - mongoDbNewInstance = newInstance; - newGroupCollection = mongoDbNewInstance.collection('groups'); - newUserCollection = mongoDbNewInstance.collection('users'); - - console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); - - // First delete the tavern group created by having required the group model - return newGroupCollection.deleteOne({_id: TAVERN_ID}); -}) -.then(function () { - return processGroups(); -}) -.catch(function (err) { - console.error(err.stack || err); -}); diff --git a/migrations/api_v3/indexes.js b/migrations/api_v3/indexes.js deleted file mode 100644 index 07aaa21db8..0000000000 --- a/migrations/api_v3/indexes.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - DEFINE BEFORE MIGRATING - - tasks: userId OK (sparse?), challenge.id OK (sparse?), challenge.taskId OK (sparse?), type? completed? - users: - id & apiToken, OK - auth.facebook.emails.value OK -> unique and sparse?, - auth.facebook.id - unique and sparse, OK - auth.local.email - unique and sparse, OK - auth.local.lowerCaseUsername, OK - auth.local.username - unique OK - auth.local.username & auth.local.hashed_password?, - auth.timestamps.created?, OK - auth.timestamps.loggedin?, OK - backer.tier -1 OK - { "contributor.admin" : 1 , "contributor.level" : -1 , "backer.npc" : -1 , "profile.name" : 1} - { "contributor.admin" : 1.0} NO, see ^ - { "contributor.level" : 1.0} OK - { "contributor.level" : 1.0 , "purchased.plan.customerId" : 1.0} ? - NO { "flags.lastWeeklyRecap" : 1 , "_id" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "preferences.emailNotifications.weeklyRecaps" : 1} - { "invitations.guilds.id" : 1} OK - { "invitations.party.id" : 1} OK - OK { "preferences.sleep" : 1 , "_id" : 1 , "flags.lastWeeklyRecap" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "preferences.emailNotifications.weeklyRecaps" : 1} - OK { "preferences.sleep" : 1 , "_id" : 1 , "lastCron" : 1 , "preferences.emailNotifications.importantAnnouncements" : 1 , "preferences.emailNotifications.unsubscribeFromAll" : 1 , "flags.recaptureEmailsPhase" : 1} - profile.name ? OK - { "purchased.plan.customerId" : 1.0} OK - { "purchased.plan.paymentMethod" : 1.0} OK - - guilds OK - party.id OK - challenges OK - challenges: - { "_id" : 1.0 , "__v" : 1.0} ? NO - { "_id" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} - { "group" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} OK - { "leader" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} OK - { "members" : 1.0 , "official" : -1.0 , "timestamp" : -1.0} ? NO - { "official" : -1 , "timestamp" : -1} ? - { "official" : -1 , "timestamp" : -1, "_id": 1} ? - groups: - { "_id" : 1 , "quest.key" : 1} ? - { "_id" : 1.0 , "__v" : 1.0} ? - { "_id" : 1.0 , "privacy" : 1.0 , "members" : 1.0} ? NO - { "members" : 1.0 , "type" : 1.0 , "memberCount" : -1.0} ? NO - { "members" : 1} ? NO - { "privacy" : 1.0 , "memberCount" : -1.0} ? - { "privacy" : 1.0} OK - { "type" : 1 , "privacy" : 1} ? - { "type" : 1.0 , "members" : 1.0} ? NO - { "type" : 1} ? OK - emailUnsubscriptions: email unique OK -*/ diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js deleted file mode 100644 index 656b8f498a..0000000000 --- a/migrations/api_v3/users.js +++ /dev/null @@ -1,262 +0,0 @@ -// Migrate users collection to new schema -// This should run AFTER challenges migration - -// The console-stamp module must be installed (not included in package.json) - -// It requires two environment variables: MONGODB_OLD and MONGODB_NEW - -// Due to some big user profiles it needs more RAM than is allowed by default by v8 (arounf 1.7GB). -// Run the script with --max-old-space-size=4096 to allow up to 4GB of RAM -console.log('Starting migrations/api_v3/users.js.'); - -require('babel-register'); -require('babel-polyfill'); - -var Bluebird = require('bluebird'); -var MongoDB = require('mongodb'); -var nconf = require('nconf'); -var mongoose = require('mongoose'); -var _ = require('lodash'); -var uuid = require('uuid'); -var consoleStamp = require('console-stamp'); -var common = require('../../common'); -var moment = require('moment'); - -// Add timestamps to console messages -consoleStamp(console); - -// Initialize configuration -require('../../website/server/libs/api-v3/setupNconf')(); - -var MONGODB_OLD = nconf.get('MONGODB_OLD'); -var MONGODB_NEW = nconf.get('MONGODB_NEW'); - -var taskDefaults = common.taskDefaults; -var MongoClient = MongoDB.MongoClient; - -mongoose.Promise = Bluebird; // otherwise mongoose models won't work - -// Load new models -var NewUser = require('../../website/server/models/user').model; -var NewTasks = require('../../website/server/models/task'); - -// To be defined later when MongoClient connects -var mongoDbOldInstance; -var oldUserCollection; - -var mongoDbNewInstance; -var newUserCollection; -var newTaskCollection; - -var BATCH_SIZE = 1000; - -var processedUsers = 0; -var totoalProcessedTasks = 0; - -var challengeTaskWithMatchingId = 0; -var challengeTaskNoMatchingId = 0; - -// Load the new tasks ids for challenges tasks -var newTasksIds = require('./newTasksIds.json'); - -// Only process users that fall in a interval ie up to -> 0000-4000-0000-0000 -var AFTER_USER_ID = nconf.get('AFTER_USER_ID'); -var BEFORE_USER_ID = nconf.get('BEFORE_USER_ID'); - -function processUsers (afterId) { - var processedTasks = 0; - var lastUser = null; - var oldUsers; - - var now = new Date(); - - var query = {}; - - if (BEFORE_USER_ID) { - query._id = {$lte: BEFORE_USER_ID}; - } - - if ((afterId || AFTER_USER_ID) && !query._id) { - query._id = {}; - } - - if (afterId) { - query._id.$gt = afterId; - } else if (AFTER_USER_ID) { - query._id.$gt = AFTER_USER_ID; - } - - var batchInsertTasks = newTaskCollection.initializeUnorderedBulkOp(); - var batchInsertUsers = newUserCollection.initializeUnorderedBulkOp(); - - console.log(`Executing users query.\nMatching users after ${afterId ? afterId : AFTER_USER_ID} and before ${BEFORE_USER_ID} (included).`); - - return oldUserCollection - .find(query) - .sort({_id: 1}) - .limit(BATCH_SIZE) - .toArray() - .then(function (oldUsersR) { - oldUsers = oldUsersR; - - console.log(`Processing ${oldUsers.length} users. Already processed ${processedUsers} users and ${totoalProcessedTasks} tasks.`); - - if (oldUsers.length === BATCH_SIZE) { - lastUser = oldUsers[oldUsers.length - 1]._id; - } - - oldUsers.forEach(function (oldUser) { - var oldTasks = oldUser.habits.concat(oldUser.dailys).concat(oldUser.rewards).concat(oldUser.todos); - delete oldUser.habits; - delete oldUser.dailys; - delete oldUser.rewards; - delete oldUser.todos; - - delete oldUser.id; - - // spookDust -> spookySparkles - - if (oldUser.achievements && oldUser.achievements.spookDust) { - oldUser.achievements.spookySparkles = oldUser.achievements.spookDust; - delete oldUser.achievements.spookDust; - } - - if (oldUser.items && oldUser.items.special && oldUser.items.special.spookDust) { - oldUser.items.special.spookySparkles = oldUser.items.special.spookDust; - delete oldUser.items.special.spookDust; - } - - if (oldUser.stats && oldUser.stats.buffs && oldUser.stats.buffs.spookySparkles) { - oldUser.stats.buffs.spookySparkles = oldUser.stats.buffs.spookDust; - delete oldUser.stats.buffs.spookDust; - } - - // end spookDust -> spookySparkles - - oldUser.tags = oldUser.tags.map(function (tag) { - return { - id: tag.id, - name: tag.name || 'tag name', - challenge: tag.challenge, - }; - }); - - if (oldUser._id === '9') { // Tyler Renelle - oldUser._id = '00000000-0000-4000-9000-000000000000'; - } - - var newUser = new NewUser(oldUser); - var isSubscribed = newUser.isSubscribed(); - - oldTasks.forEach(function (oldTask) { - oldTask._id = uuid.v4(); // create a new unique uuid - oldTask.userId = newUser._id; - oldTask._legacyId = oldTask.id; // store the old task id - delete oldTask.id; - - oldTask.challenge = oldTask.challenge || {}; - if (oldTask.challenge.id) { - if (oldTask.challenge.broken) { - oldTask.challenge.taskId = oldTask._legacyId; - } else { - var newId = newTasksIds[oldTask._legacyId + '-' + oldTask.challenge.id]; - - // Challenges' tasks ids changed - if (!newId && !oldTask.challenge.broken) { - challengeTaskNoMatchingId++; - oldTask.challenge.taskId = oldTask._legacyId; - oldTask.challenge.broken = 'CHALLENGE_TASK_NOT_FOUND'; - } else { - challengeTaskWithMatchingId++; - oldTask.challenge.taskId = newId; - } - } - } - - // Delete old completed todos - if (oldTask.type === 'todo' && oldTask.completed && (!oldTask.challenge.id || oldTask.challenge.broken)) { - if (moment(now).subtract(isSubscribed ? 90 : 30, 'days').toDate() > moment(oldTask.dateCompleted).toDate()) { - return; - } - } - - oldTask.createdAt = oldTask.dateCreated; - - if (!oldTask.text) oldTask.text = 'task text'; // required - oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) { - return tagPresent && tagId; - }).filter(function (tag) { - return tag !== false; - }); - - if (oldTask.type !== 'todo' || (oldTask.type === 'todo' && !oldTask.completed)) { - newUser.tasksOrder[`${oldTask.type}s`].push(oldTask._id); - } - - var allTasksFields = ['_id', 'type', 'text', 'notes', 'tags', 'value', 'priority', 'attribute', 'challenge', 'reminders', 'userId', '_legacyId', 'createdAt']; - // using mongoose models is too slow - if (oldTask.type === 'habit') { - oldTask = _.pick(oldTask, allTasksFields.concat(['history', 'up', 'down'])); - } else if (oldTask.type === 'daily') { - oldTask = _.pick(oldTask, allTasksFields.concat(['completed', 'collapseChecklist', 'checklist', 'history', 'frequency', 'everyX', 'startDate', 'repeat', 'streak'])); - } else if (oldTask.type === 'todo') { - oldTask = _.pick(oldTask, allTasksFields.concat(['completed', 'collapseChecklist', 'checklist', 'date', 'dateCompleted'])); - } else if (oldTask.type === 'reward') { - oldTask = _.pick(oldTask, allTasksFields); - } else { - throw new Error('Task with no or invalid type!'); - } - - batchInsertTasks.insert(taskDefaults(oldTask)); - processedTasks++; - }); - - batchInsertUsers.insert(newUser.toObject()); - }); - - console.log(`Saving ${oldUsers.length} users and ${processedTasks} tasks.`); - - return Bluebird.all([ - batchInsertUsers.execute(), - batchInsertTasks.execute(), - ]); - }) - .then(function () { - totoalProcessedTasks += processedTasks; - processedUsers += oldUsers.length; - - console.log(`Saved ${oldUsers.length} users and their tasks.`); - console.log('Challenges\' tasks no matching id: ', challengeTaskNoMatchingId); - console.log('Challenges\' tasks with matching id: ', challengeTaskWithMatchingId); - - if (lastUser) { - return processUsers(lastUser); - } else { - return console.log('Done!'); - } - }); -} - -// Connect to the databases -Bluebird.all([ - MongoClient.connect(MONGODB_OLD), - MongoClient.connect(MONGODB_NEW), -]) -.then(function (result) { - var oldInstance = result[0]; - var newInstance = result[1]; - - mongoDbOldInstance = oldInstance; - oldUserCollection = mongoDbOldInstance.collection('users'); - - mongoDbNewInstance = newInstance; - newUserCollection = mongoDbNewInstance.collection('users'); - newTaskCollection = mongoDbNewInstance.collection('tasks'); - - console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); - - return processUsers(); -}) -.catch(function (err) { - console.error(err.stack || err); -}); diff --git a/migrations/manual_password_reset.js b/migrations/manual_password_reset.js index 622e16913b..68b69cbbbe 100644 --- a/migrations/manual_password_reset.js +++ b/migrations/manual_password_reset.js @@ -7,7 +7,7 @@ nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../config.jso var Users = require('mongoskin').db(nconf.get("PRODUCTION_DB:URL"), nconf.get("PRODUCTION_DB").CREDS).collection('users'), async = require('async'), - utils = require('../website/server/utils'), + utils = require('../website/src/utils'), salt = utils.makeSalt(), newPassword = utils.makeSalt(), // use a salt as the new password too (they'll change it later) hashed_password = utils.encryptPassword(newPassword, salt); diff --git a/newrelic.js b/newrelic.js new file mode 100644 index 0000000000..0e8a550af7 --- /dev/null +++ b/newrelic.js @@ -0,0 +1,27 @@ +var nconf = require('nconf'); + +/** + * New Relic agent configuration. + * + * See lib/config.defaults.js in the agent distribution for a more complete + * description of configuration variables and their potential values. + */ +exports.config = { + /** + * Array of application names. + */ + app_name: nconf.get('NEW_RELIC_APP_NAME'), + /** + * Your New Relic license key. + */ + license_key: nconf.get('NEW_RELIC_LICENSE_KEY'), + ssl: false, + logging: { + /** + * Level at which to log. 'trace' is most useful to New Relic when diagnosing + * issues with the agent, 'info' and higher will impose the least overhead on + * production applications. + */ + level: 'info' + } +} diff --git a/package.json b/package.json index fa12cb4dbb..931b624c0b 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,32 @@ { - "name": "habitica", + "name": "habitrpg", "description": "A habit tracker app which treats your goals like a Role Playing Game.", - "version": "3.0.0", - "main": "./website/server/index.js", + "version": "0.0.0-152", + "main": "./website/src/server.js", "dependencies": { - "accepts": "^1.3.2", "amazon-payments": "0.0.4", "amplitude": "^2.0.3", - "apidoc": "^0.16.0", "async": "^1.5.0", "aws-sdk": "^2.0.25", - "babel-plugin-transform-async-to-module-method": "^6.8.0", + "babel-plugin-syntax-async-functions": "^6.5.0", + "babel-plugin-transform-regenerator": "^6.6.0", "babel-polyfill": "^6.6.1", "babel-preset-es2015": "^6.6.0", "babel-register": "^6.6.0", "babelify": "^7.2.0", - "bluebird": "^3.3.5", "body-parser": "^1.15.0", "bower": "~1.3.12", "browserify": "~12.0.1", "compression": "^1.6.1", "connect-ratelimit": "0.0.7", "cookie-session": "^1.2.0", - "coupon-code": "^0.4.3", + "coupon-code": "~0.3.0", "csv-stringify": "^1.0.2", - "cwait": "^1.0.0", "domain-middleware": "~0.1.0", - "estraverse": "^4.1.1", - "express": "~4.13.3", - "express-csv": "~0.6.0", - "express-validator": "^2.18.0", + "express": "^4.13.4", "firebase": "^2.2.9", "firebase-token-generator": "^2.0.0", "glob": "^4.3.5", - "got": "^6.1.1", "grunt": "~0.4.1", "grunt-cli": "~0.1.9", "grunt-contrib-clean": "~0.6.0", @@ -62,34 +55,32 @@ "markdown-it": "^6.0.1", "merge-stream": "^1.0.0", "method-override": "^2.3.5", - "moment": "^2.13.0", - "mongoose": "^4.4.16", + "moment": "~2.10.6", + "mongoose": "~3.8.23", "mongoose-id-autoinc": "~2013.7.14-4", "morgan": "^1.7.0", "nconf": "~0.8.2", - "newrelic": "^1.27.2", - "nib": "^1.1.0", - "nodemailer": "^2.3.2", - "object-path": "^0.9.2", + "newrelic": "~1.26.1", + "uuid": "^2.0.1", + "nib": "~1.0.1", + "nodemailer": "^1.9.0", "pageres": "^4.1.1", "passport": "~0.2.1", "passport-facebook": "2.0.0", - "paypal-ipn": "3.0.0", + "paypal-ipn": "2.1.0", "paypal-rest-sdk": "^1.2.1", "pretty-data": "^0.40.0", "ps-tree": "^1.0.0", "push-notify": "^1.1.1", - "request": "~2.72.0", - "rimraf": "^2.4.3", - "run-sequence": "^1.1.4", + "q": "^1.4.1", + "request": "~2.44.0", "s3-upload-stream": "^1.0.6", "serve-favicon": "^2.3.0", "stripe": "^4.2.0", - "superagent": "^1.8.3", + "superagent": "~1.4.0", "swagger-node-express": "lefnire/swagger-node-express#habitrpg", "universal-analytics": "~0.3.2", - "uuid": "^2.0.1", - "validator": "^4.9.0", + "validator": "~4.2.1", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^1.1.0", "winston": "^2.1.0" @@ -97,20 +88,16 @@ "private": true, "engines": { "node": "^4.3.1", - "npm": "^3.8.9" + "npm": "^2.14.9" }, "scripts": { "lint": "eslint .", "test": "npm run lint && gulp test", "test:api-v2:unit": "mocha test/server_side", "test:api-v2:integration": "mocha test/api/v2 --recursive", - "test:api-v3": "gulp test:api-v3", - "test:api-v3:unit": "gulp test:api-v3:unit", - "test:api-v3:integration": "gulp test:api-v3:integration", - "test:api-v3:integration:separate-server": "gulp test:api-v3:integration:separate-server", - "test:api-legacy": "istanbul cover -i \"website/server/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", - "test:common": "mocha test/common --recursive", - "test:content": "mocha test/content --recursive", + "test:api-legacy": "istanbul cover -i \"website/src/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", + "test:common": "mocha test/common", + "test:content": "mocha test/content", "test:karma": "karma start --single-run", "test:karma:watch": "karma start", "test:prepare:webdriver": "webdriver-manager update", @@ -129,7 +116,7 @@ "coveralls": "^2.11.2", "csv": "~0.3.6", "deep-diff": "~0.1.4", - "eslint": "^2.10.1", + "eslint": "^2.7.0", "eslint-config-habitrpg": "^1.0.0", "eslint-plugin-babel": "^3.0.0", "eslint-plugin-mocha": "^2.1.0", @@ -147,23 +134,16 @@ "mocha": "^2.3.3", "mongodb": "^2.0.46", "mongoskin": "~0.6.1", - "nock": "^2.17.0", "phantomjs": "^1.9", "protractor": "^3.1.1", - "require-again": "^1.0.1", "rewire": "^2.3.3", - "shelljs": "^0.7.0", + "rimraf": "^2.4.3", + "run-sequence": "^1.1.4", + "shelljs": "^0.4.0", "sinon": "^1.17.2", "sinon-chai": "^2.8.0", "superagent-defaults": "^0.1.13", "vinyl-source-stream": "^1.0.0", - "vinyl-transform": "^1.0.0", - "xml2js": "^0.4.16" - }, - "apidoc": { - "name": "habitica", - "title": "Habitica", - "version": "3.0.0", - "url": "https://habitica.com" + "vinyl-transform": "^1.0.0" } } diff --git a/tasks/gulp-apidoc.js b/tasks/gulp-apidoc.js deleted file mode 100644 index b8f65d2abd..0000000000 --- a/tasks/gulp-apidoc.js +++ /dev/null @@ -1,22 +0,0 @@ -import gulp from 'gulp'; -import clean from 'rimraf'; -import apidoc from 'apidoc'; - -const APIDOC_DEST_PATH = './website/build/apidoc'; -const APIDOC_SRC_PATH = './website/server'; -gulp.task('apidoc:clean', (done) => { - clean(APIDOC_DEST_PATH, done); -}); - -gulp.task('apidoc', ['apidoc:clean'], (done) => { - let result = apidoc.createDoc({ - src: APIDOC_SRC_PATH, - dest: APIDOC_DEST_PATH, - }); - - if (result === false) { - done(new Error('There was a problem generating apiDoc documentation.')) - } else { - done(); - } -}); diff --git a/tasks/gulp-build.js b/tasks/gulp-build.js index e660145d92..2fd34d0121 100644 --- a/tasks/gulp-build.js +++ b/tasks/gulp-build.js @@ -1,5 +1,4 @@ import gulp from 'gulp'; -import runSequence from 'run-sequence'; import babel from 'gulp-babel'; require('gulp-grunt')(gulp); @@ -12,7 +11,7 @@ gulp.task('build', () => { }); gulp.task('build:src', () => { - return gulp.src('website/server/**/*.js') + return gulp.src('website/src/**/*.js') .pipe(babel()) .pipe(gulp.dest('website/transpiled-babel/')); }); @@ -30,13 +29,9 @@ gulp.task('build:dev', ['browserify', 'prepare:staticNewStuff'], (done) => { }); gulp.task('build:dev:watch', ['build:dev'], () => { - gulp.watch(['website/client/**/*.styl', 'common/script/*']); + gulp.watch(['website/public/**/*.styl', 'common/script/*']); }); gulp.task('build:prod', ['browserify', 'build:server', 'prepare:staticNewStuff'], (done) => { - runSequence( - 'grunt-build:prod', - 'apidoc', - done - ); + gulp.start('grunt-build:prod', done); }); diff --git a/tasks/gulp-console.js b/tasks/gulp-console.js index 026d646cee..07512c6357 100644 --- a/tasks/gulp-console.js +++ b/tasks/gulp-console.js @@ -1,7 +1,8 @@ import mongoose from 'mongoose'; import autoinc from 'mongoose-id-autoinc'; -import logger from '../website/server/libs/api-v3/logger'; +import logging from '../website/src/libs/logging'; import nconf from 'nconf'; +import utils from '../website/src/libs/utils'; import repl from 'repl'; import gulp from 'gulp'; @@ -18,9 +19,11 @@ let improveRepl = (context) => { process.stdout.write('\u001B[2J\u001B[0;0f'); }}); - context.Challenge = require('../website/server/models/challenge').model; - context.Group = require('../website/server/models/group').model; - context.User = require('../website/server/models/user').model; + utils.setupConfig(); + + context.Challenge = require('../website/src/models/challenge').model; + context.Group = require('../website/src/models/group').model; + context.User = require('../website/src/models/user').model; var isProd = nconf.get('NODE_ENV') === 'production'; var mongooseOptions = !isProd ? {} : { @@ -33,7 +36,7 @@ let improveRepl = (context) => { mongooseOptions, function(err) { if (err) throw err; - logger.info('Connected with Mongoose'); + logging.info('Connected with Mongoose'); } ) ); diff --git a/tasks/gulp-newstuff.js b/tasks/gulp-newstuff.js index b6d8093ee5..16085e5c1c 100644 --- a/tasks/gulp-newstuff.js +++ b/tasks/gulp-newstuff.js @@ -4,7 +4,7 @@ import {writeFileSync} from 'fs'; gulp.task('prepare:staticNewStuff', () => { writeFileSync( - './website/client/new-stuff.html', + './website/public/new-stuff.html', jade.compileFile('./website/views/shared/new-stuff.jade')() ); }); diff --git a/tasks/gulp-start.js b/tasks/gulp-start.js index 51825f71ea..7cb842af00 100644 --- a/tasks/gulp-start.js +++ b/tasks/gulp-start.js @@ -9,7 +9,7 @@ gulp.task('nodemon', () => { nodemon({ script: pkg.main, ignore: [ - 'website/client/*', + 'website/public/*', 'website/views/*', 'common/dist/script/content/*', ] diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 7db299a5c4..cd8622f387 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -9,20 +9,17 @@ import mongoose from 'mongoose'; import { exec } from 'child_process'; import psTree from 'ps-tree'; import gulp from 'gulp'; -import Bluebird from 'bluebird'; +import Q from 'q'; import runSequence from 'run-sequence'; import os from 'os'; import nconf from 'nconf'; -// TODO rewrite - const TEST_SERVER_PORT = 3003 let server; const TEST_DB_URI = nconf.get('TEST_DB_URI'); const API_V2_TEST_COMMAND = 'npm run test:api-v2:integration'; -const API_V3_TEST_COMMAND = 'npm run test:api-v3'; const LEGACY_API_TEST_COMMAND = 'npm run test:api-legacy'; const COMMON_TEST_COMMAND = 'npm run test:common'; const CONTENT_TEST_COMMAND = 'npm run test:content'; @@ -44,9 +41,9 @@ let testBin = (string, additionalEnvVariables = '') => { additionalEnvVariables = additionalEnvVariables.split(' ').join('&&set '); additionalEnvVariables = 'set ' + additionalEnvVariables + '&&'; } - return `set NODE_ENV=test&&${additionalEnvVariables}${string}`; + return `set NODE_ENV=testing&&${additionalEnvVariables}${string}`; } else { - return `NODE_ENV=test ${additionalEnvVariables} ${string}`; + return `NODE_ENV=testing ${additionalEnvVariables} ${string}`; } }; @@ -68,7 +65,7 @@ gulp.task('test:prepare:mongo', (cb) => { gulp.task('test:prepare:server', ['test:prepare:mongo'], () => { if (!server) { - server = exec(testBin(`node ./website/server/index.js`, `NODE_DB_URI=${TEST_DB_URI} PORT=${TEST_SERVER_PORT}`), (error, stdout, stderr) => { + server = exec(testBin('node ./website/src/server.js', `NODE_DB_URI=${TEST_DB_URI} PORT=${TEST_SERVER_PORT} `), (error, stdout, stderr) => { if (error) { throw `Problem with the server: ${error}`; } if (stderr) { console.error(stderr); } }); @@ -104,7 +101,7 @@ gulp.task('test:common:clean', (cb) => { }); gulp.task('test:common:watch', ['test:common:clean'], () => { - gulp.watch(['common/script/**/*', 'test/common/**/*'], ['test:common:clean']); + gulp.watch(['common/script/**', 'test/common/**'], ['test:common:clean']); }); gulp.task('test:common:safe', ['test:prepare:build'], (cb) => { @@ -219,7 +216,7 @@ gulp.task('test:api-legacy:watch', [ 'test:prepare:mongo', 'test:api-legacy:clean' ], () => { - gulp.watch(['website/server/**', 'test/api-legacy/**'], ['test:api-legacy:clean']); + gulp.watch(['website/src/**', 'test/api-legacy/**'], ['test:api-legacy:clean']); }); gulp.task('test:karma', ['test:prepare:build'], (cb) => { @@ -265,7 +262,7 @@ gulp.task('test:e2e', ['test:prepare', 'test:prepare:server'], (cb) => { ].map(exec); support.push(server); - Bluebird.all([ + Q.all([ awaitPort(TEST_SERVER_PORT), awaitPort(4444) ]).then(() => { @@ -286,7 +283,7 @@ gulp.task('test:e2e:safe', ['test:prepare', 'test:prepare:server'], (cb) => { 'npm run test:e2e:webdriver', ].map(exec); - Bluebird.all([ + Q.all([ awaitPort(TEST_SERVER_PORT), awaitPort(4444) ]).then(() => { @@ -309,16 +306,16 @@ gulp.task('test:e2e:safe', ['test:prepare', 'test:prepare:server'], (cb) => { }); }); -/*gulp.task('test:api-v2', ['test:prepare:server'], (done) => { - process.env.API_VERSION = 'v2'; +gulp.task('test:api-v2', ['test:prepare:server'], (done) => { + awaitPort(TEST_SERVER_PORT).then(() => { - runMochaTests('./test/api/v2/**//*.js', server, done) + runMochaTests('./test/api/v2/**/*.js', server, done) }); }); gulp.task('test:api-v2:watch', ['test:prepare:server'], () => { process.env.RUN_INTEGRATION_TEST_FOREVER = true; - gulp.watch(['website/server/**', 'test/api/v2/**'], ['test:api-v2']); + gulp.watch(['website/src/**', 'test/api/v2/**'], ['test:api-v2']); }); gulp.task('test:api-v2:safe', ['test:prepare:server'], (done) => { @@ -327,118 +324,7 @@ gulp.task('test:api-v2:safe', ['test:prepare:server'], (done) => { testBin(API_V2_TEST_COMMAND), (err, stdout, stderr) => { testResults.push({ - suite: 'API V2 Specs\t', - pass: testCount(stdout, /(\d+) passing/), - fail: testCount(stderr, /(\d+) failing/), - pend: testCount(stdout, /(\d+) pending/) - }); - done(); - } - ); - pipe(runner); - }); -});*/ - -gulp.task('test:api-v2:integration', (done) => { - let runner = exec( - testBin('mocha test/api/v2 --recursive'), - {maxBuffer: 500*1024}, - (err, stdout, stderr) => done(err) - ) - - pipe(runner); -}); - -gulp.task('test:api-v3:unit', (done) => { - let runner = exec( - testBin('mocha test/api/v3/unit --recursive'), - (err, stdout, stderr) => done(err) - ) - - pipe(runner); -}); - -gulp.task('test:api-v3:unit:watch', () => { - gulp.watch(['website/server/libs/api-v3/*', 'test/api/v3/unit/**/*', 'website/server/controllers/**/*'], ['test:api-v3:unit']); -}); - -gulp.task('test:api-v3:integration', (done) => { - let runner = exec( - testBin('mocha test/api/v3/integration --recursive'), - {maxBuffer: 500*1024}, - (err, stdout, stderr) => done(err) - ) - - pipe(runner); -}); - -gulp.task('test:api-v3:integration:watch', () => { - gulp.watch(['website/server/controllers/api-v3/**/*', 'common/script/ops/*', 'website/server/libs/api-v3/*.js', - 'test/api/v3/integration/**/*'], ['test:api-v3:integration']); -}); - -gulp.task('test:api-v3:integration:separate-server', (done) => { - let runner = exec( - testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), - {maxBuffer: 500*1024}, - (err, stdout, stderr) => done(err) - ) - - pipe(runner); -}); - -gulp.task('test', (done) => { - runSequence( - 'test:common', - 'test:karma', - 'test:api-v3:unit', - 'test:api-v3:integration', - 'test:api-v2:integration', - done - ); -}); - -gulp.task('test:api-v3', (done) => { - runSequence( - 'test:api-v3:unit', - 'test:api-v3:integration', - done - ); -}); - -// Old tests tasks -/* -gulp.task('test:api-v3', ['test:api-v3:unit', 'test:api-v3:integration']); - -gulp.task('test:api-v3:watch', ['test:api-v3:unit:watch', 'test:api-v3:integration:watch']); - -gulp.task('test:api-v3:unit', (done) => {*/ -// runMochaTests('./test/api/v3/unit/**/*.js', null, done) -/*}); - -gulp.task('test:api-v3:unit:watch', () => { - gulp.watch(['website/server/**', 'test/api/v3/unit/**'], ['test:api-v3:unit']); -}); - -gulp.task('test:api-v3:integration', ['test:prepare:server'], (done) => { - process.env.API_VERSION = 'v3'; - awaitPort(TEST_SERVER_PORT).then(() => {*/ -// runMochaTests('./test/api/v3/integration/**/*.js', server, done) -/* }); -}); - -gulp.task('test:api-v3:integration:watch', ['test:prepare:server'], () => { - process.env.RUN_INTEGRATION_TEST_FOREVER = true; - gulp.watch(['website/server/**', 'test/api/v3/integration/**'], ['test:api-v3:integration']); -}); - -gulp.task('test:api-v3:safe', ['test:prepare:server'], (done) => { - awaitPort(TEST_SERVER_PORT).then(() => { - let runner = exec( - testBin(API_V3_TEST_COMMAND), - (err, stdout, stderr) => { - testResults.push({ - suite: 'API V3 Specs\t', + suite: 'API Specs\t', pass: testCount(stdout, /(\d+) passing/), fail: testCount(stdout, /(\d+) failing/), pend: testCount(stdout, /(\d+) pending/) @@ -452,14 +338,13 @@ gulp.task('test:api-v3:safe', ['test:prepare:server'], (done) => { gulp.task('test:all', (done) => { runSequence( - //'test:e2e:safe', - //'test:common:safe', - //'test:content:safe', + 'test:e2e:safe', + 'test:common:safe', + 'test:content:safe', // 'test:server_side:safe', - //'test:karma:safe', - //'test:api-legacy:safe', - //'test:api-v2:safe', - 'test:api-v3:safe', + 'test:karma:safe', + 'test:api-legacy:safe', + 'test:api-v2:safe', done); }); @@ -500,4 +385,4 @@ gulp.task('test', ['test:all'], () => { console.log('\n\x1b[36mThanks for helping keep Habitica clean!\x1b[0m'); process.exit(); } -});*/ +}); diff --git a/tasks/taskHelper.js b/tasks/taskHelper.js index b83faf6af2..408978efd4 100644 --- a/tasks/taskHelper.js +++ b/tasks/taskHelper.js @@ -1,9 +1,9 @@ -import { exec } from 'child_process'; -import psTree from 'ps-tree'; -import nconf from 'nconf'; -import net from 'net'; -import Bluebird from 'bluebird'; -import { post } from 'superagent'; +import { exec } from 'child_process'; +import psTree from 'ps-tree'; +import nconf from 'nconf'; +import net from 'net'; +import Q from 'q'; +import { post } from 'superagent'; import { sync as glob } from 'glob'; import Mocha from 'mocha'; import { resolve } from 'path'; @@ -43,24 +43,25 @@ export function kill(proc) { * has fully spun up. Optionally provide a maximum number of seconds to wait * before failing. */ -export function awaitPort (port, max=60) { - return new Bluebird((reject, resolve) => { - let socket, timeout, interval; +export function awaitPort(port, max=60) { + let socket, timeout, interval; + let deferred = Q.defer(); - timeout = setTimeout(() => { + timeout = setTimeout(() => { + clearInterval(interval); + deferred.reject(`Timed out after ${max} seconds`); + }, max * 1000); + + interval = setInterval(() => { + socket = net.connect({port: port}, () => { clearInterval(interval); - reject(`Timed out after ${max} seconds`); - }, max * 1000); + clearTimeout(timeout); + socket.destroy(); + deferred.resolve(); + }).on('error', () => { socket.destroy }); + }, 1000); - interval = setInterval(() => { - socket = net.connect({port: port}, () => { - clearInterval(interval); - clearTimeout(timeout); - socket.destroy(); - resolve(); - }).on('error', () => { socket.destroy }); - }, 1000); - }); + return deferred.promise }; /* diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000000..8115753727 --- /dev/null +++ b/test/README.md @@ -0,0 +1,5 @@ +We need to clean up this directory. The *real* tests are in spec/ mock/ e2e/ and api.mocha.coffee. We want to: + +1. Move all old / deprecated tests from casper, test2, etc into spec, mock, e2e +1. Remove dependency of api.mocha.coffee on Derby, port it to Mongoose +1. Add better test-coverage diff --git a/test/api-legacy/api-helper.js b/test/api-legacy/api-helper.js index 1b994a80a8..9e61773211 100644 --- a/test/api-legacy/api-helper.js +++ b/test/api-legacy/api-helper.js @@ -1,4 +1,3 @@ -require('babel-core/register'); var path, superagentDefaults; superagentDefaults = require("superagent-defaults"); @@ -6,8 +5,6 @@ superagentDefaults = require("superagent-defaults"); global.request = superagentDefaults(); global.mongoose = require("mongoose"); -var Bluebird = require('bluebird'); -mongoose.Promise = Bluebird; global.moment = require("moment"); @@ -17,7 +14,7 @@ global._ = require("lodash"); global.shared = require("../../common"); -global.User = require("../../website/server/models/user").model; +global.User = require("../../website/src/models/user").model; global.chai = require("chai"); diff --git a/test/api-legacy/challenges.js b/test/api-legacy/challenges.js index b09754bc5c..2f264655ef 100644 --- a/test/api-legacy/challenges.js +++ b/test/api-legacy/challenges.js @@ -1,10 +1,10 @@ var Challenge, Group, app; -app = require("../../website/server/server"); +app = require("../../website/src/server"); -Group = require("../../website/server/models/group").model; +Group = require("../../website/src/models/group").model; -Challenge = require("../../website/server/models/challenge").model; +Challenge = require("../../website/src/models/challenge").model; describe("Challenges", function() { var challenge, group, updateTodo; diff --git a/test/api-legacy/chat.js b/test/api-legacy/chat.js index 1f2dbab487..89b32b0c54 100644 --- a/test/api-legacy/chat.js +++ b/test/api-legacy/chat.js @@ -2,9 +2,9 @@ var Group, app, diff; diff = require("deep-diff"); -Group = require("../../website/server/models/group").model; +Group = require("../../website/src/models/group").model; -app = require("../../website/server/server"); +app = require("../../website/src/server"); describe("Chat", function() { var chat, group; diff --git a/test/api-legacy/coupons.js b/test/api-legacy/coupons.js index 4d4e366473..31d840de61 100644 --- a/test/api-legacy/coupons.js +++ b/test/api-legacy/coupons.js @@ -1,8 +1,8 @@ var Coupon, app, makeSudoUser; -app = require("../../website/server/server"); +app = require("../../website/src/server"); -Coupon = require("../../website/server/models/coupon").model; +Coupon = require("../../website/src/models/coupon").model; makeSudoUser = function(usr, cb) { return registerNewUser(function() { diff --git a/test/api-legacy/inAppPurchases.js b/test/api-legacy/inAppPurchases.js index 96809a7717..d4182e437c 100644 --- a/test/api-legacy/inAppPurchases.js +++ b/test/api-legacy/inAppPurchases.js @@ -1,12 +1,12 @@ var app, iapMock, inApp, rewire, sinon; -app = require('../../website/server/server'); +app = require('../../website/src/server'); rewire = require('rewire'); sinon = require('sinon'); -inApp = rewire('../../website/server/controllers/payments/iap'); +inApp = rewire('../../website/src/controllers/payments/iap'); iapMock = {}; diff --git a/test/api-legacy/party.js b/test/api-legacy/party.js index 2f98b67bde..8ea8188713 100644 --- a/test/api-legacy/party.js +++ b/test/api-legacy/party.js @@ -2,9 +2,9 @@ var Group, app, diff; diff = require("deep-diff"); -Group = require("../../website/server/models/group").model; +Group = require("../../website/src/models/group").model; -app = require("../../website/server/server"); +app = require("../../website/src/server"); describe("Party", function() { return context("Quests", function() { diff --git a/test/api-legacy/pushNotifications.js b/test/api-legacy/pushNotifications.js index 7f98ddccfa..ce3d9672f8 100644 --- a/test/api-legacy/pushNotifications.js +++ b/test/api-legacy/pushNotifications.js @@ -1,6 +1,6 @@ var app, rewire, sinon; -app = require("../../website/server/server"); +app = require("../../website/src/server"); rewire = require('rewire'); @@ -21,7 +21,7 @@ describe("Push-Notifications", function() { }); context("Challenges", function() { var challengeMock, challenges, userMock; - challenges = rewire("../../website/server/controllers/api-v2/challenges"); + challenges = rewire("../../website/src/controllers/api-v2/challenges"); challenges.__set__('pushNotify', pushSpy); challengeMock = { findById: function(arg, cb) { @@ -76,7 +76,7 @@ describe("Push-Notifications", function() { context("Groups", function() { var groups, recipient; recipient = null; - groups = rewire("../../website/server/controllers/api-v2/groups"); + groups = rewire("../../website/src/controllers/api-v2/groups"); groups.__set__('pushNotify', pushSpy); before(function(done) { return registerNewUser(function(err, _user) { @@ -304,7 +304,7 @@ describe("Push-Notifications", function() { }); context("sending gems from balance", function() { var members; - members = rewire("../../website/server/controllers/api-v2/members"); + members = rewire("../../website/src/controllers/api-v2/members"); members.sendMessage = function() { return true; }; @@ -342,7 +342,7 @@ describe("Push-Notifications", function() { }); return describe("Purchases", function() { var membersMock, payments; - payments = rewire("../../website/server/controllers/payments"); + payments = rewire("../../website/src/controllers/payments"); payments.__set__('pushNotify', pushSpy); membersMock = { sendMessage: function() { diff --git a/test/api-legacy/score.js b/test/api-legacy/score.js index af31a4f334..8c6906acfb 100644 --- a/test/api-legacy/score.js +++ b/test/api-legacy/score.js @@ -1,4 +1,4 @@ -require("../../website/server/server"); +require("../../website/src/server"); describe("Score", function() { before(function(done) { diff --git a/test/api-legacy/subscriptions.js b/test/api-legacy/subscriptions.js index 73b55039df..9d8624cb73 100644 --- a/test/api-legacy/subscriptions.js +++ b/test/api-legacy/subscriptions.js @@ -1,8 +1,8 @@ var app, payments; -payments = require("../../website/server/controllers/payments"); +payments = require("../../website/src/controllers/payments"); -app = require("../../website/server/server"); +app = require("../../website/src/server"); describe("Subscriptions", function() { before(function(done) { diff --git a/test/api-legacy/todos.js b/test/api-legacy/todos.js index b72ea57223..5285847fd1 100644 --- a/test/api-legacy/todos.js +++ b/test/api-legacy/todos.js @@ -1,4 +1,4 @@ -require("../../website/server/server"); +require("../../website/src/server"); describe("Todos", function() { before(function(done) { diff --git a/test/api/README.md b/test/api/README.md index 1f5c98abb3..81e7c2d54b 100644 --- a/test/api/README.md +++ b/test/api/README.md @@ -1,7 +1,5 @@ # So you want to write API integration tests? -@TODO rewrite - That's great! This README will serve as a quick primer for style conventions and practices for these tests. ## What is this? @@ -75,7 +73,7 @@ POST-groups_id_leave.test.js To mitigate [callback hell](http://callbackhell.com/) :imp:, we've written a helper method to generate a user object that can make http requests that [return promises](https://babeljs.io/docs/learn-es2015/#promises). This makes it very easy to chain together commands. All you need to do to make a subsequent request is return another promise and then call `.then((result) => {})` on the surrounding block, like so: ```js -it('does something', () => { +it('does something', () => { let user; return generateUser().then((_user) => { // We return the initial promise so this test can be run asyncronously @@ -99,7 +97,7 @@ it('does something', () => { If the test is simple, you can use the [chai-as-promised](http://chaijs.com/plugins/chai-as-promised) `return expect(somePromise).to.eventually` syntax to make your assertion. ```js -it('makes the party creator the leader automatically', () => { +it('makes the party creator the leader automatically', () => { return expect(user.post('/groups', { type: 'party', })).to.eventually.have.deep.property('leader._id', user._id); @@ -109,7 +107,7 @@ it('makes the party creator the leader automatically', () => { If the test is checking that the request returns an error, use the `.eventually.be.rejected.and.eql` syntax. ```js -it('returns an error', () => { +it('returns an error', () => { return expect(user.get('/groups/id-of-a-party-that-user-does-not-belong-to')) .to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v2/groups/GET-groups.test.js b/test/api/v2/groups/GET-groups.test.js index d941b2533e..203fdd0acc 100644 --- a/test/api/v2/groups/GET-groups.test.js +++ b/test/api/v2/groups/GET-groups.test.js @@ -3,15 +3,12 @@ import { generateUser, resetHabiticaDB, } from '../../../helpers/api-integration/v2'; -import { - TAVERN_ID, -} from '../../../../website/server/models/group'; describe('GET /groups', () => { const NUMBER_OF_PUBLIC_GUILDS = 3; + const NUMBER_OF_USERS_GUILDS = 2; let user; - let leader; before(async () => { // Set up a world with a mixture of public and private guilds @@ -19,7 +16,7 @@ describe('GET /groups', () => { await resetHabiticaDB(); user = await generateUser(); - leader = await generateUser({ balance: 10 }); + let leader = await generateUser({ balance: 10 }); await generateGroup(leader, { name: 'public guild - is member', @@ -71,7 +68,7 @@ describe('GET /groups', () => { await expect(user.get('/groups', null, {type: 'tavern'})) .to.eventually.have.a.lengthOf(1) .and.to.have.deep.property('[0]') - .and.to.have.property('_id', TAVERN_ID); + .and.to.have.property('_id', 'habitrpg'); }); }); @@ -93,8 +90,8 @@ describe('GET /groups', () => { context('guilds passed in as query', () => { it('returns all guilds user is a part of ', async () => { - await expect(leader.get('/groups', null, {type: 'guilds'})) - .to.eventually.have.a.lengthOf(4); + await expect(user.get('/groups', null, {type: 'guilds'})) + .to.eventually.have.a.lengthOf(NUMBER_OF_USERS_GUILDS); }); }); }); diff --git a/test/api/v2/groups/POST-groups_id_invite.test.js b/test/api/v2/groups/POST-groups_id_invite.test.js index ef768dbf7e..5acf6e637a 100644 --- a/test/api/v2/groups/POST-groups_id_invite.test.js +++ b/test/api/v2/groups/POST-groups_id_invite.test.js @@ -27,8 +27,8 @@ describe('POST /groups/:id/invite', () => { await inviter.post(`/groups/${group._id}/invite`, { uuids: [invitee._id], }); - group = await inviter.get(`/groups/${group._id}`); - expect(_.find(group.invites, {_id: invitee._id})._id).to.exists; + await group.sync(); + expect(group.invites).to.include(invitee._id); }); }); }); @@ -53,8 +53,8 @@ describe('POST /groups/:id/invite', () => { await inviter.post(`/groups/${group._id}/invite`, { uuids: [invitee._id], }); - group = await inviter.get(`/groups/${group._id}`); - expect(_.find(group.invites, {_id: invitee._id})._id).to.exists; + await group.sync(); + expect(group.invites).to.include(invitee._id); }); }); }); diff --git a/test/api/v2/groups/POST-groups_id_join.test.js b/test/api/v2/groups/POST-groups_id_join.test.js index cf216354a0..250fd4bbf2 100644 --- a/test/api/v2/groups/POST-groups_id_join.test.js +++ b/test/api/v2/groups/POST-groups_id_join.test.js @@ -30,8 +30,9 @@ describe('POST /groups/:id/join', () => { it(`allows user to join a ${groupType}`, async () => { await invitee.post(`/groups/${group._id}/join`); - group = await invitee.get(`/groups/${group._id}`); - expect(_.find(group.members, {_id: invitee._id})._id).to.exists; + await group.sync(); + + expect(group.members).to.include(invitee._id); }); }); }); @@ -77,9 +78,9 @@ describe('POST /groups/:id/join', () => { it('allows user to join a public guild', async () => { await user.post(`/groups/${group._id}/join`); - group = await user.get(`/groups/${group._id}`); + await group.sync(); - expect(_.find(group.members, {_id: user._id})._id).to.exists; + expect(group.members).to.include(user._id); }); }); @@ -102,9 +103,9 @@ describe('POST /groups/:id/join', () => { it('makes the joining user the leader', async () => { await user.post(`/groups/${group._id}/join`); - group = await user.get(`/groups/${group._id}`); + await group.sync(); - expect(group.leader._id).to.eql(user._id); + await expect(group.leader).to.eql(user._id); }); }); }); diff --git a/test/api/v2/groups/POST-groups_id_leave.test.js b/test/api/v2/groups/POST-groups_id_leave.test.js index f6511e941c..ef5c4a8ac2 100644 --- a/test/api/v2/groups/POST-groups_id_leave.test.js +++ b/test/api/v2/groups/POST-groups_id_leave.test.js @@ -28,9 +28,9 @@ describe('POST /groups/:id/leave', () => { it('leaves the group', async () => { await user.post(`/groups/${group._id}/leave`); - await user.sync(); + await group.sync(); - expect(user.guilds).to.not.include(group._id); + expect(group.members).to.not.include(user._id); }); }); diff --git a/test/api/v2/user/DELETE-user.test.js b/test/api/v2/user/DELETE-user.test.js index 8d28a07b78..db0eeca1c8 100644 --- a/test/api/v2/user/DELETE-user.test.js +++ b/test/api/v2/user/DELETE-user.test.js @@ -4,11 +4,7 @@ import { generateGroup, generateUser, } from '../../../helpers/api-integration/v2'; -import { - find, - map, -} from 'lodash'; -import Bluebird from 'bluebird'; +import { find } from 'lodash'; describe('DELETE /user', () => { let user; @@ -23,18 +19,6 @@ describe('DELETE /user', () => { })).to.eventually.eql(false); }); - it('deletes the user\'s tasks', async () => { - // gets the user's todos ids - let ids = user.todos.map(todo => todo._id); - expect(ids.length).to.be.above(0); // make sure the user has some task to delete - - await user.del('/user'); - - await Bluebird.all(map(ids, id => { - return expect(checkExistence('tasks', id)).to.eventually.eql(false); - })); - }); - context('user has active subscription', () => { it('does not delete account'); }); diff --git a/test/api/v2/user/batch-update/POST-user_batch-update.test.js b/test/api/v2/user/batch-update/POST-user_batch-update.test.js index f64cbc7f7b..0b1f244ff7 100644 --- a/test/api/v2/user/batch-update/POST-user_batch-update.test.js +++ b/test/api/v2/user/batch-update/POST-user_batch-update.test.js @@ -31,7 +31,7 @@ describe('POST /user/batch-update', () => { }); }); - xcontext('development only operations', () => { // These tests will fail if your NODE_ENV is set to 'development' instead of 'testing' + context('development only operations', () => { // These tests will fail if your NODE_ENV is set to 'development' instead of 'testing' let protectedOperations = { 'Add Ten Gems': 'addTenGems', 'Add Hourglass': 'addHourglass', diff --git a/test/api/v2/user/pushDevice/POST-pushDevice.test.js b/test/api/v2/user/pushDevice/POST-pushDevice.test.js index c0b5e9be72..97cfc4dbb9 100644 --- a/test/api/v2/user/pushDevice/POST-pushDevice.test.js +++ b/test/api/v2/user/pushDevice/POST-pushDevice.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../../helpers/api-integration/v2'; -xdescribe('POST /user/pushDevice', () => { +describe('POST /user/pushDevice', () => { let user; beforeEach(async () => { diff --git a/test/api/v2/user/tasks/GET-tasks.test.js b/test/api/v2/user/tasks/GET-tasks.test.js index 1506f4c2f0..7e6f847cbb 100644 --- a/test/api/v2/user/tasks/GET-tasks.test.js +++ b/test/api/v2/user/tasks/GET-tasks.test.js @@ -6,7 +6,14 @@ describe('GET /user/tasks/', () => { let user; beforeEach(async () => { - return generateUser().then((_user) => { + return generateUser({ + dailys: [ + {text: 'daily', type: 'daily'}, + {text: 'daily', type: 'daily'}, + {text: 'daily', type: 'daily'}, + {text: 'daily', type: 'daily'}, + ], + }).then((_user) => { user = _user; }); }); @@ -14,7 +21,7 @@ describe('GET /user/tasks/', () => { it('gets all tasks', async () => { return user.get('/user/tasks/').then((tasks) => { expect(tasks).to.be.an('array'); - expect(tasks.length).to.equal(1); + expect(tasks.length).to.be.greaterThan(3); let task = tasks[0]; expect(task.id).to.exist; diff --git a/test/api/v2/user/tasks/POST-clear-completed.test.js b/test/api/v2/user/tasks/POST-clear-completed.test.js deleted file mode 100644 index 40e6cbd5cf..0000000000 --- a/test/api/v2/user/tasks/POST-clear-completed.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v2'; - -describe('POST /user/tasks/clear-completed', () => { - let user; - - beforeEach(async () => { - return generateUser().then((_user) => { - user = _user; - }); - }); - - it('removes all completed todos', async () => { - let toComplete = await user.post('/user/tasks', { - type: 'todo', - text: 'done', - }); - - await user.post(`/user/tasks/${toComplete._id}/up`); - - let todos = await user.get('/user/tasks?type=todo'); - let uncomplete = await user.post('/user/tasks/clear-completed'); - expect(todos.length).to.equal(uncomplete.length + 1); - }); -}); diff --git a/test/api/v2/user/tasks/POST-tasks.test.js b/test/api/v2/user/tasks/POST-tasks.test.js index 4fe4c9f5ea..5f43224803 100644 --- a/test/api/v2/user/tasks/POST-tasks.test.js +++ b/test/api/v2/user/tasks/POST-tasks.test.js @@ -35,7 +35,7 @@ describe('POST /user/tasks', () => { }); }); - xit('does not create a task with an id that already exists', async () => { + it('does not create a task with an id that already exists', async () => { let todo = user.todos[0]; return expect(user.post('/user/tasks', { diff --git a/test/api/v2/user/tasks/PUT-tasks_id.test.js b/test/api/v2/user/tasks/PUT-tasks_id.test.js index 3bb9d8e9c6..037322a6b7 100644 --- a/test/api/v2/user/tasks/PUT-tasks_id.test.js +++ b/test/api/v2/user/tasks/PUT-tasks_id.test.js @@ -33,13 +33,13 @@ describe('PUT /user/tasks/:id', () => { text: 'new text', notes: 'new notes', value: 10000, - priority: 0.1, + priority: 0.5, attribute: 'str', }).then((updatedTask) => { expect(updatedTask.text).to.eql('new text'); expect(updatedTask.notes).to.eql('new notes'); expect(updatedTask.value).to.eql(10000); - expect(updatedTask.priority).to.eql(0.1); + expect(updatedTask.priority).to.eql(0.5); expect(updatedTask.attribute).to.eql('str'); }); }); diff --git a/test/api/v3/README.md b/test/api/v3/README.md deleted file mode 100644 index ad9b55a32c..0000000000 --- a/test/api/v3/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# How to run tests: - -1. `npm test` is equivalent to `gulp test:api-v3` which will run, in order, `gulp lint`, `gulp test:api-v3:unit` and `gulp test:api-v3:integration`. If one of these fails, the whole `npm test` command blocks and fails. Each of these commands can also be run as a standalone command. -2. To run the server and the integrations tests in two different terminals (to better inspect the output in the server) run `npm start` in one and `npm test:api-v3:integration:separate-server` in the other diff --git a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js deleted file mode 100644 index 45dc91758a..0000000000 --- a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js +++ /dev/null @@ -1,94 +0,0 @@ -import { - generateUser, - generateChallenge, - createAndPopulateGroup, - sleep, - checkExistence, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('DELETE /challenges/:challengeId', () => { - it('returns error when challengeId is not a valid UUID', async () => { - let user = await generateUser(); - await expect(user.del('/challenges/test')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns error when challengeId is not for a valid challenge', async () => { - let user = await generateUser(); - - await expect(user.del(`/challenges/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - context('Deleting a valid challenge', () => { - let groupLeader; - let group; - let challenge; - let taskText = 'A challenge task text'; - - beforeEach(async () => { - let populatedGroup = await createAndPopulateGroup(); - - groupLeader = populatedGroup.groupLeader; - group = populatedGroup.group; - - challenge = await generateChallenge(groupLeader, group); - - await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ - {type: 'habit', text: taskText}, - ]); - - await challenge.sync(); - }); - - it('returns an error when user doesn\'t have permissions to delete the challenge', async () => { - let user = await generateUser(); - - await expect(user.del(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyLeaderDeleteChal'), - }); - }); - - it('deletes challenge', async () => { - await groupLeader.del(`/challenges/${challenge._id}`); - - await sleep(0.5); - - await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false); - }); - - it('refunds gems to group leader', async () => { - let oldBalance = (await groupLeader.sync()).balance; - - await groupLeader.del(`/challenges/${challenge._id}`); - - await sleep(0.5); - - await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); - }); - - it('sets broken and doesn\'t set winner flags for user\'s challenge tasks', async () => { - await groupLeader.del(`/challenges/${challenge._id}`); - - await sleep(0.5); - - let tasks = await groupLeader.get('/tasks/user'); - let testTask = _.find(tasks, (task) => { - return task.text === taskText; - }); - - expect(testTask.challenge.broken).to.eql('CHALLENGE_DELETED'); - expect(testTask.challenge.winner).to.be.null; - }); - }); -}); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js deleted file mode 100644 index 33b329300a..0000000000 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js +++ /dev/null @@ -1,142 +0,0 @@ -import { - generateUser, - createAndPopulateGroup, - generateChallenge, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /challenges/:challengeId', () => { - it('fails if challenge doesn\'t exists', async () => { - let user = await generateUser(); - await expect(user.get(`/challenges/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - context('public guild', () => { - let groupLeader; - let group; - let challenge; - let user; - - beforeEach(async () => { - user = await generateUser(); - - let populatedGroup = await createAndPopulateGroup({ - groupDetails: {type: 'guild', privacy: 'public'}, - }); - - groupLeader = populatedGroup.groupLeader; - group = populatedGroup.group; - - challenge = await generateChallenge(groupLeader, group); - }); - - it('should return challenge data', async () => { - let chal = await user.get(`/challenges/${challenge._id}`); - expect(chal.memberCount).to.equal(challenge.memberCount); - expect(chal.name).to.equal(challenge.name); - expect(chal._id).to.equal(challenge._id); - - expect(chal.leader).to.eql({ - _id: groupLeader._id, - id: groupLeader._id, - profile: {name: groupLeader.profile.name}, - }); - expect(chal.group).to.eql(_.pick(group, ['_id', 'id', 'name', 'type', 'privacy'])); - }); - }); - - context('private guild', () => { - let groupLeader; - let group; - let challenge; - let members; - let user; - - beforeEach(async () => { - user = await generateUser(); - - let populatedGroup = await createAndPopulateGroup({ - groupDetails: {type: 'guild', privacy: 'private'}, - members: 1, - }); - - groupLeader = populatedGroup.groupLeader; - group = populatedGroup.group; - members = populatedGroup.members; - - challenge = await generateChallenge(groupLeader, group); - await members[0].post(`/challenges/${challenge._id}/join`); - }); - - it('fails if user doesn\'t have access to the challenge', async () => { - await expect(user.get(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('should return challenge data', async () => { - let chal = await members[0].get(`/challenges/${challenge._id}`); - expect(chal.name).to.equal(challenge.name); - expect(chal._id).to.equal(challenge._id); - - expect(chal.leader).to.eql({ - _id: groupLeader._id, - id: groupLeader._id, - profile: {name: groupLeader.profile.name}, - }); - expect(chal.group).to.eql(_.pick(group, ['_id', 'id', 'name', 'type', 'privacy'])); - }); - }); - - context('party', () => { - let groupLeader; - let group; - let challenge; - let members; - let user; - - beforeEach(async () => { - user = await generateUser(); - - let populatedGroup = await createAndPopulateGroup({ - groupDetails: {type: 'party'}, - members: 1, - }); - - groupLeader = populatedGroup.groupLeader; - group = populatedGroup.group; - members = populatedGroup.members; - - challenge = await generateChallenge(groupLeader, group); - await members[0].post(`/challenges/${challenge._id}/join`); - }); - - it('fails if user doesn\'t have access to the challenge', async () => { - await expect(user.get(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('should return challenge data', async () => { - let chal = await members[0].get(`/challenges/${challenge._id}`); - expect(chal.name).to.equal(challenge.name); - expect(chal._id).to.equal(challenge._id); - - expect(chal.leader).to.eql({ - _id: groupLeader._id, - id: groupLeader.id, - profile: {name: groupLeader.profile.name}, - }); - expect(chal.group).to.eql(_.pick(group, ['_id', 'id', 'name', 'type', 'privacy'])); - }); - }); -}); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js deleted file mode 100644 index 2b98af9579..0000000000 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js +++ /dev/null @@ -1,74 +0,0 @@ -import { - generateUser, - createAndPopulateGroup, - generateChallenge, - translate as t, - sleep, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /challenges/:challengeId/export/csv', () => { - let groupLeader; - let group; - let challenge; - let members; - let user; - - beforeEach(async () => { - let populatedGroup = await createAndPopulateGroup({ - members: 3, - }); - - groupLeader = populatedGroup.groupLeader; - group = populatedGroup.group; - members = populatedGroup.members; - - challenge = await generateChallenge(groupLeader, group); - await members[0].post(`/challenges/${challenge._id}/join`); - await members[1].post(`/challenges/${challenge._id}/join`); - await members[2].post(`/challenges/${challenge._id}/join`); - - await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ - {type: 'habit', text: 'Task 1'}, - {type: 'todo', text: 'Task 2'}, - ]); - await sleep(0.5); // Make sure tasks are synced to the users - await members[0].sync(); - await members[1].sync(); - await members[2].sync(); - }); - - it('fails if challenge doesn\'t exists', async () => { - user = await generateUser(); - user.get('/user'); - await expect(user.get(`/challenges/${generateUUID()}/export/csv`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('fails if user doesn\'t have access to the challenge', async () => { - user = await generateUser(); - user.get('/user'); - - await expect(user.get(`/challenges/${challenge._id}/export/csv`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('should return a valid CSV file with export data', async () => { - let res = await members[0].get(`/challenges/${challenge._id}/export/csv`); - let sortedMembers = _.sortBy([members[0], members[1], members[2], groupLeader], '_id'); - let splitRes = res.split('\n'); - - expect(splitRes[0]).to.equal('UUID,name,Task,Value,Notes,Task,Value,Notes'); - expect(splitRes[1]).to.equal(`${sortedMembers[0]._id},${sortedMembers[0].profile.name},habit:Task 1,0,,todo:Task 2,0,`); - expect(splitRes[2]).to.equal(`${sortedMembers[1]._id},${sortedMembers[1].profile.name},habit:Task 1,0,,todo:Task 2,0,`); - expect(splitRes[3]).to.equal(`${sortedMembers[2]._id},${sortedMembers[2].profile.name},habit:Task 1,0,,todo:Task 2,0,`); - expect(splitRes[4]).to.equal(`${sortedMembers[3]._id},${sortedMembers[3].profile.name},habit:Task 1,0,,todo:Task 2,0,`); - expect(splitRes[5]).to.equal(''); - }); -}); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js deleted file mode 100644 index 5117dcd556..0000000000 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js +++ /dev/null @@ -1,109 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /challenges/:challengeId/members', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('validates optional req.query.lastId to be an UUID', async () => { - await expect(user.get(`/challenges/${generateUUID()}/members?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('fails if challenge doesn\'t exists', async () => { - await expect(user.get(`/challenges/${generateUUID()}/members`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('fails if user doesn\'t have access to the challenge', async () => { - let group = await generateGroup(user); - let challenge = await generateChallenge(user, group); - let anotherUser = await generateUser(); - - await expect(anotherUser.get(`/challenges/${challenge._id}/members`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('works with challenges belonging to public guild', async () => { - let leader = await generateUser({balance: 4}); - let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); - let challenge = await generateChallenge(leader, group); - let res = await user.get(`/challenges/${challenge._id}/members`); - expect(res[0]).to.eql({ - _id: leader._id, - id: leader._id, - profile: {name: leader.profile.name}, - }); - expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); - expect(res[0].profile).to.have.all.keys(['name']); - }); - - it('populates only some fields', async () => { - let anotherUser = await generateUser({balance: 3}); - let group = await generateGroup(anotherUser, {type: 'guild', privacy: 'public', name: generateUUID()}); - let challenge = await generateChallenge(anotherUser, group); - let res = await user.get(`/challenges/${challenge._id}/members`); - expect(res[0]).to.eql({ - _id: anotherUser._id, - id: anotherUser._id, - profile: {name: anotherUser.profile.name}, - }); - expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); - expect(res[0].profile).to.have.all.keys(['name']); - }); - - it('returns only first 30 members', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await generateChallenge(user, group); - - let usersToGenerate = []; - for (let i = 0; i < 31; i++) { - usersToGenerate.push(generateUser({challenges: [challenge._id]})); - } - await Promise.all(usersToGenerate); - - let res = await user.get(`/challenges/${challenge._id}/members`); - expect(res.length).to.equal(30); - res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'id', 'profile']); - expect(member.profile).to.have.all.keys(['name']); - }); - }); - - it('supports using req.query.lastId to get more members', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await generateChallenge(user, group); - - let usersToGenerate = []; - for (let i = 0; i < 57; i++) { - usersToGenerate.push(generateUser({challenges: [challenge._id]})); - } - let generatedUsers = await Promise.all(usersToGenerate); // Group has 59 members (1 is the leader) - let expectedIds = [user._id].concat(generatedUsers.map(generatedUser => generatedUser._id)); - - let res = await user.get(`/challenges/${challenge._id}/members`); - expect(res.length).to.equal(30); - let res2 = await user.get(`/challenges/${challenge._id}/members?lastId=${res[res.length - 1]._id}`); - expect(res2.length).to.equal(28); - - let resIds = res.concat(res2).map(member => member._id); - expect(resIds).to.eql(expectedIds.sort()); - }); -}); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js deleted file mode 100644 index c47e1d4ad8..0000000000 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js +++ /dev/null @@ -1,107 +0,0 @@ -import { - generateUser, - generateChallenge, - generateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /challenges/:challengeId/members/:memberId', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('validates req.params.memberId to be an UUID', async () => { - await expect(user.get(`/challenges/invalidUUID/members/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('validates req.params.memberId to be an UUID', async () => { - await expect(user.get(`/challenges/${generateUUID()}/members/invalidUUID`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('fails if member doesn\'t exists', async () => { - let userId = generateUUID(); - await expect(user.get(`/challenges/${generateUUID()}/members/${userId}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userWithIDNotFound', {userId}), - }); - }); - - it('fails if challenge doesn\'t exists', async () => { - let member = await generateUser(); - await expect(user.get(`/challenges/${generateUUID()}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('fails if user doesn\'t have access to the challenge', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await generateChallenge(user, group); - let anotherUser = await generateUser(); - let member = await generateUser(); - await expect(anotherUser.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('fails if member is not part of the challenge', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await generateChallenge(user, group); - let member = await generateUser(); - await expect(user.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeMemberNotFound'), - }); - }); - - it('works with challenges belonging to a public guild', async () => { - let groupLeader = await generateUser({balance: 4}); - let group = await generateGroup(groupLeader, {type: 'guild', privacy: 'public', name: generateUUID()}); - let challenge = await generateChallenge(groupLeader, group); - let taskText = 'Test Text'; - await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); - - let memberProgress = await user.get(`/challenges/${challenge._id}/members/${groupLeader._id}`); - expect(memberProgress).to.have.all.keys(['_id', 'id', 'profile', 'tasks']); - expect(memberProgress.profile).to.have.all.keys(['name']); - expect(memberProgress.tasks.length).to.equal(1); - }); - - it('returns the member tasks for the challenges', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await generateChallenge(user, group); - await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: 'Test Text'}]); - - let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`); - let chalTasks = await user.get(`/tasks/challenge/${challenge._id}`); - expect(memberProgress.tasks.length).to.equal(chalTasks.length); - expect(memberProgress.tasks[0].challenge.id).to.equal(challenge._id); - expect(memberProgress.tasks[0].challenge.taskId).to.equal(chalTasks[0]._id); - }); - - it('returns the tasks without the tags', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await generateChallenge(user, group); - let taskText = 'Test Text'; - await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); - - let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`); - expect(memberProgress.tasks[0]).not.to.have.key('tags'); - }); -}); diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js deleted file mode 100644 index 275fe798f5..0000000000 --- a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js +++ /dev/null @@ -1,118 +0,0 @@ -import { - generateUser, - generateChallenge, - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('GET challenges/group/:groupId', () => { - context('Public Guild', () => { - let publicGuild, user, nonMember, challenge, challenge2; - - before(async () => { - let { group, groupLeader } = await createAndPopulateGroup({ - groupDetails: { - name: 'TestGuild', - type: 'guild', - privacy: 'public', - }, - }); - - publicGuild = group; - user = groupLeader; - - nonMember = await generateUser(); - - challenge = await generateChallenge(user, group); - challenge2 = await generateChallenge(user, group); - }); - - it('should return group challenges for non member with populated leader', async () => { - let challenges = await nonMember.get(`/challenges/groups/${publicGuild._id}`); - - let foundChallenge1 = _.find(challenges, { _id: challenge._id }); - expect(foundChallenge1).to.exist; - expect(foundChallenge1.leader).to.eql({ - _id: publicGuild.leader._id, - id: publicGuild.leader._id, - profile: {name: user.profile.name}, - }); - let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); - expect(foundChallenge2).to.exist; - expect(foundChallenge2.leader).to.eql({ - _id: publicGuild.leader._id, - id: publicGuild.leader._id, - profile: {name: user.profile.name}, - }); - }); - - it('should return group challenges for member with populated leader', async () => { - let challenges = await user.get(`/challenges/groups/${publicGuild._id}`); - - let foundChallenge1 = _.find(challenges, { _id: challenge._id }); - expect(foundChallenge1).to.exist; - expect(foundChallenge1.leader).to.eql({ - _id: publicGuild.leader._id, - id: publicGuild.leader._id, - profile: {name: user.profile.name}, - }); - let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); - expect(foundChallenge2).to.exist; - expect(foundChallenge2.leader).to.eql({ - _id: publicGuild.leader._id, - id: publicGuild.leader._id, - profile: {name: user.profile.name}, - }); - }); - }); - - context('Private Guild', () => { - let privateGuild, user, nonMember, challenge, challenge2; - - before(async () => { - let { group, groupLeader } = await createAndPopulateGroup({ - groupDetails: { - name: 'TestPrivateGuild', - type: 'guild', - privacy: 'private', - }, - }); - - privateGuild = group; - user = groupLeader; - - nonMember = await generateUser(); - - challenge = await generateChallenge(user, group); - challenge2 = await generateChallenge(user, group); - }); - - it('should prevent non-member from seeing challenges', async () => { - await expect(nonMember.get(`/challenges/groups/${privateGuild._id}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('should return group challenges for member with populated leader', async () => { - let challenges = await user.get(`/challenges/groups/${privateGuild._id}`); - - let foundChallenge1 = _.find(challenges, { _id: challenge._id }); - expect(foundChallenge1).to.exist; - expect(foundChallenge1.leader).to.eql({ - _id: privateGuild.leader._id, - id: privateGuild.leader._id, - profile: {name: user.profile.name}, - }); - let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); - expect(foundChallenge2).to.exist; - expect(foundChallenge2.leader).to.eql({ - _id: privateGuild.leader._id, - id: privateGuild.leader._id, - profile: {name: user.profile.name}, - }); - }); - }); -}); diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js deleted file mode 100644 index ecb9f55383..0000000000 --- a/test/api/v3/integration/challenges/GET-challenges_user.test.js +++ /dev/null @@ -1,132 +0,0 @@ -import { - generateUser, - generateChallenge, - createAndPopulateGroup, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('GET challenges/user', () => { - let user, member, nonMember, challenge, challenge2, publicGuild; - - before(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - name: 'TestGuild', - type: 'guild', - privacy: 'public', - }, - members: 1, - }); - - user = groupLeader; - publicGuild = group; - member = members[0]; - nonMember = await generateUser(); - - challenge = await generateChallenge(user, group); - challenge2 = await generateChallenge(user, group); - }); - - it('should return challenges user has joined', async () => { - await nonMember.post(`/challenges/${challenge._id}/join`); - - let challenges = await nonMember.get('/challenges/user'); - - let foundChallenge = _.find(challenges, { _id: challenge._id }); - expect(foundChallenge).to.exist; - expect(foundChallenge.leader).to.eql({ - _id: publicGuild.leader._id, - id: publicGuild.leader._id, - profile: {name: user.profile.name}, - }); - expect(foundChallenge.group).to.eql({ - _id: publicGuild._id, - id: publicGuild._id, - type: publicGuild.type, - privacy: publicGuild.privacy, - name: publicGuild.name, - }); - }); - - it('should return challenges user has created', async () => { - let challenges = await user.get('/challenges/user'); - - let foundChallenge1 = _.find(challenges, { _id: challenge._id }); - expect(foundChallenge1).to.exist; - expect(foundChallenge1.leader).to.eql({ - _id: publicGuild.leader._id, - id: publicGuild.leader._id, - profile: {name: user.profile.name}, - }); - expect(foundChallenge1.group).to.eql({ - _id: publicGuild._id, - id: publicGuild._id, - type: publicGuild.type, - privacy: publicGuild.privacy, - name: publicGuild.name, - }); - let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); - expect(foundChallenge2).to.exist; - expect(foundChallenge2.leader).to.eql({ - _id: publicGuild.leader._id, - id: publicGuild.leader._id, - profile: {name: user.profile.name}, - }); - expect(foundChallenge2.group).to.eql({ - _id: publicGuild._id, - id: publicGuild._id, - type: publicGuild.type, - privacy: publicGuild.privacy, - name: publicGuild.name, - }); - }); - - it('should return challenges in user\'s group', async () => { - let challenges = await member.get('/challenges/user'); - - let foundChallenge1 = _.find(challenges, { _id: challenge._id }); - expect(foundChallenge1).to.exist; - expect(foundChallenge1.leader).to.eql({ - _id: publicGuild.leader._id, - id: publicGuild.leader._id, - profile: {name: user.profile.name}, - }); - expect(foundChallenge1.group).to.eql({ - _id: publicGuild._id, - id: publicGuild._id, - type: publicGuild.type, - privacy: publicGuild.privacy, - name: publicGuild.name, - }); - let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); - expect(foundChallenge2).to.exist; - expect(foundChallenge2.leader).to.eql({ - _id: publicGuild.leader._id, - id: publicGuild.leader._id, - profile: {name: user.profile.name}, - }); - expect(foundChallenge2.group).to.eql({ - _id: publicGuild._id, - id: publicGuild._id, - type: publicGuild.type, - privacy: publicGuild.privacy, - name: publicGuild.name, - }); - }); - - it('should not return challenges user doesn\'t have access to', async () => { - let { group, groupLeader } = await createAndPopulateGroup({ - groupDetails: { - name: 'TestPrivateGuild', - type: 'guild', - privacy: 'private', - }, - }); - - let privateChallenge = await generateChallenge(groupLeader, group); - - let challenges = await nonMember.get('/challenges/user'); - - let foundChallenge = _.find(challenges, { _id: privateChallenge._id }); - expect(foundChallenge).to.not.exist; - }); -}); diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js deleted file mode 100644 index a97578eef5..0000000000 --- a/test/api/v3/integration/challenges/POST-challenges.test.js +++ /dev/null @@ -1,308 +0,0 @@ -import { - generateUser, - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /challenges', () => { - it('returns error when group is empty', async () => { - let user = await generateUser(); - - await expect(user.post('/challenges')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns error when groupId is not for a valid group', async () => { - let user = await generateUser(); - - await expect(user.post('/challenges', { - group: generateUUID(), - })).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('returns error when creating a challenge in the tavern with no prize', async () => { - let user = await generateUser(); - - await expect(user.post('/challenges', { - group: 'habitrpg', - prize: 0, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('tavChalsMinPrize'), - }); - }); - - it('returns error when creating a challenge in a public guild and you are not a member of it', async () => { - let user = await generateUser(); - let { group } = await createAndPopulateGroup({ - groupDetails: { - type: 'guild', - privacy: 'public', - }, - }); - - await expect(user.post('/challenges', { - group: group._id, - prize: 4, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('mustBeGroupMember'), - }); - }); - - context('Creating a challenge for a valid group', () => { - let groupLeader; - let group; - let groupMember; - - beforeEach(async () => { - let populatedGroup = await createAndPopulateGroup({ - members: 1, - leaderDetails: { - balance: 3, - }, - groupDetails: { - type: 'guild', - leaderOnly: { - challenges: true, - }, - }, - }); - - groupLeader = await populatedGroup.groupLeader.sync(); - group = populatedGroup.group; - groupMember = populatedGroup.members[0]; - }); - - it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { - await expect(groupMember.post('/challenges', { - group: group._id, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyGroupLeaderChal'), - }); - }); - - it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { - await expect(groupMember.post('/challenges', { - group: group._id, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyGroupLeaderChal'), - }); - }); - - it('allows non-leader member to create a challenge', async () => { - let populatedGroup = await createAndPopulateGroup({ - members: 1, - }); - - group = populatedGroup.group; - groupMember = populatedGroup.members[0]; - - let chal = await groupMember.post('/challenges', { - group: group._id, - name: 'Test Challenge', - shortName: 'TC Label', - }); - - expect(chal.leader).to.eql({ - _id: groupMember._id, - profile: {name: groupMember.profile.name}, - }); - }); - - it('doesn\'t take gems from user or group when challenge has no prize', async () => { - let oldUserBalance = groupLeader.balance; - let oldGroupBalance = group.balance; - - await groupLeader.post('/challenges', { - group: group._id, - name: 'Test Challenge', - shortName: 'TC Label', - prize: 0, - }); - - await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance); - await expect(group.sync()).to.eventually.have.property('balance', oldGroupBalance); - }); - - it('returns error when user and group can\'t pay prize', async () => { - await expect(groupLeader.post('/challenges', { - group: group._id, - name: 'Test Challenge', - shortName: 'TC Label', - prize: 20, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('cantAfford'), - }); - }); - - it('takes prize out of group if it has sufficient funds', async () => { - let oldUserBalance = groupLeader.balance; - let oldGroupBalance = group.balance; - let prize = 4; - - await groupLeader.post('/challenges', { - group: group._id, - name: 'Test Challenge', - shortName: 'TC Label', - prize, - }); - - await expect(group.sync()).to.eventually.have.property('balance', oldGroupBalance - prize / 4); - await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance); - }); - - it('takes prize out of both group and user if group doesn\'t have enough', async () => { - let oldUserBalance = groupLeader.balance; - let prize = 8; - - await groupLeader.post('/challenges', { - group: group._id, - name: 'Test Challenge', - shortName: 'TC Label', - prize, - }); - - await expect(group.sync()).to.eventually.have.property('balance', 0); - await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance - (prize / 4 - 1)); - }); - - it('takes prize out of user if group has no balance', async () => { - let oldUserBalance = groupLeader.balance; - let prize = 8; - - await group.update({ balance: 0}); - await groupLeader.post('/challenges', { - group: group._id, - name: 'Test Challenge', - shortName: 'TC Label', - prize, - }); - - await expect(group.sync()).to.eventually.have.property('balance', 0); - await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance - prize / 4); - }); - - it('increases challenge count of group', async () => { - let oldChallengeCount = group.challengeCount; - - await groupLeader.post('/challenges', { - group: group._id, - name: 'Test Challenge', - shortName: 'TC Label', - }); - - await expect(group.sync()).to.eventually.have.property('challengeCount', oldChallengeCount + 1); - }); - - it('sets challenge as official if created by admin and official flag is set', async () => { - await groupLeader.update({ - contributor: { - admin: true, - }, - }); - - let challenge = await groupLeader.post('/challenges', { - group: group._id, - name: 'Test Challenge', - shortName: 'TC Label', - official: true, - }); - - expect(challenge.official).to.eql(true); - }); - - it('doesn\'t set challenge as official if official flag is set by non-admin', async () => { - let challenge = await groupLeader.post('/challenges', { - group: group._id, - name: 'Test Challenge', - shortName: 'TC Label', - official: true, - }); - - expect(challenge.official).to.eql(false); - }); - - it('returns an error when challenge validation fails; doesn\'s save user or group', async () => { - let oldChallengeCount = group.challengeCount; - let oldUserBalance = groupLeader.balance; - let oldUserChallenges = groupLeader.challenges; - let oldGroupBalance = group.balance; - - await expect(groupLeader.post('/challenges', { - group: group._id, - prize: 8, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Challenge validation failed', - }); - - group = await group.sync(); - groupLeader = await groupLeader.sync(); - - expect(group.challengeCount).to.eql(oldChallengeCount); - expect(group.balance).to.eql(oldGroupBalance); - expect(groupLeader.balance).to.eql(oldUserBalance); - expect(groupLeader.challenges).to.eql(oldUserChallenges); - }); - - it('sets all properites of the challenge as passed', async () => { - let name = 'Test Challenge'; - let shortName = 'TC Label'; - let description = 'Test Description'; - let prize = 4; - - let challenge = await groupLeader.post('/challenges', { - group: group._id, - name, - shortName, - description, - prize, - }); - - expect(challenge.leader).to.eql({ - _id: groupLeader._id, - profile: {name: groupLeader.profile.name}, - }); - expect(challenge.name).to.eql(name); - expect(challenge.shortName).to.eql(shortName); - expect(challenge.description).to.eql(description); - expect(challenge.official).to.eql(false); - expect(challenge.group).to.eql({ - _id: group._id, - privacy: group.privacy, - name: group.name, - type: group.type, - }); - expect(challenge.memberCount).to.eql(1); - expect(challenge.prize).to.eql(prize); - }); - - it('adds challenge to creator\'s challenges', async () => { - let challenge = await groupLeader.post('/challenges', { - group: group._id, - name: 'Test Challenge', - shortName: 'TC Label', - }); - - await expect(groupLeader.sync()).to.eventually.have.property('challenges').to.include(challenge._id); - }); - }); -}); diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js deleted file mode 100644 index c55c0e0b49..0000000000 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js +++ /dev/null @@ -1,127 +0,0 @@ -import { - generateUser, - generateChallenge, - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /challenges/:challengeId/join', () => { - it('returns error when challengeId is not a valid UUID', async () => { - let user = await generateUser({ balance: 1}); - - await expect(user.post('/challenges/test/join')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns error when challengeId is not for a valid challenge', async () => { - let user = await generateUser({ balance: 1}); - - await expect(user.post(`/challenges/${generateUUID()}/join`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - context('Joining a valid challenge', () => { - let groupLeader; - let group; - let challenge; - let authorizedUser; - - beforeEach(async () => { - let populatedGroup = await createAndPopulateGroup({ - members: 1, - }); - - groupLeader = populatedGroup.groupLeader; - group = populatedGroup.group; - authorizedUser = populatedGroup.members[0]; - - challenge = await generateChallenge(groupLeader, group); - }); - - it('returns an error when user doesn\'t have permissions to access the challenge', async () => { - let unauthorizedUser = await generateUser(); - - await expect(unauthorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('returns challenge data', async () => { - let res = await authorizedUser.post(`/challenges/${challenge._id}/join`); - - expect(res.group).to.eql({ - _id: group._id, - privacy: group.privacy, - name: group.name, - type: group.type, - }); - expect(res.leader).to.eql({ - _id: groupLeader._id, - id: groupLeader._id, - profile: {name: groupLeader.profile.name}, - }); - expect(res.name).to.equal(challenge.name); - }); - - it('adds challenge to user challenges', async () => { - await authorizedUser.post(`/challenges/${challenge._id}/join`); - - await authorizedUser.sync(); - - expect(authorizedUser).to.have.property('challenges').to.include(challenge._id); - }); - - it('returns error when user has already joined the challenge', async () => { - await authorizedUser.post(`/challenges/${challenge._id}/join`); - - await expect(authorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('userAlreadyInChallenge'), - }); - }); - - it('increases memberCount of challenge', async () => { - let oldMemberCount = challenge.memberCount; - - await authorizedUser.post(`/challenges/${challenge._id}/join`); - - await challenge.sync(); - - expect(challenge).to.have.property('memberCount', oldMemberCount + 1); - }); - - it('syncs challenge tasks to joining user', async () => { - let taskText = 'A challenge task text'; - - await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ - {type: 'habit', text: taskText}, - ]); - - await authorizedUser.post(`/challenges/${challenge._id}/join`); - let tasks = await authorizedUser.get('/tasks/user'); - let tasksTexts = tasks.map((task) => { - return task.text; - }); - - expect(tasksTexts).to.include(taskText); - }); - - it('adds challenge tag to user tags', async () => { - let userTagsLength = (await authorizedUser.get('/tags')).length; - - await authorizedUser.post(`/challenges/${challenge._id}/join`); - - await expect(authorizedUser.get('/tags')).to.eventually.have.length(userTagsLength + 1); - }); - }); -}); diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js deleted file mode 100644 index 9694b263a9..0000000000 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js +++ /dev/null @@ -1,123 +0,0 @@ -import { - generateUser, - generateChallenge, - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /challenges/:challengeId/leave', () => { - it('returns error when challengeId is not a valid UUID', async () => { - let user = await generateUser(); - - await expect(user.post('/challenges/test/leave')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns error when challengeId is not for a valid challenge', async () => { - let user = await generateUser(); - - await expect(user.post(`/challenges/${generateUUID()}/leave`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - context('Leaving a valid challenge', () => { - let groupLeader; - let group; - let challenge; - let notInChallengeUser; - let leavingUser; - let taskText; - - beforeEach(async () => { - let populatedGroup = await createAndPopulateGroup({ - members: 2, - }); - - groupLeader = populatedGroup.groupLeader; - group = populatedGroup.group; - leavingUser = populatedGroup.members[0]; - notInChallengeUser = populatedGroup.members[1]; - - challenge = await generateChallenge(groupLeader, group); - - taskText = 'A challenge task text'; - - await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ - {type: 'habit', text: taskText}, - ]); - - await leavingUser.post(`/challenges/${challenge._id}/join`); - - await challenge.sync(); - }); - - it('returns an error when user doesn\'t have permissions to view the challenge', async () => { - let unauthorizedUser = await generateUser(); - - await expect(unauthorizedUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('returns an error when user isn\'t a member of the challenge', async () => { - await expect(notInChallengeUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('challengeMemberNotFound'), - }); - }); - - it('removes challenge from user challenges', async () => { - await leavingUser.post(`/challenges/${challenge._id}/leave`); - - await leavingUser.sync(); - - expect(leavingUser).to.have.property('challenges').to.not.include(challenge._id); - }); - - it('decreases memberCount of challenge', async () => { - let oldMemberCount = challenge.memberCount; - - await leavingUser.post(`/challenges/${challenge._id}/leave`); - - await challenge.sync(); - - expect(challenge).to.have.property('memberCount', oldMemberCount - 1); - }); - - it('unlinks challenge tasks from leaving user when remove-all is passed', async () => { - await leavingUser.post(`/challenges/${challenge._id}/leave`, { - keep: 'remove-all', - }); - let tasks = await leavingUser.get('/tasks/user'); - let tasksTexts = tasks.map((task) => { - return task.text; - }); - - expect(tasksTexts).to.not.include(taskText); - }); - - it('doesn\'t unlink challenge tasks from leaving user when remove-all isn\'t passed', async () => { - await leavingUser.post(`/challenges/${challenge._id}/leave`, { - keep: 'test', - }); - - let tasks = await leavingUser.get('/tasks/user'); - let testTask = _.find(tasks, (task) => { - return task.text === taskText; - }); - - expect(testTask).to.not.be.undefined; - expect(testTask.challenge).to.be.undefined; - }); - }); -}); diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js deleted file mode 100644 index 98dfc20926..0000000000 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js +++ /dev/null @@ -1,139 +0,0 @@ -import { - generateUser, - generateChallenge, - createAndPopulateGroup, - sleep, - checkExistence, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /challenges/:challengeId/winner/:winnerId', () => { - it('returns error when challengeId is not a valid UUID', async () => { - let user = await generateUser(); - - await expect(user.post(`/challenges/test/selectWinner/${user._id}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns error when winnerId is not a valid UUID', async () => { - let user = await generateUser(); - - await expect(user.post(`/challenges/${generateUUID()}/selectWinner/test`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns error when challengeId is not for a valid challenge', async () => { - let user = await generateUser(); - - await expect(user.post(`/challenges/${generateUUID()}/selectWinner/${user._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - context('Selecting winner for a valid challenge', () => { - let groupLeader; - let group; - let challenge; - let winningUser; - let taskText = 'A challenge task text'; - - beforeEach(async () => { - let populatedGroup = await createAndPopulateGroup({ - members: 1, - }); - - groupLeader = populatedGroup.groupLeader; - group = populatedGroup.group; - winningUser = populatedGroup.members[0]; - - challenge = await generateChallenge(groupLeader, group, { - prize: 1, - }); - - await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ - {type: 'habit', text: taskText}, - ]); - - await winningUser.post(`/challenges/${challenge._id}/join`); - - await challenge.sync(); - }); - - it('returns an error when user doesn\'t have permissions to select winner', async () => { - await expect(winningUser.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyLeaderDeleteChal'), - }); - }); - - it('returns an error when winning user isn\'t part of the challenge', async () => { - let notInChallengeUser = await generateUser(); - - await expect(groupLeader.post(`/challenges/${challenge._id}/selectWinner/${notInChallengeUser._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('winnerNotFound', {userId: notInChallengeUser._id}), - }); - }); - - it('deletes challenge after winner is selected', async () => { - await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); - - await sleep(0.5); - - await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false); - }); - - it('adds challenge to winner\'s achievements', async () => { - await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); - - await sleep(0.5); - - await expect(winningUser.sync()).to.eventually.have.deep.property('achievements.challenges').to.include(challenge.name); - }); - - it('gives winner gems as reward', async () => { - let oldBalance = winningUser.balance; - - await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); - - await sleep(0.5); - - await expect(winningUser.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); - }); - - it('doesn\'t refund gems to group leader', async () => { - let oldBalance = (await groupLeader.sync()).balance; - - await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); - - await sleep(0.5); - - await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance); - }); - - it('sets broken and winner flags for user\'s challenge tasks', async () => { - await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); - - await sleep(0.5); - - let tasks = await winningUser.get('/tasks/user'); - let testTask = _.find(tasks, (task) => { - return task.text === taskText; - }); - - expect(testTask.challenge.broken).to.eql('CHALLENGE_CLOSED'); - expect(testTask.challenge.winner).to.eql(winningUser.profile.name); - }); - }); -}); diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js deleted file mode 100644 index 1fdcc7a5a2..0000000000 --- a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js +++ /dev/null @@ -1,85 +0,0 @@ -import { - generateUser, - generateChallenge, - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('PUT /challenges/:challengeId', () => { - let privateGuild, user, nonMember, challenge, member; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - name: 'TestPrivateGuild', - type: 'guild', - privacy: 'private', - }, - members: 1, - }); - - privateGuild = group; - user = groupLeader; - - nonMember = await generateUser(); - member = members[0]; - - challenge = await generateChallenge(user, group); - await member.post(`/challenges/${challenge._id}/join`); - }); - - it('fails if the user can\'t view the challenge', async () => { - await expect(nonMember.put(`/challenges/${challenge._id}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('should only allow the leader or an admin to update the challenge', async () => { - await expect(member.put(`/challenges/${challenge._id}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyLeaderUpdateChal'), - }); - }); - - it('only updates allowed fields', async () => { - let res = await user.put(`/challenges/${challenge._id}`, { - // ignored - prize: 33, - group: 'blabla', - memberCount: 33, - tasksOrder: 'new order', - official: true, - shortName: 'new short name', - - // applied - name: 'New Challenge Name', - description: 'New challenge description.', - leader: member._id, - }); - - expect(res.prize).to.equal(0); - expect(res.group).to.eql({ - _id: privateGuild._id, - privacy: privateGuild.privacy, - name: privateGuild.name, - type: privateGuild.type, - }); - expect(res.memberCount).to.equal(2); - expect(res.tasksOrder).not.to.equal('new order'); - expect(res.official).to.equal(false); - expect(res.shortName).not.to.equal('new short name'); - - expect(res.leader).to.eql({ - _id: member._id, - id: member._id, - profile: {name: member.profile.name}, - }); - expect(res.name).to.equal('New Challenge Name'); - expect(res.description).to.equal('New challenge description.'); - }); -}); diff --git a/test/api/v3/integration/chat/DELETE-chat_id.test.js b/test/api/v3/integration/chat/DELETE-chat_id.test.js deleted file mode 100644 index 5d1f73f867..0000000000 --- a/test/api/v3/integration/chat/DELETE-chat_id.test.js +++ /dev/null @@ -1,81 +0,0 @@ -import { - createAndPopulateGroup, - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('DELETE /groups/:groupId/chat/:chatId', () => { - let groupWithChat, message, user, userThatDidNotCreateChat, admin; - - before(async () => { - let { group, groupLeader } = await createAndPopulateGroup({ - groupDetails: { - type: 'guild', - privacy: 'public', - }, - }); - - groupWithChat = group; - user = groupLeader; - message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); - message = message.message; - userThatDidNotCreateChat = await generateUser(); - admin = await generateUser({'contributor.admin': true}); - }); - - context('Chat errors', () => { - it('returns an error is message does not exist', async () => { - let fakeChatId = generateUUID(); - await expect(user.del(`/groups/${groupWithChat._id}/chat/${fakeChatId}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatNotFound'), - }); - }); - - it('returns an error when user does not have permission to delete', async () => { - await expect(userThatDidNotCreateChat.del(`/groups/${groupWithChat._id}/chat/${message.id}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyCreatorOrAdminCanDeleteChat'), - }); - }); - }); - - context('Chat success', () => { - let nextMessage; - - beforeEach(async () => { - nextMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some new message' }); - nextMessage = nextMessage.message; - }); - - it('allows creator to delete a their message', async () => { - await user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`); - let messages = await user.get(`/groups/${groupWithChat._id}/chat/`); - expect(messages).is.an('array'); - expect(messages).to.not.include(nextMessage); - }); - - it('allows admin to delete another user\'s message', async () => { - await admin.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`); - let messages = await user.get(`/groups/${groupWithChat._id}/chat/`); - expect(messages).is.an('array'); - expect(messages).to.not.include(nextMessage); - }); - - it('returns empty when previous message parameter is passed and the last message was deleted', async () => { - await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${nextMessage.id}`)) - .to.eventually.be.empty; - }); - - it('returns the update chat when previous message parameter is passed and the chat is updated', async () => { - await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${message.id}`)) - .eventually - .is.an('array') - .to.include(message) - .to.be.lengthOf(1); - }); - }); -}); diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js deleted file mode 100644 index 91424d655e..0000000000 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import { - generateUser, - generateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('GET /groups/:groupId/chat', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - context('public Guild', () => { - let group; - - before(async () => { - let leader = await generateUser({balance: 2}); - - group = await generateGroup(leader, { - name: 'test group', - type: 'guild', - privacy: 'public', - }, { - chat: [ - {text: 'Hello', flags: {}}, - {text: 'Welcome to the Guild', flags: {}}, - ], - }); - }); - - it('returns Guild chat', async () => { - let chat = await user.get(`/groups/${group._id}/chat`); - - expect(chat).to.eql(group.chat); - }); - }); - - context('private Guild', () => { - let group; - - before(async () => { - let leader = await generateUser({balance: 2}); - - group = await generateGroup(leader, { - name: 'test group', - type: 'guild', - privacy: 'private', - }, { - chat: [ - 'Hello', - 'Welcome to the Guild', - ], - }); - }); - - it('returns error if user is not member of requested private group', async () => { - await expect(user.get(`/groups/${group._id}/chat`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - }); -}); diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js deleted file mode 100644 index 51a3abb164..0000000000 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { find } from 'lodash'; - -describe('POST /chat/:chatId/flag', () => { - let user, admin, anotherUser, group; - const TEST_MESSAGE = 'Test Message'; - - before(async () => { - user = await generateUser({balance: 1}); - admin = await generateUser({balance: 1, 'contributor.admin': true}); - anotherUser = await generateUser(); - - group = await user.post('/groups', { - name: 'Test Guild', - type: 'guild', - privacy: 'public', - }); - }); - - it('Returns an error when chat message is not found', async () => { - await expect(user.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatNotFound'), - }); - }); - - it('Returns an error when user tries to flag their own message', async () => { - let message = await user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE }); - await expect(user.post(`/groups/${group._id}/chat/${message.message.id}/flag`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatFlagOwnMessage'), - }); - }); - - it('Flags a chat', async () => { - let message = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); - message = message.message; - - let flagResult = await user.post(`/groups/${group._id}/chat/${message.id}/flag`); - expect(flagResult.flags[user._id]).to.equal(true); - expect(flagResult.flagCount).to.equal(1); - - let groupWithFlags = await admin.get(`/groups/${group._id}`); - - let messageToCheck = find(groupWithFlags.chat, {id: message.id}); - expect(messageToCheck.flags[user._id]).to.equal(true); - }); - - it('Flags a chat with a higher flag acount when an admin flags the message', async () => { - let message = await user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); - message = message.message; - - let flagResult = await admin.post(`/groups/${group._id}/chat/${message.id}/flag`); - expect(flagResult.flags[admin._id]).to.equal(true); - expect(flagResult.flagCount).to.equal(5); - - let groupWithFlags = await admin.get(`/groups/${group._id}`); - - let messageToCheck = find(groupWithFlags.chat, {id: message.id}); - expect(messageToCheck.flags[admin._id]).to.equal(true); - expect(messageToCheck.flagCount).to.equal(5); - }); - - it('Returns an error when user tries to flag a message that is already flagged', async () => { - let message = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); - message = message.message; - - await user.post(`/groups/${group._id}/chat/${message.id}/flag`); - - await expect(user.post(`/groups/${group._id}/chat/${message.id}/flag`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatFlagAlreadyReported'), - }); - }); -}); diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js deleted file mode 100644 index d7ae6047df..0000000000 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ /dev/null @@ -1,75 +0,0 @@ -import { - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { find } from 'lodash'; - -describe('POST /chat/:chatId/like', () => { - let user; - let groupWithChat; - let testMessage = 'Test Message'; - let anotherUser; - - before(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Guild', - type: 'guild', - privacy: 'public', - }, - members: 1, - }); - - user = groupLeader; - groupWithChat = group; - anotherUser = members[0]; - }); - - it('Returns an error when chat message is not found', async () => { - await expect(user.post(`/groups/${groupWithChat._id}/chat/incorrectMessage/like`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatNotFound'), - }); - }); - - it('Returns an error when user tries to like their own message', async () => { - let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - - await expect(user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatLikeOwnMessage'), - }); - }); - - it('Likes a chat', async () => { - let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - - let likeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); - - expect(likeResult.likes[user._id]).to.equal(true); - - let groupWithChatLikes = await user.get(`/groups/${groupWithChat._id}`); - - let messageToCheck = find(groupWithChatLikes.chat, {id: message.message.id}); - expect(messageToCheck.likes[user._id]).to.equal(true); - }); - - it('Unlikes a chat', async () => { - let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - - let likeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); - expect(likeResult.likes[user._id]).to.equal(true); - - let unlikeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); - expect(unlikeResult.likes[user._id]).to.equal(false); - - let groupWithoutChatLikes = await user.get(`/groups/${groupWithChat._id}`); - - let messageToCheck = find(groupWithoutChatLikes.chat, {id: message.message.id}); - expect(messageToCheck.likes[user._id]).to.equal(false); - }); -}); diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js deleted file mode 100644 index 99d1469af8..0000000000 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ /dev/null @@ -1,81 +0,0 @@ -import { - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /chat', () => { - let user, groupWithChat, userWithChatRevoked, member; - let testMessage = 'Test Message'; - - before(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Guild', - type: 'guild', - privacy: 'public', - }, - members: 2, - }); - - user = groupLeader; - groupWithChat = group; - userWithChatRevoked = await members[0].update({'flags.chatRevoked': true}); - member = members[0]; - }); - - it('Returns an error when no message is provided', async () => { - await expect(user.post(`/groups/${groupWithChat._id}/chat`, { message: ''})) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('Returns an error when group is not found', async () => { - await expect(user.post('/groups/invalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('Returns an error when chat privileges are revoked', async () => { - await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: 'Your chat privileges have been revoked.', - }); - }); - - it('creates a chat', async () => { - let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - - expect(message.message.id).to.exist; - }); - - it('notifies other users of new messages for a guild', async () => { - let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - let memberWithNotification = await member.get('/user'); - - expect(message.message.id).to.exist; - expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.exist; - }); - - it('notifies other users of new messages for a party', async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Party', - type: 'party', - privacy: 'private', - }, - members: 1, - }); - - let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage}); - let memberWithNotification = await members[0].get('/user'); - - expect(message.message.id).to.exist; - expect(memberWithNotification.newMessages[`${group._id}`]).to.exist; - }); -}); diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js deleted file mode 100644 index 8b22461a04..0000000000 --- a/test/api/v3/integration/chat/POST-chat_seen.test.js +++ /dev/null @@ -1,63 +0,0 @@ -import { - createAndPopulateGroup, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /groups/:id/chat/seen', () => { - context('Guild', () => { - let guild, guildLeader, guildMember, guildMessage; - - before(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - type: 'guild', - privacy: 'public', - }, - members: 1, - }); - - guild = group; - guildLeader = groupLeader; - guildMember = members[0]; - - guildMessage = await guildLeader.post(`/groups/${guild._id}/chat`, { message: 'Some guild message' }); - guildMessage = guildMessage.message; - }); - - it('clears new messages for a guild', async () => { - await guildMember.post(`/groups/${guild._id}/chat/seen`); - - let guildThatHasSeenChat = await guildMember.get('/user'); - - expect(guildThatHasSeenChat.newMessages).to.be.empty; - }); - }); - - context('Party', () => { - let party, partyLeader, partyMember, partyMessage; - - before(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - type: 'party', - privacy: 'private', - }, - members: 1, - }); - - party = group; - partyLeader = groupLeader; - partyMember = members[0]; - - partyMessage = await partyLeader.post(`/groups/${party._id}/chat`, { message: 'Some party message' }); - partyMessage = partyMessage.message; - }); - - it('clears new messages for a party', async () => { - await partyMember.post(`/groups/${party._id}/chat/seen`); - - let partyMemberThatHasSeenChat = await partyMember.get('/user'); - - expect(partyMemberThatHasSeenChat.newMessages).to.be.empty; - }); - }); -}); diff --git a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js deleted file mode 100644 index 87cad5d6f0..0000000000 --- a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js +++ /dev/null @@ -1,101 +0,0 @@ -import { - createAndPopulateGroup, - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /groups/:id/chat/:id/clearflags', () => { - let groupWithChat, message, author, nonAdmin, admin; - - before(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - type: 'guild', - privacy: 'public', - }, - members: 1, - }); - - groupWithChat = group; - author = groupLeader; - nonAdmin = members[0]; - admin = await generateUser({'contributor.admin': true}); - - message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); - message = message.message; - admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/flag`); - }); - - context('Single Message', () => { - it('returns error when non-admin attempts to clear flags', async () => { - return expect(nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupChatAdminClearFlagCount'), - }); - }); - - it('returns error if message does not exist', async () => { - let fakeMessageID = generateUUID(); - - await expect(admin.post(`/groups/${groupWithChat._id}/chat/${fakeMessageID}/clearflags`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatNotFound'), - }); - }); - - it('clears flags and leaves old flags on the flag object', async () => { - await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`); - let messages = await admin.get(`/groups/${groupWithChat._id}/chat`); - expect(messages[0].flagCount).to.eql(0); - expect(messages[0].flags).to.have.property(admin._id, true); - }); - }); - - context('admin user, group with multiple messages', () => { - let message2, message3, message4; - - before(async () => { - message2 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 2' }); - message2 = message2.message; - await admin.post(`/groups/${groupWithChat._id}/chat/${message2.id}/flag`); - - message3 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 3' }); - message3 = message3.message; - await admin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`); - await nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`); - - message4 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 4' }); - message4 = message4.message; - }); - - it('changes only the message that is flagged', async () => { - await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`); - let messages = await admin.get(`/groups/${groupWithChat._id}/chat`); - - expect(messages).to.have.lengthOf(4); - - let messageThatWasUnflagged = messages[3]; - let messageWith1Flag = messages[2]; - let messageWith2Flag = messages[1]; - let messageWithoutFlags = messages[0]; - - expect(messageThatWasUnflagged.flagCount).to.eql(0); - expect(messageThatWasUnflagged.flags).to.have.property(admin._id, true); - - expect(messageWith1Flag.flagCount).to.eql(5); - expect(messageWith1Flag.flags).to.have.property(admin._id, true); - - expect(messageWith2Flag.flagCount).to.eql(6); - expect(messageWith2Flag.flags).to.have.property(admin._id, true); - expect(messageWith2Flag.flags).to.have.property(nonAdmin._id, true); - - expect(messageWithoutFlags.flagCount).to.eql(0); - expect(messageWithoutFlags.flags).to.eql({}); - }); - }); -}); diff --git a/test/api/v3/integration/content/GET-content.test.js b/test/api/v3/integration/content/GET-content.test.js deleted file mode 100644 index 9324fce398..0000000000 --- a/test/api/v3/integration/content/GET-content.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { - requester, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import i18n from '../../../../../common/script/i18n'; - -describe('GET /content', () => { - it('returns content (and does not require authentication)', async () => { - let res = await requester().get('/content'); - expect(res).to.have.deep.property('backgrounds.backgrounds062014.beach'); - expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); - }); - - it('returns content not in English', async () => { - let res = await requester().get('/content?language=de'); - expect(res).to.have.deep.property('backgrounds.backgrounds062014.beach'); - expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'de')); - }); - - it('falls back to English if the desired language is not found', async () => { - let res = await requester().get('/content?language=wrong'); - expect(res).to.have.deep.property('backgrounds.backgrounds062014.beach'); - expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); - }); -}); diff --git a/test/api/v3/integration/coupons/GET-coupons.test.js b/test/api/v3/integration/coupons/GET-coupons.test.js deleted file mode 100644 index 6008d2b1df..0000000000 --- a/test/api/v3/integration/coupons/GET-coupons.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import { - generateUser, - translate as t, - resetHabiticaDB, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('GET /coupons/', () => { - let user; - before(async () => { - await resetHabiticaDB(); - }); - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns an error if user has no sudo permission', async () => { - await user.get('/user'); // needed so the request after this will authenticate with the correct cookie session - await expect(user.get('/coupons')).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('noSudoAccess'), - }); - }); - - it('should return the coupons in CSV format ordered by creation date', async () => { - await user.update({ - 'contributor.sudo': true, - }); - - let coupons = await user.post('/coupons/generate/wondercon?count=11'); - let res = await user.get('/coupons'); - let splitRes = res.split('\n'); - - expect(splitRes.length).to.equal(13); - expect(splitRes[0]).to.equal('code,event,date,user'); - expect(splitRes[6].split(',')[1]).to.equal(coupons[5].event); - }); -}); diff --git a/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js b/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js deleted file mode 100644 index e1f9db3583..0000000000 --- a/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js +++ /dev/null @@ -1,62 +0,0 @@ -import { - generateUser, - translate as t, - resetHabiticaDB, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /coupons/enter/:code', () => { - let user; - let sudoUser; - - before(async () => { - await resetHabiticaDB(); - }); - - beforeEach(async () => { - user = await generateUser(); - sudoUser = await generateUser({ - 'contributor.sudo': true, - }); - }); - - it('returns an error if code is missing', async () => { - await expect(user.post('/coupons/enter')).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: 'Not found.', - }); - }); - - it('returns an error if code is invalid', async () => { - await expect(user.post('/coupons/enter/notValid')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidCoupon'), - }); - }); - - it('returns an error if coupon has been used', async () => { - let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1'); - await user.post(`/coupons/enter/${coupon._id}`); // use coupon - - await expect(user.post(`/coupons/enter/${coupon._id}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('couponUsed'), - }); - }); - - it('should apply the coupon to the user', async () => { - let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1'); - let userRes = await user.post(`/coupons/enter/${coupon._id}`); - expect(userRes._id).to.equal(user._id); - expect(userRes.items.gear.owned.eyewear_special_wondercon_red).to.be.true; - expect(userRes.items.gear.owned.eyewear_special_wondercon_black).to.be.true; - expect(userRes.items.gear.owned.back_special_wondercon_black).to.be.true; - expect(userRes.items.gear.owned.back_special_wondercon_red).to.be.true; - expect(userRes.items.gear.owned.body_special_wondercon_red).to.be.true; - expect(userRes.items.gear.owned.body_special_wondercon_black).to.be.true; - expect(userRes.items.gear.owned.body_special_wondercon_gold).to.be.true; - expect(userRes.extra).to.eql({signupEvent: 'wondercon'}); - }); -}); diff --git a/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js deleted file mode 100644 index 27bbc5c4f7..0000000000 --- a/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import { - generateUser, - translate as t, - resetHabiticaDB, -} from '../../../../helpers/api-v3-integration.helper'; -import couponCode from 'coupon-code'; - -describe('POST /coupons/generate/:event', () => { - let user; - before(async () => { - await resetHabiticaDB(); - }); - - beforeEach(async () => { - user = await generateUser({ - 'contributor.sudo': true, - }); - }); - - it('returns an error if user has no sudo permission', async () => { - await user.update({ - 'contributor.sudo': false, - }); - - await expect(user.post('/coupons/generate/aaa')).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('noSudoAccess'), - }); - }); - - it('returns an error if event is missing', async () => { - await expect(user.post('/coupons/generate')).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: 'Not found.', - }); - }); - - it('returns an error if event is invalid', async () => { - await expect(user.post('/coupons/generate/notValid?count=1')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Coupon validation failed', - }); - }); - - it('returns an error if count is missing', async () => { - await expect(user.post('/coupons/generate/notValid')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('should generate coupons', async () => { - await user.update({ - 'contributor.sudo': true, - }); - - let coupons = await user.post('/coupons/generate/wondercon?count=2'); - expect(coupons.length).to.equal(2); - expect(coupons[0].event).to.equal('wondercon'); - expect(couponCode.validate(coupons[1]._id)).to.not.equal(''); // '' means invalid - }); -}); diff --git a/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js b/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js deleted file mode 100644 index 9d433813ea..0000000000 --- a/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { - generateUser, - requester, - resetHabiticaDB, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /coupons/validate/:code', () => { - let api = requester(); - - before(async () => { - await resetHabiticaDB(); - }); - - it('returns an error if code is missing', async () => { - await expect(api.post('/coupons/validate')).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: 'Not found.', - }); - }); - - it('returns true if coupon code is valid', async () => { - let sudoUser = await generateUser({ - 'contributor.sudo': true, - }); - - let [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1'); - let res = await api.post(`/coupons/validate/${coupon._id}`); - expect(res).to.eql({valid: true}); - }); - - it('returns false if coupon code is valid', async () => { - let res = await api.post('/coupons/validate/notValid'); - expect(res).to.eql({valid: false}); - }); -}); diff --git a/test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js b/test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js deleted file mode 100644 index a7b97390b6..0000000000 --- a/test/api/v3/integration/dataexport/GET-export_avatar-memberId.html.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /export/avatar-:memberId.html', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('validates req.params.memberId', async () => { - await expect(user.get('/export/avatar-:memberId.html')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('handles non-existing members', async () => { - let dummyId = generateUUID(); - await expect(user.get(`/export/avatar-${dummyId}.html`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userWithIDNotFound', {userId: dummyId}), - }); - }); - - it('returns an html page', async () => { - let res = await user.get(`/export/avatar-${user._id}.html`); - expect(res.substring(0, 100).indexOf('')).to.equal(0); - }); -}); diff --git a/test/api/v3/integration/dataexport/GET-export_avatar-memberId.png.test.js b/test/api/v3/integration/dataexport/GET-export_avatar-memberId.png.test.js deleted file mode 100644 index a46ed695c6..0000000000 --- a/test/api/v3/integration/dataexport/GET-export_avatar-memberId.png.test.js +++ /dev/null @@ -1,3 +0,0 @@ -// TODO how to test this route since it points to a file on AWS s3? - -describe('GET /export/avatar-:memberId.png', () => {}); diff --git a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js deleted file mode 100644 index 2dbc80c49d..0000000000 --- a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js +++ /dev/null @@ -1,47 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; -import { - updateDocument, -} from '../../../../helpers/mongo'; -import moment from 'moment'; - -describe('GET /export/history.csv', () => { - it('should return a valid CSV file with tasks history data', async () => { - let user = await generateUser(); - let tasks = await user.post('/tasks/user', [ - {type: 'habit', text: 'habit 1'}, - {type: 'daily', text: 'daily 1'}, - {type: 'habit', text: 'habit 2'}, - {type: 'todo', text: 'todo 1'}, - ]); - - // score all the tasks twice - await Promise.all(tasks.map(task => { - return user.post(`/tasks/${task._id}/score/up`); - })); - await Promise.all(tasks.map(task => { - return user.post(`/tasks/${task._id}/score/up`); - })); - - // adding an history entry to daily 1 manually because cron didn't run yet - await updateDocument('tasks', tasks[1], { - history: {value: 3.2, date: Number(new Date())}, - }); - - // get updated tasks - tasks = await Promise.all(tasks.map(task => { - return user.get(`/tasks/${task._id}`); - })); - - let res = await user.get('/export/history.csv'); - let splitRes = res.split('\n'); - expect(splitRes[0]).to.equal('Task Name,Task ID,Task Type,Date,Value'); - expect(splitRes[1]).to.equal(`habit 1,${tasks[0]._id},habit,${moment(tasks[0].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[0].history[0].value}`); - expect(splitRes[2]).to.equal(`habit 1,${tasks[0]._id},habit,${moment(tasks[0].history[1].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[0].history[1].value}`); - expect(splitRes[3]).to.equal(`daily 1,${tasks[1]._id},daily,${moment(tasks[1].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[1].history[0].value}`); - expect(splitRes[4]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[0].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[0].value}`); - expect(splitRes[5]).to.equal(`habit 2,${tasks[2]._id},habit,${moment(tasks[2].history[1].date).format('YYYY-MM-DD HH:mm:ss')},${tasks[2].history[1].value}`); - expect(splitRes[6]).to.equal(''); - }); -}); diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js deleted file mode 100644 index d8152f1209..0000000000 --- a/test/api/v3/integration/dataexport/GET-export_userdata.json.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('GET /export/userdata.json', () => { - it('should return a valid JSON file with user data', async () => { - let user = await generateUser(); - let tasks = await user.post('/tasks/user', [ - {type: 'habit', text: 'habit 1'}, - {type: 'daily', text: 'daily 1'}, - {type: 'reward', text: 'reward 1'}, - {type: 'todo', text: 'todo 1'}, - ]); - - let res = await user.get('/export/userdata.json'); - expect(res._id).to.equal(user._id); - expect(res).to.contain.all.keys(['tasks', 'flags', 'tasksOrder', 'auth']); - expect(res.auth.local).not.to.have.keys(['salt', 'hashed_password']); - expect(res.tasks).to.have.all.keys(['dailys', 'habits', 'todos', 'rewards']); - expect(res.tasks.habits.length).to.equal(1); - expect(res.tasks.habits[0]._id).to.equal(tasks[0]._id); - expect(res.tasks.dailys.length).to.equal(1); - expect(res.tasks.dailys[0]._id).to.equal(tasks[1]._id); - expect(res.tasks.rewards.length).to.equal(1); - expect(res.tasks.rewards[0]._id).to.equal(tasks[2]._id); - expect(res.tasks.todos.length).to.equal(2); - expect(res.tasks.todos[1]._id).to.equal(tasks[3]._id); - }); -}); diff --git a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js b/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js deleted file mode 100644 index 58bf4e6135..0000000000 --- a/test/api/v3/integration/dataexport/GET-export_userdata.xml.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; -import xml2js from 'xml2js'; -import Bluebird from 'bluebird'; - -let parseStringAsync = Bluebird.promisify(xml2js.parseString, {context: xml2js}); - -describe('GET /export/userdata.xml', () => { - it('should return a valid XML file with user data', async () => { - let user = await generateUser(); - let tasks = await user.post('/tasks/user', [ - {type: 'habit', text: 'habit 1'}, - {type: 'daily', text: 'daily 1'}, - {type: 'reward', text: 'reward 1'}, - {type: 'todo', text: 'todo 1'}, - // due to how the xml parser works an array is returned only if there's more than one children - // so we create two tasks for each type - {type: 'habit', text: 'habit 2'}, - {type: 'daily', text: 'daily 2'}, - {type: 'reward', text: 'reward 2'}, - {type: 'todo', text: 'todo 2'}, - - ]); - - let response = await user.get('/export/userdata.xml'); - let {user: res} = await parseStringAsync(response, {explicitArray: false}); - - expect(res._id).to.equal(user._id); - expect(res).to.contain.all.keys(['tasks', 'flags', 'tasksOrder', 'auth']); - expect(res.auth.local).not.to.have.keys(['salt', 'hashed_password']); - expect(res.tasks).to.have.all.keys(['dailys', 'habits', 'todos', 'rewards']); - expect(res.tasks.habits.length).to.equal(2); - expect(res.tasks.habits[0]._id).to.equal(tasks[0]._id); - expect(res.tasks.dailys.length).to.equal(2); - expect(res.tasks.dailys[0]._id).to.equal(tasks[1]._id); - expect(res.tasks.rewards.length).to.equal(2); - expect(res.tasks.rewards[0]._id).to.equal(tasks[2]._id); - expect(res.tasks.todos.length).to.equal(3); - expect(res.tasks.todos[1]._id).to.equal(tasks[3]._id); - }); -}); diff --git a/test/api/v3/integration/debug/POST-debug_addHourglass.test.js b/test/api/v3/integration/debug/POST-debug_addHourglass.test.js deleted file mode 100644 index 767fa840f2..0000000000 --- a/test/api/v3/integration/debug/POST-debug_addHourglass.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import nconf from 'nconf'; -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /debug/add-hourglass', () => { - let userToGetHourGlass; - - before(async () => { - userToGetHourGlass = await generateUser(); - }); - - after(() => { - nconf.set('IS_PROD', false); - }); - - it('adds Hourglass to the current user', async () => { - await userToGetHourGlass.post('/debug/add-hourglass'); - - let userWithHourGlass = await userToGetHourGlass.get('/user'); - - expect(userWithHourGlass.purchased.plan.consecutive.trinkets).to.equal(1); - }); - - it('returns error when not in production mode', async () => { - nconf.set('IS_PROD', true); - - await expect(userToGetHourGlass.post('/debug/add-hourglass')) - .eventually.be.rejected.and.to.deep.equal({ - code: 404, - error: 'NotFound', - message: 'Not found.', - }); - }); -}); diff --git a/test/api/v3/integration/debug/POST-debug_addTenGems.test.js b/test/api/v3/integration/debug/POST-debug_addTenGems.test.js deleted file mode 100644 index fd01aea5d3..0000000000 --- a/test/api/v3/integration/debug/POST-debug_addTenGems.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import nconf from 'nconf'; -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /debug/add-ten-gems', () => { - let userToGainTenGems; - - before(async () => { - userToGainTenGems = await generateUser(); - }); - - after(() => { - nconf.set('IS_PROD', false); - }); - - it('adds ten gems to the current user', async () => { - await userToGainTenGems.post('/debug/add-ten-gems'); - - let userWithTenGems = await userToGainTenGems.get('/user'); - - expect(userWithTenGems.balance).to.equal(2.5); - }); - - it('returns error when not in production mode', async () => { - nconf.set('IS_PROD', true); - - await expect(userToGainTenGems.post('/debug/add-ten-gems')) - .eventually.be.rejected.and.to.deep.equal({ - code: 404, - error: 'NotFound', - message: 'Not found.', - }); - }); -}); diff --git a/test/api/v3/integration/debug/POST-debug_make-admin.test.js b/test/api/v3/integration/debug/POST-debug_make-admin.test.js deleted file mode 100644 index 69628aa8bc..0000000000 --- a/test/api/v3/integration/debug/POST-debug_make-admin.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import nconf from 'nconf'; -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; - -xdescribe('POST /debug/make-admin (pended for v3 prod testing)', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - afterEach(() => { - nconf.set('IS_PROD', false); - }); - - it('makes user an admine', async () => { - await user.post('/debug/make-admin'); - - await user.sync(); - - expect(user.contributor.admin).to.eql(true); - }); - - it('returns error when not in production mode', async () => { - nconf.set('IS_PROD', true); - - await expect(user.post('/debug/make-admin')) - .eventually.be.rejected.and.to.deep.equal({ - code: 404, - error: 'NotFound', - message: 'Not found.', - }); - }); -}); diff --git a/test/api/v3/integration/debug/POST-debug_modify-inventory.test.js b/test/api/v3/integration/debug/POST-debug_modify-inventory.test.js deleted file mode 100644 index 93f9081492..0000000000 --- a/test/api/v3/integration/debug/POST-debug_modify-inventory.test.js +++ /dev/null @@ -1,160 +0,0 @@ -/* eslint-disable camelcase */ - -import nconf from 'nconf'; -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /debug/modify-inventory', () => { - let user, originalItems; - - before(async () => { - originalItems = { - gear: { owned: { armor_base_0: true } }, - special: { - snowball: 1, - }, - pets: { - 'Wolf-Desert': 5, - }, - mounts: { - 'Wolf-Desert': true, - }, - eggs: { - Wolf: 5, - }, - hatchingPotions: { - Desert: 5, - }, - food: { - Watermelon: 5, - }, - quests: { - gryphon: 5, - }, - }; - user = await generateUser({ - items: originalItems, - }); - }); - - afterEach(() => { - nconf.set('IS_PROD', false); - }); - - it('sets equipment', async () => { - let gear = { - weapon_healer_2: true, - weapon_wizard_1: true, - weapon_special_critical: true, - }; - - await user.post('/debug/modify-inventory', { - gear, - }); - - await user.sync(); - - expect(user.items.gear.owned).to.eql(gear); - }); - - it('sets special spells', async () => { - let special = { - shinySeed: 3, - }; - - await user.post('/debug/modify-inventory', { - special, - }); - - await user.sync(); - - expect(user.items.special).to.eql(special); - }); - - it('sets mounts', async () => { - let mounts = { - 'Orca-Base': true, - 'Mammoth-Base': true, - }; - - await user.post('/debug/modify-inventory', { - mounts, - }); - - await user.sync(); - - expect(user.items.mounts).to.eql(mounts); - }); - - it('sets eggs', async () => { - let eggs = { - Gryphon: 3, - Hedgehog: 7, - }; - - await user.post('/debug/modify-inventory', { - eggs, - }); - - await user.sync(); - - expect(user.items.eggs).to.eql(eggs); - }); - - it('sets hatching potions', async () => { - let hatchingPotions = { - White: 7, - Spooky: 2, - }; - - await user.post('/debug/modify-inventory', { - hatchingPotions, - }); - - await user.sync(); - - expect(user.items.hatchingPotions).to.eql(hatchingPotions); - }); - - it('sets food', async () => { - let food = { - Meat: 5, - Candy_Red: 7, - }; - - await user.post('/debug/modify-inventory', { - food, - }); - - await user.sync(); - - expect(user.items.food).to.eql(food); - }); - - it('sets quests', async () => { - let quests = { - whale: 5, - cheetah: 10, - }; - - await user.post('/debug/modify-inventory', { - quests, - }); - - await user.sync(); - - expect(user.items.quests).to.eql(quests); - }); - - it('returns error when not in production mode', async () => { - nconf.set('IS_PROD', true); - - await expect(user.post('/debug/modify-inventory')) - .eventually.be.rejected.and.to.deep.equal({ - code: 404, - error: 'NotFound', - message: 'Not found.', - }); - }); -}); diff --git a/test/api/v3/integration/debug/POST-debug_quest-progress.test.js b/test/api/v3/integration/debug/POST-debug_quest-progress.test.js deleted file mode 100644 index 3ae3d48882..0000000000 --- a/test/api/v3/integration/debug/POST-debug_quest-progress.test.js +++ /dev/null @@ -1,63 +0,0 @@ -import nconf from 'nconf'; -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /debug/quest-progress', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - afterEach(() => { - nconf.set('IS_PROD', false); - }); - - it('errors if user is not on a quest', async () => { - await expect(user.post('/debug/quest-progress')) - .to.eventually.be.rejected.and.to.deep.equal({ - code: 400, - error: 'BadRequest', - message: 'User is not on a valid quest.', - }); - }); - - it('increases boss quest progress by 1000', async () => { - await user.update({ - 'party.quest.key': 'whale', - }); - - await user.post('/debug/quest-progress'); - - await user.sync(); - - expect(user.party.quest.progress.up).to.eql(1000); - }); - - it('increases collection quest progress by 300 items', async () => { - await user.update({ - 'party.quest.key': 'evilsanta2', - }); - - await user.post('/debug/quest-progress'); - - await user.sync(); - - expect(user.party.quest.progress.collect).to.eql({ - tracks: 300, - branches: 300, - }); - }); - - it('returns error when not in production mode', async () => { - nconf.set('IS_PROD', true); - - await expect(user.post('/debug/quest-progress')) - .eventually.be.rejected.and.to.deep.equal({ - code: 404, - error: 'NotFound', - message: 'Not found.', - }); - }); -}); diff --git a/test/api/v3/integration/debug/POST-debug_set-cron.test.js b/test/api/v3/integration/debug/POST-debug_set-cron.test.js deleted file mode 100644 index c737831d95..0000000000 --- a/test/api/v3/integration/debug/POST-debug_set-cron.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import nconf from 'nconf'; -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /debug/set-cron', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - afterEach(() => { - nconf.set('IS_PROD', false); - }); - - it('sets last cron', async () => { - let newCron = new Date(2015, 11, 20); - - await user.post('/debug/set-cron', { - lastCron: newCron, - }); - - await user.sync(); - - expect(user.lastCron).to.eql(newCron); - }); - - it('returns error when not in production mode', async () => { - nconf.set('IS_PROD', true); - - await expect(user.post('/debug/set-cron')) - .eventually.be.rejected.and.to.deep.equal({ - code: 404, - error: 'NotFound', - message: 'Not found.', - }); - }); -}); diff --git a/test/api/v3/integration/emails/GET-email-unsubscribe.test.js b/test/api/v3/integration/emails/GET-email-unsubscribe.test.js deleted file mode 100644 index 1bd3a532fa..0000000000 --- a/test/api/v3/integration/emails/GET-email-unsubscribe.test.js +++ /dev/null @@ -1,68 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { encrypt } from '../../../../../website/server/libs/api-v3/encryption'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /email/unsubscribe', () => { - let user; - let testEmail = 'test@habitica.com'; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('return error when code is not provided', async () => { - await expect(user.get('/email/unsubscribe')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Invalid request parameters.', - }); - }); - - it('return error when user is not found', async () => { - let code = encrypt(JSON.stringify({ - _id: generateUUID(), - })); - - await expect(user.get(`/email/unsubscribe?code=${code}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userNotFound'), - }); - }); - - it('unsubscribes a user from email notifications', async () => { - let code = encrypt(JSON.stringify({ - _id: user._id, - email: user.email, - })); - - await user.get(`/email/unsubscribe?code=${code}`); - - let unsubscribedUser = await user.get('/user'); - - expect(unsubscribedUser.preferences.emailNotifications.unsubscribeFromAll).to.be.true; - }); - - it('unsubscribes an email from notifications', async () => { - let code = encrypt(JSON.stringify({ - email: testEmail, - })); - - let unsubscribedMessage = await user.get(`/email/unsubscribe?code=${code}`); - - expect(unsubscribedMessage).to.equal('

Unsubscribed successfully!

You won\'t receive any other email from Habitica.'); - }); - - it('returns okay when email is already unsubscribed', async () => { - let code = encrypt(JSON.stringify({ - email: testEmail, - })); - - let unsubscribedMessage = await user.get(`/email/unsubscribe?code=${code}`); - - expect(unsubscribedMessage).to.equal('

Unsubscribed successfully!

You won\'t receive any other email from Habitica.'); - }); -}); diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js deleted file mode 100644 index 8e1e9dfbfc..0000000000 --- a/test/api/v3/integration/groups/GET-groups.test.js +++ /dev/null @@ -1,115 +0,0 @@ -import { - generateUser, - resetHabiticaDB, - generateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { - TAVERN_ID, -} from '../../../../../website/server/models/group'; - -describe('GET /groups', () => { - let user; - const NUMBER_OF_PUBLIC_GUILDS = 3; // 2 + the tavern - const NUMBER_OF_PUBLIC_GUILDS_USER_IS_MEMBER = 1; - const NUMBER_OF_USERS_PRIVATE_GUILDS = 1; - const NUMBER_OF_GROUPS_USER_CAN_VIEW = 5; - - before(async () => { - await resetHabiticaDB(); - - let leader = await generateUser({ balance: 10 }); - user = await generateUser({balance: 4}); - - let publicGuildUserIsMemberOf = await generateGroup(leader, { - name: 'public guild - is member', - type: 'guild', - privacy: 'public', - }); - await leader.post(`/groups/${publicGuildUserIsMemberOf._id}/invite`, { uuids: [user._id]}); - await user.post(`/groups/${publicGuildUserIsMemberOf._id}/join`); - - await generateGroup(leader, { - name: 'public guild - is not member', - type: 'guild', - privacy: 'public', - }); - - let privateGuildUserIsMemberOf = await generateGroup(leader, { - name: 'private guild - is member', - type: 'guild', - privacy: 'private', - }); - await leader.post(`/groups/${privateGuildUserIsMemberOf._id}/invite`, { uuids: [user._id]}); - await user.post(`/groups/${privateGuildUserIsMemberOf._id}/join`); - - await generateGroup(leader, { - name: 'private guild - is not member', - type: 'guild', - privacy: 'private', - }); - - await generateGroup(leader, { - name: 'party - is not member', - type: 'party', - privacy: 'private', - }); - - await user.post('/groups', { - name: 'party - is member', - type: 'party', - privacy: 'private', - }); - }); - - it('returns error when no query passed in', async () => { - await expect(user.get('/groups')) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Invalid request parameters.', - }); - }); - - it('returns error when an invalid ?type query is passed', async () => { - await expect(user.get('/groups?type=invalid')) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('groupTypesRequired'), - }); - }); - - it('returns only the tavern when tavern passed in as query', async () => { - await expect(user.get('/groups?type=tavern')) - .to.eventually.have.a.lengthOf(1) - .and.to.have.deep.property('[0]') - .and.to.have.property('_id', TAVERN_ID); - }); - - it('returns only the user\'s party when party passed in as query', async () => { - await expect(user.get('/groups?type=party')) - .to.eventually.have.a.lengthOf(1) - .and.to.have.deep.property('[0]'); - }); - - it('returns all public guilds when publicGuilds passed in as query', async () => { - await expect(user.get('/groups?type=publicGuilds')) - .to.eventually.have.a.lengthOf(NUMBER_OF_PUBLIC_GUILDS); - }); - - it('returns all the user\'s guilds when guilds passed in as query', async () => { - await expect(user.get('/groups?type=guilds')) - .to.eventually.have.a.lengthOf(NUMBER_OF_PUBLIC_GUILDS_USER_IS_MEMBER + NUMBER_OF_USERS_PRIVATE_GUILDS); - }); - - it('returns all private guilds user is a part of when privateGuilds passed in as query', async () => { - await expect(user.get('/groups?type=privateGuilds')) - .to.eventually.have.a.lengthOf(NUMBER_OF_USERS_PRIVATE_GUILDS); - }); - - it('returns a list of groups user has access to', async () => { - await expect(user.get('/groups?type=privateGuilds,publicGuilds,party,tavern')) - .to.eventually.have.lengthOf(NUMBER_OF_GROUPS_USER_CAN_VIEW); - }); -}); diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js deleted file mode 100644 index 1b7c172d94..0000000000 --- a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import { - generateUser, - generateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /groups/:groupId/invites', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('validates optional req.query.lastId to be an UUID', async () => { - await expect(user.get('/groups/groupId/invites?lastId=invalidUUID')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('fails if group doesn\'t exists', async () => { - await expect(user.get(`/groups/${generateUUID()}/invites`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('fails if user doesn\'t have access to the group', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let anotherUser = await generateUser(); - await expect(anotherUser.get(`/groups/${group._id}/invites`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('works when passing party as req.params.groupId', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let invited = await generateUser(); - await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); - let res = await user.get('/groups/party/invites'); - - expect(res).to.be.an('array'); - expect(res.length).to.equal(1); - expect(res[0]).to.eql({ - _id: invited._id, - id: invited._id, - profile: {name: invited.profile.name}, - }); - }); - - it('populates only some fields', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let invited = await generateUser(); - await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); - let res = await user.get('/groups/party/invites'); - expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); - expect(res[0].profile).to.have.all.keys(['name']); - }); - - it('returns only first 30 invites', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let invitesToGenerate = []; - for (let i = 0; i < 31; i++) { - invitesToGenerate.push(generateUser()); - } - let generatedInvites = await Promise.all(invitesToGenerate); - await user.post(`/groups/${group._id}/invite`, {uuids: generatedInvites.map(invite => invite._id)}); - - let res = await user.get('/groups/party/invites'); - expect(res.length).to.equal(30); - res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'id', 'profile']); - expect(member.profile).to.have.all.keys(['name']); - }); - }); - - it('supports using req.query.lastId to get more invites', async () => { - let leader = await generateUser({balance: 4}); - let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); - - let invitesToGenerate = []; - for (let i = 0; i < 32; i++) { - invitesToGenerate.push(generateUser()); - } - let generatedInvites = await Promise.all(invitesToGenerate); // Group has 32 invites - let expectedIds = generatedInvites.map(generatedInvite => generatedInvite._id); - await user.post(`/groups/${group._id}/invite`, {uuids: expectedIds}); - - let res = await user.get(`/groups/${group._id}/invites`); - expect(res.length).to.equal(30); - let res2 = await user.get(`/groups/${group._id}/invites?lastId=${res[res.length - 1]._id}`); - expect(res2.length).to.equal(2); - - let resIds = res.concat(res2).map(invite => invite._id); - expect(resIds).to.eql(expectedIds.sort()); - }); -}); diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js deleted file mode 100644 index 857f6fb863..0000000000 --- a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js +++ /dev/null @@ -1,96 +0,0 @@ -import { - generateUser, - generateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /groups/:groupId/members', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('validates optional req.query.lastId to be an UUID', async () => { - await expect(user.get('/groups/groupId/members?lastId=invalidUUID')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('fails if group doesn\'t exists', async () => { - await expect(user.get(`/groups/${generateUUID()}/members`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('fails if user doesn\'t have access to the group', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let anotherUser = await generateUser(); - await expect(anotherUser.get(`/groups/${group._id}/members`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('works when passing party as req.params.groupId', async () => { - await generateGroup(user, {type: 'party', name: generateUUID()}); - let res = await user.get('/groups/party/members'); - expect(res).to.be.an('array'); - expect(res.length).to.equal(1); - expect(res[0]).to.eql({ - _id: user._id, - id: user._id, - profile: {name: user.profile.name}, - }); - }); - - it('populates only some fields', async () => { - await generateGroup(user, {type: 'party', name: generateUUID()}); - let res = await user.get('/groups/party/members'); - expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); - expect(res[0].profile).to.have.all.keys(['name']); - }); - - it('returns only first 30 members', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - - let usersToGenerate = []; - for (let i = 0; i < 31; i++) { - usersToGenerate.push(generateUser({party: {_id: group._id}})); - } - await Promise.all(usersToGenerate); - - let res = await user.get('/groups/party/members'); - expect(res.length).to.equal(30); - res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'id', 'profile']); - expect(member.profile).to.have.all.keys(['name']); - }); - }); - - it('supports using req.query.lastId to get more members', async () => { - let leader = await generateUser({balance: 4}); - let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); - - let usersToGenerate = []; - for (let i = 0; i < 57; i++) { - usersToGenerate.push(generateUser({guilds: [group._id]})); - } - let generatedUsers = await Promise.all(usersToGenerate); // Group has 59 members (1 is the leader) - let expectedIds = [leader._id].concat(generatedUsers.map(generatedUser => generatedUser._id)); - - let res = await user.get(`/groups/${group._id}/members`); - expect(res.length).to.equal(30); - let res2 = await user.get(`/groups/${group._id}/members?lastId=${res[res.length - 1]._id}`); - expect(res2.length).to.equal(28); - - let resIds = res.concat(res2).map(member => member._id); - expect(resIds).to.eql(expectedIds.sort()); - }); -}); diff --git a/test/api/v3/integration/groups/GET-groups_id.test.js b/test/api/v3/integration/groups/GET-groups_id.test.js deleted file mode 100644 index 2ecb53a33f..0000000000 --- a/test/api/v3/integration/groups/GET-groups_id.test.js +++ /dev/null @@ -1,298 +0,0 @@ -import { - generateUser, - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; - -import { - each, -} from 'lodash'; - -describe('GET /groups/:id', () => { - let typesOfGroups = {}; - typesOfGroups['public guild'] = { type: 'guild', privacy: 'public' }; - typesOfGroups['private guild'] = { type: 'guild', privacy: 'private' }; - typesOfGroups.party = { type: 'party', privacy: 'private' }; - - each(typesOfGroups, (groupDetails, groupType) => { - context(`Member of a ${groupType}`, () => { - let leader, member, createdGroup; - - before(async () => { - let groupData = await createAndPopulateGroup({ - members: 30, - groupDetails, - }); - - leader = groupData.groupLeader; - member = groupData.members[0]; - createdGroup = groupData.group; - }); - - it('returns the group object', async () => { - let group = await member.get(`/groups/${createdGroup._id}`); - - expect(group._id).to.eql(createdGroup._id); - expect(group.name).to.eql(createdGroup.name); - expect(group.type).to.eql(createdGroup.type); - expect(group.privacy).to.eql(createdGroup.privacy); - }); - - it('transforms leader id to leader object', async () => { - let group = await member.get(`/groups/${createdGroup._id}`); - - expect(group.leader._id).to.eql(leader._id); - expect(group.leader.profile.name).to.eql(leader.profile.name); - }); - }); - }); - - context('Non-member of a public guild', () => { - let nonMember, createdGroup; - - before(async () => { - let groupData = await createAndPopulateGroup({ - members: 1, - groupDetails: { - name: 'test guild', - type: 'guild', - privacy: 'public', - }, - }); - - createdGroup = groupData.group; - nonMember = await generateUser(); - }); - - it('returns the group object for a non-member', async () => { - let group = await nonMember.get(`/groups/${createdGroup._id}`); - - expect(group._id).to.eql(createdGroup._id); - expect(group.name).to.eql(createdGroup.name); - expect(group.type).to.eql(createdGroup.type); - expect(group.privacy).to.eql(createdGroup.privacy); - }); - }); - - context('Non-member of a private guild', () => { - let nonMember, createdGroup; - - before(async () => { - let groupData = await createAndPopulateGroup({ - members: 1, - groupDetails: { - name: 'test guild', - type: 'guild', - privacy: 'private', - }, - }); - - createdGroup = groupData.group; - nonMember = await generateUser(); - }); - - it('does not return the group object for a non-member', async () => { - await expect(nonMember.get(`/groups/${createdGroup._id}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - }); - - context('Non-member of a party', () => { - let nonMember, createdGroup; - - before(async () => { - let groupData = await createAndPopulateGroup({ - members: 1, - groupDetails: { - name: 'test party', - type: 'party', - privacy: 'private', - }, - }); - - createdGroup = groupData.group; - nonMember = await generateUser(); - }); - - it('does not return the group object for a non-member', async () => { - await expect(nonMember.get(`/groups/${createdGroup._id}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - }); - - context('Member of a party', () => { - let member, createdGroup; - - before(async () => { - let groupData = await createAndPopulateGroup({ - members: 1, - groupDetails: { - name: 'test party', - type: 'party', - privacy: 'private', - }, - }); - - createdGroup = groupData.group; - member = groupData.members[0]; - }); - - it('returns the user\'s party if an id of "party" is passed in', async () => { - let group = await member.get('/groups/party'); - - expect(group._id).to.eql(createdGroup._id); - expect(group.name).to.eql(createdGroup.name); - expect(group.type).to.eql(createdGroup.type); - expect(group.privacy).to.eql(createdGroup.privacy); - }); - }); - - context('Non-existent group', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns error if group does not exist', async () => { - await expect(user.get('/groups/group-that-does-not-exist')) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - }); - - context('Flagged messages', () => { - let group; - - let chat1 = { - id: 'chat1', - text: 'chat 1', - flags: {}, - }; - - let chat2 = { - id: 'chat2', - text: 'chat 2', - flags: {}, - flagCount: 0, - }; - - let chat3 = { - id: 'chat3', - text: 'chat 3', - flags: { - 'user-id': true, - }, - flagCount: 1, - }; - - let chat4 = { - id: 'chat4', - text: 'chat 4', - flags: { - 'user-id': true, - 'other-user-id': true, - }, - flagCount: 2, - }; - - let chat5 = { - id: 'chat5', - text: 'chat 5', - flags: { - 'user-id': true, - 'other-user-id': true, - 'yet-another-user-id': true, - }, - flagCount: 3, - }; - - beforeEach(async () => { - let groupData = await createAndPopulateGroup({ - groupDetails: { - name: 'test guild', - type: 'guild', - privacy: 'public', - chat: [ - chat1, - chat2, - chat3, - chat4, - chat5, - ], - }, - }); - - group = groupData.group; - - await group.addChat([chat1, chat2, chat3, chat4, chat5]); - }); - - context('non-admin', () => { - let nonAdmin; - - beforeEach(async () => { - nonAdmin = await generateUser(); - }); - - it('does not include messages with a flag count of 2 or greater', async () => { - let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`); - - expect(fetchedGroup.chat).to.have.lengthOf(3); - expect(fetchedGroup.chat[0].id).to.eql(chat1.id); - expect(fetchedGroup.chat[1].id).to.eql(chat2.id); - expect(fetchedGroup.chat[2].id).to.eql(chat3.id); - }); - - it('does not include user ids in flags object', async () => { - let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`); - let chatWithOneFlag = fetchedGroup.chat[2]; - - expect(chatWithOneFlag.id).to.eql(chat3.id); - expect(chat3.flags).to.eql({ 'user-id': true }); - expect(chatWithOneFlag.flags).to.eql({}); - }); - }); - - context('admin', () => { - let admin; - - beforeEach(async () => { - admin = await generateUser({ - 'contributor.admin': true, - }); - }); - - it('includes all messages', async () => { - let fetchedGroup = await admin.get(`/groups/${group._id}`); - - expect(fetchedGroup.chat).to.have.lengthOf(5); - expect(fetchedGroup.chat[0].id).to.eql(chat1.id); - expect(fetchedGroup.chat[1].id).to.eql(chat2.id); - expect(fetchedGroup.chat[2].id).to.eql(chat3.id); - expect(fetchedGroup.chat[3].id).to.eql(chat4.id); - expect(fetchedGroup.chat[4].id).to.eql(chat5.id); - }); - - it('includes user ids in flags object', async () => { - let fetchedGroup = await admin.get(`/groups/${group._id}`); - let chatWithOneFlag = fetchedGroup.chat[2]; - - expect(chatWithOneFlag.id).to.eql(chat3.id); - expect(chat3.flags).to.eql({ 'user-id': true }); - expect(chatWithOneFlag.flags).to.eql(chat3.flags); - }); - }); - }); -}); diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js deleted file mode 100644 index ad8f7a6c92..0000000000 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ /dev/null @@ -1,228 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /group', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ balance: 10 }); - }); - - context('All Groups', () => { - it('it returns validation error when type is not provided', async () => { - await expect( - user.post('/groups', { name: 'Test Group Without Type' }) - ).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Group validation failed', - }); - }); - - it('it returns validation error when type is not supported', async () => { - await expect( - user.post('/groups', { name: 'Group with unsupported type', type: 'foo' }) - ).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Group validation failed', - }); - }); - - it('sets the group leader to the user who created the group', async () => { - let group = await user.post('/groups', { - name: 'Test Public Guild', - type: 'guild', - }); - - expect(group.leader).to.eql({ - _id: user._id, - profile: { - name: user.profile.name, - }, - }); - }); - }); - - context('Guilds', () => { - it('returns an error when a user with insufficient funds attempts to create a guild', async () => { - await user.update({ balance: 0 }); - - await expect( - user.post('/groups', { - name: 'Test Public Guild', - type: 'guild', - }) - ).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageInsufficientGems'), - }); - }); - - it('adds guild to user\'s list of guilds', async () => { - let guild = await user.post('/groups', { - name: 'some guild', - type: 'guild', - privacy: 'public', - }); - - let updatedUser = await user.get('/user'); - - expect(updatedUser.guilds).to.include(guild._id); - }); - - context('public guild', () => { - it('creates a group', async () => { - let groupName = 'Test Public Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - - let publicGuild = await user.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - - expect(publicGuild._id).to.exist; - expect(publicGuild.name).to.equal(groupName); - expect(publicGuild.type).to.equal(groupType); - expect(publicGuild.memberCount).to.equal(1); - expect(publicGuild.privacy).to.equal(groupPrivacy); - expect(publicGuild.leader).to.eql({ - _id: user._id, - profile: { - name: user.profile.name, - }, - }); - }); - }); - - context('private guild', () => { - let groupName = 'Test Private Guild'; - let groupType = 'guild'; - let groupPrivacy = 'private'; - - it('creates a group', async () => { - let privateGuild = await user.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - - expect(privateGuild._id).to.exist; - expect(privateGuild.name).to.equal(groupName); - expect(privateGuild.type).to.equal(groupType); - expect(privateGuild.memberCount).to.equal(1); - expect(privateGuild.privacy).to.equal(groupPrivacy); - expect(privateGuild.leader).to.eql({ - _id: user._id, - profile: { - name: user.profile.name, - }, - }); - }); - - it('deducts gems from user and adds them to guild bank', async () => { - let privateGuild = await user.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - - expect(privateGuild.balance).to.eql(1); - - let updatedUser = await user.get('/user'); - - expect(updatedUser.balance).to.eql(user.balance - 1); - }); - }); - }); - - context('Parties', () => { - let partyName = 'Test Party'; - let partyType = 'party'; - - it('creates a party', async () => { - let party = await user.post('/groups', { - name: partyName, - type: partyType, - }); - - expect(party._id).to.exist; - expect(party.name).to.equal(partyName); - expect(party.type).to.equal(partyType); - expect(party.memberCount).to.equal(1); - expect(party.leader).to.eql({ - _id: user._id, - profile: { - name: user.profile.name, - }, - }); - }); - - it('does not require gems to create a party', async () => { - await user.update({ balance: 0 }); - - let party = await user.post('/groups', { - name: partyName, - type: partyType, - }); - - expect(party._id).to.exist; - - let updatedUser = await user.get('/user'); - - expect(updatedUser.balance).to.eql(user.balance); - }); - - it('sets party id on user object', async () => { - let party = await user.post('/groups', { - name: partyName, - type: partyType, - }); - - let updatedUser = await user.get('/user'); - - expect(updatedUser.party._id).to.eql(party._id); - }); - - it('does not award Party Up achievement to solo partier', async () => { - await user.post('/groups', { - name: partyName, - type: partyType, - }); - - let updatedUser = await user.get('/user'); - - expect(updatedUser.achievements.partyUp).to.not.eql(true); - }); - - it('prevents user in a party from creating another party', async () => { - await user.post('/groups', { - name: partyName, - type: partyType, - }); - - await expect(user.post('/groups')).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupAlreadyInParty'), - }); - }); - - it('prevents creating a public party', async () => { - await expect(user.post('/groups', { - name: partyName, - type: partyType, - privacy: 'public', - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('partyMustbePrivate'), - }); - }); - }); -}); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js deleted file mode 100644 index f47a734e89..0000000000 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ /dev/null @@ -1,286 +0,0 @@ -import { - generateUser, - createAndPopulateGroup, - checkExistence, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /group/:groupId/join', () => { - const PET_QUEST = 'whale'; - - it('returns error when groupId is not for a valid group', async () => { - let joiningUser = await generateUser(); - - await expect(joiningUser.post(`/groups/${generateUUID()}/join`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - context('Joining a public guild', () => { - let user, joiningUser, publicGuild; - - beforeEach(async () => { - let {group, groupLeader} = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Guild', - type: 'guild', - privacy: 'public', - }, - }); - - publicGuild = group; - user = groupLeader; - joiningUser = await generateUser(); - }); - - it('allows non-invited users to join public guilds', async () => { - let res = await joiningUser.post(`/groups/${publicGuild._id}/join`); - - await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').to.include(publicGuild._id); - expect(res.leader._id).to.eql(user._id); - expect(res.leader.profile.name).to.eql(user.profile.name); - }); - - it('returns an error is user was already a member', async () => { - await joiningUser.post(`/groups/${publicGuild._id}/join`); - await expect(joiningUser.post(`/groups/${publicGuild._id}/join`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('userAlreadyInGroup'), - }); - }); - - it('promotes joining member in a public empty guild to leader', async () => { - await user.post(`/groups/${publicGuild._id}/leave`); - - await joiningUser.post(`/groups/${publicGuild._id}/join`); - - await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.deep.property('leader._id', joiningUser._id); - }); - - it('increments memberCount when joining guilds', async () => { - let oldMemberCount = publicGuild.memberCount; - - await joiningUser.post(`/groups/${publicGuild._id}/join`); - - await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); - }); - }); - - context('Joining a private guild', () => { - let user, invitedUser, guild; - - beforeEach(async () => { - let { group, groupLeader, invitees } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Guild', - type: 'guild', - privacy: 'private', - }, - invites: 1, - }); - - guild = group; - user = groupLeader; - invitedUser = invitees[0]; - }); - - it('returns error when user is not invited to private guild', async () => { - let userWithoutInvite = await generateUser(); - - await expect(userWithoutInvite.post(`/groups/${guild._id}/join`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupRequiresInvite'), - }); - }); - - context('User is invited', () => { - it('allows invited user to join private guilds', async () => { - await invitedUser.post(`/groups/${guild._id}/join`); - - await expect(invitedUser.get('/user')).to.eventually.have.property('guilds').to.include(guild._id); - }); - - it('clears invitation from user when joining guilds', async () => { - await invitedUser.post(`/groups/${guild._id}/join`); - - await expect(invitedUser.get('/user')) - .to.eventually.have.deep.property('invitations.guilds') - .to.not.include({id: guild._id}); - }); - - it('increments memberCount when joining guilds', async () => { - let oldMemberCount = guild.memberCount; - - await invitedUser.post(`/groups/${guild._id}/join`); - - await expect(invitedUser.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); - }); - - it('does not give basilist quest to inviter when joining a guild', async () => { - await invitedUser.post(`/groups/${guild._id}/join`); - - await expect(user.get('/user')).to.eventually.not.have.deep.property('items.quests.basilist'); - }); - - it('does not increment basilist quest count to inviter with basilist when joining a guild', async () => { - await user.update({ 'items.quests.basilist': 1 }); - - await invitedUser.post(`/groups/${guild._id}/join`); - - await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); - }); - }); - }); - - context('Joining a party', () => { - let user, invitedUser, party; - - beforeEach(async () => { - let { group, groupLeader, invitees } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Party', - type: 'party', - }, - members: 2, - invites: 1, - }); - - party = group; - user = groupLeader; - invitedUser = invitees[0]; - }); - - it('returns error when user is not invited to party', async () => { - let userWithoutInvite = await generateUser(); - - await expect(userWithoutInvite.post(`/groups/${party._id}/join`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupRequiresInvite'), - }); - }); - - context('User is invited', () => { - it('allows invited user to join party', async () => { - await invitedUser.post(`/groups/${party._id}/join`); - - await expect(invitedUser.get('/user')).to.eventually.have.deep.property('party._id', party._id); - }); - - it('clears invitation from user when joining party', async () => { - await invitedUser.post(`/groups/${party._id}/join`); - - await expect(invitedUser.get('/user')).to.eventually.not.have.deep.property('invitations.party.id'); - }); - - it('increments memberCount when joining party', async () => { - let oldMemberCount = party.memberCount; - - await invitedUser.post(`/groups/${party._id}/join`); - - await expect(invitedUser.get(`/groups/${party._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); - }); - - it('gives basilist quest item to the inviter when joining a party', async () => { - await invitedUser.post(`/groups/${party._id}/join`); - - await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); - }); - - it('increments basilist quest item count to inviter when joining a party', async () => { - await user.update({'items.quests.basilist': 1 }); - - await invitedUser.post(`/groups/${party._id}/join`); - - await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); - }); - - it('deletes previous party where the user was the only member', async () => { - let userToInvite = await generateUser(); - let oldParty = await userToInvite.post('/groups', { // add user to a party - name: 'Another Test Party', - type: 'party', - }); - - await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(true); - await user.post(`/groups/${party._id}/invite`, { - uuids: [userToInvite._id], - }); - await userToInvite.post(`/groups/${party._id}/join`); - - await expect(user.get('/user')).to.eventually.have.deep.property('party._id', party._id); - await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(false); - }); - - it('invites joining member to active quest', async () => { - await user.update({ - [`items.quests.${PET_QUEST}`]: 1, - }); - await user.post(`/groups/${party._id}/quests/invite/${PET_QUEST}`); - - await invitedUser.post(`/groups/${party._id}/join`); - - await invitedUser.sync(); - await party.sync(); - - expect(invitedUser).to.have.deep.property('party.quest.RSVPNeeded', true); - expect(invitedUser).to.have.deep.property('party.quest.key', party.quest.key); - expect(party.quest.members[invitedUser._id]).to.be.null; - }); - }); - }); - - context('Party incentive achievements', () => { - let leader, member, party; - - beforeEach(async () => { - leader = await generateUser(); - member = await generateUser(); - party = await leader.post('/groups', { - name: 'Testing Party', - type: 'party', - }); - await leader.post(`/groups/${party._id}/invite`, { - uuids: [member._id], - }); - await member.post(`/groups/${party._id}/join`); - }); - - it('awards Party Up achievement to party of size 2', async () => { - await member.sync(); - await leader.sync(); - - expect(member).to.have.deep.property('achievements.partyUp', true); - expect(leader).to.have.deep.property('achievements.partyUp', true); - }); - - it('does not award Party On achievement to party of size 2', async () => { - await member.sync(); - await leader.sync(); - - expect(member).to.not.have.deep.property('achievements.partyOn'); - expect(leader).to.not.have.deep.property('achievements.partyOn'); - }); - - it('awards Party On achievement to party of size 4', async () => { - let addlMemberOne = await generateUser(); - let addlMemberTwo = await generateUser(); - await leader.post(`/groups/${party._id}/invite`, { - uuids: [addlMemberOne._id, addlMemberTwo._id], - }); - await addlMemberOne.post(`/groups/${party._id}/join`); - await addlMemberTwo.post(`/groups/${party._id}/join`); - - await member.sync(); - await leader.sync(); - - expect(member).to.have.deep.property('achievements.partyOn', true); - expect(leader).to.have.deep.property('achievements.partyOn', true); - }); - }); -}); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_leave.js b/test/api/v3/integration/groups/POST-groups_groupId_leave.js deleted file mode 100644 index 3e13634bd8..0000000000 --- a/test/api/v3/integration/groups/POST-groups_groupId_leave.js +++ /dev/null @@ -1,210 +0,0 @@ -import { - generateChallenge, - checkExistence, - createAndPopulateGroup, - sleep, - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { - each, -} from 'lodash'; - -describe('POST /groups/:groupId/leave', () => { - let typesOfGroups = { - 'public guild': { type: 'guild', privacy: 'public' }, - 'private guild': { type: 'guild', privacy: 'private' }, - party: { type: 'party', privacy: 'private' }, - }; - - each(typesOfGroups, (groupDetails, groupType) => { - context(`Leaving a ${groupType}`, () => { - let groupToLeave; - let leader; - let member; - let memberCount; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails, - members: 1, - }); - - groupToLeave = group; - leader = groupLeader; - member = members[0]; - memberCount = group.memberCount; - }); - - it('prevents non members from leaving', async () => { - let user = await generateUser(); - await expect(user.post(`/groups/${groupToLeave._id}/leave`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it(`lets user leave a ${groupType}`, async () => { - await member.post(`/groups/${groupToLeave._id}/leave`); - - let userThatLeftGroup = await member.get('/user'); - - expect(userThatLeftGroup.guilds).to.be.empty; - expect(userThatLeftGroup.party._id).to.not.exist; - await groupToLeave.sync(); - expect(groupToLeave.memberCount).to.equal(memberCount - 1); - }); - - it(`sets a new group leader when leader leaves a ${groupType}`, async () => { - await leader.post(`/groups/${groupToLeave._id}/leave`); - - await groupToLeave.sync(); - expect(groupToLeave.memberCount).to.equal(memberCount - 1); - expect(groupToLeave.leader).to.equal(member._id); - }); - - context('With challenges', () => { - let challenge; - - beforeEach(async () => { - challenge = await generateChallenge(leader, groupToLeave); - - await leader.post(`/tasks/challenge/${challenge._id}`, { - text: 'test habit', - type: 'habit', - }); - - await sleep(0.5); - }); - - it('removes all challenge tasks when keep parameter is set to remove', async () => { - await leader.post(`/groups/${groupToLeave._id}/leave?keep=remove-all`); - - let userWithoutChallengeTasks = await leader.get('/user'); - - expect(userWithoutChallengeTasks.challenges).to.not.include(challenge._id); - expect(userWithoutChallengeTasks.tasksOrder.habits).to.be.empty; - }); - - it('keeps all challenge tasks when keep parameter is not set', async () => { - await leader.post(`/groups/${groupToLeave._id}/leave`); - - let userWithChallengeTasks = await leader.get('/user'); - - expect(userWithChallengeTasks.challenges).to.not.include(challenge._id); - // @TODO find elegant way to assert against the task existing - expect(userWithChallengeTasks.tasksOrder.habits).to.not.be.empty; - }); - }); - - it('prevents quest leader from leaving a groupToLeave'); - it('prevents a user from leaving during an active quest'); - }); - }); - - context('Leaving a group as the last member', () => { - context('private guild', () => { - let privateGuild; - let leader; - let invitedUser; - - beforeEach(async () => { - let { group, groupLeader, invitees } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Private Guild', - type: 'guild', - }, - invites: 1, - }); - - privateGuild = group; - leader = groupLeader; - invitedUser = invitees[0]; - }); - - it('removes a group when the last member leaves', async () => { - await leader.post(`/groups/${privateGuild._id}/leave`); - - await expect(checkExistence('groups', privateGuild._id)).to.eventually.equal(false); - }); - - it('removes invitations when the last member leaves', async () => { - await leader.post(`/groups/${privateGuild._id}/leave`); - - let userWithoutInvitation = await invitedUser.get('/user'); - - expect(userWithoutInvitation.invitations.guilds).to.be.empty; - }); - }); - - context('public guild', () => { - let publicGuild; - let leader; - let invitedUser; - - beforeEach(async () => { - let { group, groupLeader, invitees } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Public Guild', - type: 'guild', - privacy: 'public', - }, - invites: 1, - }); - - publicGuild = group; - leader = groupLeader; - invitedUser = invitees[0]; - }); - - it('keeps the group when the last member leaves', async () => { - await leader.post(`/groups/${publicGuild._id}/leave`); - - await expect(checkExistence('groups', publicGuild._id)).to.eventually.equal(true); - }); - - it('keeps the invitations when the last member leaves a public guild', async () => { - await leader.post(`/groups/${publicGuild._id}/leave`); - - let userWithoutInvitation = await invitedUser.get('/user'); - - expect(userWithoutInvitation.invitations.guilds).to.not.be.empty; - }); - }); - - context('party', () => { - let party; - let leader; - let invitedUser; - - beforeEach(async () => { - let { group, groupLeader, invitees } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Party', - type: 'party', - }, - invites: 1, - }); - - party = group; - leader = groupLeader; - invitedUser = invitees[0]; - }); - - it('removes a group when the last member leaves a party', async () => { - await leader.post(`/groups/${party._id}/leave`); - - await expect(checkExistence('party', party._id)).to.eventually.equal(false); - }); - - it('removes invitations when the last member leaves a party', async () => { - await leader.post(`/groups/${party._id}/leave`); - - let userWithoutInvitation = await invitedUser.get('/user'); - - expect(userWithoutInvitation.invitations.party).to.be.empty; - }); - }); - }); -}); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_reject.test.js b/test/api/v3/integration/groups/POST-groups_groupId_reject.test.js deleted file mode 100644 index 18f83fe9c9..0000000000 --- a/test/api/v3/integration/groups/POST-groups_groupId_reject.test.js +++ /dev/null @@ -1,113 +0,0 @@ -import { - generateUser, - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /group/:groupId/reject-invite', () => { - context('Rejecting a public guild invite', () => { - let publicGuild, invitedUser; - - beforeEach(async () => { - let {group, invitees} = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Guild', - type: 'guild', - privacy: 'public', - }, - invites: 1, - }); - - publicGuild = group; - invitedUser = invitees[0]; - }); - - it('returns error when user is not invited', async () => { - let userWithoutInvite = await generateUser(); - - await expect(userWithoutInvite.post(`/groups/${publicGuild._id}/reject-invite`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupRequiresInvite'), - }); - }); - - it('clears invitation from user', async () => { - await invitedUser.post(`/groups/${publicGuild._id}/reject-invite`); - - await expect(invitedUser.get('/user')) - .to.eventually.have.deep.property('invitations.guilds') - .to.not.include({id: publicGuild._id}); - }); - }); - - context('Rejecting a private guild invite', () => { - let invitedUser, guild; - - beforeEach(async () => { - let { group, invitees } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Guild', - type: 'guild', - privacy: 'private', - }, - invites: 1, - }); - - guild = group; - invitedUser = invitees[0]; - }); - - it('returns error when user is not invited', async () => { - let userWithoutInvite = await generateUser(); - - await expect(userWithoutInvite.post(`/groups/${guild._id}/reject-invite`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupRequiresInvite'), - }); - }); - - it('clears invitation from user', async () => { - await invitedUser.post(`/groups/${guild._id}/reject-invite`); - - await expect(invitedUser.get('/user')) - .to.eventually.have.deep.property('invitations.guilds') - .to.not.include({id: guild._id}); - }); - }); - - context('Rejecting a party invite', () => { - let invitedUser, party; - - beforeEach(async () => { - let { group, invitees } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Party', - type: 'party', - }, - members: 2, - invites: 1, - }); - - party = group; - invitedUser = invitees[0]; - }); - - it('returns error when user is not invited', async () => { - let userWithoutInvite = await generateUser(); - - await expect(userWithoutInvite.post(`/groups/${party._id}/reject-invite`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupRequiresInvite'), - }); - }); - - it('clears invitation from user', async () => { - await invitedUser.post(`/groups/${party._id}/reject-invite`); - - await expect(invitedUser.get('/user')).to.eventually.not.have.deep.property('invitations.party.id'); - }); - }); -}); diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js deleted file mode 100644 index 0ebde39361..0000000000 --- a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js +++ /dev/null @@ -1,130 +0,0 @@ -import { - generateUser, - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /groups/:groupId/removeMember/:memberId', () => { - let leader; - let invitedUser; - let guild; - let member; - let member2; - - beforeEach(async () => { - let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Guild', - type: 'guild', - privacy: 'private', - }, - invites: 1, - members: 2, - }); - - guild = group; - leader = groupLeader; - invitedUser = invitees[0]; - member = members[0]; - member2 = members[1]; - }); - - context('All Groups', () => { - it('returns an error when user is not member of the group', async () => { - let nonMember = await generateUser(); - - expect(nonMember.post(`/groups/${guild._id}/removeMember/${member._id}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - type: 'NotAuthorized', - message: t('onlyLeaderCanRemoveMember'), - }); - }); - - it('returns an error when user is a non-leader member of a group', async () => { - expect(member2.post(`/groups/${guild._id}/removeMember/${member._id}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - type: 'NotAuthorized', - message: t('onlyLeaderCanRemoveMember'), - }); - }); - - it('does not allow leader to remove themselves', async () => { - expect(leader.post(`/groups/${guild._id}/removeMember/${leader._id}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - text: t('messageGroupCannotRemoveSelf'), - }); - }); - }); - - context('Guilds', () => { - it('can remove other members', async () => { - await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); - let memberRemoved = await member.get('/user'); - - expect(memberRemoved.guilds.indexOf(guild._id)).eql(-1); - }); - - it('updates memberCount', async () => { - let oldMemberCount = guild.memberCount; - await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); - await expect(leader.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount - 1); - }); - - it('can remove other invites', async () => { - await leader.post(`/groups/${guild._id}/removeMember/${invitedUser._id}`); - - let invitedUserWithoutInvite = await invitedUser.get('/user'); - - expect(_.findIndex(invitedUserWithoutInvite.invitations.guilds, {id: guild._id})).eql(-1); - }); - }); - - context('Party', () => { - let party; - let partyleader; - let partyInvitedUser; - let partyMember; - - beforeEach(async () => { - let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Party', - type: 'party', - privacy: 'private', - }, - invites: 1, - members: 1, - }); - - party = group; - partyleader = groupLeader; - partyInvitedUser = invitees[0]; - partyMember = members[0]; - }); - - it('can remove other members', async () => { - await partyleader.post(`/groups/${party._id}/removeMember/${partyMember._id}`); - - let memberRemoved = await partyMember.get('/user'); - - expect(memberRemoved.party._id).eql(undefined); - }); - - it('updates memberCount', async () => { - let oldMemberCount = party.memberCount; - await partyleader.post(`/groups/${party._id}/removeMember/${partyMember._id}`); - await expect(partyleader.get(`/groups/${party._id}`)).to.eventually.have.property('memberCount', oldMemberCount - 1); - }); - - it('can remove other invites', async () => { - await partyleader.post(`/groups/${party._id}/removeMember/${partyInvitedUser._id}`); - - let invitedUserWithoutInvite = await partyInvitedUser.get('/user'); - - expect(_.findIndex(invitedUserWithoutInvite.invitations.party, {id: party._id})).eql(-1); - }); - }); -}); diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js deleted file mode 100644 index 92328463b2..0000000000 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ /dev/null @@ -1,332 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -const INVITES_LIMIT = 100; - -describe('Post /groups/:groupId/invite', () => { - let inviter; - let group; - let groupName = 'Test Public Guild'; - - beforeEach(async () => { - inviter = await generateUser({balance: 1}); - group = await inviter.post('/groups', { - name: groupName, - type: 'guild', - }); - }); - - describe('user id invites', () => { - it('returns an error when invited user is not found', async () => { - let fakeID = generateUUID(); - - await expect(inviter.post(`/groups/${group._id}/invite`, { - uuids: [fakeID], - })) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userWithIDNotFound', {userId: fakeID}), - }); - }); - - it('returns an error when uuids is not an array', async () => { - let fakeID = generateUUID(); - - await expect(inviter.post(`/groups/${group._id}/invite`, { - uuids: {fakeID}, - })) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('uuidsMustBeAnArray'), - }); - }); - - it('returns empty when uuids is empty', async () => { - await expect(inviter.post(`/groups/${group._id}/invite`, { - uuids: [], - })) - .to.eventually.be.empty; - }); - - it('returns an error when there are more than INVITES_LIMIT uuids', async () => { - let uuids = []; - - for (let i = 0; i < 101; i += 1) { - uuids.push(generateUUID()); - } - - await expect(inviter.post(`/groups/${group._id}/invite`, { - uuids, - })) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), - }); - }); - - it('invites a user to a group by uuid', async () => { - let userToInvite = await generateUser(); - - await expect(inviter.post(`/groups/${group._id}/invite`, { - uuids: [userToInvite._id], - })).to.eventually.deep.equal([{ - id: group._id, - name: groupName, - inviter: inviter._id, - }]); - - await expect(userToInvite.get('/user')) - .to.eventually.have.deep.property('invitations.guilds[0].id', group._id); - }); - - it('invites multiple users to a group by uuid', async () => { - let userToInvite = await generateUser(); - let userToInvite2 = await generateUser(); - - await expect(inviter.post(`/groups/${group._id}/invite`, { - uuids: [userToInvite._id, userToInvite2._id], - })).to.eventually.deep.equal([ - { - id: group._id, - name: groupName, - inviter: inviter._id, - }, - { - id: group._id, - name: groupName, - inviter: inviter._id, - }, - ]); - - await expect(userToInvite.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); - await expect(userToInvite2.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); - }); - - it('returns an error when inviting multiple users and a user is not found', async () => { - let userToInvite = await generateUser(); - let fakeID = generateUUID(); - - await expect(inviter.post(`/groups/${group._id}/invite`, { - uuids: [userToInvite._id, fakeID], - })) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userWithIDNotFound', {userId: fakeID}), - }); - }); - }); - - describe('email invites', () => { - let testInvite = {name: 'test', email: 'test@habitica.com'}; - - it('returns an error when invite is missing an email', async () => { - await expect(inviter.post(`/groups/${group._id}/invite`, { - emails: [{name: 'test'}], - })) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('inviteMissingEmail'), - }); - }); - - it('returns an error when emails is not an array', async () => { - await expect(inviter.post(`/groups/${group._id}/invite`, { - emails: {testInvite}, - })) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('emailsMustBeAnArray'), - }); - }); - - it('returns empty when emails is an empty array', async () => { - await expect(inviter.post(`/groups/${group._id}/invite`, { - emails: [], - })) - .to.eventually.be.empty; - }); - - it('returns an error when there are more than INVITES_LIMIT emails', async () => { - let emails = []; - - for (let i = 0; i < 101; i += 1) { - emails.push(`${generateUUID()}@habitica.com`); - } - - await expect(inviter.post(`/groups/${group._id}/invite`, { - emails, - })) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), - }); - }); - - it('invites a user to a group by email', async () => { - let res = await inviter.post(`/groups/${group._id}/invite`, { - emails: [testInvite], - inviter: 'inviter name', - }); - - expect(res).to.exist; - }); - - it('invites multiple users to a group by email', async () => { - let res = await inviter.post(`/groups/${group._id}/invite`, { - emails: [testInvite, {name: 'test2', email: 'test2@habitica.com'}], - }); - - expect(res).to.exist; - }); - }); - - describe('user and email invites', () => { - it('returns an error when emails and uuids are not provided', async () => { - await expect(inviter.post(`/groups/${group._id}/invite`)) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('canOnlyInviteEmailUuid'), - }); - }); - - it('returns an error when there are more than INVITES_LIMIT uuids and emails', async () => { - let emails = []; - let uuids = []; - - for (let i = 0; i < 50; i += 1) { - emails.push(`${generateUUID()}@habitica.com`); - } - - for (let i = 0; i < 51; i += 1) { - uuids.push(generateUUID()); - } - - await expect(inviter.post(`/groups/${group._id}/invite`, { - emails, - uuids, - })) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), - }); - }); - - it('invites users to a group by uuid and email', async () => { - let newUser = await generateUser(); - let invite = await inviter.post(`/groups/${group._id}/invite`, { - uuids: [newUser._id], - emails: [{name: 'test', email: 'test@habitica.com'}], - }); - let invitedUser = await newUser.get('/user'); - - expect(invitedUser.invitations.guilds[0].id).to.equal(group._id); - expect(invite).to.exist; - }); - }); - - describe('guild invites', () => { - it('returns an error when invited user is already invited to the group', async () => { - let userToInivite = await generateUser(); - await inviter.post(`/groups/${group._id}/invite`, { - uuids: [userToInivite._id], - }); - - await expect(inviter.post(`/groups/${group._id}/invite`, { - uuids: [userToInivite._id], - })) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('userAlreadyInvitedToGroup'), - }); - }); - - it('returns an error when invited user is already in the group', async () => { - let userToInvite = await generateUser(); - await inviter.post(`/groups/${group._id}/invite`, { - uuids: [userToInvite._id], - }); - await userToInvite.post(`/groups/${group._id}/join`); - - await expect(inviter.post(`/groups/${group._id}/invite`, { - uuids: [userToInvite._id], - })) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('userAlreadyInGroup'), - }); - }); - }); - - describe('party invites', () => { - let party; - - beforeEach(async () => { - party = await inviter.post('/groups', { - name: 'Test Party', - type: 'party', - }); - }); - - it('returns an error when invited user has a pending invitation to the party', async () => { - let userToInvite = await generateUser(); - await inviter.post(`/groups/${party._id}/invite`, { - uuids: [userToInvite._id], - }); - - await expect(inviter.post(`/groups/${party._id}/invite`, { - uuids: [userToInvite._id], - })) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('userAlreadyPendingInvitation'), - }); - }); - - it('returns an error when invited user is already in a party of more than 1 member', async () => { - let userToInvite = await generateUser(); - let userToInvite2 = await generateUser(); - await inviter.post(`/groups/${party._id}/invite`, { - uuids: [userToInvite._id, userToInvite2._id], - }); - await userToInvite.post(`/groups/${party._id}/join`); - await userToInvite2.post(`/groups/${party._id}/join`); - - await expect(inviter.post(`/groups/${party._id}/invite`, { - uuids: [userToInvite._id], - })) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('userAlreadyInAParty'), - }); - }); - - it('allow inviting a user to a party if he\'s partying solo', async () => { - let userToInvite = await generateUser(); - await userToInvite.post('/groups', { // add user to a party - name: 'Another Test Party', - type: 'party', - }); - - await inviter.post(`/groups/${party._id}/invite`, { - uuids: [userToInvite._id], - }); - expect((await userToInvite.get('/user')).invitations.party.id).to.equal(party._id); - }); - }); -}); diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js deleted file mode 100644 index 8d581d56ca..0000000000 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ /dev/null @@ -1,46 +0,0 @@ -import { - createAndPopulateGroup, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('PUT /group', () => { - let leader, nonLeader, groupToUpdate; - let groupName = 'Test Public Guild'; - let groupType = 'guild'; - let groupUpdatedName = 'Test Public Guild Updated'; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - name: groupName, - type: groupType, - privacy: 'public', - }, - members: 1, - }); - - groupToUpdate = group; - leader = groupLeader; - nonLeader = members[0]; - }); - - it('returns an error when a non group leader tries to update', async () => { - await expect(nonLeader.put(`/groups/${groupToUpdate._id}`, { - name: groupUpdatedName, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupOnlyLeaderCanUpdate'), - }); - }); - - it('updates a group', async () => { - let updatedGroup = await leader.put(`/groups/${groupToUpdate._id}`, { - name: groupUpdatedName, - }); - - expect(updatedGroup.leader._id).to.eql(leader._id); - expect(updatedGroup.leader.profile.name).to.eql(leader.profile.name); - expect(updatedGroup.name).to.equal(groupUpdatedName); - }); -}); diff --git a/test/api/v3/integration/hall/GET-hall_heroes.test.js b/test/api/v3/integration/hall/GET-hall_heroes.test.js deleted file mode 100644 index 745bc7739c..0000000000 --- a/test/api/v3/integration/hall/GET-hall_heroes.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('GET /hall/heroes', () => { - it('returns all heroes sorted by -contributor.level and with correct fields', async () => { - let nonHero = await generateUser(); - let hero1 = await generateUser({ - contributor: {level: 1}, - }); - let hero2 = await generateUser({ - contributor: {level: 3}, - }); - - let heroes = await nonHero.get('/hall/heroes'); - expect(heroes.length).to.equal(2); - expect(heroes[0]._id).to.equal(hero2._id); - expect(heroes[1]._id).to.equal(hero1._id); - - expect(heroes[0]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); - expect(heroes[1]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); - - expect(heroes[0].profile).to.have.all.keys(['name']); - expect(heroes[1].profile).to.have.all.keys(['name']); - - expect(heroes[0].profile.name).to.equal(hero2.profile.name); - expect(heroes[1].profile.name).to.equal(hero1.profile.name); - }); -}); diff --git a/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js deleted file mode 100644 index 2bf1a0a017..0000000000 --- a/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /heroes/:heroId', () => { - let user; - - before(async () => { - user = await generateUser({ - contributor: {admin: true}, - }); - }); - - it('requires the caller to be an admin', async () => { - let nonAdmin = await generateUser(); - - await expect(nonAdmin.get(`/hall/heroes/${user._id}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('noAdminAccess'), - }); - }); - - it('validates req.params.heroId', async () => { - await expect(user.get('/hall/heroes/invalidUUID')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('handles non-existing heroes', async () => { - let dummyId = generateUUID(); - await expect(user.get(`/hall/heroes/${dummyId}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userWithIDNotFound', {userId: dummyId}), - }); - }); - - it('returns only necessary hero data', async () => { - let hero = await generateUser({ - contributor: {tier: 23}, - }); - let heroRes = await user.get(`/hall/heroes/${hero._id}`); - - expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys - '_id', 'id', 'balance', 'profile', 'purchased', - 'contributor', 'auth', 'items', - ]); - expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); - expect(heroRes.profile).to.have.all.keys(['name']); - }); -}); diff --git a/test/api/v3/integration/hall/GET-hall_patrons.test.js b/test/api/v3/integration/hall/GET-hall_patrons.test.js deleted file mode 100644 index 9599daef89..0000000000 --- a/test/api/v3/integration/hall/GET-hall_patrons.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import { - generateUser, - translate as t, - resetHabiticaDB, -} from '../../../../helpers/api-v3-integration.helper'; -import { times } from 'lodash'; - -describe('GET /hall/patrons', () => { - let user; - - beforeEach(async () => { - await resetHabiticaDB(); - user = await generateUser(); - }); - - it('fails if req.query.page is not numeric', async () => { - await expect(user.get('/hall/patrons?page=notNumber')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns all patrons sorted by -backer.tier and with correct fields', async () => { - let patron1 = await generateUser({ - backer: {tier: 1}, - }); - let patron2 = await generateUser({ - backer: {tier: 3}, - }); - - let patrons = await user.get('/hall/patrons'); - expect(patrons.length).to.equal(2); - expect(patrons[0]._id).to.equal(patron2._id); - expect(patrons[1]._id).to.equal(patron1._id); - - expect(patrons[0]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); - expect(patrons[1]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); - - expect(patrons[0].profile).to.have.all.keys(['name']); - expect(patrons[1].profile).to.have.all.keys(['name']); - - expect(patrons[0].profile.name).to.equal(patron2.profile.name); - expect(patrons[1].profile.name).to.equal(patron1.profile.name); - }); - - it('returns only first 50 patrons per request, more if req.query.page is passed', async () => { - await Promise.all(times(53, n => { - return generateUser({backer: {tier: n}}); - })); - - let patrons = await user.get('/hall/patrons'); - expect(patrons.length).to.equal(50); - - let morePatrons = await user.get('/hall/patrons?page=1'); - expect(morePatrons.length).to.equal(2); - expect(morePatrons[0].backer.tier).to.equal(2); - expect(morePatrons[1].backer.tier).to.equal(1); - }); -}); diff --git a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js deleted file mode 100644 index 9948cbe3c7..0000000000 --- a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js +++ /dev/null @@ -1,148 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('PUT /heroes/:heroId', () => { - let user; - - before(async () => { - user = await generateUser({ - contributor: {admin: true}, - }); - }); - - it('requires the caller to be an admin', async () => { - let nonAdmin = await generateUser(); - - await expect(nonAdmin.put(`/hall/heroes/${user._id}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('noAdminAccess'), - }); - }); - - it('validates req.params.heroId', async () => { - await expect(user.put('/hall/heroes/invalidUUID')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('handles non-existing heroes', async () => { - let dummyId = generateUUID(); - await expect(user.put(`/hall/heroes/${dummyId}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userWithIDNotFound', {userId: dummyId}), - }); - }); - - it('updates contributor level, balance, ads, blocked', async () => { - let hero = await generateUser(); - let heroRes = await user.put(`/hall/heroes/${hero._id}`, { - balance: 3, - contributor: {level: 1}, - purchased: {ads: true}, - auth: {blocked: true}, - }); - - // test response - expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys - '_id', 'balance', 'profile', 'purchased', - 'contributor', 'auth', 'items', - ]); - expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); - expect(heroRes.profile).to.have.all.keys(['name']); - - // test response values - expect(heroRes.balance).to.equal(3 + 0.75); // 3+0.75 for first contrib level - expect(heroRes.contributor.level).to.equal(1); - expect(heroRes.purchased.ads).to.equal(true); - expect(heroRes.auth.blocked).to.equal(true); - // test hero values - await hero.sync(); - expect(hero.balance).to.equal(3 + 0.75); // 3+0.75 for first contrib level - expect(hero.contributor.level).to.equal(1); - expect(hero.flags.contributor).to.equal(true); - expect(hero.purchased.ads).to.equal(true); - expect(hero.auth.blocked).to.equal(true); - }); - - it('updates contributor level', async () => { - let hero = await generateUser({ - contributor: {level: 5}, - }); - let heroRes = await user.put(`/hall/heroes/${hero._id}`, { - contributor: {level: 6}, - }); - - // test response - expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys - '_id', 'balance', 'profile', 'purchased', - 'contributor', 'auth', 'items', - ]); - expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); - expect(heroRes.profile).to.have.all.keys(['name']); - - // test response values - expect(heroRes.balance).to.equal(1); // 0+1 for sixth contrib level - expect(heroRes.contributor.level).to.equal(6); - expect(heroRes.items.pets['Dragon-Hydra']).to.equal(5); - // test hero values - await hero.sync(); - expect(hero.balance).to.equal(1); // 0+1 for sixth contrib level - expect(hero.contributor.level).to.equal(6); - expect(hero.flags.contributor).to.equal(true); - expect(hero.items.pets['Dragon-Hydra']).to.equal(5); - }); - - it('updates contributor data', async () => { - let hero = await generateUser({ - contributor: {level: 5}, - }); - let heroRes = await user.put(`/hall/heroes/${hero._id}`, { - contributor: {text: 'Astronaut'}, - }); - - // test response - expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys - '_id', 'balance', 'profile', 'purchased', - 'contributor', 'auth', 'items', - ]); - expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); - expect(heroRes.profile).to.have.all.keys(['name']); - - // test response values - expect(heroRes.contributor.level).to.equal(5); // doesn't modify previous values - expect(heroRes.contributor.text).to.equal('Astronaut'); - // test hero values - await hero.sync(); - expect(hero.contributor.level).to.equal(5); // doesn't modify previous values - expect(hero.contributor.text).to.equal('Astronaut'); - }); - - it('updates items', async () => { - let hero = await generateUser(); - let heroRes = await user.put(`/hall/heroes/${hero._id}`, { - itemPath: 'items.special.snowball', - itemVal: 5, - }); - - // test response - expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys - '_id', 'balance', 'profile', 'purchased', - 'contributor', 'auth', 'items', - ]); - expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); - expect(heroRes.profile).to.have.all.keys(['name']); - - // test response values - expect(heroRes.items.special.snowball).to.equal(5); - // test hero values - await hero.sync(); - expect(hero.items.special.snowball).to.equal(5); - }); -}); diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js deleted file mode 100644 index d8ac3c4119..0000000000 --- a/test/api/v3/integration/members/GET-members_id.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /members/:memberId', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('validates req.params.memberId', async () => { - await expect(user.get('/members/invalidUUID')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns a member public data only', async () => { - let member = await generateUser({ // make sure user has all the fields that can be returned by the getMember call - contributor: {level: 1}, - backer: {tier: 3}, - preferences: { - costume: false, - background: 'volcano', - }, - }); - let memberRes = await user.get(`/members/${member._id}`); - expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys - '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', - 'backer', 'contributor', 'auth', 'items', - ]); - expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); - expect(Object.keys(memberRes.preferences).sort()).to.eql(['size', 'hair', 'skin', 'shirt', - 'costume', 'sleep', 'background'].sort()); - }); - - it('handles non-existing members', async () => { - let dummyId = generateUUID(); - await expect(user.get(`/members/${dummyId}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userWithIDNotFound', {userId: dummyId}), - }); - }); -}); diff --git a/test/api/v3/integration/members/POST-send_private_message.test.js b/test/api/v3/integration/members/POST-send_private_message.test.js deleted file mode 100644 index 3bd3380437..0000000000 --- a/test/api/v3/integration/members/POST-send_private_message.test.js +++ /dev/null @@ -1,107 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /members/send-private-message', () => { - let userToSendMessage; - let messageToSend = 'Test Private Message'; - - beforeEach(async () => { - userToSendMessage = await generateUser(); - }); - - it('returns error when message is not provided', async () => { - await expect(userToSendMessage.post('/members/send-private-message')) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Invalid request parameters.', - }); - }); - - it('returns error when toUserId is not provided', async () => { - await expect(userToSendMessage.post('/members/send-private-message', { - message: messageToSend, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Invalid request parameters.', - }); - }); - - it('returns error when to user is not found', async () => { - await expect(userToSendMessage.post('/members/send-private-message', { - message: messageToSend, - toUserId: generateUUID(), - })).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userNotFound'), - }); - }); - - it('returns error when to user has blocked the sender', async () => { - let receiver = await generateUser({'inbox.blocks': [userToSendMessage._id]}); - - await expect(userToSendMessage.post('/members/send-private-message', { - message: messageToSend, - toUserId: receiver._id, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notAuthorizedToSendMessageToThisUser'), - }); - }); - - it('returns error when sender has blocked to user', async () => { - let receiver = await generateUser(); - let sender = await generateUser({'inbox.blocks': [receiver._id]}); - - await expect(sender.post('/members/send-private-message', { - message: messageToSend, - toUserId: receiver._id, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notAuthorizedToSendMessageToThisUser'), - }); - }); - - it('returns error when to user has opted out of messaging', async () => { - let receiver = await generateUser({'inbox.optOut': true}); - - await expect(userToSendMessage.post('/members/send-private-message', { - message: messageToSend, - toUserId: receiver._id, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notAuthorizedToSendMessageToThisUser'), - }); - }); - - it('sends a private message to a user', async () => { - let receiver = await generateUser(); - - await userToSendMessage.post('/members/send-private-message', { - message: messageToSend, - toUserId: receiver._id, - }); - - let updatedReceiver = await receiver.get('/user'); - let updatedSender = await userToSendMessage.get('/user'); - - let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => { - return message.uuid === userToSendMessage._id && message.text === messageToSend; - }); - - let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (message) => { - return message.uuid === receiver._id && message.text === messageToSend; - }); - - expect(sendersMessageInReceiversInbox).to.exist; - expect(sendersMessageInSendersInbox).to.exist; - }); -}); diff --git a/test/api/v3/integration/members/POST-transfer_gems.test.js b/test/api/v3/integration/members/POST-transfer_gems.test.js deleted file mode 100644 index 96644a3e88..0000000000 --- a/test/api/v3/integration/members/POST-transfer_gems.test.js +++ /dev/null @@ -1,174 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /members/transfer-gems', () => { - let userToSendMessage; - let receiver; - let message = 'Test Private Message'; - let gemAmount = 20; - - beforeEach(async () => { - userToSendMessage = await generateUser({balance: 5}); - receiver = await generateUser(); - }); - - it('returns error when no parameters are provided', async () => { - await expect(userToSendMessage.post('/members/transfer-gems')) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Invalid request parameters.', - }); - }); - - it('returns error when toUserId is not provided', async () => { - await expect(userToSendMessage.post('/members/transfer-gems', { - message, - gemAmount, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Invalid request parameters.', - }); - }); - - it('returns error when to user is not found', async () => { - await expect(userToSendMessage.post('/members/transfer-gems', { - message, - gemAmount, - toUserId: generateUUID(), - })).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userNotFound'), - }); - }); - - it('returns error when to user attempts to send gems to themselves', async () => { - await expect(userToSendMessage.post('/members/transfer-gems', { - message, - gemAmount, - toUserId: userToSendMessage._id, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('cannotSendGemsToYourself'), - }); - }); - - it('returns error when there is no gemAmount', async () => { - await expect(userToSendMessage.post('/members/transfer-gems', { - message, - toUserId: receiver._id, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Invalid request parameters.', - }); - }); - - it('returns error when gemAmount is not an integer', async () => { - await expect(userToSendMessage.post('/members/transfer-gems', { - message, - gemAmount: 1.5, - toUserId: receiver._id, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Invalid request parameters.', - }); - }); - - it('returns error when gemAmount is negative', async () => { - await expect(userToSendMessage.post('/members/transfer-gems', { - message, - gemAmount: -5, - toUserId: receiver._id, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('badAmountOfGemsToSend'), - }); - }); - - it('returns error when gemAmount is more than the sender\'s balance', async () => { - await expect(userToSendMessage.post('/members/transfer-gems', { - message, - gemAmount: gemAmount + 4, - toUserId: receiver._id, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('badAmountOfGemsToSend'), - }); - }); - - it('sends a private message about gems to a user', async () => { - await userToSendMessage.post('/members/transfer-gems', { - message, - gemAmount, - toUserId: receiver._id, - }); - - let updatedReceiver = await receiver.get('/user'); - let updatedSender = await userToSendMessage.get('/user'); - - let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (inboxMessage) => { - return inboxMessage.uuid === userToSendMessage._id; - }); - - let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (inboxMessage) => { - return inboxMessage.uuid === receiver._id; - }); - - let messageSentContent = t('privateMessageGiftIntro', { - receiverName: receiver.profile.name, - senderName: userToSendMessage.profile.name, - }); - messageSentContent += t('privateMessageGiftGemsMessage', {gemAmount}); - messageSentContent += message; - - expect(sendersMessageInReceiversInbox).to.exist; - expect(sendersMessageInReceiversInbox.text).to.equal(messageSentContent); - expect(updatedReceiver.balance).to.equal(gemAmount / 4); - - expect(sendersMessageInSendersInbox).to.exist; - expect(sendersMessageInSendersInbox.text).to.equal(messageSentContent); - expect(updatedSender.balance).to.equal(0); - }); - - it('does not requrie a message', async () => { - await userToSendMessage.post('/members/transfer-gems', { - gemAmount, - toUserId: receiver._id, - }); - - let updatedReceiver = await receiver.get('/user'); - let updatedSender = await userToSendMessage.get('/user'); - - let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (inboxMessage) => { - return inboxMessage.uuid === userToSendMessage._id; - }); - - let sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (inboxMessage) => { - return inboxMessage.uuid === receiver._id; - }); - - let messageSentContent = t('privateMessageGiftIntro', { - receiverName: receiver.profile.name, - senderName: userToSendMessage.profile.name, - }); - messageSentContent += t('privateMessageGiftGemsMessage', {gemAmount}); - - expect(sendersMessageInReceiversInbox).to.exist; - expect(sendersMessageInReceiversInbox.text).to.equal(messageSentContent); - expect(updatedReceiver.balance).to.equal(gemAmount / 4); - - expect(sendersMessageInSendersInbox).to.exist; - expect(sendersMessageInSendersInbox.text).to.equal(messageSentContent); - expect(updatedSender.balance).to.equal(0); - }); -}); diff --git a/test/api/v3/integration/models/GET-model_paths.test.js b/test/api/v3/integration/models/GET-model_paths.test.js deleted file mode 100644 index 0a9a94451a..0000000000 --- a/test/api/v3/integration/models/GET-model_paths.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('GET /models/:model/paths', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('returns an error when model is not accessible or doesn\'t exists', async () => { - await expect(user.get('/models/1234/paths')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - let models = ['habit', 'daily', 'todo', 'reward', 'user', 'tag', 'challenge', 'group']; - models.forEach(model => { - it(`returns the model paths for ${model}`, async () => { - let res = await user.get(`/models/${model}/paths`); - - if (model !== 'tag') expect(res._id).to.equal('String'); - if (model === 'tag') expect(res.id).to.equal('String'); - - expect(res).to.not.have.keys('__v'); - }); - }); -}); diff --git a/test/api/v3/integration/notFound.test.js b/test/api/v3/integration/notFound.test.js deleted file mode 100644 index 747b370af9..0000000000 --- a/test/api/v3/integration/notFound.test.js +++ /dev/null @@ -1,13 +0,0 @@ -import { requester } from '../../../helpers/api-integration/v3'; - -describe('notFound Middleware', () => { - it('returns a 404 error when the resource is not found', async () => { - let request = requester().get('/api/v3/dummy-url'); - - await expect(request).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: 'Not found.', - }); - }); -}); diff --git a/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js deleted file mode 100644 index 37588d1f18..0000000000 --- a/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('payments : amazon #subscribeCancel', () => { - let endpoint = '/amazon/subscribe/cancel'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies subscription', async () => { - await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingSubscription'), - }); - }); -}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js b/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js deleted file mode 100644 index 7c692f31d1..0000000000 --- a/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -xdescribe('payments : paypal #checkout', () => { - let endpoint = '/paypal/checkout'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies subscription', async () => { - await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingSubscription'), - }); - }); -}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js b/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js deleted file mode 100644 index 6de04c8848..0000000000 --- a/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -xdescribe('payments : paypal #checkoutSuccess', () => { - let endpoint = '/paypal/checkout/success'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies subscription', async () => { - await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingSubscription'), - }); - }); -}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js deleted file mode 100644 index 54c540ee39..0000000000 --- a/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -xdescribe('payments : paypal #subscribe', () => { - let endpoint = '/paypal/subscribe'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies credentials', async () => { - await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingSubscription'), - }); - }); -}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js deleted file mode 100644 index 1ba8b7af16..0000000000 --- a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('payments : paypal #subscribeCancel', () => { - let endpoint = '/paypal/subscribe/cancel'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies credentials', async () => { - await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingSubscription'), - }); - }); -}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js deleted file mode 100644 index 1a38342e9c..0000000000 --- a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -xdescribe('payments : paypal #subscribeSuccess', () => { - let endpoint = '/paypal/subscribe/success'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies credentials', async () => { - await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingSubscription'), - }); - }); -}); diff --git a/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js deleted file mode 100644 index 6d7ac87d0f..0000000000 --- a/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('payments - stripe - #subscribeCancel', () => { - let endpoint = '/stripe/subscribe/cancel'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies credentials', async () => { - await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingSubscription'), - }); - }); -}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js deleted file mode 100644 index 8745a74e85..0000000000 --- a/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('payments - amazon - #checkout', () => { - let endpoint = '/amazon/checkout'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies credentials', async () => { - await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Missing req.body.orderReferenceId', - }); - }); -}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js b/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js deleted file mode 100644 index 17a50520eb..0000000000 --- a/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('payments - amazon - #createOrderReferenceId', () => { - let endpoint = '/amazon/createOrderReferenceId'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies billingAgreementId', async (done) => { - try { - await user.post(endpoint); - } catch (e) { - // Parameter AWSAccessKeyId cannot be empty. - expect(e.error).to.eql('BadRequest'); - done(); - } - }); -}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js b/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js deleted file mode 100644 index 5c3b98ad87..0000000000 --- a/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('payments - amazon - #subscribe', () => { - let endpoint = '/amazon/subscribe'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies subscription code', async () => { - await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('missingSubscriptionCode'), - }); - }); -}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js deleted file mode 100644 index 51ccf8c41c..0000000000 --- a/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('payments : amazon', () => { - let endpoint = '/amazon/verifyAccessToken'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies access token', async () => { - await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Missing req.body.access_token', - }); - }); -}); diff --git a/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js deleted file mode 100644 index 219e9ce35b..0000000000 --- a/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('payments - paypal - #ipn', () => { - let endpoint = '/paypal/ipn'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies credentials', async () => { - let result = await user.post(endpoint); - expect(result).to.eql('OK'); - }); -}); diff --git a/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js deleted file mode 100644 index 1443a3af74..0000000000 --- a/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('payments - stripe - #checkout', () => { - let endpoint = '/stripe/checkout'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies credentials', async () => { - await expect(user.post(endpoint, {id: 123})).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'Error', - message: 'Invalid API Key provided: ****************************1111', - }); - }); -}); diff --git a/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js b/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js deleted file mode 100644 index d6d568ace4..0000000000 --- a/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('payments - stripe - #subscribeEdit', () => { - let endpoint = '/stripe/subscribe/edit'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('verifies credentials', async () => { - await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingSubscription'), - }); - }); -}); diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js deleted file mode 100644 index 665a185279..0000000000 --- a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js +++ /dev/null @@ -1,119 +0,0 @@ -import { - createAndPopulateGroup, - translate as t, - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /groups/:groupId/quests/accept', () => { - const PET_QUEST = 'whale'; - - let questingGroup; - let leader; - let partyMembers; - let user; - - beforeEach(async () => { - user = await generateUser(); - - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - members: 2, - }); - - questingGroup = group; - leader = groupLeader; - partyMembers = members; - - await leader.update({ - [`items.quests.${PET_QUEST}`]: 1, - }); - }); - - context('failure conditions', () => { - it('does not accept quest without an invite', async () => { - await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('questInviteNotFound'), - }); - }); - - it('does not accept quest for a group in which user is not a member', async () => { - await expect(user.post(`/groups/${questingGroup._id}/quests/accept`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('does not accept quest for a guild', async () => { - let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'guild', privacy: 'private' }, - }); - - await expect(guildLeader.post(`/groups/${guild._id}/quests/accept`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('guildQuestsNotSupported'), - }); - }); - - it('does not accept invite twice', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`)) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('questAlreadyAccepted'), - }); - }); - - it('does not accept invite for a quest already underway', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - // quest will start after everyone has accepted - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questAlreadyUnderway'), - }); - }); - }); - - context('successfully accepting a quest invitation', () => { - it('joins a quest from an invitation', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - - await Promise.all([partyMembers[0].sync(), questingGroup.sync()]); - expect(leader.party.quest.RSVPNeeded).to.equal(false); - expect(questingGroup.quest.members[partyMembers[0]._id]); - }); - - it('does not begin the quest if pending invitations remain', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - - await questingGroup.sync(); - expect(questingGroup.quest.active).to.equal(false); - }); - - it('begins the quest if accepting the last pending invite', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - // quest will start after everyone has accepted - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - - await questingGroup.sync(); - expect(questingGroup.quest.active).to.equal(true); - }); - }); -}); diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js deleted file mode 100644 index b6d43f826b..0000000000 --- a/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js +++ /dev/null @@ -1,126 +0,0 @@ -import { - createAndPopulateGroup, - translate as t, - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('POST /groups/:groupId/quests/force-start', () => { - const PET_QUEST = 'whale'; - - let questingGroup; - let leader; - let partyMembers; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - members: 2, - }); - - questingGroup = group; - leader = groupLeader; - partyMembers = members; - - await leader.update({ - [`items.quests.${PET_QUEST}`]: 1, - }); - }); - - context('failure conditions', () => { - it('does not force start a quest for a group in which user is not a member', async () => { - let nonMember = await generateUser(); - - await expect(nonMember.post(`/groups/${questingGroup._id}/quests/force-start`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('does not force start quest for a guild', async () => { - let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'guild', privacy: 'private' }, - }); - - await expect(guildLeader.post(`/groups/${guild._id}/quests/force-start`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('guildQuestsNotSupported'), - }); - }); - - it('does not force start for a party without a pending quest', async () => { - await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('questNotPending'), - }); - }); - - it('does not force start for a quest already underway', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - // quest will start after everyone has accepted - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - - await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questAlreadyUnderway'), - }); - }); - - it('does not allow non-quest leader or non-group leader to force start a quest', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/force-start`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questOrGroupLeaderOnlyStartQuest'), - }); - }); - }); - - context('successfully force starting a quest', () => { - it('allows quest leader to force start quest', async () => { - let questLeader = partyMembers[0]; - await questLeader.update({[`items.quests.${PET_QUEST}`]: 1}); - await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - - await questLeader.post(`/groups/${questingGroup._id}/quests/force-start`); - - await questingGroup.sync(); - - expect(questingGroup.quest.active).to.eql(true); - }); - - it('allows group leader to force start quest', async () => { - let questLeader = partyMembers[0]; - await questLeader.update({[`items.quests.${PET_QUEST}`]: 1}); - await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - - await leader.post(`/groups/${questingGroup._id}/quests/force-start`); - - await questingGroup.sync(); - - expect(questingGroup.quest.active).to.eql(true); - }); - - it('sends back the quest object', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - - let quest = await leader.post(`/groups/${questingGroup._id}/quests/force-start`); - - expect(quest.active).to.eql(true); - expect(quest.key).to.eql(PET_QUEST); - expect(quest.members).to.eql({ - [`${leader._id}`]: true, - }); - }); - }); -}); diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js deleted file mode 100644 index 973a51a1a5..0000000000 --- a/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js +++ /dev/null @@ -1,192 +0,0 @@ -import { - createAndPopulateGroup, - translate as t, - sleep, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; -import { quests as questScrolls } from '../../../../../common/script/content'; - -describe('POST /groups/:groupId/quests/invite/:questKey', () => { - let questingGroup; - let leader; - let member; - const PET_QUEST = 'whale'; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - members: 1, - }); - - questingGroup = group; - leader = groupLeader; - member = members[0]; - }); - - context('failure conditions', () => { - it('does not issue invites with an invalid group ID', async () => { - await expect(leader.post(`/groups/${generateUUID()}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('does not issue invites for a group in which user is not a member', async () => { - let { group } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - members: 1, - }); - - let alternateGroup = group; - - await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('does not issue invites for Guilds', async () => { - let { group } = await createAndPopulateGroup({ - groupDetails: { type: 'guild', privacy: 'public' }, - members: 1, - }); - - let alternateGroup = group; - - await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('guildQuestsNotSupported'), - }); - }); - - it('does not issue invites with an invalid quest key', async () => { - const FAKE_QUEST = 'herkimer'; - - await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${FAKE_QUEST}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('questNotFound', {key: FAKE_QUEST}), - }); - }); - - it('does not issue invites for a quest the user does not own', async () => { - await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questNotOwned'), - }); - }); - - it('does not issue invites if the user is of insufficient Level', async () => { - const LEVELED_QUEST = 'atom1'; - const LEVELED_QUEST_REQ = questScrolls[LEVELED_QUEST].lvl; - const leaderUpdate = {}; - leaderUpdate[`items.quests.${LEVELED_QUEST}`] = 1; - leaderUpdate['stats.lvl'] = LEVELED_QUEST_REQ - 1; - - await leader.update(leaderUpdate); - - await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questLevelTooHigh', {level: LEVELED_QUEST_REQ}), - }); - }); - - it('does not issue invites if a quest is already underway', async () => { - const QUEST_IN_PROGRESS = 'atom1'; - const leaderUpdate = {}; - leaderUpdate[`items.quests.${PET_QUEST}`] = 1; - - await leader.update(leaderUpdate); - await questingGroup.update({ 'quest.key': QUEST_IN_PROGRESS }); - - await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questAlreadyUnderway'), - }); - }); - }); - - context('successfully issuing a quest invitation', () => { - beforeEach(async () => { - const memberUpdate = {}; - memberUpdate[`items.quests.${PET_QUEST}`] = 1; - - await Promise.all([ - leader.update(memberUpdate), - member.update(memberUpdate), - ]); - }); - - it('adds quest details to group object', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - - await questingGroup.sync(); - - let quest = questingGroup.quest; - - expect(quest.key).to.eql(PET_QUEST); - expect(quest.active).to.eql(false); - expect(quest.leader).to.eql(leader._id); - expect(quest.members).to.have.property(leader._id, true); - expect(quest.members).to.have.property(member._id, null); - expect(quest).to.have.property('progress'); - }); - - it('adds quest details to user objects', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - - await sleep(0.1); // member updates happen in the background - - await Promise.all([ - leader.sync(), - member.sync(), - ]); - - expect(leader.party.quest.key).to.eql(PET_QUEST); - expect(member.party.quest.key).to.eql(PET_QUEST); - expect(leader.party.quest.RSVPNeeded).to.eql(false); - expect(member.party.quest.RSVPNeeded).to.eql(true); - }); - - it('sends back the quest object', async () => { - let inviteResponse = await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - - expect(inviteResponse.key).to.eql(PET_QUEST); - expect(inviteResponse.active).to.eql(false); - expect(inviteResponse.leader).to.eql(leader._id); - expect(inviteResponse.members).to.have.property(leader._id, true); - expect(inviteResponse.members).to.have.property(member._id, null); - expect(inviteResponse).to.have.property('progress'); - }); - - it('allows non-party-leader party members to send invites', async () => { - let inviteResponse = await member.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - - await questingGroup.sync(); - - expect(inviteResponse.key).to.eql(PET_QUEST); - expect(questingGroup.quest.key).to.eql(PET_QUEST); - }); - - it('starts quest automatically if user is in a solo party', async () => { - let leaderDetails = { balance: 10 }; - leaderDetails[`items.quests.${PET_QUEST}`] = 1; - let { group, groupLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - leaderDetails, - }); - - await groupLeader.post(`/groups/${group._id}/quests/invite/${PET_QUEST}`); - - await group.sync(); - - expect(group.quest.active).to.eql(true); - }); - }); -}); diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js deleted file mode 100644 index 850cf53646..0000000000 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js +++ /dev/null @@ -1,126 +0,0 @@ -import { - createAndPopulateGroup, - translate as t, - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /groups/:groupId/quests/abort', () => { - let questingGroup; - let partyMembers; - let user; - let leader; - - const PET_QUEST = 'whale'; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - members: 2, - }); - - questingGroup = group; - leader = groupLeader; - partyMembers = members; - - await leader.update({ - [`items.quests.${PET_QUEST}`]: 1, - }); - user = await generateUser(); - }); - - context('failure conditions', () => { - it('returns an error when group is not found', async () => { - await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/abort`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('returns an error for a group in which user is not a member', async () => { - await expect(user.post(`/groups/${questingGroup._id}/quests/abort`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('returns an error when group is a guild', async () => { - let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'guild', privacy: 'private' }, - }); - - await expect(guildLeader.post(`/groups/${guild._id}/quests/abort`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('guildQuestsNotSupported'), - }); - }); - - it('returns an error when quest is not active', async () => { - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('noActiveQuestToAbort'), - }); - }); - - it('returns an error when non quest leader attempts to abort', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyLeaderAbortQuest'), - }); - }); - }); - - it('aborts a quest', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - - let res = await leader.post(`/groups/${questingGroup._id}/quests/abort`); - await Promise.all([ - leader.sync(), - questingGroup.sync(), - partyMembers[0].sync(), - partyMembers[1].sync(), - ]); - - let cleanUserQuestObj = { - key: null, - progress: { - up: 0, - down: 0, - collect: {}, - }, - completed: null, - RSVPNeeded: false, - }; - - expect(leader.party.quest).to.eql(cleanUserQuestObj); - expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj); - expect(partyMembers[1].party.quest).to.eql(cleanUserQuestObj); - expect(leader.items.quests[PET_QUEST]).to.equal(1); - expect(questingGroup.quest).to.deep.equal(res); - expect(questingGroup.quest).to.eql({ - key: null, - active: false, - leader: null, - progress: { - collect: {}, - }, - members: {}, - }); - }); -}); diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js deleted file mode 100644 index f3bd03a180..0000000000 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ /dev/null @@ -1,138 +0,0 @@ -import { - createAndPopulateGroup, - translate as t, - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /groups/:groupId/quests/cancel', () => { - let questingGroup; - let partyMembers; - let user; - let leader; - - const PET_QUEST = 'whale'; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - members: 2, - }); - - questingGroup = group; - leader = groupLeader; - partyMembers = members; - - await leader.update({ - [`items.quests.${PET_QUEST}`]: 1, - }); - user = await generateUser(); - }); - - context('failure conditions', () => { - it('returns an error when group is not found', async () => { - await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/cancel`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('does not reject quest for a group in which user is not a member', async () => { - await expect(user.post(`/groups/${questingGroup._id}/quests/cancel`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('returns an error when group is a guild', async () => { - let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'guild', privacy: 'private' }, - }); - - await expect(guildLeader.post(`/groups/${guild._id}/quests/cancel`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('guildQuestsNotSupported'), - }); - }); - - it('returns an error when group is not on a quest', async () => { - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('questInvitationDoesNotExist'), - }); - }); - - it('only the leader can cancel the quest', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyLeaderCancelQuest'), - }); - }); - - it('does not cancel a quest already underway', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - // quest will start after everyone has accepted - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - - await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('cantCancelActiveQuest'), - }); - }); - }); - - it('cancels a quest', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - - let res = await leader.post(`/groups/${questingGroup._id}/quests/cancel`); - - await Promise.all([ - leader.sync(), - partyMembers[0].sync(), - partyMembers[1].sync(), - questingGroup.sync(), - ]); - - let clean = { - key: null, - progress: { - up: 0, - down: 0, - collect: {}, - }, - completed: null, - RSVPNeeded: false, - }; - - expect(leader.party.quest).to.eql(clean); - expect(partyMembers[1].party.quest).to.eql(clean); - expect(partyMembers[0].party.quest).to.eql(clean); - - expect(res).to.eql(questingGroup.quest); - expect(questingGroup.quest).to.eql({ - key: null, - active: false, - leader: null, - progress: { - collect: {}, - }, - members: {}, - }); - }); -}); diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js deleted file mode 100644 index 65d781c163..0000000000 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js +++ /dev/null @@ -1,124 +0,0 @@ -import { - createAndPopulateGroup, - translate as t, - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /groups/:groupId/quests/leave', () => { - let questingGroup; - let partyMembers; - let user; - let leader; - - const PET_QUEST = 'whale'; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - members: 2, - }); - - questingGroup = group; - leader = groupLeader; - partyMembers = members; - - await leader.update({ - [`items.quests.${PET_QUEST}`]: 1, - }); - user = await generateUser(); - }); - - context('failure conditions', () => { - it('returns an error when group is not found', async () => { - await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/leave`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('returns an error for a group in which user is not a member', async () => { - await expect(user.post(`/groups/${questingGroup._id}/quests/leave`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('returns an error when group is a guild', async () => { - let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'guild', privacy: 'private' }, - }); - - await expect(guildLeader.post(`/groups/${guild._id}/quests/leave`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('guildQuestsNotSupported'), - }); - }); - - it('returns an error when quest is not active', async () => { - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('noActiveQuestToLeave'), - }); - }); - - it('returns an error when quest leader attempts to leave', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - - await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questLeaderCannotLeaveQuest'), - }); - }); - - it('returns an error when non quest member attempts to leave', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); - - await expect(partyMembers[1].post(`/groups/${questingGroup._id}/quests/leave`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notPartOfQuest'), - }); - }); - }); - - it('leaves a quest', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - - let leaveResult = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`); - await Promise.all([ - partyMembers[0].sync(), - questingGroup.sync(), - ]); - - expect(partyMembers[0].party.quest).to.eql({ - key: null, - progress: { - up: 0, - down: 0, - collect: {}, - }, - completed: null, - RSVPNeeded: false, - }); - expect(questingGroup.quest).to.deep.equal(leaveResult); - expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; - }); -}); diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js deleted file mode 100644 index 2dcddfe727..0000000000 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ /dev/null @@ -1,146 +0,0 @@ -import { - createAndPopulateGroup, - translate as t, - generateUser, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /groups/:groupId/quests/reject', () => { - let questingGroup; - let partyMembers; - let user; - let leader; - - const PET_QUEST = 'whale'; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - members: 2, - }); - - questingGroup = group; - leader = groupLeader; - partyMembers = members; - - await leader.update({ - [`items.quests.${PET_QUEST}`]: 1, - }); - user = await generateUser(); - }); - - context('failure conditions', () => { - it('returns an error when group is not found', async () => { - await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/reject`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('does not accept quest for a group in which user is not a member', async () => { - await expect(user.post(`/groups/${questingGroup._id}/quests/accept`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('groupNotFound'), - }); - }); - - it('returns an error when group is a guild', async () => { - let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'guild', privacy: 'private' }, - }); - - await expect(guildLeader.post(`/groups/${guild._id}/quests/reject`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('guildQuestsNotSupported'), - }); - }); - - it('returns an error when group is not on a quest', async () => { - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('questInvitationDoesNotExist'), - }); - }); - - it('return an error when a user rejects an invite twice', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); - - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('questAlreadyRejected'), - }); - }); - - it('return an error when a user rejects an invite already accepted', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('questAlreadyAccepted'), - }); - }); - - it('does not reject invite for a quest already underway', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - // quest will start after everyone has accepted - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questAlreadyUnderway'), - }); - }); - }); - - context('successfully quest rejection', () => { - let cleanUserQuestObj = { - key: null, - progress: { - up: 0, - down: 0, - collect: {}, - }, - completed: null, - RSVPNeeded: false, - }; - - it('rejects a quest invitation', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - - let res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); - await partyMembers[0].sync(); - await questingGroup.sync(); - - expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj); - expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; - expect(questingGroup.quest.active).to.be.false; - expect(res).to.eql(questingGroup.quest); - }); - - it('starts the quest when the last user reject', async () => { - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); - await questingGroup.sync(); - - expect(questingGroup.quest.active).to.be.true; - }); - }); -}); diff --git a/test/api/v3/integration/status/GET-status.test.js b/test/api/v3/integration/status/GET-status.test.js deleted file mode 100644 index 1d4d33a7d7..0000000000 --- a/test/api/v3/integration/status/GET-status.test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { - requester, -} from '../../../../helpers/api-v3-integration.helper'; - -describe('GET /status', () => { - it('returns status: up', async () => { - let res = await requester().get('/status'); - expect(res).to.eql({ - status: 'up', - }); - }); -}); diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js deleted file mode 100644 index c03e8bb9e0..0000000000 --- a/test/api/v3/integration/tags/DELETE-tags_id.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('DELETE /tags/:tagId', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('deletes a tag given it\'s id', async () => { - let tagName = 'Tag 1'; - let tag = await user.post('/tags', {name: tagName}); - let numberOfTags = (await user.get('/tags')).length; - - await user.del(`/tags/${tag.id}`); - - let tags = await user.get('/tags'); - let tagNames = tags.map((t) => { - return t.name; - }); - - expect(tags.length).to.equal(numberOfTags - 1); - expect(tagNames).to.not.include(tagName); - }); -}); diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js deleted file mode 100644 index 7a24963474..0000000000 --- a/test/api/v3/integration/tags/GET-tags.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('GET /tags', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('returns all user\'s tags', async () => { - let tag1 = await user.post('/tags', {name: 'Tag 1'}); - let tag2 = await user.post('/tags', {name: 'Tag 2'}); - - let tags = await user.get('/tags'); - - expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task - expect(tags[tags.length - 2].name).to.equal(tag1.name); - expect(tags[tags.length - 1].name).to.equal(tag2.name); - }); -}); diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js deleted file mode 100644 index 4ab818593d..0000000000 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('GET /tags/:tagId', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('returns a tag given it\'s id', async () => { - let createdTag = await user.post('/tags', {name: 'Tag 1'}); - let tag = await user.get(`/tags/${createdTag.id}`); - - expect(tag).to.deep.equal(createdTag); - }); - - it('handles non-existing tags'); -}); diff --git a/test/api/v3/integration/tags/POST-tag-reorder.test.js b/test/api/v3/integration/tags/POST-tag-reorder.test.js deleted file mode 100644 index 0710cecf3e..0000000000 --- a/test/api/v3/integration/tags/POST-tag-reorder.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /reorder-tags', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('returns error when no parameters are provided', async () => { - await expect(user.post('/reorder-tags')) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'Invalid request parameters.', - }); - }); - - it('returns error when tag is not found', async () => { - await expect(user.post('/reorder-tags', {tagId: 'fake-id', to: 3})) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('tagNotFound'), - }); - }); - - it('updates tags', async () => { - let tag1Name = 'Tag 1'; - let tag2Name = 'Tag 2'; - await user.post('/tags', {name: tag1Name}); - await user.post('/tags', {name: tag2Name}); - await user.sync(); - - await user.post('/reorder-tags', {tagId: user.tags[4].id, to: 3}); - await user.sync(); - - expect(user.tags[3].name).to.equal(tag2Name); - expect(user.tags[4].name).to.equal(tag1Name); - }); -}); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js deleted file mode 100644 index 93f2dfdb60..0000000000 --- a/test/api/v3/integration/tags/POST-tags.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /tags', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('creates a tag correctly', async () => { - let tagName = 'Tag 1'; - let createdTag = await user.post('/tags', { - name: tagName, - ignored: false, - }); - - let tag = await user.get(`/tags/${createdTag.id}`); - - expect(tag.name).to.equal(tagName); - expect(tag.ignored).to.not.exist; - expect(tag).to.deep.equal(createdTag); - }); -}); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js deleted file mode 100644 index 4c16453ac3..0000000000 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('PUT /tags/:tagId', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('updates a tag given it\'s id', async () => { - let updatedTagName = 'Tag updated'; - let createdTag = await user.post('/tags', {name: 'Tag 1'}); - let updatedTag = await user.put(`/tags/${createdTag.id}`, { - name: updatedTagName, - ignored: true, - }); - - createdTag = await user.get(`/tags/${updatedTag.id}`); - - expect(updatedTag.name).to.equal(updatedTagName); - expect(updatedTag.ignored).to.not.exist; - - expect(createdTag.name).to.equal(updatedTagName); - expect(createdTag.ignored).to.not.exist; - }); -}); diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js deleted file mode 100644 index bb92e4759f..0000000000 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ /dev/null @@ -1,59 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('DELETE /tasks/:id', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - context('task can be deleted', () => { - let task; - - beforeEach(async () => { - task = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - }); - }); - - it('deletes a user\'s task', async () => { - await user.del(`/tasks/${task._id}`); - - await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - }); - - context('task cannot be deleted', () => { - it('cannot delete a non-existant task', async () => { - await expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('cannot delete a task owned by someone else', async () => { - let anotherUser = await generateUser(); - let anotherUsersTask = await anotherUser.post('/tasks/user', { - text: 'test habit', - type: 'habit', - }); - - await expect(user.del(`/tasks/${anotherUsersTask._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('removes a task from user.tasksOrder'); // TODO - }); -}); diff --git a/test/api/v3/integration/tasks/GET-tasks_challenge_challengeId.test.js b/test/api/v3/integration/tasks/GET-tasks_challenge_challengeId.test.js deleted file mode 100644 index 3fed4cfaed..0000000000 --- a/test/api/v3/integration/tasks/GET-tasks_challenge_challengeId.test.js +++ /dev/null @@ -1,75 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import { each } from 'lodash'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /tasks/:taskId', () => { - let user; - let guild; - let challenge; - let task; - let tasksToTest = { - habit: { - text: 'test habit', - type: 'habit', - up: false, - down: true, - }, - todo: { - text: 'test todo', - type: 'todo', - }, - daily: { - text: 'test daily', - type: 'daily', - frequency: 'daily', - everyX: 5, - startDate: new Date(), - }, - reward: { - text: 'test reward', - type: 'reward', - }, - }; - - before(async () => { - user = await generateUser(); - guild = await generateGroup(user); - challenge = await generateChallenge(user, guild); - }); - - it('returns error when incorrect id is passed', async () => { - await expect(user.get(`/tasks/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - each(tasksToTest, (taskValue, taskType) => { - context(`${taskType}`, () => { - before(async () => { - task = await user.post(`/tasks/challenge/${challenge._id}`, taskValue); - }); - - it('gets challenge task', async () => { - let getTask = await user.get(`/tasks/${task._id}`); - expect(getTask).to.eql(task); - }); - - it('returns error when user is not a member of the challenge', async () => { - let anotherUser = await generateUser(); - - await expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - }); - }); -}); diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js deleted file mode 100644 index 99f2d769f4..0000000000 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /tasks/:id', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - context('task can be accessed', async () => { - let task; - - beforeEach(async () => { - task = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - }); - }); - - it('gets specified task', async () => { - let getTask = await user.get(`/tasks/${task._id}`); - expect(getTask).to.eql(task); - }); - - // TODO after challenges are implemented - it('can get active challenge task that user does not own'); // Yes? - }); - - context('task cannot be accessed', () => { - it('cannot get a non-existant task', async () => { - let dummyId = generateUUID(); - - await expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('cannot get a task owned by someone else', async () => { - let anotherUser = await generateUser(); - let task = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - }); - - await expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - }); -}); diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js deleted file mode 100644 index 406c0d43fb..0000000000 --- a/test/api/v3/integration/tasks/GET-tasks_user.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('GET /tasks/user', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns all user\'s tasks', async () => { - let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks/user'); - expect(tasks.length).to.equal(createdTasks.length + 1); // + 1 because 1 is a default task - }); - - it('returns only a type of user\'s tasks if req.query.type is specified', async () => { - let createdTasks = await user.post('/tasks/user', [ - {text: 'test habit', type: 'habit'}, - {text: 'test daily', type: 'daily'}, - {text: 'test reward', type: 'reward'}, - {text: 'test todo', type: 'todo'}, - ]); - let habits = await user.get('/tasks/user?type=habits'); - let dailys = await user.get('/tasks/user?type=dailys'); - let rewards = await user.get('/tasks/user?type=rewards'); - - expect(habits.length).to.be.at.least(1); - expect(habits[0]._id).to.equal(createdTasks[0]._id); - expect(dailys.length).to.be.at.least(1); - expect(dailys[0]._id).to.equal(createdTasks[1]._id); - expect(rewards.length).to.be.at.least(1); - expect(rewards[0]._id).to.equal(createdTasks[2]._id); - }); - - it('returns uncompleted todos if req.query.type is "todos"', async () => { - let existingTodos = await user.get('/tasks/user?type=todos'); - - // populate user with other task types - await user.post('/tasks/user', [ - {text: 'daily', type: 'daily'}, - {text: 'reward', type: 'reward'}, - {text: 'habit', type: 'habit'}, - ]); - - let newUncompletedTodos = await user.post('/tasks/user', [ - {text: 'test todo 1', type: 'todo'}, - {text: 'test todo 2', type: 'todo'}, - ]); - let todoToBeCompleted = await user.post('/tasks/user', { - text: 'wll be completed todo', type: 'todo', - }); - - await user.post(`/tasks/${todoToBeCompleted._id}/score/up`); - - let uncompletedTodos = [...existingTodos, ...newUncompletedTodos]; - - let todos = await user.get('/tasks/user?type=todos'); - - expect(todos.length).to.be.gte(2); - expect(todos.length).to.eql(uncompletedTodos.length); - expect(todos.every(task => task.type === 'todo')); - expect(todos.every(task => task.completed === false)); - }); - - it('returns completed todos sorted by reverse completion date if req.query.type is "completeTodos"', async () => { - let todo1 = await user.post('/tasks/user', {text: 'todo to complete 1', type: 'todo'}); - let todo2 = await user.post('/tasks/user', {text: 'todo to complete 2', type: 'todo'}); - - await user.sync(); - let initialTodoCount = user.tasksOrder.todos.length; - - await user.post(`/tasks/${todo2._id}/score/up`); - await user.post(`/tasks/${todo1._id}/score/up`); - await user.sync(); - - expect(user.tasksOrder.todos.length).to.equal(initialTodoCount - 2); - - let completedTodos = await user.get('/tasks/user?type=completedTodos'); - expect(completedTodos.length).to.equal(2); - expect(completedTodos[completedTodos.length - 1].text).to.equal('todo to complete 2'); // last is the todo that was completed most recently - }); -}); diff --git a/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js deleted file mode 100644 index d6cdf21749..0000000000 --- a/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js +++ /dev/null @@ -1,43 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /tasks/clearCompletedTodos', () => { - it('deletes all completed todos except the ones from a challenge', async () => { - let user = await generateUser({balance: 1}); - let guild = await generateGroup(user); - let challenge = await generateChallenge(user, guild); - - let initialTodoCount = user.tasksOrder.todos.length; - await user.post('/tasks/user', [ - {text: 'todo 1', type: 'todo'}, - {text: 'todo 2', type: 'todo'}, - {text: 'todo 3', type: 'todo'}, - {text: 'todo 4', type: 'todo'}, - {text: 'todo 5', type: 'todo'}, - ]); - - await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'todo 6', - type: 'todo', - }); - - let tasks = await user.get('/tasks/user?type=todos'); - expect(tasks.length).to.equal(initialTodoCount + 6); - - for (let task of tasks) { - if (['todo 2', 'todo 3', 'todo 6'].indexOf(task.text) !== -1) { - await user.post(`/tasks/${task._id}/score/up`); // eslint-disable-line babel/no-await-in-loop - } - } - - await user.post('/tasks/clearCompletedTodos'); - let completedTodos = await user.get('/tasks/user?type=completedTodos'); - let todos = await user.get('/tasks/user?type=todos'); - let allTodos = todos.concat(completedTodos); - expect(allTodos.length).to.equal(initialTodoCount + 4); // + 6 - 3 completed (but one is from challenge) - expect(allTodos[allTodos.length - 1].text).to.equal('todo 6'); - }); -}); diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js deleted file mode 100644 index 86236fd110..0000000000 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ /dev/null @@ -1,298 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /tasks/:id/score/:direction', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ - 'stats.gp': 100, - }); - }); - - context('all', () => { - it('requires a task id', async () => { - await expect(user.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('requires a task direction', async () => { - await expect(user.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - }); - - context('todos', () => { - let todo; - - beforeEach(async () => { - todo = await user.post('/tasks/user', { - text: 'test todo', - type: 'todo', - }); - }); - - it('completes todo when direction is up', async () => { - await user.post(`/tasks/${todo._id}/score/up`); - let task = await user.get(`/tasks/${todo._id}`); - - expect(task.completed).to.equal(true); - expect(task.dateCompleted).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type - }); - - it('moves completed todos out of user.tasksOrder.todos', async () => { - let getUser = await user.get('/user'); - expect(getUser.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); - - await user.post(`/tasks/${todo._id}/score/up`); - let updatedTask = await user.get(`/tasks/${todo._id}`); - expect(updatedTask.completed).to.equal(true); - - let updatedUser = await user.get('/user'); - expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); - }); - - it('moves un-completed todos back into user.tasksOrder.todos', async () => { - let getUser = await user.get('/user'); - expect(getUser.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); - - await user.post(`/tasks/${todo._id}/score/up`); - await user.post(`/tasks/${todo._id}/score/down`); - - let updatedTask = await user.get(`/tasks/${todo._id}`); - expect(updatedTask.completed).to.equal(false); - - let updatedUser = await user.get('/user'); - let l = updatedUser.tasksOrder.todos.length; - expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); - expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom - }); - - it('uncompletes todo when direction is down', async () => { - await user.post(`/tasks/${todo._id}/score/down`); - let updatedTask = await user.get(`/tasks/${todo._id}`); - - expect(updatedTask.completed).to.equal(false); - expect(updatedTask.dateCompleted).to.be.a('undefined'); - }); - - it('scores up todo even if it is already completed'); // Yes? - - it('scores down todo even if it is already uncompleted'); // Yes? - - context('user stats when direction is up', () => { - let updatedUser; - - beforeEach(async () => { - await user.post(`/tasks/${todo._id}/score/up`); - updatedUser = await user.get('/user'); - }); - - it('increases user\'s mp', () => { - expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); - }); - - it('increases user\'s exp', () => { - expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); - }); - - it('increases user\'s gold', () => { - expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); - }); - }); - - context('user stats when direction is down', () => { - let updatedUser; - - beforeEach(async () => { - await user.post(`/tasks/${todo._id}/score/down`); - updatedUser = await user.get('/user'); - }); - - it('decreases user\'s mp', () => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); - - it('decreases user\'s exp', () => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); - }); - - it('decreases user\'s gold', () => { - expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); - }); - }); - }); - - context('dailys', () => { - let daily; - - beforeEach(async () => { - daily = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - }); - }); - - it('completes daily when direction is up', async () => { - await user.post(`/tasks/${daily._id}/score/up`); - let task = await user.get(`/tasks/${daily._id}`); - - expect(task.completed).to.equal(true); - }); - - it('uncompletes daily when direction is down', async () => { - await user.post(`/tasks/${daily._id}/score/down`); - let task = await user.get(`/tasks/${daily._id}`); - - expect(task.completed).to.equal(false); - }); - - it('scores up daily even if it is already completed'); // Yes? - - it('scores down daily even if it is already uncompleted'); // Yes? - - context('user stats when direction is up', () => { - let updatedUser; - - beforeEach(async () => { - await user.post(`/tasks/${daily._id}/score/up`); - updatedUser = await user.get('/user'); - }); - - it('increases user\'s mp', () => { - expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); - }); - - it('increases user\'s exp', () => { - expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); - }); - - it('increases user\'s gold', () => { - expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); - }); - }); - - context('user stats when direction is down', () => { - let updatedUser; - - beforeEach(async () => { - await user.post(`/tasks/${daily._id}/score/down`); - updatedUser = await user.get('/user'); - }); - - it('decreases user\'s mp', () => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); - - it('decreases user\'s exp', () => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); - }); - - it('decreases user\'s gold', () => { - expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); - }); - }); - }); - - context('habits', () => { - let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars - - beforeEach(async () => { - habit = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - }); - - minusHabit = await user.post('/tasks/user', { - text: 'test min habit', - type: 'habit', - up: false, - }); - - plusHabit = await user.post('/tasks/user', { - text: 'test plus habit', - type: 'habit', - down: false, - }); - - neitherHabit = await user.post('/tasks/user', { - text: 'test neither habit', - type: 'habit', - up: false, - down: false, - }); - }); - - it('prevents plus only habit from scoring down'); // Yes? - - it('prevents minus only habit from scoring up'); // Yes? - - it('increases user\'s mp when direction is up', async () => { - await user.post(`/tasks/${habit._id}/score/up`); - let updatedUser = await user.get('/user'); - - expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); - }); - - it('decreases user\'s mp when direction is down', async () => { - await user.post(`/tasks/${habit._id}/score/down`); - let updatedUser = await user.get('/user'); - - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); - - it('increases user\'s exp when direction is up', async () => { - await user.post(`/tasks/${habit._id}/score/up`); - let updatedUser = await user.get('/user'); - - expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); - }); - - it('increases user\'s gold when direction is up', async () => { - await user.post(`/tasks/${habit._id}/score/up`); - let updatedUser = await user.get('/user'); - - expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); - }); - }); - - context('reward', () => { - let reward, updatedUser; - - beforeEach(async () => { - reward = await user.post('/tasks/user', { - text: 'test reward', - type: 'reward', - value: 5, - }); - - await user.post(`/tasks/${reward._id}/score/up`); - updatedUser = await user.get('/user'); - }); - - it('purchases reward', () => { - expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); - }); - - it('does not change user\'s mp', () => { - expect(user.stats.mp).to.equal(updatedUser.stats.mp); - }); - - it('does not change user\'s exp', () => { - expect(user.stats.exp).to.equal(updatedUser.stats.exp); - }); - - it('does not allow a down direction', () => { - expect(user.stats.mp).to.equal(updatedUser.stats.mp); - }); - }); -}); diff --git a/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js b/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js deleted file mode 100644 index 211c7ea975..0000000000 --- a/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /tasks/:taskId/move/to/:position', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('requires a valid taskId', async () => { - await expect(user.post('/tasks/123/move/to/1')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('requires a numeric position parameter', async () => { - await expect(user.post(`/tasks/${generateUUID()}/move/to/notANumber`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('taskId must match a valid task', async () => { - await expect(user.post(`/tasks/${generateUUID()}/move/to/1`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('can move task to new position', async () => { - let tasks = await user.post('/tasks/user', [ - {type: 'habit', text: 'habit 1'}, - {type: 'habit', text: 'habit 2'}, - {type: 'daily', text: 'daily 1'}, - {type: 'habit', text: 'habit 3'}, - {type: 'habit', text: 'habit 4'}, - {type: 'todo', text: 'todo 1'}, - {type: 'habit', text: 'habit 5'}, - ]); - - let taskToMove = tasks[1]; - expect(taskToMove.text).to.equal('habit 2'); - let newOrder = await user.post(`/tasks/${tasks[1]._id}/move/to/3`); - expect(newOrder[3]).to.equal(taskToMove._id); - expect(newOrder.length).to.equal(5); - }); - - it('can\'t move completed todo', async () => { - let task = await user.post('/tasks/user', {type: 'todo', text: 'todo 1'}); - await user.post(`/tasks/${task._id}/score/up`); - - await expect(user.post(`/tasks/${task._id}/move/to/1`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('cantMoveCompletedTodo'), - }); - }); - - it('can push to bottom', async () => { - let tasks = await user.post('/tasks/user', [ - {type: 'habit', text: 'habit 1'}, - {type: 'habit', text: 'habit 2'}, - {type: 'daily', text: 'daily 1'}, - {type: 'habit', text: 'habit 3'}, - {type: 'habit', text: 'habit 4'}, - {type: 'todo', text: 'todo 1'}, - {type: 'habit', text: 'habit 5'}, - ]); - - let taskToMove = tasks[1]; - expect(taskToMove.text).to.equal('habit 2'); - let newOrder = await user.post(`/tasks/${tasks[1]._id}/move/to/-1`); - expect(newOrder[4]).to.equal(taskToMove._id); - expect(newOrder.length).to.equal(5); - }); -}); diff --git a/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js deleted file mode 100644 index 623d9bb4a4..0000000000 --- a/test/api/v3/integration/tasks/POST-tasks_user.test.js +++ /dev/null @@ -1,593 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /tasks/user', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - context('validates params', async () => { - it('returns an error if req.body.type is absent', async () => { - await expect(user.post('/tasks/user', { - notType: 'habit', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidTaskType'), - }); - }); - - it('returns an error if req.body.type is not valid', async () => { - await expect(user.post('/tasks/user', { - type: 'habitF', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidTaskType'), - }); - }); - - it('returns an error if one object inside an array is invalid', async () => { - await expect(user.post('/tasks/user', [ - {type: 'habitF'}, - {type: 'habit'}, - ])).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidTaskType'), - }); - }); - - it('returns an error if req.body.text is absent', async () => { - await expect(user.post('/tasks/user', { - type: 'habit', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'habit validation failed', - }); - }); - - it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { - let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - await expect(user.post('/tasks/user', { - type: 'habit', - })).to.eventually.be.rejected.and.eql({ // this block is necessary - code: 400, - error: 'BadRequest', - message: 'habit validation failed', - }); - - let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; - expect(updatedHabitsOrder).to.eql(originalHabitsOrder); - }); - - it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => { - let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - await expect(user.post('/tasks/user', [ - {type: 'habit'}, // Missing text - {type: 'habit', text: 'valid'}, // Valid - ])).to.eventually.be.rejected.and.eql({ // this block is necessary - code: 400, - error: 'BadRequest', - message: 'habit validation failed', - }); - - let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; - expect(updatedHabitsOrder).to.eql(originalHabitsOrder); - }); - - it('does not save any task sent in an array when 1 is invalid', async () => { - let originalTasks = await user.get('/tasks/user'); - await expect(user.post('/tasks/user', [ - {type: 'habit'}, // Missing text - {type: 'habit', text: 'valid'}, // Valid - ])).to.eventually.be.rejected.and.eql({ // this block is necessary - code: 400, - error: 'BadRequest', - message: 'habit validation failed', - }).then(async () => { - let updatedTasks = await user.get('/tasks/user'); - - expect(updatedTasks).to.eql(originalTasks); - }); - }); - - it('automatically sets "task.userId" to user\'s uuid', async () => { - let task = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - }); - - expect(task.userId).to.equal(user._id); - }); - - it(`ignores setting userId, history, createdAt, - updatedAt, challenge, completed, - dateCompleted fields`, async () => { - let task = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - userId: 123, - history: [123], - createdAt: 'yesterday', - updatedAt: 'tomorrow', - challenge: 'no', - completed: true, - dateCompleted: 'never', - value: 324, // ignored because not a reward - }); - - expect(task.userId).to.equal(user._id); - expect(task.history).to.eql([]); - expect(task.createdAt).not.to.equal('yesterday'); - expect(task.updatedAt).not.to.equal('tomorrow'); - expect(task.challenge).not.to.equal('no'); - expect(task.completed).to.equal(false); - expect(task.streak).not.to.equal('never'); - expect(task.value).not.to.equal(324); - }); - - it('ignores invalid fields', async () => { - let task = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - notValid: true, - }); - - expect(task).not.to.have.property('notValid'); - }); - }); - - context('all types', () => { - it('can create reminders', async () => { - let id1 = generateUUID(); - - let task = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - reminders: [ - {id: id1, startDate: new Date(), time: new Date()}, - ], - }); - - expect(task.reminders).to.be.an('array'); - expect(task.reminders.length).to.eql(1); - expect(task.reminders[0]).to.be.an('object'); - expect(task.reminders[0].id).to.eql(id1); - expect(task.reminders[0].startDate).to.be.a('string'); // json doesn't have dates - expect(task.reminders[0].time).to.be.a('string'); - }); - }); - - context('habits', () => { - it('creates a habit', async () => { - let task = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - up: false, - down: true, - notes: 1976, - }); - - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test habit'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('habit'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(true); - }); - - it('updates user.tasksOrder.habits when a new habit is created', async () => { - let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let task = await user.post('/tasks/user', { - type: 'habit', - text: 'an habit', - }); - - let updatedUser = await user.get('/user'); - expect(updatedUser.tasksOrder.habits[0]).to.eql(task._id); - expect(updatedUser.tasksOrder.habits.length).to.eql(originalHabitsOrderLen + 1); - }); - - it('updates user.tasksOrder.habits when multiple habits are created', async () => { - let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let [task, task2] = await user.post('/tasks/user', [{ - type: 'habit', - text: 'an habit', - }, { - type: 'habit', - text: 'another habit', - }]); - - let updatedUser = await user.get('/user'); - expect(updatedUser.tasksOrder.habits[0]).to.eql(task2._id); - expect(updatedUser.tasksOrder.habits[1]).to.eql(task._id); - expect(updatedUser.tasksOrder.habits.length).to.eql(originalHabitsOrderLen + 2); - }); - - it('creates multiple habits', async () => { - let [task, task2] = await user.post('/tasks/user', [{ - text: 'test habit', - type: 'habit', - up: false, - down: true, - notes: 1976, - }, { - text: 'test habit 2', - type: 'habit', - up: true, - down: false, - notes: 1977, - }]); - - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test habit'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('habit'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(true); - - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test habit 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('habit'); - expect(task2.up).to.eql(true); - expect(task2.down).to.eql(false); - }); - - it('defaults to setting up and down to true', async () => { - let task = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - notes: 1976, - }); - - expect(task.up).to.eql(true); - expect(task.down).to.eql(true); - }); - - it('cannot create checklists', async () => { - let task = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - checklist: [ - {_id: 123, completed: false, text: 'checklist'}, - ], - }); - - expect(task).not.to.have.property('checklist'); - }); - }); - - context('todos', () => { - it('creates a todo', async () => { - let task = await user.post('/tasks/user', { - text: 'test todo', - type: 'todo', - notes: 1976, - }); - - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test todo'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('todo'); - }); - - it('creates multiple todos', async () => { - let [task, task2] = await user.post('/tasks/user', [{ - text: 'test todo', - type: 'todo', - notes: 1976, - }, { - text: 'test todo 2', - type: 'todo', - notes: 1977, - }]); - - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test todo'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('todo'); - - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test todo 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('todo'); - }); - - it('updates user.tasksOrder.todos when a new todo is created', async () => { - let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let task = await user.post('/tasks/user', { - type: 'todo', - text: 'a todo', - }); - - let updatedUser = await user.get('/user'); - expect(updatedUser.tasksOrder.todos[0]).to.eql(task._id); - expect(updatedUser.tasksOrder.todos.length).to.eql(originalTodosOrderLen + 1); - }); - - it('updates user.tasksOrder.todos when multiple todos are created', async () => { - let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let [task, task2] = await user.post('/tasks/user', [{ - type: 'todo', - text: 'a todo', - }, { - type: 'todo', - text: 'another todo', - }]); - - let updatedUser = await user.get('/user'); - expect(updatedUser.tasksOrder.todos[0]).to.eql(task2._id); - expect(updatedUser.tasksOrder.todos[1]).to.eql(task._id); - expect(updatedUser.tasksOrder.todos.length).to.eql(originalTodosOrderLen + 2); - }); - - it('can create checklists', async () => { - let task = await user.post('/tasks/user', { - text: 'test todo', - type: 'todo', - checklist: [ - {completed: false, text: 'checklist'}, - ], - }); - - expect(task.checklist).to.be.an('array'); - expect(task.checklist.length).to.eql(1); - expect(task.checklist[0]).to.be.an('object'); - expect(task.checklist[0].text).to.eql('checklist'); - expect(task.checklist[0].completed).to.eql(false); - expect(task.checklist[0].id).to.be.a('string'); - }); - }); - - context('dailys', () => { - it('creates a daily', async () => { - let now = new Date(); - - let task = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - notes: 1976, - frequency: 'daily', - everyX: 5, - startDate: now, - }); - - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test daily'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('daily'); - expect(task.frequency).to.eql('daily'); - expect(task.everyX).to.eql(5); - expect(new Date(task.startDate)).to.eql(now); - }); - - it('creates multiple dailys', async () => { - let [task, task2] = await user.post('/tasks/user', [{ - text: 'test daily', - type: 'daily', - notes: 1976, - }, { - text: 'test daily 2', - type: 'daily', - notes: 1977, - }]); - - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test daily'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('daily'); - - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test daily 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('daily'); - }); - - it('updates user.tasksOrder.dailys when a new daily is created', async () => { - let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let task = await user.post('/tasks/user', { - type: 'daily', - text: 'a daily', - }); - - let updatedUser = await user.get('/user'); - expect(updatedUser.tasksOrder.dailys[0]).to.eql(task._id); - expect(updatedUser.tasksOrder.dailys.length).to.eql(originalDailysOrderLen + 1); - }); - - it('updates user.tasksOrder.dailys when multiple dailys are created', async () => { - let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let [task, task2] = await user.post('/tasks/user', [{ - type: 'daily', - text: 'a daily', - }, { - type: 'daily', - text: 'another daily', - }]); - - let updatedUser = await user.get('/user'); - expect(updatedUser.tasksOrder.dailys[0]).to.eql(task2._id); - expect(updatedUser.tasksOrder.dailys[1]).to.eql(task._id); - expect(updatedUser.tasksOrder.dailys.length).to.eql(originalDailysOrderLen + 2); - }); - - it('defaults to a weekly frequency, with every day set', async () => { - let task = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - }); - - expect(task.frequency).to.eql('weekly'); - expect(task.everyX).to.eql(1); - expect(task.repeat).to.eql({ - m: true, - t: true, - w: true, - th: true, - f: true, - s: true, - su: true, - }); - }); - - it('allows repeat field to be configured', async () => { - let task = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - repeat: { - m: false, - w: false, - su: false, - }, - }); - - expect(task.repeat).to.eql({ - m: false, - t: true, - w: false, - th: true, - f: true, - s: true, - su: false, - }); - }); - - it('defaults startDate to today', async () => { - let today = (new Date()).getDay(); - - let task = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - }); - - expect((new Date(task.startDate)).getDay()).to.eql(today); - }); - - it('can create checklists', async () => { - let task = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - checklist: [ - {completed: false, text: 'checklist'}, - ], - }); - - expect(task.checklist).to.be.an('array'); - expect(task.checklist.length).to.eql(1); - expect(task.checklist[0]).to.be.an('object'); - expect(task.checklist[0].text).to.eql('checklist'); - expect(task.checklist[0].completed).to.eql(false); - expect(task.checklist[0].id).to.be.a('string'); - }); - }); - - context('rewards', () => { - it('creates a reward', async () => { - let task = await user.post('/tasks/user', { - text: 'test reward', - type: 'reward', - notes: 1976, - value: 10, - }); - - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test reward'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('reward'); - expect(task.value).to.eql(10); - }); - - it('creates multiple rewards', async () => { - let [task, task2] = await user.post('/tasks/user', [{ - text: 'test reward', - type: 'reward', - notes: 1976, - value: 11, - }, { - text: 'test reward 2', - type: 'reward', - notes: 1977, - value: 12, - }]); - - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test reward'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('reward'); - expect(task.value).to.eql(11); - - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test reward 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('reward'); - expect(task2.value).to.eql(12); - }); - - it('updates user.tasksOrder.rewards when a new reward is created', async () => { - let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let task = await user.post('/tasks/user', { - type: 'reward', - text: 'a reward', - }); - - let updatedUser = await user.get('/user'); - expect(updatedUser.tasksOrder.rewards[0]).to.eql(task._id); - expect(updatedUser.tasksOrder.rewards.length).to.eql(originalRewardsOrderLen + 1); - }); - - it('updates user.tasksOrder.dreward when multiple rewards are created', async () => { - let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let [task, task2] = await user.post('/tasks/user', [{ - type: 'reward', - text: 'a reward', - }, { - type: 'reward', - text: 'another reward', - }]); - - let updatedUser = await user.get('/user'); - expect(updatedUser.tasksOrder.rewards[0]).to.eql(task2._id); - expect(updatedUser.tasksOrder.rewards[1]).to.eql(task._id); - expect(updatedUser.tasksOrder.rewards.length).to.eql(originalRewardsOrderLen + 2); - }); - - it('defaults to a 0 value', async () => { - let task = await user.post('/tasks/user', { - text: 'test reward', - type: 'reward', - }); - - expect(task.value).to.eql(0); - }); - - it('requires value to be coerced into a number', async () => { - let task = await user.post('/tasks/user', { - text: 'test reward', - type: 'reward', - value: '10', - }); - - expect(task.value).to.eql(10); - }); - - it('cannot create checklists', async () => { - let task = await user.post('/tasks/user', { - text: 'test reward', - type: 'reward', - checklist: [ - {_id: 123, completed: false, text: 'checklist'}, - ], - }); - - expect(task).not.to.have.property('checklist'); - }); - }); -}); diff --git a/test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js b/test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js deleted file mode 100644 index 88343cb47c..0000000000 --- a/test/api/v3/integration/tasks/PUT-tasks_challenge_challengeId.test.js +++ /dev/null @@ -1,323 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('PUT /tasks/:id', () => { - let user; - let guild; - let challenge; - - before(async () => { - user = await generateUser(); - guild = await generateGroup(user); - challenge = await generateChallenge(user, guild); - }); - - context('errors', () => { - let task; - - beforeEach(async () => { - task = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test habit', - type: 'habit', - }); - }); - - it('returns error when incorrect id is passed', async () => { - await expect(user.put(`/tasks/${generateUUID()}`, { - text: 'some new text', - up: false, - down: false, - notes: 'some new notes', - })).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('returns error when user is not a member of the challenge', async () => { - let anotherUser = await generateUser(); - - await expect(anotherUser.put(`/tasks/${task._id}`, { - text: 'some new text', - up: false, - down: false, - notes: 'some new notes', - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyChalLeaderEditTasks'), - }); - }); - }); - - context('validates params', () => { - let task; - - beforeEach(async () => { - task = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test habit', - type: 'habit', - }); - }); - - it(`ignores setting _id, type, userId, history, createdAt, - updatedAt, challenge, completed, streak, - dateCompleted fields`, async () => { - let savedTask = await user.put(`/tasks/${task._id}`, { - _id: 123, - type: 'daily', - userId: 123, - history: [123], - createdAt: 'yesterday', - updatedAt: 'tomorrow', - challenge: 'no', - completed: true, - streak: 25, - dateCompleted: 'never', - value: 324, // ignored because not a reward - }); - - expect(savedTask._id).to.equal(task._id); - expect(savedTask.type).to.equal(task.type); - expect(savedTask.userId).to.equal(task.userId); - expect(savedTask.history).to.eql(task.history); - expect(savedTask.createdAt).to.equal(task.createdAt); - expect(savedTask.updatedAt).to.be.greaterThan(task.updatedAt); - expect(savedTask.challenge._id).to.equal(task.challenge._id); - expect(savedTask.completed).to.equal(task.completed); - expect(savedTask.streak).to.equal(task.streak); - expect(savedTask.dateCompleted).to.equal(task.dateCompleted); - expect(savedTask.value).to.equal(task.value); - }); - - it('ignores invalid fields', async () => { - let savedTask = await user.put(`/tasks/${task._id}`, { - notValid: true, - }); - - expect(savedTask.notValid).to.be.undefined; - }); - }); - - context('habits', () => { - let habit; - - beforeEach(async () => { - habit = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test habit', - type: 'habit', - notes: 1976, - }); - }); - - it('updates a habit', async () => { - let savedHabit = await user.put(`/tasks/${habit._id}`, { - text: 'some new text', - up: false, - down: false, - notes: 'some new notes', - }); - - expect(savedHabit.text).to.eql('some new text'); - expect(savedHabit.notes).to.eql('some new notes'); - expect(savedHabit.up).to.eql(false); - expect(savedHabit.down).to.eql(false); - }); - }); - - context('todos', () => { - let todo; - - beforeEach(async () => { - todo = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test todo', - type: 'todo', - notes: 1976, - }); - }); - - it('updates a todo', async () => { - let savedTodo = await user.put(`/tasks/${todo._id}`, { - text: 'some new text', - notes: 'some new notes', - }); - - expect(savedTodo.text).to.eql('some new text'); - expect(savedTodo.notes).to.eql('some new notes'); - }); - - it('can update checklists (replace it)', async () => { - await user.put(`/tasks/${todo._id}`, { - checklist: [ - {text: 123, completed: false}, - {text: 456, completed: true}, - ], - }); - - let savedTodo = await user.put(`/tasks/${todo._id}`, { - checklist: [ - {text: 789, completed: false}, - ], - }); - - expect(savedTodo.checklist.length).to.equal(1); - expect(savedTodo.checklist[0].text).to.equal('789'); - expect(savedTodo.checklist[0].completed).to.equal(false); - }); - - it('can update tags (replace them)', async () => { - let finalUUID = generateUUID(); - await user.put(`/tasks/${todo._id}`, { - tags: [generateUUID(), generateUUID()], - }); - - let savedTodo = await user.put(`/tasks/${todo._id}`, { - tags: [finalUUID], - }); - - expect(savedTodo.tags.length).to.equal(1); - expect(savedTodo.tags[0]).to.equal(finalUUID); - }); - }); - - context('dailys', () => { - let daily; - - beforeEach(async () => { - daily = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test daily', - type: 'daily', - notes: 1976, - }); - }); - - it('updates a daily', async () => { - let savedDaily = await user.put(`/tasks/${daily._id}`, { - text: 'some new text', - notes: 'some new notes', - frequency: 'daily', - everyX: 5, - }); - - expect(savedDaily.text).to.eql('some new text'); - expect(savedDaily.notes).to.eql('some new notes'); - expect(savedDaily.frequency).to.eql('daily'); - expect(savedDaily.everyX).to.eql(5); - }); - - it('can update checklists (replace it)', async () => { - await user.put(`/tasks/${daily._id}`, { - checklist: [ - {text: 123, completed: false}, - {text: 456, completed: true}, - ], - }); - - let savedDaily = await user.put(`/tasks/${daily._id}`, { - checklist: [ - {text: 789, completed: false}, - ], - }); - - expect(savedDaily.checklist.length).to.equal(1); - expect(savedDaily.checklist[0].text).to.equal('789'); - expect(savedDaily.checklist[0].completed).to.equal(false); - }); - - it('can update tags (replace them)', async () => { - let finalUUID = generateUUID(); - await user.put(`/tasks/${daily._id}`, { - tags: [generateUUID(), generateUUID()], - }); - - let savedDaily = await user.put(`/tasks/${daily._id}`, { - tags: [finalUUID], - }); - - expect(savedDaily.tags.length).to.equal(1); - expect(savedDaily.tags[0]).to.equal(finalUUID); - }); - - it('updates repeat, even if frequency is set to daily', async () => { - await user.put(`/tasks/${daily._id}`, { - frequency: 'daily', - }); - - let savedDaily = await user.put(`/tasks/${daily._id}`, { - repeat: { - m: false, - su: false, - }, - }); - - expect(savedDaily.repeat).to.eql({ - m: false, - t: true, - w: true, - th: true, - f: true, - s: true, - su: false, - }); - }); - - it('updates everyX, even if frequency is set to weekly', async () => { - await user.put(`/tasks/${daily._id}`, { - frequency: 'weekly', - }); - - let savedDaily = await user.put(`/tasks/${daily._id}`, { - everyX: 5, - }); - - expect(savedDaily.everyX).to.eql(5); - }); - - it('defaults startDate to today if none date object is passed in', async () => { - let savedDaily = await user.put(`/tasks/${daily._id}`, { - frequency: 'weekly', - }); - - expect((new Date(savedDaily.startDate)).getDay()).to.eql((new Date()).getDay()); - }); - }); - - context('rewards', () => { - let reward; - - beforeEach(async () => { - reward = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test reward', - type: 'reward', - notes: 1976, - value: 10, - }); - }); - - it('updates a reward', async () => { - let savedReward = await user.put(`/tasks/${reward._id}`, { - text: 'some new text', - notes: 'some new notes', - value: 11, - }); - - expect(savedReward.text).to.eql('some new text'); - expect(savedReward.notes).to.eql('some new notes'); - expect(savedReward.value).to.eql(11); - }); - - it('requires value to be coerced into a number', async () => { - let savedReward = await user.put(`/tasks/${reward._id}`, { - value: '100', - }); - - expect(savedReward.value).to.eql(100); - }); - }); -}); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js deleted file mode 100644 index 1325f8b392..0000000000 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ /dev/null @@ -1,396 +0,0 @@ -import { - generateUser, - generateGroup, - sleep, - generateChallenge, -} from '../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('PUT /tasks/:id', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - context('validates params', () => { - let task; - - beforeEach(async () => { - task = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - }); - }); - - it(`ignores setting _id, type, userId, history, createdAt, - updatedAt, challenge, completed, streak, - dateCompleted fields`, async () => { - let savedTask = await user.put(`/tasks/${task._id}`, { - _id: 123, - type: 'daily', - userId: 123, - history: [123], - createdAt: 'yesterday', - updatedAt: 'tomorrow', - challenge: 'no', - completed: true, - streak: 25, - dateCompleted: 'never', - }); - - expect(savedTask._id).to.equal(task._id); - expect(savedTask.type).to.equal(task.type); - expect(savedTask.userId).to.equal(task.userId); - expect(savedTask.history).to.eql(task.history); - expect(savedTask.createdAt).to.equal(task.createdAt); - expect(savedTask.updatedAt).to.be.greaterThan(task.updatedAt); - expect(savedTask.challenge).to.equal(task.challenge); - expect(savedTask.completed).to.eql(task.completed); - expect(savedTask.streak).to.equal(savedTask.streak); // it's an habit, dailies can change it - expect(savedTask.dateCompleted).to.equal(task.dateCompleted); - }); - - it('ignores invalid fields', async () => { - let savedTask = await user.put(`/tasks/${task._id}`, { - notValid: true, - }); - - expect(savedTask.notValid).to.be.undefined; - }); - - it(`only allows setting streak, reminders, checklist, notes, attribute, tags - fields for challenge tasks owned by a user`, async () => { - let guild = await generateGroup(user); - let challenge = await generateChallenge(user, guild); - - let challengeTask = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'daily', - text: 'Daily in challenge', - reminders: [ - {time: new Date(), startDate: new Date()}, - ], - checklist: [ - {text: 123, completed: false}, - ], - }); - await sleep(2); - - await user.sync(); - - // Pick challenge task - let challengeUserTaskId = user.tasksOrder.dailys[user.tasksOrder.dailys.length - 1]; - - let challengeUserTask = await user.get(`/tasks/${challengeUserTaskId}`); - - let savedChallengeUserTask = await user.put(`/tasks/${challengeUserTaskId}`, { - _id: 123, - type: 'daily', - userId: 123, - history: [123], - createdAt: 'yesterday', - updatedAt: 'tomorrow', - challenge: 'no', - completed: true, - streak: 25, - priority: 1.5, - repeat: { - m: false, - }, - everyX: 15, - frequency: 'weekly', - text: 'new text', - dateCompleted: 'never', - reminders: [ - {time: new Date(), startDate: new Date()}, - {time: new Date(), startDate: new Date()}, - ], - checklist: [ - {text: 123, completed: false}, - {text: 456, completed: true}, - ], - notes: 'new notes', - attribute: 'per', - tags: [challengeUserTaskId], - }); - - // original task is not touched - let updatedChallengeTask = await user.get(`/tasks/${challengeTask._id}`); - expect(updatedChallengeTask).to.eql(challengeTask); - - // ignored - expect(savedChallengeUserTask._id).to.equal(challengeUserTask._id); - expect(savedChallengeUserTask.type).to.equal(challengeUserTask.type); - expect(savedChallengeUserTask.repeat.m).to.equal(true); - expect(savedChallengeUserTask.priority).to.equal(challengeUserTask.priority); - expect(savedChallengeUserTask.frequency).to.equal(challengeUserTask.frequency); - expect(savedChallengeUserTask.userId).to.equal(challengeUserTask.userId); - expect(savedChallengeUserTask.text).to.equal(challengeUserTask.text); - expect(savedChallengeUserTask.history).to.eql(challengeUserTask.history); - expect(savedChallengeUserTask.createdAt).to.equal(challengeUserTask.createdAt); - expect(savedChallengeUserTask.updatedAt).to.be.greaterThan(challengeUserTask.updatedAt); - expect(savedChallengeUserTask.challenge).to.eql(challengeUserTask.challenge); - expect(savedChallengeUserTask.completed).to.equal(challengeUserTask.completed); - expect(savedChallengeUserTask.dateCompleted).to.equal(challengeUserTask.dateCompleted); - expect(savedChallengeUserTask.priority).to.equal(challengeUserTask.priority); - - // changed - expect(savedChallengeUserTask.notes).to.equal('new notes'); - expect(savedChallengeUserTask.attribute).to.equal('per'); - expect(savedChallengeUserTask.tags).to.eql([challengeUserTaskId]); - expect(savedChallengeUserTask.streak).to.equal(25); - expect(savedChallengeUserTask.reminders.length).to.equal(2); - expect(savedChallengeUserTask.checklist.length).to.equal(2); - }); - }); - - context('all types', () => { - let daily; - - beforeEach(async () => { - daily = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - notes: 1976, - }); - }); - - it('can update reminders (replace them)', async () => { - await user.put(`/tasks/${daily._id}`, { - reminders: [ - {time: new Date(), startDate: new Date()}, - ], - }); - - let id1 = generateUUID(); - let id2 = generateUUID(); - - let savedDaily = await user.put(`/tasks/${daily._id}`, { - reminders: [ - {id: id1, time: new Date(), startDate: new Date()}, - {id: id2, time: new Date(), startDate: new Date()}, - ], - }); - - expect(savedDaily.reminders.length).to.equal(2); - expect(savedDaily.reminders[0].id).to.equal(id1); - expect(savedDaily.reminders[1].id).to.equal(id2); - }); - }); - - context('habits', () => { - let habit; - - beforeEach(async () => { - habit = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - notes: 1976, - }); - }); - - it('updates a habit', async () => { - let savedHabit = await user.put(`/tasks/${habit._id}`, { - text: 'some new text', - up: false, - down: false, - notes: 'some new notes', - }); - - expect(savedHabit.text).to.eql('some new text'); - expect(savedHabit.notes).to.eql('some new notes'); - expect(savedHabit.up).to.eql(false); - expect(savedHabit.down).to.eql(false); - }); - }); - - context('todos', () => { - let todo; - - beforeEach(async () => { - todo = await user.post('/tasks/user', { - text: 'test todo', - type: 'todo', - notes: 1976, - }); - }); - - it('updates a todo', async () => { - let savedTodo = await user.put(`/tasks/${todo._id}`, { - text: 'some new text', - notes: 'some new notes', - }); - - expect(savedTodo.text).to.eql('some new text'); - expect(savedTodo.notes).to.eql('some new notes'); - }); - - it('can update checklists (replace it)', async () => { - await user.put(`/tasks/${todo._id}`, { - checklist: [ - {text: 123, completed: false}, - {text: 456, completed: true}, - ], - }); - - let savedTodo = await user.put(`/tasks/${todo._id}`, { - checklist: [ - {text: 789, completed: false}, - ], - }); - - expect(savedTodo.checklist.length).to.equal(1); - expect(savedTodo.checklist[0].text).to.equal('789'); - expect(savedTodo.checklist[0].completed).to.equal(false); - }); - - it('can update tags (replace them)', async () => { - let finalUUID = generateUUID(); - await user.put(`/tasks/${todo._id}`, { - tags: [generateUUID(), generateUUID()], - }); - - let savedTodo = await user.put(`/tasks/${todo._id}`, { - tags: [finalUUID], - }); - - expect(savedTodo.tags.length).to.equal(1); - expect(savedTodo.tags[0]).to.equal(finalUUID); - }); - }); - - context('dailys', () => { - let daily; - - beforeEach(async () => { - daily = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - notes: 1976, - }); - }); - - it('updates a daily', async () => { - let savedDaily = await user.put(`/tasks/${daily._id}`, { - text: 'some new text', - notes: 'some new notes', - frequency: 'daily', - everyX: 5, - }); - - expect(savedDaily.text).to.eql('some new text'); - expect(savedDaily.notes).to.eql('some new notes'); - expect(savedDaily.frequency).to.eql('daily'); - expect(savedDaily.everyX).to.eql(5); - }); - - it('can update checklists (replace it)', async () => { - await user.put(`/tasks/${daily._id}`, { - checklist: [ - {text: 123, completed: false}, - {text: 456, completed: true}, - ], - }); - - let savedDaily = await user.put(`/tasks/${daily._id}`, { - checklist: [ - {text: 789, completed: false}, - ], - }); - - expect(savedDaily.checklist.length).to.equal(1); - expect(savedDaily.checklist[0].text).to.equal('789'); - expect(savedDaily.checklist[0].completed).to.equal(false); - }); - - it('can update tags (replace them)', async () => { - let finalUUID = generateUUID(); - await user.put(`/tasks/${daily._id}`, { - tags: [generateUUID(), generateUUID()], - }); - - let savedDaily = await user.put(`/tasks/${daily._id}`, { - tags: [finalUUID], - }); - - expect(savedDaily.tags.length).to.equal(1); - expect(savedDaily.tags[0]).to.equal(finalUUID); - }); - - it('updates repeat, even if frequency is set to daily', async () => { - await user.put(`/tasks/${daily._id}`, { - frequency: 'daily', - }); - - let savedDaily = await user.put(`/tasks/${daily._id}`, { - repeat: { - m: false, - su: false, - }, - }); - - expect(savedDaily.repeat).to.eql({ - m: false, - t: true, - w: true, - th: true, - f: true, - s: true, - su: false, - }); - }); - - it('updates everyX, even if frequency is set to weekly', async () => { - await user.put(`/tasks/${daily._id}`, { - frequency: 'weekly', - }); - - let savedDaily = await user.put(`/tasks/${daily._id}`, { - everyX: 5, - }); - - expect(savedDaily.everyX).to.eql(5); - }); - - it('defaults startDate to today if none date object is passed in', async () => { - let savedDaily = await user.put(`/tasks/${daily._id}`, { - frequency: 'weekly', - }); - - expect((new Date(savedDaily.startDate)).getDay()).to.eql((new Date()).getDay()); - }); - }); - - context('rewards', () => { - let reward; - - beforeEach(async () => { - reward = await user.post('/tasks/user', { - text: 'test reward', - type: 'reward', - notes: 1976, - value: 10, - }); - }); - - it('updates a reward', async () => { - let savedReward = await user.put(`/tasks/${reward._id}`, { - text: 'some new text', - notes: 'some new notes', - value: 10, - }); - - expect(savedReward.text).to.eql('some new text'); - expect(savedReward.notes).to.eql('some new notes'); - expect(savedReward.value).to.eql(10); - }); - - it('requires value to be coerced into a number', async () => { - let savedReward = await user.put(`/tasks/${reward._id}`, { - value: '100', - }); - - expect(savedReward.value).to.eql(100); - }); - }); -}); diff --git a/test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js b/test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js deleted file mode 100644 index eac6d455ea..0000000000 --- a/test/api/v3/integration/tasks/challenges/DELETE-tasks_challenge_challengeId_checklist_itemId.test.js +++ /dev/null @@ -1,115 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('DELETE /tasks/:taskId/checklist/:itemId', () => { - let user; - let guild; - let challenge; - - before(async () => { - user = await generateUser(); - guild = await generateGroup(user); - challenge = await generateChallenge(user, guild); - }); - - it('fails on task not found', async () => { - await expect(user.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('fails on checklist item not found', async () => { - let createdTask = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'daily', - text: 'daily with checklist', - }); - - await expect(user.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('checklistItemNotFound'), - }); - }); - - it('returns error when user is not a member of the challenge', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'daily', - text: 'Daily with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, { - text: 'Checklist Item 1', - completed: false, - }); - - let anotherUser = await generateUser(); - - await expect(anotherUser.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyChalLeaderEditTasks'), - }); - }); - - it('deletes a checklist item from a daily', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'daily', - text: 'Daily with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - - await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`); - savedTask = await user.get(`/tasks/${task._id}`); - - expect(savedTask.checklist.length).to.equal(0); - }); - - it('deletes a checklist item from a todo', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'todo', - text: 'Todo with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - - await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`); - savedTask = await user.get(`/tasks/${task._id}`); - - expect(savedTask.checklist.length).to.equal(0); - }); - - it('does not work with habits', async () => { - let habit = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'habit', - text: 'habit with checklist', - }); - - await expect(user.del(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('does not work with rewards', async () => { - let reward = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'reward', - text: 'reward with checklist', - }); - - await expect(user.del(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); -}); diff --git a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js b/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js deleted file mode 100644 index 34c93c2f05..0000000000 --- a/test/api/v3/integration/tasks/challenges/DELETE-tasks_id_challenge_challengeId.test.js +++ /dev/null @@ -1,115 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, - sleep, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('DELETE /tasks/:id', () => { - let user; - let guild; - let challenge; - let task; - - before(async () => { - user = await generateUser(); - guild = await generateGroup(user); - challenge = await generateChallenge(user, guild); - }); - - beforeEach(async () => { - task = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test habit', - type: 'habit', - }); - }); - - it('cannot delete a non-existant task', async () => { - await expect(user.del(`/tasks/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('returns error when user is not leader of the challenge', async () => { - let anotherUser = await generateUser(); - - await expect(anotherUser.del(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyChalLeaderEditTasks'), - }); - }); - - it('deletes a user\'s task', async () => { - await user.del(`/tasks/${task._id}`); - - await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - context('challenge member', () => { - let anotherUser; - let anotherUsersNewChallengeTaskID; - let newChallengeTask; - - beforeEach(async () => { - anotherUser = await generateUser(); - await user.post(`/groups/${guild._id}/invite`, { uuids: [anotherUser._id] }); - await anotherUser.post(`/groups/${guild._id}/join`); - await anotherUser.post(`/challenges/${challenge._id}/join`); - - newChallengeTask = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test habit', - type: 'habit', - }); - - let anotherUserWithNewChallengeTask = await anotherUser.get('/user'); - anotherUsersNewChallengeTaskID = anotherUserWithNewChallengeTask.tasksOrder.habits[0]; - }); - - it('returns error when user attempts to delete an active challenge task', async () => { - await expect(anotherUser.del(`/tasks/${anotherUsersNewChallengeTaskID}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('cantDeleteChallengeTasks'), - }); - }); - - it('allows user to delete challenge task after user leaves challenge', async () => { - await anotherUser.post(`/challenges/${challenge._id}/leave`); - - await anotherUser.del(`/tasks/${anotherUsersNewChallengeTaskID}`); - - await expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - // TODO for some reason this test fails on TravisCI, review after mongodb indexes have been added - xit('allows user to delete challenge task after challenge task is broken', async () => { - await expect(user.del(`/tasks/${newChallengeTask._id}`)); - - await sleep(2); - - await expect(anotherUser.del(`/tasks/${anotherUsersNewChallengeTaskID}`)); - - await sleep(2); - - await expect(anotherUser.get(`/tasks/${anotherUsersNewChallengeTaskID}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - }); -}); diff --git a/test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js b/test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js deleted file mode 100644 index 2399db04b6..0000000000 --- a/test/api/v3/integration/tasks/challenges/GET_tasks_challenge.id.test.js +++ /dev/null @@ -1,86 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; -import { each } from 'lodash'; - -describe('GET /tasks/challenge/:challengeId', () => { - let user; - let guild; - let challenge; - let task; - let tasks = []; - let challengeWithTask; - let tasksToTest = { - habit: { - text: 'test habit', - type: 'habit', - up: false, - down: true, - }, - todo: { - text: 'test todo', - type: 'todo', - }, - daily: { - text: 'test daily', - type: 'daily', - frequency: 'daily', - everyX: 5, - startDate: new Date(), - }, - reward: { - text: 'test reward', - type: 'reward', - }, - }; - - before(async () => { - user = await generateUser(); - guild = await generateGroup(user); - challenge = await generateChallenge(user, guild); - }); - - it('returns error when challenge is not found', async () => { - let dummyId = generateUUID(); - - await expect(user.get(`/tasks/challenge/${dummyId}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - each(tasksToTest, (taskValue, taskType) => { - context(`${taskType}`, () => { - before(async () => { - task = await user.post(`/tasks/challenge/${challenge._id}`, taskValue); - tasks.push(task); - challengeWithTask = await user.get(`/challenges/${challenge._id}`); - }); - - it('gets challenge tasks', async () => { - let getTask = await user.get(`/tasks/challenge/${challengeWithTask._id}`); - expect(getTask).to.eql(tasks); - }); - - it('gets challenge tasks filtered by type', async () => { - let challengeTasks = await user.get(`/tasks/challenge/${challengeWithTask._id}?type=${task.type}s`); - expect(challengeTasks).to.eql([task]); - }); - - it('cannot get a task owned by someone else', async () => { - let anotherUser = await generateUser(); - - await expect(anotherUser.get(`/tasks/challenge/${challengeWithTask._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - }); - }); -}); diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js deleted file mode 100644 index 06bc7b68b2..0000000000 --- a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_challengeId_taskId_checklist.test.js +++ /dev/null @@ -1,119 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /tasks/:taskId/checklist/', () => { - let user; - let guild; - let challenge; - - before(async () => { - user = await generateUser(); - guild = await generateGroup(user); - challenge = await generateChallenge(user, guild); - }); - - it('fails on task not found', async () => { - await expect(user.post(`/tasks/${generateUUID()}/checklist`, { - text: 'Checklist Item 1', - })).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('returns error when user is not a member of the challenge', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'daily', - text: 'Daily with checklist', - }); - - let anotherUser = await generateUser(); - - await expect(anotherUser.post(`/tasks/${task._id}/checklist`, { - text: 'Checklist Item 1', - ignored: false, - _id: 123, - })) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyChalLeaderEditTasks'), - }); - }); - - it('adds a checklist item to a daily', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'daily', - text: 'Daily with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, { - text: 'Checklist Item 1', - ignored: false, - _id: 123, - }); - - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); - expect(savedTask.checklist[0].completed).to.equal(false); - expect(savedTask.checklist[0].id).to.be.a('string'); - expect(savedTask.checklist[0].id).to.not.equal('123'); - expect(savedTask.checklist[0].ignored).to.be.an('undefined'); - }); - - it('adds a checklist item to a todo', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'todo', - text: 'Todo with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, { - text: 'Checklist Item 1', - ignored: false, - _id: 123, - }); - - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); - expect(savedTask.checklist[0].completed).to.equal(false); - expect(savedTask.checklist[0].id).to.be.a('string'); - expect(savedTask.checklist[0].id).to.not.equal('123'); - expect(savedTask.checklist[0].ignored).to.be.an('undefined'); - }); - - it('does not add a checklist to habits', async () => { - let habit = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'habit', - text: 'habit with checklist', - }); - - await expect(user.post(`/tasks/${habit._id}/checklist`, { - text: 'Checklist Item 1', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('does not add a checklist to rewards', async () => { - let reward = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'reward', - text: 'reward with checklist', - }); - - await expect(user.post(`/tasks/${reward._id}/checklist`, { - text: 'Checklist Item 1', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); -}); diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js deleted file mode 100644 index 62899ffb59..0000000000 --- a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js +++ /dev/null @@ -1,125 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, - translate as t, -} from '../../../../../helpers/api-v3-integration.helper'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /tasks/challenge/:challengeId', () => { - let user; - let guild; - let challenge; - - beforeEach(async () => { - user = await generateUser({balance: 1}); - guild = await generateGroup(user); - challenge = await generateChallenge(user, guild); - }); - - it('returns error when challenge is not found', async () => { - let fakeChallengeId = generateUUID(); - - await expect(user.post(`/tasks/challenge/${fakeChallengeId}`, { - text: 'test habit', - type: 'habit', - up: false, - down: true, - notes: 1976, - })).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('returns error when user does not have the challenge', async () => { - let userWithoutChallenge = await generateUser(); - - await expect(userWithoutChallenge.post(`/tasks/challenge/${challenge._id}`, { - text: 'test habit', - type: 'habit', - up: false, - down: true, - notes: 1976, - })).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('challengeNotFound'), - }); - }); - - it('returns error when non leader tries to edit challenge', async () => { - let userThatIsNotLeaderOfChallenge = await generateUser({ - challenges: [challenge._id], - }); - - await expect(userThatIsNotLeaderOfChallenge.post(`/tasks/challenge/${challenge._id}`, { - text: 'test habit', - type: 'habit', - up: false, - down: true, - notes: 1976, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyChalLeaderEditTasks'), - }); - }); - - it('creates a habit', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test habit', - type: 'habit', - up: false, - down: true, - notes: 1976, - }); - let challengeWithTask = await user.get(`/challenges/${challenge._id}`); - - expect(challengeWithTask.tasksOrder.habits.indexOf(task._id)).to.be.above(-1); - expect(task.challenge.id).to.equal(challenge._id); - expect(task.text).to.eql('test habit'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('habit'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(true); - }); - - it('creates a todo', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test todo', - type: 'todo', - notes: 1976, - }); - let challengeWithTask = await user.get(`/challenges/${challenge._id}`); - - expect(challengeWithTask.tasksOrder.todos.indexOf(task._id)).to.be.above(-1); - expect(task.challenge.id).to.equal(challenge._id); - expect(task.text).to.eql('test todo'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('todo'); - }); - - it('creates a daily', async () => { - let now = new Date(); - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test daily', - type: 'daily', - notes: 1976, - frequency: 'daily', - everyX: 5, - startDate: now, - }); - let challengeWithTask = await user.get(`/challenges/${challenge._id}`); - - expect(challengeWithTask.tasksOrder.dailys.indexOf(task._id)).to.be.above(-1); - expect(task.challenge.id).to.equal(challenge._id); - expect(task.text).to.eql('test daily'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('daily'); - expect(task.frequency).to.eql('daily'); - expect(task.everyX).to.eql(5); - expect(new Date(task.startDate)).to.eql(now); - }); -}); diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js deleted file mode 100644 index a264826884..0000000000 --- a/test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js +++ /dev/null @@ -1,140 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, -} from '../../../../../helpers/api-integration/v3'; -import { find } from 'lodash'; - -describe('POST /tasks/:id/score/:direction', () => { - let user; - let guild; - let challenge; - - before(async () => { - user = await generateUser(); - guild = await generateGroup(user); - challenge = await generateChallenge(user, guild); - }); - - context('habits', () => { - let habit; - let usersChallengeTaskId; - let previousTaskHistory; - - before(async () => { - habit = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test habit', - type: 'habit', - }); - let updatedUser = await user.get('/user'); - usersChallengeTaskId = updatedUser.tasksOrder.habits[0]; - }); - - it('scores and adds history', async () => { - await user.post(`/tasks/${usersChallengeTaskId}/score/up`); - - let tasks = await user.get(`/tasks/challenge/${challenge._id}`); - let task = find(tasks, {_id: habit._id}); - previousTaskHistory = task.history[0]; - - expect(task.value).to.equal(1); - expect(task.history).to.have.lengthOf(1); - }); - - it('should update the history', async () => { - await user.post(`/tasks/${usersChallengeTaskId}/score/up`); - - let tasks = await user.get(`/tasks/challenge/${challenge._id}`); - let task = find(tasks, {_id: habit._id}); - - expect(task.history).to.have.lengthOf(1); - expect(task.history[0].date).to.not.equal(previousTaskHistory.date); - expect(task.history[0].value).to.not.equal(previousTaskHistory.value); - }); - }); - - context('dailies', () => { - let daily; - let usersChallengeTaskId; - let previousTaskHistory; - - before(async () => { - daily = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test daily', - type: 'daily', - }); - let updatedUser = await user.get('/user'); - usersChallengeTaskId = updatedUser.tasksOrder.dailys[0]; - }); - - it('it scores and adds history', async () => { - await user.post(`/tasks/${usersChallengeTaskId}/score/up`); - - let tasks = await user.get(`/tasks/challenge/${challenge._id}`); - let task = find(tasks, {_id: daily._id}); - previousTaskHistory = task.history[0]; - - expect(task.history).to.have.lengthOf(1); - expect(task.value).to.equal(1); - }); - - it('should update the history', async () => { - await user.post(`/tasks/${usersChallengeTaskId}/score/up`); - - let tasks = await user.get(`/tasks/challenge/${challenge._id}`); - let task = find(tasks, {_id: daily._id}); - - expect(task.history).to.have.lengthOf(1); - expect(task.history[0].date).to.not.equal(previousTaskHistory.date); - expect(task.history[0].value).to.not.equal(previousTaskHistory.value); - }); - }); - - context('todos', () => { - let todo; - let usersChallengeTaskId; - - before(async () => { - todo = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test todo', - type: 'todo', - }); - let updatedUser = await user.get('/user'); - usersChallengeTaskId = updatedUser.tasksOrder.todos[0]; - }); - - it('scores but does not add history', async () => { - await user.post(`/tasks/${usersChallengeTaskId}/score/up`); - - let tasks = await user.get(`/tasks/challenge/${challenge._id}`); - let task = find(tasks, {_id: todo._id}); - - expect(task.history).to.not.exist; - expect(task.value).to.equal(1); - }); - }); - - context('rewards', () => { - let reward; - let usersChallengeTaskId; - - before(async () => { - reward = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test reward', - type: 'reward', - }); - let updatedUser = await user.get('/user'); - usersChallengeTaskId = updatedUser.tasksOrder.todos[0]; - }); - - it('does not score', async () => { - await user.post(`/tasks/${usersChallengeTaskId}/score/up`); - - let tasks = await user.get(`/tasks/challenge/${challenge._id}`); - let task = find(tasks, {_id: reward._id}); - - expect(task.history).to.not.exist; - expect(task.value).to.equal(0); - }); - }); -}); diff --git a/test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js b/test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js deleted file mode 100644 index 4dc2ceaa3e..0000000000 --- a/test/api/v3/integration/tasks/challenges/PUT-tasks_challenge_challengeId_tasksId_checklist_itemId.test.js +++ /dev/null @@ -1,155 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('PUT /tasks/:taskId/checklist/:itemId', () => { - let user; - let guild; - let challenge; - - before(async () => { - user = await generateUser(); - guild = await generateGroup(user); - challenge = await generateChallenge(user, guild); - }); - - it('fails on task not found', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'todo', - text: 'Todo with checklist', - }); - - await expect(user.put(`/tasks/${task._id}/checklist/${generateUUID()}`, { - text: 'updated', - completed: true, - _id: 123, // ignored - })) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('checklistItemNotFound'), - }); - }); - - it('returns error when user is not a member of the challenge', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'todo', - text: 'Todo with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, { - text: 'Checklist Item 1', - completed: false, - }); - - let anotherUser = await generateUser(); - - await expect(anotherUser.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`, { - text: 'updated', - completed: true, - _id: 123, // ignored - })) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyChalLeaderEditTasks'), - }); - }); - - it('updates a checklist item on dailies', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'daily', - text: 'Daily with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, { - text: 'Checklist Item 1', - completed: false, - }); - - savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`, { - text: 'updated', - completed: true, - _id: 123, // ignored - }); - - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('updated'); - expect(savedTask.checklist[0].completed).to.equal(true); - expect(savedTask.checklist[0].id).to.not.equal('123'); - }); - - it('updates a checklist item on todos', async () => { - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - type: 'todo', - text: 'Todo with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, { - text: 'Checklist Item 1', - completed: false, - }); - - savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`, { - text: 'updated', - completed: true, - _id: 123, // ignored - }); - - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('updated'); - expect(savedTask.checklist[0].completed).to.equal(true); - expect(savedTask.checklist[0].id).to.not.equal('123'); - }); - - it('fails on habits', async () => { - let habit = await user.post('/tasks/user', { - type: 'habit', - text: 'habit with checklist', - }); - - await expect(user.put(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('fails on rewards', async () => { - let reward = await user.post('/tasks/user', { - type: 'reward', - text: 'reward with checklist', - }); - - await expect(user.put(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('fails on task not found', async () => { - await expect(user.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('fails on checklist item not found', async () => { - let createdTask = await user.post('/tasks/user', { - type: 'daily', - text: 'daily with checklist', - }); - - await expect(user.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('checklistItemNotFound'), - }); - }); -}); diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js deleted file mode 100644 index 2cf08bbece..0000000000 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ /dev/null @@ -1,74 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('DELETE /tasks/:taskId/checklist/:itemId', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('deletes a checklist item', async () => { - let task = await user.post('/tasks/user', { - type: 'daily', - text: 'Daily with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - - await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`); - savedTask = await user.get(`/tasks/${task._id}`); - - expect(savedTask.checklist.length).to.equal(0); - }); - - it('does not work with habits', async () => { - let habit = await user.post('/tasks/user', { - type: 'habit', - text: 'habit with checklist', - }); - - await expect(user.del(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('does not work with rewards', async () => { - let reward = await user.post('/tasks/user', { - type: 'reward', - text: 'reward with checklist', - }); - - await expect(user.del(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('fails on task not found', async () => { - await expect(user.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('fails on checklist item not found', async () => { - let createdTask = await user.post('/tasks/user', { - type: 'daily', - text: 'daily with checklist', - }); - - await expect(user.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('checklistItemNotFound'), - }); - }); -}); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js deleted file mode 100644 index 7166db02da..0000000000 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ /dev/null @@ -1,73 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /tasks/:taskId/checklist/', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('adds a checklist item to a task', async () => { - let task = await user.post('/tasks/user', { - type: 'daily', - text: 'Daily with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, { - text: 'Checklist Item 1', - ignored: false, - _id: 123, - }); - - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); - expect(savedTask.checklist[0].completed).to.equal(false); - expect(savedTask.checklist[0].id).to.be.a('string'); - expect(savedTask.checklist[0].id).to.not.equal('123'); - expect(savedTask.checklist[0].ignored).to.be.an('undefined'); - }); - - it('does not add a checklist to habits', async () => { - let habit = await user.post('/tasks/user', { - type: 'habit', - text: 'habit with checklist', - }); - - await expect(user.post(`/tasks/${habit._id}/checklist`, { - text: 'Checklist Item 1', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('does not add a checklist to rewards', async () => { - let reward = await user.post('/tasks/user', { - type: 'reward', - text: 'reward with checklist', - }); - - await expect(user.post(`/tasks/${reward._id}/checklist`, { - text: 'Checklist Item 1', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('fails on task not found', async () => { - await expect(user.post(`/tasks/${generateUUID()}/checklist`, { - text: 'Checklist Item 1', - })).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); -}); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js deleted file mode 100644 index edb65dfb65..0000000000 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /tasks/:taskId/checklist/:itemId/score', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('scores a checklist item', async () => { - let task = await user.post('/tasks/user', { - type: 'daily', - text: 'Daily with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, { - text: 'Checklist Item 1', - completed: false, - }); - - savedTask = await user.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}/score`); - - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].completed).to.equal(true); - }); - - it('fails on habits', async () => { - let habit = await user.post('/tasks/user', { - type: 'habit', - text: 'habit with checklist', - }); - - await expect(user.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, { - text: 'Checklist Item 1', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('fails on rewards', async () => { - let reward = await user.post('/tasks/user', { - type: 'reward', - text: 'reward with checklist', - }); - - await expect(user.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('fails on task not found', async () => { - await expect(user.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('fails on checklist item not found', async () => { - let createdTask = await user.post('/tasks/user', { - type: 'daily', - text: 'daily with checklist', - }); - - await expect(user.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('checklistItemNotFound'), - }); - }); -}); diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js deleted file mode 100644 index 003bcb2650..0000000000 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('PUT /tasks/:taskId/checklist/:itemId', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('updates a checklist item', async () => { - let task = await user.post('/tasks/user', { - type: 'daily', - text: 'Daily with checklist', - }); - - let savedTask = await user.post(`/tasks/${task._id}/checklist`, { - text: 'Checklist Item 1', - completed: false, - }); - - savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0].id}`, { - text: 'updated', - completed: true, - _id: 123, // ignored - }); - - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('updated'); - expect(savedTask.checklist[0].completed).to.equal(true); - expect(savedTask.checklist[0].id).to.not.equal('123'); - }); - - it('fails on habits', async () => { - let habit = await user.post('/tasks/user', { - type: 'habit', - text: 'habit with checklist', - }); - - await expect(user.put(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('fails on rewards', async () => { - let reward = await user.post('/tasks/user', { - type: 'reward', - text: 'reward with checklist', - }); - - await expect(user.put(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('checklistOnlyDailyTodo'), - }); - }); - - it('fails on task not found', async () => { - await expect(user.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('fails on checklist item not found', async () => { - let createdTask = await user.post('/tasks/user', { - type: 'daily', - text: 'daily with checklist', - }); - - await expect(user.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('checklistItemNotFound'), - }); - }); -}); diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js deleted file mode 100644 index ebb1a3c9e8..0000000000 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('DELETE /tasks/:taskId/tags/:tagId', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('removes a tag from a task', async () => { - let task = await user.post('/tasks/user', { - type: 'habit', - text: 'Task with tag', - }); - - let tag = await user.post('/tags', {name: 'Tag 1'}); - - await user.post(`/tasks/${task._id}/tags/${tag.id}`); - await user.del(`/tasks/${task._id}/tags/${tag.id}`); - - let updatedTask = await user.get(`/tasks/${task._id}`); - - expect(updatedTask.tags.length).to.equal(0); - }); - - it('only deletes existing tags', async () => { - let createdTask = await user.post('/tasks/user', { - type: 'habit', - text: 'Task with tag', - }); - - await expect(user.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('tagNotFound'), - }); - }); -}); diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js deleted file mode 100644 index d6cea02036..0000000000 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateUUID } from 'uuid'; - -describe('POST /tasks/:taskId/tags/:tagId', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('adds a tag to a task', async () => { - let task = await user.post('/tasks/user', { - type: 'habit', - text: 'Task with tag', - }); - - let tag = await user.post('/tags', {name: 'Tag 1'}); - let savedTask = await user.post(`/tasks/${task._id}/tags/${tag.id}`); - - expect(savedTask.tags[0]).to.equal(tag.id); - }); - - it('does not add a tag to a task twice', async () => { - let task = await user.post('/tasks/user', { - type: 'habit', - text: 'Task with tag', - }); - - let tag = await user.post('/tags', {name: 'Tag 1'}); - - await user.post(`/tasks/${task._id}/tags/${tag.id}`); - - await expect(user.post(`/tasks/${task._id}/tags/${tag.id}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('alreadyTagged'), - }); - }); - - it('does not add a non existing tag to a task', async () => { - let task = await user.post('/tasks/user', { - type: 'habit', - text: 'Task with tag', - }); - - await expect(user.post(`/tasks/${task._id}/tags/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); -}); diff --git a/test/api/v3/integration/user/DELETE-user.test.js b/test/api/v3/integration/user/DELETE-user.test.js deleted file mode 100644 index 2a284add53..0000000000 --- a/test/api/v3/integration/user/DELETE-user.test.js +++ /dev/null @@ -1,176 +0,0 @@ -import { - checkExistence, - createAndPopulateGroup, - generateGroup, - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import { - find, - each, - map, -} from 'lodash'; -import Bluebird from 'bluebird'; - -describe('DELETE /user', () => { - let user; - let password = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js - - beforeEach(async () => { - user = await generateUser({balance: 10}); - }); - - it('returns an errors if password is wrong', async () => { - await expect(user.del('/user', { - password: 'wrong-password', - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('wrongPassword'), - }); - }); - - it('returns an error if user has active subscription', async () => { - let userWithSubscription = await generateUser({'purchased.plan.customerId': 'fake-customer-id'}); - - await expect(userWithSubscription.del('/user', { - password, - })).to.be.rejected.and.to.eventually.eql({ - code: 401, - error: 'NotAuthorized', - message: t('cannotDeleteActiveAccount'), - }); - }); - - it('deletes the user\'s tasks', async () => { - // gets the user's tasks ids - let ids = []; - each(user.tasksOrder, (idsForOrder) => { - ids.push(...idsForOrder); - }); - - expect(ids.length).to.be.above(0); // make sure the user has some task to delete - - await user.del('/user', { - password, - }); - - await Bluebird.all(map(ids, id => { - return expect(checkExistence('tasks', id)).to.eventually.eql(false); - })); - }); - - it('deletes the user', async () => { - await user.del('/user', { - password, - }); - await expect(checkExistence('users', user._id)).to.eventually.eql(false); - }); - - context('last member of a party', () => { - let party; - - beforeEach(async () => { - party = await generateGroup(user, { - type: 'party', - privacy: 'private', - }); - }); - - it('deletes party when user is the only member', async () => { - await user.del('/user', { - password, - }); - await expect(checkExistence('party', party._id)).to.eventually.eql(false); - }); - }); - - context('last member of a private guild', () => { - let privateGuild; - - beforeEach(async () => { - privateGuild = await generateGroup(user, { - type: 'guild', - privacy: 'private', - }); - }); - - it('deletes guild when user is the only member', async () => { - await user.del('/user', { - password, - }); - await expect(checkExistence('groups', privateGuild._id)).to.eventually.eql(false); - }); - }); - - context('groups user is leader of', () => { - let guild, oldLeader, newLeader; - - beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - type: 'guild', - privacy: 'public', - }, - members: 1, - }); - - guild = group; - newLeader = members[0]; - oldLeader = groupLeader; - }); - - it('chooses new group leader for any group user was the leader of', async () => { - await oldLeader.del('/user', { - password, - }); - - let updatedGuild = await newLeader.get(`/groups/${guild._id}`); - - expect(updatedGuild.leader).to.exist; - expect(updatedGuild.leader._id).to.not.eql(oldLeader._id); - }); - }); - - context('groups user is a part of', () => { - let group1, group2, userToDelete, otherUser; - - beforeEach(async () => { - userToDelete = await generateUser({balance: 10}); - - group1 = await generateGroup(userToDelete, { - type: 'guild', - privacy: 'public', - }); - - let {group, members} = await createAndPopulateGroup({ - groupDetails: { - type: 'guild', - privacy: 'public', - }, - members: 3, - }); - - group2 = group; - otherUser = members[0]; - - await userToDelete.post(`/groups/${group2._id}/join`); - }); - - it('removes user from all groups user was a part of', async () => { - await userToDelete.del('/user', { - password, - }); - - let updatedGroup1Members = await otherUser.get(`/groups/${group1._id}/members`); - let updatedGroup2Members = await otherUser.get(`/groups/${group2._id}/members`); - let userInGroup = find(updatedGroup2Members, (member) => { - return member._id === userToDelete._id; - }); - - expect(updatedGroup1Members).to.be.empty; - expect(updatedGroup2Members).to.not.be.empty; - expect(userInGroup).to.not.exist; - }); - }); -}); diff --git a/test/api/v3/integration/user/DELETE-user_delete_webhook.test.js b/test/api/v3/integration/user/DELETE-user_delete_webhook.test.js deleted file mode 100644 index 46844dd855..0000000000 --- a/test/api/v3/integration/user/DELETE-user_delete_webhook.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -let user; -let endpoint = '/user/webhook'; - -describe('DELETE /user/webhook', () => { - beforeEach(async () => { - user = await generateUser(); - }); - - it('succeeds', async () => { - let id = 'some-id'; - user.preferences.webhooks[id] = { url: 'http://some-url.com', enabled: true }; - await user.sync(); - expect(user.preferences.webhooks).to.eql({}); - let response = await user.del(`${endpoint}/${id}`); - expect(response).to.eql({}); - await user.sync(); - expect(user.preferences.webhooks).to.eql({}); - }); -}); diff --git a/test/api/v3/integration/user/DELETE-user_messages.test.js b/test/api/v3/integration/user/DELETE-user_messages.test.js deleted file mode 100644 index 98df8e0209..0000000000 --- a/test/api/v3/integration/user/DELETE-user_messages.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('DELETE user message', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ inbox: { messages: { first: 'message', second: 'message' } } }); - expect(user.inbox.messages.first).to.eql('message'); - expect(user.inbox.messages.second).to.eql('message'); - }); - - it('one message', async () => { - let result = await user.del('/user/messages/first'); - await user.sync(); - expect(result).to.eql({ second: 'message' }); - expect(user.inbox.messages).to.eql({ second: 'message' }); - }); - - it('clear all', async () => { - let result = await user.del('/user/messages'); - await user.sync(); - expect(user.inbox.messages).to.eql({}); - expect(result).to.eql({}); - }); -}); diff --git a/test/api/v3/integration/user/GET-user.test.js b/test/api/v3/integration/user/GET-user.test.js deleted file mode 100644 index f4ed75f03f..0000000000 --- a/test/api/v3/integration/user/GET-user.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('GET /user', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('returns the authenticated user', async () => { - let returnedUser = await user.get('/user'); - expect(returnedUser._id).to.equal(user._id); - }); - - it('does not return private paths (and apiToken)', async () => { - let returnedUser = await user.get('/user'); - - expect(returnedUser.auth.local.hashed_password).to.not.exist; - expect(returnedUser.auth.local.salt).to.not.exist; - expect(returnedUser.apiToken).to.not.exist; - }); -}); diff --git a/test/api/v3/integration/user/GET-user_anonymized.test.js b/test/api/v3/integration/user/GET-user_anonymized.test.js deleted file mode 100644 index bbcaaf6249..0000000000 --- a/test/api/v3/integration/user/GET-user_anonymized.test.js +++ /dev/null @@ -1,90 +0,0 @@ -import { - generateUser, - generateHabit, - generateDaily, - generateReward, -} from '../../../../helpers/api-integration/v3'; -import common from '../../../../../common'; -import { v4 as generateUUID } from 'uuid'; - -describe('GET /user/anonymized', () => { - let user; - let endpoint = '/user/anonymized'; - - before(async () => { - user = await generateUser(); - await user.update({ newMessages: ['some', 'new', 'messages'], profile: 'profile', 'purchased.plan': 'purchased plan', - contributor: 'contributor', invitations: 'invitations', 'items.special.nyeReceived': 'some', 'items.special.valentineReceived': 'some', - webhooks: 'some', 'achievements.challenges': 'some', - 'inbox.messages': [{ text: 'some text' }], - tags: [{ name: 'some name', challenge: 'some challenge' }], - }); - - await generateHabit({ userId: user._id }); - await generateHabit({ userId: user._id, text: generateUUID() }); - let daily = await generateDaily({ userId: user._id, checklist: [{ completed: false, text: 'this-text' }] }); - expect(daily.checklist[0].text.substr(0, 5)).to.not.eql('item '); - await generateReward({ userId: user._id, text: 'some text 4' }); - - expect(user.newMessages).to.exist; - expect(user.profile).to.exist; - expect(user.purchased.plan).to.exist; - expect(user.contributor).to.exist; - expect(user.invitations).to.exist; - expect(user.items.special.nyeReceived).to.exist; - expect(user.items.special.valentineReceived).to.exist; - expect(user.webhooks).to.exist; - expect(user.achievements.challenges).to.exist; - expect(user.inbox.messages[0].text).to.exist; - expect(user.inbox.messages[0].text).to.not.eql('inbox message text'); - expect(user.tags[0].name).to.exist; - expect(user.tags[0].name).to.not.eql('tag'); - expect(user.tags[0].challenge).to.not.eql('challenge'); - }); - - it('returns the authenticated user', async () => { - let returnedUser = await user.get(endpoint); - returnedUser = returnedUser.user; - expect(returnedUser._id).to.equal(user._id); - }); - - it('does not return private paths (and apiToken)', async () => { - let returnedUser = await user.get(endpoint); - let tasks2 = returnedUser.tasks; - returnedUser = returnedUser.user; - expect(returnedUser.auth.local).to.not.exist; - expect(returnedUser.apiToken).to.not.exist; - expect(returnedUser.stats.maxHealth).to.eql(common.maxHealth); - expect(returnedUser.stats.toNextLevel).to.eql(common.tnl(user.stats.lvl)); - expect(returnedUser.stats.maxMP).to.eql(30); // TODO why 30? - expect(returnedUser.newMessages).to.not.exist; - expect(returnedUser.profile).to.not.exist; - expect(returnedUser.purchased.plan).to.not.exist; - expect(returnedUser.contributor).to.not.exist; - expect(returnedUser.invitations).to.not.exist; - expect(returnedUser.items.special.nyeReceived).to.not.exist; - expect(returnedUser.items.special.valentineReceived).to.not.exist; - expect(returnedUser.webhooks).to.not.exist; - expect(returnedUser.achievements.challenges).to.not.exist; - _.forEach(returnedUser.inbox.messages, (msg) => { - expect(msg.text).to.eql('inbox message text'); - }); - _.forEach(returnedUser.tags, (tag) => { - expect(tag.name).to.eql('tag'); - expect(tag.challenge).to.eql('challenge'); - }); - // tasks - expect(tasks2).to.exist; - expect(tasks2.length).to.eql(5); // +1 because generateUser() assigns one todo - expect(tasks2[0].checklist).to.exist; - _.forEach(tasks2, (task) => { - expect(task.text).to.eql('task text'); - expect(task.notes).to.eql('task notes'); - if (task.checklist) { - _.forEach(task.checklist, (c) => { - expect(c.text.substr(0, 5)).to.eql('item '); - }); - } - }); - }); -}); diff --git a/test/api/v3/integration/user/GET-user_inventory_buy.test.js b/test/api/v3/integration/user/GET-user_inventory_buy.test.js deleted file mode 100644 index fd2a25b4ee..0000000000 --- a/test/api/v3/integration/user/GET-user_inventory_buy.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('GET /user/inventory/buy', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('returns the gear items available for purchase', async () => { - let buyList = await user.get('/user/inventory/buy'); - - expect(_.find(buyList, item => { - return item.text === t('armorWarrior1Text'); - })).to.exist; - - expect(_.find(buyList, item => { - return item.text === t('armorWarrior2Text'); - })).to.not.exist; - }); -}); diff --git a/test/api/v3/integration/user/POST-user_addPushDevice.test.js b/test/api/v3/integration/user/POST-user_addPushDevice.test.js deleted file mode 100644 index 1a3a5d4f03..0000000000 --- a/test/api/v3/integration/user/POST-user_addPushDevice.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/addPushDevice', () => { - let user; - let regId = '10'; - let type = 'someRandomType'; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns an error if user already has the push device', async () => { - await user.post('/user/addPushDevice', {type, regId}); - await expect(user.post('/user/addPushDevice', {type, regId})) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('pushDeviceAlreadyAdded'), - }); - }); - - // More tests in common code unit tests - - it('adds a push device to the user', async () => { - let response = await user.post('/user/addPushDevice', {type, regId}); - await user.sync(); - - expect(response.message).to.equal(t('pushDeviceAdded')); - expect(user.pushDevices[0].type).to.equal(type); - expect(user.pushDevices[0].regId).to.equal(regId); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_add_webhook.test.js b/test/api/v3/integration/user/POST-user_add_webhook.test.js deleted file mode 100644 index d13f15baa4..0000000000 --- a/test/api/v3/integration/user/POST-user_add_webhook.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -let user; -let endpoint = '/user/webhook'; - -describe('POST /user/webhook', () => { - beforeEach(async () => { - user = await generateUser(); - }); - - it('validates', async () => { - await expect(user.post(endpoint, { enabled: true })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidUrl'), - }); - }); - - it('successfully adds the webhook', async () => { - expect(user.preferences.webhooks).to.eql({}); - let response = await user.post(endpoint, { enabled: true, url: 'http://some-url.com'}); - expect(response.id).to.exist; - await user.sync(); - expect(user.preferences.webhooks).to.not.eql({}); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_allocate.test.js b/test/api/v3/integration/user/POST-user_allocate.test.js deleted file mode 100644 index 02d4990092..0000000000 --- a/test/api/v3/integration/user/POST-user_allocate.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/allocate', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('returns an error if an invalid attribute is supplied', async () => { - await expect(user.post('/user/allocate?stat=invalid')) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidAttribute', {attr: 'invalid'}), - }); - }); - - it('returns an error if the user doesn\'t have attribute points', async () => { - await expect(user.post('/user/allocate')) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notEnoughAttrPoints'), - }); - }); - - it('allocates attribute points', async () => { - await user.update({'stats.points': 1}); - let res = await user.post('/user/allocate?stat=con'); - await user.sync(); - expect(user.stats.con).to.equal(1); - expect(user.stats.points).to.equal(0); - expect(res.con).to.equal(1); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_allocate_now.test.js b/test/api/v3/integration/user/POST-user_allocate_now.test.js deleted file mode 100644 index b45f2156be..0000000000 --- a/test/api/v3/integration/user/POST-user_allocate_now.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/allocate-now', () => { - // More tests in common code unit tests - - it('auto allocates all points', async () => { - let user = await generateUser({ - 'stats.points': 5, - 'stats.int': 3, - 'stats.con': 9, - 'stats.per': 9, - 'stats.str': 9, - 'preferences.allocationMode': 'flat', - }); - - let res = await user.post('/user/allocate-now'); - await user.sync(); - - expect(res).to.eql(user.stats); - expect(user.stats.points).to.equal(0); - expect(user.stats.con).to.equal(9); - expect(user.stats.int).to.equal(8); - expect(user.stats.per).to.equal(9); - expect(user.stats.str).to.equal(9); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_block.test.js b/test/api/v3/integration/user/POST-user_block.test.js deleted file mode 100644 index 51766eb51e..0000000000 --- a/test/api/v3/integration/user/POST-user_block.test.js +++ /dev/null @@ -1,34 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('block user', () => { - let user; - let blockedUser; - let blockedUser2; - - beforeEach(async () => { - blockedUser = await generateUser(); - blockedUser2 = await generateUser(); - user = await generateUser({ inbox: { blocks: [blockedUser._id] } }); - expect(user.inbox.blocks.length).to.eql(1); - expect(user.inbox.blocks).to.eql([blockedUser._id]); - }); - - it('validates uuid', async () => { - await expect(user.post('/user/block/1')).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidUUID'), - }); - }); - - it('successfully', async () => { - let response = await user.post(`/user/block/${blockedUser2._id}`); - await user.sync(); - expect(response).to.eql([blockedUser._id, blockedUser2._id]); - expect(user.inbox.blocks.length).to.eql(2); - expect(user.inbox.blocks).to.include(blockedUser2._id); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_buy.test.js b/test/api/v3/integration/user/POST-user_buy.test.js deleted file mode 100644 index ffad12f2a0..0000000000 --- a/test/api/v3/integration/user/POST-user_buy.test.js +++ /dev/null @@ -1,62 +0,0 @@ -/* eslint-disable camelcase */ - -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import shared from '../../../../../common/script'; - -let content = shared.content; - -describe('POST /user/buy/:key', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ - 'stats.gp': 400, - }); - }); - - // More tests in common code unit tests - - it('returns an error if the item is not found', async () => { - await expect(user.post('/user/buy/notExisting')) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('itemNotFound', {key: 'notExisting'}), - }); - }); - - it('buys a potion', async () => { - await user.update({ - 'stats.gp': 400, - }); - - let potion = content.potion; - let res = await user.post('/user/buy/potion'); - await user.sync(); - - expect(user.stats.hp).to.equal(50); - expect(res.data).to.eql(user.stats); - expect(res.message).to.equal(t('messageBought', {itemText: potion.text()})); - }); - - it('buys a piece of gear', async () => { - let key = 'armor_warrior_1'; - - await user.post(`/user/buy/${key}`); - await user.sync(); - - expect(user.items.gear.owned).to.eql({ - armor_warrior_1: true, - eyewear_special_blackTopFrame: true, - eyewear_special_blueTopFrame: true, - eyewear_special_greenTopFrame: true, - eyewear_special_pinkTopFrame: true, - eyewear_special_redTopFrame: true, - eyewear_special_whiteTopFrame: true, - eyewear_special_yellowTopFrame: true, - }); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_buy_armoire.test.js b/test/api/v3/integration/user/POST-user_buy_armoire.test.js deleted file mode 100644 index 32b8134647..0000000000 --- a/test/api/v3/integration/user/POST-user_buy_armoire.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/buy-armoire', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ - 'stats.gp': 400, - }); - }); - - // More tests in common code unit tests - - it('returns an error if user does not have enough gold', async () => { - await user.update({ - 'stats.gp': 5, - }); - - await expect(user.post('/user/buy-armoire')) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageNotEnoughGold'), - }); - }); - - it('reduces gold when buying from the armoire', async () => { - await user.post('/user/buy-armoire'); - - await user.sync(); - - expect(user.stats.gp).to.equal(300); - }); - - xit('buys a piece of armoire', async () => { - // Skipped because can't stub predictableRandom correctly - }); -}); diff --git a/test/api/v3/integration/user/POST-user_buy_gear.test.js b/test/api/v3/integration/user/POST-user_buy_gear.test.js deleted file mode 100644 index f577263d4a..0000000000 --- a/test/api/v3/integration/user/POST-user_buy_gear.test.js +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable camelcase */ - -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/buy-gear/:key', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ - 'stats.gp': 400, - }); - }); - - // More tests in common code unit tests - - it('returns an error if the item is not found', async () => { - await expect(user.post('/user/buy-gear/notExisting')) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('itemNotFound', {key: 'notExisting'}), - }); - }); - - it('buys a piece of gear', async () => { - let key = 'armor_warrior_1'; - - await user.post(`/user/buy-gear/${key}`); - await user.sync(); - - expect(user.items.gear.owned).to.eql({ - armor_warrior_1: true, - eyewear_special_blackTopFrame: true, - eyewear_special_blueTopFrame: true, - eyewear_special_greenTopFrame: true, - eyewear_special_pinkTopFrame: true, - eyewear_special_redTopFrame: true, - eyewear_special_whiteTopFrame: true, - eyewear_special_yellowTopFrame: true, - }); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_buy_health_potion.test.js b/test/api/v3/integration/user/POST-user_buy_health_potion.test.js deleted file mode 100644 index 835e893bd7..0000000000 --- a/test/api/v3/integration/user/POST-user_buy_health_potion.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import shared from '../../../../../common/script'; - -let content = shared.content; - -describe('POST /user/buy-health-potion', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ - 'stats.hp': 40, - }); - }); - - // More tests in common code unit tests - - it('returns an error if user does not have enough gold', async () => { - await expect(user.post('/user/buy-health-potion')) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageNotEnoughGold'), - }); - }); - - it('buys a potion', async () => { - await user.update({ - 'stats.gp': 400, - }); - - let potion = content.potion; - let res = await user.post('/user/buy-health-potion'); - await user.sync(); - - expect(user.stats.hp).to.equal(50); - expect(res.data).to.eql(user.stats); - expect(res.message).to.equal(t('messageBought', {itemText: potion.text()})); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js b/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js deleted file mode 100644 index da7116d732..0000000000 --- a/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/buy-mystery-set/:key', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ - 'purchased.plan.consecutive.trinkets': 1, - }); - }); - - // More tests in common code unit tests - - it('returns an error if the mystery set is not found', async () => { - await expect(user.post('/user/buy-mystery-set/notExisting')) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('mysterySetNotFound'), - }); - }); - - it('buys a mystery set', async () => { - let key = 301404; - - let res = await user.post(`/user/buy-mystery-set/${key}`); - await user.sync(); - - expect(res.data).to.eql({ - items: JSON.parse(JSON.stringify(user.items)), // otherwise dates can't be compared - purchasedPlanConsecutive: user.purchased.plan.consecutive, - }); - expect(res.message).to.equal(t('hourglassPurchaseSet')); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_buy_quest.test.js b/test/api/v3/integration/user/POST-user_buy_quest.test.js deleted file mode 100644 index 3330988537..0000000000 --- a/test/api/v3/integration/user/POST-user_buy_quest.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import shared from '../../../../../common/script'; - -let content = shared.content; - -describe('POST /user/buy-quest/:key', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('returns an error if the quest is not found', async () => { - await expect(user.post('/user/buy-quest/notExisting')) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('questNotFound', {key: 'notExisting'}), - }); - }); - - it('buys a quest', async () => { - let key = 'dilatoryDistress1'; - let item = content.quests[key]; - - await user.update({'stats.gp': 250}); - let res = await user.post(`/user/buy-quest/${key}`); - await user.sync(); - - expect(res.data).to.eql(user.items.quests); - expect(res.message).to.equal(t('messageBought', { - itemText: item.text(), - })); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_buy_special_spell.test.js b/test/api/v3/integration/user/POST-user_buy_special_spell.test.js deleted file mode 100644 index 2ae16d1baf..0000000000 --- a/test/api/v3/integration/user/POST-user_buy_special_spell.test.js +++ /dev/null @@ -1,43 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import shared from '../../../../../common/script'; - -let content = shared.content; - -describe('POST /user/buy-special-spell/:key', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('returns an error if the special spell is not found', async () => { - await expect(user.post('/user/buy-special-spell/notExisting')) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('spellNotFound', {spellId: 'notExisting'}), - }); - }); - - it('buys a special spell', async () => { - let key = 'thankyou'; - let item = content.special[key]; - - await user.update({'stats.gp': 250}); - let res = await user.post(`/user/buy-special-spell/${key}`); - await user.sync(); - - expect(res.data).to.eql({ - items: JSON.parse(JSON.stringify(user.items)), // otherwise dates can't be compared - stats: user.stats, - }); - expect(res.message).to.equal(t('messageBought', { - itemText: item.text(), - })); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_change-class.test.js b/test/api/v3/integration/user/POST-user_change-class.test.js deleted file mode 100644 index d4b4192f23..0000000000 --- a/test/api/v3/integration/user/POST-user_change-class.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/change-class', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ - 'flags.classSelected': false, - 'stats.lvl': 10, - }); - }); - - // More tests in common code unit tests - - it('changes class', async () => { - let res = await user.post('/user/change-class?class=rogue'); - await user.sync(); - - expect(res).to.eql(JSON.parse( - JSON.stringify({ - preferences: user.preferences, - stats: user.stats, - flags: user.flags, - items: user.items, - }) - )); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js b/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js deleted file mode 100644 index 8ada2ede7d..0000000000 --- a/test/api/v3/integration/user/POST-user_class_cast_spellId.test.js +++ /dev/null @@ -1,172 +0,0 @@ -import { - generateUser, - translate as t, - createAndPopulateGroup, - generateChallenge, - sleep, -} from '../../../../helpers/api-integration/v3'; - -import { v4 as generateUUID } from 'uuid'; - -describe('POST /user/class/cast/:spellId', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns an error if spell does not exist', async () => { - await user.update({'stats.class': 'rogue'}); - let spellId = 'invalidSpell'; - await expect(user.post(`/user/class/cast/${spellId}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('spellNotFound', {spellId}), - }); - }); - - it('returns an error if spell does not exist in user\'s class', async () => { - let spellId = 'pickPocket'; - await expect(user.post(`/user/class/cast/${spellId}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('spellNotFound', {spellId}), - }); - }); - - it('returns an error if spell.mana > user.mana', async () => { - await user.update({'stats.class': 'rogue'}); - await expect(user.post('/user/class/cast/backStab')) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notEnoughMana'), - }); - }); - - it('returns an error if spell.value > user.gold', async () => { - await expect(user.post('/user/class/cast/birthday')) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageNotEnoughGold'), - }); - }); - - it('returns an error if spell.lvl > user.level', async () => { - await user.update({'stats.mp': 200, 'stats.class': 'wizard'}); - await expect(user.post('/user/class/cast/earth')) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('spellLevelTooHigh', {level: 13}), - }); - }); - - it('returns an error if user doesn\'t own the spell', async () => { - await expect(user.post('/user/class/cast/snowball')) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('spellNotOwned'), - }); - }); - - it('returns an error if targetId is not an UUID', async () => { - await expect(user.post('/user/class/cast/spellId?targetId=notAnUUID')) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns an error if targetId is required but missing', async () => { - await user.update({'stats.class': 'rogue', 'stats.lvl': 11}); - await expect(user.post('/user/class/cast/pickPocket')) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('targetIdUUID'), - }); - }); - - it('returns an error if targeted task doesn\'t exist', async () => { - await user.update({'stats.class': 'rogue', 'stats.lvl': 11}); - await expect(user.post(`/user/class/cast/pickPocket?targetId=${generateUUID()}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); - - it('returns an error if a challenge task was targeted', async () => { - let {group, groupLeader} = await createAndPopulateGroup(); - let challenge = await generateChallenge(groupLeader, group); - await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ - {type: 'habit', text: 'task text'}, - ]); - await groupLeader.update({'stats.class': 'rogue', 'stats.lvl': 11}); - await sleep(0.5); - await groupLeader.sync(); - await expect(groupLeader.post(`/user/class/cast/pickPocket?targetId=${groupLeader.tasksOrder.habits[0]}`)) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('challengeTasksNoCast'), - }); - }); - - it('returns an error if targeted party member doesn\'t exist', async () => { - let {groupLeader} = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - members: 1, - }); - await groupLeader.update({'items.special.snowball': 3}); - - let target = generateUUID(); - await expect(groupLeader.post(`/user/class/cast/snowball?targetId=${target}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userWithIDNotFound', {userId: target}), - }); - }); - - it('returns an error if party does not exists', async () => { - await user.update({'items.special.snowball': 3}); - - await expect(user.post(`/user/class/cast/snowball?targetId=${generateUUID()}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('partyNotFound'), - }); - }); - - it('send message in party chat if party && !spell.silent', async () => { - let { group, groupLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - members: 1, - }); - await groupLeader.update({'stats.mp': 200, 'stats.class': 'wizard', 'stats.lvl': 13}); - await groupLeader.post('/user/class/cast/earth'); - await sleep(1); - await group.sync(); - expect(group.chat[0]).to.exists; - expect(group.chat[0].uuid).to.equal('system'); - }); - - // TODO find a way to have sinon working in integration tests - // it doesn't work when tests are running separately from server - it('passes correct target to spell when targetType === \'task\''); - it('passes correct target to spell when targetType === \'tasks\''); - it('passes correct target to spell when targetType === \'self\''); - it('passes correct target to spell when targetType === \'party\''); - it('passes correct target to spell when targetType === \'user\''); - it('passes correct target to spell when targetType === \'party\' and user is not in a party'); - it('passes correct target to spell when targetType === \'user\' and user is not in a party'); -}); diff --git a/test/api/v3/integration/user/POST-user_custom-day-start.test.js b/test/api/v3/integration/user/POST-user_custom-day-start.test.js deleted file mode 100644 index 868b9ae91d..0000000000 --- a/test/api/v3/integration/user/POST-user_custom-day-start.test.js +++ /dev/null @@ -1,47 +0,0 @@ -import moment from 'moment'; -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -let user; -let endpoint = '/user/custom-day-start'; - -describe('POST /user/custom-day-start', () => { - beforeEach(async () => { - user = await generateUser(); - }); - - it('updates user.preferences.dayStart', async () => { - expect(user.preferences.dayStart).to.eql(0); - - await user.post(endpoint, { dayStart: 1 }); - await user.sync(); - - expect(user.preferences.dayStart).to.eql(1); - }); - - it('sets lastCron to the current time to prevent an unexpected cron', async () => { - let oldCron = moment().subtract(7, 'hours'); - - await user.update({lastCron: oldCron}); - await user.post(endpoint, { dayStart: 1 }); - await user.sync(); - - expect(user.lastCron.valueOf()).to.be.gt(oldCron.valueOf()); - }); - - it('returns a confirmation message', async () => { - let {message} = await user.post(endpoint, { dayStart: 1 }); - - expect(message).to.eql(t('customDayStartHasChanged')); - }); - - it('errors if invalid value is passed', async () => { - await expect(user.post(endpoint, { dayStart: 'foo' })) - .to.eventually.be.rejected; - - await expect(user.post(endpoint, { dayStart: 24})) - .to.eventually.be.rejected; - }); -}); diff --git a/test/api/v3/integration/user/POST-user_disable-classes.test.js b/test/api/v3/integration/user/POST-user_disable-classes.test.js deleted file mode 100644 index 0632a8adc7..0000000000 --- a/test/api/v3/integration/user/POST-user_disable-classes.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/disable-classes', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('disable classes', async () => { - let res = await user.post('/user/disable-classes'); - await user.sync(); - - expect(res).to.eql(JSON.parse( - JSON.stringify({ - preferences: user.preferences, - stats: user.stats, - flags: user.flags, - }) - )); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_equip_type_key.test.js b/test/api/v3/integration/user/POST-user_equip_type_key.test.js deleted file mode 100644 index c5cde777df..0000000000 --- a/test/api/v3/integration/user/POST-user_equip_type_key.test.js +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable camelcase */ - -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/equip/:type/:key', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('equip an item', async () => { - await user.update({ - 'items.gear.owned': { - weapon_warrior_0: true, - weapon_warrior_1: true, - weapon_warrior_2: true, - weapon_wizard_1: true, - weapon_wizard_2: true, - shield_base_0: true, - shield_warrior_1: true, - }, - 'items.gear.equipped': { - weapon: 'weapon_warrior_0', - shield: 'shield_base_0', - }, - 'stats.gp': 200, - }); - - await user.post('/user/equip/equipped/weapon_warrior_1'); - let res = await user.post('/user/equip/equipped/weapon_warrior_2'); - await user.sync(); - - expect(res).to.eql(JSON.parse(JSON.stringify(user.items))); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_feed_pet_food.test.js b/test/api/v3/integration/user/POST-user_feed_pet_food.test.js deleted file mode 100644 index 7581c266ee..0000000000 --- a/test/api/v3/integration/user/POST-user_feed_pet_food.test.js +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable camelcase */ - -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import content from '../../../../../common/script/content'; - -describe('POST /user/feed/:pet/:food', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('does not enjoy the food', async () => { - await user.update({ - 'items.pets.Wolf-Base': 5, - 'items.food.Milk': 2, - }); - - let food = content.food.Milk; - let [egg, potion] = 'Wolf-Base'.split('-'); - let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; - let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - - let res = await user.post('/user/feed/Wolf-Base/Milk'); - await user.sync(); - expect(res).to.eql({ - data: user.items.pets['Wolf-Base'], - message: t('messageDontEnjoyFood', { - egg: t('petName', { - potion: potionText, - egg: eggText, - }), - foodText: food.text(), - }), - }); - - expect(user.items.food.Milk).to.equal(1); - expect(user.items.pets['Wolf-Base']).to.equal(7); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js b/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js deleted file mode 100644 index 9621377beb..0000000000 --- a/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js +++ /dev/null @@ -1,31 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/hatch/:egg/:hatchingPotion', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('hatch a new pet', async () => { - await user.update({ - 'items.eggs.Wolf': 1, - 'items.hatchingPotions.Base': 1, - }); - let res = await user.post('/user/hatch/Wolf/Base'); - await user.sync(); - expect(user.items.pets['Wolf-Base']).to.equal(5); - expect(user.items.eggs.Wolf).to.equal(0); - expect(user.items.hatchingPotions.Base).to.equal(0); - - expect(res).to.eql({ - message: t('messageHatched'), - data: JSON.parse(JSON.stringify(user.items)), - }); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_mark_pms_read.test.js b/test/api/v3/integration/user/POST-user_mark_pms_read.test.js deleted file mode 100644 index 50552359ef..0000000000 --- a/test/api/v3/integration/user/POST-user_mark_pms_read.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/mark-pms-read', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('marks user\'s private messages as read', async () => { - await user.update({ - 'inbox.newMessages': 1, - }); - await user.post('/user/mark-pms-read'); - await user.sync(); - expect(user.inbox.newMessages).to.equal(0); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_open_mystery_item.test.js b/test/api/v3/integration/user/POST-user_open_mystery_item.test.js deleted file mode 100644 index d9e9fe7326..0000000000 --- a/test/api/v3/integration/user/POST-user_open_mystery_item.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/open-mystery-item', () => { - let user; - let mysteryItemKey = 'eyewear_special_summerRogue'; - - beforeEach(async () => { - user = await generateUser({ - 'purchased.plan.mysteryItems': [mysteryItemKey], - }); - }); - - // More tests in common code unit tests - - it('opens a mystery item', async () => { - let response = await user.post('/user/open-mystery-item'); - await user.sync(); - - expect(user.items.gear.owned[mysteryItemKey]).to.be.true; - expect(response.message).to.equal(t('mysteryItemOpened')); - expect(response.data).to.deep.equal(user.items.gear.owned); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_purchase.test.js b/test/api/v3/integration/user/POST-user_purchase.test.js deleted file mode 100644 index dff6d59c48..0000000000 --- a/test/api/v3/integration/user/POST-user_purchase.test.js +++ /dev/null @@ -1,34 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/purchase/:type/:key', () => { - let user; - let type = 'hatchingPotions'; - let key = 'Base'; - - beforeEach(async () => { - user = await generateUser({ - balance: 40, - }); - }); - - // More tests in common code unit tests - - it('returns an error when key is not provided', async () => { - await expect(user.post('/user/purchase/gems/gem')) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('mustSubscribeToPurchaseGems'), - }); - }); - - it('purchases a gem item', async () => { - await user.post(`/user/purchase/${type}/${key}`); - await user.sync(); - - expect(user.items[type][key]).to.equal(1); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_purchase_hourglass.test.js b/test/api/v3/integration/user/POST-user_purchase_hourglass.test.js deleted file mode 100644 index cd43334d00..0000000000 --- a/test/api/v3/integration/user/POST-user_purchase_hourglass.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/purchase-hourglass/:type/:key', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ - 'purchased.plan.consecutive.trinkets': 2, - }); - }); - - // More tests in common code unit tests - - it('buys a hourglass pet', async () => { - let response = await user.post('/user/purchase-hourglass/pets/MantisShrimp-Base'); - await user.sync(); - - expect(response.message).to.eql(t('hourglassPurchase')); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - expect(user.items.pets).to.eql({'MantisShrimp-Base': 5}); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_read_card.test.js b/test/api/v3/integration/user/POST-user_read_card.test.js deleted file mode 100644 index 3b3573b6cc..0000000000 --- a/test/api/v3/integration/user/POST-user_read_card.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/read-card/:cardType', () => { - let user; - let cardType = 'greeting'; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns an error when unknown cardType is provded', async () => { - await expect(user.post('/user/read-card/randomCardType')) - .to.eventually.be.rejected.and.to.eql({ - code: 401, - error: 'NotAuthorized', - message: t('cardTypeNotAllowed'), - }); - }); - - // More tests in common code unit tests - - it('reads a card', async () => { - await user.update({ - 'items.special.greetingReceived': [true], - 'flags.cardReceived': true, - }); - - let response = await user.post(`/user/read-card/${cardType}`); - await user.sync(); - - expect(response.message).to.equal(t('readCard', {cardType})); - expect(user.items.special[`${cardType}Received`]).to.be.empty; - expect(user.flags.cardReceived).to.be.false; - }); -}); diff --git a/test/api/v3/integration/user/POST-user_rebirth.test.js b/test/api/v3/integration/user/POST-user_rebirth.test.js deleted file mode 100644 index 21fbed0b8d..0000000000 --- a/test/api/v3/integration/user/POST-user_rebirth.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import { - generateUser, - generateDaily, - generateReward, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/rebirth', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns an error when user balance is too low', async () => { - await expect(user.post('/user/rebirth')) - .to.eventually.be.rejected.and.to.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notEnoughGems'), - }); - }); - - // More tests in common code unit tests - - it('resets user\'s tasks', async () => { - await user.update({ - balance: 2, - }); - - let daily = await generateDaily({ - text: 'test habit', - type: 'daily', - value: 1, - streak: 1, - userId: user._id, - }); - - let reward = await generateReward({ - text: 'test reward', - type: 'reward', - value: 1, - userId: user._id, - }); - - let response = await user.post('/user/rebirth'); - await user.sync(); - - let updatedDaily = await user.get(`/tasks/${daily._id}`); - let updatedReward = await user.get(`/tasks/${reward._id}`); - - expect(response.message).to.equal(t('rebirthComplete')); - expect(updatedDaily.streak).to.equal(0); - expect(updatedDaily.value).to.equal(0); - expect(updatedReward.value).to.equal(1); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_release_both.test.js b/test/api/v3/integration/user/POST-user_release_both.test.js deleted file mode 100644 index 8c47d95dfe..0000000000 --- a/test/api/v3/integration/user/POST-user_release_both.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/release-both', () => { - let user; - let animal = 'Wolf-Base'; - - beforeEach(async () => { - user = await generateUser({ - 'items.currentMount': animal, - 'items.currentPet': animal, - 'items.pets': {animal: 5}, - 'items.mounts': {animal: true}, - }); - }); - - it('returns an error when user balance is too low and user does not have triadBingo', async () => { - await expect(user.post('/user/release-both')) - .to.eventually.be.rejected.and.to.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notEnoughGems'), - }); - }); - - // More tests in common code unit tests - - it('grants triad bingo with gems', async () => { - await user.update({ - balance: 1.5, - }); - - let response = await user.post('/user/release-both'); - await user.sync(); - - expect(response.message).to.equal(t('mountsAndPetsReleased')); - expect(user.balance).to.equal(0); - expect(user.items.currentMount).to.be.empty; - expect(user.items.currentPet).to.be.empty; - expect(user.items.pets[animal]).to.be.empty; - expect(user.items.mounts[animal]).to.equal(null); - expect(user.achievements.beastMasterCount).to.equal(1); - expect(user.achievements.mountMasterCount).to.equal(1); - expect(user.achievements.triadBingoCount).to.equal(1); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_release_mounts.test.js b/test/api/v3/integration/user/POST-user_release_mounts.test.js deleted file mode 100644 index 86391599f0..0000000000 --- a/test/api/v3/integration/user/POST-user_release_mounts.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/release-mounts', () => { - let user; - let animal = 'Wolf-Base'; - - beforeEach(async () => { - user = await generateUser({ - 'items.currentMount': animal, - 'items.mounts': {animal: true}, - }); - }); - - it('returns an error when user balance is too low', async () => { - await expect(user.post('/user/release-mounts')) - .to.eventually.be.rejected.and.to.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notEnoughGems'), - }); - }); - - // More tests in common code unit tests - - it('releases mounts', async () => { - await user.update({ - balance: 1, - }); - - let response = await user.post('/user/release-mounts'); - await user.sync(); - - expect(response.message).to.equal(t('mountsReleased')); - expect(user.balance).to.equal(0); - expect(user.items.currentMount).to.be.empty; - expect(user.items.mounts[animal]).to.equal(null); - expect(user.achievements.mountMasterCount).to.equal(1); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_release_pets.test.js b/test/api/v3/integration/user/POST-user_release_pets.test.js deleted file mode 100644 index a7f7b9b66f..0000000000 --- a/test/api/v3/integration/user/POST-user_release_pets.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/release-pets', () => { - let user; - let animal = 'Wolf-Base'; - - beforeEach(async () => { - user = await generateUser({ - 'items.currentPet': animal, - 'items.pets': {animal: 5}, - }); - }); - - it('returns an error when user balance is too low', async () => { - await expect(user.post('/user/release-pets')) - .to.eventually.be.rejected.and.to.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notEnoughGems'), - }); - }); - - // More tests in common code unit tests - - it('releases pets', async () => { - await user.update({ - balance: 1, - }); - - let response = await user.post('/user/release-pets'); - await user.sync(); - - expect(response.message).to.equal(t('petsReleased')); - expect(user.balance).to.equal(0); - expect(user.items.currentPet).to.be.empty; - expect(user.items.pets[animal]).to.equal(0); - expect(user.achievements.beastMasterCount).to.equal(1); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_reroll.test.js b/test/api/v3/integration/user/POST-user_reroll.test.js deleted file mode 100644 index 29774d1239..0000000000 --- a/test/api/v3/integration/user/POST-user_reroll.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import { - generateUser, - generateDaily, - generateReward, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/reroll', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns an error when user balance is too low', async () => { - await expect(user.post('/user/reroll')) - .to.eventually.be.rejected.and.to.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notEnoughGems'), - }); - }); - - // More tests in common code unit tests - - it('resets user\'s tasks', async () => { - await user.update({ - balance: 2, - }); - - let daily = await generateDaily({ - text: 'test habit', - type: 'daily', - userId: user._id, - }); - - let reward = await generateReward({ - text: 'test reward', - type: 'reward', - value: 1, - userId: user._id, - }); - - let response = await user.post('/user/reroll'); - await user.sync(); - - let updatedDaily = await user.get(`/tasks/${daily._id}`); - let updatedReward = await user.get(`/tasks/${reward._id}`); - - expect(response.message).to.equal(t('fortifyComplete')); - expect(updatedDaily.value).to.equal(0); - expect(updatedReward.value).to.equal(1); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_reset.test.js b/test/api/v3/integration/user/POST-user_reset.test.js deleted file mode 100644 index 2baf7bd083..0000000000 --- a/test/api/v3/integration/user/POST-user_reset.test.js +++ /dev/null @@ -1,104 +0,0 @@ -import { - generateUser, - generateGroup, - generateChallenge, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/reset', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('resets user\'s habits', async () => { - let task = await user.post('/tasks/user', { - text: 'test habit', - type: 'habit', - }); - - await user.post('/user/reset'); - await user.sync(); - - await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - - expect(user.tasksOrder.habits).to.be.empty; - }); - - it('resets user\'s dailys', async () => { - let task = await user.post('/tasks/user', { - text: 'test daily', - type: 'daily', - }); - - await user.post('/user/reset'); - await user.sync(); - - await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - - expect(user.tasksOrder.dailys).to.be.empty; - }); - - it('resets user\'s todos', async () => { - let task = await user.post('/tasks/user', { - text: 'test todo', - type: 'todo', - }); - - await user.post('/user/reset'); - await user.sync(); - - await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - - expect(user.tasksOrder.todos).to.be.empty; - }); - - it('resets user\'s rewards', async () => { - let task = await user.post('/tasks/user', { - text: 'test reward', - type: 'reward', - }); - - await user.post('/user/reset'); - await user.sync(); - - await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - - expect(user.tasksOrder.rewards).to.be.empty; - }); - - it('does not delete challenge tasks', async () => { - let guild = await generateGroup(user); - let challenge = await generateChallenge(user, guild); - let task = await user.post(`/tasks/challenge/${challenge._id}`, { - text: 'test challenge habit', - type: 'habit', - }); - - await user.post('/user/reset'); - await user.sync(); - - let userChallengeTask = await user.get(`/tasks/${task._id}`); - - expect(userChallengeTask).to.eql(task); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_revive.test.js b/test/api/v3/integration/user/POST-user_revive.test.js deleted file mode 100644 index 6ba85ac87f..0000000000 --- a/test/api/v3/integration/user/POST-user_revive.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/revive', () => { - let user; - - beforeEach(async () => { - user = await generateUser({ - 'user.items.gear.owned': {weaponKey: true}, - }); - }); - - it('returns an error when user is not dead', async () => { - await expect(user.post('/user/revive')) - .to.eventually.be.rejected.and.to.eql({ - code: 401, - error: 'NotAuthorized', - message: t('cannotRevive'), - }); - }); - - // More tests in common code unit tests - - it('decreases a stat', async () => { - await user.update({ - 'stats.str': 2, - 'stats.hp': 0, - }); - - await user.post('/user/revive'); - await user.sync(); - - expect(user.stats.str).to.equal(1); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_sell.test.js b/test/api/v3/integration/user/POST-user_sell.test.js deleted file mode 100644 index 1914336175..0000000000 --- a/test/api/v3/integration/user/POST-user_sell.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; -import content from '../../../../../common/script/content'; - -describe('POST /user/sell/:type/:key', () => { - let user; - let type = 'eggs'; - let key = 'Wolf'; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('returns an error when user does not have item', async () => { - await expect(user.post(`/user/sell/${type}/${key}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('userItemsKeyNotFound', {type}), - }); - }); - - it('sells an item', async () => { - await user.update({ - items: { - eggs: { - Wolf: 1, - }, - }, - }); - - await user.post(`/user/sell/${type}/${key}`); - await user.sync(); - - expect(user.stats.gp).to.equal(content[type][key].value); - }); -}); diff --git a/test/api/v3/integration/user/POST-user_sleep.test.js b/test/api/v3/integration/user/POST-user_sleep.test.js deleted file mode 100644 index 0e9773150e..0000000000 --- a/test/api/v3/integration/user/POST-user_sleep.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/sleep', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - // More tests in common code unit tests - - it('toggles sleep status', async () => { - let res = await user.post('/user/sleep'); - expect(res).to.eql(true); - await user.sync(); - expect(user.preferences.sleep).to.be.true; - - let res2 = await user.post('/user/sleep'); - expect(res2).to.eql(false); - await user.sync(); - expect(user.preferences.sleep).to.be.false; - }); -}); diff --git a/test/api/v3/integration/user/POST-user_unlock.js b/test/api/v3/integration/user/POST-user_unlock.js deleted file mode 100644 index 6dbdb3c1b1..0000000000 --- a/test/api/v3/integration/user/POST-user_unlock.js +++ /dev/null @@ -1,37 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /user/unlock', () => { - let user; - let unlockPath = 'shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie'; - let unlockCost = 1.25; - let usersStartingGems = 5; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns an error when user balance is too low', async () => { - await expect(user.post(`/user/unlock?path=${unlockPath}`)) - .to.eventually.be.rejected.and.to.eql({ - code: 401, - error: 'NotAuthorized', - message: t('notEnoughGems'), - }); - }); - - // More tests in common code unit tests - - it('reduces a user\'s balance', async () => { - await user.update({ - balance: usersStartingGems, - }); - let response = await user.post(`/user/unlock?path=${unlockPath}`); - await user.sync(); - - expect(response.message).to.equal(t('unlocked')); - expect(user.balance).to.equal(usersStartingGems - unlockCost); - }); -}); diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js deleted file mode 100644 index f606c95c2c..0000000000 --- a/test/api/v3/integration/user/PUT-user.test.js +++ /dev/null @@ -1,201 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -import { each, get } from 'lodash'; - -describe('PUT /user', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - context('Allowed Operations', () => { - it('updates the user', async () => { - await user.put('/user', { - 'profile.name': 'Frodo', - 'preferences.costume': true, - 'stats.hp': 14, - }); - - await user.sync(); - - expect(user.profile.name).to.eql('Frodo'); - expect(user.preferences.costume).to.eql(true); - expect(user.stats.hp).to.eql(14); - }); - }); - - context('Top Level Protected Operations', () => { - let protectedOperations = { - 'gem balance': {balance: 100}, - auth: {'auth.blocked': true, 'auth.timestamps.created': new Date()}, - contributor: {'contributor.level': 9, 'contributor.admin': true, 'contributor.text': 'some text'}, - backer: {'backer.tier': 10, 'backer.npc': 'Bilbo'}, - subscriptions: {'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000}, - 'customization gem purchases': {'purchased.background.tavern': true, 'purchased.skin.bear': true}, - }; - - each(protectedOperations, (data, testName) => { - it(`does not allow updating ${testName}`, async () => { - let errorText = t('messageUserOperationProtected', { operation: Object.keys(data)[0] }); - - await expect(user.put('/user', data)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: errorText, - }); - }); - }); - }); - - context('Sub-Level Protected Operations', () => { - let protectedOperations = { - 'class stat': {'stats.class': 'wizard'}, - 'flags unless whitelisted': {'flags.dropsEnabled': true}, - webhooks: {'preferences.webhooks': [1, 2, 3]}, - sleep: {'preferences.sleep': true}, - 'disable classes': {'preferences.disableClasses': true}, - }; - - each(protectedOperations, (data, testName) => { - it(`does not allow updating ${testName}`, async () => { - let errorText = t('messageUserOperationProtected', { operation: Object.keys(data)[0] }); - - await expect(user.put('/user', data)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: errorText, - }); - }); - }); - }); - - context('Default Appearance Preferences', () => { - let testCases = { - shirt: 'yellow', - skin: 'ddc994', - 'hair.color': 'blond', - 'hair.bangs': 2, - 'hair.base': 1, - 'hair.flower': 4, - size: 'broad', - }; - - each(testCases, (item, type) => { - const update = {}; - update[`preferences.${type}`] = item; - - it(`updates user with ${type} that is a default`, async () => { - let dbUpdate = {}; - dbUpdate[`purchased.${type}.${item}`] = true; - await user.update(dbUpdate); - - // Sanity checks to make sure user is not already equipped with item - expect(get(user.preferences, type)).to.not.eql(item); - - let updatedUser = await user.put('/user', update); - - expect(get(updatedUser.preferences, type)).to.eql(item); - }); - }); - - it('returns an error if user tries to update body size with invalid type', async () => { - await expect(user.put('/user', { - 'preferences.size': 'round', - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('mustPurchaseToSet', { val: 'round', key: 'preferences.size' }), - }); - }); - - it('can set beard to default', async () => { - await user.update({ - 'purchased.hair.beard': 3, - 'preferences.hair.beard': 3, - }); - - let updatedUser = await user.put('/user', { - 'preferences.hair.beard': 0, - }); - - expect(updatedUser.preferences.hair.beard).to.eql(0); - }); - - it('can set mustache to default', async () => { - await user.update({ - 'purchased.hair.mustache': 2, - 'preferences.hair.mustache': 2, - }); - - let updatedUser = await user.put('/user', { - 'preferences.hair.mustache': 0, - }); - - expect(updatedUser.preferences.hair.mustache).to.eql(0); - }); - }); - - context('Purchasable Appearance Preferences', () => { - let testCases = { - background: 'volcano', - shirt: 'convict', - skin: 'cactus', - 'hair.base': 7, - 'hair.beard': 2, - 'hair.color': 'rainbow', - 'hair.mustache': 2, - }; - - each(testCases, (item, type) => { - const update = {}; - update[`preferences.${type}`] = item; - - it(`returns an error if user tries to update ${type} with ${type} the user does not own`, async () => { - await expect(user.put('/user', update)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('mustPurchaseToSet', {val: item, key: `preferences.${type}`}), - }); - }); - - it(`updates user with ${type} user does own`, async () => { - let dbUpdate = {}; - dbUpdate[`purchased.${type}.${item}`] = true; - await user.update(dbUpdate); - - // Sanity check to make sure user is not already equipped with item - expect(get(user.preferences, type)).to.not.eql(item); - - let updatedUser = await user.put('/user', update); - - expect(get(updatedUser.preferences, type)).to.eql(item); - }); - }); - }); - - context('Improvement Categories', () => { - it('sets valid categories', async () => { - await user.put('/user', { - 'preferences.improvementCategories': ['work', 'school'], - }); - - await user.sync(); - - expect(user.preferences.improvementCategories).to.eql(['work', 'school']); - }); - - it('discards invalid categories', async () => { - await expect(user.put('/user', { - 'preferences.improvementCategories': ['work', 'procrastination', 'school'], - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'User validation failed', - }); - }); - }); -}); diff --git a/test/api/v3/integration/user/PUT-user_update_webhook.test.js b/test/api/v3/integration/user/PUT-user_update_webhook.test.js deleted file mode 100644 index 13ca9ff00c..0000000000 --- a/test/api/v3/integration/user/PUT-user_update_webhook.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -let user; -let url = 'http://new-url.com'; -let enabled = true; - -describe('PUT /user/webhook/:id', () => { - beforeEach(async () => { - user = await generateUser(); - }); - - it('validation fails', async () => { - await expect(user.put('/user/webhook/some-id'), { enabled: true }).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidUrl'), - }); - }); - - it('succeeds', async () => { - let response = await user.post('/user/webhook', { enabled: true, url: 'http://some-url.com'}); - await user.sync(); - expect(user.preferences.webhooks[response.id].url).to.not.eql(url); - let response2 = await user.put(`/user/webhook/${response.id}`, {url, enabled}); - expect(response2.url).to.eql(url); - await user.sync(); - expect(user.preferences.webhooks[response.id].url).to.eql(url); - }); -}); diff --git a/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js b/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js deleted file mode 100644 index cf1354b095..0000000000 --- a/test/api/v3/integration/user/auth/DELETE-user_auth_social_network.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-integration/v3'; - -describe('DELETE social registration', () => { - let user; - let endpoint = '/user/auth/social/facebook'; - beforeEach(async () => { - user = await generateUser(); - await user.update({ 'auth.facebook.id': 'some-fb-id' }); - expect(user.auth.local.username).to.not.be.empty; - expect(user.auth.facebook).to.not.be.empty; - }); - context('of NOT-FACEBOOK', () => { - it('is not supported', async () => { - await expect(user.del('/user/auth/social/SOME-OTHER-NETWORK')).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyFbSupported'), - }); - }); - }); - context('of facebook', () => { - it('fails if local registration does not exist for this user', async () => { - await user.update({ 'auth.local': { ok: true } }); - await expect(user.del(endpoint)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('cantDetachFb'), - }); - }); - it('succeeds', async () => { - let response = await user.del(endpoint); - expect(response).to.eql({}); - await user.sync(); - expect(user.auth.facebook).to.be.empty; - }); - }); -}); diff --git a/test/api/v3/integration/user/auth/GET-logout.test.js b/test/api/v3/integration/user/auth/GET-logout.test.js deleted file mode 100644 index 731c523bde..0000000000 --- a/test/api/v3/integration/user/auth/GET-logout.test.js +++ /dev/null @@ -1,3 +0,0 @@ -describe('GET /user/auth/logout', () => { - // TODO Test manually -}); diff --git a/test/api/v3/integration/user/auth/POST-firebase.test.js b/test/api/v3/integration/user/auth/POST-firebase.test.js deleted file mode 100644 index 7ebd5a20cb..0000000000 --- a/test/api/v3/integration/user/auth/POST-firebase.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import { - generateUser, -} from '../../../../../helpers/api-integration/v3'; -import moment from 'moment'; - -describe('POST /user/auth/firebase', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - it('returns a Firebase token', async () => { - let {token, expires} = await user.post('/user/auth/firebase'); - expect(moment(expires).isValid()).to.be.true; - expect(token).to.be.a('string'); - }); -}); diff --git a/test/api/v3/integration/user/auth/POST-login-local.test.js b/test/api/v3/integration/user/auth/POST-login-local.test.js deleted file mode 100644 index 571b23c3ea..0000000000 --- a/test/api/v3/integration/user/auth/POST-login-local.test.js +++ /dev/null @@ -1,69 +0,0 @@ -import { - generateUser, - requester, - translate as t, -} from '../../../../../helpers/api-integration/v3'; - -describe('POST /user/auth/local/login', () => { - let api; - let user; - let endpoint = '/user/auth/local/login'; - let password = 'password'; - beforeEach(async () => { - api = requester(); - user = await generateUser(); - }); - it('success with username', async () => { - let response = await api.post(endpoint, { - username: user.auth.local.username, - password, - }); - expect(response.apiToken).to.eql(user.apiToken); - }); - it('success with email', async () => { - let response = await api.post(endpoint, { - username: user.auth.local.email, - password, - }); - expect(response.apiToken).to.eql(user.apiToken); - }); - it('user is blocked', async () => { - await user.update({ 'auth.blocked': 1 }); - await expect(api.post(endpoint, { - username: user.auth.local.username, - password, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('accountSuspended', { userId: user._id }), - }); - }); - it('wrong password', async () => { - await expect(api.post(endpoint, { - username: user.auth.local.username, - password: 'wrong-password', - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('invalidLoginCredentialsLong'), - }); - }); - it('missing username', async () => { - await expect(api.post(endpoint, { - password: 'wrong-password', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - it('missing password', async () => { - await expect(api.post(endpoint, { - username: user.auth.local.username, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); -}); diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js deleted file mode 100644 index 63d8f755a5..0000000000 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ /dev/null @@ -1,349 +0,0 @@ -import { - generateUser, - requester, - translate as t, - createAndPopulateGroup, -} from '../../../../../helpers/api-integration/v3'; -import { v4 as generateRandomUserName } from 'uuid'; -import { each } from 'lodash'; -import { encrypt } from '../../../../../../website/server/libs/api-v3/encryption'; - -describe('POST /user/auth/local/register', () => { - context('username and email are free', () => { - let api; - - beforeEach(async () => { - api = requester(); - }); - - it('registers a new user', async () => { - let username = generateRandomUserName(); - let email = `${username}@example.com`; - let password = 'password'; - - let user = await api.post('/user/auth/local/register', { - username, - email, - password, - confirmPassword: password, - }); - - expect(user._id).to.exist; - expect(user.apiToken).to.exist; - expect(user.auth.local.username).to.eql(username); - }); - - it('requires password and confirmPassword to match', async () => { - let username = generateRandomUserName(); - let email = `${username}@example.com`; - let password = 'password'; - let confirmPassword = 'not password'; - - await expect(api.post('/user/auth/local/register', { - username, - email, - password, - confirmPassword, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('requires a username', async () => { - let email = `${generateRandomUserName()}@example.com`; - let password = 'password'; - let confirmPassword = 'password'; - - await expect(api.post('/user/auth/local/register', { - email, - password, - confirmPassword, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('requires an email', async () => { - let username = generateRandomUserName(); - let password = 'password'; - - await expect(api.post('/user/auth/local/register', { - username, - password, - confirmPassword: password, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('requires a valid email', async () => { - let username = generateRandomUserName(); - let email = 'notanemail@sdf'; - let password = 'password'; - - await expect(api.post('/user/auth/local/register', { - username, - email, - password, - confirmPassword: password, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('requires a password', async () => { - let username = generateRandomUserName(); - let email = `${username}@example.com`; - let confirmPassword = 'password'; - - await expect(api.post('/user/auth/local/register', { - username, - email, - confirmPassword, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - }); - - context('attach to facebook user', () => { - let user; - let email = 'some@email.net'; - let username = 'some-username'; - let password = 'some-password'; - beforeEach(async () => { - user = await generateUser(); - }); - it('checks onlySocialAttachLocal', async () => { - await expect(user.post('/user/auth/local/register', { - email, - username, - password, - confirmPassword: password, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlySocialAttachLocal'), - }); - }); - it('succeeds', async () => { - await user.update({ 'auth.facebook.id': 'some-fb-id', 'auth.local': { ok: true } }); - await user.post('/user/auth/local/register', { - username, - email, - password, - confirmPassword: password, - }); - await user.sync(); - expect(user.auth.local.username).to.eql(username); - expect(user.auth.local.email).to.eql(email); - }); - }); - - context('login is already taken', () => { - let username, email, api; - - beforeEach(async () => { - api = requester(); - username = generateRandomUserName(); - email = `${username}@example.com`; - - return generateUser({ - 'auth.local.username': username, - 'auth.local.lowerCaseUsername': username, - 'auth.local.email': email, - }); - }); - - it('rejects if username is already taken', async () => { - let uniqueEmail = `${generateRandomUserName()}@exampe.com`; - let password = 'password'; - - await expect(api.post('/user/auth/local/register', { - username, - email: uniqueEmail, - password, - confirmPassword: password, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('usernameTaken'), - }); - }); - - it('rejects if email is already taken', async () => { - let uniqueUsername = generateRandomUserName(); - let password = 'password'; - - await expect(api.post('/user/auth/local/register', { - username: uniqueUsername, - email, - password, - confirmPassword: password, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('emailTaken'), - }); - }); - }); - - context('req.query.groupInvite', () => { - let api, username, email, password; - - beforeEach(() => { - api = requester(); - username = generateRandomUserName(); - email = `${username}@example.com`; - password = 'password'; - }); - - it('does not crash the signup process when it\'s invalid', async () => { - let user = await api.post('/user/auth/local/register?groupInvite=aaaaInvalid', { - username, - email, - password, - confirmPassword: password, - }); - - expect(user._id).to.be.a('string'); - }); - - it('supports invite using req.query.groupInvite', async () => { - let { group, groupLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'party', privacy: 'private' }, - }); - - let invite = encrypt(JSON.stringify({ - id: group._id, - inviter: groupLeader._id, - sentAt: Date.now(), // so we can let it expire - })); - - let user = await api.post(`/user/auth/local/register?groupInvite=${invite}`, { - username, - email, - password, - confirmPassword: password, - }); - - expect(user.invitations.party).to.eql({ - id: group._id, - name: group.name, - inviter: groupLeader._id, - }); - }); - }); - - context('successful login via api', () => { - let api, username, email, password; - - beforeEach(() => { - api = requester(); - username = generateRandomUserName(); - email = `${username}@example.com`; - password = 'password'; - }); - - it('sets all site tour values to -2 (already seen)', async () => { - let user = await api.post('/user/auth/local/register', { - username, - email, - password, - confirmPassword: password, - }); - - expect(user.flags.tour).to.not.be.empty; - - each(user.flags.tour, (value) => { - expect(value).to.eql(-2); - }); - }); - - it('populates user with default todos, not no other task types', async () => { - let user = await api.post('/user/auth/local/register', { - username, - email, - password, - confirmPassword: password, - }); - - expect(user.tasksOrder.todos).to.not.be.empty; - expect(user.tasksOrder.dailys).to.be.empty; - expect(user.tasksOrder.habits).to.be.empty; - expect(user.tasksOrder.rewards).to.be.empty; - }); - - it('populates user with default tags', async () => { - let user = await api.post('/user/auth/local/register', { - username, - email, - password, - confirmPassword: password, - }); - - expect(user.tags).to.not.be.empty; - }); - }); - - context('successful login with habitica-web header', () => { - let api, username, email, password; - - beforeEach(() => { - api = requester({}, {'x-client': 'habitica-web'}); - username = generateRandomUserName(); - email = `${username}@example.com`; - password = 'password'; - }); - - it('sets all common tutorial flags to true', async () => { - let user = await api.post('/user/auth/local/register', { - username, - email, - password, - confirmPassword: password, - }); - - expect(user.flags.tour).to.not.be.empty; - - each(user.flags.tutorial.common, (value) => { - expect(value).to.eql(true); - }); - }); - - it('populates user with default todos, habits, and rewards', async () => { - let user = await api.post('/user/auth/local/register', { - username, - email, - password, - confirmPassword: password, - }); - - expect(user.tasksOrder.todos).to.not.be.empty; - expect(user.tasksOrder.dailys).to.be.empty; - expect(user.tasksOrder.habits).to.not.be.empty; - expect(user.tasksOrder.rewards).to.not.be.empty; - }); - - it('populates user with default tags', async () => { - let user = await api.post('/user/auth/local/register', { - username, - email, - password, - confirmPassword: password, - }); - - expect(user.tags).to.not.be.empty; - }); - }); -}); diff --git a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js b/test/api/v3/integration/user/auth/POST-user_reset_password.test.js deleted file mode 100644 index 773d199db6..0000000000 --- a/test/api/v3/integration/user/auth/POST-user_reset_password.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-integration/v3'; - -describe('POST /user/reset-password', async () => { - let endpoint = '/user/reset-password'; - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('resets password', async () => { - let previousPassword = user.auth.local.hashed_password; - let response = await user.post(endpoint, { - email: user.auth.local.email, - }); - expect(response).to.eql({ data: {}, message: t('passwordReset') }); - await user.sync(); - expect(user.auth.local.hashed_password).to.not.eql(previousPassword); - }); - - it('same message on error as on success', async () => { - let response = await user.post(endpoint, { - email: 'nonExistent@email.com', - }); - expect(response).to.eql({ data: {}, message: t('passwordReset') }); - }); - - it('errors if email is not provided', async () => { - await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); -}); diff --git a/test/api/v3/integration/user/auth/PUT-user_update_email.test.js b/test/api/v3/integration/user/auth/PUT-user_update_email.test.js deleted file mode 100644 index 47357d3c85..0000000000 --- a/test/api/v3/integration/user/auth/PUT-user_update_email.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-v3-integration.helper'; - -const ENDPOINT = '/user/auth/update-email'; - -describe('PUT /user/auth/update-email', () => { - let newEmail = 'some-new-email_2@example.net'; - let oldPassword = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js - - context('Local Authenticaion User', async () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('does not change email if email is not provided', async () => { - await expect(user.put(ENDPOINT)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('does not change email if password is not provided', async () => { - await expect(user.put(ENDPOINT, { - newEmail, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('does not change email if wrong password is provided', async () => { - await expect(user.put(ENDPOINT, { - newEmail, - password: 'wrong password', - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('wrongPassword'), - }); - }); - - it('changes email if new email and existing password are provided', async () => { - let response = await user.put(ENDPOINT, { - newEmail, - password: oldPassword, - }); - expect(response).to.eql({ email: 'some-new-email_2@example.net' }); - - await user.sync(); - expect(user.auth.local.email).to.eql(newEmail); - }); - }); - - context('Social Login User', async () => { - let socialUser; - - beforeEach(async () => { - socialUser = await generateUser(); - await socialUser.update({ 'auth.local': { ok: true } }); - }); - - it('does not change email if user.auth.local.email does not exist for this user', async () => { - await expect(socialUser.put(ENDPOINT, { - newEmail, - password: oldPassword, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('userHasNoLocalRegistration'), - }); - }); - }); -}); diff --git a/test/api/v3/integration/user/auth/PUT-user_update_password.test.js b/test/api/v3/integration/user/auth/PUT-user_update_password.test.js deleted file mode 100644 index bcc1ac25d3..0000000000 --- a/test/api/v3/integration/user/auth/PUT-user_update_password.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-v3-integration.helper'; - -const ENDPOINT = '/user/auth/update-password'; - -describe('PUT /user/auth/update-password', async () => { - let user; - let password = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js - let wrongPassword = 'wrong-password'; - let newPassword = 'new-password'; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('successfully changes the password', async () => { - let previousHashedPassword = user.auth.local.hashed_password; - let response = await user.put(ENDPOINT, { - password, - newPassword, - confirmPassword: newPassword, - }); - expect(response).to.eql({}); - await user.sync(); - expect(user.auth.local.hashed_password).to.not.eql(previousHashedPassword); - }); - - it('returns an error when confirmPassword does not match newPassword', async () => { - await expect(user.put(ENDPOINT, { - password, - newPassword, - confirmPassword: `${newPassword}-wrong-confirmation`, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('passwordConfirmationMatch'), - }); - }); - - it('returns an error when existing password is wrong', async () => { - await expect(user.put(ENDPOINT, { - password: wrongPassword, - newPassword, - confirmPassword: newPassword, - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('wrongPassword'), - }); - }); -}); diff --git a/test/api/v3/integration/user/auth/PUT-user_update_username.test.js b/test/api/v3/integration/user/auth/PUT-user_update_username.test.js deleted file mode 100644 index 372248db84..0000000000 --- a/test/api/v3/integration/user/auth/PUT-user_update_username.test.js +++ /dev/null @@ -1,76 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../../helpers/api-v3-integration.helper'; - -const ENDPOINT = '/user/auth/update-username'; - -describe('PUT /user/auth/update-username', async () => { - let user; - let newUsername = 'new-username'; - let password = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js - - beforeEach(async () => { - user = await generateUser(); - }); - - it('successfully changes username', async () => { - let response = await user.put(ENDPOINT, { - username: newUsername, - password, - }); - expect(response).to.eql({ username: newUsername }); - await user.sync(); - expect(user.auth.local.username).to.eql(newUsername); - }); - - context('errors', async () => { - it('prevents username update if new username is already taken', async () => { - let existingUsername = 'existing-username'; - await generateUser({'auth.local.username': existingUsername, 'auth.local.lowerCaseUsername': existingUsername }); - - await expect(user.put(ENDPOINT, { - username: existingUsername, - password, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('usernameTaken'), - }); - }); - - it('errors if password is wrong', async () => { - await expect(user.put(ENDPOINT, { - username: newUsername, - password: 'wrong-password', - })).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('wrongPassword'), - }); - }); - - it('prevents social-only user from changing username', async () => { - let socialUser = await generateUser({ 'auth.local': { ok: true } }); - - await expect(socialUser.put(ENDPOINT, { - username: newUsername, - password, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('userHasNoLocalRegistration'), - }); - }); - - it('errors if new username is not provided', async () => { - await expect(user.put(ENDPOINT, { - password, - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - }); -}); diff --git a/test/api/v3/unit/libs/analyticsService.test.js b/test/api/v3/unit/libs/analyticsService.test.js deleted file mode 100644 index 771678cc3d..0000000000 --- a/test/api/v3/unit/libs/analyticsService.test.js +++ /dev/null @@ -1,309 +0,0 @@ -import analyticsService from '../../../../../website/server/libs/api-v3/analyticsService'; - -import nock from 'nock'; - -describe('analyticsService', () => { - let amplitudeNock, gaNock; - - beforeEach(() => { - amplitudeNock = nock('https://api.amplitude.com') - .filteringPath(/httpapi.*/g, '') - .post('/') - .reply(200, {status: 'OK'}); - - gaNock = nock('http://www.google-analytics.com'); - }); - - describe('#track', () => { - let eventType, data; - - beforeEach(() => { - eventType = 'Cron'; - data = { - category: 'behavior', - uuid: 'unique-user-id', - resting: true, - cronCount: 5, - }; - }); - - context('Amplitude', () => { - it('calls out to amplitude', () => { - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('uses a dummy user id if none is provided', () => { - delete data.uuid; - - amplitudeNock - .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); - - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sets platform as server', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*server.*/g, ''); - - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sends details about event', () => { - amplitudeNock - .filteringPath(/httpapi.*event_properties%22%3A%7B%22category%22%3A%22behavior%22%2C%22resting%22%3Atrue%2C%22cronCount%22%3A5%7D%2C%22.*/g, ''); - - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sends english item name for gear if itemKey is provided', () => { - data.itemKey = 'headAccessory_special_foxEars'; - - amplitudeNock - .filteringPath(/httpapi.*itemName.*Fox%20Ears.*/g, ''); - - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sends english item name for egg if itemKey is provided', () => { - data.itemKey = 'Wolf'; - - amplitudeNock - .filteringPath(/httpapi.*itemName.*Wolf%20Egg.*/g, ''); - - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sends english item name for food if itemKey is provided', () => { - data.itemKey = 'Cake_Skeleton'; - - amplitudeNock - .filteringPath(/httpapi.*itemName.*Bare%20Bones%20Cake.*/g, ''); - - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sends english item name for hatching potion if itemKey is provided', () => { - data.itemKey = 'Golden'; - - amplitudeNock - .filteringPath(/httpapi.*itemName.*Golden%20Hatching%20Potion.*/g, ''); - - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sends english item name for quest if itemKey is provided', () => { - data.itemKey = 'atom1'; - - amplitudeNock - .filteringPath(/httpapi.*itemName.*Attack%20of%20the%20Mundane%2C%20Part%201%3A%20Dish%20Disaster!.*/g, ''); - - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sends english item name for purchased spell if itemKey is provided', () => { - data.itemKey = 'seafoam'; - - amplitudeNock - .filteringPath(/httpapi.*itemName.*Seafoam.*/g, ''); - - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sends user data if provided', () => { - let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; - let user = { - stats, - contributor: { level: 1 }, - purchased: { plan: { planId: 'foo-plan' } }, - flags: {tour: {intro: -2}}, - habits: [{_id: 'habit'}], - dailys: [{_id: 'daily'}], - todos: [{_id: 'todo'}], - rewards: [{_id: 'reward'}], - }; - - data.user = user; - - amplitudeNock - .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); - - return analyticsService.track(eventType, data) - .then(() => { - amplitudeNock.done(); - }); - }); - }); - - context('GA', () => { - it('calls out to GA', () => { - gaNock - .post('/collect') - .reply(200, {status: 'OK'}); - - return analyticsService.track(eventType, data) - .then(() => { - gaNock.done(); - }); - }); - - it('sends details about event', () => { - gaNock - .post('/collect', /ec=behavior&ea=Cron&v=1&tid=GA_ID&cid=.*&t=event/) - .reply(200, {status: 'OK'}); - - return analyticsService.track(eventType, data) - .then(() => { - gaNock.done(); - }); - }); - }); - }); - - describe('#trackPurchase', () => { - let data; - - beforeEach(() => { - data = { - uuid: 'user-id', - sku: 'paypal-checkout', - paymentMethod: 'PayPal', - itemPurchased: 'Gems', - purchaseValue: 8, - purchaseType: 'checkout', - gift: false, - quantity: 1, - }; - }); - - context('Amplitude', () => { - it('calls out to amplitude', () => { - return analyticsService.trackPurchase(data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('uses a dummy user id if none is provided', () => { - delete data.uuid; - - amplitudeNock - .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); - - return analyticsService.trackPurchase(data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sets platform as server', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*server.*/g, ''); - - return analyticsService.trackPurchase(data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sends details about purchase', () => { - amplitudeNock - .filteringPath(/httpapi.*aypal-checkout%22%2C%22paymentMethod%22%3A%22PayPal%22%2C%22itemPurchased%22%3A%22Gems%22%2C%22purchaseType%22%3A%22checkout%22%2C%22gift%22%3Afalse%2C%22quantity%22%3A1%7D%2C%22event_type%22%3A%22purchase%22%2C%22revenue.*/g, ''); - - return analyticsService.trackPurchase(data) - .then(() => { - amplitudeNock.done(); - }); - }); - - it('sends user data if provided', () => { - let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; - let user = { - stats, - contributor: { level: 1 }, - purchased: { plan: { planId: 'foo-plan' } }, - flags: {tour: {intro: -2}}, - habits: [{_id: 'habit'}], - dailys: [{_id: 'daily'}], - todos: [{_id: 'todo'}], - rewards: [{_id: 'reward'}], - }; - - data.user = user; - - amplitudeNock - .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); - - return analyticsService.trackPurchase(data) - .then(() => { - amplitudeNock.done(); - }); - }); - }); - - context('GA', () => { - it('calls out to GA', () => { - gaNock - .post('/collect') - .reply(200, {status: 'OK'}); - - return analyticsService.trackPurchase(data) - .then(() => { - gaNock.done(); - }); - }); - - it('sends details about purchase', () => { - gaNock - .post('/collect', /ti=user-id&tr=8&v=1&tid=GA_ID&cid=.*&t=transaction/) - .reply(200, {status: 'OK'}) - .post('/collect', /ec=commerce&ea=checkout&el=PayPal&ev=8&v=1&tid=GA_ID&cid=.*&t=event/) - .reply(200, {status: 'OK'}); - - return analyticsService.trackPurchase(data) - .then(() => { - gaNock.done(); - }); - }); - }); - }); - - describe('mockAnalyticsService', () => { - it('has stubbed track method', () => { - expect(analyticsService.mockAnalyticsService).to.respondTo('track'); - }); - - it('has stubbed trackPurchase method', () => { - expect(analyticsService.mockAnalyticsService).to.respondTo('trackPurchase'); - }); - }); -}); diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js deleted file mode 100644 index 39bf7df047..0000000000 --- a/test/api/v3/unit/libs/baseModel.test.js +++ /dev/null @@ -1,97 +0,0 @@ -import baseModel from '../../../../../website/server/libs/api-v3/baseModel'; -import mongoose from 'mongoose'; - -describe('Base model plugin', () => { - let schema; - - beforeEach(() => { - schema = new mongoose.Schema(); - sandbox.stub(schema, 'add'); - }); - - it('adds a _id field to the schema', () => { - schema.plugin(baseModel); - - expect(schema.add).to.be.calledWith(sinon.match({ - _id: sinon.match.object, - })); - }); - - it('can add timestamps fields', () => { - schema.plugin(baseModel, {timestamps: true}); - - expect(schema.add).to.be.calledTwice; - }); - - it('can sanitize input objects', () => { - schema.plugin(baseModel, { - noSet: ['noUpdateForMe'], - }); - - expect(schema.statics.sanitize).to.exist; - let sanitized = schema.statics.sanitize({ok: true, noUpdateForMe: true}); - - expect(sanitized).to.have.property('ok'); - expect(sanitized).not.to.have.property('noUpdateForMe'); - expect(sanitized.noUpdateForMe).to.equal(undefined); - }); - - it('accepts an array of additional fields to sanitize at runtime', () => { - schema.plugin(baseModel, { - noSet: ['noUpdateForMe'], - }); - - expect(schema.statics.sanitize).to.exist; - let sanitized = schema.statics.sanitize({ok: true, noUpdateForMe: true, usuallySettable: true}, ['usuallySettable']); - - expect(sanitized).to.have.property('ok'); - expect(sanitized).not.to.have.property('noUpdateForMe'); - expect(sanitized).not.to.have.property('usuallySettable'); - }); - - - it('can make fields private', () => { - schema.plugin(baseModel, { - private: ['amPrivate'], - }); - - expect(schema.options.toJSON.transform).to.exist; - let objToTransform = {ok: true, amPrivate: true}; - let privatized = schema.options.toJSON.transform({}, objToTransform); - - expect(privatized).to.have.property('ok'); - expect(privatized).not.to.have.property('amPrivate'); - }); - - it('accepts a further transform function for toJSON', () => { - let options = { - private: ['amPrivate'], - toJSONTransform: sandbox.stub().returns(true), - }; - - schema.plugin(baseModel, options); - - let objToTransform = {ok: true, amPrivate: true}; - let doc = {doc: true}; - let privatized = schema.options.toJSON.transform(doc, objToTransform); - - expect(privatized).to.equals(true); - expect(options.toJSONTransform).to.be.calledWith(objToTransform, doc); - }); - - it('accepts a transform function for sanitize', () => { - let options = { - private: ['amPrivate'], - sanitizeTransform: sandbox.stub().returns(true), - }; - - schema.plugin(baseModel, options); - - expect(schema.options.toJSON.transform).to.exist; - let objToSanitize = {ok: true, noUpdateForMe: true}; - let sanitized = schema.statics.sanitize(objToSanitize); - - expect(sanitized).to.equals(true); - expect(options.sanitizeTransform).to.be.calledWith(objToSanitize); - }); -}); diff --git a/test/api/v3/unit/libs/buildManifest.test.js b/test/api/v3/unit/libs/buildManifest.test.js deleted file mode 100644 index 1444738f10..0000000000 --- a/test/api/v3/unit/libs/buildManifest.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import { - getManifestFiles, -} from '../../../../../website/server/libs/api-v3/buildManifest'; - -describe('Build Manifest', () => { - describe('getManifestFiles', () => { - it('returns an html string', () => { - let htmlCode = getManifestFiles('app'); - - expect(htmlCode.startsWith(' { - expect(() => { - getManifestFiles('strange name here'); - }).to.throw(Error); - }); - }); -}); diff --git a/test/api/v3/unit/libs/collectionManipulators.test.js b/test/api/v3/unit/libs/collectionManipulators.test.js deleted file mode 100644 index da44fd5319..0000000000 --- a/test/api/v3/unit/libs/collectionManipulators.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import mongoose from 'mongoose'; -import { - removeFromArray, -} from '../../../../../website/server/libs/api-v3/collectionManipulators'; - -describe('Collection Manipulators', () => { - describe('removeFromArray', () => { - it('removes element from array', () => { - let array = ['a', 'b', 'c', 'd']; - - removeFromArray(array, 'c'); - - expect(array).to.not.include('c'); - }); - - it('removes object from array', () => { - let array = [ - { id: 'a', foo: 'bar' }, - { id: 'b', foo: 'bar' }, - { id: 'c', foo: 'bar' }, - { id: 'd', foo: 'bar' }, - { id: 'e', foo: 'bar' }, - ]; - - removeFromArray(array, { id: 'c' }); - - expect(array).to.not.include({ id: 'c', foo: 'bar' }); - }); - - it('does not change array if value is not found', () => { - let array = ['a', 'b', 'c', 'd']; - - removeFromArray(array, 'z'); - - expect(array).to.have.a.lengthOf(4); - expect(array[0]).to.eql('a'); - expect(array[1]).to.eql('b'); - expect(array[2]).to.eql('c'); - expect(array[3]).to.eql('d'); - }); - - it('returns the removed element', () => { - let array = ['a', 'b', 'c']; - - let result = removeFromArray(array, 'b'); - - expect(result).to.eql('b'); - }); - - it('returns the removed object element', () => { - let array = [ - { id: 'a', foo: 'bar' }, - { id: 'b', foo: 'bar' }, - { id: 'c', foo: 'bar' }, - { id: 'd', foo: 'bar' }, - { id: 'e', foo: 'bar' }, - ]; - - let result = removeFromArray(array, { id: 'c' }); - - expect(result).to.eql({ id: 'c', foo: 'bar' }); - }); - - it('returns false if item is not found', () => { - let array = ['a', 'b', 'c']; - - let result = removeFromArray(array, 'z'); - - expect(result).to.eql(false); - }); - - it('persists removal of element when mongoose document is saved', async () => { - let schema = new mongoose.Schema({ - array: Array, - }); - let Model = mongoose.model('ModelToTestRemoveFromArray', schema); - let model = await new Model({ - array: ['a', 'b', 'c'], - }).save(); // Initial creation - - removeFromArray(model.array, 'b'); - - let savedModel = await model.save(); - - expect(savedModel.array).to.not.include('b'); - }); - }); -}); diff --git a/test/api/v3/unit/libs/cron.test.js b/test/api/v3/unit/libs/cron.test.js deleted file mode 100644 index a7e732442c..0000000000 --- a/test/api/v3/unit/libs/cron.test.js +++ /dev/null @@ -1,573 +0,0 @@ -/* eslint-disable global-require */ -import moment from 'moment'; -import { cron } from '../../../../../website/server/libs/api-v3/cron'; -import { model as User } from '../../../../../website/server/models/user'; -import * as Tasks from '../../../../../website/server/models/task'; -import { clone } from 'lodash'; -import common from '../../../../../common'; - -// const scoreTask = common.ops.scoreTask; - -describe('cron', () => { - let user; - let tasksByType = {habits: [], dailys: [], todos: [], rewards: []}; - let daysMissed = 0; - let analytics = { - track: sinon.spy(), - }; - - beforeEach(() => { - user = new User({ - auth: { - local: { - username: 'username', - lowerCaseUsername: 'username', - email: 'email@email.email', - salt: 'salt', - hashed_password: 'hashed_password', // eslint-disable-line camelcase - }, - }, - }); - - user._statsComputed = { - mp: 10, - }; - }); - - it('updates user.auth.timestamps.loggedin and lastCron', () => { - let now = new Date(); - - cron({user, tasksByType, daysMissed, analytics, now}); - - expect(user.auth.timestamps.loggedin).to.equal(now); - expect(user.lastCron).to.equal(now); - }); - - it('updates user.preferences.timezoneOffsetAtLastCron', () => { - let timezoneOffsetFromUserPrefs = 1; - - cron({user, tasksByType, daysMissed, analytics, timezoneOffsetFromUserPrefs}); - - expect(user.preferences.timezoneOffsetAtLastCron).to.equal(timezoneOffsetFromUserPrefs); - }); - - it('resets user.items.lastDrop.count', () => { - user.items.lastDrop.count = 4; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.items.lastDrop.count).to.equal(0); - }); - - it('increments user cron count', () => { - let cronCountBefore = user.flags.cronCount; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.flags.cronCount).to.be.greaterThan(cronCountBefore); - }); - - describe('end of the month perks', () => { - beforeEach(() => { - user.purchased.plan.customerId = 'subscribedId'; - user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); - }); - - it('resets plan.gemsBought on a new month', () => { - user.purchased.plan.gemsBought = 10; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.gemsBought).to.equal(0); - }); - - it('resets plan.dateUpdated on a new month', () => { - let currentMonth = moment().format('MMYYYY'); - cron({user, tasksByType, daysMissed, analytics}); - expect(moment(user.purchased.plan.dateUpdated).format('MMYYYY')).to.equal(currentMonth); - }); - - it('increments plan.consecutive.count', () => { - user.purchased.plan.consecutive.count = 0; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.consecutive.count).to.equal(1); - }); - - it('decrements plan.consecutive.offset when offset is greater than 0', () => { - user.purchased.plan.consecutive.offset = 1; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.consecutive.offset).to.equal(0); - }); - - it('increments plan.consecutive.trinkets when user has reached a month that is a multiple of 3', () => { - user.purchased.plan.consecutive.count = 5; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.consecutive.trinkets).to.equal(1); - }); - - it('increments plan.consecutive.gemCapExtra when user has reached a month that is a multiple of 3', () => { - user.purchased.plan.consecutive.count = 5; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(5); - }); - - it('does not increment plan.consecutive.gemCapExtra when user has reached the gemCap limit', () => { - user.purchased.plan.consecutive.gemCapExtra = 25; - user.purchased.plan.consecutive.count = 5; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(25); - }); - - it('does not reset plan stats if we are before the last day of the cancelled month', () => { - user.purchased.plan.dateTerminated = moment(new Date()).add({days: 1}); - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.customerId).to.exist; - }); - - it('does reset plan stats until we are after the last day of the cancelled month', () => { - user.purchased.plan.dateTerminated = moment(new Date()).subtract({days: 1}); - user.purchased.plan.consecutive.gemCapExtra = 20; - user.purchased.plan.consecutive.count = 5; - user.purchased.plan.consecutive.offset = 1; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.purchased.plan.customerId).to.not.exist; - expect(user.purchased.plan.consecutive.gemCapExtra).to.be.empty; - expect(user.purchased.plan.consecutive.count).to.be.empty; - expect(user.purchased.plan.consecutive.offset).to.be.empty; - }); - }); - - describe('end of the month perks when user is not subscribed', () => { - it('does not reset plan.gemsBought on a new month', () => { - user.purchased.plan.gemsBought = 10; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.gemsBought).to.equal(10); - }); - - it('does not reset plan.dateUpdated on a new month', () => { - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.dateUpdated).to.be.empty; - }); - - it('does not increment plan.consecutive.count', () => { - user.purchased.plan.consecutive.count = 0; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.consecutive.count).to.equal(0); - }); - - it('does not decrement plan.consecutive.offset when offset is greater than 0', () => { - user.purchased.plan.consecutive.offset = 1; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.consecutive.offset).to.equal(1); - }); - - it('does not increment plan.consecutive.trinkets when user has reached a month that is a multiple of 3', () => { - user.purchased.plan.consecutive.count = 5; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.consecutive.trinkets).to.equal(0); - }); - - it('doest not increment plan.consecutive.gemCapExtra when user has reached a month that is a multiple of 3', () => { - user.purchased.plan.consecutive.count = 5; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(0); - }); - - it('does not increment plan.consecutive.gemCapExtra when user has reached the gemCap limit', () => { - user.purchased.plan.consecutive.gemCapExtra = 25; - user.purchased.plan.consecutive.count = 5; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(25); - }); - - it('does nothing to plan stats if we are before the last day of the cancelled month', () => { - user.purchased.plan.dateTerminated = moment(new Date()).add({days: 1}); - cron({user, tasksByType, daysMissed, analytics}); - expect(user.purchased.plan.customerId).to.not.exist; - }); - - xit('does nothing to plan stats when we are after the last day of the cancelled month', () => { - user.purchased.plan.dateTerminated = moment(new Date()).subtract({days: 1}); - user.purchased.plan.consecutive.gemCapExtra = 20; - user.purchased.plan.consecutive.count = 5; - user.purchased.plan.consecutive.offset = 1; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.purchased.plan.customerId).to.exist; - expect(user.purchased.plan.consecutive.gemCapExtra).to.exist; - expect(user.purchased.plan.consecutive.count).to.exist; - expect(user.purchased.plan.consecutive.offset).to.exist; - }); - }); - - describe('user is sleeping', () => { - beforeEach(() => { - user.preferences.sleep = true; - }); - - it('clears user buffs', () => { - user.stats.buffs = { - str: 1, - int: 1, - per: 1, - con: 1, - stealth: 1, - streaks: true, - }; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.stats.buffs.str).to.equal(0); - expect(user.stats.buffs.int).to.equal(0); - expect(user.stats.buffs.per).to.equal(0); - expect(user.stats.buffs.con).to.equal(0); - expect(user.stats.buffs.stealth).to.equal(0); - expect(user.stats.buffs.streaks).to.be.false; - }); - - it('resets all dailies without damaging user', () => { - let daily = { - text: 'test daily', - type: 'daily', - frequency: 'daily', - everyX: 5, - startDate: new Date(), - }; - - let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap - tasksByType.dailys.push(task); - tasksByType.dailys[0].completed = true; - - let healthBefore = user.stats.hp; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(tasksByType.dailys[0].completed).to.be.false; - expect(user.stats.hp).to.equal(healthBefore); - }); - }); - - describe('todos', () => { - beforeEach(() => { - let todo = { - text: 'test todo', - type: 'todo', - value: 0, - }; - - let task = new Tasks.todo(Tasks.Task.sanitize(todo)); // eslint-disable-line babel/new-cap - tasksByType.todos.push(task); - }); - - it('should make uncompleted todos redder', () => { - let valueBefore = tasksByType.todos[0].value; - cron({user, tasksByType, daysMissed, analytics}); - expect(tasksByType.todos[0].value).to.be.lessThan(valueBefore); - }); - - it('should add history of completed todos to user history', () => { - tasksByType.todos[0].completed = true; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.history.todos).to.be.lengthOf(1); - }); - }); - - describe('dailys', () => { - beforeEach(() => { - let daily = { - text: 'test daily', - type: 'daily', - }; - - let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap - tasksByType.dailys = []; - tasksByType.dailys.push(task); - - user._statsComputed = { - con: 1, - }; - }); - - it('should add history', () => { - cron({user, tasksByType, daysMissed, analytics}); - expect(tasksByType.dailys[0].history).to.be.lengthOf(1); - }); - - it('should set tasks completed to false', () => { - tasksByType.dailys[0].completed = true; - cron({user, tasksByType, daysMissed, analytics}); - expect(tasksByType.dailys[0].completed).to.be.false; - }); - - it('should reset task checklist for completed dailys', () => { - tasksByType.dailys[0].checklist.push({title: 'test', completed: false}); - tasksByType.dailys[0].completed = true; - cron({user, tasksByType, daysMissed, analytics}); - expect(tasksByType.dailys[0].checklist[0].completed).to.be.false; - }); - - it('should reset task checklist for dailys with scheduled misses', () => { - daysMissed = 10; - tasksByType.dailys[0].checklist.push({title: 'test', completed: false}); - tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); - cron({user, tasksByType, daysMissed, analytics}); - expect(tasksByType.dailys[0].checklist[0].completed).to.be.false; - }); - - it('should do damage for missing a daily', () => { - daysMissed = 1; - let hpBefore = user.stats.hp; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.stats.hp).to.be.lessThan(hpBefore); - }); - - it('should not do damage for missing a daily if user stealth buff is greater than or equal to days missed', () => { - daysMissed = 1; - let hpBefore = user.stats.hp; - user.stats.buffs.stealth = 2; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.stats.hp).to.equal(hpBefore); - }); - - it('should do less damage for missing a daily with partial completion', () => { - daysMissed = 1; - let hpBefore = user.stats.hp; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); - cron({user, tasksByType, daysMissed, analytics}); - let hpDifferenceOfFullyIncompleteDaily = hpBefore - user.stats.hp; - - hpBefore = user.stats.hp; - tasksByType.dailys[0].checklist.push({title: 'test', completed: true}); - tasksByType.dailys[0].checklist.push({title: 'test2', completed: false}); - cron({user, tasksByType, daysMissed, analytics}); - let hpDifferenceOfPartiallyIncompleteDaily = hpBefore - user.stats.hp; - - expect(hpDifferenceOfPartiallyIncompleteDaily).to.be.lessThan(hpDifferenceOfFullyIncompleteDaily); - }); - - it('should decrement quest progress down for missing a daily', () => { - daysMissed = 1; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); - - let progress = cron({user, tasksByType, daysMissed, analytics}); - - expect(progress.down).to.equal(-1); - }); - }); - - describe('habits', () => { - beforeEach(() => { - let habit = { - text: 'test habit', - type: 'habit', - }; - - let task = new Tasks.habit(Tasks.Task.sanitize(habit)); // eslint-disable-line babel/new-cap - tasksByType.habits = []; - tasksByType.habits.push(task); - }); - - it('should decrement only up value', () => { - tasksByType.habits[0].value = 1; - tasksByType.habits[0].down = false; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(tasksByType.habits[0].value).to.be.lessThan(1); - }); - - it('should decrement only down value', () => { - tasksByType.habits[0].value = 1; - tasksByType.habits[0].up = false; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(tasksByType.habits[0].value).to.be.lessThan(1); - }); - - it('should do nothing to habits with both up and down', () => { - tasksByType.habits[0].value = 1; - tasksByType.habits[0].up = true; - tasksByType.habits[0].down = true; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(tasksByType.habits[0].value).to.equal(1); - }); - }); - - describe('perfect day', () => { - beforeEach(() => { - let daily = { - text: 'test daily', - type: 'daily', - }; - - let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap - tasksByType.dailys = []; - tasksByType.dailys.push(task); - - user._statsComputed = { - con: 1, - }; - }); - - it('stores a new entry in user.history.exp', () => { - user.stats.lvl = 2; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.history.exp).to.have.lengthOf(1); - expect(user.history.exp[0].value).to.equal(150); - }); - - it('increments perfect day achievement', () => { - tasksByType.dailys[0].completed = true; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.achievements.perfect).to.equal(1); - }); - - it('increments user buffs if they have a perfect day', () => { - tasksByType.dailys[0].completed = true; - - let previousBuffs = clone(user.stats.buffs); - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str); - expect(user.stats.buffs.int).to.be.greaterThan(previousBuffs.int); - expect(user.stats.buffs.per).to.be.greaterThan(previousBuffs.per); - expect(user.stats.buffs.con).to.be.greaterThan(previousBuffs.con); - }); - - it('clears buffs if user does not have a perfect day', () => { - daysMissed = 1; - tasksByType.dailys[0].completed = false; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); - - user.stats.buffs = { - str: 1, - int: 1, - per: 1, - con: 1, - stealth: 0, - streaks: true, - }; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.stats.buffs.str).to.equal(0); - expect(user.stats.buffs.int).to.equal(0); - expect(user.stats.buffs.per).to.equal(0); - expect(user.stats.buffs.con).to.equal(0); - expect(user.stats.buffs.stealth).to.equal(0); - expect(user.stats.buffs.streaks).to.be.false; - }); - }); - - describe('adding mp', () => { - it('should add mp to user', () => { - let mpBefore = user.stats.mp; - tasksByType.dailys[0].completed = true; - user._statsComputed.maxMP = 100; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.stats.mp).to.be.greaterThan(mpBefore); - }); - - it('set user\'s mp to user._statsComputed.maxMP when user.stats.mp is greater', () => { - user.stats.mp = 120; - user._statsComputed.maxMP = 100; - cron({user, tasksByType, daysMissed, analytics}); - expect(user.stats.mp).to.equal(user._statsComputed.maxMP); - }); - }); - - describe('quest progress', () => { - beforeEach(() => { - let daily = { - text: 'test daily', - type: 'daily', - }; - - let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap - tasksByType.dailys = []; - tasksByType.dailys.push(task); - - user._statsComputed = { - con: 1, - }; - - daysMissed = 1; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); - }); - - it('resets user progress', () => { - cron({user, tasksByType, daysMissed, analytics}); - expect(user.party.quest.progress.up).to.equal(0); - expect(user.party.quest.progress.down).to.equal(0); - expect(user.party.quest.progress.collect).to.be.empty; - }); - - it('applies the user progress', () => { - let progress = cron({user, tasksByType, daysMissed, analytics}); - expect(progress.down).to.equal(-1); - }); - }); - - describe('private messages', () => { - let lastMessageId; - - beforeEach(() => { - let maxPMs = 200; - for (let index = 0; index < maxPMs - 1; index += 1) { - let messageId = common.uuid(); - user.inbox.messages[messageId] = { - id: messageId, - text: `test ${index}`, - timestamp: Number(new Date()), - likes: {}, - flags: {}, - flagCount: 0, - }; - } - - lastMessageId = common.uuid(); - user.inbox.messages[lastMessageId] = { - id: lastMessageId, - text: `test ${lastMessageId}`, - timestamp: Number(new Date()), - likes: {}, - flags: {}, - flagCount: 0, - }; - }); - - xit('does not clear pms under 200', () => { - cron({user, tasksByType, daysMissed, analytics}); - expect(user.inbox.messages[lastMessageId]).to.exist; - }); - - xit('clears pms over 200', () => { - let messageId = common.uuid(); - user.inbox.messages[messageId] = { - id: messageId, - text: `test ${messageId}`, - timestamp: Number(new Date()), - likes: {}, - flags: {}, - flagCount: 0, - }; - - cron({user, tasksByType, daysMissed, analytics}); - - expect(user.inbox.messages[messageId]).to.not.exist; - }); - }); -}); diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js deleted file mode 100644 index bb76e05cfb..0000000000 --- a/test/api/v3/unit/libs/email.test.js +++ /dev/null @@ -1,232 +0,0 @@ -/* eslint-disable global-require */ -import request from 'request'; -import nconf from 'nconf'; -import nodemailer from 'nodemailer'; -import Bluebird from 'bluebird'; -import requireAgain from 'require-again'; -import logger from '../../../../../website/server/libs/api-v3/logger'; - -function defer () { - let resolve; - let reject; - - let promise = new Bluebird((resolveParam, rejectParam) => { - resolve = resolveParam; - reject = rejectParam; - }); - - return { - resolve, - reject, - promise, - }; -} - -function getUser () { - return { - _id: 'random _id', - auth: { - local: { - username: 'username', - email: 'email@email', - }, - facebook: { - emails: [{ - value: 'email@facebook', - }], - displayName: 'fb display name', - }, - }, - profile: { - name: 'profile name', - }, - preferences: { - emailNotifications: { - unsubscribeFromAll: false, - }, - }, - }; -} - -describe('emails', () => { - let pathToEmailLib = '../../../../../website/server/libs/api-v3/email'; - - describe('sendEmail', () => { - it('can send an email using the default transport', () => { - let sendMailSpy = sandbox.stub().returns(defer().promise); - - sandbox.stub(nodemailer, 'createTransport').returns({ - sendMail: sendMailSpy, - }); - - let attachEmail = requireAgain(pathToEmailLib); - attachEmail.send(); - expect(sendMailSpy).to.be.calledOnce; - }); - - it('logs errors', (done) => { - let deferred = defer(); - let sendMailSpy = sandbox.stub().returns(deferred.promise); - - sandbox.stub(nodemailer, 'createTransport').returns({ - sendMail: sendMailSpy, - }); - sandbox.stub(logger, 'error'); - - let attachEmail = requireAgain(pathToEmailLib); - attachEmail.send(); - expect(sendMailSpy).to.be.calledOnce; - deferred.reject(); - - // wait for unhandledRejection event to fire - setTimeout(() => { - expect(logger.error).to.be.calledOnce; - done(); - }, 20); - }); - }); - - describe('getUserInfo', () => { - it('returns an empty object if no field request', () => { - let attachEmail = requireAgain(pathToEmailLib); - let getUserInfo = attachEmail.getUserInfo; - expect(getUserInfo({}, [])).to.be.empty; - }); - - it('returns correct user data', () => { - let attachEmail = requireAgain(pathToEmailLib); - let getUserInfo = attachEmail.getUserInfo; - let user = getUser(); - let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); - - expect(data).to.have.property('name', user.profile.name); - expect(data).to.have.property('email', user.auth.local.email); - expect(data).to.have.property('_id', user._id); - expect(data).to.have.property('canSend', true); - }); - - it('returns correct user data [facebook users]', () => { - let attachEmail = requireAgain(pathToEmailLib); - let getUserInfo = attachEmail.getUserInfo; - let user = getUser(); - delete user.profile.name; - delete user.auth.local; - - let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); - - expect(data).to.have.property('name', user.auth.facebook.displayName); - expect(data).to.have.property('email', user.auth.facebook.emails[0].value); - expect(data).to.have.property('_id', user._id); - expect(data).to.have.property('canSend', true); - }); - - it('has fallbacks for missing data', () => { - let attachEmail = requireAgain(pathToEmailLib); - let getUserInfo = attachEmail.getUserInfo; - let user = getUser(); - delete user.profile.name; - delete user.auth.local.email; - delete user.auth.facebook; - - let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); - - expect(data).to.have.property('name', user.auth.local.username); - expect(data).not.to.have.property('email'); - expect(data).to.have.property('_id', user._id); - expect(data).to.have.property('canSend', true); - }); - }); - - describe('sendTxnEmail', () => { - beforeEach(() => { - sandbox.stub(request, 'post'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('can send a txn email to one recipient', () => { - sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - let attachEmail = requireAgain(pathToEmailLib); - let sendTxnEmail = attachEmail.sendTxn; - let emailType = 'an email type'; - let mailingInfo = { - name: 'my name', - email: 'my@email', - }; - - sendTxnEmail(mailingInfo, emailType); - expect(request.post).to.be.calledWith(sinon.match({ - json: { - data: { - emailType: sinon.match.same(emailType), - to: sinon.match((value) => { - return Array.isArray(value) && value[0].name === mailingInfo.name; - }, 'matches mailing info array'), - }, - }, - })); - }); - - it('does not send email if address is missing', () => { - sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - let attachEmail = requireAgain(pathToEmailLib); - let sendTxnEmail = attachEmail.sendTxn; - let emailType = 'an email type'; - let mailingInfo = { - name: 'my name', - // email: 'my@email', - }; - - sendTxnEmail(mailingInfo, emailType); - expect(request.post).not.to.be.called; - }); - - it('uses getUserInfo in case of user data', () => { - sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - let attachEmail = requireAgain(pathToEmailLib); - let sendTxnEmail = attachEmail.sendTxn; - let emailType = 'an email type'; - let mailingInfo = getUser(); - - sendTxnEmail(mailingInfo, emailType); - expect(request.post).to.be.calledWith(sinon.match({ - json: { - data: { - emailType: sinon.match.same(emailType), - to: sinon.match(val => val[0]._id === mailingInfo._id), - }, - }, - })); - }); - - it('sends email with some default variables', () => { - sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - let attachEmail = requireAgain(pathToEmailLib); - let sendTxnEmail = attachEmail.sendTxn; - let emailType = 'an email type'; - let mailingInfo = { - name: 'my name', - email: 'my@email', - }; - let variables = [1, 2, 3]; - - sendTxnEmail(mailingInfo, emailType, variables); - expect(request.post).to.be.calledWith(sinon.match({ - json: { - data: { - variables: sinon.match((value) => { - return value[0].name === 'BASE_URL'; - }, 'matches variables'), - personalVariables: sinon.match((value) => { - return value[0].rcpt === mailingInfo.email && - value[0].vars[0].name === 'RECIPIENT_NAME' && - value[0].vars[1].name === 'RECIPIENT_UNSUB_URL'; - }, 'matches personal variables'), - }, - }, - })); - }); - }); -}); diff --git a/test/api/v3/unit/libs/encryption.test.js b/test/api/v3/unit/libs/encryption.test.js deleted file mode 100644 index a63a527e74..0000000000 --- a/test/api/v3/unit/libs/encryption.test.js +++ /dev/null @@ -1,15 +0,0 @@ -import { - encrypt, - decrypt, -} from '../../../../../website/server/libs/api-v3/encryption'; - -describe('encryption', () => { - it('can encrypt and decrypt', () => { - let data = 'some secret text'; - let encrypted = encrypt(data); - let decrypted = decrypt(encrypted); - - expect(encrypted).not.to.equal(data); - expect(data).to.equal(decrypted); - }); -}); diff --git a/test/api/v3/unit/libs/errors.test.js b/test/api/v3/unit/libs/errors.test.js deleted file mode 100644 index efa694d5ab..0000000000 --- a/test/api/v3/unit/libs/errors.test.js +++ /dev/null @@ -1,122 +0,0 @@ -// TODO move to shared tests -import { - CustomError, - NotAuthorized, - BadRequest, - InternalServerError, - NotFound, -} from '../../../../../website/server/libs/api-v3/errors'; - -describe('Custom Errors', () => { - describe('CustomError', () => { - it('is an instance of Error', () => { - let customError = new CustomError(); - - expect(customError).to.be.an.instanceOf(Error); - }); - }); - - describe('NotAuthorized', () => { - it('is an instance of CustomError', () => { - let notAuthorizedError = new NotAuthorized(); - - expect(notAuthorizedError).to.be.an.instanceOf(CustomError); - }); - - it('it returns an http code of 401', () => { - let notAuthorizedError = new NotAuthorized(); - - expect(notAuthorizedError.httpCode).to.eql(401); - }); - - it('returns a default message', () => { - let notAuthorizedError = new NotAuthorized(); - - expect(notAuthorizedError.message).to.eql('Not authorized.'); - }); - - it('allows a custom message', () => { - let notAuthorizedError = new NotAuthorized('Custom Error Message'); - - expect(notAuthorizedError.message).to.eql('Custom Error Message'); - }); - }); - - describe('NotFound', () => { - it('is an instance of CustomError', () => { - let notAuthorizedError = new NotFound(); - - expect(notAuthorizedError).to.be.an.instanceOf(CustomError); - }); - - it('it returns an http code of 404', () => { - let notAuthorizedError = new NotFound(); - - expect(notAuthorizedError.httpCode).to.eql(404); - }); - - it('returns a default message', () => { - let notAuthorizedError = new NotFound(); - - expect(notAuthorizedError.message).to.eql('Not found.'); - }); - - it('allows a custom message', () => { - let notAuthorizedError = new NotFound('Custom Error Message'); - - expect(notAuthorizedError.message).to.eql('Custom Error Message'); - }); - }); - - describe('BadRequest', () => { - it('is an instance of CustomError', () => { - let badRequestError = new BadRequest(); - - expect(badRequestError).to.be.an.instanceOf(CustomError); - }); - - it('it returns an http code of 401', () => { - let badRequestError = new BadRequest(); - - expect(badRequestError.httpCode).to.eql(400); - }); - - it('returns a default message', () => { - let badRequestError = new BadRequest(); - - expect(badRequestError.message).to.eql('Bad request.'); - }); - - it('allows a custom message', () => { - let badRequestError = new BadRequest('Custom Error Message'); - - expect(badRequestError.message).to.eql('Custom Error Message'); - }); - }); - - describe('InternalServerError', () => { - it('is an instance of CustomError', () => { - let internalServerError = new InternalServerError(); - - expect(internalServerError).to.be.an.instanceOf(CustomError); - }); - - it('it returns an http code of 500', () => { - let internalServerError = new InternalServerError(); - - expect(internalServerError.httpCode).to.eql(500); - }); - - it('returns a default message', () => { - let internalServerError = new InternalServerError(); - - expect(internalServerError.message).to.eql('An unexpected error occurred.'); - }); - - it('allows a custom message', () => { - let internalServerError = new InternalServerError('Custom Error Message'); - - expect(internalServerError.message).to.eql('Custom Error Message'); - }); - }); -}); diff --git a/test/api/v3/unit/libs/i18n.test.js b/test/api/v3/unit/libs/i18n.test.js deleted file mode 100644 index 06ebcbc0b6..0000000000 --- a/test/api/v3/unit/libs/i18n.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import { - translations, - localePath, - langCodes, -} from '../../../../../website/server/libs/api-v3/i18n'; -import fs from 'fs'; -import path from 'path'; - -describe('i18n', () => { - let listOfLocales = []; - - before((done) => { - fs.readdir(localePath, (err, files) => { - if (err) return done(err); - - files.forEach((file) => { - if (fs.statSync(path.join(localePath, file)).isDirectory() === false) return; - listOfLocales.push(file); - }); - - listOfLocales = listOfLocales.sort(); - done(); - }); - }); - - describe('translations', () => { - it('includes a translation object for each locale', () => { - listOfLocales.forEach((locale) => { - expect(translations[locale]).to.be.an('object'); - }); - }); - }); - - describe('langCodes', () => { - it('is a list of all the language codes', () => { - expect(langCodes.sort()).to.eql(listOfLocales); - }); - }); -}); diff --git a/test/api/v3/unit/libs/logger.js b/test/api/v3/unit/libs/logger.js deleted file mode 100644 index b7e1d490fc..0000000000 --- a/test/api/v3/unit/libs/logger.js +++ /dev/null @@ -1,57 +0,0 @@ -import winston from 'winston'; -import requireAgain from 'require-again'; - -/* eslint-disable global-require */ -describe('logger', () => { - let pathToLoggerLib = '../../../../../website/server/libs/api-v3/logger'; - let infoSpy; - let errorSpy; - - beforeEach(() => { - infoSpy = sandbox.stub(); - errorSpy = sandbox.stub(); - sandbox.stub(winston, 'Logger').returns({ - info: infoSpy, - error: errorSpy, - }); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('info', () => { - let attachLogger = requireAgain(pathToLoggerLib); - attachLogger.info(1, 2, 3); - expect(infoSpy).to.be.calledOnce; - expect(infoSpy).to.be.calledWith(1, 2, 3); - }); - - describe('error', () => { - it('with custom arguments', () => { - let attachLogger = requireAgain(pathToLoggerLib); - attachLogger.error(1, 2, 3, 4); - expect(errorSpy).to.be.calledOnce; - expect(errorSpy).to.be.calledWith(1, 2, 3, 4); - }); - - it('with error', () => { - let attachLogger = requireAgain(pathToLoggerLib); - let errInstance = new Error('An error.'); - attachLogger.error(errInstance, { - data: 1, - }, 2, 3); - expect(errorSpy).to.be.calledOnce; - // using calledWith doesn't work - let lastCallArgs = errorSpy.lastCall.args; - - expect(lastCallArgs[3]).to.equal(3); - expect(lastCallArgs[2]).to.equal(2); - expect(lastCallArgs[1]).to.eql({ - data: 1, - fullError: errInstance, - }); - expect(lastCallArgs[0]).to.eql(errInstance.stack); - }); - }); -}); diff --git a/test/api/v3/unit/libs/password.test.js b/test/api/v3/unit/libs/password.test.js deleted file mode 100644 index 68290aebc6..0000000000 --- a/test/api/v3/unit/libs/password.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { - encrypt as encryptPassword, - makeSalt, -} from '../../../../../website/server/libs/api-v3/password'; - -describe('Password Utilities', () => { - describe('Encrypt', () => { - it('always encrypt the same password to the same value when using the same salt', () => { - let textPassword = 'mySecretPassword'; - let salt = makeSalt(); - let encryptedPassword = encryptPassword(textPassword, salt); - - expect(encryptPassword(textPassword, salt)).to.eql(encryptedPassword); - }); - - it('never encrypt the same password to the same value when using a different salt', () => { - let textPassword = 'mySecretPassword'; - let aSalt = makeSalt(); - let anotherSalt = makeSalt(); - let anEncryptedPassword = encryptPassword(textPassword, aSalt); - let anotherEncryptedPassword = encryptPassword(textPassword, anotherSalt); - - expect(anEncryptedPassword).not.to.eql(anotherEncryptedPassword); - }); - }); - - describe('Make Salt', () => { - it('creates a salt with length 10 by default', () => { - let salt = makeSalt(); - - expect(salt.length).to.eql(10); - }); - - it('can create a salt of any length', () => { - let length = 24; - let salt = makeSalt(length); - - expect(salt.length).to.eql(length); - }); - }); -}); diff --git a/test/api/v3/unit/libs/payments.test.js b/test/api/v3/unit/libs/payments.test.js deleted file mode 100644 index 30fe78b643..0000000000 --- a/test/api/v3/unit/libs/payments.test.js +++ /dev/null @@ -1,72 +0,0 @@ -import * as sender from '../../../../../website/server/libs/api-v3/email'; -import * as api from '../../../../../website/server/libs/api-v3/payments'; -import { model as User } from '../../../../../website/server/models/user'; -import moment from 'moment'; - -describe('payments/index', () => { - let fakeSend; - let data; - let user; - - describe('#createSubscription', () => { - beforeEach(async () => { - user = new User(); - }); - - it('succeeds', async () => { - data = { user, sub: { key: 'basic_3mo' } }; - expect(user.purchased.plan.planId).to.not.exist; - await api.createSubscription(data); - expect(user.purchased.plan.planId).to.exist; - }); - }); - - describe('#cancelSubscription', () => { - beforeEach(() => { - fakeSend = sinon.spy(sender, 'sendTxn'); - data = { user: new User() }; - }); - - afterEach(() => { - fakeSend.restore(); - }); - - it('plan.extraMonths is defined', () => { - api.cancelSubscription(data); - let terminated = data.user.purchased.plan.dateTerminated; - data.user.purchased.plan.extraMonths = 2; - api.cancelSubscription(data); - let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days')); - expect(difference - 60).to.be.lessThan(3); // the difference is approximately two months, +/- 2 days - }); - - it('plan.extraMonth is a fraction', () => { - api.cancelSubscription(data); - let terminated = data.user.purchased.plan.dateTerminated; - data.user.purchased.plan.extraMonths = 0.3; - api.cancelSubscription(data); - let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days')); - expect(difference - 10).to.be.lessThan(3); // the difference should be 10 days. - }); - - it('nextBill is defined', () => { - api.cancelSubscription(data); - let terminated = data.user.purchased.plan.dateTerminated; - data.nextBill = moment().add({ days: 25 }); - api.cancelSubscription(data); - let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days')); - expect(difference - 5).to.be.lessThan(2); // the difference should be 5 days, +/- 1 day - }); - - it('saves the canceled subscription for the user', () => { - expect(data.user.purchased.plan.dateTerminated).to.not.exist; - api.cancelSubscription(data); - expect(data.user.purchased.plan.dateTerminated).to.exist; - }); - - it('sends a text', async () => { - await api.cancelSubscription(data); - sinon.assert.called(fakeSend); - }); - }); -}); diff --git a/test/api/v3/unit/libs/preening.test.js b/test/api/v3/unit/libs/preening.test.js deleted file mode 100644 index af503ca480..0000000000 --- a/test/api/v3/unit/libs/preening.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { preenHistory } from '../../../../../website/server/libs/api-v3/preening'; -import moment from 'moment'; -import sinon from 'sinon'; // eslint-disable-line no-shadow -import { generateHistory } from '../../../../helpers/api-unit.helper.js'; - -describe('preenHistory', () => { - let clock; - - beforeEach(() => { - // Replace system clocks so we can get predictable results - clock = sinon.useFakeTimers(Number(moment('2013-10-20').zone(0).startOf('day').toDate()), 'Date'); - }); - afterEach(() => { - return clock.restore(); - }); - - it('does not modify history if all entries are more recent than cutoff (free users)', () => { - let h = generateHistory(60); - expect(preenHistory(_.cloneDeep(h), false, 0)).to.eql(h); - }); - - it('does not modify history if all entries are more recent than cutoff (subscribers)', () => { - let h = generateHistory(365); - expect(preenHistory(_.cloneDeep(h), true, 0)).to.eql(h); - }); - - it('does aggregate data in monthly entries before cutoff (free users)', () => { - let h = generateHistory(81); // Jumps to July - let preened = preenHistory(_.cloneDeep(h), false, 0); - expect(preened.length).to.eql(62); // Keeps 60 days + 2 entries per august and july - }); - - it('does aggregate data in monthly entries before cutoff (subscribers)', () => { - let h = generateHistory(396); // Jumps to September 2012 - let preened = preenHistory(_.cloneDeep(h), true, 0); - expect(preened.length).to.eql(367); // Keeps 365 days + 2 entries per october and september - }); - - it('does aggregate data in monthly and yearly entries before cutoff (free users)', () => { - let h = generateHistory(731); // Jumps to October 21 2012 - let preened = preenHistory(_.cloneDeep(h), false, 0); - expect(preened.length).to.eql(73); // Keeps 60 days + 11 montly entries and 2 yearly entry for 2011 and 2012 - }); - - it('does aggregate data in monthly and yearly entries before cutoff (subscribers)', () => { - let h = generateHistory(1031); // Jumps to October 21 2012 - let preened = preenHistory(_.cloneDeep(h), true, 0); - expect(preened.length).to.eql(380); // Keeps 365 days + 13 montly entries and 2 yearly entries for 2011 and 2010 - }); - - it('correctly aggregates values', () => { - let h = generateHistory(63); // Compress last 3 days - let preened = preenHistory(_.cloneDeep(h), false, 0); - expect(preened[0].value).to.eql((61 + 62 + 63) / 3); - }); -}); diff --git a/test/api/v3/unit/libs/setupNconf.test.js b/test/api/v3/unit/libs/setupNconf.test.js deleted file mode 100644 index 3e848b845f..0000000000 --- a/test/api/v3/unit/libs/setupNconf.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import setupNconf from '../../../../../website/server/libs/api-v3/setupNconf'; - -import path from 'path'; -import nconf from 'nconf'; - -describe('setupNconf', () => { - beforeEach(() => { - sandbox.stub(nconf, 'argv').returnsThis(); - sandbox.stub(nconf, 'env').returnsThis(); - sandbox.stub(nconf, 'file').returnsThis(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('sets up nconf', () => { - setupNconf(); - - expect(nconf.argv).to.be.calledOnce; - expect(nconf.env).to.be.calledOnce; - expect(nconf.file).to.be.calledOnce; - - let regexString = `\\${path.sep}config.json$`; - expect(nconf.file).to.be.calledWithMatch('user', new RegExp(regexString)); - }); - - it('sets IS_PROD variable', () => { - setupNconf(); - expect(nconf.get('IS_PROD')).to.exist; - }); - - it('sets IS_DEV variable', () => { - setupNconf(); - expect(nconf.get('IS_DEV')).to.exist; - }); - - it('allows a custom config.json file to be passed in', () => { - setupNconf('customfile.json'); - - expect(nconf.file).to.be.calledOnce; - expect(nconf.file).to.be.calledWithMatch('user', 'customfile.json'); - }); -}); diff --git a/test/api/v3/unit/libs/webhooks.test.js b/test/api/v3/unit/libs/webhooks.test.js deleted file mode 100644 index 502bfe3839..0000000000 --- a/test/api/v3/unit/libs/webhooks.test.js +++ /dev/null @@ -1,135 +0,0 @@ -import request from 'request'; -import { sendTaskWebhook } from '../../../../../website/server/libs/api-v3/webhook'; - -describe('webhooks', () => { - beforeEach(() => { - sandbox.stub(request, 'post'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('sendTaskWebhook', () => { - let task = { - details: { _id: 'task-id' }, - delta: 1.4, - direction: 'up', - }; - - let data = { - task, - user: { _id: 'user-id' }, - }; - - it('does not send if no webhook endpoints exist', () => { - let webhooks = { }; - - sendTaskWebhook(webhooks, data); - - expect(request.post).to.not.be.called; - }); - - it('does not send if no webhooks are enabled', () => { - let webhooks = { - 'some-id': { - sort: 0, - id: 'some-id', - enabled: false, - url: 'http://example.org/endpoint', - }, - }; - - sendTaskWebhook(webhooks, data); - - expect(request.post).to.not.be.called; - }); - - it('does not send if webhook url is not valid', () => { - let webhooks = { - 'some-id': { - sort: 0, - id: 'some-id', - enabled: true, - url: 'http://malformedurl/endpoint', - }, - }; - - sendTaskWebhook(webhooks, data); - - expect(request.post).to.not.be.called; - }); - - it('sends task direction, task, task delta, and abridged user data', () => { - let webhooks = { - 'some-id': { - sort: 0, - id: 'some-id', - enabled: true, - url: 'http://example.org/endpoint', - }, - }; - - sendTaskWebhook(webhooks, data); - - expect(request.post).to.be.calledOnce; - expect(request.post).to.be.calledWith({ - url: 'http://example.org/endpoint', - body: { - direction: 'up', - task: { _id: 'task-id' }, - delta: 1.4, - user: { - _id: 'user-id', - }, - }, - json: true, - }); - }); - - it('sends a post request for each webhook endpoint', () => { - let webhooks = { - 'some-id': { - sort: 0, - id: 'some-id', - enabled: true, - url: 'http://example.org/endpoint', - }, - 'second-webhook': { - sort: 1, - id: 'second-webhook', - enabled: true, - url: 'http://example.com/2/endpoint', - }, - }; - - sendTaskWebhook(webhooks, data); - - expect(request.post).to.be.calledTwice; - expect(request.post).to.be.calledWith({ - url: 'http://example.org/endpoint', - body: { - direction: 'up', - task: { _id: 'task-id' }, - delta: 1.4, - user: { - _id: 'user-id', - }, - }, - json: true, - }); - expect(request.post).to.be.calledWith({ - url: 'http://example.com/2/endpoint', - body: { - direction: 'up', - task: { _id: 'task-id' }, - delta: 1.4, - user: { - _id: 'user-id', - }, - }, - json: true, - }); - }); - }); -}); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js deleted file mode 100644 index 2a25380713..0000000000 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ /dev/null @@ -1,49 +0,0 @@ -/* eslint-disable global-require */ -import { - generateRes, - generateReq, - generateNext, -} from '../../../../helpers/api-unit.helper'; -import analyticsService from '../../../../../website/server/libs/api-v3/analyticsService'; -import nconf from 'nconf'; -import requireAgain from 'require-again'; - -describe('analytics middleware', () => { - let res, req, next; - let pathToAnalyticsMiddleware = '../../../../../website/server/middlewares/api-v3/analytics'; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - }); - - it('attaches analytics object res.locals', () => { - let attachAnalytics = requireAgain(pathToAnalyticsMiddleware); - - attachAnalytics(req, res, next); - - expect(res.analytics).to.exist; - }); - - it('attaches stubbed methods for non-prod environments', () => { - sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false); - let attachAnalytics = requireAgain(pathToAnalyticsMiddleware); - - attachAnalytics(req, res, next); - - expect(res.analytics.track).to.eql(analyticsService.mockAnalyticsService.track); - expect(res.analytics.trackPurchase).to.eql(analyticsService.mockAnalyticsService.trackPurchase); - }); - - it('attaches real methods for prod environments', () => { - sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - - let attachAnalytics = requireAgain(pathToAnalyticsMiddleware); - - attachAnalytics(req, res, next); - - expect(res.analytics.track).to.eql(analyticsService.track); - expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase); - }); -}); diff --git a/test/api/v3/unit/middlewares/cors.test.js b/test/api/v3/unit/middlewares/cors.test.js deleted file mode 100644 index 78d11651f8..0000000000 --- a/test/api/v3/unit/middlewares/cors.test.js +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable global-require */ -import { - generateRes, - generateReq, - generateNext, -} from '../../../../helpers/api-unit.helper'; -import cors from '../../../../../website/server/middlewares/api-v3/cors'; - -describe('cors middleware', () => { - let res, req, next; - - beforeEach(() => { - req = generateReq(); - res = generateRes(); - next = generateNext(); - }); - - it('sets the correct headers', () => { - cors(req, res, next); - expect(res.set).to.have.been.calledWith({ - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'OPTIONS,GET,POST,PUT,HEAD,DELETE', - 'Access-Control-Allow-Headers': 'Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key', - }); - expect(res.sendStatus).to.not.have.been.called; - expect(next).to.have.been.called.once; - }); - - it('responds immediately if method is OPTIONS', () => { - req.method = 'OPTIONS'; - cors(req, res, next); - expect(res.set).to.have.been.calledWith({ - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'OPTIONS,GET,POST,PUT,HEAD,DELETE', - 'Access-Control-Allow-Headers': 'Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key', - }); - expect(res.sendStatus).to.have.been.calledWith(200); - expect(next).to.not.have.been.called; - }); -}); diff --git a/test/api/v3/unit/middlewares/cronMiddleware.js b/test/api/v3/unit/middlewares/cronMiddleware.js deleted file mode 100644 index f4e040a11c..0000000000 --- a/test/api/v3/unit/middlewares/cronMiddleware.js +++ /dev/null @@ -1,177 +0,0 @@ -import { - generateRes, - generateReq, - generateNext, - generateTodo, - generateDaily, -} from '../../../../helpers/api-unit.helper'; -import cronMiddleware from '../../../../../website/server/middlewares/api-v3/cron'; -import moment from 'moment'; -import { model as User } from '../../../../../website/server/models/user'; -import { model as Group } from '../../../../../website/server/models/group'; -import * as Tasks from '../../../../../website/server/models/task'; -import analyticsService from '../../../../../website/server/libs/api-v3/analyticsService'; -import { v4 as generateUUID } from 'uuid'; - -describe('cron middleware', () => { - let res, req, next; - let user; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - user = new User({ - auth: { - local: { - username: 'username', - lowerCaseUsername: 'username', - email: 'email@email.email', - salt: 'salt', - hashed_password: 'hashed_password', // eslint-disable-line camelcase - }, - }, - }); - - user._statsComputed = { - mp: 10, - maxMP: 100, - }; - - res.locals.user = user; - res.analytics = analyticsService; - }); - - it('calls next when user is not attached', () => { - res.locals.user = null; - cronMiddleware(req, res, next); - expect(next).to.be.calledOnce; - }); - - it('calls next when days have not been missed', () => { - cronMiddleware(req, res, next); - expect(next).to.be.calledOnce; - }); - - it('should clear todos older than 30 days for free users', async (done) => { - user.lastCron = moment(new Date()).subtract({days: 2}); - let task = generateTodo(user); - task.dateCompleted = moment(new Date()).subtract({days: 31}); - task.completed = true; - await task.save(); - - cronMiddleware(req, res, () => { - Tasks.Task.findOne({_id: task}, function (err, taskFound) { - expect(err).to.not.exist; - expect(taskFound).to.not.exist; - done(); - }); - }); - }); - - it('should not clear todos older than 30 days for subscribed users', (done) => { - user.purchased.plan.customerId = 'subscribedId'; - user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); - user.lastCron = moment(new Date()).subtract({days: 2}); - let task = generateTodo(user); - task.dateCompleted = moment(new Date()).subtract({days: 31}); - task.completed = true; - task.save(); - - cronMiddleware(req, res, () => { - Tasks.Task.findOne({_id: task}, function (err, taskFound) { - expect(err).to.not.exist; - expect(taskFound).to.exist; - done(); - }); - }); - }); - - it('should clear todos older than 90 days for subscribed users', (done) => { - user.purchased.plan.customerId = 'subscribedId'; - user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); - user.lastCron = moment(new Date()).subtract({days: 2}); - - let task = generateTodo(user); - task.dateCompleted = moment(new Date()).subtract({days: 91}); - task.completed = true; - task.save(); - - cronMiddleware(req, res, () => { - Tasks.Task.findOne({_id: task}, function (err, taskFound) { - expect(err).to.not.exist; - expect(taskFound).to.not.exist; - done(); - }); - }); - }); - - it('should call next is user was not modified after cron', (done) => { - let hpBefore = user.stats.hp; - user.lastCron = moment(new Date()).subtract({days: 2}); - - user.save().then(function () { - cronMiddleware(req, res, function () { - expect(hpBefore).to.equal(user.stats.hp); - done(); - }); - }); - }); - - it('does damage for missing dailies', (done) => { - let hpBefore = user.stats.hp; - user.lastCron = moment(new Date()).subtract({days: 2}); - let daily = generateDaily(user); - daily.startDate = moment(new Date()).subtract({days: 2}); - daily.save(); - - cronMiddleware(req, res, () => { - expect(user.stats.hp).to.be.lessThan(hpBefore); - done(); - }); - }); - - it('updates tasks', (done) => { - user.lastCron = moment(new Date()).subtract({days: 2}); - let todo = generateTodo(user); - let todoValueBefore = todo.value; - - cronMiddleware(req, res, () => { - Tasks.Task.findOne({_id: todo._id}, function (err, todoFound) { - expect(err).to.not.exist; - expect(todoFound.value).to.be.lessThan(todoValueBefore); - done(); - }); - }); - }); - - it('applies quest progress', async (done) => { - let hpBefore = user.stats.hp; - user.lastCron = moment(new Date()).subtract({days: 2}); - let daily = generateDaily(user); - daily.startDate = moment(new Date()).subtract({days: 2}); - daily.save(); - - let questKey = 'dilatory'; - user.party.quest.key = questKey; - - let party = new Group({ - type: 'party', - name: generateUUID(), - leader: user._id, - }); - party.quest.members[user._id] = true; - party.quest.key = questKey; - await party.save(); - - user.party._id = party._id; - await user.save(); - - party.startQuest(user); - - cronMiddleware(req, res, () => { - expect(user.stats.hp).to.be.lessThan(hpBefore); - done(); - }); - }); -}); diff --git a/test/api/v3/unit/middlewares/ensureAccessRight.test.js b/test/api/v3/unit/middlewares/ensureAccessRight.test.js deleted file mode 100644 index cc25e4f16b..0000000000 --- a/test/api/v3/unit/middlewares/ensureAccessRight.test.js +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable global-require */ -import { - generateRes, - generateReq, - generateNext, -} from '../../../../helpers/api-unit.helper'; -import i18n from '../../../../../common/script/i18n'; -import { ensureAdmin, ensureSudo } from '../../../../../website/server/middlewares/api-v3/ensureAccessRight'; -import { NotAuthorized } from '../../../../../website/server/libs/api-v3/errors'; - -describe('ensure access middlewares', () => { - let res, req, next; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - }); - - context('ensure admin', () => { - it('returns not authorized when user is not an admin', () => { - res.locals = {user: {contributor: {admin: false}}}; - - ensureAdmin(req, res, next); - - expect(next).to.be.calledWith(new NotAuthorized(i18n.t('noAdminAccess'))); - }); - - it('passes when user is an admin', () => { - res.locals = {user: {contributor: {admin: true}}}; - - ensureAdmin(req, res, next); - - expect(next).to.be.calledOnce; - expect(next.args[0]).to.be.empty; - }); - }); - - context('ensure sudo', () => { - it('returns not authorized when user is not a sudo user', () => { - res.locals = {user: {contributor: {sudo: false}}}; - - ensureSudo(req, res, next); - - expect(next).to.be.calledWith(new NotAuthorized(i18n.t('noSudoAccess'))); - }); - - it('passes when user is a sudo user', () => { - res.locals = {user: {contributor: {sudo: true}}}; - - ensureSudo(req, res, next); - - expect(next).to.be.calledOnce; - expect(next.args[0]).to.be.empty; - }); - }); -}); diff --git a/test/api/v3/unit/middlewares/ensureDevelpmentMode.js b/test/api/v3/unit/middlewares/ensureDevelpmentMode.js deleted file mode 100644 index d7915b365f..0000000000 --- a/test/api/v3/unit/middlewares/ensureDevelpmentMode.js +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable global-require */ -import { - generateRes, - generateReq, - generateNext, -} from '../../../../helpers/api-unit.helper'; -import ensureDevelpmentMode from '../../../../../website/server/middlewares/api-v3/ensureDevelpmentMode'; -import { NotFound } from '../../../../../website/server/libs/api-v3/errors'; -import nconf from 'nconf'; - -describe('developmentMode middleware', () => { - let res, req, next; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - }); - - it('returns not found when in production mode', () => { - sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - - ensureDevelpmentMode(req, res, next); - - expect(next).to.be.calledWith(new NotFound()); - }); - - it('passes when not in production', () => { - sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false); - - ensureDevelpmentMode(req, res, next); - - expect(next).to.be.calledOnce; - expect(next.args[0]).to.be.empty; - }); -}); diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js deleted file mode 100644 index 72cad12a32..0000000000 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ /dev/null @@ -1,173 +0,0 @@ -import { - generateRes, - generateReq, - generateNext, -} from '../../../../helpers/api-unit.helper'; - -import errorHandler from '../../../../../website/server/middlewares/api-v3/errorHandler'; -import responseMiddleware from '../../../../../website/server/middlewares/api-v3/response'; -import { - getUserLanguage, - attachTranslateFunction, -} from '../../../../../website/server/middlewares/api-v3/language'; - -import { BadRequest } from '../../../../../website/server/libs/api-v3/errors'; -import logger from '../../../../../website/server/libs/api-v3/logger'; - -describe('errorHandler', () => { - let res, req, next; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - responseMiddleware(req, res, next); - getUserLanguage(req, res, next); - attachTranslateFunction(req, res, next); - - sandbox.stub(logger, 'error'); - }); - - it('sends internal server error if error is not a CustomError and is not identified', () => { - let error = new Error(); - - errorHandler(error, req, res, next); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(500); - expect(res.json).to.be.calledWith({ - success: false, - error: 'InternalServerError', - message: 'An unexpected error occurred.', - }); - }); - - it('identifies errors with statusCode property and format them correctly', () => { - let error = new Error('Error message'); - error.statusCode = 400; - - errorHandler(error, req, res, next); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(400); - expect(res.json).to.be.calledWith({ - success: false, - error: 'Error', - message: 'Error message', - }); - }); - - it('doesn\'t leak info about 500 errors', () => { - let error = new Error('Some secret error message'); - error.statusCode = 500; - - errorHandler(error, req, res, next); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(500); - expect(res.json).to.be.calledWith({ - success: false, - error: 'InternalServerError', - message: 'An unexpected error occurred.', - }); - }); - - it('sends CustomError', () => { - let error = new BadRequest(); - - errorHandler(error, req, res, next); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(400); - expect(res.json).to.be.calledWith({ - success: false, - error: 'BadRequest', - message: 'Bad request.', - }); - }); - - it('handle http-errors errors', () => { - let error = new Error('custom message'); - error.statusCode = 422; - - errorHandler(error, req, res, next); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(error.statusCode); - expect(res.json).to.be.calledWith({ - success: false, - error: error.name, - message: error.message, - }); - }); - - it('handle express-validator errors', () => { - let error = [{param: 'param', msg: 'invalid param', value: 123}]; - - errorHandler(error, req, res, next); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(400); - expect(res.json).to.be.calledWith({ - success: false, - error: 'BadRequest', - message: 'Invalid request parameters.', - errors: [ - { param: error[0].param, value: error[0].value, message: error[0].msg }, - ], - }); - }); - - it('handle Mongoose Validation errors', () => { - let error = new Error('User validation failed.'); - error.name = 'ValidationError'; - - error.errors = { - 'auth.local.email': { - path: 'auth.local.email', - message: 'Invalid email.', - value: 'not an email', - }, - }; - - errorHandler(error, req, res, next); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(400); - expect(res.json).to.be.calledWith({ - success: false, - error: 'BadRequest', - message: 'User validation failed.', - errors: [ - { path: 'auth.local.email', message: 'Invalid email.', value: 'not an email' }, - ], - }); - }); - - it('logs error', () => { - let error = new BadRequest(); - - errorHandler(error, req, res, next); - - expect(logger.error).to.be.calledOnce; - expect(logger.error).to.be.calledWithExactly(error, { - originalUrl: req.originalUrl, - headers: req.headers, - body: req.body, - }); - }); -}); diff --git a/test/api/v3/unit/middlewares/language.test.js b/test/api/v3/unit/middlewares/language.test.js deleted file mode 100644 index 23ef6deddd..0000000000 --- a/test/api/v3/unit/middlewares/language.test.js +++ /dev/null @@ -1,307 +0,0 @@ -import { - generateRes, - generateReq, - generateNext, -} from '../../../../helpers/api-unit.helper'; -import { - getUserLanguage, - attachTranslateFunction, -} from '../../../../../website/server/middlewares/api-v3/language'; -import common from '../../../../../common'; -import Bluebird from 'bluebird'; -import { model as User } from '../../../../../website/server/models/user'; - -const i18n = common.i18n; - -describe('language middleware', () => { - describe('res.t', () => { - let res, req, next; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - - sinon.stub(i18n, 't'); - }); - - afterEach(() => { - i18n.t.restore(); - }); - - it('attaches t method to res', () => { - attachTranslateFunction(req, res, next); - - expect(res.t).to.exist; - }); - - it('uses the language specified in req.language', () => { - req.language = 'de'; - - attachTranslateFunction(req, res, next); - res.t(1, 2); - - expect(i18n.t).to.be.calledOnce; - expect(i18n.t).to.be.calledWith(1, 2); - }); - }); - - describe('getUserLanguage', () => { - let res, req, next; - - let checkResT = (resToCheck) => { - expect(resToCheck.t).to.be.a('function'); - expect(resToCheck.t('help')).to.equal(i18n.t('help', req.language)); - }; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - attachTranslateFunction(req, res, next); - }); - - context('query parameter', () => { - it('uses the language in the query parameter if avalaible', () => { - req.query = { - lang: 'es', - }; - - getUserLanguage(req, res, next); - expect(req.language).to.equal('es'); - checkResT(res); - }); - - it('falls back to english if the query parameter language does not exists', () => { - req.query = { - lang: 'bla', - }; - - getUserLanguage(req, res, next); - expect(req.language).to.equal('en'); - checkResT(res); - }); - - it('uses query even if the request includes a user and session', () => { - req.query = { - lang: 'es', - }; - - req.locals = { - user: { - preferences: { - language: 'it', - }, - }, - }; - - req.session = { - userId: 123, - }; - - getUserLanguage(req, res, next); - expect(req.language).to.equal('es'); - checkResT(res); - }); - }); - - context('authorized request', () => { - it('uses the user preferred language if avalaible', () => { - req.locals = { - user: { - preferences: { - language: 'it', - }, - }, - }; - - getUserLanguage(req, res, next); - expect(req.language).to.equal('it'); - checkResT(res); - }); - - it('falls back to english if the user preferred language is not avalaible', (done) => { - req.locals = { - user: { - preferences: { - language: 'bla', - }, - }, - }; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('en'); - checkResT(res); - done(); - }); - }); - - it('uses the user preferred language even if a session is included in request', () => { - req.locals = { - user: { - preferences: { - language: 'it', - }, - }, - }; - - req.session = { - userId: 123, - }; - - getUserLanguage(req, res, next); - expect(req.language).to.equal('it'); - checkResT(res); - }); - }); - - context('request with session', () => { - it('uses the user preferred language if avalaible', (done) => { - sandbox.stub(User, 'findOne').returns({ - lean () { - return this; - }, - exec () { - return Bluebird.resolve({ - preferences: { - language: 'it', - }, - }); - }, - }); - - req.session = { - userId: 123, - }; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('it'); - checkResT(res); - done(); - }); - }); - }); - - context('browser fallback', () => { - it('uses browser specificed language', (done) => { - req.headers['accept-language'] = 'pt'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('pt'); - checkResT(res); - done(); - }); - }); - - it('uses first language in series if browser specifies multiple', (done) => { - req.headers['accept-language'] = 'he, pt, it'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('he'); - checkResT(res); - done(); - }); - }); - - it('skips invalid lanaguages and uses first language in series if browser specifies multiple', (done) => { - req.headers['accept-language'] = 'blah, he, pt, it'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('he'); - checkResT(res); - done(); - }); - }); - - it('uses normal version of language if specialized locale is passed in', (done) => { - req.headers['accept-language'] = 'fr-CA'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('fr'); - checkResT(res); - done(); - }); - }); - - it('uses normal version of language if specialized locale is passed in', (done) => { - req.headers['accept-language'] = 'fr-CA'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('fr'); - checkResT(res); - done(); - }); - }); - - it('uses es if es is passed in', (done) => { - req.headers['accept-language'] = 'es'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('es'); - checkResT(res); - done(); - }); - }); - - it('uses es_419 if applicable es-languages are passed in', (done) => { - req.headers['accept-language'] = 'es-mx'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('es_419'); - checkResT(res); - done(); - }); - }); - - it('uses es_419 if multiple es languages are passed in', (done) => { - req.headers['accept-language'] = 'es-GT, es-MX, es-CR'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('es_419'); - checkResT(res); - done(); - }); - }); - - it('zh', (done) => { - req.headers['accept-language'] = 'zh-TW'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('zh_TW'); - checkResT(res); - done(); - }); - }); - - it('uses english if browser specified language is not compatible', (done) => { - req.headers['accept-language'] = 'blah'; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('en'); - checkResT(res); - done(); - }); - }); - - it('uses english if browser does not specify', (done) => { - req.headers['accept-language'] = ''; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('en'); - checkResT(res); - done(); - }); - }); - - it('uses english if browser does not supply an accept-language header', (done) => { - delete req.headers['accept-language']; - - getUserLanguage(req, res, () => { - expect(req.language).to.equal('en'); - checkResT(res); - done(); - }); - }); - }); - }); -}); diff --git a/test/api/v3/unit/middlewares/maintenanceMode.test.js b/test/api/v3/unit/middlewares/maintenanceMode.test.js deleted file mode 100644 index 21cabe963d..0000000000 --- a/test/api/v3/unit/middlewares/maintenanceMode.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import { - generateRes, - generateReq, - generateNext, -} from '../../../../helpers/api-unit.helper'; -import nconf from 'nconf'; -import requireAgain from 'require-again'; - -describe('maintenance mode middleware', () => { - let res, req, next; - let pathToMaintenanceModeMiddleware = '../../../../../website/server/middlewares/api-v3/maintenanceMode'; - - beforeEach(() => { - res = generateRes(); - next = generateNext(); - }); - - it('does not return 503 error when maintenance mode is off', () => { - req = generateReq(); - sandbox.stub(nconf, 'get').withArgs('MAINTENANCE_MODE').returns('false'); - let attachMaintenanceMode = requireAgain(pathToMaintenanceModeMiddleware); - - attachMaintenanceMode(req, res, next); - - expect(next).to.have.been.called.once; - expect(res.status).to.not.have.been.called; - }); - - it('returns 503 error when maintenance mode is on', () => { - req = generateReq(); - sandbox.stub(nconf, 'get').withArgs('MAINTENANCE_MODE').returns('true'); - let attachMaintenanceMode = requireAgain(pathToMaintenanceModeMiddleware); - - attachMaintenanceMode(req, res, next); - - expect(next).to.not.have.been.called; - expect(res.status).to.have.been.calledOnce; - expect(res.status).to.have.been.calledWith(503); - }); - - it('renders maintenance page when request type is HTML', () => { - req = generateReq({headers: {accept: 'text/html'}}); - sandbox.stub(nconf, 'get').withArgs('MAINTENANCE_MODE').returns('true'); - let attachMaintenanceMode = requireAgain(pathToMaintenanceModeMiddleware); - - attachMaintenanceMode(req, res, next); - expect(res.render).to.have.been.calledOnce; - }); - - it('sends error message when request type is JSON', () => { - req = generateReq({headers: {accept: 'application/json'}}); - sandbox.stub(nconf, 'get').withArgs('MAINTENANCE_MODE').returns('true'); - let attachMaintenanceMode = requireAgain(pathToMaintenanceModeMiddleware); - - attachMaintenanceMode(req, res, next); - expect(res.send).to.have.been.calledOnce; - }); -}); diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js deleted file mode 100644 index a24bd881ce..0000000000 --- a/test/api/v3/unit/middlewares/response.js +++ /dev/null @@ -1,66 +0,0 @@ -import { - generateRes, - generateReq, - generateNext, -} from '../../../../helpers/api-unit.helper'; -import responseMiddleware from '../../../../../website/server/middlewares/api-v3/response'; - -describe('response middleware', () => { - let res, req, next; - - beforeEach(() => { - res = generateRes(); - req = generateReq(); - next = generateNext(); - }); - - - it('attaches respond method to res', () => { - responseMiddleware(req, res, next); - - expect(res.respond).to.exist; - }); - - it('can be used to respond to requests', () => { - responseMiddleware(req, res, next); - res.respond(200, {field: 1}); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(200); - expect(res.json).to.be.calledWith({ - success: true, - data: {field: 1}, - }); - }); - - it('can be passed a third parameter to be used as optional message', () => { - responseMiddleware(req, res, next); - res.respond(200, {field: 1}, 'hello'); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(200); - expect(res.json).to.be.calledWith({ - success: true, - data: {field: 1}, - message: 'hello', - }); - }); - - it('treats status >= 400 as failures', () => { - responseMiddleware(req, res, next); - res.respond(403, {field: 1}); - - expect(res.status).to.be.calledOnce; - expect(res.json).to.be.calledOnce; - - expect(res.status).to.be.calledWith(403); - expect(res.json).to.be.calledWith({ - success: false, - data: {field: 1}, - }); - }); -}); diff --git a/test/api/v3/unit/models/challenge.test.js b/test/api/v3/unit/models/challenge.test.js deleted file mode 100644 index b2f0e7ff98..0000000000 --- a/test/api/v3/unit/models/challenge.test.js +++ /dev/null @@ -1,161 +0,0 @@ -import { model as Challenge } from '../../../../../website/server/models/challenge'; -import { model as Group } from '../../../../../website/server/models/group'; -import { model as User } from '../../../../../website/server/models/user'; -import * as Tasks from '../../../../../website/server/models/task'; -import common from '../../../../../common/'; -import { each, find } from 'lodash'; - -describe('Challenge Model', () => { - let guild, leader, challenge, task; - let tasksToTest = { - habit: { - text: 'test habit', - type: 'habit', - up: false, - down: true, - }, - todo: { - text: 'test todo', - type: 'todo', - }, - daily: { - text: 'test daily', - type: 'daily', - frequency: 'daily', - everyX: 5, - startDate: new Date(), - }, - reward: { - text: 'test reward', - type: 'reward', - }, - }; - - beforeEach(async () => { - guild = new Group({ - name: 'test party', - type: 'guild', - }); - - leader = new User({ - guilds: [guild._id], - }); - - guild.leader = leader._id; - - challenge = new Challenge({ - name: 'Test Challenge', - shortName: 'Test', - leader: leader._id, - group: guild._id, - }); - - leader.challenges = [challenge._id]; - - await Promise.all([ - guild.save(), - leader.save(), - challenge.save(), - ]); - }); - - each(tasksToTest, (taskValue, taskType) => { - context(`${taskType}`, () => { - beforeEach(async() => { - task = new Tasks[`${taskType}`](Tasks.Task.sanitize(taskValue)); - task.challenge.id = challenge._id; - await task.save(); - }); - - it('adds tasks to challenge and challenge members', async () => { - await challenge.addTasks([task]); - - let updatedLeader = await User.findOne({_id: leader._id}); - let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); - let syncedTask = find(updatedLeadersTasks, function findNewTask (updatedLeadersTask) { - return updatedLeadersTask.type === taskValue.type && updatedLeadersTask.text === taskValue.text; - }); - - expect(syncedTask).to.exist; - }); - - it('syncs a challenge to a user', async () => { - await challenge.addTasks([task]); - - let newMember = new User({ - guilds: [guild._id], - }); - await newMember.save(); - - await challenge.syncToUser(newMember); - - let updatedNewMember = await User.findById(newMember._id); - let updatedNewMemberTasks = await Tasks.Task.find({_id: { $in: updatedNewMember.tasksOrder[`${taskType}s`]}}); - let syncedTask = find(updatedNewMemberTasks, function findNewTask (updatedNewMemberTask) { - return updatedNewMemberTask.type === taskValue.type && updatedNewMemberTask.text === taskValue.text; - }); - - expect(updatedNewMember.challenges).to.contain(challenge._id); - expect(updatedNewMember.tags[3].id).to.equal(challenge._id); - expect(updatedNewMember.tags[3].name).to.equal(challenge.shortName); - expect(syncedTask).to.exist; - }); - - it('updates tasks to challenge and challenge members', async () => { - let updatedTaskName = 'Updated Test Habit'; - await challenge.addTasks([task]); - - let req = { - body: { text: updatedTaskName }, - }; - - Tasks.Task.sanitize(req.body); - _.assign(task, common.ops.updateTask(task.toObject(), req)[0]); - - await challenge.updateTask(task); - - let updatedLeader = await User.findOne({_id: leader._id}); - let updatedUserTask = await Tasks.Task.findById(updatedLeader.tasksOrder[`${taskType}s`][0]); - - expect(updatedUserTask.text).to.equal(updatedTaskName); - }); - - it('removes a tasks to challenge and challenge members', async () => { - await challenge.addTasks([task]); - await challenge.removeTask(task); - - let updatedLeader = await User.findOne({_id: leader._id}); - let updatedUserTask = await Tasks.Task.findOne({_id: updatedLeader.tasksOrder[`${taskType}s`][0]}).exec(); - - expect(updatedUserTask.challenge.broken).to.equal('TASK_DELETED'); - }); - - it('unlinks and deletes challenge tasks for a user when remove-all is specified', async () => { - await challenge.addTasks([task]); - await challenge.unlinkTasks(leader, 'remove-all'); - - let updatedLeader = await User.findOne({_id: leader._id}); - let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); - let syncedTask = find(updatedLeadersTasks, function findNewTask (updatedLeadersTask) { - return updatedLeadersTask.type === taskValue.type && updatedLeadersTask.text === taskValue.text; - }); - - expect(syncedTask).to.not.exist; - }); - - it('unlinks and keeps challenge tasks for a user when keep-all is specified', async () => { - await challenge.addTasks([task]); - await challenge.unlinkTasks(leader, 'keep-all'); - - let updatedLeader = await User.findOne({_id: leader._id}); - let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); - let syncedTask = find(updatedLeadersTasks, function findNewTask (updatedLeadersTask) { - return updatedLeadersTask.type === taskValue.type && updatedLeadersTask.text === taskValue.text; - }); - - expect(syncedTask).to.exist; - expect(syncedTask.challenge._id).to.be.empty; - }); - }); - }); -}); diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js deleted file mode 100644 index 32007e068d..0000000000 --- a/test/api/v3/unit/models/group.test.js +++ /dev/null @@ -1,300 +0,0 @@ -import { sleep } from '../../../../helpers/api-unit.helper'; -import { model as Group } from '../../../../../website/server/models/group'; -import { model as User } from '../../../../../website/server/models/user'; -import { quests as questScrolls } from '../../../../../common/script/content'; -import * as email from '../../../../../website/server/libs/api-v3/email'; - -describe('Group Model', () => { - context('Instance Methods', () => { - describe('#startQuest', () => { - let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; - - beforeEach(async () => { - sandbox.stub(email, 'sendTxn'); - - party = new Group({ - name: 'test party', - type: 'party', - privacy: 'private', - }); - - questLeader = new User({ - party: { _id: party._id }, - items: { - quests: { - whale: 1, - }, - }, - }); - - party.leader = questLeader._id; - - participatingMember = new User({ - party: { _id: party._id }, - }); - nonParticipatingMember = new User({ - party: { _id: party._id }, - }); - undecidedMember = new User({ - party: { _id: party._id }, - }); - - await Promise.all([ - party.save(), - questLeader.save(), - participatingMember.save(), - nonParticipatingMember.save(), - undecidedMember.save(), - ]); - }); - - context('Failure Conditions', () => { - it('throws an error if group is not a party', async () => { - let guild = new Group({ - type: 'guild', - }); - - await expect(guild.startQuest(participatingMember)).to.eventually.be.rejected; - }); - - it('throws an error if party is not on a quest', async () => { - await expect(party.startQuest(participatingMember)).to.eventually.be.rejected; - }); - - it('throws an error if quest is already active', async () => { - party.quest.key = 'whale'; - party.quest.active = true; - - await expect(party.startQuest(participatingMember)).to.eventually.be.rejected; - }); - }); - - context('Successes', () => { - beforeEach(() => { - party.quest.key = 'whale'; - party.quest.active = false; - party.quest.leader = questLeader._id; - party.quest.members = { }; - party.quest.members[questLeader._id] = true; - party.quest.members[participatingMember._id] = true; - party.quest.members[nonParticipatingMember._id] = false; - party.quest.members[undecidedMember._id] = null; - }); - - it('activates quest', () => { - party.startQuest(participatingMember); - - expect(party.quest.active).to.eql(true); - }); - - it('sets up boss quest', () => { - let bossQuest = questScrolls.whale; - party.quest.key = bossQuest.key; - - party.startQuest(participatingMember); - - expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp); - }); - - it('sets up rage meter for rage boss quest', () => { - let rageBossQuest = questScrolls.trex_undead; - party.quest.key = rageBossQuest.key; - - party.startQuest(participatingMember); - - expect(party.quest.progress.rage).to.eql(0); - }); - - it('sets up collection quest', () => { - let collectionQuest = questScrolls.vice2; - party.quest.key = collectionQuest.key; - party.startQuest(participatingMember); - - expect(party.quest.progress.collect).to.eql({ - lightCrystal: 0, - }); - }); - - it('sets up collection quest with multiple items', () => { - let collectionQuest = questScrolls.evilsanta2; - party.quest.key = collectionQuest.key; - party.startQuest(participatingMember); - - expect(party.quest.progress.collect).to.eql({ - tracks: 0, - branches: 0, - }); - }); - - it('prunes non-participating members from quest members object', () => { - party.startQuest(participatingMember); - - let expectedQuestMembers = {}; - expectedQuestMembers[questLeader._id] = true; - expectedQuestMembers[participatingMember._id] = true; - - expect(party.quest.members).to.eql(expectedQuestMembers); - }); - - it('applies updates to user object directly if user is participating', async () => { - await party.startQuest(participatingMember); - - expect(participatingMember.party.quest.key).to.eql('whale'); - expect(participatingMember.party.quest.progress.down).to.eql(0); - expect(participatingMember.party.quest.progress.collect).to.eql({}); - expect(participatingMember.party.quest.completed).to.eql(null); - }); - - it('applies updates to other participating members', async () => { - await party.startQuest(nonParticipatingMember); - - questLeader = await User.findById(questLeader._id); - participatingMember = await User.findById(participatingMember._id); - - expect(participatingMember.party.quest.key).to.eql('whale'); - expect(participatingMember.party.quest.progress.down).to.eql(0); - expect(participatingMember.party.quest.progress.collect).to.eql({}); - expect(participatingMember.party.quest.completed).to.eql(null); - - expect(questLeader.party.quest.key).to.eql('whale'); - expect(questLeader.party.quest.progress.down).to.eql(0); - expect(questLeader.party.quest.progress.collect).to.eql({}); - expect(questLeader.party.quest.completed).to.eql(null); - }); - - it('does not apply updates to nonparticipating members', async () => { - await party.startQuest(participatingMember); - - nonParticipatingMember = await User.findById(nonParticipatingMember ._id); - undecidedMember = await User.findById(undecidedMember._id); - - expect(nonParticipatingMember.party.quest.key).to.not.eql('whale'); - expect(undecidedMember.party.quest.key).to.not.eql('whale'); - }); - - it('sends email to participating members that quest has started', async () => { - participatingMember.preferences.emailNotifications.questStarted = true; - questLeader.preferences.emailNotifications.questStarted = true; - await Promise.all([ - participatingMember.save(), - questLeader.save(), - ]); - - await party.startQuest(nonParticipatingMember); - - await sleep(0.5); - - expect(email.sendTxn).to.be.calledOnce; - - let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); - let typeOfEmail = email.sendTxn.args[0][1]; - - expect(memberIds).to.have.a.lengthOf(2); - expect(memberIds).to.include(participatingMember._id); - expect(memberIds).to.include(questLeader._id); - expect(typeOfEmail).to.eql('quest-started'); - }); - - it('sends email only to members who have not opted out', async () => { - participatingMember.preferences.emailNotifications.questStarted = false; - questLeader.preferences.emailNotifications.questStarted = true; - await Promise.all([ - participatingMember.save(), - questLeader.save(), - ]); - - await party.startQuest(nonParticipatingMember); - - await sleep(0.5); - - expect(email.sendTxn).to.be.calledOnce; - - let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); - - expect(memberIds).to.have.a.lengthOf(1); - expect(memberIds).to.not.include(participatingMember._id); - expect(memberIds).to.include(questLeader._id); - }); - - it('does not send email to initiating member', async () => { - participatingMember.preferences.emailNotifications.questStarted = true; - questLeader.preferences.emailNotifications.questStarted = true; - await Promise.all([ - participatingMember.save(), - questLeader.save(), - ]); - - await party.startQuest(participatingMember); - - await sleep(0.5); - - expect(email.sendTxn).to.be.calledOnce; - - let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); - - expect(memberIds).to.have.a.lengthOf(1); - expect(memberIds).to.not.include(participatingMember._id); - expect(memberIds).to.include(questLeader._id); - }); - - it('updates participting members (not including user)', async () => { - sandbox.spy(User, 'update'); - - await party.startQuest(nonParticipatingMember); - - let members = [questLeader._id, participatingMember._id]; - - expect(User.update).to.be.calledWith( - { _id: { $in: members } }, - { - $set: { - 'party.quest.key': 'whale', - 'party.quest.progress.down': 0, - 'party.quest.progress.collect': {}, - 'party.quest.completed': null, - }, - } - ); - }); - - it('updates non-user quest leader and decrements quest scroll', async () => { - sandbox.spy(User, 'update'); - - await party.startQuest(participatingMember); - - expect(User.update).to.be.calledWith( - { _id: questLeader._id }, - { - $inc: { - 'items.quests.whale': -1, - }, - } - ); - }); - - it('modifies the participating initiating user directly', async () => { - await party.startQuest(participatingMember); - - let userQuest = participatingMember.party.quest; - - expect(userQuest.key).to.eql('whale'); - expect(userQuest.progress.down).to.eql(0); - expect(userQuest.progress.collect).to.eql({}); - expect(userQuest.completed).to.eql(null); - }); - - it('does not modify user if not participating', async () => { - await party.startQuest(nonParticipatingMember); - - expect(nonParticipatingMember.party.quest.key).to.not.eql('whale'); - }); - - it('removes the quest directly if initiating user is the quest leader', async () => { - await party.startQuest(questLeader); - - expect(questLeader.items.quests.whale).to.eql(0); - }); - }); - }); - }); -}); diff --git a/test/api/v3/unit/models/task.test.js b/test/api/v3/unit/models/task.test.js deleted file mode 100644 index 1c3082d863..0000000000 --- a/test/api/v3/unit/models/task.test.js +++ /dev/null @@ -1,74 +0,0 @@ -import { model as Challenge } from '../../../../../website/server/models/challenge'; -import { model as Group } from '../../../../../website/server/models/group'; -import { model as User } from '../../../../../website/server/models/user'; -import * as Tasks from '../../../../../website/server/models/task'; -import { each } from 'lodash'; -import { generateHistory } from '../../../../helpers/api-unit.helper.js'; - -describe('Task Model', () => { - let guild, leader, challenge, task; - let tasksToTest = { - habit: { - text: 'test habit', - type: 'habit', - up: false, - down: true, - }, - daily: { - text: 'test daily', - type: 'daily', - frequency: 'daily', - everyX: 5, - startDate: new Date(), - }, - }; - - beforeEach(async () => { - guild = new Group({ - name: 'test guild', - type: 'guild', - }); - - leader = new User({ - guilds: [guild._id], - }); - - guild.leader = leader._id; - - challenge = new Challenge({ - name: 'Test Challenge', - shortName: 'Test', - leader: leader._id, - group: guild._id, - }); - - leader.challenges = [challenge._id]; - - await Promise.all([ - guild.save(), - leader.save(), - challenge.save(), - ]); - }); - - each(tasksToTest, (taskValue, taskType) => { - context(`${taskType}`, () => { - beforeEach(async() => { - task = new Tasks[`${taskType}`](Tasks.Task.sanitize(taskValue)); - task.challenge.id = challenge._id; - task.history = generateHistory(396); - await task.save(); - }); - - it('preens challenge tasks history when scored', async () => { - let historyLengthBeforePreen = task.history.length; - - await task.scoreChallengeTask(1.2); - - let updatedTask = await Tasks.Task.findOne({_id: task._id}); - - expect(historyLengthBeforePreen).to.be.greaterThan(updatedTask.history.length); - }); - }); - }); -}); diff --git a/test/api/v3/unit/models/user.test.js b/test/api/v3/unit/models/user.test.js deleted file mode 100644 index d7f509712c..0000000000 --- a/test/api/v3/unit/models/user.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import { model as User } from '../../../../../website/server/models/user'; - -describe('User Model', () => { - it('keeps user._tmp when calling .toJSON', () => { - let user = new User({ - auth: { - local: { - username: 'username', - lowerCaseUsername: 'username', - email: 'email@email.email', - salt: 'salt', - hashed_password: 'hashed_password', // eslint-disable-line camelcase - }, - }, - }); - - user._tmp = {ok: true}; - user._nonTmp = {ok: true}; - - expect(user._tmp).to.eql({ok: true}); - expect(user._nonTmp).to.eql({ok: true}); - - let toObject = user.toObject(); - let toJSON = user.toJSON(); - - expect(toObject).to.not.have.keys('_tmp'); - expect(toObject).to.not.have.keys('_nonTmp'); - - expect(toJSON).to.have.any.key('_tmp'); - expect(toJSON._tmp).to.eql({ok: true}); - expect(toJSON).to.not.have.keys('_nonTmp'); - }); -}); diff --git a/test/common/algos.mocha.js b/test/common/algos.mocha.js new file mode 100644 index 0000000000..12581410bf --- /dev/null +++ b/test/common/algos.mocha.js @@ -0,0 +1,1491 @@ +/* eslint-disable camelcase, func-names, no-shadow */ + +import { + generateUser, + generateDaily, + generateHabit, + generateTodo, +} from '../helpers/common.helper'; + +import { + DAY_MAPPING, + startOfWeek, + startOfDay, + daysSince, +} from '../../common/script/cron'; + +let expect = require('expect.js'); +let sinon = require('sinon'); +let moment = require('moment'); +let test_helper = require('./test_helper'); +let shared = require('../../common/script/index.js'); +let $w = (s) => { + return s.split(' '); +}; + +shared.i18n.translations = require('../../website/src/libs/i18n.js').translations; +test_helper.addCustomMatchers(); + +/* Helper Functions */ +let rewrapUser = (user) => { + user._wrapped = false; + shared.wrap(user); + return user; +}; + +let beforeAfter = (options = {}) => { + let lastCron; + let user = generateUser(); + let daily = generateDaily(); + let habit = generateHabit(); + let todo = generateTodo(); + + user.dailys.push(daily); + user.habits.push(habit); + user.todos.push(todo); + + let ref = [user, _.cloneDeep(user)]; + let before = ref[0]; + let after = ref[1]; + + rewrapUser(after); + if (options.dayStart) { + before.preferences.dayStart = after.preferences.dayStart = options.dayStart; + } + before.preferences.timezoneOffset = after.preferences.timezoneOffset = options.timezoneOffset || moment().zone(); + before.preferences.timezoneOffsetAtLastCron = after.preferences.timezoneOffsetAtLastCron = before.preferences.timezoneOffset; + if (options.limitOne) { + before[`${options.limitOne}s`] = [before[`${options.limitOne}s`][0]]; + after[`${options.limitOne}s`] = [after[`${options.limitOne}s`][0]]; + } + if (options.daysAgo) { + lastCron = moment(options.now || Number(new Date())).subtract({ + days: options.daysAgo, + }); + } + if (options.daysAgo && options.cronAfterStart) { + lastCron.add({ + hours: options.dayStart, + minutes: 1, + }); + } + if (options.daysAgo) { + lastCron = Number(lastCron); + } + _.each([before, after], (obj) => { + if (options.daysAgo) { + obj.lastCron = lastCron; + } + }); + return { + before, + after, + }; +}; + +let expectLostPoints = (before, after, taskType) => { + if (taskType === 'daily' || taskType === 'habit') { + expect(after.stats.hp).to.be.lessThan(before.stats.hp); + expect(after[`${taskType}s`][0].history).to.have.length(1); + } else { + expect(after.history.todos).to.have.length(1); + } + expect(after).toHaveExp(0); + expect(after).toHaveGP(0); + expect(after[`${taskType}s`][0].value).to.be.lessThan(before[`${taskType}s`][0].value); +}; + +let expectGainedPoints = (before, after, taskType) => { + expect(after.stats.hp).to.be(50); + expect(after.stats.exp).to.be.greaterThan(before.stats.exp); + expect(after.stats.gp).to.be.greaterThan(before.stats.gp); + expect(after[`${taskType}s`][0].value).to.be.greaterThan(before[`${taskType}s`][0].value); + if (taskType === 'habit') { + expect(after[`${taskType}s`][0].history).to.have.length(1); + } +}; + +let expectNoChange = (before, after) => { + _.each($w('stats items gear dailys todos rewards preferences'), (attr) => { + expect(after[attr]).to.eql(before[attr]); + }); +}; + +let expectClosePoints = (before, after, taskType) => { + expect(Math.abs(after.stats.exp - before.stats.exp)).to.be.lessThan(0.0001); + expect(Math.abs(after.stats.gp - before.stats.gp)).to.be.lessThan(0.0001); + expect(Math.abs(after[taskType + 's'][0].value - before[taskType + 's'][0].value)).to.be.lessThan(0.0001); // eslint-disable-line prefer-template +}; + +let expectDayResetNoDamage = (b, a) => { + let ref = [_.cloneDeep(b), _.cloneDeep(a)]; + let before = ref[0]; + let after = ref[1]; + + _.each(after.dailys, (task, i) => { + expect(task.completed).to.be(false); + expect(before.dailys[i].value).to.be(task.value); + expect(before.dailys[i].streak).to.be(task.streak); + expect(task.history).to.have.length(1); + }); + _.each(after.todos, (task, i) => { + expect(task.completed).to.be(false); + expect(before.todos[i].value).to.be.greaterThan(task.value); + }); + expect(after.history.todos).to.have.length(1); + _.each([before, after], (obj) => { + delete obj.stats.buffs; + _.each($w('dailys todos history lastCron'), (path) => { + return delete obj[path]; + }); + }); + delete after._tmp; + expectNoChange(before, after); +}; + +let repeatWithoutLastWeekday = () => { + let repeat = { + su: true, + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + }; + + if (startOfWeek(moment().zone(0)).isoWeekday() === 1) { + repeat.su = false; + } else { + repeat.s = false; + } + return { + repeat, + }; +}; + +describe('User', () => { + it('calculates max MP', () => { + let user = generateUser(); + + expect(user).toHaveMaxMP(30); + user.stats.int = 10; + expect(user).toHaveMaxMP(50); + user.stats.lvl = 5; + expect(user).toHaveMaxMP(54); + user.stats.class = 'wizard'; + user.items.gear.equipped.weapon = 'weapon_wizard_1'; + expect(user).toHaveMaxMP(63); + }); + + it('handles perfect days', () => { + let user = generateUser(); + + user.dailys = []; + _.times(3, () => { + return user.dailys.push(shared.taskDefaults({ + type: 'daily', + startDate: moment().subtract(7, 'days'), + })); + }); + let cron = () => { + user.lastCron = moment().subtract(1, 'days'); + return user.fns.cron(); + }; + + cron(); + expect(user.stats.buffs.str).to.be(0); + expect(user.achievements.perfect).to.not.be.ok(); + user.dailys[0].completed = true; + cron(); + expect(user.stats.buffs.str).to.be(0); + expect(user.achievements.perfect).to.not.be.ok(); + _.each(user.dailys, (d) => { + d.completed = true; + }); + cron(); + expect(user.stats.buffs.str).to.be(1); + expect(user.achievements.perfect).to.be(1); + + let yesterday = moment().subtract(1, 'days'); + + user.dailys[0].repeat[DAY_MAPPING[yesterday.day()]] = false; + _.each(user.dailys.slice(1), (d) => { + d.completed = true; + }); + cron(); + expect(user.stats.buffs.str).to.be(1); + expect(user.achievements.perfect).to.be(2); + }); + + describe('Resting in the Inn', () => { + let user = null; + let cron = null; + + beforeEach(() => { + user = generateUser(); + user.preferences.sleep = true; + cron = () => { + user.lastCron = moment().subtract(1, 'days'); + return user.fns.cron(); + }; + user.dailys = []; + _.times(2, () => { + return user.dailys.push(shared.taskDefaults({ + type: 'daily', + startDate: moment().subtract(7, 'days'), + })); + }); + }); + + it('remains in the inn on cron', () => { + cron(); + expect(user.preferences.sleep).to.be(true); + }); + + it('resets dailies', () => { + user.dailys[0].completed = true; + cron(); + expect(user.dailys[0].completed).to.be(false); + }); + + it('resets checklist on incomplete dailies', () => { + user.dailys[0].checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, { + text: '2', + id: 'checklist-two', + completed: true, + }, { + text: '3', + id: 'checklist-three', + completed: false, + }, + ]; + cron(); + _.each(user.dailys[0].checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + + it('resets checklist on complete dailies', () => { + user.dailys[0].checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, { + text: '2', + id: 'checklist-two', + completed: true, + }, { + text: '3', + id: 'checklist-three', + completed: false, + }, + ]; + user.dailys[0].completed = true; + cron(); + _.each(user.dailys[0].checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + + it('does not reset checklist on grey incomplete dailies', () => { + let yesterday = moment().subtract(1, 'days'); + + user.dailys[0].repeat[DAY_MAPPING[yesterday.day()]] = false; + user.dailys[0].checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, { + text: '2', + id: 'checklist-two', + completed: true, + }, { + text: '3', + id: 'checklist-three', + completed: true, + }, + ]; + cron(); + _.each(user.dailys[0].checklist, (box) => { + expect(box.completed).to.be(true); + }); + }); + + it('resets checklist on complete grey complete dailies', () => { + let yesterday = moment().subtract(1, 'days'); + + user.dailys[0].repeat[DAY_MAPPING[yesterday.day()]] = false; + user.dailys[0].checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, { + text: '2', + id: 'checklist-two', + completed: true, + }, { + text: '3', + id: 'checklist-three', + completed: true, + }, + ]; + user.dailys[0].completed = true; + cron(); + _.each(user.dailys[0].checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + + it('does not damage user for incomplete dailies', () => { + expect(user).toHaveHP(50); + user.dailys[0].completed = true; + user.dailys[1].completed = false; + cron(); + expect(user).toHaveHP(50); + }); + + it('gives credit for complete dailies', () => { + user.dailys[0].completed = true; + expect(user.dailys[0].history).to.be.empty; + cron(); + expect(user.dailys[0].history).to.not.be.empty; + }); + + it('damages user for incomplete dailies after checkout', () => { + expect(user).toHaveHP(50); + user.dailys[0].completed = true; + user.dailys[1].completed = false; + user.preferences.sleep = false; + cron(); + expect(user.stats.hp).to.be.lessThan(50); + }); + }); + + describe('Death', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('revives correctly', () => { + user.stats = { + gp: 10, + exp: 100, + lvl: 2, + hp: 0, + class: 'warrior', + }; + user.items.gear.owned.weapon_warrior_0 = true; + user.ops.revive(); + + expect(user).toHaveGP(0); + expect(user).toHaveExp(0); + expect(user).toHaveLevel(1); + expect(user).toHaveHP(50); + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: false, + eyewear_special_blackTopFrame: true, + eyewear_special_blueTopFrame: true, + eyewear_special_greenTopFrame: true, + eyewear_special_pinkTopFrame: true, + eyewear_special_redTopFrame: true, + eyewear_special_yellowTopFrame: true, + eyewear_special_whiteTopFrame: true, + }); + }); + + it('doesn\'t break unbreakables', () => { + let ce = shared.countExists; + + user.items.gear.owned = { + weapon_warrior_0: true, + shield_warrior_1: true, + shield_rogue_1: true, + head_special_nye: true, + }; + + expect(ce(user.items.gear.owned)).to.be(4); + + user.stats.hp = 0; + user.ops.revive(); + + expect(ce(user.items.gear.owned)).to.be(3); + + user.stats.hp = 0; + user.ops.revive(); + + expect(ce(user.items.gear.owned)).to.be(2); + + user.stats.hp = 0; + user.ops.revive(); + + expect(ce(user.items.gear.owned)).to.be(2); + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: false, + shield_warrior_1: false, + shield_rogue_1: true, + head_special_nye: true, + }); + }); + + it('handles event items', () => { + user.items.gear.owned.head_special_nye = true; + + shared.content.gear.flat.head_special_nye.event.start = '2012-01-01'; + shared.content.gear.flat.head_special_nye.event.end = '2012-02-01'; + expect(shared.content.gear.flat.head_special_nye.canOwn(user)).to.be(true); + delete user.items.gear.owned.head_special_nye; + expect(shared.content.gear.flat.head_special_nye.canOwn(user)).to.be(false); + shared.content.gear.flat.head_special_nye.event.start = moment().subtract(5, 'days'); + shared.content.gear.flat.head_special_nye.event.end = moment().add(5, 'days'); + expect(shared.content.gear.flat.head_special_nye.canOwn(user)).to.be(true); + }); + }); + + describe('Rebirth', () => { + it('removes correct gear', () => { + let user = generateUser(); + + user.stats.lvl = 100; + user.items.gear.owned = { + weapon_warrior_0: true, + weapon_warrior_1: true, + armor_warrior_1: false, + armor_mystery_201402: true, + back_mystery_201402: false, + head_mystery_201402: true, + weapon_armoire_basicCrossbow: true, + }; + user.ops.rebirth(); + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: true, + weapon_warrior_1: false, + armor_warrior_1: false, + armor_mystery_201402: true, + back_mystery_201402: false, + head_mystery_201402: true, + weapon_armoire_basicCrossbow: false, + }); + }); + }); + + describe('store', () => { + it('buys a Quest scroll', () => { + let user = generateUser(); + + user.stats.gp = 205; + user.ops.buyQuest({ + params: { + key: 'dilatoryDistress1', + }, + }); + expect(user.items.quests).to.eql({ + dilatoryDistress1: 1, + }); + expect(user).toHaveGP(5); + }); + + it('does not buy Quests without enough Gold', () => { + let user = generateUser(); + + user.stats.gp = 1; + user.ops.buyQuest({ + params: { + key: 'dilatoryDistress1', + }, + }); + expect(user.items.quests).to.eql({}); + expect(user).toHaveGP(1); + }); + + it('does not buy nonexistent Quests', () => { + let user = generateUser(); + + user.stats.gp = 9999; + user.ops.buyQuest({ + params: { + key: 'snarfblatter', + }, + }); + expect(user.items.quests).to.eql({}); + expect(user).toHaveGP(9999); + }); + + it('does not buy Gem-premium Quests', () => { + let user = generateUser(); + + user.stats.gp = 9999; + user.ops.buyQuest({ + params: { + key: 'kraken', + }, + }); + expect(user.items.quests).to.eql({}); + expect(user).toHaveGP(9999); + }); + }); + + describe('Gem purchases', () => { + it('does not purchase items without enough Gems', () => { + let user = generateUser(); + + user.items.eggs = {}; + user.items.gear.owned = {}; + + user.ops.purchase({ + params: { + type: 'eggs', + key: 'Cactus', + }, + }); + user.ops.purchase({ + params: { + type: 'gear', + key: 'headAccessory_special_foxEars', + }, + }); + user.ops.unlock({ + query: { + path: 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars', + }, + }); + expect(user.items.eggs).to.eql({}); + expect(user.items.gear.owned).to.eql({}); + }); + + it('purchases an egg', () => { + let user = generateUser(); + + user.balance = 1; + user.ops.purchase({ + params: { + type: 'eggs', + key: 'Cactus', + }, + }); + expect(user.items.eggs).to.eql({ + Cactus: 1, + }); + expect(user.balance).to.eql(0.25); + }); + + it('purchases fox ears', () => { + let user = generateUser(); + + user.balance = 1; + user.ops.purchase({ + params: { + type: 'gear', + key: 'headAccessory_special_foxEars', + }, + }); + + expect(user.items.gear.owned.headAccessory_special_foxEars).to.eql(true); + expect(user.balance).to.eql(0.5); + }); + + it('unlocks all the animal ears at once', () => { + let user = generateUser(); + + user.balance = 2; + user.ops.unlock({ + query: { + path: 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars', + }, + }); + + expect(user.items.gear.owned.headAccessory_special_bearEars).to.eql(true); + expect(user.items.gear.owned.headAccessory_special_cactusEars).to.eql(true); + expect(user.items.gear.owned.headAccessory_special_foxEars).to.eql(true); + expect(user.items.gear.owned.headAccessory_special_lionEars).to.eql(true); + expect(user.items.gear.owned.headAccessory_special_pandaEars).to.eql(true); + expect(user.items.gear.owned.headAccessory_special_pigEars).to.eql(true); + expect(user.items.gear.owned.headAccessory_special_tigerEars).to.eql(true); + expect(user.items.gear.owned.headAccessory_special_wolfEars).to.eql(true); + expect(user.balance).to.eql(0.75); + }); + }); + + describe('spells', () => { + _.each(shared.content.spells, (spellClass) => { + _.each(spellClass, (spell) => { + it(`${spell.text} has valid values`, () => { + expect(spell.target).to.match(/^(task|self|party|user)$/); + expect(spell.mana).to.be.an('number'); + if (spell.lvl) { + expect(spell.lvl).to.be.an('number'); + expect(spell.lvl).to.be.above(0); + } + expect(spell.cast).to.be.a('function'); + }); + }); + }); + }); + + describe('drop system', () => { + let user = null; + const MIN_RANGE_FOR_POTION = 0; + const MAX_RANGE_FOR_POTION = 0.3; + const MIN_RANGE_FOR_EGG = 0.4; + const MAX_RANGE_FOR_EGG = 0.6; + const MIN_RANGE_FOR_FOOD = 0.7; + const MAX_RANGE_FOR_FOOD = 1; + + beforeEach(function () { + user = generateUser(); + user.flags.dropsEnabled = true; + this.task_id = shared.uuid(); + return user.ops.addTask({ + body: { + type: 'daily', + id: this.task_id, + }, + }); + }); + + it('drops a hatching potion', function () { + let results = []; + + for (let random = MIN_RANGE_FOR_POTION; random <= MAX_RANGE_FOR_POTION; random += 0.1) { + sinon.stub(user.fns, 'predictableRandom').returns(random); + user.ops.score({ + params: { + id: this.task_id, + direction: 'up', + }, + }); + expect(user.items.eggs).to.be.empty; + expect(user.items.hatchingPotions).to.not.be.empty; + expect(user.items.food).to.be.empty; + results.push(user.fns.predictableRandom.restore()); + } + return results; + }); + + it('drops a pet egg', function () { + let results = []; + + for (let random = MIN_RANGE_FOR_EGG; random <= MAX_RANGE_FOR_EGG; random += 0.1) { + sinon.stub(user.fns, 'predictableRandom').returns(random); + user.ops.score({ + params: { + id: this.task_id, + direction: 'up', + }, + }); + expect(user.items.eggs).to.not.be.empty; + expect(user.items.hatchingPotions).to.be.empty; + expect(user.items.food).to.be.empty; + results.push(user.fns.predictableRandom.restore()); + } + return results; + }); + + it('drops food', function () { + let results = []; + + for (let random = MIN_RANGE_FOR_FOOD; random <= MAX_RANGE_FOR_FOOD; random += 0.1) { + sinon.stub(user.fns, 'predictableRandom').returns(random); + user.ops.score({ + params: { + id: this.task_id, + direction: 'up', + }, + }); + expect(user.items.eggs).to.be.empty; + expect(user.items.hatchingPotions).to.be.empty; + expect(user.items.food).to.not.be.empty; + results.push(user.fns.predictableRandom.restore()); + } + return results; + }); + + it('does not get a drop', function () { + sinon.stub(user.fns, 'predictableRandom').returns(0.5); + user.ops.score({ + params: { + id: this.task_id, + direction: 'up', + }, + }); + expect(user.items.eggs).to.eql({}); + expect(user.items.hatchingPotions).to.eql({}); + expect(user.items.food).to.eql({}); + + user.fns.predictableRandom.restore(); + }); + }); + + describe('Quests', () => { + _.each(shared.content.quests, (quest) => { + it(`${ quest.text() } has valid values`, () => { + expect(quest.notes()).to.be.an('string'); + if (quest.completion) { + expect(quest.completion()).to.be.an('string'); + } + if (quest.previous) { + expect(quest.previous).to.be.an('string'); + } + if (quest.canBuy()) { + expect(quest.value).to.be.greaterThan(0); + } + expect(quest.drop.gp).to.not.be.lessThan(0); + expect(quest.drop.exp).to.not.be.lessThan(0); + expect(quest.category).to.match(/pet|unlockable|gold|world/); + if (quest.drop.items) { + expect(quest.drop.items).to.be.an(Array); + } + if (quest.boss) { + expect(quest.boss.name()).to.be.an('string'); + expect(quest.boss.hp).to.be.greaterThan(0); + expect(quest.boss.str).to.be.greaterThan(0); + } else if (quest.collect) { + _.each(quest.collect, (collect) => { + expect(collect.text()).to.be.an('string'); + expect(collect.count).to.be.greaterThan(0); + }); + } + }); + }); + }); + + describe('Achievements', () => { + _.each(shared.content.classes, (klass) => { + let user = generateUser(); + + user.achievements.ultimateGearSets = {}; + + user.stats.gp = 10000; + _.each(shared.content.gearTypes, (type) => { + _.each([1, 2, 3, 4, 5], (i) => { + return user.ops.buy({ + params: `${type}_${klass}_${i}`, + }); + }); + }); + + it(`does not get ultimateGear ${klass}`, () => { + expect(user.achievements.ultimateGearSets[klass]).to.not.be.ok(); + }); + _.each(shared.content.gearTypes, (type) => { + return user.ops.buy({ + params: `${type}_${klass}_6`, + }); + }); + + xit(`gets ultimateGear ${klass}`, () => { + expect(user.achievements.ultimateGearSets[klass]).to.be.ok(); + }); + }); + + it('does not remove existing Ultimate Gear achievements', () => { + let user = generateUser(); + + user.achievements.ultimateGearSets = { + healer: true, + wizard: true, + rogue: true, + warrior: true, + }; + user.items.gear.owned.shield_warrior_5 = false; + user.items.gear.owned.weapon_rogue_6 = false; + user.ops.buy({ + params: 'shield_warrior_5', + }); + expect(user.achievements.ultimateGearSets).to.eql({ + healer: true, + wizard: true, + rogue: true, + warrior: true, + }); + }); + }); + + describe('unlocking features', () => { + it('unlocks drops at level 3', () => { + let user = generateUser(); + + user.stats.lvl = 3; + user.fns.updateStats(user.stats); + expect(user.flags.dropsEnabled).to.be.ok(); + }); + + it('unlocks Rebirth at level 50', () => { + let user = generateUser(); + + user.stats.lvl = 50; + user.fns.updateStats(user.stats); + expect(user.flags.rebirthEnabled).to.be.ok(); + }); + + describe('level-awarded Quests', () => { + it('gets Attack of the Mundane at level 15', () => { + let user = generateUser(); + + user.stats.lvl = 15; + user.fns.updateStats(user.stats); + expect(user.flags.levelDrops.atom1).to.be.ok(); + expect(user.items.quests.atom1).to.eql(1); + }); + + it('gets Vice at level 30', () => { + let user = generateUser(); + + user.stats.lvl = 30; + user.fns.updateStats(user.stats); + expect(user.flags.levelDrops.vice1).to.be.ok(); + expect(user.items.quests.vice1).to.eql(1); + }); + + it('gets Golden Knight at level 40', () => { + let user = generateUser(); + + user.stats.lvl = 40; + user.fns.updateStats(user.stats); + expect(user.flags.levelDrops.goldenknight1).to.be.ok(); + expect(user.items.quests.goldenknight1).to.eql(1); + }); + + it('gets Moonstone Chain at level 60', () => { + let user = generateUser(); + + user.stats.lvl = 60; + user.fns.updateStats(user.stats); + expect(user.flags.levelDrops.moonstone1).to.be.ok(); + expect(user.items.quests.moonstone1).to.eql(1); + }); + }); + }); +}); + +describe('Simple Scoring', () => { + beforeEach(function () { + let ref = beforeAfter(); + + this.before = ref.before; + this.after = ref.after; + }); + + it('Habits : Up', function () { + this.after.ops.score({ + params: { + id: this.after.habits[0].id, + direction: 'down', + }, + query: { + times: 5, + }, + }); + expectLostPoints(this.before, this.after, 'habit'); + }); + + it('Habits : Down', function () { + this.after.ops.score({ + params: { + id: this.after.habits[0].id, + direction: 'up', + }, + query: { + times: 5, + }, + }); + expectGainedPoints(this.before, this.after, 'habit'); + }); + + it('Dailys : Up', function () { + this.after.ops.score({ + params: { + id: this.after.dailys[0].id, + direction: 'up', + }, + }); + expectGainedPoints(this.before, this.after, 'daily'); + }); + + it('Dailys : Up, Down', function () { + this.after.ops.score({ + params: { + id: this.after.dailys[0].id, + direction: 'up', + }, + }); + this.after.ops.score({ + params: { + id: this.after.dailys[0].id, + direction: 'down', + }, + }); + expectClosePoints(this.before, this.after, 'daily'); + }); + + it('Todos : Up', function () { + this.after.ops.score({ + params: { + id: this.after.todos[0].id, + direction: 'up', + }, + }); + expectGainedPoints(this.before, this.after, 'todo'); + }); + + it('Todos : Up, Down', function () { + this.after.ops.score({ + params: { + id: this.after.todos[0].id, + direction: 'up', + }, + }); + this.after.ops.score({ + params: { + id: this.after.todos[0].id, + direction: 'down', + }, + }); + expectClosePoints(this.before, this.after, 'todo'); + }); +}); + +describe('Cron', () => { + let user; + + beforeEach(() => { + user = generateUser(); + }); + + it('computes shouldCron', () => { + let paths = {}; + + user.fns.cron({ + paths, + }); + expect(user.lastCron).to.not.be.ok; + user.lastCron = Number(moment().subtract(1, 'days')); + paths = {}; + user.fns.cron({ + paths, + }); + expect(user.lastCron).to.be.greaterThan(0); + }); + + it('only dailies & todos are affected', () => { + let ref = beforeAfter({ + daysAgo: 1, + }); + let before = ref.before; + let after = ref.after; + + before.dailys = before.todos = after.dailys = after.todos = []; + after.fns.cron(); + before.stats.mp = after.stats.mp; + expect(after.lastCron).to.not.be(before.lastCron); + delete after.stats.buffs; + delete before.stats.buffs; + expect(before.stats).to.eql(after.stats); + + let beforeTasks = before.habits.concat(before.dailys).concat(before.todos).concat(before.rewards); + let afterTasks = after.habits.concat(after.dailys).concat(after.todos).concat(after.rewards); + + expect(beforeTasks).to.eql(afterTasks); + }); + + describe('preening', () => { + beforeEach(function () { + this.clock = sinon.useFakeTimers(Date.parse('2013-11-20'), 'Date'); + }); + afterEach(function () { + return this.clock.restore(); + }); + + it('should preen user history', function () { + let ref = beforeAfter({ + daysAgo: 1, + }); + let after = ref.after; + + let history = [ + { + date: '09/01/2012', + value: 0, + }, { + date: '10/01/2012', + value: 0, + }, { + date: '11/01/2012', + value: 2, + }, { + date: '12/01/2012', + value: 2, + }, { + date: '01/01/2013', + value: 1, + }, { + date: '01/15/2013', + value: 3, + }, { + date: '02/01/2013', + value: 2, + }, { + date: '02/15/2013', + value: 4, + }, { + date: '03/01/2013', + value: 3, + }, { + date: '03/15/2013', + value: 5, + }, { + date: '04/01/2013', + value: 4, + }, { + date: '04/15/2013', + value: 6, + }, { + date: '05/01/2013', + value: 5, + }, { + date: '05/15/2013', + value: 7, + }, { + date: '06/01/2013', + value: 6, + }, { + date: '06/15/2013', + value: 8, + }, { + date: '07/01/2013', + value: 7, + }, { + date: '07/15/2013', + value: 9, + }, { + date: '08/01/2013', + value: 8, + }, { + date: '08/15/2013', + value: 10, + }, { + date: '09/01/2013', + value: 9, + }, { + date: '09/15/2013', + value: 11, + }, { + date: '010/01/2013', + value: 10, + }, { + date: '010/15/2013', + value: 12, + }, { + date: '011/01/2013', + value: 12, + }, { + date: '011/02/2013', + value: 13, + }, { + date: '011/03/2013', + value: 14, + }, { + date: '011/04/2013', + value: 15, + }, + ]; + + after.history = { + exp: _.cloneDeep(history), + todos: _.cloneDeep(history), + }; + after.habits[0].history = _.cloneDeep(history); + after.fns.cron(); + after.history.exp.pop(); + after.history.todos.pop(); + _.each([after.history.exp, after.history.todos, after.habits[0].history], function (arr) { + expect(_.map(arr, (x) => { + return x.value; + })).to.eql([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); + }); + }); + }); + + describe('Todos', () => { + it('1 day missed', () => { + let ref = beforeAfter({ + daysAgo: 1, + }); + let before = ref.before; + let after = ref.after; + + before.dailys = after.dailys = []; + after.fns.cron(); + expect(after).toHaveHP(50); + expect(after).toHaveExp(0); + expect(after).toHaveGP(0); + expect(before.todos[0].value).to.be(0); + expect(after.todos[0].value).to.be(-1); + expect(after.history.todos).to.have.length(1); + }); + + it('2 days missed', () => { + let ref = beforeAfter({ + daysAgo: 2, + }); + let before = ref.before; + let after = ref.after; + + before.dailys = after.dailys = []; + after.fns.cron(); + expect(before.todos[0].value).to.be(0); + expect(after.todos[0].value).to.be(-1); + }); + }); + + describe('cron day calculations', () => { + let dayStart = 4; + let fstr = 'YYYY-MM-DD HH: mm: ss'; + + it('startOfDay before dayStart', () => { + let start = startOfDay({ + now: moment('2014-10-09 02: 30: 00'), + dayStart, + }); + + expect(start.format(fstr)).to.eql('2014-10-08 04: 00: 00'); + }); + + it('startOfDay after dayStart', () => { + let start = startOfDay({ + now: moment('2014-10-09 05: 30: 00'), + dayStart, + }); + + expect(start.format(fstr)).to.eql('2014-10-09 04: 00: 00'); + }); + + it('daysSince cron before, now after', () => { + let lastCron = moment('2014-10-09 02: 30: 00'); + let days = daysSince(lastCron, { + now: moment('2014-10-09 11: 30: 00'), + dayStart, + }); + + expect(days).to.eql(1); + }); + + it('daysSince cron before, now before', () => { + let lastCron = moment('2014-10-09 02: 30: 00'); + let days = daysSince(lastCron, { + now: moment('2014-10-09 03: 30: 00'), + dayStart, + }); + + expect(days).to.eql(0); + }); + + it('daysSince cron after, now after', () => { + let lastCron = moment('2014-10-09 05: 30: 00'); + let days = daysSince(lastCron, { + now: moment('2014-10-09 06: 30: 00'), + dayStart, + }); + + expect(days).to.eql(0); + }); + + it('daysSince cron after, now tomorrow before', () => { + let lastCron = moment('2014-10-09 12: 30: 00'); + let days = daysSince(lastCron, { + now: moment('2014-10-10 01: 30: 00'), + dayStart, + }); + + expect(days).to.eql(0); + }); + + it('daysSince cron after, now tomorrow after', () => { + let lastCron = moment('2014-10-09 12: 30: 00'); + let days = daysSince(lastCron, { + now: moment('2014-10-10 10: 30: 00'), + dayStart, + }); + + expect(days).to.eql(1); + }); + xit('daysSince, last cron before new dayStart', () => { + let lastCron = moment('2014-10-09 01: 00: 00'); + let days = daysSince(lastCron, { + now: moment('2014-10-09 05: 00: 00'), + dayStart, + }); + + expect(days).to.eql(0); + }); + }); + + describe('dailies', () => { + describe('new day', () => { + /* + This section runs through a 'cron matrix' of all permutations (that I can easily account for). It sets + task due days, user custom day start, timezoneOffset, etc - then runs cron, jumps to tomorrow and runs cron, + and so on - testing each possible outcome along the way + */ + + function runCron (options) { + _.each([480, 240, 0, -120], function (timezoneOffset) { + let now = startOfWeek({ + timezoneOffset, + }).add(options.currentHour || 0, 'hours'); + + let ref = beforeAfter({ + now, + timezoneOffset, + daysAgo: 1, + cronAfterStart: options.cronAfterStart || true, + dayStart: options.dayStart || 0, + limitOne: 'daily', + }); + + let before = ref.before; + let after = ref.after; + + if (options.repeat) { + before.dailys[0].repeat = after.dailys[0].repeat = options.repeat; + } + before.dailys[0].streak = after.dailys[0].streak = 10; + if (options.checked) { + before.dailys[0].completed = after.dailys[0].completed = true; + } + before.dailys[0].startDate = after.dailys[0].startDate = moment().subtract(30, 'days'); + if (options.shouldDo) { + expect(shared.shouldDo(now.toDate(), after.dailys[0], { + timezoneOffset, + dayStart: options.dayStart, + now, + })).to.be.ok(); + } + after.fns.cron({ + now, + }); + before.stats.mp = after.stats.mp; + + if (options.expect === 'losePoints') { + expectLostPoints(before, after, 'daily'); + } else if (options.expect === 'noChange') { + expectNoChange(before, after); + } else if (options.expect === 'noDamage') { + expectDayResetNoDamage(before, after); + } + + return { + before, + after, + }; + }); + } + + let cronMatrix = { + steps: { + 'due yesterday': { + defaults: { + daysAgo: 1, + cronAfterStart: true, + limitOne: 'daily', + }, + steps: { + '(simple)': { + expect: 'losePoints', + }, + 'due today': { + defaults: { + repeat: { + su: true, + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + }, + }, + steps: { + 'pre-dayStart': { + defaults: { + currentHour: 3, + dayStart: 4, + shouldDo: true, + }, + steps: { + checked: { + checked: true, + expect: 'noChange', + }, + 'un-checked': { + checked: false, + expect: 'noChange', + }, + }, + }, + 'post-dayStart': { + defaults: { + currentHour: 5, + dayStart: 4, + shouldDo: true, + }, + steps: { + checked: { + checked: true, + expect: 'noDamage', + }, + unchecked: { + checked: false, + expect: 'losePoints', + }, + }, + }, + }, + }, + 'NOT due today': { + defaults: { + repeat: { + su: true, + m: false, + t: true, + w: true, + th: true, + f: true, + s: true, + }, + }, + steps: { + 'pre-dayStart': { + defaults: { + currentHour: 3, + dayStart: 4, + shouldDo: true, + }, + steps: { + checked: { + checked: true, + expect: 'noChange', + }, + 'un-checked': { + checked: false, + expect: 'noChange', + }, + }, + }, + 'post-dayStart': { + defaults: { + currentHour: 5, + dayStart: 4, + shouldDo: false, + }, + steps: { + checked: { + checked: true, + expect: 'noDamage', + }, + unchecked: { + checked: false, + expect: 'losePoints', + }, + }, + }, + }, + }, + }, + }, + 'not due yesterday': { + defaults: repeatWithoutLastWeekday(), + steps: { + '(simple)': { + expect: 'noDamage', + }, + 'post-dayStart': { + currentHour: 5, + dayStart: 4, + expect: 'noDamage', + }, + 'pre-dayStart': { + currentHour: 3, + dayStart: 4, + expect: 'noChange', + }, + }, + }, + }, + }; + + let recurseCronMatrix = (obj, options = {}) => { + if (obj.steps) { + _.each(obj.steps, (step, text) => { + let o = _.cloneDeep(options); + + if (!o.text) { + o.text = ''; + } + o.text += `${text}`; + return recurseCronMatrix(step, _.defaults(o, obj.defaults)); + }); + } else { + it(`${options.text}`, () => { + return runCron(_.defaults(obj, options)); + }); + } + }; + + return recurseCronMatrix(cronMatrix); + }); + }); +}); + +describe('Helper', () => { + it('calculates gold coins', () => { + expect(shared.gold(10)).to.eql(10); + expect(shared.gold(1.957)).to.eql(1); + expect(shared.gold()).to.eql(0); + }); + + it('calculates silver coins', () => { + expect(shared.silver(10)).to.eql(0); + expect(shared.silver(1.957)).to.eql(95); + expect(shared.silver(0.01)).to.eql('01'); + expect(shared.silver()).to.eql('00'); + }); + + it('calculates experience to next level', () => { + expect(shared.tnl(1)).to.eql(150); + expect(shared.tnl(2)).to.eql(160); + expect(shared.tnl(10)).to.eql(260); + expect(shared.tnl(99)).to.eql(3580); + }); + + it('calculates the start of the day', () => { + let fstr = 'YYYY-MM-DD HH: mm: ss'; + let today = '2013-01-01 00: 00: 00'; + let zone = moment(today).zone(); + + expect(startOfDay({ + now: new Date(2013, 0, 1, 0), + }, { + timezoneOffset: zone, + }).format(fstr)).to.eql(today); + expect(startOfDay({ + now: new Date(2013, 0, 1, 5), + }, { + timezoneOffset: zone, + }).format(fstr)).to.eql(today); + expect(startOfDay({ + now: new Date(2013, 0, 1, 23, 59, 59), + timezoneOffset: zone, + }).format(fstr)).to.eql(today); + }); +}); diff --git a/test/common/dailies.js b/test/common/dailies.js new file mode 100644 index 0000000000..d3aa84cae8 --- /dev/null +++ b/test/common/dailies.js @@ -0,0 +1,499 @@ +/* eslint-disable camelcase */ +import { + startOfWeek, +} from '../../common/script/cron'; + +let expect = require('expect.js'); // eslint-disable-line no-shadow +let moment = require('moment'); +let shared = require('../../common/script/index.js'); + +shared.i18n.translations = require('../../website/src/libs/i18n.js').translations; + +let repeatWithoutLastWeekday = () => { // eslint-disable-line no-unused-vars + let repeat = { + su: true, + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + }; + + if (startOfWeek(moment().zone(0)).isoWeekday() === 1) { + repeat.su = false; + } else { + repeat.s = false; + } + return { + repeat, + }; +}; + + +/* Helper Functions */ + +import { + generateUser, +} from '../helpers/common.helper'; + +let cron = (usr, missedDays = 1) => { + usr.lastCron = moment().subtract(missedDays, 'days'); + usr.fns.cron(); +}; + +describe('daily/weekly that repeats everyday (default)', () => { + let user = null; + let daily = null; + let weekly = null; + + describe('when startDate is in the future', () => { + beforeEach(() => { + user = generateUser(); + user.dailys = [ + shared.taskDefaults({ + type: 'daily', + startDate: moment().add(7, 'days'), + frequency: 'daily', + }), shared.taskDefaults({ + type: 'daily', + startDate: moment().add(7, 'days'), + frequency: 'weekly', + repeat: { + su: true, + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + }, + }), + ]; + daily = user.dailys[0]; + weekly = user.dailys[1]; + }); + + it('does not damage user for not completing it', () => { + cron(user); + expect(user.stats.hp).to.be(50); + }); + + it('does not change value on cron if daily is incomplete', () => { + cron(user); + expect(daily.value).to.be(0); + expect(weekly.value).to.be(0); + }); + + it('does not reset checklists if daily is not marked as complete', () => { + let checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, { + text: '2', + id: 'checklist-two', + completed: true, + }, { + text: '3', + id: 'checklist-three', + completed: false, + }, + ]; + + daily.checklist = checklist; + weekly.checklist = checklist; + cron(user); + expect(daily.checklist[0].completed).to.be(true); + expect(daily.checklist[1].completed).to.be(true); + expect(daily.checklist[2].completed).to.be(false); + expect(weekly.checklist[0].completed).to.be(true); + expect(weekly.checklist[1].completed).to.be(true); + expect(weekly.checklist[2].completed).to.be(false); + }); + + it('resets checklists if daily is marked as complete', () => { + let checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, { + text: '2', + id: 'checklist-two', + completed: true, + }, { + text: '3', + id: 'checklist-three', + completed: false, + }, + ]; + + daily.checklist = checklist; + weekly.checklist = checklist; + daily.completed = true; + weekly.completed = true; + cron(user); + _.each(daily.checklist, (box) => { + expect(box.completed).to.be(false); + }); + _.each(weekly.checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + + it('is due on startDate', () => { + let daily_due_today = shared.shouldDo(moment(), daily); + let daily_due_on_start_date = shared.shouldDo(moment().add(7, 'days'), daily); + + expect(daily_due_today).to.be(false); + expect(daily_due_on_start_date).to.be(true); + + let weekly_due_today = shared.shouldDo(moment(), weekly); + let weekly_due_on_start_date = shared.shouldDo(moment().add(7, 'days'), weekly); + + expect(weekly_due_today).to.be(false); + expect(weekly_due_on_start_date).to.be(true); + }); + }); + + describe('when startDate is in the past', () => { + beforeEach(() => { + user = generateUser(); + user.dailys = [ + shared.taskDefaults({ + type: 'daily', + startDate: moment().subtract(7, 'days'), + frequency: 'daily', + }), shared.taskDefaults({ + type: 'daily', + startDate: moment().subtract(7, 'days'), + frequency: 'weekly', + }), + ]; + daily = user.dailys[0]; + weekly = user.dailys[1]; + }); + + it('does damage user for not completing it', () => { + cron(user); + expect(user.stats.hp).to.be.lessThan(50); + }); + + it('decreases value on cron if daily is incomplete', () => { + cron(user, 1); + expect(daily.value).to.be(-1); + expect(weekly.value).to.be(-1); + }); + + it('decreases value on cron once only if daily is incomplete and multiple days are missed', () => { + cron(user, 7); + expect(daily.value).to.be(-1); + expect(weekly.value).to.be(-1); + }); + + it('resets checklists if daily is not marked as complete', () => { + let checklist; + + checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, { + text: '2', + id: 'checklist-two', + completed: true, + }, { + text: '3', + id: 'checklist-three', + completed: false, + }, + ]; + daily.checklist = checklist; + weekly.checklist = checklist; + cron(user); + _.each(daily.checklist, (box) => { + expect(box.completed).to.be(false); + }); + _.each(weekly.checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + + it('resets checklists if daily is marked as complete', () => { + let checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, { + text: '2', + id: 'checklist-two', + completed: true, + }, { + text: '3', + id: 'checklist-three', + completed: false, + }, + ]; + + daily.checklist = checklist; + daily.completed = true; + weekly.checklist = checklist; + weekly.completed = true; + cron(user); + _.each(daily.checklist, (box) => { + expect(box.completed).to.be(false); + }); + _.each(weekly.checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + }); + + describe('when startDate is today', () => { + beforeEach(() => { + user = generateUser(); + user.dailys = [ + shared.taskDefaults({ + type: 'daily', + startDate: moment().subtract(1, 'days'), + frequency: 'daily', + }), shared.taskDefaults({ + type: 'daily', + startDate: moment().subtract(1, 'days'), + frequency: 'weekly', + }), + ]; + daily = user.dailys[0]; + weekly = user.dailys[1]; + }); + + it('does damage user for not completing it', () => { + cron(user); + expect(user.stats.hp).to.be.lessThan(50); + }); + + it('decreases value on cron if daily is incomplete', () => { + cron(user); + expect(daily.value).to.be.lessThan(0); + expect(weekly.value).to.be.lessThan(0); + }); + + it('resets checklists if daily is not marked as complete', () => { + let checklist; + + checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, { + text: '2', + id: 'checklist-two', + completed: true, + }, { + text: '3', + id: 'checklist-three', + completed: false, + }, + ]; + daily.checklist = checklist; + weekly.checklist = checklist; + cron(user); + _.each(daily.checklist, (box) => { + expect(box.completed).to.be(false); + }); + _.each(weekly.checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + + it('resets checklists if daily is marked as complete', () => { + let checklist; + + checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, { + text: '2', + id: 'checklist-two', + completed: true, + }, { + text: '3', + id: 'checklist-three', + completed: false, + }, + ]; + daily.checklist = checklist; + daily.completed = true; + weekly.checklist = checklist; + weekly.completed = true; + cron(user); + _.each(daily.checklist, (box) => { + expect(box.completed).to.be(false); + }); + _.each(weekly.checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + }); +}); + +describe('daily that repeats every x days', () => { + let user = null; + let daily = null; + + beforeEach(() => { + user = generateUser(); + user.dailys = [ + shared.taskDefaults({ + type: 'daily', + startDate: moment(), + frequency: 'daily', + }), + ]; + daily = user.dailys[0]; + }); + _.times(11, (due) => { + it(`where x equals ${due}`, () => { + daily.everyX = due; + _.times(30, (day) => { + let isDue; + + isDue = shared.shouldDo(moment().add(day, 'days'), daily); + if (day % due === 0) { + expect(isDue).to.be(true); + } + if (day % due !== 0) { + expect(isDue).to.be(false); + } + }); + }); + }); +}); + +describe('daily that repeats every X days when multiple days are missed', () => { + let everyX = 3; + let startDateDaysAgo = everyX * 3; + let user = null; + let daily = null; + + describe('including missing a due date', () => { + let missedDays = everyX * 2 + 1; + + beforeEach(() => { + user = generateUser(); + user.dailys = [ + shared.taskDefaults({ + type: 'daily', + startDate: moment().subtract(startDateDaysAgo, 'days'), + frequency: 'daily', + everyX, + }), + ]; + daily = user.dailys[0]; + }); + + it('decreases value on cron once only if daily is incomplete', () => { + cron(user, missedDays); + expect(daily.value).to.be(-1); + }); + + it('resets checklists if daily is incomplete', () => { + let checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, + ]; + + daily.checklist = checklist; + cron(user, missedDays); + _.each(daily.checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + + it('resets checklists if daily is marked as complete', () => { + let checklist; + + checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, + ]; + daily.checklist = checklist; + daily.completed = true; + cron(user, missedDays); + _.each(daily.checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + }); + + describe('but not missing a due date', () => { + let missedDays; + + missedDays = everyX - 1; + beforeEach(() => { + user = generateUser(); + user.dailys = [ + shared.taskDefaults({ + type: 'daily', + startDate: moment().subtract(startDateDaysAgo, 'days'), + frequency: 'daily', + everyX, + }), + ]; + daily = user.dailys[0]; + }); + + it('does not decrease value on cron', () => { + cron(user, missedDays); + expect(daily.value).to.be(0); + }); + + it('does not reset checklists if daily is incomplete', () => { + let checklist; + + checklist = [ + { + text: '1', + id: 'checklist-one', + completed: true, + }, + ]; + daily.checklist = checklist; + cron(user, missedDays); + _.each(daily.checklist, (box) => { + expect(box.completed).to.be(true); + }); + }); + + it('resets checklists if daily is marked as complete', () => { + let checklist; + + checklist = [ + { + text: 1, + id: 'checklist-one', + completed: true, + }, + ]; + daily.checklist = checklist; + daily.completed = true; + cron(user, missedDays); + _.each(daily.checklist, (box) => { + expect(box.completed).to.be(false); + }); + }); + }); +}); diff --git a/test/common/fns/autoAllocate.test.js b/test/common/fns/autoAllocate.test.js deleted file mode 100644 index e00f8dd17d..0000000000 --- a/test/common/fns/autoAllocate.test.js +++ /dev/null @@ -1,86 +0,0 @@ -import autoAllocate from '../../../common/script/fns/autoAllocate'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.fns.autoAllocate', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('user.preferences.allocationMode === flat', () => { - user.stats.con = 5; - user.stats.int = 5; - user.stats.per = 3; - user.stats.str = 8; - - user.preferences.allocationMode = 'flat'; - - autoAllocate(user); - - expect(user.stats.con).to.equal(5); - expect(user.stats.int).to.equal(5); - expect(user.stats.per).to.equal(4); - expect(user.stats.str).to.equal(8); - }); - - it('user.preferences.allocationMode === taskbased', () => { - user.stats.con = 5; - user.stats.int = 5; - user.stats.per = 3; - user.stats.str = 8; - user.stats.training.con = 2; - user.stats.training.int = 5; - user.stats.training.per = 7; - user.stats.training.str = 4; - - user.preferences.allocationMode = 'taskbased'; - - autoAllocate(user); - - expect(user.stats.con).to.equal(5); - expect(user.stats.int).to.equal(5); - expect(user.stats.per).to.equal(4); - expect(user.stats.str).to.equal(8); - - expect(user.stats.training.con).to.equal(0); - expect(user.stats.training.int).to.equal(0); - expect(user.stats.training.per).to.equal(0); - expect(user.stats.training.str).to.equal(0); - }); - - it('user.preferences.allocationMode === classbased', () => { - user.stats.lvl = 35; - user.stats.class = 'healer'; - user.stats.con = 5; - user.stats.int = 5; - user.stats.per = 3; - user.stats.str = 8; - - user.preferences.allocationMode = 'classbased'; - - autoAllocate(user); - - expect(user.stats.con).to.equal(6); - expect(user.stats.int).to.equal(5); - expect(user.stats.per).to.equal(3); - expect(user.stats.str).to.equal(8); - }); - - it('user.preferences.allocationMode === anything', () => { - user.stats.con = 5; - user.stats.int = 5; - user.stats.per = 3; - user.stats.str = 8; - user.preferences.allocationMode = 'wrong'; - - autoAllocate(user); - - expect(user.stats.con).to.equal(5); - expect(user.stats.int).to.equal(5); - expect(user.stats.per).to.equal(3); - expect(user.stats.str).to.equal(9); - }); -}); diff --git a/test/common/fns/crit.test.js b/test/common/fns/crit.test.js deleted file mode 100644 index 4f43c55fa1..0000000000 --- a/test/common/fns/crit.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import crit from '../../../common/script/fns/crit'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('crit', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('computes', () => { - let result = crit(user); - expect(result).to.eql(1); - }); -}); diff --git a/test/common/fns/handleTwoHanded.js b/test/common/fns/handleTwoHanded.js deleted file mode 100644 index 8d191ff114..0000000000 --- a/test/common/fns/handleTwoHanded.js +++ /dev/null @@ -1,38 +0,0 @@ -import handleTwoHanded from '../../../common/script/fns/handleTwoHanded'; -import content from '../../../common/script/content/index'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.fns.handleTwoHanded', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('uses "messageTwoHandedUnequip" message if item is a shield and current weapon is two handed (and sets the user\'s weapon to the base one)', () => { - let item = content.gear.tree.shield.warrior['2']; - let currentWeapon = content.gear.tree.weapon.armoire.rancherLasso; - user.items.gear.equipped.weapon = 'weapon_armoire_rancherLasso'; - - let message = handleTwoHanded(user, item); - expect(message).to.equal(i18n.t('messageTwoHandedUnequip', { - twoHandedText: currentWeapon.text(), offHandedText: item.text(), - })); - expect(user.items.gear.equipped.weapon).to.equal('weapon_base_0'); - }); - - it('uses "messageTwoHandedEquip" message if item is two handed and currentShield exists but is not "shield_base_0" (and sets the user\'s shield to the base one)', () => { - let item = content.gear.tree.weapon.armoire.rancherLasso; - let currentShield = content.gear.tree.shield.armoire.gladiatorShield; - user.items.gear.equipped.shield = 'shield_armoire_gladiatorShield'; - - let message = handleTwoHanded(user, item); - expect(message).to.equal(i18n.t('messageTwoHandedEquip', { - twoHandedText: item.text(), offHandedText: currentShield.text(), - })); - expect(user.items.gear.equipped.shield).to.equal('shield_base_0'); - }); -}); diff --git a/test/common/fns/predictableRandom.test.js b/test/common/fns/predictableRandom.test.js deleted file mode 100644 index 1cd47fc426..0000000000 --- a/test/common/fns/predictableRandom.test.js +++ /dev/null @@ -1,51 +0,0 @@ -import predictableRandom from '../../../common/script/fns/predictableRandom'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.fns.predictableRandom', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('returns a number', () => { - expect(predictableRandom(user)).to.be.a('number'); - }); - - it('returns the same value when user.stats is the same and no seed is passed', () => { - user.stats.hp = 43; - user.stats.gp = 34; - - let val1 = predictableRandom(user); - let val2 = predictableRandom(user); - - expect(val2).to.equal(val1); - }); - - it('returns a different value when user.stats is not the same and no seed is passed', () => { - user.stats.hp = 43; - user.stats.gp = 34; - let val1 = predictableRandom(user); - - user.stats.gp = 35; - let val2 = predictableRandom(user); - - expect(val2).to.not.equal(val1); - }); - - it('returns the same value when the same seed is passed', () => { - let val1 = predictableRandom(user, 4452673762); - let val2 = predictableRandom(user, 4452673762); - - expect(val2).to.equal(val1); - }); - - it('returns a different value when a different seed is passed', () => { - let val1 = predictableRandom(user, 4452673761); - let val2 = predictableRandom(user, 4452673762); - - expect(val2).to.not.equal(val1); - }); -}); diff --git a/test/common/fns/randomDrop.test.js b/test/common/fns/randomDrop.test.js deleted file mode 100644 index 9569067ffc..0000000000 --- a/test/common/fns/randomDrop.test.js +++ /dev/null @@ -1,165 +0,0 @@ -// TODO disable until we can find a way to stub predictableRandom - -/* eslint-disable */ - -import randomDrop from '../../../common/script/fns/randomDrop'; -import { - generateUser, - generateTodo, - generateHabit, - generateDaily, - generateReward, -} from '../../helpers/common.helper'; -// import predictableRandom from '../../../common/script/fns/predictableRandom'; // eslint-disable -import content from '../../../common/script/content/index'; - -xdescribe('common.fns.randomDrop', () => { - let user; - let task; - let predictableRandom; - - beforeEach(() => { - user = generateUser(); - user._tmp = user._tmp ? user._tmp : {}; - task = generateTodo({ userId: user._id }); - predictableRandom = () => { - return 0.5; - }; - }); - - /** - * function signature as follows: - * randomDrop(user, modifiers) {} - * modifiers = { task, delta = null } - **/ - - it('drops an item for the user.party.quest.progress', () => { - expect(user.party.quest.progress.collect).to.eql({}); - user.party.quest.key = 'vice2'; - let collectWhat = Object.keys(content.quests[user.party.quest.key].collect)[0]; // lightCrystal - predictableRandom = () => { - return 0.0001; - }; - randomDrop(user, { task }); - expect(user.party.quest.progress.collect[collectWhat]).to.eql(1); - randomDrop(user, { task }); - expect(user.party.quest.progress.collect[collectWhat]).to.eql(2); - }); - - context('drops enabled', () => { - beforeEach(() => { - user.flags.dropsEnabled = true; - task.priority = 100000; - }); - - it('does nothing if user.items.lastDrop.count is exceeded', () => { - user.items.lastDrop.count = 100; - randomDrop(user, { task }); - expect(user._tmp).to.eql({}); - }); - - it('drops something when the task is a todo', () => { - expect(user._tmp).to.eql({}); - user.flags.dropsEnabled = true; - predictableRandom = () => { - return 0.1; - }; - randomDrop(user, { task }); - expect(user._tmp).to.not.eql({}); - }); - - it('drops something when the task is a habit', () => { - task = generateHabit({ userId: user._id }); - expect(user._tmp).to.eql({}); - user.flags.dropsEnabled = true; - predictableRandom = () => { - return 0.1; - }; - randomDrop(user, { task }); - expect(user._tmp).to.not.eql({}); - }); - - it('drops something when the task is a daily', () => { - task = generateDaily({ userId: user._id }); - expect(user._tmp).to.eql({}); - user.flags.dropsEnabled = true; - predictableRandom = () => { - return 0.1; - }; - randomDrop(user, { task }); - expect(user._tmp).to.not.eql({}); - }); - - it('drops something when the task is a reward', () => { - task = generateReward({ userId: user._id }); - expect(user._tmp).to.eql({}); - user.flags.dropsEnabled = true; - predictableRandom = () => { - return 0.1; - }; - randomDrop(user, { task }); - expect(user._tmp).to.not.eql({}); - }); - - it('drops food', () => { - predictableRandom = () => { - return 0.65; - }; - randomDrop(user, { task }); - expect(user._tmp.drop.type).to.eql('Food'); - }); - - it('drops eggs', () => { - predictableRandom = () => { - return 0.35; - }; - randomDrop(user, { task }); - expect(user._tmp.drop.type).to.eql('Egg'); - }); - - context('drops hatching potion', () => { - it('drops a very rare potion', () => { - predictableRandom = () => { - return 0.01; - }; - randomDrop(user, { task }); - expect(user._tmp.drop.type).to.eql('HatchingPotion'); - expect(user._tmp.drop.value).to.eql(5); - expect(user._tmp.drop.key).to.eql('Golden'); - }); - - it('drops a rare potion', () => { - predictableRandom = () => { - return 0.08; - }; - randomDrop(user, { task }); - expect(user._tmp.drop.type).to.eql('HatchingPotion'); - expect(user._tmp.drop.value).to.eql(4); - let acceptableDrops = ['Zombie', 'CottonCandyPink', 'CottonCandyBlue']; - expect(acceptableDrops).to.contain(user._tmp.drop.key); // deterministically 'CottonCandyBlue' - }); - - it('drops an uncommon potion', () => { - predictableRandom = () => { - return 0.17; - }; - randomDrop(user, { task }); - expect(user._tmp.drop.type).to.eql('HatchingPotion'); - expect(user._tmp.drop.value).to.eql(3); - let acceptableDrops = ['Red', 'Shade', 'Skeleton']; - expect(acceptableDrops).to.contain(user._tmp.drop.key); // always skeleton - }); - - it('drops a common potion', () => { - predictableRandom = () => { - return 0.20; - }; - randomDrop(user, { task }); - expect(user._tmp.drop.type).to.eql('HatchingPotion'); - expect(user._tmp.drop.value).to.eql(2); - let acceptableDrops = ['Base', 'White', 'Desert']; - expect(acceptableDrops).to.contain(user._tmp.drop.key); // always Desert - }); - }); - }); -}); diff --git a/test/common/fns/randomVal.js b/test/common/fns/randomVal.js deleted file mode 100644 index b4b8e377d3..0000000000 --- a/test/common/fns/randomVal.js +++ /dev/null @@ -1,119 +0,0 @@ -import randomVal from '../../../common/script/fns/randomVal'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.fns.randomVal', () => { - let user; - let obj = { - a: 1, - b: 2, - c: 3, - d: 4, - }; - - beforeEach(() => { - user = generateUser(); - }); - - describe('returns a random property value from an object', () => { - it('returns the same value when the seed is the same', () => { - let val1 = randomVal(user, obj, { - seed: 222, - }); - - let val2 = randomVal(user, obj, { - seed: 222, - }); - - expect(val2).to.equal(val1); - }); - - it('returns the same value when user.stats is the same', () => { - user.stats.gp = 34; - let val1 = randomVal(user, obj); - let val2 = randomVal(user, obj); - - expect(val2).to.equal(val1); - }); - - it('returns a different value when the seed is different', () => { - let val1 = randomVal(user, obj, { - seed: 222, - }); - - let val2 = randomVal(user, obj, { - seed: 333, - }); - - expect(val2).to.not.equal(val1); - }); - - it('returns a different value when user.stats is different', () => { - user.stats.gp = 34; - let val1 = randomVal(user, obj); - user.stats.gp = 343; - let val2 = randomVal(user, obj); - - expect(val2).to.not.equal(val1); - }); - }); - - describe('returns a random key from an object', () => { - it('returns the same key when the seed is the same', () => { - let key1 = randomVal(user, obj, { - key: true, - seed: 222, - }); - - let key2 = randomVal(user, obj, { - key: true, - seed: 222, - }); - - expect(key2).to.equal(key1); - }); - - it('returns the same key when user.stats is the same', () => { - user.stats.gp = 45; - let key1 = randomVal(user, obj, { - key: true, - }); - - let key2 = randomVal(user, obj, { - key: true, - }); - - expect(key2).to.equal(key1); - }); - - it('returns a different key when the seed is different', () => { - let key1 = randomVal(user, obj, { - key: true, - seed: 222, - }); - - let key2 = randomVal(user, obj, { - key: true, - seed: 333, - }); - - expect(key2).to.not.equal(key1); - }); - - it('returns a different key when user.stats is different', () => { - user.stats.gp = 45; - let key1 = randomVal(user, obj, { - key: true, - }); - - user.stats.gp = 43; - - let key2 = randomVal(user, obj, { - key: true, - }); - - expect(key2).to.not.equal(key1); - }); - }); -}); diff --git a/test/common/fns/statsComputed.test.js b/test/common/fns/statsComputed.test.js deleted file mode 100644 index 07b009368d..0000000000 --- a/test/common/fns/statsComputed.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import statsComputed from '../../../common/script/libs/statsComputed'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('common.fns.statsComputed', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('returns the same result if called directly, through user.fns.statsComputed, or user._statsComputed', () => { - let result = statsComputed(user); - let result2 = user._statsComputed; - let result3 = user.fns.statsComputed(); - expect(result).to.eql(result2); - expect(result).to.eql(result3); - }); - - it('returns default values', () => { - let result = statsComputed(user); - expect(result.per).to.eql(0); - expect(result.con).to.eql(0); - expect(result.str).to.eql(0); - expect(result.maxMP).to.eql(30); - }); -}); diff --git a/test/common/fns/ultimateGear.js b/test/common/fns/ultimateGear.js deleted file mode 100644 index 8da991b565..0000000000 --- a/test/common/fns/ultimateGear.js +++ /dev/null @@ -1,33 +0,0 @@ -import ultimateGear from '../../../common/script/fns/ultimateGear'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.fns.ultimateGear', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('sets armoirEnabled when partial achievement already achieved', () => { - let items = { - gear: { - owned: { - toObject: () => { - return { - armor_warrior_5: true, // eslint-disable-line camelcase - shield_warrior_5: true, // eslint-disable-line camelcase - head_warrior_5: true, // eslint-disable-line camelcase - weapon_warrior_6: true, // eslint-disable-line camelcase - }; - }, - }, - }, - }; - - user.items = items; - ultimateGear(user); - expect(user.flags.armoireEnabled).to.equal(true); - }); -}); diff --git a/test/common/fns/updateStats.test.js b/test/common/fns/updateStats.test.js deleted file mode 100644 index cea5f6e6ca..0000000000 --- a/test/common/fns/updateStats.test.js +++ /dev/null @@ -1,170 +0,0 @@ -import updateStats from '../../../common/script/fns/updateStats'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('common.fns.updateStats', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - context('No Hp', () => { - it('updates user\s hp', () => { - let stats = { hp: 0 }; - expect(user.stats.hp).to.not.eql(0); - updateStats(user, stats); - expect(user.stats.hp).to.eql(0); - updateStats(user, { hp: 2 }); - expect(user.stats.hp).to.eql(2); - }); - - it('does not lower hp below 0', () => { - let stats = { - hp: -5, - }; - updateStats(user, stats); - expect(user.stats.hp).to.eql(0); - }); - }); - - context('Stat Allocation', () => { - it('adds only attribute points up to user\'s level', () => { - let stats = { - exp: 261, - }; - expect(user.stats.points).to.eql(0); - - user.stats.lvl = 10; - - updateStats(user, stats); - - expect(user.stats.points).to.eql(11); - }); - - it('adds an attibute point when user\'s stat points are less than max level', () => { - let stats = { - exp: 3581, - }; - - user.stats.lvl = 99; - user.stats.str = 25; - user.stats.int = 25; - user.stats.con = 25; - user.stats.per = 24; - - updateStats(user, stats); - - expect(user.stats.points).to.eql(1); - }); - - it('does not add an attibute point when user\'s stat points are equal to max level', () => { - let stats = { - exp: 3581, - }; - - user.stats.lvl = 99; - user.stats.str = 25; - user.stats.int = 25; - user.stats.con = 25; - user.stats.per = 25; - - updateStats(user, stats); - - expect(user.stats.points).to.eql(0); - }); - - it('does not add an attibute point when user\'s stat points + unallocated points are equal to max level', () => { - let stats = { - exp: 3581, - }; - - user.stats.lvl = 99; - user.stats.str = 25; - user.stats.int = 25; - user.stats.con = 25; - user.stats.per = 15; - user.stats.points = 10; - - updateStats(user, stats); - - expect(user.stats.points).to.eql(10); - }); - - it('only awards stat points up to level 100 if user is missing unallocated stat points and is over level 100', () => { - let stats = { - exp: 5581, - }; - - user.stats.lvl = 104; - user.stats.str = 25; - user.stats.int = 25; - user.stats.con = 25; - user.stats.per = 15; - user.stats.points = 0; - - updateStats(user, stats); - - expect(user.stats.points).to.eql(10); - }); - - context('assigns flags.levelDrops', () => { - it('for atom1', () => { - user.stats.lvl = 16; - user.flags.levelDrops.atom1 = false; - expect(user.items.quests.atom1).to.eql(undefined); - updateStats(user, { atom1: true }); - expect(user.items.quests.atom1).to.eql(1); - expect(user.flags.levelDrops.atom1).to.eql(true); - updateStats(user, { atom1: true }); - expect(user.items.quests.atom1).to.eql(1); // no change - }); - it('for vice1', () => { - user.stats.lvl = 31; - user.flags.levelDrops.vice1 = false; - expect(user.items.quests.vice1).to.eql(undefined); - updateStats(user, { vice1: true }); - expect(user.items.quests.vice1).to.eql(1); - expect(user.flags.levelDrops.vice1).to.eql(true); - updateStats(user, { vice1: true }); - expect(user.items.quests.vice1).to.eql(1); - }); - it('moonstone', () => { - user.stats.lvl = 60; - user.flags.levelDrops.moonstone1 = false; - expect(user.items.quests.moonstone1).to.eql(undefined); - updateStats(user, { moonstone1: true }); - expect(user.flags.levelDrops.moonstone1).to.eql(true); - expect(user.items.quests.moonstone1).to.eql(1); - updateStats(user, { moonstone1: true }); - expect(user.items.quests.moonstone1).to.eql(1); - }); - it('for goldenknight1', () => { - user.stats.lvl = 40; - user.flags.levelDrops.goldenknight1 = false; - expect(user.items.quests.goldenknight1).to.eql(undefined); - updateStats(user, { goldenknight1: true }); - expect(user.items.quests.goldenknight1).to.eql(1); - expect(user.flags.levelDrops.goldenknight1).to.eql(true); - updateStats(user, { goldenknight1: true }); - expect(user.items.quests.goldenknight1).to.eql(1); - }); - }); - - // @TODO: Set up sinon sandbox - xit('auto allocates stats if automaticAllocation is turned on', () => { - sandbox.stub(user.fns, 'autoAllocate'); - - let stats = { - exp: 261, - }; - - user.stats.lvl = 10; - - user.fns.updateStats(stats); - - expect(user.fns.autoAllocate).to.be.calledOnce; - }); - }); -}); diff --git a/test/common/libs/appliedTags.test.js b/test/common/libs/appliedTags.test.js deleted file mode 100644 index 66f3de4758..0000000000 --- a/test/common/libs/appliedTags.test.js +++ /dev/null @@ -1,10 +0,0 @@ -import appliedTags from '../../../common/script/libs/appliedTags'; - -describe('appliedTags', () => { - it('returns the tasks', () => { - let userTags = [{ id: 'tag1', name: 'tag 1' }, { id: 'tag2', name: 'tag 2' }, { id: 'tag3', name: 'tag 3' }]; - let taskTags = ['tag2', 'tag3']; - let result = appliedTags(userTags, taskTags); - expect(result).to.eql('tag 2, tag 3'); - }); -}); diff --git a/test/common/libs/gold.test.js b/test/common/libs/gold.test.js deleted file mode 100644 index 2cdfc3ef65..0000000000 --- a/test/common/libs/gold.test.js +++ /dev/null @@ -1,11 +0,0 @@ -import gold from '../../../common/script/libs/gold'; - -describe('gold', () => { - it('is 0', () => { - expect(gold()).to.eql('0'); - }); - - it('is 5 in 5.2 of gold', () => { - expect(gold(5.2)).to.eql(5); - }); -}); diff --git a/test/common/libs/noTags.test.js b/test/common/libs/noTags.test.js deleted file mode 100644 index dcd2481854..0000000000 --- a/test/common/libs/noTags.test.js +++ /dev/null @@ -1,13 +0,0 @@ -import noTags from '../../../common/script/libs/noTags'; - -describe('noTags', () => { - it('returns true for no tags', () => { - let result = noTags([]); - expect(result).to.eql(true); - }); - - it('returns false for some tags', () => { - let result = noTags(['a', 'b', 'c']); - expect(result).to.eql(false); - }); -}); diff --git a/test/common/libs/percent.test.js b/test/common/libs/percent.test.js deleted file mode 100644 index 9d1ba31024..0000000000 --- a/test/common/libs/percent.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import percent from '../../../common/script/libs/percent'; - -describe('percent', () => { - it('with direction "up"', () => { - expect(percent(1, 10, 'up')).to.eql(10); - expect(percent(1, 20, 'up')).to.eql(5); - expect(percent(1.22, 10.99, 'up')).to.eql(12); - }); - - it('with direction "down"', () => { - expect(percent(1, 10, 'down')).to.eql(10); - expect(percent(1, 20, 'down')).to.eql(5); - expect(percent(1.22, 10.99, 'down')).to.eql(11); - }); - - it('with no direction', () => { - expect(percent(1.22, 10.99)).to.eql(11); - }); -}); diff --git a/test/common/libs/pickDeep.js b/test/common/libs/pickDeep.js deleted file mode 100644 index 4a8741269d..0000000000 --- a/test/common/libs/pickDeep.js +++ /dev/null @@ -1,34 +0,0 @@ -import pickDeep from '../../../common/script/libs/pickDeep'; - -describe('pickDeep', () => { - it('throws an error if "properties" is not an array', () => { - expect(pickDeep).to.throw(Error); - }); - - it('returns an object of properties taken from the input object', () => { - let obj = { - a: true, - b: [1, 2, 3], - c: { - nested: { - two: { - times: true, - }, - }, - }, - d: false, - }; - - let res = pickDeep(obj, ['a', 'b[0]', 'c.nested.two.times']); - expect(res.a).to.be.true; - expect(res.b).to.eql([1]); - expect(res.c).to.eql({ - nested: { - two: { - times: true, - }, - }, - }); - expect(res).to.not.have.property('d'); - }); -}); diff --git a/test/common/libs/refPush.js b/test/common/libs/refPush.js deleted file mode 100644 index 4183845c9a..0000000000 --- a/test/common/libs/refPush.js +++ /dev/null @@ -1,53 +0,0 @@ -import shared from '../../../common'; -import { v4 as generateUUID } from 'uuid'; - -describe('refPush', () => { - it('it hashes one object into another by its id', () => { - let referenceObject = {}; - let objectToHash = { - a: 1, - id: generateUUID(), - }; - - shared.refPush(referenceObject, objectToHash); - - expect(referenceObject[objectToHash.id].a).to.equal(objectToHash.a); - expect(referenceObject[objectToHash.id].id).to.equal(objectToHash.id); - expect(referenceObject[objectToHash.id].sort).to.equal(0); - }); - - it('it hashes one object into another by a uuid when object does not have an id', () => { - let referenceObject = {}; - let objectToHash = { - a: 1, - }; - - shared.refPush(referenceObject, objectToHash); - - let hashedObject = _.find(referenceObject, (hashedItem) => { - return objectToHash.a === hashedItem.a; - }); - - expect(hashedObject.a).to.equal(objectToHash.a); - expect(hashedObject.id).to.equal(objectToHash.id); - expect(hashedObject.sort).to.equal(0); - }); - - it('it hashes one object into another by a id and gives it the highest sort value', () => { - let referenceObject = {}; - referenceObject[generateUUID()] = { b: 2, sort: 1 }; - let objectToHash = { - a: 1, - }; - - shared.refPush(referenceObject, objectToHash); - - let hashedObject = _.find(referenceObject, (hashedItem) => { - return objectToHash.a === hashedItem.a; - }); - - expect(hashedObject.a).to.equal(objectToHash.a); - expect(hashedObject.id).to.equal(objectToHash.id); - expect(hashedObject.sort).to.equal(2); - }); -}); diff --git a/test/common/libs/silver.test.js b/test/common/libs/silver.test.js deleted file mode 100644 index 5bd614fcd1..0000000000 --- a/test/common/libs/silver.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import silver from '../../../common/script/libs/silver'; - -describe('silver', () => { - it('is 0', () => { - expect(silver(0)).to.eql('00'); - }); - - it('20 coins in 5.2 of gold: two decimal places', () => { - expect(silver(5.2)).to.eql('20'); - }); - - it('4 coint in 5.04 of gold: one decimal place', () => { - expect(silver(5.04)).to.eql('04'); - }); - - it('is no value', () => { - expect(silver()).to.eql('00'); - }); -}); diff --git a/test/common/libs/splitWhitespace.test.js b/test/common/libs/splitWhitespace.test.js deleted file mode 100644 index 0b445d5a38..0000000000 --- a/test/common/libs/splitWhitespace.test.js +++ /dev/null @@ -1,7 +0,0 @@ -import splitWhitespace from '../../../common/script/libs/splitWhitespace'; - -describe('splitWhitespace', () => { - it('returns an array', () => { - expect(splitWhitespace('a b')).to.eql(['a', 'b']); - }); -}); diff --git a/test/common/libs/taskClasses.test.js b/test/common/libs/taskClasses.test.js deleted file mode 100644 index 226d740bac..0000000000 --- a/test/common/libs/taskClasses.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import taskClasses from '../../../common/script/libs/taskClasses'; - -describe('taskClasses', () => { - let task = {}; - let filters = {}; - let result; - - describe('a todo task', () => { - beforeEach(() => { - task = { type: 'todo', _editing: false, tags: [] }; - }); - - it('is hidden', () => { - filters = { a: true }; - result = taskClasses(task, filters, 0, Number(new Date()), false, true); - expect(result).to.eql('hidden'); - }); - it('is beingEdited', () => { - task._editing = true; - result = taskClasses(task, filters); - expect(result.split(' ').indexOf('beingEdited')).to.not.eql(-1); - }); - it('is completed', () => { - task.completed = true; - result = taskClasses(task, filters); - expect(result.split(' ').indexOf('completed')).to.not.eql(-1); - task.completed = false; - result = taskClasses(task, filters); - expect(result.split(' ').indexOf('completed')).to.eql(-1); - expect(result.split(' ').indexOf('uncompleted')).to.not.eql(-1); - }); - }); - - describe('a daily task', () => { - it('is completed', () => { - task = { type: 'daily' }; - result = taskClasses(task); - expect(result.split(' ').indexOf('completed')).to.not.eql(-1); - }); - - it('is uncompleted'); // this requires stubbing the internal dependency shouldDo in taskClasses - }); - - describe('a habit', () => { - it('that is wide', () => { - task = { type: 'habit', up: true, down: true }; - result = taskClasses(task); - expect(result.split(' ').indexOf('habit-wide')).to.not.eql(-1); - }); - it('that is narrow', () => { - task = { type: 'habit' }; - result = taskClasses(task); - expect(result.split(' ').indexOf('habit-narrow')).to.not.eql(-1); - }); - }); - - describe('varies based on priority', () => { - it('trivial', () => { - task.priority = 0.1; - result = taskClasses(task); - expect(result.split(' ').indexOf('difficulty-trivial')).to.not.eql(-1); - }); - it('hard', () => { - task.priority = 2; - result = taskClasses(task); - expect(result.split(' ').indexOf('difficulty-hard')).to.not.eql(-1); - }); - }); - - describe('varies based on value', () => { - it('color-worst', () => { - task.value = -30; - result = taskClasses(task); - expect(result.split(' ').indexOf('color-worst')).to.not.eql(-1); - }); - it('color-neutral', () => { - task.value = 0; - result = taskClasses(task); - expect(result.split(' ').indexOf('color-neutral')).to.not.eql(-1); - }); - }); -}); diff --git a/test/common/libs/taskDefaults.test.js b/test/common/libs/taskDefaults.test.js deleted file mode 100644 index c970634137..0000000000 --- a/test/common/libs/taskDefaults.test.js +++ /dev/null @@ -1,61 +0,0 @@ -import taskDefaults from '../../../common/script/libs/taskDefaults'; - -describe('taskDefaults', () => { - it('applies defaults to undefined type or habit', () => { - let task = taskDefaults(); - expect(task.type).to.eql('habit'); - expect(task._id).to.exist; - expect(task.text).to.eql(task._id); - expect(task.tags).to.eql([]); - expect(task.value).to.eql(0); - expect(task.priority).to.eql(1); - expect(task.up).to.eql(true); - expect(task.down).to.eql(true); - expect(task.history).to.eql([]); - }); - - it('applies defaults to a daily', () => { - let task = taskDefaults({ type: 'daily' }); - expect(task.type).to.eql('daily'); - expect(task._id).to.exist; - expect(task.text).to.eql(task._id); - expect(task.tags).to.eql([]); - expect(task.value).to.eql(0); - expect(task.priority).to.eql(1); - expect(task.history).to.eql([]); - expect(task.completed).to.eql(false); - expect(task.streak).to.eql(0); - expect(task.repeat).to.eql({ - m: true, - t: true, - w: true, - th: true, - f: true, - s: true, - su: true, - }); - expect(task.frequency).to.eql('weekly'); - expect(task.startDate).to.exist; - }); - - it('applies defaults a reward', () => { - let task = taskDefaults({ type: 'reward' }); - expect(task.type).to.eql('reward'); - expect(task._id).to.exist; - expect(task.text).to.eql(task._id); - expect(task.tags).to.eql([]); - expect(task.value).to.eql(10); - expect(task.priority).to.eql(1); - }); - - it('applies defaults a todo', () => { - let task = taskDefaults({ type: 'todo' }); - expect(task.type).to.eql('todo'); - expect(task._id).to.exist; - expect(task.text).to.eql(task._id); - expect(task.tags).to.eql([]); - expect(task.value).to.eql(0); - expect(task.priority).to.eql(1); - expect(task.completed).to.eql(false); - }); -}); diff --git a/test/common/libs/updateStore.js b/test/common/libs/updateStore.js deleted file mode 100644 index 97d00076af..0000000000 --- a/test/common/libs/updateStore.js +++ /dev/null @@ -1,57 +0,0 @@ -import shared from '../../../common'; -import { - generateUser, -} from '../../helpers/common.helper'; -import i18n from '../../../common/script/i18n'; - -describe('updateStore', () => { - context('returns a list of gear items available for purchase', () => { - let user = generateUser(); - user.items.gear.owned.armor_armoire_lunarArmor = false; // eslint-disable-line camelcase - user.contributor.level = 2; - user.purchased.plan.mysteryItems = ['armor_mystery_201402']; - user.items.gear.owned.armor_mystery_201402 = false; // eslint-disable-line camelcase - - let list = shared.updateStore(user); - - it('contains the first item not purchased for each gear type', () => { - expect(_.find(list, item => { - return item.text() === i18n.t('armorWarrior1Text'); - })).to.exist; - - expect(_.find(list, item => { - return item.text() === i18n.t('armorWarrior2Text'); - })).to.not.exist; - }); - - it('contains mystery items the user can own', () => { - expect(_.find(list, item => { - return item.text() === i18n.t('armorMystery201402Text'); - })).to.exist; - - expect(_.find(list, item => { - return item.text() === i18n.t('armorMystery201403Text'); - })).to.not.exist; - }); - - it('contains special items the user can own', () => { - expect(_.find(list, item => { - return item.text() === i18n.t('armorSpecial1Text'); - })).to.exist; - - expect(_.find(list, item => { - return item.text() === i18n.t('headSpecial1Text'); - })).to.not.exist; - }); - - it('contains armoire items the user can own', () => { - expect(_.find(list, item => { - return item.text() === i18n.t('armorArmoireLunarArmorText'); - })).to.exist; - - expect(_.find(list, item => { - return item.text() === i18n.t('armorArmoireGladiatorArmorText'); - })).to.not.exist; - }); - }); -}); diff --git a/test/common/ops/addPushDevice.js b/test/common/ops/addPushDevice.js deleted file mode 100644 index 535d288384..0000000000 --- a/test/common/ops/addPushDevice.js +++ /dev/null @@ -1,59 +0,0 @@ -import addPushDevice from '../../../common/script/ops/addPushDevice'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import { - NotAuthorized, - BadRequest, -} from '../../../common/script/libs/errors'; - -describe('shared.ops.addPushDevice', () => { - let user; - let regId = '10'; - let type = 'someRandomType'; - - beforeEach(() => { - user = generateUser(); - user.stats.hp = 0; - }); - - it('returns an error when regId is not provided', (done) => { - try { - addPushDevice(user); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('regIdRequired')); - done(); - } - }); - - it('returns an error when type is not provided', (done) => { - try { - addPushDevice(user, {body: {regId}}); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('typeRequired')); - done(); - } - }); - - it('adds a push device', () => { - let [, message] = addPushDevice(user, {body: {regId, type}}); - - expect(message).to.equal(i18n.t('pushDeviceAdded')); - expect(user.pushDevices[0].type).to.equal(type); - expect(user.pushDevices[0].regId).to.equal(regId); - }); - - it('does not a push device twice', (done) => { - try { - addPushDevice(user, {body: {regId, type}}); - addPushDevice(user, {body: {regId, type}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('pushDeviceAlreadyAdded')); - done(); - } - }); -}); diff --git a/test/common/ops/addTask.js b/test/common/ops/addTask.js deleted file mode 100644 index cb207036db..0000000000 --- a/test/common/ops/addTask.js +++ /dev/null @@ -1,139 +0,0 @@ -import addTask from '../../../common/script/ops/addTask'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.addTask', () => { - let user; - - beforeEach(() => { - user = generateUser(); - user.habits = []; - user.todos = []; - user.dailys = []; - user.rewards = []; - }); - - it('adds an habit', () => { - let habit = addTask(user, { - body: { - type: 'habit', - text: 'habit', - down: false, - }, - }); - - expect(user.tasksOrder.habits).to.eql([ - habit._id, - ]); - expect(habit._id).to.be.a('string'); - expect(habit.text).to.equal('habit'); - expect(habit.type).to.equal('habit'); - expect(habit.up).to.equal(true); - expect(habit.down).to.equal(false); - expect(habit.history).to.eql([]); - expect(habit.checklist).to.not.exists; - }); - - it('adds an habtit when type is invalid', () => { - let habit = addTask(user, { - body: { - type: 'invalid', - text: 'habit', - down: false, - }, - }); - - expect(user.tasksOrder.habits).to.eql([ - habit._id, - ]); - expect(habit._id).to.be.a('string'); - expect(habit.text).to.equal('habit'); - expect(habit.type).to.equal('habit'); - expect(habit.up).to.equal(true); - expect(habit.down).to.equal(false); - expect(habit.history).to.eql([]); - expect(habit.checklist).to.not.exists; - }); - - it('adds a daily', () => { - let daily = addTask(user, { - body: { - type: 'daily', - text: 'daily', - }, - }); - - expect(user.tasksOrder.dailys).to.eql([ - daily._id, - ]); - expect(daily._id).to.be.a('string'); - expect(daily.type).to.equal('daily'); - expect(daily.text).to.equal('daily'); - expect(daily.history).to.eql([]); - expect(daily.checklist).to.eql([]); - expect(daily.completed).to.be.false; - expect(daily.up).to.not.exists; - }); - - it('adds a todo', () => { - let todo = addTask(user, { - body: { - type: 'todo', - text: 'todo', - }, - }); - - expect(user.tasksOrder.todos).to.eql([ - todo._id, - ]); - expect(todo._id).to.be.a('string'); - expect(todo.type).to.equal('todo'); - expect(todo.text).to.equal('todo'); - expect(todo.checklist).to.eql([]); - expect(todo.completed).to.be.false; - expect(todo.up).to.not.exists; - }); - - it('adds a reward', () => { - let reward = addTask(user, { - body: { - type: 'reward', - text: 'reward', - }, - }); - - expect(user.tasksOrder.rewards).to.eql([ - reward._id, - ]); - expect(reward._id).to.be.a('string'); - expect(reward.type).to.equal('reward'); - expect(reward.text).to.equal('reward'); - expect(reward.value).to.equal(10); - expect(reward.up).to.not.exists; - }); - - context('respects preferences', () => { - it('true', () => { - user.preferences.newTaskEdit = true; - user.preferences.tagsCollapsed = true; - user.preferences.advancedCollapsed = false; - let task = addTask(user); - - expect(task._editing).to.be.true; - expect(task._tags).to.be.true; - expect(task._advanced).to.be.true; - }); - - it('false', () => { - user.preferences.newTaskEdit = false; - user.preferences.tagsCollapsed = false; - user.preferences.advancedCollapsed = true; - let task = addTask(user); - - expect(task._editing).to.not.exists; - expect(task._tags).to.not.exists; - expect(task._advanced).to.not.exists; - }); - }); -}); diff --git a/test/common/ops/addWebhook.test.js b/test/common/ops/addWebhook.test.js deleted file mode 100644 index 11d26e622b..0000000000 --- a/test/common/ops/addWebhook.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import addWebhook from '../../../common/script/ops/addWebhook'; -import { - BadRequest, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.addWebhook', () => { - let user; - let req; - - beforeEach(() => { - user = generateUser(); - req = { body: { - enabled: true, - url: 'http://some-url.com', - } }; - }); - - context('adds webhook', () => { - it('validates req.body.url', (done) => { - delete req.body.url; - try { - addWebhook(user, req); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('invalidUrl')); - done(); - } - }); - - it('validates req.body.enabled', (done) => { - delete req.body.enabled; - try { - addWebhook(user, req); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('invalidEnabled')); - done(); - } - }); - - it('calls marksModified()', () => { - user.markModified = sinon.spy(); - addWebhook(user, req); - expect(user.markModified.called).to.eql(true); - }); - - it('succeeds', () => { - expect(user.preferences.webhooks).to.eql({}); - addWebhook(user, req); - expect(user.preferences.webhooks).to.not.eql({}); - }); - }); -}); diff --git a/test/common/ops/allocate.js b/test/common/ops/allocate.js deleted file mode 100644 index 65f3c74fc9..0000000000 --- a/test/common/ops/allocate.js +++ /dev/null @@ -1,63 +0,0 @@ -import allocate from '../../../common/script/ops/allocate'; -import { - BadRequest, - NotAuthorized, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.allocate', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('throws an error if an invalid attribute is supplied', (done) => { - try { - allocate(user, { - query: {stat: 'notValid'}, - }); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('invalidAttribute', {attr: 'notValid'})); - done(); - } - }); - - it('throws an error if the user doesn\'t have attribute points', (done) => { - try { - allocate(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('notEnoughAttrPoints')); - done(); - } - }); - - it('defaults to the "str" attribute', () => { - expect(user.stats.str).to.equal(0); - user.stats.points = 1; - allocate(user); - expect(user.stats.str).to.equal(1); - }); - - it('allocates attribute points', () => { - expect(user.stats.con).to.equal(0); - user.stats.points = 1; - allocate(user, {query: {stat: 'con'}}); - expect(user.stats.con).to.equal(1); - expect(user.stats.points).to.equal(0); - }); - - it('increases mana when allocating to "int"', () => { - expect(user.stats.int).to.equal(0); - expect(user.stats.mp).to.equal(10); - user.stats.points = 1; - allocate(user, {query: {stat: 'int'}}); - expect(user.stats.int).to.equal(1); - expect(user.stats.mp).to.equal(11); - }); -}); diff --git a/test/common/ops/allocateNow.js b/test/common/ops/allocateNow.js deleted file mode 100644 index 4fe473ec9e..0000000000 --- a/test/common/ops/allocateNow.js +++ /dev/null @@ -1,30 +0,0 @@ -import allocateNow from '../../../common/script/ops/allocateNow'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.allocateNow', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('auto allocates all points', () => { - user.stats.points = 5; - user.stats.int = 3; - user.stats.con = 9; - user.stats.per = 9; - user.stats.str = 9; - user.preferences.allocationMode = 'flat'; - - let [data] = allocateNow(user); - - expect(user.stats.points).to.equal(0); - expect(user.stats.con).to.equal(9); - expect(user.stats.int).to.equal(8); - expect(user.stats.per).to.equal(9); - expect(user.stats.str).to.equal(9); - expect(data).to.eql(user.stats); - }); -}); diff --git a/test/common/ops/blockUser.test.js b/test/common/ops/blockUser.test.js deleted file mode 100644 index 950af25d0c..0000000000 --- a/test/common/ops/blockUser.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import blockUser from '../../../common/script/ops/blockUser'; -import { - generateUser, -} from '../../helpers/common.helper'; -import i18n from '../../../common/script/i18n'; - -describe('shared.ops.blockUser', () => { - let user; - let blockedUser; - let blockedUser2; - - beforeEach(() => { - blockedUser = generateUser(); - blockedUser2 = generateUser(); - user = generateUser(); - expect(user.inbox.blocks).to.eql([]); - }); - - it('validates uuid', (done) => { - try { - blockUser(user, { params: { uuid: 1 } }); - } catch (error) { - expect(error.message).to.eql(i18n.t('invalidUUID')); - done(); - } - }); - - it('blocks user', () => { - let [result] = blockUser(user, { params: { uuid: blockedUser._id } }); - expect(user.inbox.blocks).to.eql([blockedUser._id]); - expect(result).to.eql([blockedUser._id]); - [result] = blockUser(user, { params: { uuid: blockedUser2._id } }); - expect(user.inbox.blocks).to.eql([blockedUser._id, blockedUser2._id]); - expect(result).to.eql([blockedUser._id, blockedUser2._id]); - }); - - it('blocks, then unblocks user', () => { - blockUser(user, { params: { uuid: blockedUser._id } }); - expect(user.inbox.blocks).to.eql([blockedUser._id]); - let [result] = blockUser(user, { params: { uuid: blockedUser._id } }); - expect(user.inbox.blocks).to.eql([]); - expect(result).to.eql([]); - }); -}); diff --git a/test/common/ops/buy.js b/test/common/ops/buy.js deleted file mode 100644 index 9e05cc5225..0000000000 --- a/test/common/ops/buy.js +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable camelcase */ -import { - generateUser, -} from '../../helpers/common.helper'; -import buy from '../../../common/script/ops/buy'; -import { - BadRequest, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; - -describe('shared.ops.buy', () => { - let user; - - beforeEach(() => { - user = generateUser({ - items: { - gear: { - owned: { - weapon_warrior_0: true, - }, - equipped: { - weapon_warrior_0: true, - }, - }, - }, - stats: { gp: 200 }, - }); - }); - - it('returns error when key is not provided', (done) => { - try { - buy(user); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('missingKeyParam')); - done(); - } - }); - - it('recovers 15 hp', () => { - user.stats.hp = 30; - buy(user, {params: {key: 'potion'}}); - expect(user.stats.hp).to.eql(45); - }); - - it('adds equipment to inventory', () => { - user.stats.gp = 31; - buy(user, {params: {key: 'armor_warrior_1'}}); - expect(user.items.gear.owned).to.eql({ - weapon_warrior_0: true, - armor_warrior_1: true, - eyewear_special_blackTopFrame: true, - eyewear_special_blueTopFrame: true, - eyewear_special_greenTopFrame: true, - eyewear_special_pinkTopFrame: true, - eyewear_special_redTopFrame: true, - eyewear_special_whiteTopFrame: true, - eyewear_special_yellowTopFrame: true, - }); - }); -}); diff --git a/test/common/ops/buyArmoire.js b/test/common/ops/buyArmoire.js deleted file mode 100644 index cea6d7abde..0000000000 --- a/test/common/ops/buyArmoire.js +++ /dev/null @@ -1,205 +0,0 @@ -/* eslint-disable camelcase */ - -import sinon from 'sinon'; // eslint-disable-line no-shadow -import { - generateUser, -} from '../../helpers/common.helper'; -import count from '../../../common/script/count'; -import buyArmoire from '../../../common/script/ops/buyArmoire'; -import shared from '../../../common/script'; -import content from '../../../common/script/content/index'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; - -describe('shared.ops.buyArmoire', () => { - let user; - let YIELD_EQUIPMENT = 0.5; - let YIELD_FOOD = 0.7; - let YIELD_EXP = 0.9; - - let fullArmoire = {}; - - _(content.gearTypes).each((type) => { - _(content.gear.tree[type].armoire).each((gearObject) => { - let armoireKey = gearObject.key; - - fullArmoire[armoireKey] = true; - }).value(); - }).value(); - - - beforeEach(() => { - user = generateUser({ - items: { - gear: { - owned: { - weapon_warrior_0: true, - }, - equipped: { - weapon_warrior_0: true, - }, - }, - }, - stats: { gp: 200 }, - }); - - user.achievements.ultimateGearSets = { rogue: true }; - user.flags.armoireOpened = true; - user.stats.exp = 0; - user.items.food = {}; - - sinon.stub(shared.fns, 'randomVal'); - sinon.stub(shared.fns, 'predictableRandom'); - }); - - afterEach(() => { - shared.fns.randomVal.restore(); - shared.fns.predictableRandom.restore(); - }); - - context('failure conditions', () => { - it('does not open if user does not have enough gold', (done) => { - shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); - user.stats.gp = 50; - - try { - buyArmoire(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); - expect(user.items.gear.owned).to.eql({ - weapon_warrior_0: true, - eyewear_special_blackTopFrame: true, - eyewear_special_blueTopFrame: true, - eyewear_special_greenTopFrame: true, - eyewear_special_pinkTopFrame: true, - eyewear_special_redTopFrame: true, - eyewear_special_whiteTopFrame: true, - eyewear_special_yellowTopFrame: true, - }); - expect(user.items.food).to.be.empty; - expect(user.stats.exp).to.eql(0); - done(); - } - }); - - it('does not open without Ultimate Gear achievement', (done) => { - shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); - user.achievements.ultimateGearSets = {healer: false, wizard: false, rogue: false, warrior: false}; - - try { - buyArmoire(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('cannotBuyItem')); - expect(user.items.gear.owned).to.eql({ - weapon_warrior_0: true, - eyewear_special_blackTopFrame: true, - eyewear_special_blueTopFrame: true, - eyewear_special_greenTopFrame: true, - eyewear_special_pinkTopFrame: true, - eyewear_special_redTopFrame: true, - eyewear_special_whiteTopFrame: true, - eyewear_special_yellowTopFrame: true, - }); - expect(user.items.food).to.be.empty; - expect(user.stats.exp).to.eql(0); - done(); - } - }); - }); - - context('non-gear awards', () => { - // Skipped because can't stub predictableRandom correctly - xit('gives Experience', () => { - shared.fns.predictableRandom.returns(YIELD_EXP); - - buyArmoire(user); - - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); - expect(user.items.food).to.be.empty; - expect(user.stats.exp).to.eql(46); - expect(user.stats.gp).to.eql(100); - }); - - // Skipped because can't stub predictableRandom correctly - xit('gives food', () => { - let honey = content.food.Honey; - - shared.fns.randomVal.returns(honey); - shared.fns.predictableRandom.returns(YIELD_FOOD); - - buyArmoire(user); - - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); - expect(user.items.food).to.eql({Honey: 1}); - expect(user.stats.exp).to.eql(0); - expect(user.stats.gp).to.eql(100); - }); - - // Skipped because can't stub predictableRandom correctly - xit('does not give equipment if all equipment has been found', () => { - shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); - user.items.gear.owned = fullArmoire; - user.stats.gp = 150; - - buyArmoire(user); - - expect(user.items.gear.owned).to.eql(fullArmoire); - let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); - - expect(armoireCount).to.eql(0); - - expect(user.stats.exp).to.eql(30); - expect(user.stats.gp).to.eql(50); - }); - }); - - context('gear awards', () => { - beforeEach(() => { - let shield = content.gear.tree.shield.armoire.gladiatorShield; - - shared.fns.randomVal.returns(shield); - }); - - // Skipped because can't stub predictableRandom correctly - xit('always drops equipment the first time', () => { - delete user.flags.armoireOpened; - shared.fns.predictableRandom.returns(YIELD_EXP); - - buyArmoire(user); - - expect(user.items.gear.owned).to.eql({ - weapon_warrior_0: true, - shield_armoire_gladiatorShield: true, - }); - - let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); - - expect(armoireCount).to.eql(_.size(fullArmoire) - 1); - expect(user.items.food).to.be.empty; - expect(user.stats.exp).to.eql(0); - expect(user.stats.gp).to.eql(100); - }); - - // Skipped because can't stub predictableRandom correctly - xit('gives more equipment', () => { - shared.fns.predictableRandom.returns(YIELD_EQUIPMENT); - user.items.gear.owned = { - weapon_warrior_0: true, - head_armoire_hornedIronHelm: true, - }; - user.stats.gp = 200; - - buyArmoire(user); - - expect(user.items.gear.owned).to.eql({weapon_warrior_0: true, shield_armoire_gladiatorShield: true, head_armoire_hornedIronHelm: true}); - let armoireCount = count.remainingGearInSet(user.items.gear.owned, 'armoire'); - - expect(armoireCount).to.eql(_.size(fullArmoire) - 2); - expect(user.stats.gp).to.eql(100); - }); - }); -}); diff --git a/test/common/ops/buyGear.js b/test/common/ops/buyGear.js deleted file mode 100644 index 4d1213e80d..0000000000 --- a/test/common/ops/buyGear.js +++ /dev/null @@ -1,142 +0,0 @@ -/* eslint-disable camelcase */ - -import sinon from 'sinon'; // eslint-disable-line no-shadow -import { - generateUser, -} from '../../helpers/common.helper'; -import buyGear from '../../../common/script/ops/buyGear'; -import shared from '../../../common/script'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; - -describe('shared.ops.buyGear', () => { - let user; - - beforeEach(() => { - user = generateUser({ - items: { - gear: { - owned: { - weapon_warrior_0: true, - }, - equipped: { - weapon_warrior_0: true, - }, - }, - }, - stats: { gp: 200 }, - }); - - sinon.stub(shared.fns, 'randomVal'); - sinon.stub(shared.fns, 'predictableRandom'); - }); - - afterEach(() => { - shared.fns.randomVal.restore(); - shared.fns.predictableRandom.restore(); - }); - - context('Gear', () => { - it('adds equipment to inventory', () => { - user.stats.gp = 31; - - buyGear(user, {params: {key: 'armor_warrior_1'}}); - - expect(user.items.gear.owned).to.eql({ - weapon_warrior_0: true, - armor_warrior_1: true, - eyewear_special_blackTopFrame: true, - eyewear_special_blueTopFrame: true, - eyewear_special_greenTopFrame: true, - eyewear_special_pinkTopFrame: true, - eyewear_special_redTopFrame: true, - eyewear_special_whiteTopFrame: true, - eyewear_special_yellowTopFrame: true, - }); - }); - - it('deducts gold from user', () => { - user.stats.gp = 31; - - buyGear(user, {params: {key: 'armor_warrior_1'}}); - - expect(user.stats.gp).to.eql(1); - }); - - it('auto equips equipment if user has auto-equip preference turned on', () => { - user.stats.gp = 31; - user.preferences.autoEquip = true; - - buyGear(user, {params: {key: 'armor_warrior_1'}}); - - expect(user.items.gear.equipped).to.have.property('armor', 'armor_warrior_1'); - }); - - it('buyGears equipment but does not auto-equip', () => { - user.stats.gp = 31; - user.preferences.autoEquip = false; - - buyGear(user, {params: {key: 'armor_warrior_1'}}); - - expect(user.items.gear.equipped.property).to.not.equal('armor_warrior_1'); - }); - - it('does not buyGear equipment twice', (done) => { - user.stats.gp = 62; - buyGear(user, {params: {key: 'armor_warrior_1'}}); - - try { - buyGear(user, {params: {key: 'armor_warrior_1'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('equipmentAlreadyOwned')); - done(); - } - }); - - // TODO after user.ops.equip is done - xit('removes one-handed weapon and shield if auto-equip is on and a two-hander is bought', () => { - user.stats.gp = 100; - user.preferences.autoEquip = true; - buyGear(user, {params: {key: 'shield_warrior_1'}}); - user.ops.equip({params: {key: 'shield_warrior_1'}}); - buyGear(user, {params: {key: 'weapon_warrior_1'}}); - user.ops.equip({params: {key: 'weapon_warrior_1'}}); - - buyGear(user, {params: {key: 'weapon_wizard_1'}}); - - expect(user.items.gear.equipped).to.have.property('shield', 'shield_base_0'); - expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_wizard_1'); - }); - - // TODO after user.ops.equip is done - xit('buyGears two-handed equipment but does not automatically remove sword or shield', () => { - user.stats.gp = 100; - user.preferences.autoEquip = false; - buyGear(user, {params: {key: 'shield_warrior_1'}}); - user.ops.equip({params: {key: 'shield_warrior_1'}}); - buyGear(user, {params: {key: 'weapon_warrior_1'}}); - user.ops.equip({params: {key: 'weapon_warrior_1'}}); - - buyGear(user, {params: {key: 'weapon_wizard_1'}}); - - expect(user.items.gear.equipped).to.have.property('shield', 'shield_warrior_1'); - expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_warrior_1'); - }); - - it('does not buyGear equipment without enough Gold', (done) => { - user.stats.gp = 20; - - try { - buyGear(user, {params: {key: 'armor_warrior_1'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); - expect(user.items.gear.owned).to.not.have.property('armor_warrior_1'); - done(); - } - }); - }); -}); diff --git a/test/common/ops/buyHealthPotion.js b/test/common/ops/buyHealthPotion.js deleted file mode 100644 index 6a70f71b62..0000000000 --- a/test/common/ops/buyHealthPotion.js +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-disable camelcase */ -import { - generateUser, -} from '../../helpers/common.helper'; -import buyHealthPotion from '../../../common/script/ops/buyHealthPotion'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; - -describe('shared.ops.buyHealthPotion', () => { - let user; - - beforeEach(() => { - user = generateUser({ - items: { - gear: { - owned: { - weapon_warrior_0: true, - }, - equipped: { - weapon_warrior_0: true, - }, - }, - }, - stats: { gp: 200 }, - }); - }); - - context('Potion', () => { - it('recovers 15 hp', () => { - user.stats.hp = 30; - buyHealthPotion(user); - expect(user.stats.hp).to.eql(45); - }); - - it('does not increase hp above 50', () => { - user.stats.hp = 45; - buyHealthPotion(user); - expect(user.stats.hp).to.eql(50); - }); - - it('deducts 25 gp', () => { - user.stats.hp = 45; - buyHealthPotion(user); - - expect(user.stats.gp).to.eql(175); - }); - - it('does not purchase if not enough gp', (done) => { - user.stats.hp = 45; - user.stats.gp = 5; - try { - buyHealthPotion(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); - expect(user.stats.hp).to.eql(45); - expect(user.stats.gp).to.eql(5); - - done(); - } - }); - }); -}); diff --git a/test/common/ops/buyMysterySet.js b/test/common/ops/buyMysterySet.js deleted file mode 100644 index 8e523a5a0a..0000000000 --- a/test/common/ops/buyMysterySet.js +++ /dev/null @@ -1,76 +0,0 @@ -/* eslint-disable camelcase */ - -import { - generateUser, -} from '../../helpers/common.helper'; -import buyMysterySet from '../../../common/script/ops/buyMysterySet'; -import { - NotAuthorized, - NotFound, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; - -describe('shared.ops.buyMysterySet', () => { - let user; - - beforeEach(() => { - user = generateUser({ - items: { - gear: { - owned: { - weapon_warrior_0: true, - }, - }, - }, - }); - }); - - context('Mystery Sets', () => { - context('failure conditions', () => { - it('does not grant mystery sets without Mystic Hourglasses', (done) => { - try { - buyMysterySet(user, {params: {key: '201501'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.eql(i18n.t('notEnoughHourglasses')); - expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true); - done(); - } - }); - - it('does not grant mystery set that has already been purchased', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - user.items.gear.owned = { - weapon_warrior_0: true, - weapon_mystery_301404: true, - armor_mystery_301404: true, - head_mystery_301404: true, - eyewear_mystery_301404: true, - }; - - try { - buyMysterySet(user, {params: {key: '301404'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.eql(i18n.t('mysterySetNotFound')); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - done(); - } - }); - }); - - context('successful purchases', () => { - it('buys Steampunk Accessories Set', () => { - user.purchased.plan.consecutive.trinkets = 1; - buyMysterySet(user, {params: {key: '301404'}}); - - expect(user.purchased.plan.consecutive.trinkets).to.eql(0); - expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true); - expect(user.items.gear.owned).to.have.property('weapon_mystery_301404', true); - expect(user.items.gear.owned).to.have.property('armor_mystery_301404', true); - expect(user.items.gear.owned).to.have.property('head_mystery_301404', true); - expect(user.items.gear.owned).to.have.property('eyewear_mystery_301404', true); - }); - }); - }); -}); diff --git a/test/common/ops/buyQuest.js b/test/common/ops/buyQuest.js deleted file mode 100644 index aab691f64e..0000000000 --- a/test/common/ops/buyQuest.js +++ /dev/null @@ -1,81 +0,0 @@ -import { - generateUser, -} from '../../helpers/common.helper'; -import buyQuest from '../../../common/script/ops/buyQuest'; -import { - NotAuthorized, - NotFound, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; - -describe('shared.ops.buyQuest', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('buys a Quest scroll', () => { - user.stats.gp = 205; - buyQuest(user, { - params: { - key: 'dilatoryDistress1', - }, - }); - expect(user.items.quests).to.eql({ - dilatoryDistress1: 1, - }); - expect(user.stats.gp).to.equal(5); - }); - - it('does not buy Quests without enough Gold', (done) => { - user.stats.gp = 1; - try { - buyQuest(user, { - params: { - key: 'dilatoryDistress1', - }, - }); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); - expect(user.items.quests).to.eql({}); - expect(user.stats.gp).to.equal(1); - done(); - } - }); - - it('does not buy nonexistent Quests', (done) => { - user.stats.gp = 9999; - try { - buyQuest(user, { - params: { - key: 'snarfblatter', - }, - }); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.equal(i18n.t('questNotFound', {key: 'snarfblatter'})); - expect(user.items.quests).to.eql({}); - expect(user.stats.gp).to.equal(9999); - done(); - } - }); - - it('does not buy Gem-premium Quests', (done) => { - user.stats.gp = 9999; - try { - buyQuest(user, { - params: { - key: 'kraken', - }, - }); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('questNotGoldPurchasable', {key: 'kraken'})); - expect(user.items.quests).to.eql({}); - expect(user.stats.gp).to.equal(9999); - done(); - } - }); -}); diff --git a/test/common/ops/buySpecialSpell.js b/test/common/ops/buySpecialSpell.js deleted file mode 100644 index 249f4bc8a6..0000000000 --- a/test/common/ops/buySpecialSpell.js +++ /dev/null @@ -1,79 +0,0 @@ -import buySpecialSpell from '../../../common/script/ops/buySpecialSpell'; -import { - BadRequest, - NotFound, - NotAuthorized, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import content from '../../../common/script/content/index'; - -describe('shared.ops.buySpecialSpell', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('throws an error if params.key is missing', (done) => { - try { - buySpecialSpell(user); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('missingKeyParam')); - done(); - } - }); - - it('throws an error if the spell doesn\'t exists', (done) => { - try { - buySpecialSpell(user, { - params: { - key: 'notExisting', - }, - }); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.equal(i18n.t('spellNotFound', {spellId: 'notExisting'})); - done(); - } - }); - - it('throws an error if the user doesn\'t have enough gold', (done) => { - user.stats.gp = 1; - try { - buySpecialSpell(user, { - params: { - key: 'thankyou', - }, - }); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); - done(); - } - }); - - it('buys an item', () => { - user.stats.gp = 11; - let item = content.special.thankyou; - - let [data, message] = buySpecialSpell(user, { - params: { - key: 'thankyou', - }, - }); - - expect(user.stats.gp).to.equal(1); - expect(user.items.special.thankyou).to.equal(1); - expect(data).to.eql({ - items: user.items, - stats: user.stats, - }); - expect(message).to.equal(i18n.t('messageBought', { - itemText: item.text(), - })); - }); -}); diff --git a/test/common/ops/changeClass.js b/test/common/ops/changeClass.js deleted file mode 100644 index ae4a180178..0000000000 --- a/test/common/ops/changeClass.js +++ /dev/null @@ -1,139 +0,0 @@ -import changeClass from '../../../common/script/ops/changeClass'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.changeClass', () => { - let user; - - beforeEach(() => { - user = generateUser(); - user.stats.lvl = 11; - user.stats.flagSelected = false; - }); - - it('user is not level 10', (done) => { - user.stats.lvl = 9; - try { - changeClass(user, {query: {class: 'rogue'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('lvl10ChangeClass')); - done(); - } - }); - - context('req.query.class is a valid class', () => { - it('errors if user.stats.flagSelected is true and user.balance < 0.75', (done) => { - user.flags.classSelected = true; - user.preferences.disableClasses = false; - user.balance = 0; - - try { - changeClass(user, {query: {class: 'rogue'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('notEnoughGems')); - done(); - } - }); - - it('changes class', () => { - user.stats.class = 'healer'; - user.items.gear.owned.armor_rogue_1 = true; // eslint-disable-line camelcase - - let [data] = changeClass(user, {query: {class: 'rogue'}}); - expect(data).to.eql({ - preferences: user.preferences, - stats: user.stats, - flags: user.flags, - items: user.items, - }); - - expect(user.stats.class).to.equal('rogue'); - expect(user.flags.classSelected).to.be.true; - expect(user.items.gear.equipped.weapon).to.equal('weapon_rogue_0'); - expect(user.items.gear.owned.weapon_rogue_0).to.be.true; - expect(user.items.gear.equipped.armor).to.equal('armor_rogue_1'); - expect(user.items.gear.owned.armor_rogue_1).to.be.true; - expect(user.items.gear.equipped.shield).to.equal('shield_rogue_0'); - expect(user.items.gear.owned.shield_rogue_0).to.be.true; - expect(user.items.gear.equipped.head).to.equal('head_base_0'); - }); - }); - - context('req.query.class is missing or user.stats.flagSelected is true', () => { - it('has user.preferences.disableClasses === true', () => { - user.balance = 1; - user.preferences.disableClasses = true; - user.preferences.autoAllocate = true; - user.stats.points = 45; - user.stats.str = 1; - user.stats.con = 2; - user.stats.per = 3; - user.stats.int = 4; - user.flags.classSelected = true; - - let [data] = changeClass(user); - expect(data).to.eql({ - preferences: user.preferences, - stats: user.stats, - flags: user.flags, - items: user.items, - }); - - expect(user.preferences.disableClasses).to.be.false; - expect(user.preferences.autoAllocate).to.be.false; - expect(user.balance).to.equal(1); - expect(user.stats.str).to.equal(0); - expect(user.stats.con).to.equal(0); - expect(user.stats.per).to.equal(0); - expect(user.stats.int).to.equal(0); - expect(user.stats.points).to.equal(11); - expect(user.flags.classSelected).to.equal(false); - }); - - context('has user.preferences.disableClasses !== true', () => { - it('and less than 3 gems', (done) => { - user.balance = 0.5; - try { - changeClass(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('notEnoughGems')); - done(); - } - }); - - it('and at least 3 gems', () => { - user.balance = 1; - user.stats.points = 45; - user.stats.str = 1; - user.stats.con = 2; - user.stats.per = 3; - user.stats.int = 4; - user.flags.classSelected = true; - - let [data] = changeClass(user); - expect(data).to.eql({ - preferences: user.preferences, - stats: user.stats, - flags: user.flags, - items: user.items, - }); - - expect(user.balance).to.equal(0.25); - expect(user.stats.str).to.equal(0); - expect(user.stats.con).to.equal(0); - expect(user.stats.per).to.equal(0); - expect(user.stats.int).to.equal(0); - expect(user.stats.points).to.equal(11); - expect(user.flags.classSelected).to.equal(false); - }); - }); - }); -}); diff --git a/test/common/ops/clearCompleted.js b/test/common/ops/clearCompleted.js deleted file mode 100644 index 4dceb3c1f0..0000000000 --- a/test/common/ops/clearCompleted.js +++ /dev/null @@ -1,37 +0,0 @@ -import clearCompleted from '../../../common/script/ops/clearCompleted'; -import { - generateTodo, -} from '../../helpers/common.helper'; - -describe('shared.ops.clearCompleted', () => { - it('clear completed todos', () => { - let todos = [ - generateTodo({text: 'todo'}), - generateTodo({ - text: 'done', - completed: true, - }), - generateTodo({ - text: 'done chellenge broken', - completed: true, - challenge: { - id: 123, - broken: 'TASK_DELETED', - }, - }), - generateTodo({ - text: 'done chellenge not broken', - completed: true, - challenge: { - id: 123, - }, - }), - ]; - - clearCompleted(todos); - - expect(todos.length).to.equal(2); - expect(todos[0].text).to.equal('todo'); - expect(todos[1].text).to.equal('done chellenge not broken'); - }); -}); diff --git a/test/common/ops/clearPMs.test.js b/test/common/ops/clearPMs.test.js deleted file mode 100644 index cf1408e5a6..0000000000 --- a/test/common/ops/clearPMs.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import clearPMs from '../../../common/script/ops/clearPMs'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.clearPMs', () => { - let user; - - beforeEach(() => { - user = generateUser(); - user.inbox.messages = { first: 'message', second: 'message' }; - }); - - it('clears messages', () => { - expect(user.inbox.messages).to.not.eql({}); - let [result] = clearPMs(user); - expect(user.inbox.messages).to.eql({}); - expect(result).to.eql({}); - }); -}); diff --git a/test/common/ops/deletePM.test.js b/test/common/ops/deletePM.test.js deleted file mode 100644 index 109595eca9..0000000000 --- a/test/common/ops/deletePM.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import deletePM from '../../../common/script/ops/deletePM'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.deletePM', () => { - let user; - - beforeEach(() => { - user = generateUser(); - user.inbox.messages = { first: 'message', second: 'message' }; - }); - - it('delete message', () => { - expect(user.inbox.messages).to.not.eql({ second: 'message' }); - let [response] = deletePM(user, { params: { id: 'first' } }); - expect(user.inbox.messages).to.eql({ second: 'message' }); - expect(response).to.eql({ second: 'message' }); - }); -}); diff --git a/test/common/ops/deleteWebhook.test.js b/test/common/ops/deleteWebhook.test.js deleted file mode 100644 index 8e27a09e3e..0000000000 --- a/test/common/ops/deleteWebhook.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import deleteWebhook from '../../../common/script/ops/deleteWebhook'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.deleteWebhook', () => { - let user; - let req; - - beforeEach(() => { - user = generateUser(); - req = { params: { id: 'some-id' } }; - }); - - it('succeeds', () => { - user.preferences.webhooks = { 'some-id': {}, 'another-id': {} }; - let [data] = deleteWebhook(user, req); - expect(user.preferences.webhooks).to.eql({'another-id': {}}); - expect(data).to.equal(user.preferences.webhooks); - }); -}); diff --git a/test/common/ops/disableClasses.js b/test/common/ops/disableClasses.js deleted file mode 100644 index 81ac1a9792..0000000000 --- a/test/common/ops/disableClasses.js +++ /dev/null @@ -1,35 +0,0 @@ -import disableClasses from '../../../common/script/ops/disableClasses'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.disableClasses', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('disable classes', () => { - user.stats.lvl = 34; - user.stats.str = 45; - user.stats.class = 'healer'; - user.preferences.disableClasses = false; - user.preferences.autoAllocate = false; - user.stats.points = 2; - - let [data] = disableClasses(user); - expect(data).to.eql({ - preferences: user.preferences, - stats: user.stats, - flags: user.flags, - }); - - expect(user.stats.class).to.equal('warrior'); - expect(user.flags.classSelected).to.equal(true); - expect(user.preferences.disableClasses).to.equal(true); - expect(user.preferences.autoAllocate).to.equal(true); - expect(user.stats.str).to.equal(34); - expect(user.stats.points).to.equal(0); - }); -}); diff --git a/test/common/ops/equip.js b/test/common/ops/equip.js deleted file mode 100644 index 8641aa102e..0000000000 --- a/test/common/ops/equip.js +++ /dev/null @@ -1,87 +0,0 @@ -/* eslint-disable camelcase */ -import equip from '../../../common/script/ops/equip'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import content from '../../../common/script/content/index'; - -describe('shared.ops.equip', () => { - let user; - - beforeEach(() => { - user = generateUser({ - items: { - gear: { - owned: { - weapon_warrior_0: true, - weapon_warrior_1: true, - weapon_warrior_2: true, - weapon_wizard_1: true, - weapon_wizard_2: true, - shield_base_0: true, - shield_warrior_1: true, - }, - equipped: { - weapon: 'weapon_warrior_0', - shield: 'shield_base_0', - }, - }, - }, - stats: {gp: 200}, - }); - }); - - context('Gear', () => { - it('should not send a message if a weapon is equipped while only having zero or one weapons equipped', () => { - equip(user, {params: {key: 'weapon_warrior_1'}}); - - // one-handed to one-handed - let [, message] = equip(user, {params: {key: 'weapon_warrior_2'}}); - expect(message).to.not.exists; - - // one-handed to two-handed - [, message] = equip(user, {params: {key: 'weapon_wizard_1'}}); - expect(message).to.not.exists; - - // two-handed to two-handed - [, message] = equip(user, {params: {key: 'weapon_wizard_2'}}); - expect(message).to.not.exists; - - // two-handed to one-handed - [, message] = equip(user, {params: {key: 'weapon_warrior_2'}}); - expect(message).to.not.exists; - }); - - it('should send messages if equipping a two-hander causes the off-hander to be unequipped', () => { - equip(user, {params: {key: 'weapon_warrior_1'}}); - equip(user, {params: {key: 'shield_warrior_1'}}); - - // equipping two-hander - let [data, message] = equip(user, {params: {key: 'weapon_wizard_1'}}); - let weapon = content.gear.flat.weapon_wizard_1; - let item = content.gear.flat.shield_warrior_1; - - let res = {data, message}; - expect(res).to.eql({ - message: i18n.t('messageTwoHandedEquip', {twoHandedText: weapon.text(), offHandedText: item.text()}), - data: user.items, - }); - }); - - it('should send messages if equipping an off-hand item causes a two-handed weapon to be unequipped', () => { - // equipping two-hander - equip(user, {params: {key: 'weapon_wizard_1'}}); - let weapon = content.gear.flat.weapon_wizard_1; - let shield = content.gear.flat.shield_warrior_1; - - let [data, message] = equip(user, {params: {key: 'shield_warrior_1'}}); - - let res = {data, message}; - expect(res).to.eql({ - message: i18n.t('messageTwoHandedUnequip', {twoHandedText: weapon.text(), offHandedText: shield.text()}), - data: user.items, - }); - }); - }); -}); diff --git a/test/common/ops/feed.js b/test/common/ops/feed.js deleted file mode 100644 index 0b726f5ec7..0000000000 --- a/test/common/ops/feed.js +++ /dev/null @@ -1,216 +0,0 @@ -import feed from '../../../common/script/ops/feed'; -import content from '../../../common/script/content'; -import { - BadRequest, - NotAuthorized, - NotFound, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.feed', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - context('failure conditions', () => { - it('does not allow feeding without specifying pet and food', (done) => { - try { - feed(user); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('missingPetFoodFeed')); - done(); - } - }); - - it('does not allow feeding if pet name format is invalid', (done) => { - try { - feed(user, {params: {pet: 'invalid', food: 'food'}}); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('invalidPetName')); - done(); - } - }); - - it('does not allow feeding if food does not exists', (done) => { - try { - feed(user, {params: {pet: 'valid-pet', food: 'invalid food name'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.equal(i18n.t('messageFoodNotFound')); - done(); - } - }); - - it('does not allow feeding if pet is not owned', (done) => { - try { - feed(user, {params: {pet: 'not-owned', food: 'Meat'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.equal(i18n.t('messagePetNotFound')); - done(); - } - }); - - it('does not allow feeding if food is not owned', (done) => { - user.items.pets['Wolf-Base'] = 5; - try { - feed(user, {params: {pet: 'Wolf-Base', food: 'Meat'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.equal(i18n.t('messageFoodNotFound')); - done(); - } - }); - - it('does not allow feeding of special pets', (done) => { - user.items.pets['Wolf-Veteran'] = 5; - user.items.food.Meat = 1; - try { - feed(user, {params: {pet: 'Wolf-Veteran', food: 'Meat'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageCannotFeedPet')); - done(); - } - }); - - it('does not allow feeding of mounts', (done) => { - user.items.pets['Wolf-Base'] = -1; - user.items.mounts['Wolf-Base'] = true; - user.items.food.Meat = 1; - try { - feed(user, {params: {pet: 'Wolf-Base', food: 'Meat'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageAlreadyMount')); - done(); - } - }); - }); - - context('successful feeding', () => { - it('evolves the pet if the food is a Saddle', () => { - user.items.pets['Wolf-Base'] = 5; - user.items.food.Saddle = 2; - user.items.currentPet = 'Wolf-Base'; - let [egg, potion] = 'Wolf-Base'.split('-'); - - let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; - let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - - let [data, message] = feed(user, {params: {pet: 'Wolf-Base', food: 'Saddle'}}); - expect(data).to.eql(user.items.pets['Wolf-Base']); - expect(message).to.eql(i18n.t('messageEvolve', { - egg: i18n.t('petName', { - potion: potionText, - egg: eggText, - }), - })); - - expect(user.items.food.Saddle).to.equal(1); - expect(user.items.pets['Wolf-Base']).to.equal(-1); - expect(user.items.mounts['Wolf-Base']).to.equal(true); - expect(user.items.currentPet).to.equal(''); - }); - - it('enjoys the food', () => { - user.items.pets['Wolf-Base'] = 5; - user.items.food.Meat = 2; - - let food = content.food.Meat; - let [egg, potion] = 'Wolf-Base'.split('-'); - let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; - let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - - let [data, message] = feed(user, {params: {pet: 'Wolf-Base', food: 'Meat'}}); - expect(data).to.eql(user.items.pets['Wolf-Base']); - expect(message).to.eql(i18n.t('messageLikesFood', { - egg: i18n.t('petName', { - potion: potionText, - egg: eggText, - }), - foodText: food.text(), - })); - - expect(user.items.food.Meat).to.equal(1); - expect(user.items.pets['Wolf-Base']).to.equal(10); - }); - - it('enjoys the food (premium potion)', () => { - user.items.pets['Wolf-Spooky'] = 5; - user.items.food.Milk = 2; - - let food = content.food.Milk; - let [egg, potion] = 'Wolf-Spooky'.split('-'); - let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; - let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - - let [data, message] = feed(user, {params: {pet: 'Wolf-Spooky', food: 'Milk'}}); - expect(data).to.eql(user.items.pets['Wolf-Spooky']); - expect(message).to.eql(i18n.t('messageLikesFood', { - egg: i18n.t('petName', { - potion: potionText, - egg: eggText, - }), - foodText: food.text(), - })); - - expect(user.items.food.Milk).to.equal(1); - expect(user.items.pets['Wolf-Spooky']).to.equal(10); - }); - - it('does not like the food', () => { - user.items.pets['Wolf-Base'] = 5; - user.items.food.Milk = 2; - - let food = content.food.Milk; - let [egg, potion] = 'Wolf-Base'.split('-'); - let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; - let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - - let [data, message] = feed(user, {params: {pet: 'Wolf-Base', food: 'Milk'}}); - expect(data).to.eql(user.items.pets['Wolf-Base']); - expect(message).to.eql(i18n.t('messageDontEnjoyFood', { - egg: i18n.t('petName', { - potion: potionText, - egg: eggText, - }), - foodText: food.text(), - })); - - expect(user.items.food.Milk).to.equal(1); - expect(user.items.pets['Wolf-Base']).to.equal(7); - }); - - it('evolves the pet into a mount when feeding user.items.pets[pet] >= 50', () => { - user.items.pets['Wolf-Base'] = 49; - user.items.food.Milk = 2; - user.items.currentPet = 'Wolf-Base'; - - let [egg, potion] = 'Wolf-Base'.split('-'); - let potionText = content.hatchingPotions[potion] ? content.hatchingPotions[potion].text() : potion; - let eggText = content.eggs[egg] ? content.eggs[egg].text() : egg; - - let [data, message] = feed(user, {params: {pet: 'Wolf-Base', food: 'Milk'}}); - expect(data).to.eql(user.items.pets['Wolf-Base']); - expect(message).to.eql(i18n.t('messageEvolve', { - egg: i18n.t('petName', { - potion: potionText, - egg: eggText, - }), - })); - - expect(user.items.food.Milk).to.equal(1); - expect(user.items.pets['Wolf-Base']).to.equal(-1); - expect(user.items.mounts['Wolf-Base']).to.equal(true); - expect(user.items.currentPet).to.equal(''); - }); - }); -}); diff --git a/test/common/ops/hatch.js b/test/common/ops/hatch.js deleted file mode 100644 index 87131db13b..0000000000 --- a/test/common/ops/hatch.js +++ /dev/null @@ -1,147 +0,0 @@ -import hatch from '../../../common/script/ops/hatch'; -import { - BadRequest, - NotAuthorized, - NotFound, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.hatch', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - context('Pet Hatching', () => { - context('failure conditions', () => { - it('does not allow hatching without specifying egg and potion', () => { - user.items.pets = {}; - try { - hatch(user); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('missingEggHatchingPotionHatch')); - expect(user.items.pets).to.be.empty; - } - }); - - it('does not allow hatching if user lacks specified egg', (done) => { - user.items.eggs.Wolf = 1; - user.items.hatchingPotions.Base = 1; - user.items.pets = {}; - try { - hatch(user, {params: {egg: 'Dragon', hatchingPotion: 'Base'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.equal(i18n.t('messageMissingEggPotion')); - expect(user.items.pets).to.be.empty; - expect(user.items.eggs.Wolf).to.equal(1); - expect(user.items.hatchingPotions.Base).to.equal(1); - done(); - } - }); - - it('does not allow hatching if user lacks specified hatching potion', (done) => { - user.items.eggs.Wolf = 1; - user.items.hatchingPotions.Base = 1; - user.items.pets = {}; - try { - hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Golden'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.equal(i18n.t('messageMissingEggPotion')); - expect(user.items.pets).to.be.empty; - expect(user.items.eggs.Wolf).to.equal(1); - expect(user.items.hatchingPotions.Base).to.equal(1); - done(); - } - }); - - it('does not allow hatching if user already owns target pet', (done) => { - user.items.eggs = {Wolf: 1}; - user.items.hatchingPotions = {Base: 1}; - user.items.pets = {'Wolf-Base': 10}; - try { - hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Base'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageAlreadyPet')); - expect(user.items.pets).to.eql({'Wolf-Base': 10}); - expect(user.items.eggs).to.eql({Wolf: 1}); - expect(user.items.hatchingPotions).to.eql({Base: 1}); - done(); - } - }); - - it('does not allow hatching quest pet egg using premium potion', (done) => { - user.items.eggs = {Cheetah: 1}; - user.items.hatchingPotions = {Spooky: 1}; - user.items.pets = {}; - try { - hatch(user, {params: {egg: 'Cheetah', hatchingPotion: 'Spooky'}}); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('messageInvalidEggPotionCombo')); - expect(user.items.pets).to.be.empty; - expect(user.items.eggs).to.eql({Cheetah: 1}); - expect(user.items.hatchingPotions).to.eql({Spooky: 1}); - done(); - } - }); - }); - - context('successful hatching', () => { - it('hatches a basic pet', () => { - user.items.eggs = {Wolf: 1}; - user.items.hatchingPotions = {Base: 1}; - user.items.pets = {}; - let [data, message] = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Base'}}); - expect(message).to.equal(i18n.t('messageHatched')); - expect(data).to.eql(user.items); - expect(user.items.pets).to.eql({'Wolf-Base': 5}); - expect(user.items.eggs).to.eql({Wolf: 0}); - expect(user.items.hatchingPotions).to.eql({Base: 0}); - }); - - it('hatches a quest pet', () => { - user.items.eggs = {Cheetah: 1}; - user.items.hatchingPotions = {Base: 1}; - user.items.pets = {}; - let [data, message] = hatch(user, {params: {egg: 'Cheetah', hatchingPotion: 'Base'}}); - expect(message).to.equal(i18n.t('messageHatched')); - expect(data).to.eql(user.items); - expect(user.items.pets).to.eql({'Cheetah-Base': 5}); - expect(user.items.eggs).to.eql({Cheetah: 0}); - expect(user.items.hatchingPotions).to.eql({Base: 0}); - }); - - it('hatches a premium pet', () => { - user.items.eggs = {Wolf: 1}; - user.items.hatchingPotions = {Spooky: 1}; - user.items.pets = {}; - let [data, message] = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Spooky'}}); - expect(message).to.equal(i18n.t('messageHatched')); - expect(data).to.eql(user.items); - expect(user.items.pets).to.eql({'Wolf-Spooky': 5}); - expect(user.items.eggs).to.eql({Wolf: 0}); - expect(user.items.hatchingPotions).to.eql({Spooky: 0}); - }); - - it('hatches a pet previously raised to a mount', () => { - user.items.eggs = {Wolf: 1}; - user.items.hatchingPotions = {Base: 1}; - user.items.pets = {'Wolf-Base': -1}; - let [data, message] = hatch(user, {params: {egg: 'Wolf', hatchingPotion: 'Base'}}); - expect(message).to.eql(i18n.t('messageHatched')); - expect(data).to.eql(user.items); - expect(user.items.pets).to.eql({'Wolf-Base': 5}); - expect(user.items.eggs).to.eql({Wolf: 0}); - expect(user.items.hatchingPotions).to.eql({Base: 0}); - }); - }); - }); -}); diff --git a/test/common/ops/hourglassPurchase.js b/test/common/ops/hourglassPurchase.js deleted file mode 100644 index 98400f82b5..0000000000 --- a/test/common/ops/hourglassPurchase.js +++ /dev/null @@ -1,145 +0,0 @@ -import hourglassPurchase from '../../../common/script/ops/hourglassPurchase'; -import { - BadRequest, - NotAuthorized, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; -import content from '../../../common/script/content/index'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('user.ops.hourglassPurchase', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - context('failure conditions', () => { - it('return error when key is not provided', (done) => { - try { - hourglassPurchase(user, {params: {}}); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.eql(i18n.t('missingKeyParam')); - done(); - } - }); - - it('returns error when type is not provided', (done) => { - try { - hourglassPurchase(user, {params: {key: 'Base'}}); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.eql(i18n.t('missingTypeParam')); - done(); - } - }); - - it('returns error when inccorect type is provided', (done) => { - try { - hourglassPurchase(user, {params: {type: 'notAType', key: 'MantisShrimp-Base'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.eql(i18n.t('typeNotAllowedHourglass', {allowedTypes: _.keys(content.timeTravelStable).toString()})); - done(); - } - }); - - it('does not grant to pets without Mystic Hourglasses', (done) => { - try { - hourglassPurchase(user, {params: {type: 'pets', key: 'MantisShrimp-Base'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.eql(i18n.t('notEnoughHourglasses')); - done(); - } - }); - - it('does not grant to mounts without Mystic Hourglasses', (done) => { - try { - hourglassPurchase(user, {params: {type: 'mounts', key: 'MantisShrimp-Base'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.eql(i18n.t('notEnoughHourglasses')); - done(); - } - }); - - it('does not grant pet that is not part of the Time Travel Stable', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - - try { - hourglassPurchase(user, {params: {type: 'pets', key: 'Wolf-Veteran'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.eql(i18n.t('notAllowedHourglass')); - done(); - } - }); - - it('does not grant mount that is not part of the Time Travel Stable', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - - try { - hourglassPurchase(user, {params: {type: 'mounts', key: 'Orca-Base'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.eql(i18n.t('notAllowedHourglass')); - done(); - } - }); - - it('does not grant pet that has already been purchased', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - user.items.pets = { - 'MantisShrimp-Base': true, - }; - - try { - hourglassPurchase(user, {params: {type: 'pets', key: 'MantisShrimp-Base'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.eql(i18n.t('petsAlreadyOwned')); - done(); - } - }); - - it('does not grant mount that has already been purchased', (done) => { - user.purchased.plan.consecutive.trinkets = 1; - user.items.mounts = { - 'MantisShrimp-Base': true, - }; - - try { - hourglassPurchase(user, {params: {type: 'mounts', key: 'MantisShrimp-Base'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.eql(i18n.t('mountsAlreadyOwned')); - done(); - } - }); - }); - - context('successful purchases', () => { - it('buys a pet', () => { - user.purchased.plan.consecutive.trinkets = 2; - - let [, message] = hourglassPurchase(user, {params: {type: 'pets', key: 'MantisShrimp-Base'}}); - - expect(message).to.eql(i18n.t('hourglassPurchase')); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - expect(user.items.pets).to.eql({'MantisShrimp-Base': 5}); - }); - - it('buys a mount', () => { - user.purchased.plan.consecutive.trinkets = 2; - - let [, message] = hourglassPurchase(user, {params: {type: 'mounts', key: 'MantisShrimp-Base'}}); - expect(message).to.eql(i18n.t('hourglassPurchase')); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); - expect(user.items.mounts).to.eql({'MantisShrimp-Base': true}); - }); - }); -}); diff --git a/test/common/ops/openMysteryItem.js b/test/common/ops/openMysteryItem.js deleted file mode 100644 index 712c032254..0000000000 --- a/test/common/ops/openMysteryItem.js +++ /dev/null @@ -1,38 +0,0 @@ -import openMysteryItem from '../../../common/script/ops/openMysteryItem'; -import { - generateUser, -} from '../../helpers/common.helper'; -import { - BadRequest, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; - -describe('shared.ops.openMysteryItem', () => { - let user; - - beforeEach(() => { - user = generateUser(); - }); - - it('returns error when item key is empty', (done) => { - try { - openMysteryItem(user); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('mysteryItemIsEmpty')); - done(); - } - }); - - it('opens mystery item', () => { - let mysteryItemKey = 'eyewear_special_summerRogue'; - - user.purchased.plan.mysteryItems = [mysteryItemKey]; - - let [data, message] = openMysteryItem(user); - - expect(user.items.gear.owned[mysteryItemKey]).to.be.true; - expect(message).to.equal(i18n.t('mysteryItemOpened')); - expect(data).to.equal(user.items.gear.owned); - }); -}); diff --git a/test/common/ops/purchase.js b/test/common/ops/purchase.js deleted file mode 100644 index 60227f21b2..0000000000 --- a/test/common/ops/purchase.js +++ /dev/null @@ -1,194 +0,0 @@ -import purchase from '../../../common/script/ops/purchase'; -import planGemLimits from '../../../common/script/libs/planGemLimits'; -import { - BadRequest, - NotAuthorized, - NotFound, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.purchase', () => { - let user; - let goldPoints = 40; - let gemsBought = 40; - - before(() => { - user = generateUser({'stats.class': 'rogue'}); - }); - - context('failure conditions', () => { - it('returns an error when type is not provided', (done) => { - try { - purchase(user, {params: {}}); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('typeRequired')); - done(); - } - }); - - it('returns an error when key is not provided', (done) => { - try { - purchase(user, {params: {type: 'gems'}}); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('keyRequired')); - done(); - } - }); - - it('prevents unsubscribed user from buying gems', (done) => { - try { - purchase(user, {params: {type: 'gems', key: 'gem'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('mustSubscribeToPurchaseGems')); - done(); - } - }); - - it('prevents user with not enough gold from buying gems', (done) => { - user.purchased.plan.customerId = 'customer-id'; - - try { - purchase(user, {params: {type: 'gems', key: 'gem'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageNotEnoughGold')); - done(); - } - }); - - it('prevents user that have reached the conversion cap from buying gems', (done) => { - user.stats.gp = goldPoints; - user.purchased.plan.gemsBought = gemsBought; - - try { - purchase(user, {params: {type: 'gems', key: 'gem'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('reachedGoldToGemCap', {convCap: planGemLimits.convCap})); - done(); - } - }); - - it('returns error when unknown type is provided', (done) => { - try { - purchase(user, {params: {type: 'randomType', key: 'gem'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.equal(i18n.t('notAccteptedType')); - done(); - } - }); - - it('returns error when user attempts to purchase a piece of gear they own', (done) => { - user.items.gear.owned['shield_rogue_1'] = true; // eslint-disable-line dot-notation - - try { - purchase(user, {params: {type: 'gear', key: 'shield_rogue_1'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('alreadyHave')); - done(); - } - }); - - it('returns error when unknown item is requested', (done) => { - try { - purchase(user, {params: {type: 'gear', key: 'randomKey'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.equal(i18n.t('contentKeyNotFound', {type: 'gear'})); - done(); - } - }); - - it('returns error when user does not have permission to buy an item', (done) => { - try { - purchase(user, {params: {type: 'gear', key: 'eyewear_mystery_301405'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('messageNotAvailable')); - done(); - } - }); - - it('returns error when user does not have enough gems to buy an item', (done) => { - try { - purchase(user, {params: {type: 'gear', key: 'headAccessory_special_wolfEars'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('notEnoughGems')); - done(); - } - }); - }); - - context('successful purchase', () => { - let userGemAmount = 10; - - before(() => { - user.balance = userGemAmount; - user.stats.gp = goldPoints; - user.purchased.plan.gemsBought = 0; - }); - - it('purchases gems', () => { - let [, message] = purchase(user, {params: {type: 'gems', key: 'gem'}}); - - expect(message).to.equal(i18n.t('plusOneGem')); - expect(user.balance).to.equal(userGemAmount + 0.25); - expect(user.purchased.plan.gemsBought).to.equal(1); - expect(user.stats.gp).to.equal(goldPoints - planGemLimits.convRate); - }); - - it('purchases eggs', () => { - let type = 'eggs'; - let key = 'Wolf'; - - purchase(user, {params: {type, key}}); - - expect(user.items[type][key]).to.equal(1); - }); - - it('purchases hatchingPotions', () => { - let type = 'hatchingPotions'; - let key = 'Base'; - - purchase(user, {params: {type, key}}); - - expect(user.items[type][key]).to.equal(1); - }); - - it('purchases food', () => { - let type = 'food'; - let key = 'Meat'; - - purchase(user, {params: {type, key}}); - - expect(user.items[type][key]).to.equal(1); - }); - - it('purchases quests', () => { - let type = 'quests'; - let key = 'gryphon'; - - purchase(user, {params: {type, key}}); - - expect(user.items[type][key]).to.equal(1); - }); - - it('purchases gear', () => { - let type = 'gear'; - let key = 'headAccessory_special_tigerEars'; - - purchase(user, {params: {type, key}}); - - expect(user.items.gear.owned[key]).to.be.true; - }); - }); -}); diff --git a/test/common/ops/readCard.js b/test/common/ops/readCard.js deleted file mode 100644 index 5d771ab0d6..0000000000 --- a/test/common/ops/readCard.js +++ /dev/null @@ -1,48 +0,0 @@ -import readCard from '../../../common/script/ops/readCard'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import { - BadRequest, - NotAuthorized, -} from '../../../common/script/libs/errors'; - -describe('shared.ops.readCard', () => { - let user; - let cardType = 'greeting'; - - beforeEach(() => { - user = generateUser(); - user.items.special[`${cardType}Received`] = [true]; - user.flags.cardReceived = true; - }); - - it('returns an error when cardType is not provided', (done) => { - try { - readCard(user); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('cardTypeRequired')); - done(); - } - }); - - it('returns an error when unknown cardType is provided', (done) => { - try { - readCard(user, {params: {cardType: 'randomCardType'}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('cardTypeNotAllowed')); - done(); - } - }); - - it('reads a card', () => { - let [, message] = readCard(user, {params: {cardType: 'greeting'}}); - - expect(message).to.equal(i18n.t('readCard', {cardType})); - expect(user.items.special[`${cardType}Received`]).to.be.empty; - expect(user.flags.cardReceived).to.be.false; - }); -}); diff --git a/test/common/ops/rebirth.js b/test/common/ops/rebirth.js deleted file mode 100644 index 9916144669..0000000000 --- a/test/common/ops/rebirth.js +++ /dev/null @@ -1,236 +0,0 @@ -import rebirth from '../../../common/script/ops/rebirth'; -import i18n from '../../../common/script/i18n'; -import { MAX_LEVEL } from '../../../common/script/constants'; -import { - generateUser, - generateHabit, - generateDaily, - generateTodo, - generateReward, -} from '../../helpers/common.helper'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; - -describe('shared.ops.rebirth', () => { - let user; - let animal = 'Wolf-Base'; - let userStats = ['per', 'int', 'con', 'str', 'points', 'gp', 'exp', 'mp']; - let tasks = []; - - beforeEach(() => { - user = generateUser(); - user.balance = 2; - tasks = [generateHabit(), generateDaily(), generateTodo(), generateReward()]; - }); - - it('returns an error when user balance is too low and user is less than max level', (done) => { - user.balance = 0; - - try { - rebirth(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('notEnoughGems')); - done(); - } - }); - - it('rebirths a user with enough gems', () => { - let [, message] = rebirth(user); - - expect(message).to.equal(i18n.t('rebirthComplete')); - }); - - it('rebirths a user with not enough gems but max level', () => { - user.balance = 0; - user.stats.lvl = MAX_LEVEL; - - let [, message] = rebirth(user); - - expect(message).to.equal(i18n.t('rebirthComplete')); - }); - - it('rebirths a user with not enough gems but more than max level', () => { - user.balance = 0; - user.stats.lvl = MAX_LEVEL + 1; - - let [, message] = rebirth(user); - - expect(message).to.equal(i18n.t('rebirthComplete')); - }); - - it('resets user\'s tasks values except for rewards to 0', () => { - tasks[0].value = 1; - tasks[1].value = 1; - tasks[2].value = 1; - tasks[3].value = 1; // Reward - - rebirth(user, tasks); - - expect(tasks[0].value).to.equal(0); - expect(tasks[1].value).to.equal(0); - expect(tasks[2].value).to.equal(0); - expect(tasks[3].value).to.equal(1); // Reward - }); - - it('resets user\'s daily streaks to 0', () => { - tasks[1].streak = 1; // Daily - - rebirth(user, tasks); - - expect(tasks[1].streak).to.equal(0); - }); - - it('resets a user\'s buffs', () => { - user.stats.buffs = {test: 'test'}; - - rebirth(user); - - expect(user.stats.buffs).to.be.empty; - }); - - it('resets a user\'s health points', () => { - user.stats.hp = 40; - - rebirth(user); - - expect(user.stats.hp).to.equal(50); - }); - - it('resets a user\'s class', () => { - user.stats.class = 'rouge'; - - rebirth(user); - - expect(user.stats.class).to.equal('warrior'); - }); - - it('resets a user\'s stats', () => { - user.stats.class = 'rouge'; - _.each(userStats, function setUsersStats (value) { - user.stats[value] = 10; - }); - - rebirth(user); - - _.each(userStats, function resetUserStats (value) { - user.stats[value] = 0; - }); - }); - - it('resets a user\'s gear', () => { - let gearReset = { - armor: 'armor_base_0', - weapon: 'weapon_warrior_0', - head: 'head_base_0', - shield: 'shield_base_0', - }; - - rebirth(user); - - expect(user.items.gear.equipped).to.deep.equal(gearReset); - expect(user.items.gear.costume).to.deep.equal(gearReset); - expect(user.preferences.costume).to.be.false; - }); - - it('resets a user\'s gear owned', () => { - user.items.gear.owned.weapon_warrior_1 = true; // eslint-disable-line camelcase - rebirth(user); - - expect(user.items.gear.owned.weapon_warrior_1).to.be.false; - expect(user.items.gear.owned.weapon_warrior_0).to.be.true; - }); - - it('resets a user\'s current pet', () => { - user.items.pets[animal] = true; - user.items.currentPet = animal; - rebirth(user); - - expect(user.items.currentPet).to.be.empty; - }); - - it('resets a user\'s current mount', () => { - user.items.mounts[animal] = true; - user.items.currentMount = animal; - rebirth(user); - - expect(user.items.currentMount).to.be.empty; - }); - - it('resets a user\'s flags', () => { - user.flags.itemsEnabled = true; - user.flags.dropsEnabled = true; - user.flags.classSelected = true; - user.flags.rebirthEnabled = true; - user.flags.levelDrops = {test: 'test'}; - - rebirth(user); - - expect(user.flags.itemsEnabled).to.be.false; - expect(user.flags.dropsEnabled).to.be.false; - expect(user.flags.classSelected).to.be.false; - expect(user.flags.rebirthEnabled).to.be.false; - expect(user.flags.levelDrops).to.be.empty; - }); - - it('does not reset rebirthEnabled if user has beastMaster', () => { - user.achievements.beastMaster = 1; - user.flags.rebirthEnabled = true; - - rebirth(user); - - expect(user.flags.rebirthEnabled).to.be.true; - }); - - it('sets rebirth achievement', () => { - rebirth(user); - - expect(user.achievements.rebirths).to.equal(1); - expect(user.achievements.rebirthLevel).to.equal(user.stats.lvl); - }); - - it('increments rebirth achievements', () => { - user.stats.lvl = 2; - user.achievements.rebirths = 1; - user.achievements.rebirthLevel = 1; - - rebirth(user); - - expect(user.achievements.rebirths).to.equal(2); - expect(user.achievements.rebirthLevel).to.equal(2); - }); - - it('does not increment rebirth achievements when level is lower than previous', () => { - user.stats.lvl = 2; - user.achievements.rebirths = 1; - user.achievements.rebirthLevel = 3; - - rebirth(user); - - expect(user.achievements.rebirths).to.equal(1); - expect(user.achievements.rebirthLevel).to.equal(3); - }); - - it('always increments rebirth achievements when level is MAX_LEVEL', () => { - user.stats.lvl = MAX_LEVEL; - user.achievements.rebirths = 1; - user.achievements.rebirthLevel = MAX_LEVEL + 1; // this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test - - rebirth(user); - - expect(user.achievements.rebirths).to.equal(2); - expect(user.achievements.rebirthLevel).to.equal(MAX_LEVEL); - }); - - it('always increments rebirth achievements when level is greater than MAX_LEVEL', () => { - user.stats.lvl = MAX_LEVEL + 1; - user.achievements.rebirths = 1; - user.achievements.rebirthLevel = MAX_LEVEL + 2; // this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test - - rebirth(user); - - expect(user.achievements.rebirths).to.equal(2); - expect(user.achievements.rebirthLevel).to.equal(MAX_LEVEL); - }); -}); diff --git a/test/common/ops/releaseBoth.js b/test/common/ops/releaseBoth.js deleted file mode 100644 index 41e1bf6efb..0000000000 --- a/test/common/ops/releaseBoth.js +++ /dev/null @@ -1,98 +0,0 @@ -import releaseBoth from '../../../common/script/ops/releaseBoth'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; - -describe('shared.ops.releaseBoth', () => { - let user; - let animal = 'Wolf-Base'; - - beforeEach(() => { - user = generateUser(); - user.items.currentMount = animal; - user.items.currentPet = animal; - user.items.pets[animal] = 5; - user.items.mounts[animal] = true; - user.balance = 1.5; - }); - - it('returns an error when user balance is too low and user does not have triadBingo', (done) => { - user.balance = 0; - - try { - releaseBoth(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('notEnoughGems')); - done(); - } - }); - - it('grants triad bingo with gems', () => { - let [, message] = releaseBoth(user); - - expect(message).to.equal(i18n.t('mountsAndPetsReleased')); - expect(user.achievements.triadBingoCount).to.equal(1); - }); - - it('grants triad bingo without gems', () => { - user.balance = 0; - user.achievements.triadBingo = 1; - user.achievements.triadBingoCount = 1; - - let [, message] = releaseBoth(user); - - expect(message).to.equal(i18n.t('mountsAndPetsReleased')); - expect(user.achievements.triadBingoCount).to.equal(2); - }); - - it('releases pets', () => { - let [, message] = releaseBoth(user); - - expect(message).to.equal(i18n.t('mountsAndPetsReleased')); - expect(user.items.pets[animal]).to.be.empty; - expect(user.items.mounts[animal]).to.equal(null); - }); - - it('releases mounts', () => { - let [, message] = releaseBoth(user); - - expect(message).to.equal(i18n.t('mountsAndPetsReleased')); - expect(user.items.mounts[animal]).to.equal(null); - }); - - it('removes currentPet', () => { - releaseBoth(user); - - expect(user.items.currentMount).to.be.empty; - expect(user.items.currentPet).to.be.empty; - }); - - it('removes currentMount', () => { - releaseBoth(user); - - expect(user.items.currentMount).to.be.empty; - }); - - it('decreases user\'s balance', () => { - releaseBoth(user); - - expect(user.balance).to.equal(0); - }); - - it('incremenets beastMasterCount', () => { - releaseBoth(user); - - expect(user.achievements.beastMasterCount).to.equal(1); - }); - - it('incremenets mountMasterCount', () => { - releaseBoth(user); - - expect(user.achievements.mountMasterCount).to.equal(1); - }); -}); diff --git a/test/common/ops/releaseMounts.js b/test/common/ops/releaseMounts.js deleted file mode 100644 index 29cb3cf6ac..0000000000 --- a/test/common/ops/releaseMounts.js +++ /dev/null @@ -1,57 +0,0 @@ -import releaseMounts from '../../../common/script/ops/releaseMounts'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; - -describe('shared.ops.releaseMounts', () => { - let user; - let animal = 'Wolf-Base'; - - beforeEach(() => { - user = generateUser(); - user.items.currentMount = animal; - user.items.mounts[animal] = true; - user.balance = 1; - }); - - it('returns an error when user balance is too low', (done) => { - user.balance = 0; - - try { - releaseMounts(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('notEnoughGems')); - done(); - } - }); - - it('releases mounts', () => { - let [, message] = releaseMounts(user); - - expect(message).to.equal(i18n.t('mountsReleased')); - expect(user.items.mounts[animal]).to.equal(null); - }); - - it('removes currentMount', () => { - releaseMounts(user); - - expect(user.items.currentMount).to.be.empty; - }); - - it('increases mountMasterCount achievement', () => { - releaseMounts(user); - - expect(user.achievements.mountMasterCount).to.equal(1); - }); - - it('subtracts gems from balance', () => { - releaseMounts(user); - - expect(user.balance).to.equal(0); - }); -}); diff --git a/test/common/ops/releasePets.js b/test/common/ops/releasePets.js deleted file mode 100644 index af175736cf..0000000000 --- a/test/common/ops/releasePets.js +++ /dev/null @@ -1,57 +0,0 @@ -import releasePets from '../../../common/script/ops/releasePets'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; - -describe('shared.ops.releasePets', () => { - let user; - let animal = 'Wolf-Base'; - - beforeEach(() => { - user = generateUser(); - user.items.currentPet = animal; - user.items.pets[animal] = 5; - user.balance = 1; - }); - - it('returns an error when user balance is too low', (done) => { - user.balance = 0; - - try { - releasePets(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('notEnoughGems')); - done(); - } - }); - - it('releases pets', () => { - let [, message] = releasePets(user); - - expect(message).to.equal(i18n.t('petsReleased')); - expect(user.items.pets[animal]).to.equal(0); - }); - - it('removes currentPet', () => { - releasePets(user); - - expect(user.items.currentPet).to.be.empty; - }); - - it('decreases user\'s balance', () => { - releasePets(user); - - expect(user.balance).to.equal(0); - }); - - it('incremenets beastMasterCount', () => { - releasePets(user); - - expect(user.achievements.beastMasterCount).to.equal(1); - }); -}); diff --git a/test/common/ops/reroll.js b/test/common/ops/reroll.js deleted file mode 100644 index 4dc5da70c1..0000000000 --- a/test/common/ops/reroll.js +++ /dev/null @@ -1,63 +0,0 @@ -import reroll from '../../../common/script/ops/reroll'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, - generateDaily, - generateReward, -} from '../../helpers/common.helper'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; - -describe('shared.ops.reroll', () => { - let user; - let tasks = []; - - beforeEach(() => { - user = generateUser(); - user.balance = 1; - tasks = [generateDaily(), generateReward()]; - }); - - it('returns an error when user balance is too low', (done) => { - user.balance = 0; - - try { - reroll(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('notEnoughGems')); - done(); - } - }); - - it('rerolls a user with enough gems', () => { - let [, message] = reroll(user); - - expect(message).to.equal(i18n.t('fortifyComplete')); - }); - - it('reduces a user\'s balance', () => { - reroll(user); - - expect(user.balance).to.equal(0); - }); - - it('resets a user\'s health points', () => { - user.stats.hp = 40; - - reroll(user); - - expect(user.stats.hp).to.equal(50); - }); - - it('resets user\'s taks values except for rewards to 0', () => { - tasks[0].value = 1; - tasks[1].value = 1; - - reroll(user, tasks); - - expect(tasks[0].value).to.equal(0); - expect(tasks[1].value).to.equal(1); - }); -}); diff --git a/test/common/ops/reset.js b/test/common/ops/reset.js deleted file mode 100644 index 50ebf90cb5..0000000000 --- a/test/common/ops/reset.js +++ /dev/null @@ -1,79 +0,0 @@ -import reset from '../../../common/script/ops/reset'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, - generateDaily, - generateHabit, - generateReward, - generateTodo, -} from '../../helpers/common.helper'; - -describe('shared.ops.reset', () => { - let user; - let tasksToRemove; - - beforeEach(() => { - user = generateUser(); - user.balance = 2; - - let habit = generateHabit(); - let todo = generateTodo(); - let daily = generateDaily(); - let reward = generateReward(); - - user.tasksOrder.habits = [habit._id]; - user.tasksOrder.todos = [todo._id]; - user.tasksOrder.dailys = [daily._id]; - user.tasksOrder.rewards = [reward._id]; - - tasksToRemove = [habit, todo, daily, reward]; - }); - - - it('resets a user', () => { - let [, message] = reset(user); - - expect(message).to.equal(i18n.t('resetComplete')); - }); - - it('resets user\'s health', () => { - user.stats.hp = 40; - - reset(user); - - expect(user.stats.hp).to.equal(50); - }); - - it('resets user\'s level', () => { - user.stats.lvl = 2; - - reset(user); - - expect(user.stats.lvl).to.equal(1); - }); - - it('resets user\'s gold', () => { - user.stats.gp = 20; - - reset(user); - - expect(user.stats.gp).to.equal(0); - }); - - it('resets user\'s exp', () => { - user.stats.exp = 20; - - reset(user); - - expect(user.stats.exp).to.equal(0); - }); - - it('resets user\'s tasksOrder', () => { - reset(user, tasksToRemove); - - expect(user.tasksOrder.habits).to.be.empty; - expect(user.tasksOrder.todos).to.be.empty; - expect(user.tasksOrder.dailys).to.be.empty; - expect(user.tasksOrder.rewards).to.be.empty; - }); -}); diff --git a/test/common/ops/revive.js b/test/common/ops/revive.js deleted file mode 100644 index efd6968b42..0000000000 --- a/test/common/ops/revive.js +++ /dev/null @@ -1,91 +0,0 @@ -import revive from '../../../common/script/ops/revive'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; -import content from '../../../common/script/content/index'; - -describe('shared.ops.revive', () => { - let user; - - beforeEach(() => { - user = generateUser(); - user.stats.hp = 0; - }); - - it('returns an error when user is not dead', (done) => { - user.stats.hp = 10; - - try { - revive(user); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('cannotRevive')); - done(); - } - }); - - it('resets user\'s hp, exp and gp', () => { - user.stats.exp = 100; - user.stats.gp = 100; - - revive(user); - - expect(user.stats.hp).to.equal(50); - expect(user.stats.exp).to.equal(0); - expect(user.stats.gp).to.equal(0); - }); - - it('decreases user\'s level', () => { - user.stats.lvl = 2; - revive(user); - - expect(user.stats.lvl).to.equal(1); - }); - - it('decreases a stat', () => { - user.stats.str = 2; - revive(user); - - expect(user.stats.str).to.equal(1); - }); - - it('removes a random item from user gear owned', () => { - let weaponKey = 'weapon_warrior_0'; - user.items.gear.owned[weaponKey] = true; - - let [, message] = revive(user); - - expect(message).to.equal(i18n.t('messageLostItem', { itemText: content.gear.flat[weaponKey].text()})); - expect(user.items.gear.owned[weaponKey]).to.be.false; - }); - - it('removes a random item from user gear equipped', () => { - let weaponKey = 'weapon_warrior_0'; - let itemToLose = content.gear.flat[weaponKey]; - - user.items.gear.owned[weaponKey] = true; - user.items.gear.equipped[itemToLose.type] = itemToLose.key; - - let [, message] = revive(user); - - expect(message).to.equal(i18n.t('messageLostItem', { itemText: itemToLose.text()})); - expect(user.items.gear.equipped[itemToLose.type]).to.equal(`${itemToLose.type}_base_0`); - }); - - it('removes a random item from user gear costume', () => { - let weaponKey = 'weapon_warrior_0'; - let itemToLose = content.gear.flat[weaponKey]; - - user.items.gear.owned[weaponKey] = true; - user.items.gear.costume[itemToLose.type] = itemToLose.key; - - let [, message] = revive(user); - - expect(message).to.equal(i18n.t('messageLostItem', { itemText: itemToLose.text()})); - expect(user.items.gear.costume[itemToLose.type]).to.equal(`${itemToLose.type}_base_0`); - }); -}); diff --git a/test/common/ops/scoreTask.test.js b/test/common/ops/scoreTask.test.js deleted file mode 100644 index 727b316d62..0000000000 --- a/test/common/ops/scoreTask.test.js +++ /dev/null @@ -1,203 +0,0 @@ -import scoreTask from '../../../common/script/ops/scoreTask'; -import { - generateUser, - generateDaily, - generateHabit, - generateTodo, - generateReward, -} from '../../helpers/common.helper'; -import common from '../../../common'; -import i18n from '../../../common/script/i18n'; -import { - NotAuthorized, -} from '../../../common/script/libs/errors'; - -let EPSILON = 0.0001; // negligible distance between datapoints - -/* Helper Functions */ -let rewrapUser = (user) => { - user._wrapped = false; - common.wrap(user); - return user; -}; - -let beforeAfter = () => { - let beforeUser = generateUser(); - let afterUser = _.cloneDeep(beforeUser); - rewrapUser(afterUser); - - return { - beforeUser, - afterUser, - }; -}; - -let expectGainedPoints = (beforeUser, afterUser, beforeTask, afterTask) => { - expect(afterUser.stats.hp).to.eql(50); - expect(afterUser.stats.exp).to.be.greaterThan(beforeUser.stats.exp); - expect(afterUser.stats.gp).to.be.greaterThan(beforeUser.stats.gp); - expect(afterTask.value).to.be.greaterThan(beforeTask.value); - if (afterTask.type === 'habit') { - expect(afterTask.history).to.have.length(1); - } -}; - -let expectClosePoints = (beforeUser, afterUser, beforeTask, task) => { - expect(Math.abs(afterUser.stats.exp - beforeUser.stats.exp)).to.be.lessThan(EPSILON); - expect(Math.abs(afterUser.stats.gp - beforeUser.stats.gp)).to.be.lessThan(EPSILON); - expect(Math.abs(task.value - beforeTask.value)).to.be.lessThan(EPSILON); -}; - -let _expectRoughlyEqualDates = (date1, date2) => { - expect(date1.toString()).to.eql(date2.toString()); -}; - -describe('shared.ops.scoreTask', () => { - let ref; - - beforeEach(() => { - ref = beforeAfter(); - }); - - it('throws an error when scoring a reward if user does not have enough gold', (done) => { - let reward = generateReward({ userId: ref.afterUser._id, text: 'some reward', value: 100 }); - try { - scoreTask({ user: ref.afterUser, task: reward }); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.eql(i18n.t('messageNotEnoughGold')); - done(); - } - }); - - it('checks that the streak parameters affects the score', () => { - let task = generateDaily({ userId: ref.afterUser._id, text: 'task to check streak' }); - scoreTask({ user: ref.afterUser, task, direction: 'up', cron: false }); - scoreTask({ user: ref.afterUser, task, direction: 'up', cron: false }); - expect(task.streak).to.eql(2); - }); - - it('completes when the task direction is up', () => { - let task = generateTodo({ userId: ref.afterUser._id, text: 'todo to complete', cron: false }); - scoreTask({ user: ref.afterUser, task, direction: 'up' }); - expect(task.completed).to.eql(true); - _expectRoughlyEqualDates(task.dateCompleted, new Date()); - }); - - it('uncompletes when the task direction is down', () => { - let task = generateTodo({ userId: ref.afterUser._id, text: 'todo to complete', cron: false }); - scoreTask({ user: ref.afterUser, task, direction: 'down' }); - expect(task.completed).to.eql(false); - expect(task.dateCompleted).to.not.exist; - }); - - describe('verifies that times parameter in scoring works', () => { - let habit; - - beforeEach(() => { - ref = beforeAfter(); - habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); - }); - - it('works', () => { - let delta1, delta2, delta3; - - delta1 = scoreTask({ user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false }); - - ref = beforeAfter(); - habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); - - delta2 = scoreTask({ user: ref.afterUser, task: habit, direction: 'up', times: 4, cron: false }); - - ref = beforeAfter(); - habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); - - delta3 = scoreTask({ user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false }); - - expect(Math.abs(delta1 - delta2)).to.be.greaterThan(EPSILON); - expect(Math.abs(delta1 - delta3)).to.be.lessThan(EPSILON); - }); - }); - - describe('scores', () => { - let options = {}; - let habit; - let freshDaily, daily; - let freshTodo, todo; - - beforeEach(() => { - ref = beforeAfter(options); - habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); - freshDaily = generateDaily({ userId: ref.afterUser._id, text: 'some daily' }); - daily = generateDaily({ userId: ref.afterUser._id, text: 'some daily' }); - freshTodo = generateTodo({ userId: ref.afterUser._id, text: 'some todo' }); - todo = generateTodo({ userId: ref.afterUser._id, text: 'some todo' }); - - expect(habit.history.length).to.eql(0); - - // before and after are the same user - expect(ref.beforeUser._id).to.exist; - expect(ref.beforeUser._id).to.eql(ref.afterUser._id); - }); - - context('habits', () => { - it('up', () => { - options = { user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false }; - scoreTask(options); - - expect(habit.history.length).to.eql(1); - expect(habit.value).to.be.greaterThan(0); - - expect(ref.afterUser.stats.hp).to.eql(50); - expect(ref.afterUser.stats.exp).to.be.greaterThan(ref.beforeUser.stats.exp); - expect(ref.afterUser.stats.gp).to.be.greaterThan(ref.beforeUser.stats.gp); - }); - - it('down', () => { - scoreTask({user: ref.afterUser, task: habit, direction: 'down', times: 5, cron: false}, {}); - - expect(habit.history.length).to.eql(1); - expect(habit.value).to.be.lessThan(0); - - expect(ref.afterUser.stats.hp).to.be.lessThan(ref.beforeUser.stats.hp); - expect(ref.afterUser.stats.exp).to.eql(0); - expect(ref.afterUser.stats.gp).to.eql(0); - }); - }); - - context('dailys', () => { - it('up', () => { - expect(daily.completed).to.not.eql(true); - scoreTask({user: ref.afterUser, task: daily, direction: 'up'}); - expectGainedPoints(ref.beforeUser, ref.afterUser, freshDaily, daily); - expect(daily.completed).to.eql(true); - }); - - it('up, down', () => { - scoreTask({user: ref.afterUser, task: daily, direction: 'up'}); - scoreTask({user: ref.afterUser, task: daily, direction: 'down'}); - expectClosePoints(ref.beforeUser, ref.afterUser, freshDaily, daily); - }); - - it('sets completed = false on direction = down', () => { - daily.completed = true; - expect(daily.completed).to.not.eql(false); - scoreTask({user: ref.afterUser, task: daily, direction: 'down'}); - expect(daily.completed).to.eql(false); - }); - }); - - context('todos', () => { - it('up', () => { - scoreTask({user: ref.afterUser, task: todo, direction: 'up'}); - expectGainedPoints(ref.beforeUser, ref.afterUser, freshTodo, todo); - }); - - it('up, down', () => { - scoreTask({user: ref.afterUser, task: todo, direction: 'up'}); - scoreTask({user: ref.afterUser, task: todo, direction: 'down'}); - expectClosePoints(ref.beforeUser, ref.afterUser, freshTodo, todo); - }); - }); - }); -}); diff --git a/test/common/ops/sell.js b/test/common/ops/sell.js deleted file mode 100644 index 727302ad9c..0000000000 --- a/test/common/ops/sell.js +++ /dev/null @@ -1,79 +0,0 @@ -import sell from '../../../common/script/ops/sell'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import { - NotAuthorized, - BadRequest, - NotFound, -} from '../../../common/script/libs/errors'; -import content from '../../../common/script/content/index'; - -describe('shared.ops.sell', () => { - let user; - let type = 'eggs'; - let key = 'Wolf'; - let acceptedTypes = ['eggs', 'hatchingPotions', 'food']; - - beforeEach(() => { - user = generateUser(); - user.items[type][key] = 1; - }); - - it('returns an error when type is not provided', (done) => { - try { - sell(user); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('typeRequired')); - done(); - } - }); - - it('returns an error when key is not provided', (done) => { - try { - sell(user, {params: { type } }); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('keyRequired')); - done(); - } - }); - - it('returns an error when non-sellable type is provided', (done) => { - let nonSellableType = 'nonSellableType'; - - try { - sell(user, {params: { type: nonSellableType, key } }); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('typeNotSellable', {acceptedTypes: acceptedTypes.join(', ')})); - done(); - } - }); - - it('returns an error when key is not found with type provided', (done) => { - let fakeKey = 'fakeKey'; - - try { - sell(user, {params: { type, key: fakeKey } }); - } catch (err) { - expect(err).to.be.an.instanceof(NotFound); - expect(err.message).to.equal(i18n.t('userItemsKeyNotFound', {type})); - done(); - } - }); - - it('reduces item count from user', () => { - sell(user, {params: { type, key } }); - - expect(user.items[type][key]).to.equal(0); - }); - - it('increases user\'s gold', () => { - sell(user, {params: { type, key } }); - - expect(user.stats.gp).to.equal(content[type][key].value); - }); -}); diff --git a/test/common/ops/sleep.js b/test/common/ops/sleep.js deleted file mode 100644 index f1e15625c9..0000000000 --- a/test/common/ops/sleep.js +++ /dev/null @@ -1,18 +0,0 @@ -import sleep from '../../../common/script/ops/sleep'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.sleep', () => { - it('toggles user.preferences.sleep', () => { - let user = generateUser(); - - let [res] = sleep(user); - expect(res).to.eql(true); - expect(user.preferences.sleep).to.equal(true); - - let [res2] = sleep(user); - expect(res2).to.eql(false); - expect(user.preferences.sleep).to.equal(false); - }); -}); diff --git a/test/common/ops/unlock.js b/test/common/ops/unlock.js deleted file mode 100644 index ede5fe1d31..0000000000 --- a/test/common/ops/unlock.js +++ /dev/null @@ -1,121 +0,0 @@ -import unlock from '../../../common/script/ops/unlock'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; -import { - NotAuthorized, - BadRequest, -} from '../../../common/script/libs/errors'; - -describe('shared.ops.unlock', () => { - let user; - let unlockPath = 'shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie'; - let unlockGearSetPath = 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars'; - let backgroundUnlockPath = 'background.giant_florals'; - let unlockCost = 1.25; - let usersStartingGems = 5; - - beforeEach(() => { - user = generateUser(); - user.balance = usersStartingGems; - }); - - it('returns an error when path is not provided', (done) => { - try { - unlock(user); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('pathRequired')); - done(); - } - }); - - it('returns an error when user balance is too low', (done) => { - user.balance = 0; - - try { - unlock(user, {query: {path: unlockPath}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('notEnoughGems')); - done(); - } - }); - - it('returns an error when user already owns a full set', (done) => { - try { - unlock(user, {query: {path: unlockPath}}); - unlock(user, {query: {path: unlockPath}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('alreadyUnlocked')); - done(); - } - }); - - // disabled untill fully implemente - xit('returns an error when user already owns items in a full set', (done) => { - try { - unlock(user, {query: {path: unlockPath}}); - unlock(user, {query: {path: unlockPath}}); - } catch (err) { - expect(err).to.be.an.instanceof(NotAuthorized); - expect(err.message).to.equal(i18n.t('alreadyUnlocked')); - done(); - } - }); - - it('equips an item already owned', () => { - expect(user.purchased.background.giant_florals).to.not.exists; - - unlock(user, {query: {path: backgroundUnlockPath}}); - let afterBalance = user.balance; - let response = unlock(user, {query: {path: backgroundUnlockPath}}); - expect(user.balance).to.equal(afterBalance); // do not bill twice - - expect(response.message).to.not.exists; - expect(user.preferences.background).to.equal('giant_florals'); - }); - - it('un-equips an item already equipped', () => { - expect(user.purchased.background.giant_florals).to.not.exists; - - unlock(user, {query: {path: backgroundUnlockPath}}); // unlock - let afterBalance = user.balance; - unlock(user, {query: {path: backgroundUnlockPath}}); // equip - let response = unlock(user, {query: {path: backgroundUnlockPath}}); - expect(user.balance).to.equal(afterBalance); // do not bill twice - - expect(response.message).to.not.exists; - expect(user.preferences.background).to.equal(''); - }); - - it('unlocks a full set', () => { - let [, message] = unlock(user, {query: {path: unlockPath}}); - - expect(message).to.equal(i18n.t('unlocked')); - expect(user.purchased.shirt.convict).to.be.true; - }); - - it('unlocks a full set of gear', () => { - let [, message] = unlock(user, {query: {path: unlockGearSetPath}}); - - expect(message).to.equal(i18n.t('unlocked')); - expect(user.items.gear.owned.headAccessory_special_wolfEars).to.be.true; - }); - - it('unlocks a an item', () => { - let [, message] = unlock(user, {query: {path: backgroundUnlockPath}}); - - expect(message).to.equal(i18n.t('unlocked')); - expect(user.purchased.background.giant_florals).to.be.true; - }); - - it('reduces a user\'s balance', () => { - let [, message] = unlock(user, {query: {path: unlockPath}}); - - expect(message).to.equal(i18n.t('unlocked')); - expect(user.balance).to.equal(usersStartingGems - unlockCost); - }); -}); diff --git a/test/common/ops/updateTask.js b/test/common/ops/updateTask.js deleted file mode 100644 index 99e8d80b22..0000000000 --- a/test/common/ops/updateTask.js +++ /dev/null @@ -1,53 +0,0 @@ -import updateTask from '../../../common/script/ops/updateTask'; -import { - generateHabit, -} from '../../helpers/common.helper'; - -describe('shared.ops.updateTask', () => { - it('updates a task', () => { - let now = new Date(); - let habit = generateHabit({ - tags: [ - '123', - '456', - ], - - reminders: [{ - id: '123', - startDate: now, - time: now, - }], - }); - - let [res] = updateTask(habit, { - body: { - text: 'updated', - id: '123', - _id: '123', - type: 'todo', - tags: ['678'], - checklist: [{ - completed: false, - text: 'item', - id: '123', - }], - }, - }); - - expect(res.id).to.not.equal('123'); - expect(res._id).to.not.equal('123'); - expect(res.type).to.equal('habit'); - expect(res.text).to.equal('updated'); - expect(res.checklist).to.eql([{ - completed: false, - text: 'item', - id: '123', - }]); - expect(res.reminders).to.eql([{ - id: '123', - startDate: now, - time: now, - }]); - expect(res.tags).to.eql(['678']); - }); -}); diff --git a/test/common/ops/updateWebhook.test.js b/test/common/ops/updateWebhook.test.js deleted file mode 100644 index 43c353626e..0000000000 --- a/test/common/ops/updateWebhook.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import updateWebhook from '../../../common/script/ops/updateWebhook'; -import { - BadRequest, -} from '../../../common/script/libs/errors'; -import i18n from '../../../common/script/i18n'; -import { - generateUser, -} from '../../helpers/common.helper'; - -describe('shared.ops.updateWebhook', () => { - let user; - let req; - let newUrl = 'http://new-url.com'; - - beforeEach(() => { - user = generateUser(); - req = { params: { - id: 'this-id', - }, body: { - url: newUrl, - enabled: true, - } }; - }); - - it('validates body', (done) => { - delete req.body.url; - try { - updateWebhook(user, req); - } catch (err) { - expect(err).to.be.an.instanceof(BadRequest); - expect(err.message).to.equal(i18n.t('invalidUrl')); - done(); - } - }); - - it('succeeds', () => { - let url = 'http://existing-url.com'; - user.preferences.webhooks = { 'this-id': { url } }; - updateWebhook(user, req); - expect(user.preferences.webhooks['this-id'].url).to.eql(newUrl); - }); -}); diff --git a/test/common/preenTodos.test.js b/test/common/preenTodos.test.js new file mode 100644 index 0000000000..c9f9a45028 --- /dev/null +++ b/test/common/preenTodos.test.js @@ -0,0 +1,76 @@ +import moment from 'moment'; +import { generateTodo } from '../helpers/common.helper'; +import { preenTodos } from '../../common/script/index.js'; + +describe('#preenTodos', () => { + let todos, uncompletedTodo, completedChallengeTodo, newlyCompletedTodo, completedTodoFromTwoDaysAgo, completedTodoFromThreeDaysAgo, completedTodoFromTenDaysAgo; + + beforeEach(() => { + uncompletedTodo = generateTodo({ completed: false }); + completedChallengeTodo = generateTodo({ + completed: true, + challenge: { id: 'some-challenge' }, + }); + newlyCompletedTodo = generateTodo({ + completed: true, + dateCompleted: moment(), + }); + completedTodoFromTwoDaysAgo = generateTodo({ + completed: true, + dateCompleted: moment().subtract({ days: 2 }), + }); + completedTodoFromThreeDaysAgo = generateTodo({ + completed: true, + dateCompleted: moment().subtract({ days: 3 }), + }); + completedTodoFromTenDaysAgo = generateTodo({ + completed: true, + dateCompleted: moment().subtract({ days: 10 }), + }); + + todos = [ + uncompletedTodo, + completedChallengeTodo, + newlyCompletedTodo, + completedTodoFromTwoDaysAgo, + completedTodoFromThreeDaysAgo, + completedTodoFromTenDaysAgo, + ]; + }); + + it('includes uncompleted todos', () => { + let preenedTodos = preenTodos(todos); + + expect(preenedTodos).to.include(uncompletedTodo); + }); + + it('includes completed challenge todos', () => { + let preenedTodos = preenTodos(todos); + + expect(preenedTodos).to.include(completedChallengeTodo); + }); + + it('includes recently completed todos', () => { + let preenedTodos = preenTodos(todos); + + expect(preenedTodos).to.include(newlyCompletedTodo); + }); + + it('includes todos completed two days ago', () => { + let preenedTodos = preenTodos(todos); + + expect(preenedTodos).to.include(completedTodoFromTwoDaysAgo); + }); + + it('does not include todos completed three days ago', () => { + let preenedTodos = preenTodos(todos); + + expect(preenedTodos).to.not.include(completedTodoFromThreeDaysAgo); + }); + + it('does not include todos completed more than three days ago', () => { + let preenedTodos = preenTodos(todos); + + expect(preenedTodos).to.not.include(completedTodoFromTenDaysAgo); + }); +}); diff --git a/test/common/shared.spells.test.js b/test/common/shared.spells.test.js new file mode 100644 index 0000000000..931f9b9991 --- /dev/null +++ b/test/common/shared.spells.test.js @@ -0,0 +1,103 @@ +import shared from '../../common/script/index.js'; +import { + generateUser, + generateTodo, +} from '../helpers/common.helper'; + + +describe('Spells', () => { + let user; + + beforeEach(() => { + let todo = generateTodo(); + + user = generateUser({ + stats: { + int: 20, + str: 20, + con: 20, + per: 20, + lvl: 20, + }, + }); + user.todos.push(todo); + }); + + context('Rogue Spells', () => { + beforeEach(() => { + user.stats.class = 'rogue'; + }); + + describe('#backstab', () => { + it('adds exp to user', () => { + const PREVIOUS_EXP = user.stats.exp; + + shared.content.spells.rogue.backStab.cast(user, user.todos[0]); + + expect(user.stats.exp).to.be.greaterThan(PREVIOUS_EXP); + }); + + it('adds gp to user', () => { + const PREVIOUS_GP = user.stats.gp; + + shared.content.spells.rogue.backStab.cast(user, user.todos[0]); + + expect(user.stats.gp).to.be.greaterThan(PREVIOUS_GP); + }); + + it('levels up user if the gain in experience will level up the user', () => { + user.stats.exp = 399; + user.stats.lvl = 17; + + shared.content.spells.rogue.backStab.cast(user, user.todos[0]); + expect(user.stats.lvl).to.eql(18); + }); + + it('adds quest scroll to inventory when passing level milestone', () => { + user.stats.exp = 329; + user.stats.lvl = 14; + + expect(user.items.quests).to.not.have.property('atom1'); + + shared.content.spells.rogue.backStab.cast(user, user.todos[0]); + + expect(user.items.quests).to.have.property('atom1', 1); + }); + }); + }); + + context('Wizard Spells', () => { + beforeEach(() => { + user.stats.class = 'wizard'; + }); + + describe('#fireball (Burst of flames)', () => { + it('adds exp to user', () => { + const PREVIOUS_EXP = user.stats.exp; + + shared.content.spells.wizard.fireball.cast(user, user.todos[0]); + + expect(user.stats.exp).to.be.greaterThan(PREVIOUS_EXP); + }); + + it('levels up user if the gain in experience will level up the user', () => { + user.stats.exp = 399; + user.stats.lvl = 17; + + shared.content.spells.wizard.fireball.cast(user, user.todos[0]); + expect(user.stats.lvl).to.eql(18); + }); + + it('adds quest scroll to inventory when passing level milestone', () => { + user.stats.exp = 329; + user.stats.lvl = 14; + + expect(user.items.quests).to.not.have.property('atom1'); + + shared.content.spells.wizard.fireball.cast(user, user.todos[0]); + + expect(user.items.quests).to.have.property('atom1', 1); + }); + }); + }); +}); diff --git a/test/common/simulations/autoAllocate.js b/test/common/simulations/autoAllocate.js new file mode 100644 index 0000000000..0b0348efee --- /dev/null +++ b/test/common/simulations/autoAllocate.js @@ -0,0 +1,161 @@ +var $w, _, id, modes, shared, user; + +shared = require('../../../common/script/index.js'); + +_ = require('lodash'); + +$w = function(s) { + return s.split(' '); +}; + +id = shared.uuid(); + +user = { + stats: { + "class": 'warrior', + lvl: 1, + hp: 50, + gp: 0, + exp: 10, + per: 0, + int: 0, + con: 0, + str: 0, + buffs: { + per: 0, + int: 0, + con: 0, + str: 0 + }, + training: { + int: 0, + con: 0, + per: 0, + str: 0 + } + }, + preferences: { + automaticAllocation: false + }, + party: { + quest: { + key: 'evilsanta', + progress: { + up: 0, + down: 0 + } + } + }, + achievements: {}, + items: { + eggs: {}, + hatchingPotions: {}, + food: {}, + gear: { + equipped: { + weapon: 'weapon_warrior_4', + armor: 'armor_warrior_4', + shield: 'shield_warrior_4', + head: 'head_warrior_4' + } + } + }, + habits: [ + { + id: 'a', + value: 1, + type: 'habit', + attribute: 'str' + } + ], + dailys: [ + { + id: 'b', + value: 1, + type: 'daily', + attribute: 'str' + } + ], + todos: [ + { + id: 'c', + value: 1, + type: 'todo', + attribute: 'con' + }, { + id: 'd', + value: 1, + type: 'todo', + attribute: 'per' + }, { + id: 'e', + value: 1, + type: 'todo', + attribute: 'int' + } + ], + rewards: [] +}; + +modes = { + flat: _.cloneDeep(user), + classbased_warrior: _.cloneDeep(user), + classbased_rogue: _.cloneDeep(user), + classbased_wizard: _.cloneDeep(user), + classbased_healer: _.cloneDeep(user), + taskbased: _.cloneDeep(user) +}; + +modes.classbased_warrior.stats["class"] = 'warrior'; + +modes.classbased_rogue.stats["class"] = 'rogue'; + +modes.classbased_wizard.stats["class"] = 'wizard'; + +modes.classbased_healer.stats["class"] = 'healer'; + +_.each($w('flat classbased_warrior classbased_rogue classbased_wizard classbased_healer taskbased'), function(mode) { + _.merge(modes[mode].preferences, { + automaticAllocation: true, + allocationMode: mode.indexOf('classbased') === 0 ? 'classbased' : mode + }); + return shared.wrap(modes[mode]); +}); + +console.log("\n\n================================================"); + +console.log("New Simulation"); + +console.log("================================================\n\n"); + +_.times([20], function(lvl) { + console.log("[lvl " + lvl + "]\n--------------\n"); + return _.each($w('flat classbased_warrior classbased_rogue classbased_wizard classbased_healer taskbased'), function(mode) { + var str, u; + u = modes[mode]; + u.stats.exp = shared.tnl(lvl) + 1; + if (mode === 'taskbased') { + _.merge(u.stats, { + per: 0, + con: 0, + int: 0, + str: 0 + }); + } + u.habits[0].attribute = u.fns.randomVal({ + str: 'str', + int: 'int', + per: 'per', + con: 'con' + }); + u.ops.score({ + params: { + id: u.habits[0].id + }, + direction: 'up' + }); + u.fns.updateStats(u.stats); + str = mode + (mode === 'taskbased' ? " (" + u.habits[0].attribute + ")" : ""); + return console.log(str, _.pick(u.stats, $w('per int con str'))); + }); +}); diff --git a/test/common/simulations/passive_active_attrs.js b/test/common/simulations/passive_active_attrs.js new file mode 100644 index 0000000000..f69a2cafc2 --- /dev/null +++ b/test/common/simulations/passive_active_attrs.js @@ -0,0 +1,291 @@ +var _, clearUser, id, party, s, shared, task, user; + +shared = require('../../../common/script/index.js'); + +_ = require('lodash'); + +id = shared.uuid(); + +user = { + stats: { + "class": 'warrior', + buffs: { + per: 0, + int: 0, + con: 0, + str: 0 + } + }, + party: { + quest: { + key: 'evilsanta', + progress: { + up: 0, + down: 0 + } + } + }, + preferences: { + automaticAllocation: false + }, + achievements: {}, + flags: { + levelDrops: {} + }, + items: { + eggs: {}, + hatchingPotions: {}, + food: {}, + quests: {}, + gear: { + equipped: { + weapon: 'weapon_warrior_4', + armor: 'armor_warrior_4', + shield: 'shield_warrior_4', + head: 'head_warrior_4' + } + } + }, + habits: [ + shared.taskDefaults({ + id: id, + value: 0 + }) + ], + dailys: [ + { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + }, { + "text": "1" + } + ], + todos: [], + rewards: [] +}; + +shared.wrap(user); + +s = user.stats; + +task = user.tasks[id]; + +party = [user]; + +console.log("\n\n================================================"); + +console.log("New Simulation"); + +console.log("================================================\n\n"); + +clearUser = function(lvl) { + if (lvl == null) { + lvl = 1; + } + _.merge(user.stats, { + exp: 0, + gp: 0, + hp: 50, + lvl: lvl, + str: lvl * 1.5, + con: lvl * 1.5, + per: lvl * 1.5, + int: lvl * 1.5, + mp: 100 + }); + _.merge(s.buffs, { + str: 0, + con: 0, + int: 0, + per: 0 + }); + _.merge(user.party.quest.progress, { + up: 0, + down: 0 + }); + return user.items.lastDrop = { + count: 0 + }; +}; + +_.each([1, 25, 50, 75, 100], function(lvl) { + console.log("[LEVEL " + lvl + "] (" + (lvl * 2) + " points total in every attr)\n\n"); + _.each({ + red: -25, + yellow: 0, + green: 35 + }, function(taskVal, color) { + var _party, b4, str; + console.log("[task.value = " + taskVal + " (" + color + ")]"); + console.log("direction\texpΔ\t\thpΔ\tgpΔ\ttask.valΔ\ttask.valΔ bonus\t\tboss-hit"); + _.each(['up', 'down'], function(direction) { + var b4, delta; + clearUser(lvl); + b4 = { + hp: s.hp, + taskVal: taskVal + }; + task.value = taskVal; + if (direction === 'up') { + task.type = 'daily'; + } + delta = user.ops.score({ + params: { + id: id, + direction: direction + } + }); + return console.log((direction === 'up' ? '↑' : '↓') + "\t\t" + s.exp + "/" + (shared.tnl(s.lvl)) + "\t\t" + ((b4.hp - s.hp).toFixed(1)) + "\t" + (s.gp.toFixed(1)) + "\t" + (delta.toFixed(1)) + "\t\t" + ((task.value - b4.taskVal - delta).toFixed(1)) + "\t\t\t" + (user.party.quest.progress.up.toFixed(1))); + }); + str = '- [Wizard]'; + task.value = taskVal; + clearUser(lvl); + b4 = { + taskVal: taskVal + }; + shared.content.spells.wizard.fireball.cast(user, task); + str += "\tfireball(task.valΔ:" + ((task.value - taskVal).toFixed(1)) + " exp:" + (s.exp.toFixed(1)) + " bossHit:" + (user.party.quest.progress.up.toFixed(2)) + ")"; + task.value = taskVal; + clearUser(lvl); + _party = [ + user, { + stats: { + mp: 0 + } + } + ]; + shared.content.spells.wizard.mpheal.cast(user, _party); + str += "\t| mpheal(mp:" + _party[1].stats.mp + ")"; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.wizard.earth.cast(user, party); + str += "\t\t\t\t| earth(buffs.int:" + s.buffs.int + ")"; + s.buffs.int = 0; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.wizard.frost.cast(user, {}); + str += "\t\t\t| frost(N/A)"; + console.log(str); + str = '- [Warrior]'; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.warrior.smash.cast(user, task); + b4 = { + taskVal: taskVal + }; + str += "\tsmash(task.valΔ:" + ((task.value - taskVal).toFixed(1)) + ")"; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.warrior.defensiveStance.cast(user, {}); + str += "\t\t| defensiveStance(buffs.con:" + s.buffs.con + ")"; + s.buffs.con = 0; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.warrior.valorousPresence.cast(user, party); + str += "\t\t\t| valorousPresence(buffs.str:" + s.buffs.str + ")"; + s.buffs.str = 0; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.warrior.intimidate.cast(user, party); + str += "\t\t| intimidate(buffs.con:" + s.buffs.con + ")"; + s.buffs.con = 0; + console.log(str); + str = '- [Rogue]'; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.rogue.pickPocket.cast(user, task); + str += "\tpickPocket(gp:" + (s.gp.toFixed(1)) + ")"; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.rogue.backStab.cast(user, task); + b4 = { + taskVal: taskVal + }; + str += "\t\t| backStab(task.valΔ:" + ((task.value - b4.taskVal).toFixed(1)) + " exp:" + (s.exp.toFixed(1)) + " gp:" + (s.gp.toFixed(1)) + ")"; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.rogue.toolsOfTrade.cast(user, party); + str += "\t| toolsOfTrade(buffs.per:" + s.buffs.per + ")"; + s.buffs.per = 0; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.rogue.stealth.cast(user, {}); + str += "\t\t| stealth(avoiding " + user.stats.buffs.stealth + " tasks)"; + user.stats.buffs.stealth = 0; + console.log(str); + str = '- [Healer]'; + task.value = taskVal; + clearUser(lvl); + s.hp = 0; + shared.content.spells.healer.heal.cast(user, {}); + str += "\theal(hp:" + (s.hp.toFixed(1)) + ")"; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.healer.brightness.cast(user, {}); + b4 = { + taskVal: taskVal + }; + str += "\t\t\t| brightness(task.valΔ:" + ((task.value - b4.taskVal).toFixed(1)) + ")"; + task.value = taskVal; + clearUser(lvl); + shared.content.spells.healer.protectAura.cast(user, party); + str += "\t\t\t| protectAura(buffs.con:" + s.buffs.con + ")"; + s.buffs.con = 0; + task.value = taskVal; + clearUser(lvl); + s.hp = 0; + shared.content.spells.healer.heallAll.cast(user, party); + str += "\t\t| heallAll(hp:" + (s.hp.toFixed(1)) + ")"; + console.log(str); + return console.log('\n'); + }); + return console.log('------------------------------------------------------------'); +}); + + +/* +_.each [1,25,50,75,100,125], (lvl) -> + console.log "[LEVEL #{lvl}] (#{lvl*2} points in every attr)\n\n" + _.each {red:-25,yellow:0,green:35}, (taskVal, color) -> + console.log "[task.value = #{taskVal} (#{color})]" + console.log "direction\texpΔ\t\thpΔ\tgpΔ\ttask.valΔ\ttask.valΔ bonus\t\tboss-hit" + _.each ['up','down'], (direction) -> + clearUser(lvl) + b4 = {hp:s.hp, taskVal} + task.value = taskVal + task.type = 'daily' if direction is 'up' + delta = user.ops.score params:{id, direction} + console.log "#{if direction is 'up' then '↑' else '↓'}\t\t#{s.exp}/#{shared.tnl(s.lvl)}\t\t#{(b4.hp-s.hp).toFixed(1)}\t#{s.gp.toFixed(1)}\t#{delta.toFixed(1)}\t\t#{(task.value-b4.taskVal-delta).toFixed(1)}\t\t\t#{user.party.quest.progress.up.toFixed(1)}" + + task.value = taskVal;clearUser(lvl) + shared.content.spells.rogue.stealth.cast(user,{}) + console.log "\t\t| stealth(avoiding #{user.stats.buffs.stealth} tasks)" + user.stats.buffs.stealth = 0 + + console.log user.dailys.length + */ diff --git a/test/common/user.fns.buy.test.js b/test/common/user.fns.buy.test.js new file mode 100644 index 0000000000..0cf39c1eb0 --- /dev/null +++ b/test/common/user.fns.buy.test.js @@ -0,0 +1,288 @@ +/* eslint-disable camelcase */ + +import sinon from 'sinon'; // eslint-disable-line no-shadow + +let shared = require('../../common/script/index.js'); + +describe('user.fns.buy', () => { + let user; + + beforeEach(() => { + user = { + items: { + gear: { + owned: { + weapon_warrior_0: true, + }, + equipped: { + weapon_warrior_0: true, + }, + }, + }, + preferences: {}, + stats: { gp: 200 }, + achievements: { }, + flags: { }, + }; + + shared.wrap(user); + + sinon.stub(user.fns, 'randomVal'); + sinon.stub(user.fns, 'predictableRandom'); + }); + + afterEach(() => { + user.fns.randomVal.restore(); + user.fns.predictableRandom.restore(); + }); + + context('Potion', () => { + it('recovers 15 hp', () => { + user.stats.hp = 30; + user.ops.buy({params: {key: 'potion'}}); + expect(user.stats.hp).to.eql(45); + }); + + it('does not increase hp above 50', () => { + user.stats.hp = 45; + user.ops.buy({params: {key: 'potion'}}); + expect(user.stats.hp).to.eql(50); + }); + + it('deducts 25 gp', () => { + user.stats.hp = 45; + user.ops.buy({params: {key: 'potion'}}); + + expect(user.stats.gp).to.eql(175); + }); + + it('does not purchase if not enough gp', () => { + user.stats.hp = 45; + user.stats.gp = 5; + user.ops.buy({params: {key: 'potion'}}); + + expect(user.stats.hp).to.eql(45); + expect(user.stats.gp).to.eql(5); + }); + }); + + context('Gear', () => { + it('adds equipment to inventory', () => { + user.stats.gp = 31; + + user.ops.buy({params: {key: 'armor_warrior_1'}}); + + expect(user.items.gear.owned).to.eql({ weapon_warrior_0: true, armor_warrior_1: true }); + }); + + it('deducts gold from user', () => { + user.stats.gp = 31; + + user.ops.buy({params: {key: 'armor_warrior_1'}}); + + expect(user.stats.gp).to.eql(1); + }); + + it('auto equips equipment if user has auto-equip preference turned on', () => { + user.stats.gp = 31; + user.preferences.autoEquip = true; + + user.ops.buy({params: {key: 'armor_warrior_1'}}); + + expect(user.items.gear.equipped).to.have.property('armor', 'armor_warrior_1'); + }); + + it('buys equipment but does not auto-equip', () => { + user.stats.gp = 31; + user.preferences.autoEquip = false; + + user.ops.buy({params: {key: 'armor_warrior_1'}}); + + expect(user.items.gear.equipped).to.not.have.property('armor'); + }); + + it('removes one-handed weapon and shield if auto-equip is on and a two-hander is bought', () => { + user.stats.gp = 100; + user.preferences.autoEquip = true; + user.ops.buy({params: {key: 'shield_warrior_1'}}); + user.ops.equip({params: {key: 'shield_warrior_1'}}); + user.ops.buy({params: {key: 'weapon_warrior_1'}}); + user.ops.equip({params: {key: 'weapon_warrior_1'}}); + + user.ops.buy({params: {key: 'weapon_wizard_1'}}); + + expect(user.items.gear.equipped).to.have.property('shield', 'shield_base_0'); + expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_wizard_1'); + }); + + it('buys two-handed equipment but does not automatically remove sword or shield', () => { + user.stats.gp = 100; + user.preferences.autoEquip = false; + user.ops.buy({params: {key: 'shield_warrior_1'}}); + user.ops.equip({params: {key: 'shield_warrior_1'}}); + user.ops.buy({params: {key: 'weapon_warrior_1'}}); + user.ops.equip({params: {key: 'weapon_warrior_1'}}); + + user.ops.buy({params: {key: 'weapon_wizard_1'}}); + + expect(user.items.gear.equipped).to.have.property('shield', 'shield_warrior_1'); + expect(user.items.gear.equipped).to.have.property('weapon', 'weapon_warrior_1'); + }); + + it('does not buy equipment without enough Gold', () => { + user.stats.gp = 20; + + user.ops.buy({params: {key: 'armor_warrior_1'}}); + + expect(user.items.gear.owned).to.not.have.property('armor_warrior_1'); + }); + }); + + context('Quests', () => { + it('buys a Quest scroll'); + + it('does not buy Quests without enough Gold'); + + it('does not buy nonexistent Quests'); + + it('does not buy Gem-premium Quests'); + }); + + context('Enchanted Armoire', () => { + let YIELD_EQUIPMENT = 0.5; + let YIELD_FOOD = 0.7; + let YIELD_EXP = 0.9; + + let fullArmoire = {}; + + _(shared.content.gearTypes).each((type) => { + _(shared.content.gear.tree[type].armoire).each((gearObject) => { + let armoireKey = gearObject.key; + + fullArmoire[armoireKey] = true; + }).value(); + }).value(); + + beforeEach(() => { + user.achievements.ultimateGearSets = { rogue: true }; + user.flags.armoireOpened = true; + user.stats.exp = 0; + user.items.food = {}; + }); + + context('failure conditions', () => { + it('does not open if user does not have enough gold', (done) => { + user.fns.predictableRandom.returns(YIELD_EQUIPMENT); + user.stats.gp = 50; + + user.ops.buy({params: {key: 'armoire'}}, (response) => { + expect(response.message).to.eql('Not Enough Gold'); + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + expect(user.items.food).to.be.empty; + expect(user.stats.exp).to.eql(0); + done(); + }); + }); + + it('does not open without Ultimate Gear achievement', (done) => { + user.fns.predictableRandom.returns(YIELD_EQUIPMENT); + user.achievements.ultimateGearSets = {healer: false, wizard: false, rogue: false, warrior: false}; + + user.ops.buy({params: {key: 'armoire'}}, (response) => { + expect(response.message).to.eql('You can\'t buy this item'); + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + expect(user.items.food).to.be.empty; + expect(user.stats.exp).to.eql(0); + done(); + }); + }); + }); + + context('non-gear awards', () => { + it('gives Experience', () => { + user.fns.predictableRandom.returns(YIELD_EXP); + + user.ops.buy({params: {key: 'armoire'}}); + + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + expect(user.items.food).to.be.empty; + expect(user.stats.exp).to.eql(46); + expect(user.stats.gp).to.eql(100); + }); + + it('gives food', () => { + let honey = shared.content.food.Honey; + + user.fns.randomVal.returns(honey); + user.fns.predictableRandom.returns(YIELD_FOOD); + + user.ops.buy({params: {key: 'armoire'}}); + + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + expect(user.items.food).to.eql({Honey: 1}); + expect(user.stats.exp).to.eql(0); + expect(user.stats.gp).to.eql(100); + }); + + it('does not give equipment if all equipment has been found', () => { + user.fns.predictableRandom.returns(YIELD_EQUIPMENT); + user.items.gear.owned = fullArmoire; + user.stats.gp = 150; + + user.ops.buy({params: {key: 'armoire'}}); + + expect(user.items.gear.owned).to.eql(fullArmoire); + let armoireCount = shared.count.remainingGearInSet(user.items.gear.owned, 'armoire'); + + expect(armoireCount).to.eql(0); + + expect(user.stats.exp).to.eql(30); + expect(user.stats.gp).to.eql(50); + }); + }); + + context('gear awards', () => { + beforeEach(() => { + let shield = shared.content.gear.tree.shield.armoire.gladiatorShield; + + user.fns.randomVal.returns(shield); + }); + + it('always drops equipment the first time', () => { + delete user.flags.armoireOpened; + user.fns.predictableRandom.returns(YIELD_EXP); + + user.ops.buy({params: {key: 'armoire'}}); + + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: true, + shield_armoire_gladiatorShield: true, + }); + + let armoireCount = shared.count.remainingGearInSet(user.items.gear.owned, 'armoire'); + + expect(armoireCount).to.eql(_.size(fullArmoire) - 1); + expect(user.items.food).to.be.empty; + expect(user.stats.exp).to.eql(0); + expect(user.stats.gp).to.eql(100); + }); + + it('gives more equipment', () => { + user.fns.predictableRandom.returns(YIELD_EQUIPMENT); + user.items.gear.owned = { + weapon_warrior_0: true, + head_armoire_hornedIronHelm: true, + }; + user.stats.gp = 200; + + user.ops.buy({params: {key: 'armoire'}}); + + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true, shield_armoire_gladiatorShield: true, head_armoire_hornedIronHelm: true}); + let armoireCount = shared.count.remainingGearInSet(user.items.gear.owned, 'armoire'); + + expect(armoireCount).to.eql(_.size(fullArmoire) - 2); + expect(user.stats.gp).to.eql(100); + }); + }); + }); +}); diff --git a/test/common/user.fns.ultimateGear.test.js b/test/common/user.fns.ultimateGear.test.js new file mode 100644 index 0000000000..a95cb403d9 --- /dev/null +++ b/test/common/user.fns.ultimateGear.test.js @@ -0,0 +1,37 @@ +/* eslint-disable camelcase */ + +let shared = require('../../common/script/index.js'); + +shared.i18n.translations = require('../../website/src/libs/i18n.js').translations; + +require('./test_helper'); + +describe('User.fns.ultimateGear', () => { + it('sets armoirEnabled when partial achievement already achieved', () => { + let items = { + gear: { + owned: { + toObject: () => { + return { + armor_warrior_5: true, + shield_warrior_5: true, + head_warrior_5: true, + weapon_warrior_6: true, + }; + }, + }, + }, + }; + + let user = shared.wrap({ + items, + achievements: { + ultimateGearSets: {}, + }, + flags: {}, + }); + + user.fns.ultimateGear(); + expect(user.flags.armoireEnabled).to.equal(true); + }); +}); diff --git a/test/common/user.fns.updateStats.test.js b/test/common/user.fns.updateStats.test.js new file mode 100644 index 0000000000..fbad57e531 --- /dev/null +++ b/test/common/user.fns.updateStats.test.js @@ -0,0 +1,134 @@ +import { + generateUser, +} from '../helpers/common.helper'; + +describe('user.fns.updateStats', () => { + let user; + + beforeEach(() => { + user = generateUser({}); + }); + + context('No Hp', () => { + it('returns 0 if user\'s hp is 0', () => { + let stats = { + hp: 0, + }; + + expect(user.fns.updateStats(stats)).to.eql(0); + }); + + it('returns 0 if user\'s hp is less than 0', () => { + let stats = { + hp: -5, + }; + + expect(user.fns.updateStats(stats)).to.eql(0); + }); + + it('sets user\'s hp to 0 if it is less than 0', () => { + let stats = { + hp: -5, + }; + + user.fns.updateStats(stats); + + expect(user.stats.hp).to.eql(0); + }); + }); + + context('Stat Allocation', () => { + it('adds only attribute points up to user\'s level', () => { + let stats = { + exp: 261, + }; + + user.stats.lvl = 10; + + user.fns.updateStats(stats); + + expect(user.stats.points).to.eql(11); + }); + + it('adds an attibute point when user\'s stat points are less than max level', () => { + let stats = { + exp: 3581, + }; + + user.stats.lvl = 99; + user.stats.str = 25; + user.stats.int = 25; + user.stats.con = 25; + user.stats.per = 24; + + user.fns.updateStats(stats); + + expect(user.stats.points).to.eql(1); + }); + + it('does not add an attibute point when user\'s stat points are equal to max level', () => { + let stats = { + exp: 3581, + }; + + user.stats.lvl = 99; + user.stats.str = 25; + user.stats.int = 25; + user.stats.con = 25; + user.stats.per = 25; + + user.fns.updateStats(stats); + + expect(user.stats.points).to.eql(0); + }); + + it('does not add an attibute point when user\'s stat points + unallocated points are equal to max level', () => { + let stats = { + exp: 3581, + }; + + user.stats.lvl = 99; + user.stats.str = 25; + user.stats.int = 25; + user.stats.con = 25; + user.stats.per = 15; + user.stats.points = 10; + + user.fns.updateStats(stats); + + expect(user.stats.points).to.eql(10); + }); + + it('only awards stat points up to level 100 if user is missing unallocated stat points and is over level 100', () => { + let stats = { + exp: 5581, + }; + + user.stats.lvl = 104; + user.stats.str = 25; + user.stats.int = 25; + user.stats.con = 25; + user.stats.per = 15; + user.stats.points = 0; + + user.fns.updateStats(stats); + + expect(user.stats.points).to.eql(10); + }); + + // @TODO: Set up sinon sandbox + xit('auto allocates stats if automaticAllocation is turned on', () => { + sandbox.stub(user.fns, 'autoAllocate'); + + let stats = { + exp: 261, + }; + + user.stats.lvl = 10; + + user.fns.updateStats(stats); + + expect(user.fns.autoAllocate).to.be.calledOnce; + }); + }); +}); diff --git a/test/common/user.ops.buyMysterySet.test.js b/test/common/user.ops.buyMysterySet.test.js new file mode 100644 index 0000000000..8c7899cf50 --- /dev/null +++ b/test/common/user.ops.buyMysterySet.test.js @@ -0,0 +1,77 @@ +/* eslint-disable camelcase */ + +let shared = require('../../common/script/index.js'); + +describe('user.ops.buyMysterySet', () => { + let user; + + beforeEach(() => { + user = { + items: { + gear: { + owned: { + weapon_warrior_0: true, + }, + }, + }, + purchased: { + plan: { + consecutive: { + trinkets: 0, + }, + }, + }, + }; + + shared.wrap(user); + }); + + context('Mystery Sets', () => { + context('failure conditions', () => { + it('does not grant mystery sets without Mystic Hourglasses', (done) => { + user.ops.buyMysterySet({params: {key: '201501'}}, (response) => { + expect(response.message).to.eql('You don\'t have enough Mystic Hourglasses.'); + expect(user.items.gear.owned).to.eql({weapon_warrior_0: true}); + done(); + }); + }); + + it('does not grant mystery set that has already been purchased', (done) => { + user.purchased.plan.consecutive.trinkets = 1; + user.items.gear.owned = { + weapon_warrior_0: true, + weapon_mystery_301404: true, + armor_mystery_301404: true, + head_mystery_301404: true, + eyewear_mystery_301404: true, + }; + + user.ops.buyMysterySet({params: {key: '301404'}}, (response) => { + expect(response.message).to.eql('Mystery set not found, or set already owned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); + }); + }); + }); + + context('successful purchases', () => { + it('buys Steampunk Accessories Set', (done) => { + user.purchased.plan.consecutive.trinkets = 1; + + user.ops.buyMysterySet({params: {key: '301404'}}, () => { + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + expect(user.items.gear.owned).to.eql({ + weapon_warrior_0: true, + weapon_mystery_301404: true, + armor_mystery_301404: true, + head_mystery_301404: true, + eyewear_mystery_301404: true, + }); + + done(); + }); + }); + }); + }); +}); + diff --git a/test/common/user.ops.equip.test.js b/test/common/user.ops.equip.test.js new file mode 100644 index 0000000000..62f7956e02 --- /dev/null +++ b/test/common/user.ops.equip.test.js @@ -0,0 +1,102 @@ +/* eslint-disable camelcase */ + +import sinon from 'sinon'; // eslint-disable-line no-shadow +import {assert} from 'sinon'; +import i18n from '../../common/script/i18n'; +import shared from '../../common/script/index.js'; +import content from '../../common/script/content/index'; + +describe('user.ops.equip', () => { + let user; + let spy; + + beforeEach(() => { + user = { + items: { + gear: { + owned: { + weapon_warrior_0: true, + weapon_warrior_1: true, + weapon_warrior_2: true, + weapon_wizard_1: true, + weapon_wizard_2: true, + shield_base_0: true, + shield_warrior_1: true, + }, + equipped: { + weapon: 'weapon_warrior_0', + shield: 'shield_base_0', + }, + }, + }, + preferences: {}, + stats: {gp: 200}, + achievements: {}, + flags: {}, + }; + + shared.wrap(user); + spy = sinon.spy(); + }); + + context('Gear', () => { + it('should not send a message if a weapon is equipped while only having zero or one weapons equipped', () => { + // user.ops.equip always calls the callback, even if it isn't sending a message + // so we need to check to see if a single null message was sent. + user.ops.equip({params: {key: 'weapon_warrior_1'}}); + + // one-handed to one-handed + user.ops.equip({params: {key: 'weapon_warrior_2'}}, spy); + + assert.calledOnce(spy); + assert.calledWith(spy, null); + spy.reset(); + + // one-handed to two-handed + user.ops.equip({params: {key: 'weapon_wizard_1'}}, spy); + assert.calledOnce(spy); + assert.calledWith(spy, null); + spy.reset(); + + // two-handed to two-handed + user.ops.equip({params: {key: 'weapon_wizard_2'}}, spy); + assert.calledOnce(spy); + assert.calledWith(spy, null); + spy.reset(); + + // two-handed to one-handed + user.ops.equip({params: {key: 'weapon_warrior_2'}}, spy); + assert.calledOnce(spy); + assert.calledWith(spy, null); + spy.reset(); + }); + + it('should send messages if equipping a two-hander causes the off-hander to be unequipped', () => { + user.ops.equip({params: {key: 'weapon_warrior_1'}}); + user.ops.equip({params: {key: 'shield_warrior_1'}}); + + // equipping two-hander + user.ops.equip({params: {key: 'weapon_wizard_1'}}, spy); + let weapon = content.gear.flat.weapon_wizard_1; + let item = content.gear.flat.shield_warrior_1; + let message = i18n.t('messageTwoHandedEquip', {twoHandedText: weapon.text(null), offHandedText: item.text(null)}); + + assert.calledOnce(spy); + assert.calledWith(spy, {code: 200, message}); + }); + + it('should send messages if equipping an off-hand item causes a two-handed weapon to be unequipped', () => { + // equipping two-hander + user.ops.equip({params: {key: 'weapon_wizard_1'}}); + let weapon = content.gear.flat.weapon_wizard_1; + let shield = content.gear.flat.shield_warrior_1; + + user.ops.equip({params: {key: 'shield_warrior_1'}}, spy); + + let message = i18n.t('messageTwoHandedUnequip', {twoHandedText: weapon.text(null), offHandedText: shield.text(null)}); + + assert.calledOnce(spy); + assert.calledWith(spy, {code: 200, message}); + }); + }); +}); diff --git a/test/common/user.ops.hatch.js b/test/common/user.ops.hatch.js new file mode 100644 index 0000000000..573103a360 --- /dev/null +++ b/test/common/user.ops.hatch.js @@ -0,0 +1,129 @@ +let shared = require('../../common/script/index.js'); + +describe('user.ops.hatch', () => { + let user; + + beforeEach(() => { + user = { + items: { + eggs: {}, + hatchingPotions: {}, + pets: {}, + }, + }; + + shared.wrap(user); + }); + + context('Pet Hatching', () => { + context('failure conditions', () => { + it('does not allow hatching without specifying egg and potion', (done) => { + user.ops.hatch({params: {}}, (response) => { + expect(response.message).to.eql('Please specify query.egg & query.hatchingPotion'); + expect(user.items.pets).to.be.empty; + done(); + }); + }); + + it('does not allow hatching if user lacks specified egg', (done) => { + user.items.eggs = {Wolf: 1}; + user.items.hatchingPotions = {Base: 1}; + user.ops.hatch({params: {egg: 'Dragon', hatchingPotion: 'Base'}}, (response) => { + expect(response.message).to.eql(shared.i18n.t('messageMissingEggPotion')); + expect(user.items.pets).to.be.empty; + expect(user.items.eggs).to.eql({Wolf: 1}); + expect(user.items.hatchingPotions).to.eql({Base: 1}); + done(); + }); + }); + + it('does not allow hatching if user lacks specified hatching potion', (done) => { + user.items.eggs = {Wolf: 1}; + user.items.hatchingPotions = {Base: 1}; + user.ops.hatch({params: {egg: 'Wolf', hatchingPotion: 'Golden'}}, (response) => { + expect(response.message).to.eql(shared.i18n.t('messageMissingEggPotion')); + expect(user.items.pets).to.be.empty; + expect(user.items.eggs).to.eql({Wolf: 1}); + expect(user.items.hatchingPotions).to.eql({Base: 1}); + done(); + }); + }); + + it('does not allow hatching if user already owns target pet', (done) => { + user.items.eggs = {Wolf: 1}; + user.items.hatchingPotions = {Base: 1}; + user.items.pets = {'Wolf-Base': 10}; + user.ops.hatch({params: {egg: 'Wolf', hatchingPotion: 'Base'}}, (response) => { + expect(response.message).to.eql(shared.i18n.t('messageAlreadyPet')); + expect(user.items.pets).to.eql({'Wolf-Base': 10}); + expect(user.items.eggs).to.eql({Wolf: 1}); + expect(user.items.hatchingPotions).to.eql({Base: 1}); + done(); + }); + }); + + it('does not allow hatching quest pet egg using premium potion', (done) => { + user.items.eggs = {Cheetah: 1}; + user.items.hatchingPotions = {Spooky: 1}; + user.ops.hatch({params: {egg: 'Cheetah', hatchingPotion: 'Spooky'}}, (response) => { + expect(response.message).to.eql(shared.i18n.t('messageInvalidEggPotionCombo')); + expect(user.items.pets).to.be.empty; + expect(user.items.eggs).to.eql({Cheetah: 1}); + expect(user.items.hatchingPotions).to.eql({Spooky: 1}); + done(); + }); + }); + }); + + context('successful hatching', () => { + it('hatches a basic pet', (done) => { + user.items.eggs = {Wolf: 1}; + user.items.hatchingPotions = {Base: 1}; + user.ops.hatch({params: {egg: 'Wolf', hatchingPotion: 'Base'}}, (response) => { + expect(response.message).to.eql(shared.i18n.t('messageHatched')); + expect(user.items.pets).to.eql({'Wolf-Base': 5}); + expect(user.items.eggs).to.eql({Wolf: 0}); + expect(user.items.hatchingPotions).to.eql({Base: 0}); + done(); + }); + }); + + it('hatches a quest pet', (done) => { + user.items.eggs = {Cheetah: 1}; + user.items.hatchingPotions = {Base: 1}; + user.ops.hatch({params: {egg: 'Cheetah', hatchingPotion: 'Base'}}, (response) => { + expect(response.message).to.eql(shared.i18n.t('messageHatched')); + expect(user.items.pets).to.eql({'Cheetah-Base': 5}); + expect(user.items.eggs).to.eql({Cheetah: 0}); + expect(user.items.hatchingPotions).to.eql({Base: 0}); + done(); + }); + }); + + it('hatches a premium pet', (done) => { + user.items.eggs = {Wolf: 1}; + user.items.hatchingPotions = {Spooky: 1}; + user.ops.hatch({params: {egg: 'Wolf', hatchingPotion: 'Spooky'}}, (response) => { + expect(response.message).to.eql(shared.i18n.t('messageHatched')); + expect(user.items.pets).to.eql({'Wolf-Spooky': 5}); + expect(user.items.eggs).to.eql({Wolf: 0}); + expect(user.items.hatchingPotions).to.eql({Spooky: 0}); + done(); + }); + }); + + it('hatches a pet previously raised to a mount', (done) => { + user.items.eggs = {Wolf: 1}; + user.items.hatchingPotions = {Base: 1}; + user.items.pets = {'Wolf-Base': -1}; + user.ops.hatch({params: {egg: 'Wolf', hatchingPotion: 'Base'}}, (response) => { + expect(response.message).to.eql(shared.i18n.t('messageHatched')); + expect(user.items.pets).to.eql({'Wolf-Base': 5}); + expect(user.items.eggs).to.eql({Wolf: 0}); + expect(user.items.hatchingPotions).to.eql({Base: 0}); + done(); + }); + }); + }); + }); +}); diff --git a/test/common/user.ops.hourglassPurchase.test.js b/test/common/user.ops.hourglassPurchase.test.js new file mode 100644 index 0000000000..dbb6f877e2 --- /dev/null +++ b/test/common/user.ops.hourglassPurchase.test.js @@ -0,0 +1,122 @@ +let shared = require('../../common/script/index.js'); + +describe('user.ops.hourglassPurchase', () => { + let user; + + beforeEach(() => { + user = { + items: { + pets: {}, + mounts: {}, + hatchingPotions: {}, + }, + purchased: { + plan: { + consecutive: { + trinkets: 0, + }, + }, + }, + }; + + shared.wrap(user); + }); + + context('Time Travel Stable', () => { + context('failure conditions', () => { + it('does not allow purchase of unsupported item types', (done) => { + user.ops.hourglassPurchase({params: {type: 'hatchingPotions', key: 'Base'}}, (response) => { + expect(response.message).to.eql('Item type not supported for purchase with Mystic Hourglass. Allowed types: pets,mounts'); + expect(user.items.hatchingPotions).to.eql({}); + done(); + }); + }); + + it('does not grant pets without Mystic Hourglasses', (done) => { + user.ops.hourglassPurchase({params: {type: 'pets', key: 'MantisShrimp-Base'}}, (response) => { + expect(response.message).to.eql('You don\'t have enough Mystic Hourglasses.'); + expect(user.items.pets).to.eql({}); + done(); + }); + }); + + it('does not grant mounts without Mystic Hourglasses', (done) => { + user.ops.hourglassPurchase({params: {type: 'mounts', key: 'MantisShrimp-Base'}}, (response) => { + expect(response.message).to.eql('You don\'t have enough Mystic Hourglasses.'); + expect(user.items.mounts).to.eql({}); + done(); + }); + }); + + it('does not grant pet that has already been purchased', (done) => { + user.purchased.plan.consecutive.trinkets = 1; + user.items.pets = { + 'MantisShrimp-Base': true, + }; + + user.ops.hourglassPurchase({params: {type: 'pets', key: 'MantisShrimp-Base'}}, (response) => { + expect(response.message).to.eql('Pet already owned.'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); + }); + }); + + it('does not grant mount that has already been purchased', (done) => { + user.purchased.plan.consecutive.trinkets = 1; + user.items.mounts = { + 'MantisShrimp-Base': true, + }; + + user.ops.hourglassPurchase({params: {type: 'mounts', key: 'MantisShrimp-Base'}}, (response) => { + expect(response.message).to.eql('Mount already owned.'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); + }); + }); + + it('does not grant pet that is not part of the Time Travel Stable', (done) => { + user.purchased.plan.consecutive.trinkets = 1; + + user.ops.hourglassPurchase({params: {type: 'pets', key: 'Wolf-Veteran'}}, (response) => { + expect(response.message).to.eql('Pet not available for purchase with Mystic Hourglass.'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); + }); + }); + + it('does not grant mount that is not part of the Time Travel Stable', (done) => { + user.purchased.plan.consecutive.trinkets = 1; + + user.ops.hourglassPurchase({params: {type: 'mounts', key: 'Orca-Base'}}, (response) => { + expect(response.message).to.eql('Mount not available for purchase with Mystic Hourglass.'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + done(); + }); + }); + }); + + context('successful purchases', () => { + it('buys a pet', (done) => { + user.purchased.plan.consecutive.trinkets = 2; + + user.ops.hourglassPurchase({params: {type: 'pets', key: 'MantisShrimp-Base'}}, (response) => { + expect(response.message).to.eql('Purchased an item using a Mystic Hourglass!'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + expect(user.items.pets).to.eql({'MantisShrimp-Base': 5}); + done(); + }); + }); + + it('buys a mount', (done) => { + user.purchased.plan.consecutive.trinkets = 2; + + user.ops.hourglassPurchase({params: {type: 'mounts', key: 'MantisShrimp-Base'}}, (response) => { + expect(response.message).to.eql('Purchased an item using a Mystic Hourglass!'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + expect(user.items.mounts).to.eql({'MantisShrimp-Base': true}); + done(); + }); + }); + }); + }); +}); diff --git a/test/common/user.ops.test.js b/test/common/user.ops.test.js new file mode 100644 index 0000000000..f4e4bb82c9 --- /dev/null +++ b/test/common/user.ops.test.js @@ -0,0 +1,34 @@ +let shared = require('../../common/script/index.js'); + +describe('user.ops', () => { + let user; + + beforeEach(() => { + user = { + items: { + gear: { }, + special: { }, + }, + achievements: { }, + flags: { }, + }; + + shared.wrap(user); + }); + + describe('readCard', () => { + it('removes card from invitation array', () => { + user.items.special.valentineReceived = ['Leslie']; + user.ops.readCard({ params: { cardType: 'valentine' } }); + + expect(user.items.special.valentineReceived).to.be.empty; + }); + + it('removes the first card from invitation array', () => { + user.items.special.valentineReceived = ['Leslie', 'Vicky']; + user.ops.readCard({ params: { cardType: 'valentine' } }); + + expect(user.items.special.valentineReceived).to.eql(['Vicky']); + }); + }); +}); diff --git a/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js index f584826520..3fe7adbca7 100644 --- a/test/helpers/api-integration/api-classes.js +++ b/test/helpers/api-integration/api-classes.js @@ -4,7 +4,7 @@ import { requester } from './requester'; import { getDocument as getDocumentFromMongo, updateDocument as updateDocumentInMongo, -} from '../mongo'; +} from './mongo'; import { assign, each, @@ -59,29 +59,6 @@ export class ApiGroup extends ApiObject { this._docType = 'groups'; } - - async addChat (chat) { - let group = this; - - if (!chat) { - chat = { - id: 'Test_ID', - text: 'Test message', - flagCount: 0, - timestamp: Date(), - likes: {}, - flags: {}, - uuid: group.leader, - contributor: {}, - backer: {}, - user: group.leader, - }; - } - - let update = { chat }; - - return await this.update(update); - } } export class ApiChallenge extends ApiObject { diff --git a/test/helpers/api-integration/mongo.js b/test/helpers/api-integration/mongo.js new file mode 100644 index 0000000000..52be5263b4 --- /dev/null +++ b/test/helpers/api-integration/mongo.js @@ -0,0 +1,92 @@ +/* eslint-disable no-use-before-define */ + +import { MongoClient as mongo } from 'mongodb'; + +const DB_URI = 'mongodb://localhost/habitrpg_test'; + +// Useful for checking things that have been deleted, +// but you no longer have access to, +// like private parties or users +export async function checkExistence (collectionName, id) { + let db = await connectToMongo(); + + return new Promise((resolve, reject) => { + let collection = db.collection(collectionName); + + collection.find({_id: id}, {_id: 1}).limit(1).toArray((findError, docs) => { + if (findError) return reject(findError); + + let exists = docs.length > 0; + + db.close(); + resolve(exists); + }); + }); +} + +// Specifically helpful for the GET /groups tests, +// resets the db to an empty state and creates a tavern document +export async function resetHabiticaDB () { + let db = await connectToMongo(); + + return new Promise((resolve, reject) => { + db.dropDatabase((dbErr) => { + if (dbErr) return reject(dbErr); + let groups = db.collection('groups'); + + groups.insertOne({ + _id: 'habitrpg', + chat: [], + leader: '9', + name: 'HabitRPG', + type: 'guild', + privacy: 'public', + members: [], + }, (insertErr) => { + if (insertErr) return reject(insertErr); + + db.close(); + resolve(); + }); + }); + }); +} + +export async function updateDocument (collectionName, doc, update) { + let db = await connectToMongo(); + + let collection = db.collection(collectionName); + + return new Promise((resolve) => { + collection.updateOne({ _id: doc._id }, { $set: update }, (updateErr) => { + if (updateErr) throw new Error(`Error updating ${collectionName}: ${updateErr}`); + db.close(); + resolve(); + }); + }); +} + +export async function getDocument (collectionName, doc) { + let db = await connectToMongo(); + + let collection = db.collection(collectionName); + + return new Promise((resolve) => { + collection.findOne({ _id: doc._id }, (lookupErr, found) => { + if (lookupErr) throw new Error(`Error looking up ${collectionName}: ${lookupErr}`); + db.close(); + resolve(found); + }); + }); +} + +export function connectToMongo () { + return new Promise((resolve, reject) => { + mongo.connect(DB_URI, (err, db) => { + if (err) return reject(err); + + resolve(db); + }); + }); +} + diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index 312942c194..209d312ba1 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -1,18 +1,14 @@ /* eslint-disable no-use-before-define */ import superagent from 'superagent'; -import nconf from 'nconf'; -import { isEmpty, cloneDeep } from 'lodash'; -const API_TEST_SERVER_PORT = nconf.get('PORT'); +const API_TEST_SERVER_PORT = 3003; let apiVersion; -// Sets up an object that can make all REST requests +// Sets up an abject that can make all REST requests // If a user is passed in, the uuid and api token of // the user are used to make the requests -export function requester (user = {}, additionalSets = {}) { - additionalSets = cloneDeep(additionalSets); // cloning because it could be modified later to set cookie - +export function requester (user = {}, additionalSets) { return { get: _requestMaker(user, 'get', additionalSets), post: _requestMaker(user, 'post', additionalSets), @@ -25,21 +21,12 @@ requester.setApiVersion = (version) => { apiVersion = version; }; -function _requestMaker (user, method, additionalSets = {}) { +function _requestMaker (user, method, additionalSets) { if (!apiVersion) throw new Error('apiVersion not set'); return (route, send, query) => { return new Promise((resolve, reject) => { - let url = `http://localhost:${API_TEST_SERVER_PORT}`; - - // do not prefix with api/apiVersion requests to top level routes like dataexport, payments and emails - if (route.indexOf('/email') === 0 || route.indexOf('/export') === 0 || route.indexOf('/paypal') === 0 || route.indexOf('/amazon') === 0 || route.indexOf('/stripe') === 0) { - url += `${route}`; - } else { - url += `/api/${apiVersion}${route}`; - } - - let request = superagent[method](url) + let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/${apiVersion}${route}`) .accept('application/json'); if (user && user._id && user.apiToken) { @@ -48,7 +35,7 @@ function _requestMaker (user, method, additionalSets = {}) { .set('x-api-key', user.apiToken); } - if (!isEmpty(additionalSets)) { + if (additionalSets) { request.set(additionalSets); } @@ -59,58 +46,14 @@ function _requestMaker (user, method, additionalSets = {}) { if (err) { if (!err.response) return reject(err); - let parsedError = _parseError(err); - - return reject(parsedError); + return reject({ + code: err.status, + text: err.response.body.err, + }); } - resolve(_parseRes(response)); + resolve(response.body); }); }); }; } - -function _parseRes (res) { - let contentType = res.headers['content-type'] || ''; - let contentDisposition = res.headers['content-disposition'] || ''; - - if (contentType.indexOf('json') === -1) { // not a json response - return res.text; - } - - if (contentDisposition.indexOf('attachment') !== -1) { - return res.body; - } - - if (apiVersion === 'v2') { - return res.body; - } else if (apiVersion === 'v3') { - if (res.body.message) { - return { - data: res.body.data, - message: res.body.message, - }; - } else { - return res.body.data; - } - } -} - -function _parseError (err) { - let parsedError; - - if (apiVersion === 'v2') { - parsedError = { - code: err.status, - text: err.response.body.err, - }; - } else if (apiVersion === 'v3') { - parsedError = { - code: err.status, - error: err.response.body.error, - message: err.response.body.message, - }; - } - - return parsedError; -} diff --git a/test/helpers/api-integration/translate.js b/test/helpers/api-integration/translate.js index 3ef7d68541..4507d8fceb 100644 --- a/test/helpers/api-integration/translate.js +++ b/test/helpers/api-integration/translate.js @@ -1,5 +1,5 @@ import i18n from '../../../common/script/i18n'; -i18n.translations = require('../../../website/server/libs/api-v3/i18n').translations; +i18n.translations = require('../../../website/src/libs/i18n.js').translations; // Use this to verify error messages returned by the server // That way, if the translated string changes, the test @@ -16,3 +16,4 @@ export function translate (key, variables) { return translatedString; } + diff --git a/test/helpers/api-integration/v2/index.js b/test/helpers/api-integration/v2/index.js index 8828b9b8ed..1d0baeea4b 100644 --- a/test/helpers/api-integration/v2/index.js +++ b/test/helpers/api-integration/v2/index.js @@ -4,5 +4,5 @@ requester.setApiVersion('v2'); export { requester }; export { translate } from '../translate'; -export { checkExistence, resetHabiticaDB } from '../../mongo'; +export { checkExistence, resetHabiticaDB } from '../mongo'; export * from './object-generators'; diff --git a/test/helpers/api-integration/v2/object-generators.js b/test/helpers/api-integration/v2/object-generators.js index 3cd3c7d1d7..e10a4daa9a 100644 --- a/test/helpers/api-integration/v2/object-generators.js +++ b/test/helpers/api-integration/v2/object-generators.js @@ -1,8 +1,7 @@ import { times, - map, } from 'lodash'; -import Bluebird from 'bluebird'; +import Q from 'q'; import { v4 as generateUUID } from 'uuid'; import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes'; import { requester } from '../requester'; @@ -42,29 +41,11 @@ export async function generateGroup (leader, details = {}, update = {}) { details.privacy = details.privacy || 'private'; details.name = details.name || 'test group'; - let members; - - if (details.members) { - members = details.members; - delete details.members; - } - let group = await leader.post('/groups', details); let apiGroup = new ApiGroup(group); - const groupMembershipTypes = { - party: { 'party._id': group._id}, - guild: { guilds: [group._id] }, - }; - - await Bluebird.all( - map(members, (member) => { - return member.update(groupMembershipTypes[group.type]); - }) - ); - await apiGroup.update(update); - await apiGroup.sync(); + return apiGroup; } @@ -91,20 +72,20 @@ export async function createAndPopulateGroup (settings = {}) { let groupLeader = await generateUser(leaderDetails); let group = await generateGroup(groupLeader, groupDetails); - const groupMembershipTypes = { - party: { 'party._id': group._id}, - guild: { guilds: [group._id] }, - }; - - let members = await Bluebird.all( + let members = await Q.all( times(numberOfMembers, () => { - return generateUser(groupMembershipTypes[group.type]); + return generateUser(); }) ); - await group.update({ memberCount: numberOfMembers + 1}); + let memberIds = members.map((member) => { + return member._id; + }); + memberIds.push(groupLeader._id); - let invitees = await Bluebird.all( + await group.update({ members: memberIds }); + + let invitees = await Q.all( times(numberOfInvites, () => { return generateUser(); }) @@ -116,7 +97,7 @@ export async function createAndPopulateGroup (settings = {}) { }); }); - await Bluebird.all(invitationPromises); + await Q.all(invitationPromises); return { groupLeader, diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js deleted file mode 100644 index 6ae15d7ca6..0000000000 --- a/test/helpers/api-integration/v3/index.js +++ /dev/null @@ -1,11 +0,0 @@ -/* eslint-disable no-use-before-define */ - -// Import requester function, set it up for v2, export it -import { requester } from '../requester'; -requester.setApiVersion('v3'); -export { requester }; - -export { translate } from '../translate'; -export { checkExistence, resetHabiticaDB } from '../../mongo'; -export * from './object-generators'; -export { sleep } from '../../sleep'; diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js deleted file mode 100644 index f257c3543f..0000000000 --- a/test/helpers/api-integration/v3/object-generators.js +++ /dev/null @@ -1,157 +0,0 @@ -import { - times, -} from 'lodash'; -import Bluebird from 'bluebird'; -import { v4 as generateUUID } from 'uuid'; -import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes'; -import { requester } from '../requester'; -import * as Tasks from '../../../../website/server/models/task'; - -// Creates a new user and returns it -// If you need the user to have specific requirements, -// such as a balance > 0, just pass in the adjustment -// to the update object. If you want to adjust a nested -// paramter, such as the number of wolf eggs the user has, -// , you can do so by passing in the full path as a string: -// { 'items.eggs.Wolf': 10 } -export async function generateUser (update = {}) { - let username = generateUUID(); - let password = 'password'; - let email = `${username}@example.com`; - - let user = await requester().post('/user/auth/local/register', { - username, - email, - password, - confirmPassword: password, - }); - - let apiUser = new ApiUser(user); - - await apiUser.update(update); - - return apiUser; -} - -export async function generateHabit (update = {}) { - let type = 'habit'; - let task = new Tasks[type](update); - await task.save({ validateBeforeSave: false }); - return task; -} - -export async function generateDaily (update = {}) { - let type = 'daily'; - let task = new Tasks[type](update); - await task.save({ validateBeforeSave: false }); - return task; -} - -export async function generateReward (update = {}) { - let type = 'reward'; - let task = new Tasks[type](update); - await task.save({ validateBeforeSave: false }); - return task; -} - -export async function generateTodo (update = {}) { - let type = 'todo'; - let task = new Tasks[type](update); - await task.save({ validateBeforeSave: false }); - return task; -} - -// Generates a new group. Requires a user object, which -// will will become the groups leader. Takes a details argument -// for the initial group creation and an update argument which -// will update the group via the db -export async function generateGroup (leader, details = {}, update = {}) { - details.type = details.type || 'party'; - details.privacy = details.privacy || 'private'; - details.name = details.name || 'test group'; - - let group = await leader.post('/groups', details); - let apiGroup = new ApiGroup(group); - - await apiGroup.update(update); - - return apiGroup; -} - -// This is generate group + the ability to create -// real users to populate it. The settings object -// takes in: -// members: Number - the number of group members to create. Defaults to 0. -// inivtes: Number - the number of users to create and invite to the group. Defaults to 0. -// groupDetails: Object - how to initialize the group -// leaderDetails: Object - defaults for the leader, defaults with a gem balance so the user -// can create the group -// -// Returns an object with -// members: an array of user objects that correspond to the members of the group -// invitees: an array of user objects that correspond to the invitees of the group -// leader: the leader user object -// group: the group object -export async function createAndPopulateGroup (settings = {}) { - let numberOfMembers = settings.members || 0; - let numberOfInvites = settings.invites || 0; - let groupDetails = settings.groupDetails; - let leaderDetails = settings.leaderDetails || { balance: 10 }; - - let groupLeader = await generateUser(leaderDetails); - let group = await generateGroup(groupLeader, groupDetails); - - const groupMembershipTypes = { - party: { 'party._id': group._id}, - guild: { guilds: [group._id] }, - }; - - let members = await Bluebird.all( - times(numberOfMembers, () => { - return generateUser(groupMembershipTypes[group.type]); - }) - ); - - await group.update({ memberCount: numberOfMembers + 1}); - - let invitees = await Bluebird.all( - times(numberOfInvites, () => { - return generateUser(); - }) - ); - - let invitationPromises = invitees.map((invitee) => { - return groupLeader.post(`/groups/${group._id}/invite`, { - uuids: [invitee._id], - }); - }); - - await Bluebird.all(invitationPromises); - - return { - groupLeader, - group, - members, - invitees, - }; -} - -// Generates a new challenge. Requires an ApiUser object and a -// group-like object (can just be {_id: 'your-group-id'}). The group -// will will become the group that owns the challenge. It takes an -// optional details argument for the initial challenge creation and an -// optional update argument which will update the challenge via the db -export async function generateChallenge (challengeCreator, group, details = {}, update = {}) { - details.group = group._id; - details.name = details.name || 'a challenge'; - details.shortName = details.shortName || 'aChallenge'; - details.prize = details.prize || 0; - details.official = details.official || false; - - let challenge = await challengeCreator.post('/challenges', details); - let apiChallenge = new ApiChallenge(challenge); - - await apiChallenge.update(update); - - return apiChallenge; -} diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js deleted file mode 100644 index ec20ae763e..0000000000 --- a/test/helpers/api-unit.helper.js +++ /dev/null @@ -1,104 +0,0 @@ -import '../../website/server/libs/api-v3/i18n'; -import mongoose from 'mongoose'; -import { defaultsDeep as defaults } from 'lodash'; -import { model as User } from '../../website/server/models/user'; -import { model as Group } from '../../website/server/models/group'; -import mongo from './mongo'; // eslint-disable-line -import moment from 'moment'; -import i18n from '../../common/script/i18n'; -import * as Tasks from '../../website/server/models/task'; - -afterEach((done) => { - sandbox.restore(); - mongoose.connection.db.dropDatabase(done); -}); - -export { sleep } from './sleep'; - -export function generateUser (options = {}) { - return new User(options).toObject(); -} - -export function generateGroup (options = {}) { - return new Group(options).toObject(); -} - -export function generateRes (options = {}) { - let defaultRes = { - render: sandbox.stub(), - send: sandbox.stub(), - status: sandbox.stub().returnsThis(), - sendStatus: sandbox.stub().returnsThis(), - json: sandbox.stub(), - locals: { - user: generateUser(options.localsUser), - group: generateGroup(options.localsGroup), - }, - set: sandbox.stub(), - t (string) { - return i18n.t(string); - }, - }; - - return defaults(options, defaultRes); -} - -export function generateReq (options = {}) { - let defaultReq = { - body: {}, - query: {}, - headers: {}, - header: sandbox.stub().returns(null), - }; - - return defaults(options, defaultReq); -} - -export function generateNext (func) { - return func || sandbox.stub(); -} - -export function generateHistory (days) { - let history = []; - let now = Number(moment().toDate()); - - while (days > 0) { - history.push({ - value: days, - date: Number(moment(now).subtract(days, 'days').toDate()), - }); - days--; - } - - return history; -} - -export function generateTodo (user) { - let todo = { - text: 'test todo', - type: 'todo', - value: 0, - completed: false, - }; - - let task = new Tasks.todo(Tasks.Task.sanitize(todo)); // eslint-disable-line babel/new-cap - task.userId = user._id; - task.save(); - - return task; -} - -export function generateDaily (user) { - let daily = { - text: 'test daily', - type: 'daily', - value: 0, - completed: false, - }; - - let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap - task.userId = user._id; - task.save(); - - return task; -} diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js deleted file mode 100644 index 4f703efdd6..0000000000 --- a/test/helpers/api-v3-integration.helper.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('./api-integration/v3'); diff --git a/test/helpers/common.helper.js b/test/helpers/common.helper.js index 4cd82ca4b4..96064b8142 100644 --- a/test/helpers/common.helper.js +++ b/test/helpers/common.helper.js @@ -1,13 +1,13 @@ import mongoose from 'mongoose'; import { wrap as wrapUser } from '../../common/script/index'; -import { model as User } from '../../website/server/models/user'; +import { model as User } from '../../website/src/models/user'; import { DailySchema, HabitSchema, RewardSchema, TodoSchema, -} from '../../website/server/models/task'; +} from '../../website/src/models/task'; export function generateUser (options = {}) { let user = new User(options).toObject(); diff --git a/test/helpers/content.helper.js b/test/helpers/content.helper.js index c1ac3c5657..be87f06696 100644 --- a/test/helpers/content.helper.js +++ b/test/helpers/content.helper.js @@ -1,6 +1,7 @@ require('./globals.helper'); + import i18n from '../../common/script/i18n'; -i18n.translations = require('../../website/server/libs/api-v3/i18n').translations; +i18n.translations = require('../../website/src/libs/i18n.js').translations; export const STRING_ERROR_MSG = 'Error processing the string. Please see Help > Report a Bug.'; export const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/; diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 253d07e1d4..21f60b7f46 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,39 +1,10 @@ /* eslint-disable no-undef */ -/* eslint-disable global-require */ -/* eslint-disable no-process-env */ - -import Bluebird from 'bluebird'; - //------------------------------ // Global modules //------------------------------ + global._ = require('lodash'); global.chai = require('chai'); chai.use(require('sinon-chai')); chai.use(require('chai-as-promised')); global.expect = chai.expect; -global.sinon = require('sinon'); -global.sandbox = sinon.sandbox.create(); -global.Promise = Bluebird; - -import nconf from 'nconf'; -import mongoose from 'mongoose'; - -//------------------------------ -// Load nconf for unit tests -//------------------------------ -if (process.env.LOAD_SERVER === '0') { // when the server is in a different process we simply connect to mongoose - require('../../website/server/libs/api-v3/setupNconf')('./config.json'); - // Use Q promises instead of mpromise in mongoose - mongoose.Promise = Bluebird; - mongoose.connect(nconf.get('TEST_DB_URI')); -} else { // When running tests and the server in the same process - require('../../website/server/libs/api-v3/setupNconf')('./config.json.example'); - nconf.set('NODE_DB_URI', nconf.get('TEST_DB_URI')); - nconf.set('NODE_ENV', 'test'); - nconf.set('IS_TEST', true); - // We require src/server and npt src/index because - // 1. nconf is already setup - // 2. we don't need clustering - require('../../website/server/server'); -} diff --git a/test/helpers/mongo.js b/test/helpers/mongo.js deleted file mode 100644 index 9ad5cc8700..0000000000 --- a/test/helpers/mongo.js +++ /dev/null @@ -1,110 +0,0 @@ -import mongoose from 'mongoose'; -import { TAVERN_ID } from '../../website/server/models/group'; - -// Useful for checking things that have been deleted, -// but you no longer have access to, -// like private parties or users -export async function checkExistence (collectionName, id) { - return new Promise((resolve, reject) => { - let collection = mongoose.connection.db.collection(collectionName); - - collection.find({_id: id}, {_id: 1}).limit(1).toArray((findError, docs) => { - if (findError) return reject(findError); - - let exists = docs.length > 0; - - resolve(exists); - }); - }); -} - -// Specifically helpful for the GET /groups tests, -// resets the db to an empty state and creates a tavern document -export async function resetHabiticaDB () { - return new Promise((resolve, reject) => { - mongoose.connection.db.dropDatabase((dbErr) => { - if (dbErr) return reject(dbErr); - let groups = mongoose.connection.db.collection('groups'); - let users = mongoose.connection.db.collection('users'); - - users.count({_id: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0'}, (err, count) => { - if (err) return reject(err); - if (count > 0) return resolve(); - - // create the leader for the tavern - users.insertOne({ - _id: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', - apiToken: TAVERN_ID, - auth: { - local: { - username: 'username', - lowerCaseUsername: 'username', - email: 'username@email.com', - salt: 'salt', - hashed_password: 'hashed_password', // eslint-disable-line camelcase - }, - }, - }, (insertErr) => { - if (insertErr) return reject(insertErr); - - // For some mysterious reason after a dropDatabase there can still be a group... - groups.count({_id: TAVERN_ID}, (err2, count2) => { - if (err2) return reject(err2); - if (count2 > 0) return resolve(); - - groups.insertOne({ - _id: TAVERN_ID, - chat: [], - leader: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', // Siena Leslie - name: 'HabitRPG', - type: 'guild', - privacy: 'public', - }, (insertErr2) => { - if (insertErr2) return reject(insertErr2); - - resolve(); - }); - }); - }); - }); - }); - }); -} - -export async function updateDocument (collectionName, doc, update) { - let collection = mongoose.connection.db.collection(collectionName); - - return new Promise((resolve) => { - collection.updateOne({ _id: doc._id }, { $set: update }, (updateErr) => { - if (updateErr) throw new Error(`Error updating ${collectionName}: ${updateErr}`); - resolve(); - }); - }); -} - -export async function getDocument (collectionName, doc) { - let collection = mongoose.connection.db.collection(collectionName); - - return new Promise((resolve) => { - collection.findOne({ _id: doc._id }, (lookupErr, found) => { - if (lookupErr) throw new Error(`Error looking up ${collectionName}: ${lookupErr}`); - resolve(found); - }); - }); -} - -before((done) => { - mongoose.connection.on('open', (err) => { - if (err) return done(err); - resetHabiticaDB() - .then(() => done()) - .catch(done); - }); -}); - -after((done) => { - mongoose.connection.db.dropDatabase((err) => { - if (err) return done(err); - mongoose.connection.close(done); - }); -}); diff --git a/test/helpers/sleep.js b/test/helpers/sleep.js deleted file mode 100644 index f8dd9ab165..0000000000 --- a/test/helpers/sleep.js +++ /dev/null @@ -1,7 +0,0 @@ -export async function sleep (seconds) { - let milliseconds = seconds * 1000; - - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} diff --git a/test/migrations/20150605_ultimate_achievement_backfill.coffee b/test/migrations/20150605_ultimate_achievement_backfill.coffee index 689b49cb67..b5646020b0 100644 --- a/test/migrations/20150605_ultimate_achievement_backfill.coffee +++ b/test/migrations/20150605_ultimate_achievement_backfill.coffee @@ -2,7 +2,7 @@ TEST_DB = process.env.DB_NAME = 'habitrpg_migration_test' process.env.NODE_DB_URI = 'mongodb://localhost/' + TEST_DB -app = require('../../website/server/server') +app = require('../../website/src/server') sh = require('shelljs') runMigration = -> diff --git a/test/mocha.opts b/test/mocha.opts index 69ce4ab86d..a2323400d5 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -6,4 +6,5 @@ --globals io -r babel-polyfill --compilers js:babel-register +--require test/api-legacy/api-helper --require ./test/helpers/globals.helper diff --git a/test/server_side/analytics.test.js b/test/server_side/analytics.test.js index 5257303242..7cde7aede8 100644 --- a/test/server_side/analytics.test.js +++ b/test/server_side/analytics.test.js @@ -30,7 +30,7 @@ describe('analytics', function() { }); describe('init', function() { - var analytics = rewire('../../website/server/libs/api-v2/analytics'); + var analytics = rewire('../../website/src/libs/analytics'); it('throws an error if no options are passed in', function() { expect(analytics).to.throw('No options provided'); @@ -62,7 +62,7 @@ describe('analytics', function() { describe('track', function() { var analyticsData, event_type; - var analytics = rewire('../../website/server/libs/api-v2/analytics'); + var analytics = rewire('../../website/src/libs/analytics'); var initializedAnalytics; beforeEach(function() { @@ -370,7 +370,7 @@ describe('analytics', function() { var purchaseData; - var analytics = rewire('../../website/server/libs/api-v2/analytics'); + var analytics = rewire('../../website/src/libs/analytics'); var initializedAnalytics; beforeEach(function() { diff --git a/test/server_side/controllers/groups.test.js b/test/server_side/controllers/groups.test.js index bf12df321b..353fb581fe 100644 --- a/test/server_side/controllers/groups.test.js +++ b/test/server_side/controllers/groups.test.js @@ -3,12 +3,12 @@ var chai = require("chai"); chai.use(require("sinon-chai")); var expect = chai.expect; -var Bluebird = require('bluebird'); -var Group = require('../../../website/server/models/group').model; -var groupsController = require('../../../website/server/controllers/api-v2/groups'); +var Q = require('q'); +var Group = require('../../../website/src/models/group').model; +var groupsController = require('../../../website/src/controllers/api-v2/groups'); describe('Groups Controller', function() { - var utils = require('../../../website/server/libs/api-v2/utils'); + var utils = require('../../../website/src/libs/utils'); describe('#invite', function() { var res, req, user, group; @@ -69,7 +69,7 @@ describe('Groups Controller', function() { }); context('emails', function() { - var EmailUnsubscription = require('../../../website/server/models/emailUnsubscription').model; + var EmailUnsubscription = require('../../../website/src/models/emailUnsubscription').model; var execStub, selectStub; beforeEach(function() { @@ -301,7 +301,7 @@ describe('Groups Controller', function() { }); afterEach(function() { - Promise.all.restore(); + Q.all.restore(); }); context('error conditions', function() { @@ -342,7 +342,7 @@ describe('Groups Controller', function() { }); it('sends 500 if group cannot save', function() { - Promise.all.returns({ + Q.all.returns({ done: sinon.stub().callsArgWith(1, {err: 'save error'}) }); var nextSpy = sinon.spy(); diff --git a/test/server_side/controllers/user.test.js b/test/server_side/controllers/user.test.js index 59589df511..2be7f7215a 100644 --- a/test/server_side/controllers/user.test.js +++ b/test/server_side/controllers/user.test.js @@ -4,7 +4,7 @@ chai.use(require("sinon-chai")) var expect = chai.expect var rewire = require('rewire'); -var userController = rewire('../../../website/server/controllers/api-v2/user'); +var userController = rewire('../../../website/src/controllers/api-v2/user'); describe('User Controller', function() { @@ -359,7 +359,7 @@ describe('User Controller', function() { }); it('sends webhooks', function() { - var webhook = require('../../../website/server/libs/webhook'); + var webhook = require('../../../website/src/libs/webhook'); sinon.spy(webhook, 'sendTaskWebhook'); userController.score(req, res); @@ -384,7 +384,7 @@ describe('User Controller', function() { }); context('save callback dealing with non challenge tasks', function() { - var Challenge = require('../../../website/server/models/challenge').model; + var Challenge = require('../../../website/src/models/challenge').model; beforeEach(function() { user.save.yields(null, user); @@ -446,7 +446,7 @@ describe('User Controller', function() { }); context('save callback dealing with challenge tasks', function() { - var Challenge = require('../../../website/server/models/challenge').model; + var Challenge = require('../../../website/src/models/challenge').model; var chal; beforeEach(function() { diff --git a/test/server_side/webhooks.test.js b/test/server_side/webhooks.test.js index 621d0daba3..ef6636bf93 100644 --- a/test/server_side/webhooks.test.js +++ b/test/server_side/webhooks.test.js @@ -4,7 +4,7 @@ chai.use(require("sinon-chai")) var expect = chai.expect var rewire = require('rewire'); -var webhook = rewire('../../website/server/libs/api-v2/webhook'); +var webhook = rewire('../../website/src/libs/webhook'); describe('webhooks', function() { var postSpy; diff --git a/test/spec/chatServicesSpec.js b/test/spec/chatServicesSpec.js new file mode 100644 index 0000000000..e65c36be68 --- /dev/null +++ b/test/spec/chatServicesSpec.js @@ -0,0 +1,90 @@ +'use strict'; + +describe('Chat Service', function() { + var $httpBackend, $http, chat, user; + + beforeEach(function() { + module(function($provide) { + var usr = specHelper.newUser(); + $provide.value('User', {user:usr}); + }); + + inject(function(_$httpBackend_, Chat, User) { + $httpBackend = _$httpBackend_; + chat = Chat; + user = User; + }); + }); + + describe('utils', function() { + it('calls post chat endpoint', function() { + var payload = { + gid: 'habitrpg', + message: 'Chat', + previousMsg: 'previous-msg-id' + } + + $httpBackend.expectPOST('/api/v2/groups/habitrpg/chat?message=Chat&previousMsg=previous-msg-id').respond(); + chat.utils.postChat(payload, undefined); + $httpBackend.flush(); + }); + + it('calls like chat endpoint', function() { + var payload = { + gid: 'habitrpg', + messageId: 'msg-id' + } + + $httpBackend.expectPOST('/api/v2/groups/habitrpg/chat/msg-id/like').respond(); + chat.utils.like(payload, undefined); + $httpBackend.flush(); + }); + + it('calls delete chat endpoint', function() { + var payload = { + gid: 'habitrpg', + messageId: 'msg-id' + } + + $httpBackend.expectDELETE('/api/v2/groups/habitrpg/chat/msg-id').respond(); + chat.utils.deleteChatMessage(payload, undefined); + $httpBackend.flush(); + }); + + it('calls flag chat endpoint', function() { + var payload = { + gid: 'habitrpg', + messageId: 'msg-id' + } + + $httpBackend.expectPOST('/api/v2/groups/habitrpg/chat/msg-id/flag').respond(); + chat.utils.flagChatMessage(payload, undefined); + $httpBackend.flush(); + }); + + it('calls clear flags endpoint', function() { + var payload = { + gid: 'habitrpg', + messageId: 'msg-id' + } + + $httpBackend.expectPOST('/api/v2/groups/habitrpg/chat/msg-id/clearflags').respond(); + chat.utils.clearFlagCount(payload, undefined); + $httpBackend.flush(); + }); + }); + + describe('seenMessage(gid)', function() { + it('calls chat seen endpoint', function() { + $httpBackend.expectPOST('/api/v2/groups/habitrpg/chat/seen').respond(); + chat.seenMessage('habitrpg'); + $httpBackend.flush(); + }); + + it('removes newMessages for a specific guild from user object', function() { + user.user.newMessages = {habitrpg: "foo"}; + chat.seenMessage('habitrpg'); + expect(user.user.newMessages.habitrpg).to.not.exist; + }); + }); +}); diff --git a/test/spec/controllers/authCtrlSpec.js b/test/spec/controllers/authCtrlSpec.js index 75ea898fca..b1f87d4d91 100644 --- a/test/spec/controllers/authCtrlSpec.js +++ b/test/spec/controllers/authCtrlSpec.js @@ -25,7 +25,7 @@ describe('Auth Controller', function() { describe('logging in', function() { it('should log in users with correct uname / pass', function() { - $httpBackend.expectPOST('/api/v3/user/auth/local/login').respond({data: {id: 'abc', apiToken: 'abc'}}); + $httpBackend.expectPOST('/api/v2/user/auth/local').respond({id: 'abc', token: 'abc'}); scope.auth(); $httpBackend.flush(); expect(user.authenticate).to.be.calledOnce; @@ -33,7 +33,7 @@ describe('Auth Controller', function() { }); it('should not log in users with incorrect uname / pass', function() { - $httpBackend.expectPOST('/api/v3/user/auth/local/login').respond(404, ''); + $httpBackend.expectPOST('/api/v2/user/auth/local').respond(404, ''); scope.auth(); $httpBackend.flush(); expect(user.authenticate).to.not.be.called; diff --git a/test/spec/controllers/challengesCtrlSpec.js b/test/spec/controllers/challengesCtrlSpec.js index ec63ee20c4..0ebebeba44 100644 --- a/test/spec/controllers/challengesCtrlSpec.js +++ b/test/spec/controllers/challengesCtrlSpec.js @@ -1,7 +1,7 @@ 'use strict'; describe('Challenges Controller', function() { - var rootScope, scope, user, User, ctrl, groups, members, notification, state, challenges, tasks, tavernId; + var rootScope, scope, user, User, ctrl, groups, members, notification, state; beforeEach(function() { module(function($provide) { @@ -14,7 +14,7 @@ describe('Challenges Controller', function() { $provide.value('User', User); }); - inject(function($rootScope, $controller, _$state_, _Groups_, _Members_, _Notification_, _Challenges_, _Tasks_, _TAVERN_ID_){ + inject(function($rootScope, $controller, _$state_, _Groups_, _Members_, _Notification_){ scope = $rootScope.$new(); rootScope = $rootScope; @@ -23,13 +23,10 @@ describe('Challenges Controller', function() { ctrl = $controller('ChallengesCtrl', {$scope: scope, User: User}); - challenges = _Challenges_; - tasks = _Tasks_; groups = _Groups_; members = _Members_; notification = _Notification_; state = _$state_; - tavernId = _TAVERN_ID_; }); }); @@ -42,36 +39,30 @@ describe('Challenges Controller', function() { description: 'You are the owner and member', leader: user._id, members: [user], - _isMember: true, - _id: 'ownMem-id', + _isMember: true }); ownNotMem = specHelper.newChallenge({ description: 'You are the owner, but not a member', leader: user._id, members: [], - _isMember: false, - _id: 'ownNotMem-id', + _isMember: false }); notOwnMem = specHelper.newChallenge({ description: 'Not owner but a member', leader: {_id:"test"}, members: [user], - _isMember: true, - _id: 'notOwnMem-id', + _isMember: true }); notOwnNotMem = specHelper.newChallenge({ description: 'Not owner or member', leader: {_id:"test"}, members: [], - _isMember: false, - _id: 'notOwnNotMem-id', + _isMember: false }); - user.challenges = [ownMem._id, notOwnMem._id]; - scope.search = { group: _.transform(groups, function(m,g){m[g._id]=true;}) }; @@ -218,17 +209,6 @@ describe('Challenges Controller', function() { }); describe('addTask', function() { - var challenge; - - beforeEach(function () { - challenge = specHelper.newChallenge({ - description: 'You are the owner and member', - leader: user._id, - members: [user], - _isMember: true - }); - }); - it('adds default task to array', function() { var taskArray = []; var listDef = { @@ -236,27 +216,26 @@ describe('Challenges Controller', function() { type: 'todo' } - scope.addTask(taskArray, listDef, challenge); + scope.addTask(taskArray, listDef); - expect(challenge['todos'].length).to.eql(1); - expect(challenge['todos'][0].text).to.eql('new todo text'); - expect(challenge['todos'][0].type).to.eql('todo'); + expect(taskArray.length).to.eql(1); + expect(taskArray[0].text).to.eql('new todo text'); + expect(taskArray[0].type).to.eql('todo'); }); it('adds the task to the front of the array', function() { var previousTask = specHelper.newTodo({ text: 'previous task' }); - var taskArray = []; - challenge['todos'] = [previousTask]; + var taskArray = [previousTask]; var listDef = { newTask: 'new todo', type: 'todo' } - scope.addTask(taskArray, listDef, challenge); + scope.addTask(taskArray, listDef); - expect(challenge['todos'].length).to.eql(2); - expect(challenge['todos'][0].text).to.eql('new todo'); - expect(challenge['todos'][1].text).to.eql('previous task'); + expect(taskArray.length).to.eql(2); + expect(taskArray[0].text).to.eql('new todo'); + expect(taskArray[1].text).to.eql('previous task'); }); it('removes text from new task input box', function() { @@ -266,7 +245,7 @@ describe('Challenges Controller', function() { type: 'todo' } - scope.addTask(taskArray, listDef, challenge); + scope.addTask(taskArray, listDef); expect(listDef.newTask).to.not.exist; }); @@ -281,37 +260,31 @@ describe('Challenges Controller', function() { }); describe('removeTask', function() { - var task, challenge; + var task, list; beforeEach(function() { sandbox.stub(window, 'confirm'); task = specHelper.newTodo(); - challenge = specHelper.newChallenge({ - description: 'You are the owner and member', - leader: user._id, - members: [user], - _isMember: true - }); - challenge['todos'] = [task]; + list = [task]; }); it('asks user to confirm deletion', function() { - scope.removeTask(task, challenge); + scope.removeTask(task, list); expect(window.confirm).to.be.calledOnce; }); it('does not remove task from list if not confirmed', function() { window.confirm.returns(false); - scope.removeTask(task, challenge); + scope.removeTask(task, list); - expect(challenge['todos']).to.include(task); + expect(list).to.include(task); }); it('removes task from list', function() { window.confirm.returns(true); - scope.removeTask(task, challenge); + scope.removeTask(task, list); - expect(challenge['todos']).to.not.include(task); + expect(list).to.not.include(task); }); }); @@ -328,23 +301,16 @@ describe('Challenges Controller', function() { context('challenge owner interactions', function() { describe("save challenge", function() { - var alert, createChallengeSpy, challengeResponse, taskChallengeCreateSpy; + var alert; beforeEach(function(){ alert = sandbox.stub(window, "alert"); - createChallengeSpy = sinon.stub(challenges, 'createChallenge'); - challengeResponse = {data: {data: {_id: 'new-challenge'}}}; - createChallengeSpy.returns(Promise.resolve(challengeResponse)); - - taskChallengeCreateSpy = sinon.stub(tasks, 'createChallengeTasks'); - var taskResponse = {data: {data: []}}; - taskChallengeCreateSpy.returns(Promise.resolve(taskResponse)); }); - it("opens an alert box if challenge.group is not specified", function() { + it("opens an alert box if challenge.group is not specified", function() + { var challenge = specHelper.newChallenge({ name: 'Challenge without a group', - shortName: 'chal without group', group: null }); @@ -357,7 +323,6 @@ describe('Challenges Controller', function() { it("opens an alert box if isNew and user does not have enough gems", function() { var challenge = specHelper.newChallenge({ name: 'Challenge without enough gems', - shortName: 'chal without gem', prize: 5 }); @@ -369,84 +334,81 @@ describe('Challenges Controller', function() { }); it("saves the challenge if user does not have enough gems, but the challenge is not new", function() { - var updateChallengeSpy = sinon.spy(challenges, 'updateChallenge'); - var challenge = specHelper.newChallenge({ _id: 'challenge-has-id-so-its-not-new', name: 'Challenge without enough gems', - shortName: 'chal without gem', prize: 5, + $save: sandbox.spy() // stub $save }); scope.maxPrize = 0; scope.save(challenge); - expect(updateChallengeSpy).to.be.calledOnce; + expect(challenge.$save).to.be.calledOnce; expect(alert).to.not.be.called; }); it("saves the challenge if user has enough gems and challenge is new", function() { var challenge = specHelper.newChallenge({ name: 'Challenge without enough gems', - shortName: 'chal without gem', prize: 5, + $save: sandbox.spy() // stub $save }); scope.maxPrize = 5; scope.save(challenge); - expect(createChallengeSpy).to.be.calledOnce; + expect(challenge.$save).to.be.calledOnce; expect(alert).to.not.be.called; }); - it('saves challenge and then proceeds to detail page', function(done) { + it('saves challenge and then proceeds to detail page', function() { + var saveSpy = sandbox.stub(); + saveSpy.yields({_id: 'challenge-id'}); sandbox.stub(state, 'transitionTo'); var challenge = specHelper.newChallenge({ - name: 'Challenge', - shortName: 'chal', + $save: saveSpy // stub $save }); - setTimeout(function() { - expect(createChallengeSpy).to.be.calledOnce; - expect(state.transitionTo).to.be.calledWith( - 'options.social.challenges.detail', - { cid: 'new-challenge' }, - { - reload: true, inherit: false, notify: true - } - ); - done(); - }, 1000); - scope.save(challenge); + + expect(state.transitionTo).to.be.calledOnce; + expect(state.transitionTo).to.be.calledWith( + 'options.social.challenges.detail', + { cid: 'challenge-id' }, + { + reload: true, inherit: false, notify: true + } + ); }); - it('saves new challenge and syncs User', function(done) { - var challenge = specHelper.newChallenge(); - challenge.shortName = 'chal'; + it('saves new challenge and syncs User', function() { + var saveSpy = sandbox.stub(); + saveSpy.yields({_id: 'new-challenge'}); - setTimeout(function() { - expect(User.sync).to.be.calledOnce; - done(); - }, 1000); + var challenge = specHelper.newChallenge({ + $save: saveSpy // stub $save + }); scope.save(challenge); + + expect(User.sync).to.be.calledOnce; }); - it('saves new challenge and syncs User', function(done) { + it('saves new challenge and syncs User', function() { + var saveSpy = sandbox.stub(); + saveSpy.yields({_id: 'new-challenge'}); sinon.stub(notification, 'text'); - var challenge = specHelper.newChallenge(); - challenge.shortName = 'chal'; - - setTimeout(function() { - expect(notification.text).to.be.calledOnce; - expect(notification.text).to.be.calledWith(window.env.t('challengeCreated')); - done(); - }, 1000); + var challenge = specHelper.newChallenge({ + $save: saveSpy // stub $save + }); scope.save(challenge); + + expect(notification.text).to.be.calledOnce; + expect(notification.text).to.be.calledWith(window.env.t('challengeCreated')); }); }); @@ -494,7 +456,7 @@ describe('Challenges Controller', function() { it('defaults to tavern if no group can be set as default', function() { scope.create(); - expect(scope.newChallenge.group).to.eql(tavernId); + expect(scope.newChallenge.group).to.eql('habitrpg'); }); it('calculates maxPrize', function() { @@ -516,7 +478,7 @@ describe('Challenges Controller', function() { expect(chal.todos).to.eql([]); expect(chal.rewards).to.eql([]); expect(chal.leader).to.eql('unique-user-id'); - expect(chal.group).to.eql(tavernId); + expect(chal.group).to.eql('habitrpg'); expect(chal.timestamp).to.be.greaterThan(0); expect(chal.official).to.eql(false); }); @@ -527,7 +489,7 @@ describe('Challenges Controller', function() { it('returns true if user has no gems', function() { User.user.balance = 0; scope.newChallenge = specHelper.newChallenge({ - group: tavernId + group: 'habitrpg' }); var cannotCreateTavernChallenge = scope.insufficientGemsForTavernChallenge(); @@ -537,7 +499,7 @@ describe('Challenges Controller', function() { it('returns false if user has gems', function() { User.user.balance = .25; scope.newChallenge = specHelper.newChallenge({ - group: tavernId + group: 'habitrpg' }); var cannotCreateTavernChallenge = scope.insufficientGemsForTavernChallenge(); @@ -665,16 +627,15 @@ describe('Challenges Controller', function() { context('User interactions', function() { describe('join', function() { - it('calls challenge join', function(){ - var joinChallengeSpy = sinon.spy(challenges, 'joinChallenge'); - + it('calls challenge.$join', function(){ var challenge = specHelper.newChallenge({ _id: 'challenge-to-join', + $join: sandbox.spy() }); scope.join(challenge); - expect(joinChallengeSpy).to.be.calledOnce; + expect(challenge.$join).to.be.calledOnce; }); }); @@ -708,6 +669,7 @@ describe('Challenges Controller', function() { describe('leave', function() { var challenge = specHelper.newChallenge({ _id: 'challenge-to-leave', + $leave: sandbox.spy() }); var clickEvent = { @@ -723,12 +685,11 @@ describe('Challenges Controller', function() { expect(scope.selectedChal).to.not.exist; }); - it('calls challenge leave when anything but cancel is chosen', function() { - var leaveChallengeSpy = sinon.spy(challenges, 'leaveChallenge'); + it('calls challenge.$leave when anything but cancel is chosen', function() { scope.clickLeave(challenge, clickEvent); - scope.leave('not-cancel', challenge); - expect(leaveChallengeSpy).to.be.calledOnce; + scope.leave('not-cancel'); + expect(challenge.$leave).to.be.calledOnce; }); }); }); @@ -737,36 +698,31 @@ describe('Challenges Controller', function() { beforeEach(function() { sandbox.stub(members, 'selectMember'); sandbox.stub(rootScope, 'openModal'); - members.selectMember.returns(Promise.resolve()); }); describe('sendMessageToChallengeParticipant', function() { - it('opens private-message modal', function(done) { + it('opens private-message modal', function() { + members.selectMember.yields(); scope.sendMessageToChallengeParticipant(user._id); - setTimeout(function() { - expect(rootScope.openModal).to.be.calledOnce; - expect(rootScope.openModal).to.be.calledWith( - 'private-message', - { controller: 'MemberModalCtrl' } - ); - done(); - }, 1000); + expect(rootScope.openModal).to.be.calledOnce; + expect(rootScope.openModal).to.be.calledWith( + 'private-message', + { controller: 'MemberModalCtrl' } + ); }); }); describe('sendGiftToChallengeParticipant', function() { - it('opens send-gift modal', function(done) { + it('opens send-gift modal', function() { + members.selectMember.yields(); scope.sendGiftToChallengeParticipant(user._id); - setTimeout(function() { - expect(rootScope.openModal).to.be.calledOnce; - expect(rootScope.openModal).to.be.calledWith( - 'send-gift', - { controller: 'MemberModalCtrl' } - ); - done(); - }, 1000); + expect(rootScope.openModal).to.be.calledOnce; + expect(rootScope.openModal).to.be.calledWith( + 'send-gift', + { controller: 'MemberModalCtrl' } + ); }); }); }); diff --git a/test/spec/controllers/copyMessageModalControllerSpec.js b/test/spec/controllers/copyMessageModalControllerSpec.js index 419fb2341e..bb25b6447e 100644 --- a/test/spec/controllers/copyMessageModalControllerSpec.js +++ b/test/spec/controllers/copyMessageModalControllerSpec.js @@ -4,9 +4,11 @@ describe("CopyMessageModal controller", function() { var scope, ctrl, user, Notification, $rootScope, $controller; beforeEach(function() { - module(function($provide) {}); + module(function($provide) { + $provide.value('User', {}); + }); - inject(function($rootScope, _$controller_, _Notification_, User){ + inject(function($rootScope, _$controller_, _Notification_){ user = specHelper.newUser(); user._id = "unique-user-id"; user.ops = { @@ -18,12 +20,10 @@ describe("CopyMessageModal controller", function() { $controller = _$controller_; - User.setUser(user); - // Load RootCtrl to ensure shared behaviors are loaded - $controller('RootCtrl', {$scope: scope, User: User}); + $controller('RootCtrl', {$scope: scope, User: {user: user}}); - ctrl = $controller('CopyMessageModalCtrl', {$scope: scope, User: User}); + ctrl = $controller('CopyMessageModalCtrl', {$scope: scope, User: {user: user}}); Notification = _Notification_; Notification.text = sandbox.spy(); diff --git a/test/spec/controllers/filtersCtrlSpec.js b/test/spec/controllers/filtersCtrlSpec.js index b2adac581d..bbebce3cfd 100644 --- a/test/spec/controllers/filtersCtrlSpec.js +++ b/test/spec/controllers/filtersCtrlSpec.js @@ -1,16 +1,13 @@ 'use strict'; describe('Filters Controller', function() { - var scope, user, userService; + var scope, user; - beforeEach(inject(function($rootScope, $controller, Shared, User) { + beforeEach(inject(function($rootScope, $controller, Shared) { user = specHelper.newUser(); Shared.wrap(user); scope = $rootScope.$new(); - // user.filters = {}; - User.setUser(user); - userService = User; - $controller('FiltersCtrl', {$scope: scope, User: User}); + $controller('FiltersCtrl', {$scope: scope, User: {user: user}}); })); describe('tags', function(){ @@ -25,9 +22,9 @@ describe('Filters Controller', function() { it('toggles tag filtering', inject(function(Shared){ var tag = {id: Shared.uuid(), name: 'myTag'}; scope.toggleFilter(tag); - expect(userService.user.filters[tag.id]).to.eql(true); + expect(user.filters[tag.id]).to.eql(true); scope.toggleFilter(tag); - expect(userService.user.filters[tag.id]).to.eql(false); + expect(user.filters[tag.id]).to.eql(false); })); }); @@ -36,7 +33,7 @@ describe('Filters Controller', function() { scope.filterQuery = 'task'; scope.updateTaskFilter(); - expect(userService.user.filterQuery).to.eql(scope.filterQuery); + expect(user.filterQuery).to.eql(scope.filterQuery); }); }); }); diff --git a/test/spec/controllers/footerCtrlSpec.js b/test/spec/controllers/footerCtrlSpec.js index ce5245d099..7b12bc2875 100644 --- a/test/spec/controllers/footerCtrlSpec.js +++ b/test/spec/controllers/footerCtrlSpec.js @@ -1,17 +1,12 @@ 'use strict'; describe('Footer Controller', function() { - var scope, user, User; + var scope, user; beforeEach(inject(function($rootScope, $controller) { + console.log(window.env.NODE_ENV); user = specHelper.newUser(); - User = { - log: sandbox.stub(), - set: sandbox.stub(), - addTenGems: sandbox.stub(), - addHourglass: sandbox.stub(), - user: user - }; + var User = {log: sandbox.stub(), set: sandbox.stub(), user: user}; scope = $rootScope.$new(); $controller('FooterCtrl', {$scope: scope, User: User}); })); @@ -45,17 +40,21 @@ describe('Footer Controller', function() { describe('#addTenGems', function() { it('posts to /user/addTenGems', inject(function($httpBackend) { + $httpBackend.expectPOST('/api/v2/user/addTenGems').respond({}); + scope.addTenGems(); - expect(User.addTenGems).to.have.been.called; + $httpBackend.flush(); })); }); describe('#addHourglass', function() { it('posts to /user/addHourglass', inject(function($httpBackend) { + $httpBackend.expectPOST('/api/v2/user/addHourglass').respond({}); + scope.addHourglass(); - expect(User.addHourglass).to.have.been.called; + $httpBackend.flush(); })); }); diff --git a/test/spec/controllers/groupCtrlSpec.js b/test/spec/controllers/groupCtrlSpec.js index 6a5819ec67..dce054bef6 100644 --- a/test/spec/controllers/groupCtrlSpec.js +++ b/test/spec/controllers/groupCtrlSpec.js @@ -23,53 +23,6 @@ describe('Groups Controller', function() { }); }); - describe("isMemberOfPendingQuest", function() { - var party; - var partyStub; - - beforeEach(function () { - party = specHelper.newGroup({ - _id: "unique-party-id", - type: 'party', - members: ['leader-id'] // Ensure we wouldn't pass automatically. - }); - - partyStub = sandbox.stub(groups, "party", function() { - return party; - }); - }); - - it("returns false if group is does not have a quest", function() { - expect(scope.isMemberOfPendingQuest(user._id, party)).to.not.be.ok; - }); - - it("returns false if group quest has not members", function() { - party.quest = { - 'key': 'random-key', - }; - expect(scope.isMemberOfPendingQuest(user._id, party)).to.not.be.ok; - }); - - it("returns false if group quest is active", function() { - party.quest = { - 'key': 'random-key', - 'members': {}, - 'active': true, - }; - party.quest.members[user._id] = true; - expect(scope.isMemberOfPendingQuest(user._id, party)).to.not.be.ok; - }); - - it("returns true if user is a member of a pending quest", function() { - party.quest = { - 'key': 'random-key', - 'members': {}, - }; - party.quest.members[user._id] = true; - expect(scope.isMemberOfPendingQuest(user._id, party)).to.be.ok; - }); - }); - describe("isMemberOfGroup", function() { it("returns true if group is the user's party retrieved from groups service", function() { var party = specHelper.newGroup({ @@ -78,7 +31,7 @@ describe('Groups Controller', function() { members: ['leader-id'] // Ensure we wouldn't pass automatically. }); - var partyStub = sandbox.stub(groups, "party", function() { + var partyStub = sandbox.stub(groups,"party", function() { return party; }); @@ -93,9 +46,12 @@ describe('Groups Controller', function() { members: [user._id] }); - user.guilds = [guild._id]; + var myGuilds = sandbox.stub(groups,"myGuilds", function() { + return [guild]; + }); expect(scope.isMemberOfGroup(user._id, guild)).to.be.ok; + expect(myGuilds).to.be.called; }); it('does not return true if guild is not included in myGuilds call', function(){ @@ -106,9 +62,12 @@ describe('Groups Controller', function() { members: ['not-user-id'] }); - user.guilds = []; + var myGuilds = sandbox.stub(groups,"myGuilds", function() { + return []; + }); expect(scope.isMemberOfGroup(user._id, guild)).to.not.be.ok; + expect(myGuilds).to.be.calledOnce; }); }); @@ -163,12 +122,12 @@ describe('Groups Controller', function() { scope.editGroup(guild); }); - it('calls group update', () => { - let guildUpdate = sandbox.spy(groups.Group, 'update'); + it('calls group.save', () => { + let guildSave = sandbox.spy(scope.groupCopy, '$save'); scope.saveEdit(guild); - expect(guildUpdate).to.be.calledOnce; + expect(guildSave).to.be.calledOnce; }); it('calls cancelEdit', () => { diff --git a/test/spec/controllers/headerCtrlSpec.js b/test/spec/controllers/headerCtrlSpec.js index b32c5ccbd5..ab37d11205 100644 --- a/test/spec/controllers/headerCtrlSpec.js +++ b/test/spec/controllers/headerCtrlSpec.js @@ -5,12 +5,13 @@ describe('Header Controller', function() { beforeEach(function() { module(function($provide) { - user = specHelper.newUser(); - user._id = "unique-user-id" - $provide.value('User', {user: user}); + $provide.value('User', {}); }); inject(function(_$rootScope_, _$controller_, _$location_){ + user = specHelper.newUser(); + user._id = "unique-user-id" + scope = _$rootScope_.$new(); $rootScope = _$rootScope_; diff --git a/test/spec/controllers/inventoryCtrlSpec.js b/test/spec/controllers/inventoryCtrlSpec.js index 4000f80a33..2553acbcf3 100644 --- a/test/spec/controllers/inventoryCtrlSpec.js +++ b/test/spec/controllers/inventoryCtrlSpec.js @@ -4,9 +4,11 @@ describe('Inventory Controller', function() { var scope, ctrl, user, rootScope; beforeEach(function() { - module(function($provide) {}); + module(function($provide) { + $provide.value('User', {}); + }); - inject(function($rootScope, $controller, Shared, User, $location, $window) { + inject(function($rootScope, $controller, Shared){ user = specHelper.newUser({ balance: 4, items: { @@ -24,21 +26,17 @@ describe('Inventory Controller', function() { Shared.wrap(user); var mockWindow = { - confirm: function(msg) { + confirm: function(msg){ return true; - }, + } }; - scope = $rootScope.$new(); rootScope = $rootScope; - User.user = user; - User.setUser(user); - // Load RootCtrl to ensure shared behaviors are loaded - $controller('RootCtrl', {$scope: scope, User: User, $window: mockWindow}); + $controller('RootCtrl', {$scope: scope, User: {user: user}, $window: mockWindow}); - ctrl = $controller('InventoryCtrl', {$scope: scope, User: User, $window: mockWindow}); + ctrl = $controller('InventoryCtrl', {$scope: scope, User: {user: user}, $window: mockWindow}); }); }); @@ -90,16 +88,14 @@ describe('Inventory Controller', function() { expect(rootScope.openModal).to.have.been.calledWith('hatchPet'); }); - //@TODO: Fix Common hatch - xit('does not show modal if user tries to hatch a pet they own', function(){ + it('does not show modal if user tries to hatch a pet they own', function(){ user.items.pets['Cactus-Base'] = 5; scope.chooseEgg('Cactus'); scope.choosePotion('Base'); expect(rootScope.openModal).to.not.have.been.called; }); - //@TODO: Fix Common hatch - xit('does not show modal if user tries to hatch a premium quest pet', function(){ + it('does not show modal if user tries to hatch a premium quest pet', function(){ user.items.eggs = {Snake: 1}; user.items.hatchingPotions = {Peppermint: 1}; scope.chooseEgg('Snake'); diff --git a/test/spec/controllers/inviteToGroupCtrlSpec.js b/test/spec/controllers/inviteToGroupCtrlSpec.js index 5c316855e9..385f42dda1 100644 --- a/test/spec/controllers/inviteToGroupCtrlSpec.js +++ b/test/spec/controllers/inviteToGroupCtrlSpec.js @@ -1,7 +1,7 @@ 'use strict'; describe('Invite to Group Controller', function() { - var scope, ctrl, groups, user, guild, rootScope, $controller; + var scope, ctrl, groups, user, guild, $rootScope; beforeEach(function() { user = specHelper.newUser({ @@ -13,12 +13,8 @@ describe('Invite to Group Controller', function() { $provide.value('injectedGroup', { user: user }); }); - inject(function(_$rootScope_, _$controller_, Groups) { - rootScope = _$rootScope_; - - scope = _$rootScope_.$new(); - - $controller = _$controller_; + inject(function($rootScope, $controller, Groups){ + scope = $rootScope.$new(); // Load RootCtrl to ensure shared behaviors are loaded $controller('RootCtrl', {$scope: scope, User: {user: user}}); @@ -48,101 +44,69 @@ describe('Invite to Group Controller', function() { }); describe('inviteNewUsers', function() { - var groupInvite, groupCreate; - beforeEach(function() { scope.group = specHelper.newGroup({ type: 'party', + $save: sinon.stub().returns({ + then: function(cb) { cb(); } + }) }); - groupCreate = sandbox.stub(groups.Group, 'create'); - groupInvite = sandbox.stub(groups.Group, 'invite'); + sandbox.stub(groups.Group, 'invite'); }); context('if the party does not already exist', function() { - var groupResponse; - beforeEach(function() { delete scope.group._id; - groupResponse = {data: {data: scope.group}} }); it('saves the group if a new group is being created', function() { - groupCreate.returns(Promise.resolve(groupResponse)); scope.inviteNewUsers('uuid'); - expect(groupCreate).to.be.calledOnce; + expect(scope.group.$save).to.be.calledOnce; }); it('uses provided name', function() { scope.group.name = 'test party'; - - groupCreate.returns(Promise.resolve(groupResponse)); - scope.inviteNewUsers('uuid'); - - expect(groupCreate).to.be.calledWith(scope.group); expect(scope.group.name).to.eql('test party'); }); it('names the group if no name is provided', function() { scope.group.name = ''; - - groupCreate.returns(Promise.resolve(groupResponse)); - scope.inviteNewUsers('uuid'); - - expect(groupCreate).to.be.calledWith(scope.group); expect(scope.group.name).to.eql(env.t('possessiveParty', {name: user.profile.name})); }); }); context('email', function() { - beforeEach(function () { - sandbox.stub(rootScope, 'hardRedirect'); - }); - - it('invites user with emails', function(done) { + it('invites user with emails', function() { scope.emails = [ {name: 'Luigi', email: 'mario_bro@themushroomkingdom.com'}, {name: 'Mario', email: 'mario@tmk.com'} ]; - var inviteDetails = { + scope.inviteNewUsers('email'); + expect(groups.Group.invite).to.be.calledOnce; + expect(groups.Group.invite).to.be.calledWith({ + gid: scope.group._id, + }, { inviter: user.profile.name, emails: [ {name: 'Luigi', email: 'mario_bro@themushroomkingdom.com'}, {name: 'Mario', email: 'mario@tmk.com'} ] - }; - groupInvite.returns( - Promise.resolve() - .then(function () { - expect(groupInvite).to.be.calledOnce; - expect(groupInvite).to.be.calledWith(scope.group._id, inviteDetails); - done(); - }) - ); - - scope.inviteNewUsers('email'); + }); }); - it('resets email list after sending', function(done) { + it('resets email list after sending', function() { + groups.Group.invite.yields(); scope.emails[0].name = 'Luigi'; scope.emails[0].email = 'mario_bro@themushroomkingdom.com'; - groupInvite.returns( - Promise.resolve() - .then(function () { - //We use a timeout to test items that happen after the promise is resolved - setTimeout(function(){ - expect(scope.emails).to.eql([{name:'', email: ''},{name:'', email: ''}]); - done(); - }, 1000); - }) - ); - scope.inviteNewUsers('email'); + + expect(scope.emails).to.eql([{name:'', email: ''},{name:'', email: ''}]); }); it('filters out blank email inputs', function() { @@ -152,93 +116,66 @@ describe('Invite to Group Controller', function() { {name: 'Mario', email: 'mario@tmk.com'} ]; - var inviteDetails = { + scope.inviteNewUsers('email'); + expect(groups.Group.invite).to.be.calledOnce; + expect(groups.Group.invite).to.be.calledWith({ + gid: scope.group._id, + }, { inviter: user.profile.name, emails: [ {name: 'Luigi', email: 'mario_bro@themushroomkingdom.com'}, {name: 'Mario', email: 'mario@tmk.com'} ] - }; - - groupInvite.returns( - Promise.resolve() - .then(function () { - expect(groupInvite).to.be.calledOnce; - expect(groupInvite).to.be.calledWith(scope.group._id, inviteDetails); - done(); - }) - ); - - scope.inviteNewUsers('email'); + }); }); }); context('uuid', function() { - beforeEach(function () { - sandbox.stub(rootScope, 'hardRedirect'); - }); - - it('invites user with uuid', function(done) { + it('invites user with uuid', function() { scope.invitees = [{uuid: '1234'}]; - groupInvite.returns( - Promise.resolve() - .then(function () { - expect(groupInvite).to.be.calledOnce; - expect(groupInvite).to.be.calledWith(scope.group._id, { uuids: ['1234'] }); - done(); - }) - ); - scope.inviteNewUsers('uuid'); + expect(groups.Group.invite).to.be.calledOnce; + expect(groups.Group.invite).to.be.calledWith({ + gid: scope.group._id, + }, { + uuids: ['1234'] + }); }); - it('invites users with uuids', function(done) { + it('invites users with uuids', function() { scope.invitees = [{uuid: 'user1'}, {uuid: 'user2'}, {uuid: 'user3'}]; - groupInvite.returns( - Promise.resolve() - .then(function () { - expect(groupInvite).to.be.calledOnce; - expect(groupInvite).to.be.calledWith(scope.group._id, { uuids: ['user1', 'user2', 'user3'] }); - done(); - }) - ); - scope.inviteNewUsers('uuid'); + expect(groups.Group.invite).to.be.calledOnce; + expect(groups.Group.invite).to.be.calledWith({ + gid: scope.group._id, + }, { + uuids: ['user1', 'user2', 'user3'] + }); }); - it('resets invitee list after sending', function(done) { + it('resets invitee list after sending', function() { + groups.Group.invite.yields(); scope.invitees = [{uuid: 'user1'}, {uuid: 'user2'}, {uuid: 'user3'}]; - groupInvite.returns( - Promise.resolve() - .then(function () { - //We use a timeout to test items that happen after the promise is resolved - setTimeout(function(){ - expect(scope.invitees).to.eql([{uuid: ''}]); - done(); - }, 1000); - done(); - }) - ); - scope.inviteNewUsers('uuid'); + + expect(scope.invitees).to.eql([{uuid: ''}]); }); it('removes blank fields from being sent', function() { + groups.Group.invite.yields(); scope.invitees = [{uuid: 'user1'}, {uuid: ''}, {uuid: 'user3'}]; - groupInvite.returns( - Promise.resolve() - .then(function () { - expect(groupInvite).to.be.calledOnce; - expect(groupInvite).to.be.calledWith(scope.group._id, { uuids: ['user1', 'user3'] }); - done(); - }) - ); - scope.inviteNewUsers('uuid'); + + expect(groups.Group.invite).to.be.calledOnce; + expect(groups.Group.invite).to.be.calledWith({ + gid: scope.group._id, + }, { + uuids: ['user1', 'user3'] + }); }); }); diff --git a/test/spec/controllers/menuCtrlSpec.js b/test/spec/controllers/menuCtrlSpec.js index 1e98595105..eaa642370b 100644 --- a/test/spec/controllers/menuCtrlSpec.js +++ b/test/spec/controllers/menuCtrlSpec.js @@ -19,7 +19,7 @@ describe('Menu Controller', function() { describe('clearMessage', function() { it('is Chat.seenMessage', inject(function(Chat) { - expect(scope.clearMessages).to.eql(Chat.markChatSeen); + expect(scope.clearMessages).to.eql(Chat.seenMessage); })); }); }); diff --git a/test/spec/controllers/partyCtrlSpec.js b/test/spec/controllers/partyCtrlSpec.js index 00d3e676c5..92401f1987 100644 --- a/test/spec/controllers/partyCtrlSpec.js +++ b/test/spec/controllers/partyCtrlSpec.js @@ -1,8 +1,7 @@ 'use strict'; describe("Party Controller", function() { - var scope, ctrl, user, User, questsService, groups, rootScope, $controller, deferred; - var party; + var scope, ctrl, user, User, questsService, groups, rootScope, $controller; beforeEach(function() { user = specHelper.newUser(), @@ -11,19 +10,13 @@ describe("Party Controller", function() { user: user, sync: sandbox.spy(), set: sandbox.spy() - }; - - party = specHelper.newGroup({ - _id: "unique-party-id", - type: 'party', - members: ['leader-id'] // Ensure we wouldn't pass automatically. - }); + } module(function($provide) { $provide.value('User', User); }); - inject(function(_$rootScope_, _$controller_, Groups, Quests, _$q_){ + inject(function(_$rootScope_, _$controller_, Groups, Quests){ rootScope = _$rootScope_; @@ -42,18 +35,12 @@ describe("Party Controller", function() { }); describe('initialization', function() { - var groupResponse; - function initializeControllerWithStubbedState() { inject(function(_$state_) { var state = _$state_; sandbox.stub(state, 'is').returns(true); - var syncParty = sinon.stub(groups.Group, 'syncParty') - syncParty.returns(Promise.resolve(groupResponse)); - $controller('PartyCtrl', { $scope: scope, $state: state, User: User }); - // @TODO: I have update the party ctrl to sync the user whenever it is called rather than only on the party page - // Since I have cached the promise, this should not be a performance issue, but let's keep this test here in case anything breaks. - // expect(state.is).to.be.calledOnce; // ensure initialization worked as desired + $controller('PartyCtrl', { $scope: scope, $state: state }); + expect(state.is).to.be.calledOnce; // ensure initialization worked as desired }); }; @@ -63,7 +50,10 @@ describe("Party Controller", function() { context('party has 1 member', function() { it('awards no new achievements', function() { - groupResponse = {_id: "test", type: "party", memberCount: 1}; + sandbox.stub(groups, 'party').returns({ + $syncParty: function() {}, + memberCount: 1 + }); initializeControllerWithStubbedState(); @@ -74,65 +64,61 @@ describe("Party Controller", function() { context('party has 2 members', function() { context('user does not have "Party Up" achievement', function() { - it('awards "Party Up" achievement', function(done) { - groupResponse = {_id: "test", type: "party", memberCount: 2}; + it('awards "Party Up" achievement', function() { + sandbox.stub(groups, 'party').returns({ + $syncParty: function() {}, + memberCount: 2 + }); initializeControllerWithStubbedState(); - setTimeout(function() { - expect(User.set).to.be.calledOnce; - expect(User.set).to.be.calledWith( - { 'achievements.partyUp': true } - ); - expect(rootScope.openModal).to.be.calledOnce; - expect(rootScope.openModal).to.be.calledWith('achievements/partyUp'); - done(); - }, 1000); + expect(User.set).to.be.calledOnce; + expect(User.set).to.be.calledWith( + { 'achievements.partyUp': true } + ); + expect(rootScope.openModal).to.be.calledOnce; + expect(rootScope.openModal).to.be.calledWith('achievements/partyUp'); }); }); }); context('party has 4 members', function() { - beforeEach(function() { - groupResponse = {_id: "test", type: "party", memberCount: 4}; + sandbox.stub(groups, 'party').returns({ + $syncParty: function() {}, + memberCount: 4 + }); }); context('user has "Party Up" but not "Party On" achievement', function() { - it('awards "Party On" achievement', function(done) { + it('awards "Party On" achievement', function() { user.achievements.partyUp = true; initializeControllerWithStubbedState(); - setTimeout(function(){ - expect(User.set).to.be.calledOnce; - expect(User.set).to.be.calledWith( - { 'achievements.partyOn': true } - ); - expect(rootScope.openModal).to.be.calledOnce; - expect(rootScope.openModal).to.be.calledWith('achievements/partyOn'); - done(); - }, 1000); + expect(User.set).to.be.calledOnce; + expect(User.set).to.be.calledWith( + { 'achievements.partyOn': true } + ); + expect(rootScope.openModal).to.be.calledOnce; + expect(rootScope.openModal).to.be.calledWith('achievements/partyOn'); }); }); context('user has neither "Party Up" nor "Party On" achievements', function() { - it('awards "Party Up" and "Party On" achievements', function(done) { + it('awards "Party Up" and "Party On" achievements', function() { initializeControllerWithStubbedState(); - setTimeout(function(){ - expect(User.set).to.have.been.called; - expect(User.set).to.be.calledWith( - { 'achievements.partyUp': true} - ); - expect(User.set).to.be.calledWith( - { 'achievements.partyOn': true} - ); - expect(rootScope.openModal).to.have.been.called; - expect(rootScope.openModal).to.be.calledWith('achievements/partyUp'); - expect(rootScope.openModal).to.be.calledWith('achievements/partyOn'); - done(); - }, 1000); + expect(User.set).to.be.calledTwice; + expect(User.set).to.be.calledWith( + { 'achievements.partyUp': true} + ); + expect(User.set).to.be.calledWith( + { 'achievements.partyOn': true} + ); + expect(rootScope.openModal).to.be.calledTwice; + expect(rootScope.openModal).to.be.calledWith('achievements/partyUp'); + expect(rootScope.openModal).to.be.calledWith('achievements/partyOn'); }); }); @@ -150,107 +136,70 @@ describe("Party Controller", function() { }); }); - describe("create", function() { - var partyStub; - - beforeEach(function () { - partyStub = sinon.stub(groups.Group, "create"); - partyStub.returns(Promise.resolve(party)); - sinon.stub(rootScope, 'hardRedirect'); - }); - - it("creates a new party", function() { - var group = { - type: 'party', - }; - scope.create(group); - expect(partyStub).to.be.calledOnce; - //@TODO: Check user party console.log(User.user.party.id) - }); - }); - describe('questAccept', function() { - var sendAction; - var memberResponse; - beforeEach(function() { scope.group = { quest: { members: { 'user-id': true } } }; - - memberResponse = {members: {another: true}}; - sinon.stub(questsService, 'sendAction') - questsService.sendAction.returns(Promise.resolve(memberResponse)); + sandbox.stub(questsService, 'sendAction').returns({ + then: sandbox.stub().yields({members: {another: true}}) + }); }); it('calls Quests.sendAction', function() { scope.questAccept(); expect(questsService.sendAction).to.be.calledOnce; - expect(questsService.sendAction).to.be.calledWith('quests/accept'); + expect(questsService.sendAction).to.be.calledWith('questAccept'); }); - it('updates quest object with new participants list', function(done) { + it('updates quest object with new participants list', function() { scope.group.quest = { members: { user: true, another: true } }; - setTimeout(function(){ - expect(scope.group.quest).to.eql(memberResponse); - done(); - }, 1000); - scope.questAccept(); + + expect(scope.group.quest).to.eql({members: { another: true }}); }); }); describe('questReject', function() { - var memberResponse; - beforeEach(function() { scope.group = { quest: { members: { 'user-id': true } } }; - - memberResponse = {members: {another: true}}; - var sendAction = sinon.stub(questsService, 'sendAction') - sendAction.returns(Promise.resolve(memberResponse)); + sandbox.stub(questsService, 'sendAction').returns({ + then: sandbox.stub().yields({members: {another: true}}) + }); }); it('calls Quests.sendAction', function() { scope.questReject(); expect(questsService.sendAction).to.be.calledOnce; - expect(questsService.sendAction).to.be.calledWith('quests/reject'); + expect(questsService.sendAction).to.be.calledWith('questReject'); }); - it('updates quest object with new participants list', function(done) { + it('updates quest object with new participants list', function() { scope.group.quest = { members: { user: true, another: true } }; - setTimeout(function(){ - expect(scope.group.quest).to.eql(memberResponse); - done(); - }, 1000); - scope.questReject(); + + expect(scope.group.quest).to.eql({members: { another: true }}); }); }); describe('questCancel', function() { - var party, cancelSpy, windowSpy, memberResponse; - + var party, cancelSpy, windowSpy; beforeEach(function() { - scope.group = { - quest: { members: { 'user-id': true } } - }; - - memberResponse = {members: {another: true}}; - sinon.stub(questsService, 'sendAction') - questsService.sendAction.returns(Promise.resolve(memberResponse)); + sandbox.stub(questsService, 'sendAction').returns({ + then: sandbox.stub().yields({members: {another: true}}) + }); }); it('calls Quests.sendAction when alert box is confirmed', function() { @@ -261,7 +210,7 @@ describe("Party Controller", function() { expect(window.confirm).to.be.calledOnce; expect(window.confirm).to.be.calledWith(window.env.t('sureCancel')); expect(questsService.sendAction).to.be.calledOnce; - expect(questsService.sendAction).to.be.calledWith('quests/cancel'); + expect(questsService.sendAction).to.be.calledWith('questCancel'); }); it('does not call Quests.sendAction when alert box is not confirmed', function() { @@ -275,16 +224,10 @@ describe("Party Controller", function() { }); describe('questAbort', function() { - var memberResponse; - beforeEach(function() { - scope.group = { - quest: { members: { 'user-id': true } } - }; - - memberResponse = {members: {another: true}}; - sinon.stub(questsService, 'sendAction') - questsService.sendAction.returns(Promise.resolve(memberResponse)); + sandbox.stub(questsService, 'sendAction').returns({ + then: sandbox.stub().yields({members: {another: true}}) + }); }); it('calls Quests.sendAction when two alert boxes are confirmed', function() { @@ -296,7 +239,7 @@ describe("Party Controller", function() { expect(window.confirm).to.be.calledWith(window.env.t('doubleSureAbort')); expect(questsService.sendAction).to.be.calledOnce; - expect(questsService.sendAction).to.be.calledWith('quests/abort'); + expect(questsService.sendAction).to.be.calledWith('questAbort'); }); it('does not call Quests.sendAction when first alert box is not confirmed', function() { @@ -330,16 +273,13 @@ describe("Party Controller", function() { }); describe('#questLeave', function() { - var memberResponse; - beforeEach(function() { scope.group = { quest: { members: { 'user-id': true } } }; - - memberResponse = {members: {another: true}}; - sinon.stub(questsService, 'sendAction') - questsService.sendAction.returns(Promise.resolve(memberResponse)); + sandbox.stub(questsService, 'sendAction').returns({ + then: sandbox.stub().yields({members: {another: true}}) + }); }); it('calls Quests.sendAction when alert box is confirmed', function() { @@ -350,7 +290,7 @@ describe("Party Controller", function() { expect(window.confirm).to.be.calledOnce; expect(window.confirm).to.be.calledWith(window.env.t('sureLeave')); expect(questsService.sendAction).to.be.calledOnce; - expect(questsService.sendAction).to.be.calledWith('quests/leave'); + expect(questsService.sendAction).to.be.calledWith('questLeave'); }); it('does not call Quests.sendAction when alert box is not confirmed', function() { @@ -362,18 +302,15 @@ describe("Party Controller", function() { questsService.sendAction.should.not.have.been.calledOnce; }); - it('updates quest object with new participants list', function(done) { + it('updates quest object with new participants list', function() { scope.group.quest = { members: { user: true, another: true } }; sandbox.stub(window, "confirm").returns(true); - setTimeout(function(){ - expect(scope.group.quest).to.eql(memberResponse); - done(); - }, 1000); - scope.questLeave(); + + expect(scope.group.quest).to.eql({members: { another: true }}); }); }); @@ -425,9 +362,7 @@ describe("Party Controller", function() { describe('#leaveOldPartyAndJoinNewParty', function() { beforeEach(function() { sandbox.stub(scope, 'join'); - groups.data.party = { _id: 'old-party' }; - var groupLeave = sandbox.stub(groups.Group, 'leave'); - groupLeave.returns(Promise.resolve({})); + sandbox.stub(groups.Group, 'leave').yields(); sandbox.stub(groups, 'party').returns({ _id: 'old-party' }); @@ -445,17 +380,20 @@ describe("Party Controller", function() { scope.leaveOldPartyAndJoinNewParty('some-id', 'some-name'); expect(groups.Group.leave).to.be.calledOnce; - expect(groups.Group.leave).to.be.calledWith('old-party', false); + expect(groups.Group.leave).to.be.calledWith({ + gid: 'old-party', + keep: false + }); }); - it('joins the new party', function(done) { + it('joins the new party', function() { scope.leaveOldPartyAndJoinNewParty('some-id', 'some-name'); - setTimeout(function() { - expect(scope.join).to.be.calledOnce; - expect(scope.join).to.be.calledWith({id: 'some-id', name: 'some-name'}); - done(); - }, 1000); + expect(scope.join).to.be.calledOnce; + expect(scope.join).to.be.calledWith({ + id: 'some-id', + name: 'some-name' + }); }); }); @@ -468,7 +406,6 @@ describe("Party Controller", function() { leader: {}, quest: {} }); - scope.group = party; }); it('returns false if user is not the quest leader', function() { diff --git a/test/spec/controllers/settingsCtrlSpec.js b/test/spec/controllers/settingsCtrlSpec.js index ef960ae204..527600b29e 100644 --- a/test/spec/controllers/settingsCtrlSpec.js +++ b/test/spec/controllers/settingsCtrlSpec.js @@ -12,12 +12,6 @@ describe('Settings Controller', function () { user = specHelper.newUser(); User = { set: sandbox.stub(), - reroll: sandbox.stub(), - rebirth: sandbox.stub(), - releasePets: sandbox.stub(), - releaseMounts: sandbox.stub(), - releaseBoth: sandbox.stub(), - setCustomDayStart: sandbox.stub(), user: user }; @@ -87,11 +81,19 @@ describe('Settings Controller', function () { }); describe('#saveDayStart', function () { - it('updates user\'s custom day start', function () { + + it('updates user\'s custom day start and last cron', function () { + var fakeCurrentTime = new Date(2013, 3, 1, 8, 12).getTime(); + var expectedTime = fakeCurrentTime; + sandbox.useFakeTimers(fakeCurrentTime); scope.dayStart = 5; scope.saveDayStart(); - expect(User.setCustomDayStart).to.be.calledWith(5); + expect(User.set).to.be.calledOnce; + expect(User.set).to.be.calledWith({ + 'preferences.dayStart': 5, + 'lastCron': expectedTime + }); }); }); @@ -121,7 +123,7 @@ describe('Settings Controller', function () { scope.reroll(true); - expect(User.reroll).to.be.calledWith({}); + expect(user.ops.reroll).to.be.calledWith({}); }); it('navigates to the tasks page when confirmed', function () { @@ -171,7 +173,7 @@ describe('Settings Controller', function () { scope.rebirth(true); - expect(User.rebirth).to.be.calledWith({}); + expect(user.ops.rebirth).to.be.calledWith({}); }); it('navigates to tasks page when confirmed', function () { @@ -214,9 +216,9 @@ describe('Settings Controller', function () { it('doesn\'t call any release method if type is not provided', function () { scope.releaseAnimals(); - expect(User.releasePets).to.not.be.called; - expect(User.releaseMounts).to.not.be.called; - expect(User.releaseBoth).to.not.be.called; + expect(User.user.ops.releasePets).to.not.be.called; + expect(User.user.ops.releaseMounts).to.not.be.called; + expect(User.user.ops.releaseBoth).to.not.be.called; }); it('doesn\'t redirect to tasks page if type is not provided', function () { @@ -228,7 +230,7 @@ describe('Settings Controller', function () { it('calls releasePets when "pets" is provided', function () { scope.releaseAnimals('pets'); - expect(User.releasePets).to.be.calledOnce; + expect(User.user.ops.releasePets).to.be.calledOnce; }); it('navigates to the tasks page when "pets" is provided', function () { @@ -240,7 +242,7 @@ describe('Settings Controller', function () { it('calls releaseMounts when "mounts" is provided', function () { scope.releaseAnimals('mounts'); - expect(User.releaseMounts).to.be.calledOnce; + expect(User.user.ops.releaseMounts).to.be.calledOnce; }); it('navigates to the tasks page when "mounts" is provided', function () { @@ -252,7 +254,7 @@ describe('Settings Controller', function () { it('calls releaseBoth when "both" is provided', function () { scope.releaseAnimals('both'); - expect(User.releaseBoth).to.be.calledOnce; + expect(User.user.ops.releaseBoth).to.be.calledOnce; }); it('navigates to the tasks page when "both" is provided', function () { @@ -264,9 +266,9 @@ describe('Settings Controller', function () { it('does not call release functions when non-applicable argument is passed in', function () { scope.releaseAnimals('dummy'); - expect(User.releasePets).to.not.be.called; - expect(User.releaseMounts).to.not.be.called; - expect(User.releaseBoth).to.not.be.called; + expect(User.user.ops.releasePets).to.not.be.called; + expect(User.user.ops.releaseMounts).to.not.be.called; + expect(User.user.ops.releaseBoth).to.not.be.called; }); }); diff --git a/test/spec/controllers/tasksCtrlSpec.js b/test/spec/controllers/tasksCtrlSpec.js index 7d02a6edbb..ea6da32897 100644 --- a/test/spec/controllers/tasksCtrlSpec.js +++ b/test/spec/controllers/tasksCtrlSpec.js @@ -8,8 +8,6 @@ describe('Tasks Controller', function() { User = { user: user }; - - User.deleteTask = sandbox.stub(); User.user.ops = { deleteTask: sandbox.stub(), }; @@ -53,13 +51,13 @@ describe('Tasks Controller', function() { it('does not remove task if not confirmed', function() { window.confirm.returns(false); scope.removeTask(task); - expect(User.deleteTask).to.not.be.called; + expect(user.ops.deleteTask).to.not.be.called; }); it('removes task', function() { window.confirm.returns(true); scope.removeTask(task); - expect(User.deleteTask).to.be.calledOnce; + expect(user.ops.deleteTask).to.be.calledOnce; }); }); diff --git a/test/spec/services/challengeServicesSpec.js b/test/spec/services/challengeServicesSpec.js deleted file mode 100644 index 9bed72db31..0000000000 --- a/test/spec/services/challengeServicesSpec.js +++ /dev/null @@ -1,88 +0,0 @@ -'use strict'; - -describe('challengeServices', function() { - var $httpBackend, $http, challenges, user; - var apiV3Prefix = '/api/v3'; - - beforeEach(function() { - module(function($provide) { - $provide.value('User', {user:user}); - }); - - inject(function(_$httpBackend_, Challenges, User) { - $httpBackend = _$httpBackend_; - challenges = Challenges; - user = User; - user.sync = function(){}; - }); - }); - - it('calls create challenge endpoint', function() { - $httpBackend.expectPOST(apiV3Prefix + '/challenges').respond({}); - challenges.createChallenge(); - $httpBackend.flush(); - }); - - it('calls join challenge endpoint', function() { - var challengeId = 1; - $httpBackend.expectPOST(apiV3Prefix + '/challenges/' + challengeId + '/join').respond({}); - challenges.joinChallenge(challengeId); - $httpBackend.flush(); - }); - - it('calls leave challenge endpoint', function() { - var challengeId = 1; - $httpBackend.expectPOST(apiV3Prefix + '/challenges/' + challengeId + '/leave').respond({}); - challenges.leaveChallenge(challengeId); - $httpBackend.flush(); - }); - - it('calls get user challenges endpoint', function() { - $httpBackend.expectGET(apiV3Prefix + '/challenges/user').respond({}); - challenges.getUserChallenges(); - $httpBackend.flush(); - }); - - it('calls get group challenges endpoint', function() { - var groupId = 1; - $httpBackend.expectGET(apiV3Prefix + '/challenges/groups/' + groupId).respond({}); - challenges.getGroupChallenges(groupId); - $httpBackend.flush(); - }); - - it('calls get challenge endpoint', function() { - var challengeId = 1; - $httpBackend.expectGET(apiV3Prefix + '/challenges/' + challengeId).respond({}); - challenges.getChallenge(challengeId); - $httpBackend.flush(); - }); - - it('calls export challenge to csv endpoint', function() { - var challengeId = 1; - $httpBackend.expectGET(apiV3Prefix + '/challenges/' + challengeId + '/export/csv').respond({}); - challenges.exportChallengeCsv(challengeId); - $httpBackend.flush(); - }); - - it('calls update challenge endpoint', function() { - var challengeId = 1; - $httpBackend.expectPUT(apiV3Prefix + '/challenges/' + challengeId).respond({}); - challenges.updateChallenge(challengeId); - $httpBackend.flush(); - }); - - it('calls delete challenge endpoint', function() { - var challengeId = 1; - $httpBackend.expectDELETE(apiV3Prefix + '/challenges/' + challengeId).respond({}); - challenges.deleteChallenge(challengeId); - $httpBackend.flush(); - }); - - it('calls select challenge winner endpoint', function() { - var challengeId = 1; - var winnerId = 2; - $httpBackend.expectPOST(apiV3Prefix + '/challenges/' + challengeId + '/selectWinner/' + winnerId).respond({}); - challenges.selectChallengeWinner(challengeId, winnerId); - $httpBackend.flush(); - }); -}); diff --git a/test/spec/services/chatServicesSpec.js b/test/spec/services/chatServicesSpec.js deleted file mode 100644 index 2d1c101df8..0000000000 --- a/test/spec/services/chatServicesSpec.js +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; - -describe('chatServices', function() { - var $httpBackend, $http, chat, user; - var apiV3Prefix = '/api/v3'; - - beforeEach(function() { - module(function($provide) { - $provide.value('User', {user:user}); - }); - - inject(function(_$httpBackend_, Chat, User) { - $httpBackend = _$httpBackend_; - chat = Chat; - user = User; - user.sync = function(){}; - }); - }); - - it('calls get chat endpoint', function() { - var groupId = 1; - $httpBackend.expectGET(apiV3Prefix + '/groups/' + groupId + '/chat').respond({}); - chat.getChat(groupId); - $httpBackend.flush(); - }); - - it('calls get chat endpoint', function() { - var groupId = 1; - var message = "test message"; - $httpBackend.expectPOST(apiV3Prefix + '/groups/' + groupId + '/chat').respond({}); - chat.postChat(groupId, message); - $httpBackend.flush(); - }); - - it('calls delete chat endpoint', function() { - var groupId = 1; - var chatId = 2; - $httpBackend.expectDELETE(apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId).respond({}); - chat.deleteChat(groupId, chatId); - $httpBackend.flush(); - }); - - it('calls like chat endpoint', function() { - var groupId = 1; - var chatId = 2; - $httpBackend.expectPOST(apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/like').respond({}); - chat.like(groupId, chatId); - $httpBackend.flush(); - }); - - it('calls flag chat endpoint', function() { - var groupId = 1; - var chatId = 2; - $httpBackend.expectPOST(apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/flag').respond({}); - chat.flagChatMessage(groupId, chatId); - $httpBackend.flush(); - }); - - it('calls clearflags chat endpoint', function() { - var groupId = 1; - var chatId = 2; - $httpBackend.expectPOST(apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/clearflags').respond({}); - chat.clearFlagCount(groupId, chatId); - $httpBackend.flush(); - }); - - it('calls chat seen endpoint', function() { - var groupId = 1; - $httpBackend.expectPOST(apiV3Prefix + '/groups/' + groupId + '/chat/seen').respond({}); - chat.markChatSeen(groupId); - $httpBackend.flush(); - }); -}); diff --git a/test/spec/services/groupServicesSpec.js b/test/spec/services/groupServicesSpec.js index 5b4a88c996..e9a9285265 100644 --- a/test/spec/services/groupServicesSpec.js +++ b/test/spec/services/groupServicesSpec.js @@ -2,168 +2,41 @@ describe('groupServices', function() { var $httpBackend, $http, groups, user; - var groupApiUrlPrefix = '/api/v3/groups'; beforeEach(function() { module(function($provide) { - user = specHelper.newUser(); - user._id = "unique-user-id" - user.party._id = 'unique-party-id'; - user.sync = function(){}; - $provide.value('User', {user: user}); + $provide.value('User', {user:user}); }); inject(function(_$httpBackend_, Groups, User) { $httpBackend = _$httpBackend_; groups = Groups; + user = User; + user.sync = function(){}; }); }); - it('calls get groups', function() { - $httpBackend.expectGET(groupApiUrlPrefix).respond({}); - groups.Group.getGroups(); - $httpBackend.flush(); - }); - - it('calls get group', function() { - var gid = 1; - $httpBackend.expectGET(groupApiUrlPrefix + '/' + gid).respond({}); - groups.Group.get(gid); - $httpBackend.flush(); - }); - it('calls party endpoint', function() { - var groupId = '1234'; - var groupResponse = {data: {_id: groupId}}; - $httpBackend.expectGET(groupApiUrlPrefix + '/party').respond(groupResponse); - $httpBackend.expectGET('/api/v3/groups/' + groupId + '/members?includeAllPublicFields=true').respond({}); - $httpBackend.expectGET('/api/v3/groups/' + groupId + '/invites').respond({}); - $httpBackend.expectGET('/api/v3/challenges/groups/' + groupId).respond({}); - groups.Group.syncParty(); - $httpBackend.flush(); - }); - - it('calls create endpoint', function() { - $httpBackend.expectPOST(groupApiUrlPrefix).respond({}); - groups.Group.create({}); - $httpBackend.flush(); - }); - - it('calls update group', function() { - var gid = 1; - var groupDetails = { _id: gid }; - $httpBackend.expectPUT(groupApiUrlPrefix + '/' + gid).respond({}); - groups.Group.update(groupDetails); - $httpBackend.flush(); - }); - - it('calls join group', function() { - var gid = 1; - $httpBackend.expectPOST(groupApiUrlPrefix + '/' + gid + '/join').respond({}); - groups.Group.join(gid); - $httpBackend.flush(); - }); - - it('calls reject invite group', function() { - var gid = 1; - $httpBackend.expectPOST(groupApiUrlPrefix + '/' + gid + '/reject-invite').respond({}); - groups.Group.rejectInvite(gid); - $httpBackend.flush(); - }); - - it('calls invite group', function() { - var gid = 1; - $httpBackend.expectPOST(groupApiUrlPrefix + '/' + gid + '/invite').respond({}); - groups.Group.invite(gid, [], []); - $httpBackend.flush(); - }); - - it('calls party endpoint when party is not cached', function() { - var groupId = '1234'; - var groupResponse = {data: {_id: groupId}}; - $httpBackend.expectGET(groupApiUrlPrefix + '/party').respond(groupResponse); - $httpBackend.expectGET('/api/v3/groups/' + groupId + '/members?includeAllPublicFields=true').respond({}); - $httpBackend.expectGET('/api/v3/groups/' + groupId + '/invites').respond({}); - $httpBackend.expectGET('/api/v3/challenges/groups/' + groupId).respond({}); + $httpBackend.expectGET('/api/v2/groups/party').respond({}); groups.party(); $httpBackend.flush(); }); - it('returns party if cached', function (done) { - var uid = 'abc'; - var party = { - _id: uid, - }; - groups.data.party = party; - groups.party() - .then(function (result) { - expect(result).to.eql(party); - done(); - }); - $httpBackend.flush(); - }); - - it('calls tavern endpoint when tavern is not cached', function() { - $httpBackend.expectGET(groupApiUrlPrefix + '/habitrpg').respond({}); + it('calls tavern endpoint', function() { + $httpBackend.expectGET('/api/v2/groups/habitrpg').respond({}); groups.tavern(); $httpBackend.flush(); }); - it('returns tavern if cached', function (done) { - var uid = 'abc'; - var tavern = { - _id: uid, - }; - groups.data.tavern = tavern; - groups.tavern() - .then(function (result) { - expect(result).to.eql(tavern); - done(); - }); - $httpBackend.flush(); - }); - it('calls public guilds endpoint', function() { - $httpBackend.expectGET(groupApiUrlPrefix + '?type=publicGuilds').respond([]); + $httpBackend.expectGET('/api/v2/groups?type=public').respond([]); groups.publicGuilds(); $httpBackend.flush(); }); - it('returns public guilds if cached', function (done) { - var uid = 'abc'; - var publicGuilds = [ - {_id: uid}, - ]; - groups.data.publicGuilds = publicGuilds; - - groups.publicGuilds() - .then(function (result) { - expect(result).to.eql(publicGuilds); - done(); - }); - - $httpBackend.flush(); - }); - it('calls my guilds endpoint', function() { - $httpBackend.expectGET(groupApiUrlPrefix + '?type=guilds').respond([]); + $httpBackend.expectGET('/api/v2/groups?type=guilds').respond([]); groups.myGuilds(); $httpBackend.flush(); }); - - it('returns my guilds if cached', function (done) { - var uid = 'abc'; - var myGuilds = [ - {_id: uid}, - ]; - groups.data.myGuilds = myGuilds; - - groups.myGuilds() - .then(function (myGuilds) { - expect(myGuilds).to.eql(myGuilds); - done(); - }); - - $httpBackend.flush() - }); }); diff --git a/test/spec/services/memberServicesSpec.js b/test/spec/services/memberServicesSpec.js index 1344bf96e7..d33ccd8c98 100644 --- a/test/spec/services/memberServicesSpec.js +++ b/test/spec/services/memberServicesSpec.js @@ -2,7 +2,6 @@ describe('memberServices', function() { var $httpBackend, members; - var apiV3Prefix = '/api/v3'; beforeEach(inject(function (_$httpBackend_, Members) { $httpBackend = _$httpBackend_; @@ -21,51 +20,10 @@ describe('memberServices', function() { expect(members.selectedMember).to.be.undefined; }); - it('calls fetch member', function() { - var memberId = 1; - var memberUrl = apiV3Prefix + '/members/' + memberId; - $httpBackend.expectGET(memberUrl).respond({}); - members.fetchMember(memberId); - $httpBackend.flush(); - }); - - it('calls get group members', function() { - var groupId = 1; - var memberUrl = apiV3Prefix + '/groups/' + groupId + '/members'; - $httpBackend.expectGET(memberUrl).respond({}); - members.getGroupMembers(groupId); - $httpBackend.flush(); - }); - - it('calls get group invites', function() { - var groupId = 1; - var memberUrl = apiV3Prefix + '/groups/' + groupId + '/invites'; - $httpBackend.expectGET(memberUrl).respond({}); - members.getGroupInvites(groupId); - $httpBackend.flush(); - }); - - it('calls get challenge members', function() { - var challengeId = 1; - var memberUrl = apiV3Prefix + '/challenges/' + challengeId + '/members'; - $httpBackend.expectGET(memberUrl).respond({}); - members.getChallengeMembers(challengeId); - $httpBackend.flush(); - }); - - it('calls get challenge members progress', function() { - var challengeId = 1; - var memberId = 2; - var memberUrl = apiV3Prefix + '/challenges/' + challengeId + '/members/' + memberId; - $httpBackend.expectGET(memberUrl).respond({}); - members.getChallengeMemberProgress(challengeId, memberId); - $httpBackend.flush(); - }); - describe('addToMembersList', function() { it('adds member to members object', function() { var member = { _id: 'user_id' }; - members.addToMembersList(member, members); + members.addToMembersList(member); expect(members.members).to.eql({ user_id: { _id: 'user_id' } }); @@ -73,37 +31,27 @@ describe('memberServices', function() { }); describe('selectMember', function() { - it('fetches member if not already in cache', function(done) { + it('fetches member if not already in cache', function() { var uid = 'abc'; - var memberResponse = { - data: {_id: uid}, - } - $httpBackend.expectGET(apiV3Prefix + '/members/' + uid).respond(memberResponse); - members.selectMember(uid) - .then(function () { - expect(members.selectedMember._id).to.eql(uid); - expect(members.members).to.have.property(uid); - done(); - }); + $httpBackend.expectGET('/api/v2/members/' + uid).respond({ _id: uid }); + members.selectMember(uid, function(){}); $httpBackend.flush(); + + expect(members.selectedMember._id).to.eql(uid); + expect(members.members).to.have.property(uid); }); - it('fetches member if member data in cache is incomplete', function(done) { + it('fetches member if member data in cache is incomplete', function() { var uid = 'abc'; members.members = { abc: { _id: 'abc', items: {} } } - var memberResponse = { - data: {_id: uid}, - } - $httpBackend.expectGET(apiV3Prefix + '/members/' + uid).respond(memberResponse); - members.selectMember(uid) - .then(function () { - expect(members.selectedMember._id).to.eql(uid); - expect(members.members).to.have.property(uid); - done(); - }); + $httpBackend.expectGET('/api/v2/members/' + uid).respond({ _id: uid }); + members.selectMember(uid, function(){}); $httpBackend.flush(); + + expect(members.selectedMember._id).to.eql(uid); + expect(members.members).to.have.property(uid); }); it('gets member from cache if member has a weapons object', function() { diff --git a/test/spec/services/questServicesSpec.js b/test/spec/services/questServicesSpec.js index e71dc6f3ef..e7f8c638b2 100644 --- a/test/spec/services/questServicesSpec.js +++ b/test/spec/services/questServicesSpec.js @@ -1,14 +1,13 @@ 'use strict'; describe('Quests Service', function() { - var groupsService, quest, questsService, user, content, resolveSpy, rejectSpy, state; + var groupsService, quest, questsService, user, content, resolveSpy, rejectSpy; beforeEach(function() { user = specHelper.newUser(); user.ops = { buyQuest: sandbox.spy() }; - user.party._id = 'unique-party-id'; user.achievements.quests = {}; quest = {lvl:20}; @@ -17,11 +16,10 @@ describe('Quests Service', function() { $provide.value('User', {sync: sinon.stub(), user: user}); }); - inject(function(Quests, Groups, Content, _$state_) { + inject(function(Quests, Groups, Content) { questsService = Quests; groupsService = Groups; content = Content; - state = _$state_; }); sandbox.stub(groupsService, 'inviteOrStartParty'); @@ -74,8 +72,7 @@ describe('Quests Service', function() { scope = $rootScope.$new(); })); - //@TODO: This is fixed in a Quest Service PR port - xit('returns a promise', function() { + it('returns a promise', function() { var promise = questsService.buyQuest('whale'); expect(promise).to.respondTo('then'); }); @@ -228,7 +225,7 @@ describe('Quests Service', function() { scope = $rootScope.$new(); })); - xit('returns a promise', function() { + it('returns a promise', function() { var promise = questsService.showQuest('whale'); expect(promise).to.respondTo('then'); }); @@ -338,67 +335,39 @@ describe('Quests Service', function() { }); describe('#initQuest', function() { - var fakeBackend, scope, key = 'whale'; - - beforeEach(inject(function($httpBackend, $rootScope) { - scope = $rootScope.$new(); - fakeBackend = $httpBackend; - var partyResponse = {data:{_id: 'party-id'}}; - - fakeBackend.when('GET', 'partials/main.html').respond({}); - fakeBackend.when('GET', 'partials/main.html').respond({}); - fakeBackend.when('GET', '/api/v3/groups/party').respond(partyResponse); - fakeBackend.when('GET', '/api/v3/groups/party-id/members?includeAllPublicFields=true').respond({}); - fakeBackend.when('GET', '/api/v3/groups/party-id/invites').respond({}); - fakeBackend.when('GET', '/api/v3/challenges/groups/party-id').respond({}); - fakeBackend.when('POST', '/api/v3/groups/party-id/quests/invite/' + key).respond({quest: { key: 'whale' } }); - fakeBackend.flush(); - })); it('returns a promise', function() { - var promise = questsService.initQuest(key); + var promise = questsService.initQuest('whale'); expect(promise).to.respondTo('then'); }); - it('starts a quest', function(done) { - fakeBackend.expectPOST( '/api/v3/groups/party-id/quests/invite/' + key); - - questsService.initQuest(key) - .then(function(res) { - done(); - }); - - fakeBackend.flush(); - scope.$apply(); - }); + it('accepts quest'); it('brings user to party page'); }); - //@TODO: This is fixed in a Quest Service PR port - xdescribe('#sendAction', function() { + describe('#sendAction', function() { var fakeBackend, scope; beforeEach(inject(function($httpBackend, $rootScope) { scope = $rootScope.$new(); fakeBackend = $httpBackend; - var partyResponse = {data:{_id: 'party-id'}}; fakeBackend.when('GET', 'partials/main.html').respond({}); - fakeBackend.when('GET', '/api/v3/groups/party').respond(partyResponse); - fakeBackend.when('POST', '/api/v3/groups/party-id/quests/reject').respond({quest: { key: 'whale' } }); + fakeBackend.when('GET', '/api/v2/groups/party').respond({_id: 'party-id'}); + fakeBackend.when('POST', '/api/v2/groups/party-id/questReject').respond({quest: { key: 'whale' } }); fakeBackend.flush(); })); it('returns a promise', function() { - var promise = questsService.sendAction('quests/reject'); + var promise = questsService.sendAction('questReject'); expect(promise).to.respondTo('then'); }); it('calls specified quest endpoint', function(done) { - fakeBackend.expectPOST('/api/v3/groups/party-id/quests/reject'); + fakeBackend.expectPOST('/api/v2/groups/party-id/questReject'); - questsService.sendAction('quests/reject') + questsService.sendAction('questReject') .then(function(res) { expect(res.key).to.eql('whale'); done(); @@ -409,7 +378,7 @@ describe('Quests Service', function() { }); it('syncs User', function() { - questsService.sendAction('quests/reject') + questsService.sendAction('questReject') .then(function(res) { expect(User.sync).to.be.calledOnce; done(); diff --git a/test/spec/services/statServicesSpec.js b/test/spec/services/statServicesSpec.js index a1d99809c2..81e13645de 100644 --- a/test/spec/services/statServicesSpec.js +++ b/test/spec/services/statServicesSpec.js @@ -76,11 +76,7 @@ describe('Stats Service', function() { "armor" : "armor_warrior_1" }; var user = { - fns: { - statsComputed: function () { - return { str: 50 }; - }, - }, + _statsComputed: { str: 50 }, stats: { lvl: 10, buffs: { str: 10 }, @@ -256,8 +252,7 @@ describe('Stats Service', function() { describe('mpDisplay', function() { it('displays mp as "mp / totalMP"', function() { - user.fns = {}; - user.fns.statsComputed = function () { return { maxMP: 100 } }; + user._statsComputed = { maxMP: 100 }; user.stats.mp = 30; var mpDisplay = statCalc.mpDisplay(user); @@ -265,8 +260,7 @@ describe('Stats Service', function() { }); it('Rounds mp down when given a decimal', function() { - user.fns = {}; - user.fns.statsComputed = function () { return { maxMP: 100 } }; + user._statsComputed = { maxMP: 100 }; user.stats.mp = 30.99; var mpDisplay = statCalc.mpDisplay(user); diff --git a/test/spec/services/tagServicesSpec.js b/test/spec/services/tagServicesSpec.js deleted file mode 100644 index 119c814e09..0000000000 --- a/test/spec/services/tagServicesSpec.js +++ /dev/null @@ -1,52 +0,0 @@ -'use strict'; - -describe('Tags Service', function() { - var rootScope, tags, user, $httpBackend; - var apiV3Prefix = 'api/v3/tags'; - - beforeEach(function() { - module(function($provide) { - user = specHelper.newUser(); - $provide.value('User', {user: user}); - }); - - inject(function(_$httpBackend_, _$rootScope_, Tags, User) { - $httpBackend = _$httpBackend_; - rootScope = _$rootScope_; - tags = Tags; - }); - }); - - it('calls get tags endpoint', function() { - $httpBackend.expectGET(apiV3Prefix).respond({}); - tags.getTags(); - $httpBackend.flush(); - }); - - it('calls post tags endpoint', function() { - $httpBackend.expectPOST(apiV3Prefix).respond({}); - tags.createTag(); - $httpBackend.flush(); - }); - - it('calls get tag endpoint', function() { - var tagId = 1; - $httpBackend.expectGET(apiV3Prefix + '/' + tagId).respond({}); - tags.getTag(tagId); - $httpBackend.flush(); - }); - - it('calls update tag endpoint', function() { - var tagId = 1; - $httpBackend.expectPUT(apiV3Prefix + '/' + tagId).respond({}); - tags.updateTag(tagId, {}); - $httpBackend.flush(); - }); - - it('calls delete tag endpoint', function() { - var tagId = 1; - $httpBackend.expectDELETE(apiV3Prefix + '/' + tagId).respond({}); - tags.deleteTag(tagId); - $httpBackend.flush(); - }); -}); diff --git a/test/spec/services/taskServicesSpec.js b/test/spec/services/taskServicesSpec.js index 59d1a5d49e..212ba816f9 100644 --- a/test/spec/services/taskServicesSpec.js +++ b/test/spec/services/taskServicesSpec.js @@ -1,155 +1,22 @@ 'use strict'; describe('Tasks Service', function() { - var rootScope, tasks, user, $httpBackend; - var apiV3Prefix = '/api/v3/tasks'; + var rootScope, tasks, user; beforeEach(function() { + module(function($provide) { user = specHelper.newUser(); $provide.value('User', {user: user}); }); - inject(function(_$httpBackend_, _$rootScope_, Tasks, User) { - $httpBackend = _$httpBackend_; + inject(function(_$rootScope_, Tasks, User) { rootScope = _$rootScope_; rootScope.charts = {}; tasks = Tasks; }); }); - it('calls get user tasks endpoint', function() { - $httpBackend.expectGET(apiV3Prefix + '/user').respond({}); - tasks.getUserTasks(); - $httpBackend.flush(); - }); - - it('calls post user tasks endpoint', function() { - $httpBackend.expectPOST(apiV3Prefix + '/user').respond({}); - tasks.createUserTasks(); - $httpBackend.flush(); - }); - - it('calls get challenge tasks endpoint', function() { - var challengeId = 1; - $httpBackend.expectGET(apiV3Prefix + '/challenge/' + challengeId).respond({}); - tasks.getChallengeTasks(challengeId); - $httpBackend.flush(); - }); - - it('calls create challenge tasks endpoint', function() { - var challengeId = 1; - $httpBackend.expectPOST(apiV3Prefix + '/challenge/' + challengeId).respond({}); - tasks.createChallengeTasks(challengeId, {}); - $httpBackend.flush(); - }); - - it('calls get task endpoint', function() { - var taskId = 1; - $httpBackend.expectGET(apiV3Prefix + '/' + taskId).respond({}); - tasks.getTask(taskId); - $httpBackend.flush(); - }); - - it('calls update task endpoint', function() { - var taskId = 1; - $httpBackend.expectPUT(apiV3Prefix + '/' + taskId).respond({}); - tasks.updateTask(taskId, {}); - $httpBackend.flush(); - }); - - it('calls delete task endpoint', function() { - var taskId = 1; - $httpBackend.expectDELETE(apiV3Prefix + '/' + taskId).respond({}); - tasks.deleteTask(taskId); - $httpBackend.flush(); - }); - - it('calls score task endpoint', function() { - var taskId = 1; - var direction = "down"; - $httpBackend.expectPOST(apiV3Prefix + '/' + taskId + '/score/' + direction).respond({}); - tasks.scoreTask(taskId, direction); - $httpBackend.flush(); - }); - - it('calls move task endpoint', function() { - var taskId = 1; - var position = 0; - $httpBackend.expectPOST(apiV3Prefix + '/' + taskId + '/move/to/' + position).respond({}); - tasks.moveTask(taskId, position); - $httpBackend.flush(); - }); - - it('calls add check list item endpoint', function() { - var taskId = 1; - $httpBackend.expectPOST(apiV3Prefix + '/' + taskId + '/checklist').respond({}); - tasks.addChecklistItem(taskId, {}); - $httpBackend.flush(); - }); - - it('calls score check list item endpoint', function() { - var taskId = 1; - var itemId = 2; - $httpBackend.expectPOST(apiV3Prefix + '/' + taskId + '/checklist/' + itemId + '/score').respond({}); - tasks.scoreCheckListItem(taskId, itemId); - $httpBackend.flush(); - }); - - it('calls update check list item endpoint', function() { - var taskId = 1; - var itemId = 2; - $httpBackend.expectPUT(apiV3Prefix + '/' + taskId + '/checklist/' + itemId).respond({}); - tasks.updateChecklistItem(taskId, itemId, {}); - $httpBackend.flush(); - }); - - it('calls remove check list item endpoint', function() { - var taskId = 1; - var itemId = 2; - $httpBackend.expectDELETE(apiV3Prefix + '/' + taskId + '/checklist/' + itemId).respond({}); - tasks.removeChecklistItem(taskId, itemId); - $httpBackend.flush(); - }); - - it('calls add tag to list item endpoint', function() { - var taskId = 1; - var tagId = 2; - $httpBackend.expectPOST(apiV3Prefix + '/' + taskId + '/tags/' + tagId).respond({}); - tasks.addTagToTask(taskId, tagId); - $httpBackend.flush(); - }); - - it('calls remove tag to list item endpoint', function() { - var taskId = 1; - var tagId = 2; - $httpBackend.expectDELETE(apiV3Prefix + '/' + taskId + '/tags/' + tagId).respond({}); - tasks.removeTagFromTask(taskId, tagId); - $httpBackend.flush(); - }); - - it('calls unlinkOneTask endpoint', function() { - var taskId = 1; - var keep = "keep"; - $httpBackend.expectPOST(apiV3Prefix + '/unlink-one/' + taskId + '?keep=' + keep).respond({}); - tasks.unlinkOneTask(taskId); - $httpBackend.flush(); - }); - - it('calls unlinkAllTasks endpoint', function() { - var challengeId = 1; - var keep = "keep-all"; - $httpBackend.expectPOST(apiV3Prefix + '/unlink-all/' + challengeId + '?keep=' + keep).respond({}); - tasks.unlinkAllTasks(challengeId); - $httpBackend.flush(); - }); - - it('calls clear completed todo task endpoint', function() { - $httpBackend.expectPOST(apiV3Prefix + '/clearCompletedTodos').respond({}); - tasks.clearCompletedTodos(); - $httpBackend.flush(); - }); - describe('editTask', function() { var task; @@ -159,35 +26,35 @@ describe('Tasks Service', function() { }); it('toggles the _editing property', function() { - tasks.editTask(task, user); + tasks.editTask(task); expect(task._editing).to.eql(true); - tasks.editTask(task, user); + tasks.editTask(task); expect(task._editing).to.eql(false); }); it('sets _tags to true by default', function() { - tasks.editTask(task, user); + tasks.editTask(task); expect(task._tags).to.eql(true); }); it('sets _tags to false if preference for collapsed tags is turned on', function() { user.preferences.tagsCollapsed = true; - tasks.editTask(task, user); + tasks.editTask(task); expect(task._tags).to.eql(false); }); it('sets _advanced to true by default', function(){ user.preferences.advancedCollapsed = true; - tasks.editTask(task, user); + tasks.editTask(task); expect(task._advanced).to.eql(false); }); it('sets _advanced to false if preference for collapsed advance menu is turned on', function() { user.preferences.advancedCollapsed = false; - tasks.editTask(task, user); + tasks.editTask(task); expect(task._advanced).to.eql(true); }); @@ -195,7 +62,7 @@ describe('Tasks Service', function() { it('closes task chart if it exists', function() { rootScope.charts[task.id] = true; - tasks.editTask(task, user); + tasks.editTask(task); expect(rootScope.charts[task.id]).to.eql(false); }); }); @@ -215,22 +82,24 @@ describe('Tasks Service', function() { expect(clonedTask.attribute).to.eql(task.attribute); }); - it('does not clone original task\'s _id', function() { + it('does not clone original task\'s id or _id', function() { var task = specHelper.newTask(); var clonedTask = tasks.cloneTask(task); + expect(clonedTask.id).to.exist; + expect(clonedTask.id).to.not.eql(task.id); expect(clonedTask._id).to.exist; expect(clonedTask._id).to.not.eql(task._id); }); it('does not clone original task\'s dateCreated attribute', function() { var task = specHelper.newTask({ - createdAt: new Date(2014, 5, 1, 1, 1, 1, 1), + dateCreated: new Date(2014, 5, 1, 1, 1, 1, 1), }); var clonedTask = tasks.cloneTask(task); - expect(clonedTask.createdAt).to.exist; - expect(clonedTask.createdAt).to.not.eql(task.createdAt); + expect(clonedTask.dateCreated).to.exist; + expect(clonedTask.dateCreated).to.not.eql(task.dateCreated); }); it('does not clone original task\'s value', function() { diff --git a/test/spec/services/userServicesSpec.js b/test/spec/services/userServicesSpec.js index 7bb5b7aac9..2f34507e32 100644 --- a/test/spec/services/userServicesSpec.js +++ b/test/spec/services/userServicesSpec.js @@ -36,12 +36,12 @@ describe('userServices', function() { expect(user_id).to.eql(user.user); }); - xit('alerts when not authenticated', function(){ + it('alerts when not authenticated', function(){ user.log(); expect($window.alert).to.have.been.calledWith("Not authenticated, can't sync, go to settings first."); }); - xit('puts items in que queue', function(){ + it('puts items in que queue', function(){ user.log({}); //TODO where does that null comes from? expect(user.settings.sync.queue).to.eql([null, {}]); diff --git a/test/spec/specHelper.js b/test/spec/specHelper.js index cbacac1988..9324fa5d8b 100644 --- a/test/spec/specHelper.js +++ b/test/spec/specHelper.js @@ -28,9 +28,6 @@ var specHelper = {}; var user = { _id: 'unique-user-id', - profile: { - name: 'dummy-name', - }, auth: { timestamps: {} }, stats: stats, items: items, diff --git a/website/client/js/controllers/guildsCtrl.js b/website/client/js/controllers/guildsCtrl.js deleted file mode 100644 index 821b9aebba..0000000000 --- a/website/client/js/controllers/guildsCtrl.js +++ /dev/null @@ -1,127 +0,0 @@ -'use strict'; - -habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', 'Analytics', - function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile, Analytics) { - $scope.groups = { - guilds: [], - public: [], - }; - - Groups.myGuilds() - .then(function (guilds) { - $scope.groups.guilds = guilds; - }); - - Groups.publicGuilds() - .then(function (guilds) { - $scope.groups.public = guilds; - }); - - $scope.type = 'guild'; - $scope.text = window.env.t('guild'); - - var newGroup = function(){ - return {type:'guild', privacy:'private'}; - } - $scope.newGroup = newGroup() - - $scope.create = function(group){ - if (User.user.balance < 1) { - return $rootScope.openModal('buyGems', {track:"Gems > Create Group"}); - } - - if (confirm(window.env.t('confirmGuild'))) { - Groups.Group.create(group) - .then(function (response) { - var createdGroup = response.data.data; - if (createdGroup.privacy == 'public') { - Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'guild', 'privacy': createdGroup.privacy, 'groupName':createdGroup.name}) - } else { - Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'guild', 'privacy': createdGroup.privacy}) - } - $rootScope.hardRedirect('/#/options/groups/guilds/' + createdGroup._id); - }); - } - } - - $scope.join = function (group) { - var groupId = group._id; - - // If we don't have the _id property, we are joining from an invitation - // which contains a id property of the group - if (group.id && !group._id) { - groupId = group.id; - } - - Groups.Group.join(groupId) - .then(function (response) { - var joinedGroup = response.data.data; - - User.user.guilds.push(joinedGroup._id); - - if (joinedGroup.privacy == 'public') { - Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':false, 'groupType':'guild','privacy': joinedGroup.privacy, 'groupName': joinedGroup.name}) - } else { - Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':false, 'groupType':'guild','privacy': joinedGroup.privacy}) - } - - $location.path('/options/groups/guilds/' + joinedGroup._id); - }); - } - - $scope.reject = function(invitationToReject) { - var index = _.findIndex(User.user.invitations.guilds, function(invite) { return invite.id === invitationToReject.id; }); - User.user.invitations.guilds = User.user.invitations.guilds.splice(0, index); - Groups.Group.rejectInvite(invitationToReject.id); - } - - $scope.leave = function(keep) { - if (keep == 'cancel') { - $scope.selectedGroup = undefined; - $scope.popoverEl.popover('destroy'); - } else { - Groups.Group.leave($scope.selectedGroup._id, keep) - .success(function (data) { - var index = User.user.guilds.indexOf($scope.selectedGroup._id); - delete User.user.guilds[index]; - $scope.selectedGroup = undefined; - $location.path('/options/groups/guilds'); - }); - } - } - - $scope.clickLeave = function(group, $event){ - $scope.selectedGroup = group; - $scope.popoverEl = $($event.target).closest('.btn'); - - var html, title; - - Challenges.getGroupChallenges(group._id) - .then(function(response) { - var challenges = _.pluck(_.filter(response.data.data, function(c) { - return c.group._id == group._id; - }), '_id'); - - if (_.intersection(challenges, User.user.challenges).length > 0) { - html = $compile( - '
' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' - )($scope); - title = window.env.t('leaveGroupCha'); - } else { - html = $compile( - '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' - )($scope); - title = window.env.t('leaveGroup') - } - - $scope.popoverEl.popover('destroy').popover({ - html: true, - placement: 'top', - trigger: 'manual', - title: title, - content: html - }).popover('show'); - }); - } - } - ]); diff --git a/website/client/js/controllers/partyCtrl.js b/website/client/js/controllers/partyCtrl.js deleted file mode 100644 index 64070734bf..0000000000 --- a/website/client/js/controllers/partyCtrl.js +++ /dev/null @@ -1,213 +0,0 @@ -'use strict'; - -habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','Challenges','$state','$compile','Analytics','Quests','Social', - function($rootScope, $scope, Groups, Chat, User, Challenges, $state, $compile, Analytics, Quests, Social) { - - var user = User.user; - - $scope.type = 'party'; - $scope.text = window.env.t('party'); - - $scope.inviteOrStartParty = Groups.inviteOrStartParty; - $scope.loadWidgets = Social.loadWidgets; - - Groups.Group.syncParty() - .then(function successCallback(group) { - $rootScope.party = $scope.group = group; - checkForNotifications(); - }, function errorCallback(response) { - $rootScope.party = $scope.group = $scope.newGroup = { type: 'party' }; - }); - - function checkForNotifications () { - // Checks if user's party has reached 2 players for the first time. - if(!user.achievements.partyUp - && $scope.group.memberCount >= 2) { - User.set({'achievements.partyUp':true}); - $rootScope.openModal('achievements/partyUp', {controller:'UserCtrl', size:'sm'}); - } - - // Checks if user's party has reached 4 players for the first time. - if(!user.achievements.partyOn - && $scope.group.memberCount >= 4) { - User.set({'achievements.partyOn':true}); - $rootScope.openModal('achievements/partyOn', {controller:'UserCtrl', size:'sm'}); - } - } - - if ($scope.group && $scope.group._id) { - Chat.markChatSeen($scope.group._id); - } - - $scope.create = function(group) { - if (!group.name) group.name = env.t('possessiveParty', {name: User.user.profile.name}); - Groups.Group.create(group) - .then(function(response) { - $rootScope.party = $scope.group = response.data.data; - User.sync(); - Groups.data.party = $scope.group; - Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'join group', 'owner':true, 'groupType':'party', 'privacy':'private'}); - Analytics.updateUser({'party.id': $scope.group ._id, 'partySize': 1}); - }); - }; - - $scope.join = function (party) { - Groups.Group.join(party.id) - .then(function (response) { - $rootScope.party = $scope.group = response.data.data; - User.sync(); - Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'party','privacy':'private'}); - Analytics.updateUser({'partyID': party.id}); - $rootScope.hardRedirect('/#/options/groups/party'); - }); - }; - - // TODO: refactor guild and party leave into one function - $scope.leave = function (keep) { - if (keep == 'cancel') { - $scope.selectedGroup = undefined; - $scope.popoverEl.popover('destroy'); - } else { - Groups.Group.leave($scope.selectedGroup._id, keep) - .then(function (response) { - Analytics.updateUser({'partySize':null,'partyID':null}); - User.sync().then(function () { - $rootScope.hardRedirect('/#/options/groups/party'); - }); - }); - } - }; - - // TODO: refactor guild and party clickLeave into one function - $scope.clickLeave = function(group, $event){ - Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Leave Party'}); - $scope.selectedGroup = group; - $scope.popoverEl = $($event.target).closest('.btn'); - var html, title; - html = $compile('' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
')($scope); - title = window.env.t('leavePartyCha'); - - //TODO: Move this to challenge service - Challenges.getGroupChallenges(group._id) - .then(function(response) { - var challenges = _.pluck(_.filter(response.data.data, function(c) { - return c.group._id == group._id; - }), '_id'); - - if (_.intersection(challenges, User.user.challenges).length > 0) { - html = $compile( - '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' - )($scope); - title = window.env.t('leavePartyCha'); - } else { - html = $compile( - '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' - )($scope); - title = window.env.t('leaveParty'); - } - - $scope.popoverEl.popover('destroy').popover({ - html: true, - placement: 'top', - trigger: 'manual', - title: title, - content: html - }).popover('show'); - }); - }; - - $scope.clickStartQuest = function () { - Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Start a Quest'}); - var hasQuests = _.find(User.user.items.quests, function(quest) { - return quest > 0; - }); - - if (hasQuests){ - $rootScope.openModal("ownedQuests", { controller:"InventoryCtrl" }); - } else { - $rootScope.$state.go('options.inventory.quests'); - } - }; - - $scope.leaveOldPartyAndJoinNewParty = function(newPartyId, newPartyName) { - if (confirm('Are you sure you want to delete your party and join ' + newPartyName + '?')) { - Groups.Group.leave(Groups.data.party._id, false) - .then(function() { - $rootScope.party = $scope.group = { - loadingNewParty: true - }; - $scope.join({ id: newPartyId, name: newPartyName }); - }); - } - } - - $scope.reject = function(party) { - Groups.Group.rejectInvite(party.id); - User.set({'invitations.party':{}}); - } - - $scope.questInit = function() { - var key = $rootScope.selectedQuest.key; - - Quests.initQuest(key).then(function() { - $rootScope.selectedQuest = undefined; - $scope.$close(); - }); - }; - - $scope.questCancel = function(){ - if (!confirm(window.env.t('sureCancel'))) return; - - Quests.sendAction('quests/cancel') - .then(function(quest) { - $scope.group.quest = quest; - }); - } - - $scope.questAbort = function(){ - if (!confirm(window.env.t('sureAbort'))) return; - if (!confirm(window.env.t('doubleSureAbort'))) return; - - Quests.sendAction('quests/abort') - .then(function(quest) { - $scope.group.quest = quest; - }); - } - - $scope.questLeave = function(){ - if (!confirm(window.env.t('sureLeave'))) return; - - Quests.sendAction('quests/leave') - .then(function(quest) { - $scope.group.quest = quest; - }); - } - - $scope.questAccept = function(){ - Quests.sendAction('quests/accept') - .then(function(quest) { - $scope.group.quest = quest; - }); - }; - - $scope.questForceStart = function(){ - Quests.sendAction('quests/force-start') - .then(function(quest) { - $scope.group.quest = quest; - }); - }; - - $scope.questReject = function(){ - Quests.sendAction('quests/reject') - .then(function(quest) { - $scope.group.quest = quest; - }); - }; - - $scope.canEditQuest = function() { - var isQuestLeader = $scope.group.quest && $scope.group.quest.leader === User.user._id; - - return isQuestLeader; - }; - } - ]); diff --git a/website/client/js/controllers/tavernCtrl.js b/website/client/js/controllers/tavernCtrl.js deleted file mode 100644 index 77056e07a2..0000000000 --- a/website/client/js/controllers/tavernCtrl.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict'; - -habitrpg.controller("TavernCtrl", ['$scope', 'Groups', 'User', 'Challenges', - function($scope, Groups, User, Challenges) { - Groups.tavern() - .then(function (tavern) { - $scope.group = tavern; - Challenges.getGroupChallenges($scope.group._id) - .then(function (response) { - $scope.group.challenges = response.data.data; - }); - }) - - $scope.toggleUserTier = function($event) { - $($event.target).next().toggle(); - } - } - ]); diff --git a/website/client/js/services/challengeServices.js b/website/client/js/services/challengeServices.js deleted file mode 100644 index e5f4cfa2b8..0000000000 --- a/website/client/js/services/challengeServices.js +++ /dev/null @@ -1,99 +0,0 @@ -'use strict'; - -angular.module('habitrpg') -.factory('Challenges', ['ApiUrl', '$resource', '$http', - function(ApiUrl, $resource, $http) { - var apiV3Prefix = '/api/v3'; - - function createChallenge (challengeData) { - return $http({ - method: 'POST', - url: apiV3Prefix + '/challenges', - data: challengeData, - }); - } - - function joinChallenge (challengeId) { - return $http({ - method: 'POST', - url: apiV3Prefix + '/challenges/' + challengeId + '/join', - }); - } - - function leaveChallenge (challengeId, keep) { - return $http({ - method: 'POST', - url: apiV3Prefix + '/challenges/' + challengeId + '/leave', - data: { - keep: keep, - } - }); - } - - function getUserChallenges () { - return $http({ - method: 'GET', - url: apiV3Prefix + '/challenges/user', - }); - } - - function getGroupChallenges (groupId) { - return $http({ - method: 'GET', - url: apiV3Prefix + '/challenges/groups/' + groupId, - }); - } - - function getChallenge (challengeId) { - return $http({ - method: 'GET', - url: apiV3Prefix + '/challenges/' + challengeId, - }); - } - - function exportChallengeCsv (challengeId) { - return $http({ - method: 'GET', - url: apiV3Prefix + '/challenges/' + challengeId + '/export/csv', - }); - } - - function updateChallenge (challengeId, updateData) { - - var challengeDataToSend = _.omit(updateData, ['tasks', 'habits', 'todos', 'rewards', 'group']); - if (challengeDataToSend.leader && challengeDataToSend.leader._id) challengeDataToSend.leader = challengeDataToSend.leader._id; - - return $http({ - method: 'PUT', - url: apiV3Prefix + '/challenges/' + challengeId, - data: challengeDataToSend, - }); - } - - function deleteChallenge (challengeId) { - return $http({ - method: 'DELETE', - url: apiV3Prefix + '/challenges/' + challengeId, - }); - } - - function selectChallengeWinner (challengeId, winnerId) { - return $http({ - method: 'POST', - url: apiV3Prefix + '/challenges/' + challengeId + '/selectWinner/' + winnerId, - }); - } - - return { - createChallenge: createChallenge, - joinChallenge: joinChallenge, - leaveChallenge: leaveChallenge, - getUserChallenges: getUserChallenges, - getGroupChallenges: getGroupChallenges, - getChallenge: getChallenge, - exportChallengeCsv: exportChallengeCsv, - updateChallenge: updateChallenge, - deleteChallenge: deleteChallenge, - selectChallengeWinner: selectChallengeWinner, - } - }]); diff --git a/website/client/js/services/chatServices.js b/website/client/js/services/chatServices.js deleted file mode 100644 index 4020c9dd92..0000000000 --- a/website/client/js/services/chatServices.js +++ /dev/null @@ -1,87 +0,0 @@ -'use strict'; - -angular.module('habitrpg') -.factory('Chat', ['$http', 'ApiUrl', 'User', - function($http, ApiUrl, User) { - var apiV3Prefix = '/api/v3'; - - function getChat (groupId) { - return $http({ - method: 'GET', - url: apiV3Prefix + '/groups/' + groupId + '/chat', - }); - } - - function postChat (groupId, message, previousMsg) { - var url = apiV3Prefix + '/groups/' + groupId + '/chat'; - - if (previousMsg) { - url += '?previousMsg=' + previousMsg; - } - - return $http({ - method: 'POST', - url: url, - data: { - message: message, - } - }); - } - - function deleteChat (groupId, chatId, previousMsg) { - var url = apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId; - - if (previousMsg) { - url += '?previousMsg=' + previousMsg; - } - - return $http({ - method: 'DELETE', - url: url, - }); - } - - function like (groupId, chatId) { - return $http({ - method: 'POST', - url: apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/like', - }); - } - - function flagChatMessage (groupId, chatId) { - return $http({ - method: 'POST', - url: apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/flag', - }); - } - - function clearFlagCount (groupId, chatId) { - return $http({ - method: 'POST', - url: apiV3Prefix + '/groups/' + groupId + '/chat/' + chatId + '/clearflags', - }); - } - - function markChatSeen (groupId) { - if (User.user.newMessages) delete User.user.newMessages[groupId]; - return $http({ - method: 'POST', - url: apiV3Prefix + '/groups/' + groupId + '/chat/seen', - }); - } - - function clearCards () { - User.user._wrapped && User.set({'flags.cardReceived':false}); - } - - return { - getChat: getChat, - postChat: postChat, - deleteChat: deleteChat, - like: like, - flagChatMessage: flagChatMessage, - clearFlagCount: clearFlagCount, - markChatSeen: markChatSeen, - clearCards: clearCards, - } - }]); diff --git a/website/client/js/services/groupServices.js b/website/client/js/services/groupServices.js deleted file mode 100644 index 916efe50b7..0000000000 --- a/website/client/js/services/groupServices.js +++ /dev/null @@ -1,234 +0,0 @@ -'use strict'; - -angular.module('habitrpg') -.factory('Groups', [ '$location', '$rootScope', '$http', 'Analytics', 'ApiUrl', 'Challenges', '$q', 'User', 'Members', - function($location, $rootScope, $http, Analytics, ApiUrl, Challenges, $q, User, Members) { - var data = {party: undefined, myGuilds: undefined, publicGuilds: undefined, tavern: undefined }; - var groupApiURLPrefix = "/api/v3/groups"; - var TAVERN_NAME = 'HabitRPG'; - - var Group = {}; - - //@TODO: Add paging - Group.getGroups = function(type) { - var url = groupApiURLPrefix; - if (type) { - url += '?type=' + type; - } - - return $http({ - method: 'GET', - url: url, - }); - }; - - Group.get = function(gid) { - return $http({ - method: 'GET', - url: groupApiURLPrefix + '/' + gid, - }); - }; - - Group.syncParty = function() { - return party(); - }; - - Group.create = function(groupDetails) { - return $http({ - method: "POST", - url: groupApiURLPrefix, - data: groupDetails, - }); - }; - - Group.update = function(groupDetails) { - //@TODO: Check for what has changed? - - //Remove populated fields - var groupDetailsToSend = _.omit(groupDetails, ['challenges', 'members', 'invites']); - if (groupDetailsToSend.leader && groupDetailsToSend.leader._id) groupDetailsToSend.leader = groupDetailsToSend.leader._id; - - return $http({ - method: "PUT", - url: groupApiURLPrefix + '/' + groupDetailsToSend._id, - data: groupDetailsToSend, - }); - }; - - Group.join = function(gid) { - return $http({ - method: "POST", - url: groupApiURLPrefix + '/' + gid + '/join', - }); - }; - - Group.rejectInvite = function(gid) { - return $http({ - method: "POST", - url: groupApiURLPrefix + '/' + gid + '/reject-invite', - }); - }; - - Group.leave = function(gid, keep) { - return $http({ - method: "POST", - url: groupApiURLPrefix + '/' + gid + '/leave', - data: { - keep: keep, - } - }); - }; - - Group.removeMember = function(gid, memberId, message) { - return $http({ - method: "POST", - url: groupApiURLPrefix + '/' + gid + '/removeMember/' + memberId, - data: { - message: message, - }, - }); - }; - - Group.invite = function(gid, invitationDetails) { - return $http({ - method: "POST", - url: groupApiURLPrefix + '/' + gid + '/invite', - data: { - uuids: invitationDetails.uuids, - emails: invitationDetails.emails, - }, - }); - }; - - Group.inviteToQuest = function(gid, key) { - return $http({ - method: "POST", - url: groupApiURLPrefix + '/' + gid + '/quests/invite/' + key, - }); - }; - - //On page load, multiple controller request the party. - //So, we cache the promise until the first result is returned - var _cachedPartyPromise; - function party (forceUpdate) { - if (_cachedPartyPromise && !forceUpdate) return _cachedPartyPromise.promise; - _cachedPartyPromise = $q.defer(); - - if (!User.user.party._id) { - data.party = { type: 'party' }; - _cachedPartyPromise.reject(data.party); - } - - if (!data.party || forceUpdate) { - Group.get('party') - .then(function (response) { - data.party = response.data.data; - Members.getGroupMembers(data.party._id, true) - .then(function (response) { - data.party.members = response.data.data; - return Members.getGroupInvites(data.party._id); - }) - .then(function (response) { - data.party.invites = response.data.data; - return Challenges.getGroupChallenges(data.party._id) - }) - .then(function (response) { - data.party.challenges = response.data.data; - _cachedPartyPromise.resolve(data.party); - }); - }, function (response) { - data.party = { type: 'party' }; - _cachedPartyPromise.reject(data.party); - }) - .finally(function() { - _cachePartyPromise = null; - }); - } else { - _cachedPartyPromise.resolve(data.party); - } - - return _cachedPartyPromise.promise; - } - - function publicGuilds () { - var deferred = $q.defer(); - - if (!data.publicGuilds) { - Group.getGroups('publicGuilds') - .then(function (response) { - data.publicGuilds = response.data.data; - deferred.resolve(data.publicGuilds); - }, function (response) { - deferred.reject(response); - }); - } else { - deferred.resolve(data.publicGuilds); - } - - return deferred.promise; - //TODO combine these as {type:'guilds,public'} and create a $filter() to separate them - } - - function myGuilds () { - var deferred = $q.defer(); - - if (!data.myGuilds) { - Group.getGroups('guilds') - .then(function (response) { - data.myGuilds = response.data.data; - deferred.resolve(data.myGuilds); - }, function (response) { - deferred.reject(response); - }); - } else { - deferred.resolve(data.myGuilds); - } - - return deferred.promise; - } - - function tavern (forceUpdate) { - var deferred = $q.defer(); - - if (!data.tavern || forceUpdate) { - Group.get('habitrpg') - .then(function (response) { - data.tavern = response.data.data; - deferred.resolve(data.tavern); - }, function (response) { - deferred.reject(response); - }); - } else { - deferred.resolve(data.tavern); - } - - return deferred.promise; - } - - function inviteOrStartParty (group) { - Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Invite Friends'}); - if (group.type === "party" || $location.$$path === "/options/groups/party") { - group.type = 'party'; - $rootScope.openModal('invite-party', { - controller:'InviteToGroupCtrl', - resolve: { - injectedGroup: function(){ return group; } - } - }); - } else { - $location.path("/options/groups/party"); - } - } - - return { - TAVERN_NAME: TAVERN_NAME, - party: party, - publicGuilds: publicGuilds, - myGuilds: myGuilds, - tavern: tavern, - inviteOrStartParty: inviteOrStartParty, - - data: data, - Group: Group, - }; - }]); diff --git a/website/client/js/services/memberServices.js b/website/client/js/services/memberServices.js deleted file mode 100644 index be1eab4841..0000000000 --- a/website/client/js/services/memberServices.js +++ /dev/null @@ -1,128 +0,0 @@ -'use strict'; - -angular.module('habitrpg') -.factory('Members', [ '$rootScope', 'Shared', 'ApiUrl', '$http', '$q', - function($rootScope, Shared, ApiUrl, $http, $q) { - var members = {}; - var selectedMember = {}; - var apiV3Prefix = '/api/v3'; - - function fetchMember (memberId) { - return $http({ - method: 'GET', - url: apiV3Prefix + '/members/' + memberId, - }); - } - - //@TODO: Add paging - function getGroupMembers (groupId, includeAllPublicFields) { - var url = apiV3Prefix + '/groups/' + groupId + '/members'; - - if (includeAllPublicFields) { - url += '?includeAllPublicFields=true'; - } - - return $http({ - method: 'GET', - url: url, - }); - } - - function getGroupInvites (groupId) { - return $http({ - method: 'GET', - url: apiV3Prefix + '/groups/' + groupId + '/invites', - }); - } - - function getChallengeMembers (challengeId) { - return $http({ - method: 'GET', - url: apiV3Prefix + '/challenges/' + challengeId + '/members', - }); - } - - function getChallengeMemberProgress (challengeId, memberId) { - return $http({ - method: 'GET', - url: apiV3Prefix + '/challenges/' + challengeId + '/members/' + memberId, - }); - } - - function sendPrivateMessage (message, toUserId) { - return $http({ - method: 'POST', - url: apiV3Prefix + '/members/send-private-message', - data: { - message: message, - toUserId: toUserId, - } - }); - } - - function transferGems (message, toUserId, gemAmount) { - return $http({ - method: 'POST', - url: apiV3Prefix + '/members/transfer-gems', - data: { - message: message, - toUserId: toUserId, - gemAmount: gemAmount, - } - }); - } - - function selectMember (uid) { - var self = this; - var deferred = $q.defer(); - var memberIsReady = _checkIfMemberIsReady(members[uid]); - - if (memberIsReady) { - _prepareMember(members[uid], self); - deferred.resolve(); - } else { - fetchMember(uid) - .then(function (response) { - var member = response.data.data; - addToMembersList(member); // lazy load for later - _prepareMember(member, self); - deferred.resolve(); - }); - } - - return deferred.promise; - } - - function addToMembersList (member) { - if (member._id) { - members[member._id] = member; - } - } - - function _checkIfMemberIsReady (member) { - return member && member.items && member.items.weapon; - } - - function _prepareMember(member, self) { - Shared.wrap(member, false); - self.selectedMember = members[member._id]; - } - - $rootScope.$on('userUpdated', function(event, user){ - addToMembersList(user); - }) - - return { - members: members, - addToMembersList: addToMembersList, - selectedMember: undefined, - selectMember: selectMember, - fetchMember: fetchMember, - getGroupMembers: getGroupMembers, - getGroupInvites: getGroupInvites, - getChallengeMembers: getChallengeMembers, - getChallengeMemberProgress: getChallengeMemberProgress, - sendPrivateMessage: sendPrivateMessage, - transferGems: transferGems, - } - }]); diff --git a/website/client/js/services/tagsServices.js b/website/client/js/services/tagsServices.js deleted file mode 100644 index 2a430282b6..0000000000 --- a/website/client/js/services/tagsServices.js +++ /dev/null @@ -1,60 +0,0 @@ -'use strict'; - -angular.module('habitrpg') -.factory('Tags', ['$rootScope', '$http', - function tagsFactory($rootScope, $http) { - - function getTags () { - return $http({ - method: 'GET', - url: 'api/v3/tags', - }); - }; - - function createTag (tagDetails) { - return $http({ - method: 'POST', - url: 'api/v3/tags', - data: tagDetails, - }); - }; - - function getTag (tagId) { - return $http({ - method: 'GET', - url: 'api/v3/tags/' + tagId, - }); - }; - - function updateTag (tagId, tagDetails) { - return $http({ - method: 'PUT', - url: 'api/v3/tags/' + tagId, - data: tagDetails, - }); - }; - - function sortTag (tagId, to) { - return $http({ - method: 'POST', - url: 'api/v3/reorder-tags', - data: {tagId: tagId, to: to}, - }); - }; - - function deleteTag (tagId) { - return $http({ - method: 'DELETE', - url: 'api/v3/tags/' + tagId, - }); - }; - - return { - getTags: getTags, - createTag: createTag, - getTag: getTag, - updateTag: updateTag, - sortTag: sortTag, - deleteTag: deleteTag, - }; - }]); diff --git a/website/client/js/services/taskServices.js b/website/client/js/services/taskServices.js deleted file mode 100644 index e7eeef1a93..0000000000 --- a/website/client/js/services/taskServices.js +++ /dev/null @@ -1,205 +0,0 @@ -'use strict'; - -var TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'history', 'id', 'streak', 'createdAt']; - -angular.module('habitrpg') -.factory('Tasks', ['$rootScope', 'Shared', '$http', - function tasksFactory($rootScope, Shared, $http) { - - function getUserTasks (getCompletedTodos) { - var url = '/api/v3/tasks/user'; - - if (getCompletedTodos) url += '?type=completedTodos'; - - return $http({ - method: 'GET', - url: url, - }); - }; - - function createUserTasks (taskDetails) { - return $http({ - method: 'POST', - url: '/api/v3/tasks/user', - data: taskDetails, - }); - }; - - function getChallengeTasks (challengeId) { - return $http({ - method: 'GET', - url: '/api/v3/tasks/challenge/' + challengeId, - }); - }; - - function createChallengeTasks (challengeId, taskDetails) { - return $http({ - method: 'POST', - url: '/api/v3/tasks/challenge/' + challengeId, - data: taskDetails, - }); - }; - - function getTask (taskId) { - return $http({ - method: 'GET', - url: '/api/v3/tasks/' + taskId, - }); - }; - - function updateTask (taskId, taskDetails) { - return $http({ - method: 'PUT', - url: '/api/v3/tasks/' + taskId, - data: taskDetails, - }); - }; - - function deleteTask (taskId) { - return $http({ - method: 'DELETE', - url: '/api/v3/tasks/' + taskId, - }); - }; - - function scoreTask (taskId, direction) { - return $http({ - method: 'POST', - url: '/api/v3/tasks/' + taskId + '/score/' + direction, - }); - }; - - function moveTask (taskId, position) { - return $http({ - method: 'POST', - url: '/api/v3/tasks/' + taskId + '/move/to/' + position, - }); - }; - - function addChecklistItem (taskId, checkListItem) { - return $http({ - method: 'POST', - url: '/api/v3/tasks/' + taskId + '/checklist', - data: checkListItem, - }); - }; - - function scoreCheckListItem (taskId, itemId) { - return $http({ - method: 'POST', - url: '/api/v3/tasks/' + taskId + '/checklist/' + itemId + '/score', - }); - }; - - function updateChecklistItem (taskId, itemId, itemDetails) { - return $http({ - method: 'PUT', - url: '/api/v3/tasks/' + taskId + '/checklist/' + itemId, - data: itemDetails, - }); - }; - - function removeChecklistItem (taskId, itemId) { - return $http({ - method: 'DELETE', - url: '/api/v3/tasks/' + taskId + '/checklist/' + itemId, - }); - }; - - function addTagToTask (taskId, tagId) { - return $http({ - method: 'POST', - url: '/api/v3/tasks/' + taskId + '/tags/' + tagId, - }); - }; - - function removeTagFromTask (taskId, tagId) { - return $http({ - method: 'DELETE', - url: '/api/v3/tasks/' + taskId + '/tags/' + tagId, - }); - }; - - function unlinkOneTask (taskId, keep) { // single task - if (!keep) { - keep = "keep"; - } - - return $http({ - method: 'POST', - url: '/api/v3/tasks/unlink-one/' + taskId + '?keep=' + keep, - }); - }; - - function unlinkAllTasks (challengeId, keep) { // all tasks - if (!keep) { - keep = "keep-all"; - } - - return $http({ - method: 'POST', - url: '/api/v3/tasks/unlink-all/' + challengeId + '?keep=' + keep, - }); - }; - - function clearCompletedTodos () { - return $http({ - method: 'POST', - url: '/api/v3/tasks/clearCompletedTodos', - }); - }; - - function editTask(task, user) { - task._editing = !task._editing; - task._tags = !user.preferences.tagsCollapsed; - task._advanced = !user.preferences.advancedCollapsed; - if($rootScope.charts[task._id]) $rootScope.charts[task.id] = false; - } - - function cloneTask(task) { - var clonedTask = _.cloneDeep(task); - clonedTask = _cleanUpTask(clonedTask); - - return Shared.taskDefaults(clonedTask); - } - - function _cleanUpTask(task) { - var cleansedTask = _.omit(task, TASK_KEYS_TO_REMOVE); - - // Copy checklists but reset to uncomplete and assign new id - _(cleansedTask.checklist).forEach(function(item) { - item.completed = false; - item.id = Shared.uuid(); - }).value(); - - if (cleansedTask.type !== 'reward') { - delete cleansedTask.value; - } - - return cleansedTask; - } - - return { - getUserTasks: getUserTasks, - loadedCompletedTodos: false, - createUserTasks: createUserTasks, - getChallengeTasks: getChallengeTasks, - createChallengeTasks: createChallengeTasks, - getTask: getTask, - updateTask: updateTask, - deleteTask: deleteTask, - scoreTask: scoreTask, - moveTask: moveTask, - addChecklistItem: addChecklistItem, - scoreCheckListItem: scoreCheckListItem, - updateChecklistItem: updateChecklistItem, - removeChecklistItem: removeChecklistItem, - addTagToTask: addTagToTask, - removeTagFromTask: removeTagFromTask, - unlinkOneTask: unlinkOneTask, - unlinkAllTasks: unlinkAllTasks, - clearCompletedTodos: clearCompletedTodos, - editTask: editTask, - cloneTask: cloneTask - }; - }]); diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js deleted file mode 100644 index 70b8821c6d..0000000000 --- a/website/client/js/services/userServices.js +++ /dev/null @@ -1,583 +0,0 @@ -'use strict'; - -angular.module('habitrpg') - .service('ApiUrl', ['API_URL', function(currentApiUrl) { - this.setApiUrl = function(newUrl){ - currentApiUrl = newUrl; - }; - - this.get = function(){ - return currentApiUrl; - }; - }]) - -/** - * Services that persists and retrieves user from localStorage. - */ - .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'Notification', 'ApiUrl', 'Tasks', 'Tags', - function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, Notification, ApiUrl, Tasks, Tags) { - var authenticated = false; - var defaultSettings = { - auth: { apiId: '', apiToken: ''}, - sync: { - queue: [], //here OT will be queued up, this is NOT call-back queue! - sent: [] //here will be OT which have been sent, but we have not got reply from server yet. - }, - fetching: false, // whether fetch() was called or no. this is to avoid race conditions - online: false - }; - var settings = {}; //habit mobile settings (like auth etc.) to be stored here - var user = {}; // this is stored as a reference accessible to all controllers, that way updates propagate - - var userNotifications = { - // "party.order" : env.t("updatedParty"), - // "party.orderAscending" : env.t("updatedParty") - // party.order notifications are not currently needed because the party avatars are resorted immediately now - }; // this is a list of notifications to send to the user when changes are made, along with the message. - - //first we populate user with schema - user.apiToken = user._id = ''; // we use id / apitoken to determine if registered - - //than we try to load localStorage - if (localStorage.getItem(STORAGE_USER_ID)) { - _.extend(user, JSON.parse(localStorage.getItem(STORAGE_USER_ID))); - } - - user._wrapped = false; - - function syncUserTasks (tasks) { - user.habits = []; - user.todos = []; - user.dailys = []; - user.rewards = []; - - // Order tasks based on tasksOrder - var groupedTasks = _(tasks) - .groupBy('type') - .forEach(function (tasksOfType, type) { - var order = user.tasksOrder[type + 's']; - var orderedTasks = new Array(tasksOfType.length); - var unorderedTasks = []; // what we want to add later - - tasksOfType.forEach(function (task, index) { - var taskId = task._id; - var i = order[index] === taskId ? index : order.indexOf(taskId); - if (i === -1) { - unorderedTasks.push(task); - } else { - orderedTasks[i] = task; - } - }); - - // Remove empty values from the array and add any unordered task - user[type + 's'] = _.compact(orderedTasks).concat(unorderedTasks); - }).value(); - } - - function sync() { - return $http({ - method: "GET", - url: '/api/v3/user/', - }) - .then(function (response) { - if (response.data.message) Notification.text(response.data.message); - - _.extend(user, response.data.data); - - $rootScope.$emit('userUpdated', user); - - if (!user._wrapped) { - // This wraps user with `ops`, which are functions shared both on client and mobile. When performed on client, - // they update the user in the browser and then send the request to the server, where the same operation is - // replicated. We need to wrap each op to provide a callback to send that operation - $window.habitrpgShared.wrap(user); - _.each(user.ops, function(op,k){ - user.ops[k] = function(req){ - try { - op(req); - } catch (err) { - Notification.text(err.message); - return; - } - } - }); - } - - return Tasks.getUserTasks(); - }) - .then(function (response) { - var tasks = response.data.data; - syncUserTasks(tasks); - save(); - $rootScope.$emit('userSynced'); - }); - } - sync(); - - var save = function () { - localStorage.setItem(STORAGE_USER_ID, JSON.stringify(user)); - localStorage.setItem(STORAGE_SETTINGS_ID, JSON.stringify(settings)); - }; - - function callOpsFunctionAndRequest (opName, endPoint, method, paramString, opData) { - if (!opData) opData = {}; - - var clientResponse; - - try { - var args = [user]; - if (opName === 'rebirth' || opName === 'reroll' || opName === 'reset') { - args.push(user.habits.concat(user.dailys).concat(user.rewards).concat(user.todos)); - } - - args.push(opData); - clientResponse = $window.habitrpgShared.ops[opName].apply(null, args); - } catch (err) { - Notification.text(err.message); - return; - } - - var clientMessage = clientResponse[1]; - - if (clientMessage) { - Notification.text(clientMessage); - } - - var url = '/api/v3/user/' + endPoint; - if (paramString) { - url += '/' + paramString - } - - var body = {}; - if (opData.body) body = opData.body; - - var queryString = ''; - if (opData.query) queryString = '?' + $.param(opData.query) - - $http({ - method: method, - url: url + queryString, - body: body, - }) - .then(function (response) { - if (response.data.message && response.data.message !== clientMessage) { - Notification.text(response.data.message); - } - - save(); - }) - } - - function setUser(updates) { - for (var key in updates) { - _.set(user, key, updates[key]); - } - } - - var userServices = { - user: user, - - //@TODO: WE need a new way to set the user from tests - setUser: function (userInc) { - user = userInc; - }, - - allocate: function (data) { - callOpsFunctionAndRequest('allocate', 'allocate', "POST",'', data); - }, - - allocateNow: function () { - callOpsFunctionAndRequest('allocateNow', 'allocate-now', "POST"); - }, - - changeClass: function (data) { - callOpsFunctionAndRequest('changeClass', 'change-class', "POST",'', data); - }, - - disableClasses: function () { - callOpsFunctionAndRequest('disableClasses', 'disable-classes', "POST"); - }, - - revive: function (data) { - callOpsFunctionAndRequest('revive', 'revive', "POST"); - }, - - addTask: function (data) { - if (_.isArray(data.body)) { - data.body.forEach(function (task) { - user.ops.addTask({body: task}); - }); - } else { - user.ops.addTask(data); - } - save(); - Tasks.createUserTasks(data.body); - }, - - score: function (data) { - try { - $window.habitrpgShared.ops.scoreTask({user: user, task: data.params.task, direction: data.params.direction}, data.params); - } catch (err) { - Notification.text(err.message); - return; - } - save(); - - Tasks.scoreTask(data.params.task._id, data.params.direction).then(function (res) { - var tmp = res.data.data._tmp || {}; // used to notify drops, critical hits and other bonuses - var drop = tmp.drop; - - if (drop) user._tmp.drop = drop; - }); - }, - - sortTask: function (data) { - user.ops.sortTask(data); - save(); - Tasks.moveTask(data.params.id, data.query.to); - }, - - updateTask: function (task, data) { - $window.habitrpgShared.ops.updateTask(task, data); - save(); - Tasks.updateTask(task._id, data.body); - }, - - deleteTask: function (data) { - user.ops.deleteTask(data); - save(); - Tasks.deleteTask(data.params.id); - }, - - clearCompleted: function () { - user.ops.clearCompleted(user.todos); - save(); - Tasks.clearCompletedTodos(); - }, - - addTag: function(data) { - user.ops.addTag(data); - save(); - Tags.createTag(data.body); - }, - - updateTag: function(data) { - user.ops.updateTag(data); - save(); - Tags.updateTag(data.params.id, data.body); - }, - - sortTag: function (data) { - user.ops.sortTag(data); - Tags.sortTag(user.tags[data.query.from].id, data.query.to); - }, - - deleteTag: function(data) { - user.ops.deleteTag(data); - save(); - Tags.deleteTag(data.params.id); - }, - - addTenGems: function () { - $http({ - method: "POST", - url: 'api/v3/debug/add-ten-gems', - }) - .then(function (response) { - Notification.text('+10 Gems!'); - sync(); - }) - }, - - addHourglass: function () { - $http({ - method: "POST", - url: 'api/v3/debug/add-hourglass', - }) - .then(function (response) { - sync(); - }) - }, - - setCron: function (numberOfDays) { - var date = moment(user.lastCron).subtract(numberOfDays, 'days').toDate(); - - $http({ - method: "POST", - url: 'api/v3/debug/set-cron', - data: { - lastCron: date - } - }) - .then(function (response) { - Notification.text('-' + numberOfDays + ' day(s), remember to refresh'); - }); - }, - - setCustomDayStart: function (dayStart) { - $http({ - method: "POST", - url: 'api/v3/user/custom-day-start', - data: { - dayStart: dayStart - } - }) - .then(function (response) { - Notification.text(response.data.data.message); - sync(); - }); - }, - - makeAdmin: function () { - $http({ - method: "POST", - url: 'api/v3/debug/make-admin' - }) - .then(function (response) { - Notification.text('You are now an admin! Go to the Hall of Heroes to change your contributor level.'); - sync() - }); - }, - - clearNewMessages: function () { - callOpsFunctionAndRequest('markPmsRead', 'mark-pms-read', "POST"); - }, - - clearPMs: function () { - callOpsFunctionAndRequest('clearPMs', 'messages', "DELETE"); - }, - - deletePM: function (data) { - callOpsFunctionAndRequest('deletePM', 'messages', "DELETE", data.params.id, data); - }, - - buy: function (data) { - callOpsFunctionAndRequest('buy', 'buy', "POST", data.params.key, data); - }, - - buyQuest: function (data) { - callOpsFunctionAndRequest('buyQuest', 'buy-quest', "POST", data.params.key, data); - }, - - purchase: function (data) { - var type = data.params.type; - var key = data.params.key; - callOpsFunctionAndRequest('purchase', 'purchase', "POST", type + '/' + key, data); - }, - - buySpecialSpell: function (data) { - $window.habitrpgShared.ops['buySpecialSpell'](user, data); - var key = data.params.key; - - $http({ - method: "POST", - url: '/api/v3/user/' + 'buy-special-spell/' + key, - }) - .then(function (response) { - Notification.text(response.data.message); - }) - }, - - buyMysterySet: function (data) { - callOpsFunctionAndRequest('buyMysterySet', 'buy-mystery-set', "POST", data.params.key, data); - }, - - readCard: function (data) { - callOpsFunctionAndRequest('readCard', 'read-card', "POST", data.params.cardType, data); - }, - - openMysteryItem: function (data) { - callOpsFunctionAndRequest('openMysteryItem', 'open-mystery-item', "POST"); - }, - - sell: function (data) { - var type = data.params.type; - var key = data.params.key; - callOpsFunctionAndRequest('sell', 'sell', "POST", type + '/' + key, data); - }, - - hatch: function (data) { - var egg = data.params.egg; - var hatchingPotion = data.params.hatchingPotion; - callOpsFunctionAndRequest('hatch', 'hatch', "POST", egg + '/' + hatchingPotion, data); - }, - - feed: function (data) { - var pet = data.params.pet; - var food = data.params.food; - callOpsFunctionAndRequest('feed', 'feed', "POST", pet + '/' + food, data); - }, - - equip: function (data) { - var type = data.params.type; - var key = data.params.key; - callOpsFunctionAndRequest('equip', 'equip', "POST", type + '/' + key, data); - }, - - hourglassPurchase: function (data) { - var type = data.params.type; - var key = data.params.key; - callOpsFunctionAndRequest('purchaseHourglass', 'purchase-hourglass', "POST", type + '/' + key, data); - }, - - unlock: function (data) { - callOpsFunctionAndRequest('unlock', 'unlock', "POST", '', data); - }, - - set: function(updates) { - setUser(updates); - $http({ - method: "PUT", - url: '/api/v3/user', - data: updates, - }) - .then(function () { - save(); - $rootScope.$emit('userSynced'); - }) - }, - - reroll: function () { - callOpsFunctionAndRequest('reroll', 'reroll', "POST"); - }, - - rebirth: function () { - callOpsFunctionAndRequest('rebirth', 'rebirth', "POST"); - }, - - reset: function () { - callOpsFunctionAndRequest('reset', 'reset', "POST"); - }, - - releaseBoth: function () { - callOpsFunctionAndRequest('releaseBoth', 'release-both', "POST"); - }, - - releaseMounts: function () { - callOpsFunctionAndRequest('releaseMounts', 'release-mounts', "POST"); - }, - - releasePets: function () { - callOpsFunctionAndRequest('releasePets', 'release-pets', "POST"); - }, - - addWebhook: function (data) { - callOpsFunctionAndRequest('addWebhook', 'webhook', "POST", '', data, data.body); - }, - - updateWebhook: function (data) { - callOpsFunctionAndRequest('updateWebhook', 'webhook', "PUT", data.params.id, data, data.body); - }, - - deleteWebhook: function (data) { - callOpsFunctionAndRequest('deleteWebhook', 'webhook', "DELETE", data.params.id, data, data.body); - }, - - sleep: function () { - callOpsFunctionAndRequest('sleep', 'sleep', "POST"); - }, - - blockUser: function (data) { - callOpsFunctionAndRequest('blockUser', 'block', "POST", data.params.uuid, data); - }, - - online: function (status) { - if (status===true) { - settings.online = true; - // syncQueue(); - } else { - settings.online = false; - }; - }, - - authenticate: function (uuid, token, cb) { - if (uuid && token) { - var offset = moment().zone(); // eg, 240 - this will be converted on server as -(offset/60) - $http.defaults.headers.common['x-api-user'] = uuid; - $http.defaults.headers.common['x-api-key'] = token; - $http.defaults.headers.common['x-user-timezoneOffset'] = offset; - authenticated = true; - settings.auth.apiId = uuid; - settings.auth.apiToken = token; - settings.online = true; - save(); - sync().then(function () { - if (user.preferences.timezoneOffset !== offset) - userServices.set({'preferences.timezoneOffset': offset}); - if (cb) cb(); - }); - } else { - alert('Please enter your ID and Token in settings.') - } - }, - - authenticated: function(){ - return this.settings.auth.apiId !== ""; - }, - - getBalanceInGems: function() { - var balance = user.balance || 0; - return balance * 4; - }, - - log: function (action, cb) { - //push by one buy one if an array passed in. - if (_.isArray(action)) { - action.forEach(function (a) { - settings.sync.queue.push(a); - }); - } else { - settings.sync.queue.push(action); - } - - save(); - }, - - sync: function(){ - userServices.log({}); - return sync(); - }, - - syncUserTasks: syncUserTasks, - - save: save, - - settings: settings - }; - - //load settings if we have them - if (localStorage.getItem(STORAGE_SETTINGS_ID)) { - //use extend here to make sure we keep object reference in other angular controllers - _.extend(settings, JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID))); - - //if settings were saved while fetch was in process reset the flag. - settings.fetching = false; - //create and load if not - } else { - localStorage.setItem(STORAGE_SETTINGS_ID, JSON.stringify(defaultSettings)); - _.extend(settings, defaultSettings); - } - - //If user does not have ApiID that forward him to settings. - if (!settings.auth.apiId || !settings.auth.apiToken) { - //var search = $location.search(); // FIXME this should be working, but it's returning an empty object when at a root url /?_id=... - var search = $location.search($window.location.search.substring(1)).$$search; // so we use this fugly hack instead - if (search.err) return alert(search.err); - if (search._id && search.apiToken) { - userServices.authenticate(search._id, search.apiToken, function(){ - $window.location.href = '/'; - }); - } else { - var isStaticOrSocial = $window.location.pathname.match(/^\/(static|social)/); - if (!isStaticOrSocial){ - localStorage.clear(); - $location.path('/logout'); - } - } - } else { - userServices.authenticate(settings.auth.apiId, settings.auth.apiToken) - } - - return userServices; - } -]); diff --git a/website/client/500.html b/website/public/500.html similarity index 100% rename from website/client/500.html rename to website/public/500.html diff --git a/website/client/apple-touch-icon-114-precomposed.png b/website/public/apple-touch-icon-114-precomposed.png similarity index 100% rename from website/client/apple-touch-icon-114-precomposed.png rename to website/public/apple-touch-icon-114-precomposed.png diff --git a/website/client/apple-touch-icon-144-precomposed.png b/website/public/apple-touch-icon-144-precomposed.png similarity index 100% rename from website/client/apple-touch-icon-144-precomposed.png rename to website/public/apple-touch-icon-144-precomposed.png diff --git a/website/client/apple-touch-icon-57-precomposed.png b/website/public/apple-touch-icon-57-precomposed.png similarity index 100% rename from website/client/apple-touch-icon-57-precomposed.png rename to website/public/apple-touch-icon-57-precomposed.png diff --git a/website/client/apple-touch-icon-72-precomposed.png b/website/public/apple-touch-icon-72-precomposed.png similarity index 100% rename from website/client/apple-touch-icon-72-precomposed.png rename to website/public/apple-touch-icon-72-precomposed.png diff --git a/website/client/apple-touch-icon-precomposed.png b/website/public/apple-touch-icon-precomposed.png similarity index 100% rename from website/client/apple-touch-icon-precomposed.png rename to website/public/apple-touch-icon-precomposed.png diff --git a/website/client/cake.png b/website/public/cake.png similarity index 100% rename from website/client/cake.png rename to website/public/cake.png diff --git a/website/client/community-guidelines-images/backCorner.png b/website/public/community-guidelines-images/backCorner.png similarity index 100% rename from website/client/community-guidelines-images/backCorner.png rename to website/public/community-guidelines-images/backCorner.png diff --git a/website/client/community-guidelines-images/beingHabitican.png b/website/public/community-guidelines-images/beingHabitican.png similarity index 100% rename from website/client/community-guidelines-images/beingHabitican.png rename to website/public/community-guidelines-images/beingHabitican.png diff --git a/website/client/community-guidelines-images/consequences.png b/website/public/community-guidelines-images/consequences.png similarity index 100% rename from website/client/community-guidelines-images/consequences.png rename to website/public/community-guidelines-images/consequences.png diff --git a/website/client/community-guidelines-images/contributing.png b/website/public/community-guidelines-images/contributing.png similarity index 100% rename from website/client/community-guidelines-images/contributing.png rename to website/public/community-guidelines-images/contributing.png diff --git a/website/client/community-guidelines-images/github.gif b/website/public/community-guidelines-images/github.gif similarity index 100% rename from website/client/community-guidelines-images/github.gif rename to website/public/community-guidelines-images/github.gif diff --git a/website/client/community-guidelines-images/infractions.png b/website/public/community-guidelines-images/infractions.png similarity index 100% rename from website/client/community-guidelines-images/infractions.png rename to website/public/community-guidelines-images/infractions.png diff --git a/website/client/community-guidelines-images/intro.png b/website/public/community-guidelines-images/intro.png similarity index 100% rename from website/client/community-guidelines-images/intro.png rename to website/public/community-guidelines-images/intro.png diff --git a/website/client/community-guidelines-images/moderators.png b/website/public/community-guidelines-images/moderators.png similarity index 100% rename from website/client/community-guidelines-images/moderators.png rename to website/public/community-guidelines-images/moderators.png diff --git a/website/client/community-guidelines-images/publicGuilds.png b/website/public/community-guidelines-images/publicGuilds.png similarity index 100% rename from website/client/community-guidelines-images/publicGuilds.png rename to website/public/community-guidelines-images/publicGuilds.png diff --git a/website/client/community-guidelines-images/publicSpaces.png b/website/public/community-guidelines-images/publicSpaces.png similarity index 100% rename from website/client/community-guidelines-images/publicSpaces.png rename to website/public/community-guidelines-images/publicSpaces.png diff --git a/website/client/community-guidelines-images/restoration.png b/website/public/community-guidelines-images/restoration.png similarity index 100% rename from website/client/community-guidelines-images/restoration.png rename to website/public/community-guidelines-images/restoration.png diff --git a/website/client/community-guidelines-images/staff.png b/website/public/community-guidelines-images/staff.png similarity index 100% rename from website/client/community-guidelines-images/staff.png rename to website/public/community-guidelines-images/staff.png diff --git a/website/client/community-guidelines-images/tavern.png b/website/public/community-guidelines-images/tavern.png similarity index 100% rename from website/client/community-guidelines-images/tavern.png rename to website/public/community-guidelines-images/tavern.png diff --git a/website/client/community-guidelines-images/trello.png b/website/public/community-guidelines-images/trello.png similarity index 100% rename from website/client/community-guidelines-images/trello.png rename to website/public/community-guidelines-images/trello.png diff --git a/website/client/community-guidelines-images/wiki.png b/website/public/community-guidelines-images/wiki.png similarity index 100% rename from website/client/community-guidelines-images/wiki.png rename to website/public/community-guidelines-images/wiki.png diff --git a/website/client/css/README.md b/website/public/css/README.md similarity index 100% rename from website/client/css/README.md rename to website/public/css/README.md diff --git a/website/client/css/alerts.styl b/website/public/css/alerts.styl similarity index 100% rename from website/client/css/alerts.styl rename to website/public/css/alerts.styl diff --git a/website/client/css/avatar.styl b/website/public/css/avatar.styl similarity index 100% rename from website/client/css/avatar.styl rename to website/public/css/avatar.styl diff --git a/website/client/css/challenges.styl b/website/public/css/challenges.styl similarity index 100% rename from website/client/css/challenges.styl rename to website/public/css/challenges.styl diff --git a/website/client/css/classes.styl b/website/public/css/classes.styl similarity index 100% rename from website/client/css/classes.styl rename to website/public/css/classes.styl diff --git a/website/client/css/customizer.styl b/website/public/css/customizer.styl similarity index 100% rename from website/client/css/customizer.styl rename to website/public/css/customizer.styl diff --git a/website/client/css/filters.styl b/website/public/css/filters.styl similarity index 100% rename from website/client/css/filters.styl rename to website/public/css/filters.styl diff --git a/website/client/css/footer.styl b/website/public/css/footer.styl similarity index 100% rename from website/client/css/footer.styl rename to website/public/css/footer.styl diff --git a/website/client/css/game-pane.styl b/website/public/css/game-pane.styl similarity index 100% rename from website/client/css/game-pane.styl rename to website/public/css/game-pane.styl diff --git a/website/client/css/global-colors.styl b/website/public/css/global-colors.styl similarity index 100% rename from website/client/css/global-colors.styl rename to website/public/css/global-colors.styl diff --git a/website/client/css/global-modules.styl b/website/public/css/global-modules.styl similarity index 100% rename from website/client/css/global-modules.styl rename to website/public/css/global-modules.styl diff --git a/website/client/css/header.styl b/website/public/css/header.styl similarity index 100% rename from website/client/css/header.styl rename to website/public/css/header.styl diff --git a/website/client/css/helpers.styl b/website/public/css/helpers.styl similarity index 100% rename from website/client/css/helpers.styl rename to website/public/css/helpers.styl diff --git a/website/client/css/index.styl b/website/public/css/index.styl similarity index 100% rename from website/client/css/index.styl rename to website/public/css/index.styl diff --git a/website/client/css/inventory.styl b/website/public/css/inventory.styl similarity index 100% rename from website/client/css/inventory.styl rename to website/public/css/inventory.styl diff --git a/website/client/css/items.styl b/website/public/css/items.styl similarity index 100% rename from website/client/css/items.styl rename to website/public/css/items.styl diff --git a/website/client/css/menu.styl b/website/public/css/menu.styl similarity index 100% rename from website/client/css/menu.styl rename to website/public/css/menu.styl diff --git a/website/client/css/no-script.styl b/website/public/css/no-script.styl similarity index 100% rename from website/client/css/no-script.styl rename to website/public/css/no-script.styl diff --git a/website/client/css/npcs.styl b/website/public/css/npcs.styl similarity index 100% rename from website/client/css/npcs.styl rename to website/public/css/npcs.styl diff --git a/website/client/css/options.styl b/website/public/css/options.styl similarity index 100% rename from website/client/css/options.styl rename to website/public/css/options.styl diff --git a/website/client/css/quests.styl b/website/public/css/quests.styl similarity index 100% rename from website/client/css/quests.styl rename to website/public/css/quests.styl diff --git a/website/client/css/scrollbars.styl b/website/public/css/scrollbars.styl similarity index 100% rename from website/client/css/scrollbars.styl rename to website/public/css/scrollbars.styl diff --git a/website/client/css/shared.styl b/website/public/css/shared.styl similarity index 100% rename from website/client/css/shared.styl rename to website/public/css/shared.styl diff --git a/website/client/css/static.styl b/website/public/css/static.styl similarity index 100% rename from website/client/css/static.styl rename to website/public/css/static.styl diff --git a/website/client/css/tasks.styl b/website/public/css/tasks.styl similarity index 100% rename from website/client/css/tasks.styl rename to website/public/css/tasks.styl diff --git a/website/client/css/variables/screen-size.styl b/website/public/css/variables/screen-size.styl similarity index 100% rename from website/client/css/variables/screen-size.styl rename to website/public/css/variables/screen-size.styl diff --git a/website/client/emails/images/10-days-recapture-v1.png b/website/public/emails/images/10-days-recapture-v1.png similarity index 100% rename from website/client/emails/images/10-days-recapture-v1.png rename to website/public/emails/images/10-days-recapture-v1.png diff --git a/website/client/emails/images/3-days-1-month-recapture-v1.png b/website/public/emails/images/3-days-1-month-recapture-v1.png similarity index 100% rename from website/client/emails/images/3-days-1-month-recapture-v1.png rename to website/public/emails/images/3-days-1-month-recapture-v1.png diff --git a/website/client/emails/images/PROMO-Enchanted-Armoire-v1.png b/website/public/emails/images/PROMO-Enchanted-Armoire-v1.png similarity index 100% rename from website/client/emails/images/PROMO-Enchanted-Armoire-v1.png rename to website/public/emails/images/PROMO-Enchanted-Armoire-v1.png diff --git a/website/client/emails/images/android-promo-v1.png b/website/public/emails/images/android-promo-v1.png similarity index 100% rename from website/client/emails/images/android-promo-v1.png rename to website/public/emails/images/android-promo-v1.png diff --git a/website/client/emails/images/iphone-promo-v1.png b/website/public/emails/images/iphone-promo-v1.png similarity index 100% rename from website/client/emails/images/iphone-promo-v1.png rename to website/public/emails/images/iphone-promo-v1.png diff --git a/website/client/emails/images/one-day-v1.png b/website/public/emails/images/one-day-v1.png similarity index 100% rename from website/client/emails/images/one-day-v1.png rename to website/public/emails/images/one-day-v1.png diff --git a/website/client/emails/images/spring-2015-00-v1.png b/website/public/emails/images/spring-2015-00-v1.png similarity index 100% rename from website/client/emails/images/spring-2015-00-v1.png rename to website/public/emails/images/spring-2015-00-v1.png diff --git a/website/client/emails/images/spring-2015-01-v1.png b/website/public/emails/images/spring-2015-01-v1.png similarity index 100% rename from website/client/emails/images/spring-2015-01-v1.png rename to website/public/emails/images/spring-2015-01-v1.png diff --git a/website/client/emails/images/subscription-begins-time-travelers-v1.png b/website/public/emails/images/subscription-begins-time-travelers-v1.png similarity index 100% rename from website/client/emails/images/subscription-begins-time-travelers-v1.png rename to website/public/emails/images/subscription-begins-time-travelers-v1.png diff --git a/website/client/emails/images/subscription-begins-v1.png b/website/public/emails/images/subscription-begins-v1.png similarity index 100% rename from website/client/emails/images/subscription-begins-v1.png rename to website/public/emails/images/subscription-begins-v1.png diff --git a/website/client/favicon.ico b/website/public/favicon.ico similarity index 100% rename from website/client/favicon.ico rename to website/public/favicon.ico diff --git a/website/client/favicon_192x192.png b/website/public/favicon_192x192.png similarity index 100% rename from website/client/favicon_192x192.png rename to website/public/favicon_192x192.png diff --git a/website/client/fontello/LICENSE.txt b/website/public/fontello/LICENSE.txt similarity index 100% rename from website/client/fontello/LICENSE.txt rename to website/public/fontello/LICENSE.txt diff --git a/website/client/fontello/README.txt b/website/public/fontello/README.txt similarity index 100% rename from website/client/fontello/README.txt rename to website/public/fontello/README.txt diff --git a/website/client/fontello/css/animation.css b/website/public/fontello/css/animation.css similarity index 100% rename from website/client/fontello/css/animation.css rename to website/public/fontello/css/animation.css diff --git a/website/client/fontello/css/fontelico-codes.css b/website/public/fontello/css/fontelico-codes.css similarity index 100% rename from website/client/fontello/css/fontelico-codes.css rename to website/public/fontello/css/fontelico-codes.css diff --git a/website/client/fontello/css/fontelico-embedded.css b/website/public/fontello/css/fontelico-embedded.css similarity index 100% rename from website/client/fontello/css/fontelico-embedded.css rename to website/public/fontello/css/fontelico-embedded.css diff --git a/website/client/fontello/css/fontelico-ie7-codes.css b/website/public/fontello/css/fontelico-ie7-codes.css similarity index 100% rename from website/client/fontello/css/fontelico-ie7-codes.css rename to website/public/fontello/css/fontelico-ie7-codes.css diff --git a/website/client/fontello/css/fontelico-ie7.css b/website/public/fontello/css/fontelico-ie7.css similarity index 100% rename from website/client/fontello/css/fontelico-ie7.css rename to website/public/fontello/css/fontelico-ie7.css diff --git a/website/client/fontello/css/fontelico.css b/website/public/fontello/css/fontelico.css similarity index 100% rename from website/client/fontello/css/fontelico.css rename to website/public/fontello/css/fontelico.css diff --git a/website/client/fontello/demo.html b/website/public/fontello/demo.html similarity index 100% rename from website/client/fontello/demo.html rename to website/public/fontello/demo.html diff --git a/website/client/fontello/font/fontelico.eot b/website/public/fontello/font/fontelico.eot similarity index 100% rename from website/client/fontello/font/fontelico.eot rename to website/public/fontello/font/fontelico.eot diff --git a/website/client/fontello/font/fontelico.svg b/website/public/fontello/font/fontelico.svg similarity index 100% rename from website/client/fontello/font/fontelico.svg rename to website/public/fontello/font/fontelico.svg diff --git a/website/client/fontello/font/fontelico.ttf b/website/public/fontello/font/fontelico.ttf similarity index 100% rename from website/client/fontello/font/fontelico.ttf rename to website/public/fontello/font/fontelico.ttf diff --git a/website/client/fontello/font/fontelico.woff b/website/public/fontello/font/fontelico.woff similarity index 100% rename from website/client/fontello/font/fontelico.woff rename to website/public/fontello/font/fontelico.woff diff --git a/website/client/front/README.md b/website/public/front/README.md similarity index 100% rename from website/client/front/README.md rename to website/public/front/README.md diff --git a/website/client/front/css/blockScroll.css b/website/public/front/css/blockScroll.css similarity index 100% rename from website/client/front/css/blockScroll.css rename to website/public/front/css/blockScroll.css diff --git a/website/client/front/css/bootstrap.min.css b/website/public/front/css/bootstrap.min.css similarity index 100% rename from website/client/front/css/bootstrap.min.css rename to website/public/front/css/bootstrap.min.css diff --git a/website/client/front/css/fixed-positioning.css b/website/public/front/css/fixed-positioning.css similarity index 100% rename from website/client/front/css/fixed-positioning.css rename to website/public/front/css/fixed-positioning.css diff --git a/website/client/front/fonts/glyphicons-halflings-regular.eot b/website/public/front/fonts/glyphicons-halflings-regular.eot similarity index 100% rename from website/client/front/fonts/glyphicons-halflings-regular.eot rename to website/public/front/fonts/glyphicons-halflings-regular.eot diff --git a/website/client/front/fonts/glyphicons-halflings-regular.svg b/website/public/front/fonts/glyphicons-halflings-regular.svg similarity index 100% rename from website/client/front/fonts/glyphicons-halflings-regular.svg rename to website/public/front/fonts/glyphicons-halflings-regular.svg diff --git a/website/client/front/fonts/glyphicons-halflings-regular.ttf b/website/public/front/fonts/glyphicons-halflings-regular.ttf similarity index 100% rename from website/client/front/fonts/glyphicons-halflings-regular.ttf rename to website/public/front/fonts/glyphicons-halflings-regular.ttf diff --git a/website/client/front/fonts/glyphicons-halflings-regular.woff b/website/public/front/fonts/glyphicons-halflings-regular.woff similarity index 100% rename from website/client/front/fonts/glyphicons-halflings-regular.woff rename to website/public/front/fonts/glyphicons-halflings-regular.woff diff --git a/website/client/front/fonts/glyphicons-halflings-regular.woff2 b/website/public/front/fonts/glyphicons-halflings-regular.woff2 similarity index 100% rename from website/client/front/fonts/glyphicons-halflings-regular.woff2 rename to website/public/front/fonts/glyphicons-halflings-regular.woff2 diff --git a/website/client/front/images/Feeding_Time.png b/website/public/front/images/Feeding_Time.png similarity index 100% rename from website/client/front/images/Feeding_Time.png rename to website/public/front/images/Feeding_Time.png diff --git a/website/client/front/images/Guilds Sample Screen.png b/website/public/front/images/Guilds Sample Screen.png similarity index 100% rename from website/client/front/images/Guilds Sample Screen.png rename to website/public/front/images/Guilds Sample Screen.png diff --git a/website/client/front/images/HabitRPGPromoPostCard6.png b/website/public/front/images/HabitRPGPromoPostCard6.png similarity index 100% rename from website/client/front/images/HabitRPGPromoPostCard6.png rename to website/public/front/images/HabitRPGPromoPostCard6.png diff --git a/website/client/front/images/HabitRPGPromoThin.png b/website/public/front/images/HabitRPGPromoThin.png similarity index 100% rename from website/client/front/images/HabitRPGPromoThin.png rename to website/public/front/images/HabitRPGPromoThin.png diff --git a/website/client/front/images/Habitica_banner_by_uncommoncriminal.png b/website/public/front/images/Habitica_banner_by_uncommoncriminal.png similarity index 100% rename from website/client/front/images/Habitica_banner_by_uncommoncriminal.png rename to website/public/front/images/Habitica_banner_by_uncommoncriminal.png diff --git a/website/client/front/images/Habitica_map_by_uncommoncriminal.png b/website/public/front/images/Habitica_map_by_uncommoncriminal.png similarity index 100% rename from website/client/front/images/Habitica_map_by_uncommoncriminal.png rename to website/public/front/images/Habitica_map_by_uncommoncriminal.png diff --git a/website/client/front/images/Healer.png b/website/public/front/images/Healer.png similarity index 100% rename from website/client/front/images/Healer.png rename to website/public/front/images/Healer.png diff --git a/website/client/front/images/Mount.png b/website/public/front/images/Mount.png similarity index 100% rename from website/client/front/images/Mount.png rename to website/public/front/images/Mount.png diff --git a/website/client/front/images/Mount_Body_Dragon-Golden.png b/website/public/front/images/Mount_Body_Dragon-Golden.png similarity index 100% rename from website/client/front/images/Mount_Body_Dragon-Golden.png rename to website/public/front/images/Mount_Body_Dragon-Golden.png diff --git a/website/client/front/images/Mount_Body_Dragon-Red.png b/website/public/front/images/Mount_Body_Dragon-Red.png similarity index 100% rename from website/client/front/images/Mount_Body_Dragon-Red.png rename to website/public/front/images/Mount_Body_Dragon-Red.png diff --git a/website/client/front/images/Mount_Body_Wolf-Base.png b/website/public/front/images/Mount_Body_Wolf-Base.png similarity index 100% rename from website/client/front/images/Mount_Body_Wolf-Base.png rename to website/public/front/images/Mount_Body_Wolf-Base.png diff --git a/website/client/front/images/Mount_Head_Dragon-Golden.png b/website/public/front/images/Mount_Head_Dragon-Golden.png similarity index 100% rename from website/client/front/images/Mount_Head_Dragon-Golden.png rename to website/public/front/images/Mount_Head_Dragon-Golden.png diff --git a/website/client/front/images/Mount_Head_Dragon-Red.png b/website/public/front/images/Mount_Head_Dragon-Red.png similarity index 100% rename from website/client/front/images/Mount_Head_Dragon-Red.png rename to website/public/front/images/Mount_Head_Dragon-Red.png diff --git a/website/client/front/images/Mount_Head_Wolf-Base.png b/website/public/front/images/Mount_Head_Wolf-Base.png similarity index 100% rename from website/client/front/images/Mount_Head_Wolf-Base.png rename to website/public/front/images/Mount_Head_Wolf-Base.png diff --git a/website/client/front/images/Party-Header.png b/website/public/front/images/Party-Header.png similarity index 100% rename from website/client/front/images/Party-Header.png rename to website/public/front/images/Party-Header.png diff --git a/website/client/front/images/Pet-Dragon-Red.png b/website/public/front/images/Pet-Dragon-Red.png similarity index 100% rename from website/client/front/images/Pet-Dragon-Red.png rename to website/public/front/images/Pet-Dragon-Red.png diff --git a/website/client/front/images/Pet-Fox-Red.png b/website/public/front/images/Pet-Fox-Red.png similarity index 100% rename from website/client/front/images/Pet-Fox-Red.png rename to website/public/front/images/Pet-Fox-Red.png diff --git a/website/client/front/images/Promo_springclasses2015.png b/website/public/front/images/Promo_springclasses2015.png similarity index 100% rename from website/client/front/images/Promo_springclasses2015.png rename to website/public/front/images/Promo_springclasses2015.png diff --git a/website/client/front/images/Quest_dilatory_drag'on.png b/website/public/front/images/Quest_dilatory_drag'on.png similarity index 100% rename from website/client/front/images/Quest_dilatory_drag'on.png rename to website/public/front/images/Quest_dilatory_drag'on.png diff --git a/website/client/front/images/Quest_dilatory_drag'onSmall.png b/website/public/front/images/Quest_dilatory_drag'onSmall.png similarity index 100% rename from website/client/front/images/Quest_dilatory_drag'onSmall.png rename to website/public/front/images/Quest_dilatory_drag'onSmall.png diff --git a/website/client/front/images/Rogue.png b/website/public/front/images/Rogue.png similarity index 100% rename from website/client/front/images/Rogue.png rename to website/public/front/images/Rogue.png diff --git a/website/client/front/images/SAMPLEadventurers.png b/website/public/front/images/SAMPLEadventurers.png similarity index 100% rename from website/client/front/images/SAMPLEadventurers.png rename to website/public/front/images/SAMPLEadventurers.png diff --git a/website/client/front/images/TVreward.png b/website/public/front/images/TVreward.png similarity index 100% rename from website/client/front/images/TVreward.png rename to website/public/front/images/TVreward.png diff --git a/website/client/front/images/VICE_by_Baconsaur.png b/website/public/front/images/VICE_by_Baconsaur.png similarity index 100% rename from website/client/front/images/VICE_by_Baconsaur.png rename to website/public/front/images/VICE_by_Baconsaur.png diff --git a/website/client/front/images/Warrior.png b/website/public/front/images/Warrior.png similarity index 100% rename from website/client/front/images/Warrior.png rename to website/public/front/images/Warrior.png diff --git a/website/client/front/images/Wizard.png b/website/public/front/images/Wizard.png similarity index 100% rename from website/client/front/images/Wizard.png rename to website/public/front/images/Wizard.png diff --git a/website/client/front/images/achievement-perfect.png b/website/public/front/images/achievement-perfect.png similarity index 100% rename from website/client/front/images/achievement-perfect.png rename to website/public/front/images/achievement-perfect.png diff --git a/website/client/front/images/achievement-triadbingo.png b/website/public/front/images/achievement-triadbingo.png similarity index 100% rename from website/client/front/images/achievement-triadbingo.png rename to website/public/front/images/achievement-triadbingo.png diff --git a/website/client/front/images/avatar/Warrior.png b/website/public/front/images/avatar/Warrior.png similarity index 100% rename from website/client/front/images/avatar/Warrior.png rename to website/public/front/images/avatar/Warrior.png diff --git a/website/client/front/images/avatar/avatar.png b/website/public/front/images/avatar/avatar.png similarity index 100% rename from website/client/front/images/avatar/avatar.png rename to website/public/front/images/avatar/avatar.png diff --git a/website/client/front/images/avatar/avatarstatic.png b/website/public/front/images/avatar/avatarstatic.png similarity index 100% rename from website/client/front/images/avatar/avatarstatic.png rename to website/public/front/images/avatar/avatarstatic.png diff --git a/website/client/front/images/avatar/hair_bangs_1_brown.png b/website/public/front/images/avatar/hair_bangs_1_brown.png similarity index 100% rename from website/client/front/images/avatar/hair_bangs_1_brown.png rename to website/public/front/images/avatar/hair_bangs_1_brown.png diff --git a/website/client/front/images/avatar/head_0.png b/website/public/front/images/avatar/head_0.png similarity index 100% rename from website/client/front/images/avatar/head_0.png rename to website/public/front/images/avatar/head_0.png diff --git a/website/client/front/images/avatar/head_warrior_3.png b/website/public/front/images/avatar/head_warrior_3.png similarity index 100% rename from website/client/front/images/avatar/head_warrior_3.png rename to website/public/front/images/avatar/head_warrior_3.png diff --git a/website/client/front/images/avatar/head_warrior_5.png b/website/public/front/images/avatar/head_warrior_5.png similarity index 100% rename from website/client/front/images/avatar/head_warrior_5.png rename to website/public/front/images/avatar/head_warrior_5.png diff --git a/website/client/front/images/avatar/shield_warrior_3.png b/website/public/front/images/avatar/shield_warrior_3.png similarity index 100% rename from website/client/front/images/avatar/shield_warrior_3.png rename to website/public/front/images/avatar/shield_warrior_3.png diff --git a/website/client/front/images/avatar/shield_warrior_5.png b/website/public/front/images/avatar/shield_warrior_5.png similarity index 100% rename from website/client/front/images/avatar/shield_warrior_5.png rename to website/public/front/images/avatar/shield_warrior_5.png diff --git a/website/client/front/images/avatar/skin_f5a76e.png b/website/public/front/images/avatar/skin_f5a76e.png similarity index 100% rename from website/client/front/images/avatar/skin_f5a76e.png rename to website/public/front/images/avatar/skin_f5a76e.png diff --git a/website/client/front/images/avatar/slim_armor_warrior_3.png b/website/public/front/images/avatar/slim_armor_warrior_3.png similarity index 100% rename from website/client/front/images/avatar/slim_armor_warrior_3.png rename to website/public/front/images/avatar/slim_armor_warrior_3.png diff --git a/website/client/front/images/avatar/slim_armor_warrior_5.png b/website/public/front/images/avatar/slim_armor_warrior_5.png similarity index 100% rename from website/client/front/images/avatar/slim_armor_warrior_5.png rename to website/public/front/images/avatar/slim_armor_warrior_5.png diff --git a/website/client/front/images/avatar/slim_shirt_black.png b/website/public/front/images/avatar/slim_shirt_black.png similarity index 100% rename from website/client/front/images/avatar/slim_shirt_black.png rename to website/public/front/images/avatar/slim_shirt_black.png diff --git a/website/client/front/images/avatar/weapon_healer_6.png b/website/public/front/images/avatar/weapon_healer_6.png similarity index 100% rename from website/client/front/images/avatar/weapon_healer_6.png rename to website/public/front/images/avatar/weapon_healer_6.png diff --git a/website/client/front/images/avatar/weapon_warrior_3.png b/website/public/front/images/avatar/weapon_warrior_3.png similarity index 100% rename from website/client/front/images/avatar/weapon_warrior_3.png rename to website/public/front/images/avatar/weapon_warrior_3.png diff --git a/website/client/front/images/avatar/weapon_warrior_5.png b/website/public/front/images/avatar/weapon_warrior_5.png similarity index 100% rename from website/client/front/images/avatar/weapon_warrior_5.png rename to website/public/front/images/avatar/weapon_warrior_5.png diff --git a/website/client/front/images/blackish_fox_by_kellllly-d7pzd46.png b/website/public/front/images/blackish_fox_by_kellllly-d7pzd46.png similarity index 100% rename from website/client/front/images/blackish_fox_by_kellllly-d7pzd46.png rename to website/public/front/images/blackish_fox_by_kellllly-d7pzd46.png diff --git a/website/client/front/images/coding_by_phoneix_faerie.png b/website/public/front/images/coding_by_phoneix_faerie.png similarity index 100% rename from website/client/front/images/coding_by_phoneix_faerie.png rename to website/public/front/images/coding_by_phoneix_faerie.png diff --git a/website/client/front/images/devices.png b/website/public/front/images/devices.png similarity index 100% rename from website/client/front/images/devices.png rename to website/public/front/images/devices.png diff --git a/website/client/front/images/explosion.jpg b/website/public/front/images/explosion.jpg similarity index 100% rename from website/client/front/images/explosion.jpg rename to website/public/front/images/explosion.jpg diff --git a/website/client/front/images/explosion.png b/website/public/front/images/explosion.png similarity index 100% rename from website/client/front/images/explosion.png rename to website/public/front/images/explosion.png diff --git a/website/client/front/images/habitrpg_pixel.png b/website/public/front/images/habitrpg_pixel.png similarity index 100% rename from website/client/front/images/habitrpg_pixel.png rename to website/public/front/images/habitrpg_pixel.png diff --git a/website/client/front/images/icon175x175.png b/website/public/front/images/icon175x175.png similarity index 100% rename from website/client/front/images/icon175x175.png rename to website/public/front/images/icon175x175.png diff --git a/website/client/front/images/intro.jpg b/website/public/front/images/intro.jpg similarity index 100% rename from website/client/front/images/intro.jpg rename to website/public/front/images/intro.jpg diff --git a/website/client/front/images/intro.psd b/website/public/front/images/intro.psd similarity index 100% rename from website/client/front/images/intro.psd rename to website/public/front/images/intro.psd diff --git a/website/client/front/images/misc/Pet_Food_Cake_Base.png b/website/public/front/images/misc/Pet_Food_Cake_Base.png similarity index 100% rename from website/client/front/images/misc/Pet_Food_Cake_Base.png rename to website/public/front/images/misc/Pet_Food_Cake_Base.png diff --git a/website/client/front/images/misc/inventory_quest_scroll_harpy.png b/website/public/front/images/misc/inventory_quest_scroll_harpy.png similarity index 100% rename from website/client/front/images/misc/inventory_quest_scroll_harpy.png rename to website/public/front/images/misc/inventory_quest_scroll_harpy.png diff --git a/website/client/front/images/misc/rebirth_orb.png b/website/public/front/images/misc/rebirth_orb.png similarity index 100% rename from website/client/front/images/misc/rebirth_orb.png rename to website/public/front/images/misc/rebirth_orb.png diff --git a/website/client/front/images/misc/shop_gold.png b/website/public/front/images/misc/shop_gold.png similarity index 100% rename from website/client/front/images/misc/shop_gold.png rename to website/public/front/images/misc/shop_gold.png diff --git a/website/client/front/images/misc/shop_potion.png b/website/public/front/images/misc/shop_potion.png similarity index 100% rename from website/client/front/images/misc/shop_potion.png rename to website/public/front/images/misc/shop_potion.png diff --git a/website/client/front/images/mockup_for_habit_by_cosmic_caterpillar-d8mf5mb.png b/website/public/front/images/mockup_for_habit_by_cosmic_caterpillar-d8mf5mb.png similarity index 100% rename from website/client/front/images/mockup_for_habit_by_cosmic_caterpillar-d8mf5mb.png rename to website/public/front/images/mockup_for_habit_by_cosmic_caterpillar-d8mf5mb.png diff --git a/website/client/front/images/party/AnnaCosplay.png b/website/public/front/images/party/AnnaCosplay.png similarity index 100% rename from website/client/front/images/party/AnnaCosplay.png rename to website/public/front/images/party/AnnaCosplay.png diff --git a/website/client/front/images/party/Ariel_cosplay.png b/website/public/front/images/party/Ariel_cosplay.png similarity index 100% rename from website/client/front/images/party/Ariel_cosplay.png rename to website/public/front/images/party/Ariel_cosplay.png diff --git a/website/client/front/images/party/Big_Daddy_(BioShock).png b/website/public/front/images/party/Big_Daddy_(BioShock).png similarity index 100% rename from website/client/front/images/party/Big_Daddy_(BioShock).png rename to website/public/front/images/party/Big_Daddy_(BioShock).png diff --git a/website/client/front/images/party/Cosplay_Daenerys_Targaryen.png b/website/public/front/images/party/Cosplay_Daenerys_Targaryen.png similarity index 100% rename from website/client/front/images/party/Cosplay_Daenerys_Targaryen.png rename to website/public/front/images/party/Cosplay_Daenerys_Targaryen.png diff --git a/website/client/front/images/party/GrimReaper.png b/website/public/front/images/party/GrimReaper.png similarity index 100% rename from website/client/front/images/party/GrimReaper.png rename to website/public/front/images/party/GrimReaper.png diff --git a/website/client/front/images/party/HomeStuckLusus.png b/website/public/front/images/party/HomeStuckLusus.png similarity index 100% rename from website/client/front/images/party/HomeStuckLusus.png rename to website/public/front/images/party/HomeStuckLusus.png diff --git a/website/client/front/images/presslogos/Cnetlogo.png b/website/public/front/images/presslogos/Cnetlogo.png similarity index 100% rename from website/client/front/images/presslogos/Cnetlogo.png rename to website/public/front/images/presslogos/Cnetlogo.png diff --git a/website/client/front/images/presslogos/Fast-Company-logo.png b/website/public/front/images/presslogos/Fast-Company-logo.png similarity index 100% rename from website/client/front/images/presslogos/Fast-Company-logo.png rename to website/public/front/images/presslogos/Fast-Company-logo.png diff --git a/website/client/front/images/presslogos/Forbes_logo.png b/website/public/front/images/presslogos/Forbes_logo.png similarity index 100% rename from website/client/front/images/presslogos/Forbes_logo.png rename to website/public/front/images/presslogos/Forbes_logo.png diff --git a/website/client/front/images/presslogos/GitHub_Logo.png b/website/public/front/images/presslogos/GitHub_Logo.png similarity index 100% rename from website/client/front/images/presslogos/GitHub_Logo.png rename to website/public/front/images/presslogos/GitHub_Logo.png diff --git a/website/client/front/images/presslogos/discover_logo.png b/website/public/front/images/presslogos/discover_logo.png similarity index 100% rename from website/client/front/images/presslogos/discover_logo.png rename to website/public/front/images/presslogos/discover_logo.png diff --git a/website/client/front/images/presslogos/ionic-logo-blog.png b/website/public/front/images/presslogos/ionic-logo-blog.png similarity index 100% rename from website/client/front/images/presslogos/ionic-logo-blog.png rename to website/public/front/images/presslogos/ionic-logo-blog.png diff --git a/website/client/front/images/presslogos/ionic-logo-horizontal-transparent.png b/website/public/front/images/presslogos/ionic-logo-horizontal-transparent.png similarity index 100% rename from website/client/front/images/presslogos/ionic-logo-horizontal-transparent.png rename to website/public/front/images/presslogos/ionic-logo-horizontal-transparent.png diff --git a/website/client/front/images/presslogos/kickstarter-logo.png b/website/public/front/images/presslogos/kickstarter-logo.png similarity index 100% rename from website/client/front/images/presslogos/kickstarter-logo.png rename to website/public/front/images/presslogos/kickstarter-logo.png diff --git a/website/client/front/images/presslogos/landing_slack_hash_wordmark_logo.png b/website/public/front/images/presslogos/landing_slack_hash_wordmark_logo.png similarity index 100% rename from website/client/front/images/presslogos/landing_slack_hash_wordmark_logo.png rename to website/public/front/images/presslogos/landing_slack_hash_wordmark_logo.png diff --git a/website/client/front/images/presslogos/lifehacker.png b/website/public/front/images/presslogos/lifehacker.png similarity index 100% rename from website/client/front/images/presslogos/lifehacker.png rename to website/public/front/images/presslogos/lifehacker.png diff --git a/website/client/front/images/presslogos/logo_webstorm.png b/website/public/front/images/presslogos/logo_webstorm.png similarity index 100% rename from website/client/front/images/presslogos/logo_webstorm.png rename to website/public/front/images/presslogos/logo_webstorm.png diff --git a/website/client/front/images/presslogos/makeuseof.png b/website/public/front/images/presslogos/makeuseof.png similarity index 100% rename from website/client/front/images/presslogos/makeuseof.png rename to website/public/front/images/presslogos/makeuseof.png diff --git a/website/client/front/images/presslogos/nyt-logo.png b/website/public/front/images/presslogos/nyt-logo.png similarity index 100% rename from website/client/front/images/presslogos/nyt-logo.png rename to website/public/front/images/presslogos/nyt-logo.png diff --git a/website/client/front/images/presslogos/slack.png b/website/public/front/images/presslogos/slack.png similarity index 100% rename from website/client/front/images/presslogos/slack.png rename to website/public/front/images/presslogos/slack.png diff --git a/website/client/front/images/presslogos/trello-logo-blue.png b/website/public/front/images/presslogos/trello-logo-blue.png similarity index 100% rename from website/client/front/images/presslogos/trello-logo-blue.png rename to website/public/front/images/presslogos/trello-logo-blue.png diff --git a/website/client/front/images/quest_vice3.png b/website/public/front/images/quest_vice3.png similarity index 100% rename from website/client/front/images/quest_vice3.png rename to website/public/front/images/quest_vice3.png diff --git a/website/client/front/images/screenshot.png b/website/public/front/images/screenshot.png similarity index 100% rename from website/client/front/images/screenshot.png rename to website/public/front/images/screenshot.png diff --git a/website/client/front/images/t_bone_fight_2_by_mortquitue-d8dtxbl.png b/website/public/front/images/t_bone_fight_2_by_mortquitue-d8dtxbl.png similarity index 100% rename from website/client/front/images/t_bone_fight_2_by_mortquitue-d8dtxbl.png rename to website/public/front/images/t_bone_fight_2_by_mortquitue-d8dtxbl.png diff --git a/website/client/front/images/testimonial_by_Streak.png b/website/public/front/images/testimonial_by_Streak.png similarity index 100% rename from website/client/front/images/testimonial_by_Streak.png rename to website/public/front/images/testimonial_by_Streak.png diff --git a/website/client/front/images/testimonials/16bitFil.png b/website/public/front/images/testimonials/16bitFil.png similarity index 100% rename from website/client/front/images/testimonials/16bitFil.png rename to website/public/front/images/testimonials/16bitFil.png diff --git a/website/client/front/images/testimonials/AlexandraSo.png b/website/public/front/images/testimonials/AlexandraSo.png similarity index 100% rename from website/client/front/images/testimonials/AlexandraSo.png rename to website/public/front/images/testimonials/AlexandraSo.png diff --git a/website/client/front/images/testimonials/Althaire.png b/website/public/front/images/testimonials/Althaire.png similarity index 100% rename from website/client/front/images/testimonials/Althaire.png rename to website/public/front/images/testimonials/Althaire.png diff --git a/website/client/front/images/testimonials/AndeeLiao.png b/website/public/front/images/testimonials/AndeeLiao.png similarity index 100% rename from website/client/front/images/testimonials/AndeeLiao.png rename to website/public/front/images/testimonials/AndeeLiao.png diff --git a/website/client/front/images/testimonials/Brenna.png b/website/public/front/images/testimonials/Brenna.png similarity index 100% rename from website/client/front/images/testimonials/Brenna.png rename to website/public/front/images/testimonials/Brenna.png diff --git a/website/client/front/images/testimonials/Drag0nsilver.png b/website/public/front/images/testimonials/Drag0nsilver.png similarity index 100% rename from website/client/front/images/testimonials/Drag0nsilver.png rename to website/public/front/images/testimonials/Drag0nsilver.png diff --git a/website/client/front/images/testimonials/Drei-M.png b/website/public/front/images/testimonials/Drei-M.png similarity index 100% rename from website/client/front/images/testimonials/Drei-M.png rename to website/public/front/images/testimonials/Drei-M.png diff --git a/website/client/front/images/testimonials/Elmi.png b/website/public/front/images/testimonials/Elmi.png similarity index 100% rename from website/client/front/images/testimonials/Elmi.png rename to website/public/front/images/testimonials/Elmi.png diff --git a/website/client/front/images/testimonials/EvaGantz.png b/website/public/front/images/testimonials/EvaGantz.png similarity index 100% rename from website/client/front/images/testimonials/EvaGantz.png rename to website/public/front/images/testimonials/EvaGantz.png diff --git a/website/client/front/images/testimonials/Helcura.png b/website/public/front/images/testimonials/Helcura.png similarity index 100% rename from website/client/front/images/testimonials/Helcura.png rename to website/public/front/images/testimonials/Helcura.png diff --git a/website/client/front/images/testimonials/InfH.png b/website/public/front/images/testimonials/InfH.png similarity index 100% rename from website/client/front/images/testimonials/InfH.png rename to website/public/front/images/testimonials/InfH.png diff --git a/website/client/front/images/testimonials/Kai.png b/website/public/front/images/testimonials/Kai.png similarity index 100% rename from website/client/front/images/testimonials/Kai.png rename to website/public/front/images/testimonials/Kai.png diff --git a/website/client/front/images/testimonials/Kazui.png b/website/public/front/images/testimonials/Kazui.png similarity index 100% rename from website/client/front/images/testimonials/Kazui.png rename to website/public/front/images/testimonials/Kazui.png diff --git a/website/client/front/images/testimonials/Zelah_Meyer.png b/website/public/front/images/testimonials/Zelah_Meyer.png similarity index 100% rename from website/client/front/images/testimonials/Zelah_Meyer.png rename to website/public/front/images/testimonials/Zelah_Meyer.png diff --git a/website/client/front/images/testimonials/autumnesquirrel.png b/website/public/front/images/testimonials/autumnesquirrel.png similarity index 100% rename from website/client/front/images/testimonials/autumnesquirrel.png rename to website/public/front/images/testimonials/autumnesquirrel.png diff --git a/website/client/front/images/testimonials/frabjabulous.png b/website/public/front/images/testimonials/frabjabulous.png similarity index 100% rename from website/client/front/images/testimonials/frabjabulous.png rename to website/public/front/images/testimonials/frabjabulous.png diff --git a/website/client/front/images/testimonials/galarix.png b/website/public/front/images/testimonials/galarix.png similarity index 100% rename from website/client/front/images/testimonials/galarix.png rename to website/public/front/images/testimonials/galarix.png diff --git a/website/client/front/images/testimonials/gwyn.blath.png b/website/public/front/images/testimonials/gwyn.blath.png similarity index 100% rename from website/client/front/images/testimonials/gwyn.blath.png rename to website/public/front/images/testimonials/gwyn.blath.png diff --git a/website/client/front/images/testimonials/irishfeet123.png b/website/public/front/images/testimonials/irishfeet123.png similarity index 100% rename from website/client/front/images/testimonials/irishfeet123.png rename to website/public/front/images/testimonials/irishfeet123.png diff --git a/website/client/front/images/testimonials/skysailor.png b/website/public/front/images/testimonials/skysailor.png similarity index 100% rename from website/client/front/images/testimonials/skysailor.png rename to website/public/front/images/testimonials/skysailor.png diff --git a/website/client/front/images/testimonials/supermouse35.png b/website/public/front/images/testimonials/supermouse35.png similarity index 100% rename from website/client/front/images/testimonials/supermouse35.png rename to website/public/front/images/testimonials/supermouse35.png diff --git a/website/client/front/images/testimonials/tonitonirocca.png b/website/public/front/images/testimonials/tonitonirocca.png similarity index 100% rename from website/client/front/images/testimonials/tonitonirocca.png rename to website/public/front/images/testimonials/tonitonirocca.png diff --git a/website/client/front/images/uses/achievement-bkgd.png b/website/public/front/images/uses/achievement-bkgd.png similarity index 100% rename from website/client/front/images/uses/achievement-bkgd.png rename to website/public/front/images/uses/achievement-bkgd.png diff --git a/website/client/front/images/uses/clipart-rosemonkeyct-meditation.png b/website/public/front/images/uses/clipart-rosemonkeyct-meditation.png similarity index 100% rename from website/client/front/images/uses/clipart-rosemonkeyct-meditation.png rename to website/public/front/images/uses/clipart-rosemonkeyct-meditation.png diff --git a/website/client/front/images/uses/clipart-rosemonkeyct-meditation.psd b/website/public/front/images/uses/clipart-rosemonkeyct-meditation.psd similarity index 100% rename from website/client/front/images/uses/clipart-rosemonkeyct-meditation.psd rename to website/public/front/images/uses/clipart-rosemonkeyct-meditation.psd diff --git a/website/client/front/images/uses/clipart-rosemonkeyct-reading.png b/website/public/front/images/uses/clipart-rosemonkeyct-reading.png similarity index 100% rename from website/client/front/images/uses/clipart-rosemonkeyct-reading.png rename to website/public/front/images/uses/clipart-rosemonkeyct-reading.png diff --git a/website/client/front/images/uses/coding.png b/website/public/front/images/uses/coding.png similarity index 100% rename from website/client/front/images/uses/coding.png rename to website/public/front/images/uses/coding.png diff --git a/website/client/front/images/uses/coding_3_by_phoneix_faerie-d7idtti.png b/website/public/front/images/uses/coding_3_by_phoneix_faerie-d7idtti.png similarity index 100% rename from website/client/front/images/uses/coding_3_by_phoneix_faerie-d7idtti.png rename to website/public/front/images/uses/coding_3_by_phoneix_faerie-d7idtti.png diff --git a/website/client/front/images/uses/consequences.png b/website/public/front/images/uses/consequences.png similarity index 100% rename from website/client/front/images/uses/consequences.png rename to website/public/front/images/uses/consequences.png diff --git a/website/client/front/images/uses/dusting-bkgd.png b/website/public/front/images/uses/dusting-bkgd.png similarity index 100% rename from website/client/front/images/uses/dusting-bkgd.png rename to website/public/front/images/uses/dusting-bkgd.png diff --git a/website/client/front/images/uses/dusting_by_leephon.png b/website/public/front/images/uses/dusting_by_leephon.png similarity index 100% rename from website/client/front/images/uses/dusting_by_leephon.png rename to website/public/front/images/uses/dusting_by_leephon.png diff --git a/website/client/front/images/uses/gaining_an_achievement_by_cosmic_caterpillar-d7uyv5z.png b/website/public/front/images/uses/gaining_an_achievement_by_cosmic_caterpillar-d7uyv5z.png similarity index 100% rename from website/client/front/images/uses/gaining_an_achievement_by_cosmic_caterpillar-d7uyv5z.png rename to website/public/front/images/uses/gaining_an_achievement_by_cosmic_caterpillar-d7uyv5z.png diff --git a/website/client/front/images/uses/meditation-bkgd.png b/website/public/front/images/uses/meditation-bkgd.png similarity index 100% rename from website/client/front/images/uses/meditation-bkgd.png rename to website/public/front/images/uses/meditation-bkgd.png diff --git a/website/client/front/images/uses/publicSpaces.png b/website/public/front/images/uses/publicSpaces.png similarity index 100% rename from website/client/front/images/uses/publicSpaces.png rename to website/public/front/images/uses/publicSpaces.png diff --git a/website/client/front/images/uses/reading.png b/website/public/front/images/uses/reading.png similarity index 100% rename from website/client/front/images/uses/reading.png rename to website/public/front/images/uses/reading.png diff --git a/website/client/front/js/blockScroll.js b/website/public/front/js/blockScroll.js similarity index 100% rename from website/client/front/js/blockScroll.js rename to website/public/front/js/blockScroll.js diff --git a/website/client/front/js/bootstrap.min.js b/website/public/front/js/bootstrap.min.js similarity index 100% rename from website/client/front/js/bootstrap.min.js rename to website/public/front/js/bootstrap.min.js diff --git a/website/client/front/js/skrollr.min.js b/website/public/front/js/skrollr.min.js similarity index 100% rename from website/client/front/js/skrollr.min.js rename to website/public/front/js/skrollr.min.js diff --git a/website/client/front/landingv1Wireframe.jpg b/website/public/front/landingv1Wireframe.jpg similarity index 100% rename from website/client/front/landingv1Wireframe.jpg rename to website/public/front/landingv1Wireframe.jpg diff --git a/website/client/front/staticstyle.css b/website/public/front/staticstyle.css similarity index 100% rename from website/client/front/staticstyle.css rename to website/public/front/staticstyle.css diff --git a/website/client/front/style.css b/website/public/front/style.css similarity index 100% rename from website/client/front/style.css rename to website/public/front/style.css diff --git a/website/client/google280633b772b94345.html b/website/public/google280633b772b94345.html similarity index 100% rename from website/client/google280633b772b94345.html rename to website/public/google280633b772b94345.html diff --git a/website/client/google8ca65b6ff3506fb8.html b/website/public/google8ca65b6ff3506fb8.html similarity index 100% rename from website/client/google8ca65b6ff3506fb8.html rename to website/public/google8ca65b6ff3506fb8.html diff --git a/website/client/googlef3b1402b0e28338a.html b/website/public/googlef3b1402b0e28338a.html similarity index 100% rename from website/client/googlef3b1402b0e28338a.html rename to website/public/googlef3b1402b0e28338a.html diff --git a/website/client/js/.eslintrc b/website/public/js/.eslintrc similarity index 100% rename from website/client/js/.eslintrc rename to website/public/js/.eslintrc diff --git a/website/client/js/app.js b/website/public/js/app.js similarity index 73% rename from website/client/js/app.js rename to website/public/js/app.js index 86801e6777..6b39f45d63 100644 --- a/website/client/js/app.js +++ b/website/public/js/app.js @@ -26,7 +26,6 @@ window.habitrpg = angular.module('habitrpg', .constant("STORAGE_USER_ID", 'habitrpg-user') .constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings') .constant("MOBILE_APP", false) - .constant("TAVERN_ID", window.habitrpgShared.TAVERN_ID) //.constant("STORAGE_GROUPS_ID", "") // if we decide to take groups offline .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'STORAGE_SETTINGS_ID', @@ -151,25 +150,12 @@ window.habitrpg = angular.module('habitrpg', url: '/:gid', templateUrl: 'partials/options.social.guilds.detail.html', title: env.t('titleGuilds'), - controller: ['$scope', 'Groups', 'Chat', '$stateParams', 'Members', 'Challenges', - function($scope, Groups, Chat, $stateParams, Members, Challenges){ - Groups.Group.get($stateParams.gid) - .then(function (response) { - $scope.group = response.data.data; - Chat.markChatSeen($scope.group._id); - Members.getGroupMembers($scope.group._id) - .then(function (response) { - $scope.group.members = response.data.data; - }); - Members.getGroupInvites($scope.group._id) - .then(function (response) { - $scope.group.invites = response.data.data; - }); - Challenges.getGroupChallenges($scope.group._id) - .then(function (response) { - $scope.group.challenges = response.data.data; - }); - }); + controller: ['$scope', 'Groups', 'Chat', '$stateParams', + function($scope, Groups, Chat, $stateParams){ + Groups.Group.get({gid:$stateParams.gid}, function(group){ + $scope.group = group; + Chat.seenMessage(group._id); + }); }] }) @@ -185,69 +171,33 @@ window.habitrpg = angular.module('habitrpg', url: '/:cid', templateUrl: 'partials/options.social.challenges.detail.html', title: env.t('titleChallenges'), - controller: ['$scope', 'Challenges', '$stateParams', 'Tasks', 'Members', - function ($scope, Challenges, $stateParams, Tasks, Members) { - Challenges.getChallenge($stateParams.cid) - .then(function (response) { - $scope.obj = $scope.challenge = response.data.data; - $scope.challenge._locked = true; - return Tasks.getChallengeTasks($scope.challenge._id); - }) - .then(function (response) { - var tasks = response.data.data; - tasks.forEach(function (element, index, array) { - if (!$scope.challenge[element.type + 's']) $scope.challenge[element.type + 's'] = []; - $scope.challenge[element.type + 's'].push(element); - }) - - return Members.getChallengeMembers($scope.challenge._id); - }) - .then(function (response) { - $scope.challenge.members = response.data.data; - }); + controller: ['$scope', 'Challenges', '$stateParams', + function($scope, Challenges, $stateParams){ + $scope.obj = $scope.challenge = Challenges.Challenge.get({cid:$stateParams.cid}, function(){ + $scope.challenge._locked = true; + }); }] }) .state('options.social.challenges.edit', { url: '/:cid/edit', templateUrl: 'partials/options.social.challenges.detail.html', title: env.t('titleChallenges'), - controller: ['$scope', 'Challenges', '$stateParams', 'Tasks', - function ($scope, Challenges, $stateParams, Tasks) { - Challenges.getChallenge($stateParams.cid) - .then(function (response) { - $scope.obj = $scope.challenge = response.data.data; - $scope.challenge._locked = false; - return Tasks.getChallengeTasks($scope.challenge._id); - }) - .then(function (response) { - var tasks = response.data.data; - tasks.forEach(function (element, index, array) { - if (!$scope.challenge[element.type + 's']) $scope.challenge[element.type + 's'] = []; - $scope.challenge[element.type + 's'].push(element); - }) - }); + controller: ['$scope', 'Challenges', '$stateParams', + function($scope, Challenges, $stateParams){ + $scope.obj = $scope.challenge = Challenges.Challenge.get({cid:$stateParams.cid}, function(){ + $scope.challenge._locked = false; + }); }] }) .state('options.social.challenges.detail.member', { url: '/:uid', templateUrl: 'partials/options.social.challenges.detail.member.html', title: env.t('titleChallenges'), - controller: ['$scope', 'Members', '$stateParams', - function($scope, Members, $stateParams){ - Members.getChallengeMemberProgress($stateParams.cid, $stateParams.uid) - .then(function(response) { - $scope.obj = response.data.data; - - $scope.obj.habits = []; - $scope.obj.todos = []; - $scope.obj.dailys = []; - $scope.obj.rewards = []; - $scope.obj.tasks.forEach(function (element, index, array) { - $scope.obj[element.type + 's'].push(element) - }); - - $scope.obj._locked = true; - }); + controller: ['$scope', 'Challenges', '$stateParams', + function($scope, Challenges, $stateParams){ + $scope.obj = Challenges.Challenge.getMember({cid:$stateParams.cid, uid:$stateParams.uid}, function(){ + $scope.obj._locked = true; + }); }] }) @@ -331,7 +281,6 @@ window.habitrpg = angular.module('habitrpg', }); var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID)); - if (settings && settings.auth) { $httpProvider.defaults.headers.common['Content-Type'] = 'application/json;charset=utf-8'; $httpProvider.defaults.headers.common['x-api-user'] = settings.auth.apiId; diff --git a/website/client/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js similarity index 71% rename from website/client/js/controllers/authCtrl.js rename to website/public/js/controllers/authCtrl.js index 13bc8ca1e6..5b486677f5 100644 --- a/website/client/js/controllers/authCtrl.js +++ b/website/public/js/controllers/authCtrl.js @@ -17,10 +17,10 @@ angular.module('habitrpg') var runAuth = function(id, token) { User.authenticate(id, token, function(err) { if(!err) $scope.registrationInProgress = false; + $window.location.href = ('/' + window.location.hash); Analytics.login(); Analytics.updateUser(); Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'login'}); - $window.location.href = ('/' + window.location.hash); }); }; @@ -28,12 +28,8 @@ angular.module('habitrpg') $scope.registrationInProgress = false; if (status === 0) { $window.alert(window.env.t('noReachServer')); - } else if (status === 400 && data.errors && _.isArray(data.errors)) { // bad requests - data.errors.forEach(function (err) { - $window.alert(err.message); - }); - } else if (!!data && !!data.error) { - $window.alert(data.message); + } else if (!!data && !!data.err) { + $window.alert(data.err); } else { $window.alert(window.env.t('errorUpCase') + ' ' + status); } @@ -50,18 +46,10 @@ angular.module('habitrpg') $scope.registrationInProgress = true; - var url = ApiUrl.get() + "/api/v3/user/auth/local/register"; - if (location.search && location.search.indexOf('Invite=') !== -1) { // matches groupInvite and partyInvite - url += location.search; - } - - if($rootScope.selectedLanguage) { - var toAppend = url.indexOf('?') !== -1 ? '&' : '?'; - url = url + toAppend + 'lang=' + $rootScope.selectedLanguage.code; - } - - $http.post(url, scope.registerVals).success(function(res, status, headers, config) { - runAuth(res.data._id, res.data.apiToken); + var url = ApiUrl.get() + "/api/v2/register"; + if($rootScope.selectedLanguage) url = url + '?lang=' + $rootScope.selectedLanguage.code; + $http.post(url, scope.registerVals).success(function(data, status, headers, config) { + runAuth(data.id, data.apiToken); }).error(errorAlert); }; @@ -70,14 +58,13 @@ angular.module('habitrpg') username: $scope.loginUsername || $('#loginForm input[name="username"]').val(), password: $scope.loginPassword || $('#loginForm input[name="password"]').val() }; - //@TODO: Move all the $http methods to a service - $http.post(ApiUrl.get() + "/api/v3/user/auth/local/login", data) - .success(function(res, status, headers, config) { - runAuth(res.data.id, res.data.apiToken); + $http.post(ApiUrl.get() + "/api/v2/user/auth/local", data) + .success(function(data, status, headers, config) { + runAuth(data.id, data.token); }).error(errorAlert); }; - $scope.playButtonClick = function() { + $scope.playButtonClick = function(){ Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Play'}) if (User.authenticated()) { window.location.href = ('/' + window.location.hash); @@ -93,7 +80,7 @@ angular.module('habitrpg') if(email == null || email.length == 0) { alert(window.env.t('invalidEmail')); } else { - $http.post(ApiUrl.get() + '/api/v3/user/reset-password', {email:email}) + $http.post(ApiUrl.get() + '/api/v2/user/reset-password', {email:email}) .success(function(){ alert(window.env.t('newPassSent')); }) @@ -111,12 +98,12 @@ angular.module('habitrpg') $scope.socialLogin = function(network){ hello(network).login({scope:'email'}).then(function(auth){ - $http.post(ApiUrl.get() + "/api/v3/user/auth/social", auth) - .success(function(res, status, headers, config) { - runAuth(res.data.id, res.data.apiToken); + $http.post(ApiUrl.get() + "/api/v2/user/auth/social", auth) + .success(function(data, status, headers, config) { + runAuth(data.id, data.token); }).error(errorAlert); }, function( e ){ - alert("Signin error: " + e.message ); + alert("Signin error: " + e.error.message ); }); }; diff --git a/website/client/js/controllers/autoCompleteCtrl.js b/website/public/js/controllers/autoCompleteCtrl.js similarity index 100% rename from website/client/js/controllers/autoCompleteCtrl.js rename to website/public/js/controllers/autoCompleteCtrl.js diff --git a/website/client/js/controllers/challengesCtrl.js b/website/public/js/controllers/challengesCtrl.js similarity index 61% rename from website/client/js/controllers/challengesCtrl.js rename to website/public/js/controllers/challengesCtrl.js index bcc62eca72..30aac589d5 100644 --- a/website/client/js/controllers/challengesCtrl.js +++ b/website/public/js/controllers/challengesCtrl.js @@ -1,5 +1,5 @@ -habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', 'Challenges', 'Notification', '$compile', 'Groups', '$state', '$stateParams', 'Members', 'Tasks', 'TAVERN_ID', - function($rootScope, $scope, Shared, User, Challenges, Notification, $compile, Groups, $state, $stateParams, Members, Tasks, TAVERN_ID) { +habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', 'Challenges', 'Notification', '$compile', 'Groups', '$state', '$stateParams', 'Members', 'Tasks', + function($rootScope, $scope, Shared, User, Challenges, Notification, $compile, Groups, $state, $stateParams, Members, Tasks) { // Use presence of cid to determine whether to show a list or a single // challenge @@ -10,11 +10,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', _getChallenges(); // FIXME $scope.challenges needs to be resolved first (see app.js) - $scope.groups = []; - Groups.Group.getGroups('party,guilds,tavern') - .then(function (response) { - $scope.groups = response.data.data; - }); + $scope.groups = Groups.Group.query({type:'party,guilds,tavern'}); // override score() for tasks listed in challenges-editing pages, so that nothing happens $scope.score = function(){} @@ -30,16 +26,13 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', }); }; - $scope.isUserMemberOf = function (challenge) { - return User.user.challenges.indexOf(challenge._id) !== -1; - } - $scope.editTask = Tasks.editTask; /** * Create */ $scope.create = function() { + //If the user has one filter selected, assume that the user wants to default to that group var defaultGroup; //Our filters contain all groups, but we only want groups that have atleast one challenge @@ -48,19 +41,19 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', var filterCount = 0; for ( var i = 0; i < len; i += 1 ) { - if ($scope.search.group[groupsWithChallenges[i]] === true) { + if ( $scope.search.group[groupsWithChallenges[i]] == true ) { filterCount += 1; defaultGroup = groupsWithChallenges[i]; } - - if (filterCount >= 1 && defaultGroup) { + if (filterCount > 1) { + defaultGroup = $scope.groups[0]._id break; } } - if(!defaultGroup) defaultGroup = TAVERN_ID; + if(!defaultGroup) defaultGroup = 'habitrpg'; - $scope.obj = $scope.newChallenge = { + $scope.obj = $scope.newChallenge = new Challenges.Challenge({ name: '', description: '', habits: [], @@ -72,7 +65,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', timestamp: +(new Date), members: [], official: false - }; + }); _calculateMaxPrize(defaultGroup); }; @@ -89,12 +82,10 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', }; _(clonedTasks).each(function(val, type) { - if (challenge[type + 's']) { - challenge[type + 's'].forEach(_cloneTaskAndPush); - } + challenge[type + 's'].forEach(_cloneTaskAndPush); }).value(); - $scope.obj = $scope.newChallenge = { + $scope.obj = $scope.newChallenge = new Challenges.Challenge({ name: challenge.name, shortName: challenge.shortName, description: challenge.description, @@ -106,7 +97,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', group: challenge.group._id, official: challenge.official, prize: challenge.prize - }; + }); function _cloneTaskAndPush(taskToClone) { var task = Tasks.cloneTask(taskToClone); @@ -120,45 +111,22 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.save = function(challenge) { if (!challenge.group) return alert(window.env.t('selectGroup')); - if (!challenge.shortName || challenge.shortName.length < 3) return alert(window.env.t('shortNameTooShort')); - var isNew = !challenge._id; if(isNew && challenge.prize > $scope.maxPrize) { return alert(window.env.t('challengeNotEnoughGems')); } - if (isNew) { - var _challenge; - Challenges.createChallenge(challenge) - .then(function (response) { - _challenge = response.data.data; - Notification.text(window.env.t('challengeCreated')); + challenge.$save(function(_challenge){ + if (isNew) { + Notification.text(window.env.t('challengeCreated')); + User.sync(); + } - var challengeTasks = []; - challengeTasks = challengeTasks.concat(challenge.todos); - challengeTasks = challengeTasks.concat(challenge.habits); - challengeTasks = challengeTasks.concat(challenge.dailys); - challengeTasks = challengeTasks.concat(challenge.rewards); - - return Tasks.createChallengeTasks(_challenge._id, challengeTasks); - }) - .then(function (response) { - $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { - reload: true, inherit: false, notify: true - }); - User.sync(); - }); - } else { - Challenges.updateChallenge(challenge._id, challenge) - .then(function (response) { - var _challenge = response.data.data; - $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { - reload: true, inherit: false, notify: true - }); - User.sync(); - }); - } + $state.transitionTo('options.social.challenges.detail', { cid: _challenge._id }, { + reload: true, inherit: false, notify: true + }); + }); }; /** @@ -168,6 +136,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.newChallenge = null; }; + /** * Close Challenge * ------------------ @@ -179,34 +148,27 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', challenge.winner = undefined; }; - //@TODO: change to $scope.remove $scope["delete"] = function(challenge) { var warningMsg; - - if(challenge.group._id == TAVERN_ID) { + if(challenge.group._id == 'habitrpg') { warningMsg = window.env.t('sureDelChaTavern'); } else { warningMsg = window.env.t('sureDelCha'); } - if (!confirm(warningMsg)) return; - - Challenges.deleteChallenge(challenge._id) - .then(function (response) { - $scope.popoverEl.popover('destroy'); - _backToChallenges(); - }); + challenge.$delete(function(){ + $scope.popoverEl.popover('destroy'); + _backToChallenges(); + }); }; $scope.selectWinner = function(challenge) { if (!challenge.winner) return; if (!confirm(window.env.t('youSure'))) return; - - Challenges.selectChallengeWinner(challenge._id, challenge.winner) - .then(function (response) { - $scope.popoverEl.popover('destroy'); - _backToChallenges(); - }); + challenge.$close({uid:challenge.winner}, function(){ + $scope.popoverEl.popover('destroy'); + _backToChallenges(); + }) } $scope.close = function(challenge, $event) { @@ -241,36 +203,19 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', //------------------------------------------------------------ // Tasks //------------------------------------------------------------ - function addTask (addTo, listDef, challenge) { + + $scope.addTask = function(addTo, listDef) { var task = Shared.taskDefaults({text: listDef.newTask, type: listDef.type}); - //If the challenge has not been created, we bulk add tasks on save - if (challenge._id) Tasks.createChallengeTasks(challenge._id, task); - if (!challenge[task.type + 's']) challenge[task.type + 's'] = []; - challenge[task.type + 's'].unshift(task); + addTo.unshift(task); + //User.log({op: "addTask", data: task}); //TODO persist delete listDef.newTask; }; - $scope.addTask = function(addTo, listDef, challenge) { - if (listDef.bulk) { - var tasks = listDef.newTask.split(/[\n\r]+/); - //Reverse the order of tasks so the tasks will appear in the order the user entered them - tasks.reverse(); - _.each(tasks, function(t) { - listDef.newTask = t; - addTask(addTo, listDef, challenge); - }); - listDef.bulk = false; - } else { - addTask(addTo, listDef, challenge); - } - } - - $scope.removeTask = function(task, challenge) { + $scope.removeTask = function(task, list) { if (!confirm(window.env.t('sureDelete', {taskType: window.env.t(task.type), taskText: task.text}))) return; - //We only pass to the api if the challenge exists, otherwise, the tasks only exist on the client - if (challenge._id) Tasks.deleteTask(task._id); - var index = challenge[task.type + 's'].indexOf(task); - challenge[task.type + 's'].splice(index, 1); + //TODO persist + // User.log({op: "delTask", data: task}); + _.remove(list, task); }; $scope.saveTask = function(task){ @@ -278,48 +223,28 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', // TODO persist } - $scope.toggleBulk = function(list) { - if (typeof list.bulk === 'undefined') { - list.bulk = false; - } - list.bulk = !list.bulk; - list.focus = true; - }; - /* -------------------------- Subscription -------------------------- */ - $scope.join = function (challenge) { - Challenges.joinChallenge(challenge._id) - .then(function (response) { - User.user.challenges.push(challenge._id); - _getChallenges(); - return Tasks.getUserTasks(); - }) - .then(function (response) { - var tasks = response.data.data; - User.syncUserTasks(tasks); - }); + $scope.join = function(challenge){ + challenge.$join(function(){ + _getChallenges() + User.log({}); + }); + } - $scope.leave = function(keep, challenge) { + $scope.leave = function(keep) { if (keep == 'cancel') { $scope.selectedChal = undefined; } else { - Challenges.leaveChallenge($scope.selectedChal._id, keep) - .then(function (response) { - var index = User.user.challenges.indexOf($scope.selectedChal._id); - delete User.user.challenges[index]; - _getChallenges(); - return Tasks.getUserTasks(); - }) - .then(function (response) { - var tasks = response.data.data; - User.syncUserTasks(tasks); - }); + $scope.selectedChal.$leave({keep:keep}, function(){ + _getChallenges() + User.log({}); + }); } $scope.popoverEl.popover('destroy'); } @@ -358,7 +283,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', _calculateMaxPrize(gid); - if (gid == TAVERN_ID) { + if (gid == 'habitrpg') { $scope.newChallenge.prize = 1; } }) @@ -381,7 +306,7 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.insufficientGemsForTavernChallenge = function() { var balance = User.user.balance || 0; - var isForTavern = $scope.newChallenge.group == TAVERN_ID; + var isForTavern = $scope.newChallenge.group == 'habitrpg'; if (isForTavern) { return balance <= 0; @@ -391,24 +316,21 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', } $scope.sendMessageToChallengeParticipant = function(uid) { - Members.selectMember(uid) - .then(function () { - $rootScope.openModal('private-message', {controller:'MemberModalCtrl'}); - }); + Members.selectMember(uid, function(){ + $rootScope.openModal('private-message',{controller:'MemberModalCtrl'}); + }); }; $scope.sendGiftToChallengeParticipant = function(uid) { - Members.selectMember(uid) - .then(function () { - $rootScope.openModal('send-gift', {controller:'MemberModalCtrl'}); - }); + Members.selectMember(uid, function(){ + $rootScope.openModal('send-gift',{controller:'MemberModalCtrl'}) + }); }; $scope.filterInitialChallenges = function() { - $scope.groupsFilter = _.uniq(_.compact(_.pluck($scope.challenges, 'group')), function(g) {return g._id}); - + $scope.groupsFilter = _.uniq(_.pluck($scope.challenges, 'group'), function(g){return g._id}); $scope.search = { - group: _.transform($scope.groups, function(m,g) { m[g._id] = true;}), + group: _.transform($scope.groups, function(m,g){m[g._id]=true;}), _isMember: "either", _isOwner: "either" }; @@ -439,14 +361,14 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', return groupBalance; } - function _shouldShowChallenge (chal) { + function _shouldShowChallenge(chal) { // Have to check that the leader object exists first in the // case where a challenge's leader deletes their account var userIsOwner = (chal.leader && chal.leader._id) === User.user.id; - var groupSelected = $scope.search.group[chal.group ? chal.group._id : null]; + var groupSelected = $scope.search.group[chal.group._id]; var checkOwner = $scope.search._isOwner === 'either' || (userIsOwner === $scope.search._isOwner); - var checkMember = $scope.search._isMember === 'either' || ($scope.isUserMemberOf(chal) === $scope.search._isMember); + var checkMember = $scope.search._isMember === 'either' || (chal._isMember === $scope.search._isMember); return groupSelected && checkOwner && checkMember; } @@ -455,24 +377,24 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User', $scope.popoverEl.popover('destroy'); $scope.cid = null; $state.go('options.social.challenges'); - _getChallenges(); + $scope.challenges = Challenges.Challenge.query(); + User.log({}); } + // Fetch single challenge if a cid is present; fetch multiple challenges // otherwise function _getChallenges() { if ($scope.cid) { - Challenges.getChallenge($scope.cid) - .then(function (response) { - var challenge = response.data.data; - $scope.challenges = [challenge]; - }); + Challenges.Challenge.get({cid: $scope.cid}, function(challenge) { + $scope.challenges = [challenge]; + }); } else { - Challenges.getUserChallenges() - .then(function(response){ - $scope.challenges = response.data.data; - $scope.filterInitialChallenges(); - }); + Challenges.Challenge.query(function(challenges){ + $scope.challenges = challenges; + $scope.filterInitialChallenges(); + }); } }; + }]); diff --git a/website/client/js/controllers/chatCtrl.js b/website/public/js/controllers/chatCtrl.js similarity index 62% rename from website/client/js/controllers/chatCtrl.js rename to website/public/js/controllers/chatCtrl.js index c7c29d6502..8ba5b2a15c 100644 --- a/website/client/js/controllers/chatCtrl.js +++ b/website/public/js/controllers/chatCtrl.js @@ -27,72 +27,66 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A if (_.isEmpty(message) || $scope._sending) return; $scope._sending = true; var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false; - Chat.postChat(group._id, message, previousMsg) - .then(function(response) { - var message = response.data.data.message; - - group.chat.unshift(message); - - $scope.message.content = ''; - $scope._sending = false; - - if (group.type == 'party') { - Analytics.updateUser({'partyID': group.id, 'partySize': group.memberCount}); - } - - if (group.privacy == 'public'){ - Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'group chat','groupType':group.type,'privacy':group.privacy,'groupName':group.name}); - } else { - Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'group chat','groupType':group.type,'privacy':group.privacy}); - } - }, function(err){ - $scope._sending = false; - }); + Chat.utils.postChat({gid: group._id, message:message, previousMsg: previousMsg}, undefined, function(data){ + if(data.chat){ + group.chat = data.chat; + }else if(data.message){ + group.chat.unshift(data.message); + } + $scope.message.content = ''; + $scope._sending = false; + if (group.type == 'party') { + Analytics.updateUser({'partyID':group.id,'partySize':group.memberCount}); + } + if (group.privacy == 'public'){ + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'group chat','groupType':group.type,'privacy':group.privacy,'groupName':group.name}); + } else { + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'group chat','groupType':group.type,'privacy':group.privacy}); + } + }, function(err){ + $scope._sending = false; + }); } $scope.deleteChatMessage = function(group, message){ if(message.uuid === User.user.id || (User.user.backer && User.user.contributor.admin)){ var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false; - if (confirm('Are you sure you want to delete this message?')) { - Chat.deleteChat(group._id, message.id, previousMsg) - .then(function (response) { - var i = _.findIndex(group.chat, {id: message.id}); - if(i !== -1) group.chat.splice(i, 1); - }); + if(confirm('Are you sure you want to delete this message?')){ + Chat.utils.deleteChatMessage({gid:group._id, messageId:message.id, previousMsg:previousMsg}, undefined, function(data){ + if(data.chat) group.chat = data.chat; + + var i = _.findIndex(group.chat, {id: message.id}); + if(i !== -1) group.chat.splice(i, 1); + }); } } } - $scope.likeChatMessage = function(group, message) { + $scope.likeChatMessage = function(group,message) { if (message.uuid == User.user._id) return Notification.text(window.env.t('foreverAlone')); - if (!message.likes) message.likes = {}; - if (message.likes[User.user._id]) { delete message.likes[User.user._id]; } else { message.likes[User.user._id] = true; } - - Chat.like(group._id, message.id); + Chat.utils.like({ gid:group._id, messageId:message.id }, undefined); } $scope.flagChatMessage = function(groupId,message) { if(!message.flags) message.flags = {}; - - if (message.flags[User.user._id]) { + if(message.flags[User.user._id]) Notification.text(window.env.t('abuseAlreadyReported')); - } else { + else { $scope.abuseObject = message; $scope.groupId = groupId; - Members.selectMember(message.uuid) - .then(function () { - $rootScope.openModal('abuse-flag',{ - controller:'MemberModalCtrl', - scope: $scope - }); + Members.selectMember(message.uuid, function(){ + $rootScope.openModal('abuse-flag',{ + controller:'MemberModalCtrl', + scope: $scope }); + }); } }; @@ -114,21 +108,15 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A }); }; - function handleGroupResponse (response) { - $scope.group = response; - if (!$scope.group._id) $scope.group = response.data.data; - }; - - $scope.sync = function(group) { - if (group.name === Groups.TAVERN_NAME) { - Groups.tavern(true).then(handleGroupResponse); - } else if (group._id === User.user.party._id) { - Groups.party(true).then(handleGroupResponse); + $scope.sync = function(group){ + if(group.type == 'party') { + group.$syncParty(); // Syncs the whole party, not just 15 members } else { - Groups.Group.get(group._id).then(handleGroupResponse); + group.$get(); } - - Chat.markChatSeen(group._id); + // When the user clicks fetch recent messages we need to update + // that the user has seen the new messages + Chat.seenMessage(group._id); } // List of Ordering options for the party members list diff --git a/website/client/js/controllers/copyMessageModalCtrl.js b/website/public/js/controllers/copyMessageModalCtrl.js similarity index 89% rename from website/client/js/controllers/copyMessageModalCtrl.js rename to website/public/js/controllers/copyMessageModalCtrl.js index 1e58237454..60d07ec152 100644 --- a/website/client/js/controllers/copyMessageModalCtrl.js +++ b/website/public/js/controllers/copyMessageModalCtrl.js @@ -9,7 +9,7 @@ habitrpg.controller("CopyMessageModalCtrl", ['$scope', 'User', 'Notification', notes: $scope.notes }; - User.addTask({body:newTask}); + User.user.ops.addTask({body:newTask}); Notification.text(window.env.t('messageAddedAsToDo')); $scope.$close(); diff --git a/website/client/js/controllers/filtersCtrl.js b/website/public/js/controllers/filtersCtrl.js similarity index 81% rename from website/client/js/controllers/filtersCtrl.js rename to website/public/js/controllers/filtersCtrl.js index 4a3ed6e59c..cfdc45658d 100644 --- a/website/client/js/controllers/filtersCtrl.js +++ b/website/public/js/controllers/filtersCtrl.js @@ -14,7 +14,7 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared', _.each(User.user.tags, function(tag){ // Send an update op for each changed tag (excluding new tags & deleted tags, this if() packs a punch) if (tagsSnap[tag.id] && tagsSnap[tag.id].name != tag.name) - User.updateTag({params:{id:tag.id}, body:{name:tag.name}}); + User.user.ops.updateTag({params:{id:tag.id},body:{name:tag.name}}); }) $scope._editing = false; } else { @@ -25,12 +25,7 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared', }; $scope.toggleFilter = function(tag) { - if (!user.filters[tag.id]) { - user.filters[tag.id] = true; - } else { - user.filters[tag.id] = !user.filters[tag.id]; - } - + user.filters[tag.id] = !user.filters[tag.id]; // no longer persisting this, it was causing a lot of confusion - users thought they'd permanently lost tasks // Note: if we want to persist for just this computer, easy method is: // User.save(); @@ -42,7 +37,7 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared', $scope.updateTaskFilter(); $scope.createTag = function() { - User.addTag({body:{name: $scope._newTag.name, id: Shared.uuid()}}); + User.user.ops.addTag({body:{name:$scope._newTag.name, id:Shared.uuid()}}); $scope._newTag.name = ''; }; }]); diff --git a/website/client/js/controllers/footerCtrl.js b/website/public/js/controllers/footerCtrl.js similarity index 64% rename from website/client/js/controllers/footerCtrl.js rename to website/public/js/controllers/footerCtrl.js index 2d07aff430..cd0b9ac182 100644 --- a/website/client/js/controllers/footerCtrl.js +++ b/website/public/js/controllers/footerCtrl.js @@ -7,8 +7,8 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { $scope.loadWidgets = Social.loadWidgets; if(env.isStaticPage){ - $scope.languages = env.availableLanguages; - $scope.selectedLanguage = _.find(env.availableLanguages, {code: env.language.code}); + $scope.languages = env.avalaibleLanguages; + $scope.selectedLanguage = _.find(env.avalaibleLanguages, {code: env.language.code}); $rootScope.selectedLanguage = $scope.selectedLanguage; @@ -80,16 +80,21 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { $scope.addMissedDay = function(numberOfDays){ if (!confirm("Are you sure you want to reset the day by " + numberOfDays + " day(s)?")) return; - - User.setCron(numberOfDays); + var dayBefore = moment(User.user.lastCron).subtract(numberOfDays, 'days').toDate(); + User.set({'lastCron': dayBefore}); + Notification.text('-' + numberOfDays + ' day(s), remember to refresh'); }; $scope.addTenGems = function(){ - User.addTenGems(); + $http.post(ApiUrl.get() + '/api/v2/user/addTenGems').success(function(){ + User.log({}); + }) }; $scope.addHourglass = function(){ - User.addHourglass(); + $http.post(ApiUrl.get() + '/api/v2/user/addHourglass').success(function(){ + User.log({}); + }) }; $scope.addGold = function(){ @@ -118,63 +123,10 @@ function($scope, $rootScope, User, $http, Notification, ApiUrl, Social) { }); }; - $scope.addQuestProgress = function(){ - $http({ - method: "POST", - url: 'api/v3/debug/quest-progress' - }) - .then(function (response) { - Notification.text('Quest progress increased'); - User.sync(); - }) - }; - - $scope.makeAdmin = function () { - User.makeAdmin(); - }; - - $scope.openModifyInventoryModal = function () { - $rootScope.openModal('modify-inventory', {controller: 'FooterCtrl', scope: $scope }); - $scope.showInv = { }; - $scope.inv = { - gear: {}, - special: {}, - pets: {}, - mounts: {}, - eggs: {}, - hatchingPotions: {}, - food: {}, - quests: {}, - }; - $scope.setAllItems = function (type, value) { - var set = $scope.inv[type]; - - for (var item in set) { - if (set.hasOwnProperty(item)) { - set[item] = value; - } - } - }; - }; - - $scope.modifyInventory = function () { - $http({ - method: "POST", - url: 'api/v3/debug/modify-inventory', - data: { - gear: $scope.showInv.gear ? $scope.inv.gear : null, - special: $scope.showInv.special ? $scope.inv.special : null, - pets: $scope.showInv.pets ? $scope.inv.pets : null, - mounts: $scope.showInv.mounts ? $scope.inv.mounts : null, - eggs: $scope.showInv.eggs ? $scope.inv.eggs : null, - hatchingPotions: $scope.showInv.hatchingPotions ? $scope.inv.hatchingPotions : null, - food: $scope.showInv.food ? $scope.inv.food : null, - quests: $scope.showInv.quests ? $scope.inv.quests : null, - } - }) - .then(function (response) { - Notification.text('Inventory updated. Refresh or sync.'); - }) + $scope.addBossQuestProgressUp = function(){ + User.set({ + 'party.quest.progress.up': User.user.party.quest.progress.up + 1000 + }); }; } }]) diff --git a/website/client/js/controllers/groupsCtrl.js b/website/public/js/controllers/groupsCtrl.js similarity index 72% rename from website/client/js/controllers/groupsCtrl.js rename to website/public/js/controllers/groupsCtrl.js index cafbe3b158..e7865afb55 100644 --- a/website/client/js/controllers/groupsCtrl.js +++ b/website/public/js/controllers/groupsCtrl.js @@ -2,28 +2,30 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', 'Notification', function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state, Notification) { - $scope.isMemberOfPendingQuest = function (userid, group) { + + $scope.isMemberOfPendingQuest = function(userid, group) { if (!group.quest || !group.quest.members) return false; if (group.quest.active) return false; // quest is started, not pending return userid in group.quest.members && group.quest.members[userid] != false; }; - $scope.isMemberOfRunningQuest = function (userid, group) { + $scope.isMemberOfRunningQuest = function(userid, group) { if (!group.quest || !group.quest.members) return false; if (!group.quest.active) return false; // quest is pending, not started return group.quest.members[userid]; }; - $scope.isMemberOfGroup = function (userid, group) { + $scope.isMemberOfGroup = function(userid, group){ + // If the group is a guild, just check for an intersection with the // current user's guilds, rather than checking the members of the group. if(group.type === 'guild') { - return _.detect(User.user.guilds, function(guildId) { return guildId === group._id }); + return _.detect(Groups.myGuilds(), function(g) { return g._id === group._id }); } // Similarly, if we're dealing with the user's current party, return true. if(group.type === 'party') { - var currentParty = group; + var currentParty = Groups.party(); if(currentParty._id && currentParty._id === group._id) return true; } @@ -32,13 +34,12 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' return ~(memberIds.indexOf(userid)); }; - $scope.isMember = function (user, group) { + $scope.isMember = function(user, group){ return ~(group.members.indexOf(user._id)); }; $scope.Members = Members; - - $scope._editing = {group: false}; + $scope._editing = {group:false}; $scope.groupCopy = {}; $scope.editGroup = function (group) { @@ -46,6 +47,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' group._editing = true; }; + $scope.saveEdit = function (group) { var newLeader = $scope.groupCopy._newLeader && $scope.groupCopy._newLeader._id; @@ -55,7 +57,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' angular.copy($scope.groupCopy, group); - Groups.Group.update(group); + group.$save(); $scope.cancelEdit(group); }; @@ -67,13 +69,13 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.deleteAllMessages = function() { if (confirm(window.env.t('confirmDeleteAllMessages'))) { - User.clearPMs(); + User.user.ops.clearPMs({}); } }; // ------ Modals ------ - $scope.clickMember = function (uid, forceShow) { + $scope.clickMember = function(uid, forceShow) { if (User.user._id == uid && !forceShow) { if ($state.is('tasks')) { $state.go('options.profile.avatar'); @@ -83,14 +85,14 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } else { // We need the member information up top here, but then we pass it down to the modal controller // down below. Better way of handling this? - Members.selectMember(uid) - .then(function () { - $rootScope.openModal('member', {controller: 'MemberModalCtrl', windowClass: 'profile-modal', size: 'lg'}); - }); + Members.selectMember(uid, function(){ + $rootScope.openModal('member', {controller:'MemberModalCtrl', windowClass:'profile-modal', size:'lg'}); + }); } }; - $scope.removeMember = function (group, member, isMember) { + + $scope.removeMember = function(group, member, isMember){ // TODO find a better way to do this (share data with remove member modal) $scope.removeMemberData = { group: group, @@ -100,13 +102,13 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $rootScope.openModal('remove-member', {scope: $scope}); }; - $scope.confirmRemoveMember = function (confirm) { - if (confirm) { - Groups.Group.removeMember( - $scope.removeMemberData.group._id, - $scope.removeMemberData.member._id, - $scope.removeMemberData.message - ).then(function (response) { + $scope.confirmRemoveMember = function(confirm){ + if(confirm){ + Groups.Group.removeMember({ + gid: $scope.removeMemberData.group._id, + uuid: $scope.removeMemberData.member._id, + message: $scope.removeMemberData.message, + }, undefined, function(){ if($scope.removeMemberData.isMember){ _.pull($scope.removeMemberData.group.members, $scope.removeMemberData.member); }else{ @@ -115,16 +117,15 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.removeMemberData = undefined; }); - } else { + }else{ $scope.removeMemberData = undefined; } }; - $scope.openInviteModal = function (group) { + $scope.openInviteModal = function(group){ if (group.type !== 'party' && group.type !== 'guild') { return console.log('Invalid group type.') } - $rootScope.openModal('invite-' + group.type, { controller:'InviteToGroupCtrl', resolve: { @@ -135,10 +136,10 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' }); }; - $scope.quickReply = function (uid) { - Members.selectMember(uid) - .then(function (response) { - $rootScope.openModal('private-message', {controller: 'MemberModalCtrl'}); - }); + $scope.quickReply = function(uid) { + Members.selectMember(uid, function(){ + $rootScope.openModal('private-message',{controller:'MemberModalCtrl'}); + }); } + }]); diff --git a/website/public/js/controllers/guildsCtrl.js b/website/public/js/controllers/guildsCtrl.js new file mode 100644 index 0000000000..df6b001448 --- /dev/null +++ b/website/public/js/controllers/guildsCtrl.js @@ -0,0 +1,94 @@ +'use strict'; + +habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', 'Analytics', + function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile, Analytics) { + $scope.groups = { + guilds: Groups.myGuilds(), + "public": Groups.publicGuilds() + } + + $scope.type = 'guild'; + $scope.text = window.env.t('guild'); + var newGroup = function(){ + return new Groups.Group({type:'guild', privacy:'private'}); + } + $scope.newGroup = newGroup() + $scope.create = function(group){ + if (User.user.balance < 1) + return $rootScope.openModal('buyGems', {track:"Gems > Create Group"}); + + if (confirm(window.env.t('confirmGuild'))) { + group.$save(function(saved){ + if (saved.privacy == 'public') {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':true,'groupType':'guild','privacy':saved.privacy,'groupName':saved.name})} + else {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':true,'groupType':'guild','privacy':saved.privacy})} + $rootScope.hardRedirect('/#/options/groups/guilds/' + saved._id); + }); + } + } + + $scope.join = function(group){ + // If we're accepting an invitation, we don't have the actual group object, but a faux group object (for performance + // purposes) {id, name}. Let's trick ngResource into thinking we have a group, so we can call the same $join + // function (server calls .attachGroup(), which finds group by _id and handles this properly) + if (group.id && !group._id) { + group = new Groups.Group({_id:group.id}); + } + + group.$join(function(joined){ + if (joined.privacy == 'public') {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'guild','privacy':joined.privacy,'groupName':joined.name})} + else {Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'guild','privacy':joined.privacy})} + $rootScope.hardRedirect('/#/options/groups/guilds/' + joined._id); + }) + } + + $scope.leave = function(keep) { + if (keep == 'cancel') { + $scope.selectedGroup = undefined; + $scope.popoverEl.popover('destroy'); + } else { + Groups.Group.leave({gid: $scope.selectedGroup._id, keep:keep}, undefined, function(){ + $rootScope.hardRedirect('/#/options/groups/guilds'); + }); + } + } + + $scope.clickLeave = function(group, $event){ + $scope.selectedGroup = group; + $scope.popoverEl = $($event.target).closest('.btn'); + var html, title; + Challenges.Challenge.query(function(challenges) { + challenges = _.pluck(_.filter(challenges, function(c) { + return c.group._id == group._id; + }), '_id'); + + if (_.intersection(challenges, User.user.challenges).length > 0) { + html = $compile( + '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leaveGroupCha'); + } else { + html = $compile( + '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leaveGroup') + } + + $scope.popoverEl.popover('destroy').popover({ + html: true, + placement: 'top', + trigger: 'manual', + title: title, + content: html + }).popover('show'); + }); + } + + $scope.reject = function(guild){ + var i = _.findIndex(User.user.invitations.guilds, {id:guild.id}); + if (~i){ + User.user.invitations.guilds.splice(i, 1); + User.set({'invitations.guilds':User.user.invitations.guilds}); + } + } + } + ]); diff --git a/website/client/js/controllers/hallCtrl.js b/website/public/js/controllers/hallCtrl.js similarity index 66% rename from website/client/js/controllers/hallCtrl.js rename to website/public/js/controllers/hallCtrl.js index 3aad20a782..fe385a316c 100644 --- a/website/client/js/controllers/hallCtrl.js +++ b/website/public/js/controllers/hallCtrl.js @@ -2,12 +2,10 @@ habitrpg.controller("HallHeroesCtrl", ['$scope', '$rootScope', 'User', 'Notification', 'ApiUrl', '$resource', function($scope, $rootScope, User, Notification, ApiUrl, $resource) { - var Hero = $resource(ApiUrl.get() + '/api/v3/hall/heroes/:uid', {uid:'@_id'}); + var Hero = $resource(ApiUrl.get() + '/api/v2/hall/heroes/:uid', {uid:'@_id'}); $scope.hero = undefined; $scope.loadHero = function(uuid){ - Hero.query({uid:uuid}, function (heroData) { - $scope.hero = heroData.data; - }); + $scope.hero = Hero.get({uid:uuid}); } $scope.saveHero = function(hero) { $scope.hero.contributor.admin = ($scope.hero.contributor.level > 7) ? true : false; @@ -15,14 +13,10 @@ habitrpg.controller("HallHeroesCtrl", ['$scope', '$rootScope', 'User', 'Notifica Notification.text("User updated"); $scope.hero = undefined; $scope._heroID = undefined; - Hero.query({}, function (heroesData) { - $scope.heroes = heroesData.data; - }); + $scope.heroes = Hero.query(); }) } - Hero.query({}, function (heroesData) { - $scope.heroes = heroesData.data; - }); + $scope.heroes = Hero.query(); $scope.populateContributorInput = function(id) { $scope._heroID = id; @@ -33,14 +27,14 @@ habitrpg.controller("HallHeroesCtrl", ['$scope', '$rootScope', 'User', 'Notifica habitrpg.controller("HallPatronsCtrl", ['$scope', '$rootScope', 'User', 'Notification', 'ApiUrl', '$resource', function($scope, $rootScope, User, Notification, ApiUrl, $resource) { - var Patron = $resource(ApiUrl.get() + '/api/v3/hall/patrons/:uid', {uid:'@_id'}); + var Patron = $resource(ApiUrl.get() + '/api/v2/hall/patrons/:uid', {uid:'@_id'}); var page = 0; $scope.patrons = []; $scope.loadMore = function(){ - Patron.query({page: page++}, function(patronsData){ - $scope.patrons = $scope.patrons.concat(patronsData.data); + Patron.query({page: page++}, function(patrons){ + $scope.patrons = $scope.patrons.concat(patrons); }) } $scope.loadMore(); diff --git a/website/client/js/controllers/headerCtrl.js b/website/public/js/controllers/headerCtrl.js similarity index 79% rename from website/client/js/controllers/headerCtrl.js rename to website/public/js/controllers/headerCtrl.js index 390d2c92a9..68000da3b9 100644 --- a/website/client/js/controllers/headerCtrl.js +++ b/website/public/js/controllers/headerCtrl.js @@ -8,19 +8,15 @@ habitrpg.controller("HeaderCtrl", ['$scope', 'Groups', 'User', $scope.inviteOrStartParty = Groups.inviteOrStartParty; - function handlePartyResponse (party) { - $scope.party = party; + $scope.party = Groups.party(function(){ + var triggerResort = function() { + $scope.partyMinusSelf = resortParty(); + }; - var triggerResort = function() { - $scope.partyMinusSelf = resortParty(); - }; - - triggerResort(); - $scope.$watch('user.party.order', triggerResort); - $scope.$watch('user.party.orderAscending', triggerResort); - } - - Groups.party().then(handlePartyResponse, handlePartyResponse); + triggerResort(); + $scope.$watch('user.party.order', triggerResort); + $scope.$watch('user.party.orderAscending', triggerResort); + }); function resortParty() { var result = _.sortBy( diff --git a/website/client/js/controllers/inventoryCtrl.js b/website/public/js/controllers/inventoryCtrl.js similarity index 92% rename from website/client/js/controllers/inventoryCtrl.js rename to website/public/js/controllers/inventoryCtrl.js index 41fa416abf..80c80bf3d4 100644 --- a/website/client/js/controllers/inventoryCtrl.js +++ b/website/public/js/controllers/inventoryCtrl.js @@ -99,7 +99,7 @@ habitrpg.controller("InventoryCtrl", var selected = $scope.selectedEgg ? 'selectedEgg' : $scope.selectedPotion ? 'selectedPotion' : $scope.selectedFood ? 'selectedFood' : undefined; if (selected) { var type = $scope.selectedEgg ? 'eggs' : $scope.selectedPotion ? 'hatchingPotions' : $scope.selectedFood ? 'food' : undefined; - User.sell({params:{type:type, key: $scope[selected].key}}); + user.ops.sell({params:{type:type, key: $scope[selected].key}}); if (user.items[type][$scope[selected].key] < 1) { $scope[selected] = null; } @@ -118,7 +118,7 @@ habitrpg.controller("InventoryCtrl", var userHasPet = user.items.pets[egg.key + '-' + potion.key] > 0; var isPremiumPet = Content.hatchingPotions[potion.key].premium && !Content.dropEggs[egg.key]; - User.hatch({params:{egg:egg.key, hatchingPotion:potion.key}}); + user.ops.hatch({params:{egg:egg.key, hatchingPotion:potion.key}}); if (!user.preferences.suppressModals.hatchPet && !userHasPet && !isPremiumPet) { $scope.hatchedPet = { @@ -128,13 +128,11 @@ habitrpg.controller("InventoryCtrl", eggKey: egg.key, pet: 'Pet-' + egg.key + '-' + potion.key }; - $rootScope.openModal('hatchPet', { scope: $scope, size: 'sm' }); } - $scope.selectedEgg = null; $scope.selectedPotion = null; @@ -172,7 +170,7 @@ habitrpg.controller("InventoryCtrl", } else if (!$window.confirm(window.env.t('feedPet', {name: petDisplayName, article: food.article, text: food.text()}))) { return; } - User.feed({params:{pet: pet, food: food.key}}); + User.user.ops.feed({params:{pet: pet, food: food.key}}); $scope.selectedFood = null; _updateDropAnimalCount(user.items); @@ -198,12 +196,12 @@ habitrpg.controller("InventoryCtrl", // Selecting Pet } else { - User.equip({params:{type: 'pet', key: pet}}); + User.user.ops.equip({params:{type: 'pet', key: pet}}); } } $scope.chooseMount = function(egg, potion) { - User.equip({params:{type: 'mount', key: egg + '-' + potion}}); + User.user.ops.equip({params:{type: 'mount', key: egg + '-' + potion}}); } $scope.getSeasonalShopArray = function(set){ @@ -230,7 +228,7 @@ habitrpg.controller("InventoryCtrl", for (item in user.items.gear.equipped){ var itemKey = user.items.gear.equipped[item]; if (user.items.gear.owned[itemKey]) { - User.equip({params: {type: 'equipped', key: itemKey}}); + user.ops.equip({params: {key: itemKey}}); } } break; @@ -239,7 +237,7 @@ habitrpg.controller("InventoryCtrl", for (item in user.items.gear.costume){ var itemKey = user.items.gear.costume[item]; if (user.items.gear.owned[itemKey]) { - User.equip({params: {type:"costume", key: itemKey}}); + user.ops.equip({params: {type:"costume", key: itemKey}}); } } break; @@ -247,17 +245,17 @@ habitrpg.controller("InventoryCtrl", case "petMountBackground": var pet = user.items.currentPet; if (pet) { - User.equip({params:{type: 'pet', key: pet}}); + user.ops.equip({params:{type: 'pet', key: pet}}); } var mount = user.items.currentMount; if (mount) { - User.equip({params:{type: 'mount', key: mount}}); + user.ops.equip({params:{type: 'mount', key: mount}}); } var background = user.preferences.background; if (background) { - User.unlock({query:{path:"background."+background}}); + User.user.ops.unlock({query:{path:"background."+background}}); } break; @@ -310,9 +308,9 @@ habitrpg.controller("InventoryCtrl", }; $scope.clickTimeTravelItem = function(type,key) { - if (user.purchased.plan.consecutive.trinkets < 1) return User.hourglassPurchase({params:{type:type,key:key}}); + if (user.purchased.plan.consecutive.trinkets < 1) return user.ops.hourglassPurchase({params:{type:type,key:key}}); if (!window.confirm(window.env.t('hourglassBuyItemConfirm'))) return; - User.hourglassPurchase({params:{type:type,key:key}}); + user.ops.hourglassPurchase({params:{type:type,key:key}}); }; function _updateDropAnimalCount(items) { diff --git a/website/client/js/controllers/inviteToGroupCtrl.js b/website/public/js/controllers/inviteToGroupCtrl.js similarity index 63% rename from website/client/js/controllers/inviteToGroupCtrl.js rename to website/public/js/controllers/inviteToGroupCtrl.js index a986cd768c..f4a74dbac9 100644 --- a/website/client/js/controllers/inviteToGroupCtrl.js +++ b/website/public/js/controllers/inviteToGroupCtrl.js @@ -1,7 +1,6 @@ 'use strict'; -habitrpg.controller('InviteToGroupCtrl', ['$scope', '$rootScope', 'User', 'Groups', 'injectedGroup', '$http', 'Notification', - function($scope, $rootScope, User, Groups, injectedGroup, $http, Notification) { +habitrpg.controller('InviteToGroupCtrl', ['$scope', 'User', 'Groups', 'injectedGroup', '$http', 'Notification', function($scope, User, Groups, injectedGroup, $http, Notification) { $scope.group = injectedGroup; $scope.inviter = User.user.profile.name; @@ -18,12 +17,8 @@ habitrpg.controller('InviteToGroupCtrl', ['$scope', '$rootScope', 'User', 'Group $scope.inviteNewUsers = function(inviteMethod) { if (!$scope.group._id) { $scope.group.name = $scope.group.name || env.t('possessiveParty', {name: User.user.profile.name}); - - return Groups.Group.create($scope.group) - .then(function(response) { - $scope.group = response.data.data; - User.sync(); - Groups.data.party = $scope.group; + return $scope.group.$save() + .then(function(res) { _inviteByMethod(inviteMethod); }); } @@ -44,21 +39,12 @@ habitrpg.controller('InviteToGroupCtrl', ['$scope', '$rootScope', 'User', 'Group return console.log('Invalid invite method.') } - Groups.Group.invite($scope.group._id, invitationDetails) - .then(function() { - Notification.text(window.env.t('invitationsSent')); - _resetInvitees(); - var redirectTo = '/#/options/groups/' - if ($scope.group.type === 'party') { - redirectTo += 'party'; - } else { - redirectTo += ('guilds/' + $scope.group._id); - } - - $rootScope.hardRedirect(redirectTo); - }, function(){ - _resetInvitees(); - }); + Groups.Group.invite({gid: $scope.group._id}, invitationDetails, function(){ + Notification.text(window.env.t('invitationsSent')); + _resetInvitees(); + }, function(){ + _resetInvitees(); + }); } function _getOnlyUuids() { @@ -79,6 +65,7 @@ habitrpg.controller('InviteToGroupCtrl', ['$scope', '$rootScope', 'User', 'Group function _resetInvitees() { var emptyEmails = [{name:"",email:""},{name:"",email:""}]; var emptyInvitees = [{uuid: ''}]; + $scope.emails = emptyEmails; $scope.invitees = emptyInvitees; } diff --git a/website/client/js/controllers/memberModalCtrl.js b/website/public/js/controllers/memberModalCtrl.js similarity index 50% rename from website/client/js/controllers/memberModalCtrl.js rename to website/public/js/controllers/memberModalCtrl.js index 6e8b96d0ae..54f0bb054a 100644 --- a/website/client/js/controllers/memberModalCtrl.js +++ b/website/public/js/controllers/memberModalCtrl.js @@ -20,49 +20,45 @@ habitrpg }); $scope.sendPrivateMessage = function(uuid, message){ + // Don't do anything if the user somehow gets here without a message. if (!message) return; - Members.sendPrivateMessage(message, uuid) - .then(function (response) { - Notification.text(window.env.t('messageSentAlert')); - $rootScope.User.sync(); - $scope.$close(); - }); + $http.post('/api/v2/members/'+uuid+'/message',{message:message}).success(function(){ + Notification.text(window.env.t('messageSentAlert')); + $rootScope.User.sync(); + $scope.$close(); + }); }; - //@TODO: We don't send subscriptions so the structure has changed in the back. Update this when we update the views. $scope.gift = { type: 'gems', - gems: {amount: 0, fromBalance: true}, - subscription: {key: ''}, - message: '' + gems: {amount:0, fromBalance:true}, + subscription: {key:''}, + message:'' }; - $scope.sendGift = function (uuid) { - Members.transferGems($scope.gift.message, uuid, $scope.gift.gems.amount) - .then(function (response) { - Notification.text(window.env.t('sentGems')); - $rootScope.User.sync(); - $scope.$close(); - }); + $scope.sendGift = function(uuid, gift){ + $http.post('/api/v2/members/'+uuid+'/gift', gift).success(function(){ + Notification.text('Gift sent!') + $rootScope.User.sync(); + $scope.$close(); + }) }; $scope.reportAbuse = function(reporter, message, groupId) { message.flags[reporter._id] = true; - Chat.flagChatMessage(groupId, message.id) - .then(function(data){ - Notification.text(window.env.t('abuseReported')); - $scope.$close(); - }); + Chat.utils.flagChatMessage({gid: groupId, messageId: message.id}, undefined, function(data){ + Notification.text(window.env.t('abuseReported')); + $scope.$close(); + }); }; $scope.clearFlagCount = function(message, groupId) { - Chat.clearFlagCount(groupId, message.id) - .then(function(data){ - message.flagCount = 0; - Notification.text("Flags cleared"); - $scope.$close(); - }); + Chat.utils.clearFlagCount({gid: groupId, messageId: message.id}, undefined, function(data){ + message.flagCount = 0; + Notification.text("Flags cleared"); + $scope.$close(); + }); } } ]); diff --git a/website/client/js/controllers/menuCtrl.js b/website/public/js/controllers/menuCtrl.js similarity index 97% rename from website/client/js/controllers/menuCtrl.js rename to website/public/js/controllers/menuCtrl.js index 761c118986..5549c527dd 100644 --- a/website/client/js/controllers/menuCtrl.js +++ b/website/public/js/controllers/menuCtrl.js @@ -26,7 +26,7 @@ angular.module('habitrpg') } } - $scope.clearMessages = Chat.markChatSeen; + $scope.clearMessages = Chat.seenMessage; $scope.clearCards = Chat.clearCards; $scope.iconClasses = function() { diff --git a/website/client/js/controllers/notificationCtrl.js b/website/public/js/controllers/notificationCtrl.js similarity index 97% rename from website/client/js/controllers/notificationCtrl.js rename to website/public/js/controllers/notificationCtrl.js index 16a2c3a991..ac3fab9e6a 100644 --- a/website/client/js/controllers/notificationCtrl.js +++ b/website/public/js/controllers/notificationCtrl.js @@ -68,7 +68,7 @@ habitrpg.controller('NotificationCtrl', } $rootScope.$watch('user.stats.lvl', function(after, before) { - if (after <= before) return; + if (after <= before) return; Notification.lvl(); $rootScope.playSound('Level_Up'); if (User.user._tmp && User.user._tmp.drop && (User.user._tmp.drop.type === 'Quest')) return; @@ -127,7 +127,7 @@ habitrpg.controller('NotificationCtrl', Notification.drop(env.t('messageDropFood', {dropArticle: after.article, dropText: text, dropNotes: notes}), after); } else if (after.type === 'Quest') { $rootScope.selectedQuest = Content.quests[after.key]; - $rootScope.openModal('questDrop', {controller:'PartyCtrl', size:'sm'}); + $rootScope.openModal('questDrop', {controller:'PartyCtrl',size:'sm'}); } else if (after.notificationType === 'Mystery') { text = Content.gear.flat[after.key].text(); Notification.drop(env.t('messageDropMysteryItem', {dropText: text}), after); @@ -180,13 +180,9 @@ habitrpg.controller('NotificationCtrl', $rootScope.openModal('questInvitation', {controller:'PartyCtrl'}); }); - $rootScope.$on('responseError500', function(ev, error){ + $rootScope.$on('responseError', function(ev, error){ Notification.error(error); }); - $rootScope.$on('responseError', function(ev, error){ - Notification.error(error, true); - }); - $rootScope.$on('responseText', function(ev, error){ Notification.text(error); }); diff --git a/website/public/js/controllers/partyCtrl.js b/website/public/js/controllers/partyCtrl.js new file mode 100644 index 0000000000..6494679911 --- /dev/null +++ b/website/public/js/controllers/partyCtrl.js @@ -0,0 +1,173 @@ +'use strict'; + +habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','Challenges','$state','$compile','Analytics','Quests','Social', + function($rootScope,$scope,Groups,Chat,User,Challenges,$state,$compile,Analytics,Quests,Social) { + + var user = User.user; + + $scope.type = 'party'; + $scope.text = window.env.t('party'); + $scope.group = $rootScope.party = Groups.party(); + $scope.newGroup = new Groups.Group({type:'party'}); + $scope.inviteOrStartParty = Groups.inviteOrStartParty; + $scope.loadWidgets = Social.loadWidgets; + + if ($state.is('options.social.party')) { + $scope.group.$syncParty(); // Sync party automatically when navigating to party page + + // Checks if user's party has reached 2 players for the first time. + if(!user.achievements.partyUp + && $scope.group.memberCount >= 2) { + User.set({'achievements.partyUp':true}); + $rootScope.openModal('achievements/partyUp', {controller:'UserCtrl', size:'sm'}); + } + + // Checks if user's party has reached 4 players for the first time. + if(!user.achievements.partyOn + && $scope.group.memberCount >= 4) { + User.set({'achievements.partyOn':true}); + $rootScope.openModal('achievements/partyOn', {controller:'UserCtrl', size:'sm'}); + } + } + + Chat.seenMessage($scope.group._id); + + $scope.create = function(group){ + if (!group.name) group.name = env.t('possessiveParty', {name: User.user.profile.name}); + group.$save(function(){ + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':true,'groupType':'party','privacy':'private'}); + Analytics.updateUser({'partyID':group.id,'partySize':1}); + $rootScope.hardRedirect('/#/options/groups/party'); + }); + }; + + $scope.join = function(party){ + var group = new Groups.Group({_id: party.id, name: party.name}); + group.$join(function(){ + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'join group','owner':false,'groupType':'party','privacy':'private'}); + Analytics.updateUser({'partyID':party.id}); + $rootScope.hardRedirect('/#/options/groups/party'); + }); + }; + + // TODO: refactor guild and party leave into one function + $scope.leave = function(keep) { + if (keep == 'cancel') { + $scope.selectedGroup = undefined; + $scope.popoverEl.popover('destroy'); + } else { + Groups.Group.leave({gid: $scope.selectedGroup._id, keep:keep}, undefined, function(){ + Analytics.updateUser({'partySize':null,'partyID':null}); + $rootScope.hardRedirect('/#/options/groups/party'); + }); + } + }; + + // TODO: refactor guild and party clickLeave into one function + $scope.clickLeave = function(group, $event){ + Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Leave Party'}); + $scope.selectedGroup = group; + $scope.popoverEl = $($event.target).closest('.btn'); + var html, title; + Challenges.Challenge.query(function(challenges) { + challenges = _.pluck(_.filter(challenges, function(c) { + return c.group._id == group._id; + }), '_id'); + if (_.intersection(challenges, User.user.challenges).length > 0) { + html = $compile( + '' + window.env.t('removeTasks') + '
\n' + window.env.t('keepTasks') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leavePartyCha'); + } else { + html = $compile( + '' + window.env.t('confirm') + '
\n' + window.env.t('cancel') + '
' + )($scope); + title = window.env.t('leaveParty'); + } + $scope.popoverEl.popover('destroy').popover({ + html: true, + placement: 'top', + trigger: 'manual', + title: title, + content: html + }).popover('show'); + }); + }; + + $scope.clickStartQuest = function(){ + Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Start a Quest'}); + var hasQuests = _.find(User.user.items.quests, function(quest) { + return quest > 0; + }); + + if (hasQuests){ + $rootScope.openModal("ownedQuests", { controller:"InventoryCtrl" }); + } else { + $rootScope.$state.go('options.inventory.quests'); + } + }; + + $scope.leaveOldPartyAndJoinNewParty = function(newPartyId, newPartyName) { + if (confirm('Are you sure you want to delete your party and join ' + newPartyName + '?')) { + Groups.Group.leave({gid: Groups.party()._id, keep:false}, undefined, function() { + $scope.group = { + loadingNewParty: true + }; + $scope.join({ id: newPartyId, name: newPartyName }); + }); + } + } + + $scope.reject = function(){ + User.set({'invitations.party':{}}); + } + + $scope.questCancel = function(){ + if (!confirm(window.env.t('sureCancel'))) return; + + Quests.sendAction('questCancel') + .then(function(quest) { + $scope.group.quest = quest; + }); + } + + $scope.questAbort = function(){ + if (!confirm(window.env.t('sureAbort'))) return; + if (!confirm(window.env.t('doubleSureAbort'))) return; + + Quests.sendAction('questAbort') + .then(function(quest) { + $scope.group.quest = quest; + }); + } + + $scope.questLeave = function(){ + if (!confirm(window.env.t('sureLeave'))) return; + + Quests.sendAction('questLeave') + .then(function(quest) { + $scope.group.quest = quest; + }); + } + + $scope.questAccept = function(){ + Quests.sendAction('questAccept') + .then(function(quest) { + $scope.group.quest = quest; + }); + }; + + $scope.questReject = function(){ + Quests.sendAction('questReject') + .then(function(quest) { + $scope.group.quest = quest; + }); + }; + + $scope.canEditQuest = function(party) { + var isQuestLeader = party.quest && party.quest.leader === User.user._id; + + return isQuestLeader; + }; + } + ]); diff --git a/website/client/js/controllers/rootCtrl.js b/website/public/js/controllers/rootCtrl.js similarity index 86% rename from website/client/js/controllers/rootCtrl.js rename to website/public/js/controllers/rootCtrl.js index b4fee4076a..eacc0d2cce 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/public/js/controllers/rootCtrl.js @@ -3,8 +3,8 @@ /* Make user and settings available for everyone through root scope. */ -habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrl', 'Payments','$sce','$window','Analytics','TAVERN_ID', - function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window, Analytics, TAVERN_ID) { +habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrl', 'Payments','$sce','$window','Analytics', + function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window, Analytics) { var user = User.user; var initSticky = _.once(function(){ @@ -21,11 +21,10 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ if (!!fromState.name) Analytics.track({'hitType':'pageview','eventCategory':'navigation','eventAction':'navigate','page':'/#/'+toState.name}); // clear inbox when entering or exiting inbox tab if (fromState.name=='options.social.inbox' || toState.name=='options.social.inbox') { - User.clearNewMessages(); + User.user.ops.update && User.set({'inbox.newMessages':0}); } }); - $rootScope.TAVERN_ID = TAVERN_ID; $rootScope.User = User; $rootScope.user = user; $rootScope.moment = window.moment; @@ -219,11 +218,11 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ key: itemKey }; - User.equip({ params: equipParams }); + user.ops.equip({ params: equipParams }); } $rootScope.purchase = function(type, item){ - if (type == 'special') return User.buySpecialSpell({params:{key:item.key}}); + if (type == 'special') return user.ops.buySpecialSpell({params:{key:item.key}}); var gems = user.balance * 4; var price = item.value; @@ -249,7 +248,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ message += window.env.t('buyThis', {text: itemName, price: price, gems: gems}); if ($window.confirm(message)) - User.purchase({params:{type:type,key:item.key}}); + user.ops.purchase({params:{type:type,key:item.key}}); }; function _canBuyEquipment(itemKey) { @@ -278,51 +277,31 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ if (spell.target == 'self') { $scope.castEnd(null, 'self'); } else if (spell.target == 'party') { - Groups.party() - .then(function (party) { - party = (_.isArray(party) ? party : []).concat(User.user); - $scope.castEnd(party, 'party'); - }) - .catch(function (party) { // not in a party, act as a solo party - if (party && party.type === 'party') { - party = [User.user]; - $scope.castEnd(party, 'party'); - } - }); - } else if (spell.target == 'tasks') { - var tasks = User.user.habits.concat(User.user.dailys).concat(User.user.rewards).concat(User.user.todos); - // exclude challenge tasks - tasks = tasks.filter(function (task) { - if (!task.challenge) return true; - return (!task.challenge.id || task.challenge.broken); - }); - $scope.castEnd(tasks, 'tasks'); + var party = Groups.party(); + party = (_.isArray(party) ? party : []).concat(User.user); + $scope.castEnd(party, 'party'); } } $scope.castEnd = function(target, type, $event){ if (!$rootScope.applyingAction) return 'No applying action'; $event && ($event.stopPropagation(),$event.preventDefault()); - if ($scope.spell.target != type) return Notification.text(window.env.t('invalidTarget')); $scope.spell.cast(User.user, target); User.save(); var spell = $scope.spell; - var targetId = target ? target._id : null; + var targetId = (type == 'party' || type == 'self') ? '' : type == 'task' ? target.id : target._id; $scope.spell = null; $rootScope.applyingAction = false; - var spellUrl = ApiUrl.get() + '/api/v3/user/class/cast/' + spell.key; - if (targetId) spellUrl += '?targetId=' + targetId; - - $http.post(spellUrl) - .success(function(){ // TODO response will always include the modified data, no need to sync! + $http.post(ApiUrl.get() + '/api/v2/user/class/cast/'+spell.key+'?targetType='+type+'&targetId='+targetId) + .success(function(){ var msg = window.env.t('youCast', {spell: spell.text()}); switch (type) { - case 'task': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.text});break; - case 'user': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.profile.name});break; - case 'party': msg = window.env.t('youCastParty', {spell: spell.text()});break; + case 'task': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.text});break; + case 'user': msg = window.env.t('youCastTarget', {spell: spell.text(), target: target.profile.name});break; + case 'party': msg = window.env.t('youCastParty', {spell: spell.text()});break; } Notification.markdown(msg); User.sync(); diff --git a/website/client/js/controllers/settingsCtrl.js b/website/public/js/controllers/settingsCtrl.js similarity index 89% rename from website/client/js/controllers/settingsCtrl.js rename to website/public/js/controllers/settingsCtrl.js index 7e78a8b095..3672a4d703 100644 --- a/website/client/js/controllers/settingsCtrl.js +++ b/website/public/js/controllers/settingsCtrl.js @@ -77,11 +77,14 @@ habitrpg.controller('SettingsCtrl', }; $scope.saveDayStart = function() { - User.setCustomDayStart(Math.floor($scope.dayStart)); + User.set({ + 'preferences.dayStart': Math.floor($scope.dayStart), + 'lastCron': +new Date + }); }; $scope.language = window.env.language; - $scope.availableLanguages = window.env.availableLanguages; + $scope.avalaibleLanguages = window.env.avalaibleLanguages; $scope.changeLanguage = function(){ $rootScope.$on('userSynced', function(){ @@ -96,7 +99,7 @@ habitrpg.controller('SettingsCtrl', $scope.popoverEl.popover('destroy'); if (confirm) { - User.reroll({}); + User.user.ops.reroll({}); $rootScope.$state.go('tasks'); } } @@ -121,7 +124,7 @@ habitrpg.controller('SettingsCtrl', $scope.popoverEl.popover('destroy'); if (confirm) { - User.rebirth({}); + User.user.ops.rebirth({}); $rootScope.$state.go('tasks'); } } @@ -143,7 +146,7 @@ habitrpg.controller('SettingsCtrl', } $scope.changeUser = function(attr, updates){ - $http.put(ApiUrl.get() + '/api/v3/user/auth/update-'+attr, updates) + $http.post(ApiUrl.get() + '/api/v2/user/change-'+attr, updates) .success(function(){ alert(window.env.t(attr+'Success')); _.each(updates, function(v,k){updates[k]=null;}); @@ -172,25 +175,21 @@ habitrpg.controller('SettingsCtrl', } $scope.reset = function(){ - User.reset({}); - User.sync(); + User.user.ops.reset({}); $rootScope.$state.go('tasks'); } - $scope['delete'] = function(password) { - $http({ - url: ApiUrl.get() + '/api/v3/user', - method: 'DELETE', - data: {password: password}, - }) - .then(function(res, code) { - localStorage.clear(); - window.location.href = '/logout'; - }); + $scope['delete'] = function(){ + $http['delete'](ApiUrl.get() + '/api/v2/user') + .success(function(res, code){ + if (res.err) return alert(res.err); + localStorage.clear(); + window.location.href = '/logout'; + }); } $scope.enterCoupon = function(code) { - $http.post(ApiUrl.get() + '/api/v3/coupons/enter/' + code).success(function(res,code){ + $http.post(ApiUrl.get() + '/api/v2/user/coupon/' + code).success(function(res,code){ if (code!==200) return; User.sync(); Notification.text(env.t('promoCodeApplied')); @@ -202,7 +201,7 @@ habitrpg.controller('SettingsCtrl', .success(function(res,code){ $scope._codes = {}; if (code!==200) return; - window.location.href = '/api/v2/coupons?limit='+codes.count+'&_id='+User.user._id+'&apiToken='+User.settings.auth.apiToken; + window.location.href = '/api/v2/coupons?limit='+codes.count+'&_id='+User.user._id+'&apiToken='+User.user.apiToken; }) } @@ -236,7 +235,7 @@ habitrpg.controller('SettingsCtrl', var releaseFunction = RELEASE_ANIMAL_TYPES[type]; if (releaseFunction) { - User[releaseFunction]({}); + User.user.ops[releaseFunction]({}); $rootScope.$state.go('tasks'); } } @@ -247,19 +246,19 @@ habitrpg.controller('SettingsCtrl', $scope.hasWebhooks = _.size(webhooks); }) $scope.addWebhook = function(url) { - User.addWebhook({body:{url:url, id:Shared.uuid()}}); + User.user.ops.addWebhook({body:{url:url, id:Shared.uuid()}}); $scope._newWebhook.url = ''; } $scope.saveWebhook = function(id,webhook) { delete webhook._editing; - User.updateWebhook({params:{id:id}, body:webhook}); + User.user.ops.updateWebhook({params:{id:id}, body:webhook}); } $scope.deleteWebhook = function(id) { - User.deleteWebhook({params:{id:id}}); + User.user.ops.deleteWebhook({params:{id:id}}); } $scope.applyCoupon = function(coupon){ - $http.get(ApiUrl.get() + '/api/v3/coupons/validate/'+coupon) + $http.get(ApiUrl.get() + '/api/v2/coupons/valid-discount/'+coupon) .success(function(){ Notification.text("Coupon applied!"); var subs = Content.subscriptionBlocks; diff --git a/website/client/js/controllers/sortableInventoryCtrl.js b/website/public/js/controllers/sortableInventoryCtrl.js similarity index 100% rename from website/client/js/controllers/sortableInventoryCtrl.js rename to website/public/js/controllers/sortableInventoryCtrl.js diff --git a/website/client/js/controllers/tasksCtrl.js b/website/public/js/controllers/tasksCtrl.js similarity index 71% rename from website/client/js/controllers/tasksCtrl.js rename to website/public/js/controllers/tasksCtrl.js index fb44d19cb3..619b0a8b3f 100644 --- a/website/client/js/controllers/tasksCtrl.js +++ b/website/public/js/controllers/tasksCtrl.js @@ -24,23 +24,20 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N if (direction === 'down') $rootScope.playSound('Minus_Habit'); else if (direction === 'up') $rootScope.playSound('Plus_Habit'); } - User.score({params:{task: task, direction:direction}}); + User.user.ops.score({params:{id: task.id, direction:direction}}); Analytics.updateUser(); Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'score task','taskType':task.type,'direction':direction}); }; - function addTask(addTo, listDef, tasks) { - tasks = _.isArray(tasks) ? tasks : [tasks]; - - User.addTask({ - body: tasks.map(function (task) { - return { - text: task, - type: listDef.type, - tags: _.keys(User.user.filters), - } - }), - }); + function addTask(addTo, listDef, task) { + var newTask = { + text: task, + type: listDef.type, + tags: _.transform(User.user.filters, function(m,v,k){ + if (v) m[k]=v; + }) + }; + User.user.ops.addTask({body:newTask}); } $scope.addTask = function(addTo, listDef) { @@ -48,7 +45,9 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N var tasks = listDef.newTask.split(/[\n\r]+/); //Reverse the order of tasks so the tasks will appear in the order the user entered them tasks.reverse(); - addTask(addTo, listDef, tasks); + _.each(tasks, function(t) { + addTask(addTo, listDef, t); + }); listDef.bulk = false; } else { addTask(addTo, listDef, listDef.newTask); @@ -71,16 +70,14 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N /** * Add the new task to the actions log */ - $scope.clearDoneTodos = function() { - Tasks.clearCompletedTodos(); - }; + $scope.clearDoneTodos = function() {}; /** * Pushes task to top or bottom of list */ $scope.pushTask = function(task, index, location) { var to = (location === 'bottom' || $scope.ctrlPressed) ? -1 : 0; - User.sortTask({params:{id: task._id, taskType: task.type}, query:{from:index, to:to}}) + User.user.ops.sortTask({params:{id:task.id},query:{from:index, to:to}}) }; /** @@ -96,22 +93,16 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $scope.removeTask = function(task) { if (!confirm(window.env.t('sureDelete', {taskType: window.env.t(task.type), taskText: task.text}))) return; - User.deleteTask({params:{id: task._id, taskType: task.type}}) + User.user.ops.deleteTask({params:{id:task.id}}) }; $scope.saveTask = function(task, stayOpen, isSaveAndClose) { - if (task.checklist) { - task.checklist = _.filter(task.checklist, function (i) { - return !!i.text - }); - } - User.updateTask(task, {body: task}); + if (task.checklist) + task.checklist = _.filter(task.checklist,function(i){return !!i.text}); + User.user.ops.updateTask({params:{id:task.id},body:task}); if (!stayOpen) task._editing = false; - - if (isSaveAndClose) { - $("#task-" + task._id).parent().children('.popover').removeClass('in'); - } - + if (isSaveAndClose) + $("#task-" + task.id).parent().children('.popover').removeClass('in'); if (task.type == 'habit') Guide.goto('intro', 3); }; @@ -129,17 +120,11 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N }; $scope.unlink = function(task, keep) { - if (keep.search('-all') !== -1) { // unlink all tasks - Tasks.unlinkAllTasks(task.challenge.id, keep) - .success(function () { - User.sync({}); - }); - } else { // unlink a task - Tasks.unlinkOneTask(task._id, keep) - .success(function () { - User.sync({}); - }); - } + // TODO move this to userServices, turn userSerivces.user into ng-resource + $http.post(ApiUrl.get() + '/api/v2/user/tasks/' + task.id + '/unlink?keep=' + keep) + .success(function(){ + User.log({}); + }); }; /* @@ -149,16 +134,6 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N */ $scope._today = moment().add({days: 1}); - $scope.loadedCompletedTodos = function () { - if (Tasks.loadedCompletedTodos === true) return; - - Tasks.getUserTasks(true) - .then(function (response) { - User.user.todos = User.user.todos.concat(response.data.data); - Tasks.loadedCompletedTodos = true; - }); - } - /* ------------------------ Dailies @@ -179,60 +154,53 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N */ function focusChecklist(task,index) { window.setTimeout(function(){ - $('#task-'+task._id+' .checklist-form input[type="text"]')[index].focus(); + $('#task-'+task.id+' .checklist-form input[type="text"]')[index].focus(); }); } - $scope.addChecklist = function(task) { - task.checklist = [{completed:false, text:""}]; + task.checklist = [{completed:false,text:""}]; focusChecklist(task,0); } - - $scope.addChecklistItem = function(task, $event, $index) { + $scope.addChecklistItem = function(task,$event,$index) { if (!task.checklist[$index].text) { // Don't allow creation of an empty checklist item // TODO Provide UI feedback that this item is still blank - } else if ($index == task.checklist.length - 1) { - Tasks.addChecklistItem(task._id, task.checklist[$index]); + } else if ($index == task.checklist.length-1){ + User.user.ops.updateTask({params:{id:task.id},body:task}); // don't preen the new empty item task.checklist.push({completed:false,text:''}); focusChecklist(task,task.checklist.length-1); } else { - $scope.saveTask(task, true); - focusChecklist(task, $index + 1); + $scope.saveTask(task,true); + focusChecklist(task,$index+1); } } - - $scope.removeChecklistItem = function(task, $event, $index, force){ + $scope.removeChecklistItem = function(task,$event,$index,force){ // Remove item if clicked on trash icon if (force) { - Tasks.removeChecklistItem(task._id, task.checklist[$index].id); - task.checklist.splice($index, 1); + task.checklist.splice($index,1); + $scope.saveTask(task,true); } else if (!task.checklist[$index].text) { // User deleted all the text and is now wishing to delete the item // saveTask will prune the empty item - Tasks.removeChecklistItem(task._id, task.checklist[$index].id); + $scope.saveTask(task,true); // Move focus if the list is still non-empty if ($index > 0) - focusChecklist(task, $index-1); + focusChecklist(task,$index-1); // Don't allow the backspace key to navigate back now that the field is gone $event.preventDefault(); } } - $scope.swapChecklistItems = function(task, oldIndex, newIndex) { var toSwap = task.checklist.splice(oldIndex, 1)[0]; task.checklist.splice(newIndex, 0, toSwap); $scope.saveTask(task, true); } - $scope.navigateChecklist = function(task,$index,$event){ focusChecklist(task, $event.keyCode == '40' ? $index+1 : $index-1); } - $scope.checklistCompletion = function(checklist){ return _.reduce(checklist,function(m,i){return m+(i.completed ? 1 : 0);},0) } - $scope.collapseChecklist = function(task) { task.collapseChecklist = !task.collapseChecklist; $scope.saveTask(task,true); @@ -253,9 +221,10 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $scope.buy = function(item) { playRewardSound(item); - User.buy({params:{key:item.key}}); + User.user.ops.buy({params:{key:item.key}}); }; + /* ------------------------ Hiding Tasks @@ -289,21 +258,4 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N $rootScope.playSound('Reward'); } } - - /* - ------------------------ - Tags - ------------------------ - */ - - $scope.updateTaskTags = function (tagId, task) { - var tagIndex = task.tags.indexOf(tagId); - if (tagIndex === -1) { - Tasks.addTagToTask(task._id, tagId); - task.tags.push(tagId); - } else { - Tasks.removeTagFromTask(task._id, tagId); - task.tags.splice(tagIndex, 0); - } - } }]); diff --git a/website/public/js/controllers/tavernCtrl.js b/website/public/js/controllers/tavernCtrl.js new file mode 100644 index 0000000000..995fa22ad0 --- /dev/null +++ b/website/public/js/controllers/tavernCtrl.js @@ -0,0 +1,10 @@ +'use strict'; + +habitrpg.controller("TavernCtrl", ['$scope', 'Groups', 'User', + function($scope, Groups, User) { + $scope.group = Groups.tavern(); + $scope.toggleUserTier = function($event) { + $($event.target).next().toggle(); + } + } + ]); diff --git a/website/client/js/controllers/userCtrl.js b/website/public/js/controllers/userCtrl.js similarity index 90% rename from website/client/js/controllers/userCtrl.js rename to website/public/js/controllers/userCtrl.js index b62207b945..e345d8ba2e 100644 --- a/website/client/js/controllers/userCtrl.js +++ b/website/public/js/controllers/userCtrl.js @@ -17,17 +17,17 @@ habitrpg.controller("UserCtrl", ['$rootScope', '$scope', '$location', 'User', '$ }); $scope.allocate = function(stat){ - User.allocate({query:{stat:stat}}); + User.user.ops.allocate({query:{stat:stat}}); } $scope.changeClass = function(klass){ if (!klass) { if (!confirm(window.env.t('sureReset'))) return; - return User.changeClass({}); + return User.user.ops.changeClass({}); } - User.changeClass({query:{class:klass}}); + User.user.ops.changeClass({query:{class:klass}}); $scope.selectedClass = undefined; Shared.updateStore(User.user); Guide.goto('classes', 0,true); @@ -46,7 +46,7 @@ habitrpg.controller("UserCtrl", ['$rootScope', '$scope', '$location', 'User', '$ } $scope.acknowledgeHealthWarning = function(){ - User.set({'flags.warnedLowHealth':true}); + User.user.ops.update && User.set({'flags.warnedLowHealth':true}); } /** @@ -69,7 +69,7 @@ habitrpg.controller("UserCtrl", ['$rootScope', '$scope', '$location', 'User', '$ if (confirm(window.env.t('purchaseFor',{cost:cost*4})) !== true) return; if (User.user.balance < cost) return $rootScope.openModal('buyGems'); } - User.unlock({query:{path:path}}) + User.user.ops.unlock({query:{path:path}}) } $scope.ownsSet = function(type,_set) { diff --git a/website/client/js/directives/close-menu.directive.js b/website/public/js/directives/close-menu.directive.js similarity index 100% rename from website/client/js/directives/close-menu.directive.js rename to website/public/js/directives/close-menu.directive.js diff --git a/website/client/js/directives/expand-menu.directive.js b/website/public/js/directives/expand-menu.directive.js similarity index 100% rename from website/client/js/directives/expand-menu.directive.js rename to website/public/js/directives/expand-menu.directive.js diff --git a/website/client/js/directives/focus-element.directive.js b/website/public/js/directives/focus-element.directive.js similarity index 100% rename from website/client/js/directives/focus-element.directive.js rename to website/public/js/directives/focus-element.directive.js diff --git a/website/client/js/directives/from-now.directive.js b/website/public/js/directives/from-now.directive.js similarity index 100% rename from website/client/js/directives/from-now.directive.js rename to website/public/js/directives/from-now.directive.js diff --git a/website/client/js/directives/habitrpg-tasks.directive.js b/website/public/js/directives/habitrpg-tasks.directive.js similarity index 100% rename from website/client/js/directives/habitrpg-tasks.directive.js rename to website/public/js/directives/habitrpg-tasks.directive.js diff --git a/website/client/js/directives/hrpg-sort-checklist.directive.js b/website/public/js/directives/hrpg-sort-checklist.directive.js similarity index 100% rename from website/client/js/directives/hrpg-sort-checklist.directive.js rename to website/public/js/directives/hrpg-sort-checklist.directive.js diff --git a/website/client/js/directives/hrpg-sort-tags.directive.js b/website/public/js/directives/hrpg-sort-tags.directive.js similarity index 88% rename from website/client/js/directives/hrpg-sort-tags.directive.js rename to website/public/js/directives/hrpg-sort-tags.directive.js index 9a9e3d49bb..5b42bc778f 100644 --- a/website/client/js/directives/hrpg-sort-tags.directive.js +++ b/website/public/js/directives/hrpg-sort-tags.directive.js @@ -16,10 +16,10 @@ ui.item.data('startIndex', ui.item.index()); }, stop: function (event, ui) { - User.sortTag({ + User.user.ops.sortTag({ query: { from: ui.item.data('startIndex'), - to: ui.item.index() + to:ui.item.index() } }); } diff --git a/website/client/js/directives/hrpg-sort-tasks.directive.js b/website/public/js/directives/hrpg-sort-tasks.directive.js similarity index 89% rename from website/client/js/directives/hrpg-sort-tasks.directive.js rename to website/public/js/directives/hrpg-sort-tasks.directive.js index 0ce42d82eb..820fccfbe8 100644 --- a/website/client/js/directives/hrpg-sort-tasks.directive.js +++ b/website/public/js/directives/hrpg-sort-tasks.directive.js @@ -20,8 +20,8 @@ stop: function (event, ui) { var task = angular.element(ui.item[0]).scope().task; var startIndex = ui.item.data('startIndex'); - User.sortTask({ - params: { id: task._id, taskType: task.type }, + User.user.ops.sortTask({ + params: { id: task.id }, query: { from: startIndex, to: ui.item.index() diff --git a/website/client/js/directives/popover-html-popup.directive.js b/website/public/js/directives/popover-html-popup.directive.js similarity index 100% rename from website/client/js/directives/popover-html-popup.directive.js rename to website/public/js/directives/popover-html-popup.directive.js diff --git a/website/client/js/directives/popover-html.directive.js b/website/public/js/directives/popover-html.directive.js similarity index 100% rename from website/client/js/directives/popover-html.directive.js rename to website/public/js/directives/popover-html.directive.js diff --git a/website/client/js/directives/when-scrolled.directive.js b/website/public/js/directives/when-scrolled.directive.js similarity index 100% rename from website/client/js/directives/when-scrolled.directive.js rename to website/public/js/directives/when-scrolled.directive.js diff --git a/website/client/js/env.js b/website/public/js/env.js similarity index 100% rename from website/client/js/env.js rename to website/public/js/env.js diff --git a/website/client/js/filters/money.js b/website/public/js/filters/money.js similarity index 100% rename from website/client/js/filters/money.js rename to website/public/js/filters/money.js diff --git a/website/client/js/filters/roundLargeNumbers.js b/website/public/js/filters/roundLargeNumbers.js similarity index 100% rename from website/client/js/filters/roundLargeNumbers.js rename to website/public/js/filters/roundLargeNumbers.js diff --git a/website/client/js/filters/taskOrdering.js b/website/public/js/filters/taskOrdering.js similarity index 100% rename from website/client/js/filters/taskOrdering.js rename to website/public/js/filters/taskOrdering.js diff --git a/website/client/js/filters/timezoneOffsetToUtc.js b/website/public/js/filters/timezoneOffsetToUtc.js similarity index 100% rename from website/client/js/filters/timezoneOffsetToUtc.js rename to website/public/js/filters/timezoneOffsetToUtc.js diff --git a/website/client/js/services/analyticsServices.js b/website/public/js/services/analyticsServices.js similarity index 100% rename from website/client/js/services/analyticsServices.js rename to website/public/js/services/analyticsServices.js diff --git a/website/public/js/services/challengeServices.js b/website/public/js/services/challengeServices.js new file mode 100644 index 0000000000..51b91de8c2 --- /dev/null +++ b/website/public/js/services/challengeServices.js @@ -0,0 +1,26 @@ +'use strict'; + +/** + * Services that persists and retrieves user from localStorage. + */ + +angular.module('habitrpg').factory('Challenges', +['ApiUrl', '$resource', +function(ApiUrl, $resource) { + var Challenge = $resource(ApiUrl.get() + '/api/v2/challenges/:cid', + {cid:'@_id'}, + { + //'query': {method: "GET", isArray:false} + join: {method: "POST", url: ApiUrl.get() + '/api/v2/challenges/:cid/join'}, + leave: {method: "POST", url: ApiUrl.get() + '/api/v2/challenges/:cid/leave'}, + close: {method: "POST", params: {uid:''}, url: ApiUrl.get() + '/api/v2/challenges/:cid/close'}, + getMember: {method: "GET", url: ApiUrl.get() + '/api/v2/challenges/:cid/member/:uid'} + }); + + //var challenges = []; + + return { + Challenge: Challenge + //challenges: challenges + } +}]); diff --git a/website/public/js/services/chatServices.js b/website/public/js/services/chatServices.js new file mode 100644 index 0000000000..7fe2533506 --- /dev/null +++ b/website/public/js/services/chatServices.js @@ -0,0 +1,33 @@ +'use strict'; + +angular.module('habitrpg').factory('Chat', +['$resource', '$http', 'ApiUrl', 'User', +function($resource, $http, ApiUrl, User) { + var utils = $resource(ApiUrl.get() + '/api/v2/groups/:gid', + {gid:'@_id', messageId: '@_messageId'}, + { + postChat: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/chat'}, + like: {method: 'POST', isArray: true, url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId/like'}, + deleteChatMessage: {method: "DELETE", url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId'}, + flagChatMessage: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId/flag'}, + clearFlagCount: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/chat/:messageId/clearflags'}, + }); + + var chatService = { + seenMessage: seenMessage, + clearCards: clearCards, + utils: utils + }; + + return chatService; + + function clearCards() { + User.user.ops.update && User.set({'flags.cardReceived':false}); + } + + function seenMessage(gid) { + // On enter, set chat message to "seen" + $http.post(ApiUrl.get() + '/api/v2/groups/'+gid+'/chat/seen'); + if (User.user.newMessages) delete User.user.newMessages[gid]; + } +}]); diff --git a/website/public/js/services/groupServices.js b/website/public/js/services/groupServices.js new file mode 100644 index 0000000000..001a137d63 --- /dev/null +++ b/website/public/js/services/groupServices.js @@ -0,0 +1,92 @@ +'use strict'; + +(function() { + angular + .module('habitrpg') + .factory('Groups', groupsFactory); + + groupsFactory.$inject = [ + '$location', + '$resource', + '$rootScope', + 'Analytics', + 'ApiUrl', + 'Challenges', + 'User' + ]; + + function groupsFactory($location, $resource, $rootScope, Analytics, ApiUrl, Challenges, User) { + + var data = {party: undefined, myGuilds: undefined, publicGuilds: undefined, tavern: undefined}; + var Group = $resource(ApiUrl.get() + '/api/v2/groups/:gid', + {gid:'@_id', messageId: '@_messageId'}, + { + get: { + method: "GET", + isArray:false, + // Wrap challenges as ngResource so they have functions like $leave or $join + transformResponse: function(data) { + data = angular.fromJson(data); + _.each(data && data.challenges, function(c) { + angular.extend(c, Challenges.Challenge.prototype); + }); + return data; + } + }, + + syncParty: {method: "GET", url: '/api/v2/groups/party'}, + join: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/join'}, + leave: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/leave'}, + invite: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/invite'}, + removeMember: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/removeMember'}, + startQuest: {method: "POST", url: ApiUrl.get() + '/api/v2/groups/:gid/questAccept'} + }); + + function party(cb) { + if (!data.party) return (data.party = Group.get({gid: 'party'}, cb)); + return (cb) ? cb(party) : data.party; + } + + function publicGuilds() { + //TODO combine these as {type:'guilds,public'} and create a $filter() to separate them + if (!data.publicGuilds) data.publicGuilds = Group.query({type:'public'}); + return data.publicGuilds; + } + + function myGuilds() { + if (!data.myGuilds) data.myGuilds = Group.query({type:'guilds'}); + return data.myGuilds; + } + + function tavern() { + if (!data.tavern) data.tavern = Group.get({gid:'habitrpg'}); + return data.tavern; + } + + function inviteOrStartParty(group) { + Analytics.track({'hitType':'event','eventCategory':'button','eventAction':'click','eventLabel':'Invite Friends'}); + if (group.type === "party" || $location.$$path === "/options/groups/party") { + group.type = 'party'; + $rootScope.openModal('invite-party', { + controller:'InviteToGroupCtrl', + resolve: { + injectedGroup: function(){ return group; } + } + }); + } else { + $location.path("/options/groups/party"); + } + } + + return { + party: party, + publicGuilds: publicGuilds, + myGuilds: myGuilds, + tavern: tavern, + inviteOrStartParty: inviteOrStartParty, + + data: data, + Group: Group + } + } +})(); diff --git a/website/client/js/services/guideServices.js b/website/public/js/services/guideServices.js similarity index 97% rename from website/client/js/services/guideServices.js rename to website/public/js/services/guideServices.js index 7f3ce9e499..ee0be9d2c8 100644 --- a/website/client/js/services/guideServices.js +++ b/website/public/js/services/guideServices.js @@ -241,7 +241,7 @@ function($rootScope, User, $timeout, $state, Analytics) { }); var goto = function(chapter, page, force) { - if (chapter == 'intro' && User.user.flags.welcomed != true) User.set({'flags.welcomed': true}); + if (chapter == 'intro') User.set({'flags.welcomed': true}); if (page === -1) page = 0; var curr = User.user.flags.tour[chapter]; if (page != curr+1 && !force) return; @@ -264,8 +264,8 @@ function($rootScope, User, $timeout, $state, Analytics) { } //Init and show the welcome tour (only after user is pulled from server & wrapped). - var watcher = $rootScope.$watch('User.user._wrapped', function(wrapped){ - if (!wrapped) return; // only run after user has been wrapped + var watcher = $rootScope.$watch('User.user.ops.update', function(updateFn){ + if (!updateFn) return; // only run after user has been wrapped watcher(); // deregister watcher if (window.env.IS_MOBILE) return; // Don't show tour immediately on mobile devices if (User.user.flags.welcomed == false) { diff --git a/website/public/js/services/memberServices.js b/website/public/js/services/memberServices.js new file mode 100644 index 0000000000..4146a4ea1f --- /dev/null +++ b/website/public/js/services/memberServices.js @@ -0,0 +1,60 @@ +'use strict'; +(function(){ + angular + .module('habitrpg') + .factory('Members', membersFactory); + + membersFactory.$inject = [ + '$rootScope', + 'Shared', + 'ApiUrl', + '$resource' + ]; + + function membersFactory($rootScope, Shared, ApiUrl, $resource) { + var members = {}; + var fetchMember = $resource(ApiUrl.get() + '/api/v2/members/:uid', { uid: '@_id' }).get; + + function selectMember(uid, cb) { + + var self = this; + var memberIsReady = _checkIfMemberIsReady(members[uid]); + + if (memberIsReady) { + _prepareMember(self, members[uid], cb); + } else { + fetchMember({ uid: uid }, function(member) { + addToMembersList(member); // lazy load for later + _prepareMember(self, member, cb); + }); + } + } + + function addToMembersList(member){ + if (member._id) { + members[member._id] = member; + } + } + + function _checkIfMemberIsReady(member) { + return member && member.items && member.items.weapon; + } + + function _prepareMember(self, member, cb) { + Shared.wrap(member, false); + self.selectedMember = members[member._id]; + cb(); + } + + $rootScope.$on('userUpdated', function(event, user){ + addToMembersList(user); + }) + + return { + members: members, + addToMembersList: addToMembersList, + selectedMember: undefined, + selectMember: selectMember + } + } +}()); diff --git a/website/client/js/services/notificationServices.js b/website/public/js/services/notificationServices.js similarity index 95% rename from website/client/js/services/notificationServices.js rename to website/public/js/services/notificationServices.js index 096c1643af..f4556f6989 100644 --- a/website/client/js/services/notificationServices.js +++ b/website/public/js/services/notificationServices.js @@ -54,8 +54,8 @@ angular.module("habitrpg").factory("Notification", _notify(_sign(val) + " " + _round(val) + " " + window.env.t('experience'), 'xp', 'glyphicon glyphicon-star'); } - function error(error, canHide){ - _notify(error, "danger", 'glyphicon glyphicon-exclamation-sign', canHide); + function error(error){ + _notify(error, "danger", 'glyphicon glyphicon-exclamation-sign'); } function gp(val, bonus) { @@ -107,14 +107,14 @@ angular.module("habitrpg").factory("Notification", // Used to stack notifications, must be outside of _notify var stack_topright = {"dir1": "down", "dir2": "left", "spacing1": 15, "spacing2": 15, "firstpos1": 60}; - function _notify(html, type, icon, canHide) { + function _notify(html, type, icon) { var notice = $.pnotify({ type: type || 'warning', //('info', 'text', 'warning', 'success', 'gp', 'xp', 'hp', 'lvl', 'death', 'mp', 'crit') text: html, opacity: 1, addclass: 'alert-' + type, delay: 7000, - hide: ((type == 'error' || type == 'danger') && !canHide) ? false : true, + hide: (type == 'error' || type == 'danger') ? false : true, mouse_reset: false, width: "250px", stack: stack_topright, diff --git a/website/client/js/services/paymentServices.js b/website/public/js/services/paymentServices.js similarity index 95% rename from website/client/js/services/paymentServices.js rename to website/public/js/services/paymentServices.js index 6797da7bf9..a3fc832d24 100644 --- a/website/client/js/services/paymentServices.js +++ b/website/public/js/services/paymentServices.js @@ -37,7 +37,7 @@ function($rootScope, User, $http, Content) { $http.post(url, res).success(function() { window.location.reload(true); }).error(function(res) { - alert(res.message); + alert(res.err); }); } }); @@ -55,7 +55,7 @@ function($rootScope, User, $http, Content) { $http.post(url, data).success(function() { window.location.reload(true); }).error(function(data) { - alert(data.message); + alert(data.err); }); } }); @@ -127,12 +127,12 @@ function($rootScope, User, $http, Content) { var url = '/amazon/createOrderReferenceId' $http.post(url, { billingAgreementId: Payments.amazonPayments.billingAgreementId - }).success(function(res){ + }).success(function(data){ Payments.amazonPayments.loggedIn = true; - Payments.amazonPayments.orderReferenceId = res.data.orderReferenceId; + Payments.amazonPayments.orderReferenceId = data.orderReferenceId; Payments.amazonPayments.initWidgets(); }).error(function(res){ - alert(res.message); + alert(res.err); }); } }, @@ -146,7 +146,7 @@ function($rootScope, User, $http, Content) { var url = '/amazon/verifyAccessToken' $http.post(url, response).error(function(res){ - alert(res.message); + alert(res.err); }); }); }, @@ -232,7 +232,7 @@ function($rootScope, User, $http, Content) { Payments.amazonPayments.reset(); window.location.reload(true); }).error(function(res){ - alert(res.message); + alert(res.err); Payments.amazonPayments.reset(); }); }else if(Payments.amazonPayments.type === 'subscription'){ @@ -246,7 +246,7 @@ function($rootScope, User, $http, Content) { Payments.amazonPayments.reset(); window.location.reload(true); }).error(function(res){ - alert(res.message); + alert(res.err); Payments.amazonPayments.reset(); }); } @@ -262,7 +262,7 @@ function($rootScope, User, $http, Content) { paymentMethod = paymentMethod.toLowerCase(); } - window.location.href = '/' + paymentMethod + '/subscribe/cancel?_id=' + User.user._id + '&apiToken=' + User.settings.auth.apiToken; + window.location.href = '/' + paymentMethod + '/subscribe/cancel?_id=' + User.user._id + '&apiToken=' + User.user.apiToken; } Payments.encodeGift = function(uuid, gift){ diff --git a/website/client/js/services/questServices.js b/website/public/js/services/questServices.js similarity index 80% rename from website/client/js/services/questServices.js rename to website/public/js/services/questServices.js index 5033bad33d..a69ae800d8 100644 --- a/website/client/js/services/questServices.js +++ b/website/public/js/services/questServices.js @@ -1,16 +1,25 @@ 'use strict'; -angular.module('habitrpg') -.factory('Quests', ['$http', '$state','$q', 'ApiUrl', 'Content', 'Groups', 'User', 'Analytics', +(function(){ + angular + .module('habitrpg') + .factory('Quests', questsFactory); + + questsFactory.$inject = [ + '$http', + '$state', + '$q', + 'ApiUrl', + 'Content', + 'Groups', + 'User', + 'Analytics' + ]; + function questsFactory($http, $state, $q, ApiUrl, Content, Groups, User, Analytics) { var user = User.user; - var party; - - Groups.party() - .then(function (partyFound) { - party = partyFound; - }); + var party = Groups.party(); function lockQuest(quest,ignoreLevel) { if (!ignoreLevel){ @@ -97,21 +106,20 @@ angular.module('habitrpg') function initQuest(key) { return $q(function(resolve, reject) { - Analytics.track({'hitType':'event', 'eventCategory':'behavior', 'eventAction':'quest', 'owner':true, 'response':'accept', 'questName': key}); - Analytics.updateUser({'partyID': party._id, 'partySize': party.memberCount}); - Groups.Group.inviteToQuest(party._id, key) - .then(function(response) { - party.quest = response.data.data; - Groups.data.party = party; - $state.go('options.social.party'); - resolve(); - }); + Analytics.track({'hitType':'event','eventCategory':'behavior','eventAction':'quest','owner':true,'response':'accept','questName': key}); + Analytics.updateUser({'partyID':party._id,'partySize':party.memberCount}); + party.$startQuest({key:key}, function(){ + party.$syncParty(); + $state.go('options.social.party'); + resolve(); + }); }); } function sendAction(action) { return $q(function(resolve, reject) { - $http.post(ApiUrl.get() + '/api/v3/groups/' + party._id + '/' + action) + + $http.post(ApiUrl.get() + '/api/v2/groups/' + party._id + '/' + action) .then(function(response) { User.sync(); @@ -121,7 +129,6 @@ angular.module('habitrpg') }); var quest = response.data.quest; - if (!quest) quest = response.data.data; resolve(quest); });; }); @@ -135,4 +142,5 @@ angular.module('habitrpg') showQuest: showQuest, initQuest: initQuest } - }]); + } +}()); diff --git a/website/client/js/services/sharedServices.js b/website/public/js/services/sharedServices.js similarity index 100% rename from website/client/js/services/sharedServices.js rename to website/public/js/services/sharedServices.js diff --git a/website/client/js/services/socialServices.js b/website/public/js/services/socialServices.js similarity index 100% rename from website/client/js/services/socialServices.js rename to website/public/js/services/socialServices.js diff --git a/website/client/js/services/statServices.js b/website/public/js/services/statServices.js similarity index 94% rename from website/client/js/services/statServices.js rename to website/public/js/services/statServices.js index b716dbcaf7..f2db805fb6 100644 --- a/website/client/js/services/statServices.js +++ b/website/public/js/services/statServices.js @@ -22,7 +22,7 @@ } function classBonus(user, stat) { - var computedStats = (user.fns && user.fns.statsComputed) ? user.fns.statsComputed() : null; + var computedStats = user._statsComputed; if(computedStats) { var bonus = computedStats[stat] @@ -95,7 +95,7 @@ function mpDisplay(user) { var remainingMP = Math.floor(user.stats.mp); - var totalMP = (user.fns && user.fns.statsComputed) ? user.fns.statsComputed().maxMP : null; + var totalMP = user._statsComputed.maxMP; var display = _formatOutOfTotalDisplay(remainingMP, totalMP); return display; diff --git a/website/public/js/services/taskServices.js b/website/public/js/services/taskServices.js new file mode 100644 index 0000000000..bbe7f6f689 --- /dev/null +++ b/website/public/js/services/taskServices.js @@ -0,0 +1,53 @@ +'use strict'; + +(function(){ + var TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'dateCreated', 'history', 'id', 'streak']; + + angular + .module('habitrpg') + .factory('Tasks', tasksFactory); + + tasksFactory.$inject = [ + '$rootScope', + 'Shared', + 'User' + ]; + + function tasksFactory($rootScope, Shared, User) { + + function editTask(task) { + task._editing = !task._editing; + task._tags = !User.user.preferences.tagsCollapsed; + task._advanced = !User.user.preferences.advancedCollapsed; + if($rootScope.charts[task.id]) $rootScope.charts[task.id] = false; + } + + function cloneTask(task) { + var clonedTask = _.cloneDeep(task); + clonedTask = _cleanUpTask(clonedTask); + + return Shared.taskDefaults(clonedTask); + } + + function _cleanUpTask(task) { + var cleansedTask = _.omit(task, TASK_KEYS_TO_REMOVE); + + // Copy checklists but reset to uncomplete and assign new id + _(cleansedTask.checklist).forEach(function(item) { + item.completed = false; + item.id = Shared.uuid(); + }).value(); + + if (cleansedTask.type !== 'reward') { + delete cleansedTask.value; + } + + return cleansedTask; + } + + return { + editTask: editTask, + cloneTask: cloneTask + }; + } +})(); diff --git a/website/client/js/static.js b/website/public/js/static.js similarity index 77% rename from website/client/js/static.js rename to website/public/js/static.js index c8af2adf27..67a07df815 100644 --- a/website/client/js/static.js +++ b/website/public/js/static.js @@ -6,21 +6,18 @@ window.habitrpg = angular.module('habitrpg', ['chieffancypants.loadingBar', 'ui. .constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings') .constant("MOBILE_APP", false) -.controller("RootCtrl", ['$scope', '$location', '$modal', '$http', 'Stats', 'Members', - function($scope, $location, $modal, $http, Stats, Members) { +.controller("RootCtrl", ['$scope', '$location', '$modal', '$http', 'Stats', function($scope, $location, $modal, $http, Stats){ var memberId = $location.search()['memberId']; if (memberId) { - Members.fetchMember(memberId) - .success(function(response) { - $scope.profile = response.data; - - $scope.statCalc = Stats; - $scope.Content = window.habitrpgShared.content; - $modal.open({ - templateUrl: 'modals/member.html', - scope: $scope - }); + $http.get('/api/v2/members/'+memberId).success(function(data, status, headers, config){ + $scope.profile = window.habitrpgShared.wrap(data, false); + $scope.statCalc = Stats; + $scope.Content = window.habitrpgShared.content; + $modal.open({ + templateUrl: 'modals/member.html', + scope: $scope }); + }) } $http.defaults.headers.common['x-client'] = 'habitica-web'; diff --git a/website/client/logo.png b/website/public/logo.png similarity index 100% rename from website/client/logo.png rename to website/public/logo.png diff --git a/website/client/logo/HABITRPG logo version 1.psd b/website/public/logo/HABITRPG logo version 1.psd similarity index 100% rename from website/client/logo/HABITRPG logo version 1.psd rename to website/public/logo/HABITRPG logo version 1.psd diff --git a/website/client/logo/HABITRPG-logo-version-1.gif b/website/public/logo/HABITRPG-logo-version-1.gif similarity index 100% rename from website/client/logo/HABITRPG-logo-version-1.gif rename to website/public/logo/HABITRPG-logo-version-1.gif diff --git a/website/client/logo/habitrpg.jpg b/website/public/logo/habitrpg.jpg similarity index 100% rename from website/client/logo/habitrpg.jpg rename to website/public/logo/habitrpg.jpg diff --git a/website/client/logo/habitrpg_bl.eps b/website/public/logo/habitrpg_bl.eps similarity index 100% rename from website/client/logo/habitrpg_bl.eps rename to website/public/logo/habitrpg_bl.eps diff --git a/website/client/logo/habitrpg_pixel.png b/website/public/logo/habitrpg_pixel.png similarity index 100% rename from website/client/logo/habitrpg_pixel.png rename to website/public/logo/habitrpg_pixel.png diff --git a/website/client/manifest.json b/website/public/manifest.json similarity index 94% rename from website/client/manifest.json rename to website/public/manifest.json index 2c770893f1..20da085a28 100644 --- a/website/client/manifest.json +++ b/website/public/manifest.json @@ -31,6 +31,7 @@ "bower_components/jquery-ui/ui/minified/jquery.ui.widget.min.js", "bower_components/jquery-ui/ui/minified/jquery.ui.mouse.min.js", "bower_components/jquery-ui/ui/minified/jquery.ui.sortable.min.js", + "bower_components/smart-app-banner/smart-app-banner.js", "common/dist/scripts/habitrpg-shared.js", @@ -42,6 +43,7 @@ "js/services/sharedServices.js", "js/services/notificationServices.js", + "common/script/public/userServices.js", "common/script/public/directives.js", "js/services/analyticsServices.js", "js/services/groupServices.js", @@ -49,13 +51,11 @@ "js/services/memberServices.js", "js/services/guideServices.js", "js/services/taskServices.js", - "js/services/tagsServices.js", "js/services/challengeServices.js", "js/services/paymentServices.js", "js/services/questServices.js", "js/services/socialServices.js", "js/services/statServices.js", - "js/services/userServices.js", "js/filters/money.js", "js/filters/roundLargeNumbers.js", @@ -130,13 +130,10 @@ "js/static.js", "js/services/analyticsServices.js", "js/services/notificationServices.js", - "js/services/userServices.js", "js/services/sharedServices.js", "js/services/socialServices.js", "js/services/statServices.js", - "js/services/taskServices.js", - "js/services/tagsServices.js", - "js/services/memberServices.js", + "common/script/public/userServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" ], @@ -170,10 +167,7 @@ "js/services/sharedServices.js", "js/services/socialServices.js", "js/services/statServices.js", - "js/services/taskServices.js", - "js/services/tagsServices.js", - "js/services/userServices.js", - "js/services/memberServices.js", + "common/script/public/userServices.js", "js/controllers/authCtrl.js", "js/controllers/footerCtrl.js" ], diff --git a/website/client/marketing/android_iphone.png b/website/public/marketing/android_iphone.png similarity index 100% rename from website/client/marketing/android_iphone.png rename to website/public/marketing/android_iphone.png diff --git a/website/client/marketing/animals.png b/website/public/marketing/animals.png similarity index 100% rename from website/client/marketing/animals.png rename to website/public/marketing/animals.png diff --git a/website/client/marketing/challenge.png b/website/public/marketing/challenge.png similarity index 100% rename from website/client/marketing/challenge.png rename to website/public/marketing/challenge.png diff --git a/website/client/marketing/devices.png b/website/public/marketing/devices.png similarity index 100% rename from website/client/marketing/devices.png rename to website/public/marketing/devices.png diff --git a/website/client/marketing/drops.png b/website/public/marketing/drops.png similarity index 100% rename from website/client/marketing/drops.png rename to website/public/marketing/drops.png diff --git a/website/client/marketing/education.png b/website/public/marketing/education.png similarity index 100% rename from website/client/marketing/education.png rename to website/public/marketing/education.png diff --git a/website/client/marketing/gear.png b/website/public/marketing/gear.png similarity index 100% rename from website/client/marketing/gear.png rename to website/public/marketing/gear.png diff --git a/website/client/marketing/guild.png b/website/public/marketing/guild.png similarity index 100% rename from website/client/marketing/guild.png rename to website/public/marketing/guild.png diff --git a/website/client/marketing/guild_small.png b/website/public/marketing/guild_small.png similarity index 100% rename from website/client/marketing/guild_small.png rename to website/public/marketing/guild_small.png diff --git a/website/client/marketing/integration.png b/website/public/marketing/integration.png similarity index 100% rename from website/client/marketing/integration.png rename to website/public/marketing/integration.png diff --git a/website/client/marketing/lefnire.png b/website/public/marketing/lefnire.png similarity index 100% rename from website/client/marketing/lefnire.png rename to website/public/marketing/lefnire.png diff --git a/website/client/marketing/promos/201403_Forest_Walker.png b/website/public/marketing/promos/201403_Forest_Walker.png similarity index 100% rename from website/client/marketing/promos/201403_Forest_Walker.png rename to website/public/marketing/promos/201403_Forest_Walker.png diff --git a/website/client/marketing/promos/April14SAMPLE2.png b/website/public/marketing/promos/April14SAMPLE2.png similarity index 100% rename from website/client/marketing/promos/April14SAMPLE2.png rename to website/public/marketing/promos/April14SAMPLE2.png diff --git a/website/client/marketing/screenshot.png b/website/public/marketing/screenshot.png similarity index 100% rename from website/client/marketing/screenshot.png rename to website/public/marketing/screenshot.png diff --git a/website/client/marketing/social_competitve.png b/website/public/marketing/social_competitve.png similarity index 100% rename from website/client/marketing/social_competitve.png rename to website/public/marketing/social_competitve.png diff --git a/website/client/marketing/wellness.png b/website/public/marketing/wellness.png similarity index 100% rename from website/client/marketing/wellness.png rename to website/public/marketing/wellness.png diff --git a/website/client/merch/stickermule-logo.png b/website/public/merch/stickermule-logo.png similarity index 100% rename from website/client/merch/stickermule-logo.png rename to website/public/merch/stickermule-logo.png diff --git a/website/client/merch/stickermule-logo.svg b/website/public/merch/stickermule-logo.svg similarity index 100% rename from website/client/merch/stickermule-logo.svg rename to website/public/merch/stickermule-logo.svg diff --git a/website/client/merch/stickermule.png b/website/public/merch/stickermule.png similarity index 100% rename from website/client/merch/stickermule.png rename to website/public/merch/stickermule.png diff --git a/website/client/merch/teespring-eu-logo.png b/website/public/merch/teespring-eu-logo.png similarity index 100% rename from website/client/merch/teespring-eu-logo.png rename to website/public/merch/teespring-eu-logo.png diff --git a/website/client/merch/teespring-eu.png b/website/public/merch/teespring-eu.png similarity index 100% rename from website/client/merch/teespring-eu.png rename to website/public/merch/teespring-eu.png diff --git a/website/client/merch/teespring-logo.png b/website/public/merch/teespring-logo.png similarity index 100% rename from website/client/merch/teespring-logo.png rename to website/public/merch/teespring-logo.png diff --git a/website/client/merch/teespring-logo.svg b/website/public/merch/teespring-logo.svg similarity index 100% rename from website/client/merch/teespring-logo.svg rename to website/public/merch/teespring-logo.svg diff --git a/website/client/merch/teespring.png b/website/public/merch/teespring.png similarity index 100% rename from website/client/merch/teespring.png rename to website/public/merch/teespring.png diff --git a/website/client/page-loader.gif b/website/public/page-loader.gif similarity index 100% rename from website/client/page-loader.gif rename to website/public/page-loader.gif diff --git a/website/client/presskit/Boss - Basi-List.png b/website/public/presskit/Boss - Basi-List.png similarity index 100% rename from website/client/presskit/Boss - Basi-List.png rename to website/public/presskit/Boss - Basi-List.png diff --git a/website/client/presskit/Boss - Battling the Ghost Stag.png b/website/public/presskit/Boss - Battling the Ghost Stag.png similarity index 100% rename from website/client/presskit/Boss - Battling the Ghost Stag.png rename to website/public/presskit/Boss - Battling the Ghost Stag.png diff --git a/website/client/presskit/Boss - Laundromancer.png b/website/public/presskit/Boss - Laundromancer.png similarity index 100% rename from website/client/presskit/Boss - Laundromancer.png rename to website/public/presskit/Boss - Laundromancer.png diff --git a/website/client/presskit/Boss - Necro-Vice.png b/website/public/presskit/Boss - Necro-Vice.png similarity index 100% rename from website/client/presskit/Boss - Necro-Vice.png rename to website/public/presskit/Boss - Necro-Vice.png diff --git a/website/client/presskit/Boss - SnackLess Monster.png b/website/public/presskit/Boss - SnackLess Monster.png similarity index 100% rename from website/client/presskit/Boss - SnackLess Monster.png rename to website/public/presskit/Boss - SnackLess Monster.png diff --git a/website/client/presskit/Boss - Stagnant Dishes.png b/website/public/presskit/Boss - Stagnant Dishes.png similarity index 100% rename from website/client/presskit/Boss - Stagnant Dishes.png rename to website/public/presskit/Boss - Stagnant Dishes.png diff --git a/website/client/presskit/Habitica Gryphon.png b/website/public/presskit/Habitica Gryphon.png similarity index 100% rename from website/client/presskit/Habitica Gryphon.png rename to website/public/presskit/Habitica Gryphon.png diff --git a/website/client/presskit/Habitica Logo - Android.png b/website/public/presskit/Habitica Logo - Android.png similarity index 100% rename from website/client/presskit/Habitica Logo - Android.png rename to website/public/presskit/Habitica Logo - Android.png diff --git a/website/client/presskit/Habitica Logo - Icon with Text.png b/website/public/presskit/Habitica Logo - Icon with Text.png similarity index 100% rename from website/client/presskit/Habitica Logo - Icon with Text.png rename to website/public/presskit/Habitica Logo - Icon with Text.png diff --git a/website/client/presskit/Habitica Logo - Icon.png b/website/public/presskit/Habitica Logo - Icon.png similarity index 100% rename from website/client/presskit/Habitica Logo - Icon.png rename to website/public/presskit/Habitica Logo - Icon.png diff --git a/website/client/presskit/Habitica Logo - Text.png b/website/public/presskit/Habitica Logo - Text.png similarity index 100% rename from website/client/presskit/Habitica Logo - Text.png rename to website/public/presskit/Habitica Logo - Text.png diff --git a/website/client/presskit/Habitica Logo - iOS.png b/website/public/presskit/Habitica Logo - iOS.png similarity index 100% rename from website/client/presskit/Habitica Logo - iOS.png rename to website/public/presskit/Habitica Logo - iOS.png diff --git a/website/client/presskit/Habitica Promo - Thin.png b/website/public/presskit/Habitica Promo - Thin.png similarity index 100% rename from website/client/presskit/Habitica Promo - Thin.png rename to website/public/presskit/Habitica Promo - Thin.png diff --git a/website/client/presskit/Habitica Promo.png b/website/public/presskit/Habitica Promo.png similarity index 100% rename from website/client/presskit/Habitica Promo.png rename to website/public/presskit/Habitica Promo.png diff --git a/website/client/presskit/Sample Screen - Boss (iOS).png b/website/public/presskit/Sample Screen - Boss (iOS).png similarity index 100% rename from website/client/presskit/Sample Screen - Boss (iOS).png rename to website/public/presskit/Sample Screen - Boss (iOS).png diff --git a/website/client/presskit/Sample Screen - Challenges.png b/website/public/presskit/Sample Screen - Challenges.png similarity index 100% rename from website/client/presskit/Sample Screen - Challenges.png rename to website/public/presskit/Sample Screen - Challenges.png diff --git a/website/client/presskit/Sample Screen - Equipment.png b/website/public/presskit/Sample Screen - Equipment.png similarity index 100% rename from website/client/presskit/Sample Screen - Equipment.png rename to website/public/presskit/Sample Screen - Equipment.png diff --git a/website/client/presskit/Sample Screen - Guilds.png b/website/public/presskit/Sample Screen - Guilds.png similarity index 100% rename from website/client/presskit/Sample Screen - Guilds.png rename to website/public/presskit/Sample Screen - Guilds.png diff --git a/website/client/presskit/Sample Screen - Level Up (iOS).png b/website/public/presskit/Sample Screen - Level Up (iOS).png similarity index 100% rename from website/client/presskit/Sample Screen - Level Up (iOS).png rename to website/public/presskit/Sample Screen - Level Up (iOS).png diff --git a/website/client/presskit/Sample Screen - Market.png b/website/public/presskit/Sample Screen - Market.png similarity index 100% rename from website/client/presskit/Sample Screen - Market.png rename to website/public/presskit/Sample Screen - Market.png diff --git a/website/client/presskit/Sample Screen - Party (iOS).png b/website/public/presskit/Sample Screen - Party (iOS).png similarity index 100% rename from website/client/presskit/Sample Screen - Party (iOS).png rename to website/public/presskit/Sample Screen - Party (iOS).png diff --git a/website/client/presskit/Sample Screen - Pets (iOS).png b/website/public/presskit/Sample Screen - Pets (iOS).png similarity index 100% rename from website/client/presskit/Sample Screen - Pets (iOS).png rename to website/public/presskit/Sample Screen - Pets (iOS).png diff --git a/website/client/presskit/Sample Screen - Tasks Page (iOS).png b/website/public/presskit/Sample Screen - Tasks Page (iOS).png similarity index 100% rename from website/client/presskit/Sample Screen - Tasks Page (iOS).png rename to website/public/presskit/Sample Screen - Tasks Page (iOS).png diff --git a/website/client/presskit/Sample Screen - Tasks Page.png b/website/public/presskit/Sample Screen - Tasks Page.png similarity index 100% rename from website/client/presskit/Sample Screen - Tasks Page.png rename to website/public/presskit/Sample Screen - Tasks Page.png diff --git a/website/client/presskit/World Boss - Dread Drag'on of Dilatory.png b/website/public/presskit/World Boss - Dread Drag'on of Dilatory.png similarity index 100% rename from website/client/presskit/World Boss - Dread Drag'on of Dilatory.png rename to website/public/presskit/World Boss - Dread Drag'on of Dilatory.png diff --git a/website/client/presskit/presskit.zip b/website/public/presskit/presskit.zip similarity index 100% rename from website/client/presskit/presskit.zip rename to website/public/presskit/presskit.zip diff --git a/website/client/refresh.png b/website/public/refresh.png similarity index 100% rename from website/client/refresh.png rename to website/public/refresh.png diff --git a/website/server/controllers/api-v2/challenges.js b/website/server/controllers/api-v2/challenges.js deleted file mode 100644 index c5d716d88e..0000000000 --- a/website/server/controllers/api-v2/challenges.js +++ /dev/null @@ -1,428 +0,0 @@ -// @see ../routes for routing - -var _ = require('lodash'); -var nconf = require('nconf'); -var async = require('async'); -var shared = require('../../../../common'); -import { - model as User, -} from '../../models/user'; -import { - model as Group, - basicFields as basicGroupFields, - TAVERN_ID, -} from '../../models/group'; -import { - model as Challenge, -} from '../../models/challenge'; -import * as Tasks from '../../models/task'; -var logging = require('./../../libs/api-v2/logging'); -var csvStringify = require('csv-stringify'); -var utils = require('../../libs/api-v2/utils'); -var api = module.exports; -var pushNotify = require('./pushNotifications'); -import Bluebird from 'bluebird'; -import v3MembersController from '../api-v3/members'; -/* - ------------------------------------------------------------------------ - Challenges - ------------------------------------------------------------------------ -*/ - -var nameFields = 'profile.name'; - -api.list = async function(req, res, next) { - try { - var user = res.locals.user; - - let challenges = await Challenge.find({ - $or: [ - {_id: {$in: user.challenges}}, // Challenges where the user is participating - {group: {$in: user.getGroups()}}, // Challenges in groups where I'm a member - {leader: user._id}, // Challenges where I'm the leader - ], - _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit - }) - .sort('-official -timestamp') - // .populate('group', basicGroupFields) - // .populate('leader', nameFields) - .exec(); - - let resChals = challenges.map(challenge => { - let obj = challenge.toJSON(); - - obj._isMember = user.challenges.indexOf(challenge._id) !== -1; - return obj; - }); - - // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - await Bluebird.all(resChals.map((chal, index) => { - return Bluebird.all([ - User.findById(chal.leader).select(nameFields).exec(), - Group.findById(chal.group).select(basicGroupFields).exec(), - ]).then(populatedData => { - resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null; - resChals[index].group = populatedData[1] ? populatedData[1].toJSON({minimize: true}) : null; - }); - })); - - res.json(resChals); - } catch (err) { - next(err); - } -} - -// GET -api.get = async function(req, res, next) { - try { - let user = res.locals.user; - let challengeId = req.params.cid; - - let challenge = await Challenge.findById(challengeId) - // Don't populate the group as we'll fetch it manually later - // .populate('leader', nameFields) - .exec(); - if (!challenge) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'}); - - // Fetching basic group data - let group = await Group.getGroup({user, groupId: challenge.group, optionalMembership: true}); - if (!group || !challenge.canView(user, group)) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'}); - - let leaderRes = await User.findById(challenge.leader).select('profile.name').exec(); - leaderRes = leaderRes ? leaderRes.toJSON({minimize: true}) : null; - - challenge.getTransformedData({ - populateMembers: 'profile.name', - cb (err, transformedChal) { - transformedChal.group = group.toJSON({minimize: true}); - transformedChal.leader = leaderRes; - transformedChal._isMember = user.challenges.indexOf(transformedChal._id) !== -1; - res.json(transformedChal); - } - }); - } catch (err) { - next(err); - } -} - -api.csv = function(req, res, next) { - var cid = req.params.cid; - req.params.challengeId = cid; - v3MembersController.exportChallengeCsv.handler(req, res, next).catch(next); -} - -api.getMember = function(req, res, next) { - var cid = req.params.cid; - var uid = req.params.uid; - - req.params.memberId = uid; - req.params.challengeId = cid; - v3MembersController.getChallengeMemberProgress.handler(req, res, next) - .then(result => { - let newResult = { - profile: { - name: result.profile.name, - }, - habits: [], - dailys: [], - todos: [], - rewards: [], - }; - - let tasks = result.tasks; - tasks.forEach(task => { - let taskObj = task.toJSONV2(); - newResult[taskObj.type + 's'].push(taskObj); - }); - - res.json(newResult); - }) - .catch(next); -} - -// CREATE -api.create = async function(req, res, next){ - try { - var user = res.locals.user; - - let groupId = req.body.group; - let prize = req.body.prize; - - let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true}); - if (!group) return res.status(404).json({err:"Group." + req.body.group + " not found"}); - if (!group.isMember(user)) return res.status(404).json({err:"Group." + req.body.group + " not found"}); - - if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { - return res.status(401).json({err:"Only the group leader can create challenges"}); - } - - if (group._id === TAVERN_ID && prize < 1) { - return res.status(401).json({err: 'Prize must be at least 1 Gem for public challenges.'}) - } - - if (prize > 0) { - let groupBalance = group.balance && group.leader === user._id ? group.balance : 0; - let prizeCost = prize / 4; - - if (prizeCost > user.balance + groupBalance) { - return res.status(401).json({err: 'You can\'t afford this prize. Purchase more gems or lower the prize amount.'}); - } - - if (groupBalance >= prizeCost) { - // Group pays for all of prize - group.balance -= prizeCost; - } else if (groupBalance > 0) { - // User pays remainder of prize cost after group - let remainder = prizeCost - group.balance; - group.balance = 0; - user.balance -= remainder; - } else { - // User pays for all of prize - user.balance -= prizeCost; - } - } - - group.challengeCount += 1; - - req.body.leader = user._id; - req.body.official = user.contributor.admin && req.body.official; - let challenge = new Challenge(Challenge.sanitize(req.body)); - - // First validate challenge so we don't save group if it's invalid (only runs sync validators) - let challengeValidationErrors = challenge.validateSync(); - if (challengeValidationErrors) throw challengeValidationErrors; - - req.body.habits = req.body.habits || []; - req.body.todos = req.body.todos || []; - req.body.dailys = req.body.dailys || []; - req.body.rewards = req.body.rewards || []; - - var chalTasks = req.body.habits.concat(req.body.rewards) - .concat(req.body.dailys).concat(req.body.todos) - .map(v2Task => Tasks.Task.fromJSONV2(v2Task)); - - chalTasks = chalTasks.map(function(task) { - var newTask = new Tasks[task.type](Tasks.Task.sanitize(task)); - newTask.challenge.id = challenge._id; - return newTask.save(); - }); - - let results = await Bluebird.all([challenge.save({ - validateBeforeSave: false, // already validated - }), group.save()].concat(chalTasks)); - let savedChal = results[0]; - - await savedChal.syncToUser(user); // (it also saves the user) - - savedChal.getTransformedData({ - cb (err, transformedChal) { - res.status(201).json(transformedChal); - }, - }); - } catch (err) { - next(err); - } -} - -// UPDATE -api.update = function(req, res, next){ - var cid = req.params.cid; - var user = res.locals.user; - var before; - var updatedTasks; - - async.waterfall([ - function(cb){ - // We first need the original challenge data, since we're going to compare against new & decide to sync users - Challenge.findById(cid, cb); - }, - function(chal, cb){ - if(!chal) return cb({chal: null}); - - chal.getTasks(function(err, tasks){ - cb(err, { - chal: chal, - tasks: tasks - }); - }); - }, - function(_before, cb) { - if (!_before.chal) return cb('Challenge ' + cid + ' not found'); - if (_before.chal.leader != user._id && !user.contributor.admin) return cb({code: 401, err: shared.i18n.t('noPermissionEditChallenge', req.language)}); - // Update the challenge, since syncing will need the updated challenge. But store `before` we're going to do some - // before-save / after-save comparison to determine if we need to sync to users - before = {chal: _before.chal, tasks: _before.tasks}; - var chalAttrs = _.pick(req.body, 'name shortName description date'.split(' ')); - async.parallel({ - chal: function(cb1){ - Challenge.findByIdAndUpdate(cid, {$set:chalAttrs}, {new: true}, cb1); - }, - tasks: function(cb1) { - // Convert to map of {id: task} so we can easily match them - var _beforeClonedTasks = _before.tasks; - updatedTasks = _.object(_.pluck(_beforeClonedTasks, '_id'), _beforeClonedTasks); - var newTasks = req.body.habits.concat(req.body.dailys) - .concat(req.body.todos).concat(req.body.rewards); - - var newTasksObj = _.object(_.pluck(newTasks, '_id'), newTasks); - async.forEachOf(newTasksObj, function(newTask, taskId, cb2){ - // some properties can't be changed - newTask = Tasks.Task.sanitize(newTask); - // we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? - _.assign(updatedTasks[taskId], shared.ops.updateTask(updatedTasks[taskId].toObject(), {body: newTask})); - _before.chal.updateTask(updatedTasks[taskId]).then(cb2).catch(cb2); - }, cb1); - } - }, cb); - }, - ], function(err, saved){ - if(err) { - return err.code ? res.json(err.code, err) : next(err); - } - - saved.chal.getTransformedData({cb: function(err, newChal){ - if(err) return next(err); - res.json(newChal); - }}) - cid = user = before = null; - }); -} - -/** - * Delete & close - */ -api.delete = async function(req, res, next){ - try { - var user = res.locals.user; - var cid = req.params.cid; - - let challenge = await Challenge.findOne({_id: req.params.cid}).exec(); - if (!challenge) return next('Challenge ' + cid + ' not found'); - if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge')); - - // Close channel in background, some ops are run in the background without `await`ing - await challenge.closeChal({broken: 'CHALLENGE_DELETED'}); - res.sendStatus(200); - } catch (err) { - next(err); - } -} - -/** - * Select Winner & Close - */ -api.selectWinner = async function(req, res, next) { - try { - if (!req.query.uid) return res.status(401).json({err: 'Must select a winner'}); - - let challenge = await Challenge.findOne({_id: req.params.cid}).exec(); - if (!challenge) return next('Challenge ' + req.params.cid + ' not found'); - if (!challenge.canModify(res.locals.user)) return next(shared.i18n.t('noPermissionCloseChallenge')); - - let winner = await User.findOne({_id: req.query.uid}).exec(); - if (!winner || winner.challenges.indexOf(challenge._id) === -1) return next('Winner ' + req.query.uid + ' not found.'); - - // Close channel in background, some ops are run in the background without `await`ing - await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner}); - res.respond(200, {}); - } catch (err) { - next(err); - } -} - -api.join = async function(req, res, next){ - try { - var user = res.locals.user; - var cid = req.params.cid; - - let challenge = await Challenge.findOne({ _id: cid }); - if (!challenge) return next(shared.i18n.t('challengeNotFound')); - if (challenge.isMember(user)) return next(shared.i18n.t('userAlreadyInChallenge')); - - let group = await Group.getGroup({user, groupId: challenge.group, optionalMembership: true}); - if (!group || !challenge.hasAccess(user, group)) return next(shared.i18n.t('challengeNotFound')); - - challenge.memberCount += 1; - - // Add all challenge's tasks to user's tasks and save the challenge - await Bluebird.all([challenge.syncToUser(user), challenge.save()]); - - challenge.getTransformedData({ - cb (err, transformedChal) { - transformedChal._isMember = true; - res.json(transformedChal); - } - }); - } catch (e) { - next(e); - } -} - -api.leave = async function(req, res, next){ - try { - var user = res.locals.user; - var cid = req.params.cid; - // whether or not to keep challenge's tasks. strictly default to true if "keep-all" isn't provided - var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all'; - - let challenge = await Challenge.findOne({ _id: cid }); - if (!challenge) return next(shared.i18n.t('challengeNotFound')); - - let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy'}); - if (!group || !challenge.canView(user, group)) return next(shared.i18n.t('challengeNotFound')); - - if (!challenge.isMember(user)) return next(shared.i18n.t('challengeMemberNotFound')); - - challenge.memberCount -= 1; - - // Unlink challenge's tasks from user's tasks and save the challenge - await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]); - - challenge.getTransformedData({ - cb (err, transformedChal) { - transformedChal._isMember = false; - res.json(transformedChal); - } - }); - } catch (e) { - next(e); - } -} - -import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; - -api.unlink = async function(req, res, next) { - try { - var user = res.locals.user; - var tid = req.params.id; - var cid; - if (!req.query.keep) - return res.status(400).json({err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'}); - - let keep = req.query.keep; - let task = await Tasks.Task.findOne({ - _id: tid, - userId: user._id, - }).exec(); - - if (!task) return next(shared.i18n.t('taskNotFound')); - if (!task.challenge.id) return next(shared.i18n.t('cantOnlyUnlinkChalTask')); - - cid = task.challenge.id; - if (keep === 'keep') { - task.challenge = {}; - await task.save(); - } else { // remove - if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if - removeFromArray(user.tasksOrder[`${task.type}s`], tid); - await Bluebird.all([user.save(), task.remove()]); - } else { - await task.remove(); - } - } - - res.sendStatus(200); - } catch (e) { - next(e); - } -} diff --git a/website/server/controllers/api-v2/user.js b/website/server/controllers/api-v2/user.js deleted file mode 100644 index 656a48c908..0000000000 --- a/website/server/controllers/api-v2/user.js +++ /dev/null @@ -1,1053 +0,0 @@ -var url = require('url'); -var ipn = require('paypal-ipn'); -var _ = require('lodash'); -var validator = require('validator'); -var nconf = require('nconf'); -var asyncM = require('async'); -var shared = require('../../../../common'); -import { - model as User, -} from '../../models/user'; -import { - NotFound, -} from '../../libs/api-v3/errors'; -import { model as Tag } from '../../models/tag'; -import * as Tasks from '../../models/task'; -import Bluebird from 'bluebird'; -import {removeFromArray} from './../../libs/api-v3/collectionManipulators'; -var utils = require('./../../libs/api-v2/utils'); -var analytics = utils.analytics; -import { - basicFields as basicGroupFields, - model as Group, -} from '../../models/group'; -import { - model as Challenge, -} from '../../models/challenge'; -var moment = require('moment'); -var logging = require('./../../libs/api-v2/logging'); -var acceptablePUTPaths; -let restrictedPUTSubPaths; -import v3UserController from '../api-v3/user'; - -let i18n = shared.i18n; - -var api = module.exports; -var firebase = require('../../libs/api-v2/firebase'); -var webhook = require('../../libs/api-v2/webhook'); - -const partyMembersFields = 'profile.name stats achievements items.special'; - -// api.purchase // Shared.ops - -api.getContent = function(req, res, next) { - var language = 'en'; - - if (typeof req.query.language != 'undefined') - language = req.query.language.toString(); //|| 'en' in i18n - - var content = _.cloneDeep(shared.content); - var walk = function(obj, lang){ - _.each(obj, function(item, key, source){ - if (_.isPlainObject(item) || _.isArray(item)) return walk(item, lang); - if (_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang); - }); - } - walk(content, language); - res.json(content); -} - -api.getModelPaths = function(req,res,next){ - res.json(_.reduce(User.schema.paths,function(m,v,k){ - m[k] = v.instance || 'Boolean'; - return m; - },{})); -} - -/* - ------------------------------------------------------------------------ - Tasks - ------------------------------------------------------------------------ -*/ - - -/* - Local Methods - --------------- -*/ - -var findTask = function(req, res) { - return res.locals.user.tasks[req.params.id]; -}; - -function findTaskByIdOrLegacyId (user, taskId, callback) { - asyncM.waterfall([ - function (cb) { - Tasks.Task.findOne({ - _id: taskId, - userId: user._id, - }, cb); - }, - function (task, cb) { - if (task) return cb(null, task); - - Tasks.Task.findOne({ - _legacyId: taskId, - userId: user._id, - }, cb); - }, - ], callback); -} - -/* - API Routes - --------------- -*/ - -api.score = function(req, res, next) { - var id = req.params.id, - direction = req.params.direction, - user = res.locals.user, - body = req.body || {}, - task; - - // Send error responses for improper API call - if (!id) return res.json(400, {err: ':id required'}); - if (direction !== 'up' && direction !== 'down') { - if (direction == 'unlink' || direction == 'sort') return next(); - return res.json(400, {err: ":direction must be 'up' or 'down'"}); - } - - findTaskByIdOrLegacyId(user, id, function (err, task) { - if (err) return next(err); - - // If exists already, score it - if (!task) { - // If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it - // Defaults. Other defaults are handled in user.ops.addTask() - var taskOptions = { - type: body.type || 'habit', - text: body.text || id, - userId: user._id, - notes: body.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." // TODO translate - } - - if (validator.isUUID(id)) { - taskOptions._id = id; // TODO this might easily lead to conflicts as ids are now unique db-wide - } else { - taskOptions._legacyId = id; - } - - task = new Tasks.Task(taskOptions); - - user.tasksOrder[task.type + 's'].unshift(task._id); - } - - // Set completed if type is daily or todo - if (task.type === 'daily' || task.type === 'todo') { - task.completed = direction === 'up'; - } - - var [delta] = shared.ops.scoreTask({ - user, - task, - direction, - }, req); - // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) - if (direction === 'up') user.fns.randomDrop({task, delta}, req); - - asyncM.parallel({ - task: task.save.bind(task), - user: user.save.bind(user) - }, function(err, results){ - if(err) return next(err); - - // TODO this is suuuper strange, sometimes results.user is an array, sometimes user directly - var saved = Array.isArray(results.user) ? results.user[0] : results.user; - var task = Array.isArray(results.task) ? results.task[0] : results.task; - - var userStats = saved.toJSON().stats; - var resJsonData = _.extend({ delta: delta, _tmp: user._tmp }, userStats); - res.json(200, resJsonData); - - var webhookData = _generateWebhookTaskData( - task, direction, delta, userStats, user - ); - webhook.sendTaskWebhook(user.preferences.webhooks, webhookData); - - if ( - (!task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there - || (task.type == 'reward') // we don't want to update the reward GP cost - ) return; - - // select name and shortName because they can be synced on syncToUser - Challenge.findById(task.challenge.id, 'name shortName', function(err, chal) { - if (err) return next(err); - if (!chal) { - task.challenge.broken = 'CHALLENGE_DELETED'; - task.save(); - return; - } - - Tasks.Task.findOne({ - '_id': task.challenge.taskId, - userId: {$exists: false} - }, function(err, chalTask){ - if(err) return; //TODO - // this task was removed from the challenge, notify user - if(!chalTask) { - // TODO finish - chal.getTasks(function(err, chalTasks){ - if(err) return; //TODO - chal.syncToUser(user, chalTasks); - }); - } else { - chalTask.value += delta; - if (chalTask.type == 'habit' || chalTask.type == 'daily') - chalTask.history.push({value: chalTask.value, date: +new Date}); - chalTask.save(); - } - }); - }); - }); - }); -}; - -/** - * Get all tasks - */ -api.getTasks = function(req, res, next) { - var user = res.locals.user; - - user.getTasks(req.query.type, function (err, tasks) { - if (err) return next(err); - res.status(200).json(tasks.map(task => task.toJSONV2())); - }); -}; - -/** - * Get Task - */ -api.getTask = function(req, res, next) { - var user = res.locals.user, - id = req.params.id; - - findTaskByIdOrLegacyId(user, id, function (err, task) { - if (err) return next(err); - if (!task) return res.status(404).json({err: shared.i18n.t('messageTaskNotFound')}); - res.status(200).json(task.toJSONV2()); - }); -}; - -/* - ------------------------------------------------------------------------ - Items - ------------------------------------------------------------------------ -*/ -// api.buy // handled in Shard.ops - -api.getBuyList = function (req, res, next) { - var list = shared.updateStore(res.locals.user); - return res.status(200).json(list); -}; - -/* - ------------------------------------------------------------------------ - User - ------------------------------------------------------------------------ -*/ - -/** - * Get User - */ -api.getUser = function(req, res, next) { - res.locals.user.getTransformedData(function(err, user){ - user.stats.toNextLevel = shared.tnl(user.stats.lvl); - user.stats.maxHealth = shared.maxHealth; - user.stats.maxMP = res.locals.user._statsComputed.maxMP; - delete user.apiToken; - if (user.auth && user.auth.local) { - delete user.auth.local.hashed_password; - delete user.auth.local.salt; - } - return res.status(200).json(user); - }); -}; - -/** - * Get anonymized User - */ -api.getUserAnonymized = function(req, res, next) { - res.locals.user.getTransformedData(function(err, user){ - user.stats.toNextLevel = shared.tnl(user.stats.lvl); - user.stats.maxHealth = shared.maxHealth; - user.stats.maxMP = res.locals.user._statsComputed.maxMP; - - delete user.apiToken; - - if (user.auth) { - delete user.auth.local; - delete user.auth.facebook; - } - - delete user.newMessages; - - delete user.profile; - delete user.purchased.plan; - delete user.contributor; - delete user.invitations; - - delete user.items.special.nyeReceived; - delete user.items.special.valentineReceived; - - delete user.webhooks; - delete user.achievements.challenges; - - _.forEach(user.inbox.messages, function(msg){ - msg.text = "inbox message text"; - }); - - _.forEach(user.tags, function(tag){ - tag.name = "tag"; - tag.challenge = "challenge"; - }); - - function cleanChecklist(task){ - var checklistIndex = 0; - - _.forEach(task.checklist, function(c){ - c.text = "item" + checklistIndex++; - }); - } - - _.forEach(user.habits, function(task){ - task.text = "task text"; - task.notes = "task notes"; - }); - - _.forEach(user.rewards, function(task){ - task.text = "task text"; - task.notes = "task notes"; - }); - - _.forEach(user.dailys, function(task){ - task.text = "task text"; - task.notes = "task notes"; - - cleanChecklist(task); - }); - - _.forEach(user.todos, function(task){ - task.text = "task text"; - task.notes = "task notes"; - - cleanChecklist(task); - }); - - return res.status(200).json(user); - }); -}; - -/** - * This tells us for which paths users can call `PUT /user` (or batch-update equiv, which use `User.set()` on our client). - * The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs) - * TODO - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations - */ -acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, (m, v, leaf) => { - let updatablePaths = 'achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '); - let found = _.find(updatablePaths, (rootPath) => { - return leaf.indexOf(rootPath) === 0; - }); - - if (found) m[leaf] = true; - - return m; -}, {}); - -restrictedPUTSubPaths = 'stats.class'.split(' '); - -_.each(restrictedPUTSubPaths, (removePath) => { - delete acceptablePUTPaths[removePath]; -}); - -let requiresPurchase = { - 'preferences.background': 'background', - 'preferences.shirt': 'shirt', - 'preferences.size': 'size', - 'preferences.skin': 'skin', - 'preferences.chair': 'chair', - 'preferences.hair.bangs': 'hair.bangs', - 'preferences.hair.base': 'hair.base', - 'preferences.hair.beard': 'hair.beard', - 'preferences.hair.color': 'hair.color', - 'preferences.hair.flower': 'hair.flower', - 'preferences.hair.mustache': 'hair.mustache', -}; - -let checkPreferencePurchase = (user, path, item) => { - let itemPath = `${path}.${item}`; - let appearance = _.get(shared.content.appearances, itemPath) - if (!appearance) return false; - if (appearance.price === 0) return true; - - return _.get(user.purchased, itemPath); -}; - -/** - * Update user - * Send up PUT /user as `req.body={path1:val, path2:val, etc}`. Example: - * PUT /user {'stats.hp':50, 'tasks.TASK_ID.repeat.m':false} - * See acceptablePUTPaths for which user paths are supported -*/ -api.update = (req, res, next) => { - let user = res.locals.user; - let errors = []; - - if (_.isEmpty(req.body)) return res.status(200).json(user); - - _.each(req.body, (v, k) => { - let purchasable = requiresPurchase[k]; - - if (purchasable && !checkPreferencePurchase(user, purchasable, v)) { - return errors.push(`Must purchase ${v} to set it on ${k}`); - } - - if (acceptablePUTPaths[k]) { - user.fns.dotSet(k, v); - } else { - errors.push(shared.i18n.t('messageUserOperationProtected', { operation: k })); - } - return true; - }); - - user.save((err) => { - if (!_.isEmpty(errors)) return res.status(401).json({err: errors}); - if (err) { - if (err.name == 'ValidationError') { - let errorMessages = _.map(_.values(err.errors), (error) => { - return error.message; - }); - return res.status(400).json({err: errorMessages}); - } - return next(err); - } - - res.status(200).json(user); - user = errors = null; - }); -}; - -api.cron = require('../../middlewares/api-v3/cron'); - -// api.reroll // Shared.ops -// api.reset // Shared.ops - -api.delete = function(req, res, next) { - var user = res.locals.user; - var plan = user.purchased.plan; - - if (plan && plan.customerId && !plan.dateTerminated){ - return res.status(400).json({err:"You have an active subscription, cancel your plan before deleting your account."}); - } - - let types = ['party', 'guilds']; - let groupFields = basicGroupFields.concat(' leader memberCount'); - - Group.getGroups({user, types, groupFields}) - .then(groups => { - return Bluebird.all(groups.map((group) => { - return group.leave(user, 'remove-all'); - })); - }) - .then(() => { - return Tasks.Task.remove({ - userId: user._id, - }).exec(); - }) - .then(() => { - return user.remove(); - }) - .then(() => { - firebase.deleteUser(user._id); - res.sendStatus(200); - }) - .catch(next); -} - -/* - ------------------------------------------------------------------------ - Development Only Operations - ------------------------------------------------------------------------ - */ -if (nconf.get('NODE_ENV') === 'development') { - - api.addTenGems = function(req, res, next) { - var user = res.locals.user; - - user.balance += 2.5; - - user.save(function(err){ - if (err) return next(err); - res.sendStatus(204); - }); - }; - - api.addHourglass = function(req, res, next) { - var user = res.locals.user; - - user.purchased.plan.consecutive.trinkets += 1; - - user.save(function(err){ - if (err) return next(err); - res.sendStatus(204); - }); - }; -} - -/* - ------------------------------------------------------------------------ - Tags - ------------------------------------------------------------------------ - */ - -api.getTags = function (req, res, next) { - res.json(res.locals.user.tags.toObject().map(tag => { - return { - name: tag.name, - id: tag.id, - challenge: tag.challenge, - } - })); -}; - -api.getTag = function (req, res, next) { - let tag = _.find(res.locals.user.tags, {id: req.params.id}); - if (!tag) { - return res.status(404).json({err: i18n.t('messageTagNotFound', req.language)}); - } - - res.json({ - name: tag.name, - id: tag.id, - challenge: tag.challenge, - }); -}; - -api.addTag = function (req, res, next) { - let user = res.locals.user; - - user.tags.push(Tag.sanitize(req.body)); - user.save(function (err, user) { - if (err) return next(err); - - res.json(user.tags.toObject().map(tag => { - return { - name: tag.name, - id: tag.id, - challenge: tag.challenge, - } - })); - }); -}; - -api.updateTag = function (req, res, next) { - let user = res.locals.user; - - let tag = _.find(res.locals.user.tags, {id: req.params.id}); - if (!tag) { - return res.status(404).json({err: i18n.t('messageTagNotFound', req.language)}); - } - - tag.name = req.body.name; - user.save(function (err, user) { - if (err) return next(err); - - res.json({ - name: tag.name, - id: tag.id, - challenge: tag.challenge, - }); - }); -} - -api.sortTag = function (req, res, next) { - var ref = req.query; - var to = ref.to; - var from = ref.from; - let user = res.locals.user; - - if (!((to != null) && (from != null))) { - return res.statu(500).json('?to=__&from=__ are required'); - } - - user.tags.splice(to, 0, user.tags.splice(from, 1)[0]); - user.save(function (err, user) { - if (err) return next(err); - - res.json(user.tags.toObject().map(tag => { - return { - name: tag.name, - id: tag.id, - challenge: tag.challenge, - } - })); - }); -} - -api.deleteTag = function (req, res, next) { - let user = res.locals.user; - - let tag = removeFromArray(user.tags, { id: req.params.id }); - if (!tag) { - return res.status(404).json({err: i18n.t('messageTagNotFound', req.language)}); - } - - - Tasks.Task.update({ - userId: user._id, - }, { - $pull: { - tags: tag.id, - }, - }, {multi: true}).exec(); - - user.save(function (err, user) { - if (err) return next(err); - - res.json(user.tags.toObject().map(tag => { - return { - name: tag.name, - id: tag.id, - challenge: tag.challenge, - } - })); - }); -} - -/* - ------------------------------------------------------------------------ - Spells - ------------------------------------------------------------------------ - */ -api.cast = async function(req, res, next) { - try { - let user = res.locals.user; - let spellId = req.params.spell; - let targetId = req.query.targetId; - - if (spellId === 'heallAll') { - spellId = 'healAll'; - } else if (spellId === 'spookDust') { - spellId = 'spookySparkles'; - } - - let klass = shared.content.spells.special[spellId] ? 'special' : user.stats.class; - let spell = shared.content.spells[klass][spellId]; - - if (!spell) return res.status(404).json({err: 'Spell "' + req.params.spell + '" not found.'}); - if (spell.mana > user.stats.mp) return res.status(400).json({err: 'Not enough mana to cast spell'}); - - let targetType = spell.target; - - if (targetType === 'task') { - let task = await Tasks.Task.findOne({ - _id: targetId, - userId: user._id, - }).exec(); - if (!task) { - return res.status(404).json({err: 'Task "' + targetId + '" not found.'}); - } - - spell.cast(user, task, req); - await task.save(); - } else if (targetType === 'self') { - spell.cast(user, null, req); - await user.save(); - } else if (targetType === 'tasks') { // new target type when all the user's tasks are necessary - let tasks = await Tasks.Task.find({ - userId: user._id, - 'challenge.id': {$exists: false}, // exclude challenge tasks - $or: [ // Exclude completed todos - {type: 'todo', completed: false}, - {type: {$in: ['habit', 'daily', 'reward']}}, - ], - }).exec(); - - spell.cast(user, tasks, req); - - let toSave = tasks.filter(t => t.isModified()); - let isUserModified = user.isModified(); - toSave.unshift(user.save()); - let saved = await Bluebird.all(toSave); - } else if (targetType === 'party' || targetType === 'user') { - let party = await Group.getGroup({groupId: 'party', user}); - // arrays of users when targetType is 'party' otherwise single users - let partyMembers; - - if (targetType === 'party') { - if (!party) { - partyMembers = [user]; // Act as solo party - } else { - partyMembers = await User.find({'party._id': party._id}).select(partyMembersFields).exec(); - } - - spell.cast(user, partyMembers, req); - await Bluebird.all(partyMembers.map(m => m.save())); - } else { - if (!party && (!targetId || user._id === targetId)) { - partyMembers = user; - } else { - partyMembers = await User.findOne({_id: targetId, 'party._id': party._id}).select(partyMembersFields).exec(); - } - - if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); - spell.cast(user, partyMembers, req); - if (partyMembers === user) { - await partyMembers.save(); - } else { - await Bluebird.all([ - await partyMembers.save(), - await user.save(), - ]); - } - } - - if (party && !spell.silent) { - let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``; - party.sendChat(message); - await party.save(); - } - } - - user.getTransformedData(function (err, transformedUser) { - if (err) next(err); - res.json(transformedUser); - }); - } catch (e) { - return res.status(500).json({err: 'An error happened'}); - } -} - -// It supports guild too now but we'll stick to partyInvite for backward compatibility -api.sessionPartyInvite = function(req,res,next){ - if (!req.session.partyInvite) return next(); - var inv = res.locals.user.invitations; - if (inv.party && inv.party.id) return next(); // already invited to a party - asyncM.waterfall([ - function(cb){ - Group.findOne({_id:req.session.partyInvite.id, members:{$in:[req.session.partyInvite.inviter]}}) - .select('invites members type').exec(cb); - }, - function(group, cb){ - if (!group){ - // Don't send error as it will prevent users from using the site - delete req.session.partyInvite; - return cb(); - } - - if (group.type == 'guild'){ - inv.guilds.push(req.session.partyInvite); - } else{ - //req.body.type in 'guild', 'party' - inv.party = req.session.partyInvite; - } - inv.party = req.session.partyInvite; - delete req.session.partyInvite; - if (!~group.invites.indexOf(res.locals.user._id)) - group.invites.push(res.locals.user._id); //$addToSt - group.save(cb); - }, - function(saved, cb){ - res.locals.user.save(cb); - } - ], next); -} - -api.clearCompleted = function(req, res, next) { - var user = res.locals.user; - - Tasks.Task.remove({ - userId: user._id, - type: 'todo', - completed: true, - 'challenge.id': {$exists: false}, - }, function (err) { - if (err) return next(err); - - Tasks.Task.find({ - userId: user._id, - type: 'todo', - completed: false, - }, function (err, uncompleted) { - if (err) return next(err); - res.json(uncompleted); - }); - }); -}; - -api.sortTask = async function (req, res, next) { - try { - let user = res.locals.user; - let to = Number(req.query.to); - - let task = await Tasks.Task.findOne({ - _id: req.params.id, - userId: user._id, - }).exec(); - - if (!task) return res.status(404).json(i18n.t('messageTaskNotFound', req.language)); - if (task.type !== 'todo' || !task.completed) { - let order = user.tasksOrder[`${task.type}s`]; - let currentIndex = order.indexOf(task._id); - - // If for some reason the task isn't ordered (should never happen), push it in the new position - // if the task is moved to a non existing position - // or if the task is moved to position -1 (push to bottom) - // -> push task at end of list - if (!order[to] && to !== -1) { - order.push(task._id); - } else { - if (currentIndex !== -1) order.splice(currentIndex, 1); - if (to === -1) { - order.push(task._id); - } else { - order.splice(to, 0, task._id); - } - } - await user.save(); - } - - user.getTasks(function (err, userTasks) { - if(err) return next(err); - res.json(userTasks); - }); - } catch (e) { - res.status(500).json({err: 'An error happened.'}); - } -} - -api.deleteTask = function(req, res, next) { - var user = res.locals.user; - if(!req.params || !req.params.id) return res.json(404, shared.i18n.t('messageTaskNotFound', req.language)); - - var id = req.params.id; - // Try removing from all orders since we don't know the task's type - var removeTaskFromOrder = function(array) { - removeFromArray(array, id); - }; - - ['habits', 'dailys', 'todos', 'rewards'].forEach(function (type){ - removeTaskFromOrder(user.tasksOrder[type]) - }); - - asyncM.parallel({ - user: user.save.bind(user), - task: function(cb) { - Tasks.Task.remove({_id: id, userId: user._id}, cb); - } - }, function(err, results) { - if(err) return next(err); - - if(results.task.result.n < 1){ - return res.status(404).json({err: shared.i18n.t('messageTaskNotFound', req.language)}) - } - - res.status(200).json({}); - }); -}; - -api.updateTask = function(req, res, next) { - var user = res.locals.user, - id = req.params.id; - - req.body = Tasks.Task.fromJSONV2(req.body); - - findTaskByIdOrLegacyId(user, id, function (err, task) { - if(err) return next(err); - if(!task) return res.status(404).json({err: 'Task not found.'}) - - try { - _.assign(task, shared.ops.updateTask(task.toObject(), req)[0]); - task.save(function(err, task){ - if(err) return next(err); - - return res.json(task.toJSONV2()); - }); - } catch (err) { - return res.status(err.code).json({err: err.message}); - } - }); -}; - -api.addTask = function(req, res, next) { - var user = res.locals.user; - req.body.type = req.body.type || 'habit'; - req.body.text = req.body.text || 'text'; - req.body = Tasks.Task.fromJSONV2(req.body); - - var task = new Tasks[req.body.type](Tasks.Task.sanitize(req.body)); - - task.userId = user._id; - user.tasksOrder[task.type + 's'].unshift(task._id); - - // Validate that the task is valid and throw if it isn't - // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality - let validationErrors = task.validateSync(); - if (validationErrors) return next(validationErrors); - - Bluebird.all([ - user.save(), - task.save({validateBeforeSave: false}) // already done ^ - ]).then(results => { - res.status(200).json(results[1].toJSONV2()); - }).catch(next); -}; - -/** - * All other user.ops which can easily be mapped to common/script/index.js, not requiring custom API-wrapping - */ -_.each(shared.ops, function(op,k){ - var kv3; - - if (['rebirth', 'reroll', 'reset'].indexOf(k) !== -1) { // proxy ops that change tasks directly to v3 - if (k === 'rebirth') kv3 = 'userRebirth'; // the name is different in v3 - if (k === 'reroll') kv3 = 'userReroll'; - if (k === 'reset') kv3 = 'userReset'; - - api[k] = function (req, res, next) { - req.v2 = true; - v3UserController[kv3].handler(req, res, next).catch(next); - } - } else if (!api[k]) { - api[k] = function(req, res, next) { - var opResponse; - try { - req.v2 = true; // Used to indicate to the shared code that the old response data should be returned - opResponse = shared.ops[k](res.locals.user, req, analytics); - if (Array.isArray(opResponse) && opResponse.length < 3) { - opResponse = opResponse[0]; - } - } catch (err) { - if (!err.code) return next(err); - if (err.code >= 400) return res.status(err.code).json({err:err.message}); - } - - // If we want to send something other than 500, pass err as {code: 200, message: "Not enough GP"} - res.locals.user.save(function(err){ - if (err) return next(err); - if (opResponse === res.locals.user) { // add tasks - res.locals.user.getTransformedData(function (err, transformedUser) { - if (err) return next(err); - res.status(200).json(transformedUser); - }); - } else { - res.status(200).json(opResponse); - } - }); - } - } -}) - -/* - ------------------------------------------------------------------------ - Batch Update - Run a bunch of updates all at once - ------------------------------------------------------------------------ -*/ -api.batchUpdate = function(req, res, next) { - if (_.isEmpty(req.body)) req.body = []; // cases of {} or null - if (req.body[0] && req.body[0].data) - return res.status(501).json({err: "API has been updated, please refresh your browser or upgrade your mobile app."}) - - var user = res.locals.user; - var oldSend = res.send; - var oldJson = res.json; - - // Stash user.save, we'll queue the save op till the end (so we don't overload the server) - //var oldSave = user.save; - //user.save = function(cb){cb(null,user)} - - // Setup the array of functions we're going to call in parallel with async - res.locals.ops = []; - var ops = _.transform(req.body, function(m,_req){ - if (_.isEmpty(_req)) return; - _req.language = req.language; - - m.push(function() { - var cb = arguments[arguments.length-1]; - res.locals.ops.push(_req); - res.send = res.json = function(code, data) { - if (_.isNumber(code) && code >= 500) - return cb(code+": "+ (data.message ? data.message : data.err ? data.err : JSON.stringify(data))); - return cb(); - }; - if(!api[_req.op]) { return cb(shared.i18n.t('messageUserOperationNotFound', { operation: _req.op })); } - - api[_req.op](_req, res, cb); - }); - }) - // Finally, save user at the end - .concat(/*function(){ - user.save = oldSave; - user.save(arguments[arguments.length-1]); - }*/); - - // call all the operations, then return the user object to the requester - asyncM.waterfall(ops, function(err) { - res.json = oldJson; - res.send = oldSend; - if (err) return next(err); - - var response; - - // return only drops & streaks - if (user._tmp && user._tmp.drop){ - response = user.toJSON(); - res.status(200).json({_tmp: {drop: response._tmp.drop}, _v: response._v}); - - // Fetch full user object - } else if (res.locals.wasModified){ - // Preen 3-day past-completed To-Dos from Angular & mobile app - user.getTransformedData(function(err, transformedData){ - if (err) next(err); - response = transformedData; - - response.todos = shared.preenTodos(response.todos); - response.wasModified = true; - res.status(200).json(response); - }); - // return only the version number - } else{ - response = user.toJSON(); - res.status(200).json({_v: response._v}); - } - - //user.fns.nullify(); - user = res.locals.user = oldSend = oldJson = null; - }); -}; - -function _generateWebhookTaskData(task, direction, delta, stats, user) { - var extendedStats = _.extend(stats, { - toNextLevel: shared.tnl(user.stats.lvl), - maxHealth: shared.maxHealth, - maxMP: user._statsComputed.maxMP - }); - - var userData = { - _id: user._id, - _tmp: user._tmp, - stats: extendedStats - }; - - var taskData = { - details: task, - direction: direction, - delta: delta - } - - return { - task: taskData, - user: userData - } -} diff --git a/website/server/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js deleted file mode 100644 index f8eabf4c82..0000000000 --- a/website/server/controllers/api-v3/auth.js +++ /dev/null @@ -1,512 +0,0 @@ -import validator from 'validator'; -import moment from 'moment'; -import passport from 'passport'; -import nconf from 'nconf'; -import { - authWithHeaders, -} from '../../middlewares/api-v3/auth'; -import { - NotAuthorized, - BadRequest, - NotFound, -} from '../../libs/api-v3/errors'; -import Bluebird from 'bluebird'; -import * as passwordUtils from '../../libs/api-v3/password'; -import logger from '../../libs/api-v3/logger'; -import { model as User } from '../../models/user'; -import { model as Group } from '../../models/group'; -import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; -import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; -import { decrypt } from '../../libs/api-v3/encryption'; -import FirebaseTokenGenerator from 'firebase-token-generator'; -import { send as sendEmail } from '../../libs/api-v3/email'; - -let api = {}; - -// When the user signed up after having been invited to a group, invite them automatically to the group -async function _handleGroupInvitation (user, invite) { - // wrapping the code in a try because we don't want it to prevent the user from signing up - // that's why errors are not translated - try { - let {sentAt, id: groupId, inviter} = JSON.parse(decrypt(invite)); - - // check that the invite has not expired (after 7 days) - if (sentAt && moment().subtract(7, 'days').isAfter(sentAt)) { - let err = new Error('Invite expired.'); - err.privateData = invite; - throw err; - } - - let group = await Group.getGroup({user, optionalMembership: true, groupId, fields: 'name type'}); - if (!group) throw new NotFound('Group not found.'); - - if (group.type === 'party') { - user.invitations.party = {id: group._id, name: group.name, inviter}; - } else { - user.invitations.guilds.push({id: group._id, name: group.name, inviter}); - } - } catch (err) { - logger.error(err); - } -} - -/** - * @api {post} /api/v3/user/auth/local/register Register - * @apiDescription Register a new user with email, username and password or attach local auth to a social user - * @apiVersion 3.0.0 - * @apiName UserRegisterLocal - * @apiGroup User - * - * @apiParam {String} username Body parameter - Username of the new user - * @apiParam {String} email Body parameter - Email address of the new user - * @apiParam {String} password Body parameter - Password for the new user - * @apiParam {String} confirmPassword Body parameter - Password confirmation - * - * @apiSuccess {Object} data The user object, if local auth was just attached to a social user then only user.auth.local - */ -api.registerLocal = { - method: 'POST', - middlewares: [authWithHeaders(true)], - url: '/user/auth/local/register', - async handler (req, res) { - let fbUser = res.locals.user; // If adding local auth to social user - - req.checkBody({ - email: { - notEmpty: {errorMessage: res.t('missingEmail')}, - isEmail: {errorMessage: res.t('notAnEmail')}, - }, - username: {notEmpty: {errorMessage: res.t('missingUsername')}}, - password: { - notEmpty: {errorMessage: res.t('missingPassword')}, - equals: {options: [req.body.confirmPassword], errorMessage: res.t('passwordConfirmationMatch')}, - }, - }); - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let { email, username, password } = req.body; - - // Get the lowercase version of username to check that we do not have duplicates - // So we can search for it in the database and then reject the choosen username if 1 or more results are found - email = email.toLowerCase(); - let lowerCaseUsername = username.toLowerCase(); - - // Search for duplicates using lowercase version of username - let user = await User.findOne({$or: [ - {'auth.local.email': email}, - {'auth.local.lowerCaseUsername': lowerCaseUsername}, - ]}, {'auth.local': 1}).exec(); - - if (user) { - if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken')); - // Check that the lowercase username isn't already used - if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken')); - } - - let salt = passwordUtils.makeSalt(); - let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase - let newUser = { - auth: { - local: { - username, - lowerCaseUsername, - email, - salt, - hashed_password, // eslint-disable-line camelcase - }, - }, - preferences: { - language: req.language, - }, - }; - - if (fbUser) { - if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal')); - fbUser.auth.local = newUser.auth.local; - newUser = fbUser; - } else { - newUser = new User(newUser); - newUser.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used - } - - // we check for partyInvite for backward compatibility - if (req.query.groupInvite || req.query.partyInvite) { - await _handleGroupInvitation(newUser, req.query.groupInvite || req.query.partyInvite); - } - - let savedUser = await newUser.save(); - - if (savedUser.auth.facebook.id) { - res.respond(200, savedUser.toJSON().auth.local); // We convert to toJSON to hide private fields - } else { - res.respond(201, savedUser); - } - - // Clean previous email preferences and send welcome email - EmailUnsubscription - .remove({email: savedUser.auth.local.email}) - .then(() => sendTxnEmail(savedUser, 'welcome')); - - if (!savedUser.auth.facebook.id) { - res.analytics.track('register', { - category: 'acquisition', - type: 'local', - gaLabel: 'local', - uuid: savedUser._id, - }); - } - - return null; - }, -}; - -function _loginRes (user, req, res) { - if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id})); - return res.respond(200, {id: user._id, apiToken: user.apiToken}); -} - -/** - * @api {post} /api/v3/user/auth/local/login Login - * @apiDescription Login a user with email / username and password - * @apiVersion 3.0.0 - * @apiName UserLoginLocal - * @apiGroup User - * - * @apiParam {String} username Body parameter - Username or email of the user - * @apiParam {String} password Body parameter - The user's password - * - * @apiSuccess {String} data._id The user's unique identifier - * @apiSuccess {String} data.apiToken The user's api token that must be used to authenticate requests. - */ -api.loginLocal = { - method: 'POST', - url: '/user/auth/local/login', - middlewares: [], - async handler (req, res) { - req.checkBody({ - username: { - notEmpty: true, - errorMessage: res.t('missingUsernameEmail'), - }, - password: { - notEmpty: true, - errorMessage: res.t('missingPassword'), - }, - }); - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - req.sanitizeBody('username').trim(); - req.sanitizeBody('password').trim(); - - let login; - let username = req.body.username; - - if (validator.isEmail(username)) { - login = {'auth.local.email': username.toLowerCase()}; // Emails are stored lowercase - } else { - login = {'auth.local.username': username}; - } - - let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec(); - let isValidPassword = user && user.auth.local.hashed_password === passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentialsLong')); - return _loginRes(user, ...arguments); - }, -}; - -function _passportFbProfile (accessToken) { - return new Bluebird((resolve, reject) => { - passport._strategies.facebook.userProfile(accessToken, (err, profile) => { - if (err) { - reject(err); - } else { - resolve(profile); - } - }); - }); -} - -// Called as a callback by Facebook (or other social providers). Internal route -api.loginSocial = { - method: 'POST', - url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 - async handler (req, res) { - let accessToken = req.body.authResponse.access_token; - let network = req.body.network; - - if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); - - let profile = await _passportFbProfile(accessToken); - - let user = await User.findOne({ - [`auth.${network}.id`]: profile.id, - }, {_id: 1, apiToken: 1, auth: 1}).exec(); - - // User already signed up - if (user) { - _loginRes(user, ...arguments); - } else { // Create new user - user = new User({ - auth: { - [network]: profile, - }, - preferences: { - language: req.language, - }, - }); - user.registeredThrough = req.headers['x-client']; - - let savedUser = await user.save(); - - _loginRes(user, ...arguments); - - // Clean previous email preferences - if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) { - EmailUnsubscription - .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()}) - .exec() - .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks - } - - res.analytics.track('register', { - category: 'acquisition', - type: network, - gaLabel: network, - uuid: savedUser._id, - }); - - return null; - } - }, -}; - -/** - * @api {put} /api/v3/user/auth/update-username Update username - * @apiDescription Update the username of a local user - * @apiVersion 3.0.0 - * @apiName UpdateUsername - * @apiGroup User - * - * @apiParam {string} password Body parameter - The current user password - * @apiParam {string} username Body parameter - The new username - - * @apiSuccess {String} data.username The new username - **/ -api.updateUsername = { - method: 'PUT', - middlewares: [authWithHeaders()], - url: '/user/auth/update-username', - async handler (req, res) { - let user = res.locals.user; - - req.checkBody({ - password: { - notEmpty: {errorMessage: res.t('missingPassword')}, - }, - username: { - notEmpty: { errorMessage: res.t('missingUsername') }, - }, - }); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - if (!user.auth.local.username) throw new BadRequest(res.t('userHasNoLocalRegistration')); - - let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); - - let count = await User.count({ 'auth.local.lowerCaseUsername': req.body.username.toLowerCase() }); - if (count > 0) throw new BadRequest(res.t('usernameTaken')); - - // save username - user.auth.local.lowerCaseUsername = req.body.username.toLowerCase(); - user.auth.local.username = req.body.username; - await user.save(); - - res.respond(200, { username: req.body.username }); - }, -}; - -/** - * @api {put} /api/v3/user/auth/update-password - * @apiDescription Update the password of a local user - * @apiVersion 3.0.0 - * @apiName UpdatePassword - * @apiGroup User - * - * @apiParam {string} password Body parameter - The old password - * @apiParam {string} newPassword Body parameter - The new password - * @apiParam {string} confirmPassword Body parameter - New password confirmation - * - * @apiSuccess {Object} data An empty object - **/ -api.updatePassword = { - method: 'PUT', - middlewares: [authWithHeaders()], - url: '/user/auth/update-password', - async handler (req, res) { - let user = res.locals.user; - - if (!user.auth.local.hashed_password) throw new BadRequest(res.t('userHasNoLocalRegistration')); - - let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); - - req.checkBody({ - password: { - notEmpty: {errorMessage: res.t('missingNewPassword')}, - }, - newPassword: { - notEmpty: {errorMessage: res.t('missingPassword')}, - }, - }); - - if (req.body.newPassword !== req.body.confirmPassword) throw new NotAuthorized(res.t('passwordConfirmationMatch')); - - user.auth.local.hashed_password = passwordUtils.encrypt(req.body.newPassword, user.auth.local.salt); // eslint-disable-line camelcase - await user.save(); - res.respond(200, {}); - }, -}; - -/** - * @api {post} /api/v3/user/reset-password Reset password - * @apiDescription Reset the user password - * @apiVersion 3.0.0 - * @apiName ResetPassword - * @apiGroup User - * - * @apiParam {string} email Body parameter - The email address of the user - * - * @apiSuccess {string} message The localized success message - **/ -api.resetPassword = { - method: 'POST', - middlewares: [], - url: '/user/reset-password', - async handler (req, res) { - req.checkBody({ - email: { - notEmpty: {errorMessage: res.t('missingEmail')}, - }, - }); - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let email = req.body.email.toLowerCase(); - let salt = passwordUtils.makeSalt(); - let newPassword = passwordUtils.makeSalt(); // use a salt as the new password too (they'll change it later) - let hashedPassword = passwordUtils.encrypt(newPassword, salt); - - let user = await User.findOne({ 'auth.local.email': email }, { 'auth.local': 1 }); - - if (user) { - user.auth.local.salt = salt; - user.auth.local.hashed_password = hashedPassword; // eslint-disable-line camelcase - sendEmail({ - from: 'Habitica ', - to: email, - subject: res.t('passwordResetEmailSubject'), - text: res.t('passwordResetEmailText', { username: user.auth.local.username, - newPassword, - baseUrl: nconf.get('BASE_URL'), - }), - html: res.t('passwordResetEmailHtml', { username: user.auth.local.username, - newPassword, - baseUrl: nconf.get('BASE_URL'), - }), - }); - await user.save(); - } - res.respond(200, {}, res.t('passwordReset')); - }, -}; - -/** - * @api {put} /api/v3/user/auth/update-email Update email - * @apiDescription Change the user email address - * @apiVersion 3.0.0 - * @apiName UpdateEmail - * @apiGroup User - * - * @apiParam {string} Body parameter - newEmail The new email address. - * @apiParam {string} Body parameter - password The user password. - * - * @apiSuccess {string} data.email The updated email address - */ -api.updateEmail = { - method: 'PUT', - middlewares: [authWithHeaders()], - url: '/user/auth/update-email', - async handler (req, res) { - let user = res.locals.user; - - if (!user.auth.local.email) throw new BadRequest(res.t('userHasNoLocalRegistration')); - - req.checkBody('newEmail', res.t('newEmailRequired')).notEmpty().isEmail(); - req.checkBody('password', res.t('missingPassword')).notEmpty(); - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let candidatePassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (candidatePassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); - - user.auth.local.email = req.body.newEmail; - await user.save(); - - return res.respond(200, { email: user.auth.local.email }); - }, -}; - -const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); - -// Internal route -api.getFirebaseToken = { - method: 'POST', - url: '/user/auth/firebase', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - // Expires 24 hours from now (60*60*24*1000) (in milliseconds) - let expires = new Date(); - expires.setTime(expires.getTime() + 86400000); - - let token = firebaseTokenGenerator.createToken({ - uid: user._id, - isHabiticaUser: true, - }, { expires }); - - res.respond(200, {token, expires}); - }, -}; - -/** - * @api {delete} /api/v3/user/auth/social/:network Delete social authentication method - * @apiDescription Remove a social authentication method (only facebook supported) from a user profile. The user must have local authentication enabled - * @apiVersion 3.0.0 - * @apiName UserDeleteSocial - * @apiGroup User - * - * @apiSuccess {Object} data Empty object - */ -api.deleteSocial = { - method: 'DELETE', - url: '/user/auth/social/:network', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let network = req.params.network; - - if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); - if (!user.auth.local.username) throw new NotAuthorized(res.t('cantDetachFb')); - - await User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}).exec(); - - res.respond(200, {}); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/challenges.js b/website/server/controllers/api-v3/challenges.js deleted file mode 100644 index 6d7bc56667..0000000000 --- a/website/server/controllers/api-v3/challenges.js +++ /dev/null @@ -1,518 +0,0 @@ -import { authWithHeaders, authWithSession } from '../../middlewares/api-v3/auth'; -import _ from 'lodash'; -import { model as Challenge } from '../../models/challenge'; -import { - model as Group, - basicFields as basicGroupFields, - TAVERN_ID, -} from '../../models/group'; -import { - model as User, - nameFields, -} from '../../models/user'; -import { - NotFound, - NotAuthorized, -} from '../../libs/api-v3/errors'; -import * as Tasks from '../../models/task'; -import Bluebird from 'bluebird'; -import csvStringify from '../../libs/api-v3/csvStringify'; - -let api = {}; - -/** - * @api {post} /api/v3/challenges Create a new challenge - * @apiVersion 3.0.0 - * @apiName CreateChallenge - * @apiGroup Challenge - * - * @apiSuccess {object} data The newly created challenge - */ -api.createChallenge = { - method: 'POST', - url: '/challenges', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkBody('group', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let groupId = req.body.group; - let prize = req.body.prize; - - let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true}); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (!group.isMember(user)) throw new NotAuthorized(res.t('mustBeGroupMember')); - - if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { - throw new NotAuthorized(res.t('onlyGroupLeaderChal')); - } - - if (group._id === TAVERN_ID && prize < 1) { - throw new NotAuthorized(res.t('tavChalsMinPrize')); - } - - if (prize > 0) { - let groupBalance = group.balance && group.leader === user._id ? group.balance : 0; - let prizeCost = prize / 4; - - if (prizeCost > user.balance + groupBalance) { - throw new NotAuthorized(res.t('cantAfford')); - } - - if (groupBalance >= prizeCost) { - // Group pays for all of prize - group.balance -= prizeCost; - } else if (groupBalance > 0) { - // User pays remainder of prize cost after group - let remainder = prizeCost - group.balance; - group.balance = 0; - user.balance -= remainder; - } else { - // User pays for all of prize - user.balance -= prizeCost; - } - } - - group.challengeCount += 1; - - req.body.leader = user._id; - req.body.official = user.contributor.admin && req.body.official ? true : false; - let challenge = new Challenge(Challenge.sanitize(req.body)); - - // First validate challenge so we don't save group if it's invalid (only runs sync validators) - let challengeValidationErrors = challenge.validateSync(); - if (challengeValidationErrors) throw challengeValidationErrors; - - let results = await Bluebird.all([challenge.save({ - validateBeforeSave: false, // already validate - }), group.save()]); - let savedChal = results[0]; - - await savedChal.syncToUser(user); // (it also saves the user) - - let response = savedChal.toJSON(); - response.leader = { // the leader is the authenticated user - _id: user._id, - profile: {name: user.profile.name}, - }; - response.group = { // we already have the group data - _id: group._id, - name: group.name, - type: group.type, - privacy: group.privacy, - }; - - res.respond(201, response); - }, -}; - -/** - * @api {post} /api/v3/challenges/:challengeId/join Joins a challenge - * @apiVersion 3.0.0 - * @apiName JoinChallenge - * @apiGroup Challenge - * @apiParam {UUID} challengeId The challenge _id - * - * @apiSuccess {object} data The challenge the user joined - */ -api.joinChallenge = { - method: 'POST', - url: '/challenges/:challengeId/join', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let challenge = await Challenge.findOne({ _id: req.params.challengeId }); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); - - let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true}); - if (!group || !challenge.hasAccess(user, group)) throw new NotFound(res.t('challengeNotFound')); - - challenge.memberCount += 1; - - // Add all challenge's tasks to user's tasks and save the challenge - let results = await Bluebird.all([challenge.syncToUser(user), challenge.save()]); - - let response = results[1].toJSON(); - response.group = { // we already have the group data - _id: group._id, - name: group.name, - type: group.type, - privacy: group.privacy, - }; - let chalLeader = await User.findById(response.leader).select(nameFields).exec(); - response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null; - - res.respond(200, response); - }, -}; - -/** - * @api {post} /api/v3/challenges/:challengeId/leave Leaves a challenge - * @apiVersion 3.0.0 - * @apiName LeaveChallenge - * @apiGroup Challenge - * @apiParam {UUID} challengeId The challenge _id - * - * @apiSuccess {object} data An empty object - */ -api.leaveChallenge = { - method: 'POST', - url: '/challenges/:challengeId/leave', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let keep = req.body.keep === 'remove-all' ? 'remove-all' : 'keep-all'; - - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let challenge = await Challenge.findOne({ _id: req.params.challengeId }); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - - let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy'}); - if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - - if (!challenge.isMember(user)) throw new NotAuthorized(res.t('challengeMemberNotFound')); - - challenge.memberCount -= 1; - - // Unlink challenge's tasks from user's tasks and save the challenge - await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]); - res.respond(200, {}); - }, -}; - -/** - * @api {get} /api/v3/challenges/user Get challenges for a user - * @apiVersion 3.0.0 - * @apiName GetUserChallenges - * @apiGroup Challenge - * - * @apiSuccess {Array} data An array of challenges - */ -api.getUserChallenges = { - method: 'GET', - url: '/challenges/user', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - let challenges = await Challenge.find({ - $or: [ - {_id: {$in: user.challenges}}, // Challenges where the user is participating - {group: {$in: user.getGroups()}}, // Challenges in groups where I'm a member - {leader: user._id}, // Challenges where I'm the leader - ], - _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit - }) - .sort('-official -timestamp') - // see below why we're not using populate - // .populate('group', basicGroupFields) - // .populate('leader', nameFields) - .exec(); - - let resChals = challenges.map(challenge => challenge.toJSON()); - // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - await Bluebird.all(resChals.map((chal, index) => { - return Bluebird.all([ - User.findById(chal.leader).select(nameFields).exec(), - Group.findById(chal.group).select(basicGroupFields).exec(), - ]).then(populatedData => { - resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null; - resChals[index].group = populatedData[1] ? populatedData[1].toJSON({minimize: true}) : null; - }); - })); - - res.respond(200, resChals); - }, -}; - -/** - * @api {get} /api/v3/challenges/group/group:Id Get challenges for a group - * @apiDescription Get challenges that the user is a member, public challenges and the ones from the user's groups. - * @apiVersion 3.0.0 - * @apiName GetGroupChallenges - * @apiGroup Challenge - * - * @apiParam {groupId} groupId The group _id - * - * @apiSuccess {Array} data An array of challenges - */ -api.getGroupChallenges = { - method: 'GET', - url: '/challenges/groups/:groupId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let groupId = req.params.groupId; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId}); - if (!group) throw new NotFound(res.t('groupNotFound')); - - let challenges = await Challenge.find({group: groupId}) - .sort('-official -timestamp') - // .populate('leader', nameFields) // Only populate the leader as the group is implicit - .exec(); - - let resChals = challenges.map(challenge => challenge.toJSON()); - // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - await Bluebird.all(resChals.map((chal, index) => { - return User.findById(chal.leader).select(nameFields).exec().then(populatedLeader => { - resChals[index].leader = populatedLeader ? populatedLeader.toJSON({minimize: true}) : null; - }); - })); - - res.respond(200, resChals); - }, -}; - -/** - * @api {get} /api/v3/challenges/:challengeId Get a challenge given its id - * @apiVersion 3.0.0 - * @apiName GetChallenge - * @apiGroup Challenge - * - * @apiParam {UUID} challengeId The challenge _id - * - * @apiSuccess {object} data The challenge object - */ -api.getChallenge = { - method: 'GET', - url: '/challenges/:challengeId', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let challengeId = req.params.challengeId; - - let challenge = await Challenge.findById(challengeId) - // Don't populate the group as we'll fetch it manually later - // .populate('leader', nameFields) - .exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - - // Fetching basic group data - let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true}); - if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - - let chalRes = challenge.toJSON(); - chalRes.group = group.toJSON({minimize: true}); - // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - let chalLeader = await User.findById(chalRes.leader).select(nameFields).exec(); - chalRes.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null; - - res.respond(200, chalRes); - }, -}; - -/** - * @api {get} /api/v3/challenges/:challengeId/export/csv Export a challenge in CSV - * @apiVersion 3.0.0 - * @apiName ExportChallengeCsv - * @apiGroup Challenge - * - * @apiParam {UUID} challengeId The challenge _id - * - * @apiSuccess {string} challenge A csv file - */ -api.exportChallengeCsv = { - method: 'GET', - url: '/challenges/:challengeId/export/csv', - middlewares: [authWithSession], - async handler (req, res) { - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let challengeId = req.params.challengeId; - - let challenge = await Challenge.findById(challengeId).select('_id group leader tasksOrder').exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); - if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - - // In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all - // results on the server so the perf difference isn't that big (hopefully) - - let [members, tasks] = await Bluebird.all([ - User.find({challenges: challengeId}) - .select(nameFields) - .sort({_id: 1}) - .lean() // so we don't involve mongoose - .exec(), - - Tasks.Task.find({'challenge.id': challengeId, userId: {$exists: true}}) - .sort({userId: 1, text: 1}).select('userId type text value notes').lean().exec(), - ]); - - let resArray = members.map(member => [member._id, member.profile.name]); - - // We assume every user in the challenge as at least some data so we can say that members[0] tasks will be at tasks [0] - let lastUserId; - let index = -1; - tasks.forEach(task => { - if (task.userId !== lastUserId) { - lastUserId = task.userId; - index++; - } - - resArray[index].push(`${task.type}:${task.text}`, task.value, task.notes); - }); - - // The first row is going to be UUID name Task Value Notes repeated n times for the n challenge tasks - let challengeTasks = _.reduce(challenge.tasksOrder.toObject(), (result, array) => { - return result.concat(array); - }, []).sort(); - resArray.unshift(['UUID', 'name']); - _.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes')); - - res.set({ - 'Content-Type': 'text/csv', - 'Content-disposition': `attachment; filename=${challengeId}.csv`, - }); - - let csvRes = await csvStringify(resArray); - res.status(200).send(csvRes); - }, -}; - -/** - * @api {put} /api/v3/challenges/:challengeId Update a challenge - * @apiVersion 3.0.0 - * @apiName UpdateChallenge - * @apiGroup Challenge - * - * @apiParam {UUID} challengeId The challenge _id - * - * @apiSuccess {object} data The updated challenge - */ -api.updateChallenge = { - method: 'PUT', - url: '/challenges/:challengeId', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let challengeId = req.params.challengeId; - - let challenge = await Challenge.findById(challengeId).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - - let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true}); - if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderUpdateChal')); - - _.merge(challenge, Challenge.sanitizeUpdate(req.body)); - - let savedChal = await challenge.save(); - let response = savedChal.toJSON(); - response.group = { // we already have the group data - _id: group._id, - name: group.name, - type: group.type, - privacy: group.privacy, - }; - let chalLeader = await User.findById(response.leader).select(nameFields).exec(); - response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null; - res.respond(200, response); - }, -}; - -/** - * @api {delete} /api/v3/challenges/:challengeId Delete a challenge - * @apiVersion 3.0.0 - * @apiName DeleteChallenge - * @apiGroup Challenge - * - * @apiParam {UUID} challengeId The _id for the challenge to delete - * - * @apiSuccess {object} data An empty object - */ -api.deleteChallenge = { - method: 'DELETE', - url: '/challenges/:challengeId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); - - // Close channel in background, some ops are run in the background without `await`ing - await challenge.closeChal({broken: 'CHALLENGE_DELETED'}); - res.respond(200, {}); - }, -}; - -/** - * @api {post} /api/v3/challenges/:challengeId/selectWinner/:winnerId Select winner for challenge - * @apiVersion 3.0.0 - * @apiName SelectChallengeWinner - * @apiGroup Challenge - * - * @apiParam {UUID} challengeId The _id for the challenge to close with a winner - * @apiParam {UUID} winnerId The _id of the winning user - * - * @apiSuccess {object} data An empty object - */ -api.selectChallengeWinner = { - method: 'POST', - url: '/challenges/:challengeId/selectWinner/:winnerId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); - - let winner = await User.findOne({_id: req.params.winnerId}).exec(); - if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId})); - - // Close channel in background, some ops are run in the background without `await`ing - await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner}); - res.respond(200, {}); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js deleted file mode 100644 index 7738189b6f..0000000000 --- a/website/server/controllers/api-v3/chat.js +++ /dev/null @@ -1,398 +0,0 @@ -import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import { - model as Group, - TAVERN_ID, -} from '../../models/group'; -import { model as User } from '../../models/user'; -import { - NotFound, - NotAuthorized, -} from '../../libs/api-v3/errors'; -import _ from 'lodash'; -import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; -import { sendTxn } from '../../libs/api-v3/email'; -import nconf from 'nconf'; -import Bluebird from 'bluebird'; - -const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { - return { email, canSend: true }; -}); - -let api = {}; - -/** - * @api {get} /api/v3/groups/:groupId/chat Get chat messages from a group - * @apiVersion 3.0.0 - * @apiName GetChat - * @apiGroup Chat - * - * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * - * @apiSuccess {Array} data An array of chat messages - */ -api.getChat = { - method: 'GET', - url: '/groups/:groupId/chat', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'chat'}); - if (!group) throw new NotFound(res.t('groupNotFound')); - - res.respond(200, Group.toJSONCleanChat(group, user).chat); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/chat Post chat message to a group - * @apiVersion 3.0.0 - * @apiName PostCat - * @apiGroup Chat - * - * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {message} Body parameter - message The message to post - * @apiParam {previousMsg} previousMsg Query parameter - The previous chat message which will force a return of the full group chat - * - * @apiSuccess data An array of chat messages if a new message was posted after previousMsg, otherwise the posted message - */ -api.postChat = { - method: 'POST', - url: '/groups/:groupId/chat', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let groupId = req.params.groupId; - let chatUpdated; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - req.checkBody('message', res.t('messageGroupChatBlankMessage')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party' && user.flags.chatRevoked) { - throw new NotFound('Your chat privileges have been revoked.'); - } - - let lastClientMsg = req.query.previousMsg; - chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; - - group.sendChat(req.body.message, user); - - let toSave = [group.save()]; - - if (group.type === 'party') { - user.party.lastMessageSeen = group.chat[0].id; - toSave.push(user.save()); - } - - let [savedGroup] = await Bluebird.all(toSave); - if (chatUpdated) { - res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat}); - } else { - res.respond(200, {message: savedGroup.chat[0]}); - } - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/chat/:chatId/like Like a group chat message - * @apiVersion 3.0.0 - * @apiName LikeChat - * @apiGroup Chat - * - * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {chatId} chatId The chat message _id - * - * @apiSuccess {Object} data The liked chat message - */ -api.likeChat = { - method: 'POST', - url: '/groups/:groupId/chat/:chatId/like', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let groupId = req.params.groupId; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId}); - if (!group) throw new NotFound(res.t('groupNotFound')); - - let message = _.find(group.chat, {id: req.params.chatId}); - if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); - if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); - - let update = {$set: {}}; - - if (!message.likes) message.likes = {}; - - message.likes[user._id] = !message.likes[user._id]; - update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id]; - - await Group.update( - {_id: group._id, 'chat.id': message.id}, - update - ); - res.respond(200, message); // TODO what if the message is flagged and shouldn't be returned? - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/chat/:chatId/like Like a group chat message - * @apiVersion 3.0.0 - * @apiName LikeChat - * @apiGroup Chat - * - * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {chatId} chatId The chat message id - * - * @apiSuccess {object} data The flagged chat message - */ -api.flagChat = { - method: 'POST', - url: '/groups/:groupId/chat/:chatId/flag', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let groupId = req.params.groupId; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId}); - if (!group) throw new NotFound(res.t('groupNotFound')); - let message = _.find(group.chat, {id: req.params.chatId}); - - if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); - - if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage')); - - let author = await User.findOne({_id: message.uuid}, {auth: 1}); - - let update = {$set: {}}; - - // Log user ids that have flagged the message - if (!message.flags) message.flags = {}; - if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported')); - message.flags[user._id] = true; - update.$set[`chat.$.flags.${user._id}`] = true; - - // Log total number of flags (publicly viewable) - if (!message.flagCount) message.flagCount = 0; - if (user.contributor.admin) { - // Arbitraty amount, higher than 2 - message.flagCount = 5; - } else { - message.flagCount++; - } - update.$set['chat.$.flagCount'] = message.flagCount; - - await Group.update( - {_id: group._id, 'chat.id': message.id}, - update - ); - - let reporterEmailContent; - if (user.auth.local) { - reporterEmailContent = user.auth.local.email; - } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) { - reporterEmailContent = user.auth.facebook.emails[0].value; - } - - let authorEmailContent; - if (author.auth.local) { - authorEmailContent = author.auth.local.email; - } else if (author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) { - authorEmailContent = author.auth.facebook.emails[0].value; - } - - let groupUrl; - if (group._id === TAVERN_ID) { - groupUrl = '/#/options/groups/tavern'; - } else if (group.type === 'guild') { - groupUrl = `/#/options/groups/guilds/${group._id}`; - } else { - groupUrl = 'party'; - } - - sendTxn(FLAG_REPORT_EMAILS, 'flag-report-to-mods', [ - {name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()}, - {name: 'MESSAGE_TEXT', content: message.text}, - - {name: 'REPORTER_USERNAME', content: user.profile.name}, - {name: 'REPORTER_UUID', content: user._id}, - {name: 'REPORTER_EMAIL', content: reporterEmailContent}, - {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId=${user._id}`}, - - {name: 'AUTHOR_USERNAME', content: message.user}, - {name: 'AUTHOR_UUID', content: message.uuid}, - {name: 'AUTHOR_EMAIL', content: authorEmailContent}, - {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${message.uuid}`}, - - {name: 'GROUP_NAME', content: group.name}, - {name: 'GROUP_TYPE', content: group.type}, - {name: 'GROUP_ID', content: group._id}, - {name: 'GROUP_URL', content: groupUrl}, - ]); - - res.respond(200, message); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/chat/:chatId/clear-flags Clear a group chat message's flags - * @apiDescription Admin-only - * @apiVersion 3.0.0 - * @apiName ClearFlags - * @apiGroup Chat - * - * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {chatId} chatId The chat message id - * - * @apiSuccess {Object} data An empty object - */ -api.clearChatFlags = { - method: 'Post', - url: '/groups/:groupId/chat/:chatId/clearflags', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let groupId = req.params.groupId; - let chatId = req.params.chatId; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - if (!user.contributor.admin) { - throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount')); - } - - let group = await Group.getGroup({user, groupId}); - if (!group) throw new NotFound(res.t('groupNotFound')); - - let message = _.find(group.chat, {id: chatId}); - if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); - - message.flagCount = 0; - - await Group.update( - {_id: group._id, 'chat.id': message.id}, - {$set: {'chat.$.flagCount': message.flagCount}} - ); - - res.respond(200, {}); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/chat/:chatId/seen Seen a group chat message - * @apiVersion 3.0.0 - * @apiName SeenChat - * @apiGroup Chat - * - * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * - * @apiSuccess {Object} data An empty object - */ -api.seenChat = { - method: 'POST', - url: '/groups/:groupId/chat/seen', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let groupId = req.params.groupId; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - // Do not validate group existence, it doesn't really matter and make it works if the group gets deleted - // let group = await Group.getGroup({user, groupId}); - // if (!group) throw new NotFound(res.t('groupNotFound')); - - let update = {$unset: {}}; - update.$unset[`newMessages.${groupId}`] = true; - - await User.update({_id: user._id}, update).exec(); - res.respond(200, {}); - }, -}; - -/** - * @api {delete} /api/v3/groups/:groupId/chat/:chatId Delete chat message from a group - * @apiVersion 3.0.0 - * @apiName DeleteChat - * @apiGroup Chat - * - * @apiParam {string} previousMsg Query parameter - The last message fetched by the client so that the whole chat will be returned only if new messages have been posted in the meantime - * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {string} chatId The chat message id - * - * @apiSuccess data The updated chat array or an empty object if no message was posted after previousMsg - * @apiSuccess {Object} data An empty object when the previous message was deleted - */ -api.deleteChat = { - method: 'DELETE', - url: '/groups/:groupId/chat/:chatId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let groupId = req.params.groupId; - let chatId = req.params.chatId; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId, fields: 'chat'}); - if (!group) throw new NotFound(res.t('groupNotFound')); - - let message = _.find(group.chat, {id: chatId}); - if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); - - if (user._id !== message.uuid && !user.contributor.admin) { - throw new NotAuthorized(res.t('onlyCreatorOrAdminCanDeleteChat')); - } - - let lastClientMsg = req.query.previousMsg; - let chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; - - await Group.update( - {_id: group._id}, - {$pull: {chat: {id: chatId}}} - ); - - if (chatUpdated) { - let chatRes = Group.toJSONCleanChat(group, user).chat; - removeFromArray(chatRes, {id: chatId}); - res.respond(200, chatRes); - } else { - res.respond(200, {}); - } - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/content.js b/website/server/controllers/api-v3/content.js deleted file mode 100644 index 0235f61645..0000000000 --- a/website/server/controllers/api-v3/content.js +++ /dev/null @@ -1,110 +0,0 @@ -import common from '../../../../common'; -import _ from 'lodash'; -import { langCodes } from '../../libs/api-v3/i18n'; -import Bluebird from 'bluebird'; -import fsCallback from 'fs'; -import path from 'path'; -import logger from '../../libs/api-v3/logger'; - -// Transform fs methods that accept callbacks in ones that return promises -const fs = { - readFile: Bluebird.promisify(fsCallback.readFile, {context: fsCallback}), - writeFile: Bluebird.promisify(fsCallback.writeFile, {context: fsCallback}), - stat: Bluebird.promisify(fsCallback.stat, {context: fsCallback}), - mkdir: Bluebird.promisify(fsCallback.mkdir, {context: fsCallback}), -}; - -let api = {}; - -function walkContent (obj, lang) { - _.each(obj, (item, key, source) => { - if (_.isPlainObject(item) || _.isArray(item)) return walkContent(item, lang); - if (_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang); - }); -} - -// After the getContent route is called the first time for a certain language -// the response is saved on disk and subsequentially served directly from there to reduce computation. -// Example: if `cachedContentResponses.en` is true it means that the response is cached -let cachedContentResponses = {}; - -// Language key set to true while the cache file is being written -let cacheBeingWritten = {}; - -_.each(langCodes, code => { - cachedContentResponses[code] = false; - cacheBeingWritten[code] = false; -}); - - -const CONTENT_CACHE_PATH = path.join(__dirname, '/../../../build/content_cache/'); - -async function saveContentToDisk (language, content) { - try { - cacheBeingWritten[language] = true; - - await fs.stat(CONTENT_CACHE_PATH); // check if the directory exists, if it doesn't an error is thrown - await fs.writeFile(`${CONTENT_CACHE_PATH}${language}.json`, content, 'utf8'); - - cacheBeingWritten[language] = false; - cachedContentResponses[language] = true; - } catch (err) { - if (err.code === 'ENOENT' && err.syscall === 'stat') { // the directory doesn't exists, create it and retry - await fs.mkdir(CONTENT_CACHE_PATH); - return saveContentToDisk(language, content); - } else { - cacheBeingWritten[language] = false; - logger.error(err); - return; - } - } -} - -/** - * @api {get} /api/v3/content Get all available content objects - * @apiDescription Does not require authentication. - * @apiVersion 3.0.0 - * @apiName ContentGet - * @apiGroup Content - * - * @apiParam {string} language Query parameter, the language code used for the items' strings. Defaulting to english - * - * @apiSuccess {Object} data All the content available on Habitica - */ -api.getContent = { - method: 'GET', - url: '/content', - async handler (req, res) { - let language = 'en'; - let proposedLang = req.query.language && req.query.language.toString(); - - if (proposedLang in cachedContentResponses) { - language = proposedLang; - } - - let content; - - // is the content response for this language cached? - if (cachedContentResponses[language] === true) { - content = await fs.readFile(`${CONTENT_CACHE_PATH}${language}.json`, 'utf8'); - } else { // generate the response - content = _.cloneDeep(common.content); - walkContent(content, language); - content = JSON.stringify(content); - } - - res.set({ - 'Content-Type': 'application/json', - }); - - let jsonResString = `{"success": true, "data": ${content}}`; - res.status(200).send(jsonResString); - - // save the file in background unless it's already cached or being written right now - if (cachedContentResponses[language] !== true && cacheBeingWritten[language] !== true) { - saveContentToDisk(language, content); - } - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/coupon.js b/website/server/controllers/api-v3/coupon.js deleted file mode 100644 index 4dc434aa8c..0000000000 --- a/website/server/controllers/api-v3/coupon.js +++ /dev/null @@ -1,126 +0,0 @@ -import csvStringify from '../../libs/api-v3/csvStringify'; -import { - authWithHeaders, - authWithSession, -} from '../../middlewares/api-v3/auth'; -import { ensureSudo } from '../../middlewares/api-v3/ensureAccessRight'; -import { model as Coupon } from '../../models/coupon'; -import _ from 'lodash'; -import couponCode from 'coupon-code'; - -let api = {}; - -/** - * @api {get} /api/v3/coupons Get coupons - * @apiDescription Sudo users only - * @apiVersion 3.0.0 - * @apiName GetCoupons - * @apiGroup Coupon - * - * @apiSuccess {string} Coupons in CSV format - */ -api.getCoupons = { - method: 'GET', - url: '/coupons', - middlewares: [authWithSession, ensureSudo], - async handler (req, res) { - let coupons = await Coupon.find().sort('createdAt').lean().exec(); - - let output = [['code', 'event', 'date', 'user']].concat(_.map(coupons, coupon => { - return [coupon._id, coupon.event, coupon.createdAt, coupon.user]; - })); - let csv = await csvStringify(output); - - res.set({ - 'Content-Type': 'text/csv', - 'Content-disposition': 'attachment; filename=habitica-coupons.csv', - }); - res.status(200).send(csv); - }, -}; - -/** - * @api {post} /api/v3/coupons/generate/:event Generate coupons for an event - * @apiDescription Sudo users only - * @apiVersion 3.0.0 - * @apiName GenerateCoupons - * @apiGroup Coupon - * - * @apiParam {string} event The event for which the coupon should be generated - * @apiParam {number} count Query parameter to specify the number of coupon codes to generate - * - * @apiSuccess {array} data Generated coupons - */ -api.generateCoupons = { - method: 'POST', - url: '/coupons/generate/:event', - middlewares: [authWithHeaders(), ensureSudo], - async handler (req, res) { - req.checkParams('event', res.t('eventRequired')).notEmpty(); - req.checkQuery('count', res.t('countRequired')).notEmpty().isNumeric(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let coupons = await Coupon.generate(req.params.event, req.query.count); - res.respond(200, coupons); - }, -}; - -/** - * @api {post} /api/v3/user/coupon/:code Enter coupon code - * @apiVersion 3.0.0 - * @apiName EnterCouponCode - * @apiGroup Coupon - * - * @apiParam {string} code The coupon code to apply - * - * @apiSuccess {object} data User object - */ -api.enterCouponCode = { - method: 'POST', - url: '/coupons/enter/:code', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('code', res.t('couponCodeRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - await Coupon.apply(user, req, req.params.code); - res.respond(200, user); - }, -}; - -/** - * @api {post} /api/v3/coupons/validate/:code Validate a coupon code - * @apiVersion 3.0.0 - * @apiName ValidateCoupon - * @apiGroup Coupon - * - * @apiSuccess {boolean} data.valid True or false - */ -api.validateCoupon = { - method: 'POST', - url: '/coupons/validate/:code', - middlewares: [authWithHeaders(true)], - async handler (req, res) { - req.checkParams('code', res.t('couponCodeRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let valid = false; - let code = couponCode.validate(req.params.code); - if (code) { - let coupon = await Coupon.findOne({_id: code}).exec(); - valid = coupon ? true : false; - } - - res.respond(200, {valid}); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js deleted file mode 100644 index 9949421513..0000000000 --- a/website/server/controllers/api-v3/debug.js +++ /dev/null @@ -1,190 +0,0 @@ -import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode'; -import { BadRequest } from '../../libs/api-v3/errors'; -import { content } from '../../../../common'; -import _ from 'lodash'; - -let api = {}; - -/** - * @api {post} /api/v3/debug/add-ten-gems Add ten gems to the current user - * @apiDescription Only available in development mode. - * @apiVersion 3.0.0 - * @apiName AddTenGems - * @apiGroup Development - * - * @apiSuccess {Object} data An empty Object - */ -api.addTenGems = { - method: 'POST', - url: '/debug/add-ten-gems', - middlewares: [ensureDevelpmentMode, authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - user.balance += 2.5; - - await user.save(); - - res.respond(200, {}); - }, -}; - -/** - * @api {post} /api/v3/debug/add-hourglass Add Hourglass to the current user - * @apiDescription Only available in development mode. - * @apiVersion 3.0.0 - * @apiName AddHourglass - * @apiGroup Development - * - * @apiSuccess {Object} data An empty Object - */ -api.addHourglass = { - method: 'POST', - url: '/debug/add-hourglass', - middlewares: [ensureDevelpmentMode, authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - user.purchased.plan.consecutive.trinkets += 1; - - await user.save(); - - res.respond(200, {}); - }, -}; - -/** - * @api {post} /api/v3/debug/set-cron Sets lastCron for user - * @apiDescription Only available in development mode. - * @apiVersion 3.0.0 - * @apiName setCron - * @apiGroup Development - * - * @apiSuccess {Object} data An empty Object - */ -api.setCron = { - method: 'POST', - url: '/debug/set-cron', - middlewares: [ensureDevelpmentMode, authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let cron = req.body.lastCron; - - user.lastCron = cron; - - await user.save(); - - res.respond(200, {}); - }, -}; - -/** - * @api {post} /api/v3/debug/make-admin Sets contributor.admin to true - * @apiDescription Only available in development mode. - * @apiVersion 3.0.0 - * @apiName setCron - * @apiGroup Development - * - * @apiSuccess {Object} data An empty Object - */ -// TODO: Re-enable after v3 prod testing is done -// api.makeAdmin = { -// method: 'POST', -// url: '/debug/make-admin', -// middlewares: [ensureDevelpmentMode, authWithHeaders()], -// async handler (req, res) { -// let user = res.locals.user; -// -// user.contributor.admin = true; -// -// await user.save(); -// -// res.respond(200, {}); -// }, -// }; - -/** - * @api {post} /api/v3/debug/modify-inventory Manipulate user's inventory - * @apiDescription Only available in development mode. - * @apiVersion 3.0.0 - * @apiName modifyInventory - * @apiGroup Development - * - * @apiSuccess {Object} data An empty Object - */ -api.modifyInventory = { - method: 'POST', - url: '/debug/modify-inventory', - middlewares: [ensureDevelpmentMode, authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let { gear } = req.body; - - if (gear) { - user.items.gear.owned = gear; - } - - [ - 'special', - 'pets', - 'mounts', - 'eggs', - 'hatchingPotions', - 'food', - 'quests', - ].forEach((type) => { - if (req.body[type]) { - user.items[type] = req.body[type]; - } - }); - - await user.save(); - - res.respond(200, {}); - }, -}; - -/** - * @api {post} /api/v3/debug/quest-progress Artificially accelerate quest progress - * @apiDescription Only available in development mode. - * @apiVersion 3.0.0 - * @apiName questProgress - * @apiGroup Development - * - * @apiSuccess {Object} data An empty Object - */ -api.questProgress = { - method: 'POST', - url: '/debug/quest-progress', - middlewares: [ensureDevelpmentMode, authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let key = _.get(user, 'party.quest.key'); - let quest = content.quests[key]; - - if (!quest) { - throw new BadRequest('User is not on a valid quest.'); - } - - if (quest.boss) { - user.party.quest.progress.up += 1000; - } - - if (quest.collect) { - let collect = user.party.quest.progress.collect; - _.each(quest.collect, (details, item) => { - collect[item] = collect[item] || 0; - collect[item] += 300; - }); - } - - user.markModified('party.quest.progress'); - - await user.save(); - - res.respond(200, {}); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js deleted file mode 100644 index 07e9b5be54..0000000000 --- a/website/server/controllers/api-v3/groups.js +++ /dev/null @@ -1,670 +0,0 @@ -import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import Bluebird from 'bluebird'; -import _ from 'lodash'; -import { - INVITES_LIMIT, - model as Group, - basicFields as basicGroupFields, -} from '../../models/group'; -import { - model as User, - nameFields, -} from '../../models/user'; -import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; -import { - NotFound, - BadRequest, - NotAuthorized, -} from '../../libs/api-v3/errors'; -import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; -import * as firebase from '../../libs/api-v3/firebase'; -import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; -import { encrypt } from '../../libs/api-v3/encryption'; -import common from '../../../../common'; -import sendPushNotification from '../../libs/api-v3/pushNotifications'; -let api = {}; - -/** - * @api {post} /api/v3/groups Create group - * @apiVersion 3.0.0 - * @apiName CreateGroup - * @apiGroup Group - * - * @apiSuccess {Object} data The create group - */ -api.createGroup = { - method: 'POST', - url: '/groups', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let group = new Group(Group.sanitize(req.body)); - group.leader = user._id; - - if (group.type === 'guild') { - if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems')); - - group.balance = 1; - - user.balance--; - user.guilds.push(group._id); - } else { - if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate')); - if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty')); - - user.party._id = group._id; - } - - let results = await Bluebird.all([user.save(), group.save()]); - let savedGroup = results[1]; - - // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - // await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise - let response = savedGroup.toJSON(); - // the leader is the authenticated user - response.leader = { - _id: user._id, - profile: {name: user.profile.name}, - }; - res.respond(201, response); // do not remove chat flags data as we've just created the group - - firebase.updateGroupData(savedGroup); - firebase.addUserToGroup(savedGroup._id, user._id); - }, -}; - -/** - * @api {get} /api/v3/groups Get groups for a user - * @apiVersion 3.0.0 - * @apiName GetGroups - * @apiGroup Group - * - * @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, guilds, privateGuilds, publicGuilds, tavern - * - * @apiSuccess {Array} data An array of the requested groups - */ -api.getGroups = { - method: 'GET', - url: '/groups', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkQuery('type', res.t('groupTypesRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let types = req.query.type.split(','); - let groupFields = basicGroupFields.concat(' description memberCount balance'); - let sort = '-memberCount'; - - let results = await Group.getGroups({user, types, groupFields, sort}); - res.respond(200, results); - }, -}; - -/** - * @api {get} /api/v3/groups/:groupId Get group - * @apiVersion 3.0.0 - * @apiName GetGroup - * @apiGroup Group - * - * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * - * @apiSuccess {Object} data The group object - */ -api.getGroup = { - method: 'GET', - url: '/groups/:groupId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: false}); - if (!group) throw new NotFound(res.t('groupNotFound')); - - group = Group.toJSONCleanChat(group, user); - // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - let leader = await User.findById(group.leader).select(nameFields).exec(); - if (leader) group.leader = leader.toJSON({minimize: true}); - - res.respond(200, group); - }, -}; - -/** - * @api {put} /api/v3/groups/:groupId Update group - * @apiVersion 3.0.0 - * @apiName UpdateGroup - * @apiGroup Group - * - * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * - * @apiSuccess {Object} data The updated group - */ -api.updateGroup = { - method: 'PUT', - url: '/groups/:groupId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId}); - if (!group) throw new NotFound(res.t('groupNotFound')); - - if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); - - _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body))); - - let savedGroup = await group.save(); - let response = Group.toJSONCleanChat(savedGroup, user); - // If the leader changed fetch new data, otherwise use authenticated user - if (response.leader !== user._id) { - response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); - } else { - response.leader = { - _id: user._id, - profile: {name: user.profile.name}, - }; - } - res.respond(200, response); - - firebase.updateGroupData(savedGroup); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/join Join a group - * @apiVersion 3.0.0 - * @apiName JoinGroup - * @apiGroup Group - * - * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * - * @apiSuccess {Object} data The joined group - */ -api.joinGroup = { - method: 'POST', - url: '/groups/:groupId/join', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let inviter; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party' - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - // Works even if the user is not yet a member of the group - let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group - if (!group) throw new NotFound(res.t('groupNotFound')); - - let isUserInvited = false; - - if (group.type === 'party' && group._id === user.invitations.party.id) { - inviter = user.invitations.party.inviter; - user.invitations.party = {}; // Clear invite - user.markModified('invitations.party'); - - // invite new user to pending quest - if (group.quest.key && !group.quest.active) { - user.party.quest.RSVPNeeded = true; - user.party.quest.key = group.quest.key; - group.quest.members[user._id] = null; - group.markModified('quest.members'); - } - - // If user was in a different party (when partying solo you can be invited to a new party) - // make him leave that party before doing anything - if (user.party._id) { - let userPreviousParty = await Group.getGroup({user, groupId: user.party._id}); - if (userPreviousParty) await userPreviousParty.leave(user); - } - - user.party._id = group._id; // Set group as user's party - - isUserInvited = true; - } else if (group.type === 'guild') { - let hasInvitation = removeFromArray(user.invitations.guilds, { id: group._id }); - - if (hasInvitation) { - isUserInvited = true; - } else { - isUserInvited = group.privacy === 'private' ? false : true; - } - } - - if (isUserInvited && group.type === 'guild') { - if (user.guilds.indexOf(group._id) !== -1) { // if user is already a member (party is checked previously) - throw new NotAuthorized(res.t('userAlreadyInGroup')); - } - user.guilds.push(group._id); // Add group to user's guilds - } - if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite')); - - if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader - - group.memberCount += 1; - - let promises = [group.save(), user.save()]; - - if (group.type === 'party' && inviter) { - promises.push(User.update({_id: inviter}, {$inc: {'items.quests.basilist': 1}}).exec()); // Reward inviter - if (group.memberCount > 1) { - promises.push(User.update({$or: [{'party._id': group._id}, {_id: user._id}], 'achievements.partyUp': {$ne: true}}, {$set: {'achievements.partyUp': true}}, {multi: true}).exec()); - } - if (group.memberCount > 3) { - promises.push(User.update({$or: [{'party._id': group._id}, {_id: user._id}], 'achievements.partyOn': {$ne: true}}, {$set: {'achievements.partyOn': true}}, {multi: true}).exec()); - } - } - - promises = await Bluebird.all(promises); - - let response = Group.toJSONCleanChat(promises[0], user); - let leader = await User.findById(response.leader).select(nameFields).exec(); - if (leader) { - response.leader = leader.toJSON({minimize: true}); - } - res.respond(200, response); - - firebase.addUserToGroup(group._id, user._id); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/reject Reject a group invitation - * @apiVersion 3.0.0 - * @apiName RejectGroupInvite - * @apiGroup Group - * - * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * - * @apiSuccess {Object} data An empty object - */ -api.rejectGroupInvite = { - method: 'POST', - url: '/groups/:groupId/reject-invite', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party' - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let groupId = req.params.groupId; - let isUserInvited = false; - - if (groupId === user.invitations.party.id) { - user.invitations.party = {}; - user.markModified('invitations.party'); - isUserInvited = true; - } else { - let hasInvitation = removeFromArray(user.invitations.guilds, { id: groupId }); - - if (hasInvitation) { - isUserInvited = true; - } - } - - if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite')); - - await user.save(); - - res.respond(200, {}); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/leave Leave a group - * @apiVersion 3.0.0 - * @apiName LeaveGroup - * @apiGroup Group - * - * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {string="remove-all","keep-all"} keep Query parameter - Whether to keep or not challenges' tasks. Defaults to keep-all - * - * @apiSuccess {Object} data An empty object - */ -api.leaveGroup = { - method: 'POST', - url: '/groups/:groupId/leave', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - // When removing the user from challenges, should we keep the tasks? - req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat', requireMembership: true}); - if (!group) throw new NotFound(res.t('groupNotFound')); - - // During quests, checke wheter user can leave - if (group.type === 'party') { - if (group.quest && group.quest.leader === user._id) { - throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup')); - } - - if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) { - throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest')); - } - } - - await group.leave(user, req.query.keep); - res.respond(200, {}); - }, -}; - -// Send an email to the removed user with an optional message from the leader -function _sendMessageToRemoved (group, removedUser, message) { - if (removedUser.preferences.emailNotifications.kickedGroup !== false) { - sendTxnEmail(removedUser, `kicked-from-${group.type}`, [ - {name: 'GROUP_NAME', content: group.name}, - {name: 'MESSAGE', content: message}, - {name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'}, - {name: 'PARTY_WANTED_GUILD', content: '/#/options/groups/guilds/f2db2a7f-13c5-454d-b3ee-ea1f5089e601'}, - ]); - } -} - -/** - * @api {post} /api/v3/groups/:groupId/removeMember/:memberId Remove a member from a group - * @apiVersion 3.0.0 - * @apiName RemoveGroupMember - * @apiGroup Group - * - * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {UUID} memberId The _id of the member to remove - * @apiParam {string} message Query parameter - The message to send to the removed members - * - * @apiSuccess {Object} data An empty object - */ -api.removeGroupMember = { - method: 'POST', - url: '/groups/:groupId/removeMember/:memberId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat - if (!group) throw new NotFound(res.t('groupNotFound')); - - let uuid = req.params.memberId; - - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); - if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself')); - - let member = await User.findOne({_id: uuid}).exec(); - - // We're removing the user from a guild or a party? is the user invited only? - let isInGroup; - if (member.party._id === group._id) { - isInGroup = 'party'; - } else if (member.guilds.indexOf(group._id) !== -1) { - isInGroup = 'guild'; - } - - let isInvited; - if (member.invitations.party && member.invitations.party.id === group._id) { - isInvited = 'party'; - } else if (_.findIndex(member.invitations.guilds, {id: group._id}) !== -1) { - isInvited = 'guild'; - } - - if (isInGroup) { - group.memberCount -= 1; - - if (group.quest && group.quest.leader === member._id) { - group.quest.key = undefined; - group.quest.leader = undefined; - } else if (group.quest && group.quest.members) { - // remove member from quest - group.quest.members[member._id] = undefined; - group.markModified('quest.members'); - } - - if (isInGroup === 'guild') { - removeFromArray(member.guilds, group._id); - } - if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? Use group.leave()? - - if (member.newMessages[group._id]) { - member.newMessages[group._id] = undefined; - member.markModified('newMessages'); - } - - if (group.quest && group.quest.active && group.quest.leader === member._id) { - member.items.quests[group.quest.key] += 1; - } - } else if (isInvited) { - if (isInvited === 'guild') { - removeFromArray(member.invitations.guilds, { id: group._id }); - } - if (isInvited === 'party') { - user.invitations.party = {}; - user.markModified('invitations.party'); - } - } else { - throw new NotFound(res.t('groupMemberNotFound')); - } - - let message = req.query.message; - if (message) _sendMessageToRemoved(group, member, message); - - await Bluebird.all([ - member.save(), - group.save(), - ]); - res.respond(200, {}); - }, -}; - -async function _inviteByUUID (uuid, group, inviter, req, res) { - let userToInvite = await User.findById(uuid).exec(); - - if (!userToInvite) { - throw new NotFound(res.t('userWithIDNotFound', {userId: uuid})); - } - - if (group.type === 'guild') { - if (_.contains(userToInvite.guilds, group._id)) { - throw new NotAuthorized(res.t('userAlreadyInGroup')); - } - if (_.find(userToInvite.invitations.guilds, {id: group._id})) { - throw new NotAuthorized(res.t('userAlreadyInvitedToGroup')); - } - userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: inviter._id}); - } else if (group.type === 'party') { - if (userToInvite.invitations.party.id) { - throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); - } - - if (userToInvite.party._id) { - let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'}); - - // Allow user to be invited to a new party when they're partying solo - if (userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty')); - } - - userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id}; - } - - let groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; - let groupTemplate = group.type === 'guild' ? 'guild' : 'party'; - if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) { - let emailVars = [ - {name: 'INVITER', content: inviter.profile.name}, - ]; - - if (group.type === 'guild') { - emailVars.push( - {name: 'GUILD_NAME', content: group.name}, - {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'} - ); - } else { - emailVars.push( - {name: 'PARTY_NAME', content: group.name}, - {name: 'PARTY_URL', content: '/#/options/groups/party'} - ); - } - - sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars); - } - - sendPushNotification( - userToInvite, - common.i18n.t(group.type === 'guild' ? 'invitedGuild' : 'invitedParty'), - group.name - ); - - let userInvited = await userToInvite.save(); - if (group.type === 'guild') { - return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1]; - } else if (group.type === 'party') { - return userInvited.invitations.party; - } -} - -async function _inviteByEmail (invite, group, inviter, req, res) { - let userReturnInfo; - - if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); - - let userToContact = await User.findOne({$or: [ - {'auth.local.email': invite.email}, - {'auth.facebook.emails.value': invite.email}, - ]}) - .select({_id: true, 'preferences.emailNotifications': true}) - .exec(); - - if (userToContact) { - userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res); - } else { - userReturnInfo = invite.email; - const groupQueryString = JSON.stringify({ - id: group._id, - inviter: inviter._id, - sentAt: Date.now(), // so we can let it expire - }); - let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`; - - let variables = [ - {name: 'LINK', content: link}, - {name: 'INVITER', content: req.body.inviter || inviter.profile.name}, - ]; - - if (group.type === 'guild') { - variables.push({name: 'GUILD_NAME', content: group.name}); - } - - // Check for the email address not to be unsubscribed - let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec(); - let groupLabel = group.type === 'guild' ? '-guild' : ''; - if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables); - } - - return userReturnInfo; -} - -/** - * @api {post} /api/v3/groups/:groupId/invite Invite users to a group using their UUIDs or email addresses - * @apiVersion 3.0.0 - * @apiName InviteToGroup - * @apiGroup Group - * - * @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * - * @apiParam {array} emails Body parameter - An array of emails addresses to invite (optional) - * @apiParam {array} uuids Body parameter - An array of uuids to invite (optional) - * @apiParam {string} inviter Body parameter - The inviters' name (optional) - * - * @apiSuccess {array} data The invites - */ -api.inviteToGroup = { - method: 'POST', - url: '/groups/:groupId/invite', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); - if (!group) throw new NotFound(res.t('groupNotFound')); - - let uuids = req.body.uuids; - let emails = req.body.emails; - - let uuidsIsArray = Array.isArray(uuids); - let emailsIsArray = Array.isArray(emails); - - if (!uuids && !emails) { - throw new BadRequest(res.t('canOnlyInviteEmailUuid')); - } - - let results = []; - let totalInvites = 0; - - if (uuids) { - if (!uuidsIsArray) { - throw new BadRequest(res.t('uuidsMustBeAnArray')); - } else { - totalInvites += uuids.length; - } - } - - if (emails) { - if (!emailsIsArray) { - throw new BadRequest(res.t('emailsMustBeAnArray')); - } else { - totalInvites += emails.length; - } - } - - if (totalInvites > INVITES_LIMIT) { - throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT})); - } - - if (uuids) { - let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res)); - let uuidResults = await Bluebird.all(uuidInvites); - results.push(...uuidResults); - } - - if (emails) { - let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res)); - let emailResults = await Bluebird.all(emailInvites); - results.push(...emailResults); - } - - res.respond(200, results); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js deleted file mode 100644 index 077b1ef74b..0000000000 --- a/website/server/controllers/api-v3/hall.js +++ /dev/null @@ -1,183 +0,0 @@ -import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import { ensureAdmin } from '../../middlewares/api-v3/ensureAccessRight'; -import { model as User } from '../../models/user'; -import { - NotFound, -} from '../../libs/api-v3/errors'; -import _ from 'lodash'; - -let api = {}; - -/** - * @api {get} /api/v3/hall/patrons Get all patrons - * @apiDescription Only the first 50 patrons are returned. More can be accessed passing ?page=n - * @apiVersion 3.0.0 - * @apiName GetPatrons - * @apiGroup Hall - * - * @apiParam {Number} page Query Parameter - The result page. Default is 0 - * - * @apiSuccess {Array} data An array of patrons - */ -api.getPatrons = { - method: 'GET', - url: '/hall/patrons', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkQuery('page', res.t('pageMustBeNumber')).optional().isNumeric(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let page = req.query.page ? Number(req.query.page) : 0; - const perPage = 50; - - let patrons = await User - .find({ - 'backer.tier': {$gt: 0}, - }) - .select('contributor backer profile.name') - .sort('-backer.tier') - .skip(page * perPage) - .limit(perPage) - .lean() - .exec(); - - res.respond(200, patrons); - }, -}; - -/** - * @api {get} /api/v3/hall/heroes Get all Heroes - * @apiVersion 3.0.0 - * @apiName GetHeroes - * @apiGroup Hall - * - * @apiSuccess {Array} data An array of heroes - */ -api.getHeroes = { - method: 'GET', - url: '/hall/heroes', - middlewares: [authWithHeaders()], - async handler (req, res) { - let heroes = await User - .find({ - 'contributor.level': {$gt: 0}, - }) - .select('contributor backer profile.name') - .sort('-contributor.level') - .lean() - .exec(); - - res.respond(200, heroes); - }, -}; - -// Note, while the following routes are called getHero / updateHero -// they can be used by admins to get/update any user - -const heroAdminFields = 'contributor balance profile.name purchased items auth'; - -/** - * @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID - * @apiDescription Must be an admin to make this request. - * @apiVersion 3.0.0 - * @apiName GetHero - * @apiGroup Hall - * - * @apiSuccess {Object} data The user object - */ -api.getHero = { - method: 'GET', - url: '/hall/heroes/:heroId', - middlewares: [authWithHeaders(), ensureAdmin], - async handler (req, res) { - let heroId = req.params.heroId; - - req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let hero = await User - .findById(heroId) - .select(heroAdminFields) - .exec(); - - if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId})); - let heroRes = hero.toJSON({minimize: true}); - // supply to the possible absence of hero.contributor - // if we didn't pass minimize: true it would have returned all fields as empty - if (!heroRes.contributor) heroRes.contributor = {}; - res.respond(200, heroRes); - }, -}; - -// e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff -const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0}; - -/** - * @api {put} /api/v3/hall/heroes/:heroId Update any user ("hero") - * @apiDescription Must be an admin to make this request. - * @apiVersion 3.0.0 - * @apiName UpdateHero - * @apiGroup Hall - * - * @apiSuccess {Object} data The updated user object - */ -api.updateHero = { - method: 'PUT', - url: '/hall/heroes/:heroId', - middlewares: [authWithHeaders(), ensureAdmin], - async handler (req, res) { - let heroId = req.params.heroId; - let updateData = req.body; - - req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let hero = await User.findById(heroId).exec(); - if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId})); - - if (updateData.balance) hero.balance = updateData.balance; - - // give them gems if they got an higher level - let newTier = updateData.contributor && updateData.contributor.level; // tier = level in this context - let oldTier = hero.contributor && hero.contributor.level || 0; - if (newTier > oldTier) { - hero.flags.contributor = true; - let tierDiff = newTier - oldTier; // can be 2+ tier increases at once - while (tierDiff) { - hero.balance += gemsPerTier[newTier] / 4; // balance is in $ - tierDiff--; - newTier--; // give them gems for the next tier down if they weren't aready that tier - } - } - - if (updateData.contributor) _.assign(hero.contributor, updateData.contributor); - if (updateData.purchased && updateData.purchased.ads) hero.purchased.ads = updateData.purchased.ads; - - // give them the Dragon Hydra pet if they're above level 6 - if (hero.contributor.level >= 6) hero.items.pets['Dragon-Hydra'] = 5; - if (updateData.itemPath && updateData.itemVal && - updateData.itemPath.indexOf('items.') === 0 && - User.schema.paths[updateData.itemPath]) { - _.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary) - } - - if (updateData.auth && _.isBoolean(updateData.auth.blocked)) hero.auth.blocked = updateData.auth.blocked; - - let savedHero = await hero.save(); - let heroJSON = savedHero.toJSON(); - let responseHero = {_id: heroJSON._id}; // only respond with important fields - heroAdminFields.split(' ').forEach(field => { - _.set(responseHero, field, _.get(heroJSON, field)); - }); - - res.respond(200, responseHero); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/iap.js b/website/server/controllers/api-v3/iap.js deleted file mode 100644 index daaef0cef8..0000000000 --- a/website/server/controllers/api-v3/iap.js +++ /dev/null @@ -1,4 +0,0 @@ -// NOTE: this file is only used because the mobile apps expect IAP routes -// to be found at /api/v3/iap instead of /iap. - -module.exports = require('../top-level/payments/iap'); \ No newline at end of file diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js deleted file mode 100644 index 37bc3c5d27..0000000000 --- a/website/server/controllers/api-v3/members.js +++ /dev/null @@ -1,364 +0,0 @@ -import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import { - model as User, - publicFields as memberFields, - nameFields, -} from '../../models/user'; -import { model as Group } from '../../models/group'; -import { model as Challenge } from '../../models/challenge'; -import { - NotFound, - NotAuthorized, -} from '../../libs/api-v3/errors'; -import * as Tasks from '../../models/task'; -import { - getUserInfo, - sendTxn as sendTxnEmail, -} from '../../libs/api-v3/email'; -import Bluebird from 'bluebird'; -import sendPushNotification from '../../libs/api-v3/pushNotifications'; - -let api = {}; - -/** - * @api {get} /api/v3/members/:memberId Get a member profile - * @apiVersion 3.0.0 - * @apiName GetMember - * @apiGroup Member - * - * @apiParam {UUID} memberId The member's id - * - * @apiSuccess {object} data The member object - */ -api.getMember = { - method: 'GET', - url: '/members/:memberId', - middlewares: [], - async handler (req, res) { - req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let memberId = req.params.memberId; - - let member = await User - .findById(memberId) - .select(memberFields) - .exec(); - - if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); - - // manually call toJSON with minimize: true so empty paths aren't returned - res.respond(200, member.toJSON({minimize: true})); - }, -}; - -// Return a request handler for getMembersForGroup / getInvitesForGroup / getMembersForChallenge -// type is `invites` or `members` -function _getMembersForItem (type) { - if (['group-members', 'group-invites', 'challenge-members'].indexOf(type) === -1) { - throw new Error('Type must be one of "group-members", "group-invites", "challenge-members"'); - } - - return async function handleGetMembersForItem (req, res) { - if (type === 'challenge-members') { - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - } else { - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - } - req.checkQuery('lastId').optional().notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let groupId = req.params.groupId; - let challengeId = req.params.challengeId; - let lastId = req.query.lastId; - let user = res.locals.user; - let challenge; - let group; - - if (type === 'challenge-members') { - challenge = await Challenge.findById(challengeId).select('_id type leader group').exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - - // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge - // for example if you've been booted from it, are the leader or a site admin - group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); - if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - } else { - group = await Group.getGroup({user, groupId, fields: '_id type'}); - if (!group) throw new NotFound(res.t('groupNotFound')); - } - - let query = {}; - let fields = nameFields; - - if (type === 'challenge-members') { - query.challenges = challenge._id; - } else if (type === 'group-members') { - if (group.type === 'guild') { - query.guilds = group._id; - } else { - query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' - - if (req.query.includeAllPublicFields === 'true') { - fields = memberFields; - } - } - } else if (type === 'group-invites') { - if (group.type === 'guild') { // eslint-disable-line no-lonely-if - query['invitations.guilds.id'] = group._id; - } else { - query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' - } - } - - if (lastId) query._id = {$gt: lastId}; - - let members = await User - .find(query) - .sort({_id: 1}) - .limit(30) - .select(fields) - .exec(); - - // manually call toJSON with minimize: true so empty paths aren't returned - res.respond(200, members.map(member => member.toJSON({minimize: true}))); - }; -} - -/** - * @api {get} /api/v3/groups/:groupId/members Get members for a group - * @apiDescription With a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. - * @apiVersion 3.0.0 - * @apiName GetMembersForGroup - * @apiGroup Member - * - * @apiParam {UUID} groupId The group id - * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results - * @apiParam {boolean} includeAllPublicFields Query parameter available only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member) - * - * @apiSuccess {array} data An array of members, sorted by _id - */ -api.getMembersForGroup = { - method: 'GET', - url: '/groups/:groupId/members', - middlewares: [authWithHeaders()], - handler: _getMembersForItem('group-members'), -}; - -/** - * @api {get} /api/v3/groups/:groupId/invites Get invites for a group - * @apiDescription With a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. - * @apiVersion 3.0.0 - * @apiName GetInvitesForGroup - * @apiGroup Member - * - * @apiParam {UUID} groupId The group id - * @apiParam {UUID} lastId Query parameter to specify the last invite returned in a previous request to this route and get the next batch of results - * - * @apiSuccess {array} data An array of invites, sorted by _id - */ -api.getInvitesForGroup = { - method: 'GET', - url: '/groups/:groupId/invites', - middlewares: [authWithHeaders()], - handler: _getMembersForItem('group-invites'), -}; - -/** - * @api {get} /api/v3/challenges/:challengeId/members Get members for a challenge - * @apiDescription With a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. - * @apiVersion 3.0.0 - * @apiName GetMembersForChallenge - * @apiGroup Member - * - * @apiParam {UUID} challengeId The challenge id - * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results - * - * @apiSuccess {array} data An array of members, sorted by _id - */ -api.getMembersForChallenge = { - method: 'GET', - url: '/challenges/:challengeId/members', - middlewares: [authWithHeaders()], - handler: _getMembersForItem('challenge-members'), -}; - -/** - * @api {get} /api/v3/challenges/:challengeId/members/:memberId Get a challenge member progress - * @apiVersion 3.0.0 - * @apiName GetChallenge - * @apiGroup Challenge - * - * @apiParam {UUID} challengeId The challenge _id - * @apiParam {UUID} member The member _id - * - * @apiSuccess {object} data Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member - */ -api.getChallengeMemberProgress = { - method: 'GET', - url: '/challenges/:challengeId/members/:memberId', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let challengeId = req.params.challengeId; - let memberId = req.params.memberId; - - let member = await User.findById(memberId).select(`${nameFields} challenges`).exec(); - if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); - - let challenge = await Challenge.findById(challengeId).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - - // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge - // for example if you've been booted from it, are the leader or a site admin - let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); - if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); - - let chalTasks = await Tasks.Task.find({ - userId: memberId, - 'challenge.id': challengeId, - }) - .select('-tags') // We don't want to return the tags publicly TODO same for other data? - .exec(); - - // manually call toJSON with minimize: true so empty paths aren't returned - let response = member.toJSON({minimize: true}); - delete response.challenges; - response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true})); - res.respond(200, response); - }, -}; - -/** - * @api {posts} /members/send-private-message Send a private message to a member - * @apiVersion 3.0.0 - * @apiName SendPrivateMessage - * @apiGroup Members - * - * @apiParam {String} message Body parameter - The message - * @apiParam {UUID} toUserId Body parameter - The user to contact - * - * @apiSuccess {Object} data An empty Object - */ -api.sendPrivateMessage = { - method: 'POST', - url: '/members/send-private-message', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkBody('message', res.t('messageRequired')).notEmpty(); - req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let sender = res.locals.user; - let message = req.body.message; - - let receiver = await User.findById(req.body.toUserId).exec(); - if (!receiver) throw new NotFound(res.t('userNotFound')); - - let userBlockedSender = receiver.inbox.blocks.indexOf(sender._id) !== -1; - let userIsBlockBySender = sender.inbox.blocks.indexOf(receiver._id) !== -1; - let userOptedOutOfMessaging = receiver.inbox.optOut; - - if (userBlockedSender || userIsBlockBySender || userOptedOutOfMessaging) { - throw new NotAuthorized(res.t('notAuthorizedToSendMessageToThisUser')); - } - - await sender.sendMessage(receiver, message); - - if (receiver.preferences.emailNotifications.newPM !== false) { - sendTxnEmail(receiver, 'new-pm', [ - {name: 'SENDER', content: getUserInfo(sender, ['name']).name}, - {name: 'PMS_INBOX_URL', content: '/#/options/groups/inbox'}, - ]); - } - - res.respond(200, {}); - }, -}; - -/** - * @api {posts} /members/transfer-gems Send a gem gift to a member - * @apiVersion 3.0.0 - * @apiName TransferGems - * @apiGroup Members - * - * @apiParam {String} message Body parameter The message - * @apiParam {UUID} toUserId Body parameter The toUser _id - * @apiParam {Integer} gemAmount Body parameter The number of gems to send - * - * @apiSuccess {Object} data An empty Object - */ -api.transferGems = { - method: 'POST', - url: '/members/transfer-gems', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID(); - req.checkBody('gemAmount', res.t('gemAmountRequired')).notEmpty().isInt(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let sender = res.locals.user; - - let receiver = await User.findById(req.body.toUserId).exec(); - if (!receiver) throw new NotFound(res.t('userNotFound')); - - if (receiver._id === sender._id) { - throw new NotAuthorized(res.t('cannotSendGemsToYourself')); - } - - let gemAmount = req.body.gemAmount; - let amount = gemAmount / 4; - - if (amount <= 0 || sender.balance < amount) { - throw new NotAuthorized(res.t('badAmountOfGemsToSend')); - } - - receiver.balance += amount; - sender.balance -= amount; - let promises = [receiver.save(), sender.save()]; - await Bluebird.all(promises); - - let message = res.t('privateMessageGiftIntro', { - receiverName: receiver.profile.name, - senderName: sender.profile.name, - }); - message += res.t('privateMessageGiftGemsMessage', {gemAmount}); - - if (req.body.message) { - message += req.body.message; - } - - await sender.sendMessage(receiver, message); - - let byUsername = getUserInfo(sender, ['name']).name; - - if (receiver.preferences.emailNotifications.giftedGems !== false) { - sendTxnEmail(receiver, 'gifted-gems', [ - {name: 'GIFTER', content: byUsername}, - {name: 'X_GEMS_GIFTED', content: gemAmount}, - ]); - } - - sendPushNotification(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername })); - - res.respond(200, {}); - }, -}; - - -module.exports = api; diff --git a/website/server/controllers/api-v3/modelsPaths.js b/website/server/controllers/api-v3/modelsPaths.js deleted file mode 100644 index 927087df01..0000000000 --- a/website/server/controllers/api-v3/modelsPaths.js +++ /dev/null @@ -1,40 +0,0 @@ -import mongoose from 'mongoose'; - -let api = {}; - -let tasksModels = ['habit', 'daily', 'todo', 'reward']; -let allModels = ['user', 'tag', 'challenge', 'group'].concat(tasksModels); - -/** - * @api {get} /api/v3/models/:model/paths Get all paths for the specified model - * @apiDescription Doesn't require authentication - * @apiVersion 3.0.0 - * @apiName GetUserModelPaths - * @apiGroup Meta - * - * @apiParam {string="user","group","challenge","tag","habit","daily","todo","reward"} model The name of the model - * - * @apiSuccess {object} data A key-value object made of fieldPath: fieldType (like {'field.nested': Boolean}) - */ -api.getModelPaths = { - method: 'GET', - url: '/models/:model/paths', - async handler (req, res) { - req.checkParams('model', res.t('modelNotFound')).notEmpty().isIn(allModels); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let model = req.params.model; - // tasks models are lowercase, the others have the first letter uppercase (User, Group) - if (tasksModels.indexOf(model) === -1) { - model = model.charAt(0).toUpperCase() + model.slice(1); - } - - model = mongoose.model(model); - - res.respond(200, model.getModelPaths()); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js deleted file mode 100644 index 9107fda8d6..0000000000 --- a/website/server/controllers/api-v3/quests.js +++ /dev/null @@ -1,451 +0,0 @@ -import _ from 'lodash'; -import Bluebird from 'bluebird'; -import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import analytics from '../../libs/api-v3/analyticsService'; -import { - model as Group, -} from '../../models/group'; -import { model as User } from '../../models/user'; -import { - NotFound, - NotAuthorized, - BadRequest, -} from '../../libs/api-v3/errors'; -import { - getUserInfo, - sendTxn as sendTxnEmail, -} from '../../libs/api-v3/email'; -import common from '../../../../common'; -import sendPushNotification from '../../libs/api-v3/pushNotifications'; - -const questScrolls = common.content.quests; - -function canStartQuestAutomatically (group) { - // If all members are either true (accepted) or false (rejected) return true - // If any member is null/undefined (undecided) return false - return _.every(group.quest.members, _.isBoolean); -} - -let api = {}; - -/** - * @api {post} /api/v3/groups/:groupId/quests/invite Invite users to a quest - * @apiVersion 3.0.0 - * @apiName InviteToQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * @apiParam {string} questKey - * - * @apiSuccess {Object} data Quest object - */ -api.inviteToQuest = { - method: 'POST', - url: '/groups/:groupId/quests/invite/:questKey', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let questKey = req.params.questKey; - let quest = questScrolls[questKey]; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); - if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); - if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); - if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - - let members = await User.find({ - 'party._id': group._id, - _id: {$ne: user._id}, - }).select('auth.facebook auth.local preferences.emailNotifications profile.name pushDevices') - .exec(); - - group.markModified('quest'); - group.quest.key = questKey; - group.quest.leader = user._id; - group.quest.members = {}; - group.quest.members[user._id] = true; - - user.party.quest.RSVPNeeded = false; - user.party.quest.key = questKey; - - await User.update({ - 'party._id': group._id, - _id: {$ne: user._id}, - }, { - $set: { - 'party.quest.RSVPNeeded': true, - 'party.quest.key': questKey, - }, - }, {multi: true}).exec(); - - _.each(members, (member) => { - group.quest.members[member._id] = null; - }); - - if (canStartQuestAutomatically(group)) { - await group.startQuest(user); - } - - let [savedGroup] = await Bluebird.all([ - group.save(), - user.save(), - ]); - - res.respond(200, savedGroup.quest); - - // send out invites - let inviterVars = getUserInfo(user, ['name', 'email']); - let membersToEmail = members.filter(member => { - // send push notifications while filtering members before sending emails - sendPushNotification( - member, - common.i18n.t('questInvitationTitle'), - common.i18n.t('questInvitationInfo', { quest: quest.text() }) - ); - - return member.preferences.emailNotifications.invitedQuest !== false; - }); - sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [ - {name: 'QUEST_NAME', content: quest.text()}, - {name: 'INVITER', content: inviterVars.name}, - {name: 'PARTY_URL', content: '/#/options/groups/party'}, - ]); - - // track that the inviting user has accepted the quest - analytics.track('quest', { - category: 'behavior', - owner: true, - response: 'accept', - gaLabel: 'accept', - questName: questKey, - uuid: user._id, - }); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/quests/accept Accept a pending quest - * @apiVersion 3.0.0 - * @apiName AcceptQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} data Quest Object - */ -api.acceptQuest = { - method: 'POST', - url: '/groups/:groupId/quests/accept', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound')); - if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); - if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted')); - - group.markModified('quest'); - group.quest.members[user._id] = true; - user.party.quest.RSVPNeeded = false; - - if (canStartQuestAutomatically(group)) { - await group.startQuest(user); - } - - let [savedGroup] = await Bluebird.all([ - group.save(), - user.save(), - ]); - - res.respond(200, savedGroup.quest); - - // track that a user has accepted the quest - analytics.track('quest', { - category: 'behavior', - owner: false, - response: 'accept', - gaLabel: 'accept', - questName: group.quest.key, - uuid: user._id, - }); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/quests/reject Reject a quest - * @apiVersion 3.0.0 - * @apiName RejectQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} data Quest Object - */ -api.rejectQuest = { - method: 'POST', - url: '/groups/:groupId/quests/reject', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); - if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); - if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted')); - if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected')); - - group.quest.members[user._id] = false; - group.markModified('quest.members'); - - user.party.quest = Group.cleanQuestProgress(); - user.markModified('party.quest'); - - if (canStartQuestAutomatically(group)) { - await group.startQuest(user); - } - - let [savedGroup] = await Bluebird.all([ - group.save(), - user.save(), - ]); - - res.respond(200, savedGroup.quest); - - analytics.track('quest', { - category: 'behavior', - owner: false, - response: 'reject', - gaLabel: 'reject', - questName: group.quest.key, - uuid: user._id, - }); - }, -}; - - -/** - * @api {post} /api/v3/groups/:groupId/quests/force-start Force-start a pending quest - * @apiVersion 3.0.0 - * @apiName ForceQuestStart - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} data Quest Object - */ -api.forceStart = { - method: 'POST', - url: '/groups/:groupId/quests/force-start', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest leader'}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!group.quest.key) throw new NotFound(res.t('questNotPending')); - if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); - if (!(user._id === group.quest.leader || user._id === group.leader)) throw new NotAuthorized(res.t('questOrGroupLeaderOnlyStartQuest')); - - group.markModified('quest'); - - await group.startQuest(user); - - let [savedGroup] = await Bluebird.all([ - group.save(), - user.save(), - ]); - - res.respond(200, savedGroup.quest); - - analytics.track('quest', { - category: 'behavior', - owner: user._id === group.quest.leader, - response: 'force-start', - gaLabel: 'force-start', - questName: group.quest.key, - uuid: user._id, - }); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/quests/cancel Cancels a quest - * @apiVersion 3.0.0 - * @apiName CancelQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} data Quest Object - */ -api.cancelQuest = { - method: 'POST', - url: '/groups/:groupId/quests/cancel', - middlewares: [authWithHeaders()], - async handler (req, res) { - // Cancel a quest BEFORE it has begun (i.e., in the invitation stage) - // Quest scroll has not yet left quest owner's inventory so no need to return it. - // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started. - let user = res.locals.user; - let groupId = req.params.groupId; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId, fields: 'type leader quest'}); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); - if (user._id !== group.leader && group.quest.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCancelQuest')); - if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest')); - - group.quest = Group.cleanGroupQuest(); - group.markModified('quest'); - - let [savedGroup] = await Bluebird.all([ - group.save(), - User.update( - {'party._id': groupId}, - {$set: {'party.quest': Group.cleanQuestProgress()}}, - {multi: true} - ), - ]); - - res.respond(200, savedGroup.quest); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/quests/abort Abort the current quest - * @apiVersion 3.0.0 - * @apiName AbortQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} data Quest Object - */ -api.abortQuest = { - method: 'POST', - url: '/groups/:groupId/quests/abort', - middlewares: [authWithHeaders()], - async handler (req, res) { - // Abort a quest AFTER it has begun (see questCancel for BEFORE) - let user = res.locals.user; - let groupId = req.params.groupId; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId, fields: 'type quest leader'}); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort')); - if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest')); - - let memberUpdates = User.update({ - 'party._id': groupId, - }, { - $set: {'party.quest': Group.cleanQuestProgress()}, - }, {multi: true}).exec(); - - let questLeaderUpdate = User.update({ - _id: group.quest.leader, - }, { - $inc: { - [`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader - }, - }).exec(); - - group.quest = Group.cleanGroupQuest(); - group.markModified('quest'); - - let [groupSaved] = await Bluebird.all([group.save(), memberUpdates, questLeaderUpdate]); - - res.respond(200, groupSaved.quest); - }, -}; - -/** - * @api {post} /api/v3/groups/:groupId/quests/leave Leaves the active quest - * @apiVersion 3.0.0 - * @apiName LeaveQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} data Quest Object - */ -api.leaveQuest = { - method: 'POST', - url: '/groups/:groupId/quests/leave', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let groupId = req.params.groupId; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId, fields: 'type quest'}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToLeave')); - if (group.quest.leader === user._id) throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest')); - if (!group.quest.members[user._id]) throw new NotAuthorized(res.t('notPartOfQuest')); - - group.quest.members[user._id] = false; - group.markModified('quest.members'); - - user.party.quest = Group.cleanQuestProgress(); - user.markModified('party.quest'); - - let [savedGroup] = await Bluebird.all([ - group.save(), - user.save(), - ]); - - res.respond(200, savedGroup.quest); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/status.js b/website/server/controllers/api-v3/status.js deleted file mode 100644 index 68c232463f..0000000000 --- a/website/server/controllers/api-v3/status.js +++ /dev/null @@ -1,21 +0,0 @@ -let api = {}; - -/** - * @api {get} /api/v3/status Get Habitica's API status - * @apiVersion 3.0.0 - * @apiName GetStatus - * @apiGroup Status - * - * @apiSuccess {status} data.status 'up' if everything is ok - */ -api.getStatus = { - method: 'GET', - url: '/status', - async handler (req, res) { - res.respond(200, { - status: 'up', - }); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/tags.js b/website/server/controllers/api-v3/tags.js deleted file mode 100644 index 250c343537..0000000000 --- a/website/server/controllers/api-v3/tags.js +++ /dev/null @@ -1,190 +0,0 @@ -import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import { model as Tag } from '../../models/tag'; -import * as Tasks from '../../models/task'; -import { - NotFound, -} from '../../libs/api-v3/errors'; -import _ from 'lodash'; -import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; - -let api = {}; - -/** - * @api {post} /api/v3/tags Create a new tag - * @apiVersion 3.0.0 - * @apiName CreateTag - * @apiGroup Tag - * - * @apiSuccess {Object} data The newly created tag - */ -api.createTag = { - method: 'POST', - url: '/tags', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - user.tags.push(Tag.sanitize(req.body)); - let savedUser = await user.save(); - - let l = savedUser.tags.length; - let tag = savedUser.tags[l - 1]; - res.respond(201, tag); - }, -}; - -/** - * @api {get} /api/v3/tag Get a user's tags - * @apiVersion 3.0.0 - * @apiName GetTags - * @apiGroup Tag - * - * @apiSuccess {Array} data An array of tags - */ -api.getTags = { - method: 'GET', - url: '/tags', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - res.respond(200, user.tags); - }, -}; - -/** - * @api {get} /api/v3/tags/:tagId Get a tag given its id - * @apiVersion 3.0.0 - * @apiName GetTag - * @apiGroup Tag - * - * @apiParam {UUID} tagId The tag _id - * - * @apiSuccess {object} data The tag object - */ -api.getTag = { - method: 'GET', - url: '/tags/:tagId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let tag = _.find(user.tags, {id: req.params.tagId}); - if (!tag) throw new NotFound(res.t('tagNotFound')); - res.respond(200, tag); - }, -}; - -/** - * @api {put} /api/v3/tag/:tagId Update a tag - * @apiVersion 3.0.0 - * @apiName UpdateTag - * @apiGroup Tag - * - * @apiParam {UUID} tagId The tag _id - * - * @apiSuccess {object} data The updated tag - */ -api.updateTag = { - method: 'PUT', - url: '/tags/:tagId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); - - let tagId = req.params.tagId; - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let tag = _.find(user.tags, {id: tagId}); - if (!tag) throw new NotFound(res.t('tagNotFound')); - - _.merge(tag, Tag.sanitize(req.body)); - - let savedUser = await user.save(); - res.respond(200, _.find(savedUser.tags, {id: tagId})); - }, -}; - -/** - * @api {post} /api/v3/reorder-tags Reorder a tag - * @apiVersion 3.0.0 - * @apiName ReorderTags - * @apiGroup Tag - * - * @apiParam {tagId} UUID Id of the tag to move - * @apiParam {to} number Position the tag is moving to - * - * @apiSuccess {object} data An empty object - */ -api.reorderTags = { - method: 'POST', - url: '/reorder-tags', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkBody('to', res.t('toRequired')).notEmpty(); - req.checkBody('tagId', res.t('tagIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let tagIndex = _.findIndex(user.tags, function findTag (tag) { - return tag.id === req.body.tagId; - }); - if (tagIndex === -1) throw new NotFound(res.t('tagNotFound')); - user.tags.splice(req.body.to, 0, user.tags.splice(tagIndex, 1)[0]); - - await user.save(); - res.respond(200, {}); - }, -}; - -/** - * @api {delete} /api/v3/tag/:tagId Delete a user tag given its id - * @apiVersion 3.0.0 - * @apiName DeleteTag - * @apiGroup Tag - * - * @apiParam {UUID} tagId The tag _id - * - * @apiSuccess {object} data An empty object - */ -api.deleteTag = { - method: 'DELETE', - url: '/tags/:tagId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let tag = removeFromArray(user.tags, { id: req.params.tagId }); - if (!tag) throw new NotFound(res.t('tagNotFound')); - - // Remove from all the tasks TODO test - await Tasks.Task.update({ - userId: user._id, - }, { - $pull: { - tags: tag.id, - }, - }, {multi: true}).exec(); - - await user.save(); - res.respond(200, {}); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js deleted file mode 100644 index 919c2fb541..0000000000 --- a/website/server/controllers/api-v3/tasks.js +++ /dev/null @@ -1,971 +0,0 @@ -import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import { sendTaskWebhook } from '../../libs/api-v3/webhook'; -import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; -import * as Tasks from '../../models/task'; -import { model as Challenge } from '../../models/challenge'; -import { model as Group } from '../../models/group'; -import { - NotFound, - NotAuthorized, - BadRequest, -} from '../../libs/api-v3/errors'; -import common from '../../../../common'; -import Bluebird from 'bluebird'; -import _ from 'lodash'; -import logger from '../../libs/api-v3/logger'; - -let api = {}; - -// challenge must be passed only when a challenge task is being created -async function _createTasks (req, res, user, challenge) { - let toSave = Array.isArray(req.body) ? req.body : [req.body]; - - toSave = toSave.map(taskData => { - // Validate that task.type is valid - if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); - - let taskType = taskData.type; - let newTask = new Tasks[taskType](Tasks.Task.sanitize(taskData)); - - if (challenge) { - newTask.challenge.id = challenge.id; - } else { - newTask.userId = user._id; - } - - // Validate that the task is valid and throw if it isn't - // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality - let validationErrors = newTask.validateSync(); - if (validationErrors) throw validationErrors; - - // Otherwise update the user/challenge - (challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id); - - return newTask; - }).map(task => task.save({ // If all tasks are valid (this is why it's not in the previous .map()), save everything, withough running validation again - validateBeforeSave: false, - })); - - toSave.unshift((challenge || user).save()); - - let tasks = await Bluebird.all(toSave); - tasks.splice(0, 1); // Remove user or challenge - return tasks; -} - -/** - * @api {post} /api/v3/tasks/user Create a new task belonging to the user - * @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks. - * @apiVersion 3.0.0 - * @apiName CreateUserTasks - * @apiGroup Task - * - * @apiSuccess data An object if a single task was created, otherwise an array of tasks - */ -api.createUserTasks = { - method: 'POST', - url: '/tasks/user', - middlewares: [authWithHeaders()], - async handler (req, res) { - let tasks = await _createTasks(req, res, res.locals.user); - res.respond(201, tasks.length === 1 ? tasks[0] : tasks); - }, -}; - -/** - * @api {post} /api/v3/tasks/challenge/:challengeId Create a new task belonging to a challenge - * @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks. - * @apiVersion 3.0.0 - * @apiName CreateChallengeTasks - * @apiGroup Task - * - * @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to - * - * @apiSuccess data An object if a single task was created, otherwise an array of tasks - */ -api.createChallengeTasks = { - method: 'POST', - url: '/tasks/challenge/:challengeId', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - - let reqValidationErrors = req.validationErrors(); - if (reqValidationErrors) throw reqValidationErrors; - - let user = res.locals.user; - let challengeId = req.params.challengeId; - - let challenge = await Challenge.findOne({_id: challengeId}).exec(); - - // If the challenge does not exist, or if it exists but user is not the leader -> throw error - if (!challenge || user.challenges.indexOf(challengeId) === -1) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - - let tasks = await _createTasks(req, res, user, challenge); - - res.respond(201, tasks.length === 1 ? tasks[0] : tasks); - - // If adding tasks to a challenge -> sync users - if (challenge) challenge.addTasks(tasks); - - return null; - }, -}; - -// challenge must be passed only when a challenge task is being created -async function _getTasks (req, res, user, challenge) { - let query = challenge ? {'challenge.id': challenge.id, userId: {$exists: false}} : {userId: user._id}; - let type = req.query.type; - - if (type) { - if (type === 'todos') { - query.completed = false; // Exclude completed todos - query.type = 'todo'; - } else if (type === 'completedTodos') { - query = Tasks.Task.find({ - userId: user._id, - type: 'todo', - completed: true, - }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos - dateCompleted: -1, - }); - } else { - query.type = type.slice(0, -1); // removing the final "s" - } - } else { - query.$or = [ // Exclude completed todos - {type: 'todo', completed: false}, - {type: {$in: ['habit', 'daily', 'reward']}}, - ]; - } - - let tasks = await Tasks.Task.find(query).exec(); - - // Order tasks based on tasksOrder - if (type && type !== 'completedTodos') { - let order = (challenge || user).tasksOrder[type]; - let orderedTasks = new Array(tasks.length); - let unorderedTasks = []; // what we want to add later - - tasks.forEach((task, index) => { - let taskId = task._id; - let i = order[index] === taskId ? index : order.indexOf(taskId); - if (i === -1) { - unorderedTasks.push(task); - } else { - orderedTasks[i] = task; - } - }); - - // Remove empty values from the array and add any unordered task - orderedTasks = _.compact(orderedTasks).concat(unorderedTasks); - res.respond(200, orderedTasks); - } else { - res.respond(200, tasks); - } -} - -/** - * @api {get} /api/v3/tasks/user Get a user's tasks - * @apiVersion 3.0.0 - * @apiName GetUserTasks - * @apiGroup Task - * - * @apiParam {string="habits","dailys","todos","rewards","completedTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that must be requested separately. - * - * @apiSuccess {Array} data An array of tasks - */ -api.getUserTasks = { - method: 'GET', - url: '/tasks/user', - middlewares: [authWithHeaders()], - async handler (req, res) { - let types = Tasks.tasksTypes.map(type => `${type}s`); - types.push('completedTodos'); - req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - return await _getTasks(req, res, res.locals.user); - }, -}; - -/** - * @api {get} /api/v3/tasks/challenge/:challengeId Get a challenge's tasks - * @apiVersion 3.0.0 - * @apiName GetChallengeTasks - * @apiGroup Task - * - * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks - * @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks - * - * @apiSuccess {Array} data An array of tasks - */ -api.getChallengeTasks = { - method: 'GET', - url: '/tasks/challenge/:challengeId', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - let types = Tasks.tasksTypes.map(type => `${type}s`); - req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let challengeId = req.params.challengeId; - - let challenge = await Challenge.findOne({_id: challengeId}).select('group leader tasksOrder').exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); - if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - - return await _getTasks(req, res, res.locals.user, challenge); - }, -}; - -/** - * @api {get} /api/v3/task/:taskId Get a task - * @apiVersion 3.0.0 - * @apiName GetTask - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * - * @apiSuccess {object} data The task object - */ -api.getTask = { - method: 'GET', - url: '/tasks/:taskId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - }).exec(); - - if (!task) { - throw new NotFound(res.t('taskNotFound')); - } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - let challenge = await Challenge.find({_id: task.challenge.id}).select('leader').exec(); - if (!challenge || (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens - throw new NotFound(res.t('taskNotFound')); - } - } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one - throw new NotFound(res.t('taskNotFound')); - } - - res.respond(200, task); - }, -}; - -/** - * @api {put} /api/v3/task/:taskId Update a task - * @apiVersion 3.0.0 - * @apiName UpdateTask - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * - * @apiSuccess {object} data The updated task - */ -api.updateTask = { - method: 'PUT', - url: '/tasks/:taskId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let challenge; - - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - }).exec(); - - if (!task) { - throw new NotFound(res.t('taskNotFound')); - } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one - throw new NotFound(res.t('taskNotFound')); - } - - // we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? - let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req); - - - // Sanitize differently user tasks linked to a challenge - let sanitizedObj; - - if (!challenge && task.userId && task.challenge && task.challenge.id) { - sanitizedObj = Tasks.Task.sanitizeUserChallengeTask(updatedTaskObj); - } else { - sanitizedObj = Tasks.Task.sanitize(updatedTaskObj); - } - - _.assign(task, sanitizedObj); - // console.log(task.modifiedPaths(), task.toObject().repeat === tep) - // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() - // see https://github.com/Automattic/mongoose/issues/2749 - - let savedTask = await task.save(); - res.respond(200, savedTask); - if (challenge) challenge.updateTask(savedTask); - - return null; - }, -}; - -function _generateWebhookTaskData (task, direction, delta, stats, user) { - let extendedStats = _.extend(stats, { - toNextLevel: common.tnl(user.stats.lvl), - maxHealth: common.maxHealth, - maxMP: common.statsComputed(user).maxMP, - }); - - let userData = { - _id: user._id, - _tmp: user._tmp, - stats: extendedStats, - }; - - let taskData = { - details: task, - direction, - delta, - }; - - return { - task: taskData, - user: userData, - }; -} - -/** - * @api {put} /api/v3/tasks/:taskId/score/:direction Score a task - * @apiVersion 3.0.0 - * @apiName ScoreTask - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * @apiParam {string="up","down"} direction The direction for scoring the task - * - * @apiSuccess {object} data._tmp If an item was dropped it'll be returned in te _tmp object - * @apiSuccess {number} data.delta - * @apiSuccess {object} data The user stats - */ -api.scoreTask = { - method: 'POST', - url: '/tasks/:taskId/score/:direction', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let direction = req.params.direction; - - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - userId: user._id, - }).exec(); - - if (!task) throw new NotFound(res.t('taskNotFound')); - - let wasCompleted = task.completed; - - let [delta] = common.ops.scoreTask({task, user, direction}, req); - // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) - if (direction === 'up') user.fns.randomDrop({task, delta}, req); - - // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list - // TODO move to common code? - if (task.type === 'todo') { - if (!wasCompleted && task.completed) { - removeFromArray(user.tasksOrder.todos, task._id); - } else if (wasCompleted && !task.completed) { - let hasTask = removeFromArray(user.tasksOrder.todos, task._id); - if (!hasTask) { - user.tasksOrder.todos.push(task._id); - } // If for some reason it hadn't been removed previously don't do anything - } - } - - let results = await Bluebird.all([ - user.save(), - task.save(), - ]); - - let savedUser = results[0]; - - let userStats = savedUser.stats.toJSON(); - let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); - res.respond(200, resJsonData); - - sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); - - if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { - // Wrapping everything in a try/catch block because if an error occurs using `await` it MUST NOT bubble up because the request has already been handled - try { - let chalTask = await Tasks.Task.findOne({ - _id: task.challenge.taskId, - }).exec(); - - await chalTask.scoreChallengeTask(delta); - } catch (e) { - logger.error(e); - } - } - - return null; - }, -}; - -/** - * @api {post} /api/v3/tasks/:taskId/move/to/:position Move a task to a new position - * @apiDescription Note: completed To-Dos are not sortable, do not appear in user.tasksOrder.todos, and are ordered by date of completion. - * @apiVersion 3.0.0 - * @apiName MoveTask - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * @apiParam {Number} position Query parameter - Where to move the task (-1 means push to bottom). First position is 0 - * - * @apiSuccess {array} data The new tasks order (user.tasksOrder.{task.type}s) - */ -api.moveTask = { - method: 'POST', - url: '/tasks/:taskId/move/to/:position', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let to = Number(req.params.position); - - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - userId: user._id, - }).exec(); - - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo')); - let order = user.tasksOrder[`${task.type}s`]; - let currentIndex = order.indexOf(task._id); - - // If for some reason the task isn't ordered (should never happen), push it in the new position - // if the task is moved to a non existing position - // or if the task is moved to position -1 (push to bottom) - // -> push task at end of list - if (!order[to] && to !== -1) { - order.push(task._id); - } else { - if (currentIndex !== -1) order.splice(currentIndex, 1); - if (to === -1) { - order.push(task._id); - } else { - order.splice(to, 0, task._id); - } - } - - await user.save(); - res.respond(200, order); - }, -}; - -/** - * @api {post} /api/v3/tasks/:taskId/checklist Add an item to the task's checklist - * @apiVersion 3.0.0 - * @apiName AddChecklistItem - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * - * @apiSuccess {object} data The updated task - */ -api.addChecklistItem = { - method: 'POST', - url: '/tasks/:taskId/checklist', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let challenge; - - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - }).exec(); - - if (!task) { - throw new NotFound(res.t('taskNotFound')); - } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one - throw new NotFound(res.t('taskNotFound')); - } - - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - - task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); - let savedTask = await task.save(); - - res.respond(200, savedTask); - if (challenge) challenge.updateTask(savedTask); - - return null; - }, -}; - -/** - * @api {post} /api/v3/tasks/:taskId/checklist/:itemId/score Score a checklist item - * @apiVersion 3.0.0 - * @apiName ScoreChecklistItem - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * @apiParam {UUID} itemId The checklist item _id - * - * @apiSuccess {object} data The updated task - */ -api.scoreCheckListItem = { - method: 'POST', - url: '/tasks/:taskId/checklist/:itemId/score', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - userId: user._id, - }).exec(); - - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - - let item = _.find(task.checklist, {id: req.params.itemId}); - - if (!item) throw new NotFound(res.t('checklistItemNotFound')); - item.completed = !item.completed; - let savedTask = await task.save(); - - res.respond(200, savedTask); - }, -}; - -/** - * @api {put} /api/v3/tasks/:taskId/checklist/:itemId Update a checklist item - * @apiVersion 3.0.0 - * @apiName UpdateChecklistItem - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * @apiParam {UUID} itemId The checklist item _id - * - * @apiSuccess {object} data The updated task - */ -api.updateChecklistItem = { - method: 'PUT', - url: '/tasks/:taskId/checklist/:itemId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let challenge; - - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - }).exec(); - - if (!task) { - throw new NotFound(res.t('taskNotFound')); - } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one - throw new NotFound(res.t('taskNotFound')); - } - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - - let item = _.find(task.checklist, {id: req.params.itemId}); - if (!item) throw new NotFound(res.t('checklistItemNotFound')); - - _.merge(item, Tasks.Task.sanitizeChecklist(req.body)); - let savedTask = await task.save(); - - res.respond(200, savedTask); - if (challenge) challenge.updateTask(savedTask); - - return null; - }, -}; - -/** - * @api {delete} /api/v3/tasks/:taskId/checklist/:itemId Remove a checklist item - * @apiVersion 3.0.0 - * @apiName RemoveChecklistItem - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * @apiParam {UUID} itemId The checklist item _id - * - * @apiSuccess {object} data The updated task - */ -api.removeChecklistItem = { - method: 'DELETE', - url: '/tasks/:taskId/checklist/:itemId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let challenge; - - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - }).exec(); - - if (!task) { - throw new NotFound(res.t('taskNotFound')); - } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one - throw new NotFound(res.t('taskNotFound')); - } - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - - let hasItem = removeFromArray(task.checklist, { id: req.params.itemId }); - if (!hasItem) throw new NotFound(res.t('checklistItemNotFound')); - - let savedTask = await task.save(); - res.respond(200, savedTask); - if (challenge) challenge.updateTask(savedTask); - - return null; - }, -}; - -/** - * @api {post} /api/v3/tasks/:taskId/tags/:tagId Add a tag to a task - * @apiVersion 3.0.0 - * @apiName AddTagToTask - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * @apiParam {UUID} tagId The tag id - * - * @apiSuccess {object} data The updated task - */ -api.addTagToTask = { - method: 'POST', - url: '/tasks/:taskId/tags/:tagId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - let userTags = user.tags.map(tag => tag.id); - req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - userId: user._id, - }).exec(); - - if (!task) throw new NotFound(res.t('taskNotFound')); - let tagId = req.params.tagId; - - let alreadyTagged = task.tags.indexOf(tagId) !== -1; - if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged')); - - task.tags.push(tagId); - - let savedTask = await task.save(); - res.respond(200, savedTask); - }, -}; - -/** - * @api {delete} /api/v3/tasks/:taskId/tags/:tagId Remove a tag from atask - * @apiVersion 3.0.0 - * @apiName RemoveTagFromTask - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * @apiParam {UUID} tagId The tag id - * - * @apiSuccess {object} data The updated task - */ -api.removeTagFromTask = { - method: 'DELETE', - url: '/tasks/:taskId/tags/:tagId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - userId: user._id, - }).exec(); - - if (!task) throw new NotFound(res.t('taskNotFound')); - - let hasTag = removeFromArray(task.tags, req.params.tagId); - if (!hasTag) throw new NotFound(res.t('tagNotFound')); - - let savedTask = await task.save(); - res.respond(200, savedTask); - }, -}; - -/** - * @api {post} /api/v3/tasks/unlink-all/:challengeId Unlink all tasks from a challenge - * @apiVersion 3.0.0 - * @apiName UnlinkAllTasks - * @apiGroup Task - * - * @apiParam {UUID} challengeId The challenge _id - * @apiParam {string} keep Query parameter - keep-all or remove-all - * - * @apiSuccess {object} data An empty object - */ -api.unlinkAllTasks = { - method: 'POST', - url: '/tasks/unlink-all/:challengeId', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - req.checkQuery('keep', res.t('keepOrRemoveAll')).notEmpty().isIn(['keep-all', 'remove-all']); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let keep = req.query.keep; - let challengeId = req.params.challengeId; - - let tasks = await Tasks.Task.find({ - 'challenge.id': challengeId, - userId: user._id, - }).exec(); - - let validTasks = tasks.every(task => { - return task.challenge.broken; - }); - - if (!validTasks) throw new BadRequest(res.t('cantOnlyUnlinkChalTask')); - - if (keep === 'keep-all') { - await Bluebird.all(tasks.map(task => { - task.challenge = {}; - return task.save(); - })); - } else { // remove - let toSave = []; - - tasks.forEach(task => { - if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if - removeFromArray(user.tasksOrder[`${task.type}s`], task._id); - } - - toSave.push(task.remove()); - }); - - toSave.push(user.save()); - - await Bluebird.all(toSave); - } - - res.respond(200, {}); - }, -}; - -/** - * @api {post} /api/v3/tasks/unlink-one/:taskId Unlink a challenge task - * @apiVersion 3.0.0 - * @apiName UnlinkOneTask - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * @apiParam {string} keep Query parameter - keep or remove - * - * @apiSuccess {object} data An empty object - */ -api.unlinkOneTask = { - method: 'POST', - url: '/tasks/unlink-one/:taskId', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkQuery('keep', res.t('keepOrRemove')).notEmpty().isIn(['keep', 'remove']); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let keep = req.query.keep; - let taskId = req.params.taskId; - - let task = await Tasks.Task.findOne({ - _id: taskId, - userId: user._id, - }).exec(); - - if (!task) throw new NotFound(res.t('taskNotFound')); - if (!task.challenge.id) throw new BadRequest(res.t('cantOnlyUnlinkChalTask')); - if (!task.challenge.broken) throw new BadRequest(res.t('cantOnlyUnlinkChalTask')); - - if (keep === 'keep') { - task.challenge = {}; - await task.save(); - } else { // remove - if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if - removeFromArray(user.tasksOrder[`${task.type}s`], taskId); - await Bluebird.all([user.save(), task.remove()]); - } else { - await task.remove(); - } - } - - res.respond(200, {}); - }, -}; - -/** - * @api {post} /api/v3/tasks/clearCompletedTodos Delete user's completed todos - * @apiVersion 3.0.0 - * @apiName ClearCompletedTodos - * @apiGroup Task - * - * @apiSuccess {object} data An empty object - */ -api.clearCompletedTodos = { - method: 'POST', - url: '/tasks/clearCompletedTodos', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - - // Clear completed todos - // Do not delete challenges completed todos unless the task is broken - await Tasks.Task.remove({ - userId: user._id, - type: 'todo', - completed: true, - $or: [ - {'challenge.id': {$exists: false}}, - {'challenge.broken': {$exists: true}}, - ], - }).exec(); - - res.respond(200, {}); - }, -}; - -/** - * @api {delete} /api/v3/tasks/:taskId Delete a task given its id - * @apiVersion 3.0.0 - * @apiName DeleteTask - * @apiGroup Task - * - * @apiParam {UUID} taskId The task _id - * - * @apiSuccess {object} data An empty object - */ -api.deleteTask = { - method: 'DELETE', - url: '/tasks/:taskId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let user = res.locals.user; - let challenge; - - req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let taskId = req.params.taskId; - let task = await Tasks.Task.findById(taskId).exec(); - - if (!task) { - throw new NotFound(res.t('taskNotFound')); - } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights - challenge = await Challenge.findOne({_id: task.challenge.id}).exec(); - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one - throw new NotFound(res.t('taskNotFound')); - } else if (task.userId && task.challenge.id && !task.challenge.broken) { - throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); - } - - if (task.type !== 'todo' || !task.completed) { - removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId); - await Bluebird.all([(challenge || user).save(), task.remove()]); - } else { - await task.remove(); - } - - res.respond(200, {}); - if (challenge) challenge.removeTask(task); - - return null; - }, -}; - -module.exports = api; diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js deleted file mode 100644 index c3b03f620d..0000000000 --- a/website/server/controllers/api-v3/user.js +++ /dev/null @@ -1,1358 +0,0 @@ -import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import common from '../../../../common'; -import { - NotFound, - BadRequest, - NotAuthorized, -} from '../../libs/api-v3/errors'; -import * as Tasks from '../../models/task'; -import { - basicFields as basicGroupFields, - model as Group, -} from '../../models/group'; -import { model as User } from '../../models/user'; -import Bluebird from 'bluebird'; -import _ from 'lodash'; -import * as firebase from '../../libs/api-v3/firebase'; -import * as passwordUtils from '../../libs/api-v3/password'; - -let api = {}; - -/** - * @api {get} /api/v3/user Get the authenticated user's profile - * @apiVersion 3.0.0 - * @apiName UserGet - * @apiGroup User - * - * @apiSuccess {Object} data The user object - */ -api.getUser = { - method: 'GET', - middlewares: [authWithHeaders()], - url: '/user', - async handler (req, res) { - let user = res.locals.user.toJSON(); - - // Remove apiToken from response TODO make it private at the user level? returned in signup/login - delete user.apiToken; - - // TODO move to model? (maybe virtuals, maybe in toJSON) - // NOTE: if an item is manually added to user.stats common/fns/predictableRandom must be tweaked - // so it's not considered. Otherwise the client will have it while the server won't and the results will be different. - user.stats.toNextLevel = common.tnl(user.stats.lvl); - user.stats.maxHealth = common.maxHealth; - user.stats.maxMP = common.statsComputed(user).maxMP; - - return res.respond(200, user); - }, -}; - -/** - * @api {get} /api/v3/user/inventory/buy Get the gear items available for purchase for the current user - * @apiVersion 3.0.0 - * @apiName UserGetBuyList - * @apiGroup User - * - * @apiSuccess {Object} data The buy list - */ -api.getBuyList = { - method: 'GET', - middlewares: [authWithHeaders()], - url: '/user/inventory/buy', - async handler (req, res) { - let list = _.cloneDeep(common.updateStore(res.locals.user)); - - // return text and notes strings - _.each(list, item => { - _.each(item, (itemPropVal, itemPropKey) => { - if (_.isFunction(itemPropVal) && itemPropVal.i18nLangFunc) item[itemPropKey] = itemPropVal(req.language); - }); - }); - - res.respond(200, list); - }, -}; - -let updatablePaths = [ - 'flags.customizationsNotification', - 'flags.showTour', - 'flags.tour', - 'flags.tutorial', - 'flags.communityGuidelinesAccepted', - 'flags.welcomed', - 'flags.cardReceived', - 'flags.warnedLowHealth', - 'flags.newStuff', - - 'achievements', - - 'party.order', - 'party.orderAscending', - 'party.quest.completed', - 'party.quest.RSVPNeeded', - - 'preferences', - 'profile', - 'stats', - 'inbox.optOut', -]; - -// This tells us for which paths users can call `PUT /user`. -// The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs) -let acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, (accumulator, val, leaf) => { - let found = _.find(updatablePaths, (rootPath) => { - return leaf.indexOf(rootPath) === 0; - }); - - if (found) accumulator[leaf] = true; - - return accumulator; -}, {}); - -let restrictedPUTSubPaths = [ - 'stats.class', - - 'preferences.disableClasses', - 'preferences.sleep', - 'preferences.webhooks', -]; - -_.each(restrictedPUTSubPaths, (removePath) => { - delete acceptablePUTPaths[removePath]; -}); - -let requiresPurchase = { - 'preferences.background': 'background', - 'preferences.shirt': 'shirt', - 'preferences.size': 'size', - 'preferences.skin': 'skin', - 'preferences.hair.bangs': 'hair.bangs', - 'preferences.hair.base': 'hair.base', - 'preferences.hair.beard': 'hair.beard', - 'preferences.hair.color': 'hair.color', - 'preferences.hair.flower': 'hair.flower', - 'preferences.hair.mustache': 'hair.mustache', -}; - -let checkPreferencePurchase = (user, path, item) => { - let itemPath = `${path}.${item}`; - let appearance = _.get(common.content.appearances, itemPath); - if (!appearance) return false; - if (appearance.price === 0) return true; - - return _.get(user.purchased, itemPath); -}; - -/** - * @api {put} /api/v3/user Update the user - * @apiDescription Example body: {'stats.hp':50, 'preferences.background': 'beach'} - * @apiVersion 3.0.0 - * @apiName UserUpdate - * @apiGroup User - * - * @apiSuccess {object} data The updated user object - */ -api.updateUser = { - method: 'PUT', - middlewares: [authWithHeaders()], - url: '/user', - async handler (req, res) { - let user = res.locals.user; - - _.each(req.body, (val, key) => { - let purchasable = requiresPurchase[key]; - - if (purchasable && !checkPreferencePurchase(user, purchasable, val)) { - throw new NotAuthorized(res.t('mustPurchaseToSet', { val, key })); - } - - if (acceptablePUTPaths[key]) { - _.set(user, key, val); - } else { - throw new NotAuthorized(res.t('messageUserOperationProtected', { operation: key })); - } - }); - - await user.save(); - return res.respond(200, user); - }, -}; - -/** - * @api {delete} /api/v3/user Delete an authenticated user's account - * @apiVersion 3.0.0 - * @apiName UserDelete - * @apiGroup User - * - * @apiParam {string} password The user's password (unless it's a Facebook account) - * - * @apiSuccess {Object} data An empty Object - */ -api.deleteUser = { - method: 'DELETE', - middlewares: [authWithHeaders()], - url: '/user', - async handler (req, res) { - let user = res.locals.user; - let plan = user.purchased.plan; - - req.checkBody({ - password: { - notEmpty: {errorMessage: res.t('missingPassword')}, - }, - }); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt); - if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword')); - - if (plan && plan.customerId && !plan.dateTerminated) { - throw new NotAuthorized(res.t('cannotDeleteActiveAccount')); - } - - let types = ['party', 'guilds']; - let groupFields = basicGroupFields.concat(' leader memberCount'); - - let groupsUserIsMemberOf = await Group.getGroups({user, types, groupFields}); - - let groupLeavePromises = groupsUserIsMemberOf.map((group) => { - return group.leave(user, 'remove-all'); - }); - - await Bluebird.all(groupLeavePromises); - - await Tasks.Task.remove({ - userId: user._id, - }).exec(); - - await user.remove(); - - res.respond(200, {}); - - firebase.deleteUser(user._id); - }, -}; - -function _cleanChecklist (task) { - _.forEach(task.checklist, (c, i) => { - c.text = `item ${i}`; - }); -} - -/** - * @api {get} /api/v3/user/anonymized Get anonymized user data - * @apiVersion 3.0.0 - * @apiName UserGetAnonymized - * @apiGroup User - * - * @apiSuccess {Object} data.user - * @apiSuccess {Array} data.tasks - **/ -api.getUserAnonymized = { - method: 'GET', - middlewares: [authWithHeaders()], - url: '/user/anonymized', - async handler (req, res) { - let user = res.locals.user.toJSON(); - user.stats.toNextLevel = common.tnl(user.stats.lvl); - user.stats.maxHealth = common.maxHealth; - user.stats.maxMP = res.locals.user._statsComputed.maxMP; - - delete user.apiToken; - if (user.auth) { - delete user.auth.local; - delete user.auth.facebook; - } - delete user.newMessages; - delete user.profile; - delete user.purchased.plan; - delete user.contributor; - delete user.invitations; - delete user.items.special.nyeReceived; - delete user.items.special.valentineReceived; - delete user.webhooks; - delete user.achievements.challenges; - - _.forEach(user.inbox.messages, (msg) => { - msg.text = 'inbox message text'; - }); - _.forEach(user.tags, (tag) => { - tag.name = 'tag'; - tag.challenge = 'challenge'; - }); - - let query = { - userId: user._id, - $or: [ - { type: 'todo', completed: false }, - { type: { $in: ['habit', 'daily', 'reward'] } }, - ], - }; - let tasks = await Tasks.Task.find(query).exec(); - - _.forEach(tasks, (task) => { - task.text = 'task text'; - task.notes = 'task notes'; - if (task.type === 'todo' || task.type === 'daily') { - _cleanChecklist(task); - } - }); - - return res.respond(200, { user, tasks }); - }, -}; - -const partyMembersFields = 'profile.name stats achievements items.special'; - -/** - * @api {post} /api/v3/user/class/cast/:spellId Cast a skill (spell) on a target - * @apiVersion 3.0.0 - * @apiName UserCast - * @apiGroup User - * - * @apiParam {string} spellId The skill to cast - * @apiParam {UUID} targetId Optional query parameter, the id of the target when casting a skill on a party member or a task - * - * @apiSuccess data Will return the modified targets. For party members only the necessary fields will be populated. The user is always returned. - */ -api.castSpell = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/class/cast/:spellId', - async handler (req, res) { - let user = res.locals.user; - let spellId = req.params.spellId; - let targetId = req.query.targetId; - - // optional because not required by all targetTypes, presence is checked later if necessary - req.checkQuery('targetId', res.t('targetIdUUID')).optional().isUUID(); - - let reqValidationErrors = req.validationErrors(); - if (reqValidationErrors) throw reqValidationErrors; - - let klass = common.content.spells.special[spellId] ? 'special' : user.stats.class; - let spell = common.content.spells[klass][spellId]; - - if (!spell) throw new NotFound(res.t('spellNotFound', {spellId})); - if (spell.mana > user.stats.mp) throw new NotAuthorized(res.t('notEnoughMana')); - if (spell.value > user.stats.gp && !spell.previousPurchase) throw new NotAuthorized(res.t('messageNotEnoughGold')); - if (spell.lvl > user.stats.lvl) throw new NotAuthorized(res.t('spellLevelTooHigh', {level: spell.lvl})); - - let targetType = spell.target; - - if (targetType === 'task') { - if (!targetId) throw new BadRequest(res.t('targetIdUUID')); - - let task = await Tasks.Task.findOne({ - _id: targetId, - userId: user._id, - }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.challenge.id) throw new BadRequest(res.t('challengeTasksNoCast')); - - spell.cast(user, task, req); - - let results = await Bluebird.all([ - user.save(), - task.save(), - ]); - - res.respond(200, { - user: results[0], - task: results[1], - }); - } else if (targetType === 'self') { - spell.cast(user, null, req); - await user.save(); - res.respond(200, { user }); - } else if (targetType === 'tasks') { // new target type in v3: when all the user's tasks are necessary - let tasks = await Tasks.Task.find({ - userId: user._id, - $or: [ // exclude challenge tasks - {'challenge.id': {$exists: false}}, - {'challenge.broken': {$exists: true}}, - ], - }).exec(); - - spell.cast(user, tasks, req); - - let toSave = tasks - .filter(t => t.isModified()) - .map(t => t.save()); - - toSave.unshift(user.save()); - let saved = await Bluebird.all(toSave); - - let response = { - tasks: saved, - user, - }; - - res.respond(200, response); - } else if (targetType === 'party' || targetType === 'user') { - let party = await Group.getGroup({groupId: 'party', user}); - // arrays of users when targetType is 'party' otherwise single users - let partyMembers; - - if (targetType === 'party') { - if (!party) { - partyMembers = [user]; // Act as solo party - } else { - partyMembers = await User - .find({ - 'party._id': party._id, - _id: { $ne: user._id }, // add separately - }) - // .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save - // default values for non-selected fields and pre('save') will mess up thinking some values are missing - .exec(); - - partyMembers.unshift(user); - } - - spell.cast(user, partyMembers, req); - await Bluebird.all(partyMembers.map(m => m.save())); - } else { - if (!party && (!targetId || user._id === targetId)) { - partyMembers = user; - } else { - if (!targetId) throw new BadRequest(res.t('targetIdUUID')); - if (!party) throw new NotFound(res.t('partyNotFound')); - partyMembers = await User - .findOne({_id: targetId, 'party._id': party._id}) - // .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save - // default values for non-selected fields and pre('save') will mess up thinking some values are missing - .exec(); - } - - if (!partyMembers) throw new NotFound(res.t('userWithIDNotFound', {userId: targetId})); - - spell.cast(user, partyMembers, req); - - if (partyMembers !== user) { - await Bluebird.all([ - user.save(), - partyMembers.save(), - ]); - } else { - await partyMembers.save(); // partyMembers is user - } - } - - let partyMembersRes = Array.isArray(partyMembers) ? partyMembers : [partyMembers]; - // Only return some fields. - // See comment above on why we can't just select the necessary fields when querying - partyMembersRes = partyMembersRes.map(partyMember => { - return common.pickDeep(partyMember.toJSON(), common.$w(partyMembersFields)); - }); - - res.respond(200, { - partyMembers: partyMembersRes, - user, - }); - - if (party && !spell.silent) { - let message = `\`${user.profile.name} casts ${spell.text()}${targetType === 'user' ? ` on ${partyMembers.profile.name}` : ' for the party'}.\``; - party.sendChat(message); - await party.save(); - } - } - }, -}; - -/** - * @api {post} /api/v3/user/sleep Make the user start / stop sleeping (resting in the Inn) - * @apiVersion 3.0.0 - * @apiName UserSleep - * @apiGroup User - * - * @apiSuccess {boolean} data user.preferences.sleep - */ -api.sleep = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/sleep', - async handler (req, res) { - let user = res.locals.user; - let sleepRes = common.ops.sleep(user); - await user.save(); - res.respond(200, ...sleepRes); - }, -}; - -/** - * @api {post} /api/v3/user/allocate Allocate an attribute point - * @apiVersion 3.0.0 - * @apiName UserAllocate - * @apiGroup User - * - * @apiParam {string} stat Query parameter - Defaults to 'str', mast be one of be of str, con, int or per - * - * @apiSuccess {Object} data user.stats - */ -api.allocate = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/allocate', - async handler (req, res) { - let user = res.locals.user; - let allocateRes = common.ops.allocate(user, req); - await user.save(); - res.respond(200, ...allocateRes); - }, -}; - -/** - * @api {post} /api/v3/user/allocate-now Allocate all attribute points - * @apiDescription Uses the user's chosen automatic allocation method, or if none, assigns all to STR. - * @apiVersion 3.0.0 - * @apiName UserAllocateNow - * @apiGroup User - * - * @apiSuccess {Object} data user.stats - */ -api.allocateNow = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/allocate-now', - async handler (req, res) { - let user = res.locals.user; - let allocateNowRes = common.ops.allocateNow(user, req); - await user.save(); - res.respond(200, ...allocateNowRes); - }, -}; - -/** - * @api {post} /user/buy/:key Buy gear, armoire or potion - * @apiDescription Under the hood uses UserBuyGear, UserBuyPotion and UserBuyArmoire - * @apiVersion 3.0.0 - * @apiName UserBuy - * @apiGroup User - * - * @apiParam {string} key The item to buy - */ -api.buy = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/buy/:key', - async handler (req, res) { - let user = res.locals.user; - let buyRes = common.ops.buy(user, req, res.analytics); - await user.save(); - res.respond(200, ...buyRes); - }, -}; - -/** - * @api {post} /user/buy-gear/:key Buy a piece of gear - * @apiVersion 3.0.0 - * @apiName UserBuyGear - * @apiGroup User - * - * @apiParam {string} key The item to buy - * - * @apiSuccess {object} data.items user.items - * @apiSuccess {object} data.flags user.flags - * @apiSuccess {object} data.achievements user.achievements - * @apiSuccess {object} data.stats user.stats - * @apiSuccess {string} message Success message - */ -api.buyGear = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/buy-gear/:key', - async handler (req, res) { - let user = res.locals.user; - let buyGearRes = common.ops.buyGear(user, req, res.analytics); - await user.save(); - res.respond(200, ...buyGearRes); - }, -}; - -/** - * @api {post} /user/buy-armoire Buy an armoire item - * @apiVersion 3.0.0 - * @apiName UserBuyArmoire - * @apiGroup User - * - * @apiSuccess {object} data.items user.items - * @apiSuccess {object} data.flags user.flags - * @apiSuccess {object} data.armoire Extra item given by the armoire - * @apiSuccess {string} message Success message - */ -api.buyArmoire = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/buy-armoire', - async handler (req, res) { - let user = res.locals.user; - let buyArmoireResponse = common.ops.buyArmoire(user, req, res.analytics); - await user.save(); - res.respond(200, ...buyArmoireResponse); - }, -}; - -/** - * @api {post} /user/buy-health-potion Buy a health potion - * @apiVersion 3.0.0 - * @apiName UserBuyPotion - * @apiGroup User - * - * @apiSuccess {Object} data user.stats - * @apiSuccess {string} message Success message - */ -api.buyHealthPotion = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/buy-health-potion', - async handler (req, res) { - let user = res.locals.user; - let buyHealthPotionResponse = common.ops.buyHealthPotion(user, req, res.analytics); - await user.save(); - res.respond(200, ...buyHealthPotionResponse); - }, -}; - -/** - * @api {post} /user/buy-mystery-set/:key Buy a mystery set - * @apiVersion 3.0.0 - * @apiName UserBuyMysterySet - * @apiGroup User - * - * @apiParam {string} key The mystery set to buy - * - * @apiSuccess {Object} data.items user.items - * @apiSuccess {Object} data.purchasedPlanConsecutive user.purchased.plan.consecutive - * @apiSuccess {string} message Success message - */ -api.buyMysterySet = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/buy-mystery-set/:key', - async handler (req, res) { - let user = res.locals.user; - let buyMysterySetRes = common.ops.buyMysterySet(user, req, res.analytics); - await user.save(); - res.respond(200, ...buyMysterySetRes); - }, -}; - -/** - * @api {post} /api/v3/user/buy-quest/:key Buy a quest with gold - * @apiVersion 3.0.0 - * @apiName UserBuyQuest - * @apiGroup User - * - * @apiParam {string} key The quest scroll to buy - * - * @apiSuccess {Object} data `user.items.quests` - * @apiSuccess {string} message Success message - */ -api.buyQuest = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/buy-quest/:key', - async handler (req, res) { - let user = res.locals.user; - let buyQuestRes = common.ops.buyQuest(user, req, res.analytics); - await user.save(); - res.respond(200, ...buyQuestRes); - }, -}; - -/** - * @api {post} /api/v3/user/buy-special-spell/:key Buy special "spell" item - * @apiDescription Includes gift cards (e.g., birthday card), and avatar Transformation Items and their antidotes (e.g., Snowball item and Salt reward). - * @apiVersion 3.0.0 - * @apiName UserBuySpecialSpell - * @apiGroup User - * - * @apiParam {string} key The special item to buy. Must be one of the keys from "content.special", such as birthday, snowball, salt. - * - * @apiSuccess {Object} data.stats user.stats - * @apiSuccess {Object} data.items user.items - * @apiSuccess {string} message Success message - */ -api.buySpecialSpell = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/buy-special-spell/:key', - async handler (req, res) { - let user = res.locals.user; - let buySpecialSpellRes = common.ops.buySpecialSpell(user, req); - await user.save(); - res.respond(200, ...buySpecialSpellRes); - }, -}; - -/** - * @api {post} /api/v3/user/hatch/:egg/:hatchingPotion Hatch a pet - * @apiVersion 3.0.0 - * @apiName UserHatch - * @apiGroup User - * - * @apiParam {string} egg The egg to use - * @apiParam {string} hatchingPotion The hatching potion to use - * - * @apiSuccess {Object} data user.items - * @apiSuccess {string} message - */ -api.hatch = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/hatch/:egg/:hatchingPotion', - async handler (req, res) { - let user = res.locals.user; - let hatchRes = common.ops.hatch(user, req); - await user.save(); - res.respond(200, ...hatchRes); - }, -}; - -/** - * @api {post} /api/v3/user/equip/:type/:key Equip an item - * @apiVersion 3.0.0 - * @apiName UserEquip - * @apiGroup User - * - * @apiParam {string} type The type of item to equip (mount, pet, costume or equipped) - * @apiParam {string} key The item to equip - * - * @apiSuccess {Object} data user.items - * @apiSuccess {string} message Optional success message - */ -api.equip = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/equip/:type/:key', - async handler (req, res) { - let user = res.locals.user; - let equipRes = common.ops.equip(user, req); - await user.save(); - res.respond(200, ...equipRes); - }, -}; - -/** - * @api {post} /api/v3/user/equip/:pet/:food Feed a pet - * @apiVersion 3.0.0 - * @apiName UserFeed - * @apiGroup User - * - * @apiParam {string} pet - * @apiParam {string} food - * - * @apiSuccess {number} data The pet value - * @apiSuccess {string} message Success message - */ -api.feed = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/feed/:pet/:food', - async handler (req, res) { - let user = res.locals.user; - let feedRes = common.ops.feed(user, req); - await user.save(); - res.respond(200, ...feedRes); - }, -}; - -/** -* @api {post} /api/v3/user/change-class Change class -* @apiDescription User must be at least level 10. If ?class is defined and user.flags.classSelected is false it'll change the class. If user.preferences.disableClasses it'll enable classes, otherwise it sets user.flags.classSelected to false (costs 3 gems) -* @apiVersion 3.0.0 -* @apiName UserChangeClass -* @apiGroup User -* -* @apiParam {string} class Query parameter - ?class={warrior|rogue|wizard|healer} -* -* @apiSuccess {object} data.flags user.flags -* @apiSuccess {object} data.stats user.stats -* @apiSuccess {object} data.preferences user.preferences -* @apiSuccess {object} data.items user.items -*/ -api.changeClass = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/change-class', - async handler (req, res) { - let user = res.locals.user; - let changeClassRes = common.ops.changeClass(user, req, res.analytics); - await user.save(); - res.respond(200, ...changeClassRes); - }, -}; - -/** -* @api {post} /api/v3/user/disable-classes Disable classes -* @apiVersion 3.0.0 -* @apiName UserDisableClasses -* @apiGroup User -* -* @apiSuccess {object} data.flags user.flags -* @apiSuccess {object} data.stats user.stats -* @apiSuccess {object} data.preferences user.preferences -*/ -api.disableClasses = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/disable-classes', - async handler (req, res) { - let user = res.locals.user; - let disableClassesRes = common.ops.disableClasses(user, req); - await user.save(); - res.respond(200, ...disableClassesRes); - }, -}; - -/** -* @api {post} /api/v3/user/purchase/:type/:key Purchase Gem or Gem-purchasable item -* @apiVersion 3.0.0 -* @apiName UserPurchase -* @apiGroup User -* -* @apiParam {string} type Type of item to purchase. Must be one of: gems, eggs, hatchingPotions, food, quests, or gear -* @apiParam {string} key Item's key (use "gem" for purchasing gems) -* -* @apiSuccess {object} data.items user.items -* @apiSuccess {number} data.balance user.balance -* @apiSuccess {string} message Success message -*/ -api.purchase = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/purchase/:type/:key', - async handler (req, res) { - let user = res.locals.user; - let purchaseRes = common.ops.purchase(user, req, res.analytics); - await user.save(); - res.respond(200, ...purchaseRes); - }, -}; - -/** -* @api {post} /api/v3/user/purchase-hourglass/:type/:key Purchase Hourglass-purchasable item -* @apiVersion 3.0.0 -* @apiName UserPurchaseHourglass -* @apiGroup User -* -* @apiParam {string} type The type of item to purchase (pets or mounts) -* @apiParam {string} key Ex: {MantisShrimp-Base}. The key for the mount/pet -* -* @apiSuccess {object} data.items user.items -* @apiSuccess {object} data.purchasedPlanConsecutive user.purchased.plan.consecutive -* @apiSuccess {string} message Success message -*/ -api.userPurchaseHourglass = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/purchase-hourglass/:type/:key', - async handler (req, res) { - let user = res.locals.user; - let purchaseHourglassRes = common.ops.purchaseHourglass(user, req, res.analytics); - await user.save(); - res.respond(200, ...purchaseHourglassRes); - }, -}; - -/** -* @api {post} /api/v3/user/read-card/:cardType Reads a card -* @apiVersion 3.0.0 -* @apiName UserReadCard -* @apiGroup User -* -* @apiParam {string} cardType Type of card to read -* -* @apiSuccess {object} data.specialItems user.items.special -* @apiSuccess {boolean} data.cardReceived user.flags.cardReceived -* @apiSuccess {string} message Success message -*/ -api.readCard = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/read-card/:cardType', - async handler (req, res) { - let user = res.locals.user; - let readCardRes = common.ops.readCard(user, req); - await user.save(); - res.respond(200, ...readCardRes); - }, -}; - -/** -* @api {post} /api/v3/user/open-mystery-item Open the Mystery Item box -* @apiVersion 3.0.0 -* @apiName UserOpenMysteryItem -* @apiGroup User -* -* @apiSuccess {Object} data user.items.gear.owned -* @apiSuccess {string} message Success message -*/ -api.userOpenMysteryItem = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/open-mystery-item', - async handler (req, res) { - let user = res.locals.user; - let openMysteryItemRes = common.ops.openMysteryItem(user, req, res.analytics); - await user.save(); - res.respond(200, ...openMysteryItemRes); - }, -}; - -/** -* @api {post} /api/v3/user/webhook Create a new webhook - BETA -* @apiVersion 3.0.0 -* @apiName UserAddWebhook -* @apiGroup User -* -* @apiParam {string} url Body parameter - The webhook's URL -* @apiParam {boolean} enabled Body parameter - If the webhook should be enabled -* -* @apiSuccess {Object} data The created webhook -*/ -api.addWebhook = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/webhook', - async handler (req, res) { - let user = res.locals.user; - let addWebhookRes = common.ops.addWebhook(user, req); - await user.save(); - res.respond(200, ...addWebhookRes); - }, -}; - -/** -* @api {put} /api/v3/user/webhook/:id Edit a webhook - BETA -* @apiVersion 3.0.0 -* @apiName UserUpdateWebhook -* @apiGroup User -* -* @apiParam {UUID} id The id of the webhook to update -* @apiParam {string} url Body parameter - The webhook's URL -* @apiParam {boolean} enabled Body parameter - If the webhook should be enabled -* -* @apiSuccess {Object} data The updated webhook -*/ -api.updateWebhook = { - method: 'PUT', - middlewares: [authWithHeaders()], - url: '/user/webhook/:id', - async handler (req, res) { - let user = res.locals.user; - let updateWebhookRes = common.ops.updateWebhook(user, req); - await user.save(); - res.respond(200, ...updateWebhookRes); - }, -}; - -/** -* @api {delete} /api/v3/user/webhook/:id Delete a webhook - BETA -* @apiVersion 3.0.0 -* @apiName UserDeleteWebhook -* @apiGroup User -* -* @apiParam {UUID} id The id of the webhook to delete -* -* @apiSuccess {Object} data The user webhooks -*/ -api.deleteWebhook = { - method: 'DELETE', - middlewares: [authWithHeaders()], - url: '/user/webhook/:id', - async handler (req, res) { - let user = res.locals.user; - let deleteWebhookRes = common.ops.deleteWebhook(user, req); - await user.save(); - res.respond(200, ...deleteWebhookRes); - }, -}; - - -/* @api {post} /api/v3/user/release-pets Release pets -* @apiVersion 3.0.0 -* @apiName UserReleasePets -* @apiGroup User -* -* @apiSuccess {Object} data.items `user.items.pets` -* @apiSuccess {string} message Success message -*/ -api.userReleasePets = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/release-pets', - async handler (req, res) { - let user = res.locals.user; - let releasePetsRes = common.ops.releasePets(user, req, res.analytics); - await user.save(); - res.respond(200, ...releasePetsRes); - }, -}; - -/** -* @api {post} /api/v3/user/release-both Release pets and mounts and grants Triad Bingo -* @apiVersion 3.0.0 -* @apiName UserReleaseBoth -* @apiGroup User - -* @apiSuccess {Object} data.achievements -* @apiSuccess {Object} data.items -* @apiSuccess {number} data.balance -* @apiSuccess {string} message Success message -*/ -api.userReleaseBoth = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/release-both', - async handler (req, res) { - let user = res.locals.user; - let releaseBothRes = common.ops.releaseBoth(user, req, res.analytics); - await user.save(); - res.respond(200, ...releaseBothRes); - }, -}; - -/** -* @api {post} /api/v3/user/release-mounts Release mounts -* @apiVersion 3.0.0 -* @apiName UserReleaseMounts -* @apiGroup User -* -* @apiSuccess {Object} data user.items.mounts -* @apiSuccess {string} message Success message -*/ -api.userReleaseMounts = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/release-mounts', - async handler (req, res) { - let user = res.locals.user; - let releaseMountsRes = common.ops.releaseMounts(user, req, res.analytics); - await user.save(); - res.respond(200, ...releaseMountsRes); - }, -}; - -/** -* @api {post} /api/v3/user/sell/:type/:key Sell a gold-sellable item owned by the user -* @apiVersion 3.0.0 -* @apiName UserSell -* @apiGroup User -* -* @apiParam {string} type The type of item to sell. Must be one of: eggs, hatchingPotions, or food -* @apiParam {string} key The key of the item -* -* @apiSuccess {Object} data.stats -* @apiSuccess {Object} data.items -* @apiSuccess {string} message Success message -*/ -api.userSell = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/sell/:type/:key', - async handler (req, res) { - let user = res.locals.user; - let sellRes = common.ops.sell(user, req); - await user.save(); - res.respond(200, ...sellRes); - }, -}; - -/** -* @api {post} /api/v3/user/unlock Unlock item or set of items by purchase -* @apiVersion 3.0.0 -* @apiName UserUnlock -* @apiGroup User -* -* @apiParam {string} path Query parameter. The path to unlock -* -* @apiSuccess {Object} data.purchased -* @apiSuccess {Object} data.items -* @apiSuccess {Object} data.preferences -* @apiSuccess {string} message -*/ -api.userUnlock = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/unlock', - async handler (req, res) { - let user = res.locals.user; - let unlockRes = common.ops.unlock(user, req); - await user.save(); - res.respond(200, ...unlockRes); - }, -}; - -/** -* @api {post} /api/v3/user/revive Revive user from death -* @apiVersion 3.0.0 -* @apiName UserRevive -* @apiGroup User -* -* @apiSuccess {Object} data user.items -* @apiSuccess {string} message Success message -*/ -api.userRevive = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/revive', - async handler (req, res) { - let user = res.locals.user; - let reviveRes = common.ops.revive(user, req, res.analytics); - await user.save(); - res.respond(200, ...reviveRes); - }, -}; - -/** -* @api {post} /api/v3/user/rebirth Use Orb of Rebirth on user -* @apiVersion 3.0.0 -* @apiName UserRebirth -* @apiGroup User -* -* @apiSuccess {Object} data.user -* @apiSuccess {array} data.tasks User's modified tasks (no rewards) -* @apiSuccess {string} message Success message -*/ -api.userRebirth = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/rebirth', - async handler (req, res) { - let user = res.locals.user; - let tasks = await Tasks.Task.find({ - userId: user._id, - type: {$in: ['daily', 'habit', 'todo']}, - $or: [ // exclude challenge tasks - {'challenge.id': {$exists: false}}, - {'challenge.broken': {$exists: true}}, - ], - }).exec(); - - let rebirthRes = common.ops.rebirth(user, tasks, req, res.analytics); - - let toSave = tasks.map(task => task.save()); - - toSave.push(user.save()); - - await Bluebird.all(toSave); - - res.respond(200, ...rebirthRes); - }, -}; - -/** - * @api {post} /api/v3/user/block/:uuid Block and unblock a user - * @apiDescription Must be an admin to make this request. - * @apiVersion 3.0.0 - * @apiName BlockUser - * @apiGroup User - * - * @apiParam {UUID} uuid The uuid of the user to block / unblock - * - * @apiSuccess {array} data user.inbox.blocks -**/ -api.blockUser = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/block/:uuid', - async handler (req, res) { - let user = res.locals.user; - let blockUserRes = common.ops.blockUser(user, req); - await user.save(); - res.respond(200, ...blockUserRes); - }, -}; - -/** - * @api {delete} /api/v3/user/messages/:id Delete a message - * @apiVersion 3.0.0 - * @apiName deleteMessage - * @apiGroup User - * - * @apiParam {UUID} id The id of the message to delete - * - * @apiSuccess {object} data user.inbox.messages -**/ -api.deleteMessage = { - method: 'DELETE', - middlewares: [authWithHeaders()], - url: '/user/messages/:id', - async handler (req, res) { - let user = res.locals.user; - let deletePMRes = common.ops.deletePM(user, req); - await user.save(); - res.respond(200, ...deletePMRes); - }, -}; - -/** - * @api {delete} /api/v3/user/messages Delete all messages - * @apiVersion 3.0.0 - * @apiName clearMessages - * @apiGroup User - * - * @apiSuccess {object} data user.inbox.messages -**/ -api.clearMessages = { - method: 'DELETE', - middlewares: [authWithHeaders()], - url: '/user/messages', - async handler (req, res) { - let user = res.locals.user; - let clearPMsRes = common.ops.clearPMs(user, req); - await user.save(); - res.respond(200, ...clearPMsRes); - }, -}; - -/** - * @api {post} /api/v3/user/mark-pms-read Marks Private Messages as read - * @apiVersion 3.0.0 - * @apiName markPmsRead - * @apiGroup User - * - * @apiSuccess {object} data user.inbox.messages -**/ -api.markPmsRead = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/mark-pms-read', - async handler (req, res) { - let user = res.locals.user; - let markPmsResponse = common.ops.markPmsRead(user, req); - await user.save(); - res.respond(200, markPmsResponse); - }, -}; - -/** -* @api {post} /api/v3/user/reroll Reroll a user using the Fortify Potion -* @apiVersion 3.0.0 -* @apiName UserReroll -* @apiGroup User -* -* @apiSuccess {Object} data.user -* @apiSuccess {Object} data.tasks User's modified tasks (no rewards) -* @apiSuccess {Object} message Success message -*/ -api.userReroll = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/reroll', - async handler (req, res) { - let user = res.locals.user; - let query = { - userId: user._id, - type: {$in: ['daily', 'habit', 'todo']}, - $or: [ // exclude challenge tasks - {'challenge.id': {$exists: false}}, - {'challenge.broken': {$exists: true}}, - ], - }; - let tasks = await Tasks.Task.find(query).exec(); - let rerollRes = common.ops.reroll(user, tasks, req, res.analytics); - - let promises = tasks.map(task => task.save()); - promises.push(user.save()); - - await Bluebird.all(promises); - - res.respond(200, ...rerollRes); - }, -}; - -/** -* @api {post} /api/v3/user/addPushDevice Add a push device to a user -* @apiVersion 3.0.0 -* @apiName UserAddPushDevice -* @apiGroup User -* -* @apiParam {string} regId The id of the push device -* @apiParam {string} uuid The type of push device -* -* @apiSuccess {Object} data List of push devices -* @apiSuccess {string} message Success message -*/ -api.userAddPushDevice = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/addPushDevice', - async handler (req, res) { - let user = res.locals.user; - - let addPushDeviceRes = common.ops.addPushDevice(user, req); - await user.save(); - - res.respond(200, ...addPushDeviceRes); - }, -}; - -/** -* @api {post} /api/v3/user/reset Reset user -* @apiVersion 3.0.0 -* @apiName UserReset -* @apiGroup User -* -* @apiSuccess {Object} data.user -* @apiSuccess {Object} data.tasksToRemove IDs of removed tasks -* @apiSuccess {string} message Success message -*/ -api.userReset = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/reset', - async handler (req, res) { - let user = res.locals.user; - - let tasks = await Tasks.Task.find({ - userId: user._id, - $or: [ // exclude challenge tasks - {'challenge.id': {$exists: false}}, - {'challenge.broken': {$exists: true}}, - ], - }).select('_id type challenge').exec(); - - let resetRes = common.ops.reset(user, tasks, req); - - await Bluebird.all([ - Tasks.Task.remove({_id: {$in: resetRes[0].tasksToRemove}, userId: user._id}), - user.save(), - ]); - - res.respond(200, ...resetRes); - }, -}; - -/** -* @api {post} /api/v3/user/custom-day-start Sets preferences.dayStart for user -* @apiVersion 3.0.0 -* @apiName setCustomDayStart -* @apiGroup User -* -* @apiSuccess {Object} data An empty Object -*/ -api.setCustomDayStart = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/custom-day-start', - async handler (req, res) { - let user = res.locals.user; - let dayStart = req.body.dayStart; - - user.preferences.dayStart = dayStart; - user.lastCron = new Date(); - - await user.save(); - - res.respond(200, { - message: res.t('customDayStartHasChanged'), - }); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/top-level/auth.js b/website/server/controllers/top-level/auth.js deleted file mode 100644 index 90724ab1f4..0000000000 --- a/website/server/controllers/top-level/auth.js +++ /dev/null @@ -1,16 +0,0 @@ -let api = {}; - -// Internal authentication routes - -// Logout the user from the website. -api.logout = { - method: 'GET', - url: '/logout', - async handler (req, res) { - if (req.logout) req.logout(); // passportjs method - req.session = null; - res.redirect('/'); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js deleted file mode 100644 index 08bc4a1f8e..0000000000 --- a/website/server/controllers/top-level/dataexport.js +++ /dev/null @@ -1,247 +0,0 @@ -import { authWithSession } from '../../middlewares/api-v3/auth'; -import { model as User } from '../../models/user'; -import * as Tasks from '../../models/task'; -import { - NotFound, -} from '../../libs/api-v3/errors'; -import _ from 'lodash'; -import csvStringify from '../../libs/api-v3/csvStringify'; -import moment from 'moment'; -import js2xml from 'js2xmlparser'; -import Pageres from 'pageres'; -import AWS from 'aws-sdk'; -import nconf from 'nconf'; -import got from 'got'; -import Bluebird from 'bluebird'; -import locals from '../../middlewares/api-v3/locals'; - -let S3 = new AWS.S3({ - accessKeyId: nconf.get('S3:accessKeyId'), - secretAccessKey: nconf.get('S3:secretAccessKey'), -}); -const S3_BUCKET = nconf.get('S3:bucket'); - -const BASE_URL = nconf.get('BASE_URL'); - -let api = {}; - -/** - * @api {get} /export/history.csv Export user tasks history in CSV format - * @apiDescription History is only available for habits and dailys so todos and rewards won't be included. NOTE: Part of the private API that may change at any time. - * @apiVersion 3.0.0 - * @apiName ExportUserHistory - * @apiGroup DataExport - * - * @apiSuccess {string} A cvs file - */ -api.exportUserHistory = { - method: 'GET', - url: '/export/history.csv', - middlewares: [authWithSession], - async handler (req, res) { - let user = res.locals.user; - - let tasks = await Tasks.Task.find({ - userId: user._id, - type: {$in: ['habit', 'daily']}, - }).exec(); - - let output = [ - ['Task Name', 'Task ID', 'Task Type', 'Date', 'Value'], - ]; - - tasks.forEach(task => { - task.history.forEach(history => { - output.push([ - task.text, - task._id, - task.type, - moment(history.date).format('YYYY-MM-DD HH:mm:ss'), - history.value, - ]); - }); - }); - - res.set({ - 'Content-Type': 'text/csv', - 'Content-disposition': 'attachment; filename=habitica-tasks-history.csv', - }); - - let csvRes = await csvStringify(output); - res.status(200).send(csvRes); - }, -}; - -// Convert user to json and attach tasks divided by type -// at user.tasks[`${taskType}s`] (user.tasks.{dailys/habits/...}) -async function _getUserDataForExport (user) { - let userData = user.toJSON(); - userData.tasks = {}; - - let tasks = await Tasks.Task.find({ - userId: user._id, - }).exec(); - - tasks = _.chain(tasks) - .map(task => task.toJSON()) - .groupBy(task => task.type) - .each((tasksPerType, taskType) => { - userData.tasks[`${taskType}s`] = tasksPerType; - }) - .value(); - - return userData; -} - -/** - * @api {get} /export/userdata.json Export user data in JSON format - * @apiVersion 3.0.0 - * @apiName ExportUserDataJson - * @apiGroup DataExport - * @apiDescription NOTE: Part of the private API that may change at any time. - * - * @apiSuccess {string} A json file - */ -api.exportUserDataJson = { - method: 'GET', - url: '/export/userdata.json', - middlewares: [authWithSession], - async handler (req, res) { - let userData = await _getUserDataForExport(res.locals.user); - - res.set({ - 'Content-Type': 'application/json', - 'Content-disposition': 'attachment; filename=habitica-user-data.json', - }); - let jsonRes = JSON.stringify(userData); - - res.status(200).send(jsonRes); - }, -}; - -/** - * @api {get} /export/userdata.xml Export user data in XML format - * @apiVersion 3.0.0 - * @apiName ExportUserDataXml - * @apiGroup DataExport - * @apiDescription NOTE: Part of the private API that may change at any time. - * - * @apiSuccess {string} A xml file - */ -api.exportUserDataXml = { - method: 'GET', - url: '/export/userdata.xml', - middlewares: [authWithSession], - async handler (req, res) { - let userData = await _getUserDataForExport(res.locals.user); - - res.set({ - 'Content-Type': 'text/xml', - 'Content-disposition': 'attachment; filename=habitica-user-data.xml', - }); - res.status(200).send(js2xml('user', userData)); - }, -}; - -/** - * @api {get} /export/avatar-:uuid.html Render a user avatar as an HTML page - * @apiVersion 3.0.0 - * @apiName ExportUserAvatarHtml - * @apiGroup DataExport - * @apiDescription NOTE: Part of the private API that may change at any time. - * - * @apiSuccess {string} An html page - */ -api.exportUserAvatarHtml = { - method: 'GET', - url: '/export/avatar-:memberId.html', - middlewares: [locals], - async handler (req, res) { - req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let memberId = req.params.memberId; - let member = await User - .findById(memberId) - .select('stats profile items achievements preferences backer contributor') - .exec(); - - if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); - res.render('avatar-static', { - title: member.profile.name, - env: _.defaults({user: member}, res.locals.habitrpg), - }); - }, -}; - -/** - * @api {get} /export/avatar-:uuid.html Export a user avatar as a PNG file - * @apiVersion 3.0.0 - * @apiName ExportUserAvatarPng - * @apiGroup DataExport - * @apiDescription NOTE: Part of the private API that may change at any time. - * - * @apiSuccess {string} A png file - */ -api.exportUserAvatarPng = { - method: 'GET', - url: '/export/avatar-:memberId.png', - async handler (req, res) { - req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let memberId = req.params.memberId; - - let filename = `avatars/${memberId}.png`; - let s3url = `https://${S3_BUCKET}.s3.amazonaws.com/${filename}`; - - let response; - try { - response = await got.head(s3url); - } catch (gotError) { - if (gotError.code !== 'ENOTFOUND' && gotError.statusCode !== 404) { - throw gotError; - } - } - - // cache images for 30 minutes on aws, else upload a new one - if (response && response.statusCode === 200 && moment().diff(response.headers['last-modified'], 'minutes') < 30) { - return res.redirect(s3url); - } - - let [stream] = await new Pageres() - .src(`${BASE_URL}/export/avatar-${memberId}.html`, ['140x147'], { - crop: true, - filename: filename.replace('.png', ''), - }) - .run(); - - let s3upload = S3.upload({ - Bucket: S3_BUCKET, - Key: filename, - ACL: 'public-read', - StorageClass: 'REDUCED_REDUNDANCY', - ContentType: 'image/png', - Expires: moment().add({minutes: 5}).toDate(), - Body: stream, - }); - - let s3res = await new Bluebird((resolve, reject) => { - s3upload.send((err, s3uploadRes) => { - if (err) { - reject(err); - } else { - resolve(s3uploadRes); - } - }); - }); - - res.redirect(s3res.Location); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/top-level/email.js b/website/server/controllers/top-level/email.js deleted file mode 100644 index b84f54c6e1..0000000000 --- a/website/server/controllers/top-level/email.js +++ /dev/null @@ -1,54 +0,0 @@ -import { model as User } from '../../models/user'; -import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; -import { decrypt } from '../../libs/api-v3/encryption'; -import { - NotFound, -} from '../../libs/api-v3/errors'; - -let api = {}; - -/** - * @api {get} /api/v3/email/unsubscribe Unsubscribe an email or user from email notifications - * @apiDescription Does not require authentication - * @apiVersion 3.0.0 - * @apiName UnsubscribeEmail - * @apiGroup Unsubscribe - * @apiDescription This is a GET method so that you can put the unsubscribe link in emails. - * - * @apiParam {String} code Query parameter - An unsubscription code - * - * @apiSuccess {String} An html success message - */ -api.unsubscribe = { - method: 'GET', - url: '/email/unsubscribe', - async handler (req, res) { - req.checkQuery({ - code: { - notEmpty: {errorMessage: res.t('missingUnsubscriptionCode')}, - }, - }); - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let data = JSON.parse(decrypt(req.query.code)); - - if (data._id) { - let userUpdated = await User.update( - {_id: data._id}, - { $set: {'preferences.emailNotifications.unsubscribeFromAll': true}} - ); - - if (userUpdated.nModified !== 1) throw new NotFound(res.t('userNotFound')); - - res.send(`

${res.t('unsubscribedSuccessfully')}

${res.t('unsubscribedTextUsers')}`); - } else { - let unsubscribedEmail = await EmailUnsubscription.findOne({email: data.email.toLowerCase()}); - let okResponse = `

${res.t('unsubscribedSuccessfully')}

${res.t('unsubscribedTextOthers')}`; - if (!unsubscribedEmail) await EmailUnsubscription.create({email: data.email.toLowerCase()}); - res.send(okResponse); - } - }, -}; - -module.exports = api; diff --git a/website/server/controllers/top-level/pages.js b/website/server/controllers/top-level/pages.js deleted file mode 100644 index 614b1edc0a..0000000000 --- a/website/server/controllers/top-level/pages.js +++ /dev/null @@ -1,79 +0,0 @@ -import locals from '../../middlewares/api-v3/locals'; -import _ from 'lodash'; -import markdownIt from 'markdown-it'; - -const md = markdownIt({ - html: true, -}); - -let api = {}; - -const TOTAL_USER_COUNT = '1,100,000'; - -api.getFrontPage = { - method: 'GET', - url: '/', - middlewares: [locals], - runCron: false, - async handler (req, res) { - if (!req.header('x-api-user') && !req.header('x-api-key') && !(req.session && req.session.userId)) { - return res.redirect('/static/front'); - } - - return res.render('index.jade', { - title: 'Habitica | Your Life The Role Playing Game', - env: res.locals.habitrpg, - }); - }, -}; - -let staticPages = ['front', 'privacy', 'terms', 'api-v2', 'features', - 'videos', 'contact', 'plans', 'new-stuff', 'community-guidelines', - 'old-news', 'press-kit', 'faq', 'overview', 'apps', - 'clear-browser-data', 'merch', 'maintenance-info']; - -_.each(staticPages, (name) => { - api[`get${name}Page`] = { - method: 'GET', - url: `/static/${name}`, - middlewares: [locals], - runCron: false, - async handler (req, res) { - return res.render(`static/${name}.jade`, { - env: res.locals.habitrpg, - md, - userCount: TOTAL_USER_COUNT, - }); - }, - }; -}); - -let shareables = ['level-up', 'hatch-pet', 'raise-pet', 'unlock-quest', 'won-challenge', 'achievement']; - -_.each(shareables, (name) => { - api[`get${name}ShareablePage`] = { - method: 'GET', - url: `/social/${name}`, - middlewares: [locals], - runCron: false, - async handler (req, res) { - return res.render(`social/${name}`, { - env: res.locals.habitrpg, - md, - userCount: TOTAL_USER_COUNT, - }); - }, - }; -}); - -api.redirectExtensionsPage = { - method: 'GET', - url: '/static/extensions', - runCron: false, - async handler (req, res) { - return res.redirect('http://habitica.wikia.com/wiki/App_and_Extension_Integrations'); - }, -}; - - -module.exports = api; diff --git a/website/server/controllers/top-level/payments/amazon.js b/website/server/controllers/top-level/payments/amazon.js deleted file mode 100644 index a22618fa23..0000000000 --- a/website/server/controllers/top-level/payments/amazon.js +++ /dev/null @@ -1,256 +0,0 @@ -import { - BadRequest, - NotAuthorized, -} from '../../../libs/api-v3/errors'; -import amzLib from '../../../libs/api-v3/amazonPayments'; -import { - authWithHeaders, - authWithUrl, -} from '../../../middlewares/api-v3/auth'; -import shared from '../../../../../common'; -import payments from '../../../libs/api-v3/payments'; -import moment from 'moment'; -import { model as Coupon } from '../../../models/coupon'; -import { model as User } from '../../../models/user'; -import cc from 'coupon-code'; - -let api = {}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {post} /amazon/verifyAccessToken Amazon Payments: verify access token - * @apiVersion 3.0.0 - * @apiName AmazonVerifyAccessToken - * @apiGroup Payments - * - * @apiSuccess {Object} data Empty object - **/ -api.verifyAccessToken = { - method: 'POST', - url: '/amazon/verifyAccessToken', - middlewares: [authWithHeaders()], - async handler (req, res) { - let accessToken = req.body.access_token; - - if (!accessToken) throw new BadRequest('Missing req.body.access_token'); - - await amzLib.getTokenInfo(accessToken); - res.respond(200, {}); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {post} /amazon/createOrderReferenceId Amazon Payments: create order reference id - * @apiVersion 3.0.0 - * @apiName AmazonCreateOrderReferenceId - * @apiGroup Payments - * - * @apiSuccess {string} data.orderReferenceId The order reference id. - **/ -api.createOrderReferenceId = { - method: 'POST', - url: '/amazon/createOrderReferenceId', - middlewares: [authWithHeaders()], - async handler (req, res) { - let billingAgreementId = req.body.billingAgreementId; - - if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId'); - - let response = await amzLib.createOrderReferenceId({ - Id: billingAgreementId, - IdType: 'BillingAgreement', - ConfirmNow: false, - }); - - res.respond(200, { - orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, - }); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {post} /amazon/checkout Amazon Payments: checkout - * @apiVersion 3.0.0 - * @apiName AmazonCheckout - * @apiGroup Payments - * - * @apiSuccess {object} data Empty object - **/ -api.checkout = { - method: 'POST', - url: '/amazon/checkout', - middlewares: [authWithHeaders()], - async handler (req, res) { - let gift = req.body.gift; - let user = res.locals.user; - let orderReferenceId = req.body.orderReferenceId; - let amount = 5; - - if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId'); - - if (gift) { - if (gift.type === 'gems') { - amount = gift.gems.amount / 4; - } else if (gift.type === 'subscription') { - amount = shared.content.subscriptionBlocks[gift.subscription.key].price; - } - } - - await amzLib.setOrderReferenceDetails({ - AmazonOrderReferenceId: orderReferenceId, - OrderReferenceAttributes: { - OrderTotal: { - CurrencyCode: 'USD', - Amount: amount, - }, - SellerNote: 'HabitRPG Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG', - }, - }, - }); - - await amzLib.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId }); - - await amzLib.authorize({ - AmazonOrderReferenceId: orderReferenceId, - AuthorizationReferenceId: shared.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: 'USD', - Amount: amount, - }, - SellerAuthorizationNote: 'HabitRPG Payment', - TransactionTimeout: 0, - CaptureNow: true, - }); - - await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); - - // execute payment - let method = 'buyGems'; - let data = { user, paymentMethod: 'Amazon Payments' }; - - if (gift) { - if (gift.type === 'subscription') method = 'createSubscription'; - gift.member = await User.findById(gift ? gift.uuid : undefined); - data.gift = gift; - data.paymentMethod = 'Gift'; - } - - await payments[method](data); - - res.respond(200); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {post} /amazon/subscribe Amazon Payments: subscribe - * @apiVersion 3.0.0 - * @apiName AmazonSubscribe - * @apiGroup Payments - * - * @apiSuccess {object} data Empty object - **/ -api.subscribe = { - method: 'POST', - url: '/amazon/subscribe', - middlewares: [authWithHeaders()], - async handler (req, res) { - let billingAgreementId = req.body.billingAgreementId; - let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; - let coupon = req.body.coupon; - let user = res.locals.user; - - if (!sub) throw new BadRequest(res.t('missingSubscriptionCode')); - if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId'); - - if (sub.discount) { // apply discount - if (!coupon) throw new BadRequest(res.t('couponCodeRequired')); - let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key}); - if (!result) throw new NotAuthorized(res.t('invalidCoupon')); - } - - await amzLib.setBillingAgreementDetails({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: 'HabitRPG Subscription', - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: shared.uuid(), - StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription', - }, - }, - }); - - await amzLib.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }); - - await amzLib.authorizeOnBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - AuthorizationReferenceId: shared.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: 'USD', - Amount: sub.price, - }, - SellerAuthorizationNote: 'HabitRPG Subscription Payment', - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: 'HabitRPG Subscription Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG', - }, - }); - - await payments.createSubscription({ - user, - customerId: billingAgreementId, - paymentMethod: 'Amazon Payments', - sub, - }); - - res.respond(200); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {get} /amazon/subscribe/cancel Amazon Payments: subscribe cancel - * @apiVersion 3.0.0 - * @apiName AmazonSubscribe - * @apiGroup Payments - **/ -api.subscribeCancel = { - method: 'GET', - url: '/amazon/subscribe/cancel', - middlewares: [authWithUrl], - async handler (req, res) { - let user = res.locals.user; - let billingAgreementId = user.purchased.plan.customerId; - - if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription')); - - await amzLib.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }); - - await payments.cancelSubscription({ - user, - nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }), - paymentMethod: 'Amazon Payments', - }); - - if (req.query.noRedirect) { - res.respond(200); - } else { - res.redirect('/'); - } - }, -}; - -module.exports = api; diff --git a/website/server/controllers/top-level/payments/iap.js b/website/server/controllers/top-level/payments/iap.js deleted file mode 100644 index e99590fa71..0000000000 --- a/website/server/controllers/top-level/payments/iap.js +++ /dev/null @@ -1,191 +0,0 @@ -import iap from 'in-app-purchase'; -import nconf from 'nconf'; -import { - authWithHeaders, - authWithUrl, -} from '../../../middlewares/api-v3/auth'; -import payments from '../../../libs/api-v3/payments'; - -// NOT PORTED TO v3 - -iap.config({ - // this is the path to the directory containing iap-sanbox/iap-live files - googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), -}); - -// Validation ERROR Codes -const INVALID_PAYLOAD = 6778001; -// const CONNECTION_FAILED = 6778002; -// const PURCHASE_EXPIRED = 6778003; - -let api = {}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {post} /iap/android/verify Android Verify IAP - * @apiVersion 3.0.0 - * @apiName IapAndroidVerify - * @apiGroup Payments - **/ -api.iapAndroidVerify = { - method: 'POST', - url: '/iap/android/verify', - middlewares: [authWithUrl], - async handler (req, res) { - let user = res.locals.user; - let iapBody = req.body; - - iap.setup((error) => { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // google receipt must be provided as an object - // { - // "data": "{stringified data object}", - // "signature": "signature from google" - // } - let testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature, - }; - - // iap is ready - iap.validate(iap.GOOGLE, testObj, (err, googleRes) => { - if (err) { - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, - }; - - return res.json(resObj); - } - - if (iap.isValidated(googleRes)) { - let resObj = { - ok: true, - data: googleRes, - }; - - payments.buyGems({ - user, - paymentMethod: 'IAP GooglePlay', - amount: 5.25, - }).then(() => res.json(resObj)); - } - }); - }); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {post} /iap/ios/verify iOS Verify IAP - * @apiVersion 3.0.0 - * @apiName IapiOSVerify - * @apiGroup Payments - **/ -api.iapiOSVerify = { - method: 'POST', - url: '/iap/ios/verify', - middlewares: [authWithHeaders()], - async handler (req, res) { - let iapBody = req.body; - let user = res.locals.user; - - iap.setup(function iosSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => { - if (err) { - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, - }; - - return res.json(resObj); - } - - if (iap.isValidated(appleRes)) { - let purchaseDataList = iap.getPurchaseData(appleRes); - if (purchaseDataList.length > 0) { - let correctReceipt = true; - - for (let index in purchaseDataList) { - switch (purchaseDataList[index].productId) { - case 'com.habitrpg.ios.Habitica.4gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); - break; - case 'com.habitrpg.ios.Habitica.8gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); - break; - case 'com.habitrpg.ios.Habitica.20gems': - case 'com.habitrpg.ios.Habitica.21gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); - break; - case 'com.habitrpg.ios.Habitica.42gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); - break; - default: - correctReceipt = false; - } - } - - if (correctReceipt) { - let resObj = { - ok: true, - data: appleRes, - }; - - // yay good! - return res.json(resObj); - } - } - - // wrong receipt content - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: 'Incorrect receipt content', - }, - }; - - return res.json(resObj); - } - - // invalid receipt - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: 'Invalid receipt', - }, - }; - - return res.json(resObj); - }); - }); - }, -}; - -module.exports = api; diff --git a/website/server/controllers/top-level/payments/paypal.js b/website/server/controllers/top-level/payments/paypal.js deleted file mode 100644 index 4bcd2d0735..0000000000 --- a/website/server/controllers/top-level/payments/paypal.js +++ /dev/null @@ -1,278 +0,0 @@ -/* eslint-disable camelcase */ - -import nconf from 'nconf'; -import moment from 'moment'; -import _ from 'lodash'; -import payments from '../../../libs/api-v3/payments'; -import ipn from 'paypal-ipn'; -import paypal from 'paypal-rest-sdk'; -import shared from '../../../../../common'; -import cc from 'coupon-code'; -import Bluebird from 'bluebird'; -import { model as Coupon } from '../../../models/coupon'; -import { model as User } from '../../../models/user'; -import { - authWithUrl, - authWithSession, -} from '../../../middlewares/api-v3/auth'; -import { - BadRequest, - NotAuthorized, -} from '../../../libs/api-v3/errors'; - -const BASE_URL = nconf.get('BASE_URL'); - -// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have -// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created -// there, get it's plan.id and store it in config.json -_.each(shared.content.subscriptionBlocks, (block) => { - block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`); -}); - -paypal.configure({ - mode: nconf.get('PAYPAL:mode'), // sandbox or live - client_id: nconf.get('PAYPAL:client_id'), - client_secret: nconf.get('PAYPAL:client_secret'), -}); - -// TODO better handling of errors -const paypalPaymentCreate = Bluebird.promisify(paypal.payment.create, {context: paypal.payment}); -const paypalPaymentExecute = Bluebird.promisify(paypal.payment.execute, {context: paypal.payment}); -const paypalBillingAgreementCreate = Bluebird.promisify(paypal.billingAgreement.create, {context: paypal.billingAgreement}); -const paypalBillingAgreementExecute = Bluebird.promisify(paypal.billingAgreement.execute, {context: paypal.billingAgreement}); -const paypalBillingAgreementGet = Bluebird.promisify(paypal.billingAgreement.get, {context: paypal.billingAgreement}); -const paypalBillingAgreementCancel = Bluebird.promisify(paypal.billingAgreement.cancel, {context: paypal.billingAgreement}); - -const ipnVerifyAsync = Bluebird.promisify(ipn.verify, {context: ipn}); - -let api = {}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {get} /paypal/checkout Paypal: checkout - * @apiVersion 3.0.0 - * @apiName PaypalCheckout - * @apiGroup Payments - **/ -api.checkout = { - method: 'GET', - url: '/paypal/checkout', - middlewares: [authWithUrl], - async handler (req, res) { - let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - req.session.gift = req.query.gift; - - let amount = 5.00; - let description = 'HabitRPG gems'; - if (gift) { - if (gift.type === 'gems') { - amount = Number(gift.gems.amount / 4).toFixed(2); - description = `${description} (Gift)`; - } else { - amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); - description = 'mo. HabitRPG Subscription (Gift)'; - } - } - - let createPayment = { - intent: 'sale', - payer: { payment_method: 'Paypal' }, - redirect_urls: { - return_url: `${BASE_URL}/paypal/checkout/success`, - cancel_url: `${BASE_URL}`, - }, - transactions: [{ - item_list: { - items: [{ - name: description, - // sku: 1, - price: amount, - currency: 'USD', - quantity: 1, - }], - }, - amount: { - currency: 'USD', - total: amount, - }, - description, - }], - }; - - let result = await paypalPaymentCreate(createPayment); - let link = _.find(result.links, { rel: 'approval_url' }).href; - res.redirect(link); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {get} /paypal/checkout/success Paypal: checkout success - * @apiVersion 3.0.0 - * @apiName PaypalCheckoutSuccess - * @apiGroup Payments - **/ -api.checkoutSuccess = { - method: 'GET', - url: '/paypal/checkout/success', - middlewares: [authWithSession], - async handler (req, res) { - let paymentId = req.query.paymentId; - let customerId = req.query.payerID; - - let method = 'buyGems'; - let data = { - user: res.locals.user, - customerId, - paymentMethod: 'Paypal', - }; - - let gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; - delete req.session.gift; - - if (gift) { - gift.member = await User.findById(gift.uuid); - if (gift.type === 'subscription') { - method = 'createSubscription'; - } - - data.paymentMethod = 'Gift'; - data.gift = gift; - } - - await paypalPaymentExecute(paymentId, { payer_id: customerId }); - await payments[method](data); - res.redirect('/'); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {get} /paypal/subscribe Paypal: subscribe - * @apiVersion 3.0.0 - * @apiName PaypalSubscribe - * @apiGroup Payments - **/ -api.subscribe = { - method: 'GET', - url: '/paypal/subscribe', - middlewares: [authWithUrl], - async handler (req, res) { - let sub = shared.content.subscriptionBlocks[req.query.sub]; - - if (sub.discount) { - if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired')); - let coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key}); - if (!coupon) throw new NotAuthorized(res.t('invalidCoupon')); - } - - let billingPlanTitle = `HabitRPG Subscription ($${sub.price} every ${sub.months} months, recurring)`; - let billingAgreementAttributes = { - name: billingPlanTitle, - description: billingPlanTitle, - start_date: moment().add({ minutes: 5 }).format(), - plan: { - id: sub.paypalKey, - }, - payer: { - payment_method: 'Paypal', - }, - }; - let billingAgreement = await paypalBillingAgreementCreate(billingAgreementAttributes); - - req.session.paypalBlock = req.query.sub; - let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href; - res.redirect(link); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {get} /paypal/subscribe/success Paypal: subscribe success - * @apiVersion 3.0.0 - * @apiName PaypalSubscribeSuccess - * @apiGroup Payments - **/ -api.subscribeSuccess = { - method: 'GET', - url: '/paypal/subscribe/success', - middlewares: [authWithSession], - async handler (req, res) { - let user = res.locals.user; - let block = shared.content.subscriptionBlocks[req.session.paypalBlock]; - delete req.session.paypalBlock; - - let result = await paypalBillingAgreementExecute(req.query.token, {}); - await payments.createSubscription({ - user, - customerId: result.id, - paymentMethod: 'Paypal', - sub: block, - }); - - res.redirect('/'); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {get} /paypal/subscribe/cancel Paypal: subscribe cancel - * @apiVersion 3.0.0 - * @apiName PaypalSubscribeCancel - * @apiGroup Payments - **/ -api.subscribeCancel = { - method: 'GET', - url: '/paypal/subscribe/cancel', - middlewares: [authWithUrl], - async handler (req, res) { - let user = res.locals.user; - let customerId = user.purchased.plan.customerId; - if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription')); - - let customer = await paypalBillingAgreementGet(customerId); - - let nextBillingDate = customer.agreement_details.next_billing_date; - if (customer.agreement_details.cycles_completed === '0') { // hasn't billed yet - throw new BadRequest(res.t('planNotActive', { nextBillingDate })); - } - - await paypalBillingAgreementCancel(customerId, { note: res.t('cancelingSubscription') }); - await payments.cancelSubscription({ - user, - paymentMethod: 'Paypal', - nextBill: nextBillingDate, - }); - - res.redirect('/'); - }, -}; - -// General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their -// recurring paypal payments in their paypal dashboard. TODO ? Remove this when we can move to webhooks or some other solution - -/** - * @apiIgnore Payments are considered part of the private API - * @api {post} /paypal/ipn Paypal IPN - * @apiVersion 3.0.0 - * @apiName PaypalIpn - * @apiGroup Payments - **/ -api.ipn = { - method: 'POST', - url: '/paypal/ipn', - async handler (req, res) { - res.sendStatus(200); - - await ipnVerifyAsync(req.body); - - if (req.body.txn_type === 'recurring_payment_profile_cancel' || req.body.txn_type === 'subscr_cancel') { - let user = await User.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id }); - if (user) { - await payments.cancelSubscription({ user, paymentMethod: 'Paypal' }); - } - } - }, -}; - -module.exports = api; diff --git a/website/server/controllers/top-level/payments/stripe.js b/website/server/controllers/top-level/payments/stripe.js deleted file mode 100644 index 2ac8c863f7..0000000000 --- a/website/server/controllers/top-level/payments/stripe.js +++ /dev/null @@ -1,169 +0,0 @@ -import stripeModule from 'stripe'; -import shared from '../../../../../common'; -import { - BadRequest, - NotAuthorized, -} from '../../../libs/api-v3/errors'; -import { model as Coupon } from '../../../models/coupon'; -import payments from '../../../libs/api-v3/payments'; -import nconf from 'nconf'; -import { model as User } from '../../../models/user'; -import cc from 'coupon-code'; -import { - authWithHeaders, - authWithUrl, -} from '../../../middlewares/api-v3/auth'; - -const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); - -let api = {}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {post} /stripe/checkout Stripe checkout - * @apiVersion 3.0.0 - * @apiName StripeCheckout - * @apiGroup Payments - * - * @apiParam {string} id Body parameter - The token - * @apiParam {string} email Body parameter - the customer email - * @apiParam {string} gift Query parameter - stringified json object, gift - * @apiParam {string} sub Query parameter - subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo - * @apiParam {string} coupon Query parameter - coupon for the matching subscription, required only for certain subscriptions - * - * @apiSuccess {Object} data Empty object - **/ -api.checkout = { - method: 'POST', - url: '/stripe/checkout', - middlewares: [authWithHeaders()], - async handler (req, res) { - let token = req.body.id; - let user = res.locals.user; - let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; - let coupon; - let response; - - if (!token) throw new BadRequest('Missing req.body.id'); - - if (sub) { - if (sub.discount) { - if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired')); - coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key}); - if (!coupon) throw new BadRequest(res.t('invalidCoupon')); - } - - response = await stripe.customers.create({ - email: req.body.email, - metadata: { uuid: user._id }, - card: token, - plan: sub.key, - }); - } else { - let amount = 500; // $5 - - if (gift) { - if (gift.type === 'subscription') { - amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; - } else { - amount = `${gift.gems.amount / 4 * 100}`; - } - } - - response = await stripe.charges.create({ - amount, - currency: 'usd', - card: token, - }); - } - - if (sub) { - await payments.createSubscription({ - user, - customerId: response.id, - paymentMethod: 'Stripe', - sub, - }); - } else { - let method = 'buyGems'; - let data = { - user, - customerId: response.id, - paymentMethod: 'Stripe', - gift, - }; - - if (gift) { - let member = await User.findById(gift.uuid); - gift.member = member; - if (gift.type === 'subscription') method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - - await payments[method](data); - } - - res.respond(200, {}); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {post} /stripe/subscribe/edit Edit Stripe subscription - * @apiVersion 3.0.0 - * @apiName StripeSubscribeEdit - * @apiGroup Payments - * - * @apiParam {string} id Body parameter - The token - * - * @apiSuccess {Object} data Empty object - **/ -api.subscribeEdit = { - method: 'POST', - url: '/stripe/subscribe/edit', - middlewares: [authWithHeaders()], - async handler (req, res) { - let token = req.body.id; - let user = res.locals.user; - let customerId = user.purchased.plan.customerId; - - if (!customerId) throw new NotAuthorized(res.t('missingSubscription')); - if (!token) throw new BadRequest('Missing req.body.id'); - - let subscriptions = await stripe.customers.listSubscriptions(customerId); - let subscriptionId = subscriptions.data[0].id; - await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); - - res.respond(200, {}); - }, -}; - -/** - * @apiIgnore Payments are considered part of the private API - * @api {get} /stripe/subscribe/cancel Cancel Stripe subscription - * @apiVersion 3.0.0 - * @apiName StripeSubscribeCancel - * @apiGroup Payments - **/ -api.subscribeCancel = { - method: 'GET', - url: '/stripe/subscribe/cancel', - middlewares: [authWithUrl], - async handler (req, res) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription')); - - let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId); - await stripe.customers.del(user.purchased.plan.customerId); - await payments.cancelSubscriptoin({ - user, - nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds - paymentMethod: 'Stripe', - }); - - res.redirect('/'); - }, -}; - -module.exports = api; diff --git a/website/server/index.js b/website/server/index.js deleted file mode 100644 index ec8e882c99..0000000000 --- a/website/server/index.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict'; -/* eslint-disable global-require, no-process-env */ - -// Register babel hook so we can write the real entry file (server.js) in ES6 -// In production, the es6 code is pre-transpiled so it doesn't need it -if (process.env.NODE_ENV !== 'production') { - require('babel-register'); -} - -// The BabelJS polyfill is needed in production too -require('babel-polyfill'); - -// Setup Bluebird as the global promise library -global.Promise = require('bluebird'); - -// Initialize configuration BEFORE anything -const setupNconf = require('./libs/api-v3/setupNconf'); -setupNconf(); - -const nconf = require('nconf'); - -const cluster = require('cluster'); -const logger = require('./libs/api-v3/logger'); - -const IS_PROD = nconf.get('IS_PROD'); -const IS_DEV = nconf.get('IS_DEV'); -const CORES = Number(nconf.get('WEB_CONCURRENCY')) || 0; - -// Initialize New Relic -if (IS_PROD && nconf.get('NEW_RELIC_ENABLED') === 'true') require('newrelic'); - -// Setup the cluster module -if (CORES !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) { - // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) - for (let i = 0; i < CORES; i += 1) { - cluster.fork(); - } - - cluster.on('disconnect', function onWorkerDisconnect (worker) { - let w = cluster.fork(); // replace the dead worker - - logger.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid); - }); -} else { - module.exports = require('./server.js'); -} diff --git a/website/server/libs/api-v3/amazonPayments.js b/website/server/libs/api-v3/amazonPayments.js deleted file mode 100644 index 4d3a3756b8..0000000000 --- a/website/server/libs/api-v3/amazonPayments.js +++ /dev/null @@ -1,62 +0,0 @@ -import amazonPayments from 'amazon-payments'; -import nconf from 'nconf'; -import common from '../../../../common'; -import Bluebird from 'bluebird'; -import { - BadRequest, -} from './errors'; - -// TODO better handling of errors - -const i18n = common.i18n; -const IS_PROD = nconf.get('NODE_ENV') === 'production'; - -let amzPayment = amazonPayments.connect({ - environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], - sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), - mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), - mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), - clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), -}); - -let getTokenInfo = Bluebird.promisify(amzPayment.api.getTokenInfo, {context: amzPayment.api}); -let createOrderReferenceId = Bluebird.promisify(amzPayment.offAmazonPayments.createOrderReferenceForId, {context: amzPayment.offAmazonPayments}); -let setOrderReferenceDetails = Bluebird.promisify(amzPayment.offAmazonPayments.setOrderReferenceDetails, {context: amzPayment.offAmazonPayments}); -let confirmOrderReference = Bluebird.promisify(amzPayment.offAmazonPayments.confirmOrderReference, {context: amzPayment.offAmazonPayments}); -let closeOrderReference = Bluebird.promisify(amzPayment.offAmazonPayments.closeOrderReference, {context: amzPayment.offAmazonPayments}); -let setBillingAgreementDetails = Bluebird.promisify(amzPayment.offAmazonPayments.setBillingAgreementDetails, {context: amzPayment.offAmazonPayments}); -let confirmBillingAgreement = Bluebird.promisify(amzPayment.offAmazonPayments.confirmBillingAgreement, {context: amzPayment.offAmazonPayments}); -let closeBillingAgreement = Bluebird.promisify(amzPayment.offAmazonPayments.closeBillingAgreement, {context: amzPayment.offAmazonPayments}); - -let authorizeOnBillingAgreement = (inputSet) => { - return new Promise((resolve, reject) => { - amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => { - if (err) return reject(err); - if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful'))); - return resolve(response); - }); - }); -}; - -let authorize = (inputSet) => { - return new Promise((resolve, reject) => { - amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { - if (err) return reject(err); - if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful'))); - return resolve(response); - }); - }); -}; - -module.exports = { - getTokenInfo, - createOrderReferenceId, - setOrderReferenceDetails, - confirmOrderReference, - closeOrderReference, - confirmBillingAgreement, - setBillingAgreementDetails, - closeBillingAgreement, - authorizeOnBillingAgreement, - authorize, -}; diff --git a/website/server/libs/api-v3/analyticsService.js b/website/server/libs/api-v3/analyticsService.js deleted file mode 100644 index b810ac1858..0000000000 --- a/website/server/libs/api-v3/analyticsService.js +++ /dev/null @@ -1,237 +0,0 @@ -/* eslint-disable camelcase */ -import nconf from 'nconf'; -import Amplitude from 'amplitude'; -import Bluebird from 'bluebird'; -import googleAnalytics from 'universal-analytics'; -import { - each, - omit, -} from 'lodash'; -import { content as Content } from '../../../../common'; - -const AMPLIUDE_TOKEN = nconf.get('AMPLITUDE_KEY'); -const GA_TOKEN = nconf.get('GA_ID'); -const GA_POSSIBLE_LABELS = ['gaLabel', 'itemKey']; -const GA_POSSIBLE_VALUES = ['gaValue', 'gemCost', 'goldCost']; -const AMPLITUDE_PROPERTIES_TO_SCRUB = ['uuid', 'user', 'purchaseValue', 'gaLabel', 'gaValue']; - -let amplitude = new Amplitude(AMPLIUDE_TOKEN); -let ga = googleAnalytics(GA_TOKEN); - -let _lookUpItemName = (itemKey) => { - if (!itemKey) return; - - let gear = Content.gear.flat[itemKey]; - let egg = Content.eggs[itemKey]; - let food = Content.food[itemKey]; - let hatchingPotion = Content.hatchingPotions[itemKey]; - let quest = Content.quests[itemKey]; - let spell = Content.special[itemKey]; - - let itemName; - - if (gear) { - itemName = gear.text(); - } else if (egg) { - itemName = `${egg.text()} Egg`; - } else if (food) { - itemName = food.text(); - } else if (hatchingPotion) { - itemName = `${hatchingPotion.text()} Hatching Potion`; - } else if (quest) { - itemName = quest.text(); - } else if (spell) { - itemName = spell.text(); - } - - return itemName; -}; - -let _formatUserData = (user) => { - let properties = {}; - - if (user.stats) { - properties.Class = user.stats.class; - properties.Experience = Math.floor(user.stats.exp); - properties.Gold = Math.floor(user.stats.gp); - properties.Health = Math.ceil(user.stats.hp); - properties.Level = user.stats.lvl; - properties.Mana = Math.floor(user.stats.mp); - } - - properties.tutorialComplete = user.flags && user.flags.tour && user.flags.tour.intro === -2; - - if (user.habits && user.dailys && user.todos && user.rewards) { - properties['Number Of Tasks'] = { - habits: user.habits.length, - dailys: user.dailys.length, - todos: user.todos.length, - rewards: user.rewards.length, - }; - } - - if (user.contributor && user.contributor.level) { - properties.contributorLevel = user.contributor.level; - } - - if (user.purchased && user.purchased.plan.planId) { - properties.subscription = user.purchased.plan.planId; - } - - return properties; -}; - - -let _formatDataForAmplitude = (data) => { - let event_properties = omit(data, AMPLITUDE_PROPERTIES_TO_SCRUB); - - let ampData = { - user_id: data.uuid || 'no-user-id-was-provided', - platform: 'server', - event_properties, - }; - - if (data.user) { - ampData.user_properties = _formatUserData(data.user); - } - - let itemName = _lookUpItemName(data.itemKey); - - if (itemName) { - event_properties.itemName = itemName; - } - - return ampData; -}; - -let _sendDataToAmplitude = (eventType, data) => { - let amplitudeData = _formatDataForAmplitude(data); - - amplitudeData.event_type = eventType; - - return new Bluebird((resolve, reject) => { - amplitude.track(amplitudeData) - .then(resolve) - .catch(reject); - }); -}; - -let _generateLabelForGoogleAnalytics = (data) => { - let label; - - each(GA_POSSIBLE_LABELS, (key) => { - if (data[key]) { - label = data[key]; - return false; // exit each early - } - }); - - return label; -}; - -let _generateValueForGoogleAnalytics = (data) => { - let value; - - each(GA_POSSIBLE_VALUES, (key) => { - if (data[key]) { - value = data[key]; - return false; // exit each early - } - }); - - return value; -}; - -let _sendDataToGoogle = (eventType, data) => { - let eventData = { - ec: data.category, - ea: eventType, - }; - - let label = _generateLabelForGoogleAnalytics(data); - - if (label) { - eventData.el = label; - } - - let value = _generateValueForGoogleAnalytics(data); - - if (value) { - eventData.ev = value; - } - - return new Bluebird((resolve, reject) => { - ga.event(eventData, (err) => { - if (err) return reject(err); - resolve(); - }); - }); -}; - -let _sendPurchaseDataToAmplitude = (data) => { - let amplitudeData = _formatDataForAmplitude(data); - - amplitudeData.event_type = 'purchase'; - amplitudeData.revenue = data.purchaseValue; - - return new Bluebird((resolve, reject) => { - amplitude.track(amplitudeData) - .then(resolve) - .catch(reject); - }); -}; - -let _sendPurchaseDataToGoogle = (data) => { - let label = data.paymentMethod; - let type = data.purchaseType; - let price = data.purchaseValue; - let qty = data.quantity; - let sku = data.sku; - let itemKey = data.itemPurchased; - let variation = type; - - if (data.gift) variation += ' - Gift'; - - let eventData = { - ec: 'commerce', - ea: type, - el: label, - ev: price, - }; - - return new Bluebird((resolve) => { - ga.event(eventData).send(); - - ga.transaction(data.uuid, price) - .item(price, qty, sku, itemKey, variation) - .send(); - - resolve(); - }); -}; - -function track (eventType, data) { - return Bluebird.all([ - _sendDataToAmplitude(eventType, data), - _sendDataToGoogle(eventType, data), - ]); -} - -function trackPurchase (data) { - return Bluebird.all([ - _sendPurchaseDataToAmplitude(data), - _sendPurchaseDataToGoogle(data), - ]); -} - -// Stub for non-prod environments -let mockAnalyticsService = { - track: () => { }, - trackPurchase: () => { }, -}; - -module.exports = { - track, - trackPurchase, - mockAnalyticsService, -}; diff --git a/website/server/libs/api-v3/baseModel.js b/website/server/libs/api-v3/baseModel.js deleted file mode 100644 index 009b735fa6..0000000000 --- a/website/server/libs/api-v3/baseModel.js +++ /dev/null @@ -1,79 +0,0 @@ -import { v4 as uuid } from 'uuid'; -import validator from 'validator'; -import objectPath from 'object-path'; // TODO use lodash's unset once v4 is out -import _ from 'lodash'; - -module.exports = function baseModel (schema, options = {}) { - if (options._id !== false) { - schema.add({ - _id: { - type: String, - default: uuid, - validate: [validator.isUUID, 'Invalid uuid.'], - }, - }); - } - - if (options.timestamps) { - schema.add({ - createdAt: { - type: Date, - default: Date.now, - }, - updatedAt: { - type: Date, - default: Date.now, - }, - }); - } - - if (options.timestamps) { - schema.pre('save', function updateUpdatedAt (next) { - if (!this.isNew) this.updatedAt = Date.now(); - next(); - }); - - schema.pre('update', function preUpdateModel () { - this.update({}, { $set: { updatedAt: new Date() } }); - }); - } - - let noSetFields = ['createdAt', 'updatedAt']; - let privateFields = ['__v']; - - if (Array.isArray(options.noSet)) noSetFields.push(...options.noSet); - // This method accepts an additional array of fields to be sanitized that can be passed at runtime - schema.statics.sanitize = function sanitize (objToSanitize = {}, additionalFields = []) { - noSetFields.concat(additionalFields).forEach((fieldPath) => { - objectPath.del(objToSanitize, fieldPath); - }); - - // Allow a sanitize transform function to be used - return options.sanitizeTransform ? options.sanitizeTransform(objToSanitize) : objToSanitize; - }; - - if (Array.isArray(options.private)) privateFields.push(...options.private); - - if (!schema.options.toJSON) schema.options.toJSON = {}; - schema.options.toJSON.transform = function transformToObject (doc, plainObj) { - privateFields.forEach((fieldPath) => { - objectPath.del(plainObj, fieldPath); - }); - - // Always return `id` - if (!plainObj.id && plainObj._id) plainObj.id = plainObj._id; - - // Allow an additional toJSON transform function to be used - return options.toJSONTransform ? options.toJSONTransform(plainObj, doc) : plainObj; - }; - - schema.statics.getModelPaths = function getModelPaths () { - return _.reduce(this.schema.paths, (result, field, path) => { - if (privateFields.indexOf(path) === -1) { - result[path] = field.instance || 'Boolean'; - } - - return result; - }, {}); - }; -}; diff --git a/website/server/libs/api-v3/buildManifest.js b/website/server/libs/api-v3/buildManifest.js deleted file mode 100644 index 55db474354..0000000000 --- a/website/server/libs/api-v3/buildManifest.js +++ /dev/null @@ -1,62 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import nconf from 'nconf'; - -const MANIFEST_FILE_PATH = path.join(__dirname, '/../../../client/manifest.json'); -const BUILD_FOLDER_PATH = path.join(__dirname, '/../../../build'); -let manifestFiles = require(MANIFEST_FILE_PATH); - -const IS_PROD = nconf.get('IS_PROD'); -let buildFiles = []; - -function _walk (folder) { - let files = fs.readdirSync(folder); - - files.forEach((fileName) => { - let file = `${folder}/${fileName}`; - - if (fs.statSync(file).isDirectory()) { - _walk(file); - } else { - let relFolder = path.relative(BUILD_FOLDER_PATH, folder); - let original = fileName.replace(/-.{8}(\.[\d\w]+)$/, '$1'); // Match the hash part of the filename - - if (relFolder) { - original = `${relFolder}/${original}`; - fileName = `${relFolder}/${fileName}`; - } - - buildFiles[original] = fileName; - } - }); -} - -// Walks through all the files in the build directory -// and creates a map of original files names and hashed files names -_walk(BUILD_FOLDER_PATH); - -export function getBuildUrl (url) { - return `/${buildFiles[url] || url}`; -} - -export function getManifestFiles (page) { - let files = manifestFiles[page]; - - if (!files) throw new Error(`Page "${page}" not found!`); - - let htmlCode = ''; - - if (IS_PROD) { - htmlCode += ``; // eslint-disable-line prefer-template - htmlCode += ``; // eslint-disable-line prefer-template - } else { - files.css.forEach((file) => { - htmlCode += ``; - }); - files.js.forEach((file) => { - htmlCode += ``; - }); - } - - return htmlCode; -} diff --git a/website/server/libs/api-v3/collectionManipulators.js b/website/server/libs/api-v3/collectionManipulators.js deleted file mode 100644 index 95d3981601..0000000000 --- a/website/server/libs/api-v3/collectionManipulators.js +++ /dev/null @@ -1,22 +0,0 @@ -import { - findIndex, - isPlainObject, -} from 'lodash'; - -export function removeFromArray (array, element) { - let elementIndex; - - if (isPlainObject(element)) { - elementIndex = findIndex(array, element); - } else { - elementIndex = array.indexOf(element); - } - - if (elementIndex !== -1) { - let removedElement = array[elementIndex]; - array.splice(elementIndex, 1); - return removedElement; - } - - return false; -} diff --git a/website/server/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js deleted file mode 100644 index da076b54ce..0000000000 --- a/website/server/libs/api-v3/cron.js +++ /dev/null @@ -1,280 +0,0 @@ -import moment from 'moment'; -import common from '../../../../common/'; -import { preenUserHistory } from '../../libs/api-v3/preening'; -import _ from 'lodash'; -import nconf from 'nconf'; - -const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true'; -const shouldDo = common.shouldDo; -const scoreTask = common.ops.scoreTask; -// const maxPMs = 200; - -let CLEAR_BUFFS = { - str: 0, - int: 0, - per: 0, - con: 0, - stealth: 0, - streaks: false, -}; - -function grantEndOfTheMonthPerks (user, now) { - let plan = user.purchased.plan; - - if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { - plan.gemsBought = 0; // reset gem-cap - plan.dateUpdated = now; - // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks - // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 - // TODO use month diff instead of ++ / --? see https://github.com/HabitRPG/habitrpg/issues/4317 - _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); - - plan.consecutive.count++; - - if (plan.consecutive.offset > 0) { - plan.consecutive.offset--; - } else if (plan.consecutive.count % 3 === 0) { // every 3 months - plan.consecutive.trinkets++; - plan.consecutive.gemCapExtra += 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) - } - } -} - -function removeTerminatedSubscription (user) { - // If subscription's termination date has arrived - let plan = user.purchased.plan; - - if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { - _.merge(plan, { - planId: null, - customerId: null, - paymentMethod: null, - }); - - _.merge(plan.consecutive, { - count: 0, - offset: 0, - gemCapExtra: 0, - }); - - user.markModified('purchased.plan'); - } -} - -function performSleepTasks (user, tasksByType, now) { - user.stats.buffs = _.cloneDeep(CLEAR_BUFFS); - - tasksByType.dailys.forEach((daily) => { - let completed = daily.completed; - let thatDay = moment(now).subtract({days: 1}); - - if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { - // TODO also untick checklists if the Daily was due on previous missed days, if two or more days were missed at once -- https://github.com/HabitRPG/habitrpg/pull/7218#issuecomment-219256016 - daily.checklist.forEach(box => box.completed = false); - } - - daily.completed = false; - }); -} - -// Perform various beginning-of-day reset actions. -export function cron (options = {}) { - let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options; - - user.auth.timestamps.loggedin = now; - user.lastCron = now; - user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; - // User is only allowed a certain number of drops a day. This resets the count. - if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; - - // "Perfect Day" achievement for perfect-days - let perfect = true; - - if (user.isSubscribed()) { - grantEndOfTheMonthPerks(user, now); - if (!CRON_SAFE_MODE) removeTerminatedSubscription(user); - } - - // User is resting at the inn. - // On cron, buffs are cleared and all dailies are reset without performing damage - if (user.preferences.sleep === true) { - performSleepTasks(user, tasksByType, now); - return; - } - - let multiDaysCountAsOneDay = true; - // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. - // When site-wide difficulty settings are introduced, this can be a user preference option. - - // Tally each task - let todoTally = 0; - - tasksByType.todos.forEach(task => { // make uncompleted To-Dos redder (further incentive to complete them) - scoreTask({ - task, - user, - direction: 'down', - cron: true, - times: multiDaysCountAsOneDay ? 1 : daysMissed, - }); - - todoTally += task.value; - }); - - // For incomplete Dailys, add value (further incentive), deduct health, keep records for later decreasing the nightly mana gain - let dailyChecked = 0; // how many dailies were checked? - let dailyDueUnchecked = 0; // how many dailies were un-checked? - if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; - - tasksByType.dailys.forEach((task) => { - let completed = task.completed; - // Deduct points for missed Daily tasks - let EvadeTask = 0; - let scheduleMisses = daysMissed; - - if (completed) { - dailyChecked += 1; - } else { - // dailys repeat, so need to calculate how many they've missed according to their own schedule - scheduleMisses = 0; - - for (let i = 0; i < daysMissed; i++) { - let thatDay = moment(now).subtract({days: i + 1}); - - if (shouldDo(thatDay.toDate(), task, user.preferences)) { - scheduleMisses++; - if (user.stats.buffs.stealth) { - user.stats.buffs.stealth--; - EvadeTask++; - } - if (multiDaysCountAsOneDay) break; - } - } - - if (scheduleMisses > EvadeTask) { - // The user did not complete this due Daily (but no penalty if cron is running in safe mode). - if (CRON_SAFE_MODE) { - dailyChecked += 1; // allows full allotment of mp to be gained - } else { - perfect = false; - - if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points - let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; - dailyDueUnchecked += 1 - fractionChecked; - dailyChecked += fractionChecked; - } else { - dailyDueUnchecked += 1; - } - - let delta = scoreTask({ - user, - task, - direction: 'down', - times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, - cron: true, - }); - - // Apply damage from a boss, less damage for Trivial priority (difficulty) - user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); - // NB: Medium and Hard priorities do not increase damage from boss. This was by accident - // initially, and when we realised, we could not fix it because users are used to - // their Medium and Hard Dailies doing an Easy amount of damage from boss. - // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future - // setting between Trivial and Easy. - } - } - } - - task.history.push({ - date: Number(new Date()), - value: task.value, - }); - task.completed = false; - - if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = false); - } - }); - - // move singleton Habits towards yellow. - tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 - if (task.up === false || task.down === false) { - task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; - } - }); - - // Finished tallying - user.history.todos.push({date: now, value: todoTally}); - - // tally experience - let expTally = user.stats.exp; - let lvl = 0; // iterator - while (lvl < user.stats.lvl - 1) { - lvl++; - expTally += common.tnl(lvl); - } - - user.history.exp.push({date: now, value: expTally}); - - // preen user history so that it doesn't become a performance problem - // also for subscribed users but differently - // TODO also do while resting in the inn. Note that later we'll be allowing the value/color of tasks to change while sleeping (https://github.com/HabitRPG/habitrpg/issues/5232), so the code in performSleepTasks() might be best merged back into here for that. Perhaps wait until then to do preen history for sleeping users. - preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); - - if (perfect) { - user.achievements.perfect++; - let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); - user.stats.buffs = { - str: lvlDiv2, - int: lvlDiv2, - per: lvlDiv2, - con: lvlDiv2, - stealth: 0, - streaks: false, - }; - } else { - user.stats.buffs = _.cloneDeep(CLEAR_BUFFS); - } - - // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit - // Adjust for fraction of dailies completed - if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; - - // After all is said and done, progress up user's effect on quest, return those values & reset the user's - let progress = user.party.quest.progress; - let _progress = _.cloneDeep(progress); - _.merge(progress, {down: 0, up: 0}); - progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); - - // TODO: Clean PMs - keep 200 for subscribers and 50 for free users. Should also be done while resting in the inn - // let numberOfPMs = Object.keys(user.inbox.messages).length; - // if (numberOfPMs > maxPMs) { - // _(user.inbox.messages) - // .sortBy('timestamp') - // .takeRight(numberOfPMs - maxPMs) - // .each(pm => { - // delete user.inbox.messages[pm.id]; - // }).value(); - // - // user.markModified('inbox.messages'); - // } - - // Analytics - user.flags.cronCount++; - analytics.track('Cron', { // TODO also do while resting in the inn. https://github.com/HabitRPG/habitrpg/issues/7161#issuecomment-218214191 - category: 'behavior', - gaLabel: 'Cron Count', - gaValue: user.flags.cronCount, - uuid: user._id, - user, - resting: user.preferences.sleep, - cronCount: user.flags.cronCount, - progressUp: _.min([_progress.up, 900]), - progressDown: _progress.down, - }); - - return _progress; -} diff --git a/website/server/libs/api-v3/csvStringify.js b/website/server/libs/api-v3/csvStringify.js deleted file mode 100644 index 39fb7c16c8..0000000000 --- a/website/server/libs/api-v3/csvStringify.js +++ /dev/null @@ -1,11 +0,0 @@ -import csvStringify from 'csv-stringify'; -import Bluebird from 'bluebird'; - -module.exports = (input) => { - return new Bluebird((resolve, reject) => { - csvStringify(input, (err, output) => { - if (err) return reject(err); - return resolve(output); - }); - }); -}; diff --git a/website/server/libs/api-v3/email.js b/website/server/libs/api-v3/email.js deleted file mode 100644 index fd2e166098..0000000000 --- a/website/server/libs/api-v3/email.js +++ /dev/null @@ -1,156 +0,0 @@ -import { createTransport } from 'nodemailer'; -import nconf from 'nconf'; -import { encrypt } from './encryption'; -import request from 'request'; -import logger from './logger'; - -const IS_PROD = nconf.get('IS_PROD'); -const EMAIL_SERVER = { - url: nconf.get('EMAIL_SERVER:url'), - auth: { - user: nconf.get('EMAIL_SERVER:authUser'), - password: nconf.get('EMAIL_SERVER:authPassword'), - }, -}; -const BASE_URL = nconf.get('BASE_URL'); - -let smtpTransporter = createTransport({ - service: nconf.get('SMTP_SERVICE'), - auth: { - user: nconf.get('SMTP_USER'), - pass: nconf.get('SMTP_PASS'), - }, -}); - -// Send email directly from the server using the smtpTransporter, -// used only to send password reset emails because users unsubscribed on Mandrill wouldn't get them -export function send (mailData) { - return smtpTransporter.sendMail(mailData); // promise -} - -export function getUserInfo (user, fields = []) { - let info = {}; - - if (fields.indexOf('name') !== -1) { - info.name = user.profile && user.profile.name; - - if (!info.name) { - if (user.auth.local && user.auth.local.username) { - info.name = user.auth.local.username; - } else if (user.auth.facebook) { - info.name = user.auth.facebook.displayName || user.auth.facebook.username; - } - } - } - - if (fields.indexOf('email') !== -1) { - if (user.auth.local && user.auth.local.email) { - info.email = user.auth.local.email; - } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value) { - info.email = user.auth.facebook.emails[0].value; - } - } - - if (fields.indexOf('_id') !== -1) { - info._id = user._id; - } - - if (fields.indexOf('canSend') !== -1) { - if (user.preferences && user.preferences.emailNotifications) { - info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true; - } - } - - return info; -} - -// Send a transactional email using Mandrill through the external email server -export function sendTxn (mailingInfoArray, emailType, variables, personalVariables) { - mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray]; - - variables = [ - {name: 'BASE_URL', content: BASE_URL}, - ].concat(variables || []); - - // It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed - mailingInfoArray = mailingInfoArray.map((mailingInfo) => { - return mailingInfo._id ? getUserInfo(mailingInfo, ['_id', 'email', 'name', 'canSend']) : mailingInfo; - }).filter((mailingInfo) => { - // Always send reset-password emails - // Don't check canSend for non registered users as already checked before - return mailingInfo.email && (!mailingInfo._id || mailingInfo.canSend || emailType === 'reset-password'); - }); - - // Personal variables are personal to each email recipient, if they are missing - // we manually create a structure for them with RECIPIENT_NAME and RECIPIENT_UNSUB_URL - // otherwise we just add RECIPIENT_NAME and RECIPIENT_UNSUB_URL to the existing personal variables - if (!personalVariables || personalVariables.length === 0) { - personalVariables = mailingInfoArray.map((mailingInfo) => { - return { - rcpt: mailingInfo.email, - vars: [ - { - name: 'RECIPIENT_NAME', - content: mailingInfo.name, - }, - { - name: 'RECIPIENT_UNSUB_URL', - content: `/email/unsubscribe?code=${encrypt(JSON.stringify({ - _id: mailingInfo._id, - email: mailingInfo.email, - }))}`, - }, - ], - }; - }); - } else { - let temporaryPersonalVariables = {}; - - mailingInfoArray.forEach((mailingInfo) => { - temporaryPersonalVariables[mailingInfo.email] = { - name: mailingInfo.name, - _id: mailingInfo._id, - }; - }); - - personalVariables.forEach((singlePersonalVariables) => { - singlePersonalVariables.vars.push( - { - name: 'RECIPIENT_NAME', - content: temporaryPersonalVariables[singlePersonalVariables.rcpt].name, - }, - { - name: 'RECIPIENT_UNSUB_URL', - content: `/email/unsubscribe?code=${encrypt(JSON.stringify({ - _id: temporaryPersonalVariables[singlePersonalVariables.rcpt]._id, - email: singlePersonalVariables.rcpt, - }))}`, - } - ); - }); - } - - if (IS_PROD && mailingInfoArray.length > 0) { - request.post({ - url: `${EMAIL_SERVER.url}/job`, - auth: { - user: EMAIL_SERVER.auth.user, - pass: EMAIL_SERVER.auth.password, - }, - json: { - type: 'email', - data: { - emailType, - to: mailingInfoArray, - variables, - personalVariables, - }, - options: { - priority: 'high', - attempts: 5, - backoff: {delay: 10 * 60 * 1000, type: 'fixed'}, - }, - }, - }, (err) => logger.error(err)); - } -} diff --git a/website/server/libs/api-v3/encryption.js b/website/server/libs/api-v3/encryption.js deleted file mode 100644 index 390c59234e..0000000000 --- a/website/server/libs/api-v3/encryption.js +++ /dev/null @@ -1,24 +0,0 @@ -import { - createCipher, - createDecipher, -} from 'crypto'; -import nconf from 'nconf'; - -const algorithm = 'aes-256-ctr'; -const SESSION_SECRET = nconf.get('SESSION_SECRET'); - -export function encrypt (text) { - let cipher = createCipher(algorithm, SESSION_SECRET); - let crypted = cipher.update(text, 'utf8', 'hex'); - - crypted += cipher.final('hex'); - return crypted; -} - -export function decrypt (text) { - let decipher = createDecipher(algorithm, SESSION_SECRET); - let dec = decipher.update(text, 'hex', 'utf8'); - - dec += decipher.final('utf8'); - return dec; -} diff --git a/website/server/libs/api-v3/errors.js b/website/server/libs/api-v3/errors.js deleted file mode 100644 index 2b6d52bbe3..0000000000 --- a/website/server/libs/api-v3/errors.js +++ /dev/null @@ -1,62 +0,0 @@ -import common from '../../../../common'; - -export const CustomError = common.errors.CustomError; - -/** - * @apiDefine NotAuthorized - * @apiError NotAuthorized The client is not authorized to make this request. - * - * @apiErrorExample Error-Response: - * HTTP/1.1 401 Unauthorized - * { - * "error": "NotAuthorized", - * "message": "Not authorized." - * } - */ -export const NotAuthorized = common.errors.NotAuthorized; - -/** - * @apiDefine BadRequest - * @apiError BadRequest The request wasn't formatted correctly. - * - * @apiErrorExample Error-Response: - * HTTP/1.1 400 Bad Request - * { - * "error": "BadRequest", - * "message": "Bad request." - * } - */ -export const BadRequest = common.errors.BadRequest; - -/** - * @apiDefine NotFound - * @apiError NotFound The requested resource was not found. - * - * @apiErrorExample Error-Response: - * HTTP/1.1 404 Not Found - * { - * "error": "NotFound", - * "message": "Not found." - * } - */ -export const NotFound = common.errors.NotFound; - -/** - * @apiDefine InternalServerError - * @apiError InternalServerError An unexpected error occurred. - * - * @apiErrorExample Error-Response: - * HTTP/1.1 500 Internal Server Error - * { - * "error": "InternalServerError", - * "message": "An unexpected error occurred." - * } - */ -export class InternalServerError extends CustomError { - constructor (customMessage) { - super(); - this.name = this.constructor.name; - this.httpCode = 500; - this.message = customMessage || 'An unexpected error occurred.'; - } -} diff --git a/website/server/libs/api-v3/firebase.js b/website/server/libs/api-v3/firebase.js deleted file mode 100644 index 324183e85f..0000000000 --- a/website/server/libs/api-v3/firebase.js +++ /dev/null @@ -1,69 +0,0 @@ -import Firebase from 'firebase'; -import nconf from 'nconf'; -import { TAVERN_ID } from '../../models/group'; - -const FIREBASE_CONFIG = nconf.get('FIREBASE'); -const FIREBASE_ENABLED = FIREBASE_CONFIG.ENABLED === 'true'; - -let firebaseRef; - -if (FIREBASE_ENABLED) { - firebaseRef = new Firebase(`https://${FIREBASE_CONFIG.APP}.firebaseio.com`); - - // TODO what happens if an op is sent before client is authenticated? - firebaseRef.authWithCustomToken(FIREBASE_CONFIG.SECRET, (err) => { - // TODO it's ok to kill the server here? what if FB is offline? - if (err) throw new Error('Impossible to authenticate Firebase'); - }); -} - -export function updateGroupData (group) { - if (!FIREBASE_ENABLED) return; - // TODO is throw ok? we don't have callbacks - if (!group) throw new Error('group obj is required.'); - // Return in case of tavern (comparison working because we use string for _id) - if (group._id === TAVERN_ID) return; - - firebaseRef.child(`rooms/${group._id}`) - .set({ - name: group.name, - }); -} - -export function addUserToGroup (groupId, userId) { - if (!FIREBASE_ENABLED) return; - if (!userId || !groupId) throw new Error('groupId, userId are required.'); - if (groupId === TAVERN_ID) return; - - firebaseRef.child(`members/${groupId}/${userId}`).set(true); - firebaseRef.child(`users/${userId}/rooms/${groupId}`).set(true); -} - -export function removeUserFromGroup (groupId, userId) { - if (!FIREBASE_ENABLED) return; - if (!userId || !groupId) throw new Error('groupId, userId are required.'); - if (groupId === TAVERN_ID) return; - - firebaseRef.child(`members/${groupId}/${userId}`).remove(); - firebaseRef.child(`users/${userId}/rooms/${groupId}`).remove(); -} - -export function deleteGroup (groupId) { - if (!FIREBASE_ENABLED) return; - if (!groupId) throw new Error('groupId is required.'); - if (groupId === TAVERN_ID) return; - - firebaseRef.child(`members/${groupId}`).remove(); - // TODO not really necessary as long as we only store room data, - // as empty objects are automatically deleted (/members/... in future...) - firebaseRef.child(`rooms/${groupId}`).remove(); -} - -// TODO not really necessary as long as we only store room data, -// as empty objects are automatically deleted -export function deleteUser (userId) { - if (!FIREBASE_ENABLED) return; - if (!userId) throw new Error('userId is required.'); - - firebaseRef.child(`users/${userId}`).remove(); -} diff --git a/website/server/libs/api-v3/i18n.js b/website/server/libs/api-v3/i18n.js deleted file mode 100644 index 4c424ace39..0000000000 --- a/website/server/libs/api-v3/i18n.js +++ /dev/null @@ -1,105 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import _ from 'lodash'; -import shared from '../../../../common'; - -export const localePath = path.join(__dirname, '/../../../../common/locales/'); - -// Store translations -export let translations = {}; -// Store MomentJS localization files -export let momentLangs = {}; - -// Handle differencies in language codes between MomentJS and /locales -let momentLangsMapping = { - en: 'en-gb', - en_GB: 'en-gb', // eslint-disable-line camelcase - no: 'nn', - zh: 'zh-cn', - es_419: 'es', // eslint-disable-line camelcase -}; - -function _loadTranslations (locale) { - let files = fs.readdirSync(path.join(localePath, locale)); - - translations[locale] = {}; - - files.forEach((file) => { - if (path.extname(file) !== '.json') return; - - // We use require to load and parse a JSON file - _.merge(translations[locale], require(path.join(localePath, locale, file))); // eslint-disable-line global-require - }); -} - -// First fetch English strings so we can merge them with missing strings in other languages -_loadTranslations('en'); - -// Then load all other languages -fs.readdirSync(localePath).forEach((file) => { - if (file === 'en' || fs.statSync(path.join(localePath, file)).isDirectory() === false) return; - _loadTranslations(file); - - // Merge missing strings from english - _.defaults(translations[file], translations.en); -}); - -// Add translations to shared -shared.i18n.translations = translations; - -export let langCodes = Object.keys(translations); - -export let availableLanguages = langCodes.map((langCode) => { - return { - code: langCode, - name: translations[langCode].languageName, - }; -}); - -langCodes.forEach((code) => { - let lang = _.find(availableLanguages, {code}); - - lang.momentLangCode = momentLangsMapping[code] || code; - - try { - // MomentJS lang files are JS files that has to be executed in the browser so we load them as plain text files - // We wrap everything in a try catch because the file might not exist - let f = fs.readFileSync(path.join(__dirname, `/../../../node_modules/moment/locale/${lang.momentLangCode}.js`), 'utf8'); - - momentLangs[code] = f; - } catch (e) { // eslint-disable-lint no-empty - // The catch block is mandatory so it won't crash the server - } -}); - -// Remove en_GB from langCodes checked by browser to avoid it being -// used in place of plain original 'en' (it's an optional language that can be enabled only in setting) -export let defaultLangCodes = _.without(langCodes, 'en_GB'); - -// A map of languages that have different versions and the relative versions -export let multipleVersionsLanguages = { - es: { - 'es-419': 'es_419', - 'es-mx': 'es_419', - 'es-gt': 'es_419', - 'es-cr': 'es_419', - 'es-pa': 'es_419', - 'es-do': 'es_419', - 'es-ve': 'es_419', - 'es-co': 'es_419', - 'es-pe': 'es_419', - 'es-ar': 'es_419', - 'es-ec': 'es_419', - 'es-cl': 'es_419', - 'es-uy': 'es_419', - 'es-py': 'es_419', - 'es-bo': 'es_419', - 'es-sv': 'es_419', - 'es-hn': 'es_419', - 'es-ni': 'es_419', - 'es-pr': 'es_419', - }, - zh: { - 'zh-tw': 'zh_TW', - }, -}; diff --git a/website/server/libs/api-v3/logger.js b/website/server/libs/api-v3/logger.js deleted file mode 100644 index ed1353e8ad..0000000000 --- a/website/server/libs/api-v3/logger.js +++ /dev/null @@ -1,60 +0,0 @@ -// Logger utility -import winston from 'winston'; -import nconf from 'nconf'; -import _ from 'lodash'; - -const IS_PROD = nconf.get('IS_PROD'); -const IS_TEST = nconf.get('IS_TEST'); -const ENABLE_CONSOLE_LOGS_IN_PROD = nconf.get('ENABLE_CONSOLE_LOGS_IN_PROD') === 'true'; - -const logger = new winston.Logger(); - -if (IS_PROD) { - if (ENABLE_CONSOLE_LOGS_IN_PROD) { - logger.add(winston.transports.Console, { - colorize: true, - prettyPrint: true, - }); - } -} else if (IS_TEST) { - // Do not log anything when testing -} else { - logger - .add(winston.transports.Console, { - colorize: true, - prettyPrint: true, - }); -} - -// exports a public interface insteaf of accessing directly the logger module -let loggerInterface = { - info (...args) { - logger.info(...args); - }, - - // Accepts two argument, - // an Error object (required) - // and an object of additional data to log alongside the error - // If the first argument isn't an Error, it'll call logger.error with all the arguments supplied - error (...args) { - let [err, errorData = {}, ...otherArgs] = args; - - if (err instanceof Error) { - // pass the error stack as the first parameter to logger.error - let stack = err.stack || err.message || err; - - if (_.isPlainObject(errorData) && !errorData.fullError) errorData.fullError = err; - logger.error(stack, errorData, ...otherArgs); - } else { - logger.error(...args); - } - }, -}; - -// Logs unhandled promises errors -// when no catch is attached to a promise a unhandledRejection event will be triggered -process.on('unhandledRejection', function handlePromiseRejection (reason) { - loggerInterface.error(reason); -}); - -module.exports = loggerInterface; diff --git a/website/server/libs/api-v3/password.js b/website/server/libs/api-v3/password.js deleted file mode 100644 index c825083936..0000000000 --- a/website/server/libs/api-v3/password.js +++ /dev/null @@ -1,18 +0,0 @@ -// Utilities for working with passwords -import crypto from 'crypto'; - -// Return the encrypted version of a password (using sha1) given a salt -export function encrypt (password, salt) { - return crypto - .createHmac('sha1', salt) - .update(password) - .digest('hex'); -} - -// Create a salt, default length is 10 -export function makeSalt (len = 10) { - return crypto - .randomBytes(Math.ceil(len / 2)) - .toString('hex') - .substring(0, len); -} \ No newline at end of file diff --git a/website/server/libs/api-v3/payments.js b/website/server/libs/api-v3/payments.js deleted file mode 100644 index 5a9c888972..0000000000 --- a/website/server/libs/api-v3/payments.js +++ /dev/null @@ -1,185 +0,0 @@ -import _ from 'lodash' ; -import analytics from './analyticsService'; -import { - getUserInfo, - sendTxn as txnEmail, -} from './email'; -import members from '../../controllers/api-v3/members'; -import moment from 'moment'; -import nconf from 'nconf'; -import pushNotify from './pushNotifications'; -import shared from '../../../../common' ; - -const IS_PROD = nconf.get('IS_PROD'); - -let api = {}; - -function revealMysteryItems (user) { - _.each(shared.content.gear.flat, function findMysteryItems (item) { - if ( - item.klass === 'mystery' && - moment().isAfter(shared.content.mystery[item.mystery].start) && - moment().isBefore(shared.content.mystery[item.mystery].end) && - !user.items.gear.owned[item.key] && - user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 - ) { - user.purchased.plan.mysteryItems.push(item.key); - } - }); -} - -api.createSubscription = async function createSubscription (data) { - let recipient = data.gift ? data.gift.member : data.user; - let plan = recipient.purchased.plan; - let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; - let months = Number(block.months); - - if (data.gift) { - if (plan.customerId && !plan.dateTerminated) { // User has active plan - plan.extraMonths += months; - } else { - plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); - if (!plan.dateUpdated) plan.dateUpdated = new Date(); - } - - if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId - } else { - _(plan).merge({ // override with these values - planId: block.key, - customerId: data.customerId, - dateUpdated: new Date(), - gemsBought: 0, - paymentMethod: data.paymentMethod, - extraMonths: Number(plan.extraMonths) + - Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0), - dateTerminated: null, - // Specify a lastBillingDate just for Amazon Payments - // Resetted every time the subscription restarts - lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, - }).defaults({ // allow non-override if a plan was previously used - dateCreated: new Date(), - mysteryItems: [], - }).value(); - } - - // Block sub perks - let perks = Math.floor(months / 3); - if (perks) { - plan.consecutive.offset += months; - plan.consecutive.gemCapExtra += perks * 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; - plan.consecutive.trinkets += perks; - } - - revealMysteryItems(recipient); - - if (IS_PROD) { - if (!data.gift) txnEmail(data.user, 'subscription-begins'); - - analytics.trackPurchase({ - uuid: data.user._id, - itemPurchased: 'Subscription', - sku: `${data.paymentMethod.toLowerCase()}-subscription`, - purchaseType: 'subscribe', - paymentMethod: data.paymentMethod, - quantity: 1, - gift: Boolean(data.gift), - purchaseValue: block.price, - }); - } - - data.user.purchased.txnCount++; - - if (data.gift) { - members.sendMessage(data.user, data.gift.member, data.gift); - - let byUserName = getUserInfo(data.user, ['name']).name; - - if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { - txnEmail(data.gift.member, 'gifted-subscription', [ - {name: 'GIFTER', content: byUserName}, - {name: 'X_MONTHS_SUBSCRIPTION', content: months}, - ]); - } - - if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); - } - } - - await data.user.save(); - if (data.gift) await data.gift.member.save(); -}; - -// Sets their subscription to be cancelled later -api.cancelSubscription = async function cancelSubscription (data) { - let plan = data.user.purchased.plan; - let now = moment(); - let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; - let nowStr = `${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`; - let nowStrFormat = 'MM/DD/YYYY'; - - plan.dateTerminated = - moment(nowStr, nowStrFormat) - .add({days: remaining}) // end their subscription 1mo from their last payment - .add({days: Math.ceil(30 * plan.extraMonths)}) // plus any extra time (carry-over, gifted subscription, etc) they have. - .toDate(); - plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated - - await data.user.save(); - - txnEmail(data.user, 'cancel-subscription'); - - analytics.track('unsubscribe', { - uuid: data.user._id, - gaCategory: 'commerce', - gaLabel: data.paymentMethod, - paymentMethod: data.paymentMethod, - }); -}; - -api.buyGems = async function buyGems (data) { - let amt = data.amount || 5; - amt = data.gift ? data.gift.gems.amount / 4 : amt; - - (data.gift ? data.gift.member : data.user).balance += amt; - data.user.purchased.txnCount++; - - if (IS_PROD) { - if (!data.gift) txnEmail(data.user, 'donation'); - - analytics.trackPurchase({ - uuid: data.user._id, - itemPurchased: 'Gems', - sku: `${data.paymentMethod.toLowerCase()}-checkout`, - purchaseType: 'checkout', - paymentMethod: data.paymentMethod, - quantity: 1, - gift: Boolean(data.gift), - purchaseValue: amt, - }); - } - - if (data.gift) { - let byUsername = getUserInfo(data.user, ['name']).name; - let gemAmount = data.gift.gems.amount || 20; - - members.sendMessage(data.user, data.gift.member, data.gift); - if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { - txnEmail(data.gift.member, 'gifted-gems', [ - {name: 'GIFTER', content: byUsername}, - {name: 'X_GEMS_GIFTED', content: gemAmount}, - ]); - } - - if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); - } - - await data.gift.member.save(); - } - - await data.user.save(); -}; - -module.exports = api; diff --git a/website/server/libs/api-v3/preening.js b/website/server/libs/api-v3/preening.js deleted file mode 100644 index 00be142299..0000000000 --- a/website/server/libs/api-v3/preening.js +++ /dev/null @@ -1,82 +0,0 @@ -import _ from 'lodash'; -import moment from 'moment'; - -// Aggregate entries -function _aggregate (history, aggregateBy) { - return _.chain(history) - .groupBy(entry => { // group entries by aggregateBy - return moment(entry.date).format(aggregateBy); - }) - .sortBy((entry, key) => key) // sort by date - .map(entries => { - return { - date: Number(entries[0].date), - value: _.reduce(entries, (previousValue, entry) => { - return previousValue + entry.value; - }, 0) / entries.length, - }; - }) - .value(); -} - -/* Preen an array of history entries -Free users: -- 1 value for each day of the past 60 days (no compression) -- 1 value each month for the previous 10 months -- 1 value each year for the previous years -Subscribers and challenges: -- 1 value for each day of the past 365 days (no compression) -- 1 value each month for the previous 12 months -- 1 value each year for the previous years - */ -export function preenHistory (history, isSubscribed, timezoneOffset) { - // history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries - let now = timezoneOffset ? moment().zone(timezoneOffset) : moment(); - // Date after which to begin compressing data - let cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day'); - - // Keep uncompressed entries (modifies history and returns removed items) - let newHistory = _.remove(history, entry => { - let date = moment(entry.date); - return date.isSame(cutOff) || date.isAfter(cutOff); - }); - - // Date after which to begin compressing data by year - let monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day'); - let aggregateByMonth = _.remove(history, entry => { - let date = moment(entry.date); - return date.isSame(monthsCutOff) || date.isAfter(monthsCutOff); - }); - // Aggregate remaining entries by month and year - if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM')); - if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY')); - - return newHistory; -} - -// Preen history for users and tasks. -export function preenUserHistory (user, tasksByType) { - let isSubscribed = user.isSubscribed(); - let timezoneOffset = user.preferences.timezoneOffset; - let minHistoryLength = isSubscribed ? 365 : 60; - - function _processTask (task) { - if (task.history && task.history.length > minHistoryLength) { - task.history = preenHistory(task.history, isSubscribed, timezoneOffset); - task.markModified('history'); - } - } - - tasksByType.habits.forEach(_processTask); - tasksByType.dailys.forEach(_processTask); - - if (user.history.exp.length > minHistoryLength) { - user.history.exp = preenHistory(user.history.exp, isSubscribed, timezoneOffset); - user.markModified('history.exp'); - } - - if (user.history.todos.length > minHistoryLength) { - user.history.todos = preenHistory(user.history.todos, isSubscribed, timezoneOffset); - user.markModified('history.todos'); - } -} diff --git a/website/server/libs/api-v3/pushNotifications.js b/website/server/libs/api-v3/pushNotifications.js deleted file mode 100644 index 8354de04e4..0000000000 --- a/website/server/libs/api-v3/pushNotifications.js +++ /dev/null @@ -1,53 +0,0 @@ -import _ from 'lodash'; -import nconf from 'nconf'; -import pushNotify from 'push-notify'; - -const GCM_API_KEY = nconf.get('PUSH_CONFIGS:GCM_SERVER_API_KEY'); - -let gcm = GCM_API_KEY ? pushNotify.gcm({ - apiKey: GCM_API_KEY, - retries: 3, -}) : undefined; - -// TODO review and test this file when push notifications are added back - -if (gcm) { - gcm.on('transmitted', (/* result, message, registrationId */) => { - // console.info("transmitted", result, message, registrationId); - }); - - gcm.on('transmissionError', (/* error, message, registrationId */) => { - // console.info("transmissionError", error, message, registrationId); - }); - - gcm.on('updated', (/* result, registrationId */) => { - // console.info("updated", result, registrationId); - }); -} - -module.exports = function sendNotification (user, title, message, timeToLive = 15) { - if (!user) return; - - _.each(user.pushDevices, pushDevice => { - switch (pushDevice.type) { - case 'android': - if (gcm) { - gcm.send({ - registrationId: pushDevice.regId, - // collapseKey: 'COLLAPSE_KEY', - delayWhileIdle: true, - timeToLive, - data: { - title, - message, - }, - }); - } - - break; - - case 'ios': - break; - } - }); -}; diff --git a/website/server/libs/api-v3/routes.js b/website/server/libs/api-v3/routes.js deleted file mode 100644 index c0399fb73c..0000000000 --- a/website/server/libs/api-v3/routes.js +++ /dev/null @@ -1,61 +0,0 @@ -import fs from 'fs'; -import _ from 'lodash'; -import { - getUserLanguage, -} from '../../middlewares/api-v3/language'; -import cron from '../../middlewares/api-v3/cron'; - -// Wrapper function to handler `async` route handlers that return promises -// It takes the async function, execute it and pass any error to next (args[2]) -let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]); -let noop = (req, res, next) => next(); - -module.exports.readController = function readController (router, controller) { - _.each(controller, (action) => { - let {method, url, middlewares = [], handler, runCron} = action; - - // If an authentication middleware is used run getUserLanguage after it, otherwise before - // for cron instead use it only if an authentication middleware is present - let authMiddlewareIndex = _.findIndex(middlewares, middleware => { - if (middleware.name.indexOf('authWith') === 0) { // authWith{Headers|Session|Url|...} - return true; - } else { - return false; - } - }); - - let middlewaresToAdd = [getUserLanguage]; - - if (authMiddlewareIndex !== -1) { // the user will be authenticated, getUserLanguage and cron after authentication - if (!(runCron === false)) { // eslint-disable-line no-extra-parens - middlewaresToAdd.push(cron); - } - - if (authMiddlewareIndex === middlewares.length - 1) { - middlewares.push(...middlewaresToAdd); - } else { - middlewares.splice(authMiddlewareIndex + 1, 0, ...middlewaresToAdd); - } - } else { // no auth, getUserLanguage as the first middleware - middlewares.unshift(...middlewaresToAdd); - } - - method = method.toLowerCase(); - let fn = handler ? _wrapAsyncFn(handler) : noop; - - router[method](url, ...middlewares, fn); - }); -}; - -module.exports.walkControllers = function walkControllers (router, filePath) { - fs - .readdirSync(filePath) - .forEach(fileName => { - if (!fs.statSync(filePath + fileName).isFile()) { - walkControllers(router, `${filePath}${fileName}/`); - } else if (fileName.match(/\.js$/)) { - let controller = require(filePath + fileName); // eslint-disable-line global-require - module.exports.readController(router, controller); - } - }); -}; diff --git a/website/server/libs/api-v3/setupMongoose.js b/website/server/libs/api-v3/setupMongoose.js deleted file mode 100644 index 41ba9e5358..0000000000 --- a/website/server/libs/api-v3/setupMongoose.js +++ /dev/null @@ -1,28 +0,0 @@ -import nconf from 'nconf'; -import logger from './logger'; -import autoinc from 'mongoose-id-autoinc'; -import mongoose from 'mongoose'; -import Bluebird from 'bluebird'; - -const IS_PROD = nconf.get('IS_PROD'); -const MAINTENANCE_MODE = nconf.get('MAINTENANCE_MODE'); - -// Use Q promises instead of mpromise in mongoose -mongoose.Promise = Bluebird; - -// Do not connect to MongoDB when in maintenance mode -if (MAINTENANCE_MODE !== 'true') { - let mongooseOptions = !IS_PROD ? {} : { - replset: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, - server: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } }, - }; - - const NODE_DB_URI = nconf.get('IS_TEST') ? nconf.get('TEST_DB_URI') : nconf.get('NODE_DB_URI'); - - let db = mongoose.connect(NODE_DB_URI, mongooseOptions, (err) => { - if (err) throw err; - logger.info('Connected with Mongoose.'); - }); - - autoinc.init(db); -} \ No newline at end of file diff --git a/website/server/libs/api-v3/setupNconf.js b/website/server/libs/api-v3/setupNconf.js deleted file mode 100644 index d88b04014e..0000000000 --- a/website/server/libs/api-v3/setupNconf.js +++ /dev/null @@ -1,17 +0,0 @@ -import nconf from 'nconf'; -import { join, resolve } from 'path'; - -const PATH_TO_CONFIG = join(resolve(__dirname, '../../../../config.json')); - -module.exports = function setupNconf (file) { - let configFile = file || PATH_TO_CONFIG; - - nconf - .argv() - .env() - .file('user', configFile); - - nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production'); - nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development'); - nconf.set('IS_TEST', nconf.get('NODE_ENV') === 'test'); -}; diff --git a/website/server/libs/api-v3/setupPassport.js b/website/server/libs/api-v3/setupPassport.js deleted file mode 100644 index dd9fdeaaa2..0000000000 --- a/website/server/libs/api-v3/setupPassport.js +++ /dev/null @@ -1,24 +0,0 @@ -import passport from 'passport'; -import nconf from 'nconf'; -import passportFacebook from 'passport-facebook'; - -const FacebookStrategy = passportFacebook.Strategy; - -// Passport session setup. -// To support persistent login sessions, Passport needs to be able to -// serialize users into and deserialize users out of the session. Typically, -// this will be as simple as storing the user ID when serializing, and finding -// the user by ID when deserializing. However, since this example does not -// have a database of user records, the complete Facebook profile is serialized -// and deserialized. -passport.serializeUser((user, done) => done(null, user)); -passport.deserializeUser((obj, done) => done(null, obj)); - -// TODO remove? -// This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) -// The proper fix would be to move to a general OAuth module simply to verify accessTokens -passport.use(new FacebookStrategy({ - clientID: nconf.get('FACEBOOK_KEY'), - clientSecret: nconf.get('FACEBOOK_SECRET'), - // callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" -}, (accessToken, refreshToken, profile, done) => done(null, profile))); diff --git a/website/server/libs/api-v3/webhook.js b/website/server/libs/api-v3/webhook.js deleted file mode 100644 index e53eebf541..0000000000 --- a/website/server/libs/api-v3/webhook.js +++ /dev/null @@ -1,31 +0,0 @@ -import { each } from 'lodash'; -import { post } from 'request'; -import { isURL } from 'validator'; -import logger from './logger'; - -let _sendWebhook = (url, body) => { - post({ - url, - body, - json: true, - }, (err) => logger.error(err)); -}; - -let _isInvalidWebhook = (hook) => { - return !hook.enabled || !isURL(hook.url); -}; - -export function sendTaskWebhook (webhooks, data) { - each(webhooks, (hook) => { - if (_isInvalidWebhook(hook)) return; - - let body = { - direction: data.task.direction, - task: data.task.details, - delta: data.task.delta, - user: data.user, - }; - - _sendWebhook(hook.url, body); - }); -} diff --git a/website/server/middlewares/api-v3/analytics.js b/website/server/middlewares/api-v3/analytics.js deleted file mode 100644 index 8512d16011..0000000000 --- a/website/server/middlewares/api-v3/analytics.js +++ /dev/null @@ -1,23 +0,0 @@ -import nconf from 'nconf'; -import { - track, - trackPurchase, - mockAnalyticsService, -} from '../../libs/api-v3/analyticsService'; - -let service; - -if (nconf.get('IS_PROD')) { - service = { - track, - trackPurchase, - }; -} else { - service = mockAnalyticsService; -} - -module.exports = function attachAnalytics (req, res, next) { - res.analytics = service; - - next(); -}; diff --git a/website/server/middlewares/api-v3/auth.js b/website/server/middlewares/api-v3/auth.js deleted file mode 100644 index 0feb73a98d..0000000000 --- a/website/server/middlewares/api-v3/auth.js +++ /dev/null @@ -1,87 +0,0 @@ -import { - NotAuthorized, -} from '../../libs/api-v3/errors'; -import { - model as User, -} from '../../models/user'; - -// Strins won't be translated here because getUserLanguage has not run yet - -// Authenticate a request through the x-api-user and x-api key header -// If optional is true, don't error on missing authentication -export function authWithHeaders (optional = false) { - return function authWithHeadersHandler (req, res, next) { - let userId = req.header('x-api-user'); - let apiToken = req.header('x-api-key'); - - if (!userId || !apiToken) { - if (optional) return next(); - return next(new NotAuthorized(res.t('missingAuthHeaders'))); - } - - return User.findOne({ - _id: userId, - apiToken, - }) - .exec() - .then((user) => { - if (!user) throw new NotAuthorized(res.t('invalidCredentials')); - if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id})); - - res.locals.user = user; - - req.session.userId = user._id; - return next(); - }) - .catch(next); - }; -} - -// Authenticate a request through a valid session -export function authWithSession (req, res, next) { - let userId = req.session.userId; - - // Always allow authentication with headers - if (!userId) { - if (!req.header('x-api-user') || !req.header('x-api-key')) { - return next(new NotAuthorized(res.t('invalidCredentials'))); - } else { - return authWithHeaders()(req, res, next); - } - } - - return User.findOne({ - _id: userId, - }) - .exec() - .then((user) => { - if (!user) throw new NotAuthorized(res.t('invalidCredentials')); - - res.locals.user = user; - return next(); - }) - .catch(next); -} - -export function authWithUrl (req, res, next) { - let userId = req.query._id; - let apiToken = req.query.apiToken; - - // Always allow authentication with headers - if (!userId || !apiToken) { - if (!req.header('x-api-user') || !req.header('x-api-key')) { - return next(new NotAuthorized(res.t('missingAuthParams'))); - } else { - return authWithHeaders()(req, res, next); - } - } - - return User.findOne({ _id: userId, apiToken }).exec() - .then((user) => { - if (!user) throw new NotAuthorized(res.t('invalidCredentials')); - - res.locals.user = user; - return next(); - }) - .catch(next); -} diff --git a/website/server/middlewares/api-v3/cors.js b/website/server/middlewares/api-v3/cors.js deleted file mode 100644 index c249c183c6..0000000000 --- a/website/server/middlewares/api-v3/cors.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = function corsMiddleware (req, res, next) { - res.set({ - 'Access-Control-Allow-Origin': req.header('origin') || '*', - 'Access-Control-Allow-Methods': 'OPTIONS,GET,POST,PUT,HEAD,DELETE', - 'Access-Control-Allow-Headers': 'Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key', - }); - if (req.method === 'OPTIONS') return res.sendStatus(200); - return next(); -}; diff --git a/website/server/middlewares/api-v3/cron.js b/website/server/middlewares/api-v3/cron.js deleted file mode 100644 index 780f400e8f..0000000000 --- a/website/server/middlewares/api-v3/cron.js +++ /dev/null @@ -1,160 +0,0 @@ -import _ from 'lodash'; -import moment from 'moment'; -import common from '../../../../common'; -import * as Tasks from '../../models/task'; -import Bluebird from 'bluebird'; -import { model as Group } from '../../models/group'; -import { model as User } from '../../models/user'; -import { cron } from '../../libs/api-v3/cron'; - -const daysSince = common.daysSince; - -module.exports = function cronMiddleware (req, res, next) { - let user = res.locals.user; - - if (!user) return next(); // User might not be available when authentication is not mandatory - - let analytics = res.analytics; - - let now = new Date(); - - // If the user's timezone has changed (due to travel or daylight savings), - // cron can be triggered twice in one day, so we check for that and use - // both timezones to work out if cron should run. - // CDS = Custom Day Start time. - let timezoneOffsetFromUserPrefs = user.preferences.timezoneOffset || 0; - let timezoneOffsetAtLastCron = _.isFinite(user.preferences.timezoneOffsetAtLastCron) ? user.preferences.timezoneOffsetAtLastCron : timezoneOffsetFromUserPrefs; - let timezoneOffsetFromBrowser = Number(req.header('x-user-timezoneoffset')); - timezoneOffsetFromBrowser = _.isFinite(timezoneOffsetFromBrowser) ? timezoneOffsetFromBrowser : timezoneOffsetFromUserPrefs; - // NB: All timezone offsets can be 0, so can't use `... || ...` to apply non-zero defaults - - if (timezoneOffsetFromBrowser !== timezoneOffsetFromUserPrefs) { - // The user's browser has just told Habitica that the user's timezone has - // changed so store and use the new zone. - user.preferences.timezoneOffset = timezoneOffsetFromBrowser; - timezoneOffsetFromUserPrefs = timezoneOffsetFromBrowser; - } - - // How many days have we missed using the user's current timezone: - let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences)); - - if (timezoneOffsetAtLastCron !== timezoneOffsetFromUserPrefs) { - // Since cron last ran, the user's timezone has changed. - // How many days have we missed using the old timezone: - let daysMissedNewZone = daysMissed; - let daysMissedOldZone = daysSince(user.lastCron, _.defaults({ - now, - timezoneOffsetOverride: timezoneOffsetAtLastCron, - }, user.preferences)); - - if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) { - // The timezone change was in the unsafe direction. - // E.g., timezone changes from UTC+1 (offset -60) to UTC+0 (offset 0). - // or timezone changes from UTC-4 (offset 240) to UTC-5 (offset 300). - // Local time changed from, for example, 03:00 to 02:00. - - if (daysMissedOldZone > 0 && daysMissedNewZone > 0) { - // Both old and new timezones indicate that we SHOULD run cron, so - // it is safe to do so immediately. - daysMissed = Math.min(daysMissedOldZone, daysMissedNewZone); - // use minimum value to be nice to user - } else if (daysMissedOldZone > 0) { - // The old timezone says that cron should run; the new timezone does not. - // This should be impossible for this direction of timezone change, but - // just in case I'm wrong... - // TODO - // console.log("zone has changed - old zone says run cron, NEW zone says no - stop cron now only -- SHOULD NOT HAVE GOT TO HERE", timezoneOffsetAtLastCron, timezoneOffsetFromUserPrefs, now); // used in production for confirming this never happens - } else if (daysMissedNewZone > 0) { - // The old timezone says that cron should NOT run -- i.e., cron has - // already run today, from the old timezone's point of view. - // The new timezone says that cron SHOULD run, but this is almost - // certainly incorrect. - // This happens when cron occurred at a time soon after the CDS. When - // you reinterpret that time in the new timezone, it looks like it - // was before the CDS, because local time has stepped backwards. - // To fix this, rewrite the cron time to a time that the new - // timezone interprets as being in today. - - daysMissed = 0; // prevent cron running now - let timezoneOffsetDiff = timezoneOffsetAtLastCron - timezoneOffsetFromUserPrefs; - // e.g., for dangerous zone change: 240 - 300 = -60 or -660 - -600 = -60 - - user.lastCron = moment(user.lastCron).subtract(timezoneOffsetDiff, 'minutes'); - // NB: We don't change user.auth.timestamps.loggedin so that will still record the time that the previous cron actually ran. - // From now on we can ignore the old timezone: - user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; - } else { - // Both old and new timezones indicate that cron should - // NOT run. - daysMissed = 0; // prevent cron running now - } - } else if (timezoneOffsetAtLastCron > timezoneOffsetFromUserPrefs) { - daysMissed = daysMissedNewZone; - // TODO: Either confirm that there is nothing that could possibly go wrong here and remove the need for this else branch, or fix stuff. - // There are probably situations where the Dailies do not reset early enough for a user who was expecting the zone change and wants to use all their Dailies immediately in the new zone; - // if so, we should provide an option for easy reset of Dailies (can't be automatic because there will be other situations where the user was not prepared). - } - } - - if (daysMissed <= 0) return next(); - - // Fetch active tasks (no completed todos) - Tasks.Task.find({ - userId: user._id, - $or: [ // Exclude completed todos - {type: 'todo', completed: false}, - {type: {$in: ['habit', 'daily', 'reward']}}, - ], - }).exec() - .then(tasks => { - let tasksByType = {habits: [], dailys: [], todos: [], rewards: []}; - tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); - - // Run cron - let progress = cron({user, tasksByType, now, daysMissed, analytics, timezoneOffsetFromUserPrefs}); - - // Clear old completed todos - 30 days for free users, 90 for subscribers - // Do not delete challenges completed todos TODO unless the task is broken? - Tasks.Task.remove({ - userId: user._id, - type: 'todo', - completed: true, - dateCompleted: { - $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(), - }, - 'challenge.id': {$exists: false}, - }).exec(); - - let ranCron = user.isModified(); - let quest = common.content.quests[user.party.quest.key]; - - if (ranCron) res.locals.wasModified = true; // TODO remove after v2 is retired - if (!ranCron) return next(); - - // Group.tavernBoss(user, progress); - - // Save user and tasks - let toSave = [user.save()]; - tasks.forEach(task => { - if (task.isModified()) toSave.push(task.save()); - }); - - return Bluebird.all(toSave) - .then(saved => { - user = res.locals.user = saved[0]; - if (!quest) return; - // If user is on a quest, roll for boss & player, or handle collections - let questType = quest.boss ? 'boss' : 'collect'; - // TODO this saves user, runs db updates, loads user. Is there a better way to handle this? - return Group[`${questType}Quest`](user, progress) - .then(() => User.findById(user._id).exec()) // fetch the updated user... - .then(updatedUser => { - res.locals.user = updatedUser; - - return null; - }); - }) - .then(() => next()) - .catch(next); - }); -}; diff --git a/website/server/middlewares/api-v3/domain.js b/website/server/middlewares/api-v3/domain.js deleted file mode 100644 index fb5e28d352..0000000000 --- a/website/server/middlewares/api-v3/domain.js +++ /dev/null @@ -1,13 +0,0 @@ -import domainMiddleware from 'domain-middleware'; - -module.exports = function implementDomainMiddleware (server, mongoose) { - return domainMiddleware({ - server: { - close () { - server.close(); - mongoose.connection.close(); - }, - }, - killTimeout: 10000, - }); -}; diff --git a/website/server/middlewares/api-v3/ensureAccessRight.js b/website/server/middlewares/api-v3/ensureAccessRight.js deleted file mode 100644 index 2fb64ed8af..0000000000 --- a/website/server/middlewares/api-v3/ensureAccessRight.js +++ /dev/null @@ -1,23 +0,0 @@ -import { - NotAuthorized, -} from '../../libs/api-v3/errors'; - -export function ensureAdmin (req, res, next) { - let user = res.locals.user; - - if (!user.contributor.admin) { - return next(new NotAuthorized(res.t('noAdminAccess'))); - } - - next(); -} - -export function ensureSudo (req, res, next) { - let user = res.locals.user; - - if (!user.contributor.sudo) { - return next(new NotAuthorized(res.t('noSudoAccess'))); - } - - next(); -} diff --git a/website/server/middlewares/api-v3/ensureDevelpmentMode.js b/website/server/middlewares/api-v3/ensureDevelpmentMode.js deleted file mode 100644 index 98f70d33f5..0000000000 --- a/website/server/middlewares/api-v3/ensureDevelpmentMode.js +++ /dev/null @@ -1,12 +0,0 @@ -import nconf from 'nconf'; -import { - NotFound, -} from '../../libs/api-v3/errors'; - -module.exports = function ensureDevelpmentMode (req, res, next) { - if (nconf.get('IS_PROD')) { - next(new NotFound()); - } else { - next(); - } -}; diff --git a/website/server/middlewares/api-v3/errorHandler.js b/website/server/middlewares/api-v3/errorHandler.js deleted file mode 100644 index 11d75042e3..0000000000 --- a/website/server/middlewares/api-v3/errorHandler.js +++ /dev/null @@ -1,86 +0,0 @@ -// The error handler middleware that handles all errors -// and respond to the client -import logger from '../../libs/api-v3/logger'; -import { - CustomError, - BadRequest, - InternalServerError, -} from '../../libs/api-v3/errors'; -import { - map, - omit, -} from 'lodash'; - -module.exports = function errorHandler (err, req, res, next) { // eslint-disable-line no-unused-vars - logger.error(err, { - originalUrl: req.originalUrl, - headers: omit(req.headers, ['x-api-key']), - body: req.body, - }); - - // In case of a CustomError class, use it's data - // Otherwise try to identify the type of error (mongoose validation, mongodb unique, ...) - // If we can't identify it, respond with a generic 500 error - let responseErr = err instanceof CustomError ? err : null; - - // Handle errors created with 'http-errors' or similar that have a status/statusCode property - if (err.statusCode && typeof err.statusCode === 'number') { - responseErr = new CustomError(); - responseErr.httpCode = err.statusCode; - responseErr.name = err.name; - responseErr.message = err.message; - } - - // Handle errors by express-validator - if (Array.isArray(err) && err[0].param && err[0].msg) { - responseErr = new BadRequest(res.t('invalidReqParams')); - responseErr.errors = err.map((paramErr) => { - return { - message: paramErr.msg, - param: paramErr.param, - value: paramErr.value, - }; - }); - } - - // Handle mongoose validation errors - if (err.name === 'ValidationError') { - responseErr = new BadRequest(err.message); // TODO standard message? translate? - responseErr.errors = map(err.errors, (mongooseErr) => { - return { - message: mongooseErr.message, - path: mongooseErr.path, - value: mongooseErr.value, - }; - }); - } - - // Handle Stripe Card errors errors (can be safely shown to the users) - // https://stripe.com/docs/api/node#errors - if (err.type === 'StripeCardError') { - responseErr = new BadRequest(err.message); - } - - if (!responseErr || responseErr.httpCode >= 500) { - // Try to identify the error... - // ... - // Otherwise create an InternalServerError and use it - // we don't want to leak anything, just a generic error message - // Use it also in case of identified errors but with httpCode === 500 - responseErr = new InternalServerError(); - } - - let jsonRes = { - success: false, - error: responseErr.name, - message: responseErr.message, - }; - - if (responseErr.errors) { - jsonRes.errors = responseErr.errors; - } - - // In some occasions like when invalid JSON is supplied `res.respond` might be not yet avalaible, - // in this case we use the standard res.status(...).json(...) - return res.status(responseErr.httpCode).json(jsonRes); -}; diff --git a/website/server/middlewares/api-v3/index.js b/website/server/middlewares/api-v3/index.js deleted file mode 100644 index 694fdb6ea5..0000000000 --- a/website/server/middlewares/api-v3/index.js +++ /dev/null @@ -1,87 +0,0 @@ -// This module is only used to attach middlewares to the express app -import errorHandler from './errorHandler'; -import bodyParser from 'body-parser'; -import notFoundHandler from './notFound'; -import nconf from 'nconf'; -import morgan from 'morgan'; -import cookieSession from 'cookie-session'; -import cors from './cors'; -import staticMiddleware from './static'; -import domainMiddleware from './domain'; -import mongoose from 'mongoose'; -import compression from 'compression'; -import favicon from 'serve-favicon'; -import methodOverride from 'method-override'; -import passport from 'passport'; -import path from 'path'; -import maintenanceMode from './maintenanceMode'; -import { - forceSSL, - forceHabitica, -} from './redirects'; -import v1 from './v1'; -import v2 from './v2'; -import v3 from './v3'; -import responseHandler from './response'; -import { - attachTranslateFunction, -} from './language'; - -const IS_PROD = nconf.get('IS_PROD'); -const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); -const PUBLIC_DIR = path.join(__dirname, '/../../../client'); - -const SESSION_SECRET = nconf.get('SESSION_SECRET'); -const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; - -module.exports = function attachMiddlewares (app, server) { - app.set('view engine', 'jade'); - app.set('views', `${__dirname}/../views`); - - app.use(domainMiddleware(server, mongoose)); - - if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev')); - - // add res.respond and res.t - app.use(responseHandler); - app.use(attachTranslateFunction); - - app.use(compression()); - app.use(favicon(`${PUBLIC_DIR}/favicon.ico`)); - - app.use(maintenanceMode); - - app.use(cors); - app.use(forceSSL); - app.use(forceHabitica); - - app.use(bodyParser.urlencoded({ - extended: true, // Uses 'qs' library as old connect middleware - })); - app.use(bodyParser.json()); - app.use(methodOverride()); - - app.use(cookieSession({ - name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies - secret: SESSION_SECRET, - httpOnly: true, // so cookies are not accessible with browser JS - // TODO what about https only (secure) ? - maxAge: TWO_WEEKS, - })); - - // Initialize Passport! Also use passport.session() middleware, to support - // persistent login sessions (recommended). - app.use(passport.initialize()); - app.use(passport.session()); - - app.use('/api/v2', v2); - app.use('/api/v1', v1); - app.use(v3); // the main app, also setup top-level routes - staticMiddleware(app); - - app.use(notFoundHandler); - - // Error handler middleware, define as the last one. - // Used for v3 and v1, v2 will keep using its own error handler - app.use(errorHandler); -}; diff --git a/website/server/middlewares/api-v3/language.js b/website/server/middlewares/api-v3/language.js deleted file mode 100644 index b83c9d5208..0000000000 --- a/website/server/middlewares/api-v3/language.js +++ /dev/null @@ -1,91 +0,0 @@ -import { model as User } from '../../models/user'; -import accepts from 'accepts'; -import common from '../../../../common'; -import _ from 'lodash'; -import { - translations, - defaultLangCodes, - multipleVersionsLanguages, -} from '../../libs/api-v3/i18n'; - -const i18n = common.i18n; - -function _getUniqueListOfLanguages (languages) { - let acceptableLanguages = _(languages).map((lang) => { - return lang.slice(0, 2); - }).uniq().value(); - - let uniqueListOfLanguages = _.intersection(acceptableLanguages, defaultLangCodes); - - return uniqueListOfLanguages; -} - -function _checkForApplicableLanguageVariant (originalLanguageOptions) { - let languageVariant = _.find(originalLanguageOptions, (accepted) => { - let trimmedAccepted = accepted.slice(0, 2); - - return multipleVersionsLanguages[trimmedAccepted]; - }); - - return languageVariant; -} - -function _getFromBrowser (req) { - let originalLanguageOptions = accepts(req).languages(); - let uniqueListOfLanguages = _getUniqueListOfLanguages(originalLanguageOptions); - let baseLanguage = (uniqueListOfLanguages[0] || '').toLowerCase(); - let languageMapping = multipleVersionsLanguages[baseLanguage]; - - if (languageMapping) { - let languageVariant = _checkForApplicableLanguageVariant(originalLanguageOptions); - - if (languageVariant) { - languageVariant = languageVariant.toLowerCase(); - } else { - return 'en'; - } - - return languageMapping[languageVariant] || baseLanguage; - } else { - return baseLanguage || 'en'; - } -} - -function _getFromUser (user, req) { - let preferredLang = user && user.preferences && user.preferences.language; - let lang = translations[preferredLang] ? preferredLang : _getFromBrowser(req); - - return lang; -} - -export function attachTranslateFunction (req, res, next) { - res.t = function reqTranslation () { - return i18n.t(...arguments, req.language); - }; - - next(); -} - -export function getUserLanguage (req, res, next) { - if (req.query.lang) { // In case the language is specified in the request url, use it - req.language = translations[req.query.lang] ? req.query.lang : 'en'; - return next(); - } else if (req.locals && req.locals.user) { // If the request is authenticated, use the user's preferred language - req.language = _getFromUser(req.locals.user, req); - return next(); - } else if (req.session && req.session.userId) { // Same thing if the user has a valid session - return User.findOne({ - _id: req.session.userId, - }, 'preferences.language') - .lean() - .exec() - .then((user) => { - req.language = _getFromUser(user, req); - return next(); - }) - .catch(next); - } else { // Otherwise get from browser - req.language = _getFromUser(null, req); - return next(); - } -} diff --git a/website/server/middlewares/api-v3/locals.js b/website/server/middlewares/api-v3/locals.js deleted file mode 100644 index e6e06e3b16..0000000000 --- a/website/server/middlewares/api-v3/locals.js +++ /dev/null @@ -1,61 +0,0 @@ -import nconf from 'nconf'; -import _ from 'lodash'; -import shared from '../../../../common'; -import * as i18n from '../../libs/api-v3/i18n'; -import { - getBuildUrl, - getManifestFiles, -} from '../../libs/api-v3/buildManifest'; -import forceRefresh from './../forceRefresh'; -import { tavernQuest } from '../../models/group'; -import { mods } from '../../models/user'; - -// To avoid stringifying more data then we need, -// items from `env` used on the client will have to be specified in this array -const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations', - 'FACEBOOK_KEY', 'NODE_ENV', 'BASE_URL', 'GA_ID', - 'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY', - 'worldDmg', 'mods', 'IS_MOBILE']; - -let env = { - getManifestFiles, - getBuildUrl, - _, - clientVars: CLIENT_VARS, - mods, - Content: shared.content, - siteVersion: forceRefresh.siteVersion, - availableLanguages: i18n.availableLanguages, - AMAZON_PAYMENTS: { - SELLER_ID: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), - CLIENT_ID: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), - }, -}; - -'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY'.split(' ').forEach(key => { - env[key] = nconf.get(key); -}); - -module.exports = function locals (req, res, next) { - let language = _.find(i18n.availableLanguages, {code: req.language}); - let isStaticPage = req.url.split('/')[1] === 'static'; // If url contains '/static/' - - // Load moment.js language file only when not on static pages - language.momentLang = !isStaticPage && i18n.momentLangs[language.code] || undefined; - - res.locals.habitrpg = _.assign(env, { - IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')), - language, - isStaticPage, - translations: i18n.translations[language.code], - t (...args) { // stringName and vars are the allowed parameters - args.push(language.code); - return shared.i18n.t(...args); - }, - // Defined here and not outside of the middleware because tavernQuest might be an - // empty object until the query to fetch it finishes - worldDmg: tavernQuest && tavernQuest.extra && tavernQuest.extra.worldDmg || {}, - }); - - next(); -}; diff --git a/website/server/middlewares/api-v3/maintenanceMode.js b/website/server/middlewares/api-v3/maintenanceMode.js deleted file mode 100644 index 331663125a..0000000000 --- a/website/server/middlewares/api-v3/maintenanceMode.js +++ /dev/null @@ -1,31 +0,0 @@ -import { getUserLanguage } from './language'; -import nconf from 'nconf'; - -const MAINTENANCE_MODE = nconf.get('MAINTENANCE_MODE'); - -module.exports = function maintenanceMode (req, res, next) { - if (MAINTENANCE_MODE !== 'true') return next(); - - getUserLanguage(req, res, (err) => { - if (err) return next(err); - - let pageVariables = { - maintenanceStart: nconf.get('MAINTENANCE_START'), - maintenanceEnd: nconf.get('MAINTENANCE_END'), - translation: res.t, - }; - - if (req.headers && req.headers.accept && req.headers.accept.indexOf('text/html') !== -1) { - if (req.path === '/views/static/maintenance-info') { - return res.status(503).render('../../../views/static/maintenance-info', pageVariables); - } else { - return res.status(503).render('../../../views/static/maintenance', pageVariables); - } - } else { - return res.status(503).send({ - error: 'Maintenance', - message: 'Server offline for maintenance.', - }); - } - }); -}; diff --git a/website/server/middlewares/api-v3/notFound.js b/website/server/middlewares/api-v3/notFound.js deleted file mode 100644 index 6a71b5def4..0000000000 --- a/website/server/middlewares/api-v3/notFound.js +++ /dev/null @@ -1,7 +0,0 @@ -import { - NotFound, -} from '../../libs/api-v3/errors'; - -module.exports = function NotFoundMiddleware (req, res, next) { - next(new NotFound()); -}; diff --git a/website/server/middlewares/api-v3/redirects.js b/website/server/middlewares/api-v3/redirects.js deleted file mode 100644 index a907a89b14..0000000000 --- a/website/server/middlewares/api-v3/redirects.js +++ /dev/null @@ -1,43 +0,0 @@ -import nconf from 'nconf'; - -const IS_PROD = nconf.get('IS_PROD'); -const IGNORE_REDIRECT = nconf.get('IGNORE_REDIRECT'); -const BASE_URL = nconf.get('BASE_URL'); - -function isHTTP (req) { - return ( // eslint-disable-line no-extra-parens - req.header('x-forwarded-proto') && - req.header('x-forwarded-proto') !== 'https' && - IS_PROD && - BASE_URL.indexOf('https') === 0 - ); -} - -function isProxied (req) { - return ( // eslint-disable-line no-extra-parens - req.header('x-habitica-lb') && - req.header('x-habitica-lb') === 'Yes' - ); -} - -export function forceSSL (req, res, next) { - if (isHTTP(req) && !isProxied(req)) { - return res.redirect(BASE_URL + req.originalUrl); - } - - next(); -} - -// Redirect to habitica for non-api urls - -function nonApiUrl (req) { - return req.originalUrl.search(/\/api\//) === -1; -} - -export function forceHabitica (req, res, next) { - if (IS_PROD && !IGNORE_REDIRECT && !isProxied(req) && nonApiUrl(req)) { - return res.redirect(301, BASE_URL + req.url); - } - - next(); -} diff --git a/website/server/middlewares/api-v3/response.js b/website/server/middlewares/api-v3/response.js deleted file mode 100644 index e00d691fb2..0000000000 --- a/website/server/middlewares/api-v3/response.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = function responseHandler (req, res, next) { - // Only used for successful responses - res.respond = function respond (status = 200, data = {}, message) { - let user = res.locals && res.locals.user; - - let response = { - success: status < 400, - data, - }; - - if (message) response.message = message; - - // When userV=Number (user version) query parameter is passed and a user is logged in, - // sends back the current user._v in the response so that the client - // can verify if it's the most up to date data. - // Considered part of the private API for now and not officially supported - if (user && req.query.userV) { - response.userV = user._v; - } - - res.status(status).json(response); - }; - - next(); -}; diff --git a/website/server/middlewares/api-v3/setupBody.js b/website/server/middlewares/api-v3/setupBody.js deleted file mode 100644 index b3309fb2da..0000000000 --- a/website/server/middlewares/api-v3/setupBody.js +++ /dev/null @@ -1,5 +0,0 @@ -// TODO test this middleware -module.exports = function setupBodyMiddleware (req, res, next) { - req.body = req.body || {}; - next(); -}; diff --git a/website/server/middlewares/api-v3/static.js b/website/server/middlewares/api-v3/static.js deleted file mode 100644 index 19c1c9025c..0000000000 --- a/website/server/middlewares/api-v3/static.js +++ /dev/null @@ -1,18 +0,0 @@ -import express from 'express'; -import nconf from 'nconf'; -import path from 'path'; - -const IS_PROD = nconf.get('IS_PROD'); -const MAX_AGE = IS_PROD ? 31536000000 : 0; -const PUBLIC_DIR = path.join(__dirname, '/../../../client'); -const BUILD_DIR = path.join(__dirname, '/../../../build'); - -module.exports = function staticMiddleware (expressApp) { - // TODO move all static files to a single location (one for public and one for build) - expressApp.use(express.static(BUILD_DIR, { maxAge: MAX_AGE })); - expressApp.use('/common/dist', express.static(`${PUBLIC_DIR}/../../common/dist`, { maxAge: MAX_AGE })); - expressApp.use('/common/audio', express.static(`${PUBLIC_DIR}/../../common/audio`, { maxAge: MAX_AGE })); - expressApp.use('/common/script/public', express.static(`${PUBLIC_DIR}/../../common/script/public`, { maxAge: MAX_AGE })); - expressApp.use('/common/img', express.static(`${PUBLIC_DIR}/../../common/img`, { maxAge: MAX_AGE })); - expressApp.use(express.static(PUBLIC_DIR)); -}; diff --git a/website/server/middlewares/api-v3/v1.js b/website/server/middlewares/api-v3/v1.js deleted file mode 100644 index abe26f42e6..0000000000 --- a/website/server/middlewares/api-v3/v1.js +++ /dev/null @@ -1,19 +0,0 @@ -// API v1 middlewares and routes -// DEPRECATED AND INACTIVE - -import express from 'express'; -import nconf from 'nconf'; -import { - NotFound, -} from '../../libs/api-v3/errors'; - -const router = express.Router(); // eslint-disable-line babel/new-cap - -const BASE_URL = nconf.get('BASE_URL'); - -router.all('*', function deprecatedV1 (req, res, next) { - let error = new NotFound(`API v1 is no longer supported, please use API v3 instead (${BASE_URL}/static/api).`); - return next(error); -}); - -module.exports = router; diff --git a/website/server/middlewares/api-v3/v2.js b/website/server/middlewares/api-v3/v2.js deleted file mode 100644 index ec49e326a4..0000000000 --- a/website/server/middlewares/api-v3/v2.js +++ /dev/null @@ -1,27 +0,0 @@ -// DEPRECATED BUT STILL ACTIVE - -// import path from 'path'; -import swagger from 'swagger-node-express'; -// import shared from '../../../../common'; -import express from 'express'; -import analytics from './analytics'; -import responseHandler from './response'; - -const v2app = express(); - -// re-set the view options because they are not inherited from the top level app -v2app.set('view engine', 'jade'); -v2app.set('views', `${__dirname}/../../../views`); - -v2app.use(analytics); -v2app.use(responseHandler); - - -// Custom Directives -v2app.use('/', require('../../routes/api-v2/auth')); - -require('../../routes/api-v2/swagger')(swagger, v2app); - -v2app.use(require('../api-v2/errorHandler')); - -module.exports = v2app; diff --git a/website/server/middlewares/api-v3/v3.js b/website/server/middlewares/api-v3/v3.js deleted file mode 100644 index f9aa637fa0..0000000000 --- a/website/server/middlewares/api-v3/v3.js +++ /dev/null @@ -1,30 +0,0 @@ -import express from 'express'; -import expressValidator from 'express-validator'; -import analytics from './analytics'; -import setupBody from './setupBody'; -import routes from '../../libs/api-v3/routes'; -import path from 'path'; - -const API_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); -const TOP_LEVEL_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/top-level/'); - -const v3app = express(); - -// re-set the view options because they are not inherited from the top level app -v3app.set('view engine', 'jade'); -v3app.set('views', `${__dirname}/../../../views`); - -v3app.use(expressValidator()); -v3app.use(analytics); -v3app.use(setupBody); - -const topLevelRouter = express.Router(); // eslint-disable-line babel/new-cap - -routes.walkControllers(topLevelRouter, TOP_LEVEL_CONTROLLERS_PATH); -v3app.use('/', topLevelRouter); - -const v3Router = express.Router(); // eslint-disable-line babel/new-cap -routes.walkControllers(v3Router, API_CONTROLLERS_PATH); -v3app.use('/api/v3', v3Router); - -module.exports = v3app; diff --git a/website/server/models/challenge.js b/website/server/models/challenge.js deleted file mode 100644 index 9901af0e79..0000000000 --- a/website/server/models/challenge.js +++ /dev/null @@ -1,426 +0,0 @@ -import mongoose from 'mongoose'; -import Bluebird from 'bluebird'; -import validator from 'validator'; -import baseModel from '../libs/api-v3/baseModel'; -import _ from 'lodash'; -import * as Tasks from './task'; -import { model as User } from './user'; -import { - model as Group, - TAVERN_ID, -} from './group'; -import { removeFromArray } from '../libs/api-v3/collectionManipulators'; -import shared from '../../../common'; -import { sendTxn as txnEmail } from '../libs/api-v3/email'; -import sendPushNotification from '../libs/api-v3/pushNotifications'; -import cwait from 'cwait'; - -let Schema = mongoose.Schema; - -let schema = new Schema({ - name: {type: String, required: true}, - shortName: {type: String, required: true, minlength: 3}, - description: String, - official: {type: Boolean, default: false}, - tasksOrder: { - habits: [{type: String, ref: 'Task'}], - dailys: [{type: String, ref: 'Task'}], - todos: [{type: String, ref: 'Task'}], - rewards: [{type: String, ref: 'Task'}], - }, - leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, - group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, - memberCount: {type: Number, default: 1}, - prize: {type: Number, default: 0, min: 0}, -}, { - strict: true, - minimize: false, // So empty objects are returned -}); - -schema.plugin(baseModel, { - noSet: ['_id', 'memberCount', 'tasksOrder'], - timestamps: true, -}); - -// A list of additional fields that cannot be updated (but can be set on creation) -let noUpdate = ['group', 'official', 'shortName', 'prize']; -schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { - return this.sanitize(updateObj, noUpdate); -}; - -// Returns true if user is a member of the challenge -schema.methods.isMember = function isChallengeMember (user) { - return user.challenges.indexOf(this._id) !== -1; -}; - -// Returns true if the user can modify (close, selectWinner, ...) the challenge -schema.methods.canModify = function canModifyChallenge (user) { - return user.contributor.admin || this.leader === user._id; -}; - -// Returns true if user has access to the challenge (can join) -schema.methods.hasAccess = function hasAccessToChallenge (user, group) { - if (group.type === 'guild' && group.privacy === 'public') return true; - return user.getGroups().indexOf(this.group) !== -1; -}; - -// Returns true if user can view the challenge -// Different from hasAccess because you can see challenges of groups you've been removed from if you're partecipating in them -schema.methods.canView = function canViewChallenge (user, group) { - if (this.isMember(user)) return true; - return this.hasAccess(user, group); -}; - -// Takes a Task document and return a plain object of attributes that can be synced to the user -function _syncableAttrs (task) { - let t = task.toObject(); // lodash doesn't seem to like _.omit on Document - // only sync/compare important attrs - let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes', 'updatedAt']; - if (t.type !== 'reward') omitAttrs.push('value'); - return _.omit(t, omitAttrs); -} - -// Sync challenge to user, including tasks and tags. -// Used when user joins the challenge or to force sync. -schema.methods.syncToUser = async function syncChallengeToUser (user) { - let challenge = this; - challenge.shortName = challenge.shortName || challenge.name; - - // Add challenge to user.challenges - if (!_.contains(user.challenges, challenge._id)) user.challenges.push(challenge._id); - - // Sync tags - let userTags = user.tags; - let i = _.findIndex(userTags, {id: challenge._id}); - - if (i !== -1) { - if (userTags[i].name !== challenge.shortName) { - // update the name - it's been changed since - userTags[i].name = challenge.shortName; - } - } else { - userTags.push({ - id: challenge._id, - name: challenge.shortName, - challenge: true, - }); - } - - let [challengeTasks, userTasks] = await Bluebird.all([ - // Find original challenge tasks - Tasks.Task.find({ - userId: {$exists: false}, - 'challenge.id': challenge._id, - }).exec(), - // Find user's tasks linked to this challenge - Tasks.Task.find({ - userId: user._id, - 'challenge.id': challenge._id, - }).exec(), - ]); - - let toSave = []; // An array of things to save - - challengeTasks.forEach(chalTask => { - let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); - - if (!matchingTask) { // If the task is new, create it - matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask))); - matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; - matchingTask.userId = user._id; - user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); - } else { - _.merge(matchingTask, _syncableAttrs(chalTask)); - // Make sure the task is in user.tasksOrder - let orderList = user.tasksOrder[`${chalTask.type}s`]; - if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); - } - - if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided - if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing - toSave.push(matchingTask.save()); - }); - - // Flag deleted tasks as "broken" - userTasks.forEach(userTask => { - if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) { - userTask.challenge.broken = 'TASK_DELETED'; - toSave.push(userTask.save()); - } - }); - - toSave.push(user.save()); - return Bluebird.all(toSave); -}; - -async function _fetchMembersIds (challengeId) { - return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id); -} - -async function _addTaskFn (challenge, tasks, memberId) { - let updateTasksOrderQ = {$push: {}}; - let toSave = []; - - tasks.forEach(chalTask => { - let userTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask))); - userTask.challenge = {taskId: chalTask._id, id: challenge._id}; - userTask.userId = memberId; - - let tasksOrderList = updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`]; - if (!tasksOrderList) { - updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`] = { - $position: 0, // unshift - $each: [userTask._id], - }; - } else { - tasksOrderList.$each.unshift(userTask._id); - } - - toSave.push(userTask.save({ - validateBeforeSave: false, // no user data supplied - })); - }); - - // Update the user - toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec()); - return await Bluebird.all(toSave); -} - -// Add a new task to challenge members -schema.methods.addTasks = async function challengeAddTasks (tasks) { - let challenge = this; - let membersIds = await _fetchMembersIds(challenge._id); - - let queue = new cwait.TaskQueue(Bluebird, 5); // process only 5 users concurrently - - await Bluebird.map(membersIds, queue.wrap((memberId) => { - return _addTaskFn(challenge, tasks, memberId); - })); -}; - -// Sync updated task to challenge members -schema.methods.updateTask = async function challengeUpdateTask (task) { - let challenge = this; - - let updateCmd = {$set: {}}; - - let syncableAttrs = _syncableAttrs(task); - for (let key in syncableAttrs) { - updateCmd.$set[key] = syncableAttrs[key]; - } - - // Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks - await Tasks.Task.update({ - userId: {$exists: true}, - 'challenge.id': challenge.id, - 'challenge.taskId': task._id, - }, updateCmd, {multi: true}).exec(); -}; - -// Remove a task from challenge members -schema.methods.removeTask = async function challengeRemoveTask (task) { - let challenge = this; - - // Set the task as broken - await Tasks.Task.update({ - userId: {$exists: true}, - 'challenge.id': challenge.id, - 'challenge.taskId': task._id, - }, { - $set: {'challenge.broken': 'TASK_DELETED'}, - }, {multi: true}).exec(); -}; - -// Unlink challenges tasks (and the challenge itself) from user -schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) { - let challengeId = this._id; - let findQuery = { - userId: user._id, - 'challenge.id': challengeId, - }; - - removeFromArray(user.challenges, challengeId); - - if (keep === 'keep-all') { - await Tasks.Task.update(findQuery, { - $set: {challenge: {}}, - }, {multi: true}).exec(); - - await user.save(); - } else { // keep = 'remove-all' - let tasks = await Tasks.Task.find(findQuery).select('_id type completed').exec(); - let taskPromises = tasks.map(task => { - // Remove task from user.tasksOrder and delete them - if (task.type !== 'todo' || !task.completed) { - removeFromArray(user.tasksOrder[`${task.type}s`], task._id); - } - - return task.remove(); - }); - user.markModified('tasksOrder'); - taskPromises.push(user.save()); - return Bluebird.all(taskPromises); - } -}; - -// TODO everything here should be moved to a worker -// actually even for a worker it's probably just too big and will kill mongo, figure out something else -schema.methods.closeChal = async function closeChal (broken = {}) { - let challenge = this; - - let winner = broken.winner; - let brokenReason = broken.broken; - - // Delete the challenge - await this.model('Challenge').remove({_id: challenge._id}).exec(); - - // Refund the leader if the challenge is closed and the group not the tavern - if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') { - await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec(); - } - - // Update the challengeCount on the group - await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(); - - // Award prize to winner and notify - if (winner) { - winner.achievements.challenges.push(challenge.name); - winner.balance += challenge.prize / 4; - let savedWinner = await winner.save(); - if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { - txnEmail(savedWinner, 'won-challenge', [ - {name: 'CHALLENGE_NAME', content: challenge.name}, - ]); - } - - sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); - } - - // Run some operations in the background withouth blocking the thread - let backgroundTasks = [ - // And it's tasks - Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(), - // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges - User.update({ - challenges: challenge._id, - 'tags._id': challenge._id, - }, { - $set: {'tags.$.challenge': false}, - $pull: {challenges: challenge._id}, - }, {multi: true}).exec(), - // Break users' tasks - Tasks.Task.update({ - 'challenge.id': challenge._id, - }, { - $set: { - 'challenge.broken': brokenReason, - 'challenge.winner': winner && winner.profile.name, - }, - }, {multi: true}).exec(), - ]; - - Bluebird.all(backgroundTasks); -}; - -// Methods to adapt the new schema to API v2 responses (mostly tasks inside the challenge model) -// These will be removed once API v2 is discontinued - -// Get all the tasks belonging to a challenge, -schema.methods.getTasks = function getChallengeTasks () { - let args = Array.from(arguments); - let cb; - let type; - - if (args.length === 1) { - cb = args[0]; - } else if (args.length > 1) { - type = args[0]; - cb = args[1]; - } else { - cb = function noop () {}; - } - - let query = { - userId: { - $exists: false, - }, - - 'challenge.id': this._id, - }; - - if (type) query.type = type; - - return Tasks.Task.find(query, cb); // so we can use it as a promise -}; - -// Given challenge and an array of tasks and one of members return an API compatible challenge + tasks obj + members -schema.methods.addToChallenge = function addToChallenge (tasks, members) { - let obj = this.toJSON(); - obj.members = members; - - let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it - - obj.habits = []; - obj.dailys = []; - obj.todos = []; - obj.rewards = []; - - obj.tasksOrder = undefined; - let unordered = []; - - tasks.forEach((task) => { - // We want to push the task at the same position where it's stored in tasksOrder - let pos = tasksOrder[`${task.type}s`].indexOf(task._id); - if (pos === -1) { // Should never happen, it means the lists got out of sync - unordered.push(task.toJSONV2()); - } else { - obj[`${task.type}s`][pos] = task.toJSONV2(); - } - }); - - // Reconcile unordered items - unordered.forEach((task) => { - obj[`${task.type}s`].push(task); - }); - - // Remove null values that can be created when inserting tasks at an index > length - ['habits', 'dailys', 'rewards', 'todos'].forEach((type) => { - obj[type] = _.compact(obj[type]); - }); - - return obj; -}; - -// Return the data maintaining backward compatibility -schema.methods.getTransformedData = function getTransformedData (options) { - let self = this; - - let cb = options.cb; - let populateMembers = options.populateMembers; - - let queryMembers = { - challenges: self._id, - }; - - let selectDataMembers = '_id'; - - if (populateMembers) { - selectDataMembers += ` ${populateMembers}`; - } - - let membersQuery = User.find(queryMembers).select(selectDataMembers); - if (options.limitPopulation) membersQuery.limit(15); - - Bluebird.all([ - membersQuery.exec(), - self.getTasks(), - ]) - .then((results) => { - cb(null, self.addToChallenge(results[1], results[0])); - }) - .catch(cb); -}; - -// END of API v2 methods - -export let model = mongoose.model('Challenge', schema); diff --git a/website/server/models/coupon.js b/website/server/models/coupon.js deleted file mode 100644 index af9953aba0..0000000000 --- a/website/server/models/coupon.js +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable camelcase */ - -import mongoose from 'mongoose'; -import _ from 'lodash'; -import shared from '../../../common'; -import couponCode from 'coupon-code'; -import baseModel from '../libs/api-v3/baseModel'; -import { - BadRequest, - NotAuthorized, -} from '../libs/api-v3/errors'; - -export let schema = new mongoose.Schema({ - _id: {type: String, default: couponCode.generate}, - event: {type: String, enum: ['wondercon', 'google_6mo']}, - user: {type: String, ref: 'User'}, -}, { - strict: true, - minimize: false, // So empty objects are returned -}); - -schema.plugin(baseModel, { - timestamps: true, - _id: false, -}); - -schema.statics.generate = async function generateCoupons (event, count = 1) { - let coupons = _.times(count, () => { - return {event}; - }); - - return await this.create(coupons); -}; - -schema.statics.apply = async function applyCoupon (user, req, code) { - let coupon = await this.findById(couponCode.validate(code)).exec(); - if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon', req.language)); - if (coupon.user) throw new NotAuthorized(shared.i18n.t('couponUsed', req.language)); - - if (coupon.event === 'wondercon') { - user.items.gear.owned.eyewear_special_wondercon_red = true; - user.items.gear.owned.eyewear_special_wondercon_black = true; - user.items.gear.owned.back_special_wondercon_black = true; - user.items.gear.owned.back_special_wondercon_red = true; - user.items.gear.owned.body_special_wondercon_red = true; - user.items.gear.owned.body_special_wondercon_black = true; - user.items.gear.owned.body_special_wondercon_gold = true; - user.extra = {signupEvent: 'wondercon'}; - } - - await user.save(); - coupon.user = user._id; - await coupon.save(); -}; - -module.exports.schema = schema; -export let model = mongoose.model('Coupon', schema); diff --git a/website/server/models/emailUnsubscription.js b/website/server/models/emailUnsubscription.js deleted file mode 100644 index fe30e5d608..0000000000 --- a/website/server/models/emailUnsubscription.js +++ /dev/null @@ -1,24 +0,0 @@ -import mongoose from 'mongoose'; -import validator from 'validator'; -import baseModel from '../libs/api-v3/baseModel'; - -// A collection used to store mailing list unsubscription for non registered email addresses -export let schema = new mongoose.Schema({ - email: { - type: String, - required: true, - trim: true, - lowercase: true, - validator: [validator.isEmail, 'Invalid email.'], - }, -}, { - strict: true, - minimize: false, // So empty objects are returned -}); - -schema.plugin(baseModel, { - noSet: ['_id'], - timestamps: true, -}); - -export let model = mongoose.model('EmailUnsubscription', schema); diff --git a/website/server/models/group.js b/website/server/models/group.js deleted file mode 100644 index 53305d94b9..0000000000 --- a/website/server/models/group.js +++ /dev/null @@ -1,760 +0,0 @@ -import mongoose from 'mongoose'; -import { - model as User, - nameFields, -} from './user'; -import shared from '../../../common'; -import _ from 'lodash'; -import { model as Challenge} from './challenge'; -import validator from 'validator'; -import { removeFromArray } from '../libs/api-v3/collectionManipulators'; -import { - InternalServerError, - BadRequest, -} from '../libs/api-v3/errors'; -import * as firebase from '../libs/api-v2/firebase'; -import baseModel from '../libs/api-v3/baseModel'; -import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; -import Bluebird from 'bluebird'; -import nconf from 'nconf'; -import sendPushNotification from '../libs/api-v3/pushNotifications'; - -const questScrolls = shared.content.quests; -const Schema = mongoose.Schema; - -export const INVITES_LIMIT = 100; -export const TAVERN_ID = shared.TAVERN_ID; - -// NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API -// changes made directly to the db will cause Firebase to get out of sync -export let schema = new Schema({ - name: {type: String, required: true}, - description: String, - leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, - type: {type: String, enum: ['guild', 'party'], required: true}, - privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true}, - chat: Array, - /* - # [{ - # timestamp: Date - # user: String - # text: String - # contributor: String - # uuid: String - # id: String - # }] - */ - leaderOnly: { // restrict group actions to leader (members can't do them) - challenges: {type: Boolean, default: false, required: true}, - // invites: {type: Boolean, default: false, required: true}, - }, - memberCount: {type: Number, default: 1}, - challengeCount: {type: Number, default: 0}, - balance: {type: Number, default: 0}, - logo: String, - leaderMessage: String, - quest: { - key: String, - active: {type: Boolean, default: false}, - leader: {type: String, ref: 'User'}, - progress: { - hp: Number, - collect: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, // {feather: 5, ingot: 3} - rage: Number, // limit break / "energy stored in shell", for explosion-attacks - }, - - // Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click - // 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. - // TODO when booting user, remove from .joined and check again if we can now start the quest - members: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - extra: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - }, -}, { - strict: true, - minimize: false, // So empty objects are returned -}); - -schema.plugin(baseModel, { - noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount'], -}); - -// A list of additional fields that cannot be updated (but can be set on creation) -let noUpdate = ['privacy', 'type']; -schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { - return this.sanitize(updateObj, noUpdate); -}; - -// Basic fields to fetch for populating a group info -export let basicFields = 'name type privacy'; - -schema.pre('remove', true, async function preRemoveGroup (next, done) { - next(); - try { - await this.removeGroupInvitations(); - done(); - } catch (err) { - done(err); - } -}); - -schema.post('remove', function postRemoveGroup (group) { - firebase.deleteGroup(group._id); -}); - -schema.statics.getGroup = async function getGroup (options = {}) { - let {user, groupId, fields, optionalMembership = false, populateLeader = false, requireMembership = false} = options; - let query; - - let isUserParty = groupId === 'party' || user.party._id === groupId; - let isUserGuild = user.guilds.indexOf(groupId) !== -1; - let isTavern = ['habitrpg', TAVERN_ID].indexOf(groupId) !== -1; - - // When requireMembership is true check that user is member even in public guild - if (requireMembership && !isUserParty && !isUserGuild && !isTavern) { - return null; - } - - // When optionalMembership is true it's not required for the user to be a member of the group - if (isUserParty) { - query = {type: 'party', _id: user.party._id}; - } else if (isTavern) { - query = {_id: TAVERN_ID}; - } else if (optionalMembership === true) { - query = {_id: groupId}; - } else if (isUserGuild) { - query = {type: 'guild', _id: groupId}; - } else { - query = {type: 'guild', privacy: 'public', _id: groupId}; - } - - let mQuery = this.findOne(query); - if (fields) mQuery.select(fields); - if (populateLeader === true) mQuery.populate('leader', nameFields); - let group = await mQuery.exec(); - return group; -}; - -export const VALID_QUERY_TYPES = ['party', 'guilds', 'privateGuilds', 'publicGuilds', 'tavern']; - -schema.statics.getGroups = async function getGroups (options = {}) { - let {user, types, groupFields = basicFields, sort = '-memberCount', populateLeader = false} = options; - let queries = []; - - // Throw error if an invalid type is supplied - let areValidTypes = types.every(type => VALID_QUERY_TYPES.indexOf(type) !== -1); - if (!areValidTypes) throw new BadRequest(shared.i18n.t('groupTypesRequired')); - - types.forEach(type => { - switch (type) { - case 'party': { - queries.push(this.getGroup({user, groupId: 'party', fields: groupFields, populateLeader})); - break; - } - case 'guilds': { - let userGuildsQuery = this.find({ - type: 'guild', - _id: {$in: user.guilds}, - }).select(groupFields); - if (populateLeader === true) userGuildsQuery.populate('leader', nameFields); - userGuildsQuery.sort(sort).exec(); - queries.push(userGuildsQuery); - break; - } - case 'privateGuilds': { - let privateGuildsQuery = this.find({ - type: 'guild', - privacy: 'private', - _id: {$in: user.guilds}, - }).select(groupFields); - if (populateLeader === true) privateGuildsQuery.populate('leader', nameFields); - privateGuildsQuery.sort(sort).exec(); - queries.push(privateGuildsQuery); - break; - } - // NOTE: when returning publicGuilds we use `.lean()` so all mongoose methods won't be available. - // Docs are going to be plain javascript objects - case 'publicGuilds': { - let publicGuildsQuery = this.find({ - type: 'guild', - privacy: 'public', - }).select(groupFields); - if (populateLeader === true) publicGuildsQuery.populate('leader', nameFields); - publicGuildsQuery.sort(sort).lean().exec(); - queries.push(publicGuildsQuery); - break; - } - case 'tavern': { - if (types.indexOf('publicGuilds') === -1) { - queries.push(this.getGroup({user, groupId: TAVERN_ID, fields: groupFields})); - } - break; - } - } - }); - - let groupsArray = _.reduce(await Bluebird.all(queries), (previousValue, currentValue) => { - if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array - return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue - }, []); - - return groupsArray; -}; - -// When converting to json remove chat messages with more than 1 flag and remove all flags info -// unless the user is an admin -// Not putting into toJSON because there we can't access user -schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) { - let toJSON = group.toJSON(); - if (!user.contributor.admin) { - _.remove(toJSON.chat, chatMsg => { - chatMsg.flags = {}; - return chatMsg.flagCount >= 2; - }); - } - return toJSON; -}; - -schema.methods.removeGroupInvitations = async function removeGroupInvitations () { - let group = this; - - let usersToRemoveInvitationsFrom = await User.find({ - [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id, - }).exec(); - - let userUpdates = usersToRemoveInvitationsFrom.map(user => { - if (group.type === 'party') { - user.invitations.party = {}; - this.markModified('invitations.party'); - } else { - removeFromArray(user.invitations.guilds, { id: group._id }); - } - return user.save(); - }); - - return Bluebird.all(userUpdates); -}; - -// Return true if user is a member of the group -schema.methods.isMember = function isGroupMember (user) { - if (this._id === TAVERN_ID) { - return true; // everyone is considered part of the tavern - } else if (this.type === 'party') { - return user.party._id === this._id ? true : false; - } else { // guilds - return user.guilds.indexOf(this._id) !== -1; - } -}; - -export function chatDefaults (msg, user) { - let message = { - id: shared.uuid(), - text: msg, - timestamp: Number(new Date()), - likes: {}, - flags: {}, - flagCount: 0, - }; - - if (user) { - _.defaults(message, { - uuid: user._id, - contributor: user.contributor && user.contributor.toObject(), - backer: user.backer && user.backer.toObject(), - user: user.profile.name, - }); - } else { - message.uuid = 'system'; - } - - return message; -} - -const NO_CHAT_NOTIFICATIONS = [TAVERN_ID]; -schema.methods.sendChat = function sendChat (message, user) { - this.chat.unshift(chatDefaults(message, user)); - this.chat.splice(200); - - // Kick off chat notifications in the background. - let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; - lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; - - // do not send notifications for guilds with more than 5000 users and for the tavern - if (NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1 || this.memberCount > 5000) { - // TODO For Tavern, only notify them if their name was mentioned - // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? - // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); - } else { - let query = {}; - - if (this.type === 'party') { - query['party._id'] = this._id; - } else { - query.guilds = this._id; - } - - query._id = { $ne: user ? user._id : ''}; - - User.update(query, lastSeenUpdate, {multi: true}).exec(); - } -}; - -schema.methods.startQuest = async function startQuest (user) { - // not using i18n strings because these errors are meant for devs who forgot to pass some parameters - if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); - if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest'); - if (this.quest.active) throw new InternalServerError('Quest is already active'); - - let userIsParticipating = this.quest.members[user._id]; - let quest = questScrolls[this.quest.key]; - let collected = {}; - if (quest.collect) { - collected = _.transform(quest.collect, (result, n, itemToCollect) => { - result[itemToCollect] = 0; - }); - } - - this.markModified('quest'); - this.quest.active = true; - if (quest.boss) { - this.quest.progress.hp = quest.boss.hp; - if (quest.boss.rage) this.quest.progress.rage = 0; - } else if (quest.collect) { - this.quest.progress.collect = collected; - } - - // Changes quest.members to only include participating members - // TODO: is that important? What does it matter if the non-participating members - // are still on the object? - // TODO: is it important to run clean quest progress on non-members like we did in v2? - this.quest.members = _.pick(this.quest.members, _.identity); - let nonUserQuestMembers = _.keys(this.quest.members); - removeFromArray(nonUserQuestMembers, user._id); - - if (userIsParticipating) { - user.party.quest.key = this.quest.key; - user.party.quest.progress.down = 0; - user.party.quest.progress.collect = collected; - user.party.quest.completed = null; - user.markModified('party.quest'); - } - - // Remove the quest from the quest leader items (if they are the current user) - if (this.quest.leader === user._id) { - user.items.quests[this.quest.key] -= 1; - user.markModified('items.quests'); - } else { // another user is starting the quest, update the leader separately - await User.update({_id: this.quest.leader}, { - $inc: { - [`items.quests.${this.quest.key}`]: -1, - }, - }).exec(); - } - - // update the remaining users - await User.update({ - _id: { $in: nonUserQuestMembers }, - }, { - $set: { - 'party.quest.key': this.quest.key, - 'party.quest.progress.down': 0, - 'party.quest.progress.collect': collected, - 'party.quest.completed': null, - }, - }, { multi: true }).exec(); - - // send notifications in the background without blocking - User.find( - { _id: { $in: nonUserQuestMembers } }, - 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications pushDevices profile.name' - ).exec().then((membersToNotify) => { - let membersToEmail = _.filter(membersToNotify, (member) => { - // send push notifications and filter users that disabled emails - sendPushNotification(member, 'HabitRPG', `${shared.i18n.t('questStarted')}: ${quest.text()}`); - - return member.preferences.emailNotifications.questStarted !== false && - member._id !== user._id; - }); - sendTxnEmail(membersToEmail, 'quest-started', [ - { name: 'PARTY_URL', content: '/#/options/groups/party' }, - ]); - }); -}; - -// return a clean object for user.quest -function _cleanQuestProgress (merge) { - let clean = { - key: null, - progress: { - up: 0, - down: 0, - collect: {}, - }, - completed: null, - RSVPNeeded: false, - }; - - if (merge) { - _.merge(clean, _.omit(merge, 'progress')); - if (merge.progress) _.merge(clean.progress, merge.progress); - } - - return clean; -} - -schema.statics.cleanQuestProgress = _cleanQuestProgress; - -// returns a clean object for group.quest -schema.statics.cleanGroupQuest = function cleanGroupQuest () { - return { - key: null, - active: false, - leader: null, - progress: { - collect: {}, - }, - members: {}, - }; -}; - -// Participants: Grant rewards & achievements, finish quest -// Returns the promise from update().exec() -schema.methods.finishQuest = function finishQuest (quest) { - let questK = quest.key; - let updates = {$inc: {}, $set: {}}; - - updates.$inc[`achievements.quests.${questK}`] = 1; - updates.$inc['stats.gp'] = Number(quest.drop.gp); - updates.$inc['stats.exp'] = Number(quest.drop.exp); - updates.$inc._v = 1; - - if (this._id === TAVERN_ID) { - updates.$set['party.quest.completed'] = questK; // Just show the notif - } else { - updates.$set['party.quest'] = _cleanQuestProgress({completed: questK}); // clear quest progress - } - - _.each(quest.drop.items, (item) => { - let dropK = item.key; - - switch (item.type) { - case 'gear': { - // TODO This means they can lose their new gear on death, is that what we want? - updates.$set[`items.gear.owned.${dropK}`] = true; - break; - } - case 'eggs': - case 'food': - case 'hatchingPotions': - case 'quests': { - updates.$inc[`items.${item.type}.${dropK}`] = _.where(quest.drop.items, {type: item.type, key: item.key}).length; - break; - } - case 'pets': { - updates.$set[`items.pets.${dropK}`] = 5; - break; - } - case 'mounts': { - updates.$set[`items.mounts.${dropK}`] = true; - break; - } - } - }); - - let q = this._id === TAVERN_ID ? {} : {_id: {$in: _.keys(this.quest.members)}}; - this.quest = {}; - this.markModified('quest'); - return User.update(q, updates, {multi: true}).exec(); -}; - -function _isOnQuest (user, progress, group) { - return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; -} - -// Returns a promise -schema.statics.collectQuest = async function collectQuest (user, progress) { - let group = await this.getGroup({user, groupId: 'party'}); - if (!_isOnQuest(user, progress, group)) return; - let quest = shared.content.quests[group.quest.key]; - - _.each(progress.collect, (v, k) => { - group.quest.progress.collect[k] += v; - }); - - let foundText = _.reduce(progress.collect, (m, v, k) => { - m.push(`${v} ${quest.collect[k].text('en')}`); - return m; - }, []); - - foundText = foundText ? foundText.join(', ') : 'nothing'; - group.sendChat(`\`${user.profile.name} found ${foundText}.\``); - group.markModified('quest.progress.collect'); - - // Still needs completing - if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { - return group.quest.progress.collect[k] < v.count; - })) return group.save(); - - await group.finishQuest(quest); - group.sendChat('`All items found! Party has received their rewards.`'); - return group.save(); -}; - -schema.statics.bossQuest = async function bossQuest (user, progress) { - let group = await this.getGroup({user, groupId: 'party'}); - if (!_isOnQuest(user, progress, group)) return; - - let quest = shared.content.quests[group.quest.key]; - if (!progress || !quest) return; // TODO why is this ever happening, progress should be defined at this point, log? - - let down = progress.down * quest.boss.str; // multiply by boss strength - - group.quest.progress.hp -= progress.up; - // TODO Create a party preferred language option so emits like this can be localized. Suggestion: Always display the English version too. Or, if English is not displayed to the players, at least include it in a new field in the chat object that's visible in the database - essential for admins when troubleshooting quests! - let playerAttack = `${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage.`; - let bossAttack = nconf.get('CRON_SAFE_MODE') === 'true' ? `${quest.boss.name('en')} did not attack the party because it was asleep while maintenance was happening.` : `${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.`; - group.sendChat(`\`${playerAttack}\` \`${bossAttack}\``); - - // If boss has Rage, increment Rage as well - if (quest.boss.rage) { - group.quest.progress.rage += Math.abs(down); - if (group.quest.progress.rage >= quest.boss.rage.value) { - group.sendChat(quest.boss.rage.effect('en')); - group.quest.progress.rage = 0; - - // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage - if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing; - if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; - } - } - - // Everyone takes damage - await User.update({ - _id: {$in: _.keys(group.quest.members)}, - }, { - $inc: {'stats.hp': down, _v: 1}, - }, {multi: true}).exec(); - // Apply changes the currently cronning user locally so we don't have to reload it to get the updated state - // TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167 - // must be notModified or otherwise could overwrite future changes: if the user is saved it'll save - // the modified user.stats.hp but that must not happen as the hp value has already been updated by the User.update above - // if (down) user.stats.hp += down; - - // Boss slain, finish quest - if (group.quest.progress.hp <= 0) { - group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); - - // Participants: Grant rewards & achievements, finish quest - await group.finishQuest(shared.content.quests[group.quest.key]); - return group.save(); - } - - return group.save(); -}; - -// to set a boss: `db.groups.update({_id:TAVERN_ID},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` -// we export an empty object that is then populated with the query-returned data -export let tavernQuest = {}; -let tavernQ = {_id: TAVERN_ID, 'quest.key': {$ne: null}}; - -// we use process.nextTick because at this point the model is not yet available -process.nextTick(() => { - model // eslint-disable-line no-use-before-define - .findOne(tavernQ).exec() - .then(tavern => { - if (!tavern) return; // No tavern quest - - // Using _assign so we don't lose the reference to the exported tavernQuest - _.assign(tavernQuest, tavern.quest.toObject()); - }) - .catch(err => { - throw err; - }); -}); - -// returns a promise -schema.statics.tavernBoss = async function tavernBoss (user, progress) { - if (!progress) return; - - // hack: prevent crazy damage to world boss - let dmg = Math.min(900, Math.abs(progress.up || 0)); - let rage = -Math.min(900, Math.abs(progress.down || 0)); - - let tavern = await this.findOne(tavernQ).exec(); - if (!(tavern && tavern.quest && tavern.quest.key)) return; - - let quest = shared.content.quests[tavern.quest.key]; - - if (tavern.quest.progress.hp <= 0) { - tavern.sendChat(quest.completionChat('en')); - await tavern.finishQuest(quest); - _.assign(tavernQuest, {extra: null}); - return tavern.save(); - } else { - // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, - // use those first - which allows us to update the boss on the go if things are too easy/hard. - if (!tavern.quest.extra) tavern.quest.extra = {}; - tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); - tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); - - if (tavern.quest.progress.rage >= quest.boss.rage.value) { - if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; - - let wd = tavern.quest.extra.worldDmg; - // Burnout attacks Ian, Seasonal Sorceress, tavern - // Be-Wilder attacks Alex, Matt, Bailey - let scene = wd.market ? wd.stables ? wd.bailey ? false : 'bailey' : 'stables' : 'market'; // eslint-disable-line no-nested-ternary - - if (!scene) { - tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); - tavern.quest.progress.rage = 0; // quest.boss.rage.value; - } else { - tavern.sendChat(quest.boss.rage[scene]('en')); - tavern.quest.extra.worldDmg[scene] = true; - tavern.quest.extra.worldDmg.recent = scene; - tavern.markModified('quest.extra.worldDmg'); - tavern.quest.progress.rage = 0; - if (quest.boss.rage.healing) { - tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp; - } - } - } - - if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { - tavern.sendChat(quest.boss.desperation.text('en')); - tavern.quest.extra.desperate = true; - tavern.quest.extra.def = quest.boss.desperation.def; - tavern.quest.extra.str = quest.boss.desperation.str; - tavern.markModified('quest.extra'); - } - - _.assign(tavernQuest, tavern.quest.toObject()); - return tavern.save(); - } -}; - -schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { - let group = this; - - let challenges = await Challenge.find({ - _id: {$in: user.challenges}, - group: group._id, - }); - - let challengesToRemoveUserFrom = challenges.map(chal => { - return chal.unlinkTasks(user, keep); - }); - await Bluebird.all(challengesToRemoveUserFrom); - - let promises = []; - - // remove the group from the user's groups - if (group.type === 'guild') { - promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id}}).exec()); - } else { - promises.push(User.update({_id: user._id}, {$set: {party: {}}}).exec()); - } - - // If user is the last one in group and group is private, delete it - if (group.memberCount <= 1 && group.privacy === 'private') { - promises.push(group.remove()); - } else { // otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for) - let update = { - $inc: {memberCount: -1}, - }; - - if (group.leader === user._id) { - let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id}; - query._id = {$ne: user._id}; - let seniorMember = await User.findOne(query).select('_id').exec(); - - // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving - if (seniorMember) update.$set = {leader: seniorMember._id}; - } - promises.push(group.update(update).exec()); - } - - firebase.removeUserFromGroup(group._id, user._id); - - return await Bluebird.all(promises); -}; - -// API v2 compatibility methods -schema.methods.getTransformedData = function getTransformedData (options) { - let cb = options.cb; - let populateMembers = options.populateMembers; - let populateInvites = options.populateInvites; - let populateChallenges = options.populateChallenges; - - let obj = this.toJSON(); - - let queryMembers = {}; - let queryInvites = {}; - - if (this.type === 'guild') { - queryInvites['invitations.guilds.id'] = this._id; - } else { - queryInvites['invitations.party.id'] = this._id; - } - - if (this.type === 'guild') { - queryMembers.guilds = this._id; - } else { - queryMembers['party._id'] = this._id; - } - - let selectDataMembers = '_id'; - let selectDataInvites = '_id'; - let selectDataChallenges = '_id'; - - if (populateMembers) { - selectDataMembers += ` ${populateMembers}`; - } - if (populateInvites) { - selectDataInvites += ` ${populateInvites}`; - } - if (populateChallenges) { - selectDataChallenges += ` ${populateChallenges}`; - } - - let membersQuery = User.find(queryMembers).select(selectDataMembers); - if (options.limitPopulation) membersQuery.limit(15); - - Bluebird.all([ - membersQuery.exec(), - User.find(queryInvites).select(populateInvites).exec(), - Challenge.find({group: obj._id}).select(populateMembers).exec(), - ]) - .then((results) => { - obj.members = results[0]; - obj.invites = results[1]; - obj.challenges = results[2]; - - cb(null, obj); - }) - .catch(cb); -}; -// END API v2 compatibility methods - -export let model = mongoose.model('Group', schema); - -// initialize tavern if !exists (fresh installs) -// do not run when testing as it's handled by the tests and can easily cause a race condition -if (!nconf.get('IS_TEST')) { - model.count({_id: TAVERN_ID}, (err, ct) => { - if (err) throw err; - if (ct > 0) return; - new model({ // eslint-disable-line babel/new-cap - _id: TAVERN_ID, - leader: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', // Siena Leslie - name: 'Tavern', - type: 'guild', - privacy: 'public', - }).save(); - }); -} diff --git a/website/server/models/tag.js b/website/server/models/tag.js deleted file mode 100644 index f201541540..0000000000 --- a/website/server/models/tag.js +++ /dev/null @@ -1,27 +0,0 @@ -import mongoose from 'mongoose'; -import baseModel from '../libs/api-v3/baseModel'; -import { v4 as uuid } from 'uuid'; -import validator from 'validator'; - -let Schema = mongoose.Schema; - -export let schema = new Schema({ - id: { - type: String, - default: uuid, - validate: [validator.isUUID, 'Invalid uuid.'], - }, - name: {type: String, required: true}, - challenge: {type: String}, -}, { - strict: true, - minimize: false, // So empty objects are returned - _id: false, // use id instead of _id -}); - -schema.plugin(baseModel, { - noSet: ['_id', 'id', 'challenge'], - _id: false, // use id instead of _id -}); - -export let model = mongoose.model('Tag', schema); diff --git a/website/server/models/task.js b/website/server/models/task.js deleted file mode 100644 index 536d95ec0c..0000000000 --- a/website/server/models/task.js +++ /dev/null @@ -1,222 +0,0 @@ -import mongoose from 'mongoose'; -import shared from '../../../common'; -import validator from 'validator'; -import moment from 'moment'; -import baseModel from '../libs/api-v3/baseModel'; -import _ from 'lodash'; -import { preenHistory } from '../libs/api-v3/preening'; - -let Schema = mongoose.Schema; -let discriminatorOptions = { - discriminatorKey: 'type', // the key that distinguishes task types -}; -let subDiscriminatorOptions = _.defaults(_.cloneDeep(discriminatorOptions), {_id: false}); - -export let tasksTypes = ['habit', 'daily', 'todo', 'reward']; - -// Important -// When something changes here remember to update the client side model at common/script/libs/taskDefaults -export let TaskSchema = new Schema({ - _legacyId: String, // TODO Remove when v2 is deprecated - type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]}, - text: {type: String, required: true}, - notes: {type: String, default: ''}, - tags: [{ - type: String, - validate: [validator.isUUID, 'Invalid uuid.'], - }], - value: {type: Number, default: 0, required: true}, // redness or cost for rewards Required because it must be settable (for rewards) - priority: { - type: Number, - default: 1, - required: true, - validate: [ - (val) => [0.1, 1, 1.5, 2].indexOf(val) !== -1, - 'Valid priority values are 0.1, 1, 1.5, 2.', - ], - }, - attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, - userId: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set it belongs to a challenge - - challenge: { - id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task - taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task - broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED', 'CHALLENGE_TASK_NOT_FOUND']}, // CHALLENGE_TASK_NOT_FOUND comes from v3 migration - winner: String, // user.profile.name of the winner - }, - - reminders: [{ - _id: false, - id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], default: shared.uuid, required: true}, - startDate: {type: Date}, - time: {type: Date, required: true}, - }], -}, _.defaults({ - minimize: true, // So empty objects are returned - strict: true, -}, discriminatorOptions)); - -TaskSchema.plugin(baseModel, { - noSet: ['challenge', 'userId', 'completed', 'history', 'dateCompleted', '_legacyId'], - sanitizeTransform (taskObj) { - if (taskObj.type && taskObj.type !== 'reward') { // value should be settable directly only for rewards - delete taskObj.value; - } - - return taskObj; - }, - private: [], - timestamps: true, -}); - -// Sanitize user tasks linked to a challenge -// See http://habitica.wikia.com/wiki/Challenges#Challenge_Participant.27s_Permissions for more info -TaskSchema.statics.sanitizeUserChallengeTask = function sanitizeUserChallengeTask (taskObj) { - let initialSanitization = this.sanitize(taskObj); - - return _.pick(initialSanitization, ['streak', 'checklist', 'attribute', 'reminders', 'tags', 'notes']); -}; - -// Sanitize checklist objects (disallowing id) -TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) { - delete checklistObj.id; - return checklistObj; -}; - -// Sanitize reminder objects (disallowing id) -TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) { - delete reminderObj.id; - return reminderObj; -}; - -TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta) { - let chalTask = this; - - chalTask.value += delta; - - if (chalTask.type === 'habit' || chalTask.type === 'daily') { - // Add only one history entry per day - let lastChallengHistoryIndex = chalTask.history.length - 1; - - if (chalTask.history[lastChallengHistoryIndex] && - moment(chalTask.history[lastChallengHistoryIndex].date).isSame(new Date(), 'day')) { - chalTask.history[lastChallengHistoryIndex] = { - date: Number(new Date()), - value: chalTask.value, - }; - chalTask.markModified(`history.${lastChallengHistoryIndex}`); - } else { - chalTask.history.push({ - date: Number(new Date()), - value: chalTask.value, - }); - - // Only preen task history once a day when the task is scored first - if (chalTask.history.length > 365) { - chalTask.history = preenHistory(chalTask.history, true); // true means the challenge will retain as much entries as a subscribed user - } - } - } - - await chalTask.save(); -}; - - -// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model) -// These will be removed once API v2 is discontinued - -// toJSON for API v2 -TaskSchema.methods.toJSONV2 = function toJSONV2 () { - let toJSON = this.toJSON(); - if (toJSON._legacyId) { - toJSON.id = toJSON._legacyId; - } else { - toJSON.id = toJSON._id; - } - - if (!toJSON.challenge) toJSON.challenge = {}; - - let v3Tags = this.tags; - - toJSON.tags = {}; - v3Tags.forEach(tag => { - toJSON.tags[tag] = true; - }); - - toJSON.dateCreated = this.createdAt; - - return toJSON; -}; - -TaskSchema.statics.fromJSONV2 = function fromJSONV2 (taskObj) { - if (taskObj.id) taskObj._id = taskObj.id; - - let v2Tags = taskObj.tags || {}; - - taskObj.tags = []; - taskObj.tags = _.map(v2Tags, (tag, key) => key); - - return taskObj; -}; - -// END of API v2 methods - -export let Task = mongoose.model('Task', TaskSchema); - -// habits and dailies shared fields -let habitDailySchema = () => { - return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems -}; - -// dailys and todos shared fields -let dailyTodoSchema = () => { - return { - completed: {type: Boolean, default: false}, - // Checklist fields (dailies and todos) - collapseChecklist: {type: Boolean, default: false}, - checklist: [{ - completed: {type: Boolean, default: false}, - text: {type: String, required: false, default: ''}, // required:false because it can be empty on creation - _id: false, - id: {type: String, default: shared.uuid, validate: [validator.isUUID, 'Invalid uuid.']}, - }], - }; -}; - -export let HabitSchema = new Schema(_.defaults({ - up: {type: Boolean, default: true}, - down: {type: Boolean, default: true}, -}, habitDailySchema()), subDiscriminatorOptions); -export let habit = Task.discriminator('habit', HabitSchema); - -export let DailySchema = new Schema(_.defaults({ - frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']}, - everyX: {type: Number, default: 1}, // e.g. once every X weeks - startDate: { - type: Date, - default () { - return moment().startOf('day').toDate(); - }, - }, - repeat: { // used only for 'weekly' frequency, - m: {type: Boolean, default: true}, - t: {type: Boolean, default: true}, - w: {type: Boolean, default: true}, - th: {type: Boolean, default: true}, - f: {type: Boolean, default: true}, - s: {type: Boolean, default: true}, - su: {type: Boolean, default: true}, - }, - streak: {type: Number, default: 0}, -}, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions); -export let daily = Task.discriminator('daily', DailySchema); - -export let TodoSchema = new Schema(_.defaults({ - dateCompleted: Date, - // TODO we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date see http://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript - date: String, // due date for todos -}, dailyTodoSchema()), subDiscriminatorOptions); -export let todo = Task.discriminator('todo', TodoSchema); - -export let RewardSchema = new Schema({}, subDiscriminatorOptions); -export let reward = Task.discriminator('reward', RewardSchema); diff --git a/website/server/models/user.js b/website/server/models/user.js deleted file mode 100644 index 2d0e561a66..0000000000 --- a/website/server/models/user.js +++ /dev/null @@ -1,824 +0,0 @@ -import mongoose from 'mongoose'; -import shared from '../../../common'; -import _ from 'lodash'; -import validator from 'validator'; -import moment from 'moment'; -import * as Tasks from './task'; -import Bluebird from 'bluebird'; -import { schema as TagSchema } from './tag'; -import baseModel from '../libs/api-v3/baseModel'; -import { - chatDefaults, - TAVERN_ID, -} from './group'; -import { defaults } from 'lodash'; - -let Schema = mongoose.Schema; - -// User schema definition -export let schema = new Schema({ - apiToken: { - type: String, - default: shared.uuid, - }, - - auth: { - blocked: Boolean, - facebook: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - local: { - email: { - type: String, - validate: [validator.isEmail, shared.i18n.t('invalidEmail')], - }, - username: { - type: String, - }, - // Store a lowercase version of username to check for duplicates - lowerCaseUsername: String, - hashed_password: String, // eslint-disable-line camelcase - salt: String, - }, - timestamps: { - created: {type: Date, default: Date.now}, - loggedin: {type: Date, default: Date.now}, - }, - }, - // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which - // have been updated (http://goo.gl/gQLz41), but we want *every* update - _v: { type: Number, default: 0 }, - migration: String, - achievements: { - originalUser: Boolean, - habitSurveys: Number, - ultimateGearSets: { - healer: {type: Boolean, default: false}, - wizard: {type: Boolean, default: false}, - rogue: {type: Boolean, default: false}, - warrior: {type: Boolean, default: false}, - }, - beastMaster: Boolean, - beastMasterCount: Number, - mountMaster: Boolean, - mountMasterCount: Number, - triadBingo: Boolean, - triadBingoCount: Number, - veteran: Boolean, - snowball: Number, - spookySparkles: Number, - shinySeed: Number, - seafoam: Number, - streak: Number, - challenges: Array, - quests: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - rebirths: Number, - rebirthLevel: Number, - perfect: {type: Number, default: 0}, - habitBirthdays: Number, - valentine: Number, - costumeContest: Boolean, // Superseded by costumeContests - nye: Number, - habiticaDays: Number, - greeting: Number, - thankyou: Number, - costumeContests: Number, - birthday: Number, - partyUp: Boolean, - partyOn: Boolean, - }, - - backer: { - tier: Number, - npc: String, - tokensApplied: Boolean, - }, - - contributor: { - // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801 - level: { - type: Number, - min: 0, - max: 9, - }, - admin: Boolean, - sudo: Boolean, - // Artisan, Friend, Blacksmith, etc - text: String, - // a markdown textarea to list their contributions + links - contributions: String, - critical: String, - }, - - balance: {type: Number, default: 0}, - // Not saved on the user right now - filters: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - - purchased: { - ads: {type: Boolean, default: false}, - // eg, {skeleton: true, pumpkin: true, eb052b: true} - skin: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - hair: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - shirt: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - background: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - txnCount: {type: Number, default: 0}, - mobileChat: Boolean, - plan: { - planId: String, - paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} - customerId: String, // Billing Agreement Id in case of Amazon Payments - dateCreated: Date, - dateTerminated: Date, - dateUpdated: Date, - extraMonths: {type: Number, default: 0}, - gemsBought: {type: Number, default: 0}, - mysteryItems: {type: Array, default: () => []}, - lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date - consecutive: { - count: {type: Number, default: 0}, - offset: {type: Number, default: 0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 - gemCapExtra: {type: Number, default: 0}, - trinkets: {type: Number, default: 0}, - }, - }, - }, - - flags: { - customizationsNotification: {type: Boolean, default: false}, - showTour: {type: Boolean, default: true}, - tour: { - // -1 indicates "uninitiated", -2 means "complete", any other number is the current tour step (0-index) - intro: {type: Number, default: -1}, - classes: {type: Number, default: -1}, - stats: {type: Number, default: -1}, - tavern: {type: Number, default: -1}, - party: {type: Number, default: -1}, - guilds: {type: Number, default: -1}, - challenges: {type: Number, default: -1}, - market: {type: Number, default: -1}, - pets: {type: Number, default: -1}, - mounts: {type: Number, default: -1}, - hall: {type: Number, default: -1}, - equipment: {type: Number, default: -1}, - }, - tutorial: { - common: { - habits: {type: Boolean, default: false}, - dailies: {type: Boolean, default: false}, - todos: {type: Boolean, default: false}, - rewards: {type: Boolean, default: false}, - party: {type: Boolean, default: false}, - pets: {type: Boolean, default: false}, - gems: {type: Boolean, default: false}, - skills: {type: Boolean, default: false}, - classes: {type: Boolean, default: false}, - tavern: {type: Boolean, default: false}, - equipment: {type: Boolean, default: false}, - items: {type: Boolean, default: false}, - }, - ios: { - addTask: {type: Boolean, default: false}, - editTask: {type: Boolean, default: false}, - deleteTask: {type: Boolean, default: false}, - filterTask: {type: Boolean, default: false}, - groupPets: {type: Boolean, default: false}, - inviteParty: {type: Boolean, default: false}, - }, - }, - dropsEnabled: {type: Boolean, default: false}, - itemsEnabled: {type: Boolean, default: false}, - newStuff: {type: Boolean, default: false}, - rewrite: {type: Boolean, default: true}, - contributor: Boolean, - classSelected: {type: Boolean, default: false}, - mathUpdates: Boolean, - rebirthEnabled: {type: Boolean, default: false}, - levelDrops: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - chatRevoked: Boolean, - // Used to track the status of recapture emails sent to each user, - // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user - recaptureEmailsPhase: {type: Number, default: 0}, - // Needed to track the tip to send inside the email - weeklyRecapEmailsPhase: {type: Number, default: 0}, - // Used to track when the next weekly recap should be sent - lastWeeklyRecap: {type: Date, default: Date.now}, - // Used to enable weekly recap emails as users login - lastWeeklyRecapDiscriminator: Boolean, - communityGuidelinesAccepted: {type: Boolean, default: false}, - cronCount: {type: Number, default: 0}, - welcomed: {type: Boolean, default: false}, - armoireEnabled: {type: Boolean, default: false}, - armoireOpened: {type: Boolean, default: false}, - armoireEmpty: {type: Boolean, default: false}, - cardReceived: {type: Boolean, default: false}, - warnedLowHealth: {type: Boolean, default: false}, - }, - - history: { - exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined - todos: Array, // [{data: Date, value: Number}] // big peformance issues if these are defined - }, - - items: { - gear: { - owned: _.transform(shared.content.gear.flat, (m, v) => { - m[v.key] = {type: Boolean}; - if (v.key.match(/[armor|head|shield]_warrior_0/) || v.gearSet === 'glasses') { - m[v.key].default = true; - } - }), - - equipped: { - weapon: String, - armor: {type: String, default: 'armor_base_0'}, - head: {type: String, default: 'head_base_0'}, - shield: {type: String, default: 'shield_base_0'}, - back: String, - headAccessory: String, - eyewear: String, - body: String, - }, - costume: { - weapon: String, - armor: {type: String, default: 'armor_base_0'}, - head: {type: String, default: 'head_base_0'}, - shield: {type: String, default: 'shield_base_0'}, - back: String, - headAccessory: String, - eyewear: String, - body: String, - }, - }, - - special: { - snowball: {type: Number, default: 0}, - spookySparkles: {type: Number, default: 0}, - shinySeed: {type: Number, default: 0}, - seafoam: {type: Number, default: 0}, - valentine: {type: Number, default: 0}, - valentineReceived: Array, // array of strings, by sender name - nye: {type: Number, default: 0}, - nyeReceived: Array, - greeting: {type: Number, default: 0}, - greetingReceived: Array, - thankyou: {type: Number, default: 0}, - thankyouReceived: Array, - birthday: {type: Number, default: 0}, - birthdayReceived: Array, - }, - - // -------------- Animals ------------------- - // Complex bit here. The result looks like: - // pets: { - // 'Wolf-Desert': 0, // 0 means does not own - // 'PandaCub-Red': 10, // Number represents "Growth Points" - // etc... - // } - pets: _.defaults( - // First transform to a 1D eggs/potions mapping - _.transform(shared.content.pets, (m, v, k) => m[k] = Number), - // Then add additional pets (quest, backer, contributor, premium) - _.transform(shared.content.questPets, (m, v, k) => m[k] = Number), - _.transform(shared.content.specialPets, (m, v, k) => m[k] = Number), - _.transform(shared.content.premiumPets, (m, v, k) => m[k] = Number) - ), - currentPet: String, // Cactus-Desert - - // eggs: { - // 'PandaCub': 0, // 0 indicates "doesn't own" - // 'Wolf': 5 // Number indicates "stacking" - // } - eggs: _.transform(shared.content.eggs, (m, v, k) => m[k] = Number), - - // hatchingPotions: { - // 'Desert': 0, // 0 indicates "doesn't own" - // 'CottonCandyBlue': 5 // Number indicates "stacking" - // } - hatchingPotions: _.transform(shared.content.hatchingPotions, (m, v, k) => m[k] = Number), - - // Food: { - // 'Watermelon': 0, // 0 indicates "doesn't own" - // 'RottenMeat': 5 // Number indicates "stacking" - // } - food: _.transform(shared.content.food, (m, v, k) => m[k] = Number), - - // mounts: { - // 'Wolf-Desert': true, - // 'PandaCub-Red': false, - // etc... - // } - mounts: _.defaults( - // First transform to a 1D eggs/potions mapping - _.transform(shared.content.pets, (m, v, k) => m[k] = Boolean), - // Then add quest and premium pets - _.transform(shared.content.questPets, (m, v, k) => m[k] = Boolean), - _.transform(shared.content.premiumPets, (m, v, k) => m[k] = Boolean), - // Then add additional mounts (backer, contributor) - _.transform(shared.content.specialMounts, (m, v, k) => m[k] = Boolean) - ), - currentMount: String, - - // Quests: { - // 'boss_0': 0, // 0 indicates "doesn't own" - // 'collection_honey': 5 // Number indicates "stacking" - // } - quests: _.transform(shared.content.quests, (m, v, k) => m[k] = Number), - - lastDrop: { - date: {type: Date, default: Date.now}, - count: {type: Number, default: 0}, - }, - }, - - lastCron: {type: Date, default: Date.now}, - - // {GROUP_ID: Boolean}, represents whether they have unseen chat messages - newMessages: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - - challenges: [{type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}], - - invitations: { - // Using an array without validation because otherwise mongoose treat this as a subdocument and applies _id by default - // Schema is (id, name, inviter) - // TODO one way to fix is http://mongoosejs.com/docs/guide.html#_id - guilds: {type: Array, default: () => []}, - // Using a Mixed type because otherwise user.invitations.party = {} // to reset invitation, causes validation to fail TODO - // schema is the same as for guild invitations (id, name, inviter) - party: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - }, - - guilds: [{type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}], - - party: { - _id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Group'}, - order: {type: String, default: 'level'}, - orderAscending: {type: String, default: 'ascending'}, - quest: { - key: String, - progress: { - up: {type: Number, default: 0}, - down: {type: Number, default: 0}, - collect: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, // {feather:1, ingot:2} - }, - completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser - RSVPNeeded: {type: Boolean, default: false}, // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled - }, - }, - preferences: { - dayStart: {type: Number, default: 0, min: 0, max: 23}, - size: {type: String, enum: ['broad', 'slim'], default: 'slim'}, - hair: { - color: {type: String, default: 'red'}, - base: {type: Number, default: 3}, - bangs: {type: Number, default: 1}, - beard: {type: Number, default: 0}, - mustache: {type: Number, default: 0}, - flower: {type: Number, default: 1}, - }, - hideHeader: {type: Boolean, default: false}, - skin: {type: String, default: '915533'}, - shirt: {type: String, default: 'blue'}, - timezoneOffset: {type: Number, default: 0}, - sound: {type: String, default: 'off', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme']}, - chair: {type: String, default: 'none'}, - timezoneOffsetAtLastCron: Number, - language: String, - automaticAllocation: Boolean, - allocationMode: {type: String, enum: ['flat', 'classbased', 'taskbased'], default: 'flat'}, - autoEquip: {type: Boolean, default: true}, - costume: Boolean, - dateFormat: {type: String, enum: ['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], default: 'MM/dd/yyyy'}, - sleep: {type: Boolean, default: false}, - stickyHeader: {type: Boolean, default: true}, - disableClasses: {type: Boolean, default: false}, - newTaskEdit: {type: Boolean, default: false}, - dailyDueDefaultView: {type: Boolean, default: false}, - tagsCollapsed: {type: Boolean, default: false}, - advancedCollapsed: {type: Boolean, default: false}, - toolbarCollapsed: {type: Boolean, default: false}, - reverseChatOrder: {type: Boolean, default: false}, - background: String, - displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true}, - webhooks: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - // For the following fields make sure to use strict comparison when searching for falsey values (=== false) - // As users who didn't login after these were introduced may have them undefined/null - emailNotifications: { - unsubscribeFromAll: {type: Boolean, default: false}, - newPM: {type: Boolean, default: true}, - kickedGroup: {type: Boolean, default: true}, - wonChallenge: {type: Boolean, default: true}, - giftedGems: {type: Boolean, default: true}, - giftedSubscription: {type: Boolean, default: true}, - invitedParty: {type: Boolean, default: true}, - invitedGuild: {type: Boolean, default: true}, - questStarted: {type: Boolean, default: true}, - invitedQuest: {type: Boolean, default: true}, - // remindersToLogin: {type: Boolean, default: true}, - // importantAnnouncements are in fact the recapture emails - importantAnnouncements: {type: Boolean, default: true}, - weeklyRecaps: {type: Boolean, default: true}, - }, - suppressModals: { - levelUp: {type: Boolean, default: false}, - hatchPet: {type: Boolean, default: false}, - raisePet: {type: Boolean, default: false}, - streak: {type: Boolean, default: false}, - }, - improvementCategories: { - type: Array, - validate: (categories) => { - const validCategories = ['work', 'exercise', 'healthWellness', 'school', 'teams', 'chores', 'creativity']; - let isValidCategory = categories.every(category => validCategories.indexOf(category) !== -1); - return isValidCategory; - }, - }, - }, - profile: { - blurb: String, - imageUrl: String, - name: String, - }, - stats: { - hp: {type: Number, default: shared.maxHealth}, - mp: {type: Number, default: 10}, - exp: {type: Number, default: 0}, - gp: {type: Number, default: 0}, - lvl: {type: Number, default: 1}, - - // Class System - class: {type: String, enum: ['warrior', 'rogue', 'wizard', 'healer'], default: 'warrior', required: true}, - points: {type: Number, default: 0}, - str: {type: Number, default: 0}, - con: {type: Number, default: 0}, - int: {type: Number, default: 0}, - per: {type: Number, default: 0}, - buffs: { - str: {type: Number, default: 0}, - int: {type: Number, default: 0}, - per: {type: Number, default: 0}, - con: {type: Number, default: 0}, - stealth: {type: Number, default: 0}, - streaks: {type: Boolean, default: false}, - snowball: {type: Boolean, default: false}, - spookySparkles: {type: Boolean, default: false}, - shinySeed: {type: Boolean, default: false}, - seafoam: {type: Boolean, default: false}, - }, - training: { - int: {type: Number, default: 0}, - per: {type: Number, default: 0}, - str: {type: Number, default: 0}, - con: {type: Number, default: 0}, - }, - }, - - tags: [TagSchema], - - inbox: { - newMessages: {type: Number, default: 0}, - blocks: {type: Array, default: () => []}, - messages: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - optOut: {type: Boolean, default: false}, - }, - tasksOrder: { - habits: [{type: String, ref: 'Task'}], - dailys: [{type: String, ref: 'Task'}], - todos: [{type: String, ref: 'Task'}], - rewards: [{type: String, ref: 'Task'}], - }, - extra: {type: Schema.Types.Mixed, default: () => { - return {}; - }}, - pushDevices: { - type: [{ - regId: {type: String}, - type: {type: String}, - }], - default: () => [], - }, -}, { - strict: true, - minimize: false, // So empty objects are returned -}); - -schema.plugin(baseModel, { - // noSet is not used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...) - noSet: [], - private: ['auth.local.hashed_password', 'auth.local.salt'], - toJSONTransform: function userToJSON (plainObj, originalDoc) { - // plainObj.filters = {}; // TODO Not saved, remove? - plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs - - return plainObj; - }, -}); - -// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private) -export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt - preferences.costume preferences.sleep preferences.background profile stats achievements party - backer contributor auth.timestamps items`; - -// The minimum amount of data needed when populating multiple users -export let nameFields = 'profile.name'; - -schema.post('init', function postInitUser (doc) { - shared.wrap(doc); -}); - -function _populateDefaultTasks (user, taskTypes) { - let tagsI = taskTypes.indexOf('tag'); - - if (tagsI !== -1) { - user.tags = _.map(shared.content.userDefaults.tags, (tag) => { - let newTag = _.cloneDeep(tag); - - // tasks automatically get _id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here - newTag.id = shared.uuid(); - // Render tag's name in user's language - newTag.name = newTag.name(user.preferences.language); - return newTag; - }); - } - - let tasksToCreate = []; - - if (tagsI !== -1) { - taskTypes = _.clone(taskTypes); - taskTypes.splice(tagsI, 1); - } - - _.each(taskTypes, (taskType) => { - let tasksOfType = _.map(shared.content.userDefaults[`${taskType}s`], (taskDefaults) => { - let newTask = new Tasks[taskType](taskDefaults); - - newTask.userId = user._id; - newTask.text = taskDefaults.text(user.preferences.language); - if (newTask.notes) newTask.notes = taskDefaults.notes(user.preferences.language); - if (taskDefaults.checklist) { - newTask.checklist = _.map(taskDefaults.checklist, (checklistItem) => { - checklistItem.text = checklistItem.text(user.preferences.language); - return checklistItem; - }); - } - - return newTask.save(); - }); - - tasksToCreate.push(...tasksOfType); - }); - - return Bluebird.all(tasksToCreate) - .then((tasksCreated) => { - _.each(tasksCreated, (task) => { - user.tasksOrder[`${task.type}s`].push(task._id); - }); - }); -} - -function _populateDefaultsForNewUser (user) { - let taskTypes; - let iterableFlags = user.flags.toObject(); - - if (user.registeredThrough === 'habitica-web' || user.registeredThrough === 'habitica-android') { - taskTypes = ['habit', 'daily', 'todo', 'reward', 'tag']; - - _.each(iterableFlags.tutorial.common, (val, section) => { - user.flags.tutorial.common[section] = true; - }); - } else { - taskTypes = ['todo', 'tag']; - user.flags.showTour = false; - - _.each(iterableFlags.tour, (val, section) => { - user.flags.tour[section] = -2; - }); - } - - return _populateDefaultTasks(user, taskTypes); -} - -function _setProfileName (user) { - let fb = user.auth.facebook; - - let localUsername = user.auth.local && user.auth.local.username; - let facebookUsername = fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`); - let anonymous = 'Anonymous'; - - return localUsername || facebookUsername || anonymous; -} - -schema.pre('save', true, function preSaveUser (next, done) { - next(); - - if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { - this.preferences.dayStart = 0; - } - - if (!this.profile.name) { - this.profile.name = _setProfileName(this); - } - - // Determines if Beast Master should be awarded - let beastMasterProgress = shared.count.beastMasterProgress(this.items.pets); - - if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) { - this.achievements.beastMaster = true; - } - - // Determines if Mount Master should be awarded - let mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts); - - if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) { - this.achievements.mountMaster = true; - } - - // Determines if Triad Bingo should be awarded - - let dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets); - let qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90; - - if (qualifiesForTriad || this.achievements.triadBingoCount > 0) { - this.achievements.triadBingo = true; - } - - // Enable weekly recap emails for old users who sign in - if (this.flags.lastWeeklyRecapDiscriminator) { - // Enable weekly recap emails in 24 hours - this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate(); - // Unset the field so this is run only once - this.flags.lastWeeklyRecapDiscriminator = undefined; - } - - // EXAMPLE CODE for allowing all existing and new players to be - // automatically granted an item during a certain time period: - // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) - // this.items.pets['JackOLantern-Base'] = 5; - - // our own version incrementer - if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; - this._v++; - - // Populate new users with default content - if (this.isNew) { - _populateDefaultsForNewUser(this) - .then(() => done()) - .catch(done); - } else { - done(); - } -}); - -schema.pre('update', function preUpdateUser () { - this.update({}, {$inc: {_v: 1}}); -}); - -schema.methods.isSubscribed = function isSubscribed () { - return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion -}; - -// Get an array of groups ids the user is member of -schema.methods.getGroups = function getUserGroups () { - let userGroups = this.guilds.slice(0); // clone user.guilds so we don't modify the original - if (this.party._id) userGroups.push(this.party._id); - userGroups.push(TAVERN_ID); - return userGroups; -}; - -schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, message) { - let sender = this; - - shared.refPush(userToReceiveMessage.inbox.messages, chatDefaults(message, sender)); - userToReceiveMessage.inbox.newMessages++; - userToReceiveMessage._v++; - userToReceiveMessage.markModified('inbox.messages'); - - shared.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(message, userToReceiveMessage))); - sender.markModified('inbox.messages'); - - let promises = [userToReceiveMessage.save(), sender.save()]; - await Bluebird.all(promises); -}; - -// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model) -// These will be removed once API v2 is discontinued - -// Get all the tasks belonging to a user, -schema.methods.getTasks = function getUserTasks () { - let args = Array.from(arguments); - let cb; - let type; - - if (args.length === 1) { - cb = args[0]; - } else { - type = args[0]; - cb = args[1]; - } - - let query = { - userId: this._id, - }; - - if (type) query.type = type; - - Tasks.Task.find(query, cb); -}; - -// Given user and an array of tasks, return an API compatible user + tasks obj -schema.methods.addTasksToUser = function addTasksToUser (tasks) { - let obj = this.toJSON(); - - obj.id = obj._id; - obj.filters = {}; - - obj.tags = obj.tags.map(tag => { - return { - id: tag.id, - name: tag.name, - challenge: tag.challenge, - }; - }); - - let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it - - obj.habits = []; - obj.dailys = []; - obj.todos = []; - obj.rewards = []; - - obj.tasksOrder = undefined; - let unordered = []; - - tasks.forEach((task) => { - // We want to push the task at the same position where it's stored in tasksOrder - let pos = tasksOrder[`${task.type}s`].indexOf(task._id); - if (pos === -1) { // Should never happen, it means the lists got out of sync - unordered.push(task.toJSONV2()); - } else { - obj[`${task.type}s`][pos] = task.toJSONV2(); - } - }); - - // Reconcile unordered items - unordered.forEach((task) => { - obj[`${task.type}s`].push(task); - }); - - // Remove null values that can be created when inserting tasks at an index > length - ['habits', 'dailys', 'rewards', 'todos'].forEach((type) => { - obj[type] = _.compact(obj[type]); - }); - - return obj; -}; - -// Return the data maintaining backward compatibility -schema.methods.getTransformedData = function getTransformedData (cb) { - let self = this; - this.getTasks((err, tasks) => { - if (err) return cb(err); - cb(null, self.addTasksToUser(tasks)); - }); -}; - -// END of API v2 methods -export let model = mongoose.model('User', schema); - -// Initially export an empty object so external requires will get -// the right object by reference when it's defined later -// Otherwise it would remain undefined if requested before the query executes -export let mods = []; - -mongoose.model('User') - .find({'contributor.admin': true}) - .sort('-contributor.level -backer.npc profile.name') - .select('profile contributor backer') - .exec() - .then((foundMods) => { - // Using push to maintain the reference to mods - mods.push(...foundMods); - }); // In case of failure we don't want this to crash the whole server diff --git a/website/server/routes/api-v2/auth.js b/website/server/routes/api-v2/auth.js deleted file mode 100644 index f8af1b4338..0000000000 --- a/website/server/routes/api-v2/auth.js +++ /dev/null @@ -1,21 +0,0 @@ -var auth = require('../../controllers/api-v2/auth'); -var express = require('express'); -var i18n = require('../../libs/api-v2/i18n'); -var router = express.Router(); -import { - getUserLanguage -} from '../../middlewares/api-v3/language'; - -/* auth.auth*/ -// auth.setupPassport(router); //TODO make this consistent with the others -router.post('/register', getUserLanguage, auth.registerUser); -router.post('/user/auth/local', getUserLanguage, auth.loginLocal); -router.post('/user/auth/social', getUserLanguage, auth.loginSocial); -router.delete('/user/auth/social', getUserLanguage, auth.auth, auth.deleteSocial); -router.post('/user/reset-password', getUserLanguage, auth.resetPassword); -router.post('/user/change-password', getUserLanguage, auth.auth, auth.changePassword); -router.post('/user/change-username', getUserLanguage, auth.auth, auth.changeUsername); -router.post('/user/change-email', getUserLanguage, auth.auth, auth.changeEmail); -// router.post('/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken); - -module.exports = router; diff --git a/website/server/routes/api-v2/coupon.js b/website/server/routes/api-v2/coupon.js deleted file mode 100644 index 7caee3f815..0000000000 --- a/website/server/routes/api-v2/coupon.js +++ /dev/null @@ -1,15 +0,0 @@ -var nconf = require('nconf'); -var express = require('express'); -var router = express.Router(); -var auth = require('../../controllers/api-v2/auth'); -var coupon = require('../../controllers/api-v2/coupon'); -var i18n = require('../../libs/api-v2/i18n'); -import { - getUserLanguage -} from '../../middlewares/api-v3/language'; - -router.get('/coupons', auth.authWithUrl, getUserLanguage, coupon.ensureAdmin, coupon.getCoupons); -router.post('/coupons/generate/:event', auth.auth, getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons); -router.post('/user/coupon/:code', auth.auth, getUserLanguage, coupon.enterCode); - -module.exports = router; diff --git a/website/server/routes/api-v2/unsubscription.js b/website/server/routes/api-v2/unsubscription.js deleted file mode 100644 index cbd2e16554..0000000000 --- a/website/server/routes/api-v2/unsubscription.js +++ /dev/null @@ -1,11 +0,0 @@ -var express = require('express'); -var router = express.Router(); -var i18n = require('../../libs/api-v2/i18n'); -var unsubscription = require('../../controllers/api-v2/unsubscription'); -import { - getUserLanguage -} from '../../middlewares/api-v3/language'; - -router.get('/unsubscribe', getUserLanguage, unsubscription.unsubscribe); - -module.exports = router; diff --git a/website/server/routes/payments.js b/website/server/routes/payments.js deleted file mode 100644 index 13e9ec9163..0000000000 --- a/website/server/routes/payments.js +++ /dev/null @@ -1,34 +0,0 @@ -var nconf = require('nconf'); -var express = require('express'); -var router = express.Router(); -var auth = require('../controllers/api-v2/auth'); -var payments = require('../controllers/payments'); -var i18n = require('../libs/api-v2/i18n'); -import { - getUserLanguage -} from '../../middlewares/api-v3/language'; - -router.get('/paypal/checkout', auth.authWithUrl, getUserLanguage, payments.paypalCheckout); -router.get('/paypal/checkout/success', getUserLanguage, payments.paypalCheckoutSuccess); -router.get('/paypal/subscribe', auth.authWithUrl, getUserLanguage, payments.paypalSubscribe); -router.get('/paypal/subscribe/success', getUserLanguage, payments.paypalSubscribeSuccess); -router.get('/paypal/subscribe/cancel', auth.authWithUrl, getUserLanguage, payments.paypalSubscribeCancel); -router.post('/paypal/ipn', getUserLanguage, payments.paypalIPN); // misc ipn handling - -router.post('/stripe/checkout', auth.auth, getUserLanguage, payments.stripeCheckout); -router.post('/stripe/subscribe/edit', auth.auth, getUserLanguage, payments.stripeSubscribeEdit); -//router.get('/stripe/subscribe', auth.authWithUrl, getUserLanguage, payments.stripeSubscribe); // checkout route is used (above) with ?plan= instead -router.get('/stripe/subscribe/cancel', auth.authWithUrl, getUserLanguage, payments.stripeSubscribeCancel); - -router.post('/amazon/verifyAccessToken', auth.auth, getUserLanguage, payments.amazonVerifyAccessToken); -router.post('/amazon/createOrderReferenceId', auth.auth, getUserLanguage, payments.amazonCreateOrderReferenceId); -router.post('/amazon/checkout', auth.auth, getUserLanguage, payments.amazonCheckout); -router.post('/amazon/subscribe', auth.auth, getUserLanguage, payments.amazonSubscribe); -router.get('/amazon/subscribe/cancel', auth.authWithUrl, getUserLanguage, payments.amazonSubscribeCancel); - -router.post('/iap/android/verify', auth.authWithUrl, /*getUserLanguage, */payments.iapAndroidVerify); -router.post('/iap/ios/verify', auth.auth, /*getUserLanguage, */ payments.iapIosVerify); - -router.get('/api/v2/coupons/valid-discount/:code', /*auth.authWithUrl, getUserLanguage, */ payments.validCoupon); - -module.exports = router; diff --git a/website/server/server.js b/website/server/server.js deleted file mode 100644 index f86e6c63b7..0000000000 --- a/website/server/server.js +++ /dev/null @@ -1,35 +0,0 @@ -import nconf from 'nconf'; -import logger from './libs/api-v3/logger'; -import express from 'express'; -import http from 'http'; -import attachMiddlewares from './middlewares/api-v3/index'; -import Bluebird from 'bluebird'; - -global.Promise = Bluebird; - -const server = http.createServer(); -const app = express(); - -app.set('port', nconf.get('PORT')); - -// Setup translations -import './libs/api-v3/i18n'; - -// Load config files -import './libs/api-v3/setupMongoose'; -import './libs/api-v3/firebase'; -import './libs/api-v3/setupPassport'; - -// Load some schemas & models -import './models/challenge'; -import './models/group'; -import './models/user'; - -attachMiddlewares(app, server); - -server.on('request', app); -server.listen(app.get('port'), () => { - logger.info(`Express server listening on port ${app.get('port')}`); -}); - -module.exports = server; diff --git a/website/server/controllers/api-v2/auth.js b/website/src/controllers/api-v2/auth.js similarity index 94% rename from website/server/controllers/api-v2/auth.js rename to website/src/controllers/api-v2/auth.js index cfa5c40a0d..f63c652814 100644 --- a/website/server/controllers/api-v2/auth.js +++ b/website/src/controllers/api-v2/auth.js @@ -3,19 +3,14 @@ var validator = require('validator'); var passport = require('passport'); var shared = require('../../../../common'); var async = require('async'); -var utils = require('../../libs/api-v2/utils'); +var utils = require('../../libs/utils'); var nconf = require('nconf'); var request = require('request'); var FirebaseTokenGenerator = require('firebase-token-generator'); -import { - model as User, -} from '../../models/user'; -import { - model as EmailUnsubscription, -} from '../../models/emailUnsubscription'; - +var User = require('../../models/user').model; +var EmailUnsubscription = require('../../models/emailUnsubscription').model; var analytics = utils.analytics; -var i18n = require('./../../libs/api-v2/i18n'); +var i18n = require('./../../libs/i18n'); var isProd = nconf.get('NODE_ENV') === 'production'; @@ -58,7 +53,6 @@ api.authWithSession = function(req, res, next) { //[todo] there is probably a mo }); }; -// TODO passing auth params as query params is not safe as they are logged by browser history, ... api.authWithUrl = function(req, res, next) { User.findOne({_id:req.query._id, apiToken:req.query.apiToken}, function(err,user){ if (err) return next(err); @@ -132,8 +126,8 @@ api.registerUser = function(req, res, next) { analytics.track('register', analyticsData) user.save(function(err, savedUser){ - if (err) return cb(err); // Clean previous email preferences + // TODO when emails added to EmailUnsubcription they should use lowercase version EmailUnsubscription.remove({email: savedUser.auth.local.email}, function(){ utils.txnEmail(savedUser, 'welcome'); }); @@ -143,13 +137,15 @@ api.registerUser = function(req, res, next) { }] }, function(err, data) { if (err) return err.code ? res.status(err.code).json(err) : next(err); - data.register[0].getTransformedData(function(err, userTransformed){ - if(err) return next(err); - res.status(200).json(userTransformed); - }); + res.status(200).json(data.register[0]); }); }; +/* + Register new user with uname / password + */ + + api.loginLocal = function(req, res, next) { var username = req.body.username; var password = req.body.password; @@ -240,7 +236,7 @@ api.loginSocial = function(req, res, next) { api.deleteSocial = function(req,res,next){ if (!res.locals.user.auth.local.username) return res.status(401).json({err:"Account lacks another authentication method, can't detach Facebook"}); - //TODO for some reason, the following gives https://gist.github.com/lefnire/f93eb306069b9089d123 + //FIXME for some reason, the following gives https://gist.github.com/lefnire/f93eb306069b9089d123 //res.locals.user.auth.facebook = null; //res.locals.user.auth.save(function(err, saved){ User.update({_id:res.locals.user._id}, {$unset:{'auth.facebook':1}}, function(err){ @@ -351,8 +347,7 @@ api.changePassword = function(req, res, next) { }) }; -// DISABLED FOR API v2 -/*var firebaseTokenGeneratorInstance = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); +var firebaseTokenGeneratorInstance = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); api.getFirebaseToken = function(req, res, next) { var user = res.locals.user; // Expires 24 hours after now (60*60*24*1000) (in milliseconds) @@ -371,10 +366,13 @@ api.getFirebaseToken = function(req, res, next) { token: token, expires: expires }); -};*/ +}; -// DISABLED FOR API v2 -/*api.setupPassport = function(router) { +/* + Registers a new user. Only accepting username/password registrations, no Facebook +*/ + +api.setupPassport = function(router) { router.get('/logout', i18n.getUserLanguage, function(req, res) { req.logout(); @@ -382,4 +380,4 @@ api.getFirebaseToken = function(req, res, next) { res.redirect('/'); }) -};*/ +}; diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js new file mode 100644 index 0000000000..967f175213 --- /dev/null +++ b/website/src/controllers/api-v2/challenges.js @@ -0,0 +1,453 @@ +// @see ../routes for routing + +var _ = require('lodash'); +var nconf = require('nconf'); +var async = require('async'); +var shared = require('../../../../common'); +var User = require('./../../models/user').model; +var Group = require('./../../models/group').model; +var Challenge = require('./../../models/challenge').model; +var logging = require('./../../libs/logging'); +var csvStringify = require('csv-stringify'); +var utils = require('../../libs/utils'); +var api = module.exports; +var pushNotify = require('./../pushNotifications'); + +/* + ------------------------------------------------------------------------ + Challenges + ------------------------------------------------------------------------ +*/ + +api.list = function(req, res, next) { + var user = res.locals.user; + async.waterfall([ + function(cb){ + // Get all available groups I belong to + Group.find({members: {$in: [user._id]}}).select('_id').exec(cb); + }, + function(gids, cb){ + // and their challenges + Challenge.find({ + $or:[ + {leader: user._id}, + {members:{$in:[user._id]}}, // all challenges I belong to (is this necessary? thought is a left a group, but not its challenge) + {group:{$in:gids}}, // all challenges in my groups + {group: 'habitrpg'} // public group + ], + _id:{$ne:'95533e05-1ff9-4e46-970b-d77219f199e9'} // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug + }) + .select('name leader description group memberCount prize official') + .select({members:{$elemMatch:{$in:[user._id]}}}) + .sort('-official -timestamp') + .populate('group', '_id name type') + .populate('leader', 'profile.name') + .exec(cb); + } + ], function(err, challenges){ + if (err) return next(err); + _.each(challenges, function(c){ + c._isMember = c.members.length > 0; + }) + res.json(challenges); + user = null; + }); +} + +// GET +api.get = function(req, res, next) { + var user = res.locals.user; + // TODO use mapReduce() or aggregate() here to + // 1) Find the sum of users.tasks.values within the challnege (eg, {'profile.name':'tyler', 'sum': 100}) + // 2) Sort by the sum + // 3) Limit 30 (only show the 30 users currently in the lead) + Challenge.findById(req.params.cid) + .populate('members', 'profile.name _id') + .populate('group', '_id name type') + .populate('leader', 'profile.name') + .exec(function(err, challenge){ + if(err) return next(err); + if (!challenge) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'}); + challenge._isMember = !!(_.find(challenge.members, function(member) { + return member._id === user._id; + })); + res.json(challenge); + }); +} + +api.csv = function(req, res, next) { + var cid = req.params.cid; + var challenge; + async.waterfall([ + function(cb){ + Challenge.findById(cid,cb) + }, + function(_challenge,cb) { + challenge = _challenge; + if (!challenge) return cb('Challenge ' + cid + ' not found'); + User.aggregate([ + {$match:{'_id':{ '$in': challenge.members}}}, //yes, we want members + {$project:{'profile.name':1,tasks:{$setUnion:["$habits","$dailys","$todos","$rewards"]}}}, + {$unwind:"$tasks"}, + {$match:{"tasks.challenge.id":cid}}, + {$sort:{'tasks.type':1,'tasks.id':1}}, + {$group:{_id:"$_id", "tasks":{$push:"$tasks"},"name":{$first:"$profile.name"}}} + ], cb); + } + ],function(err,users){ + if(err) return next(err); + var output = ['UUID','name']; + _.each(challenge.tasks,function(t){ + //output.push(t.type+':'+t.text); + //not the right order yet + output.push('Task'); + output.push('Value'); + output.push('Notes'); + }) + output = [output]; + _.each(users, function(u){ + var uData = [u._id,u.name]; + _.each(u.tasks,function(t){ + uData = uData.concat([t.type+':'+t.text, t.value, t.notes]); + }) + output.push(uData); + }); + + res.set({ + 'Content-Type': 'text/csv', + 'Content-disposition': `attachment; filename=${cid}.csv`, + }); + + csvStringify(output, (err, csv) => { + if (err) return next(err); + res.status(200).send(csv); + challenge = cid = null; + }); + }) +} + +api.getMember = function(req, res, next) { + var cid = req.params.cid; + var uid = req.params.uid; + + // We need to start using the aggregation framework instead of in-app filtering, see http://docs.mongodb.org/manual/aggregation/ + // See code at 32c0e75 for unwind/group example + + //http://stackoverflow.com/questions/24027213/how-to-match-multiple-array-elements-without-using-unwind + var proj = {'profile.name':'$profile.name'}; + _.each(['habits','dailys','todos','rewards'], function(type){ + proj[type] = { + $setDifference: [{ + $map: { + input: '$'+type, + as: "el", + in: { + $cond: [{$eq: ["$$el.challenge.id", cid]}, '$$el', false] + } + } + }, [false]] + } + }); + User.aggregate() + .match({_id: uid}) + .project(proj) + .exec(function(err, member){ + if (err) return next(err); + if (!member) return res.status(404).json({err: 'Member '+uid+' for challenge '+cid+' not found'}); + res.json(member[0]); + uid = cid = null; + }); +} + +// CREATE +api.create = function(req, res, next){ + var user = res.locals.user; + + async.auto({ + get_group: function(cb){ + var q = {_id:req.body.group}; + if (req.body.group!='habitrpg') q.members = {$in:[user._id]}; // make sure they're a member of the group + Group.findOne(q, cb); + }, + save_chal: ['get_group', function(cb, results){ + var group = results.get_group, + prize = +req.body.prize; + if (!group) + return cb({code:404, err:"Group." + req.body.group + " not found"}); + if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) + return cb({code:401, err: "Only the group leader can create challenges"}); + // If they're adding a prize, do some validation + if (prize < 0) + return cb({code:401, err: 'Challenge prize must be >= 0'}); + if (req.body.group=='habitrpg' && prize < 1) + return cb({code:401, err: 'Prize must be at least 1 Gem for public challenges.'}); + if (prize > 0) { + var groupBalance = ((group.balance && group.leader==user._id) ? group.balance : 0); + var prizeCost = prize/4; // I really should have stored user.balance as gems rather than dollars... stupid... + if (prizeCost > user.balance + groupBalance) + return cb("You can't afford this prize. Purchase more gems or lower the prize amount.") + + if (groupBalance >= prizeCost) { + // Group pays for all of prize + group.balance -= prizeCost; + } else if (groupBalance > 0) { + // User pays remainder of prize cost after group + var remainder = prizeCost - group.balance; + group.balance = 0; + user.balance -= remainder; + } else { + // User pays for all of prize + user.balance -= prizeCost; + } + } + req.body.leader = user._id; + req.body.official = user.contributor.admin && req.body.official; + var chal = new Challenge(req.body); // FIXME sanitize + chal.members.push(user._id); + chal.save(cb); + }], + save_group: ['save_chal', function(cb, results){ + results.get_group.challenges.push(results.save_chal[0]._id); + results.get_group.save(cb); + }], + sync_user: ['save_group', function(cb, results){ + // Auto-join creator to challenge (see members.push above) + results.save_chal[0].syncToUser(user, cb); + }] + }, function(err, results){ + if (err) return err.code? res.status(err.code).json(err) : next(err); + return res.json(results.save_chal[0]); + user = null; + }) +} + +// UPDATE +api.update = function(req, res, next){ + var cid = req.params.cid; + var user = res.locals.user; + var before; + async.waterfall([ + function(cb){ + // We first need the original challenge data, since we're going to compare against new & decide to sync users + Challenge.findById(cid, cb); + }, + function(_before, cb) { + if (!_before) return cb('Challenge ' + cid + ' not found'); + if (_before.leader != user._id && !user.contributor.admin) return cb(shared.i18n.t('noPermissionEditChallenge', req.language)); + // Update the challenge, since syncing will need the updated challenge. But store `before` we're going to do some + // before-save / after-save comparison to determine if we need to sync to users + before = _before; + var attrs = _.pick(req.body, 'name shortName description habits dailys todos rewards date'.split(' ')); + Challenge.findByIdAndUpdate(cid, {$set:attrs}, {new: true}, cb); + }, + function(saved, cb) { + + // Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers + if (before.isOutdated(req.body)) { + User.find({_id: {$in: saved.members}}, function(err, users){ + logging.info('Challenge updated, sync to subscribers'); + if (err) throw err; + _.each(users, function(user){ + saved.syncToUser(user); + }) + }) + } + + // after saving, we're done as far as the client's concerned. We kick off syncing (heavy task) in the background + cb(null, saved); + } + ], function(err, saved){ + if(err) next(err); + res.json(saved); + cid = user = before = null; + }) +} + +/** + * Called by either delete() or selectWinner(). Will delete the challenge and set the "broken" property on all users' subscribed tasks + * @param {cid} the challenge id + * @param {broken} the object representing the broken status of the challenge. Eg: + * {broken: 'CHALLENGE_DELETED', id: CHALLENGE_ID} + * {broken: 'CHALLENGE_CLOSED', id: CHALLENGE_ID, winner: USER_NAME} + */ +function closeChal(cid, broken, cb) { + var removed; + async.waterfall([ + function(cb2){ + Challenge.findOneAndRemove({_id:cid}, cb2) + }, + function(_removed, cb2) { + removed = _removed; + var pull = {'$pull':{}}; pull['$pull'][_removed._id] = 1; + Group.findByIdAndUpdate(_removed.group, {new: true}, pull); + User.find({_id:{$in: removed.members}}, cb2); + }, + function(users, cb2) { + var parallel = []; + _.each(users, function(user){ + var tag = _.find(user.tags, {id:cid}); + if (tag) tag.challenge = undefined; + _.each(user.tasks, function(task){ + if (task.challenge && task.challenge.id == removed._id) { + _.merge(task.challenge, broken); + } + }) + parallel.push(function(cb3){ + user.save(cb3); + }) + }) + async.parallel(parallel, cb2); + removed = null; + } + ], cb); +} + +/** + * Delete & close + */ +api.delete = function(req, res, next){ + var user = res.locals.user; + var cid = req.params.cid; + + async.waterfall([ + function(cb){ + Challenge.findById(cid, cb); + }, + function(chal, cb){ + if (!chal) return cb('Challenge ' + cid + ' not found'); + if (chal.leader != user._id && !user.contributor.admin) return cb(shared.i18n.t('noPermissionDeleteChallenge', req.language)); + if (chal.group != 'habitrpg') user.balance += chal.prize/4; // Refund gems to user if a non-tavern challenge + user.save(cb); + }, + function(save, num, cb){ + closeChal(req.params.cid, {broken: 'CHALLENGE_DELETED'}, cb); + } + ], function(err){ + if (err) return next(err); + res.sendStatus(200); + user = cid = null; + }); +} + +/** + * Select Winner & Close + */ +api.selectWinner = function(req, res, next) { + if (!req.query.uid) return res.status(401).json({err: 'Must select a winner'}); + var user = res.locals.user; + var cid = req.params.cid; + var chal; + async.waterfall([ + function(cb){ + Challenge.findById(cid, cb); + }, + function(_chal, cb){ + chal = _chal; + if (!chal) return cb('Challenge ' + cid + ' not found'); + if (chal.leader != user._id && !user.contributor.admin) return cb(shared.i18n.t('noPermissionCloseChallenge', req.language)); + User.findById(req.query.uid, cb) + }, + function(winner, cb){ + if (!winner) return cb('Winner ' + req.query.uid + ' not found.'); + _.defaults(winner.achievements, {challenges: []}); + winner.achievements.challenges.push(chal.name); + winner.balance += chal.prize/4; + winner.save(cb); + }, + function(saved, num, cb) { + if(saved.preferences.emailNotifications.wonChallenge !== false){ + utils.txnEmail(saved, 'won-challenge', [ + {name: 'CHALLENGE_NAME', content: chal.name} + ]); + } + + pushNotify.sendNotify(saved, shared.i18n.t('wonChallenge'), chal.name); + + closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb); + } + ], function(err){ + if (err) return next(err); + res.sendStatus(200); + user = cid = chal = null; + }) +} + +api.join = function(req, res, next){ + var user = res.locals.user; + var cid = req.params.cid; + + async.waterfall([ + function(cb) { + Challenge.findByIdAndUpdate(cid, {$addToSet:{members:user._id}}, {new: true}, cb); + }, + function(chal, cb) { + + // Trigger updating challenge member count in the background. We can't do it above because we don't have + // _.size(challenge.members). We can't do it in pre(save) because we're calling findByIdAndUpdate above. + Challenge.update({_id:cid}, {$set:{memberCount:_.size(chal.members)}}).exec(); + + if (!~user.challenges.indexOf(cid)) + user.challenges.unshift(cid); + // Add all challenge's tasks to user's tasks + chal.syncToUser(user, function(err){ + if (err) return cb(err); + cb(null, chal); // we want the saved challenge in the return results, due to ng-resource + }); + } + ], function(err, chal){ + if(err) return next(err); + chal._isMember = true; + res.json(chal); + user = cid = null; + }); +} + + +api.leave = function(req, res, next){ + var user = res.locals.user; + var cid = req.params.cid; + // whether or not to keep challenge's tasks. strictly default to true if "keep-all" isn't provided + var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all'; + + async.waterfall([ + function(cb){ + Challenge.findByIdAndUpdate(cid, {$pull:{members:user._id}}, {new: true}, cb); + }, + function(chal, cb){ + + // Trigger updating challenge member count in the background. We can't do it above because we don't have + // _.size(challenge.members). We can't do it in pre(save) because we're calling findByIdAndUpdate above. + if (chal) + Challenge.update({_id:cid}, {$set:{memberCount:_.size(chal.members)}}).exec(); + + var i = user.challenges.indexOf(cid) + if (~i) user.challenges.splice(i,1); + user.unlink({cid:cid, keep:keep}, function(err){ + if (err) return cb(err); + cb(null, chal); + }) + } + ], function(err, chal){ + if(err) return next(err); + if (chal) chal._isMember = false; + res.json(chal); + user = cid = keep = null; + }); +} + +api.unlink = function(req, res, next) { + // they're scoring the task - commented out, we probably don't need it due to route ordering in api.js + //var urlParts = req.originalUrl.split('/'); + //if (_.contains(['up','down'], urlParts[urlParts.length -1])) return next(); + + var user = res.locals.user; + var tid = req.params.id; + var cid = user.tasks[tid].challenge.id; + if (!req.query.keep) + return res.status(400).json({err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'}); + user.unlink({cid:cid, keep:req.query.keep, tid:tid}, function(err, saved){ + if (err) return next(err); + res.sendStatus(200); + user = tid = cid = null; + }); +} diff --git a/website/server/controllers/api-v2/coupon.js b/website/src/controllers/api-v2/coupon.js similarity index 90% rename from website/server/controllers/api-v2/coupon.js rename to website/src/controllers/api-v2/coupon.js index 17cd289bd3..a69c41e326 100644 --- a/website/server/controllers/api-v2/coupon.js +++ b/website/src/controllers/api-v2/coupon.js @@ -1,7 +1,5 @@ var _ = require('lodash'); -import { - model as Coupon, -} from '../../models/coupon'; +var Coupon = require('./../../models/coupon').model; var api = module.exports; var csvStringify = require('csv-stringify'); var async = require('async'); @@ -30,7 +28,7 @@ api.getCoupons = function(req,res,next) { res.set({ 'Content-Type': 'text/csv', - 'Content-disposition': 'attachment; filename=habitica-coupons.csv', + 'Content-disposition': `attachment; filename=habitica-coupons.csv`, }); csvStringify(output, (err, csv) => { if (err) return next(err); diff --git a/website/server/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js similarity index 69% rename from website/server/controllers/api-v2/groups.js rename to website/src/controllers/api-v2/groups.js index 31a21d7dc9..e117aa40ad 100644 --- a/website/server/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -8,31 +8,18 @@ function clone(a) { var _ = require('lodash'); var nconf = require('nconf'); var async = require('async'); -var Bluebird = require('bluebird'); -var utils = require('./../../libs/api-v2/utils'); +var Q = require('q'); +var utils = require('./../../libs/utils'); var shared = require('../../../../common'); - -import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; - -import { - model as User, -} from './../../models/user'; -import { - model as Group, - TAVERN_ID, -} from './../../models/group'; -import { - model as Challenge, -} from './../../models/challenge'; -import { - model as EmailUnsubscription, -} from './../../models/emailUnsubscription'; - +var User = require('./../../models/user').model; +var Group = require('./../../models/group').model; +var Challenge = require('./../../models/challenge').model; +var EmailUnsubscription = require('./../../models/emailUnsubscription').model; var isProd = nconf.get('NODE_ENV') === 'production'; var api = module.exports; -var pushNotify = require('./pushNotifications'); +var pushNotify = require('./../pushNotifications'); var analytics = utils.analytics; -var firebase = require('../../libs/api-v2/firebase'); +var firebase = require('../../libs/firebase'); /* ------------------------------------------------------------------------ @@ -85,41 +72,31 @@ api.list = function(req, res, next) { // unecessary given our ui-router setup party: function(cb){ if (!~type.indexOf('party')) return cb(null, {}); - Group.findOne({_id: user.party._id, type: 'party'}) + Group.findOne({type: 'party', members: {'$in': [user._id]}}) .select(groupFields).exec(function(err, party){ if (err) return cb(err); - if (!party) return cb(null, []); - party.getTransformedData({cb: function (err, transformedParty) { - if (err) return cb(err); - cb(null, (transformedParty === null ? [] : [transformedParty])); // return as an array for consistent ngResource use - }}); + cb(null, (party === null ? [] : [party])); // return as an array for consistent ngResource use }); }, guilds: function(cb) { if (!~type.indexOf('guilds')) return cb(null, []); - Group.find({_id: {'$in': user.guilds}, type:'guild'}) - .select(groupFields).sort(sort).exec(function (err, guilds) { - if (err) return cb(err); - async.map(guilds, function (guild, cb1) { - guild.getTransformedData({cb: cb1}) - }, function(err, guildsTransormed) { - cb(err, guildsTransormed); - }); - }); + Group.find({members: {'$in': [user._id]}, type:'guild'}) + .select(groupFields).sort(sort).exec(cb); }, 'public': function(cb) { if (!~type.indexOf('public')) return cb(null, []); Group.find({privacy: 'public'}) - .select(groupFields) + .select(groupFields + ' members') .sort(sort) .lean() .exec(function(err, groups){ if (err) return cb(err); _.each(groups, function(g){ // To save some client-side performance, don't send down the full members arr, just send down temp var _isMember - if (user.guilds.indexOf(g._id) !== -1) g._isMember = true; + if (~g.members.indexOf(user._id)) g._isMember = true; + g.members = undefined; }); cb(null, groups); }); @@ -128,12 +105,9 @@ api.list = function(req, res, next) { // unecessary given our ui-router setup tavern: function(cb) { if (!~type.indexOf('tavern')) return cb(null, {}); - Group.findById(TAVERN_ID).select(groupFields).exec(function(err, tavern){ + Group.findById('habitrpg').select(groupFields).exec(function(err, tavern){ if (err) return cb(err); - tavern.getTransformedData({cb: function (err, transformedTavern) { - if (err) return cb(err); - cb(null, ([transformedTavern])); // return as an array for consistent ngResource use - }}); + cb(null, [tavern]); // return as an array for consistent ngResource use }); } @@ -160,26 +134,14 @@ api.list = function(req, res, next) { api.get = function(req, res, next) { var user = res.locals.user; var gid = req.params.gid; - let isUserGuild = user.guilds.indexOf(gid) !== -1; - var q; - - if (gid === 'party' || gid === user.party._id) { - q = Group.findOne({_id: user.party._id, type: 'party'}) - } else { - - if (isUserGuild) { - q = Group.findOne({type: 'guild', _id: gid}); - } else if (gid === 'habitrpg') { - q = Group.findOne({_id: TAVERN_ID}); - } else { - q = Group.findOne({type: 'guild', privacy: 'public', _id: gid}); - } - } - - q.populate('leader', nameFields); - - //populateQuery(gid, q); + var q = (gid == 'party') + ? Group.findOne({type: 'party', members: {'$in': [user._id]}}) + : Group.findOne({$or:[ + {_id:gid, privacy:'public'}, + {_id:gid, privacy:'private', members: {$in:[user._id]}} // if the group is private, only return if they have access + ]}); + populateQuery(gid, q); q.exec(function(err, group){ if (err) return next(err); if(!group){ @@ -190,27 +152,34 @@ api.get = function(req, res, next) { return res.json(group); } - group.getTransformedData({ - cb: function (err, transformedGroup) { + if (!user.contributor.admin) { + _purgeFlagInfoFromChat(group, user); + } + + //Since we have a limit on how many members are populate to the group, we want to make sure the user is always in the group + var userInGroup = _.find(group.members, function(member){ return member._id == user._id; }); + //If the group is private or the group is a party, then the user must be a member of the group based on access restrictions above + if (group.privacy === 'private' || gid === 'party') { + //If the user is not in the group query, remove a user and add the current user + if (!userInGroup) { + group.members.splice(0,1); + group.members.push(user); + } + res.json(group); + } else if ( group.privacy === "public" ) { //The group is public, we must do an extra check to see if the user is already in the group query + //We must see how to check if a user is a member of a public group, so we requery + var q2 = Group.findOne({ _id: group._id, privacy:'public', members: {$in:[user._id]} }); + q2.exec(function(err, group2){ if (err) return next(err); - - if (!user.contributor.admin) { - _purgeFlagInfoFromChat(transformedGroup, user); + if (group2 && !userInGroup) { + group.members.splice(0,1); + group.members.push(user); } + res.json(group); + }); + } - //Since we have a limit on how many members are populate to the group, we want to make sure the user is always in the group - var userInGroup = _.find(transformedGroup.members, function(member){ return member._id == user._id; }); - if ((gid === 'party' || isUserGuild) && !userInGroup) { - transformedGroup.members.splice(0,1); - transformedGroup.members.push(user); - } - - res.json(transformedGroup); - }, - populateMembers: group.type === 'party' ? partyFields : nameFields, - populateInvites: nameFields, - populateChallenges: challengeFields, - }); + gid = null; }); }; @@ -218,12 +187,10 @@ api.get = function(req, res, next) { api.create = function(req, res, next) { var group = new Group(req.body); var user = res.locals.user; - //group.members = [user._id]; + group.members = [user._id]; group.leader = user._id; - if (!group.name) group.name = 'group name'; if(group.type === 'guild'){ - user.guilds.push(group._id); if(user.balance < 1) return res.status(401).json({err: shared.i18n.t('messageInsufficientGems')}); group.balance = 1; @@ -235,31 +202,33 @@ api.create = function(req, res, next) { function(saved,ct,cb){ firebase.updateGroupData(saved); firebase.addUserToGroup(saved._id, user._id); - saved.getTransformedData({ - populateMembers: nameFields, - cb, - }) + saved.populate('members', nameFields, cb); } - ],function(err,groupTransformed){ + ],function(err,saved){ if (err) return next(err); - res.json(groupTransformed); + res.json(saved); group = user = null; }); } else{ - if (user.party._id) return res.status(400).json({err:shared.i18n.t('messageGroupAlreadyInParty')}); - user.party._id = group._id; - user.save(function (err) { + async.waterfall([ + function(cb){ + Group.findOne({type:'party',members:{$in:[user._id]}},cb); + }, + function(found, cb){ + if (found) return cb(shared.i18n.t('messageGroupAlreadyInParty')); + group.save(cb); + }, + function(saved, count, cb){ + firebase.updateGroupData(saved); + firebase.addUserToGroup(saved._id, user._id); + saved.populate('members', nameFields, cb); + } + ], function(err, populated){ + if (err === shared.i18n.t('messageGroupAlreadyInParty')) return res.status(400).json({err:err}); if (err) return next(err); - group.save(function(err, saved) { - if (err) return next(err); - saved.getTransformedData({ - populateMembers: nameFields, - cb (err, groupTransformed) { - res.json(groupTransformed); - }, - }); - }); + group = user = null; + return res.json(populated); }) } } @@ -272,7 +241,7 @@ api.update = function(req, res, next) { return res.status(401).json({err: shared.i18n.t('messageGroupOnlyLeaderCanUpdate')}); 'name description logo logo leaderMessage leader leaderOnly'.split(' ').forEach(function(attr){ - if (req.body[attr]) group[attr] = req.body[attr]; + group[attr] = req.body[attr]; }); group.save(function(err, saved){ @@ -286,11 +255,8 @@ api.update = function(req, res, next) { // TODO remove from api object? api.attachGroup = function(req, res, next) { var user = res.locals.user; - var gid = req.params.gid === 'party' ? user.party._id : req.params.gid; - if (gid === 'habitrpg') gid = TAVERN_ID; - - let q = Group.findOne({_id: gid}) - + var gid = req.params.gid; + var q = (gid == 'party') ? Group.findOne({type: 'party', members: {'$in': [res.locals.user._id]}}) : Group.findById(gid); q.exec(function(err, group){ if(err) return next(err); if(!group) return res.status(404).json({err: shared.i18n.t('messageGroupNotFound')}); @@ -308,22 +274,13 @@ api.getChat = function(req, res, next) { // TODO: This code is duplicated from api.get - pull it out into a function to remove duplication. var user = res.locals.user; var gid = req.params.gid; - - var q; - let isUserGuild = user.guilds.indexOf(gid) !== -1; - - if (gid === 'party' || gid === user.party._id) { - q = Group.findOne({_id: user.party._id, type: 'party'}) - } else { - if (isUserGuild) { - q = Group.findOne({type: 'guild', _id: gid}); - } else if (gid === 'habitrpg') { - q = Group.findOne({_id: TAVERN_ID}); - } else { - q = Group.findOne({type: 'guild', privacy: 'public', _id: gid}); - } - } - + var q = (gid == 'party') + ? Group.findOne({type: 'party', members: {$in:[user._id]}}) + : Group.findOne({$or:[ + {_id:gid, privacy:'public'}, + {_id:gid, privacy:'private', members: {$in:[user._id]}} + ]}); + populateQuery(gid, q); q.exec(function(err, group){ if (err) return next(err); if (!group && gid!=='party') return res.status(404).json({err: shared.i18n.t('messageGroupNotFound')}); @@ -346,7 +303,7 @@ api.postChat = function(req, res, next) { var lastClientMsg = req.query.previousMsg; var chatUpdated = (lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg) ? true : false; - group.sendChat(req.query.message, user); // TODO this should be body, but ngResource is funky + group.sendChat(req.query.message, user); // FIXME this should be body, but ngResource is funky if (group.type === 'party') { user.party.lastMessageSeen = group.chat[0].id; @@ -429,7 +386,7 @@ api.flagChatMessage = function(req, res, next){ {name: "GROUP_NAME", content: group.name}, {name: "GROUP_TYPE", content: group.type}, {name: "GROUP_ID", content: group._id}, - {name: "GROUP_URL", content: group._id == TAVERN_ID ? '/#/options/groups/tavern' : (group.type === 'guild' ? ('/#/options/groups/guilds/' + group._id) : 'party')}, + {name: "GROUP_URL", content: group._id == 'habitrpg' ? '/#/options/groups/tavern' : (group.type === 'guild' ? ('/#/options/groups/guilds/' + group._id) : 'party')}, ]); return res.sendStatus(204); @@ -498,9 +455,7 @@ api.join = function(req, res, next) { if (group.type == 'party' && group._id == (user.invitations && user.invitations.party && user.invitations.party.id)) { User.update({_id:user.invitations.party.inviter}, {$inc:{'items.quests.basilist':1}}).exec(); // Reward inviter - user.invitations.party = {}; // Clear invite - user.markModified('invitations.party'); - user.party._id = group._id; + user.invitations.party = undefined; // Clear invite user.save(); // invite new user to pending quest if (group.quest.key && !group.quest.active) { @@ -509,29 +464,20 @@ api.join = function(req, res, next) { group.markModified('quest.members'); } isUserInvited = true; - } else if (group.type == 'guild') { + } else if (group.type == 'guild' && user.invitations && user.invitations.guilds) { var i = _.findIndex(user.invitations.guilds, {id:group._id}); if (~i){ isUserInvited = true; user.invitations.guilds.splice(i,1); - user.guilds.push(group._id); user.save(); }else{ isUserInvited = group.privacy === 'private' ? false : true; - if (isUserInvited) { - user.guilds.push(group._id); - user.save(); - } } } if(!isUserInvited) return res.status(401).json({err: shared.i18n.t('messageGroupRequiresInvite')}); - if (group.memberCount === 0) { - group.leader = user._id; - } - - /*if (!_.contains(group.members, user._id)){ + if (!_.contains(group.members, user._id)){ if (group.members.length === 0) { group.leader = user._id; } @@ -541,7 +487,7 @@ api.join = function(req, res, next) { if (group.invites.length > 0) { group.invites.splice(_.indexOf(group.invites, user._id), 1); } - }*/ + } async.series([ function(cb){ @@ -549,12 +495,8 @@ api.join = function(req, res, next) { }, function(cb){ firebase.addUserToGroup(group._id, user._id); - group.getTransformedData({ - cb, - populateMembers: group.type === 'party' ? partyFields : nameFields, - populateInvites: nameFields, - populateChallenges: challengeFields, - }) + // TODO why query group once again? + populateQuery(group.type, Group.findById(group._id)).exec(cb); } ], function(err, results){ if (err) return next(err); @@ -581,9 +523,12 @@ api.leave = function(req, res, next) { // When removing the user from challenges, should we keep the tasks? var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all'; - group.leave(user, keep) - .then(() => res.sendStatus(204)) - .catch(next); + group.leave(user, keep, function(err){ + if (err) return next(err); + user = group = keep = null; + + return res.sendStatus(204); + }); }; var inviteByUUIDs = function(uuids, group, req, res, next){ @@ -593,7 +538,7 @@ var inviteByUUIDs = function(uuids, group, req, res, next){ if (!invite) return cb({code:400,err:'User with id "' + uuid + '" not found'}); if (group.type == 'guild') { - if (_.contains(invite.guilds, group._id)) + if (_.contains(group.members,uuid)) return cb({code:400, err: "User already in that group"}); if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id})) return cb({code:400, err:"User already invited to that group"}); @@ -601,10 +546,13 @@ var inviteByUUIDs = function(uuids, group, req, res, next){ } else if (group.type == 'party') { if (invite.invitations && !_.isEmpty(invite.invitations.party)) return cb({code: 400,err:"User already pending invitation."}); - if (invite.party && invite.party._id) { - return cb({code: 400, err: "User already in a party."}) - } - sendInvite(); + Group.find({type: 'party', members: {$in: [uuid]}}, function(err, groups){ + if (err) return cb(err); + if (!_.isEmpty(groups) && groups[0].members.length > 1) { + return cb({code: 400, err: "User already in a party."}) + } + sendInvite(); + }); } function sendInvite (){ @@ -619,7 +567,7 @@ var inviteByUUIDs = function(uuids, group, req, res, next){ pushNotify.sendNotify(invite, shared.i18n.t('invitedParty'), group.name); } - //group.invites.push(invite._id); + group.invites.push(invite._id); async.series([ function(cb){ @@ -662,17 +610,10 @@ var inviteByUUIDs = function(uuids, group, req, res, next){ }, function(cb) { // TODO pass group from save above don't find it again, or you have to find it again in order to run populate? - Group.findById(group._id).populate('leader', nameFields).exec(function (err, savedGroup) { - if (err) return next(err); - savedGroup.getTransformedData({ - cb: function (err, transformedGroup) { - if (err) return next(err); - res.json(transformedGroup); - }, - populateMembers: savedGroup.type === 'party' ? partyFields : nameFields, - populateInvites: nameFields, - populateChallenges: challengeFields, - }) + populateQuery(group.type, Group.findById(group._id)).exec(function(err, populatedGroup){ + if(err) return next(err); + + res.json(populatedGroup); }); } ]); @@ -740,17 +681,10 @@ var inviteByEmails = function(invites, group, req, res, next){ api.invite = function(req, res, next){ var group = res.locals.group; - let userParty = res.locals.user.party && res.locals.user.party._id; - let userGuilds = res.locals.user.guilds; - if (group.type === 'party' && userParty !== group._id) { + if (group.privacy === 'private' && !_.contains(group.members,res.locals.user._id)) { return res.status(401).json({err: "Only a member can invite new members!"}); } - - if (group.type === 'guild' && group.privacy === 'private' && !_.contains(userGuilds, group._id)) { - return res.status(401).json({err: "Only a member can invite new members!"}); - } - if (req.body.uuids) { inviteByUUIDs(req.body.uuids, group, req, res, next); } else if (req.body.emails) { @@ -786,35 +720,28 @@ api.removeMember = function(req, res, next){ return res.status(401).json({err: "You cannot remove yourself!"}); } - User.findById(uuid, function(err, removedUser){ - if (err) return next(err); - let isMember = group._id === removedUser.party._id || _.contains(removedUser.guilds, group._id); - let isInvited = group._id === removedUser.invitations.party._id || !!_.find(removedUser.invitations.guilds, {id: group._id}); + if(_.contains(group.members, uuid)){ + var update = {$pull:{members:uuid}}; + if (group.quest && group.quest.leader === uuid) { + update['$set'] = { + quest: { key: null, leader: null } + }; + } else if(group.quest && group.quest.members){ + // remove member from quest + update['$unset'] = {}; + update['$unset']['quest.members.' + uuid] = ""; + } + update['$inc'] = {memberCount: -1}; + Group.update({_id:group._id},update, function(err, saved){ + if (err) return next(err); - if(isMember){ - var update = {}; - if (group.quest && group.quest.leader === uuid) { - update['$set'] = { - quest: { key: null, leader: null } - }; - } else if(group.quest && group.quest.members){ - // remove member from quest - update['$unset'] = {}; - update['$unset']['quest.members.' + uuid] = ""; - } - update['$inc'] = {memberCount: -1}; - Group.update({_id:group._id},update, function(err, saved){ - if (err) return next(err); + User.findById(uuid, function(err, removedUser){ + if(err) return next(err); sendMessage(removedUser); //Mark removed users messages as seen var update = {$unset:{}}; - if (group.type === 'guild') { - update.$pull = {guilds: group._id}; - } else { - update.$unset.party = true; - } update.$unset['newMessages.' + group._id] = ''; if (group.quest && group.quest.active && group.quest.leader === uuid) { update['$inc'] = {}; @@ -827,8 +754,12 @@ api.removeMember = function(req, res, next){ group = uuid = null; return res.sendStatus(204); }); - }else if(isInvited){ - var invitations = removedUser.invitations; + }); + }else if(_.contains(group.invites, uuid)){ + User.findById(uuid, function(err,invited){ + if(err) return next(err); + + var invitations = invited.invitations; if(group.type === 'guild'){ invitations.guilds.splice(_.indexOf(invitations.guilds, group._id), 1); }else{ @@ -837,32 +768,31 @@ api.removeMember = function(req, res, next){ async.series([ function(cb){ - removedUser.save(cb); + invited.save(cb); }, + function(cb){ + Group.update({_id:group._id},{$pull:{invites:uuid}}, cb); + } ], function(err, results){ if (err) return next(err); // Sending an empty 204 because Group.update doesn't return the group // see http://mongoosejs.com/docs/api.html#model_Model.update - sendMessage(removedUser); + sendMessage(invited); group = uuid = null; return res.sendStatus(204); }); - }else{ - group = uuid = null; - return res.status(400).json({err: "User not found among group's members!"}); - } - }); + + }); + }else{ + group = uuid = null; + return res.status(400).json({err: "User not found among group's members!"}); + } } // ------------------------------------ // Quests // ------------------------------------ -function canStartQuestAutomatically (group) { - // If all members are either true (accepted) or false (rejected) return true - // If any member is null/undefined (undecided) return false - return _.every(group.quest.members, _.isBoolean); -} function questStart(req, res, next) { var group = res.locals.group; @@ -978,103 +908,70 @@ api.questAccept = function(req, res, next) { if (quest.lvl && user.stats.lvl < quest.lvl) return res.status(400).json({err: "You must be level "+quest.lvl+" to begin this quest."}); if (group.quest.key) return res.status(400).json({err: 'Your party is already on a quest. Try again when the current quest has ended.'}); if (!user.items.quests[key]) return res.status(400).json({err: "You don't own that quest scroll"}); - - let members; + group.quest.key = key; + group.quest.members = {}; + // Invite everyone. true means "accepted", false="rejected", undefined="pending". Once we click "start quest" + // or everyone has either accepted/rejected, then we store quest key in user object. + _.each(group.members, function(m){ + if (m == user._id) { + var analyticsData = { + category: 'behavior', + owner: true, + response: 'accept', + gaLabel: 'accept', + questName: key, + uuid: user._id, + }; + analytics.track('quest',analyticsData); + group.quest.members[m] = true; + group.quest.leader = user._id; + } else { + User.update({_id:m},{$set: {'party.quest.RSVPNeeded': true, 'party.quest.key': group.quest.key}}).exec(); + group.quest.members[m] = undefined; + } + }); User.find({ - 'party._id': group._id, - _id: {$ne: user._id}, - }).select('auth.facebook auth.local preferences.emailNotifications profile.name pushDevices') - .exec().then(membersF => { - members = membersF; + _id: { + $in: _.without(group.members, user._id) + } + }, {auth: 1, preferences: 1, profile: 1, pushDevices: 1}, function(err, members){ + if(err) return next(err); - group.markModified('quest'); - group.quest.key = key; - group.quest.leader = user._id; - group.quest.members = {}; - group.quest.members[user._id] = true; + var inviterVars = utils.getUserInfo(user, ['name', 'email']); - user.party.quest.RSVPNeeded = false; - user.party.quest.key = key; - - return User.update({ - 'party._id': group._id, - _id: {$ne: user._id}, - }, { - $set: { - 'party.quest.RSVPNeeded': true, - 'party.quest.key': key, - }, - }, {multi: true}).exec(); - }).then(() => { - _.each(members, (member) => { - group.quest.members[member._id] = null; + var membersToEmail = members.filter(function(member){ + return member.preferences.emailNotifications.invitedQuest !== false; }); - if (canStartQuestAutomatically(group)) { - group.startQuest(user).then(() => { - return Bluebird.all([group.save(), user.save()]) - }) - .then(results => { - results[0].getTransformedData({ - cb (err, groupTransformed) { - if (err) return next(err); - res.json(groupTransformed); - }, - populateMembers: group.type === 'party' ? partyFields : nameFields, - }); - }) - .catch(next); + utils.txnEmail(membersToEmail, ('invite-' + (quest.boss ? 'boss' : 'collection') + '-quest'), [ + {name: 'QUEST_NAME', content: quest.text()}, + {name: 'INVITER', content: inviterVars.name}, + {name: 'PARTY_URL', content: '/#/options/groups/party'} + ]); - } else { - Bluebird.all([group.save(), user.save()]) - .then(results => { - results[0].getTransformedData({ - cb (err, groupTransformed) { - if (err) return next(err); - res.json(groupTransformed); - }, - populateMembers: group.type === 'party' ? partyFields : nameFields, - }); - }) - .catch(next); - } - }).catch(next); + _.each(members, function(groupMember){ + pushNotify.sendNotify(groupMember, shared.i18n.t('questInvitationTitle'), shared.i18n.t('questInvitationInfo', { quest: quest.text() })); + }); + + questStart(req,res,next); + }); // Party member accepting the invitation } else { - group.markModified('quest'); + if (!group.quest.key) return res.status(400).json({err:'No quest invitation has been sent out yet.'}); + var analyticsData = { + category: 'behavior', + owner: false, + response: 'accept', + gaLabel: 'accept', + questName: group.quest.key, + uuid: user._id, + }; + analytics.track('quest',analyticsData); group.quest.members[user._id] = true; - user.party.quest.RSVPNeeded = false; - - if (canStartQuestAutomatically(group)) { - group.startQuest(user).then(() => { - return Bluebird.all([group.save(), user.save()]) - }) - .then(results => { - results[0].getTransformedData({ - cb (err, groupTransformed) { - if (err) return next(err); - res.json(groupTransformed); - }, - populateMembers: group.type === 'party' ? partyFields : nameFields, - }); - }) - .catch(next); - - } else { - Bluebird.all([group.save(), user.save()]) - .then(results => { - results[0].getTransformedData({ - cb (err, groupTransformed) { - if (err) return next(err); - res.json(groupTransformed); - }, - populateMembers: group.type === 'party' ? partyFields : nameFields, - }); - }) - .catch(next); - } + User.update({_id:user._id}, {$set: {'party.quest.RSVPNeeded': false}}).exec(); + questStart(req,res,next); } } @@ -1082,102 +979,84 @@ api.questReject = function(req, res, next) { var group = res.locals.group; var user = res.locals.user; + if (!group.quest.key) return res.status(400).json({err:'No quest invitation has been sent out yet.'}); + var analyticsData = { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }; + analytics.track('quest',analyticsData); group.quest.members[user._id] = false; - group.markModified('quest.members'); - - user.party.quest = Group.cleanQuestProgress(); - user.markModified('party.quest'); - - if (canStartQuestAutomatically(group)) { - group.startQuest(user).then(() => { - return Bluebird.all([group.save(), user.save()]) - }) - .then(results => { - results[0].getTransformedData({ - cb (err, groupTransformed) { - if (err) return next(err); - res.json(groupTransformed); - }, - populateMembers: group.type === 'party' ? partyFields : nameFields, - }); - }) - .catch(next); - - } else { - Bluebird.all([group.save(), user.save()]) - .then(results => { - results[0].getTransformedData({ - cb (err, groupTransformed) { - if (err) return next(err); - res.json(groupTransformed); - }, - populateMembers: group.type === 'party' ? partyFields : nameFields, - }); - }) - .catch(next); - } + User.update({_id:user._id}, {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}).exec(); + questStart(req,res,next); } api.questCancel = function(req, res, next){ - var group = res.locals.group; - - group.quest = Group.cleanGroupQuest(); - group.markModified('quest'); - - Bluebird.all([ - group.save(), - User.update( - {'party._id': group._id}, - {$set: {'party.quest': Group.cleanQuestProgress()}}, - {multi: true} - ), - ]).then(results => { - results[0].getTransformedData({ - cb (err, groupTransformed) { - if (err) return next(err); - res.json(groupTransformed); - }, - populateMembers: group.type === 'party' ? partyFields : nameFields, - }); - }).catch(next); - // Cancel a quest BEFORE it has begun (i.e., in the invitation stage) // Quest scroll has not yet left quest owner's inventory so no need to return it. // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started. + var group = res.locals.group; + async.parallel([ + function(cb){ + if (! group.quest.active) { + // Do not cancel active quests because this function does + // not do the clean-up required for that. + // TODO: return an informative error when quest is active + group.quest = {key:null,progress:{},leader:null}; + group.markModified('quest'); + group.save(cb); + _.each(group.members, function(m){ + User.update({_id:m}, {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}).exec(); + }); + } + } + ], function(err){ + if (err) return next(err); + res.json(group); + group = null; + }) } api.questAbort = function(req, res, next){ + // Abort a quest AFTER it has begun (see questCancel for BEFORE) var group = res.locals.group; - - let memberUpdates = User.update({ - 'party._id': group._id, - }, { - $set: {'party.quest': Group.cleanQuestProgress()}, - $inc: {_v: 1}, // TODO update middleware - }, {multi: true}).exec(); - - let questLeaderUpdate = User.update({ - _id: group.quest.leader, - }, { - $inc: { - [`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader + async.parallel([ + function(cb){ + User.update( + {_id:{$in: _.keys(group.quest.members)}}, + { + $set: {'party.quest':Group.cleanQuestProgress()}, + $inc: {_v:1} + }, + {multi:true}, + cb); }, - }).exec(); + // Refund party leader quest scroll + function(cb){ + if (group.quest.active) { + var update = {$inc:{}}; + update['$inc']['items.quests.' + group.quest.key] = 1; + User.update({_id:group.quest.leader}, update).exec(); + } + group.quest = {key:null,progress:{},leader:null}; + group.markModified('quest'); + group.save(cb); + }, function(cb){ + populateQuery(group.type, Group.findById(group._id)).exec(cb); + } + ], function(err, results){ + if (err) return next(err); - group.quest = Group.cleanGroupQuest(); - group.markModified('quest'); + var groupClone = clone(group); - Bluebird.all([group.save(), memberUpdates, questLeaderUpdate]) - .then(results => { - results[0].getTransformedData({ - cb (err, groupTransformed) { - if (err) return next(err); - res.json(groupTransformed); - }, - populateMembers: group.type === 'party' ? partyFields : nameFields, - }); + groupClone.members = results[2].members; + + res.json(groupClone); + group = null; }) - .catch(next); } api.questLeave = function(req, res, next) { @@ -1197,16 +1076,16 @@ api.questLeave = function(req, res, next) { return res.status(403).json({ err: 'Quest leader cannot leave quest' }); } - group.quest.members[user._id] = false; + delete group.quest.members[user._id]; group.markModified('quest.members'); user.party.quest = Group.cleanQuestProgress(); user.markModified('party.quest'); - var groupSavePromise = Bluebird.promisify(group.save, {context: group}); - var userSavePromise = Bluebird.promisify(user.save, {context: user}); + var groupSavePromise = Q.nbind(group.save, group); + var userSavePromise = Q.nbind(user.save, user); - Bluebird.all([groupSavePromise(), userSavePromise()]) + Q.all([groupSavePromise(), userSavePromise()]) .done(function(values) { return res.sendStatus(204); }, function(error) { diff --git a/website/server/controllers/api-v2/hall.js b/website/src/controllers/api-v2/hall.js similarity index 96% rename from website/server/controllers/api-v2/hall.js rename to website/src/controllers/api-v2/hall.js index 05d3bf520c..ec88894c62 100644 --- a/website/server/controllers/api-v2/hall.js +++ b/website/src/controllers/api-v2/hall.js @@ -2,12 +2,8 @@ var _ = require('lodash'); var nconf = require('nconf'); var async = require('async'); var shared = require('../../../../common'); -import { - model as User, -} from '../../models/user'; -import { - model as Group, -} from '../../models/group'; +var User = require('./../../models/user').model; +var Group = require('./../../models/group').model; var api = module.exports; api.ensureAdmin = function(req, res, next) { diff --git a/website/server/controllers/api-v2/members.js b/website/src/controllers/api-v2/members.js similarity index 90% rename from website/server/controllers/api-v2/members.js rename to website/src/controllers/api-v2/members.js index 232bb9ed73..01c136c472 100644 --- a/website/server/controllers/api-v2/members.js +++ b/website/src/controllers/api-v2/members.js @@ -1,18 +1,13 @@ -import { - model as groups, - chatDefaults, -} from '../../models/group'; -import { - model as User, -} from '../../models/user'; -let partyFields = require('./groups').partyFields; +var User = require('mongoose').model('User'); +var groups = require('../../models/group'); +var partyFields = require('./groups').partyFields var api = module.exports; var async = require('async'); var _ = require('lodash'); var shared = require('../../../../common'); -var utils = require('../../libs/api-v2/utils'); +var utils = require('../../libs/utils'); var nconf = require('nconf'); -var pushNotify = require('./pushNotifications'); +var pushNotify = require('./../pushNotifications'); var fetchMember = function(uuid, restrict){ return function(cb){ @@ -54,12 +49,12 @@ api.sendMessage = function(user, member, data){ } msg += data.message ? data.message : ''; } - shared.refPush(member.inbox.messages, chatDefaults(msg, user)); + shared.refPush(member.inbox.messages, groups.chatDefaults(msg, user)); member.inbox.newMessages++; member._v++; member.markModified('inbox.messages'); - shared.refPush(user.inbox.messages, _.defaults({sent:true}, chatDefaults(msg, member))); + shared.refPush(user.inbox.messages, _.defaults({sent:true}, groups.chatDefaults(msg, member))); user.markModified('inbox.messages'); } diff --git a/website/server/controllers/api-v2/unsubscription.js b/website/src/controllers/api-v2/unsubscription.js similarity index 80% rename from website/server/controllers/api-v2/unsubscription.js rename to website/src/controllers/api-v2/unsubscription.js index a91db19d86..2fecbef03f 100644 --- a/website/server/controllers/api-v2/unsubscription.js +++ b/website/src/controllers/api-v2/unsubscription.js @@ -1,10 +1,6 @@ -import { - model as User, -} from '../../models/user'; -import { - model as EmailUnsubscription, -} from '../../models/emailUnsubscription'; -var utils = require('../../libs/api-v2/utils'); +var User = require('../../models/user').model; +var EmailUnsubscription = require('../../models/emailUnsubscription').model; +var utils = require('../../libs/utils'); var i18n = require('../../../../common').i18n; var api = module.exports = {}; @@ -19,7 +15,7 @@ api.unsubscribe = function(req, res, next){ $set: {'preferences.emailNotifications.unsubscribeFromAll': true} }, {multi: false}, function(err, updateRes){ if(err) return next(err); - if(updateRes.n !== 1) return res.json(404, {err: 'User not found'}); + if(updateRes !== 1) return res.status(404).json({err: 'User not found'}); res.send('

' + i18n.t('unsubscribedSuccessfully', null, req.language) + '

' + i18n.t('unsubscribedTextUsers', null, req.language)); }); diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js new file mode 100644 index 0000000000..da57347d15 --- /dev/null +++ b/website/src/controllers/api-v2/user.js @@ -0,0 +1,707 @@ +var url = require('url'); +var ipn = require('paypal-ipn'); +var _ = require('lodash'); +var nconf = require('nconf'); +var async = require('async'); +var shared = require('../../../../common'); +var User = require('./../../models/user').model; +var utils = require('./../../libs/utils'); +var analytics = utils.analytics; +var Group = require('./../../models/group').model; +var Challenge = require('./../../models/challenge').model; +var moment = require('moment'); +var logging = require('./../../libs/logging'); +let acceptablePUTPaths; +let restrictedPUTSubPaths; + +var api = module.exports; +var firebase = require('../../libs/firebase'); +var webhook = require('../../libs/webhook'); + +// api.purchase // Shared.ops + +api.getContent = function(req, res, next) { + var language = 'en'; + + if (typeof req.query.language != 'undefined') + language = req.query.language.toString(); //|| 'en' in i18n + + var content = _.cloneDeep(shared.content); + var walk = function(obj, lang){ + _.each(obj, function(item, key, source){ + if (_.isPlainObject(item) || _.isArray(item)) return walk(item, lang); + if (_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang); + }); + } + walk(content, language); + res.json(content); +} + +api.getModelPaths = function(req,res,next){ + res.json(_.reduce(User.schema.paths,function(m,v,k){ + m[k] = v.instance || 'Boolean'; + return m; + },{})); +} + +/* + ------------------------------------------------------------------------ + Tasks + ------------------------------------------------------------------------ +*/ + + +/* + Local Methods + --------------- +*/ + +var findTask = function(req, res) { + return res.locals.user.tasks[req.params.id]; +}; + +/* + API Routes + --------------- +*/ + +api.score = function(req, res, next) { + var id = req.params.id, + direction = req.params.direction, + user = res.locals.user, + task; + + var clearMemory = function(){user = task = id = direction = null;} + + // Send error responses for improper API call + if (!id) return res.status(400).json({err: ':id required'}); + if (direction !== 'up' && direction !== 'down') { + if (direction == 'unlink' || direction == 'sort') return next(); + return res.status(400).json({err: ":direction must be 'up' or 'down'"}); + } + // If exists already, score it + if (task = user.tasks[id]) { + // Set completed if type is daily or todo and task exists + if (task.type === 'daily' || task.type === 'todo') { + task.completed = direction === 'up'; + } + } else { + // If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it + // Defaults. Other defaults are handled in user.ops.addTask() + task = { + id: id, + type: req.body && req.body.type, + text: req.body && req.body.text, + notes: (req.body && req.body.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." + }; + + if (task.type === 'daily' || task.type === 'todo') + task.completed = direction === 'up'; + + task = user.ops.addTask({body:task}); + } + var delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language}); + + user.save(function(err, saved){ + if (err) return next(err); + + var userStats = saved.toJSON().stats; + var resJsonData = _.extend({ delta: delta, _tmp: user._tmp }, userStats); + res.status(200).json(resJsonData); + + var webhookData = _generateWebhookTaskData( + task, direction, delta, userStats, user + ); + webhook.sendTaskWebhook(user.preferences.webhooks, webhookData); + + if ( + (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there + || (task.type == 'reward') // we don't want to update the reward GP cost + ) return clearMemory(); + + Challenge.findById(task.challenge.id, 'habits dailys todos rewards', function(err, chal) { + if (err) return next(err); + if (!chal) { + task.challenge.broken = 'CHALLENGE_DELETED'; + user.save(); + return clearMemory(); + } + var t = chal.tasks[task.id]; + // this task was removed from the challenge, notify user + if (!t) { + chal.syncToUser(user); + return clearMemory(); + } + + t.value += delta; + if (t.type == 'habit' || t.type == 'daily') { + t.history.push({value: t.value, date: +new Date}); + } + chal.save(); + clearMemory(); + }); + }); +}; + +/** + * Get all tasks + */ +api.getTasks = function(req, res, next) { + var user = res.locals.user; + if (req.query.type) { + return res.json(user[req.query.type+'s']); + } else { + return res.json(_.toArray(user.tasks)); + } +}; + +/** + * Get Task + */ +api.getTask = function(req, res, next) { + var task = findTask(req,res); + if (!task) return res.status(404).json({err: shared.i18n.t('messageTaskNotFound')}); + return res.status(200).json(task); +}; + + +/* + Update Task +*/ + +//api.deleteTask // see Shared.ops +// api.updateTask // handled in Shared.ops +// api.addTask // handled in Shared.ops +// api.sortTask // handled in Shared.ops #TODO updated api, mention in docs + +/* + ------------------------------------------------------------------------ + Items + ------------------------------------------------------------------------ +*/ +// api.buy // handled in Shard.ops + +api.getBuyList = function (req, res, next) { + var list = shared.updateStore(res.locals.user); + return res.status(200).json(list); +}; + +/* + ------------------------------------------------------------------------ + User + ------------------------------------------------------------------------ +*/ + +/** + * Get User + */ +api.getUser = function(req, res, next) { + var user = res.locals.user.toJSON(); + user.stats.toNextLevel = shared.tnl(user.stats.lvl); + user.stats.maxHealth = shared.maxHealth; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; + delete user.apiToken; + if (user.auth && user.auth.local) { + delete user.auth.local.hashed_password; + delete user.auth.local.salt; + } + return res.status(200).json(user); +}; + +/** + * Get anonymized User + */ +api.getUserAnonymized = function(req, res, next) { + var user = res.locals.user.toJSON(); + user.stats.toNextLevel = shared.tnl(user.stats.lvl); + user.stats.maxHealth = shared.maxHealth; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; + + delete user.apiToken; + + if (user.auth) { + delete user.auth.local; + delete user.auth.facebook; + } + + delete user.newMessages; + + delete user.profile; + delete user.purchased.plan; + delete user.contributor; + delete user.invitations; + + delete user.items.special.nyeReceived; + delete user.items.special.valentineReceived; + + delete user.webhooks; + delete user.achievements.challenges; + + _.forEach(user.inbox.messages, function(msg){ + msg.text = "inbox message text"; + }); + + _.forEach(user.tags, function(tag){ + tag.name = "tag"; + tag.challenge = "challenge"; + }); + + function cleanChecklist(task){ + var checklistIndex = 0; + + _.forEach(task.checklist, function(c){ + c.text = "item" + checklistIndex++; + }); + } + + _.forEach(user.habits, function(task){ + task.text = "task text"; + task.notes = "task notes"; + }); + + _.forEach(user.rewards, function(task){ + task.text = "task text"; + task.notes = "task notes"; + }); + + _.forEach(user.dailys, function(task){ + task.text = "task text"; + task.notes = "task notes"; + + cleanChecklist(task); + }); + + _.forEach(user.todos, function(task){ + task.text = "task text"; + task.notes = "task notes"; + + cleanChecklist(task); + }); + + return res.status(200).json(user); +}; + +/** + * This tells us for which paths users can call `PUT /user` (or batch-update equiv, which use `User.set()` on our client). + * The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs) + * FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations + */ +acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, (m, v, leaf) => { + let updatablePaths = 'achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '); + let found = _.find(updatablePaths, (rootPath) => { + return leaf.indexOf(rootPath) === 0; + }); + + if (found) m[leaf] = true; + + return m; +}, {}); + +restrictedPUTSubPaths = 'stats.class'.split(' '); + +_.each(restrictedPUTSubPaths, (removePath) => { + delete acceptablePUTPaths[removePath]; +}); + +let requiresPurchase = { + 'preferences.background': 'background', + 'preferences.shirt': 'shirt', + 'preferences.size': 'size', + 'preferences.skin': 'skin', + 'preferences.chair': 'chair', + 'preferences.hair.bangs': 'hair.bangs', + 'preferences.hair.base': 'hair.base', + 'preferences.hair.beard': 'hair.beard', + 'preferences.hair.color': 'hair.color', + 'preferences.hair.flower': 'hair.flower', + 'preferences.hair.mustache': 'hair.mustache', +}; + +let checkPreferencePurchase = (user, path, item) => { + let itemPath = `${path}.${item}`; + let appearance = _.get(shared.content.appearances, itemPath) + if (!appearance) return false; + if (appearance.price === 0) return true; + + return _.get(user.purchased, itemPath); +}; + +/** + * Update user + * Send up PUT /user as `req.body={path1:val, path2:val, etc}`. Example: + * PUT /user {'stats.hp':50, 'tasks.TASK_ID.repeat.m':false} + * See acceptablePUTPaths for which user paths are supported +*/ +api.update = (req, res, next) => { + let user = res.locals.user; + let errors = []; + + if (_.isEmpty(req.body)) return res.status(200).json(user); + + _.each(req.body, (v, k) => { + let purchasable = requiresPurchase[k]; + + if (purchasable && !checkPreferencePurchase(user, purchasable, v)) { + return errors.push(`Must purchase ${v} to set it on ${k}`); + } + + if (acceptablePUTPaths[k]) { + user.fns.dotSet(k, v); + } else { + errors.push(shared.i18n.t('messageUserOperationProtected', { operation: k })); + } + return true; + }); + + user.save((err) => { + if (!_.isEmpty(errors)) return res.status(401).json({err: errors}); + if (err) { + if (err.name == 'ValidationError') { + let errorMessages = _.map(_.values(err.errors), (error) => { + return error.message; + }); + return res.status(400).json({err: errorMessages}); + } + return next(err); + } + + res.status(200).json(user); + user = errors = null; + }); +}; + +api.cron = function(req, res, next) { + var user = res.locals.user, + progress = user.fns.cron({analytics:utils.analytics, timezoneOffset:req.headers['x-user-timezoneoffset']}), + ranCron = user.isModified(), + quest = shared.content.quests[user.party.quest.key]; + + if (ranCron) res.locals.wasModified = true; + if (!ranCron) return next(null,user); + Group.tavernBoss(user,progress); + if (!quest) return user.save(next); + + // If user is on a quest, roll for boss & player, or handle collections + // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + async.waterfall([ + function(cb){ + user.save(cb); // make sure to save the cron effects + }, + function(saved, count, cb){ + var type = quest.boss ? 'boss' : 'collect'; + Group[type+'Quest'](user,progress,cb); + }, + function(){ + var cb = arguments[arguments.length-1]; + // User has been updated in boss-grapple, reload + User.findById(user._id, cb); + } + ], function(err, saved) { + res.locals.user = saved; + next(err,saved); + user = progress = quest = null; + }); +}; + +// api.reroll // Shared.ops +// api.reset // Shared.ops + +api.delete = function(req, res, next) { + var user = res.locals.user; + var plan = user.purchased.plan; + + if (plan && plan.customerId && !plan.dateTerminated){ + return res.status(400).json({err:"You have an active subscription, cancel your plan before deleting your account."}); + } + + Group.find({ + members: { + '$in': [user._id] + } + }, function(err, groups){ + if(err) return next(err); + + async.each(groups, function(group, cb){ + group.leave(user, 'remove-all', cb); + }, function(err){ + if(err) return next(err); + + user.remove(function(err){ + if(err) return next(err); + + firebase.deleteUser(user._id); + res.sendStatus(200); + }); + }); + }); +} + +/* + ------------------------------------------------------------------------ + Development Only Operations + ------------------------------------------------------------------------ + */ +if (nconf.get('NODE_ENV') === 'development') { + + api.addTenGems = function(req, res, next) { + var user = res.locals.user; + + user.balance += 2.5; + + user.save(function(err){ + if (err) return next(err); + res.sendStatus(204); + }); + }; + + api.addHourglass = function(req, res, next) { + var user = res.locals.user; + + user.purchased.plan.consecutive.trinkets += 1; + + user.save(function(err){ + if (err) return next(err); + res.sendStatus(204); + }); + }; +} + +/* + ------------------------------------------------------------------------ + Tags + ------------------------------------------------------------------------ + */ +// api.deleteTag // handled in Shared.ops +// api.addTag // handled in Shared.ops +// api.updateTag // handled in Shared.ops +// api.sortTag // handled in Shared.ops + +/* + ------------------------------------------------------------------------ + Spells + ------------------------------------------------------------------------ + */ +api.cast = function(req, res, next) { + var user = res.locals.user, + targetType = req.query.targetType, + targetId = req.query.targetId, + klass = shared.content.spells.special[req.params.spell] ? 'special' : user.stats.class, + spell = shared.content.spells[klass][req.params.spell]; + + if (!spell) return res.status(404).json({err: 'Spell "' + req.params.spell + '" not found.'}); + if (spell.mana > user.stats.mp) return res.status(400).json({err: 'Not enough mana to cast spell'}); + + var done = function(){ + var err = arguments[0]; + var saved = _.size(arguments == 3) ? arguments[2] : arguments[1]; + if (err) return next(err); + res.json(saved); + user = targetType = targetId = klass = spell = null; + } + + switch (targetType) { + case 'task': + if (!user.tasks[targetId]) return res.status(404).json({err: 'Task "' + targetId + '" not found.'}); + spell.cast(user, user.tasks[targetId]); + user.save(done); + break; + + case 'self': + spell.cast(user); + user.save(done); + break; + + case 'party': + case 'user': + async.waterfall([ + function(cb){ + Group.findOne({type: 'party', members: {'$in': [user._id]}}).populate('members', 'profile.name stats achievements items.special').exec(cb); + }, + function(group, cb) { + // Solo player? let's just create a faux group for simpler code + var g = group ? group : {members:[user]}; + var series = [], found; + if (targetType == 'party') { + spell.cast(user, g.members); + series = _.transform(g.members, function(m,v,k){ + m.push(function(cb2){v.save(cb2)}); + }); + } else { + found = _.find(g.members, {_id: targetId}) + spell.cast(user, found); + series.push(function(cb2){found.save(cb2)}); + } + + if (group && !spell.silent) { + series.push(function(cb2){ + var message = '`'+user.profile.name+' casts '+spell.text() + (targetType=='user' ? ' on '+found.profile.name : ' for the party')+'.`'; + group.sendChat(message); + group.save(cb2); + }) + } + + series.push(function(cb2){g = group = series = found = null;cb2();}) + + async.series(series, cb); + }, + function(whatever, cb){ + user.save(cb); + } + ], done); + break; + } +} + +// It supports guild too now but we'll stick to partyInvite for backward compatibility +api.sessionPartyInvite = function(req,res,next){ + if (!req.session.partyInvite) return next(); + var inv = res.locals.user.invitations; + if (inv.party && inv.party.id) return next(); // already invited to a party + async.waterfall([ + function(cb){ + Group.findOne({_id:req.session.partyInvite.id, members:{$in:[req.session.partyInvite.inviter]}}) + .select('invites members type').exec(cb); + }, + function(group, cb){ + if (!group){ + // Don't send error as it will prevent users from using the site + delete req.session.partyInvite; + return cb(); + } + + if (group.type == 'guild'){ + inv.guilds.push(req.session.partyInvite); + } else{ + //req.body.type in 'guild', 'party' + inv.party = req.session.partyInvite; + } + inv.party = req.session.partyInvite; + delete req.session.partyInvite; + if (!~group.invites.indexOf(res.locals.user._id)) + group.invites.push(res.locals.user._id); //$addToSt + group.save(cb); + }, + function(saved, cb){ + res.locals.user.save(cb); + } + ], next); +} + +/** + * All other user.ops which can easily be mapped to common/script/index.js, not requiring custom API-wrapping + */ +_.each(shared.wrap({}).ops, function(op,k){ + if (!api[k]) { + api[k] = function(req, res, next) { + res.locals.user.ops[k](req,function(err, response){ + // If we want to send something other than 500, pass err as {code: 200, message: "Not enough GP"} + if (err) { + if (!err.code) return next(err); + if (err.code >= 400) return res.status(err.code).json({err:err.message}); + // In the case of 200s, they're friendly alert messages like "You're pet has hatched!" - still send the op + } + res.locals.user.save(function(err){ + if (err) return next(err); + res.status(200).json(response); + }) + }, analytics); + } + } +}) + +/* + ------------------------------------------------------------------------ + Batch Update + Run a bunch of updates all at once + ------------------------------------------------------------------------ +*/ +api.batchUpdate = function(req, res, next) { + if (_.isEmpty(req.body)) req.body = []; // cases of {} or null + if (req.body[0] && req.body[0].data) + return res.status(501).json({err: "API has been updated, please refresh your browser or upgrade your mobile app."}) + + var user = res.locals.user; + var oldSend = res.send; + var oldJson = res.json; + + // Stash user.save, we'll queue the save op till the end (so we don't overload the server) + var oldSave = user.save; + user.save = function(cb){cb(null,user)} + + // Setup the array of functions we're going to call in parallel with async + res.locals.ops = []; + var ops = _.transform(req.body, function(m,_req){ + if (_.isEmpty(_req)) return; + _req.language = req.language; + + m.push(function() { + var cb = arguments[arguments.length-1]; + res.locals.ops.push(_req); + res.send = res.json = function(code, data) { + if (_.isNumber(code) && code >= 500) + return cb(code+": "+ (data.message ? data.message : data.err ? data.err : JSON.stringify(data))); + return cb(); + }; + if(!api[_req.op]) { return cb(shared.i18n.t('messageUserOperationNotFound', { operation: _req.op })); } + api[_req.op](_req, res, cb); + }); + }) + // Finally, save user at the end + .concat(function(){ + user.save = oldSave; + user.save(arguments[arguments.length-1]); + }); + + // call all the operations, then return the user object to the requester + async.waterfall(ops, function(err,_user) { + res.json = oldJson; + res.send = oldSend; + if (err) return next(err); + + var response = _user.toJSON(); + response.wasModified = res.locals.wasModified; + + user.fns.nullify(); + user = res.locals.user = oldSend = oldJson = oldSave = null; + + // return only drops & streaks + if (response._tmp && response._tmp.drop){ + res.status(200).json({_tmp: {drop: response._tmp.drop}, _v: response._v}); + + // Fetch full user object + } else if (response.wasModified){ + // Preen 3-day past-completed To-Dos from Angular & mobile app + response.todos = shared.preenTodos(response.todos); + res.status(200).json(response); + + // return only the version number + } else{ + res.status(200).json({_v: response._v}); + } + }); +}; + +function _generateWebhookTaskData(task, direction, delta, stats, user) { + var extendedStats = _.extend(stats, { + toNextLevel: shared.tnl(user.stats.lvl), + maxHealth: shared.maxHealth, + maxMP: user._statsComputed.maxMP + }); + + var userData = { + _id: user._id, + _tmp: user._tmp, + stats: extendedStats + }; + + var taskData = { + details: task, + direction: direction, + delta: delta + } + + return { + task: taskData, + user: userData + } +} diff --git a/website/server/controllers/api-v2/dataexport.js b/website/src/controllers/dataexport.js similarity index 89% rename from website/server/controllers/api-v2/dataexport.js rename to website/src/controllers/dataexport.js index f0aad2b35c..8c16b9864f 100644 --- a/website/server/controllers/api-v2/dataexport.js +++ b/website/src/controllers/dataexport.js @@ -5,17 +5,15 @@ var nconf = require('nconf'); var moment = require('moment'); var js2xmlparser = require("js2xmlparser"); var pd = require('pretty-data').pd; -import { - model as User, -} from '../../models/user'; +var User = require('../models/user').model; // Avatar screenshot/static-page includes -//var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres -//var AWS = require('aws-sdk'); -//AWS.config.update({accessKeyId: nconf.get("S3:accessKeyId"), secretAccessKey: nconf.get("S3:secretAccessKey")}); -//var s3Stream = require('s3-upload-stream')(new AWS.S3()); //https://github.com/nathanpeck/s3-upload-stream -//var bucket = nconf.get("S3:bucket"); -//var request = require('request'); +var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres +var AWS = require('aws-sdk'); +AWS.config.update({accessKeyId: nconf.get("S3:accessKeyId"), secretAccessKey: nconf.get("S3:secretAccessKey")}); +var s3Stream = require('s3-upload-stream')(new AWS.S3()); //https://github.com/nathanpeck/s3-upload-stream +var bucket = nconf.get("S3:bucket"); +var request = require('request'); /* ------------------------------------------------------------------------ @@ -44,7 +42,7 @@ dataexport.history = function(req, res) { res.set({ 'Content-Type': 'text/csv', - 'Content-disposition': 'attachment; filename=habitica-tasks-history.csv', + 'Content-disposition': `attachment; filename=habitica-tasks-history.csv`, }); csvStringify(output, (err, csv) => { diff --git a/website/src/controllers/payments/amazon.js b/website/src/controllers/payments/amazon.js new file mode 100644 index 0000000000..8c01663c10 --- /dev/null +++ b/website/src/controllers/payments/amazon.js @@ -0,0 +1,271 @@ +var amazonPayments = require('amazon-payments'); +var mongoose = require('mongoose'); +var moment = require('moment'); +var nconf = require('nconf'); +var async = require('async'); +var User = require('mongoose').model('User'); +var shared = require('../../../../common'); +var payments = require('./index'); +var cc = require('coupon-code'); +var isProd = nconf.get('NODE_ENV') === 'production'; + +var amzPayment = amazonPayments.connect({ + environment: amazonPayments.Environment[isProd ? 'Production' : 'Sandbox'], + sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), + mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), + mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), + clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID') +}); + +exports.verifyAccessToken = function(req, res, next){ + if(!req.body || !req.body['access_token']){ + return res.status(400).json({err: 'Access token not supplied.'}); + } + + amzPayment.api.getTokenInfo(req.body['access_token'], function(err, tokenInfo){ + if(err) return res.status(400).json({err:err}); + + res.sendStatus(200); + }); +}; + +exports.createOrderReferenceId = function(req, res, next){ + if(!req.body || !req.body.billingAgreementId){ + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + amzPayment.offAmazonPayments.createOrderReferenceForId({ + Id: req.body.billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false + }, function(err, response){ + if(err) return next(err); + if(!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId){ + return next(new Error('Missing attributes in Amazon response.')); + } + + res.json({ + orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId + }); + }); +}; + +exports.checkout = function(req, res, next){ + if(!req.body || !req.body.orderReferenceId){ + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + var gift = req.body.gift; + var user = res.locals.user; + var orderReferenceId = req.body.orderReferenceId; + var amount = 5; + + if(gift){ + if(gift.type === 'gems'){ + amount = gift.gems.amount/4; + }else if(gift.type === 'subscription'){ + amount = shared.content.subscriptionBlocks[gift.subscription.key].price; + } + } + + async.series({ + setOrderReferenceDetails: function(cb){ + amzPayment.offAmazonPayments.setOrderReferenceDetails({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { + CurrencyCode: 'USD', + Amount: amount + }, + SellerNote: 'HabitRPG Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG' + } + } + }, cb); + }, + + confirmOrderReference: function(cb){ + amzPayment.offAmazonPayments.confirmOrderReference({ + AmazonOrderReferenceId: orderReferenceId + }, cb); + }, + + authorize: function(cb){ + amzPayment.offAmazonPayments.authorize({ + AmazonOrderReferenceId: orderReferenceId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: amount + }, + SellerAuthorizationNote: 'HabitRPG Payment', + TransactionTimeout: 0, + CaptureNow: true + }, function(err, res){ + if(err) return cb(err); + + if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ + return cb(new Error('The payment was not successfull.')); + } + + return cb(); + }); + }, + + closeOrderReference: function(cb){ + amzPayment.offAmazonPayments.closeOrderReference({ + AmazonOrderReferenceId: orderReferenceId + }, cb); + }, + + executePayment: function(cb){ + async.waterfall([ + function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }, + function(member, cb2){ + var data = {user:user, paymentMethod:'Amazon Payments'}; + var method = 'buyGems'; + + if (gift){ + if (gift.type == 'subscription') method = 'createSubscription'; + gift.member = member; + data.gift = gift; + data.paymentMethod = 'Gift'; + } + + payments[method](data, cb2); + } + ], cb); + } + }, function(err, results){ + if(err) return next(err); + + res.sendStatus(200); + }); + +}; + +exports.subscribe = function(req, res, next){ + if(!req.body || !req.body['billingAgreementId']){ + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + var billingAgreementId = req.body.billingAgreementId; + var sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; + var coupon = req.body.coupon; + var user = res.locals.user; + + if(!sub){ + return res.status(400).json({err: 'Subscription plan not found.'}); + } + + async.series({ + applyDiscount: function(cb){ + if (!sub.discount) return cb(); + if (!coupon) return cb(new Error('Please provide a coupon code for this plan.')); + mongoose.model('Coupon').findOne({_id:cc.validate(coupon), event:sub.key}, function(err, coupon){ + if(err) return cb(err); + if(!coupon) return cb(new Error('Coupon code not found.')); + cb(); + }); + }, + + setBillingAgreementDetails: function(cb){ + amzPayment.offAmazonPayments.setBillingAgreementDetails({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: 'HabitRPG Subscription', + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: shared.uuid(), + StoreName: 'HabitRPG', + CustomInformation: 'HabitRPG Subscription' + } + } + }, cb); + }, + + confirmBillingAgreement: function(cb){ + amzPayment.offAmazonPayments.confirmBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId + }, cb); + }, + + authorizeOnBillingAgreeement: function(cb){ + amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: sub.price + }, + SellerAuthorizationNote: 'HabitRPG Subscription Payment', + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: 'HabitRPG Subscription Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG' + } + }, function(err, res){ + if(err) return cb(err); + + if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ + return cb(new Error('The payment was not successfull.')); + } + + return cb(); + }); + }, + + createSubscription: function(cb){ + payments.createSubscription({ + user: user, + customerId: billingAgreementId, + paymentMethod: 'Amazon Payments', + sub: sub + }, cb); + } + }, function(err, results){ + if(err) return next(err); + + res.sendStatus(200); + }); +}; + +exports.subscribeCancel = function(req, res, next){ + var user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.status(401).json({err: 'User does not have a plan subscription'}); + + var billingAgreementId = user.purchased.plan.customerId; + + async.series({ + closeBillingAgreement: function(cb){ + amzPayment.offAmazonPayments.closeBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId + }, cb); + }, + + cancelSubscription: function(cb){ + var data = { + user: user, + // Date of next bill + nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), + paymentMethod: 'Amazon Payments' + }; + + payments.cancelSubscription(data, cb); + } + }, function(err, results){ + if (err) return next(err); // don't json this, let toString() handle errors + + if(req.query.noRedirect){ + res.sendStatus(200); + }else{ + res.redirect('/'); + } + + user = null; + }); +}; diff --git a/website/src/controllers/payments/iap.js b/website/src/controllers/payments/iap.js new file mode 100644 index 0000000000..829482ed67 --- /dev/null +++ b/website/src/controllers/payments/iap.js @@ -0,0 +1,155 @@ +var iap = require('in-app-purchase'); +var async = require('async'); +var payments = require('./index'); +var nconf = require('nconf'); + +var inAppPurchase = require('in-app-purchase'); +inAppPurchase.config({ + // this is the path to the directory containing iap-sanbox/iap-live files + googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR') +}); + +// Validation ERROR Codes +var INVALID_PAYLOAD = 6778001; +var CONNECTION_FAILED = 6778002; +var PURCHASE_EXPIRED = 6778003; + +exports.androidVerify = function(req, res, next) { + var iapBody = req.body; + var user = res.locals.user; + + iap.setup(function (error) { + if (error) { + var resObj = { + ok: false, + data: 'IAP Error' + }; + + return res.json(resObj); + + } + + /* + google receipt must be provided as an object + { + "data": "{stringified data object}", + "signature": "signature from google" + } + */ + var testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature + }; + + // iap is ready + iap.validate(iap.GOOGLE, testObj, function (err, googleRes) { + if (err) { + var resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString() + } + }; + + return res.json(resObj); + } + + if (iap.isValidated(googleRes)) { + var resObj = { + ok: true, + data: googleRes + }; + + payments.buyGems({user:user, paymentMethod:'IAP GooglePlay', amount: 5.25}); + + return res.json(resObj); + } + }); + }); +}; + +exports.iosVerify = function(req, res, next) { + var iapBody = req.body; + var user = res.locals.user; + + iap.setup(function (error) { + if (error) { + var resObj = { + ok: false, + data: 'IAP Error' + }; + + return res.json(resObj); + + } + + //iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) { + if (err) { + var resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString() + } + }; + + return res.json(resObj); + } + + if (iap.isValidated(appleRes)) { + var purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length > 0) { + var correctReceipt = true; + for (var index in purchaseDataList) { + switch (purchaseDataList[index].productId) { + case 'com.habitrpg.ios.Habitica.4gems': + payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 1}); + break; + case 'com.habitrpg.ios.Habitica.8gems': + payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 2}); + break; + case 'com.habitrpg.ios.Habitica.20gems': + case 'com.habitrpg.ios.Habitica.21gems': + payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 5.25}); + break; + case 'com.habitrpg.ios.Habitica.42gems': + payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 10.5}); + break; + default: + correctReceipt = false; + } + } + if (correctReceipt) { + var resObj = { + ok: true, + data: appleRes + }; + // yay good! + return res.json(resObj); + } + } + //wrong receipt content + var resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Incorrect receipt content' + } + }; + return res.json(resObj); + } + //invalid receipt + var resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Invalid receipt' + } + }; + + return res.json(resObj); + }); + }); +}; diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js new file mode 100644 index 0000000000..dad53feb13 --- /dev/null +++ b/website/src/controllers/payments/index.js @@ -0,0 +1,207 @@ +var _ = require('lodash'); +var shared = require('../../../../common'); +var nconf = require('nconf'); +var utils = require('./../../libs/utils'); +var moment = require('moment'); +var isProduction = nconf.get("NODE_ENV") === "production"; +var stripe = require('./stripe'); +var paypal = require('./paypal'); +var amazon = require('./amazon'); +var members = require('../api-v2/members') +var async = require('async'); +var iap = require('./iap'); +var mongoose= require('mongoose'); +var cc = require('coupon-code'); +var pushNotify = require('./../pushNotifications'); + +function revealMysteryItems(user) { + _.each(shared.content.gear.flat, function(item) { + if ( + item.klass === 'mystery' && + moment().isAfter(shared.content.mystery[item.mystery].start) && + moment().isBefore(shared.content.mystery[item.mystery].end) && + !user.items.gear.owned[item.key] && + !~user.purchased.plan.mysteryItems.indexOf(item.key) + ) { + user.purchased.plan.mysteryItems.push(item.key); + } + }); +} + +exports.createSubscription = function(data, cb) { + var recipient = data.gift ? data.gift.member : data.user; + //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // FIXME double-check, this should never be the case + var p = recipient.purchased.plan; + var block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; + var months = +block.months; + + if (data.gift) { + if (p.customerId && !p.dateTerminated) { // User has active plan + p.extraMonths += months; + } else { + p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); + if (!p.dateUpdated) p.dateUpdated = new Date(); + } + if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + } else { + _(p).merge({ // override with these values + planId: block.key, + customerId: data.customerId, + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: data.paymentMethod, + extraMonths: +p.extraMonths + + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0), + dateTerminated: null, + // Specify a lastBillingDate just for Amazon Payments + // Resetted every time the subscription restarts + lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined + }).defaults({ // allow non-override if a plan was previously used + dateCreated: new Date(), + mysteryItems: [] + }).value(); + } + + // Block sub perks + var perks = Math.floor(months/3); + if (perks) { + p.consecutive.offset += months; + p.consecutive.gemCapExtra += perks*5; + if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25; + p.consecutive.trinkets += perks; + } + revealMysteryItems(recipient); + if(isProduction) { + if (!data.gift) utils.txnEmail(data.user, 'subscription-begins'); + + var analyticsData = { + uuid: data.user._id, + itemPurchased: 'Subscription', + sku: data.paymentMethod.toLowerCase() + '-subscription', + purchaseType: 'subscribe', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: !!data.gift, // coerced into a boolean + purchaseValue: block.price + } + utils.analytics.trackPurchase(analyticsData); + } + data.user.purchased.txnCount++; + if (data.gift){ + members.sendMessage(data.user, data.gift.member, data.gift); + + var byUserName = utils.getUserInfo(data.user, ['name']).name; + + if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ + utils.txnEmail(data.gift.member, 'gifted-subscription', [ + {name: 'GIFTER', content: byUserName}, + {name: 'X_MONTHS_SUBSCRIPTION', content: months} + ]); + } + + if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), months + " months - by "+ byUserName); + } + } + async.parallel([ + function(cb2){data.user.save(cb2)}, + function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + ], cb); +} + +/** + * Sets their subscription to be cancelled later + */ +exports.cancelSubscription = function(data, cb) { + var p = data.user.purchased.plan, + now = moment(), + remaining = data.nextBill ? moment(data.nextBill).diff(new Date, 'days') : 30; + + p.dateTerminated = + moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) + .add({days: remaining}) // end their subscription 1mo from their last payment + .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .toDate(); + p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + + data.user.save(cb); + utils.txnEmail(data.user, 'cancel-subscription'); + var analyticsData = { + uuid: data.user._id, + gaCategory: 'commerce', + gaLabel: data.paymentMethod, + paymentMethod: data.paymentMethod + } + utils.analytics.track('unsubscribe', analyticsData); +} + +exports.buyGems = function(data, cb) { + var amt = data.amount || 5; + amt = data.gift ? data.gift.gems.amount/4 : amt; + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + if(isProduction) { + if (!data.gift) utils.txnEmail(data.user, 'donation'); + + var analyticsData = { + uuid: data.user._id, + itemPurchased: 'Gems', + sku: data.paymentMethod.toLowerCase() + '-checkout', + purchaseType: 'checkout', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: !!data.gift, // coerced into a boolean + purchaseValue: amt + } + utils.analytics.trackPurchase(analyticsData); + } + + if (data.gift){ + var byUsername = utils.getUserInfo(data.user, ['name']).name; + var gemAmount = data.gift.gems.amount || 20; + + members.sendMessage(data.user, data.gift.member, data.gift); + if(data.gift.member.preferences.emailNotifications.giftedGems !== false){ + utils.txnEmail(data.gift.member, 'gifted-gems', [ + {name: 'GIFTER', content: byUsername}, + {name: 'X_GEMS_GIFTED', content: gemAmount} + ]); + } + + if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), gemAmount + ' Gems - by '+byUsername); + } + } + async.parallel([ + function(cb2){data.user.save(cb2)}, + function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + ], cb); +} + +exports.validCoupon = function(req, res, next){ + mongoose.model('Coupon').findOne({_id:cc.validate(req.params.code), event:'google_6mo'}, function(err, coupon){ + if (err) return next(err); + if (!coupon) return res.status(401).json({err:"Invalid coupon code"}); + return res.sendStatus(200); + }); +} + +exports.stripeCheckout = stripe.checkout; +exports.stripeSubscribeCancel = stripe.subscribeCancel; +exports.stripeSubscribeEdit = stripe.subscribeEdit; + +exports.paypalSubscribe = paypal.createBillingAgreement; +exports.paypalSubscribeSuccess = paypal.executeBillingAgreement; +exports.paypalSubscribeCancel = paypal.cancelSubscription; +exports.paypalCheckout = paypal.createPayment; +exports.paypalCheckoutSuccess = paypal.executePayment; +exports.paypalIPN = paypal.ipn; + +exports.amazonVerifyAccessToken = amazon.verifyAccessToken; +exports.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +exports.amazonCheckout = amazon.checkout; +exports.amazonSubscribe = amazon.subscribe; +exports.amazonSubscribeCancel = amazon.subscribeCancel; + +exports.iapAndroidVerify = iap.androidVerify; +exports.iapIosVerify = iap.iosVerify; diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/payments/paypal.js new file mode 100644 index 0000000000..3c5258c222 --- /dev/null +++ b/website/src/controllers/payments/paypal.js @@ -0,0 +1,216 @@ +var nconf = require('nconf'); +var moment = require('moment'); +var async = require('async'); +var _ = require('lodash'); +var url = require('url'); +var User = require('mongoose').model('User'); +var payments = require('./index'); +var logger = require('../../libs/logging'); +var ipn = require('paypal-ipn'); +var paypal = require('paypal-rest-sdk'); +var shared = require('../../../../common'); +var mongoose = require('mongoose'); +var cc = require('coupon-code'); + +// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have +// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created +// there, get it's plan.id and store it in config.json +_.each(shared.content.subscriptionBlocks, function(block){ + block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.key); +}); + +paypal.configure({ + 'mode': nconf.get("PAYPAL:mode"), //sandbox or live + 'client_id': nconf.get("PAYPAL:client_id"), + 'client_secret': nconf.get("PAYPAL:client_secret") +}); + +var parseErr = function(res, err){ + //var error = err.response ? err.response.message || err.response.details[0].issue : err; + var error = JSON.stringify(err); + return res.status(400).json({err:error}); +} + +exports.createBillingAgreement = function(req,res,next){ + var sub = shared.content.subscriptionBlocks[req.query.sub]; + async.waterfall([ + function(cb){ + if (!sub.discount) return cb(null, null); + if (!req.query.coupon) return cb('Please provide a coupon code for this plan.'); + mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb); + }, + function(coupon, cb){ + if (sub.discount && !coupon) return cb('Invalid coupon code.'); + var billingPlanTitle = "HabitRPG Subscription" + ' ($'+sub.price+' every '+sub.months+' months, recurring)'; + var billingAgreementAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "start_date": moment().add({minutes:5}).format(), + "plan": { + "id": sub.paypalKey + }, + "payer": { + "payment_method": "paypal" + } + }; + paypal.billingAgreement.create(billingAgreementAttributes, cb); + } + ], function(err, billingAgreement){ + if (err) return parseErr(res, err); + // For approving subscription via Paypal, first redirect user to: approval_url + req.session.paypalBlock = req.query.sub; + var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href; + res.redirect(approval_url); + }); +} + +exports.executeBillingAgreement = function(req,res,next){ + var block = shared.content.subscriptionBlocks[req.session.paypalBlock]; + delete req.session.paypalBlock; + async.auto({ + exec: function (cb) { + paypal.billingAgreement.execute(req.query.token, {}, cb); + }, + get_user: function (cb) { + User.findById(req.session.userId, cb); + }, + create_sub: ['exec', 'get_user', function (cb, results) { + payments.createSubscription({ + user: results.get_user, + customerId: results.exec.id, + paymentMethod: 'Paypal', + sub: block + }, cb); + }] + },function(err){ + if (err) return parseErr(res, err); + res.redirect('/'); + }) +} + +exports.createPayment = function(req, res) { + // if we're gifting to a user, put it in session for the `execute()` + req.session.gift = req.query.gift || undefined; + var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + var price = !gift ? 5.00 + : gift.type=='gems' ? Number(gift.gems.amount/4).toFixed(2) + : Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); + var description = !gift ? "HabitRPG Gems" + : gift.type=='gems' ? "HabitRPG Gems (Gift)" + : shared.content.subscriptionBlocks[gift.subscription.key].months + "mo. HabitRPG Subscription (Gift)"; + var create_payment = { + "intent": "sale", + "payer": { + "payment_method": "paypal" + }, + "redirect_urls": { + "return_url": nconf.get('BASE_URL') + '/paypal/checkout/success', + "cancel_url": nconf.get('BASE_URL') + }, + "transactions": [{ + "item_list": { + "items": [{ + "name": description, + //"sku": "1", + "price": price, + "currency": "USD", + "quantity": 1 + }] + }, + "amount": { + "currency": "USD", + "total": price + }, + "description": description + }] + }; + paypal.payment.create(create_payment, function (err, payment) { + if (err) return parseErr(res, err); + var link = _.find(payment.links, {rel: 'approval_url'}).href; + res.redirect(link); + }); +} + +exports.executePayment = function(req, res) { + var paymentId = req.query.paymentId, + PayerID = req.query.PayerID, + gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; + delete req.session.gift; + async.waterfall([ + function(cb){ + paypal.payment.execute(paymentId, {payer_id: PayerID}, cb); + }, + function(payment, cb){ + async.parallel([ + function(cb2){ User.findById(req.session.userId, cb2); }, + function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); } + ], cb); + }, + function(results, cb){ + if (_.isEmpty(results[0])) return cb("User not found when completing paypal transaction"); + var data = {user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift} + var method = 'buyGems'; + if (gift) { + gift.member = results[1]; + if (gift.type=='subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + payments[method](data, cb); + } + ],function(err){ + if (err) return parseErr(res, err); + res.redirect('/'); + }) +} + +exports.cancelSubscription = function(req, res, next){ + var user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.status(401).json({err: "User does not have a plan subscription"}); + async.auto({ + get_cus: function(cb){ + paypal.billingAgreement.get(user.purchased.plan.customerId, cb); + }, + verify_cus: ['get_cus', function(cb, results){ + var hasntBilledYet = results.get_cus.agreement_details.cycles_completed == "0"; + if (hasntBilledYet) + return cb("The plan hasn't activated yet (due to a PayPal bug). It will begin "+results.get_cus.agreement_details.next_billing_date+", after which you can cancel to retain your full benefits"); + cb(); + }], + del_cus: ['verify_cus', function(cb, results){ + paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb); + }], + cancel_sub: ['get_cus', 'verify_cus', function(cb, results){ + var data = {user: user, paymentMethod: 'Paypal', nextBill: results.get_cus.agreement_details.next_billing_date}; + payments.cancelSubscription(data, cb) + }] + }, function(err){ + if (err) return parseErr(res, err); + res.redirect('/'); + user = null; + }); +} + +/** + * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their + * recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution + */ +exports.ipn = function(req, res, next) { + console.log('IPN Called'); + res.sendStatus(200); // Must respond to PayPal IPN request with an empty 200 first + ipn.verify(req.body, function(err, msg) { + if (err) return logger.error(msg); + switch (req.body.txn_type) { + // TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead... + case 'recurring_payment_profile_cancel': + case 'subscr_cancel': + User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ + if (err) return logger.error(err); + if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel) + payments.cancelSubscription({user:user, paymentMethod: 'Paypal'}); + }); + break; + } + }); +}; + diff --git a/scripts/paypalBillingSetup.js b/website/src/controllers/payments/paypalBillingSetup.js similarity index 99% rename from scripts/paypalBillingSetup.js rename to website/src/controllers/payments/paypalBillingSetup.js index d21cd80c1c..2effcbd81d 100644 --- a/scripts/paypalBillingSetup.js +++ b/website/src/controllers/payments/paypalBillingSetup.js @@ -2,16 +2,14 @@ // payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this // file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), // and once for any time you need to edit the plan thereafter - var path = require('path'); var nconf = require('nconf'); -var _ = require('lodash'); +_ = require('lodash'); +nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); var paypal = require('paypal-rest-sdk'); var blocks = require('../../../../common').content.subscriptionBlocks; var live = nconf.get('PAYPAL:mode')=='live'; -nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); - var OP = 'create'; // list create update remove paypal.configure({ diff --git a/website/src/controllers/payments/stripe.js b/website/src/controllers/payments/stripe.js new file mode 100644 index 0000000000..1a1085227c --- /dev/null +++ b/website/src/controllers/payments/stripe.js @@ -0,0 +1,123 @@ +var nconf = require('nconf'); +var stripe = require('stripe')(nconf.get('STRIPE_API_KEY')); +var async = require('async'); +var payments = require('./index'); +var User = require('mongoose').model('User'); +var shared = require('../../../../common'); +var mongoose = require('mongoose'); +var cc = require('coupon-code'); + +/* + Setup Stripe response when posting payment + */ +exports.checkout = function(req, res, next) { + var token = req.body.id; + var user = res.locals.user; + var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + var sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; + + async.waterfall([ + function(cb){ + if (sub) { + async.waterfall([ + function(cb2){ + if (!sub.discount) return cb2(null, null); + if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); + mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb2); + }, + function(coupon, cb2){ + if (sub.discount && !coupon) return cb2('Invalid coupon code.'); + var customer = { + email: req.body.email, + metadata: {uuid: user._id}, + card: token, + plan: sub.key + }; + stripe.customers.create(customer, cb2); + } + ], cb); + } else { + stripe.charges.create({ + amount: !gift ? '500' //"500" = $5 + : gift.type=='subscription' ? ''+shared.content.subscriptionBlocks[gift.subscription.key].price*100 + : ''+gift.gems.amount/4*100, + currency: 'usd', + card: token + }, cb); + } + }, + function(response, cb) { + if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb); + async.waterfall([ + function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }, + function(member, cb2){ + var data = {user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift}; + var method = 'buyGems'; + if (gift) { + gift.member = member; + if (gift.type=='subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + payments[method](data, cb2); + } + ], cb); + } + ], function(err){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.sendStatus(200); + user = token = null; + }); +}; + +exports.subscribeCancel = function(req, res, next) { + var user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.status(401).json({err: 'User does not have a plan subscription'}); + + async.auto({ + get_cus: function(cb){ + stripe.customers.retrieve(user.purchased.plan.customerId, cb); + }, + del_cus: ['get_cus', function(cb, results){ + stripe.customers.del(user.purchased.plan.customerId, cb); + }], + cancel_sub: ['get_cus', function(cb, results) { + var data = { + user: user, + nextBill: results.get_cus.subscription.current_period_end*1000, // timestamp is in seconds + paymentMethod: 'Stripe' + }; + payments.cancelSubscription(data, cb); + }] + }, function(err, results){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.redirect('/'); + user = null; + }); +}; + +exports.subscribeEdit = function(req, res, next) { + var token = req.body.id; + var user = res.locals.user; + var user_id = user.purchased.plan.customerId; + var sub_id; + + async.waterfall([ + function(cb){ + stripe.customers.listSubscriptions(user_id, cb); + }, + function(response, cb) { + sub_id = response.data[0].id; + console.warn(sub_id); + console.warn([user_id, sub_id, { card: token }]); + stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb); + }, + function(response, cb) { + user.save(cb); + } + ], function(err, saved){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.sendStatus(200); + token = user = user_id = sub_id; + }); +}; diff --git a/website/server/controllers/api-v2/pushNotifications.js b/website/src/controllers/pushNotifications.js similarity index 98% rename from website/server/controllers/api-v2/pushNotifications.js rename to website/src/controllers/pushNotifications.js index 860cfbf56a..d1c728f365 100644 --- a/website/server/controllers/api-v2/pushNotifications.js +++ b/website/src/controllers/pushNotifications.js @@ -1,4 +1,3 @@ -// TODO move to /api-v2 var api = module.exports; var _ = require('lodash'); var nconf = require('nconf'); diff --git a/website/server/libs/api-v2/analytics.js b/website/src/libs/analytics.js similarity index 98% rename from website/server/libs/api-v2/analytics.js rename to website/src/libs/analytics.js index 6c1ccd3020..dffa7c2a9b 100644 --- a/website/server/libs/api-v2/analytics.js +++ b/website/src/libs/analytics.js @@ -1,7 +1,7 @@ require('./i18n'); var _ = require('lodash'); -var Content = require('../../../../common').content; +var Content = require('../../../common').content; var Amplitude = require('amplitude'); var googleAnalytics = require('universal-analytics'); diff --git a/website/server/libs/api-v2/buildManifest.js b/website/src/libs/buildManifest.js similarity index 91% rename from website/server/libs/api-v2/buildManifest.js rename to website/src/libs/buildManifest.js index bfbab1421a..e2b337860f 100644 --- a/website/server/libs/api-v2/buildManifest.js +++ b/website/src/libs/buildManifest.js @@ -2,7 +2,7 @@ var fs = require('fs'); var path = require('path'); var nconf = require('nconf'); var _ = require('lodash'); -var manifestFiles = require("../../../client/manifest.json"); +var manifestFiles = require("../../public/manifest.json"); var IS_PROD = nconf.get('NODE_ENV') === 'production'; var buildFiles = []; @@ -15,7 +15,7 @@ var walk = function(folder){ if(fs.statSync(file).isDirectory()){ walk(file); }else{ - var relFolder = path.relative(path.join(__dirname, "/../../../build"), folder); + var relFolder = path.relative(path.join(__dirname, "/../../build"), folder); var old = fileName.replace(/-.{8}(\.[\d\w]+)$/, '$1'); if(relFolder){ @@ -28,7 +28,7 @@ var walk = function(folder){ }); }; -walk(path.join(__dirname, "/../../../build")); +walk(path.join(__dirname, "/../../build")); var getBuildUrl = module.exports.getBuildUrl = function(url){ if(buildFiles[url]) return '/' + buildFiles[url]; @@ -56,4 +56,4 @@ module.exports.getManifestFiles = function(page){ } return code; -}; +}; \ No newline at end of file diff --git a/website/server/libs/api-v2/firebase.js b/website/src/libs/firebase.js similarity index 87% rename from website/server/libs/api-v2/firebase.js rename to website/src/libs/firebase.js index 8a8d9f002c..84a90b22d4 100644 --- a/website/server/libs/api-v2/firebase.js +++ b/website/src/libs/firebase.js @@ -6,8 +6,6 @@ var firebaseConfig = nconf.get('FIREBASE'); var firebaseRef; var isFirebaseEnabled = (nconf.get('NODE_ENV') === 'production') && (firebaseConfig.ENABLED === 'true'); -import { TAVERN_ID } from '../../models/group'; - // Setup if(isFirebaseEnabled){ firebaseRef = new Firebase('https://' + firebaseConfig.APP + '.firebaseio.com'); @@ -26,7 +24,7 @@ api.updateGroupData = function(group){ // TODO is throw ok? we don't have callbacks if(!group) throw new Error('group is required.'); // Return in case of tavern (comparison working because we use string for _id) - if(group._id === TAVERN_ID) return; + if(group._id === 'habitrpg') return; firebaseRef.child('rooms/' + group._id) .set({ @@ -37,7 +35,7 @@ api.updateGroupData = function(group){ api.addUserToGroup = function(groupId, userId){ if(!isFirebaseEnabled) return; if(!userId || !groupId) throw new Error('groupId, userId are required.'); - if(groupId === TAVERN_ID) return; + if(groupId === 'habitrpg') return; firebaseRef.child('members/' + groupId + '/' + userId) .set(true); @@ -49,7 +47,7 @@ api.addUserToGroup = function(groupId, userId){ api.removeUserFromGroup = function(groupId, userId){ if(!isFirebaseEnabled) return; if(!userId || !groupId) throw new Error('groupId, userId are required.'); - if(groupId === TAVERN_ID) return; + if(groupId === 'habitrpg') return; firebaseRef.child('members/' + groupId + '/' + userId) .remove(); @@ -61,18 +59,18 @@ api.removeUserFromGroup = function(groupId, userId){ api.deleteGroup = function(groupId){ if(!isFirebaseEnabled) return; if(!groupId) throw new Error('groupId is required.'); - if(groupId === TAVERN_ID) return; + if(groupId === 'habitrpg') return; firebaseRef.child('rooms/' + groupId) .remove(); - // TODO not really necessary as long as we only store room data, + // FIXME not really necessary as long as we only store room data, // as empty objects are automatically deleted (/members/... in future...) firebaseRef.child('members/' + groupId) .remove(); }; -// TODO not really necessary as long as we only store room data, +// FIXME not really necessary as long as we only store room data, // as empty objects are automatically deleted api.deleteUser = function(userId){ if(!isFirebaseEnabled) return; @@ -80,4 +78,4 @@ api.deleteUser = function(userId){ firebaseRef.child('users/' + userId) .remove(); -}; +}; \ No newline at end of file diff --git a/website/server/libs/api-v2/i18n.js b/website/src/libs/i18n.js similarity index 90% rename from website/server/libs/api-v2/i18n.js rename to website/src/libs/i18n.js index e8295deb03..3edf901279 100644 --- a/website/server/libs/api-v2/i18n.js +++ b/website/src/libs/i18n.js @@ -1,12 +1,11 @@ var fs = require('fs'), path = require('path'), _ = require('lodash'), - User = require('../../models/user').model, - accepts = require('accepts'), - shared = require('../../../../common'), + User = require('../models/user').model, + shared = require('../../../common'), translations = {}; -var localePath = path.join(__dirname, "/../../../../common/locales/") +var localePath = path.join(__dirname, "/../../../common/locales/") var loadTranslations = function(locale){ var files = fs.readdirSync(path.join(localePath, locale)); @@ -55,7 +54,7 @@ _.each(langCodes, function(code){ lang.momentLangCode = (momentLangsMapping[code] || code); try{ // MomentJS lang files are JS files that has to be executed in the browser so we load them as plain text files - var f = fs.readFileSync(path.join(__dirname, '/../../node_modules/moment/locale/' + lang.momentLangCode + '.js'), 'utf8'); + var f = fs.readFileSync(path.join(__dirname, '/../../../node_modules/moment/locale/' + lang.momentLangCode + '.js'), 'utf8'); momentLangs[code] = f; }catch (e){} }); @@ -95,9 +94,7 @@ var chineseVersions = { var getUserLanguage = function(req, res, next){ var getFromBrowser = function(){ - var acceptedLanguages = accepts(req).languages(); - - var acceptable = _(acceptedLanguages).map(function(lang){ + var acceptable = _(req.acceptedLanguages).map(function(lang){ return lang.slice(0, 2); }).uniq().value(); @@ -106,7 +103,7 @@ var getUserLanguage = function(req, res, next){ var iAcceptedCompleteLang = (matches.length > 0) ? multipleVersionsLanguages.indexOf(matches[0].toLowerCase()) : -1; if(iAcceptedCompleteLang !== -1){ - var acceptedCompleteLang = _.find(acceptedLanguages, function(accepted){ + var acceptedCompleteLang = _.find(req.acceptedLanguages, function(accepted){ return accepted.slice(0, 2) == multipleVersionsLanguages[iAcceptedCompleteLang]; }); diff --git a/website/server/libs/api-v2/logging.js b/website/src/libs/logging.js similarity index 94% rename from website/server/libs/api-v2/logging.js rename to website/src/libs/logging.js index 9737159f43..f832adb6d5 100644 --- a/website/server/libs/api-v2/logging.js +++ b/website/src/libs/logging.js @@ -22,9 +22,9 @@ if (nconf.get('LOGGLY:enabled')){ if (!logger) { logger = new (winston.Logger)({}); - logger.add(winston.transports.Console, {colorize:true}); // TODO remove if (nconf.get('NODE_ENV') !== 'production') { + logger.add(winston.transports.Console, {colorize:true}); logger.add(winston.transports.File, {filename: 'habitrpg.log'}); } } diff --git a/website/server/libs/api-v2/utils.js b/website/src/libs/utils.js similarity index 90% rename from website/server/libs/api-v2/utils.js rename to website/src/libs/utils.js index 8657bf6513..6266843edf 100644 --- a/website/server/libs/api-v2/utils.js +++ b/website/src/libs/utils.js @@ -4,8 +4,8 @@ var crypto = require('crypto'); var path = require("path"); var request = require('request'); -const IS_PROD = nconf.get('IS_PROD'); -const BASE_URL = nconf.get('BASE_URL'); +// Set when utils.setupConfig is run +var isProd, baseUrl; module.exports.sendEmail = function(mailData) { var smtpTransport = nodemailer.createTransport({ @@ -17,7 +17,7 @@ module.exports.sendEmail = function(mailData) { }); smtpTransport.sendMail(mailData, function(error, response){ - var logging = require('./api-v2/logging'); + var logging = require('./logging'); if(error) logging.error(error); else logging.info("Message sent: " + response.message); smtpTransport.close(); // shut down the connection pool, no more messages @@ -60,7 +60,7 @@ module.exports.txnEmail = function(mailingInfoArray, emailType, variables, perso var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray]; var variables = [ - {name: 'BASE_URL', content: BASE_URL} + {name: 'BASE_URL', content: baseUrl} ].concat(variables || []); // It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed @@ -121,7 +121,7 @@ module.exports.txnEmail = function(mailingInfoArray, emailType, variables, perso }); } - if(IS_PROD && mailingInfoArray.length > 0){ + if(isProd && mailingInfoArray.length > 0){ request({ url: nconf.get('EMAIL_SERVER:url') + '/job', method: 'POST', @@ -168,12 +168,20 @@ module.exports.analytics = { track: function() { }, trackPurchase: function() { * Load nconf and define default configuration values if config.json or ENV vars are not found */ module.exports.setupConfig = function(){ - if (nconf.get('IS_DEV')) + nconf.argv() + .env() + //.file('defaults', path.join(path.resolve(__dirname, '../config.json.example'))) + .file('user', path.join(path.resolve(__dirname, './../../../config.json'))); + + if (nconf.get('NODE_ENV') === "development") Error.stackTraceLimit = Infinity; - if (IS_PROD && nconf.get('NEW_RELIC_ENABLED') === 'true') + if (nconf.get('NODE_ENV') === 'production' && nconf.get('NEW_RELIC_ENABLED') === 'true') require('newrelic'); - var analytics = IS_PROD && require('./api-v2/analytics'); + isProd = nconf.get('NODE_ENV') === 'production'; + baseUrl = nconf.get('BASE_URL'); + + var analytics = isProd && require('./analytics'); var analyticsTokens = { amplitudeToken: nconf.get('AMPLITUDE_KEY'), googleAnalytics: nconf.get('GA_ID') diff --git a/website/server/libs/api-v2/webhook.js b/website/src/libs/webhook.js similarity index 100% rename from website/server/libs/api-v2/webhook.js rename to website/src/libs/webhook.js diff --git a/website/server/middlewares/apiThrottle.js b/website/src/middlewares/apiThrottle.js similarity index 74% rename from website/server/middlewares/apiThrottle.js rename to website/src/middlewares/apiThrottle.js index b392cc777e..8de298106c 100644 --- a/website/server/middlewares/apiThrottle.js +++ b/website/src/middlewares/apiThrottle.js @@ -3,10 +3,8 @@ var limiter = require('connect-ratelimit'); var IS_PROD = nconf.get('NODE_ENV') === 'production'; -// TODO since Habitica runs on many different servers this module is pretty useless -// as it will only block requests that go to the same server but anyway we should probably have a rate limiter in place - module.exports = function(app) { + // TODO review later // disable the rate limiter middleware if (/*!IS_PROD || */true) return; app.use(limiter({ diff --git a/website/src/middlewares/cors.js b/website/src/middlewares/cors.js new file mode 100644 index 0000000000..e72db26981 --- /dev/null +++ b/website/src/middlewares/cors.js @@ -0,0 +1,7 @@ +module.exports = function(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,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key"); + if (req.method === 'OPTIONS') return res.sendStatus(200); + return next(); +}; diff --git a/website/server/middlewares/api-v2/domain.js b/website/src/middlewares/domain.js similarity index 100% rename from website/server/middlewares/api-v2/domain.js rename to website/src/middlewares/domain.js diff --git a/website/server/middlewares/api-v2/errorHandler.js b/website/src/middlewares/errorHandler.js similarity index 95% rename from website/server/middlewares/api-v2/errorHandler.js rename to website/src/middlewares/errorHandler.js index e62753f5f9..2b06824dce 100644 --- a/website/server/middlewares/api-v2/errorHandler.js +++ b/website/src/middlewares/errorHandler.js @@ -1,4 +1,4 @@ -var logging = require('../../libs/api-v2/logging'); +var logging = require('../libs/logging'); module.exports = function(err, req, res, next) { //res.locals.domain.emit('error', err); diff --git a/website/server/middlewares/forceRefresh.js b/website/src/middlewares/forceRefresh.js similarity index 83% rename from website/server/middlewares/forceRefresh.js rename to website/src/middlewares/forceRefresh.js index f843694790..6d5b4fa8c9 100644 --- a/website/server/middlewares/forceRefresh.js +++ b/website/src/middlewares/forceRefresh.js @@ -1,5 +1,3 @@ -// TODO do we need this module anymore in v3? No - module.exports.siteVersion = 1; module.exports.middleware = function(req, res, next){ diff --git a/website/server/middlewares/api-v2/locals.js b/website/src/middlewares/locals.js similarity index 94% rename from website/server/middlewares/api-v2/locals.js rename to website/src/middlewares/locals.js index 223186a81a..2f238a6a99 100644 --- a/website/server/middlewares/api-v2/locals.js +++ b/website/src/middlewares/locals.js @@ -1,9 +1,9 @@ var nconf = require('nconf'); var _ = require('lodash'); -var utils = require('../libs/api-v2/utils'); +var utils = require('../libs/utils'); var shared = require('../../../common'); -var i18n = require('../libs/api-v2/i18n'); -var buildManifest = require('../libs/api-v2/buildManifest'); +var i18n = require('../libs/i18n'); +var buildManifest = require('../libs/buildManifest'); var shared = require('../../../common'); var forceRefresh = require('./forceRefresh'); var tavernQuest = require('../models/group').tavernQuest; diff --git a/website/src/middlewares/redirects.js b/website/src/middlewares/redirects.js new file mode 100644 index 0000000000..ddc135beab --- /dev/null +++ b/website/src/middlewares/redirects.js @@ -0,0 +1,41 @@ +var nconf = require('nconf'); +var IS_PROD = nconf.get('NODE_ENV') === 'production'; +var ignoreRedirect = nconf.get('IGNORE_REDIRECT'); +var BASE_URL = nconf.get('BASE_URL'); + +function isHTTP(req) { + return ( + req.headers['x-forwarded-proto'] && + req.headers['x-forwarded-proto'] !== 'https' && + IS_PROD && + BASE_URL.indexOf('https') === 0 + ); +} + +function isProxied(req) { + return ( + req.headers['x-habitica-lb'] && + req.headers['x-habitica-lb'] === 'Yes' + ); +} + +module.exports.forceSSL = function(req, res, next){ + if(isHTTP(req) && !isProxied(req)) { + return res.redirect(BASE_URL + req.url); + } + + next(); +}; + +// Redirect to habitica for non-api urls + +function nonApiUrl(req) { + return req.url.search(/\/api\//) === -1; +} + +module.exports.forceHabitica = function(req, res, next) { + if (IS_PROD && !ignoreRedirect && !isProxied(req) && nonApiUrl(req)) { + return res.redirect(301, BASE_URL + req.url); + } + next(); +}; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js new file mode 100644 index 0000000000..d44b798bc0 --- /dev/null +++ b/website/src/models/challenge.js @@ -0,0 +1,120 @@ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var TaskSchemas = require('./task'); + +var ChallengeSchema = new Schema({ + _id: {type: String, 'default': shared.uuid}, + name: String, + shortName: String, + description: String, + official: {type: Boolean,'default':false}, + habits: [TaskSchemas.HabitSchema], + dailys: [TaskSchemas.DailySchema], + todos: [TaskSchemas.TodoSchema], + rewards: [TaskSchemas.RewardSchema], + leader: {type: String, ref: 'User'}, + group: {type: String, ref: 'Group'}, + timestamp: {type: Date, 'default': Date.now}, + members: [{type: String, ref: 'User'}], + memberCount: {type: Number, 'default': 0}, + prize: {type: Number, 'default': 0} +}); + +ChallengeSchema.virtual('tasks').get(function () { + var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); + var tasks = _.object(_.pluck(tasks,'id'), tasks); + return tasks; +}); + +ChallengeSchema.methods.toJSON = function(){ + var doc = this.toObject(); + doc._isMember = this._isMember; + return doc; +} + +// -------------- +// Syncing logic +// -------------- + +function syncableAttrs(task) { + var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument + // only sync/compare important attrs + var omitAttrs = 'challenge history tags completed streak notes'.split(' '); + if (t.type != 'reward') omitAttrs.push('value'); + return _.omit(t, omitAttrs); +} + +/** + * Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers + */ +function comparableData(obj) { + return JSON.stringify( + _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) + .sortBy('id') // we don't want to update if they're sort-order is different + .transform(function(result, task){ + result.push(syncableAttrs(task)); + }) + .value()) +} + +ChallengeSchema.methods.isOutdated = function(newData) { + return comparableData(this) !== comparableData(newData); +} + +/** + * Syncs all new tasks, deleted tasks, etc to the user object + * @param user + * @return nothing, user is modified directly. REMEMBER to save the user! + */ +ChallengeSchema.methods.syncToUser = function(user, cb) { + if (!user) return; + var self = this; + self.shortName = self.shortName || self.name; + + // Add challenge to user.challenges + if (!_.contains(user.challenges, self._id)) { + user.challenges.push(self._id); + } + + // Sync tags + var tags = user.tags || []; + var i = _.findIndex(tags, {id: self._id}) + if (~i) { + if (tags[i].name !== self.shortName) { + // update the name - it's been changed since + user.tags[i].name = self.shortName; + } + } else { + user.tags.push({ + id: self._id, + name: self.shortName, + challenge: true + }); + } + + // Sync new tasks and updated tasks + _.each(self.tasks, function(task){ + var list = user[task.type+'s']; + var userTask = user.tasks[task.id] || (list.push(syncableAttrs(task)), list[list.length-1]); + if (!userTask.notes) userTask.notes = task.notes; // don't override the notes, but provide it if not provided + userTask.challenge = {id:self._id}; + userTask.tags = userTask.tags || {}; + userTask.tags[self._id] = true; + _.merge(userTask, syncableAttrs(task)); + }) + + // Flag deleted tasks as "broken" + _.each(user.tasks, function(task){ + if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) { + task.challenge.broken = 'TASK_DELETED'; + } + }) + + user.save(cb); +}; + + +module.exports.schema = ChallengeSchema; +module.exports.model = mongoose.model("Challenge", ChallengeSchema); diff --git a/website/src/models/coupon.js b/website/src/models/coupon.js new file mode 100644 index 0000000000..3b0afb3f2a --- /dev/null +++ b/website/src/models/coupon.js @@ -0,0 +1,59 @@ +var mongoose = require("mongoose"); +var shared = require('../../../common'); +var _ = require('lodash'); +var async = require('async'); +var cc = require('coupon-code'); +var autoinc = require('mongoose-id-autoinc'); + +var CouponSchema = new mongoose.Schema({ + _id: {type: String, 'default': cc.generate}, + event: {type:String, enum:['wondercon','google_6mo']}, + user: {type: 'String', ref: 'User'} +}); + +CouponSchema.statics.generate = function(event, count, callback) { + async.times(count, function(n,cb){ + mongoose.model('Coupon').create({event: event}, cb); + }, callback); +} + +CouponSchema.statics.apply = function(user, code, next){ + async.auto({ + get_coupon: function (cb) { + mongoose.model('Coupon').findById(cc.validate(code), cb); + }, + apply_coupon: ['get_coupon', function (cb, results) { + if (!results.get_coupon) return cb("Invalid coupon code"); + if (results.get_coupon.user) return cb("Coupon already used"); + switch (results.get_coupon.event) { + case 'wondercon': + user.items.gear.owned.eyewear_special_wondercon_red = true; + user.items.gear.owned.eyewear_special_wondercon_black = true; + user.items.gear.owned.back_special_wondercon_black = true; + user.items.gear.owned.back_special_wondercon_red = true; + user.items.gear.owned.body_special_wondercon_red = true; + user.items.gear.owned.body_special_wondercon_black = true; + user.items.gear.owned.body_special_wondercon_gold = true; + user.extra = {signupEvent: 'wondercon'}; + user.save(cb); + break; + } + }], + expire_coupon: ['apply_coupon', function (cb, results) { + results.get_coupon.user = user._id; + results.get_coupon.save(cb); + }] + }, function(err, results){ + if (err) return next(err); + next(null,results.apply_coupon[0]); + }) +} + +CouponSchema.plugin(autoinc.plugin, { + model: 'Coupon', + field: 'seq' +}); + +module.exports.schema = CouponSchema; +module.exports.model = mongoose.model("Coupon", CouponSchema); + diff --git a/website/src/models/emailUnsubscription.js b/website/src/models/emailUnsubscription.js new file mode 100644 index 0000000000..144417f3fc --- /dev/null +++ b/website/src/models/emailUnsubscription.js @@ -0,0 +1,14 @@ +var mongoose = require("mongoose"); +var shared = require('../../../common'); + +// A collection used to store mailing list unsubscription for non registered email addresses +var EmailUnsubscriptionSchema = new mongoose.Schema({ + _id: { + type: String, + 'default': shared.uuid + }, + email: String +}); + +module.exports.schema = EmailUnsubscriptionSchema; +module.exports.model = mongoose.model('EmailUnsubscription', EmailUnsubscriptionSchema); \ No newline at end of file diff --git a/website/src/models/group.js b/website/src/models/group.js new file mode 100644 index 0000000000..caed72331e --- /dev/null +++ b/website/src/models/group.js @@ -0,0 +1,501 @@ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var User = require('./user').model; +var shared = require('../../../common'); +var _ = require('lodash'); +var async = require('async'); +var logging = require('../libs/logging'); +var Challenge = require('./../models/challenge').model; +var firebase = require('../libs/firebase'); + +// NOTE any change to groups' members in MongoDB will have to be run through the API +// changes made directly to the db will cause Firebase to get out of sync +var GroupSchema = new Schema({ + _id: {type: String, 'default': shared.uuid}, + name: String, + description: String, + leader: {type: String, ref: 'User'}, + members: [{type: String, ref: 'User'}], + invites: [{type: String, ref: 'User'}], + type: {type: String, "enum": ['guild', 'party']}, + privacy: {type: String, "enum": ['private', 'public'], 'default':'private'}, + //_v: {type: Number,'default': 0}, + chat: Array, + /* + # [{ + # timestamp: Date + # user: String + # text: String + # contributor: String + # uuid: String + # id: String + # }] + */ + leaderOnly: { // restrict group actions to leader (members can't do them) + challenges: {type:Boolean, 'default':false}, + //invites: {type:Boolean, 'default':false} + }, + memberCount: {type: Number, 'default': 0}, + challengeCount: {type: Number, 'default': 0}, + balance: Number, + logo: String, + leaderMessage: String, + challenges: [{type:'String', ref:'Challenge'}], // do we need this? could depend on back-ref instead (Challenge.find({group:GID})) + quest: { + key: String, + active: {type:Boolean, 'default':false}, + leader: {type:String, ref:'User'}, + progress:{ + hp: Number, + collect: {type:Schema.Types.Mixed, 'default':{}}, // {feather: 5, ingot: 3} + rage: Number, // limit break / "energy stored in shell", for explosion-attacks + }, + + //Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click + //'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. + //TODO when booting user, remove from .joined and check again if we can now start the quest + members: Schema.Types.Mixed, + extra: Schema.Types.Mixed + } +}, { + strict: 'throw', + minimize: false // So empty objects are returned +}); + +/** + * Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration + * to remove duplicates, then take these fucntions out + */ +function removeDuplicates(doc){ + // Remove duplicate members + if (doc.members) { + var uniqMembers = _.uniq(doc.members); + if (uniqMembers.length != doc.members.length) { + doc.members = uniqMembers; + } + } +} + +// FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate() +// @see https://github.com/LearnBoost/mongoose/issues/964 +GroupSchema.pre('save', function(next){ + removeDuplicates(this); + this.memberCount = _.size(this.members); + this.challengeCount = _.size(this.challenges); + next(); +}) + +GroupSchema.pre('remove', function(next) { + var group = this; + async.waterfall([ + function(cb) { + var invitationQuery = {}; + var groupType = group.type; + //Add an 's' to group type guild because the model has the plural version + if (group.type == "guild") groupType += "s"; + invitationQuery['invitations.' + groupType + '.id'] = group._id; + User.find(invitationQuery, cb); + }, + function(users, cb) { + if (users) { + users.forEach(function (user, index, array) { + if ( group.type == "party" ) { + user.invitations.party = {}; + } else { + var i = _.findIndex(user.invitations.guilds, {id: group._id}); + user.invitations.guilds.splice(i, 1); + } + user.save(); + }); + } + cb(); + } + ], next); +}); + +GroupSchema.post('remove', function(group) { + firebase.deleteGroup(group._id); +}); + +GroupSchema.methods.toJSON = function(){ + var doc = this.toObject(); + removeDuplicates(doc); + doc._isMember = this._isMember; + + //fix(groups): temp fix to remove chat entries stored as strings (not sure why that's happening..). + // Required as angular 1.3 is strict on dupes, and no message.id to `track by` + _.remove(doc.chat,function(msg){return !msg.id}); + + // @see pre('save') comment above + this.memberCount = _.size(this.members); + this.challengeCount = _.size(this.challenges); + + return doc; +} + +var chatDefaults = module.exports.chatDefaults = function(msg,user){ + var message = { + id: shared.uuid(), + text: msg, + timestamp: +new Date, + likes: {}, + flags: {}, + flagCount: 0 + }; + if (user) { + _.defaults(message, { + uuid: user._id, + contributor: user.contributor && user.contributor.toObject(), + backer: user.backer && user.backer.toObject(), + user: user.profile.name + }); + } else { + message.uuid = 'system'; + } + return message; +} + +var NO_CHAT_NOTIFICATIONS = ['habitrpg'] + +GroupSchema.methods.sendChat = function(message, user){ + var group = this; + group.chat.unshift(chatDefaults(message,user)); + group.chat.splice(200); + // Kick off chat notifications in the background. + var lastSeenUpdate = {$set:{}, $inc:{_v:1}}; + lastSeenUpdate['$set']['newMessages.'+group._id] = {name:group.name,value:true}; + if (NO_CHAT_NOTIFICATIONS.indexOf(group._id) !== -1 || group.memberCount > 5000) { + // TODO For Tavern, only notify them if their name was mentioned + // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? + // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); + } else { + mongoose.model('User').update({_id:{$in:group.members, $ne: user ? user._id : ''}},lastSeenUpdate,{multi:true}).exec(); + } +} + +var cleanQuestProgress = function(merge){ + var clean = { + key: null, + progress: { + up: 0, + down: 0, + collect: {} + }, + completed: null, + RSVPNeeded: false + }; + merge = merge || {progress:{}}; + _.merge(clean, _.omit(merge,'progress')); + _.merge(clean.progress, merge.progress); + return clean; +} +GroupSchema.statics.cleanQuestProgress = cleanQuestProgress; + +// Participants: Grant rewards & achievements, finish quest +GroupSchema.methods.finishQuest = function(quest, cb) { + var group = this; + var questK = quest.key; + var updates = {$inc:{},$set:{}}; + + updates['$inc']['achievements.quests.' + questK] = 1; + updates['$inc']['stats.gp'] = +quest.drop.gp; + updates['$inc']['stats.exp'] = +quest.drop.exp; + updates['$inc']['_v'] = 1; + if (group._id == 'habitrpg') { + updates['$set']['party.quest.completed'] = questK; // Just show the notif + } else { + updates['$set']['party.quest'] = cleanQuestProgress({completed: questK}); // clear quest progress + } + + _.each(quest.drop.items, function(item){ + var dropK = item.key; + switch (item.type) { + case 'gear': + // TODO This means they can lose their new gear on death, is that what we want? + updates['$set']['items.gear.owned.'+dropK] = true; + break; + case 'eggs': + case 'food': + case 'hatchingPotions': + case 'quests': + updates['$inc']['items.'+item.type+'.'+dropK] = _.where(quest.drop.items,{type:item.type,key:item.key}).length; + break; + case 'pets': + updates['$set']['items.pets.'+dropK] = 5; + break; + case 'mounts': + updates['$set']['items.mounts.'+dropK] = true; + break; + } + }) + var q = group._id === 'habitrpg' ? {} : {_id:{$in:_.keys(group.quest.members)}}; + group.quest = {};group.markModified('quest'); + mongoose.model('User').update(q, updates, {multi:true}, cb); +} + +function isOnQuest(user,progress,group){ + return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; +} + +GroupSchema.statics.collectQuest = function(user, progress, cb) { + this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ + if (!isOnQuest(user,progress,group)) return cb(null); + var quest = shared.content.quests[group.quest.key]; + + _.each(progress.collect,function(v,k){ + group.quest.progress.collect[k] += v; + }); + + var foundText = _.reduce(progress.collect, function(m,v,k){ + m.push(v + ' ' + quest.collect[k].text('en')); + return m; + }, []); + foundText = foundText ? foundText.join(', ') : 'nothing'; + group.sendChat("`" + user.profile.name + " found "+foundText+".`"); + group.markModified('quest.progress.collect'); + + // Still needs completing + if (_.find(shared.content.quests[group.quest.key].collect, function(v,k){ + return group.quest.progress.collect[k] < v.count; + })) return group.save(cb); + + async.series([ + function(cb2){ + group.finishQuest(quest,cb2); + }, + function(cb2){ + group.sendChat('`All items found! Party has received their rewards.`'); + group.save(cb2); + } + ],cb); + }) +} + +// to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` +module.exports.tavernQuest = {}; +var tavernQ = {_id:'habitrpg','quest.key':{$ne:null}}; +process.nextTick(function(){ + mongoose.model('Group').findOne(tavernQ, function(err,tavern){ + if (!tavern) return; // No tavern quest + + var quest = tavern.quest.toObject(); + // Using _assign so we don't lose the reference to the exported tavernQuest + _.assign(module.exports.tavernQuest, quest); + }); +}); + +GroupSchema.statics.tavernBoss = function(user,progress) { + if (!progress) return; + + // hack: prevent crazy damage to world boss + var dmg = Math.min(900, Math.abs(progress.up||0)), + rage = -Math.min(900, Math.abs(progress.down||0)); + + async.waterfall([ + function(cb){ + mongoose.model('Group').findOne(tavernQ,cb); + }, + function(tavern,cb){ + if (!(tavern && tavern.quest && tavern.quest.key)) return cb(true); + + var quest = shared.content.quests[tavern.quest.key]; + if (tavern.quest.progress.hp <= 0) { + tavern.sendChat(quest.completionChat('en')); + tavern.finishQuest(quest, function(){}); + tavern.save(cb); + _.assign(module.exports.tavernQuest, {extra: null}); + } else { + // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, + // use those first - which allows us to update the boss on the go if things are too easy/hard. + if (!tavern.quest.extra) tavern.quest.extra = {}; + tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); + tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); + if (tavern.quest.progress.rage >= quest.boss.rage.value) { + if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; + var wd = tavern.quest.extra.worldDmg; + var scene = wd.market ? wd.stables ? wd.bailey ? false : 'bailey' : 'stables' : 'market'; // Be-Wilder attacks Alex, Matt, Bailey + if (!scene) { + tavern.sendChat('`'+quest.boss.name('en')+' tries to unleash '+quest.boss.rage.title('en')+', but is too tired.`'); + tavern.quest.progress.rage = 0 //quest.boss.rage.value; + } else { + tavern.sendChat(quest.boss.rage[scene]('en')); + tavern.quest.extra.worldDmg[scene] = true; + tavern.quest.extra.worldDmg.recent = scene; + tavern.markModified('quest.extra.worldDmg'); + tavern.quest.progress.rage = 0; + if (quest.boss.rage.healing) { + tavern.quest.progress.hp += (quest.boss.rage.healing * tavern.quest.progress.hp); + } + } + } + if (quest.boss.desperation && (tavern.quest.progress.hp < quest.boss.desperation.threshold) && !tavern.quest.extra.desperate) { + tavern.sendChat(quest.boss.desperation.text('en')); + tavern.quest.extra.desperate = true; + tavern.quest.extra.def = quest.boss.desperation.def; + tavern.quest.extra.str = quest.boss.desperation.str; + tavern.markModified('quest.extra'); + } + + _.assign(module.exports.tavernQuest, tavern.quest.toObject()); + tavern.save(cb); + } + } + ],function(err,res){ + if (err === true) return; // no current quest + if (err) return logging.error(err); + dmg = rage = null; + }) +} + +GroupSchema.statics.bossQuest = function(user, progress, cb) { + this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ + if (!isOnQuest(user,progress,group)) return cb(null); + var quest = shared.content.quests[group.quest.key]; + if (!progress || !quest) return cb(null); // FIXME why is this ever happening, progress should be defined at this point + var down = progress.down * quest.boss.str; // multiply by boss strength + + group.quest.progress.hp -= progress.up; + group.sendChat("`" + user.profile.name + " attacks " + quest.boss.name('en') + " for " + (progress.up.toFixed(1)) + " damage.` `" + quest.boss.name('en') + " attacks party for " + Math.abs(down).toFixed(1) + " damage.`"); //TODO Create a party preferred language option so emits like this can be localized + + // If boss has Rage, increment Rage as well + if (quest.boss.rage) { + group.quest.progress.rage += Math.abs(down); + if (group.quest.progress.rage >= quest.boss.rage.value) { + group.sendChat(quest.boss.rage.effect('en')); + group.quest.progress.rage = 0; + if (quest.boss.rage.healing) group.quest.progress.hp += (group.quest.progress.hp * quest.boss.rage.healing); //TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage + if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; + } + } + // Everyone takes damage + var series = [ + function(cb2){ + mongoose.models.User.update({_id:{$in: _.keys(group.quest.members)}}, {$inc:{'stats.hp':down, _v:1}}, {multi:true}, cb2); + } + ] + + // Boss slain, finish quest + if (group.quest.progress.hp <= 0) { + group.sendChat('`You defeated ' + quest.boss.name('en') + '! Questing party members receive the rewards of victory.`'); + // Participants: Grant rewards & achievements, finish quest + series.push(function(cb2){ + group.finishQuest(quest,cb2); + }); + } + + series.push(function(cb2){group.save(cb2)}); + async.series(series,cb); + }) +} + +// Remove user from this group +GroupSchema.methods.leave = function(user, keep, mainCb){ + if(!user) return mainCb(new Error('Missing user.')); + + if(keep && typeof keep === 'function'){ + mainCb = keep; + keep = null; + } + if(typeof keep !== 'string') keep = 'keep-all'; // can be also 'remove-all' + + var group = this; + + async.parallel([ + // Remove user from group challenges + function(cb){ + async.waterfall([ + // Find relevant challenges + function(cb2) { + Challenge.find({ + _id: {$in: user.challenges}, // Challenges I am in + group: group._id // that belong to the group I am leaving + }, cb2); + }, + + // Update each challenge + function(challenges, cb2) { + Challenge.update( + {_id: {$in: _.pluck(challenges, '_id')}}, + {$pull: {members: user._id}}, + {multi: true}, + function(err) { + cb2(err, challenges); // pass `challenges` above to cb + } + ); + }, + + // Unlink the challenge tasks from user + function(challenges, cb2) { + async.waterfall(challenges.map(function(chal) { + return function(cb3) { + var i = user.challenges.indexOf(chal._id) + if (~i) user.challenges.splice(i,1); + user.unlink({cid: chal._id, keep: keep}, cb3); + } + }), cb2); + } + ], cb); + }, + + // Update the group + function(cb){ + // If user is the last one in group and group is private, delete it + if(group.members.length === 1 && ( + group.type === 'party' || + (group.type === 'guild' && group.privacy === 'private') + )){ + group.remove(cb) + }else{ // otherwise just remove a member + var update = {$pull: {members: user._id}}; + + // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) + var leader = group.leader; + + if(leader == user._id || !~group.members.indexOf(leader)){ + var seniorMember = _.find(group.members, function (m) {return m != user._id}); + + // could not exist in case of public guild with 1 member who is leaving + if(seniorMember){ + if (leader == user._id || !~group.members.indexOf(leader)) { + update['$set'] = update['$set'] || {}; + update['$set'].leader = seniorMember; + } + } + } + + update['$inc'] = {memberCount: -1}; + Group.update({_id: group._id}, update, cb); + } + } + ], function(err){ + if(err) return mainCb(err); + + firebase.removeUserFromGroup(group._id, user._id); + return mainCb(); + }); +}; + + +GroupSchema.methods.toJSON = function() { + var doc = this.toObject(); + + return doc; +}; + + +module.exports.schema = GroupSchema; +var Group = module.exports.model = mongoose.model("Group", GroupSchema); + +// initialize tavern if !exists (fresh installs) +Group.count({_id: 'habitrpg'}, function(err, ct){ + if (ct > 0) return; + + new Group({ + _id: 'habitrpg', + chat: [], + leader: '9', + name: 'HabitRPG', + type: 'guild', + privacy: 'public' + }).save(); +}); diff --git a/website/src/models/task.js b/website/src/models/task.js new file mode 100644 index 0000000000..97a0e1e7c6 --- /dev/null +++ b/website/src/models/task.js @@ -0,0 +1,113 @@ +// User.js +// ======= +// Defines the user data model (schema) for use via the API. + +// Dependencies +// ------------ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var moment = require('moment'); + +// Task Schema +// ----------- + +var TaskSchema = { + //_id:{type: String,'default': helpers.uuid}, + id: {type: String,'default': shared.uuid}, + dateCreated: {type:Date, 'default':Date.now}, + text: String, + notes: {type: String, 'default': ''}, + tags: {type: Schema.Types.Mixed, 'default': {}}, //{ "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, + value: {type: Number, 'default': 0}, // redness + priority: {type: Number, 'default': '1'}, + attribute: {type: String, 'default': "str", enum: ['str','con','int','per']}, + challenge: { + id: {type: 'String', ref:'Challenge'}, + broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED + winner: String // user.profile.name + // group: {type: 'Strign', ref: 'Group'} // if we restore this, rename `id` above to `challenge` + }, + reminders: [{ + id: {type:String,'default':shared.uuid}, + startDate: Date, + time: Date + }] +}; + +var HabitSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'habit'}, + history: Array, // [{date:Date, value:Number}], // this causes major performance problems + up: {type: Boolean, 'default': true}, + down: {type: Boolean, 'default': true} + }, TaskSchema) + , { _id: false, minimize:false } +); + +var collapseChecklist = {type:Boolean, 'default':false}; +var checklist = [{ + completed:{type:Boolean,'default':false}, + text: String, + _id:false, + id: {type:String,'default':shared.uuid} +}]; + +var DailySchema = new Schema( + _.defaults({ + type: {type: String, 'default': 'daily'}, + frequency: {type: String, 'default': 'weekly', enum: ['daily', 'weekly']}, + everyX: {type: Number, 'default': 1}, // e.g. once every X weeks + startDate: {type: Date, 'default': moment().startOf('day').toDate()}, + history: Array, + completed: {type: Boolean, 'default': false}, + repeat: { // used only for 'weekly' frequency, + m: {type: Boolean, 'default': true}, + t: {type: Boolean, 'default': true}, + w: {type: Boolean, 'default': true}, + th: {type: Boolean, 'default': true}, + f: {type: Boolean, 'default': true}, + s: {type: Boolean, 'default': true}, + su: {type: Boolean, 'default': true} + }, + collapseChecklist:collapseChecklist, + checklist:checklist, + streak: {type: Number, 'default': 0} + }, TaskSchema) + , { _id: false, minimize:false } +) + +var TodoSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'todo'}, + completed: {type: Boolean, 'default': false}, + dateCompleted: Date, + date: String, // due date for todos // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date + collapseChecklist:collapseChecklist, + checklist:checklist + }, TaskSchema) + , { _id: false, minimize:false } +); + +var RewardSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'reward'} + }, TaskSchema) + , { _id: false, minimize:false } +); + +/** + * Workaround for bug when _id & id were out of sync, we can remove this after challenges has been running for a while + */ +//_.each([HabitSchema, DailySchema, TodoSchema, RewardSchema], function(schema){ +// schema.post('init', function(doc){ +// if (!doc.id && doc._id) doc.id = doc._id; +// }) +//}) + +module.exports.TaskSchema = TaskSchema; +module.exports.HabitSchema = HabitSchema; +module.exports.DailySchema = DailySchema; +module.exports.TodoSchema = TodoSchema; +module.exports.RewardSchema = RewardSchema; diff --git a/website/src/models/user.js b/website/src/models/user.js new file mode 100644 index 0000000000..ac5f1ba0a2 --- /dev/null +++ b/website/src/models/user.js @@ -0,0 +1,697 @@ +// User.js +// ======= +// Defines the user data model (schema) for use via the API. + +// Dependencies +// ------------ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var TaskSchemas = require('./task'); +var Challenge = require('./challenge').model; +var moment = require('moment'); + +// User Schema +// ----------- + +var UserSchema = new Schema({ + // ### UUID and API Token + _id: { + type: String, + 'default': shared.uuid + }, + apiToken: { + type: String, + 'default': shared.uuid + }, + + // ### Mongoose Update Object + // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which + // have been updated (http://goo.gl/gQLz41), but we want *every* update + _v: { type: Number, 'default': 0 }, + achievements: { + originalUser: Boolean, + habitSurveys: Number, + ultimateGearSets: Schema.Types.Mixed, + beastMaster: Boolean, + beastMasterCount: Number, + mountMaster: Boolean, + mountMasterCount: Number, + triadBingo: Boolean, + triadBingoCount: Number, + veteran: Boolean, + snowball: Number, + spookDust: Number, + shinySeed: Number, + seafoam: Number, + streak: Number, + challenges: Array, + quests: Schema.Types.Mixed, + rebirths: Number, + rebirthLevel: Number, + perfect: Number, + habitBirthdays: Number, + valentine: Number, + costumeContest: Boolean, // Superseded by costumeContests + nye: Number, + habiticaDays: Number, + greeting: Number, + thankyou: Number, + costumeContests: Number, + birthday: Number, + partyUp: Boolean, + partyOn: Boolean + }, + auth: { + blocked: Boolean, + facebook: Schema.Types.Mixed, + local: { + email: String, + hashed_password: String, + salt: String, + username: String, + lowerCaseUsername: String // Store a lowercase version of username to check for duplicates + }, + timestamps: { + created: {type: Date,'default': Date.now}, + loggedin: {type: Date,'default': Date.now} + } + }, + + backer: { + tier: Number, + npc: String, + tokensApplied: Boolean + }, + + contributor: { + level: Number, // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801 + admin: Boolean, + sudo: Boolean, + text: String, // Artisan, Friend, Blacksmith, etc + contributions: String, // a markdown textarea to list their contributions + links + critical: String + }, + + balance: {type: Number, 'default':0}, + filters: {type: Schema.Types.Mixed, 'default': {}}, + + purchased: { + ads: {type: Boolean, 'default': false}, + skin: {type: Schema.Types.Mixed, 'default': {}}, // eg, {skeleton: true, pumpkin: true, eb052b: true} + hair: {type: Schema.Types.Mixed, 'default': {}}, + shirt: {type: Schema.Types.Mixed, 'default': {}}, + background: {type: Schema.Types.Mixed, 'default': {}}, + txnCount: {type: Number, 'default':0}, + mobileChat: Boolean, + plan: { + planId: String, + paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} + customerId: String, // Billing Agreement Id in case of Amazon Payments + dateCreated: Date, + dateTerminated: Date, + dateUpdated: Date, + extraMonths: {type:Number, 'default':0}, + gemsBought: {type: Number, 'default': 0}, + mysteryItems: {type: Array, 'default': []}, + lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date + consecutive: { + count: {type:Number, 'default':0}, + offset: {type:Number, 'default':0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 + gemCapExtra: {type:Number, 'default':0}, + trinkets: {type:Number, 'default':0} + } + } + }, + + flags: { + customizationsNotification: {type: Boolean, 'default': false}, + showTour: {type: Boolean, 'default': true}, + tour: { + // -1 indicates "uninitiated", -2 means "complete", any other number is the current tour step (0-index) + intro: {type: Number, 'default': -1}, + classes: {type: Number, 'default': -1}, + stats: {type: Number, 'default': -1}, + tavern: {type: Number, 'default': -1}, + party: {type: Number, 'default': -1}, + guilds: {type: Number, 'default': -1}, + challenges: {type: Number, 'default': -1}, + market: {type: Number, 'default': -1}, + pets: {type: Number, 'default': -1}, + mounts: {type: Number, 'default': -1}, + hall: {type: Number, 'default': -1}, + equipment: {type: Number, 'default': -1} + }, + tutorial: { + common: { + habits: {type: Boolean, 'default': false}, + dailies: {type: Boolean, 'default': false}, + todos: {type: Boolean, 'default': false}, + rewards: {type: Boolean, 'default': false}, + party: {type: Boolean, 'default': false}, + pets: {type: Boolean, 'default': false}, + gems: {type: Boolean, 'default': false}, + skills: {type: Boolean, 'default': false}, + classes: {type: Boolean, 'default': false}, + tavern: {type: Boolean, 'default': false}, + equipment: {type: Boolean, 'default': false}, + items: {type: Boolean, 'default': false}, + }, + ios: { + addTask: {type: Boolean, 'default': false}, + editTask: {type: Boolean, 'default': false}, + deleteTask: {type: Boolean, 'default': false}, + filterTask: {type: Boolean, 'default': false}, + groupPets: {type: Boolean, 'default': false}, + inviteParty: {type: Boolean, 'default': false}, + } + }, + dropsEnabled: {type: Boolean, 'default': false}, + itemsEnabled: {type: Boolean, 'default': false}, + newStuff: {type: Boolean, 'default': false}, + rewrite: {type: Boolean, 'default': true}, + contributor: Boolean, + classSelected: {type: Boolean, 'default': false}, + mathUpdates: Boolean, + rebirthEnabled: {type: Boolean, 'default': false}, + levelDrops: {type:Schema.Types.Mixed, 'default':{}}, + chatRevoked: Boolean, + // Used to track the status of recapture emails sent to each user, + // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user + recaptureEmailsPhase: {type: Number, 'default': 0}, + // Needed to track the tip to send inside the email + weeklyRecapEmailsPhase: {type: Number, 'default': 0}, + // Used to track when the next weekly recap should be sent + lastWeeklyRecap: {type: Date, 'default': Date.now}, + // Used to enable weekly recap emails as users login + lastWeeklyRecapDiscriminator: Boolean, + communityGuidelinesAccepted: {type: Boolean, 'default': false}, + cronCount: {type:Number, 'default':0}, + welcomed: {type: Boolean, 'default': false}, + armoireEnabled: {type: Boolean, 'default': false}, + armoireOpened: {type: Boolean, 'default': false}, + armoireEmpty: {type: Boolean, 'default': false}, + cardReceived: {type: Boolean, 'default': false}, + warnedLowHealth: {type: Boolean, 'default': false} + }, + history: { + exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined + todos: Array //[{data: Date, value: Number}] // big peformance issues if these are defined + }, + + invitations: { + guilds: {type: Array, 'default': []}, + party: Schema.Types.Mixed + }, + items: { + gear: { + owned: _.transform(shared.content.gear.flat, function(m,v,k){ + m[v.key] = {type: Boolean}; + if (v.key.match(/[armor|head|shield]_warrior_0/) || v.gearSet === 'glasses') + m[v.key]['default'] = true; + }), + + equipped: { + weapon: String, + armor: {type: String, 'default': 'armor_base_0'}, + head: {type: String, 'default': 'head_base_0'}, + shield: {type: String, 'default': 'shield_base_0'}, + back: String, + headAccessory: String, + eyewear: String, + body: String + }, + costume: { + weapon: String, + armor: {type: String, 'default': 'armor_base_0'}, + head: {type: String, 'default': 'head_base_0'}, + shield: {type: String, 'default': 'shield_base_0'}, + back: String, + headAccessory: String, + eyewear: String, + body: String + } + }, + + special:{ + snowball: {type: Number, 'default': 0}, + spookDust: {type: Number, 'default': 0}, + shinySeed: {type: Number, 'default': 0}, + seafoam: {type: Number, 'default': 0}, + valentine: Number, + valentineReceived: Array, // array of strings, by sender name + nye: Number, + nyeReceived: Array, + greeting: Number, + greetingReceived: Array, + thankyou: Number, + thankyouReceived: Array, + birthday: Number, + birthdayReceived: Array + }, + + // -------------- Animals ------------------- + // Complex bit here. The result looks like: + // pets: { + // 'Wolf-Desert': 0, // 0 means does not own + // 'PandaCub-Red': 10, // Number represents "Growth Points" + // etc... + // } + pets: + _.defaults( + // First transform to a 1D eggs/potions mapping + _.transform(shared.content.pets, function(m,v,k){ m[k] = Number; }), + // Then add additional pets (quest, backer, contributor, premium) + _.transform(shared.content.questPets, function(m,v,k){ m[k] = Number; }), + _.transform(shared.content.specialPets, function(m,v,k){ m[k] = Number; }), + _.transform(shared.content.premiumPets, function(m,v,k){ m[k] = Number; }) + ), + currentPet: String, // Cactus-Desert + + // eggs: { + // 'PandaCub': 0, // 0 indicates "doesn't own" + // 'Wolf': 5 // Number indicates "stacking" + // } + eggs: _.transform(shared.content.eggs, function(m,v,k){ m[k] = Number; }), + + // hatchingPotions: { + // 'Desert': 0, // 0 indicates "doesn't own" + // 'CottonCandyBlue': 5 // Number indicates "stacking" + // } + hatchingPotions: _.transform(shared.content.hatchingPotions, function(m,v,k){ m[k] = Number; }), + + // Food: { + // 'Watermelon': 0, // 0 indicates "doesn't own" + // 'RottenMeat': 5 // Number indicates "stacking" + // } + food: _.transform(shared.content.food, function(m,v,k){ m[k] = Number; }), + + // mounts: { + // 'Wolf-Desert': true, + // 'PandaCub-Red': false, + // etc... + // } + mounts: _.defaults( + // First transform to a 1D eggs/potions mapping + _.transform(shared.content.pets, function(m,v,k){ m[k] = Boolean; }), + // Then add quest and premium pets + _.transform(shared.content.questPets, function(m,v,k){ m[k] = Boolean; }), + _.transform(shared.content.premiumPets, function(m,v,k){ m[k] = Boolean; }), + // Then add additional mounts (backer, contributor) + _.transform(shared.content.specialMounts, function(m,v,k){ m[k] = Boolean; }) + ), + currentMount: String, + + // Quests: { + // 'boss_0': 0, // 0 indicates "doesn't own" + // 'collection_honey': 5 // Number indicates "stacking" + // } + quests: _.transform(shared.content.quests, function(m,v,k){ m[k] = Number; }), + + lastDrop: { + date: {type: Date, 'default': Date.now}, + count: {type: Number, 'default': 0} + } + }, + + lastCron: {type: Date, 'default': Date.now}, + + // {GROUP_ID: Boolean}, represents whether they have unseen chat messages + newMessages: {type: Schema.Types.Mixed, 'default': {}}, + + party: { + // id // FIXME can we use a populated doc instead of fetching party separate from user? + order: {type:String, 'default':'level'}, + orderAscending: {type:String, 'default':'ascending'}, + quest: { + key: String, + progress: { + up: {type: Number, 'default': 0}, + down: {type: Number, 'default': 0}, + collect: {type: Schema.Types.Mixed, 'default': {}} // {feather:1, ingot:2} + }, + completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser + RSVPNeeded: {type: Boolean, 'default': false} // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled + } + }, + preferences: { + dayStart: {type:Number, 'default': 0, min: 0, max: 23}, + size: {type:String, enum: ['broad','slim'], 'default': 'slim'}, + hair: { + color: {type: String, 'default': 'red'}, + base: {type: Number, 'default': 3}, + bangs: {type: Number, 'default': 1}, + beard: {type: Number, 'default': 0}, + mustache: {type: Number, 'default': 0}, + flower: {type: Number, 'default': 1} + }, + chair: {type: String, 'default': 'none'}, + hideHeader: {type:Boolean, 'default':false}, + skin: {type:String, 'default':'915533'}, + shirt: {type: String, 'default': 'blue'}, + timezoneOffset: {type: Number, 'default': 0}, + timezoneOffsetAtLastCron: Number, + sound: {type:String, 'default':'off', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme']}, + language: String, + automaticAllocation: Boolean, + allocationMode: {type:String, enum: ['flat','classbased','taskbased'], 'default': 'flat'}, + autoEquip: {type: Boolean, 'default': true}, + costume: Boolean, + dateFormat: {type: String, enum:['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], 'default': 'MM/dd/yyyy'}, + sleep: {type: Boolean, 'default': false}, + stickyHeader: {type: Boolean, 'default': true}, + disableClasses: {type: Boolean, 'default': false}, + newTaskEdit: {type: Boolean, 'default': false}, + dailyDueDefaultView: {type: Boolean, 'default': false}, + tagsCollapsed: {type: Boolean, 'default': false}, + advancedCollapsed: {type: Boolean, 'default': false}, + toolbarCollapsed: {type:Boolean, 'default':false}, + reverseChatOrder: {type:Boolean, 'default':false}, + background: String, + displayInviteToPartyWhenPartyIs1: { type:Boolean, 'default':true}, + webhooks: {type: Schema.Types.Mixed, 'default': {}}, + // For this fields make sure to use strict comparison when searching for falsey values (=== false) + // As users who didn't login after these were introduced may have them undefined/null + emailNotifications: { + unsubscribeFromAll: {type: Boolean, 'default': false}, + newPM: {type: Boolean, 'default': true}, + kickedGroup: {type: Boolean, 'default': true}, + wonChallenge: {type: Boolean, 'default': true}, + giftedGems: {type: Boolean, 'default': true}, + giftedSubscription: {type: Boolean, 'default': true}, + invitedParty: {type: Boolean, 'default': true}, + invitedGuild: {type: Boolean, 'default': true}, + questStarted: {type: Boolean, 'default': true}, + invitedQuest: {type: Boolean, 'default': true}, + //remindersToLogin: {type: Boolean, 'default': true}, + // Those importantAnnouncements are in fact the recapture emails + importantAnnouncements: {type: Boolean, 'default': true}, + weeklyRecaps: {type: Boolean, 'default': true} + }, + suppressModals: { + levelUp: {type: Boolean, 'default': false}, + hatchPet: {type: Boolean, 'default': false}, + raisePet: {type: Boolean, 'default': false}, + streak: {type: Boolean, 'default': false} + }, + improvementCategories: { + type: Array, + validate: (categories) => { + const validCategories = ['work', 'exercise', 'healthWellness', 'school', 'teams', 'chores', 'creativity']; + let isValidCategory = categories.every(category => validCategories.indexOf(category) !== -1); + return isValidCategory; + }} + }, + profile: { + blurb: String, + imageUrl: String, + name: String + }, + stats: { + hp: {type: Number, 'default': shared.maxHealth}, + mp: {type: Number, 'default': 10}, + exp: {type: Number, 'default': 0}, + gp: {type: Number, 'default': 0}, + lvl: {type: Number, 'default': 1}, + + // Class System + 'class': {type: String, enum: ['warrior','rogue','wizard','healer'], 'default': 'warrior'}, + points: {type: Number, 'default': 0}, + str: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0}, + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + buffs: { + str: {type: Number, 'default': 0}, + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0}, + stealth: {type: Number, 'default': 0}, + streaks: {type: Boolean, 'default': false}, + snowball: {type: Boolean, 'default': false}, + spookDust: {type: Boolean, 'default': false}, + shinySeed: {type: Boolean, 'default': false}, + seafoam: {type: Boolean, 'default': false} + }, + training: { + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + str: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0} + } + }, + + tags: {type: [{ + _id: false, + id: { type: String, 'default': shared.uuid }, + name: String, + challenge: String + }]}, + + challenges: [{type: 'String', ref:'Challenge'}], + + inbox: { + newMessages: {type:Number, 'default':0}, + blocks: {type:Array, 'default':[]}, + messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist + optOut: {type:Boolean, 'default':false} + }, + + habits: {type:[TaskSchemas.HabitSchema]}, + dailys: {type:[TaskSchemas.DailySchema]}, + todos: {type:[TaskSchemas.TodoSchema]}, + rewards: {type:[TaskSchemas.RewardSchema]}, + + extra: Schema.Types.Mixed, + + pushDevices: {type: [{ + regId: {type: String}, + type: {type: String} + }],'default': []} + +}, { + strict: true, + minimize: false // So empty objects are returned +}); + +UserSchema.methods.deleteTask = function(tid) { + this.ops.deleteTask({params:{id:tid}},function(){}); // TODO remove this whole method, since it just proxies, and change all references to this method +} + +UserSchema.methods.toJSON = function() { + var doc = this.toObject(); + doc.id = doc._id; + + // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? + doc.filters = {}; + doc._tmp = this._tmp; // be sure to send down drop notifs + + return doc; +}; + +//UserSchema.virtual('tasks').get(function () { +// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); +// var tasks = _.object(_.pluck(tasks,'id'), tasks); +// return tasks; +//}); + +UserSchema.post('init', function(doc){ + shared.wrap(doc); +}) + +UserSchema.pre('save', function(next) { + + // Populate new users with default content + if (this.isNew){ + _populateDefaultsForNewUser(this); + } + + //this.markModified('tasks'); + if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { + this.preferences.dayStart = 0; + } + + if (!this.profile.name) { + var fb = this.auth.facebook; + this.profile.name = + (this.auth.local && this.auth.local.username) || + (fb && (fb.displayName || fb.name || fb.username || (fb.first_name && fb.first_name + ' ' + fb.last_name))) || + 'Anonymous'; + } + + // Determines if Beast Master should be awarded + var beastMasterProgress = shared.count.beastMasterProgress(this.items.pets); + if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) { + this.achievements.beastMaster = true; + } + + // Determines if Mount Master should be awarded + var mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts); + + if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) { + this.achievements.mountMaster = true; + } + + // Determines if Triad Bingo should be awarded + + var dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets); + var qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90; + + if (qualifiesForTriad || this.achievements.triadBingoCount > 0) { + this.achievements.triadBingo = true; + } + + // Enable weekly recap emails for old users who sign in + if(this.flags.lastWeeklyRecapDiscriminator){ + // Enable weekly recap emails in 24 hours + this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate(); + // Unset the field so this is run only once + this.flags.lastWeeklyRecapDiscriminator = undefined; + } + + // EXAMPLE CODE for allowing all existing and new players to be + // automatically granted an item during a certain time period: + // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) + // this.items.pets['JackOLantern-Base'] = 5; + + //our own version incrementer + if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; + this._v++; + + next(); +}); + +UserSchema.methods.unlink = function(options, cb) { + var cid = options.cid, keep = options.keep, tid = options.tid; + if (!cid) { + return cb("Could not remove challenge tasks. Please delete them manually."); + } + var self = this; + switch (keep) { + case 'keep': + self.tasks[tid].challenge = {}; + break; + case 'remove': + self.deleteTask(tid); + break; + case 'keep-all': + _.each(self.tasks, function(t){ + if (t.challenge && t.challenge.id == cid) { + t.challenge = {}; + } + }); + break; + case 'remove-all': + _.each(self.tasks, function(t){ + if (t.challenge && t.challenge.id == cid) { + self.deleteTask(t.id); + } + }) + break; + } + self.markModified('habits'); + self.markModified('dailys'); + self.markModified('todos'); + self.markModified('rewards'); + self.save(cb); +} + +function _populateDefaultsForNewUser(user) { + var taskTypes; + + if (user.registeredThrough === "habitica-web" || user.registeredThrough === "habitica-android") { + taskTypes = ['habits', 'dailys', 'todos', 'rewards', 'tags']; + + var tutorialCommonSections = [ + 'habits', + 'dailies', + 'todos', + 'rewards', + 'party', + 'pets', + 'gems', + 'skills', + 'classes', + 'tavern', + 'equipment', + 'items', + 'inviteParty', + ]; + + _.each(tutorialCommonSections, function(section) { + user.flags.tutorial.common[section] = true; + }); + } else { + taskTypes = ['todos', 'tags'] + + user.flags.showTour = false; + + var tourSections = [ + 'showTour', + 'intro', + 'classes', + 'stats', + 'tavern', + 'party', + 'guilds', + 'challenges', + 'market', + 'pets', + 'mounts', + 'hall', + 'equipment', + ]; + + _.each(tourSections, function(section) { + user.flags.tour[section] = -2; + }); + } + + _populateDefaultTasks(user, taskTypes); +} + +function _populateDefaultTasks (user, taskTypes) { + _.each(taskTypes, function(taskType){ + user[taskType] = _.map(shared.content.userDefaults[taskType], function(task){ + var newTask = _.cloneDeep(task); + + // Render task's text and notes in user's language + if(taskType === 'tags'){ + // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here + newTask.id = shared.uuid(); + newTask.name = newTask.name(user.preferences.language); + }else{ + newTask.text = newTask.text(user.preferences.language); + if(newTask.notes) { + newTask.notes = newTask.notes(user.preferences.language); + } + + if(newTask.checklist){ + newTask.checklist = _.map(newTask.checklist, function(checklistItem){ + checklistItem.text = checklistItem.text(user.preferences.language); + return checklistItem; + }); + } + } + + return newTask; + }); + }); +} + +module.exports.schema = UserSchema; +module.exports.model = mongoose.model("User", UserSchema); +// Initially export an empty object so external requires will get +// the right object by reference when it's defined later +// Otherwise it would remain undefined if requested before the query executes +module.exports.mods = []; + +mongoose.model("User") + .find({'contributor.admin':true}) + .sort('-contributor.level -backer.npc profile.name') + .select('profile contributor backer') + .exec(function(err,mods){ + // Using push to maintain the reference to mods + module.exports.mods.push.apply(module.exports.mods, mods); +}); diff --git a/website/src/routes/api-v1.js b/website/src/routes/api-v1.js new file mode 100644 index 0000000000..b3408f523d --- /dev/null +++ b/website/src/routes/api-v1.js @@ -0,0 +1,173 @@ +var express = require('express'); +var router = express.Router(); +var _ = require('lodash'); +var async = require('async'); +var icalendar = require('icalendar'); +var api = require('./../controllers/api-v2/user'); +var auth = require('./../controllers/api-v2/auth'); +var logging = require('./../libs/logging'); +var i18n = require('./../libs/i18n'); +var forceRefresh = require('../middlewares/forceRefresh').middleware; + +/* ---------- Deprecated API ------------*/ + +var initDeprecated = function(req, res, next) { + req.headers['x-api-user'] = req.params.uid; + req.headers['x-api-key'] = req.body.apiToken; + return next(); +}; + +router.post('/v1/users/:uid/tasks/:taskId/:direction', initDeprecated, auth.auth, i18n.getUserLanguage, api.score); + +// FIXME add this back in +router.get('/v1/users/:uid/calendar.ics', i18n.getUserLanguage, function(req, res, next) { + return next() //disable for now + + var apiToken, model, query, uid; + uid = req.params.uid; + apiToken = req.query.apiToken; + model = req.getModel(); + query = model.query('users').withIdAndToken(uid, apiToken); + return query.fetch(function(err, result) { + var formattedIcal, ical, tasks, tasksWithDates; + if (err) { + return res.send(500, err); + } + tasks = result.get('tasks'); + /* tasks = result[0].tasks*/ + + tasksWithDates = _.filter(tasks, function(task) { + return !!task.date; + }); + if (_.isEmpty(tasksWithDates)) { + return res.send(500, "No events found"); + } + ical = new icalendar.iCalendar(); + ical.addProperty('NAME', 'HabitRPG'); + _.each(tasksWithDates, function(task) { + var d, event; + 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); + return true; + }); + res.type('text/calendar'); + formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:'); + return res.send(200, formattedIcal); + }); +}); + +/* + ------------------------------------------------------------------------ + Batch Update + This is super-deprecated, and will be removed once apiv2 is running against mobile for a while + ------------------------------------------------------------------------ + */ +var batchUpdate = function(req, res, next) { + var user = res.locals.user; + var oldSend = res.send; + var oldJson = res.json; + var performAction = function(action, cb) { + + // req.body=action.data; delete action.data; _.defaults(req.params, action) + // Would require changing action.dir on mobile app + req.params.id = action.data && action.data.id; + req.params.direction = action.dir; + req.params.type = action.type; + req.body = action.data; + res.send = res.json = function(code, data) { + if (_.isNumber(code) && code >= 400) { + logging.error({ + code: code, + data: data + }); + } + //FIXME send error messages down + return cb(); + }; + switch (action.op) { + case "score": + api.score(req, res); + break; + case "addTask": + api.addTask(req, res); + break; + case "delTask": + api.deleteTask(req, res); + break; + case "revive": + api.revive(req, res); + break; + default: + cb(); + break; + } + }; + + // Setup the array of functions we're going to call in parallel with async + var actions = _.transform(req.body || [], function(result, action) { + if (!_.isEmpty(action)) { + result.push(function(cb) { + performAction(action, cb); + }); + } + }); + + // call all the operations, then return the user object to the requester + async.series(actions, function(err) { + res.json = oldJson; + res.send = oldSend; + if (err) return res.json(500, {err: err}); + var response = user.toJSON(); + response.wasModified = res.locals.wasModified; + if (response._tmp && response._tmp.drop){ + res.json(200, {_tmp: {drop: response._tmp.drop}, _v: response._v}); + }else if(response.wasModified){ + res.json(200, response); + }else{ + res.json(200, {_v: response._v}); + } + }); +}; + +/* + ------------------------------------------------------------------------ + API v1 Routes + ------------------------------------------------------------------------ + */ + + +var cron = api.cron; + +router.get('/status', i18n.getUserLanguage, function(req, res) { + return res.json({ + status: 'up' + }); +}); + +// Scoring +router.post('/user/task/:id/:direction', auth.auth, i18n.getUserLanguage, cron, api.score); +router.post('/user/tasks/:id/:direction', auth.auth, i18n.getUserLanguage, cron, api.score); + +// Tasks +router.get('/user/tasks', auth.auth, i18n.getUserLanguage, cron, api.getTasks); +router.get('/user/task/:id', auth.auth, i18n.getUserLanguage, cron, api.getTask); +router.delete('/user/task/:id', auth.auth, i18n.getUserLanguage, cron, api.deleteTask); +router.post('/user/task', auth.auth, i18n.getUserLanguage, cron, api.addTask); + +// User +router.get('/user', auth.auth, i18n.getUserLanguage, cron, api.getUser); +router.post('/user/revive', auth.auth, i18n.getUserLanguage, cron, api.revive); +router.post('/user/batch-update', forceRefresh, auth.auth, i18n.getUserLanguage, cron, batchUpdate); + +function deprecated(req, res) { + res.json(404, {err:'API v1 is no longer supported, please use API v2 instead (https://github.com/HabitRPG/habitrpg/blob/develop/API.md)'}); +} +router.get('*', i18n.getUserLanguage, deprecated); +router.post('*', i18n.getUserLanguage, deprecated); +router.put('*', i18n.getUserLanguage, deprecated); + +module.exports = router; diff --git a/website/src/routes/api-v2/auth.js b/website/src/routes/api-v2/auth.js new file mode 100644 index 0000000000..62e506d097 --- /dev/null +++ b/website/src/routes/api-v2/auth.js @@ -0,0 +1,21 @@ +var auth = require('../../controllers/api-v2/auth'); +var express = require('express'); +var i18n = require('../../libs/i18n'); +var router = express.Router(); + +/* auth.auth*/ +auth.setupPassport(router); //FIXME make this consistent with the others +router.post('/api/v2/register', i18n.getUserLanguage, auth.registerUser); +router.post('/api/v2/user/auth/local', i18n.getUserLanguage, auth.loginLocal); +router.post('/api/v2/user/auth/social', i18n.getUserLanguage, auth.loginSocial); +router.delete('/api/v2/user/auth/social', i18n.getUserLanguage, auth.auth, auth.deleteSocial); +router.post('/api/v2/user/reset-password', i18n.getUserLanguage, auth.resetPassword); +router.post('/api/v2/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); +router.post('/api/v2/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); +router.post('/api/v2/user/change-email', i18n.getUserLanguage, auth.auth, auth.changeEmail); +router.post('/api/v2/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken); + +router.post('/api/v1/register', i18n.getUserLanguage, auth.registerUser); +router.post('/api/v1/user/auth/local', i18n.getUserLanguage, auth.loginLocal); +router.post('/api/v1/user/auth/social', i18n.getUserLanguage, auth.loginSocial); +module.exports = router; diff --git a/website/src/routes/api-v2/coupon.js b/website/src/routes/api-v2/coupon.js new file mode 100644 index 0000000000..9057758d3a --- /dev/null +++ b/website/src/routes/api-v2/coupon.js @@ -0,0 +1,12 @@ +var nconf = require('nconf'); +var express = require('express'); +var router = express.Router(); +var auth = require('../../controllers/api-v2/auth'); +var coupon = require('../../controllers/api-v2/coupon'); +var i18n = require('../../libs/i18n'); + +router.get('/api/v2/coupons', auth.authWithUrl, i18n.getUserLanguage, coupon.ensureAdmin, coupon.getCoupons); +router.post('/api/v2/coupons/generate/:event', auth.auth, i18n.getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons); +router.post('/api/v2/user/coupon/:code', auth.auth, i18n.getUserLanguage, coupon.enterCode); + +module.exports = router; diff --git a/website/server/routes/api-v2/swagger.js b/website/src/routes/api-v2/swagger.js similarity index 91% rename from website/server/routes/api-v2/swagger.js rename to website/src/routes/api-v2/swagger.js index c60d8364b6..a1500ac39a 100644 --- a/website/server/routes/api-v2/swagger.js +++ b/website/src/routes/api-v2/swagger.js @@ -13,15 +13,12 @@ var members = require("../../controllers/api-v2/members"); var auth = require("../../controllers/api-v2/auth"); var hall = require("../../controllers/api-v2/hall"); var challenges = require("../../controllers/api-v2/challenges"); -var dataexport = require("../../controllers/api-v2/dataexport"); +var dataexport = require("../../controllers/dataexport"); var nconf = require("nconf"); var cron = user.cron; var _ = require('lodash'); var content = require('../../../../common').content; -var i18n = require('../../libs/api-v2/i18n'); -import { - getUserLanguage -} from '../../middlewares/api-v3/language'; +var i18n = require('../../libs/i18n'); var forceRefresh = require('../../middlewares/forceRefresh').middleware; module.exports = function(swagger, v2) { @@ -63,7 +60,7 @@ module.exports = function(swagger, v2) { description: "Export user history", method: 'GET' }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: dataexport.history }, "/user/tasks/{id}/{direction}": { @@ -137,7 +134,7 @@ module.exports = function(swagger, v2) { description: 'Unlink a task from its challenge', parameters: [path("id", "Task ID", "string"), query('keep', "When unlinking a challenge task, how to handle the orphans?", 'string', ['keep', 'keep-all', 'remove', 'remove-all'])] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: challenges.unlink }, "/user/inventory/buy": { @@ -238,7 +235,7 @@ module.exports = function(swagger, v2) { method: 'DELETE', description: "Delete a user object entirely, USE WITH CAUTION!" }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: user["delete"] }, "/user/revive": { @@ -314,7 +311,7 @@ module.exports = function(swagger, v2) { description: "This is an advanced route which is useful for apps which might for example need offline support. You can send a whole batch of user-based operations, which allows you to queue them up offline and send them all at once. The format is {op:'nameOfOperation',parameters:{},body:{},query:{}}", parameters: [body('', 'The array of batch-operations to perform', 'object')] }, - middleware: [forceRefresh, auth.auth, getUserLanguage, cron, user.sessionPartyInvite], + middleware: [forceRefresh, auth.auth, i18n.getUserLanguage, cron, user.sessionPartyInvite], action: user.batchUpdate }, "/user/tags/{id}:GET": { @@ -409,7 +406,7 @@ module.exports = function(swagger, v2) { description: "Get a list of groups", parameters: [query('type', "Comma-separated types of groups to return, eg 'party,guilds,public,tavern'", 'string')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: groups.list }, "/groups:POST": { @@ -419,7 +416,7 @@ module.exports = function(swagger, v2) { description: 'Create a group', parameters: [body('', 'Group object (see GroupSchema)', 'object')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: groups.create }, "/groups/{gid}:GET": { @@ -428,7 +425,7 @@ module.exports = function(swagger, v2) { description: "Get a group. The party the user currently is in can be accessed with the gid 'party'.", parameters: [path('gid', 'Group ID', 'string')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: groups.get }, "/groups/{gid}:POST": { @@ -438,7 +435,7 @@ module.exports = function(swagger, v2) { description: "Edit a group", parameters: [body('', 'Group object (see GroupSchema)', 'object')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.update }, "/groups/{gid}/join": { @@ -447,7 +444,7 @@ module.exports = function(swagger, v2) { description: 'Join a group', parameters: [path('gid', 'Id of the group to join', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.join }, "/groups/{gid}/leave": { @@ -456,7 +453,7 @@ module.exports = function(swagger, v2) { description: 'Leave a group', parameters: [path('gid', 'ID of the group to leave', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.leave }, "/groups/{gid}/invite": { @@ -465,7 +462,7 @@ module.exports = function(swagger, v2) { description: "Invite a user to a group", parameters: [path('gid', 'Group id', 'string'), body('', 'a payload of invites either under body.uuids or body.emails, only one of them!', 'object')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.invite }, "/groups/{gid}/removeMember": { @@ -474,7 +471,7 @@ module.exports = function(swagger, v2) { description: "Remove / boot a member from a group", parameters: [path('gid', 'Group id', 'string'), query('uuid', 'User id to boot', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.removeMember }, "/groups/{gid}/questAccept": { @@ -483,7 +480,7 @@ module.exports = function(swagger, v2) { description: "Accept a quest invitation", parameters: [path('gid', "Group id", 'string'), query('key', "optional. if provided, trigger new invite, if not, accept existing invite", 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.questAccept }, "/groups/{gid}/questReject": { @@ -492,7 +489,7 @@ module.exports = function(swagger, v2) { description: 'Reject quest invitation', parameters: [path('gid', 'Group id', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.questReject }, "/groups/{gid}/questCancel": { @@ -501,7 +498,7 @@ module.exports = function(swagger, v2) { description: 'Cancel quest before it starts (in invitation stage)', parameters: [path('gid', 'Group to cancel quest in', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.questCancel }, "/groups/{gid}/questAbort": { @@ -510,7 +507,7 @@ module.exports = function(swagger, v2) { description: 'Abort quest after it has started (all progress will be lost)', parameters: [path('gid', 'Group to abort quest in', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.questAbort }, "/groups/{gid}/questLeave": { @@ -519,7 +516,7 @@ module.exports = function(swagger, v2) { description: 'Leave an active quest (Quest leaders cannot leave active quests. They must abort the quest to leave)', parameters: [path('gid', 'Group to leave quest in', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.questLeave }, "/groups/{gid}/chat:GET": { @@ -528,7 +525,7 @@ module.exports = function(swagger, v2) { description: "Get all chat messages", parameters: [path('gid', 'Group to return the chat from ', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.getChat }, "/groups/{gid}/chat:POST": { @@ -538,7 +535,7 @@ module.exports = function(swagger, v2) { description: "Send a chat message", parameters: [query('message', 'Chat message', 'string'), path('gid', 'Group id', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.postChat }, "/groups/{gid}/chat/seen": { @@ -555,7 +552,7 @@ module.exports = function(swagger, v2) { description: 'Delete a chat message in a given group', parameters: [path('gid', 'ID of the group containing the message to be deleted', 'string'), path('messageId', 'ID of message to be deleted', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.deleteChatMessage }, "/groups/{gid}/chat/{mid}/like": { @@ -564,7 +561,7 @@ module.exports = function(swagger, v2) { description: "Like a chat message", parameters: [path('gid', 'Group id', 'string'), path('mid', 'Message id', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.likeChatMessage }, "/groups/{gid}/chat/{mid}/flag": { @@ -573,7 +570,7 @@ module.exports = function(swagger, v2) { description: "Flag a chat message", parameters: [path('gid', 'Group id', 'string'), path('mid', 'Message id', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.flagChatMessage }, "/groups/{gid}/chat/{mid}/clearflags": { @@ -582,7 +579,7 @@ module.exports = function(swagger, v2) { description: "Clear flag count from message and unhide it", parameters: [path('gid', 'Group id', 'string'), path('mid', 'Message id', 'string')] }, - middleware: [auth.auth, getUserLanguage, groups.attachGroup], + middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup], action: groups.clearFlagCount }, "/members/{uuid}:GET": { @@ -591,7 +588,7 @@ module.exports = function(swagger, v2) { description: "Get a member.", parameters: [path('uuid', 'Member ID', 'string')] }, - middleware: [getUserLanguage], + middleware: [i18n.getUserLanguage], action: members.getMember }, "/members/{uuid}/message": { @@ -623,14 +620,14 @@ module.exports = function(swagger, v2) { }, "/hall/heroes": { spec: {}, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: hall.getHeroes }, "/hall/heroes/{uid}:GET": { spec: { path: "/hall/heroes/{uid}" }, - middleware: [auth.auth, getUserLanguage, hall.ensureAdmin], + middleware: [auth.auth, i18n.getUserLanguage, hall.ensureAdmin], action: hall.getHero }, "/hall/heroes/{uid}:POST": { @@ -638,14 +635,14 @@ module.exports = function(swagger, v2) { method: 'POST', path: "/hall/heroes/{uid}" }, - middleware: [auth.auth, getUserLanguage, hall.ensureAdmin], + middleware: [auth.auth, i18n.getUserLanguage, hall.ensureAdmin], action: hall.updateHero }, "/hall/patrons": { spec: { parameters: [query('page', 'Page number to fetch (this list is long)', 'string')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: hall.getPatrons }, "/challenges:GET": { @@ -653,7 +650,7 @@ module.exports = function(swagger, v2) { path: '/challenges', description: "Get a list of challenges" }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: challenges.list }, "/challenges:POST": { @@ -663,7 +660,7 @@ module.exports = function(swagger, v2) { description: "Create a challenge", parameters: [body('', 'Challenge object (see ChallengeSchema)', 'object')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: challenges.create }, "/challenges/{cid}:GET": { @@ -672,7 +669,7 @@ module.exports = function(swagger, v2) { description: 'Get a challenge', parameters: [path('cid', 'Challenge id', 'string')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: challenges.get }, "/challenges/{cid}/csv": { @@ -689,7 +686,7 @@ module.exports = function(swagger, v2) { description: "Update a challenge", parameters: [path('cid', 'Challenge id', 'string'), body('', 'Challenge object (see ChallengeSchema)', 'object')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: challenges.update }, "/challenges/{cid}:DELETE": { @@ -699,7 +696,7 @@ module.exports = function(swagger, v2) { description: "Delete a challenge", parameters: [path('cid', 'Challenge id', 'string')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: challenges["delete"] }, "/challenges/{cid}/close": { @@ -708,7 +705,7 @@ module.exports = function(swagger, v2) { description: 'Close a challenge', parameters: [path('cid', 'Challenge id', 'string'), query('uid', 'User ID of the winner', 'string', true)] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: challenges.selectWinner }, "/challenges/{cid}/join": { @@ -717,7 +714,7 @@ module.exports = function(swagger, v2) { description: "Join a challenge", parameters: [path('cid', 'Challenge id', 'string')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: challenges.join }, "/challenges/{cid}/leave": { @@ -726,7 +723,7 @@ module.exports = function(swagger, v2) { description: 'Leave a challenge', parameters: [path('cid', 'Challenge id', 'string')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: challenges.leave }, "/challenges/{cid}/member/{uid}": { @@ -734,7 +731,7 @@ module.exports = function(swagger, v2) { description: "Get a member's progress in a particular challenge", parameters: [path('cid', 'Challenge id', 'string'), path('uid', 'User id', 'string')] }, - middleware: [auth.auth, getUserLanguage], + middleware: [auth.auth, i18n.getUserLanguage], action: challenges.getMember } }; @@ -768,7 +765,7 @@ module.exports = function(swagger, v2) { method: 'GET' }); if (route.middleware == null) { - route.middleware = path.indexOf('/user') === 0 ? [auth.auth, getUserLanguage, cron] : [i18n.getUserLanguage]; + route.middleware = path.indexOf('/user') === 0 ? [auth.auth, i18n.getUserLanguage, cron] : [i18n.getUserLanguage]; } swagger["add" + route.spec.method](route); return true; diff --git a/website/src/routes/api-v2/unsubscription.js b/website/src/routes/api-v2/unsubscription.js new file mode 100644 index 0000000000..882fb10392 --- /dev/null +++ b/website/src/routes/api-v2/unsubscription.js @@ -0,0 +1,8 @@ +var express = require('express'); +var router = express.Router(); +var i18n = require('../../libs/i18n'); +var unsubscription = require('../../controllers/api-v2/unsubscription'); + +router.get('/unsubscribe', i18n.getUserLanguage, unsubscription.unsubscribe); + +module.exports = router; diff --git a/website/src/routes/dataexport.js b/website/src/routes/dataexport.js new file mode 100644 index 0000000000..9dd8511e3d --- /dev/null +++ b/website/src/routes/dataexport.js @@ -0,0 +1,16 @@ +var express = require('express'); +var router = express.Router(); +var dataexport = require('../controllers/dataexport'); +var auth = require('../controllers/api-v2/auth'); +var nconf = require('nconf'); +var i18n = require('../libs/i18n'); +var locals = require('../middlewares/locals'); + +/* Data export */ +router.get('/history.csv',auth.authWithSession,i18n.getUserLanguage,dataexport.history); //[todo] encode data output options in the data controller and use these to build routes +router.get('/userdata.xml',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.xml); +router.get('/userdata.json',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.json); +router.get('/avatar-:uuid.html', i18n.getUserLanguage, locals, dataexport.avatarPage); +router.get('/avatar-:uuid.png', i18n.getUserLanguage, locals, dataexport.avatarImage); + +module.exports = router; diff --git a/website/server/routes/pages.js b/website/src/routes/pages.js similarity index 94% rename from website/server/routes/pages.js rename to website/src/routes/pages.js index 17818285ed..04766d9551 100644 --- a/website/server/routes/pages.js +++ b/website/src/routes/pages.js @@ -2,8 +2,8 @@ var nconf = require('nconf'); var express = require('express'); var router = express.Router(); var _ = require('lodash'); -var locals = require('../middlewares/api-v2/locals'); -var i18n = require('../libs/api-v2/i18n'); +var locals = require('../middlewares/locals'); +var i18n = require('../libs/i18n'); var md = require('markdown-it')({ html: true, }); diff --git a/website/src/routes/payments.js b/website/src/routes/payments.js new file mode 100644 index 0000000000..c385c835c7 --- /dev/null +++ b/website/src/routes/payments.js @@ -0,0 +1,31 @@ +var nconf = require('nconf'); +var express = require('express'); +var router = express.Router(); +var auth = require('../controllers/api-v2/auth'); +var payments = require('../controllers/payments'); +var i18n = require('../libs/i18n'); + +router.get('/paypal/checkout', auth.authWithUrl, i18n.getUserLanguage, payments.paypalCheckout); +router.get('/paypal/checkout/success', i18n.getUserLanguage, payments.paypalCheckoutSuccess); +router.get('/paypal/subscribe', auth.authWithUrl, i18n.getUserLanguage, payments.paypalSubscribe); +router.get('/paypal/subscribe/success', i18n.getUserLanguage, payments.paypalSubscribeSuccess); +router.get('/paypal/subscribe/cancel', auth.authWithUrl, i18n.getUserLanguage, payments.paypalSubscribeCancel); +router.post('/paypal/ipn', i18n.getUserLanguage, payments.paypalIPN); // misc ipn handling + +router.post('/stripe/checkout', auth.auth, i18n.getUserLanguage, payments.stripeCheckout); +router.post('/stripe/subscribe/edit', auth.auth, i18n.getUserLanguage, payments.stripeSubscribeEdit); +//router.get('/stripe/subscribe', auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribe); // checkout route is used (above) with ?plan= instead +router.get('/stripe/subscribe/cancel', auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribeCancel); + +router.post('/amazon/verifyAccessToken', auth.auth, i18n.getUserLanguage, payments.amazonVerifyAccessToken); +router.post('/amazon/createOrderReferenceId', auth.auth, i18n.getUserLanguage, payments.amazonCreateOrderReferenceId); +router.post('/amazon/checkout', auth.auth, i18n.getUserLanguage, payments.amazonCheckout); +router.post('/amazon/subscribe', auth.auth, i18n.getUserLanguage, payments.amazonSubscribe); +router.get('/amazon/subscribe/cancel', auth.authWithUrl, i18n.getUserLanguage, payments.amazonSubscribeCancel); + +router.post('/iap/android/verify', auth.authWithUrl, /*i18n.getUserLanguage, */payments.iapAndroidVerify); +router.post('/iap/ios/verify', auth.auth, /*i18n.getUserLanguage, */ payments.iapIosVerify); + +router.get('/api/v2/coupons/valid-discount/:code', /*auth.authWithUrl, i18n.getUserLanguage, */ payments.validCoupon); + +module.exports = router; diff --git a/website/src/server.js b/website/src/server.js new file mode 100644 index 0000000000..7271d85579 --- /dev/null +++ b/website/src/server.js @@ -0,0 +1,177 @@ +if (process.env.NODE_ENV !== 'production') { + require('babel-register'); +} +// Only do the minimal amount of work before forking just in case of a dyno restart +var cluster = require("cluster"); +var _ = require('lodash'); +var nconf = require('nconf'); +var utils = require('./libs/utils'); +utils.setupConfig(); +var logging = require('./libs/logging'); +var isProd = nconf.get('NODE_ENV') === 'production'; +var isDev = nconf.get('NODE_ENV') === 'development'; +var DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); +var cores = +nconf.get("WEB_CONCURRENCY") || 0; +var moment = require('moment'); + +if (cores!==0 && cluster.isMaster && (isDev || isProd)) { + // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) + _.times(cores, function () { + cluster.fork(); + }); + + cluster.on('disconnect', function(worker, code, signal) { + var w = cluster.fork(); // replace the dead worker + logging.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid); + }); + +} else { + var express = require("express"); + var bodyParser = require('body-parser'); + var session = require('cookie-session'); + var logger = require('morgan'); + var compression = require('compression'); + var favicon = require('serve-favicon'); + + var BODY_PARSER_LIMIT = '1mb'; + + var http = require("http"); + var path = require("path"); + var swagger = require("swagger-node-express"); + var autoinc = require('mongoose-id-autoinc'); + var shared = require('../../common'); + + // Setup translations + var i18n = require('./libs/i18n'); + + var TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; + var app = express(); + var server = http.createServer(); + + // ------------ MongoDB Configuration ------------ + var mongoose = require('mongoose'); + var mongooseOptions = !isProd ? {} : { + replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, + server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } } + }; + var db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, function(err) { + if (err) throw err; + logging.info('Connected with Mongoose'); + }); + autoinc.init(db); + + require('./libs/firebase'); + + // load schemas & models + require('./models/challenge'); + require('./models/group'); + require('./models/user'); + + // ------------ Passport Configuration ------------ + var passport = require('passport') + var util = require('util') + var FacebookStrategy = require('passport-facebook').Strategy; + // Passport session setup. + // To support persistent login sessions, Passport needs to be able to + // serialize users into and deserialize users out of the session. Typically, + // this will be as simple as storing the user ID when serializing, and finding + // the user by ID when deserializing. However, since this example does not + // have a database of user records, the complete Facebook profile is serialized + // and deserialized. + passport.serializeUser(function(user, done) { + done(null, user); + }); + + passport.deserializeUser(function(obj, done) { + done(null, obj); + }); + + // FIXME + // This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) + // The proper fix would be to move to a general OAuth module simply to verify accessTokens + passport.use(new FacebookStrategy({ + clientID: nconf.get("FACEBOOK_KEY"), + clientSecret: nconf.get("FACEBOOK_SECRET"), + //callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" + }, + function(accessToken, refreshToken, profile, done) { + done(null, profile); + } + )); + + // ------------ Server Configuration ------------ + var publicDir = path.join(__dirname, "/../public"); + + app.set("port", nconf.get('PORT')); + + // Setup two different Express apps, one that matches everything except '/api/v3' + // and the other for /api/v3 routes, so we can keep the old an new api versions completely separate + // not sharing a single middleware if we don't want to + var oldApp = express(); // api v1 and v2, and not scoped routes + var newApp = express(); // api v3 + + // Matches all request except the ones going to /api/v3/** + app.all(/^(?!\/api\/v3).+/i, oldApp); + // Matches all requests going to /api/v3 + app.all('/api/v3', newApp); + + require('./middlewares/apiThrottle')(oldApp); + oldApp.use(require('./middlewares/domain')(server,mongoose)); + if (!isProd && !DISABLE_LOGGING) oldApp.use(logger("dev")); + oldApp.use(compression()); + oldApp.set("views", __dirname + "/../views"); + oldApp.set("view engine", "jade"); + oldApp.use(favicon(publicDir + '/favicon.ico')); + oldApp.use(require('./middlewares/cors')); + + var redirects = require('./middlewares/redirects'); + oldApp.use(redirects.forceHabitica); + oldApp.use(redirects.forceSSL); + oldApp.use(bodyParser.urlencoded({ + extended: true, + limit: BODY_PARSER_LIMIT, + })); + oldApp.use(bodyParser.json({ + limit: BODY_PARSER_LIMIT, + })); + oldApp.use(require('method-override')()); + oldApp.use(session({ + name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies + secret: nconf.get('SESSION_SECRET'), + httpOnly: false, + maxAge: TWO_WEEKS + })); + + // Initialize Passport! Also use passport.session() middleware, to support + // persistent login sessions (recommended). + oldApp.use(passport.initialize()); + oldApp.use(passport.session()); + + var maxAge = isProd ? 31536000000 : 0; + oldApp.use(express['static'](path.join(__dirname, "/../build"), { maxAge: maxAge })); + oldApp.use('/common/dist', express['static'](publicDir + "/../../common/dist", { maxAge: maxAge })); + oldApp.use('/common/audio', express['static'](publicDir + "/../../common/audio", { maxAge: maxAge })); + oldApp.use('/common/script/public', express['static'](publicDir + "/../../common/script/public", { maxAge: maxAge })); + oldApp.use('/common/img', express['static'](publicDir + "/../../common/img", { maxAge: maxAge })); + oldApp.use(express['static'](publicDir)); + + // Custom Directives + oldApp.use('/', require('./routes/pages')); + oldApp.use('/', require('./routes/payments')); + oldApp.use('/', require('./routes/api-v2/auth')); + oldApp.use('/', require('./routes/api-v2/coupon')); + oldApp.use('/', require('./routes/api-v2/unsubscription')); + var v2 = express(); + oldApp.use('/api/v2', v2); + oldApp.use('/api/', require('./routes/api-v1')); + oldApp.use('/export', require('./routes/dataexport')); + require('./routes/api-v2/swagger')(swagger, v2); + oldApp.use(require('./middlewares/errorHandler')); + + server.on('request', app); + server.listen(app.get("port"), function() { + return logging.info("Express server listening on port " + app.get("port")); + }); + + module.exports = server; +} diff --git a/website/views/avatar-static.jade b/website/views/avatar-static.jade index 58e11ae31a..9a3532f726 100644 --- a/website/views/avatar-static.jade +++ b/website/views/avatar-static.jade @@ -9,7 +9,7 @@ html(ng-app="habitrpg") meta(name='apple-mobile-web-app-capable', content='yes') // .slice(0).push('user') is to clone the array, - // to be surethat `user` is never available to other requests' env + // to be surethat `user` is never avalaible to other requests' env // TODO does it need only `user` in clientVars, not the others? - clientVars = env.clientVars.slice(0); diff --git a/website/views/main/filters.jade b/website/views/main/filters.jade index 40e11817a8..b72d424364 100644 --- a/website/views/main/filters.jade +++ b/website/views/main/filters.jade @@ -31,7 +31,7 @@ li.filters-edit(ng-class='{active: user.filters[tag.id]}', ng-repeat='tag in user.tags', bindonce='user.tags') form.hrpg-input-group input(type='text', ng-model='tag.name', ui-keyup="{13: 'saveOrEdit()'}") - button(type='button', ng-click='User.deleteTag({params:{id:tag.id}})') + button(type='button', ng-click='user.ops.deleteTag({params:{id:tag.id}})') span.glyphicon.glyphicon-trash ul(ng-if='!_editing', hrpg-sort-tags) li.filters-tags(ng-class='{active: user.filters[tag.id], challenge: tag.challenge}', ng-repeat='tag in user.tags', bindonce='user.tags') diff --git a/website/views/options/inventory/drops.jade b/website/views/options/inventory/drops.jade index 322a5083f5..134e9ade50 100644 --- a/website/views/options/inventory/drops.jade +++ b/website/views/options/inventory/drops.jade @@ -57,7 +57,7 @@ ng-click='castStart(Content.special.#{k})') .badge.badge-info.stack-count {{user.items.special.#{k}}} +specialItem('snowball') - +specialItem('spookySparkles') + +specialItem('spookDust') +specialItem('shinySeed') +specialItem('seafoam') @@ -65,7 +65,7 @@ button.customize-option(class='inventory_present inventory_present_{{moment().format("MM")}}', popover=env.t('subscriberItemText'), popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', - ng-click="User.openMysteryItem({})") + ng-click="user.ops.openMysteryItem({})") .badge.badge-info.stack-count {{user.purchased.plan.mysteryItems.length}} div(ng-if='user.purchased.plan.consecutive.trinkets') @@ -199,7 +199,7 @@ button.customize-option(popover=env.t('subGemPop'), popover-title=env.t('subGemName'), popover-trigger='mouseenter', popover-placement='top', popover-append-to-body='true', - ng-click='User.purchase({params:{type:"gems",key:"gem"}})') + ng-click='user.ops.purchase({params:{type:"gems",key:"gem"}})') span.Pet_Currency_Gem.inline-gems .badge.badge-success.stack-count {{Shared.planGemLimits.convCap + User.user.purchased.plan.consecutive.gemCapExtra - User.user.purchased.plan.gemsBought}} p diff --git a/website/views/options/inventory/time-travelers.jade b/website/views/options/inventory/time-travelers.jade index be0c3ea7dd..66fc530bd0 100644 --- a/website/views/options/inventory/time-travelers.jade +++ b/website/views/options/inventory/time-travelers.jade @@ -35,4 +35,4 @@ popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter', popover-placement='right', popover-append-to-body='true', - ng-click='User.buyMysterySet({params:{key:set.key}})') + ng-click='user.ops.buyMysterySet({params:{key:set.key}})') diff --git a/website/views/options/profile.jade b/website/views/options/profile.jade index 036d0cf529..d9af640ae8 100644 --- a/website/views/options/profile.jade +++ b/website/views/options/profile.jade @@ -219,7 +219,7 @@ mixin profileStats input(type='radio', name='allocationMode', value='taskbased', ng-model='user.preferences.allocationMode', ng-change='set({"preferences.allocationMode": "taskbased"})') span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('taskAllocationPop'))=env.t('taskAllocation') div(ng-show='user.preferences.automaticAllocation && !(user.preferences.allocationMode === "taskbased") && (user.stats.points > 0)') - a.btn.btn-primary.btn-xs(ng-click='User.allocateNow({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('distributePointsPop')) + a.btn.btn-primary.btn-xs(ng-click='user.ops.allocateNow({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('distributePointsPop')) span.glyphicon.glyphicon-download |  =env.t('distributePoints') @@ -227,7 +227,7 @@ mixin profileStats div(ng-class='user.flags.classSelected && !user.preferences.disableClasses ? "col-md-4" : "col-md-6"') - button.btn.btn-default(ng-if='user.preferences.disableClasses', ng-click='User.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') + button.btn.btn-default(ng-if='user.preferences.disableClasses', ng-click='user.ops.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') hr(ng-if='user.preferences.disableClasses') include ../shared/profiles/achievements diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index 34c4dff0ca..c2608126b3 100644 --- a/website/views/options/settings.jade +++ b/website/views/options/settings.jade @@ -32,7 +32,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') .form-horizontal h5=env.t('language') - select.form-control(ng-model='language.code', ng-options='lang.code as lang.name for lang in availableLanguages', ng-change='changeLanguage()') + select.form-control(ng-model='language.code', ng-options='lang.code as lang.name for lang in avalaibleLanguages', ng-change='changeLanguage()') small !=env.t('americanEnglishGovern') br @@ -92,7 +92,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') button.btn.btn-default(ng-click='showBailey()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('showBaileyPop'))= env.t('showBailey') button.btn.btn-default(ng-click='openRestoreModal()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('fixValPop'))= env.t('fixVal') - button.btn.btn-default(ng-if='user.preferences.disableClasses==true', ng-click='User.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') + button.btn.btn-default(ng-if='user.preferences.disableClasses==true', ng-click='user.ops.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') hr @@ -133,11 +133,11 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') .panel-body div(ng-if='user.auth.facebook.id') button.btn.btn-primary(disabled='disabled', ng-if='!user.auth.local.username')=env.t('registeredWithFb') - button.btn.btn-danger(ng-click='http("delete", "/api/v3/user/auth/social/facebook", null, "detachedFacebook")', ng-if='user.auth.local.username')=env.t('detachFacebook') + button.btn.btn-danger(ng-click='http("delete","/api/v2/user/auth/social",null,"detachedFacebook")', ng-if='user.auth.local.username')=env.t('detachFacebook') hr div(ng-if='!user.auth.local.username') p=env.t('addLocalAuth') - form(ng-submit='http("post", "/api/v3/user/auth/local/register", localAuth, "addedLocalAuth")', ng-init='localAuth={}', name='localAuth', novalidate) + form(ng-submit='http("post","/api/v2/register",localAuth,"addedLocalAuth")', ng-init='localAuth={}', name='localAuth', novalidate) //-.alert.alert-danger(ng-messages='changeUsername.$error && changeUsername.submitted')=env.t('fillAll') .form-group input.form-control(type='text', placeholder=env.t('username'), ng-model='localAuth.username', required) @@ -175,7 +175,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') h5=env.t('changeEmail') form(ng-submit='changeUser("email", emailUpdates)', ng-show='user.auth.local', name='changeEmail', novalidate) .form-group - input.form-control(type='text', placeholder=env.t('newEmail'), ng-model='emailUpdates.newEmail', required) + input.form-control(type='text', placeholder=env.t('newEmail'), ng-model='emailUpdates.email', required) .form-group input.form-control(type='password', placeholder=env.t('password'), ng-model='emailUpdates.password', required) input.btn.btn-default(type='submit', ng-disabled='changeEmail.$invalid', value=env.t('submit')) @@ -218,7 +218,7 @@ script(type='text/ng-template', id='partials/options.settings.promo.html') input.form-control(type='number',ng-model='_codes.count',placeholder="Number of codes to generate (eg, 250)") .form-group button.btn.btn-primary(type='submit')=env.t('generate') - a.btn.btn-default(href='/api/v3/coupons?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}')=env.t('getCodes') + a.btn.btn-default(href='/api/v2/coupons?_id={{user._id}}&apiToken={{user.apiToken}}')=env.t('getCodes') script(type='text/ng-template', id='partials/options.settings.api.html') .container-fluid @@ -229,9 +229,9 @@ script(type='text/ng-template', id='partials/options.settings.api.html') h6=env.t('userId') pre.prettyprint {{user.id}} h6=env.t('APIToken') - pre.prettyprint {{User.settings.auth.apiToken}} + pre.prettyprint {{user.apiToken}} h6=env.t('qrCode') - img.img-rendering-auto(src='https://chart.googleapis.com/chart?cht=qr&chs=200x200&chl=%7B%22address%22%3A%22https%3A%2F%2Fhabitrpg.com%22%2C%22user%22%3A%22{{user.id}}%22%2C%22key%22%3A%22{{User.settings.auth.apiToken}}%22%7D&choe=UTF-8&chld=L', alt='qrcode') + img.img-rendering-auto(src='https://chart.googleapis.com/chart?cht=qr&chs=200x200&chl=%7B%22address%22%3A%22https%3A%2F%2Fhabitrpg.com%22%2C%22user%22%3A%22{{user.id}}%22%2C%22key%22%3A%22{{user.apiToken}}%22%7D&choe=UTF-8&chld=L', alt='qrcode') br h3=env.t('thirdPartyApps') ul @@ -389,7 +389,7 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') input.form-control(type='text', ng-model='_subscription.coupon', placeholder= env.t('couponPlaceholder')) .form-group button.pull-right.btn.btn-small(type='button',ng-click='applyCoupon(_subscription.coupon)')= env.t("apply") - + div(ng-if='user.purchased.plan.customerId') .btn.btn-primary(ng-if='!user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()')=env.t('subUpdateCard') .btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub') @@ -400,8 +400,9 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') .col-xs-4 a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon})', ng-disabled='!_subscription.key')= env.t('card') .col-xs-4 - a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key') + a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key') img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal')) .col-xs-4 a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon})") img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments')) + diff --git a/website/views/options/social/challenge-box.jade b/website/views/options/social/challenge-box.jade index 177e049e4d..9a7df490d8 100644 --- a/website/views/options/social/challenge-box.jade +++ b/website/views/options/social/challenge-box.jade @@ -12,7 +12,7 @@ td a(ui-sref='options.social.challenges.detail({cid:challenge._id, groupIdFilter: group._id})') markdown(text='challenge.name') - div(ng-if='!group.challenges || group.challenges.length == 0') + div(ng-if='group.challenges.length == 0') p |  =env.t('noChallenges') diff --git a/website/views/options/social/challenges.jade b/website/views/options/social/challenges.jade index ab84189f6f..3fd8dba61e 100644 --- a/website/views/options/social/challenges.jade +++ b/website/views/options/social/challenges.jade @@ -55,7 +55,7 @@ script(type='text/ng-template', id='partials/options.social.challenges.detail.ht // Member List div(bindonce='challenge', ng-if='challenge.members.length > 0') - a.btn.btn-primary.btn-sm.pull-right(ng-href='/api/v3/challenges/{{challenge._id}}/export/csv') + a.btn.btn-primary.btn-sm.pull-right(ng-href='/api/v2/challenges/{{challenge._id}}/csv') =env.t('exportChallengeCSV') h3=env.t('hows') menu @@ -145,7 +145,7 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') .row .form-group.col-md-6.col-sm-12 - input.form-control(type='text', + input.form-control(type='text', minlength="3", ng-model='newChallenge.shortName', placeholder=env.t('challengeTag'), required ng-disabled='insufficientGemsForTavernChallenge()') |  @@ -160,12 +160,12 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') .Pet_Currency_Gem1x input.form-control(type='number', placeholder=env.t('prize'), ng-disabled='insufficientGemsForTavernChallenge()' - min="{{newChallenge.group==TAVERN_ID ? 1 : 0}}", + min="{{newChallenge.group=='habitrpg' ? 1 : 0}}", max="{{maxPrize}}", ng-model='newChallenge.prize') - a.hint(popover="{{newChallenge.group==TAVERN_ID ? env.t('prizePopTavern') : env.t('prizePop')}}", + a.hint(popover="{{newChallenge.group=='habitrpg' ? env.t('prizePopTavern') : env.t('prizePop')}}", popover-trigger='mouseenter', popover-placement='right') =env.t('moreInfo') - div(ng-show='newChallenge.group==TAVERN_ID') + div(ng-show='newChallenge.group=="habitrpg"') !=env.t('publicChallenges') .form-group(ng-if='user.contributor.admin') @@ -178,7 +178,7 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') // Challenges list .panel-group - .panel.panel-default(ng-repeat='challenge in challenges | filter:filterChallenges track by challenge._id ') + .panel.panel-default(ng-repeat='challenge in challenges|filter:filterChallenges track by challenge._id ') .panel-heading ul.pull-right.challenge-accordion-header-specs li.bg-transparent(ng-if='challenge.official') @@ -199,10 +199,10 @@ script(type='text/ng-template', id='partials/options.social.challenges.html') p!=env.t('prizeValue', {gemcount: "{{challenge.prize}}", gemicon: ""}) li.bg-transparent // leave / join - a.btn.btn-sm.btn-danger(ng-show='isUserMemberOf(challenge)', ng-click='clickLeave(challenge, $event)') + a.btn.btn-sm.btn-danger(ng-show='challenge._isMember', ng-click='clickLeave(challenge, $event)') span.glyphicon.glyphicon-ban-circle =env.t('leave') - a.btn.btn-sm.btn-success(ng-hide='isUserMemberOf(challenge)', ng-click='join(challenge)') + a.btn.btn-sm.btn-success(ng-hide='challenge._isMember', ng-click='join(challenge)') span.glyphicon.glyphicon-ok =env.t('join') a.accordion-toggle(id="{{challenge._id}}" ng-click='toggle(challenge._id)') diff --git a/website/views/options/social/chat-box.jade b/website/views/options/social/chat-box.jade index 3792070706..74fc071dfe 100644 --- a/website/views/options/social/chat-box.jade +++ b/website/views/options/social/chat-box.jade @@ -8,7 +8,7 @@ div.chat-form.guidelines-not-accepted(ng-if='!user.flags.communityGuidelinesAcce form.chat-form(ng-if='user.flags.communityGuidelinesAccepted' ng-submit='postChat(group,message.content)') div(ng-controller='AutocompleteCtrl') - textarea.form-control(rows=4, ui-keydown='{"meta-enter":"postChat(group,message.content)"}', ui-keypress='{13:"postChat(group,message.content)"}', ng-model='message.content', updateinterval='250', flag='@', at-user, auto-complete placeholder="{{group._id == TAVERN_ID ? env.t('tavernCommunityGuidelinesPlaceholder') : ''}}", ng-disabled='_sending == true') + textarea.form-control(rows=4, ui-keydown='{"meta-enter":"postChat(group,message.content)"}', ui-keypress='{13:"postChat(group,message.content)"}', ng-model='message.content', updateinterval='250', flag='@', at-user, auto-complete placeholder="{{group._id == 'habitrpg' ? env.t('tavernCommunityGuidelinesPlaceholder') : ''}}", ng-disabled='_sending == true') span.user-list ul.list-at-user(ng-show="query") li(ng-repeat='msg in response | filter:filterUser | limitTo: 5', ng-click='performCompletion(msg)') diff --git a/website/views/options/social/chat-message.jade b/website/views/options/social/chat-message.jade index 07447b483c..3231726e82 100644 --- a/website/views/options/social/chat-message.jade +++ b/website/views/options/social/chat-message.jade @@ -29,7 +29,7 @@ mixin chatMessages(inbox) a(ng-click="quickReply(message.uuid)") span.glyphicon.glyphicon-share-alt(tooltip=env.t('pm-reply')) span(ng-if='#{inbox ? "true" : ":: user.contributor.admin || message.uuid == user.id"}')     - a(ng-click='#{inbox? "User.deletePM({params:{id:message.$key}})" : "deleteChatMessage(group, message)"}') + a(ng-click='#{inbox? "user.ops.deletePM({params:{id:message.$key}})" : "deleteChatMessage(group, message)"}') span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) span(ng-if=':: user.contributor.admin || (!message.sent && user.flags.communityGuidelinesAccepted && message.uuid != user.id && message.uuid != "system")')     a(ng-click="flagChatMessage(group._id, message)") diff --git a/website/views/options/social/group.jade b/website/views/options/social/group.jade index 6232990915..3630ca3d2e 100644 --- a/website/views/options/social/group.jade +++ b/website/views/options/social/group.jade @@ -18,10 +18,10 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter h3.panel-title span {{group.name}} span.group-leave-join(ng-if='group') - a.btn.btn-sm.btn-danger.pull-right(ng-if="isMemberOfGroup(User.user._id, group)", ng-hide='group._editing', ng-click='clickLeave(group, $event)') + a.btn.btn-sm.btn-danger.pull-right(ng-if=":: isMemberOfGroup(User.user._id, group)", ng-hide='group._editing', ng-click='clickLeave(group, $event)') span.glyphicon.glyphicon-ban-circle =env.t('leave') - a.btn.btn-success.pull-right(ng-if='!isMemberOfGroup(User.user._id, group)', ng-click='join(group)')=env.t('join') + a.btn.btn-success.pull-right(ng-if=':: !isMemberOfGroup(User.user._id, group)', ng-click='join(group)')=env.t('join') span(ng-if='group.leader._id == user.id') button.btn.btn-sm.btn-primary.pull-right(ng-click='cancelEdit(group)', ng-hide='!group._editing')=env.t('cancel') button.btn.btn-sm.btn-primary.pull-right(ng-click='saveEdit(group)', ng-show='group._editing')=env.t('save') @@ -60,7 +60,7 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter .text-center(ng-if='group.type === "party"') .row.row-margin: .col-sm-6.col-sm-offset-3 button.btn.btn-success.btn-block( - ng-if='!group.quest.key', + ng-if='!party.quest.key', ng-click='clickStartQuest();' )=env.t('startAQuest') diff --git a/website/views/options/social/hall.jade b/website/views/options/social/hall.jade index 381e5ec8fd..7e17980117 100644 --- a/website/views/options/social/hall.jade +++ b/website/views/options/social/hall.jade @@ -50,7 +50,7 @@ script(type='text/ng-template', id='partials/options.social.hall.heroes.html') h4 Update Item .form-group.well input.form-control(type='text',placeholder='Path (eg, items.pets.BearCub-Base)',ng-model='hero.itemPath') - small.muted Enter the item path. E.g., items.pets.BearCub-Zombie or items.gear.owned.head_special_0 or items.gear.equipped.head. See all paths here. When in doubt, ask Tyler. + small.muted Enter the item path. E.g., items.pets.BearCub-Zombie or items.gear.owned.head_special_0 or items.gear.equipped.head. See all paths here. When in doubt, ask Tyler. br input.form-control(type='text',placeholder='Value (eg, 5)',ng-model='hero.itemVal') small.muted Enter the item value. E.g., 5 or false or head_warrior_3 (respectively from above examples). diff --git a/website/views/options/social/index.jade b/website/views/options/social/index.jade index 754d3d08f6..f4c3a7043a 100644 --- a/website/views/options/social/index.jade +++ b/website/views/options/social/index.jade @@ -45,10 +45,10 @@ script(type='text/ng-template', id='partials/options.social.guilds.public.html') li='{{::group.memberCount}} ' + env.t('members') // join / leave li.bg-transparent - a.btn.btn-sm.btn-danger(ng-if="isMemberOfGroup(User.user._id, group)", ng-click='clickLeave(group, $event)') + a.btn.btn-sm.btn-danger(ng-if="::group._isMember", ng-click='clickLeave(group, $event)') span.glyphicon.glyphicon-ban-circle =env.t('leave') - a.btn.btn-sm.btn-success(ng-if="!isMemberOfGroup(User.user._id, group)", ng-click='join(group)') + a.btn.btn-sm.btn-success(ng-if="::!group._isMember", ng-click='join(group)') span.glyphicon.glyphicon-ok =env.t('join') h4: a(href='/#/options/groups/guilds/{{::group._id}}') {{::group.name}} diff --git a/website/views/options/social/party/leave-party-and-join-another.jade b/website/views/options/social/party/leave-party-and-join-another.jade index b6ff6c7862..438feb3786 100644 --- a/website/views/options/social/party/leave-party-and-join-another.jade +++ b/website/views/options/social/party/leave-party-and-join-another.jade @@ -1,5 +1,5 @@ - var newParty = 'User.user.invitations.party' -.containter-fluid(ng-if='#{newParty}.id && group._id') +.containter-fluid(ng-if='#{newParty}.id && party._id') .row.text-center .col-sm-6.col-sm-offset-3.alert.alert-warning p {{::env.t('invitedToNewParty', { partyName: #{newParty}.name })}} diff --git a/website/views/options/social/party/party-invitation.jade b/website/views/options/social/party/party-invitation.jade index 0bfd72b6d8..f4972e6e41 100644 --- a/website/views/options/social/party/party-invitation.jade +++ b/website/views/options/social/party/party-invitation.jade @@ -5,4 +5,5 @@ data-type='party', ng-click='join(user.invitations.party)' )=env.t('accept') - a.btn.btn-danger(ng-click='reject(user.invitations.party)')=env.t('reject') + a.btn.btn-danger(ng-click='reject()')=env.t('reject') + diff --git a/website/views/options/social/quests/questActive.jade b/website/views/options/social/quests/questActive.jade index b457f8d0e3..969861e8c1 100644 --- a/website/views/options/social/quests/questActive.jade +++ b/website/views/options/social/quests/questActive.jade @@ -23,7 +23,7 @@ div(ng-if='group.quest.active===true') include ./ianQuestInfo unless tavern - button.btn.btn-sm.btn-warning(ng-if='::canEditQuest()', + button.btn.btn-sm.btn-warning(ng-if='::canEditQuest(party)', ng-click='questAbort()')=env.t('abort') button.btn.btn-sm.btn-warning(ng-if='!(group.quest.leader && group.quest.leader === user._id) && isMemberOfRunningQuest(user._id,group)', ng-click='questLeave()')=env.t('leaveQuest') diff --git a/website/views/options/social/quests/questNotActive.jade b/website/views/options/social/quests/questNotActive.jade index c20ae5a226..3472011e29 100644 --- a/website/views/options/social/quests/questNotActive.jade +++ b/website/views/options/social/quests/questNotActive.jade @@ -26,6 +26,6 @@ div(ng-if='group.quest.active===false') button.btn.btn-sm.btn-success(ng-click='questAccept()')=env.t('accept') button.btn.btn-sm.btn-danger(ng-click='questReject()')=env.t('reject') - span(ng-if='::canEditQuest()') - button.btn.btn-sm.btn-warning(ng-click='questForceStart()')=env.t('begin') + span(ng-if='::canEditQuest(party)') + button.btn.btn-sm.btn-warning(ng-click='party.$startQuest({"force":true})')=env.t('begin') button.btn.btn-sm.btn-danger(ng-click='questCancel()')=env.t('cancel') diff --git a/website/views/options/social/tavern.jade b/website/views/options/social/tavern.jade index d0f16c187d..ed7e4ef6c3 100644 --- a/website/views/options/social/tavern.jade +++ b/website/views/options/social/tavern.jade @@ -16,7 +16,7 @@ .popover-content span(ng-if='!env.worldDmg.tavern') {{user.preferences.sleep ? env.t('innText',{name: user.profile.name}) : env.t('danielText')}} span(ng-if='env.worldDmg.tavern') {{user.preferences.sleep ? env.t('innTextBroken',{name: user.profile.name}) : env.t('danielTextBroken')}} - button.btn-block.btn.btn-lg.btn-success(ng-click='User.sleep({})') + button.btn-block.btn.btn-lg.btn-success(ng-click='User.user.ops.sleep({})') | {{user.preferences.sleep ? env.t('innCheckOut') : env.t('innCheckIn')}} span(ng-if='!user.preferences.sleep && !env.worldDmg.tavern')=env.t('danielText2') span(ng-if='!user.preferences.sleep && env.worldDmg.tavern')=env.t('danielText2Broken') diff --git a/website/views/shared/avatar/appearance.jade b/website/views/shared/avatar/appearance.jade index 14f69414a0..7b28273996 100644 --- a/website/views/shared/avatar/appearance.jade +++ b/website/views/shared/avatar/appearance.jade @@ -11,13 +11,13 @@ mixin avatar(opts) span(ng-if='profile.items.currentMount', class='Mount_Body_{{profile.items.currentMount}}') // Buffs that cause visual changes to avatar: Snowman, Ghost, Flower, etc - - var visualBuffs = { snowball: 'snowman', spookySparkles: 'ghost', shinySeed: 'avatar_floral_{{profile.stats.class}}', seafoam: 'seafoam_star' } + - var visualBuffs = { snowball: 'snowman', spookDust: 'spookman', shinySeed: 'avatar_floral_{{profile.stats.class}}', seafoam: 'seafoam_star' } each klass, item in visualBuffs span(ng-if='profile.stats.buffs.#{item}', class='#{klass}') // Show avatar only if not currently affected by visual buff - var buffs = '!profile.stats.buffs' - span(ng-if='#{buffs}.snowball && #{buffs}.spookySparkles && #{buffs}.shinySeed && #{buffs}.seafoam') + span(ng-if='#{buffs}.snowball && #{buffs}.spookDust && #{buffs}.shinySeed && #{buffs}.seafoam') +generatedAvatar // Mount Head diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index 0164b16783..0f5bc5c041 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -47,6 +47,8 @@ footer.footer(ng-controller='FooterCtrl') a(target='_blank', href='/static/clear-browser-data')=env.t('communityBug') li a(target='_blank', href='https://trello.com/c/odmhIqyW/440-read-first-table-of-contents')=env.t('communityFeature') + li + a(target='_blank', href='https://habitica.com/static/api')=env.t('API') li a(href='http://habitica.wikia.com/wiki/App_and_Extension_Integrations', target='_blank')=env.t('communityExtensions') li @@ -57,14 +59,6 @@ footer.footer(ng-controller='FooterCtrl') a(target='_blank', href='https://www.facebook.com/Habitica')=env.t('communityFacebook') li a(target='_blank', href='http://www.reddit.com/r/habitrpg/')=env.t('communityReddit') - h4=env.t('footerDevs') - ul.list-unstyled - li - a(target='_blank', href='http://devs.habitica.com')=env.t('devBlog') + ' - The Forge' - li - a(target='_blank', href='/apidoc')=env.t('APIv3') - li - a(target='_blank', href='/static/api-v2')=env.t('APIv2') .col-sm-3 if (env.NODE_ENV === 'production' && !env.IS_MOBILE) h4=env.t('footerSocial') @@ -85,7 +79,7 @@ footer.footer(ng-controller='FooterCtrl') tr td iframe(src='/bower_components/github-buttons/github-btn.html?user=habitrpg&repo=habitrpg&type=watch&count=true', allowtransparency='true', frameborder='0', scrolling='0', width='85px', height='20px') - if (env.NODE_ENV==='development' || env.NODE_ENV==='test') && !env.isStaticPage + else if (env.NODE_ENV==='development' || env.NODE_ENV==='test') && !env.isStaticPage h4 Debug .btn-group-vertical a.btn.btn-default(ng-click='setHealthLow()') Health = 1 @@ -97,10 +91,7 @@ footer.footer(ng-controller='FooterCtrl') a.btn.btn-default(ng-click='addMana()') +MP a.btn.btn-default(ng-click='addLevelsAndGold()') +Exp +GP +MP a.btn.btn-default(ng-click='addOneLevel()') +1 Level - a.btn.btn-default(ng-click='addQuestProgress()' tooltip="+1000 to boss quests. 300 items to collection quests") Quest Progress Up - // TODO Re-enable after v3 prod testing - // a.btn.btn-default(ng-click='makeAdmin()') Make Admin - a.btn.btn-default(ng-click='openModifyInventoryModal()') Modify Inventory + a.btn.btn-default(ng-click='addBossQuestProgressUp()') +1000 Boss Quest Progress Up div(ng-init='deferredScripts()') diff --git a/website/views/shared/header/header.jade b/website/views/shared/header/header.jade index 6535bb217f..5c0b847849 100644 --- a/website/views/shared/header/header.jade +++ b/website/views/shared/header/header.jade @@ -25,13 +25,13 @@ .meter-label(tooltip='Mana', ng-if='user.flags.classSelected && !user.preferences.disableClasses') span.glyphicon.glyphicon-fire .meter.mana(ng-if='user.flags.classSelected && !user.preferences.disableClasses', tooltip='{{Math.round(user.stats.mp * 100) / 100}}') - .bar(ng-style='{"width": (user.stats.mp / user.fns.statsComputed().maxMP * 100) + "%"}') + .bar(ng-style='{"width": (user.stats.mp / user._statsComputed.maxMP * 100) + "%"}') span.meter-text.value span - | {{Math.floor(user.stats.mp)}} / {{user.fns.statsComputed().maxMP}} + | {{Math.floor(user.stats.mp)}} / {{user._statsComputed.maxMP}} // party .party(ng-controller='PartyCtrl') - button.party-invite.btn.btn-primary(ng-click="inviteOrStartParty(party)", + button.party-invite.btn.btn-primary(ng-click="inviteOrStartParty(group)", ng-if="(!party.members || party.memberCount === 1) && user.preferences.displayInviteToPartyWhenPartyIs1", popover="{{!party.members ? env.t('startAParty') : env.t('addToParty')}}", popover-placement="left", popover-trigger="mouseenter") span=env.t("battleWithFriends") diff --git a/website/views/shared/header/menu.jade b/website/views/shared/header/menu.jade index d009ebdd4d..78f60557ca 100644 --- a/website/views/shared/header/menu.jade +++ b/website/views/shared/header/menu.jade @@ -205,7 +205,7 @@ nav.toolbar(ng-controller='MenuCtrl') span.glyphicon.glyphicon-plus-sign span=env.t('haveUnallocated', {points: '{{user.stats.points}}'}) li(ng-repeat='(k,v) in user.newMessages', ng-if='v.value') - a(ng-click='(k === party._id || k === user.party._id) ? $state.go("options.social.party") : $state.go("options.social.guilds.detail",{gid:k}); ', data-close-menu) + a(ng-click='k === party._id ? $state.go("options.social.party") : $state.go("options.social.guilds.detail",{gid:k}); ', data-close-menu) span.glyphicon.glyphicon-comment span {{v.name}} a(ng-click='clearMessages(k)', popover=env.t('clear'),popover-placement='right',popover-trigger='mouseenter',popover-append-to-body='true') diff --git a/website/views/shared/modals/buy-gems.jade b/website/views/shared/modals/buy-gems.jade index 4ebebf57ea..d071450cab 100644 --- a/website/views/shared/modals/buy-gems.jade +++ b/website/views/shared/modals/buy-gems.jade @@ -10,7 +10,7 @@ mixin buyGemsDropdown() p small.muted=env.t('paymentMethods') a.purchase.btn.btn-primary(ng-click='Payments.showStripe({})')=env.t('card') - a.purchase(href='/paypal/checkout?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}') + a.purchase(href='/paypal/checkout?_id={{user._id}}&apiToken={{user.apiToken}}') img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt='Pay now with Paypal') a.purchase(ng-click="Payments.amazonPayments.init({type: 'single'})") img(src='https://payments.amazon.com/gp/cba/button',alt='Pay now with Amazon Payments') @@ -34,7 +34,7 @@ script(id='modals/buyGems.html', type='text/ng-template') .container-fluid .row .col-md-3 - button.customize-option(ng-click='User.purchase({params:{type:"gems",key:"gem"}})') + button.customize-option(ng-click='user.ops.purchase({params:{type:"gems",key:"gem"}})') span.Pet_Currency_Gem.inline-gems .badge.badge-success.stack-count {{Shared.planGemLimits.convCap + User.user.purchased.plan.consecutive.gemCapExtra - User.user.purchased.plan.gemsBought}} p diff --git a/website/views/shared/modals/classes.jade b/website/views/shared/modals/classes.jade index 754d6f31f0..6484626318 100644 --- a/website/views/shared/modals/classes.jade +++ b/website/views/shared/modals/classes.jade @@ -68,6 +68,6 @@ script(type='text/ng-template', id='modals/chooseClass.html') .modal-footer span(popover-placement='left', popover-trigger='mouseenter', popover=env.t('optOutOfClassesText')) - button.btn.btn-danger(ng-click='User.disableClasses({}); $close()')=env.t('optOutOfClasses') + button.btn.btn-danger(ng-click='user.ops.disableClasses({}); $close()')=env.t('optOutOfClasses') button.btn.btn-primary(ng-disabled='!selectedClass' ng-click='changeClass(selectedClass); $close()')=env.t('select') .pull-left!=env.t('chooseClassLearn') diff --git a/website/views/shared/modals/death.jade b/website/views/shared/modals/death.jade index 60a92c3802..cfdbced16e 100644 --- a/website/views/shared/modals/death.jade +++ b/website/views/shared/modals/death.jade @@ -21,5 +21,5 @@ script(type='text/ng-template', id='modals/death.html') h4(style='margin-top:1.5em')=env.t('dontDespair') p(style='margin-top:1.5em')=env.t('deathPenaltyDetails') .modal-footer - a.btn.btn-danger.btn-lg.flex-column(ng-click='User.revive(); $close()')=env.t('refillHealthTryAgain') + a.btn.btn-danger.btn-lg.flex-column(ng-click='user.ops.revive({}); $close()')=env.t('refillHealthTryAgain') h4.text-center!=env.t('dyingOftenTips') diff --git a/website/views/shared/modals/index.jade b/website/views/shared/modals/index.jade index c492e09ee1..a57ee62189 100644 --- a/website/views/shared/modals/index.jade +++ b/website/views/shared/modals/index.jade @@ -19,7 +19,6 @@ include ./level-up.jade include ./hatch-pet.jade include ./raise-pet.jade include ./won-challenge.jade -include ./modify-inventory.jade //- Settings script(type='text/ng-template', id='modals/change-day-start.html') diff --git a/website/views/shared/modals/limited.jade b/website/views/shared/modals/limited.jade index 0f68a2dcad..158fd5a647 100644 --- a/website/views/shared/modals/limited.jade +++ b/website/views/shared/modals/limited.jade @@ -9,4 +9,4 @@ script(id='modals/cards.html', type='text/ng-template') markdown(text='::cardMessage') .modal-footer small.pull-left {{::env.t(cardType + 'CardExplanation')}} - button.btn.btn-default(ng-click='User.readCard({params: {cardType: cardType}}); $close()')=env.t('ok') + button.btn.btn-default(ng-click='user.ops.readCard({params: {cardType: cardType}}); $close()')=env.t('ok') diff --git a/website/views/shared/modals/members.jade b/website/views/shared/modals/members.jade index c4741156f8..b40a56eea5 100644 --- a/website/views/shared/modals/members.jade +++ b/website/views/shared/modals/members.jade @@ -33,9 +33,9 @@ script(type='text/ng-template', id='modals/member.html') include ../profiles/achievements .modal-footer .btn-group.pull-left(ng-if='::user') - button.btn.btn-md.btn-default(ng-if='user.inbox.blocks | contains:profile._id', tooltip=env.t('unblock'), ng-click="User.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') + button.btn.btn-md.btn-default(ng-if='user.inbox.blocks | contains:profile._id', tooltip=env.t('unblock'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') span.glyphicon.glyphicon-plus - button.btn.btn-md.btn-default(ng-if='profile._id != user._id && !profile.contributor.admin && !(user.inbox.blocks | contains:profile._id)', tooltip=env.t('block'), ng-click="User.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') + button.btn.btn-md.btn-default(ng-if='profile._id != user._id && !profile.contributor.admin && !(user.inbox.blocks | contains:profile._id)', tooltip=env.t('block'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') span.glyphicon.glyphicon-ban-circle button.btn.btn-md.btn-default(tooltip=env.t('sendPM'), ng-click="openModal('private-message',{controller:'MemberModalCtrl'})", tooltip-placement='right') span.glyphicon.glyphicon-envelope @@ -94,9 +94,9 @@ script(type='text/ng-template', id='modals/send-gift.html') .modal-footer - var fromBal = "gift.type=='gems' && gift.gems.fromBalance" - button.btn.btn-primary(ng-show=fromBal, ng-click='sendGift(profile._id)')=env.t("send") + button.btn.btn-primary(ng-show=fromBal, ng-click='sendGift(profile._id, gift)')=env.t("send") a.btn.btn-primary(ng-hide=fromBal, ng-click='Payments.showStripe({gift:gift, uuid:profile._id})')=env.t('card') - a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?_id={{::user._id}}&apiToken={{::User.settings.auth.apiToken}}&gift={{Payments.encodeGift(profile._id, gift)}}') PayPal + a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?_id={{::user._id}}&apiToken={{::user.apiToken}}&gift={{Payments.encodeGift(profile._id, gift)}}') PayPal .btn.btn-success(ng-hide=fromBal, ng-click="Payments.amazonPayments.init({type: 'single', gift: gift, giftedTo: profile._id})") Amazon Payments button.btn.btn-default(ng-click='$close()')=env.t('cancel') diff --git a/website/views/shared/modals/modify-inventory.jade b/website/views/shared/modals/modify-inventory.jade deleted file mode 100644 index fd7ae0c61f..0000000000 --- a/website/views/shared/modals/modify-inventory.jade +++ /dev/null @@ -1,252 +0,0 @@ -script(type='text/ng-template', id='modals/modify-inventory.html') - .modal-header - h4 Modify Inventory for {{::user.profile.name}} - .modal-body - .container-fluid - .row - .col-xs-12 - button.btn.btn-default.pull-right(ng-if="!showInv.gear", ng-click="showInv.gear = true") Show Gear - button.btn.btn-default.pull-right(ng-if="showInv.gear", ng-click="showInv.gear = false") Hide Gear - h4 Gear - div(ng-if="showInv.gear") - button.btn.btn-default(ng-click="setAllItems('gear', true)") Own All - button.btn.btn-default(ng-click="setAllItems('gear', false)") Previously Own All - button.btn.btn-default(ng-click="setAllItems('gear', undefined)") Never Own All - - hr - - ul.list-group - li.list-group-item(ng-repeat="item in Content.gear.flat" ng-init="inv.gear[item.key] = user.items.gear.owned[item.key]") - .pull-left(class="shop_{{::item.key}}" style="margin-right: 10px") - | {{::item.text()}} - - .clearfix - label.radio-inline - input(type="radio" name="gear-{{::item.key}}" ng-model="inv.gear[item.key]" ng-value="true") - | Owned - label.radio-inline - input(type="radio" name="gear-{{::item.key}}" ng-model="inv.gear[item.key]" ng-value="false") - | Previously Owned - label.radio-inline - input(type="radio" name="gear-{{::item.key}}" ng-model="inv.gear[item.key]" ng-value="undefined") - | Never Owned - - hr - - .row - .col-xs-12 - button.btn.btn-default.pull-right(ng-if="!showInv.special", ng-click="showInv.special = true") Show Special Items - button.btn.btn-default.pull-right(ng-if="showInv.special", ng-click="showInv.special = false") Hide Special Items - h4 Special Items - div(ng-if="showInv.special") - button.btn.btn-default(ng-click="setAllItems('special', 999)") Set All to 999 - button.btn.btn-default(ng-click="setAllItems('special', 0)") Set All to 0 - button.btn.btn-default(ng-click="setAllItems('special', undefined)") Set All to undefined - - hr - - ul.list-group - li.list-group-item(ng-repeat="item in Content.special" ng-init="inv.special[item.key] = user.items.special[item.key]" ng-if="item.value === 15") - .form-inline.clearfix - .pull-left(class="inventory_special_{{::item.key}}" style="margin-right: 10px") - p {{::item.text()}} - input.form-control(type="number" ng-model="inv.special[item.key]") - - hr - - .row - .col-xs-12 - button.btn.btn-default.pull-right(ng-if="!showInv.pets", ng-click="showInv.pets = true") Show Pets - button.btn.btn-default.pull-right(ng-if="showInv.pets", ng-click="showInv.pets = false") Hide Pets - h4 Pets - div(ng-if="showInv.pets") - button.btn.btn-default(ng-click="setAllItems('pets', 45)") Set All to 45 - button.btn.btn-default(ng-click="setAllItems('pets', 0)") Set All to 0 - button.btn.btn-default(ng-click="setAllItems('pets', -1)") Set All to -1 - button.btn.btn-default(ng-click="setAllItems('pets', undefined)") Set All to undefined - - hr - - h5 Drop Pets - ul.list-group - li.list-group-item(ng-repeat="(pet, value) in Content.pets" ng-init="inv.pets[pet] = user.items.pets[pet]") - .form-inline.clearfix - .pull-left(class="Pet-{{::pet}}" style="margin-right: 10px") - p {{::pet}} - input.form-control(type="number" ng-model="inv.pets[pet]") - - h5 Quest Pets - ul.list-group - li.list-group-item(ng-repeat="(pet, value) in Content.questPets" ng-init="inv.pets[pet] = user.items.pets[pet]") - .form-inline.clearfix - .pull-left(class="Pet-{{::pet}}" style="margin-right: 10px") - p {{::pet}} - input.form-control(type="number" ng-model="inv.pets[pet]") - - h5 Special Pets - ul.list-group - li.list-group-item(ng-repeat="(pet, value) in Content.specialPets" ng-init="inv.pets[pet] = user.items.pets[pet]") - .form-inline.clearfix - .pull-left(class="Pet-{{::pet}}" style="margin-right: 10px") - p {{::pet}} - input.form-control(type="number" ng-model="inv.pets[pet]") - - h5 Premium Pets - ul.list-group - li.list-group-item(ng-repeat="(pet, value) in Content.premiumPets" ng-init="inv.pets[pet] = user.items.pets[pet]") - .form-inline.clearfix - .pull-left(class="Pet-{{::pet}}" style="margin-right: 10px") - p {{::pet}} - input.form-control(type="number" ng-model="inv.pets[pet]") - - hr - - .row - .col-xs-12 - button.btn.btn-default.pull-right(ng-if="!showInv.mounts", ng-click="showInv.mounts = true") Show Mounts - button.btn.btn-default.pull-right(ng-if="showInv.mounts", ng-click="showInv.mounts = false") Hide Mounts - h4 Mounts - div(ng-if="showInv.mounts") - button.btn.btn-default(ng-click="setAllItems('mounts', true)") Set all to Owned - button.btn.btn-default(ng-click="setAllItems('mounts', undefined)") Set all to Not Owned - - hr - - h5 Drop Mounts - ul.list-group - li.list-group-item(ng-repeat="(mount, value) in Content.mounts" ng-init="inv.mounts[mount] = user.items.mounts[mount]") - .pull-left(class="Mount_Icon_{{::mount}}" style="margin-right: 10px") - | {{::mount}} - .clearfix - label.radio-inline - input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="true") - | Owned - label.radio-inline - input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="undefined") - | Not Owned - - h5 Quest Mounts - ul.list-group - li.list-group-item(ng-repeat="(mount, value) in Content.questMounts" ng-init="inv.mounts[mount] = user.items.mounts[mount]") - .pull-left(class="Mount_Icon_{{::mount}}" style="margin-right: 10px") - | {{::mount}} - .clearfix - label.radio-inline - input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="true") - | Owned - label.radio-inline - input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="undefined") - | Not Owned - - h5 Special Mounts - ul.list-group - li.list-group-item(ng-repeat="(mount, value) in Content.specialMounts" ng-init="inv.mounts[mount] = user.items.mounts[mount]") - .pull-left(class="Mount_Icon_{{::mount}}" style="margin-right: 10px") - | {{::mount}} - .clearfix - label.radio-inline - input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="true") - | Owned - label.radio-inline - input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="undefined") - | Not Owned - - h5 Premium Mounts - ul.list-group - li.list-group-item(ng-repeat="(mount, value) in Content.premiumMounts" ng-init="inv.mounts[mount] = user.items.mounts[mount]") - .pull-left(class="Mount_Icon_{{::mount}}" style="margin-right: 10px") - | {{::mount}} - .clearfix - label.radio-inline - input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="true") - | Owned - label.radio-inline - input(type="radio" name="mounts-{{::mount}}" ng-model="inv.mounts[mount]" ng-value="undefined") - | Not Owned - - hr - - .row - .col-xs-12 - button.btn.btn-default.pull-right(ng-if="!showInv.hatchingPotions", ng-click="showInv.hatchingPotions = true") Show Hatching Potions - button.btn.btn-default.pull-right(ng-if="showInv.hatchingPotions", ng-click="showInv.hatchingPotions = false") Hide Hatching Potions - h4 Hatching Potions - div(ng-if="showInv.hatchingPotions") - button.btn.btn-default(ng-click="setAllItems('hatchingPotions', 999)") Set All to 999 - button.btn.btn-default(ng-click="setAllItems('hatchingPotions', 0)") Set All to 0 - button.btn.btn-default(ng-click="setAllItems('hatchingPotions', undefined)") Set All to undefined - - hr - - ul.list-group - li.list-group-item(ng-repeat="item in Content.hatchingPotions" ng-init="inv.hatchingPotions[item.key] = user.items.hatchingPotions[item.key]") - .form-inline.clearfix - .pull-left(class="Pet_HatchingPotion_{{::item.key}}" style="margin-right: 10px") - p {{::item.text()}} - input.form-control(type="number" ng-model="inv.hatchingPotions[item.key]") - - hr - - .row - .col-xs-12 - button.btn.btn-default.pull-right(ng-if="!showInv.eggs", ng-click="showInv.eggs = true") Show Eggs - button.btn.btn-default.pull-right(ng-if="showInv.eggs", ng-click="showInv.eggs = false") Hide Eggs - h4 Eggs - div(ng-if="showInv.eggs") - button.btn.btn-default(ng-click="setAllItems('eggs', 999)") Set All to 999 - button.btn.btn-default(ng-click="setAllItems('eggs', 0)") Set All to 0 - button.btn.btn-default(ng-click="setAllItems('eggs', undefined)") Set All to undefined - - hr - - ul.list-group - li.list-group-item(ng-repeat="item in Content.eggs" ng-init="inv.eggs[item.key] = user.items.eggs[item.key]") - .form-inline.clearfix - .pull-left(class="Pet_Egg_{{::item.key}}" style="margin-right: 10px") - p {{::item.text()}} - input.form-control(type="number" ng-model="inv.eggs[item.key]") - - hr - - .row - .col-xs-12 - button.btn.btn-default.pull-right(ng-if="!showInv.food", ng-click="showInv.food = true") Show Food - button.btn.btn-default.pull-right(ng-if="showInv.food", ng-click="showInv.food = false") Hide Food - h4 Food - div(ng-if="showInv.food") - button.btn.btn-default(ng-click="setAllItems('food', 999)") Set All to 999 - button.btn.btn-default(ng-click="setAllItems('food', 0)") Set All to 0 - button.btn.btn-default(ng-click="setAllItems('food', undefined)") Set All to undefined - - hr - - ul.list-group - li.list-group-item(ng-repeat="item in Content.food" ng-init="inv.food[item.key] = user.items.food[item.key]") - .form-inline.clearfix - .pull-left(class="Pet_Food_{{::item.key}}" style="margin-right: 10px") - p {{::item.text()}} - input.form-control(type="number" ng-model="inv.food[item.key]") - - hr - - .row - .col-xs-12 - button.btn.btn-default.pull-right(ng-if="!showInv.quests", ng-click="showInv.quests = true") Show Quests - button.btn.btn-default.pull-right(ng-if="showInv.quests", ng-click="showInv.quests = false") Hide Quests - h4 Quests - div(ng-if="showInv.quests") - button.btn.btn-default(ng-click="setAllItems('quests', 999)") Set All to 999 - button.btn.btn-default(ng-click="setAllItems('quests', 0)") Set All to 0 - button.btn.btn-default(ng-click="setAllItems('quests', undefined)") Set All to undefined - - hr - - ul.list-group - li.list-group-item(ng-repeat="item in Content.quests" ng-init="inv.quests[item.key] = user.items.quests[item.key]" ng-if="item.category !== 'world'") - .form-inline.clearfix - .pull-left(class="inventory_quest_scroll_{{::item.key}}" style="margin-right: 10px") - p {{::item.text()}} - input.form-control(type="number" ng-model="inv.quests[item.key]") - .modal-footer - button.btn.btn-default(ng-click="$close()")=env.t('close') - button.btn.btn-primary(ng-click="$close();modifyInventory()") Apply Changes diff --git a/website/views/shared/modals/quests.jade b/website/views/shared/modals/quests.jade index 40efa3b5c4..a9dbf6a991 100644 --- a/website/views/shared/modals/quests.jade +++ b/website/views/shared/modals/quests.jade @@ -57,7 +57,7 @@ script(type='text/ng-template', id='modals/buyQuest.html') .modal-footer button.btn.btn-default(ng-click='closeQuest(); $close()')=env.t('neverMind') button.btn.btn-primary(ng-if='::selectedQuest.category !== "gold"', ng-click='purchase("quests", quest); closeQuest(); $close()')=env.t('buyQuest') + ': {{::selectedQuest.value}} ' + env.t('gems') - button.btn.btn-primary(ng-if='::selectedQuest.category === "gold"', ng-click='User.buyQuest({params:{key:selectedQuest.key}}); closeQuest(); $close()')=env.t('buyQuest') + ': {{::selectedQuest.goldValue}} ' + env.t('gold') + button.btn.btn-primary(ng-if='::selectedQuest.category === "gold"', ng-click='user.ops.buyQuest({params:{key:selectedQuest.key}}); closeQuest(); $close()')=env.t('buyQuest') + ': {{::selectedQuest.goldValue}} ' + env.t('gold') script(type='text/ng-template', id='modals/questInvitation.html') .modal-header @@ -104,8 +104,7 @@ script(type='text/ng-template', id='modals/questDrop.html') .quest-icon(class='inventory_quest_scroll_{{::selectedQuest.key}}') h4!=env.t('leveledUpReceivedQuest', {level:'{{user.stats.lvl}}'}) .row(style='margin-top:2em') - button.btn.btn-primary(ng-click='inviteOrStartParty(party); $close()', ng-if='!User.user.party._id')=env.t('startAParty') - button.btn.btn-primary(ng-click='inviteOrStartParty(party); $close()', ng-if='!User.user.party._id && !party.members')=env.t('battleWithFriends') + button.btn.btn-primary(ng-click='inviteOrStartParty(group); $close()', ng-if='!party.members')=env.t('startAParty') button.btn.btn-primary(ng-click='questInit(); $close()', ng-if='party.members')=env.t('inviteParty') button.btn.btn-default(ng-click='closeQuest(); $close()')=env.t('questLater') .modal-footer(style='margin-top:0', ng-init='loadWidgets()') diff --git a/website/views/shared/modals/settings.jade b/website/views/shared/modals/settings.jade index 5063173a96..8a263f2df0 100644 --- a/website/views/shared/modals/settings.jade +++ b/website/views/shared/modals/settings.jade @@ -55,11 +55,11 @@ script(type='text/ng-template', id='modals/delete.html') .modal-header h4=env.t('deleteAccount') .modal-body - p!=env.t('deleteText', {deleteWord: 'Your password'}) + p!=env.t('deleteText', {deleteWord: 'DELETE'}) br .row .col-md-6 - input.form-control(type='password', ng-model='_deleteAccount') + input.form-control(type='text', ng-model='_deleteAccount') .modal-footer button.btn.btn-default(ng-click='$close()')=env.t('neverMind') - button.btn.btn-danger(ng-disabled='!_deleteAccount', ng-click='$close(); delete(_deleteAccount)')=env.t('deleteDo') + button.btn.btn-danger(ng-disabled='_deleteAccount != "DELETE"', ng-click='$close(); delete()')=env.t('deleteDo') diff --git a/website/views/shared/new-stuff.jade b/website/views/shared/new-stuff.jade index c59174b701..dfd4cbfcaf 100644 --- a/website/views/shared/new-stuff.jade +++ b/website/views/shared/new-stuff.jade @@ -1,44 +1,26 @@ -h2 5/21/2016 - WELCOME BACK, HABITICA! +h2 5/19/2016 - IMPORTANT: UPCOMING MAINTENANCE! hr tr td - h3 Welcome Back, Everyone! - p Hurrah! After many hours of toil, our valiant blacksmiths were able to complete our planned maintenance ahead of schedule. The site should be working normally again! If you notice any issues or have any questions, please feel free to email us at admin@habitica.com and we will be happy to help. - tr - td - h3 Important Mobile App Updates - p We’ve released an iOS update and an Android update that contain the new code. It’s very important to download these updates immediately, or you may encounter significant bugs! - tr - td - .Pet-Wolf-Veteran.pull-right - h3 Veteran Pets - p To thank you for your patience during the maintenance, we have awarded everyone a special Veteran Pet! You can see it under Inventory > Pets, at the bottom of the screen. If it hasn’t appeared yet, never fear: because there are so many Habiticans, it can sometimes take an hour or two for everyone to receive their pet. You will have it soon! Thanks again for bearing with us during the downtime. - tr - td - h3 Daily Safe Mode - p To protect the accounts of Habiticans in different time zones across the world, we enabled Cron Daily Safe Mode during the maintenance, which will prevent you from taking any damage or losing any streaks for the rest of the weekend. Let us know at admin@habitica.com if you have any questions or concerns! + h3 Maintenance to Take Place May 21 + p This Saturday, we will be performing important maintenance on Habitica to build out the groundwork for some exciting upcoming features! We'll be doing everything we can to make this as smooth as possible, but unfortunately, there will be significant downtime for much of the day. + br + p.strong We expect that on Saturday, May 21st, Habitica will be unavailable between 1 PM and 10 PM Pacific Time (8 pm - 5 am UTC). + ul + li Don't worry, you will NOT lose any streaks or take any damage during this weekend, not even from Bosses! This maintenance will not harm your accounts. + li If you will need to see your task list on Saturday, we recommend taking a screenshot of your tasks before the maintenance begins so that you can use them as a reference during downtime. + li At the end of the maintenance, to thank people for their patience, everyone will receive a rare Veteran pet! + li This maintenance should not result in any major visible differences to the site; it's all behind-the-scenes work. However, at the end of it, we will release new updates to the mobile apps, which will be required in order for the apps to work properly with the new changes! Be sure to download those updates on Saturday as soon as they are released. + li For more information, please check out our detailed info page about the maintenance! And if you have any further questions or concerns, feel free to reach out to Leslie (leslie@habitica.com), and she will be happy to help you. + p We understand that it's very frustrating to have Habitica unavailable for such a long part of the day. Rest assured that we'll be doing everything we can to make the maintenance go as quickly as possible, but with over a million Habitican accounts to migrate, this is a hefty task! During the maintenance on Saturday we will be posting regular status reports on our Twitter account, so you can follow us for the most accurate updates. + br + p Thank you for your patience, and for using Habitica! if menuItem !== 'oldNews' hr a(href='/static/old-news', target='_blank') Read older news mixin oldNews - h2 5/19/2016 - IMPORTANT: UPCOMING MAINTENANCE! - tr - td - h3 Maintenance to Take Place May 21 - p This Saturday, we will be performing important maintenance on Habitica to build out the groundwork for some exciting upcoming features! We'll be doing everything we can to make this as smooth as possible, but unfortunately, there will be significant downtime for much of the day. - br - p.strong We expect that on Saturday, May 21st, Habitica will be unavailable between 1 PM and 10 PM Pacific Time (8 pm - 5 am UTC). - ul - li Don't worry, you will NOT lose any streaks or take any damage during this weekend, not even from Bosses! This maintenance will not harm your accounts. - li If you will need to see your task list on Saturday, we recommend taking a screenshot of your tasks before the maintenance begins so that you can use them as a reference during downtime. - li At the end of the maintenance, to thank people for their patience, everyone will receive a rare Veteran pet! - li This maintenance should not result in any major visible differences to the site; it's all behind-the-scenes work. However, at the end of it, we will release new updates to the mobile apps, which will be required in order for the apps to work properly with the new changes! Be sure to download those updates on Saturday as soon as they are released. - li For more information, please check out our detailed info page about the maintenance! And if you have any further questions or concerns, feel free to reach out to Leslie (leslie@habitica.com), and she will be happy to help you. - p We understand that it's very frustrating to have Habitica unavailable for such a long part of the day. Rest assured that we'll be doing everything we can to make the maintenance go as quickly as possible, but with over a million Habitican accounts to migrate, this is a hefty task! During the maintenance on Saturday we will be posting regular status reports on our Twitter account, so you can follow us for the most accurate updates. - br - p Thank you for your patience, and for using Habitica! h2 5/17/2016 - TREELING PET QUEST AND CHALLENGE SPOTLIGHT! tr td @@ -343,7 +325,7 @@ mixin oldNews tr td .promo_spring_classes_2016.pull-right - h3 Limited Edition Class Outfits + h3 Limited Edition Class Outfits p From now until April 30th, limited edition outfits are available in the Rewards column! Depending on your class, you can be a Springing Bunny, Clever Dog, Grand Malkin, or Brave Mouse. You'd better get productive to earn enough Gold before your time runs out... p.small.muted by PainterProphet and Balduranne tr @@ -897,7 +879,7 @@ mixin oldNews p Exciting news - for the next three weeks, we are offering Habitica T-shirts via Teespring! Show your Habitica pride in purple or black. We are also offering an EU run for cheaper shipping to Europe! br p Whether you're getting them for yourself or as a holiday gift, we hope you enjoy these limited-run T-shirts! As always, thanks for supporting Habitica. - + h2 11/5/2015 - HUGE IOS UPDATE AND ANDROID MAILING LIST tr td @@ -927,7 +909,7 @@ mixin oldNews tr td h3 Android Mailing List - p For those of you anxious for news about the new native Android app, we've created a mailing list so that you can be notified about important updates for the Android app. You can sign up here! + p For those of you anxious for news about the new native Android app, we've created a mailing list so that you can be notified about important updates for the Android app. You can sign up here! br p Our staff has been working very hard on it and testing out a new build each week, so progress is definitely advancing. When the beta is ready we will announce it on social media and on the site, but the mailing list is the easiest way to make sure you don't miss it! Thanks very much for your patience. h2 11/3/2015 - NOVEMBER BACKGROUNDS AND ARMOIRE ITEMS, AND AUTO-EQUIP NEW GEAR @@ -988,14 +970,14 @@ mixin oldNews td .promo_mystery_201510.pull-right h3 Last Chance for Horned Goblin Set - p Reminder: this is the final day to subscribe and receive the Horned Goblin Item Set! If you want the Goblin Horns or the Goblin Tail, now's the time! + p Reminder: this is the final day to subscribe and receive the Horned Goblin Item Set! If you want the Goblin Horns or the Goblin Tail, now's the time! br - p Thanks so much for your supporting the site -- you're helping us keep Habitica alive. + p Thanks so much for your supporting the site -- you're helping us keep Habitica alive. tr td .npc_justin.pull-right h3 Happy Habitoween! - p Burnout is nearly defeated, so what could be a better way to speed the celebration than to have some fun? In honor of Habitoween and defiance of the looming threat, all of the remaining NPCs have dressed up as monsters from the Flourishing Fields! Be sure to visit them on the site to admire their outfits. If only the three Exhaust Spirits could join them... + p Burnout is nearly defeated, so what could be a better way to speed the celebration than to have some fun? In honor of Habitoween and defiance of the looming threat, all of the remaining NPCs have dressed up as monsters from the Flourishing Fields! Be sure to visit them on the site to admire their outfits. If only the three Exhaust Spirits could join them... h2 10/27/2015 - BURNOUT STRIKES AGAIN! PLUS, SPOOKY POTIONS VANISHING SOON tr @@ -2699,9 +2681,9 @@ mixin oldNews td h3 Spooky Sparkles .pull-right - .inventory_special_spookySparkles - .achievement-spookySparkles - .ghost + .inventory_special_spookDust + .achievement-spookDust + .spookman p There's a new gold-purchasable item in the Market: Spooky Sparkles! Buy some and then cast it on your friends. I wonder what it will do? br p If you have Spooky Sparkles cast on you, you will receive the "Alarming Friends" badge! Don't worry, any mysterious effects will wear off the next day.... or you can cancel them early by buying an Opaque Potion! diff --git a/website/views/shared/profiles/achievements.jade b/website/views/shared/profiles/achievements.jade index cb75e0db32..1b3659a863 100644 --- a/website/views/shared/profiles/achievements.jade +++ b/website/views/shared/profiles/achievements.jade @@ -183,11 +183,11 @@ div(ng-if='::profile.achievements.snowball') =env.t('annoyingFriendsText', {snowballs: "{{::profile.achievements.snowball}}"}) hr -div(ng-if='::profile.achievements.spookySparkles') - .achievement.achievement-spookySparkles +div(ng-if='::profile.achievements.spookDust') + .achievement.achievement-spookDust h5=env.t('alarmingFriends') small - =env.t('alarmingFriendsText', {spookySparkles: "{{::profile.achievements.spookySparkles}}"}) + =env.t('alarmingFriendsText', {spookDust: "{{::profile.achievements.spookDust}}"}) hr div(ng-if='::profile.achievements.shinySeed') diff --git a/website/views/shared/profiles/stats/attributes.jade b/website/views/shared/profiles/stats/attributes.jade index bf5b1dbcba..0d222a42b4 100644 --- a/website/views/shared/profiles/stats/attributes.jade +++ b/website/views/shared/profiles/stats/attributes.jade @@ -7,7 +7,7 @@ table.table.table-striped span.hint(popover-title=env.t(statInfo.title), popover-placement='right', popover=env.t(statInfo.popover), popover-trigger='mouseenter') strong=env.t(statInfo.title) - strong : {{profile.fns.statsComputed().#{stat}}} + strong : {{profile._statsComputed.#{stat}}} td: ul.list-unstyled +statList('statCalc.levelBonus(profile.stats.lvl)', 'levelBonus', 'level', true) diff --git a/website/views/shared/tasks/edit/habits/plus_minus.jade b/website/views/shared/tasks/edit/habits/plus_minus.jade index 0a67ff9001..bbe17b7d4e 100644 --- a/website/views/shared/tasks/edit/habits/plus_minus.jade +++ b/website/views/shared/tasks/edit/habits/plus_minus.jade @@ -1,8 +1,8 @@ fieldset.option-group.plusminus(ng-if='task.type=="habit" && !task.challenge.id') legend.option-title=env.t('direction/Actions') span.task-checker - input.visuallyhidden.focusable(id='{{obj._id}}_{{task._id}}-option-plus', type='checkbox', ng-model='task.up') - label(for='{{obj._id}}_{{task._id}}-option-plus') + input.visuallyhidden.focusable(id='{{obj._id}}_{{task.id}}-option-plus', type='checkbox', ng-model='task.up') + label(for='{{obj._id}}_{{task.id}}-option-plus') span.task-checker - input.visuallyhidden.focusable(id='{{obj._id}}_{{task._id}}-option-minus', type='checkbox', ng-model='task.down') - label(for='{{obj._id}}_{{task._id}}-option-minus') + input.visuallyhidden.focusable(id='{{obj._id}}_{{task.id}}-option-minus', type='checkbox', ng-model='task.down') + label(for='{{obj._id}}_{{task.id}}-option-minus') diff --git a/website/views/shared/tasks/edit/index.jade b/website/views/shared/tasks/edit/index.jade index d7bc217598..ce29ee8896 100644 --- a/website/views/shared/tasks/edit/index.jade +++ b/website/views/shared/tasks/edit/index.jade @@ -3,12 +3,12 @@ div(ng-if='task._editing') // Broken Challenge .well(ng-if='task.challenge.broken') - div(ng-if='task.challenge.broken=="TASK_DELETED" || task.challenge.broken=="CHALLENGE_TASK_NOT_FOUND') + div(ng-if='task.challenge.broken=="TASK_DELETED"') p=env.t('brokenTask') p a(ng-click='unlink(task, "keep")')=env.t('keepIt') |    - a(ng-click="removeTask(task, obj)")=env.t('removeIt') + a(ng-click="removeTask(task, obj[list.type+'s'])")=env.t('removeIt') div(ng-if='task.challenge.broken=="CHALLENGE_DELETED"') p |  diff --git a/website/views/shared/tasks/edit/tags.jade b/website/views/shared/tasks/edit/tags.jade index 8abcedc355..089aa0c408 100644 --- a/website/views/shared/tasks/edit/tags.jade +++ b/website/views/shared/tasks/edit/tags.jade @@ -1,5 +1,5 @@ fieldset.option-group(ng-if='!$state.includes("options.social.challenges")') p.option-title.mega(ng-class='{active: task._tags}', ng-click='task._tags = !task._tags', tooltip=env.t('expandCollapse'))=env.t('tags') label.checkbox(ng-repeat='tag in user.tags', ng-if='task._tags') - input(type='checkbox', ng-checked="task.tags.indexOf(tag.id) !== -1", ng-click="updateTaskTags(tag.id, task)") + input(type='checkbox', ng-model='task.tags[tag.id]') markdown(text='tag.name') diff --git a/website/views/shared/tasks/index.jade b/website/views/shared/tasks/index.jade index f128b0b322..fc3223b888 100644 --- a/website/views/shared/tasks/index.jade +++ b/website/views/shared/tasks/index.jade @@ -23,7 +23,7 @@ script(id='templates/habitrpg-tasks.html', type="text/ng-template") i.glyphicon.glyphicon-warning-sign   =env.t('dailiesRestingInInn') - button.btn-block.btn.btn-lg.btn-success(ng-click='User.sleep({})') + button.btn-block.btn.btn-lg.btn-success(ng-click='User.user.ops.sleep({})') | {{env.t('innCheckOut')}} +taskColumnTabs('top') diff --git a/website/views/shared/tasks/meta_controls.jade b/website/views/shared/tasks/meta_controls.jade index 3feaef0d87..733b37d23a 100644 --- a/website/views/shared/tasks/meta_controls.jade +++ b/website/views/shared/tasks/meta_controls.jade @@ -23,15 +23,15 @@ |{{checklistCompletion(task.checklist)}}/{{task.checklist.length}} span.glyphicon.glyphicon-tags(tooltip='{{Shared.appliedTags(user.tags, task.tags)}}', ng-hide='Shared.noTags(task.tags)') // edit - a(ng-hide='task._editing', ng-click='editTask(task, user)', tooltip=env.t('edit')) + a(ng-hide='task._editing', ng-click='editTask(task)', tooltip=env.t('edit')) |   span.glyphicon.glyphicon-pencil(ng-hide='task._editing') |   - a(ng-hide='!task._editing', ng-click='editTask(task, user)', tooltip=env.t('cancel')) + a(ng-hide='!task._editing', ng-click='editTask(task)', tooltip=env.t('cancel')) span.glyphicon.glyphicon-remove(ng-hide='!task._editing') |   // save - a(ng-hide='!task._editing', ng-click='editTask(task, user);saveTask(task)', tooltip=env.t('save')) + a(ng-hide='!task._editing', ng-click='editTask(task);saveTask(task)', tooltip=env.t('save')) span.glyphicon.glyphicon-ok(ng-hide='!task._editing') |   //challenges @@ -43,12 +43,12 @@ span.glyphicon.glyphicon-bullhorn(tooltip=env.t('challenge')) |   // delete - a(ng-if='!task.challenge.id || obj.leader._id === User.user._id', ng-click='removeTask(task, obj)', tooltip=env.t('delete')) + a(ng-if='!task.challenge.id', ng-click='removeTask(task, obj[list.type+"s"])', tooltip=env.t('delete')) span.glyphicon.glyphicon-trash |   // chart - a(ng-show='task.history', ng-click='toggleChart(obj._id+task._id, task)', tooltip=env.t('progress')) + a(ng-show='task.history', ng-click='toggleChart(obj._id+task.id, task)', tooltip=env.t('progress')) span.glyphicon.glyphicon-signal |   // notes diff --git a/website/views/shared/tasks/task.jade b/website/views/shared/tasks/task.jade index ad8ee4ffe5..3a69e1f760 100644 --- a/website/views/shared/tasks/task.jade +++ b/website/views/shared/tasks/task.jade @@ -1,4 +1,4 @@ -li(id='task-{{::task._id}}', +li(id='task-{{::task.id}}', ng-repeat='task in obj[list.type+"s"] | filterByTaskInfo: obj.filterQuery | conditionalOrderBy: list.view=="dated":"date"', class='task {{Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main)}}', ng-class='{"cast-target":spell && (list.type != "reward"), "locked-task":obj._locked === true}', @@ -14,4 +14,4 @@ li(id='task-{{::task._id}}', include ./edit/index - div(class='{{obj._id}}{{task._id}}-chart', ng-show='charts[obj._id+task._id]') + div(class='{{obj._id}}{{task.id}}-chart', ng-show='charts[obj._id+task.id]') diff --git a/website/views/shared/tasks/task_view/add_new.jade b/website/views/shared/tasks/task_view/add_new.jade index 2d4fd1c869..f9baf76499 100644 --- a/website/views/shared/tasks/task_view/add_new.jade +++ b/website/views/shared/tasks/task_view/add_new.jade @@ -1,4 +1,4 @@ -form.task-add(name='new{{list.type}}form', ng-hide='obj._locked', ng-submit='addTask(obj[list.type+"s"], list, obj)', novalidate) +form.task-add(name='new{{list.type}}form', ng-hide='obj._locked', ng-submit='addTask(obj[list.type+"s"],list)', novalidate) textarea(rows='6', focus-element='list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolderBulk}}', ng-if='list.bulk', ui-keydown='{"meta-enter ctrl-enter":"addTask(obj[list.type+\'s\'],list)"}', required) input(type='text', focus-element='!list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolder}}', ng-if='!list.bulk', required) button(type='submit', ng-disabled='new{{list.type}}form.$invalid') diff --git a/website/views/shared/tasks/task_view/graph.jade b/website/views/shared/tasks/task_view/graph.jade index be2fba13a8..82f062dfde 100644 --- a/website/views/shared/tasks/task_view/graph.jade +++ b/website/views/shared/tasks/task_view/graph.jade @@ -1,7 +1,7 @@ span.option-box.pull-right(ng-if='::main') a.option-action(ng-if='list.type=="todo"', ng-show='obj.history.todos', ng-click='toggleChart("todos")', tooltip=env.t('progress'), style='margin-right:5px;') span.glyphicon.glyphicon-signal - //a.option-action(ng-href='/v1/users/{{user.id}}/calendar.ics?apiToken={{User.settings.auth.apiToken}}', tooltip='iCal') + //a.option-action(ng-href='/v1/users/{{user.id}}/calendar.ics?apiToken={{user.apiToken}}', tooltip='iCal') //-a.option-action(ng-if='list.type=="todo"', ng-click='notPorted()', tooltip='iCal', ng-show='false') span.glyphicon.glyphicon-calendar // diff --git a/website/views/shared/tasks/task_view/index.jade b/website/views/shared/tasks/task_view/index.jade index 16628bfa5d..1d151ebd07 100644 --- a/website/views/shared/tasks/task_view/index.jade +++ b/website/views/shared/tasks/task_view/index.jade @@ -28,13 +28,13 @@ // Daily & Todos span.task-checker.action-yesno(ng-if='::task.type=="daily" || task.type=="todo"') - input.task-input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task._id}}', type='checkbox', + input.task-input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task.id}}', type='checkbox', ng-model='task.completed', ng-if='$state.includes("tasks")', - ng-change='changeCheck(task)' + ng-change='task.type=="todo" && pushTask(task,$index,"bottom"); changeCheck(task)' ui-keypress='{13:"task.completed = !task.completed; changeCheck(task)"}' ) - input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task._id}}', type='checkbox', + input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task.id}}', type='checkbox', ng-if='!$state.includes("tasks")') - label(for='box-{{::obj._id}}_{{::task._id}}') + label(for='box-{{::obj._id}}_{{::task.id}}') // main content .task-text(ng-dblclick='task._editing ? saveTask(task) : editTask(task)') diff --git a/website/views/shared/tasks/task_view/mixins.jade b/website/views/shared/tasks/task_view/mixins.jade index 2c71074d71..398813d5ad 100644 --- a/website/views/shared/tasks/task_view/mixins.jade +++ b/website/views/shared/tasks/task_view/mixins.jade @@ -24,7 +24,7 @@ mixin taskColumnTabs(position) div(ng-show='list.view == "complete"') .alert =env.t('lotOfToDos') - button.task-action-btn.tile.spacious.bright(ng-click='User.clearCompleted({})',popover=env.t('deleteToDosExplanation'),popover-trigger='mouseenter')=env.t('clearCompleted') + button.task-action-btn.tile.spacious.bright(ng-click='user.ops.clearCompleted({})',popover=env.t('deleteToDosExplanation'),popover-trigger='mouseenter')=env.t('clearCompleted') // remaining/completed tabs ul.task-filter li(ng-class='{active: list.view == "remaining"}') @@ -32,7 +32,7 @@ mixin taskColumnTabs(position) li(ng-class='{active: list.view == "dated"}') a(ng-click='list.view = "dated"')=env.t('dated') li(ng-class='{active: list.view == "complete"}') - a(ng-click='list.view = "complete";loadedCompletedTodos()')=env.t('complete') + a(ng-click='list.view = "complete"')=env.t('complete') // Rewards Tabs div(ng-if='::main && list.type=="reward"', class='tabbable tabs-below') ul.task-filter diff --git a/website/views/shared/tasks/task_view/skills.jade b/website/views/shared/tasks/task_view/skills.jade index dccd6d1233..da8cabef47 100644 --- a/website/views/shared/tasks/task_view/skills.jade +++ b/website/views/shared/tasks/task_view/skills.jade @@ -1,5 +1,5 @@ // Events -- var seasonalSkills = {'snowball':'salt', 'spookySparkles':'opaquePotion', 'shinySeed':'petalFreePotion', 'seafoam':'sand'} +- var seasonalSkills = {'snowball':'salt', 'spookDust':'opaquePotion', 'shinySeed':'petalFreePotion', 'seafoam':'sand'} ul.items.rewards each dispel,skill in seasonalSkills span(ng-if='main && list.type=="reward" && (user.items.special.#{skill}>0 || user.stats.buffs.#{skill})') diff --git a/website/views/static/api-v2.jade b/website/views/static/api.jade similarity index 91% rename from website/views/static/api-v2.jade rename to website/views/static/api.jade index c6e5c034f0..8c7ebcce8a 100644 --- a/website/views/static/api-v2.jade +++ b/website/views/static/api.jade @@ -75,10 +75,6 @@ html //.input a#explore(href='#') Explore br - h2 API v3 - p This page contains documentation for version 2 of Habitica's API. A new API version, the third, has been released and its documentation can be found here and an introductory blog post with the most important changes here. - p API v2 is still available to give time to developers to port their apps and integration to the new API but it's considered deprecated and should not be used for new projects. It'll be completely retired shortly. - br h2 Two API Types p Habitica's API is meant for two different audiences: (1) extensions and scripts, and (2) full-fledged applications. Extensions and scripts can utilize Habitica's up/down scoring for individual tasks. An example of this in action is the Chrome Extension, which up-scores you for visiting productive websites, and down-scores you for visiting procrastination websites. Other examples currently in use are Pomodoro, Anki, and Github scripts - which up-score you for good behavior and downscore you for bad behavior - see the list. The second API consumer is for full-fledge applications, which need read / write access to the entire user document. An example of this would be Mobile Apps or Desktop application. h2 Extensions / Scripts @@ -97,7 +93,7 @@ html p All API requests should be prefaced by https://habitica.com. Every authenticated request should include two headers. Your api key (x-api-key) and your user id (x-api-user). Do not include {} braces in your header (-H 'x-api-user: a94b6d9d-6b64-43ae-856c-2c3f211bd426') h2 Requirements: p The base-url for all routes is /api/v2. So /user actions will be at https://habitica.com/api/v2/*. You need to send x-api-user and x-api-key headers for each request. - p For create & edit paths (PUT & POST), you'll need to know the schema of the object you're trying to create or edit. See Schema definitions here + p For create & edit paths (PUT & POST), you'll need to know the schema of the object you're trying to create or edit. See Schema definitions here p If any of the documentation is lacking or you're having trouble with it, please post an issue to Github #message-bar.swagger-ui-wrap #swagger-ui-container.swagger-ui-wrap diff --git a/website/views/static/front.jade b/website/views/static/front.jade index 3a3a06f37f..a77527c549 100644 --- a/website/views/static/front.jade +++ b/website/views/static/front.jade @@ -34,7 +34,6 @@ html(ng-app='habitrpg', ng-controller='RootCtrl') script(type='text/javascript'). window.env = !{JSON.stringify(env._.pick(env, env.clientVars))}; - != env.getManifestFiles("tmp_static_front") script(type='text/javascript', src='https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/js/bootstrap.min.js') diff --git a/website/views/static/maintenance-info.jade b/website/views/static/maintenance-info.jade index 241aafde3a..deba47297e 100644 --- a/website/views/static/maintenance-info.jade +++ b/website/views/static/maintenance-info.jade @@ -1,4 +1,4 @@ -- var t = env ? env.t : translation; +- var t = t || env.t; title Habitica |  =t('maintenance') diff --git a/website/views/static/maintenance.jade b/website/views/static/maintenance.jade deleted file mode 100644 index 0537913219..0000000000 --- a/website/views/static/maintenance.jade +++ /dev/null @@ -1,18 +0,0 @@ -- var t = env ? env.t : translation; - -title Habitica |  - =t('maintenance') - -head - link(rel='stylesheet', type='text/css', href='https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/css/bootstrap.min.css') - -body.text-center - h1=t('habiticaBackSoon') - img.img-rendering-auto.center-block.img-responsive(src='https://d2afqr2xdmyzvu.cloudfront.net/assets/scene_maintenance.png') - p!=t('importantMaintenance') - p!=t('twitterMaintenanceUpdates') - ul.lead(style='list-style-position:inside') - li=t('noDamageKeepStreaks') - li=t('veteranPetAward') - p!=t('maintenanceMoreInfo', {linkStart: '', linkEnd: '',}) - p.lead=t('thanksForPatience')